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