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