16 Commits
Author SHA1 Message Date
Felitendo e344320e75 fix: drop the error label in the ely.by dialog 2026-09-17 16:53:23 +02:00
Felitendo e2c72bcff8 fix: clearer errors for custom server addresses 2026-09-17 16:53:23 +02:00
Felitendo da8048a2d4 docs: add installation section 2026-09-17 16:14:11 +02:00
Felitendo 1249fef8a5 feat: browse littleskin's skin library 2026-09-17 16:14:11 +02:00
Felitendo 9c87e603a9 feat: sign in to custom servers in the browser 2026-09-17 16:14:11 +02:00
Felitendo 8a0cd12613 feat: show and change skins of custom server accounts 2026-09-17 16:14:11 +02:00
Felitendo c1c892ebd9 fix: keep settings tabs clear of the app version 2026-09-17 16:14:11 +02:00
Felitendo 70dedfb9ee feat: add accounts from custom servers (#1) 2026-09-17 16:14:11 +02:00
Felitendo 616afefed1 fix: clear stale bundles before building 2026-09-17 16:14:11 +02:00
Felitendo 9676c94430 feat: update from own releases 2026-09-17 16:14:11 +02:00
Felitendo be8a500937 chore: drop filler from the ely.by dialog 2026-09-17 16:14:11 +02:00
Felitendo cb0d80495b fix: let another copy past the second running check 2026-09-17 16:14:11 +02:00
Felitendo 0842e602b3 build: rebase patches onto v0.21.4
Upstream now depends on the md5 crate, so the offline account UUID uses it instead of md-5.
2026-09-17 16:14:11 +02:00
Felitendo 997483b3d4 fix: start on wayland with nvidia 2026-09-15 21:21:46 +02:00
Felitendo 030aa8fb57 feat: account in the title bar and sign-in without pasting 2026-09-15 17:02:40 +02:00
Felitendo b045aaee56 ci: release patch changes and list every patch 2026-09-15 17:02:40 +02:00
29 changed files with 5388 additions and 349 deletions

No files matched your search

+12
View File
@@ -16,6 +16,10 @@ on:
description: Upstream tag to build instead of the one in upstream.txt description: Upstream tag to build instead of the one in upstream.txt
type: string type: string
required: false required: false
revision:
description: Which release of the upstream version this is, for the updater
type: string
required: false
outputs: outputs:
version: version:
description: Version the app was built as description: Version the app was built as
@@ -27,6 +31,7 @@ concurrency:
env: env:
UPSTREAM_REF: ${{ inputs.upstream-ref }} UPSTREAM_REF: ${{ inputs.upstream-ref }}
MODRINTH_ENHANCED_REVISION: ${{ inputs.revision }}
jobs: jobs:
build: build:
@@ -104,12 +109,19 @@ jobs:
shell: bash shell: bash
run: scripts/check.sh run: scripts/check.sh
# Without the key, as for a pull request from a fork, the build simply
# has no updater.
- name: Build - name: Build
shell: bash shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: scripts/build.sh run: scripts/build.sh
- name: Check the build output - name: Check the build output
shell: bash shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: scripts/check.sh run: scripts/check.sh
- name: Upload installers - name: Upload installers
+99 -26
View File
@@ -2,6 +2,8 @@ name: Upstream release
# Watches Modrinth for a new Modrinth App release, rebuilds Modrinth Enhanced # Watches Modrinth for a new Modrinth App release, rebuilds Modrinth Enhanced
# on top of it, and publishes a release of our own if everything still works. # on top of it, and publishes a release of our own if everything still works.
# When the patches changed since our last release of that upstream version,
# it publishes them again as a revision: v1.2.3-2, v1.2.3-3, ...
# #
# Nothing is published unless the patches applied, the checks passed and all # Nothing is published unless the patches applied, the checks passed and all
# three platforms built, so an upstream change that breaks a patch stops here # three platforms built, so an upstream change that breaks a patch stops here
@@ -16,10 +18,6 @@ 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
revision:
description: Release the current patches again on the upstream release in upstream.txt, as v1.2.3-2, v1.2.3-3, ...
type: boolean
default: false
permissions: permissions:
contents: write contents: write
@@ -35,6 +33,7 @@ jobs:
outputs: outputs:
upstream: ${{ steps.check.outputs.upstream }} upstream: ${{ steps.check.outputs.upstream }}
tag: ${{ steps.check.outputs.tag }} tag: ${{ steps.check.outputs.tag }}
revision: ${{ steps.check.outputs.revision }}
proceed: ${{ steps.check.outputs.proceed }} proceed: ${{ steps.check.outputs.proceed }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -42,43 +41,46 @@ jobs:
- name: Decide what to release - name: Decide what to release
id: check id: check
env: env:
GH_TOKEN: ${{ github.token }}
REQUESTED: ${{ inputs.upstream-ref }} REQUESTED: ${{ inputs.upstream-ref }}
REVISION: ${{ inputs.revision }}
run: | run: |
set -euo pipefail set -euo pipefail
current="$(tr -d '[:space:]' < upstream.txt)" current="$(tr -d '[:space:]' < upstream.txt)"
if [ "$REVISION" = true ]; then
# A revision rebuilds what we already ship, not whatever is newest.
upstream="${REQUESTED:-$current}"
else
upstream="${REQUESTED:-$(scripts/latest-upstream.sh)}" upstream="${REQUESTED:-$(scripts/latest-upstream.sh)}"
fi
echo "upstream=$upstream" >> "$GITHUB_OUTPUT" echo "upstream=$upstream" >> "$GITHUB_OUTPUT"
echo "Upstream release: $upstream (we are on $current)" echo "Upstream release: $upstream (we are on $current)"
if ! gh release view "$upstream" >/dev/null 2>&1; then # Our newest release of it, as "<revision> <tag>". The plain tag is
tag="$upstream" # revision 1.
elif [ "$REVISION" = true ]; then
# The first release of an upstream version is its plain tag, which
# counts as revision 1.
last="$( last="$(
git ls-remote --tags --refs origin "$upstream-*" | git ls-remote --tags --refs origin "$upstream" "$upstream-*" |
sed 's#.*refs/tags/##' | sed 's#.*refs/tags/##' |
awk -v prefix="$upstream-" 'index($0, prefix) == 1 { awk -v up="$upstream" '
n = substr($0, length(prefix) + 1) $0 == up { print 1, $0; next }
if (n ~ /^[0-9]+$/) print n index($0, up "-") == 1 {
n = substr($0, length(up) + 2)
if (n ~ /^[0-9]+$/) print n, $0
}' | }' |
sort -n | sort -n |
tail -1 tail -1
)" )"
tag="$upstream-$(( ${last:-1} + 1 ))"
if [ -z "$last" ]; then
revision=1
tag="$upstream"
else else
echo "$upstream has already been released; nothing to do." last_tag="${last#* }"
git fetch --no-tags --depth=1 origin "refs/tags/$last_tag:refs/tags/$last_tag"
# Only what goes into the build counts, not docs or CI.
if git diff --quiet "$last_tag" HEAD -- patches scripts updater.pub; then
echo "$last_tag already ships the current patches; nothing to do."
echo "proceed=false" >> "$GITHUB_OUTPUT" echo "proceed=false" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
revision="$(( ${last%% *} + 1 ))"
tag="$upstream-$revision"
fi
echo "revision=$revision" >> "$GITHUB_OUTPUT"
echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "proceed=true" >> "$GITHUB_OUTPUT" echo "proceed=true" >> "$GITHUB_OUTPUT"
echo "Releasing $tag" echo "Releasing $tag"
@@ -90,6 +92,8 @@ jobs:
uses: ./.github/workflows/build.yml uses: ./.github/workflows/build.yml
with: with:
upstream-ref: ${{ needs.detect.outputs.upstream }} upstream-ref: ${{ needs.detect.outputs.upstream }}
revision: ${{ needs.detect.outputs.revision }}
secrets: inherit
release: release:
name: Release name: Release
@@ -119,6 +123,63 @@ jobs:
path: artifacts path: artifacts
merge-multiple: true merge-multiple: true
# The app looks for updates in releases/latest/download/latest.json.
# Spaces in asset names become dots first, as GitHub would make them, so
# the addresses written into it are exact. A release without signatures
# stops here: every install it reached could never update again.
- name: Write the update manifest
env:
TAG: ${{ needs.detect.outputs.tag }}
UPSTREAM: ${{ needs.detect.outputs.upstream }}
REVISION: ${{ needs.detect.outputs.revision }}
run: |
set -euo pipefail
for file in artifacts/*' '*; do
if [ -e "$file" ]; then mv "$file" "${file// /.}"; fi
done
python3 - <<'EOF'
import datetime, json, os, pathlib
artifacts = pathlib.Path("artifacts")
tag = os.environ["TAG"]
repository = os.environ["GITHUB_REPOSITORY"]
# The app's version is the upstream one. A revision comes along as
# build metadata, which the app compares itself.
version = os.environ["UPSTREAM"].removeprefix("v")
if int(os.environ["REVISION"]) > 1:
version += "+" + os.environ["REVISION"]
def signed(suffix):
matches = [p for p in artifacts.iterdir() if p.name.endswith(suffix)]
if len(matches) != 1:
raise SystemExit(f"Expected one *{suffix}, found {[p.name for p in matches]}")
signature = pathlib.Path(f"{matches[0]}.sig")
if not signature.is_file():
raise SystemExit(f"{matches[0].name} is not signed: is TAURI_SIGNING_PRIVATE_KEY set?")
return {
"signature": signature.read_text().strip(),
"url": f"https://github.com/{repository}/releases/download/{tag}/{matches[0].name}",
}
macos = signed(".app.tar.gz")
manifest = {
"version": version,
"notes": f"Modrinth Enhanced {tag}",
"pub_date": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"platforms": {
"linux-x86_64": signed(".AppImage"),
"windows-x86_64": signed("-setup.exe"),
"darwin-x86_64": macos,
"darwin-aarch64": macos,
},
}
(artifacts / "latest.json").write_text(json.dumps(manifest, indent=2) + "\n")
print((artifacts / "latest.json").read_text())
EOF
- name: Publish the release - name: Publish the release
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
@@ -127,15 +188,24 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
ls -la artifacts ls -la artifacts
# Every patch by its subject, so the list never falls behind.
patches="$(
for patch in patches/0*.patch; do
awk '/^Subject: /{
s = $0
sub(/^Subject: (\[PATCH[^]]*\] )?/, "", s)
while ((getline line) > 0 && line ~ /^ /) s = s line
print "- " s
exit
}' "$patch"
done
)"
notes="$(cat <<EOF notes="$(cat <<EOF
Modrinth Enhanced $TAG, built from [Modrinth App $UPSTREAM](https://github.com/modrinth/code/releases/tag/$UPSTREAM). 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:
- no advertising, $patches
- no telemetry, crash reporting or survey embeds,
- offline accounts for singleplayer and offline-mode servers,
- renamed and re-iconed as Modrinth Enhanced.
It keeps using the same data directory as the official Modrinth App, It keeps using the same data directory as the official Modrinth App,
so instances, settings and accounts carry over. Do not run both at so instances, settings and accounts carry over. Do not run both at
@@ -144,7 +214,10 @@ jobs:
See the upstream release notes for everything else that changed. See the upstream release notes for everything else that changed.
EOF EOF
)" )"
# The tag goes on the commit that was built, not on whatever main is
# by now, so the next run compares against the patches it shipped.
gh release create "$TAG" \ gh release create "$TAG" \
--target "$(git rev-parse HEAD)" \
--title "Modrinth Enhanced $TAG" \ --title "Modrinth Enhanced $TAG" \
--notes "$notes" \ --notes "$notes" \
artifacts/* artifacts/*
+95 -21
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 and Ely.by accounts, a skins browser and tons of fixes for Linux. with offline, Ely.by and custom server 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
@@ -9,6 +9,30 @@ Modrinth ships a new version, the patches are reapplied on top of it, the result
checked on Linux, Windows and macOS, and a release is published automatically if it all still checked on Linux, Windows and macOS, and a release is published automatically if it all still
works. works.
## Installation
Installers are on the [latest release](https://github.com/Felitendo/Modrinth-Enhanced/releases/latest):
the `-setup.exe` for Windows, the `.dmg` for macOS and the `.AppImage` for Linux. All of them support auto-updates.
Arch Linux:
```bash
yay -S modrinth-enhanced-bin
```
Debian and Ubuntu:
```bash
curl -fLo /tmp/modrinth-enhanced.deb "$(curl -fsSL https://api.github.com/repos/Felitendo/Modrinth-Enhanced/releases/latest | grep -o 'https://[^"]*_amd64\.deb"' | tr -d '"')"
sudo apt install /tmp/modrinth-enhanced.deb
```
Fedora:
```bash
sudo dnf install "$(curl -fsSL https://api.github.com/repos/Felitendo/Modrinth-Enhanced/releases/latest | grep -o 'https://[^"]*\.x86_64\.rpm"' | tr -d '"')"
```
## What changes ## What changes
| Patch | What it does | | Patch | What it does |
@@ -28,20 +52,28 @@ works.
| `0013-Explain-what-went-wrong-...` | The Logs tab says what a crash was and offers a fix where there is one, without a connection. | | `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. | | `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. | | `0015-Use-the-desktop-s-file-picker-...` | File pickers on Linux are the desktop's own, such as KDE's, through the XDG desktop portal. |
| `0016-Show-the-account-in-the-title-bar-...` | With the right sidebar folded away, the Minecraft account is shown in the title bar and managed from there. |
| `0017-Start-on-Wayland-with-an-NVIDIA-GPU` | The app no longer crashes at start under Wayland with the NVIDIA driver. |
| `0018-Update-from-Modrinth-Enhanced-s-...` | Updates come from this project's own signed releases rather than Modrinth's. |
| `0019-Add-accounts-from-other-...` | Sign in to Drasl, Blessing Skin and other account servers, as with Ely.by. |
| `0020-Keep-the-settings-tabs-clear-of-...` | The settings tab list scrolls instead of running over the app version on Linux. |
| `0021-Show-and-change-skins-of-custom-...` | A custom server account's skin is shown and changed on the skin page, and its head in the account list. |
| `0022-Sign-in-to-custom-servers-in-...` | Servers with Yggdrasil Connect, such as LittleSkin, sign in on their own page, with two-factor authentication. |
| `0023-Browse-LittleSkin-s-skin-library` | LittleSkin's skin library in the Browse tab, with search, sorting and likes. |
### Offline accounts ### Offline accounts
"Add offline account" sits next to "Sign in to Minecraft" in the account card. It asks for a "Offline" under "Add account" in the account card asks for a username and nothing else.
username and nothing else.
The player UUID is derived exactly the way Minecraft itself derives it — an MD5 name UUID over The player UUID is derived exactly the way Minecraft itself derives it — an MD5 name UUID over
`OfflinePlayer:<name>` — so worlds keep the same player data when they are opened from another `OfflinePlayer:<name>` — so worlds keep the same player data when they are opened from another
launcher. Offline accounts can play singleplayer and join servers running in offline mode. Servers 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.
Both sit next to "Sign in to Microsoft" everywhere an account can be added: the account card, the Offline, Ely.by and custom server accounts sit next to Microsoft everywhere an account can be
modal you get when pressing Play with no account, and "Sign in to Minecraft" in the getting started added: the account card, the title bar menu, the modal you get when pressing Play with no account,
checklist. Upstream offered Microsoft and nothing else at all three. and "Sign in to Minecraft" in the getting started checklist. Upstream offered Microsoft and nothing
else.
### Microsoft sign-in ### Microsoft sign-in
@@ -49,17 +81,16 @@ Microsoft sign-in opens your own browser rather than a webview inside the launch
password manager, autofill and passkeys work, and you can see in the address bar that the page is password manager, autofill and passkeys work, and you can see in the address bar that the page is
really Microsoft's. really Microsoft's.
Microsoft cannot hand the result back: the client id the launcher uses is Minecraft's own, whose The client id the launcher uses is Minecraft's own, with no redirect the launcher could listen on.
only registered redirect is a fixed page on `login.live.com`, with no loopback address for the So the browser signs in on Microsoft's device code page, with the code already filled in, while the
launcher to listen on. So the browser lands on that page with the code in the address and you copy launcher asks Microsoft every few seconds whether that has happened. Once it has, the account is
the address into the launcher. The webview is still one click away for anyone the browser does not added and the launcher comes back to the front, without anything to paste. The window inside the
work out for. launcher is still one click away in the dialog, for when the browser does not work out.
### Ely.by accounts ### Ely.by accounts
"Add Ely.by account" sits in the same account card. It asks for an Ely.by account name or email and "Ely.by" asks for an Ely.by account name or email and a password. With two-factor authentication on, append the current code to the password after a
a password, which go to `authserver.ely.by` and nowhere else. With two-factor authentication on, colon, which is Ely.by's own convention.
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 At launch the game is pointed at Ely.by with
[authlib-injector](https://github.com/yushijinhun/authlib-injector), downloaded once and cached, so [authlib-injector](https://github.com/yushijinhun/authlib-injector), downloaded once and cached, so
@@ -72,6 +103,28 @@ 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 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. application registered with Ely.by — one has not been registered for Modrinth Enhanced.
### Custom server accounts
"Custom server" does the same for any other server authlib-injector works with, such as
[Drasl](https://github.com/unmojang/drasl), Blessing Skin or LittleSkin. It asks for the server as
well: its website is enough, since the server names its API in the `X-Authlib-Injector-API-Location`
header. The dialog then shows the server's name and a link to sign up there. An account with several
players asks which one to play as.
On Drasl, a player who signed up through another service uses the Minecraft token from their
account page as the password; the dialog says so. Servers with
[Yggdrasil Connect](https://github.com/yushijinhun/authlib-injector/issues/268), such as LittleSkin or
Blessing Skin with Janus, also offer "Sign in in the browser": the server's own page opens with the
code filled in, two-factor authentication included, and the launcher picks the account up once it is
done. That takes a client id, which the server either shares or has registered for this launcher;
the registered ones are listed in `CONNECT_CLIENT_IDS` in
`0022-Sign-in-to-custom-servers-in-the-browser.patch`, and there are none yet.
The account list shows which server an account is on, with the head of the skin worn there. The
skin page shows that skin, and picking or adding one uploads it to the server through
authlib-injector's texture API, which Drasl, Blessing Skin and LittleSkin all have. Capes are changed
on the server's website: it only knows the one uploaded there, and taking it off would delete it.
### Sidebar and news ### Sidebar and news
The Modrinth Servers button in the left sidebar can be switched off under The Modrinth Servers button in the left sidebar can be switched off under
@@ -80,8 +133,10 @@ Settings > Features > Sidebar.
The news section in the right sidebar folds away by clicking its heading. The right sidebar itself 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 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 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 title bar button briefly shows a check once its state is saved; the first time, a short note
as the mod browser, the button stays in place, greyed out. explains this. On pages that need the sidebar, such as the mod browser, the button stays in place,
greyed out. While the sidebar is folded away, the
Minecraft account sits in the title bar next to the window buttons, to switch, add or remove accounts.
None of the three reach Modrinth. Preference syncing maps a fixed list of named fields in both 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 directions and these are not in it, so they are neither sent to your Modrinth account nor
@@ -101,6 +156,12 @@ File pickers on Linux go through the XDG desktop portal, so KDE shows its own di
own, instead of a GTK dialog the AppImage themes as light Adwaita. Without a portal, GTK's dialog is 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. used as before. Windows and macOS already use their native pickers.
With the NVIDIA driver under Wayland, WebKitGTK used to close the window at start with "Error 71".
The webview now hands its frames over through shared memory there
(`WEBKIT_DMABUF_RENDERER_FORCE_SHM=1`), still rendering on the GPU. Setting that or
`WEBKIT_DISABLE_DMABUF_RENDERER` yourself keeps your choice. The AppImage forces X11 and was not
affected.
### Skins ### Skins
With an Ely.by account selected, the skin page shows the account's skins on Ely.by: apply one, add With an Ely.by account selected, the skin page shows the account's skins on Ely.by: apply one, add
@@ -113,7 +174,8 @@ name: first in the `player_skins` folder in the launcher directory (`<name>.png`
has the switch and a button that opens the folder. 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, 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 filters and like, wearer and view counts, and so is LittleSkin's library, with its search by name,
sorting and likes. 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 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. you open there is previewed and can be added.
@@ -145,6 +207,14 @@ without making anyone more private.
neither ends up in a build; removing the entries would mean carrying a patch against the lockfile neither ends up in a build; removing the entries would mean carrying a patch against the lockfile
for no practical gain. for no practical gain.
### Updates
The app updates itself from this project's releases on GitHub: on Windows, on macOS, and as an
AppImage on Linux. Updates are signed with this project's own key, whose public half is
`updater.pub`, and Modrinth is no longer asked, since its update would be the official app.
Installs from the AUR, a `.deb` or a `.rpm` are updated like any other package; the app shows a
notice with a button to the release when there is a new one.
## Relationship to the official app ## Relationship to the official app
Modrinth Enhanced keeps the upstream bundle identifier, which means it uses **the same data Modrinth Enhanced keeps the upstream bundle identifier, which means it uses **the same data
@@ -204,10 +274,14 @@ scripts/prepare.sh
published a newer release than `upstream.txt`, it rebases the patches onto it, rebuilds 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 - **Revisions** come from the same run: when `patches/` or `scripts/` changed since the last
with `revision` ticked. It builds the upstream release in `upstream.txt` again with the current release of that upstream version, it is released again as `v0.21.2-2`, `v0.21.2-3` and so on.
patches and publishes it as `v0.21.2-2`, `v0.21.2-3` and so on. The app and installers still The app and installers still carry the upstream version: RPM and the Windows installers do not
carry the upstream version: RPM and the Windows installers do not accept a suffix in it. accept a suffix in it.
- **Updates** are published with every release as `latest.json`, next to installers signed with the
`TAURI_SIGNING_PRIVATE_KEY` secret. A release without signatures fails instead of shipping, since
every install it reached could never update again. Builds without the secret, such as pull
requests from forks, have no updater.
`scripts/check.sh` is what makes the automation trustworthy. A patch can apply cleanly and still `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
@@ -49,7 +49,7 @@ index 5f863d5..32a86a3 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 e81abe7..ba08ca7 100644 index 09f98ec..b03a35d 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
@@ -1626,7 +1626,7 @@ @@ -1626,7 +1626,7 @@
+1 -1
View File
@@ -302,7 +302,7 @@ 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 ba08ca7..28d0366 100644 index b03a35d..2a589bd 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
@@ -2745,7 +2745,7 @@ @@ -2745,7 +2745,7 @@
+12 -34
View File
@@ -36,7 +36,6 @@ which offered Microsoft and nothing else:
same choice. same choice.
--- ---
Cargo.lock | 1 + Cargo.lock | 1 +
Cargo.toml | 1 +
apps/app-frontend/src/App.vue | 12 +- apps/app-frontend/src/App.vue | 12 +-
.../src/components/ui/AccountsCard.vue | 30 ++++ .../src/components/ui/AccountsCard.vue | 30 ++++
.../src/components/ui/OfflineAccountModal.vue | 136 ++++++++++++++++++ .../src/components/ui/OfflineAccountModal.vue | 136 ++++++++++++++++++
@@ -46,34 +45,22 @@ which offered Microsoft and nothing else:
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 | 66 +++++++++
12 files changed, 331 insertions(+), 8 deletions(-) 11 files changed, 328 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 diff --git a/Cargo.lock b/Cargo.lock
index bdc7307..9006b00 100644 index 28231e3..f94d0fe 100644
--- a/Cargo.lock --- a/Cargo.lock
+++ b/Cargo.lock +++ b/Cargo.lock
@@ -11534,6 +11534,7 @@ dependencies = [ @@ -12361,6 +12361,7 @@ dependencies = [
"indicatif", "indicatif",
"itertools 0.14.0", "itertools 0.14.0",
"json5", "json5",
+ "md-5", + "md5",
"modrinth-content-management", "modrinth-content-management",
"notify", "notify",
"notify-debouncer-mini", "notify-debouncer-mini",
diff --git a/Cargo.toml b/Cargo.toml
index b8b227e..8348c8d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -132,6 +132,7 @@ lz4_flex = { version = "0.11.5", default-features = false, features = [
"std",
] }
maxminddb = "0.26.0"
+md-5 = "0.10.6"
modrinth-content-management = { path = "packages/modrinth-content-management" }
modrinth-log = { path = "packages/modrinth-log" }
modrinth-util = { path = "packages/modrinth-util" }
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index 447f636..d750a5f 100644 index 447f636..d750a5f 100644
--- a/apps/app-frontend/src/App.vue --- a/apps/app-frontend/src/App.vue
@@ -476,14 +463,14 @@ 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 15a1dc9..4fe7643 100644 index 9bb14ce..58f85e3 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
@@ -52,6 +52,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 }
+md-5 = { workspace = true } +md5 = { workspace = true }
modrinth-content-management = { workspace = true } modrinth-content-management = { workspace = true }
notify = { workspace = true } notify = { workspace = true }
notify-debouncer-mini = { workspace = true } notify-debouncer-mini = { workspace = true }
@@ -538,18 +525,10 @@ index e7195c6..a7fac4a 100644
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> { pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
let state = State::get().await?; let state = State::get().await?;
diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs
index b835ad4..d97d233 100644 index b835ad4..14455ec 100644
--- a/packages/app-lib/src/state/minecraft_auth.rs --- a/packages/app-lib/src/state/minecraft_auth.rs
+++ b/packages/app-lib/src/state/minecraft_auth.rs +++ b/packages/app-lib/src/state/minecraft_auth.rs
@@ -6,6 +6,7 @@ use chrono::{DateTime, Duration, TimeZone, Utc}; @@ -212,6 +212,33 @@ pub struct Credentials {
use dashmap::DashMap;
use futures::TryStreamExt;
use heck::ToTitleCase;
+use md5::Md5;
use p256::ecdsa::signature::Signer;
use p256::ecdsa::{Signature, SigningKey, VerifyingKey};
use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
@@ -212,6 +213,34 @@ pub struct Credentials {
pub active: bool, pub active: bool,
} }
@@ -574,8 +553,7 @@ index b835ad4..d97d233 100644
+/// means a world played here keeps the same player data when it is opened from +/// means a world played here keeps the same player data when it is opened from
+/// somewhere else. +/// somewhere else.
+pub fn offline_uuid(username: &str) -> Uuid { +pub fn offline_uuid(username: &str) -> Uuid {
+ let mut bytes: [u8; 16] = + let mut bytes = md5::compute(format!("OfflinePlayer:{username}")).0;
+ Md5::digest(format!("OfflinePlayer:{username}").as_bytes()).into();
+ bytes[6] = (bytes[6] & 0x0f) | 0x30; // Version 3 + bytes[6] = (bytes[6] & 0x0f) | 0x30; // Version 3
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant + bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
+ Uuid::from_bytes(bytes) + Uuid::from_bytes(bytes)
@@ -584,7 +562,7 @@ index b835ad4..d97d233 100644
/// An entry in the player profile cache, keyed by player UUID. /// An entry in the player profile cache, keyed by player UUID.
pub(super) enum ProfileCacheEntry { pub(super) enum ProfileCacheEntry {
/// A cached profile that is valid, even though it may be stale. /// A cached profile that is valid, even though it may be stale.
@@ -265,12 +294,45 @@ impl OnlineProfileCacheIntent { @@ -265,12 +292,45 @@ impl OnlineProfileCacheIntent {
} }
impl Credentials { impl Credentials {
@@ -630,7 +608,7 @@ index b835ad4..d97d233 100644
// Use a margin of 5 minutes to give e.g. Minecraft and potentially // Use a margin of 5 minutes to give e.g. Minecraft and potentially
// other operations that depend on a fresh token 5 minutes to complete // other operations that depend on a fresh token 5 minutes to complete
// from now, and deal with some classes of clock skew // from now, and deal with some classes of clock skew
@@ -351,6 +413,12 @@ impl Credentials { @@ -351,6 +411,12 @@ impl Credentials {
&self, &self,
cache_intent: OnlineProfileCacheIntent, cache_intent: OnlineProfileCacheIntent,
) -> Option<Arc<MinecraftProfile>> { ) -> Option<Arc<MinecraftProfile>> {
+80 -26
View File
@@ -30,14 +30,14 @@ 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 it syncs. The other two are not settings entries - a heading and a title
bar button - so nothing there promises anything. bar button - so nothing there promises anything.
--- ---
apps/app-frontend/src/App.vue | 153 ++++++++++++++++-- apps/app-frontend/src/App.vue | 184 ++++++++++++++++--
.../ui/settings/display/FeaturesSettings.vue | 28 ++++ .../ui/settings/display/FeaturesSettings.vue | 28 +++
.../src/composables/use-app-settings.ts | 3 + .../src/composables/use-app-settings.ts | 4 +
packages/app-lib/src/state/settings.rs | 3 + packages/app-lib/src/state/settings.rs | 4 +
4 files changed, 172 insertions(+), 15 deletions(-) 4 files changed, 205 insertions(+), 15 deletions(-)
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index d750a5f..ac8308c 100644 index d750a5f..c47484d 100644
--- a/apps/app-frontend/src/App.vue --- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue
@@ -10,6 +10,8 @@ import { @@ -10,6 +10,8 @@ import {
@@ -49,7 +49,23 @@ index d750a5f..ac8308c 100644
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
CompassIcon, CompassIcon,
@@ -254,12 +256,41 @@ const credentials = ref() @@ -34,6 +36,7 @@ import {
AccountSwitchOverlay,
Admonition,
Avatar,
+ Button,
ButtonLink,
commonMessages,
commonSettingsMessages,
@@ -44,6 +47,7 @@ import {
I18nDebugPanel,
IconButton,
LoadingBar,
+ NewModal,
NewsArticleCard,
NotificationPanel,
PopupNotificationPanel,
@@ -254,12 +258,46 @@ const credentials = ref()
const storedModrinthAccounts = ref([]) const storedModrinthAccounts = ref([])
let credentialsRefreshId = 0 let credentialsRefreshId = 0
const sidebarToggled = ref(true) const sidebarToggled = ref(true)
@@ -72,29 +88,34 @@ index d750a5f..ac8308c 100644
+ (hide) => setSidebarCollapsed(hide), + (hide) => setSidebarCollapsed(hide),
) )
+ +
+// Briefly shows a check on the fold button once the state is saved, so it is +// Briefly shows a check on the fold button once the state is saved, and the
+// clear the choice is remembered. +// first time also explains that the choice is remembered.
+const sidebarSaved = ref(false) +const sidebarSaved = ref(false)
+let sidebarSavedTimeout +let sidebarSavedTimeout
+const sidebarRememberedModal = ref(null)
+ +
+function setSidebarCollapsed(collapsed) { +function setSidebarCollapsed(collapsed) {
+ const explain = !appSettings.featureFlags.sidebar_fold_explained
+ appSettings.featureFlags.right_sidebar_collapsed = collapsed + appSettings.featureFlags.right_sidebar_collapsed = collapsed
+ appSettings.featureFlags.sidebar_fold_explained = true
+ getSettings() + getSettings()
+ .then((settings) => { + .then((settings) => {
+ settings.feature_flags.right_sidebar_collapsed = collapsed + settings.feature_flags.right_sidebar_collapsed = collapsed
+ settings.feature_flags.sidebar_fold_explained = true
+ return setSettings(settings) + return setSettings(settings)
+ }) + })
+ .then(() => { + .then(() => {
+ sidebarSaved.value = true + sidebarSaved.value = true
+ clearTimeout(sidebarSavedTimeout) + clearTimeout(sidebarSavedTimeout)
+ sidebarSavedTimeout = setTimeout(() => (sidebarSaved.value = false), 1600) + sidebarSavedTimeout = setTimeout(() => (sidebarSaved.value = false), 1600)
+ if (explain) sidebarRememberedModal.value?.show()
+ }) + })
+ .catch(handleError) + .catch(handleError)
+} +}
const forceSidebar = computed( const forceSidebar = computed(
() => () =>
route.path.startsWith('/browse') || route.path.startsWith('/browse') ||
@@ -358,6 +389,21 @@ useQuery({ @@ -358,6 +396,21 @@ useQuery({
// ad and the gradient being drawn above it. // ad and the gradient being drawn above it.
const showAd = computed(() => false) const showAd = computed(() => false)
const adConsentAvailable = computed(() => false) const adConsentAvailable = computed(() => false)
@@ -116,15 +137,24 @@ index d750a5f..ac8308c 100644
providePageContext({ providePageContext({
hierarchicalSidebarAvailable: ref(true), hierarchicalSidebarAvailable: ref(true),
showAds: showAd, showAds: showAd,
@@ -588,6 +634,16 @@ const messages = defineMessages({ @@ -588,6 +641,25 @@ const messages = defineMessages({
goBack: { id: 'app.navigation.go-back', defaultMessage: 'Go back' }, goBack: { id: 'app.navigation.go-back', defaultMessage: 'Go back' },
goForward: { id: 'app.navigation.go-forward', defaultMessage: 'Go forward' }, goForward: { id: 'app.navigation.go-forward', defaultMessage: 'Go forward' },
nextImage: { id: 'app.navigation.next-image', defaultMessage: 'Next image' }, nextImage: { id: 'app.navigation.next-image', defaultMessage: 'Next image' },
+ hideSidebar: { id: 'app.navigation.hide-sidebar', defaultMessage: 'Hide sidebar' }, + hideSidebar: { id: 'app.navigation.hide-sidebar', defaultMessage: 'Hide sidebar' },
+ showSidebar: { id: 'app.navigation.show-sidebar', defaultMessage: 'Show sidebar' }, + showSidebar: { id: 'app.navigation.show-sidebar', defaultMessage: 'Show sidebar' },
+ sidebarRemembered: { + sidebarRememberedTitle: {
+ id: 'app.navigation.sidebar-remembered', + id: 'app.navigation.sidebar-remembered.title',
+ defaultMessage: 'Remembered after restart', + defaultMessage: 'The sidebar stays how you leave it',
+ },
+ sidebarRememberedBody: {
+ id: 'app.navigation.sidebar-remembered.body',
+ defaultMessage:
+ 'Folding the sidebar away or bringing it back is saved right away, so the app opens the same way next time, even after a restart. The check on the button shows when it has been saved.',
+ },
+ sidebarRememberedDone: {
+ id: 'app.navigation.sidebar-remembered.done',
+ defaultMessage: 'Done',
+ }, + },
+ sidebarRequired: { + sidebarRequired: {
+ id: 'app.navigation.sidebar-required', + id: 'app.navigation.sidebar-required',
@@ -133,7 +163,7 @@ index d750a5f..ac8308c 100644
updateDownloadMissingVersion: { updateDownloadMissingVersion: {
id: 'app.update.download-error.missing-version', id: 'app.update.download-error.missing-version',
defaultMessage: 'Failed to download update: no version available', defaultMessage: 'Failed to download update: no version available',
@@ -2252,6 +2308,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) @@ -2252,6 +2324,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<ImageIcon /> <ImageIcon />
</NavButton> </NavButton>
<NavButton <NavButton
@@ -141,7 +171,7 @@ index d750a5f..ac8308c 100644
v-tooltip.right="formatMessage(messages.modrinthHosting)" v-tooltip.right="formatMessage(messages.modrinthHosting)"
to="/hosting/manage" to="/hosting/manage"
:is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'" :is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'"
@@ -2377,16 +2434,44 @@ provideAppUpdateDownloadProgress(appUpdateDownload) @@ -2377,16 +2450,42 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<Breadcrumbs /> <Breadcrumbs />
</div> </div>
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center"> <section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
@@ -162,8 +192,6 @@ index d750a5f..ac8308c 100644
+ formatMessage( + formatMessage(
+ forceSidebar + forceSidebar
+ ? messages.sidebarRequired + ? messages.sidebarRequired
+ : sidebarSaved
+ ? messages.sidebarRemembered
+ : sidebarToggled + : sidebarToggled
+ ? messages.hideSidebar + ? messages.hideSidebar
+ : messages.showSidebar, + : messages.showSidebar,
@@ -195,7 +223,7 @@ index d750a5f..ac8308c 100644
<div class="flex mr-3"> <div class="flex mr-3">
<Suspense> <Suspense>
<AppActionBar /> <AppActionBar />
@@ -2502,10 +2587,18 @@ provideAppUpdateDownloadProgress(appUpdateDownload) @@ -2502,10 +2601,18 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid" 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"> <div v-if="news && news.length > 0" class="p-4 flex flex-col items-center">
@@ -217,7 +245,29 @@ index d750a5f..ac8308c 100644
<NewsArticleCard <NewsArticleCard
v-for="(item, index) in news" v-for="(item, index) in news"
:key="`news-${index}`" :key="`news-${index}`"
@@ -2743,6 +2836,36 @@ provideAppUpdateDownloadProgress(appUpdateDownload) @@ -2531,6 +2638,21 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<I18nDebugPanel />
<NotificationPanel :has-sidebar="sidebarVisible" />
<PopupNotificationPanel :has-sidebar="sidebarVisible" />
+ <NewModal
+ ref="sidebarRememberedModal"
+ :header="formatMessage(messages.sidebarRememberedTitle)"
+ max-width="460px"
+ >
+ <p class="m-0">{{ formatMessage(messages.sidebarRememberedBody) }}</p>
+ <template #actions>
+ <div class="flex justify-end">
+ <Button type="colored" color="brand" @click="sidebarRememberedModal?.hide()">
+ <CheckIcon />
+ {{ formatMessage(messages.sidebarRememberedDone) }}
+ </Button>
+ </div>
+ </template>
+ </NewModal>
<ErrorModal ref="errorModal" />
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
<MinecraftRequiredModal ref="minecraftRequiredModal" />
@@ -2743,6 +2865,38 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
display: contents; display: contents;
} }
@@ -238,9 +288,11 @@ index d750a5f..ac8308c 100644
+ opacity: 0; + opacity: 0;
+ } + }
+ +
+ /* The path runs from the long stroke's tip back to the short one, so it is
+ revealed from its end: the check is drawn left to right. */
+ .sidebar-saved-badge :deep(path) { + .sidebar-saved-badge :deep(path) {
+ stroke-dasharray: 20; + stroke-dasharray: 24;
+ stroke-dashoffset: 20; + stroke-dashoffset: -24;
+ animation: sidebar-saved-draw 0.3s 0.15s ease-out forwards; + animation: sidebar-saved-draw 0.3s 0.15s ease-out forwards;
+ } + }
+} +}
@@ -336,29 +388,31 @@ index 1d3e379..ae88550 100644
<h3 class="m-0 text-lg font-semibold text-contrast"> <h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.quickInstancesTitle) }} {{ 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 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 index 0d58903..28c78c3 100644
--- a/apps/app-frontend/src/composables/use-app-settings.ts --- a/apps/app-frontend/src/composables/use-app-settings.ts
+++ b/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 = { @@ -24,6 +24,10 @@ export const DEFAULT_FEATURE_FLAGS = {
friends_pending_collapsed: true, friends_pending_collapsed: true,
dismissed_photosensitivity_filter_warning: false, dismissed_photosensitivity_filter_warning: false,
localhost_sign_in: false, localhost_sign_in: false,
+ show_hosting_in_sidebar: true, + show_hosting_in_sidebar: true,
+ news_collapsed: false, + news_collapsed: false,
+ sidebar_fold_explained: false,
+ right_sidebar_collapsed: false, + right_sidebar_collapsed: false,
} }
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS 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 diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs
index 20601b8..2fd0d87 100644 index 20601b8..b5f2ec6 100644
--- a/packages/app-lib/src/state/settings.rs --- a/packages/app-lib/src/state/settings.rs
+++ b/packages/app-lib/src/state/settings.rs +++ b/packages/app-lib/src/state/settings.rs
@@ -88,6 +88,9 @@ pub enum FeatureFlag { @@ -88,6 +88,10 @@ pub enum FeatureFlag {
FriendsPendingCollapsed, FriendsPendingCollapsed,
DismissedPhotosensitivityFilterWarning, DismissedPhotosensitivityFilterWarning,
LocalhostSignIn, LocalhostSignIn,
+ ShowHostingInSidebar, + ShowHostingInSidebar,
+ NewsCollapsed, + NewsCollapsed,
+ SidebarFoldExplained,
+ RightSidebarCollapsed, + RightSidebarCollapsed,
} }
+18 -17
View File
@@ -4,10 +4,9 @@ Date: Mon, 14 Sep 2026 13:24:41 +0200
Subject: [PATCH] Add Ely.by accounts Subject: [PATCH] Add Ely.by accounts
Ely.by is an alternative Minecraft account system. "Add Ely.by account" 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 sits next to the Microsoft and offline options in the account card and
account name and password go to authserver.ely.by and nowhere else, and asks for the account name and password. A two-factor code is appended
a two-factor code is appended to the password after a colon, which is to the password after a colon, which is Ely.by's own convention.
Ely.by's own convention.
Minecraft asks Mojang who the player is, so an Ely.by account cannot 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 start the game on its own. The authlib-injector agent points those calls
@@ -28,7 +27,7 @@ and needs an OAuth application registered with Ely.by; there is none for
Modrinth Enhanced yet. Modrinth Enhanced yet.
--- ---
.../src/components/ui/AccountsCard.vue | 30 ++- .../src/components/ui/AccountsCard.vue | 30 ++-
.../src/components/ui/ElyAccountModal.vue | 166 ++++++++++++++ .../src/components/ui/ElyAccountModal.vue | 168 ++++++++++++++
.../MinecraftRequiredModal.vue | 29 ++- .../MinecraftRequiredModal.vue | 29 ++-
apps/app-frontend/src/helpers/auth.js | 15 ++ apps/app-frontend/src/helpers/auth.js | 15 ++
apps/app/build.rs | 1 + apps/app/build.rs | 1 +
@@ -38,7 +37,7 @@ Modrinth Enhanced yet.
packages/app-lib/src/state/minecraft_auth.rs | 213 +++++++++++++++++- packages/app-lib/src/state/minecraft_auth.rs | 213 +++++++++++++++++-
packages/app-lib/src/util/authlib_injector.rs | 77 +++++++ packages/app-lib/src/util/authlib_injector.rs | 77 +++++++
packages/app-lib/src/util/mod.rs | 1 + packages/app-lib/src/util/mod.rs | 1 +
11 files changed, 579 insertions(+), 13 deletions(-) 11 files changed, 581 insertions(+), 13 deletions(-)
create mode 100644 apps/app-frontend/src/components/ui/ElyAccountModal.vue create mode 100644 apps/app-frontend/src/components/ui/ElyAccountModal.vue
create mode 100644 packages/app-lib/src/util/authlib_injector.rs create mode 100644 packages/app-lib/src/util/authlib_injector.rs
@@ -141,10 +140,10 @@ index ebc92c1..35c21fa 100644
defaultMessage: '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 diff --git a/apps/app-frontend/src/components/ui/ElyAccountModal.vue b/apps/app-frontend/src/components/ui/ElyAccountModal.vue
new file mode 100644 new file mode 100644
index 0000000..d909ec3 index 0000000..c589375
--- /dev/null --- /dev/null
+++ b/apps/app-frontend/src/components/ui/ElyAccountModal.vue +++ b/apps/app-frontend/src/components/ui/ElyAccountModal.vue
@@ -0,0 +1,166 @@ @@ -0,0 +1,168 @@
+<template> +<template>
+ <NewModal ref="modal" :header="formatMessage(messages.header)" max-width="480px" width="100%"> + <NewModal ref="modal" :header="formatMessage(messages.header)" max-width="480px" width="100%">
+ <div class="flex flex-col gap-4"> + <div class="flex flex-col gap-4">
@@ -265,8 +264,10 @@ index 0000000..d909ec3
+ modal.value?.hide() + modal.value?.hide()
+ emit('created', account) + emit('created', account)
+ } catch (e) { + } catch (e) {
+ error.value = + const text =
+ typeof e === 'string' ? e : ((e as Error)?.message ?? formatMessage(messages.genericError)) + typeof e === 'string' ? e : ((e as Error)?.message ?? formatMessage(messages.genericError))
+ // The launcher's own label for the kind of error says nothing here.
+ error.value = text.replace(/^(Error|Invalid input): /, '')
+ } finally { + } finally {
+ submitting.value = false + submitting.value = false
+ } + }
@@ -282,7 +283,7 @@ index 0000000..d909ec3
+ description: { + description: {
+ id: 'app.ely-account.description', + id: 'app.ely-account.description',
+ defaultMessage: + 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.', + 'Ely.by is an alternative Minecraft account system. Its accounts can play singleplayer and join any server that accepts Ely.by.',
+ }, + },
+ usernameLabel: { + usernameLabel: {
+ id: 'app.ely-account.username-label', + id: 'app.ely-account.username-label',
@@ -487,10 +488,10 @@ index a7fac4a..2d18da3 100644
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> { pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
let state = State::get().await?; let state = State::get().await?;
diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs
index 7910694..e623cab 100644 index 17d20af..93cfd20 100644
--- a/packages/app-lib/src/launcher/mod.rs --- a/packages/app-lib/src/launcher/mod.rs
+++ b/packages/app-lib/src/launcher/mod.rs +++ b/packages/app-lib/src/launcher/mod.rs
@@ -1059,6 +1059,22 @@ pub async fn launch_minecraft( @@ -1072,6 +1072,22 @@ pub async fn launch_minecraft(
command.arg("--add-opens=jdk.internal/jdk.internal.misc=ALL-UNNAMED"); command.arg("--add-opens=jdk.internal/jdk.internal.misc=ALL-UNNAMED");
} }
@@ -514,10 +515,10 @@ index 7910694..e623cab 100644
.arg("com.modrinth.theseus.MinecraftLaunch") .arg("com.modrinth.theseus.MinecraftLaunch")
.arg(version_info.main_class.clone()) .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 diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs
index d97d233..25c6dfb 100644 index 14455ec..4130488 100644
--- a/packages/app-lib/src/state/minecraft_auth.rs --- a/packages/app-lib/src/state/minecraft_auth.rs
+++ b/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 { @@ -239,6 +239,67 @@ pub fn offline_uuid(username: &str) -> Uuid {
Uuid::from_bytes(bytes) Uuid::from_bytes(bytes)
} }
@@ -585,7 +586,7 @@ index d97d233..25c6dfb 100644
/// An entry in the player profile cache, keyed by player UUID. /// An entry in the player profile cache, keyed by player UUID.
pub(super) enum ProfileCacheEntry { pub(super) enum ProfileCacheEntry {
/// A cached profile that is valid, even though it may be stale. /// A cached profile that is valid, even though it may be stale.
@@ -321,6 +382,135 @@ impl Credentials { @@ -319,6 +380,135 @@ impl Credentials {
self.refresh_token == OFFLINE_REFRESH_TOKEN self.refresh_token == OFFLINE_REFRESH_TOKEN
} }
@@ -721,7 +722,7 @@ index d97d233..25c6dfb 100644
/// Refreshes the authentication tokens for this user if they are expired, or /// Refreshes the authentication tokens for this user if they are expired, or
/// very close to expiration. /// very close to expiration.
async fn refresh( async fn refresh(
@@ -340,6 +530,22 @@ impl Credentials { @@ -338,6 +528,22 @@ impl Credentials {
return Ok(()); return Ok(());
} }
@@ -744,7 +745,7 @@ index d97d233..25c6dfb 100644
let oauth_token = oauth_refresh(&self.refresh_token).await?; let oauth_token = oauth_refresh(&self.refresh_token).await?;
let (pair, current_date) = let (pair, current_date) =
DeviceTokenPair::refresh_and_get_device_token( DeviceTokenPair::refresh_and_get_device_token(
@@ -413,9 +619,10 @@ impl Credentials { @@ -411,9 +617,10 @@ impl Credentials {
&self, &self,
cache_intent: OnlineProfileCacheIntent, cache_intent: OnlineProfileCacheIntent,
) -> Option<Arc<MinecraftProfile>> { ) -> Option<Arc<MinecraftProfile>> {
@@ -8,30 +8,30 @@ 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 else: no password manager, no autofill, no passkeys, and no way to tell
by looking that the page is really Microsoft's. by looking that the page is really Microsoft's.
The browser gets all of it. What it cannot do is hand the code back: The browser gets all of it. What it cannot do is hand a code back: this
this client id is Minecraft's own, its only registered redirect is a client id is Minecraft's own, and no loopback address is registered for
fixed page on login.live.com, and no loopback address is registered for it. So the browser signs in on Microsoft's device code page, with the
it, so there is nothing for the launcher to listen on. The browser code already filled in, while the launcher polls for the result and
therefore lands on that page with the code in the address, and the comes back to the front once it is there.
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 Every entry point goes through it, since they all end up at
`AccountsCard.login()`. The webview is still one click away in that `AccountsCard.login()`. The webview is still one click away in that
modal for anyone the browser does not work out for. modal for anyone the browser does not work out for.
--- ---
.../src/components/ui/AccountsCard.vue | 17 +- .../src/components/ui/AccountsCard.vue | 21 +-
.../src/components/ui/MicrosoftLoginModal.vue | 213 ++++++++++++++++++ .../src/components/ui/MicrosoftLoginModal.vue | 219 ++++++++++++++++++
.../MinecraftRequiredModal.vue | 29 +-- .../MinecraftRequiredModal.vue | 29 +--
apps/app-frontend/src/helpers/auth.js | 25 ++ apps/app-frontend/src/helpers/auth.js | 23 ++
apps/app/build.rs | 2 + apps/app/build.rs | 2 +
apps/app/src/api/auth.rs | 77 +++++++ apps/app/src/api/auth.rs | 64 +++++
packages/app-lib/src/api/minecraft_auth.rs | 30 +++
packages/app-lib/src/api/mod.rs | 12 +- packages/app-lib/src/api/mod.rs | 12 +-
7 files changed, 333 insertions(+), 42 deletions(-) packages/app-lib/src/state/minecraft_auth.rs | 145 +++++++++++-
9 files changed, 499 insertions(+), 46 deletions(-)
create mode 100644 apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue 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 diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
index 35c21fa..c81603c 100644 index 35c21fa..224d776 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
@@ -105,6 +105,7 @@ @@ -105,6 +105,7 @@
@@ -84,46 +84,64 @@ index 35c21fa..c81603c 100644
} }
async function accountAdded() { async function accountAdded() {
@@ -316,8 +309,8 @@ const messages = defineMessages({
defaultMessage: 'Not signed in',
},
addAccount: {
- id: 'minecraft-account.add-account',
- defaultMessage: 'Add account',
+ id: 'minecraft-account.add-microsoft-account',
+ defaultMessage: 'Add Microsoft account',
},
addOfflineAccount: {
id: 'minecraft-account.add-offline-account',
diff --git a/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue b/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue diff --git a/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue b/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue
new file mode 100644 new file mode 100644
index 0000000..283b89c index 0000000..d587736
--- /dev/null --- /dev/null
+++ b/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue +++ b/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue
@@ -0,0 +1,213 @@ @@ -0,0 +1,219 @@
+<template> +<template>
+ <NewModal ref="modal" :header="formatMessage(messages.header)" max-width="520px" width="100%"> + <NewModal
+ ref="modal"
+ :header="formatMessage(messages.header)"
+ max-width="480px"
+ width="100%"
+ :on-hide="stop"
+ >
+ <div class="flex flex-col gap-4"> + <div class="flex flex-col gap-4">
+ <p class="m-0 leading-tight text-secondary"> + <p class="m-0 leading-tight text-secondary">
+ {{ formatMessage(messages.description) }} + {{ formatMessage(messages.description) }}
+ </p> + </p>
+ +
+ <ol class="m-0 flex list-decimal flex-col gap-1 pl-5 leading-tight text-secondary"> + <div v-if="code" class="flex flex-col items-center gap-1 rounded-2xl bg-surface-2 px-4 py-3">
+ <li>{{ formatMessage(messages.stepSignIn) }}</li> + <span class="text-sm text-secondary">{{ formatMessage(messages.codeLabel) }}</span>
+ <li>{{ formatMessage(messages.stepCopy) }}</li> + <div class="flex items-center gap-2">
+ </ol> + <span class="select-all font-mono text-2xl font-bold tracking-widest text-contrast">
+ {{ code.user_code }}
+ </span>
+ <IconButton
+ v-tooltip="formatMessage(messages.copyCode)"
+ type="quiet"
+ size="sm"
+ :label="formatMessage(messages.copyCode)"
+ @click="copyCode"
+ >
+ <CheckIcon v-if="copied" />
+ <CopyIcon v-else />
+ </IconButton>
+ </div>
+ </div>
+ +
+ <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> + <p v-if="error" class="m-0 text-sm leading-tight text-red">{{ error }}</p>
+ </form> + <p v-else class="m-0 flex items-center gap-2 leading-tight text-secondary">
+ <SpinnerIcon aria-hidden="true" class="animate-spin" />
+ {{ formatMessage(code ? messages.waiting : messages.opening) }}
+ </p>
+ +
+ <button + <button
+ class="button-base m-0 cursor-pointer border-0 bg-transparent p-0 text-left text-sm text-secondary underline" + class="button-base m-0 cursor-pointer border-0 bg-transparent p-0 text-left text-sm text-secondary underline"
+ type="button" + type="button"
+ :disabled="submitting"
+ @click="useBuiltInWindow" + @click="useBuiltInWindow"
+ > + >
+ {{ formatMessage(messages.useBuiltInWindow) }} + {{ formatMessage(messages.useBuiltInWindow) }}
@@ -136,45 +154,38 @@ index 0000000..283b89c
+ <XIcon aria-hidden="true" /> + <XIcon aria-hidden="true" />
+ {{ formatMessage(commonMessages.cancelButton) }} + {{ formatMessage(commonMessages.cancelButton) }}
+ </Button> + </Button>
+ <Button + <Button native-type="button" :disabled="opening" @click="openBrowser">
+ native-type="button"
+ :disabled="submitting || opening"
+ @click="openBrowser"
+ >
+ <SpinnerIcon v-if="opening" aria-hidden="true" class="animate-spin" /> + <SpinnerIcon v-if="opening" aria-hidden="true" class="animate-spin" />
+ <ExternalIcon v-else aria-hidden="true" /> + <ExternalIcon v-else aria-hidden="true" />
+ {{ formatMessage(messages.openAgain) }} + {{ formatMessage(messages.openAgain) }}
+ </Button> + </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> + </div>
+ </template> + </template>
+ </NewModal> + </NewModal>
+</template> +</template>
+ +
+<script setup lang="ts"> +<script setup lang="ts">
+import { ExternalIcon, LinkIcon, LogInIcon, SpinnerIcon, XIcon } from '@modrinth/assets' +import { CheckIcon, CopyIcon, ExternalIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
+import { +import {
+ Button, + Button,
+ commonMessages, + commonMessages,
+ defineMessages, + defineMessages,
+ Input, + IconButton,
+ NewModal, + NewModal,
+ useVIntl, + useVIntl,
+} from '@modrinth/ui' +} from '@modrinth/ui'
+import { ref } from 'vue' +import { ref } from 'vue'
+ +
+import { handleSevereError } from '@/composables/use-error.js' +import { handleSevereError } from '@/composables/use-error.js'
+import { login as builtInLogin, login_browser_begin, login_browser_finish } from '@/helpers/auth' +import { login as builtInLogin, login_device_begin, login_device_poll } from '@/helpers/auth'
+
+type DeviceCode = {
+ user_code: string
+ device_code: string
+ verification_uri: string
+ interval: number
+ expires_in: number
+}
+ +
+const { formatMessage } = useVIntl() +const { formatMessage } = useVIntl()
+ +
@@ -183,60 +194,71 @@ index 0000000..283b89c
+}>() +}>()
+ +
+const modal = ref<InstanceType<typeof NewModal>>() +const modal = ref<InstanceType<typeof NewModal>>()
+const redirect = ref('') +const code = ref<DeviceCode | null>(null)
+const error = ref('') +const error = ref('')
+const opening = ref(false) +const opening = ref(false)
+const submitting = ref(false) +const copied = ref(false)
+let flow: unknown = null
+ +
+async function show(event?: MouseEvent) { +// Bumped whenever the sign-in starts over or is given up on, so that a poll
+ redirect.value = '' +// from before does not carry on.
+ error.value = '' +let attempt = 0
+ submitting.value = false +let pollTimeout: ReturnType<typeof setTimeout> | undefined
+
+function show(event?: MouseEvent) {
+ modal.value?.show(event) + modal.value?.show(event)
+ await openBrowser() + void openBrowser()
+}
+
+function stop() {
+ attempt++
+ clearTimeout(pollTimeout)
+} +}
+ +
+async function openBrowser() { +async function openBrowser() {
+ if (opening.value) return + stop()
+ + const current = attempt
+ code.value = null
+ error.value = ''
+ copied.value = false
+ opening.value = true + opening.value = true
+ error.value = ''
+ +
+ try { + try {
+ flow = await login_browser_begin() + const started = (await login_device_begin()) as DeviceCode
+ if (current !== attempt) return
+ code.value = started
+ schedulePoll(current, started)
+ } catch (e) { + } catch (e) {
+ flow = null + if (current === attempt) error.value = messageOf(e, messages.openError)
+ error.value = messageOf(e, messages.openError)
+ } finally { + } finally {
+ opening.value = false + if (current === attempt) opening.value = false
+ } + }
+} +}
+ +
+async function submit() { +function schedulePoll(current: number, started: DeviceCode) {
+ if (submitting.value) return + pollTimeout = setTimeout(() => void poll(current, started), started.interval * 1000)
+
+ const value = redirect.value.trim()
+ if (!value) return
+
+ if (!flow) {
+ error.value = formatMessage(messages.noFlow)
+ return
+} +}
+ +
+ submitting.value = true +async function poll(current: number, started: DeviceCode) {
+ error.value = ''
+
+ try { + try {
+ const account = await login_browser_finish(value, flow) + const account = await login_device_poll(started.device_code)
+ redirect.value = '' + if (account) {
+ modal.value?.hide() + // The account exists now, so it is announced even if the dialog was
+ // closed in the meantime.
+ if (current === attempt) modal.value?.hide()
+ emit('created', account) + emit('created', account)
+ } catch (e) { + } else if (current === attempt) {
+ error.value = messageOf(e, messages.genericError) + schedulePoll(current, started)
+ } finally {
+ submitting.value = false
+ } + }
+ } catch (e) {
+ if (current === attempt) error.value = messageOf(e, messages.genericError)
+ }
+}
+
+async function copyCode() {
+ if (!code.value) return
+ await navigator.clipboard.writeText(code.value.user_code)
+ copied.value = true
+ setTimeout(() => (copied.value = false), 1500)
+} +}
+ +
+// The window the launcher opens itself, for when the browser will not do. +// The window the launcher opens itself, for when the browser will not do.
@@ -262,29 +284,28 @@ index 0000000..283b89c
+ description: { + description: {
+ id: 'app.microsoft-login.description', + id: 'app.microsoft-login.description',
+ defaultMessage: + 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.', + 'Sign in to Microsoft in the browser that just opened, where your password manager and passkeys work as usual. Once you are done, you are brought back here.',
+ }, + },
+ stepSignIn: { + codeLabel: {
+ id: 'app.microsoft-login.step-sign-in', + id: 'app.microsoft-login.code-label',
+ defaultMessage: 'Sign in to Microsoft in the browser.', + defaultMessage: 'If Microsoft asks for a code, enter',
+ }, + },
+ stepCopy: { + copyCode: {
+ id: 'app.microsoft-login.step-copy', + id: 'app.microsoft-login.copy-code',
+ defaultMessage: + defaultMessage: 'Copy code',
+ 'You will land on a blank page. Copy its whole address from the address bar and paste it below.',
+ }, + },
+ addressLabel: { + opening: {
+ id: 'app.microsoft-login.address-label', + id: 'app.microsoft-login.opening',
+ defaultMessage: 'Address the browser finished at', + defaultMessage: 'Opening your browser…',
+ },
+ waiting: {
+ id: 'app.microsoft-login.waiting',
+ defaultMessage: 'Waiting for you to sign in…',
+ }, + },
+ openAgain: { + openAgain: {
+ id: 'app.microsoft-login.open-again', + id: 'app.microsoft-login.open-again',
+ defaultMessage: 'Open browser again', + defaultMessage: 'Open browser again',
+ }, + },
+ finish: {
+ id: 'app.microsoft-login.finish',
+ defaultMessage: 'Sign in',
+ },
+ useBuiltInWindow: { + useBuiltInWindow: {
+ id: 'app.microsoft-login.use-built-in-window', + id: 'app.microsoft-login.use-built-in-window',
+ defaultMessage: 'Trouble with the browser? Sign in in a window here instead.', + defaultMessage: 'Trouble with the browser? Sign in in a window here instead.',
@@ -293,10 +314,6 @@ index 0000000..283b89c
+ id: 'app.microsoft-login.open-error', + id: 'app.microsoft-login.open-error',
+ defaultMessage: 'Could not open a browser to sign in with.', + 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: { + genericError: {
+ id: 'app.microsoft-login.generic-error', + id: 'app.microsoft-login.generic-error',
+ defaultMessage: 'Could not sign in to Microsoft.', + defaultMessage: 'Could not sign in to Microsoft.',
@@ -369,56 +386,54 @@ index 7781cee..341ccdc 100644
function addOfflineAccount(event: MouseEvent) { function addOfflineAccount(event: MouseEvent) {
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 57580ff..c25af95 100644 index 57580ff..9b2408e 100644
--- a/apps/app-frontend/src/helpers/auth.js --- a/apps/app-frontend/src/helpers/auth.js
+++ b/apps/app-frontend/src/helpers/auth.js +++ b/apps/app-frontend/src/helpers/auth.js
@@ -33,6 +33,31 @@ export async function login() { @@ -33,6 +33,29 @@ export async function login() {
return await invoke('plugin:auth|login') return await invoke('plugin:auth|login')
} }
+/** +/**
+ * Starts a Microsoft sign-in in the default browser. + * Starts a Microsoft sign-in in the default browser.
+ * + *
+ * Opens the browser and returns the flow to hand back to + * Opens Microsoft's sign-in page with the code already filled in. Unlike the
+ * {@link login_browser_finish} together with the address the browser ends up + * window the launcher opens itself, the browser has the player's password
+ * at. Unlike the window the launcher opens itself, the browser has the + * manager, autofill and passkeys.
+ * player's password manager, autofill and passkeys.
+ * + *
+ * @returns {Promise<object>} the login flow to finish with + * @returns {Promise<object>} the code, to show and to poll {@link login_device_poll} with
+ */ + */
+export async function login_browser_begin() { +export async function login_device_begin() {
+ return await invoke('plugin:auth|login_browser_begin') + return await invoke('plugin:auth|login_device_begin')
+} +}
+ +
+/** +/**
+ * Finishes a browser sign-in. + * Checks on a browser sign-in.
+ * + *
+ * @param {string} redirect the address the browser finished at, or the code in it + * @param {string} deviceCode the `device_code` from {@link login_device_begin}
+ * @param {object} flow the flow returned by {@link login_browser_begin} + * @returns {Promise<Credential | null>} the new account, or null while the player is still signing in
+ * @returns {Promise<Credential>}
+ */ + */
+export async function login_browser_finish(redirect, flow) { +export async function login_device_poll(deviceCode) {
+ return await invoke('plugin:auth|login_browser_finish', { redirect, flow }) + return await invoke('plugin:auth|login_device_poll', { deviceCode })
+} +}
+ +
/** /**
* Adds an offline account with the given username and makes it the active one. * 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 diff --git a/apps/app/build.rs b/apps/app/build.rs
index 0f62dd8..8562a68 100644 index 0f62dd8..919c91a 100644
--- a/apps/app/build.rs --- a/apps/app/build.rs
+++ b/apps/app/build.rs +++ b/apps/app/build.rs
@@ -14,6 +14,8 @@ fn main() { @@ -14,6 +14,8 @@ fn main() {
.commands(&[ .commands(&[
"check_reachable", "check_reachable",
"login", "login",
+ "login_browser_begin", + "login_device_begin",
+ "login_browser_finish", + "login_device_poll",
"login_offline", "login_offline",
"login_ely", "login_ely",
"remove_user", "remove_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 dea07b2..5c3648c 100644 index dea07b2..eab604d 100644
--- a/apps/app/src/api/auth.rs --- a/apps/app/src/api/auth.rs
+++ b/apps/app/src/api/auth.rs +++ b/apps/app/src/api/auth.rs
@@ -2,6 +2,7 @@ use crate::api::Result; @@ -2,6 +2,7 @@ use crate::api::Result;
@@ -433,12 +448,12 @@ index dea07b2..5c3648c 100644
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
check_reachable, check_reachable,
login, login,
+ login_browser_begin, + login_device_begin,
+ login_browser_finish, + login_device_poll,
login_offline, login_offline,
login_ely, login_ely,
remove_user, remove_user,
@@ -88,6 +91,80 @@ pub async fn login<R: Runtime>( @@ -88,6 +91,67 @@ pub async fn login<R: Runtime>(
Ok(None) Ok(None)
} }
@@ -449,19 +464,26 @@ index dea07b2..5c3648c 100644
+/// cannot sign in the way they sign in everywhere else. Their own browser has +/// cannot sign in the way they sign in everywhere else. Their own browser has
+/// all of it. +/// all of it.
+/// +///
+/// What it cannot do is hand the code back. This client id's redirect is a +/// This client id has no redirect the launcher could listen on, so the browser
+/// fixed page on login.live.com - no loopback address is registered for it, so +/// cannot hand a code back. It signs in on Microsoft's device code page
+/// there is nothing for the launcher to listen on - and the browser lands +/// instead, with the code already filled in, while [`login_device_poll`] asks
+/// there with the code in the address. The player copies that address over, +/// Microsoft whether that has happened yet.
+/// which is what [`login_browser_finish`] takes.
+#[tauri::command] +#[tauri::command]
+pub async fn login_browser_begin<R: Runtime>( +pub async fn login_device_begin<R: Runtime>(
+ app: tauri::AppHandle<R>, + app: tauri::AppHandle<R>,
+) -> Result<MinecraftLoginFlow> { +) -> Result<MinecraftDeviceCode> {
+ let flow = minecraft_auth::begin_login().await?; + let code = minecraft_auth::begin_device_login().await?;
+
+ let mut url = url::Url::parse(&code.verification_uri).map_err(|_| {
+ theseus::ErrorKind::OtherError(
+ "Error parsing the sign-in address".to_string(),
+ )
+ .as_error()
+ })?;
+ url.query_pairs_mut().append_pair("otc", &code.user_code);
+ +
+ app.opener() + app.opener()
+ .open_url(&flow.auth_request_uri, None::<String>) + .open_url(url.as_str(), None::<String>)
+ .map_err(|error| { + .map_err(|error| {
+ theseus::ErrorKind::OtherError(format!( + theseus::ErrorKind::OtherError(format!(
+ "Could not open a browser to sign in with: {error}" + "Could not open a browser to sign in with: {error}"
@@ -469,58 +491,79 @@ index dea07b2..5c3648c 100644
+ .as_error() + .as_error()
+ })?; + })?;
+ +
+ Ok(flow) + Ok(code)
+} +}
+ +
+/// Finishes a browser sign-in with the address the browser ended up at. +/// Checks on a sign-in started with [`login_device_begin`]: `None` until the
+/// +/// player has finished it in the browser, after which the launcher comes back
+/// Takes the whole address, since that is what a player can select and copy, +/// to the front.
+/// and a bare code as well, since that is what someone who knows what they are
+/// looking at will paste.
+#[tauri::command] +#[tauri::command]
+pub async fn login_browser_finish( +pub async fn login_device_poll<R: Runtime>(
+ redirect: String, + app: tauri::AppHandle<R>,
+ flow: MinecraftLoginFlow, + device_code: String,
+) -> Result<Credentials> { +) -> Result<Option<Credentials>> {
+ let redirect = redirect.trim(); + let credentials = minecraft_auth::poll_device_login(&device_code).await?;
+ +
+ let code = url::Url::parse(redirect) + if credentials.is_some()
+ .ok() + && let Some(window) = app.get_webview_window("main")
+ .and_then(|url| { + {
+ url.query_pairs() + // Best effort: a desktop may keep a window in the background from
+ .find(|(key, _)| key == "code") + // taking focus, in which case it at least asks for attention.
+ .map(|(_, code)| code.into_owned()) + let _ = window.unminimize();
+ }) + let _ = window.set_focus();
+ .or_else(|| { + let _ = window
+ // Not a URL, so treat it as the code itself - but only if it looks + .request_user_attention(Some(UserAttentionType::Informational));
+ // 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?) + Ok(credentials)
+} +}
+ +
/// Adds an offline account with the given username and makes it active. /// Adds an offline account with the given username and makes it active.
#[tauri::command] #[tauri::command]
pub async fn login_offline(username: String) -> Result<Credentials> { pub async fn login_offline(username: String) -> Result<Credentials> {
diff --git a/packages/app-lib/src/api/minecraft_auth.rs b/packages/app-lib/src/api/minecraft_auth.rs
index 2d18da3..fba473f 100644
--- a/packages/app-lib/src/api/minecraft_auth.rs
+++ b/packages/app-lib/src/api/minecraft_auth.rs
@@ -47,6 +47,36 @@ pub async fn finish_login(
Ok(credentials)
}
+/// Starts a sign-in in the player's own browser. See [`poll_device_login`].
+#[tracing::instrument]
+pub async fn begin_device_login()
+-> crate::Result<crate::state::MinecraftDeviceCode> {
+ crate::state::login_device_begin().await
+}
+
+/// Checks on a sign-in started with [`begin_device_login`]: `None` until the
+/// player has finished it in the browser.
+#[tracing::instrument(skip(device_code))]
+pub async fn poll_device_login(
+ device_code: &str,
+) -> crate::Result<Option<Credentials>> {
+ let state = State::get().await?;
+
+ let credentials =
+ crate::state::login_device_poll(device_code, &state.pool).await?;
+
+ if credentials.is_some()
+ && let Err(error) =
+ crate::onboarding_checklist::mark_logged_into_minecraft().await
+ {
+ tracing::warn!(
+ "Failed to mark Minecraft login in onboarding checklist: {error}"
+ );
+ }
+
+ Ok(credentials)
+}
+
/// Creates an offline account for `username`, or reuses the existing one, and
/// makes it the active account.
///
diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs
index 320112f..743d5e7 100644 index 320112f..ba6c342 100644
--- a/packages/app-lib/src/api/mod.rs --- a/packages/app-lib/src/api/mod.rs
+++ b/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs
@@ -28,12 +28,12 @@ pub mod data { @@ -28,12 +28,12 @@ pub mod data {
@@ -533,12 +576,194 @@ index 320112f..743d5e7 100644
- SearchResults, SearchResultsV3, Settings, SharedInstanceAttachment, - SearchResults, SearchResultsV3, Settings, SharedInstanceAttachment,
- SharedInstanceRole, TeamMember, Theme, User, UserFriend, Version, - SharedInstanceRole, TeamMember, Theme, User, UserFriend, Version,
- WindowSize, - WindowSize,
+ JavaVersion, LinkedModpackInfo, MemorySettings, MinecraftLoginFlow, + JavaVersion, LinkedModpackInfo, MemorySettings, MinecraftDeviceCode,
+ ModLoader, ModrinthCredentials, OnboardingChecklist, Organization, + MinecraftLoginFlow, ModLoader, ModrinthCredentials, OnboardingChecklist,
+ OwnerType, ProcessMetadata, Project, ProjectType, ProjectV3, + Organization, OwnerType, ProcessMetadata, Project, ProjectType,
+ SearchResult, SearchResults, SearchResultsV3, Settings, + ProjectV3, SearchResult, SearchResults, SearchResultsV3, Settings,
+ SharedInstanceAttachment, SharedInstanceRole, TeamMember, Theme, User, + SharedInstanceAttachment, SharedInstanceRole, TeamMember, Theme, User,
+ UserFriend, Version, WindowSize, + UserFriend, Version, WindowSize,
}; };
pub use ariadne::users::UserStatus; pub use ariadne::users::UserStatus;
pub use modrinth_content_management::{ pub use modrinth_content_management::{
diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs
index 4130488..1331a24 100644
--- a/packages/app-lib/src/state/minecraft_auth.rs
+++ b/packages/app-lib/src/state/minecraft_auth.rs
@@ -36,6 +36,7 @@ use uuid::Uuid;
pub enum MinecraftAuthStep {
GetDeviceToken,
SisuAuthenticate,
+ GetDeviceCode,
GetOAuthToken,
RefreshOAuthToken,
SisuAuthorize,
@@ -107,6 +108,16 @@ pub struct MinecraftLoginFlow {
pub auth_request_uri: String,
}
+/// A sign-in in the player's own browser, on Microsoft's device code page.
+#[derive(Serialize, Deserialize, Debug)]
+pub struct MinecraftDeviceCode {
+ pub user_code: String,
+ pub device_code: String,
+ pub verification_uri: String,
+ pub interval: u64,
+ pub expires_in: u64,
+}
+
#[tracing::instrument]
pub async fn login_begin(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
@@ -143,13 +154,37 @@ pub async fn login_finish(
code: &str,
flow: MinecraftLoginFlow,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
+) -> crate::Result<Credentials> {
+ let oauth_token = oauth_token(code, &flow.verifier).await?;
+ login_with_oauth_token(Some(&flow.session_id), oauth_token, exec).await
+}
+
+/// Checks on a sign-in in the player's own browser: `None` until they have
+/// finished it there.
+#[tracing::instrument(skip(device_code))]
+pub async fn login_device_poll(
+ device_code: &str,
+ exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
+) -> crate::Result<Option<Credentials>> {
+ match oauth_device_token(device_code).await? {
+ Some(oauth_token) => login_with_oauth_token(None, oauth_token, exec)
+ .await
+ .map(Some),
+ None => Ok(None),
+ }
+}
+
+/// Turns a Microsoft token into a Minecraft account and saves it.
+async fn login_with_oauth_token(
+ session_id: Option<&str>,
+ oauth_token: RequestWithDate<OAuthToken>,
+ exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<Credentials> {
let (pair, _) =
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), exec).await?;
- let oauth_token = oauth_token(code, &flow.verifier).await?;
let sisu_authorize = sisu_authorize(
- Some(&flow.session_id),
+ session_id,
&oauth_token.value.access_token,
&pair.token.token,
&pair.key,
@@ -1271,6 +1306,112 @@ async fn oauth_token(
})
}
+/// Starts a sign-in on Microsoft's device code page.
+#[tracing::instrument]
+pub async fn login_device_begin() -> crate::Result<MinecraftDeviceCode> {
+ let mut query = HashMap::new();
+ query.insert("client_id", MICROSOFT_CLIENT_ID);
+ query.insert("scope", REQUESTED_SCOPE);
+ query.insert("response_type", "device_code");
+
+ let res = auth_retry(|| {
+ INSECURE_REQWEST_CLIENT
+ .post("https://login.live.com/oauth20_connect.srf")
+ .header("Accept", "application/json")
+ .form(&query)
+ .send()
+ })
+ .await
+ .map_err(|source| MinecraftAuthenticationError::Request {
+ source,
+ step: MinecraftAuthStep::GetDeviceCode,
+ })?;
+
+ let status = res.status();
+ let text = res.text().await.map_err(|source| {
+ MinecraftAuthenticationError::Request {
+ source,
+ step: MinecraftAuthStep::GetDeviceCode,
+ }
+ })?;
+
+ let body = serde_json::from_str(&text).map_err(|source| {
+ MinecraftAuthenticationError::DeserializeResponse {
+ source,
+ raw: text,
+ step: MinecraftAuthStep::GetDeviceCode,
+ status_code: status,
+ }
+ })?;
+
+ Ok(body)
+}
+
+/// The token for a device code sign-in, or `None` while the player is still
+/// signing in.
+#[tracing::instrument(skip(device_code))]
+async fn oauth_device_token(
+ device_code: &str,
+) -> crate::Result<Option<RequestWithDate<OAuthToken>>> {
+ let mut query = HashMap::new();
+ query.insert("client_id", MICROSOFT_CLIENT_ID);
+ query.insert("device_code", device_code);
+ query.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
+
+ // Not retried: it is asked again every few seconds anyway.
+ let res = INSECURE_REQWEST_CLIENT
+ .post("https://login.live.com/oauth20_token.srf")
+ .header("Accept", "application/json")
+ .form(&query)
+ .send()
+ .await
+ .map_err(|source| MinecraftAuthenticationError::Request {
+ source,
+ step: MinecraftAuthStep::GetOAuthToken,
+ })?;
+
+ let status = res.status();
+ let current_date = get_date_header(res.headers());
+ let text = res.text().await.map_err(|source| {
+ MinecraftAuthenticationError::Request {
+ source,
+ step: MinecraftAuthStep::GetOAuthToken,
+ }
+ })?;
+
+ if !status.is_success()
+ && let Ok(response) = serde_json::from_str::<OAuthErrorResponse>(&text)
+ {
+ let message = match response.error.as_str() {
+ "authorization_pending" | "slow_down" => return Ok(None),
+ "expired_token" => {
+ "The sign-in took too long. Open the browser again to start over."
+ }
+ "authorization_declined" | "access_denied" => {
+ "The sign-in was cancelled in the browser."
+ }
+ _ => "",
+ };
+ if !message.is_empty() {
+ return Err(ErrorKind::OtherError(message.to_string()).into());
+ }
+ }
+
+ let body = serde_json::from_str(&text).map_err(|source| {
+ MinecraftAuthenticationError::DeserializeResponse {
+ source,
+ raw: text,
+ step: MinecraftAuthStep::GetOAuthToken,
+ status_code: status,
+ }
+ })?;
+
+ Ok(Some(RequestWithDate {
+ date: current_date,
+ value: body,
+ }))
+}
+
#[tracing::instrument]
async fn oauth_refresh(
refresh_token: &str,
@@ -11,10 +11,10 @@ Subject: [PATCH] Round the window corners on Linux
4 files changed, 53 insertions(+), 1 deletion(-) 4 files changed, 53 insertions(+), 1 deletion(-)
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index ac8308c..d681a14 100644 index c47484d..556ef25 100644
--- a/apps/app-frontend/src/App.vue --- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue
@@ -540,6 +540,16 @@ watch([os, isFullscreen], ([osName, fullscreen]) => { @@ -547,6 +547,16 @@ watch([os, isFullscreen], ([osName, fullscreen]) => {
document.documentElement.classList.toggle('mac-traffic-lights', osName === 'MacOS' && !fullscreen) document.documentElement.classList.toggle('mac-traffic-lights', osName === 'MacOS' && !fullscreen)
}) })
@@ -11,10 +11,10 @@ Subject: [PATCH] Scroll with the middle mouse button
create mode 100644 apps/app-frontend/src/helpers/autoscroll.ts 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 diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index d681a14..2102c4c 100644 index 556ef25..0ffddff 100644
--- a/apps/app-frontend/src/App.vue --- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue
@@ -124,6 +124,7 @@ import { @@ -126,6 +126,7 @@ import {
} from '@/helpers/ads.js' } from '@/helpers/ads.js'
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics' import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js' import { check_reachable } from '@/helpers/auth.js'
@@ -22,7 +22,7 @@ index d681a14..2102c4c 100644
import { get_user, get_user_many, get_version } from '@/helpers/cache.js' import { get_user, get_user_many, get_version } from '@/helpers/cache.js'
import { gameSettingsQueryOptions } from '@/helpers/game-options' import { gameSettingsQueryOptions } from '@/helpers/game-options'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install' import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
@@ -600,11 +601,18 @@ onMounted(async () => { @@ -607,11 +608,18 @@ onMounted(async () => {
document.querySelector('body').addEventListener('auxclick', handleAuxClick) document.querySelector('body').addEventListener('auxclick', handleAuxClick)
document.querySelector('body').addEventListener('contextmenu', handleContextMenu) document.querySelector('body').addEventListener('contextmenu', handleContextMenu)
document.addEventListener('fullscreenchange', handleFullscreenChange) document.addEventListener('fullscreenchange', handleFullscreenChange)
@@ -844,7 +844,7 @@ index 8240b8b..bcd79ec 100644
<div v-if="!currentUser" class="sticky w-full bottom-0 z-20 p-4 pt-0"> <div v-if="!currentUser" class="sticky w-full bottom-0 z-20 p-4 pt-0">
<div <div
diff --git a/apps/app/build.rs b/apps/app/build.rs diff --git a/apps/app/build.rs b/apps/app/build.rs
index 8562a68..297f383 100644 index 919c91a..1e44522 100644
--- a/apps/app/build.rs --- a/apps/app/build.rs
+++ b/apps/app/build.rs +++ b/apps/app/build.rs
@@ -27,6 +27,23 @@ fn main() { @@ -27,6 +27,23 @@ fn main() {
@@ -1416,7 +1416,7 @@ index 0000000..574183b
+ Ok(bytes.to_vec()) + Ok(bytes.to_vec())
+} +}
diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs
index 743d5e7..ade3ca8 100644 index ba6c342..a175675 100644
--- a/packages/app-lib/src/api/mod.rs --- a/packages/app-lib/src/api/mod.rs
+++ b/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs
@@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
@@ -1427,10 +1427,10 @@ index 743d5e7..ade3ca8 100644
pub mod handler; pub mod handler;
pub mod instance; pub mod instance;
diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs
index 25c6dfb..ab85457 100644 index 1331a24..2514d8e 100644
--- a/packages/app-lib/src/state/minecraft_auth.rs --- a/packages/app-lib/src/state/minecraft_auth.rs
+++ b/packages/app-lib/src/state/minecraft_auth.rs +++ b/packages/app-lib/src/state/minecraft_auth.rs
@@ -957,12 +957,14 @@ impl Serialize for Credentials { @@ -990,12 +990,14 @@ impl Serialize for Credentials {
), ),
}; };
@@ -187,12 +187,12 @@ index ae88550..9a9e8ef 100644
<h2 class="m-0 text-xl font-semibold text-contrast"> <h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.playPageTitle) }} {{ formatMessage(messages.playPageTitle) }}
diff --git a/apps/app-frontend/src/composables/use-app-settings.ts b/apps/app-frontend/src/composables/use-app-settings.ts diff --git a/apps/app-frontend/src/composables/use-app-settings.ts b/apps/app-frontend/src/composables/use-app-settings.ts
index 59da62b..5340ad2 100644 index 28c78c3..e0c0a89 100644
--- a/apps/app-frontend/src/composables/use-app-settings.ts --- a/apps/app-frontend/src/composables/use-app-settings.ts
+++ b/apps/app-frontend/src/composables/use-app-settings.ts +++ b/apps/app-frontend/src/composables/use-app-settings.ts
@@ -27,6 +27,7 @@ export const DEFAULT_FEATURE_FLAGS = { @@ -28,6 +28,7 @@ export const DEFAULT_FEATURE_FLAGS = {
show_hosting_in_sidebar: true,
news_collapsed: false, news_collapsed: false,
sidebar_fold_explained: false,
right_sidebar_collapsed: false, right_sidebar_collapsed: false,
+ universal_skins: true, + universal_skins: true,
} }
@@ -214,7 +214,7 @@ index f6e99d3..f06e812 100644
export async function showInstanceInFolder(instanceId) { export async function showInstanceInFolder(instanceId) {
const fullPath = await get_full_path(instanceId) const fullPath = await get_full_path(instanceId)
diff --git a/apps/app/build.rs b/apps/app/build.rs diff --git a/apps/app/build.rs b/apps/app/build.rs
index 297f383..02c30c0 100644 index 1e44522..f5af028 100644
--- a/apps/app/build.rs --- a/apps/app/build.rs
+++ b/apps/app/build.rs +++ b/apps/app/build.rs
@@ -399,6 +399,7 @@ fn main() { @@ -399,6 +399,7 @@ fn main() {
@@ -2493,10 +2493,10 @@ index a482f66..76bedc7 100644
if !arg.is_empty() { if !arg.is_empty() {
parsed_arguments.push(arg); parsed_arguments.push(arg);
diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs
index e623cab..e352716 100644 index 93cfd20..5378b99 100644
--- a/packages/app-lib/src/launcher/mod.rs --- a/packages/app-lib/src/launcher/mod.rs
+++ b/packages/app-lib/src/launcher/mod.rs +++ b/packages/app-lib/src/launcher/mod.rs
@@ -1018,6 +1018,28 @@ pub async fn launch_minecraft( @@ -1031,6 +1031,28 @@ pub async fn launch_minecraft(
let rpc_server = RpcServerBuilder::new().launch().await?; let rpc_server = RpcServerBuilder::new().launch().await?;
@@ -2525,7 +2525,7 @@ index e623cab..e352716 100644
command.args( command.args(
args::get_jvm_arguments( args::get_jvm_arguments(
args.get(&d::minecraft::ArgumentType::Jvm) args.get(&d::minecraft::ArgumentType::Jvm)
@@ -1044,6 +1066,7 @@ pub async fn launch_minecraft( @@ -1057,6 +1079,7 @@ pub async fn launch_minecraft(
.as_ref() .as_ref()
.and_then(|x| x.get(&LoggingSide::Client)), .and_then(|x| x.get(&LoggingSide::Client)),
rpc_server.address(), rpc_server.address(),
@@ -2580,12 +2580,12 @@ index 7418675..a25a7fd 100644
pub(crate) async fn relocate_tree(from: &Path, to: &Path) -> crate::Result<()> { pub(crate) async fn relocate_tree(from: &Path, to: &Path) -> crate::Result<()> {
if !fs::try_exists(from).await? { if !fs::try_exists(from).await? {
diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs
index 2fd0d87..673dfaa 100644 index b5f2ec6..69fc7ff 100644
--- a/packages/app-lib/src/state/settings.rs --- a/packages/app-lib/src/state/settings.rs
+++ b/packages/app-lib/src/state/settings.rs +++ b/packages/app-lib/src/state/settings.rs
@@ -91,6 +91,7 @@ pub enum FeatureFlag { @@ -92,6 +92,7 @@ pub enum FeatureFlag {
ShowHostingInSidebar,
NewsCollapsed, NewsCollapsed,
SidebarFoldExplained,
RightSidebarCollapsed, RightSidebarCollapsed,
+ UniversalSkins, + UniversalSkins,
} }
@@ -38,9 +38,9 @@ Adapted from Noctrinth (https://github.com/Everelsu/Noctrinth).
packages/app-lib/src/api/instance/run.rs | 24 +++- packages/app-lib/src/api/instance/run.rs | 24 +++-
packages/app-lib/src/api/logs.rs | 23 +++- packages/app-lib/src/api/logs.rs | 23 +++-
packages/app-lib/src/event/mod.rs | 2 + packages/app-lib/src/event/mod.rs | 2 +
packages/app-lib/src/launcher/mod.rs | 41 +++--- packages/app-lib/src/launcher/mod.rs | 46 ++++---
packages/app-lib/src/state/process.rs | 123 ++++++++++++++---- packages/app-lib/src/state/process.rs | 123 ++++++++++++++----
20 files changed, 418 insertions(+), 51 deletions(-) 20 files changed, 422 insertions(+), 52 deletions(-)
create mode 100644 apps/app-frontend/src/components/ui/ProcessConsole.vue 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 diff --git a/apps/app-frontend/src/components/ui/ProcessConsole.vue b/apps/app-frontend/src/components/ui/ProcessConsole.vue
@@ -440,7 +440,7 @@ index 638386d..3cccc8a 100644
function normalizeInstallProgress(value: unknown): WireObject { function normalizeInstallProgress(value: unknown): WireObject {
diff --git a/apps/app/build.rs b/apps/app/build.rs diff --git a/apps/app/build.rs b/apps/app/build.rs
index 02c30c0..fc78fe1 100644 index f5af028..f98395e 100644
--- a/apps/app/build.rs --- a/apps/app/build.rs
+++ b/apps/app/build.rs +++ b/apps/app/build.rs
@@ -110,7 +110,9 @@ fn main() { @@ -110,7 +110,9 @@ fn main() {
@@ -649,7 +649,7 @@ index 833349d..84b9a87 100644
#[cfg_attr(feature = "export-ts", ts(flatten))] #[cfg_attr(feature = "export-ts", ts(flatten))]
pub event: LogEvent, pub event: LogEvent,
diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs
index e352716..6e7de28 100644 index 5378b99..4199bff 100644
--- a/packages/app-lib/src/launcher/mod.rs --- a/packages/app-lib/src/launcher/mod.rs
+++ b/packages/app-lib/src/launcher/mod.rs +++ b/packages/app-lib/src/launcher/mod.rs
@@ -825,6 +825,9 @@ pub async fn launch_minecraft( @@ -825,6 +825,9 @@ pub async fn launch_minecraft(
@@ -704,8 +704,20 @@ index e352716..6e7de28 100644
+ } + }
} }
let natives_dir = state.directories.version_natives_dir(&version_jar); if let Some(path) = download::missing_runtime_file(
@@ -1212,6 +1221,8 @@ pub async fn launch_minecraft( @@ -1178,7 +1187,10 @@ pub async fn launch_minecraft(
let _store_lease = state.content_store.lease().await;
state.content_store.recover(Some(&instance.id)).await?;
// state.content_store.validate_instance(instance).await?;
- if crate::state::instance_has_running_process(&instance.id, &state).await? {
+ if !additional
+ && crate::state::instance_has_running_process(&instance.id, &state)
+ .await?
+ {
return Err(crate::ErrorKind::LauncherError(format!(
"Instance {} is already running",
instance.id
@@ -1225,6 +1237,8 @@ pub async fn launch_minecraft(
&instance.id, &instance.id,
&instance.path, &instance.path,
&instance.name, &instance.name,
@@ -880,7 +880,7 @@ index e2a59fd..4fcddea 100644
}) })
</script> </script>
diff --git a/apps/app/build.rs b/apps/app/build.rs diff --git a/apps/app/build.rs b/apps/app/build.rs
index fc78fe1..4553d48 100644 index f98395e..0fb53a5 100644
--- a/apps/app/build.rs --- a/apps/app/build.rs
+++ b/apps/app/build.rs +++ b/apps/app/build.rs
@@ -44,6 +44,17 @@ fn main() { @@ -44,6 +44,17 @@ fn main() {
@@ -2075,7 +2075,7 @@ index 0000000..9a6f7e8
+ } + }
+} +}
diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs
index ade3ca8..566810b 100644 index a175675..4daf723 100644
--- a/packages/app-lib/src/api/mod.rs --- a/packages/app-lib/src/api/mod.rs
+++ b/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs
@@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
@@ -976,7 +976,7 @@ index bcd79ec..9a10f0e 100644
</div> </div>
diff --git a/apps/app/build.rs b/apps/app/build.rs diff --git a/apps/app/build.rs b/apps/app/build.rs
index 4553d48..90e22ef 100644 index 0fb53a5..f01ba91 100644
--- a/apps/app/build.rs --- a/apps/app/build.rs
+++ b/apps/app/build.rs +++ b/apps/app/build.rs
@@ -44,6 +44,19 @@ fn main() { @@ -44,6 +44,19 @@ fn main() {
@@ -1165,7 +1165,7 @@ index 8429e4c..04eedbd 100644
.plugin(api::onboarding_checklist::init()) .plugin(api::onboarding_checklist::init())
.plugin(api::import::init()) .plugin(api::import::init())
diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs
index 566810b..a6f770d 100644 index 4daf723..e64a606 100644
--- a/packages/app-lib/src/api/mod.rs --- a/packages/app-lib/src/api/mod.rs
+++ b/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs
@@ -17,6 +17,7 @@ pub mod process; @@ -17,6 +17,7 @@ pub mod process;
@@ -0,0 +1,268 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Tue, 15 Sep 2026 15:14:28 +0200
Subject: [PATCH] Show the account in the title bar when the sidebar is folded
The Minecraft account lives in the right sidebar, so folding the sidebar
away took with it any way to see which account is selected or to switch
to another. While the sidebar is folded, the account now sits in the
title bar next to the window buttons: its head and name, opening a menu
to switch accounts, add one or remove one.
The menu stands in for the sidebar's account card rather than being a
second one. That card stays mounted while the sidebar is hidden, so the
selection, the avatar and the sign-in dialogs are the card's own and
stay in step with it, and a dialog opened from the menu outlives the
menu closing.
---
apps/app-frontend/src/App.vue | 8 +
.../src/components/ui/AccountsCard.vue | 32 ++--
.../components/ui/TitleBarAccountSwitcher.vue | 163 ++++++++++++++++++
3 files changed, 191 insertions(+), 12 deletions(-)
create mode 100644 apps/app-frontend/src/components/ui/TitleBarAccountSwitcher.vue
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index 0ffddff..5cbe7d8 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -79,6 +79,7 @@ import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from
import { RouterView, useRoute, useRouter } from 'vue-router'
import AccountsCard from '@/components/ui/AccountsCard.vue'
+import TitleBarAccountSwitcher from '@/components/ui/TitleBarAccountSwitcher.vue'
import AppActionBar from '@/components/ui/AppActionBar.vue'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import ErrorModal from '@/components/ui/ErrorModal.vue'
@@ -2509,6 +2510,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<AppActionBar />
</Suspense>
</div>
+ <!--
+ The account lives in the right sidebar. While that is folded away, it
+ is here instead, next to the window buttons, so it can still be seen
+ and switched.
+ -->
+ <!-- Flush against the window buttons, so no draggable gap is left between them. -->
+ <TitleBarAccountSwitcher v-if="!sidebarVisible" :card="accounts" />
<WindowControls />
</section>
</div>
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
index 224d776..632483e 100644
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
@@ -226,18 +226,6 @@ function showElyAccountModal(event?: MouseEvent) {
elyAccountModal.value?.show(event)
}
-defineExpose({
- refreshValues,
- showOfflineAccountModal,
- showElyAccountModal,
- setEquippedSkin,
- setLoginDisabled,
- login,
- loginDisabled,
-})
-
-await refreshValues()
-
const selectedAccount = computed(() =>
accounts.value.find((account) => account.profile.id === defaultUser.value),
)
@@ -256,6 +244,26 @@ const avatarUrl = computed(() => {
return 'https://launcher-files.modrinth.com/assets/steve_head.png'
})
+defineExpose({
+ refreshValues,
+ showOfflineAccountModal,
+ showElyAccountModal,
+ setEquippedSkin,
+ setLoginDisabled,
+ login,
+ loginDisabled,
+ // For the title bar's account switcher, which stands in for this card while
+ // the sidebar is folded away.
+ accounts,
+ selectedAccount,
+ avatarUrl,
+ getAccountAvatarUrl,
+ setAccount,
+ logout,
+})
+
+await refreshValues()
+
function getAccountAvatarUrl(account: MinecraftCredential) {
if (
account.profile.id === selectedAccount.value?.profile?.id &&
diff --git a/apps/app-frontend/src/components/ui/TitleBarAccountSwitcher.vue b/apps/app-frontend/src/components/ui/TitleBarAccountSwitcher.vue
new file mode 100644
index 0000000..70ab9e9
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/TitleBarAccountSwitcher.vue
@@ -0,0 +1,163 @@
+<!--
+ The Minecraft account, in the title bar while the right sidebar is folded away:
+ shown, switched, added and removed.
+
+ The sidebar's account card does the work: it stays mounted while the sidebar is
+ hidden, so the account shown here, switching it and the sign-in dialogs are that
+ card's own, and a dialog opened from here outlives this menu closing.
+-->
+<template>
+ <TeleportPopoutMenu
+ v-if="card"
+ type="quiet"
+ :label="formatMessage(messages.switchAccount)"
+ :tooltip="formatMessage(messages.switchAccount)"
+ placement="bottom-end"
+ >
+ <template #trigger>
+ <Avatar size="24px" :src="card.avatarUrl" />
+ <span class="max-w-40 truncate">
+ {{ card.selectedAccount?.profile.name ?? formatMessage(messages.selectAccount) }}
+ </span>
+ <DropdownIcon class="size-4 text-secondary" />
+ </template>
+ <template #panel="{ close }">
+ <div class="flex w-64 flex-col gap-1 p-2">
+ <div
+ v-for="account in card.accounts"
+ :key="account.profile.id"
+ class="flex min-w-0 items-center gap-1"
+ >
+ <button
+ class="button-base flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-lg border-0 bg-transparent p-2 text-left"
+ @click="choose(account, close)"
+ >
+ <RadioButtonCheckedIcon v-if="isSelected(account)" class="size-5 shrink-0 text-brand" />
+ <RadioButtonIcon v-else class="size-5 shrink-0 text-secondary" />
+ <Avatar :src="card.getAccountAvatarUrl(account)" size="24px" />
+ <span
+ class="min-w-0 truncate"
+ :class="isSelected(account) ? 'font-semibold text-contrast' : 'text-primary'"
+ >
+ {{ account.profile.name }}
+ </span>
+ </button>
+ <!-- The menu stays open, so what is left is on show. -->
+ <IconButton
+ v-tooltip="formatMessage(messages.removeAccount)"
+ type="quiet"
+ color="red"
+ size="sm"
+ :label="formatMessage(messages.removeAccount)"
+ @click="card.logout(account.profile.id)"
+ >
+ <TrashIcon />
+ </IconButton>
+ </div>
+ <div v-if="card.accounts.length" class="my-1 h-px bg-surface-5" />
+ <Button
+ type="quiet"
+ class="!justify-start"
+ :disabled="card.loginDisabled"
+ @click="(event: MouseEvent) => add(close, () => card?.login(event))"
+ >
+ <PlusIcon />
+ {{ formatMessage(messages.addAccount) }}
+ </Button>
+ <Button
+ type="quiet"
+ class="!justify-start"
+ @click="(event: MouseEvent) => add(close, () => card?.showElyAccountModal(event))"
+ >
+ <KeyIcon />
+ {{ formatMessage(messages.addElyAccount) }}
+ </Button>
+ <Button
+ type="quiet"
+ class="!justify-start"
+ @click="(event: MouseEvent) => add(close, () => card?.showOfflineAccountModal(event))"
+ >
+ <UserIcon />
+ {{ formatMessage(messages.addOfflineAccount) }}
+ </Button>
+ </div>
+ </template>
+ </TeleportPopoutMenu>
+</template>
+
+<script setup lang="ts">
+import {
+ DropdownIcon,
+ KeyIcon,
+ PlusIcon,
+ RadioButtonCheckedIcon,
+ RadioButtonIcon,
+ TrashIcon,
+ UserIcon,
+} from '@modrinth/assets'
+import {
+ Avatar,
+ Button,
+ defineMessages,
+ IconButton,
+ TeleportPopoutMenu,
+ useVIntl,
+} from '@modrinth/ui'
+import type { PropType } from 'vue'
+
+import type AccountsCard from '@/components/ui/AccountsCard.vue'
+
+type Card = InstanceType<typeof AccountsCard>
+type Account = Card['accounts'][number]
+
+const props = defineProps({
+ card: {
+ type: Object as PropType<Card | null>,
+ default: null,
+ },
+})
+
+const { formatMessage } = useVIntl()
+
+const messages = defineMessages({
+ switchAccount: {
+ id: 'minecraft-account.switch',
+ defaultMessage: 'Switch account',
+ },
+ selectAccount: {
+ id: 'minecraft-account.select-account',
+ defaultMessage: 'Select account',
+ },
+ addAccount: {
+ id: 'minecraft-account.add-microsoft-account',
+ defaultMessage: 'Add Microsoft account',
+ },
+ addElyAccount: {
+ id: 'minecraft-account.add-ely-account',
+ defaultMessage: 'Add Ely.by account',
+ },
+ addOfflineAccount: {
+ id: 'minecraft-account.add-offline-account',
+ defaultMessage: 'Add offline account',
+ },
+ removeAccount: {
+ id: 'minecraft-account.remove-account',
+ defaultMessage: 'Remove account',
+ },
+})
+
+function isSelected(account: Account) {
+ return props.card?.selectedAccount?.profile.id === account.profile.id
+}
+
+async function choose(account: Account, close: () => void) {
+ close()
+ if (!isSelected(account)) await props.card?.setAccount(account)
+}
+
+/** Closes the menu first: the dialog belongs to the sidebar's card, not to it. */
+function add(close: () => void, open: () => void) {
+ close()
+ open()
+}
+</script>
@@ -0,0 +1,41 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Tue, 15 Sep 2026 21:11:40 +0200
Subject: [PATCH] Start on Wayland with an NVIDIA GPU
With the NVIDIA driver under Wayland, WebKitGTK's DMA-BUF renderer takes
the window down at start with "Error 71 (Protocol error) dispatching to
Wayland display", so the .deb and .rpm did not open at all there. The
AppImage forces X11 and never hit this. WEBKIT_DMABUF_RENDERER_FORCE_SHM
is now set at startup when the NVIDIA DRM module is loaded, which hands
frames over through shared memory and still renders on the GPU. A player
who set it, or WEBKIT_DISABLE_DMABUF_RENDERER, keeps their value.
---
apps/app/src/main.rs | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs
index 4c269a3..5cdab91 100644
--- a/apps/app/src/main.rs
+++ b/apps/app/src/main.rs
@@ -125,6 +125,20 @@ fn main() {
unsafe { std::env::set_var("GTK_USE_PORTAL", "1") };
}
+ // With the NVIDIA driver under Wayland, WebKitGTK's DMA-BUF renderer
+ // takes the window down at start with "Error 71 (Protocol error)
+ // dispatching to Wayland display". Handing frames over through shared
+ // memory avoids it and still renders on the GPU. The AppImage forces X11
+ // and never hit this.
+ #[cfg(target_os = "linux")]
+ if std::path::Path::new("/sys/module/nvidia_drm").exists()
+ && std::env::var_os("WEBKIT_DMABUF_RENDERER_FORCE_SHM").is_none()
+ && std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none()
+ {
+ // SAFETY: nothing else is running yet.
+ unsafe { std::env::set_var("WEBKIT_DMABUF_RENDERER_FORCE_SHM", "1") };
+ }
+
#[cfg(feature = "export-app-events")]
theseus::export_app_event_bindings(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
@@ -0,0 +1,176 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Tue, 15 Sep 2026 18:43:17 +0200
Subject: [PATCH] Update from Modrinth Enhanced's own releases
The updater asked Modrinth, whose update is the official app and would
replace this one. It now takes this project's own signed releases, with
the endpoint and key given at build time by scripts/build.sh.
Revisions of one upstream version share the app's version, since the
installers refuse a suffix in it, so a release carries its revision as
build metadata and the comparison looks at that. On Linux only the
AppImage updates itself; other installs, and builds without the updater,
get a notice with a button to the release on GitHub.
---
apps/app-frontend/src/App.vue | 63 ++++++++++++++++++++++++-----------
apps/app/src/main.rs | 32 ++++++++++++++++++
apps/app/tauri.conf.json | 2 +-
3 files changed, 76 insertions(+), 21 deletions(-)
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index 5cbe7d8..dfdcdb1 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -1888,9 +1888,13 @@ const updatePopupMessages = defineMessages({
defaultMessage: `Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App.`,
},
linuxBody: {
- id: 'app.update-popup.body.linux',
+ id: 'app.update-popup.body.linux-release',
defaultMessage:
- 'Modrinth App v{version} is available. Use your package manager to update for the latest features and fixes!',
+ 'Modrinth Enhanced v{version} is available. Update it with your package manager, or download it from the release on GitHub.',
+ },
+ openRelease: {
+ id: 'app.update-popup.open-release',
+ defaultMessage: 'Open release',
},
reload: {
id: 'app.update-popup.reload',
@@ -2065,28 +2069,47 @@ async function checkUpdates() {
)
}
+// A .deb or .rpm install, or a build without the updater, is only told about
+// a new release: this project's own on GitHub, not Modrinth's, whose update
+// would be the official app.
async function checkLinuxUpdates() {
try {
- const [response, currentVersion] = await Promise.all([
- fetch('https://launcher-files.modrinth.com/updates.json'),
+ const [repository, currentVersion, currentRevision] = await Promise.all([
+ invoke('release_repository'),
getVersion(),
+ invoke('app_revision'),
])
- const updates = await response.json()
- const latestVersion = updates?.version
-
- if (latestVersion && latestVersion !== currentVersion) {
- markAppUpdateActionable(latestVersion)
- const nextPopupTime = getNextAppUpdatePopupTime(latestVersion)
- if (nextPopupTime !== null && Date.now() >= nextPopupTime) {
- addPopupNotification({
- contentType: 'standard',
- title: formatMessage(updatePopupMessages.updateAvailable),
- text: formatMessage(updatePopupMessages.linuxBody, { version: latestVersion }),
- type: 'info',
- autoCloseMs: null,
- })
- markAppUpdatePopupShown(latestVersion)
- }
+ const response = await fetch(`https://api.github.com/repos/${repository}/releases/latest`)
+ if (!response.ok) return
+ const release = await response.json()
+
+ // v1.2.3 is the first release of an upstream version, v1.2.3-2 the next.
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-(\d+))?$/.exec(release?.tag_name ?? '')
+ if (!match) return
+ const latest = [...match.slice(1, 4).map(Number), Number(match[4] ?? 1)]
+ const current = [...currentVersion.split('.').map(Number), currentRevision]
+ const differs = latest.findIndex((part, i) => part !== current[i])
+ if (differs === -1 || latest[differs] < current[differs]) return
+
+ const latestVersion = release.tag_name.replace(/^v/, '')
+ markAppUpdateActionable(latestVersion)
+ const nextPopupTime = getNextAppUpdatePopupTime(latestVersion)
+ if (nextPopupTime !== null && Date.now() >= nextPopupTime) {
+ addPopupNotification({
+ contentType: 'standard',
+ title: formatMessage(updatePopupMessages.updateAvailable),
+ text: formatMessage(updatePopupMessages.linuxBody, { version: latestVersion }),
+ type: 'info',
+ autoCloseMs: null,
+ buttons: [
+ {
+ label: formatMessage(updatePopupMessages.openRelease),
+ action: () => openUrl(release.html_url),
+ color: 'brand',
+ },
+ ],
+ })
+ markAppUpdatePopupShown(latestVersion)
}
} catch (e) {
console.error('Failed to check for updates:', e)
diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs
index 5cdab91..1deb12f 100644
--- a/apps/app/src/main.rs
+++ b/apps/app/src/main.rs
@@ -77,6 +77,25 @@ fn is_dev() -> bool {
fn are_updates_enabled() -> bool {
cfg!(feature = "updater")
&& env::var("MODRINTH_EXTERNAL_UPDATE_PROVIDER").is_err()
+ // On Linux the updater replaces the AppImage it runs from. A .deb or
+ // .rpm install gets the notice pointing at the release instead.
+ && (!cfg!(target_os = "linux") || env::var_os("APPIMAGE").is_some())
+}
+
+/// Which release of the upstream version this build is: 2 for v1.2.3-2. The
+/// version itself stays the upstream one, since installers refuse a suffix.
+#[tauri::command]
+fn app_revision() -> u64 {
+ option_env!("MODRINTH_ENHANCED_REVISION")
+ .and_then(|revision| revision.parse().ok())
+ .unwrap_or(1)
+}
+
+/// The repository this build takes its updates from.
+#[tauri::command]
+fn release_repository() -> &'static str {
+ option_env!("MODRINTH_ENHANCED_REPOSITORY")
+ .unwrap_or("Felitendo/Modrinth-Enhanced")
}
#[cfg(feature = "updater")]
@@ -187,6 +206,17 @@ fn main() {
HeaderValue::from_str(&launcher_user_agent()).unwrap(),
)
.unwrap()
+ // A release carries its revision as build metadata, 1.2.3+2,
+ // which version ordering ignores.
+ .default_version_comparator(|current, release| {
+ let remote = release.version;
+ let ours = (current.major, current.minor, current.patch);
+ let theirs = (remote.major, remote.minor, remote.patch);
+ if theirs != ours {
+ return theirs > ours;
+ }
+ remote.build.as_str().parse().unwrap_or(1) > app_revision()
+ })
.build(),
);
}
@@ -303,6 +333,8 @@ fn main() {
initialize_state,
is_dev,
are_updates_enabled,
+ app_revision,
+ release_repository,
get_update_size,
enqueue_update_for_installation,
remove_enqueued_update,
diff --git a/apps/app/tauri.conf.json b/apps/app/tauri.conf.json
index e5bbabe..bd2434d 100644
--- a/apps/app/tauri.conf.json
+++ b/apps/app/tauri.conf.json
@@ -102,7 +102,7 @@
"capabilities": ["ads", "core", "plugins"],
"csp": {
"default-src": "'self' customprotocol: asset:",
- "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https://www.intercom-reporting.com wss://*.nodes.modrinth.com https://*.taila228c5.ts.net https://*.taila228c5.ts.net wss://*.taila228c5.ts.net https://fill.papermc.io https://api.purpurmc.org 'self' data: blob:",
+ "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https://www.intercom-reporting.com wss://*.nodes.modrinth.com https://*.taila228c5.ts.net https://*.taila228c5.ts.net wss://*.taila228c5.ts.net https://fill.papermc.io https://api.purpurmc.org https://api.github.com 'self' data: blob:",
"font-src": ["https://cdn.modrinth.com/fonts/", "https://js.intercomcdn.com"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'",
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,27 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Thu, 17 Sep 2026 14:23:15 +0200
Subject: [PATCH] Keep the settings tabs clear of the app version
The settings tab list is sized to its column with a percentage height,
which WebKitGTK leaves unresolved inside a flex item without a set
height. The list then ran on over the app version at the bottom of the
column instead of scrolling. It fills its column absolutely now, the way
the content beside it already does.
---
packages/ui/src/components/modal/TabbedModal.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/ui/src/components/modal/TabbedModal.vue b/packages/ui/src/components/modal/TabbedModal.vue
index 4715916..1b1d69a 100644
--- a/packages/ui/src/components/modal/TabbedModal.vue
+++ b/packages/ui/src/components/modal/TabbedModal.vue
@@ -158,7 +158,7 @@ defineExpose({ show, hide, selectedTab, setTab })
<div
ref="sidebarScrollContainer"
- class="flex h-full flex-col gap-1 overflow-y-auto"
+ class="absolute inset-0 flex flex-col gap-1 overflow-y-auto"
@scroll="checkSidebarScrollState"
>
<template v-for="(tab, index) in visibleTabs" :key="index">
@@ -0,0 +1,572 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Thu, 17 Sep 2026 15:09:57 +0200
Subject: [PATCH] Show and change skins of custom server accounts
A custom server account got the skin page of an offline one: Steve, and
nothing it could change. Its profile now comes from the server's session
server, as the game gets it, so the page shows the skin and cape it
wears there, and the account list and title bar show its head instead of
asking mc-heads.net, which only knows Mojang's players.
Picking or adding a skin uploads it through authlib-injector's texture
API, which Drasl, Blessing Skin and LittleSkin all have, and resetting
it removes it there. The skin library works as for a Microsoft account.
Capes are left alone. Such a server only knows the one cape uploaded on
its website, and taking it off would delete it, so the cape picker gives
way to a note. The textures are handed to the page as data URLs, since
these servers send no CORS headers for them.
Ely.by accounts get their heads the same way; their skin page stays the
Ely.by one.
---
.../src/components/ui/AccountsCard.vue | 31 ++-
.../src/components/ui/skin/EditSkinModal.vue | 15 +-
apps/app-frontend/src/pages/Skins.vue | 1 +
packages/app-lib/src/api/minecraft_skins.rs | 4 +-
packages/app-lib/src/state/minecraft_auth.rs | 242 +++++++++++++++++-
.../src/state/minecraft_skins/mojang_api.rs | 64 +++--
6 files changed, 326 insertions(+), 31 deletions(-)
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
index 03a61f2..364aef0 100644
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
@@ -185,11 +185,14 @@ type MinecraftCredential = {
profile: {
id: string
name: string
+ skins?: ProfileSkin[]
}
/** The Yggdrasil server a non-Microsoft account is on, such as Ely.by. */
auth_server?: string | null
}
+type ProfileSkin = { state: string; url: string; textureKey?: string }
+
const accounts: Ref<MinecraftCredential[]> = ref([])
const offlineAccountModal = ref<InstanceType<typeof OfflineAccountModal>>()
const elyAccountModal = ref<InstanceType<typeof ElyAccountModal>>()
@@ -200,6 +203,8 @@ const defaultUser = ref<string | undefined>()
const equippedSkin = ref<Skin | null>(null)
const equippedHeadUrl = ref<string>()
let headRequest = 0
+// Heads of accounts on other servers, which mc-heads.net does not know.
+const serverHeads = ref<Record<string, string>>({})
async function updateHeadUrl(skin: Skin | null) {
const request = ++headRequest
@@ -214,13 +219,30 @@ async function updateHeadUrl(skin: Skin | null) {
onUnmounted(() => {
headRequest++
if (equippedHeadUrl.value) URL.revokeObjectURL(equippedHeadUrl.value)
+ Object.values(serverHeads.value).forEach((url) => URL.revokeObjectURL(url))
})
+async function updateServerHeads() {
+ const heads: Record<string, string> = {}
+ for (const account of accounts.value) {
+ const skin = account.profile.skins?.find((s) => s.state === 'ACTIVE')
+ if (!account.auth_server || !skin?.textureKey) continue
+ heads[account.profile.id] = await getPlayerHeadUrl({
+ texture_key: skin.textureKey,
+ texture: skin.url,
+ } as Skin).catch(() => '')
+ }
+ const previous = serverHeads.value
+ serverHeads.value = heads
+ Object.values(previous).forEach((url) => URL.revokeObjectURL(url))
+}
+
async function refreshValues() {
defaultUser.value = await get_default_user().catch(handleError)
const userList = await users().catch(handleError)
accounts.value = Array.isArray(userList) ? [...userList] : []
accounts.value.sort((a, b) => (a.profile?.name ?? '').localeCompare(b.profile?.name ?? ''))
+ void updateServerHeads()
try {
const skins = await get_available_skins()
@@ -272,7 +294,10 @@ const avatarUrl = computed(() => {
return `https://mc-heads.net/avatar/${equippedSkin.value.texture_key}/128`
}
if (selectedAccount.value?.profile?.id) {
- return `https://mc-heads.net/avatar/${selectedAccount.value.profile.id}/128`
+ return (
+ serverHeads.value[selectedAccount.value.profile.id] ||
+ `https://mc-heads.net/avatar/${selectedAccount.value.profile.id}/128`
+ )
}
return 'https://launcher-files.modrinth.com/assets/steve_head.png'
})
@@ -308,7 +333,9 @@ function getAccountAvatarUrl(account: MinecraftCredential) {
return cachedUrl
}
}
- return `https://mc-heads.net/avatar/${account.profile.id}/128`
+ return (
+ serverHeads.value[account.profile.id] || `https://mc-heads.net/avatar/${account.profile.id}/128`
+ )
}
async function setAccount(account: MinecraftCredential) {
diff --git a/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue b/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue
index ee32e2e..7d9b78d 100644
--- a/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue
+++ b/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue
@@ -47,7 +47,11 @@
</RadioButtons>
</section>
- <section>
+ <section v-if="capesLocked">
+ <h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.capeSection) }}</h2>
+ <p class="m-0 text-sm text-secondary">{{ formatMessage(messages.capesOnServer) }}</p>
+ </section>
+ <section v-else>
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.capeSection) }}</h2>
<div class="relative w-fit max-w-full">
<Transition
@@ -194,6 +198,10 @@ const messages = defineMessages({
id: 'app.skins.modal.cape-section',
defaultMessage: 'Cape',
},
+ capesOnServer: {
+ id: 'app.skins.modal.capes-on-server',
+ defaultMessage: "Capes are changed on your account server's website.",
+ },
noCapeTooltip: {
id: 'app.skins.modal.no-cape-tooltip',
defaultMessage: 'No cape',
@@ -248,7 +256,8 @@ const previewSkin = ref<string>('')
const variant = ref<SkinModel>('CLASSIC')
const selectedCape = ref<Cape | undefined>(undefined)
-const props = defineProps<{ capes?: Cape[]; demo?: boolean }>()
+// An account on another server keeps the cape uploaded there.
+const props = defineProps<{ capes?: Cape[]; demo?: boolean; capesLocked?: boolean }>()
const selectedCapeTexture = computed(() => selectedCape.value?.texture)
const canEditTextureAndModel = computed(() => currentSkin.value?.source !== 'default')
@@ -379,7 +388,7 @@ async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
currentSkin.value = null
uploadedTextureUrl.value = skinTextureUrl
variant.value = await determineModelType(skinTextureUrl.original)
- selectedCape.value = undefined
+ selectedCape.value = props.capesLocked ? props.capes?.find((c) => c.is_equipped) : undefined
await loadPreviewSkin()
diff --git a/apps/app-frontend/src/pages/Skins.vue b/apps/app-frontend/src/pages/Skins.vue
index 9a10f0e..3b58ea9 100644
--- a/apps/app-frontend/src/pages/Skins.vue
+++ b/apps/app-frontend/src/pages/Skins.vue
@@ -1448,6 +1448,7 @@ await loadAccountSkins()
<EditSkinModal
ref="editSkinModal"
:capes="capes"
+ :capes-locked="!!currentUser?.auth_server"
:demo="!currentUser"
@saved="onSkinSaved"
@deleted="() => loadSkins()"
diff --git a/packages/app-lib/src/api/minecraft_skins.rs b/packages/app-lib/src/api/minecraft_skins.rs
index e5431e3..aae52e3 100644
--- a/packages/app-lib/src/api/minecraft_skins.rs
+++ b/packages/app-lib/src/api/minecraft_skins.rs
@@ -81,7 +81,7 @@ mod assets {
pub use default::DEFAULT_SKINS;
}
-mod png_util;
+pub(crate) mod png_util;
const SKIN_CHANGE_DEBOUNCE: Duration = Duration::from_secs(10);
@@ -1345,7 +1345,7 @@ fn is_bundled_skin(texture_key: &str, variant: MinecraftSkinVariant) -> bool {
})
}
-fn get_fallback_default_skin() -> crate::Result<&'static Skin> {
+pub(crate) fn get_fallback_default_skin() -> crate::Result<&'static Skin> {
assets::DEFAULT_SKINS
.iter()
.find(|skin| {
diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs
index 0245152..b916a6d 100644
--- a/packages/app-lib/src/state/minecraft_auth.rs
+++ b/packages/app-lib/src/state/minecraft_auth.rs
@@ -290,6 +290,10 @@ const AUTHLIB_REFRESH_TOKEN_PREFIX: &str = "modrinth-enhanced:authlib:";
/// Ely.by's Yggdrasil endpoints.
const ELY_AUTHSERVER: &str = "https://authserver.ely.by/auth";
+/// Where Ely.by answers the game's questions about players.
+const ELY_SESSIONSERVER: &str =
+ "https://authserver.ely.by/api/authlib-injector/sessionserver";
+
/// An account server that speaks Yggdrasil, the protocol Mojang's own used to,
/// and that authlib-injector can point the game at.
#[derive(Debug, Clone)]
@@ -334,6 +338,195 @@ impl AuthServer {
INSECURE_REQWEST_CLIENT.post(url).json(&body).send().await
}
+ /// The player's profile as this server hands it to the game, with the skin
+ /// and cape it has.
+ ///
+ /// The textures come along as data URLs: the skin page draws capes straight
+ /// from their URL, and these servers neither send CORS headers nor always
+ /// use https.
+ async fn session_profile(
+ &self,
+ id: Uuid,
+ ) -> Result<MinecraftProfile, MinecraftAuthenticationError> {
+ #[derive(Deserialize)]
+ struct SessionProfile {
+ name: String,
+ #[serde(default)]
+ properties: Vec<Property>,
+ }
+
+ #[derive(Deserialize)]
+ struct Property {
+ name: String,
+ value: String,
+ }
+
+ #[derive(Deserialize, Default)]
+ struct TexturesProperty {
+ #[serde(default)]
+ textures: Textures,
+ }
+
+ #[derive(Deserialize, Default)]
+ struct Textures {
+ #[serde(rename = "SKIN")]
+ skin: Option<Texture>,
+ #[serde(rename = "CAPE")]
+ cape: Option<Texture>,
+ }
+
+ #[derive(Deserialize)]
+ struct Texture {
+ url: Url,
+ #[serde(default)]
+ metadata: Option<TextureMetadata>,
+ }
+
+ #[derive(Deserialize)]
+ struct TextureMetadata {
+ model: Option<String>,
+ }
+
+ let step = MinecraftAuthStep::MinecraftProfile;
+ let sessionserver = match self {
+ Self::Ely => ELY_SESSIONSERVER.to_owned(),
+ Self::Authlib(root) => format!("{root}/sessionserver"),
+ };
+
+ let response = INSECURE_REQWEST_CLIENT
+ .get(format!(
+ "{sessionserver}/session/minecraft/profile/{}",
+ id.simple()
+ ))
+ .query(&[("unsigned", "true")])
+ .timeout(std::time::Duration::from_secs(10))
+ .send()
+ .await
+ .map_err(|source| MinecraftAuthenticationError::Request {
+ source,
+ step,
+ })?;
+
+ let status = response.status();
+ let text = response.text().await.map_err(|source| {
+ MinecraftAuthenticationError::Request { source, step }
+ })?;
+ let profile = serde_json::from_str::<SessionProfile>(&text).map_err(
+ |source| MinecraftAuthenticationError::DeserializeResponse {
+ source,
+ raw: text,
+ step,
+ status_code: status,
+ },
+ )?;
+
+ let textures = profile
+ .properties
+ .iter()
+ .find(|property| property.name == "textures")
+ .and_then(|property| BASE64_STANDARD.decode(&property.value).ok())
+ .and_then(|json| {
+ serde_json::from_slice::<TexturesProperty>(&json).ok()
+ })
+ .unwrap_or_default()
+ .textures;
+
+ let skin = match textures.skin {
+ Some(texture) => Some(MinecraftSkin {
+ id: texture_id(&texture.url),
+ state: MinecraftCharacterExpressionState::Active,
+ texture_key: texture
+ .url
+ .path_segments()
+ .and_then(|mut segments| segments.next_back())
+ .map(|name| Arc::from(name.trim_end_matches(".png"))),
+ variant: match texture
+ .metadata
+ .and_then(|metadata| metadata.model)
+ {
+ Some(model) if model == "slim" => {
+ MinecraftSkinVariant::Slim
+ }
+ _ => MinecraftSkinVariant::Classic,
+ },
+ url: texture_data_url(texture.url).await,
+ name: None,
+ }),
+ // A player without a skin of their own wears a default one.
+ None => crate::api::minecraft_skins::get_fallback_default_skin()
+ .ok()
+ .map(|default| MinecraftSkin {
+ id: Uuid::nil(),
+ state: MinecraftCharacterExpressionState::Active,
+ url: Arc::clone(&default.texture),
+ texture_key: Some(Arc::clone(&default.texture_key)),
+ variant: default.variant,
+ name: default.name.as_deref().map(str::to_owned),
+ }),
+ };
+
+ let capes = match textures.cape {
+ Some(texture) => vec![MinecraftCape {
+ id: texture_id(&texture.url),
+ state: MinecraftCharacterExpressionState::Active,
+ url: texture_data_url(texture.url).await,
+ name: Arc::from("Cape"),
+ }],
+ None => Vec::new(),
+ };
+
+ Ok(MinecraftProfile {
+ id,
+ name: profile.name,
+ skins: skin.into_iter().collect(),
+ capes,
+ fetch_time: Some(Instant::now()),
+ })
+ }
+
+ /// Uploads a skin or cape to this server, or with no form removes it, per
+ /// authlib-injector's texture API. Ely.by has no such API.
+ pub(crate) async fn change_texture(
+ &self,
+ credentials: &Credentials,
+ kind: &str,
+ form: Option<reqwest::multipart::Form>,
+ ) -> crate::Result<()> {
+ let Self::Authlib(root) = self else {
+ return Err(crate::ErrorKind::OtherError(format!(
+ "Skins on {} cannot be changed from here",
+ self.name()
+ ))
+ .into());
+ };
+
+ let url = format!(
+ "{root}/api/user/profile/{}/{kind}",
+ credentials.offline_profile.id.simple()
+ );
+ let request = match form {
+ Some(form) => INSECURE_REQWEST_CLIENT.put(url).multipart(form),
+ None => INSECURE_REQWEST_CLIENT.delete(url),
+ };
+
+ let response = request
+ .bearer_auth(&credentials.access_token)
+ .send()
+ .await
+ .map_err(|error| {
+ crate::ErrorKind::OtherError(format!(
+ "Could not reach {}: {error}",
+ self.name()
+ ))
+ })?;
+
+ if !response.status().is_success() {
+ return Err(self.error(response).await);
+ }
+
+ Ok(())
+ }
+
fn refresh_token(&self, client_token: &str) -> String {
match self {
Self::Ely => format!("{ELY_REFRESH_TOKEN_PREFIX}{client_token}"),
@@ -811,10 +1004,10 @@ impl Credentials {
&self,
cache_intent: OnlineProfileCacheIntent,
) -> Option<Arc<MinecraftProfile>> {
- // Neither offline nor Yggdrasil 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.yggdrasil().is_some() {
+ // Offline accounts have a profile nowhere, 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() {
return None;
}
@@ -864,7 +1057,15 @@ impl Credentials {
stale_profile
};
- match minecraft_profile(&self.access_token).await {
+ // A Yggdrasil account's skin and cape are on its own server.
+ let fetched = match self.auth_server() {
+ Some(server) => {
+ server.session_profile(self.offline_profile.id).await
+ }
+ None => minecraft_profile(&self.access_token).await,
+ };
+
+ match fetched {
Ok(profile) => {
let profile = Arc::new(profile);
let cache_entry = ProfileCacheEntry::Hit(Arc::clone(&profile));
@@ -1959,6 +2160,37 @@ impl Deref for MaybeOnlineMinecraftProfile<'_> {
}
}
+/// A stable id for a texture from another account server, which has none.
+fn texture_id(url: &Url) -> Uuid {
+ Uuid::from_bytes(md5::compute(url.as_str()).0)
+}
+
+/// A texture from another account server as a data URL, or as it was if it
+/// cannot be fetched.
+async fn texture_data_url(url: Url) -> Arc<Url> {
+ let texture = async {
+ INSECURE_REQWEST_CLIENT
+ .get(url.clone())
+ .timeout(std::time::Duration::from_secs(10))
+ .send()
+ .await?
+ .error_for_status()?
+ .bytes()
+ .await
+ };
+
+ match texture.await {
+ Ok(bytes) => {
+ crate::api::minecraft_skins::png_util::blob_to_data_url(bytes)
+ .unwrap_or_else(|| Arc::new(url))
+ }
+ Err(error) => {
+ tracing::warn!("Could not fetch the texture at {url}: {error}");
+ Arc::new(url)
+ }
+ }
+}
+
#[tracing::instrument(skip(token))]
async fn minecraft_profile(
token: &str,
diff --git a/packages/app-lib/src/state/minecraft_skins/mojang_api.rs b/packages/app-lib/src/state/minecraft_skins/mojang_api.rs
index 9e89537..d037c47 100644
--- a/packages/app-lib/src/state/minecraft_skins/mojang_api.rs
+++ b/packages/app-lib/src/state/minecraft_skins/mojang_api.rs
@@ -25,6 +25,12 @@ impl MinecraftCapeOperation {
credentials: &Credentials,
cape_id: Uuid,
) -> crate::Result<()> {
+ // Another account server only has the one cape uploaded there, and it
+ // is always worn.
+ if credentials.auth_server().is_some() {
+ return Ok(());
+ }
+
update_profile_cache_from_response(
INSECURE_REQWEST_CLIENT
.put("https://api.minecraftservices.com/minecraft/profile/capes/active")
@@ -45,6 +51,11 @@ impl MinecraftCapeOperation {
}
pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> {
+ // Taking it off there deletes it, which is left to the server's website.
+ if credentials.auth_server().is_some() {
+ return Ok(());
+ }
+
update_profile_cache_from_response(
INSECURE_REQWEST_CLIENT
.delete("https://api.minecraftservices.com/minecraft/profile/capes/active")
@@ -75,26 +86,37 @@ impl MinecraftSkinOperation {
TextureStream::Error: Into<Box<dyn Error + Send + Sync>>,
Bytes: From<TextureStream::Ok>,
{
+ let variant = match variant {
+ MinecraftSkinVariant::Slim => "slim",
+ MinecraftSkinVariant::Classic => "classic",
+ _ => {
+ return Err(ErrorKind::OtherError(
+ "Cannot equip skin of unknown model variant".into(),
+ )
+ .into());
+ }
+ };
+ let file = Part::stream(Body::wrap_stream(texture))
+ .mime_str("image/png")?
+ .file_name("skin.png");
+
+ // Another account server takes the skin through authlib-injector's
+ // texture API, where the wide model is no model at all. The profile is
+ // read again afterwards.
+ if let Some(server) = credentials.auth_server() {
+ let model = if variant == "slim" { "slim" } else { "" };
+ let form = reqwest::multipart::Form::new()
+ .text("model", model)
+ .part("file", file);
+ server
+ .change_texture(credentials, "skin", Some(form))
+ .await?;
+ return Ok(None);
+ }
+
let form = reqwest::multipart::Form::new()
- .text(
- "variant",
- match variant {
- MinecraftSkinVariant::Slim => "slim",
- MinecraftSkinVariant::Classic => "classic",
- _ => {
- return Err(ErrorKind::OtherError(
- "Cannot equip skin of unknown model variant".into(),
- )
- .into());
- }
- },
- )
- .part(
- "file",
- Part::stream(Body::wrap_stream(texture))
- .mime_str("image/png")?
- .file_name("skin.png"),
- );
+ .text("variant", variant)
+ .part("file", file);
let profile = update_profile_cache_from_response(
INSECURE_REQWEST_CLIENT
@@ -115,6 +137,10 @@ impl MinecraftSkinOperation {
}
pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> {
+ if let Some(server) = credentials.auth_server() {
+ return server.change_texture(credentials, "skin", None).await;
+ }
+
update_profile_cache_from_response(
INSECURE_REQWEST_CLIENT
.delete("https://api.minecraftservices.com/minecraft/profile/skins/active")
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,617 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Thu, 17 Sep 2026 15:52:25 +0200
Subject: [PATCH] Browse LittleSkin's skin library
LittleSkin is an account server, and also one of the largest skin
libraries around. The Browse tab gets it next to Ely.by: its own search
by name, sorting by likes or date and a filter for wide or slim arms,
with the likes on every tile and more pages as the list is scrolled.
Picking a skin previews it, and adding it works for any account.
The list is the JSON LittleSkin's library page loads for itself, and its
robots.txt excludes none of it. Textures come through Rust, as Ely.by's
do: the site sends no CORS headers.
---
.../src/components/ui/skin/SkinBrowser.vue | 165 +++++++++++++++---
apps/app-frontend/src/helpers/skin-browser.ts | 42 ++++-
apps/app/build.rs | 2 +
apps/app/src/api/skin_browser.rs | 20 ++-
packages/app-lib/src/api/skin_browser.rs | 118 ++++++++++++-
5 files changed, 318 insertions(+), 29 deletions(-)
diff --git a/apps/app-frontend/src/components/ui/skin/SkinBrowser.vue b/apps/app-frontend/src/components/ui/skin/SkinBrowser.vue
index 3d92c68..26646a4 100644
--- a/apps/app-frontend/src/components/ui/skin/SkinBrowser.vue
+++ b/apps/app-frontend/src/components/ui/skin/SkinBrowser.vue
@@ -1,7 +1,7 @@
<!--
Finding skins elsewhere, next to the account's own.
- Ely.by's catalogue is browsed right here. NameMC, laby.net and crafty.gg are
+ Ely.by's catalogue and LittleSkin's library are browsed right here. NameMC, laby.net and crafty.gg are
not: their lists are bot-protected or not meant for other programs, so they
open in a window to browse as usual, and the skin page open there is what
gets previewed. Picking a skin only previews it; the skin page adds it.
@@ -10,10 +10,57 @@
<div class="flex flex-col gap-4">
<Chips v-model="source" :items="SOURCES" :format-label="sourceLabel" size="small" />
- <template v-if="source === 'ely'">
- <Chips v-model="elyMode" :items="ELY_MODES" :format-label="elyModeLabel" size="small" />
+ <template v-if="source === 'ely' || source === 'littleskin'">
+ <div v-if="source === 'littleskin'" class="flex flex-wrap items-center gap-2">
+ <Combobox
+ v-model="littleSkinSort"
+ class="w-max"
+ :options="littleSkinSortOptions"
+ :show-icon-in-selected="false"
+ dropdown-min-width="160px"
+ >
+ <template #prefix>
+ <ArrowUpDownIcon class="size-5 text-primary" />
+ </template>
+ <template #selected="{ label }">
+ <span>{{ label }}</span>
+ </template>
+ </Combobox>
+ <Combobox
+ v-model="littleSkinFilter"
+ class="w-max"
+ :options="littleSkinFilterOptions"
+ :show-icon-in-selected="false"
+ dropdown-min-width="160px"
+ >
+ <template #prefix>
+ <UserIcon class="size-5 text-primary" />
+ </template>
+ <template #selected="{ label }">
+ <span>{{ label }}</span>
+ </template>
+ </Combobox>
+ <Input
+ v-model="littleSkinKeyword"
+ type="search"
+ :icon="SearchIcon"
+ :placeholder="formatMessage(messages.namePlaceholder)"
+ wrapper-class="w-60"
+ />
+ </div>
- <div v-if="elyMode === 'catalogue'" class="flex flex-wrap items-center gap-2">
+ <Chips
+ v-if="source === 'ely'"
+ v-model="elyMode"
+ :items="ELY_MODES"
+ :format-label="elyModeLabel"
+ size="small"
+ />
+
+ <div
+ v-if="source === 'ely' && elyMode === 'catalogue'"
+ class="flex flex-wrap items-center gap-2"
+ >
<Combobox
v-model="sort"
class="w-max"
@@ -87,7 +134,7 @@
/>
</div>
<Input
- v-else
+ v-else-if="source === 'ely'"
v-model="playerTerm"
type="search"
:icon="SearchIcon"
@@ -117,10 +164,18 @@
<span v-tooltip="formatMessage(messages.likes)" class="flex items-center gap-1">
<HeartIcon class="size-4" />{{ compact(tile.stats.likes) }}
</span>
- <span v-tooltip="formatMessage(messages.wearers)" class="flex items-center gap-1">
+ <span
+ v-if="tile.stats.wearers !== undefined"
+ v-tooltip="formatMessage(messages.wearers)"
+ class="flex items-center gap-1"
+ >
<UsersIcon class="size-4" />{{ compact(tile.stats.wearers) }}
</span>
- <span v-tooltip="formatMessage(messages.views)" class="flex items-center gap-1">
+ <span
+ v-if="tile.stats.views !== undefined"
+ v-tooltip="formatMessage(messages.views)"
+ class="flex items-center gap-1"
+ >
<EyeIcon class="size-4" />{{ compact(tile.stats.views) }}
</span>
</template>
@@ -131,7 +186,7 @@
<p v-else-if="!loading" class="m-0 text-secondary">
{{
formatMessage(
- elyMode === 'players' && playerTerm.trim().length < 3
+ source === 'ely' && elyMode === 'players' && playerTerm.trim().length < 3
? messages.playerHint
: messages.nothingFound,
)
@@ -140,7 +195,7 @@
<div class="flex justify-center">
<SpinnerIcon v-if="loading" class="size-6 animate-spin" />
- <Button v-else-if="elyMode === 'catalogue' && hasMore" @click="loadMore">
+ <Button v-else-if="!showsPlayers && hasMore" @click="loadMore">
{{ formatMessage(messages.loadMore) }}
</Button>
</div>
@@ -223,7 +278,11 @@ import {
type ElyModel,
type ElySort,
getElyCatalogue,
+ getLittleSkinLibrary,
+ getLittleSkinTexture,
getSiteSkin,
+ type LittleSkinFilter,
+ type LittleSkinSort,
onSkinSitePage,
openSkinSite,
searchElyPlayers,
@@ -240,19 +299,20 @@ const emit = defineEmits<{
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
-type Source = 'ely' | SkinSite
+type Source = 'ely' | 'littleskin' | SkinSite
type ElyMode = 'catalogue' | 'players'
type Order = 'site' | 'likes' | 'wearers' | 'views'
interface Stats {
likes: number
- wearers: number
- views: number
+ wearers?: number
+ views?: number
}
interface Entry {
key: string
title: string
+ /** Ely.by's texture address, or LittleSkin's texture id. */
url: string
variant: SkinModel
stats?: Stats
@@ -262,10 +322,11 @@ interface Tile extends Entry {
skin?: Skin
}
-const SOURCES: Source[] = ['ely', 'namemc', 'laby', 'crafty']
+const SOURCES: Source[] = ['ely', 'littleskin', 'namemc', 'laby', 'crafty']
const ELY_MODES: ElyMode[] = ['catalogue', 'players']
const SITE_NAMES: Record<Source, string> = {
ely: 'Ely.by',
+ littleskin: 'LittleSkin',
namemc: 'NameMC',
laby: 'laby.net',
crafty: 'crafty.gg',
@@ -284,6 +345,8 @@ const messages = defineMessages({
modelOld: { id: 'app.skins.browse.model.old', defaultMessage: 'Old format' },
modelNew: { id: 'app.skins.browse.model.new', defaultMessage: 'New format' },
modelSlim: { id: 'app.skins.browse.model.slim', defaultMessage: 'Slim' },
+ modelWide: { id: 'app.skins.browse.model.wide', defaultMessage: 'Wide' },
+ namePlaceholder: { id: 'app.skins.browse.name-placeholder', defaultMessage: 'Search by name' },
allCategories: { id: 'app.skins.browse.kind.all', defaultMessage: 'All categories' },
tagsPlaceholder: {
id: 'app.skins.browse.tags-placeholder',
@@ -341,6 +404,9 @@ const kind = ref('')
const tagsText = ref('')
const uploader = ref('')
const playerTerm = ref('')
+const littleSkinSort = ref<LittleSkinSort>('likes')
+const littleSkinFilter = ref<LittleSkinFilter>('skin')
+const littleSkinKeyword = ref('')
const sortOptions = computed<ComboboxOption<ElySort>[]>(() => [
{ value: 'best', label: formatMessage(messages.sortBest) },
@@ -362,6 +428,17 @@ const modelOptions = computed<ComboboxOption<ElyModel>[]>(() => [
{ value: 'slim', label: formatMessage(messages.modelSlim) },
])
+const littleSkinSortOptions = computed<ComboboxOption<LittleSkinSort>[]>(() => [
+ { value: 'likes', label: formatMessage(messages.orderLikes) },
+ { value: 'time', label: formatMessage(messages.sortNew) },
+])
+
+const littleSkinFilterOptions = computed<ComboboxOption<LittleSkinFilter>[]>(() => [
+ { value: 'skin', label: formatMessage(messages.modelAny) },
+ { value: 'steve', label: formatMessage(messages.modelWide) },
+ { value: 'alex', label: formatMessage(messages.modelSlim) },
+])
+
const kindOptions = computed<ComboboxOption<string>[]>(() => [
{ value: '', label: formatMessage(messages.allCategories) },
...ELY_KINDS.map((value) => ({ value, label: value })),
@@ -376,6 +453,7 @@ const pickedKey = ref<string | null>(null)
let generation = 0
const hasMore = computed(() => page.value < lastPage.value)
+const showsPlayers = computed(() => source.value === 'ely' && elyMode.value === 'players')
function skinOf(entry: Entry): Skin | undefined {
const texture = textures.value[entry.key]
@@ -391,10 +469,12 @@ function skinOf(entry: Entry): Skin | undefined {
const tiles = computed<Tile[]>(() => {
const ordered =
- order.value === 'site' || elyMode.value === 'players'
+ order.value === 'site' || source.value !== 'ely' || showsPlayers.value
? entries.value
: [...entries.value].sort(
- (a, b) => (b.stats?.[order.value as keyof Stats] ?? 0) - (a.stats?.[order.value as keyof Stats] ?? 0),
+ (a, b) =>
+ (b.stats?.[order.value as keyof Stats] ?? 0) -
+ (a.stats?.[order.value as keyof Stats] ?? 0),
)
return ordered.map((entry) => ({ ...entry, skin: skinOf(entry) }))
})
@@ -407,11 +487,14 @@ function tags() {
.slice(0, 10)
}
-/** Textures come through Rust: ely.by sends no CORS headers. They land one by one. */
+/** Textures come through Rust: neither site sends CORS headers. They land one by one. */
function loadTextures(list: Entry[], forGeneration: number) {
for (const entry of list) {
if (textures.value[entry.key]) continue
- getElyTexture(entry.url)
+ const request = entry.key.startsWith('browse-littleskin-')
+ ? getLittleSkinTexture(Number(entry.url))
+ : getElyTexture(entry.url)
+ request
.then((texture) => {
if (forGeneration !== generation) return
textures.value = { ...textures.value, [entry.key]: texture }
@@ -455,6 +538,39 @@ async function loadCatalogue(pageNumber: number, forGeneration: number) {
}
}
+async function loadLittleSkin(pageNumber: number, forGeneration: number) {
+ loading.value = true
+ try {
+ const result = await getLittleSkinLibrary({
+ filter: littleSkinFilter.value,
+ sort: littleSkinSort.value,
+ keyword: littleSkinKeyword.value.trim(),
+ page: pageNumber,
+ })
+ if (forGeneration !== generation) return
+
+ const known = new Set(entries.value.map((entry) => entry.key))
+ const added: Entry[] = result.items
+ .map((skin) => ({
+ key: `browse-littleskin-${skin.id}`,
+ title: skin.uploader ? `${skin.name} · ${skin.uploader}` : skin.name,
+ url: String(skin.id),
+ variant: (skin.is_slim ? 'SLIM' : 'CLASSIC') as SkinModel,
+ stats: { likes: skin.likes },
+ }))
+ .filter((entry) => !known.has(entry.key))
+
+ entries.value = [...entries.value, ...added]
+ page.value = result.current || pageNumber
+ lastPage.value = result.has_more ? page.value + 1 : page.value
+ loadTextures(added, forGeneration)
+ } catch (error) {
+ if (forGeneration === generation) handleError(error as Error)
+ } finally {
+ if (forGeneration === generation) loading.value = false
+ }
+}
+
async function loadPlayers(forGeneration: number) {
loading.value = true
try {
@@ -486,6 +602,11 @@ function reload() {
lastPage.value = 1
loading.value = false
+ if (source.value === 'littleskin') {
+ void loadLittleSkin(1, forGeneration)
+ return
+ }
+ if (source.value !== 'ely') return
if (elyMode.value === 'players') {
if (playerTerm.value.trim().length >= 3) void loadPlayers(forGeneration)
return
@@ -494,13 +615,15 @@ function reload() {
}
function loadMore() {
- if (!loading.value && hasMore.value) void loadCatalogue(page.value + 1, generation)
+ if (loading.value || !hasMore.value) return
+ if (source.value === 'littleskin') void loadLittleSkin(page.value + 1, generation)
+ else void loadCatalogue(page.value + 1, generation)
}
-watch([sort, model, kind, elyMode], reload)
+watch([source, sort, model, kind, elyMode, littleSkinSort, littleSkinFilter], reload)
let typingTimeout: ReturnType<typeof setTimeout> | undefined
-watch([tagsText, uploader, playerTerm], () => {
+watch([tagsText, uploader, playerTerm, littleSkinKeyword], () => {
clearTimeout(typingTimeout)
typingTimeout = setTimeout(reload, 500)
})
@@ -538,7 +661,7 @@ let unlistenSite: UnlistenFn | undefined
let unmounted = false
function openSite() {
- if (source.value === 'ely') return
+ if (source.value === 'ely' || source.value === 'littleskin') return
openSkinSite(source.value).catch((error) => handleError(error as Error))
}
diff --git a/apps/app-frontend/src/helpers/skin-browser.ts b/apps/app-frontend/src/helpers/skin-browser.ts
index ea86e86..e3c4b21 100644
--- a/apps/app-frontend/src/helpers/skin-browser.ts
+++ b/apps/app-frontend/src/helpers/skin-browser.ts
@@ -1,9 +1,11 @@
/**
* Finding skins on other sites, for the skin page.
*
- * Ely.by's catalogue is read directly. NameMC, laby.net and crafty.gg open in a
- * window the player browses; the launcher only learns which skin page it is on.
+ * Ely.by's catalogue and LittleSkin's library are read directly. NameMC, laby.net
+ * and crafty.gg open in a window the player browses; the launcher only learns
+ * which skin page it is on.
*/
+import { arrayBufferToBase64 } from '@modrinth/utils'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
@@ -39,6 +41,30 @@ export interface ElyCataloguePage {
total: number
}
+export type LittleSkinFilter = 'skin' | 'steve' | 'alex'
+export type LittleSkinSort = 'likes' | 'time'
+
+export interface LittleSkinQuery {
+ filter: LittleSkinFilter
+ sort: LittleSkinSort
+ keyword: string
+ page: number
+}
+
+export interface LittleSkinSkin {
+ id: number
+ name: string
+ uploader: string
+ is_slim: boolean
+ likes: number
+}
+
+export interface LittleSkinPage {
+ items: LittleSkinSkin[]
+ current: number
+ has_more: boolean
+}
+
export interface ElyPlayer {
nickname: string
skin_url: string | null
@@ -86,6 +112,18 @@ export async function searchElyPlayers(term: string): Promise<ElyPlayer[]> {
return await invoke('plugin:skin-browser|skin_browser_ely_players', { term })
}
+export async function getLittleSkinLibrary(query: LittleSkinQuery): Promise<LittleSkinPage> {
+ return await invoke('plugin:skin-browser|skin_browser_littleskin_library', { query })
+}
+
+/** A LittleSkin texture as a data URL. It comes through Rust, as Ely.by's do. */
+export async function getLittleSkinTexture(id: number): Promise<string> {
+ const bytes = await invoke<number[]>('plugin:skin-browser|skin_browser_littleskin_texture', {
+ id,
+ })
+ return `data:image/png;base64,${arrayBufferToBase64(new Uint8Array(bytes))}`
+}
+
export async function getSiteSkin(site: SkinSite, id: string): Promise<SiteSkin> {
return await invoke('plugin:skin-browser|skin_browser_site_skin', { site, id })
}
diff --git a/apps/app/build.rs b/apps/app/build.rs
index 507ad2f..8cb21f2 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -54,6 +54,8 @@ fn main() {
.commands(&[
"skin_browser_ely_catalogue",
"skin_browser_ely_players",
+ "skin_browser_littleskin_library",
+ "skin_browser_littleskin_texture",
"skin_browser_site_skin",
"skin_browser_open_site",
])
diff --git a/apps/app/src/api/skin_browser.rs b/apps/app/src/api/skin_browser.rs
index cfd0d8c..e4451cf 100644
--- a/apps/app/src/api/skin_browser.rs
+++ b/apps/app/src/api/skin_browser.rs
@@ -1,6 +1,7 @@
//! Browsing skins from the skin page.
//!
-//! Ely.by's catalogue is read directly (see `theseus::skin_browser`). NameMC,
+//! Ely.by's catalogue and LittleSkin's library are read directly (see
+//! `theseus::skin_browser`). NameMC,
//! laby.net and crafty.gg open in a window the player browses like any browser;
//! the launcher only watches which page that window is on, so that the skin
//! page the player has open can be previewed and added.
@@ -13,7 +14,8 @@ use tauri::{
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder,
};
use theseus::skin_browser::{
- self, ElyCatalogueQuery, ElyCataloguePage, ElyPlayer, SiteSkin, SkinSite,
+ self, ElyCatalogueQuery, ElyCataloguePage, ElyPlayer, LittleSkinPage,
+ LittleSkinQuery, SiteSkin, SkinSite,
};
use crate::api::{Result, TheseusSerializableError};
@@ -23,6 +25,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.invoke_handler(tauri::generate_handler![
skin_browser_ely_catalogue,
skin_browser_ely_players,
+ skin_browser_littleskin_library,
+ skin_browser_littleskin_texture,
skin_browser_site_skin,
skin_browser_open_site,
])
@@ -53,6 +57,18 @@ pub async fn skin_browser_ely_players(term: String) -> Result<Vec<ElyPlayer>> {
Ok(skin_browser::ely_players(&term).await?)
}
+#[tauri::command]
+pub async fn skin_browser_littleskin_library(
+ query: LittleSkinQuery,
+) -> Result<LittleSkinPage> {
+ Ok(skin_browser::littleskin_library(&query).await?)
+}
+
+#[tauri::command]
+pub async fn skin_browser_littleskin_texture(id: u64) -> Result<Vec<u8>> {
+ Ok(skin_browser::littleskin_texture(id).await?)
+}
+
#[tauri::command]
pub async fn skin_browser_site_skin(
site: SkinSite,
diff --git a/packages/app-lib/src/api/skin_browser.rs b/packages/app-lib/src/api/skin_browser.rs
index fba085b..9038989 100644
--- a/packages/app-lib/src/api/skin_browser.rs
+++ b/packages/app-lib/src/api/skin_browser.rs
@@ -1,9 +1,10 @@
//! Browsing skins from other sites.
//!
-//! Only Ely.by's catalogue is read from here: its robots.txt invites it. NameMC,
-//! laby.net and crafty.gg are not - their lists are bot-protected or not meant
-//! for other programs - so the player browses them in a window of the desktop
-//! shell, and all that is read from them is the one skin whose page is open.
+//! Ely.by's catalogue and LittleSkin's skin library are read from here: Ely.by's
+//! robots.txt invites it, and LittleSkin's excludes nothing. NameMC, laby.net and
+//! crafty.gg are not - their lists are bot-protected or not meant for other
+//! programs - so the player browses them in a window of the desktop shell, and
+//! all that is read from them is the one skin whose page is open.
use base64::Engine;
use serde::{Deserialize, Serialize};
@@ -194,6 +195,115 @@ pub async fn ely_players(term: &str) -> crate::Result<Vec<ElyPlayer>> {
.map_err(|error| failed("read the Ely.by search", error))
}
+/// A search of LittleSkin's skin library, as the library page sends it.
+#[derive(Deserialize, Debug)]
+pub struct LittleSkinQuery {
+ /// `skin` for either model, `steve` or `alex`.
+ pub filter: String,
+ /// `likes` or `time`.
+ pub sort: String,
+ pub keyword: String,
+ pub page: u32,
+}
+
+#[derive(Serialize, Debug)]
+pub struct LittleSkinSkin {
+ pub id: u64,
+ pub name: String,
+ pub uploader: String,
+ pub is_slim: bool,
+ pub likes: u64,
+}
+
+#[derive(Serialize, Debug)]
+pub struct LittleSkinPage {
+ pub items: Vec<LittleSkinSkin>,
+ pub current: u32,
+ pub has_more: bool,
+}
+
+/// One page of LittleSkin's skin library, a Blessing Skin one.
+pub async fn littleskin_library(
+ query: &LittleSkinQuery,
+) -> crate::Result<LittleSkinPage> {
+ #[derive(Deserialize)]
+ struct RawPage {
+ #[serde(default)]
+ current_page: u32,
+ next_page_url: Option<String>,
+ data: Vec<RawTexture>,
+ }
+
+ #[derive(Deserialize)]
+ struct RawTexture {
+ tid: u64,
+ #[serde(default)]
+ name: String,
+ #[serde(rename = "type")]
+ kind: String,
+ #[serde(default)]
+ likes: u64,
+ #[serde(default)]
+ nickname: String,
+ }
+
+ if !matches!(query.filter.as_str(), "skin" | "steve" | "alex") {
+ return Err(invalid("skin model"));
+ }
+ if !matches!(query.sort.as_str(), "likes" | "time") {
+ return Err(invalid("sort"));
+ }
+ if !plain(&query.keyword, 64) {
+ return Err(invalid("search"));
+ }
+
+ let page = query.page.max(1).to_string();
+ let url = url::Url::parse_with_params(
+ "https://littleskin.cn/skinlib/list",
+ &[
+ ("filter", query.filter.as_str()),
+ ("sort", query.sort.as_str()),
+ ("keyword", query.keyword.trim()),
+ ("page", page.as_str()),
+ ],
+ )
+ .map_err(|error| failed("build the library address", error))?;
+
+ let page: RawPage = REQWEST_CLIENT
+ .get(url)
+ .header("Accept", "application/json")
+ .send()
+ .await
+ .and_then(|response| response.error_for_status())
+ .map_err(|error| failed("load the LittleSkin library", error))?
+ .json()
+ .await
+ .map_err(|error| failed("read the LittleSkin library", error))?;
+
+ Ok(LittleSkinPage {
+ items: page
+ .data
+ .into_iter()
+ // Capes share the library with skins.
+ .filter(|texture| matches!(texture.kind.as_str(), "steve" | "alex"))
+ .map(|texture| LittleSkinSkin {
+ id: texture.tid,
+ name: texture.name,
+ uploader: texture.nickname,
+ is_slim: texture.kind == "alex",
+ likes: texture.likes,
+ })
+ .collect(),
+ current: page.current_page,
+ has_more: page.next_page_url.is_some(),
+ })
+}
+
+/// A skin from LittleSkin's library.
+pub async fn littleskin_texture(id: u64) -> crate::Result<Vec<u8>> {
+ png(&format!("https://littleskin.cn/raw/{id}"), "LittleSkin").await
+}
+
/// A site that is browsed in the launcher's window.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
+1 -1
View File
@@ -1 +1 @@
v0.21.2 v0.21.4
+52 -6
View File
@@ -56,6 +56,47 @@ Darwin)
;; ;;
esac esac
# The repository releases come from, which the app's update notice points at.
export MODRINTH_ENHANCED_REPOSITORY="${GITHUB_REPOSITORY:-$(git -C "$REPO_ROOT" remote get-url origin | sed -E 's#^.*github\.com[:/]##; s#\.git$##')}"
# Updates come from this repository's own releases, signed with its own key
# (the public half is updater.pub). A build without the private key, such as
# one for a pull request or a local one, has nothing to sign them with and
# leaves the updater out.
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
repository="$MODRINTH_ENHANCED_REPOSITORY"
updater_conf="$REPO_ROOT/build/updater.conf.json"
mkdir -p "$(dirname "$updater_conf")"
node -e '
const fs = require("fs")
const [conf, pubkey, repository] = process.argv.slice(1)
const { capabilities } = JSON.parse(fs.readFileSync(conf, "utf8")).app.security
console.log(JSON.stringify({
bundle: { createUpdaterArtifacts: true },
build: { features: ["updater"] },
app: { security: { capabilities: [...capabilities, "updater"] } },
plugins: {
updater: {
pubkey: fs.readFileSync(pubkey, "utf8").trim(),
endpoints: [`https://github.com/${repository}/releases/latest/download/latest.json`],
windows: { installMode: "passive" },
},
},
}, null, "\t"))
' "$WORKTREE/apps/app/tauri.conf.json" "$REPO_ROOT/updater.pub" "$repository" >"$updater_conf"
tauri_args+=(--config "$updater_conf")
# Tauri asks for the password when none is set, which fails without a
# terminal. The project's key has none.
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="${TAURI_SIGNING_PRIVATE_KEY_PASSWORD-}"
log "Updates will come from github.com/$repository"
else
warn "TAURI_SIGNING_PRIVATE_KEY is not set, so this build has no updater"
fi
# Which release of this upstream version this is, for the updater to tell
# v1.2.3-2 from v1.2.3, since the app's own version cannot carry it.
export MODRINTH_ENHANCED_REVISION="${MODRINTH_ENHANCED_REVISION:-1}"
# Emptied before the build, not after it: a build that fails halfway would # Emptied before the build, not after it: a build that fails halfway would
# otherwise leave the previous run's installers sitting here, where # otherwise leave the previous run's installers sitting here, where
# scripts/check.sh would happily pass them off as this build's output. # scripts/check.sh would happily pass them off as this build's output.
@@ -63,24 +104,29 @@ log "Clearing $ARTIFACTS"
rm -rf "$ARTIFACTS" rm -rf "$ARTIFACTS"
mkdir -p "$ARTIFACTS" mkdir -p "$ARTIFACTS"
log "Building for $platform"
(cd "$WORKTREE" && pnpm --filter=@modrinth/app run tauri build "${tauri_args[@]}")
log "Collecting bundles into $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"
else else
bundle_dir="$WORKTREE/target/release/bundle" bundle_dir="$WORKTREE/target/release/bundle"
fi fi
# Tauri leaves earlier bundles, and their signatures, where they were. A build
# without the key would otherwise ship an older build's signatures.
rm -rf "$bundle_dir"
log "Building for $platform"
(cd "$WORKTREE" && pnpm --filter=@modrinth/app run tauri build "${tauri_args[@]}")
log "Collecting bundles into $ARTIFACTS"
found=0 found=0
while IFS= read -r -d '' artifact; do while IFS= read -r -d '' artifact; do
cp "$artifact" "$ARTIFACTS/" cp "$artifact" "$ARTIFACTS/"
found=1 found=1
done < <(find "$bundle_dir" -maxdepth 2 -type f \ done < <(find "$bundle_dir" -maxdepth 2 -type f \
\( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \ \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
-o -name '*.dmg' -o -name '*.app.tar.gz' -o -name '*-setup.exe' \) -print0) -o -name '*.dmg' -o -name '*.app.tar.gz' -o -name '*-setup.exe' \
-o -name '*.sig' \) -print0)
[ "$found" = 1 ] || die "No bundles were produced under $bundle_dir" [ "$found" = 1 ] || die "No bundles were produced under $bundle_dir"
+53 -1
View File
@@ -34,6 +34,17 @@ missing() {
! grep -qrF "$2" "$1" ! grep -qrF "$2" "$1"
} }
# Every installer the updater can take has its signature next to it.
signed() {
local artifact found=0
for artifact in "$1"/*.AppImage "$1"/*-setup.exe "$1"/*.app.tar.gz; do
[ -e "$artifact" ] || continue
[ -s "$artifact.sig" ] || return 1
found=1
done
[ "$found" = 1 ]
}
log "Branding" log "Branding"
check "tauri.conf.json is named Modrinth Enhanced" \ 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"'
@@ -76,7 +87,9 @@ done < <(grep -A1 '\.plugin($' "$WORKTREE/apps/app/build.rs" |
log "Microsoft sign-in" log "Microsoft sign-in"
check "the browser flow is registered" \ check "the browser flow is registered" \
contains "$WORKTREE/apps/app/src/api/auth.rs" 'login_browser_begin,' contains "$WORKTREE/apps/app/src/api/auth.rs" 'login_device_begin,'
check "the browser sign-in comes back without pasting" \
contains "$WORKTREE/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue" 'login_device_poll('
check "the sign-in button opens it" \ check "the sign-in button opens it" \
contains "$WORKTREE/apps/app-frontend/src/components/ui/AccountsCard.vue" 'microsoftLoginModal.value?.show' contains "$WORKTREE/apps/app-frontend/src/components/ui/AccountsCard.vue" 'microsoftLoginModal.value?.show'
@@ -88,6 +101,24 @@ check "the Tauri command is registered" \
check "authlib-injector is added at launch" \ check "authlib-injector is added at launch" \
contains "$WORKTREE/packages/app-lib/src/launcher/mod.rs" 'authlib_injector' contains "$WORKTREE/packages/app-lib/src/launcher/mod.rs" 'authlib_injector'
log "Custom server accounts"
check "app-lib can sign in to other servers" \
contains "$WORKTREE/packages/app-lib/src/api/minecraft_auth.rs" 'pub async fn login_authlib'
check "the Tauri command is registered" \
contains "$WORKTREE/apps/app/src/api/auth.rs" 'login_authlib,'
check "the server is found from its website" \
contains "$WORKTREE/packages/app-lib/src/util/authlib_injector.rs" 'x-authlib-injector-api-location'
check "the game is pointed at the account's server" \
contains "$WORKTREE/packages/app-lib/src/launcher/mod.rs" 'server.api_root()'
check "the account card offers it" \
contains "$WORKTREE/apps/app-frontend/src/components/ui/AccountsCard.vue" 'authlibAccountModal?.show'
check "their skins are uploaded to the server" \
contains "$WORKTREE/packages/app-lib/src/state/minecraft_skins/mojang_api.rs" 'change_texture(credentials, "skin"'
check "their profile comes from the server" \
contains "$WORKTREE/packages/app-lib/src/state/minecraft_auth.rs" 'server.session_profile(self.offline_profile.id)'
check "Yggdrasil Connect is found in the metadata" \
contains "$WORKTREE/packages/app-lib/src/util/authlib_injector.rs" 'feature.openid_configuration_url'
log "Ely.by skins" log "Ely.by skins"
check "the frontend can tell an Ely.by account" \ check "the frontend can tell an Ely.by account" \
contains "$WORKTREE/packages/app-lib/src/state/minecraft_auth.rs" 'serialize_field("ely"' contains "$WORKTREE/packages/app-lib/src/state/minecraft_auth.rs" 'serialize_field("ely"'
@@ -107,6 +138,8 @@ check "the skins folder can be opened" \
log "Another copy of a running instance" log "Another copy of a running instance"
check "a running instance can start again" \ check "a running instance can start again" \
contains "$WORKTREE/packages/app-lib/src/api/instance/run.rs" 'pub async fn run_additional' contains "$WORKTREE/packages/app-lib/src/api/instance/run.rs" 'pub async fn run_additional'
check "neither running check refuses another copy" \
test "$(grep -c 'if !additional' "$WORKTREE/packages/app-lib/src/launcher/mod.rs")" -ge 2
check "each copy has a console" \ check "each copy has a console" \
contains "$WORKTREE/apps/app-frontend/src/pages/instance/logs/index.vue" 'ProcessConsole' contains "$WORKTREE/apps/app-frontend/src/pages/instance/logs/index.vue" 'ProcessConsole'
check "log events say which copy" \ check "log events say which copy" \
@@ -123,6 +156,8 @@ check "the Logs tab shows it" \
log "Skin browser" log "Skin browser"
check "Ely.by's catalogue can be browsed" \ check "Ely.by's catalogue can be browsed" \
contains "$WORKTREE/packages/app-lib/src/api/skin_browser.rs" 'pub async fn ely_catalogue' contains "$WORKTREE/packages/app-lib/src/api/skin_browser.rs" 'pub async fn ely_catalogue'
check "LittleSkin's library can be browsed" \
contains "$WORKTREE/packages/app-lib/src/api/skin_browser.rs" 'pub async fn littleskin_library'
check "skin sites open in a window" \ check "skin sites open in a window" \
contains "$WORKTREE/apps/app/src/api/skin_browser.rs" 'pub async fn skin_browser_open_site' contains "$WORKTREE/apps/app/src/api/skin_browser.rs" 'pub async fn skin_browser_open_site'
check "the skin page has a Browse tab" \ check "the skin page has a Browse tab" \
@@ -135,6 +170,10 @@ check "the news section can be collapsed" \
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'setNewsCollapsed' contains "$WORKTREE/apps/app-frontend/src/App.vue" 'setNewsCollapsed'
check "the right sidebar has a fold button" \ check "the right sidebar has a fold button" \
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'setSidebarCollapsed(sidebarToggled)' contains "$WORKTREE/apps/app-frontend/src/App.vue" 'setSidebarCollapsed(sidebarToggled)'
check "folding the sidebar is explained once" \
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'sidebarRememberedModal.value?.show()'
check "the account stays reachable with the sidebar folded" \
contains "$WORKTREE/apps/app-frontend/src/App.vue" '<TitleBarAccountSwitcher'
# tauri.linux.conf.json replaces the whole window list, so an upstream change to # tauri.linux.conf.json replaces the whole window list, so an upstream change to
# the main window would otherwise silently not reach Linux. # the main window would otherwise silently not reach Linux.
@@ -158,6 +197,16 @@ check "the middle button autoscrolls" \
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'installAutoscroll()' contains "$WORKTREE/apps/app-frontend/src/App.vue" 'installAutoscroll()'
check "file pickers use the desktop portal on Linux" \ check "file pickers use the desktop portal on Linux" \
contains "$WORKTREE/apps/app/src/main.rs" 'set_var("GTK_USE_PORTAL", "1")' contains "$WORKTREE/apps/app/src/main.rs" 'set_var("GTK_USE_PORTAL", "1")'
check "the settings tabs scroll instead of covering the version" \
contains "$WORKTREE/packages/ui/src/components/modal/TabbedModal.vue" 'class="absolute inset-0 flex flex-col gap-1 overflow-y-auto"'
check "NVIDIA under Wayland does not crash the webview" \
contains "$WORKTREE/apps/app/src/main.rs" 'set_var("WEBKIT_DMABUF_RENDERER_FORCE_SHM", "1")'
log "Updates"
check "updates do not come from Modrinth" \
missing "$WORKTREE/apps/app-frontend/src/App.vue" 'launcher-files.modrinth.com/updates.json'
check "the updater tells revisions apart" \
contains "$WORKTREE/apps/app/src/main.rs" 'default_version_comparator'
log "No advertising or upsells" log "No advertising or upsells"
check "no Modrinth+ upsell in the app" \ check "no Modrinth+ upsell in the app" \
@@ -208,6 +257,9 @@ if [ -d "$artifacts" ] && [ -n "$(ls -A "$artifacts" 2>/dev/null)" ]; then
;; ;;
esac esac
done done
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
check "the installers are signed for the updater" signed "$artifacts"
fi
fi fi
if [ "$failures" -gt 0 ]; then if [ "$failures" -gt 0 ]; then
+1
View File
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDY1Nzc2MEZBMkQyQkRGQjkKUldTNTN5c3QrbUIzWlk1RHdYTFFBMStjM29zQkZ4MW5ibHZGb2p6ckxFK0JUNDBJTkZ6RXc3NUMK
+1 -1
View File
@@ -1 +1 @@
v0.21.2 v0.21.4