1 /*
   2  * Copyright (c) 2015, 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.File;
  29 import java.io.FileInputStream;
  30 import java.io.IOException;
  31 import java.io.InputStream;
  32 import java.io.Writer;
  33 import java.math.BigInteger;
  34 import java.nio.file.Files;
  35 import java.nio.file.Path;
  36 import java.nio.file.StandardCopyOption;
  37 import java.nio.file.attribute.PosixFilePermission;
  38 import java.text.MessageFormat;
  39 import java.util.ArrayList;
  40 import java.util.Arrays;
  41 import java.util.EnumSet;
  42 import java.util.HashMap;
  43 import java.util.List;
  44 import java.util.Map;
  45 import java.util.Objects;
  46 import java.util.Optional;
  47 import java.util.ResourceBundle;
  48 import java.util.Set;
  49 import java.util.concurrent.atomic.AtomicReference;
  50 import java.util.function.Consumer;
  51 import java.util.stream.Stream;
  52 import javax.xml.parsers.DocumentBuilder;
  53 import javax.xml.parsers.DocumentBuilderFactory;
  54 import javax.xml.xpath.XPath;
  55 import javax.xml.xpath.XPathConstants;
  56 import javax.xml.xpath.XPathFactory;
  57 
  58 import static jdk.incubator.jpackage.internal.StandardBundlerParam.*;
  59 import static jdk.incubator.jpackage.internal.MacBaseInstallerBundler.*;
  60 import static jdk.incubator.jpackage.internal.MacAppBundler.*;
  61 import static jdk.incubator.jpackage.internal.OverridableResource.createResource;
  62 
  63 public class MacAppImageBuilder extends AbstractAppImageBuilder {
  64 
  65     private static final ResourceBundle I18N = ResourceBundle.getBundle(
  66             "jdk.incubator.jpackage.internal.resources.MacResources");
  67 
  68     private static final String LIBRARY_NAME = "libapplauncher.dylib";
  69     private static final String TEMPLATE_BUNDLE_ICON = "java.icns";
  70     private static final String OS_TYPE_CODE = "APPL";
  71     private static final String TEMPLATE_INFO_PLIST_LITE =
  72             "Info-lite.plist.template";
  73     private static final String TEMPLATE_RUNTIME_INFO_PLIST =
  74             "Runtime-Info.plist.template";
  75 
  76     private final Path root;
  77     private final Path contentsDir;
  78     private final Path appDir;
  79     private final Path javaModsDir;
  80     private final Path resourcesDir;
  81     private final Path macOSDir;
  82     private final Path runtimeDir;
  83     private final Path runtimeRoot;
  84     private final Path mdir;
  85 
  86     private static List<String> keyChains;
  87 
  88     public static final BundlerParamInfo<Boolean>
  89             MAC_CONFIGURE_LAUNCHER_IN_PLIST = new StandardBundlerParam<>(
  90                     "mac.configure-launcher-in-plist",
  91                     Boolean.class,
  92                     params -> Boolean.FALSE,
  93                     (s, p) -> Boolean.valueOf(s));
  94 
  95     public static final BundlerParamInfo<String> MAC_CF_BUNDLE_NAME =
  96             new StandardBundlerParam<>(
  97                     Arguments.CLIOptions.MAC_BUNDLE_NAME.getId(),
  98                     String.class,
  99                     params -> null,
 100                     (s, p) -> s);
 101 
 102     public static final BundlerParamInfo<String> MAC_CF_BUNDLE_IDENTIFIER =
 103             new StandardBundlerParam<>(
 104                     Arguments.CLIOptions.MAC_BUNDLE_IDENTIFIER.getId(),
 105                     String.class,
 106                     params -> {
 107                         // Get identifier from app image if user provided
 108                         // app image and did not provide the identifier via CLI.
 109                         String identifier = extractBundleIdentifier(params);
 110                         if (identifier != null) {
 111                             return identifier;
 112                         }
 113 
 114                         identifier = IDENTIFIER.fetchFrom(params);
 115                         if (identifier != null) {
 116                             return identifier;
 117                         }
 118                         // the IDENTIFIER (above) will default to derive from
 119                         // the main-class, in case there is no main-class
 120                         // (such as runtime installer) revert to the name.
 121                         // any of these could be invalid, so check later.
 122                         return APP_NAME.fetchFrom(params);
 123                     },
 124                     (s, p) -> s);
 125 
 126     public static final BundlerParamInfo<String> MAC_CF_BUNDLE_VERSION =
 127             new StandardBundlerParam<>(
 128                     "mac.CFBundleVersion",
 129                     String.class,
 130                     p -> {
 131                         String s = VERSION.fetchFrom(p);
 132                         if (validCFBundleVersion(s)) {
 133                             return s;
 134                         } else {
 135                             return "100";
 136                         }
 137                     },
 138                     (s, p) -> s);
 139 
 140     public static final BundlerParamInfo<File> ICON_ICNS =
 141             new StandardBundlerParam<>(
 142             "icon.icns",
 143             File.class,
 144             params -> {
 145                 File f = ICON.fetchFrom(params);
 146                 if (f != null && !f.getName().toLowerCase().endsWith(".icns")) {
 147                     Log.error(MessageFormat.format(
 148                             I18N.getString("message.icon-not-icns"), f));
 149                     return null;
 150                 }
 151                 return f;
 152             },
 153             (s, p) -> new File(s));
 154 
 155     public static final StandardBundlerParam<Boolean> SIGN_BUNDLE  =
 156             new StandardBundlerParam<>(
 157             Arguments.CLIOptions.MAC_SIGN.getId(),
 158             Boolean.class,
 159             params -> false,
 160             // valueOf(null) is false, we actually do want null in some cases
 161             (s, p) -> (s == null || "null".equalsIgnoreCase(s)) ?
 162                     null : Boolean.valueOf(s)
 163         );
 164 
 165     public MacAppImageBuilder(Map<String, Object> params, Path imageOutDir)
 166             throws IOException {
 167         super(params, imageOutDir.resolve(APP_NAME.fetchFrom(params)
 168                 + ".app/Contents/runtime/Contents/Home"));
 169 
 170         Objects.requireNonNull(imageOutDir);
 171 
 172         this.root = imageOutDir.resolve(APP_NAME.fetchFrom(params) + ".app");
 173         this.contentsDir = root.resolve("Contents");
 174         this.appDir = contentsDir.resolve("app");
 175         this.javaModsDir = appDir.resolve("mods");
 176         this.resourcesDir = contentsDir.resolve("Resources");
 177         this.macOSDir = contentsDir.resolve("MacOS");
 178         this.runtimeDir = contentsDir.resolve("runtime");
 179         this.runtimeRoot = runtimeDir.resolve("Contents/Home");
 180         this.mdir = runtimeRoot.resolve("lib");
 181         Files.createDirectories(appDir);
 182         Files.createDirectories(resourcesDir);
 183         Files.createDirectories(macOSDir);
 184         Files.createDirectories(runtimeDir);
 185     }
 186 
 187     private void writeEntry(InputStream in, Path dstFile) throws IOException {
 188         Files.createDirectories(dstFile.getParent());
 189         Files.copy(in, dstFile);
 190     }
 191 
 192     public static boolean validCFBundleVersion(String v) {
 193         // CFBundleVersion (String - iOS, OS X) specifies the build version
 194         // number of the bundle, which identifies an iteration (released or
 195         // unreleased) of the bundle. The build version number should be a
 196         // string comprised of three non-negative, period-separated integers
 197         // with the first integer being greater than zero. The string should
 198         // only contain numeric (0-9) and period (.) characters. Leading zeros
 199         // are truncated from each integer and will be ignored (that is,
 200         // 1.02.3 is equivalent to 1.2.3). This key is not localizable.
 201 
 202         if (v == null) {
 203             return false;
 204         }
 205 
 206         String p[] = v.split("\\.");
 207         if (p.length > 3 || p.length < 1) {
 208             Log.verbose(I18N.getString(
 209                     "message.version-string-too-many-components"));
 210             return false;
 211         }
 212 
 213         try {
 214             BigInteger n = new BigInteger(p[0]);
 215             if (BigInteger.ONE.compareTo(n) > 0) {
 216                 Log.verbose(I18N.getString(
 217                         "message.version-string-first-number-not-zero"));
 218                 return false;
 219             }
 220             if (p.length > 1) {
 221                 n = new BigInteger(p[1]);
 222                 if (BigInteger.ZERO.compareTo(n) > 0) {
 223                     Log.verbose(I18N.getString(
 224                             "message.version-string-no-negative-numbers"));
 225                     return false;
 226                 }
 227             }
 228             if (p.length > 2) {
 229                 n = new BigInteger(p[2]);
 230                 if (BigInteger.ZERO.compareTo(n) > 0) {
 231                     Log.verbose(I18N.getString(
 232                             "message.version-string-no-negative-numbers"));
 233                     return false;
 234                 }
 235             }
 236         } catch (NumberFormatException ne) {
 237             Log.verbose(I18N.getString("message.version-string-numbers-only"));
 238             Log.verbose(ne);
 239             return false;
 240         }
 241 
 242         return true;
 243     }
 244 
 245     @Override
 246     public Path getAppDir() {
 247         return appDir;
 248     }
 249 
 250     @Override
 251     public Path getAppModsDir() {
 252         return javaModsDir;
 253     }
 254 
 255     @Override
 256     public void prepareApplicationFiles(Map<String, ? super Object> params)
 257             throws IOException {
 258         Map<String, ? super Object> originalParams = new HashMap<>(params);
 259         // Generate PkgInfo
 260         File pkgInfoFile = new File(contentsDir.toFile(), "PkgInfo");
 261         pkgInfoFile.createNewFile();
 262         writePkgInfo(pkgInfoFile);
 263 
 264         Path executable = macOSDir.resolve(getLauncherName(params));
 265 
 266         // create the main app launcher
 267         try (InputStream is_launcher =
 268                 getResourceAsStream("jpackageapplauncher");
 269             InputStream is_lib = getResourceAsStream(LIBRARY_NAME)) {
 270             // Copy executable and library to MacOS folder
 271             writeEntry(is_launcher, executable);
 272             writeEntry(is_lib, macOSDir.resolve(LIBRARY_NAME));
 273         }
 274         executable.toFile().setExecutable(true, false);
 275         // generate main app launcher config file
 276         File cfg = new File(root.toFile(), getLauncherCfgName(params));
 277         writeCfgFile(params, cfg);
 278 
 279         // create additional app launcher(s) and config file(s)
 280         List<Map<String, ? super Object>> entryPoints =
 281                 StandardBundlerParam.ADD_LAUNCHERS.fetchFrom(params);
 282         for (Map<String, ? super Object> entryPoint : entryPoints) {
 283             Map<String, ? super Object> tmp =
 284                     AddLauncherArguments.merge(originalParams, entryPoint);
 285 
 286             // add executable for add launcher
 287             Path addExecutable = macOSDir.resolve(getLauncherName(tmp));
 288             try (InputStream is = getResourceAsStream("jpackageapplauncher");) {
 289                 writeEntry(is, addExecutable);
 290             }
 291             addExecutable.toFile().setExecutable(true, false);
 292 
 293             // add config file for add launcher
 294             cfg = new File(root.toFile(), getLauncherCfgName(tmp));
 295             writeCfgFile(tmp, cfg);
 296         }
 297 
 298         // Copy class path entries to Java folder
 299         copyClassPathEntries(appDir, params);
 300 
 301         /*********** Take care of "config" files *******/
 302 
 303         createResource(TEMPLATE_BUNDLE_ICON, params)
 304                 .setCategory("icon")
 305                 .setExternal(ICON_ICNS.fetchFrom(params))
 306                 .saveToFile(resourcesDir.resolve(APP_NAME.fetchFrom(params)
 307                         + ".icns"));
 308 
 309         // copy file association icons
 310         for (Map<String, ?
 311                 super Object> fa : FILE_ASSOCIATIONS.fetchFrom(params)) {
 312             File f = FA_ICON.fetchFrom(fa);
 313             if (f != null && f.exists()) {
 314                 try (InputStream in2 = new FileInputStream(f)) {
 315                     Files.copy(in2, resourcesDir.resolve(f.getName()));
 316                 }
 317 
 318             }
 319         }
 320 
 321         copyRuntimeFiles(params);
 322         sign(params);
 323     }
 324 
 325     @Override
 326     public void prepareJreFiles(Map<String, ? super Object> params)
 327             throws IOException {
 328         copyRuntimeFiles(params);
 329         sign(params);
 330     }
 331 
 332     @Override
 333     File getRuntimeImageDir(File runtimeImageTop) {
 334         File home = new File(runtimeImageTop, "Contents/Home");
 335         return (home.exists() ? home : runtimeImageTop);
 336     }
 337 
 338     private void copyRuntimeFiles(Map<String, ? super Object> params)
 339             throws IOException {
 340         // Generate Info.plist
 341         writeInfoPlist(contentsDir.resolve("Info.plist").toFile(), params);
 342 
 343         // generate java runtime info.plist
 344         writeRuntimeInfoPlist(
 345                 runtimeDir.resolve("Contents/Info.plist").toFile(), params);
 346 
 347         // copy library
 348         Path runtimeMacOSDir = Files.createDirectories(
 349                 runtimeDir.resolve("Contents/MacOS"));
 350 
 351         // JDK 9, 10, and 11 have extra '/jli/' subdir
 352         Path jli = runtimeRoot.resolve("lib/libjli.dylib");
 353         if (!Files.exists(jli)) {
 354             jli = runtimeRoot.resolve("lib/jli/libjli.dylib");
 355         }
 356 
 357         Files.copy(jli, runtimeMacOSDir.resolve("libjli.dylib"));
 358     }
 359 
 360     private void sign(Map<String, ? super Object> params) throws IOException {
 361         if (Optional.ofNullable(
 362                 SIGN_BUNDLE.fetchFrom(params)).orElse(Boolean.TRUE)) {
 363             try {
 364                 addNewKeychain(params);
 365             } catch (InterruptedException e) {
 366                 Log.error(e.getMessage());
 367             }
 368             String signingIdentity =
 369                     DEVELOPER_ID_APP_SIGNING_KEY.fetchFrom(params);
 370             if (signingIdentity != null) {
 371                 prepareEntitlements(params);
 372                 signAppBundle(params, root, signingIdentity,
 373                         BUNDLE_ID_SIGNING_PREFIX.fetchFrom(params),
 374                         getConfig_Entitlements(params).toString(),
 375                         getConfig_Inherit_Entitlements(params).toString());
 376             }
 377             restoreKeychainList(params);
 378         }
 379     }
 380 
 381     private File getConfig_Entitlements(Map<String, ? super Object> params) {
 382         return new File(CONFIG_ROOT.fetchFrom(params),
 383                 getLauncherName(params) + ".entitlements");
 384     }
 385 
 386     private File getConfig_Inherit_Entitlements(
 387             Map<String, ? super Object> params) {
 388         return new File(CONFIG_ROOT.fetchFrom(params), 
 389                 getLauncherName(params) + "_Inherit.entitlements");
 390     }
 391 
 392     private void prepareEntitlements(Map<String, ? super Object> params)
 393             throws IOException {
 394         createResource("Mac.entitlements", params)
 395                 .setCategory(I18N.getString("resource.mac-entitlements"))
 396                 .saveToFile(getConfig_Entitlements(params));
 397 
 398         createResource("Mac_Inherit.entitlements", params)
 399                 .setCategory(I18N.getString(
 400                         "resource.mac-inherit-entitlements"))
 401                 .saveToFile(getConfig_Inherit_Entitlements(params));
 402     }
 403 
 404 
 405     private String getLauncherName(Map<String, ? super Object> params) {
 406         if (APP_NAME.fetchFrom(params) != null) {
 407             return APP_NAME.fetchFrom(params);
 408         } else {
 409             return MAIN_CLASS.fetchFrom(params);
 410         }
 411     }
 412 
 413     public static String getLauncherCfgName(
 414             Map<String, ? super Object> params) {
 415         return "Contents/app/" + APP_NAME.fetchFrom(params) + ".cfg";
 416     }
 417 
 418     private void copyClassPathEntries(Path javaDirectory,
 419             Map<String, ? super Object> params) throws IOException {
 420         List<RelativeFileSet> resourcesList =
 421                 APP_RESOURCES_LIST.fetchFrom(params);
 422         if (resourcesList == null) {
 423             throw new RuntimeException(
 424                     I18N.getString("message.null-classpath"));
 425         }
 426 
 427         for (RelativeFileSet classPath : resourcesList) {
 428             File srcdir = classPath.getBaseDirectory();
 429             for (String fname : classPath.getIncludedFiles()) {
 430                 copyEntry(javaDirectory, srcdir, fname);
 431             }
 432         }
 433     }
 434 
 435     private String getBundleName(Map<String, ? super Object> params) {
 436         if (MAC_CF_BUNDLE_NAME.fetchFrom(params) != null) {
 437             String bn = MAC_CF_BUNDLE_NAME.fetchFrom(params);
 438             if (bn.length() > 16) {
 439                 Log.error(MessageFormat.format(I18N.getString(
 440                         "message.bundle-name-too-long-warning"),
 441                         MAC_CF_BUNDLE_NAME.getID(), bn));
 442             }
 443             return MAC_CF_BUNDLE_NAME.fetchFrom(params);
 444         } else if (APP_NAME.fetchFrom(params) != null) {
 445             return APP_NAME.fetchFrom(params);
 446         } else {
 447             String nm = MAIN_CLASS.fetchFrom(params);
 448             if (nm.length() > 16) {
 449                 nm = nm.substring(0, 16);
 450             }
 451             return nm;
 452         }
 453     }
 454 
 455     private void writeRuntimeInfoPlist(File file,
 456             Map<String, ? super Object> params) throws IOException {
 457         Map<String, String> data = new HashMap<>();
 458         String identifier = StandardBundlerParam.isRuntimeInstaller(params) ?
 459                 MAC_CF_BUNDLE_IDENTIFIER.fetchFrom(params) :
 460                 "com.oracle.java." + MAC_CF_BUNDLE_IDENTIFIER.fetchFrom(params);
 461         data.put("CF_BUNDLE_IDENTIFIER", identifier);
 462         String name = StandardBundlerParam.isRuntimeInstaller(params) ?
 463                 getBundleName(params): "Java Runtime Image";
 464         data.put("CF_BUNDLE_NAME", name);
 465         data.put("CF_BUNDLE_VERSION", VERSION.fetchFrom(params));
 466         data.put("CF_BUNDLE_SHORT_VERSION_STRING", VERSION.fetchFrom(params));
 467 
 468         createResource(TEMPLATE_RUNTIME_INFO_PLIST, params)
 469                 .setPublicName("Runtime-Info.plist")
 470                 .setCategory(I18N.getString("resource.runtime-info-plist"))
 471                 .setSubstitutionData(data)
 472                 .saveToFile(file);
 473     }
 474 
 475     private void writeInfoPlist(File file, Map<String, ? super Object> params)
 476             throws IOException {
 477         Log.verbose(MessageFormat.format(I18N.getString(
 478                 "message.preparing-info-plist"), file.getAbsolutePath()));
 479 
 480         //prepare config for exe
 481         //Note: do not need CFBundleDisplayName if we don't support localization
 482         Map<String, String> data = new HashMap<>();
 483         data.put("DEPLOY_ICON_FILE", APP_NAME.fetchFrom(params) + ".icns");
 484         data.put("DEPLOY_BUNDLE_IDENTIFIER",
 485                 MAC_CF_BUNDLE_IDENTIFIER.fetchFrom(params));
 486         data.put("DEPLOY_BUNDLE_NAME",
 487                 getBundleName(params));
 488         data.put("DEPLOY_BUNDLE_COPYRIGHT",
 489                 COPYRIGHT.fetchFrom(params) != null ?
 490                 COPYRIGHT.fetchFrom(params) : "Unknown");
 491         data.put("DEPLOY_LAUNCHER_NAME", getLauncherName(params));
 492         data.put("DEPLOY_BUNDLE_SHORT_VERSION",
 493                 VERSION.fetchFrom(params) != null ?
 494                 VERSION.fetchFrom(params) : "1.0.0");
 495         data.put("DEPLOY_BUNDLE_CFBUNDLE_VERSION",
 496                 MAC_CF_BUNDLE_VERSION.fetchFrom(params) != null ?
 497                 MAC_CF_BUNDLE_VERSION.fetchFrom(params) : "100");
 498 
 499         boolean hasMainJar = MAIN_JAR.fetchFrom(params) != null;
 500         boolean hasMainModule =
 501                 StandardBundlerParam.MODULE.fetchFrom(params) != null;
 502 
 503         if (hasMainJar) {
 504             data.put("DEPLOY_MAIN_JAR_NAME", MAIN_JAR.fetchFrom(params).
 505                     getIncludedFiles().iterator().next());
 506         }
 507         else if (hasMainModule) {
 508             data.put("DEPLOY_MODULE_NAME",
 509                     StandardBundlerParam.MODULE.fetchFrom(params));
 510         }
 511 
 512         StringBuilder sb = new StringBuilder();
 513         List<String> jvmOptions = JAVA_OPTIONS.fetchFrom(params);
 514 
 515         String newline = ""; //So we don't add extra line after last append
 516         for (String o : jvmOptions) {
 517             sb.append(newline).append(
 518                     "    <string>").append(o).append("</string>");
 519             newline = "\n";
 520         }
 521 
 522         data.put("DEPLOY_JAVA_OPTIONS", sb.toString());
 523 
 524         sb = new StringBuilder();
 525         List<String> args = ARGUMENTS.fetchFrom(params);
 526         newline = "";
 527         // So we don't add unneccessary extra line after last append
 528 
 529         for (String o : args) {
 530             sb.append(newline).append("    <string>").append(o).append(
 531                     "</string>");
 532             newline = "\n";
 533         }
 534         data.put("DEPLOY_ARGUMENTS", sb.toString());
 535 
 536         newline = "";
 537 
 538         data.put("DEPLOY_LAUNCHER_CLASS", MAIN_CLASS.fetchFrom(params));
 539 
 540         data.put("DEPLOY_APP_CLASSPATH",
 541                   getCfgClassPath(CLASSPATH.fetchFrom(params)));
 542 
 543         StringBuilder bundleDocumentTypes = new StringBuilder();
 544         StringBuilder exportedTypes = new StringBuilder();
 545         for (Map<String, ? super Object>
 546                 fileAssociation : FILE_ASSOCIATIONS.fetchFrom(params)) {
 547 
 548             List<String> extensions = FA_EXTENSIONS.fetchFrom(fileAssociation);
 549 
 550             if (extensions == null) {
 551                 Log.verbose(I18N.getString(
 552                         "message.creating-association-with-null-extension"));
 553             }
 554 
 555             List<String> mimeTypes = FA_CONTENT_TYPE.fetchFrom(fileAssociation);
 556             String itemContentType = MAC_CF_BUNDLE_IDENTIFIER.fetchFrom(params)
 557                     + "." + ((extensions == null || extensions.isEmpty())
 558                     ? "mime" : extensions.get(0));
 559             String description = FA_DESCRIPTION.fetchFrom(fileAssociation);
 560             File icon = FA_ICON.fetchFrom(fileAssociation);
 561 
 562             bundleDocumentTypes.append("    <dict>\n")
 563                     .append("      <key>LSItemContentTypes</key>\n")
 564                     .append("      <array>\n")
 565                     .append("        <string>")
 566                     .append(itemContentType)
 567                     .append("</string>\n")
 568                     .append("      </array>\n")
 569                     .append("\n")
 570                     .append("      <key>CFBundleTypeName</key>\n")
 571                     .append("      <string>")
 572                     .append(description)
 573                     .append("</string>\n")
 574                     .append("\n")
 575                     .append("      <key>LSHandlerRank</key>\n")
 576                     .append("      <string>Owner</string>\n")
 577                             // TODO make a bundler arg
 578                     .append("\n")
 579                     .append("      <key>CFBundleTypeRole</key>\n")
 580                     .append("      <string>Editor</string>\n")
 581                             // TODO make a bundler arg
 582                     .append("\n")
 583                     .append("      <key>LSIsAppleDefaultForType</key>\n")
 584                     .append("      <true/>\n")
 585                             // TODO make a bundler arg
 586                     .append("\n");
 587 
 588             if (icon != null && icon.exists()) {
 589                 bundleDocumentTypes
 590                         .append("      <key>CFBundleTypeIconFile</key>\n")
 591                         .append("      <string>")
 592                         .append(icon.getName())
 593                         .append("</string>\n");
 594             }
 595             bundleDocumentTypes.append("    </dict>\n");
 596 
 597             exportedTypes.append("    <dict>\n")
 598                     .append("      <key>UTTypeIdentifier</key>\n")
 599                     .append("      <string>")
 600                     .append(itemContentType)
 601                     .append("</string>\n")
 602                     .append("\n")
 603                     .append("      <key>UTTypeDescription</key>\n")
 604                     .append("      <string>")
 605                     .append(description)
 606                     .append("</string>\n")
 607                     .append("      <key>UTTypeConformsTo</key>\n")
 608                     .append("      <array>\n")
 609                     .append("          <string>public.data</string>\n")
 610                             //TODO expose this?
 611                     .append("      </array>\n")
 612                     .append("\n");
 613 
 614             if (icon != null && icon.exists()) {
 615                 exportedTypes.append("      <key>UTTypeIconFile</key>\n")
 616                         .append("      <string>")
 617                         .append(icon.getName())
 618                         .append("</string>\n")
 619                         .append("\n");
 620             }
 621 
 622             exportedTypes.append("\n")
 623                     .append("      <key>UTTypeTagSpecification</key>\n")
 624                     .append("      <dict>\n")
 625                             // TODO expose via param? .append(
 626                             // "        <key>com.apple.ostype</key>\n");
 627                             // TODO expose via param? .append(
 628                             // "        <string>ABCD</string>\n")
 629                     .append("\n");
 630 
 631             if (extensions != null && !extensions.isEmpty()) {
 632                 exportedTypes.append(
 633                         "        <key>public.filename-extension</key>\n")
 634                         .append("        <array>\n");
 635 
 636                 for (String ext : extensions) {
 637                     exportedTypes.append("          <string>")
 638                             .append(ext)
 639                             .append("</string>\n");
 640                 }
 641                 exportedTypes.append("        </array>\n");
 642             }
 643             if (mimeTypes != null && !mimeTypes.isEmpty()) {
 644                 exportedTypes.append("        <key>public.mime-type</key>\n")
 645                         .append("        <array>\n");
 646 
 647                 for (String mime : mimeTypes) {
 648                     exportedTypes.append("          <string>")
 649                             .append(mime)
 650                             .append("</string>\n");
 651                 }
 652                 exportedTypes.append("        </array>\n");
 653             }
 654             exportedTypes.append("      </dict>\n")
 655                     .append("    </dict>\n");
 656         }
 657         String associationData;
 658         if (bundleDocumentTypes.length() > 0) {
 659             associationData =
 660                     "\n  <key>CFBundleDocumentTypes</key>\n  <array>\n"
 661                     + bundleDocumentTypes.toString()
 662                     + "  </array>\n\n"
 663                     + "  <key>UTExportedTypeDeclarations</key>\n  <array>\n"
 664                     + exportedTypes.toString()
 665                     + "  </array>\n";
 666         } else {
 667             associationData = "";
 668         }
 669         data.put("DEPLOY_FILE_ASSOCIATIONS", associationData);
 670 
 671         createResource(TEMPLATE_INFO_PLIST_LITE, params)
 672                 .setCategory(I18N.getString("resource.app-info-plist"))
 673                 .setSubstitutionData(data)
 674                 .setPublicName("Info.plist")
 675                 .saveToFile(file);
 676     }
 677 
 678     private void writePkgInfo(File file) throws IOException {
 679         //hardcoded as it does not seem we need to change it ever
 680         String signature = "????";
 681 
 682         try (Writer out = Files.newBufferedWriter(file.toPath())) {
 683             out.write(OS_TYPE_CODE + signature);
 684             out.flush();
 685         }
 686     }
 687 
 688     public static void addNewKeychain(Map<String, ? super Object> params)
 689                                     throws IOException, InterruptedException {
 690         if (Platform.getMajorVersion() < 10 ||
 691                 (Platform.getMajorVersion() == 10 &&
 692                 Platform.getMinorVersion() < 12)) {
 693             // we need this for OS X 10.12+
 694             return;
 695         }
 696 
 697         String keyChain = SIGNING_KEYCHAIN.fetchFrom(params);
 698         if (keyChain == null || keyChain.isEmpty()) {
 699             return;
 700         }
 701 
 702         // get current keychain list
 703         String keyChainPath = new File (keyChain).getAbsolutePath().toString();
 704         List<String> keychainList = new ArrayList<>();
 705         int ret = IOUtils.getProcessOutput(
 706                 keychainList, "security", "list-keychains");
 707         if (ret != 0) {
 708             Log.error(I18N.getString("message.keychain.error"));
 709             return;
 710         }
 711 
 712         boolean contains = keychainList.stream().anyMatch(
 713                     str -> str.trim().equals("\""+keyChainPath.trim()+"\""));
 714         if (contains) {
 715             // keychain is already added in the search list
 716             return;
 717         }
 718 
 719         keyChains = new ArrayList<>();
 720         // remove "
 721         keychainList.forEach((String s) -> {
 722             String path = s.trim();
 723             if (path.startsWith("\"") && path.endsWith("\"")) {
 724                 path = path.substring(1, path.length()-1);
 725             }
 726             keyChains.add(path);
 727         });
 728 
 729         List<String> args = new ArrayList<>();
 730         args.add("security");
 731         args.add("list-keychains");
 732         args.add("-s");
 733 
 734         args.addAll(keyChains);
 735         args.add(keyChain);
 736 
 737         ProcessBuilder  pb = new ProcessBuilder(args);
 738         IOUtils.exec(pb);
 739     }
 740 
 741     public static void restoreKeychainList(Map<String, ? super Object> params)
 742             throws IOException{
 743         if (Platform.getMajorVersion() < 10 ||
 744                 (Platform.getMajorVersion() == 10 &&
 745                 Platform.getMinorVersion() < 12)) {
 746             // we need this for OS X 10.12+
 747             return;
 748         }
 749 
 750         if (keyChains == null || keyChains.isEmpty()) {
 751             return;
 752         }
 753 
 754         List<String> args = new ArrayList<>();
 755         args.add("security");
 756         args.add("list-keychains");
 757         args.add("-s");
 758 
 759         args.addAll(keyChains);
 760 
 761         ProcessBuilder  pb = new ProcessBuilder(args);
 762         IOUtils.exec(pb);
 763     }
 764 
 765     public static void signAppBundle(
 766             Map<String, ? super Object> params, Path appLocation,
 767             String signingIdentity, String identifierPrefix,
 768             String entitlementsFile, String inheritedEntitlements)
 769             throws IOException {
 770         AtomicReference<IOException> toThrow = new AtomicReference<>();
 771         String appExecutable = "/Contents/MacOS/" + APP_NAME.fetchFrom(params);
 772         String keyChain = SIGNING_KEYCHAIN.fetchFrom(params);
 773 
 774         // sign all dylibs and jars
 775         try (Stream<Path> stream = Files.walk(appLocation)) {
 776             stream.peek(path -> { // fix permissions
 777                 try {
 778                     Set<PosixFilePermission> pfp =
 779                             Files.getPosixFilePermissions(path);
 780                     if (!pfp.contains(PosixFilePermission.OWNER_WRITE)) {
 781                         pfp = EnumSet.copyOf(pfp);
 782                         pfp.add(PosixFilePermission.OWNER_WRITE);
 783                         Files.setPosixFilePermissions(path, pfp);
 784                     }
 785                 } catch (IOException e) {
 786                     Log.verbose(e);
 787                 }
 788             }).filter(p -> Files.isRegularFile(p)
 789                       && !(p.toString().contains("/Contents/MacOS/libjli.dylib")
 790                       || p.toString().endsWith(appExecutable)
 791                       || p.toString().contains("/Contents/runtime")
 792                       || p.toString().contains("/Contents/Frameworks"))
 793                      ).forEach(p -> {
 794                 //noinspection ThrowableResultOfMethodCallIgnored
 795                 if (toThrow.get() != null) return;
 796 
 797                 // If p is a symlink then skip the signing process.
 798                 if (Files.isSymbolicLink(p)) {
 799                     if (VERBOSE.fetchFrom(params)) {
 800                         Log.verbose(MessageFormat.format(I18N.getString(
 801                                 "message.ignoring.symlink"), p.toString()));
 802                     }
 803                 } else {
 804                     if (p.toString().endsWith(LIBRARY_NAME)) {
 805                         if (isFileSigned(p)) {
 806                             return;
 807                         }
 808                     }
 809                     List<String> args = new ArrayList<>();
 810                     args.addAll(Arrays.asList("codesign",
 811                             "--timestamp",
 812                             "--options", "runtime",
 813                             "--deep",
 814                             "--force",
 815                             "-s", signingIdentity,
 816                             "--prefix", identifierPrefix,
 817                             "-vvvv"));
 818                     if (entitlementsFile != null &&
 819                             (p.toString().endsWith(".jar")
 820                             || p.toString().endsWith(".dylib"))) {
 821                         args.add("--entitlements");
 822                         args.add(entitlementsFile); // entitlements
 823                     } else if (inheritedEntitlements != null &&
 824                             Files.isExecutable(p)) {
 825                         args.add("--entitlements");
 826                         args.add(inheritedEntitlements);
 827                         // inherited entitlements for executable processes
 828                     }
 829                     if (keyChain != null && !keyChain.isEmpty()) {
 830                         args.add("--keychain");
 831                         args.add(keyChain);
 832                     }
 833                     args.add(p.toString());
 834 
 835                     try {
 836                         Set<PosixFilePermission> oldPermissions =
 837                                 Files.getPosixFilePermissions(p);
 838                         File f = p.toFile();
 839                         f.setWritable(true, true);
 840 
 841                         ProcessBuilder pb = new ProcessBuilder(args);
 842                         IOUtils.exec(pb);
 843 
 844                         Files.setPosixFilePermissions(p, oldPermissions);
 845                     } catch (IOException ioe) {
 846                         toThrow.set(ioe);
 847                     }
 848                 }
 849             });
 850         }
 851         IOException ioe = toThrow.get();
 852         if (ioe != null) {
 853             throw ioe;
 854         }
 855 
 856         // sign all runtime and frameworks
 857         Consumer<? super Path> signIdentifiedByPList = path -> {
 858             //noinspection ThrowableResultOfMethodCallIgnored
 859             if (toThrow.get() != null) return;
 860 
 861             try {
 862                 List<String> args = new ArrayList<>();
 863                 args.addAll(Arrays.asList("codesign",
 864                         "-f",
 865                         "-s", signingIdentity, // sign with this key
 866                         "--prefix", identifierPrefix,
 867                         // use the identifier as a prefix
 868                         "-vvvv"));
 869 
 870                 if (entitlementsFile != null &&
 871                         (path.toString().endsWith(".jar")
 872                         || path.toString().endsWith(".dylib"))) {
 873                     args.add("--entitlements");
 874                     args.add(entitlementsFile); // entitlements
 875                 } else if (inheritedEntitlements != null &&
 876                         Files.isExecutable(path)) {
 877                     args.add("--entitlements");
 878                     args.add(inheritedEntitlements);
 879                     // inherited entitlements for executable processes
 880                 }
 881 
 882                 if (keyChain != null && !keyChain.isEmpty()) {
 883                     args.add("--keychain");
 884                     args.add(keyChain);
 885                 }
 886                 args.add(path.toString());
 887                 ProcessBuilder pb = new ProcessBuilder(args);
 888                 IOUtils.exec(pb);
 889 
 890 
 891                 args = new ArrayList<>();
 892                 args.addAll(Arrays.asList("codesign",
 893                         "--timestamp",
 894                         "--options", "runtime",
 895                         "--deep",
 896                         "--force",
 897                         "-s", signingIdentity,
 898                         "--prefix", identifierPrefix,
 899                         "-vvvv"));
 900                 if (keyChain != null && !keyChain.isEmpty()) {
 901                     args.add("--keychain");
 902                     args.add(keyChain);
 903                 }
 904                 args.add(path.toString()
 905                         + "/Contents/_CodeSignature/CodeResources");
 906                 pb = new ProcessBuilder(args);
 907                 IOUtils.exec(pb);
 908             } catch (IOException e) {
 909                 toThrow.set(e);
 910             }
 911         };
 912 
 913         Path javaPath = appLocation.resolve("Contents/runtime");
 914         if (Files.isDirectory(javaPath)) {
 915             signIdentifiedByPList.accept(javaPath);
 916 
 917             ioe = toThrow.get();
 918             if (ioe != null) {
 919                 throw ioe;
 920             }
 921         }
 922         Path frameworkPath = appLocation.resolve("Contents/Frameworks");
 923         if (Files.isDirectory(frameworkPath)) {
 924             Files.list(frameworkPath)
 925                     .forEach(signIdentifiedByPList);
 926 
 927             ioe = toThrow.get();
 928             if (ioe != null) {
 929                 throw ioe;
 930             }
 931         }
 932 
 933         // sign the app itself
 934         List<String> args = new ArrayList<>();
 935         args.addAll(Arrays.asList("codesign",
 936                 "--timestamp",
 937                 "--options", "runtime",
 938                 "--deep",
 939                 "--force",
 940                 "-s", signingIdentity,
 941                 "-vvvv"));
 942         if (entitlementsFile != null) {
 943             args.add("--entitlements");
 944             args.add(entitlementsFile); // entitlements
 945         }
 946         if (keyChain != null && !keyChain.isEmpty()) {
 947             args.add("--keychain");
 948             args.add(keyChain);
 949         }
 950         args.add(appLocation.toString());
 951 
 952         ProcessBuilder pb =
 953                 new ProcessBuilder(args.toArray(new String[args.size()]));
 954         IOUtils.exec(pb);
 955     }
 956 
 957     private static boolean isFileSigned(Path file) {
 958         ProcessBuilder pb =
 959                 new ProcessBuilder("codesign", "--verify", file.toString());
 960 
 961         try {
 962             IOUtils.exec(pb);
 963         } catch (IOException ex) {
 964             return false;
 965         }
 966 
 967         return true;
 968     }
 969 
 970     private static String extractBundleIdentifier(Map<String, Object> params) {
 971         if (PREDEFINED_APP_IMAGE.fetchFrom(params) == null) {
 972             return null;
 973         }
 974 
 975         try {
 976             File infoPList = new File(PREDEFINED_APP_IMAGE.fetchFrom(params) +
 977                                       File.separator + "Contents" +
 978                                       File.separator + "Info.plist");
 979 
 980             DocumentBuilderFactory dbf
 981                     = DocumentBuilderFactory.newDefaultInstance();
 982             dbf.setFeature("http://apache.org/xml/features/" +
 983                            "nonvalidating/load-external-dtd", false);
 984             DocumentBuilder b = dbf.newDocumentBuilder();
 985             org.w3c.dom.Document doc = b.parse(new FileInputStream(
 986                     infoPList.getAbsolutePath()));
 987 
 988             XPath xPath = XPathFactory.newInstance().newXPath();
 989             // Query for the value of <string> element preceding <key>
 990             // element with value equal to CFBundleIdentifier
 991             String v = (String) xPath.evaluate(
 992                     "//string[preceding-sibling::key = \"CFBundleIdentifier\"][1]",
 993                     doc, XPathConstants.STRING);
 994 
 995             if (v != null && !v.isEmpty()) {
 996                 return v;
 997             }
 998         } catch (Exception ex) {
 999             Log.verbose(ex);
1000         }
1001 
1002         return null;
1003     }
1004 
1005 }