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