diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/FileUtils.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/FileUtils.java index 8ac88c13e1d..cb1bcaa51b1 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/FileUtils.java +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/FileUtils.java @@ -30,6 +30,7 @@ import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.List; @@ -106,6 +107,17 @@ public final class FileUtils { private static record CopyAction(Path src, Path dest) { void apply(CopyOption... options) throws IOException { + if (List.of(options).contains(StandardCopyOption.REPLACE_EXISTING)) { + // They requested copying with replacing the existing content. + if (src == null && Files.isRegularFile(dest)) { + // This copy action creates a directory, but a file at the same path already exists, so delete it. + Files.deleteIfExists(dest); + } else if (src != null && Files.isDirectory(dest)) { + // This copy action copies a file, but a directory at the same path exists already, so delete it. + deleteRecursive(dest); + } + } + if (src == null) { Files.createDirectories(dest); } else { diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/ConfigurationTarget.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/ConfigurationTarget.java index ba3131a7680..0d68d055b92 100644 --- a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/ConfigurationTarget.java +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/ConfigurationTarget.java @@ -62,6 +62,27 @@ public record ConfigurationTarget(Optional cmd, Optional verifier) { + cmd.ifPresent(Objects.requireNonNull(verifier)); + test.ifPresent(v -> { + v.addInstallVerifier(verifier::accept); + }); + return this; + } + + public ConfigurationTarget addRunOnceInitializer(Consumer initializer) { + Objects.requireNonNull(initializer); + cmd.ifPresent(_ -> { + initializer.accept(this); + }); + test.ifPresent(v -> { + v.addRunOnceInitializer(() -> { + initializer.accept(this); + }); + }); + return this; + } + public ConfigurationTarget add(AdditionalLauncher addLauncher) { return apply(addLauncher::applyTo, addLauncher::applyTo); } diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/TKit.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/TKit.java index a19b3697a81..baeeda4e569 100644 --- a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/TKit.java +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/TKit.java @@ -26,6 +26,7 @@ import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE; import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; import static java.util.stream.Collectors.toSet; import static jdk.jpackage.internal.util.function.ThrowingBiFunction.toBiFunction; +import static jdk.jpackage.internal.util.function.ThrowingFunction.toFunction; import static jdk.jpackage.internal.util.function.ThrowingSupplier.toSupplier; import java.io.Closeable; @@ -896,7 +897,14 @@ public final class TKit { public static void assertSymbolicLinkExists(Path path) { assertPathExists(path, true); assertTrue(Files.isSymbolicLink(path), String.format - ("Check [%s] is a symbolic link", path)); + ("Check [%s] is a symbolic link", Objects.requireNonNull(path))); + } + + public static void assertSymbolicLinkTarget(Path symlinkPath, Path expectedTargetPath) { + assertSymbolicLinkExists(symlinkPath); + var targetPath = toFunction(Files::readSymbolicLink).apply(symlinkPath); + assertEquals(expectedTargetPath, targetPath, + String.format("Check the target of the symbolic link [%s]", symlinkPath)); } public static void assertFileExists(Path path) { diff --git a/test/jdk/tools/jpackage/share/AppContentTest.java b/test/jdk/tools/jpackage/share/AppContentTest.java index 2c6498a631b..5b5734df61d 100644 --- a/test/jdk/tools/jpackage/share/AppContentTest.java +++ b/test/jdk/tools/jpackage/share/AppContentTest.java @@ -21,24 +21,43 @@ * questions. */ -import static jdk.internal.util.OperatingSystem.LINUX; -import static jdk.internal.util.OperatingSystem.MACOS; +import static java.util.Map.entry; import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toMap; +import static jdk.internal.util.OperatingSystem.MACOS; +import static jdk.internal.util.OperatingSystem.WINDOWS; +import static jdk.jpackage.internal.util.function.ThrowingFunction.toFunction; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; -import jdk.jpackage.test.PackageTest; -import jdk.jpackage.test.TKit; -import jdk.jpackage.test.Annotations.Test; -import jdk.jpackage.test.Annotations.Parameter; -import java.util.Arrays; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.Predicate; +import java.util.function.Supplier; import java.util.stream.Stream; +import java.util.stream.StreamSupport; import jdk.jpackage.internal.util.FileUtils; -import jdk.jpackage.internal.util.function.ThrowingFunction; +import jdk.jpackage.internal.util.IdentityWrapper; +import jdk.jpackage.internal.util.function.ThrowingSupplier; +import jdk.jpackage.test.Annotations.Parameter; +import jdk.jpackage.test.Annotations.ParameterSupplier; +import jdk.jpackage.test.Annotations.Test; +import jdk.jpackage.test.ConfigurationTarget; import jdk.jpackage.test.JPackageCommand; import jdk.jpackage.test.JPackageStringBundle; +import jdk.jpackage.test.PackageTest; +import jdk.jpackage.test.PackageType; +import jdk.jpackage.test.TKit; /** @@ -57,68 +76,23 @@ import jdk.jpackage.test.JPackageStringBundle; */ public class AppContentTest { - private static final String TEST_JAVA = "apps/PrintEnv.java"; - private static final String TEST_DUKE = "apps/dukeplug.png"; - private static final String TEST_DUKE_LINK = "dukeplugLink.txt"; - private static final String TEST_DIR = "apps"; - private static final String TEST_BAD = "non-existant"; - - // On OSX `--app-content` paths will be copied into the "Contents" folder - // of the output app image. - // "codesign" imposes restrictions on the directory structure of "Contents" folder. - // In particular, random files should be placed in "Contents/Resources" folder - // otherwise "codesign" will fail to sign. - // Need to prepare arguments for `--app-content` accordingly. - private static final boolean copyInResources = TKit.isOSX(); - - private static final String RESOURCES_DIR = "Resources"; - private static final String LINKS_DIR = "Links"; + @Test + @ParameterSupplier + @ParameterSupplier(value="testSymlink", ifNotOS = WINDOWS) + public void test(TestSpec testSpec) throws Exception { + testSpec.test(new ConfigurationTarget(new PackageTest().configureHelloApp())); + } @Test - // include two files in two options - @Parameter({TEST_JAVA, TEST_DUKE}) - // try to include non-existant content - @Parameter({TEST_JAVA, TEST_BAD}) - // two files in one option and a dir tree in another option. - @Parameter({TEST_JAVA + "," + TEST_DUKE, TEST_DIR}) - // include one file and one link to the file - @Parameter(value = {TEST_JAVA, TEST_DUKE_LINK}, ifOS = {MACOS,LINUX}) - public void test(String... args) throws Exception { - final List testPathArgs = List.of(args); - final int expectedJPackageExitCode; - if (testPathArgs.contains(TEST_BAD)) { - expectedJPackageExitCode = 1; - } else { - expectedJPackageExitCode = 0; - } - - var appContentInitializer = new AppContentInitializer(testPathArgs); - - new PackageTest().configureHelloApp() - .addRunOnceInitializer(appContentInitializer::initAppContent) - .addInitializer(appContentInitializer::applyTo) - .addInstallVerifier(cmd -> { - for (String arg : testPathArgs) { - List paths = Arrays.asList(arg.split(",")); - for (String p : paths) { - Path name = Path.of(p).getFileName(); - if (isSymlinkPath(name)) { - TKit.assertSymbolicLinkExists(getAppContentRoot(cmd) - .resolve(LINKS_DIR).resolve(name)); - } else { - TKit.assertPathExists(getAppContentRoot(cmd) - .resolve(name), true); - } - } - } - }) - .setExpectedExitCode(expectedJPackageExitCode) - .run(); + @ParameterSupplier("test") + @ParameterSupplier(value="testSymlink", ifNotOS = WINDOWS) + public void testAppImage(TestSpec testSpec) throws Exception { + testSpec.test(new ConfigurationTarget(JPackageCommand.helloAppImage())); } @Test(ifOS = MACOS) - @Parameter({TEST_DIR, "warning.non.standard.contents.sub.dir"}) - @Parameter({TEST_DUKE, "warning.app.content.is.not.dir"}) + @Parameter({"apps", "warning.non.standard.contents.sub.dir"}) + @Parameter({"apps/dukeplug.png", "warning.app.content.is.not.dir"}) public void testWarnings(String testPath, String warningId) throws Exception { final var appContentValue = TKit.TEST_SRC_ROOT.resolve(testPath); final var expectedWarning = JPackageStringBundle.MAIN.cannedFormattedString( @@ -126,96 +100,588 @@ public class AppContentTest { JPackageCommand.helloAppImage() .addArguments("--app-content", appContentValue) + .setFakeRuntime() .validateOutput(expectedWarning) .executeIgnoreExitCode(); } + public static Collection test() { + return Stream.of( + build().add(TEST_JAVA).add(TEST_DUKE), + build().add(TEST_JAVA).add(TEST_BAD), + build().startGroup().add(TEST_JAVA).add(TEST_DUKE).endGroup().add(TEST_DIR), + // Same directory specified multiple times. + build().add(TEST_DIR).add(TEST_DIR), + // Same file specified multiple times. + build().add(TEST_JAVA).add(TEST_JAVA), + // Two files with the same name but different content. + build() + .add(createTextFileContent("welcome.txt", "Welcome")) + .add(createTextFileContent("welcome.txt", "Benvenuti")), + // Same name: one is a directory, another is a file. + build().add(createTextFileContent("a/b/c/d", "Foo")).add(createTextFileContent("a", "Bar")), + // Same name: one is a file, another is a directory. + build().add(createTextFileContent("a", "Bar")).add(createTextFileContent("a/b/c/d", "Foo")) + ).map(TestSpec.Builder::create).map(v -> { + return new Object[] {v}; + }).toList(); + } + + public static Collection testSymlink() { + return Stream.of( + build().add(TEST_JAVA) + .add(new SymlinkContentFactory("Links", "duke-link", "duke-target")) + .add(new SymlinkContentFactory("", "a/b/foo-link", "c/bar-target")) + ).map(TestSpec.Builder::create).map(v -> { + return new Object[] {v}; + }).toList(); + } + + public record TestSpec(List> contentFactories) { + public TestSpec { + contentFactories.stream().flatMap(List::stream).forEach(Objects::requireNonNull); + } + + @Override + public String toString() { + return contentFactories.stream().map(group -> { + return group.stream().map(ContentFactory::toString).collect(joining(",")); + }).collect(joining("; ")); + } + + void test(ConfigurationTarget target) { + final int expectedJPackageExitCode; + if (contentFactories.stream().flatMap(List::stream).anyMatch(TEST_BAD::equals)) { + expectedJPackageExitCode = 1; + } else { + expectedJPackageExitCode = 0; + } + + final List> allContent = new ArrayList<>(); + + target.addInitializer(JPackageCommand::setFakeRuntime) + .addRunOnceInitializer(_ -> { + contentFactories.stream().map(group -> { + return group.stream().map(ContentFactory::create).toList(); + }).forEach(allContent::add); + }).addInitializer(cmd -> { + allContent.stream().map(group -> { + return Stream.of("--app-content", group.stream() + .map(Content::paths) + .flatMap(List::stream) + .map(appContentArg -> { + if (COPY_IN_RESOURCES && Optional.ofNullable(appContentArg.getParent()) + .map(Path::getFileName) + .map(RESOURCES_DIR::equals) + .orElse(false)) { + return appContentArg.getParent(); + } else { + return appContentArg; + } + }) + .map(Path::toString) + .collect(joining(","))); + }).flatMap(x -> x).forEachOrdered(cmd::addArgument); + }); + + target.cmd().ifPresent(cmd -> { + if (expectedJPackageExitCode == 0) { + cmd.executeAndAssertImageCreated(); + } else { + cmd.execute(expectedJPackageExitCode); + } + }); + + target.addInstallVerifier(cmd -> { + var appContentRoot = getAppContentRoot(cmd); + + Set disabledVerifiers = new HashSet<>(); + + var verifiers = allContent.stream().flatMap(List::stream).flatMap(content -> { + return StreamSupport.stream(content.verifiers(appContentRoot).spliterator(), false).map(verifier -> { + return new PathVerifierWithOrigin(verifier, content); + }); + }).collect(toMap(PathVerifierWithOrigin::path, x -> x, (first, second) -> { + // The same file in the content directory is sourced from multiple origins. + // jpackage will handle this case such that the following origins overwrite preceding origins. + // Scratch all path verifiers affected by overrides. + first.getNestedVerifiers(appContentRoot, first.path()).forEach(disabledVerifiers::add); + return second; + }, TreeMap::new)).values().stream() + .map(PathVerifierWithOrigin::verifier) + .filter(Predicate.not(disabledVerifiers::contains)) + .filter(verifier -> { + if (!(verifier instanceof DirectoryVerifier dirVerifier)) { + return true; + } else { + try { + // Run the directory verifier if the directory is empty. + // Otherwise, it just pollutes the test log. + return isDirectoryEmpty(verifier.path()); + } catch (NoSuchFileException ex) { + // If an MSI contains an empty directory, it will be installed but not created when the MSI is unpacked. + // In the latter the control flow will reach this point. + if (dirVerifier.isEmpty() + && PackageType.WINDOWS.contains(cmd.packageType()) + && cmd.isPackageUnpacked(String.format( + "Expected empty directory [%s] is missing", verifier.path()))) { + return false; + } + throw new UncheckedIOException(ex); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + }) + .toList(); + + verifiers.forEach(PathVerifier::verify); + }); + + target.test().ifPresent(test -> { + test.setExpectedExitCode(expectedJPackageExitCode).run(); + }); + } + + static final class Builder { + TestSpec create() { + return new TestSpec(groups); + } + + final class GroupBuilder { + GroupBuilder add(ContentFactory cf) { + group.add(Objects.requireNonNull(cf)); + return this; + } + + Builder endGroup() { + if (!group.isEmpty()) { + groups.add(group); + } + return Builder.this; + } + + private final List group = new ArrayList<>(); + } + + Builder add(ContentFactory cf) { + return startGroup().add(cf).endGroup(); + } + + GroupBuilder startGroup() { + return new GroupBuilder(); + } + + private final List> groups = new ArrayList<>(); + } + + private record PathVerifierWithOrigin(PathVerifier verifier, Content origin) { + PathVerifierWithOrigin { + Objects.requireNonNull(verifier); + Objects.requireNonNull(origin); + } + + Path path() { + return verifier.path(); + } + + Stream getNestedVerifiers(Path appContentRoot, Path path) { + if (!path.startsWith(appContentRoot)) { + throw new IllegalArgumentException(); + } + + return StreamSupport.stream(origin.verifiers(appContentRoot).spliterator(), false).filter(v -> { + return v.path().getNameCount() > path.getNameCount() && v.path().startsWith(path); + }); + } + } + } + + private static TestSpec.Builder build() { + return new TestSpec.Builder(); + } + private static Path getAppContentRoot(JPackageCommand cmd) { - Path contentDir = cmd.appLayout().contentDirectory(); - if (copyInResources) { + final Path contentDir = cmd.appLayout().contentDirectory(); + if (COPY_IN_RESOURCES) { return contentDir.resolve(RESOURCES_DIR); } else { return contentDir; } } - private static boolean isSymlinkPath(Path v) { - return v.getFileName().toString().contains("Link"); + private static Path createAppContentRoot() { + if (COPY_IN_RESOURCES) { + return TKit.createTempDirectory("app-content").resolve(RESOURCES_DIR); + } else { + return TKit.createTempDirectory("app-content"); + } } - private static final class AppContentInitializer { - AppContentInitializer(List appContentArgs) { - appContentPathGroups = appContentArgs.stream().map(arg -> { - return Stream.of(arg.split(",")).map(Path::of).toList(); - }).toList(); + private static boolean isDirectoryEmpty(Path path) throws IOException { + if (Files.exists(path) && !Files.isDirectory(path)) { + throw new IllegalArgumentException(); } - void initAppContent() { - jpackageArgs = appContentPathGroups.stream() - .map(AppContentInitializer::initAppContentPaths) - .mapMulti((appContentPaths, consumer) -> { - consumer.accept("--app-content"); - consumer.accept( - appContentPaths.stream().map(Path::toString).collect( - joining(","))); - }).toList(); + try (var files = Files.list(path)) { + return files.findAny().isEmpty(); + } + } + + @FunctionalInterface + private interface ContentFactory { + Content create(); + } + + private interface Content { + List paths(); + Iterable verifiers(Path appContentRoot); + } + + private sealed interface PathVerifier permits + RegularFileVerifier, + DirectoryVerifier, + SymlinkTargetVerifier, + NoPathVerifier { + + Path path(); + void verify(); + } + + private record RegularFileVerifier(Path path, Path srcFile) implements PathVerifier { + RegularFileVerifier { + Objects.requireNonNull(path); + Objects.requireNonNull(srcFile); } - void applyTo(JPackageCommand cmd) { - cmd.addArguments(jpackageArgs); + @Override + public void verify() { + TKit.assertSameFileContent(srcFile, path); + } + } + + private record DirectoryVerifier(Path path, boolean isEmpty, IdentityWrapper origin) implements PathVerifier { + DirectoryVerifier { + Objects.requireNonNull(path); } - private static Path copyAppContentPath(Path appContentPath) throws IOException { - var appContentArg = TKit.createTempDirectory("app-content").resolve(RESOURCES_DIR); - var srcPath = TKit.TEST_SRC_ROOT.resolve(appContentPath); - var dstPath = appContentArg.resolve(srcPath.getFileName()); - FileUtils.copyRecursive(srcPath, dstPath); - return appContentArg; - } - - private static Path createAppContentLink(Path appContentPath) throws IOException { - var appContentArg = TKit.createTempDirectory("app-content"); - Path dstPath; - if (copyInResources) { - appContentArg = appContentArg.resolve(RESOURCES_DIR); - dstPath = appContentArg.resolve(LINKS_DIR) - .resolve(appContentPath.getFileName()); + @Override + public void verify() { + if (isEmpty) { + TKit.assertDirectoryEmpty(path); } else { - appContentArg = appContentArg.resolve(LINKS_DIR); - dstPath = appContentArg.resolve(appContentPath.getFileName()); + TKit.assertDirectoryExists(path); + } + } + } + + private record SymlinkTargetVerifier(Path path, Path targetPath) implements PathVerifier { + SymlinkTargetVerifier { + Objects.requireNonNull(path); + Objects.requireNonNull(targetPath); + } + + @Override + public void verify() { + TKit.assertSymbolicLinkTarget(path, targetPath); + } + } + + private record NoPathVerifier(Path path) implements PathVerifier { + NoPathVerifier { + Objects.requireNonNull(path); + } + + @Override + public void verify() { + TKit.assertPathExists(path, false); + } + } + + private record FileContent(Path path, int level) implements Content { + + FileContent { + Objects.requireNonNull(path); + if ((level < 0) || (path.getNameCount() <= level)) { + throw new IllegalArgumentException(); + } + } + + @Override + public List paths() { + return List.of(appContentOptionValue()); + } + + @Override + public Iterable verifiers(Path appContentRoot) { + List verifiers = new ArrayList<>(); + + var appContentPath = appContentRoot.resolve(pathInAppContentRoot()); + + if (Files.isDirectory(path)) { + try (var walk = Files.walk(path)) { + verifiers.addAll(walk.map(srcFile -> { + var dstFile = appContentPath.resolve(path.relativize(srcFile)); + if (Files.isRegularFile(srcFile)) { + return new RegularFileVerifier(dstFile, srcFile); + } else { + return new DirectoryVerifier(dstFile, + toFunction(AppContentTest::isDirectoryEmpty).apply(srcFile), + new IdentityWrapper<>(this)); + } + }).toList()); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } else if (Files.isRegularFile(path)) { + verifiers.add(new RegularFileVerifier(appContentPath, path)); + } else { + verifiers.add(new NoPathVerifier(appContentPath)); } - Files.createDirectories(dstPath.getParent()); - - // Create target file for a link - String tagetName = dstPath.getFileName().toString().replace("Link", ""); - Path targetPath = dstPath.getParent().resolve(tagetName); - Files.write(targetPath, "foo".getBytes()); - // Create link - Files.createSymbolicLink(dstPath, targetPath.getFileName()); - - return appContentArg; - } - - private static List initAppContentPaths(List appContentPaths) { - return appContentPaths.stream().map(appContentPath -> { - if (appContentPath.endsWith(TEST_BAD)) { - return appContentPath; - } else if (isSymlinkPath(appContentPath)) { - return ThrowingFunction.toFunction( - AppContentInitializer::createAppContentLink).apply( - appContentPath); - } else if (copyInResources) { - return ThrowingFunction.toFunction( - AppContentInitializer::copyAppContentPath).apply( - appContentPath); - } else { - return TKit.TEST_SRC_ROOT.resolve(appContentPath); + if (level > 0) { + var cur = appContentPath; + for (int i = 0; i != level; i++) { + cur = cur.getParent(); + verifiers.add(new DirectoryVerifier(cur, false, new IdentityWrapper<>(this))); } - }).toList(); + } + + return verifiers; } - private List jpackageArgs; - private final List> appContentPathGroups; + private Path appContentOptionValue() { + var cur = path; + for (int i = 0; i != level; i++) { + cur = cur.getParent(); + } + return cur; + } + + private Path pathInAppContentRoot() { + return StreamSupport.stream(path.spliterator(), false) + .skip(path.getNameCount() - level - 1) + .reduce(Path::resolve).orElseThrow(); + } } + + /** + * Non-existing content. + */ + private static final class NonExistantPath implements ContentFactory { + @Override + public Content create() { + var nonExistant = TKit.createTempFile("non-existant"); + try { + TKit.deleteIfExists(nonExistant); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + return new FileContent(nonExistant, 0); + } + + @Override + public String toString() { + return "*non-existant*"; + } + } + + /** + * Creates a content from a directory tree. + * + * @param path name of directory where to create a directory tree + */ + private static ContentFactory createDirTreeContent(Path path) { + if (path.isAbsolute()) { + throw new IllegalArgumentException(); + } + + return new FileContentFactory(() -> { + var basedir = TKit.createTempDirectory("content").resolve(path); + + for (var textFile : Map.ofEntries( + entry("woods/moose", "The moose"), + entry("woods/bear", "The bear"), + entry("woods/trees/jay", "The gray jay") + ).entrySet()) { + var src = basedir.resolve(textFile.getKey()); + Files.createDirectories(src.getParent()); + TKit.createTextFile(src, Stream.of(textFile.getValue())); + } + + for (var emptyDir : List.of("sky")) { + Files.createDirectories(basedir.resolve(emptyDir)); + } + + return basedir; + }, path); + } + + private static ContentFactory createDirTreeContent(String path) { + return createDirTreeContent(Path.of(path)); + } + + /** + * Creates a content from a text file. + * + * @param path the path where to copy the text file in app image's content directory + * @param lines the content of the source text file + */ + private static ContentFactory createTextFileContent(Path path, String ... lines) { + if (path.isAbsolute()) { + throw new IllegalArgumentException(); + } + + return new FileContentFactory(() -> { + var srcPath = TKit.createTempDirectory("content").resolve(path); + Files.createDirectories(srcPath.getParent()); + TKit.createTextFile(srcPath, Stream.of(lines)); + return srcPath; + }, path); + } + + private static ContentFactory createTextFileContent(String path, String ... lines) { + return createTextFileContent(Path.of(path), lines); + } + + /** + * Symlink content factory. + * + * @path basedir the directory where to write the content in app image's content + * directory + * @param symlink the path to the symlink relative to {@code basedir} path + * @param symlinked the path to the source file for the symlink + */ + private record SymlinkContentFactory(Path basedir, Path symlink, Path symlinked) implements ContentFactory { + SymlinkContentFactory { + for (final var path : List.of(basedir, symlink, symlinked)) { + if (path.isAbsolute()) { + throw new IllegalArgumentException(); + } + } + } + + SymlinkContentFactory(String basedir, String symlink, String symlinked) { + this(Path.of(basedir), Path.of(symlink), Path.of(symlinked)); + } + + @Override + public Content create() { + final var appContentRoot = createAppContentRoot(); + + final var symlinkPath = appContentRoot.resolve(symlinkPath()); + final var symlinkedPath = appContentRoot.resolve(symlinkedPath()); + try { + Files.createDirectories(symlinkPath.getParent()); + Files.createDirectories(symlinkedPath.getParent()); + // Create the target file for the link. + Files.writeString(symlinkedPath, symlinkedPath().toString()); + // Create the link. + Files.createSymbolicLink(symlinkPath, symlinkTarget()); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + + List contentPaths; + if (COPY_IN_RESOURCES) { + contentPaths = List.of(appContentRoot); + } else if (basedir.equals(Path.of(""))) { + contentPaths = Stream.of(symlinkPath(), symlinkedPath()).map(path -> { + return path.getName(0); + }).map(appContentRoot::resolve).toList(); + } else { + contentPaths = List.of(appContentRoot.resolve(basedir)); + } + + return new Content() { + @Override + public List paths() { + return contentPaths; + } + + @Override + public Iterable verifiers(Path appContentRoot) { + return List.of( + new RegularFileVerifier(appContentRoot.resolve(symlinkedPath()), symlinkedPath), + new SymlinkTargetVerifier(appContentRoot.resolve(symlinkPath()), symlinkTarget()) + ); + } + }; + } + + @Override + public String toString() { + return String.format("symlink:[%s]->[%s][%s]", symlinkPath(), symlinkedPath(), symlinkTarget()); + } + + private Path symlinkPath() { + return basedir.resolve(symlink); + } + + private Path symlinkedPath() { + return basedir.resolve(symlinked); + } + + private Path symlinkTarget() { + return Optional.ofNullable(symlinkPath().getParent()).map(dir -> { + return dir.relativize(symlinkedPath()); + }).orElseGet(this::symlinkedPath); + } + } + + private static final class FileContentFactory implements ContentFactory { + + FileContentFactory(ThrowingSupplier factory, Path pathInAppContentRoot) { + this.factory = ThrowingSupplier.toSupplier(factory); + this.pathInAppContentRoot = pathInAppContentRoot; + if (pathInAppContentRoot.isAbsolute()) { + throw new IllegalArgumentException(); + } + } + + @Override + public Content create() { + Path srcPath = factory.get(); + if (!srcPath.endsWith(pathInAppContentRoot)) { + throw new IllegalArgumentException(); + } + + Path dstPath; + if (!COPY_IN_RESOURCES) { + dstPath = srcPath; + } else { + var contentDir = createAppContentRoot(); + dstPath = contentDir.resolve(pathInAppContentRoot); + try { + FileUtils.copyRecursive(srcPath, dstPath); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + return new FileContent(dstPath, pathInAppContentRoot.getNameCount() - 1); + } + + @Override + public String toString() { + return pathInAppContentRoot.toString(); + } + + private final Supplier factory; + private final Path pathInAppContentRoot; + } + + private static final ContentFactory TEST_JAVA = createTextFileContent("apps/PrintEnv.java", "Not what someone would expect"); + private static final ContentFactory TEST_DUKE = createTextFileContent("duke.txt", "Hi Duke!"); + private static final ContentFactory TEST_DIR = createDirTreeContent("apps"); + private static final ContentFactory TEST_BAD = new NonExistantPath(); + + // On OSX `--app-content` paths will be copied into the "Contents" folder + // of the output app image. + // "codesign" imposes restrictions on the directory structure of "Contents" folder. + // In particular, random files should be placed in "Contents/Resources" folder + // otherwise "codesign" will fail to sign. + // Need to prepare arguments for `--app-content` accordingly. + private static final boolean COPY_IN_RESOURCES = TKit.isOSX(); + + private static final Path RESOURCES_DIR = Path.of("Resources"); }