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, false);
  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> p) throws IOException {
  95         File dmgSetup = getConfig_VolumeScript(p);
  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(p));
 104 
 105         data.put("DEPLOY_INSTALL_LOCATION", "(path to desktop folder)");
 106         data.put("DEPLOY_INSTALL_NAME", "Desktop");
 107 
 108         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(p),
 112                 RESOURCE_DIR.fetchFrom(p)));
 113         w.close();
 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             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             w.close();
 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> p, File outdir)
 254             throws IOException {
 255         File imagesRoot = IMAGES_ROOT.fetchFrom(p);
 256         if (!imagesRoot.exists()) imagesRoot.mkdirs();
 257 
 258         File protoDMG = new File(imagesRoot, APP_NAME.fetchFrom(p) +"-tmp.dmg");
 259         File finalDMG = new File(outdir, INSTALLER_NAME.fetchFrom(p)
 260                 + INSTALLER_SUFFIX.fetchFrom(p)
 261                 + ".dmg");
 262 
 263         File srcFolder = APP_IMAGE_TEMP_ROOT.fetchFrom(p);
 264         File predefinedImage = StandardBundlerParam.getPredefinedAppImage(p);
 265         if (predefinedImage != null) {
 266             srcFolder = predefinedImage;
 267         }
 268 
 269         Log.verbose(MessageFormat.format(I18N.getString(
 270                 "message.creating-dmg-file"), finalDMG.getAbsolutePath()));
 271 
 272         protoDMG.delete();
 273         if (finalDMG.exists() && !finalDMG.delete()) {
 274             throw new IOException(MessageFormat.format(I18N.getString(
 275                     "message.dmg-cannot-be-overwritten"),
 276                     finalDMG.getAbsolutePath()));
 277         }
 278 
 279         protoDMG.getParentFile().mkdirs();
 280         finalDMG.getParentFile().mkdirs();
 281 
 282         String hdiUtilVerbosityFlag = Log.isDebug() ? "-verbose" : "-quiet";
 283 
 284         // create temp image
 285         ProcessBuilder pb = new ProcessBuilder(
 286                 hdiutil,
 287                 "create",
 288                 hdiUtilVerbosityFlag,
 289                 "-srcfolder", srcFolder.getAbsolutePath(),
 290                 "-volname", APP_NAME.fetchFrom(p),
 291                 "-ov", protoDMG.getAbsolutePath(),
 292                 "-fs", "HFS+",
 293                 "-format", "UDRW");
 294         IOUtils.exec(pb, false);
 295 
 296         // mount temp image
 297         pb = new ProcessBuilder(
 298                 hdiutil,
 299                 "attach",
 300                 protoDMG.getAbsolutePath(),
 301                 hdiUtilVerbosityFlag,
 302                 "-mountroot", imagesRoot.getAbsolutePath());
 303         IOUtils.exec(pb, false);
 304 
 305         File mountedRoot =
 306                 new File(imagesRoot.getAbsolutePath(), APP_NAME.fetchFrom(p));
 307 
 308         // volume icon
 309         File volumeIconFile = new File(mountedRoot, ".VolumeIcon.icns");
 310         IOUtils.copyFile(getConfig_VolumeIcon(p),
 311                 volumeIconFile);
 312 
 313         pb = new ProcessBuilder("osascript",
 314                 getConfig_VolumeScript(p).getAbsolutePath());
 315         IOUtils.exec(pb, false);
 316 
 317         // Indicate that we want a custom icon
 318         // NB: attributes of the root directory are ignored
 319         // when creating the volume
 320         // Therefore we have to do this after we mount image
 321         String setFileUtility = findSetFileUtility();
 322         if (setFileUtility != null) {
 323                 //can not find utility => keep going without icon
 324             try {
 325                 volumeIconFile.setWritable(true);
 326                 // The "creator" attribute on a file is a legacy attribute
 327                 // but it seems Finder excepts these bytes to be
 328                 // "icnC" for the volume icon
 329                 // http://endrift.com/blog/2010/06/14/dmg-files-volume-icons-cli
 330                 // (might not work on Mac 10.13 with old XCode)
 331                 pb = new ProcessBuilder(
 332                         setFileUtility,
 333                         "-c", "icnC",
 334                         volumeIconFile.getAbsolutePath());
 335                 IOUtils.exec(pb, false);
 336                 volumeIconFile.setReadOnly();
 337 
 338                 pb = new ProcessBuilder(
 339                         setFileUtility,
 340                         "-a", "C",
 341                         mountedRoot.getAbsolutePath());
 342                 IOUtils.exec(pb, false);
 343             } catch (IOException ex) {
 344                 Log.error(ex.getMessage());
 345                 Log.verbose("Cannot enable custom icon using SetFile utility");
 346             }
 347         } else {
 348             Log.verbose(
 349                 "Skip enabling custom icon as SetFile utility is not found");
 350         }
 351 
 352         // Detach the temporary image
 353         pb = new ProcessBuilder(
 354                 hdiutil,
 355                 "detach",
 356                 hdiUtilVerbosityFlag,
 357                 mountedRoot.getAbsolutePath());
 358         IOUtils.exec(pb, false);
 359 
 360         // Compress it to a new image
 361         pb = new ProcessBuilder(
 362                 hdiutil,
 363                 "convert",
 364                 protoDMG.getAbsolutePath(),
 365                 hdiUtilVerbosityFlag,
 366                 "-format", "UDZO",
 367                 "-o", finalDMG.getAbsolutePath());
 368         IOUtils.exec(pb, false);
 369 
 370         //add license if needed
 371         if (getConfig_LicenseFile(p).exists()) {
 372             //hdiutil unflatten your_image_file.dmg
 373             pb = new ProcessBuilder(
 374                     hdiutil,
 375                     "unflatten",
 376                     finalDMG.getAbsolutePath()
 377             );
 378             IOUtils.exec(pb, false);
 379 
 380             //add license
 381             pb = new ProcessBuilder(
 382                     hdiutil,
 383                     "udifrez",
 384                     finalDMG.getAbsolutePath(),
 385                     "-xml",
 386                     getConfig_LicenseFile(p).getAbsolutePath()
 387             );
 388             IOUtils.exec(pb, false);
 389 
 390             //hdiutil flatten your_image_file.dmg
 391             pb = new ProcessBuilder(
 392                     hdiutil,
 393                     "flatten",
 394                     finalDMG.getAbsolutePath()
 395             );
 396             IOUtils.exec(pb, false);
 397 
 398         }
 399 
 400         //Delete the temporary image
 401         protoDMG.delete();
 402 
 403         Log.verbose(MessageFormat.format(I18N.getString(
 404                 "message.output-to-location"),
 405                 APP_NAME.fetchFrom(p), finalDMG.getAbsolutePath()));
 406 
 407         return finalDMG;
 408     }
 409 
 410 
 411     //////////////////////////////////////////////////////////////////////////
 412     // Implement Bundler
 413     //////////////////////////////////////////////////////////////////////////
 414 
 415     @Override
 416     public String getName() {
 417         return I18N.getString("dmg.bundler.name");
 418     }
 419 
 420     @Override
 421     public String getDescription() {
 422         return I18N.getString("dmg.bundler.description");
 423     }
 424 
 425     @Override
 426     public String getID() {
 427         return "dmg";
 428     }
 429 
 430     @Override
 431     public Collection<BundlerParamInfo<?>> getBundleParameters() {
 432         Collection<BundlerParamInfo<?>> results = new LinkedHashSet<>();
 433         results.addAll(MacAppBundler.getAppBundleParameters());
 434         results.addAll(getDMGBundleParameters());
 435         return results;
 436     }
 437 
 438     public Collection<BundlerParamInfo<?>> getDMGBundleParameters() {
 439         Collection<BundlerParamInfo<?>> results = new LinkedHashSet<>();
 440 
 441         results.addAll(MacAppBundler.getAppBundleParameters());
 442         results.addAll(Arrays.asList(
 443                 INSTALLER_SUFFIX,
 444                 LICENSE_FILE
 445         ));
 446 
 447         return results;
 448     }
 449 
 450 
 451     @Override
 452     public boolean validate(Map<String, ? super Object> params)
 453             throws UnsupportedPlatformException, ConfigException {
 454         try {
 455             if (params == null) throw new ConfigException(
 456                     I18N.getString("error.parameters-null"),
 457                     I18N.getString("error.parameters-null.advice"));
 458 
 459             //run basic validation to ensure requirements are met
 460             //we are not interested in return code, only possible exception
 461             validateAppImageAndBundeler(params);
 462 
 463             return true;
 464         } catch (RuntimeException re) {
 465             if (re.getCause() instanceof ConfigException) {
 466                 throw (ConfigException) re.getCause();
 467             } else {
 468                 throw new ConfigException(re);
 469             }
 470         }
 471     }
 472 
 473     @Override
 474     public File execute(Map<String, ? super Object> params,
 475             File outputParentDir) throws PackagerException {
 476         return bundle(params, outputParentDir);
 477     }
 478 
 479     @Override
 480     public boolean supported(boolean runtimeInstaller) {
 481         return Platform.getPlatform() == Platform.MAC;
 482     }
 483 }