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             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             if (params == null) throw new ConfigException(
 501                     I18N.getString("error.parameters-null"),
 502                     I18N.getString("error.parameters-null.advice"));
 503 
 504             // run basic validation to ensure requirements are met
 505             // we are not interested in return code, only possible exception
 506             validateAppImageAndBundeler(params);
 507 
 508             // reject explicitly set sign to true and no valid signature key
 509             if (Optional.ofNullable(MacAppImageBuilder.
 510                     SIGN_BUNDLE.fetchFrom(params)).orElse(Boolean.FALSE)) {
 511                 String signingIdentity =
 512                         DEVELOPER_ID_INSTALLER_SIGNING_KEY.fetchFrom(params);
 513                 if (signingIdentity == null) {
 514                     throw new ConfigException(
 515                             I18N.getString("error.explicit-sign-no-cert"),
 516                             I18N.getString(
 517                             "error.explicit-sign-no-cert.advice"));
 518                 }
 519             }
 520 
 521             // hdiutil is always available so there's no need
 522             // to test for availability.
 523 
 524             return true;
 525         } catch (RuntimeException re) {
 526             if (re.getCause() instanceof ConfigException) {
 527                 throw (ConfigException) re.getCause();
 528             } else {
 529                 throw new ConfigException(re);
 530             }
 531         }
 532     }
 533 
 534     @Override
 535     public File execute(Map<String, ? super Object> params,
 536             File outputParentDir) throws PackagerException {
 537         return bundle(params, outputParentDir);
 538     }
 539 
 540     @Override
 541     public boolean supported(boolean runtimeInstaller) {
 542         return true;
 543     }
 544 
 545 }