8231862: Decouple DesktopIntegration and LinuxPackageBundler classes JDK-8200758-branch
authorherrick
Wed, 16 Oct 2019 09:57:23 -0400
branchJDK-8200758-branch
changeset 58647 2c43b89b1679
parent 58608 a561014c28d0
child 58648 3bf53ffa9ae7
8231862: Decouple DesktopIntegration and LinuxPackageBundler classes Submitted-by: asemenyuk Reviewed-by: aherrick, almatvee
src/jdk.jpackage/linux/classes/jdk/jpackage/internal/DesktopIntegration.java
src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxAppImageBuilder.java
src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebBundler.java
src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackageBundler.java
src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmBundler.java
src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacAppBundler.java
src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacAppImageBuilder.java
src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacAppStoreBundler.java
src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacCertificate.java
src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgBundler.java
src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacPkgBundler.java
src/jdk.jpackage/share/classes/jdk/jpackage/internal/AbstractAppImageBuilder.java
src/jdk.jpackage/share/classes/jdk/jpackage/internal/AbstractBundler.java
src/jdk.jpackage/share/classes/jdk/jpackage/internal/ApplicationLayout.java
src/jdk.jpackage/share/classes/jdk/jpackage/internal/OverridableResource.java
src/jdk.jpackage/share/classes/jdk/jpackage/internal/Platform.java
src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinExeBundler.java
src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinMsiBundler.java
src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WindowsAppImageBuilder.java
src/jdk.jpackage/windows/classes/jdk/jpackage/internal/resources/WinResources.properties
src/jdk.jpackage/windows/classes/jdk/jpackage/internal/resources/WinResources_ja.properties
src/jdk.jpackage/windows/classes/jdk/jpackage/internal/resources/WinResources_zh_CN.properties
test/jdk/tools/jpackage/junit/jdk/jpackage/internal/OverridableResourceTest.java
test/jdk/tools/jpackage/junit/junit.java
test/jdk/tools/jpackage/junit/run_junit.sh
test/jdk/tools/jpackage/macosx/base/SigningCheck.java
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/DesktopIntegration.java	Wed Oct 16 09:57:23 2019 -0400
@@ -0,0 +1,478 @@
+/*
+ * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation.  Oracle designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+package jdk.jpackage.internal;
+
+import java.awt.image.BufferedImage;
+import java.io.*;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import javax.imageio.ImageIO;
+import javax.xml.stream.XMLOutputFactory;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamWriter;
+import static jdk.jpackage.internal.LinuxAppBundler.ICON_PNG;
+import static jdk.jpackage.internal.LinuxAppImageBuilder.DEFAULT_ICON;
+import static jdk.jpackage.internal.OverridableResource.createResource;
+import static jdk.jpackage.internal.StandardBundlerParam.*;
+
+/**
+ * Helper to create files for desktop integration.
+ */
+final class DesktopIntegration {
+
+    static final String DESKTOP_COMMANDS_INSTALL = "DESKTOP_COMMANDS_INSTALL";
+    static final String DESKTOP_COMMANDS_UNINSTALL = "DESKTOP_COMMANDS_UNINSTALL";
+    static final String UTILITY_SCRIPTS = "UTILITY_SCRIPTS";
+
+    DesktopIntegration(PlatformPackage thePackage,
+            Map<String, ? super Object> params) {
+
+        associations = FILE_ASSOCIATIONS.fetchFrom(params).stream().filter(
+                a -> {
+                    if (a == null) {
+                        return false;
+                    }
+                    List<String> mimes = FA_CONTENT_TYPE.fetchFrom(a);
+                    return (mimes != null && !mimes.isEmpty());
+                }).collect(Collectors.toUnmodifiableList());
+
+        launchers = ADD_LAUNCHERS.fetchFrom(params);
+
+        this.thePackage = thePackage;
+
+        final File customIconFile = ICON_PNG.fetchFrom(params);
+
+        iconResource = createResource(DEFAULT_ICON, params)
+                .setCategory(I18N.getString("resource.menu-icon"))
+                .setExternal(customIconFile);
+        desktopFileResource = createResource("template.desktop", params)
+                .setCategory(I18N.getString("resource.menu-shortcut-descriptor"));
+
+        // XDG recommends to use vendor prefix in desktop file names as xdg
+        // commands copy files to system directories.
+        // Package name should be a good prefix.
+        final String desktopFileName = String.format("%s-%s.desktop",
+                    thePackage.name(), APP_NAME.fetchFrom(params));
+        final String mimeInfoFileName = String.format("%s-%s-MimeInfo.xml",
+                    thePackage.name(), APP_NAME.fetchFrom(params));
+
+        mimeInfoFile = new DesktopFile(mimeInfoFileName);
+
+        if (!associations.isEmpty() || SHORTCUT_HINT.fetchFrom(params) || customIconFile != null) {
+            //
+            // Create primary .desktop file if one of conditions is met:
+            // - there are file associations configured
+            // - user explicitely requested to create a shortcut
+            // - custom icon specified
+            //
+            desktopFile = new DesktopFile(desktopFileName);
+            iconFile = new DesktopFile(String.format("%s.png",
+                    APP_NAME.fetchFrom(params)));
+        } else {
+            desktopFile = null;
+            iconFile = null;
+        }
+
+        desktopFileData = Collections.unmodifiableMap(
+                createDataForDesktopFile(params));
+
+        nestedIntegrations = launchers.stream().map(
+                launcherParams -> new DesktopIntegration(thePackage,
+                        launcherParams)).collect(Collectors.toList());
+    }
+
+    List<String> requiredPackages() {
+        return Stream.of(List.of(this), nestedIntegrations).flatMap(
+                List::stream).map(DesktopIntegration::requiredPackagesSelf).flatMap(
+                List::stream).distinct().collect(Collectors.toList());
+    }
+
+    Map<String, String> create() throws IOException {
+        if (iconFile != null) {
+            // Create application icon file.
+            iconResource.saveToFile(iconFile.srcPath());
+        }
+
+        Map<String, String> data = new HashMap<>(desktopFileData);
+
+        final ShellCommands shellCommands;
+        if (desktopFile != null) {
+            // Create application desktop description file.
+            createDesktopFile(data);
+
+            // Shell commands will be created only if desktop file
+            // should be installed.
+            shellCommands = new ShellCommands();
+        } else {
+            shellCommands = null;
+        }
+
+        if (!associations.isEmpty()) {
+            // Create XML file with mime types corresponding to file associations.
+            createFileAssociationsMimeInfoFile();
+
+            shellCommands.setFileAssociations();
+
+            // Create icon files corresponding to file associations
+            Map<String, Path> mimeTypeWithIconFile = createFileAssociationIconFiles();
+            mimeTypeWithIconFile.forEach((k, v) -> {
+                shellCommands.addIcon(k, v);
+            });
+        }
+
+        // Create shell commands to install/uninstall integration with desktop of the app.
+        if (shellCommands != null) {
+            shellCommands.applyTo(data);
+        }
+
+        boolean needCleanupScripts = !associations.isEmpty();
+
+        // Take care of additional launchers if there are any.
+        // Process every additional launcher as the main application launcher.
+        // Collect shell commands to install/uninstall integration with desktop
+        // of the additional launchers and append them to the corresponding
+        // commands of the main launcher.
+        List<String> installShellCmds = new ArrayList<>(Arrays.asList(
+                data.get(DESKTOP_COMMANDS_INSTALL)));
+        List<String> uninstallShellCmds = new ArrayList<>(Arrays.asList(
+                data.get(DESKTOP_COMMANDS_UNINSTALL)));
+        for (var integration: nestedIntegrations) {
+            if (!integration.associations.isEmpty()) {
+                needCleanupScripts = true;
+            }
+
+            Map<String, String> launcherData = integration.create();
+
+            installShellCmds.add(launcherData.get(DESKTOP_COMMANDS_INSTALL));
+            uninstallShellCmds.add(launcherData.get(
+                    DESKTOP_COMMANDS_UNINSTALL));
+        }
+
+        data.put(DESKTOP_COMMANDS_INSTALL, stringifyShellCommands(
+                installShellCmds));
+        data.put(DESKTOP_COMMANDS_UNINSTALL, stringifyShellCommands(
+                uninstallShellCmds));
+
+        if (needCleanupScripts) {
+            // Pull in utils.sh scrips library.
+            try (InputStream is = OverridableResource.readDefault("utils.sh");
+                    InputStreamReader isr = new InputStreamReader(is);
+                    BufferedReader reader = new BufferedReader(isr)) {
+                data.put(UTILITY_SCRIPTS, reader.lines().collect(
+                        Collectors.joining(System.lineSeparator())));
+            }
+        } else {
+            data.put(UTILITY_SCRIPTS, "");
+        }
+
+        return data;
+    }
+
+    private List<String> requiredPackagesSelf() {
+        if (desktopFile != null) {
+            return List.of("xdg-utils");
+        }
+        return Collections.emptyList();
+    }
+
+    private Map<String, String> createDataForDesktopFile(
+            Map<String, ? super Object> params) {
+        Map<String, String> data = new HashMap<>();
+        data.put("APPLICATION_NAME", APP_NAME.fetchFrom(params));
+        data.put("APPLICATION_DESCRIPTION", DESCRIPTION.fetchFrom(params));
+        data.put("APPLICATION_ICON",
+                iconFile != null ? iconFile.installPath().toString() : null);
+        data.put("DEPLOY_BUNDLE_CATEGORY", MENU_GROUP.fetchFrom(params));
+        data.put("APPLICATION_LAUNCHER",
+                thePackage.installedApplicationLayout().launchersDirectory().resolve(
+                        LinuxAppImageBuilder.getLauncherName(params)).toString());
+
+        return data;
+    }
+
+    /**
+     * Shell commands to integrate something with desktop.
+     */
+    private class ShellCommands {
+
+        ShellCommands() {
+            registerIconCmds = new ArrayList<>();
+            unregisterIconCmds = new ArrayList<>();
+
+            registerDesktopFileCmd = String.join(" ", "xdg-desktop-menu",
+                    "install", desktopFile.installPath().toString());
+            unregisterDesktopFileCmd = String.join(" ", "xdg-desktop-menu",
+                    "uninstall", desktopFile.installPath().toString());
+        }
+
+        void setFileAssociations() {
+            registerFileAssociationsCmd = String.join(" ", "xdg-mime",
+                    "install",
+                    mimeInfoFile.installPath().toString());
+            unregisterFileAssociationsCmd = String.join(" ", "xdg-mime",
+                    "uninstall", mimeInfoFile.installPath().toString());
+
+            //
+            // Add manual cleanup of system files to get rid of
+            // the default mime type handlers.
+            //
+            // Even after mime type is unregisterd with `xdg-mime uninstall`
+            // command and desktop file deleted with `xdg-desktop-menu uninstall`
+            // command, records in
+            // `/usr/share/applications/defaults.list` (Ubuntu 16) or
+            // `/usr/local/share/applications/defaults.list` (OracleLinux 7)
+            // files remain referencing deleted mime time and deleted
+            // desktop file which makes `xdg-mime query default` output name
+            // of non-existing desktop file.
+            //
+            String cleanUpCommand = String.join(" ",
+                    "uninstall_default_mime_handler",
+                    desktopFile.installPath().getFileName().toString(),
+                    String.join(" ", getMimeTypeNamesFromFileAssociations()));
+
+            unregisterFileAssociationsCmd = stringifyShellCommands(
+                    unregisterFileAssociationsCmd, cleanUpCommand);
+        }
+
+        void addIcon(String mimeType, Path iconFile) {
+            final int imgSize = getSquareSizeOfImage(iconFile.toFile());
+            final String dashMime = mimeType.replace('/', '-');
+            registerIconCmds.add(String.join(" ", "xdg-icon-resource",
+                    "install", "--context", "mimetypes", "--size ",
+                    Integer.toString(imgSize), iconFile.toString(), dashMime));
+            unregisterIconCmds.add(String.join(" ", "xdg-icon-resource",
+                    "uninstall", dashMime));
+        }
+
+        void applyTo(Map<String, String> data) {
+            List<String> cmds = new ArrayList<>();
+
+            cmds.add(registerDesktopFileCmd);
+            cmds.add(registerFileAssociationsCmd);
+            cmds.addAll(registerIconCmds);
+            data.put(DESKTOP_COMMANDS_INSTALL, stringifyShellCommands(cmds));
+
+            cmds.clear();
+            cmds.add(unregisterDesktopFileCmd);
+            cmds.add(unregisterFileAssociationsCmd);
+            cmds.addAll(unregisterIconCmds);
+            data.put(DESKTOP_COMMANDS_UNINSTALL, stringifyShellCommands(cmds));
+        }
+
+        private String registerDesktopFileCmd;
+        private String unregisterDesktopFileCmd;
+
+        private String registerFileAssociationsCmd;
+        private String unregisterFileAssociationsCmd;
+
+        private List<String> registerIconCmds;
+        private List<String> unregisterIconCmds;
+    }
+
+    /**
+     * Desktop integration file. xml, icon, etc.
+     * Resides somewhere in application installation tree.
+     * Has two paths:
+     *  - path where it should be placed at package build time;
+     *  - path where it should be installed by package manager;
+     */
+    private class DesktopFile {
+
+        DesktopFile(String fileName) {
+            installPath = thePackage
+                    .installedApplicationLayout()
+                    .destktopIntegrationDirectory().resolve(fileName);
+            srcPath = thePackage
+                    .sourceApplicationLayout()
+                    .destktopIntegrationDirectory().resolve(fileName);
+        }
+
+        private final Path installPath;
+        private final Path srcPath;
+
+        Path installPath() {
+            return installPath;
+        }
+
+        Path srcPath() {
+            return srcPath;
+        }
+    }
+
+    private void appendFileAssociation(XMLStreamWriter xml,
+            Map<String, ? super Object> assoc) throws XMLStreamException {
+
+        xml.writeStartElement("mime-type");
+        final String thisMime = FA_CONTENT_TYPE.fetchFrom(assoc).get(0);
+        xml.writeAttribute("type", thisMime);
+
+        final String description = FA_DESCRIPTION.fetchFrom(assoc);
+        if (description != null && !description.isEmpty()) {
+            xml.writeStartElement("comment");
+            xml.writeCharacters(description);
+            xml.writeEndElement();
+        }
+
+        final List<String> extensions = FA_EXTENSIONS.fetchFrom(assoc);
+        if (extensions == null) {
+            Log.error(I18N.getString(
+                    "message.creating-association-with-null-extension"));
+        } else {
+            for (String ext : extensions) {
+                xml.writeStartElement("glob");
+                xml.writeAttribute("pattern", "*." + ext);
+                xml.writeEndElement();
+            }
+        }
+
+        xml.writeEndElement();
+    }
+
+    private void createFileAssociationsMimeInfoFile() throws IOException {
+        XMLOutputFactory xmlFactory = XMLOutputFactory.newInstance();
+
+        try (Writer w = new BufferedWriter(new FileWriter(
+                mimeInfoFile.srcPath().toFile()))) {
+            XMLStreamWriter xml = xmlFactory.createXMLStreamWriter(w);
+
+            xml.writeStartDocument();
+            xml.writeStartElement("mime-info");
+            xml.writeNamespace("xmlns",
+                    "http://www.freedesktop.org/standards/shared-mime-info");
+
+            for (var assoc : associations) {
+                appendFileAssociation(xml, assoc);
+            }
+
+            xml.writeEndElement();
+            xml.writeEndDocument();
+            xml.flush();
+            xml.close();
+
+        } catch (XMLStreamException ex) {
+            Log.verbose(ex);
+            throw new IOException(ex);
+        }
+    }
+
+    private Map<String, Path> createFileAssociationIconFiles() throws
+            IOException {
+        Map<String, Path> mimeTypeWithIconFile = new HashMap<>();
+        for (var assoc : associations) {
+            File customFaIcon = FA_ICON.fetchFrom(assoc);
+            if (customFaIcon == null || !customFaIcon.exists() || getSquareSizeOfImage(
+                    customFaIcon) == 0) {
+                continue;
+            }
+
+            String fname = iconFile.srcPath().getFileName().toString();
+            if (fname.indexOf(".") > 0) {
+                fname = fname.substring(0, fname.lastIndexOf("."));
+            }
+
+            DesktopFile faIconFile = new DesktopFile(
+                    fname + "_fa_" + customFaIcon.getName());
+
+            IOUtils.copyFile(customFaIcon, faIconFile.srcPath().toFile());
+
+            mimeTypeWithIconFile.put(FA_CONTENT_TYPE.fetchFrom(assoc).get(0),
+                    faIconFile.installPath());
+        }
+        return mimeTypeWithIconFile;
+    }
+
+    private void createDesktopFile(Map<String, String> data) throws IOException {
+        List<String> mimeTypes = getMimeTypeNamesFromFileAssociations();
+        data.put("DESKTOP_MIMES", "MimeType=" + String.join(";", mimeTypes));
+
+        // prepare desktop shortcut
+        desktopFileResource
+                .setSubstitutionData(data)
+                .saveToFile(desktopFile.srcPath());
+    }
+
+    private List<String> getMimeTypeNamesFromFileAssociations() {
+        return associations.stream().map(
+                a -> FA_CONTENT_TYPE.fetchFrom(a).get(0)).collect(
+                        Collectors.toUnmodifiableList());
+    }
+
+    private static int getSquareSizeOfImage(File f) {
+        try {
+            BufferedImage bi = ImageIO.read(f);
+            if (bi.getWidth() == bi.getHeight()) {
+                return bi.getWidth();
+            }
+        } catch (IOException e) {
+            Log.verbose(e);
+        }
+        return 0;
+    }
+
+    private static String stringifyShellCommands(String... commands) {
+        return stringifyShellCommands(Arrays.asList(commands));
+    }
+
+    private static String stringifyShellCommands(List<String> commands) {
+        return String.join(System.lineSeparator(), commands.stream().filter(
+                s -> s != null && !s.isEmpty()).collect(Collectors.toList()));
+    }
+
+    private final PlatformPackage thePackage;
+
+    private final List<Map<String, ? super Object>> associations;
+
+    private final List<Map<String, ? super Object>> launchers;
+
+    private final OverridableResource iconResource;
+    private final OverridableResource desktopFileResource;
+
+    private final DesktopFile mimeInfoFile;
+    private final DesktopFile desktopFile;
+    private final DesktopFile iconFile;
+
+    private final List<DesktopIntegration> nestedIntegrations;
+
+    private final Map<String, String> desktopFileData;
+
+    private static final BundlerParamInfo<String> MENU_GROUP =
+        new StandardBundlerParam<>(
+                Arguments.CLIOptions.LINUX_MENU_GROUP.getId(),
+                String.class,
+                params -> I18N.getString("param.menu-group.default"),
+                (s, p) -> s
+        );
+
+    private static final StandardBundlerParam<Boolean> SHORTCUT_HINT =
+        new StandardBundlerParam<>(
+                Arguments.CLIOptions.LINUX_SHORTCUT_HINT.getId(),
+                Boolean.class,
+                params -> false,
+                (s, p) -> (s == null || "null".equalsIgnoreCase(s))
+                        ? false : Boolean.valueOf(s)
+        );
+}
--- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxAppImageBuilder.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxAppImageBuilder.java	Wed Oct 16 09:57:23 2019 -0400
@@ -30,13 +30,11 @@
 import java.io.InputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
 import java.text.MessageFormat;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
