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.text.MessageFormat; 38 import java.util.ArrayList; 39 import java.util.Arrays; 40 import java.util.Collection; 41 import java.util.HashMap; 42 import java.util.LinkedHashSet; 43 import java.util.List; 44 import java.util.Map; 45 import java.util.Optional; 46 import java.util.ResourceBundle; 47 48 import static jdk.jpackage.internal.StandardBundlerParam.*; 49 import static jdk.jpackage.internal.MacBaseInstallerBundler.SIGNING_KEYCHAIN; 50 import static jdk.jpackage.internal.MacBaseInstallerBundler.SIGNING_KEY_USER; 51 52 public class MacPkgBundler extends MacBaseInstallerBundler { 53 54 private static final ResourceBundle I18N = ResourceBundle.getBundle( 55 "jdk.jpackage.internal.resources.MacResources"); 56 57 private static final String DEFAULT_BACKGROUND_IMAGE = "background_pkg.png"; 58 59 private static final String TEMPLATE_PREINSTALL_SCRIPT = 60 "preinstall.template"; 61 private static final String TEMPLATE_POSTINSTALL_SCRIPT = 62 "postinstall.template"; 63 64 private static final BundlerParamInfo<File> PACKAGES_ROOT = 65 new StandardBundlerParam<>( 66 I18N.getString("param.packages-root.name"), 67 I18N.getString("param.packages-root.description"), 68 "mac.pkg.packagesRoot", 69 File.class, 70 params -> { 71 File packagesRoot = 72 new File(BUILD_ROOT.fetchFrom(params), "packages"); 73 packagesRoot.mkdirs(); 74 return packagesRoot; 75 }, 76 (s, p) -> new File(s)); 77 78 79 protected final BundlerParamInfo<File> SCRIPTS_DIR = 80 new StandardBundlerParam<>( 81 I18N.getString("param.scripts-dir.name"), 82 I18N.getString("param.scripts-dir.description"), 83 "mac.pkg.scriptsDir", 84 File.class, 85 params -> { 86 File scriptsDir = 87 new File(CONFIG_ROOT.fetchFrom(params), "scripts"); 88 scriptsDir.mkdirs(); 89 return scriptsDir; 90 }, 91 (s, p) -> new File(s)); 92 93 public static final 94 BundlerParamInfo<String> DEVELOPER_ID_INSTALLER_SIGNING_KEY = 95 new StandardBundlerParam<>( 96 I18N.getString("param.signing-key-developer-id-installer.name"), 97 I18N.getString( 98 "param.signing-key-developer-id-installer.description"), 99 "mac.signing-key-developer-id-installer", 100 String.class, 101 params -> { 102 String result = MacBaseInstallerBundler.findKey( 103 "Developer ID Installer: " 104 + SIGNING_KEY_USER.fetchFrom(params), 105 SIGNING_KEYCHAIN.fetchFrom(params), 106 VERBOSE.fetchFrom(params)); 107 if (result != null) { 108 MacCertificate certificate = new MacCertificate( 109 result, VERBOSE.fetchFrom(params)); 110 111 if (!certificate.isValid()) { 112 Log.error(MessageFormat.format( 113 I18N.getString("error.certificate.expired"), 114 result)); 115 } 116 } 117 118 return result; 119 }, 120 (s, p) -> s); 121 122 public static final BundlerParamInfo<String> MAC_INSTALL_DIR = 123 new StandardBundlerParam<>( 124 I18N.getString("param.mac-install-dir.name"), 125 I18N.getString("param.mac-install-dir.description"), 126 "mac-install-dir", 127 String.class, 128 params -> { 129 String dir = INSTALL_DIR.fetchFrom(params); 130 return (dir != null) ? dir : "/Applications"; 131 }, 132 (s, p) -> s 133 ); 134 135 public static final BundlerParamInfo<String> INSTALLER_SUFFIX = 136 new StandardBundlerParam<> ( 137 I18N.getString("param.installer-suffix.name"), 138 I18N.getString("param.installer-suffix.description"), 139 "mac.pkg.installerName.suffix", 140 String.class, 141 params -> "", 142 (s, p) -> s); 143 144 public File bundle(Map<String, ? super Object> params, 145 File outdir) throws PackagerException { 146 Log.verbose(MessageFormat.format(I18N.getString("message.building-pkg"), 147 APP_NAME.fetchFrom(params))); 148 if (!outdir.isDirectory() && !outdir.mkdirs()) { 149 throw new PackagerException( 150 "error.cannot-create-output-dir", 151 outdir.getAbsolutePath()); 152 } 153 if (!outdir.canWrite()) { 154 throw new PackagerException( 155 "error.cannot-write-to-output-dir", 156 outdir.getAbsolutePath()); 157 } 158 159 File appImageDir = null; 160 try { 161 appImageDir = prepareAppBundle(params, false); 162 163 if (appImageDir != null && prepareConfigFiles(params)) { 164 165 File configScript = getConfig_Script(params); 166 if (configScript.exists()) { 167 Log.verbose(MessageFormat.format(I18N.getString( 168 "message.running-script"), 169 configScript.getAbsolutePath())); 170 IOUtils.run("bash", configScript, false); 171 } 172 173 return createPKG(params, outdir, appImageDir); 174 } 175 return null; 176 } catch (IOException ex) { 177 Log.verbose(ex); 178 throw new PackagerException(ex); 179 } 180 } 181 182 private File getPackages_AppPackage(Map<String, ? super Object> params) { 183 return new File(PACKAGES_ROOT.fetchFrom(params), 184 APP_NAME.fetchFrom(params) + "-app.pkg"); 185 } 186 187 private File getPackages_DaemonPackage(Map<String, ? super Object> params) { 188 return new File(PACKAGES_ROOT.fetchFrom(params), 189 APP_NAME.fetchFrom(params) + "-daemon.pkg"); 190 } 191 192 private File getConfig_DistributionXMLFile( 193 Map<String, ? super Object> params) { 194 return new File(CONFIG_ROOT.fetchFrom(params), "distribution.dist"); 195 } 196 197 private File getConfig_BackgroundImage(Map<String, ? super Object> params) { 198 return new File(CONFIG_ROOT.fetchFrom(params), 199 APP_NAME.fetchFrom(params) + "-background.png"); 200 } 201 202 private File getScripts_PreinstallFile(Map<String, ? super Object> params) { 203 return new File(SCRIPTS_DIR.fetchFrom(params), "preinstall"); 204 } 205 206 private File getScripts_PostinstallFile( 207 Map<String, ? super Object> params) { 208 return new File(SCRIPTS_DIR.fetchFrom(params), "postinstall"); 209 } 210 211 private String getAppIdentifier(Map<String, ? super Object> params) { 212 return IDENTIFIER.fetchFrom(params); 213 } 214 215 private String getDaemonIdentifier(Map<String, ? super Object> params) { 216 return IDENTIFIER.fetchFrom(params) + ".daemon"; 217 } 218 219 private void preparePackageScripts(Map<String, ? super Object> params) 220 throws IOException { 221 Log.verbose(I18N.getString("message.preparing-scripts")); 222 223 Map<String, String> data = new HashMap<>(); 224 225 data.put("DEPLOY_DAEMON_IDENTIFIER", getDaemonIdentifier(params)); 226 data.put("DEPLOY_LAUNCHD_PLIST_FILE", 227 IDENTIFIER.fetchFrom(params).toLowerCase() + ".launchd.plist"); 228 229 Writer w = new BufferedWriter( 230 new FileWriter(getScripts_PreinstallFile(params))); 231 String content = preprocessTextResource( 232 getScripts_PreinstallFile(params).getName(), 233 I18N.getString("resource.pkg-preinstall-script"), 234 TEMPLATE_PREINSTALL_SCRIPT, 235 data, 236 VERBOSE.fetchFrom(params), 237 RESOURCE_DIR.fetchFrom(params)); 238 w.write(content); 239 w.close(); 240 getScripts_PreinstallFile(params).setExecutable(true, false); 241 242 w = new BufferedWriter( 243 new FileWriter(getScripts_PostinstallFile(params))); 244 content = preprocessTextResource( 245 getScripts_PostinstallFile(params).getName(), 246 I18N.getString("resource.pkg-postinstall-script"), 247 TEMPLATE_POSTINSTALL_SCRIPT, 248 data, 249 VERBOSE.fetchFrom(params), 250 RESOURCE_DIR.fetchFrom(params)); 251 w.write(content); 252 w.close(); 253 getScripts_PostinstallFile(params).setExecutable(true, false); 254 } 255 256 private void prepareDistributionXMLFile(Map<String, ? super Object> params) 257 throws IOException { 258 File f = getConfig_DistributionXMLFile(params); 259 260 Log.verbose(MessageFormat.format(I18N.getString( 261 "message.preparing-distribution-dist"), f.getAbsolutePath())); 262 263 PrintStream out = new PrintStream(f); 264 265 out.println( 266 "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"no\"?>"); 267 out.println("<installer-gui-script minSpecVersion=\"1\">"); 268 269 out.println("<title>" + APP_NAME.fetchFrom(params) + "</title>"); 270 out.println("<background" + " file=\"" 271 + getConfig_BackgroundImage(params).getName() 272 + "\"" 273 + " mime-type=\"image/png\"" 274 + " alignment=\"bottomleft\" " 275 + " scaling=\"none\"" 276 + "/>"); 277 278 String licFileStr = LICENSE_FILE.fetchFrom(params); 279 if (licFileStr != null) { 280 File licFile = new File(licFileStr); 281 out.println("<license" 282 + " file=\"" + licFile.getAbsolutePath() + "\"" 283 + " mime-type=\"text/rtf\"" 284 + "/>"); 285 } 286 287 /* 288 * Note that the content of the distribution file 289 * below is generated by productbuild --synthesize 290 */ 291 292 String appId = getAppIdentifier(params); 293 294 out.println("<pkg-ref id=\"" + appId + "\"/>"); 295 296 out.println("<options customize=\"never\" require-scripts=\"false\"/>"); 297 out.println("<choices-outline>"); 298 out.println(" <line choice=\"default\">"); 299 out.println(" <line choice=\"" + appId + "\"/>"); 300 out.println(" </line>"); 301 out.println("</choices-outline>"); 302 out.println("<choice id=\"default\"/>"); 303 out.println("<choice id=\"" + appId + "\" visible=\"false\">"); 304 out.println(" <pkg-ref id=\"" + appId + "\"/>"); 305 out.println("</choice>"); 306 out.println("<pkg-ref id=\"" + appId + "\" version=\"" 307 + VERSION.fetchFrom(params) + "\" onConclusion=\"none\">" 308 + URLEncoder.encode(getPackages_AppPackage(params).getName(), 309 "UTF-8") + "</pkg-ref>"); 310 311 out.println("</installer-gui-script>"); 312 313 out.close(); 314 } 315 316 private boolean prepareConfigFiles(Map<String, ? super Object> params) 317 throws IOException { 318 File imageTarget = getConfig_BackgroundImage(params); 319 fetchResource(imageTarget.getName(), 320 I18N.getString("resource.pkg-background-image"), 321 DEFAULT_BACKGROUND_IMAGE, 322 imageTarget, 323 VERBOSE.fetchFrom(params), 324 RESOURCE_DIR.fetchFrom(params)); 325 326 prepareDistributionXMLFile(params); 327 328 fetchResource(getConfig_Script(params).getName(), 329 I18N.getString("resource.post-install-script"), 330 (String) null, 331 getConfig_Script(params), 332 VERBOSE.fetchFrom(params), 333 RESOURCE_DIR.fetchFrom(params)); 334 335 return true; 336 } 337 338 // name of post-image script 339 private File getConfig_Script(Map<String, ? super Object> params) { 340 return new File(CONFIG_ROOT.fetchFrom(params), 341 APP_NAME.fetchFrom(params) + "-post-image.sh"); 342 } 343 344 private void patchCPLFile(File cpl) throws IOException { 345 String cplData = Files.readString(cpl.toPath()); 346 String[] lines = cplData.split("\n"); 347 try (PrintWriter out = new PrintWriter(new BufferedWriter( 348 new FileWriter(cpl)))) { 349 boolean skip = false; // Used to skip Java.runtime bundle, since 350 // pkgbuild with --root will find two bundles app and Java runtime. 351 // We cannot generate component proprty list when using 352 // --component argument. 353 for (int i = 0; i < lines.length; i++) { 354 if (lines[i].trim().equals("<key>BundleIsRelocatable</key>")) { 355 out.println(lines[i]); 356 out.println("<false/>"); 357 i++; 358 } else if (lines[i].trim().equals("<key>ChildBundles</key>")) { 359 skip = true; 360 } else if (skip && lines[i].trim().equals("</array>")) { 361 skip = false; 362 } else { 363 if (!skip) { 364 out.println(lines[i]); 365 } 366 } 367 } 368 } 369 } 370 371 private File createPKG(Map<String, ? super Object> params, 372 File outdir, File appLocation) { 373 // generic find attempt 374 try { 375 File appPKG = getPackages_AppPackage(params); 376 377 // Generate default CPL file 378 File cpl = new File(CONFIG_ROOT.fetchFrom(params).getAbsolutePath() 379 + File.separator + "cpl.plist"); 380 ProcessBuilder pb = new ProcessBuilder("pkgbuild", 381 "--root", 382 appLocation.getParent(), 383 "--install-location", 384 MAC_INSTALL_DIR.fetchFrom(params), 385 "--analyze", 386 cpl.getAbsolutePath()); 387 388 IOUtils.exec(pb, false); 389 390 patchCPLFile(cpl); 391 392 // build application package 393 pb = new ProcessBuilder("pkgbuild", 394 "--root", 395 appLocation.getParent(), 396 "--install-location", 397 MAC_INSTALL_DIR.fetchFrom(params), 398 "--component-plist", 399 cpl.getAbsolutePath(), 400 appPKG.getAbsolutePath()); 401 IOUtils.exec(pb, false); 402 403 // build final package 404 File finalPKG = new File(outdir, INSTALLER_NAME.fetchFrom(params) 405 + INSTALLER_SUFFIX.fetchFrom(params) 406 + ".pkg"); 407 outdir.mkdirs(); 408 409 List<String> commandLine = new ArrayList<>(); 410 commandLine.add("productbuild"); 411 412 commandLine.add("--resources"); 413 commandLine.add(CONFIG_ROOT.fetchFrom(params).getAbsolutePath()); 414 415 // maybe sign 416 if (Optional.ofNullable(MacAppImageBuilder. 417 SIGN_BUNDLE.fetchFrom(params)).orElse(Boolean.TRUE)) { 418 if (Platform.getMajorVersion() > 10 || 419 (Platform.getMajorVersion() == 10 && 420 Platform.getMinorVersion() >= 12)) { 421 // we need this for OS X 10.12+ 422 Log.verbose(I18N.getString("message.signing.pkg")); 423 } 424 425 String signingIdentity = 426 DEVELOPER_ID_INSTALLER_SIGNING_KEY.fetchFrom(params); 427 if (signingIdentity != null) { 428 commandLine.add("--sign"); 429 commandLine.add(signingIdentity); 430 } 431 432 String keychainName = SIGNING_KEYCHAIN.fetchFrom(params); 433 if (keychainName != null && !keychainName.isEmpty()) { 434 commandLine.add("--keychain"); 435 commandLine.add(keychainName); 436 } 437 } 438 439 commandLine.add("--distribution"); 440 commandLine.add( 441 getConfig_DistributionXMLFile(params).getAbsolutePath()); 442 commandLine.add("--package-path"); 443 commandLine.add(PACKAGES_ROOT.fetchFrom(params).getAbsolutePath()); 444 445 commandLine.add(finalPKG.getAbsolutePath()); 446 447 pb = new ProcessBuilder(commandLine); 448 IOUtils.exec(pb, false); 449 450 return finalPKG; 451 } catch (Exception ignored) { 452 Log.verbose(ignored); 453 return null; 454 } 455 } 456 457 ////////////////////////////////////////////////////////////////////////// 458 // Implement Bundler 459 ////////////////////////////////////////////////////////////////////////// 460 461 @Override 462 public String getName() { 463 return I18N.getString("pkg.bundler.name"); 464 } 465 466 @Override 467 public String getDescription() { 468 return I18N.getString("pkg.bundler.description"); 469 } 470 471 @Override 472 public String getID() { 473 return "pkg"; 474 } 475 476 @Override 477 public Collection<BundlerParamInfo<?>> getBundleParameters() { 478 Collection<BundlerParamInfo<?>> results = new LinkedHashSet<>(); 479 results.addAll(MacAppBundler.getAppBundleParameters()); 480 results.addAll(getPKGBundleParameters()); 481 return results; 482 } 483 484 public Collection<BundlerParamInfo<?>> getPKGBundleParameters() { 485 Collection<BundlerParamInfo<?>> results = new LinkedHashSet<>(); 486 487 results.addAll(MacAppBundler.getAppBundleParameters()); 488 results.addAll(Arrays.asList( 489 DEVELOPER_ID_INSTALLER_SIGNING_KEY, 490 // IDENTIFIER, 491 INSTALLER_SUFFIX, 492 LICENSE_FILE, 493 // SERVICE_HINT, 494 SIGNING_KEYCHAIN)); 495 496 return results; 497 } 498 499 @Override 500 public boolean validate(Map<String, ? super Object> params) 501 throws UnsupportedPlatformException, ConfigException { 502 try { 503 if (params == null) throw new ConfigException( 504 I18N.getString("error.parameters-null"), 505 I18N.getString("error.parameters-null.advice")); 506 507 // run basic validation to ensure requirements are met 508 // we are not interested in return code, only possible exception 509 validateAppImageAndBundeler(params); 510 511 // reject explicitly set sign to true and no valid signature key 512 if (Optional.ofNullable(MacAppImageBuilder. 513 SIGN_BUNDLE.fetchFrom(params)).orElse(Boolean.FALSE)) { 514 String signingIdentity = 515 DEVELOPER_ID_INSTALLER_SIGNING_KEY.fetchFrom(params); 516 if (signingIdentity == null) { 517 throw new ConfigException( 518 I18N.getString("error.explicit-sign-no-cert"), 519 I18N.getString( 520 "error.explicit-sign-no-cert.advice")); 521 } 522 } 523 524 // hdiutil is always available so there's no need 525 // to test for availability. 526 527 return true; 528 } catch (RuntimeException re) { 529 if (re.getCause() instanceof ConfigException) { 530 throw (ConfigException) re.getCause(); 531 } else { 532 throw new ConfigException(re); 533 } 534 } 535 } 536 537 @Override 538 public File execute(Map<String, ? super Object> params, 539 File outputParentDir) throws PackagerException { 540 return bundle(params, outputParentDir); 541 } 542 543 @Override 544 public boolean supported(boolean runtimeInstaller) { 545 return Platform.getPlatform() == Platform.MAC; 546 } 547 548 }