From 9676c94430985a6035e6dd78c699be5f07c19605 Mon Sep 17 00:00:00 2001 From: Felitendo Date: Thu, 17 Sep 2026 16:14:11 +0200 Subject: [PATCH] feat: update from own releases --- .github/workflows/build.yml | 12 ++ .github/workflows/upstream-release.yml | 68 ++++++- README.md | 13 ++ ...rom-Modrinth-Enhanced-s-own-releases.patch | 176 ++++++++++++++++++ scripts/build.sh | 44 ++++- scripts/check.sh | 20 ++ updater.pub | 1 + 7 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 patches/0018-Update-from-Modrinth-Enhanced-s-own-releases.patch create mode 100644 updater.pub diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cc46674..6131791 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,6 +16,10 @@ on: description: Upstream tag to build instead of the one in upstream.txt type: string required: false + revision: + description: Which release of the upstream version this is, for the updater + type: string + required: false outputs: version: description: Version the app was built as @@ -27,6 +31,7 @@ concurrency: env: UPSTREAM_REF: ${{ inputs.upstream-ref }} + MODRINTH_ENHANCED_REVISION: ${{ inputs.revision }} jobs: build: @@ -104,12 +109,19 @@ jobs: shell: bash run: scripts/check.sh + # Without the key, as for a pull request from a fork, the build simply + # has no updater. - name: Build shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: scripts/build.sh - name: Check the build output shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} run: scripts/check.sh - name: Upload installers diff --git a/.github/workflows/upstream-release.yml b/.github/workflows/upstream-release.yml index 51c7847..e21d209 100644 --- a/.github/workflows/upstream-release.yml +++ b/.github/workflows/upstream-release.yml @@ -33,6 +33,7 @@ jobs: outputs: upstream: ${{ steps.check.outputs.upstream }} tag: ${{ steps.check.outputs.tag }} + revision: ${{ steps.check.outputs.revision }} proceed: ${{ steps.check.outputs.proceed }} steps: - uses: actions/checkout@v4 @@ -64,20 +65,22 @@ jobs: )" if [ -z "$last" ]; then + revision=1 tag="$upstream" else - revision="${last%% *}" last_tag="${last#* }" git fetch --no-tags --depth=1 origin "refs/tags/$last_tag:refs/tags/$last_tag" # Only what goes into the build counts, not docs or CI. - if git diff --quiet "$last_tag" HEAD -- patches scripts; then + if git diff --quiet "$last_tag" HEAD -- patches scripts updater.pub; then echo "$last_tag already ships the current patches; nothing to do." echo "proceed=false" >> "$GITHUB_OUTPUT" exit 0 fi - tag="$upstream-$((revision + 1))" + revision="$(( ${last%% *} + 1 ))" + tag="$upstream-$revision" fi + echo "revision=$revision" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "proceed=true" >> "$GITHUB_OUTPUT" echo "Releasing $tag" @@ -89,6 +92,8 @@ jobs: uses: ./.github/workflows/build.yml with: upstream-ref: ${{ needs.detect.outputs.upstream }} + revision: ${{ needs.detect.outputs.revision }} + secrets: inherit release: name: Release @@ -118,6 +123,63 @@ jobs: path: artifacts merge-multiple: true + # The app looks for updates in releases/latest/download/latest.json. + # Spaces in asset names become dots first, as GitHub would make them, so + # the addresses written into it are exact. A release without signatures + # stops here: every install it reached could never update again. + - name: Write the update manifest + env: + TAG: ${{ needs.detect.outputs.tag }} + UPSTREAM: ${{ needs.detect.outputs.upstream }} + REVISION: ${{ needs.detect.outputs.revision }} + run: | + set -euo pipefail + for file in artifacts/*' '*; do + if [ -e "$file" ]; then mv "$file" "${file// /.}"; fi + done + python3 - <<'EOF' + import datetime, json, os, pathlib + + artifacts = pathlib.Path("artifacts") + tag = os.environ["TAG"] + repository = os.environ["GITHUB_REPOSITORY"] + + # The app's version is the upstream one. A revision comes along as + # build metadata, which the app compares itself. + version = os.environ["UPSTREAM"].removeprefix("v") + if int(os.environ["REVISION"]) > 1: + version += "+" + os.environ["REVISION"] + + + def signed(suffix): + matches = [p for p in artifacts.iterdir() if p.name.endswith(suffix)] + if len(matches) != 1: + raise SystemExit(f"Expected one *{suffix}, found {[p.name for p in matches]}") + signature = pathlib.Path(f"{matches[0]}.sig") + if not signature.is_file(): + raise SystemExit(f"{matches[0].name} is not signed: is TAURI_SIGNING_PRIVATE_KEY set?") + return { + "signature": signature.read_text().strip(), + "url": f"https://github.com/{repository}/releases/download/{tag}/{matches[0].name}", + } + + + macos = signed(".app.tar.gz") + manifest = { + "version": version, + "notes": f"Modrinth Enhanced {tag}", + "pub_date": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "platforms": { + "linux-x86_64": signed(".AppImage"), + "windows-x86_64": signed("-setup.exe"), + "darwin-x86_64": macos, + "darwin-aarch64": macos, + }, + } + (artifacts / "latest.json").write_text(json.dumps(manifest, indent=2) + "\n") + print((artifacts / "latest.json").read_text()) + EOF + - name: Publish the release env: GH_TOKEN: ${{ github.token }} diff --git a/README.md b/README.md index 966e62f..369b282 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ works. | `0015-Use-the-desktop-s-file-picker-...` | File pickers on Linux are the desktop's own, such as KDE's, through the XDG desktop portal. | | `0016-Show-the-account-in-the-title-bar-...` | With the right sidebar folded away, the Minecraft account is shown in the title bar and managed from there. | | `0017-Start-on-Wayland-with-an-NVIDIA-GPU` | The app no longer crashes at start under Wayland with the NVIDIA driver. | +| `0018-Update-from-Modrinth-Enhanced-s-...` | Updates come from this project's own signed releases rather than Modrinth's. | ### Offline accounts @@ -155,6 +156,14 @@ without making anyone more private. neither ends up in a build; removing the entries would mean carrying a patch against the lockfile for no practical gain. +### Updates + +The app updates itself from this project's releases on GitHub: on Windows, on macOS, and as an +AppImage on Linux. Updates are signed with this project's own key, whose public half is +`updater.pub`, and Modrinth is no longer asked, since its update would be the official app. +Installs from the AUR, a `.deb` or a `.rpm` are updated like any other package; the app shows a +notice with a button to the release when there is a new one. + ## Relationship to the official app Modrinth Enhanced keeps the upstream bundle identifier, which means it uses **the same data @@ -218,6 +227,10 @@ scripts/prepare.sh release of that upstream version, it is released again as `v0.21.2-2`, `v0.21.2-3` and so on. The app and installers still carry the upstream version: RPM and the Windows installers do not accept a suffix in it. +- **Updates** are published with every release as `latest.json`, next to installers signed with the + `TAURI_SIGNING_PRIVATE_KEY` secret. A release without signatures fails instead of shipping, since + every install it reached could never update again. Builds without the secret, such as pull + requests from forks, have no updater. `scripts/check.sh` is what makes the automation trustworthy. A patch can apply cleanly and still stop doing its job if upstream moves the thing it was holding down, so the checks assert the diff --git a/patches/0018-Update-from-Modrinth-Enhanced-s-own-releases.patch b/patches/0018-Update-from-Modrinth-Enhanced-s-own-releases.patch new file mode 100644 index 0000000..136ecca --- /dev/null +++ b/patches/0018-Update-from-Modrinth-Enhanced-s-own-releases.patch @@ -0,0 +1,176 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Modrinth Enhanced +Date: Tue, 15 Sep 2026 18:43:17 +0200 +Subject: [PATCH] Update from Modrinth Enhanced's own releases + +The updater asked Modrinth, whose update is the official app and would +replace this one. It now takes this project's own signed releases, with +the endpoint and key given at build time by scripts/build.sh. + +Revisions of one upstream version share the app's version, since the +installers refuse a suffix in it, so a release carries its revision as +build metadata and the comparison looks at that. On Linux only the +AppImage updates itself; other installs, and builds without the updater, +get a notice with a button to the release on GitHub. +--- + apps/app-frontend/src/App.vue | 63 ++++++++++++++++++++++++----------- + apps/app/src/main.rs | 32 ++++++++++++++++++ + apps/app/tauri.conf.json | 2 +- + 3 files changed, 76 insertions(+), 21 deletions(-) + +diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue +index 5cbe7d8..dfdcdb1 100644 +--- a/apps/app-frontend/src/App.vue ++++ b/apps/app-frontend/src/App.vue +@@ -1888,9 +1888,13 @@ const updatePopupMessages = defineMessages({ + defaultMessage: `Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App.`, + }, + linuxBody: { +- id: 'app.update-popup.body.linux', ++ id: 'app.update-popup.body.linux-release', + defaultMessage: +- 'Modrinth App v{version} is available. Use your package manager to update for the latest features and fixes!', ++ 'Modrinth Enhanced v{version} is available. Update it with your package manager, or download it from the release on GitHub.', ++ }, ++ openRelease: { ++ id: 'app.update-popup.open-release', ++ defaultMessage: 'Open release', + }, + reload: { + id: 'app.update-popup.reload', +@@ -2065,28 +2069,47 @@ async function checkUpdates() { + ) + } + ++// A .deb or .rpm install, or a build without the updater, is only told about ++// a new release: this project's own on GitHub, not Modrinth's, whose update ++// would be the official app. + async function checkLinuxUpdates() { + try { +- const [response, currentVersion] = await Promise.all([ +- fetch('https://launcher-files.modrinth.com/updates.json'), ++ const [repository, currentVersion, currentRevision] = await Promise.all([ ++ invoke('release_repository'), + getVersion(), ++ invoke('app_revision'), + ]) +- const updates = await response.json() +- const latestVersion = updates?.version +- +- if (latestVersion && latestVersion !== currentVersion) { +- markAppUpdateActionable(latestVersion) +- const nextPopupTime = getNextAppUpdatePopupTime(latestVersion) +- if (nextPopupTime !== null && Date.now() >= nextPopupTime) { +- addPopupNotification({ +- contentType: 'standard', +- title: formatMessage(updatePopupMessages.updateAvailable), +- text: formatMessage(updatePopupMessages.linuxBody, { version: latestVersion }), +- type: 'info', +- autoCloseMs: null, +- }) +- markAppUpdatePopupShown(latestVersion) +- } ++ const response = await fetch(`https://api.github.com/repos/${repository}/releases/latest`) ++ if (!response.ok) return ++ const release = await response.json() ++ ++ // v1.2.3 is the first release of an upstream version, v1.2.3-2 the next. ++ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-(\d+))?$/.exec(release?.tag_name ?? '') ++ if (!match) return ++ const latest = [...match.slice(1, 4).map(Number), Number(match[4] ?? 1)] ++ const current = [...currentVersion.split('.').map(Number), currentRevision] ++ const differs = latest.findIndex((part, i) => part !== current[i]) ++ if (differs === -1 || latest[differs] < current[differs]) return ++ ++ const latestVersion = release.tag_name.replace(/^v/, '') ++ markAppUpdateActionable(latestVersion) ++ const nextPopupTime = getNextAppUpdatePopupTime(latestVersion) ++ if (nextPopupTime !== null && Date.now() >= nextPopupTime) { ++ addPopupNotification({ ++ contentType: 'standard', ++ title: formatMessage(updatePopupMessages.updateAvailable), ++ text: formatMessage(updatePopupMessages.linuxBody, { version: latestVersion }), ++ type: 'info', ++ autoCloseMs: null, ++ buttons: [ ++ { ++ label: formatMessage(updatePopupMessages.openRelease), ++ action: () => openUrl(release.html_url), ++ color: 'brand', ++ }, ++ ], ++ }) ++ markAppUpdatePopupShown(latestVersion) + } + } catch (e) { + console.error('Failed to check for updates:', e) +diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs +index 5cdab91..1deb12f 100644 +--- a/apps/app/src/main.rs ++++ b/apps/app/src/main.rs +@@ -77,6 +77,25 @@ fn is_dev() -> bool { + fn are_updates_enabled() -> bool { + cfg!(feature = "updater") + && env::var("MODRINTH_EXTERNAL_UPDATE_PROVIDER").is_err() ++ // On Linux the updater replaces the AppImage it runs from. A .deb or ++ // .rpm install gets the notice pointing at the release instead. ++ && (!cfg!(target_os = "linux") || env::var_os("APPIMAGE").is_some()) ++} ++ ++/// Which release of the upstream version this build is: 2 for v1.2.3-2. The ++/// version itself stays the upstream one, since installers refuse a suffix. ++#[tauri::command] ++fn app_revision() -> u64 { ++ option_env!("MODRINTH_ENHANCED_REVISION") ++ .and_then(|revision| revision.parse().ok()) ++ .unwrap_or(1) ++} ++ ++/// The repository this build takes its updates from. ++#[tauri::command] ++fn release_repository() -> &'static str { ++ option_env!("MODRINTH_ENHANCED_REPOSITORY") ++ .unwrap_or("Felitendo/Modrinth-Enhanced") + } + + #[cfg(feature = "updater")] +@@ -187,6 +206,17 @@ fn main() { + HeaderValue::from_str(&launcher_user_agent()).unwrap(), + ) + .unwrap() ++ // A release carries its revision as build metadata, 1.2.3+2, ++ // which version ordering ignores. ++ .default_version_comparator(|current, release| { ++ let remote = release.version; ++ let ours = (current.major, current.minor, current.patch); ++ let theirs = (remote.major, remote.minor, remote.patch); ++ if theirs != ours { ++ return theirs > ours; ++ } ++ remote.build.as_str().parse().unwrap_or(1) > app_revision() ++ }) + .build(), + ); + } +@@ -303,6 +333,8 @@ fn main() { + initialize_state, + is_dev, + are_updates_enabled, ++ app_revision, ++ release_repository, + get_update_size, + enqueue_update_for_installation, + remove_enqueued_update, +diff --git a/apps/app/tauri.conf.json b/apps/app/tauri.conf.json +index e5bbabe..bd2434d 100644 +--- a/apps/app/tauri.conf.json ++++ b/apps/app/tauri.conf.json +@@ -102,7 +102,7 @@ + "capabilities": ["ads", "core", "plugins"], + "csp": { + "default-src": "'self' customprotocol: asset:", +- "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https://www.intercom-reporting.com wss://*.nodes.modrinth.com https://*.taila228c5.ts.net https://*.taila228c5.ts.net wss://*.taila228c5.ts.net https://fill.papermc.io https://api.purpurmc.org 'self' data: blob:", ++ "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https://www.intercom-reporting.com wss://*.nodes.modrinth.com https://*.taila228c5.ts.net https://*.taila228c5.ts.net wss://*.taila228c5.ts.net https://fill.papermc.io https://api.purpurmc.org https://api.github.com 'self' data: blob:", + "font-src": ["https://cdn.modrinth.com/fonts/", "https://js.intercomcdn.com"], + "img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:", + "style-src": "'unsafe-inline' 'self'", diff --git a/scripts/build.sh b/scripts/build.sh index 496037d..37f3f13 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -56,6 +56,47 @@ Darwin) ;; esac +# The repository releases come from, which the app's update notice points at. +export MODRINTH_ENHANCED_REPOSITORY="${GITHUB_REPOSITORY:-$(git -C "$REPO_ROOT" remote get-url origin | sed -E 's#^.*github\.com[:/]##; s#\.git$##')}" + +# Updates come from this repository's own releases, signed with its own key +# (the public half is updater.pub). A build without the private key, such as +# one for a pull request or a local one, has nothing to sign them with and +# leaves the updater out. +if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then + repository="$MODRINTH_ENHANCED_REPOSITORY" + updater_conf="$REPO_ROOT/build/updater.conf.json" + mkdir -p "$(dirname "$updater_conf")" + node -e ' + const fs = require("fs") + const [conf, pubkey, repository] = process.argv.slice(1) + const { capabilities } = JSON.parse(fs.readFileSync(conf, "utf8")).app.security + console.log(JSON.stringify({ + bundle: { createUpdaterArtifacts: true }, + build: { features: ["updater"] }, + app: { security: { capabilities: [...capabilities, "updater"] } }, + plugins: { + updater: { + pubkey: fs.readFileSync(pubkey, "utf8").trim(), + endpoints: [`https://github.com/${repository}/releases/latest/download/latest.json`], + windows: { installMode: "passive" }, + }, + }, + }, null, "\t")) + ' "$WORKTREE/apps/app/tauri.conf.json" "$REPO_ROOT/updater.pub" "$repository" >"$updater_conf" + tauri_args+=(--config "$updater_conf") + # Tauri asks for the password when none is set, which fails without a + # terminal. The project's key has none. + export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="${TAURI_SIGNING_PRIVATE_KEY_PASSWORD-}" + log "Updates will come from github.com/$repository" +else + warn "TAURI_SIGNING_PRIVATE_KEY is not set, so this build has no updater" +fi + +# Which release of this upstream version this is, for the updater to tell +# v1.2.3-2 from v1.2.3, since the app's own version cannot carry it. +export MODRINTH_ENHANCED_REVISION="${MODRINTH_ENHANCED_REVISION:-1}" + # Emptied before the build, not after it: a build that fails halfway would # otherwise leave the previous run's installers sitting here, where # scripts/check.sh would happily pass them off as this build's output. @@ -80,7 +121,8 @@ while IFS= read -r -d '' artifact; do found=1 done < <(find "$bundle_dir" -maxdepth 2 -type f \ \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \ - -o -name '*.dmg' -o -name '*.app.tar.gz' -o -name '*-setup.exe' \) -print0) + -o -name '*.dmg' -o -name '*.app.tar.gz' -o -name '*-setup.exe' \ + -o -name '*.sig' \) -print0) [ "$found" = 1 ] || die "No bundles were produced under $bundle_dir" diff --git a/scripts/check.sh b/scripts/check.sh index e70a38d..91cd531 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -34,6 +34,17 @@ missing() { ! grep -qrF "$2" "$1" } +# Every installer the updater can take has its signature next to it. +signed() { + local artifact found=0 + for artifact in "$1"/*.AppImage "$1"/*-setup.exe "$1"/*.app.tar.gz; do + [ -e "$artifact" ] || continue + [ -s "$artifact.sig" ] || return 1 + found=1 + done + [ "$found" = 1 ] +} + log "Branding" check "tauri.conf.json is named Modrinth Enhanced" \ contains "$WORKTREE/apps/app/tauri.conf.json" '"productName": "Modrinth Enhanced"' @@ -169,6 +180,12 @@ check "file pickers use the desktop portal on Linux" \ check "NVIDIA under Wayland does not crash the webview" \ contains "$WORKTREE/apps/app/src/main.rs" 'set_var("WEBKIT_DMABUF_RENDERER_FORCE_SHM", "1")' +log "Updates" +check "updates do not come from Modrinth" \ + missing "$WORKTREE/apps/app-frontend/src/App.vue" 'launcher-files.modrinth.com/updates.json' +check "the updater tells revisions apart" \ + contains "$WORKTREE/apps/app/src/main.rs" 'default_version_comparator' + log "No advertising or upsells" check "no Modrinth+ upsell in the app" \ missing "$WORKTREE/apps/app-frontend/src/App.vue" "modrinth.plus" @@ -218,6 +235,9 @@ if [ -d "$artifacts" ] && [ -n "$(ls -A "$artifacts" 2>/dev/null)" ]; then ;; esac done + if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then + check "the installers are signed for the updater" signed "$artifacts" + fi fi if [ "$failures" -gt 0 ]; then diff --git a/updater.pub b/updater.pub new file mode 100644 index 0000000..72d5fec --- /dev/null +++ b/updater.pub @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDY1Nzc2MEZBMkQyQkRGQjkKUldTNTN5c3QrbUIzWlk1RHdYTFFBMStjM29zQkZ4MW5ibHZGb2p6ckxFK0JUNDBJTkZ6RXc3NUMK