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         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("INSTALL_LOCATION", MAC_INSTALL_DIR.fetchFrom(params));
 216 
 217         Writer w = new BufferedWriter(
 218                 new FileWriter(getScripts_PreinstallFile(params)));
 219         String content = preprocessTextResource(
 220                 getScripts_PreinstallFile(params).getName(),
 221                 I18N.getString("resource.pkg-preinstall-script"),
 222                 TEMPLATE_PREINSTALL_SCRIPT,
 223                 data,
 224                 VERBOSE.fetchFrom(params),
 225                 RESOURCE_DIR.fetchFrom(params));
 226         w.write(content);
 227         w.close();
 228         getScripts_PreinstallFile(params).setExecutable(true, false);
 229 
 230         w = new BufferedWriter(
 231                 new FileWriter(getScripts_PostinstallFile(params)));
 232         content = preprocessTextResource(
 233                 getScripts_PostinstallFile(params).getName(),
 234                 I18N.getString("resource.pkg-postinstall-script"),
 235                 TEMPLATE_POSTINSTALL_SCRIPT,
 236                 data,
 237                 VERBOSE.fetchFrom(params),
 238                 RESOURCE_DIR.fetchFrom(params));
 239         w.write(content);
 240         w.close();
 241         getScripts_PostinstallFile(params).setExecutable(true, false);
 242     }
 243 
 244     private void prepareDistributionXMLFile(Map<String, ? super Object> params)
 245             throws IOException {
 246         File f = getConfig_DistributionXMLFile(params);
 247 
 248         Log.verbose(MessageFormat.format(I18N.getString(
 249                 "message.preparing-distribution-dist"), f.getAbsolutePath()));
 250 
 251         PrintStream out = new PrintStream(f);
 252 
 253         out.println(
 254                 "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"no\"?>");
 255         out.println("<installer-gui-script minSpecVersion=\"1\">");
 256 
 257         out.println("<title>" + APP_NAME.fetchFrom(params) + "</title>");
 258         out.println("<background" + " file=\""
 259                 + getConfig_BackgroundImage(params).getName()
 260                 + "\""
 261                 + " mime-type=\"image/png\""
 262                 + " alignment=\"bottomleft\" "
 263                 + " scaling=\"none\""
 264                 + "/>");
 265 
 266         String licFileStr = LICENSE_FILE.fetchFrom(params);
 267         if (licFileStr != null) {
 268             File licFile = new File(licFileStr);
 269             out.println("<license"
 270                     + " file=\"" + licFile.getAbsolutePath() + "\""
 271                     + " mime-type=\"text/rtf\""
 272                     + "/>");
 273         }
 274 
 275         /*
 276          * Note that the content of the distribution file
 277          * below is generated by productbuild --synthesize
 278          */
 279 
 280         String appId = getAppIdentifier(params);
 281 
 282         out.println("<pkg-ref id=\"" + appId + "\"/>");
 283 
 284         out.println("<options customize=\"never\" require-scripts=\"false\"/>");
 285         out.println("<choices-outline>");
 286         out.println("    <line choice=\"default\">");
 287         out.println("        <line choice=\"" + appId + "\"/>");
 288         out.println("    </line>");
 289         out.println("</choices-outline>");
 290         out.println("<choice id=\"default\"/>");
 291         out.println("<choice id=\"" + appId + "\" visible=\"false\">");
 292         out.println("    <pkg-ref id=\"" + appId + "\"/>");
 293         out.println("</choice>");
 294         out.println("<pkg-ref id=\"" + appId + "\" version=\""
 295                 + VERSION.fetchFrom(params) + "\" onConclusion=\"none\">"
 296                 + URLEncoder.encode(getPackages_AppPackage(params).getName(),
 297                 "UTF-8") + "</pkg-ref>");
 298 
 299         out.println("</installer-gui-script>");
 300 
 301         out.close();
 302     }
 303 
 304     private boolean prepareConfigFiles(Map<String, ? super Object> params)
 305             throws IOException {
 306         File imageTarget = getConfig_BackgroundImage(params);
 307         fetchResource(imageTarget.getName(),
 308                 I18N.getString("resource.pkg-background-image"),
 309                 DEFAULT_BACKGROUND_IMAGE,
 310                 imageTarget,
 311                 VERBOSE.fetchFrom(params),
 312                 RESOURCE_DIR.fetchFrom(params));
 313 
 314         prepareDistributionXMLFile(params);
 315 
 316         fetchResource(getConfig_Script(params).getName(),
 317                 I18N.getString("resource.post-install-script"),
 318                 (String) null,
 319                 getConfig_Script(params),
 320                 VERBOSE.fetchFrom(params),
 321                 RESOURCE_DIR.fetchFrom(params));
 322 
 323         return true;
 324     }
 325 
 326     // name of post-image script
 327     private File getConfig_Script(Map<String, ? super Object> params) {
 328         return new File(CONFIG_ROOT.fetchFrom(params),
 329                 APP_NAME.fetchFrom(params) + "-post-image.sh");
 330     }
 331 
 332     private void patchCPLFile(File cpl) throws IOException {
 333         String cplData = Files.readString(cpl.toPath());
 334         String[] lines = cplData.split("\n");
 335         try (PrintWriter out = new PrintWriter(new BufferedWriter(
 336                 new FileWriter(cpl)))) {
 337             boolean skip = false; // Used to skip Java.runtime bundle, since
 338             // pkgbuild with --root will find two bundles app and Java runtime.
 339             // We cannot generate component proprty list when using
 340             // --component argument.
 341             for (int i = 0; i < lines.length; i++) {
 342                 if (lines[i].trim().equals("<key>BundleIsRelocatable</key>")) {
 343                     out.println(lines[i]);
 344                     out.println("<false/>");
 345                     i++;
 346                 } else if (lines[i].trim().equals("<key>ChildBundles</key>")) {
 347                     skip = true;
 348                 } else if (skip && lines[i].trim().equals("</array>")) {
 349                     skip = false;
 350                 } else {
 351                     if (!skip) {
 352                         out.println(lines[i]);
 353                     }
 354                 }
 355             }
 356         }
 357     }
 358 
 359     // pkgbuild includes all components from "--root" and subfolders,
 360     // so if we have app image in folder which contains other images, then they
 361     // will be included as well. It does have "--filter" option which use regex
 362     // to exclude files/folder, but it will overwrite default one which excludes
 363     // based on doc "any .svn or CVS directories, and any .DS_Store files".
 364     // So easy aproach will be to copy user provided app-image into temp folder
 365     // if root path contains other files.
 366     private String getRoot(Map<String, ? super Object> params,
 367             File appLocation) throws IOException {
 368         String root = appLocation.getParent() == null ?
 369                 "." : appLocation.getParent();
 370         File rootDir = new File(root);
 371         File[] list = rootDir.listFiles();
 372         if (list != null) { // Should not happend
 373             // We should only have app image and/or .DS_Store
 374             if (list.length == 1) {
 375                 return root;
 376             } else if (list.length == 2) {
 377                 // Check case with app image and .DS_Store
 378                 if (list[0].toString().toLowerCase().endsWith(".ds_store") ||
 379                     list[1].toString().toLowerCase().endsWith(".ds_store")) {
 380                     return root; // Only app image and .DS_Store
 381                 }
 382             }
 383         }
 384 
 385         // Copy to new root
 386         Path newRoot = Files.createTempDirectory(
 387                 TEMP_ROOT.fetchFrom(params).toPath(),
 388                 "root-");
 389 
 390         IOUtils.copyRecursive(appLocation.toPath(),
 391                 newRoot.resolve(appLocation.getName()));
 392 
 393         return newRoot.toString();
 394     }
 395 
 396     private File createPKG(Map<String, ? super Object> params,
 397             File outdir, File appLocation) {
 398         // generic find attempt
 399         try {
 400             File appPKG = getPackages_AppPackage(params);
 401 
 402             String root = getRoot(params, appLocation);
 403 
 404             // Generate default CPL file
 405             File cpl = new File(CONFIG_ROOT.fetchFrom(params).getAbsolutePath()
 406                     + File.separator + "cpl.plist");
 407             ProcessBuilder pb = new ProcessBuilder("pkgbuild",
 408                     "--root",
 409                     root,
 410                     "--install-location",
 411                     MAC_INSTALL_DIR.fetchFrom(params),
 412                     "--analyze",
 413                     cpl.getAbsolutePath());
 414 
 415             IOUtils.exec(pb, false);
 416 
 417             patchCPLFile(cpl);
 418 
 419             preparePackageScripts(params);
 420 
 421             // build application package
 422             pb = new ProcessBuilder("pkgbuild",
 423                     "--root",
 424                     root,
 425                     "--install-location",
 426                     MAC_INSTALL_DIR.fetchFrom(params),
 427                     "--component-plist",
 428                     cpl.getAbsolutePath(),
 429                     "--scripts",
 430                     SCRIPTS_DIR.fetchFrom(params).getAbsolutePath(),
 431                     appPKG.getAbsolutePath());
 432             IOUtils.exec(pb, false);
 433 
 434             // build final package
 435             File finalPKG = new File(outdir, INSTALLER_NAME.fetchFrom(params)
 436                     + INSTALLER_SUFFIX.fetchFrom(params)
 437                     + ".pkg");
 438             outdir.mkdirs();
 439 
 440             List<String> commandLine = new ArrayList<>();
 441             commandLine.add("productbuild");
 442 
 443             commandLine.add("--resources");
 444             commandLine.add(CONFIG_ROOT.fetchFrom(params).getAbsolutePath());
 445 
 446             // maybe sign
 447             if (Optional.ofNullable(MacAppImageBuilder.
 448                     SIGN_BUNDLE.fetchFrom(params)).orElse(Boolean.TRUE)) {
 449                 if (Platform.getMajorVersion() > 10 ||
 450                     (Platform.getMajorVersion() == 10 &&
 451                     Platform.getMinorVersion() >= 12)) {
 452                     // we need this for OS X 10.12+
 453                     Log.verbose(I18N.getString("message.signing.pkg"));
 454                 }
 455 
 456                 String signingIdentity =
 457                         DEVELOPER_ID_INSTALLER_SIGNING_KEY.fetchFrom(params);
 458                 if (signingIdentity != null) {
 459                     commandLine.add("--sign");
 460                     commandLine.add(signingIdentity);
 461                 }
 462 
 463                 String keychainName = SIGNING_KEYCHAIN.fetchFrom(params);
 464                 if (keychainName != null && !keychainName.isEmpty()) {
 465                     commandLine.add("--keychain");
 466                     commandLine.add(keychainName);
 467                 }
 468             }
 469 
 470             commandLine.add("--distribution");
 471             commandLine.add(
 472                     getConfig_DistributionXMLFile(params).getAbsolutePath());
 473             commandLine.add("--package-path");
 474             commandLine.add(PACKAGES_ROOT.fetchFrom(params).getAbsolutePath());
 475 
 476             commandLine.add(finalPKG.getAbsolutePath());
 477 
 478             pb = new ProcessBuilder(commandLine);
 479             IOUtils.exec(pb, false);
 480 
 481             return finalPKG;
 482         } catch (Exception ignored) {
 483             Log.verbose(ignored);
 484             return null;
 485         }
 486     }
 487 
 488     //////////////////////////////////////////////////////////////////////////
 489     // Implement Bundler
 490     //////////////////////////////////////////////////////////////////////////
 491 
 492     @Override
 493     public String getName() {
 494         return I18N.getString("pkg.bundler.name");
 495     }
 496 
 497     @Override
 498     public String getDescription() {
 499         return I18N.getString("pkg.bundler.description");
 500     }
 501 
 502     @Override
 503     public String getID() {
 504         return "pkg";
 505     }
 506 
 507     @Override
 508     public Collection<BundlerParamInfo<?>> getBundleParameters() {
 509         Collection<BundlerParamInfo<?>> results = new LinkedHashSet<>();
 510         results.addAll(MacAppBundler.getAppBundleParameters());
 511         results.addAll(getPKGBundleParameters());
 512         return results;
 513     }
 514 
 515     public Collection<BundlerParamInfo<?>> getPKGBundleParameters() {
 516         Collection<BundlerParamInfo<?>> results = new LinkedHashSet<>();
 517 
 518         results.addAll(MacAppBundler.getAppBundleParameters());
 519         results.addAll(Arrays.asList(
 520                 DEVELOPER_ID_INSTALLER_SIGNING_KEY,
 521                 // IDENTIFIER,
 522                 INSTALLER_SUFFIX,
 523                 LICENSE_FILE,
 524                 // SERVICE_HINT,
 525                 SIGNING_KEYCHAIN));
 526 
 527         return results;
 528     }
 529 
 530     @Override
 531     public boolean validate(Map<String, ? super Object> params)
 532             throws UnsupportedPlatformException, ConfigException {
 533         try {
 534             if (params == null) throw new ConfigException(
 535                     I18N.getString("error.parameters-null"),
 536                     I18N.getString("error.parameters-null.advice"));
 537 
 538             // run basic validation to ensure requirements are met
 539             // we are not interested in return code, only possible exception
 540             validateAppImageAndBundeler(params);
 541 
 542             // reject explicitly set sign to true and no valid signature key
 543             if (Optional.ofNullable(MacAppImageBuilder.
 544                     SIGN_BUNDLE.fetchFrom(params)).orElse(Boolean.FALSE)) {
 545                 String signingIdentity =
 546                         DEVELOPER_ID_INSTALLER_SIGNING_KEY.fetchFrom(params);
 547                 if (signingIdentity == null) {
 548                     throw new ConfigException(
 549                             I18N.getString("error.explicit-sign-no-cert"),
 550                             I18N.getString(
 551                             "error.explicit-sign-no-cert.advice"));
 552                 }
 553             }
 554 
 555             // hdiutil is always available so there's no need
 556             // to test for availability.
 557 
 558             return true;
 559         } catch (RuntimeException re) {
 560             if (re.getCause() instanceof ConfigException) {
 561                 throw (ConfigException) re.getCause();
 562             } else {
 563                 throw new ConfigException(re);
 564             }
 565         }
 566     }
 567 
 568     @Override
 569     public File execute(Map<String, ? super Object> params,
 570             File outputParentDir) throws PackagerException {
 571         return bundle(params, outputParentDir);
 572     }
 573 
 574     @Override
 575     public boolean supported(boolean runtimeInstaller) {
 576         return Platform.getPlatform() == Platform.MAC;
 577     }
 578 
 579 }