Merge pull request #51 from neilcsmith-net/nbpackage-nb14

NBPackage updates - macOS, templates, utilities
diff --git a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/FileUtils.java b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/FileUtils.java
index 0d7f27d..bcbfa81 100644
--- a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/FileUtils.java
+++ b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/FileUtils.java
@@ -19,11 +19,13 @@
 package org.apache.netbeans.nbpackage;
 
 import java.io.IOException;
+import java.net.URI;
 import java.nio.file.FileAlreadyExistsException;
 import java.nio.file.FileSystems;
 import java.nio.file.FileVisitResult;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.PathMatcher;
 import java.nio.file.SimpleFileVisitor;
 import java.nio.file.StandardCopyOption;
 import java.nio.file.attribute.BasicFileAttributes;
@@ -31,6 +33,7 @@
 import java.nio.file.attribute.PosixFileAttributeView;
 import java.nio.file.attribute.PosixFilePermission;
 import java.util.List;
+import java.util.Map;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.apache.commons.compress.archivers.ArchiveException;
@@ -141,6 +144,9 @@
             @Override
             public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
                     throws IOException {
+                if (dir.equals(src)) {
+                    return CONTINUE;
+                }
                 Path targetDir = dst.resolve(src.relativize(dir));
                 try {
                     Files.copy(dir, targetDir);
@@ -165,6 +171,9 @@
             @Override
             public FileVisitResult postVisitDirectory(Path dir, IOException exc)
                     throws IOException {
+                if (dir.equals(src)) {
+                    return CONTINUE;
+                }
                 Files.delete(dir);
                 return CONTINUE;
             }
@@ -173,6 +182,51 @@
     }
 
     /**
+     * Recursively delete a directory and all files inside it.
+     *
+     * @param dir directory to delete
+     * @throws IOException
+     */
+    public static void deleteFiles(Path dir) throws IOException {
+        Files.walkFileTree(dir, new SimpleFileVisitor<Path>() {
+
+            @Override
+            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
+                Files.delete(file);
+                return FileVisitResult.CONTINUE;
+            }
+
+            @Override
+            public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
+                Files.delete(dir);
+                return FileVisitResult.CONTINUE;
+            }
+
+        });
+    }
+
+    /**
+     * Find all files that match the provided glob pattern within a directory.
+     * Files will be included if their relative path within the directory, or
+     * their filename, match the pattern.
+     *
+     * @param searchDir directory to search within
+     * @param pattern pattern to search for
+     * @return list of files that match pattern
+     * @throws IOException
+     */
+    public static List<Path> find(Path searchDir, String pattern) throws IOException {
+        var matcher = searchDir.getFileSystem().getPathMatcher("glob:" + pattern);
+        try (var stream = Files.find(searchDir, Integer.MAX_VALUE, (file, attr)
+                -> !file.equals(searchDir) && 
+                        (matcher.matches(searchDir.relativize(file)) ||
+                                matcher.matches(file.getFileName()))
+        )) {
+            return stream.collect(Collectors.toList());
+        }
+    }
+
+    /**
      * Find the directories in the given search directory that contains files
      * that match the given glob patterns. This might include the search
      * directory itself. eg. to find NetBeans or a RCP application in a search
@@ -207,6 +261,77 @@
     }
 
     /**
+     * Process files inside a JAR file. This can be used, for example, to sign
+     * native executables inside a JAR. Files whose path inside the JAR matches
+     * the given pattern will be extracted into a temporary directory and passed
+     * to the provided processor. If the processor returns true the file will be
+     * updated inside the JAR.
+     *
+     * @param jarFile JAR file
+     * @param pattern glob pattern to match paths for processing
+     * @param processor process files
+     * @return true if the JAR contents have been updated
+     * @throws IOException
+     */
+    public static boolean processJarContents(Path jarFile, String pattern, JarProcessor processor)
+            throws IOException {
+        URI jarURI = URI.create("jar:" + jarFile.toUri());
+        boolean updated = false;
+        try (var jarFS = FileSystems.newFileSystem(jarURI, Map.of())) {
+            PathMatcher matcher = jarFS.getPathMatcher("glob:" + pattern);
+            List<Path> filesToProcess;
+            try (var jarStream = Files.walk(jarFS.getPath("/"))) {
+                filesToProcess = jarStream
+                        .filter(file -> Files.isRegularFile(file))
+                        .filter(matcher::matches)
+                        .collect(Collectors.toList());
+            }
+            if (filesToProcess.isEmpty()) {
+                return false;
+            }
+            Path tmpDir = Files.createTempDirectory("nbpackage-jar-");
+            try {
+                for (Path file : filesToProcess) {
+                    Path tmp = null;
+                    try {
+                        tmp = Files.copy(file, tmpDir.resolve(file.getFileName().toString()));
+                        boolean processed = processor.processFile(tmp, file.toString());
+                        if (processed) {
+                            Files.copy(tmp, file, StandardCopyOption.REPLACE_EXISTING);
+                            updated = true;
+                        }
+                    } finally {
+                        Files.deleteIfExists(tmp);
+                    }
+                }
+
+            } finally {
+                Files.delete(tmpDir);
+            }
+        }
+        return updated;
+    }
+
+    /**
+     * Processor for updating files inside a JAR. For use with
+     * {@link FileUtils#processJarContents(java.nio.file.Path, java.lang.String, org.apache.netbeans.nbpackage.FileUtils.JarProcessor)}.
+     */
+    @FunctionalInterface
+    public static interface JarProcessor {
+
+        /**
+         * Process file from JAR.
+         *
+         * @param tmpFile path to temporary extracted file
+         * @param jarPath full path inside JAR
+         * @return true to update the file in the JAR
+         * @throws IOException
+         */
+        public boolean processFile(Path tmpFile, String jarPath) throws IOException;
+
+    }
+
+    /**
      * Remove the extension from the given file name, if one exists. Simply
      * removes the last dot and remaining characters.
      *
diff --git a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/Main.java b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/Main.java
index d7efbbb..7a2c274 100644
--- a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/Main.java
+++ b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/Main.java
@@ -21,7 +21,6 @@
 import java.io.PrintWriter;
 import java.io.StringWriter;
 import java.nio.file.Path;
-import java.text.MessageFormat;
 import java.util.Iterator;
 import java.util.Map;
 import java.util.concurrent.Callable;
@@ -72,6 +71,10 @@
         @CommandLine.Option(names = {"--save-config"},
                 descriptionKey = "option.saveconfig.description")
         private Path configOut;
+        
+        @CommandLine.Option(names = {"--save-templates"},
+                descriptionKey = "option.savetemplates.description")
+        private Path templatesOut;
 
         @CommandLine.Option(names = {"--image-only"},
                 descriptionKey = "option.imageonly.description")
@@ -88,7 +91,7 @@
         @Override
         public Integer call() throws Exception {
             try {
-                if (input == null && inputImage == null && configOut == null) {
+                if (input == null && inputImage == null && !hasAuxTasks()) {
                     warning(NBPackage.MESSAGES.getString("message.notasks"));
                     return 1;
                 }
@@ -124,6 +127,10 @@
                     NBPackage.writeFullConfiguration(conf, configOut);
                 }
 
+                if (templatesOut != null) {
+                    NBPackage.copyTemplates(conf, templatesOut);
+                }
+                
                 Path dest = output == null ? Path.of("") : output;
 
                 Path created = null;
@@ -160,6 +167,10 @@
             );
             System.out.println(ansiMsg);
         }
+        
+        private boolean hasAuxTasks() {
+            return configOut != null || templatesOut != null;
+        } 
 
     }
 
diff --git a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/NBPackage.java b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/NBPackage.java
index 06ea784..63c9bf7 100644
--- a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/NBPackage.java
+++ b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/NBPackage.java
@@ -204,6 +204,34 @@
     }
 
     /**
+     * Copy templates to the provided destination. If a package type is
+     * specified in the provided configuration, only templates for that type
+     * will be copied. Template overrides in the configuration will be
+     * respected. The destination must be a directory or not already exist.
+     * Destination files must not already exist.
+     *
+     * @param configuration configuration to restrict / control templates
+     * @param destination directory to copy files in to (will be created if
+     * needed)
+     * @throws IOException if destination is not a directory, or cannot be
+     * created; if the template cannot be loaded; or if the destination file
+     * exists or cannot be created.
+     */
+    public static void copyTemplates(Configuration configuration, Path destination)
+            throws IOException {
+        Files.createDirectories(destination);
+        var type = configuration.getValue(PACKAGE_TYPE);
+        var templates = (type.isBlank() ? templates() : templates(type))
+                .toArray(Template[]::new);
+        for (var template : templates) {
+            var contents = template.load(configuration);
+            Files.writeString(destination.resolve(template.name()),
+                    contents,
+                    StandardOpenOption.CREATE_NEW);
+        }
+    }
+
+    /**
      * Query all available packagers. Not all packagers are guaranteed to run on
      * the current system / OS.
      *
@@ -264,6 +292,26 @@
                 findPackager(packagerName).options());
     }
 
+    /**
+     * Query all templates.
+     *
+     * @return stream of all templates
+     */
+    public static Stream<Template> templates() {
+        return packagers().flatMap(Packager::templates);
+    }
+
+    /**
+     * Query all templates used by the specified packager.
+     *
+     * @param packagerName name of packager
+     * @return stream of templates
+     * @throws IllegalArgumentException if no packager by this name exists
+     */
+    public static Stream<Template> templates(String packagerName) {
+        return findPackager(packagerName).templates();
+    }
+
     // @TODO properly escape and support multi-line comments / values
     private static void writeOption(StringBuilder sb, Configuration conf, Option<?> option, boolean comment) {
         String value = conf.getValue(option);
diff --git a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/Option.java b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/Option.java
index ffaf12b..d34122a 100644
--- a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/Option.java
+++ b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/Option.java
@@ -98,6 +98,18 @@
     }
 
     /**
+     * Create an Option of a String type with empty default value.
+     *
+     * @param key key used to store option in configuration
+     * @param comment help text for user
+     * @return option
+     */
+    public static Option<String> ofString(String key,
+            String comment) {
+        return ofString(key, "", comment);
+    }
+
+    /**
      * Create an Option of a String type.
      *
      * @param key key used to store option in configuration
@@ -112,6 +124,18 @@
     }
 
     /**
+     * Create an option of a Path type with empty default value.
+     *
+     * @param key key used to store option in configuration
+     * @param comment help text for user
+     * @return option
+     */
+    public static Option<Path> ofPath(String key,
+            String comment) {
+        return ofPath(key, "", comment);
+    }
+
+    /**
      * Create an option of a Path type.
      *
      * @param key key used to store option in configuration
@@ -146,7 +170,7 @@
 
     /**
      * Parser to convert text value into option type.
-     * 
+     *
      * @param <T> option type
      */
     @FunctionalInterface
