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                 "-format", "UDRW");
 348         IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 349 
 350         //mount temp image
 351         pb = new ProcessBuilder(
 352                 hdiutil,
 353                 "attach",
 354                 protoDMG.getAbsolutePath(),
 355                 hdiUtilVerbosityFlag,
 356                 "-mountroot", imagesRoot.getAbsolutePath());
 357         IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 358 
 359         File mountedRoot = new File(imagesRoot.getAbsolutePath(), APP_NAME.fetchFrom(p));
 360 
 361         //volume icon
 362         File volumeIconFile = new File(mountedRoot, ".VolumeIcon.icns");
 363         IOUtils.copyFile(getConfig_VolumeIcon(p),
 364                 volumeIconFile);
 365 
 366         if (!SIMPLE_DMG.fetchFrom(p)) {
 367             //background image
 368             File bgdir = new File(mountedRoot, ".background");
 369             bgdir.mkdirs();
 370             IOUtils.copyFile(getConfig_VolumeBackground(p),
 371                     new File(bgdir, "background.png"));
 372 
 373             pb = new ProcessBuilder("osascript",
 374                     getConfig_VolumeScript(p).getAbsolutePath());
 375             IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 376         }
 377 
 378         //Indicate that we want a custom icon
 379         //NB: attributes of the root directory are ignored when creating the volume
 380         //  Therefore we have to do this after we mount image
 381         String setFileUtility = findSetFileUtility();
 382         if (setFileUtility != null) { //can not find utility => keep going without icon
 383             volumeIconFile.setWritable(true);
 384             //The “creator” attribute on a file is a legacy attribute
 385             // but it seems Finder expects these bytes to be “icnC” for the volume icon
 386             // (http://endrift.com/blog/2010/06/14/dmg-files-volume-icons-cli/)
 387             pb = new ProcessBuilder(
 388                     setFileUtility,
 389                     "-c", "icnC",
 390                     volumeIconFile.getAbsolutePath());
 391             IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 392             volumeIconFile.setReadOnly();
 393 
 394             pb = new ProcessBuilder(
 395                     setFileUtility,
 396                     "-a", "C",
 397                     mountedRoot.getAbsolutePath());
 398             IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 399         } else {
 400             Log.verbose("Skip enabling custom icon as SetFile utility is not found");
 401         }
 402 
 403         // Detach the temporary image
 404         pb = new ProcessBuilder(
 405                 hdiutil,
 406                 "detach",
 407                 hdiUtilVerbosityFlag,
 408                 mountedRoot.getAbsolutePath());
 409         IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 410 
 411         // Compress it to a new image
 412         pb = new ProcessBuilder(
 413                 hdiutil,
 414                 "convert",
 415                 protoDMG.getAbsolutePath(),
 416                 hdiUtilVerbosityFlag,
 417                 "-format", "UDZO",
 418                 "-o", finalDMG.getAbsolutePath());
 419         IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 420 
 421         //add license if needed
 422         if (getConfig_LicenseFile(p).exists()) {
 423             //hdiutil unflatten your_image_file.dmg
 424             pb = new ProcessBuilder(
 425                     hdiutil,
 426                     "unflatten",
 427                     finalDMG.getAbsolutePath()
 428             );
 429             IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 430 
 431             //add license
 432             pb = new ProcessBuilder(
 433                     hdiutil,
 434                     "udifrez",
 435                     finalDMG.getAbsolutePath(),
 436                     "-xml",
 437                     getConfig_LicenseFile(p).getAbsolutePath()
 438             );
 439             IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 440 
 441             //hdiutil flatten your_image_file.dmg
 442             pb = new ProcessBuilder(
 443                     hdiutil,
 444                     "flatten",
 445                     finalDMG.getAbsolutePath()
 446             );
 447             IOUtils.exec(pb, VERBOSE.fetchFrom(p));
 448 
 449         }
 450 
 451         //Delete the temporary image
 452         protoDMG.delete();
 453 
 454         Log.info(MessageFormat.format(I18N.getString("message.output-to-location"), APP_NAME.fetchFrom(p), finalDMG.getAbsolutePath()));
 455 
 456         return finalDMG;
 457     }
 458 
 459 
 460     //////////////////////////////////////////////////////////////////////////////////
 461     // Implement Bundler
 462     //////////////////////////////////////////////////////////////////////////////////
 463 
 464     @Override
 465     public String getName() {
 466         return I18N.getString("bundler.name");
 467     }
 468 
 469     @Override
 470     public String getDescription() {
 471         return I18N.getString("bundler.description");
 472     }
 473 
 474     @Override
 475     public String getID() {
 476         return "dmg";
 477     }
 478 
 479     @Override
 480     public Collection<BundlerParamInfo<?>> getBundleParameters() {
 481         Collection<BundlerParamInfo<?>> results = new LinkedHashSet<>();
 482         results.addAll(MacAppBundler.getAppBundleParameters());
 483         results.addAll(getDMGBundleParameters());
 484         return results;
 485     }
 486 
 487     public Collection<BundlerParamInfo<?>> getDMGBundleParameters() {
 488         Collection<BundlerParamInfo<?>> results = new LinkedHashSet<>();
 489 
 490         results.addAll(MacAppBundler.getAppBundleParameters());
 491         results.addAll(Arrays.asList(
 492                 INSTALLER_SUFFIX,
 493                 LICENSE_FILE,
 494                 SIMPLE_DMG,
 495                 SYSTEM_WIDE
 496         ));
 497 
 498         return results;
 499     }
 500 
 501 
 502     @Override
 503     public boolean validate(Map<String, ? super Object> params) throws UnsupportedPlatformException, ConfigException {
 504         try {
 505             if (params == null) throw new ConfigException(
 506                     I18N.getString("error.parameters-null"),
 507                     I18N.getString("error.parameters-null.advice"));
 508 
 509             //run basic validation to ensure requirements are met
 510             //we are not interested in return code, only possible exception
 511             validateAppImageAndBundeler(params);
 512 
 513             // hdiutil is always available so there's no need to test for availability.
 514             if (SERVICE_HINT.fetchFrom(params)) {
 515                 throw new ConfigException(
 516                         I18N.getString("error.dmg-does-not-do-daemons"),
 517                         I18N.getString("error.dmg-does-not-do-daemons.advice"));
 518             }
 519 
 520             // validate license file, if used, exists in the proper place
 521             if (params.containsKey(LICENSE_FILE.getID())) {
 522                 List<RelativeFileSet> appResourcesList = APP_RESOURCES_LIST.fetchFrom(params);
 523                 for (String license : LICENSE_FILE.fetchFrom(params)) {
 524                     boolean found = false;
 525                     for (RelativeFileSet appResources : appResourcesList) {
 526                         found = found || appResources.contains(license);
 527                     }
 528                     if (!found) {
 529                         throw new ConfigException(
 530                                 I18N.getString("error.license-missing"),
 531                                 MessageFormat.format(I18N.getString("error.license-missing.advice"),
 532                                         license));
 533                     }
 534                 }
 535             }
 536 
 537             return true;
 538         } catch (RuntimeException re) {
 539             if (re.getCause() instanceof ConfigException) {
 540                 throw (ConfigException) re.getCause();
 541             } else {
 542                 throw new ConfigException(re);
 543             }
 544         }
 545     }
 546 
 547     @Override
 548     public File execute(Map<String, ? super Object> params, File outputParentDir) {
 549         return bundle(params, outputParentDir);
 550     }
 551 }