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_dmg.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 139 IOUtils.writableOutputDir(outdir.toPath()); 140 141 try { 142 File appImageDir = prepareAppBundle(params); 143 144 if (appImageDir != null && prepareConfigFiles(params)) { 145 146 File configScript = getConfig_Script(params); 147 if (configScript.exists()) { 148 Log.verbose(MessageFormat.format(I18N.getString( 149 "message.running-script"), 150 configScript.getAbsolutePath())); 151 IOUtils.run("bash", configScript); 152 } 153 154 return createPKG(params, outdir, appImageDir); 155 } 156 return null; 157 } catch (IOException ex) { 158 Log.verbose(ex); 159 throw new PackagerException(ex); 160 } 161 } 162 163 private File getPackages_AppPackage(Map<String, ? super Object> params) { 164 return new File(PACKAGES_ROOT.fetchFrom(params), 165 APP_NAME.fetchFrom(params) + "-app.pkg"); 166 } 167 168 private File getPackages_DaemonPackage(Map<String, ? super Object> params) { 169 return new File(PACKAGES_ROOT.fetchFrom(params), 170 APP_NAME.fetchFrom(params) + "-daemon.pkg"); 171 } 172 173 private File getConfig_DistributionXMLFile( 174 Map<String, ? super Object> params) { 175 return new File(CONFIG_ROOT.fetchFrom(params), "distribution.dist"); 176 } 177 178 private File getConfig_BackgroundImage(Map<String, ? super Object> params) { 179 return new File(CONFIG_ROOT.fetchFrom(params), 180 APP_NAME.fetchFrom(params) + "-background.png"); 181 } 182 183 private File getScripts_PreinstallFile(Map<String, ? super Object> params) { 184 return new File(SCRIPTS_DIR.fetchFrom(params), "preinstall"); 185 } 186 187 private File getScripts_PostinstallFile( 188 Map<String, ? super Object> params) { 189 return new File(SCRIPTS_DIR.fetchFrom(params), "postinstall"); 190 } 191 192 private String getAppIdentifier(Map<String, ? super Object> params) { 193 return IDENTIFIER.fetchFrom(params); 194 } 195 196 private String getDaemonIdentifier(Map<String, ? super Object> params) { 197 return IDENTIFIER.fetchFrom(params) + ".daemon"; 198 } 199 200 private void preparePackageScripts(Map<String, ? super Object> params) 201 throws IOException { 202 Log.verbose(I18N.getString("message.preparing-scripts")); 203 204 Map<String, String> data = new HashMap<>(); 205 206 data.put("INSTALL_LOCATION", MAC_INSTALL_DIR.fetchFrom(params)); 207 208 try (Writer w = Files.newBufferedWriter( 209 getScripts_PreinstallFile(params).toPath())) { 210 String content = preprocessTextResource( 211 getScripts_PreinstallFile(params).getName(), 212 I18N.getString("resource.pkg-preinstall-script"), 213 TEMPLATE_PREINSTALL_SCRIPT, 214 data, 215 VERBOSE.fetchFrom(params), 216 RESOURCE_DIR.fetchFrom(params)); 217 w.write(content); 218 } 219 getScripts_PreinstallFile(params).setExecutable(true, false); 220 221 try (Writer w = Files.newBufferedWriter( 222 getScripts_PostinstallFile(params).toPath())) { 223 String content = preprocessTextResource( 224 getScripts_PostinstallFile(params).getName(), 225 I18N.getString("resource.pkg-postinstall-script"), 226 TEMPLATE_POSTINSTALL_SCRIPT, 227 data, 228 VERBOSE.fetchFrom(params), 229 RESOURCE_DIR.fetchFrom(params)); 230 w.write(content); 231 } 232 getScripts_PostinstallFile(params).setExecutable(true, false); 233 } 234 235 private void prepareDistributionXMLFile(Map<String, ? super Object> params) 236 throws IOException { 237 File f = getConfig_DistributionXMLFile(params); 238 239 Log.verbose(MessageFormat.format(I18N.getString( 240 "message.preparing-distribution-dist"), f.getAbsolutePath())); 241 242 try (PrintStream out = new PrintStream(f)) { 243 244 out.println( 245 "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"no\"?>"); 246 out.println("<installer-gui-script minSpecVersion=\"1\">"); 247 248 out.println("<title>" + APP_NAME.fetchFrom(params) + "</title>"); 249 out.println("<background" + " file=\"" 250 + getConfig_BackgroundImage(params).getName() 251 + "\"" 252 + " mime-type=\"image/png\"" 253 + " alignment=\"bottomleft\" " 254 + " scaling=\"none\"" 255 + "/>"); 256 257 String licFileStr = LICENSE_FILE.fetchFrom(params); 258 if (licFileStr != null) { 259 File licFile = new File(licFileStr); 260 out.println("<license" 261 + " file=\"" + licFile.getAbsolutePath() + "\"" 262 + " mime-type=\"text/rtf\"" 263 + "/>"); 264 } 265 266 /* 267 * Note that the content of the distribution file 268 * below is generated by productbuild --synthesize 269 */ 270 271 String appId = getAppIdentifier(params); 272 273 out.println("<pkg-ref id=\"" + appId + "\"/>"); 274 out.println( 275 "<options customize=\"never\" require-scripts=\"false\"/>"); 276 out.println("<choices-outline>"); 277 out.println(" <line choice=\"default\">"); 278 out.println(" <line choice=\"" + appId + "\"/>"); 279 out.println(" </line>"); 280 out.println("</choices-outline>"); 281 out.println("<choice id=\"default\"/>"); 282 out.println("<choice id=\"" + appId + "\" visible=\"false\">"); 283 out.println(" <pkg-ref id=\"" + appId + "\"/>"); 284 out.println("</choice>"); 285 out.println("<pkg-ref id=\"" + appId + "\" version=\"" 286 + VERSION.fetchFrom(params) + "\" onConclusion=\"none\">" 287 + URLEncoder.encode( 288 getPackages_AppPackage(params).getName(), 289 "UTF-8") + "</pkg-ref>"); 290 291 out.println("</installer-gui-script>"); 292 293 } 294 } 295 296 private boolean prepareConfigFiles(Map<String, ? super Object> params) 297 throws IOException { 298 File imageTarget = getConfig_BackgroundImage(params); 299 fetchResource(imageTarget.getName(), 300 I18N.getString("resource.pkg-background-image"), 301 DEFAULT_BACKGROUND_IMAGE, 302 imageTarget, 303 VERBOSE.fetchFrom(params), 304 RESOURCE_DIR.fetchFrom(params)); 305 306 prepareDistributionXMLFile(params); 307 308 fetchResource(getConfig_Script(params).getName(), 309 I18N.getString("resource.post-install-script"), 310 (String) null, 311 getConfig_Script(params), 312 VERBOSE.fetchFrom(params), 313 RESOURCE_DIR.fetchFrom(params)); 314 315 return true; 316 } 317 318 // name of post-image script 319 private File getConfig_Script(Map<String, ? super Object> params) { 320 return new File(CONFIG_ROOT.fetchFrom(params), 321 APP_NAME.fetchFrom(params) + "-post-image.sh"); 322 } 323 324 private void patchCPLFile(File cpl) throws IOException { 325 String cplData = Files.readString(cpl.toPath()); 326 String[] lines = cplData.split("\n"); 327 try (PrintWriter out = new PrintWriter(Files.newBufferedWriter( 328 cpl.toPath()))) { 329 int skip = 0; 330 // Used to skip Java.runtime bundle, since 331 // pkgbuild with --root will find two bundles app and Java runtime. 332 // We cannot generate component proprty list when using 333 // --component argument. 334 for (int i = 0; i < lines.length; i++) { 335 if (lines[i].trim().equals("<key>BundleIsRelocatable</key>")) { 336 out.println(lines[i]); 337 out.println("<false/>"); 338 i++; 339 } else if (lines[i].trim().equals("<key>ChildBundles</key>")) { 340 ++skip; 341 } else if ((skip > 0) && lines[i].trim().equals("</array>")) { 342 --skip; 343 } else { 344 if (skip == 0) { 345 out.println(lines[i]); 346 } 347 } 348 } 349 } 350 } 351 352 // pkgbuild includes all components from "--root" and subfolders, 353 // so if we have app image in folder which contains other images, then they 354 // will be included as well. It does have "--filter" option which use regex 355 // to exclude files/folder, but it will overwrite default one which excludes 356 // based on doc "any .svn or CVS directories, and any .DS_Store files". 357 // So easy aproach will be to copy user provided app-image into temp folder 358 // if root path contains other files. 359 private String getRoot(Map<String, ? super Object> params, 360 File appLocation) throws IOException { 361 String root = appLocation.getParent() == null ? 362 "." : appLocation.getParent(); 363 File rootDir = new File(root); 364 File[] list = rootDir.listFiles(); 365 if (list != null) { // Should not happend 366 // We should only have app image and/or .DS_Store 367 if (list.length == 1) { 368 return root; 369 } else if (list.length == 2) { 370 // Check case with app image and .DS_Store 371 if (list[0].toString().toLowerCase().endsWith(".ds_store") || 372 list[1].toString().toLowerCase().endsWith(".ds_store")) { 373 return root; // Only app image and .DS_Store 374 } 375 } 376 } 377 378 // Copy to new root 379 Path newRoot = Files.createTempDirectory( 380 TEMP_ROOT.fetchFrom(params).toPath(), 381 "root-"); 382 383 IOUtils.copyRecursive(appLocation.toPath(), 384 newRoot.resolve(appLocation.getName())); 385 386 return newRoot.toString(); 387 } 388 389 private File createPKG(Map<String, ? super Object> params, 390 File outdir, File appLocation) { 391 // generic find attempt 392 try { 393 File appPKG = getPackages_AppPackage(params); 394 395 String root = getRoot(params, appLocation); 396 397 // Generate default CPL file 398 File cpl = new File(CONFIG_ROOT.fetchFrom(params).getAbsolutePath() 399 + File.separator + "cpl.plist"); 400 ProcessBuilder pb = new ProcessBuilder("pkgbuild", 401 "--root", 402 root, 403 "--install-location", 404 MAC_INSTALL_DIR.fetchFrom(params), 405 "--analyze", 406 cpl.getAbsolutePath()); 407 408 IOUtils.exec(pb); 409 410 patchCPLFile(cpl); 411 412 preparePackageScripts(params); 413 414 // build application package 415 pb = new ProcessBuilder("pkgbuild", 416 "--root", 417 root, 418 "--install-location", 419 MAC_INSTALL_DIR.fetchFrom(params), 420 "--component-plist", 421 cpl.getAbsolutePath(), 422 "--scripts", 423 SCRIPTS_DIR.fetchFrom(params).getAbsolutePath(), 424 appPKG.getAbsolutePath()); 425 IOUtils.exec(pb); 426 427 // build final package 428 File finalPKG = new File(outdir, INSTALLER_NAME.fetchFrom(params) 429 + INSTALLER_SUFFIX.fetchFrom(params) 430 + ".pkg"); 431 outdir.mkdirs(); 432 433 List<String> commandLine = new ArrayList<>(); 434 commandLine.add("productbuild"); 435 436 commandLine.add("--resources"); 437 commandLine.add(CONFIG_ROOT.fetchFrom(params).getAbsolutePath()); 438 439 // maybe sign 440 if (Optional.ofNullable(MacAppImageBuilder. 441 SIGN_BUNDLE.fetchFrom(params)).orElse(Boolean.TRUE)) { 442 if (Platform.getMajorVersion() > 10 || 443 (Platform.getMajorVersion() == 10 && 444 Platform.getMinorVersion() >= 12)) { 445 // we need this for OS X 10.12+ 446 Log.verbose(I18N.getString("message.signing.pkg")); 447 } 448 449 String signingIdentity = 450 DEVELOPER_ID_INSTALLER_SIGNING_KEY.fetchFrom(params); 451 if (signingIdentity != null) { 452 commandLine.add("--sign"); 453 commandLine.add(signingIdentity); 454 } 455 456 String keychainName = SIGNING_KEYCHAIN.fetchFrom(params); 457 if (keychainName != null && !keychainName.isEmpty()) { 458 commandLine.add("--keychain"); 459 commandLine.add(keychainName); 460 } 461 } 462 463 commandLine.add("--distribution"); 464 commandLine.add( 465 getConfig_DistributionXMLFile(params).getAbsolutePath()); 466 commandLine.add("--package-path"); 467 commandLine.add(PACKAGES_ROOT.fetchFrom(params).getAbsolutePath()); 468 469 commandLine.add(finalPKG.getAbsolutePath()); 470 471 pb = new ProcessBuilder(commandLine); 472 IOUtils.exec(pb); 473 474 return finalPKG; 475 } catch (Exception ignored) { 476 Log.verbose(ignored); 477 return null; 478 } 479 } 480 481 ////////////////////////////////////////////////////////////////////////// 482 // Implement Bundler 483 ////////////////////////////////////////////////////////////////////////// 484 485 @Override 486 public String getName() { 487 return I18N.getString("pkg.bundler.name"); 488 } 489 490 @Override 491 public String getID() { 492 return "pkg"; 493 } 494 495 @Override 496 public boolean validate(Map<String, ? super Object> params) 497 throws ConfigException { 498 try { 499 if (params == null) throw new ConfigException( 500 I18N.getString("error.parameters-null"), 501 I18N.getString("error.parameters-null.advice")); 502 503 // run basic validation to ensure requirements are met 504 // we are not interested in return code, only possible exception 505 validateAppImageAndBundeler(params); 506 507 // reject explicitly set sign to true and no valid signature key 508 if (Optional.ofNullable(MacAppImageBuilder. 509 SIGN_BUNDLE.fetchFrom(params)).orElse(Boolean.FALSE)) { 510 String signingIdentity = 511 DEVELOPER_ID_INSTALLER_SIGNING_KEY.fetchFrom(params); 512 if (signingIdentity == null) { 513 throw new ConfigException( 514 I18N.getString("error.explicit-sign-no-cert"), 515 I18N.getString( 516 "error.explicit-sign-no-cert.advice")); 517 } 518 } 519 520 // hdiutil is always available so there's no need 521 // to test for availability. 522 523 return true; 524 } catch (RuntimeException re) { 525 if (re.getCause() instanceof ConfigException) { 526 throw (ConfigException) re.getCause(); 527 } else { 528 throw new ConfigException(re); 529 } 530 } 531 } 532 533 @Override 534 public File execute(Map<String, ? super Object> params, 535 File outputParentDir) throws PackagerException { 536 return bundle(params, outputParentDir); 537 } 538 539 @Override 540 public boolean supported(boolean runtimeInstaller) { 541 return true; 542 } 543 544 @Override 545 public boolean isDefault() { 546 return false; 547 } 548 549 }