1 /*
   2  * Copyright (c) 2012, 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.*;
  29 import java.nio.file.Files;
  30 import java.text.MessageFormat;
  31 import java.util.*;
  32 
  33 import static jdk.jpackage.internal.StandardBundlerParam.*;
  34 
  35 public class MacDmgBundler extends MacBaseInstallerBundler {
  36 
  37     private static final ResourceBundle I18N = ResourceBundle.getBundle(
  38             "jdk.jpackage.internal.resources.MacResources");
  39 
  40     static final String DEFAULT_BACKGROUND_IMAGE="background_dmg.png";
  41     static final String DEFAULT_DMG_SETUP_SCRIPT="DMGsetup.scpt";
  42     static final String TEMPLATE_BUNDLE_ICON = "GenericApp.icns";
  43 
  44     static final String DEFAULT_LICENSE_PLIST="lic_template.plist";
  45 
  46     public static final BundlerParamInfo<String> INSTALLER_SUFFIX =
  47             new StandardBundlerParam<> (
  48             "mac.dmg.installerName.suffix",
  49             String.class,
  50             params -> "",
  51             (s, p) -> s);
  52 
  53     public File bundle(Map<String, ? super Object> params,
  54             File outdir) throws PackagerException {
  55         Log.verbose(MessageFormat.format(I18N.getString("message.building-dmg"),
  56                 APP_NAME.fetchFrom(params)));
  57         if (!outdir.isDirectory() && !outdir.mkdirs()) {
  58             throw new PackagerException(
  59                     "error.cannot-create-output-dir",
  60                     outdir.getAbsolutePath());
  61         }
  62         if (!outdir.canWrite()) {
  63             throw new PackagerException(
  64                     "error.cannot-write-to-output-dir",
  65                     outdir.getAbsolutePath());
  66         }
  67 
  68         File appImageDir = APP_IMAGE_TEMP_ROOT.fetchFrom(params);
  69         try {
  70             appImageDir.mkdirs();
  71 
  72             if (prepareAppBundle(params, true) != null &&
  73                     prepareConfigFiles(params)) {
  74                 File configScript = getConfig_Script(params);
  75                 if (configScript.exists()) {
  76                     Log.verbose(MessageFormat.format(
  77                             I18N.getString("message.running-script"),
  78                             configScript.getAbsolutePath()));
  79                     IOUtils.run("bash", configScript);
  80                 }
  81 
  82                 return buildDMG(params, outdir);
  83             }
  84             return null;
  85         } catch (IOException ex) {
  86             Log.verbose(ex);
  87             throw new PackagerException(ex);
  88         }
  89     }
  90 
  91     private static final String hdiutil = "/usr/bin/hdiutil";
  92 
  93     private void prepareDMGSetupScript(String volumeName,
  94             Map<String, ? super Object> params) throws IOException {
  95         File dmgSetup = getConfig_VolumeScript(params);
  96         Log.verbose(MessageFormat.format(
  97                 I18N.getString("message.preparing-dmg-setup"),
  98                 dmgSetup.getAbsolutePath()));
  99 
 100         //prepare config for exe
 101         Map<String, String> data = new HashMap<>();
 102         data.put("DEPLOY_ACTUAL_VOLUME_NAME", volumeName);
 103         data.put("DEPLOY_APPLICATION_NAME", APP_NAME.fetchFrom(params));
 104 
 105         data.put("DEPLOY_INSTALL_LOCATION", "(path to desktop folder)");
 106         data.put("DEPLOY_INSTALL_NAME", "Desktop");
 107 
 108         try (Writer w = new BufferedWriter(new FileWriter(dmgSetup))) {
 109             w.write(preprocessTextResource(dmgSetup.getName(),
 110                     I18N.getString("resource.dmg-setup-script"),
 111                     DEFAULT_DMG_SETUP_SCRIPT, data, VERBOSE.fetchFrom(params),
 112                     RESOURCE_DIR.fetchFrom(params)));
 113         }
 114     }
 115 
 116     private File getConfig_VolumeScript(Map<String, ? super Object> params) {
 117         return new File(CONFIG_ROOT.fetchFrom(params),
 118                 APP_NAME.fetchFrom(params) + "-dmg-setup.scpt");
 119     }
 120 
 121     private File getConfig_VolumeBackground(
 122             Map<String, ? super Object> params) {
 123         return new File(CONFIG_ROOT.fetchFrom(params),
 124                 APP_NAME.fetchFrom(params) + "-background.png");
 125     }
 126 
 127     private File getConfig_VolumeIcon(Map<String, ? super Object> params) {
 128         return new File(CONFIG_ROOT.fetchFrom(params),
 129                 APP_NAME.fetchFrom(params) + "-volume.icns");
 130     }
 131 
 132     private File getConfig_LicenseFile(Map<String, ? super Object> params) {
 133         return new File(CONFIG_ROOT.fetchFrom(params),
 134                 APP_NAME.fetchFrom(params) + "-license.plist");
 135     }
 136 
 137     private void prepareLicense(Map<String, ? super Object> params) {
 138         try {
 139             String licFileStr = LICENSE_FILE.fetchFrom(params);
 140             if (licFileStr == null) {
 141                 return;
 142             }
 143 
 144             File licFile = new File(licFileStr);
 145             byte[] licenseContentOriginal = Files.readAllBytes(licFile.toPath());
 146             String licenseInBase64 =
 147                     Base64.getEncoder().encodeToString(licenseContentOriginal);
 148 
 149             Map<String, String> data = new HashMap<>();
 150             data.put("APPLICATION_LICENSE_TEXT", licenseInBase64);
 151 
 152             try (Writer w = new BufferedWriter(
 153                     new FileWriter(getConfig_LicenseFile(params)))) {
 154                 w.write(preprocessTextResource(
 155                         getConfig_LicenseFile(params).getName(),
 156                         I18N.getString("resource.license-setup"),
 157                         DEFAULT_LICENSE_PLIST, data, VERBOSE.fetchFrom(params),
 158                         RESOURCE_DIR.fetchFrom(params)));
 159             }
 160 
 161         } catch (IOException ex) {
 162             Log.verbose(ex);
 163         }
 164     }
 165 
 166     private boolean prepareConfigFiles(Map<String, ? super Object> params)
 167             throws IOException {
 168         File bgTarget = getConfig_VolumeBackground(params);
 169         fetchResource(bgTarget.getName(),
 170                 I18N.getString("resource.dmg-background"),
 171                 DEFAULT_BACKGROUND_IMAGE,
 172                 bgTarget,
 173                 VERBOSE.fetchFrom(params),
 174                 RESOURCE_DIR.fetchFrom(params));
 175 
 176         File iconTarget = getConfig_VolumeIcon(params);
 177         if (MacAppBundler.ICON_ICNS.fetchFrom(params) == null ||
 178                 !MacAppBundler.ICON_ICNS.fetchFrom(params).exists()) {
 179             fetchResource(iconTarget.getName(),
 180                     I18N.getString("resource.volume-icon"),
 181                     TEMPLATE_BUNDLE_ICON,
 182                     iconTarget,
 183                     VERBOSE.fetchFrom(params),
 184                     RESOURCE_DIR.fetchFrom(params));
 185         } else {
 186             fetchResource(iconTarget.getName(),
 187                     I18N.getString("resource.volume-icon"),
 188                     MacAppBundler.ICON_ICNS.fetchFrom(params),
 189                     iconTarget,
 190                     VERBOSE.fetchFrom(params),
 191                     RESOURCE_DIR.fetchFrom(params));
 192         }
 193 
 194 
 195         fetchResource(getConfig_Script(params).getName(),
 196                 I18N.getString("resource.post-install-script"),
 197                 (String) null,
 198                 getConfig_Script(params),
 199                 VERBOSE.fetchFrom(params),
 200                 RESOURCE_DIR.fetchFrom(params));
 201 
 202         prepareLicense(params);
 203 
 204         // In theory we need to extract name from results of attach command
 205         // However, this will be a problem for customization as name will
 206         // possibly change every time and developer will not be able to fix it
 207         // As we are using tmp dir chance we get "different" name are low =>
 208         // Use fixed name we used for bundle
 209         prepareDMGSetupScript(APP_NAME.fetchFrom(params), params);
 210 
 211         return true;
 212     }
 213 
 214     // name of post-image script
 215     private File getConfig_Script(Map<String, ? super Object> params) {
 216         return new File(CONFIG_ROOT.fetchFrom(params),
 217                 APP_NAME.fetchFrom(params) + "-post-image.sh");
 218     }
 219 
 220     // Location of SetFile utility may be different depending on MacOS version
 221     // We look for several known places and if none of them work will
 222     // try ot find it
 223     private String findSetFileUtility() {
 224         String typicalPaths[] = {"/Developer/Tools/SetFile",
 225                 "/usr/bin/SetFile", "/Developer/usr/bin/SetFile"};
 226 
 227         for (String path: typicalPaths) {
 228             File f = new File(path);
 229             if (f.exists() && f.canExecute()) {
 230                 return path;
 231             }
 232         }
 233 
 234         // generic find attempt
 235         try {
 236             ProcessBuilder pb = new ProcessBuilder("xcrun", "-find", "SetFile");
 237             Process p = pb.start();
 238             InputStreamReader isr = new InputStreamReader(p.getInputStream());
 239             BufferedReader br = new BufferedReader(isr);
 240             String lineRead = br.readLine();
 241             if (lineRead != null) {
 242                 File f = new File(lineRead);
 243                 if (f.exists() && f.canExecute()) {
 244                     return f.getAbsolutePath();
 245                 }
 246             }
 247         } catch (IOException ignored) {}
 248 
 249         return null;
 250     }
 251 
 252     private File buildDMG(
 253             Map<String, ? super Object> params, File outdir)
 254             throws IOException {
 255         File imagesRoot = IMAGES_ROOT.fetchFrom(params);
 256         if (!imagesRoot.exists()) imagesRoot.mkdirs();
 257 
 258         File protoDMG = new File(imagesRoot,
 259                 APP_NAME.fetchFrom(params) +"-tmp.dmg");
 260         File finalDMG = new File(outdir, INSTALLER_NAME.fetchFrom(params)
 261                 + INSTALLER_SUFFIX.fetchFrom(params) + ".dmg");
 262 
 263         File srcFolder = APP_IMAGE_TEMP_ROOT.fetchFrom(params);
 264         File predefinedImage =
 265                 StandardBundlerParam.getPredefinedAppImage(params);
 266         if (predefinedImage != null) {
 267             srcFolder = predefinedImage;
 268         }
 269 
 270         Log.verbose(MessageFormat.format(I18N.getString(
 271                 "message.creating-dmg-file"), finalDMG.getAbsolutePath()));
 272 
 273         protoDMG.delete();
 274         if (finalDMG.exists() && !finalDMG.delete()) {
 275             throw new IOException(MessageFormat.format(I18N.getString(
 276                     "message.dmg-cannot-be-overwritten"),
 277                     finalDMG.getAbsolutePath()));
 278         }
 279 
 280         protoDMG.getParentFile().mkdirs();
 281         finalDMG.getParentFile().mkdirs();
 282 
 283         String hdiUtilVerbosityFlag = Log.isDebug() ? "-verbose" : "-quiet";
 284 
 285         // create temp image
 286         ProcessBuilder pb = new ProcessBuilder(
 287                 hdiutil,
 288                 "create",
 289                 hdiUtilVerbosityFlag,
 290                 "-srcfolder", srcFolder.getAbsolutePath(),
 291                 "-volname", APP_NAME.fetchFrom(params),
 292                 "-ov", protoDMG.getAbsolutePath(),
 293                 "-fs", "HFS+",
 294                 "-format", "UDRW");
 295         IOUtils.exec(pb);
 296 
 297         // mount temp image
 298         pb = new ProcessBuilder(
 299                 hdiutil,
 300                 "attach",
 301                 protoDMG.getAbsolutePath(),
 302                 hdiUtilVerbosityFlag,
 303                 "-mountroot", imagesRoot.getAbsolutePath());
 304         IOUtils.exec(pb);
 305 
 306         File mountedRoot = new File(imagesRoot.getAbsolutePath(),
 307                 APP_NAME.fetchFrom(params));
 308 
 309         // volume icon
 310         File volumeIconFile = new File(mountedRoot, ".VolumeIcon.icns");
 311         IOUtils.copyFile(getConfig_VolumeIcon(params),
 312                 volumeIconFile);
 313 
 314         pb = new ProcessBuilder("osascript",
 315                 getConfig_VolumeScript(params).getAbsolutePath());
 316         IOUtils.exec(pb);
 317 
 318         // Indicate that we want a custom icon
 319         // NB: attributes of the root directory are ignored
 320         // when creating the volume
 321         // Therefore we have to do this after we mount image
 322         String setFileUtility = findSetFileUtility();
 323         if (setFileUtility != null) {
 324                 //can not find utility => keep going without icon
 325             try {
 326                 volumeIconFile.setWritable(true);
 327                 // The "creator" attribute on a file is a legacy attribute
 328                 // but it seems Finder excepts these bytes to be
 329                 // "icnC" for the volume icon
 330                 // http://endrift.com/blog/2010/06/14/dmg-files-volume-icons-cli
 331                 // (might not work on Mac 10.13 with old XCode)
 332                 pb = new ProcessBuilder(
 333                         setFileUtility,
 334                         "-c", "icnC",
 335                         volumeIconFile.getAbsolutePath());
 336                 IOUtils.exec(pb);
 337                 volumeIconFile.setReadOnly();
 338 
 339                 pb = new ProcessBuilder(
 340                         setFileUtility,
 341                         "-a", "C",
 342                         mountedRoot.getAbsolutePath());
 343                 IOUtils.exec(pb);
 344             } catch (IOException ex) {
 345                 Log.error(ex.getMessage());
 346                 Log.verbose("Cannot enable custom icon using SetFile utility");
 347             }
 348         } else {
 349             Log.verbose(
 350                 "Skip enabling custom icon as SetFile utility is not found");
 351         }
 352 
 353         // Detach the temporary image
 354         pb = new ProcessBuilder(
 355                 hdiutil,
 356                 "detach",
 357                 hdiUtilVerbosityFlag,
 358                 mountedRoot.getAbsolutePath());
 359         IOUtils.exec(pb);
 360 
 361         // Compress it to a new image
 362         pb = new ProcessBuilder(
 363                 hdiutil,
 364                 "convert",
 365                 protoDMG.getAbsolutePath(),
 366                 hdiUtilVerbosityFlag,
 367                 "-format", "UDZO",
 368                 "-o", finalDMG.getAbsolutePath());
 369         IOUtils.exec(pb);
 370 
 371         //add license if needed
 372         if (getConfig_LicenseFile(params).exists()) {
 373             //hdiutil unflatten your_image_file.dmg
 374             pb = new ProcessBuilder(
 375                     hdiutil,
 376                     "unflatten",
 377                     finalDMG.getAbsolutePath()
 378             );
 379             IOUtils.exec(pb);
 380 
 381             //add license
 382             pb = new ProcessBuilder(
 383                     hdiutil,
 384                     "udifrez",
 385                     finalDMG.getAbsolutePath(),
 386                     "-xml",
 387                     getConfig_LicenseFile(params).getAbsolutePath()
 388             );
 389             IOUtils.exec(pb);
 390 
 391             //hdiutil flatten your_image_file.dmg
 392             pb = new ProcessBuilder(
 393                     hdiutil,
 394                     "flatten",
 395                     finalDMG.getAbsolutePath()
 396             );
 397             IOUtils.exec(pb);
 398 
 399         }
 400 
 401         //Delete the temporary image
 402         protoDMG.delete();
 403 
 404         Log.verbose(MessageFormat.format(I18N.getString(
 405                 "message.output-to-location"),
 406                 APP_NAME.fetchFrom(params), finalDMG.getAbsolutePath()));
 407 
 408         return finalDMG;
 409     }
 410 
 411 
 412     //////////////////////////////////////////////////////////////////////////
 413     // Implement Bundler
 414     //////////////////////////////////////////////////////////////////////////
 415 
 416     @Override
 417     public String getName() {
 418         return I18N.getString("dmg.bundler.name");
 419     }
 420 
 421     @Override
 422     public String getDescription() {
 423         return I18N.getString("dmg.bundler.description");
 424     }
 425 
 426     @Override
 427     public String getID() {
 428         return "dmg";
 429     }
 430 
 431     @Override
 432     public Collection<BundlerParamInfo<?>> getBundleParameters() {
 433         Collection<BundlerParamInfo<?>> results = new LinkedHashSet<>();
 434         results.addAll(MacAppBundler.getAppBundleParameters());
 435         results.addAll(getDMGBundleParameters());
 436         return results;
 437     }
 438 
 439     public Collection<BundlerParamInfo<?>> getDMGBundleParameters() {
 440         Collection<BundlerParamInfo<?>> results = new LinkedHashSet<>();
 441 
 442         results.addAll(MacAppBundler.getAppBundleParameters());
 443         results.addAll(Arrays.asList(
 444                 INSTALLER_SUFFIX,
 445                 LICENSE_FILE
 446         ));
 447 
 448         return results;
 449     }
 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             return true;
 465         } catch (RuntimeException re) {
 466             if (re.getCause() instanceof ConfigException) {
 467                 throw (ConfigException) re.getCause();
 468             } else {
 469                 throw new ConfigException(re);
 470             }
 471         }
 472     }
 473 
 474     @Override
 475     public File execute(Map<String, ? super Object> params,
 476             File outputParentDir) throws PackagerException {
 477         return bundle(params, outputParentDir);
 478     }
 479 
 480     @Override
 481     public boolean supported(boolean runtimeInstaller) {
 482         return Platform.getPlatform() == Platform.MAC;
 483     }
 484 }