1 /*
   2  * Copyright (c) 2012, 2015, 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 package com.oracle.tools.packager.mac;
  26 
  27 import com.oracle.tools.packager.*;
  28 import com.oracle.tools.packager.IOUtils;
  29 
  30 import java.io.*;
  31 import java.text.MessageFormat;
  32 import java.util.*;
  33 
  34 import static com.oracle.tools.packager.StandardBundlerParam.*;
  35 
  36 public class MacDmgBundler extends MacBaseInstallerBundler {
  37 
  38     private static final ResourceBundle I18N =
  39             ResourceBundle.getBundle(MacDmgBundler.class.getName());
  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 = "GenericApp.icns";
  44 
  45     //existing SQE tests look for "license" string in the filenames
  46     // when they look for unauthorized license files in the build artifacts
  47     // Use different name to make them happy
  48     static final String DEFAULT_LICENSE_PLIST="lic_template.plist";
  49 
  50     public static final BundlerParamInfo<Boolean> SIMPLE_DMG = new StandardBundlerParam<>(
  51             I18N.getString("param.simple-dmg.name"),
  52             I18N.getString("param.simple-dmg.description"),
  53             "mac.dmg.simple",
  54             Boolean.class,
  55             params -> Boolean.FALSE,
  56             (s, p) -> Boolean.parseBoolean(s));
  57 
  58     public static final BundlerParamInfo<String> INSTALLER_SUFFIX = new StandardBundlerParam<> (
  59             I18N.getString("param.installer-suffix.name"),
  60             I18N.getString("param.installer-suffix.description"),
  61             "mac.dmg.installerName.suffix",
  62             String.class,
  63             params -> "",
  64             (s, p) -> s);
  65 
  66     public MacDmgBundler() {
  67         super();
  68         baseResourceLoader = MacResources.class;
  69     }
  70 
  71     //@Override
  72     public File bundle(Map<String, ? super Object> params, File outdir) {
  73         Log.info(MessageFormat.format(I18N.getString("message.building-dmg"), APP_NAME.fetchFrom(params)));
  74         if (!outdir.isDirectory() && !outdir.mkdirs()) {
  75             throw new RuntimeException(MessageFormat.format(I18N.getString("error.cannot-create-output-dir"), outdir.getAbsolutePath()));
  76         }
  77         if (!outdir.canWrite()) {
  78             throw new RuntimeException(MessageFormat.format(I18N.getString("error.cannot-write-to-output-dir"), outdir.getAbsolutePath()));
  79         }
  80 
  81         File appImageDir = APP_IMAGE_BUILD_ROOT.fetchFrom(params);
  82         try {
  83             appImageDir.mkdirs();
  84 
  85             if (prepareAppBundle(params) != null && prepareConfigFiles(params)) {
  86                 File configScript = getConfig_Script(params);
  87                 if (configScript.exists()) {
  88                     Log.info(MessageFormat.format(I18N.getString("message.running-script"), configScript.getAbsolutePath()));
  89                     IOUtils.run("bash", configScript, VERBOSE.fetchFrom(params));
  90                 }
  91 
  92                 return buildDMG(params, outdir);
  93             }
  94             return null;
  95         } catch (IOException ex) {
  96             Log.verbose(ex);
  97             return null;
  98         } finally {
  99             try {
 100                 if (appImageDir != null && !Log.isDebug()) {
 101                     IOUtils.deleteRecursive(appImageDir);
 102                 } else if (appImageDir != null) {
 103                     Log.info(MessageFormat.format(I18N.getString("message.intermediate-image-location"), appImageDir.getAbsolutePath()));
 104                 }
 105                 if (!VERBOSE.fetchFrom(params)) {
 106                     //cleanup
 107                     cleanupConfigFiles(params);
 108                 } else {
 109                     Log.info(MessageFormat.format(I18N.getString("message.config-save-location"), CONFIG_ROOT.fetchFrom(params).getAbsolutePath()));
 110                 }
 111             } catch (FileNotFoundException ex) {
 112                 Log.debug(ex);
 113                 //noinspection ReturnInsideFinallyBlock
 114                 return null;
 115             }
 116         }
 117     }
 118 
 119     //remove
 120     protected void cleanupConfigFiles(Map<String, ? super Object> params) {
 121         if (getConfig_VolumeBackground(params) != null) {
 122             getConfig_VolumeBackground(params).delete();
 123         }
 124         if (getConfig_VolumeIcon(params) != null) {
 125             getConfig_VolumeIcon(params).delete();
 126         }
 127         if (getConfig_VolumeScript(params) != null) {
 128             getConfig_VolumeScript(params).delete();
 129         }
 130         if (getConfig_Script(params) != null) {
 131             getConfig_Script(params).delete();
 132         }
 133         if (getConfig_LicenseFile(params) != null) {
 134             getConfig_LicenseFile(params).delete();
 135         }
 136         APP_BUNDLER.fetchFrom(params).cleanupConfigFiles(params);
 137     }
 138 
 139     private static final String hdiutil = "/usr/bin/hdiutil";
 140 
 141     private void prepareDMGSetupScript(String volumeName, Map<String, ? super Object> p) throws IOException {
 142         File dmgSetup = getConfig_VolumeScript(p);
 143         Log.verbose(MessageFormat.format(I18N.getString("message.preparing-dmg-setup"), dmgSetup.getAbsolutePath()));
 144 
 145         //prepare config for exe
 146         Map<String, String> data = new HashMap<>();
 147         data.put("DEPLOY_ACTUAL_VOLUME_NAME", volumeName);
 148         data.put("DEPLOY_APPLICATION_NAME", APP_NAME.fetchFrom(p));
 149 
 150         //treat default null as "system wide install"
 151         boolean systemWide = SYSTEM_WIDE.fetchFrom(p) == null || SYSTEM_WIDE.fetchFrom(p);
 152 
 153         if (systemWide) {
 154             data.put("DEPLOY_INSTALL_LOCATION", "POSIX file \"/Applications\"");
 155             data.put("DEPLOY_INSTALL_NAME", "Applications");
 156         } else {
 157             data.put("DEPLOY_INSTALL_LOCATION", "(path to desktop folder)");
 158             data.put("DEPLOY_INSTALL_NAME", "Desktop");
 159         }
 160 
 161         Writer w = new BufferedWriter(new FileWriter(dmgSetup));
 162         w.write(preprocessTextResource(
 163                 MacAppBundler.MAC_BUNDLER_PREFIX + dmgSetup.getName(),
 164                 I18N.getString("resource.dmg-setup-script"), DEFAULT_DMG_SETUP_SCRIPT, data, VERBOSE.fetchFrom(p),
 165                 DROP_IN_RESOURCES_ROOT.fetchFrom(p)));
 166         w.close();
 167     }
 168 
 169     private File getConfig_VolumeScript(Map<String, ? super Object> params) {
 170         return new File(CONFIG_ROOT.fetchFrom(params), APP_NAME.fetchFrom(params) + "-dmg-setup.scpt");
 171     }
 172 
 173     private File getConfig_VolumeBackground(Map<String, ? super Object> params) {
 174         return new File(CONFIG_ROOT.fetchFrom(params), APP_NAME.fetchFrom(params) + "-background.png");
 175     }
 176 
 177     private File getConfig_VolumeIcon(Map<String, ? super Object> params) {
 178         return new File(CONFIG_ROOT.fetchFrom(params), APP_NAME.fetchFrom(params) + "-volume.icns");
 179     }
 180 
 181     private File getConfig_LicenseFile(Map<String, ? super Object> params) {
 182         return new File(CONFIG_ROOT.fetchFrom(params), APP_NAME.fetchFrom(params) + "-license.plist");
 183     }
 184 
 185     private void prepareLicense(Map<String, ? super Object> params) {
 186         try {
 187             File licFile = null;
 188 
 189             List<String> licFiles = LICENSE_FILE.fetchFrom(params);
 190             if (licFiles.isEmpty()) {
 191                 return;
 192             }
 193             String licFileStr = licFiles.get(0);
 194 
 195             for (RelativeFileSet rfs : APP_RESOURCES_LIST.fetchFrom(params)) {
 196                 if (rfs.contains(licFileStr)) {
 197                     licFile = new File(rfs.getBaseDirectory(), licFileStr);
 198                     break;
 199                 }
 200             }
 201 
 202             if (licFile == null) {
 203                 // this is NPE protection, validate should have caught it's absence
 204                 // so we don't complain or throw an error
 205                 return;
 206             }
 207 
 208             byte[] licenseContentOriginal = IOUtils.readFully(licFile);
 209             String licenseInBase64 = Base64.getEncoder().encodeToString(licenseContentOriginal);
 210 
 211             Map<String, String> data = new HashMap<>();
 212             data.put("APPLICATION_LICENSE_TEXT", licenseInBase64);
 213 
 214             Writer w = new BufferedWriter(new FileWriter(getConfig_LicenseFile(params)));
 215             w.write(preprocessTextResource(
 216                     MacAppBundler.MAC_BUNDLER_PREFIX + getConfig_LicenseFile(params).getName(),
 217                     I18N.getString("resource.license-setup"), DEFAULT_LICENSE_PLIST, data, VERBOSE.fetchFrom(params),
 218                     DROP_IN_RESOURCES_ROOT.fetchFrom(params)));
 219             w.close();
 220 
 221         } catch (IOException ex) {
 222             Log.verbose(ex);
 223         }
 224 
 225     }
 226 
 227     private boolean prepareConfigFiles(Map<String, ? super Object> params) throws IOException {
 228         File bgTarget = getConfig_VolumeBackground(params);
 229         fetchResource(MacAppBundler.MAC_BUNDLER_PREFIX + bgTarget.getName(),
 230                 I18N.getString("resource.dmg-background"),
 231                 DEFAULT_BACKGROUND_IMAGE,
 232                 bgTarget,
 233                 VERBOSE.fetchFrom(params),
 234                 DROP_IN_RESOURCES_ROOT.fetchFrom(params));
 235 
 236         File iconTarget = getConfig_VolumeIcon(params);
 237         if (MacAppBundler.ICON_ICNS.fetchFrom(params) == null || !MacAppBundler.ICON_ICNS.fetchFrom(params).exists()) {
 238             fetchResource(MacAppBundler.MAC_BUNDLER_PREFIX + iconTarget.getName(),
 239                     I18N.getString("resource.volume-icon"),
 240                     TEMPLATE_BUNDLE_ICON,
 241                     iconTarget,
 242                     VERBOSE.fetchFrom(params),
 243                     DROP_IN_RESOURCES_ROOT.fetchFrom(params));
 244         } else {
 245             fetchResource(MacAppBundler.MAC_BUNDLER_PREFIX + iconTarget.getName(),
 246                     I18N.getString("resource.volume-icon"),
 247                     MacAppBundler.ICON_ICNS.fetchFrom(params),
 248                     iconTarget,
 249                     VERBOSE.fetchFrom(params),
 250                     DROP_IN_RESOURCES_ROOT.fetchFrom(params));
 251         }
 252 
 253 
 254         fetchResource(MacAppBundler.MAC_BUNDLER_PREFIX + getConfig_Script(params).getName(),
 255                 I18N.getString("resource.post-install-script"),
 256                 (String) null,
 257                 getConfig_Script(params),
 258                 VERBOSE.fetchFrom(params),
 259                 DROP_IN_RESOURCES_ROOT.fetchFrom(params));
 260 
 261         prepareLicense(params);
 262 
 263         //In theory we need to extract name from results of attach command
 264         //However, this will be a problem for customization as name will
 265         //possibly change every time and developer will not be able to fix it
 266         //As we are using tmp dir chance we get "different" namr are low =>
 267         //Use fixed name we used for bundle
 268         prepareDMGSetupScript(APP_NAME.fetchFrom(params), params);
 269 
 270         return true;
 271     }
 272 
 273     //name of post-image script
 274     private File getConfig_Script(Map<String, ? super Object> params) {
 275         return new File(CONFIG_ROOT.fetchFrom(params), APP_NAME.fetchFrom(params) + "-post-image.sh");
 276     }
 277 
 278     //Location of SetFile utility may be different depending on MacOS version
 279     // We look for several known places and if none of them work will
 280     // try ot find it
 281     private String findSetFileUtility() {
 282         String typicalPaths[] = {"/Developer/Tools/SetFile",
 283                 "/usr/bin/SetFile", "/Developer/usr/bin/SetFile"};
 284 
 285         for (String path: typicalPaths) {
 286             File f = new File(path);
 287             if (f.exists() && f.canExecute()) {
 288                 return path;
 289             }
 290         }
 291 
 292         //generic find attempt
 293         try {
 294             ProcessBuilder pb = new ProcessBuilder("xcrun", "-find", "SetFile");
 295             Process p = pb.start();
 296             InputStreamReader isr = new InputStreamReader(p.getInputStream());
 297             BufferedReader br = new BufferedReader(isr);
 298             String lineRead = br.readLine();
 299             if (lineRead != null) {
 300                 File f = new File(lineRead);
 301                 if (f.exists() && f.canExecute()) {
 302                     return f.getAbsolutePath();
 303                 }
 304             }
 305         } catch (IOException ignored) {}
 306 
 307         return null;
 308     }
 309 
 310     private File buildDMG(
 311             Map<String, ? super Object> p, File outdir)
 312             throws IOException {
 313         File imagesRoot = IMAGES_ROOT.fetchFrom(p);
 314         if (!imagesRoot.exists()) imagesRoot.mkdirs();
 315 
 316         File protoDMG = new File(imagesRoot, APP_NAME.fetchFrom(p) +"-tmp.dmg");
 317         File finalDMG = new File(outdir, INSTALLER_NAME.fetchFrom(p)
 318                 + INSTALLER_SUFFIX.fetchFrom(p)
 319                 + ".dmg");
 320 
 321         File srcFolder = APP_IMAGE_BUILD_ROOT.fetchFrom(p); //new File(imageDir, p.name+".app");
 322         File predefinedImage = getPredefinedImage(p);
 323         if (predefinedImage != null) {
 324             srcFolder = predefinedImage;
 325         }
 326 
 327         Log.verbose(MessageFormat.format(I18N.getString("message.creating-dmg-file"), finalDMG.getAbsolutePath()));
 328 
 329         protoDMG.delete();
 330         if (finalDMG.exists() && !finalDMG.delete()) {
 331             throw new IOException(MessageFormat.format(I18N.getString("message.dmg-cannot-be-overwritten"), finalDMG.getAbsolutePath()));
 332         }
 333 
 334         protoDMG.getParentFile().mkdirs();
 335         finalDMG.getParentFile().mkdirs();
 336 
 337         String hdiUtilVerbosityFlag = Log.isDebug() ? "-verbose" : "-quiet";
 338 
 339         //create temp image
 340         ProcessBuilder pb = new ProcessBuilder(
 341                 hdiutil,
 342                 "create",
 343                 hdiUtilVerbosityFlag,
 344                 "-srcfolder", srcFolder.getAbsolutePath(),
 345                 "-volname", APP_NAME.fetchFrom(p),
 346                 "-ov", protoDMG.getAbsolutePath(),
 347                 "-fs", "HFS+",
 348                 "-format", "UDRW");
 349         IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 350 
 351         //mount temp image
 352         pb = new ProcessBuilder(
 353                 hdiutil,
 354                 "attach",
 355                 protoDMG.getAbsolutePath(),
 356                 hdiUtilVerbosityFlag,
 357                 "-mountroot", imagesRoot.getAbsolutePath());
 358         IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 359 
 360         File mountedRoot = new File(imagesRoot.getAbsolutePath(), APP_NAME.fetchFrom(p));
 361 
 362         //volume icon
 363         File volumeIconFile = new File(mountedRoot, ".VolumeIcon.icns");
 364         IOUtils.copyFile(getConfig_VolumeIcon(p),
 365                 volumeIconFile);
 366 
 367         if (!SIMPLE_DMG.fetchFrom(p)) {
 368             //background image
 369             File bgdir = new File(mountedRoot, ".background");
 370             bgdir.mkdirs();
 371             IOUtils.copyFile(getConfig_VolumeBackground(p),
 372                     new File(bgdir, "background.png"));
 373 
 374             pb = new ProcessBuilder("osascript",
 375                     getConfig_VolumeScript(p).getAbsolutePath());
 376             IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 377         }
 378 
 379         //Indicate that we want a custom icon
 380         //NB: attributes of the root directory are ignored when creating the volume
 381         //  Therefore we have to do this after we mount image
 382         String setFileUtility = findSetFileUtility();
 383         if (setFileUtility != null) { //can not find utility => keep going without icon
 384             try {
 385                 volumeIconFile.setWritable(true);
 386                 // The "creator" attribute on a file is a legacy attribute
 387                 // but it seems Finder excepts these bytes to be "icnC" for the volume icon
 388                 // (http://endrift.com/blog/2010/06/14/dmg-files-volume-icons-cli/)
 389                 // (might not work on Mac 10.13 with old XCode)
 390                 pb = new ProcessBuilder(
 391                         setFileUtility,
 392                         "-c", "icnC",
 393                         volumeIconFile.getAbsolutePath());
 394                 IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 395                 volumeIconFile.setReadOnly();
 396 
 397                 pb = new ProcessBuilder(
 398                         setFileUtility,
 399                         "-a", "C",
 400                         mountedRoot.getAbsolutePath());
 401                 IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 402             } catch (IOException ex) {
 403                 Log.info(ex.getMessage());
 404                 Log.verbose("Cannot enable custom icon using SetFile utility");
 405             }
 406         } else {
 407             Log.verbose("Skip enabling custom icon as SetFile utility is not found");
 408         }
 409 
 410         // Detach the temporary image
 411         pb = new ProcessBuilder(
 412                 hdiutil,
 413                 "detach",
 414                 hdiUtilVerbosityFlag,
 415                 mountedRoot.getAbsolutePath());
 416         IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 417 
 418         // Compress it to a new image
 419         pb = new ProcessBuilder(
 420                 hdiutil,
 421                 "convert",
 422                 protoDMG.getAbsolutePath(),
 423                 hdiUtilVerbosityFlag,
 424                 "-format", "UDZO",
 425                 "-o", finalDMG.getAbsolutePath());
 426         IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 427 
 428         //add license if needed
 429         if (getConfig_LicenseFile(p).exists()) {
 430             //hdiutil unflatten your_image_file.dmg
 431             pb = new ProcessBuilder(
 432                     hdiutil,
 433                     "unflatten",
 434                     finalDMG.getAbsolutePath()
 435             );
 436             IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 437 
 438             //add license
 439             pb = new ProcessBuilder(
 440                     hdiutil,
 441                     "udifrez",
 442                     finalDMG.getAbsolutePath(),
 443                     "-xml",
 444                     getConfig_LicenseFile(p).getAbsolutePath()
 445             );
 446             IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 447 
 448             //hdiutil flatten your_image_file.dmg
 449             pb = new ProcessBuilder(
 450                     hdiutil,
 451                     "flatten",
 452                     finalDMG.getAbsolutePath()
 453             );
 454             IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 455 
 456         }
 457 
 458         //Delete the temporary image
 459         protoDMG.delete();
 460 
 461         Log.info(MessageFormat.format(I18N.getString("message.output-to-location"), APP_NAME.fetchFrom(p), finalDMG.getAbsolutePath()));
 462 
 463         return finalDMG;
 464     }
 465 
 466 
 467     //////////////////////////////////////////////////////////////////////////////////
 468     // Implement Bundler
 469     //////////////////////////////////////////////////////////////////////////////////
 470 
 471     @Override
 472     public String getName() {
 473         return I18N.getString("bundler.name");
 474     }
 475 
 476     @Override
 477     public String getDescription() {
 478         return I18N.getString("bundler.description");
 479     }
 480 
 481     @Override
 482     public String getID() {
 483         return "dmg";
 484     }
 485 
 486     @Override
 487     public Collection<BundlerParamInfo<?>> getBundleParameters() {
 488         Collection<BundlerParamInfo<?>> results = new LinkedHashSet<>();
 489         results.addAll(MacAppBundler.getAppBundleParameters());
 490         results.addAll(getDMGBundleParameters());
 491         return results;
 492     }
 493 
 494     public Collection<BundlerParamInfo<?>> getDMGBundleParameters() {
 495         Collection<BundlerParamInfo<?>> results = new LinkedHashSet<>();
 496 
 497         results.addAll(MacAppBundler.getAppBundleParameters());
 498         results.addAll(Arrays.asList(
 499                 INSTALLER_SUFFIX,
 500                 LICENSE_FILE,
 501                 SIMPLE_DMG,
 502                 SYSTEM_WIDE
 503         ));
 504 
 505         return results;
 506     }
 507 
 508 
 509     @Override
 510     public boolean validate(Map<String, ? super Object> params) throws UnsupportedPlatformException, ConfigException {
 511         try {
 512             if (params == null) throw new ConfigException(
 513                     I18N.getString("error.parameters-null"),
 514                     I18N.getString("error.parameters-null.advice"));
 515 
 516             //run basic validation to ensure requirements are met
 517             //we are not interested in return code, only possible exception
 518             validateAppImageAndBundeler(params);
 519 
 520             // hdiutil is always available so there's no need to test for availability.
 521             if (SERVICE_HINT.fetchFrom(params)) {
 522                 throw new ConfigException(
 523                         I18N.getString("error.dmg-does-not-do-daemons"),
 524                         I18N.getString("error.dmg-does-not-do-daemons.advice"));
 525             }
 526 
 527             // validate license file, if used, exists in the proper place
 528             if (params.containsKey(LICENSE_FILE.getID())) {
 529                 List<RelativeFileSet> appResourcesList = APP_RESOURCES_LIST.fetchFrom(params);
 530                 for (String license : LICENSE_FILE.fetchFrom(params)) {
 531                     boolean found = false;
 532                     for (RelativeFileSet appResources : appResourcesList) {
 533                         found = found || appResources.contains(license);
 534                     }
 535                     if (!found) {
 536                         throw new ConfigException(
 537                                 I18N.getString("error.license-missing"),
 538                                 MessageFormat.format(I18N.getString("error.license-missing.advice"),
 539                                         license));
 540                     }
 541                 }
 542             }
 543 
 544             return true;
 545         } catch (RuntimeException re) {
 546             if (re.getCause() instanceof ConfigException) {
 547                 throw (ConfigException) re.getCause();
 548             } else {
 549                 throw new ConfigException(re);
 550             }
 551         }
 552     }
 553 
 554     @Override
 555     public File execute(Map<String, ? super Object> params, File outputParentDir) {
 556         return bundle(params, outputParentDir);
 557     }
 558 }