-import java.util.ResourceBundle;
+import static jdk.jpackage.internal.OverridableResource.createResource;
 
 import static jdk.jpackage.internal.StandardBundlerParam.*;
 
@@ -170,19 +168,13 @@
     private void copyIcon(Map<String, ? super Object> params)
             throws IOException {
 
-        File icon = ICON_PNG.fetchFrom(params);
-        File iconTarget = appLayout.destktopIntegrationDirectory().resolve(
-                APP_NAME.fetchFrom(params) + ".png").toFile();
+        Path iconTarget = appLayout.destktopIntegrationDirectory().resolve(
+                APP_NAME.fetchFrom(params) + ".png");
 
-        InputStream in = locateResource(
-                iconTarget.getName(),
-                "icon",
-                DEFAULT_ICON,
-                icon,
-                VERBOSE.fetchFrom(params),
-                RESOURCE_DIR.fetchFrom(params));
-
-        Files.copy(in, iconTarget.toPath(), StandardCopyOption.REPLACE_EXISTING);
+        createResource(DEFAULT_ICON, params)
+                .setCategory("icon")
+                .setExternal(ICON_PNG.fetchFrom(params))
+                .saveToFile(iconTarget);
     }
 
     private void copyApplication(Map<String, ? super Object> params)
--- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebBundler.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebBundler.java	Wed Oct 16 09:57:23 2019 -0400
@@ -42,6 +42,7 @@
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import static jdk.jpackage.internal.LinuxAppBundler.LINUX_INSTALL_DIR;
+import static jdk.jpackage.internal.OverridableResource.createResource;
 
 import static jdk.jpackage.internal.StandardBundlerParam.*;
 
