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