diff --git a/src/main/java/org/apache/netbeans/nbpackage/ArchiveUtils.java b/src/main/java/org/apache/netbeans/nbpackage/ArchiveUtils.java index 24337b4..a05c314 100644 --- a/src/main/java/org/apache/netbeans/nbpackage/ArchiveUtils.java +++ b/src/main/java/org/apache/netbeans/nbpackage/ArchiveUtils.java @@ -41,6 +41,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.FileVisitResult; import java.nio.file.Files; @@ -433,6 +434,48 @@ public static void createArchive(ArchiveType archiveTyp } } + /** + * Creates a tar file embedded in a shell script from the contents in {@code directoryToArchive}. + * + * @param directoryToArchive the directory to archive the contents of + * @param archiveFile the file to write the archive to + * @throws IOException if an IO error occurs + * @throws ArchiveException if an archive error occurs + */ + public static void createEmbeddedTarScript(String shellScript, Path directoryToArchive, Path archiveFile) + throws IOException, ArchiveException { + try (OutputStream fileOutputStream = new BufferedOutputStream(Files.newOutputStream(archiveFile)); + TarArchiveOutputStream archiveOutputStream = new ArchiveStreamFactory() + .createArchiveOutputStream(ArchiveType.TAR.getCommonsCompressName(), fileOutputStream)) { + + fileOutputStream.write(shellScript.getBytes(Charset.forName("UTF-8"))); + + archiveOutputStream.setLongFileMode(LONGFILE_GNU); + + + Files.walkFileTree(directoryToArchive, new SimpleFileVisitor() { + @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + createAndPutArchiveEntry(ArchiveType.TAR, archiveOutputStream, directoryToArchive, file); + archiveOutputStream.closeArchiveEntry(); + return FileVisitResult.CONTINUE; + } + + @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + if (Files.isSameFile(dir, directoryToArchive)) { + return FileVisitResult.CONTINUE; + } + + TarArchiveEntry entry = archiveOutputStream.createArchiveEntry(dir.toFile(), getRelativePathString(dir, directoryToArchive)); + archiveOutputStream.putArchiveEntry(entry); + archiveOutputStream.closeArchiveEntry(); + return FileVisitResult.CONTINUE; + } + }); + + archiveOutputStream.finish(); + } + } + @SuppressWarnings("unchecked") private static void createAndPutArchiveEntry(ArchiveType archiveType, ArchiveOutputStream archiveOutputStream, Path directoryToArchive, Path filePathToArchive) throws IOException { diff --git a/src/main/java/org/apache/netbeans/nbpackage/FileUtils.java b/src/main/java/org/apache/netbeans/nbpackage/FileUtils.java index 67e318b..0ebffcc 100644 --- a/src/main/java/org/apache/netbeans/nbpackage/FileUtils.java +++ b/src/main/java/org/apache/netbeans/nbpackage/FileUtils.java @@ -107,6 +107,25 @@ public static void createZipArchive(Path directory, Path destination) throws IOE } } + /** + * Create a tar file embedded in a shell script of the provided directory, maintaining file attributes. + * The destination file must not already exist. + * + * @param directory directory to zip + * @param destination destination file (must not exist) + * @throws IOException + */ + public static void createEmbeddedTarScript(String script, Path directory, Path destination) throws IOException { + if (Files.exists(destination)) { + throw new IOException(destination.toString()); + } + try { + ArchiveUtils.createEmbeddedTarScript(script, directory, destination); + } catch (ArchiveException ex) { + throw new IOException(ex); + } + } + /** * Extract an archive into the given destination directory, maintaining file * attributes where possible. The destination directory must already exist. diff --git a/src/main/java/org/apache/netbeans/nbpackage/NBPackage.java b/src/main/java/org/apache/netbeans/nbpackage/NBPackage.java index c538fe6..de54441 100644 --- a/src/main/java/org/apache/netbeans/nbpackage/NBPackage.java +++ b/src/main/java/org/apache/netbeans/nbpackage/NBPackage.java @@ -36,6 +36,7 @@ import org.apache.netbeans.nbpackage.innosetup.InnoSetupPackager; import org.apache.netbeans.nbpackage.macos.PkgPackager; import org.apache.netbeans.nbpackage.rpm.RpmPackager; +import org.apache.netbeans.nbpackage.tar.TarScriptPackager; import org.apache.netbeans.nbpackage.zip.ZipPackager; /** @@ -130,6 +131,7 @@ public final class NBPackage { new AppImagePackager(), new DebPackager(), new RpmPackager(), + new TarScriptPackager(), new InnoSetupPackager(), new PkgPackager(), new ZipPackager() diff --git a/src/main/java/org/apache/netbeans/nbpackage/tar/TarScriptPackageTask.java b/src/main/java/org/apache/netbeans/nbpackage/tar/TarScriptPackageTask.java new file mode 100644 index 0000000..5e66d45 --- /dev/null +++ b/src/main/java/org/apache/netbeans/nbpackage/tar/TarScriptPackageTask.java @@ -0,0 +1,180 @@ +/* + * 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.tar; + +import org.apache.netbeans.nbpackage.AbstractPackagerTask; +import org.apache.netbeans.nbpackage.Architecture; +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 java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import static org.apache.netbeans.nbpackage.Architecture.AARCH64; +import static org.apache.netbeans.nbpackage.Architecture.X86_64; + +class TarScriptPackageTask extends AbstractPackagerTask { + + private static final String ARCH_AARCH64 = "aarch64"; + private static final String ARCH_X86_64 = "x86_64"; + private static final String ARCH_NOARCH = "noarch"; + + private String packageArch; + + TarScriptPackageTask(ExecutionContext context) { + super(context); + } + + @Override + protected void customizeImage(Path image) throws Exception { + String appDir = findLauncher( + image.resolve("APPDIR").resolve("bin")) + .getFileName().toString(); + + Path launcherDir = calculateAppPath(image).resolve("launcher"); + Files.createDirectories(launcherDir); + + Path icon = context().getValue(TarScriptPackager.ICON_PATH).orElse(null); + Path svg = context().getValue(TarScriptPackager.SVG_ICON_PATH).orElse(null); + if (svg != null && icon == null) { + context().warningHandler().accept(TarScriptPackager.MESSAGES.getString("message.svgnoicon")); + svg = null; + } + + if (icon != null) { + Files.copy(icon, launcherDir.resolve(appDir + ".png")); + } else { + Files.copy(getClass().getResourceAsStream( + "/org/apache/netbeans/nbpackage/apache-netbeans-48x48.png"), + launcherDir.resolve(appDir + ".png")); + } + if (svg != null) { + Files.copy(svg, launcherDir.resolve(appDir + ".svg")); + } else if (icon == null) { + Files.copy(getClass().getResourceAsStream( + "/org/apache/netbeans/nbpackage/apache-netbeans.svg"), + launcherDir.resolve(appDir + ".svg")); + } + } + + @Override + protected Path buildPackage(Path image) throws Exception { + String appDir = findLauncher( + image.resolve("APPDIR").resolve("bin")) + .getFileName().toString(); + + String appName = context().getValue(NBPackage.PACKAGE_NAME).orElse(appDir); + String appNameSafe = sanitize(appName); + + Path dst = context().destination().resolve(image.getFileName().toString() + ".sh"); + + String desktop = setupDesktopFile("exe", "icon"); + + String launcher = TarScriptPackager.LAUNCHER_TEMPLATE.load(context()); + + String template = TarScriptPackager.TAR_SCRIPT_TEMPLATE.load(context()); + Map tokens = Map.of("package.tar.app_name_safe", appNameSafe, + "package.tar.app_name", appName, "package.tar.app_dir", appDir, + "package.tar.desktop", desktop, "package.tar.launcher", launcher); + String script = StringUtils.replaceTokens(template, + key -> { + var ret = tokens.get(key); + if (ret != null) { + return ret; + } else { + return context().tokenReplacementFor(key); + } + }); + + FileUtils.createEmbeddedTarScript(script, image, dst); + + try { + Files.setPosixFilePermissions(dst, PosixFilePermissions.fromString("rwxr-xr-x")); + } catch (UnsupportedOperationException ex) { + context().warningHandler().accept("UnsupportedOperationException : PosixFilePermissions"); + } + return dst; + } + + @Override + protected String calculateImageName(Path input) throws Exception { + return super.calculateImageName(input) + "." + packageArch(); + } + + @Override + protected Path calculateRuntimePath(Path image, Path application) throws Exception { + return application.resolve("jdk"); + } + + @Override + protected Path calculateAppPath(Path image) throws IOException { + return image.resolve("APPDIR"); + } + + + private String packageArch() { + if (packageArch == null) { + packageArch = context().getValue(NBPackage.PACKAGE_ARCH) + .orElseGet(() -> { + Optional runtime = context().getValue(NBPackage.PACKAGE_RUNTIME); + if (runtime.isPresent()) { + return Architecture.detectFromPath( + runtime.get()).map(a -> { + return switch (a) { + case AARCH64 -> + ARCH_AARCH64; + case X86_64 -> + ARCH_X86_64; + }; + }).orElseGet(() -> { + context().warningHandler().accept( + TarScriptPackager.MESSAGES.getString("message.unknownarch")); + return ARCH_NOARCH; + }); + } else { + return ARCH_NOARCH; + } + }); + } + return packageArch; + } + + private String sanitize(String text) { + return text.toLowerCase(Locale.ROOT) + .replaceAll("[^a-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 String setupDesktopFile(String exec, String pkgName) throws IOException { + String template = TarScriptPackager.DESKTOP_TEMPLATE.load(context()); + return context().replaceTokens(template); + } + +} diff --git a/src/main/java/org/apache/netbeans/nbpackage/tar/TarScriptPackager.java b/src/main/java/org/apache/netbeans/nbpackage/tar/TarScriptPackager.java new file mode 100644 index 0000000..1329905 --- /dev/null +++ b/src/main/java/org/apache/netbeans/nbpackage/tar/TarScriptPackager.java @@ -0,0 +1,145 @@ +/* + * 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.tar; + +import org.apache.netbeans.nbpackage.ExecutionContext; +import org.apache.netbeans.nbpackage.Option; +import org.apache.netbeans.nbpackage.Packager; +import org.apache.netbeans.nbpackage.Template; +import java.nio.file.Path; +import java.util.List; +import java.util.ResourceBundle; +import java.util.stream.Stream; + +/** + * Packager for Linux embedded tar in script installer. + */ +public class TarScriptPackager implements Packager { + + static final ResourceBundle MESSAGES + = ResourceBundle.getBundle(TarScriptPackager.class.getPackageName() + ".Messages"); + + /** + * Path to png icon (48x48) as required by AppDir / XDG specification. + * Defaults to Apache NetBeans icon. + */ + static final Option ICON_PATH + = Option.ofPath("package.tar.icon", + MESSAGES.getString("option.icon.help")); + + /** + * Path to svg icon. Defaults to Apache NetBeans icon. + */ + static final Option SVG_ICON_PATH + = Option.ofPath("package.tar.svg-icon", + MESSAGES.getString("option.svg.help")); + + /** + * Name for the .desktop file (without suffix). Defaults to sanitized + * version of package name. + */ + static final Option DESKTOP_FILENAME + = Option.ofString("package.tar.desktop-filename", + MESSAGES.getString("option.desktopfilename.help")); + + /** + * StartupWMClass to set in .desktop file. + */ + static final Option DESKTOP_WMCLASS + = Option.ofString("package.tar.wmclass", + MESSAGES.getString("option.wmclass.default"), + MESSAGES.getString("option.wmclass.help")); + + /** + * Category (or categories) to set in .desktop file. + */ + static final Option DESKTOP_CATEGORY + = Option.ofString("package.tar.category", + MESSAGES.getString("option.category.default"), + MESSAGES.getString("option.category.help")); + + /** + * Optional path to custom .desktop template. + */ + static final Option DESKTOP_TEMPLATE_PATH + = Option.ofPath("package.tar.desktop-template", + MESSAGES.getString("option.desktop_template.help")); + + /** + * Desktop file template. + */ + static final Template DESKTOP_TEMPLATE + = Template.of(DESKTOP_TEMPLATE_PATH, "tar.desktop.template", + () -> TarScriptPackager.class.getResourceAsStream("tar.desktop.template")); + + /** + * Optional path to custom launcher template. + */ + static final Option LAUNCHER_TEMPLATE_PATH + = Option.ofPath("package.tar.launcher-template", + MESSAGES.getString("option.launcher_template.help")); + + /** + * Launcher script template. + */ + static final Template LAUNCHER_TEMPLATE + = Template.of(LAUNCHER_TEMPLATE_PATH, "tar.launcher.template", + () -> TarScriptPackager.class.getResourceAsStream("tar.launcher.template")); + + /** + * Path to alternative tar script template. + */ + static final Option TAR_SCRIPT_TEMPLATE_PATH + = Option.ofPath("package.tar.template", "", + MESSAGES.getString("option.template.help")); + + /** + * Shell file template. + */ + static final Template TAR_SCRIPT_TEMPLATE + = Template.of(TAR_SCRIPT_TEMPLATE_PATH, "tar.script.template", + () -> TarScriptPackager.class.getResourceAsStream("tar.script.template")); + + private static final List> TAR_SCRIPT_OPTIONS + = List.of(ICON_PATH, SVG_ICON_PATH, DESKTOP_FILENAME, DESKTOP_WMCLASS, + DESKTOP_CATEGORY, DESKTOP_TEMPLATE_PATH, LAUNCHER_TEMPLATE_PATH); + + private static final List