1 /*
   2  * Copyright (c) 2014, 2020, 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 
 366         File[] list = rootDir.listFiles();
 367         if (list != null) { // Should not happend
 368             // We should only have app image and/or .DS_Store
 369             if (list.length == 1) {
 370                 return root;
 371             } else if (list.length == 2) {
 372                 // Check case with app image and .DS_Store
 373                 if (list[0].toString().toLowerCase().endsWith(".ds_store") ||
 374                     list[1].toString().toLowerCase().endsWith(".ds_store")) {
 375                     return root; // Only app image and .DS_Store
 376                 }
 377             }
 378         }
 379 
 380         // Copy to new root
 381         Path newRoot = Files.createTempDirectory(
 382                 TEMP_ROOT.fetchFrom(params).toPath(), "root-");
 383 
 384         Path source, dest;
 385 
 386         if (StandardBundlerParam.isRuntimeInstaller(params)) {
 387             // firs, is this already a runtime with
 388             // <runtime>/Contents/Home - if so we need the Home dir
 389             Path original = appLocation.toPath();
 390             Path home = original.resolve("Contents/Home");
 391             source = (Files.exists(home)) ? home : original;
 392 
 393             // Then we need to put back the <NAME>/Content/Home
 394             dest = newRoot.resolve(
 395                 MAC_CF_BUNDLE_IDENTIFIER.fetchFrom(params) + "/Contents/Home");
 396         } else {
 397             source = appLocation.toPath();
 398             dest = newRoot.resolve(appLocation.getName());
 399         }
 400         IOUtils.copyRecursive(source, dest);
 401 
 402         return newRoot.toString();
 403     }
 404 
 405     private File createPKG(Map<String, ? super Object> params,
 406             File outdir, File appLocation) {
 407         // generic find attempt
 408         try {
 409             File appPKG = getPackages_AppPackage(params);
 410 
 411             String root = getRoot(params, appLocation);
 412 
 413             // Generate default CPL file
 414             File cpl = new File(CONFIG_ROOT.fetchFrom(params).getAbsolutePath()
 415                     + File.separator + "cpl.plist");
 416             ProcessBuilder pb = new ProcessBuilder("pkgbuild",
 417                     "--root",
 418                     root,
 419                     "--install-location",
 420                     MAC_INSTALL_DIR.fetchFrom(params),
 421                     "--analyze",
 422                     cpl.getAbsolutePath());
 423 
 424             IOUtils.exec(pb);
 425 
 426             patchCPLFile(cpl);
 427 
 428             preparePackageScripts(params);
 429 
 430             // build application package
 431             pb = new ProcessBuilder("pkgbuild",
 432                     "--root",
 433                     root,
 434                     "--install-location",
 435                     MAC_INSTALL_DIR.fetchFrom(params),
 436                     "--component-plist",
 437                     cpl.getAbsolutePath(),
 438                     "--scripts",
 439                     SCRIPTS_DIR.fetchFrom(params).getAbsolutePath(),
 440                     "--identifier",
 441                      MAC_CF_BUNDLE_IDENTIFIER.fetchFrom(params),
 442                     appPKG.getAbsolutePath());
 443             IOUtils.exec(pb);
 444 
 445             // build final package
 446             File finalPKG = new File(outdir, INSTALLER_NAME.fetchFrom(params)
 447                     + INSTALLER_SUFFIX.fetchFrom(params)
 448                     + ".pkg");
 449             outdir.mkdirs();
 450 
 451             List<String> commandLine = new ArrayList<>();
 452             commandLine.add("productbuild");
 453 
 454             commandLine.add("--resources");
 455             commandLine.add(CONFIG_ROOT.fetchFrom(params).getAbsolutePath());
 456 
 457             // maybe sign
 458             if (Optional.ofNullable(MacAppImageBuilder.
 459                     SIGN_BUNDLE.fetchFrom(params)).orElse(Boolean.TRUE)) {
 460                 if (Platform.getMajorVersion() > 10 ||
 461                     (Platform.getMajorVersion() == 10 &&
 462                     Platform.getMinorVersion() >= 12)) {
 463                     // we need this for OS X 10.12+
 464                     Log.verbose(I18N.getString("message.signing.pkg"));
 465                 }
 466 
 467                 String signingIdentity =
 468                         DEVELOPER_ID_INSTALLER_SIGNING_KEY.fetchFrom(params);
 469                 if (signingIdentity != null) {
 470                     commandLine.add("--sign");
 471                     commandLine.add(signingIdentity);
 472                 }
 473 
 474                 String keychainName = SIGNING_KEYCHAIN.fetchFrom(params);
 475                 if (keychainName != null && !keychainName.isEmpty()) {
 476                     commandLine.add("--keychain");
 477                     commandLine.add(keychainName);
 478                 }
 479             }
 480 
 481             commandLine.add("--distribution");
 482             commandLine.add(
 483                     getConfig_DistributionXMLFile(params).getAbsolutePath());
 484             commandLine.add("--package-path");
 485             commandLine.add(PACKAGES_ROOT.fetchFrom(params).getAbsolutePath());
 486 
 487             commandLine.add(finalPKG.getAbsolutePath());
 488 
 489             pb = new ProcessBuilder(commandLine);
 490             IOUtils.exec(pb);
 491 
 492             return finalPKG;
 493         } catch (Exception ignored) {
 494             Log.verbose(ignored);
 495             return null;
 496         }
 497     }
 498 
 499     //////////////////////////////////////////////////////////////////////////
 500     // Implement Bundler
 501     //////////////////////////////////////////////////////////////////////////
 502 
 503     @Override
 504     public String getName() {
 505         return I18N.getString("pkg.bundler.name");
 506     }
 507 
 508     @Override
 509     public String getID() {
 510         return "pkg";
 511     }
 512 
 513     @Override
 514     public boolean validate(Map<String, ? super Object> params)
 515             throws ConfigException {
 516         try {
 517             Objects.requireNonNull(params);
 518 
 519             // run basic validation to ensure requirements are met
 520             // we are not interested in return code, only possible exception
 521             validateAppImageAndBundeler(params);
 522 
 523             if (MAC_CF_BUNDLE_IDENTIFIER.fetchFrom(params) == null) {
 524                 throw new ConfigException(
 525                         I18N.getString("message.app-image-requires-identifier"),
 526                         I18N.getString(
 527                             "message.app-image-requires-identifier.advice"));
 528             }
 529 
 530             // reject explicitly set sign to true and no valid signature key
 531             if (Optional.ofNullable(MacAppImageBuilder.
 532                     SIGN_BUNDLE.fetchFrom(params)).orElse(Boolean.FALSE)) {
 533                 String signingIdentity =
 534                         DEVELOPER_ID_INSTALLER_SIGNING_KEY.fetchFrom(params);
 535                 if (signingIdentity == null) {
 536                     throw new ConfigException(
 537                             I18N.getString("error.explicit-sign-no-cert"),
 538                             I18N.getString(
 539                             "error.explicit-sign-no-cert.advice"));
 540                 }
 541             }
 542 
 543             // hdiutil is always available so there's no need
 544             // to test for availability.
 545 
 546             return true;
 547         } catch (RuntimeException re) {
 548             if (re.getCause() instanceof ConfigException) {
 549                 throw (ConfigException) re.getCause();
 550             } else {
 551                 throw new ConfigException(re);
 552             }
 553         }
 554     }
 555 
 556     @Override
 557     public File execute(Map<String, ? super Object> params,
 558             File outputParentDir) throws PackagerException {
 559         return bundle(params, outputParentDir);
 560     }
 561 
 562     @Override
 563     public boolean supported(boolean runtimeInstaller) {
 564         return true;
 565     }
 566 
 567     @Override
 568     public boolean isDefault() {
 569         return false;
 570     }
 571 
 572 }