feat: update from own releases

This commit is contained in:
Felitendo committed 2026-09-17 16:14:11 +02:00
1 parent be8a500937
commit 9676c94430
7 files changed
+330 -4

No files matched your search

+12
View File
@@ -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
+65 -3
View File
@@ -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 }}
+13
View File
@@ -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
@@ -0,0 +1,176 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
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'",
+43 -1
View File
@@ -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"
+20
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDY1Nzc2MEZBMkQyQkRGQjkKUldTNTN5c3QrbUIzWlk1RHdYTFFBMStjM29zQkZ4MW5ibHZGb2p6ckxFK0JUNDBJTkZ6RXc3NUMK