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