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 }