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 }