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