29 Commits
Author SHA1 Message Date
Felitendo 76c250aa2f build: rebase patches onto new upstream releases 2026-09-15 15:02:13 +02:00
Felitendo 7e02e6fa9b chore: update readme 2026-09-15 14:56:59 +02:00
Felitendo 3f81e125f1 feat: use the desktop's file picker on linux 2026-09-15 14:52:50 +02:00
Felitendo 344d50e1f6 build: rebase patches onto v0.21.2 2026-09-15 14:52:50 +02:00
Felitendo dba5d2906d docs: describe the new patches 2026-09-15 13:57:16 +02:00
Felitendo 478867172a build: turn off turbo's local cache 2026-09-15 13:56:25 +02:00
Felitendo 0c9a44bd59 chore: check new patches and plugin permissions 2026-09-15 13:56:25 +02:00
Felitendo cd8bc9d1af feat: browse skins from other sites 2026-09-15 13:56:25 +02:00
Felitendo 1b4d43c699 feat: explain game crashes 2026-09-15 13:56:25 +02:00
Felitendo 399f069bfc feat: launch a running instance again 2026-09-15 13:56:25 +02:00
Felitendo 3d15cfd11a feat: show every player's skin on offline servers 2026-09-15 13:56:25 +02:00
Felitendo f398c24183 feat: manage ely.by skins in the app 2026-09-15 13:56:25 +02:00
Felitendo 29335b8418 feat: confirm that the sidebar state is saved 2026-09-15 13:56:25 +02:00
Felitendo 7b53b324a1 fix: autoscroll through nested scrollers and past webkit repaint lag 2026-09-14 23:29:37 +02:00
Felitendo af66b2ffe5 docs: forbid ai attribution in commits 2026-09-14 23:01:54 +02:00
Felitendo d93955cbce ci: release revisions of an upstream version 2026-09-14 20:05:08 +02:00
Felitendo 109171e355 feat: rounded window corners and middle-click autoscroll 2026-09-14 20:05:07 +02:00
Felitendo 46d7c792be fix: set ARCH for appimagetool 2026-09-14 20:05:07 +02:00
Felitendo 4c2cdbc4f0 fix: allow the new auth commands in the tauri acl 2026-09-14 18:01:57 +02:00
Felitendo 65d04fb282 fix: skip linuxdeploy's strip pass and clear artifacts first 2026-09-14 15:33:04 +02:00
Felitendo 0a71af9b94 fix: route the onboarding checklist at the account chooser 2026-09-14 14:48:32 +02:00
Felitendo 9f8f11f424 feat: browser microsoft sign-in, reachable offline and ely.by login 2026-09-14 14:44:42 +02:00
Felitendo 61a9b53af6 feat: note that the servers switch is not synced 2026-09-14 14:28:35 +02:00
Felitendo 511a799fb6 fix: unpack linuxdeploy instead of mounting it 2026-09-14 14:23:00 +02:00
Felitendo 70fc115846 feat: always show the right sidebar fold button, default servers on 2026-09-14 14:22:44 +02:00
Felitendo 4ac177a6fa revert: drop the custom icon, keep upstream's 2026-09-14 13:35:04 +02:00
Felitendo d0528cc1b8 feat: add ely.by accounts 2026-09-14 13:26:06 +02:00
Felitendo d7fdb05329 docs: mirror CLAUDE.md as AGENTS.md 2026-09-14 13:14:21 +02:00
Felitendo e1ec2068e6 feat: redesign icon, hide servers in sidebar, collapse news 2026-09-14 13:14:21 +02:00
29 changed files with 11800 additions and 25429 deletions

No files matched your search

