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