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