diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index fb985ceac9e..460ffd9016c 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -54,3 +54,49 @@ jobs: name: HMCL-${{ env.SHORT_SHA }}-deb path: HMCL/build/libs/HMCL-*.deb archive: false + + rpm: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - name: Build RPM in RHEL-compatible container + run: | + HOST_UID="$(id -u)" + HOST_GID="$(id -g)" + docker run --rm \ + -e GRADLE_USER_HOME=/tmp/hmcl-gradle \ + -e MICROSOFT_AUTH_ID="${MICROSOFT_AUTH_ID}" \ + -e CURSEFORGE_API_KEY="${CURSEFORGE_API_KEY}" \ + -e HOST_UID="$HOST_UID" \ + -e HOST_GID="$HOST_GID" \ + -v "$PWD":/workspace \ + -w /workspace \ + rockylinux:9 \ + bash -lc ' + dnf install -y java-17-openjdk-devel rpm-build && + ./gradlew :HMCL:makeRpm --no-daemon --stacktrace + status=$? + chown -R "$HOST_UID:$HOST_GID" \ + .gradle \ + HMCL/build \ + HMCLCore/build \ + HMCLBoot/build \ + HMCLMultiMCBootstrap/build \ + HMCLTransformerDiscoveryService/build \ + buildSrc/build \ + 2>/dev/null || true + exit "$status" + ' + env: + MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }} + CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }} + - name: Get short SHA + run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV + - name: Upload RPM + uses: actions/upload-artifact@v7 + with: + name: HMCL-${{ env.SHORT_SHA }}-rpm + path: HMCL/build/libs/HMCL-*.rpm + archive: false diff --git a/HMCL/build.gradle.kts b/HMCL/build.gradle.kts index 623496c1793..ab38e5290ea 100644 --- a/HMCL/build.gradle.kts +++ b/HMCL/build.gradle.kts @@ -7,6 +7,7 @@ import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate import org.jackhuang.hmcl.gradle.mod.ParseModDataTask import org.jackhuang.hmcl.gradle.pack.CreateDeb +import org.jackhuang.hmcl.gradle.pack.CreateRpm import org.jackhuang.hmcl.gradle.pack.ReleaseType import org.jackhuang.hmcl.gradle.utils.PropertiesUtils import java.net.URI @@ -308,6 +309,28 @@ val makeDeb by tasks.registering(CreateDeb::class) { } } +val makeRpm by tasks.registering(CreateRpm::class) { + dependsOn(makeExecutables) + + val rpmFile = layout.file(provider { artifactFile("rpm") }) + + val rpmChannel = when (versionType) { + "stable" -> ReleaseType.STABLE + "dev" -> ReleaseType.DEVELOPMENT + else -> ReleaseType.NIGHTLY + } + + version.set(project.version.toString()) + releaseType.set(rpmChannel) + appShFile.set(layout.file(provider { artifactFile("sh") })) + iconFile.set(layout.projectDirectory.file("image/hmcl.png")) + outputFile.set(rpmFile) + + doLast { + createChecksum(rpmFile.get().asFile) + } +} + tasks.build { dependsOn(makeExecutables) dependsOn(makeDeb) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/PackageManagerIntegration.java b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/PackageManagerIntegration.java new file mode 100644 index 00000000000..bb024c84583 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/PackageManagerIntegration.java @@ -0,0 +1,54 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.upgrade; + +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +/// Detects launchers installed and updated by an external system package manager. +/// +/// Package-managed installations should not overwrite their own executable +/// files because that would make the system package database disagree with the +/// files installed on disk. The Debian and RPM wrappers set this flag before +/// launching HMCL. +@NotNullByDefault +public final class PackageManagerIntegration { + /// System property used by package wrappers to mark a package-managed launch. + public static final String PACKAGE_MANAGED_PROPERTY = "hmcl.package_managed"; + + /// Environment variable used by package wrappers to mark a package-managed launch. + public static final String PACKAGE_MANAGED_ENV = "HMCL_PACKAGE_MANAGED"; + + /// Utility class constructor. + private PackageManagerIntegration() { + } + + /// Returns whether the current process was launched from a package-managed installation. + public static boolean isPackageManaged() { + return isEnabled(System.getProperty(PACKAGE_MANAGED_PROPERTY)) + || isEnabled(System.getenv(PACKAGE_MANAGED_ENV)); + } + + /// Parses a package-managed marker value. + private static boolean isEnabled(@Nullable String value) { + return value != null + && !value.isBlank() + && !"0".equals(value) + && !"false".equalsIgnoreCase(value); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateChecker.java b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateChecker.java index 3db51606969..b6954b90749 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateChecker.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateChecker.java @@ -56,6 +56,9 @@ private UpdateChecker() { private static final ReadOnlyBooleanWrapper checkingUpdate = new ReadOnlyBooleanWrapper(false); public static void init() { + if (PackageManagerIntegration.isPackageManaged()) + return; + requestCheckUpdate(UpdateChannel.getChannel(), settings().acceptPreviewUpdateProperty().get()); } @@ -102,6 +105,9 @@ private static boolean isDevelopmentVersion(String version) { } public static void requestCheckUpdate(UpdateChannel channel, boolean preview) { + if (PackageManagerIntegration.isPackageManaged()) + return; + Platform.runLater(() -> { if (isCheckingUpdate()) return; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java index ec28b67daff..41ca57fd102 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java @@ -100,6 +100,10 @@ public static boolean processArguments(String[] args) { public static void updateFrom(RemoteVersion version) { checkFxUserThread(); + if (PackageManagerIntegration.isPackageManaged()) { + return; + } + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && !OperatingSystem.isWindows7OrLater()) { Controllers.dialog(i18n("fatal.apply_update_need_win7", Metadata.PUBLISH_URL), i18n("message.error"), MessageType.ERROR); return; diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 568bad20c9b..17681989141 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { implementation(libs.jna) implementation(libs.kala.compress.tar) implementation(libs.kala.compress.ar) + compileOnly(libs.jetbrains.annotations) } java { diff --git a/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/pack/CreateDeb.java b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/pack/CreateDeb.java index c2c2e151ba0..28edc7b3403 100644 --- a/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/pack/CreateDeb.java +++ b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/pack/CreateDeb.java @@ -92,24 +92,25 @@ private ReleaseType getCurrentType() { return getReleaseType().get(); } - private String getCurrentTypeName() { - return getCurrentType().getName(); - } - private String getLauncherPath() { - return "/usr/bin/hmcl-" + getCurrentTypeName(); + return getPackageFiles().launcherPath(); } private String getTargetPath() { - return "/usr/share/java/hmcl/" + getAppShFile().getAsFile().get().getName(); + return getPackageFiles().targetPath(); } private String getDesktopFilePath() { - return "/usr/share/applications/hmcl-%s.desktop".formatted(getCurrentTypeName()); + return getPackageFiles().desktopFilePath(); } private String getIconTargetPath() { - return "/usr/share/icons/hicolor/256x256/apps/hmcl-%s.png".formatted(getCurrentTypeName()); + return getPackageFiles().iconTargetPath(); + } + + /// Creates the shared Linux package path helper for this task. + private LinuxPackageFiles getPackageFiles() { + return new LinuxPackageFiles(getCurrentType(), getAppShFile().getAsFile().get().getName(), "deb"); } /// Ensures parent directories exist in the tar stream before child entries are written. @@ -168,8 +169,9 @@ public void run() throws IOException { if (iconBytes.length == 0) throw new IOException("Empty icon file: " + iconFile); - byte[] launcherScriptBytes = getLauncherScript().getBytes(StandardCharsets.UTF_8); - byte[] desktopInfoBytes = getDesktopInfo().getBytes(StandardCharsets.UTF_8); + LinuxPackageFiles files = getPackageFiles(); + byte[] launcherScriptBytes = files.launcherScript().getBytes(StandardCharsets.UTF_8); + byte[] desktopInfoBytes = files.desktopInfo().getBytes(StandardCharsets.UTF_8); LOGGER.lifecycle("Creating control.tar.gz"); var controlData = new ByteArrayOutputStream(); @@ -234,8 +236,6 @@ private String getControl(long appSize, long launcherScriptSize, long desktopInf """.formatted(getCurrentType().getPackageName(), getVersion().get(), Math.max(installedSize, 1)) + "\n"; } - private static final String COMMON_LAUNCHER_PATH = "/usr/bin/hmcl"; - /// Registers the channel command into the shared `hmcl` alternatives group. private String getPostinst() { return """ @@ -245,7 +245,7 @@ private String getPostinst() { if [ "$1" = configure ]; then update-alternatives --install %s hmcl %s %d fi - """.formatted(COMMON_LAUNCHER_PATH, getLauncherPath(), getCurrentType().getAlternativesPriority()); + """.formatted(LinuxPackageFiles.COMMON_LAUNCHER_PATH, getLauncherPath(), getCurrentType().getAlternativesPriority()); } /// Removes the channel command from the shared `hmcl` alternatives group. @@ -260,41 +260,4 @@ private String getPrerm() { """.formatted(getLauncherPath()); } - /// Creates a tiny wrapper that launches the bundled shell script from the user's home directory. - private String getLauncherScript() { - return """ - #!/usr/bin/env bash - cd "$HOME" - if [ -z "${HMCL_USER_HOME:-}" ]; then - if [ -z "${XDG_DATA_HOME:-}" ]; then - export HMCL_USER_HOME="$HOME/.local/share/hmcl" - else - export HMCL_USER_HOME="$XDG_DATA_HOME/hmcl" - fi - fi - if [ -z "${HMCL_LOCAL_HOME:-}" ]; then - export HMCL_LOCAL_HOME="$HMCL_USER_HOME/local-%s" - fi - if [ -z "${HMCL_DEPENDENCIES_DIR:-}" ]; then - export HMCL_DEPENDENCIES_DIR="$HMCL_USER_HOME/dependencies" - fi - exec %s "$@" - """.formatted(getCurrentTypeName(), getTargetPath()); - } - - /// Generates the desktop entry that points to the channel-specific launcher command. - private String getDesktopInfo() { - return """ - [Desktop Entry] - Type=Application - Name=%s - Comment=Hello Minecraft! Launcher - Exec=%s - Icon=%s - Terminal=false - StartupNotify=false - Categories=Game; - Keywords=mc;minecraft; - """.formatted(getCurrentType().getDisplayName(), getLauncherPath(), getIconTargetPath()); - } } diff --git a/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/pack/CreateRpm.java b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/pack/CreateRpm.java new file mode 100644 index 00000000000..03d61531c30 --- /dev/null +++ b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/pack/CreateRpm.java @@ -0,0 +1,287 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.gradle.pack; + +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.logging.Logger; +import org.gradle.api.logging.Logging; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Comparator; +import java.util.Locale; +import java.util.stream.Stream; + +/// Creates a RHEL-compatible RPM package for the current HMCL channel. +/// +/// The task stages the package payload and writes a small RPM spec, then uses +/// the platform `rpmbuild` tool. Keeping the RPM assembly in `rpmbuild` avoids +/// hand-writing RPM headers and makes the generated package easier for RPM +/// distribution users to inspect. +@NotNullByDefault +public abstract class CreateRpm extends DefaultTask { + /// Task logger used for RPM build progress messages. + public static final Logger LOGGER = Logging.getLogger(CreateRpm.class); + + /// File mode used for executable files in the staged payload. + private static final String EXECUTABLE_PERMISSIONS = "rwxr-xr-x"; + + /// File mode used for regular files in the staged payload. + private static final String REGULAR_FILE_PERMISSIONS = "rw-r--r--"; + + /// RPM version written into the spec before RPM-specific sanitization. + @Input + public abstract Property getVersion(); + + /// Release type metadata that controls package name, launcher name, and alias priority. + @Input + public abstract Property getReleaseType(); + + /// Executable `.sh` artifact produced by `makeExecutables`. + @InputFile + public abstract RegularFileProperty getAppShFile(); + + /// Desktop icon installed into the hicolor icon theme. + @InputFile + public abstract RegularFileProperty getIconFile(); + + /// Final `.rpm` artifact copied out of the rpmbuild directory. + @OutputFile + public abstract RegularFileProperty getOutputFile(); + + /// Builds the staged payload, invokes `rpmbuild`, and copies the resulting RPM to `outputFile`. + @TaskAction + public void run() throws IOException, InterruptedException { + assertRpmBuildAvailable(); + + Path appShFile = getAppShFile().getAsFile().get().toPath(); + if (!Files.isRegularFile(appShFile)) + throw new IOException("Invalid app script file: " + appShFile); + + Path iconFile = getIconFile().getAsFile().get().toPath(); + if (!Files.isRegularFile(iconFile)) + throw new IOException("Invalid icon file: " + iconFile); + + Path outputFile = getOutputFile().get().getAsFile().toPath(); + @Nullable Path outputParent = outputFile.getParent(); + if (outputParent != null) { + Files.createDirectories(outputParent); + } + + Path workDir = getTemporaryDir().toPath(); + deleteRecursively(workDir); + + Path sourcesDir = workDir.resolve("SOURCES"); + Path specsDir = workDir.resolve("SPECS"); + Path payloadDir = sourcesDir.resolve("payload"); + Files.createDirectories(sourcesDir); + Files.createDirectories(specsDir); + stagePayload(payloadDir, appShFile, iconFile); + + ReleaseType releaseType = getReleaseType().get(); + String rpmVersion = sanitizeRpmVersion(getVersion().get()); + Path specFile = specsDir.resolve(releaseType.getPackageName() + ".spec"); + Files.writeString(specFile, getSpec(releaseType, rpmVersion), StandardCharsets.UTF_8); + + LOGGER.lifecycle("Creating rpm file"); + runCommand("rpmbuild", "-bb", "--define", "_topdir " + workDir, specFile.toString()); + + Path builtRpm = workDir.resolve("RPMS").resolve("noarch") + .resolve("%s-%s-1.noarch.rpm".formatted(releaseType.getPackageName(), rpmVersion)); + if (!Files.isRegularFile(builtRpm)) { + throw new IOException("rpmbuild did not create expected file: " + builtRpm); + } + + Files.copy(builtRpm, outputFile, StandardCopyOption.REPLACE_EXISTING); + } + + /// Stages all installed files under an RPM build root payload directory. + private void stagePayload(Path payloadDir, Path appShFile, Path iconFile) throws IOException { + LinuxPackageFiles files = getPackageFiles(appShFile.getFileName().toString()); + + copyFile(appShFile, payloadDir.resolve(stripRoot(files.targetPath())), EXECUTABLE_PERMISSIONS); + writeFile(payloadDir.resolve(stripRoot(files.launcherPath())), files.launcherScript(), EXECUTABLE_PERMISSIONS); + writeFile(payloadDir.resolve(stripRoot(files.desktopFilePath())), files.desktopInfo(), REGULAR_FILE_PERMISSIONS); + copyFile(iconFile, payloadDir.resolve(stripRoot(files.iconTargetPath())), REGULAR_FILE_PERMISSIONS); + } + + /// Creates the shared Linux package path helper for this task. + private LinuxPackageFiles getPackageFiles(String appFileName) { + return new LinuxPackageFiles(getReleaseType().get(), appFileName, "rpm"); + } + + /// Writes an RPM spec that installs the pre-staged payload and registers alternatives. + private String getSpec(ReleaseType releaseType, String rpmVersion) { + LinuxPackageFiles files = getPackageFiles(getAppShFile().getAsFile().get().getName()); + return """ + Name: %s + Version: %s + Release: 1 + Summary: Hello Minecraft! Launcher + License: GPL-3.0-or-later + URL: https://github.com/HMCL-dev/HMCL + BuildArch: noarch + Requires: bash + Requires(post): %%{_sbindir}/alternatives + Requires(preun): %%{_sbindir}/alternatives + + %%description + Hello Minecraft! Launcher is a Minecraft launcher with mod and instance management support. + + %%prep + + %%build + + %%install + rm -rf "%%{buildroot}" + mkdir -p "%%{buildroot}" + cp -a "%%{_sourcedir}/payload/." "%%{buildroot}/" + + %%post + if [ "$1" -eq 1 ] || [ "$1" -eq 2 ]; then + %%{_sbindir}/alternatives --install %s hmcl %s %d || : + fi + + %%preun + if [ "$1" -eq 0 ]; then + %%{_sbindir}/alternatives --remove hmcl %s || : + fi + + %%files + %%dir /usr/share/java/hmcl + %%attr(0755,root,root) %s + %%attr(0755,root,root) %s + %%attr(0644,root,root) %s + %%attr(0644,root,root) %s + """.formatted( + releaseType.getPackageName(), + rpmVersion, + LinuxPackageFiles.COMMON_LAUNCHER_PATH, + files.launcherPath(), + releaseType.getAlternativesPriority(), + files.launcherPath(), + files.targetPath(), + files.launcherPath(), + files.desktopFilePath(), + files.iconTargetPath() + ); + } + + /// Converts an absolute install path into a relative path below the staged payload directory. + private static String stripRoot(String path) { + if (!path.startsWith("/")) { + throw new IllegalArgumentException("Expected absolute path: " + path); + } + return path.substring(1); + } + + /// Copies a file into the payload and applies POSIX permissions. + private static void copyFile(Path source, Path target, String permissions) throws IOException { + @Nullable Path targetParent = target.getParent(); + if (targetParent != null) { + Files.createDirectories(targetParent); + } + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + Files.setPosixFilePermissions(target, PosixFilePermissions.fromString(permissions)); + } + + /// Writes a UTF-8 file into the payload and applies POSIX permissions. + private static void writeFile(Path target, String content, String permissions) throws IOException { + @Nullable Path targetParent = target.getParent(); + if (targetParent != null) { + Files.createDirectories(targetParent); + } + Files.writeString(target, content, StandardCharsets.UTF_8); + Files.setPosixFilePermissions(target, PosixFilePermissions.fromString(permissions)); + } + + /// Removes the previous temporary build directory, if one exists. + private static void deleteRecursively(Path path) throws IOException { + if (!Files.exists(path)) { + return; + } + + try (Stream paths = Files.walk(path)) { + paths.sorted(Comparator.reverseOrder()).forEach(CreateRpm::deletePath); + } catch (UncheckedIOException e) { + throw e.getCause(); + } + } + + /// Deletes one path during recursive cleanup. + private static void deletePath(Path path) { + try { + Files.delete(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /// Converts HMCL versions to RPM-compatible versions by replacing unsupported characters. + private static String sanitizeRpmVersion(String version) { + if (version.isBlank()) { + throw new GradleException("RPM version must not be blank"); + } + + StringBuilder result = new StringBuilder(version.length()); + for (int i = 0; i < version.length(); i++) { + char ch = version.charAt(i); + if (Character.isLetterOrDigit(ch) || ch == '.' || ch == '_' || ch == '+' || ch == '~') { + result.append(ch); + } else { + result.append('_'); + } + } + + return result.toString().toLowerCase(Locale.ROOT); + } + + /// Verifies that `rpmbuild` is available before staging package files. + private static void assertRpmBuildAvailable() throws IOException, InterruptedException { + try { + runCommand("rpmbuild", "--version"); + } catch (IOException e) { + throw new IOException("rpmbuild is required to create RPM packages. Install rpm-build or run this task in the RPM CI container.", e); + } + } + + /// Runs an external command and fails the task when it exits unsuccessfully. + private static void runCommand(String... command) throws IOException, InterruptedException { + Process process = new ProcessBuilder(command) + .inheritIO() + .start(); + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new GradleException("Command failed with exit code %d: %s".formatted(exitCode, String.join(" ", command))); + } + } +} diff --git a/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/pack/LinuxPackageFiles.java b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/pack/LinuxPackageFiles.java new file mode 100644 index 00000000000..bd60b24dc27 --- /dev/null +++ b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/pack/LinuxPackageFiles.java @@ -0,0 +1,108 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.gradle.pack; + +import org.jetbrains.annotations.NotNullByDefault; + +/// Shared Linux package paths and generated file contents for HMCL packages. +/// +/// Debian and RPM packages intentionally install the same launcher layout so +/// channel-specific commands, desktop entries, and alternatives registration +/// behave consistently across package formats. +@NotNullByDefault +final class LinuxPackageFiles { + /// Generic command managed by the alternatives system. + static final String COMMON_LAUNCHER_PATH = "/usr/bin/hmcl"; + + /// Release channel metadata used for names and alternatives priority. + private final ReleaseType releaseType; + + /// Bundled executable shell artifact produced by `makeExecutables`. + private final String appFileName; + + /// Package manager name exposed to the launcher process. + private final String packageManager; + + /// Creates package path helpers for one release channel and package manager. + LinuxPackageFiles(ReleaseType releaseType, String appFileName, String packageManager) { + this.releaseType = releaseType; + this.appFileName = appFileName; + this.packageManager = packageManager; + } + + /// Returns the channel-specific command installed into `/usr/bin`. + String launcherPath() { + return "/usr/bin/hmcl-" + releaseType.getName(); + } + + /// Returns the installed location of the bundled HMCL shell artifact. + String targetPath() { + return "/usr/share/java/hmcl/" + appFileName; + } + + /// Returns the installed desktop entry path for this channel. + String desktopFilePath() { + return "/usr/share/applications/hmcl-%s.desktop".formatted(releaseType.getName()); + } + + /// Returns the installed hicolor icon path for this channel. + String iconTargetPath() { + return "/usr/share/icons/hicolor/256x256/apps/hmcl-%s.png".formatted(releaseType.getName()); + } + + /// Creates a wrapper that launches the bundled shell artifact from the user's home directory. + String launcherScript() { + return """ + #!/usr/bin/env bash + cd "$HOME" + if [ -z "${HMCL_PACKAGE_MANAGED:-}" ]; then + export HMCL_PACKAGE_MANAGED="%s" + fi + if [ -z "${HMCL_USER_HOME:-}" ]; then + if [ -z "${XDG_DATA_HOME:-}" ]; then + export HMCL_USER_HOME="$HOME/.local/share/hmcl" + else + export HMCL_USER_HOME="$XDG_DATA_HOME/hmcl" + fi + fi + if [ -z "${HMCL_LOCAL_HOME:-}" ]; then + export HMCL_LOCAL_HOME="$HMCL_USER_HOME/local-%s" + fi + if [ -z "${HMCL_DEPENDENCIES_DIR:-}" ]; then + export HMCL_DEPENDENCIES_DIR="$HMCL_USER_HOME/dependencies" + fi + exec %s "$@" + """.formatted(packageManager, releaseType.getName(), targetPath()); + } + + /// Creates the desktop entry that points to the channel-specific command. + String desktopInfo() { + return """ + [Desktop Entry] + Type=Application + Name=%s + Comment=Hello Minecraft! Launcher + Exec=%s + Icon=%s + Terminal=false + StartupNotify=false + Categories=Game; + Keywords=mc;minecraft; + """.formatted(releaseType.getDisplayName(), launcherPath(), iconTargetPath()); + } +}