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