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