feat: add patch series, build scripts and release automation

This commit is contained in:
Felitendo committed 2026-09-14 11:03:47 +02:00
1 parent 8f4e5e0521
commit 02e18e3765
18 files changed
+23026

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Patch files must reach `git am` byte for byte. Line ending conversion on a
# Windows checkout would corrupt them, binary hunks in particular.
*.patch -text
# The shell scripts run under bash, including on Windows runners, where CRLF
# line endings break them.
*.sh text eol=lf
+120
View File
@@ -0,0 +1,120 @@
name: Build
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
upstream-ref:
description: Upstream tag to build instead of the one in upstream.txt
type: string
required: false
workflow_call:
inputs:
upstream-ref:
description: Upstream tag to build instead of the one in upstream.txt
type: string
required: false
outputs:
version:
description: Version the app was built as
value: ${{ jobs.build.outputs.version }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.upstream-ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
UPSTREAM_REF: ${{ inputs.upstream-ref }}
jobs:
build:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
outputs:
version: ${{ steps.version.outputs.version }}
strategy:
fail-fast: false
matrix:
include:
- name: Linux
os: ubuntu-latest
- name: Windows
os: windows-latest
- name: macOS
os: macos-latest
steps:
- name: Check out Modrinth Enhanced
uses: actions/checkout@v4
# Everything below works on the patched tree in build/upstream, so this
# has to come first: a patch that no longer applies fails the build here,
# which is exactly the signal we want when upstream has moved.
- name: Apply patches
shell: bash
run: scripts/prepare.sh
- name: Resolve version
id: version
shell: bash
run: |
. scripts/common.sh
echo "version=$(app_version)" >> "$GITHUB_OUTPUT"
echo "upstream=$UPSTREAM_REF" >> "$GITHUB_OUTPUT"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: build/upstream/.nvmrc
- name: Enable Corepack
shell: bash
run: corepack enable
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Set up Rust
shell: bash
working-directory: build/upstream
run: |
rustup show active-toolchain || rustup toolchain install
if [ "$RUNNER_OS" = macOS ]; then
rustup target add x86_64-apple-darwin aarch64-apple-darwin
fi
- name: Cache Rust build
uses: Swatinem/rust-cache@v2
with:
workspaces: build/upstream
key: ${{ steps.version.outputs.upstream }}
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
- name: Check the patched sources
shell: bash
run: scripts/check.sh
- name: Build
shell: bash
run: scripts/build.sh
- name: Check the build output
shell: bash
run: scripts/check.sh
- name: Upload installers
uses: actions/upload-artifact@v4
with:
name: modrinth-enhanced-${{ matrix.name }}
path: build/artifacts/*
if-no-files-found: error
+124
View File
@@ -0,0 +1,124 @@
name: Upstream release
# Watches Modrinth for a new Modrinth App release, rebuilds Modrinth Enhanced
# on top of it, and publishes a release of our own if everything still works.
#
# Nothing is published unless the patches applied, the checks passed and all
# three platforms built, so an upstream change that breaks a patch stops here
# and reports a failure instead of shipping a broken build.
on:
schedule:
- cron: '17 6 * * *'
workflow_dispatch:
inputs:
upstream-ref:
description: Build this upstream tag instead of the newest one
type: string
required: false
force:
description: Release even if this version was already released
type: boolean
default: false
permissions:
contents: write
concurrency:
group: upstream-release
cancel-in-progress: false
jobs:
detect:
name: Detect
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.check.outputs.tag }}
proceed: ${{ steps.check.outputs.proceed }}
steps:
- uses: actions/checkout@v4
- name: Find the newest upstream release
id: check
env:
GH_TOKEN: ${{ github.token }}
REQUESTED: ${{ inputs.upstream-ref }}
FORCE: ${{ inputs.force }}
run: |
set -euo pipefail
tag="${REQUESTED:-$(scripts/latest-upstream.sh)}"
current="$(tr -d '[:space:]' < upstream.txt)"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "Newest upstream release: $tag (we are on $current)"
if gh release view "$tag" >/dev/null 2>&1 && [ "$FORCE" != 'true' ]; then
echo "$tag has already been released; nothing to do."
echo "proceed=false" >> "$GITHUB_OUTPUT"
else
echo "proceed=true" >> "$GITHUB_OUTPUT"
fi
build:
name: Build
needs: detect
if: needs.detect.outputs.proceed == 'true'
uses: ./.github/workflows/build.yml
with:
upstream-ref: ${{ needs.detect.outputs.tag }}
release:
name: Release
needs: [detect, build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Record the upstream release we build against
env:
TAG: ${{ needs.detect.outputs.tag }}
run: |
set -euo pipefail
printf '%s\n' "$TAG" > upstream.txt
if git diff --quiet -- upstream.txt; then
echo "upstream.txt already points at $TAG"
exit 0
fi
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git commit -m "Track upstream $TAG" -- upstream.txt
git push origin HEAD:${{ github.event.repository.default_branch }}
- name: Download installers
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Publish the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.detect.outputs.tag }}
run: |
set -euo pipefail
ls -la artifacts
notes="$(cat <<EOF
Modrinth Enhanced built from [Modrinth App $TAG](https://github.com/modrinth/code/releases/tag/$TAG).
Same app as upstream, with the patches in \`patches/\` applied:
- no advertising,
- no telemetry, crash reporting or survey embeds,
- offline accounts for singleplayer and offline-mode servers,
- renamed and re-iconed as Modrinth Enhanced.
It keeps using the same data directory as the official Modrinth App,
so instances, settings and accounts carry over. Do not run both at
the same time.
See the upstream release notes for everything else that changed.
EOF
)"
gh release create "$TAG" \
--title "Modrinth Enhanced $TAG" \
--notes "$notes" \
artifacts/*
+2
View File
@@ -0,0 +1,2 @@
# Everything under build/ is regenerated from upstream.txt plus patches/.
/build/
+111
View File
@@ -0,0 +1,111 @@
# Modrinth Enhanced
The [Modrinth App](https://github.com/modrinth/code), without advertising, without telemetry, and
with offline accounts.
Everything else is deliberately left alone. This repository holds no forked source code — only a
series of patches that are applied to an upstream release tag, built, and published. Whenever
Modrinth ships a new version, the patches are reapplied on top of it, the result is built and
checked on Linux, Windows and macOS, and a release is published automatically if it all still
works.
## What changes
| Patch | What it does |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `0001-Rename-the-app-to-Modrinth-Enhanced` | Product name, binary name, window title, version label, and an "Enhanced" pill next to the wordmark. |
| `0002-Use-the-Modrinth-Enhanced-icon` | The Modrinth mark with a sparkle badge, rendered into every icon the bundles need. The vector source ships alongside them. |
| `0003-Remove-advertising` | The sidebar ad slot, the "Upgrade to Modrinth+" nag and the ad cookie consent prompt. The ad webview is never created. |
| `0004-Remove-telemetry` | PostHog analytics, Sentry crash reporting, the Tally survey embed, and the playtime and server-play reports the launcher sends to Modrinth. |
| `0005-Add-offline-accounts` | A second way to add a Minecraft account that never contacts Microsoft or Mojang. |
### Offline accounts
"Add offline account" sits next to "Sign in to Minecraft" in the account card. It asks for a
username and nothing else.
The player UUID is derived exactly the way Minecraft itself derives it — an MD5 name UUID over
`OfflinePlayer:<name>` — so worlds keep the same player data when they are opened from another
launcher. Offline accounts can play singleplayer and join servers running in offline mode. Servers
in online mode reject them, as they do in every other launcher.
Microsoft sign-in is untouched and still the default.
### What is *not* removed
Download attribution still happens. It is a header on downloads you already asked for, and it is
what credits project authors for them. Removing it would take money out of creators' pockets
without making anyone more private.
`posthog-js` and `@sentry/vue` remain listed in `package.json`. Nothing imports them any more, so
neither ends up in a build; removing the entries would mean carrying a patch against the lockfile
for no practical gain.
## Relationship to the official app
Modrinth Enhanced keeps the upstream bundle identifier, which means it uses **the same data
directory as the official Modrinth App**. Instances, settings and accounts carry over in both
directions, and it can be installed as a drop-in replacement.
The flip side: do not run both at once, and on Windows the two installers share an uninstall entry.
If you would rather have them fully separated, change `identifier` in
`apps/app/tauri.conf.json` — it is one line in `0001-Rename-the-app-to-Modrinth-Enhanced.patch`.
## Building it yourself
You need git, Node (the version in the upstream `.nvmrc`), pnpm via Corepack, a Rust toolchain,
JDK 17, and on Linux `libwebkit2gtk-4.1-dev`, `libayatana-appindicator3-dev` and `librsvg2-dev`.
```bash
scripts/prepare.sh # check out the pinned upstream tag and apply every patch
scripts/check.sh # assert the patches still do what they claim
scripts/build.sh # build installers into build/artifacts
```
`build/` is scratch space and is never committed.
## Working on the patches
The patched checkout is an ordinary git repository with one commit per patch, so patches are
maintained as commits rather than as diffs by hand:
```bash
scripts/prepare.sh # build/upstream, branch `enhanced`
cd build/upstream
# ...edit, then either commit a new change or amend an existing one
cd ../..
scripts/export-patches.sh # rewrite patches/ from those commits
```
Patches are applied with `git am --3way`, so small upstream movements around a hunk resolve by
themselves. When one genuinely conflicts, `scripts/prepare.sh` stops and leaves the conflict staged
in `build/upstream` to be resolved with `git am --continue`, after which `scripts/export-patches.sh`
writes the fixed series back.
To move to a newer upstream release:
```bash
scripts/latest-upstream.sh --write # update upstream.txt
scripts/prepare.sh
```
## Automation
- **Build** (`.github/workflows/build.yml`) runs on every push and pull request, and is also the
reusable workflow the release job calls. It applies the patches, checks them, and builds on
Linux, Windows and macOS.
- **Upstream release** (`.github/workflows/upstream-release.yml`) runs daily. If Modrinth has
published a newer release than `upstream.txt`, it rebuilds against it and — only if every
platform built and every check passed — commits the bump, tags it with the upstream version and
publishes a release with the installers.
`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
outcome instead of the diff: the app is named correctly, offline accounts are wired up end to end,
no telemetry endpoint survives into the built frontend, and the installers are named after this
fork.
## Licence
The Modrinth App is GPL-3.0, and so is everything here. Modrinth Enhanced is not affiliated with or
endorsed by Rinth, Inc.
@@ -0,0 +1,125 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Mon, 14 Sep 2026 09:56:18 +0200
Subject: [PATCH] Rename the app to Modrinth Enhanced
Changes the product name, the binary name, the window title and the
version label shown in settings. A small "Enhanced" pill is added next
to the wordmark in the title bar so the fork is recognisable at a
glance.
The bundle identifier is deliberately left as `ModrinthApp` so that
Modrinth Enhanced keeps using the same data directory as the official
app and stays a drop-in replacement for it.
---
apps/app-frontend/index.html | 2 +-
apps/app-frontend/src/App.vue | 5 +++++
.../src/components/ui/modal/AppSettingsModal.vue | 2 +-
apps/app-frontend/src/locales/en-US/index.json | 2 +-
apps/app/tauri.conf.json | 6 +++---
apps/app/tauri.linux.conf.json | 2 +-
apps/app/tauri.macos.conf.json | 2 +-
7 files changed, 13 insertions(+), 8 deletions(-)
diff --git a/apps/app-frontend/index.html b/apps/app-frontend/index.html
index 50867a4..6367788 100644
--- a/apps/app-frontend/index.html
+++ b/apps/app-frontend/index.html
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
- <title>Modrinth App</title>
+ <title>Modrinth Enhanced</title>
<link rel="stylesheet" href="/src/assets/stylesheets/global.scss" />
</head>
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index a2ae0e6..1d1a9ce 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -2374,6 +2374,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
<div data-tauri-drag-region class="flex min-w-0 flex-1 items-center overflow-hidden p-2">
<TextLogo class="h-7 w-auto shrink-0 text-contrast pointer-events-none" />
+ <span
+ data-tauri-drag-region
+ class="ml-1.5 shrink-0 rounded-full bg-brand-highlight px-1.5 py-0.5 text-[0.625rem] font-bold uppercase leading-none tracking-wide text-brand pointer-events-none"
+ >Enhanced</span
+ >
<div data-tauri-drag-region class="ml-2 flex shrink-0 items-center gap-2">
<IconButton
type="outlined"
diff --git a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue b/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue
index dd55fe5..0f33d0e 100644
--- a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue
+++ b/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue
@@ -312,7 +312,7 @@ const messages = defineMessages({
},
appVersion: {
id: 'app.settings.app-version',
- defaultMessage: 'Modrinth App {version}',
+ defaultMessage: 'Modrinth Enhanced {version}',
},
macos: {
id: 'app.settings.operating-system.macos',
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index cc65180..faab078 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -1548,7 +1548,7 @@
"message": "Oldest"
},
"app.settings.app-version": {
- "message": "Modrinth App {version}"
+ "message": "Modrinth Enhanced {version}"
},
"app.settings.default-instance-options.environment-variables.description": {
"message": "Environment variables set when launching an instance."
diff --git a/apps/app/tauri.conf.json b/apps/app/tauri.conf.json
index db4ef36..e69e6b7 100644
--- a/apps/app/tauri.conf.json
+++ b/apps/app/tauri.conf.json
@@ -55,9 +55,9 @@
}
]
},
- "productName": "Modrinth App",
+ "productName": "Modrinth Enhanced",
"version": "../app-frontend/package.json",
- "mainBinaryName": "Modrinth App",
+ "mainBinaryName": "Modrinth Enhanced",
"identifier": "ModrinthApp",
"plugins": {
"deep-link": {
@@ -77,7 +77,7 @@
"fullscreen": false,
"height": 800,
"resizable": true,
- "title": "Modrinth App",
+ "title": "Modrinth Enhanced",
"label": "main",
"width": 1280,
"minHeight": 700,
diff --git a/apps/app/tauri.linux.conf.json b/apps/app/tauri.linux.conf.json
index 5b2e9ec..81a368c 100644
--- a/apps/app/tauri.linux.conf.json
+++ b/apps/app/tauri.linux.conf.json
@@ -1,3 +1,3 @@
{
- "mainBinaryName": "ModrinthApp"
+ "mainBinaryName": "ModrinthEnhanced"
}
diff --git a/apps/app/tauri.macos.conf.json b/apps/app/tauri.macos.conf.json
index 7ab0afc..2b20a49 100644
--- a/apps/app/tauri.macos.conf.json
+++ b/apps/app/tauri.macos.conf.json
@@ -7,7 +7,7 @@
"fullscreen": false,
"height": 800,
"resizable": true,
- "title": "Modrinth App",
+ "title": "Modrinth Enhanced",
"width": 1280,
"minHeight": 700,
"minWidth": 1100,
File diff suppressed because it is too large. Load diff
+117
View File
@@ -0,0 +1,117 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Mon, 14 Sep 2026 10:18:37 +0200
Subject: [PATCH] Remove advertising
The sidebar ad slot, the "Upgrade to Modrinth+" nag above it and the ad
cookie consent prompt are all driven by two computed flags in App.vue,
so pinning both to false takes the whole surface out of the layout.
The helpers in `helpers/ads.js` are additionally stubbed out, which
stops the Tauri `ads` plugin from ever being asked to spawn the ad
webview, no matter which call site reaches for it.
---
apps/app-frontend/src/App.vue | 8 ++--
apps/app-frontend/src/helpers/ads.js | 65 +++++++++-------------------
2 files changed, 25 insertions(+), 48 deletions(-)
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index 1d1a9ce..4df835f 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -366,10 +366,10 @@ const hasPlus = computed(
(hasMidasBadge(credentials.value.user) ||
hasActivePride26Midas(authenticatedModrinthUser.value?.campaigns?.pride_26)),
)
-const showAd = computed(
- () => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
-)
-const adConsentAvailable = computed(() => credentials.value !== undefined && !hasPlus.value)
+// Modrinth Enhanced ships without advertising, so the sidebar ad slot and the
+// ad cookie consent flow that only exists to serve it are both switched off.
+const showAd = computed(() => false)
+const adConsentAvailable = computed(() => false)
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: showAd,
diff --git a/apps/app-frontend/src/helpers/ads.js b/apps/app-frontend/src/helpers/ads.js
index 8c85970..96005e6 100644
--- a/apps/app-frontend/src/helpers/ads.js
+++ b/apps/app-frontend/src/helpers/ads.js
@@ -1,55 +1,32 @@
-import { invoke } from '@tauri-apps/api/core'
+/**
+ * Modrinth Enhanced does not show advertising.
+ *
+ * Upstream these helpers forward to the Tauri `ads` plugin, which spawns a
+ * second webview that loads the ad network and tracks clicks on it. Every
+ * helper below is a no-op instead, so the plugin is never asked to create
+ * that webview and the consent flow that exists purely for ad cookies never
+ * has anything to consent to.
+ *
+ * The functions are kept — rather than removed along with their callers — so
+ * that upstream call sites keep working unchanged.
+ */
-export async function init_ads_window(overrideShown = false) {
- return await invoke('plugin:ads|init_ads_window', {
- overrideShown,
- dpr: window.devicePixelRatio,
- })
-}
-
-let adsWindowHoldUpdate = Promise.resolve()
-
-async function update_ads_window_hold(acquire) {
- adsWindowHoldUpdate = adsWindowHoldUpdate
- .catch(() => {})
- .then(() =>
- invoke('plugin:ads|update_ads_window_hold', {
- acquire,
- dpr: window.devicePixelRatio,
- }),
- )
+export async function init_ads_window() {}
- return await adsWindowHoldUpdate
-}
-
-export async function take_ads_window_hold() {
- return await update_ads_window_hold(true)
-}
+export async function take_ads_window_hold() {}
-export async function release_ads_window_hold() {
- return await update_ads_window_hold(false)
-}
+export async function release_ads_window_hold() {}
-export async function hide_ads_window(reset) {
- return await invoke('plugin:ads|hide_ads_window', { reset })
-}
+export async function hide_ads_window() {}
export async function should_show_ads_consent_popup() {
- return await invoke('plugin:ads|should_show_ads_consent_popup')
+ return false
}
-export async function perform_ads_consent_action(action) {
- return await invoke('plugin:ads|perform_ads_consent_action', { action })
-}
+export async function perform_ads_consent_action() {}
-export async function open_ads_consent_preferences() {
- return await invoke('plugin:ads|open_ads_consent_preferences')
-}
+export async function open_ads_consent_preferences() {}
-export async function record_ads_click() {
- return await invoke('plugin:ads|record_ads_click')
-}
+export async function record_ads_click() {}
-export async function open_ads_link(path, origin) {
- return await invoke('plugin:ads|open_link', { path, origin })
-}
+export async function open_ads_link() {}
+451
View File
@@ -0,0 +1,451 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Mon, 14 Sep 2026 10:23:01 +0200
Subject: [PATCH] Remove telemetry
Product analytics, crash reporting and the user survey embed are all
removed, and the Modrinth analytics endpoints the launcher itself posts
to are no longer called:
* `helpers/analytics.ts` becomes an inert module. It keeps the event map
and the exported functions so that every call site still type checks
without a patch of its own, but nothing is queued or sent and the
PostHog SDK is no longer part of the bundle at all.
* `helpers/error-reporting.ts` no longer loads `@sentry/vue`; errors are
logged locally, as they are in upstream development builds.
* The third-party Tally embed is dropped from `index.html` and the
survey list is no longer fetched, so the popup that carries the
signed-in Modrinth user id never appears.
* `analytics/playtime` and `analytics/minecraft-server-play` are no
longer posted from `app-lib`. The latter also removes a Mojang session
handshake whose only purpose was to authenticate that report.
* The webview CSP no longer allows PostHog, Sentry or Tally at all, so
this is enforced rather than merely intended.
`posthog-js` and `@sentry/vue` stay in package.json. Removing them would
mean carrying a patch against the lockfile, and nothing imports them any
more, so neither ends up in a build.
Download attribution is deliberately left in place: it is a header on
downloads the user already requested and is what credits project authors
for them.
---
apps/app-frontend/index.html | 1 -
.../src/components/ui/SurveyPopup.vue | 12 ++-
.../ui/settings/account/PrivacySettings.vue | 3 +-
apps/app-frontend/src/helpers/analytics.ts | 89 ++++---------------
.../src/helpers/error-reporting.ts | 72 +++------------
.../app-frontend/src/locales/en-US/index.json | 2 +-
apps/app/tauri.conf.json | 6 +-
packages/app-lib/src/api/instance/run.rs | 83 +++--------------
8 files changed, 50 insertions(+), 218 deletions(-)
diff --git a/apps/app-frontend/index.html b/apps/app-frontend/index.html
index 6367788..9738bbc 100644
--- a/apps/app-frontend/index.html
+++ b/apps/app-frontend/index.html
@@ -11,7 +11,6 @@
<body>
<div id="app"></div>
- <script src="https://tally.so/widgets/embed.js" async></script>
<script type="module" src="/src/main.js"></script>
</body>
</html>
diff --git a/apps/app-frontend/src/components/ui/SurveyPopup.vue b/apps/app-frontend/src/components/ui/SurveyPopup.vue
index 7c2f406..6d63e0a 100644
--- a/apps/app-frontend/src/components/ui/SurveyPopup.vue
+++ b/apps/app-frontend/src/components/ui/SurveyPopup.vue
@@ -2,7 +2,6 @@
import { NotepadTextIcon, XIcon } from '@modrinth/assets'
import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { type } from '@tauri-apps/plugin-os'
-import { $fetch } from 'ofetch'
import { onMounted, onUnmounted, ref } from 'vue'
import { release_ads_window_hold, take_ads_window_hold } from '@/helpers/ads.js'
@@ -157,12 +156,11 @@ async function processPendingSurveys() {
isWithinLastTwoWeeks(instance.last_played) && !isWithinLastTwoWeeks(instance.created),
)
- let surveys: Survey[] = []
- try {
- surveys = await $fetch('https://api.modrinth.com/v2/surveys')
- } catch (e) {
- console.error('Error fetching surveys:', e)
- }
+ // Modrinth Enhanced does not show Modrinth's user surveys. They are served
+ // through a third-party Tally embed that is loaded on every start and they
+ // carry the signed-in Modrinth user id as a hidden form field, so the
+ // survey list is never fetched and the popup never appears.
+ const surveys: Survey[] = []
const surveyToShow = surveys.find(
(survey) =>
diff --git a/apps/app-frontend/src/components/ui/settings/account/PrivacySettings.vue b/apps/app-frontend/src/components/ui/settings/account/PrivacySettings.vue
index 8773f8f..d31e8c5 100644
--- a/apps/app-frontend/src/components/ui/settings/account/PrivacySettings.vue
+++ b/apps/app-frontend/src/components/ui/settings/account/PrivacySettings.vue
@@ -40,7 +40,7 @@ const messages = defineMessages({
telemetryDescription: {
id: 'app.settings.privacy.telemetry.description',
defaultMessage:
- 'Modrinth collects anonymized analytics and usage data to improve our user experience and customize your experience. By disabling this option, you opt out and your data will no longer be collected.',
+ 'Modrinth Enhanced collects no analytics or usage data and sends no crash reports, so there is nothing here to turn off.',
},
discordRichPresenceTitle: {
id: 'app.settings.privacy.discord-rich-presence.title',
@@ -97,7 +97,6 @@ watch(
{{ formatMessage(messages.telemetryDescription) }}
</p>
</div>
- <Toggle id="opt-out-analytics" v-model="settings.telemetry" />
</div>
<div class="mt-4 flex items-center justify-between gap-4">
diff --git a/apps/app-frontend/src/helpers/analytics.ts b/apps/app-frontend/src/helpers/analytics.ts
index b3d2c0a..c9f4846 100644
--- a/apps/app-frontend/src/helpers/analytics.ts
+++ b/apps/app-frontend/src/helpers/analytics.ts
@@ -1,4 +1,16 @@
-import type { PostHog } from 'posthog-js'
+/**
+ * Modrinth Enhanced does not collect analytics.
+ *
+ * Upstream this module lazily loads `posthog-js` on the first interaction and
+ * sends every event below to `posthog.modrinth.com`. Here the whole module is
+ * inert: nothing is loaded, nothing is queued and nothing is sent, which also
+ * keeps the PostHog SDK out of the bundle entirely rather than merely leaving
+ * it switched off.
+ *
+ * The event map and the exported functions are kept exactly as upstream
+ * declares them so that every `trackEvent` call site still type checks and
+ * needs no patch of its own.
+ */
interface InstanceProperties {
loader: string
@@ -43,72 +55,13 @@ type AnalyticsEventMap = {
export type AnalyticsEvent = keyof AnalyticsEventMap
-let analytics: PostHog | undefined
-let pending: Promise<void> | undefined
-let enabled = false
-let activated = false
-let debug = false
-let explicitlyOptedIn = false
-const events: Array<{ name: AnalyticsEvent; properties: Record<string, unknown> | undefined }> = []
-const allowed = import.meta.env.PROD || import.meta.env.VITE_ENABLE_ANALYTICS === 'true'
+export const initAnalytics = () => {}
-function removeActivationListeners() {
- window.removeEventListener('pointerdown', activate)
- window.removeEventListener('keydown', activate)
-}
-
-function activate() {
- activated = true
- removeActivationListeners()
- if (!enabled || pending || analytics) return
- pending = import('posthog-js')
- .then(({ posthog }) => {
- if (!enabled) return
- posthog.init('phc_9Iqi6lFs9sr5BSqh9RRNRSJ0mATS9PSgirDiX3iOYJ', {
- persistence: 'localStorage',
- api_host: 'https://posthog.modrinth.com',
- })
- analytics = posthog
- if (explicitlyOptedIn) posthog.opt_in_capturing()
- if (debug) posthog.debug()
- for (const event of events.splice(0)) posthog.capture(event.name, event.properties)
- })
- .catch(() => {
- events.length = 0
- })
- .finally(() => {
- pending = undefined
- })
-}
+export const debugAnalytics = () => {}
-export const initAnalytics = () => {
- if (!allowed || enabled) return
- enabled = true
- if (activated) activate()
- else {
- window.addEventListener('pointerdown', activate, { once: true, passive: true })
- window.addEventListener('keydown', activate, { once: true })
- }
-}
+export const optOutAnalytics = () => {}
-export const debugAnalytics = () => {
- debug = true
- analytics?.debug()
-}
-
-export const optOutAnalytics = () => {
- explicitlyOptedIn = false
- enabled = false
- events.length = 0
- removeActivationListeners()
- analytics?.opt_out_capturing()
-}
-
-export const optInAnalytics = () => {
- explicitlyOptedIn = true
- initAnalytics()
- analytics?.opt_in_capturing()
-}
+export const optInAnalytics = () => {}
type OptionalArgs<T> = Record<string, never> extends T ? [properties?: T] : [properties: T]
@@ -116,10 +69,6 @@ export const trackEvent = <E extends AnalyticsEvent>(
eventName: E,
...args: OptionalArgs<AnalyticsEventMap[E]>
) => {
- if (!enabled) return
- if (analytics) analytics.capture(eventName, args[0])
- else {
- if (events.length >= 100) events.shift()
- events.push({ name: eventName, properties: args[0] })
- }
+ void eventName
+ void args
}
diff --git a/apps/app-frontend/src/helpers/error-reporting.ts b/apps/app-frontend/src/helpers/error-reporting.ts
index bb20aa9..46a9749 100644
--- a/apps/app-frontend/src/helpers/error-reporting.ts
+++ b/apps/app-frontend/src/helpers/error-reporting.ts
@@ -1,66 +1,16 @@
import type { App } from 'vue'
import type { Router } from 'vue-router'
+/**
+ * Modrinth Enhanced does not send crash reports.
+ *
+ * Upstream this installs a Vue error handler plus `error` and
+ * `unhandledrejection` listeners that lazily load `@sentry/vue` and ship
+ * exceptions — including breadcrumbs and route traces — to Sentry. This
+ * build keeps the local behaviour of the upstream development path instead:
+ * errors are logged to the console and go nowhere else.
+ */
export function setupErrorReporting(app: App, router: Router): void {
- if (!import.meta.env.PROD) return
-
- const previousHandler = app.config.errorHandler
- let pending: Promise<typeof import('@sentry/vue')> | undefined
- let queuedErrors = 0
-
- function removeListeners() {
- window.removeEventListener('pointerdown', activate)
- window.removeEventListener('keydown', activate)
- window.removeEventListener('error', onError)
- window.removeEventListener('unhandledrejection', onRejection)
- }
-
- function load() {
- pending ??= import('@sentry/vue').then((sentry) => {
- app.config.errorHandler = previousHandler
- sentry.init({
- app,
- dsn: 'https://9508775ee5034536bc70433f5f531dd4@o485889.ingest.us.sentry.io/4504579615227904',
- integrations: [sentry.browserTracingIntegration({ router })],
- tracesSampleRate: 0.1,
- })
- removeListeners()
- return sentry
- })
- return pending
- }
-
- function capture(error: unknown) {
- if (queuedErrors >= 20) return
- queuedErrors++
- void load()
- .then((sentry) => sentry.captureException(error))
- .catch(() => {})
- .finally(() => {
- queuedErrors--
- })
- }
-
- function onError(event: ErrorEvent) {
- capture(event.error ?? event.message)
- }
-
- function onRejection(event: PromiseRejectionEvent) {
- capture(event.reason)
- }
-
- function activate() {
- void load().catch(() => {})
- }
-
- app.config.errorHandler = (error, instance, info) => {
- if (previousHandler) previousHandler(error, instance, info)
- else console.error(error)
- capture(error)
- }
- window.addEventListener('error', onError)
- window.addEventListener('unhandledrejection', onRejection)
- window.addEventListener('pointerdown', activate, { once: true, passive: true })
- window.addEventListener('keydown', activate, { once: true })
- app.onUnmount(removeListeners)
+ void app
+ void router
}
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index faab078..233cac2 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -2667,7 +2667,7 @@
"message": "Discord Rich Presence"
},
"app.settings.privacy.telemetry.description": {
- "message": "Modrinth collects anonymized analytics and usage data to improve our user experience and customize your experience. By disabling this option, you opt out and your data will no longer be collected."
+ "message": "Modrinth Enhanced collects no analytics or usage data and sends no crash reports, so there is nothing here to turn off."
},
"app.settings.privacy.telemetry.title": {
"message": "Telemetry"
diff --git a/apps/app/tauri.conf.json b/apps/app/tauri.conf.json
index e69e6b7..e5bbabe 100644
--- a/apps/app/tauri.conf.json
+++ b/apps/app/tauri.conf.json
@@ -102,12 +102,12 @@
"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://*.posthog.com https://posthog.modrinth.com https://*.sentry.io 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 https://app.getsentry.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 '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'",
- "script-src": "https://*.posthog.com https://posthog.modrinth.com https://js.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://tally.so/widgets/embed.js 'self'",
- "frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ https://js.stripe.com https://hooks.stripe.com https://*.intercom.io https://intercom-sheets.com https://www.intercom-reporting.com https://app.intercom.com 'self'",
+ "script-src": "https://js.stripe.com https://widget.intercom.io https://js.intercomcdn.com 'self'",
+ "frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://js.stripe.com https://hooks.stripe.com https://*.intercom.io https://intercom-sheets.com https://www.intercom-reporting.com https://app.intercom.com 'self'",
"media-src": "https://*.githubusercontent.com"
}
}
diff --git a/packages/app-lib/src/api/instance/run.rs b/packages/app-lib/src/api/instance/run.rs
index 25626c1..d638128 100644
--- a/packages/app-lib/src/api/instance/run.rs
+++ b/packages/app-lib/src/api/instance/run.rs
@@ -4,13 +4,10 @@ use crate::state::{
Credentials, InstanceLink, ProcessMetadata, Settings, State,
game_options_sync_is_enabled, load_game_option_preferences,
};
-use crate::util::fetch;
use crate::util::io::IOError;
use serde_json::json;
use std::collections::HashMap;
-use std::time::Duration;
use tokio::process::Command;
-use tracing::{info, warn};
#[derive(Debug, Clone)]
pub enum QuickPlayType {
@@ -232,54 +229,10 @@ async fn run_credentials(
mc_set_options.push(("fullscreen".to_string(), "true".to_string()));
}
- if let Some(project_id) = server_play_project_id(&context.link)
- && !project_id.trim().is_empty()
- {
- let server_id = uuid::Uuid::new_v4().to_string();
- let join_result = fetch::INSECURE_REQWEST_CLIENT
- .post("https://sessionserver.mojang.com/session/minecraft/join")
- .json(&json!({
- "accessToken": &credentials.access_token,
- "selectedProfile": credentials.offline_profile.id.simple().to_string(),
- "serverId": &server_id,
- }))
- .timeout(Duration::from_secs(5))
- .send()
- .await;
-
- match join_result {
- Ok(resp) if resp.status().is_success() => {
- let result = fetch::post_json(
- concat!(
- env!("MODRINTH_API_BASE_URL"),
- "analytics/minecraft-server-play"
- ),
- json!({
- "project_id": project_id,
- "username": &credentials.offline_profile.name,
- "server_id": &server_id,
- }),
- &state.api_semaphore,
- &state.pool,
- )
- .await;
-
- match result {
- Ok(()) => {
- info!(
- "Tracked server play for '{project_id}' in analytics"
- )
- }
- Err(err) => warn!("Failed to report server play: {err:?}"),
- }
- }
- Ok(resp) => warn!(
- "Failed to join Mojang session server: HTTP {}",
- resp.status()
- ),
- Err(err) => warn!("Failed to join Mojang session server: {err:?}"),
- }
- }
+ // Modrinth Enhanced does not report server plays. Upstream authenticates
+ // that report by handshaking with Mojang's session server and then sends
+ // the player's Minecraft username to Modrinth's analytics endpoint;
+ // neither request is made here.
crate::minecraft_skins::flush_pending_skin_change().await?;
crate::launcher::launch_minecraft(
@@ -297,21 +250,6 @@ async fn run_credentials(
.await
}
-fn server_play_project_id(link: &InstanceLink) -> Option<&String> {
- match link {
- InstanceLink::ServerProject { project_id }
- | InstanceLink::ServerProjectModpack {
- server_project_id: project_id,
- ..
- } => Some(project_id),
- InstanceLink::Unmanaged
- | InstanceLink::ModrinthModpack { .. }
- | InstanceLink::ModrinthHosting { .. }
- | InstanceLink::ImportedModpack { .. }
- | InstanceLink::SharedInstance { .. } => None,
- }
-}
-
pub async fn kill(instance_id: &str) -> crate::Result<()> {
let state = State::get().await?;
let processes =
@@ -373,13 +311,12 @@ pub async fn try_update_playtime_by_instance_id(
}
}
- fetch::post_json(
- concat!(env!("MODRINTH_API_BASE_URL"), "analytics/playtime"),
- serde_json::to_value(hashmap)?,
- &state.api_semaphore,
- &state.pool,
- )
- .await
+ // Modrinth Enhanced does not report playtime to Modrinth's analytics
+ // endpoint. The payload is still assembled and then dropped so that
+ // the bookkeeping below, which clears the instance's recent playtime
+ // counter, keeps behaving exactly like it does upstream.
+ drop(hashmap);
+ Ok(())
} else {
Ok(())
};
+487
View File
@@ -0,0 +1,487 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Mon, 14 Sep 2026 10:27:34 +0200
Subject: [PATCH] Add offline accounts
Adds a second way to add a Minecraft account that never talks to
Microsoft or Mojang, for playing singleplayer worlds and servers running
in offline mode.
An offline account is an ordinary row in `minecraft_users`, marked by a
sentinel in the refresh token column, so no database migration is
needed. `Credentials::refresh` and the online profile lookup both return
early for such an account, which keeps every existing code path -
launching, account switching, serialisation to the frontend - working
without further changes.
The player UUID is derived the way Minecraft itself derives it, as an
MD5 name UUID over `OfflinePlayer:<name>`. That is what vanilla servers
in offline mode and other launchers use, so worlds keep the same player
data when they are opened elsewhere.
Usernames are validated the way Mojang validates them: 3 to 16
characters of letters, numbers and underscores.
---
Cargo.toml | 1 +
.../src/components/ui/AccountsCard.vue | 25 ++++
.../src/components/ui/OfflineAccountModal.vue | 136 ++++++++++++++++++
apps/app-frontend/src/helpers/auth.js | 13 ++
apps/app/src/api/auth.rs | 7 +
packages/app-lib/Cargo.toml | 1 +
packages/app-lib/src/api/minecraft_auth.rs | 39 +++++
packages/app-lib/src/state/minecraft_auth.rs | 68 +++++++++
8 files changed, 290 insertions(+)
create mode 100644 apps/app-frontend/src/components/ui/OfflineAccountModal.vue
diff --git a/Cargo.toml b/Cargo.toml
index a4a779c..a85f576 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -132,6 +132,7 @@ lz4_flex = { version = "0.11.5", default-features = false, features = [
"std",
] }
maxminddb = "0.26.0"
+md-5 = "0.10.6"
modrinth-content-management = { path = "packages/modrinth-content-management" }
modrinth-log = { path = "packages/modrinth-log" }
modrinth-util = { path = "packages/modrinth-util" }
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
index 70cc46e..e695a6d 100644
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
@@ -9,6 +9,10 @@
<SpinnerIcon v-else class="animate-spin" />
{{ formatMessage(messages.signInToMinecraft) }}
</Button>
+ <Button @click="offlineAccountModal?.show($event)">
+ <UserIcon />
+ {{ formatMessage(messages.addOfflineAccount) }}
+ </Button>
</div>
<Accordion
v-else
@@ -80,9 +84,17 @@
<PlusIcon />
{{ formatMessage(messages.addAccount) }}
</Button>
+ <Button
+ class="w-full !bg-button-bg !text-primary ![box-shadow:var(--shadow-button)]"
+ @click="offlineAccountModal?.show($event)"
+ >
+ <UserIcon />
+ {{ formatMessage(messages.addOfflineAccount) }}
+ </Button>
</div>
</div>
</Accordion>
+ <OfflineAccountModal ref="offlineAccountModal" @created="offlineAccountCreated" />
</template>
<script setup lang="ts">
@@ -93,6 +105,7 @@ import {
RadioButtonIcon,
SpinnerIcon,
TrashIcon,
+ UserIcon,
} from '@modrinth/assets'
import {
Accordion,
@@ -106,6 +119,7 @@ import {
import type { Ref } from 'vue'
import { computed, onUnmounted, ref } from 'vue'
+import OfflineAccountModal from '@/components/ui/OfflineAccountModal.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
@@ -135,6 +149,7 @@ type MinecraftCredential = {
}
const accounts: Ref<MinecraftCredential[]> = ref([])
+const offlineAccountModal = ref<InstanceType<typeof OfflineAccountModal>>()
const loginDisabled = ref(false)
const defaultUser = ref<string | undefined>()
const equippedSkin = ref<Skin | null>(null)
@@ -247,6 +262,12 @@ async function login() {
loginDisabled.value = false
}
+async function offlineAccountCreated() {
+ // `login_offline` already marks the new account as the active one.
+ await refreshValues()
+ emit('change')
+}
+
async function logout(id: string) {
await remove_user(id).catch(handleError)
await refreshValues()
@@ -273,6 +294,10 @@ const messages = defineMessages({
id: 'minecraft-account.add-account',
defaultMessage: 'Add account',
},
+ addOfflineAccount: {
+ id: 'minecraft-account.add-offline-account',
+ defaultMessage: 'Add offline account',
+ },
removeAccount: {
id: 'minecraft-account.remove-account',
defaultMessage: 'Remove account',
diff --git a/apps/app-frontend/src/components/ui/OfflineAccountModal.vue b/apps/app-frontend/src/components/ui/OfflineAccountModal.vue
new file mode 100644
index 0000000..8300e28
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/OfflineAccountModal.vue
@@ -0,0 +1,136 @@
+<template>
+ <NewModal ref="modal" :header="formatMessage(messages.header)" max-width="480px" width="100%">
+ <div class="flex flex-col gap-4">
+ <p class="m-0 leading-tight text-secondary">
+ {{ formatMessage(messages.description) }}
+ </p>
+
+ <form class="flex flex-col gap-2" @submit.prevent="submit">
+ <label class="font-semibold text-contrast" for="offline-account-username">
+ {{ formatMessage(messages.usernameLabel) }}
+ </label>
+ <Input
+ id="offline-account-username"
+ ref="usernameInput"
+ v-model="username"
+ :icon="UserIcon"
+ :placeholder="formatMessage(messages.usernamePlaceholder)"
+ :error="!!error"
+ :maxlength="16"
+ autocapitalize="none"
+ autocorrect="off"
+ :spellcheck="false"
+ class="w-full"
+ />
+ <p v-if="error" class="m-0 text-sm leading-tight text-red">{{ error }}</p>
+ </form>
+ </div>
+
+ <template #actions>
+ <div class="flex justify-end gap-2">
+ <Button native-type="button" @click="modal?.hide()">
+ <XIcon aria-hidden="true" />
+ {{ formatMessage(commonMessages.cancelButton) }}
+ </Button>
+ <Button
+ type="colored"
+ color="brand"
+ native-type="button"
+ :disabled="submitting || !username.trim()"
+ @click="submit"
+ >
+ <SpinnerIcon v-if="submitting" aria-hidden="true" class="animate-spin" />
+ <PlusIcon v-else aria-hidden="true" />
+ {{ formatMessage(messages.addButton) }}
+ </Button>
+ </div>
+ </template>
+ </NewModal>
+</template>
+
+<script setup lang="ts">
+import { PlusIcon, SpinnerIcon, UserIcon, XIcon } from '@modrinth/assets'
+import {
+ Button,
+ commonMessages,
+ defineMessages,
+ Input,
+ NewModal,
+ useVIntl,
+} from '@modrinth/ui'
+import { nextTick, ref } from 'vue'
+
+import { login_offline } from '@/helpers/auth'
+
+const { formatMessage } = useVIntl()
+
+const emit = defineEmits<{
+ created: [account: unknown]
+}>()
+
+const modal = ref<InstanceType<typeof NewModal>>()
+const username = ref('')
+const error = ref('')
+const submitting = ref(false)
+
+function show(event?: MouseEvent) {
+ username.value = ''
+ error.value = ''
+ submitting.value = false
+ modal.value?.show(event)
+ void nextTick(() => {
+ document.getElementById('offline-account-username')?.focus()
+ })
+}
+
+async function submit() {
+ if (submitting.value) return
+
+ const name = username.value.trim()
+ if (!name) return
+
+ submitting.value = true
+ error.value = ''
+
+ try {
+ const account = await login_offline(name)
+ modal.value?.hide()
+ emit('created', account)
+ } catch (e) {
+ error.value =
+ typeof e === 'string' ? e : ((e as Error)?.message ?? formatMessage(messages.genericError))
+ } finally {
+ submitting.value = false
+ }
+}
+
+defineExpose({ show })
+
+const messages = defineMessages({
+ header: {
+ id: 'app.offline-account.header',
+ defaultMessage: 'Add an offline account',
+ },
+ description: {
+ id: 'app.offline-account.description',
+ defaultMessage:
+ 'An offline account never contacts Microsoft or Mojang. You can play singleplayer worlds and join servers running in offline mode; servers in online mode will reject it.',
+ },
+ usernameLabel: {
+ id: 'app.offline-account.username-label',
+ defaultMessage: 'Username',
+ },
+ usernamePlaceholder: {
+ id: 'app.offline-account.username-placeholder',
+ defaultMessage: '3 to 16 letters, numbers or underscores',
+ },
+ addButton: {
+ id: 'app.offline-account.add-button',
+ defaultMessage: 'Add account',
+ },
+ genericError: {
+ id: 'app.offline-account.generic-error',
+ defaultMessage: 'Could not add the offline account.',
+ },
+})
+</script>
diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js
index 94bd13e..cb7319a 100644
--- a/apps/app-frontend/src/helpers/auth.js
+++ b/apps/app-frontend/src/helpers/auth.js
@@ -33,6 +33,19 @@ export async function login() {
return await invoke('plugin:auth|login')
}
+/**
+ * Adds an offline account with the given username and makes it the active one.
+ *
+ * Offline accounts never contact Microsoft or Mojang. They can play
+ * singleplayer worlds and join servers running in offline mode.
+ *
+ * @param {string} username
+ * @returns {Promise<Credential>}
+ */
+export async function login_offline(username) {
+ return await invoke('plugin:auth|login_offline', { username })
+}
+
/**
* Retrieves the default user
* @return {Promise<UUID | undefined>}
diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs
index 8227d94..f4eded6 100644
--- a/apps/app/src/api/auth.rs
+++ b/apps/app/src/api/auth.rs
@@ -9,6 +9,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.invoke_handler(tauri::generate_handler![
check_reachable,
login,
+ login_offline,
remove_user,
get_default_user,
set_default_user,
@@ -86,6 +87,12 @@ pub async fn login<R: Runtime>(
Ok(None)
}
+/// Adds an offline account with the given username and makes it active.
+#[tauri::command]
+pub async fn login_offline(username: String) -> Result<Credentials> {
+ Ok(minecraft_auth::login_offline(&username).await?)
+}
+
#[tauri::command]
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
Ok(minecraft_auth::remove_user(user).await?)
diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml
index 69545c2..7da46c9 100644
--- a/packages/app-lib/Cargo.toml
+++ b/packages/app-lib/Cargo.toml
@@ -48,6 +48,7 @@ image = { workspace = true, features = ["gif", "jpeg", "png", "webp"] }
indicatif = { workspace = true, optional = true }
itertools = { workspace = true }
json5 = { workspace = true }
+md-5 = { workspace = true }
modrinth-content-management = { workspace = true }
notify = { workspace = true }
notify-debouncer-mini = { workspace = true }
diff --git a/packages/app-lib/src/api/minecraft_auth.rs b/packages/app-lib/src/api/minecraft_auth.rs
index e7195c6..a7fac4a 100644
--- a/packages/app-lib/src/api/minecraft_auth.rs
+++ b/packages/app-lib/src/api/minecraft_auth.rs
@@ -47,6 +47,45 @@ pub async fn finish_login(
Ok(credentials)
}
+/// Creates an offline account for `username`, or reuses the existing one, and
+/// makes it the active account.
+///
+/// Offline accounts never contact Microsoft or Mojang. They are enough to play
+/// singleplayer worlds and to join servers running in offline mode, and they
+/// are refused by servers in online mode, exactly like offline accounts in
+/// other launchers.
+#[tracing::instrument]
+pub async fn login_offline(username: &str) -> crate::Result<Credentials> {
+ let username = username.trim();
+
+ if !(3..=16).contains(&username.len())
+ || !username
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
+ {
+ return Err(crate::ErrorKind::InputError(
+ "An offline username must be 3 to 16 characters long and may only \
+ contain letters, numbers and underscores"
+ .to_string(),
+ )
+ .into());
+ }
+
+ let state = State::get().await?;
+ let credentials = Credentials::offline(username);
+ credentials.upsert(&state.pool).await?;
+
+ if let Err(error) =
+ crate::onboarding_checklist::mark_logged_into_minecraft().await
+ {
+ tracing::warn!(
+ "Failed to mark Minecraft login in onboarding checklist: {error}"
+ );
+ }
+
+ Ok(credentials)
+}
+
#[tracing::instrument]
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
let state = State::get().await?;
diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs
index b835ad4..d97d233 100644
--- a/packages/app-lib/src/state/minecraft_auth.rs
+++ b/packages/app-lib/src/state/minecraft_auth.rs
@@ -6,6 +6,7 @@ use chrono::{DateTime, Duration, TimeZone, Utc};
use dashmap::DashMap;
use futures::TryStreamExt;
use heck::ToTitleCase;
+use md5::Md5;
use p256::ecdsa::signature::Signer;
use p256::ecdsa::{Signature, SigningKey, VerifyingKey};
use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
@@ -212,6 +213,34 @@ pub struct Credentials {
pub active: bool,
}
+/// Access token handed to Minecraft for offline accounts.
+///
+/// The game only uses this token to talk to Mojang's session server, which an
+/// offline account never does, so the value just has to be non-empty. `0` is
+/// what launchers have traditionally used for offline play.
+const OFFLINE_ACCESS_TOKEN: &str = "0";
+
+/// Marker stored in an offline account's refresh token column.
+///
+/// Microsoft refresh tokens are opaque base64, so this value cannot collide
+/// with a real one, and reusing an existing column means offline accounts need
+/// no database migration.
+const OFFLINE_REFRESH_TOKEN: &str = "modrinth-enhanced:offline-account";
+
+/// Computes the player UUID Minecraft itself derives for an offline player.
+///
+/// This mirrors Java's `UUID.nameUUIDFromBytes("OfflinePlayer:<name>")`, which
+/// is what vanilla servers in offline mode and other launchers use. Matching it
+/// means a world played here keeps the same player data when it is opened from
+/// somewhere else.
+pub fn offline_uuid(username: &str) -> Uuid {
+ let mut bytes: [u8; 16] =
+ Md5::digest(format!("OfflinePlayer:{username}").as_bytes()).into();
+ bytes[6] = (bytes[6] & 0x0f) | 0x30; // Version 3
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
+ Uuid::from_bytes(bytes)
+}
+
/// An entry in the player profile cache, keyed by player UUID.
pub(super) enum ProfileCacheEntry {
/// A cached profile that is valid, even though it may be stale.
@@ -265,12 +294,45 @@ impl OnlineProfileCacheIntent {
}
impl Credentials {
+ /// Builds credentials for an offline account with the given username.
+ ///
+ /// Offline accounts hold no Microsoft tokens and have no Mojang profile
+ /// behind them; they exist so the launcher can start the game for
+ /// singleplayer worlds and offline-mode servers without signing in.
+ pub fn offline(username: &str) -> Self {
+ Self {
+ offline_profile: MinecraftProfile {
+ id: offline_uuid(username),
+ name: username.to_owned(),
+ ..MinecraftProfile::default()
+ },
+ access_token: OFFLINE_ACCESS_TOKEN.to_owned(),
+ refresh_token: OFFLINE_REFRESH_TOKEN.to_owned(),
+ // There is nothing to expire. `refresh` returns early for offline
+ // accounts, and a far future date keeps every other expiry check
+ // from doing anything surprising.
+ expires: Utc::now() + Duration::days(365 * 100),
+ active: true,
+ }
+ }
+
+ /// Whether these credentials belong to an offline account.
+ pub fn is_offline(&self) -> bool {
+ self.refresh_token == OFFLINE_REFRESH_TOKEN
+ }
+
/// Refreshes the authentication tokens for this user if they are expired, or
/// very close to expiration.
async fn refresh(
&mut self,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<()> {
+ // Offline accounts have no tokens to refresh and nothing to ask
+ // Microsoft about.
+ if self.is_offline() {
+ return Ok(());
+ }
+
// Use a margin of 5 minutes to give e.g. Minecraft and potentially
// other operations that depend on a fresh token 5 minutes to complete
// from now, and deal with some classes of clock skew
@@ -351,6 +413,12 @@ impl Credentials {
&self,
cache_intent: OnlineProfileCacheIntent,
) -> Option<Arc<MinecraftProfile>> {
+ // Offline accounts have no Mojang profile, so skip the request that
+ // would only ever fail and fall back to the offline profile.
+ if self.is_offline() {
+ return None;
+ }
+
let max_age = cache_intent.max_age();
let stale_profile = {
let mut profile_cache = PROFILE_CACHE.lock().await;
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Build Modrinth Enhanced from the patched checkout and collect the installers.
#
# Expects scripts/prepare.sh to have run. Finished bundles are copied to
# build/artifacts.
. "$(dirname "${BASH_SOURCE[0]}")/common.sh"
require_worktree
ARTIFACTS="${ARTIFACTS:-$REPO_ROOT/build/artifacts}"
# Upstream keeps the API endpoints out of the sources and picks one at build
# time; production is the only sensible choice for a release build.
log "Selecting the production environment"
cp "$WORKTREE/packages/app-lib/.env.prod" "$WORKTREE/packages/app-lib/.env"
"$REPO_ROOT/scripts/set-version.sh"
log "Installing JavaScript dependencies"
(cd "$WORKTREE" && pnpm install --frozen-lockfile)
tauri_args=()
case "$(uname -s)" in
MINGW* | MSYS* | CYGWIN* | Windows_NT)
platform=windows
tauri_args+=(--bundles nsis)
;;
Darwin)
platform=macos
tauri_args+=(--target universal-apple-darwin)
;;
*)
platform=linux
;;
esac
log "Building for $platform"
(cd "$WORKTREE" && pnpm --filter=@modrinth/app run tauri build "${tauri_args[@]}")
log "Collecting bundles into $ARTIFACTS"
rm -rf "$ARTIFACTS"
mkdir -p "$ARTIFACTS"
if [ "$platform" = macos ]; then
bundle_dir="$WORKTREE/target/universal-apple-darwin/release/bundle"
else
bundle_dir="$WORKTREE/target/release/bundle"
fi
found=0
while IFS= read -r -d '' artifact; do
cp "$artifact" "$ARTIFACTS/"
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)
[ "$found" = 1 ] || die "No bundles were produced under $bundle_dir"
ls -la "$ARTIFACTS"
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Verify that the patched checkout still is Modrinth Enhanced.
#
# A patch can apply cleanly and still stop doing its job - upstream may move
# the thing it was holding down. These checks assert the outcome rather than
# the diff: the app is named correctly, offline accounts exist, and no
# telemetry endpoint survives into the built frontend.
#
# Checks that need the built frontend are skipped when it is not there yet, so
# this is useful both before and after scripts/build.sh.
. "$(dirname "${BASH_SOURCE[0]}")/common.sh"
require_worktree
failures=0
check() {
local description="$1"
shift
if "$@" >/dev/null 2>&1; then
printf ' \033[1;32mok\033[0m %s\n' "$description"
else
printf ' \033[1;31mFAIL\033[0m %s\n' "$description"
failures=$((failures + 1))
fi
}
contains() {
grep -qF "$2" "$1"
}
missing() {
! grep -qrF "$2" "$1"
}
log "Branding"
check "tauri.conf.json is named Modrinth Enhanced" \
contains "$WORKTREE/apps/app/tauri.conf.json" '"productName": "Modrinth Enhanced"'
check "the window is titled Modrinth Enhanced" \
contains "$WORKTREE/apps/app/tauri.conf.json" '"title": "Modrinth Enhanced"'
check "the icon set was replaced" \
test -f "$WORKTREE/apps/app/icons/modrinth-enhanced.svg"
log "Offline accounts"
check "app-lib exposes login_offline" \
contains "$WORKTREE/packages/app-lib/src/api/minecraft_auth.rs" 'pub async fn login_offline'
check "the Tauri command is registered" \
contains "$WORKTREE/apps/app/src/api/auth.rs" 'login_offline,'
check "the frontend can reach it" \
contains "$WORKTREE/apps/app-frontend/src/helpers/auth.js" "plugin:auth|login_offline"
log "No telemetry in the sources"
# Quoted, so that the module names being mentioned in a comment explaining why
# they are gone does not count as importing them.
check "nothing imports posthog-js" \
missing "$WORKTREE/apps/app-frontend/src" "'posthog-js'"
check "nothing imports @sentry/vue" \
missing "$WORKTREE/apps/app-frontend/src" "'@sentry/vue'"
check "the Tally embed is gone" \
missing "$WORKTREE/apps/app-frontend/index.html" "tally.so"
check "playtime is not reported" \
missing "$WORKTREE/packages/app-lib/src/api/instance/run.rs" "analytics/playtime"
check "server plays are not reported" \
missing "$WORKTREE/packages/app-lib/src/api/instance/run.rs" "analytics/minecraft-server-play"
check "the CSP does not allow PostHog" \
missing "$WORKTREE/apps/app/tauri.conf.json" "posthog"
check "the CSP does not allow Sentry" \
missing "$WORKTREE/apps/app/tauri.conf.json" "sentry.io"
dist="$WORKTREE/apps/app-frontend/dist"
if [ -d "$dist" ]; then
log "No telemetry in the built frontend"
check "no PostHog endpoint in the bundle" missing "$dist" "posthog.modrinth.com"
check "no Sentry endpoint in the bundle" missing "$dist" "ingest.us.sentry.io"
check "no Tally embed in the bundle" missing "$dist" "tally.so"
else
warn "Skipping bundle checks: $dist does not exist yet"
fi
artifacts="${ARTIFACTS:-$REPO_ROOT/build/artifacts}"
if [ -d "$artifacts" ] && [ -n "$(ls -A "$artifacts" 2>/dev/null)" ]; then
log "Installers"
for artifact in "$artifacts"/*; do
name="$(basename "$artifact")"
case "$name" in
"Modrinth Enhanced"* | ModrinthEnhanced* | "Modrinth_Enhanced"*)
printf ' \033[1;32mok\033[0m %s\n' "$name"
;;
*)
printf ' \033[1;31mFAIL\033[0m %s is not named after Modrinth Enhanced\n' "$name"
failures=$((failures + 1))
;;
esac
done
fi
if [ "$failures" -gt 0 ]; then
die "$failures check(s) failed"
fi
log "All checks passed"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Shared settings for every script in this repository.
#
# Source this, do not run it.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Where the Modrinth App sources come from, and which release of them the
# patches in this repository are written against.
UPSTREAM_REPO="${UPSTREAM_REPO:-https://github.com/modrinth/code.git}"
UPSTREAM_REF="${UPSTREAM_REF:-$(tr -d '[:space:]' <"$REPO_ROOT/upstream.txt")}"
# The patched checkout. Everything in here is disposable: it is recreated from
# the upstream tag plus patches/ and is never committed to this repository.
WORKTREE="${WORKTREE:-$REPO_ROOT/build/upstream}"
# Branch the patches are applied on top of the upstream tag as.
PATCH_BRANCH=enhanced
PATCH_DIR="$REPO_ROOT/patches"
log() {
printf '\033[1;32m==>\033[0m %s\n' "$*"
}
warn() {
printf '\033[1;33m==>\033[0m %s\n' "$*" >&2
}
die() {
printf '\033[1;31m==>\033[0m %s\n' "$*" >&2
exit 1
}
# Version the built app reports, derived from the upstream tag it is built
# from: `v0.20.5` becomes `0.20.5`.
app_version() {
printf '%s\n' "${UPSTREAM_REF#v}"
}
require_worktree() {
[ -d "$WORKTREE/.git" ] || die "No patched checkout at $WORKTREE. Run scripts/prepare.sh first."
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Regenerate patches/ from the commits in build/upstream.
#
# Run this after changing anything in the patched checkout: every commit on top
# of the upstream tag becomes one patch file, and patch files that no longer
# have a commit behind them are deleted.
. "$(dirname "${BASH_SOURCE[0]}")/common.sh"
require_worktree
if [ -n "$(git -C "$WORKTREE" status --porcelain)" ]; then
die "$WORKTREE has uncommitted changes. Commit them first: each commit becomes one patch."
fi
count="$(git -C "$WORKTREE" rev-list --count "$UPSTREAM_REF..HEAD")"
[ "$count" -gt 0 ] || die "No commits on top of $UPSTREAM_REF to export."
log "Exporting $count patches"
rm -f "$PATCH_DIR"/*.patch
git -C "$WORKTREE" format-patch \
--binary \
--zero-commit \
--no-signature \
--no-numbered \
--output-directory "$PATCH_DIR" \
"$UPSTREAM_REF..HEAD"
log "patches/ now contains:"
ls -1 "$PATCH_DIR"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Print the newest Modrinth App release tag upstream has published.
#
# With --write, also store it in upstream.txt. Exits with status 0 either way;
# compare the output with upstream.txt to find out whether anything moved.
. "$(dirname "${BASH_SOURCE[0]}")/common.sh"
latest="$(
git ls-remote --tags --refs "$UPSTREAM_REPO" 'v*' |
sed 's#.*refs/tags/##' |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
sort -V |
tail -1
)"
[ -n "$latest" ] || die "Could not determine the latest upstream release tag"
if [ "${1:-}" = --write ]; then
printf '%s\n' "$latest" >"$REPO_ROOT/upstream.txt"
fi
printf '%s\n' "$latest"
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Check out the pinned Modrinth App release and apply every patch on top of it.
#
# The result lands in build/upstream on the `enhanced` branch and is what all
# other scripts build from. Running this again always starts from a clean
# upstream tree, so it is safe to repeat.
. "$(dirname "${BASH_SOURCE[0]}")/common.sh"
log "Upstream $UPSTREAM_REF from $UPSTREAM_REPO"
if [ ! -d "$WORKTREE/.git" ]; then
log "Cloning into $WORKTREE"
mkdir -p "$(dirname "$WORKTREE")"
git clone --depth 1 --branch "$UPSTREAM_REF" "$UPSTREAM_REPO" "$WORKTREE"
else
log "Fetching $UPSTREAM_REF into the existing checkout"
git -C "$WORKTREE" remote set-url origin "$UPSTREAM_REPO"
git -C "$WORKTREE" fetch --depth 1 --force origin "refs/tags/$UPSTREAM_REF:refs/tags/$UPSTREAM_REF"
fi
# `git am` refuses to run with a rebase or merge in progress, and a previous
# run may have stopped on a conflict.
git -C "$WORKTREE" am --abort 2>/dev/null || true
log "Resetting to $UPSTREAM_REF"
git -C "$WORKTREE" checkout --detach --force "$UPSTREAM_REF"
git -C "$WORKTREE" branch -f "$PATCH_BRANCH" "$UPSTREAM_REF"
git -C "$WORKTREE" checkout --force "$PATCH_BRANCH"
git -C "$WORKTREE" reset --hard "$UPSTREAM_REF"
git -C "$WORKTREE" clean -fdx -e node_modules -e target
# `git am` needs an identity for the commits it creates.
git -C "$WORKTREE" config user.name "Modrinth Enhanced"
git -C "$WORKTREE" config user.email "patches@modrinth-enhanced.invalid"
git -C "$WORKTREE" config commit.gpgsign false
shopt -s nullglob
patches=("$PATCH_DIR"/*.patch)
shopt -u nullglob
[ ${#patches[@]} -gt 0 ] || die "No patches found in $PATCH_DIR"
log "Applying ${#patches[@]} patches"
if ! git -C "$WORKTREE" am --3way --whitespace=nowarn "${patches[@]}"; then
cat >&2 <<EOF
A patch did not apply to $UPSTREAM_REF.
The failed patch is left staged in $WORKTREE so it can be fixed by hand:
cd $WORKTREE
git status # see the conflicts
# ...resolve them, then:
git add -A && git am --continue
# once every patch is in:
$REPO_ROOT/scripts/export-patches.sh
EOF
exit 1
fi
log "Patched checkout ready at $WORKTREE"
git -C "$WORKTREE" --no-pager log --oneline "$UPSTREAM_REF..$PATCH_BRANCH"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Stamp the build version into the patched checkout.
#
# Upstream leaves `1.0.0-local` in the sources and injects the real version in
# its release workflow; this does the same. Without an argument the version is
# derived from the upstream tag in upstream.txt.
. "$(dirname "${BASH_SOURCE[0]}")/common.sh"
require_worktree
version="${1:-$(app_version)}"
version="${version#v}"
log "Setting version to $version"
python3 - "$WORKTREE" "$version" <<'PY'
import json
import pathlib
import re
import sys
worktree = pathlib.Path(sys.argv[1])
version = sys.argv[2]
for relative in ("apps/app/Cargo.toml", "packages/app-lib/Cargo.toml"):
path = worktree / relative
text = path.read_text(encoding="utf-8")
# Only the `version` key of the leading [package] table, never a dependency.
patched, count = re.subn(
r'(?m)^version = "[^"]*"$', f'version = "{version}"', text, count=1
)
if count != 1:
sys.exit(f"Could not find a package version in {relative}")
path.write_text(patched, encoding="utf-8")
print(f" {relative}")
path = worktree / "apps/app-frontend/package.json"
data = json.loads(path.read_text(encoding="utf-8"))
data["version"] = version
# Keep the file byte-compatible with the tab indentation upstream uses so that
# `prettier --check` stays happy.
path.write_text(json.dumps(data, indent="\t", ensure_ascii=False) + "\n", encoding="utf-8")
print(" apps/app-frontend/package.json")
PY
+1
View File
@@ -0,0 +1 @@
v0.20.5