+44 -18
View File
@@ -16,8 +16,8 @@ on:
description: Build this upstream tag instead of the newest one description: Build this upstream tag instead of the newest one
type: string type: string
required: false required: false
force: revision:
description: Release even if this version was already released description: Release the current patches again on the upstream release in upstream.txt, as v1.2.3-2, v1.2.3-3, ...
type: boolean type: boolean
default: false default: false
@@ -33,30 +33,55 @@ jobs:
name: Detect name: Detect
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
upstream: ${{ steps.check.outputs.upstream }}
tag: ${{ steps.check.outputs.tag }} tag: ${{ steps.check.outputs.tag }}
proceed: ${{ steps.check.outputs.proceed }} proceed: ${{ steps.check.outputs.proceed }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Find the newest upstream release - name: Decide what to release
id: check id: check
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
REQUESTED: ${{ inputs.upstream-ref }} REQUESTED: ${{ inputs.upstream-ref }}
FORCE: ${{ inputs.force }} REVISION: ${{ inputs.revision }}
run: | run: |
set -euo pipefail set -euo pipefail
tag="${REQUESTED:-$(scripts/latest-upstream.sh)}"
current="$(tr -d '[:space:]' < upstream.txt)" current="$(tr -d '[:space:]' < upstream.txt)"
echo "tag=$tag" >> "$GITHUB_OUTPUT" if [ "$REVISION" = true ]; then
echo "Newest upstream release: $tag (we are on $current)" # A revision rebuilds what we already ship, not whatever is newest.
upstream="${REQUESTED:-$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 else
echo "proceed=true" >> "$GITHUB_OUTPUT" upstream="${REQUESTED:-$(scripts/latest-upstream.sh)}"
fi fi
echo "upstream=$upstream" >> "$GITHUB_OUTPUT"
echo "Upstream release: $upstream (we are on $current)"
if ! gh release view "$upstream" >/dev/null 2>&1; then
tag="$upstream"
elif [ "$REVISION" = true ]; then
# The first release of an upstream version is its plain tag, which
# counts as revision 1.
last="$(
git ls-remote --tags --refs origin "$upstream-*" |
sed 's#.*refs/tags/##' |
awk -v prefix="$upstream-" 'index($0, prefix) == 1 {
n = substr($0, length(prefix) + 1)
if (n ~ /^[0-9]+$/) print n
}' |
sort -n |
tail -1
)"
tag="$upstream-$(( ${last:-1} + 1 ))"
else
echo "$upstream has already been released; nothing to do."
echo "proceed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "proceed=true" >> "$GITHUB_OUTPUT"
echo "Releasing $tag"
build: build:
name: Build name: Build
@@ -64,7 +89,7 @@ jobs:
if: needs.detect.outputs.proceed == 'true' if: needs.detect.outputs.proceed == 'true'
uses: ./.github/workflows/build.yml uses: ./.github/workflows/build.yml
with: with:
upstream-ref: ${{ needs.detect.outputs.tag }} upstream-ref: ${{ needs.detect.outputs.upstream }}
release: release:
name: Release name: Release
@@ -75,17 +100,17 @@ jobs:
- name: Record the upstream release we build against - name: Record the upstream release we build against
env: env:
TAG: ${{ needs.detect.outputs.tag }} UPSTREAM: ${{ needs.detect.outputs.upstream }}
run: | run: |
set -euo pipefail set -euo pipefail
printf '%s\n' "$TAG" > upstream.txt printf '%s\n' "$UPSTREAM" > upstream.txt
if git diff --quiet -- upstream.txt; then if git diff --quiet -- upstream.txt; then
echo "upstream.txt already points at $TAG" echo "upstream.txt already points at $UPSTREAM"
exit 0 exit 0
fi fi
git config user.name 'github-actions[bot]' git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git commit -m "Track upstream $TAG" -- upstream.txt git commit -m "Track upstream $UPSTREAM" -- upstream.txt
git push origin HEAD:${{ github.event.repository.default_branch }} git push origin HEAD:${{ github.event.repository.default_branch }}
- name: Download installers - name: Download installers
@@ -98,11 +123,12 @@ jobs:
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.detect.outputs.tag }} TAG: ${{ needs.detect.outputs.tag }}
UPSTREAM: ${{ needs.detect.outputs.upstream }}
run: | run: |
set -euo pipefail set -euo pipefail
ls -la artifacts ls -la artifacts
notes="$(cat <<EOF notes="$(cat <<EOF
Modrinth Enhanced built from [Modrinth App $TAG](https://github.com/modrinth/code/releases/tag/$TAG). Modrinth Enhanced $TAG, built from [Modrinth App $UPSTREAM](https://github.com/modrinth/code/releases/tag/$UPSTREAM).
Same app as upstream, with the patches in \`patches/\` applied: Same app as upstream, with the patches in \`patches/\` applied:
+14
View File
@@ -0,0 +1,14 @@
# AGENTS.md
## Style
Keep everything short — replies, explanations, comments, docs.
## Commits
- English only.
- Conventional Commits prefix: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `ci:`, `build:`.
- Subject as short as possible. Imperative, lowercase, no trailing period.
- Body only when something genuinely cannot be inferred from the diff.
- Never add `Co-Authored-By`, "Generated with" or any other AI attribution to commits or PR descriptions.
- Exception: the commits in `build/upstream` that `patches/` is exported from. Their subjects become the patch file names, so they stay plain and descriptive.
+1
View File
@@ -10,4 +10,5 @@ Keep everything short — replies, explanations, comments, docs.
- Conventional Commits prefix: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `ci:`, `build:`. - Conventional Commits prefix: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `ci:`, `build:`.
- Subject as short as possible. Imperative, lowercase, no trailing period. - Subject as short as possible. Imperative, lowercase, no trailing period.
- Body only when something genuinely cannot be inferred from the diff. - Body only when something genuinely cannot be inferred from the diff.
- Never add `Co-Authored-By`, "Generated with" or any other AI attribution to commits or PR descriptions.
- Exception: the commits in `build/upstream` that `patches/` is exported from. Their subjects become the patch file names, so they stay plain and descriptive. - Exception: the commits in `build/upstream` that `patches/` is exported from. Their subjects become the patch file names, so they stay plain and descriptive.
+122 -17
View File
@@ -1,7 +1,7 @@
# Modrinth Enhanced # Modrinth Enhanced
The [Modrinth App](https://github.com/modrinth/code), without advertising, without telemetry, and The [Modrinth App](https://github.com/modrinth/code), without advertising, without telemetry, and
with offline accounts. with offline and Ely.by accounts, a skins browser and tons of fixes for Linux.
Everything else is deliberately left alone. This repository holds no forked source code — only a 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 series of patches that are applied to an upstream release tag, built, and published. Whenever
@@ -13,11 +13,21 @@ works.
| Patch | What it does | | 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. | | `0001-Rename-the-app-to-Modrinth-Enhanced` | Product name, binary name, window title and version label. |
| `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. | | `0002-Remove-advertising-and-...` | The sidebar ad slot, both "Upgrade to Modrinth+" prompts and the ad cookie consent prompt. The ad webview is never created. |
| `0003-Remove-advertising` | The sidebar ad slot, the "Upgrade to Modrinth+" nag and the ad cookie consent prompt. The ad webview is never created. | | `0003-Remove-telemetry` | PostHog analytics, Sentry crash reporting, the Tally survey embeds, and the playtime and server-play reports the launcher sends to Modrinth. |
| `0004-Remove-telemetry` | PostHog analytics, Sentry crash reporting, the Tally survey embed, and the playtime and server-play reports the launcher sends to Modrinth. | | `0004-Add-offline-accounts` | A way to add a Minecraft account that never contacts Microsoft or Mojang. |
| `0005-Add-offline-accounts` | A second way to add a Minecraft account that never contacts Microsoft or Mojang. | | `0005-Make-the-sidebars-foldable` | A switch for the Modrinth Servers button, a news section that folds away, and a title bar button that folds the right sidebar away. |
| `0006-Add-Ely.by-accounts` | Sign in with Ely.by, launched through authlib-injector. |
| `0007-Sign-in-to-Microsoft-in-the-...` | Microsoft sign-in happens in your own browser instead of a webview, so your password manager works. |
| `0008-Round-the-window-corners-on-Linux` | The undecorated window gets rounded corners on Linux. |
| `0009-Scroll-with-the-middle-mouse-button` | Middle-click autoscroll on Linux and macOS, as browsers do it on Windows. |
| `0010-Manage-Ely.by-skins-from-...` | An Ely.by account's skins can be picked, uploaded, switched between models and deleted on the skin page. |
| `0011-Show-every-player-s-skin-...` | Players who have a skin show it on offline-mode servers, and skins can be put in a folder by hand. |
| `0012-Launch-a-running-instance-...` | A running instance can be started again on another account, with a console per copy. |
| `0013-Explain-what-went-wrong-...` | The Logs tab says what a crash was and offers a fix where there is one, without a connection. |
| `0014-Browse-skins-from-...` | A Browse tab on the skin page: Ely.by's catalogue in the app, and NameMC, laby.net and crafty.gg in a window. |
| `0015-Use-the-desktop-s-file-picker-...` | File pickers on Linux are the desktop's own, such as KDE's, through the XDG desktop portal. |
### Offline accounts ### Offline accounts
@@ -29,7 +39,101 @@ The player UUID is derived exactly the way Minecraft itself derives it — an MD
launcher. Offline accounts can play singleplayer and join servers running in offline mode. Servers 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. in online mode reject them, as they do in every other launcher.
Microsoft sign-in is untouched and still the default. Both sit next to "Sign in to Microsoft" everywhere an account can be added: the account card, the
modal you get when pressing Play with no account, and "Sign in to Minecraft" in the getting started
checklist. Upstream offered Microsoft and nothing else at all three.
### Microsoft sign-in
Microsoft sign-in opens your own browser rather than a webview inside the launcher, so your
password manager, autofill and passkeys work, and you can see in the address bar that the page is
really Microsoft's.
Microsoft cannot hand the result back: the client id the launcher uses is Minecraft's own, whose
only registered redirect is a fixed page on `login.live.com`, with no loopback address for the
launcher to listen on. So the browser lands on that page with the code in the address and you copy
the address into the launcher. The webview is still one click away for anyone the browser does not
work out for.
### Ely.by accounts
"Add Ely.by account" sits in the same account card. It asks for an Ely.by account name or email and
a password, which go to `authserver.ely.by` and nowhere else. With two-factor authentication on,
append the current code to the password after a colon, which is Ely.by's own convention.
At launch the game is pointed at Ely.by with
[authlib-injector](https://github.com/yushijinhun/authlib-injector), downloaded once and cached, so
such an account can play singleplayer and join any server that accepts Ely.by.
The account is stored in the same table as every other one, marked by the client token Ely.by
issues; the token pair is checked and renewed against Ely.by a few times a day rather than on every
read of the account list.
Signing in on Ely.by's own page instead of in this form would be better, and needs an OAuth
application registered with Ely.by — one has not been registered for Modrinth Enhanced.
### Sidebar and news
The Modrinth Servers button in the left sidebar can be switched off under
Settings > Features > Sidebar.
The news section in the right sidebar folds away by clicking its heading. The right sidebar itself
folds away with the arrow button in the title bar, which upstream only shows once "Hide right
sidebar" is turned on in settings. Both remember what they were set to across restarts, and the
title bar button briefly shows a check once its state is saved. On pages that need the sidebar, such
as the mod browser, the button stays in place, greyed out.
None of the three reach Modrinth. Preference syncing maps a fixed list of named fields in both
directions and these are not in it, so they are neither sent to your Modrinth account nor
overwritten by another device.
### Window
On Linux the window has rounded corners while it floats. Maximized, fullscreen or with native
decorations turned on, it is square as before. The window is created transparent for this, which
needs a compositor; without one the corners show black.
A click with the middle mouse button on anything that scrolls starts autoscroll: press and release
to scroll until the next click, or hold and drag to scroll until you let go. Links and text fields
keep their middle-click. Windows is left alone, since WebView2 autoscrolls by itself.
File pickers on Linux go through the XDG desktop portal, so KDE shows its own dialog and GNOME its
own, instead of a GTK dialog the AppImage themes as light Adwaita. Without a portal, GTK's dialog is
used as before. Windows and macOS already use their native pickers.
### Skins
With an Ely.by account selected, the skin page shows the account's skins on Ely.by: apply one, add
one from a file, switch its model or delete it. Ely.by has no API for changing skins, so the
launcher makes the website's own calls from a hidden window and asks you to sign in there once.
On servers that send no skins, such as offline-mode servers, the game looks each player's skin up by
name: first in the `player_skins` folder in the launcher directory (`<name>.png`, `<name>-slim.png`,
`capes/`, `elytras/`), then Ely.by, then Mojang, with capes from OptiFine. Settings > Features > Skins
has the switch and a button that opens the folder.
The Browse tab finds skins elsewhere. Ely.by's catalogue is browsed in the app, with its sorting,
filters and like, wearer and view counts. NameMC, laby.net and crafty.gg open in a window of the app
instead, because their skin lists are bot-protected or not meant for other programs; the skin page
you open there is previewed and can be added.
### Instances and crashes
A running instance can be started again from the button next to Stop, as whichever account is
selected, and the Logs tab then shows a console for each copy.
After a crash the Logs tab reads the crash report, the JVM error file and the end of the log, and
says what went wrong: out of memory, the wrong Java, missing or duplicate mods and more.
Ely.by skin management, skins for every player, second copies and crash explanations are adapted
from [Noctrinth](https://github.com/Everelsu/Noctrinth).
### Modrinth+
Nothing in the app is gated behind Modrinth+. In upstream it decides whether the ad slot, the
consent prompt and the two "Upgrade to Modrinth+" prompts are shown, and nothing else — so removing
the advertising is the whole of it, and there is nothing further to unlock from here. Badges and
everything else a subscription buys are decided on Modrinth's servers.
### What is *not* removed ### What is *not* removed
@@ -77,15 +181,12 @@ cd ../..
scripts/export-patches.sh # rewrite patches/ from those commits scripts/export-patches.sh # rewrite patches/ from those commits
``` ```
The icons are the one thing that is generated rather than written. Edit Patches are applied to the release they were exported against (`patches/base.txt`), where they
`build/upstream/apps/app/icons/modrinth-enhanced.svg`, run `scripts/render-icons.py`, and every PNG, always fit, and then rebased onto the release in `upstream.txt` when that is newer. The rebase
`.ico` and `.icns` next to it is rewritten from that source; rendering the unchanged source again merges against the files the patches were written for, so upstream changes near a hunk resolve by
reproduces the current files byte for byte. themselves. When one genuinely conflicts, `scripts/prepare.sh` stops with the rebase in progress in
`build/upstream`, to be resolved with `git rebase --continue`, after which
Patches are applied with `git am --3way`, so small upstream movements around a hunk resolve by `scripts/export-patches.sh` writes the fixed series back against the new release.
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: To move to a newer upstream release:
@@ -100,9 +201,13 @@ scripts/prepare.sh
reusable workflow the release job calls. It applies the patches, checks them, and builds on reusable workflow the release job calls. It applies the patches, checks them, and builds on
Linux, Windows and macOS. Linux, Windows and macOS.
- **Upstream release** (`.github/workflows/upstream-release.yml`) runs daily. If Modrinth has - **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 published a newer release than `upstream.txt`, it rebases the patches onto it, rebuilds and — only if every
platform built and every check passed — commits the bump, tags it with the upstream version and platform built and every check passed — commits the bump, tags it with the upstream version and
publishes a release with the installers. publishes a release with the installers.
- **Revisions** of the same upstream release are published by running Upstream release by hand
with `revision` ticked. It builds the upstream release in `upstream.txt` again with the current
patches and publishes it as `v0.21.2-2`, `v0.21.2-3` and so on. The app and installers still
carry the upstream version: RPM and the Windows installers do not accept a suffix in it.
`scripts/check.sh` is what makes the automation trustworthy. A patch can apply cleanly and still `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 stop doing its job if upstream moves the thing it was holding down, so the checks assert the
@@ -4,22 +4,23 @@ Date: Mon, 14 Sep 2026 09:56:18 +0200
Subject: [PATCH] Rename the app to Modrinth Enhanced Subject: [PATCH] Rename the app to Modrinth Enhanced
Changes the product name, the binary name, the window title and the Changes the product name, the binary name, the window title and the
version label shown in settings. A small "Enhanced" pill is added next version label shown in settings.
to the wordmark in the title bar so the fork is recognisable at a
glance. The title bar keeps the plain Modrinth wordmark: a badge next to it
pushes the width of that bar up, and the name is already visible in the
window title and in settings.
The bundle identifier is deliberately left as `ModrinthApp` so that The bundle identifier is deliberately left as `ModrinthApp` so that
Modrinth Enhanced keeps using the same data directory as the official Modrinth Enhanced keeps using the same data directory as the official
app and stays a drop-in replacement for it. app and stays a drop-in replacement for it.
--- ---
apps/app-frontend/index.html | 2 +- apps/app-frontend/index.html | 2 +-
apps/app-frontend/src/App.vue | 5 +++++
.../src/components/ui/modal/AppSettingsModal.vue | 2 +- .../src/components/ui/modal/AppSettingsModal.vue | 2 +-
apps/app-frontend/src/locales/en-US/index.json | 2 +- apps/app-frontend/src/locales/en-US/index.json | 2 +-
apps/app/tauri.conf.json | 6 +++--- apps/app/tauri.conf.json | 6 +++---
apps/app/tauri.linux.conf.json | 2 +- apps/app/tauri.linux.conf.json | 2 +-
apps/app/tauri.macos.conf.json | 2 +- apps/app/tauri.macos.conf.json | 2 +-
7 files changed, 13 insertions(+), 8 deletions(-) 6 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/apps/app-frontend/index.html b/apps/app-frontend/index.html diff --git a/apps/app-frontend/index.html b/apps/app-frontend/index.html
index 50867a4..6367788 100644 index 50867a4..6367788 100644
@@ -34,24 +35,8 @@ index 50867a4..6367788 100644
<link rel="stylesheet" href="/src/assets/stylesheets/global.scss" /> <link rel="stylesheet" href="/src/assets/stylesheets/global.scss" />
</head> </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 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 index 5f863d5..32a86a3 100644
--- a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue --- a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue
+++ b/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({ @@ -312,7 +312,7 @@ const messages = defineMessages({
@@ -64,10 +49,10 @@ index dd55fe5..0f33d0e 100644
macos: { macos: {
id: 'app.settings.operating-system.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 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 index e81abe7..ba08ca7 100644
--- a/apps/app-frontend/src/locales/en-US/index.json --- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -1548,7 +1548,7 @@ @@ -1626,7 +1626,7 @@
"message": "Oldest" "message": "Oldest"
}, },
"app.settings.app-version": { "app.settings.app-version": {
@@ -0,0 +1,235 @@
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 and Modrinth+ upsells
The sidebar ad slot, the ad cookie consent prompt and both "Upgrade to
Modrinth+" prompts are gone:
* `showAd` and `adConsentAvailable` are pinned to false, which takes the
ad slot and the consent prompt out of the layout.
* The upgrade link above the ad slot and the entry in the account menu
are removed outright rather than left behind a false condition.
* The sidebar keeps upstream's `has-plus` class unconditionally, which
is what stops space being reserved at the bottom for an ad.
* With no upsell left to decide about, the request that asked Modrinth
whether this account has Modrinth+ goes too.
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.
Nothing else in the app is gated behind Modrinth+: it decided whether
ads and these prompts were shown, and nothing more.
---
apps/app-frontend/src/App.vue | 55 ++++-------------------
apps/app-frontend/src/helpers/ads.js | 65 +++++++++-------------------
2 files changed, 29 insertions(+), 91 deletions(-)
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index 5faea7c..447f636 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -9,7 +9,6 @@ import {
VerboseLoggingFeature,
} from '@modrinth/api-client'
import {
- ArrowBigUpDashIcon,
ArrowLeftRightIcon,
ChevronLeftIcon,
ChevronRightIcon,
@@ -94,7 +93,6 @@ import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
import OnboardingChecklist from '@/components/ui/onboarding-checklist/index.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
-import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SharedInstanceInviteHandler from '@/components/ui/shared-instances/shared-instance-invite-handler/index.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
@@ -159,7 +157,6 @@ import {
syncedServersQueryOptions,
} from '@/helpers/synced-options'
import { syncedPackQueryOptions } from '@/helpers/synced-packs'
-import { hasActivePride26Midas, hasMidasBadge } from '@/helpers/user-campaigns.ts'
import { get_user_preferences } from '@/helpers/user-preferences.ts'
import { parse_modrinth_user_link } from '@/helpers/users'
import {
@@ -344,12 +341,6 @@ const tauriApiClient = new TauriModrinthClient({
],
})
provideModrinthClient(tauriApiClient)
-const { data: authenticatedModrinthUser } = useQuery({
- queryKey: computed(() => ['authenticated-user', 'campaigns', credentials.value?.user?.id]),
- queryFn: () => tauriApiClient.labrinth.users_v3.getAuthenticated(),
- enabled: () => !!credentials.value?.session,
- retry: false,
-})
useQuery({
queryKey: computed(() => instanceKeys.sharedEligibility(credentials.value?.user?.id)),
queryFn: can_current_user_use_shared_instances,
@@ -360,16 +351,13 @@ useQuery({
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
-const hasPlus = computed(
- () =>
- !!credentials.value?.user &&
- (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.
+// The sidebar keeps upstream's `has-plus` class unconditionally for the same
+// reason: that class is what stops space being reserved at the bottom for an
+// ad and the gradient being drawn above it.
+const showAd = computed(() => false)
+const adConsentAvailable = computed(() => false)
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: showAd,
@@ -694,10 +682,6 @@ const messages = defineMessages({
id: 'app.restarting',
defaultMessage: 'Restarting...',
},
- upgradeToModrinthPlus: {
- id: 'app.nav.upgrade-to-modrinth-plus',
- defaultMessage: 'Upgrade to Modrinth+',
- },
news: {
id: 'app.news.title',
defaultMessage: 'News',
@@ -1529,16 +1513,6 @@ const modrinthAccountMenuOptions = computed(() => [
icon: UserIcon,
action: () => router.push(`/user/${encodeURIComponent(credentials.value.user.username)}`),
},
- {
- id: 'plus',
- label: formatMessage(messages.upgradeToModrinthPlus),
- icon: ArrowBigUpDashIcon,
- type: 'link',
- href: 'https://modrinth.plus?app',
- target: '_blank',
- tone: 'purple',
- shown: !hasPlus.value,
- },
{
id: 'add-friend',
label: formatMessage(messages.addFriend),
@@ -2484,13 +2458,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</RouterView>
</div>
<div
- class="app-sidebar mt-px shrink-0 flex flex-col border-0 border-l-[1px] border-[--brand-gradient-border] border-solid"
- :class="{ 'has-plus': hasPlus }"
+ class="app-sidebar mt-px shrink-0 flex flex-col border-0 border-l-[1px] border-[--brand-gradient-border] border-solid has-plus"
>
<div
v-overlay-scrollbars="sidebarOverlayScrollbarsOptions"
class="app-sidebar-scrollable flex-grow shrink relative"
- :class="{ 'pb-12': !hasPlus }"
data-overlayscrollbars-initialize
>
<OnboardingChecklist
@@ -2552,17 +2524,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</div>
</div>
</div>
- <template v-if="showAd">
- <a
- href="https://modrinth.plus?app"
- class="absolute bottom-[250px] w-full flex justify-center items-center gap-1 px-4 py-3 text-purple font-medium hover:underline z-10"
- target="_blank"
- >
- <ArrowBigUpDashIcon class="text-2xl" />
- {{ formatMessage(messages.upgradeToModrinthPlus) }}
- </a>
- <PromotionWrapper />
- </template>
</div>
</div>
<I18nDebugPanel />
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() {}
File diff suppressed because it is too large. Load diff
-117
View File
@@ -1,117 +0,0 @@
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() {}
@@ -302,10 +302,10 @@ index bb20aa9..46a9749 100644
+ void router + void router
} }
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json 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 index ba08ca7..28d0366 100644
--- a/apps/app-frontend/src/locales/en-US/index.json --- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -2667,7 +2667,7 @@ @@ -2745,7 +2745,7 @@
"message": "Discord Rich Presence" "message": "Discord Rich Presence"
}, },
"app.settings.privacy.telemetry.description": { "app.settings.privacy.telemetry.description": {
@@ -335,7 +335,7 @@ index e69e6b7..e5bbabe 100644
} }
} }
diff --git a/packages/app-lib/src/api/instance/run.rs b/packages/app-lib/src/api/instance/run.rs diff --git a/packages/app-lib/src/api/instance/run.rs b/packages/app-lib/src/api/instance/run.rs
index 25626c1..d638128 100644 index 415af27..4e46190 100644
--- a/packages/app-lib/src/api/instance/run.rs --- a/packages/app-lib/src/api/instance/run.rs
+++ b/packages/app-lib/src/api/instance/run.rs +++ b/packages/app-lib/src/api/instance/run.rs
@@ -4,13 +4,10 @@ use crate::state::{ @@ -4,13 +4,10 @@ use crate::state::{
@@ -352,7 +352,7 @@ index 25626c1..d638128 100644
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum QuickPlayType { pub enum QuickPlayType {
@@ -232,54 +229,10 @@ async fn run_credentials( @@ -242,54 +239,10 @@ async fn run_credentials(
mc_set_options.push(("fullscreen".to_string(), "true".to_string())); mc_set_options.push(("fullscreen".to_string(), "true".to_string()));
} }
@@ -411,7 +411,7 @@ index 25626c1..d638128 100644
crate::minecraft_skins::flush_pending_skin_change().await?; crate::minecraft_skins::flush_pending_skin_change().await?;
crate::launcher::launch_minecraft( crate::launcher::launch_minecraft(
@@ -297,21 +250,6 @@ async fn run_credentials( @@ -307,21 +260,6 @@ async fn run_credentials(
.await .await
} }
@@ -433,7 +433,7 @@ index 25626c1..d638128 100644
pub async fn kill(instance_id: &str) -> crate::Result<()> { pub async fn kill(instance_id: &str) -> crate::Result<()> {
let state = State::get().await?; let state = State::get().await?;
let processes = let processes =
@@ -373,13 +311,12 @@ pub async fn try_update_playtime_by_instance_id( @@ -383,13 +321,12 @@ pub async fn try_update_playtime_by_instance_id(
} }
} }
@@ -21,20 +21,49 @@ data when they are opened elsewhere.
Usernames are validated the way Mojang validates them: 3 to 16 Usernames are validated the way Mojang validates them: 3 to 16
characters of letters, numbers and underscores. characters of letters, numbers and underscores.
Three places had to be opened up for any of this to be reachable, all of
which offered Microsoft and nothing else:
* The account card was hidden until a Microsoft account had been added,
which left offline sign-in unreachable for exactly the people who want
it.
* The "Minecraft required" modal - what you hit on pressing Play with no
account - now lists the alternatives too. It is no longer only about
something being required, so it is titled "Sign in to Minecraft".
* "Sign in to Minecraft" in the getting started checklist went straight
to Microsoft. It opens that same modal now, so every way in offers the
same choice.
--- ---
Cargo.lock | 1 +
Cargo.toml | 1 + Cargo.toml | 1 +
.../src/components/ui/AccountsCard.vue | 25 ++++ apps/app-frontend/src/App.vue | 12 +-
.../src/components/ui/AccountsCard.vue | 30 ++++
.../src/components/ui/OfflineAccountModal.vue | 136 ++++++++++++++++++ .../src/components/ui/OfflineAccountModal.vue | 136 ++++++++++++++++++
.../MinecraftRequiredModal.vue | 30 +++-
apps/app-frontend/src/helpers/auth.js | 13 ++ apps/app-frontend/src/helpers/auth.js | 13 ++
apps/app/build.rs | 1 +
apps/app/src/api/auth.rs | 7 + apps/app/src/api/auth.rs | 7 +
packages/app-lib/Cargo.toml | 1 + packages/app-lib/Cargo.toml | 1 +
packages/app-lib/src/api/minecraft_auth.rs | 39 +++++ packages/app-lib/src/api/minecraft_auth.rs | 39 +++++
packages/app-lib/src/state/minecraft_auth.rs | 68 +++++++++ packages/app-lib/src/state/minecraft_auth.rs | 68 +++++++++
8 files changed, 290 insertions(+) 12 files changed, 331 insertions(+), 8 deletions(-)
create mode 100644 apps/app-frontend/src/components/ui/OfflineAccountModal.vue create mode 100644 apps/app-frontend/src/components/ui/OfflineAccountModal.vue
diff --git a/Cargo.lock b/Cargo.lock
index bdc7307..9006b00 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11534,6 +11534,7 @@ dependencies = [
"indicatif",
"itertools 0.14.0",
"json5",
+ "md-5",
"modrinth-content-management",
"notify",
"notify-debouncer-mini",
diff --git a/Cargo.toml b/Cargo.toml diff --git a/Cargo.toml b/Cargo.toml
index a4a779c..a85f576 100644 index b8b227e..8348c8d 100644
--- a/Cargo.toml --- a/Cargo.toml
+++ b/Cargo.toml +++ b/Cargo.toml
@@ -132,6 +132,7 @@ lz4_flex = { version = "0.11.5", default-features = false, features = [ @@ -132,6 +132,7 @@ lz4_flex = { version = "0.11.5", default-features = false, features = [
@@ -45,8 +74,35 @@ index a4a779c..a85f576 100644
modrinth-content-management = { path = "packages/modrinth-content-management" } modrinth-content-management = { path = "packages/modrinth-content-management" }
modrinth-log = { path = "packages/modrinth-log" } modrinth-log = { path = "packages/modrinth-log" }
modrinth-util = { path = "packages/modrinth-util" } modrinth-util = { path = "packages/modrinth-util" }
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index 447f636..d750a5f 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -2467,15 +2467,17 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<OnboardingChecklist
@create-instance="installationModal?.show()"
- @login-minecraft="accounts?.login()"
+ @login-minecraft="minecraftRequiredModal?.show()"
@login-modrinth="signIn"
/>
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
- <div
- v-show="hasLoggedIntoMinecraft"
- class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
- >
+ <!--
+ Upstream hides this until a Microsoft account has been added, which
+ would leave the offline sign-in below it unreachable for exactly the
+ people who want it.
+ -->
+ <div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<h3 class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.playingAs) }}
</h3>
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
index 70cc46e..e695a6d 100644 index 70cc46e..ebc92c1 100644
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue --- a/apps/app-frontend/src/components/ui/AccountsCard.vue
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue +++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
@@ -9,6 +9,10 @@ @@ -9,6 +9,10 @@
@@ -102,7 +158,21 @@ index 70cc46e..e695a6d 100644
const loginDisabled = ref(false) const loginDisabled = ref(false)
const defaultUser = ref<string | undefined>() const defaultUser = ref<string | undefined>()
const equippedSkin = ref<Skin | null>(null) const equippedSkin = ref<Skin | null>(null)
@@ -247,6 +262,12 @@ async function login() { @@ -187,8 +202,13 @@ function setLoginDisabled(value: boolean) {
loginDisabled.value = value
}
+function showOfflineAccountModal(event?: MouseEvent) {
+ offlineAccountModal.value?.show(event)
+}
+
defineExpose({
refreshValues,
+ showOfflineAccountModal,
setEquippedSkin,
setLoginDisabled,
login,
@@ -247,6 +267,12 @@ async function login() {
loginDisabled.value = false loginDisabled.value = false
} }
@@ -115,7 +185,7 @@ index 70cc46e..e695a6d 100644
async function logout(id: string) { async function logout(id: string) {
await remove_user(id).catch(handleError) await remove_user(id).catch(handleError)
await refreshValues() await refreshValues()
@@ -273,6 +294,10 @@ const messages = defineMessages({ @@ -273,6 +299,10 @@ const messages = defineMessages({
id: 'minecraft-account.add-account', id: 'minecraft-account.add-account',
defaultMessage: 'Add account', defaultMessage: 'Add account',
}, },
@@ -268,6 +338,82 @@ index 0000000..8300e28
+ }, + },
+}) +})
+</script> +</script>
diff --git a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
index 73293f5..8e6b275 100644
--- a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
+++ b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
@@ -43,6 +43,16 @@
{{ formatMessage(messages.signIn) }}
</Button>
</div>
+ <div class="flex flex-col gap-2 border-0 border-t border-solid border-surface-4 pt-5">
+ <p class="m-0 text-sm leading-tight text-secondary">
+ {{ formatMessage(messages.offlineHint) }}
+ </p>
+ <Button @click="addOfflineAccount">
+ <UserIcon />
+ {{ formatMessage(messages.addOfflineAccount) }}
+ </Button>
+ </div>
+
<p class="m-0 text-center text-sm text-secondary">
{{ formatMessage(messages.dontHaveAccount) }}
<a
@@ -57,7 +67,7 @@
</template>
<script setup lang="ts">
-import { MessagesSquareIcon, SpinnerIcon } from '@modrinth/assets'
+import { MessagesSquareIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
import { Button, ButtonLink, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { inject, type Ref, ref } from 'vue'
@@ -73,7 +83,7 @@ const accountsCard = inject('accountsCard') as Ref<InstanceType<typeof AccountsC
const messages = defineMessages({
header: {
id: 'minecraft-required.header',
- defaultMessage: 'Minecraft required',
+ defaultMessage: 'Sign in to Minecraft',
},
descriptionHeader: {
id: 'minecraft-required.description-header',
@@ -82,7 +92,7 @@ const messages = defineMessages({
description: {
id: 'minecraft-required.description',
defaultMessage:
- 'You need a Microsoft account that owns Minecraft before you can launch and play.',
+ 'A Microsoft account that owns Minecraft is what lets you play online and keep your skin.',
},
getSupport: {
id: 'minecraft-required.get-support',
@@ -100,6 +110,15 @@ const messages = defineMessages({
id: 'minecraft-required.get-minecraft',
defaultMessage: 'Get Minecraft',
},
+ offlineHint: {
+ id: 'minecraft-required.offline-hint',
+ defaultMessage:
+ 'Or play singleplayer and offline-mode servers without an account of any kind.',
+ },
+ addOfflineAccount: {
+ id: 'minecraft-required.add-offline-account',
+ defaultMessage: 'Add offline account',
+ },
})
const modal = ref<InstanceType<typeof NewModal>>()
@@ -127,6 +146,11 @@ async function signIn() {
}
}
+function addOfflineAccount(event: MouseEvent) {
+ modal.value?.hide()
+ accountsCard.value?.showOfflineAccountModal(event)
+}
+
defineExpose({
show,
})
diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js
index 94bd13e..cb7319a 100644 index 94bd13e..cb7319a 100644
--- a/apps/app-frontend/src/helpers/auth.js --- a/apps/app-frontend/src/helpers/auth.js
@@ -292,6 +438,18 @@ index 94bd13e..cb7319a 100644
/** /**
* Retrieves the default user * Retrieves the default user
* @return {Promise<UUID | undefined>} * @return {Promise<UUID | undefined>}
diff --git a/apps/app/build.rs b/apps/app/build.rs
index 926cab4..68a02aa 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -14,6 +14,7 @@ fn main() {
.commands(&[
"check_reachable",
"login",
+ "login_offline",
"remove_user",
"get_default_user",
"set_default_user",
diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs
index 8227d94..f4eded6 100644 index 8227d94..f4eded6 100644
--- a/apps/app/src/api/auth.rs --- a/apps/app/src/api/auth.rs
@@ -318,10 +476,10 @@ index 8227d94..f4eded6 100644
pub async fn remove_user(user: uuid::Uuid) -> Result<()> { pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
Ok(minecraft_auth::remove_user(user).await?) Ok(minecraft_auth::remove_user(user).await?)
diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml
index 69545c2..7da46c9 100644 index 15a1dc9..4fe7643 100644
--- a/packages/app-lib/Cargo.toml --- a/packages/app-lib/Cargo.toml
+++ b/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml
@@ -48,6 +48,7 @@ image = { workspace = true, features = ["gif", "jpeg", "png", "webp"] } @@ -52,6 +52,7 @@ image = { workspace = true, features = ["gif", "jpeg", "png", "webp"] }
indicatif = { workspace = true, optional = true } indicatif = { workspace = true, optional = true }
itertools = { workspace = true } itertools = { workspace = true }
json5 = { workspace = true } json5 = { workspace = true }
@@ -0,0 +1,365 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Mon, 14 Sep 2026 13:06:38 +0200
Subject: [PATCH] Make the sidebars foldable
Three sidebar preferences, all stored as feature flags so they survive a
restart without needing a settings migration:
* The Modrinth Servers button in the left sidebar can be switched off
under Settings > Features > Sidebar. It stays on by default.
* The news section in the right sidebar collapses by clicking its
heading, remembering the state the way the friends list does.
* The button that folds the right sidebar away is always in the title
bar, rather than appearing only once "Hide right sidebar" is turned on
in settings, and what it is set to is remembered. That setting still
works, and still seeds the state. Its label was pointing at the wrong
message ("Next image"), which is fixed here too.
Once the fold state is saved, a check pops onto the button and its
tooltip says it is remembered after a restart, since nothing else shows
that. On pages that need the sidebar (browse, projects, users) the button
stays in place, disabled, instead of vanishing and shifting the title bar.
None of these three reach Modrinth. Preference syncing maps a fixed list
of named fields in both directions, and these are not in it, so they are
neither sent nor overwritten by another device.
The Modrinth Servers switch says so where it sits, because it sits on a
page headed "Sync features across devices" and would otherwise look like
it syncs. The other two are not settings entries - a heading and a title
bar button - so nothing there promises anything.
---
apps/app-frontend/src/App.vue | 153 ++++++++++++++++--
.../ui/settings/display/FeaturesSettings.vue | 28 ++++
.../src/composables/use-app-settings.ts | 3 +
packages/app-lib/src/state/settings.rs | 3 +
4 files changed, 172 insertions(+), 15 deletions(-)
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index d750a5f..ac8308c 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -10,6 +10,8 @@ import {
} from '@modrinth/api-client'
import {
ArrowLeftRightIcon,
+ CheckIcon,
+ ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
@@ -254,12 +256,41 @@ const credentials = ref()
const storedModrinthAccounts = ref([])
let credentialsRefreshId = 0
const sidebarToggled = ref(true)
+// Whether the right sidebar is folded away, remembered across restarts in the
+// feature flags. The title bar button below is what normally sets it; the
+// "Hide right sidebar" setting still seeds it, so turning that on folds the
+// sidebar away as it always did.
watch(
- () => appSettings.toggleSidebar,
- (toggleSidebar) => {
- sidebarToggled.value = !toggleSidebar
+ () => appSettings.getFeatureFlag('right_sidebar_collapsed'),
+ (collapsed) => {
+ sidebarToggled.value = !collapsed
},
+ { immediate: true },
+)
+watch(
+ () => appSettings.toggleSidebar,
+ (hide) => setSidebarCollapsed(hide),
)
+
+// Briefly shows a check on the fold button once the state is saved, so it is
+// clear the choice is remembered.
+const sidebarSaved = ref(false)
+let sidebarSavedTimeout
+
+function setSidebarCollapsed(collapsed) {
+ appSettings.featureFlags.right_sidebar_collapsed = collapsed
+ getSettings()
+ .then((settings) => {
+ settings.feature_flags.right_sidebar_collapsed = collapsed
+ return setSettings(settings)
+ })
+ .then(() => {
+ sidebarSaved.value = true
+ clearTimeout(sidebarSavedTimeout)
+ sidebarSavedTimeout = setTimeout(() => (sidebarSaved.value = false), 1600)
+ })
+ .catch(handleError)
+}
const forceSidebar = computed(
() =>
route.path.startsWith('/browse') ||
@@ -358,6 +389,21 @@ useQuery({
// ad and the gradient being drawn above it.
const showAd = computed(() => false)
const adConsentAvailable = computed(() => false)
+
+// Whether the news section in the right sidebar is folded away. Kept in the
+// feature flags so it survives a restart, the same way the friends list
+// remembers its collapsed sections.
+const newsCollapsed = computed(() => appSettings.getFeatureFlag('news_collapsed'))
+
+function setNewsCollapsed(collapsed) {
+ appSettings.featureFlags.news_collapsed = collapsed
+ getSettings()
+ .then((settings) => {
+ settings.feature_flags.news_collapsed = collapsed
+ return setSettings(settings)
+ })
+ .catch(handleError)
+}
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: showAd,
@@ -588,6 +634,16 @@ const messages = defineMessages({
goBack: { id: 'app.navigation.go-back', defaultMessage: 'Go back' },
goForward: { id: 'app.navigation.go-forward', defaultMessage: 'Go forward' },
nextImage: { id: 'app.navigation.next-image', defaultMessage: 'Next image' },
+ hideSidebar: { id: 'app.navigation.hide-sidebar', defaultMessage: 'Hide sidebar' },
+ showSidebar: { id: 'app.navigation.show-sidebar', defaultMessage: 'Show sidebar' },
+ sidebarRemembered: {
+ id: 'app.navigation.sidebar-remembered',
+ defaultMessage: 'Remembered after restart',
+ },
+ sidebarRequired: {
+ id: 'app.navigation.sidebar-required',
+ defaultMessage: 'This page needs the sidebar',
+ },
updateDownloadMissingVersion: {
id: 'app.update.download-error.missing-version',
defaultMessage: 'Failed to download update: no version available',
@@ -2252,6 +2308,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<ImageIcon />
</NavButton>
<NavButton
+ v-if="appSettings.getFeatureFlag('show_hosting_in_sidebar')"
v-tooltip.right="formatMessage(messages.modrinthHosting)"
to="/hosting/manage"
:is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'"
@@ -2377,16 +2434,44 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<Breadcrumbs />
</div>
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
- <IconButton
- v-if="!forceSidebar && appSettings.toggleSidebar"
- :type="sidebarToggled ? 'base' : 'quiet'"
- :label="formatMessage(messages.nextImage)"
- class="mr-3 transition-transform"
- :class="{ 'rotate-180': !sidebarToggled }"
- @click="sidebarToggled = !sidebarToggled"
+ <!--
+ Stays in place on pages that need the sidebar, so the title bar does
+ not shift. The tooltip sits on the wrapper since a disabled button
+ gets no hover events.
+ -->
+ <span
+ v-tooltip="
+ formatMessage(
+ forceSidebar
+ ? messages.sidebarRequired
+ : sidebarSaved
+ ? messages.sidebarRemembered
+ : sidebarToggled
+ ? messages.hideSidebar
+ : messages.showSidebar,
+ )
+ "
+ class="relative mr-3 flex"
>
- <RightArrowIcon />
- </IconButton>
+ <IconButton
+ :type="sidebarVisible ? 'base' : 'quiet'"
+ :label="formatMessage(sidebarToggled ? messages.hideSidebar : messages.showSidebar)"
+ :disabled="forceSidebar"
+ class="transition-transform"
+ :class="{ 'rotate-180': !sidebarVisible }"
+ @click="setSidebarCollapsed(sidebarToggled)"
+ >
+ <RightArrowIcon />
+ </IconButton>
+ <Transition name="sidebar-saved">
+ <span
+ v-if="sidebarSaved && !forceSidebar"
+ class="sidebar-saved-badge pointer-events-none absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-brand text-brand-inverted"
+ >
+ <CheckIcon class="size-3" stroke-width="3" />
+ </span>
+ </Transition>
+ </span>
<div class="flex mr-3">
<Suspense>
<AppActionBar />
@@ -2502,10 +2587,18 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
/>
<div v-if="news && news.length > 0" class="p-4 flex flex-col items-center">
- <h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">
+ <button
+ class="button-base m-0 mb-4 flex w-full cursor-pointer items-center justify-between gap-2 border-0 bg-transparent p-0 text-left text-base font-medium text-primary"
+ :aria-expanded="!newsCollapsed"
+ @click="setNewsCollapsed(!newsCollapsed)"
+ >
{{ formatMessage(messages.news) }}
- </h3>
- <div class="space-y-4 flex flex-col items-center w-full">
+ <ChevronDownIcon
+ class="h-5 w-5 shrink-0 transition-transform"
+ :class="{ '-rotate-90': newsCollapsed }"
+ />
+ </button>
+ <div v-if="!newsCollapsed" class="space-y-4 flex flex-col items-center w-full">
<NewsArticleCard
v-for="(item, index) in news"
:key="`news-${index}`"
@@ -2743,6 +2836,36 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
display: contents;
}
+@media (prefers-reduced-motion: no-preference) {
+ .sidebar-saved-enter-active {
+ transition: transform 0.35s cubic-bezier(0.15, 1.4, 0.64, 0.96);
+ }
+
+ .sidebar-saved-leave-active {
+ transition:
+ transform 0.2s ease,
+ opacity 0.2s ease;
+ }
+
+ .sidebar-saved-enter-from,
+ .sidebar-saved-leave-to {
+ transform: scale(0);
+ opacity: 0;
+ }
+
+ .sidebar-saved-badge :deep(path) {
+ stroke-dasharray: 20;
+ stroke-dashoffset: 20;
+ animation: sidebar-saved-draw 0.3s 0.15s ease-out forwards;
+ }
+}
+
+@keyframes sidebar-saved-draw {
+ to {
+ stroke-dashoffset: 0;
+ }
+}
+
@media (prefers-reduced-motion: no-preference) {
.nav-button-animated-enter-active {
transition: all 0.5s cubic-bezier(0.15, 1.4, 0.64, 0.96);
diff --git a/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue b/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue
index 1d3e379..ae88550 100644
--- a/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue
+++ b/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue
@@ -43,6 +43,7 @@ const quickInstances = useQuickInstanceLimit()
const queryClient = useQueryClient()
const showJumpInFlag: FeatureFlag = 'worlds_in_home'
+const showHostingFlag: FeatureFlag = 'show_hosting_in_sidebar'
const messages = defineMessages({
syncAcrossDevicesTitle: {
@@ -95,6 +96,15 @@ const messages = defineMessages({
id: 'app.features-settings.show-skin-selector.description',
defaultMessage: 'Show a button in the left sidebar to open the skin selector.',
},
+ showHostingTitle: {
+ id: 'app.features-settings.show-hosting.title',
+ defaultMessage: 'Show Modrinth Servers in sidebar',
+ },
+ showHostingDescription: {
+ id: 'app.features-settings.show-hosting.description',
+ defaultMessage:
+ 'Show a button in the left sidebar to manage Modrinth Servers. Added by Modrinth Enhanced, so it stays on this device and is not synced to your Modrinth account.',
+ },
quickInstancesTitle: {
id: 'app.features-settings.quick-instances.title',
defaultMessage: 'Quick instances in sidebar',
@@ -125,6 +135,7 @@ type FeaturesSettingsState = {
showScreenshotsTab: boolean
showAllScreenshots: boolean
showSkinSelector: boolean
+ showHosting: boolean
quickInstanceCount: number
showJumpIn: boolean
}
@@ -144,6 +155,7 @@ function getFeaturesSettingsState(
showScreenshotsTab: settings.show_screenshots_tab_in_instances,
showAllScreenshots: globalSyncedOptions.screenshots,
showSkinSelector: settings.show_skin_selector_in_sidebar,
+ showHosting: settings.feature_flags[showHostingFlag] ?? DEFAULT_FEATURE_FLAGS[showHostingFlag],
quickInstanceCount: quickInstances.limit.value ?? QUICK_INSTANCE_LIMIT_MAX,
showJumpIn: settings.feature_flags[showJumpInFlag] ?? DEFAULT_FEATURE_FLAGS[showJumpInFlag],
}
@@ -184,6 +196,7 @@ const settingsMutation = useMutation({
feature_flags: {
...latestSettings.feature_flags,
[showJumpInFlag]: value.showJumpIn,
+ [showHostingFlag]: value.showHosting,
},
}
@@ -206,6 +219,7 @@ const settingsMutation = useMutation({
appSettings.showScreenshotsTabInInstances = value.showScreenshotsTab
appSettings.showSkinSelectorInSidebar = value.showSkinSelector
appSettings.featureFlags[showJumpInFlag] = value.showJumpIn
+ appSettings.featureFlags[showHostingFlag] = value.showHosting
if (updateQuickInstanceCount) {
quickInstances.setLimit(value.quickInstanceCount)
@@ -366,6 +380,20 @@ onBeforeUnmount(() => {
/>
</div>
+ <div class="flex items-center justify-between gap-4">
+ <div>
+ <h3 class="m-0 text-lg font-semibold text-contrast">
+ {{ formatMessage(messages.showHostingTitle) }}
+ </h3>
+ <p class="m-0 mt-1">{{ formatMessage(messages.showHostingDescription) }}</p>
+ </div>
+ <Toggle
+ id="show-hosting-in-sidebar"
+ v-model="current.showHosting"
+ :aria-label="formatMessage(messages.showHostingTitle)"
+ />
+ </div>
+
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.quickInstancesTitle) }}
diff --git a/apps/app-frontend/src/composables/use-app-settings.ts b/apps/app-frontend/src/composables/use-app-settings.ts
index 0d58903..59da62b 100644
--- a/apps/app-frontend/src/composables/use-app-settings.ts
+++ b/apps/app-frontend/src/composables/use-app-settings.ts
@@ -24,6 +24,9 @@ export const DEFAULT_FEATURE_FLAGS = {
friends_pending_collapsed: true,
dismissed_photosensitivity_filter_warning: false,
localhost_sign_in: false,
+ show_hosting_in_sidebar: true,
+ news_collapsed: false,
+ right_sidebar_collapsed: false,
}
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs
index 20601b8..2fd0d87 100644
--- a/packages/app-lib/src/state/settings.rs
+++ b/packages/app-lib/src/state/settings.rs
@@ -88,6 +88,9 @@ pub enum FeatureFlag {
FriendsPendingCollapsed,
DismissedPhotosensitivityFilterWarning,
LocalhostSignIn,
+ ShowHostingInSidebar,
+ NewsCollapsed,
+ RightSidebarCollapsed,
}
impl Settings {
+853
View File
@@ -0,0 +1,853 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Mon, 14 Sep 2026 13:24:41 +0200
Subject: [PATCH] Add Ely.by accounts
Ely.by is an alternative Minecraft account system. "Add Ely.by account"
sits next to the Microsoft and offline options in the account card; the
account name and password go to authserver.ely.by and nowhere else, and
a two-factor code is appended to the password after a colon, which is
Ely.by's own convention.
Minecraft asks Mojang who the player is, so an Ely.by account cannot
start the game on its own. The authlib-injector agent points those calls
at Ely.by instead; it is downloaded once and cached, after which such an
account launches with no network at all.
Like offline accounts, an Ely.by account is a row in `minecraft_users`
rather than a table of its own, so no migration is needed: the client
token Ely.by issues alongside the access token lives behind a marker in
the refresh token column, which is both what renews the pair and what
identifies the account. The expiry column becomes "check again after" -
Ely.by does not say when its tokens expire - so the pair is validated
and, if needed, renewed a few times a day instead of on every read of
the account list.
Signing in on Ely.by's own page would be better than a password form,
and needs an OAuth application registered with Ely.by; there is none for
Modrinth Enhanced yet.
---
.../src/components/ui/AccountsCard.vue | 30 ++-
.../src/components/ui/ElyAccountModal.vue | 166 ++++++++++++++
.../MinecraftRequiredModal.vue | 29 ++-
apps/app-frontend/src/helpers/auth.js | 15 ++
apps/app/build.rs | 1 +
apps/app/src/api/auth.rs | 10 +
packages/app-lib/src/api/minecraft_auth.rs | 34 +++
packages/app-lib/src/launcher/mod.rs | 16 ++
packages/app-lib/src/state/minecraft_auth.rs | 213 +++++++++++++++++-
packages/app-lib/src/util/authlib_injector.rs | 77 +++++++
packages/app-lib/src/util/mod.rs | 1 +
11 files changed, 579 insertions(+), 13 deletions(-)
create mode 100644 apps/app-frontend/src/components/ui/ElyAccountModal.vue
create mode 100644 packages/app-lib/src/util/authlib_injector.rs
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
index ebc92c1..35c21fa 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="elyAccountModal?.show($event)">
+ <KeyIcon />
+ {{ formatMessage(messages.addElyAccount) }}
+ </Button>
<Button @click="offlineAccountModal?.show($event)">
<UserIcon />
{{ formatMessage(messages.addOfflineAccount) }}
@@ -84,6 +88,13 @@
<PlusIcon />
{{ formatMessage(messages.addAccount) }}
</Button>
+ <Button
+ class="w-full !bg-button-bg !text-primary ![box-shadow:var(--shadow-button)]"
+ @click="elyAccountModal?.show($event)"
+ >
+ <KeyIcon />
+ {{ formatMessage(messages.addElyAccount) }}
+ </Button>
<Button
class="w-full !bg-button-bg !text-primary ![box-shadow:var(--shadow-button)]"
@click="offlineAccountModal?.show($event)"
@@ -94,11 +105,13 @@
</div>
</div>
</Accordion>
- <OfflineAccountModal ref="offlineAccountModal" @created="offlineAccountCreated" />
+ <OfflineAccountModal ref="offlineAccountModal" @created="accountAdded" />
+ <ElyAccountModal ref="elyAccountModal" @created="accountAdded" />
</template>
<script setup lang="ts">
import {
+ KeyIcon,
LogInIcon,
PlusIcon,
RadioButtonCheckedIcon,
@@ -119,6 +132,7 @@ import {
import type { Ref } from 'vue'
import { computed, onUnmounted, ref } from 'vue'
+import ElyAccountModal from '@/components/ui/ElyAccountModal.vue'
import OfflineAccountModal from '@/components/ui/OfflineAccountModal.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { handleSevereError } from '@/composables/use-error.js'
@@ -150,6 +164,7 @@ type MinecraftCredential = {
const accounts: Ref<MinecraftCredential[]> = ref([])
const offlineAccountModal = ref<InstanceType<typeof OfflineAccountModal>>()
+const elyAccountModal = ref<InstanceType<typeof ElyAccountModal>>()
const loginDisabled = ref(false)
const defaultUser = ref<string | undefined>()
const equippedSkin = ref<Skin | null>(null)
@@ -206,9 +221,14 @@ function showOfflineAccountModal(event?: MouseEvent) {
offlineAccountModal.value?.show(event)
}
+function showElyAccountModal(event?: MouseEvent) {
+ elyAccountModal.value?.show(event)
+}
+
defineExpose({
refreshValues,
showOfflineAccountModal,
+ showElyAccountModal,
setEquippedSkin,
setLoginDisabled,
login,
@@ -267,8 +287,8 @@ async function login() {
loginDisabled.value = false
}
-async function offlineAccountCreated() {
- // `login_offline` already marks the new account as the active one.
+async function accountAdded() {
+ // Both sign-in paths already mark the new account as the active one.
await refreshValues()
emit('change')
}
@@ -303,6 +323,10 @@ const messages = defineMessages({
id: 'minecraft-account.add-offline-account',
defaultMessage: 'Add offline account',
},
+ addElyAccount: {
+ id: 'minecraft-account.add-ely-account',
+ defaultMessage: 'Add Ely.by account',
+ },
removeAccount: {
id: 'minecraft-account.remove-account',
defaultMessage: 'Remove account',
diff --git a/apps/app-frontend/src/components/ui/ElyAccountModal.vue b/apps/app-frontend/src/components/ui/ElyAccountModal.vue
new file mode 100644
index 0000000..d909ec3
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/ElyAccountModal.vue
@@ -0,0 +1,166 @@
+<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-3" @submit.prevent="submit">
+ <div class="flex flex-col gap-2">
+ <label class="font-semibold text-contrast" for="ely-account-username">
+ {{ formatMessage(messages.usernameLabel) }}
+ </label>
+ <Input
+ id="ely-account-username"
+ v-model="username"
+ :icon="UserIcon"
+ :placeholder="formatMessage(messages.usernamePlaceholder)"
+ :error="!!error"
+ autocapitalize="none"
+ autocorrect="off"
+ :spellcheck="false"
+ class="w-full"
+ />
+ </div>
+
+ <div class="flex flex-col gap-2">
+ <label class="font-semibold text-contrast" for="ely-account-password">
+ {{ formatMessage(messages.passwordLabel) }}
+ </label>
+ <Input
+ id="ely-account-password"
+ v-model="password"
+ type="password"
+ :icon="KeyIcon"
+ :error="!!error"
+ class="w-full"
+ />
+ <p class="m-0 text-sm leading-tight text-secondary">
+ {{ formatMessage(messages.twoFactorHint) }}
+ </p>
+ </div>
+
+ <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() || !password"
+ @click="submit"
+ >
+ <SpinnerIcon v-if="submitting" aria-hidden="true" class="animate-spin" />
+ <LogInIcon v-else aria-hidden="true" />
+ {{ formatMessage(messages.signInButton) }}
+ </Button>
+ </div>
+ </template>
+ </NewModal>
+</template>
+
+<script setup lang="ts">
+import { KeyIcon, LogInIcon, SpinnerIcon, UserIcon, XIcon } from '@modrinth/assets'
+import {
+ Button,
+ commonMessages,
+ defineMessages,
+ Input,
+ NewModal,
+ useVIntl,
+} from '@modrinth/ui'
+import { nextTick, ref } from 'vue'
+
+import { login_ely } from '@/helpers/auth'
+
+const { formatMessage } = useVIntl()
+
+const emit = defineEmits<{
+ created: [account: unknown]
+}>()
+
+const modal = ref<InstanceType<typeof NewModal>>()
+const username = ref('')
+const password = ref('')
+const error = ref('')
+const submitting = ref(false)
+
+function show(event?: MouseEvent) {
+ username.value = ''
+ password.value = ''
+ error.value = ''
+ submitting.value = false
+ modal.value?.show(event)
+ void nextTick(() => {
+ document.getElementById('ely-account-username')?.focus()
+ })
+}
+
+async function submit() {
+ if (submitting.value) return
+
+ const name = username.value.trim()
+ if (!name || !password.value) return
+
+ submitting.value = true
+ error.value = ''
+
+ try {
+ const account = await login_ely(name, password.value)
+ password.value = ''
+ 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.ely-account.header',
+ defaultMessage: 'Sign in with Ely.by',
+ },
+ description: {
+ id: 'app.ely-account.description',
+ defaultMessage:
+ 'Ely.by is an alternative Minecraft account system. Your credentials go to Ely.by and nowhere else; the game is pointed at it with authlib-injector when the instance launches.',
+ },
+ usernameLabel: {
+ id: 'app.ely-account.username-label',
+ defaultMessage: 'Account name or email',
+ },
+ usernamePlaceholder: {
+ id: 'app.ely-account.username-placeholder',
+ defaultMessage: 'Your Ely.by account',
+ },
+ passwordLabel: {
+ id: 'app.ely-account.password-label',
+ defaultMessage: 'Password',
+ },
+ twoFactorHint: {
+ id: 'app.ely-account.two-factor-hint',
+ defaultMessage:
+ 'With two-factor authentication on, append your current code to the password, separated by a colon.',
+ },
+ signInButton: {
+ id: 'app.ely-account.sign-in-button',
+ defaultMessage: 'Sign in',
+ },
+ genericError: {
+ id: 'app.ely-account.generic-error',
+ defaultMessage: 'Could not sign in to Ely.by.',
+ },
+})
+</script>
diff --git a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
index 8e6b275..7781cee 100644
--- a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
+++ b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
@@ -47,10 +47,16 @@
<p class="m-0 text-sm leading-tight text-secondary">
{{ formatMessage(messages.offlineHint) }}
</p>
- <Button @click="addOfflineAccount">
- <UserIcon />
- {{ formatMessage(messages.addOfflineAccount) }}
- </Button>
+ <div class="grid grid-cols-2 gap-2">
+ <Button @click="addElyAccount">
+ <KeyIcon />
+ {{ formatMessage(messages.addElyAccount) }}
+ </Button>
+ <Button @click="addOfflineAccount">
+ <UserIcon />
+ {{ formatMessage(messages.addOfflineAccount) }}
+ </Button>
+ </div>
</div>
<p class="m-0 text-center text-sm text-secondary">
@@ -67,7 +73,7 @@
</template>
<script setup lang="ts">
-import { MessagesSquareIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
+import { KeyIcon, MessagesSquareIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
import { Button, ButtonLink, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { inject, type Ref, ref } from 'vue'
@@ -113,11 +119,15 @@ const messages = defineMessages({
offlineHint: {
id: 'minecraft-required.offline-hint',
defaultMessage:
- 'Or play singleplayer and offline-mode servers without an account of any kind.',
+ 'Or sign in with Ely.by, or play singleplayer and offline-mode servers without an account of any kind.',
+ },
+ addElyAccount: {
+ id: 'minecraft-required.add-ely-account',
+ defaultMessage: 'Ely.by account',
},
addOfflineAccount: {
id: 'minecraft-required.add-offline-account',
- defaultMessage: 'Add offline account',
+ defaultMessage: 'Offline account',
},
})
@@ -151,6 +161,11 @@ function addOfflineAccount(event: MouseEvent) {
accountsCard.value?.showOfflineAccountModal(event)
}
+function addElyAccount(event: MouseEvent) {
+ modal.value?.hide()
+ accountsCard.value?.showElyAccountModal(event)
+}
+
defineExpose({
show,
})
diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js
index cb7319a..57580ff 100644
--- a/apps/app-frontend/src/helpers/auth.js
+++ b/apps/app-frontend/src/helpers/auth.js
@@ -46,6 +46,21 @@ export async function login_offline(username) {
return await invoke('plugin:auth|login_offline', { username })
}
+/**
+ * Signs in to Ely.by and makes that account the active one.
+ *
+ * Ely.by is an alternative Minecraft account system. The game is pointed at it
+ * with the authlib-injector agent at launch.
+ *
+ * @param {string} username Ely.by account name or email
+ * @param {string} password Ely.by password, with `:code` appended when the
+ * account has two-factor authentication enabled
+ * @returns {Promise<Credential>}
+ */
+export async function login_ely(username, password) {
+ return await invoke('plugin:auth|login_ely', { username, password })
+}
+
/**
* Retrieves the default user
* @return {Promise<UUID | undefined>}
diff --git a/apps/app/build.rs b/apps/app/build.rs
index 68a02aa..0f62dd8 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -15,6 +15,7 @@ fn main() {
"check_reachable",
"login",
"login_offline",
+ "login_ely",
"remove_user",
"get_default_user",
"set_default_user",
diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs
index f4eded6..dea07b2 100644
--- a/apps/app/src/api/auth.rs
+++ b/apps/app/src/api/auth.rs
@@ -10,6 +10,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
check_reachable,
login,
login_offline,
+ login_ely,
remove_user,
get_default_user,
set_default_user,
@@ -93,6 +94,15 @@ pub async fn login_offline(username: String) -> Result<Credentials> {
Ok(minecraft_auth::login_offline(&username).await?)
}
+/// Signs in to Ely.by and makes that account active.
+#[tauri::command]
+pub async fn login_ely(
+ username: String,
+ password: String,
+) -> Result<Credentials> {
+ Ok(minecraft_auth::login_ely(&username, &password).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/src/api/minecraft_auth.rs b/packages/app-lib/src/api/minecraft_auth.rs
index a7fac4a..2d18da3 100644
--- a/packages/app-lib/src/api/minecraft_auth.rs
+++ b/packages/app-lib/src/api/minecraft_auth.rs
@@ -86,6 +86,40 @@ pub async fn login_offline(username: &str) -> crate::Result<Credentials> {
Ok(credentials)
}
+/// Signs in to Ely.by and makes the account the active one.
+///
+/// Ely.by is an alternative account system for Minecraft. The game is pointed
+/// at it with the authlib-injector agent at launch, so such an account can play
+/// singleplayer and join any server that accepts Ely.by.
+#[tracing::instrument(skip(password))]
+pub async fn login_ely(
+ username: &str,
+ password: &str,
+) -> crate::Result<Credentials> {
+ let username = username.trim();
+
+ if username.is_empty() || password.is_empty() {
+ return Err(crate::ErrorKind::InputError(
+ "An Ely.by account name and password are both required".to_string(),
+ )
+ .into());
+ }
+
+ let state = State::get().await?;
+ let credentials = Credentials::ely(username, password).await?;
+ 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/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs
index 7910694..e623cab 100644
--- a/packages/app-lib/src/launcher/mod.rs
+++ b/packages/app-lib/src/launcher/mod.rs
@@ -1059,6 +1059,22 @@ pub async fn launch_minecraft(
command.arg("--add-opens=jdk.internal/jdk.internal.misc=ALL-UNNAMED");
}
+ // Minecraft asks Mojang who the player is, and an Ely.by account is not a
+ // Mojang account. authlib-injector is a Java agent that points those calls
+ // at Ely.by instead, and without it such an account cannot start the game.
+ if credentials.is_ely() {
+ let injector = crate::util::authlib_injector::get_authlib_injector(
+ &state.directories,
+ )
+ .await?;
+
+ command.arg(format!(
+ "-javaagent:{}={}",
+ injector.to_string_lossy(),
+ crate::state::ELY_API_ROOT
+ ));
+ }
+
command
.arg("com.modrinth.theseus.MinecraftLaunch")
.arg(version_info.main_class.clone())
diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs
index d97d233..25c6dfb 100644
--- a/packages/app-lib/src/state/minecraft_auth.rs
+++ b/packages/app-lib/src/state/minecraft_auth.rs
@@ -241,6 +241,67 @@ pub fn offline_uuid(username: &str) -> Uuid {
Uuid::from_bytes(bytes)
}
+/// Marker stored in front of an Ely.by account's client token.
+///
+/// Ely.by's Yggdrasil flow hands back an access token and a client token, and
+/// renewing the pair needs both. The client token therefore goes in the refresh
+/// token column behind this marker, which both identifies the account as an
+/// Ely.by one and keeps it out of a table of its own.
+const ELY_REFRESH_TOKEN_PREFIX: &str = "modrinth-enhanced:ely:";
+
+/// Ely.by's Yggdrasil server, which answers the same shapes Mojang's used to.
+const ELY_AUTHSERVER: &str = "https://authserver.ely.by";
+
+/// What authlib-injector is handed so the game asks Ely.by rather than Mojang.
+///
+/// The agent resolves the short form to Ely.by's actual API root itself.
+pub const ELY_API_ROOT: &str = "ely.by";
+
+#[derive(Deserialize)]
+struct ElyAuthResponse {
+ #[serde(rename = "accessToken")]
+ access_token: String,
+ #[serde(rename = "clientToken")]
+ client_token: String,
+ #[serde(rename = "selectedProfile")]
+ selected_profile: ElyProfile,
+}
+
+#[derive(Deserialize)]
+struct ElyProfile {
+ id: String,
+ name: String,
+}
+
+/// Reads the error message out of an Ely.by refusal, falling back to the status.
+async fn ely_error(response: Response) -> crate::Error {
+ #[derive(Deserialize)]
+ struct ElyError {
+ #[serde(rename = "errorMessage")]
+ error_message: Option<String>,
+ }
+
+ let status = response.status();
+ let message = response
+ .json::<ElyError>()
+ .await
+ .ok()
+ .and_then(|error| error.error_message)
+ .unwrap_or_else(|| format!("Ely.by refused the request ({status})"));
+
+ crate::ErrorKind::OtherError(message).as_error()
+}
+
+/// Ely.by's Yggdrasil UUIDs come without dashes.
+fn parse_ely_uuid(id: &str) -> crate::Result<Uuid> {
+ Uuid::parse_str(id).map_err(|_| {
+ crate::ErrorKind::OtherError(format!(
+ "Ely.by returned a player id that could not be read: {id}"
+ ))
+ .as_error()
+ })
+}
+
/// 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.
@@ -321,6 +382,135 @@ impl Credentials {
self.refresh_token == OFFLINE_REFRESH_TOKEN
}
+ /// Whether these credentials belong to an Ely.by account.
+ pub fn is_ely(&self) -> bool {
+ self.refresh_token.starts_with(ELY_REFRESH_TOKEN_PREFIX)
+ }
+
+ /// The client token Ely.by issued with the access token, if this is an
+ /// Ely.by account.
+ fn ely_client_token(&self) -> Option<&str> {
+ self.refresh_token.strip_prefix(ELY_REFRESH_TOKEN_PREFIX)
+ }
+
+ /// Signs in to Ely.by with an account name or email and a password.
+ ///
+ /// Ely.by accounts with two-factor authentication expect the current code
+ /// appended to the password with a colon, which is Ely.by's own convention
+ /// and is passed straight through.
+ pub async fn ely(username: &str, password: &str) -> crate::Result<Self> {
+ let client_token = Uuid::new_v4().to_string();
+
+ let response = INSECURE_REQWEST_CLIENT
+ .post(format!("{ELY_AUTHSERVER}/auth/authenticate"))
+ .json(&json!({
+ "username": username,
+ "password": password,
+ "clientToken": client_token,
+ "requestUser": false,
+ "agent": { "name": "Minecraft", "version": 1 },
+ }))
+ .send()
+ .await
+ .map_err(|error| {
+ crate::ErrorKind::OtherError(format!(
+ "Could not reach Ely.by: {error}"
+ ))
+ })?;
+
+ if !response.status().is_success() {
+ return Err(ely_error(response).await);
+ }
+
+ let auth = response.json::<ElyAuthResponse>().await?;
+
+ Ok(Self {
+ offline_profile: MinecraftProfile {
+ id: parse_ely_uuid(&auth.selected_profile.id)?,
+ name: auth.selected_profile.name,
+ ..MinecraftProfile::default()
+ },
+ access_token: auth.access_token,
+ refresh_token: format!(
+ "{ELY_REFRESH_TOKEN_PREFIX}{}",
+ auth.client_token
+ ),
+ // Ely.by does not say when its token expires. The expiry column is
+ // used as "check again after" instead, so the launcher asks Ely.by
+ // about the token a few times a day rather than on every read of
+ // the account list.
+ expires: Utc::now() + Duration::hours(6),
+ active: true,
+ })
+ }
+
+ /// Renews an Ely.by token pair, returning whether it is now usable.
+ ///
+ /// Ely.by refuses a pair that has been invalidated elsewhere - signing in
+ /// from another launcher does that - and there is no way back from it
+ /// without the password, so the caller is told rather than the launch
+ /// failing on its own later.
+ async fn refresh_ely(&mut self) -> crate::Result<bool> {
+ let Some(client_token) = self.ely_client_token() else {
+ return Ok(false);
+ };
+
+ let response = INSECURE_REQWEST_CLIENT
+ .post(format!("{ELY_AUTHSERVER}/auth/refresh"))
+ .json(&json!({
+ "accessToken": &self.access_token,
+ "clientToken": client_token,
+ "requestUser": false,
+ }))
+ .send()
+ .await;
+
+ let response = match response {
+ Ok(response) => response,
+ // Ely.by being unreachable says nothing about the token. Leave it
+ // alone: an offline launch with a still-valid token works.
+ Err(error) => {
+ tracing::warn!("Could not reach Ely.by to refresh: {error}");
+ return Ok(true);
+ }
+ };
+
+ if response.status().is_server_error() {
+ return Ok(true);
+ }
+
+ if !response.status().is_success() {
+ return Ok(false);
+ }
+
+ let auth = response.json::<ElyAuthResponse>().await?;
+ self.access_token = auth.access_token;
+ self.refresh_token =
+ format!("{ELY_REFRESH_TOKEN_PREFIX}{}", auth.client_token);
+ self.offline_profile = MinecraftProfile {
+ id: parse_ely_uuid(&auth.selected_profile.id)?,
+ name: auth.selected_profile.name,
+ ..MinecraftProfile::default()
+ };
+
+ Ok(true)
+ }
+
+ /// Whether Ely.by still accepts this account's access token.
+ async fn ely_token_is_valid(&self) -> bool {
+ let response = INSECURE_REQWEST_CLIENT
+ .post(format!("{ELY_AUTHSERVER}/auth/validate"))
+ .json(&json!({ "accessToken": &self.access_token }))
+ .send()
+ .await;
+
+ match response {
+ Ok(response) => response.status().is_success(),
+ // Unreachable is not invalid; see `refresh_ely`.
+ Err(_) => true,
+ }
+ }
+
/// Refreshes the authentication tokens for this user if they are expired, or
/// very close to expiration.
async fn refresh(
@@ -340,6 +530,22 @@ impl Credentials {
return Ok(());
}
+ // Ely.by issues its own tokens and renews them at its own endpoint,
+ // so Microsoft is not involved at any point below.
+ if self.is_ely() {
+ if !self.ely_token_is_valid().await && !self.refresh_ely().await? {
+ return Err(crate::ErrorKind::OtherError(
+ "Ely.by no longer accepts this account's session. Sign in again."
+ .to_string(),
+ )
+ .into());
+ }
+
+ self.expires = Utc::now() + Duration::hours(6);
+ self.upsert(exec).await?;
+ return Ok(());
+ }
+
let oauth_token = oauth_refresh(&self.refresh_token).await?;
let (pair, current_date) =
DeviceTokenPair::refresh_and_get_device_token(
@@ -413,9 +619,10 @@ 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() {
+ // Neither offline nor Ely.by accounts have a Mojang profile, so skip
+ // the request that would only ever fail and fall back to the profile
+ // recorded locally, which already holds the right id and name.
+ if self.is_offline() || self.is_ely() {
return None;
}
diff --git a/packages/app-lib/src/util/authlib_injector.rs b/packages/app-lib/src/util/authlib_injector.rs
new file mode 100644
index 0000000..dc099f9
--- /dev/null
+++ b/packages/app-lib/src/util/authlib_injector.rs
@@ -0,0 +1,77 @@
+//! Downloads and caches the authlib-injector Java agent.
+//!
+//! Minecraft asks Mojang who a player is. An Ely.by account is not a Mojang
+//! account, so the game has to be told to ask Ely.by instead, and there is no
+//! switch for that: authlib-injector is a Java agent that rewrites the calls
+//! on the way out. Without it an Ely.by account cannot start the game at all.
+
+use std::path::PathBuf;
+
+use crate::state::DirectoryInfo;
+use crate::util::fetch::REQWEST_CLIENT;
+use crate::util::io;
+
+/// The agent's own distribution metadata.
+const LATEST_URL: &str = "https://authlib-injector.yushi.moe/artifact/latest.json";
+
+#[derive(serde::Deserialize)]
+struct LatestArtifact {
+ download_url: String,
+}
+
+/// Returns the path to the agent jar, downloading it once if it is not cached.
+///
+/// The cached copy is reused as it is. The agent is not tied to a game or
+/// launcher version, so there is nothing to keep up to date, and reusing it
+/// means an Ely.by account still launches with no network at all.
+pub async fn get_authlib_injector(
+ directories: &DirectoryInfo,
+) -> crate::Result<PathBuf> {
+ let dir = directories.caches_dir().join("authlib-injector");
+ io::create_dir_all(&dir).await?;
+
+ let jar = dir.join("authlib-injector.jar");
+ if io::metadata(&jar).await.is_ok() {
+ return Ok(jar);
+ }
+
+ tracing::info!("Downloading authlib-injector for an Ely.by launch");
+
+ let latest = REQWEST_CLIENT
+ .get(LATEST_URL)
+ .send()
+ .await
+ .map_err(|error| {
+ crate::ErrorKind::OtherError(format!(
+ "Could not reach the authlib-injector distribution: {error}"
+ ))
+ })?
+ .json::<LatestArtifact>()
+ .await
+ .map_err(|error| {
+ crate::ErrorKind::OtherError(format!(
+ "Could not read the authlib-injector metadata: {error}"
+ ))
+ })?;
+
+ let bytes = REQWEST_CLIENT
+ .get(&latest.download_url)
+ .send()
+ .await
+ .map_err(|error| {
+ crate::ErrorKind::OtherError(format!(
+ "Could not download authlib-injector: {error}"
+ ))
+ })?
+ .bytes()
+ .await
+ .map_err(|error| {
+ crate::ErrorKind::OtherError(format!(
+ "Could not read the authlib-injector download: {error}"
+ ))
+ })?;
+
+ io::write(&jar, &bytes).await?;
+
+ Ok(jar)
+}
diff --git a/packages/app-lib/src/util/mod.rs b/packages/app-lib/src/util/mod.rs
index 1962d6c..395d7f1 100644
--- a/packages/app-lib/src/util/mod.rs
+++ b/packages/app-lib/src/util/mod.rs
@@ -1,4 +1,5 @@
//! Theseus utility functions
+pub mod authlib_injector;
pub(crate) mod content_hash;
pub mod fetch;
pub mod io;
@@ -0,0 +1,544 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Mon, 14 Sep 2026 14:43:17 +0200
Subject: [PATCH] Sign in to Microsoft in the player's own browser
The launcher opened Microsoft's sign-in page in a webview of its own,
which is the one place a player cannot use the tools they use everywhere
else: no password manager, no autofill, no passkeys, and no way to tell
by looking that the page is really Microsoft's.
The browser gets all of it. What it cannot do is hand the code back:
this client id is Minecraft's own, its only registered redirect is a
fixed page on login.live.com, and no loopback address is registered for
it, so there is nothing for the launcher to listen on. The browser
therefore lands on that page with the code in the address, and the
player copies the address over - which is what the new modal asks for,
and what `login_browser_finish` reads the code out of.
Every entry point goes through it, since they all end up at
`AccountsCard.login()`. The webview is still one click away in that
modal for anyone the browser does not work out for.
---
.../src/components/ui/AccountsCard.vue | 17 +-
.../src/components/ui/MicrosoftLoginModal.vue | 213 ++++++++++++++++++
.../MinecraftRequiredModal.vue | 29 +--
apps/app-frontend/src/helpers/auth.js | 25 ++
apps/app/build.rs | 2 +
apps/app/src/api/auth.rs | 77 +++++++
packages/app-lib/src/api/mod.rs | 12 +-
7 files changed, 333 insertions(+), 42 deletions(-)
create mode 100644 apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
index 35c21fa..c81603c 100644
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
@@ -105,6 +105,7 @@
</div>
</div>
</Accordion>
+ <MicrosoftLoginModal ref="microsoftLoginModal" @created="accountAdded" />
<OfflineAccountModal ref="offlineAccountModal" @created="accountAdded" />
<ElyAccountModal ref="elyAccountModal" @created="accountAdded" />
</template>
@@ -133,13 +134,12 @@ import type { Ref } from 'vue'
import { computed, onUnmounted, ref } from 'vue'
import ElyAccountModal from '@/components/ui/ElyAccountModal.vue'
+import MicrosoftLoginModal from '@/components/ui/MicrosoftLoginModal.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'
import {
get_default_user,
- login as login_flow,
remove_user,
set_default_user,
users,
@@ -165,6 +165,7 @@ type MinecraftCredential = {
const accounts: Ref<MinecraftCredential[]> = ref([])
const offlineAccountModal = ref<InstanceType<typeof OfflineAccountModal>>()
const elyAccountModal = ref<InstanceType<typeof ElyAccountModal>>()
+const microsoftLoginModal = ref<InstanceType<typeof MicrosoftLoginModal>>()
const loginDisabled = ref(false)
const defaultUser = ref<string | undefined>()
const equippedSkin = ref<Skin | null>(null)
@@ -275,16 +276,8 @@ async function setAccount(account: MinecraftCredential) {
emit('change')
}
-async function login() {
- loginDisabled.value = true
- const loggedIn = await login_flow().catch(handleSevereError)
-
- if (loggedIn) {
- await setAccount(loggedIn)
- }
-
- trackEvent('AccountLogIn')
- loginDisabled.value = false
+function login(event?: MouseEvent) {
+ microsoftLoginModal.value?.show(event)
}
async function accountAdded() {
diff --git a/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue b/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue
new file mode 100644
index 0000000..283b89c
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue
@@ -0,0 +1,213 @@
+<template>
+ <NewModal ref="modal" :header="formatMessage(messages.header)" max-width="520px" width="100%">
+ <div class="flex flex-col gap-4">
+ <p class="m-0 leading-tight text-secondary">
+ {{ formatMessage(messages.description) }}
+ </p>
+
+ <ol class="m-0 flex list-decimal flex-col gap-1 pl-5 leading-tight text-secondary">
+ <li>{{ formatMessage(messages.stepSignIn) }}</li>
+ <li>{{ formatMessage(messages.stepCopy) }}</li>
+ </ol>
+
+ <form class="flex flex-col gap-2" @submit.prevent="submit">
+ <label class="font-semibold text-contrast" for="microsoft-login-redirect">
+ {{ formatMessage(messages.addressLabel) }}
+ </label>
+ <Input
+ id="microsoft-login-redirect"
+ v-model="redirect"
+ :icon="LinkIcon"
+ placeholder="https://login.live.com/oauth20_desktop.srf?code=..."
+ :error="!!error"
+ 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>
+
+ <button
+ class="button-base m-0 cursor-pointer border-0 bg-transparent p-0 text-left text-sm text-secondary underline"
+ type="button"
+ :disabled="submitting"
+ @click="useBuiltInWindow"
+ >
+ {{ formatMessage(messages.useBuiltInWindow) }}
+ </button>
+ </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
+ native-type="button"
+ :disabled="submitting || opening"
+ @click="openBrowser"
+ >
+ <SpinnerIcon v-if="opening" aria-hidden="true" class="animate-spin" />
+ <ExternalIcon v-else aria-hidden="true" />
+ {{ formatMessage(messages.openAgain) }}
+ </Button>
+ <Button
+ type="colored"
+ color="brand"
+ native-type="button"
+ :disabled="submitting || !redirect.trim()"
+ @click="submit"
+ >
+ <SpinnerIcon v-if="submitting" aria-hidden="true" class="animate-spin" />
+ <LogInIcon v-else aria-hidden="true" />
+ {{ formatMessage(messages.finish) }}
+ </Button>
+ </div>
+ </template>
+ </NewModal>
+</template>
+
+<script setup lang="ts">
+import { ExternalIcon, LinkIcon, LogInIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
+import {
+ Button,
+ commonMessages,
+ defineMessages,
+ Input,
+ NewModal,
+ useVIntl,
+} from '@modrinth/ui'
+import { ref } from 'vue'
+
+import { handleSevereError } from '@/composables/use-error.js'
+import { login as builtInLogin, login_browser_begin, login_browser_finish } from '@/helpers/auth'
+
+const { formatMessage } = useVIntl()
+
+const emit = defineEmits<{
+ created: [account: unknown]
+}>()
+
+const modal = ref<InstanceType<typeof NewModal>>()
+const redirect = ref('')
+const error = ref('')
+const opening = ref(false)
+const submitting = ref(false)
+let flow: unknown = null
+
+async function show(event?: MouseEvent) {
+ redirect.value = ''
+ error.value = ''
+ submitting.value = false
+ modal.value?.show(event)
+ await openBrowser()
+}
+
+async function openBrowser() {
+ if (opening.value) return
+
+ opening.value = true
+ error.value = ''
+
+ try {
+ flow = await login_browser_begin()
+ } catch (e) {
+ flow = null
+ error.value = messageOf(e, messages.openError)
+ } finally {
+ opening.value = false
+ }
+}
+
+async function submit() {
+ if (submitting.value) return
+
+ const value = redirect.value.trim()
+ if (!value) return
+
+ if (!flow) {
+ error.value = formatMessage(messages.noFlow)
+ return
+ }
+
+ submitting.value = true
+ error.value = ''
+
+ try {
+ const account = await login_browser_finish(value, flow)
+ redirect.value = ''
+ modal.value?.hide()
+ emit('created', account)
+ } catch (e) {
+ error.value = messageOf(e, messages.genericError)
+ } finally {
+ submitting.value = false
+ }
+}
+
+// The window the launcher opens itself, for when the browser will not do.
+async function useBuiltInWindow() {
+ modal.value?.hide()
+
+ const account = await builtInLogin().catch(handleSevereError)
+ if (account) emit('created', account)
+}
+
+function messageOf(e: unknown, fallback: { id: string; defaultMessage: string }) {
+ if (typeof e === 'string') return e
+ return (e as Error)?.message ?? formatMessage(fallback)
+}
+
+defineExpose({ show })
+
+const messages = defineMessages({
+ header: {
+ id: 'app.microsoft-login.header',
+ defaultMessage: 'Sign in to Microsoft',
+ },
+ description: {
+ id: 'app.microsoft-login.description',
+ defaultMessage:
+ 'Your browser has opened, so your password manager and passkeys work as they do everywhere else. Microsoft cannot hand the result back to the launcher on its own, so it has to be copied over.',
+ },
+ stepSignIn: {
+ id: 'app.microsoft-login.step-sign-in',
+ defaultMessage: 'Sign in to Microsoft in the browser.',
+ },
+ stepCopy: {
+ id: 'app.microsoft-login.step-copy',
+ defaultMessage:
+ 'You will land on a blank page. Copy its whole address from the address bar and paste it below.',
+ },
+ addressLabel: {
+ id: 'app.microsoft-login.address-label',
+ defaultMessage: 'Address the browser finished at',
+ },
+ openAgain: {
+ id: 'app.microsoft-login.open-again',
+ defaultMessage: 'Open browser again',
+ },
+ finish: {
+ id: 'app.microsoft-login.finish',
+ defaultMessage: 'Sign in',
+ },
+ useBuiltInWindow: {
+ id: 'app.microsoft-login.use-built-in-window',
+ defaultMessage: 'Trouble with the browser? Sign in in a window here instead.',
+ },
+ openError: {
+ id: 'app.microsoft-login.open-error',
+ defaultMessage: 'Could not open a browser to sign in with.',
+ },
+ noFlow: {
+ id: 'app.microsoft-login.no-flow',
+ defaultMessage: 'Open the browser again before signing in.',
+ },
+ genericError: {
+ id: 'app.microsoft-login.generic-error',
+ defaultMessage: 'Could not sign in to Microsoft.',
+ },
+})
+</script>
diff --git a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
index 7781cee..341ccdc 100644
--- a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
+++ b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
@@ -25,10 +25,8 @@
<MessagesSquareIcon />
{{ formatMessage(messages.getSupport) }}
</ButtonLink>
- <Button type="colored" color="brand" :disabled="loadingSignIn" @click="signIn">
- <SpinnerIcon v-if="loadingSignIn" class="animate-spin" />
+ <Button type="colored" color="brand" @click="signIn">
<svg
- v-else
width="20"
height="20"
viewBox="0 0 20 20"
@@ -73,15 +71,12 @@
</template>
<script setup lang="ts">
-import { KeyIcon, MessagesSquareIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
+import { KeyIcon, MessagesSquareIcon, UserIcon } from '@modrinth/assets'
import { Button, ButtonLink, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { inject, type Ref, ref } from 'vue'
import steveImage from '@/assets/steve-look-up-left.webp'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
-import { handleSevereError } from '@/composables/use-error.js'
-import { trackEvent } from '@/helpers/analytics'
-import { login as loginFlow, set_default_user } from '@/helpers/auth.js'
const { formatMessage } = useVIntl()
const accountsCard = inject('accountsCard') as Ref<InstanceType<typeof AccountsCard> | null>
@@ -132,28 +127,14 @@ const messages = defineMessages({
})
const modal = ref<InstanceType<typeof NewModal>>()
-const loadingSignIn = ref(false)
function show() {
modal.value?.show()
}
-async function signIn() {
- loadingSignIn.value = true
-
- try {
- const loggedIn = await loginFlow()
- if (!loggedIn) return
-
- await set_default_user(loggedIn.profile.id)
- await accountsCard.value?.refreshValues()
- await trackEvent('AccountLogIn', { source: 'MinecraftRequiredModal' })
- modal.value?.hide()
- } catch (error) {
- handleSevereError(error)
- } finally {
- loadingSignIn.value = false
- }
+function signIn(event: MouseEvent) {
+ modal.value?.hide()
+ accountsCard.value?.login(event)
}
function addOfflineAccount(event: MouseEvent) {
diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js
index 57580ff..c25af95 100644
--- a/apps/app-frontend/src/helpers/auth.js
+++ b/apps/app-frontend/src/helpers/auth.js
@@ -33,6 +33,31 @@ export async function login() {
return await invoke('plugin:auth|login')
}
+/**
+ * Starts a Microsoft sign-in in the default browser.
+ *
+ * Opens the browser and returns the flow to hand back to
+ * {@link login_browser_finish} together with the address the browser ends up
+ * at. Unlike the window the launcher opens itself, the browser has the
+ * player's password manager, autofill and passkeys.
+ *
+ * @returns {Promise<object>} the login flow to finish with
+ */
+export async function login_browser_begin() {
+ return await invoke('plugin:auth|login_browser_begin')
+}
+
+/**
+ * Finishes a browser sign-in.
+ *
+ * @param {string} redirect the address the browser finished at, or the code in it
+ * @param {object} flow the flow returned by {@link login_browser_begin}
+ * @returns {Promise<Credential>}
+ */
+export async function login_browser_finish(redirect, flow) {
+ return await invoke('plugin:auth|login_browser_finish', { redirect, flow })
+}
+
/**
* Adds an offline account with the given username and makes it the active one.
*
diff --git a/apps/app/build.rs b/apps/app/build.rs
index 0f62dd8..8562a68 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -14,6 +14,8 @@ fn main() {
.commands(&[
"check_reachable",
"login",
+ "login_browser_begin",
+ "login_browser_finish",
"login_offline",
"login_ely",
"remove_user",
diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs
index dea07b2..5c3648c 100644
--- a/apps/app/src/api/auth.rs
+++ b/apps/app/src/api/auth.rs
@@ -2,6 +2,7 @@ use crate::api::Result;
use chrono::{Duration, Utc};
use tauri::plugin::TauriPlugin;
use tauri::{Manager, Runtime, UserAttentionType};
+use tauri_plugin_opener::OpenerExt;
use theseus::prelude::*;
pub fn init<R: Runtime>() -> TauriPlugin<R> {
@@ -9,6 +10,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.invoke_handler(tauri::generate_handler![
check_reachable,
login,
+ login_browser_begin,
+ login_browser_finish,
login_offline,
login_ely,
remove_user,
@@ -88,6 +91,80 @@ pub async fn login<R: Runtime>(
Ok(None)
}
+/// Starts a Microsoft sign-in in the player's own browser.
+///
+/// The window the launcher opens for this has no password manager, no
+/// autofill and no passkeys, which makes the launcher the one place a player
+/// cannot sign in the way they sign in everywhere else. Their own browser has
+/// all of it.
+///
+/// What it cannot do is hand the code back. This client id's redirect is a
+/// fixed page on login.live.com - no loopback address is registered for it, so
+/// there is nothing for the launcher to listen on - and the browser lands
+/// there with the code in the address. The player copies that address over,
+/// which is what [`login_browser_finish`] takes.
+#[tauri::command]
+pub async fn login_browser_begin<R: Runtime>(
+ app: tauri::AppHandle<R>,
+) -> Result<MinecraftLoginFlow> {
+ let flow = minecraft_auth::begin_login().await?;
+
+ app.opener()
+ .open_url(&flow.auth_request_uri, None::<String>)
+ .map_err(|error| {
+ theseus::ErrorKind::OtherError(format!(
+ "Could not open a browser to sign in with: {error}"
+ ))
+ .as_error()
+ })?;
+
+ Ok(flow)
+}
+
+/// Finishes a browser sign-in with the address the browser ended up at.
+///
+/// Takes the whole address, since that is what a player can select and copy,
+/// and a bare code as well, since that is what someone who knows what they are
+/// looking at will paste.
+#[tauri::command]
+pub async fn login_browser_finish(
+ redirect: String,
+ flow: MinecraftLoginFlow,
+) -> Result<Credentials> {
+ let redirect = redirect.trim();
+
+ let code = url::Url::parse(redirect)
+ .ok()
+ .and_then(|url| {
+ url.query_pairs()
+ .find(|(key, _)| key == "code")
+ .map(|(_, code)| code.into_owned())
+ })
+ .or_else(|| {
+ // Not a URL, so treat it as the code itself - but only if it looks
+ // like one, rather than sending whatever was in the clipboard.
+ (!redirect.is_empty()
+ && redirect.len() < 2048
+ && redirect.bytes().all(|byte| {
+ byte.is_ascii_alphanumeric()
+ || byte == b'.'
+ || byte == b'-'
+ || byte == b'_'
+ }))
+ .then(|| redirect.to_owned())
+ })
+ .ok_or_else(|| {
+ theseus::ErrorKind::InputError(
+ "That does not look like the address the browser finished at. \
+ Copy the whole address out of the address bar."
+ .to_string(),
+ )
+ .as_error()
+ })?;
+
+ Ok(minecraft_auth::finish_login(&code, flow).await?)
+}
+
/// Adds an offline account with the given username and makes it active.
#[tauri::command]
pub async fn login_offline(username: String) -> Result<Credentials> {
diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs
index 320112f..743d5e7 100644
--- a/packages/app-lib/src/api/mod.rs
+++ b/packages/app-lib/src/api/mod.rs
@@ -28,12 +28,12 @@ pub mod data {
InstanceInstallCandidate, InstanceInstallTarget,
InstanceLaunchOverridesPatch, InstanceLink, InstanceMetadata,
InstanceSyncedOption, InstanceSyncedOptions, InstanceTabVisibility,
- JavaVersion, LinkedModpackInfo, MemorySettings, ModLoader,
- ModrinthCredentials, OnboardingChecklist, Organization, OwnerType,
- ProcessMetadata, Project, ProjectType, ProjectV3, SearchResult,
- SearchResults, SearchResultsV3, Settings, SharedInstanceAttachment,
- SharedInstanceRole, TeamMember, Theme, User, UserFriend, Version,
- WindowSize,
+ JavaVersion, LinkedModpackInfo, MemorySettings, MinecraftLoginFlow,
+ ModLoader, ModrinthCredentials, OnboardingChecklist, Organization,
+ OwnerType, ProcessMetadata, Project, ProjectType, ProjectV3,
+ SearchResult, SearchResults, SearchResultsV3, Settings,
+ SharedInstanceAttachment, SharedInstanceRole, TeamMember, Theme, User,
+ UserFriend, Version, WindowSize,
};
pub use ariadne::users::UserStatus;
pub use modrinth_content_management::{
@@ -0,0 +1,106 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Mon, 14 Sep 2026 19:15:01 +0200
Subject: [PATCH] Round the window corners on Linux
---
apps/app-frontend/src/App.vue | 10 +++++++++
.../src/assets/stylesheets/global.scss | 17 ++++++++++++++
apps/app-frontend/src/main.js | 5 +++++
apps/app/tauri.linux.conf.json | 22 ++++++++++++++++++-
4 files changed, 53 insertions(+), 1 deletion(-)
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index ac8308c..d681a14 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -540,6 +540,16 @@ watch([os, isFullscreen], ([osName, fullscreen]) => {
document.documentElement.classList.toggle('mac-traffic-lights', osName === 'MacOS' && !fullscreen)
})
+watch(
+ [os, isMaximized, isFullscreen, () => appSettings.nativeDecorations],
+ ([osName, maximized, fullscreen, nativeDecorations]) => {
+ document.documentElement.classList.toggle(
+ 'rounded-window',
+ osName === 'Linux' && !nativeDecorations && !maximized && !fullscreen,
+ )
+ },
+)
+
const authUnreachableDebug = useDebugLogger('AuthReachableChecker')
const authServerQuery = useQuery({
queryKey: ['authServerReachability'],
diff --git a/apps/app-frontend/src/assets/stylesheets/global.scss b/apps/app-frontend/src/assets/stylesheets/global.scss
index a9fa554..ba4da08 100644
--- a/apps/app-frontend/src/assets/stylesheets/global.scss
+++ b/apps/app-frontend/src/assets/stylesheets/global.scss
@@ -66,6 +66,23 @@ body {
overflow: hidden;
}
+// An undecorated window on Linux is transparent (tauri.linux.conf.json), so the
+// page paints its own background and cuts the corners off. clip-path rather
+// than overflow, because overflow does not clip fixed-position modals.
+html.rounded-window,
+html.rounded-window body {
+ background: transparent;
+}
+
+html.rounded-window body {
+ clip-path: inset(0 round 10px);
+}
+
+html.rounded-window #app {
+ height: 100%;
+ background-color: var(--color-raised-bg);
+}
+
* {
box-sizing: border-box;
}
diff --git a/apps/app-frontend/src/main.js b/apps/app-frontend/src/main.js
index 9607cfb..e7991ef 100644
--- a/apps/app-frontend/src/main.js
+++ b/apps/app-frontend/src/main.js
@@ -13,6 +13,11 @@ import i18nDebugPlugin from '@/plugins/i18n-debug'
import router from '@/routes'
debugStartup('Frontend entry module evaluated')
+// Round the corners from the first frame on; App.vue squares them again once it
+// knows the window is maximized or natively decorated.
+if (navigator.userAgent.includes('Linux')) {
+ document.documentElement.classList.add('rounded-window')
+}
const app = createApp(App)
setupErrorReporting(app, router)
diff --git a/apps/app/tauri.linux.conf.json b/apps/app/tauri.linux.conf.json
index 81a368c..68fadf3 100644
--- a/apps/app/tauri.linux.conf.json
+++ b/apps/app/tauri.linux.conf.json
@@ -1,3 +1,23 @@
{
- "mainBinaryName": "ModrinthEnhanced"
+ "mainBinaryName": "ModrinthEnhanced",
+ "app": {
+ "windows": [
+ {
+ "titleBarStyle": "Overlay",
+ "hiddenTitle": true,
+ "fullscreen": false,
+ "height": 800,
+ "resizable": true,
+ "title": "Modrinth Enhanced",
+ "label": "main",
+ "width": 1280,
+ "minHeight": 700,
+ "minWidth": 1100,
+ "visible": false,
+ "zoomHotkeysEnabled": false,
+ "decorations": false,
+ "transparent": true
+ }
+ ]
+ }
}
@@ -0,0 +1,400 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Mon, 14 Sep 2026 19:17:53 +0200
Subject: [PATCH] Scroll with the middle mouse button
---
apps/app-frontend/src/App.vue | 8 +
.../src/assets/stylesheets/global.scss | 50 +++
apps/app-frontend/src/helpers/autoscroll.ts | 290 ++++++++++++++++++
3 files changed, 348 insertions(+)
create mode 100644 apps/app-frontend/src/helpers/autoscroll.ts
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index d681a14..2102c4c 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -124,6 +124,7 @@ import {
} from '@/helpers/ads.js'
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js'
+import { installAutoscroll } from '@/helpers/autoscroll.ts'
import { get_user, get_user_many, get_version } from '@/helpers/cache.js'
import { gameSettingsQueryOptions } from '@/helpers/game-options'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
@@ -600,11 +601,18 @@ onMounted(async () => {
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
document.querySelector('body').addEventListener('contextmenu', handleContextMenu)
document.addEventListener('fullscreenchange', handleFullscreenChange)
+ // Chromium, and so WebView2 on Windows, already autoscrolls by itself
+ if (!navigator.userAgent.includes('Windows')) {
+ uninstallAutoscroll = installAutoscroll()
+ }
checkUpdates()
})
+let uninstallAutoscroll
+
onUnmounted(async () => {
+ uninstallAutoscroll?.()
document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
document.querySelector('body').removeEventListener('contextmenu', handleContextMenu)
diff --git a/apps/app-frontend/src/assets/stylesheets/global.scss b/apps/app-frontend/src/assets/stylesheets/global.scss
index ba4da08..c5129ac 100644
--- a/apps/app-frontend/src/assets/stylesheets/global.scss
+++ b/apps/app-frontend/src/assets/stylesheets/global.scss
@@ -83,6 +83,56 @@ html.rounded-window #app {
background-color: var(--color-raised-bg);
}
+// Middle-click autoscroll (helpers/autoscroll.ts)
+html.autoscrolling,
+html.autoscrolling * {
+ cursor: all-scroll !important;
+}
+
+.autoscroll-shield {
+ position: fixed;
+ inset: 0;
+ z-index: 2147483646;
+}
+
+.autoscroll-indicator {
+ position: fixed;
+ z-index: 2147483647;
+ pointer-events: none;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ color: var(--color-contrast);
+ background-color: var(--color-raised-bg);
+ // A ring instead of a border, which would shrink the space the icon has.
+ box-shadow:
+ 0 0 0 1px var(--color-button-bg),
+ 0 2px 8px rgba(0, 0, 0, 0.35);
+
+ // Overrides the global `svg { width: 1em; height: 1em }`, which squeezed the
+ // icon into the top left corner.
+ svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+ }
+
+ path {
+ opacity: 0.45;
+ transition:
+ opacity 0.1s,
+ stroke 0.1s;
+ }
+
+ &[data-dir*='n'] [data-dir='n'],
+ &[data-dir*='s'] [data-dir='s'],
+ &[data-dir*='e'] [data-dir='e'],
+ &[data-dir*='w'] [data-dir='w'] {
+ opacity: 1;
+ stroke: var(--color-brand);
+ }
+}
+
* {
box-sizing: border-box;
}
diff --git a/apps/app-frontend/src/helpers/autoscroll.ts b/apps/app-frontend/src/helpers/autoscroll.ts
new file mode 100644
index 0000000..82e1790
--- /dev/null
+++ b/apps/app-frontend/src/helpers/autoscroll.ts
@@ -0,0 +1,290 @@
+// Middle-click autoscroll, as browsers on Windows do it. WebKitGTK has none:
+// on Linux the middle button pastes instead.
+//
+// Press and release to keep scrolling until the next click, or press, drag and
+// release to scroll only while the button is held.
+
+const DEAD_ZONE = 12
+// A press shorter than this, without leaving the dead zone, is a click that
+// leaves autoscroll running.
+const CLICK_MS = 300
+
+const INTERACTIVE =
+ 'a, button, input, textarea, select, [contenteditable]:not([contenteditable="false"])'
+
+type Axes = { x: boolean; y: boolean }
+
+function overflowScrolls(overflow: string) {
+ return overflow === 'auto' || overflow === 'scroll'
+}
+
+// Every ancestor that can scroll, innermost first.
+function scrollersFrom(start: Element | null): Element[] {
+ const scrollers: Element[] = []
+ for (let el = start; el && el !== document.documentElement; el = el.parentElement) {
+ const style = getComputedStyle(el)
+ if (overflowScrolls(style.overflowX) || overflowScrolls(style.overflowY)) scrollers.push(el)
+ }
+ return scrollers
+}
+
+function scrollableAxes(scrollers: Element[]): Axes {
+ const axes = { x: false, y: false }
+ for (const el of scrollers) {
+ const style = getComputedStyle(el)
+ axes.x ||= overflowScrolls(style.overflowX) && el.scrollWidth > el.clientWidth
+ axes.y ||= overflowScrolls(style.overflowY) && el.scrollHeight > el.clientHeight
+ }
+ return axes
+}
+
+// Scroll the innermost scroller that can still move this way, and hand the
+// movement outwards once it reaches its end, as wheel scrolling does. Returns
+// whether anything moved.
+function scrollChained(scrollers: Element[], left: number, top: number): boolean {
+ let moved = false
+ for (const [delta, vertical] of [
+ [left, false],
+ [top, true],
+ ] as const) {
+ if (!delta) continue
+ for (const el of scrollers) {
+ const before = vertical ? el.scrollTop : el.scrollLeft
+ el.scrollBy(
+ vertical ? { top: delta, behavior: 'instant' } : { left: delta, behavior: 'instant' },
+ )
+ if ((vertical ? el.scrollTop : el.scrollLeft) !== before) {
+ moved = true
+ break
+ }
+ }
+ }
+ return moved
+}
+
+// WebKitGTK can leave the painted scroll position a few frames behind fast
+// programmatic scrolling. Once the real position stops changing, as it does at
+// either end, it never paints the last ones: the page looks stuck short of the
+// end while hit testing already uses the real position. A one pixel round trip
+// over two frames makes it paint again.
+function repaintScrollPositions(scrollers: Element[]) {
+ for (const el of scrollers) {
+ for (const vertical of [true, false]) {
+ const range = vertical ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth
+ if (range <= 0) continue
+ const position = vertical ? el.scrollTop : el.scrollLeft
+ const away = position > 0 ? position - 1 : position + 1
+ if (vertical) el.scrollTop = away
+ else el.scrollLeft = away
+ requestAnimationFrame(() => {
+ if (vertical) el.scrollTop = position
+ else el.scrollLeft = position
+ })
+ }
+ }
+}
+
+function createIndicator(x: number, y: number, axes: Axes): HTMLElement {
+ const chevrons = [
+ axes.y ? '<path data-dir="n" d="M11 10l5-5 5 5"/><path data-dir="s" d="M11 22l5 5 5-5"/>' : '',
+ axes.x ? '<path data-dir="w" d="M10 11l-5 5 5 5"/><path data-dir="e" d="M22 11l5 5-5 5"/>' : '',
+ ].join('')
+ const indicator = document.createElement('div')
+ indicator.className = 'autoscroll-indicator'
+ indicator.style.left = `${x - 16}px`
+ indicator.style.top = `${y - 16}px`
+ indicator.innerHTML = `<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="16" cy="16" r="2" fill="currentColor" stroke="none"/>${chevrons}</svg>`
+ document.body.appendChild(indicator)
+ return indicator
+}
+
+// Covers the page while autoscroll runs. The pointer does not move while the
+// page does, so without it whatever scrolls under the pointer would light up
+// as hovered.
+function createShield(): HTMLElement {
+ const shield = document.createElement('div')
+ shield.className = 'autoscroll-shield'
+ document.body.appendChild(shield)
+ return shield
+}
+
+// Pixels per second for a pointer this far from where autoscroll started.
+function speed(distance: number): number {
+ const past = Math.abs(distance) - DEAD_ZONE
+ if (past <= 0) return 0
+ return Math.sign(distance) * Math.min(past ** 1.5 * 1.2, 8000)
+}
+
+export function installAutoscroll(): () => void {
+ let active: {
+ origin: Element
+ scrollers: Element[]
+ axes: Axes
+ startX: number
+ startY: number
+ pointerX: number
+ pointerY: number
+ pressedAt: number
+ held: boolean
+ carryX: number
+ carryY: number
+ lastFrame: number
+ frame: number
+ stuckFrames: number
+ indicator: HTMLElement
+ shield: HTMLElement
+ } | null = null
+ // The mouseup, click and auxclick that belong to a press autoscroll consumed.
+ let swallowButtons = 0
+
+ function stop() {
+ if (!active) return
+ cancelAnimationFrame(active.frame)
+ active.indicator.remove()
+ active.shield.remove()
+ document.documentElement.classList.remove('autoscrolling')
+ repaintScrollPositions(active.scrollers)
+ active = null
+ }
+
+ function tick(now: number) {
+ if (!active) return
+ const dt = Math.min(now - active.lastFrame, 100) / 1000
+ active.lastFrame = now
+
+ // The page may have re-rendered the element autoscroll started on.
+ if (!active.origin.isConnected) {
+ active.shield.style.pointerEvents = 'none'
+ const origin = document.elementFromPoint(active.startX, active.startY)
+ active.shield.style.pointerEvents = ''
+ if (origin) {
+ active.origin = origin
+ active.scrollers = scrollersFrom(origin)
+ }
+ }
+
+ active.carryX += active.axes.x ? speed(active.pointerX - active.startX) * dt : 0
+ active.carryY += active.axes.y ? speed(active.pointerY - active.startY) * dt : 0
+ const left = Math.trunc(active.carryX)
+ const top = Math.trunc(active.carryY)
+ if (left || top) {
+ if (scrollChained(active.scrollers, left, top)) {
+ active.stuckFrames = 0
+ } else if (++active.stuckFrames === 2) {
+ // Just ran into an end: make sure the last frames get painted.
+ repaintScrollPositions(active.scrollers)
+ }
+ active.carryX -= left
+ active.carryY -= top
+ }
+ active.frame = requestAnimationFrame(tick)
+ }
+
+ function swallow(e: Event) {
+ e.preventDefault()
+ e.stopImmediatePropagation()
+ }
+
+ function onMouseDown(e: MouseEvent) {
+ if (active) {
+ // Any press ends a running autoscroll and does nothing else.
+ swallow(e)
+ stop()
+ swallowButtons = 1 << e.button
+ return
+ }
+ swallowButtons = 0
+ if (e.button !== 1 || !(e.target instanceof Element)) return
+ if (e.target.closest(INTERACTIVE)) return
+
+ const scrollers = scrollersFrom(e.target)
+ const axes = scrollableAxes(scrollers)
+ if (!axes.x && !axes.y) return
+
+ swallow(e)
+ swallowButtons = 1 << e.button
+ active = {
+ origin: e.target,
+ scrollers,
+ axes,
+ startX: e.clientX,
+ startY: e.clientY,
+ pointerX: e.clientX,
+ pointerY: e.clientY,
+ pressedAt: performance.now(),
+ held: false,
+ carryX: 0,
+ carryY: 0,
+ lastFrame: performance.now(),
+ frame: 0,
+ stuckFrames: 0,
+ shield: createShield(),
+ indicator: createIndicator(e.clientX, e.clientY, axes),
+ }
+ document.documentElement.classList.add('autoscrolling')
+ active.frame = requestAnimationFrame(tick)
+ }
+
+ function onMouseMove(e: MouseEvent) {
+ if (!active) return
+ active.pointerX = e.clientX
+ active.pointerY = e.clientY
+ const dx = e.clientX - active.startX
+ const dy = e.clientY - active.startY
+ if (Math.abs(dx) > DEAD_ZONE || Math.abs(dy) > DEAD_ZONE) {
+ active.held = true
+ }
+
+ // Light up the chevrons for the direction the page is moving in
+ const vertical = active.axes.y && Math.abs(dy) > DEAD_ZONE ? (dy < 0 ? 'n' : 's') : ''
+ const horizontal = active.axes.x && Math.abs(dx) > DEAD_ZONE ? (dx < 0 ? 'w' : 'e') : ''
+ active.indicator.dataset.dir = vertical + horizontal
+ }
+
+ function onMouseUp(e: MouseEvent) {
+ if (!(swallowButtons & (1 << e.button))) return
+ swallow(e)
+ if (
+ active &&
+ e.button === 1 &&
+ (active.held || performance.now() - active.pressedAt > CLICK_MS)
+ ) {
+ stop()
+ }
+ }
+
+ function onClick(e: MouseEvent) {
+ if (!(swallowButtons & (1 << e.button))) return
+ swallow(e)
+ swallowButtons &= ~(1 << e.button)
+ }
+
+ function onKeyDown(e: KeyboardEvent) {
+ if (!active) return
+ if (e.key === 'Escape') swallow(e)
+ stop()
+ }
+
+ const listeners: [string, (e: never) => void][] = [
+ ['mousedown', onMouseDown],
+ ['mousemove', onMouseMove],
+ ['mouseup', onMouseUp],
+ ['click', onClick],
+ ['auxclick', onClick],
+ ['keydown', onKeyDown],
+ ['wheel', stop],
+ // Capturing on window also sees every element losing focus; only the
+ // window itself losing it matters.
+ ['blur', (e: FocusEvent) => e.target === window && stop()],
+ ]
+ for (const [type, listener] of listeners) {
+ window.addEventListener(type, listener as EventListener, { capture: true })
+ }
+
+ return () => {
+ stop()
+ for (const [type, listener] of listeners) {
+ window.removeEventListener(type, listener as EventListener, { capture: true })
+ }
+ }
+}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,999 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Tue, 15 Sep 2026 09:26:27 +0200
Subject: [PATCH] Launch a running instance again on another account
An instance that is running can be started once more from the button
beside Stop on its page. The new copy runs as whichever account is
selected now, so switching accounts first is how one instance is played
on two accounts at once. A plain launch still refuses a running instance,
so a double-clicked play button stays one game.
The live log was kept per instance, so two copies would have written
into one buffer. It is kept per process now, log events carry the
process they came from, and while more than one copy runs the Logs tab
shows a console for each, named after its account. The instance's log
file is shared: another copy appends to it under a header naming its
account instead of wiping what the first is still writing.
The app event bindings are regenerated for the new log event field.
Adapted from Noctrinth (https://github.com/Everelsu/Noctrinth).
---
.../src/components/ui/ProcessConsole.vue | 92 +++++++++++++
.../src/composables/useInstanceConsole.ts | 33 ++++-
.../src/generated/app-events/LogPayload.ts | 6 +-
.../generated/app-events/postcard/index.d.ts | 2 +-
.../generated/app-events/postcard/index.js | 1 +
apps/app-frontend/src/helpers/instance.ts | 4 +-
apps/app-frontend/src/helpers/logs.js | 10 ++
.../instance/components/page-header/index.vue | 18 +++
.../src/pages/instance/layout.vue | 25 ++++
.../src/pages/instance/logs/index.vue | 30 ++++-
.../src/providers/setup/app-event-codec.ts | 2 +-
apps/app/build.rs | 2 +
apps/app/src/api/instance.rs | 9 +-
apps/app/src/api/logs.rs | 19 +++
packages/app-lib/src/api/instance.rs | 3 +-
packages/app-lib/src/api/instance/run.rs | 24 +++-
packages/app-lib/src/api/logs.rs | 23 +++-
packages/app-lib/src/event/mod.rs | 2 +
packages/app-lib/src/launcher/mod.rs | 41 +++---
packages/app-lib/src/state/process.rs | 123 ++++++++++++++----
20 files changed, 418 insertions(+), 51 deletions(-)
create mode 100644 apps/app-frontend/src/components/ui/ProcessConsole.vue
diff --git a/apps/app-frontend/src/components/ui/ProcessConsole.vue b/apps/app-frontend/src/components/ui/ProcessConsole.vue
new file mode 100644
index 0000000..b5102eb
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/ProcessConsole.vue
@@ -0,0 +1,92 @@
+<!--
+ The live console of one copy of an instance, for when it runs more than once.
+ It provides a console manager of its own, so several can sit side by side.
+-->
+<template>
+ <div class="flex min-h-0 min-w-0 flex-1 flex-col gap-2">
+ <div class="flex items-center gap-2 text-sm">
+ <OnlineIndicatorIcon />
+ <span class="text-contrast">
+ {{ process.account_name || formatMessage(messages.unknownAccount) }}
+ </span>
+ <span v-if="startedAt" class="text-secondary">
+ {{ formatMessage(messages.startedAt, { time: startedAt }) }}
+ </span>
+ </div>
+ <ConsolePageLayout />
+ </div>
+</template>
+
+<script setup lang="ts">
+import { OnlineIndicatorIcon } from '@modrinth/assets'
+import { ConsolePageLayout, defineMessages, provideConsoleManager, useVIntl } from '@modrinth/ui'
+import { computed, onMounted, ref, shallowRef, triggerRef, watch } from 'vue'
+
+import { useAppEvent } from '@/composables/use-app-event'
+import { useProcessConsole } from '@/composables/useInstanceConsole'
+
+const props = defineProps<{
+ process: { uuid: string; account_name?: string; start_time?: string }
+}>()
+
+const { formatMessage } = useVIntl()
+
+const messages = defineMessages({
+ startedAt: {
+ id: 'instance.console.copy-started-at',
+ defaultMessage: 'started {time}',
+ },
+ unknownAccount: {
+ id: 'instance.console.copy-unknown-account',
+ defaultMessage: 'Unknown account',
+ },
+})
+
+const { console: liveConsole, hydrate, clear } = useProcessConsole(props.process.uuid)
+
+const startedAt = computed(() => {
+ if (!props.process.start_time) return ''
+ const started = new Date(props.process.start_time)
+ return Number.isNaN(started.getTime()) ? '' : started.toLocaleTimeString()
+})
+
+const loading = ref(true)
+const logLines = shallowRef(liveConsole.output.value)
+
+watch(
+ liveConsole.output,
+ (lines) => {
+ logLines.value = lines
+ triggerRef(logLines)
+ },
+ { deep: true },
+)
+
+onMounted(async () => {
+ await hydrate().catch((error) => {
+ console.warn('Could not read what this copy has said so far:', error)
+ })
+ loading.value = false
+})
+
+// Every console hears every line; each keeps its own copy's.
+useAppEvent('log', (payload) => {
+ if (payload.process_uuid !== props.process.uuid) return
+
+ if (payload.type === 'log4j') {
+ liveConsole.addLog4jEvent(payload)
+ } else if (payload.type === 'legacy') {
+ liveConsole.addLegacyLog(payload.message)
+ }
+})
+
+provideConsoleManager({
+ logLines,
+ loading,
+ showCommandInput: false,
+ emptyStateType: 'instance',
+ onClear: () => {
+ void clear()
+ },
+})
+</script>
diff --git a/apps/app-frontend/src/composables/useInstanceConsole.ts b/apps/app-frontend/src/composables/useInstanceConsole.ts
index e7e30d4..c2b3277 100644
--- a/apps/app-frontend/src/composables/useInstanceConsole.ts
+++ b/apps/app-frontend/src/composables/useInstanceConsole.ts
@@ -1,6 +1,12 @@
import { createConsoleState } from '@modrinth/ui'
-import { clear_log_buffer, get_live_log_buffer, get_logs } from '@/helpers/logs'
+import {
+ clear_log_buffer,
+ clear_log_buffer_for_process,
+ get_live_log_buffer,
+ get_live_log_buffer_for_process,
+ get_logs,
+} from '@/helpers/logs'
type ConsoleState = ReturnType<typeof createConsoleState>
@@ -84,6 +90,31 @@ async function destroy(instanceId: string): Promise<void> {
await clear_log_buffer(instanceId).catch(() => {})
}
+/** Live consoles of single processes, for an instance running more than once. */
+const processConsoles = new Map<string, ConsoleState>()
+
+export function useProcessConsole(processUuid: string) {
+ let state = processConsoles.get(processUuid)
+ if (!state) {
+ state = createConsoleState()
+ processConsoles.set(processUuid, state)
+ }
+ const console = state
+
+ return {
+ console,
+ hydrate: async () => {
+ if (console.output.value.length > 0) return
+ const buffer = await get_live_log_buffer_for_process(processUuid)
+ if (buffer) console.addLegacyLog(buffer)
+ },
+ clear: async () => {
+ console.clear()
+ await clear_log_buffer_for_process(processUuid).catch(() => {})
+ },
+ }
+}
+
export function useInstanceConsole(instanceId: string) {
const entry = getOrCreate(instanceId)
return {
diff --git a/apps/app-frontend/src/generated/app-events/LogPayload.ts b/apps/app-frontend/src/generated/app-events/LogPayload.ts
index 977bf82..c390c4c 100644
--- a/apps/app-frontend/src/generated/app-events/LogPayload.ts
+++ b/apps/app-frontend/src/generated/app-events/LogPayload.ts
@@ -1,4 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Log4jEvent } from "./Log4jEvent";
-export type LogPayload = { instance_id: string, } & ({ "type": "log4j" } & Log4jEvent | { "type": "legacy", message: string, });
+export type LogPayload = { instance_id: string,
+/**
+ * Which process said this: an instance can run more than once.
+ */
+process_uuid: string, } & ({ "type": "log4j" } & Log4jEvent | { "type": "legacy", message: string, });
diff --git a/apps/app-frontend/src/generated/app-events/postcard/index.d.ts b/apps/app-frontend/src/generated/app-events/postcard/index.d.ts
index e763358..149f9ef 100644
--- a/apps/app-frontend/src/generated/app-events/postcard/index.d.ts
+++ b/apps/app-frontend/src/generated/app-events/postcard/index.d.ts
@@ -50,7 +50,7 @@ export type FriendPayload = { tag: "friend_request", value: { from: string } } |
export type FriendStatusPayload = { user_id: string, profile_name: string | undefined, last_update: string }
export type SharedInstanceUnavailableReason = { tag: "deleted" } | { tag: "access_revoked" } | { tag: "quarantined" }
export type LogEvent = { tag: "log4j", value: Log4jEvent } | { tag: "legacy", value: { message: string } }
-export type LogPayload = { instance_id: string, event: LogEvent }
+export type LogPayload = { instance_id: string, process_uuid: string, event: LogEvent }
export type OnboardingChecklist = { has_created_instance: boolean, has_logged_into_minecraft: boolean, has_logged_into_modrinth: boolean, show_checklist: boolean }
export type Log4jEvent = { timestamp_millis: i64 | undefined, logger_name: string | undefined, level: string | undefined, thread_name: string | undefined, message: string | undefined, throwable: string | undefined }
export type ModLoader = { tag: "vanilla" } | { tag: "forge" } | { tag: "fabric" } | { tag: "quilt" } | { tag: "neoforge" }
diff --git a/apps/app-frontend/src/generated/app-events/postcard/index.js b/apps/app-frontend/src/generated/app-events/postcard/index.js
index 2407d7d..1b343a0 100644
--- a/apps/app-frontend/src/generated/app-events/postcard/index.js
+++ b/apps/app-frontend/src/generated/app-events/postcard/index.js
@@ -475,6 +475,7 @@ function deserialize_LOG_EVENT(d) {
function deserialize_LOG_PAYLOAD(d) {
return {
instance_id: d.deserialize_string(),
+ process_uuid: d.deserialize_string(),
event: deserialize_LOG_EVENT(d)
};
}
diff --git a/apps/app-frontend/src/helpers/instance.ts b/apps/app-frontend/src/helpers/instance.ts
index a0c0b56..817a63c 100644
--- a/apps/app-frontend/src/helpers/instance.ts
+++ b/apps/app-frontend/src/helpers/instance.ts
@@ -601,11 +601,13 @@ export async function get_pack_export_candidates(
// Run Minecraft using an instance
// Returns PID of child
+// `additional` starts an instance that is already running once more.
export async function run(
instanceId: string,
serverAddress: string | null = null,
+ additional = false,
): Promise<unknown> {
- return await invoke('plugin:instance|instance_run', { instanceId, serverAddress })
+ return await invoke('plugin:instance|instance_run', { instanceId, serverAddress, additional })
}
export async function kill(instanceId: string): Promise<void> {
diff --git a/apps/app-frontend/src/helpers/logs.js b/apps/app-frontend/src/helpers/logs.js
index 843e572..44d07d5 100644
--- a/apps/app-frontend/src/helpers/logs.js
+++ b/apps/app-frontend/src/helpers/logs.js
@@ -72,3 +72,13 @@ export async function get_live_log_buffer(instanceId) {
export async function clear_log_buffer(instanceId) {
return await invoke('plugin:logs|logs_clear_live_log_buffer', { instanceId })
}
+
+/// Get the buffered live log lines of one process
+export async function get_live_log_buffer_for_process(processUuid) {
+ return await invoke('plugin:logs|logs_get_live_log_buffer_for_process', { processUuid })
+}
+
+/// Clear the live log buffer of one process
+export async function clear_log_buffer_for_process(processUuid) {
+ return await invoke('plugin:logs|logs_clear_live_log_buffer_for_process', { processUuid })
+}
diff --git a/apps/app-frontend/src/pages/instance/components/page-header/index.vue b/apps/app-frontend/src/pages/instance/components/page-header/index.vue
index ce4b436..31a6ded 100644
--- a/apps/app-frontend/src/pages/instance/components/page-header/index.vue
+++ b/apps/app-frontend/src/pages/instance/components/page-header/index.vue
@@ -144,6 +144,19 @@
formatMessage(messages.starting)
}}</Button>
+ <!-- Beside Stop rather than instead of it. -->
+ <IconButton
+ v-if="playing && !isInstalling"
+ v-tooltip="formatMessage(messages.playAgain)"
+ size="xl"
+ :label="formatMessage(messages.playAgain)"
+ native-type="button"
+ :disabled="loading"
+ @click="emit('playAgain')"
+ >
+ <PlayIcon />
+ </IconButton>
+
<IconButton
v-tooltip="formatMessage(messages.instanceSettings)"
size="xl"
@@ -256,6 +269,10 @@ const messages = defineMessages({
id: 'instance.action.stopping',
defaultMessage: 'Stopping...',
},
+ playAgain: {
+ id: 'instance.action.play-again',
+ defaultMessage: 'Launch another copy with the selected account',
+ },
sharedInstanceTooltip: {
id: 'instance.shared-instance.tooltip',
defaultMessage: "This instance's content is being managed by someone else.",
@@ -302,6 +319,7 @@ const emit = defineEmits<{
repair: []
stop: []
play: []
+ playAgain: []
playServer: []
settings: []
openFolder: []
diff --git a/apps/app-frontend/src/pages/instance/layout.vue b/apps/app-frontend/src/pages/instance/layout.vue
index 7ec73ca..fcd5802 100644
--- a/apps/app-frontend/src/pages/instance/layout.vue
+++ b/apps/app-frontend/src/pages/instance/layout.vue
@@ -46,6 +46,7 @@
@repair="() => repairInstance()"
@stop="() => stopInstance('InstancePage')"
@play="() => startInstance('InstancePage')"
+ @play-again="() => launchAnotherCopy('InstancePage')"
@play-server="() => handlePlayServer()"
@settings="() => settingsModal?.show()"
@open-folder="() => instance && showInstanceInFolder(instance.id)"
@@ -558,6 +559,30 @@ const launchInstance = async (context: string) => {
})
}
+/**
+ * Starts an instance that is already running once more, as the account selected
+ * now, which is how somebody plays one instance on two accounts.
+ */
+const launchAnotherCopy = async (context: string) => {
+ const currentInstance = instance.value
+ if (!currentInstance || currentInstance.quarantined || loading.value || !playing.value) return
+
+ loading.value = true
+ try {
+ await run(currentInstance.id, null, true)
+ await processesQuery.refetch()
+ } catch (err) {
+ handleSevereError(err, { instanceId: currentInstance.id })
+ }
+ loading.value = false
+
+ trackEvent('InstanceStart', {
+ loader: currentInstance.loader,
+ game_version: currentInstance.game_version,
+ source: context,
+ })
+}
+
async function handleSharedInstanceUnavailable(
reason: SharedInstanceUnavailableReason | null = null,
) {
diff --git a/apps/app-frontend/src/pages/instance/logs/index.vue b/apps/app-frontend/src/pages/instance/logs/index.vue
index 2aacfe0..e2a59fd 100644
--- a/apps/app-frontend/src/pages/instance/logs/index.vue
+++ b/apps/app-frontend/src/pages/instance/logs/index.vue
@@ -1,6 +1,10 @@
<template>
<div class="flex flex-col gap-4 h-full">
- <ConsolePageLayout />
+ <!-- One console per copy while the instance is running more than once. -->
+ <div v-if="runningCopies.length > 1" class="flex min-h-0 flex-1 gap-4">
+ <ProcessConsole v-for="copy in runningCopies" :key="copy.uuid" :process="copy" />
+ </div>
+ <ConsolePageLayout v-else />
</div>
</template>
@@ -14,12 +18,13 @@ import {
import { useQuery } from '@tanstack/vue-query'
import { computed, ref, shallowRef, triggerRef, watch, watchEffect } from 'vue'
+import ProcessConsole from '@/components/ui/ProcessConsole.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { delete_logs_by_filename, get_output_by_filename } from '@/helpers/logs.js'
import { injectInstancePage } from '../instance-context'
-import { instanceKeys } from '../query-options'
+import { instanceKeys, instanceProcessesQueryOptions } from '../query-options'
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
@@ -83,6 +88,20 @@ watch(historicalLogsQuery.error, (error) => {
if (error) handleError(error)
})
+// The running copies decide between one console and one per copy. Oldest
+// first, so the consoles keep their places.
+const processesQuery = useQuery(
+ computed(() => ({
+ ...instanceProcessesQueryOptions(instanceId.value),
+ enabled: !!instanceId.value,
+ })),
+)
+const runningCopies = computed(() =>
+ [...(processesQuery.data.value ?? [])].sort((a, b) =>
+ String(a.start_time ?? '').localeCompare(String(b.start_time ?? '')),
+ ),
+)
+
const selectedLogIndex = ref(0)
const isLive = computed(() => selectedLogIndex.value === 0)
@@ -194,6 +213,11 @@ if (!instancePage.playing.value) {
useAppEvent('log', (payload) => {
if (payload.instance_id !== instanceId.value) return
+ // This console follows the newest copy, which is what its buffer holds, so it
+ // makes sense again once the other copies have stopped.
+ const newest = runningCopies.value[runningCopies.value.length - 1]
+ if (newest && payload.process_uuid !== newest.uuid) return
+
if (payload.type === 'log4j') {
liveConsole.addLog4jEvent(payload)
} else if (payload.type === 'legacy') {
@@ -203,6 +227,8 @@ useAppEvent('log', (payload) => {
useAppEvent('process', async (e) => {
if (e.instance_id !== instanceId.value) return
+ // A copy starting or stopping adds or takes away a console.
+ void processesQuery.refetch()
if (e.event === 'launched') {
liveConsole.clear()
invalidate()
diff --git a/apps/app-frontend/src/providers/setup/app-event-codec.ts b/apps/app-frontend/src/providers/setup/app-event-codec.ts
index 638386d..3cccc8a 100644
--- a/apps/app-frontend/src/providers/setup/app-event-codec.ts
+++ b/apps/app-frontend/src/providers/setup/app-event-codec.ts
@@ -122,7 +122,7 @@ function normalizeLogPayload(value: unknown): WireObject {
event.timestamp_millis = number(event.timestamp_millis)
}
- return { instance_id: payload.instance_id, ...event }
+ return { instance_id: payload.instance_id, process_uuid: payload.process_uuid, ...event }
}
function normalizeInstallProgress(value: unknown): WireObject {
diff --git a/apps/app/build.rs b/apps/app/build.rs
index 02c30c0..fc78fe1 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -110,7 +110,9 @@ fn main() {
"logs_delete_logs_by_filename",
"logs_get_latest_log_cursor",
"logs_get_live_log_buffer",
+ "logs_get_live_log_buffer_for_process",
"logs_clear_live_log_buffer",
+ "logs_clear_live_log_buffer_for_process",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
diff --git a/apps/app/src/api/instance.rs b/apps/app/src/api/instance.rs
index 2861594..2a512dd 100644
--- a/apps/app/src/api/instance.rs
+++ b/apps/app/src/api/instance.rs
@@ -1324,12 +1324,19 @@ pub async fn instance_get_pack_export_candidates(
pub async fn instance_run(
instance_id: &str,
server_address: Option<String>,
+ additional: Option<bool>,
) -> Result<ProcessMetadata> {
let quick_play = match server_address {
Some(addr) => QuickPlayType::Server(ServerAddress::Unresolved(addr)),
None => QuickPlayType::None,
};
- Ok(theseus::instance::run(instance_id, quick_play).await?)
+ // Another copy of a running instance has to be asked for, so that a
+ // double-clicked play button is still one game.
+ Ok(if additional.unwrap_or(false) {
+ theseus::instance::run_additional(instance_id, quick_play).await?
+ } else {
+ theseus::instance::run(instance_id, quick_play).await?
+ })
}
#[tauri::command]
diff --git a/apps/app/src/api/logs.rs b/apps/app/src/api/logs.rs
index e938efa..5150b00 100644
--- a/apps/app/src/api/logs.rs
+++ b/apps/app/src/api/logs.rs
@@ -22,7 +22,9 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
logs_delete_logs_by_filename,
logs_get_latest_log_cursor,
logs_get_live_log_buffer,
+ logs_get_live_log_buffer_for_process,
logs_clear_live_log_buffer,
+ logs_clear_live_log_buffer_for_process,
])
.build()
}
@@ -91,9 +93,26 @@ pub async fn logs_get_live_log_buffer(
Ok(logs::get_live_log_buffer(instance_id).await?)
}
+/// Get the live log buffer of one process.
+#[tauri::command]
+pub async fn logs_get_live_log_buffer_for_process(
+ process_uuid: uuid::Uuid,
+) -> Result<CensoredString> {
+ Ok(logs::get_live_log_buffer_for_process(process_uuid).await?)
+}
+
/// Clear the live log buffer for an instance.
#[tauri::command]
pub async fn logs_clear_live_log_buffer(instance_id: &str) -> Result<()> {
logs::clear_live_log_buffer(instance_id);
Ok(())
}
+
+/// Clear the live log buffer of one process.
+#[tauri::command]
+pub async fn logs_clear_live_log_buffer_for_process(
+ process_uuid: uuid::Uuid,
+) -> Result<()> {
+ logs::clear_live_log_buffer_for_process(process_uuid);
+ Ok(())
+}
diff --git a/packages/app-lib/src/api/instance.rs b/packages/app-lib/src/api/instance.rs
index 8a0040f..46cc47a 100644
--- a/packages/app-lib/src/api/instance.rs
+++ b/packages/app-lib/src/api/instance.rs
@@ -59,7 +59,8 @@ pub use self::projects::{
update_managed_modrinth_version, update_project,
};
pub use self::run::{
- QuickPlayType, kill, run, try_update_playtime_by_instance_id,
+ QuickPlayType, kill, run, run_additional,
+ try_update_playtime_by_instance_id,
};
pub use self::screenshot_groups::{
ScreenshotGroup, ScreenshotGroupImport, ScreenshotGroupMembershipUpdate,
diff --git a/packages/app-lib/src/api/instance/run.rs b/packages/app-lib/src/api/instance/run.rs
index 4e46190..1d01c06 100644
--- a/packages/app-lib/src/api/instance/run.rs
+++ b/packages/app-lib/src/api/instance/run.rs
@@ -20,6 +20,25 @@ pub enum QuickPlayType {
pub async fn run(
instance_id: &str,
quick_play_type: QuickPlayType,
+) -> crate::Result<ProcessMetadata> {
+ run_inner(instance_id, quick_play_type, false).await
+}
+
+/// Runs an instance that is already running once more, as whichever account is
+/// selected now. A normal run refuses that, so a double-clicked play button
+/// stays one game.
+#[tracing::instrument]
+pub async fn run_additional(
+ instance_id: &str,
+ quick_play_type: QuickPlayType,
+) -> crate::Result<ProcessMetadata> {
+ run_inner(instance_id, quick_play_type, true).await
+}
+
+async fn run_inner(
+ instance_id: &str,
+ quick_play_type: QuickPlayType,
+ additional: bool,
) -> crate::Result<ProcessMetadata> {
let state = State::get().await?;
if crate::state::instances::adapters::sqlite::instance_rows::is_instance_quarantined(
@@ -53,7 +72,8 @@ pub async fn run(
.await?
.ok_or_else(|| crate::ErrorKind::NoCredentialsError.as_error())?;
- run_credentials(instance_id, &default_account, quick_play_type).await
+ run_credentials(instance_id, &default_account, quick_play_type, additional)
+ .await
}
#[tracing::instrument(skip(credentials))]
@@ -61,6 +81,7 @@ async fn run_credentials(
instance_id: &str,
credentials: &Credentials,
quick_play_type: QuickPlayType,
+ additional: bool,
) -> crate::Result<ProcessMetadata> {
let state = State::get().await?;
let settings = Settings::get(&state.pool).await?;
@@ -256,6 +277,7 @@ async fn run_credentials(
post_exit_hook,
&context,
quick_play_type,
+ additional,
)
.await
}
diff --git a/packages/app-lib/src/api/logs.rs b/packages/app-lib/src/api/logs.rs
index 4ce0c57..3382b5b 100644
--- a/packages/app-lib/src/api/logs.rs
+++ b/packages/app-lib/src/api/logs.rs
@@ -469,12 +469,27 @@ pub async fn delete_logs_by_filename(
Ok(())
}
+/// The live output of an instance's newest process.
#[tracing::instrument]
pub async fn get_live_log_buffer(
instance_id: &str,
+) -> crate::Result<CensoredString> {
+ censor_live_lines(crate::state::get_instance_log_buffer(instance_id)).await
+}
+
+/// The live output of one process, for showing each copy of an instance on
+/// its own.
+#[tracing::instrument]
+pub async fn get_live_log_buffer_for_process(
+ process_uuid: uuid::Uuid,
+) -> crate::Result<CensoredString> {
+ censor_live_lines(crate::state::get_log_buffer(process_uuid)).await
+}
+
+async fn censor_live_lines(
+ lines: Vec<String>,
) -> crate::Result<CensoredString> {
let state = State::get().await?;
- let lines = crate::state::get_log_buffer(instance_id);
let joined = lines.join("\n");
let compacted = compact_duplicate_lines(&joined);
@@ -488,7 +503,11 @@ pub async fn get_live_log_buffer(
}
pub fn clear_live_log_buffer(instance_id: &str) {
- crate::state::remove_log_buffer(instance_id);
+ crate::state::remove_instance_log_buffers(instance_id);
+}
+
+pub fn clear_live_log_buffer_for_process(process_uuid: uuid::Uuid) {
+ crate::state::remove_log_buffer(process_uuid);
}
#[tracing::instrument]
diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs
index 833349d..84b9a87 100644
--- a/packages/app-lib/src/event/mod.rs
+++ b/packages/app-lib/src/event/mod.rs
@@ -559,6 +559,8 @@ mod log_types {
#[serde_binhum::serde_binhum]
pub struct LogPayload {
pub instance_id: String,
+ /// Which process said this: an instance can run more than once.
+ pub process_uuid: String,
#[serde(flatten)]
#[cfg_attr(feature = "export-ts", ts(flatten))]
pub event: LogEvent,
diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs
index e352716..6e7de28 100644
--- a/packages/app-lib/src/launcher/mod.rs
+++ b/packages/app-lib/src/launcher/mod.rs
@@ -825,6 +825,9 @@ pub async fn launch_minecraft(
post_exit_hook: Option<String>,
context: &InstanceLaunchContext,
mut quick_play_type: QuickPlayType,
+ // Whether this is another copy of an instance that is already running,
+ // asked for on purpose.
+ additional: bool,
) -> crate::Result<ProcessMetadata> {
let instance = &context.instance;
let content_set = &context.applied_content_set;
@@ -954,21 +957,27 @@ pub async fn launch_minecraft(
let env_args = Vec::from(env_args);
- // Check if instance has a running process, and reject running the command if it does
- let existing_processes = process::get_by_instance_id(&instance.id).await?;
- if let Some(process) = existing_processes.first() {
- return Err(crate::ErrorKind::LauncherError(format!(
- "Instance {} is already running as process {}",
- instance.id, process.uuid
- ))
- .as_error());
- }
- if crate::state::instance_has_running_process(&instance.id, &state).await? {
- return Err(crate::ErrorKind::LauncherError(format!(
- "Instance {} is already running",
- instance.id
- ))
- .as_error());
+ // Check if instance has a running process, and reject running the command if it does,
+ // unless another copy is exactly what was asked for.
+ if !additional {
+ let existing_processes =
+ process::get_by_instance_id(&instance.id).await?;
+ if let Some(process) = existing_processes.first() {
+ return Err(crate::ErrorKind::LauncherError(format!(
+ "Instance {} is already running as process {}",
+ instance.id, process.uuid
+ ))
+ .as_error());
+ }
+ if crate::state::instance_has_running_process(&instance.id, &state)
+ .await?
+ {
+ return Err(crate::ErrorKind::LauncherError(format!(
+ "Instance {} is already running",
+ instance.id
+ ))
+ .as_error());
+ }
}
let natives_dir = state.directories.version_natives_dir(&version_jar);
@@ -1212,6 +1221,8 @@ pub async fn launch_minecraft(
&instance.id,
&instance.path,
&instance.name,
+ &credentials.offline_profile.name,
+ additional,
command,
post_exit_hook,
env_args,
diff --git a/packages/app-lib/src/state/process.rs b/packages/app-lib/src/state/process.rs
index fb09842..e9aaa3a 100644
--- a/packages/app-lib/src/state/process.rs
+++ b/packages/app-lib/src/state/process.rs
@@ -47,37 +47,74 @@ impl LogRingBuffer {
fn get_all(&self) -> Vec<String> {
self.lines.iter().cloned().collect()
}
-
- fn clear(&mut self) {
- self.lines.clear();
- }
}
-static LOG_BUFFERS: LazyLock<DashMap<String, LogRingBuffer>> =
+/// Live output, kept per process: an instance can run more than once, and two
+/// games in one buffer is a log nobody can read.
+static LOG_BUFFERS: LazyLock<DashMap<Uuid, LogRingBuffer>> =
+ LazyLock::new(DashMap::new);
+
+/// The processes each instance has had since it was last started fresh, oldest
+/// first. Whatever only knows the instance reads the newest one.
+static INSTANCE_PROCESSES: LazyLock<DashMap<String, Vec<Uuid>>> =
LazyLock::new(DashMap::new);
-pub fn push_log_line(instance_id: &str, line: String) {
+pub fn push_log_line(process_uuid: Uuid, line: String) {
LOG_BUFFERS
- .entry(instance_id.to_string())
+ .entry(process_uuid)
.or_insert_with(LogRingBuffer::new)
.push(line);
}
-pub fn get_log_buffer(instance_id: &str) -> Vec<String> {
+pub fn get_log_buffer(process_uuid: Uuid) -> Vec<String> {
LOG_BUFFERS
- .get(instance_id)
+ .get(&process_uuid)
.map(|buf| buf.get_all())
.unwrap_or_default()
}
-pub fn clear_log_buffer(instance_id: &str) {
- if let Some(mut buf) = LOG_BUFFERS.get_mut(instance_id) {
- buf.clear();
+/// The live output of an instance's newest process.
+pub fn get_instance_log_buffer(instance_id: &str) -> Vec<String> {
+ INSTANCE_PROCESSES
+ .get(instance_id)
+ .and_then(|uuids| uuids.last().map(|uuid| get_log_buffer(*uuid)))
+ .unwrap_or_default()
+}
+
+pub fn remove_log_buffer(process_uuid: Uuid) {
+ LOG_BUFFERS.remove(&process_uuid);
+}
+
+/// Forgets what an instance's processes said, but not which processes there
+/// are, so a game still running has somewhere to be read from.
+pub fn remove_instance_log_buffers(instance_id: &str) {
+ if let Some(uuids) = INSTANCE_PROCESSES.get(instance_id) {
+ for uuid in uuids.iter() {
+ remove_log_buffer(*uuid);
+ }
}
}
-pub fn remove_log_buffer(instance_id: &str) {
- LOG_BUFFERS.remove(instance_id);
+/// Takes note of a process that has just started.
+///
+/// A fresh start drops what the instance's earlier processes said, as starting
+/// the game always has. A second copy joins them: the first is still running.
+fn note_new_process(instance_id: &str, process_uuid: Uuid, additional: bool) {
+ if additional {
+ INSTANCE_PROCESSES
+ .entry(instance_id.to_string())
+ .or_default()
+ .push(process_uuid);
+ return;
+ }
+
+ if let Some(previous) = INSTANCE_PROCESSES
+ .insert(instance_id.to_string(), vec![process_uuid])
+ {
+ for uuid in previous {
+ remove_log_buffer(uuid);
+ }
+ }
}
async fn clear_persisted_process(
@@ -171,6 +208,10 @@ impl ProcessManager {
instance_id: &str,
instance_path: &str,
instance_name: &str,
+ account_name: &str,
+ // Whether the instance is already running and this is another copy of
+ // it, whose log joins the one already being written.
+ additional: bool,
mut mc_command: Command,
post_exit_command: Option<String>,
post_exit_env_vars: Vec<(String, String)>,
@@ -200,13 +241,17 @@ impl ProcessManager {
let log_path = logs_folder.join(LAUNCHER_LOG_PATH);
- clear_log_buffer(instance_id);
+ let process_uuid = Uuid::new_v4();
+ note_new_process(instance_id, process_uuid, additional);
{
+ // One log file per instance: another copy appends to it rather than
+ // wiping what the running one is still writing.
let mut log_file = OpenOptions::new()
.write(true)
.create(true)
- .truncate(true)
+ .append(additional)
+ .truncate(!additional)
.open(&log_path)
.map_err(|e| IOError::with_path(e, &log_path))?;
@@ -219,6 +264,10 @@ impl ProcessManager {
.map_err(|e| IOError::with_path(e, &log_path))?;
writeln!(log_file, "# Instance: {instance_path} \n")
.map_err(|e| IOError::with_path(e, &log_path))?;
+ if additional {
+ writeln!(log_file, "# Another copy, as {account_name}")
+ .map_err(|e| IOError::with_path(e, &log_path))?;
+ }
writeln!(log_file).map_err(|e| IOError::with_path(e, &log_path))?;
}
@@ -230,11 +279,12 @@ impl ProcessManager {
let mut process = Process {
metadata: ProcessMetadata {
- uuid: Uuid::new_v4(),
+ uuid: process_uuid,
start_time: Utc::now(),
instance_id: instance_id.to_string(),
instance_path: instance_path.to_string(),
instance_name: instance_name.to_string(),
+ account_name: account_name.to_string(),
},
child: mc_proc,
rpc_server,
@@ -297,10 +347,12 @@ impl ProcessManager {
let instance_id = metadata.instance_id.clone();
let instance_path = metadata.instance_path.clone();
+ let process_uuid = metadata.uuid;
tokio::spawn(async move {
Process::process_output(
&instance_id,
&instance_path,
+ process_uuid,
stdout,
log_path_clone,
xml_logging,
@@ -314,10 +366,12 @@ impl ProcessManager {
let instance_id = metadata.instance_id.clone();
let instance_path = metadata.instance_path.clone();
+ let process_uuid = metadata.uuid;
tokio::spawn(async move {
Process::process_output(
&instance_id,
&instance_path,
+ process_uuid,
stderr,
log_path_clone,
xml_logging,
@@ -400,6 +454,10 @@ pub struct ProcessMetadata {
pub instance_id: String,
pub instance_path: String,
pub instance_name: String,
+ /// The account the game was started as, which is what tells copies of one
+ /// instance apart.
+ #[serde(default)]
+ pub account_name: String,
pub start_time: DateTime<Utc>,
}
@@ -429,6 +487,7 @@ impl Process {
async fn process_output<R>(
instance_id: &str,
_instance_path: &str,
+ process_uuid: Uuid,
reader: R,
log_path: impl AsRef<Path>,
xml_logging: bool,
@@ -555,6 +614,7 @@ impl Process {
Self::emit_log4j_event(
instance_id,
+ process_uuid,
&current_event,
);
}
@@ -599,6 +659,7 @@ impl Process {
Self::emit_log4j_event(
instance_id,
+ process_uuid,
&current_event,
);
}
@@ -625,7 +686,11 @@ impl Process {
e
);
}
- Self::emit_legacy_log(instance_id, &text);
+ Self::emit_legacy_log(
+ instance_id,
+ process_uuid,
+ &text,
+ );
}
}
Ok(Event::CData(e)) => {
@@ -652,7 +717,11 @@ impl Process {
if let Err(e) = Self::append_to_log_file(&log_path, &line) {
tracing::warn!("Failed to write to log file: {}", e);
}
- Self::emit_legacy_log(instance_id, line.trim_ascii_end());
+ Self::emit_legacy_log(
+ instance_id,
+ process_uuid,
+ line.trim_ascii_end(),
+ );
if let Err(e) = Self::maybe_handle_old_server_join_logging(
instance_id,
line.trim_ascii_end(),
@@ -711,13 +780,17 @@ impl Process {
))
}
- fn emit_log4j_event(instance_id: &str, event: &Log4jEvent) {
+ fn emit_log4j_event(
+ instance_id: &str,
+ process_uuid: Uuid,
+ event: &Log4jEvent,
+ ) {
if let Some(formatted) = Self::format_log4j_entry(event) {
- push_log_line(instance_id, formatted.trim_end().to_string());
+ push_log_line(process_uuid, formatted.trim_end().to_string());
}
if let Some(ref throwable) = event.throwable {
for line in throwable.lines().filter(|l| !l.is_empty()) {
- push_log_line(instance_id, line.to_string());
+ push_log_line(process_uuid, line.to_string());
}
}
@@ -726,6 +799,7 @@ impl Process {
let event_state = crate::EventState::get();
let _ = event_state.send(crate::event::AppEvent::Log(LogPayload {
instance_id: instance_id.to_string(),
+ process_uuid: process_uuid.to_string(),
event: LogEvent::Log4j(event.clone()),
}));
}
@@ -735,14 +809,15 @@ impl Process {
}
}
- fn emit_legacy_log(instance_id: &str, message: &str) {
- push_log_line(instance_id, message.to_string());
+ fn emit_legacy_log(instance_id: &str, process_uuid: Uuid, message: &str) {
+ push_log_line(process_uuid, message.to_string());
#[cfg(feature = "tauri")]
{
let event_state = crate::EventState::get();
let _ = event_state.send(crate::event::AppEvent::Log(LogPayload {
instance_id: instance_id.to_string(),
+ process_uuid: process_uuid.to_string(),
event: LogEvent::Legacy {
message: message.to_string(),
},
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,39 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Tue, 15 Sep 2026 14:25:43 +0200
Subject: [PATCH] Use the desktop's file picker on Linux
File pickers - the webview's own, like the skin page's, and the dialog
plugin's - are GtkFileChooserNative on Linux, which only uses the XDG
desktop portal when asked. Unasked, it draws a GTK dialog, and inside the
AppImage that dialog is themed as light Adwaita whatever the desktop
looks like. GTK_USE_PORTAL is now set at startup, so KDE shows its own
file dialog and GNOME its own. A player who set it already keeps their
value, and without a portal GTK falls back to its own dialog as before.
Windows and macOS already use the native pickers.
---
apps/app/src/main.rs | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs
index 04eedbd..4c269a3 100644
--- a/apps/app/src/main.rs
+++ b/apps/app/src/main.rs
@@ -115,6 +115,16 @@ async fn set_restart_after_pending_update(
// if Tauri app is called with arguments, then those arguments will be treated as commands
// ie: deep links or filepaths for .mrpacks
fn main() {
+ // File pickers on Linux - the webview's own and the dialog plugin's, both
+ // GtkFileChooserNative - go through the desktop portal, so KDE and GNOME
+ // show their own dialog rather than a GTK one the AppImage themes as
+ // Adwaita. Without a portal, GTK falls back to its own dialog.
+ #[cfg(target_os = "linux")]
+ if std::env::var_os("GTK_USE_PORTAL").is_none() {
+ // SAFETY: nothing else is running yet.
+ unsafe { std::env::set_var("GTK_USE_PORTAL", "1") };
+ }
+
#[cfg(feature = "export-app-events")]
theseus::export_app_event_bindings(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+1
View File
@@ -0,0 +1 @@
v0.21.2
+28 -2
View File
@@ -20,6 +20,14 @@ cp "$WORKTREE/packages/app-lib/.env.prod" "$WORKTREE/packages/app-lib/.env"
log "Installing JavaScript dependencies" log "Installing JavaScript dependencies"
(cd "$WORKTREE" && pnpm install --frozen-lockfile) (cd "$WORKTREE" && pnpm install --frozen-lockfile)
# The frontend is built through turbo, whose local cache keeps every task's
# outputs, and upstream counts the Rust target directory among them: gigabytes
# a build, never cleaned up, and copied back over target/ on a cache hit. A
# release build wants none of that, so the local cache is off and what an
# earlier build left there is removed.
export TURBO_CACHE=remote:r
rm -rf "$WORKTREE/.turbo/cache"
tauri_args=() tauri_args=()
case "$(uname -s)" in case "$(uname -s)" in
MINGW* | MSYS* | CYGWIN* | Windows_NT) MINGW* | MSYS* | CYGWIN* | Windows_NT)
@@ -32,15 +40,33 @@ Darwin)
;; ;;
*) *)
platform=linux platform=linux
# The AppImage is assembled by linuxdeploy, which is itself an AppImage and
# needs FUSE to mount. Plenty of desktops no longer ship FUSE 2, so tell it
# to unpack itself instead; on a machine that has FUSE this changes nothing.
export APPIMAGE_EXTRACT_AND_RUN=1
# Its strip pass fails outright on some distributions, taking the whole
# bundle with it and reporting nothing about why. Skipping it was measured
# to cost 4KB of the finished 136MB AppImage, which is not worth a build
# that only works on some machines.
export NO_STRIP=1
# appimagetool guesses the architecture from every ELF file in the AppDir
# and gives up when it finds two, which happens as soon as the GTK plugin
# picks up a 32-bit GIO module from a multilib system's /usr/lib32.
export ARCH="$(uname -m)"
;; ;;
esac esac
# Emptied before the build, not after it: a build that fails halfway would
# otherwise leave the previous run's installers sitting here, where
# scripts/check.sh would happily pass them off as this build's output.
log "Clearing $ARTIFACTS"
rm -rf "$ARTIFACTS"
mkdir -p "$ARTIFACTS"
log "Building for $platform" log "Building for $platform"
(cd "$WORKTREE" && pnpm --filter=@modrinth/app run tauri build "${tauri_args[@]}") (cd "$WORKTREE" && pnpm --filter=@modrinth/app run tauri build "${tauri_args[@]}")
log "Collecting bundles into $ARTIFACTS" log "Collecting bundles into $ARTIFACTS"
rm -rf "$ARTIFACTS"
mkdir -p "$ARTIFACTS"
if [ "$platform" = macos ]; then if [ "$platform" = macos ]; then
bundle_dir="$WORKTREE/target/universal-apple-darwin/release/bundle" bundle_dir="$WORKTREE/target/universal-apple-darwin/release/bundle"
+117 -2
View File
@@ -39,8 +39,6 @@ check "tauri.conf.json is named Modrinth Enhanced" \
contains "$WORKTREE/apps/app/tauri.conf.json" '"productName": "Modrinth Enhanced"' contains "$WORKTREE/apps/app/tauri.conf.json" '"productName": "Modrinth Enhanced"'
check "the window is titled Modrinth Enhanced" \ check "the window is titled Modrinth Enhanced" \
contains "$WORKTREE/apps/app/tauri.conf.json" '"title": "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" log "Offline accounts"
check "app-lib exposes login_offline" \ check "app-lib exposes login_offline" \
@@ -49,6 +47,123 @@ check "the Tauri command is registered" \
contains "$WORKTREE/apps/app/src/api/auth.rs" 'login_offline,' contains "$WORKTREE/apps/app/src/api/auth.rs" 'login_offline,'
check "the frontend can reach it" \ check "the frontend can reach it" \
contains "$WORKTREE/apps/app-frontend/src/helpers/auth.js" "plugin:auth|login_offline" contains "$WORKTREE/apps/app-frontend/src/helpers/auth.js" "plugin:auth|login_offline"
check "it is reachable with no account yet" \
contains "$WORKTREE/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue" 'showOfflineAccountModal'
check "the checklist offers the same choice" \
contains "$WORKTREE/apps/app-frontend/src/App.vue" '@login-minecraft="minecraftRequiredModal?.show()"'
# A Tauri command that is not listed in build.rs compiles, ships, and then
# fails at runtime with "not allowed by ACL". Nothing else here would catch it.
log "Tauri command permissions"
while read -r command; do
[ -n "$command" ] || continue
check "$command is allowed by the ACL" \
contains "$WORKTREE/apps/app/build.rs" "\"$command\","
done < <(grep -h -A2 '#\[tauri::command\]' \
"$WORKTREE/apps/app/src/api/auth.rs" "$WORKTREE/apps/app/src/api/ely_skins.rs" \
"$WORKTREE/apps/app/src/api/crash_analysis.rs" "$WORKTREE/apps/app/src/api/skin_browser.rs" |
grep -oE 'pub async fn [a-z_]+' | awk '{print $4}' | sort -u)
# Listing a command in build.rs is not enough either: the main window only
# reaches a plugin whose default permission is in its capabilities.
log "Plugin permissions"
while read -r plugin; do
[ -n "$plugin" ] || continue
check "$plugin is granted to the main window" \
contains "$WORKTREE/apps/app/capabilities/plugins.json" "\"$plugin:default\""
done < <(grep -A1 '\.plugin($' "$WORKTREE/apps/app/build.rs" |
grep -oE '^ *"[a-z-]+",$' | tr -d ' ",' | sort -u)
log "Microsoft sign-in"
check "the browser flow is registered" \
contains "$WORKTREE/apps/app/src/api/auth.rs" 'login_browser_begin,'
check "the sign-in button opens it" \
contains "$WORKTREE/apps/app-frontend/src/components/ui/AccountsCard.vue" 'microsoftLoginModal.value?.show'
log "Ely.by accounts"
check "app-lib can sign in to Ely.by" \
contains "$WORKTREE/packages/app-lib/src/api/minecraft_auth.rs" 'pub async fn login_ely'
check "the Tauri command is registered" \
contains "$WORKTREE/apps/app/src/api/auth.rs" 'login_ely,'
check "authlib-injector is added at launch" \
contains "$WORKTREE/packages/app-lib/src/launcher/mod.rs" 'authlib_injector'
log "Ely.by skins"
check "the frontend can tell an Ely.by account" \
contains "$WORKTREE/packages/app-lib/src/state/minecraft_auth.rs" 'serialize_field("ely"'
check "the plugin is registered" \
contains "$WORKTREE/apps/app/src/main.rs" 'api::ely_skins::init()'
check "the skin page changes skins on Ely.by" \
contains "$WORKTREE/apps/app-frontend/src/pages/Skins.vue" 'wearElySkin'
log "Skins on offline servers"
check "the agent fills in missing skins" \
contains "$WORKTREE/packages/app-lib/java/src/main/java/com/modrinth/theseus/agent/TheseusAgent.java" 'SessionServiceTransformer'
check "the launcher turns it on" \
contains "$WORKTREE/packages/app-lib/src/launcher/args.rs" 'enhanced.skins.source'
check "the skins folder can be opened" \
contains "$WORKTREE/apps/app/build.rs" '"show_player_skins_folder",'
log "Another copy of a running instance"
check "a running instance can start again" \
contains "$WORKTREE/packages/app-lib/src/api/instance/run.rs" 'pub async fn run_additional'
check "each copy has a console" \
contains "$WORKTREE/apps/app-frontend/src/pages/instance/logs/index.vue" 'ProcessConsole'
check "log events say which copy" \
contains "$WORKTREE/apps/app-frontend/src/generated/app-events/LogPayload.ts" 'process_uuid'
log "Crash explanation"
check "the rules are there" \
contains "$WORKTREE/packages/app-lib/src/api/crash_analysis.rs" 'pub async fn analyze_instance'
check "the plugin is registered" \
contains "$WORKTREE/apps/app/src/main.rs" 'api::crash_analysis::init()'
check "the Logs tab shows it" \
contains "$WORKTREE/apps/app-frontend/src/pages/instance/logs/index.vue" '<CrashDiagnosis'
log "Skin browser"
check "Ely.by's catalogue can be browsed" \
contains "$WORKTREE/packages/app-lib/src/api/skin_browser.rs" 'pub async fn ely_catalogue'
check "skin sites open in a window" \
contains "$WORKTREE/apps/app/src/api/skin_browser.rs" 'pub async fn skin_browser_open_site'
check "the skin page has a Browse tab" \
contains "$WORKTREE/apps/app-frontend/src/pages/Skins.vue" '<SkinBrowser'
log "Sidebar and news"
check "Modrinth Servers is behind a flag" \
contains "$WORKTREE/apps/app-frontend/src/App.vue" "getFeatureFlag('show_hosting_in_sidebar')"
check "the news section can be collapsed" \
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'setNewsCollapsed'
check "the right sidebar has a fold button" \
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'setSidebarCollapsed(sidebarToggled)'
# tauri.linux.conf.json replaces the whole window list, so an upstream change to
# the main window would otherwise silently not reach Linux.
same_linux_window() {
node -e '
const fs = require("fs")
const [base, linux] = process.argv.slice(1).map((p) => JSON.parse(fs.readFileSync(p, "utf8")).app.windows[0])
delete linux.transparent
const sorted = (o) => JSON.stringify(o, Object.keys(o).sort())
process.exit(sorted(base) === sorted(linux) ? 0 : 1)
' "$WORKTREE/apps/app/tauri.conf.json" "$WORKTREE/apps/app/tauri.linux.conf.json"
}
log "Window"
check "the Linux window is transparent" \
contains "$WORKTREE/apps/app/tauri.linux.conf.json" '"transparent": true'
check "the Linux window otherwise matches upstream" same_linux_window
check "its corners are rounded" \
contains "$WORKTREE/apps/app-frontend/src/App.vue" "'rounded-window'"
check "the middle button autoscrolls" \
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'installAutoscroll()'
check "file pickers use the desktop portal on Linux" \
contains "$WORKTREE/apps/app/src/main.rs" 'set_var("GTK_USE_PORTAL", "1")'
log "No advertising or upsells"
check "no Modrinth+ upsell in the app" \
missing "$WORKTREE/apps/app-frontend/src/App.vue" "modrinth.plus"
check "the ad helpers are stubbed" \
missing "$WORKTREE/apps/app-frontend/src/helpers/ads.js" "plugin:ads"
log "No telemetry in the sources" log "No telemetry in the sources"
# Quoted, so that the module names being mentioned in a comment explaining why # Quoted, so that the module names being mentioned in a comment explaining why
+12 -1
View File
@@ -19,7 +19,18 @@ WORKTREE="${WORKTREE:-$REPO_ROOT/build/upstream}"
# Branch the patches are applied on top of the upstream tag as. # Branch the patches are applied on top of the upstream tag as.
PATCH_BRANCH=enhanced PATCH_BRANCH=enhanced
PATCH_DIR="$REPO_ROOT/patches" PATCH_DIR="${PATCH_DIR:-$REPO_ROOT/patches}"
# The upstream release the patches in patches/ were last exported against,
# written there by export-patches.sh. upstream.txt cannot say: a release bumps
# it without exporting the patches again.
patch_base() {
if [ -f "$PATCH_DIR/base.txt" ]; then
tr -d '[:space:]' <"$PATCH_DIR/base.txt"
else
printf '%s\n' "$UPSTREAM_REF"
fi
}
log() { log() {
printf '\033[1;32m==>\033[0m %s\n' "$*" printf '\033[1;32m==>\033[0m %s\n' "$*"
+3
View File
@@ -26,5 +26,8 @@ git -C "$WORKTREE" format-patch \
--output-directory "$PATCH_DIR" \ --output-directory "$PATCH_DIR" \
"$UPSTREAM_REF..HEAD" "$UPSTREAM_REF..HEAD"
# So that prepare.sh applies them where they fit and rebases from there.
printf '%s\n' "$UPSTREAM_REF" >"$PATCH_DIR/base.txt"
log "patches/ now contains:" log "patches/ now contains:"
ls -1 "$PATCH_DIR" ls -1 "$PATCH_DIR"
+50 -11
View File
@@ -4,11 +4,23 @@
# The result lands in build/upstream on the `enhanced` branch and is what all # 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 # other scripts build from. Running this again always starts from a clean
# upstream tree, so it is safe to repeat. # upstream tree, so it is safe to repeat.
#
# The patches are applied to the release they were exported against, where they
# always fit, and rebased onto $UPSTREAM_REF when that is a different release.
# A rebase merges against the files the patches were written for, so upstream
# changing something near a patch resolves by itself. `git am` straight onto the
# new release cannot do that: a shallow checkout does not have those files.
. "$(dirname "${BASH_SOURCE[0]}")/common.sh" . "$(dirname "${BASH_SOURCE[0]}")/common.sh"
BASE_REF="$(patch_base)"
log "Upstream $UPSTREAM_REF from $UPSTREAM_REPO" log "Upstream $UPSTREAM_REF from $UPSTREAM_REPO"
fetch_tag() {
git -C "$WORKTREE" fetch --depth 1 --force origin "refs/tags/$1:refs/tags/$1"
}
if [ ! -d "$WORKTREE/.git" ]; then if [ ! -d "$WORKTREE/.git" ]; then
log "Cloning into $WORKTREE" log "Cloning into $WORKTREE"
mkdir -p "$(dirname "$WORKTREE")" mkdir -p "$(dirname "$WORKTREE")"
@@ -16,21 +28,27 @@ if [ ! -d "$WORKTREE/.git" ]; then
else else
log "Fetching $UPSTREAM_REF into the existing checkout" log "Fetching $UPSTREAM_REF into the existing checkout"
git -C "$WORKTREE" remote set-url origin "$UPSTREAM_REPO" 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" fetch_tag "$UPSTREAM_REF"
fi fi
# `git am` refuses to run with a rebase or merge in progress, and a previous if [ "$BASE_REF" != "$UPSTREAM_REF" ]; then
# run may have stopped on a conflict. log "Fetching $BASE_REF, which the patches were exported against"
git -C "$WORKTREE" am --abort 2>/dev/null || true fetch_tag "$BASE_REF"
fi
log "Resetting to $UPSTREAM_REF" # Neither `git am` nor `git rebase` runs while the other is in progress, and a
git -C "$WORKTREE" checkout --detach --force "$UPSTREAM_REF" # previous run may have stopped on a conflict in either.
git -C "$WORKTREE" branch -f "$PATCH_BRANCH" "$UPSTREAM_REF" git -C "$WORKTREE" am --abort 2>/dev/null || true
git -C "$WORKTREE" rebase --abort 2>/dev/null || true
log "Resetting to $BASE_REF"
git -C "$WORKTREE" checkout --detach --force "$BASE_REF"
git -C "$WORKTREE" branch -f "$PATCH_BRANCH" "$BASE_REF"
git -C "$WORKTREE" checkout --force "$PATCH_BRANCH" git -C "$WORKTREE" checkout --force "$PATCH_BRANCH"
git -C "$WORKTREE" reset --hard "$UPSTREAM_REF" git -C "$WORKTREE" reset --hard "$BASE_REF"
git -C "$WORKTREE" clean -fdx -e node_modules -e target git -C "$WORKTREE" clean -fdx -e node_modules -e target
# `git am` needs an identity for the commits it creates. # `git am` and `git rebase` need an identity for the commits they create.
git -C "$WORKTREE" config user.name "Modrinth Enhanced" git -C "$WORKTREE" config user.name "Modrinth Enhanced"
git -C "$WORKTREE" config user.email "patches@modrinth-enhanced.invalid" git -C "$WORKTREE" config user.email "patches@modrinth-enhanced.invalid"
git -C "$WORKTREE" config commit.gpgsign false git -C "$WORKTREE" config commit.gpgsign false
@@ -41,11 +59,11 @@ shopt -u nullglob
[ ${#patches[@]} -gt 0 ] || die "No patches found in $PATCH_DIR" [ ${#patches[@]} -gt 0 ] || die "No patches found in $PATCH_DIR"
log "Applying ${#patches[@]} patches" log "Applying ${#patches[@]} patches to $BASE_REF"
if ! git -C "$WORKTREE" am --3way --whitespace=nowarn "${patches[@]}"; then if ! git -C "$WORKTREE" am --3way --whitespace=nowarn "${patches[@]}"; then
cat >&2 <<EOF cat >&2 <<EOF
A patch did not apply to $UPSTREAM_REF. A patch did not apply to $BASE_REF.
The failed patch is left staged in $WORKTREE so it can be fixed by hand: The failed patch is left staged in $WORKTREE so it can be fixed by hand:
@@ -60,5 +78,26 @@ EOF
exit 1 exit 1
fi fi
if [ "$BASE_REF" != "$UPSTREAM_REF" ]; then
log "Rebasing the patches onto $UPSTREAM_REF"
if ! git -C "$WORKTREE" rebase --onto "$UPSTREAM_REF" "$BASE_REF" "$PATCH_BRANCH"; then
cat >&2 <<EOF
A patch conflicts with what changed between $BASE_REF and $UPSTREAM_REF.
The rebase is left stopped in $WORKTREE so it can be resolved by hand:
cd $WORKTREE
git status # see the conflicts
# ...resolve them, then:
git add -A && git rebase --continue
# once every patch is in, with upstream.txt set to $UPSTREAM_REF:
$REPO_ROOT/scripts/export-patches.sh
EOF
exit 1
fi
fi
log "Patched checkout ready at $WORKTREE" log "Patched checkout ready at $WORKTREE"
git -C "$WORKTREE" --no-pager log --oneline "$UPSTREAM_REF..$PATCH_BRANCH" git -C "$WORKTREE" --no-pager log --oneline "$UPSTREAM_REF..$PATCH_BRANCH"
-141
View File
@@ -1,141 +0,0 @@
#!/usr/bin/env python3
"""Render the Modrinth Enhanced icon set from its vector source.
The source lives in the patched checkout as
`apps/app/icons/modrinth-enhanced.svg`; this rewrites every generated icon
next to it. Run it after editing that SVG, then commit the result in
build/upstream and re-export the patches.
Needs `rsvg-convert` (librsvg). ImageMagick is not used: the ICNS and ICO
writers below are a few lines each and work the same everywhere, whereas
ImageMagick's ICNS support depends on how it was built.
"""
import os
import shutil
import struct
import subprocess
import sys
import tempfile
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ICONS = os.path.join(
os.environ.get("WORKTREE", os.path.join(REPO_ROOT, "build", "upstream")),
"apps",
"app",
"icons",
)
SVG = os.path.join(ICONS, "modrinth-enhanced.svg")
# Every PNG the Tauri bundles reference, and the size it has to be.
PNGS = {
"icon.png": 512,
"128x128.png": 128,
"128x128@2x.png": 256,
"StoreLogo.png": 50,
"Square30x30Logo.png": 30,
"Square44x44Logo.png": 44,
"Square71x71Logo.png": 71,
"Square89x89Logo.png": 89,
"Square107x107Logo.png": 107,
"Square142x142Logo.png": 142,
"Square150x150Logo.png": 150,
"Square284x284Logo.png": 284,
"Square310x310Logo.png": 310,
}
ICO_SIZES = [16, 24, 32, 48, 64, 256]
FAVICON_SIZES = [16, 24, 32, 64]
# ICNS chunk type -> pixel size. All chunks carry PNG payloads, which macOS
# 10.7 and newer understand.
ICNS_TYPES = [
(b"icp4", 16),
(b"icp5", 32),
(b"ic11", 32),
(b"ic12", 64),
(b"ic07", 128),
(b"ic13", 256),
(b"ic08", 256),
(b"ic14", 512),
(b"ic09", 512),
(b"ic10", 1024),
]
def render(size, path):
"""Rasterise the source at exactly `size`, rather than downscaling one
large render, so the small sizes stay crisp."""
subprocess.run(
["rsvg-convert", "-w", str(size), "-h", str(size), SVG, "-o", path],
check=True,
)
def build_ico(sizes, cache, path):
entries, blobs, offset = [], [], 6 + 16 * len(sizes)
for size in sizes:
with open(cache[size], "rb") as handle:
data = handle.read()
entries.append(
struct.pack(
"<BBBBHHII",
# 256 is written as 0 in an ICO directory entry.
size if size < 256 else 0,
size if size < 256 else 0,
0,
0,
1,
32,
len(data),
offset,
)
)
blobs.append(data)
offset += len(data)
with open(path, "wb") as handle:
handle.write(struct.pack("<HHH", 0, 1, len(sizes)))
for entry in entries:
handle.write(entry)
for blob in blobs:
handle.write(blob)
def build_icns(cache, path):
chunks = b""
for kind, size in ICNS_TYPES:
with open(cache[size], "rb") as handle:
data = handle.read()
chunks += kind + struct.pack(">I", len(data) + 8) + data
with open(path, "wb") as handle:
handle.write(b"icns" + struct.pack(">I", len(chunks) + 8) + chunks)
def main():
if not shutil.which("rsvg-convert"):
sys.exit("rsvg-convert is required (install librsvg)")
if not os.path.isfile(SVG):
sys.exit(f"No icon source at {SVG}. Run scripts/prepare.sh first.")
with tempfile.TemporaryDirectory() as tmp:
cache = {}
needed = set(PNGS.values()) | set(ICO_SIZES) | set(FAVICON_SIZES)
needed |= {size for _, size in ICNS_TYPES}
for size in sorted(needed):
cache[size] = os.path.join(tmp, f"{size}.png")
render(size, cache[size])
for name, size in PNGS.items():
shutil.copyfile(cache[size], os.path.join(ICONS, name))
build_ico(ICO_SIZES, cache, os.path.join(ICONS, "icon.ico"))
build_ico(FAVICON_SIZES, cache, os.path.join(ICONS, "favicon.ico"))
build_icns(cache, os.path.join(ICONS, "icon.icns"))
for name in sorted(os.listdir(ICONS)):
full = os.path.join(ICONS, name)
if os.path.isfile(full):
print(f"{name:24s} {os.path.getsize(full):>8d} bytes")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1 +1 @@
v0.20.5 v0.21.2