1 /* 2 * Copyright (c) 2012, 2020, 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.incubator.jpackage.internal; 27 28 import java.io.*; 29 import java.nio.file.Files; 30 import java.nio.file.Path; 31 import java.text.MessageFormat; 32 import java.util.*; 33 import static jdk.incubator.jpackage.internal.MacAppImageBuilder.ICON_ICNS; 34 import static jdk.incubator.jpackage.internal.MacAppImageBuilder.MAC_CF_BUNDLE_IDENTIFIER; 35 import static jdk.incubator.jpackage.internal.OverridableResource.createResource; 36 37 import static jdk.incubator.jpackage.internal.StandardBundlerParam.*; 38 39 public class MacDmgBundler extends MacBaseInstallerBundler { 40 41 private static final ResourceBundle I18N = ResourceBundle.getBundle( 42 "jdk.incubator.jpackage.internal.resources.MacResources"); 43 44 // Background image name in resources 45 static final String DEFAULT_BACKGROUND_IMAGE = "background_dmg.tiff"; 46 // Backround image name and folder under which it will be stored in DMG 47 static final String BACKGROUND_IMAGE_FOLDER =".background"; 48 static final String BACKGROUND_IMAGE = "background.tiff"; 49 static final String DEFAULT_DMG_SETUP_SCRIPT = "DMGsetup.scpt"; 50 static final String TEMPLATE_BUNDLE_ICON = "java.icns"; 51 52 static final String DEFAULT_LICENSE_PLIST="lic_template.plist"; 53 54 public static final BundlerParamInfo<String> INSTALLER_SUFFIX = 55 new StandardBundlerParam<> ( 56 "mac.dmg.installerName.suffix", 57 String.class, 58 params -> "", 59 (s, p) -> s); 60 61 public File bundle(Map<String, ? super Object> params, 62 File outdir) throws PackagerException { 63 Log.verbose(MessageFormat.format(I18N.getString("message.building-dmg"), 64 APP_NAME.fetchFrom(params))); 65 66 IOUtils.writableOutputDir(outdir.toPath()); 67 68 File appImageDir = APP_IMAGE_TEMP_ROOT.fetchFrom(params); 69 try { 70 appImageDir.mkdirs(); 71 72 if (prepareAppBundle(params) != null && 73 prepareConfigFiles(params)) { 74 File configScript = getConfig_Script(params); 75 if (configScript.exists()) { 76 Log.verbose(MessageFormat.format( 77 I18N.getString("message.running-script"), 78 configScript.getAbsolutePath())); 79 IOUtils.run("bash", configScript); 80 } 81 82 return buildDMG(params, outdir); 83 } 84 return null; 85 } catch (IOException ex) { 86 Log.verbose(ex); 87 throw new PackagerException(ex); 88 } 89 } 90 91 private static final String hdiutil = "/usr/bin/hdiutil"; 92 93 private void prepareDMGSetupScript(Map<String, ? super Object> params) 94 throws IOException { 95 File dmgSetup = getConfig_VolumeScript(params); 96 Log.verbose(MessageFormat.format( 97 I18N.getString("message.preparing-dmg-setup"), 98 dmgSetup.getAbsolutePath())); 99 100 // We need to use URL for DMG to find it. We cannot use volume name, since 101 // user might have open DMG with same volume name already. Url should end with 102 // '/' and it should be real path (no symbolic links). 103 File imageDir = IMAGES_ROOT.fetchFrom(params); 104 if (!imageDir.exists()) imageDir.mkdirs(); // Create it, since it does not exist 105 Path rootPath = Path.of(imageDir.toString()).toRealPath(); 106 Path volumePath = rootPath.resolve(APP_NAME.fetchFrom(params)); 107 String volumeUrl = volumePath.toUri().toString() + File.separator; 108 109 // Provide full path to backround image, so we can find it. 110 Path bgFile = Path.of(rootPath.toString(), APP_NAME.fetchFrom(params), 111 BACKGROUND_IMAGE_FOLDER, BACKGROUND_IMAGE); 112 113 //prepare config for exe 114 Map<String, String> data = new HashMap<>(); 115 data.put("DEPLOY_VOLUME_URL", volumeUrl); 116 data.put("DEPLOY_BG_FILE", bgFile.toString()); 117 data.put("DEPLOY_VOLUME_PATH", volumePath.toString()); 118 data.put("DEPLOY_APPLICATION_NAME", APP_NAME.fetchFrom(params)); 119 120 data.put("DEPLOY_INSTALL_LOCATION", MAC_INSTALL_DIR.fetchFrom(params)); 121 data.put("DEPLOY_INSTALL_NAME", MAC_INSTALL_DIR.fetchFrom(params)); 122 123 createResource(DEFAULT_DMG_SETUP_SCRIPT, params) 124 .setCategory(I18N.getString("resource.dmg-setup-script")) 125 .setSubstitutionData(data) 126 .saveToFile(dmgSetup); 127 } 128 129 private File getConfig_VolumeScript(Map<String, ? super Object> params) { 130 return new File(CONFIG_ROOT.fetchFrom(params), 131 APP_NAME.fetchFrom(params) + "-dmg-setup.scpt"); 132 } 133 134 private File getConfig_VolumeBackground( 135 Map<String, ? super Object> params) { 136 return new File(CONFIG_ROOT.fetchFrom(params), 137 APP_NAME.fetchFrom(params) + "-background.tiff"); 138 } 139 140 private File getConfig_VolumeIcon(Map<String, ? super Object> params) { 141 return new File(CONFIG_ROOT.fetchFrom(params), 142 APP_NAME.fetchFrom(params) + "-volume.icns"); 143 } 144 145 private File getConfig_LicenseFile(Map<String, ? super Object> params) { 146 return new File(CONFIG_ROOT.fetchFrom(params), 147 APP_NAME.fetchFrom(params) + "-license.plist"); 148 } 149 150 private void prepareLicense(Map<String, ? super Object> params) { 151 try { 152 String licFileStr = LICENSE_FILE.fetchFrom(params); 153 if (licFileStr == null) { 154 return; 155 } 156 157 File licFile = new File(licFileStr); 158 byte[] licenseContentOriginal = 159 Files.readAllBytes(licFile.toPath()); 160 String licenseInBase64 = 161 Base64.getEncoder().encodeToString(licenseContentOriginal); 162 163 Map<String, String> data = new HashMap<>(); 164 data.put("APPLICATION_LICENSE_TEXT", licenseInBase64); 165 166 createResource(DEFAULT_LICENSE_PLIST, params) 167 .setCategory(I18N.getString("resource.license-setup")) 168 .setSubstitutionData(data) 169 .saveToFile(getConfig_LicenseFile(params)); 170 171 } catch (IOException ex) { 172 Log.verbose(ex); 173 } 174 } 175 176 private boolean prepareConfigFiles(Map<String, ? super Object> params) 177 throws IOException { 178 179 createResource(DEFAULT_BACKGROUND_IMAGE, params) 180 .setCategory(I18N.getString("resource.dmg-background")) 181 .saveToFile(getConfig_VolumeBackground(params)); 182 183 createResource(TEMPLATE_BUNDLE_ICON, params) 184 .setCategory(I18N.getString("resource.volume-icon")) 185 .setExternal(ICON_ICNS.fetchFrom(params)) 186 .saveToFile(getConfig_VolumeIcon(params)); 187 188 createResource(null, params) 189 .setCategory(I18N.getString("resource.post-install-script")) 190 .saveToFile(getConfig_Script(params)); 191 192 prepareLicense(params); 193 194 prepareDMGSetupScript(params); 195 196 return true; 197 } 198 199 // name of post-image script 200 private File getConfig_Script(Map<String, ? super Object> params) { 201 return new File(CONFIG_ROOT.fetchFrom(params), 202 APP_NAME.fetchFrom(params) + "-post-image.sh"); 203 } 204 205 // Location of SetFile utility may be different depending on MacOS version 206 // We look for several known places and if none of them work will 207 // try ot find it 208 private String findSetFileUtility() { 209 String typicalPaths[] = {"/Developer/Tools/SetFile", 210 "/usr/bin/SetFile", "/Developer/usr/bin/SetFile"}; 211 212 String setFilePath = null; 213 for (String path: typicalPaths) { 214 File f = new File(path); 215 if (f.exists() && f.canExecute()) { 216 setFilePath = path; 217 break; 218 } 219 } 220 221 // Validate SetFile, if Xcode is not installed it will run, but exit with error 222 // code 223 if (setFilePath != null) { 224 try { 225 ProcessBuilder pb = new ProcessBuilder(setFilePath, "-h"); 226 Process p = pb.start(); 227 int code = p.waitFor(); 228 if (code == 0) { 229 return setFilePath; 230 } 231 } catch (Exception ignored) {} 232 233 // No need for generic find attempt. We found it, but it does not work. 234 // Probably due to missing xcode. 235 return null; 236 } 237 238 // generic find attempt 239 try { 240 ProcessBuilder pb = new ProcessBuilder("xcrun", "-find", "SetFile"); 241 Process p = pb.start(); 242 InputStreamReader isr = new InputStreamReader(p.getInputStream()); 243 BufferedReader br = new BufferedReader(isr); 244 String lineRead = br.readLine(); 245 if (lineRead != null) { 246 File f = new File(lineRead); 247 if (f.exists() && f.canExecute()) { 248 return f.getAbsolutePath(); 249 } 250 } 251 } catch (IOException ignored) {} 252 253 return null; 254 } 255 256 private File buildDMG( 257 Map<String, ? super Object> params, File outdir) 258 throws IOException { 259 File imagesRoot = IMAGES_ROOT.fetchFrom(params); 260 if (!imagesRoot.exists()) imagesRoot.mkdirs(); 261 262 File protoDMG = new File(imagesRoot, 263 APP_NAME.fetchFrom(params) +"-tmp.dmg"); 264 File finalDMG = new File(outdir, INSTALLER_NAME.fetchFrom(params) 265 + INSTALLER_SUFFIX.fetchFrom(params) + ".dmg"); 266 267 File srcFolder = APP_IMAGE_TEMP_ROOT.fetchFrom(params); 268 File predefinedImage = 269 StandardBundlerParam.getPredefinedAppImage(params); 270 if (predefinedImage != null) { 271 srcFolder = predefinedImage; 272 } 273 274 Log.verbose(MessageFormat.format(I18N.getString( 275 "message.creating-dmg-file"), finalDMG.getAbsolutePath())); 276 277 protoDMG.delete(); 278 if (finalDMG.exists() && !finalDMG.delete()) { 279 throw new IOException(MessageFormat.format(I18N.getString( 280 "message.dmg-cannot-be-overwritten"), 281 finalDMG.getAbsolutePath())); 282 } 283 284 protoDMG.getParentFile().mkdirs(); 285 finalDMG.getParentFile().mkdirs(); 286 287 String hdiUtilVerbosityFlag = VERBOSE.fetchFrom(params) ? 288 "-verbose" : "-quiet"; 289 290 // create temp image 291 ProcessBuilder pb = new ProcessBuilder( 292 hdiutil, 293 "create", 294 hdiUtilVerbosityFlag, 295 "-srcfolder", srcFolder.getAbsolutePath(), 296 "-volname", APP_NAME.fetchFrom(params), 297 "-ov", protoDMG.getAbsolutePath(), 298 "-fs", "HFS+", 299 "-format", "UDRW"); 300 IOUtils.exec(pb); 301 302 // mount temp image 303 pb = new ProcessBuilder( 304 hdiutil, 305 "attach", 306 protoDMG.getAbsolutePath(), 307 hdiUtilVerbosityFlag, 308 "-mountroot", imagesRoot.getAbsolutePath()); 309 IOUtils.exec(pb, false, null, true); 310 311 File mountedRoot = new File(imagesRoot.getAbsolutePath(), 312 APP_NAME.fetchFrom(params)); 313 try { 314 // background image 315 File bgdir = new File(mountedRoot, BACKGROUND_IMAGE_FOLDER); 316 bgdir.mkdirs(); 317 IOUtils.copyFile(getConfig_VolumeBackground(params), 318 new File(bgdir, BACKGROUND_IMAGE)); 319 320 // We will not consider setting background image and creating link 321 // to install-dir in DMG as critical error, since it can fail in 322 // headless enviroment. 323 try { 324 pb = new ProcessBuilder("osascript", 325 getConfig_VolumeScript(params).getAbsolutePath()); 326 IOUtils.exec(pb); 327 } catch (IOException ex) { 328 Log.verbose(ex); 329 } 330 331 // volume icon 332 File volumeIconFile = new File(mountedRoot, ".VolumeIcon.icns"); 333 IOUtils.copyFile(getConfig_VolumeIcon(params), 334 volumeIconFile); 335 336 // Indicate that we want a custom icon 337 // NB: attributes of the root directory are ignored 338 // when creating the volume 339 // Therefore we have to do this after we mount image 340 String setFileUtility = findSetFileUtility(); 341 if (setFileUtility != null) { 342 //can not find utility => keep going without icon 343 try { 344 volumeIconFile.setWritable(true); 345 // The "creator" attribute on a file is a legacy attribute 346 // but it seems Finder excepts these bytes to be 347 // "icnC" for the volume icon 348 // (might not work on Mac 10.13 with old XCode) 349 pb = new ProcessBuilder( 350 setFileUtility, 351 "-c", "icnC", 352 volumeIconFile.getAbsolutePath()); 353 IOUtils.exec(pb); 354 volumeIconFile.setReadOnly(); 355 356 pb = new ProcessBuilder( 357 setFileUtility, 358 "-a", "C", 359 mountedRoot.getAbsolutePath()); 360 IOUtils.exec(pb); 361 } catch (IOException ex) { 362 Log.error(ex.getMessage()); 363 Log.verbose("Cannot enable custom icon using SetFile utility"); 364 } 365 } else { 366 Log.verbose(I18N.getString("message.setfile.dmg")); 367 } 368 369 } finally { 370 // Detach the temporary image 371 pb = new ProcessBuilder( 372 hdiutil, 373 "detach", 374 "-force", 375 hdiUtilVerbosityFlag, 376 mountedRoot.getAbsolutePath()); 377 IOUtils.exec(pb); 378 } 379 380 // Compress it to a new image 381 pb = new ProcessBuilder( 382 hdiutil, 383 "convert", 384 protoDMG.getAbsolutePath(), 385 hdiUtilVerbosityFlag, 386 "-format", "UDZO", 387 "-o", finalDMG.getAbsolutePath()); 388 IOUtils.exec(pb); 389 390 //add license if needed 391 if (getConfig_LicenseFile(params).exists()) { 392 //hdiutil unflatten your_image_file.dmg 393 pb = new ProcessBuilder( 394 hdiutil, 395 "unflatten", 396 finalDMG.getAbsolutePath() 397 ); 398 IOUtils.exec(pb); 399 400 //add license 401 pb = new ProcessBuilder( 402 hdiutil, 403 "udifrez", 404 finalDMG.getAbsolutePath(), 405 "-xml", 406 getConfig_LicenseFile(params).getAbsolutePath() 407 ); 408 IOUtils.exec(pb); 409 410 //hdiutil flatten your_image_file.dmg 411 pb = new ProcessBuilder( 412 hdiutil, 413 "flatten", 414 finalDMG.getAbsolutePath() 415 ); 416 IOUtils.exec(pb); 417 418 } 419 420 //Delete the temporary image 421 protoDMG.delete(); 422 423 Log.verbose(MessageFormat.format(I18N.getString( 424 "message.output-to-location"), 425 APP_NAME.fetchFrom(params), finalDMG.getAbsolutePath())); 426 427 return finalDMG; 428 } 429 430 431 ////////////////////////////////////////////////////////////////////////// 432 // Implement Bundler 433 ////////////////////////////////////////////////////////////////////////// 434 435 @Override 436 public String getName() { 437 return I18N.getString("dmg.bundler.name"); 438 } 439 440 @Override 441 public String getID() { 442 return "dmg"; 443 } 444 445 @Override 446 public boolean validate(Map<String, ? super Object> params) 447 throws ConfigException { 448 try { 449 Objects.requireNonNull(params); 450 451 //run basic validation to ensure requirements are met 452 //we are not interested in return code, only possible exception 453 validateAppImageAndBundeler(params); 454 455 return true; 456 } catch (RuntimeException re) { 457 if (re.getCause() instanceof ConfigException) { 458 throw (ConfigException) re.getCause(); 459 } else { 460 throw new ConfigException(re); 461 } 462 } 463 } 464 465 @Override 466 public File execute(Map<String, ? super Object> params, 467 File outputParentDir) throws PackagerException { 468 return bundle(params, outputParentDir); 469 } 470 471 @Override 472 public boolean supported(boolean runtimeInstaller) { 473 return isSupported(); 474 } 475 476 public final static String[] required = 477 {"/usr/bin/hdiutil", "/usr/bin/osascript"}; 478 public static boolean isSupported() { 479 try { 480 for (String s : required) { 481 File f = new File(s); 482 if (!f.exists() || !f.canExecute()) { 483 return false; 484 } 485 } 486 return true; 487 } catch (Exception e) { 488 return false; 489 } 490 } 491 492 @Override 493 public boolean isDefault() { 494 return true; 495 } 496 }