1 /*
   2  * Copyright (c) 2012, 2020, 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.incubator.jpackage.internal;
  27 
  28 import java.io.BufferedReader;
  29 import java.io.File;
  30 import java.io.IOException;
  31 import java.io.InputStreamReader;
  32 import java.nio.file.Files;
  33 import java.nio.file.Path;
  34 import java.text.MessageFormat;
  35 import java.util.Base64;
  36 import java.util.HashMap;
  37 import java.util.Map;
  38 import java.util.Objects;
  39 import java.util.ResourceBundle;
  40 import static jdk.incubator.jpackage.internal.MacAppImageBuilder.ICON_ICNS;
  41 import static jdk.incubator.jpackage.internal.MacAppImageBuilder.MAC_CF_BUNDLE_IDENTIFIER;
  42 import static jdk.incubator.jpackage.internal.OverridableResource.createResource;
  43 
  44 import static jdk.incubator.jpackage.internal.StandardBundlerParam.APP_NAME;
  45 import static jdk.incubator.jpackage.internal.StandardBundlerParam.CONFIG_ROOT;
  46 import static jdk.incubator.jpackage.internal.StandardBundlerParam.LICENSE_FILE;
  47 import static jdk.incubator.jpackage.internal.StandardBundlerParam.TEMP_ROOT;
  48 import static jdk.incubator.jpackage.internal.StandardBundlerParam.VERBOSE;
  49 
  50 public class MacDmgBundler extends MacBaseInstallerBundler {
  51 
  52     private static final ResourceBundle I18N = ResourceBundle.getBundle(
  53             "jdk.incubator.jpackage.internal.resources.MacResources");
  54 
  55     // Background image name in resources
  56     static final String DEFAULT_BACKGROUND_IMAGE = "background_dmg.tiff";
  57     // Backround image name and folder under which it will be stored in DMG
  58     static final String BACKGROUND_IMAGE_FOLDER =".background";
  59     static final String BACKGROUND_IMAGE = "background.tiff";
  60     static final String DEFAULT_DMG_SETUP_SCRIPT = "DMGsetup.scpt";
  61     static final String TEMPLATE_BUNDLE_ICON = "java.icns";
  62 
  63     static final String DEFAULT_LICENSE_PLIST="lic_template.plist";
  64 
  65     public static final BundlerParamInfo<String> INSTALLER_SUFFIX =
  66             new StandardBundlerParam<> (
  67             "mac.dmg.installerName.suffix",
  68             String.class,
  69             params -> "",
  70             (s, p) -> s);
  71 
  72     public Path bundle(Map<String, ? super Object> params,
  73             Path outdir) throws PackagerException {
  74         Log.verbose(MessageFormat.format(I18N.getString("message.building-dmg"),
  75                 APP_NAME.fetchFrom(params)));
  76 
  77         IOUtils.writableOutputDir(outdir);
  78 
  79         try {
  80             Path appLocation = prepareAppBundle(params);
  81 
  82             if (appLocation != null && prepareConfigFiles(params)) {
  83                 Path configScript = getConfig_Script(params);
  84                 if (IOUtils.exists(configScript)) {
  85                     Log.verbose(MessageFormat.format(
  86                             I18N.getString("message.running-script"),
  87                             configScript.toAbsolutePath().toString()));
  88                     IOUtils.run("bash", configScript);
  89                 }
  90 
  91                 return buildDMG(params, appLocation, outdir);
  92             }
  93             return null;
  94         } catch (IOException | PackagerException ex) {
  95             Log.verbose(ex);
  96             throw new PackagerException(ex);
  97         }
  98     }
  99 
 100     private static final String hdiutil = "/usr/bin/hdiutil";
 101 
 102     private void prepareDMGSetupScript(Map<String, ? super Object> params)
 103                                                                     throws IOException {
 104         Path dmgSetup = getConfig_VolumeScript(params);
 105         Log.verbose(MessageFormat.format(
 106                 I18N.getString("message.preparing-dmg-setup"),
 107                 dmgSetup.toAbsolutePath().toString()));
 108 
 109         // We need to use URL for DMG to find it. We cannot use volume name, since
 110         // user might have open DMG with same volume name already. Url should end with
 111         // '/' and it should be real path (no symbolic links).
 112         Path imageDir = IMAGES_ROOT.fetchFrom(params);
 113         if (!Files.exists(imageDir)) {
 114              // Create it, since it does not exist
 115              Files.createDirectories(imageDir);
 116         }
 117         Path rootPath = Path.of(imageDir.toString()).toRealPath();
 118         Path volumePath = rootPath.resolve(APP_NAME.fetchFrom(params));
 119         String volumeUrl = volumePath.toUri().toString() + File.separator;
 120 
 121         // Provide full path to backround image, so we can find it.
 122         Path bgFile = Path.of(rootPath.toString(), APP_NAME.fetchFrom(params),
 123                               BACKGROUND_IMAGE_FOLDER, BACKGROUND_IMAGE);
 124 
 125         //prepare config for exe
 126         Map<String, String> data = new HashMap<>();
 127         data.put("DEPLOY_VOLUME_URL", volumeUrl);
 128         data.put("DEPLOY_BG_FILE", bgFile.toString());
 129         data.put("DEPLOY_VOLUME_PATH", volumePath.toString());
 130         data.put("DEPLOY_APPLICATION_NAME", APP_NAME.fetchFrom(params));
 131 
 132         data.put("DEPLOY_INSTALL_LOCATION", getInstallDir(params));
 133 
 134         createResource(DEFAULT_DMG_SETUP_SCRIPT, params)
 135                 .setCategory(I18N.getString("resource.dmg-setup-script"))
 136                 .setSubstitutionData(data)
 137                 .saveToFile(dmgSetup);
 138     }
 139 
 140     private Path getConfig_VolumeScript(Map<String, ? super Object> params) {
 141         return CONFIG_ROOT.fetchFrom(params).resolve(
 142                 APP_NAME.fetchFrom(params) + "-dmg-setup.scpt");
 143     }
 144 
 145     private Path getConfig_VolumeBackground(
 146             Map<String, ? super Object> params) {
 147         return CONFIG_ROOT.fetchFrom(params).resolve(
 148                 APP_NAME.fetchFrom(params) + "-background.tiff");
 149     }
 150 
 151     private Path getConfig_VolumeIcon(Map<String, ? super Object> params) {
 152         return CONFIG_ROOT.fetchFrom(params).resolve(
 153                 APP_NAME.fetchFrom(params) + "-volume.icns");
 154     }
 155 
 156     private Path getConfig_LicenseFile(Map<String, ? super Object> params) {
 157         return CONFIG_ROOT.fetchFrom(params).resolve(
 158                 APP_NAME.fetchFrom(params) + "-license.plist");
 159     }
 160 
 161     private void prepareLicense(Map<String, ? super Object> params) {
 162         try {
 163             String licFileStr = LICENSE_FILE.fetchFrom(params);
 164             if (licFileStr == null) {
 165                 return;
 166             }
 167 
 168             Path licFile = Path.of(licFileStr);
 169             byte[] licenseContentOriginal =
 170                     Files.readAllBytes(licFile);
 171             String licenseInBase64 =
 172                     Base64.getEncoder().encodeToString(licenseContentOriginal);
 173 
 174             Map<String, String> data = new HashMap<>();
 175             data.put("APPLICATION_LICENSE_TEXT", licenseInBase64);
 176 
 177             createResource(DEFAULT_LICENSE_PLIST, params)
 178                     .setCategory(I18N.getString("resource.license-setup"))
 179                     .setSubstitutionData(data)
 180                     .saveToFile(getConfig_LicenseFile(params));
 181 
 182         } catch (IOException ex) {
 183             Log.verbose(ex);
 184         }
 185     }
 186 
 187     private boolean prepareConfigFiles(Map<String, ? super Object> params)
 188             throws IOException {
 189 
 190         createResource(DEFAULT_BACKGROUND_IMAGE, params)
 191                     .setCategory(I18N.getString("resource.dmg-background"))
 192                     .saveToFile(getConfig_VolumeBackground(params));
 193 
 194         createResource(TEMPLATE_BUNDLE_ICON, params)
 195                 .setCategory(I18N.getString("resource.volume-icon"))
 196                 .setExternal(ICON_ICNS.fetchFrom(params))
 197                 .saveToFile(getConfig_VolumeIcon(params));
 198 
 199         createResource(null, params)
 200                 .setCategory(I18N.getString("resource.post-install-script"))
 201                 .saveToFile(getConfig_Script(params));
 202 
 203         prepareLicense(params);
 204 
 205         prepareDMGSetupScript(params);
 206 
 207         return true;
 208     }
 209 
 210     // name of post-image script
 211     private Path getConfig_Script(Map<String, ? super Object> params) {
 212         return CONFIG_ROOT.fetchFrom(params).resolve(
 213                 APP_NAME.fetchFrom(params) + "-post-image.sh");
 214     }
 215 
 216     // Location of SetFile utility may be different depending on MacOS version
 217     // We look for several known places and if none of them work will
 218     // try ot find it
 219     private String findSetFileUtility() {
 220         String typicalPaths[] = {"/Developer/Tools/SetFile",
 221                 "/usr/bin/SetFile", "/Developer/usr/bin/SetFile"};
 222 
 223         String setFilePath = null;
 224         for (String path : typicalPaths) {
 225             Path f = Path.of(path);
 226             if (Files.exists(f) && Files.isExecutable(f)) {
 227                 setFilePath = path;
 228                 break;
 229             }
 230         }
 231 
 232         // Validate SetFile, if Xcode is not installed it will run, but exit with error
 233         // code
 234         if (setFilePath != null) {
 235             try {
 236                 ProcessBuilder pb = new ProcessBuilder(setFilePath, "-h");
 237                 Process p = pb.start();
 238                 int code = p.waitFor();
 239                 if (code == 0) {
 240                     return setFilePath;
 241                 }
 242             } catch (Exception ignored) {}
 243 
 244             // No need for generic find attempt. We found it, but it does not work.
 245             // Probably due to missing xcode.
 246             return null;
 247         }
 248 
 249         // generic find attempt
 250         try {
 251             ProcessBuilder pb = new ProcessBuilder("xcrun", "-find", "SetFile");
 252             Process p = pb.start();
 253             InputStreamReader isr = new InputStreamReader(p.getInputStream());
 254             BufferedReader br = new BufferedReader(isr);
 255             String lineRead = br.readLine();
 256             if (lineRead != null) {
 257                 Path f = Path.of(lineRead);
 258                 if (Files.exists(f) && Files.isExecutable(f)) {
 259                     return f.toAbsolutePath().toString();
 260                 }
 261             }
 262         } catch (IOException ignored) {}
 263 
 264         return null;
 265     }
 266 
 267     private Path buildDMG( Map<String, ? super Object> params,
 268             Path appLocation, Path outdir) throws IOException {
 269         boolean copyAppImage = false;
 270         Path imagesRoot = IMAGES_ROOT.fetchFrom(params);
 271         if (!Files.exists(imagesRoot)) {
 272             Files.createDirectories(imagesRoot);
 273         }
 274 
 275         Path protoDMG = imagesRoot.resolve(APP_NAME.fetchFrom(params) +"-tmp.dmg");
 276         Path finalDMG = outdir.resolve(INSTALLER_NAME.fetchFrom(params)
 277                 + INSTALLER_SUFFIX.fetchFrom(params) + ".dmg");
 278 
 279         Path srcFolder = APP_IMAGE_TEMP_ROOT.fetchFrom(params);
 280         Path predefinedImage = StandardBundlerParam.getPredefinedAppImage(params);
 281         if (predefinedImage != null) {
 282             srcFolder = predefinedImage;
 283         } else if (StandardBundlerParam.isRuntimeInstaller(params)) {
 284             Path newRoot = Files.createTempDirectory(TEMP_ROOT.fetchFrom(params),
 285                     "root-");
 286 
 287             // first, is this already a runtime with
 288             // <runtime>/Contents/Home - if so we need the Home dir
 289             Path home = appLocation.resolve("Contents/Home");
 290             Path source = (Files.exists(home)) ? home : appLocation;
 291 
 292             // Then we need to put back the <NAME>/Content/Home
 293             Path root = newRoot.resolve(
 294                     MAC_CF_BUNDLE_IDENTIFIER.fetchFrom(params));
 295             Path dest = root.resolve("Contents/Home");
 296 
 297             IOUtils.copyRecursive(source, dest);
 298 
 299             srcFolder = newRoot;
 300         }
 301 
 302         Log.verbose(MessageFormat.format(I18N.getString(
 303                 "message.creating-dmg-file"), finalDMG.toAbsolutePath()));
 304 
 305         Files.deleteIfExists(protoDMG);
 306         try {
 307             Files.deleteIfExists(finalDMG);
 308         } catch (IOException ex) {
 309             throw new IOException(MessageFormat.format(I18N.getString(
 310                     "message.dmg-cannot-be-overwritten"),
 311                     finalDMG.toAbsolutePath()));
 312         }
 313 
 314         Files.createDirectories(protoDMG.getParent());
 315         Files.createDirectories(finalDMG.getParent());
 316 
 317         String hdiUtilVerbosityFlag = VERBOSE.fetchFrom(params) ?
 318                 "-verbose" : "-quiet";
 319 
 320         // create temp image
 321         ProcessBuilder pb = new ProcessBuilder(
 322                 hdiutil,
 323                 "create",
 324                 hdiUtilVerbosityFlag,
 325                 "-srcfolder", srcFolder.toAbsolutePath().toString(),
 326                 "-volname", APP_NAME.fetchFrom(params),
 327                 "-ov", protoDMG.toAbsolutePath().toString(),
 328                 "-fs", "HFS+",
 329                 "-format", "UDRW");
 330         try {
 331             IOUtils.exec(pb);
 332         } catch (IOException ex) {
 333             Log.verbose(ex); // Log exception
 334 
 335             // Creating DMG from entire app image failed, so lets try to create empty
 336             // DMG and copy files manually. See JDK-8248059.
 337             copyAppImage = true;
 338 
 339             long size = new PathGroup(Map.of(new Object(), srcFolder)).sizeInBytes();
 340             size += 50 * 1024 * 1024; // Add extra 50 megabytes. Actually DMG size will
 341             // not be bigger, but it will able to hold additional 50 megabytes of data.
 342             // We need extra room for icons and background image. When we providing
 343             // actual files to hdiutil, it will create DMG with ~50 megabytes extra room.
 344             pb = new ProcessBuilder(
 345                 hdiutil,
 346                 "create",
 347                 hdiUtilVerbosityFlag,
 348                 "-size", String.valueOf(size),
 349                 "-volname", APP_NAME.fetchFrom(params),
 350                 "-ov", protoDMG.toAbsolutePath().toString(),
 351                 "-fs", "HFS+");
 352             IOUtils.exec(pb);
 353         }
 354 
 355         // mount temp image
 356         pb = new ProcessBuilder(
 357                 hdiutil,
 358                 "attach",
 359                 protoDMG.toAbsolutePath().toString(),
 360                 hdiUtilVerbosityFlag,
 361                 "-mountroot", imagesRoot.toAbsolutePath().toString());
 362         IOUtils.exec(pb, false, null, true, Executor.INFINITE_TIMEOUT);
 363 
 364         Path mountedRoot = imagesRoot.resolve(APP_NAME.fetchFrom(params));
 365 
 366         // Copy app image, since we did not create DMG with it, but instead we created
 367         // empty one.
 368         if (copyAppImage) {
 369             // In case of predefine app image srcFolder will point to app bundle, so if
 370             // we use it as is we will copy content of app bundle, but we need app bundle
 371             // folder as well.
 372             if (srcFolder.toString().toLowerCase().endsWith(".app")) {
 373                 Path destPath = mountedRoot
 374                         .resolve(srcFolder.getFileName());
 375                 Files.createDirectory(destPath);
 376                 IOUtils.copyRecursive(srcFolder, destPath);
 377             } else {
 378                 IOUtils.copyRecursive(srcFolder, mountedRoot);
 379             }
 380         }
 381 
 382         try {
 383             // background image
 384             Path bgdir = mountedRoot.resolve(BACKGROUND_IMAGE_FOLDER);
 385             Files.createDirectories(bgdir);
 386             IOUtils.copyFile(getConfig_VolumeBackground(params),
 387                     bgdir.resolve(BACKGROUND_IMAGE));
 388 
 389             // We will not consider setting background image and creating link
 390             // to install-dir in DMG as critical error, since it can fail in
 391             // headless enviroment.
 392             try {
 393                 pb = new ProcessBuilder("osascript",
 394                         getConfig_VolumeScript(params).toAbsolutePath().toString());
 395                 IOUtils.exec(pb, 180); // Wait 3 minutes. See JDK-8248248.
 396             } catch (IOException ex) {
 397                 Log.verbose(ex);
 398             }
 399 
 400             // volume icon
 401             Path volumeIconFile = mountedRoot.resolve(".VolumeIcon.icns");
 402             IOUtils.copyFile(getConfig_VolumeIcon(params),
 403                     volumeIconFile);
 404 
 405             // Indicate that we want a custom icon
 406             // NB: attributes of the root directory are ignored
 407             // when creating the volume
 408             // Therefore we have to do this after we mount image
 409             String setFileUtility = findSetFileUtility();
 410             if (setFileUtility != null) {
 411                 //can not find utility => keep going without icon
 412                 try {
 413                     volumeIconFile.toFile().setWritable(true);
 414                     // The "creator" attribute on a file is a legacy attribute
 415                     // but it seems Finder excepts these bytes to be
 416                     // "icnC" for the volume icon
 417                     // (might not work on Mac 10.13 with old XCode)
 418                     pb = new ProcessBuilder(
 419                             setFileUtility,
 420                             "-c", "icnC",
 421                             volumeIconFile.toAbsolutePath().toString());
 422                     IOUtils.exec(pb);
 423                     volumeIconFile.toFile().setReadOnly();
 424 
 425                     pb = new ProcessBuilder(
 426                             setFileUtility,
 427                             "-a", "C",
 428                             mountedRoot.toAbsolutePath().toString());
 429                     IOUtils.exec(pb);
 430                 } catch (IOException ex) {
 431                     Log.error(ex.getMessage());
 432                     Log.verbose("Cannot enable custom icon using SetFile utility");
 433                 }
 434             } else {
 435                 Log.verbose(I18N.getString("message.setfile.dmg"));
 436             }
 437 
 438         } finally {
 439             // Detach the temporary image
 440             pb = new ProcessBuilder(
 441                     hdiutil,
 442                     "detach",
 443                     "-force",
 444                     hdiUtilVerbosityFlag,
 445                     mountedRoot.toAbsolutePath().toString());
 446             // "hdiutil detach" might not work right away due to resource busy error, so
 447             // repeat detach several times.
 448             RetryExecutor retryExecutor = new RetryExecutor();
 449             // 10 times with 3 second delays.
 450             retryExecutor.setMaxAttemptsCount(10).setAttemptTimeoutMillis(3000)
 451                     .execute(pb);
 452         }
 453 
 454         // Compress it to a new image
 455         pb = new ProcessBuilder(
 456                 hdiutil,
 457                 "convert",
 458                 protoDMG.toAbsolutePath().toString(),
 459                 hdiUtilVerbosityFlag,
 460                 "-format", "UDZO",
 461                 "-o", finalDMG.toAbsolutePath().toString());
 462         IOUtils.exec(pb);
 463 
 464         //add license if needed
 465         if (Files.exists(getConfig_LicenseFile(params))) {
 466             //hdiutil unflatten your_image_file.dmg
 467             pb = new ProcessBuilder(
 468                     hdiutil,
 469                     "unflatten",
 470                     finalDMG.toAbsolutePath().toString()
 471             );
 472             IOUtils.exec(pb);
 473 
 474             //add license
 475             pb = new ProcessBuilder(
 476                     hdiutil,
 477                     "udifrez",
 478                     finalDMG.toAbsolutePath().toString(),
 479                     "-xml",
 480                     getConfig_LicenseFile(params).toAbsolutePath().toString()
 481             );
 482             IOUtils.exec(pb);
 483 
 484             //hdiutil flatten your_image_file.dmg
 485             pb = new ProcessBuilder(
 486                     hdiutil,
 487                     "flatten",
 488                     finalDMG.toAbsolutePath().toString()
 489             );
 490             IOUtils.exec(pb);
 491 
 492         }
 493 
 494         //Delete the temporary image
 495         Files.deleteIfExists(protoDMG);
 496 
 497         Log.verbose(MessageFormat.format(I18N.getString(
 498                 "message.output-to-location"),
 499                 APP_NAME.fetchFrom(params), finalDMG.toAbsolutePath().toString()));
 500 
 501         return finalDMG;
 502     }
 503 
 504 
 505     //////////////////////////////////////////////////////////////////////////
 506     // Implement Bundler
 507     //////////////////////////////////////////////////////////////////////////
 508 
 509     @Override
 510     public String getName() {
 511         return I18N.getString("dmg.bundler.name");
 512     }
 513 
 514     @Override
 515     public String getID() {
 516         return "dmg";
 517     }
 518 
 519     @Override
 520     public boolean validate(Map<String, ? super Object> params)
 521             throws ConfigException {
 522         try {
 523             Objects.requireNonNull(params);
 524 
 525             //run basic validation to ensure requirements are met
 526             //we are not interested in return code, only possible exception
 527             validateAppImageAndBundeler(params);
 528 
 529             return true;
 530         } catch (RuntimeException re) {
 531             if (re.getCause() instanceof ConfigException) {
 532                 throw (ConfigException) re.getCause();
 533             } else {
 534                 throw new ConfigException(re);
 535             }
 536         }
 537     }
 538 
 539     @Override
 540     public Path execute(Map<String, ? super Object> params,
 541             Path outputParentDir) throws PackagerException {
 542         return bundle(params, outputParentDir);
 543     }
 544 
 545     @Override
 546     public boolean supported(boolean runtimeInstaller) {
 547         return isSupported();
 548     }
 549 
 550     public final static String[] required =
 551             {"/usr/bin/hdiutil", "/usr/bin/osascript"};
 552     public static boolean isSupported() {
 553         try {
 554             for (String s : required) {
 555                 Path f = Path.of(s);
 556                 if (!Files.exists(f) || !Files.isExecutable(f)) {
 557                     return false;
 558                 }
 559             }
 560             return true;
 561         } catch (Exception e) {
 562             return false;
 563         }
 564     }
 565 
 566     @Override
 567     public boolean isDefault() {
 568         return true;
 569     }
 570 }