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