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