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