Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3148d82d3 | ||
|
|
a84b460ee2 | ||
|
|
47aeca9b20 | ||
|
|
0a80cadbc0 | ||
|
|
13ba1ae18f | ||
|
|
88888c6b8e | ||
|
|
1f98dbb939 | ||
|
|
a95df01482 | ||
|
|
9181bde08a | ||
|
|
3dc1f6a8fd | ||
|
|
a529f5f5d2 | ||
|
|
5234208662 | ||
|
|
c53037fac8 | ||
|
|
0753f3faba | ||
|
|
5cb7c17eae | ||
|
|
56ffa26cf5 | ||
|
|
944cc6adfb | ||
|
|
8f2d69cc08 | ||
|
|
e3e36cd158 | ||
|
|
daedc88f2d | ||
|
|
b62b471da2 | ||
|
|
108e2b0fab | ||
|
|
b97568b60b | ||
|
|
01c84cec6f | ||
|
|
ae13fb44a3 | ||
|
|
2f3d030f6c | ||
|
|
e344320e75 | ||
|
|
e2c72bcff8 | ||
|
|
da8048a2d4 | ||
|
|
1249fef8a5 | ||
|
|
9c87e603a9 | ||
|
|
8a0cd12613 | ||
|
|
c1c892ebd9 | ||
|
|
70dedfb9ee | ||
|
|
616afefed1 | ||
|
|
9676c94430 | ||
|
|
be8a500937 | ||
|
|
cb0d80495b | ||
|
|
0842e602b3 | ||
|
|
997483b3d4 | ||
|
|
030aa8fb57 | ||
|
|
b045aaee56 | ||
|
|
76c250aa2f | ||
|
|
7e02e6fa9b | ||
|
|
3f81e125f1 | ||
|
|
344d50e1f6 | ||
|
|
dba5d2906d | ||
|
|
478867172a | ||
|
|
0c9a44bd59 | ||
|
|
cd8bc9d1af | ||
|
|
1b4d43c699 | ||
|
|
399f069bfc | ||
|
|
3d15cfd11a | ||
|
|
f398c24183 | ||
|
|
29335b8418 | ||
|
|
7b53b324a1 | ||
|
|
af66b2ffe5 | ||
|
|
d93955cbce | ||
|
|
109171e355 | ||
|
|
46d7c792be | ||
|
|
4c2cdbc4f0 | ||
|
|
65d04fb282 | ||
|
|
0a71af9b94 | ||
|
|
9f8f11f424 | ||
|
|
61a9b53af6 | ||
|
|
511a799fb6 | ||
|
|
70fc115846 | ||
|
|
4ac177a6fa | ||
|
|
d0528cc1b8 | ||
|
|
d7fdb05329 | ||
|
|
e1ec2068e6 |
No files matched your search
@@ -0,0 +1,119 @@
|
|||||||
|
name: 🐛 Bug report
|
||||||
|
description: Something in Modrinth Enhanced does not work
|
||||||
|
labels: [bug]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Modrinth Enhanced is the Modrinth App with a series of patches applied. If the same thing
|
||||||
|
happens in the official Modrinth App, it belongs
|
||||||
|
[upstream](https://github.com/modrinth/code/issues/new/choose) instead.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Please confirm the following.
|
||||||
|
options:
|
||||||
|
- label: I checked the [existing issues](https://github.com/Felitendo/Modrinth-Enhanced/issues?q=is%3Aissue) for the same problem
|
||||||
|
required: true
|
||||||
|
- label: I am on the [latest release](https://github.com/Felitendo/Modrinth-Enhanced/releases/latest)
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Which version are you on?
|
||||||
|
description: Settings, bottom left, next to the Modrinth logo.
|
||||||
|
placeholder: Modrinth Enhanced 0.21.4
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: install
|
||||||
|
attributes:
|
||||||
|
label: How did you install it?
|
||||||
|
options:
|
||||||
|
- AppImage
|
||||||
|
- Windows installer
|
||||||
|
- macOS disk image
|
||||||
|
- Debian or Ubuntu package
|
||||||
|
- Fedora package
|
||||||
|
- Arch Linux (modrinth-enhanced-bin)
|
||||||
|
- Built it myself
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: Which operating system?
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Windows
|
||||||
|
- macOS
|
||||||
|
- Linux
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: area
|
||||||
|
attributes:
|
||||||
|
label: What is it about?
|
||||||
|
options:
|
||||||
|
- Accounts (offline, Ely.by, custom servers)
|
||||||
|
- Skins
|
||||||
|
- Instances, launching or logs
|
||||||
|
- Sidebar, title bar or window
|
||||||
|
- Updates or installation
|
||||||
|
- Something else
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: upstream
|
||||||
|
attributes:
|
||||||
|
label: Does the official Modrinth App do it too?
|
||||||
|
options:
|
||||||
|
- I have not tried it
|
||||||
|
- 'No: only Modrinth Enhanced'
|
||||||
|
- 'Yes: the official app as well'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: What happens?
|
||||||
|
description: What goes wrong, and what you expected instead. Screenshots help.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: How can it be reproduced?
|
||||||
|
placeholder: |
|
||||||
|
1. Open ...
|
||||||
|
2. Click ...
|
||||||
|
3. See ...
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Launcher log
|
||||||
|
description: |
|
||||||
|
The newest file in `launcher_logs`, in the app's folder:
|
||||||
|
`%APPDATA%\ModrinthApp` on Windows, `~/Library/Application Support/ModrinthApp` on macOS,
|
||||||
|
`~/.local/share/ModrinthApp` on Linux. Remove anything you would rather not share.
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Anything else?
|
||||||
|
description: Whatever else may matter, such as the account server or the instance it happens with.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
name: 💡 Feature request
|
||||||
|
description: Suggest something for Modrinth Enhanced
|
||||||
|
labels: [enhancement]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Everything here is carried as a patch on top of the Modrinth App, and every patch has to be
|
||||||
|
kept working as upstream moves. Ideas that would suit the app for everyone are better off
|
||||||
|
[upstream](https://github.com/modrinth/code/issues/new/choose), where they reach far more
|
||||||
|
players.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Please confirm the following.
|
||||||
|
options:
|
||||||
|
- label: I checked the [existing issues](https://github.com/Felitendo/Modrinth-Enhanced/issues?q=is%3Aissue) for the same idea
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: area
|
||||||
|
attributes:
|
||||||
|
label: What is it about?
|
||||||
|
options:
|
||||||
|
- Accounts (offline, Ely.by, custom servers)
|
||||||
|
- Skins
|
||||||
|
- Instances, launching or logs
|
||||||
|
- Sidebar, title bar or window
|
||||||
|
- Updates or installation
|
||||||
|
- Something else
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: What is the problem?
|
||||||
|
description: What you are trying to do, and what stands in the way today.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: What should it do instead?
|
||||||
|
description: How you picture it working. Screenshots or mockups help.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Is there another way?
|
||||||
|
description: Other launchers that do it, mods that do it, or ways you work around it now.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Anything else?
|
||||||
|
description: Links to the service, mod or documentation involved, and whatever else may matter.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: 🎮 Bug in the Modrinth App itself
|
||||||
|
about: Anything that the official Modrinth App does too belongs upstream.
|
||||||
|
url: https://github.com/modrinth/code/issues/new/choose
|
||||||
|
- name: 🫶 Using the Modrinth App
|
||||||
|
about: Questions about the app, your account or Modrinth itself.
|
||||||
|
url: https://support.modrinth.com
|
||||||
|
- name: 📦 Downloads
|
||||||
|
about: Installers for Windows, macOS and Linux, and what changed in each release.
|
||||||
|
url: https://github.com/Felitendo/Modrinth-Enhanced/releases/latest
|
||||||
@@ -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:
|
||||||
@@ -47,7 +52,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Modrinth Enhanced
|
- name: Check out Modrinth Enhanced
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
# Everything below works on the patched tree in build/upstream, so this
|
# Everything below works on the patched tree in build/upstream, so this
|
||||||
# has to come first: a patch that no longer applies fails the build here,
|
# has to come first: a patch that no longer applies fails the build here,
|
||||||
@@ -65,7 +70,7 @@ jobs:
|
|||||||
echo "upstream=$UPSTREAM_REF" >> "$GITHUB_OUTPUT"
|
echo "upstream=$UPSTREAM_REF" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: build/upstream/.nvmrc
|
node-version-file: build/upstream/.nvmrc
|
||||||
|
|
||||||
@@ -74,7 +79,7 @@ jobs:
|
|||||||
run: corepack enable
|
run: corepack enable
|
||||||
|
|
||||||
- name: Set up Java
|
- name: Set up Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v6
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 17
|
java-version: 17
|
||||||
@@ -104,16 +109,23 @@ 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
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: modrinth-enhanced-${{ matrix.name }}
|
name: modrinth-enhanced-${{ matrix.name }}
|
||||||
path: build/artifacts/*
|
path: build/artifacts/*
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
name: Notify
|
||||||
|
|
||||||
|
# Opens an issue when a workflow fails on main, so it reaches GitHub's
|
||||||
|
# notifications, the mobile app and email, and comments on it for every further
|
||||||
|
# failure. Closes it again once that workflow passes.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [Build, Upstream release]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: notify-${{ github.event.workflow_run.name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify:
|
||||||
|
name: Notify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# A pull request shows its own checks.
|
||||||
|
if: >-
|
||||||
|
github.event.workflow_run.event != 'pull_request' &&
|
||||||
|
github.event.workflow_run.head_branch == github.event.repository.default_branch
|
||||||
|
steps:
|
||||||
|
- name: Open, update or close the failure issue
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
OWNER: ${{ github.repository_owner }}
|
||||||
|
WORKFLOW: ${{ github.event.workflow_run.name }}
|
||||||
|
CONCLUSION: ${{ github.event.workflow_run.conclusion }}
|
||||||
|
EVENT: ${{ github.event.workflow_run.event }}
|
||||||
|
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||||
|
RUN_URL: ${{ github.event.workflow_run.html_url }}
|
||||||
|
SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
title="$WORKFLOW is failing"
|
||||||
|
label=ci-failure
|
||||||
|
issue="$(
|
||||||
|
gh issue list --state open --label "$label" --json number,title |
|
||||||
|
jq -r --arg title "$title" '.[] | select(.title == $title) | .number' |
|
||||||
|
head -1
|
||||||
|
)"
|
||||||
|
|
||||||
|
case "$CONCLUSION" in
|
||||||
|
success)
|
||||||
|
if [ -n "$issue" ]; then
|
||||||
|
gh issue close "$issue" --comment "Passing again: $RUN_URL"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
failure | timed_out | startup_failure) ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
failed="$(
|
||||||
|
gh api "repos/$GH_REPO/actions/runs/$RUN_ID/jobs?per_page=100" --jq '
|
||||||
|
.jobs[] | select(.conclusion == "failure" or .conclusion == "timed_out")'
|
||||||
|
)"
|
||||||
|
jobs="$(
|
||||||
|
jq -r '"- [\(.name)](\(.html_url))" +
|
||||||
|
([.steps[]? | select(.conclusion == "failure") | .name] |
|
||||||
|
if length > 0 then ": " + join(", ") else "" end)' <<<"$failed"
|
||||||
|
)"
|
||||||
|
|
||||||
|
# The lines leading up to the first error of the first failed job,
|
||||||
|
# enough to see what broke without opening the log.
|
||||||
|
excerpt=""
|
||||||
|
job_id="$(jq -r '.id' <<<"$failed" | head -1)"
|
||||||
|
if [ -n "$job_id" ]; then
|
||||||
|
excerpt="$(
|
||||||
|
curl -fsSL -H "Authorization: Bearer $GH_TOKEN" \
|
||||||
|
"$GITHUB_API_URL/repos/$GH_REPO/actions/jobs/$job_id/logs" |
|
||||||
|
sed -E 's/^[0-9TZ:.-]+ //; s/\x1b\[[0-9;]*m//g' |
|
||||||
|
awk '{ print } /^##\[error\]/ { exit }' |
|
||||||
|
tail -40 || true
|
||||||
|
)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
body="$(cat <<EOF
|
||||||
|
[$WORKFLOW]($RUN_URL) failed ($CONCLUSION) on \`${SHA:0:7}\`, started by \`$EVENT\`.
|
||||||
|
|
||||||
|
${jobs:-No failed job was reported.}
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
if [ -n "$excerpt" ]; then
|
||||||
|
body="$body
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Log</summary>
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
$excerpt
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
</details>"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$issue" ]; then
|
||||||
|
gh issue comment "$issue" --body "$body"
|
||||||
|
else
|
||||||
|
gh label create "$label" --color d73a4a \
|
||||||
|
--description "A workflow is failing on main" --force
|
||||||
|
gh issue create --title "$title" --label "$label" --body "@$OWNER $body"
|
||||||
|
fi
|
||||||
@@ -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
|
||||||
force:
|
|
||||||
description: Release even if this version was already released
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -33,30 +31,59 @@ jobs:
|
|||||||
name: Detect
|
name: Detect
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
|
upstream: ${{ steps.check.outputs.upstream }}
|
||||||
tag: ${{ steps.check.outputs.tag }}
|
tag: ${{ steps.check.outputs.tag }}
|
||||||
|
revision: ${{ steps.check.outputs.revision }}
|
||||||
proceed: ${{ steps.check.outputs.proceed }}
|
proceed: ${{ steps.check.outputs.proceed }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Find the newest upstream release
|
- name: Decide what to release
|
||||||
id: check
|
id: check
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
REQUESTED: ${{ inputs.upstream-ref }}
|
REQUESTED: ${{ inputs.upstream-ref }}
|
||||||
FORCE: ${{ inputs.force }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
tag="${REQUESTED:-$(scripts/latest-upstream.sh)}"
|
|
||||||
current="$(tr -d '[:space:]' < upstream.txt)"
|
current="$(tr -d '[:space:]' < upstream.txt)"
|
||||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
upstream="${REQUESTED:-$(scripts/latest-upstream.sh)}"
|
||||||
echo "Newest upstream release: $tag (we are on $current)"
|
echo "upstream=$upstream" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Upstream release: $upstream (we are on $current)"
|
||||||
|
|
||||||
if gh release view "$tag" >/dev/null 2>&1 && [ "$FORCE" != 'true' ]; then
|
# Our newest release of it, as "<revision> <tag>". The plain tag is
|
||||||
echo "$tag has already been released; nothing to do."
|
# revision 1.
|
||||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
last="$(
|
||||||
|
git ls-remote --tags --refs origin "$upstream" "$upstream-*" |
|
||||||
|
sed 's#.*refs/tags/##' |
|
||||||
|
awk -v up="$upstream" '
|
||||||
|
$0 == up { print 1, $0; next }
|
||||||
|
index($0, up "-") == 1 {
|
||||||
|
n = substr($0, length(up) + 2)
|
||||||
|
if (n ~ /^[0-9]+$/) print n, $0
|
||||||
|
}' |
|
||||||
|
sort -n |
|
||||||
|
tail -1
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [ -z "$last" ]; then
|
||||||
|
revision=1
|
||||||
|
tag="$upstream"
|
||||||
else
|
else
|
||||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
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"
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
revision="$(( ${last%% *} + 1 ))"
|
||||||
|
tag="$upstream-$revision"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "revision=$revision" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Releasing $tag"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
@@ -64,61 +91,165 @@ jobs:
|
|||||||
if: needs.detect.outputs.proceed == 'true'
|
if: needs.detect.outputs.proceed == 'true'
|
||||||
uses: ./.github/workflows/build.yml
|
uses: ./.github/workflows/build.yml
|
||||||
with:
|
with:
|
||||||
upstream-ref: ${{ needs.detect.outputs.tag }}
|
upstream-ref: ${{ needs.detect.outputs.upstream }}
|
||||||
|
revision: ${{ needs.detect.outputs.revision }}
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: Release
|
name: Release
|
||||||
needs: [detect, build]
|
needs: [detect, build]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
# The whole history: the release notes are the commits since the last one.
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Record the upstream release we build against
|
- name: Record the upstream release we build against
|
||||||
env:
|
env:
|
||||||
TAG: ${{ needs.detect.outputs.tag }}
|
UPSTREAM: ${{ needs.detect.outputs.upstream }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
printf '%s\n' "$TAG" > upstream.txt
|
printf '%s\n' "$UPSTREAM" > upstream.txt
|
||||||
if git diff --quiet -- upstream.txt; then
|
if git diff --quiet -- upstream.txt; then
|
||||||
echo "upstream.txt already points at $TAG"
|
echo "upstream.txt already points at $UPSTREAM"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
git config user.name 'github-actions[bot]'
|
git config user.name 'github-actions[bot]'
|
||||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||||
git commit -m "Track upstream $TAG" -- upstream.txt
|
git commit -m "Track upstream $UPSTREAM" -- upstream.txt
|
||||||
git push origin HEAD:${{ github.event.repository.default_branch }}
|
git push origin HEAD:${{ github.event.repository.default_branch }}
|
||||||
|
|
||||||
- name: Download installers
|
- name: Download installers
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
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 }}
|
||||||
TAG: ${{ needs.detect.outputs.tag }}
|
TAG: ${{ needs.detect.outputs.tag }}
|
||||||
|
UPSTREAM: ${{ needs.detect.outputs.upstream }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ls -la artifacts
|
ls -la artifacts
|
||||||
|
# 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
|
||||||
|
)"
|
||||||
|
patch_count="$(printf '%s\n' "$patches" | grep -c '^- ' || true)"
|
||||||
|
|
||||||
|
# What changed here since the last release, as its commit subjects.
|
||||||
|
# The bump of upstream.txt is left out: the line above already says
|
||||||
|
# which Modrinth App this is built from.
|
||||||
|
previous="$(gh release view --json tagName --jq .tagName 2>/dev/null || true)"
|
||||||
|
changes=""
|
||||||
|
if [ -n "$previous" ]; then
|
||||||
|
changes="$(
|
||||||
|
git log --reverse --no-merges --pretty='- %s' "$previous..HEAD" |
|
||||||
|
grep -v '^- Track upstream ' || true
|
||||||
|
)"
|
||||||
|
fi
|
||||||
|
if [ -z "$changes" ]; then
|
||||||
|
changes="- Rebuilt on Modrinth App $UPSTREAM, with the patches unchanged."
|
||||||
|
fi
|
||||||
|
if [ -n "$previous" ]; then
|
||||||
|
changes="$changes
|
||||||
|
|
||||||
|
[Everything since $previous](https://github.com/$GITHUB_REPOSITORY/compare/$previous...$TAG)"
|
||||||
|
fi
|
||||||
|
|
||||||
notes="$(cat <<EOF
|
notes="$(cat <<EOF
|
||||||
Modrinth Enhanced built from [Modrinth App $TAG](https://github.com/modrinth/code/releases/tag/$TAG).
|
Modrinth Enhanced $TAG, built from [Modrinth App $UPSTREAM](https://github.com/modrinth/code/releases/tag/$UPSTREAM).
|
||||||
|
|
||||||
Same app as upstream, with the patches in \`patches/\` applied:
|
## What changed
|
||||||
|
|
||||||
- no advertising,
|
$changes
|
||||||
- no telemetry, crash reporting or survey embeds,
|
|
||||||
- offline accounts for singleplayer and offline-mode servers,
|
Modrinth's own changes are in the [Modrinth App $UPSTREAM release notes](https://github.com/modrinth/code/releases/tag/$UPSTREAM).
|
||||||
- renamed and re-iconed as Modrinth Enhanced.
|
|
||||||
|
<details>
|
||||||
|
<summary>The $patch_count patches in this build</summary>
|
||||||
|
|
||||||
|
$patches
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
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
|
||||||
the same time.
|
the same time.
|
||||||
|
|
||||||
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/*
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
Keep everything short — replies, explanations, comments, docs.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
- English only.
|
||||||
|
- Conventional Commits prefix: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `ci:`, `build:`.
|
||||||
|
- Subject as short as possible. Imperative, lowercase, no trailing period.
|
||||||
|
- Body only when something genuinely cannot be inferred from the diff.
|
||||||
|
- Never add `Co-Authored-By`, "Generated with" or any other AI attribution to commits or PR descriptions.
|
||||||
|
- Exception: the commits in `build/upstream` that `patches/` is exported from. Their subjects become the patch file names, so they stay plain and descriptive.
|
||||||
|
|
||||||
|
## Dev builds
|
||||||
|
|
||||||
|
Never build over an installed Modrinth Enhanced. The stable app on this machine is installed
|
||||||
|
separately, from the AUR, and stays untouched.
|
||||||
|
|
||||||
|
- `DEV=1 scripts/build.sh` builds "Modrinth Enhanced (dev)". Its bundle identifier is
|
||||||
|
`ModrinthAppDev`, so it has a data directory of its own and shares no instances, accounts or
|
||||||
|
settings with the stable app, and it has no updater.
|
||||||
|
- Install it beside the stable app, never over it: `~/.local/bin/Modrinth.Enhanced.dev.AppImage`
|
||||||
|
with a desktop entry of its own.
|
||||||
|
- `FAST=1` cuts the build to about a third of the time, at the cost of a few megabytes. Use it for
|
||||||
|
anything that is not meant to be release-like: `DEV=1 FAST=1 scripts/build.sh`.
|
||||||
|
- Build after every change that touches the app, and install it over the last dev build, so it can
|
||||||
|
be tried out right away. Neither pnpm nor a JDK is on PATH by default:
|
||||||
|
`PATH="$PWD/build/bin:$PWD/build/jdk-17.0.20.1+1/bin:$PATH" JAVA_HOME="$PWD/build/jdk-17.0.20.1+1" DEV=1 FAST=1 scripts/build.sh`
|
||||||
@@ -10,4 +10,24 @@ Keep everything short — replies, explanations, comments, docs.
|
|||||||
- Conventional Commits prefix: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `ci:`, `build:`.
|
- Conventional Commits prefix: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `ci:`, `build:`.
|
||||||
- Subject as short as possible. Imperative, lowercase, no trailing period.
|
- Subject as short as possible. Imperative, lowercase, no trailing period.
|
||||||
- Body only when something genuinely cannot be inferred from the diff.
|
- Body only when something genuinely cannot be inferred from the diff.
|
||||||
|
- Never add `Co-Authored-By`, "Generated with" or any other AI attribution to commits or PR descriptions.
|
||||||
- Exception: the commits in `build/upstream` that `patches/` is exported from. Their subjects become the patch file names, so they stay plain and descriptive.
|
- Exception: the commits in `build/upstream` that `patches/` is exported from. Their subjects become the patch file names, so they stay plain and descriptive.
|
||||||
|
|
||||||
|
## Dev builds
|
||||||
|
|
||||||
|
Never build over an installed Modrinth Enhanced. The stable app on this machine is installed
|
||||||
|
separately, from the AUR, and stays untouched.
|
||||||
|
|
||||||
|
- `DEV=1 scripts/build.sh` builds "Modrinth Enhanced (dev)". Its bundle identifier is
|
||||||
|
`ModrinthAppDev`, so it has a data directory of its own and shares no instances, accounts or
|
||||||
|
settings with the stable app, and it has no updater.
|
||||||
|
- Install it beside the stable app, never over it: `~/.local/bin/Modrinth.Enhanced.dev.AppImage`
|
||||||
|
with a desktop entry of its own.
|
||||||
|
- `FAST=1` cuts the build to about a third of the time, at the cost of a few megabytes. Use it for
|
||||||
|
anything that is not meant to be release-like: `DEV=1 FAST=1 scripts/build.sh`.
|
||||||
|
- Build after every change that touches the app, and install it over the last dev build, so it can
|
||||||
|
be tried out right away. The system's pnpm and JDK are the wrong versions, and sharp must not
|
||||||
|
build against the system's libvips:
|
||||||
|
`PATH="$PWD/build/bin:$PWD/build/jdk-17.0.20.1+1/bin:$PATH" JAVA_HOME="$PWD/build/jdk-17.0.20.1+1" SHARP_IGNORE_GLOBAL_LIBVIPS=1 DEV=1 FAST=1 scripts/build.sh`
|
||||||
|
- If `build/` is gone: `npm install -g --prefix build pnpm@<packageManager version>` and unpack
|
||||||
|
Temurin 17 from api.adoptium.net into `build/`.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Modrinth Enhanced
|
# Modrinth Enhanced
|
||||||
|
|
||||||
The [Modrinth App](https://github.com/modrinth/code), without advertising, without telemetry, and
|
The [Modrinth App](https://github.com/modrinth/code), without advertising, without telemetry, and
|
||||||
with offline accounts.
|
with offline, 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,27 +9,206 @@ 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 |
|
||||||
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `0001-Rename-the-app-to-Modrinth-Enhanced` | Product name, binary name, window title, version label, and an "Enhanced" pill next to the wordmark. |
|
| `0001-Rename-the-app-to-Modrinth-Enhanced` | Product name, binary name, window title and version label. |
|
||||||
| `0002-Use-the-Modrinth-Enhanced-icon` | The Modrinth mark with a sparkle badge, rendered into every icon the bundles need. The vector source ships alongside them. |
|
| `0002-Remove-advertising-and-...` | The sidebar ad slot, both "Upgrade to Modrinth+" prompts and the ad cookie consent prompt. The ad webview is never created. |
|
||||||
| `0003-Remove-advertising` | The sidebar ad slot, the "Upgrade to Modrinth+" nag and the ad cookie consent prompt. The ad webview is never created. |
|
| `0003-Remove-telemetry` | PostHog analytics, Sentry crash reporting, the Tally survey embeds, and the playtime and server-play reports the launcher sends to Modrinth. |
|
||||||
| `0004-Remove-telemetry` | PostHog analytics, Sentry crash reporting, the Tally survey embed, and the playtime and server-play reports the launcher sends to Modrinth. |
|
| `0004-Add-offline-accounts` | A way to add a Minecraft account that never contacts Microsoft or Mojang. |
|
||||||
| `0005-Add-offline-accounts` | A second way to add a Minecraft account that never contacts Microsoft or Mojang. |
|
| `0005-Make-the-sidebars-foldable` | A switch for the Modrinth Servers button, a news section that folds away, and a title bar button that folds the right sidebar away. |
|
||||||
|
| `0006-Add-Ely.by-accounts` | Sign in with Ely.by, launched through authlib-injector. |
|
||||||
|
| `0007-Sign-in-to-Microsoft-in-the-...` | Microsoft sign-in happens in your own browser instead of a webview, so your password manager works. |
|
||||||
|
| `0008-Round-the-window-corners-on-Linux` | The undecorated window gets rounded corners on Linux. |
|
||||||
|
| `0009-Scroll-with-the-middle-mouse-button` | Middle-click autoscroll on Linux and macOS, as browsers do it on Windows. |
|
||||||
|
| `0010-Manage-Ely.by-skins-from-...` | An Ely.by account's skins can be picked, uploaded, switched between models and deleted on the skin page. |
|
||||||
|
| `0011-Show-every-player-s-skin-...` | Players who have a skin show it on offline-mode servers, and skins can be put in a folder by hand. |
|
||||||
|
| `0012-Launch-a-running-instance-...` | A running instance can be started again on another account, with a console per copy. |
|
||||||
|
| `0013-Explain-what-went-wrong-...` | The Logs tab says what a crash was and offers a fix where there is one, without a connection. |
|
||||||
|
| `0014-Browse-skins-from-...` | A Browse tab on the skin page: Ely.by's catalogue in the app, and NameMC, laby.net and crafty.gg in a window. |
|
||||||
|
| `0015-Use-the-desktop-s-file-picker-...` | File pickers on Linux are the desktop's own, such as KDE's, through the XDG desktop portal. |
|
||||||
|
| `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. |
|
||||||
|
| `0024-Offer-the-launchers-instances-...` | Every launcher the importers read is offered, with the folder it keeps instances in. |
|
||||||
|
| `0025-Arrange-the-left-sidebar` | The buttons in the left sidebar are reordered and hidden in a settings tab of their own. |
|
||||||
|
| `0026-Delete-an-instance-from-the-sidebar` | Right-clicking an instance in the sidebar can delete it. |
|
||||||
|
|
||||||
### 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.
|
||||||
|
|
||||||
Microsoft sign-in is untouched and still the default.
|
Offline, Ely.by and custom server accounts sit next to Microsoft everywhere an account can be
|
||||||
|
added: the account card, the title bar menu, the modal you get when pressing Play with no account,
|
||||||
|
and "Sign in to Minecraft" in the getting started checklist. Upstream offered Microsoft and nothing
|
||||||
|
else.
|
||||||
|
|
||||||
|
### Microsoft sign-in
|
||||||
|
|
||||||
|
Microsoft sign-in opens your own browser rather than a webview inside the launcher, so your
|
||||||
|
password manager, autofill and passkeys work, and you can see in the address bar that the page is
|
||||||
|
really Microsoft's.
|
||||||
|
|
||||||
|
The client id the launcher uses is Minecraft's own, with no redirect the launcher could listen on.
|
||||||
|
So the browser signs in on Microsoft's device code page, with the code already filled in, while the
|
||||||
|
launcher asks Microsoft every few seconds whether that has happened. Once it has, the account is
|
||||||
|
added and the launcher comes back to the front, without anything to paste. The window inside the
|
||||||
|
launcher is still one click away in the dialog, for when the browser does not work out.
|
||||||
|
|
||||||
|
### Ely.by accounts
|
||||||
|
|
||||||
|
"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
|
||||||
|
colon, which is Ely.by's own convention.
|
||||||
|
|
||||||
|
At launch the game is pointed at Ely.by with
|
||||||
|
[authlib-injector](https://github.com/yushijinhun/authlib-injector), downloaded once and cached, so
|
||||||
|
such an account can play singleplayer and join any server that accepts Ely.by.
|
||||||
|
|
||||||
|
The account is stored in the same table as every other one, marked by the client token Ely.by
|
||||||
|
issues; the token pair is checked and renewed against Ely.by a few times a day rather than on every
|
||||||
|
read of the account list.
|
||||||
|
|
||||||
|
Signing in on Ely.by's own page instead of in this form would be better, and needs an OAuth
|
||||||
|
application registered with Ely.by — one has not been registered for Modrinth Enhanced.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
Settings > Sidebar has the buttons above the instances — Home, Discover, Skins, Screenshots and
|
||||||
|
Modrinth Hosting — in a list: move one up or down, hide it, or put the usual order back.
|
||||||
|
Right-clicking one of them in the sidebar hides it or opens that tab. Where it all sits is kept on
|
||||||
|
this machine, since it is about how the app looks here.
|
||||||
|
|
||||||
|
Right-clicking an instance in the sidebar plays or stops it, opens it or its folder, and deletes it,
|
||||||
|
which asks first and is refused while the instance is running.
|
||||||
|
|
||||||
|
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. Upstream shows that button only once "Hide right
|
||||||
|
sidebar" is turned on in settings; here it is always there and that setting is gone. Both remember what they were set to across restarts, and the
|
||||||
|
title bar button briefly shows a check once its state is saved; the first time, a short note
|
||||||
|
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
|
||||||
|
directions and these are not in it, so they are neither sent to your Modrinth account nor
|
||||||
|
overwritten by another device.
|
||||||
|
|
||||||
|
### Window
|
||||||
|
|
||||||
|
On Linux the window has rounded corners while it floats. Maximized, fullscreen or with native
|
||||||
|
decorations turned on, it is square as before. The window is created transparent for this, which
|
||||||
|
needs a compositor; without one the corners show black.
|
||||||
|
|
||||||
|
A click with the middle mouse button on anything that scrolls starts autoscroll: press and release
|
||||||
|
to scroll until the next click, or hold and drag to scroll until you let go. Links and text fields
|
||||||
|
keep their middle-click. Windows is left alone, since WebView2 autoscrolls by itself.
|
||||||
|
|
||||||
|
File pickers on Linux go through the XDG desktop portal, so KDE shows its own dialog and GNOME its
|
||||||
|
own, instead of a GTK dialog the AppImage themes as light Adwaita. Without a portal, GTK's dialog is
|
||||||
|
used as before. Windows and macOS already use their native pickers.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
With an Ely.by account selected, the skin page shows the account's skins on Ely.by: apply one, add
|
||||||
|
one from a file, switch its model or delete it. Ely.by has no API for changing skins, so the
|
||||||
|
launcher makes the website's own calls from a hidden window and asks you to sign in there once.
|
||||||
|
|
||||||
|
On servers that send no skins, such as offline-mode servers, the game looks each player's skin up by
|
||||||
|
name: first in the `player_skins` folder in the launcher directory (`<name>.png`, `<name>-slim.png`,
|
||||||
|
`capes/`, `elytras/`), then Ely.by, then Mojang, with capes from OptiFine. Settings > Features > Skins
|
||||||
|
has the switch and a button that opens the folder.
|
||||||
|
|
||||||
|
The Browse tab finds skins elsewhere. Ely.by's catalogue is browsed in the app, with its sorting,
|
||||||
|
filters and like, wearer and view counts, 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
|
||||||
|
you open there is previewed and can be added.
|
||||||
|
|
||||||
|
### Instances and crashes
|
||||||
|
|
||||||
|
Importing instances offers every launcher the importers read — Prism Launcher, MultiMC, PolyMC,
|
||||||
|
ATLauncher, GDLauncher and CurseForge — whether they are installed or not, each with the folder it
|
||||||
|
keeps its instances in on this system, Flatpak and macOS bundles included, and each with its own
|
||||||
|
logo. Upstream listed only the launchers it found, and left the rest to a path typed in by hand.
|
||||||
|
|
||||||
|
A running instance can be started again from the button next to Stop, as whichever account is
|
||||||
|
selected, and the Logs tab then shows a console for each copy.
|
||||||
|
|
||||||
|
After a crash the Logs tab reads the crash report, the JVM error file and the end of the log, and
|
||||||
|
says what went wrong: out of memory, the wrong Java, missing or duplicate mods and more.
|
||||||
|
|
||||||
|
Ely.by skin management, skins for every player, second copies and crash explanations are adapted
|
||||||
|
from [Noctrinth](https://github.com/Everelsu/Noctrinth).
|
||||||
|
|
||||||
|
### Modrinth+
|
||||||
|
|
||||||
|
Nothing in the app is gated behind Modrinth+. In upstream it decides whether the ad slot, the
|
||||||
|
consent prompt and the two "Upgrade to Modrinth+" prompts are shown, and nothing else — so removing
|
||||||
|
the advertising is the whole of it, and there is nothing further to unlock from here. Badges and
|
||||||
|
everything else a subscription buys are decided on Modrinth's servers.
|
||||||
|
|
||||||
### What is *not* removed
|
### What is *not* removed
|
||||||
|
|
||||||
@@ -41,6 +220,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
|
||||||
@@ -62,6 +249,13 @@ scripts/check.sh # assert the patches still do what they claim
|
|||||||
scripts/build.sh # build installers into build/artifacts
|
scripts/build.sh # build installers into build/artifacts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`FAST=1 scripts/build.sh` builds with thin link-time optimization instead of the full one, which
|
||||||
|
roughly halves the Rust build at the cost of a few megabytes. Releases are built without it.
|
||||||
|
|
||||||
|
`DEV=1 scripts/build.sh` builds "Modrinth Enhanced (dev)" instead, under the bundle identifier
|
||||||
|
`ModrinthAppDev`. It has a data directory of its own and no updater, so it can be installed and run
|
||||||
|
beside a release without sharing instances, accounts or settings with it.
|
||||||
|
|
||||||
`build/` is scratch space and is never committed.
|
`build/` is scratch space and is never committed.
|
||||||
|
|
||||||
## Working on the patches
|
## Working on the patches
|
||||||
@@ -77,15 +271,12 @@ cd ../..
|
|||||||
scripts/export-patches.sh # rewrite patches/ from those commits
|
scripts/export-patches.sh # rewrite patches/ from those commits
|
||||||
```
|
```
|
||||||
|
|
||||||
The icons are the one thing that is generated rather than written. Edit
|
Patches are applied to the release they were exported against (`patches/base.txt`), where they
|
||||||
`build/upstream/apps/app/icons/modrinth-enhanced.svg`, run `scripts/render-icons.py`, and every PNG,
|
always fit, and then rebased onto the release in `upstream.txt` when that is newer. The rebase
|
||||||
`.ico` and `.icns` next to it is rewritten from that source; rendering the unchanged source again
|
merges against the files the patches were written for, so upstream changes near a hunk resolve by
|
||||||
reproduces the current files byte for byte.
|
themselves. When one genuinely conflicts, `scripts/prepare.sh` stops with the rebase in progress in
|
||||||
|
`build/upstream`, to be resolved with `git rebase --continue`, after which
|
||||||
Patches are applied with `git am --3way`, so small upstream movements around a hunk resolve by
|
`scripts/export-patches.sh` writes the fixed series back against the new release.
|
||||||
themselves. When one genuinely conflicts, `scripts/prepare.sh` stops and leaves the conflict staged
|
|
||||||
in `build/upstream` to be resolved with `git am --continue`, after which `scripts/export-patches.sh`
|
|
||||||
writes the fixed series back.
|
|
||||||
|
|
||||||
To move to a newer upstream release:
|
To move to a newer upstream release:
|
||||||
|
|
||||||
@@ -100,9 +291,18 @@ scripts/prepare.sh
|
|||||||
reusable workflow the release job calls. It applies the patches, checks them, and builds on
|
reusable workflow the release job calls. It applies the patches, checks them, and builds on
|
||||||
Linux, Windows and macOS.
|
Linux, Windows and macOS.
|
||||||
- **Upstream release** (`.github/workflows/upstream-release.yml`) runs daily. If Modrinth has
|
- **Upstream release** (`.github/workflows/upstream-release.yml`) runs daily. If Modrinth has
|
||||||
published a newer release than `upstream.txt`, it rebuilds against it and — only if every
|
published a newer release than `upstream.txt`, it rebases the patches onto it, rebuilds and — only if every
|
||||||
platform built and every check passed — commits the bump, tags it with the upstream version and
|
platform built and every check passed — commits the bump, tags it with the upstream version and
|
||||||
publishes a release with the installers.
|
publishes a release with the installers. Its notes are the commits since the last release, with
|
||||||
|
the patch list folded away underneath.
|
||||||
|
- **Revisions** come from the same run: when `patches/` or `scripts/` changed since the last
|
||||||
|
release of that upstream version, it is released again as `v0.21.2-2`, `v0.21.2-3` and so on.
|
||||||
|
The app and installers still carry the upstream version: RPM and the Windows installers do not
|
||||||
|
accept a suffix in it.
|
||||||
|
- **Updates** are published with every release as `latest.json`, next to installers signed with the
|
||||||
|
`TAURI_SIGNING_PRIVATE_KEY` secret. A release without signatures fails instead of shipping, since
|
||||||
|
every install it reached could never update again. Builds without the secret, such as pull
|
||||||
|
requests from forks, have no updater.
|
||||||
|
|
||||||
`scripts/check.sh` is what makes the automation trustworthy. A patch can apply cleanly and still
|
`scripts/check.sh` is what makes the automation trustworthy. A patch can apply cleanly and still
|
||||||
stop doing its job if upstream moves the thing it was holding down, so the checks assert the
|
stop doing its job if upstream moves the thing it was holding down, so the checks assert the
|
||||||
|
|||||||
@@ -4,22 +4,23 @@ Date: Mon, 14 Sep 2026 09:56:18 +0200
|
|||||||
Subject: [PATCH] Rename the app to Modrinth Enhanced
|
Subject: [PATCH] Rename the app to Modrinth Enhanced
|
||||||
|
|
||||||
Changes the product name, the binary name, the window title and the
|
Changes the product name, the binary name, the window title and the
|
||||||
version label shown in settings. A small "Enhanced" pill is added next
|
version label shown in settings.
|
||||||
to the wordmark in the title bar so the fork is recognisable at a
|
|
||||||
glance.
|
The title bar keeps the plain Modrinth wordmark: a badge next to it
|
||||||
|
pushes the width of that bar up, and the name is already visible in the
|
||||||
|
window title and in settings.
|
||||||
|
|
||||||
The bundle identifier is deliberately left as `ModrinthApp` so that
|
The bundle identifier is deliberately left as `ModrinthApp` so that
|
||||||
Modrinth Enhanced keeps using the same data directory as the official
|
Modrinth Enhanced keeps using the same data directory as the official
|
||||||
app and stays a drop-in replacement for it.
|
app and stays a drop-in replacement for it.
|
||||||
---
|
---
|
||||||
apps/app-frontend/index.html | 2 +-
|
apps/app-frontend/index.html | 2 +-
|
||||||
apps/app-frontend/src/App.vue | 5 +++++
|
|
||||||
.../src/components/ui/modal/AppSettingsModal.vue | 2 +-
|
.../src/components/ui/modal/AppSettingsModal.vue | 2 +-
|
||||||
apps/app-frontend/src/locales/en-US/index.json | 2 +-
|
apps/app-frontend/src/locales/en-US/index.json | 2 +-
|
||||||
apps/app/tauri.conf.json | 6 +++---
|
apps/app/tauri.conf.json | 6 +++---
|
||||||
apps/app/tauri.linux.conf.json | 2 +-
|
apps/app/tauri.linux.conf.json | 2 +-
|
||||||
apps/app/tauri.macos.conf.json | 2 +-
|
apps/app/tauri.macos.conf.json | 2 +-
|
||||||
7 files changed, 13 insertions(+), 8 deletions(-)
|
6 files changed, 8 insertions(+), 8 deletions(-)
|
||||||
|
|
||||||
diff --git a/apps/app-frontend/index.html b/apps/app-frontend/index.html
|
diff --git a/apps/app-frontend/index.html b/apps/app-frontend/index.html
|
||||||
index 50867a4..6367788 100644
|
index 50867a4..6367788 100644
|
||||||
@@ -34,24 +35,8 @@ index 50867a4..6367788 100644
|
|||||||
|
|
||||||
<link rel="stylesheet" href="/src/assets/stylesheets/global.scss" />
|
<link rel="stylesheet" href="/src/assets/stylesheets/global.scss" />
|
||||||
</head>
|
</head>
|
||||||
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
|
|
||||||
index a2ae0e6..1d1a9ce 100644
|
|
||||||
--- a/apps/app-frontend/src/App.vue
|
|
||||||
+++ b/apps/app-frontend/src/App.vue
|
|
||||||
@@ -2374,6 +2374,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|
||||||
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
|
|
||||||
<div data-tauri-drag-region class="flex min-w-0 flex-1 items-center overflow-hidden p-2">
|
|
||||||
<TextLogo class="h-7 w-auto shrink-0 text-contrast pointer-events-none" />
|
|
||||||
+ <span
|
|
||||||
+ data-tauri-drag-region
|
|
||||||
+ class="ml-1.5 shrink-0 rounded-full bg-brand-highlight px-1.5 py-0.5 text-[0.625rem] font-bold uppercase leading-none tracking-wide text-brand pointer-events-none"
|
|
||||||
+ >Enhanced</span
|
|
||||||
+ >
|
|
||||||
<div data-tauri-drag-region class="ml-2 flex shrink-0 items-center gap-2">
|
|
||||||
<IconButton
|
|
||||||
type="outlined"
|
|
||||||
diff --git a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue b/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue
|
diff --git a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue b/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue
|
||||||
index dd55fe5..0f33d0e 100644
|
index 5f863d5..32a86a3 100644
|
||||||
--- a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue
|
--- a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue
|
||||||
+++ b/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue
|
+++ b/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue
|
||||||
@@ -312,7 +312,7 @@ const messages = defineMessages({
|
@@ -312,7 +312,7 @@ const messages = defineMessages({
|
||||||
@@ -64,10 +49,10 @@ index dd55fe5..0f33d0e 100644
|
|||||||
macos: {
|
macos: {
|
||||||
id: 'app.settings.operating-system.macos',
|
id: 'app.settings.operating-system.macos',
|
||||||
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
|
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
|
||||||
index cc65180..faab078 100644
|
index 8f3a5d0..721dd32 100644
|
||||||
--- a/apps/app-frontend/src/locales/en-US/index.json
|
--- a/apps/app-frontend/src/locales/en-US/index.json
|
||||||
+++ b/apps/app-frontend/src/locales/en-US/index.json
|
+++ b/apps/app-frontend/src/locales/en-US/index.json
|
||||||
@@ -1548,7 +1548,7 @@
|
@@ -1626,7 +1626,7 @@
|
||||||
"message": "Oldest"
|
"message": "Oldest"
|
||||||
},
|
},
|
||||||
"app.settings.app-version": {
|
"app.settings.app-version": {
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Mon, 14 Sep 2026 10:18:37 +0200
|
||||||
|
Subject: [PATCH] Remove advertising and Modrinth+ upsells
|
||||||
|
|
||||||
|
The sidebar ad slot, the ad cookie consent prompt and both "Upgrade to
|
||||||
|
Modrinth+" prompts are gone:
|
||||||
|
|
||||||
|
* `showAd` and `adConsentAvailable` are pinned to false, which takes the
|
||||||
|
ad slot and the consent prompt out of the layout.
|
||||||
|
* The upgrade link above the ad slot and the entry in the account menu
|
||||||
|
are removed outright rather than left behind a false condition.
|
||||||
|
* The sidebar keeps upstream's `has-plus` class unconditionally, which
|
||||||
|
is what stops space being reserved at the bottom for an ad.
|
||||||
|
* With no upsell left to decide about, the request that asked Modrinth
|
||||||
|
whether this account has Modrinth+ goes too.
|
||||||
|
|
||||||
|
The helpers in `helpers/ads.js` are additionally stubbed out, which
|
||||||
|
stops the Tauri `ads` plugin from ever being asked to spawn the ad
|
||||||
|
webview, no matter which call site reaches for it.
|
||||||
|
|
||||||
|
Nothing else in the app is gated behind Modrinth+: it decided whether
|
||||||
|
ads and these prompts were shown, and nothing more.
|
||||||
|
---
|
||||||
|
apps/app-frontend/src/App.vue | 55 ++++-------------------
|
||||||
|
apps/app-frontend/src/helpers/ads.js | 65 +++++++++-------------------
|
||||||
|
2 files changed, 29 insertions(+), 91 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
|
||||||
|
index 564be92..4cd09f9 100644
|
||||||
|
--- a/apps/app-frontend/src/App.vue
|
||||||
|
+++ b/apps/app-frontend/src/App.vue
|
||||||
|
@@ -9,7 +9,6 @@ import {
|
||||||
|
VerboseLoggingFeature,
|
||||||
|
} from '@modrinth/api-client'
|
||||||
|
import {
|
||||||
|
- ArrowBigUpDashIcon,
|
||||||
|
ArrowLeftRightIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
@@ -94,7 +93,6 @@ import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||||
|
import NavButton from '@/components/ui/NavButton.vue'
|
||||||
|
import OnboardingChecklist from '@/components/ui/onboarding-checklist/index.vue'
|
||||||
|
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
|
||||||
|
-import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||||
|
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||||
|
import SharedInstanceInviteHandler from '@/components/ui/shared-instances/shared-instance-invite-handler/index.vue'
|
||||||
|
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||||
|
@@ -159,7 +157,6 @@ import {
|
||||||
|
syncedServersQueryOptions,
|
||||||
|
} from '@/helpers/synced-options'
|
||||||
|
import { syncedPackQueryOptions } from '@/helpers/synced-packs'
|
||||||
|
-import { hasActivePride26Midas, hasMidasBadge } from '@/helpers/user-campaigns.ts'
|
||||||
|
import { get_user_preferences } from '@/helpers/user-preferences.ts'
|
||||||
|
import { parse_modrinth_user_link } from '@/helpers/users'
|
||||||
|
import {
|
||||||
|
@@ -344,12 +341,6 @@ const tauriApiClient = new TauriModrinthClient({
|
||||||
|
],
|
||||||
|
})
|
||||||
|
provideModrinthClient(tauriApiClient)
|
||||||
|
-const { data: authenticatedModrinthUser } = useQuery({
|
||||||
|
- queryKey: computed(() => ['authenticated-user', 'campaigns', credentials.value?.user?.id]),
|
||||||
|
- queryFn: () => tauriApiClient.labrinth.users_v3.getAuthenticated(),
|
||||||
|
- enabled: () => !!credentials.value?.session,
|
||||||
|
- retry: false,
|
||||||
|
-})
|
||||||
|
useQuery({
|
||||||
|
queryKey: computed(() => instanceKeys.sharedEligibility(credentials.value?.user?.id)),
|
||||||
|
queryFn: can_current_user_use_shared_instances,
|
||||||
|
@@ -360,16 +351,13 @@ useQuery({
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
})
|
||||||
|
-const hasPlus = computed(
|
||||||
|
- () =>
|
||||||
|
- !!credentials.value?.user &&
|
||||||
|
- (hasMidasBadge(credentials.value.user) ||
|
||||||
|
- hasActivePride26Midas(authenticatedModrinthUser.value?.campaigns?.pride_26)),
|
||||||
|
-)
|
||||||
|
-const showAd = computed(
|
||||||
|
- () => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
|
||||||
|
-)
|
||||||
|
-const adConsentAvailable = computed(() => credentials.value !== undefined && !hasPlus.value)
|
||||||
|
+// Modrinth Enhanced ships without advertising, so the sidebar ad slot and the
|
||||||
|
+// ad cookie consent flow that only exists to serve it are both switched off.
|
||||||
|
+// The sidebar keeps upstream's `has-plus` class unconditionally for the same
|
||||||
|
+// reason: that class is what stops space being reserved at the bottom for an
|
||||||
|
+// ad and the gradient being drawn above it.
|
||||||
|
+const showAd = computed(() => false)
|
||||||
|
+const adConsentAvailable = computed(() => false)
|
||||||
|
providePageContext({
|
||||||
|
hierarchicalSidebarAvailable: ref(true),
|
||||||
|
showAds: showAd,
|
||||||
|
@@ -694,10 +682,6 @@ const messages = defineMessages({
|
||||||
|
id: 'app.restarting',
|
||||||
|
defaultMessage: 'Restarting...',
|
||||||
|
},
|
||||||
|
- upgradeToModrinthPlus: {
|
||||||
|
- id: 'app.nav.upgrade-to-modrinth-plus',
|
||||||
|
- defaultMessage: 'Upgrade to Modrinth+',
|
||||||
|
- },
|
||||||
|
news: {
|
||||||
|
id: 'app.news.title',
|
||||||
|
defaultMessage: 'News',
|
||||||
|
@@ -1548,16 +1532,6 @@ const modrinthAccountMenuOptions = computed(() => [
|
||||||
|
icon: UserIcon,
|
||||||
|
action: () => router.push(`/user/${encodeURIComponent(credentials.value.user.username)}`),
|
||||||
|
},
|
||||||
|
- {
|
||||||
|
- id: 'plus',
|
||||||
|
- label: formatMessage(messages.upgradeToModrinthPlus),
|
||||||
|
- icon: ArrowBigUpDashIcon,
|
||||||
|
- type: 'link',
|
||||||
|
- href: 'https://modrinth.plus?app',
|
||||||
|
- target: '_blank',
|
||||||
|
- tone: 'purple',
|
||||||
|
- shown: !hasPlus.value,
|
||||||
|
- },
|
||||||
|
{
|
||||||
|
id: 'add-friend',
|
||||||
|
label: formatMessage(messages.addFriend),
|
||||||
|
@@ -2503,13 +2477,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||||
|
</RouterView>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
- class="app-sidebar mt-px shrink-0 flex flex-col border-0 border-l-[1px] border-[--brand-gradient-border] border-solid"
|
||||||
|
- :class="{ 'has-plus': hasPlus }"
|
||||||
|
+ class="app-sidebar mt-px shrink-0 flex flex-col border-0 border-l-[1px] border-[--brand-gradient-border] border-solid has-plus"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-overlay-scrollbars="sidebarOverlayScrollbarsOptions"
|
||||||
|
class="app-sidebar-scrollable flex-grow shrink relative"
|
||||||
|
- :class="{ 'pb-12': !hasPlus }"
|
||||||
|
data-overlayscrollbars-initialize
|
||||||
|
>
|
||||||
|
<OnboardingChecklist
|
||||||
|
@@ -2571,17 +2543,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
- <template v-if="showAd">
|
||||||
|
- <a
|
||||||
|
- href="https://modrinth.plus?app"
|
||||||
|
- class="absolute bottom-[250px] w-full flex justify-center items-center gap-1 px-4 py-3 text-purple font-medium hover:underline z-10"
|
||||||
|
- target="_blank"
|
||||||
|
- >
|
||||||
|
- <ArrowBigUpDashIcon class="text-2xl" />
|
||||||
|
- {{ formatMessage(messages.upgradeToModrinthPlus) }}
|
||||||
|
- </a>
|
||||||
|
- <PromotionWrapper />
|
||||||
|
- </template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<I18nDebugPanel />
|
||||||
|
diff --git a/apps/app-frontend/src/helpers/ads.js b/apps/app-frontend/src/helpers/ads.js
|
||||||
|
index 8c85970..96005e6 100644
|
||||||
|
--- a/apps/app-frontend/src/helpers/ads.js
|
||||||
|
+++ b/apps/app-frontend/src/helpers/ads.js
|
||||||
|
@@ -1,55 +1,32 @@
|
||||||
|
-import { invoke } from '@tauri-apps/api/core'
|
||||||
|
+/**
|
||||||
|
+ * Modrinth Enhanced does not show advertising.
|
||||||
|
+ *
|
||||||
|
+ * Upstream these helpers forward to the Tauri `ads` plugin, which spawns a
|
||||||
|
+ * second webview that loads the ad network and tracks clicks on it. Every
|
||||||
|
+ * helper below is a no-op instead, so the plugin is never asked to create
|
||||||
|
+ * that webview and the consent flow that exists purely for ad cookies never
|
||||||
|
+ * has anything to consent to.
|
||||||
|
+ *
|
||||||
|
+ * The functions are kept — rather than removed along with their callers — so
|
||||||
|
+ * that upstream call sites keep working unchanged.
|
||||||
|
+ */
|
||||||
|
|
||||||
|
-export async function init_ads_window(overrideShown = false) {
|
||||||
|
- return await invoke('plugin:ads|init_ads_window', {
|
||||||
|
- overrideShown,
|
||||||
|
- dpr: window.devicePixelRatio,
|
||||||
|
- })
|
||||||
|
-}
|
||||||
|
-
|
||||||
|
-let adsWindowHoldUpdate = Promise.resolve()
|
||||||
|
-
|
||||||
|
-async function update_ads_window_hold(acquire) {
|
||||||
|
- adsWindowHoldUpdate = adsWindowHoldUpdate
|
||||||
|
- .catch(() => {})
|
||||||
|
- .then(() =>
|
||||||
|
- invoke('plugin:ads|update_ads_window_hold', {
|
||||||
|
- acquire,
|
||||||
|
- dpr: window.devicePixelRatio,
|
||||||
|
- }),
|
||||||
|
- )
|
||||||
|
+export async function init_ads_window() {}
|
||||||
|
|
||||||
|
- return await adsWindowHoldUpdate
|
||||||
|
-}
|
||||||
|
-
|
||||||
|
-export async function take_ads_window_hold() {
|
||||||
|
- return await update_ads_window_hold(true)
|
||||||
|
-}
|
||||||
|
+export async function take_ads_window_hold() {}
|
||||||
|
|
||||||
|
-export async function release_ads_window_hold() {
|
||||||
|
- return await update_ads_window_hold(false)
|
||||||
|
-}
|
||||||
|
+export async function release_ads_window_hold() {}
|
||||||
|
|
||||||
|
-export async function hide_ads_window(reset) {
|
||||||
|
- return await invoke('plugin:ads|hide_ads_window', { reset })
|
||||||
|
-}
|
||||||
|
+export async function hide_ads_window() {}
|
||||||
|
|
||||||
|
export async function should_show_ads_consent_popup() {
|
||||||
|
- return await invoke('plugin:ads|should_show_ads_consent_popup')
|
||||||
|
+ return false
|
||||||
|
}
|
||||||
|
|
||||||
|
-export async function perform_ads_consent_action(action) {
|
||||||
|
- return await invoke('plugin:ads|perform_ads_consent_action', { action })
|
||||||
|
-}
|
||||||
|
+export async function perform_ads_consent_action() {}
|
||||||
|
|
||||||
|
-export async function open_ads_consent_preferences() {
|
||||||
|
- return await invoke('plugin:ads|open_ads_consent_preferences')
|
||||||
|
-}
|
||||||
|
+export async function open_ads_consent_preferences() {}
|
||||||
|
|
||||||
|
-export async function record_ads_click() {
|
||||||
|
- return await invoke('plugin:ads|record_ads_click')
|
||||||
|
-}
|
||||||
|
+export async function record_ads_click() {}
|
||||||
|
|
||||||
|
-export async function open_ads_link(path, origin) {
|
||||||
|
- return await invoke('plugin:ads|open_link', { path, origin })
|
||||||
|
-}
|
||||||
|
+export async function open_ads_link() {}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,117 +0,0 @@
|
|||||||
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
|
||||||
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
|
||||||
Date: Mon, 14 Sep 2026 10:18:37 +0200
|
|
||||||
Subject: [PATCH] Remove advertising
|
|
||||||
|
|
||||||
The sidebar ad slot, the "Upgrade to Modrinth+" nag above it and the ad
|
|
||||||
cookie consent prompt are all driven by two computed flags in App.vue,
|
|
||||||
so pinning both to false takes the whole surface out of the layout.
|
|
||||||
|
|
||||||
The helpers in `helpers/ads.js` are additionally stubbed out, which
|
|
||||||
stops the Tauri `ads` plugin from ever being asked to spawn the ad
|
|
||||||
webview, no matter which call site reaches for it.
|
|
||||||
---
|
|
||||||
apps/app-frontend/src/App.vue | 8 ++--
|
|
||||||
apps/app-frontend/src/helpers/ads.js | 65 +++++++++-------------------
|
|
||||||
2 files changed, 25 insertions(+), 48 deletions(-)
|
|
||||||
|
|
||||||
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
|
|
||||||
index 1d1a9ce..4df835f 100644
|
|
||||||
--- a/apps/app-frontend/src/App.vue
|
|
||||||
+++ b/apps/app-frontend/src/App.vue
|
|
||||||
@@ -366,10 +366,10 @@ const hasPlus = computed(
|
|
||||||
(hasMidasBadge(credentials.value.user) ||
|
|
||||||
hasActivePride26Midas(authenticatedModrinthUser.value?.campaigns?.pride_26)),
|
|
||||||
)
|
|
||||||
-const showAd = computed(
|
|
||||||
- () => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
|
|
||||||
-)
|
|
||||||
-const adConsentAvailable = computed(() => credentials.value !== undefined && !hasPlus.value)
|
|
||||||
+// Modrinth Enhanced ships without advertising, so the sidebar ad slot and the
|
|
||||||
+// ad cookie consent flow that only exists to serve it are both switched off.
|
|
||||||
+const showAd = computed(() => false)
|
|
||||||
+const adConsentAvailable = computed(() => false)
|
|
||||||
providePageContext({
|
|
||||||
hierarchicalSidebarAvailable: ref(true),
|
|
||||||
showAds: showAd,
|
|
||||||
diff --git a/apps/app-frontend/src/helpers/ads.js b/apps/app-frontend/src/helpers/ads.js
|
|
||||||
index 8c85970..96005e6 100644
|
|
||||||
--- a/apps/app-frontend/src/helpers/ads.js
|
|
||||||
+++ b/apps/app-frontend/src/helpers/ads.js
|
|
||||||
@@ -1,55 +1,32 @@
|
|
||||||
-import { invoke } from '@tauri-apps/api/core'
|
|
||||||
+/**
|
|
||||||
+ * Modrinth Enhanced does not show advertising.
|
|
||||||
+ *
|
|
||||||
+ * Upstream these helpers forward to the Tauri `ads` plugin, which spawns a
|
|
||||||
+ * second webview that loads the ad network and tracks clicks on it. Every
|
|
||||||
+ * helper below is a no-op instead, so the plugin is never asked to create
|
|
||||||
+ * that webview and the consent flow that exists purely for ad cookies never
|
|
||||||
+ * has anything to consent to.
|
|
||||||
+ *
|
|
||||||
+ * The functions are kept — rather than removed along with their callers — so
|
|
||||||
+ * that upstream call sites keep working unchanged.
|
|
||||||
+ */
|
|
||||||
|
|
||||||
-export async function init_ads_window(overrideShown = false) {
|
|
||||||
- return await invoke('plugin:ads|init_ads_window', {
|
|
||||||
- overrideShown,
|
|
||||||
- dpr: window.devicePixelRatio,
|
|
||||||
- })
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-let adsWindowHoldUpdate = Promise.resolve()
|
|
||||||
-
|
|
||||||
-async function update_ads_window_hold(acquire) {
|
|
||||||
- adsWindowHoldUpdate = adsWindowHoldUpdate
|
|
||||||
- .catch(() => {})
|
|
||||||
- .then(() =>
|
|
||||||
- invoke('plugin:ads|update_ads_window_hold', {
|
|
||||||
- acquire,
|
|
||||||
- dpr: window.devicePixelRatio,
|
|
||||||
- }),
|
|
||||||
- )
|
|
||||||
+export async function init_ads_window() {}
|
|
||||||
|
|
||||||
- return await adsWindowHoldUpdate
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-export async function take_ads_window_hold() {
|
|
||||||
- return await update_ads_window_hold(true)
|
|
||||||
-}
|
|
||||||
+export async function take_ads_window_hold() {}
|
|
||||||
|
|
||||||
-export async function release_ads_window_hold() {
|
|
||||||
- return await update_ads_window_hold(false)
|
|
||||||
-}
|
|
||||||
+export async function release_ads_window_hold() {}
|
|
||||||
|
|
||||||
-export async function hide_ads_window(reset) {
|
|
||||||
- return await invoke('plugin:ads|hide_ads_window', { reset })
|
|
||||||
-}
|
|
||||||
+export async function hide_ads_window() {}
|
|
||||||
|
|
||||||
export async function should_show_ads_consent_popup() {
|
|
||||||
- return await invoke('plugin:ads|should_show_ads_consent_popup')
|
|
||||||
+ return false
|
|
||||||
}
|
|
||||||
|
|
||||||
-export async function perform_ads_consent_action(action) {
|
|
||||||
- return await invoke('plugin:ads|perform_ads_consent_action', { action })
|
|
||||||
-}
|
|
||||||
+export async function perform_ads_consent_action() {}
|
|
||||||
|
|
||||||
-export async function open_ads_consent_preferences() {
|
|
||||||
- return await invoke('plugin:ads|open_ads_consent_preferences')
|
|
||||||
-}
|
|
||||||
+export async function open_ads_consent_preferences() {}
|
|
||||||
|
|
||||||
-export async function record_ads_click() {
|
|
||||||
- return await invoke('plugin:ads|record_ads_click')
|
|
||||||
-}
|
|
||||||
+export async function record_ads_click() {}
|
|
||||||
|
|
||||||
-export async function open_ads_link(path, origin) {
|
|
||||||
- return await invoke('plugin:ads|open_link', { path, origin })
|
|
||||||
-}
|
|
||||||
+export async function open_ads_link() {}
|
|
||||||
@@ -108,7 +108,7 @@ index 8773f8f..d31e8c5 100644
|
|||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between gap-4">
|
<div class="mt-4 flex items-center justify-between gap-4">
|
||||||
diff --git a/apps/app-frontend/src/helpers/analytics.ts b/apps/app-frontend/src/helpers/analytics.ts
|
diff --git a/apps/app-frontend/src/helpers/analytics.ts b/apps/app-frontend/src/helpers/analytics.ts
|
||||||
index b3d2c0a..c9f4846 100644
|
index 6118ac3..5ba7d5d 100644
|
||||||
--- a/apps/app-frontend/src/helpers/analytics.ts
|
--- a/apps/app-frontend/src/helpers/analytics.ts
|
||||||
+++ b/apps/app-frontend/src/helpers/analytics.ts
|
+++ b/apps/app-frontend/src/helpers/analytics.ts
|
||||||
@@ -1,4 +1,16 @@
|
@@ -1,4 +1,16 @@
|
||||||
@@ -129,7 +129,7 @@ index b3d2c0a..c9f4846 100644
|
|||||||
|
|
||||||
interface InstanceProperties {
|
interface InstanceProperties {
|
||||||
loader: string
|
loader: string
|
||||||
@@ -43,72 +55,13 @@ type AnalyticsEventMap = {
|
@@ -42,72 +54,13 @@ type AnalyticsEventMap = {
|
||||||
|
|
||||||
export type AnalyticsEvent = keyof AnalyticsEventMap
|
export type AnalyticsEvent = keyof AnalyticsEventMap
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ index b3d2c0a..c9f4846 100644
|
|||||||
|
|
||||||
type OptionalArgs<T> = Record<string, never> extends T ? [properties?: T] : [properties: T]
|
type OptionalArgs<T> = Record<string, never> extends T ? [properties?: T] : [properties: T]
|
||||||
|
|
||||||
@@ -116,10 +69,6 @@ export const trackEvent = <E extends AnalyticsEvent>(
|
@@ -115,10 +68,6 @@ export const trackEvent = <E extends AnalyticsEvent>(
|
||||||
eventName: E,
|
eventName: E,
|
||||||
...args: OptionalArgs<AnalyticsEventMap[E]>
|
...args: OptionalArgs<AnalyticsEventMap[E]>
|
||||||
) => {
|
) => {
|
||||||
@@ -302,10 +302,10 @@ index bb20aa9..46a9749 100644
|
|||||||
+ void router
|
+ void router
|
||||||
}
|
}
|
||||||
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
|
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
|
||||||
index faab078..233cac2 100644
|
index 721dd32..6c48b86 100644
|
||||||
--- a/apps/app-frontend/src/locales/en-US/index.json
|
--- a/apps/app-frontend/src/locales/en-US/index.json
|
||||||
+++ b/apps/app-frontend/src/locales/en-US/index.json
|
+++ b/apps/app-frontend/src/locales/en-US/index.json
|
||||||
@@ -2667,7 +2667,7 @@
|
@@ -2745,7 +2745,7 @@
|
||||||
"message": "Discord Rich Presence"
|
"message": "Discord Rich Presence"
|
||||||
},
|
},
|
||||||
"app.settings.privacy.telemetry.description": {
|
"app.settings.privacy.telemetry.description": {
|
||||||
@@ -335,7 +335,7 @@ index e69e6b7..e5bbabe 100644
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
diff --git a/packages/app-lib/src/api/instance/run.rs b/packages/app-lib/src/api/instance/run.rs
|
diff --git a/packages/app-lib/src/api/instance/run.rs b/packages/app-lib/src/api/instance/run.rs
|
||||||
index 25626c1..d638128 100644
|
index 415af27..4e46190 100644
|
||||||
--- a/packages/app-lib/src/api/instance/run.rs
|
--- a/packages/app-lib/src/api/instance/run.rs
|
||||||
+++ b/packages/app-lib/src/api/instance/run.rs
|
+++ b/packages/app-lib/src/api/instance/run.rs
|
||||||
@@ -4,13 +4,10 @@ use crate::state::{
|
@@ -4,13 +4,10 @@ use crate::state::{
|
||||||
@@ -352,7 +352,7 @@ index 25626c1..d638128 100644
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum QuickPlayType {
|
pub enum QuickPlayType {
|
||||||
@@ -232,54 +229,10 @@ async fn run_credentials(
|
@@ -242,54 +239,10 @@ async fn run_credentials(
|
||||||
mc_set_options.push(("fullscreen".to_string(), "true".to_string()));
|
mc_set_options.push(("fullscreen".to_string(), "true".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,7 +411,7 @@ index 25626c1..d638128 100644
|
|||||||
|
|
||||||
crate::minecraft_skins::flush_pending_skin_change().await?;
|
crate::minecraft_skins::flush_pending_skin_change().await?;
|
||||||
crate::launcher::launch_minecraft(
|
crate::launcher::launch_minecraft(
|
||||||
@@ -297,21 +250,6 @@ async fn run_credentials(
|
@@ -307,21 +260,6 @@ async fn run_credentials(
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +433,7 @@ index 25626c1..d638128 100644
|
|||||||
pub async fn kill(instance_id: &str) -> crate::Result<()> {
|
pub async fn kill(instance_id: &str) -> crate::Result<()> {
|
||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
let processes =
|
let processes =
|
||||||
@@ -373,13 +311,12 @@ pub async fn try_update_playtime_by_instance_id(
|
@@ -383,13 +321,12 @@ pub async fn try_update_playtime_by_instance_id(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+172
-36
@@ -21,32 +21,75 @@ data when they are opened elsewhere.
|
|||||||
|
|
||||||
Usernames are validated the way Mojang validates them: 3 to 16
|
Usernames are validated the way Mojang validates them: 3 to 16
|
||||||
characters of letters, numbers and underscores.
|
characters of letters, numbers and underscores.
|
||||||
|
|
||||||
|
Three places had to be opened up for any of this to be reachable, all of
|
||||||
|
which offered Microsoft and nothing else:
|
||||||
|
|
||||||
|
* The account card was hidden until a Microsoft account had been added,
|
||||||
|
which left offline sign-in unreachable for exactly the people who want
|
||||||
|
it.
|
||||||
|
* The "Minecraft required" modal - what you hit on pressing Play with no
|
||||||
|
account - now lists the alternatives too. It is no longer only about
|
||||||
|
something being required, so it is titled "Sign in to Minecraft".
|
||||||
|
* "Sign in to Minecraft" in the getting started checklist went straight
|
||||||
|
to Microsoft. It opens that same modal now, so every way in offers the
|
||||||
|
same choice.
|
||||||
---
|
---
|
||||||
Cargo.toml | 1 +
|
Cargo.lock | 1 +
|
||||||
.../src/components/ui/AccountsCard.vue | 25 ++++
|
apps/app-frontend/src/App.vue | 12 +-
|
||||||
|
.../src/components/ui/AccountsCard.vue | 30 ++++
|
||||||
.../src/components/ui/OfflineAccountModal.vue | 136 ++++++++++++++++++
|
.../src/components/ui/OfflineAccountModal.vue | 136 ++++++++++++++++++
|
||||||
|
.../MinecraftRequiredModal.vue | 30 +++-
|
||||||
apps/app-frontend/src/helpers/auth.js | 13 ++
|
apps/app-frontend/src/helpers/auth.js | 13 ++
|
||||||
|
apps/app/build.rs | 1 +
|
||||||
apps/app/src/api/auth.rs | 7 +
|
apps/app/src/api/auth.rs | 7 +
|
||||||
packages/app-lib/Cargo.toml | 1 +
|
packages/app-lib/Cargo.toml | 1 +
|
||||||
packages/app-lib/src/api/minecraft_auth.rs | 39 +++++
|
packages/app-lib/src/api/minecraft_auth.rs | 39 +++++
|
||||||
packages/app-lib/src/state/minecraft_auth.rs | 68 +++++++++
|
packages/app-lib/src/state/minecraft_auth.rs | 66 +++++++++
|
||||||
8 files changed, 290 insertions(+)
|
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.toml b/Cargo.toml
|
diff --git a/Cargo.lock b/Cargo.lock
|
||||||
index a4a779c..a85f576 100644
|
index a3ffa8f..e17ace5 100644
|
||||||
--- a/Cargo.toml
|
--- a/Cargo.lock
|
||||||
+++ b/Cargo.toml
|
+++ b/Cargo.lock
|
||||||
@@ -132,6 +132,7 @@ lz4_flex = { version = "0.11.5", default-features = false, features = [
|
@@ -12376,6 +12376,7 @@ dependencies = [
|
||||||
"std",
|
"indicatif",
|
||||||
] }
|
"itertools 0.14.0",
|
||||||
maxminddb = "0.26.0"
|
"json5",
|
||||||
+md-5 = "0.10.6"
|
+ "md5",
|
||||||
modrinth-content-management = { path = "packages/modrinth-content-management" }
|
"modrinth-content-management",
|
||||||
modrinth-log = { path = "packages/modrinth-log" }
|
"notify",
|
||||||
modrinth-util = { path = "packages/modrinth-util" }
|
"notify-debouncer-mini",
|
||||||
|
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
|
||||||
|
index 4cd09f9..c5d4703 100644
|
||||||
|
--- a/apps/app-frontend/src/App.vue
|
||||||
|
+++ b/apps/app-frontend/src/App.vue
|
||||||
|
@@ -2486,15 +2486,17 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||||
|
>
|
||||||
|
<OnboardingChecklist
|
||||||
|
@create-instance="installationModal?.show()"
|
||||||
|
- @login-minecraft="accounts?.login()"
|
||||||
|
+ @login-minecraft="minecraftRequiredModal?.show()"
|
||||||
|
@login-modrinth="signIn"
|
||||||
|
/>
|
||||||
|
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
|
||||||
|
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
|
||||||
|
- <div
|
||||||
|
- v-show="hasLoggedIntoMinecraft"
|
||||||
|
- class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
|
||||||
|
- >
|
||||||
|
+ <!--
|
||||||
|
+ Upstream hides this until a Microsoft account has been added, which
|
||||||
|
+ would leave the offline sign-in below it unreachable for exactly the
|
||||||
|
+ people who want it.
|
||||||
|
+ -->
|
||||||
|
+ <div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||||
|
<h3 class="text-base text-primary font-medium m-0">
|
||||||
|
{{ formatMessage(messages.playingAs) }}
|
||||||
|
</h3>
|
||||||
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||||
index 70cc46e..e695a6d 100644
|
index 70cc46e..ebc92c1 100644
|
||||||
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
|
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||||
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||||
@@ -9,6 +9,10 @@
|
@@ -9,6 +9,10 @@
|
||||||
@@ -102,7 +145,21 @@ index 70cc46e..e695a6d 100644
|
|||||||
const loginDisabled = ref(false)
|
const loginDisabled = ref(false)
|
||||||
const defaultUser = ref<string | undefined>()
|
const defaultUser = ref<string | undefined>()
|
||||||
const equippedSkin = ref<Skin | null>(null)
|
const equippedSkin = ref<Skin | null>(null)
|
||||||
@@ -247,6 +262,12 @@ async function login() {
|
@@ -187,8 +202,13 @@ function setLoginDisabled(value: boolean) {
|
||||||
|
loginDisabled.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
+function showOfflineAccountModal(event?: MouseEvent) {
|
||||||
|
+ offlineAccountModal.value?.show(event)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
defineExpose({
|
||||||
|
refreshValues,
|
||||||
|
+ showOfflineAccountModal,
|
||||||
|
setEquippedSkin,
|
||||||
|
setLoginDisabled,
|
||||||
|
login,
|
||||||
|
@@ -247,6 +267,12 @@ async function login() {
|
||||||
loginDisabled.value = false
|
loginDisabled.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +172,7 @@ index 70cc46e..e695a6d 100644
|
|||||||
async function logout(id: string) {
|
async function logout(id: string) {
|
||||||
await remove_user(id).catch(handleError)
|
await remove_user(id).catch(handleError)
|
||||||
await refreshValues()
|
await refreshValues()
|
||||||
@@ -273,6 +294,10 @@ const messages = defineMessages({
|
@@ -273,6 +299,10 @@ const messages = defineMessages({
|
||||||
id: 'minecraft-account.add-account',
|
id: 'minecraft-account.add-account',
|
||||||
defaultMessage: 'Add account',
|
defaultMessage: 'Add account',
|
||||||
},
|
},
|
||||||
@@ -268,6 +325,82 @@ index 0000000..8300e28
|
|||||||
+ },
|
+ },
|
||||||
+})
|
+})
|
||||||
+</script>
|
+</script>
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||||
|
index 73293f5..8e6b275 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||||
|
@@ -43,6 +43,16 @@
|
||||||
|
{{ formatMessage(messages.signIn) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
+ <div class="flex flex-col gap-2 border-0 border-t border-solid border-surface-4 pt-5">
|
||||||
|
+ <p class="m-0 text-sm leading-tight text-secondary">
|
||||||
|
+ {{ formatMessage(messages.offlineHint) }}
|
||||||
|
+ </p>
|
||||||
|
+ <Button @click="addOfflineAccount">
|
||||||
|
+ <UserIcon />
|
||||||
|
+ {{ formatMessage(messages.addOfflineAccount) }}
|
||||||
|
+ </Button>
|
||||||
|
+ </div>
|
||||||
|
+
|
||||||
|
<p class="m-0 text-center text-sm text-secondary">
|
||||||
|
{{ formatMessage(messages.dontHaveAccount) }}
|
||||||
|
<a
|
||||||
|
@@ -57,7 +67,7 @@
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
-import { MessagesSquareIcon, SpinnerIcon } from '@modrinth/assets'
|
||||||
|
+import { MessagesSquareIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
|
||||||
|
import { Button, ButtonLink, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||||
|
import { inject, type Ref, ref } from 'vue'
|
||||||
|
|
||||||
|
@@ -73,7 +83,7 @@ const accountsCard = inject('accountsCard') as Ref<InstanceType<typeof AccountsC
|
||||||
|
const messages = defineMessages({
|
||||||
|
header: {
|
||||||
|
id: 'minecraft-required.header',
|
||||||
|
- defaultMessage: 'Minecraft required',
|
||||||
|
+ defaultMessage: 'Sign in to Minecraft',
|
||||||
|
},
|
||||||
|
descriptionHeader: {
|
||||||
|
id: 'minecraft-required.description-header',
|
||||||
|
@@ -82,7 +92,7 @@ const messages = defineMessages({
|
||||||
|
description: {
|
||||||
|
id: 'minecraft-required.description',
|
||||||
|
defaultMessage:
|
||||||
|
- 'You need a Microsoft account that owns Minecraft before you can launch and play.',
|
||||||
|
+ 'A Microsoft account that owns Minecraft is what lets you play online and keep your skin.',
|
||||||
|
},
|
||||||
|
getSupport: {
|
||||||
|
id: 'minecraft-required.get-support',
|
||||||
|
@@ -100,6 +110,15 @@ const messages = defineMessages({
|
||||||
|
id: 'minecraft-required.get-minecraft',
|
||||||
|
defaultMessage: 'Get Minecraft',
|
||||||
|
},
|
||||||
|
+ offlineHint: {
|
||||||
|
+ id: 'minecraft-required.offline-hint',
|
||||||
|
+ defaultMessage:
|
||||||
|
+ 'Or play singleplayer and offline-mode servers without an account of any kind.',
|
||||||
|
+ },
|
||||||
|
+ addOfflineAccount: {
|
||||||
|
+ id: 'minecraft-required.add-offline-account',
|
||||||
|
+ defaultMessage: 'Add offline account',
|
||||||
|
+ },
|
||||||
|
})
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
@@ -127,6 +146,11 @@ async function signIn() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
+function addOfflineAccount(event: MouseEvent) {
|
||||||
|
+ modal.value?.hide()
|
||||||
|
+ accountsCard.value?.showOfflineAccountModal(event)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
defineExpose({
|
||||||
|
show,
|
||||||
|
})
|
||||||
diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js
|
diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js
|
||||||
index 94bd13e..cb7319a 100644
|
index 94bd13e..cb7319a 100644
|
||||||
--- a/apps/app-frontend/src/helpers/auth.js
|
--- a/apps/app-frontend/src/helpers/auth.js
|
||||||
@@ -292,6 +425,18 @@ index 94bd13e..cb7319a 100644
|
|||||||
/**
|
/**
|
||||||
* Retrieves the default user
|
* Retrieves the default user
|
||||||
* @return {Promise<UUID | undefined>}
|
* @return {Promise<UUID | undefined>}
|
||||||
|
diff --git a/apps/app/build.rs b/apps/app/build.rs
|
||||||
|
index 653f423..7806b4b 100644
|
||||||
|
--- a/apps/app/build.rs
|
||||||
|
+++ b/apps/app/build.rs
|
||||||
|
@@ -14,6 +14,7 @@ fn main() {
|
||||||
|
.commands(&[
|
||||||
|
"check_reachable",
|
||||||
|
"login",
|
||||||
|
+ "login_offline",
|
||||||
|
"remove_user",
|
||||||
|
"get_default_user",
|
||||||
|
"set_default_user",
|
||||||
diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs
|
diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs
|
||||||
index 8227d94..f4eded6 100644
|
index 8227d94..f4eded6 100644
|
||||||
--- a/apps/app/src/api/auth.rs
|
--- a/apps/app/src/api/auth.rs
|
||||||
@@ -318,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 69545c2..7da46c9 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
|
||||||
@@ -48,6 +48,7 @@ image = { workspace = true, features = ["gif", "jpeg", "png", "webp"] }
|
@@ -52,6 +52,7 @@ image = { workspace = true, features = ["gif", "jpeg", "png", "webp"] }
|
||||||
indicatif = { workspace = true, optional = true }
|
indicatif = { workspace = true, optional = true }
|
||||||
itertools = { workspace = true }
|
itertools = { workspace = true }
|
||||||
json5 = { workspace = true }
|
json5 = { workspace = true }
|
||||||
+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 }
|
||||||
@@ -380,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,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)
|
||||||
@@ -426,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 {
|
||||||
@@ -472,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>> {
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Mon, 14 Sep 2026 13:06:38 +0200
|
||||||
|
Subject: [PATCH] Make the sidebars foldable
|
||||||
|
|
||||||
|
Three sidebar preferences, all stored as feature flags so they survive a
|
||||||
|
restart without needing a settings migration:
|
||||||
|
|
||||||
|
* The Modrinth Servers button in the left sidebar can be switched off
|
||||||
|
under Settings > Features > Sidebar. It stays on by default.
|
||||||
|
* The news section in the right sidebar collapses by clicking its
|
||||||
|
heading, remembering the state the way the friends list does.
|
||||||
|
* The button that folds the right sidebar away is always in the title
|
||||||
|
bar, rather than appearing only once "Hide right sidebar" is turned on
|
||||||
|
in settings, and what it is set to is remembered. That setting still
|
||||||
|
works, and still seeds the state. Its label was pointing at the wrong
|
||||||
|
message ("Next image"), which is fixed here too.
|
||||||
|
|
||||||
|
Once the fold state is saved, a check pops onto the button and its
|
||||||
|
tooltip says it is remembered after a restart, since nothing else shows
|
||||||
|
that. On pages that need the sidebar (browse, projects, users) the button
|
||||||
|
stays in place, disabled, instead of vanishing and shifting the title bar.
|
||||||
|
|
||||||
|
None of these three reach Modrinth. Preference syncing maps a fixed list
|
||||||
|
of named fields in both directions, and these are not in it, so they are
|
||||||
|
neither sent nor overwritten by another device.
|
||||||
|
|
||||||
|
The Modrinth Servers switch says so where it sits, because it sits on a
|
||||||
|
page headed "Sync features across devices" and would otherwise look like
|
||||||
|
it syncs. The other two are not settings entries - a heading and a title
|
||||||
|
bar button - so nothing there promises anything.
|
||||||
|
---
|
||||||
|
apps/app-frontend/src/App.vue | 180 ++++++++++++++++--
|
||||||
|
.../ui/settings/display/BehaviorSettings.vue | 18 --
|
||||||
|
.../ui/settings/display/FeaturesSettings.vue | 38 +++-
|
||||||
|
.../src/composables/use-app-settings.ts | 4 +
|
||||||
|
packages/app-lib/src/state/settings.rs | 4 +
|
||||||
|
5 files changed, 210 insertions(+), 34 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
|
||||||
|
index c5d4703..f9dd80a 100644
|
||||||
|
--- a/apps/app-frontend/src/App.vue
|
||||||
|
+++ b/apps/app-frontend/src/App.vue
|
||||||
|
@@ -10,6 +10,8 @@ import {
|
||||||
|
} from '@modrinth/api-client'
|
||||||
|
import {
|
||||||
|
ArrowLeftRightIcon,
|
||||||
|
+ CheckIcon,
|
||||||
|
+ ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
CompassIcon,
|
||||||
|
@@ -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,42 @@ const credentials = ref()
|
||||||
|
const storedModrinthAccounts = ref([])
|
||||||
|
let credentialsRefreshId = 0
|
||||||
|
const sidebarToggled = ref(true)
|
||||||
|
+// Whether the right sidebar is folded away, remembered across restarts in the
|
||||||
|
+// feature flags. The title bar button below is the only thing that sets it:
|
||||||
|
+// upstream's "Hide right sidebar" setting, which used to be what showed that
|
||||||
|
+// button, is gone.
|
||||||
|
watch(
|
||||||
|
- () => appSettings.toggleSidebar,
|
||||||
|
- (toggleSidebar) => {
|
||||||
|
- sidebarToggled.value = !toggleSidebar
|
||||||
|
+ () => appSettings.getFeatureFlag('right_sidebar_collapsed'),
|
||||||
|
+ (collapsed) => {
|
||||||
|
+ sidebarToggled.value = !collapsed
|
||||||
|
},
|
||||||
|
+ { immediate: true },
|
||||||
|
)
|
||||||
|
+
|
||||||
|
+// Briefly shows a check on the fold button once the state is saved, and the
|
||||||
|
+// first time also explains that the choice is remembered.
|
||||||
|
+const sidebarSaved = ref(false)
|
||||||
|
+let sidebarSavedTimeout
|
||||||
|
+const sidebarRememberedModal = ref(null)
|
||||||
|
+
|
||||||
|
+function setSidebarCollapsed(collapsed) {
|
||||||
|
+ const explain = !appSettings.featureFlags.sidebar_fold_explained
|
||||||
|
+ appSettings.featureFlags.right_sidebar_collapsed = collapsed
|
||||||
|
+ appSettings.featureFlags.sidebar_fold_explained = true
|
||||||
|
+ getSettings()
|
||||||
|
+ .then((settings) => {
|
||||||
|
+ settings.feature_flags.right_sidebar_collapsed = collapsed
|
||||||
|
+ settings.feature_flags.sidebar_fold_explained = true
|
||||||
|
+ return setSettings(settings)
|
||||||
|
+ })
|
||||||
|
+ .then(() => {
|
||||||
|
+ sidebarSaved.value = true
|
||||||
|
+ clearTimeout(sidebarSavedTimeout)
|
||||||
|
+ sidebarSavedTimeout = setTimeout(() => (sidebarSaved.value = false), 1600)
|
||||||
|
+ if (explain) sidebarRememberedModal.value?.show()
|
||||||
|
+ })
|
||||||
|
+ .catch(handleError)
|
||||||
|
+}
|
||||||
|
const forceSidebar = computed(
|
||||||
|
() =>
|
||||||
|
route.path.startsWith('/browse') ||
|
||||||
|
@@ -358,6 +392,21 @@ useQuery({
|
||||||
|
// ad and the gradient being drawn above it.
|
||||||
|
const showAd = computed(() => false)
|
||||||
|
const adConsentAvailable = computed(() => false)
|
||||||
|
+
|
||||||
|
+// Whether the news section in the right sidebar is folded away. Kept in the
|
||||||
|
+// feature flags so it survives a restart, the same way the friends list
|
||||||
|
+// remembers its collapsed sections.
|
||||||
|
+const newsCollapsed = computed(() => appSettings.getFeatureFlag('news_collapsed'))
|
||||||
|
+
|
||||||
|
+function setNewsCollapsed(collapsed) {
|
||||||
|
+ appSettings.featureFlags.news_collapsed = collapsed
|
||||||
|
+ getSettings()
|
||||||
|
+ .then((settings) => {
|
||||||
|
+ settings.feature_flags.news_collapsed = collapsed
|
||||||
|
+ return setSettings(settings)
|
||||||
|
+ })
|
||||||
|
+ .catch(handleError)
|
||||||
|
+}
|
||||||
|
providePageContext({
|
||||||
|
hierarchicalSidebarAvailable: ref(true),
|
||||||
|
showAds: showAd,
|
||||||
|
@@ -588,6 +637,25 @@ const messages = defineMessages({
|
||||||
|
goBack: { id: 'app.navigation.go-back', defaultMessage: 'Go back' },
|
||||||
|
goForward: { id: 'app.navigation.go-forward', defaultMessage: 'Go forward' },
|
||||||
|
nextImage: { id: 'app.navigation.next-image', defaultMessage: 'Next image' },
|
||||||
|
+ hideSidebar: { id: 'app.navigation.hide-sidebar', defaultMessage: 'Hide sidebar' },
|
||||||
|
+ showSidebar: { id: 'app.navigation.show-sidebar', defaultMessage: 'Show sidebar' },
|
||||||
|
+ sidebarRememberedTitle: {
|
||||||
|
+ id: 'app.navigation.sidebar-remembered.title',
|
||||||
|
+ 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: {
|
||||||
|
+ id: 'app.navigation.sidebar-required',
|
||||||
|
+ defaultMessage: 'This page needs the sidebar',
|
||||||
|
+ },
|
||||||
|
updateDownloadMissingVersion: {
|
||||||
|
id: 'app.update.download-error.missing-version',
|
||||||
|
defaultMessage: 'Failed to download update: no version available',
|
||||||
|
@@ -2271,6 +2339,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||||
|
<ImageIcon />
|
||||||
|
</NavButton>
|
||||||
|
<NavButton
|
||||||
|
+ v-if="appSettings.getFeatureFlag('show_hosting_in_sidebar')"
|
||||||
|
v-tooltip.right="formatMessage(messages.modrinthHosting)"
|
||||||
|
to="/hosting/manage"
|
||||||
|
:is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'"
|
||||||
|
@@ -2396,16 +2465,42 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||||
|
<Breadcrumbs />
|
||||||
|
</div>
|
||||||
|
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
|
||||||
|
- <IconButton
|
||||||
|
- v-if="!forceSidebar && appSettings.toggleSidebar"
|
||||||
|
- :type="sidebarToggled ? 'base' : 'quiet'"
|
||||||
|
- :label="formatMessage(messages.nextImage)"
|
||||||
|
- class="mr-3 transition-transform"
|
||||||
|
- :class="{ 'rotate-180': !sidebarToggled }"
|
||||||
|
- @click="sidebarToggled = !sidebarToggled"
|
||||||
|
+ <!--
|
||||||
|
+ Stays in place on pages that need the sidebar, so the title bar does
|
||||||
|
+ not shift. The tooltip sits on the wrapper since a disabled button
|
||||||
|
+ gets no hover events.
|
||||||
|
+ -->
|
||||||
|
+ <span
|
||||||
|
+ v-tooltip="
|
||||||
|
+ formatMessage(
|
||||||
|
+ forceSidebar
|
||||||
|
+ ? messages.sidebarRequired
|
||||||
|
+ : sidebarToggled
|
||||||
|
+ ? messages.hideSidebar
|
||||||
|
+ : messages.showSidebar,
|
||||||
|
+ )
|
||||||
|
+ "
|
||||||
|
+ class="relative mr-3 flex"
|
||||||
|
>
|
||||||
|
- <RightArrowIcon />
|
||||||
|
- </IconButton>
|
||||||
|
+ <IconButton
|
||||||
|
+ :type="sidebarVisible ? 'base' : 'quiet'"
|
||||||
|
+ :label="formatMessage(sidebarToggled ? messages.hideSidebar : messages.showSidebar)"
|
||||||
|
+ :disabled="forceSidebar"
|
||||||
|
+ class="transition-transform"
|
||||||
|
+ :class="{ 'rotate-180': !sidebarVisible }"
|
||||||
|
+ @click="setSidebarCollapsed(sidebarToggled)"
|
||||||
|
+ >
|
||||||
|
+ <RightArrowIcon />
|
||||||
|
+ </IconButton>
|
||||||
|
+ <Transition name="sidebar-saved">
|
||||||
|
+ <span
|
||||||
|
+ v-if="sidebarSaved && !forceSidebar"
|
||||||
|
+ class="sidebar-saved-badge pointer-events-none absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-brand text-brand-inverted"
|
||||||
|
+ >
|
||||||
|
+ <CheckIcon class="size-3" stroke-width="3" />
|
||||||
|
+ </span>
|
||||||
|
+ </Transition>
|
||||||
|
+ </span>
|
||||||
|
<div class="flex mr-3">
|
||||||
|
<Suspense>
|
||||||
|
<AppActionBar />
|
||||||
|
@@ -2521,10 +2616,18 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||||
|
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
|
||||||
|
/>
|
||||||
|
<div v-if="news && news.length > 0" class="p-4 flex flex-col items-center">
|
||||||
|
- <h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">
|
||||||
|
+ <button
|
||||||
|
+ class="button-base m-0 mb-4 flex w-full cursor-pointer items-center justify-between gap-2 border-0 bg-transparent p-0 text-left text-base font-medium text-primary"
|
||||||
|
+ :aria-expanded="!newsCollapsed"
|
||||||
|
+ @click="setNewsCollapsed(!newsCollapsed)"
|
||||||
|
+ >
|
||||||
|
{{ formatMessage(messages.news) }}
|
||||||
|
- </h3>
|
||||||
|
- <div class="space-y-4 flex flex-col items-center w-full">
|
||||||
|
+ <ChevronDownIcon
|
||||||
|
+ class="h-5 w-5 shrink-0 transition-transform"
|
||||||
|
+ :class="{ '-rotate-90': newsCollapsed }"
|
||||||
|
+ />
|
||||||
|
+ </button>
|
||||||
|
+ <div v-if="!newsCollapsed" class="space-y-4 flex flex-col items-center w-full">
|
||||||
|
<NewsArticleCard
|
||||||
|
v-for="(item, index) in news"
|
||||||
|
:key="`news-${index}`"
|
||||||
|
@@ -2550,6 +2653,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" />
|
||||||
|
@@ -2762,6 +2880,38 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
+@media (prefers-reduced-motion: no-preference) {
|
||||||
|
+ .sidebar-saved-enter-active {
|
||||||
|
+ transition: transform 0.35s cubic-bezier(0.15, 1.4, 0.64, 0.96);
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ .sidebar-saved-leave-active {
|
||||||
|
+ transition:
|
||||||
|
+ transform 0.2s ease,
|
||||||
|
+ opacity 0.2s ease;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ .sidebar-saved-enter-from,
|
||||||
|
+ .sidebar-saved-leave-to {
|
||||||
|
+ transform: scale(0);
|
||||||
|
+ opacity: 0;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /* 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) {
|
||||||
|
+ stroke-dasharray: 24;
|
||||||
|
+ stroke-dashoffset: -24;
|
||||||
|
+ animation: sidebar-saved-draw 0.3s 0.15s ease-out forwards;
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+@keyframes sidebar-saved-draw {
|
||||||
|
+ to {
|
||||||
|
+ stroke-dashoffset: 0;
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.nav-button-animated-enter-active {
|
||||||
|
transition: all 0.5s cubic-bezier(0.15, 1.4, 0.64, 0.96);
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/settings/display/BehaviorSettings.vue b/apps/app-frontend/src/components/ui/settings/display/BehaviorSettings.vue
|
||||||
|
index db502ad..c433de6 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/settings/display/BehaviorSettings.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/settings/display/BehaviorSettings.vue
|
||||||
|
@@ -79,14 +79,6 @@ const messages = defineMessages({
|
||||||
|
id: 'app.appearance-settings.default-landing-page.library',
|
||||||
|
defaultMessage: 'Library',
|
||||||
|
},
|
||||||
|
- toggleSidebarTitle: {
|
||||||
|
- id: 'app.appearance-settings.toggle-sidebar.title',
|
||||||
|
- defaultMessage: 'Hide right sidebar',
|
||||||
|
- },
|
||||||
|
- toggleSidebarDescription: {
|
||||||
|
- id: 'app.appearance-settings.toggle-sidebar.description',
|
||||||
|
- defaultMessage: 'Hide the right sidebar by default and add a button to show or hide it.',
|
||||||
|
- },
|
||||||
|
compactModeTitle: {
|
||||||
|
id: 'app.appearance-settings.compact-mode.title',
|
||||||
|
defaultMessage: 'Compact mode',
|
||||||
|
@@ -298,16 +290,6 @@ onBeforeUnmount(() => {
|
||||||
|
aria-describedby="refocus-on-game-close-description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
-
|
||||||
|
- <div class="flex items-center justify-between gap-4">
|
||||||
|
- <div>
|
||||||
|
- <h3 class="m-0 text-lg font-semibold text-contrast">
|
||||||
|
- {{ formatMessage(messages.toggleSidebarTitle) }}
|
||||||
|
- </h3>
|
||||||
|
- <p class="m-0 mt-1">{{ formatMessage(messages.toggleSidebarDescription) }}</p>
|
||||||
|
- </div>
|
||||||
|
- <Toggle id="toggle-sidebar" v-model="current.hideRightSidebar" />
|
||||||
|
- </div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue b/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue
|
||||||
|
index 6ba67a9..2edfeb8 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue
|
||||||
|
@@ -12,7 +12,11 @@ import {
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import { inject, onBeforeUnmount, onMounted } from 'vue'
|
||||||
|
|
||||||
|
-import { useAppSettings } from '@/composables/use-app-settings.ts'
|
||||||
|
+import {
|
||||||
|
+ DEFAULT_FEATURE_FLAGS,
|
||||||
|
+ type FeatureFlag,
|
||||||
|
+ useAppSettings,
|
||||||
|
+} from '@/composables/use-app-settings.ts'
|
||||||
|
import {
|
||||||
|
QUICK_INSTANCE_LIMIT_MAX,
|
||||||
|
useQuickInstanceLimit,
|
||||||
|
@@ -38,6 +42,8 @@ const settingsModal = inject(appSettingsModalContextKey, null)
|
||||||
|
const quickInstances = useQuickInstanceLimit()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
+const showHostingFlag: FeatureFlag = 'show_hosting_in_sidebar'
|
||||||
|
+
|
||||||
|
const messages = defineMessages({
|
||||||
|
syncAcrossDevicesTitle: {
|
||||||
|
id: 'app.features-settings.sync-across-devices.title',
|
||||||
|
@@ -89,6 +95,15 @@ const messages = defineMessages({
|
||||||
|
id: 'app.features-settings.show-skin-selector.description',
|
||||||
|
defaultMessage: 'Show a button in the left sidebar to open the skin selector.',
|
||||||
|
},
|
||||||
|
+ showHostingTitle: {
|
||||||
|
+ id: 'app.features-settings.show-hosting.title',
|
||||||
|
+ defaultMessage: 'Show Modrinth Servers in sidebar',
|
||||||
|
+ },
|
||||||
|
+ showHostingDescription: {
|
||||||
|
+ id: 'app.features-settings.show-hosting.description',
|
||||||
|
+ defaultMessage:
|
||||||
|
+ 'Show a button in the left sidebar to manage Modrinth Servers. Added by Modrinth Enhanced, so it stays on this device and is not synced to your Modrinth account.',
|
||||||
|
+ },
|
||||||
|
quickInstancesTitle: {
|
||||||
|
id: 'app.features-settings.quick-instances.title',
|
||||||
|
defaultMessage: 'Quick instances in sidebar',
|
||||||
|
@@ -119,6 +134,7 @@ type FeaturesSettingsState = {
|
||||||
|
showScreenshotsTab: boolean
|
||||||
|
showAllScreenshots: boolean
|
||||||
|
showSkinSelector: boolean
|
||||||
|
+ showHosting: boolean
|
||||||
|
quickInstanceCount: number
|
||||||
|
showJumpIn: boolean
|
||||||
|
}
|
||||||
|
@@ -138,6 +154,7 @@ function getFeaturesSettingsState(
|
||||||
|
showScreenshotsTab: settings.show_screenshots_tab_in_instances,
|
||||||
|
showAllScreenshots: globalSyncedOptions.screenshots,
|
||||||
|
showSkinSelector: settings.show_skin_selector_in_sidebar,
|
||||||
|
+ showHosting: settings.feature_flags[showHostingFlag] ?? DEFAULT_FEATURE_FLAGS[showHostingFlag],
|
||||||
|
quickInstanceCount: quickInstances.limit.value ?? QUICK_INSTANCE_LIMIT_MAX,
|
||||||
|
showJumpIn: settings.show_jump_in,
|
||||||
|
}
|
||||||
|
@@ -176,6 +193,10 @@ const settingsMutation = useMutation({
|
||||||
|
show_screenshots_tab_in_instances: value.showScreenshotsTab,
|
||||||
|
show_skin_selector_in_sidebar: value.showSkinSelector,
|
||||||
|
show_jump_in: value.showJumpIn,
|
||||||
|
+ feature_flags: {
|
||||||
|
+ ...latestSettings.feature_flags,
|
||||||
|
+ [showHostingFlag]: value.showHosting,
|
||||||
|
+ },
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenshotsChanged =
|
||||||
|
@@ -197,6 +218,7 @@ const settingsMutation = useMutation({
|
||||||
|
appSettings.showScreenshotsTabInInstances = value.showScreenshotsTab
|
||||||
|
appSettings.showSkinSelectorInSidebar = value.showSkinSelector
|
||||||
|
appSettings.showJumpIn = value.showJumpIn
|
||||||
|
+ appSettings.featureFlags[showHostingFlag] = value.showHosting
|
||||||
|
|
||||||
|
if (updateQuickInstanceCount) {
|
||||||
|
quickInstances.setLimit(value.quickInstanceCount)
|
||||||
|
@@ -357,6 +379,20 @@ onBeforeUnmount(() => {
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
+ <div class="flex items-center justify-between gap-4">
|
||||||
|
+ <div>
|
||||||
|
+ <h3 class="m-0 text-lg font-semibold text-contrast">
|
||||||
|
+ {{ formatMessage(messages.showHostingTitle) }}
|
||||||
|
+ </h3>
|
||||||
|
+ <p class="m-0 mt-1">{{ formatMessage(messages.showHostingDescription) }}</p>
|
||||||
|
+ </div>
|
||||||
|
+ <Toggle
|
||||||
|
+ id="show-hosting-in-sidebar"
|
||||||
|
+ v-model="current.showHosting"
|
||||||
|
+ :aria-label="formatMessage(messages.showHostingTitle)"
|
||||||
|
+ />
|
||||||
|
+ </div>
|
||||||
|
+
|
||||||
|
<div class="flex flex-col gap-2.5">
|
||||||
|
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||||
|
{{ formatMessage(messages.quickInstancesTitle) }}
|
||||||
|
diff --git a/apps/app-frontend/src/composables/use-app-settings.ts b/apps/app-frontend/src/composables/use-app-settings.ts
|
||||||
|
index dceec83..bce1f20 100644
|
||||||
|
--- a/apps/app-frontend/src/composables/use-app-settings.ts
|
||||||
|
+++ b/apps/app-frontend/src/composables/use-app-settings.ts
|
||||||
|
@@ -11,6 +11,10 @@ export const DEFAULT_FEATURE_FLAGS = {
|
||||||
|
pride_fundraiser: true,
|
||||||
|
i18n_debug: false,
|
||||||
|
localhost_sign_in: false,
|
||||||
|
+ show_hosting_in_sidebar: true,
|
||||||
|
+ news_collapsed: false,
|
||||||
|
+ sidebar_fold_explained: false,
|
||||||
|
+ right_sidebar_collapsed: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
||||||
|
diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs
|
||||||
|
index 47490ad..7797e78 100644
|
||||||
|
--- a/packages/app-lib/src/state/settings.rs
|
||||||
|
+++ b/packages/app-lib/src/state/settings.rs
|
||||||
|
@@ -104,6 +104,10 @@ pub enum FeatureFlag {
|
||||||
|
ServerProjectQa,
|
||||||
|
I18nDebug,
|
||||||
|
LocalhostSignIn,
|
||||||
|
+ ShowHostingInSidebar,
|
||||||
|
+ NewsCollapsed,
|
||||||
|
+ SidebarFoldExplained,
|
||||||
|
+ RightSidebarCollapsed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
@@ -0,0 +1,854 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Mon, 14 Sep 2026 13:24:41 +0200
|
||||||
|
Subject: [PATCH] Add Ely.by accounts
|
||||||
|
|
||||||
|
Ely.by is an alternative Minecraft account system. "Add Ely.by account"
|
||||||
|
sits next to the Microsoft and offline options in the account card and
|
||||||
|
asks for the account name and password. A two-factor code is appended
|
||||||
|
to the password after a colon, which is Ely.by's own convention.
|
||||||
|
|
||||||
|
Minecraft asks Mojang who the player is, so an Ely.by account cannot
|
||||||
|
start the game on its own. The authlib-injector agent points those calls
|
||||||
|
at Ely.by instead; it is downloaded once and cached, after which such an
|
||||||
|
account launches with no network at all.
|
||||||
|
|
||||||
|
Like offline accounts, an Ely.by account is a row in `minecraft_users`
|
||||||
|
rather than a table of its own, so no migration is needed: the client
|
||||||
|
token Ely.by issues alongside the access token lives behind a marker in
|
||||||
|
the refresh token column, which is both what renews the pair and what
|
||||||
|
identifies the account. The expiry column becomes "check again after" -
|
||||||
|
Ely.by does not say when its tokens expire - so the pair is validated
|
||||||
|
and, if needed, renewed a few times a day instead of on every read of
|
||||||
|
the account list.
|
||||||
|
|
||||||
|
Signing in on Ely.by's own page would be better than a password form,
|
||||||
|
and needs an OAuth application registered with Ely.by; there is none for
|
||||||
|
Modrinth Enhanced yet.
|
||||||
|
---
|
||||||
|
.../src/components/ui/AccountsCard.vue | 30 ++-
|
||||||
|
.../src/components/ui/ElyAccountModal.vue | 168 ++++++++++++++
|
||||||
|
.../MinecraftRequiredModal.vue | 29 ++-
|
||||||
|
apps/app-frontend/src/helpers/auth.js | 15 ++
|
||||||
|
apps/app/build.rs | 1 +
|
||||||
|
apps/app/src/api/auth.rs | 10 +
|
||||||
|
packages/app-lib/src/api/minecraft_auth.rs | 34 +++
|
||||||
|
packages/app-lib/src/launcher/mod.rs | 16 ++
|
||||||
|
packages/app-lib/src/state/minecraft_auth.rs | 213 +++++++++++++++++-
|
||||||
|
packages/app-lib/src/util/authlib_injector.rs | 77 +++++++
|
||||||
|
packages/app-lib/src/util/mod.rs | 1 +
|
||||||
|
11 files changed, 581 insertions(+), 13 deletions(-)
|
||||||
|
create mode 100644 apps/app-frontend/src/components/ui/ElyAccountModal.vue
|
||||||
|
create mode 100644 packages/app-lib/src/util/authlib_injector.rs
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||||
|
index ebc92c1..35c21fa 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||||
|
@@ -9,6 +9,10 @@
|
||||||
|
<SpinnerIcon v-else class="animate-spin" />
|
||||||
|
{{ formatMessage(messages.signInToMinecraft) }}
|
||||||
|
</Button>
|
||||||
|
+ <Button @click="elyAccountModal?.show($event)">
|
||||||
|
+ <KeyIcon />
|
||||||
|
+ {{ formatMessage(messages.addElyAccount) }}
|
||||||
|
+ </Button>
|
||||||
|
<Button @click="offlineAccountModal?.show($event)">
|
||||||
|
<UserIcon />
|
||||||
|
{{ formatMessage(messages.addOfflineAccount) }}
|
||||||
|
@@ -84,6 +88,13 @@
|
||||||
|
<PlusIcon />
|
||||||
|
{{ formatMessage(messages.addAccount) }}
|
||||||
|
</Button>
|
||||||
|
+ <Button
|
||||||
|
+ class="w-full !bg-button-bg !text-primary ![box-shadow:var(--shadow-button)]"
|
||||||
|
+ @click="elyAccountModal?.show($event)"
|
||||||
|
+ >
|
||||||
|
+ <KeyIcon />
|
||||||
|
+ {{ formatMessage(messages.addElyAccount) }}
|
||||||
|
+ </Button>
|
||||||
|
<Button
|
||||||
|
class="w-full !bg-button-bg !text-primary ![box-shadow:var(--shadow-button)]"
|
||||||
|
@click="offlineAccountModal?.show($event)"
|
||||||
|
@@ -94,11 +105,13 @@
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Accordion>
|
||||||
|
- <OfflineAccountModal ref="offlineAccountModal" @created="offlineAccountCreated" />
|
||||||
|
+ <OfflineAccountModal ref="offlineAccountModal" @created="accountAdded" />
|
||||||
|
+ <ElyAccountModal ref="elyAccountModal" @created="accountAdded" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
+ KeyIcon,
|
||||||
|
LogInIcon,
|
||||||
|
PlusIcon,
|
||||||
|
RadioButtonCheckedIcon,
|
||||||
|
@@ -119,6 +132,7 @@ import {
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
+import ElyAccountModal from '@/components/ui/ElyAccountModal.vue'
|
||||||
|
import OfflineAccountModal from '@/components/ui/OfflineAccountModal.vue'
|
||||||
|
import { useAppEvent } from '@/composables/use-app-event'
|
||||||
|
import { handleSevereError } from '@/composables/use-error.js'
|
||||||
|
@@ -150,6 +164,7 @@ type MinecraftCredential = {
|
||||||
|
|
||||||
|
const accounts: Ref<MinecraftCredential[]> = ref([])
|
||||||
|
const offlineAccountModal = ref<InstanceType<typeof OfflineAccountModal>>()
|
||||||
|
+const elyAccountModal = ref<InstanceType<typeof ElyAccountModal>>()
|
||||||
|
const loginDisabled = ref(false)
|
||||||
|
const defaultUser = ref<string | undefined>()
|
||||||
|
const equippedSkin = ref<Skin | null>(null)
|
||||||
|
@@ -206,9 +221,14 @@ function showOfflineAccountModal(event?: MouseEvent) {
|
||||||
|
offlineAccountModal.value?.show(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
+function showElyAccountModal(event?: MouseEvent) {
|
||||||
|
+ elyAccountModal.value?.show(event)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
defineExpose({
|
||||||
|
refreshValues,
|
||||||
|
showOfflineAccountModal,
|
||||||
|
+ showElyAccountModal,
|
||||||
|
setEquippedSkin,
|
||||||
|
setLoginDisabled,
|
||||||
|
login,
|
||||||
|
@@ -267,8 +287,8 @@ async function login() {
|
||||||
|
loginDisabled.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
-async function offlineAccountCreated() {
|
||||||
|
- // `login_offline` already marks the new account as the active one.
|
||||||
|
+async function accountAdded() {
|
||||||
|
+ // Both sign-in paths already mark the new account as the active one.
|
||||||
|
await refreshValues()
|
||||||
|
emit('change')
|
||||||
|
}
|
||||||
|
@@ -303,6 +323,10 @@ const messages = defineMessages({
|
||||||
|
id: 'minecraft-account.add-offline-account',
|
||||||
|
defaultMessage: 'Add offline account',
|
||||||
|
},
|
||||||
|
+ addElyAccount: {
|
||||||
|
+ id: 'minecraft-account.add-ely-account',
|
||||||
|
+ defaultMessage: 'Add Ely.by account',
|
||||||
|
+ },
|
||||||
|
removeAccount: {
|
||||||
|
id: 'minecraft-account.remove-account',
|
||||||
|
defaultMessage: 'Remove account',
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/ElyAccountModal.vue b/apps/app-frontend/src/components/ui/ElyAccountModal.vue
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..c589375
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/ElyAccountModal.vue
|
||||||
|
@@ -0,0 +1,168 @@
|
||||||
|
+<template>
|
||||||
|
+ <NewModal ref="modal" :header="formatMessage(messages.header)" max-width="480px" width="100%">
|
||||||
|
+ <div class="flex flex-col gap-4">
|
||||||
|
+ <p class="m-0 leading-tight text-secondary">
|
||||||
|
+ {{ formatMessage(messages.description) }}
|
||||||
|
+ </p>
|
||||||
|
+
|
||||||
|
+ <form class="flex flex-col gap-3" @submit.prevent="submit">
|
||||||
|
+ <div class="flex flex-col gap-2">
|
||||||
|
+ <label class="font-semibold text-contrast" for="ely-account-username">
|
||||||
|
+ {{ formatMessage(messages.usernameLabel) }}
|
||||||
|
+ </label>
|
||||||
|
+ <Input
|
||||||
|
+ id="ely-account-username"
|
||||||
|
+ v-model="username"
|
||||||
|
+ :icon="UserIcon"
|
||||||
|
+ :placeholder="formatMessage(messages.usernamePlaceholder)"
|
||||||
|
+ :error="!!error"
|
||||||
|
+ autocapitalize="none"
|
||||||
|
+ autocorrect="off"
|
||||||
|
+ :spellcheck="false"
|
||||||
|
+ class="w-full"
|
||||||
|
+ />
|
||||||
|
+ </div>
|
||||||
|
+
|
||||||
|
+ <div class="flex flex-col gap-2">
|
||||||
|
+ <label class="font-semibold text-contrast" for="ely-account-password">
|
||||||
|
+ {{ formatMessage(messages.passwordLabel) }}
|
||||||
|
+ </label>
|
||||||
|
+ <Input
|
||||||
|
+ id="ely-account-password"
|
||||||
|
+ v-model="password"
|
||||||
|
+ type="password"
|
||||||
|
+ :icon="KeyIcon"
|
||||||
|
+ :error="!!error"
|
||||||
|
+ class="w-full"
|
||||||
|
+ />
|
||||||
|
+ <p class="m-0 text-sm leading-tight text-secondary">
|
||||||
|
+ {{ formatMessage(messages.twoFactorHint) }}
|
||||||
|
+ </p>
|
||||||
|
+ </div>
|
||||||
|
+
|
||||||
|
+ <p v-if="error" class="m-0 text-sm leading-tight text-red">{{ error }}</p>
|
||||||
|
+ </form>
|
||||||
|
+ </div>
|
||||||
|
+
|
||||||
|
+ <template #actions>
|
||||||
|
+ <div class="flex justify-end gap-2">
|
||||||
|
+ <Button native-type="button" @click="modal?.hide()">
|
||||||
|
+ <XIcon aria-hidden="true" />
|
||||||
|
+ {{ formatMessage(commonMessages.cancelButton) }}
|
||||||
|
+ </Button>
|
||||||
|
+ <Button
|
||||||
|
+ type="colored"
|
||||||
|
+ color="brand"
|
||||||
|
+ native-type="button"
|
||||||
|
+ :disabled="submitting || !username.trim() || !password"
|
||||||
|
+ @click="submit"
|
||||||
|
+ >
|
||||||
|
+ <SpinnerIcon v-if="submitting" aria-hidden="true" class="animate-spin" />
|
||||||
|
+ <LogInIcon v-else aria-hidden="true" />
|
||||||
|
+ {{ formatMessage(messages.signInButton) }}
|
||||||
|
+ </Button>
|
||||||
|
+ </div>
|
||||||
|
+ </template>
|
||||||
|
+ </NewModal>
|
||||||
|
+</template>
|
||||||
|
+
|
||||||
|
+<script setup lang="ts">
|
||||||
|
+import { KeyIcon, LogInIcon, SpinnerIcon, UserIcon, XIcon } from '@modrinth/assets'
|
||||||
|
+import {
|
||||||
|
+ Button,
|
||||||
|
+ commonMessages,
|
||||||
|
+ defineMessages,
|
||||||
|
+ Input,
|
||||||
|
+ NewModal,
|
||||||
|
+ useVIntl,
|
||||||
|
+} from '@modrinth/ui'
|
||||||
|
+import { nextTick, ref } from 'vue'
|
||||||
|
+
|
||||||
|
+import { login_ely } from '@/helpers/auth'
|
||||||
|
+
|
||||||
|
+const { formatMessage } = useVIntl()
|
||||||
|
+
|
||||||
|
+const emit = defineEmits<{
|
||||||
|
+ created: [account: unknown]
|
||||||
|
+}>()
|
||||||
|
+
|
||||||
|
+const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
+const username = ref('')
|
||||||
|
+const password = ref('')
|
||||||
|
+const error = ref('')
|
||||||
|
+const submitting = ref(false)
|
||||||
|
+
|
||||||
|
+function show(event?: MouseEvent) {
|
||||||
|
+ username.value = ''
|
||||||
|
+ password.value = ''
|
||||||
|
+ error.value = ''
|
||||||
|
+ submitting.value = false
|
||||||
|
+ modal.value?.show(event)
|
||||||
|
+ void nextTick(() => {
|
||||||
|
+ document.getElementById('ely-account-username')?.focus()
|
||||||
|
+ })
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+async function submit() {
|
||||||
|
+ if (submitting.value) return
|
||||||
|
+
|
||||||
|
+ const name = username.value.trim()
|
||||||
|
+ if (!name || !password.value) return
|
||||||
|
+
|
||||||
|
+ submitting.value = true
|
||||||
|
+ error.value = ''
|
||||||
|
+
|
||||||
|
+ try {
|
||||||
|
+ const account = await login_ely(name, password.value)
|
||||||
|
+ password.value = ''
|
||||||
|
+ modal.value?.hide()
|
||||||
|
+ emit('created', account)
|
||||||
|
+ } catch (e) {
|
||||||
|
+ const text =
|
||||||
|
+ 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 {
|
||||||
|
+ submitting.value = false
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+defineExpose({ show })
|
||||||
|
+
|
||||||
|
+const messages = defineMessages({
|
||||||
|
+ header: {
|
||||||
|
+ id: 'app.ely-account.header',
|
||||||
|
+ defaultMessage: 'Sign in with Ely.by',
|
||||||
|
+ },
|
||||||
|
+ description: {
|
||||||
|
+ id: 'app.ely-account.description',
|
||||||
|
+ defaultMessage:
|
||||||
|
+ 'Ely.by is an alternative Minecraft account system. Its accounts can play singleplayer and join any server that accepts Ely.by.',
|
||||||
|
+ },
|
||||||
|
+ usernameLabel: {
|
||||||
|
+ id: 'app.ely-account.username-label',
|
||||||
|
+ defaultMessage: 'Account name or email',
|
||||||
|
+ },
|
||||||
|
+ usernamePlaceholder: {
|
||||||
|
+ id: 'app.ely-account.username-placeholder',
|
||||||
|
+ defaultMessage: 'Your Ely.by account',
|
||||||
|
+ },
|
||||||
|
+ passwordLabel: {
|
||||||
|
+ id: 'app.ely-account.password-label',
|
||||||
|
+ defaultMessage: 'Password',
|
||||||
|
+ },
|
||||||
|
+ twoFactorHint: {
|
||||||
|
+ id: 'app.ely-account.two-factor-hint',
|
||||||
|
+ defaultMessage:
|
||||||
|
+ 'With two-factor authentication on, append your current code to the password, separated by a colon.',
|
||||||
|
+ },
|
||||||
|
+ signInButton: {
|
||||||
|
+ id: 'app.ely-account.sign-in-button',
|
||||||
|
+ defaultMessage: 'Sign in',
|
||||||
|
+ },
|
||||||
|
+ genericError: {
|
||||||
|
+ id: 'app.ely-account.generic-error',
|
||||||
|
+ defaultMessage: 'Could not sign in to Ely.by.',
|
||||||
|
+ },
|
||||||
|
+})
|
||||||
|
+</script>
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||||
|
index 8e6b275..7781cee 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||||
|
@@ -47,10 +47,16 @@
|
||||||
|
<p class="m-0 text-sm leading-tight text-secondary">
|
||||||
|
{{ formatMessage(messages.offlineHint) }}
|
||||||
|
</p>
|
||||||
|
- <Button @click="addOfflineAccount">
|
||||||
|
- <UserIcon />
|
||||||
|
- {{ formatMessage(messages.addOfflineAccount) }}
|
||||||
|
- </Button>
|
||||||
|
+ <div class="grid grid-cols-2 gap-2">
|
||||||
|
+ <Button @click="addElyAccount">
|
||||||
|
+ <KeyIcon />
|
||||||
|
+ {{ formatMessage(messages.addElyAccount) }}
|
||||||
|
+ </Button>
|
||||||
|
+ <Button @click="addOfflineAccount">
|
||||||
|
+ <UserIcon />
|
||||||
|
+ {{ formatMessage(messages.addOfflineAccount) }}
|
||||||
|
+ </Button>
|
||||||
|
+ </div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="m-0 text-center text-sm text-secondary">
|
||||||
|
@@ -67,7 +73,7 @@
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
-import { MessagesSquareIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
|
||||||
|
+import { KeyIcon, MessagesSquareIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
|
||||||
|
import { Button, ButtonLink, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||||
|
import { inject, type Ref, ref } from 'vue'
|
||||||
|
|
||||||
|
@@ -113,11 +119,15 @@ const messages = defineMessages({
|
||||||
|
offlineHint: {
|
||||||
|
id: 'minecraft-required.offline-hint',
|
||||||
|
defaultMessage:
|
||||||
|
- 'Or play singleplayer and offline-mode servers without an account of any kind.',
|
||||||
|
+ 'Or sign in with Ely.by, or play singleplayer and offline-mode servers without an account of any kind.',
|
||||||
|
+ },
|
||||||
|
+ addElyAccount: {
|
||||||
|
+ id: 'minecraft-required.add-ely-account',
|
||||||
|
+ defaultMessage: 'Ely.by account',
|
||||||
|
},
|
||||||
|
addOfflineAccount: {
|
||||||
|
id: 'minecraft-required.add-offline-account',
|
||||||
|
- defaultMessage: 'Add offline account',
|
||||||
|
+ defaultMessage: 'Offline account',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
@@ -151,6 +161,11 @@ function addOfflineAccount(event: MouseEvent) {
|
||||||
|
accountsCard.value?.showOfflineAccountModal(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
+function addElyAccount(event: MouseEvent) {
|
||||||
|
+ modal.value?.hide()
|
||||||
|
+ accountsCard.value?.showElyAccountModal(event)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
defineExpose({
|
||||||
|
show,
|
||||||
|
})
|
||||||
|
diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js
|
||||||
|
index cb7319a..57580ff 100644
|
||||||
|
--- a/apps/app-frontend/src/helpers/auth.js
|
||||||
|
+++ b/apps/app-frontend/src/helpers/auth.js
|
||||||
|
@@ -46,6 +46,21 @@ export async function login_offline(username) {
|
||||||
|
return await invoke('plugin:auth|login_offline', { username })
|
||||||
|
}
|
||||||
|
|
||||||
|
+/**
|
||||||
|
+ * Signs in to Ely.by and makes that account the active one.
|
||||||
|
+ *
|
||||||
|
+ * Ely.by is an alternative Minecraft account system. The game is pointed at it
|
||||||
|
+ * with the authlib-injector agent at launch.
|
||||||
|
+ *
|
||||||
|
+ * @param {string} username Ely.by account name or email
|
||||||
|
+ * @param {string} password Ely.by password, with `:code` appended when the
|
||||||
|
+ * account has two-factor authentication enabled
|
||||||
|
+ * @returns {Promise<Credential>}
|
||||||
|
+ */
|
||||||
|
+export async function login_ely(username, password) {
|
||||||
|
+ return await invoke('plugin:auth|login_ely', { username, password })
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
/**
|
||||||
|
* Retrieves the default user
|
||||||
|
* @return {Promise<UUID | undefined>}
|
||||||
|
diff --git a/apps/app/build.rs b/apps/app/build.rs
|
||||||
|
index 7806b4b..24db197 100644
|
||||||
|
--- a/apps/app/build.rs
|
||||||
|
+++ b/apps/app/build.rs
|
||||||
|
@@ -15,6 +15,7 @@ fn main() {
|
||||||
|
"check_reachable",
|
||||||
|
"login",
|
||||||
|
"login_offline",
|
||||||
|
+ "login_ely",
|
||||||
|
"remove_user",
|
||||||
|
"get_default_user",
|
||||||
|
"set_default_user",
|
||||||
|
diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs
|
||||||
|
index f4eded6..dea07b2 100644
|
||||||
|
--- a/apps/app/src/api/auth.rs
|
||||||
|
+++ b/apps/app/src/api/auth.rs
|
||||||
|
@@ -10,6 +10,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
|
check_reachable,
|
||||||
|
login,
|
||||||
|
login_offline,
|
||||||
|
+ login_ely,
|
||||||
|
remove_user,
|
||||||
|
get_default_user,
|
||||||
|
set_default_user,
|
||||||
|
@@ -93,6 +94,15 @@ pub async fn login_offline(username: String) -> Result<Credentials> {
|
||||||
|
Ok(minecraft_auth::login_offline(&username).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
+/// Signs in to Ely.by and makes that account active.
|
||||||
|
+#[tauri::command]
|
||||||
|
+pub async fn login_ely(
|
||||||
|
+ username: String,
|
||||||
|
+ password: String,
|
||||||
|
+) -> Result<Credentials> {
|
||||||
|
+ Ok(minecraft_auth::login_ely(&username, &password).await?)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
|
||||||
|
Ok(minecraft_auth::remove_user(user).await?)
|
||||||
|
diff --git a/packages/app-lib/src/api/minecraft_auth.rs b/packages/app-lib/src/api/minecraft_auth.rs
|
||||||
|
index a7fac4a..2d18da3 100644
|
||||||
|
--- a/packages/app-lib/src/api/minecraft_auth.rs
|
||||||
|
+++ b/packages/app-lib/src/api/minecraft_auth.rs
|
||||||
|
@@ -86,6 +86,40 @@ pub async fn login_offline(username: &str) -> crate::Result<Credentials> {
|
||||||
|
Ok(credentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
+/// Signs in to Ely.by and makes the account the active one.
|
||||||
|
+///
|
||||||
|
+/// Ely.by is an alternative account system for Minecraft. The game is pointed
|
||||||
|
+/// at it with the authlib-injector agent at launch, so such an account can play
|
||||||
|
+/// singleplayer and join any server that accepts Ely.by.
|
||||||
|
+#[tracing::instrument(skip(password))]
|
||||||
|
+pub async fn login_ely(
|
||||||
|
+ username: &str,
|
||||||
|
+ password: &str,
|
||||||
|
+) -> crate::Result<Credentials> {
|
||||||
|
+ let username = username.trim();
|
||||||
|
+
|
||||||
|
+ if username.is_empty() || password.is_empty() {
|
||||||
|
+ return Err(crate::ErrorKind::InputError(
|
||||||
|
+ "An Ely.by account name and password are both required".to_string(),
|
||||||
|
+ )
|
||||||
|
+ .into());
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ let state = State::get().await?;
|
||||||
|
+ let credentials = Credentials::ely(username, password).await?;
|
||||||
|
+ credentials.upsert(&state.pool).await?;
|
||||||
|
+
|
||||||
|
+ if let Err(error) =
|
||||||
|
+ crate::onboarding_checklist::mark_logged_into_minecraft().await
|
||||||
|
+ {
|
||||||
|
+ tracing::warn!(
|
||||||
|
+ "Failed to mark Minecraft login in onboarding checklist: {error}"
|
||||||
|
+ );
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ Ok(credentials)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
#[tracing::instrument]
|
||||||
|
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
|
||||||
|
let state = State::get().await?;
|
||||||
|
diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs
|
||||||
|
index 0d5c08d..fd11a46 100644
|
||||||
|
--- a/packages/app-lib/src/launcher/mod.rs
|
||||||
|
+++ b/packages/app-lib/src/launcher/mod.rs
|
||||||
|
@@ -1133,6 +1133,22 @@ pub async fn launch_minecraft(
|
||||||
|
command.arg("--add-opens=jdk.internal/jdk.internal.misc=ALL-UNNAMED");
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // Minecraft asks Mojang who the player is, and an Ely.by account is not a
|
||||||
|
+ // Mojang account. authlib-injector is a Java agent that points those calls
|
||||||
|
+ // at Ely.by instead, and without it such an account cannot start the game.
|
||||||
|
+ if credentials.is_ely() {
|
||||||
|
+ let injector = crate::util::authlib_injector::get_authlib_injector(
|
||||||
|
+ &state.directories,
|
||||||
|
+ )
|
||||||
|
+ .await?;
|
||||||
|
+
|
||||||
|
+ command.arg(format!(
|
||||||
|
+ "-javaagent:{}={}",
|
||||||
|
+ injector.to_string_lossy(),
|
||||||
|
+ crate::state::ELY_API_ROOT
|
||||||
|
+ ));
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
command
|
||||||
|
.arg("com.modrinth.theseus.MinecraftLaunch")
|
||||||
|
.arg(version_info.main_class.clone())
|
||||||
|
diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs
|
||||||
|
index 14455ec..4130488 100644
|
||||||
|
--- a/packages/app-lib/src/state/minecraft_auth.rs
|
||||||
|
+++ b/packages/app-lib/src/state/minecraft_auth.rs
|
||||||
|
@@ -239,6 +239,67 @@ pub fn offline_uuid(username: &str) -> Uuid {
|
||||||
|
Uuid::from_bytes(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
+/// Marker stored in front of an Ely.by account's client token.
|
||||||
|
+///
|
||||||
|
+/// Ely.by's Yggdrasil flow hands back an access token and a client token, and
|
||||||
|
+/// renewing the pair needs both. The client token therefore goes in the refresh
|
||||||
|
+/// token column behind this marker, which both identifies the account as an
|
||||||
|
+/// Ely.by one and keeps it out of a table of its own.
|
||||||
|
+const ELY_REFRESH_TOKEN_PREFIX: &str = "modrinth-enhanced:ely:";
|
||||||
|
+
|
||||||
|
+/// Ely.by's Yggdrasil server, which answers the same shapes Mojang's used to.
|
||||||
|
+const ELY_AUTHSERVER: &str = "https://authserver.ely.by";
|
||||||
|
+
|
||||||
|
+/// What authlib-injector is handed so the game asks Ely.by rather than Mojang.
|
||||||
|
+///
|
||||||
|
+/// The agent resolves the short form to Ely.by's actual API root itself.
|
||||||
|
+pub const ELY_API_ROOT: &str = "ely.by";
|
||||||
|
+
|
||||||
|
+#[derive(Deserialize)]
|
||||||
|
+struct ElyAuthResponse {
|
||||||
|
+ #[serde(rename = "accessToken")]
|
||||||
|
+ access_token: String,
|
||||||
|
+ #[serde(rename = "clientToken")]
|
||||||
|
+ client_token: String,
|
||||||
|
+ #[serde(rename = "selectedProfile")]
|
||||||
|
+ selected_profile: ElyProfile,
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+#[derive(Deserialize)]
|
||||||
|
+struct ElyProfile {
|
||||||
|
+ id: String,
|
||||||
|
+ name: String,
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/// Reads the error message out of an Ely.by refusal, falling back to the status.
|
||||||
|
+async fn ely_error(response: Response) -> crate::Error {
|
||||||
|
+ #[derive(Deserialize)]
|
||||||
|
+ struct ElyError {
|
||||||
|
+ #[serde(rename = "errorMessage")]
|
||||||
|
+ error_message: Option<String>,
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ let status = response.status();
|
||||||
|
+ let message = response
|
||||||
|
+ .json::<ElyError>()
|
||||||
|
+ .await
|
||||||
|
+ .ok()
|
||||||
|
+ .and_then(|error| error.error_message)
|
||||||
|
+ .unwrap_or_else(|| format!("Ely.by refused the request ({status})"));
|
||||||
|
+
|
||||||
|
+ crate::ErrorKind::OtherError(message).as_error()
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/// Ely.by's Yggdrasil UUIDs come without dashes.
|
||||||
|
+fn parse_ely_uuid(id: &str) -> crate::Result<Uuid> {
|
||||||
|
+ Uuid::parse_str(id).map_err(|_| {
|
||||||
|
+ crate::ErrorKind::OtherError(format!(
|
||||||
|
+ "Ely.by returned a player id that could not be read: {id}"
|
||||||
|
+ ))
|
||||||
|
+ .as_error()
|
||||||
|
+ })
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
/// An entry in the player profile cache, keyed by player UUID.
|
||||||
|
pub(super) enum ProfileCacheEntry {
|
||||||
|
/// A cached profile that is valid, even though it may be stale.
|
||||||
|
@@ -319,6 +380,135 @@ impl Credentials {
|
||||||
|
self.refresh_token == OFFLINE_REFRESH_TOKEN
|
||||||
|
}
|
||||||
|
|
||||||
|
+ /// Whether these credentials belong to an Ely.by account.
|
||||||
|
+ pub fn is_ely(&self) -> bool {
|
||||||
|
+ self.refresh_token.starts_with(ELY_REFRESH_TOKEN_PREFIX)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /// The client token Ely.by issued with the access token, if this is an
|
||||||
|
+ /// Ely.by account.
|
||||||
|
+ fn ely_client_token(&self) -> Option<&str> {
|
||||||
|
+ self.refresh_token.strip_prefix(ELY_REFRESH_TOKEN_PREFIX)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /// Signs in to Ely.by with an account name or email and a password.
|
||||||
|
+ ///
|
||||||
|
+ /// Ely.by accounts with two-factor authentication expect the current code
|
||||||
|
+ /// appended to the password with a colon, which is Ely.by's own convention
|
||||||
|
+ /// and is passed straight through.
|
||||||
|
+ pub async fn ely(username: &str, password: &str) -> crate::Result<Self> {
|
||||||
|
+ let client_token = Uuid::new_v4().to_string();
|
||||||
|
+
|
||||||
|
+ let response = INSECURE_REQWEST_CLIENT
|
||||||
|
+ .post(format!("{ELY_AUTHSERVER}/auth/authenticate"))
|
||||||
|
+ .json(&json!({
|
||||||
|
+ "username": username,
|
||||||
|
+ "password": password,
|
||||||
|
+ "clientToken": client_token,
|
||||||
|
+ "requestUser": false,
|
||||||
|
+ "agent": { "name": "Minecraft", "version": 1 },
|
||||||
|
+ }))
|
||||||
|
+ .send()
|
||||||
|
+ .await
|
||||||
|
+ .map_err(|error| {
|
||||||
|
+ crate::ErrorKind::OtherError(format!(
|
||||||
|
+ "Could not reach Ely.by: {error}"
|
||||||
|
+ ))
|
||||||
|
+ })?;
|
||||||
|
+
|
||||||
|
+ if !response.status().is_success() {
|
||||||
|
+ return Err(ely_error(response).await);
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ let auth = response.json::<ElyAuthResponse>().await?;
|
||||||
|
+
|
||||||
|
+ Ok(Self {
|
||||||
|
+ offline_profile: MinecraftProfile {
|
||||||
|
+ id: parse_ely_uuid(&auth.selected_profile.id)?,
|
||||||
|
+ name: auth.selected_profile.name,
|
||||||
|
+ ..MinecraftProfile::default()
|
||||||
|
+ },
|
||||||
|
+ access_token: auth.access_token,
|
||||||
|
+ refresh_token: format!(
|
||||||
|
+ "{ELY_REFRESH_TOKEN_PREFIX}{}",
|
||||||
|
+ auth.client_token
|
||||||
|
+ ),
|
||||||
|
+ // Ely.by does not say when its token expires. The expiry column is
|
||||||
|
+ // used as "check again after" instead, so the launcher asks Ely.by
|
||||||
|
+ // about the token a few times a day rather than on every read of
|
||||||
|
+ // the account list.
|
||||||
|
+ expires: Utc::now() + Duration::hours(6),
|
||||||
|
+ active: true,
|
||||||
|
+ })
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /// Renews an Ely.by token pair, returning whether it is now usable.
|
||||||
|
+ ///
|
||||||
|
+ /// Ely.by refuses a pair that has been invalidated elsewhere - signing in
|
||||||
|
+ /// from another launcher does that - and there is no way back from it
|
||||||
|
+ /// without the password, so the caller is told rather than the launch
|
||||||
|
+ /// failing on its own later.
|
||||||
|
+ async fn refresh_ely(&mut self) -> crate::Result<bool> {
|
||||||
|
+ let Some(client_token) = self.ely_client_token() else {
|
||||||
|
+ return Ok(false);
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ let response = INSECURE_REQWEST_CLIENT
|
||||||
|
+ .post(format!("{ELY_AUTHSERVER}/auth/refresh"))
|
||||||
|
+ .json(&json!({
|
||||||
|
+ "accessToken": &self.access_token,
|
||||||
|
+ "clientToken": client_token,
|
||||||
|
+ "requestUser": false,
|
||||||
|
+ }))
|
||||||
|
+ .send()
|
||||||
|
+ .await;
|
||||||
|
+
|
||||||
|
+ let response = match response {
|
||||||
|
+ Ok(response) => response,
|
||||||
|
+ // Ely.by being unreachable says nothing about the token. Leave it
|
||||||
|
+ // alone: an offline launch with a still-valid token works.
|
||||||
|
+ Err(error) => {
|
||||||
|
+ tracing::warn!("Could not reach Ely.by to refresh: {error}");
|
||||||
|
+ return Ok(true);
|
||||||
|
+ }
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ if response.status().is_server_error() {
|
||||||
|
+ return Ok(true);
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if !response.status().is_success() {
|
||||||
|
+ return Ok(false);
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ let auth = response.json::<ElyAuthResponse>().await?;
|
||||||
|
+ self.access_token = auth.access_token;
|
||||||
|
+ self.refresh_token =
|
||||||
|
+ format!("{ELY_REFRESH_TOKEN_PREFIX}{}", auth.client_token);
|
||||||
|
+ self.offline_profile = MinecraftProfile {
|
||||||
|
+ id: parse_ely_uuid(&auth.selected_profile.id)?,
|
||||||
|
+ name: auth.selected_profile.name,
|
||||||
|
+ ..MinecraftProfile::default()
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ Ok(true)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /// Whether Ely.by still accepts this account's access token.
|
||||||
|
+ async fn ely_token_is_valid(&self) -> bool {
|
||||||
|
+ let response = INSECURE_REQWEST_CLIENT
|
||||||
|
+ .post(format!("{ELY_AUTHSERVER}/auth/validate"))
|
||||||
|
+ .json(&json!({ "accessToken": &self.access_token }))
|
||||||
|
+ .send()
|
||||||
|
+ .await;
|
||||||
|
+
|
||||||
|
+ match response {
|
||||||
|
+ Ok(response) => response.status().is_success(),
|
||||||
|
+ // Unreachable is not invalid; see `refresh_ely`.
|
||||||
|
+ Err(_) => true,
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
/// Refreshes the authentication tokens for this user if they are expired, or
|
||||||
|
/// very close to expiration.
|
||||||
|
async fn refresh(
|
||||||
|
@@ -338,6 +528,22 @@ impl Credentials {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // Ely.by issues its own tokens and renews them at its own endpoint,
|
||||||
|
+ // so Microsoft is not involved at any point below.
|
||||||
|
+ if self.is_ely() {
|
||||||
|
+ if !self.ely_token_is_valid().await && !self.refresh_ely().await? {
|
||||||
|
+ return Err(crate::ErrorKind::OtherError(
|
||||||
|
+ "Ely.by no longer accepts this account's session. Sign in again."
|
||||||
|
+ .to_string(),
|
||||||
|
+ )
|
||||||
|
+ .into());
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ self.expires = Utc::now() + Duration::hours(6);
|
||||||
|
+ self.upsert(exec).await?;
|
||||||
|
+ return Ok(());
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
let oauth_token = oauth_refresh(&self.refresh_token).await?;
|
||||||
|
let (pair, current_date) =
|
||||||
|
DeviceTokenPair::refresh_and_get_device_token(
|
||||||
|
@@ -411,9 +617,10 @@ impl Credentials {
|
||||||
|
&self,
|
||||||
|
cache_intent: OnlineProfileCacheIntent,
|
||||||
|
) -> Option<Arc<MinecraftProfile>> {
|
||||||
|
- // Offline accounts have no Mojang profile, so skip the request that
|
||||||
|
- // would only ever fail and fall back to the offline profile.
|
||||||
|
- if self.is_offline() {
|
||||||
|
+ // Neither offline nor Ely.by accounts have a Mojang profile, so skip
|
||||||
|
+ // the request that would only ever fail and fall back to the profile
|
||||||
|
+ // recorded locally, which already holds the right id and name.
|
||||||
|
+ if self.is_offline() || self.is_ely() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
diff --git a/packages/app-lib/src/util/authlib_injector.rs b/packages/app-lib/src/util/authlib_injector.rs
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..dc099f9
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/packages/app-lib/src/util/authlib_injector.rs
|
||||||
|
@@ -0,0 +1,77 @@
|
||||||
|
+//! Downloads and caches the authlib-injector Java agent.
|
||||||
|
+//!
|
||||||
|
+//! Minecraft asks Mojang who a player is. An Ely.by account is not a Mojang
|
||||||
|
+//! account, so the game has to be told to ask Ely.by instead, and there is no
|
||||||
|
+//! switch for that: authlib-injector is a Java agent that rewrites the calls
|
||||||
|
+//! on the way out. Without it an Ely.by account cannot start the game at all.
|
||||||
|
+
|
||||||
|
+use std::path::PathBuf;
|
||||||
|
+
|
||||||
|
+use crate::state::DirectoryInfo;
|
||||||
|
+use crate::util::fetch::REQWEST_CLIENT;
|
||||||
|
+use crate::util::io;
|
||||||
|
+
|
||||||
|
+/// The agent's own distribution metadata.
|
||||||
|
+const LATEST_URL: &str = "https://authlib-injector.yushi.moe/artifact/latest.json";
|
||||||
|
+
|
||||||
|
+#[derive(serde::Deserialize)]
|
||||||
|
+struct LatestArtifact {
|
||||||
|
+ download_url: String,
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/// Returns the path to the agent jar, downloading it once if it is not cached.
|
||||||
|
+///
|
||||||
|
+/// The cached copy is reused as it is. The agent is not tied to a game or
|
||||||
|
+/// launcher version, so there is nothing to keep up to date, and reusing it
|
||||||
|
+/// means an Ely.by account still launches with no network at all.
|
||||||
|
+pub async fn get_authlib_injector(
|
||||||
|
+ directories: &DirectoryInfo,
|
||||||
|
+) -> crate::Result<PathBuf> {
|
||||||
|
+ let dir = directories.caches_dir().join("authlib-injector");
|
||||||
|
+ io::create_dir_all(&dir).await?;
|
||||||
|
+
|
||||||
|
+ let jar = dir.join("authlib-injector.jar");
|
||||||
|
+ if io::metadata(&jar).await.is_ok() {
|
||||||
|
+ return Ok(jar);
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ tracing::info!("Downloading authlib-injector for an Ely.by launch");
|
||||||
|
+
|
||||||
|
+ let latest = REQWEST_CLIENT
|
||||||
|
+ .get(LATEST_URL)
|
||||||
|
+ .send()
|
||||||
|
+ .await
|
||||||
|
+ .map_err(|error| {
|
||||||
|
+ crate::ErrorKind::OtherError(format!(
|
||||||
|
+ "Could not reach the authlib-injector distribution: {error}"
|
||||||
|
+ ))
|
||||||
|
+ })?
|
||||||
|
+ .json::<LatestArtifact>()
|
||||||
|
+ .await
|
||||||
|
+ .map_err(|error| {
|
||||||
|
+ crate::ErrorKind::OtherError(format!(
|
||||||
|
+ "Could not read the authlib-injector metadata: {error}"
|
||||||
|
+ ))
|
||||||
|
+ })?;
|
||||||
|
+
|
||||||
|
+ let bytes = REQWEST_CLIENT
|
||||||
|
+ .get(&latest.download_url)
|
||||||
|
+ .send()
|
||||||
|
+ .await
|
||||||
|
+ .map_err(|error| {
|
||||||
|
+ crate::ErrorKind::OtherError(format!(
|
||||||
|
+ "Could not download authlib-injector: {error}"
|
||||||
|
+ ))
|
||||||
|
+ })?
|
||||||
|
+ .bytes()
|
||||||
|
+ .await
|
||||||
|
+ .map_err(|error| {
|
||||||
|
+ crate::ErrorKind::OtherError(format!(
|
||||||
|
+ "Could not read the authlib-injector download: {error}"
|
||||||
|
+ ))
|
||||||
|
+ })?;
|
||||||
|
+
|
||||||
|
+ io::write(&jar, &bytes).await?;
|
||||||
|
+
|
||||||
|
+ Ok(jar)
|
||||||
|
+}
|
||||||
|
diff --git a/packages/app-lib/src/util/mod.rs b/packages/app-lib/src/util/mod.rs
|
||||||
|
index 1962d6c..395d7f1 100644
|
||||||
|
--- a/packages/app-lib/src/util/mod.rs
|
||||||
|
+++ b/packages/app-lib/src/util/mod.rs
|
||||||
|
@@ -1,4 +1,5 @@
|
||||||
|
//! Theseus utility functions
|
||||||
|
+pub mod authlib_injector;
|
||||||
|
pub(crate) mod content_hash;
|
||||||
|
pub mod fetch;
|
||||||
|
pub mod io;
|
||||||
@@ -0,0 +1,769 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Mon, 14 Sep 2026 14:43:17 +0200
|
||||||
|
Subject: [PATCH] Sign in to Microsoft in the player's own browser
|
||||||
|
|
||||||
|
The launcher opened Microsoft's sign-in page in a webview of its own,
|
||||||
|
which is the one place a player cannot use the tools they use everywhere
|
||||||
|
else: no password manager, no autofill, no passkeys, and no way to tell
|
||||||
|
by looking that the page is really Microsoft's.
|
||||||
|
|
||||||
|
The browser gets all of it. What it cannot do is hand a code back: this
|
||||||
|
client id is Minecraft's own, and no loopback address is registered for
|
||||||
|
it. So the browser signs in on Microsoft's device code page, with the
|
||||||
|
code already filled in, while the launcher polls for the result and
|
||||||
|
comes back to the front once it is there.
|
||||||
|
|
||||||
|
Every entry point goes through it, since they all end up at
|
||||||
|
`AccountsCard.login()`. The webview is still one click away in that
|
||||||
|
modal for anyone the browser does not work out for.
|
||||||
|
---
|
||||||
|
.../src/components/ui/AccountsCard.vue | 21 +-
|
||||||
|
.../src/components/ui/MicrosoftLoginModal.vue | 219 ++++++++++++++++++
|
||||||
|
.../MinecraftRequiredModal.vue | 29 +--
|
||||||
|
apps/app-frontend/src/helpers/auth.js | 23 ++
|
||||||
|
apps/app/build.rs | 2 +
|
||||||
|
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/state/minecraft_auth.rs | 145 +++++++++++-
|
||||||
|
9 files changed, 499 insertions(+), 46 deletions(-)
|
||||||
|
create mode 100644 apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||||
|
index 35c21fa..224d776 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||||
|
@@ -105,6 +105,7 @@
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Accordion>
|
||||||
|
+ <MicrosoftLoginModal ref="microsoftLoginModal" @created="accountAdded" />
|
||||||
|
<OfflineAccountModal ref="offlineAccountModal" @created="accountAdded" />
|
||||||
|
<ElyAccountModal ref="elyAccountModal" @created="accountAdded" />
|
||||||
|
</template>
|
||||||
|
@@ -133,13 +134,12 @@ import type { Ref } from 'vue'
|
||||||
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import ElyAccountModal from '@/components/ui/ElyAccountModal.vue'
|
||||||
|
+import MicrosoftLoginModal from '@/components/ui/MicrosoftLoginModal.vue'
|
||||||
|
import OfflineAccountModal from '@/components/ui/OfflineAccountModal.vue'
|
||||||
|
import { useAppEvent } from '@/composables/use-app-event'
|
||||||
|
-import { handleSevereError } from '@/composables/use-error.js'
|
||||||
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
|
import {
|
||||||
|
get_default_user,
|
||||||
|
- login as login_flow,
|
||||||
|
remove_user,
|
||||||
|
set_default_user,
|
||||||
|
users,
|
||||||
|
@@ -165,6 +165,7 @@ type MinecraftCredential = {
|
||||||
|
const accounts: Ref<MinecraftCredential[]> = ref([])
|
||||||
|
const offlineAccountModal = ref<InstanceType<typeof OfflineAccountModal>>()
|
||||||
|
const elyAccountModal = ref<InstanceType<typeof ElyAccountModal>>()
|
||||||
|
+const microsoftLoginModal = ref<InstanceType<typeof MicrosoftLoginModal>>()
|
||||||
|
const loginDisabled = ref(false)
|
||||||
|
const defaultUser = ref<string | undefined>()
|
||||||
|
const equippedSkin = ref<Skin | null>(null)
|
||||||
|
@@ -275,16 +276,8 @@ async function setAccount(account: MinecraftCredential) {
|
||||||
|
emit('change')
|
||||||
|
}
|
||||||
|
|
||||||
|
-async function login() {
|
||||||
|
- loginDisabled.value = true
|
||||||
|
- const loggedIn = await login_flow().catch(handleSevereError)
|
||||||
|
-
|
||||||
|
- if (loggedIn) {
|
||||||
|
- await setAccount(loggedIn)
|
||||||
|
- }
|
||||||
|
-
|
||||||
|
- trackEvent('AccountLogIn')
|
||||||
|
- loginDisabled.value = false
|
||||||
|
+function login(event?: MouseEvent) {
|
||||||
|
+ microsoftLoginModal.value?.show(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function accountAdded() {
|
||||||
|
@@ -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
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..d587736
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue
|
||||||
|
@@ -0,0 +1,219 @@
|
||||||
|
+<template>
|
||||||
|
+ <NewModal
|
||||||
|
+ ref="modal"
|
||||||
|
+ :header="formatMessage(messages.header)"
|
||||||
|
+ max-width="480px"
|
||||||
|
+ width="100%"
|
||||||
|
+ :on-hide="stop"
|
||||||
|
+ >
|
||||||
|
+ <div class="flex flex-col gap-4">
|
||||||
|
+ <p class="m-0 leading-tight text-secondary">
|
||||||
|
+ {{ formatMessage(messages.description) }}
|
||||||
|
+ </p>
|
||||||
|
+
|
||||||
|
+ <div v-if="code" class="flex flex-col items-center gap-1 rounded-2xl bg-surface-2 px-4 py-3">
|
||||||
|
+ <span class="text-sm text-secondary">{{ formatMessage(messages.codeLabel) }}</span>
|
||||||
|
+ <div class="flex items-center gap-2">
|
||||||
|
+ <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>
|
||||||
|
+
|
||||||
|
+ <p v-if="error" class="m-0 text-sm leading-tight text-red">{{ error }}</p>
|
||||||
|
+ <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
|
||||||
|
+ class="button-base m-0 cursor-pointer border-0 bg-transparent p-0 text-left text-sm text-secondary underline"
|
||||||
|
+ type="button"
|
||||||
|
+ @click="useBuiltInWindow"
|
||||||
|
+ >
|
||||||
|
+ {{ formatMessage(messages.useBuiltInWindow) }}
|
||||||
|
+ </button>
|
||||||
|
+ </div>
|
||||||
|
+
|
||||||
|
+ <template #actions>
|
||||||
|
+ <div class="flex justify-end gap-2">
|
||||||
|
+ <Button native-type="button" @click="modal?.hide()">
|
||||||
|
+ <XIcon aria-hidden="true" />
|
||||||
|
+ {{ formatMessage(commonMessages.cancelButton) }}
|
||||||
|
+ </Button>
|
||||||
|
+ <Button native-type="button" :disabled="opening" @click="openBrowser">
|
||||||
|
+ <SpinnerIcon v-if="opening" aria-hidden="true" class="animate-spin" />
|
||||||
|
+ <ExternalIcon v-else aria-hidden="true" />
|
||||||
|
+ {{ formatMessage(messages.openAgain) }}
|
||||||
|
+ </Button>
|
||||||
|
+ </div>
|
||||||
|
+ </template>
|
||||||
|
+ </NewModal>
|
||||||
|
+</template>
|
||||||
|
+
|
||||||
|
+<script setup lang="ts">
|
||||||
|
+import { CheckIcon, CopyIcon, ExternalIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||||
|
+import {
|
||||||
|
+ Button,
|
||||||
|
+ commonMessages,
|
||||||
|
+ defineMessages,
|
||||||
|
+ IconButton,
|
||||||
|
+ NewModal,
|
||||||
|
+ useVIntl,
|
||||||
|
+} from '@modrinth/ui'
|
||||||
|
+import { ref } from 'vue'
|
||||||
|
+
|
||||||
|
+import { handleSevereError } from '@/composables/use-error.js'
|
||||||
|
+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 emit = defineEmits<{
|
||||||
|
+ created: [account: unknown]
|
||||||
|
+}>()
|
||||||
|
+
|
||||||
|
+const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
+const code = ref<DeviceCode | null>(null)
|
||||||
|
+const error = ref('')
|
||||||
|
+const opening = ref(false)
|
||||||
|
+const copied = ref(false)
|
||||||
|
+
|
||||||
|
+// Bumped whenever the sign-in starts over or is given up on, so that a poll
|
||||||
|
+// from before does not carry on.
|
||||||
|
+let attempt = 0
|
||||||
|
+let pollTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
+
|
||||||
|
+function show(event?: MouseEvent) {
|
||||||
|
+ modal.value?.show(event)
|
||||||
|
+ void openBrowser()
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+function stop() {
|
||||||
|
+ attempt++
|
||||||
|
+ clearTimeout(pollTimeout)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+async function openBrowser() {
|
||||||
|
+ stop()
|
||||||
|
+ const current = attempt
|
||||||
|
+ code.value = null
|
||||||
|
+ error.value = ''
|
||||||
|
+ copied.value = false
|
||||||
|
+ opening.value = true
|
||||||
|
+
|
||||||
|
+ try {
|
||||||
|
+ const started = (await login_device_begin()) as DeviceCode
|
||||||
|
+ if (current !== attempt) return
|
||||||
|
+ code.value = started
|
||||||
|
+ schedulePoll(current, started)
|
||||||
|
+ } catch (e) {
|
||||||
|
+ if (current === attempt) error.value = messageOf(e, messages.openError)
|
||||||
|
+ } finally {
|
||||||
|
+ if (current === attempt) opening.value = false
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+function schedulePoll(current: number, started: DeviceCode) {
|
||||||
|
+ pollTimeout = setTimeout(() => void poll(current, started), started.interval * 1000)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+async function poll(current: number, started: DeviceCode) {
|
||||||
|
+ try {
|
||||||
|
+ const account = await login_device_poll(started.device_code)
|
||||||
|
+ if (account) {
|
||||||
|
+ // 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)
|
||||||
|
+ } else if (current === attempt) {
|
||||||
|
+ schedulePoll(current, started)
|
||||||
|
+ }
|
||||||
|
+ } 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.
|
||||||
|
+async function useBuiltInWindow() {
|
||||||
|
+ modal.value?.hide()
|
||||||
|
+
|
||||||
|
+ const account = await builtInLogin().catch(handleSevereError)
|
||||||
|
+ if (account) emit('created', account)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+function messageOf(e: unknown, fallback: { id: string; defaultMessage: string }) {
|
||||||
|
+ if (typeof e === 'string') return e
|
||||||
|
+ return (e as Error)?.message ?? formatMessage(fallback)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+defineExpose({ show })
|
||||||
|
+
|
||||||
|
+const messages = defineMessages({
|
||||||
|
+ header: {
|
||||||
|
+ id: 'app.microsoft-login.header',
|
||||||
|
+ defaultMessage: 'Sign in to Microsoft',
|
||||||
|
+ },
|
||||||
|
+ description: {
|
||||||
|
+ id: 'app.microsoft-login.description',
|
||||||
|
+ defaultMessage:
|
||||||
|
+ '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.',
|
||||||
|
+ },
|
||||||
|
+ codeLabel: {
|
||||||
|
+ id: 'app.microsoft-login.code-label',
|
||||||
|
+ defaultMessage: 'If Microsoft asks for a code, enter',
|
||||||
|
+ },
|
||||||
|
+ copyCode: {
|
||||||
|
+ id: 'app.microsoft-login.copy-code',
|
||||||
|
+ defaultMessage: 'Copy code',
|
||||||
|
+ },
|
||||||
|
+ opening: {
|
||||||
|
+ id: 'app.microsoft-login.opening',
|
||||||
|
+ defaultMessage: 'Opening your browser…',
|
||||||
|
+ },
|
||||||
|
+ waiting: {
|
||||||
|
+ id: 'app.microsoft-login.waiting',
|
||||||
|
+ defaultMessage: 'Waiting for you to sign in…',
|
||||||
|
+ },
|
||||||
|
+ openAgain: {
|
||||||
|
+ id: 'app.microsoft-login.open-again',
|
||||||
|
+ defaultMessage: 'Open browser again',
|
||||||
|
+ },
|
||||||
|
+ useBuiltInWindow: {
|
||||||
|
+ id: 'app.microsoft-login.use-built-in-window',
|
||||||
|
+ defaultMessage: 'Trouble with the browser? Sign in in a window here instead.',
|
||||||
|
+ },
|
||||||
|
+ openError: {
|
||||||
|
+ id: 'app.microsoft-login.open-error',
|
||||||
|
+ defaultMessage: 'Could not open a browser to sign in with.',
|
||||||
|
+ },
|
||||||
|
+ genericError: {
|
||||||
|
+ id: 'app.microsoft-login.generic-error',
|
||||||
|
+ defaultMessage: 'Could not sign in to Microsoft.',
|
||||||
|
+ },
|
||||||
|
+})
|
||||||
|
+</script>
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||||
|
index 7781cee..341ccdc 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||||
|
@@ -25,10 +25,8 @@
|
||||||
|
<MessagesSquareIcon />
|
||||||
|
{{ formatMessage(messages.getSupport) }}
|
||||||
|
</ButtonLink>
|
||||||
|
- <Button type="colored" color="brand" :disabled="loadingSignIn" @click="signIn">
|
||||||
|
- <SpinnerIcon v-if="loadingSignIn" class="animate-spin" />
|
||||||
|
+ <Button type="colored" color="brand" @click="signIn">
|
||||||
|
<svg
|
||||||
|
- v-else
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
@@ -73,15 +71,12 @@
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
-import { KeyIcon, MessagesSquareIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
|
||||||
|
+import { KeyIcon, MessagesSquareIcon, UserIcon } from '@modrinth/assets'
|
||||||
|
import { Button, ButtonLink, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||||
|
import { inject, type Ref, ref } from 'vue'
|
||||||
|
|
||||||
|
import steveImage from '@/assets/steve-look-up-left.webp'
|
||||||
|
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||||
|
-import { handleSevereError } from '@/composables/use-error.js'
|
||||||
|
-import { trackEvent } from '@/helpers/analytics'
|
||||||
|
-import { login as loginFlow, set_default_user } from '@/helpers/auth.js'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const accountsCard = inject('accountsCard') as Ref<InstanceType<typeof AccountsCard> | null>
|
||||||
|
@@ -132,28 +127,14 @@ const messages = defineMessages({
|
||||||
|
})
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
-const loadingSignIn = ref(false)
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
modal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
-async function signIn() {
|
||||||
|
- loadingSignIn.value = true
|
||||||
|
-
|
||||||
|
- try {
|
||||||
|
- const loggedIn = await loginFlow()
|
||||||
|
- if (!loggedIn) return
|
||||||
|
-
|
||||||
|
- await set_default_user(loggedIn.profile.id)
|
||||||
|
- await accountsCard.value?.refreshValues()
|
||||||
|
- await trackEvent('AccountLogIn', { source: 'MinecraftRequiredModal' })
|
||||||
|
- modal.value?.hide()
|
||||||
|
- } catch (error) {
|
||||||
|
- handleSevereError(error)
|
||||||
|
- } finally {
|
||||||
|
- loadingSignIn.value = false
|
||||||
|
- }
|
||||||
|
+function signIn(event: MouseEvent) {
|
||||||
|
+ modal.value?.hide()
|
||||||
|
+ accountsCard.value?.login(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOfflineAccount(event: MouseEvent) {
|
||||||
|
diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js
|
||||||
|
index 57580ff..9b2408e 100644
|
||||||
|
--- a/apps/app-frontend/src/helpers/auth.js
|
||||||
|
+++ b/apps/app-frontend/src/helpers/auth.js
|
||||||
|
@@ -33,6 +33,29 @@ export async function login() {
|
||||||
|
return await invoke('plugin:auth|login')
|
||||||
|
}
|
||||||
|
|
||||||
|
+/**
|
||||||
|
+ * Starts a Microsoft sign-in in the default browser.
|
||||||
|
+ *
|
||||||
|
+ * Opens Microsoft's sign-in page with the code already filled in. Unlike the
|
||||||
|
+ * window the launcher opens itself, the browser has the player's password
|
||||||
|
+ * manager, autofill and passkeys.
|
||||||
|
+ *
|
||||||
|
+ * @returns {Promise<object>} the code, to show and to poll {@link login_device_poll} with
|
||||||
|
+ */
|
||||||
|
+export async function login_device_begin() {
|
||||||
|
+ return await invoke('plugin:auth|login_device_begin')
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/**
|
||||||
|
+ * Checks on a browser sign-in.
|
||||||
|
+ *
|
||||||
|
+ * @param {string} deviceCode the `device_code` from {@link login_device_begin}
|
||||||
|
+ * @returns {Promise<Credential | null>} the new account, or null while the player is still signing in
|
||||||
|
+ */
|
||||||
|
+export async function login_device_poll(deviceCode) {
|
||||||
|
+ return await invoke('plugin:auth|login_device_poll', { deviceCode })
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
/**
|
||||||
|
* Adds an offline account with the given username and makes it the active one.
|
||||||
|
*
|
||||||
|
diff --git a/apps/app/build.rs b/apps/app/build.rs
|
||||||
|
index 24db197..3dbf3dc 100644
|
||||||
|
--- a/apps/app/build.rs
|
||||||
|
+++ b/apps/app/build.rs
|
||||||
|
@@ -14,6 +14,8 @@ fn main() {
|
||||||
|
.commands(&[
|
||||||
|
"check_reachable",
|
||||||
|
"login",
|
||||||
|
+ "login_device_begin",
|
||||||
|
+ "login_device_poll",
|
||||||
|
"login_offline",
|
||||||
|
"login_ely",
|
||||||
|
"remove_user",
|
||||||
|
diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs
|
||||||
|
index dea07b2..eab604d 100644
|
||||||
|
--- a/apps/app/src/api/auth.rs
|
||||||
|
+++ b/apps/app/src/api/auth.rs
|
||||||
|
@@ -2,6 +2,7 @@ use crate::api::Result;
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use tauri::plugin::TauriPlugin;
|
||||||
|
use tauri::{Manager, Runtime, UserAttentionType};
|
||||||
|
+use tauri_plugin_opener::OpenerExt;
|
||||||
|
use theseus::prelude::*;
|
||||||
|
|
||||||
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
|
@@ -9,6 +10,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
check_reachable,
|
||||||
|
login,
|
||||||
|
+ login_device_begin,
|
||||||
|
+ login_device_poll,
|
||||||
|
login_offline,
|
||||||
|
login_ely,
|
||||||
|
remove_user,
|
||||||
|
@@ -88,6 +91,67 @@ pub async fn login<R: Runtime>(
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
+/// Starts a Microsoft sign-in in the player's own browser.
|
||||||
|
+///
|
||||||
|
+/// The window the launcher opens for this has no password manager, no
|
||||||
|
+/// autofill and no passkeys, which makes the launcher the one place a player
|
||||||
|
+/// cannot sign in the way they sign in everywhere else. Their own browser has
|
||||||
|
+/// all of it.
|
||||||
|
+///
|
||||||
|
+/// This client id has no redirect the launcher could listen on, so the browser
|
||||||
|
+/// cannot hand a code back. It signs in on Microsoft's device code page
|
||||||
|
+/// instead, with the code already filled in, while [`login_device_poll`] asks
|
||||||
|
+/// Microsoft whether that has happened yet.
|
||||||
|
+#[tauri::command]
|
||||||
|
+pub async fn login_device_begin<R: Runtime>(
|
||||||
|
+ app: tauri::AppHandle<R>,
|
||||||
|
+) -> Result<MinecraftDeviceCode> {
|
||||||
|
+ 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()
|
||||||
|
+ .open_url(url.as_str(), None::<String>)
|
||||||
|
+ .map_err(|error| {
|
||||||
|
+ theseus::ErrorKind::OtherError(format!(
|
||||||
|
+ "Could not open a browser to sign in with: {error}"
|
||||||
|
+ ))
|
||||||
|
+ .as_error()
|
||||||
|
+ })?;
|
||||||
|
+
|
||||||
|
+ Ok(code)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/// 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
|
||||||
|
+/// to the front.
|
||||||
|
+#[tauri::command]
|
||||||
|
+pub async fn login_device_poll<R: Runtime>(
|
||||||
|
+ app: tauri::AppHandle<R>,
|
||||||
|
+ device_code: String,
|
||||||
|
+) -> Result<Option<Credentials>> {
|
||||||
|
+ let credentials = minecraft_auth::poll_device_login(&device_code).await?;
|
||||||
|
+
|
||||||
|
+ if credentials.is_some()
|
||||||
|
+ && let Some(window) = app.get_webview_window("main")
|
||||||
|
+ {
|
||||||
|
+ // Best effort: a desktop may keep a window in the background from
|
||||||
|
+ // taking focus, in which case it at least asks for attention.
|
||||||
|
+ let _ = window.unminimize();
|
||||||
|
+ let _ = window.set_focus();
|
||||||
|
+ let _ = window
|
||||||
|
+ .request_user_attention(Some(UserAttentionType::Informational));
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ Ok(credentials)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
/// Adds an offline account with the given username and makes it active.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn login_offline(username: String) -> Result<Credentials> {
|
||||||
|
diff --git a/packages/app-lib/src/api/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
|
||||||
|
index 320112f..ba6c342 100644
|
||||||
|
--- a/packages/app-lib/src/api/mod.rs
|
||||||
|
+++ b/packages/app-lib/src/api/mod.rs
|
||||||
|
@@ -28,12 +28,12 @@ pub mod data {
|
||||||
|
InstanceInstallCandidate, InstanceInstallTarget,
|
||||||
|
InstanceLaunchOverridesPatch, InstanceLink, InstanceMetadata,
|
||||||
|
InstanceSyncedOption, InstanceSyncedOptions, InstanceTabVisibility,
|
||||||
|
- JavaVersion, LinkedModpackInfo, MemorySettings, ModLoader,
|
||||||
|
- ModrinthCredentials, OnboardingChecklist, Organization, OwnerType,
|
||||||
|
- ProcessMetadata, Project, ProjectType, ProjectV3, SearchResult,
|
||||||
|
- SearchResults, SearchResultsV3, Settings, SharedInstanceAttachment,
|
||||||
|
- SharedInstanceRole, TeamMember, Theme, User, UserFriend, Version,
|
||||||
|
- WindowSize,
|
||||||
|
+ JavaVersion, LinkedModpackInfo, MemorySettings, MinecraftDeviceCode,
|
||||||
|
+ MinecraftLoginFlow, ModLoader, ModrinthCredentials, OnboardingChecklist,
|
||||||
|
+ Organization, OwnerType, ProcessMetadata, Project, ProjectType,
|
||||||
|
+ ProjectV3, SearchResult, SearchResults, SearchResultsV3, Settings,
|
||||||
|
+ SharedInstanceAttachment, SharedInstanceRole, TeamMember, Theme, User,
|
||||||
|
+ UserFriend, Version, WindowSize,
|
||||||
|
};
|
||||||
|
pub use ariadne::users::UserStatus;
|
||||||
|
pub use modrinth_content_management::{
|
||||||
|
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,
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Mon, 14 Sep 2026 19:15:01 +0200
|
||||||
|
Subject: [PATCH] Round the window corners on Linux
|
||||||
|
|
||||||
|
---
|
||||||
|
apps/app-frontend/src/App.vue | 10 +++++++++
|
||||||
|
.../src/assets/stylesheets/global.scss | 17 ++++++++++++++
|
||||||
|
apps/app-frontend/src/main.js | 5 +++++
|
||||||
|
apps/app/tauri.linux.conf.json | 22 ++++++++++++++++++-
|
||||||
|
4 files changed, 53 insertions(+), 1 deletion(-)
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
|
||||||
|
index f9dd80a..288549d 100644
|
||||||
|
--- a/apps/app-frontend/src/App.vue
|
||||||
|
+++ b/apps/app-frontend/src/App.vue
|
||||||
|
@@ -543,6 +543,16 @@ watch([os, isFullscreen], ([osName, fullscreen]) => {
|
||||||
|
document.documentElement.classList.toggle('mac-traffic-lights', osName === 'MacOS' && !fullscreen)
|
||||||
|
})
|
||||||
|
|
||||||
|
+watch(
|
||||||
|
+ [os, isMaximized, isFullscreen, () => appSettings.nativeDecorations],
|
||||||
|
+ ([osName, maximized, fullscreen, nativeDecorations]) => {
|
||||||
|
+ document.documentElement.classList.toggle(
|
||||||
|
+ 'rounded-window',
|
||||||
|
+ osName === 'Linux' && !nativeDecorations && !maximized && !fullscreen,
|
||||||
|
+ )
|
||||||
|
+ },
|
||||||
|
+)
|
||||||
|
+
|
||||||
|
const authUnreachableDebug = useDebugLogger('AuthReachableChecker')
|
||||||
|
const authServerQuery = useQuery({
|
||||||
|
queryKey: ['authServerReachability'],
|
||||||
|
diff --git a/apps/app-frontend/src/assets/stylesheets/global.scss b/apps/app-frontend/src/assets/stylesheets/global.scss
|
||||||
|
index a9fa554..ba4da08 100644
|
||||||
|
--- a/apps/app-frontend/src/assets/stylesheets/global.scss
|
||||||
|
+++ b/apps/app-frontend/src/assets/stylesheets/global.scss
|
||||||
|
@@ -66,6 +66,23 @@ body {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
+// An undecorated window on Linux is transparent (tauri.linux.conf.json), so the
|
||||||
|
+// page paints its own background and cuts the corners off. clip-path rather
|
||||||
|
+// than overflow, because overflow does not clip fixed-position modals.
|
||||||
|
+html.rounded-window,
|
||||||
|
+html.rounded-window body {
|
||||||
|
+ background: transparent;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+html.rounded-window body {
|
||||||
|
+ clip-path: inset(0 round 10px);
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+html.rounded-window #app {
|
||||||
|
+ height: 100%;
|
||||||
|
+ background-color: var(--color-raised-bg);
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
diff --git a/apps/app-frontend/src/main.js b/apps/app-frontend/src/main.js
|
||||||
|
index 9607cfb..e7991ef 100644
|
||||||
|
--- a/apps/app-frontend/src/main.js
|
||||||
|
+++ b/apps/app-frontend/src/main.js
|
||||||
|
@@ -13,6 +13,11 @@ import i18nDebugPlugin from '@/plugins/i18n-debug'
|
||||||
|
import router from '@/routes'
|
||||||
|
|
||||||
|
debugStartup('Frontend entry module evaluated')
|
||||||
|
+// Round the corners from the first frame on; App.vue squares them again once it
|
||||||
|
+// knows the window is maximized or natively decorated.
|
||||||
|
+if (navigator.userAgent.includes('Linux')) {
|
||||||
|
+ document.documentElement.classList.add('rounded-window')
|
||||||
|
+}
|
||||||
|
const app = createApp(App)
|
||||||
|
setupErrorReporting(app, router)
|
||||||
|
|
||||||
|
diff --git a/apps/app/tauri.linux.conf.json b/apps/app/tauri.linux.conf.json
|
||||||
|
index 81a368c..68fadf3 100644
|
||||||
|
--- a/apps/app/tauri.linux.conf.json
|
||||||
|
+++ b/apps/app/tauri.linux.conf.json
|
||||||
|
@@ -1,3 +1,23 @@
|
||||||
|
{
|
||||||
|
- "mainBinaryName": "ModrinthEnhanced"
|
||||||
|
+ "mainBinaryName": "ModrinthEnhanced",
|
||||||
|
+ "app": {
|
||||||
|
+ "windows": [
|
||||||
|
+ {
|
||||||
|
+ "titleBarStyle": "Overlay",
|
||||||
|
+ "hiddenTitle": true,
|
||||||
|
+ "fullscreen": false,
|
||||||
|
+ "height": 800,
|
||||||
|
+ "resizable": true,
|
||||||
|
+ "title": "Modrinth Enhanced",
|
||||||
|
+ "label": "main",
|
||||||
|
+ "width": 1280,
|
||||||
|
+ "minHeight": 700,
|
||||||
|
+ "minWidth": 1100,
|
||||||
|
+ "visible": false,
|
||||||
|
+ "zoomHotkeysEnabled": false,
|
||||||
|
+ "decorations": false,
|
||||||
|
+ "transparent": true
|
||||||
|
+ }
|
||||||
|
+ ]
|
||||||
|
+ }
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Mon, 14 Sep 2026 19:17:53 +0200
|
||||||
|
Subject: [PATCH] Scroll with the middle mouse button
|
||||||
|
|
||||||
|
---
|
||||||
|
apps/app-frontend/src/App.vue | 8 +
|
||||||
|
.../src/assets/stylesheets/global.scss | 50 +++
|
||||||
|
apps/app-frontend/src/helpers/autoscroll.ts | 290 ++++++++++++++++++
|
||||||
|
3 files changed, 348 insertions(+)
|
||||||
|
create mode 100644 apps/app-frontend/src/helpers/autoscroll.ts
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
|
||||||
|
index 288549d..4283ebd 100644
|
||||||
|
--- a/apps/app-frontend/src/App.vue
|
||||||
|
+++ b/apps/app-frontend/src/App.vue
|
||||||
|
@@ -126,6 +126,7 @@ import {
|
||||||
|
} from '@/helpers/ads.js'
|
||||||
|
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
|
||||||
|
import { check_reachable } from '@/helpers/auth.js'
|
||||||
|
+import { installAutoscroll } from '@/helpers/autoscroll.ts'
|
||||||
|
import { get_user, get_user_many, get_version } from '@/helpers/cache.js'
|
||||||
|
import { gameSettingsQueryOptions } from '@/helpers/game-options'
|
||||||
|
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
|
||||||
|
@@ -603,11 +604,18 @@ onMounted(async () => {
|
||||||
|
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
|
||||||
|
document.querySelector('body').addEventListener('contextmenu', handleContextMenu)
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||||
|
+ // Chromium, and so WebView2 on Windows, already autoscrolls by itself
|
||||||
|
+ if (!navigator.userAgent.includes('Windows')) {
|
||||||
|
+ uninstallAutoscroll = installAutoscroll()
|
||||||
|
+ }
|
||||||
|
|
||||||
|
checkUpdates()
|
||||||
|
})
|
||||||
|
|
||||||
|
+let uninstallAutoscroll
|
||||||
|
+
|
||||||
|
onUnmounted(async () => {
|
||||||
|
+ uninstallAutoscroll?.()
|
||||||
|
document.querySelector('body').removeEventListener('click', handleClick)
|
||||||
|
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
|
||||||
|
document.querySelector('body').removeEventListener('contextmenu', handleContextMenu)
|
||||||
|
diff --git a/apps/app-frontend/src/assets/stylesheets/global.scss b/apps/app-frontend/src/assets/stylesheets/global.scss
|
||||||
|
index ba4da08..c5129ac 100644
|
||||||
|
--- a/apps/app-frontend/src/assets/stylesheets/global.scss
|
||||||
|
+++ b/apps/app-frontend/src/assets/stylesheets/global.scss
|
||||||
|
@@ -83,6 +83,56 @@ html.rounded-window #app {
|
||||||
|
background-color: var(--color-raised-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
+// Middle-click autoscroll (helpers/autoscroll.ts)
|
||||||
|
+html.autoscrolling,
|
||||||
|
+html.autoscrolling * {
|
||||||
|
+ cursor: all-scroll !important;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+.autoscroll-shield {
|
||||||
|
+ position: fixed;
|
||||||
|
+ inset: 0;
|
||||||
|
+ z-index: 2147483646;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+.autoscroll-indicator {
|
||||||
|
+ position: fixed;
|
||||||
|
+ z-index: 2147483647;
|
||||||
|
+ pointer-events: none;
|
||||||
|
+ width: 32px;
|
||||||
|
+ height: 32px;
|
||||||
|
+ border-radius: 50%;
|
||||||
|
+ color: var(--color-contrast);
|
||||||
|
+ background-color: var(--color-raised-bg);
|
||||||
|
+ // A ring instead of a border, which would shrink the space the icon has.
|
||||||
|
+ box-shadow:
|
||||||
|
+ 0 0 0 1px var(--color-button-bg),
|
||||||
|
+ 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||||
|
+
|
||||||
|
+ // Overrides the global `svg { width: 1em; height: 1em }`, which squeezed the
|
||||||
|
+ // icon into the top left corner.
|
||||||
|
+ svg {
|
||||||
|
+ display: block;
|
||||||
|
+ width: 100%;
|
||||||
|
+ height: 100%;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ path {
|
||||||
|
+ opacity: 0.45;
|
||||||
|
+ transition:
|
||||||
|
+ opacity 0.1s,
|
||||||
|
+ stroke 0.1s;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ &[data-dir*='n'] [data-dir='n'],
|
||||||
|
+ &[data-dir*='s'] [data-dir='s'],
|
||||||
|
+ &[data-dir*='e'] [data-dir='e'],
|
||||||
|
+ &[data-dir*='w'] [data-dir='w'] {
|
||||||
|
+ opacity: 1;
|
||||||
|
+ stroke: var(--color-brand);
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
diff --git a/apps/app-frontend/src/helpers/autoscroll.ts b/apps/app-frontend/src/helpers/autoscroll.ts
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..82e1790
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/apps/app-frontend/src/helpers/autoscroll.ts
|
||||||
|
@@ -0,0 +1,290 @@
|
||||||
|
+// Middle-click autoscroll, as browsers on Windows do it. WebKitGTK has none:
|
||||||
|
+// on Linux the middle button pastes instead.
|
||||||
|
+//
|
||||||
|
+// Press and release to keep scrolling until the next click, or press, drag and
|
||||||
|
+// release to scroll only while the button is held.
|
||||||
|
+
|
||||||
|
+const DEAD_ZONE = 12
|
||||||
|
+// A press shorter than this, without leaving the dead zone, is a click that
|
||||||
|
+// leaves autoscroll running.
|
||||||
|
+const CLICK_MS = 300
|
||||||
|
+
|
||||||
|
+const INTERACTIVE =
|
||||||
|
+ 'a, button, input, textarea, select, [contenteditable]:not([contenteditable="false"])'
|
||||||
|
+
|
||||||
|
+type Axes = { x: boolean; y: boolean }
|
||||||
|
+
|
||||||
|
+function overflowScrolls(overflow: string) {
|
||||||
|
+ return overflow === 'auto' || overflow === 'scroll'
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// Every ancestor that can scroll, innermost first.
|
||||||
|
+function scrollersFrom(start: Element | null): Element[] {
|
||||||
|
+ const scrollers: Element[] = []
|
||||||
|
+ for (let el = start; el && el !== document.documentElement; el = el.parentElement) {
|
||||||
|
+ const style = getComputedStyle(el)
|
||||||
|
+ if (overflowScrolls(style.overflowX) || overflowScrolls(style.overflowY)) scrollers.push(el)
|
||||||
|
+ }
|
||||||
|
+ return scrollers
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+function scrollableAxes(scrollers: Element[]): Axes {
|
||||||
|
+ const axes = { x: false, y: false }
|
||||||
|
+ for (const el of scrollers) {
|
||||||
|
+ const style = getComputedStyle(el)
|
||||||
|
+ axes.x ||= overflowScrolls(style.overflowX) && el.scrollWidth > el.clientWidth
|
||||||
|
+ axes.y ||= overflowScrolls(style.overflowY) && el.scrollHeight > el.clientHeight
|
||||||
|
+ }
|
||||||
|
+ return axes
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// Scroll the innermost scroller that can still move this way, and hand the
|
||||||
|
+// movement outwards once it reaches its end, as wheel scrolling does. Returns
|
||||||
|
+// whether anything moved.
|
||||||
|
+function scrollChained(scrollers: Element[], left: number, top: number): boolean {
|
||||||
|
+ let moved = false
|
||||||
|
+ for (const [delta, vertical] of [
|
||||||
|
+ [left, false],
|
||||||
|
+ [top, true],
|
||||||
|
+ ] as const) {
|
||||||
|
+ if (!delta) continue
|
||||||
|
+ for (const el of scrollers) {
|
||||||
|
+ const before = vertical ? el.scrollTop : el.scrollLeft
|
||||||
|
+ el.scrollBy(
|
||||||
|
+ vertical ? { top: delta, behavior: 'instant' } : { left: delta, behavior: 'instant' },
|
||||||
|
+ )
|
||||||
|
+ if ((vertical ? el.scrollTop : el.scrollLeft) !== before) {
|
||||||
|
+ moved = true
|
||||||
|
+ break
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ return moved
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// WebKitGTK can leave the painted scroll position a few frames behind fast
|
||||||
|
+// programmatic scrolling. Once the real position stops changing, as it does at
|
||||||
|
+// either end, it never paints the last ones: the page looks stuck short of the
|
||||||
|
+// end while hit testing already uses the real position. A one pixel round trip
|
||||||
|
+// over two frames makes it paint again.
|
||||||
|
+function repaintScrollPositions(scrollers: Element[]) {
|
||||||
|
+ for (const el of scrollers) {
|
||||||
|
+ for (const vertical of [true, false]) {
|
||||||
|
+ const range = vertical ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth
|
||||||
|
+ if (range <= 0) continue
|
||||||
|
+ const position = vertical ? el.scrollTop : el.scrollLeft
|
||||||
|
+ const away = position > 0 ? position - 1 : position + 1
|
||||||
|
+ if (vertical) el.scrollTop = away
|
||||||
|
+ else el.scrollLeft = away
|
||||||
|
+ requestAnimationFrame(() => {
|
||||||
|
+ if (vertical) el.scrollTop = position
|
||||||
|
+ else el.scrollLeft = position
|
||||||
|
+ })
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+function createIndicator(x: number, y: number, axes: Axes): HTMLElement {
|
||||||
|
+ const chevrons = [
|
||||||
|
+ axes.y ? '<path data-dir="n" d="M11 10l5-5 5 5"/><path data-dir="s" d="M11 22l5 5 5-5"/>' : '',
|
||||||
|
+ axes.x ? '<path data-dir="w" d="M10 11l-5 5 5 5"/><path data-dir="e" d="M22 11l5 5-5 5"/>' : '',
|
||||||
|
+ ].join('')
|
||||||
|
+ const indicator = document.createElement('div')
|
||||||
|
+ indicator.className = 'autoscroll-indicator'
|
||||||
|
+ indicator.style.left = `${x - 16}px`
|
||||||
|
+ indicator.style.top = `${y - 16}px`
|
||||||
|
+ indicator.innerHTML = `<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="16" cy="16" r="2" fill="currentColor" stroke="none"/>${chevrons}</svg>`
|
||||||
|
+ document.body.appendChild(indicator)
|
||||||
|
+ return indicator
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// Covers the page while autoscroll runs. The pointer does not move while the
|
||||||
|
+// page does, so without it whatever scrolls under the pointer would light up
|
||||||
|
+// as hovered.
|
||||||
|
+function createShield(): HTMLElement {
|
||||||
|
+ const shield = document.createElement('div')
|
||||||
|
+ shield.className = 'autoscroll-shield'
|
||||||
|
+ document.body.appendChild(shield)
|
||||||
|
+ return shield
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// Pixels per second for a pointer this far from where autoscroll started.
|
||||||
|
+function speed(distance: number): number {
|
||||||
|
+ const past = Math.abs(distance) - DEAD_ZONE
|
||||||
|
+ if (past <= 0) return 0
|
||||||
|
+ return Math.sign(distance) * Math.min(past ** 1.5 * 1.2, 8000)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+export function installAutoscroll(): () => void {
|
||||||
|
+ let active: {
|
||||||
|
+ origin: Element
|
||||||
|
+ scrollers: Element[]
|
||||||
|
+ axes: Axes
|
||||||
|
+ startX: number
|
||||||
|
+ startY: number
|
||||||
|
+ pointerX: number
|
||||||
|
+ pointerY: number
|
||||||
|
+ pressedAt: number
|
||||||
|
+ held: boolean
|
||||||
|
+ carryX: number
|
||||||
|
+ carryY: number
|
||||||
|
+ lastFrame: number
|
||||||
|
+ frame: number
|
||||||
|
+ stuckFrames: number
|
||||||
|
+ indicator: HTMLElement
|
||||||
|
+ shield: HTMLElement
|
||||||
|
+ } | null = null
|
||||||
|
+ // The mouseup, click and auxclick that belong to a press autoscroll consumed.
|
||||||
|
+ let swallowButtons = 0
|
||||||
|
+
|
||||||
|
+ function stop() {
|
||||||
|
+ if (!active) return
|
||||||
|
+ cancelAnimationFrame(active.frame)
|
||||||
|
+ active.indicator.remove()
|
||||||
|
+ active.shield.remove()
|
||||||
|
+ document.documentElement.classList.remove('autoscrolling')
|
||||||
|
+ repaintScrollPositions(active.scrollers)
|
||||||
|
+ active = null
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function tick(now: number) {
|
||||||
|
+ if (!active) return
|
||||||
|
+ const dt = Math.min(now - active.lastFrame, 100) / 1000
|
||||||
|
+ active.lastFrame = now
|
||||||
|
+
|
||||||
|
+ // The page may have re-rendered the element autoscroll started on.
|
||||||
|
+ if (!active.origin.isConnected) {
|
||||||
|
+ active.shield.style.pointerEvents = 'none'
|
||||||
|
+ const origin = document.elementFromPoint(active.startX, active.startY)
|
||||||
|
+ active.shield.style.pointerEvents = ''
|
||||||
|
+ if (origin) {
|
||||||
|
+ active.origin = origin
|
||||||
|
+ active.scrollers = scrollersFrom(origin)
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ active.carryX += active.axes.x ? speed(active.pointerX - active.startX) * dt : 0
|
||||||
|
+ active.carryY += active.axes.y ? speed(active.pointerY - active.startY) * dt : 0
|
||||||
|
+ const left = Math.trunc(active.carryX)
|
||||||
|
+ const top = Math.trunc(active.carryY)
|
||||||
|
+ if (left || top) {
|
||||||
|
+ if (scrollChained(active.scrollers, left, top)) {
|
||||||
|
+ active.stuckFrames = 0
|
||||||
|
+ } else if (++active.stuckFrames === 2) {
|
||||||
|
+ // Just ran into an end: make sure the last frames get painted.
|
||||||
|
+ repaintScrollPositions(active.scrollers)
|
||||||
|
+ }
|
||||||
|
+ active.carryX -= left
|
||||||
|
+ active.carryY -= top
|
||||||
|
+ }
|
||||||
|
+ active.frame = requestAnimationFrame(tick)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function swallow(e: Event) {
|
||||||
|
+ e.preventDefault()
|
||||||
|
+ e.stopImmediatePropagation()
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function onMouseDown(e: MouseEvent) {
|
||||||
|
+ if (active) {
|
||||||
|
+ // Any press ends a running autoscroll and does nothing else.
|
||||||
|
+ swallow(e)
|
||||||
|
+ stop()
|
||||||
|
+ swallowButtons = 1 << e.button
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+ swallowButtons = 0
|
||||||
|
+ if (e.button !== 1 || !(e.target instanceof Element)) return
|
||||||
|
+ if (e.target.closest(INTERACTIVE)) return
|
||||||
|
+
|
||||||
|
+ const scrollers = scrollersFrom(e.target)
|
||||||
|
+ const axes = scrollableAxes(scrollers)
|
||||||
|
+ if (!axes.x && !axes.y) return
|
||||||
|
+
|
||||||
|
+ swallow(e)
|
||||||
|
+ swallowButtons = 1 << e.button
|
||||||
|
+ active = {
|
||||||
|
+ origin: e.target,
|
||||||
|
+ scrollers,
|
||||||
|
+ axes,
|
||||||
|
+ startX: e.clientX,
|
||||||
|
+ startY: e.clientY,
|
||||||
|
+ pointerX: e.clientX,
|
||||||
|
+ pointerY: e.clientY,
|
||||||
|
+ pressedAt: performance.now(),
|
||||||
|
+ held: false,
|
||||||
|
+ carryX: 0,
|
||||||
|
+ carryY: 0,
|
||||||
|
+ lastFrame: performance.now(),
|
||||||
|
+ frame: 0,
|
||||||
|
+ stuckFrames: 0,
|
||||||
|
+ shield: createShield(),
|
||||||
|
+ indicator: createIndicator(e.clientX, e.clientY, axes),
|
||||||
|
+ }
|
||||||
|
+ document.documentElement.classList.add('autoscrolling')
|
||||||
|
+ active.frame = requestAnimationFrame(tick)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function onMouseMove(e: MouseEvent) {
|
||||||
|
+ if (!active) return
|
||||||
|
+ active.pointerX = e.clientX
|
||||||
|
+ active.pointerY = e.clientY
|
||||||
|
+ const dx = e.clientX - active.startX
|
||||||
|
+ const dy = e.clientY - active.startY
|
||||||
|
+ if (Math.abs(dx) > DEAD_ZONE || Math.abs(dy) > DEAD_ZONE) {
|
||||||
|
+ active.held = true
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Light up the chevrons for the direction the page is moving in
|
||||||
|
+ const vertical = active.axes.y && Math.abs(dy) > DEAD_ZONE ? (dy < 0 ? 'n' : 's') : ''
|
||||||
|
+ const horizontal = active.axes.x && Math.abs(dx) > DEAD_ZONE ? (dx < 0 ? 'w' : 'e') : ''
|
||||||
|
+ active.indicator.dataset.dir = vertical + horizontal
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function onMouseUp(e: MouseEvent) {
|
||||||
|
+ if (!(swallowButtons & (1 << e.button))) return
|
||||||
|
+ swallow(e)
|
||||||
|
+ if (
|
||||||
|
+ active &&
|
||||||
|
+ e.button === 1 &&
|
||||||
|
+ (active.held || performance.now() - active.pressedAt > CLICK_MS)
|
||||||
|
+ ) {
|
||||||
|
+ stop()
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function onClick(e: MouseEvent) {
|
||||||
|
+ if (!(swallowButtons & (1 << e.button))) return
|
||||||
|
+ swallow(e)
|
||||||
|
+ swallowButtons &= ~(1 << e.button)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function onKeyDown(e: KeyboardEvent) {
|
||||||
|
+ if (!active) return
|
||||||
|
+ if (e.key === 'Escape') swallow(e)
|
||||||
|
+ stop()
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ const listeners: [string, (e: never) => void][] = [
|
||||||
|
+ ['mousedown', onMouseDown],
|
||||||
|
+ ['mousemove', onMouseMove],
|
||||||
|
+ ['mouseup', onMouseUp],
|
||||||
|
+ ['click', onClick],
|
||||||
|
+ ['auxclick', onClick],
|
||||||
|
+ ['keydown', onKeyDown],
|
||||||
|
+ ['wheel', stop],
|
||||||
|
+ // Capturing on window also sees every element losing focus; only the
|
||||||
|
+ // window itself losing it matters.
|
||||||
|
+ ['blur', (e: FocusEvent) => e.target === window && stop()],
|
||||||
|
+ ]
|
||||||
|
+ for (const [type, listener] of listeners) {
|
||||||
|
+ window.addEventListener(type, listener as EventListener, { capture: true })
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ return () => {
|
||||||
|
+ stop()
|
||||||
|
+ for (const [type, listener] of listeners) {
|
||||||
|
+ window.removeEventListener(type, listener as EventListener, { capture: true })
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,39 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Tue, 15 Sep 2026 14:25:43 +0200
|
||||||
|
Subject: [PATCH] Use the desktop's file picker on Linux
|
||||||
|
|
||||||
|
File pickers - the webview's own, like the skin page's, and the dialog
|
||||||
|
plugin's - are GtkFileChooserNative on Linux, which only uses the XDG
|
||||||
|
desktop portal when asked. Unasked, it draws a GTK dialog, and inside the
|
||||||
|
AppImage that dialog is themed as light Adwaita whatever the desktop
|
||||||
|
looks like. GTK_USE_PORTAL is now set at startup, so KDE shows its own
|
||||||
|
file dialog and GNOME its own. A player who set it already keeps their
|
||||||
|
value, and without a portal GTK falls back to its own dialog as before.
|
||||||
|
|
||||||
|
Windows and macOS already use the native pickers.
|
||||||
|
---
|
||||||
|
apps/app/src/main.rs | 10 ++++++++++
|
||||||
|
1 file changed, 10 insertions(+)
|
||||||
|
|
||||||
|
diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs
|
||||||
|
index 04eedbd..4c269a3 100644
|
||||||
|
--- a/apps/app/src/main.rs
|
||||||
|
+++ b/apps/app/src/main.rs
|
||||||
|
@@ -115,6 +115,16 @@ async fn set_restart_after_pending_update(
|
||||||
|
// if Tauri app is called with arguments, then those arguments will be treated as commands
|
||||||
|
// ie: deep links or filepaths for .mrpacks
|
||||||
|
fn main() {
|
||||||
|
+ // File pickers on Linux - the webview's own and the dialog plugin's, both
|
||||||
|
+ // GtkFileChooserNative - go through the desktop portal, so KDE and GNOME
|
||||||
|
+ // show their own dialog rather than a GTK one the AppImage themes as
|
||||||
|
+ // Adwaita. Without a portal, GTK falls back to its own dialog.
|
||||||
|
+ #[cfg(target_os = "linux")]
|
||||||
|
+ if std::env::var_os("GTK_USE_PORTAL").is_none() {
|
||||||
|
+ // SAFETY: nothing else is running yet.
|
||||||
|
+ unsafe { std::env::set_var("GTK_USE_PORTAL", "1") };
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
#[cfg(feature = "export-app-events")]
|
||||||
|
theseus::export_app_event_bindings(
|
||||||
|
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
@@ -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 4283ebd..0a55cb4 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'
|
||||||
|
@@ -2524,6 +2525,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 0a55cb4..9679884 100644
|
||||||
|
--- a/apps/app-frontend/src/App.vue
|
||||||
|
+++ b/apps/app-frontend/src/App.vue
|
||||||
|
@@ -1903,9 +1903,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',
|
||||||
|
@@ -2080,28 +2084,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 e33a52d..ac124d9 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")]
|
||||||
@@ -0,0 +1,899 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Fri, 18 Sep 2026 00:03:21 +0200
|
||||||
|
Subject: [PATCH] Offer the launchers instances can be imported from
|
||||||
|
|
||||||
|
Importing only listed the launchers that were found and had instances.
|
||||||
|
Anyone whose launcher sits somewhere else, or who has not installed it
|
||||||
|
where the launcher usually goes, was left with an empty list and a path
|
||||||
|
to type in by hand.
|
||||||
|
|
||||||
|
Every launcher the importers read is offered now, installed or not:
|
||||||
|
Prism Launcher, MultiMC, PolyMC, ATLauncher, GDLauncher and CurseForge,
|
||||||
|
each with the folder it keeps its instances in on this system, Flatpak
|
||||||
|
and macOS bundles included. Picking one reads that folder; with nothing
|
||||||
|
there, its path lands in the field to be corrected. PolyMC and other
|
||||||
|
MultiMC forks are read by Prism's importer, so the name shown and the
|
||||||
|
importer used are no longer the same thing.
|
||||||
|
|
||||||
|
The initials in a badge tell the entries apart without carrying anyone
|
||||||
|
else's logo around.
|
||||||
|
---
|
||||||
|
apps/app-frontend/src/helpers/import.js | 6 +
|
||||||
|
.../src/providers/setup/creation-modal.ts | 7 +-
|
||||||
|
.../src/providers/setup/instance-import.ts | 26 +++-
|
||||||
|
apps/app/build.rs | 1 +
|
||||||
|
apps/app/src/api/import.rs | 8 +
|
||||||
|
packages/app-lib/src/api/pack/import/mod.rs | 146 ++++++++++++++----
|
||||||
|
packages/ui/src/assets/launchers/README.md | 16 ++
|
||||||
|
.../ui/src/assets/launchers/atlauncher.svg | 1 +
|
||||||
|
.../ui/src/assets/launchers/gdlauncher.png | Bin 0 -> 7218 bytes
|
||||||
|
packages/ui/src/assets/launchers/multimc.svg | 1 +
|
||||||
|
packages/ui/src/assets/launchers/polymc.svg | 21 +++
|
||||||
|
.../src/assets/launchers/prism-launcher.svg | 57 +++++++
|
||||||
|
.../components/ImportInstanceStage.vue | 109 ++++++++++++-
|
||||||
|
.../components/LauncherMark.vue | 55 +++++++
|
||||||
|
packages/ui/src/providers/instance-import.ts | 14 ++
|
||||||
|
15 files changed, 426 insertions(+), 42 deletions(-)
|
||||||
|
create mode 100644 packages/ui/src/assets/launchers/README.md
|
||||||
|
create mode 100644 packages/ui/src/assets/launchers/atlauncher.svg
|
||||||
|
create mode 100644 packages/ui/src/assets/launchers/gdlauncher.png
|
||||||
|
create mode 100644 packages/ui/src/assets/launchers/multimc.svg
|
||||||
|
create mode 100644 packages/ui/src/assets/launchers/polymc.svg
|
||||||
|
create mode 100644 packages/ui/src/assets/launchers/prism-launcher.svg
|
||||||
|
create mode 100644 packages/ui/src/components/flows/creation-flow-modal/components/LauncherMark.vue
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/helpers/import.js b/apps/app-frontend/src/helpers/import.js
|
||||||
|
index 34cf250..bfc728e 100644
|
||||||
|
--- a/apps/app-frontend/src/helpers/import.js
|
||||||
|
+++ b/apps/app-frontend/src/helpers/import.js
|
||||||
|
@@ -45,6 +45,12 @@ export async function is_valid_importable_instance(instanceFolder, launcherType)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
+/// Gets every launcher instances can be imported from, with the folder each one
|
||||||
|
+/// uses on this system, whether it is installed or not
|
||||||
|
+export async function launcher_suggestions() {
|
||||||
|
+ return await invoke('plugin:import|launcher_suggestions')
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
/// Gets the default path for the given launcher type
|
||||||
|
/// null if it can't be found or doesn't exist
|
||||||
|
/// eg: get_default_launcher_path("MultiMC")
|
||||||
|
diff --git a/apps/app-frontend/src/providers/setup/creation-modal.ts b/apps/app-frontend/src/providers/setup/creation-modal.ts
|
||||||
|
index 31ad5aa..f4a13ce 100644
|
||||||
|
--- a/apps/app-frontend/src/providers/setup/creation-modal.ts
|
||||||
|
+++ b/apps/app-frontend/src/providers/setup/creation-modal.ts
|
||||||
|
@@ -113,7 +113,12 @@ export function setupCreationModal(
|
||||||
|
if (!launcher || instanceSet.size === 0) continue
|
||||||
|
for (const name of instanceSet) {
|
||||||
|
importCount += 1
|
||||||
|
- const job = await import_instance(launcher.name, launcher.path, name).catch(handleError)
|
||||||
|
+ // PolyMC and other forks are read by another launcher's importer.
|
||||||
|
+ const job = await import_instance(
|
||||||
|
+ launcher.launcher ?? launcher.name,
|
||||||
|
+ launcher.path,
|
||||||
|
+ name,
|
||||||
|
+ ).catch(handleError)
|
||||||
|
if (job) {
|
||||||
|
importedInstanceId = installJobInstanceId(job)
|
||||||
|
}
|
||||||
|
diff --git a/apps/app-frontend/src/providers/setup/instance-import.ts b/apps/app-frontend/src/providers/setup/instance-import.ts
|
||||||
|
index 851e44d..752832c 100644
|
||||||
|
--- a/apps/app-frontend/src/providers/setup/instance-import.ts
|
||||||
|
+++ b/apps/app-frontend/src/providers/setup/instance-import.ts
|
||||||
|
@@ -2,9 +2,9 @@ import { type AbstractWebNotificationManager, provideInstanceImport } from '@mod
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
|
|
||||||
|
import {
|
||||||
|
- get_default_launcher_path,
|
||||||
|
get_importable_instances,
|
||||||
|
import_instance,
|
||||||
|
+ launcher_suggestions,
|
||||||
|
} from '@/helpers/import.js'
|
||||||
|
|
||||||
|
export function setupInstanceImportProvider(notificationManager: AbstractWebNotificationManager) {
|
||||||
|
@@ -12,15 +12,18 @@ export function setupInstanceImportProvider(notificationManager: AbstractWebNoti
|
||||||
|
|
||||||
|
provideInstanceImport({
|
||||||
|
async getDetectedLaunchers() {
|
||||||
|
- const launcherNames = ['MultiMC', 'GDLauncher', 'ATLauncher', 'Curseforge', 'PrismLauncher']
|
||||||
|
const launchers = []
|
||||||
|
- for (const name of launcherNames) {
|
||||||
|
+ for (const suggestion of await launcher_suggestions()) {
|
||||||
|
+ if (!suggestion.installed || !suggestion.path) continue
|
||||||
|
try {
|
||||||
|
- const path = await get_default_launcher_path(name)
|
||||||
|
- if (!path) continue
|
||||||
|
- const instances = await get_importable_instances(name, path)
|
||||||
|
+ const instances = await get_importable_instances(suggestion.launcher, suggestion.path)
|
||||||
|
if (instances?.length > 0) {
|
||||||
|
- launchers.push({ name, path, instances })
|
||||||
|
+ launchers.push({
|
||||||
|
+ name: suggestion.name,
|
||||||
|
+ launcher: suggestion.launcher,
|
||||||
|
+ path: suggestion.path,
|
||||||
|
+ instances,
|
||||||
|
+ })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip launchers that fail detection
|
||||||
|
@@ -28,6 +31,15 @@ export function setupInstanceImportProvider(notificationManager: AbstractWebNoti
|
||||||
|
}
|
||||||
|
return launchers
|
||||||
|
},
|
||||||
|
+ async getLauncherSuggestions() {
|
||||||
|
+ const suggestions = await launcher_suggestions()
|
||||||
|
+ return suggestions.map((suggestion) => ({
|
||||||
|
+ name: suggestion.name,
|
||||||
|
+ launcher: suggestion.launcher,
|
||||||
|
+ path: suggestion.path ?? '',
|
||||||
|
+ installed: suggestion.installed,
|
||||||
|
+ }))
|
||||||
|
+ },
|
||||||
|
async getImportableInstances(launcherName: string, path: string) {
|
||||||
|
return (await get_importable_instances(launcherName, path)) ?? []
|
||||||
|
},
|
||||||
|
diff --git a/apps/app/build.rs b/apps/app/build.rs
|
||||||
|
index ac124d9..c9eebb7 100644
|
||||||
|
--- a/apps/app/build.rs
|
||||||
|
+++ b/apps/app/build.rs
|
||||||
|
@@ -108,6 +108,7 @@ fn main() {
|
||||||
|
"get_importable_instances",
|
||||||
|
"is_valid_importable_instance",
|
||||||
|
"get_default_launcher_path",
|
||||||
|
+ "launcher_suggestions",
|
||||||
|
])
|
||||||
|
.default_permission(
|
||||||
|
DefaultPermissionRule::AllowAllCommands,
|
||||||
|
diff --git a/apps/app/src/api/import.rs b/apps/app/src/api/import.rs
|
||||||
|
index 773fcb2..cda652f 100644
|
||||||
|
--- a/apps/app/src/api/import.rs
|
||||||
|
+++ b/apps/app/src/api/import.rs
|
||||||
|
@@ -11,6 +11,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||||
|
get_importable_instances,
|
||||||
|
is_valid_importable_instance,
|
||||||
|
get_default_launcher_path,
|
||||||
|
+ launcher_suggestions,
|
||||||
|
])
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
@@ -39,6 +40,13 @@ pub async fn is_valid_importable_instance(
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
+/// Returns every launcher that can be imported from, installed or not, with
|
||||||
|
+/// the folder each one keeps its instances in on this system.
|
||||||
|
+#[tauri::command]
|
||||||
|
+pub async fn launcher_suggestions() -> Result<Vec<import::LauncherSuggestion>> {
|
||||||
|
+ Ok(import::launcher_suggestions())
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
/// Returns the default path for the given launcher type
|
||||||
|
/// None if it can't be found or doesn't exist
|
||||||
|
#[tauri::command]
|
||||||
|
diff --git a/packages/app-lib/src/api/pack/import/mod.rs b/packages/app-lib/src/api/pack/import/mod.rs
|
||||||
|
index 52707bc..62ee673 100644
|
||||||
|
--- a/packages/app-lib/src/api/pack/import/mod.rs
|
||||||
|
+++ b/packages/app-lib/src/api/pack/import/mod.rs
|
||||||
|
@@ -247,36 +247,130 @@ async fn import_instance_inner(
|
||||||
|
pub fn get_default_launcher_path(
|
||||||
|
r#type: ImportLauncherType,
|
||||||
|
) -> Option<PathBuf> {
|
||||||
|
- let path = match r#type {
|
||||||
|
- ImportLauncherType::MultiMC => {
|
||||||
|
- return find_multimc_path();
|
||||||
|
- }
|
||||||
|
- ImportLauncherType::PrismLauncher => {
|
||||||
|
- Some(dirs::data_dir()?.join("PrismLauncher"))
|
||||||
|
- }
|
||||||
|
- ImportLauncherType::ATLauncher => {
|
||||||
|
- Some(dirs::data_dir()?.join("ATLauncher"))
|
||||||
|
- }
|
||||||
|
- ImportLauncherType::GDLauncher => {
|
||||||
|
- Some(dirs::data_dir()?.join("gdlauncher_next"))
|
||||||
|
- }
|
||||||
|
- ImportLauncherType::Curseforge => {
|
||||||
|
- let home = dirs::home_dir()?;
|
||||||
|
- let primary = home.join("curseforge").join("minecraft");
|
||||||
|
- if primary.exists() {
|
||||||
|
- return Some(primary);
|
||||||
|
+ let name = match r#type {
|
||||||
|
+ ImportLauncherType::MultiMC => "MultiMC",
|
||||||
|
+ ImportLauncherType::PrismLauncher => "Prism Launcher",
|
||||||
|
+ ImportLauncherType::ATLauncher => "ATLauncher",
|
||||||
|
+ ImportLauncherType::GDLauncher => "GDLauncher",
|
||||||
|
+ ImportLauncherType::Curseforge => "CurseForge",
|
||||||
|
+ ImportLauncherType::Unknown => return None,
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ installed_path(r#type, name)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/// A launcher instances can be imported from, and where it keeps them.
|
||||||
|
+#[derive(Debug, Clone, Serialize)]
|
||||||
|
+pub struct LauncherSuggestion {
|
||||||
|
+ /// The importer that reads this launcher's instances.
|
||||||
|
+ pub launcher: String,
|
||||||
|
+ pub name: String,
|
||||||
|
+ /// Where it is, or where it would be on this system.
|
||||||
|
+ pub path: Option<PathBuf>,
|
||||||
|
+ /// Whether it is actually there.
|
||||||
|
+ pub installed: bool,
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/// The launchers worth offering, with the importer each one is read by.
|
||||||
|
+///
|
||||||
|
+/// PolyMC and other MultiMC forks keep their instances the way Prism does, so
|
||||||
|
+/// that importer reads them as well.
|
||||||
|
+const SUGGESTED_LAUNCHERS: &[(ImportLauncherType, &str)] = &[
|
||||||
|
+ (ImportLauncherType::PrismLauncher, "Prism Launcher"),
|
||||||
|
+ (ImportLauncherType::MultiMC, "MultiMC"),
|
||||||
|
+ (ImportLauncherType::PrismLauncher, "PolyMC"),
|
||||||
|
+ (ImportLauncherType::ATLauncher, "ATLauncher"),
|
||||||
|
+ (ImportLauncherType::GDLauncher, "GDLauncher"),
|
||||||
|
+ (ImportLauncherType::Curseforge, "CurseForge"),
|
||||||
|
+];
|
||||||
|
+
|
||||||
|
+/// Every launcher that can be imported from, whether it is installed or not:
|
||||||
|
+/// one that is not still gets the folder it would be in, to start looking from.
|
||||||
|
+pub fn launcher_suggestions() -> Vec<LauncherSuggestion> {
|
||||||
|
+ SUGGESTED_LAUNCHERS
|
||||||
|
+ .iter()
|
||||||
|
+ .map(|&(launcher, name)| {
|
||||||
|
+ let installed = installed_path(launcher, name);
|
||||||
|
+ LauncherSuggestion {
|
||||||
|
+ launcher: launcher.to_string(),
|
||||||
|
+ name: name.to_owned(),
|
||||||
|
+ path: installed
|
||||||
|
+ .clone()
|
||||||
|
+ .or_else(|| launcher_paths(name).into_iter().next()),
|
||||||
|
+ installed: installed.is_some(),
|
||||||
|
}
|
||||||
|
- Some(dirs::document_dir()?.join("curseforge").join("minecraft"))
|
||||||
|
- }
|
||||||
|
- ImportLauncherType::Unknown => None,
|
||||||
|
+ })
|
||||||
|
+ .collect()
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/// Where a launcher of this name actually is, if it is installed.
|
||||||
|
+fn installed_path(r#type: ImportLauncherType, name: &str) -> Option<PathBuf> {
|
||||||
|
+ let paths = launcher_paths(name);
|
||||||
|
+
|
||||||
|
+ // MultiMC is unpacked wherever the player put it, so its own config file is
|
||||||
|
+ // what tells one of those places from any other folder of that name.
|
||||||
|
+ if r#type == ImportLauncherType::MultiMC {
|
||||||
|
+ return paths
|
||||||
|
+ .into_iter()
|
||||||
|
+ .find(|path| path.join("multimc.cfg").exists());
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ paths.into_iter().find(|path| path.exists())
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/// Everywhere a launcher of this name keeps its data on this system, likeliest
|
||||||
|
+/// first.
|
||||||
|
+fn launcher_paths(name: &str) -> Vec<PathBuf> {
|
||||||
|
+ let data = dirs::data_dir();
|
||||||
|
+ let home = dirs::home_dir();
|
||||||
|
+ // Flatpak gives each app a data directory of its own.
|
||||||
|
+ let flatpak = |app: &str, folder: &str| {
|
||||||
|
+ home.as_ref().map(|home| {
|
||||||
|
+ home.join(".var")
|
||||||
|
+ .join("app")
|
||||||
|
+ .join(app)
|
||||||
|
+ .join("data")
|
||||||
|
+ .join(folder)
|
||||||
|
+ })
|
||||||
|
};
|
||||||
|
- let path = path?;
|
||||||
|
- if path.exists() { Some(path) } else { None }
|
||||||
|
+
|
||||||
|
+ match name {
|
||||||
|
+ "Prism Launcher" => [
|
||||||
|
+ data.map(|data| data.join("PrismLauncher")),
|
||||||
|
+ flatpak("org.prismlauncher.PrismLauncher", "PrismLauncher"),
|
||||||
|
+ ]
|
||||||
|
+ .into_iter()
|
||||||
|
+ .flatten()
|
||||||
|
+ .collect(),
|
||||||
|
+ "PolyMC" => [
|
||||||
|
+ data.map(|data| data.join("PolyMC")),
|
||||||
|
+ flatpak("org.polymc.PolyMC", "PolyMC"),
|
||||||
|
+ ]
|
||||||
|
+ .into_iter()
|
||||||
|
+ .flatten()
|
||||||
|
+ .collect(),
|
||||||
|
+ "MultiMC" => multimc_paths(),
|
||||||
|
+ "ATLauncher" => data
|
||||||
|
+ .map(|data| vec![data.join("ATLauncher")])
|
||||||
|
+ .unwrap_or_default(),
|
||||||
|
+ "GDLauncher" => data
|
||||||
|
+ .map(|data| vec![data.join("gdlauncher_next")])
|
||||||
|
+ .unwrap_or_default(),
|
||||||
|
+ "CurseForge" => [
|
||||||
|
+ home.map(|home| home.join("curseforge").join("minecraft")),
|
||||||
|
+ dirs::document_dir()
|
||||||
|
+ .map(|docs| docs.join("curseforge").join("minecraft")),
|
||||||
|
+ ]
|
||||||
|
+ .into_iter()
|
||||||
|
+ .flatten()
|
||||||
|
+ .collect(),
|
||||||
|
+ _ => Vec::new(),
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
|
||||||
|
-/// Searches common locations for a MultiMC installation.
|
||||||
|
+/// The common locations for a MultiMC installation.
|
||||||
|
/// MultiMC stores data in its own application directory (not a standard data dir)
|
||||||
|
-fn find_multimc_path() -> Option<PathBuf> {
|
||||||
|
+fn multimc_paths() -> Vec<PathBuf> {
|
||||||
|
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||||
|
|
||||||
|
// Linux/macOS: ~/.local/share/multimc is the typical location
|
||||||
|
@@ -318,8 +412,6 @@ fn find_multimc_path() -> Option<PathBuf> {
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates
|
||||||
|
- .into_iter()
|
||||||
|
- .find(|p| p.join("multimc.cfg").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if this PathBuf is a valid instance for the given launcher type
|
||||||
|
diff --git a/packages/ui/src/assets/launchers/README.md b/packages/ui/src/assets/launchers/README.md
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..4c7d7a6
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/packages/ui/src/assets/launchers/README.md
|
||||||
|
@@ -0,0 +1,16 @@
|
||||||
|
+# Launcher logos
|
||||||
|
+
|
||||||
|
+The logos of the launchers instances can be imported from, shown beside their name in the import
|
||||||
|
+dialog.
|
||||||
|
+
|
||||||
|
+| File | From | Licence |
|
||||||
|
+| -------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||||
|
+| `prism-launcher.svg` | [PrismLauncher/PrismLauncher](https://github.com/PrismLauncher/PrismLauncher), `program_info` | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), unchanged |
|
||||||
|
+| `polymc.svg` | [PolyMC/PolyMC](https://github.com/PolyMC/PolyMC), `program_info` | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), unchanged |
|
||||||
|
+| `atlauncher.svg` | [ATLauncher/ATLauncher](https://github.com/ATLauncher/ATLauncher), `packaging/linux` | GPL-3.0, unchanged |
|
||||||
|
+| `gdlauncher.png` | [gorilla-devs/GDLauncher](https://github.com/gorilla-devs/GDLauncher), `public/icon.png` | GPL-3.0, scaled down to 128×128 |
|
||||||
|
+| `multimc.svg` | [MultiMC/Launcher](https://github.com/MultiMC/Launcher), `branding` | No licence given; shown to name the launcher, optimised with svgo |
|
||||||
|
+
|
||||||
|
+CurseForge's logo is the one Modrinth already ships in `@modrinth/assets`.
|
||||||
|
+
|
||||||
|
+A launcher without a logo here falls back to a badge with its initials.
|
||||||
|
diff --git a/packages/ui/src/assets/launchers/atlauncher.svg b/packages/ui/src/assets/launchers/atlauncher.svg
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..5dd0000
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/packages/ui/src/assets/launchers/atlauncher.svg
|
||||||
|
@@ -0,0 +1 @@
|
||||||
|
+<svg viewBox="0 0 2084 2084" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2"><g fill-rule="nonzero"><path d="M1041.67 81.38l272.437 159.032-825.246 478.685-272.438-157.971L1041.67 81.38zm87.28 371.074l274.024-159.032 463.937 271.945-276.14 153.73-461.821-266.643z" fill="#3b3b3b"/><path d="M216.42 561.126v961.081l825.247 479.746V1684.95l-551.222-321.774-1.587-644.079L216.42 561.126z" fill="#2e2e2e"/><path d="M1866.91 1517.97l-825.246 483.986v-317.003l550.164-320.714-1.058-645.139 276.14-153.73v952.6z" fill="#333"/><path d="M1590.77 719.097l-549.106 310.112v165.393l214.246-122.984v488.757l138.599-81.106V989.451l196.261-115.563V719.097z" fill="#89c236"/><path d="M488.858 719.097l1.587 644.079 152.353 90.118v-198.79l230.645 132.527v199.319l168.753 98.6v-655.741L488.858 719.097zm383.527 531.166l-227.471-131.466v-150.02l227.471 127.225v154.261z" fill="#7baf31"/></g></svg>
|
||||||
|
\ No newline at end of file
|
||||||
|
diff --git a/packages/ui/src/assets/launchers/gdlauncher.png b/packages/ui/src/assets/launchers/gdlauncher.png
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000000000000000000000000000000000000..9d369eb6d30a38821f97569a41fbb0f2346f7fc6
|
||||||
|
GIT binary patch
|
||||||
|
literal 7218
|
||||||
|
zcmeAS@N?(olHy`uVBq!ia0y~yU}ykg4mJh`hQoG=rx_Tee|ow&hE&{o8(UoydUfi*
|
||||||
|
zYxjQNanGCA(tbeW#|sU9S5EzjUAHpby#k%~{##c1;ihEv>e4LTg!DtI23uRMrMtfh
|
||||||
|
z$qudg%ONx+uwaW<PRoZN8HEK+9ZFIxPC}Eai_hPzJ}dwHp7(+V&l8XDJ$bjksNVAX
|
||||||
|
zoZ|0M>1*@r???S+csWtdwyed8Y2gG9f0y&pB0PH2KJsteuW74mn_y!8=f+Coww}3G
|
||||||
|
z$tOPkyB^&3#`2<_%Bj=g3s+_BkSu;Gdy`pz$DjL*A4NLyWG%i3y+7cnY`*W`Cw*mx
|
||||||
|
zU=5bQxVUq+N+RDFE_WvsNKBm;x0*5G%$CT@oFbD>S>5h5V`P|eocrw!Ysr{Bc`yIC
|
||||||
|
ze#{7n+BLN?X>wVE$lqLH^|qAC%8ifPe$N!{IARm@%4o5y#A6GVNel-PbX$U)v^G3m
|
||||||
|
zlVs+aJ$-5j!+|56ilK3FYZwb|F$SrfXHU({T*=nZz5H*|gv4yKjyy4^LmOwCFtRLK
|
||||||
|
ze9?Y$V#Cy@Q;%gd{N!}}cE{BF{q5Q}mY4Y^oH=d9prL2>xXJ$e=C`|rnT(fg`ZOnF
|
||||||
|
z#>|rfb7j68I16tKkh9M`zOL~g(<UYU&}nu8d3FD`Fsocm;9~Q3RXQ(zZbzGki1$;;
|
||||||
|
zRd3ZKl1&7+a(yt`Eav2qS-Eg-hUjL_wc61eo^aT4NHtizW$>K(JY96c0UMz#q0Nog
|
||||||
|
zwmCPjtmxf8c_yRo(xn|NH!QSoEXa7f>8JE=Zo38VCOqfn`oDI6)BkXvKgpW9PhDo4
|
||||||
|
zH2ya`zuoe{hC~7F{_w0^>my%QG=JDDeOm61($$ZQOOxjDGivDRnX$6|&su3*b@y0H
|
||||||
|
zyynxF&s~%go`tplzP5x_Y0|03&5vDHcNVVy{PLiE&Yd6b)*@V;k3^2JPg(V_&gbEo
|
||||||
|
zlP*c?rd;z_yV$_Znk8s%lf{8PfgsP9jn}^IK9lxs*QQC~L58{kt1fIWY%u@z+qlhB
|
||||||
|
ztXT1hiNS)oE7Y1Ys^{6aRva)0s#M`<xae&<^XBXLm<?ZVRQp*USoyS)xxwB4Z|1%Q
|
||||||
|
zYa|)!)TTTwUL?rOa3!vC-`l(d28YU;Ll$K$2M)Kr7GD$p?F3_Z30GIjF?OTK&ovwJ
|
||||||
|
z@BcV*amS&V_DT<{{i5c*mwKzQIM>%@Qpc`4jhbdXWhM@CPd~;nZTt7~%V#~yJ?X!#
|
||||||
|
z--m{V&P*`SSl#64B=m}V%9F}}Tun37r<mT$*lk%md(Fh|{tKtXdXm?t%~({Ls+zB+
|
||||||
|
zZMPuh%;(3;&RDhU&T@9SE+{Bty^2%7qe*?=(+>=8k#Z-`Bs@z$`OI?a>F4h~%xV-S
|
||||||
|
zJ1vXd$`yK@&;0TFU#VgL6Q|!}_Vnk>>@_MAmR>xwBjuFDe~&l;w`}HQ1>N_oKF^jl
|
||||||
|
zI;&N%)joby9<TkolI4tu)AiHmrs#ZmyW>#gb;FFlEo+Kpc5~ig`(@V2+@z+sWYeAt
|
||||||
|
z+x9jeDAqdt%zie{7NgSAdGe)Sdp-trRXRn5Zzx<)dg=Mk;6v;|TCPq9Gk*K7^}X0O
|
||||||
|
zU2M%euNsF~*ZY0DeyzLQ`b9+WgR-K?+<;}S+l!miHf=u4W0O}>a<6UryZ>vnGg4pW
|
||||||
|
z%q)9!M7rkTpUdwQI%Wn%{`50BbS7t)_l|tkwK`LdyWiT#Z{XAU|G@P9`ug=+mu7A<
|
||||||
|
zE=`*!A8WBWK;Z1uQ`RxJeGTqZe#yOL#dJeZWvaHdd9LA^&DW<-ol+`h_rid^<EaTd
|
||||||
|
zgWkV?XS#VV+bCXn@@-<~?nIjmHEZd|dz3o5ukDHWbZ=wv%rATwY}$M0e%f5O#^{~m
|
||||||
|
z1^<?yX~Mk{MbkZBru7IvZkAa4<_1rkhu`D;YkKuNb}jzExK+D0I@ERP(xp|8j&y3>
|
||||||
|
z$^Ns}<<I+|%3tMMd~W-GYz&c;-y;0CX=dG9`yIdbpFVwhZi0b^cRSzHZ->~g+&jJN
|
||||||
|
zV(aF0A_r_TE8Q~H6)!Kd&cEfO?X5bM{Z?O|fy2dLn|^BFF23pU*7bhh&R62jo{>jg
|
||||||
|
zK1nUmPQ8^U!T)HJF+<wk7Fp{p@1Fi%b*njS*|z6DkFqJuGpG!k_jX}?Q^E2HTFQ|r
|
||||||
|
zxAI=Fu@%-VnCoUBw`tLz+g;($uJ2gAHv6`Ub@1!iTKz9>Coem<Cz2yjWs2#J-50m7
|
||||||
|
zbkLSQ{_lu{asJ&+lhpgx&VO-%!yxOGt#Ipu|5Gl_D)5=^Yjvl5*PAs9w#Fv%{5{^5
|
||||||
|
zx?l7C3JwQ`$Bz!}{w=Yo&Rb=jy3h;5FS%S7PMu*p^DOh=H~s5V{=Qp1BXhr*P>0yF
|
||||||
|
z8b$%>X{^)C3-*@DyuA@9J2~_6G56^w&oy-{Np{~_CX-vqd|i4PE5pX@&FkeCP5pSN
|
||||||
|
zaFO8KIO&%$^G)g=+&a|hX2P*HiN{r2nQy6MT+sD}9%;_9&lp}_{M+U=^%<)YQ%6jO
|
||||||
|
zGpELzSD7-CgRQ=2b_d3kw7hyG@v7)GL+~PFm6fs%9E?m4GI}$vv@PcCF?^>Ztk9vb
|
||||||
|
zv}LKvlt}_jN#_zS-23<7*L()S1fIi%COPiT*}R8qp3G}$n8YOK?dZwLcBk6U+Tk_N
|
||||||
|
z`C|+ADe@%q6m4d<NOq8)b4^{`GE#Db4(suYEAly#{~xS+V_m?(&L`_;afwBf<&Pb|
|
||||||
|
zj(6kCM4tJN_6l@xY<sI9)9E11y#IXnpF2KZ&YpLD9~XIo>&L<=ExFb!*5)6X9xr?7
|
||||||
|
zUM+j!igk=m94Wu{Dkx66Bsl3D;}Ve-_A+h9gLc&1DX4iqTe;xjh0O*F^5vG;*rlvo
|
||||||
|
zn}1|^_5MT8-tOM`?Qj2vm9K<rN*9Qon#A#AFT;wfll5B|4rFkcAKYmA>&f$knuC=R
|
||||||
|
z){mqu{_k-FdEiM(s?7J@QPOYjhBy0$%0I6E|I02c|4!eEyDe*c?6;U@vRedpuyV9#
|
||||||
|
zNgE`75%^Q`OQlD`=*Nm=AD!)T2iaH@IV>Y3E&kte1mPbKuTEc7Rj+$5Znx;^`*#|A
|
||||||
|
zLw6rIbAsmwW14x){4*a6*A(th<WV}*>U{pl6m7SMmo^^0u)h4<eVKfo#g*ICl{_>H
|
||||||
|
zs{Yr^xo0mQyw6nXeg1Ejex0bsuCKQz%$+*%fNmzc1=}mxyq*W&r1y2t|MPjOyYD&$
|
||||||
|
zbDrb9#e1q|SqP{oeR(~%`N5Nq9DZ{BUA(tHe7O`F@%J73h9xcc-&;Nm)>!IbA=A3n
|
||||||
|
z^jN=*_;vr?Ji>0gmbY$f%-*s6k=28b9HD)d(v1d5CldaAymx!UhnZ6&ew^`2N&7Kr
|
||||||
|
znWcxS_qp|}^N*~#&1Jav!={LP&mtt2g(-eI@<h6A3X`xK@4=Qp=MPm!e^;bj<f{4b
|
||||||
|
z@%f3fd@@^$I_mc?`(|*?Hd~?2s#a*9ZQ_wY#n=vE>EABjRg%xWS|)$U|KCrq6xNy<
|
||||||
|
z|J#jH(o<Df)dEgV_{@LR^uhdPJ_o)WI{hGnSJQ8;;0fNt?rQTpKD^~rF7P~9`D6X^
|
||||||
|
z_@l<B@3kEd+Mx6O`R#4ZE8ad>mc8!4+w1k>l6Cnyt9JNRD80F;6>$1pR$A4=)EisV
|
||||||
|
z<>Mma8t=Sun4ZjZ@#q;*C0{c(-WScg^LBEzNd|FjN$2bo$n#!k#wJ;(pLQpzgS{)S
|
||||||
|
z>Hc%aC7N9x!6J3@+4)88wn%OlQ#$;<wa>Cwy73nOV-LlI8HLw%!xJww*maoPjJ@|!
|
||||||
|
zZI{^>;X@DAeCC%)8X4Cl9F{dx4B{{_-eUIS+7Ipz&sn!Gj(;EX<@MZdd;J}<_oeC?
|
||||||
|
zx0pScAzU`)@aDvnv>yhww^BLUXD4MYWnadi<Ye;d!IpiMGfWCqW?XzYS<s(1N_vj*
|
||||||
|
z4tue?_38`@{MYAX<oa1&*?#5a%tlXP_eUGW<J|u~G?+4J?zxn*N)`bZ4);YG5_M*u
|
||||||
|
zikLV{?ndr7Y_QmWea@GcQ{OI=)3iGKai(OYC8G$Bz4SwmE3tRK>=1F#bC9<Dqhnw-
|
||||||
|
z|FpQFvqAB4X@zydJu-FEb_*?#o53_ga@x`ly$Am$C!JrpEn@4!0~>qAp9Zg~RW929
|
||||||
|
zzxUu@;VGs4-3-SJjQ+%ZIeR|Sk9FIv&Mk$l@>$<@E^e#V?TdYV;<?$Sc`s(qZ|2$m
|
||||||
|
zeahFaDa8#xHl2CCRp7*jtt+ie`y?NoKDOemirwP5!Crr2jJ7Q)Szr33ID5V5|Fw-g
|
||||||
|
z@-=bV8{fQ%bP-_o;)?Lvwp2yTrDfJHH=WvQ;cNN#dz(vnPn_pWc-12K<G7)-aGT}T
|
||||||
|
z&fL1b>(@dPBai;J2)Nx4cayQlE?;0-Y?b1tO-4!Iuk0$0_Hy#PxOB~iS6%`ucn@D(
|
||||||
|
z`#QDb^XuF8YmcpQKm8%|t7C;NR}e={RIR}4?X^we+S?!4ubbauw=;726PAzf>q=M1
|
||||||
|
z{r~xeYtQCN^(iI2-)`<}jtJk%efs`S`!oLw70=o3mG{wRT)NnF%R@cZ#zm_c7AQ1&
|
||||||
|
z1dH@OR9hf=sUutD!Aa5V4;SavzL-D1*=Ik0oVVhG?+W_b%%Zc)AN8?|KTe2so_*Qz
|
||||||
|
zk2U*ise<Fp-GLh`)jw@A68KR4V5^AHU%M~o&mVNJ{`T;sXtu+ZPp^;kXK|P-UJ_|l
|
||||||
|
zxcIq(L6f8Rp~kB3*Ui?y&$?+PbzqCP{*g%Ls>QpO@onWzv^duAAgTM$8wR^O!M(r5
|
||||||
|
z|Em_(%${H;_%8mBc+HOGQa^7pnuj+(kZ8WuX0R&zRl&7}do#*jwst;_o^zPt&F%Q^
|
||||||
|
zV+A6cxNja<m(0YfD7E$D#dUi%EdIX{d;M4`?Si2*+vAywW^Z`4mp?FaEBE`_FOuKn
|
||||||
|
zt$KLYvp2DP+GO-$dhhLz&H25DHW;%XkjOK<EMZ^vdfnb1r;oioahmJMsRKMxvz1=V
|
||||||
|
zo!$BI@cBbK7N$Si)csG+lF{BG&ijJXZ{vpkvbU}#S;`Y0vYLFi5ztt2piS5MWAo~L
|
||||||
|
zH&@yC8tP0C{_yw}r*gs97Qq$kRvtdJHMC;mAKN!~<GbT-N^aPC@U`@|<Qp@YlU${*
|
||||||
|
zT*{4R5Qw|KL3W$jgr<TG>jasro<ErOKDJ5wIG^kOJ#Q8lcxD&bAMJ`Z&$-EYG1zfI
|
||||||
|
zV%z=a2X{x?x%tL2f7%r7&VGERjj%q`$<xPp!}|_T@zy(#d|G?MoAt97EOEH19>2ps
|
||||||
|
zU7JNtkwpT;aqw*Xb&R8FdjFCAJ~JEQHX0dR?s3iO{TSOhGa&eOL;3a^p8e-PZw>Xj
|
||||||
|
zE5vMIY%}f9^!Td}zV6TSYPlEIZSABo$5FaDE>d#F&%^H%zV%L4?AWmTm%=M)CC)`F
|
||||||
|
zTor^JYMohjujY3WNc`42+3<7weaH1{dRTrc3(2r-oyEZ1a9!K%;pz2R4_>bHNvJWZ
|
||||||
|
zk>ij1F~3~M#V`7~(mS3rJjW$`q^x;9=j*n(d%EQ8zWTiThMA35(!aH>Gfc`PWQtw1
|
||||||
|
z@_ug;;#7Ls`oEIhu7A0A<N>|)97$<E{>tUt{wNt2vy;t!#;3Ev(^5M*x;Ty*=*brH
|
||||||
|
zx&8b5lH-Ss?z4Z7kAFFN^2q0f(Gw&WXN6i{;gERzZ<~00WOJ^yopYRD+WB=HUG9x%
|
||||||
|
zc;v<OqB{5f&Wc&NO?DoaB1hZJwI}lr3wyh)`#AIVQa{DG#XHLHJL<$d5o;^^dU?JG
|
||||||
|
z-+?n5rW{(hi>*bj!j7$DN9?CX8O;9wx3`FHojmOU)3poC`VRtPnRTvi-D$samh)D_
|
||||||
|
z6X(7xPC9Hh#U_eP(Pa(G?%I~+>we^Y-t+zw7n5rLw1r8B%~H~TS%+-(Ir%QZUHZw>
|
||||||
|
zlt(X@<|jT}G<D-D*~udQCs*41Y`eQ>f5AmBo4sFjpFB-D<hkhGf+bnJmv1SUTMO^6
|
||||||
|
z{}p;8>hU>gb@c<m)uAtD&G=E2lhJQpu`N6{>CZe>LAk0=qBg(YWS=;D&S!hu)|5w|
|
||||||
|
zHZfOxnK^aEy?Tz#?{{3gdgk`RjLC&b%-s<)&#esmeMC0WtI=a}@6P@4J)5RZ)qCWB
|
||||||
|
z=-GuOcNCnYoA=!DnX-4AnEAcG2HW2Mu}S>1%`a)rgUWxsUsv4OrTy{B@yH#g*6#lB
|
||||||
|
zNBVWNkHp*;`xv*sDEWQveBtA}{EOdg-Fo7*uis<!uY8J8(qB$-Cfq(}yW`=z_K(Yt
|
||||||
|
z<{v04X?m0C8yz~QVP$s5gN3U3iIGQ_rLF48ev>LX`@@Av+XJo(WST0b&YI}5Z81p4
|
||||||
|
zgSOrB$F|L_zM!o>p(N2hbz4W_7RR>x>}ki|%T;q;eqSZ9T5J23UD?NX%+6Vq^qH?^
|
||||||
|
zvDvqG>wCZc=HyVkQ}*ufRu1>vkeqvkSIVO<*KS!=#J%kPSB1LoH@p+lQhwBWvi=Av
|
||||||
|
z>Xs1q5&TqSv@!Si^Bu|O9`C4lvUA6~SKfu52P-FhdcE@IkN@HM|0KLPWUTIV>?&ZX
|
||||||
|
zoVjj8uz25t3lH5i6gO0zKK|p~j)%HYOvkKy@7>%Xdw$;+vuk^PGqwnvIDN9kP;F&7
|
||||||
|
zo99Z~rWtpqOn6wXdH0mB{Gyt-VKHC%7k|86ooBJKOXB*=#oL<u?e4R$U%KGyMeBBn
|
||||||
|
z!xv^f+$v&sYcqe~?LyaClNk>EFn#~?t#QTA-mf33bW_-BI=@WSQeIldX?)ByChR~z
|
||||||
|
z&wTOScDKKOZSmcFY2B<}ZW9zze%PG&QZ3w`@@wyj50wswbWM7vPnyfaab*3@*PClk
|
||||||
|
ze5jl#`a%7j{!}-wJmK`7#>BSk$y;~))4P-6EX{oTqD6Xwe1-1R2-XVS)?&7Ah32(J
|
||||||
|
zY!6r5ePrdj{&>;(WFIZR)U5ZyHzs-ZA8+H?FYe`K5%swG@%j91n>(_fz3;V`@4FTd
|
||||||
|
zr1aqPwjJS$p}g~hBpy@*#tK|WvF5z=)a9quz9p|G&+h#5hM{e~CByVN^<_!=O&0P6
|
||||||
|
zCx50t{?vW$s5pDpMQd~8jU_gb*)y)O`EFx&bXa9{P4o9DGfh=KdA`MpcUs=~*!$g^
|
||||||
|
z`EXSgci!x_J3jSE%`2bH_GNVVc=o>Y+lK|lq7(hxbQUPyX*t|}ctX%JWry@`ogl6&
|
||||||
|
zS7eXvV{V@?_oe;HzmNSt-T!*hcAM?{75k0<-ea+_k=)P|pMAg4dBsZWH*O0=co~&S
|
||||||
|
zl^YzKlZCgvy>a=`+N8(3JLhhR*)96~=C)*hDXH03x%rJ-m}Mq+bT*Wm<@o2^y!Pqt
|
||||||
|
z5x;G+Ij7GH?>7qy-{=?1Zt&*Pk?ecF8GdKKin=<h^wrr#2mZ~nT2uL+Ips&~+C{yW
|
||||||
|
z`7ZQnGoGHb;$D3Brt8<5l3Ewcb#xOHJQWw+{p#=54LY2D?hSf68oWO~-0{wT?BH9w
|
||||||
|
zNMMWd?8p`cnH4LK7whlsJ=387bjJR?rwkvTO?t(=b;tXU(~|=alxTc(aRIRdYZq)g
|
||||||
|
zJ=>~k53@p?+cePwo339^I@x8acl-H^vi*IJcj!EvB$~b9&DX6b&T=i8^5XK^c**1&
|
||||||
|
z3fd2jua}Q(+OD<TL38@2-2QgseMeUZ3FU|89b7LT`H0ItXV>8?rTpw2m$!L&-Kl*)
|
||||||
|
zx#H{E-hiMcj^!a;Pv6~T*3kU;Dq6bm`(FQJ=e#{Wp1A)vPU`5HfFK91#+j<!9}Wng
|
||||||
|
z7VN#nxzBzdZ;p4j4MVx`ii|7AnEiE*pPA%3VdM3K1?3y<f+Dyr486opocZvu|NMV_
|
||||||
|
zP^PN;bGZJ9=;QPDU289Y{LUw<EPYJc{;_rM{+(6Ea<%VFbN(fm7j0&G_q%@A#E;Q$
|
||||||
|
z8C;rXMygk<BrsH3&iqkuJ?@U>cmEv)FJ{iDsboARKBwK7?Tg*Zv<vg!-R-|Bz5Vgj
|
||||||
|
z?Pf{ASM@wJCoSsSd{lp$+Cf=yMhUOVnjq_KX>ESbwzp^HKIkuH{eS<P|AxfReovmV
|
||||||
|
z7*r(0R?pL5OndZ-}d6<NN=r~jJX`1;bW=F?BRD_6`8B%hq3`7x=jZ*NrGt;Fc1
|
||||||
|
z@p83I=Jl3+ZoyX?-&!OTKk>C^DL=2|tyy8KwXwSV(qx;rA3nX1`}*t0j)k0GwwIrJ
|
||||||
|
zchBLhtn%MqKOW1-^RT&s$36{%)9>lDXb7E>dUL}!A-Uw&-9vLHJa3(F>eC^MZ|cWp
|
||||||
|
zRKA`z)rOJbU_!vOY13w1dog*fSy*VOD0{PGWaw7W`T769RX3lX^<j(iF@eCwEjDqB
|
||||||
|
zWtkgRuD;)T=`T+{!-h{kRAxv=&b}WRxG(YSr%9#^!3GhkkNMNw-5M6V8SIFdHZAV8
|
||||||
|
zTl4BV!zoijeN@&nGQ4#$6=a!E))H)Hd)VXY#CPiO!IQqNj?tGNX)eFkDeSyy&z>8F
|
||||||
|
z9}o6;2A$X$6t#Fu-yw;)LO&ELKko?%s#>shucNeW_2Esd+6)Pgj!e+f*T1$rs_&BC
|
||||||
|
z&#Le5ikbsFSbl#K&DW`YbGZ1|DSP+5%-;ncDy9A`>M1)ByEy1q=~vyj^OHB&)E#uI
|
||||||
|
zPwd%wyuRRTZgyPtyTg$2!CQYrHs<Lk+*zm0=3>IogFXt?W$2Y@Jij24{pm-}#};PI
|
||||||
|
z;*&1_n7#G1{5rd`yxYa8R}_`S7RfN|`0?9V-^k~4OTgrryj!-(GNnk#%WUnPGVM~3
|
||||||
|
z3)3-+^&F2Dnd(p5Q~oBV?8v1Z%JmYg%}bT|g51Q<>4>-#Fa5-Ky-c|0qHW#H6KA40
|
||||||
|
zG?zYYEt|DX;@QpDr@c<~G3;5u*kL_M`sEKnuAPeeciixqQ_-wHr~Z~Ki(#;q_We$`
|
||||||
|
z88dzTi(4w@h0Doj9=AJSp!$zF{@=bme^z>Y{`rchXw7A}&oXAOUhVc1zZC3ppuZ(x
|
||||||
|
ziq@8*>Py>?S)4iW*v<WUZpF)g=dHBPtM@X?l;5mC9%S5lId*#QnFQUXK`J6W+wNbv
|
||||||
|
zC6Zf_dGOV@m}^U$m}SJ}d$i}2$~HgqIBm6j_r+6ZUq<KNw&t)|zA5M5=DAa!Pn;v_
|
||||||
|
zcx36)>#qVdR1Ww|P5nH7>MhBa78fmD7R6Qm5jrEY*dciOzSOj*AH9_V-&<U?46;_%
|
||||||
|
zKK;y|JB_Q=X(M~X4sF&=OC-)}o-GVcn|oO7tm2~A9UOhKS6Q~y&i|&Dn4dW1>BNn;
|
||||||
|
z9P?CXKM&EHzkjjqi`#iG8IB)jPv%OGa}@a8vgw#~?((KLcYKy@O^~TJJuI?TTf1_z
|
||||||
|
z_U9TcONXSJ5^Gj1l8{<f*lL`gDC8OxnQn1W%|Kn=u48Jl=hKf)*58&NNsCL3JUO4C
|
||||||
|
zz(8WkQs?%fbe-3`+L>kc=9n;4e7Q3J8TYAKj1SVCBJI?r3^)44PGm94?b)|gnL*Ah
|
||||||
|
zW`DxpP40X$(e{lhx|2MAU9?H>`}eOWanjKk4oluUQ#TbG&z!~}u{UJ$%)S4PvPMtV
|
||||||
|
zJ->Vp^Be|wA2p%krCZ$0CeIChp5uGK&v@py*fitn*vOOGCucq1-Oa4>!I8tEuyCWM
|
||||||
|
z#*c9CxffrXsr~WN^tV@+>I`19iTP#pN*g;1zb&&QzFW<g`*ZkMZ~YeMg%dQ~o?qB*
|
||||||
|
zdz-P(SzBD>Uvxd||MfF|91n^*)oGT=v|WDfnH-5Dryfs@y3J>3`%0na{jp8#`(JSV
|
||||||
|
zGJDIQ?!leB{G66TW2MTKCEq5_?Vpx;6FyH7Y<Q>Ko!vrjt<AE6W2P>PBDBs2gqP+0
|
||||||
|
z-E?tkrmN4g-Y~=27j|u8^`6wY&)3?4JLP%d!TG;$e!I)rC>a!4>1T50Ov=7>vroRQ
|
||||||
|
zNLpx9cd0LyxtTS^Rc@6|t-_s|M!B~bZ*j`U*t`{Q=84|ySh88*(~-MA?kBfMCDb`h
|
||||||
|
zuq{7(>37-ks(J4V`3h=k42qd1&T#jNE%PxKo>hJmF-24|-R6F<tin8T7XynPp&tpi
|
||||||
|
z*1Tll@=Trk)@3#WgW<0-_2%yDx^wrx@R)c1l*4z<?=Mzt`tU^VX-sJ7sf7U^Q>L#!
|
||||||
|
z^!)a|zyA(6irZ!`DPWLdRyw}HSiYCnd`)!S|EdN}k*0_pAIg}g7++ev`(g7@&gJsf
|
||||||
|
zrN<_03{K~<y!Zdo;%fe^yf-)QzZCi^XYQmpG2+%z_o^>PKK_5cChAIJI^!Y+1_lOC
|
||||||
|
LS3j3^P6<r_Bp?1#
|
||||||
|
|
||||||
|
literal 0
|
||||||
|
HcmV?d00001
|
||||||
|
|
||||||
|
diff --git a/packages/ui/src/assets/launchers/multimc.svg b/packages/ui/src/assets/launchers/multimc.svg
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..0c072ef
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/packages/ui/src/assets/launchers/multimc.svg
|
||||||
|
@@ -0,0 +1 @@
|
||||||
|
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="68.267" height="68.267"><defs><linearGradient id="g"><stop offset="0" style="stop-color:#98c867;stop-opacity:1"/><stop offset="1" style="stop-color:#5c9a33;stop-opacity:1"/></linearGradient><linearGradient id="a"><stop offset="0" style="stop-color:#75b54b;stop-opacity:1"/><stop offset="1" style="stop-color:#75b54b;stop-opacity:.6"/></linearGradient><linearGradient id="b"><stop offset="0" style="stop-color:#000;stop-opacity:.8"/><stop offset="1" style="stop-color:#000;stop-opacity:.35"/></linearGradient><linearGradient xlink:href="#a" id="n" x1="6.734" x2="50.507" y1="28.511" y2="61.774" gradientTransform="matrix(1.26715 0 0 .8979 -.02 -.008)" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#b" id="m" x1="14.312" x2="44.097" y1="9.795" y2="82.973" gradientTransform="scale(1.26715 .8979)" gradientUnits="userSpaceOnUse"/><linearGradient id="d"><stop offset="0" style="stop-color:#000;stop-opacity:.0627451"/><stop offset="1" style="stop-color:#322217;stop-opacity:.58823532"/></linearGradient><linearGradient id="f"><stop offset="0" style="stop-color:#a3704b;stop-opacity:1"/><stop offset="1" style="stop-color:#6a4a33;stop-opacity:1"/></linearGradient><linearGradient id="c"><stop offset="0" style="stop-color:#966c4a;stop-opacity:1"/><stop offset="1" style="stop-color:#593d29;stop-opacity:1"/></linearGradient><linearGradient xlink:href="#c" id="h" x1="36.546" x2="86.416" y1="33.805" y2="97.066" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#d" id="k" x1="29.867" x2="38.4" y1="29.868" y2="38.401" gradientTransform="translate(-.001)" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#b" id="l" x1="14.312" x2="44.097" y1="9.795" y2="82.973" gradientTransform="matrix(1.26715 0 0 .8979 -.829 -1.001)" gradientUnits="userSpaceOnUse"/><radialGradient xlink:href="#f" id="i" cx="34.133" cy="34.133" r="29.867" fx="34.133" fy="34.133" gradientTransform="translate(0 -.001)" gradientUnits="userSpaceOnUse"/><radialGradient xlink:href="#g" id="j" cx="-42.668" cy="-34.134" r="34.133" fx="-42.668" fy="-34.134" gradientTransform="matrix(1.75003 .125 -.01781 .24936 95.394 18.11)" gradientUnits="userSpaceOnUse"/></defs><g transform="translate(0 .001)"><rect width="68.267" height="68.267" rx="8.533" ry="8.533" style="fill:url(#h);fill-opacity:1;stroke:none;stroke-width:17.06666756"/><rect width="59.733" height="59.733" x="4.266" y="4.266" rx="4.267" ry="4.267" style="fill:url(#i);fill-opacity:1;stroke:none;stroke-width:14.93333435"/><path d="m4.267 4.265-.001 8.533h4.268v4.267h8.533v4.266h4.265V8.532H25.6v8.533h4.266v-4.267h4.267v8.533H38.4v-4.266h4.267v4.266h4.268v-4.266H51.2v-4.267h4.267v4.267H64v-12.8z" style="opacity:.6;fill:#593d29;fill-opacity:1;stroke:none;stroke-width:17.06666756"/><path d="M8.533-.002A8.514 8.514 0 0 0 0 8.531V12.8h4.265V8.531h4.268V12.8h8.533v4.266h4.266v-12.8h4.267v8.533h4.266V8.531h4.268v8.534h4.265v-4.266h4.268v4.266h4.267v-4.266H51.2V8.531h4.268V12.8H64V8.531h4.265a8.514 8.514 0 0 0-8.533-8.533zm-3.02 5.514" style="fill:url(#j);fill-opacity:1;stroke:none;stroke-width:17.06666756"/><path d="M8.532 0A8.514 8.514 0 0 0 0 8.531v51.2a8.516 8.516 0 0 0 8.533 8.535h51.2a8.516 8.516 0 0 0 8.533-8.535v-51.2A8.514 8.514 0 0 0 59.732 0zm0 4.267h51.2a4.257 4.257 0 0 1 4.267 4.265v51.2a4.26 4.26 0 0 1-4.267 4.267h-51.2a4.26 4.26 0 0 1-4.267-4.267v-51.2a4.257 4.257 0 0 1 4.267-4.265" style="opacity:1;fill:url(#k);fill-opacity:1;stroke:none;stroke-width:17.06666756"/></g><path d="M38.887 44.94q-1.462-1.2-3.772-4.008-3.157 4.009-5.94 5.78-3.488 2.136-8.909 2.137-6.363 0-10.56-3.374-4.43-3.54-4.43-9.386 0-5.611 4.43-9.42 3.96-3.374 10.655-3.374 3.442 0 5.986 1.002 2.97 1.102 4.997 3.007 1.885 1.704 3.771 4.008 3.159-4.008 5.94-5.779 3.489-2.137 8.91-2.137 6.363 0 10.56 3.373 4.43 3.54 4.43 9.387 0 5.61-4.43 9.42-3.96 3.373-10.655 3.373-3.442 0-5.986-1.002-2.546-.901-4.997-3.006m-19.14-.468q7.779 0 12.446-8.017-5.988-8.751-12.445-8.751-4.715 0-7.119 2.405-2.592 2.572-2.592 5.979 0 3.741 2.592 6.046 2.64 2.338 7.119 2.338m30.735-16.702q-7.023 0-12.445 8.017 5.94 8.752 12.445 8.752 4.715 0 7.118-2.405 2.593-2.572 2.593-5.98 0-3.74-2.593-6.045-2.64-2.34-7.118-2.339" style="font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:76.18933868px;line-height:125%;font-family:"DejaVu Sans";-inkscape-font-specification:"DejaVu Sans";letter-spacing:0;word-spacing:0;fill:url(#l);fill-opacity:1;stroke:none;stroke-width:1.06666672;opacity:.5"/><path d="M39.715 45.942q-1.461-1.203-3.77-4.008-3.16 4.008-5.94 5.779-3.489 2.137-8.91 2.137-6.364 0-10.56-3.373-4.43-3.54-4.43-9.387 0-5.61 4.43-9.42 3.96-3.373 10.654-3.373 3.441 0 5.987 1.002 2.97 1.103 4.997 3.006 1.886 1.704 3.771 4.009 3.159-4.009 5.94-5.78 3.488-2.137 8.91-2.137 6.363 0 10.559 3.374 4.43 3.54 4.43 9.386 0 5.611-4.43 9.42-3.96 3.374-10.654 3.374-3.441 0-5.987-1.003-2.546-.9-4.997-3.006m-19.139-.467q7.779
Line truncated
|
||||||
|
\ No newline at end of file
|
||||||
|
diff --git a/packages/ui/src/assets/launchers/polymc.svg b/packages/ui/src/assets/launchers/polymc.svg
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..c192d50
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/packages/ui/src/assets/launchers/polymc.svg
|
||||||
|
@@ -0,0 +1,21 @@
|
||||||
|
+<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
+<svg width="64" height="64" version="1.1" viewBox="0 0 16.933 16.933" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
+ <defs>
|
||||||
|
+ <linearGradient id="linearGradient84726" x1="4.4979" x2="12.435" y1="3.8011" y2="9.5681" gradientUnits="userSpaceOnUse">
|
||||||
|
+ <stop stop-color="#88b858" offset="0"/>
|
||||||
|
+ <stop stop-color="#72b147" offset=".5"/>
|
||||||
|
+ <stop stop-color="#5a9a30" offset="1"/>
|
||||||
|
+ </linearGradient>
|
||||||
|
+ </defs>
|
||||||
|
+ <g>
|
||||||
|
+ <path d="m3.561 16.016s0-3.5642 4.9056-3.5642c4.9069 0 4.9056 3.5642 4.9056 3.5642z" fill="#765338"/>
|
||||||
|
+ <path d="m8.4667 12.452-4.9056 3.5642-3.0319-9.3311z" fill="#b7835a"/>
|
||||||
|
+ <path d="m8.4667 12.452 7.9375-5.7669-3.0319 9.3311z" fill="#5b422d"/>
|
||||||
|
+ <path d="m8.8308 12.716-0.36417 0.26458-0.36417-0.26458c0-0.26458 0.36417-0.26458 0.36417-0.26458s0.36417 0 0.36417 0.26458z" fill="#72b147"/>
|
||||||
|
+ <path d="m8.4667 12.452s-2e-7 -5.7669 7.9375-5.7669l-0.22507 0.69269-0.91853 1.1965-0.91853 0.13819-0.91853 1.1965-0.91853 0.13819-0.91853 1.1965-0.91853 0.13819-0.91853 1.1965-0.91853 0.13819z" fill="#5a9a30"/>
|
||||||
|
+ <path d="m8.1025 12.716-0.91853-0.13819-0.91853-1.1965-0.91853-0.13819-0.91853-1.1965-0.91853-0.13819-0.91853-1.1965-0.91853-0.13819-0.91853-1.1965-0.22507-0.69269c7.9375 1e-7 7.9375 5.7669 7.9375 5.7669z" fill="#88b858"/>
|
||||||
|
+ <path d="m0.52917 6.6846 7.9375 5.7669 7.9375-5.7669-7.9375-5.7669z" fill="url(#linearGradient84726)"/>
|
||||||
|
+ </g>
|
||||||
|
+ <path d="m0.75424 7.3773-0.22507-0.69269 7.9375 5.7669 7.9375-5.7669-0.22507 0.69269-7.7124 5.6034z" fill-opacity="0"/>
|
||||||
|
+</svg>
|
||||||
|
diff --git a/packages/ui/src/assets/launchers/prism-launcher.svg b/packages/ui/src/assets/launchers/prism-launcher.svg
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..aeee843
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/packages/ui/src/assets/launchers/prism-launcher.svg
|
||||||
|
@@ -0,0 +1,57 @@
|
||||||
|
+<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
+<svg width="48" height="48" version="1.1" viewBox="0 0 12.7 12.7" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||||
|
+ <title>Prism Launcher Logo</title>
|
||||||
|
+ <g stroke-width=".26458">
|
||||||
|
+ <path d="m6.35 6.35" fill="#99cd61"/>
|
||||||
|
+ <path d="m6.35 0.52917-2.5208 4.3656 2.5208 1.4552 2.5203-1.4552 0.10955-3.0996c-1.1511-0.66459-2.3388-1.2661-2.6298-1.2661z" fill="#df6277"/>
|
||||||
|
+ <path d="m8.9798 1.7952-2.6298 4.5548 2.5203 1.4552 2.5208-4.3656c-0.14552-0.25205-1.2601-0.97975-2.4112-1.6443z" fill="#fb9168"/>
|
||||||
|
+ <path d="m11.391 3.4396-5.041 2.9104 2.5203 1.4552 2.7389-1.4552c0-1.3292-0.072554-2.6584-0.21808-2.9104z" fill="#f3db6c"/>
|
||||||
|
+ <path d="m6.35 6.35v2.9104h5.041c0.14552-0.25205 0.21807-1.5812 0.21808-2.9104h-5.2591z" fill="#7ab392"/>
|
||||||
|
+ <path d="m6.35 6.35v2.9104l2.6298 1.6443c1.1511-0.66459 2.2657-1.3923 2.4112-1.6443l-5.041-2.9104z" fill="#4b7cbc"/>
|
||||||
|
+ <path d="m6.35 6.35-2.5208 1.4552 2.5208 4.3656c0.29104 0 1.4787-0.60148 2.6298-1.2661l-2.6298-4.5548z" fill="#6f488c"/>
|
||||||
|
+ <path d="m3.8292 4.8948-2.5203 4.3656c0.29104 0.5041 4.459 2.9104 5.041 2.9104v-5.8208l-2.5208-1.4552z" fill="#4d3f33"/>
|
||||||
|
+ <path d="m1.309 3.4396c-0.29104 0.5041-0.29104 5.3167 0 5.8208l5.041-2.9104v-2.9104h-5.041z" fill="#7a573b"/>
|
||||||
|
+ <path d="m6.35 0.52917c-0.58208-2e-8 -4.75 2.4063-5.041 2.9104l5.041 2.9104v-5.8208z" fill="#99cd61"/>
|
||||||
|
+ </g>
|
||||||
|
+ <g transform="matrix(.88 0 0 .88 -10.906 -1.2421)">
|
||||||
|
+ <g transform="translate(13.26 2.2776)">
|
||||||
|
+ <path transform="matrix(.96975 0 0 .96975 .1921 .1921)" d="m6.3498 2.9393c-0.34105 0-2.7827 1.4099-2.9532 1.7052l2.9532 5.1157 2.9538-5.1157c-0.17052-0.29535-2.6127-1.7052-2.9538-1.7052z" fill="#fff" stroke-width=".26458"/>
|
||||||
|
+ </g>
|
||||||
|
+ <path d="m16.746 6.9737 2.8639 4.9609c0.33073 0 2.6991-1.3672 2.8644-1.6536 0.16536-0.28642 0.16536-3.0209 0-3.3073l-2.8644 1.6536z" fill="#dfdfdf" stroke-width=".26458"/>
|
||||||
|
+ </g>
|
||||||
|
+ <path d="m3.8299 4.8948c-0.14551 0.25205-0.14553 2.6584 0 2.9104 0.14553 0.25204 2.2292 1.4552 2.5203 1.4552v-2.9104z" fill="#d6d2d2" stroke-width=".26458"/>
|
||||||
|
+ <metadata>
|
||||||
|
+ <rdf:RDF>
|
||||||
|
+ <cc:Work rdf:about="">
|
||||||
|
+ <dc:title>Prism Launcher Logo</dc:title>
|
||||||
|
+ <dc:date>19/10/2022</dc:date>
|
||||||
|
+ <dc:creator>
|
||||||
|
+ <cc:Agent>
|
||||||
|
+ <dc:title>Prism Launcher</dc:title>
|
||||||
|
+ </cc:Agent>
|
||||||
|
+ </dc:creator>
|
||||||
|
+ <dc:contributor>
|
||||||
|
+ <cc:Agent>
|
||||||
|
+ <dc:title>AutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zeke</dc:title>
|
||||||
|
+ </cc:Agent>
|
||||||
|
+ </dc:contributor>
|
||||||
|
+ <dc:source>https://github.com/PrismLauncher/PrismLauncher</dc:source>
|
||||||
|
+ <dc:publisher>
|
||||||
|
+ <cc:Agent>
|
||||||
|
+ <dc:title>Prism Launcher</dc:title>
|
||||||
|
+ </cc:Agent>
|
||||||
|
+ </dc:publisher>
|
||||||
|
+ <cc:license rdf:resource="http://creativecommons.org/licenses/by-sa/4.0/"/>
|
||||||
|
+ </cc:Work>
|
||||||
|
+ <cc:License rdf:about="http://creativecommons.org/licenses/by-sa/4.0/">
|
||||||
|
+ <cc:permits rdf:resource="http://creativecommons.org/ns#Reproduction"/>
|
||||||
|
+ <cc:permits rdf:resource="http://creativecommons.org/ns#Distribution"/>
|
||||||
|
+ <cc:requires rdf:resource="http://creativecommons.org/ns#Notice"/>
|
||||||
|
+ <cc:requires rdf:resource="http://creativecommons.org/ns#Attribution"/>
|
||||||
|
+ <cc:permits rdf:resource="http://creativecommons.org/ns#DerivativeWorks"/>
|
||||||
|
+ <cc:requires rdf:resource="http://creativecommons.org/ns#ShareAlike"/>
|
||||||
|
+ </cc:License>
|
||||||
|
+ </rdf:RDF>
|
||||||
|
+ </metadata>
|
||||||
|
+</svg>
|
||||||
|
diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/ImportInstanceStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/ImportInstanceStage.vue
|
||||||
|
index a4dfcdb..5384d29 100644
|
||||||
|
--- a/packages/ui/src/components/flows/creation-flow-modal/components/ImportInstanceStage.vue
|
||||||
|
+++ b/packages/ui/src/components/flows/creation-flow-modal/components/ImportInstanceStage.vue
|
||||||
|
@@ -51,6 +51,7 @@
|
||||||
|
@update:model-value="toggleLauncherAll(launcher, $event)"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
+ <LauncherMark :name="launcher.name" />
|
||||||
|
<span class="font-semibold text-contrast">{{ launcher.name }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@@ -75,6 +76,30 @@
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
+ <!-- Launchers that were not found, with where they usually are -->
|
||||||
|
+ <div v-if="otherLaunchers.length > 0" class="flex flex-col gap-2">
|
||||||
|
+ <span class="text-sm font-semibold text-secondary">
|
||||||
|
+ {{ formatMessage(messages.otherLaunchers) }}
|
||||||
|
+ </span>
|
||||||
|
+ <button
|
||||||
|
+ v-for="suggestion in otherLaunchers"
|
||||||
|
+ :key="suggestion.name"
|
||||||
|
+ class="button-base flex w-full cursor-pointer items-center gap-3 rounded-2xl border border-solid border-surface-4 bg-surface-2 p-3 text-left"
|
||||||
|
+ :disabled="scanning === suggestion.name"
|
||||||
|
+ @click="useSuggestion(suggestion)"
|
||||||
|
+ >
|
||||||
|
+ <LauncherMark :name="suggestion.name" />
|
||||||
|
+ <span class="flex min-w-0 flex-col">
|
||||||
|
+ <span class="font-semibold text-contrast">{{ suggestion.name }}</span>
|
||||||
|
+ <span class="truncate text-xs text-secondary">
|
||||||
|
+ {{ suggestion.path || formatMessage(messages.suggestionNoPath) }}
|
||||||
|
+ </span>
|
||||||
|
+ </span>
|
||||||
|
+ <SpinnerIcon v-if="scanning === suggestion.name" class="ml-auto size-5 animate-spin" />
|
||||||
|
+ <PlusIcon v-else class="ml-auto size-5 shrink-0 text-secondary" />
|
||||||
|
+ </button>
|
||||||
|
+ </div>
|
||||||
|
+
|
||||||
|
<!-- Add launcher path -->
|
||||||
|
<div v-if="!showAddPath">
|
||||||
|
<Button class="w-full" @click="showAddPath = true">
|
||||||
|
@@ -99,18 +124,25 @@
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
-import { ChevronRightIcon, FolderSearchIcon, SearchIcon } from '@modrinth/assets'
|
||||||
|
+import {
|
||||||
|
+ ChevronRightIcon,
|
||||||
|
+ FolderSearchIcon,
|
||||||
|
+ PlusIcon,
|
||||||
|
+ SearchIcon,
|
||||||
|
+ SpinnerIcon,
|
||||||
|
+} from '@modrinth/assets'
|
||||||
|
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { Button, IconButton } from '#ui/components/base/buttons'
|
||||||
|
|
||||||
|
import { injectInstanceImport, injectNotificationManager } from '../../../../providers'
|
||||||
|
-import type { ImportableLauncher } from '../../../../providers/instance-import'
|
||||||
|
+import type { ImportableLauncher, LauncherSuggestion } from '../../../../providers/instance-import'
|
||||||
|
import Checkbox from '../../../base/Checkbox.vue'
|
||||||
|
import Collapsible from '../../../base/Collapsible.vue'
|
||||||
|
import Input from '../../../base/inputs/Input.vue'
|
||||||
|
import { injectCreationFlowContext } from '../creation-flow-context'
|
||||||
|
+import LauncherMark from './LauncherMark.vue'
|
||||||
|
|
||||||
|
const ctx = injectCreationFlowContext()
|
||||||
|
const importProvider = injectInstanceImport()
|
||||||
|
@@ -122,6 +154,8 @@ const expandedLaunchers = ref(new Set<string>())
|
||||||
|
const expandedBeforeSearch = ref<Set<string> | null>(null)
|
||||||
|
const showAddPath = ref(false)
|
||||||
|
const newLauncherPath = ref('')
|
||||||
|
+const suggestions = ref<LauncherSuggestion[]>([])
|
||||||
|
+const scanning = ref<string | null>(null)
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
launcherInstancesTitle: {
|
||||||
|
@@ -160,6 +194,14 @@ const messages = defineMessages({
|
||||||
|
id: 'creation-flow.modal.import-instance.notification.no-instances-found.text',
|
||||||
|
defaultMessage: 'No importable instances were found at the specified path.',
|
||||||
|
},
|
||||||
|
+ otherLaunchers: {
|
||||||
|
+ id: 'creation-flow.modal.import-instance.other-launchers',
|
||||||
|
+ defaultMessage: 'Other launchers',
|
||||||
|
+ },
|
||||||
|
+ suggestionNoPath: {
|
||||||
|
+ id: 'creation-flow.modal.import-instance.other-launchers.no-path',
|
||||||
|
+ defaultMessage: 'Pick the folder it keeps its instances in',
|
||||||
|
+ },
|
||||||
|
customLauncherName: {
|
||||||
|
id: 'creation-flow.modal.import-instance.custom-launcher.name',
|
||||||
|
defaultMessage: 'Custom ({pathName})',
|
||||||
|
@@ -182,9 +224,65 @@ onMounted(async () => {
|
||||||
|
} catch {
|
||||||
|
ctx.importLaunchers.value = []
|
||||||
|
}
|
||||||
|
+
|
||||||
|
+ try {
|
||||||
|
+ suggestions.value = (await importProvider.getLauncherSuggestions?.()) ?? []
|
||||||
|
+ } catch {
|
||||||
|
+ suggestions.value = []
|
||||||
|
+ }
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
+// Whatever is already listed above needs no offer of its own.
|
||||||
|
+const otherLaunchers = computed(() =>
|
||||||
|
+ suggestions.value.filter(
|
||||||
|
+ (suggestion) =>
|
||||||
|
+ !ctx.importLaunchers.value.some((launcher) => launcher.name === suggestion.name),
|
||||||
|
+ ),
|
||||||
|
+)
|
||||||
|
+
|
||||||
|
+/**
|
||||||
|
+ * Reads the instances a launcher keeps where it usually does. With none there,
|
||||||
|
+ * its folder goes into the path field to be corrected by hand.
|
||||||
|
+ */
|
||||||
|
+async function useSuggestion(suggestion: LauncherSuggestion) {
|
||||||
|
+ if (scanning.value) return
|
||||||
|
+ scanning.value = suggestion.name
|
||||||
|
+
|
||||||
|
+ try {
|
||||||
|
+ const instances = suggestion.path
|
||||||
|
+ ? await importProvider.getImportableInstances(suggestion.launcher, suggestion.path)
|
||||||
|
+ : []
|
||||||
|
+ if (instances.length > 0) {
|
||||||
|
+ addLauncher({
|
||||||
|
+ name: suggestion.name,
|
||||||
|
+ launcher: suggestion.launcher,
|
||||||
|
+ path: suggestion.path,
|
||||||
|
+ instances,
|
||||||
|
+ })
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+ } catch {
|
||||||
|
+ // Falls through to picking the folder by hand.
|
||||||
|
+ } finally {
|
||||||
|
+ scanning.value = null
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ newLauncherPath.value = suggestion.path
|
||||||
|
+ showAddPath.value = true
|
||||||
|
+ addNotification({
|
||||||
|
+ type: 'warning',
|
||||||
|
+ title: formatMessage(messages.noInstancesFoundTitle),
|
||||||
|
+ text: formatMessage(messages.noInstancesFoundText),
|
||||||
|
+ })
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+function addLauncher(launcher: ImportableLauncher) {
|
||||||
|
+ ctx.importLaunchers.value = [...ctx.importLaunchers.value, launcher]
|
||||||
|
+ expandedLaunchers.value.add(launcher.name)
|
||||||
|
+ expandedLaunchers.value = new Set(expandedLaunchers.value)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
// Filter instances by search query
|
||||||
|
function filteredInstances(launcher: ImportableLauncher): string[] {
|
||||||
|
const query = ctx.importSearchQuery.value.toLowerCase().trim()
|
||||||
|
@@ -316,16 +414,13 @@ async function addLauncherPath() {
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
- const launcher: ImportableLauncher = {
|
||||||
|
+ addLauncher({
|
||||||
|
name: formatMessage(messages.customLauncherName, {
|
||||||
|
pathName: path.split(/[\\/]/).pop() || path,
|
||||||
|
}),
|
||||||
|
path,
|
||||||
|
instances,
|
||||||
|
- }
|
||||||
|
- ctx.importLaunchers.value = [...ctx.importLaunchers.value, launcher]
|
||||||
|
- expandedLaunchers.value.add(launcher.name)
|
||||||
|
- expandedLaunchers.value = new Set(expandedLaunchers.value)
|
||||||
|
+ })
|
||||||
|
} catch {
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/LauncherMark.vue b/packages/ui/src/components/flows/creation-flow-modal/components/LauncherMark.vue
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..aa1b97d
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/packages/ui/src/components/flows/creation-flow-modal/components/LauncherMark.vue
|
||||||
|
@@ -0,0 +1,55 @@
|
||||||
|
+<!--
|
||||||
|
+ A launcher's own logo, or its initials in a badge for one we have none of.
|
||||||
|
+ See src/assets/launchers/README.md.
|
||||||
|
+-->
|
||||||
|
+<template>
|
||||||
|
+ <!-- CurseForge's is a wide mark rather than a square icon, so it gets a badge. -->
|
||||||
|
+ <span
|
||||||
|
+ v-if="name === 'CurseForge'"
|
||||||
|
+ class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-surface-4"
|
||||||
|
+ >
|
||||||
|
+ <CurseForgeIcon class="size-5 text-contrast" />
|
||||||
|
+ </span>
|
||||||
|
+ <img v-else-if="logo" :src="logo" :alt="''" class="size-8 shrink-0 rounded-lg" />
|
||||||
|
+ <span
|
||||||
|
+ v-else
|
||||||
|
+ class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-surface-4 text-xs font-extrabold text-contrast"
|
||||||
|
+ aria-hidden="true"
|
||||||
|
+ >
|
||||||
|
+ {{ mark }}
|
||||||
|
+ </span>
|
||||||
|
+</template>
|
||||||
|
+
|
||||||
|
+<script setup lang="ts">
|
||||||
|
+import { CurseForgeIcon } from '@modrinth/assets'
|
||||||
|
+import { computed } from 'vue'
|
||||||
|
+
|
||||||
|
+// As URLs, not as components: these are the launchers' own artwork, colours
|
||||||
|
+// and all, rather than an icon that takes the colour around it.
|
||||||
|
+import atlauncher from '../../../../assets/launchers/atlauncher.svg?url'
|
||||||
|
+import gdlauncher from '../../../../assets/launchers/gdlauncher.png'
|
||||||
|
+import multimc from '../../../../assets/launchers/multimc.svg?url'
|
||||||
|
+import polymc from '../../../../assets/launchers/polymc.svg?url'
|
||||||
|
+import prismLauncher from '../../../../assets/launchers/prism-launcher.svg?url'
|
||||||
|
+
|
||||||
|
+const props = defineProps<{ name: string }>()
|
||||||
|
+
|
||||||
|
+const LOGOS: Record<string, string> = {
|
||||||
|
+ 'Prism Launcher': prismLauncher,
|
||||||
|
+ MultiMC: multimc,
|
||||||
|
+ PolyMC: polymc,
|
||||||
|
+ ATLauncher: atlauncher,
|
||||||
|
+ GDLauncher: gdlauncher,
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+const logo = computed(() => LOGOS[props.name])
|
||||||
|
+
|
||||||
|
+// Custom (foo) gives C, MultiMC would give MM.
|
||||||
|
+const mark = computed(
|
||||||
|
+ () =>
|
||||||
|
+ props.name
|
||||||
|
+ .match(/\p{Lu}/gu)
|
||||||
|
+ ?.slice(0, 2)
|
||||||
|
+ .join('') ?? props.name.slice(0, 2).toUpperCase(),
|
||||||
|
+)
|
||||||
|
+</script>
|
||||||
|
diff --git a/packages/ui/src/providers/instance-import.ts b/packages/ui/src/providers/instance-import.ts
|
||||||
|
index 13c73a1..e9a9767 100644
|
||||||
|
--- a/packages/ui/src/providers/instance-import.ts
|
||||||
|
+++ b/packages/ui/src/providers/instance-import.ts
|
||||||
|
@@ -2,13 +2,27 @@ import { createContext } from '.'
|
||||||
|
|
||||||
|
export interface ImportableLauncher {
|
||||||
|
name: string
|
||||||
|
+ /** The importer that reads it, where that is not the name shown. */
|
||||||
|
+ launcher?: string
|
||||||
|
path: string
|
||||||
|
instances: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
+/** A launcher instances can be imported from, installed here or not. */
|
||||||
|
+export interface LauncherSuggestion {
|
||||||
|
+ name: string
|
||||||
|
+ /** The importer that reads it. */
|
||||||
|
+ launcher: string
|
||||||
|
+ /** Where it is, or where it would be on this system. */
|
||||||
|
+ path: string
|
||||||
|
+ installed: boolean
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
export interface InstanceImportProvider {
|
||||||
|
/** Returns launchers with instances already populated (one round trip on mount) */
|
||||||
|
getDetectedLaunchers: () => Promise<ImportableLauncher[]>
|
||||||
|
+ /** Every launcher that can be imported from, to offer the ones not found */
|
||||||
|
+ getLauncherSuggestions?: () => Promise<LauncherSuggestion[]>
|
||||||
|
/** Only needed for manually-added launcher paths */
|
||||||
|
getImportableInstances: (launcherName: string, path: string) => Promise<string[]>
|
||||||
|
/** Perform the actual import */
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Fri, 18 Sep 2026 09:43:57 +0200
|
||||||
|
Subject: [PATCH] Arrange the left sidebar
|
||||||
|
|
||||||
|
The buttons above the instances were in a fixed order, and hiding one
|
||||||
|
meant finding its switch among the feature settings, where three of them
|
||||||
|
sat next to things that have nothing to do with the sidebar.
|
||||||
|
|
||||||
|
Settings has a Sidebar tab now: every button in a list, moved up or down
|
||||||
|
and shown or hidden, with a way back to the usual order. Right-clicking a
|
||||||
|
button in the sidebar hides it, or opens that tab. The order and what is
|
||||||
|
hidden are kept on this machine, not sent to Modrinth with the synced
|
||||||
|
preferences: it is how the app looks here.
|
||||||
|
|
||||||
|
The three switches move out of the feature settings, since the tab is
|
||||||
|
where they belong; only the number of instances stays there.
|
||||||
|
---
|
||||||
|
apps/app-frontend/src/App.vue | 138 ++++++++++++------
|
||||||
|
.../components/ui/modal/AppSettingsModal.vue | 22 ++-
|
||||||
|
.../ui/settings/display/FeaturesSettings.vue | 70 ---------
|
||||||
|
.../ui/settings/display/SidebarSettings.vue | 125 ++++++++++++++++
|
||||||
|
.../src/composables/use-sidebar-layout.ts | 72 +++++++++
|
||||||
|
5 files changed, 310 insertions(+), 117 deletions(-)
|
||||||
|
create mode 100644 apps/app-frontend/src/components/ui/settings/display/SidebarSettings.vue
|
||||||
|
create mode 100644 apps/app-frontend/src/composables/use-sidebar-layout.ts
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
|
||||||
|
index 9679884..cb688fc 100644
|
||||||
|
--- a/apps/app-frontend/src/App.vue
|
||||||
|
+++ b/apps/app-frontend/src/App.vue
|
||||||
|
@@ -15,6 +15,7 @@ import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
CompassIcon,
|
||||||
|
+ EyeOffIcon,
|
||||||
|
ImageIcon,
|
||||||
|
LogInIcon,
|
||||||
|
LogOutIcon,
|
||||||
|
@@ -42,6 +43,7 @@ import {
|
||||||
|
commonSettingsMessages,
|
||||||
|
ContentInstallModal,
|
||||||
|
ContentUpdaterModal,
|
||||||
|
+ ContextMenu,
|
||||||
|
CreationFlowModal,
|
||||||
|
defineMessages,
|
||||||
|
I18nDebugPanel,
|
||||||
|
@@ -79,7 +81,6 @@ 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'
|
||||||
|
@@ -107,6 +108,7 @@ import {
|
||||||
|
markSyncInstancesUpdateNotificationShown,
|
||||||
|
shouldShowSyncInstancesUpdateNotification,
|
||||||
|
} from '@/components/ui/sync-instances-update-modal/show-notification'
|
||||||
|
+import TitleBarAccountSwitcher from '@/components/ui/TitleBarAccountSwitcher.vue'
|
||||||
|
import WindowControls from '@/components/ui/WindowControls.vue'
|
||||||
|
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||||
|
import { useAppEvent } from '@/composables/use-app-event'
|
||||||
|
@@ -114,6 +116,7 @@ import { useAppSettings } from '@/composables/use-app-settings.ts'
|
||||||
|
import { useError } from '@/composables/use-error.js'
|
||||||
|
import { useInstanceMetadataRefresh } from '@/composables/use-instance-metadata-refresh'
|
||||||
|
import { useQuickInstanceLimit } from '@/composables/use-quick-instance-limit.ts'
|
||||||
|
+import { useSidebarLayout } from '@/composables/use-sidebar-layout'
|
||||||
|
import { isDarkTheme, useTheme } from '@/composables/use-theme.ts'
|
||||||
|
import { config } from '@/config'
|
||||||
|
import { getAccountAppearance, rememberAccountAppearance } from '@/helpers/account-appearance.ts'
|
||||||
|
@@ -259,6 +262,73 @@ const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
|
||||||
|
const credentials = ref()
|
||||||
|
const storedModrinthAccounts = ref([])
|
||||||
|
let credentialsRefreshId = 0
|
||||||
|
+// The buttons above the instances. Which of them are shown, and in what order,
|
||||||
|
+// is up to the player: Settings > Sidebar, or a right-click on one of them.
|
||||||
|
+const sidebarLayout = useSidebarLayout()
|
||||||
|
+const sidebarMenu = ref()
|
||||||
|
+
|
||||||
|
+const sidebarButtons = computed(() => {
|
||||||
|
+ const buttons = {
|
||||||
|
+ home: {
|
||||||
|
+ label: () => formatMessage(messages.home),
|
||||||
|
+ icon: PlayIcon,
|
||||||
|
+ iconClass: 'ml-0.5',
|
||||||
|
+ to: '/',
|
||||||
|
+ isPrimary: (r) => r.path === '/',
|
||||||
|
+ isSubpage: () =>
|
||||||
|
+ (route.path.startsWith('/browse') || route.path.startsWith('/project')) && !!route.query.i,
|
||||||
|
+ },
|
||||||
|
+ discover: {
|
||||||
|
+ label: () => formatMessage(commonMessages.discoverContentLabel),
|
||||||
|
+ icon: CompassIcon,
|
||||||
|
+ to: '/browse/modpack',
|
||||||
|
+ isPrimary: () => route.path.startsWith('/browse') && !route.query.i && !route.query.sid,
|
||||||
|
+ isSubpage: (r) => r.path.startsWith('/project') && !r.query.i && !r.query.sid,
|
||||||
|
+ },
|
||||||
|
+ skins: {
|
||||||
|
+ label: () => formatMessage(appMessages.skinSelectorLabel),
|
||||||
|
+ icon: ShirtIcon,
|
||||||
|
+ to: '/skins',
|
||||||
|
+ },
|
||||||
|
+ screenshots: {
|
||||||
|
+ label: () => formatMessage(messages.screenshots),
|
||||||
|
+ icon: ImageIcon,
|
||||||
|
+ to: '/screenshots',
|
||||||
|
+ },
|
||||||
|
+ hosting: {
|
||||||
|
+ label: () => formatMessage(messages.modrinthHosting),
|
||||||
|
+ icon: ServerStackIcon,
|
||||||
|
+ to: '/hosting/manage',
|
||||||
|
+ isPrimary: (r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/',
|
||||||
|
+ isSubpage: (r) =>
|
||||||
|
+ (r.path.startsWith('/hosting/manage/') && r.path !== '/hosting/manage/') ||
|
||||||
|
+ ((r.path.startsWith('/browse') || r.path.startsWith('/project')) && !!r.query.sid),
|
||||||
|
+ },
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ return sidebarLayout.order.value
|
||||||
|
+ .filter((id) => !sidebarLayout.isHidden(id))
|
||||||
|
+ .map((id) => ({ id, ...buttons[id] }))
|
||||||
|
+})
|
||||||
|
+
|
||||||
|
+function openSidebarMenu(event, button) {
|
||||||
|
+ sidebarMenu.value?.open(event, [
|
||||||
|
+ {
|
||||||
|
+ id: 'hide',
|
||||||
|
+ label: formatMessage(messages.hideFromSidebar, { name: button.label() }),
|
||||||
|
+ icon: EyeOffIcon,
|
||||||
|
+ action: () => sidebarLayout.setHidden(button.id, true),
|
||||||
|
+ },
|
||||||
|
+ { type: 'divider' },
|
||||||
|
+ {
|
||||||
|
+ id: 'arrange',
|
||||||
|
+ label: formatMessage(messages.arrangeSidebar),
|
||||||
|
+ icon: SettingsIcon,
|
||||||
|
+ action: () => appSettingsModal.value?.showSidebar(),
|
||||||
|
+ },
|
||||||
|
+ ])
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
const sidebarToggled = ref(true)
|
||||||
|
// Whether the right sidebar is folded away, remembered across restarts in the
|
||||||
|
// feature flags. The title bar button below is the only thing that sets it:
|
||||||
|
@@ -729,6 +799,18 @@ const messages = defineMessages({
|
||||||
|
id: 'app.nav.screenshots',
|
||||||
|
defaultMessage: 'Screenshots',
|
||||||
|
},
|
||||||
|
+ sidebarActions: {
|
||||||
|
+ id: 'app.nav.sidebar-actions',
|
||||||
|
+ defaultMessage: 'Sidebar button',
|
||||||
|
+ },
|
||||||
|
+ hideFromSidebar: {
|
||||||
|
+ id: 'app.nav.hide-from-sidebar',
|
||||||
|
+ defaultMessage: 'Hide “{name}”',
|
||||||
|
+ },
|
||||||
|
+ arrangeSidebar: {
|
||||||
|
+ id: 'app.nav.arrange-sidebar',
|
||||||
|
+ defaultMessage: 'Arrange the sidebar…',
|
||||||
|
+ },
|
||||||
|
createNewInstance: {
|
||||||
|
id: 'app.nav.create-new-instance',
|
||||||
|
defaultMessage: 'Create new instance',
|
||||||
|
@@ -2346,52 +2428,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||||
|
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.25rem] w-[--left-bar-width]"
|
||||||
|
>
|
||||||
|
<NavButton
|
||||||
|
- v-tooltip.right="formatMessage(messages.home)"
|
||||||
|
- to="/"
|
||||||
|
- :is-primary="(route) => route.path === '/'"
|
||||||
|
- :is-subpage="
|
||||||
|
- () =>
|
||||||
|
- (route.path.startsWith('/browse') || route.path.startsWith('/project')) && route.query.i
|
||||||
|
- "
|
||||||
|
- >
|
||||||
|
- <PlayIcon class="ml-0.5" />
|
||||||
|
- </NavButton>
|
||||||
|
- <NavButton
|
||||||
|
- v-tooltip.right="formatMessage(commonMessages.discoverContentLabel)"
|
||||||
|
- to="/browse/modpack"
|
||||||
|
- :is-primary="() => route.path.startsWith('/browse') && !route.query.i && !route.query.sid"
|
||||||
|
- :is-subpage="
|
||||||
|
- (route) => route.path.startsWith('/project') && !route.query.i && !route.query.sid
|
||||||
|
- "
|
||||||
|
- >
|
||||||
|
- <CompassIcon />
|
||||||
|
- </NavButton>
|
||||||
|
- <NavButton
|
||||||
|
- v-if="appSettings.showSkinSelectorInSidebar"
|
||||||
|
- v-tooltip.right="formatMessage(appMessages.skinSelectorLabel)"
|
||||||
|
- to="/skins"
|
||||||
|
- >
|
||||||
|
- <ShirtIcon />
|
||||||
|
- </NavButton>
|
||||||
|
- <NavButton
|
||||||
|
- v-if="globalSyncedOptionsQuery.data.value?.screenshots"
|
||||||
|
- v-tooltip.right="formatMessage(messages.screenshots)"
|
||||||
|
- to="/screenshots"
|
||||||
|
- >
|
||||||
|
- <ImageIcon />
|
||||||
|
- </NavButton>
|
||||||
|
- <NavButton
|
||||||
|
- v-if="appSettings.getFeatureFlag('show_hosting_in_sidebar')"
|
||||||
|
- v-tooltip.right="formatMessage(messages.modrinthHosting)"
|
||||||
|
- to="/hosting/manage"
|
||||||
|
- :is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'"
|
||||||
|
- :is-subpage="
|
||||||
|
- (r) =>
|
||||||
|
- (r.path.startsWith('/hosting/manage/') && r.path !== '/hosting/manage/') ||
|
||||||
|
- ((r.path.startsWith('/browse') || r.path.startsWith('/project')) && r.query.sid)
|
||||||
|
- "
|
||||||
|
+ v-for="button in sidebarButtons"
|
||||||
|
+ :key="button.id"
|
||||||
|
+ v-tooltip.right="button.label()"
|
||||||
|
+ :to="button.to"
|
||||||
|
+ :is-primary="button.isPrimary"
|
||||||
|
+ :is-subpage="button.isSubpage"
|
||||||
|
+ @contextmenu.prevent.stop="(event) => openSidebarMenu(event, button)"
|
||||||
|
>
|
||||||
|
- <ServerStackIcon />
|
||||||
|
+ <component :is="button.icon" :class="button.iconClass" />
|
||||||
|
</NavButton>
|
||||||
|
<suspense>
|
||||||
|
<QuickInstanceSwitcher>
|
||||||
|
@@ -2475,6 +2520,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||||
|
<LogInIcon class="text-brand" />
|
||||||
|
</NavButton>
|
||||||
|
</div>
|
||||||
|
+ <ContextMenu ref="sidebarMenu" :label="formatMessage(messages.sidebarActions)" />
|
||||||
|
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
|
||||||
|
<div data-tauri-drag-region class="flex min-w-0 flex-1 items-center overflow-hidden p-2">
|
||||||
|
<TextLogo class="h-7 w-auto shrink-0 text-contrast pointer-events-none" />
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue b/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue
|
||||||
|
index 32a86a3..e409510 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue
|
||||||
|
@@ -3,6 +3,7 @@ import {
|
||||||
|
CoffeeIcon,
|
||||||
|
HeartHandshakeIcon,
|
||||||
|
LanguagesIcon,
|
||||||
|
+ LayoutTemplateIcon,
|
||||||
|
LightBulbIcon,
|
||||||
|
MicrochipIcon,
|
||||||
|
ModrinthIcon,
|
||||||
|
@@ -36,6 +37,7 @@ import AppearanceSettings from '@/components/ui/settings/display/AppearanceSetti
|
||||||
|
import BehaviorSettings from '@/components/ui/settings/display/BehaviorSettings.vue'
|
||||||
|
import FeatureFlagSettings from '@/components/ui/settings/display/FeatureFlagSettings.vue'
|
||||||
|
import FeaturesSettings from '@/components/ui/settings/display/FeaturesSettings.vue'
|
||||||
|
+import SidebarSettings from '@/components/ui/settings/display/SidebarSettings.vue'
|
||||||
|
import LanguageSettings from '@/components/ui/settings/display/LanguageSettings.vue'
|
||||||
|
import InstancesSyncedSettings from '@/components/ui/settings/instances/instances-synced-settings/index.vue'
|
||||||
|
import JavaSettings from '@/components/ui/settings/instances/JavaSettings.vue'
|
||||||
|
@@ -96,6 +98,15 @@ const tabs = [
|
||||||
|
icon: LightBulbIcon,
|
||||||
|
content: FeaturesSettings,
|
||||||
|
},
|
||||||
|
+ {
|
||||||
|
+ name: defineMessage({
|
||||||
|
+ id: 'app.settings.tabs.sidebar',
|
||||||
|
+ defaultMessage: 'Sidebar',
|
||||||
|
+ }),
|
||||||
|
+ category: tabCategories.display,
|
||||||
|
+ icon: LayoutTemplateIcon,
|
||||||
|
+ content: SidebarSettings,
|
||||||
|
+ },
|
||||||
|
{
|
||||||
|
name: defineMessage({
|
||||||
|
id: 'app.settings.tabs.behavior',
|
||||||
|
@@ -247,6 +258,15 @@ function showFeatureFlags(): void {
|
||||||
|
modal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
+/** Opens the settings at the sidebar tab, for the menu on a sidebar button. */
|
||||||
|
+function showSidebar(): void {
|
||||||
|
+ show()
|
||||||
|
+ const sidebarTabIndex = availableTabs.value.findIndex((tab) => tab.content === SidebarSettings)
|
||||||
|
+ if (sidebarTabIndex >= 0) {
|
||||||
|
+ modal.value?.setTab(sidebarTabIndex)
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
function showSyncedOptions(): void {
|
||||||
|
const syncedOptionsTabIndex = availableTabs.value.findIndex(
|
||||||
|
(tab) => tab.content === InstancesSyncedSettings,
|
||||||
|
@@ -257,7 +277,7 @@ function showSyncedOptions(): void {
|
||||||
|
modal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
-defineExpose({ show, showProfile, showFeatureFlags, showSyncedOptions })
|
||||||
|
+defineExpose({ show, showProfile, showFeatureFlags, showSidebar, showSyncedOptions })
|
||||||
|
|
||||||
|
const { progress, version: downloadingVersion } = injectAppUpdateDownloadProgress()
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue b/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue
|
||||||
|
index 4e55cdd..9bc7ce3 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/settings/display/FeaturesSettings.vue
|
||||||
|
@@ -82,32 +82,6 @@ const messages = defineMessages({
|
||||||
|
id: 'app.features-settings.sidebar.title',
|
||||||
|
defaultMessage: 'Sidebar',
|
||||||
|
},
|
||||||
|
- showAllScreenshotsTitle: {
|
||||||
|
- id: 'app.features-settings.show-all-screenshots.title',
|
||||||
|
- defaultMessage: 'Show all screenshots in sidebar',
|
||||||
|
- },
|
||||||
|
- showAllScreenshotsDescription: {
|
||||||
|
- id: 'app.features-settings.show-all-screenshots.description',
|
||||||
|
- defaultMessage:
|
||||||
|
- 'Show a button in the left sidebar to view screenshots from all your instances.',
|
||||||
|
- },
|
||||||
|
- showSkinSelectorTitle: {
|
||||||
|
- id: 'app.features-settings.show-skin-selector.title',
|
||||||
|
- defaultMessage: 'Show skin selector in sidebar',
|
||||||
|
- },
|
||||||
|
- showSkinSelectorDescription: {
|
||||||
|
- id: 'app.features-settings.show-skin-selector.description',
|
||||||
|
- defaultMessage: 'Show a button in the left sidebar to open the skin selector.',
|
||||||
|
- },
|
||||||
|
- showHostingTitle: {
|
||||||
|
- id: 'app.features-settings.show-hosting.title',
|
||||||
|
- defaultMessage: 'Show Modrinth Servers in sidebar',
|
||||||
|
- },
|
||||||
|
- showHostingDescription: {
|
||||||
|
- id: 'app.features-settings.show-hosting.description',
|
||||||
|
- defaultMessage:
|
||||||
|
- 'Show a button in the left sidebar to manage Modrinth Servers. Added by Modrinth Enhanced, so it stays on this device and is not synced to your Modrinth account.',
|
||||||
|
- },
|
||||||
|
quickInstancesTitle: {
|
||||||
|
id: 'app.features-settings.quick-instances.title',
|
||||||
|
defaultMessage: 'Quick instances in sidebar',
|
||||||
|
@@ -384,50 +358,6 @@ onBeforeUnmount(() => {
|
||||||
|
{{ formatMessage(messages.sidebarTitle) }}
|
||||||
|
</h2>
|
||||||
|
<div class="mt-4 flex flex-col gap-6">
|
||||||
|
- <div class="flex items-center justify-between gap-4">
|
||||||
|
- <div>
|
||||||
|
- <h3 class="m-0 text-lg font-semibold text-contrast">
|
||||||
|
- {{ formatMessage(messages.showAllScreenshotsTitle) }}
|
||||||
|
- </h3>
|
||||||
|
- <p class="m-0 mt-1">
|
||||||
|
- {{ formatMessage(messages.showAllScreenshotsDescription) }}
|
||||||
|
- </p>
|
||||||
|
- </div>
|
||||||
|
- <Toggle
|
||||||
|
- id="show-all-screenshots"
|
||||||
|
- v-model="current.showAllScreenshots"
|
||||||
|
- :aria-label="formatMessage(messages.showAllScreenshotsTitle)"
|
||||||
|
- />
|
||||||
|
- </div>
|
||||||
|
-
|
||||||
|
- <div class="flex items-center justify-between gap-4">
|
||||||
|
- <div>
|
||||||
|
- <h3 class="m-0 text-lg font-semibold text-contrast">
|
||||||
|
- {{ formatMessage(messages.showSkinSelectorTitle) }}
|
||||||
|
- </h3>
|
||||||
|
- <p class="m-0 mt-1">{{ formatMessage(messages.showSkinSelectorDescription) }}</p>
|
||||||
|
- </div>
|
||||||
|
- <Toggle
|
||||||
|
- id="show-skin-selector-in-sidebar"
|
||||||
|
- v-model="current.showSkinSelector"
|
||||||
|
- :aria-label="formatMessage(messages.showSkinSelectorTitle)"
|
||||||
|
- />
|
||||||
|
- </div>
|
||||||
|
-
|
||||||
|
- <div class="flex items-center justify-between gap-4">
|
||||||
|
- <div>
|
||||||
|
- <h3 class="m-0 text-lg font-semibold text-contrast">
|
||||||
|
- {{ formatMessage(messages.showHostingTitle) }}
|
||||||
|
- </h3>
|
||||||
|
- <p class="m-0 mt-1">{{ formatMessage(messages.showHostingDescription) }}</p>
|
||||||
|
- </div>
|
||||||
|
- <Toggle
|
||||||
|
- id="show-hosting-in-sidebar"
|
||||||
|
- v-model="current.showHosting"
|
||||||
|
- :aria-label="formatMessage(messages.showHostingTitle)"
|
||||||
|
- />
|
||||||
|
- </div>
|
||||||
|
-
|
||||||
|
<div class="flex flex-col gap-2.5">
|
||||||
|
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||||
|
{{ formatMessage(messages.quickInstancesTitle) }}
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/settings/display/SidebarSettings.vue b/apps/app-frontend/src/components/ui/settings/display/SidebarSettings.vue
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..dc0cd1f
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/settings/display/SidebarSettings.vue
|
||||||
|
@@ -0,0 +1,125 @@
|
||||||
|
+<template>
|
||||||
|
+ <h2 class="m-0 text-xl font-semibold text-contrast">{{ formatMessage(messages.title) }}</h2>
|
||||||
|
+ <p class="mt-1 mb-0">{{ formatMessage(messages.description) }}</p>
|
||||||
|
+
|
||||||
|
+ <Draggable
|
||||||
|
+ v-model="orderedItems"
|
||||||
|
+ class="mt-4 flex flex-col gap-2"
|
||||||
|
+ item-key="id"
|
||||||
|
+ :animation="200"
|
||||||
|
+ filter=".sidebar-item-toggle"
|
||||||
|
+ :prevent-on-filter="false"
|
||||||
|
+ ghost-class="sidebar-item-ghost"
|
||||||
|
+ >
|
||||||
|
+ <template #item="{ element: item }">
|
||||||
|
+ <div
|
||||||
|
+ class="flex cursor-grab items-center gap-3 rounded-2xl border border-solid border-surface-4 bg-surface-2 p-3 active:cursor-grabbing"
|
||||||
|
+ >
|
||||||
|
+ <button
|
||||||
|
+ v-tooltip="formatMessage(messages.reorder)"
|
||||||
|
+ type="button"
|
||||||
|
+ class="flex cursor-grab border-none bg-transparent p-0 text-secondary active:cursor-grabbing"
|
||||||
|
+ :aria-label="formatMessage(messages.reorder)"
|
||||||
|
+ @keydown.up.prevent="layout.move(item.id, -1)"
|
||||||
|
+ @keydown.down.prevent="layout.move(item.id, 1)"
|
||||||
|
+ >
|
||||||
|
+ <GripVerticalIcon class="size-5" />
|
||||||
|
+ </button>
|
||||||
|
+ <component :is="item.icon" class="size-5 shrink-0 text-secondary" />
|
||||||
|
+ <span class="font-semibold text-contrast">{{ item.label }}</span>
|
||||||
|
+ <Toggle
|
||||||
|
+ :id="`sidebar-${item.id}`"
|
||||||
|
+ :model-value="!layout.isHidden(item.id)"
|
||||||
|
+ :aria-label="formatMessage(messages.show, { name: item.label })"
|
||||||
|
+ class="sidebar-item-toggle ml-auto"
|
||||||
|
+ @update:model-value="(shown: boolean) => layout.setHidden(item.id, !shown)"
|
||||||
|
+ />
|
||||||
|
+ </div>
|
||||||
|
+ </template>
|
||||||
|
+ </Draggable>
|
||||||
|
+
|
||||||
|
+ <Button class="mt-4" @click="layout.reset()">
|
||||||
|
+ <RotateCounterClockwiseIcon />
|
||||||
|
+ {{ formatMessage(messages.reset) }}
|
||||||
|
+ </Button>
|
||||||
|
+</template>
|
||||||
|
+
|
||||||
|
+<script setup lang="ts">
|
||||||
|
+import {
|
||||||
|
+ CompassIcon,
|
||||||
|
+ GripVerticalIcon,
|
||||||
|
+ ImageIcon,
|
||||||
|
+ PlayIcon,
|
||||||
|
+ RotateCounterClockwiseIcon,
|
||||||
|
+ ServerStackIcon,
|
||||||
|
+ ShirtIcon,
|
||||||
|
+} from '@modrinth/assets'
|
||||||
|
+import { Button, commonMessages, defineMessages, Toggle, useVIntl } from '@modrinth/ui'
|
||||||
|
+import { computed } from 'vue'
|
||||||
|
+import Draggable from 'vuedraggable'
|
||||||
|
+
|
||||||
|
+import { type SidebarItemId, useSidebarLayout } from '@/composables/use-sidebar-layout'
|
||||||
|
+import { appMessages } from '@/utils/app-messages'
|
||||||
|
+
|
||||||
|
+const { formatMessage } = useVIntl()
|
||||||
|
+const layout = useSidebarLayout()
|
||||||
|
+
|
||||||
|
+const messages = defineMessages({
|
||||||
|
+ title: {
|
||||||
|
+ id: 'app.sidebar-settings.title',
|
||||||
|
+ defaultMessage: 'Sidebar',
|
||||||
|
+ },
|
||||||
|
+ description: {
|
||||||
|
+ id: 'app.sidebar-settings.description',
|
||||||
|
+ defaultMessage:
|
||||||
|
+ 'The buttons above the instances in the left sidebar. Right-clicking one of them hides it as well.',
|
||||||
|
+ },
|
||||||
|
+ reorder: {
|
||||||
|
+ id: 'app.sidebar-settings.reorder',
|
||||||
|
+ defaultMessage: 'Drag to reorder, or use the arrow keys',
|
||||||
|
+ },
|
||||||
|
+ show: {
|
||||||
|
+ id: 'app.sidebar-settings.show',
|
||||||
|
+ defaultMessage: 'Show {name}',
|
||||||
|
+ },
|
||||||
|
+ reset: {
|
||||||
|
+ id: 'app.sidebar-settings.reset',
|
||||||
|
+ defaultMessage: 'Back to the usual order',
|
||||||
|
+ },
|
||||||
|
+ home: {
|
||||||
|
+ id: 'app.sidebar-settings.home',
|
||||||
|
+ defaultMessage: 'Home',
|
||||||
|
+ },
|
||||||
|
+ screenshots: {
|
||||||
|
+ id: 'app.sidebar-settings.screenshots',
|
||||||
|
+ defaultMessage: 'Screenshots',
|
||||||
|
+ },
|
||||||
|
+ hosting: {
|
||||||
|
+ id: 'app.sidebar-settings.hosting',
|
||||||
|
+ defaultMessage: 'Modrinth Hosting',
|
||||||
|
+ },
|
||||||
|
+})
|
||||||
|
+
|
||||||
|
+const ITEMS: Record<SidebarItemId, { icon: unknown; label: () => string }> = {
|
||||||
|
+ home: { icon: PlayIcon, label: () => formatMessage(messages.home) },
|
||||||
|
+ discover: {
|
||||||
|
+ icon: CompassIcon,
|
||||||
|
+ label: () => formatMessage(commonMessages.discoverContentLabel),
|
||||||
|
+ },
|
||||||
|
+ skins: { icon: ShirtIcon, label: () => formatMessage(appMessages.skinSelectorLabel) },
|
||||||
|
+ screenshots: { icon: ImageIcon, label: () => formatMessage(messages.screenshots) },
|
||||||
|
+ hosting: { icon: ServerStackIcon, label: () => formatMessage(messages.hosting) },
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+const orderedItems = computed({
|
||||||
|
+ get: () =>
|
||||||
|
+ layout.order.value.map((id) => ({ id, icon: ITEMS[id].icon, label: ITEMS[id].label() })),
|
||||||
|
+ set: (items: { id: SidebarItemId }[]) => layout.setOrder(items.map((item) => item.id)),
|
||||||
|
+})
|
||||||
|
+</script>
|
||||||
|
+
|
||||||
|
+<style scoped>
|
||||||
|
+.sidebar-item-ghost {
|
||||||
|
+ opacity: 0.4;
|
||||||
|
+}
|
||||||
|
+</style>
|
||||||
|
diff --git a/apps/app-frontend/src/composables/use-sidebar-layout.ts b/apps/app-frontend/src/composables/use-sidebar-layout.ts
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..7225c21
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/apps/app-frontend/src/composables/use-sidebar-layout.ts
|
||||||
|
@@ -0,0 +1,72 @@
|
||||||
|
+/**
|
||||||
|
+ * The order of the buttons above the instances in the left sidebar, and which
|
||||||
|
+ * of them are shown.
|
||||||
|
+ *
|
||||||
|
+ * This is about how the app looks on this machine, so it stays here rather than
|
||||||
|
+ * going to Modrinth with the synced preferences.
|
||||||
|
+ */
|
||||||
|
+import { useStorage } from '@vueuse/core'
|
||||||
|
+import { computed } from 'vue'
|
||||||
|
+
|
||||||
|
+/** The buttons, in the order upstream has them. */
|
||||||
|
+export const SIDEBAR_ITEMS = ['home', 'discover', 'skins', 'screenshots', 'hosting'] as const
|
||||||
|
+
|
||||||
|
+export type SidebarItemId = (typeof SIDEBAR_ITEMS)[number]
|
||||||
|
+
|
||||||
|
+type SidebarLayout = {
|
||||||
|
+ order: SidebarItemId[]
|
||||||
|
+ hidden: SidebarItemId[]
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+const layout = useStorage<SidebarLayout>('modrinth-enhanced:sidebar-layout', {
|
||||||
|
+ order: [...SIDEBAR_ITEMS],
|
||||||
|
+ hidden: [],
|
||||||
|
+})
|
||||||
|
+
|
||||||
|
+/** The stored order, with anything unknown dropped and anything new appended. */
|
||||||
|
+const order = computed<SidebarItemId[]>(() => {
|
||||||
|
+ const stored = (layout.value.order ?? []).filter((id) => SIDEBAR_ITEMS.includes(id))
|
||||||
|
+ return [...stored, ...SIDEBAR_ITEMS.filter((id) => !stored.includes(id))]
|
||||||
|
+})
|
||||||
|
+
|
||||||
|
+const hidden = computed<SidebarItemId[]>(() =>
|
||||||
|
+ (layout.value.hidden ?? []).filter((id) => SIDEBAR_ITEMS.includes(id)),
|
||||||
|
+)
|
||||||
|
+
|
||||||
|
+export function useSidebarLayout() {
|
||||||
|
+ function isHidden(id: SidebarItemId): boolean {
|
||||||
|
+ return hidden.value.includes(id)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function setHidden(id: SidebarItemId, hide: boolean) {
|
||||||
|
+ layout.value = {
|
||||||
|
+ order: order.value,
|
||||||
|
+ hidden: hide ? [...hidden.value, id] : hidden.value.filter((other) => other !== id),
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /** Moves a button by `by` places, as far as there is room. */
|
||||||
|
+ function move(id: SidebarItemId, by: number) {
|
||||||
|
+ const ids = [...order.value]
|
||||||
|
+ const from = ids.indexOf(id)
|
||||||
|
+ const to = from + by
|
||||||
|
+ if (from < 0 || to < 0 || to >= ids.length) return
|
||||||
|
+
|
||||||
|
+ ids.splice(to, 0, ...ids.splice(from, 1))
|
||||||
|
+ layout.value = { order: ids, hidden: hidden.value }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ /** The order the buttons were dragged into, anything unknown dropped. */
|
||||||
|
+ function setOrder(ids: SidebarItemId[]) {
|
||||||
|
+ layout.value = {
|
||||||
|
+ order: ids.filter((id) => SIDEBAR_ITEMS.includes(id)),
|
||||||
|
+ hidden: hidden.value,
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function reset() {
|
||||||
|
+ layout.value = { order: [...SIDEBAR_ITEMS], hidden: [] }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ return { order, hidden, isHidden, setHidden, move, setOrder, reset }
|
||||||
|
+}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Fri, 18 Sep 2026 09:43:57 +0200
|
||||||
|
Subject: [PATCH] Delete an instance from the sidebar
|
||||||
|
|
||||||
|
Right-clicking an instance in the sidebar offered play, stop, open and
|
||||||
|
show in folder, but deleting one meant going to the library first. It is
|
||||||
|
in that menu now, in red and below a divider, and asks first with the
|
||||||
|
same dialog the library uses. An instance that is running has to be
|
||||||
|
stopped first, which the entry says.
|
||||||
|
---
|
||||||
|
.../components/ui/QuickInstanceSwitcher.vue | 49 ++++++++++++++++++-
|
||||||
|
1 file changed, 47 insertions(+), 2 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue b/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue
|
||||||
|
index 5c58a50..2ad9979 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue
|
||||||
|
@@ -1,5 +1,12 @@
|
||||||
|
<script setup>
|
||||||
|
-import { EyeIcon, FolderOpenIcon, PlayIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets'
|
||||||
|
+import {
|
||||||
|
+ EyeIcon,
|
||||||
|
+ FolderOpenIcon,
|
||||||
|
+ PlayIcon,
|
||||||
|
+ SpinnerIcon,
|
||||||
|
+ StopCircleIcon,
|
||||||
|
+ TrashIcon,
|
||||||
|
+} from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
commonMessages,
|
||||||
|
@@ -13,6 +20,7 @@ import dayjs from 'dayjs'
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
+import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||||
|
import NavButton from '@/components/ui/NavButton.vue'
|
||||||
|
import { useAppEvent } from '@/composables/use-app-event'
|
||||||
|
import { handleSevereError } from '@/composables/use-error.js'
|
||||||
|
@@ -21,7 +29,7 @@ import {
|
||||||
|
useQuickInstanceLimit,
|
||||||
|
} from '@/composables/use-quick-instance-limit.ts'
|
||||||
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
|
-import { getInstanceIconUrl, kill, run } from '@/helpers/instance'
|
||||||
|
+import { getInstanceIconUrl, kill, remove, run } from '@/helpers/instance'
|
||||||
|
import { get_all } from '@/helpers/process'
|
||||||
|
import { showInstanceInFolder } from '@/helpers/utils'
|
||||||
|
import { instanceListQueryOptions } from '@/pages/instance/query-options'
|
||||||
|
@@ -31,6 +39,9 @@ const { handleError } = injectNotificationManager()
|
||||||
|
const instancesQuery = useQuery(instanceListQueryOptions())
|
||||||
|
const router = useRouter()
|
||||||
|
const instanceOptions = ref()
|
||||||
|
+const confirmDeleteModal = ref()
|
||||||
|
+// The instance the delete dialog is about, kept for its text and the deleting.
|
||||||
|
+const instanceToDelete = ref(null)
|
||||||
|
const runningInstances = ref([])
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
@@ -212,6 +223,10 @@ const messages = defineMessages({
|
||||||
|
id: 'app.quick-instance-switcher.instance-locked',
|
||||||
|
defaultMessage: 'This instance has been locked',
|
||||||
|
},
|
||||||
|
+ deleteWhileRunning: {
|
||||||
|
+ id: 'app.quick-instance-switcher.delete-while-running',
|
||||||
|
+ defaultMessage: 'Stop the instance first',
|
||||||
|
+ },
|
||||||
|
title: {
|
||||||
|
id: 'app.quick-instance-switcher.title',
|
||||||
|
defaultMessage: 'Recent instances',
|
||||||
|
@@ -285,8 +300,33 @@ function openContextMenu(event, instance) {
|
||||||
|
icon: FolderOpenIcon,
|
||||||
|
action: () => showInstanceInFolder(instance.id),
|
||||||
|
},
|
||||||
|
+ { type: 'divider' },
|
||||||
|
+ {
|
||||||
|
+ id: 'delete',
|
||||||
|
+ label: formatMessage(commonMessages.deleteLabel),
|
||||||
|
+ icon: TrashIcon,
|
||||||
|
+ tone: 'red',
|
||||||
|
+ disabled: playing,
|
||||||
|
+ tooltip: playing ? formatMessage(messages.deleteWhileRunning) : undefined,
|
||||||
|
+ action: () => askToDelete(instance),
|
||||||
|
+ },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
+
|
||||||
|
+/** Deleting an instance takes everything in it, so it is asked about first. */
|
||||||
|
+function askToDelete(instance) {
|
||||||
|
+ instanceToDelete.value = instance
|
||||||
|
+ confirmDeleteModal.value?.show()
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+async function deleteInstance() {
|
||||||
|
+ const instance = instanceToDelete.value
|
||||||
|
+ if (!instance) return
|
||||||
|
+
|
||||||
|
+ instanceToDelete.value = null
|
||||||
|
+ await remove(instance.id).catch(handleError)
|
||||||
|
+ trackEvent('InstanceRemove', { source: 'QuickInstanceSwitcher' })
|
||||||
|
+}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
@@ -326,6 +366,11 @@ function openContextMenu(event, instance) {
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
<ContextMenu ref="instanceOptions" :label="formatMessage(messages.instanceActions)" />
|
||||||
|
+ <ConfirmDeleteInstanceModal
|
||||||
|
+ ref="confirmDeleteModal"
|
||||||
|
+ :instances="instanceToDelete ? [instanceToDelete] : []"
|
||||||
|
+ @delete="deleteInstance"
|
||||||
|
+ />
|
||||||
|
<div
|
||||||
|
v-tooltip.right="dividerTooltip"
|
||||||
|
role="separator"
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Fri, 18 Sep 2026 10:42:13 +0200
|
||||||
|
Subject: [PATCH] Make an instance group from the library's free space
|
||||||
|
|
||||||
|
Right-clicking the empty space around the library offered a new instance
|
||||||
|
and nothing else, so making a group meant finding the toolbar button.
|
||||||
|
The same menu now offers it, and opens the dialog that button does.
|
||||||
|
---
|
||||||
|
.../src/components/ui/library/index.vue | 4 ++++
|
||||||
|
apps/app-frontend/src/pages/Index.vue | 16 ++++++++++++++--
|
||||||
|
2 files changed, 18 insertions(+), 2 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/library/index.vue b/apps/app-frontend/src/components/ui/library/index.vue
|
||||||
|
index 20000b6..1849835 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/library/index.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/library/index.vue
|
||||||
|
@@ -53,6 +53,7 @@ const {
|
||||||
|
currentDeleteInstances,
|
||||||
|
clearLibraryInstanceSelection,
|
||||||
|
deleteInstance,
|
||||||
|
+ openNewGroupModal,
|
||||||
|
handleInstanceIconSaved,
|
||||||
|
selectedLibraryInstances,
|
||||||
|
setSelectedLibraryInstances,
|
||||||
|
@@ -260,6 +261,9 @@ watch(selectedLibraryInstances, (selectedInstances) => {
|
||||||
|
anchorInstance.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
+
|
||||||
|
+// So the page's own menu, on the free space around the library, can offer it.
|
||||||
|
+defineExpose({ openNewGroupModal })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
diff --git a/apps/app-frontend/src/pages/Index.vue b/apps/app-frontend/src/pages/Index.vue
|
||||||
|
index cdad0e8..7aa6e89 100644
|
||||||
|
--- a/apps/app-frontend/src/pages/Index.vue
|
||||||
|
+++ b/apps/app-frontend/src/pages/Index.vue
|
||||||
|
@@ -1,5 +1,5 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
-import { PlayIcon, PlusIcon } from '@modrinth/assets'
|
||||||
|
+import { PlayIcon, PlusIcon, SquarePlusIcon } from '@modrinth/assets'
|
||||||
|
import { ContextMenu, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||||
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
@@ -25,6 +25,7 @@ const { handleError } = injectNotificationManager()
|
||||||
|
const { hasCreatedInstance, isReady } = injectOnboardingChecklist()
|
||||||
|
const showCreationModal = inject<() => void>('showCreationModal')
|
||||||
|
const pageOptions = ref<InstanceType<typeof ContextMenu>>()
|
||||||
|
+const librarySection = ref<InstanceType<typeof LibrarySection>>()
|
||||||
|
const appSettings = useAppSettings()
|
||||||
|
onBeforeRouteLeave(() => {
|
||||||
|
libraryScrollTop.value = document.querySelector('.app-viewport')?.scrollTop ?? 0
|
||||||
|
@@ -39,6 +40,11 @@ const messages = defineMessages({
|
||||||
|
id: 'app.library.context-menu.create-instance',
|
||||||
|
defaultMessage: 'New instance',
|
||||||
|
},
|
||||||
|
+ // The library toolbar's button, so this is translated wherever that is.
|
||||||
|
+ newGroup: {
|
||||||
|
+ id: 'app.library.group.new',
|
||||||
|
+ defaultMessage: 'New group',
|
||||||
|
+ },
|
||||||
|
libraryActionsLabel: {
|
||||||
|
id: 'app.library.actions.label',
|
||||||
|
defaultMessage: 'Library actions',
|
||||||
|
@@ -84,6 +90,12 @@ function openPageContextMenu(event: MouseEvent) {
|
||||||
|
icon: PlusIcon,
|
||||||
|
action: () => showCreationModal?.(),
|
||||||
|
},
|
||||||
|
+ {
|
||||||
|
+ id: 'new_group',
|
||||||
|
+ label: formatMessage(messages.newGroup),
|
||||||
|
+ icon: SquarePlusIcon,
|
||||||
|
+ action: () => librarySection.value?.openNewGroupModal(),
|
||||||
|
+ },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@@ -100,7 +112,7 @@ function openPageContextMenu(event: MouseEvent) {
|
||||||
|
v-if="recentInstances?.length > 0 && appSettings.showJumpIn"
|
||||||
|
:recent-instances="recentInstances"
|
||||||
|
/>
|
||||||
|
- <LibrarySection :instances="instances" />
|
||||||
|
+ <LibrarySection ref="librarySection" :instances="instances" />
|
||||||
|
<ContextMenu ref="pageOptions" :label="formatMessage(messages.libraryActionsLabel)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Fri, 18 Sep 2026 10:24:21 +0200
|
||||||
|
Subject: [PATCH] Start in the language the system is set to
|
||||||
|
|
||||||
|
The app started in English until someone found the language setting,
|
||||||
|
even on a desktop that is not in English. The first start now takes the
|
||||||
|
language from the system, as long as upstream has that one translated to
|
||||||
|
at least 70 percent - below that the app would be half English anyway,
|
||||||
|
which reads worse than all of it being. The system is only asked once,
|
||||||
|
and never over a language that was picked in the settings.
|
||||||
|
---
|
||||||
|
apps/app-frontend/src/App.vue | 17 ++++-
|
||||||
|
.../app-frontend/src/helpers/system-locale.ts | 71 +++++++++++++++++++
|
||||||
|
2 files changed, 85 insertions(+), 3 deletions(-)
|
||||||
|
create mode 100644 apps/app-frontend/src/helpers/system-locale.ts
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
|
||||||
|
index cb688fc..bb4aa89 100644
|
||||||
|
--- a/apps/app-frontend/src/App.vue
|
||||||
|
+++ b/apps/app-frontend/src/App.vue
|
||||||
|
@@ -166,6 +166,7 @@ import {
|
||||||
|
syncedServersQueryOptions,
|
||||||
|
} from '@/helpers/synced-options'
|
||||||
|
import { syncedPackQueryOptions } from '@/helpers/synced-packs'
|
||||||
|
+import { localeFromSystem } from '@/helpers/system-locale'
|
||||||
|
import { get_user_preferences } from '@/helpers/user-preferences.ts'
|
||||||
|
import { parse_modrinth_user_link } from '@/helpers/users'
|
||||||
|
import {
|
||||||
|
@@ -952,9 +953,19 @@ async function setupApp() {
|
||||||
|
pending_update_toast_for_version,
|
||||||
|
} = await traceStartupStep('Read startup settings', getSettings)
|
||||||
|
|
||||||
|
- // Initialize locale from saved settings
|
||||||
|
- if (locale) {
|
||||||
|
- await traceStartupStep('Apply startup locale', () => setLocale(locale))
|
||||||
|
+ // Initialize locale from saved settings, or on the very first start from the
|
||||||
|
+ // language the desktop is set to.
|
||||||
|
+ const startupLocale =
|
||||||
|
+ (await traceStartupStep('Read the system language', () => localeFromSystem(locale)).catch(
|
||||||
|
+ () => undefined,
|
||||||
|
+ )) ?? locale
|
||||||
|
+ if (startupLocale) {
|
||||||
|
+ await traceStartupStep('Apply startup locale', () => setLocale(startupLocale))
|
||||||
|
+ }
|
||||||
|
+ if (startupLocale !== locale) {
|
||||||
|
+ await getSettings()
|
||||||
|
+ .then((settings) => setSettings({ ...settings, locale: startupLocale }))
|
||||||
|
+ .catch(handleError)
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(appSettings.featureFlags, feature_flags)
|
||||||
|
diff --git a/apps/app-frontend/src/helpers/system-locale.ts b/apps/app-frontend/src/helpers/system-locale.ts
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..2e98fa7
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/apps/app-frontend/src/helpers/system-locale.ts
|
||||||
|
@@ -0,0 +1,71 @@
|
||||||
|
+/**
|
||||||
|
+ * The language the app starts in the first time it runs, taken from the one the
|
||||||
|
+ * desktop is set to.
|
||||||
|
+ *
|
||||||
|
+ * Only languages upstream has translated far enough are taken. Below that the
|
||||||
|
+ * app would be half English anyway, which reads worse than all of it being.
|
||||||
|
+ */
|
||||||
|
+import { LOCALES } from '@modrinth/ui'
|
||||||
|
+import { languageCoverage } from '@modrinth/ui/src/layouts/wrapped/settings/language-settings/language-settings-coverage.generated'
|
||||||
|
+import { locale as osLocale } from '@tauri-apps/plugin-os'
|
||||||
|
+
|
||||||
|
+/** How much of the app has to be in a language for it to be picked. */
|
||||||
|
+const MIN_COVERAGE = 70
|
||||||
|
+
|
||||||
|
+/** Written once the system has been asked, so that only ever happens once. */
|
||||||
|
+const ASKED_KEY = 'modrinth-enhanced:language-from-system'
|
||||||
|
+
|
||||||
|
+/** Windows says zh-Hans-CN; the script is what tells the two Chinese apart. */
|
||||||
|
+const BY_SCRIPT: Record<string, string> = { hans: 'zh-CN', hant: 'zh-TW' }
|
||||||
|
+
|
||||||
|
+function coverage(code: string): number {
|
||||||
|
+ return languageCoverage.app[code]?.percentage ?? 0
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/** The language the app calls a tag that is not one of its own codes. */
|
||||||
|
+function preferred(language: string, region: string | undefined, script: string | undefined) {
|
||||||
|
+ if (script) return BY_SCRIPT[script]
|
||||||
|
+ // Spanish outside Spain is the Latin American one.
|
||||||
|
+ if (language === 'es' && region && region !== 'es') return 'es-419'
|
||||||
|
+ // Otherwise the country the language is named after: de → de-DE, fr → fr-FR.
|
||||||
|
+ return `${language}-${language.toUpperCase()}`
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/** The best translated language the app has for `tag`, if it has one at all. */
|
||||||
|
+function bestMatch(tag: string): string | undefined {
|
||||||
|
+ // de_DE.UTF-8 is a language, a country and how it is encoded.
|
||||||
|
+ const wanted = tag
|
||||||
|
+ .replace(/[.@].*$/, '')
|
||||||
|
+ .replaceAll('_', '-')
|
||||||
|
+ .toLowerCase()
|
||||||
|
+ const parts = wanted.split('-')
|
||||||
|
+ const [language, region] = [parts[0], parts.at(-1)]
|
||||||
|
+ const second = preferred(
|
||||||
|
+ language,
|
||||||
|
+ region,
|
||||||
|
+ parts.find((part) => part in BY_SCRIPT),
|
||||||
|
+ )
|
||||||
|
+
|
||||||
|
+ const rank = (code: string) => (code.toLowerCase() === wanted ? 2 : code === second ? 1 : 0)
|
||||||
|
+
|
||||||
|
+ return LOCALES.map((locale) => locale.code)
|
||||||
|
+ .filter((code) => code.split('-')[0] === language)
|
||||||
|
+ .sort((a, b) => rank(b) - rank(a) || coverage(b) - coverage(a))
|
||||||
|
+ .find((code) => coverage(code) >= MIN_COVERAGE)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/**
|
||||||
|
+ * The language to start in, or nothing to stay with `current`.
|
||||||
|
+ *
|
||||||
|
+ * The system is only asked once, and never over a language that was picked in
|
||||||
|
+ * the settings.
|
||||||
|
+ */
|
||||||
|
+export async function localeFromSystem(current: string): Promise<string | undefined> {
|
||||||
|
+ if (localStorage.getItem(ASKED_KEY) || (current && current !== 'en-US')) return undefined
|
||||||
|
+
|
||||||
|
+ const system = await osLocale().catch(() => null)
|
||||||
|
+ localStorage.setItem(ASKED_KEY, new Date().toISOString())
|
||||||
|
+
|
||||||
|
+ const match = bestMatch(system ?? navigator.language ?? '')
|
||||||
|
+ return match && match !== current ? match : undefined
|
||||||
|
+}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Sat, 19 Sep 2026 02:04:37 +0200
|
||||||
|
Subject: [PATCH] Keep WebKit's own menu off the interface's own images
|
||||||
|
|
||||||
|
---
|
||||||
|
apps/app-frontend/src/App.vue | 11 ++++++++++-
|
||||||
|
apps/app-frontend/src/pages/project/Gallery.vue | 2 +-
|
||||||
|
2 files changed, 11 insertions(+), 2 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
|
||||||
|
index bb4aa89..2559f6a 100644
|
||||||
|
--- a/apps/app-frontend/src/App.vue
|
||||||
|
+++ b/apps/app-frontend/src/App.vue
|
||||||
|
@@ -2359,7 +2359,16 @@ function handleAuxClick(e) {
|
||||||
|
function handleContextMenu(event) {
|
||||||
|
const target = event.target
|
||||||
|
if (target instanceof Element) {
|
||||||
|
- if (target.closest('img, textarea, [contenteditable="true"]')) return
|
||||||
|
+ // WebKit's own menu is worth having on the images a player may want to
|
||||||
|
+ // save or copy — the ones in descriptions and galleries — but not on
|
||||||
|
+ // every avatar and icon the interface is built from.
|
||||||
|
+ if (
|
||||||
|
+ target instanceof HTMLImageElement &&
|
||||||
|
+ target.closest('.markdown-body, [data-native-menu]')
|
||||||
|
+ ) {
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+ if (target.closest('textarea, [contenteditable="true"]')) return
|
||||||
|
const input = target.closest('input')
|
||||||
|
if (
|
||||||
|
input &&
|
||||||
|
diff --git a/apps/app-frontend/src/pages/project/Gallery.vue b/apps/app-frontend/src/pages/project/Gallery.vue
|
||||||
|
index ac6a31c..70036d1 100644
|
||||||
|
--- a/apps/app-frontend/src/pages/project/Gallery.vue
|
||||||
|
+++ b/apps/app-frontend/src/pages/project/Gallery.vue
|
||||||
|
@@ -1,5 +1,5 @@
|
||||||
|
<template>
|
||||||
|
- <div class="gallery">
|
||||||
|
+ <div class="gallery" data-native-menu>
|
||||||
|
<Card v-for="(image, index) in filteredGallery" :key="image.url" class="gallery-item">
|
||||||
|
<a @click="expandImage(image, index)">
|
||||||
|
<img :src="image.url" :alt="image.title" class="gallery-image" />
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Sat, 19 Sep 2026 02:04:37 +0200
|
||||||
|
Subject: [PATCH] Fit the Minecraft sign-in modal to longer translations
|
||||||
|
|
||||||
|
---
|
||||||
|
.../ui/minecraft-required-modal/MinecraftRequiredModal.vue | 4 ++--
|
||||||
|
1 file changed, 2 insertions(+), 2 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||||
|
index 99695db..2cb2e5d 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||||
|
@@ -1,6 +1,6 @@
|
||||||
|
<template>
|
||||||
|
<NewModal ref="modal" :header="formatMessage(messages.header)" max-width="544px" no-padding>
|
||||||
|
- <div class="grid grid-cols-[1fr_auto] gap-2.5 h-[154px] px-7 pt-4 pb-1 pr-9">
|
||||||
|
+ <div class="grid grid-cols-[1fr_auto] gap-2.5 min-h-[154px] px-7 pt-4 pb-1 pr-9">
|
||||||
|
<div class="flex flex-col gap-2.5 items-start justify-center h-min mt-5">
|
||||||
|
<div class="font-semibold text-xl text-contrast">
|
||||||
|
{{ formatMessage(messages.descriptionHeader) }}
|
||||||
|
@@ -10,7 +10,7 @@
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative h-full w-[96px] overflow-hidden mx-3">
|
||||||
|
- <div class="absolute top-0 left-0 z-0 w-full flex grow-0 flex-col items-end p-0">
|
||||||
|
+ <div class="absolute bottom-0 left-0 z-0 w-full flex grow-0 flex-col items-end p-0">
|
||||||
|
<img :src="steveImage" alt="" class="self-stretch" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Sat, 19 Sep 2026 02:22:32 +0200
|
||||||
|
Subject: [PATCH] Hold a button's label still as the pointer arrives
|
||||||
|
|
||||||
|
---
|
||||||
|
.../src/components/ui/TitleBarAccountSwitcher.vue | 4 +++-
|
||||||
|
packages/ui/src/components/base/buttons/ButtonFrame.vue | 6 +++++-
|
||||||
|
2 files changed, 8 insertions(+), 2 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/TitleBarAccountSwitcher.vue b/apps/app-frontend/src/components/ui/TitleBarAccountSwitcher.vue
|
||||||
|
index b21fae7..f9be6c5 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/TitleBarAccountSwitcher.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/TitleBarAccountSwitcher.vue
|
||||||
|
@@ -28,8 +28,10 @@
|
||||||
|
:key="account.profile.id"
|
||||||
|
class="flex min-w-0 items-center gap-1"
|
||||||
|
>
|
||||||
|
+ <!-- btn-transparent: the same hover background as the buttons below,
|
||||||
|
+ rather than button-base's filter, which makes the text shift. -->
|
||||||
|
<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"
|
||||||
|
+ class="button-base btn-transparent flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-lg border-0 bg-transparent p-2 text-left hover:bg-surface-4 focus-visible:bg-surface-4"
|
||||||
|
@click="choose(account, close)"
|
||||||
|
>
|
||||||
|
<RadioButtonCheckedIcon v-if="isSelected(account)" class="size-5 shrink-0 text-brand" />
|
||||||
|
diff --git a/packages/ui/src/components/base/buttons/ButtonFrame.vue b/packages/ui/src/components/base/buttons/ButtonFrame.vue
|
||||||
|
index cc33922..6afd6a5 100644
|
||||||
|
--- a/packages/ui/src/components/base/buttons/ButtonFrame.vue
|
||||||
|
+++ b/packages/ui/src/components/base/buttons/ButtonFrame.vue
|
||||||
|
@@ -53,8 +53,12 @@ const typeClasses: Record<ButtonType, string> = {
|
||||||
|
}
|
||||||
|
|
||||||
|
const interactionClasses: Record<ButtonInteraction, string> = {
|
||||||
|
+ // The background carries the hover on its own. Brightening the button as
|
||||||
|
+ // well puts its label in a filter of its own, which WebKit rasterizes apart
|
||||||
|
+ // from the page and lands a glyph or two off, so the text appears to shift
|
||||||
|
+ // as the pointer arrives and again as it leaves.
|
||||||
|
surface:
|
||||||
|
- '[&:not(:disabled):not([aria-disabled=true]):hover]:bg-surface-4 [&:not(:disabled):not([aria-disabled=true]):focus-visible]:bg-surface-4',
|
||||||
|
+ '[&:not(:disabled):not([aria-disabled=true]):hover]:bg-surface-4 [&:not(:disabled):not([aria-disabled=true]):focus-visible]:bg-surface-4 [&:not(:disabled):not([aria-disabled=true]):hover]:!filter-none [&:not(:disabled):not([aria-disabled=true]):focus-visible]:!filter-none',
|
||||||
|
filled:
|
||||||
|
'[&:not(:disabled):not([aria-disabled=true]):hover]:!bg-[--button-color] [&:not(:disabled):not([aria-disabled=true]):focus-visible]:!bg-[--button-color] [&:not(:disabled):not([aria-disabled=true]):hover]:!text-[var(--color-accent-contrast)] [&:not(:disabled):not([aria-disabled=true]):focus-visible]:!text-[var(--color-accent-contrast)]',
|
||||||
|
none: '[&:not(:disabled):not([aria-disabled=true]):hover]:!brightness-100 [&:not(:disabled):not([aria-disabled=true]):focus-visible]:!brightness-100',
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Sat, 19 Sep 2026 02:22:32 +0200
|
||||||
|
Subject: [PATCH] Stay behind the game while another instance is running
|
||||||
|
|
||||||
|
---
|
||||||
|
packages/app-lib/src/state/process.rs | 5 ++++-
|
||||||
|
1 file changed, 4 insertions(+), 1 deletion(-)
|
||||||
|
|
||||||
|
diff --git a/packages/app-lib/src/state/process.rs b/packages/app-lib/src/state/process.rs
|
||||||
|
index 4c9ea57..c242fea 100644
|
||||||
|
--- a/packages/app-lib/src/state/process.rs
|
||||||
|
+++ b/packages/app-lib/src/state/process.rs
|
||||||
|
@@ -1063,8 +1063,11 @@ impl Process {
|
||||||
|
|
||||||
|
let _ = state.friends_socket.update_status(None).await;
|
||||||
|
|
||||||
|
+ // Refocus only once the last instance is gone: while another one is
|
||||||
|
+ // still being played, taking the screen back from it is the last thing
|
||||||
|
+ // the player wants.
|
||||||
|
#[cfg(feature = "tauri")]
|
||||||
|
- {
|
||||||
|
+ if state.process_manager.get_all().is_empty() {
|
||||||
|
let settings = crate::state::Settings::get(&state.pool).await?;
|
||||||
|
if settings.refocus_on_game_close
|
||||||
|
&& let Some(window) =
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Sat, 19 Sep 2026 02:22:32 +0200
|
||||||
|
Subject: [PATCH] Drop the tooltip from the sidebar settings' drag handles
|
||||||
|
|
||||||
|
---
|
||||||
|
.../src/components/ui/settings/display/SidebarSettings.vue | 3 ++-
|
||||||
|
1 file changed, 2 insertions(+), 1 deletion(-)
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/settings/display/SidebarSettings.vue b/apps/app-frontend/src/components/ui/settings/display/SidebarSettings.vue
|
||||||
|
index dc0cd1f..8f2ea3a 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/settings/display/SidebarSettings.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/settings/display/SidebarSettings.vue
|
||||||
|
@@ -15,8 +15,9 @@
|
||||||
|
<div
|
||||||
|
class="flex cursor-grab items-center gap-3 rounded-2xl border border-solid border-surface-4 bg-surface-2 p-3 active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
+ <!-- No tooltip: it would follow the pointer around a list whose
|
||||||
|
+ every row has one, and stay up through the drag itself. -->
|
||||||
|
<button
|
||||||
|
- v-tooltip="formatMessage(messages.reorder)"
|
||||||
|
type="button"
|
||||||
|
class="flex cursor-grab border-none bg-transparent p-0 text-secondary active:cursor-grabbing"
|
||||||
|
:aria-label="formatMessage(messages.reorder)"
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Sat, 19 Sep 2026 02:22:32 +0200
|
||||||
|
Subject: [PATCH] Keep Modrinth's translations off the text this fork rewrote
|
||||||
|
|
||||||
|
---
|
||||||
|
.../minecraft-required-modal/MinecraftRequiredModal.vue | 4 ++--
|
||||||
|
.../src/components/ui/modal/AppSettingsModal.vue | 2 +-
|
||||||
|
.../components/ui/settings/account/PrivacySettings.vue | 2 +-
|
||||||
|
apps/app-frontend/src/locales/de-CH/index.json | 9 +++++++++
|
||||||
|
apps/app-frontend/src/locales/de-DE/index.json | 9 +++++++++
|
||||||
|
apps/app-frontend/src/locales/es-419/index.json | 9 +++++++++
|
||||||
|
apps/app-frontend/src/locales/es-ES/index.json | 9 +++++++++
|
||||||
|
apps/app-frontend/src/locales/fr-FR/index.json | 9 +++++++++
|
||||||
|
apps/app-frontend/src/locales/hu-HU/index.json | 9 +++++++++
|
||||||
|
apps/app-frontend/src/locales/it-IT/index.json | 9 +++++++++
|
||||||
|
apps/app-frontend/src/locales/ja-JP/index.json | 9 +++++++++
|
||||||
|
apps/app-frontend/src/locales/nl-NL/index.json | 9 +++++++++
|
||||||
|
apps/app-frontend/src/locales/pl-PL/index.json | 9 +++++++++
|
||||||
|
apps/app-frontend/src/locales/pt-BR/index.json | 9 +++++++++
|
||||||
|
apps/app-frontend/src/locales/ru-RU/index.json | 9 +++++++++
|
||||||
|
apps/app-frontend/src/locales/sv-SE/index.json | 9 +++++++++
|
||||||
|
apps/app-frontend/src/locales/tr-TR/index.json | 9 +++++++++
|
||||||
|
apps/app-frontend/src/locales/uk-UA/index.json | 9 +++++++++
|
||||||
|
apps/app-frontend/src/locales/zh-CN/index.json | 9 +++++++++
|
||||||
|
apps/app-frontend/src/locales/zh-TW/index.json | 9 +++++++++
|
||||||
|
20 files changed, 157 insertions(+), 4 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||||
|
index 2cb2e5d..4c5e246 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||||
|
@@ -87,7 +87,7 @@ const accountsCard = inject('accountsCard') as Ref<InstanceType<typeof AccountsC
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
header: {
|
||||||
|
- id: 'minecraft-required.header',
|
||||||
|
+ id: 'minecraft-required.modal-title',
|
||||||
|
defaultMessage: 'Sign in to Minecraft',
|
||||||
|
},
|
||||||
|
descriptionHeader: {
|
||||||
|
@@ -95,7 +95,7 @@ const messages = defineMessages({
|
||||||
|
defaultMessage: 'Sign in to a Microsoft account',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
- id: 'minecraft-required.description',
|
||||||
|
+ id: 'minecraft-required.microsoft-explainer',
|
||||||
|
defaultMessage:
|
||||||
|
'A Microsoft account that owns Minecraft is what lets you play online and keep your skin.',
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue b/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue
|
||||||
|
index e409510..77d3a15 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue
|
||||||
|
@@ -331,7 +331,7 @@ const messages = defineMessages({
|
||||||
|
defaultMessage: 'Downloading v{version}',
|
||||||
|
},
|
||||||
|
appVersion: {
|
||||||
|
- id: 'app.settings.app-version',
|
||||||
|
+ id: 'app.settings.enhanced-version',
|
||||||
|
defaultMessage: 'Modrinth Enhanced {version}',
|
||||||
|
},
|
||||||
|
macos: {
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/settings/account/PrivacySettings.vue b/apps/app-frontend/src/components/ui/settings/account/PrivacySettings.vue
|
||||||
|
index d31e8c5..5061572 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/settings/account/PrivacySettings.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/settings/account/PrivacySettings.vue
|
||||||
|
@@ -38,7 +38,7 @@ const messages = defineMessages({
|
||||||
|
defaultMessage: 'Telemetry',
|
||||||
|
},
|
||||||
|
telemetryDescription: {
|
||||||
|
- id: 'app.settings.privacy.telemetry.description',
|
||||||
|
+ id: 'app.settings.privacy.telemetry.none',
|
||||||
|
defaultMessage:
|
||||||
|
'Modrinth Enhanced collects no analytics or usage data and sends no crash reports, so there is nothing here to turn off.',
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/locales/de-CH/index.json b/apps/app-frontend/src/locales/de-CH/index.json
|
||||||
|
index 1dae5a6..5238f19 100644
|
||||||
|
--- a/apps/app-frontend/src/locales/de-CH/index.json
|
||||||
|
+++ b/apps/app-frontend/src/locales/de-CH/index.json
|
||||||
|
@@ -3245,6 +3245,9 @@
|
||||||
|
"app.settings.privacy.telemetry.description": {
|
||||||
|
"message": "Modrinth sammelt anonymisierte Analyse- und Nutzungsdaten, um unsere Nutzererfahrung zu verbessern und deine Erfahrung anzupassen. Wenn du diese Option deaktivierst, entscheidest du dich dagegen und deine Daten werden nicht mehr gesammelt."
|
||||||
|
},
|
||||||
|
+ "app.settings.privacy.telemetry.none": {
|
||||||
|
+ "message": "Modrinth Enhanced sammelt keine Analyse- oder Nutzungsdaten und sendet keine Absturzberichte – hier gibt es also nichts abzuschalten."
|
||||||
|
+ },
|
||||||
|
"app.settings.privacy.telemetry.title": {
|
||||||
|
"message": "Telemetrie"
|
||||||
|
},
|
||||||
|
@@ -5177,6 +5180,12 @@
|
||||||
|
"minecraft-required.header": {
|
||||||
|
"message": "Minecraft erforderlich"
|
||||||
|
},
|
||||||
|
+ "minecraft-required.microsoft-explainer": {
|
||||||
|
+ "message": "Mit einem Microsoft-Konto, das Minecraft besitzt, kannst du online spielen und behältst deinen Skin."
|
||||||
|
+ },
|
||||||
|
+ "minecraft-required.modal-title": {
|
||||||
|
+ "message": "Bei Minecraft anmelden"
|
||||||
|
+ },
|
||||||
|
"minecraft-required.offline-hint": {
|
||||||
|
"message": "Oder melde dich mit Ely.by oder einem eigenen Server an – oder spiele Einzelspieler und Offline-Server ganz ohne Account."
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/locales/de-DE/index.json b/apps/app-frontend/src/locales/de-DE/index.json
|
||||||
|
index f3e3d9d..76a27ab 100644
|
||||||
|
--- a/apps/app-frontend/src/locales/de-DE/index.json
|
||||||
|
+++ b/apps/app-frontend/src/locales/de-DE/index.json
|
||||||
|
@@ -3245,6 +3245,9 @@
|
||||||
|
"app.settings.privacy.telemetry.description": {
|
||||||
|
"message": "Modrinth sammelt anonymisierte Analyse- und Nutzungsdaten, um unsere Nutzererfahrung zu verbessern und deine Erfahrung anzupassen. Wenn du diese Option deaktivierst, entscheidest du dich dagegen und deine Daten werden nicht mehr gesammelt."
|
||||||
|
},
|
||||||
|
+ "app.settings.privacy.telemetry.none": {
|
||||||
|
+ "message": "Modrinth Enhanced sammelt keine Analyse- oder Nutzungsdaten und sendet keine Absturzberichte – hier gibt es also nichts abzuschalten."
|
||||||
|
+ },
|
||||||
|
"app.settings.privacy.telemetry.title": {
|
||||||
|
"message": "Telemetrie"
|
||||||
|
},
|
||||||
|
@@ -5177,6 +5180,12 @@
|
||||||
|
"minecraft-required.header": {
|
||||||
|
"message": "Minecraft erforderlich"
|
||||||
|
},
|
||||||
|
+ "minecraft-required.microsoft-explainer": {
|
||||||
|
+ "message": "Mit einem Microsoft-Konto, das Minecraft besitzt, kannst du online spielen und behältst deinen Skin."
|
||||||
|
+ },
|
||||||
|
+ "minecraft-required.modal-title": {
|
||||||
|
+ "message": "Bei Minecraft anmelden"
|
||||||
|
+ },
|
||||||
|
"minecraft-required.offline-hint": {
|
||||||
|
"message": "Oder melde dich mit Ely.by oder einem eigenen Server an – oder spiele Einzelspieler und Offline-Server ganz ohne Account."
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/locales/es-419/index.json b/apps/app-frontend/src/locales/es-419/index.json
|
||||||
|
index 0ba9ee0..b0ad1f6 100644
|
||||||
|
--- a/apps/app-frontend/src/locales/es-419/index.json
|
||||||
|
+++ b/apps/app-frontend/src/locales/es-419/index.json
|
||||||
|
@@ -3245,6 +3245,9 @@
|
||||||
|
"app.settings.privacy.telemetry.description": {
|
||||||
|
"message": "Modrinth recolecta datos analíticos anónimos y datos de uso para mejorar la experiencia de nuestros usuarios y para personalizar tu experiencia. Al desactivar esta opción, tus datos dejarán de ser recolectados."
|
||||||
|
},
|
||||||
|
+ "app.settings.privacy.telemetry.none": {
|
||||||
|
+ "message": "Modrinth Enhanced no recopila datos de análisis ni de uso, y no envía informes de fallos, así que aquí no hay nada que desactivar."
|
||||||
|
+ },
|
||||||
|
"app.settings.privacy.telemetry.title": {
|
||||||
|
"message": "Telemetría"
|
||||||
|
},
|
||||||
|
@@ -5177,6 +5180,12 @@
|
||||||
|
"minecraft-required.header": {
|
||||||
|
"message": "Minecraft requerido"
|
||||||
|
},
|
||||||
|
+ "minecraft-required.microsoft-explainer": {
|
||||||
|
+ "message": "Una cuenta de Microsoft que tenga Minecraft es lo que te permite jugar en línea y conservar tu skin."
|
||||||
|
+ },
|
||||||
|
+ "minecraft-required.modal-title": {
|
||||||
|
+ "message": "Inicia sesión en Minecraft"
|
||||||
|
+ },
|
||||||
|
"minecraft-required.offline-hint": {
|
||||||
|
"message": "O inicia sesión con Ely.by o con un servidor personalizado, o juega en un jugador y en servidores sin conexión sin ninguna cuenta."
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/locales/es-ES/index.json b/apps/app-frontend/src/locales/es-ES/index.json
|
||||||
|
index 115ae56..8504901 100644
|
||||||
|
--- a/apps/app-frontend/src/locales/es-ES/index.json
|
||||||
|
+++ b/apps/app-frontend/src/locales/es-ES/index.json
|
||||||
|
@@ -3242,6 +3242,9 @@
|
||||||
|
"app.settings.privacy.telemetry.description": {
|
||||||
|
"message": "Modrinth recopila análisis y datos de uso anónimos para mejorar la experiencia de usuario y personalizar tu experiencia. Deshabilitando esta opción sus datos dejarán de ser recopilados."
|
||||||
|
},
|
||||||
|
+ "app.settings.privacy.telemetry.none": {
|
||||||
|
+ "message": "Modrinth Enhanced no recopila datos de análisis ni de uso, y no envía informes de fallos, así que aquí no hay nada que desactivar."
|
||||||
|
+ },
|
||||||
|
"app.settings.privacy.telemetry.title": {
|
||||||
|
"message": "Telemetría"
|
||||||
|
},
|
||||||
|
@@ -5174,6 +5177,12 @@
|
||||||
|
"minecraft-required.header": {
|
||||||
|
"message": "Minecraft requerido"
|
||||||
|
},
|
||||||
|
+ "minecraft-required.microsoft-explainer": {
|
||||||
|
+ "message": "Una cuenta de Microsoft que tenga Minecraft es lo que te permite jugar en línea y conservar tu skin."
|
||||||
|
+ },
|
||||||
|
+ "minecraft-required.modal-title": {
|
||||||
|
+ "message": "Inicia sesión en Minecraft"
|
||||||
|
+ },
|
||||||
|
"minecraft-required.offline-hint": {
|
||||||
|
"message": "O inicia sesión con Ely.by o con un servidor personalizado, o juega en un jugador y en servidores sin conexión sin ninguna cuenta."
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/locales/fr-FR/index.json b/apps/app-frontend/src/locales/fr-FR/index.json
|
||||||
|
index aeed85e..6f6a4bb 100644
|
||||||
|
--- a/apps/app-frontend/src/locales/fr-FR/index.json
|
||||||
|
+++ b/apps/app-frontend/src/locales/fr-FR/index.json
|
||||||
|
@@ -2915,6 +2915,9 @@
|
||||||
|
"app.settings.privacy.telemetry.description": {
|
||||||
|
"message": "Modrinth collecte des données d'analyse et d'utilisation anonymes afin d'améliorer l'expérience utilisateur et de personnaliser votre expérience. En désactivant cette option, vous choisissez de ne plus participer et vos données ne seront plus collectées."
|
||||||
|
},
|
||||||
|
+ "app.settings.privacy.telemetry.none": {
|
||||||
|
+ "message": "Modrinth Enhanced ne collecte aucune donnée d'analyse ou d'utilisation et n'envoie aucun rapport de plantage : il n'y a donc rien à désactiver ici."
|
||||||
|
+ },
|
||||||
|
"app.settings.privacy.telemetry.title": {
|
||||||
|
"message": "Télémétrie"
|
||||||
|
},
|
||||||
|
@@ -4457,6 +4460,12 @@
|
||||||
|
"minecraft-required.header": {
|
||||||
|
"message": "Minecraft requis"
|
||||||
|
},
|
||||||
|
+ "minecraft-required.microsoft-explainer": {
|
||||||
|
+ "message": "Un compte Microsoft qui possède Minecraft est ce qui permet de jouer en ligne et de garder son skin."
|
||||||
|
+ },
|
||||||
|
+ "minecraft-required.modal-title": {
|
||||||
|
+ "message": "Se connecter à Minecraft"
|
||||||
|
+ },
|
||||||
|
"minecraft-required.offline-hint": {
|
||||||
|
"message": "Ou connectez-vous avec Ely.by ou un serveur personnalisé, ou jouez en solo et sur des serveurs en mode hors ligne sans aucun compte."
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/locales/hu-HU/index.json b/apps/app-frontend/src/locales/hu-HU/index.json
|
||||||
|
index e6b0ddf..66952d4 100644
|
||||||
|
--- a/apps/app-frontend/src/locales/hu-HU/index.json
|
||||||
|
+++ b/apps/app-frontend/src/locales/hu-HU/index.json
|
||||||
|
@@ -3197,6 +3197,9 @@
|
||||||
|
"app.settings.privacy.telemetry.description": {
|
||||||
|
"message": "A Modrinth névtelen használati és telemetriai adatokat gyűjt a felhasználói élmény javítása és személyre szabása érdekében. Ha kikapcsolod ezt a beállítást, leiratkozol erről, és a rendszer többé nem gyűjti az adataidat."
|
||||||
|
},
|
||||||
|
+ "app.settings.privacy.telemetry.none": {
|
||||||
|
+ "message": "A Modrinth Enhanced nem gyűjt elemzési vagy használati adatokat, és nem küld összeomlási jelentéseket, így itt nincs mit kikapcsolni."
|
||||||
|
+ },
|
||||||
|
"app.settings.privacy.telemetry.title": {
|
||||||
|
"message": "Telemetria"
|
||||||
|
},
|
||||||
|
@@ -5069,6 +5072,12 @@
|
||||||
|
"minecraft-required.header": {
|
||||||
|
"message": "Minecraft szükséges"
|
||||||
|
},
|
||||||
|
+ "minecraft-required.microsoft-explainer": {
|
||||||
|
+ "message": "Egy Minecrafttal rendelkező Microsoft-fiók az, ami lehetővé teszi az online játékot és a skin megtartását."
|
||||||
|
+ },
|
||||||
|
+ "minecraft-required.modal-title": {
|
||||||
|
+ "message": "Bejelentkezés a Minecraftba"
|
||||||
|
+ },
|
||||||
|
"minecraft-required.offline-hint": {
|
||||||
|
"message": "Vagy jelentkezz be Ely.byjal vagy saját szerverrel, vagy játssz egyjátékos módban és offline szervereken bármilyen fiók nélkül."
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/locales/it-IT/index.json b/apps/app-frontend/src/locales/it-IT/index.json
|
||||||
|
index 93dab40..db542bd 100644
|
||||||
|
--- a/apps/app-frontend/src/locales/it-IT/index.json
|
||||||
|
+++ b/apps/app-frontend/src/locales/it-IT/index.json
|
||||||
|
@@ -3239,6 +3239,9 @@
|
||||||
|
"app.settings.privacy.telemetry.description": {
|
||||||
|
"message": "Modrinth raccoglie dati anonimizzati di analisi e utilizzo per migliorare l'esperienza utente e personalizzarla. Disabilitando questa opzione neghi il consenso e i tuoi dati non saranno più raccolti."
|
||||||
|
},
|
||||||
|
+ "app.settings.privacy.telemetry.none": {
|
||||||
|
+ "message": "Modrinth Enhanced non raccoglie dati di analisi o di utilizzo e non invia segnalazioni di arresto anomalo: qui non c'è nulla da disattivare."
|
||||||
|
+ },
|
||||||
|
"app.settings.privacy.telemetry.title": {
|
||||||
|
"message": "Telemetria"
|
||||||
|
},
|
||||||
|
@@ -5171,6 +5174,12 @@
|
||||||
|
"minecraft-required.header": {
|
||||||
|
"message": "Minecraft richiesto"
|
||||||
|
},
|
||||||
|
+ "minecraft-required.microsoft-explainer": {
|
||||||
|
+ "message": "Un account Microsoft che possiede Minecraft è ciò che ti permette di giocare online e di mantenere la tua skin."
|
||||||
|
+ },
|
||||||
|
+ "minecraft-required.modal-title": {
|
||||||
|
+ "message": "Accedi a Minecraft"
|
||||||
|
+ },
|
||||||
|
"minecraft-required.offline-hint": {
|
||||||
|
"message": "Oppure accedi con Ely.by o con un server personalizzato, o gioca in singolo e sui server in modalità offline senza alcun account."
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/locales/ja-JP/index.json b/apps/app-frontend/src/locales/ja-JP/index.json
|
||||||
|
index 7805ff0..51b7174 100644
|
||||||
|
--- a/apps/app-frontend/src/locales/ja-JP/index.json
|
||||||
|
+++ b/apps/app-frontend/src/locales/ja-JP/index.json
|
||||||
|
@@ -2303,6 +2303,9 @@
|
||||||
|
"app.settings.privacy.telemetry.description": {
|
||||||
|
"message": "Modrinthはユーザー体験の向上および最適化のために、匿名化されたアナリティクスと利用データを収集しています。このオプションをオフにすることで無効化(オプトアウト)され、以降データの収集は行われません。"
|
||||||
|
},
|
||||||
|
+ "app.settings.privacy.telemetry.none": {
|
||||||
|
+ "message": "Modrinth Enhanced は分析データや利用データを一切収集せず、クラッシュレポートも送信しません。そのため、ここで無効にするものはありません。"
|
||||||
|
+ },
|
||||||
|
"app.settings.privacy.telemetry.title": {
|
||||||
|
"message": "テレメトリ"
|
||||||
|
},
|
||||||
|
@@ -3809,6 +3812,12 @@
|
||||||
|
"minecraft-required.header": {
|
||||||
|
"message": "Minecraftが必要です"
|
||||||
|
},
|
||||||
|
+ "minecraft-required.microsoft-explainer": {
|
||||||
|
+ "message": "Minecraft を所有する Microsoft アカウントがあれば、オンラインでプレイでき、スキンもそのまま使えます。"
|
||||||
|
+ },
|
||||||
|
+ "minecraft-required.modal-title": {
|
||||||
|
+ "message": "Minecraft にサインイン"
|
||||||
|
+ },
|
||||||
|
"minecraft-required.offline-hint": {
|
||||||
|
"message": "Ely.by やカスタムサーバーでサインインすることも、アカウントなしでシングルプレイやオフラインモードのサーバーで遊ぶこともできます。"
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/locales/nl-NL/index.json b/apps/app-frontend/src/locales/nl-NL/index.json
|
||||||
|
index 2d3892f..e3b7aa4 100644
|
||||||
|
--- a/apps/app-frontend/src/locales/nl-NL/index.json
|
||||||
|
+++ b/apps/app-frontend/src/locales/nl-NL/index.json
|
||||||
|
@@ -3125,6 +3125,9 @@
|
||||||
|
"app.settings.privacy.telemetry.description": {
|
||||||
|
"message": "Modrinth verzamelt geanonimiseerde analyse- en gebruiksgegevens om de gebruikerservaring te verbeteren en uw ervaring te personaliseren. Door deze optie uit te schakelen, meldt u zich af en worden uw gegevens niet langer verzameld."
|
||||||
|
},
|
||||||
|
+ "app.settings.privacy.telemetry.none": {
|
||||||
|
+ "message": "Modrinth Enhanced verzamelt geen analyse- of gebruiksgegevens en stuurt geen crashrapporten, dus hier valt niets uit te schakelen."
|
||||||
|
+ },
|
||||||
|
"app.settings.privacy.telemetry.title": {
|
||||||
|
"message": "Telemetrie"
|
||||||
|
},
|
||||||
|
@@ -4724,6 +4727,12 @@
|
||||||
|
"minecraft-required.header": {
|
||||||
|
"message": "Minecraft vereist"
|
||||||
|
},
|
||||||
|
+ "minecraft-required.microsoft-explainer": {
|
||||||
|
+ "message": "Met een Microsoft-account dat Minecraft bezit kun je online spelen en houd je je skin."
|
||||||
|
+ },
|
||||||
|
+ "minecraft-required.modal-title": {
|
||||||
|
+ "message": "Inloggen bij Minecraft"
|
||||||
|
+ },
|
||||||
|
"minecraft-required.offline-hint": {
|
||||||
|
"message": "Of log in met Ely.by of een eigen server, of speel singleplayer en servers in offlinemodus zonder enig account."
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/locales/pl-PL/index.json b/apps/app-frontend/src/locales/pl-PL/index.json
|
||||||
|
index 42d3acd..2d3daf1 100644
|
||||||
|
--- a/apps/app-frontend/src/locales/pl-PL/index.json
|
||||||
|
+++ b/apps/app-frontend/src/locales/pl-PL/index.json
|
||||||
|
@@ -3233,6 +3233,9 @@
|
||||||
|
"app.settings.privacy.telemetry.description": {
|
||||||
|
"message": "Modrinth zbiera anonimowe dane analityczne i użytkowania, aby poprawić komfort naszych użytkowników i dostosować Twoje doświadczenia. Wyłączając tę opcję, rezygnujesz i Twoje dane nie będą już gromadzone."
|
||||||
|
},
|
||||||
|
+ "app.settings.privacy.telemetry.none": {
|
||||||
|
+ "message": "Modrinth Enhanced nie zbiera żadnych danych analitycznych ani danych o użyciu i nie wysyła raportów o awariach, więc nie ma tu czego wyłączać."
|
||||||
|
+ },
|
||||||
|
"app.settings.privacy.telemetry.title": {
|
||||||
|
"message": "Telemetria"
|
||||||
|
},
|
||||||
|
@@ -5165,6 +5168,12 @@
|
||||||
|
"minecraft-required.header": {
|
||||||
|
"message": "Minecraft wymagany"
|
||||||
|
},
|
||||||
|
+ "minecraft-required.microsoft-explainer": {
|
||||||
|
+ "message": "Konto Microsoft z Minecraftem pozwala grać online i zachować swój skin."
|
||||||
|
+ },
|
||||||
|
+ "minecraft-required.modal-title": {
|
||||||
|
+ "message": "Zaloguj się do Minecrafta"
|
||||||
|
+ },
|
||||||
|
"minecraft-required.offline-hint": {
|
||||||
|
"message": "Albo zaloguj się przez Ely.by lub własny serwer, albo graj w trybie jednoosobowym i na serwerach offline zupełnie bez konta."
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/locales/pt-BR/index.json b/apps/app-frontend/src/locales/pt-BR/index.json
|
||||||
|
index 24e6af6..e01bf82 100644
|
||||||
|
--- a/apps/app-frontend/src/locales/pt-BR/index.json
|
||||||
|
+++ b/apps/app-frontend/src/locales/pt-BR/index.json
|
||||||
|
@@ -3245,6 +3245,9 @@
|
||||||
|
"app.settings.privacy.telemetry.description": {
|
||||||
|
"message": "O Modrinth coleta analíticas anonimizadas e uso de dados para aprimorar nossa experiência de usuário e personalizar sua experiência. Ao desativar esta opção, você negará e seus dados não serão mais coletados."
|
||||||
|
},
|
||||||
|
+ "app.settings.privacy.telemetry.none": {
|
||||||
|
+ "message": "O Modrinth Enhanced não coleta dados de análise ou de uso e não envia relatórios de falha, então não há nada para desativar aqui."
|
||||||
|
+ },
|
||||||
|
"app.settings.privacy.telemetry.title": {
|
||||||
|
"message": "Telemetria"
|
||||||
|
},
|
||||||
|
@@ -5177,6 +5180,12 @@
|
||||||
|
"minecraft-required.header": {
|
||||||
|
"message": "Minecraft necessário"
|
||||||
|
},
|
||||||
|
+ "minecraft-required.microsoft-explainer": {
|
||||||
|
+ "message": "Uma conta Microsoft que possui o Minecraft é o que permite jogar online e manter sua skin."
|
||||||
|
+ },
|
||||||
|
+ "minecraft-required.modal-title": {
|
||||||
|
+ "message": "Entrar no Minecraft"
|
||||||
|
+ },
|
||||||
|
"minecraft-required.offline-hint": {
|
||||||
|
"message": "Ou entre com o Ely.by ou com um servidor personalizado, ou jogue sozinho e em servidores offline sem conta nenhuma."
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/locales/ru-RU/index.json b/apps/app-frontend/src/locales/ru-RU/index.json
|
||||||
|
index e27fc6c..d010ced 100644
|
||||||
|
--- a/apps/app-frontend/src/locales/ru-RU/index.json
|
||||||
|
+++ b/apps/app-frontend/src/locales/ru-RU/index.json
|
||||||
|
@@ -3224,6 +3224,9 @@
|
||||||
|
"app.settings.privacy.telemetry.description": {
|
||||||
|
"message": "Modrinth собирает анонимные данные об использовании для персонализации и улучшения сервиса. Отключите этот параметр, чтобы отказаться от сбора данных."
|
||||||
|
},
|
||||||
|
+ "app.settings.privacy.telemetry.none": {
|
||||||
|
+ "message": "Modrinth Enhanced не собирает аналитику и данные об использовании и не отправляет отчёты о сбоях, поэтому отключать здесь нечего."
|
||||||
|
+ },
|
||||||
|
"app.settings.privacy.telemetry.title": {
|
||||||
|
"message": "Телеметрия"
|
||||||
|
},
|
||||||
|
@@ -5153,6 +5156,12 @@
|
||||||
|
"minecraft-required.header": {
|
||||||
|
"message": "Требуется Minecraft"
|
||||||
|
},
|
||||||
|
+ "minecraft-required.microsoft-explainer": {
|
||||||
|
+ "message": "Аккаунт Microsoft с купленным Minecraft — это то, что позволяет играть по сети и сохранять свой скин."
|
||||||
|
+ },
|
||||||
|
+ "minecraft-required.modal-title": {
|
||||||
|
+ "message": "Вход в Minecraft"
|
||||||
|
+ },
|
||||||
|
"minecraft-required.offline-hint": {
|
||||||
|
"message": "Или войдите через Ely.by либо свой сервер — или играйте в одиночной игре и на офлайн-серверах вообще без учётной записи."
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/locales/sv-SE/index.json b/apps/app-frontend/src/locales/sv-SE/index.json
|
||||||
|
index efa9b5a..49b0f9f 100644
|
||||||
|
--- a/apps/app-frontend/src/locales/sv-SE/index.json
|
||||||
|
+++ b/apps/app-frontend/src/locales/sv-SE/index.json
|
||||||
|
@@ -2624,6 +2624,9 @@
|
||||||
|
"app.settings.privacy.telemetry.description": {
|
||||||
|
"message": "Modrinth samlar in anonymiserad statistik och användningsdata för att förbättra vår användarupplevelse och anpassa din upplevelse. Genom att inaktivera det här alternativet avanmäler du dig och din data kommer inte längre samlas in."
|
||||||
|
},
|
||||||
|
+ "app.settings.privacy.telemetry.none": {
|
||||||
|
+ "message": "Modrinth Enhanced samlar inte in någon analys- eller användningsdata och skickar inga kraschrapporter, så här finns inget att stänga av."
|
||||||
|
+ },
|
||||||
|
"app.settings.privacy.telemetry.title": {
|
||||||
|
"message": "Telemetri"
|
||||||
|
},
|
||||||
|
@@ -4175,6 +4178,12 @@
|
||||||
|
"minecraft-required.header": {
|
||||||
|
"message": "Minecraft krävs"
|
||||||
|
},
|
||||||
|
+ "minecraft-required.microsoft-explainer": {
|
||||||
|
+ "message": "Ett Microsoft-konto som äger Minecraft är det som låter dig spela online och behålla ditt skin."
|
||||||
|
+ },
|
||||||
|
+ "minecraft-required.modal-title": {
|
||||||
|
+ "message": "Logga in på Minecraft"
|
||||||
|
+ },
|
||||||
|
"minecraft-required.offline-hint": {
|
||||||
|
"message": "Eller logga in med Ely.by eller en egen server, eller spela ensamspelarvärldar och servrar i offlineläge helt utan konto."
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/locales/tr-TR/index.json b/apps/app-frontend/src/locales/tr-TR/index.json
|
||||||
|
index a8c7802..5f0dfe0 100644
|
||||||
|
--- a/apps/app-frontend/src/locales/tr-TR/index.json
|
||||||
|
+++ b/apps/app-frontend/src/locales/tr-TR/index.json
|
||||||
|
@@ -2708,6 +2708,9 @@
|
||||||
|
"app.settings.privacy.telemetry.description": {
|
||||||
|
"message": "Modrinth kullanıcı deneyimimizi geliştirmek için ve deneyiminizi kişiselleştirmek için anonimleştirilmiş analiz ve kullanım verilerini toplar. Bu seçeneği devre dışı bırakarak, bundan vazgeçmiş olursunuz ve verileriniz artık toplanmayacaktır."
|
||||||
|
},
|
||||||
|
+ "app.settings.privacy.telemetry.none": {
|
||||||
|
+ "message": "Modrinth Enhanced hiçbir analiz veya kullanım verisi toplamaz ve çökme raporu göndermez; burada kapatılacak bir şey yok."
|
||||||
|
+ },
|
||||||
|
"app.settings.privacy.telemetry.title": {
|
||||||
|
"message": "Telemetri"
|
||||||
|
},
|
||||||
|
@@ -4217,6 +4220,12 @@
|
||||||
|
"minecraft-required.header": {
|
||||||
|
"message": "Minecraft gereklidir"
|
||||||
|
},
|
||||||
|
+ "minecraft-required.microsoft-explainer": {
|
||||||
|
+ "message": "Minecraft'a sahip bir Microsoft hesabı, çevrim içi oynamanı ve görünümünü korumanı sağlayan şeydir."
|
||||||
|
+ },
|
||||||
|
+ "minecraft-required.modal-title": {
|
||||||
|
+ "message": "Minecraft'a giriş yap"
|
||||||
|
+ },
|
||||||
|
"minecraft-required.offline-hint": {
|
||||||
|
"message": "Ya da Ely.by veya özel bir sunucuyla oturum açın; ya da hiçbir hesap olmadan tek oyunculu ve çevrimdışı sunucularda oynayın."
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/locales/uk-UA/index.json b/apps/app-frontend/src/locales/uk-UA/index.json
|
||||||
|
index d38df4c..ab6596e 100644
|
||||||
|
--- a/apps/app-frontend/src/locales/uk-UA/index.json
|
||||||
|
+++ b/apps/app-frontend/src/locales/uk-UA/index.json
|
||||||
|
@@ -3236,6 +3236,9 @@
|
||||||
|
"app.settings.privacy.telemetry.description": {
|
||||||
|
"message": "Modrinth збирає анонімізовану аналітику та дані про використання для покращення зручності та адаптації сервісу. Вимкнувши цю опцію, ви відмовляєтеся від збору, і ваші дані більше не збиратимуться."
|
||||||
|
},
|
||||||
|
+ "app.settings.privacy.telemetry.none": {
|
||||||
|
+ "message": "Modrinth Enhanced не збирає аналітику та дані про використання й не надсилає звіти про збої, тож тут нема чого вимикати."
|
||||||
|
+ },
|
||||||
|
"app.settings.privacy.telemetry.title": {
|
||||||
|
"message": "Телеметрія"
|
||||||
|
},
|
||||||
|
@@ -5168,6 +5171,12 @@
|
||||||
|
"minecraft-required.header": {
|
||||||
|
"message": "Потрібний Minecraft"
|
||||||
|
},
|
||||||
|
+ "minecraft-required.microsoft-explainer": {
|
||||||
|
+ "message": "Обліковий запис Microsoft із придбаним Minecraft — це те, що дозволяє грати онлайн і зберігати свій скін."
|
||||||
|
+ },
|
||||||
|
+ "minecraft-required.modal-title": {
|
||||||
|
+ "message": "Вхід у Minecraft"
|
||||||
|
+ },
|
||||||
|
"minecraft-required.offline-hint": {
|
||||||
|
"message": "Або увійдіть через Ely.by чи власний сервер — або грайте в одиночній грі та на офлайн-серверах узагалі без облікового запису."
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json
|
||||||
|
index 2a763fb..5449c62 100644
|
||||||
|
--- a/apps/app-frontend/src/locales/zh-CN/index.json
|
||||||
|
+++ b/apps/app-frontend/src/locales/zh-CN/index.json
|
||||||
|
@@ -3245,6 +3245,9 @@
|
||||||
|
"app.settings.privacy.telemetry.description": {
|
||||||
|
"message": "Modrinth 收集匿名分析和使用数据,以提升用户体验并为您提供个性化服务。若禁用此选项,即表示您选择退出,您的数据将不再被收集。"
|
||||||
|
},
|
||||||
|
+ "app.settings.privacy.telemetry.none": {
|
||||||
|
+ "message": "Modrinth Enhanced 不收集任何分析或使用数据,也不发送崩溃报告,因此这里没有可关闭的内容。"
|
||||||
|
+ },
|
||||||
|
"app.settings.privacy.telemetry.title": {
|
||||||
|
"message": "遥测"
|
||||||
|
},
|
||||||
|
@@ -5177,6 +5180,12 @@
|
||||||
|
"minecraft-required.header": {
|
||||||
|
"message": "需要 Minecraft"
|
||||||
|
},
|
||||||
|
+ "minecraft-required.microsoft-explainer": {
|
||||||
|
+ "message": "拥有 Minecraft 的 Microsoft 账号才能联机游戏并保留你的皮肤。"
|
||||||
|
+ },
|
||||||
|
+ "minecraft-required.modal-title": {
|
||||||
|
+ "message": "登录 Minecraft"
|
||||||
|
+ },
|
||||||
|
"minecraft-required.offline-hint": {
|
||||||
|
"message": "也可以使用 Ely.by 或自定义服务器登录,或者不用任何账户,直接玩单人游戏和离线模式服务器。"
|
||||||
|
},
|
||||||
|
diff --git a/apps/app-frontend/src/locales/zh-TW/index.json b/apps/app-frontend/src/locales/zh-TW/index.json
|
||||||
|
index 81d505d..7cf751d 100644
|
||||||
|
--- a/apps/app-frontend/src/locales/zh-TW/index.json
|
||||||
|
+++ b/apps/app-frontend/src/locales/zh-TW/index.json
|
||||||
|
@@ -3245,6 +3245,9 @@
|
||||||
|
"app.settings.privacy.telemetry.description": {
|
||||||
|
"message": "Modrinth 會收集匿名化的分析與使用資料,以提升使用者體驗並提供客製化服務。若停用此選項,即表示你選擇退出,你的資料將不再被收集。"
|
||||||
|
},
|
||||||
|
+ "app.settings.privacy.telemetry.none": {
|
||||||
|
+ "message": "Modrinth Enhanced 不會收集任何分析或使用資料,也不會傳送當機報告,因此這裡沒有可以關閉的項目。"
|
||||||
|
+ },
|
||||||
|
"app.settings.privacy.telemetry.title": {
|
||||||
|
"message": "遙測"
|
||||||
|
},
|
||||||
|
@@ -5177,6 +5180,12 @@
|
||||||
|
"minecraft-required.header": {
|
||||||
|
"message": "需要 Minecraft"
|
||||||
|
},
|
||||||
|
+ "minecraft-required.microsoft-explainer": {
|
||||||
|
+ "message": "擁有 Minecraft 的 Microsoft 帳號才能連線遊玩並保留你的外觀。"
|
||||||
|
+ },
|
||||||
|
+ "minecraft-required.modal-title": {
|
||||||
|
+ "message": "登入 Minecraft"
|
||||||
|
+ },
|
||||||
|
"minecraft-required.offline-hint": {
|
||||||
|
"message": "也可以用 Ely.by 或自訂伺服器登入,或者不用任何帳號,直接玩單人遊戲和離線模式的伺服器。"
|
||||||
|
},
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||||
|
Date: Sat, 19 Sep 2026 02:42:55 +0200
|
||||||
|
Subject: [PATCH] Show the running instance bar only while one is running
|
||||||
|
|
||||||
|
---
|
||||||
|
.../src/components/ui/AppActionBar.vue | 189 +++++++++---------
|
||||||
|
1 file changed, 91 insertions(+), 98 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/apps/app-frontend/src/components/ui/AppActionBar.vue b/apps/app-frontend/src/components/ui/AppActionBar.vue
|
||||||
|
index eff399e..d4648a5 100644
|
||||||
|
--- a/apps/app-frontend/src/components/ui/AppActionBar.vue
|
||||||
|
+++ b/apps/app-frontend/src/components/ui/AppActionBar.vue
|
||||||
|
@@ -17,106 +17,103 @@
|
||||||
|
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
|
||||||
|
</div>
|
||||||
|
<AppUpdateButton />
|
||||||
|
+ <!-- Nothing running is worth no chip at all: it is there while an
|
||||||
|
+ instance is, and out of the way the rest of the time. -->
|
||||||
|
<div
|
||||||
|
+ v-if="selectedProcess"
|
||||||
|
class="flex border-solid border-surface-5 text-sm font-medium items-center gap-2 py-1.5 px-3 rounded-xl border"
|
||||||
|
>
|
||||||
|
- <template v-if="selectedProcess">
|
||||||
|
- <OnlineIndicatorIcon />
|
||||||
|
- <div class="text-contrast flex items-center gap-2">
|
||||||
|
- <router-link
|
||||||
|
- v-tooltip="formatMessage(messages.viewInstance)"
|
||||||
|
- :to="`/instance/${encodeURIComponent(selectedProcess.instance.id)}`"
|
||||||
|
- class="hover:underline"
|
||||||
|
- >
|
||||||
|
- {{ selectedProcess.instance.name }}
|
||||||
|
- </router-link>
|
||||||
|
- <FloatingMenu
|
||||||
|
- v-if="currentProcesses.length > 1"
|
||||||
|
- placement="bottom"
|
||||||
|
- @open="showInstances = true"
|
||||||
|
- @close="showInstances = false"
|
||||||
|
+ <OnlineIndicatorIcon />
|
||||||
|
+ <div class="text-contrast flex items-center gap-2">
|
||||||
|
+ <router-link
|
||||||
|
+ v-tooltip="formatMessage(messages.viewInstance)"
|
||||||
|
+ :to="`/instance/${encodeURIComponent(selectedProcess.instance.id)}`"
|
||||||
|
+ class="hover:underline"
|
||||||
|
+ >
|
||||||
|
+ {{ selectedProcess.instance.name }}
|
||||||
|
+ </router-link>
|
||||||
|
+ <FloatingMenu
|
||||||
|
+ v-if="currentProcesses.length > 1"
|
||||||
|
+ placement="bottom"
|
||||||
|
+ @open="showInstances = true"
|
||||||
|
+ @close="showInstances = false"
|
||||||
|
+ >
|
||||||
|
+ <IconButton
|
||||||
|
+ v-tooltip="
|
||||||
|
+ showInstances
|
||||||
|
+ ? formatMessage(messages.hideMoreRunningInstances)
|
||||||
|
+ : formatMessage(messages.showMoreRunningInstances)
|
||||||
|
+ "
|
||||||
|
+ class="!size-6"
|
||||||
|
+ type="quiet"
|
||||||
|
+ size="xs"
|
||||||
|
+ :label="
|
||||||
|
+ showInstances
|
||||||
|
+ ? formatMessage(messages.hideMoreRunningInstances)
|
||||||
|
+ : formatMessage(messages.showMoreRunningInstances)
|
||||||
|
+ "
|
||||||
|
>
|
||||||
|
- <IconButton
|
||||||
|
- v-tooltip="
|
||||||
|
- showInstances
|
||||||
|
- ? formatMessage(messages.hideMoreRunningInstances)
|
||||||
|
- : formatMessage(messages.showMoreRunningInstances)
|
||||||
|
- "
|
||||||
|
- class="!size-6"
|
||||||
|
- type="quiet"
|
||||||
|
- size="xs"
|
||||||
|
- :label="
|
||||||
|
- showInstances
|
||||||
|
- ? formatMessage(messages.hideMoreRunningInstances)
|
||||||
|
- : formatMessage(messages.showMoreRunningInstances)
|
||||||
|
- "
|
||||||
|
- >
|
||||||
|
- <DropdownIcon :class="{ 'rotate-180': !!showInstances }" />
|
||||||
|
- </IconButton>
|
||||||
|
- <template #popper>
|
||||||
|
- <div class="flex w-[20rem] max-h-[24rem] flex-col gap-2 overflow-auto">
|
||||||
|
- <div
|
||||||
|
- v-for="process in currentProcesses"
|
||||||
|
- :key="process.uuid"
|
||||||
|
- class="flex w-full items-center gap-2 rounded-xl bg-surface-4 p-2 text-sm"
|
||||||
|
+ <DropdownIcon :class="{ 'rotate-180': !!showInstances }" />
|
||||||
|
+ </IconButton>
|
||||||
|
+ <template #popper>
|
||||||
|
+ <div class="flex w-[20rem] max-h-[24rem] flex-col gap-2 overflow-auto">
|
||||||
|
+ <div
|
||||||
|
+ v-for="process in currentProcesses"
|
||||||
|
+ :key="process.uuid"
|
||||||
|
+ class="flex w-full items-center gap-2 rounded-xl bg-surface-4 p-2 text-sm"
|
||||||
|
+ >
|
||||||
|
+ <button
|
||||||
|
+ v-tooltip.left="
|
||||||
|
+ process.uuid === selectedProcess.uuid
|
||||||
|
+ ? formatMessage(messages.primaryInstance)
|
||||||
|
+ : formatMessage(messages.makePrimaryInstance)
|
||||||
|
+ "
|
||||||
|
+ class="flex flex-grow items-center gap-2"
|
||||||
|
+ :class="{
|
||||||
|
+ 'active:scale-95 transition-transform': process.uuid !== selectedProcess.uuid,
|
||||||
|
+ }"
|
||||||
|
+ :disabled="process.uuid === selectedProcess.uuid"
|
||||||
|
+ @click="selectProcess(process)"
|
||||||
|
+ >
|
||||||
|
+ <OnlineIndicatorIcon />
|
||||||
|
+ <span class="mr-auto text-contrast flex items-center gap-2">
|
||||||
|
+ {{ process.instance.name }}
|
||||||
|
+ <StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" />
|
||||||
|
+ </span>
|
||||||
|
+ </button>
|
||||||
|
+ <button
|
||||||
|
+ v-tooltip="formatMessage(messages.stopInstance)"
|
||||||
|
+ class="active:scale-95 flex"
|
||||||
|
+ @click.stop="stop(process)"
|
||||||
|
+ >
|
||||||
|
+ <StopCircleIcon class="text-red size-5" />
|
||||||
|
+ </button>
|
||||||
|
+ <button
|
||||||
|
+ v-tooltip="formatMessage(messages.viewLogs)"
|
||||||
|
+ class="active:scale-95 flex"
|
||||||
|
+ @click.stop="goToTerminal(process.instance.id)"
|
||||||
|
>
|
||||||
|
- <button
|
||||||
|
- v-tooltip.left="
|
||||||
|
- process.uuid === selectedProcess.uuid
|
||||||
|
- ? formatMessage(messages.primaryInstance)
|
||||||
|
- : formatMessage(messages.makePrimaryInstance)
|
||||||
|
- "
|
||||||
|
- class="flex flex-grow items-center gap-2"
|
||||||
|
- :class="{
|
||||||
|
- 'active:scale-95 transition-transform': process.uuid !== selectedProcess.uuid,
|
||||||
|
- }"
|
||||||
|
- :disabled="process.uuid === selectedProcess.uuid"
|
||||||
|
- @click="selectProcess(process)"
|
||||||
|
- >
|
||||||
|
- <OnlineIndicatorIcon />
|
||||||
|
- <span class="mr-auto text-contrast flex items-center gap-2">
|
||||||
|
- {{ process.instance.name }}
|
||||||
|
- <StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" />
|
||||||
|
- </span>
|
||||||
|
- </button>
|
||||||
|
- <button
|
||||||
|
- v-tooltip="formatMessage(messages.stopInstance)"
|
||||||
|
- class="active:scale-95 flex"
|
||||||
|
- @click.stop="stop(process)"
|
||||||
|
- >
|
||||||
|
- <StopCircleIcon class="text-red size-5" />
|
||||||
|
- </button>
|
||||||
|
- <button
|
||||||
|
- v-tooltip="formatMessage(messages.viewLogs)"
|
||||||
|
- class="active:scale-95 flex"
|
||||||
|
- @click.stop="goToTerminal(process.instance.id)"
|
||||||
|
- >
|
||||||
|
- <TerminalSquareIcon class="text-secondary size-5" />
|
||||||
|
- </button>
|
||||||
|
- </div>
|
||||||
|
+ <TerminalSquareIcon class="text-secondary size-5" />
|
||||||
|
+ </button>
|
||||||
|
</div>
|
||||||
|
- </template>
|
||||||
|
- </FloatingMenu>
|
||||||
|
- </div>
|
||||||
|
- <button
|
||||||
|
- v-tooltip="formatMessage(messages.stopInstance)"
|
||||||
|
- class="active:scale-95 flex"
|
||||||
|
- @click="stop(selectedProcess)"
|
||||||
|
- >
|
||||||
|
- <StopCircleIcon class="text-red size-5" />
|
||||||
|
- </button>
|
||||||
|
- <button
|
||||||
|
- v-tooltip="formatMessage(messages.viewLogs)"
|
||||||
|
- class="active:scale-95 flex"
|
||||||
|
- @click="goToTerminal()"
|
||||||
|
- >
|
||||||
|
- <TerminalSquareIcon class="text-secondary size-5" />
|
||||||
|
- </button>
|
||||||
|
- </template>
|
||||||
|
- <template v-else>
|
||||||
|
- <span class="size-2 rounded-full bg-secondary" />
|
||||||
|
- <span class="text-secondary"> {{ formatMessage(messages.noInstancesRunning) }} </span>
|
||||||
|
- </template>
|
||||||
|
+ </div>
|
||||||
|
+ </template>
|
||||||
|
+ </FloatingMenu>
|
||||||
|
+ </div>
|
||||||
|
+ <button
|
||||||
|
+ v-tooltip="formatMessage(messages.stopInstance)"
|
||||||
|
+ class="active:scale-95 flex"
|
||||||
|
+ @click="stop(selectedProcess)"
|
||||||
|
+ >
|
||||||
|
+ <StopCircleIcon class="text-red size-5" />
|
||||||
|
+ </button>
|
||||||
|
+ <button
|
||||||
|
+ v-tooltip="formatMessage(messages.viewLogs)"
|
||||||
|
+ class="active:scale-95 flex"
|
||||||
|
+ @click="goToTerminal()"
|
||||||
|
+ >
|
||||||
|
+ <TerminalSquareIcon class="text-secondary size-5" />
|
||||||
|
+ </button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
@@ -202,10 +199,6 @@ const messages = defineMessages({
|
||||||
|
id: 'app.action-bar.view-logs',
|
||||||
|
defaultMessage: 'View logs',
|
||||||
|
},
|
||||||
|
- noInstancesRunning: {
|
||||||
|
- id: 'app.action-bar.no-instances-running',
|
||||||
|
- defaultMessage: 'No instances running',
|
||||||
|
- },
|
||||||
|
downloadingJava: {
|
||||||
|
id: 'app.action-bar.downloading-java',
|
||||||
|
defaultMessage: 'Downloading Java {version}',
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
v0.21.6
|
||||||
+114
-4
@@ -20,6 +20,14 @@ cp "$WORKTREE/packages/app-lib/.env.prod" "$WORKTREE/packages/app-lib/.env"
|
|||||||
log "Installing JavaScript dependencies"
|
log "Installing JavaScript dependencies"
|
||||||
(cd "$WORKTREE" && pnpm install --frozen-lockfile)
|
(cd "$WORKTREE" && pnpm install --frozen-lockfile)
|
||||||
|
|
||||||
|
# The frontend is built through turbo, whose local cache keeps every task's
|
||||||
|
# outputs, and upstream counts the Rust target directory among them: gigabytes
|
||||||
|
# a build, never cleaned up, and copied back over target/ on a cache hit. A
|
||||||
|
# release build wants none of that, so the local cache is off and what an
|
||||||
|
# earlier build left there is removed.
|
||||||
|
export TURBO_CACHE=remote:r
|
||||||
|
rm -rf "$WORKTREE/.turbo/cache"
|
||||||
|
|
||||||
tauri_args=()
|
tauri_args=()
|
||||||
case "$(uname -s)" in
|
case "$(uname -s)" in
|
||||||
MINGW* | MSYS* | CYGWIN* | Windows_NT)
|
MINGW* | MSYS* | CYGWIN* | Windows_NT)
|
||||||
@@ -32,13 +40,105 @@ Darwin)
|
|||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
platform=linux
|
platform=linux
|
||||||
|
# The AppImage is assembled by linuxdeploy, which is itself an AppImage and
|
||||||
|
# needs FUSE to mount. Plenty of desktops no longer ship FUSE 2, so tell it
|
||||||
|
# to unpack itself instead; on a machine that has FUSE this changes nothing.
|
||||||
|
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||||
|
# Its strip pass fails outright on some distributions, taking the whole
|
||||||
|
# bundle with it and reporting nothing about why. Skipping it was measured
|
||||||
|
# to cost 4KB of the finished 136MB AppImage, which is not worth a build
|
||||||
|
# that only works on some machines.
|
||||||
|
export NO_STRIP=1
|
||||||
|
# appimagetool guesses the architecture from every ELF file in the AppDir
|
||||||
|
# and gives up when it finds two, which happens as soon as the GTK plugin
|
||||||
|
# picks up a 32-bit GIO module from a multilib system's /usr/lib32.
|
||||||
|
export ARCH="$(uname -m)"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
log "Building for $platform"
|
# The repository releases come from, which the app's update notice points at.
|
||||||
(cd "$WORKTREE" && pnpm --filter=@modrinth/app run tauri build "${tauri_args[@]}")
|
export MODRINTH_ENHANCED_REPOSITORY="${GITHUB_REPOSITORY:-$(git -C "$REPO_ROOT" remote get-url origin | sed -E 's#^.*github\.com[:/]##; s#\.git$##')}"
|
||||||
|
|
||||||
log "Collecting bundles into $ARTIFACTS"
|
# 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 "${DEV:-}" ]; then
|
||||||
|
warn "DEV is set, so this build has no updater"
|
||||||
|
elif [ -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}"
|
||||||
|
|
||||||
|
# A dev build is an app of its own: its own name, and with it its own bundle
|
||||||
|
# identifier, which is what decides the data directory. It shares no instances,
|
||||||
|
# accounts or settings with an installed Modrinth Enhanced, and is installed
|
||||||
|
# beside one rather than over it.
|
||||||
|
if [ -n "${DEV:-}" ]; then
|
||||||
|
dev_conf="$REPO_ROOT/build/dev.conf.json"
|
||||||
|
mkdir -p "$(dirname "$dev_conf")"
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs")
|
||||||
|
const [base, platform] = process.argv.slice(1)
|
||||||
|
const config = JSON.parse(fs.readFileSync(base, "utf8"))
|
||||||
|
// The platform file replaces the windows of the one it is merged into.
|
||||||
|
const platformConfig = fs.existsSync(platform)
|
||||||
|
? JSON.parse(fs.readFileSync(platform, "utf8"))
|
||||||
|
: {}
|
||||||
|
const windows = platformConfig.app?.windows ?? config.app.windows
|
||||||
|
const name = "Modrinth Enhanced (dev)"
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
productName: name,
|
||||||
|
identifier: "ModrinthAppDev",
|
||||||
|
app: { windows: windows.map((window) => ({ ...window, title: name })) },
|
||||||
|
}, null, "\t"))
|
||||||
|
' "$WORKTREE/apps/app/tauri.conf.json" "$WORKTREE/apps/app/tauri.$platform.conf.json" >"$dev_conf"
|
||||||
|
tauri_args+=(--config "$dev_conf")
|
||||||
|
log "Building Modrinth Enhanced (dev), which shares nothing with a stable install"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A local build to try something out does not need the last few percent of the
|
||||||
|
# release profile. Thin LTO across several cores takes well under half the time
|
||||||
|
# of the full one, for an installer some megabytes larger.
|
||||||
|
if [ -n "${FAST:-}" ]; then
|
||||||
|
export CARGO_PROFILE_RELEASE_LTO=thin
|
||||||
|
export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
|
||||||
|
warn "FAST is set, so this build is optimized less than a release"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Emptied before the build, not after it: a build that fails halfway would
|
||||||
|
# otherwise leave the previous run's installers sitting here, where
|
||||||
|
# scripts/check.sh would happily pass them off as this build's output.
|
||||||
|
log "Clearing $ARTIFACTS"
|
||||||
rm -rf "$ARTIFACTS"
|
rm -rf "$ARTIFACTS"
|
||||||
mkdir -p "$ARTIFACTS"
|
mkdir -p "$ARTIFACTS"
|
||||||
|
|
||||||
@@ -48,13 +148,23 @@ 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"
|
||||||
|
|
||||||
|
|||||||
+195
-2
@@ -34,13 +34,22 @@ 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"'
|
||||||
check "the window is titled Modrinth Enhanced" \
|
check "the window is titled Modrinth Enhanced" \
|
||||||
contains "$WORKTREE/apps/app/tauri.conf.json" '"title": "Modrinth Enhanced"'
|
contains "$WORKTREE/apps/app/tauri.conf.json" '"title": "Modrinth Enhanced"'
|
||||||
check "the icon set was replaced" \
|
|
||||||
test -f "$WORKTREE/apps/app/icons/modrinth-enhanced.svg"
|
|
||||||
|
|
||||||
log "Offline accounts"
|
log "Offline accounts"
|
||||||
check "app-lib exposes login_offline" \
|
check "app-lib exposes login_offline" \
|
||||||
@@ -49,6 +58,187 @@ check "the Tauri command is registered" \
|
|||||||
contains "$WORKTREE/apps/app/src/api/auth.rs" 'login_offline,'
|
contains "$WORKTREE/apps/app/src/api/auth.rs" 'login_offline,'
|
||||||
check "the frontend can reach it" \
|
check "the frontend can reach it" \
|
||||||
contains "$WORKTREE/apps/app-frontend/src/helpers/auth.js" "plugin:auth|login_offline"
|
contains "$WORKTREE/apps/app-frontend/src/helpers/auth.js" "plugin:auth|login_offline"
|
||||||
|
check "it is reachable with no account yet" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue" 'showOfflineAccountModal'
|
||||||
|
check "the checklist offers the same choice" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/App.vue" '@login-minecraft="minecraftRequiredModal?.show()"'
|
||||||
|
|
||||||
|
# A Tauri command that is not listed in build.rs compiles, ships, and then
|
||||||
|
# fails at runtime with "not allowed by ACL". Nothing else here would catch it.
|
||||||
|
log "Tauri command permissions"
|
||||||
|
while read -r command; do
|
||||||
|
[ -n "$command" ] || continue
|
||||||
|
check "$command is allowed by the ACL" \
|
||||||
|
contains "$WORKTREE/apps/app/build.rs" "\"$command\","
|
||||||
|
done < <(grep -h -A2 '#\[tauri::command\]' \
|
||||||
|
"$WORKTREE/apps/app/src/api/auth.rs" "$WORKTREE/apps/app/src/api/ely_skins.rs" \
|
||||||
|
"$WORKTREE/apps/app/src/api/crash_analysis.rs" "$WORKTREE/apps/app/src/api/skin_browser.rs" |
|
||||||
|
grep -oE 'pub async fn [a-z_]+' | awk '{print $4}' | sort -u)
|
||||||
|
|
||||||
|
# Listing a command in build.rs is not enough either: the main window only
|
||||||
|
# reaches a plugin whose default permission is in its capabilities.
|
||||||
|
log "Plugin permissions"
|
||||||
|
while read -r plugin; do
|
||||||
|
[ -n "$plugin" ] || continue
|
||||||
|
check "$plugin is granted to the main window" \
|
||||||
|
contains "$WORKTREE/apps/app/capabilities/plugins.json" "\"$plugin:default\""
|
||||||
|
done < <(grep -A1 '\.plugin($' "$WORKTREE/apps/app/build.rs" |
|
||||||
|
grep -oE '^ *"[a-z-]+",$' | tr -d ' ",' | sort -u)
|
||||||
|
|
||||||
|
log "Microsoft sign-in"
|
||||||
|
check "the browser flow is registered" \
|
||||||
|
contains "$WORKTREE/apps/app/src/api/auth.rs" 'login_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" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/components/ui/AccountsCard.vue" 'microsoftLoginModal.value?.show'
|
||||||
|
|
||||||
|
log "Ely.by accounts"
|
||||||
|
check "app-lib can sign in to Ely.by" \
|
||||||
|
contains "$WORKTREE/packages/app-lib/src/api/minecraft_auth.rs" 'pub async fn login_ely'
|
||||||
|
check "the Tauri command is registered" \
|
||||||
|
contains "$WORKTREE/apps/app/src/api/auth.rs" 'login_ely,'
|
||||||
|
check "authlib-injector is added at launch" \
|
||||||
|
contains "$WORKTREE/packages/app-lib/src/launcher/mod.rs" 'authlib_injector'
|
||||||
|
|
||||||
|
log "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"
|
||||||
|
check "the frontend can tell an Ely.by account" \
|
||||||
|
contains "$WORKTREE/packages/app-lib/src/state/minecraft_auth.rs" 'serialize_field("ely"'
|
||||||
|
check "the plugin is registered" \
|
||||||
|
contains "$WORKTREE/apps/app/src/main.rs" 'api::ely_skins::init()'
|
||||||
|
check "the skin page changes skins on Ely.by" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/pages/Skins.vue" 'wearElySkin'
|
||||||
|
|
||||||
|
log "Skins on offline servers"
|
||||||
|
check "the agent fills in missing skins" \
|
||||||
|
contains "$WORKTREE/packages/app-lib/java/src/main/java/com/modrinth/theseus/agent/TheseusAgent.java" 'SessionServiceTransformer'
|
||||||
|
check "the launcher turns it on" \
|
||||||
|
contains "$WORKTREE/packages/app-lib/src/launcher/args.rs" 'enhanced.skins.source'
|
||||||
|
check "the skins folder can be opened" \
|
||||||
|
contains "$WORKTREE/apps/app/build.rs" '"show_player_skins_folder",'
|
||||||
|
|
||||||
|
log "Importing instances"
|
||||||
|
check "every launcher is offered, installed or not" \
|
||||||
|
contains "$WORKTREE/packages/app-lib/src/api/pack/import/mod.rs" 'pub fn launcher_suggestions'
|
||||||
|
check "the import stage shows them" \
|
||||||
|
contains "$WORKTREE/packages/ui/src/components/flows/creation-flow-modal/components/ImportInstanceStage.vue" 'otherLaunchers'
|
||||||
|
|
||||||
|
log "Another copy of a running instance"
|
||||||
|
check "a running instance can start again" \
|
||||||
|
contains "$WORKTREE/packages/app-lib/src/api/instance/run.rs" 'pub async fn run_additional'
|
||||||
|
check "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" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/pages/instance/logs/index.vue" 'ProcessConsole'
|
||||||
|
check "log events say which copy" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/generated/app-events/LogPayload.ts" 'process_uuid'
|
||||||
|
|
||||||
|
log "Crash explanation"
|
||||||
|
check "the rules are there" \
|
||||||
|
contains "$WORKTREE/packages/app-lib/src/api/crash_analysis.rs" 'pub async fn analyze_instance'
|
||||||
|
check "the plugin is registered" \
|
||||||
|
contains "$WORKTREE/apps/app/src/main.rs" 'api::crash_analysis::init()'
|
||||||
|
check "the Logs tab shows it" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/pages/instance/logs/index.vue" '<CrashDiagnosis'
|
||||||
|
|
||||||
|
log "Skin browser"
|
||||||
|
check "Ely.by's catalogue can be browsed" \
|
||||||
|
contains "$WORKTREE/packages/app-lib/src/api/skin_browser.rs" 'pub async fn ely_catalogue'
|
||||||
|
check "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" \
|
||||||
|
contains "$WORKTREE/apps/app/src/api/skin_browser.rs" 'pub async fn skin_browser_open_site'
|
||||||
|
check "the skin page has a Browse tab" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/pages/Skins.vue" '<SkinBrowser'
|
||||||
|
|
||||||
|
log "Sidebar and news"
|
||||||
|
check "the sidebar buttons follow the saved order" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'sidebarLayout.order.value'
|
||||||
|
check "they can be arranged in the settings" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue" 'content: SidebarSettings'
|
||||||
|
check "right-clicking one hides it" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'openSidebarMenu'
|
||||||
|
check "an instance can be deleted from the sidebar" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue" 'askToDelete'
|
||||||
|
check "the library's free space offers a new group" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/pages/Index.vue" 'openNewGroupModal()'
|
||||||
|
check "the news section can be collapsed" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'setNewsCollapsed'
|
||||||
|
check "the right sidebar has a fold button" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'setSidebarCollapsed(sidebarToggled)'
|
||||||
|
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
|
||||||
|
# the main window would otherwise silently not reach Linux.
|
||||||
|
same_linux_window() {
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs")
|
||||||
|
const [base, linux] = process.argv.slice(1).map((p) => JSON.parse(fs.readFileSync(p, "utf8")).app.windows[0])
|
||||||
|
delete linux.transparent
|
||||||
|
const sorted = (o) => JSON.stringify(o, Object.keys(o).sort())
|
||||||
|
process.exit(sorted(base) === sorted(linux) ? 0 : 1)
|
||||||
|
' "$WORKTREE/apps/app/tauri.conf.json" "$WORKTREE/apps/app/tauri.linux.conf.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
log "Window"
|
||||||
|
check "the Linux window is transparent" \
|
||||||
|
contains "$WORKTREE/apps/app/tauri.linux.conf.json" '"transparent": true'
|
||||||
|
check "the Linux window otherwise matches upstream" same_linux_window
|
||||||
|
check "its corners are rounded" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/App.vue" "'rounded-window'"
|
||||||
|
check "the middle button autoscrolls" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'installAutoscroll()'
|
||||||
|
check "file pickers use the desktop portal on Linux" \
|
||||||
|
contains "$WORKTREE/apps/app/src/main.rs" 'set_var("GTK_USE_PORTAL", "1")'
|
||||||
|
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 "Language"
|
||||||
|
check "the first start takes the system's language" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'localeFromSystem(locale)'
|
||||||
|
check "a barely translated language is left alone" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/helpers/system-locale.ts" 'MIN_COVERAGE = 70'
|
||||||
|
# The app's own text lives outside Modrinth's catalogues, so nothing but this
|
||||||
|
# keeps it English in languages the rest of the app is translated to.
|
||||||
|
for locale in de-CH de-DE es-419 es-ES fr-FR hu-HU it-IT ja-JP nl-NL pl-PL pt-BR ru-RU sv-SE tr-TR uk-UA zh-CN zh-TW; do
|
||||||
|
check "our own text is in $locale" \
|
||||||
|
contains "$WORKTREE/apps/app-frontend/src/locales/$locale/index.json" '"app.crash.header"'
|
||||||
|
done
|
||||||
|
|
||||||
|
log "Updates"
|
||||||
|
check "updates do not come from Modrinth" \
|
||||||
|
missing "$WORKTREE/apps/app-frontend/src/App.vue" 'launcher-files.modrinth.com/updates.json'
|
||||||
|
check "the updater tells revisions apart" \
|
||||||
|
contains "$WORKTREE/apps/app/src/main.rs" 'default_version_comparator'
|
||||||
|
|
||||||
|
log "No advertising or upsells"
|
||||||
|
check "no Modrinth+ upsell in the app" \
|
||||||
|
missing "$WORKTREE/apps/app-frontend/src/App.vue" "modrinth.plus"
|
||||||
|
check "the ad helpers are stubbed" \
|
||||||
|
missing "$WORKTREE/apps/app-frontend/src/helpers/ads.js" "plugin:ads"
|
||||||
|
|
||||||
log "No telemetry in the sources"
|
log "No telemetry in the sources"
|
||||||
# Quoted, so that the module names being mentioned in a comment explaining why
|
# Quoted, so that the module names being mentioned in a comment explaining why
|
||||||
@@ -93,6 +283,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
|
||||||
|
|||||||
+12
-1
@@ -19,7 +19,18 @@ WORKTREE="${WORKTREE:-$REPO_ROOT/build/upstream}"
|
|||||||
# Branch the patches are applied on top of the upstream tag as.
|
# Branch the patches are applied on top of the upstream tag as.
|
||||||
PATCH_BRANCH=enhanced
|
PATCH_BRANCH=enhanced
|
||||||
|
|
||||||
PATCH_DIR="$REPO_ROOT/patches"
|
PATCH_DIR="${PATCH_DIR:-$REPO_ROOT/patches}"
|
||||||
|
|
||||||
|
# The upstream release the patches in patches/ were last exported against,
|
||||||
|
# written there by export-patches.sh. upstream.txt cannot say: a release bumps
|
||||||
|
# it without exporting the patches again.
|
||||||
|
patch_base() {
|
||||||
|
if [ -f "$PATCH_DIR/base.txt" ]; then
|
||||||
|
tr -d '[:space:]' <"$PATCH_DIR/base.txt"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$UPSTREAM_REF"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
printf '\033[1;32m==>\033[0m %s\n' "$*"
|
printf '\033[1;32m==>\033[0m %s\n' "$*"
|
||||||
|
|||||||
@@ -26,5 +26,8 @@ git -C "$WORKTREE" format-patch \
|
|||||||
--output-directory "$PATCH_DIR" \
|
--output-directory "$PATCH_DIR" \
|
||||||
"$UPSTREAM_REF..HEAD"
|
"$UPSTREAM_REF..HEAD"
|
||||||
|
|
||||||
|
# So that prepare.sh applies them where they fit and rebases from there.
|
||||||
|
printf '%s\n' "$UPSTREAM_REF" >"$PATCH_DIR/base.txt"
|
||||||
|
|
||||||
log "patches/ now contains:"
|
log "patches/ now contains:"
|
||||||
ls -1 "$PATCH_DIR"
|
ls -1 "$PATCH_DIR"
|
||||||
+50
-11
@@ -4,11 +4,23 @@
|
|||||||
# The result lands in build/upstream on the `enhanced` branch and is what all
|
# The result lands in build/upstream on the `enhanced` branch and is what all
|
||||||
# other scripts build from. Running this again always starts from a clean
|
# other scripts build from. Running this again always starts from a clean
|
||||||
# upstream tree, so it is safe to repeat.
|
# upstream tree, so it is safe to repeat.
|
||||||
|
#
|
||||||
|
# The patches are applied to the release they were exported against, where they
|
||||||
|
# always fit, and rebased onto $UPSTREAM_REF when that is a different release.
|
||||||
|
# A rebase merges against the files the patches were written for, so upstream
|
||||||
|
# changing something near a patch resolves by itself. `git am` straight onto the
|
||||||
|
# new release cannot do that: a shallow checkout does not have those files.
|
||||||
|
|
||||||
. "$(dirname "${BASH_SOURCE[0]}")/common.sh"
|
. "$(dirname "${BASH_SOURCE[0]}")/common.sh"
|
||||||
|
|
||||||
|
BASE_REF="$(patch_base)"
|
||||||
|
|
||||||
log "Upstream $UPSTREAM_REF from $UPSTREAM_REPO"
|
log "Upstream $UPSTREAM_REF from $UPSTREAM_REPO"
|
||||||
|
|
||||||
|
fetch_tag() {
|
||||||
|
git -C "$WORKTREE" fetch --depth 1 --force origin "refs/tags/$1:refs/tags/$1"
|
||||||
|
}
|
||||||
|
|
||||||
if [ ! -d "$WORKTREE/.git" ]; then
|
if [ ! -d "$WORKTREE/.git" ]; then
|
||||||
log "Cloning into $WORKTREE"
|
log "Cloning into $WORKTREE"
|
||||||
mkdir -p "$(dirname "$WORKTREE")"
|
mkdir -p "$(dirname "$WORKTREE")"
|
||||||
@@ -16,21 +28,27 @@ if [ ! -d "$WORKTREE/.git" ]; then
|
|||||||
else
|
else
|
||||||
log "Fetching $UPSTREAM_REF into the existing checkout"
|
log "Fetching $UPSTREAM_REF into the existing checkout"
|
||||||
git -C "$WORKTREE" remote set-url origin "$UPSTREAM_REPO"
|
git -C "$WORKTREE" remote set-url origin "$UPSTREAM_REPO"
|
||||||
git -C "$WORKTREE" fetch --depth 1 --force origin "refs/tags/$UPSTREAM_REF:refs/tags/$UPSTREAM_REF"
|
fetch_tag "$UPSTREAM_REF"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# `git am` refuses to run with a rebase or merge in progress, and a previous
|
if [ "$BASE_REF" != "$UPSTREAM_REF" ]; then
|
||||||
# run may have stopped on a conflict.
|
log "Fetching $BASE_REF, which the patches were exported against"
|
||||||
git -C "$WORKTREE" am --abort 2>/dev/null || true
|
fetch_tag "$BASE_REF"
|
||||||
|
fi
|
||||||
|
|
||||||
log "Resetting to $UPSTREAM_REF"
|
# Neither `git am` nor `git rebase` runs while the other is in progress, and a
|
||||||
git -C "$WORKTREE" checkout --detach --force "$UPSTREAM_REF"
|
# previous run may have stopped on a conflict in either.
|
||||||
git -C "$WORKTREE" branch -f "$PATCH_BRANCH" "$UPSTREAM_REF"
|
git -C "$WORKTREE" am --abort 2>/dev/null || true
|
||||||
|
git -C "$WORKTREE" rebase --abort 2>/dev/null || true
|
||||||
|
|
||||||
|
log "Resetting to $BASE_REF"
|
||||||
|
git -C "$WORKTREE" checkout --detach --force "$BASE_REF"
|
||||||
|
git -C "$WORKTREE" branch -f "$PATCH_BRANCH" "$BASE_REF"
|
||||||
git -C "$WORKTREE" checkout --force "$PATCH_BRANCH"
|
git -C "$WORKTREE" checkout --force "$PATCH_BRANCH"
|
||||||
git -C "$WORKTREE" reset --hard "$UPSTREAM_REF"
|
git -C "$WORKTREE" reset --hard "$BASE_REF"
|
||||||
git -C "$WORKTREE" clean -fdx -e node_modules -e target
|
git -C "$WORKTREE" clean -fdx -e node_modules -e target
|
||||||
|
|
||||||
# `git am` needs an identity for the commits it creates.
|
# `git am` and `git rebase` need an identity for the commits they create.
|
||||||
git -C "$WORKTREE" config user.name "Modrinth Enhanced"
|
git -C "$WORKTREE" config user.name "Modrinth Enhanced"
|
||||||
git -C "$WORKTREE" config user.email "patches@modrinth-enhanced.invalid"
|
git -C "$WORKTREE" config user.email "patches@modrinth-enhanced.invalid"
|
||||||
git -C "$WORKTREE" config commit.gpgsign false
|
git -C "$WORKTREE" config commit.gpgsign false
|
||||||
@@ -41,11 +59,11 @@ shopt -u nullglob
|
|||||||
|
|
||||||
[ ${#patches[@]} -gt 0 ] || die "No patches found in $PATCH_DIR"
|
[ ${#patches[@]} -gt 0 ] || die "No patches found in $PATCH_DIR"
|
||||||
|
|
||||||
log "Applying ${#patches[@]} patches"
|
log "Applying ${#patches[@]} patches to $BASE_REF"
|
||||||
if ! git -C "$WORKTREE" am --3way --whitespace=nowarn "${patches[@]}"; then
|
if ! git -C "$WORKTREE" am --3way --whitespace=nowarn "${patches[@]}"; then
|
||||||
cat >&2 <<EOF
|
cat >&2 <<EOF
|
||||||
|
|
||||||
A patch did not apply to $UPSTREAM_REF.
|
A patch did not apply to $BASE_REF.
|
||||||
|
|
||||||
The failed patch is left staged in $WORKTREE so it can be fixed by hand:
|
The failed patch is left staged in $WORKTREE so it can be fixed by hand:
|
||||||
|
|
||||||
@@ -60,5 +78,26 @@ EOF
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$BASE_REF" != "$UPSTREAM_REF" ]; then
|
||||||
|
log "Rebasing the patches onto $UPSTREAM_REF"
|
||||||
|
if ! git -C "$WORKTREE" rebase --onto "$UPSTREAM_REF" "$BASE_REF" "$PATCH_BRANCH"; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
|
||||||
|
A patch conflicts with what changed between $BASE_REF and $UPSTREAM_REF.
|
||||||
|
|
||||||
|
The rebase is left stopped in $WORKTREE so it can be resolved by hand:
|
||||||
|
|
||||||
|
cd $WORKTREE
|
||||||
|
git status # see the conflicts
|
||||||
|
# ...resolve them, then:
|
||||||
|
git add -A && git rebase --continue
|
||||||
|
# once every patch is in, with upstream.txt set to $UPSTREAM_REF:
|
||||||
|
$REPO_ROOT/scripts/export-patches.sh
|
||||||
|
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
log "Patched checkout ready at $WORKTREE"
|
log "Patched checkout ready at $WORKTREE"
|
||||||
git -C "$WORKTREE" --no-pager log --oneline "$UPSTREAM_REF..$PATCH_BRANCH"
|
git -C "$WORKTREE" --no-pager log --oneline "$UPSTREAM_REF..$PATCH_BRANCH"
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Render the Modrinth Enhanced icon set from its vector source.
|
|
||||||
|
|
||||||
The source lives in the patched checkout as
|
|
||||||
`apps/app/icons/modrinth-enhanced.svg`; this rewrites every generated icon
|
|
||||||
next to it. Run it after editing that SVG, then commit the result in
|
|
||||||
build/upstream and re-export the patches.
|
|
||||||
|
|
||||||
Needs `rsvg-convert` (librsvg). ImageMagick is not used: the ICNS and ICO
|
|
||||||
writers below are a few lines each and work the same everywhere, whereas
|
|
||||||
ImageMagick's ICNS support depends on how it was built.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import struct
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
ICONS = os.path.join(
|
|
||||||
os.environ.get("WORKTREE", os.path.join(REPO_ROOT, "build", "upstream")),
|
|
||||||
"apps",
|
|
||||||
"app",
|
|
||||||
"icons",
|
|
||||||
)
|
|
||||||
SVG = os.path.join(ICONS, "modrinth-enhanced.svg")
|
|
||||||
|
|
||||||
# Every PNG the Tauri bundles reference, and the size it has to be.
|
|
||||||
PNGS = {
|
|
||||||
"icon.png": 512,
|
|
||||||
"128x128.png": 128,
|
|
||||||
"128x128@2x.png": 256,
|
|
||||||
"StoreLogo.png": 50,
|
|
||||||
"Square30x30Logo.png": 30,
|
|
||||||
"Square44x44Logo.png": 44,
|
|
||||||
"Square71x71Logo.png": 71,
|
|
||||||
"Square89x89Logo.png": 89,
|
|
||||||
"Square107x107Logo.png": 107,
|
|
||||||
"Square142x142Logo.png": 142,
|
|
||||||
"Square150x150Logo.png": 150,
|
|
||||||
"Square284x284Logo.png": 284,
|
|
||||||
"Square310x310Logo.png": 310,
|
|
||||||
}
|
|
||||||
|
|
||||||
ICO_SIZES = [16, 24, 32, 48, 64, 256]
|
|
||||||
FAVICON_SIZES = [16, 24, 32, 64]
|
|
||||||
|
|
||||||
# ICNS chunk type -> pixel size. All chunks carry PNG payloads, which macOS
|
|
||||||
# 10.7 and newer understand.
|
|
||||||
ICNS_TYPES = [
|
|
||||||
(b"icp4", 16),
|
|
||||||
(b"icp5", 32),
|
|
||||||
(b"ic11", 32),
|
|
||||||
(b"ic12", 64),
|
|
||||||
(b"ic07", 128),
|
|
||||||
(b"ic13", 256),
|
|
||||||
(b"ic08", 256),
|
|
||||||
(b"ic14", 512),
|
|
||||||
(b"ic09", 512),
|
|
||||||
(b"ic10", 1024),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def render(size, path):
|
|
||||||
"""Rasterise the source at exactly `size`, rather than downscaling one
|
|
||||||
large render, so the small sizes stay crisp."""
|
|
||||||
subprocess.run(
|
|
||||||
["rsvg-convert", "-w", str(size), "-h", str(size), SVG, "-o", path],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_ico(sizes, cache, path):
|
|
||||||
entries, blobs, offset = [], [], 6 + 16 * len(sizes)
|
|
||||||
for size in sizes:
|
|
||||||
with open(cache[size], "rb") as handle:
|
|
||||||
data = handle.read()
|
|
||||||
entries.append(
|
|
||||||
struct.pack(
|
|
||||||
"<BBBBHHII",
|
|
||||||
# 256 is written as 0 in an ICO directory entry.
|
|
||||||
size if size < 256 else 0,
|
|
||||||
size if size < 256 else 0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
32,
|
|
||||||
len(data),
|
|
||||||
offset,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
blobs.append(data)
|
|
||||||
offset += len(data)
|
|
||||||
with open(path, "wb") as handle:
|
|
||||||
handle.write(struct.pack("<HHH", 0, 1, len(sizes)))
|
|
||||||
for entry in entries:
|
|
||||||
handle.write(entry)
|
|
||||||
for blob in blobs:
|
|
||||||
handle.write(blob)
|
|
||||||
|
|
||||||
|
|
||||||
def build_icns(cache, path):
|
|
||||||
chunks = b""
|
|
||||||
for kind, size in ICNS_TYPES:
|
|
||||||
with open(cache[size], "rb") as handle:
|
|
||||||
data = handle.read()
|
|
||||||
chunks += kind + struct.pack(">I", len(data) + 8) + data
|
|
||||||
with open(path, "wb") as handle:
|
|
||||||
handle.write(b"icns" + struct.pack(">I", len(chunks) + 8) + chunks)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if not shutil.which("rsvg-convert"):
|
|
||||||
sys.exit("rsvg-convert is required (install librsvg)")
|
|
||||||
if not os.path.isfile(SVG):
|
|
||||||
sys.exit(f"No icon source at {SVG}. Run scripts/prepare.sh first.")
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
cache = {}
|
|
||||||
needed = set(PNGS.values()) | set(ICO_SIZES) | set(FAVICON_SIZES)
|
|
||||||
needed |= {size for _, size in ICNS_TYPES}
|
|
||||||
for size in sorted(needed):
|
|
||||||
cache[size] = os.path.join(tmp, f"{size}.png")
|
|
||||||
render(size, cache[size])
|
|
||||||
|
|
||||||
for name, size in PNGS.items():
|
|
||||||
shutil.copyfile(cache[size], os.path.join(ICONS, name))
|
|
||||||
build_ico(ICO_SIZES, cache, os.path.join(ICONS, "icon.ico"))
|
|
||||||
build_ico(FAVICON_SIZES, cache, os.path.join(ICONS, "favicon.ico"))
|
|
||||||
build_icns(cache, os.path.join(ICONS, "icon.icns"))
|
|
||||||
|
|
||||||
for name in sorted(os.listdir(ICONS)):
|
|
||||||
full = os.path.join(ICONS, name)
|
|
||||||
if os.path.isfile(full):
|
|
||||||
print(f"{name:24s} {os.path.getsize(full):>8d} bytes")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDY1Nzc2MEZBMkQyQkRGQjkKUldTNTN5c3QrbUIzWlk1RHdYTFFBMStjM29zQkZ4MW5ibHZGb2p6ckxFK0JUNDBJTkZ6RXc3NUMK
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
v0.20.5
|
v0.21.6
|
||||||
Reference in new issue
Block a user