@@ -154,7 +178,7 @@
 
         /**
          * Parse text to type.
-         * 
+         *
          * @param text to parse
          * @return value as type
          * @throws Exception on parsing error
diff --git a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/Packager.java b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/Packager.java
index d610cb4..a462749 100644
--- a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/Packager.java
+++ b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/Packager.java
@@ -53,6 +53,16 @@
     }
 
     /**
+     * A stream of packager-specific templates used by this packager. The
+     * default implementation provides an empty stream.
+     *
+     * @return stream of packager-specific templates
+     */
+    public default Stream<Template> templates() {
+        return Stream.empty();
+    }
+
+    /**
      * Task API. A task only supports a single execution. Not all stages of the
      * task may be executed - eg. when only creating a package image or creating
      * a package from an image. The validation methods for all required stages
diff --git a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/StringUtils.java b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/StringUtils.java
index 2dc052e..5844792 100644
--- a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/StringUtils.java
+++ b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/StringUtils.java
@@ -25,7 +25,7 @@
 import java.util.regex.Pattern;
 
 /**
- * A range of useful file utility functions for packagers.
+ * A range of useful String utility functions for packagers.
  */
 public class StringUtils {
 
diff --git a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/Template.java b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/Template.java
new file mode 100644
index 0000000..56d53f5
--- /dev/null
+++ b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/Template.java
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.netbeans.nbpackage;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.function.Supplier;
+
+/**
+ * Definition for a text template supported by NBPackage or one of the
+ * underlying packagers. Text will be loaded from the provided path option if
+ * set in the configuration, or from the default stream provided.
+ */
+public final class Template {
+
+    private final Option<Path> option;
+    private final String name;
+    private final Supplier<Reader> readerProvider;
+
+    private Template(Option<Path> option, String name, Supplier<Reader> readerSupplier) {
+        this.name = name;
+        this.option = option;
+        this.readerProvider = readerSupplier;
+    }
+
+    /**
+     * Name of the template. May be used for a file name for exporting
+     * templates.
+     *
+     * @return template name
+     */
+    public String name() {
+        return name;
+    }
+
+    /**
+     * The path option for overriding the default template.
+     *
+     * @return path option
+     */
+    public Option<Path> option() {
+        return option;
+    }
+
+    /**
+     * Load the text template. The path option from the provided
+     * {@link ExecutionContext} will be used if set, or the template will be
+     * loaded from the default source.
+     *
+     * @param context execution context for path option
+     * @return loaded template
+     * @throws IOException
+     */
+    public String load(ExecutionContext context) throws IOException {
+        Path file = context.getValue(option).orElse(null);
+        return loadImpl(file);
+    }
+
+    String load(Configuration config) throws IOException {
+        String optionValue = config.getValue(option);
+        if (!optionValue.isBlank()) {
+            return loadImpl(Path.of(optionValue));
+        } else {
+            return loadImpl(null);
+        }
+    }
+
+    private String loadImpl(Path file) throws IOException {
+        if (file != null) {
+            return Files.readString(file);
+        } else {
+            try (Reader in = readerProvider.get();  StringWriter out = new StringWriter()) {
+                in.transferTo(out);
+                return out.toString();
+            }
+        }
+    }
+
+    /**
+     * Create a template definition from the provided path option and default
+     * template source. The input stream for the default source should be
+     * readable as UTF-8 text. A packager will usually use
+     * {@link Class#getResourceAsStream(java.lang.String)}. The template name
+     * may be used as a file name for exporting templates.
+     *
+     * @param option user configurable path option to override template
+     * @param name template name / export file name
+     * @param defaultSourceSupplier supplier of input stream for default template
+     * @return template
+     */
+    public static Template of(Option<Path> option, String name,
+            Supplier<InputStream> defaultSourceSupplier) {
+        return new Template(option, name, ()
+                -> new InputStreamReader(defaultSourceSupplier.get(), StandardCharsets.UTF_8));
+    }
+
+}
diff --git a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/appimage/AppImagePackager.java b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/appimage/AppImagePackager.java
index c9992f5..73f1fa6 100644
--- a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/appimage/AppImagePackager.java
+++ b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/appimage/AppImagePackager.java
@@ -25,6 +25,7 @@
 import org.apache.netbeans.nbpackage.ExecutionContext;
 import org.apache.netbeans.nbpackage.Option;
 import org.apache.netbeans.nbpackage.Packager;
+import org.apache.netbeans.nbpackage.Template;
 
 /**
  * Packager for Linux AppImage, using appimagetool.
@@ -37,21 +38,21 @@
     /**
      * Path to appimagetool executable.
      */
-    public static final Option<Path> APPIMAGE_TOOL
+    static final Option<Path> APPIMAGE_TOOL
             = Option.ofPath("package.appimage.tool", "",
                     MESSAGES.getString("option.appimagetool.description"));
 
     /**
      * Path to png icon (48x48) as required by AppDir / XDG specification.
      */
-    public static final Option<Path> APPIMAGE_ICON
+    static final Option<Path> APPIMAGE_ICON
             = Option.ofPath("package.appimage.icon", "",
                     MESSAGES.getString("option.appimageicon.description"));
 
     /**
      * Category (or categories) to set in .desktop file.
      */
-    public static final Option<String> APPIMAGE_CATEGORY
+    static final Option<String> APPIMAGE_CATEGORY
             = Option.ofString("package.appimage.category",
                     "Development;Java;IDE;",
                     MESSAGES.getString("option.appimagecategory.description"));
@@ -60,16 +61,45 @@
      * Architecture of AppImage to create. Defaults to parsing from appimagetool
      * file name.
      */
-    public static final Option<String> APPIMAGE_ARCH
+    static final Option<String> APPIMAGE_ARCH
             = Option.ofString("package.appimage.arch",
                     "",
                     MESSAGES.getString("option.appimagearch.description"));
+    
+    /**
+     * Optional path to custom .desktop template.
+     */
+    static final Option<Path> DESKTOP_TEMPLATE_PATH
+            = Option.ofPath("package.appimage.desktop-template",
+                    MESSAGES.getString("option.desktop_template.description"));
 
-//    public static final Option<Path> APPIMAGE_SVG_ICON
-//            = Option.ofPath("package.appimage.svg", "",
-//                    MESSAGES.getString("option.appimagesvg.description"));
+    /**
+     * Desktop file template.
+     */
+    static final Template DESKTOP_TEMPLATE
+            = Template.of(DESKTOP_TEMPLATE_PATH, "AppImage.desktop.template",
+                    () -> AppImagePackager.class.getResourceAsStream("AppImage.desktop.template"));
+    
+    /**
+     * Optional path to custom AppRun launcher template.
+     */
+    static final Option<Path> LAUNCHER_TEMPLATE_PATH
+            = Option.ofPath("package.appimage.launcher-template",
+                    MESSAGES.getString("option.launcher_template.description"));
+
+    /**
+     * AppRun launcher script template.
+     */
+    static final Template LAUNCHER_TEMPLATE
+            = Template.of(LAUNCHER_TEMPLATE_PATH, "AppImage.launcher.template",
+                    () -> AppImagePackager.class.getResourceAsStream("AppImage.launcher.template"));
+
     private static final List<Option<?>> APPIMAGE_OPTIONS
-            = List.of(APPIMAGE_TOOL, APPIMAGE_ICON, APPIMAGE_CATEGORY, APPIMAGE_ARCH);
+            = List.of(APPIMAGE_TOOL, APPIMAGE_ICON, APPIMAGE_CATEGORY,
+                    APPIMAGE_ARCH, DESKTOP_TEMPLATE_PATH, LAUNCHER_TEMPLATE_PATH);
+    
+    private static final List<Template> APPIMAGE_TEMPLATES
+            = List.of(DESKTOP_TEMPLATE, LAUNCHER_TEMPLATE);
 
     @Override
     public Task createTask(ExecutionContext context) {
@@ -86,4 +116,9 @@
         return APPIMAGE_OPTIONS.stream();
     }
 
+    @Override
+    public Stream<Template> templates() {
+        return APPIMAGE_TEMPLATES.stream();
+    }
+
 }
