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