| /* | |
| * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. | |
| * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. | |
| * | |
| * This code is free software; you can redistribute it and/or modify it | |
| * under the terms of the GNU General Public License version 2 only, as | |
| * published by the Free Software Foundation. Oracle designates this | |
| * particular file as subject to the "Classpath" exception as provided | |
| * by Oracle in the LICENSE file that accompanied this code. | |
| * | |
| * This code is distributed in the hope that it will be useful, but WITHOUT | |
| * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or | |
| * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License | |
| * version 2 for more details (a copy is included in the LICENSE file that | |
| * accompanied this code). | |
| * | |
| * You should have received a copy of the GNU General Public License version | |
| * 2 along with this work; if not, write to the Free Software Foundation, | |
| * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. | |
| * | |
| * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA | |
| * or visit www.oracle.com if you need additional information or have any | |
| * questions. | |
| */ | |
| package jdk.jpackage.internal; | |
| import static jdk.jpackage.internal.util.PathUtils.normalizedAbsolutePathString; | |
| import java.io.ByteArrayInputStream; | |
| import java.io.IOException; | |
| import java.io.UncheckedIOException; | |
| import java.net.URI; | |
| import java.net.URISyntaxException; | |
| import java.nio.file.Files; | |
| import java.nio.file.Path; | |
| import java.text.MessageFormat; | |
| import java.util.ArrayList; | |
| import java.util.HashMap; | |
| import java.util.List; | |
| import java.util.Map; | |
| import java.util.Objects; | |
| import java.util.Optional; | |
| import java.util.stream.Stream; | |
| import javax.xml.stream.XMLStreamException; | |
| import javax.xml.stream.XMLStreamWriter; | |
| import javax.xml.transform.TransformerException; | |
| import javax.xml.transform.TransformerFactory; | |
| import javax.xml.transform.dom.DOMSource; | |
| import javax.xml.transform.stream.StreamResult; | |
| import javax.xml.transform.stream.StreamSource; | |
| import jdk.internal.util.Architecture; | |
| import jdk.internal.util.OSVersion; | |
| import jdk.jpackage.internal.PackagingPipeline.PackageTaskID; | |
| import jdk.jpackage.internal.PackagingPipeline.StartupParameters; | |
| import jdk.jpackage.internal.PackagingPipeline.TaskID; | |
| import jdk.jpackage.internal.model.MacPkgPackage; | |
| import jdk.jpackage.internal.model.PackagerException; | |
| import jdk.jpackage.internal.resources.ResourceLocator; | |
| import jdk.jpackage.internal.util.XmlUtils; | |
| import org.xml.sax.SAXException; | |
| record MacPkgPackager(MacPkgPackage pkg, BuildEnv env, Optional<Services> services, Path outputDir) { | |
| enum PkgPackageTaskID implements TaskID { | |
| PREPARE_MAIN_SCRIPTS, | |
| CREATE_DISTRIBUTION_XML_FILE, | |
| CREATE_COMPONENT_PLIST_FILE, | |
| PREPARE_SERVICES | |
| } | |
| static Builder build() { | |
| return new Builder(); | |
| } | |
| static final class Builder extends PackagerBuilder<MacPkgPackage, Builder> { | |
| Path execute() throws PackagerException { | |
| Log.verbose(MessageFormat.format(I18N.getString("message.building-pkg"), | |
| pkg.app().name())); | |
| IOUtils.writableOutputDir(outputDir); | |
| return execute(MacPackagingPipeline.build(Optional.of(pkg))); | |
| } | |
| @Override | |
| protected void configurePackagingPipeline(PackagingPipeline.Builder pipelineBuilder, | |
| StartupParameters startupParameters) { | |
| final var packager = new MacPkgPackager(pkg, startupParameters.packagingEnv(), createServices(), outputDir); | |
| packager.applyToPipeline(pipelineBuilder); | |
| } | |
| private Optional<Services> createServices() { | |
| if (pkg.app().isService()) { | |
| return Optional.of(Services.create(pkg, env)); | |
| } else { | |
| return Optional.empty(); | |
| } | |
| } | |
| } | |
| record InternalPackage(Path srcRoot, String identifier, Path path, List<String> otherPkgbuildArgs) { | |
| InternalPackage { | |
| Objects.requireNonNull(srcRoot); | |
| Objects.requireNonNull(identifier); | |
| Objects.requireNonNull(path); | |
| Objects.requireNonNull(otherPkgbuildArgs); | |
| } | |
| private List<String> allPkgbuildArgs() { | |
| final List<String> args = new ArrayList<>(); | |
| args.add("--root"); | |
| args.add(normalizedAbsolutePathString(srcRoot)); | |
| args.addAll(otherPkgbuildArgs); | |
| args.add("--identifier"); | |
| args.add(identifier); | |
| args.add(normalizedAbsolutePathString(path)); | |
| return args; | |
| } | |
| void build() { | |
| final List<String> cmdline = new ArrayList<>(); | |
| cmdline.add("/usr/bin/pkgbuild"); | |
| cmdline.addAll(allPkgbuildArgs()); | |
| try { | |
| Files.createDirectories(path.getParent()); | |
| IOUtils.exec(new ProcessBuilder(cmdline), false, null, true, Executor.INFINITE_TIMEOUT); | |
| } catch (IOException ex) { | |
| throw new UncheckedIOException(ex); | |
| } | |
| } | |
| } | |
| record Services(InternalPackage servicesPkg, Path servicesScriptsDir, InternalPackage supportPkg) { | |
| Services { | |
| Objects.requireNonNull(servicesPkg); | |
| Objects.requireNonNull(servicesScriptsDir); | |
| Objects.requireNonNull(supportPkg); | |
| } | |
| static Services create(MacPkgPackage pkg, BuildEnv env) { | |
| final var servicesRoot = env.buildRoot().resolve("services"); | |
| final var supportRoot = env.buildRoot().resolve("support"); | |
| final var servicesScriptsDir = servicesRoot.resolve("scripts"); | |
| final var servicesPkg = InternalPackageType.SERVICES.createInternalPackage( | |
| servicesRoot.resolve("src"), pkg, env, List.of( | |
| "--install-location", "/", | |
| "--scripts", normalizedAbsolutePathString(servicesScriptsDir))); | |
| final var supportPkg = InternalPackageType.SUPPORT.createInternalPackage( | |
| supportRoot.resolve("src"), pkg, env, List.of( | |
| "--install-location", "/Library/Application Support")); | |
| return new Services(servicesPkg, servicesScriptsDir, supportPkg); | |
| } | |
| Stream<InternalPackage> asStream() { | |
| return Stream.of(servicesPkg, supportPkg); | |
| } | |
| void prepareForPkgbuild(MacPkgPackage pkg, BuildEnv env) throws IOException { | |
| prepareSupportForPkgbuild(pkg, env, prepareServicesForPkgbuild(pkg, env)); | |
| } | |
| private Map<String, String> prepareServicesForPkgbuild(MacPkgPackage pkg, BuildEnv env) throws IOException { | |
| final var services = new MacLaunchersAsServices(BuildEnv.withAppImageDir(env, servicesPkg.srcRoot()), pkg); | |
| final var data = services.create(); | |
| data.put("SERVICES_PACKAGE_ID", servicesPkg.identifier()); | |
| MacPkgInstallerScripts.createServicesScripts() | |
| .setResourceDir(env.resourceDir().orElse(null)) | |
| .setSubstitutionData(data) | |
| .saveInFolder(servicesScriptsDir); | |
| return data; | |
| } | |
| private void prepareSupportForPkgbuild(MacPkgPackage pkg, BuildEnv env, | |
| Map<String, String> servicesSubstitutionData) throws IOException { | |
| final var enqouter = Enquoter.forShellLiterals().setEnquotePredicate(str -> true); | |
| final var mainInstallDir = Path.of("/").resolve(pkg.relativeInstallDir()); | |
| final var supportInstallDir = Path.of("/Library/Application Support").resolve(pkg.packageName()); | |
| Map<String, String> data = new HashMap<>(servicesSubstitutionData); | |
| data.put("APP_INSTALLATION_FOLDER", enqouter.applyTo(mainInstallDir.toString())); | |
| data.put("SUPPORT_INSTALLATION_FOLDER", enqouter.applyTo(supportInstallDir.toString())); | |
| new ShellScriptResource("uninstall.command") | |
| .setResource(env.createResource("uninstall.command.template") | |
| .setCategory(I18N.getString("resource.pkg-uninstall-script")) | |
| .setPublicName("uninstaller") | |
| .setSubstitutionData(data)) | |
| .saveInFolder(supportPkg.srcRoot().resolve(pkg.app().name())); | |
| } | |
| } | |
| private enum InternalPackageType implements TaskID { | |
| MAIN, | |
| SERVICES("services"), | |
| SUPPORT("support"); | |
| InternalPackage createInternalPackage(Path srcRoot, MacPkgPackage pkg, BuildEnv env, List<String> otherPkgbuildArgs) { | |
| return new InternalPackage(srcRoot, identifier(pkg), env.buildRoot().resolve("packages").resolve(filename(pkg)), otherPkgbuildArgs); | |
| } | |
| private InternalPackageType(String nameSuffix) { | |
| this.nameSuffix = Optional.of(nameSuffix); | |
| } | |
| private InternalPackageType() { | |
| this.nameSuffix = Optional.empty(); | |
| } | |
| private String identifier(MacPkgPackage pkg) { | |
| final var baseIdentifier = pkg.app().bundleIdentifier(); | |
| return nameSuffix.map(v -> baseIdentifier + "." + v).orElse(baseIdentifier); | |
| } | |
| private String filename(MacPkgPackage pkg) { | |
| return String.format("%s-%s.pkg", pkg.app().name(), nameSuffix.orElse("app")); | |
| } | |
| private final Optional<String> nameSuffix; | |
| } | |
| private void applyToPipeline(PackagingPipeline.Builder pipelineBuilder) { | |
| pipelineBuilder | |
| .excludeDirFromCopying(outputDir) | |
| .task(PkgPackageTaskID.PREPARE_MAIN_SCRIPTS) | |
| .action(this::prepareMainScripts) | |
| .addDependent(PackageTaskID.RUN_POST_IMAGE_USER_SCRIPT) | |
| .add() | |
| .task(PkgPackageTaskID.CREATE_DISTRIBUTION_XML_FILE) | |
| .action(this::prepareDistributionXMLFile) | |
| .addDependent(PackageTaskID.RUN_POST_IMAGE_USER_SCRIPT) | |
| .add() | |
| .task(PkgPackageTaskID.CREATE_COMPONENT_PLIST_FILE) | |
| .action(this::createComponentPlistFile) | |
| .addDependent(PackageTaskID.RUN_POST_IMAGE_USER_SCRIPT) | |
| .add() | |
| .task(PackageTaskID.CREATE_CONFIG_FILES) | |
| .action(this::prepareConfigFiles) | |
| .add() | |
| .task(PackageTaskID.CREATE_PACKAGE_FILE) | |
| .action(this::productbuild) | |
| .addDependencies(InternalPackageType.values()) | |
| .addDependencies(PkgPackageTaskID.CREATE_DISTRIBUTION_XML_FILE, PkgPackageTaskID.CREATE_COMPONENT_PLIST_FILE) | |
| .add() | |
| .task(PkgPackageTaskID.PREPARE_SERVICES) | |
| .action(this::prepareServicesForBkgbuild) | |
| .addDependent(PackageTaskID.RUN_POST_IMAGE_USER_SCRIPT) | |
| .add() | |
| .task(InternalPackageType.SERVICES) | |
| .action(this::buildServicesPKG) | |
| .addDependencies(PkgPackageTaskID.PREPARE_SERVICES, PackageTaskID.RUN_POST_IMAGE_USER_SCRIPT) | |
| .add() | |
| .task(InternalPackageType.SUPPORT) | |
| .action(this::buildSupportPKG) | |
| .addDependencies(PkgPackageTaskID.PREPARE_SERVICES, PackageTaskID.RUN_POST_IMAGE_USER_SCRIPT) | |
| .add() | |
| .task(InternalPackageType.MAIN) | |
| .action(this::buildMainPKG) | |
| .addDependencies(PkgPackageTaskID.PREPARE_MAIN_SCRIPTS, PackageTaskID.RUN_POST_IMAGE_USER_SCRIPT) | |
| .add(); | |
| final List<TaskID> disabledTasks = new ArrayList<>(); | |
| if (services.isEmpty()) { | |
| disabledTasks.addAll(List.of(PkgPackageTaskID.PREPARE_SERVICES, InternalPackageType.SERVICES, InternalPackageType.SUPPORT)); | |
| } | |
| if (scriptsRoot().isEmpty()) { | |
| disabledTasks.add(PkgPackageTaskID.PREPARE_MAIN_SCRIPTS); | |
| } | |
| for (final var taskID : disabledTasks) { | |
| pipelineBuilder.task(taskID).noaction().add(); | |
| } | |
| } | |
| List<InternalPackage> internalPackages() { | |
| return Stream.concat(Stream.of(mainPkg()), | |
| services.map(Services::asStream).orElseGet(Stream::of)).toList(); | |
| } | |
| InternalPackage mainPkg() { | |
| final List<String> args = new ArrayList<>(); | |
| args.add("--install-location"); | |
| args.add(normalizedAbsolutePathString(installLocation())); | |
| args.add("--component-plist"); | |
| args.add(normalizedAbsolutePathString(componentPlistFile())); | |
| scriptsRoot().ifPresent(scriptsRoot -> { | |
| args.add("--scripts"); | |
| args.add(normalizedAbsolutePathString(scriptsRoot)); | |
| }); | |
| return InternalPackageType.MAIN.createInternalPackage(env.appImageDir(), pkg, env, args); | |
| } | |
| Optional<Path> scriptsRoot() { | |
| if (pkg.app().appStore() || pkg.isRuntimeInstaller()) { | |
| return Optional.empty(); | |
| } else { | |
| return Optional.of(env.configDir().resolve("scripts")); | |
| } | |
| } | |
| Path componentPlistFile() { | |
| return env.configDir().resolve("cpl.plist"); | |
| } | |
| Path installLocation() { | |
| return Path.of("/").resolve(pkg.relativeInstallDir()).getParent(); | |
| } | |
| Path distributionXmlFile() { | |
| return env.configDir().resolve("distribution.dist"); | |
| } | |
| Path appStoreProductFile() { | |
| return env.configDir().resolve("product-def.plist"); | |
| } | |
| Path backgroundImage() { | |
| return env.configDir().resolve(pkg.app().name() + "-background.png"); | |
| } | |
| Path backgroundImageDarkAqua() { | |
| return env.configDir().resolve(pkg.app().name() + "-background-darkAqua.png"); | |
| } | |
| private void addInternalPackageToInstallerGuiScript(InternalPackage internalPkg, | |
| XMLStreamWriter xml) throws IOException, XMLStreamException { | |
| xml.writeStartElement("pkg-ref"); | |
| xml.writeAttribute("id", internalPkg.identifier()); | |
| xml.writeEndElement(); // </pkg-ref> | |
| xml.writeStartElement("choice"); | |
| xml.writeAttribute("id", internalPkg.identifier()); | |
| xml.writeAttribute("visible", "false"); | |
| xml.writeStartElement("pkg-ref"); | |
| xml.writeAttribute("id", internalPkg.identifier()); | |
| xml.writeEndElement(); // </pkg-ref> | |
| xml.writeEndElement(); // </choice> | |
| xml.writeStartElement("pkg-ref"); | |
| xml.writeAttribute("id", internalPkg.identifier()); | |
| xml.writeAttribute("version", pkg.version()); | |
| xml.writeAttribute("onConclusion", "none"); | |
| try { | |
| xml.writeCharacters(new URI(null, null, internalPkg.path().getFileName().toString(), null).toASCIIString()); | |
| } catch (URISyntaxException ex) { | |
| throw new RuntimeException(ex); | |
| } | |
| xml.writeEndElement(); // </pkg-ref> | |
| } | |
| private void prepareMainScripts() throws IOException { | |
| Log.verbose(I18N.getString("message.preparing-scripts")); | |
| final var scriptsRoot = scriptsRoot().orElseThrow(); | |
| Files.createDirectories(scriptsRoot); | |
| final Map<String, String> data = new HashMap<>(); | |
| final var appLocation = pkg.asInstalledPackageApplicationLayout().orElseThrow().appDirectory(); | |
| data.put("INSTALL_LOCATION", Path.of("/").resolve(pkg.relativeInstallDir()).toString()); | |
| data.put("APP_LOCATION", appLocation.toString()); | |
| MacPkgInstallerScripts.createAppScripts() | |
| .setResourceDir(env.resourceDir().orElse(null)) | |
| .setSubstitutionData(data) | |
| .saveInFolder(scriptsRoot); | |
| } | |
| private void prepareDistributionXMLFile() throws IOException { | |
| final var f = distributionXmlFile(); | |
| Log.verbose(MessageFormat.format(I18N.getString( | |
| "message.preparing-distribution-dist"), f.toAbsolutePath().toString())); | |
| XmlUtils.createXml(f, xml -> { | |
| xml.writeStartElement("installer-gui-script"); | |
| xml.writeAttribute("minSpecVersion", "1"); | |
| xml.writeStartElement("title"); | |
| xml.writeCharacters(pkg.app().name()); | |
| xml.writeEndElement(); | |
| xml.writeStartElement("background"); | |
| xml.writeAttribute("file", backgroundImage().getFileName().toString()); | |
| xml.writeAttribute("mime-type", "image/png"); | |
| xml.writeAttribute("alignment", "bottomleft"); | |
| xml.writeAttribute("scaling", "none"); | |
| xml.writeEndElement(); | |
| xml.writeStartElement("background-darkAqua"); | |
| xml.writeAttribute("file", backgroundImageDarkAqua().getFileName().toString()); | |
| xml.writeAttribute("mime-type", "image/png"); | |
| xml.writeAttribute("alignment", "bottomleft"); | |
| xml.writeAttribute("scaling", "none"); | |
| xml.writeEndElement(); | |
| final var licFile = pkg.licenseFile(); | |
| if (licFile.isPresent()) { | |
| xml.writeStartElement("license"); | |
| xml.writeAttribute("file", licFile.orElseThrow().toAbsolutePath().toString()); | |
| xml.writeAttribute("mime-type", "text/rtf"); | |
| xml.writeEndElement(); | |
| } | |
| /* | |
| * Note that the content of the distribution file | |
| * below is generated by productbuild --synthesize | |
| */ | |
| for (final var p : internalPackages()) { | |
| addInternalPackageToInstallerGuiScript(p, xml); | |
| } | |
| xml.writeStartElement("options"); | |
| xml.writeAttribute("customize", "never"); | |
| xml.writeAttribute("require-scripts", "false"); | |
| xml.writeAttribute("hostArchitectures", Architecture.isAARCH64() ? "arm64" : "x86_64"); | |
| xml.writeEndElement(); // </options> | |
| xml.writeStartElement("choices-outline"); | |
| xml.writeStartElement("line"); | |
| xml.writeAttribute("choice", "default"); | |
| for (final var p : internalPackages()) { | |
| xml.writeStartElement("line"); | |
| xml.writeAttribute("choice", p.identifier()); | |
| xml.writeEndElement(); // </line> | |
| } | |
| xml.writeEndElement(); // </line> | |
| xml.writeEndElement(); // </choices-outline> | |
| xml.writeStartElement("choice"); | |
| xml.writeAttribute("id", "default"); | |
| xml.writeEndElement(); // </choice> | |
| xml.writeEndElement(); // </installer-gui-script> | |
| }); | |
| } | |
| private void prepareConfigFiles() throws IOException { | |
| env.createResource(DEFAULT_BACKGROUND_IMAGE) | |
| .setCategory(I18N.getString("resource.pkg-background-image")) | |
| .saveToFile(backgroundImage()); | |
| env.createResource(DEFAULT_BACKGROUND_IMAGE) | |
| .setCategory(I18N.getString("resource.pkg-background-image")) | |
| .saveToFile(backgroundImageDarkAqua()); | |
| if (pkg.app().appStore()) { | |
| env.createResource(DEFAULT_PDF) | |
| .setCategory(I18N.getString("resource.pkg-pdf")) | |
| .saveToFile(appStoreProductFile()); | |
| } | |
| } | |
| private void patchCPLFile(Path cpl) throws IOException { | |
| try (final var xsltResource = ResourceLocator.class.getResourceAsStream("adjust-component-plist.xsl")) { | |
| final var srcXml = new DOMSource(XmlUtils.initDocumentBuilder().parse( | |
| new ByteArrayInputStream(Files.readAllBytes(cpl)))); | |
| final var dstXml = new StreamResult(cpl.toFile()); | |
| final var xslt = TransformerFactory.newInstance().newTransformer(new StreamSource(xsltResource)); | |
| xslt.transform(srcXml, dstXml); | |
| } catch (TransformerException|SAXException ex) { | |
| throw new RuntimeException(ex); | |
| } | |
| } | |
| private void prepareServicesForBkgbuild() throws IOException { | |
| services.orElseThrow().prepareForPkgbuild(pkg, env); | |
| } | |
| private void buildServicesPKG() { | |
| services.orElseThrow().servicesPkg().build(); | |
| } | |
| private void buildSupportPKG() { | |
| services.orElseThrow().supportPkg().build(); | |
| } | |
| private void buildMainPKG() throws IOException { | |
| mainPkg().build(); | |
| } | |
| private void createComponentPlistFile() throws IOException { | |
| final var cpl = componentPlistFile(); | |
| Files.createDirectories(cpl.getParent()); | |
| final var pb = new ProcessBuilder("/usr/bin/pkgbuild", | |
| "--root", | |
| normalizedAbsolutePathString(env.appImageDir()), | |
| "--install-location", | |
| normalizedAbsolutePathString(installLocation()), | |
| "--analyze", | |
| normalizedAbsolutePathString(cpl)); | |
| IOUtils.exec(pb, false, null, true, Executor.INFINITE_TIMEOUT); | |
| patchCPLFile(cpl); | |
| } | |
| private void productbuild() throws IOException { | |
| final var finalPkg = outputDir.resolve(pkg.packageFileNameWithSuffix()); | |
| Files.createDirectories(finalPkg.getParent()); | |
| List<String> commandLine = new ArrayList<>(); | |
| commandLine.add("/usr/bin/productbuild"); | |
| commandLine.add("--resources"); | |
| commandLine.add(normalizedAbsolutePathString(env.configDir())); | |
| // maybe sign | |
| if (pkg.sign()) { | |
| if (OSVersion.current().compareTo(new OSVersion(10, 12)) >= 0) { | |
| // we need this for OS X 10.12+ | |
| Log.verbose(I18N.getString("message.signing.pkg")); | |
| } | |
| final var pkgSigningConfig = pkg.signingConfig().orElseThrow(); | |
| commandLine.add("--sign"); | |
| commandLine.add(pkgSigningConfig.identity().id()); | |
| pkgSigningConfig.keychain().map(Keychain::new).ifPresent(keychain -> { | |
| commandLine.add("--keychain"); | |
| commandLine.add(keychain.asCliArg()); | |
| }); | |
| } | |
| if (pkg.app().appStore()) { | |
| commandLine.add("--product"); | |
| commandLine.add(normalizedAbsolutePathString(appStoreProductFile())); | |
| commandLine.add("--component"); | |
| Path p = env.appImageDir().resolve(pkg.app().appImageDirName()); | |
| commandLine.add(p.toAbsolutePath().toString()); | |
| commandLine.add(normalizedAbsolutePathString(installLocation())); | |
| } else { | |
| commandLine.add("--distribution"); | |
| commandLine.add(normalizedAbsolutePathString(distributionXmlFile())); | |
| commandLine.add("--package-path"); | |
| // Assume all internal .pkg files reside in the same directory. | |
| commandLine.add(normalizedAbsolutePathString(mainPkg().path().getParent())); | |
| } | |
| commandLine.add(normalizedAbsolutePathString(finalPkg)); | |
| final var pb = new ProcessBuilder(commandLine); | |
| IOUtils.exec(pb, false, null, true, Executor.INFINITE_TIMEOUT); | |
| } | |
| private static final String DEFAULT_BACKGROUND_IMAGE = "background_pkg.png"; | |
| private static final String DEFAULT_PDF = "product-def.plist"; | |
| } |