1 /* 2 * Copyright (c) 2014, 2019, Oracle and/or its affiliates. All rights reserved. 3 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. 4 * 5 * This code is free software; you can redistribute it and/or modify it 6 * under the terms of the GNU General Public License version 2 only, as 7 * published by the Free Software Foundation. Oracle designates this 8 * particular file as subject to the "Classpath" exception as provided 9 * by Oracle in the LICENSE file that accompanied this code. 10 * 11 * This code is distributed in the hope that it will be useful, but WITHOUT 12 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 13 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 14 * version 2 for more details (a copy is included in the LICENSE file that 15 * accompanied this code). 16 * 17 * You should have received a copy of the GNU General Public License version 18 * 2 along with this work; if not, write to the Free Software Foundation, 19 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. 20 * 21 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA 22 * or visit www.oracle.com if you need additional information or have any 23 * questions. 24 */ 25 26 package jdk.jpackage.internal; 27 28 import java.io.BufferedWriter; 29 import java.io.File; 30 import java.io.FileWriter; 31 import java.io.IOException; 32 import java.io.PrintStream; 33 import java.io.PrintWriter; 34 import java.io.Writer; 35 import java.net.URLEncoder; 36 import java.nio.file.Files; 37 import java.nio.file.Path; 38 import java.text.MessageFormat; 39 import java.util.ArrayList; 40 import java.util.Arrays; 41 import java.util.Collection; 42 import java.util.HashMap; 43 import java.util.LinkedHashSet; 44 import java.util.List; 45 import java.util.Map; 46 import java.util.Optional; 47 import java.util.ResourceBundle; 48 49 import static jdk.jpackage.internal.StandardBundlerParam.*; 50 import static jdk.jpackage.internal.MacBaseInstallerBundler.SIGNING_KEYCHAIN; 51 import static jdk.jpackage.internal.MacBaseInstallerBundler.SIGNING_KEY_USER; 52 53 public class MacPkgBundler extends MacBaseInstallerBundler { 54 55 private static final ResourceBundle I18N = ResourceBundle.getBundle( 56 "jdk.jpackage.internal.resources.MacResources"); 57 58 private static final String DEFAULT_BACKGROUND_IMAGE = "background_pkg.png"; 59 60 private static final String TEMPLATE_PREINSTALL_SCRIPT = 61 "preinstall.template"; 62 private static final String TEMPLATE_POSTINSTALL_SCRIPT = 63 "postinstall.template"; 64 65 private static final BundlerParamInfo<File> PACKAGES_ROOT = 66 new StandardBundlerParam<>( 67 "mac.pkg.packagesRoot", 68 File.class, 69 params -> { 70 File packagesRoot = 71 new File(TEMP_ROOT.fetchFrom(params), "packages"); 72 packagesRoot.mkdirs(); 73 return packagesRoot; 74 }, 75 (s, p) -> new File(s)); 76 77 78 protected final BundlerParamInfo<File> SCRIPTS_DIR = 79 new StandardBundlerParam<>( 80 "mac.pkg.scriptsDir", 81 File.class, 82 params -> { 83 File scriptsDir = 84 new File(CONFIG_ROOT.fetchFrom(params), "scripts"); 85 scriptsDir.mkdirs(); 86 return scriptsDir; 87 }, 88 (s, p) -> new File(s)); 89 90 public static final 91 BundlerParamInfo<String> DEVELOPER_ID_INSTALLER_SIGNING_KEY = 92 new StandardBundlerParam<>( 93 "mac.signing-key-developer-id-installer", 94 String.class, 95 params -> { 96 String result = MacBaseInstallerBundler.findKey( 97 "Developer ID Installer: " 98 + SIGNING_KEY_USER.fetchFrom(params), 99 SIGNING_KEYCHAIN.fetchFrom(params), 100 VERBOSE.fetchFrom(params)); 101 if (result != null) { 102 MacCertificate certificate = new MacCertificate( 103 result, VERBOSE.fetchFrom(params)); 104 105 if (!certificate.isValid()) { 106 Log.error(MessageFormat.format( 107 I18N.getString("error.certificate.expired"), 108 result)); 109 } 110 } 111 112 return result; 113 }, 114 (s, p) -> s); 115 116 public static final BundlerParamInfo<String> MAC_INSTALL_DIR = 117 new StandardBundlerParam<>( 118 "mac-install-dir", 119 String.class, 120 params -> { 121 String dir = INSTALL_DIR.fetchFrom(params); 122 return (dir != null) ? dir : "/Applications"; 123 }, 124 (s, p) -> s 125 ); 126 127 public static final BundlerParamInfo<String> INSTALLER_SUFFIX = 128 new StandardBundlerParam<> ( 129 "mac.pkg.installerName.suffix", 130 String.class, 131 params -> "", 132 (s, p) -> s); 133 134 public File bundle(Map<String, ? super Object> params, 135 File outdir) throws PackagerException { 136 Log.verbose(MessageFormat.format(I18N.getString("message.building-pkg"), 137 APP_NAME.fetchFrom(params))); 138 if (!outdir.isDirectory() && !outdir.mkdirs()) { 139 throw new PackagerException( 140 "error.cannot-create-output-dir", 141 outdir.getAbsolutePath()); 142 } 143 if (!outdir.canWrite()) { 144 throw new PackagerException( 145 "error.cannot-write-to-output-dir", 146 outdir.getAbsolutePath()); 147 } 148 149 File appImageDir = null; 150 try { 151 appImageDir = prepareAppBundle(params, false); 152 153 if (appImageDir != null && prepareConfigFiles(params)) { 154 155 File configScript = getConfig_Script(params); 156 if (configScript.exists()) { 157 Log.verbose(MessageFormat.format(I18N.getString( 158 "message.running-script"), 159 configScript.getAbsolutePath())); 160 IOUtils.run("bash", configScript, false); 161 } 162 163 return createPKG(params, outdir, appImageDir); 164 } 165 return null; 166 } catch (IOException ex) { 167 Log.verbose(ex); 168 throw new PackagerException(ex); 169 } 170 } 171 172 private File getPackages_AppPackage(Map<String, ? super Object> params) { 173 return new File(PACKAGES_ROOT.fetchFrom(params), 174 APP_NAME.fetchFrom(params) + "-app.pkg"); 175 } 176 177 private File getPackages_DaemonPackage(Map<String, ? super Object> params) { 178 return new File(PACKAGES_ROOT.fetchFrom(params), 179 APP_NAME.fetchFrom(params) + "-daemon.pkg"); 180 } 181 182 private File getConfig_DistributionXMLFile( 183 Map<String, ? super Object> params) { 184 return new File(CONFIG_ROOT.fetchFrom(params), "distribution.dist"); 185 } 186 187 private File getConfig_BackgroundImage(Map<String, ? super Object> params) { 188 return new File(CONFIG_ROOT.fetchFrom(params), 189 APP_NAME.fetchFrom(params) + "-background.png"); 190 } 191 192 private File getScripts_PreinstallFile(Map<String, ? super Object> params) { 193 return new File(SCRIPTS_DIR.fetchFrom(params), "preinstall"); 194 } 195 196 private File getScripts_PostinstallFile( 197 Map<String, ? super Object> params) { 198 return new File(SCRIPTS_DIR.fetchFrom(params), "postinstall"); 199 } 200 201 private String getAppIdentifier(Map<String, ? super Object> params) { 202 return IDENTIFIER.fetchFrom(params); 203 } 204 205 private String getDaemonIdentifier(Map<String, ? super Object> params) { 206 return IDENTIFIER.fetchFrom(params) + ".daemon"; 207 } 208 209 private void preparePackageScripts(Map<String, ? super Object> params) 210 throws IOException { 211 Log.verbose(I18N.getString("message.preparing-scripts")); 212 213 Map<String, String> data = new HashMap<>(); 214 215 data.put("INSTALL_LOCATION", MAC_INSTALL_DIR.fetchFrom(params)); 216 217 Writer w = new BufferedWriter( 218 new FileWriter(getScripts_PreinstallFile(params))); 219 String content = preprocessTextResource( 220 getScripts_PreinstallFile(params).getName(), 221 I18N.getString("resource.pkg-preinstall-script"), 222 TEMPLATE_PREINSTALL_SCRIPT, 223 data, 224 VERBOSE.fetchFrom(params), 225 RESOURCE_DIR.fetchFrom(params)); 226 w.write(content); 227 w.close(); 228 getScripts_PreinstallFile(params).setExecutable(true, false); 229 230 w = new BufferedWriter( 231 new FileWriter(getScripts_PostinstallFile(params))); 232 content = preprocessTextResource( 233 getScripts_PostinstallFile(params).getName(), 234 I18N.getString("resource.pkg-postinstall-script"), 235 TEMPLATE_POSTINSTALL_SCRIPT, 236 data, 237 VERBOSE.fetchFrom(params), 238 RESOURCE_DIR.fetchFrom(params)); 239 w.write(content); 240 w.close(); 241 getScripts_PostinstallFile(params).setExecutable(true, false); 242 } 243 244 private void prepareDistributionXMLFile(Map<String, ? super Object> params) 245 throws IOException { 246 File f = getConfig_DistributionXMLFile(params); 247 248 Log.verbose(MessageFormat.format(I18N.getString( 249 "message.preparing-distribution-dist"), f.getAbsolutePath())); 250 251 PrintStream out = new PrintStream(f); 252 253 out.println( 254 "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"no\"?>"); 255 out.println("<installer-gui-script minSpecVersion=\"1\">"); 256 257 out.println("<title>" + APP_NAME.fetchFrom(params) + "</title>"); 258 out.println("<background" + " file=\"" 259 + getConfig_BackgroundImage(params).getName() 260 + "\"" 261 + " mime-type=\"image/png\"" 262 + " alignment=\"bottomleft\" " 263 + " scaling=\"none\"" 264 + "/>"); 265 266 String licFileStr = LICENSE_FILE.fetchFrom(params); 267 if (licFileStr != null) { 268 File licFile = new File(licFileStr); 269 out.println("<license" 270 + " file=\"" + licFile.getAbsolutePath() + "\"" 271 + " mime-type=\"text/rtf\"" 272 + "/>"); 273 } 274 275 /* 276 * Note that the content of the distribution file 277 * below is generated by productbuild --synthesize 278 */ 279 280 String appId = getAppIdentifier(params); 281 282 out.println("<pkg-ref id=\"" + appId + "\"/>"); 283 284 out.println("<options customize=\"never\" require-scripts=\"false\"/>"); 285 out.println("<choices-outline>"); 286 out.println(" <line choice=\"default\">"); 287 out.println(" <line choice=\"" + appId + "\"/>"); 288 out.println(" </line>"); 289 out.println("</choices-outline>"); 290 out.println("<choice id=\"default\"/>"); 291 out.println("<choice id=\"" + appId + "\" visible=\"false\">"); 292 out.println(" <pkg-ref id=\"" + appId + "\"/>"); 293 out.println("</choice>"); 294 out.println("<pkg-ref id=\"" + appId + "\" version=\"" 295 + VERSION.fetchFrom(params) + "\" onConclusion=\"none\">" 296 + URLEncoder.encode(getPackages_AppPackage(params).getName(), 297 "UTF-8") + "</pkg-ref>"); 298 299 out.println("</installer-gui-script>"); 300 301 out.close(); 302 } 303 304 private boolean prepareConfigFiles(Map<String, ? super Object> params) 305 throws IOException { 306 File imageTarget = getConfig_BackgroundImage(params); 307 fetchResource(imageTarget.getName(), 308 I18N.getString("resource.pkg-background-image"), 309 DEFAULT_BACKGROUND_IMAGE, 310 imageTarget, 311 VERBOSE.fetchFrom(params), 312 RESOURCE_DIR.fetchFrom(params)); 313 314 prepareDistributionXMLFile(params); 315 316 fetchResource(getConfig_Script(params).getName(), 317 I18N.getString("resource.post-install-script"), 318 (String) null, 319 getConfig_Script(params), 320 VERBOSE.fetchFrom(params), 321 RESOURCE_DIR.fetchFrom(params)); 322 323 return true; 324 } 325 326 // name of post-image script 327 private File getConfig_Script(Map<String, ? super Object> params) { 328 return new File(CONFIG_ROOT.fetchFrom(params), 329 APP_NAME.fetchFrom(params) + "-post-image.sh"); 330 } 331 332 private void patchCPLFile(File cpl) throws IOException { 333 String cplData = Files.readString(cpl.toPath()); 334 String[] lines = cplData.split("\n"); 335 try (PrintWriter out = new PrintWriter(new BufferedWriter( 336 new FileWriter(cpl)))) { 337 boolean skip = false; // Used to skip Java.runtime bundle, since 338 // pkgbuild with --root will find two bundles app and Java runtime. 339 // We cannot generate component proprty list when using 340 // --component argument. 341 for (int i = 0; i < lines.length; i++) { 342 if (lines[i].trim().equals("<key>BundleIsRelocatable</key>")) { 343 out.println(lines[i]); 344 out.println("<false/>"); 345 i++; 346 } else if (lines[i].trim().equals("<key>ChildBundles</key>")) { 347 skip = true; 348 } else if (skip && lines[i].trim().equals("</array>")) { 349 skip = false; 350 } else { 351 if (!skip) { 352 out.println(lines[i]); 353 } 354 } 355 } 356 } 357 } 358 359 // pkgbuild includes all components from "--root" and subfolders, 360 // so if we have app image in folder which contains other images, then they 361 // will be included as well. It does have "--filter" option which use regex 362 // to exclude files/folder, but it will overwrite default one which excludes 363 // based on doc "any .svn or CVS directories, and any .DS_Store files". 364 // So easy aproach will be to copy user provided app-image into temp folder 365 // if root path contains other files. 366 private String getRoot(Map<String, ? super Object> params, 367 File appLocation) throws IOException { 368 String root = appLocation.getParent() == null ? 369 "." : appLocation.getParent(); 370 File rootDir = new File(root); 371 File[] list = rootDir.listFiles(); 372 if (list != null) { // Should not happend 373 // We should only have app image and/or .DS_Store 374 if (list.length == 1) { 375 return root; 376 } else if (list.length == 2) { 377 // Check case with app image and .DS_Store 378 if (list[0].toString().toLowerCase().endsWith(".ds_store") || 379 list[1].toString().toLowerCase().endsWith(".ds_store")) { 380 return root; // Only app image and .DS_Store 381 } 382 } 383 } 384 385 // Copy to new root 386 Path newRoot = Files.createTempDirectory( 387 TEMP_ROOT.fetchFrom(params).toPath(), 388 "root-"); 389 390 IOUtils.copyRecursive(appLocation.toPath(), 391 newRoot.resolve(appLocation.getName())); 392 393 return newRoot.toString(); 394 } 395 396 private File createPKG(Map<String, ? super Object> params, 397 File outdir, File appLocation) { 398 // generic find attempt 399 try { 400 File appPKG = getPackages_AppPackage(params); 401 402 String root = getRoot(params, appLocation); 403 404 // Generate default CPL file 405 File cpl = new File(CONFIG_ROOT.fetchFrom(params).getAbsolutePath() 406 + File.separator + "cpl.plist"); 407 ProcessBuilder pb = new ProcessBuilder("pkgbuild", 408 "--root", 409 root, 410 "--install-location", 411 MAC_INSTALL_DIR.fetchFrom(params), 412 "--analyze", 413 cpl.getAbsolutePath()); 414 415 IOUtils.exec(pb, false); 416 417 patchCPLFile(cpl); 418 419 preparePackageScripts(params); 420 421 // build application package 422 pb = new ProcessBuilder("pkgbuild", 423 "--root", 424 root, 425 "--install-location", 426 MAC_INSTALL_DIR.fetchFrom(params), 427 "--component-plist", 428 cpl.getAbsolutePath(), 429 "--scripts", 430 SCRIPTS_DIR.fetchFrom(params).getAbsolutePath(), 431 appPKG.getAbsolutePath()); 432 IOUtils.exec(pb, false); 433 434 // build final package 435 File finalPKG = new File(outdir, INSTALLER_NAME.fetchFrom(params) 436 + INSTALLER_SUFFIX.fetchFrom(params) 437 + ".pkg"); 438 outdir.mkdirs(); 439 440 List<String> commandLine = new ArrayList<>(); 441 commandLine.add("productbuild"); 442 443 commandLine.add("--resources"); 444 commandLine.add(CONFIG_ROOT.fetchFrom(params).getAbsolutePath()); 445 446 // maybe sign 447 if (Optional.ofNullable(MacAppImageBuilder. 448 SIGN_BUNDLE.fetchFrom(params)).orElse(Boolean.TRUE)) { 449 if (Platform.getMajorVersion() > 10 || 450 (Platform.getMajorVersion() == 10 && 451 Platform.getMinorVersion() >= 12)) { 452 // we need this for OS X 10.12+ 453 Log.verbose(I18N.getString("message.signing.pkg")); 454 } 455 456 String signingIdentity = 457 DEVELOPER_ID_INSTALLER_SIGNING_KEY.fetchFrom(params); 458 if (signingIdentity != null) { 459 commandLine.add("--sign"); 460 commandLine.add(signingIdentity); 461 } 462 463 String keychainName = SIGNING_KEYCHAIN.fetchFrom(params); 464 if (keychainName != null && !keychainName.isEmpty()) { 465 commandLine.add("--keychain"); 466 commandLine.add(keychainName); 467 } 468 } 469 470 commandLine.add("--distribution"); 471 commandLine.add( 472 getConfig_DistributionXMLFile(params).getAbsolutePath()); 473 commandLine.add("--package-path"); 474 commandLine.add(PACKAGES_ROOT.fetchFrom(params).getAbsolutePath()); 475 476 commandLine.add(finalPKG.getAbsolutePath()); 477 478 pb = new ProcessBuilder(commandLine); 479 IOUtils.exec(pb, false); 480 481 return finalPKG; 482 } catch (Exception ignored) { 483 Log.verbose(ignored); 484 return null; 485 } 486 } 487 488 ////////////////////////////////////////////////////////////////////////// 489 // Implement Bundler 490 ////////////////////////////////////////////////////////////////////////// 491 492 @Override 493 public String getName() { 494 return I18N.getString("pkg.bundler.name"); 495 } 496 497 @Override 498 public String getDescription() { 499 return I18N.getString("pkg.bundler.description"); 500 } 501 502 @Override 503 public String getID() { 504 return "pkg"; 505 } 506 507 @Override 508 public Collection<BundlerParamInfo<?>> getBundleParameters() { 509 Collection<BundlerParamInfo<?>> results = new LinkedHashSet<>(); 510 results.addAll(MacAppBundler.getAppBundleParameters()); 511 results.addAll(getPKGBundleParameters()); 512 return results; 513 } 514 515 public Collection<BundlerParamInfo<?>> getPKGBundleParameters() { 516 Collection<BundlerParamInfo<?>> results = new LinkedHashSet<>(); 517 518 results.addAll(MacAppBundler.getAppBundleParameters()); 519 results.addAll(Arrays.asList( 520 DEVELOPER_ID_INSTALLER_SIGNING_KEY, 521 // IDENTIFIER, 522 INSTALLER_SUFFIX, 523 LICENSE_FILE, 524 // SERVICE_HINT, 525 SIGNING_KEYCHAIN)); 526 527 return results; 528 } 529 530 @Override 531 public boolean validate(Map<String, ? super Object> params) 532 throws UnsupportedPlatformException, ConfigException { 533 try { 534 if (params == null) throw new ConfigException( 535 I18N.getString("error.parameters-null"), 536 I18N.getString("error.parameters-null.advice")); 537 538 // run basic validation to ensure requirements are met 539 // we are not interested in return code, only possible exception 540 validateAppImageAndBundeler(params); 541 542 // reject explicitly set sign to true and no valid signature key 543 if (Optional.ofNullable(MacAppImageBuilder. 544 SIGN_BUNDLE.fetchFrom(params)).orElse(Boolean.FALSE)) { 545 String signingIdentity = 546 DEVELOPER_ID_INSTALLER_SIGNING_KEY.fetchFrom(params); 547 if (signingIdentity == null) { 548 throw new ConfigException( 549 I18N.getString("error.explicit-sign-no-cert"), 550 I18N.getString( 551 "error.explicit-sign-no-cert.advice")); 552 } 553 } 554 555 // hdiutil is always available so there's no need 556 // to test for availability. 557 558 return true; 559 } catch (RuntimeException re) { 560 if (re.getCause() instanceof ConfigException) { 561 throw (ConfigException) re.getCause(); 562 } else { 563 throw new ConfigException(re); 564 } 565 } 566 } 567 568 @Override 569 public File execute(Map<String, ? super Object> params, 570 File outputParentDir) throws PackagerException { 571 return bundle(params, outputParentDir); 572 } 573 574 @Override 575 public boolean supported(boolean runtimeInstaller) { 576 return Platform.getPlatform() == Platform.MAC; 577 } 578 579 }