diff --git a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/appimage/AppImageTask.java b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/appimage/AppImageTask.java
index e4e5c5d..dcd0b59 100644
--- a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/appimage/AppImageTask.java
+++ b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/appimage/AppImageTask.java
@@ -153,12 +153,7 @@
     }
 
     private void setupDesktopFile(Path image, String execName) throws IOException {
-        String template;
-        try ( var reader = new BufferedReader(
-                new InputStreamReader(
-                        getClass().getResourceAsStream("AppImage.desktop.template")))) {
-            template = reader.lines().collect(Collectors.joining("\n", "", "\n"));
-        }
+        String template = AppImagePackager.DESKTOP_TEMPLATE.load(context());
         String desktop = StringUtils.replaceTokens(template,
                 key -> "EXEC".equals(key) ? execName : context().tokenReplacementFor(key));
         Path desktopDir = image.resolve("usr")
@@ -172,12 +167,7 @@
     }
 
     private void setupAppRunScript(Path image, String execName) throws IOException {
-        String template;
-        try ( var reader = new BufferedReader(
-                new InputStreamReader(
-                        getClass().getResourceAsStream("AppImage.launcher.template")))) {
-            template = reader.lines().collect(Collectors.joining("\n", "", "\n"));
-        }
+        String template = AppImagePackager.LAUNCHER_TEMPLATE.load(context());
         String appRun = StringUtils.replaceTokens(template, Map.of("EXEC", execName));
         Path appRunPath = image.resolve("AppRun");
         Files.writeString(appRunPath, appRun, StandardOpenOption.CREATE_NEW);
diff --git a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/deb/DebPackager.java b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/deb/DebPackager.java
index ba1846c..2ff7569 100644
--- a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/deb/DebPackager.java
+++ b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/deb/DebPackager.java
@@ -25,6 +25,7 @@
 import org.apache.netbeans.nbpackage.ExecutionContext;
 import org.apache.netbeans.nbpackage.Option;
 import org.apache.netbeans.nbpackage.Packager;
+import org.apache.netbeans.nbpackage.Template;
 
 /**
  * Packager for Linux DEB using dpkg-deb.
@@ -38,7 +39,7 @@
      * Path to png icon (48x48) as required by AppDir / XDG specification.
      * Defaults to Apache NetBeans icon.
      */
-    public static final Option<Path> DEB_ICON
+    static final Option<Path> ICON_PATH
             = Option.ofPath("package.deb.icon", "",
                     MESSAGES.getString("option.icon.description"));
 
@@ -46,7 +47,7 @@
      * Path to svg icon. Will only be used if DEB_ICON is also set. Defaults to
      * Apache NetBeans icon.
      */
-    public static final Option<Path> DEB_SVG
+    static final Option<Path> SVG_ICON_PATH
             = Option.ofPath("package.deb.svg-icon", "",
                     MESSAGES.getString("option.svg.description"));
 
@@ -54,44 +55,90 @@
      * Name for the .desktop file (without suffix). Defaults to sanitized
      * version of package name.
      */
-    public static final Option<String> DEB_DESKTOP_FILENAME
+    static final Option<String> DESKTOP_FILENAME
             = Option.ofString("package.deb.desktop-filename", "",
                     MESSAGES.getString("option.desktopfilename.description"));
 
     /**
      * StartupWMClass to set in .desktop file.
      */
-    public static final Option<String> DEB_WMCLASS
+    static final Option<String> DESKTOP_WMCLASS
             = Option.ofString("package.deb.wmclass",
                     "${package.name}",
                     MESSAGES.getString("option.wmclass.description"));
-    
+
     /**
      * Category (or categories) to set in .desktop file.
      */
-    public static final Option<String> DEB_CATEGORY
+    static final Option<String> DESKTOP_CATEGORY
             = Option.ofString("package.deb.category",
                     "Development;Java;IDE;",
                     MESSAGES.getString("option.category.description"));
-    
+
     /**
      * Maintainer name and email for Debian Control file.
      */
-    public static final Option<String> DEB_MAINTAINER
+    static final Option<String> DEB_MAINTAINER
             = Option.ofString("package.deb.maintainer", "",
                     MESSAGES.getString("option.maintainer.description"));
-    
+
     /**
      * Package description for Debian Control file.
      */
-    public static final Option<String> DEB_DESCRIPTION
-            = Option.ofString("package.deb.description", 
+    static final Option<String> DEB_DESCRIPTION
+            = Option.ofString("package.deb.description",
                     "Package of ${package.name} ${package.version}.",
                     MESSAGES.getString("option.description.description"));
 
+    /**
+     * Optional path to custom DEB control template.
+     */
+    static final Option<Path> CONTROL_TEMPLATE_PATH
+            = Option.ofPath("package.deb.control-template",
+                    MESSAGES.getString("option.control_template.description"));
+
+    /**
+     * DEB control template.
+     */
+    static final Template CONTROL_TEMPLATE
+            = Template.of(CONTROL_TEMPLATE_PATH, "deb.control.template",
+                    () -> DebPackager.class.getResourceAsStream("deb.control.template"));
+
+    /**
+     * Optional path to custom .desktop template.
+     */
+    static final Option<Path> DESKTOP_TEMPLATE_PATH
+            = Option.ofPath("package.deb.desktop-template",
+                    MESSAGES.getString("option.desktop_template.description"));
+
+    /**
+     * Desktop file template.
+     */
+    static final Template DESKTOP_TEMPLATE
+            = Template.of(DESKTOP_TEMPLATE_PATH, "deb.desktop.template",
+                    () -> DebPackager.class.getResourceAsStream("deb.desktop.template"));
+
+    /**
+     * Optional path to custom launcher template.
+     */
+    static final Option<Path> LAUNCHER_TEMPLATE_PATH
+            = Option.ofPath("package.deb.launcher-template",
+                    MESSAGES.getString("option.launcher_template.description"));
+
+    /**
+     * Launcher script template.
+     */
+    static final Template LAUNCHER_TEMPLATE
+            = Template.of(LAUNCHER_TEMPLATE_PATH, "deb.launcher.template",
+                    () -> DebPackager.class.getResourceAsStream("deb.launcher.template"));
+
     private static final List<Option<?>> DEB_OPTIONS
-            = List.of(DEB_ICON, DEB_SVG, DEB_DESKTOP_FILENAME, DEB_WMCLASS,
-                    DEB_CATEGORY, DEB_MAINTAINER, DEB_DESCRIPTION);
+            = List.of(ICON_PATH, SVG_ICON_PATH, DESKTOP_FILENAME, DESKTOP_WMCLASS,
+                    DESKTOP_CATEGORY, DEB_MAINTAINER, DEB_DESCRIPTION,
+                    CONTROL_TEMPLATE_PATH, DESKTOP_TEMPLATE_PATH, LAUNCHER_TEMPLATE_PATH);
+
+    private static final List<Template> DEB_TEMPLATES
+            = List.of(CONTROL_TEMPLATE, DESKTOP_TEMPLATE, LAUNCHER_TEMPLATE);
 
     @Override
     public Task createTask(ExecutionContext context) {
@@ -108,4 +155,9 @@
         return DEB_OPTIONS.stream();
     }
 
+    @Override
+    public Stream<Template> templates() {
+        return DEB_TEMPLATES.stream();
+    }
+
 }
diff --git a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/deb/DebTask.java b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/deb/DebTask.java
index eb7127b..006c8ca 100644
--- a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/deb/DebTask.java
+++ b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/deb/DebTask.java
@@ -18,9 +18,7 @@
  */
 package org.apache.netbeans.nbpackage.deb;
 
-import java.io.BufferedReader;
 import java.io.IOException;
-import java.io.InputStreamReader;
 import java.nio.file.FileAlreadyExistsException;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -31,7 +29,6 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
-import java.util.stream.Collectors;
 import org.apache.netbeans.nbpackage.AbstractPackagerTask;
 import org.apache.netbeans.nbpackage.ExecutionContext;
 import org.apache.netbeans.nbpackage.NBPackage;
@@ -188,12 +185,7 @@
 
     private void setupLauncher(Path binDir, String packageLocation, String execName)
             throws IOException {
-        String template;
-        try ( var reader = new BufferedReader(
-                new InputStreamReader(
-                        getClass().getResourceAsStream("deb.launcher.template")))) {
-            template = reader.lines().collect(Collectors.joining("\n", "", "\n"));
-        }
+        String template = DebPackager.LAUNCHER_TEMPLATE.load(context());
         String script = StringUtils.replaceTokens(template,
                 Map.of("PACKAGE", packageLocation, "EXEC", execName));
         Path bin = binDir.resolve(execName);
@@ -210,8 +202,8 @@
                 .resolve("hicolor")
                 .resolve("scalable")
                 .resolve("apps");
-        Path icon = context().getValue(DebPackager.DEB_ICON).orElse(null);
-        Path svg = context().getValue(DebPackager.DEB_SVG).orElse(null);
+        Path icon = context().getValue(DebPackager.ICON_PATH).orElse(null);
+        Path svg = context().getValue(DebPackager.SVG_ICON_PATH).orElse(null);
         if (svg != null && icon == null) {
             context().warningHandler().accept(DebPackager.MESSAGES.getString("message.svgnoicon"));
             svg = null;
@@ -236,12 +228,7 @@
     }
 
     private void setupDesktopFile(Path share, String exec, String pkgName) throws IOException {
-        String template;
-        try ( var reader = new BufferedReader(
-                new InputStreamReader(
-                        getClass().getResourceAsStream("deb.desktop.template")))) {
-            template = reader.lines().collect(Collectors.joining("\n", "", "\n"));
-        }
+        String template = DebPackager.DESKTOP_TEMPLATE.load(context());
         Map<String, String> tokens = Map.of("EXEC", exec, "ICON", pkgName);
         String desktop = StringUtils.replaceTokens(template,
                 key -> {
@@ -254,7 +241,7 @@
                 });
         Path desktopDir = share.resolve("applications");
         Files.createDirectories(desktopDir);
-        String desktopFileName = context().getValue(DebPackager.DEB_DESKTOP_FILENAME)
+        String desktopFileName = context().getValue(DebPackager.DESKTOP_FILENAME)
                 .map(name -> sanitize(name))
                 .orElse(pkgName);
         Path desktopFile = desktopDir.resolve(desktopFileName + ".desktop");
@@ -262,12 +249,7 @@
     }
 
     private void setupControlFile(Path DEBIAN) throws Exception {
-        String template;
-        try ( var reader = new BufferedReader(
-                new InputStreamReader(
-                        getClass().getResourceAsStream("deb.control.template")))) {
-            template = reader.lines().collect(Collectors.joining("\n", "", "\n"));
-        }
+        String template = DebPackager.CONTROL_TEMPLATE.load(context());
         String maintainer = context().getValue(DebPackager.DEB_MAINTAINER)
                 .orElse("");
         if (maintainer.isBlank()) {
diff --git a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/innosetup/InnoSetupPackager.java b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/innosetup/InnoSetupPackager.java
index bec5933..9d99e09 100644
--- a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/innosetup/InnoSetupPackager.java
+++ b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/innosetup/InnoSetupPackager.java
@@ -25,6 +25,7 @@
 import org.apache.netbeans.nbpackage.ExecutionContext;
 import org.apache.netbeans.nbpackage.Option;
 import org.apache.netbeans.nbpackage.Packager;
+import org.apache.netbeans.nbpackage.Template;
 
 /**
  * Packager for Windows .exe installer using Inno Setup.
@@ -38,21 +39,21 @@
      * Path to InnoSetup compiler executable. Or to Linux script to invoke via
      * Wine.
      */
-    public static final Option<Path> INNOSETUP_TOOL
+    static final Option<Path> TOOL_PATH
             = Option.ofPath("package.innosetup.tool", "",
                     MESSAGES.getString("option.innosetuptool.description"));
 
     /**
      * InnoSetup App ID.
      */
-    public static final Option<String> INNOSETUP_APPID
+    static final Option<String> APPID
             = Option.ofString("package.innosetup.appid", "",
                     MESSAGES.getString("option.innosetupappid.description"));
 
     /**
      * Path to icon file (*.ico).
      */
-    public static final Option<Path> INNOSETUP_ICON
+    static final Option<Path> ICON_PATH
             = Option.ofPath("package.innosetup.icon", "",
                     MESSAGES.getString("option.innosetupicon.description"));
 
@@ -60,20 +61,30 @@
      * Path to optional license file (*.txt or *.rtf) to display during
      * installation.
      */
-    public static final Option<Path> INNOSETUP_LICENSE
+    static final Option<Path> LICENSE_PATH
             = Option.ofPath("package.innosetup.license", "",
                     MESSAGES.getString("option.innosetuplicense.description"));
 
     /**
      * Path to alternative InnoSetup template.
      */
-    public static final Option<Path> INNOSETUP_TEMPLATE
+    static final Option<Path> ISS_TEMPLATE_PATH
             = Option.ofPath("package.innosetup.template", "",
                     MESSAGES.getString("option.innosetuptemplate.description"));
+    
+    /**
+     * ISS file template.
+     */
+    static final Template ISS_TEMPLATE
+            = Template.of(ISS_TEMPLATE_PATH, "InnoSetup.iss.template",
+                    () -> InnoSetupPackager.class.getResourceAsStream("InnoSetup.iss.template"));
 
     private static final List<Option<?>> INNOSETUP_OPTIONS
-            = List.of(INNOSETUP_TOOL, INNOSETUP_APPID, INNOSETUP_ICON,
-                    INNOSETUP_LICENSE, INNOSETUP_TEMPLATE);
+            = List.of(TOOL_PATH, APPID, ICON_PATH,
+                    LICENSE_PATH, ISS_TEMPLATE_PATH);
+    
+    private static final List<Template> INNOSETUP_TEMPLATES
+            = List.of(ISS_TEMPLATE);
 
     @Override
     public Task createTask(ExecutionContext context) {
@@ -90,4 +101,9 @@
         return INNOSETUP_OPTIONS.stream();
     }
 
+    @Override
+    public Stream<Template> templates() {
+        return INNOSETUP_TEMPLATES.stream();
+    }
+    
 }
diff --git a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/innosetup/InnoSetupTask.java b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/innosetup/InnoSetupTask.java
index 74cca5c..3679559 100644
--- a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/innosetup/InnoSetupTask.java
+++ b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/innosetup/InnoSetupTask.java
@@ -43,7 +43,7 @@
 
     @Override
     public void validateCreatePackage() throws Exception {
-        context().getValue(INNOSETUP_TOOL)
+        context().getValue(TOOL_PATH)
                 .orElseThrow(() -> new IllegalStateException(
                 MESSAGES.getString("message.noinnosetuptool")));
     }
@@ -65,7 +65,7 @@
 
     @Override
     public Path createPackage(Path image) throws Exception {
-        Path tool = context().getValue(INNOSETUP_TOOL)
+        Path tool = context().getValue(TOOL_PATH)
                 .orElseThrow(() -> new IllegalStateException(
                 MESSAGES.getString("message.noinnosetuptool")))
                 .toAbsolutePath();
@@ -126,7 +126,7 @@
     }
 
     private void setupIcons(Path image, String execName) throws IOException {
-        Path icoFile = context().getValue(INNOSETUP_ICON).orElse(null);
+        Path icoFile = context().getValue(ICON_PATH).orElse(null);
         Path dstFile = image.resolve(execName)
                 .resolve("etc")
                 .resolve(execName + ".ico");
@@ -141,7 +141,7 @@
     }
     
     private void setupLicenseFile(Path image) throws IOException {
-        var license = context().getValue(INNOSETUP_LICENSE).orElse(null);
+        var license = context().getValue(LICENSE_PATH).orElse(null);
         if (license == null) {
             return;
         }
@@ -156,15 +156,9 @@
     }
 
     private void createInnoSetupScript(Path image, String execName) throws IOException {
-        Path templateFile = context().getValue(INNOSETUP_TEMPLATE).orElse(null);
-        String template;
-        try (var reader = templateFile != null
-                ? Files.newBufferedReader(templateFile)
-                : new BufferedReader(
-                        new InputStreamReader(
-                                getClass().getResourceAsStream("InnoSetup.iss.template")))) {
-            template = reader.lines().collect(Collectors.joining("\r\n", "", "\r\n"));
-        }
+        // make sure loaded template has correct line endings
+        String template = ISS_TEMPLATE.load(context()).lines()
+                .collect(Collectors.joining("\r\n", "", "\r\n"));
 
         List<Path> files;
         try (var l = Files.list(image.resolve(execName))) {
@@ -176,7 +170,7 @@
 
         String appName = context().getValue(NBPackage.PACKAGE_NAME).orElse(execName);
         String appNameSafe = sanitize(appName);
-        String appID = context().getValue(INNOSETUP_APPID).orElse(appName);
+        String appID = context().getValue(APPID).orElse(appName);
         String appVersion = context().getValue(NBPackage.PACKAGE_VERSION).orElse("1.0");
 
         String appLicense;
diff --git a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/macos/AppBundleTask.java b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/macos/AppBundleTask.java
index 77d0924..aa70f6d 100644
--- a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/macos/AppBundleTask.java
+++ b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/macos/AppBundleTask.java
@@ -18,71 +18,104 @@
  */
 package org.apache.netbeans.nbpackage.macos;
 
-import java.io.BufferedReader;
 import java.io.IOException;
-import java.io.InputStreamReader;
-import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
 import java.nio.file.StandardOpenOption;
+import java.text.MessageFormat;
+import java.util.Arrays;
+import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 import org.apache.netbeans.nbpackage.AbstractPackagerTask;
 import org.apache.netbeans.nbpackage.ExecutionContext;
+import org.apache.netbeans.nbpackage.FileUtils;
 import org.apache.netbeans.nbpackage.NBPackage;
 import org.apache.netbeans.nbpackage.StringUtils;
 
-import static org.apache.netbeans.nbpackage.macos.PkgPackager.*;
-
 /**
  *
  */
 class AppBundleTask extends AbstractPackagerTask {
-
+    
+    private static final String DEFAULT_JAR_INTERNAL_BIN_GLOB = "**/*.{dylib,jnilib}";
+    private static final String NATIVE_BIN_FILENAME = "nativeBinaries";
+    private static final String JAR_BIN_FILENAME = "jarBinaries";
+    private static final String ENTITLEMENTS_FILENAME = "sandbox.plist";
+    private static final String LAUNCHER_SRC_DIRNAME = "macos-launcher-src";
+    
     private String bundleName;
-
+    
     AppBundleTask(ExecutionContext context) {
         super(context);
     }
-
+    
     @Override
     public void validateCreatePackage() throws Exception {
-        throw new UnsupportedOperationException("Not supported yet.");
+        String[] cmds;
+        if (context().getValue(MacOS.CODESIGN_ID).isEmpty()) {
+            cmds = new String[] {"swift"};
+        } else {
+            cmds = new String[] {"swift", "codesign"};
+        }
+        validateTools(cmds);
     }
-
+    
     @Override
     public Path createImage(Path input) throws Exception {
         Path image = super.createImage(input);
         Path bundle = image.resolve(getBundleName() + ".app");
         Path contents = bundle.resolve("Contents");
         Path resources = contents.resolve("Resources");
-
-        String execName = findLauncher(
-                resources
-                        .resolve("APPDIR")
-                        .resolve("bin"))
+        
+        String execName = findLauncher(resources.resolve("APPDIR").resolve("bin"))
                 .getFileName().toString();
         Files.move(resources.resolve("APPDIR"), resources.resolve(execName));
-
+        
         Files.createDirectory(contents.resolve("MacOS"));
         setupIcons(resources, execName);
         setupInfo(contents, execName);
         setupLauncherSource(image);
-
+        setupSigningConfiguration(image, bundle);
+        
         return image;
-
+        
     }
-
+    
     @Override
     public Path createPackage(Path image) throws Exception {
-        throw new UnsupportedOperationException("Not supported yet.");
+        Path bundle = image.resolve(getBundleName() + ".app");
+        
+        String execName = FileUtils.find(bundle, "Contents/Resources/*/bin/*")
+                .stream()
+                .filter(path -> !path.toString().endsWith(".exe"))
+                .findFirst()
+                .map(path -> path.getFileName().toString())
+                .orElseThrow();
+        Path launcher = compileLauncher(image.resolve(LAUNCHER_SRC_DIRNAME));
+        Files.copy(launcher, bundle.resolve("Contents")
+                .resolve("MacOS").resolve(execName),
+                StandardCopyOption.COPY_ATTRIBUTES);
+        
+        String signID = context().getValue(MacOS.CODESIGN_ID).orElse("");
+        if (signID.isBlank()) {
+            context().warningHandler().accept(
+                    MacOS.MESSAGES.getString("message.nocodesignid"));
+            return bundle;
+        }
+        Path entitlements = image.resolve(ENTITLEMENTS_FILENAME);
+        signBinariesInJARs(image, entitlements, signID);
+        signNativeBinaries(image, entitlements, signID);
+        codesign(bundle, entitlements, signID);
+        return bundle;
     }
-
+    
     @Override
     protected String imageName(Path input) throws Exception {
         return super.imageName(input) + "-macOS-app";
     }
-
+    
     @Override
     protected Path applicationDirectory(Path image) throws Exception {
         return image.resolve(getBundleName() + ".app")
@@ -90,14 +123,14 @@
                 .resolve("Resources")
                 .resolve("APPDIR");
     }
-
+    
     @Override
     protected Path runtimeDirectory(Path image, Path application) throws Exception {
         return image.resolve(getBundleName() + ".app")
                 .resolve("Contents")
                 .resolve("Home");
     }
-
+  
     String getBundleName() {
         if (bundleName == null) {
             var name = sanitize(context().getValue(NBPackage.PACKAGE_NAME).orElseThrow());
@@ -108,24 +141,39 @@
         }
         return bundleName;
     }
-
-    private String sanitize(String name) {
+    
+    void validateTools(String... tools) throws Exception {
+        if (context().isVerbose()) {
+            context().infoHandler().accept(MessageFormat.format(
+                    MacOS.MESSAGES.getString("message.validatingtools"),
+                    Arrays.toString(tools)));
+        }
+        for (String tool : tools) {
+            if (context().exec(List.of("which", tool)) != 0) {
+                throw new IllegalStateException(MessageFormat.format(
+                        MacOS.MESSAGES.getString("message.missingtool"),
+                        tool));
+            }
+        }
+    }
+    
+    String sanitize(String name) {
         return name.replaceAll("[\\\\/:*?\"<>|]", "_");
     }
-
+    
     private String sanitizeBundleID(String name) {
         return name.replaceAll("[^a-zA-Z0-9-\\.]", "-");
     }
-
+    
     private Path findLauncher(Path binDir) throws IOException {
         try ( var files = Files.list(binDir)) {
             return files.filter(f -> !f.getFileName().toString().endsWith(".exe"))
                     .findFirst().orElseThrow(IOException::new);
         }
     }
-
+    
     private void setupIcons(Path resources, String execName) throws IOException {
-        Path icnsFile = context().getValue(MACOS_ICON).orElse(null);
+        Path icnsFile = context().getValue(MacOS.ICON_PATH).orElse(null);
         Path dstFile = resources.resolve(execName + ".icns");
         if (icnsFile != null) {
             Files.copy(icnsFile, dstFile);
@@ -134,64 +182,129 @@
                     "/org/apache/netbeans/nbpackage/apache-netbeans.icns"), dstFile);
         }
     }
-
+    
     private void setupInfo(Path contents, String execName) throws IOException {
-        Path templateFile = context().getValue(MACOS_INFO_TEMPLATE).orElse(null);
-        String template;
-        try ( var reader = templateFile != null
-                ? Files.newBufferedReader(templateFile)
-                : new BufferedReader(
-                        new InputStreamReader(
-                                getClass().getResourceAsStream("Info.plist.template"),
-                                StandardCharsets.UTF_8))) {
-            template = reader.lines().collect(Collectors.joining("\n", "", "\n"));
-        }
-
+        String template = MacOS.INFO_TEMPLATE.load(context());
+        
         var tokenMap = Map.of(
                 "BUNDLE_NAME", getBundleName(),
                 "BUNDLE_DISPLAY", context().getValue(NBPackage.PACKAGE_NAME).orElseThrow(),
                 "BUNDLE_VERSION", context().getValue(NBPackage.PACKAGE_VERSION).orElseThrow(),
                 "BUNDLE_EXEC", execName,
-                "BUNDLE_ID", context().getValue(MACOS_BUNDLE_ID)
+                "BUNDLE_ID", context().getValue(MacOS.BUNDLE_ID)
                         .orElse(sanitizeBundleID(getBundleName())),
                 "BUNDLE_ICON", execName + ".icns"
         );
-
+        
         String info = StringUtils.replaceTokens(template, tokenMap);
-
+        
         Files.writeString(contents.resolve("Info.plist"), info,
                 StandardOpenOption.CREATE_NEW);
-
+        
     }
-
+    
     private void setupLauncherSource(Path image) throws IOException {
-        Path launcherProject = image.resolve("macos-launcher-src");
+        Path launcherProject = image.resolve(LAUNCHER_SRC_DIRNAME);
         Files.createDirectories(launcherProject);
         Path sourceDir = launcherProject.resolve("Sources").resolve("AppLauncher");
         Files.createDirectories(sourceDir);
-
-        String packageSwift;
-        try ( var reader = new BufferedReader(
-                new InputStreamReader(
-                        getClass().getResourceAsStream("Package.swift.template"),
-                        StandardCharsets.UTF_8
-                ))) {
-            packageSwift = reader.lines().collect(Collectors.joining("\n", "", "\n"));
-        }
-
-        String mainSwift;
-        try ( var reader = new BufferedReader(
-                new InputStreamReader(
-                        getClass().getResourceAsStream("main.swift.template"),
-                        StandardCharsets.UTF_8
-                ))) {
-            mainSwift = reader.lines().collect(Collectors.joining("\n", "", "\n"));
-        }
-
+        
+        String packageSwift = MacOS.LAUNCHER_PACKAGE_TEMPLATE.load(context());
+        String mainSwift = MacOS.LAUNCHER_TEMPLATE.load(context());
+        
         Files.writeString(launcherProject.resolve("Package.swift"),
                 packageSwift, StandardOpenOption.CREATE_NEW);
         Files.writeString(sourceDir.resolve("main.swift"),
                 mainSwift, StandardOpenOption.CREATE_NEW);
     }
+    
+    private void setupSigningConfiguration(Path image, Path bundle) throws IOException {
+        Files.writeString(image.resolve(ENTITLEMENTS_FILENAME),
+                MacOS.ENTITLEMENTS_TEMPLATE.load(context())
+                , StandardOpenOption.CREATE_NEW);
+        var nativeBinaries = FileUtils.find(bundle,
+                context().getValue(MacOS.SIGNING_FILES).orElseThrow());
+        Files.writeString(image.resolve(NATIVE_BIN_FILENAME),
+                nativeBinaries.stream()
+                        .map(path -> image.relativize(path))
+                        .map(Path::toString)
+                        .collect(Collectors.joining("\n", "", "\n")),
+                StandardOpenOption.CREATE_NEW);
+        var jarBinaries = FileUtils.find(bundle,
+                context().getValue(MacOS.SIGNING_JARS).orElseThrow());
+        Files.writeString(image.resolve(JAR_BIN_FILENAME),
+                jarBinaries.stream()
+                        .map(path -> image.relativize(path))
+                        .map(Path::toString)
+                        .collect(Collectors.joining("\n", "", "\n")),
+                StandardOpenOption.CREATE_NEW);
+    }
+    
+    private Path compileLauncher(Path launcherProject) throws IOException, InterruptedException {
+        var pb = new ProcessBuilder("swift", "build",
+                "--configuration", "release",
+                "--arch", "x86_64");
+        pb.directory(launcherProject.toFile());
+        context().exec(pb);
+        Path launcher = launcherProject.resolve(".build/release/AppLauncher");
+        if (!Files.exists(launcher)) {
+            throw new IOException(launcher.toString());
+        }
+        return launcher;
+    }
+    
+    private void signBinariesInJARs(Path image, Path entitlements, String id)
+            throws IOException {
+        Path jarFiles = image.resolve(JAR_BIN_FILENAME);
+        if (!Files.exists(jarFiles)) {
+            return;
+        }
+        List<Path> jars = Files.readString(jarFiles).lines()
+                .filter(l -> !l.isBlank())
+                .map(Path::of)
+                .map(image::resolve)
+                .collect(Collectors.toList());
+        for (Path jar : jars) {
+            FileUtils.processJarContents(jar,
+                    DEFAULT_JAR_INTERNAL_BIN_GLOB,
+                    (file, path) -> {
+                        codesign(file, entitlements, id);
+                        return true;
+                    }
+            );
+        }
+    }
+    
+    private void signNativeBinaries(Path image, Path entitlements, String id)
+            throws IOException  {
+        Path nativeFiles = image.resolve(NATIVE_BIN_FILENAME);
+        if (!Files.exists(nativeFiles)) {
+            return;
+        }
+        List<Path> files = Files.readString(nativeFiles).lines()
+                .filter(l -> !l.isBlank())
+                .map(Path::of)
+                .map(image::resolve)
+                .collect(Collectors.toList());
+        for (Path file : files) {
+            codesign(file, entitlements, id);
+        }
+    }
 
+    private void codesign(Path file, Path entitlements, String id)
+            throws IOException {
+        try {
+            context().exec("codesign",
+                    "--force",
+                    "--timestamp",
+                    "--options=runtime",
+                    "--entitlements", entitlements.toString(),
+                    "-s", id,
+                    "-v",
+                    file.toString());
+        } catch (InterruptedException ex) {
+            throw new IOException(ex);
+        }
+    }
+    
 }
diff --git a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/macos/MacOS.java b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/macos/MacOS.java
new file mode 100644
index 0000000..2ab0b15
--- /dev/null
+++ b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/macos/MacOS.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.netbeans.nbpackage.macos;
+
+import java.nio.file.Path;
+import java.util.ResourceBundle;
+import org.apache.netbeans.nbpackage.Option;
+import org.apache.netbeans.nbpackage.Template;
+
+/**
+ * Package private options and utilities across packagers.
+ */
+class MacOS {
+
+    private static final String DEFAULT_BIN_GLOB = "{*.dylib,*.jnilib,**/nativeexecution/MacOSX-*/*,Contents/Home/bin/*,Contents/Home/lib/jspawnhelper}";
+    private static final String DEFAULT_JAR_BIN_GLOB = "{jna-5*.jar,junixsocket-native-common-*.jar,launcher-common-*.jar,jansi-*.jar,nbi-engine.jar}";
+
+    static final ResourceBundle MESSAGES
+            = ResourceBundle.getBundle(PkgPackager.class.getPackageName() + ".Messages");
+
+    /**
+     * Value for CFBundleIdentifier.
+     */
+    static final Option<String> BUNDLE_ID
+            = Option.ofString("package.macos.bundleid",
+                    MESSAGES.getString("option.bundle_id.description"));
+
+    /**
+     * Path to icon (*.icns) file.
+     */
+    static final Option<Path> ICON_PATH
+            = Option.ofPath("package.macos.icon",
+                    MESSAGES.getString("option.icon.description"));
+
+    /**
+     * Optional Info.plist template path.
+     */
+    static final Option<Path> INFO_TEMPLATE_PATH
+            = Option.ofPath("package.macos.info-template",
+                    MESSAGES.getString("option.info_template.description"));
+
+    /**
+     * Info.plist template.
+     */
+    static final Template INFO_TEMPLATE
+            = Template.of(INFO_TEMPLATE_PATH, "Info.plist.template",
+                    () -> MacOS.class.getResourceAsStream("Info.plist.template"));
+
+    /**
+     * Optional launcher (main.swift) template path.
+     */
+    static final Option<Path> LAUNCHER_TEMPLATE_PATH
+            = Option.ofPath("package.macos.launcher-template",
+                    MESSAGES.getString("option.launcher_template.description"));
+
+    /**
+     * Launcher (main.swift) template.
+     */
+    static final Template LAUNCHER_TEMPLATE
+            = Template.of(LAUNCHER_TEMPLATE_PATH, "main.swift.template",
+                    () -> MacOS.class.getResourceAsStream("main.swift.template"));
+
+    /**
+     * Unlisted Swift package template.
+     */
+    static final Option<Path> LAUNCHER_PACKAGE_TEMPLATE_PATH
+            = Option.ofPath("package.macos.launcher-package-template", "");
+
+    /**
+     * Unlisted launcher package (Package.swift) template.
+     */
+    static final Template LAUNCHER_PACKAGE_TEMPLATE
+            = Template.of(LAUNCHER_PACKAGE_TEMPLATE_PATH, "Package.swift.template",
+                    () -> MacOS.class.getResourceAsStream("Package.swift.template"));
+
+    /**
+     * Optional codesign entitlements template path.
+     */
+    static final Option<Path> ENTITLEMENTS_TEMPLATE_PATH
+            = Option.ofPath("package.macos.entitlements-template",
+                    MESSAGES.getString("option.entitlements_template.description"));
+
+    /**
+     * Codesign entitlements template.
+     */
+    static final Template ENTITLEMENTS_TEMPLATE
+            = Template.of(ENTITLEMENTS_TEMPLATE_PATH, "sandbox.plist.template",
+                    () -> MacOS.class.getResourceAsStream("sandbox.plist.template"));
+
+    /**
+     * Search pattern for files that need to be code signed.
+     */
+    static final Option<String> SIGNING_FILES
+            = Option.ofString("package.macos.codesign-files", DEFAULT_BIN_GLOB,
+                    MESSAGES.getString("option.codesign_files.description"));
+
+    /**
+     * Search pattern for JARs containing native binaries that need to be code
+     * signed.
+     */
+    static final Option<String> SIGNING_JARS
+            = Option.ofString("package.macos.codesign-jars", DEFAULT_JAR_BIN_GLOB,
+                    MESSAGES.getString("option.codesign_jars.description"));
+
+    /**
+     * Codesign ID for signing binaries and app bundle.
+     */
+    static final Option<String> CODESIGN_ID
+            = Option.ofString("package.macos.codesign-id",
+                    MESSAGES.getString("option.codesign_id.description"));
+
+    /**
+     * Pkgbuild ID for signing installer.
+     */
+    static final Option<String> PKGBUILD_ID
+            = Option.ofString("package.macos.pkgbuild-id",
+                    MESSAGES.getString("option.pkgbuild_id.description"));
+
+    private MacOS() {
+    }
+
+}
diff --git a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/macos/PkgPackager.java b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/macos/PkgPackager.java
index f8de2a8..2fe4ae7 100644
--- a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/macos/PkgPackager.java
+++ b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/macos/PkgPackager.java
@@ -18,46 +18,35 @@
  */
 package org.apache.netbeans.nbpackage.macos;
 
-import java.nio.file.Path;
 import java.util.List;
-import java.util.ResourceBundle;
 import java.util.stream.Stream;
 import org.apache.netbeans.nbpackage.ExecutionContext;
 import org.apache.netbeans.nbpackage.Option;
 import org.apache.netbeans.nbpackage.Packager;
+import org.apache.netbeans.nbpackage.Template;
 
 /**
  * Packager for macOS PKG installer.
  */
 public class PkgPackager implements Packager {
-    
-    static final ResourceBundle MESSAGES
-            = ResourceBundle.getBundle(PkgPackager.class.getPackageName() + ".Messages");
 
-    /**
-     * Value for CFBundleIdentifier.
-     */
-    public static final Option<String> MACOS_BUNDLE_ID
-            = Option.ofString("package.macos.bundleid", "",
-                    MESSAGES.getString("option.bundle_id.description"));
-    
-    /**
-     * Path to icon (*.icns) file.
-     */
-    public static final Option<Path> MACOS_ICON
-            = Option.ofPath("package.macos.icon", "",
-                    MESSAGES.getString("option.icon.description"));
-    
-    /**
-     * Optional Info.plist template.
-     */
-    public static final Option<Path> MACOS_INFO_TEMPLATE
-            = Option.ofPath("package.macos.info-template", "",
-                    MESSAGES.getString("option.info_template.description"));
-    
     private static final List<Option<?>> PKG_OPTIONS = List.of(
-            MACOS_BUNDLE_ID, MACOS_ICON);
-    
+            MacOS.BUNDLE_ID,
+            MacOS.ICON_PATH,
+            MacOS.INFO_TEMPLATE_PATH,
+            MacOS.LAUNCHER_TEMPLATE_PATH,
+            MacOS.ENTITLEMENTS_TEMPLATE_PATH,
+            MacOS.SIGNING_FILES,
+            MacOS.SIGNING_JARS,
+            MacOS.CODESIGN_ID,
+            MacOS.PKGBUILD_ID);
+
+    private static final List<Template> PKG_TEMPLATE = List.of(
+            MacOS.INFO_TEMPLATE,
+            MacOS.LAUNCHER_TEMPLATE,
+            MacOS.ENTITLEMENTS_TEMPLATE
+    );
+
     @Override
     public Task createTask(ExecutionContext context) {
         return new PkgTask(context);
@@ -73,4 +62,9 @@
         return PKG_OPTIONS.stream();
     }
 
+    @Override
+    public Stream<Template> templates() {
+        return PKG_TEMPLATE.stream();
+    }
+    
 }
diff --git a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/macos/PkgTask.java b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/macos/PkgTask.java
index 755fe37..4caa185 100644
--- a/nbpackage/src/main/java/org/apache/netbeans/nbpackage/macos/PkgTask.java
+++ b/nbpackage/src/main/java/org/apache/netbeans/nbpackage/macos/PkgTask.java
@@ -19,7 +19,10 @@
 package org.apache.netbeans.nbpackage.macos;
 
 import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
 import org.apache.netbeans.nbpackage.ExecutionContext;
+import org.apache.netbeans.nbpackage.NBPackage;
 
 /**
  *
@@ -29,15 +32,45 @@
     PkgTask(ExecutionContext context) {
         super(context);
     }
-
+    
     @Override
-    public Path createPackage(Path image) throws Exception {
-        throw new UnsupportedOperationException("Not supported yet.");
+    public void validateCreatePackage() throws Exception {
+        super.validateCreatePackage();
+        validateTools("pkgbuild");
     }
 
     @Override
-    public void validateCreatePackage() throws Exception {
-        throw new UnsupportedOperationException("Not supported yet.");
+    public Path createPackage(Path image) throws Exception {
+        Path bundle = super.createPackage(image);
+        String name = context().getValue(NBPackage.PACKAGE_NAME).orElseThrow();
+        String version = context().getValue(NBPackage.PACKAGE_VERSION).orElseThrow();
+        Path output = context().destination().resolve(
+                sanitize(name) + " " + sanitize(version) + ".pkg");
+        String signingID = context().getValue(MacOS.PKGBUILD_ID).orElse("");
+        List<String> command = new ArrayList<>();
+        command.add("pkgbuild");
+        command.add("--component");
+        command.add(bundle.toString());
+        command.add("--version");
+        command.add(version);
+        command.add("--install-location");
+        command.add("/Applications");
+        
+        if (signingID.isBlank()) {
+            context().warningHandler().accept(
+                    MacOS.MESSAGES.getString("message.nopkgbuildid"));
+        } else {
+            command.add("--sign");
+            command.add(signingID);
+        }
+        
+        command.add(output.toString());
+        int result = context().exec(command);
+        if (result != 0) {
+            throw new Exception();
+        } else {
+            return output;
+        }
     }
 
     @Override
diff --git a/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/Messages.properties b/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/Messages.properties
index 272084f..961d630 100644
--- a/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/Messages.properties
+++ b/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/Messages.properties
@@ -24,6 +24,7 @@
 option.output.description=Output directory. Defaults to current working directory. Must exist.
 option.config.description=Path to configuration file to load.
 option.saveconfig.description=Path to save configuration for editing. Must not exist.
+option.savetemplates.description=Path to save templates for editing. Must not exist or be an existing directory.
 option.imageonly.description=Output package image only (advanced).
 option.verbose.description=Output extra diagnostic information during execution.
 option.property.description=Override the value of configuration options - eg. -Ppackage.version=2.2.
@@ -47,4 +48,4 @@
 message.validatingpackage=Checking package creation prerequisites.
 message.creatingimage=Building image from {0}.
 message.creatingpackage=Building package from {0}.
-message.outputcreated=Successfully built {0}.
\ No newline at end of file
+message.outputcreated=Successfully built {0}.
diff --git a/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/appimage/AppImage.desktop.template b/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/appimage/AppImage.desktop.template
index 6fe22a7..313b400 100644
--- a/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/appimage/AppImage.desktop.template
+++ b/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/appimage/AppImage.desktop.template
@@ -1,6 +1,6 @@
 [Desktop Entry]
 Encoding=UTF-8
-Name=${package.name} ${package.version}
+Name=${package.name}
 Exec=${EXEC}
 Icon=${EXEC}
 Categories=${package.appimage.category}
diff --git a/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/appimage/Messages.properties b/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/appimage/Messages.properties
index 97ce573..657d25c 100644
--- a/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/appimage/Messages.properties
+++ b/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/appimage/Messages.properties
@@ -19,5 +19,7 @@
 option.appimageicon.description=Path to 48x48 png icon as required by xdg specification. Defaults to Apache NetBeans logo.
 option.appimagecategory.description=Application category (or categories) to use in the AppImage .desktop file.
 option.appimagearch.description=Architecture to build appimage for. By default will extract from appimagetool name.
+option.desktop_template.description=Optional path to custom .desktop file template.
+option.launcher_template.description=Optional path to custom AppRun launcher script template.
 
 message.noappimagetool=The package.appimage.tool option must be set.
\ No newline at end of file
diff --git a/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/deb/Messages.properties b/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/deb/Messages.properties
index b392429..a1ab98d 100644
--- a/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/deb/Messages.properties
+++ b/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/deb/Messages.properties
@@ -22,6 +22,9 @@
 option.wmclass.description=StartupWMClass to set in .desktop file. Should match the WMClass of the main application window.
 option.category.description=Application category (or categories) to use in the .desktop file.
 option.desktopfilename.description=Optional name for .desktop file (without suffix). Defaults to sanitized package name.
+option.control_template.description=Optional path to custom Debian Control file template.
+option.desktop_template.description=Optional path to custom .desktop file template.
+option.launcher_template.description=Optional path to custom launcher script template.
 
 message.missingdebtools=The dpkg, dpkg-deb and fakeroot commands must be installed.
 message.svgnoicon=The package.deb.icon-svg option is set, but package.deb.icon is not. Using default icons.
diff --git a/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/deb/deb.desktop.template b/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/deb/deb.desktop.template
index a8be994..90155f0 100644
--- a/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/deb/deb.desktop.template
+++ b/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/deb/deb.desktop.template
@@ -1,6 +1,6 @@
 [Desktop Entry]
 Encoding=UTF-8
-Name=${package.name} ${package.version}
+Name=${package.name}
 Exec=${EXEC}
 Icon=${ICON}
 Categories=${package.deb.category}
diff --git a/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/innosetup/InnoSetup.iss.template b/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/innosetup/InnoSetup.iss.template
index 6c83ab7..6673de8 100644
--- a/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/innosetup/InnoSetup.iss.template
+++ b/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/innosetup/InnoSetup.iss.template
@@ -1,35 +1,35 @@
-[Setup]
-AppId=${APP_ID}
-AppName=${APP_NAME}
-AppVersion=${APP_VERSION}
-
-; TO-DO ${APP_URLS}
-${APP_LICENSE}
-
-DefaultDirName="{autopf}\${APP_NAME_SAFE}"
-DisableProgramGroupPage=yes
-OutputBaseFilename="${APP_NAME_SAFE} ${APP_VERSION}"
-SetupIconFile="${EXEC_NAME}\etc\${EXEC_NAME}.ico"
-Compression=lzma
-SolidCompression=yes
-ArchitecturesAllowed=x64
-ArchitecturesInstallIn64BitMode=x64
-
-[Languages]
-Name: "english"; MessagesFile: "compiler:Default.isl"
-
-[Tasks]
-Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checkedonce
-
-[InstallDelete]
-${INSTALL_DELETE}
-
-[Files]
-${FILES}
-
-[Icons]
-Name: "{commonprograms}\${APP_NAME_SAFE}"; Filename: "{app}\bin\${EXEC_NAME}64.exe"; ${PARAMETERS} IconFilename: "{app}\etc\${EXEC_NAME}.ico";
-Name: "{commondesktop}\${APP_NAME_SAFE}"; Filename: "{app}\bin\${EXEC_NAME}64.exe"; ${PARAMETERS} IconFilename: "{app}\etc\${EXEC_NAME}.ico"; Tasks: desktopicon
-
-[Run]
-Filename: "{app}\bin\${EXEC_NAME}64.exe"; ${PARAMETERS} Description: "{cm:LaunchProgram,${APP_NAME_SAFE}}"; Flags: nowait postinstall skipifsilent
+[Setup]

+AppId=${APP_ID}

+AppName=${APP_NAME}

+AppVersion=${APP_VERSION}

+

+; TO-DO ${APP_URLS}

+${APP_LICENSE}

+

+DefaultDirName="{autopf}\${APP_NAME_SAFE}"

+DisableProgramGroupPage=yes

+OutputBaseFilename="${APP_NAME_SAFE} ${APP_VERSION}"

+SetupIconFile="${EXEC_NAME}\etc\${EXEC_NAME}.ico"

+Compression=lzma

+SolidCompression=yes

+ArchitecturesAllowed=x64

+ArchitecturesInstallIn64BitMode=x64

+

+[Languages]

+Name: "english"; MessagesFile: "compiler:Default.isl"

+

+[Tasks]

+Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checkedonce

+

+[InstallDelete]

+${INSTALL_DELETE}

+

+[Files]

+${FILES}

+

+[Icons]

+Name: "{commonprograms}\${APP_NAME_SAFE}"; Filename: "{app}\bin\${EXEC_NAME}64.exe"; ${PARAMETERS} IconFilename: "{app}\etc\${EXEC_NAME}.ico";

+Name: "{commondesktop}\${APP_NAME_SAFE}"; Filename: "{app}\bin\${EXEC_NAME}64.exe"; ${PARAMETERS} IconFilename: "{app}\etc\${EXEC_NAME}.ico"; Tasks: desktopicon

+

+[Run]

+Filename: "{app}\bin\${EXEC_NAME}64.exe"; ${PARAMETERS} Description: "{cm:LaunchProgram,${APP_NAME_SAFE}}"; Flags: nowait postinstall skipifsilent

diff --git a/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/macos/Messages.properties b/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/macos/Messages.properties
index b120e58..fec0030 100644
--- a/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/macos/Messages.properties
+++ b/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/macos/Messages.properties
@@ -17,4 +17,15 @@
 
 option.bundle_id.description=Value for CFBundleIdentifier.
 option.icon.description=Path to icon file (*.icns). Defaults to Apache NetBeans logo.
-option.info_template.description=Optional Info.plist template.
+option.info_template.description=Optional path to Info.plist template.
+option.launcher_template.description=Optional path to launcher (main.swift) template.
+option.entitlements_template.description=Optional path to codesign entitlements template.
+option.codesign_files.description=Search pattern for native binaries that need to be code signed.
+option.codesign_jars.description=Search pattern for JARs that bundle native binaries that need to be code signed.
+option.codesign_id.description=Code signing identity as passed to Codesign.
+option.pkgbuild_id.description=Installer signing identity as passed to Pkgbuild.
+
+message.validatingtools=Validating required tools - {0}
+message.missingtool=Cannot find required tool - {0}
+message.nocodesignid=No codesign ID has been configured. App bundle will be unsigned.
+message.nopkgbuildid=No pkgbuild ID has been configured. Installer will be unsigned.
diff --git a/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/macos/sandbox.plist.template b/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/macos/sandbox.plist.template
new file mode 100644
index 0000000..665ed16
--- /dev/null
+++ b/nbpackage/src/main/resources/org/apache/netbeans/nbpackage/macos/sandbox.plist.template
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+
+    <key>com.apple.security.cs.allow-jit</key>
+    <true/>
+
+    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
+    <true/>
+
+    <key>com.apple.security.cs.disable-library-validation</key>
+    <true/>
+
+    <key>com.apple.security.cs.allow-dyld-environment-variables</key>
+    <true/>
+
+    <key>com.apple.security.cs.debugger</key>
+    <true/>
+
+  </dict>
+</plist>
diff --git a/nbpackage/src/test/java/org/apache/netbeans/nbpackage/FileUtilsTest.java b/nbpackage/src/test/java/org/apache/netbeans/nbpackage/FileUtilsTest.java
new file mode 100644
index 0000000..9d9a906
--- /dev/null
+++ b/nbpackage/src/test/java/org/apache/netbeans/nbpackage/FileUtilsTest.java
@@ -0,0 +1,244 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.netbeans.nbpackage;
+
+import java.net.URI;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ *
+ */
+public class FileUtilsTest {
+
+    public FileUtilsTest() {
+    }
+
+    @BeforeAll
+    public static void setUpClass() {
+    }
+
+    @AfterAll
+    public static void tearDownClass() {
+    }
+
+    @BeforeEach
+    public void setUp() {
+    }
+
+    @AfterEach
+    public void tearDown() {
+    }
+
+    /**
+     * Test of copyFiles method, of class FileUtils.
+     */
+    @Test
+    public void testCopyFiles() throws Exception {
+        Path src = Files.createTempDirectory("nbp-copy-src-");
+        Path parent = src.resolve("foo").resolve("bar");
+        Path file = parent.resolve("baz");
+        Files.createDirectories(parent);
+        Files.createFile(file);
+        assertTrue(Files.isRegularFile(file));
+        Path dst = Files.createTempDirectory("nbp-copy-dst-");
+        FileUtils.copyFiles(src, dst);
+        assertTrue(Files.isRegularFile(file));
+        Path dstFile = dst.resolve(src.relativize(file));
+        assertTrue(Files.isRegularFile(dstFile));
+        FileUtils.deleteFiles(src);
+        FileUtils.deleteFiles(dst);
+    }
+
+    /**
+     * Test of moveFiles method, of class FileUtils.
+     */
+    @Test
+    public void testMoveFiles() throws Exception {
+        Path src = Files.createTempDirectory("nbp-move-src-");
+        Path parent = src.resolve("foo").resolve("bar");
+        Path file = parent.resolve("baz");
+        Files.createDirectories(parent);
+        Files.createFile(file);
+        assertTrue(Files.isRegularFile(file));
+        Path dst = Files.createTempDirectory("nbp-move-dst-");
+        FileUtils.moveFiles(src, dst);
+        assertFalse(Files.isRegularFile(file));
+        Path dstFile = dst.resolve(src.relativize(file));
+        assertTrue(Files.isRegularFile(dstFile));
+        Files.delete(src);
+        FileUtils.deleteFiles(dst);
+    }
+
+    /**
+     * Test of deleteFiles method, of class FileUtils.
+     */
+    @Test
+    public void testDeleteFiles() throws Exception {
+        Path tmpDir = Files.createTempDirectory("nbp-delete-");
+        Path parent = tmpDir.resolve("foo").resolve("bar");
+        Path file = parent.resolve("baz");
+        Files.createDirectories(parent);
+        Files.createFile(file);
+        assertTrue(Files.isRegularFile(file));
+        FileUtils.deleteFiles(tmpDir.resolve("foo"));
+        assertFalse(Files.exists(file));
+        assertFalse(Files.exists(parent));
+        Files.delete(tmpDir);
+    }
+    
+    /**
+     * Test of find method, of class FileUtils.
+     * 
+     * @throws Exception 
+     */
+    @Test
+    public void testFind() throws Exception {
+       Path tmpDir = Files.createTempDirectory("nbp-find-");
+       try {
+           List<Path> found = FileUtils.find(tmpDir, "**");
+           assertTrue(found.isEmpty());
+           Path dir = Files.createDirectory(tmpDir.resolve("dir"));
+           Path file1 = Files.createFile(dir.resolve("file1.ext1"));
+           Path file2 = Files.createFile(tmpDir.resolve("file2.ext2"));
+           found = FileUtils.find(tmpDir, "*");
+           assertEquals(3, found.size());
+           found = FileUtils.find(tmpDir, "dir/*");
+           assertTrue(found.size() == 1 && found.contains(file1));
+           found = FileUtils.find(tmpDir, "*.{ext1,ext2}");
+           assertTrue(found.size() == 2 && found.contains(file1) && found.contains(file2));
+           found = FileUtils.find(tmpDir, "**.{ext1,ext2}");
+           assertTrue(found.size() == 2 && found.contains(file1) && found.contains(file2));
+           found = FileUtils.find(tmpDir, "**/*.{ext1,ext2}");
+           assertTrue(found.size() == 1 && found.contains(file1));
+           found = FileUtils.find(tmpDir, "*/file2.ext2");
+           assertTrue(found.isEmpty());
+       } finally {
+           FileUtils.deleteFiles(tmpDir);
+       }
+    }
+    
+
+    /**
+     * Test of findDirs method, of class FileUtils.
+     */
+    @Test
+    public void testFindDirs() throws Exception {
+        Path tmpDir = Files.createTempDirectory("nbp-find-dirs-");
+        Path foo = tmpDir.resolve("foo");
+        Path fooBin = foo.resolve("bin");
+        Files.createDirectories(fooBin);
+        Files.createFile(fooBin.resolve("launcher"));
+        Path fooEtc = foo.resolve("etc");
+        Files.createDirectories(fooEtc);
+        Files.createFile(fooEtc.resolve("test.conf"));
+        Path bar = tmpDir.resolve("bar");
+        Path barBin = bar.resolve("bin");
+        Files.createDirectories(barBin);
+        Files.createFile(barBin.resolve("launcher"));
+
+        List<Path> findDirs = FileUtils.findDirs(tmpDir, 1, "bin/launcher");
+
+        assertEquals(2, findDirs.size());
+        assertTrue(findDirs.contains(foo));
+        assertTrue(findDirs.contains(bar));
+
+        findDirs = FileUtils.findDirs(tmpDir, 1, "bin/launcher", "etc/*.conf");
+
+        assertEquals(1, findDirs.size());
+        assertTrue(findDirs.contains(foo));
+        assertFalse(findDirs.contains(bar));
+
+        FileUtils.deleteFiles(tmpDir);
+
+    }
+    
+    @Test
+    public void testProcessJarContents() throws Exception {
+        Path tmpDir = Files.createTempDirectory("nbp-process-jar-");
+        try {
+            Path root = Files.createDirectory(tmpDir.resolve("root"));
+            Path dir1 = Files.createDirectory(root.resolve("dir1"));
+            Path dir2 = Files.createDirectory(root.resolve("dir2"));
+            Path file1 = Files.writeString(dir1.resolve("file1"),
+                    "File One",
+                    StandardOpenOption.CREATE_NEW);
+            Path file2 = Files.writeString(dir2.resolve("file2"),
+                    "File Two",
+                    StandardOpenOption.CREATE_NEW);
+            Path jarFile = tmpDir.resolve("test.jar");
+            FileUtils.createZipArchive(root, jarFile);
+            
+            int[] found = new int[]{0};
+            boolean processed;
+            
+            processed = FileUtils.processJarContents(jarFile, "**/*", (file, path) -> {
+                found[0] = found[0] + 1;
+                Files.writeString(file, "GARBAGE");
+                return false;
+            });
+            
+            assertFalse(processed);
+            assertEquals(2, found[0]);
+            
+            var jarURI = URI.create("jar:" + jarFile.toUri());
+            
+            try (var jarFS = FileSystems.newFileSystem(jarURI, Map.of())) {
+                assertEquals("File One",
+                        Files.readString(jarFS.getPath("dir1", "file1")));
+                assertEquals("File Two",
+                        Files.readString(jarFS.getPath("dir2", "file2")));
+            }
+            
+            found[0] = 0;
+            processed = FileUtils.processJarContents(jarFile, "/dir1/*", (file, path) -> {
+                found[0] = found[0] + 1;
+                assertEquals("file1", file.getFileName().toString());
+                assertEquals("/dir1/file1", path);
+                Files.writeString(file, "FILE ONE UPDATED");
+                return true;
+            });
+            
+            assertTrue(processed);
+            assertEquals(1, found[0]);
+            
+            try (var jarFS = FileSystems.newFileSystem(jarURI, Map.of())) {
+                assertEquals("FILE ONE UPDATED",
+                        Files.readString(jarFS.getPath("dir1", "file1")));
+                assertEquals("File Two",
+                        Files.readString(jarFS.getPath("dir2", "file2")));
+            }
+        } finally {
+            FileUtils.deleteFiles(tmpDir);
+        }
+        
+    }
+
+}
diff --git a/nbpackage/src/test/java/org/apache/netbeans/nbpackage/TemplateTest.java b/nbpackage/src/test/java/org/apache/netbeans/nbpackage/TemplateTest.java
new file mode 100644
index 0000000..1ea6f68
--- /dev/null
+++ b/nbpackage/src/test/java/org/apache/netbeans/nbpackage/TemplateTest.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.netbeans.nbpackage;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ *
+ */
+public class TemplateTest {
+
+    public TemplateTest() {
+    }
+
+    /**
+     * Test of load method, of class Template.
+     */
+    @Test
+    public void testLoad() throws Exception {
+        Path tmpDir = Files.createTempDirectory("nbp-templates-");
+        try {
+            Option<Path> option1 = Option.ofPath("option1", "");
+            Option<Path> option2 = Option.ofPath("option2", "");
+            Template template1 = Template.of(option1, "Template 1",
+                    () -> TemplateTest.class.getResourceAsStream("template1.template"));
+            Template template2 = Template.of(option2, "Template 2",
+                    () -> {
+                        throw new AssertionError("Default source should not be called");
+                    });
+            Path override2 = Files.writeString(tmpDir.resolve("template2"),
+                    "TEMPLATE TWO OVERRIDE", StandardOpenOption.CREATE_NEW);
+
+            Configuration config = Configuration.builder()
+                    .set(option2, override2.toAbsolutePath().toString())
+                    .build();
+            ExecutionContext ctxt = new ExecutionContext(null, null, config, null, false);
+            String loaded1 = template1.load(ctxt);
+            String loaded2 = template2.load(ctxt);
+            assertEquals("TEMPLATE ONE", loaded1);
+            assertEquals("TEMPLATE TWO OVERRIDE", loaded2);
+        } finally {
+            FileUtils.deleteFiles(tmpDir);
+        }
+    }
+
+}
diff --git a/nbpackage/src/test/resources/org/apache/netbeans/nbpackage/template1.template b/nbpackage/src/test/resources/org/apache/netbeans/nbpackage/template1.template
new file mode 100644
index 0000000..265485e
--- /dev/null
+++ b/nbpackage/src/test/resources/org/apache/netbeans/nbpackage/template1.template
@@ -0,0 +1 @@
+TEMPLATE ONE
\ No newline at end of file