1 /*
   2  * Copyright (c) 2014, 2018, 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.jpackager.internal.mac;
  27 
  28 import jdk.jpackager.internal.BundlerParamInfo;
  29 import jdk.jpackager.internal.StandardBundlerParam;
  30 import jdk.jpackager.internal.Log;
  31 import jdk.jpackager.internal.ConfigException;
  32 import jdk.jpackager.internal.IOUtils;
  33 import jdk.jpackager.internal.Platform;
  34 import jdk.jpackager.internal.RelativeFileSet;
  35 import jdk.jpackager.internal.UnsupportedPlatformException;
  36 import jdk.jpackager.internal.resources.mac.MacResources;
  37 import jdk.jpackager.internal.builders.mac.MacAppImageBuilder;
  38 import jdk.jpackager.internal.Arguments;
  39 
  40 import java.io.BufferedWriter;
  41 import java.io.File;
  42 import java.io.FileWriter;
  43 import java.io.IOException;
  44 import java.io.PrintStream;
  45 import java.io.Writer;
  46 import java.net.URLEncoder;
  47 import java.text.MessageFormat;
  48 import java.util.ArrayList;
  49 import java.util.Arrays;
  50 import java.util.Collection;
  51 import java.util.HashMap;
  52 import java.util.LinkedHashSet;
  53 import java.util.List;
  54 import java.util.Map;
  55 import java.util.Optional;
  56 import java.util.ResourceBundle;
  57 
  58 import static jdk.jpackager.internal.StandardBundlerParam.*;
  59 import static
  60         jdk.jpackager.internal.mac.MacBaseInstallerBundler.SIGNING_KEYCHAIN;
  61 import static
  62         jdk.jpackager.internal.mac.MacBaseInstallerBundler.SIGNING_KEY_USER;
  63 
  64 public class MacPkgBundler extends MacBaseInstallerBundler {
  65 
  66     private static final ResourceBundle I18N = ResourceBundle.getBundle(
  67             "jdk.jpackager.internal.resources.mac.MacPkgBundler");
  68 
  69     public final static String MAC_BUNDLER_PREFIX =
  70             BUNDLER_PREFIX + "macosx" + File.separator;
  71 
  72     private static final String DEFAULT_BACKGROUND_IMAGE = "background_pkg.png";
  73 
  74     private static final String TEMPLATE_PREINSTALL_SCRIPT =
  75             "preinstall.template";
  76     private static final String TEMPLATE_POSTINSTALL_SCRIPT =
  77             "postinstall.template";
  78 
  79     private static final BundlerParamInfo<File> PACKAGES_ROOT =
  80             new StandardBundlerParam<>(
  81             I18N.getString("param.packages-root.name"),
  82             I18N.getString("param.packages-root.description"),
  83             "mac.pkg.packagesRoot",
  84             File.class,
  85             params -> {
  86                 File packagesRoot =
  87                         new File(BUILD_ROOT.fetchFrom(params), "packages");
  88                 packagesRoot.mkdirs();
  89                 return packagesRoot;
  90             },
  91             (s, p) -> new File(s));
  92 
  93 
  94     protected final BundlerParamInfo<File> SCRIPTS_DIR =
  95             new StandardBundlerParam<>(
  96             I18N.getString("param.scripts-dir.name"),
  97             I18N.getString("param.scripts-dir.description"),
  98             "mac.pkg.scriptsDir",
  99             File.class,
 100             params -> {
 101                 File scriptsDir =
 102                         new File(CONFIG_ROOT.fetchFrom(params), "scripts");
 103                 scriptsDir.mkdirs();
 104                 return scriptsDir;
 105             },
 106             (s, p) -> new File(s));
 107 
 108     public static final
 109             BundlerParamInfo<String> DEVELOPER_ID_INSTALLER_SIGNING_KEY =
 110             new StandardBundlerParam<>(
 111             I18N.getString("param.signing-key-developer-id-installer.name"),
 112             I18N.getString(
 113             "param.signing-key-developer-id-installer.description"),
 114             "mac.signing-key-developer-id-installer",
 115             String.class,
 116             params -> {
 117                     String result = MacBaseInstallerBundler.findKey(
 118                             "Developer ID Installer: "
 119                             + SIGNING_KEY_USER.fetchFrom(params),
 120                             SIGNING_KEYCHAIN.fetchFrom(params),
 121                             VERBOSE.fetchFrom(params));
 122                     if (result != null) {
 123                         MacCertificate certificate = new MacCertificate(
 124                                 result, VERBOSE.fetchFrom(params));
 125 
 126                         if (!certificate.isValid()) {
 127                             Log.error(MessageFormat.format(
 128                                     I18N.getString("error.certificate.expired"),
 129                                     result));
 130                         }
 131                     }
 132 
 133                     return result;
 134                 },
 135             (s, p) -> s);
 136 
 137     public static final BundlerParamInfo<String> MAC_INSTALL_DIR =
 138             new StandardBundlerParam<>(
 139             I18N.getString("param.mac-install-dir.name"),
 140             I18N.getString("param.mac-install-dir.description"),
 141             "mac-install-dir",
 142             String.class,
 143              params -> {
 144                  String dir = INSTALL_DIR.fetchFrom(params);
 145                  return (dir != null) ? dir : "/Applications";
 146              },
 147             (s, p) -> s
 148     );
 149 
 150     public static final BundlerParamInfo<String> INSTALLER_SUFFIX =
 151             new StandardBundlerParam<> (
 152             I18N.getString("param.installer-suffix.name"),
 153             I18N.getString("param.installer-suffix.description"),
 154             "mac.pkg.installerName.suffix",
 155             String.class,
 156             params -> "",
 157             (s, p) -> s);
 158 
 159     public MacPkgBundler() {
 160         super();
 161         baseResourceLoader = MacResources.class;
 162     }
 163 
 164     public File bundle(Map<String, ? super Object> params, File outdir) {
 165         Log.verbose(MessageFormat.format(I18N.getString("message.building-pkg"),
 166                 APP_NAME.fetchFrom(params)));
 167         if (!outdir.isDirectory() && !outdir.mkdirs()) {
 168             throw new RuntimeException(MessageFormat.format(
 169                     I18N.getString("error.cannot-create-output-dir"),
 170                     outdir.getAbsolutePath()));
 171         }
 172         if (!outdir.canWrite()) {
 173             throw new RuntimeException(MessageFormat.format(
 174                     I18N.getString("error.cannot-write-to-output-dir"),
 175                     outdir.getAbsolutePath()));
 176         }
 177 
 178         File appImageDir = null;
 179         try {
 180             appImageDir = prepareAppBundle(params, false);
 181 
 182             if (appImageDir != null && prepareConfigFiles(params)) {
 183 
 184                 File configScript = getConfig_Script(params);
 185                 if (configScript.exists()) {
 186                     Log.verbose(MessageFormat.format(I18N.getString(
 187                             "message.running-script"),
 188                             configScript.getAbsolutePath()));
 189                     IOUtils.run("bash", configScript, false);
 190                 }
 191 
 192                 return createPKG(params, outdir, appImageDir);
 193             }
 194             return null;
 195         } catch (IOException ex) {
 196             Log.verbose(ex);
 197             return null;
 198         } finally {
 199             try {
 200                 if (appImageDir != null &&
 201                         PREDEFINED_APP_IMAGE.fetchFrom(params) == null &&
 202                         (PREDEFINED_RUNTIME_IMAGE.fetchFrom(params) == null ||
 203                         !Arguments.CREATE_JRE_INSTALLER.fetchFrom(params)) &&
 204                         !Log.isDebug()) {
 205                     IOUtils.deleteRecursive(appImageDir);
 206                 } else if (appImageDir != null) {
 207                     Log.verbose(MessageFormat.format(I18N.getString(
 208                             "message.intermediate-image-location"),
 209                             appImageDir.getAbsolutePath()));
 210                 }
 211 
 212                 // cleanup
 213                 cleanupConfigFiles(params);
 214             } catch (IOException ex) {
 215                 Log.debug(ex);
 216                 // noinspection ReturnInsideFinallyBlock
 217                 return null;
 218             }
 219         }
 220     }
 221 
 222     private File getPackages_AppPackage(Map<String, ? super Object> params) {
 223         return new File(PACKAGES_ROOT.fetchFrom(params),
 224                 APP_FS_NAME.fetchFrom(params) + "-app.pkg");
 225     }
 226 
 227     private File getPackages_DaemonPackage(Map<String, ? super Object> params) {
 228         return new File(PACKAGES_ROOT.fetchFrom(params),
 229                 APP_FS_NAME.fetchFrom(params) + "-daemon.pkg");
 230     }
 231 
 232     private void cleanupPackagesFiles(Map<String, ? super Object> params) {
 233         if (getPackages_AppPackage(params) != null) {
 234             getPackages_AppPackage(params).delete();
 235         }
 236         if (getPackages_DaemonPackage(params) != null) {
 237             getPackages_DaemonPackage(params).delete();
 238         }
 239     }
 240 
 241     private File getConfig_DistributionXMLFile(
 242             Map<String, ? super Object> params) {
 243         return new File(CONFIG_ROOT.fetchFrom(params), "distribution.dist");
 244     }
 245 
 246     private File getConfig_BackgroundImage(Map<String, ? super Object> params) {
 247         return new File(CONFIG_ROOT.fetchFrom(params),
 248                 APP_NAME.fetchFrom(params) + "-background.png");
 249     }
 250 
 251     private File getScripts_PreinstallFile(Map<String, ? super Object> params) {
 252         return new File(SCRIPTS_DIR.fetchFrom(params), "preinstall");
 253     }
 254 
 255     private File getScripts_PostinstallFile(
 256             Map<String, ? super Object> params) {
 257         return new File(SCRIPTS_DIR.fetchFrom(params), "postinstall");
 258     }
 259 
 260     private void cleanupConfigFiles(Map<String, ? super Object> params) {
 261         if (getConfig_DistributionXMLFile(params) != null) {
 262             getConfig_DistributionXMLFile(params).delete();
 263         }
 264         if (getConfig_BackgroundImage(params) != null) {
 265             getConfig_BackgroundImage(params).delete();
 266         }
 267     }
 268 
 269     private String getAppIdentifier(Map<String, ? super Object> params) {
 270         return IDENTIFIER.fetchFrom(params);
 271     }
 272 
 273     private String getDaemonIdentifier(Map<String, ? super Object> params) {
 274         return IDENTIFIER.fetchFrom(params) + ".daemon";
 275     }
 276 
 277     private void preparePackageScripts(Map<String, ? super Object> params)
 278             throws IOException {
 279         Log.verbose(I18N.getString("message.preparing-scripts"));
 280 
 281         Map<String, String> data = new HashMap<>();
 282 
 283         data.put("DEPLOY_DAEMON_IDENTIFIER", getDaemonIdentifier(params));
 284         data.put("DEPLOY_LAUNCHD_PLIST_FILE",
 285                 IDENTIFIER.fetchFrom(params).toLowerCase() + ".launchd.plist");
 286 
 287         Writer w = new BufferedWriter(
 288                 new FileWriter(getScripts_PreinstallFile(params)));
 289         String content = preprocessTextResource(MAC_BUNDLER_PREFIX
 290                 + getScripts_PreinstallFile(params).getName(),
 291                 I18N.getString("resource.pkg-preinstall-script"),
 292                 TEMPLATE_PREINSTALL_SCRIPT,
 293                 data,
 294                 VERBOSE.fetchFrom(params),
 295                 DROP_IN_RESOURCES_ROOT.fetchFrom(params));
 296         w.write(content);
 297         w.close();
 298         getScripts_PreinstallFile(params).setExecutable(true, false);
 299 
 300         w = new BufferedWriter(
 301                 new FileWriter(getScripts_PostinstallFile(params)));
 302         content = preprocessTextResource(MAC_BUNDLER_PREFIX
 303                 + getScripts_PostinstallFile(params).getName(),
 304                 I18N.getString("resource.pkg-postinstall-script"),
 305                 TEMPLATE_POSTINSTALL_SCRIPT,
 306                 data,
 307                 VERBOSE.fetchFrom(params),
 308                 DROP_IN_RESOURCES_ROOT.fetchFrom(params));
 309         w.write(content);
 310         w.close();
 311         getScripts_PostinstallFile(params).setExecutable(true, false);
 312     }
 313 
 314     private void prepareDistributionXMLFile(Map<String, ? super Object> params)
 315             throws IOException {
 316         File f = getConfig_DistributionXMLFile(params);
 317 
 318         Log.verbose(MessageFormat.format(I18N.getString(
 319                 "message.preparing-distribution-dist"), f.getAbsolutePath()));
 320 
 321         PrintStream out = new PrintStream(f);
 322 
 323         out.println(
 324                 "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"no\"?>");
 325         out.println("<installer-gui-script minSpecVersion=\"1\">");
 326 
 327         out.println("<title>" + APP_NAME.fetchFrom(params) + "</title>");
 328         out.println("<background" + " file=\""
 329                 + getConfig_BackgroundImage(params).getName()
 330                 + "\""
 331                 + " mime-type=\"image/png\""
 332                 + " alignment=\"bottomleft\" "
 333                 + " scaling=\"none\""
 334                 + "/>");
 335 
 336         if (!LICENSE_FILE.fetchFrom(params).isEmpty()) {
 337             File licFile = null;
 338 
 339             List<String> licFiles = LICENSE_FILE.fetchFrom(params);
 340             if (licFiles.isEmpty()) {
 341                 return;
 342             }
 343             String licFileStr = licFiles.get(0);
 344 
 345             for (RelativeFileSet rfs : APP_RESOURCES_LIST.fetchFrom(params)) {
 346                 if (rfs.contains(licFileStr)) {
 347                     licFile = new File(rfs.getBaseDirectory(), licFileStr);
 348                     break;
 349                 }
 350             }
 351 
 352             // this is NPE protection, validate should have caught it's absence
 353             // so we don't complain or throw an error
 354             if (licFile != null) {
 355                 out.println("<license"
 356                         + " file=\"" + licFile.getAbsolutePath() + "\""
 357                         + " mime-type=\"text/rtf\""
 358                         + "/>");
 359             }
 360         }
 361 
 362         /*
 363          * Note that the content of the distribution file
 364          * below is generated by productbuild --synthesize
 365          */
 366 
 367         String appId = getAppIdentifier(params);
 368         String daemonId = getDaemonIdentifier(params);
 369 
 370         out.println("<pkg-ref id=\"" + appId + "\"/>");
 371 
 372         out.println("<options customize=\"never\" require-scripts=\"false\"/>");
 373         out.println("<choices-outline>");
 374         out.println("    <line choice=\"default\">");
 375         out.println("        <line choice=\"" + appId + "\"/>");
 376         out.println("    </line>");
 377         out.println("</choices-outline>");
 378         out.println("<choice id=\"default\"/>");
 379         out.println("<choice id=\"" + appId + "\" visible=\"false\">");
 380         out.println("    <pkg-ref id=\"" + appId + "\"/>");
 381         out.println("</choice>");
 382         out.println("<pkg-ref id=\"" + appId + "\" version=\""
 383                 + VERSION.fetchFrom(params) + "\" onConclusion=\"none\">"
 384                 + URLEncoder.encode(getPackages_AppPackage(params).getName(),
 385                 "UTF-8") + "</pkg-ref>");
 386 
 387         out.println("</installer-gui-script>");
 388 
 389         out.close();
 390     }
 391 
 392     private boolean prepareConfigFiles(Map<String, ? super Object> params)
 393             throws IOException {
 394         File imageTarget = getConfig_BackgroundImage(params);
 395         fetchResource(MacAppBundler.MAC_BUNDLER_PREFIX + imageTarget.getName(),
 396                 I18N.getString("resource.pkg-background-image"),
 397                 DEFAULT_BACKGROUND_IMAGE,
 398                 imageTarget,
 399                 VERBOSE.fetchFrom(params),
 400                 DROP_IN_RESOURCES_ROOT.fetchFrom(params));
 401 
 402         prepareDistributionXMLFile(params);
 403 
 404         fetchResource(MacAppBundler.MAC_BUNDLER_PREFIX
 405                 + getConfig_Script(params).getName(),
 406                 I18N.getString("resource.post-install-script"),
 407                 (String) null,
 408                 getConfig_Script(params),
 409                 VERBOSE.fetchFrom(params),
 410                 DROP_IN_RESOURCES_ROOT.fetchFrom(params));
 411 
 412         return true;
 413     }
 414 
 415     // name of post-image script
 416     private File getConfig_Script(Map<String, ? super Object> params) {
 417         return new File(CONFIG_ROOT.fetchFrom(params),
 418                 APP_NAME.fetchFrom(params) + "-post-image.sh");
 419     }
 420 
 421     private File createPKG(Map<String, ? super Object> params,
 422             File outdir, File appLocation) {
 423         // generic find attempt
 424         try {
 425             File appPKG = getPackages_AppPackage(params);
 426 
 427             // build application package
 428             ProcessBuilder pb = new ProcessBuilder("pkgbuild",
 429                     "--component",
 430                     appLocation.toString(),
 431                     "--install-location",
 432                     MAC_INSTALL_DIR.fetchFrom(params),
 433                     appPKG.getAbsolutePath());
 434             IOUtils.exec(pb, false);
 435 
 436             // build final package
 437             File finalPKG = new File(outdir, INSTALLER_NAME.fetchFrom(params)
 438                     + INSTALLER_SUFFIX.fetchFrom(params)
 439                     + ".pkg");
 440             outdir.mkdirs();
 441 
 442             List<String> commandLine = new ArrayList<>();
 443             commandLine.add("productbuild");
 444 
 445             commandLine.add("--resources");
 446             commandLine.add(CONFIG_ROOT.fetchFrom(params).getAbsolutePath());
 447 
 448             // maybe sign
 449             if (Optional.ofNullable(MacAppImageBuilder.
 450                     SIGN_BUNDLE.fetchFrom(params)).orElse(Boolean.TRUE)) {
 451                 if (Platform.getMajorVersion() > 10 ||
 452                     (Platform.getMajorVersion() == 10 &&
 453                     Platform.getMinorVersion() >= 12)) {
 454                     // we need this for OS X 10.12+
 455                     Log.verbose(I18N.getString("message.signing.pkg"));
 456                 }
 457 
 458                 String signingIdentity =
 459                         DEVELOPER_ID_INSTALLER_SIGNING_KEY.fetchFrom(params);
 460                 if (signingIdentity != null) {
 461                     commandLine.add("--sign");
 462                     commandLine.add(signingIdentity);
 463                 }
 464 
 465                 String keychainName = SIGNING_KEYCHAIN.fetchFrom(params);
 466                 if (keychainName != null && !keychainName.isEmpty()) {
 467                     commandLine.add("--keychain");
 468                     commandLine.add(keychainName);
 469                 }
 470             }
 471 
 472             commandLine.add("--distribution");
 473             commandLine.add(
 474                     getConfig_DistributionXMLFile(params).getAbsolutePath());
 475             commandLine.add("--package-path");
 476             commandLine.add(PACKAGES_ROOT.fetchFrom(params).getAbsolutePath());
 477 
 478             commandLine.add(finalPKG.getAbsolutePath());
 479 
 480             pb = new ProcessBuilder(commandLine);
 481             IOUtils.exec(pb, false);
 482 
 483             return finalPKG;
 484         } catch (Exception ignored) {
 485             Log.verbose(ignored);
 486             return null;
 487         } finally {
 488             cleanupPackagesFiles(params);
 489             cleanupConfigFiles(params);
 490         }
 491     }
 492 
 493     //////////////////////////////////////////////////////////////////////////
 494     // Implement Bundler
 495     //////////////////////////////////////////////////////////////////////////
 496 
 497     @Override
 498     public String getName() {
 499         return I18N.getString("bundler.name");
 500     }
 501 
 502     @Override
 503     public String getDescription() {
 504         return I18N.getString("bundler.description");
 505     }
 506 
 507     @Override
 508     public String getID() {
 509         return "pkg";
 510     }
 511 
 512     @Override
 513     public Collection<BundlerParamInfo<?>> getBundleParameters() {
 514         Collection<BundlerParamInfo<?>> results = new LinkedHashSet<>();
 515         results.addAll(MacAppBundler.getAppBundleParameters());
 516         results.addAll(getPKGBundleParameters());
 517         return results;
 518     }
 519 
 520     public Collection<BundlerParamInfo<?>> getPKGBundleParameters() {
 521         Collection<BundlerParamInfo<?>> results = new LinkedHashSet<>();
 522 
 523         results.addAll(MacAppBundler.getAppBundleParameters());
 524         results.addAll(Arrays.asList(
 525                 DEVELOPER_ID_INSTALLER_SIGNING_KEY,
 526                 // IDENTIFIER,
 527                 INSTALLER_SUFFIX,
 528                 LICENSE_FILE,
 529                 // SERVICE_HINT,
 530                 SIGNING_KEYCHAIN));
 531 
 532         return results;
 533     }
 534 
 535     @Override
 536     public boolean validate(Map<String, ? super Object> params)
 537             throws UnsupportedPlatformException, ConfigException {
 538         try {
 539             if (params == null) throw new ConfigException(
 540                     I18N.getString("error.parameters-null"),
 541                     I18N.getString("error.parameters-null.advice"));
 542 
 543             // run basic validation to ensure requirements are met
 544             // we are not interested in return code, only possible exception
 545             validateAppImageAndBundeler(params);
 546 
 547             // validate license file, if used, exists in the proper place
 548             if (params.containsKey(LICENSE_FILE.getID())) {
 549                 List<RelativeFileSet> appResourcesList =
 550                         APP_RESOURCES_LIST.fetchFrom(params);
 551                 for (String license : LICENSE_FILE.fetchFrom(params)) {
 552                     boolean found = false;
 553                     for (RelativeFileSet appResources : appResourcesList) {
 554                         found = found || appResources.contains(license);
 555                     }
 556                     if (!found) {
 557                         throw new ConfigException(
 558                                 I18N.getString("error.license-missing"),
 559                                 MessageFormat.format(
 560                                 I18N.getString("error.license-missing.advice"),
 561                                 license));
 562                     }
 563                 }
 564             }
 565 
 566             // reject explicitly set sign to true and no valid signature key
 567             if (Optional.ofNullable(MacAppImageBuilder.
 568                     SIGN_BUNDLE.fetchFrom(params)).orElse(Boolean.FALSE)) {
 569                 String signingIdentity =
 570                         DEVELOPER_ID_INSTALLER_SIGNING_KEY.fetchFrom(params);
 571                 if (signingIdentity == null) {
 572                     throw new ConfigException(
 573                             I18N.getString("error.explicit-sign-no-cert"),
 574                             I18N.getString(
 575                             "error.explicit-sign-no-cert.advice"));
 576                 }
 577             }
 578 
 579             // hdiutil is always available so there's no need
 580             // to test for availability.
 581 
 582             return true;
 583         } catch (RuntimeException re) {
 584             if (re.getCause() instanceof ConfigException) {
 585                 throw (ConfigException) re.getCause();
 586             } else {
 587                 throw new ConfigException(re);
 588             }
 589         }
 590     }
 591 
 592     @Override
 593     public File execute(
 594         Map<String, ? super Object> params, File outputParentDir) {
 595         return bundle(params, outputParentDir);
 596     }
 597 
 598     @Override
 599     public boolean supported() {
 600         return Platform.getPlatform() == Platform.MAC;
 601     }
 602 
 603 }