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_pkg.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         File appImageDir = null;
 142         try {
 143             appImageDir = prepareAppBundle(params, false);
 144 
 145             if (appImageDir != null && prepareConfigFiles(params)) {
 146 
 147                 File configScript = getConfig_Script(params);
 148                 if (configScript.exists()) {
 149                     Log.verbose(MessageFormat.format(I18N.getString(
 150                             "message.running-script"),
 151                             configScript.getAbsolutePath()));
 152                     IOUtils.run("bash", configScript);
 153                 }
 154 
 155                 return createPKG(params, outdir, appImageDir);
 156             }
 157             return null;
 158         } catch (IOException ex) {
 159             Log.verbose(ex);
 160             throw new PackagerException(ex);
 161         }
 162     }
 163 
 164     private File getPackages_AppPackage(Map<String, ? super Object> params) {
 165         return new File(PACKAGES_ROOT.fetchFrom(params),
 166                 APP_NAME.fetchFrom(params) + "-app.pkg");
 167     }
 168 
 169     private File getPackages_DaemonPackage(Map<String, ? super Object> params) {
 170         return new File(PACKAGES_ROOT.fetchFrom(params),
 171                 APP_NAME.fetchFrom(params) + "-daemon.pkg");
 172     }
 173 
 174     private File getConfig_DistributionXMLFile(
 175             Map<String, ? super Object> params) {
 176         return new File(CONFIG_ROOT.fetchFrom(params), "distribution.dist");
 177     }
 178 
 179     private File getConfig_BackgroundImage(Map<String, ? super Object> params) {
 180         return new File(CONFIG_ROOT.fetchFrom(params),
 181                 APP_NAME.fetchFrom(params) + "-background.png");
 182     }
 183 
 184     private File getScripts_PreinstallFile(Map<String, ? super Object> params) {
 185         return new File(SCRIPTS_DIR.fetchFrom(params), "preinstall");
 186     }
 187 
 188     private File getScripts_PostinstallFile(
 189             Map<String, ? super Object> params) {
 190         return new File(SCRIPTS_DIR.fetchFrom(params), "postinstall");
 191     }
 192 
 193     private String getAppIdentifier(Map<String, ? super Object> params) {
 194         return IDENTIFIER.fetchFrom(params);
 195     }
 196 
 197     private String getDaemonIdentifier(Map<String, ? super Object> params) {
 198         return IDENTIFIER.fetchFrom(params) + ".daemon";
 199     }
 200 
 201     private void preparePackageScripts(Map<String, ? super Object> params)
 202             throws IOException {
 203         Log.verbose(I18N.getString("message.preparing-scripts"));
 204 
 205         Map<String, String> data = new HashMap<>();
 206 
 207         data.put("INSTALL_LOCATION", MAC_INSTALL_DIR.fetchFrom(params));
 208 
 209         try (Writer w = Files.newBufferedWriter(
 210                 getScripts_PreinstallFile(params).toPath())) {
 211             String content = preprocessTextResource(
 212                     getScripts_PreinstallFile(params).getName(),
 213                     I18N.getString("resource.pkg-preinstall-script"),
 214                     TEMPLATE_PREINSTALL_SCRIPT,
 215                     data,
 216                     VERBOSE.fetchFrom(params),
 217                     RESOURCE_DIR.fetchFrom(params));
 218             w.write(content);
 219         }
 220         getScripts_PreinstallFile(params).setExecutable(true, false);
 221 
 222         try (Writer w = Files.newBufferedWriter(
 223                 getScripts_PostinstallFile(params).toPath())) {
 224             String content = preprocessTextResource(
 225                     getScripts_PostinstallFile(params).getName(),
 226                     I18N.getString("resource.pkg-postinstall-script"),
 227                     TEMPLATE_POSTINSTALL_SCRIPT,
 228                     data,
 229                     VERBOSE.fetchFrom(params),
 230                     RESOURCE_DIR.fetchFrom(params));
 231             w.write(content);
 232         }
 233         getScripts_PostinstallFile(params).setExecutable(true, false);
 234     }
 235 
 236     private void prepareDistributionXMLFile(Map<String, ? super Object> params)
 237             throws IOException {
 238         File f = getConfig_DistributionXMLFile(params);
 239 
 240         Log.verbose(MessageFormat.format(I18N.getString(
 241                 "message.preparing-distribution-dist"), f.getAbsolutePath()));
 242 
 243         try (PrintStream out = new PrintStream(f)) {
 244 
 245             out.println(
 246                 "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"no\"?>");
 247             out.println("<installer-gui-script minSpecVersion=\"1\">");
 248 
 249             out.println("<title>" + APP_NAME.fetchFrom(params) + "</title>");
 250             out.println("<background" + " file=\""
 251                     + getConfig_BackgroundImage(params).getName()
 252                     + "\""
 253                     + " mime-type=\"image/png\""
 254                     + " alignment=\"bottomleft\" "
 255                     + " scaling=\"none\""
 256                     + "/>");
 257 
 258             String licFileStr = LICENSE_FILE.fetchFrom(params);
 259             if (licFileStr != null) {
 260                 File licFile = new File(licFileStr);
 261                 out.println("<license"
 262                         + " file=\"" + licFile.getAbsolutePath() + "\""
 263                         + " mime-type=\"text/rtf\""
 264                         + "/>");
 265             }
 266 
 267             /*
 268              * Note that the content of the distribution file
 269              * below is generated by productbuild --synthesize
 270              */
 271 
 272             String appId = getAppIdentifier(params);
 273 
 274             out.println("<pkg-ref id=\"" + appId + "\"/>");
 275             out.println(
 276                     "<options customize=\"never\" require-scripts=\"false\"/>");
 277             out.println("<choices-outline>");
 278             out.println("    <line choice=\"default\">");
 279             out.println("        <line choice=\"" + appId + "\"/>");
 280             out.println("    </line>");
 281             out.println("</choices-outline>");
 282             out.println("<choice id=\"default\"/>");
 283             out.println("<choice id=\"" + appId + "\" visible=\"false\">");
 284             out.println("    <pkg-ref id=\"" + appId + "\"/>");
 285             out.println("</choice>");
 286             out.println("<pkg-ref id=\"" + appId + "\" version=\""
 287                     + VERSION.fetchFrom(params) + "\" onConclusion=\"none\">"
 288                     + URLEncoder.encode(
 289                     getPackages_AppPackage(params).getName(),
 290                     "UTF-8") + "</pkg-ref>");
 291 
 292             out.println("</installer-gui-script>");
 293 
 294         }
 295     }
 296 
 297     private boolean prepareConfigFiles(Map<String, ? super Object> params)
 298             throws IOException {
 299         File imageTarget = getConfig_BackgroundImage(params);
 300         fetchResource(imageTarget.getName(),
 301                 I18N.getString("resource.pkg-background-image"),
 302                 DEFAULT_BACKGROUND_IMAGE,
 303                 imageTarget,
 304                 VERBOSE.fetchFrom(params),
 305                 RESOURCE_DIR.fetchFrom(params));
 306 
 307         prepareDistributionXMLFile(params);
 308 
 309         fetchResource(getConfig_Script(params).getName(),
 310                 I18N.getString("resource.post-install-script"),
 311                 (String) null,
 312                 getConfig_Script(params),
 313                 VERBOSE.fetchFrom(params),
 314                 RESOURCE_DIR.fetchFrom(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             boolean skip = false; // 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 = true;
 341                 } else if (skip && lines[i].trim().equals("</array>")) {
 342                     skip = false;
 343                 } else {
 344                     if (!skip) {
 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 }