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