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