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