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