feat: add patch series, build scripts and release automation
This commit is contained in:
1 parent
8f4e5e0521
commit
02e18e3765
18 files changed
+23026
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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/*
|
||||
@@ -0,0 +1,2 @@
|
||||
# Everything under build/ is regenerated from upstream.txt plus patches/.
|
||||
/build/
|
||||
@@ -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
@@ -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() {}
|
||||
@@ -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(())
|
||||
};
|
||||
@@ -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;
|
||||
Executable
+61
@@ -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"
|
||||
Executable
+102
@@ -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"
|
||||
Executable
+45
@@ -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."
|
||||
}
|
||||
Executable
+30
@@ -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"
|
||||
Executable
+23
@@ -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"
|
||||
Executable
+64
@@ -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"
|
||||
Executable
+45
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
v0.20.5
|
||||
Reference in new issue
Block a user