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