@@ -332,17 +333,11 @@
 
         void create(Map<String, String> data, Map<String, ? super Object> params)
                 throws IOException {
-            Files.createDirectories(dstFilePath.getParent());
-            try (Writer w = Files.newBufferedWriter(dstFilePath)) {
-                String content = preprocessTextResource(
-                        dstFilePath.getFileName().toString(),
-                        I18N.getString(comment),
-                        "template." + dstFilePath.getFileName().toString(),
-                        data,
-                        VERBOSE.fetchFrom(params),
-                        RESOURCE_DIR.fetchFrom(params));
-                w.write(content);
-            }
+            createResource("template." + dstFilePath.getFileName().toString(),
+                    params)
+                    .setCategory(I18N.getString(comment))
+                    .setSubstitutionData(data)
+                    .saveToFile(dstFilePath);
             if (permissions != null) {
                 setPermissions(dstFilePath.toFile(), permissions);
             }
--- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackageBundler.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackageBundler.java	Wed Oct 16 09:57:23 2019 -0400
@@ -24,65 +24,26 @@
  */
 package jdk.jpackage.internal;
 
-import java.awt.image.BufferedImage;
 import java.io.*;
 import java.nio.file.InvalidPathException;
-import java.nio.file.Files;
 import java.nio.file.Path;
 import java.text.MessageFormat;
 import java.util.*;
 import java.util.function.Predicate;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
-import javax.imageio.ImageIO;
-import javax.xml.stream.XMLOutputFactory;
-import javax.xml.stream.XMLStreamException;
-import javax.xml.stream.XMLStreamWriter;
+import static jdk.jpackage.internal.DesktopIntegration.*;
 import static jdk.jpackage.internal.LinuxAppBundler.LINUX_INSTALL_DIR;
 import static jdk.jpackage.internal.LinuxAppBundler.LINUX_PACKAGE_DEPENDENCIES;
-import static jdk.jpackage.internal.LinuxAppImageBuilder.DEFAULT_ICON;
-import static jdk.jpackage.internal.LinuxAppImageBuilder.ICON_PNG;
 import static jdk.jpackage.internal.StandardBundlerParam.*;
 
 
 abstract class LinuxPackageBundler extends AbstractBundler {
 
-    private static final String DESKTOP_COMMANDS_INSTALL = "DESKTOP_COMMANDS_INSTALL";
-    private static final String DESKTOP_COMMANDS_UNINSTALL = "DESKTOP_COMMANDS_UNINSTALL";
-    private static final String UTILITY_SCRIPTS = "UTILITY_SCRIPTS";
-
-    private static final BundlerParamInfo<LinuxAppBundler> APP_BUNDLER =
-        new StandardBundlerParam<>(
-                "linux.app.bundler",
-                LinuxAppBundler.class,
-                (params) -> new LinuxAppBundler(),
-                null
-        );
-
-    private static final BundlerParamInfo<String> MENU_GROUP =
-        new StandardBundlerParam<>(
-                Arguments.CLIOptions.LINUX_MENU_GROUP.getId(),
-                String.class,
-                params -> I18N.getString("param.menu-group.default"),
-                (s, p) -> s
-        );
-
-    private static final StandardBundlerParam<Boolean> SHORTCUT_HINT =
-        new StandardBundlerParam<>(
-                Arguments.CLIOptions.LINUX_SHORTCUT_HINT.getId(),
-                Boolean.class,
-                params -> false,
-                (s, p) -> (s == null || "null".equalsIgnoreCase(s))
-                        ? false : Boolean.valueOf(s)
-        );
-
     LinuxPackageBundler(BundlerParamInfo<String> packageName) {
         this.packageName = packageName;
     }
 
-    private final BundlerParamInfo<String> packageName;
-    private boolean withFindNeededPackages;
-
     @Override
     final public boolean validate(Map<String, ? super Object> params)
             throws ConfigException {
@@ -361,449 +322,16 @@
         }
     }
 
-    /**
-     * Helper to create files for desktop integration.
-     */
-    private class DesktopIntegration {
-
-        DesktopIntegration(PlatformPackage thePackage,
-                Map<String, ? super Object> params) {
-
-            associations = FILE_ASSOCIATIONS.fetchFrom(params).stream().filter(
-                    a -> {
-                        if (a == null) {
-                            return false;
-                        }
-                        List<String> mimes = FA_CONTENT_TYPE.fetchFrom(a);
-                        return (mimes != null && !mimes.isEmpty());
-                    }).collect(Collectors.toUnmodifiableList());
-
-            launchers = ADD_LAUNCHERS.fetchFrom(params);
-
-            this.thePackage = thePackage;
-
-            customIconFile = ICON_PNG.fetchFrom(params);
-
-            verbose = VERBOSE.fetchFrom(params);
-            resourceDir = RESOURCE_DIR.fetchFrom(params);
-
-            // XDG recommends to use vendor prefix in desktop file names as xdg
-            // commands copy files to system directories.
-            // Package name should be a good prefix.
-            final String desktopFileName = String.format("%s-%s.desktop",
-                        thePackage.name(), APP_NAME.fetchFrom(params));
-            final String mimeInfoFileName = String.format("%s-%s-MimeInfo.xml",
-                        thePackage.name(), APP_NAME.fetchFrom(params));
-
-            mimeInfoFile = new DesktopFile(mimeInfoFileName);
-
-            if (!associations.isEmpty() || SHORTCUT_HINT.fetchFrom(params) || customIconFile != null) {
-                //
-                // Create primary .desktop file if one of conditions is met:
-                // - there are file associations configured
-                // - user explicitely requested to create a shortcut
-                // - custom icon specified
-                //
-                desktopFile = new DesktopFile(desktopFileName);
-                iconFile = new DesktopFile(String.format("%s.png",
-                        APP_NAME.fetchFrom(params)));
-            } else {
-                desktopFile = null;
-                iconFile = null;
-            }
-
-            desktopFileData = Collections.unmodifiableMap(
-                    createDataForDesktopFile(params));
-
-            nestedIntegrations = launchers.stream().map(
-                    launcherParams -> new DesktopIntegration(thePackage,
-                            launcherParams)).collect(Collectors.toList());
-        }
-
-        List<String> requiredPackages() {
-            return Stream.of(List.of(this), nestedIntegrations).flatMap(
-                    List::stream).map(DesktopIntegration::requiredPackagesSelf).flatMap(
-                    List::stream).distinct().collect(Collectors.toList());
-        }
-
-        Map<String, String> create() throws IOException {
-            if (iconFile != null) {
-                // Create application icon file.
-                prepareSrcIconFile();
-            }
-
-            Map<String, String> data = new HashMap<>(desktopFileData);
-
-            final ShellCommands shellCommands;
-            if (desktopFile != null) {
-                // Create application desktop description file.
-                createDesktopFile(data);
-
-                // Shell commands will be created only if desktop file
-                // should be installed.
-                shellCommands = new ShellCommands();
-            } else {
-                shellCommands = null;
-            }
-
-            if (!associations.isEmpty()) {
-                // Create XML file with mime types corresponding to file associations.
-                createFileAssociationsMimeInfoFile();
-
-                shellCommands.setFileAssociations();
-
-                // Create icon files corresponding to file associations
-                Map<String, Path> mimeTypeWithIconFile = createFileAssociationIconFiles();
-                mimeTypeWithIconFile.forEach((k, v) -> {
-                    shellCommands.addIcon(k, v);
-                });
-            }
-
-            // Create shell commands to install/uninstall integration with desktop of the app.
-            if (shellCommands != null) {
-                shellCommands.applyTo(data);
-            }
-
-            boolean needCleanupScripts = !associations.isEmpty();
-
-            // Take care of additional launchers if there are any.
-            // Process every additional launcher as the main application launcher.
-            // Collect shell commands to install/uninstall integration with desktop
-            // of the additional launchers and append them to the corresponding
-            // commands of the main launcher.
-            List<String> installShellCmds = new ArrayList<>(Arrays.asList(
-                    data.get(DESKTOP_COMMANDS_INSTALL)));
-            List<String> uninstallShellCmds = new ArrayList<>(Arrays.asList(
-                    data.get(DESKTOP_COMMANDS_UNINSTALL)));
-            for (var integration: nestedIntegrations) {
-                if (!integration.associations.isEmpty()) {
-                    needCleanupScripts = true;
-                }
-
-                Map<String, String> launcherData = integration.create();
-
-                installShellCmds.add(launcherData.get(DESKTOP_COMMANDS_INSTALL));
-                uninstallShellCmds.add(launcherData.get(
-                        DESKTOP_COMMANDS_UNINSTALL));
-            }
-
-            data.put(DESKTOP_COMMANDS_INSTALL, stringifyShellCommands(
-                    installShellCmds));
-            data.put(DESKTOP_COMMANDS_UNINSTALL, stringifyShellCommands(
-                    uninstallShellCmds));
-
-            if (needCleanupScripts) {
-                // Pull in utils.sh scrips library.
-                try (InputStream is = getResourceAsStream("utils.sh");
-                        InputStreamReader isr = new InputStreamReader(is);
-                        BufferedReader reader = new BufferedReader(isr)) {
-                    data.put(UTILITY_SCRIPTS, reader.lines().collect(
-                            Collectors.joining(System.lineSeparator())));
-                }
-            } else {
-                data.put(UTILITY_SCRIPTS, "");
-            }
-
-            return data;
-        }
-
-        private List<String> requiredPackagesSelf() {
-            if (desktopFile != null) {
-                return List.of("xdg-utils");
-            }
-            return Collections.emptyList();
-        }
-
-        private Map<String, String> createDataForDesktopFile(
-                Map<String, ? super Object> params) {
-            Map<String, String> data = new HashMap<>();
-            data.put("APPLICATION_NAME", APP_NAME.fetchFrom(params));
-            data.put("APPLICATION_DESCRIPTION", DESCRIPTION.fetchFrom(params));
-            data.put("APPLICATION_ICON",
-                    iconFile != null ? iconFile.installPath().toString() : null);
-            data.put("DEPLOY_BUNDLE_CATEGORY", MENU_GROUP.fetchFrom(params));
-            data.put("APPLICATION_LAUNCHER",
-                    thePackage.installedApplicationLayout().launchersDirectory().resolve(
-                            LinuxAppImageBuilder.getLauncherName(params)).toString());
-
-            return data;
-        }
-
-        /**
-         * Shell commands to integrate something with desktop.
-         */
-        private class ShellCommands {
-
-            ShellCommands() {
-                registerIconCmds = new ArrayList<>();
-                unregisterIconCmds = new ArrayList<>();
-
-                registerDesktopFileCmd = String.join(" ", "xdg-desktop-menu",
-                        "install", desktopFile.installPath().toString());
-                unregisterDesktopFileCmd = String.join(" ", "xdg-desktop-menu",
-                        "uninstall", desktopFile.installPath().toString());
-            }
-
-            void setFileAssociations() {
-                registerFileAssociationsCmd = String.join(" ", "xdg-mime",
-                        "install",
-                        mimeInfoFile.installPath().toString());
-                unregisterFileAssociationsCmd = String.join(" ", "xdg-mime",
-                        "uninstall", mimeInfoFile.installPath().toString());
-
-                //
-                // Add manual cleanup of system files to get rid of
-                // the default mime type handlers.
-                //
-                // Even after mime type is unregisterd with `xdg-mime uninstall`
-                // command and desktop file deleted with `xdg-desktop-menu uninstall`
-                // command, records in
-                // `/usr/share/applications/defaults.list` (Ubuntu 16) or
-                // `/usr/local/share/applications/defaults.list` (OracleLinux 7)
-                // files remain referencing deleted mime time and deleted
-                // desktop file which makes `xdg-mime query default` output name
-                // of non-existing desktop file.
-                //
-                String cleanUpCommand = String.join(" ",
-                        "uninstall_default_mime_handler",
-                        desktopFile.installPath().getFileName().toString(),
-                        String.join(" ", getMimeTypeNamesFromFileAssociations()));
-
-                unregisterFileAssociationsCmd = stringifyShellCommands(
-                        unregisterFileAssociationsCmd, cleanUpCommand);
-            }
-
-            void addIcon(String mimeType, Path iconFile) {
-                final int imgSize = getSquareSizeOfImage(iconFile.toFile());
-                final String dashMime = mimeType.replace('/', '-');
-                registerIconCmds.add(String.join(" ", "xdg-icon-resource",
-                        "install", "--context", "mimetypes", "--size ",
-                        Integer.toString(imgSize), iconFile.toString(), dashMime));
-                unregisterIconCmds.add(String.join(" ", "xdg-icon-resource",
-                        "uninstall", dashMime));
-            }
+    private final BundlerParamInfo<String> packageName;
+    private boolean withFindNeededPackages;
+    private DesktopIntegration desktopIntegration;
 
-            void applyTo(Map<String, String> data) {
-                List<String> cmds = new ArrayList<>();
-
-                cmds.add(registerDesktopFileCmd);
-                cmds.add(registerFileAssociationsCmd);
-                cmds.addAll(registerIconCmds);
-                data.put(DESKTOP_COMMANDS_INSTALL, stringifyShellCommands(cmds));
-
-                cmds.clear();
-                cmds.add(unregisterDesktopFileCmd);
-                cmds.add(unregisterFileAssociationsCmd);
-                cmds.addAll(unregisterIconCmds);
-                data.put(DESKTOP_COMMANDS_UNINSTALL, stringifyShellCommands(cmds));
-            }
-
-            private String registerDesktopFileCmd;
-            private String unregisterDesktopFileCmd;
-
-            private String registerFileAssociationsCmd;
-            private String unregisterFileAssociationsCmd;
-
-            private List<String> registerIconCmds;
-            private List<String> unregisterIconCmds;
-        }
-
-        private final PlatformPackage thePackage;
-
-        private final List<Map<String, ? super Object>> associations;
-
-        private final List<Map<String, ? super Object>> launchers;
-
-        /**
-         * Desktop integration file. xml, icon, etc.
-         * Resides somewhere in application installation tree.
-         * Has two paths:
-         *  - path where it should be placed at package build time;
-         *  - path where it should be installed by package manager;
-         */
-        private class DesktopFile {
-
-            DesktopFile(String fileName) {
-                installPath = thePackage
-                        .installedApplicationLayout()
-                        .destktopIntegrationDirectory().resolve(fileName);
-                srcPath = thePackage
-                        .sourceApplicationLayout()
-                        .destktopIntegrationDirectory().resolve(fileName);
-            }
-
-            private final Path installPath;
-            private final Path srcPath;
-
-            Path installPath() {
-                return installPath;
-            }
-
-            Path srcPath() {
-                return srcPath;
-            }
-        }
-
-        private final boolean verbose;
-        private final File resourceDir;
-
-        private final DesktopFile mimeInfoFile;
-        private final DesktopFile desktopFile;
-        private final DesktopFile iconFile;
-
-        final private List<DesktopIntegration> nestedIntegrations;
-
-        private final Map<String, String> desktopFileData;
-
-        /**
-         * Path to icon file provided by user or null.
-         */
-        private final File customIconFile;
-
-        private void appendFileAssociation(XMLStreamWriter xml,
-                Map<String, ? super Object> assoc) throws XMLStreamException {
-
-            xml.writeStartElement("mime-type");
-            final String thisMime = FA_CONTENT_TYPE.fetchFrom(assoc).get(0);
-            xml.writeAttribute("type", thisMime);
-
-            final String description = FA_DESCRIPTION.fetchFrom(assoc);
-            if (description != null && !description.isEmpty()) {
-                xml.writeStartElement("comment");
-                xml.writeCharacters(description);
-                xml.writeEndElement();
-            }
-
-            final List<String> extensions = FA_EXTENSIONS.fetchFrom(assoc);
-            if (extensions == null) {
-                Log.error(I18N.getString(
-                        "message.creating-association-with-null-extension"));
-            } else {
-                for (String ext : extensions) {
-                    xml.writeStartElement("glob");
-                    xml.writeAttribute("pattern", "*." + ext);
-                    xml.writeEndElement();
-                }
-            }
-
-            xml.writeEndElement();
-        }
-
-        private void createFileAssociationsMimeInfoFile() throws IOException {
-            XMLOutputFactory xmlFactory = XMLOutputFactory.newInstance();
+    private static final BundlerParamInfo<LinuxAppBundler> APP_BUNDLER =
+        new StandardBundlerParam<>(
+                "linux.app.bundler",
+                LinuxAppBundler.class,
+                (params) -> new LinuxAppBundler(),
+                null
+        );
 
-            try (Writer w = new BufferedWriter(new FileWriter(
-                    mimeInfoFile.srcPath().toFile()))) {
-                XMLStreamWriter xml = xmlFactory.createXMLStreamWriter(w);
-
-                xml.writeStartDocument();
-                xml.writeStartElement("mime-info");
-                xml.writeNamespace("xmlns",
-                      "https://www.freedesktop.org/standards/shared-mime-info");
-
-                for (var assoc : associations) {
-                    appendFileAssociation(xml, assoc);
-                }
-
-                xml.writeEndElement();
-                xml.writeEndDocument();
-                xml.flush();
-                xml.close();
-
-            } catch (XMLStreamException ex) {
-                Log.verbose(ex);
-                throw new IOException(ex);
-            }
-        }
-
-        private Map<String, Path> createFileAssociationIconFiles() throws
-                IOException {
-            Map<String, Path> mimeTypeWithIconFile = new HashMap<>();
-            for (var assoc : associations) {
-                File customFaIcon = FA_ICON.fetchFrom(assoc);
-                if (customFaIcon == null || !customFaIcon.exists() || getSquareSizeOfImage(
-                        customFaIcon) == 0) {
-                    continue;
-                }
-
-                String fname = iconFile.srcPath().getFileName().toString();
-                if (fname.indexOf(".") > 0) {
-                    fname = fname.substring(0, fname.lastIndexOf("."));
-                }
-
-                DesktopFile faIconFile = new DesktopFile(
-                        fname + "_fa_" + customFaIcon.getName());
-
-                IOUtils.copyFile(customFaIcon, faIconFile.srcPath().toFile());
-
-                mimeTypeWithIconFile.put(FA_CONTENT_TYPE.fetchFrom(assoc).get(0),
-                        faIconFile.installPath());
-            }
-            return mimeTypeWithIconFile;
-        }
-
-        private void createDesktopFile(Map<String, String> data) throws IOException {
-            List<String> mimeTypes = getMimeTypeNamesFromFileAssociations();
-            data.put("DESKTOP_MIMES", "MimeType=" + String.join(";", mimeTypes));
-
-            // prepare desktop shortcut
-            try (Writer w = Files.newBufferedWriter(desktopFile.srcPath())) {
-                String content = preprocessTextResource(
-                        desktopFile.srcPath().getFileName().toString(),
-                        I18N.getString("resource.menu-shortcut-descriptor"),
-                        "template.desktop",
-                        data,
-                        verbose,
-                        resourceDir);
-                w.write(content);
-            }
-        }
-
-        private void prepareSrcIconFile() throws IOException {
-            if (customIconFile == null || !customIconFile.exists()) {
-                fetchResource(iconFile.srcPath().getFileName().toString(),
-                        I18N.getString("resource.menu-icon"),
-                        DEFAULT_ICON,
-                        iconFile.srcPath().toFile(),
-                        verbose,
-                        resourceDir);
-            } else {
-                fetchResource(iconFile.srcPath().getFileName().toString(),
-                        I18N.getString("resource.menu-icon"),
-                        customIconFile,
-                        iconFile.srcPath().toFile(),
-                        verbose,
-                        resourceDir);
-            }
-        }
-
-        private List<String> getMimeTypeNamesFromFileAssociations() {
-            return associations.stream().map(
-                    a -> FA_CONTENT_TYPE.fetchFrom(a).get(0)).collect(
-                            Collectors.toUnmodifiableList());
-        }
-    }
-
-    private static int getSquareSizeOfImage(File f) {
-        try {
-            BufferedImage bi = ImageIO.read(f);
-            if (bi.getWidth() == bi.getHeight()) {
-                return bi.getWidth();
-            }
-        } catch (IOException e) {
-            Log.verbose(e);
-        }
-        return 0;
-    }
-
-    private static String stringifyShellCommands(String ... commands) {
-        return stringifyShellCommands(Arrays.asList(commands));
-    }
-
-    private static String stringifyShellCommands(List<String> commands) {
-        return String.join(System.lineSeparator(), commands.stream().filter(
-                s -> s != null && !s.isEmpty()).collect(Collectors.toList()));
-    }
-
-    private DesktopIntegration desktopIntegration;
 }
--- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmBundler.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmBundler.java	Wed Oct 16 09:57:23 2019 -0400
@@ -26,7 +26,6 @@
 package jdk.jpackage.internal;
 
 import java.io.*;
-import java.nio.file.Files;
 import java.nio.file.Path;
 import java.text.MessageFormat;
 import java.util.*;
@@ -36,6 +35,7 @@
 
 import static jdk.jpackage.internal.StandardBundlerParam.*;
 import static jdk.jpackage.internal.LinuxAppBundler.LINUX_INSTALL_DIR;
+import static jdk.jpackage.internal.OverridableResource.createResource;
 
 /**
  * There are two command line options to configure license information for RPM
@@ -146,16 +146,10 @@
         Path specFile = specFile(params);
 
         // prepare spec file
-        Files.createDirectories(specFile.getParent());
-        try (Writer w = Files.newBufferedWriter(specFile)) {
-            String content = preprocessTextResource(
-                    specFile.getFileName().toString(),
-                    I18N.getString("resource.rpm-spec-file"),
-                    DEFAULT_SPEC_TEMPLATE, replacementData,
-                    VERBOSE.fetchFrom(params),
-                    RESOURCE_DIR.fetchFrom(params));
-            w.write(content);
-        }
+        createResource(DEFAULT_SPEC_TEMPLATE, params)
+                .setCategory(I18N.getString("resource.rpm-spec-file"))
+                .setSubstitutionData(replacementData)
+                .saveToFile(specFile);
 
         return buildRPM(params, outputParentDir);
     }
@@ -171,14 +165,11 @@
         data.put("APPLICATION_LICENSE_TYPE", LICENSE_TYPE.fetchFrom(params));
 
         String licenseFile = LICENSE_FILE.fetchFrom(params);
-        if (licenseFile == null) {
-            licenseFile = "";
-        } else {
+        if (licenseFile != null) {
             licenseFile = Path.of(licenseFile).toAbsolutePath().normalize().toString();
         }
         data.put("APPLICATION_LICENSE_FILE", licenseFile);
-        data.put("APPLICATION_GROUP", Optional.ofNullable(
-                GROUP.fetchFrom(params)).orElse(""));
+        data.put("APPLICATION_GROUP", GROUP.fetchFrom(params));
 
         return data;
     }
--- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacAppBundler.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacAppBundler.java	Wed Oct 16 09:57:23 2019 -0400
@@ -141,8 +141,7 @@
                             SIGNING_KEYCHAIN.fetchFrom(params),
                             VERBOSE.fetchFrom(params));
                     if (result != null) {
-                        MacCertificate certificate = new MacCertificate(result,
-                                VERBOSE.fetchFrom(params));
+                        MacCertificate certificate = new MacCertificate(result);
 
                         if (!certificate.isValid()) {
                             Log.error(MessageFormat.format(I18N.getString(
--- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacAppImageBuilder.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacAppImageBuilder.java	Wed Oct 16 09:57:23 2019 -0400
@@ -25,16 +25,10 @@
 
 package jdk.jpackage.internal;
 
-import java.io.BufferedWriter;
 import java.io.File;
 import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.FileWriter;
 import java.io.IOException;
 import java.io.InputStream;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.io.UncheckedIOException;
 import java.io.Writer;
 import java.math.BigInteger;
 import java.nio.file.Files;
@@ -63,6 +57,7 @@
 import static jdk.jpackage.internal.StandardBundlerParam.*;
 import static jdk.jpackage.internal.MacBaseInstallerBundler.*;
 import static jdk.jpackage.internal.MacAppBundler.*;
+import static jdk.jpackage.internal.OverridableResource.createResource;
 
 public class MacAppImageBuilder extends AbstractAppImageBuilder {
 
@@ -143,13 +138,6 @@
                     },
                     (s, p) -> s);
 
-    public static final BundlerParamInfo<String> DEFAULT_ICNS_ICON =
-            new StandardBundlerParam<>(
-            ".mac.default.icns",
-            String.class,
-            params -> TEMPLATE_BUNDLE_ICON,
-            (s, p) -> s);
-
     public static final BundlerParamInfo<File> ICON_ICNS =
             new StandardBundlerParam<>(
             "icon.icns",
@@ -312,18 +300,12 @@
         copyClassPathEntries(appDir, params);
 
         /*********** Take care of "config" files *******/
-        File icon = ICON_ICNS.fetchFrom(params);
 
-        InputStream in = locateResource(
-                APP_NAME.fetchFrom(params) + ".icns",
-                "icon",
-                DEFAULT_ICNS_ICON.fetchFrom(params),
-                icon,
-                VERBOSE.fetchFrom(params),
-                RESOURCE_DIR.fetchFrom(params));
-        Files.copy(in,
-                resourcesDir.resolve(APP_NAME.fetchFrom(params) + ".icns"),
-                StandardCopyOption.REPLACE_EXISTING);
+        createResource(TEMPLATE_BUNDLE_ICON, params)
+                .setCategory("icon")
+                .setExternal(ICON_ICNS.fetchFrom(params))
+                .saveToFile(resourcesDir.resolve(APP_NAME.fetchFrom(params)
+                        + ".icns"));
 
         // copy file association icons
         for (Map<String, ?
@@ -457,14 +439,11 @@
         data.put("CF_BUNDLE_VERSION", VERSION.fetchFrom(params));
         data.put("CF_BUNDLE_SHORT_VERSION_STRING", VERSION.fetchFrom(params));
 
-        try (Writer w = Files.newBufferedWriter(file.toPath())) {
-            w.write(preprocessTextResource("Runtime-Info.plist",
-                    I18N.getString("resource.runtime-info-plist"),
-                    TEMPLATE_RUNTIME_INFO_PLIST,
-                    data,
-                    VERBOSE.fetchFrom(params),
-                    RESOURCE_DIR.fetchFrom(params)));
-        }
+        createResource(TEMPLATE_RUNTIME_INFO_PLIST, params)
+                .setPublicName("Runtime-Info.plist")
+                .setCategory(I18N.getString("resource.runtime-info-plist"))
+                .setSubstitutionData(data)
+                .saveToFile(file);
     }
 
     private void writeInfoPlist(File file, Map<String, ? super Object> params)
