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