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