@@ -664,16 +643,11 @@
         }
         data.put("DEPLOY_FILE_ASSOCIATIONS", associationData);
 
-
-        try (Writer w = Files.newBufferedWriter(file.toPath())) {
-            w.write(preprocessTextResource(
-                    // getConfig_InfoPlist(params).getName(),
-                    "Info.plist",
-                    I18N.getString("resource.app-info-plist"),
-                    TEMPLATE_INFO_PLIST_LITE,
-                    data, VERBOSE.fetchFrom(params),
-                    RESOURCE_DIR.fetchFrom(params)));
-        }
+        createResource(TEMPLATE_INFO_PLIST_LITE, params)
+                .setCategory(I18N.getString("resource.app-info-plist"))
+                .setSubstitutionData(data)
+                .setPublicName("Info.plist")
+                .saveToFile(file);
     }
 
     private void writePkgInfo(File file) throws IOException {
--- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacAppStoreBundler.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacAppStoreBundler.java	Wed Oct 16 09:57:23 2019 -0400
@@ -29,9 +29,6 @@
 import java.io.IOException;
 import java.text.MessageFormat;
 import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -39,6 +36,7 @@
 
 import static jdk.jpackage.internal.StandardBundlerParam.*;
 import static jdk.jpackage.internal.MacAppBundler.*;
+import static jdk.jpackage.internal.OverridableResource.createResource;
 
 public class MacAppStoreBundler extends MacBaseInstallerBundler {
 
@@ -62,8 +60,7 @@
                         SIGNING_KEYCHAIN.fetchFrom(params),
                         VERBOSE.fetchFrom(params));
                 if (result != null) {
-                    MacCertificate certificate = new MacCertificate(result,
-                            VERBOSE.fetchFrom(params));
+                    MacCertificate certificate = new MacCertificate(result);
 
                     if (!certificate.isValid()) {
                         Log.error(MessageFormat.format(
@@ -88,8 +85,7 @@
                         VERBOSE.fetchFrom(params));
 
                 if (result != null) {
-                    MacCertificate certificate = new MacCertificate(
-                            result, VERBOSE.fetchFrom(params));
+                    MacCertificate certificate = new MacCertificate(result);
 
                     if (!certificate.isValid()) {
                         Log.error(MessageFormat.format(
@@ -209,40 +205,18 @@
 
     private void prepareEntitlements(Map<String, ? super Object> params)
             throws IOException {
-        File entitlements = MAC_APP_STORE_ENTITLEMENTS.fetchFrom(params);
-        if (entitlements == null || !entitlements.exists()) {
-            fetchResource(getEntitlementsFileName(params),
-                    I18N.getString("resource.mac-app-store-entitlements"),
-                    DEFAULT_ENTITLEMENTS,
-                    getConfig_Entitlements(params),
-                    VERBOSE.fetchFrom(params),
-                    RESOURCE_DIR.fetchFrom(params));
-        } else {
-            fetchResource(getEntitlementsFileName(params),
-                    I18N.getString("resource.mac-app-store-entitlements"),
-                    entitlements,
-                    getConfig_Entitlements(params),
-                    VERBOSE.fetchFrom(params),
-                    RESOURCE_DIR.fetchFrom(params));
-        }
-        fetchResource(getInheritEntitlementsFileName(params),
-                I18N.getString("resource.mac-app-store-inherit-entitlements"),
-                DEFAULT_INHERIT_ENTITLEMENTS,
-                getConfig_Inherit_Entitlements(params),
-                VERBOSE.fetchFrom(params),
-                RESOURCE_DIR.fetchFrom(params));
+        createResource(DEFAULT_ENTITLEMENTS, params)
+                .setCategory(
+                        I18N.getString("resource.mac-app-store-entitlements"))
+                .setExternal(MAC_APP_STORE_ENTITLEMENTS.fetchFrom(params))
+                .saveToFile(getConfig_Entitlements(params));
+
+        createResource(DEFAULT_INHERIT_ENTITLEMENTS, params)
+                .setCategory(I18N.getString(
+                        "resource.mac-app-store-inherit-entitlements"))
+                .saveToFile(getConfig_Entitlements(params));
     }
 
-    private String getEntitlementsFileName(Map<String, ? super Object> params) {
-        return APP_NAME.fetchFrom(params) + ".entitlements";
-    }
-
-    private String getInheritEntitlementsFileName(
-            Map<String, ? super Object> params) {
-        return APP_NAME.fetchFrom(params) + "_Inherit.entitlements";
-    }
-
-
     ///////////////////////////////////////////////////////////////////////
     // Implement Bundler
     ///////////////////////////////////////////////////////////////////////
--- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacCertificate.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacCertificate.java	Wed Oct 16 09:57:23 2019 -0400
@@ -43,23 +43,16 @@
 
 public final class MacCertificate {
     private final String certificate;
-    private final boolean verbose;
 
     public MacCertificate(String certificate) {
         this.certificate = certificate;
-        this.verbose = false;
-    }
-
-    public MacCertificate(String certificate, boolean verbose) {
-        this.certificate = certificate;
-        this.verbose = verbose;
     }
 
     public boolean isValid() {
-        return verifyCertificate(this.certificate, verbose);
+        return verifyCertificate(this.certificate);
     }
 
-    private static File findCertificate(String certificate, boolean verbose) {
+    private static File findCertificate(String certificate) {
         File result = null;
 
         List<String> args = new ArrayList<>();
@@ -87,7 +80,7 @@
         return result;
     }
 
-    private static Date findCertificateDate(String filename, boolean verbose) {
+    private static Date findCertificateDate(String filename) {
         Date result = null;
 
         List<String> args = new ArrayList<>();
@@ -114,8 +107,7 @@
         return result;
     }
 
-    private static boolean verifyCertificate(
-            String certificate, boolean verbose) {
+    private static boolean verifyCertificate(String certificate) {
         boolean result = false;
 
         try {
@@ -123,11 +115,11 @@
             Date certificateDate = null;
 
             try {
-                file = findCertificate(certificate, verbose);
+                file = findCertificate(certificate);
 
                 if (file != null) {
                     certificateDate = findCertificateDate(
-                            file.getCanonicalPath(), verbose);
+                            file.getCanonicalPath());
                 }
             }
             finally {
--- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgBundler.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgBundler.java	Wed Oct 16 09:57:23 2019 -0400
@@ -29,6 +29,7 @@
 import java.nio.file.Files;
 import java.text.MessageFormat;
 import java.util.*;
+import static jdk.jpackage.internal.OverridableResource.createResource;
 
 import static jdk.jpackage.internal.StandardBundlerParam.*;
 
@@ -97,12 +98,10 @@
         data.put("DEPLOY_INSTALL_LOCATION", "(path to applications folder)");
         data.put("DEPLOY_INSTALL_NAME", "Applications");
 
-        try (Writer w = Files.newBufferedWriter(dmgSetup.toPath())) {
-            w.write(preprocessTextResource(dmgSetup.getName(),
-                    I18N.getString("resource.dmg-setup-script"),
-                    DEFAULT_DMG_SETUP_SCRIPT, data, VERBOSE.fetchFrom(params),
-                    RESOURCE_DIR.fetchFrom(params)));
-        }
+        createResource(DEFAULT_DMG_SETUP_SCRIPT, params)
+                .setCategory(I18N.getString("resource.dmg-setup-script"))
+                .setSubstitutionData(data)
+                .saveToFile(dmgSetup);
     }
 
     private File getConfig_VolumeScript(Map<String, ? super Object> params) {
@@ -142,14 +141,10 @@
             Map<String, String> data = new HashMap<>();
             data.put("APPLICATION_LICENSE_TEXT", licenseInBase64);
 
-            try (Writer w = Files.newBufferedWriter(
-                    getConfig_LicenseFile(params).toPath())) {
-                w.write(preprocessTextResource(
-                        getConfig_LicenseFile(params).getName(),
-                        I18N.getString("resource.license-setup"),
-                        DEFAULT_LICENSE_PLIST, data, VERBOSE.fetchFrom(params),
-                        RESOURCE_DIR.fetchFrom(params)));
-            }
+            createResource(DEFAULT_LICENSE_PLIST, params)
+                    .setCategory(I18N.getString("resource.license-setup"))
+                    .setSubstitutionData(data)
+                    .saveToFile(getConfig_LicenseFile(params));
 
         } catch (IOException ex) {
             Log.verbose(ex);
@@ -158,39 +153,19 @@
 
     private boolean prepareConfigFiles(Map<String, ? super Object> params)
             throws IOException {
-        File bgTarget = getConfig_VolumeBackground(params);
-        fetchResource(bgTarget.getName(),
-                I18N.getString("resource.dmg-background"),
-                DEFAULT_BACKGROUND_IMAGE,
-                bgTarget,
-                VERBOSE.fetchFrom(params),
-                RESOURCE_DIR.fetchFrom(params));
+
+        createResource(DEFAULT_BACKGROUND_IMAGE, params)
+                    .setCategory(I18N.getString("resource.dmg-background"))
+                    .saveToFile(getConfig_VolumeBackground(params));
 
-        File iconTarget = getConfig_VolumeIcon(params);
-        if (MacAppBundler.ICON_ICNS.fetchFrom(params) == null ||
-                !MacAppBundler.ICON_ICNS.fetchFrom(params).exists()) {
-            fetchResource(iconTarget.getName(),
-                    I18N.getString("resource.volume-icon"),
-                    TEMPLATE_BUNDLE_ICON,
-                    iconTarget,
-                    VERBOSE.fetchFrom(params),
-                    RESOURCE_DIR.fetchFrom(params));
-        } else {
-            fetchResource(iconTarget.getName(),
-                    I18N.getString("resource.volume-icon"),
-                    MacAppBundler.ICON_ICNS.fetchFrom(params),
-                    iconTarget,
-                    VERBOSE.fetchFrom(params),
-                    RESOURCE_DIR.fetchFrom(params));
-        }
+        createResource(TEMPLATE_BUNDLE_ICON, params)
+                .setCategory(I18N.getString("resource.volume-icon"))
+                .setExternal(MacAppBundler.ICON_ICNS.fetchFrom(params))
+                .saveToFile(getConfig_VolumeIcon(params));
 
-
-        fetchResource(getConfig_Script(params).getName(),
-                I18N.getString("resource.post-install-script"),
-                (String) null,
-                getConfig_Script(params),
-                VERBOSE.fetchFrom(params),
-                RESOURCE_DIR.fetchFrom(params));
+        createResource(null, params)
+                .setCategory(I18N.getString("resource.post-install-script"))
+                .saveToFile(getConfig_Script(params));
 
         prepareLicense(params);
 
--- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacPkgBundler.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacPkgBundler.java	Wed Oct 16 09:57:23 2019 -0400
@@ -50,6 +50,7 @@
 import static jdk.jpackage.internal.MacBaseInstallerBundler.SIGNING_KEYCHAIN;
 import static jdk.jpackage.internal.MacBaseInstallerBundler.SIGNING_KEY_USER;
 import static jdk.jpackage.internal.MacAppImageBuilder.MAC_CF_BUNDLE_IDENTIFIER;
+import static jdk.jpackage.internal.OverridableResource.createResource;
 
 public class MacPkgBundler extends MacBaseInstallerBundler {
 
@@ -100,8 +101,7 @@
                             SIGNING_KEYCHAIN.fetchFrom(params),
                             VERBOSE.fetchFrom(params));
                     if (result != null) {
-                        MacCertificate certificate = new MacCertificate(
-                                result, VERBOSE.fetchFrom(params));
+                        MacCertificate certificate = new MacCertificate(result);
 
                         if (!certificate.isValid()) {
                             Log.error(MessageFormat.format(
@@ -206,30 +206,16 @@
         data.put("INSTALL_LOCATION", MAC_INSTALL_DIR.fetchFrom(params));
         data.put("APP_LOCATION", appLocation.toString());
 
-        try (Writer w = Files.newBufferedWriter(
-                getScripts_PreinstallFile(params).toPath())) {
-            String content = preprocessTextResource(
-                    getScripts_PreinstallFile(params).getName(),
-                    I18N.getString("resource.pkg-preinstall-script"),
-                    TEMPLATE_PREINSTALL_SCRIPT,
-                    data,
-                    VERBOSE.fetchFrom(params),
-                    RESOURCE_DIR.fetchFrom(params));
-            w.write(content);
-        }
+        createResource(TEMPLATE_PREINSTALL_SCRIPT, params)
+                .setCategory(I18N.getString("resource.pkg-preinstall-script"))
+                .setSubstitutionData(data)
+                .saveToFile(getScripts_PreinstallFile(params));
         getScripts_PreinstallFile(params).setExecutable(true, false);
 
-        try (Writer w = Files.newBufferedWriter(
-                getScripts_PostinstallFile(params).toPath())) {
-            String content = preprocessTextResource(
-                    getScripts_PostinstallFile(params).getName(),
-                    I18N.getString("resource.pkg-postinstall-script"),
-                    TEMPLATE_POSTINSTALL_SCRIPT,
-                    data,
-                    VERBOSE.fetchFrom(params),
-                    RESOURCE_DIR.fetchFrom(params));
-            w.write(content);
-        }
+        createResource(TEMPLATE_POSTINSTALL_SCRIPT, params)
+                .setCategory(I18N.getString("resource.pkg-postinstall-script"))
+                .setSubstitutionData(data)
+                .saveToFile(getScripts_PostinstallFile(params));
         getScripts_PostinstallFile(params).setExecutable(true, false);
     }
 
@@ -335,30 +321,20 @@
 
     private boolean prepareConfigFiles(Map<String, ? super Object> params)
             throws IOException, URISyntaxException {
-        File imageTarget = getConfig_BackgroundImage(params);
-        fetchResource(imageTarget.getName(),
-                I18N.getString("resource.pkg-background-image"),
-                DEFAULT_BACKGROUND_IMAGE,
-                imageTarget,
-                VERBOSE.fetchFrom(params),
-                RESOURCE_DIR.fetchFrom(params));
 
-        imageTarget = getConfig_BackgroundImageDarkAqua(params);
-        fetchResource(imageTarget.getName(),
-                I18N.getString("resource.pkg-background-image"),
-                DEFAULT_BACKGROUND_IMAGE,
-                imageTarget,
-                VERBOSE.fetchFrom(params),
-                RESOURCE_DIR.fetchFrom(params));
+        createResource(DEFAULT_BACKGROUND_IMAGE, params)
+                .setCategory(I18N.getString("resource.pkg-background-image"))
+                .saveToFile(getConfig_BackgroundImage(params));
+
+        createResource(DEFAULT_BACKGROUND_IMAGE, params)
+                .setCategory(I18N.getString("resource.pkg-background-image"))
+                .saveToFile(getConfig_BackgroundImageDarkAqua(params));
 
         prepareDistributionXMLFile(params);
 
-        fetchResource(getConfig_Script(params).getName(),
-                I18N.getString("resource.post-install-script"),
-                (String) null,
-                getConfig_Script(params),
-                VERBOSE.fetchFrom(params),
-                RESOURCE_DIR.fetchFrom(params));
+        createResource(null, params)
+                .setCategory(I18N.getString("resource.post-install-script"))
+                .saveToFile(getConfig_Script(params));
 
         return true;
     }
--- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/AbstractAppImageBuilder.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/AbstractAppImageBuilder.java	Wed Oct 16 09:57:23 2019 -0400
@@ -87,92 +87,6 @@
         }
     }
 
-    protected InputStream locateResource(String publicName, String category,
-            String defaultName, File customFile,
-            boolean verbose, File publicRoot) throws IOException {
-        InputStream is = null;
-        boolean customFromClasspath = false;
-        boolean customFromFile = false;
-        if (publicName != null) {
-            if (publicRoot != null) {
-                File publicResource = new File(publicRoot, publicName);
-                if (publicResource.exists() && publicResource.isFile()) {
-                    is = new FileInputStream(publicResource);
-                }
-            } else {
-                is = getResourceAsStream(publicName);
-            }
-            customFromClasspath = (is != null);
-        }
-        if (is == null && customFile != null) {
-            is = new FileInputStream(customFile);
-            customFromFile = (is != null);
-        }
-        if (is == null && defaultName != null) {
-            is = getResourceAsStream(defaultName);
-        }
-        if (verbose) {
-            String msg = null;
-            if (customFromClasspath) {
-                msg = MessageFormat.format(I18N.getString(
-                    "message.using-custom-resource"),
-                    category == null ? "" : "[" + category + "] ", publicName);
-            } else if (customFromFile) {
-                msg = MessageFormat.format(I18N.getString(
-                    "message.using-custom-resource-from-file"),
-                    category == null ? "" : "[" + category + "] ",
-                    customFile.getAbsoluteFile());
-            } else if (is != null) {
-                msg = MessageFormat.format(I18N.getString(
-                    "message.using-default-resource"),
-                    defaultName,
-                    category == null ? "" : "[" + category + "] ",
-                    publicName);
-            } else {
-                msg = MessageFormat.format(I18N.getString(
-                    "message.no-default-resource"),
-                    defaultName == null ? "" : defaultName,
-                    category == null ? "" : "[" + category + "] ",
-                    publicName);
-            }
-            if (msg != null) {
-                Log.verbose(msg);
-            }
-        }
-        return is;
-    }
-
-
-    protected String preprocessTextResource(String publicName, String category,
-            String defaultName, Map<String, String> pairs,
-            boolean verbose, File publicRoot) throws IOException {
-        InputStream inp = locateResource(publicName, category,
-                defaultName, null, verbose, publicRoot);
-        if (inp == null) {
-            throw new RuntimeException(
-                    "Module corrupt? No "+defaultName+" resource!");
-        }
-
-        try (InputStream is = inp) {
-            //read fully into memory
-            ByteArrayOutputStream baos = new ByteArrayOutputStream();
-            byte[] buffer = new byte[1024];
-            int length;
-            while ((length = is.read(buffer)) != -1) {
-                baos.write(buffer, 0, length);
-            }
-
-            //substitute
-            String result = new String(baos.toByteArray());
-            for (Map.Entry<String, String> e : pairs.entrySet()) {
-                if (e.getValue() != null) {
-                    result = result.replace(e.getKey(), e.getValue());
-                }
-            }
-            return result;
-        }
-    }
-
     public void writeCfgFile(Map<String, ? super Object> params,
             File cfgFileName) throws IOException {
         cfgFileName.getParentFile().mkdirs();
--- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/AbstractBundler.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/AbstractBundler.java	Wed Oct 16 09:57:23 2019 -0400
@@ -25,31 +25,19 @@
 
 package jdk.jpackage.internal;
 
-import java.io.ByteArrayOutputStream;
 import java.io.File;
-import java.io.FileInputStream;
-import java.io.BufferedInputStream;
 import java.io.IOException;
-import java.io.InputStream;
-import java.nio.file.StandardCopyOption;
-import java.nio.file.Files;
-import java.text.MessageFormat;
 import java.util.Map;
-import java.util.ResourceBundle;
 
-import jdk.jpackage.internal.resources.ResourceLocator;
 
 /**
  * AbstractBundler
  *
- * This is the base class all Bundlers extend from.
- * It contains methods and parameters common to all Bundlers.
- * The concrete implementations are in the platform specific Bundlers.
+ * This is the base class all bundlers extend from.
+ * It contains methods and parameters common to all bundlers.
+ * The concrete implementations are in the platform specific bundlers.
  */
-public abstract class AbstractBundler implements Bundler {
-
-    private static final ResourceBundle I18N = ResourceBundle.getBundle(
-            "jdk.jpackage.internal.resources.MainResources");
+abstract class AbstractBundler implements Bundler {
 
     static final BundlerParamInfo<File> IMAGES_ROOT =
             new StandardBundlerParam<>(
@@ -59,118 +47,6 @@
                 StandardBundlerParam.TEMP_ROOT.fetchFrom(params), "images"),
             (s, p) -> null);
 
-    public InputStream getResourceAsStream(String name) {
-        return ResourceLocator.class.getResourceAsStream(name);
-    }
-
-    protected void fetchResource(String publicName, String category,
-            String defaultName, File result, boolean verbose, File publicRoot)
-            throws IOException {
-
-        try (InputStream is = streamResource(publicName, category,
-                defaultName, verbose, publicRoot)) {
-            if (is != null) {
-                Files.copy(is, result.toPath(),
-                        StandardCopyOption.REPLACE_EXISTING);
-            } else {
-                if (verbose) {
-                    Log.verbose(MessageFormat.format(I18N.getString(
-                            "message.no-default-resource"),
-                            defaultName == null ? "" : defaultName,
-                            category == null ? "" : "[" + category + "] ",
-                            publicName));
-                }
-            }
-        }
-    }
-
-    protected void fetchResource(String publicName, String category,
-            File defaultFile, File result, boolean verbose, File publicRoot)
-            throws IOException {
-
-        try (InputStream is = streamResource(publicName, category,
-                null, verbose, publicRoot)) {
-            if (is != null) {
-                Files.copy(is, result.toPath());
-            } else {
-                IOUtils.copyFile(defaultFile, result);
-                if (verbose) {
-                    Log.verbose(MessageFormat.format(I18N.getString(
-                            "message.using-custom-resource-from-file"),
-                            category == null ? "" : "[" + category + "] ",
-                            defaultFile.getAbsoluteFile()));
-                }
-            }
-        }
-    }
-
-    private InputStream streamResource(String publicName, String category,
-            String defaultName, boolean verbose, File publicRoot)
-            throws IOException {
-        boolean custom = false;
-        InputStream is = null;
-        if (publicName != null) {
-            if (publicRoot != null) {
-                File publicResource = new File(publicRoot, publicName);
-                if (publicResource.exists() && publicResource.isFile()) {
-                    is = new BufferedInputStream(
-                            new FileInputStream(publicResource));
-                }
-            } else {
-                is = getResourceAsStream(publicName);
-            }
-            custom = (is != null);
-        }
-        if (is == null && defaultName != null) {
-            is = getResourceAsStream(defaultName);
-        }
-        if (verbose && is != null) {
-            String msg = null;
-            if (custom) {
-                msg = MessageFormat.format(I18N.getString(
-                        "message.using-custom-resource"),
-                        category == null ?
-                        "" : "[" + category + "] ", publicName);
-            } else {
-                msg = MessageFormat.format(I18N.getString(
-                        "message.using-default-resource"),
-                        defaultName == null ? "" : defaultName,
-                        category == null ? "" : "[" + category + "] ",
-                        publicName);
-            }
-            Log.verbose(msg);
-        }
-        return is;
-    }
-
-    protected String preprocessTextResource(String publicName, String category,
-            String defaultName, Map<String, String> pairs,
-            boolean verbose, File publicRoot) throws IOException {
-        InputStream inp = streamResource(
-                publicName, category, defaultName, verbose, publicRoot);
-        if (inp == null) {
-            throw new RuntimeException(
-                    "Jar corrupt? No " + defaultName + " resource!");
-        }
-
-        // read fully into memory
-        ByteArrayOutputStream baos = new ByteArrayOutputStream();
-        byte[] buffer = new byte[1024];
-        int length;
-        while ((length = inp.read(buffer)) != -1) {
-            baos.write(buffer, 0, length);
-        }
-
-        // substitute
-        String result = new String(baos.toByteArray());
-        for (Map.Entry<String, String> e : pairs.entrySet()) {
-            if (e.getValue() != null) {
-                result = result.replace(e.getKey(), e.getValue());
-            }
-        }
-        return result;
-    }
-
     @Override
     public String toString() {
         return getName();
--- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ApplicationLayout.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ApplicationLayout.java	Wed Oct 16 09:57:23 2019 -0400
@@ -144,7 +144,7 @@
             return macAppImage();
         }
 
-        throw new IllegalArgumentException("Unknown platform");
+        throw Platform.throwUnknownPlatformError();
     }
 
     static ApplicationLayout javaRuntime() {
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/OverridableResource.java	Wed Oct 16 09:57:23 2019 -0400
@@ -0,0 +1,223 @@
+/*
+ * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation.  Oracle designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+package jdk.jpackage.internal;
+
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.text.MessageFormat;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import static jdk.jpackage.internal.StandardBundlerParam.RESOURCE_DIR;
+import jdk.jpackage.internal.resources.ResourceLocator;
+
+/**
+ * Resource file that may have the default value supplied by jpackage. It can be
+ * overridden by a file from resource directory set with {@code --resource-dir}
+ * jpackage parameter.
+ *
+ * Resource has default name and public name. Default name is the name of a file
+ * in {@code jdk.jpackage.internal.resources} package that provides the default
+ * value of the resource.
+ *
+ * Public name is a path relative to resource directory to a file with custom
+ * value of the resource.
+ *
+ * Use #setPublicName to set the public name.
+ *
+ * If #setPublicName was not called, name of file passed in #saveToFile function
+ * will be used as a public name.
+ *
+ * Use #setExternal to set arbitrary file as a source of resource. If non-null
+ * value was passed in #setExternal call that value will be used as a path to file
+ * to copy in the destination file passed in #saveToFile function call.
+ */
+final class OverridableResource {
+
+    OverridableResource(String defaultName) {
+        this.defaultName = defaultName;
+    }
+
+    OverridableResource setSubstitutionData(Map<String, String> v) {
+        if (v != null) {
+            // Disconnect `v`
+            substitutionData = new HashMap<>(v);
+        } else {
+            substitutionData = null;
+        }
+        return this;
+    }
+
+    OverridableResource setCategory(String v) {
+        category = v;
+        return this;
+    }
+
+    String getCategory() {
+        return category;
+    }
+
+    OverridableResource setResourceDir(Path v) {
+        resourceDir = v;
+        return this;
+    }
+
+    OverridableResource setResourceDir(File v) {
+        return setResourceDir(toPath(v));
+    }
+
+    /**
+     * Set name of file to look for in resource dir.
+     *
+     * @return this
+     */
+    OverridableResource setPublicName(Path v) {
+        publicName = v;
+        return this;
+    }
+
+    OverridableResource setPublicName(String v) {
+        return setPublicName(Path.of(v));
+    }
+
+    OverridableResource setExternal(Path v) {
+        externalPath = v;
+        return this;
+    }
+
+    OverridableResource setExternal(File v) {
+        return setExternal(toPath(v));
+    }
+
+    void saveToFile(Path dest) throws IOException {
+        final String printableCategory;
+        if (category != null) {
+            printableCategory = String.format("[%s]", category);
+        } else {
+            printableCategory = "";
+        }
+
+        if (externalPath != null && externalPath.toFile().exists()) {
+            Log.verbose(MessageFormat.format(I18N.getString(
+                    "message.using-custom-resource-from-file"),
+                    printableCategory,
+                    externalPath.toAbsolutePath().normalize()));
+
+            try (InputStream in = Files.newInputStream(externalPath)) {
+                processResourceStream(in, dest);
+            }
+            return;
+        }
+
+        final Path resourceName = Optional.ofNullable(publicName).orElse(
+                dest.getFileName());
+
+        if (resourceDir != null) {
+            final Path customResource = resourceDir.resolve(resourceName);
+            if (customResource.toFile().exists()) {
+                Log.verbose(MessageFormat.format(I18N.getString(
+                        "message.using-custom-resource"), printableCategory,
+                        resourceDir.normalize().toAbsolutePath().relativize(
+                                customResource.normalize().toAbsolutePath())));
+
+                try (InputStream in = Files.newInputStream(customResource)) {
+                    processResourceStream(in, dest);
+                }
+                return;
+            }
+        }
+
+        if (defaultName != null) {
+            Log.verbose(MessageFormat.format(
+                    I18N.getString("message.using-default-resource"),
+                    defaultName, printableCategory, resourceName));
+
+            try (InputStream in = readDefault(defaultName)) {
+                processResourceStream(in, dest);
+            }
+        }
+    }
+
+    void saveToFile(File dest) throws IOException {
+        saveToFile(dest.toPath());
+    }
+
+    static InputStream readDefault(String resourceName) {
+        return ResourceLocator.class.getResourceAsStream(resourceName);
+    }
+
+    static OverridableResource createResource(String defaultName,
+            Map<String, ? super Object> params) {
+        return new OverridableResource(defaultName).setResourceDir(
+                RESOURCE_DIR.fetchFrom(params));
+    }
+
+    private static List<String> substitute(Stream<String> lines,
+            Map<String, String> substitutionData) {
+        return lines.map(line -> {
+            String result = line;
+            for (var entry : substitutionData.entrySet()) {
+                result = result.replace(entry.getKey(), Optional.ofNullable(
+                        entry.getValue()).orElse(""));
+            }
+            return result;
+        }).collect(Collectors.toList());
+    }
+
+    private static Path toPath(File v) {
+        if (v != null) {
+            return v.toPath();
+        }
+        return null;
+    }
+
+    private void processResourceStream(InputStream rawResource, Path dest)
+            throws IOException {
+        if (substitutionData == null) {
+            Files.createDirectories(dest.getParent());
+            Files.copy(rawResource, dest, StandardCopyOption.REPLACE_EXISTING);
+        } else {
+            // Utf8 in and out
+            try (BufferedReader reader = new BufferedReader(
+                    new InputStreamReader(rawResource, StandardCharsets.UTF_8))) {
+                Files.createDirectories(dest.getParent());
+                Files.write(dest, substitute(reader.lines(), substitutionData));
+            }
+        }
+    }
+
+    private Map<String, String> substitutionData;
+    private String category;
+    private Path resourceDir;
+    private Path publicName;
+    private Path externalPath;
+    private final String defaultName;
+}
--- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/Platform.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/Platform.java	Wed Oct 16 09:57:23 2019 -0400
@@ -114,4 +114,8 @@
     static boolean isLinux() {
         return getPlatform() == LINUX;
     }
+
+    static RuntimeException throwUnknownPlatformError() {
+        throw new IllegalArgumentException("Unknown platform");
+    }
 }
--- a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinExeBundler.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinExeBundler.java	Wed Oct 16 09:57:23 2019 -0400
@@ -127,7 +127,7 @@
         // Copy template msi wrapper next to msi file
         String exePath = msi.getAbsolutePath();
         exePath = exePath.substring(0, exePath.lastIndexOf('.')) + ".exe";
-        try (InputStream is = getResourceAsStream(EXE_WRAPPER_NAME)) {
+        try (InputStream is = OverridableResource.readDefault(EXE_WRAPPER_NAME)) {
             Files.copy(is, Path.of(exePath));
         }
         // Embed msi in msi wrapper exe.
--- a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinMsiBundler.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinMsiBundler.java	Wed Oct 16 09:57:23 2019 -0400
@@ -28,6 +28,7 @@
 import java.io.*;
 import java.nio.charset.Charset;
 import java.nio.file.Files;
+import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.text.MessageFormat;
 import java.util.*;
@@ -35,6 +36,7 @@
 import javax.xml.stream.XMLOutputFactory;
 import javax.xml.stream.XMLStreamException;
 import javax.xml.stream.XMLStreamWriter;
+import static jdk.jpackage.internal.OverridableResource.createResource;
 
 import static jdk.jpackage.internal.WindowsBundlerParam.*;
 
@@ -527,12 +529,12 @@
 
     private void prepareBasicProjectConfig(
         Map<String, ? super Object> params) throws IOException {
-        fetchResource(getConfig_Script(params).getName(),
-                I18N.getString("resource.post-install-script"),
-                (String) null,
-                getConfig_Script(params),
-                VERBOSE.fetchFrom(params),
-                RESOURCE_DIR.fetchFrom(params));
+
+        Path scriptPath = getConfig_Script(params).toPath();
+
+        createResource(null, params)
+                .setCategory(I18N.getString("resource.post-install-script"))
+                .saveToFile(scriptPath);
     }
 
     private static String relativePath(File basedir, File file) {
@@ -663,7 +665,7 @@
         if (INSTALLDIR_CHOOSER.fetchFrom(params)) {
             data.put("JpInstallDirChooser", "yes");
             String fname = "wixhelper.dll";
-            try (InputStream is = getResourceAsStream(fname)) {
+            try (InputStream is = OverridableResource.readDefault(fname)) {
                 Files.copy(is, Paths.get(
                         CONFIG_ROOT.fetchFrom(params).getAbsolutePath(),
                         fname));
@@ -673,14 +675,14 @@
         // Copy l10n files.
         for (String loc : Arrays.asList("en", "ja", "zh_CN")) {
             String fname = "MsiInstallerStrings_" + loc + ".wxl";
-            try (InputStream is = getResourceAsStream(fname)) {
+            try (InputStream is = OverridableResource.readDefault(fname)) {
                 Files.copy(is, Paths.get(
                         CONFIG_ROOT.fetchFrom(params).getAbsolutePath(),
                         fname));
             }
         }
 
-        try (InputStream is = getResourceAsStream("main.wxs")) {
+        try (InputStream is = OverridableResource.readDefault("main.wxs")) {
             Files.copy(is, Paths.get(
                     getConfig_ProjectFile(params).getAbsolutePath()));
         }
--- a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WindowsAppImageBuilder.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WindowsAppImageBuilder.java	Wed Oct 16 09:57:23 2019 -0400
@@ -50,6 +50,7 @@
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
+import static jdk.jpackage.internal.OverridableResource.createResource;
 
 import static jdk.jpackage.internal.StandardBundlerParam.*;
 
@@ -265,35 +266,21 @@
         validateValueAndPut(data, "PRODUCT_NAME", APP_NAME, params);
         validateValueAndPut(data, "PRODUCT_VERSION", VERSION, params);
 
-        try (Writer w = Files.newBufferedWriter(
-                getConfig_ExecutableProperties(params).toPath(),
-                StandardCharsets.UTF_8)) {
-            String content = preprocessTextResource(
-                    getConfig_ExecutableProperties(params).getName(),
-                    I18N.getString("resource.executable-properties-template"),
-                    EXECUTABLE_PROPERTIES_TEMPLATE, data,
-                    VERBOSE.fetchFrom(params),
-                    RESOURCE_DIR.fetchFrom(params));
-            w.write(content);
-        }
+        createResource(EXECUTABLE_PROPERTIES_TEMPLATE, params)
+                .setCategory(I18N.getString("resource.executable-properties-template"))
+                .setSubstitutionData(data)
+                .saveToFile(getConfig_ExecutableProperties(params));
     }
 
     private void createLauncherForEntryPoint(
             Map<String, ? super Object> params) throws IOException {
 
-        File icon = ICON_ICO.fetchFrom(params);
         File iconTarget = getConfig_AppIcon(params);
 
-        InputStream in = locateResource(
-                iconTarget.getName(),
-                "icon",
-                TEMPLATE_APP_ICON,
-                icon,
-                VERBOSE.fetchFrom(params),
-                RESOURCE_DIR.fetchFrom(params));
-
-        Files.copy(in, iconTarget.toPath(),
-                StandardCopyOption.REPLACE_EXISTING);
+        createResource(TEMPLATE_APP_ICON, params)
+                .setCategory("icon")
+                .setExternal(ICON_ICO.fetchFrom(params))
+                .saveToFile(iconTarget);
 
         writeCfgFile(params, root.resolve(
                 getLauncherCfgName(params)).toFile());
--- a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/resources/WinResources.properties	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/resources/WinResources.properties	Wed Oct 16 09:57:23 2019 -0400
@@ -30,7 +30,7 @@
 
 param.menu-group.default=Unknown
 
-resource.executable-properties-template=Template for creating executable properties file.
+resource.executable-properties-template=Template for creating executable properties file
 resource.setup-icon=setup dialog icon
 resource.post-install-script=script to run after application image is populated
 resource.wxl-file-name=MsiInstallerStrings_en.wxl
--- a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/resources/WinResources_ja.properties	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/resources/WinResources_ja.properties	Wed Oct 16 09:57:23 2019 -0400
@@ -30,7 +30,7 @@
 
 param.menu-group.default=Unknown
 
-resource.executable-properties-template=Template for creating executable properties file.
+resource.executable-properties-template=Template for creating executable properties file
 resource.setup-icon=setup dialog icon
 resource.post-install-script=script to run after application image is populated
 resource.wxl-file-name=MsiInstallerStrings_en.wxl
--- a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/resources/WinResources_zh_CN.properties	Tue Oct 15 14:00:04 2019 -0400
+++ b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/resources/WinResources_zh_CN.properties	Wed Oct 16 09:57:23 2019 -0400
@@ -30,7 +30,7 @@
 
 param.menu-group.default=Unknown
 
-resource.executable-properties-template=Template for creating executable properties file.
+resource.executable-properties-template=Template for creating executable properties file
 resource.setup-icon=setup dialog icon
 resource.post-install-script=script to run after application image is populated
 resource.wxl-file-name=MsiInstallerStrings_en.wxl
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/test/jdk/tools/jpackage/junit/jdk/jpackage/internal/OverridableResourceTest.java	Wed Oct 16 09:57:23 2019 -0400
@@ -0,0 +1,226 @@
+/*
+ * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation.  Oracle designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+package jdk.jpackage.internal;
+
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import jdk.jpackage.internal.resources.ResourceLocator;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.not;
+import static org.junit.Assert.*;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class OverridableResourceTest {
+
+    @Rule
+    public final TemporaryFolder tempFolder = new TemporaryFolder();
+
+    @Test
+    public void testDefault() throws IOException {
+        byte[] actualBytes = saveToFile(new OverridableResource(DEFAULT_NAME));
+
+        try (InputStream is = ResourceLocator.class.getResourceAsStream(
+                DEFAULT_NAME)) {
+            assertArrayEquals(is.readAllBytes(), actualBytes);
+        }
+    }
+
+    @Test
+    public void testDefaultWithSubstitution() throws IOException {
+        OverridableResource resource = new OverridableResource(DEFAULT_NAME);
+
+        List<String> linesBeforeSubstitution = convertToStringList(saveToFile(
+                resource));
+
+        if (SUBSTITUTION_DATA.size() != 1) {
+            // Test setup issue
+            throw new IllegalArgumentException(
+                    "Substitution map should contain only a single entry");
+        }
+
+        resource.setSubstitutionData(SUBSTITUTION_DATA);
+        List<String> linesAfterSubstitution = convertToStringList(saveToFile(
+                resource));
+
+        assertEquals(linesBeforeSubstitution.size(), linesAfterSubstitution.size());
+
+        Iterator<String> beforeIt = linesBeforeSubstitution.iterator();
+        Iterator<String> afterIt = linesAfterSubstitution.iterator();
+
+        var substitutionEntry = SUBSTITUTION_DATA.entrySet().iterator().next();
+
+        boolean linesMismatch = false;
+        while (beforeIt.hasNext()) {
+            String beforeStr = beforeIt.next();
+            String afterStr = afterIt.next();
+
+            if (beforeStr.equals(afterStr)) {
+                assertFalse(beforeStr.contains(substitutionEntry.getKey()));
+            } else {
+                linesMismatch = true;
+                assertTrue(beforeStr.contains(substitutionEntry.getKey()));
+                assertTrue(afterStr.contains(substitutionEntry.getValue()));
+                assertFalse(afterStr.contains(substitutionEntry.getKey()));
+            }
+        }
+
+        assertTrue(linesMismatch);
+    }
+
+    @Test
+    public void testCustom() throws IOException {
+        testCustom(DEFAULT_NAME);
+    }
+
+    @Test
+    public void testCustomNoDefault() throws IOException {
+        testCustom(null);
+    }
+
+    private void testCustom(String defaultName) throws IOException {
+        List<String> expectedResourceData = List.of("A", "B", "C");
+
+        Path customFile = createCustomFile("foo", expectedResourceData);
+
+        List<String> actualResourceData = convertToStringList(saveToFile(
+                new OverridableResource(defaultName)
+                        .setPublicName(customFile.getFileName())
+                        .setResourceDir(customFile.getParent())));
+
+        assertArrayEquals(expectedResourceData.toArray(String[]::new),
+                actualResourceData.toArray(String[]::new));
+    }
+
+    @Test
+    public void testCustomtWithSubstitution() throws IOException {
+        testCustomtWithSubstitution(DEFAULT_NAME);
+    }
+
+    @Test
+    public void testCustomtWithSubstitutionNoDefault() throws IOException {
+        testCustomtWithSubstitution(null);
+    }
+
+    private void testCustomtWithSubstitution(String defaultName) throws IOException {
+        final List<String> resourceData = List.of("A", "[BB]", "C", "Foo",
+                "GoodbyeHello");
+        final Path customFile = createCustomFile("foo", resourceData);
+
+        final Map<String, String> substitutionData = new HashMap(Map.of("B",
+                "Bar", "Foo", "B"));
+        substitutionData.put("Hello", null);
+
+        final List<String> expectedResourceData = List.of("A", "[BarBar]", "C",
+                "B", "Goodbye");
+
+        final List<String> actualResourceData = convertToStringList(saveToFile(
+                new OverridableResource(defaultName)
+                        .setPublicName(customFile.getFileName())
+                        .setSubstitutionData(substitutionData)
+                        .setResourceDir(customFile.getParent())));
+        assertArrayEquals(expectedResourceData.toArray(String[]::new),
+                actualResourceData.toArray(String[]::new));
+
+        // Don't call setPublicName()
+        final Path dstFile = tempFolder.newFolder().toPath().resolve(customFile.getFileName());
+        new OverridableResource(defaultName)
+                .setSubstitutionData(substitutionData)
+                .setResourceDir(customFile.getParent())
+                .saveToFile(dstFile);
+        assertArrayEquals(expectedResourceData.toArray(String[]::new),
+                convertToStringList(Files.readAllBytes(dstFile)).toArray(
+                        String[]::new));
+
+        // Verify setSubstitutionData() stores a copy of passed in data
+        Map<String, String> substitutionData2 = new HashMap(substitutionData);
+        var resource = new OverridableResource(defaultName)
+                .setResourceDir(customFile.getParent());
+
+        resource.setSubstitutionData(substitutionData2);
+        substitutionData2.clear();
+        Files.delete(dstFile);
+        resource.saveToFile(dstFile);
+        assertArrayEquals(expectedResourceData.toArray(String[]::new),
+                convertToStringList(Files.readAllBytes(dstFile)).toArray(
+                        String[]::new));
+    }
+
+    @Test
+    public void testNoDefault() throws IOException {
+        Path dstFolder = tempFolder.newFolder().toPath();
+        Path dstFile = dstFolder.resolve(Path.of("foo", "bar"));
+
+        new OverridableResource(null).saveToFile(dstFile);
+
+        assertFalse(dstFile.toFile().exists());
+    }
+
+    private final static String DEFAULT_NAME;
+    private final static Map<String, String> SUBSTITUTION_DATA;
+    static {
+        if (Platform.isWindows()) {
+            DEFAULT_NAME = "WinLauncher.template";
+            SUBSTITUTION_DATA = Map.of("COMPANY_NAME", "Foo9090345");
+        } else if (Platform.isLinux()) {
+            DEFAULT_NAME = "template.control";
+            SUBSTITUTION_DATA = Map.of("APPLICATION_PACKAGE", "Package1967");
+        } else if (Platform.isMac()) {
+            DEFAULT_NAME = "Info-lite.plist.template";
+            SUBSTITUTION_DATA = Map.of("DEPLOY_BUNDLE_IDENTIFIER", "12345");
+        } else {
+            throw Platform.throwUnknownPlatformError();
+        }
+    }
+
+    private byte[] saveToFile(OverridableResource resource) throws IOException {
+        Path dstFile = tempFolder.newFile().toPath();
+        resource.saveToFile(dstFile);
+        assertThat(0, is(not(dstFile.toFile().length())));
+
+        return Files.readAllBytes(dstFile);
+    }
+
+    private Path createCustomFile(String publicName, List<String> data) throws
+            IOException {
+        Path resourceFolder = tempFolder.newFolder().toPath();
+        Path customFile = resourceFolder.resolve(publicName);
+
+        Files.write(customFile, data);
+
+        return customFile;
+    }
+
+    private static List<String> convertToStringList(byte[] data) {
+        return List.of(new String(data, StandardCharsets.UTF_8).split("\\R"));
+    }
+}
--- a/test/jdk/tools/jpackage/junit/junit.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/test/jdk/tools/jpackage/junit/junit.java	Wed Oct 16 09:57:23 2019 -0400
@@ -28,9 +28,4 @@
  * @summary jpackage unit tests
  * @library ${jtreg.home}/lib/junit.jar
  * @run shell run_junit.sh
- *  jdk.jpackage.internal.PathGroupTest
- *  jdk.jpackage.internal.DeployParamsTest
- *  jdk.jpackage.internal.ApplicationLayoutTest
- *  jdk.jpackage.internal.ToolValidatorTest
- *  jdk.jpackage.internal.AppImageFileTest
  */
--- a/test/jdk/tools/jpackage/junit/run_junit.sh	Tue Oct 15 14:00:04 2019 -0400
+++ b/test/jdk/tools/jpackage/junit/run_junit.sh	Wed Oct 16 09:57:23 2019 -0400
@@ -9,10 +9,11 @@
   exit
 fi
 
-classes=( "$@" )
 sources=()
-for c in "${classes[@]}"; do
-  sources+=( "${TESTSRC}/$(echo $c | sed -e 's|\.|/|g').java" )
+classes=()
+for s in $(find "${TESTSRC}" -name  "*.java" | grep -v junit.java); do
+  sources+=( "$s" )
+  classes+=( $(echo "$s" | sed -e "s|${TESTSRC}/||" -e 's|/|.|g' -e 's/.java$//') )
 done
 
 common_args=(\
@@ -28,4 +29,4 @@
 
 # Run junit
 "${TESTJAVA}/bin/java" ${TESTVMOPTS} ${TESTJAVAOPTS} \
-  "${common_args[@]}" org.junit.runner.JUnitCore "$@"
+  "${common_args[@]}" org.junit.runner.JUnitCore "${classes[@]}"
--- a/test/jdk/tools/jpackage/macosx/base/SigningCheck.java	Tue Oct 15 14:00:04 2019 -0400
+++ b/test/jdk/tools/jpackage/macosx/base/SigningCheck.java	Wed Oct 16 09:57:23 2019 -0400
@@ -72,8 +72,7 @@
 
     private static void validateCertificate(String key) {
         if (key != null) {
-            MacCertificate certificate = new MacCertificate(
-                    key, true);
+            MacCertificate certificate = new MacCertificate(key);
             if (!certificate.isValid()) {
                 TKit.throwSkippedException("Certifcate expired: " + key);
             } else {