1 /*
   2  * Copyright (c) 2012, 2019, Oracle and/or its affiliates. All rights reserved.
   3  * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
   4  *
   5  * This code is free software; you can redistribute it and/or modify it
   6  * under the terms of the GNU General Public License version 2 only, as
   7  * published by the Free Software Foundation.  Oracle designates this
   8  * particular file as subject to the "Classpath" exception as provided
   9  * by Oracle in the LICENSE file that accompanied this code.
  10  *
  11  * This code is distributed in the hope that it will be useful, but WITHOUT
  12  * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
  13  * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
  14  * version 2 for more details (a copy is included in the LICENSE file that
  15  * accompanied this code).
  16  *
  17  * You should have received a copy of the GNU General Public License version
  18  * 2 along with this work; if not, write to the Free Software Foundation,
  19  * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
  20  *
  21  * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
  22  * or visit www.oracle.com if you need additional information or have any
  23  * questions.
  24  */
  25 
  26 package jdk.incubator.jpackage.internal;
  27 
  28 import java.io.*;
  29 import java.nio.file.Files;
  30 import java.text.MessageFormat;
  31 import java.util.*;
  32 import static jdk.incubator.jpackage.internal.OverridableResource.createResource;
  33 
  34 import static jdk.incubator.jpackage.internal.StandardBundlerParam.*;
  35 
  36 public class MacDmgBundler extends MacBaseInstallerBundler {
  37 
  38     private static final ResourceBundle I18N = ResourceBundle.getBundle(
  39             "jdk.incubator.jpackage.internal.resources.MacResources");
  40 
  41     static final String DEFAULT_BACKGROUND_IMAGE="background_dmg.png";
  42     static final String DEFAULT_DMG_SETUP_SCRIPT="DMGsetup.scpt";
  43     static final String TEMPLATE_BUNDLE_ICON = "java.icns";
  44 
  45     static final String DEFAULT_LICENSE_PLIST="lic_template.plist";
  46 
  47     public static final BundlerParamInfo<String> INSTALLER_SUFFIX =
  48             new StandardBundlerParam<> (
  49             "mac.dmg.installerName.suffix",
  50             String.class,
  51             params -> "",
  52             (s, p) -> s);
  53 
  54     public File bundle(Map<String, ? super Object> params,
  55             File outdir) throws PackagerException {
  56         Log.verbose(MessageFormat.format(I18N.getString("message.building-dmg"),
  57                 APP_NAME.fetchFrom(params)));
  58 
  59         IOUtils.writableOutputDir(outdir.toPath());
  60 
  61         File appImageDir = APP_IMAGE_TEMP_ROOT.fetchFrom(params);
  62         try {
  63             appImageDir.mkdirs();
  64 
  65             if (prepareAppBundle(params) != null &&
  66                     prepareConfigFiles(params)) {
  67                 File configScript = getConfig_Script(params);
  68                 if (configScript.exists()) {
  69                     Log.verbose(MessageFormat.format(
  70                             I18N.getString("message.running-script"),
  71                             configScript.getAbsolutePath()));
  72                     IOUtils.run("bash", configScript);
  73                 }
  74 
  75                 return buildDMG(params, outdir);
  76             }
  77             return null;
  78         } catch (IOException ex) {
  79             Log.verbose(ex);
  80             throw new PackagerException(ex);
  81         }
  82     }
  83 
  84     private static final String hdiutil = "/usr/bin/hdiutil";
  85 
  86     private void prepareDMGSetupScript(String volumeName,
  87             Map<String, ? super Object> params) throws IOException {
  88         File dmgSetup = getConfig_VolumeScript(params);
  89         Log.verbose(MessageFormat.format(
  90                 I18N.getString("message.preparing-dmg-setup"),
  91                 dmgSetup.getAbsolutePath()));
  92 
  93         //prepare config for exe
  94         Map<String, String> data = new HashMap<>();
  95         data.put("DEPLOY_ACTUAL_VOLUME_NAME", volumeName);
  96         data.put("DEPLOY_APPLICATION_NAME", APP_NAME.fetchFrom(params));
  97 
  98         data.put("DEPLOY_INSTALL_LOCATION", "(path to applications folder)");
  99         data.put("DEPLOY_INSTALL_NAME", "Applications");
 100 
 101         createResource(DEFAULT_DMG_SETUP_SCRIPT, params)
 102                 .setCategory(I18N.getString("resource.dmg-setup-script"))
 103                 .setSubstitutionData(data)
 104                 .saveToFile(dmgSetup);
 105     }
 106 
 107     private File getConfig_VolumeScript(Map<String, ? super Object> params) {
 108         return new File(CONFIG_ROOT.fetchFrom(params),
 109                 APP_NAME.fetchFrom(params) + "-dmg-setup.scpt");
 110     }
 111 
 112     private File getConfig_VolumeBackground(
 113             Map<String, ? super Object> params) {
 114         return new File(CONFIG_ROOT.fetchFrom(params),
 115                 APP_NAME.fetchFrom(params) + "-background.png");
 116     }
 117 
 118     private File getConfig_VolumeIcon(Map<String, ? super Object> params) {
 119         return new File(CONFIG_ROOT.fetchFrom(params),
 120                 APP_NAME.fetchFrom(params) + "-volume.icns");
 121     }
 122 
 123     private File getConfig_LicenseFile(Map<String, ? super Object> params) {
 124         return new File(CONFIG_ROOT.fetchFrom(params),
 125                 APP_NAME.fetchFrom(params) + "-license.plist");
 126     }
 127 
 128     private void prepareLicense(Map<String, ? super Object> params) {
 129         try {
 130             String licFileStr = LICENSE_FILE.fetchFrom(params);
 131             if (licFileStr == null) {
 132                 return;
 133             }
 134 
 135             File licFile = new File(licFileStr);
 136             byte[] licenseContentOriginal =
 137                     Files.readAllBytes(licFile.toPath());
 138             String licenseInBase64 =
 139                     Base64.getEncoder().encodeToString(licenseContentOriginal);
 140 
 141             Map<String, String> data = new HashMap<>();
 142             data.put("APPLICATION_LICENSE_TEXT", licenseInBase64);
 143 
 144             createResource(DEFAULT_LICENSE_PLIST, params)
 145                     .setCategory(I18N.getString("resource.license-setup"))
 146                     .setSubstitutionData(data)
 147                     .saveToFile(getConfig_LicenseFile(params));
 148 
 149         } catch (IOException ex) {
 150             Log.verbose(ex);
 151         }
 152     }
 153 
 154     private boolean prepareConfigFiles(Map<String, ? super Object> params)
 155             throws IOException {
 156 
 157         createResource(DEFAULT_BACKGROUND_IMAGE, params)
 158                     .setCategory(I18N.getString("resource.dmg-background"))
 159                     .saveToFile(getConfig_VolumeBackground(params));
 160 
 161         createResource(TEMPLATE_BUNDLE_ICON, params)
 162                 .setCategory(I18N.getString("resource.volume-icon"))
 163                 .setExternal(MacAppBundler.ICON_ICNS.fetchFrom(params))
 164                 .saveToFile(getConfig_VolumeIcon(params));
 165 
 166         createResource(null, params)
 167                 .setCategory(I18N.getString("resource.post-install-script"))
 168                 .saveToFile(getConfig_Script(params));
 169 
 170         prepareLicense(params);
 171 
 172         // In theory we need to extract name from results of attach command
 173         // However, this will be a problem for customization as name will
 174         // possibly change every time and developer will not be able to fix it
 175         // As we are using tmp dir chance we get "different" name are low =>
 176         // Use fixed name we used for bundle
 177         prepareDMGSetupScript(APP_NAME.fetchFrom(params), params);
 178 
 179         return true;
 180     }
 181 
 182     // name of post-image script
 183     private File getConfig_Script(Map<String, ? super Object> params) {
 184         return new File(CONFIG_ROOT.fetchFrom(params),
 185                 APP_NAME.fetchFrom(params) + "-post-image.sh");
 186     }
 187 
 188     // Location of SetFile utility may be different depending on MacOS version
 189     // We look for several known places and if none of them work will
 190     // try ot find it
 191     private String findSetFileUtility() {
 192         String typicalPaths[] = {"/Developer/Tools/SetFile",
 193                 "/usr/bin/SetFile", "/Developer/usr/bin/SetFile"};
 194 
 195         String setFilePath = null;
 196         for (String path: typicalPaths) {
 197             File f = new File(path);
 198             if (f.exists() && f.canExecute()) {
 199                 setFilePath = path;
 200                 break;
 201             }
 202         }
 203 
 204         // Validate SetFile, if Xcode is not installed it will run, but exit with error
 205         // code
 206         if (setFilePath != null) {
 207             try {
 208                 ProcessBuilder pb = new ProcessBuilder(setFilePath, "-h");
 209                 Process p = pb.start();
 210                 int code = p.waitFor();
 211                 if (code == 0) {
 212                     return setFilePath;
 213                 }
 214             } catch (Exception ignored) {}
 215 
 216             // No need for generic find attempt. We found it, but it does not work.
 217             // Probably due to missing xcode.
 218             return null;
 219         }
 220 
 221         // generic find attempt
 222         try {
 223             ProcessBuilder pb = new ProcessBuilder("xcrun", "-find", "SetFile");
 224             Process p = pb.start();
 225             InputStreamReader isr = new InputStreamReader(p.getInputStream());
 226             BufferedReader br = new BufferedReader(isr);
 227             String lineRead = br.readLine();
 228             if (lineRead != null) {
 229                 File f = new File(lineRead);
 230                 if (f.exists() && f.canExecute()) {
 231                     return f.getAbsolutePath();
 232                 }
 233             }
 234         } catch (IOException ignored) {}
 235 
 236         return null;
 237     }
 238 
 239     private File buildDMG(
 240             Map<String, ? super Object> params, File outdir)
 241             throws IOException {
 242         File imagesRoot = IMAGES_ROOT.fetchFrom(params);
 243         if (!imagesRoot.exists()) imagesRoot.mkdirs();
 244 
 245         File protoDMG = new File(imagesRoot,
 246                 APP_NAME.fetchFrom(params) +"-tmp.dmg");
 247         File finalDMG = new File(outdir, INSTALLER_NAME.fetchFrom(params)
 248                 + INSTALLER_SUFFIX.fetchFrom(params) + ".dmg");
 249 
 250         File srcFolder = APP_IMAGE_TEMP_ROOT.fetchFrom(params);
 251         File predefinedImage =
 252                 StandardBundlerParam.getPredefinedAppImage(params);
 253         if (predefinedImage != null) {
 254             srcFolder = predefinedImage;
 255         }
 256 
 257         Log.verbose(MessageFormat.format(I18N.getString(
 258                 "message.creating-dmg-file"), finalDMG.getAbsolutePath()));
 259 
 260         protoDMG.delete();
 261         if (finalDMG.exists() && !finalDMG.delete()) {
 262             throw new IOException(MessageFormat.format(I18N.getString(
 263                     "message.dmg-cannot-be-overwritten"),
 264                     finalDMG.getAbsolutePath()));
 265         }
 266 
 267         protoDMG.getParentFile().mkdirs();
 268         finalDMG.getParentFile().mkdirs();
 269 
 270         String hdiUtilVerbosityFlag = VERBOSE.fetchFrom(params) ?
 271                 "-verbose" : "-quiet";
 272 
 273         // create temp image
 274         ProcessBuilder pb = new ProcessBuilder(
 275                 hdiutil,
 276                 "create",
 277                 hdiUtilVerbosityFlag,
 278                 "-srcfolder", srcFolder.getAbsolutePath(),
 279                 "-volname", APP_NAME.fetchFrom(params),
 280                 "-ov", protoDMG.getAbsolutePath(),
 281                 "-fs", "HFS+",
 282                 "-format", "UDRW");
 283         IOUtils.exec(pb);
 284 
 285         // mount temp image
 286         pb = new ProcessBuilder(
 287                 hdiutil,
 288                 "attach",
 289                 protoDMG.getAbsolutePath(),
 290                 hdiUtilVerbosityFlag,
 291                 "-mountroot", imagesRoot.getAbsolutePath());
 292         IOUtils.exec(pb);
 293 
 294         File mountedRoot = new File(imagesRoot.getAbsolutePath(),
 295                     APP_NAME.fetchFrom(params));
 296 
 297         try {
 298             // volume icon
 299             File volumeIconFile = new File(mountedRoot, ".VolumeIcon.icns");
 300             IOUtils.copyFile(getConfig_VolumeIcon(params),
 301                     volumeIconFile);
 302 
 303             // background image
 304             File bgdir = new File(mountedRoot, ".background");
 305             bgdir.mkdirs();
 306             IOUtils.copyFile(getConfig_VolumeBackground(params),
 307                     new File(bgdir, "background.png"));
 308 
 309             // Indicate that we want a custom icon
 310             // NB: attributes of the root directory are ignored
 311             // when creating the volume
 312             // Therefore we have to do this after we mount image
 313             String setFileUtility = findSetFileUtility();
 314             if (setFileUtility != null) {
 315                 //can not find utility => keep going without icon
 316                 try {
 317                     volumeIconFile.setWritable(true);
 318                     // The "creator" attribute on a file is a legacy attribute
 319                     // but it seems Finder excepts these bytes to be
 320                     // "icnC" for the volume icon
 321                     // (might not work on Mac 10.13 with old XCode)
 322                     pb = new ProcessBuilder(
 323                             setFileUtility,
 324                             "-c", "icnC",
 325                             volumeIconFile.getAbsolutePath());
 326                     IOUtils.exec(pb);
 327                     volumeIconFile.setReadOnly();
 328 
 329                     pb = new ProcessBuilder(
 330                             setFileUtility,
 331                             "-a", "C",
 332                             mountedRoot.getAbsolutePath());
 333                     IOUtils.exec(pb);
 334                 } catch (IOException ex) {
 335                     Log.error(ex.getMessage());
 336                     Log.verbose("Cannot enable custom icon using SetFile utility");
 337                 }
 338             } else {
 339                 Log.verbose(I18N.getString("message.setfile.dmg"));
 340             }
 341 
 342             // We will not consider setting background image and creating link to
 343             // /Application folder in DMG as critical error, since it can fail in
 344             // headless enviroment.
 345             try {
 346                 pb = new ProcessBuilder("osascript",
 347                         getConfig_VolumeScript(params).getAbsolutePath());
 348                 IOUtils.exec(pb);
 349             } catch (IOException ex) {
 350                 Log.verbose(ex);
 351             }
 352         } finally {
 353             // Detach the temporary image
 354             pb = new ProcessBuilder(
 355                     hdiutil,
 356                     "detach",
 357                     "-force",
 358                     hdiUtilVerbosityFlag,
 359                     mountedRoot.getAbsolutePath());
 360             IOUtils.exec(pb);
 361         }
 362 
 363         // Compress it to a new image
 364         pb = new ProcessBuilder(
 365                 hdiutil,
 366                 "convert",
 367                 protoDMG.getAbsolutePath(),
 368                 hdiUtilVerbosityFlag,
 369                 "-format", "UDZO",
 370                 "-o", finalDMG.getAbsolutePath());
 371         IOUtils.exec(pb);
 372 
 373         //add license if needed
 374         if (getConfig_LicenseFile(params).exists()) {
 375             //hdiutil unflatten your_image_file.dmg
 376             pb = new ProcessBuilder(
 377                     hdiutil,
 378                     "unflatten",
 379                     finalDMG.getAbsolutePath()
 380             );
 381             IOUtils.exec(pb);
 382 
 383             //add license
 384             pb = new ProcessBuilder(
 385                     hdiutil,
 386                     "udifrez",
 387                     finalDMG.getAbsolutePath(),
 388                     "-xml",
 389                     getConfig_LicenseFile(params).getAbsolutePath()
 390             );
 391             IOUtils.exec(pb);
 392 
 393             //hdiutil flatten your_image_file.dmg
 394             pb = new ProcessBuilder(
 395                     hdiutil,
 396                     "flatten",
 397                     finalDMG.getAbsolutePath()
 398             );
 399             IOUtils.exec(pb);
 400 
 401         }
 402 
 403         //Delete the temporary image
 404         protoDMG.delete();
 405 
 406         Log.verbose(MessageFormat.format(I18N.getString(
 407                 "message.output-to-location"),
 408                 APP_NAME.fetchFrom(params), finalDMG.getAbsolutePath()));
 409 
 410         return finalDMG;
 411     }
 412 
 413 
 414     //////////////////////////////////////////////////////////////////////////
 415     // Implement Bundler
 416     //////////////////////////////////////////////////////////////////////////
 417 
 418     @Override
 419     public String getName() {
 420         return I18N.getString("dmg.bundler.name");
 421     }
 422 
 423     @Override
 424     public String getID() {
 425         return "dmg";
 426     }
 427 
 428     @Override
 429     public boolean validate(Map<String, ? super Object> params)
 430             throws ConfigException {
 431         try {
 432             Objects.requireNonNull(params);
 433 
 434             //run basic validation to ensure requirements are met
 435             //we are not interested in return code, only possible exception
 436             validateAppImageAndBundeler(params);
 437 
 438             return true;
 439         } catch (RuntimeException re) {
 440             if (re.getCause() instanceof ConfigException) {
 441                 throw (ConfigException) re.getCause();
 442             } else {
 443                 throw new ConfigException(re);
 444             }
 445         }
 446     }
 447 
 448     @Override
 449     public File execute(Map<String, ? super Object> params,
 450             File outputParentDir) throws PackagerException {
 451         return bundle(params, outputParentDir);
 452     }
 453 
 454     @Override
 455     public boolean supported(boolean runtimeInstaller) {
 456         return isSupported();
 457     }
 458 
 459     public final static String[] required =
 460             {"/usr/bin/hdiutil", "/usr/bin/osascript"};
 461     public static boolean isSupported() {
 462         try {
 463             for (String s : required) {
 464                 File f = new File(s);
 465                 if (!f.exists() || !f.canExecute()) {
 466                     return false;
 467                 }
 468             }
 469             return true;
 470         } catch (Exception e) {
 471             return false;
 472         }
 473     }
 474 
 475     @Override
 476     public boolean isDefault() {
 477         return true;
 478     }
 479 
 480 }