Compare commits

..
Author SHA1 Message Date
Disy fa96226beb refactor: remove telemetry and fix launcher workflows
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Deploy telemetry dashboard / deploy (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
2026-09-13 20:32:57 +08:00
Disy ccc7f7e16b feat: add Starlight Mine Chromatic Realms easter egg 2026-09-13 20:32:57 +08:00
325 changed files with 36102 additions and 27623 deletions

No files matched your search

+8 -8
View File
@@ -1,3 +1,10 @@
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
[target."cfg(windows)"]
rustflags = ["-C", "link-args=/STACK:16777220"]
[target.x86_64-pc-windows-msvc]
linker = "rust-lld"
# Reproducible public-service defaults for downstream desktop builds. Private
# Modrinth services remain disabled by Axolotl capabilities at runtime.
[env]
@@ -5,12 +12,5 @@ MODRINTH_API_BASE_URL = "https://api.modrinth.com"
MODRINTH_API_URL = "https://api.modrinth.com/v2/"
MODRINTH_API_URL_V3 = "https://api.modrinth.com/v3/"
MODRINTH_LAUNCHER_META_URL = "https://launcher-meta.modrinth.com/"
MODRINTH_SOCKET_URL = "wss://disabled.invalid/"
MODRINTH_URL = "https://modrinth.com/"
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
[target."cfg(windows)"]
rustflags = ["-C", "link-args=/STACK:16777220"]
[target.x86_64-pc-windows-msvc]
linker = "rust-lld"
MODRINTH_SOCKET_URL = "wss://disabled.invalid/"
@@ -1,4 +1,4 @@
name: Starlight release
name: Axolotl release
on:
push:
@@ -53,15 +53,6 @@ jobs:
node scripts/axolotl/i18n-check.mjs
cargo fmt --all --check
- name: Check updater signing configuration
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: |
if [[ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]]; then
echo '::error::Configure TAURI_SIGNING_PRIVATE_KEY with the private key matching apps/app/tauri-release.conf.json before publishing updates.'
exit 1
fi
- name: Prepare draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -81,7 +72,7 @@ jobs:
release_args=(
--draft
--notes-file "$release_notes"
--title "Starlight Launcher ${GITHUB_REF_NAME}"
--title "Axolotl Launcher ${GITHUB_REF_NAME}"
)
if [[ "${GITHUB_REF_NAME}" == *-* ]]; then
release_args+=(--prerelease)
@@ -184,7 +175,7 @@ jobs:
tauriScript: pnpm tauri
args: ${{ matrix.args }}
tagName: ${{ github.ref_name }}
releaseName: Starlight Launcher ${{ github.ref_name }}
releaseName: Axolotl Launcher ${{ github.ref_name }}
releaseDraft: true
prerelease: ${{ contains(github.ref_name, '-') }}
generateReleaseNotes: false
@@ -207,7 +198,7 @@ jobs:
shell: pwsh
run: |
$version = "${{ github.ref_name }}" -replace '^v', ''
$appName = (Get-Content apps/app/tauri.conf.json -Raw | ConvertFrom-Json).mainBinaryName
$appName = "Axolotl Launcher"
$outDir = "target/release/bundle/nsis"
$buildDir = "target/portable-build"
$publishDir = Join-Path $env:RUNNER_TEMP "windows-assets"
@@ -258,15 +249,6 @@ jobs:
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl" | Out-Null
Copy-Item -Force $appExe "$buildDir\Axolotl\"
$editorResources = "target/release/resources/blockbench-skin"
foreach ($requiredFile in @('index.html', 'css/setup.css', 'dist/skin.bundle.js.gz')) {
if (!(Test-Path -LiteralPath (Join-Path $editorResources $requiredFile) -PathType Leaf)) {
throw "Missing skin editor resource: $requiredFile"
}
}
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl\resources" | Out-Null
Copy-Item -LiteralPath $editorResources -Destination "$buildDir\Axolotl\resources" -Recurse -Force
# Create empty .Axolotl folder to trigger portable mode
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl\.Axolotl" | Out-Null
@@ -286,37 +268,18 @@ jobs:
name: Verify and publish
needs: build
runs-on: ubuntu-24.04
env:
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
UPDATE_SERVER_WEBHOOK_SECRET: ${{ secrets.UPDATE_SERVER_WEBHOOK_SECRET }}
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
CNB_REPO_URL_HTTPS: ${{ secrets.CNB_REPO_URL_HTTPS }}
steps:
- name: Check out source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
ref: ${{ github.ref }}
ref: main
- name: Download updater signatures
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release download "${GITHUB_REF_NAME}" --pattern '*.sig' --dir release-check/signatures
# Publish the draft release before generating the updater manifest. While
# the release is a draft GitHub reports asset URLs as
# `.../releases/download/untagged-<hash>/...`; only after publishing does
# the manifest point at the stable `.../releases/download/<tag>/...` path.
- name: Publish release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [[ "${GITHUB_REF_NAME}" == *-* ]]; then
gh release edit "${GITHUB_REF_NAME}" --draft=false --prerelease
else
gh release edit "${GITHUB_REF_NAME}" --draft=false --latest
fi
- name: Download release metadata
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -343,6 +306,31 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${GITHUB_REF_NAME}" release-check/latest.json --clobber
- name: Publish release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [[ "${GITHUB_REF_NAME}" == *-* ]]; then
gh release edit "${GITHUB_REF_NAME}" --draft=false --prerelease
else
gh release edit "${GITHUB_REF_NAME}" --draft=false --latest
fi
- name: Refresh published GitHub release metadata
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
release_id="$(
gh api "repos/${GITHUB_REPOSITORY}/releases" \
--paginate \
--jq ".[] | select(.tag_name == \"${GITHUB_REF_NAME}\") | .id"
)"
if [[ -z "$release_id" ]]; then
echo "Published release ${GITHUB_REF_NAME} was not found"
exit 1
fi
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > release-check/release.json
- name: Build Update Server artifact catalog
run: |
node scripts/axolotl/create-update-server-catalog.mjs \
@@ -351,31 +339,7 @@ jobs:
release-check/update-server-catalog.json
node scripts/axolotl/create-update-server-catalog.test.mjs
- name: Build complete skin-site update package
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
version="${GITHUB_REF_NAME#v}"
bundle_root="release-check/skin-site-update"
bundle_name="Starlight_Launcher_${version}_skin-site-update.zip"
mkdir -p "${bundle_root}/files"
gh release download "${GITHUB_REF_NAME}" --dir "${bundle_root}/files"
find "${bundle_root}/files" -maxdepth 1 -type f \
\( -name 'latest.json' -o -name '*_skin-site-update.zip' \) -delete
cp release-check/latest.json "${bundle_root}/latest.json"
cp release-check/update-server-catalog.json "${bundle_root}/update-server-catalog.json"
cp release-check/release.json "${bundle_root}/release.json"
(
cd "${bundle_root}"
zip -r -9 "../${bundle_name}" latest.json update-server-catalog.json release.json files
)
gh release upload "${GITHUB_REF_NAME}" "release-check/${bundle_name}" --clobber
- name: Notify Update Server to publish release assets
if: ${{ env.UPDATE_SERVER_URL != '' && env.UPDATE_SERVER_WEBHOOK_SECRET != '' }}
env:
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
UPDATE_SERVER_WEBHOOK_SECRET: ${{ secrets.UPDATE_SERVER_WEBHOOK_SECRET }}
@@ -386,13 +350,6 @@ jobs:
release-check/release.json \
"${GITHUB_REF_NAME}"
- name: Check out website branch
if: ${{ !contains(github.ref_name, '-') }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
ref: main
clean: false
- name: Publish website download metadata
if: ${{ !contains(github.ref_name, '-') }}
env:
@@ -415,14 +372,11 @@ jobs:
fi
- name: Mirror published release to CNB
if: ${{ env.CNB_TOKEN != '' && env.CNB_REPO_URL_HTTPS != '' }}
env:
CNB_REPO_URL_HTTPS: ${{ secrets.CNB_REPO_URL_HTTPS }}
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
CNB_TOKEN_USER_NAME: ${{ secrets.CNB_TOKEN_USER_NAME }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_API_BASE_URL: https://api.github.com/repos/${{ github.repository }}
GITHUB_RELEASE_BASE_URL: https://github.com/${{ github.repository }}/releases/download
run: |
set -euo pipefail
if [[ -z "${CNB_TOKEN:-}" ]]; then
+5 -2
View File
@@ -22,5 +22,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- run: npx --yes tombi@1.6.1 lint
- run: npx --yes tombi@1.6.1 fmt --check
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
with:
tool: tombi@1.1.2
- run: tombi lint
- run: tombi fmt --check
+2 -3
View File
@@ -19,7 +19,6 @@ concurrency:
jobs:
sync:
name: Sync Git ref
if: ${{ vars.ENABLE_CNB_SYNC == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
@@ -39,8 +38,8 @@ jobs:
DELETED_REF_TYPE: ${{ github.event.ref_type }}
run: |
set -euo pipefail
if [[ -z "${CNB_TOKEN:-}" || -z "${CNB_REPO_URL_HTTPS:-}" ]]; then
echo "CNB_TOKEN and CNB_REPO_URL_HTTPS must be configured"
if [[ -z "${CNB_TOKEN:-}" ]]; then
echo "CNB_TOKEN is not configured"
exit 1
fi
-2
View File
@@ -38,7 +38,6 @@ node_modules
npm-debug.log
yarn-error.log
testem.log
replay_pid*.log
/typings
# System Files
@@ -80,7 +79,6 @@ access-jwt*.txt
# Codex local verification artifacts
/.codex-*
/.tmp/
# Oh My Code
.omc/
Generated
+2 -2
View File
@@ -6795,7 +6795,7 @@ dependencies = [
[[package]]
name = "theseus"
version = "1.0.0-beta15"
version = "1.9.6-beta.3"
dependencies = [
"ariadne",
"async-minecraft-ping",
@@ -6899,7 +6899,7 @@ dependencies = [
[[package]]
name = "theseus_gui"
version = "1.0.0-beta15"
version = "1.9.6-beta.3"
dependencies = [
"ashpd",
"async_zip",
+24 -6
View File
@@ -20,7 +20,9 @@ ariadne = { path = "packages/ariadne" }
async-minecraft-ping = { path = "packages/async-minecraft-ping" }
async-recursion = "1.1.1"
async-trait = "0.1.89"
async-tungstenite = { version = "0.31.0", default-features = false, features = ["futures-03-sink"] }
async-tungstenite = { version = "0.31.0", default-features = false, features = [
"futures-03-sink"
] }
async-walkdir = "2.1.0"
async_zip = "0.0.18"
base64 = "0.22.1"
@@ -30,7 +32,9 @@ bytes = "1.10.1"
cc = "1.2.56"
chardetng = "0.1.17"
chrono = "0.4.42"
cidre = { version = "0.15.0", default-features = false, features = ["macos_15_0"] }
cidre = { version = "0.15.0", default-features = false, features = [
"macos_15_0"
] }
crc32fast = "1.5.0"
daedalus = { path = "packages/daedalus" }
dashmap = "6.1.0"
@@ -54,7 +58,11 @@ hmac = "0.12.1"
http = "1.3.1"
hyper = "1.7.0"
hyper-util = "0.1.17"
image = { version = "0.25.5", default-features = false, features = ["jpeg", "png", "webp"] }
image = { version = "0.25.5", default-features = false, features = [
"jpeg",
"png",
"webp"
] }
indicatif = "0.18.0"
itertools = "0.14.0"
jsonwebtoken = "9.3.1"
@@ -80,7 +88,11 @@ rand = "=0.8.5" # Locked on 0.8 until p256 updates to 0.9
regex = "1.12.2"
reqwest = { version = "0.12.24", default-features = false }
rgb = "0.8.52"
rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
rustls = { version = "0.23.42", default-features = false, features = [
"ring",
"std",
"tls12"
] }
rustls-native-certs = "0.8.4"
rustls-pki-types = "1.15.0"
serde = "1.0.228"
@@ -115,7 +127,10 @@ tempfile = "3.23.0"
theseus = { path = "packages/app-lib" }
thiserror = "2.0.17"
tokio = "1.47.1"
tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"] }
tokio-rustls = { version = "0.26.4", default-features = false, features = [
"ring",
"tls12"
] }
tokio-util = "0.7.16"
toml = "0.8.19"
tracing = "0.1.41"
@@ -134,7 +149,10 @@ windows-core = "=0.61.2" # Locked on 0.61 until webview2-com updates to 0.62
winreg = "0.55.0"
xz2 = { version = "0.1.7", features = ["static"] }
zbus = "5.11.0"
zhconv = { version = "0.4.1", default-features = false, features = ["compress", "opencc"] }
zhconv = { version = "0.4.1", default-features = false, features = [
"compress",
"opencc"
] }
zip = { version = "6.0.0", default-features = false, features = [
"bzip2",
"deflate",
+6 -6
View File
@@ -5,13 +5,13 @@
<p><strong>次世代 Minecraft 桌面客户端,全能、美观、全平台覆盖。</strong></p>
<p>
<a href="https://github.com/ApTx2012/Starlight_Lancher/actions">
<img src="https://img.shields.io/badge/Repo-GitHub-black?style=for-the-badge&logo=github" alt="Repository" />
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/actions">
<img src="https://img.shields.io/badge/Repo-git.starlight.cool-blue?style=for-the-badge&logo=git" alt="Repository" />
</a>
<a href="https://github.com/ApTx2012/Starlight_Lancher/releases">
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/releases">
<img src="https://img.shields.io/badge/Releases-Download-green?style=for-the-badge&logo=github" alt="Releases" />
</a>
<a href="https://github.com/ApTx2012/Starlight_Lancher/stargazers">
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/stargazers">
<img src="https://img.shields.io/badge/Stars-★-ffb800?style=for-the-badge&logo=github" alt="Stars" />
</a>
<a href="COPYING.md">
@@ -21,7 +21,7 @@
<p>
<a href="https://skin.starlight.cool/">官方网站</a> |
<a href="https://github.com/ApTx2012/Starlight_Lancher/releases/latest">下载最新版</a> |
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/releases/latest">下载最新版</a> |
<a href="CONTRIBUTING.md">参与贡献</a>
| <a href="CODE_OF_CONDUCT.md">行为准则</a>
</p>
@@ -57,7 +57,7 @@ _(注:本项目是调用 Modrinth 公开 API 的独立客户端,与 Rinth, I
## 下载与安装
请前往 [Releases](https://github.com/ApTx2012/Starlight_Lancher/releases/latest) 下载适合你操作系统的最新安装包。
请前往 [Releases](https://git.starlight.cool/AxTps/Starlight_Lancher/releases/latest) 下载适合你操作系统的最新安装包。
已安装的用户每次均可通过内置的 Tauri 签名校验机制,自动在后台完成更新,无需手动下载安装更新。
| 系统平台 | 推荐下载文件 |
-4
View File
@@ -22,10 +22,6 @@ Item identifiers, readable names, and textures are sourced from the `minecraft-t
The recipe data and item artwork ultimately derive from Minecraft data generator output and Minecraft game resources. Minecraft and its original resources are Copyright Mojang Studios / Microsoft and are used only to identify compatible game content. Axolotl Launcher is not affiliated with or endorsed by Mojang Studios or Microsoft.
## About-page scene attribution
The about-page victory illustration is the user-approved generated artwork. Its bundled scene layers preserve the approved preview without regenerating the illustration at runtime. The supplied netherite sword artwork and the unmodified `clouds.png` and `moon_phases.png` textures from Minecraft: Java Edition 1.20.6 are Minecraft resources, Copyright Mojang Studios / Microsoft. These assets are stored in `src/assets/about-scene` and do not require a network connection.
## AI integration attribution
The AI provider settings information architecture, provider catalog, and provider descriptions are adapted from [LobeChat](https://github.com/lobehub/lobe-chat) at commit `a27dfaeda1ab499ac024a6eb0448917b216ba8a1`. The localized provider descriptions under `src/data/lobehub-provider-descriptions` are reproduced from that release. Bundled text-model metadata is synchronized separately from [LobeHub's model bank](https://github.com/lobehub/lobehub/tree/main/packages/model-bank/src/aiModels), with its exact source revision recorded in the backend catalog. LobeChat is distributed under the LobeHub Community License; a verbatim copy of that license is provided in [third-party/licenses/LobeHub-Community-License.txt](../../third-party/licenses/LobeHub-Community-License.txt).
+1 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@modrinth/app-frontend",
"private": true,
"version": "1.0.0-beta15",
"version": "1.0.0-beta1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,9 +14,6 @@
"test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\""
},
"dependencies": {
"@dnd-kit/core": "6.3.1",
"@dnd-kit/utilities": "3.2.2",
"@dnd-kit/vue": "^0.5.0",
"@lobehub/icons-static-svg": "1.94.0",
"@modrinth/api-client": "workspace:^",
"@modrinth/assets": "workspace:*",
+23 -110
View File
@@ -3,12 +3,12 @@ import { AuthFeature, TauriModrinthClient, VerboseLoggingFeature } from '@modrin
import {
ChangeSkinIcon,
CompassIcon,
GlobeIcon,
DownloadIcon,
ExternalIcon,
FlaskConicalIcon,
FolderOpenIcon,
HomeIcon,
ImagesIcon,
LeftArrowIcon,
LibraryIcon,
LogInIcon,
@@ -20,7 +20,6 @@ import {
SettingsIcon,
SpinnerIcon,
UserIcon,
UsersIcon,
WorldIcon,
} from '@modrinth/assets'
import {
@@ -58,18 +57,16 @@ import SymlinkMethodCards from '@modrinth/ui/src/components/flows/drop/SymlinkMe
import { useQuery } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app'
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { hideAllPoppers } from 'floating-vue'
import { listen } from '@tauri-apps/api/event'
import { Effect, getCurrentWindow } from '@tauri-apps/api/window'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type as getOsType } from '@tauri-apps/plugin-os'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { hideAllPoppers } from 'floating-vue'
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { type RouteLocationNormalizedLoaded, RouterView, useRoute, useRouter } from 'vue-router'
import InstancePlayerModal from '@/components/instance/InstancePlayerModal.vue'
import TaggedModDownloadsModal from '@/components/instance/TaggedModDownloadsModal.vue'
import InstanceExportModal from '@/components/lab/recipe-generator/InstanceExportModal.vue'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import AppActionBar from '@/components/ui/AppActionBar.vue'
@@ -93,8 +90,6 @@ import NavButton from '@/components/ui/NavButton.vue'
import NavRail from '@/components/ui/NavRail.vue'
import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
import StartupAnnouncementModal from '@/components/ui/StartupAnnouncementModal.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
@@ -116,7 +111,6 @@ import {
} from '@/helpers/events.js'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
import { type DirectLinkSyncReport, get as getInstance, run } from '@/helpers/instance'
import { PlayerSelectionNavigatedAwayError } from '@/helpers/instance-player'
import { reconcileMojangAuthSourceAtStartup } from '@/helpers/mojang-auth'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
@@ -141,9 +135,9 @@ import {
isDev,
isElevated,
isNetworkMetered,
restartApp,
setRestartAfterPendingUpdate,
} from '@/helpers/utils.js'
import { installUiScale, setUiScale } from '@/helpers/ui-scale'
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
import i18n, { resolveInitialLocale } from '@/i18n.config'
import {
@@ -315,22 +309,6 @@ const showOnboarding = ref(false)
const onboardingMode = ref('main')
const onboardingSettings = ref(null)
const onboardingReplay = ref(false)
// Expand the sidebar while the onboarding tour is visible so steps that point
// at sidebar entries (account card, announcement, create instance) can find
// their targets. The previous state is restored when the tour ends.
let sidebarStateBeforeOnboarding: boolean | undefined
watch(showOnboarding, (visible) => {
if (visible) {
sidebarStateBeforeOnboarding = sidebarToggled.value
sidebarToggled.value = true
setSidebarExpanded(true)
} else if (sidebarStateBeforeOnboarding !== undefined) {
sidebarToggled.value = sidebarStateBeforeOnboarding
setSidebarExpanded(sidebarStateBeforeOnboarding)
sidebarStateBeforeOnboarding = undefined
}
})
const nativeDecorations = ref(false)
const os = ref('')
@@ -509,16 +487,9 @@ onMounted(async () => {
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, handleDirectLinkSyncReport)
try {
await stateInitialization
} catch {
return
}
checkUpdates()
void warnIfRunningElevated()
startDirectLinkSync()
maybeShowStartupAnnouncement()
})
let directLinkSync: (() => Promise<void>) | undefined
@@ -759,6 +730,10 @@ const messages = defineMessages({
id: 'app.navigation.skin-selector',
defaultMessage: 'Skin selector',
},
starlightSkin: {
id: 'app.navigation.starlight-skin',
defaultMessage: '斯达莱特',
},
library: {
id: 'app.navigation.library',
defaultMessage: 'Library',
@@ -767,10 +742,6 @@ const messages = defineMessages({
id: 'app.navigation.multiplayer',
defaultMessage: 'Multiplayer',
},
screenshots: {
id: 'app.navigation.screenshots',
defaultMessage: 'Screenshots',
},
downloads: {
id: 'app.navigation.downloads',
defaultMessage: 'Downloads',
@@ -787,9 +758,9 @@ const messages = defineMessages({
id: 'app.account.signed-in-as',
defaultMessage: 'Signed in as',
},
userInformation: {
id: 'app.minecraft.user-information',
defaultMessage: 'User information',
playingAs: {
id: 'app.minecraft.playing-as',
defaultMessage: 'Playing as',
},
collapseSidebar: {
id: 'app.sidebar.collapse',
@@ -1096,7 +1067,6 @@ async function setupApp() {
close_behavior,
developer_mode,
feature_flags,
ui_scale,
} = initialSettings
// Initialize locale from saved settings
@@ -1127,7 +1097,6 @@ async function setupApp() {
nativeDecorations.value = native_decorations
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
setUiScale(ui_scale)
themeStore.setThemeState(theme)
await initializeSystemAccentColor()
themeStore.setAccentColor(accent_color)
@@ -1260,7 +1229,7 @@ async function replayOnboarding(mode) {
async function finishOnboarding() {
const wasReplay = onboardingReplay.value
const settings = await getSettings()
const settings = onboardingSettings.value ?? (await getSettings())
if (!onboardingReplay.value) {
if (onboardingMode.value === 'instance') {
settings.onboarding_instance_tour_completed = true
@@ -1296,9 +1265,6 @@ provide(
)
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
provide('showLauncherPopup', (_request: unknown) => {})
provide('showStartupAnnouncement', () => startupAnnouncementModal.value?.show())
installUiScale()
const stateFailed = ref(false)
stateInitialization
@@ -1568,33 +1534,6 @@ const updateToPlayModal = ref()
const modrinthLoginFlowWaitModal = ref()
const startupAnnouncementModal = ref<InstanceType<typeof StartupAnnouncementModal> | null>(null)
const STARTUP_ANNOUNCEMENT_KEY = 'starlight-startup-announcement-seen-v1'
function maybeShowStartupAnnouncement() {
let alreadySeen = false
try {
alreadySeen = localStorage.getItem(STARTUP_ANNOUNCEMENT_KEY) !== null
} catch (error) {
console.warn('Failed to read startup announcement flag', error)
}
if (alreadySeen) return
const modal = startupAnnouncementModal.value
if (!modal) {
// The modal component has not mounted yet; try again on the next tick.
void nextTick(maybeShowStartupAnnouncement)
return
}
modal.show()
try {
localStorage.setItem(STARTUP_ANNOUNCEMENT_KEY, '1')
} catch (error) {
console.warn('Failed to write startup announcement flag', error)
}
}
// ── Drop import system ──────────────────────────────────────────────────
const dropImport = useDropImport({
notificationManager,
@@ -1763,7 +1702,6 @@ onMounted(() => {
setServerUpdateToPlayModal(updateToPlayModal.value)
void (async () => {
try {
await stateInitialization
const ready = await invoke<{
pending_crashes: { instance_id: string; uuid: string }[]
pending_commands: Parameters<typeof handleCommand>[0][]
@@ -1821,10 +1759,6 @@ async function handleCommand(e) {
} else if (e.event === 'LaunchInstance') {
const instance = await getInstance(e.id).catch(() => null)
const handleLaunchCommandError = async (launchError) => {
// Navigating to the skin-site login to pick a player is a deliberate
// user action, not a launch failure: stay silent and let the user
// re-trigger the launch after signing in.
if (launchError instanceof PlayerSelectionNavigatedAwayError) return
const handled =
(await minecraftCrashModal.value?.handleLaunchError(launchError, {
instance_id: e.id,
@@ -2222,9 +2156,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<template>
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<SkinSiteSessionFrame v-if="stateInitialized" />
<InstancePlayerModal v-if="stateInitialized" />
<StartupAnnouncementModal ref="startupAnnouncementModal" />
<div id="teleports"></div>
<div
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"
@@ -2306,6 +2237,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<ChangeSkinIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.starlightSkin)"
data-onboarding-id="nav-starlight-skin"
to="/starlight-skin"
>
<GlobeIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.library)"
data-onboarding-id="nav-library"
@@ -2320,23 +2258,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<LibraryIcon />
</NavButton>
<NavButton
v-if="themeStore.featureFlags.multiplayer_in_app"
v-tooltip.right="formatMessage(messages.multiplayer)"
data-onboarding-id="nav-multiplayer"
to="/multiplayer"
:is-primary="(r) => r.path.startsWith('/multiplayer')"
>
<UsersIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.screenshots)"
data-onboarding-id="nav-screenshots"
to="/screenshots"
:is-primary="(r) => r.path.startsWith('/screenshots')"
>
<ImagesIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.lab)"
data-onboarding-id="nav-lab"
@@ -2504,13 +2425,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
{{ formatMessage(messages.authUnreachableBody) }}
</Admonition>
<div class="page-transition-grid grid min-h-full">
<RouterView v-slot="{ Component, route: currentRoute }">
<RouterView v-slot="{ Component, route }">
<Transition name="page-slide" :css="themeStore.getFeatureFlag('page_transitions')">
<div
v-if="Component"
:key="getPageTransitionKey(currentRoute)"
class="page-transition-layer"
>
<div v-if="Component" :key="getPageTransitionKey(route)" class="page-transition-layer">
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
<component :is="Component"></component>
</Suspense>
@@ -2550,12 +2467,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<div id="sidebar-teleport-target" class="sidebar-teleport-content contents"></div>
<div class="sidebar-default-content hidden" :class="{ 'sidebar-enabled': sidebarVisible }">
<div
data-onboarding-id="sidebar-accounts"
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
>
<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.userInformation) }}
{{ formatMessage(messages.playingAs) }}
</h3>
<suspense>
<AccountsCard ref="accounts" />
@@ -2577,7 +2491,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
:on-error-action="exportNotificationErrorLogs"
:error-action-label="formatMessage(messages.exportErrorLogs)"
/>
<MinecraftCrashModal v-if="stateInitialized" ref="minecraftCrashModal" @error="handleError" />
<MinecraftCrashModal ref="minecraftCrashModal" @error="handleError" />
<JavaDownloadConfirmationModal ref="javaDownloadConfirmationModal" />
<NewModal
ref="closeChoiceModal"
@@ -2665,7 +2579,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@install="handleContentInstallModpackInstall"
@cancel="handleContentInstallModpackInstallCancel"
/>
<TaggedModDownloadsModal v-if="stateInitialized" />
<CurseForgeManualDownloadsModal
ref="contentInstallCurseForgeManualDownloadsModal"
@view-instance="handleContentInstallModpackDuplicateGoToInstance"
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 835 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

@@ -1,31 +0,0 @@
# 凋灵局部清理素材
## 横梁删除修正(2026-09-15)
- 素材:[wither-beams-removed.png](./wither-beams-removed.png),2172 × 724。
- 方式:内置 imagegen,局部对象删除;输入为上一版清理图与用户标注的两处横梁。
- 仅使用左右横梁及其侧面的轮廓区域,补回露出的背景;其他位置仍使用之前的清晰素材。对应的横梁护盾面同时删除。
- 不重编码既有母图,不使用整张新生成图替换画面,不增加模糊或羽化。
最终提示词:
Use case: precise-object-edit.
Image 1 is the edit target: the full 3:1 Minecraft night scene. Image 2 is a location guide only, with red rectangles identifying two unwanted horizontal shoulder beams. Do NOT copy the UI, red marks, sword, star, clouds or alternate appearance from image 2.
Remove the TWO long thin horizontal brown/black bars running out sideways from the fallen Wither's upper chest to its left and right side skulls. In image 1 at normalized 2048x683 coordinates these are approximately left x978–1140 y467–527 and right x1280–1405 y487–535. ERASE these bars completely, including their top face and side face; show the existing dark blue-gray ground/background visible through the new open gaps. The left and right skulls must stay in their exact positions on the ground, separated visually from the chest in these gaps. Do not invent substitute bars, short stumps, connecting rods, upright shoulder bones, debris, ribs or decorations in these two gaps.
Preserve all other pixels/geometry/composition: the three blocky skulls, central neck, broken rib cage, spine, player's exact pose and clothes, moon, landscape, frame edges, lighting and colors. In particular do not remove or redraw the existing curved/segmented chest ribs below the two bars. Precisely restore the small exposed background behind removed bars, with crisp square voxel texture. No blur, smoothing, feathering, depth of field, re-rendering of the whole image, glow or haze. Keep the original full 3:1 frame and exact object registration, at 2172x724 if possible. This is a tiny local object removal, not a restyle.
## 上一版肩部凸出结构清理
- 素材:[wither-shoulder-cleanup.png](./wither-shoulder-cleanup.png),2172 × 724。
- 生成方式:内置 imagegen 图像编辑工具。
- 编辑目标:`victory-clean.jpg`;辅助参考为用户标红的两处肩部异常结构及 Minecraft 凋灵截图。
- 实际使用:仅将两处肩部异常结构的清理结果合成到清晰母图。整张生成图不作为背景使用,三个头、肋骨、角色和环境保留原有素材。
- 动画在独立图层中绘制;护盾几何记录在 `wither-shield.ts`,坐标以 2048 × 682⅔ 的场景空间表示。
## 生成时使用的提示词
Use case: precise-object-edit. Asset type: a sharp replacement Wither plate for an existing Minecraft cinematic animated banner. Image 1 is the EDIT TARGET: retain its exact 3:1 panoramic framing and camera, keep every other object and the character unchanged. Image 2 is ONLY an annotation reference: its red rectangles identify two erroneous upright slabs/extra structures on the Wither's shoulders which must be removed; do not draw any red marks. Image 3 is ONLY a vanilla Minecraft Wither anatomy reference; use its simple three-headed skeletal structure, not its standing pose or blue shield.
Regenerate ONLY the fallen Wither in Image 1 cleanly, with crisp straight voxel edges, dark charcoal/black nether bone material and clean low-resolution Minecraft pixel textures. Exactly three cuboid skulls: near-left skull under the player's raised boot, central skull farther back facing upward, right skull at the right, preserving their existing centers, scale, screen orientation and silhouette as closely as possible. Retain the existing supine defeated pose, clean horizontal shoulder crossbar linking the three heads, a compact rib cage with separated squared ribs, and the central spine receding into the foreground. Remove the two upright paddle-like shoulder projections indicated by the red boxes, all extra heads, all spikes/horns, all gold mechanisms, odd rubble embedded in the body, noisy invented decorations and duplicate bones. The chest has one coherent small broken sternum opening centered exactly where the animated sword will enter (about 56% across and 72% down the full panorama). Preserve visible negative space between ribs, without random detached bits. This is a defeated non-human Minecraft boss, no blood or gore.
Keep the player's existing foot resting on the near-left skull at precisely the same height and location. Keep the character, face, volumetric brown hair, yellow clothing, armor, hands and boots absolutely unchanged. Keep the sky, moon, trees, terrain and all other regions unchanged and sharp. Do NOT add a sword or nether star: these already exist as separate animated layers. Do NOT paint any blue shielding, electricity, glow halos or particles onto the Wither; those will be separate precisely aligned animated layers. Match existing cool night illumination and subtle warm gold light on the top-facing bone planes. Reconstruct only the small pieces of ground/sky revealed by removing the two wrong shoulder slabs. No blur, no painterly smoothing, no depth-of-field blur, no sharpening halos. Output a single full panoramic 3:1 edited plate, same framing as Image 1, at the highest available resolution.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

@@ -1,41 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<title>幸运四叶草</title>
<path
d="M33 34C39.5 38.5 44.5 46 47 56"
stroke="var(--clover-edge, #16883F)"
stroke-width="5.5"
stroke-linecap="round"
/>
<path
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
fill="var(--clover-leaf, #42C95A)"
stroke="var(--clover-edge, #16883F)"
stroke-width="2.2"
stroke-linejoin="round"
/>
<path
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
transform="rotate(90 32 32)"
fill="var(--clover-leaf, #42C95A)"
stroke="var(--clover-edge, #16883F)"
stroke-width="2.2"
stroke-linejoin="round"
/>
<path
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
transform="rotate(180 32 32)"
fill="var(--clover-leaf, #42C95A)"
stroke="var(--clover-edge, #16883F)"
stroke-width="2.2"
stroke-linejoin="round"
/>
<path
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
transform="rotate(270 32 32)"
fill="var(--clover-leaf, #42C95A)"
stroke="var(--clover-edge, #16883F)"
stroke-width="2.2"
stroke-linejoin="round"
/>
<circle cx="32" cy="32" r="3.7" fill="var(--clover-edge, #16883F)" />
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 740 KiB

@@ -42,7 +42,6 @@ const { offline } = useNetworkStatus()
const handleMinecraftLaunchError = useMinecraftLaunchError()
const messages = defineMessages({
stopAll: { id: 'app.instance.stop-all', defaultMessage: 'Stop all windows of this instance' },
addContent: { id: 'app.instances.add-content', defaultMessage: 'Add content' },
viewInstance: { id: 'app.instances.view-instance', defaultMessage: 'View instance' },
duplicateInstance: {
@@ -266,12 +265,7 @@ onUnmounted(() => {
@delete="deleteInstance"
/>
<div ref="rowContainer" class="flex flex-col gap-4">
<div
v-for="row in actualInstances"
ref="rows"
:key="row.label"
class="row flex flex-col items-start overflow-hidden w-full min-w-full"
>
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row flex flex-col items-start overflow-hidden w-full min-w-full">
<HeadingLink class="mt-1" :to="row.route">
{{ row.label }}
</HeadingLink>
@@ -313,7 +307,7 @@ onUnmounted(() => {
</div>
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> {{ formatMessage(commonMessages.playButton) }} </template>
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stopAll) }} </template>
<template #stop> <StopCircleIcon /> {{ formatMessage(commonMessages.stopButton) }} </template>
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template>
<template #delete> <TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }} </template>
@@ -1,33 +0,0 @@
<script setup lang="ts">
import { NewspaperIcon, RightArrowIcon } from '@modrinth/assets'
import { inject } from 'vue'
const showAnnouncement = inject<(() => void) | undefined>('showStartupAnnouncement')
function open() {
showAnnouncement?.()
}
</script>
<template>
<section
data-onboarding-id="home-announcement"
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
>
<button
type="button"
class="group flex w-full cursor-pointer flex-col gap-2 border-0 bg-transparent p-0 text-left"
@click="open"
>
<div class="flex items-center gap-2">
<NewspaperIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
<h2 class="m-0 truncate text-lg">公告</h2>
<RightArrowIcon
class="ml-auto size-4 shrink-0 text-secondary transition-transform group-hover:translate-x-0.5"
aria-hidden="true"
/>
</div>
<p class="m-0 text-sm leading-relaxed text-secondary">点击查看最新公告</p>
</button>
</section>
</template>
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { DownloadIcon } from '@modrinth/assets'
import { defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onUnmounted, ref } from 'vue'
import { loading_listener } from '@/helpers/events'
import type { LoadingBar } from '@/helpers/state'
import { progress_bars_list } from '@/helpers/state'
const { formatMessage } = useVIntl()
@@ -15,7 +15,6 @@ const messages = defineMessages({
})
interface LoadingEventPayload {
total?: number | null
event: LoadingBar['bar_type']
loader_uuid: string
fraction: number | null
@@ -23,7 +22,6 @@ interface LoadingEventPayload {
}
interface LaunchProgressItem {
waiting: boolean
key: string
message: string
fraction: number | null
@@ -31,7 +29,6 @@ interface LaunchProgressItem {
// 启动相关(以及准备)阶段会发的 loading 类型;安装/下载也一并显示,便于用户看到进度
const LAUNCH_BAR_TYPES = new Set([
'hosted_pack_sync',
'minecraft_download',
'instance_update',
'zip_extract',
@@ -57,7 +54,6 @@ function applyEvent(payload: LoadingEventPayload) {
if (!isVisible(payload.event)) return
activeMap.set(payload.loader_uuid, {
waiting: payload.total === 0,
key: payload.loader_uuid,
message: payload.message,
fraction: payload.fraction,
@@ -68,28 +64,13 @@ function applyEvent(payload: LoadingEventPayload) {
const hasProgress = computed(() => progressItems.value.length > 0)
function percent(item: LaunchProgressItem): string {
if (item.waiting || item.fraction == null || !Number.isFinite(item.fraction)) return ''
if (item.fraction == null || !Number.isFinite(item.fraction)) return ''
return `${Math.round(Math.max(0, Math.min(1, item.fraction)) * 100)}%`
}
let initializing = true
const buffered: LoadingEventPayload[] = []
const unlistenLoading = await loading_listener((payload: LoadingEventPayload) => {
if (initializing) buffered.push(payload)
else applyEvent(payload)
applyEvent(payload)
})
const bars = await progress_bars_list().catch(() => ({}))
for (const bar of Object.values(bars)) {
applyEvent({
event: bar.bar_type,
loader_uuid: String(bar.loading_bar_uuid),
fraction: bar.total ? (bar.current ?? 0) / bar.total : 0,
total: bar.total,
message: bar.message ?? '',
})
}
initializing = false
for (const payload of buffered) applyEvent(payload)
onUnmounted(() => {
unlistenLoading?.()
@@ -115,7 +96,12 @@ onUnmounted(() => {
{{ percent(item) }}
</span>
</div>
<ProgressBar :progress="item.fraction ?? 0" :waiting="item.waiting" full-width />
<div class="h-1 w-full overflow-hidden rounded-full bg-surface-4">
<div
class="h-full rounded-full bg-brand transition-[width] duration-200"
:style="{ width: percent(item) || '100%' }"
/>
</div>
</li>
</ul>
</section>
@@ -1,70 +1,35 @@
<script setup lang="ts">
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import LuckyCloverIcon from '@/assets/icons/lucky-clover.svg'
import {
openSkinSiteLogin,
requestSkinSiteLuck,
skinSiteUser,
} from '@/composables/skin-site-session'
import { ExternalIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
widgetTitle: { id: 'app.home.luck.title', defaultMessage: 'Daily luck index' },
getLuck: { id: 'app.home.luck.get', defaultMessage: 'Get with one click' },
loginToGet: { id: 'app.home.luck.login-to-get', defaultMessage: 'Sign in to get it' },
loading: { id: 'app.home.luck.loading', defaultMessage: 'Asking the little sprite...' },
error: {
id: 'app.home.luck.error',
defaultMessage: 'The little sprite did not answer. Please try again later.',
},
outOf: { id: 'app.home.luck.out-of', defaultMessage: 'out of 100' },
widgetTitle: { id: 'app.home.skin-site.title', defaultMessage: 'StarLight skin site' },
luckyIndex: { id: 'app.home.skin-site.lucky-index', defaultMessage: 'Lucky index' },
jump: { id: 'app.home.skin-site.jump', defaultMessage: 'Jump to StarLight skin site' },
})
const luck = ref<number | null>(null)
const loading = ref(false)
const failed = ref(false)
const waitingForLogin = ref(false)
const signedIn = computed(() => Boolean(skinSiteUser.value))
const SKIN_PROFILE_URL = 'https://skin.starlight.cool/profile'
const SKIN_HOME_URL = 'https://skin.starlight.cool/'
async function fetchLuck() {
if (loading.value) return
loading.value = true
failed.value = false
async function openProfile() {
try {
luck.value = await requestSkinSiteLuck()
} catch {
failed.value = true
} finally {
loading.value = false
await openUrl(SKIN_PROFILE_URL)
} catch (error) {
handleError(error)
}
}
function handlePrimaryAction() {
if (signedIn.value) {
void fetchLuck()
return
async function openSkinSite() {
try {
await openUrl(SKIN_HOME_URL)
} catch (error) {
handleError(error)
}
waitingForLogin.value = true
openSkinSiteLogin()
}
watch(
() => skinSiteUser.value?.uuid,
(uuid, previousUuid) => {
if (uuid === previousUuid) return
luck.value = null
failed.value = false
if (uuid && waitingForLogin.value) {
waitingForLogin.value = false
void fetchLuck()
} else if (!uuid) {
waitingForLogin.value = false
}
},
)
</script>
<template>
@@ -72,72 +37,32 @@ watch(
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
>
<div class="flex items-center gap-2">
<LuckyCloverIcon class="lucky-clover size-5 shrink-0" aria-hidden="true" />
<ExternalIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
<h2 class="m-0 truncate text-lg">
{{ formatMessage(messages.widgetTitle) }}
</h2>
</div>
<div class="flex flex-col gap-3">
<div v-if="loading" class="luck-feedback" role="status" aria-live="polite">
<LuckyCloverIcon
class="lucky-clover lucky-clover-loading size-7 shrink-0"
aria-hidden="true"
/>
<span>{{ formatMessage(messages.loading) }}</span>
</div>
<div v-else-if="luck !== null" class="luck-feedback" aria-live="polite">
<strong class="text-3xl leading-none text-contrast">{{ luck }}</strong>
<span class="text-xs text-secondary">{{ formatMessage(messages.outOf) }}</span>
</div>
<p v-else-if="failed" class="m-0 text-sm leading-5 text-secondary" role="alert">
{{ formatMessage(messages.error) }}
</p>
<div class="flex flex-col gap-2">
<button
type="button"
class="flex w-full cursor-pointer items-center justify-between rounded-lg border border-[--brand-gradient-border] bg-transparent px-3 py-2 text-left transition-colors hover:bg-button-bg"
@click="openProfile"
>
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.luckyIndex) }}
</span>
<ExternalIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
</button>
<ButtonStyled color="brand" class="w-full">
<button type="button" class="w-full" :disabled="loading" @click="handlePrimaryAction">
{{ formatMessage(signedIn ? messages.getLuck : messages.loginToGet) }}
</button>
</ButtonStyled>
<button
type="button"
class="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-[--brand-gradient] px-3 py-2 font-semibold text-[var(--color-contrast)] transition-opacity hover:opacity-90"
@click="openSkinSite"
>
<ExternalIcon class="size-4 shrink-0" aria-hidden="true" />
<span>{{ formatMessage(messages.jump) }}</span>
</button>
</div>
</section>
</template>
<style scoped>
.lucky-clover {
--clover-leaf: color-mix(in srgb, #42c95a 88%, var(--color-brand));
--clover-edge: color-mix(in srgb, #16883f 88%, var(--color-brand));
}
.luck-feedback {
display: flex;
min-height: 2.75rem;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-weight: 600;
color: var(--color-secondary);
text-align: center;
}
.lucky-clover-loading {
transform-origin: center;
animation: clover-wait 850ms ease-in-out infinite;
}
@keyframes clover-wait {
0%,
100% {
transform: translateY(1px) rotate(-5deg) scale(0.96);
}
50% {
transform: translateY(-2px) rotate(6deg) scale(1.04);
}
}
@media (prefers-reduced-motion: reduce) {
.lucky-clover-loading {
animation: none;
}
}
</style>
@@ -28,7 +28,6 @@ import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance'
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { handleSevereError } from '@/store/error'
@@ -51,8 +50,6 @@ const handleMinecraftLaunchError = useMinecraftLaunchError()
const { offline } = useNetworkStatus()
const messages = defineMessages({
launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
stopAll: { id: 'app.instance.stop-all', defaultMessage: 'Stop all windows of this instance' },
chooseInstance: {
id: 'app.home.minimal.choose-instance',
defaultMessage: 'Choose instance',
@@ -91,17 +88,14 @@ const selectedInstance = computed(() =>
props.instances.find((instance) => instance.id === props.selectedInstanceId),
)
const running = ref(false)
const installingId = ref<string>()
const loading = ref(false)
const currentEvent = ref<string | null>(null)
const installed = computed(() => selectedInstance.value?.install_stage === 'installed')
const installing = computed(
() => selectedInstance.value?.install_stage.includes('installing') ?? false,
)
const busy = computed(
() =>
!!selectedInstance.value &&
(isInstanceLaunching(selectedInstance.value.id) ||
installingId.value === selectedInstance.value.id ||
installing.value),
() => loading.value || installing.value || (currentEvent.value === 'launched' && !running.value),
)
const lastPlayed = computed(() => {
@@ -117,17 +111,18 @@ async function refreshProcessState() {
return
}
const id = selectedInstance.value.id
const processes = await get_by_instance_id(id).catch((error) => {
const processes = await get_by_instance_id(selectedInstance.value.id).catch((error) => {
handleError(error)
return []
})
if (selectedInstance.value?.id === id) running.value = processes.length > 0
running.value = processes.length > 0
}
async function playInstance() {
const instance = selectedInstance.value
if (!instance || busy.value) return
if (!instance) return
loading.value = true
try {
await run(instance.id)
} catch (error) {
@@ -137,6 +132,7 @@ async function playInstance() {
})
if (!handled) handleSevereError(error, { instanceId: instance.id })
} finally {
loading.value = false
await refreshProcessState()
}
}
@@ -146,14 +142,14 @@ async function stopInstance() {
if (!instance) return
await kill(instance.id).catch(handleError)
await refreshProcessState()
running.value = false
}
async function installInstance() {
const instance = selectedInstance.value
if (!instance) return
installingId.value = instance.id
loading.value = true
try {
if (
instance.install_stage !== 'pack_installed' &&
@@ -172,14 +168,14 @@ async function installInstance() {
} catch (error) {
handleError(error)
} finally {
if (installingId.value === instance.id) installingId.value = undefined
loading.value = false
}
}
watch(
() => props.selectedInstanceId,
() => {
running.value = false
currentEvent.value = null
void refreshProcessState()
},
)
@@ -188,7 +184,9 @@ await refreshProcessState()
const unlistenProcess = await process_listener((event: { instance_id: string; event: string }) => {
if (event.instance_id !== selectedInstance.value?.id) return
void refreshProcessState()
currentEvent.value = event.event
if (event.event === 'finished') running.value = false
else void refreshProcessState()
})
onUnmounted(() => {
@@ -201,7 +199,7 @@ onUnmounted(() => {
data-onboarding-id="home-instances"
class="minimal-home-stage flex min-w-0 items-center justify-center px-6 pb-14 pt-8"
>
<div data-onboarding-id="home-widget-grid" class="flex w-full max-w-3xl flex-col items-center text-center">
<div class="flex w-full max-w-3xl flex-col items-center text-center">
<HomeGreeting :player-name="playerName" variant="minimal" />
<template v-if="selectedInstance">
@@ -239,17 +237,13 @@ onUnmounted(() => {
</router-link>
<div class="flex min-h-11 shrink-0 items-center justify-end gap-2">
<ButtonStyled v-if="running" color="red" size="large" circular>
<button
v-tooltip="formatMessage(messages.stopAll)"
:aria-label="formatMessage(messages.stopAll)"
:disabled="busy"
@click="stopInstance"
>
<ButtonStyled v-if="running" color="red" size="large">
<button class="w-36 justify-center" @click="stopInstance">
<StopCircleIcon aria-hidden="true" />
<span class="truncate">{{ formatMessage(commonMessages.stopButton) }}</span>
</button>
</ButtonStyled>
<ButtonStyled v-if="busy" size="large">
<ButtonStyled v-else-if="busy" size="large">
<button class="w-36 justify-center" disabled>
<SpinnerIcon class="animate-spin" aria-hidden="true" />
<span class="truncate">
@@ -262,9 +256,7 @@ onUnmounted(() => {
<ButtonStyled v-else-if="installed" color="brand" size="large">
<button class="w-36 justify-center" @click="playInstance">
<PlayIcon class="translate-x-px" aria-hidden="true" />
<span class="truncate">{{
formatMessage(running ? messages.launchAnother : commonMessages.playButton)
}}</span>
<span class="truncate">{{ formatMessage(commonMessages.playButton) }}</span>
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" size="large">
@@ -36,7 +36,6 @@ const props = withDefaults(
limit?: HomeRecentLimit
}>(),
{
dashboardSize: null,
limit: HOME_RECENT_DEFAULT_LIMIT,
},
)
@@ -147,17 +146,11 @@ async function stopInstance(instance: GameInstance) {
</script>
<template>
<section
class="home-recent-worlds flex min-w-0 min-h-0 h-full flex-col gap-3"
:data-size="dashboardSize"
>
<section class="home-recent-worlds flex min-w-0 min-h-0 h-full flex-col gap-3" :data-size="dashboardSize">
<div class="home-widget-heading flex min-w-0 h-8 flex-none items-center gap-2">
<h2>{{ formatMessage(messages.recentTitle) }}</h2>
</div>
<div
v-if="recentItems.length > 0"
class="home-recent-list flex min-w-0 min-h-0 flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto pr-1"
>
<div v-if="recentItems.length > 0" class="home-recent-list flex min-w-0 min-h-0 flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto pr-1">
<template
v-for="item in recentItems"
:key="item.type === 'world' ? worldKey(item.world) : `${item.instance.id}:instance`"
@@ -1,161 +0,0 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
max-width="560px"
:on-hide="handleHide"
>
<div class="flex flex-col gap-4">
<p class="m-0 text-secondary">
{{ formatMessage(messages.description) }}
</p>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.gameDirLabel) }}
</span>
<div class="flex gap-2">
<StyledInput
class="flex-1"
:model-value="selectedPath"
readonly
:placeholder="defaultPath || formatMessage(messages.noSelection)"
/>
<ButtonStyled>
<button type="button" @click="browse">
<FolderOpenIcon />
{{ formatMessage(messages.browse) }}
</button>
</ButtonStyled>
</div>
<button
v-if="defaultPath && selectedPath !== defaultPath"
type="button"
class="self-start text-sm text-brand underline decoration-transparent underline-offset-2 transition-colors hover:decoration-current"
@click="selectedPath = defaultPath"
>
{{ formatMessage(messages.resetDefault) }}
</button>
</div>
<p v-if="previewPath" class="m-0 text-sm text-secondary break-all">
{{ formatMessage(messages.preview, { path: previewPath }) }}
</p>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button type="button" @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" :disabled="!selectedPath" @click="handleConfirm">
<CheckIcon />
{{ formatMessage(messages.confirm) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { CheckIcon, FolderOpenIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
injectFilePicker,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
import { computed, ref } from 'vue'
const emit = defineEmits<{
(e: 'confirm', gameDirRoot: string): void
(e: 'cancel'): void
}>()
const { formatMessage } = useVIntl()
const filePicker = injectFilePicker()
const modal = ref<InstanceType<typeof NewModal>>()
const defaultPath = ref('')
const selectedPath = ref('')
const accepted = ref(false)
const messages = defineMessages({
header: {
id: 'app.hosted-install.game-dir.header',
defaultMessage: 'Choose game directory',
},
description: {
id: 'app.hosted-install.game-dir.description',
defaultMessage:
'StarLight instance data (mods, saves, configs, resource packs) is stored in an external game directory. Pick a root folder — the modpack gets its own subfolder inside it.',
},
gameDirLabel: {
id: 'app.hosted-install.game-dir.label',
defaultMessage: 'Game directory root',
},
browse: {
id: 'app.hosted-install.game-dir.browse',
defaultMessage: 'Browse',
},
noSelection: {
id: 'app.hosted-install.game-dir.no-selection',
defaultMessage: 'No folder selected',
},
resetDefault: {
id: 'app.hosted-install.game-dir.reset-default',
defaultMessage: 'Use default location',
},
preview: {
id: 'app.hosted-install.game-dir.preview',
defaultMessage: 'Game files will be installed to: {path}',
},
confirm: {
id: 'app.hosted-install.game-dir.confirm',
defaultMessage: 'Install',
},
})
const previewPath = computed(() => {
const base = (selectedPath.value ?? '').replace(/[\\/]+$/, '')
return base ? `${base}/<pack name>` : ''
})
async function show() {
if (!defaultPath.value) {
defaultPath.value = await invoke<string>('get_launcher_root_dir').catch(() => '')
}
if (!selectedPath.value) selectedPath.value = defaultPath.value
accepted.value = false
modal.value?.show()
}
async function browse() {
const picked = await filePicker.pickFolder?.()
if (picked?.path) selectedPath.value = picked.path
}
function handleCancel() {
modal.value?.hide()
}
function handleConfirm() {
accepted.value = true
modal.value?.hide()
emit('confirm', selectedPath.value)
}
function handleHide() {
if (!accepted.value) emit('cancel')
}
defineExpose({ show })
</script>
@@ -1,208 +0,0 @@
<template>
<InstanceModeSettings :instance-id="instanceId" :disabled="syncing" class="mb-5" />
<div v-if="modeQuery.data.value === 'local'" class="flex flex-col gap-3">
<h2 class="m-0">{{ formatMessage(messages.localTitle) }}</h2>
<p class="m-0 text-secondary">{{ formatMessage(messages.localPacks) }}</p>
<div class="flex flex-wrap gap-3">
<ButtonStyled
><button type="button" @click="router.push('/browse/modpack')">
{{ formatMessage(messages.browse) }}
</button></ButtonStyled
>
<ButtonStyled
><button type="button" @click="importLocal">
{{ formatMessage(messages.importLocal) }}
</button></ButtonStyled
>
</div>
</div>
<template v-if="modeQuery.data.value === 'starlight'">
<section class="flex flex-col gap-4" :aria-busy="loading || syncing">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0">{{ formatMessage(messages.title) }}</h2>
<p>{{ formatMessage(messages.description) }}</p>
</div>
<ButtonStyled
><button type="button" :disabled="loading || syncing" @click="load">
{{ formatMessage(messages.refresh) }}
</button></ButtonStyled
>
</div>
<p v-if="loading" role="status">{{ formatMessage(messages.loading) }}</p>
<p v-if="error" role="alert" class="text-red">{{ error }}</p>
<p v-if="binding">
{{
formatMessage(messages.bound, {
name: binding.publication.manifest.name,
version: binding.publication.manifest.version,
})
}}
</p>
<HostedPackProgress :instance-id="instanceId" :active="syncing" />
<div v-if="result" role="status" class="rounded-xl bg-bg-raised p-4">
<p>
{{
formatMessage(messages.complete, {
version: result.version,
count: result.changedFiles,
size: (result.downloadedBytes / 1048576).toFixed(2),
})
}}
</p>
<details v-if="result.preservedFiles.length">
<summary>
{{ formatMessage(messages.preserved, { count: result.preservedFiles.length }) }}
</summary>
<ul>
<li v-for="path in result.preservedFiles" :key="path">{{ path }}</li>
</ul>
</details>
</div>
<article
v-if="pack"
class="flex flex-wrap items-center justify-between gap-4 rounded-xl bg-bg-raised p-4"
>
<div>
<h3 class="m-0">{{ pack.manifest.name }}</h3>
<p class="mb-0">
{{ pack.manifest.version }} · Minecraft {{ pack.manifest.runtime.gameVersion }} ·
{{ pack.manifest.runtime.loader }}
</p>
</div>
<ButtonStyled color="brand"
><button type="button" :disabled="!ready || syncing || loading" @click="sync">
{{ formatMessage(binding ? messages.update : messages.install) }}
</button></ButtonStyled
>
</article>
</section>
</template>
</template>
<script setup lang="ts">
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, inject, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import HostedPackProgress from '@/components/instance/HostedPackProgress.vue'
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
import { markHostedCreationCompleted } from '@/composables/useHostedCreation'
import { useHostedSync } from '@/composables/useHostedSync'
import { useInstanceMode } from '@/composables/useInstanceMode'
import {
type HostedBinding,
hostedBinding,
hostedDefault,
type HostedPublication,
} from '@/helpers/hosted-packs'
import { injectDownloadManager } from '@/providers/download-manager'
const props = defineProps<{ instanceId: string }>()
const modeQuery = useInstanceMode(() => props.instanceId)
const router = useRouter()
const showCreation = inject<(options: { skipSetupType: boolean; initialMode: 'import' }) => void>(
'showCreationModalWithOptions',
)
function importLocal() {
showCreation?.({ skipSetupType: true, initialMode: 'import' })
}
const { formatMessage } = useVIntl()
const messages = defineMessages({
localTitle: { id: 'app.instance-mode.local-packs-title', defaultMessage: 'Local modpacks' },
localPacks: {
id: 'app.instance-mode.local-packs',
defaultMessage:
'Choose any modpack to install as a local instance. Existing files and worlds are kept when you switch to Local; StarLight changes will no longer be synchronized.',
},
browse: { id: 'app.instance-mode.browse', defaultMessage: 'Browse modpacks' },
importLocal: { id: 'app.instance-mode.import', defaultMessage: 'Import as a local instance' },
title: { id: 'app.hosted-packs.title', defaultMessage: 'Server-managed modpack' },
description: {
id: 'app.hosted-packs.description',
defaultMessage:
'The administrator selects this modpack and its versions. Every launch checks for updates and downloads changes before starting. A valid StarLight login and network connection are required.',
},
refresh: { id: 'app.hosted-packs.refresh', defaultMessage: 'Refresh' },
loading: { id: 'app.hosted-packs.loading', defaultMessage: 'Loading published modpacks…' },
bound: { id: 'app.hosted-packs.bound', defaultMessage: 'Installed: {name} · {version}' },
syncing: {
id: 'app.hosted-packs.syncing',
defaultMessage: 'Comparing files and synchronizing changes…',
},
complete: {
id: 'app.hosted-packs.complete',
defaultMessage: 'Synced to {version}. Changed {count} files; downloaded {size} MiB.',
},
preserved: {
id: 'app.hosted-packs.preserved',
defaultMessage: 'Preserved {count} locally modified or personal files',
},
empty: {
id: 'app.hosted-packs.empty',
defaultMessage: 'No modpacks have been approved for publication yet.',
},
update: { id: 'app.hosted-packs.update', defaultMessage: 'Synchronize now' },
install: { id: 'app.hosted-packs.install', defaultMessage: 'Retry automatic installation' },
})
const pack = ref<HostedPublication | null>(null)
const binding = ref<HostedBinding | null>(null)
const task = useHostedSync(() => props.instanceId)
const result = task.result
const manager = injectDownloadManager()
const loading = ref(false)
const syncing = computed(
() =>
task.busy.value ||
manager.legacyDownloads.value.some(
(bar) =>
bar.bar_type?.type === 'hosted_pack_sync' &&
bar.bar_type.instance_id === props.instanceId &&
!bar.bar_type.error,
),
)
const ready = ref(false)
const loadError = ref('')
const error = computed(() => loadError.value || task.error.value)
let generation = 0
async function load() {
if (modeQuery.data.value !== 'starlight') return
const current = ++generation
const instanceId = props.instanceId
loading.value = true
ready.value = false
loadError.value = ''
try {
const [official, installed] = await Promise.all([hostedDefault(), hostedBinding(instanceId)])
if (current !== generation) return
pack.value = official
binding.value = installed
ready.value = true
} catch (cause) {
if (current === generation) loadError.value = String(cause)
} finally {
if (current === generation) loading.value = false
}
}
async function sync() {
if (syncing.value || !ready.value || modeQuery.data.value !== 'starlight') return
loadError.value = ''
const result = await task.sync()
if (result) markHostedCreationCompleted(props.instanceId)
}
watch(syncing, (busy, wasBusy) => {
if (!busy && wasBusy) void load()
})
watch(
() => [props.instanceId, modeQuery.data.value] as const,
() => {
generation++
loading.value = false
ready.value = false
loadError.value = ''
pack.value = null
binding.value = null
void load()
},
{ immediate: true },
)
</script>
@@ -1,50 +0,0 @@
<script setup lang="ts">
import { defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import { injectDownloadManager } from '@/providers/download-manager'
const props = defineProps<{ instanceId?: string; active?: boolean }>()
const manager = injectDownloadManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
preparing: {
id: 'app.hosted-packs.progress.preparing',
defaultMessage: 'Preparing modpack installation…',
},
downloads: { id: 'app.hosted-packs.progress.downloads', defaultMessage: 'View downloads' },
})
const bar = computed(() =>
manager.legacyDownloads.value.find(
(item) =>
props.instanceId &&
item.bar_type?.type === 'hosted_pack_sync' &&
!item.bar_type.error &&
item.bar_type.instance_id === props.instanceId,
),
)
const waiting = computed(() => !bar.value?.total)
const current = computed(() =>
Math.max(0, Math.min(bar.value?.current ?? 0, bar.value?.total ?? 0)),
)
const message = computed(() => bar.value?.message || formatMessage(messages.preparing))
</script>
<template>
<div v-if="active || bar" class="flex min-w-0 flex-col gap-2" role="status">
<ProgressBar
:progress="current"
:max="bar?.total || 1"
:waiting="waiting"
:label="message"
label-class="min-w-0 break-all text-sm text-secondary"
:show-progress="!waiting"
full-width
>
<template #progress-icon />
</ProgressBar>
<RouterLink to="/downloads" class="self-start text-sm text-brand hover:underline">
{{ formatMessage(messages.downloads) }}
</RouterLink>
</div>
</template>
@@ -1,59 +0,0 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { useId } from 'vue'
import type { InstanceMode } from '@/helpers/hosted-packs'
defineProps<{ modelValue?: InstanceMode; disabled?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: InstanceMode] }>()
const group = useId()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.instance-mode.title', defaultMessage: 'Instance type' },
starlight: { id: 'app.instance-mode.starlight', defaultMessage: 'StarLight instance' },
local: { id: 'app.instance-mode.local', defaultMessage: 'Local instance' },
starlightDescription: {
id: 'app.instance-mode.starlight-description',
defaultMessage:
'Required for playing on StarLight. Automatically installs the server-selected modpack, Minecraft version, and loader. Every launch requires a StarLight login and checks for updates before starting.',
},
localDescription: {
id: 'app.instance-mode.local-description',
defaultMessage:
'Does not sync StarLight server changes. Skips sync checks for faster startup and lets you choose your own modpacks. Best for third-party servers and personal single-player worlds.',
},
})
</script>
<template>
<fieldset class="m-0 flex flex-col gap-3 border-0 p-0" :disabled="disabled">
<legend class="mb-3 text-lg font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</legend>
<label
v-for="mode in ['starlight', 'local'] as const"
:key="mode"
class="flex items-start gap-3 rounded-xl border border-solid border-divider p-4"
:class="[
modelValue === mode ? 'bg-surface-3' : 'bg-surface-1',
disabled ? 'opacity-60' : 'cursor-pointer',
]"
>
<input
type="radio"
class="mt-1"
:name="group"
:value="mode"
:checked="modelValue === mode"
@change="emit('update:modelValue', mode)"
/>
<span class="flex flex-col gap-1"
><span class="font-semibold text-contrast">{{ formatMessage(messages[mode]) }}</span
><span class="text-sm text-secondary">{{
formatMessage(
mode === 'starlight' ? messages.starlightDescription : messages.localDescription,
)
}}</span></span
>
</label>
</fieldset>
</template>
@@ -1,67 +0,0 @@
<script setup lang="ts">
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, watch } from 'vue'
import InstanceModeOptions from '@/components/instance/InstanceModeOptions.vue'
import { useInstanceMode, useSetInstanceMode } from '@/composables/useInstanceMode'
import type { InstanceMode } from '@/helpers/hosted-packs'
const props = defineProps<{ instanceId: string; disabled?: boolean }>()
const query = useInstanceMode(() => props.instanceId)
const save = useSetInstanceMode()
const { formatMessage } = useVIntl()
const messages = defineMessages({
saving: {
id: 'app.instance-mode.saving',
defaultMessage: 'Applying instance type and installing the server modpack when needed…',
},
loading: { id: 'app.instance-mode.loading', defaultMessage: 'Loading instance type…' },
retry: { id: 'app.instance-mode.retry', defaultMessage: 'Retry' },
})
const saving = computed(
() => save.isPending.value && save.variables.value?.instanceId === props.instanceId,
)
const selection = computed(() => (saving.value ? save.variables.value?.mode : query.data.value))
const failure = computed(
() =>
query.error.value ??
(save.variables.value?.instanceId === props.instanceId ? save.error.value : null),
)
function select(mode: InstanceMode) {
if (props.disabled || saving.value || !query.data.value || query.data.value === mode) return
save.mutate({ instanceId: props.instanceId, mode })
}
function retry() {
if (props.disabled || saving.value) return
if (query.error.value) void query.refetch()
else if (save.variables.value?.instanceId === props.instanceId) save.mutate(save.variables.value)
}
watch(
() => props.instanceId,
() => save.reset(),
)
</script>
<template>
<div class="flex flex-col gap-3">
<InstanceModeOptions
:model-value="selection"
:disabled="disabled || !query.data.value || query.isPending.value || saving"
@update:model-value="select"
/>
<p v-if="query.isPending.value || saving" class="m-0 text-secondary" role="status">
{{ formatMessage(saving ? messages.saving : messages.loading) }}
</p>
<div v-if="failure" role="alert" class="flex items-center gap-3">
<span>{{ String(failure) }}</span
><ButtonStyled
><button
type="button"
:disabled="disabled || query.isFetching.value || saving"
@click="retry"
>
{{ formatMessage(messages.retry) }}
</button></ButtonStyled
>
</div>
</div>
</template>
@@ -1,250 +0,0 @@
<script setup lang="ts">
import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
import {
openSkinSiteLogin,
requestSkinSitePlayers,
skinSitePlayers,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { users } from '@/helpers/auth'
import { getInstanceMode } from '@/helpers/hosted-packs'
import {
type InstancePlayer,
type PlayerChoice,
PlayerSelectionNavigatedAwayError,
registerInstancePlayerPicker,
saveInstancePlayer,
waitForSkinSiteSession,
} from '@/helpers/instance-player'
import { playerMessages as messages } from './instance-player-messages'
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const microsoftLogin = ref<InstanceType<typeof MinecraftLoginModal>>()
const router = useRouter()
const accounts = ref<PlayerChoice[]>([])
const active = ref(false)
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const instanceId = ref('')
const locked = ref<InstancePlayer | null>(null)
const awaitingSkinLogin = ref(false)
let hideForLogin = false
let generation = 0
let resolveSelection: ((player: InstancePlayer) => void) | undefined
let rejectSelection: ((reason: Error) => void) | undefined
const choices = computed(() => {
const skin: PlayerChoice[] =
skinSiteStatus.value === 'signed-in'
? skinSitePlayers.value
.filter((player) => !player.isMojang)
.map((player) => ({
id: player.uuid,
name: player.name,
account_type: 'yggdrasil',
skin_site_user: skinSiteUser.value?.uuid,
head: player.headDataUrl,
}))
: []
return [...skin, ...accounts.value].filter(
(player) =>
!locked.value ||
(player.id.replaceAll('-', '') === locked.value.id.replaceAll('-', '') &&
player.account_type === locked.value.account_type &&
(player.skin_site_user ?? null) === (locked.value.skin_site_user ?? null)),
)
})
async function refresh() {
const revision = ++generation
loading.value = true
error.value = ''
try {
const [available, mode] = await Promise.all([users(), getInstanceMode(instanceId.value)])
if (revision !== generation) return
accounts.value = available
.filter(
(account) =>
account.account_type === 'microsoft' ||
(mode === 'local' && account.account_type === 'offline'),
)
.map((account) => ({
id: account.profile.id,
name: account.profile.name,
account_type: account.account_type,
}))
await waitForSkinSiteSession()
if (revision !== generation) return
if (skinSiteStatus.value === 'signed-in') await requestSkinSitePlayers()
if (skinSiteStatus.value === 'error') throw new Error('无法确认皮肤站登录状态,请重试。')
} catch (cause) {
if (revision === generation)
error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
if (revision === generation) loading.value = false
}
}
function cancelled() {
if (hideForLogin) {
hideForLogin = false
return
}
if (!active.value) return
active.value = false
awaitingSkinLogin.value = false
generation++
rejectSelection?.(new Error('已取消选择实例玩家'))
resolveSelection = undefined
rejectSelection = undefined
}
async function select(player: PlayerChoice) {
if (loading.value || saving.value || !active.value) return
saving.value = true
error.value = ''
try {
const { head: _, ...binding } = player
await saveInstancePlayer(instanceId.value, binding)
active.value = false
resolveSelection?.(binding)
resolveSelection = undefined
rejectSelection = undefined
modal.value?.hide()
} catch (cause) {
error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
saving.value = false
}
}
function signInSkinSite() {
awaitingSkinLogin.value = true
hideForLogin = true
active.value = false
generation++
// Settle the pending selection so `prepareInstancePlayer` releases its
// in-flight entry; otherwise a later launch would await this forever and
// never re-prompt. The caller treats this sentinel as a silent abort.
rejectSelection?.(new PlayerSelectionNavigatedAwayError())
resolveSelection = undefined
rejectSelection = undefined
openSkinSiteLogin()
modal.value?.hide()
void router.push('/starlight-skin')
}
const unregister = registerInstancePlayerPicker((id, saved) => {
if (active.value) return Promise.reject(new Error('请先完成当前实例的玩家选择。'))
instanceId.value = id
locked.value = saved
accounts.value = []
awaitingSkinLogin.value = false
active.value = true
modal.value?.show()
void refresh()
return new Promise((resolve, reject) => {
resolveSelection = resolve
rejectSelection = reject
})
})
watch([skinSiteStatus, () => skinSiteUser.value?.uuid], () => {
if (!active.value || saving.value) return
if (
awaitingSkinLogin.value &&
skinSiteStatus.value === 'signed-in' &&
(!locked.value?.skin_site_user || locked.value.skin_site_user === skinSiteUser.value?.uuid)
) {
awaitingSkinLogin.value = false
modal.value?.show()
}
void refresh()
})
onUnmounted(() => {
unregister()
cancelled()
})
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
:closable="!saving"
:on-hide="cancelled"
>
<div class="flex flex-col gap-3">
<p class="m-0 text-secondary">
{{
locked
? formatMessage(messages.restore, { name: locked.name })
: formatMessage(messages.remember)
}}
</p>
<p v-if="loading" class="m-0" role="status">{{ formatMessage(messages.loading) }}</p>
<template v-else>
<ButtonStyled v-for="player in choices" :key="`${player.account_type}:${player.id}`">
<button
type="button"
:disabled="saving"
class="flex items-center gap-3"
@click="select(player)"
>
<img
v-if="player.head"
:src="player.head"
alt=""
class="size-8 rounded-md [image-rendering:pixelated]"
/>
<span>{{ player.name }}</span
><span class="text-secondary">{{
formatMessage(
player.account_type === 'microsoft'
? messages.licensed
: player.account_type === 'offline'
? messages.offline
: messages.skin,
)
}}</span>
</button>
</ButtonStyled>
<p v-if="!choices.length && !error && !locked" class="m-0 text-secondary">
{{ formatMessage(skinSiteStatus === 'signed-in' ? messages.empty : messages.signIn) }}
</p>
</template>
<p v-if="error" class="m-0 text-red" role="alert">{{ error }}</p>
<ButtonStyled v-if="error"
><button :disabled="loading || saving" @click="refresh">
{{ formatMessage(messages.retry) }}
</button></ButtonStyled
>
<ButtonStyled
v-if="
skinSiteStatus === 'signed-out' ||
(locked?.skin_site_user && locked.skin_site_user !== skinSiteUser?.uuid)
"
><button :disabled="saving" @click="signInSkinSite">
{{ formatMessage(messages.skinLogin) }}
</button></ButtonStyled
>
<div class="flex flex-col items-center gap-2">
<span class="text-secondary">{{ formatMessage(messages.or) }}</span>
<ButtonStyled
><button :disabled="loading || saving" @click="microsoftLogin?.showDeviceLogin()">
{{ formatMessage(messages.useMicrosoft) }}
</button></ButtonStyled
>
</div>
<p v-if="saving" class="m-0" role="status">{{ formatMessage(messages.saving) }}</p>
</div>
</NewModal>
<MinecraftLoginModal ref="microsoftLogin" @complete="refresh" />
</template>
@@ -1,81 +0,0 @@
<script setup lang="ts">
import { ButtonStyled, useVIntl } from '@modrinth/ui'
import { onUnmounted, ref, watch } from 'vue'
import {
chooseInstancePlayer,
getInstancePlayer,
type InstancePlayer,
onInstancePlayerChanged,
} from '@/helpers/instance-player'
import { playerMessages as messages } from './instance-player-messages'
const { formatMessage } = useVIntl()
const props = defineProps<{ instanceId: string }>()
const player = ref<InstancePlayer | null>(null)
const busy = ref(false)
const error = ref('')
let generation = 0
const stop = onInstancePlayerChanged((id, saved) => {
if (id === props.instanceId) {
generation++
player.value = saved
busy.value = false
error.value = ''
}
})
onUnmounted(() => {
generation++
stop()
})
watch(
() => props.instanceId,
async (id) => {
const revision = ++generation
player.value = null
error.value = ''
busy.value = true
try {
const saved = await getInstancePlayer(id)
if (generation === revision) player.value = saved
} catch (cause) {
if (generation === revision) error.value = String(cause)
} finally {
if (generation === revision) busy.value = false
}
},
{ immediate: true },
)
async function change() {
if (busy.value) return
const revision = generation
const id = props.instanceId
busy.value = true
error.value = ''
try {
const saved = await chooseInstancePlayer(id)
if (generation === revision) player.value = saved
} catch (cause) {
if (generation === revision)
error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
if (generation === revision) busy.value = false
}
}
</script>
<template>
<div class="mb-6 flex flex-col gap-2" data-onboarding-id="instance-player-settings">
<h3 class="m-0 text-contrast">{{ formatMessage(messages.setting) }}</h3>
<p class="m-0 text-secondary">
{{ player ? player.name : formatMessage(messages.firstLaunch) }}
</p>
<ButtonStyled
><button :disabled="busy" @click="change">
{{ formatMessage(busy ? messages.loading : messages.change) }}
</button></ButtonStyled
>
<p v-if="error" class="m-0 text-red" role="alert">{{ error }}</p>
</div>
</template>
@@ -1,73 +0,0 @@
<template>
<div
class="management-switch relative isolate mb-4 flex w-full max-w-[26rem] rounded-xl bg-surface-1 p-1"
:data-packs="selected === 'packs'"
role="group"
:aria-label="formatMessage(messages.label)"
>
<span
class="selection absolute inset-y-1 left-1 -z-10 w-[calc(50%_-_4px)] rounded-lg bg-surface-3 shadow-sm"
aria-hidden="true"
/>
<button
class="flex-1 cursor-pointer border-0 bg-transparent px-4 py-2.5 font-semibold text-base aria-pressed:text-contrast"
type="button"
:aria-pressed="selected === 'mods'"
@click="select('mods')"
>
{{ formatMessage(messages.mods) }}
</button>
<button
class="flex-1 cursor-pointer border-0 bg-transparent px-4 py-2.5 font-semibold text-base aria-pressed:text-contrast"
type="button"
:aria-pressed="selected === 'packs'"
@click="select('packs')"
>
{{ formatMessage(messages.packs) }}
</button>
</div>
<div v-show="selected === 'mods'"><slot /></div>
<div v-if="openedPacks" v-show="selected === 'packs'"><slot name="packs" /></div>
</template>
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
label: { id: 'app.hosted-packs.management', defaultMessage: 'Content management' },
mods: { id: 'app.hosted-packs.mods', defaultMessage: 'Manage mods' },
packs: { id: 'app.hosted-packs.packs', defaultMessage: 'Manage modpacks' },
})
const key = 'starlight:instance:mod-management-tab'
function initial(): 'mods' | 'packs' {
try {
return localStorage.getItem(key) === 'packs' ? 'packs' : 'mods'
} catch {
return 'mods'
}
}
const selected = ref(initial())
const openedPacks = ref(selected.value === 'packs')
function select(value: 'mods' | 'packs') {
selected.value = value
if (value === 'packs') openedPacks.value = true
try {
localStorage.setItem(key, value)
} catch {
/* Selection remains usable without storage. */
}
}
</script>
<style scoped>
.selection {
transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-packs='true'] .selection {
transform: translateX(100%);
}
@media (prefers-reduced-motion: reduce) {
.selection {
transition: none;
}
}
</style>
@@ -1,151 +0,0 @@
<script setup lang="ts">
import { ButtonStyled, defineMessages, NewModal, ProgressBar, useVIntl } from '@modrinth/ui'
import { nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import { loading_listener } from '@/helpers/events'
import { onHostedPackAttemptStarted } from '@/helpers/hosted-packs'
import { progress_bars_list } from '@/helpers/state'
import {
createTaggedModProgress,
type TaggedProgressEvent,
type TaggedProgressGroup,
} from '@/helpers/tagged-mod-progress'
const modal = ref<InstanceType<typeof NewModal>>()
const router = useRouter()
const { formatMessage } = useVIntl()
const state = createTaggedModProgress()
const groups = shallowRef<TaggedProgressGroup[]>([])
const messages = defineMessages({
title: { id: 'app.hosted-mods.title', defaultMessage: 'Updating server Mods' },
description: {
id: 'app.hosted-mods.description',
defaultMessage: 'The game starts after all required updates have been installed.',
},
complete: { id: 'app.hosted-mods.complete', defaultMessage: 'Downloaded' },
downloads: { id: 'app.hosted-mods.downloads', defaultMessage: 'View downloads' },
close: { id: 'app.hosted-mods.close', defaultMessage: 'Close' },
})
const size = (value: number) => `${(value / 1048576).toFixed(1)} MiB`
let disposed = false
let unlisten: (() => void) | undefined
let stopAttempts: (() => void) | undefined
function refresh() {
groups.value = [...state.groups.values()]
}
function consume(payload: TaggedProgressEvent) {
const opened = state.update(payload)
refresh()
if (opened)
void nextTick(() => {
const group = state.groups.get(payload.event?.batch_id ?? '')
if (!disposed && group && (!group.done || group.error)) modal.value?.show()
})
if (groups.value.length > 0 && groups.value.every((group) => group.done && !group.error))
modal.value?.hide()
}
onMounted(async () => {
stopAttempts = onHostedPackAttemptStarted((id) => {
state.reset(id)
refresh()
if (!groups.value.length) modal.value?.hide()
})
let initializing = true
const buffered: TaggedProgressEvent[] = []
const stop = await loading_listener((payload: TaggedProgressEvent) => {
if (initializing) buffered.push(payload)
else consume(payload)
})
if (disposed) {
stop()
return
}
unlisten = stop
const bars = await progress_bars_list().catch(() => ({}))
if (disposed) return
const ordered = Object.values(bars).sort(
(a, b) =>
Number(b.bar_type?.type === 'hosted_pack_sync') -
Number(a.bar_type?.type === 'hosted_pack_sync'),
)
for (const bar of ordered)
consume({
loader_uuid: String(bar.loading_bar_uuid),
event: bar.bar_type,
fraction: bar.total ? (bar.current ?? 0) / bar.total : 0,
total: bar.total,
message: bar.message ?? '',
})
initializing = false
for (const payload of buffered) consume(payload)
})
onUnmounted(() => {
disposed = true
unlisten?.()
stopAttempts?.()
})
function openDownloads() {
modal.value?.hide()
void router.push('/downloads')
}
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
width="min(38rem, calc(100vw - 2rem))"
scrollable
max-content-height="60vh"
:close-on-click-outside="false"
>
<div class="flex flex-col gap-4">
<p class="m-0 text-secondary">{{ formatMessage(messages.description) }}</p>
<section v-for="group in groups" :key="group.id" class="flex min-w-0 flex-col gap-3">
<h3 class="m-0 text-contrast">{{ group.name }}</h3>
<p v-if="group.error" role="alert" class="m-0 break-words text-red">{{ group.error }}</p>
<p v-else-if="group.message" class="m-0 break-words text-sm text-secondary">
{{ group.message }}
</p>
<div
v-for="file in group.files.values()"
:key="file.id"
class="flex min-w-0 flex-col gap-1"
>
<div class="flex flex-wrap justify-between gap-2 text-sm">
<span class="min-w-0 break-all text-contrast">{{ file.name }}</span>
<span class="text-secondary tabular-nums"
>{{ size(file.current) }} / {{ size(file.total) }}</span
>
</div>
<p v-if="file.error" class="m-0 break-words text-sm text-red">{{ file.error }}</p>
<ProgressBar
v-else
:progress="file.current"
:max="file.total || 1"
:waiting="!file.total"
:show-progress="!file.done"
:label="file.done ? formatMessage(messages.complete) : file.message"
full-width
><template #progress-icon
/></ProgressBar>
</div>
</section>
</div>
<template #actions>
<div class="flex w-full items-center justify-between gap-2">
<ButtonStyled>
<button @click="openDownloads">
{{ formatMessage(messages.downloads) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal?.hide()">
{{ formatMessage(messages.close) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
@@ -1,37 +0,0 @@
import { defineMessages } from '@modrinth/ui'
export const playerMessages = defineMessages({
title: { id: 'app.instance-player.title', defaultMessage: 'Choose an instance player' },
remember: {
id: 'app.instance-player.remember',
defaultMessage:
'This instance will keep using your choice. Switch players in instance settings.',
},
restore: {
id: 'app.instance-player.restore',
defaultMessage: 'Sign in again as {name}. To choose someone else, open instance settings.',
},
loading: { id: 'app.instance-player.loading', defaultMessage: 'Loading signed-in players…' },
licensed: { id: 'app.instance-player.licensed', defaultMessage: 'Minecraft account' },
skin: { id: 'app.instance-player.skin', defaultMessage: 'Skin site player' },
offline: { id: 'app.instance-player.offline', defaultMessage: 'Offline player' },
empty: {
id: 'app.instance-player.empty',
defaultMessage: 'No available skin site players. You can create a player on the skin site.',
},
signIn: { id: 'app.instance-player.sign-in', defaultMessage: 'Sign in to choose a player.' },
retry: { id: 'app.instance-player.retry', defaultMessage: 'Retry' },
skinLogin: { id: 'app.instance-player.skin-login', defaultMessage: 'Sign in to skin site' },
or: { id: 'app.instance-player.or', defaultMessage: 'Or' },
useMicrosoft: {
id: 'app.instance-player.use-microsoft',
defaultMessage: 'Use a Minecraft account',
},
saving: { id: 'app.instance-player.saving', defaultMessage: 'Saving instance player…' },
setting: { id: 'app.instance-player.setting', defaultMessage: 'Instance player' },
firstLaunch: {
id: 'app.instance-player.first-launch',
defaultMessage: 'Choose a player on first launch',
},
change: { id: 'app.instance-player.change', defaultMessage: 'Switch instance player' },
})
@@ -12,14 +12,10 @@
>
{{ formatMessage(messages.score, { score }) }}
</span>
<span
class="shrink-0 whitespace-nowrap rounded-full bg-surface-3 px-2 py-0.5 tabular-nums text-secondary"
>
<span class="shrink-0 whitespace-nowrap rounded-full bg-surface-3 px-2 py-0.5 tabular-nums text-secondary">
{{ formatMessage(messages.highestLevel, { level: highestLevel + 1 }) }}
</span>
<span
class="shrink-0 whitespace-nowrap rounded-full bg-surface-3 px-2 py-0.5 tabular-nums text-secondary"
>
<span class="shrink-0 whitespace-nowrap rounded-full bg-surface-3 px-2 py-0.5 tabular-nums text-secondary">
{{ formatMessage(messages.best, { score: bestScore }) }}
</span>
<NewButton type="base" size="sm" class="shrink-0" @click="settleGame">
@@ -84,10 +80,7 @@
</div>
</div>
</Transition>
<div
v-if="gameOver && !endedManually"
class="tide-drain pointer-events-none absolute inset-0 z-20"
/>
<div v-if="gameOver && !endedManually" class="tide-drain pointer-events-none absolute inset-0 z-20" />
<Transition name="panel">
<div
v-if="gameOver"
@@ -122,26 +115,16 @@
</div>
<div class="grid w-full min-w-0 grid-cols-3 gap-1.5">
<div class="flex min-w-0 flex-col items-center rounded-lg bg-surface-3 px-1 py-1">
<span class="text-[9px] text-secondary">{{
formatMessage(messages.levelLabel)
}}</span>
<span class="text-sm font-bold tabular-nums text-contrast">{{
highestLevel + 1
}}</span>
<span class="text-[9px] text-secondary">{{ formatMessage(messages.levelLabel) }}</span>
<span class="text-sm font-bold tabular-nums text-contrast">{{ highestLevel + 1 }}</span>
</div>
<div class="flex min-w-0 flex-col items-center rounded-lg bg-surface-3 px-1 py-1">
<span class="text-[9px] text-secondary">{{
formatMessage(messages.bestLabel)
}}</span>
<span class="text-[9px] text-secondary">{{ formatMessage(messages.bestLabel) }}</span>
<span class="text-sm font-bold tabular-nums text-contrast">{{ bestScore }}</span>
</div>
<div class="flex min-w-0 flex-col items-center rounded-lg bg-surface-3 px-1 py-1">
<span class="text-[9px] text-secondary">{{
formatMessage(messages.overtimesLabel)
}}</span>
<span class="text-sm font-bold tabular-nums text-contrast">{{
overtimeCount
}}</span>
<span class="text-[9px] text-secondary">{{ formatMessage(messages.overtimesLabel) }}</span>
<span class="text-sm font-bold tabular-nums text-contrast">{{ overtimeCount }}</span>
</div>
</div>
<div class="flex flex-wrap justify-center gap-2">
@@ -167,8 +150,8 @@ import blueStar from '@/assets/stars/blue.png'
import greenStar from '@/assets/stars/green.png'
import pinkStar from '@/assets/stars/pink.png'
import redStar from '@/assets/stars/red.png'
import superStar from '@/assets/stars/super.png'
import yellowStar from '@/assets/stars/yellow.png'
import superStar from '@/assets/stars/super.png'
interface Piece {
id: number
@@ -413,14 +396,7 @@ function mergePieces(a: Piece, b: Piece) {
score.value += points[level]
highestLevel.value = Math.max(highestLevel.value, level)
const isRainbow = level === colors.length - 1
spawnBurst(
x,
y,
isRainbow ? rainbowColors : [colors[level]],
10 + level * 3,
130 + level * 20,
3.4,
)
spawnBurst(x, y, isRainbow ? rainbowColors : [colors[level]], 10 + level * 3, 130 + level * 20, 3.4)
spawnShockwave(x, y, 46 + level * 26, 'rgba(255, 255, 255, 0.9)', Math.min(3, 1.4 + level * 0.3))
shakePower = Math.max(shakePower, Math.min(2 + level * 1.2, 7))
spawnFloater(x, y, `+${points[level]}`, 15 + level * 3, colors[level])
@@ -447,14 +423,7 @@ function easeOutBack(t: number) {
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
}
function spawnBurst(
x: number,
y: number,
palette: string[],
count: number,
speed: number,
size: number,
) {
function spawnBurst(x: number, y: number, palette: string[], count: number, speed: number, size: number) {
for (let i = 0; i < count; i++) {
if (particles.length >= 260) return
const angle = Math.random() * Math.PI * 2
@@ -539,8 +508,7 @@ function update(delta: number) {
if (overtimeStartAt >= 0) {
const overtimeElapsed = elapsed - overtimeStartAt
if (overtimeElapsed < 0.8) {
surfaceRatio =
overtimeFromRatio + (0.05 - overtimeFromRatio) * easeInOutCubic(overtimeElapsed / 0.8)
surfaceRatio = overtimeFromRatio + (0.05 - overtimeFromRatio) * easeInOutCubic(overtimeElapsed / 0.8)
} else {
overtimeStartAt = -1
}
@@ -702,7 +670,17 @@ function prepareBallSprite(src: string, level: number) {
sprite.height = Math.max(1, Math.round(bounds.h * scale))
const ctx = sprite.getContext('2d')
if (!ctx) return
ctx.drawImage(image, bounds.x, bounds.y, bounds.w, bounds.h, 0, 0, sprite.width, sprite.height)
ctx.drawImage(
image,
bounds.x,
bounds.y,
bounds.w,
bounds.h,
0,
0,
sprite.width,
sprite.height,
)
ballSprites[level] = sprite
}
image.src = src
@@ -714,7 +692,9 @@ function drawMergePiece(piece: Piece) {
const spawnProgress = Math.min(1, (elapsed - piece.spawnAt) / 0.22)
const spawnScale = spawnProgress >= 1 ? 1 : easeOutBack(spawnProgress)
const radius =
piece.radius * spawnScale * (isRainbow ? 1 + 0.05 * Math.sin(elapsed * 3.2 + piece.id) : 1)
piece.radius *
spawnScale *
(isRainbow ? 1 + 0.05 * Math.sin(elapsed * 3.2 + piece.id) : 1)
context.save()
context.translate(piece.x, piece.y)
const hue = (elapsed * 55) % 360
@@ -812,7 +792,9 @@ function draw() {
context.beginPath()
for (let x = 0; x <= width; x += 8) {
const waveY =
surfaceY + Math.sin(x * 0.045 + elapsed * 1.8) * 2.5 + Math.sin(x * 0.013 + elapsed * 0.9) * 2
surfaceY +
Math.sin(x * 0.045 + elapsed * 1.8) * 2.5 +
Math.sin(x * 0.013 + elapsed * 0.9) * 2
if (x === 0) context.moveTo(x, waveY)
else context.lineTo(x, waveY)
}
@@ -825,12 +807,7 @@ function draw() {
const progress = Math.min(1, (elapsed - waveStart) / 0.8)
const waveY = height * (1 - progress)
const bandHeight = Math.max(22, height * 0.1)
const gradient = context.createLinearGradient(
0,
waveY - bandHeight,
0,
waveY + bandHeight * 0.5,
)
const gradient = context.createLinearGradient(0, waveY - bandHeight, 0, waveY + bandHeight * 0.5)
gradient.addColorStop(0, 'rgba(255, 255, 255, 0)')
gradient.addColorStop(0.55, 'rgba(96, 178, 255, 0.5)')
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
@@ -1,44 +1,318 @@
<template>
<canvas ref="canvas" class="about-scene" aria-hidden="true" />
<canvas id="about_scene" class="size-full" />
</template>
<script setup lang="ts">
import { onActivated, onDeactivated, onMounted, onScopeDispose, useTemplateRef, watch } from 'vue'
import * as THREE from 'three'
import { onMounted, onScopeDispose, useTemplateRef } from 'vue'
import { createWitherVictoryScene } from './about-scene/wither-victory'
import { useTheming } from '@/store/theme'
const props = defineProps<{ paused?: boolean }>()
const canvas = useTemplateRef<HTMLCanvasElement>('canvas')
let scene: ReturnType<typeof createWitherVictoryScene> | undefined
let active = true
function updatePlayback() {
scene?.setPaused(!active || Boolean(props.paused))
const themeStore = useTheming()
function isDarkMode() {
if (themeStore.selectedTheme == 'system') {
return matchMedia('(prefers-color-scheme: dark)').matches
}
return ['dark', 'oled'].includes(themeStore.selectedTheme)
}
onMounted(() => {
if (!canvas.value) return
scene = createWitherVictoryScene(canvas.value)
updatePlayback()
scene.ready.catch((error: unknown) => console.warn('Unable to load about-page scene', error))
})
watch(() => props.paused, updatePlayback)
onActivated(() => {
active = true
updatePlayback()
})
onDeactivated(() => {
active = false
updatePlayback()
})
onScopeDispose(() => scene?.dispose())
</script>
function createTip(position: THREE.Vector3, color: THREE.ColorRepresentation = 0x00ff00) {
const tipGeometry = new THREE.SphereGeometry(2)
const tipMaterial = new THREE.MeshBasicMaterial({ color })
const tipMesh = new THREE.Mesh(tipGeometry, tipMaterial)
tipMesh.position.copy(position)
return tipMesh
}
<style scoped>
.about-scene {
display: block;
width: 100%;
height: 100%;
background: #0c1729;
function createWaterMaterial(): THREE.ShaderMaterial {
return new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
seed: { value: Math.random() * 83 + 17 },
color: { value: new THREE.Color(0.3, 0.3, 1.0) },
},
transparent: true,
vertexShader: `#define WATER_VERT
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `#define WATER_FRAG
uniform float time;
uniform float seed;
uniform vec3 color;
varying vec2 vUv;
vec2 randomGradient(vec2 p) {
float n = sin(dot(p, vec2(127.1, 311.7)));
float angle = fract(n * 43758.5453123) * 6.28318530718 * seed;
return vec2(cos(angle), sin(angle));
}
float perlinNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
vec2 g1 = randomGradient(i);
vec2 g2 = randomGradient(i + vec2(1.0, 0.0));
vec2 g3 = randomGradient(i + vec2(0.0, 1.0));
vec2 g4 = randomGradient(i + vec2(1.0, 1.0));
vec2 d1 = f;
vec2 d2 = f - vec2(1.0, 0.0);
vec2 d3 = f - vec2(0.0, 1.0);
vec2 d4 = f - vec2(1.0, 1.0);
float v1 = dot(g1, d1);
float v2 = dot(g2, d2);
float v3 = dot(g3, d3);
float v4 = dot(g4, d4);
return mix(mix(v1, v2, u.x), mix(v3, v4, u.x), u.y);
}
void main() {
float height = 0.0;
height += perlinNoise(vec2(vUv.x * 10.0, time * 0.8)) * 0.3;
height += perlinNoise(vec2(vUv.x * 5.0, time * 0.4)) * 0.35;
height += perlinNoise(vec2(vUv.x * 2.5, time * 0.2)) * 0.15;
height += perlinNoise(vec2(vUv.x * 2.0, time * 0.2)) * 0.2;
height = clamp(height, -1.0, 1.0);
height = height * 0.8 + 0.6;
float thickness = 0.008;
if(vUv.y < height - thickness) {
float scalar = 1.0 - height + vUv.y;
scalar = scalar * scalar * scalar * 0.6;
gl_FragColor = vec4(color, scalar);
} else if(vUv.y > height + thickness) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
} else {
gl_FragColor = vec4(color, 1.0);
}
}`,
})
}
function createWater(material: THREE.ShaderMaterial, position: THREE.Vector3) {
const geometry = new THREE.PlaneGeometry(120, 16)
const waterMesh = new THREE.Mesh(geometry, material)
waterMesh.position.copy(position)
return waterMesh
}
function createCircleMaterial(): THREE.ShaderMaterial {
return new THREE.ShaderMaterial({
uniforms: {
color: { value: new THREE.Color(0.3, 0.3, 1.0) },
},
transparent: true,
vertexShader: `#define CIRCLE_VERT
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `#define CIRCLE_FRAG
varying vec2 vUv;
uniform vec3 color;
float remap(float v, float inMin, float inMax, float outMin, float outMax) {
float t = (v - inMin) / (inMax - inMin);
return outMin + (outMax - outMin) * t;
}
void main() {
float dis = distance(vUv, vec2(0.5));
float thickness = 0.05;
gl_FragColor = vec4(0.0);
if(dis <= 0.35 && dis >= 0.35 - thickness) {
gl_FragColor = vec4(color, 0.8);
} else {
// emissive
float scalar = 0.0;
if(dis >= 0.35) {
scalar = clamp(0.5 - dis, 0.0, 0.15);
scalar = remap(scalar, 0.0, 0.15, 0.0, 1.0);
} else {
scalar = clamp(0.35 - dis, 0.0, 0.5);
scalar = remap(scalar, 0.0, 0.35, 1.0, 0.0);
}
scalar = clamp(scalar * scalar * scalar, 0.0, 1.0);
gl_FragColor = vec4(color, scalar);
}
}`,
})
}
function createCircle(material: THREE.ShaderMaterial, position: THREE.Vector3) {
const geometry = new THREE.PlaneGeometry(0.6, 0.6)
const mesh = new THREE.Mesh(geometry, material)
mesh.position.copy(position)
return mesh
}
function main() {
const canvas = document.querySelector<HTMLCanvasElement>('#about_scene')
if (!canvas) return console.error('No canvas')
let isUpdating = true
const canvasSize = new THREE.Vector2(
canvas.getBoundingClientRect().width,
canvas.getBoundingClientRect().height,
)
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
canvas,
})
renderer.setPixelRatio(devicePixelRatio)
renderer.setSize(canvasSize.x, canvasSize.y)
const deltaClock = new THREE.Clock()
const elapseClock = new THREE.Clock()
deltaClock.start()
elapseClock.start()
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(30, canvasSize.x / canvasSize.y, 1, 3000)
camera.fov *= 0.7
camera.position.set(-10, 5, 30)
camera.lookAt(0, 0, 0)
const ambientLight = new THREE.AmbientLight(0xffffff)
scene.add(ambientLight)
const dirLight = new THREE.DirectionalLight(0xffffff, 4.0)
dirLight.position.set(-30, 30, 28)
scene.add(dirLight)
scene.add(createTip(dirLight.position, 0xffff00))
scene.add(createTip(camera.position))
const accentColor =
getComputedStyle(document.documentElement).getPropertyValue('--color-brand').trim() || '#4444ff'
const waterMaterial = createWaterMaterial()
waterMaterial.uniforms.color.value = new THREE.Color(accentColor).multiplyScalar(
isDarkMode() ? 0.6 : 2.4,
)
// .multiplyScalar(0.6)
// .multiplyScalar(2.4)
scene.add(createWater(waterMaterial, new THREE.Vector3(0, -6.5, 4)))
scene.add(createWater(waterMaterial, new THREE.Vector3(2, -8, -10)))
scene.add(createWater(waterMaterial, new THREE.Vector3(16, -8, -26)))
// 静止的下界之星(替换原动态美西螈)
const starTexture = new THREE.TextureLoader().load('/models/netherstar.png')
starTexture.colorSpace = THREE.SRGBColorSpace
const starMaterial = new THREE.SpriteMaterial({ map: starTexture, transparent: true })
const starSprite = new THREE.Sprite(starMaterial)
starSprite.scale.set(8, 8, 1)
starSprite.position.set(0, -2.5, 0)
scene.add(starSprite)
let updateGLTF = (_deltaTime: number, _elapsedTime: number) => {}
const circleMaterial = createCircleMaterial()
circleMaterial.uniforms.color.value = new THREE.Color(accentColor).multiplyScalar(
isDarkMode() ? 1.2 : 3,
)
// .multiplyScalar(1.2)
// .multiplyScalar(3)
let circleMeshList: THREE.Mesh[] = []
let nextCircleCreateTime = 0.0
function updateCircle(deltaTime: number, elapsedTime: number) {
circleMeshList = circleMeshList.filter((m) => {
m.position.y += deltaTime * 2.0
if (m.position.y >= 32) {
scene.remove(m)
return false
}
return true
})
if (elapsedTime >= nextCircleCreateTime) {
nextCircleCreateTime = elapsedTime + Math.random() * 0.8
const circle = createCircle(
circleMaterial,
new THREE.Vector3(Math.random() * 64 - 32 - 12, -20, Math.random() * 6 + 1),
)
scene.add(circle)
circleMeshList.push(circle)
}
}
function animate(_time: number) {
if (isUpdating === false) return
requestAnimationFrame(animate)
const deltaTime = deltaClock.getDelta()
const elapsedTime = elapseClock.getElapsedTime()
updateGLTF(deltaTime, elapsedTime)
waterMaterial.uniforms.time.value = elapsedTime
updateCircle(deltaTime, elapsedTime)
renderer.render(scene, camera)
}
animate(Date.now())
const originCameraPosition = camera.position.clone()
function onMouseMove(event: MouseEvent) {
const mouseXOffsetRatio = ((event.clientX - innerWidth / 2) / innerWidth) * 2
const mouseYOffsetRatio = ((event.clientY - innerHeight / 2) / innerHeight) * 2
const newPosition = new THREE.Vector3(
originCameraPosition.x + mouseXOffsetRatio,
originCameraPosition.y + mouseYOffsetRatio * 0.5,
originCameraPosition.z,
)
camera.position.copy(newPosition)
}
function updateSize() {
if (!isUpdating) return
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const w = rect.width
const h = rect.height
if (w > 0 && h > 0) {
renderer.setSize(w, h)
camera.aspect = w / h
camera.updateProjectionMatrix()
}
}
const resizeObserver = new ResizeObserver(updateSize)
resizeObserver.observe(canvas)
addEventListener('mousemove', onMouseMove)
onScopeDispose(() => {
isUpdating = false
removeEventListener('mousemove', onMouseMove)
resizeObserver.disconnect()
deltaClock.stop()
elapseClock.stop()
renderer.dispose()
})
}
onMounted(main)
</script>
<style>
#about_scene {
background: linear-gradient(
to bottom,
color-mix(in srgb, var(--color-brand) 36%, var(--surface-1) 100%),
#00000000 40%
);
}
</style>
@@ -1,292 +0,0 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { createRequire, registerHooks, stripTypeScriptTypes } from 'node:module'
import test from 'node:test'
import { createRenderer, h, nextTick, Suspense } from 'vue'
import {
receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
} from '../../composables/skin-site-session.ts'
import { useNetworkStatus } from '../../composables/useNetworkStatus.ts'
// Exercise the real component's setup and reactive account list. Only platform,
// navigation, and visual dependencies are replaced; auth/session logic stays real.
const require = createRequire(import.meta.url)
const { parse, compileScript } = require('vue/compiler-sfc')
const componentUrl = new URL('./AccountsCard.vue', import.meta.url).href
const stubs: Record<string, string> = {
'@modrinth/assets': `export const CopyIcon={}, LogInIcon={}, PlusIcon={}, RadioButtonCheckedIcon={}, RadioButtonIcon={}, RefreshCwIcon={}, SpinnerIcon={}, TrashIcon={}`,
'@modrinth/ui': `export const Accordion={}, Avatar={}, ButtonStyled={}; export const defineMessages=x=>x; export const injectNotificationManager=()=>({handleError:()=>{}}); export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})`,
'@tanstack/vue-query': `export const useQueryClient=()=>({refetchQueries:async()=>{}})`,
'@tauri-apps/api/event': `export const listen=async()=>()=>{}`,
'vue-router': `export const useRoute=()=>({fullPath:'/'}); export const useRouter=()=>({push:async()=>{}})`,
'@/assets/netherstar.png': `export default 'logo'`,
'@/components/ui/MinecraftLoginModal.vue': `export default {}`,
'@/helpers/events': `export const process_listener=async()=>()=>{}`,
'@/helpers/rendering/batch-skin-renderer.ts': `export const getPlayerHeadUrl=async()=> 'head'`,
'@/helpers/skins': `export const get_available_skins=async()=>[]`,
'@/store/error.js': `export const handleSevereError=()=>{}`,
'@/store/state': `export const useTheming=()=>({homeLayout:'standard'})`,
}
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier in stubs) {
return {
url: `data:text/javascript,${encodeURIComponent(stubs[specifier])}`,
shortCircuit: true,
}
}
if (specifier.startsWith('@/')) {
const file = new URL(`../../${specifier.slice(2)}`, import.meta.url)
if (!/\.(ts|js)$/.test(file.pathname))
file.pathname += specifier === '@/helpers/auth' ? '.js' : '.ts'
return nextResolve(file.href, context)
}
return nextResolve(specifier, context)
},
load(url, context, nextLoad) {
if (url !== componentUrl) return nextLoad(url, context)
const { descriptor } = parse(readFileSync(new URL(url), 'utf8'))
return {
format: 'module',
source: stripTypeScriptTypes(compileScript(descriptor, { id: 'accounts-card-test' }).content),
shortCircuit: true,
}
},
})
const { default: AccountsCard } = await import(componentUrl)
hooks.deregister()
type TestNode = Record<string, unknown>
type TestAccount = {
account_type: 'microsoft' | 'yggdrasil'
profile: { id: string; name: string }
yggdrasil?: { api_root: string; server_name: string; login: string }
}
type CardState = {
accounts: TestAccount[]
defaultUser: string | undefined
selectedAccount: TestAccount | undefined
setAccount: (account: TestAccount) => Promise<void>
getAccountAvatarUrl: (account: TestAccount) => string | undefined
}
const renderer = createRenderer<TestNode, TestNode>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
insert() {},
remove() {},
setText() {},
setElementText() {},
patchProp() {},
parentNode: () => null,
nextSibling: () => null,
})
const flush = async () => {
await new Promise((resolve) => setImmediate(resolve))
await nextTick()
}
const firstId = '12345678-1234-1234-1234-123456789abc'
const secondId = '22345678-1234-1234-1234-123456789abc'
async function fixture(hasOfficial = true) {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
const stored: TestAccount[] = hasOfficial
? [{ account_type: 'microsoft', profile: { id: 'official', name: 'Official' } }]
: []
let selected: string | undefined = hasOfficial ? 'official' : undefined
let rejectLogin = false
let loginCalls = 0
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type === 'starlight-pack-token-request')
message({
type: 'starlight-pack-token-result',
requestId: data.requestId,
token: 'fixture.jwt',
})
if (data.type === 'starlight-skin-players-request')
message({
type: 'starlight-skin-players-result',
requestId: data.requestId,
ok: true,
players: [
{ uuid: firstId, name: 'First', isMojang: false, skinState: 'empty' },
{ uuid: secondId, name: 'Second', isMojang: false, skinState: 'empty' },
],
})
},
} as unknown as Window
function message(data: unknown) {
receiveSkinSiteMessage({ origin: SKIN_SITE_ORIGIN, source: frame, data } as MessageEvent, frame)
}
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } })
useNetworkStatus().refreshBrowserOffline()
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
__TAURI_INTERNALS__: {
async invoke(command: string, args: Record<string, unknown>) {
if (command === 'plugin:auth|get_default_user') return selected
if (command === 'plugin:auth|get_users') return stored
if (command === 'plugin:auth|login_skin_site_player') {
loginCalls++
assert.equal(args.userId, 'owner')
assert.equal(args.token, 'fixture.jwt')
assert.ok(typeof args.playerId === 'string')
if (rejectLogin) throw new Error('Login failed')
stored.push({
profile: { id: args.playerId, name: args.playerId === firstId ? 'First' : 'Second' },
account_type: 'yggdrasil',
yggdrasil: {
api_root: `${SKIN_SITE_ORIGIN}/yggdrasil`,
server_name: 'StarLight',
login: 'owner',
},
})
return stored[stored.length - 1]
}
if (command === 'plugin:auth|set_default_user') {
assert.ok(typeof args.user === 'string')
assert.ok(stored.some((account) => account.profile.id === args.user))
selected = args.user
return
}
throw new Error(`Unexpected command: ${command}`)
},
},
},
})
resetSkinSiteSession()
setSkinSiteFrame(frame)
const node = h({ ...AccountsCard, render: () => null })
const app = renderer.createApp({ render: () => h(Suspense, null, { default: () => node }) })
app.mount({})
await flush()
return {
state: (node.component! as unknown as { setupState: CardState }).setupState,
login() {
message({
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'owner', username: 'Owner' },
})
},
signOut() {
message({ type: 'starlight-skin-session', status: 'signed-out' })
},
failLogin(value: boolean) {
rejectLogin = value
},
get selected() {
return selected
},
get loginCalls() {
return loginCalls
},
close() {
app.unmount()
resetSkinSiteSession()
setSkinSiteFrame(null)
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
else Reflect.deleteProperty(globalThis, 'window')
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator)
else Reflect.deleteProperty(globalThis, 'navigator')
},
}
}
test('sidebar offers every skin-site player before any game authentication or launch', async () => {
const view = await fixture(false)
try {
view.failLogin(true)
view.login()
await flush()
assert.deepEqual(
view.state.accounts.map((account) => account.profile.id),
[firstId, secondId],
)
assert.equal(view.loginCalls, 0)
assert.equal(view.selected, undefined)
} finally {
view.close()
}
})
test('choosing an unused skin-site player authenticates and persists the default without a launch', async () => {
const view = await fixture()
try {
view.login()
await flush()
await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === secondId)!,
)
assert.equal(view.selected, secondId)
assert.equal(view.state.selectedAccount?.profile.id, secondId)
assert.equal(view.state.accounts.length, 3)
assert.equal(view.loginCalls, 1)
} finally {
view.close()
}
})
test('failed player authentication preserves the default and allows retry', async () => {
const view = await fixture()
try {
view.login()
await flush()
view.failLogin(true)
await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === secondId)!,
)
assert.equal(view.selected, 'official')
assert.equal(view.state.defaultUser, 'official')
view.failLogin(false)
await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === secondId)!,
)
assert.equal(view.selected, secondId)
} finally {
view.close()
}
})
test('signing out removes unregistered choices while keeping saved accounts', async () => {
const view = await fixture()
try {
view.login()
await flush()
await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === firstId)!,
)
view.signOut()
await flush()
assert.deepEqual(
view.state.accounts.map((account) => account.profile.id),
[firstId, 'official'],
)
assert.equal(view.selected, firstId)
view.login()
await flush()
assert.equal(view.state.accounts.length, 3)
assert.equal(view.loginCalls, 1)
} finally {
view.close()
}
})
test('a new player without a skin can render the shared empty avatar', async () => {
const view = await fixture(false)
try {
view.login()
await flush()
assert.equal(view.state.getAccountAvatarUrl(view.state.accounts[0]), undefined)
} finally {
view.close()
}
})
@@ -1,106 +1,10 @@
<template>
<div v-if="skinSiteUser" class="flex items-center gap-3 mt-2 p-3 rounded-xl bg-button-bg">
<Avatar :src="axolotlLogo" size="36px" />
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-contrast">{{ skinSiteUser.username }}</span>
<span class="text-secondary text-xs">{{ formatMessage(messages.skinSiteSignedIn) }}</span>
</div>
</div>
<p v-else-if="skinSiteStatus === 'checking'" class="text-sm text-secondary">
{{ formatMessage(messages.skinSiteChecking) }}
</p>
<p v-else-if="skinSiteStatus === 'error'" class="text-sm text-secondary">
{{ formatMessage(messages.skinSiteSyncError) }}
</p>
<div
v-if="skinSiteUser"
class="mt-2 overflow-hidden rounded-xl border border-solid border-surface-5 bg-button-bg"
>
<div class="flex items-center justify-between gap-3 px-3 py-2">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.skinSitePlayers, { count: skinSitePlayers.length }) }}
</span>
<button
v-if="skinSitePlayersStatus === 'error'"
type="button"
class="button-base flex cursor-pointer items-center gap-1 border-0 bg-transparent p-1 text-xs text-secondary hover:text-brand"
@click="refreshSkinSitePlayers()"
>
<RefreshCwIcon class="h-4 w-4" />
{{ formatMessage(messages.retrySkinSitePlayers) }}
</button>
<SpinnerIcon
v-else-if="skinSitePlayersStatus === 'checking'"
class="h-4 w-4 animate-spin text-secondary"
/>
</div>
<div v-if="skinSitePlayers.length > 0" class="border-0 border-t border-solid border-surface-5">
<button
v-for="player in skinSitePlayers"
:key="player.uuid"
type="button"
class="flex w-full cursor-pointer items-center gap-2 border-0 border-b border-solid border-surface-5 px-3 py-2 text-left transition-colors last:border-b-0 hover:bg-button-hover"
:class="selectedSkinSitePlayerId === player.uuid ? 'bg-button-hover' : 'bg-transparent'"
:aria-pressed="selectedSkinSitePlayerId === player.uuid"
@click="setSkinSitePlayer(player.uuid)"
>
<Avatar
v-if="player.skinState === 'ready' && player.headDataUrl"
:src="player.headDataUrl"
size="32px"
pixelated
:unframed-natural-width="36"
/>
<div
v-else
class="h-8 w-8 shrink-0 rounded-sm border-2 border-dashed border-secondary/45 bg-surface-5/50 text-secondary"
style="
background-image:
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor);
background-position:
6px 8px,
22px 8px,
8px 20px;
background-repeat: no-repeat;
background-size:
4px 4px,
4px 4px,
16px 2px;
"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 truncate text-sm text-primary">{{ player.name }}</span>
</button>
</div>
<p v-else-if="skinSitePlayersStatus === 'ready'" class="m-0 px-3 pb-3 text-sm text-secondary">
{{ formatMessage(messages.noSkinSitePlayers) }}
</p>
<p v-else-if="skinSitePlayersStatus === 'error'" class="m-0 px-3 pb-3 text-sm text-secondary">
{{ formatMessage(messages.skinSitePlayersError) }}
</p>
</div>
<ButtonStyled v-if="accounts.length > 0 && !offline && !skinSiteUser" color="brand">
<button class="mt-2 w-full" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
<LogInIcon />
{{ formatMessage(messages.signInToStarlight) }}
</button>
</ButtonStyled>
<div
v-if="offline"
class="flex flex-col gap-1 bg-highlight-orange border border-solid border-orange rounded-xl p-3 mt-2"
>
<span class="font-semibold text-contrast">{{ formatMessage(messages.offlineMode) }}</span>
<span class="text-sm text-secondary">
{{
formatMessage(
browserOffline
? messages.offlineModeNoInternetDescription
: messages.offlineModeServerUnavailableDescription,
)
}}
</span>
<span class="text-sm text-secondary">{{ formatMessage(messages.offlineModeDescription) }}</span>
<ButtonStyled>
<button class="mt-1" :disabled="refreshingNetwork" @click="refreshNetworkStatus()">
<SpinnerIcon v-if="refreshingNetwork" class="animate-spin" />
@@ -113,18 +17,18 @@
v-if="accounts.length === 0"
class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2"
>
<span v-if="skinSiteStatus === 'signed-out'">{{ formatMessage(messages.notSignedIn) }}</span>
<ButtonStyled v-if="!offline && !skinSiteUser" color="brand">
<button color="primary" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
<span>{{ formatMessage(messages.notSignedIn) }}</span>
<ButtonStyled v-if="!offline" color="brand">
<button color="primary" :disabled="loginDisabled" @click="login()">
<LogInIcon v-if="!loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
{{ formatMessage(messages.signInToStarlight) }}
{{ formatMessage(messages.signInToMinecraft) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="!offline">
<button :disabled="loginDisabled" @click="login()">
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
<PlusIcon />
{{ formatMessage(messages.addMicrosoftAccount) }}
{{ formatMessage(messages.addThirdPartyAccount) }}
</button>
</ButtonStyled>
</div>
@@ -164,7 +68,6 @@
<div v-for="account in accounts" :key="account.profile.id" class="flex gap-1 items-center">
<button
class="flex items-center flex-shrink flex-grow overflow-clip gap-2 p-2 border-0 bg-transparent cursor-pointer button-base min-w-0"
:disabled="accountSelectionPending"
@click="setAccount(account)"
>
<RadioButtonCheckedIcon
@@ -222,7 +125,6 @@
<CopyIcon />
</button>
<button
v-if="!account.skin_site_user"
v-tooltip="formatMessage(messages.removeAccount)"
type="button"
class="button-base border-0 bg-transparent p-1.5 cursor-pointer text-secondary hover:text-red"
@@ -240,10 +142,109 @@
{{ formatMessage(messages.addMicrosoftAccount) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
<PlusIcon />
{{ formatMessage(messages.addThirdPartyAccount) }}
</button>
</ButtonStyled>
</div>
</div>
</Accordion>
<MinecraftLoginModal ref="minecraftLoginModal" @complete="onMicrosoftLogin" />
<ModalWrapper ref="yggdrasilAccountModal" :header="formatMessage(messages.thirdPartyModalTitle)">
<div class="flex min-w-[24rem] flex-col gap-4">
<p class="m-0 text-secondary">{{ formatMessage(messages.thirdPartyModalDescription) }}</p>
<div v-if="savedYggdrasilLogins.length > 0" class="flex flex-col gap-2">
<span class="font-semibold">{{ formatMessage(messages.savedLogins) }}</span>
<div
v-for="savedLogin in savedYggdrasilLogins"
:key="`${savedLogin.api_root}:${savedLogin.login}`"
class="flex items-center gap-1 rounded-xl bg-surface-3 p-1"
>
<button
class="flex min-w-0 flex-grow flex-col items-start border-0 bg-transparent px-3 py-2 text-left cursor-pointer"
:disabled="loginDisabled"
@click="selectSavedYggdrasilLogin(savedLogin)"
>
<span class="w-full truncate font-semibold text-primary">{{ savedLogin.login }}</span>
<span class="w-full truncate text-xs text-secondary">{{ savedLogin.api_root }}</span>
</button>
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
<button
v-tooltip="formatMessage(messages.removeSavedLogin)"
:disabled="loginDisabled"
@click="removeSavedYggdrasilLogin(savedLogin)"
>
<TrashIcon />
</button>
</ButtonStyled>
</div>
</div>
<label class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.apiRootLabel) }}
<StyledInput
v-model="yggdrasilApiRoot"
:disabled="true"
readonly
:placeholder="formatMessage(messages.apiRootPlaceholder)"
inputmode="url"
@blur="loadRememberedYggdrasilPassword()"
/>
</label>
<label class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.accountLabel) }}
<StyledInput
v-model="yggdrasilLogin"
:disabled="loginDisabled"
:placeholder="formatMessage(messages.accountPlaceholder)"
autocomplete="username"
@blur="loadRememberedYggdrasilPassword()"
/>
</label>
<label class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.passwordLabel) }}
<StyledInput
v-model="yggdrasilPassword"
type="password"
:disabled="loginDisabled"
autocomplete="current-password"
@keyup.enter="addYggdrasilAccount()"
/>
</label>
<Checkbox
v-model="rememberYggdrasilPassword"
:disabled="loginDisabled"
:label="formatMessage(messages.rememberPassword)"
/>
<div class="input-group push-right">
<ButtonStyled>
<button :disabled="loginDisabled" @click="yggdrasilAccountModal?.hide()">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="loginDisabled || !yggdrasilFormValid" @click="addYggdrasilAccount()">
<SpinnerIcon v-if="loginDisabled" class="animate-spin" />
<LogInIcon v-else />
{{ formatMessage(messages.signInButton) }}
</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="yggdrasilProfileModal" :header="formatMessage(messages.selectProfileTitle)">
<div class="flex min-w-[22rem] flex-col gap-2">
<p class="m-0 mb-2 text-secondary">{{ formatMessage(messages.selectProfileDescription) }}</p>
<ButtonStyled v-for="profile in pendingYggdrasilProfiles" :key="profile.id" class="w-full">
<button :disabled="loginDisabled" @click="selectYggdrasilProfile(profile.id)">
<SpinnerIcon v-if="loginDisabled" class="animate-spin" />
<RadioButtonIcon v-else />
{{ profile.name }}
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
@@ -261,59 +262,48 @@ import {
Accordion,
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { listen } from '@tauri-apps/api/event'
import type { Ref } from 'vue'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import axolotlLogo from '@/assets/netherstar.png'
import steveSkinTexture from '@/assets/skins/steve.png?inline'
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
import {
openSkinSiteLogin,
requestSkinSitePlayers,
selectedSkinSitePlayerId,
selectSkinSitePlayer,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { compareMinecraftAccounts } from '@/helpers/accounts'
import {
begin_yggdrasil_login,
delete_yggdrasil_password,
finish_yggdrasil_login,
get_default_user,
get_yggdrasil_password,
list_yggdrasil_saved_logins,
login as loginToMinecraft,
remove_user,
set_default_user,
set_yggdrasil_password,
users,
} from '@/helpers/auth'
import { process_listener } from '@/helpers/events'
import { authenticateInstancePlayer } from '@/helpers/instance-player'
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins'
import { get_available_skins } from '@/helpers/skins'
import { handleSevereError } from '@/store/error.js'
import { useTheming } from '@/store/state'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const { browserOffline, offline, refreshBrowserOffline } = useNetworkStatus()
const { offline, refreshBrowserOffline } = useNetworkStatus()
const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
const themeStore = useTheming()
async function goToSkinSiteLogin() {
openSkinSiteLogin()
// The login action must reveal the iframe even if Minimal Home was selected.
themeStore.homeLayout = 'standard'
await router.push('/').catch(handleError)
}
const refreshingNetwork = ref(false)
/**
@@ -355,45 +345,25 @@ type MinecraftCredential = {
server_name: string
login: string
}
skin_site_user?: string
head?: string
}
type YggdrasilProfile = {
id: string
name: string
}
type SavedYggdrasilLogin = {
api_root: string
login: string
}
type YggdrasilLoginResult =
| { status: 'complete'; credentials: MinecraftCredential }
| { status: 'select_profile'; flow_id: string; profiles: YggdrasilProfile[] }
const STARLIGHT_YGGDRASIL_API_ROOT = 'https://skin.starlight.cool/yggdrasil'
const storedAccounts: Ref<MinecraftCredential[]> = ref([])
const accounts = computed(() => {
// A site's player list is already authoritative for available choices.
// Game credentials are created only when the user selects a player.
const byId = new Map(
storedAccounts.value.map((account) => [
account.profile.id.replaceAll('-', '').toLowerCase(),
account,
]),
)
if (skinSiteStatus.value === 'signed-in' && skinSiteUser.value) {
for (const player of skinSitePlayers.value) {
if (player.isMojang) continue
const id = player.uuid.replaceAll('-', '').toLowerCase()
const stored = byId.get(id)
if (stored && stored.account_type !== 'yggdrasil') continue
byId.set(id, {
...stored,
account_type: 'yggdrasil',
profile: { ...stored?.profile, id: stored?.profile.id ?? player.uuid, name: player.name },
yggdrasil: {
api_root: STARLIGHT_YGGDRASIL_API_ROOT,
server_name: 'StarLight',
login: skinSiteUser.value.uuid,
},
skin_site_user: skinSiteUser.value.uuid,
head: player.headDataUrl,
})
}
}
return [...byId.values()].sort(compareMinecraftAccounts)
})
const accountSelectionPending = ref(false)
const accounts: Ref<MinecraftCredential[]> = ref([])
const loginDisabled = ref(false)
const defaultUser = ref<string | undefined>()
const equippedSkin = ref<Skin | null>(null)
@@ -405,13 +375,34 @@ let refreshGeneration = 0
let headRefreshTimer: ReturnType<typeof setTimeout> | undefined
let defaultUserUpdateQueue = Promise.resolve()
const minecraftLoginModal = ref<InstanceType<typeof MinecraftLoginModal> | null>(null)
async function refreshSkinSitePlayers() {
await requestSkinSitePlayers().catch(() => {})
const yggdrasilAccountModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
const yggdrasilProfileModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
const yggdrasilApiRoot = ref(STARLIGHT_YGGDRASIL_API_ROOT)
const yggdrasilLogin = ref('')
const yggdrasilPassword = ref('')
const rememberYggdrasilPassword = ref(true)
const savedYggdrasilLogins = ref<SavedYggdrasilLogin[]>([])
const pendingYggdrasilFlowId = ref<string | undefined>()
const pendingYggdrasilProfiles = ref<YggdrasilProfile[]>([])
const yggdrasilFormValid = computed(
() =>
yggdrasilApiRoot.value.trim().length > 0 &&
yggdrasilLogin.value.trim().length > 0 &&
yggdrasilPassword.value.length > 0,
)
function createSkinHeadDataUrl(textureUrl: string) {
const escapedTextureUrl = textureUrl
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 8 8" shape-rendering="crispEdges"><image href="${escapedTextureUrl}" x="-8" y="-8" width="64" height="64" style="image-rendering:pixelated"/><image href="${escapedTextureUrl}" x="-40" y="-8" width="64" height="64" style="image-rendering:pixelated"/></svg>`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}
function setSkinSitePlayer(playerId: string) {
selectSkinSitePlayer(playerId)
}
const defaultSteveHeadUrl = createSkinHeadDataUrl(steveSkinTexture)
const HEAD_REFRESH_RETRY_DELAYS = [1500, 5000, 15000, 30000] as const
const HEAD_REFRESH_CONTINUOUS_DELAY = 60_000
@@ -426,7 +417,7 @@ function hasResolvedAccountHead(account: MinecraftCredential) {
}
function hasMissingAccountHeads() {
return storedAccounts.value.some(
return accounts.value.some(
(account) => account.account_type !== 'offline' && !hasResolvedAccountHead(account),
)
}
@@ -460,7 +451,7 @@ async function refreshValues(headRefreshAttempt = 0) {
defaultUser.value = selectedUser
if (offline.value && selectedUser) {
await persistDefaultUser(selectedUser).catch(handleError)
await persistDefaultUser(selectedUser)
if (generation !== refreshGeneration) return
}
const userList = await users(offline.value).catch(handleError)
@@ -469,7 +460,7 @@ async function refreshValues(headRefreshAttempt = 0) {
// MinecraftCredential but the TS types from the Tauri IPC bridge do not
// carry this refinement. The double cast is deliberate — the shape is
// correct and verified at runtime by the backend.
storedAccounts.value = Array.isArray(userList)
accounts.value = Array.isArray(userList)
? [...(userList as unknown as MinecraftCredential[])].filter(
(account) =>
account.account_type === 'microsoft' ||
@@ -477,7 +468,8 @@ async function refreshValues(headRefreshAttempt = 0) {
account.yggdrasil?.api_root.replace(/\/+$/, '') === STARLIGHT_YGGDRASIL_API_ROOT),
)
: []
await renderAccountHeads(storedAccounts.value, generation)
accounts.value.sort(compareMinecraftAccounts)
await renderAccountHeads(accounts.value, generation)
if (generation !== refreshGeneration) return
try {
const skins = await get_available_skins()
@@ -615,7 +607,6 @@ async function renderAccountHeads(accountList: MinecraftCredential[], generation
const avatarUrl = computed(() => {
if (selectedAccount.value) {
if (selectedAccount.value.head) return selectedAccount.value.head
const cachedHeadUrl = accountHeadUrlCache.value.get(selectedAccount.value.profile.id)
if (cachedHeadUrl) return cachedHeadUrl
}
@@ -625,11 +616,10 @@ const avatarUrl = computed(() => {
return cachedUrl
}
}
return selectedAccount.value ? undefined : axolotlLogo
return selectedAccount.value ? defaultSteveHeadUrl : axolotlLogo
})
function getAccountAvatarUrl(account: MinecraftCredential) {
if (account.head) return account.head
const cachedHeadUrl = accountHeadUrlCache.value.get(account.profile.id)
if (cachedHeadUrl) {
return cachedHeadUrl
@@ -643,53 +633,28 @@ function getAccountAvatarUrl(account: MinecraftCredential) {
return cachedUrl
}
}
return undefined
return defaultSteveHeadUrl
}
function persistDefaultUser(userId: string) {
const update = defaultUserUpdateQueue.then(async () => {
await set_default_user(userId)
await set_default_user(userId).catch(handleError)
})
defaultUserUpdateQueue = update.catch(() => {})
return update
}
async function setAccount(account: MinecraftCredential) {
if (accountSelectionPending.value) return
accountSelectionPending.value = true
const userId = account.profile.id
refreshGeneration += 1
try {
if (account.skin_site_user) {
await authenticateInstancePlayer({
id: userId,
name: account.profile.name,
account_type: account.account_type,
skin_site_user: account.skin_site_user,
})
}
await persistDefaultUser(userId)
selectSkinSitePlayer(null)
defaultUser.value = userId
equippedSkin.value = null
notifyAccountChange()
await refreshValues()
} catch (error) {
handleError(error as Error)
} finally {
accountSelectionPending.value = false
}
}
defaultUser.value = userId
equippedSkin.value = null
watch(
[skinSitePlayers, defaultUser, skinSiteUser],
([availablePlayers, selectedLocalUser]) => {
if (!selectedLocalUser && !selectedSkinSitePlayerId.value && availablePlayers.length > 0) {
selectSkinSitePlayer(availablePlayers[0].uuid)
}
},
{ immediate: true },
)
await persistDefaultUser(userId)
if (defaultUser.value !== userId) return
await refreshValues()
if (defaultUser.value === userId) notifyAccountChange()
}
async function login() {
if (offline.value) return
@@ -719,6 +684,185 @@ async function onMicrosoftLogin(account: MinecraftCredential) {
}
}
async function showYggdrasilAccountModal() {
yggdrasilApiRoot.value = STARLIGHT_YGGDRASIL_API_ROOT
yggdrasilLogin.value = ''
yggdrasilPassword.value = ''
rememberYggdrasilPassword.value = true
pendingYggdrasilFlowId.value = undefined
pendingYggdrasilProfiles.value = []
await loadSavedYggdrasilLogins()
yggdrasilAccountModal.value?.show()
}
async function loadSavedYggdrasilLogins() {
const storedLogins = await list_yggdrasil_saved_logins().catch(handleError)
const savedLogins: SavedYggdrasilLogin[] = Array.isArray(storedLogins)
? [...storedLogins].filter(
(savedLogin) => savedLogin.api_root.replace(/\/+$/, '') === STARLIGHT_YGGDRASIL_API_ROOT,
)
: []
const savedLoginKeys = new Set(
savedLogins.map((savedLogin) => `${savedLogin.api_root}\n${savedLogin.login}`),
)
for (const account of accounts.value) {
if (
!account.yggdrasil ||
account.yggdrasil.api_root.replace(/\/+$/, '') !== STARLIGHT_YGGDRASIL_API_ROOT
)
continue
const savedLogin = {
api_root: account.yggdrasil.api_root,
login: account.yggdrasil.login,
}
const key = `${savedLogin.api_root}\n${savedLogin.login}`
if (savedLoginKeys.has(key)) continue
try {
const password = await get_yggdrasil_password(savedLogin.api_root, savedLogin.login)
if (!password) continue
await set_yggdrasil_password(savedLogin.api_root, savedLogin.login, password)
savedLogins.push(savedLogin)
savedLoginKeys.add(key)
} catch {
continue
}
}
savedYggdrasilLogins.value = savedLogins.sort((left, right) =>
left.login.localeCompare(right.login),
)
}
async function selectSavedYggdrasilLogin(savedLogin: SavedYggdrasilLogin) {
if (loginDisabled.value) return
loginDisabled.value = true
try {
const password = await get_yggdrasil_password(savedLogin.api_root, savedLogin.login)
if (!password) {
await delete_yggdrasil_password(savedLogin.api_root, savedLogin.login)
await loadSavedYggdrasilLogins()
return
}
yggdrasilApiRoot.value = savedLogin.api_root
yggdrasilLogin.value = savedLogin.login
yggdrasilPassword.value = password
rememberYggdrasilPassword.value = true
} catch (error) {
handleError(error as Error)
} finally {
loginDisabled.value = false
}
}
async function removeSavedYggdrasilLogin(savedLogin: SavedYggdrasilLogin) {
if (loginDisabled.value) return
loginDisabled.value = true
try {
await delete_yggdrasil_password(savedLogin.api_root, savedLogin.login)
savedYggdrasilLogins.value = savedYggdrasilLogins.value.filter(
(entry) => entry.api_root !== savedLogin.api_root || entry.login !== savedLogin.login,
)
if (
yggdrasilApiRoot.value === savedLogin.api_root &&
yggdrasilLogin.value === savedLogin.login
) {
yggdrasilPassword.value = ''
rememberYggdrasilPassword.value = false
}
} catch (error) {
handleError(error as Error)
} finally {
loginDisabled.value = false
}
}
async function loadRememberedYggdrasilPassword() {
if (
!rememberYggdrasilPassword.value ||
!yggdrasilApiRoot.value.trim() ||
!yggdrasilLogin.value.trim() ||
yggdrasilPassword.value
)
return
try {
const password = await get_yggdrasil_password(
yggdrasilApiRoot.value.trim(),
yggdrasilLogin.value.trim(),
)
if (password) yggdrasilPassword.value = password
} catch {
return
}
}
async function persistYggdrasilPasswordPreference() {
try {
if (rememberYggdrasilPassword.value) {
await set_yggdrasil_password(
yggdrasilApiRoot.value.trim(),
yggdrasilLogin.value.trim(),
yggdrasilPassword.value,
)
} else {
await delete_yggdrasil_password(yggdrasilApiRoot.value.trim(), yggdrasilLogin.value.trim())
}
} catch (error) {
handleError(error as Error)
}
}
async function addYggdrasilAccount() {
if (!yggdrasilFormValid.value || loginDisabled.value) return
if (yggdrasilApiRoot.value.trim().replace(/\/+$/, '') !== STARLIGHT_YGGDRASIL_API_ROOT) return
loginDisabled.value = true
try {
const result = (await begin_yggdrasil_login(
yggdrasilApiRoot.value.trim(),
yggdrasilLogin.value.trim(),
yggdrasilPassword.value,
)) as YggdrasilLoginResult
if (result.status === 'complete') {
await persistYggdrasilPasswordPreference()
yggdrasilAccountModal.value?.hide()
await setAccount(result.credentials)
} else {
pendingYggdrasilFlowId.value = result.flow_id
pendingYggdrasilProfiles.value = result.profiles
yggdrasilAccountModal.value?.hide()
yggdrasilProfileModal.value?.show()
}
} catch (error) {
handleError(error as Error)
} finally {
loginDisabled.value = false
}
}
async function selectYggdrasilProfile(profileId: string) {
if (!pendingYggdrasilFlowId.value || loginDisabled.value) return
loginDisabled.value = true
try {
const account = (await finish_yggdrasil_login(
pendingYggdrasilFlowId.value,
profileId,
)) as MinecraftCredential
await persistYggdrasilPasswordPreference()
yggdrasilProfileModal.value?.hide()
await setAccount(account)
} catch (error) {
handleError(error as Error)
} finally {
loginDisabled.value = false
}
}
async function logout(account: MinecraftCredential) {
await remove_user(account.profile.id).catch(handleError)
await refreshValues()
@@ -737,7 +881,7 @@ async function copyAccountUuid(account: MinecraftCredential) {
}
}
const unlisten = await process_listener(async (e: { event: string }) => {
const unlisten = await process_listener(async (e) => {
if (e.event === 'launched') {
await refreshValues()
}
@@ -753,47 +897,14 @@ onUnmounted(() => {
})
const messages = defineMessages({
skinSiteSignedIn: {
id: 'minecraft-account.skin-site.signed-in',
defaultMessage: 'Signed in to StarLight Skin Site',
},
skinSiteChecking: {
id: 'minecraft-account.skin-site.checking',
defaultMessage: 'Checking skin site session…',
},
skinSiteSyncError: {
id: 'minecraft-account.skin-site.sync-error',
defaultMessage: 'Could not verify the skin site session. Retrying automatically.',
},
skinSitePlayers: {
id: 'minecraft-account.skin-site.players',
defaultMessage: 'Skin site players ({count})',
},
retrySkinSitePlayers: {
id: 'minecraft-account.skin-site.players.retry',
defaultMessage: 'Retry',
},
noSkinSitePlayers: {
id: 'minecraft-account.skin-site.players.empty',
defaultMessage: 'No player profiles are attached to this skin site account.',
},
skinSitePlayersError: {
id: 'minecraft-account.skin-site.players.error',
defaultMessage: 'Could not refresh the player list. Previously loaded players are kept.',
},
offlineMode: {
id: 'minecraft-account.offline-mode',
defaultMessage: 'Offline mode',
},
offlineModeNoInternetDescription: {
id: 'minecraft-account.offline-mode.description.no-internet',
offlineModeDescription: {
id: 'minecraft-account.offline-mode.description',
defaultMessage:
'It looks like this device may not be connected to the internet. You can currently only launch fully downloaded instances and will most likely be unable to connect to StarLight servers. Check your network connection. If you are using a proxy, try disabling or enabling it, then refresh the connection status below.',
},
offlineModeServerUnavailableDescription: {
id: 'minecraft-account.offline-mode.description.server-unavailable',
defaultMessage:
"Your internet connection is working, but StarLight's authentication server cannot be reached. The StarLight server may be undergoing maintenance, or your proxy may be misconfigured. If you are using a proxy, try disabling or enabling it, then refresh the connection status below. If that does not help, contact a server administrator in the StarLight community group to confirm the maintenance status.",
'Only offline accounts are available. You can launch fully downloaded instances.',
},
refreshNetworkStatus: {
id: 'minecraft-account.offline-mode.refresh',
@@ -805,11 +916,11 @@ const messages = defineMessages({
},
addMicrosoftAccount: {
id: 'minecraft-account.add-microsoft-account',
defaultMessage: 'Add your own Minecraft account',
defaultMessage: 'Add Microsoft account',
},
signInToStarlight: {
id: 'minecraft-account.sign-in-starlight',
defaultMessage: 'Sign in to StarLight Skin Site',
addThirdPartyAccount: {
id: 'minecraft-account.add-third-party-account',
defaultMessage: 'Add third-party account',
},
thirdPartyAccount: {
id: 'minecraft-account.third-party-account',
@@ -819,6 +930,58 @@ const messages = defineMessages({
id: 'minecraft-account.third-party-badge',
defaultMessage: 'StarLight skin',
},
thirdPartyModalTitle: {
id: 'minecraft-account.third-party-modal.title',
defaultMessage: 'Sign in with StarLight skin',
},
thirdPartyModalDescription: {
id: 'minecraft-account.third-party-modal.description',
defaultMessage: 'Sign in with your StarLight skin account.',
},
apiRootLabel: {
id: 'minecraft-account.third-party-modal.api-root',
defaultMessage: 'StarLight skin API address',
},
apiRootPlaceholder: {
id: 'minecraft-account.third-party-modal.api-root-placeholder',
defaultMessage: 'https://skin.starlight.cool/yggdrasil',
},
accountLabel: {
id: 'minecraft-account.third-party-modal.account',
defaultMessage: 'Account or email',
},
accountPlaceholder: {
id: 'minecraft-account.third-party-modal.account-placeholder',
defaultMessage: 'Enter your account or email',
},
passwordLabel: {
id: 'minecraft-account.third-party-modal.password',
defaultMessage: 'Password',
},
rememberPassword: {
id: 'minecraft-account.third-party-modal.remember-password',
defaultMessage: 'Save this login on this device',
},
savedLogins: {
id: 'minecraft-account.third-party-modal.saved-logins',
defaultMessage: 'Saved logins',
},
removeSavedLogin: {
id: 'minecraft-account.third-party-modal.remove-saved-login',
defaultMessage: 'Remove saved login',
},
signInButton: {
id: 'minecraft-account.third-party-modal.sign-in',
defaultMessage: 'Sign in',
},
selectProfileTitle: {
id: 'minecraft-account.third-party-profile.title',
defaultMessage: 'Select a profile',
},
selectProfileDescription: {
id: 'minecraft-account.third-party-profile.description',
defaultMessage: 'Choose the Minecraft profile to use with this account.',
},
offlineAccount: {
id: 'minecraft-account.offline-account',
defaultMessage: 'Offline Minecraft account',
@@ -901,6 +1064,10 @@ const messages = defineMessages({
id: 'minecraft-account.label',
defaultMessage: 'Minecraft account',
},
signInToMinecraft: {
id: 'minecraft-account.sign-in',
defaultMessage: 'Sign in to Minecraft',
},
loginTrouble: {
id: 'minecraft-login.trouble',
defaultMessage: 'Having trouble?',
@@ -137,11 +137,6 @@
<OnlineIndicatorIcon />
<span class="mr-auto text-contrast flex items-center gap-2">
{{ process.instance.name }}
<span class="text-xs text-secondary">{{
formatMessage(messages.windowStarted, {
time: new Date(process.start_time),
})
}}</span>
<StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" />
</span>
</button>
@@ -312,13 +307,11 @@ const notificationCenterShown = ref(false)
interface RunningProcess {
uuid: string
start_time: string
instance_id: string
instance: GameInstance
}
interface LoadingEventPayload {
total?: number | null
event: LoadingBar['bar_type']
loader_uuid: string
fraction: number | null
@@ -326,10 +319,6 @@ interface LoadingEventPayload {
}
const messages = defineMessages({
windowStarted: {
id: 'app.action-bar.window-started',
defaultMessage: 'Started {time, time, medium}',
},
offline: {
id: 'app.action-bar.offline',
defaultMessage: 'Offline',
@@ -356,7 +345,7 @@ const messages = defineMessages({
},
stopInstance: {
id: 'app.action-bar.stop-instance',
defaultMessage: 'Stop window',
defaultMessage: 'Stop instance',
},
viewLogs: {
id: 'app.action-bar.view-logs',
@@ -411,9 +400,8 @@ const refresh = async () => {
const processes = ((await getRunningProcesses().catch((error) => {
handleError(error)
return []
})) ?? []) as Array<{ uuid: string; instance_id: string; start_time: string }>
processes.sort((a, b) => a.start_time.localeCompare(b.start_time))
const instanceIds = [...new Set(processes.map((process) => process.instance_id))]
})) ?? []) as Array<{ uuid: string; instance_id: string }>
const instanceIds = processes.map((process) => process.instance_id)
const instances: GameInstance[] = await getInstances(instanceIds).catch((error) => {
handleError(error)
return []
@@ -431,9 +419,9 @@ const refresh = async () => {
}
})
.filter((process): process is RunningProcess => process !== null)
selectedProcess.value =
currentProcesses.value.find((process) => process.uuid === selectedProcess.value?.uuid) ??
currentProcesses.value[0]
if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) {
selectedProcess.value = currentProcesses.value[0]
}
}
await refresh()
@@ -447,6 +435,7 @@ const unlistenProcess = await process_listener(async () => {
const stop = async (process: RunningProcess) => {
try {
await killProcess(process.uuid).catch(handleError)
} catch (e) {
console.error(e)
}
@@ -640,7 +629,6 @@ function isVisibleLoadingBar(loadingBar: LoadingBar): boolean {
return (
loadingBar.bar_type?.type !== 'launcher_update' &&
[
'hosted_pack_sync',
'java_download',
'pack_file_download',
'pack_download',
@@ -667,8 +655,8 @@ function applyLoadingEvent(payload: LoadingEventPayload): boolean {
const loadingBar = formatLoadingBars({
loading_bar_uuid: payload.loader_uuid,
message: payload.message,
current: payload.fraction * (payload.total ?? 1),
total: payload.total ?? 1,
current: payload.fraction,
total: 1,
bar_type: payload.event,
})
if (!isVisibleLoadingBar(loadingBar)) return false
@@ -11,8 +11,8 @@ import {
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { join } from '@tauri-apps/api/path'
import { save } from '@tauri-apps/plugin-dialog'
import { join } from '@tauri-apps/api/path'
import { readDir, stat } from '@tauri-apps/plugin-fs'
import { ref } from 'vue'
@@ -1,5 +1,12 @@
<script setup>
import { DownloadIcon, GameIcon, PlayIcon, SpinnerIcon, TimerIcon } from '@modrinth/assets'
import {
DownloadIcon,
GameIcon,
PlayIcon,
SpinnerIcon,
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
@@ -20,7 +27,6 @@ import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance'
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import { get_by_instance_id } from '@/helpers/process'
import { showInstanceInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
@@ -30,7 +36,6 @@ const formatRelativeTime = useRelativeTime()
const { formatMessage } = useVIntl()
const handleMinecraftLaunchError = useMinecraftLaunchError()
const messages = defineMessages({
launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
loading: { id: 'app.instance.loading', defaultMessage: 'Instance is loading...' },
played: { id: 'app.instance.played', defaultMessage: 'Played {time}' },
neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' },
@@ -80,8 +85,13 @@ const isPlaying = computed(() => props.playing ?? internalPlaying.value)
const displayIcon = computed(() =>
getDisplayInstanceIcon(props.instance.icon_path, props.instance.loader),
)
const loading = computed(() => isInstanceLaunching(props.instance.id))
const modLoading = computed(() => loading.value || installing.value)
const loading = ref(false)
const modLoading = computed(
() =>
loading.value ||
currentEvent.value === 'installing' ||
(currentEvent.value === 'launched' && !isPlaying.value),
)
const installing = computed(() => props.instance.install_stage.includes('installing'))
const installed = computed(() => props.instance.install_stage === 'installed')
@@ -95,26 +105,31 @@ const checkProcess = async () => {
if (props.playing !== undefined) return
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError)
if (Array.isArray(runningProcesses)) internalPlaying.value = runningProcesses.length > 0
internalPlaying.value = runningProcesses.length > 0
}
const play = async (e) => {
const play = async (e, context) => {
e?.stopPropagation()
if (loading.value) return
await run(props.instance.id).catch(async (err) => {
const handled = await handleMinecraftLaunchError(err, {
instance_id: props.instance.id,
instance_name: props.instance.name,
loading.value = true
await run(props.instance.id)
.catch(async (err) => {
const handled = await handleMinecraftLaunchError(err, {
instance_id: props.instance.id,
instance_name: props.instance.name,
})
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
})
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
})
await checkProcess()
.finally(() => {
})
loading.value = false
}
const stop = async (e) => {
const stop = async (e, context) => {
e?.stopPropagation()
internalPlaying.value = false
await kill(props.instance.id).catch(handleError)
await checkProcess()
}
const repair = async (e) => {
@@ -156,11 +171,16 @@ defineExpose({
instance: props.instance,
})
const currentEvent = ref(null)
const unlisten =
props.playing === undefined
? await process_listener((e) => {
if (e.instance_id === props.instance.id) {
void checkProcess()
currentEvent.value = e.event
if (e.event === 'finished') {
internalPlaying.value = false
}
}
})
: () => undefined
@@ -192,7 +212,15 @@ onUnmounted(() => unlisten())
<span class="line-clamp-2">{{ instance.name }}</span>
</div>
<div class="flex items-center">
<ButtonStyled v-if="modLoading" color="standard" circular>
<ButtonStyled v-if="isPlaying" color="red" circular @mousehover="checkProcess">
<button
v-tooltip="formatMessage(commonMessages.stopButton)"
@click="(e) => stop(e, 'InstanceCard')"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else-if="modLoading" color="standard" circular>
<button v-tooltip="formatMessage(messages.loading)" disabled>
<SpinnerIcon class="animate-spin" />
</button>
@@ -202,10 +230,10 @@ onUnmounted(() => unlisten())
v-tooltip="
offline && !installed
? formatMessage(messages.offlineInstalledOnly)
: formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
: formatMessage(commonMessages.playButton)
"
:disabled="offline && !installed"
@click="play"
@click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<!-- Translate for optical centering -->
@@ -262,8 +290,17 @@ onUnmounted(() => unlisten())
/>
</div>
<div class="absolute bottom-1.5 right-1.5 flex size-12 items-center justify-center">
<ButtonStyled v-if="isPlaying" size="large" color="red" circular>
<button
v-tooltip="formatMessage(commonMessages.stopButton)"
@click="(e) => stop(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled
v-if="!modLoading && !installing && !installed"
v-else-if="!modLoading && !installing && !installed"
size="large"
color="brand"
circular
@@ -287,15 +324,13 @@ onUnmounted(() => unlisten())
</ButtonStyled>
<ButtonStyled v-else-if="!modLoading && !installing" size="large" color="brand" circular>
<button
v-tooltip="
formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
"
v-tooltip="formatMessage(commonMessages.playButton)"
:class="{
'pointer-events-none scale-75 opacity-0': disabled,
'scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100':
!disabled,
}"
@click="play"
@click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<PlayIcon class="translate-x-[1px]" />
@@ -334,8 +369,19 @@ onUnmounted(() => unlisten())
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div class="absolute inset-0 flex items-center justify-center">
<ButtonStyled v-if="isPlaying" size="large" color="red" circular>
<button
v-tooltip="formatMessage(commonMessages.stopButton)"
:class="{ 'scale-100 opacity-100': isPlaying }"
class="transition-all origin-bottom opacity-0 card-shadow"
@click="(e) => stop(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<SpinnerIcon
v-if="modLoading || installing"
v-else-if="modLoading || installing"
v-tooltip="
modLoading
? formatMessage(messages.loading)
@@ -360,9 +406,7 @@ onUnmounted(() => unlisten())
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand" circular>
<button
v-tooltip="
formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
"
v-tooltip="formatMessage(commonMessages.playButton)"
:class="`transition-all scale-75 origin-bottom card-shadow ${disabled ? 'opacity-0 scale-75' : 'opacity-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100'}`"
@click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess"
@@ -522,7 +522,7 @@ async function copyShareUrl(): Promise<void> {
}
}
function openAIAnalysis(): void {
function _openAIAnalysis(): void {
if (!lastAnalysis?.combined_log) {
notifyNoLogContent()
return
@@ -603,7 +603,7 @@ onUnmounted(() => {
unlistenProcess?.()
})
defineExpose({ handleLaunchError, handleWarning, isLaunchFailure, showPreview, openAIAnalysis })
defineExpose({ handleLaunchError, handleWarning, isLaunchFailure, showPreview, openAIAnalysis: _openAIAnalysis })
</script>
<template>
@@ -1,23 +0,0 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { skinSiteFrameUrl } from '@/composables/skin-site-session'
const { formatMessage } = useVIntl()
const messages = defineMessages({
frameTitle: {
id: 'app.starlight-skin.frame-title',
defaultMessage: 'StarLight Skin Site',
},
})
</script>
<template>
<iframe
:src="skinSiteFrameUrl"
allow="clipboard-write; clipboard-read"
:title="formatMessage(messages.frameTitle)"
class="block h-full min-h-0 w-full border-0"
/>
</template>
@@ -1,65 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import {
receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { clearHostedSession } from '@/helpers/hosted-packs'
const frame = ref<HTMLIFrameElement>()
let lastMessage = 0
let expiryTimer: ReturnType<typeof setInterval> | undefined
watch(
[skinSiteStatus, () => skinSiteUser.value?.uuid],
() => {
void clearHostedSession().catch(() => {})
},
{ flush: 'sync' },
)
function connect() {
resetSkinSiteSession()
lastMessage = 0
const contentWindow = frame.value?.contentWindow ?? null
setSkinSiteFrame(contentWindow)
contentWindow?.postMessage({ type: 'starlight-skin-session-connect' }, SKIN_SITE_ORIGIN)
}
function receive(event: MessageEvent) {
if (receiveSkinSiteMessage(event, frame.value?.contentWindow ?? null)) lastMessage = Date.now()
}
onMounted(() => {
window.addEventListener('message', receive)
connect()
expiryTimer = setInterval(() => {
if (lastMessage && Date.now() - lastMessage > 90_000) resetSkinSiteSession()
}, 10_000)
})
onUnmounted(() => {
window.removeEventListener('message', receive)
clearInterval(expiryTimer)
setSkinSiteFrame(null)
resetSkinSiteSession()
void clearHostedSession().catch(() => {})
})
</script>
<template>
<iframe
ref="frame"
:src="`${SKIN_SITE_ORIGIN}/`"
title="StarLight Skin Site session"
aria-hidden="true"
tabindex="-1"
class="pointer-events-none fixed -left-[10000px] top-0 h-px w-px opacity-0"
@load="connect"
/>
</template>
@@ -1,134 +0,0 @@
<script setup lang="ts">
import { ExternalIcon, UsersIcon, XIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, NewModal } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ref } from 'vue'
import { teamMembers } from '@/data/about'
const modal = ref<InstanceType<typeof NewModal>>()
const SERVER_IPS: { label: string; host: string }[] = [
{ label: '直连 IPv4', host: 'slv4.starlight.cool' },
{ label: '直连 IPv6', host: 'slv6.starlight.cool' },
{ label: '北方代理 IPv4', host: 'north.starlight.cool' },
{ label: '南方代理 IPv4', host: 'south.starlight.cool' },
{ label: '美国代理 IPv4', host: 'usa.starlight.cool' },
]
const RULES_URL = 'https://kdocs.cn/l/cmT6lVdryHJu'
const QQ_GROUP = '535392227'
function show() {
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function openLink(url: string) {
void openUrl(url)
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" header="欢迎使用 Starlight Launcher" width="640px" max-width="640px">
<div class="flex min-w-0 flex-col gap-5">
<!-- 1. 开发组 -->
<section class="flex flex-col gap-2">
<h3 class="m-0 flex items-center gap-2 text-sm font-semibold text-contrast">
<UsersIcon class="size-4 text-secondary" />
开发团队
</h3>
<p class="m-0 text-sm text-secondary">
本启动器由
<span class="font-semibold text-contrast">Ax_Tps</span>
和
<span class="font-semibold text-contrast">Disy920</span>
基于 Axolotl 开发。
</p>
<div class="grid gap-3 sm:grid-cols-2">
<a
v-for="member in teamMembers"
:key="member.name"
:href="member.url ?? undefined"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
@click.prevent="member.url && openLink(member.url)"
>
<Avatar :src="member.avatarUrl" :alt="member.name" size="2.5rem" circle no-shadow />
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">{{ member.name }}</span>
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
</a>
</div>
</section>
<!-- 2. SLS 专属 -->
<section class="flex flex-col gap-2">
<h3 class="m-0 text-sm font-semibold text-contrast">Starlight 专用启动器</h3>
<p class="m-0 text-sm text-secondary">
该启动器为 Starlight 专用。如果您还未加入 StarLight,请前往我们的 QQ 审核群
<button
type="button"
class="cursor-pointer border-0 bg-transparent p-0 font-semibold text-brand underline"
@click="openLink(`https://qm.qq.com/q/${QQ_GROUP}`)"
>
{{ QQ_GROUP }}
</button>
。
</p>
</section>
<!-- 3. 服规更新 -->
<section class="flex flex-col gap-2">
<h3 class="m-0 text-sm font-semibold text-contrast">StarLight 总则章程更新</h3>
<p class="m-0 text-sm text-secondary">
StarLight 总则章程(即“服规”)更新至第 15 版。该版服规进一步完善与严格了聚落建立、改组、招新与运营的全链条要求,请大家注意阅读并遵守!本版服规自
2026 年 9 月 27 日 0 点起开始试行。
</p>
<button
type="button"
class="flex w-fit cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-sm font-semibold text-brand underline"
@click="openLink(RULES_URL)"
>
查看服规链接
<ExternalIcon class="size-3.5" />
</button>
</section>
<!-- 4. 服务器连接 -->
<section class="flex flex-col gap-2">
<h3 class="m-0 text-sm font-semibold text-contrast">服务器连接</h3>
<p class="m-0 text-sm text-secondary">服务器目前下列几项 IP 可用:</p>
<ul class="m-0 flex flex-col gap-1 pl-5 text-sm text-secondary">
<li v-for="ip in SERVER_IPS" :key="ip.host">
<span class="text-contrast">{{ ip.label }}</span>
:<code class="text-brand">{{ ip.host }}</code>
</li>
</ul>
<p class="m-0 text-sm text-secondary">
请注意,我们优先推荐您尝试直连 IP(包括 v4 和 v6,哪个可用用哪个)。若没有出现高延迟或延迟波动严重的问题,尽量避免使用代理
IP(代理 IP 是流量计费的,使用会给服务器带来更大的经济压力)。
</p>
<p class="m-0 text-sm text-secondary">
若必须使用代理 IP,按以往经验,河北(含)以北的我国省份,在选用代理 IP 时建议优先选用北方代理,否则请使用南方代理。
</p>
</section>
</div>
<template #actions>
<div class="flex justify-end">
<ButtonStyled color="brand">
<button @click="hide">
<XIcon />
我知道了
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
@@ -1,274 +0,0 @@
type Point = [number, number]
type Quad = [Point, Point, Point, Point]
type ShieldSurface = { plane: Quad; outline?: Point[]; shade?: number }
// Coordinates follow the sharp master with its localized shoulder cleanup,
// normalized to the scene's 2048 × 682⅔ plate.
// Each visible bone has its own face: the air between ribs must never receive armor.
export const witherShieldSurfaces: ShieldSurface[] = [
// Only the upper chest remains; the removed side beams have no shield surfaces.
{
plane: [
[1194, 479],
[1294, 489],
[1283, 516],
[1183, 505],
],
},
// Far central skull: face, jaw wall and right side share the same edges.
{
plane: [
[1162, 355],
[1319, 352],
[1267, 446],
[1108, 442],
],
},
{
plane: [
[1108, 442],
[1267, 446],
[1280, 489],
[1119, 470],
],
shade: 0.68,
},
{
plane: [
[1319, 352],
[1356, 443],
[1319, 492],
[1267, 446],
],
shade: 0.6,
},
// Near-left skull, underneath the boot.
{
plane: [
[794, 477],
[968, 454],
[1003, 549],
[833, 602],
],
},
{
plane: [
[794, 477],
[833, 602],
[821, 625],
[781, 539],
],
shade: 0.55,
},
{
plane: [
[833, 602],
[1003, 549],
[983, 587],
[821, 625],
],
shade: 0.65,
},
// Right skull.
{
plane: [
[1482, 399],
[1639, 469],
[1568, 582],
[1402, 507],
],
},
{
plane: [
[1639, 469],
[1650, 557],
[1597, 631],
[1568, 582],
],
shade: 0.57,
},
{
plane: [
[1402, 507],
[1568, 582],
[1597, 631],
[1414, 583],
],
shade: 0.66,
outline: [
[1402, 507],
[1568, 582],
[1597, 631],
[1561, 637],
[1444, 621],
[1414, 583],
],
},
// Rear rib, left and right of the chest opening.
{
plane: [
[1021, 505],
[1129, 484],
[1118, 501],
[1009, 520],
],
},
{
plane: [
[1009, 520],
[1118, 501],
[1112, 515],
[1006, 535],
],
shade: 0.65,
},
{
plane: [
[1191, 486],
[1300, 510],
[1289, 530],
[1181, 510],
],
},
{
plane: [
[1289, 530],
[1300, 510],
[1310, 611],
[1296, 621],
],
shade: 0.6,
},
// Middle rib and broken sternum: separate profiles preserve the dark gaps.
{
plane: [
[1052, 514],
[1159, 519],
[1129, 553],
[992, 548],
],
outline: [
[1015, 535],
[1052, 520],
[1094, 522],
[1110, 514],
[1159, 519],
[1129, 553],
[992, 548],
],
},
{
plane: [
[992, 548],
[1129, 553],
[1137, 575],
[987, 567],
],
shade: 0.6,
},
{
plane: [
[1159, 519],
[1170, 560],
[1137, 575],
[1129, 553],
],
shade: 0.55,
},
{
plane: [
[1180, 513],
[1289, 551],
[1274, 575],
[1159, 538],
],
},
{
plane: [
[1274, 575],
[1289, 551],
[1298, 617],
[1279, 632],
],
shade: 0.62,
},
// Closest right rib; its downturned end stops above the ground.
{
plane: [
[1157, 542],
[1261, 581],
[1243, 602],
[1141, 564],
],
},
{
plane: [
[1141, 564],
[1243, 602],
[1255, 641],
[1148, 605],
],
shade: 0.7,
},
{
plane: [
[1243, 602],
[1261, 581],
[1270, 627],
[1255, 641],
],
shade: 0.55,
},
// Foreground rib and spine.
{
plane: [
[1000, 566],
[1140, 581],
[1107, 604],
[960, 589],
],
},
{
plane: [
[960, 589],
[1107, 604],
[1112, 680],
[968, 622],
],
shade: 0.6,
outline: [
[960, 589],
[1107, 604],
[1112, 680],
[1070, 681],
[1066, 619],
[968, 611],
],
},
{
plane: [
[1107, 604],
[1140, 581],
[1160, 669],
[1112, 680],
],
shade: 0.5,
},
{
plane: [
[986, 611],
[1070, 617],
[967, 684],
[874, 671],
],
},
{
plane: [
[1070, 617],
[1074, 668],
[1060, 684],
[967, 684],
],
shade: 0.55,
},
]
@@ -1,953 +0,0 @@
import cloudTextureUrl from '@/assets/about-scene/clouds.png'
import moonTextureUrl from '@/assets/about-scene/moon-phases.png'
import swordTextureUrl from '@/assets/about-scene/netherite-sword.png'
import cleanUrl from '@/assets/about-scene/victory-clean.jpg'
import originalUrl from '@/assets/about-scene/victory-master.jpg'
import correctedPoseUrl from '@/assets/about-scene/victory-pose.jpg'
import removedBeamsUrl from '@/assets/about-scene/wither-beams-removed.png'
import rebuiltWitherUrl from '@/assets/about-scene/wither-shoulder-cleanup.png'
import { witherShieldSurfaces } from './wither-shield'
type Point = [number, number]
type Vertex = [number, number, number]
/** The approved artwork and item geometry are kept separate from the animation lifecycle. */
export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
const context = canvas.getContext('2d')
if (!context) throw new Error('Unable to initialize about-page canvas')
const ctx = context
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
const original = new Image()
const clean = new Image()
const correctedPose = new Image()
const rebuiltWither = new Image()
const removedBeams = new Image()
const swordTexture = new Image()
const cloudTexture = new Image()
const moonTexture = new Image()
const W = 2048,
H = W / 3,
artworkWidth = 2172,
artworkHeight = 724,
artworkScale = artworkWidth / W,
TAU = Math.PI * 2
let time = 0,
last = 0,
paused = false,
visible = false,
ready = false
let frame = 0,
destroyed = false
const makeLayer = (w = W, h = H) => {
const layer = document.createElement('canvas')
layer.width = Math.ceil(w)
layer.height = Math.ceil(h)
return layer
}
const base = makeLayer(artworkWidth, artworkHeight)
const shield = makeLayer(artworkWidth, artworkHeight)
const star = makeLayer(180, 180)
const starFace = makeLayer(180, 180)
const swordFace = makeLayer(128, 128)
const grippingFingers = makeLayer(64, 80)
let cloudField: ReturnType<typeof makeVanillaCloudField>
const buriedBlade: Point[] = [
[1088, 526],
[1099, 505],
[1105, 491],
[1116, 490],
[1124, 483],
[1133, 485],
[1142, 478],
[1158, 477],
[1195, 480],
[1212, 534],
[1169, 567],
[1101, 555],
]
const wound: Point[] = [
[1104, 507],
[1116, 490],
[1136, 490],
[1140, 480],
[1156, 477],
[1178, 479],
[1173, 493],
[1157, 505],
[1152, 524],
[1137, 534],
[1126, 523],
]
const starRows = [
'....p....',
'...pwp...',
'..pwYwp..',
'.pwYIYwp.',
'pwYISIYwp',
'.pwYIYwp.',
'..pwYwp..',
'...pwp...',
'....p....',
]
const starColors: Record<string, string> = {
p: '#b692d0',
w: '#f8e6cf',
Y: '#ffe3a0',
I: '#fff6da',
S: '#fffef1',
}
// Irregular failing-lamp events: brief reignitions, weak sputters, and dark gaps.
const shieldEvents = [
[0, 0.68, 0.48],
[0.87, 0.09, 0.82],
[1.05, 0.16, 0.27],
[1.39, 0.08, 0.61],
[2.7, 0.42, 0.2],
[3.27, 0.13, 0.65],
[4.8, 0.82, 0.42],
[5.83, 0.06, 0.82],
[6.08, 0.13, 0.29],
[6.39, 0.09, 0.7],
[8.15, 0.53, 0.26],
[9.64, 0.12, 0.69],
[9.88, 0.19, 0.34],
[10.2, 0.08, 0.73],
[11.75, 0.7, 0.45],
[12.8, 0.07, 0.87],
[13.14, 0.1, 0.24],
[15.02, 0.28, 0.5],
[15.58, 0.09, 0.86],
[15.82, 0.13, 0.31],
[17.5, 0.84, 0.22],
[18.63, 0.08, 0.68],
[18.85, 0.18, 0.39],
[20.25, 0.56, 0.53],
[21.13, 0.1, 0.86],
[21.36, 0.12, 0.35],
[22.17, 0.29, 0.18],
]
const rand = (n: number) => {
const v = Math.sin(n * 127.1 + 311.7) * 43758.5453
return v - Math.floor(v)
}
const sparks = Array.from({ length: 34 }, (_, i) => ({
seed: i,
phase: rand(i),
life: 6,
size: 2 + rand(i + 9) * 5,
}))
const dust = Array.from({ length: 56 }, (_, i) => ({
seed: i + 80,
phase: rand(i + 31),
life: 6 + rand(i + 42) * 5,
size: 4 + rand(i + 64) * 7,
}))
const armor: Point[][] = [
[
[655, 22],
[809, 72],
[885, 81],
[858, 132],
[773, 119],
[690, 132],
[651, 112],
],
[
[693, 214],
[768, 223],
[805, 385],
[727, 398],
[712, 301],
],
[
[627, 157],
[670, 171],
[649, 237],
[596, 221],
],
[
[877, 183],
[918, 210],
[899, 244],
[865, 224],
],
]
function path(points: Point[]) {
ctx.beginPath()
points.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])))
ctx.closePath()
}
function glow(x: number, y: number, r: number, color: string, alpha: number) {
const gradient = ctx.createRadialGradient(x, y, 0, x, y, r)
gradient.addColorStop(0, `rgba(${color},${alpha})`)
gradient.addColorStop(0.35, `rgba(${color},${alpha * 0.36})`)
gradient.addColorStop(1, `rgba(${color},0)`)
ctx.fillStyle = gradient
ctx.fillRect(x - r, y - r, r * 2, r * 2)
}
function prepare() {
// Restore the sharp master for all unaffected materials, anatomy and terrain.
const b = base.getContext('2d')!
b.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
b.imageSmoothingEnabled = false
b.drawImage(original, 0, 0, W, H)
const sky = makeLayer(artworkWidth, artworkHeight),
skyCtx = sky.getContext('2d')!
skyCtx.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
skyCtx.drawImage(clean, 0, 0, W, H)
skyCtx.globalCompositeOperation = 'destination-in'
const skyFade = skyCtx.createLinearGradient(0, 409, 0, 448)
skyFade.addColorStop(0, '#fff')
skyFade.addColorStop(1, 'transparent')
skyCtx.fillStyle = skyFade
skyCtx.fillRect(0, 0, W, 442)
b.drawImage(sky, 0, 0, W, H)
const restore = (source: HTMLImageElement, polygon: Point[]) => {
b.save()
b.beginPath()
polygon.forEach((p, i) => (i ? b.lineTo(...p) : b.moveTo(...p)))
b.closePath()
b.clip()
if (source === correctedPose)
b.drawImage(source, (583 * W) / 2172, (212 * W) / 2172, (510 * W) / 2172, (477 * W) / 2172)
else b.drawImage(source, 0, 0, W, H)
b.restore()
}
// Crisp original character; only previously corrected hands/boots use their local plate.
restore(original, [
[687, 8],
[906, 81],
[884, 173],
[919, 183],
[923, 209],
[876, 242],
[854, 273],
[862, 314],
[918, 308],
[938, 385],
[950, 424],
[930, 480],
[817, 480],
[805, 399],
[747, 399],
[720, 491],
[627, 502],
[651, 423],
[625, 452],
[590, 437],
[574, 410],
[594, 386],
[572, 374],
[577, 341],
[574, 331],
[593, 270],
[605, 237],
[588, 237],
[607, 159],
[650, 139],
])
// Remove the baked angled moon locally with matching moon-free sky.
// The new vanilla moon is a separate, camera-facing layer.
const moonSky = makeLayer(280, 270),
ms = moonSky.getContext('2d')!
ms.drawImage(
clean,
0,
0,
(280 * clean.naturalWidth) / W,
(270 * clean.naturalHeight) / H,
0,
0,
280,
270,
)
ms.globalCompositeOperation = 'destination-in'
ms.save()
ms.translate(140, 130)
ms.scale(1, 1.13)
const moonMask = ms.createRadialGradient(0, 0, 80, 0, 0, 123)
moonMask.addColorStop(0, '#fff')
moonMask.addColorStop(1, 'transparent')
ms.fillStyle = moonMask
ms.fillRect(-140, -135, 280, 270)
ms.restore()
b.drawImage(moonSky, 193, -20)
// Restore the sharp skull planes covered by the sky plate.
restore(original, [
[1159, 355],
[1318, 352],
[1358, 421],
[1315, 498],
[1246, 474],
[1107, 443],
])
// Remove the old painted blade without resampling the rest of the creature.
restore(clean, [
[975, 339],
[1002, 334],
[1078, 369],
[1148, 456],
[1185, 469],
[1200, 481],
[1178, 520],
[1144, 562],
[1080, 570],
[1025, 548],
[1053, 502],
[1006, 476],
])
// Retain the previously approved removal of baked lightning below the skulls.
restore(clean, [
[900, 595],
[948, 572],
[1000, 588],
[982, 615],
[914, 630],
[877, 625],
])
restore(clean, [
[1380, 420],
[1428, 430],
[1400, 492],
[1375, 535],
[1356, 606],
[1377, 626],
[1294, 639],
[1284, 579],
[1318, 525],
[1320, 470],
])
// Use regeneration only to erase the two unwanted shoulder slabs. The skulls,
// ribs, character and background retain their original sharp source pixels.
restore(rebuiltWither, [
[963, 423],
[1014, 417],
[1049, 431],
[1074, 479],
[1066, 495],
[1017, 511],
[985, 524],
[963, 466],
])
restore(rebuiltWither, [
[1360, 415],
[1392, 414],
[1429, 427],
[1448, 445],
[1405, 506],
[1398, 532],
[1355, 528],
[1319, 514],
[1324, 494],
])
// Replace only the removed beam silhouettes with the newly exposed background.
// No full-frame regeneration or feathering: unaffected source pixels stay intact.
restore(removedBeams, [
[976, 480],
[1146, 462],
[1152, 488],
[1124, 508],
[999, 530],
[990, 542],
])
restore(removedBeams, [
[1294, 489],
[1404, 498],
[1445, 548],
[1452, 629],
[1402, 621],
[1305, 615],
[1299, 549],
[1283, 521],
])
restore(correctedPose, [
[811, 377],
[951, 377],
[975, 415],
[987, 481],
[952, 496],
[813, 486],
])
restore(correctedPose, [
[591, 486],
[744, 495],
[757, 650],
[550, 650],
[565, 553],
])
restore(correctedPose, [
[880, 210],
[925, 218],
[951, 235],
[977, 245],
[979, 280],
[957, 302],
[932, 299],
[910, 280],
[877, 267],
])
try {
cloudField = makeVanillaCloudField()
} catch (error) {
console.warn('Unable to initialize about-page clouds', error)
}
// Occlude with skin pixels only: background in the palm must not erase the grip.
const fingers = grippingFingers.getContext('2d')!
fingers.drawImage(
clean,
(922 * clean.naturalWidth) / W,
(228 * clean.naturalHeight) / H,
(64 * clean.naturalWidth) / W,
(80 * clean.naturalHeight) / H,
0,
0,
64,
80,
)
const skin = fingers.getImageData(0, 0, 64, 80)
for (let pixel = 0; pixel < skin.data.length; pixel += 4) {
const [red, green, blue] = skin.data.subarray(pixel, pixel + 3)
if (red < 90 || green < 48 || red < green * 1.04 || green < blue * 1.08)
skin.data[pixel + 3] = 0
}
fingers.putImageData(skin, 0, 0)
// A small extruded item sprite retains sharp pixels during its vertical-axis turn.
const s = star.getContext('2d')!
starRows.forEach((row, y) =>
[...row].forEach((v, x) => {
if (v !== '.') {
s.fillStyle = starColors[v]
s.fillRect(x * 20, y * 20, 20, 20)
}
}),
)
}
function enchantedFace(
target: HTMLCanvasElement,
texture: CanvasImageSource,
t: number,
strength: number,
) {
const c = target.getContext('2d')!,
size = target.width
c.clearRect(0, 0, size, size)
c.imageSmoothingEnabled = false
c.globalCompositeOperation = 'source-over'
c.drawImage(texture, 0, 0, size, size)
c.globalCompositeOperation = 'source-atop'
c.fillStyle = `rgba(123,63,207,${strength * 0.14})`
c.fillRect(0, 0, size, size)
// Translation of glint only: the texture coordinates and geometry never change.
const shift = ((t / 6) % 1) * size * 1.25
for (let band = -2; band < 3; band++) {
const x = shift + band * size * 1.25
const g = c.createLinearGradient(x - size * 0.7, 0, x + size * 0.3, size)
g.addColorStop(0, 'rgba(135,72,236,0)')
g.addColorStop(0.34, 'rgba(135,72,236,0)')
g.addColorStop(0.46, `rgba(163,99,247,${strength * 0.6})`)
g.addColorStop(0.5, `rgba(224,178,255,${strength})`)
g.addColorStop(0.55, `rgba(151,81,244,${strength * 0.5})`)
g.addColorStop(0.66, 'rgba(135,72,236,0)')
g.addColorStop(1, 'rgba(135,72,236,0)')
c.fillStyle = g
c.fillRect(0, 0, size, size)
}
c.globalCompositeOperation = 'source-over'
return target
}
function drawSword(t: number) {
const face = enchantedFace(swordFace, swordTexture, t, 0.34)
ctx.save()
// Hide only the buried sword pixels; never paint a base-image patch over the shield.
ctx.beginPath()
ctx.rect(0, 0, W, H)
buriedBlade.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])))
ctx.closePath()
ctx.clip('evenodd')
// Original 128x128 asset is rigidly rotated/scaled, with no mesh warp or repainting.
// Place the grip's actual texture center inside the corrected curled palm.
ctx.translate(938, 268)
ctx.rotate((95.5 * Math.PI) / 180)
ctx.scale(2.12, 2.12)
ctx.imageSmoothingEnabled = false
ctx.save()
ctx.translate(1.25, 1.25)
ctx.filter = 'brightness(.55)'
ctx.drawImage(swordTexture, -24, -104, 128, 128)
ctx.restore()
ctx.drawImage(face, -24, -104, 128, 128)
ctx.restore()
// The fingers occlude the handle so it remains held, rather than pasted over the hand.
ctx.drawImage(grippingFingers, 922, 228)
}
function drawStar(t: number, angle: number, x: number, y: number) {
const front = enchantedFace(starFace, star, t, 0.23)
const cosine = Math.cos(angle),
sine = Math.sin(angle),
halfDepth = 5
ctx.save()
ctx.translate(x, y)
ctx.imageSmoothingEnabled = false
// One native item-texel layer of thickness; keep the front's pixel silhouette intact.
const edge = sine > 0 ? -1 : 1
for (let row = 0; row < 9; row++)
for (let col = 0; col < 9; col++) {
const color = starRows[row][col]
if (color === '.') continue
const neighbor = starRows[row][col + edge]
if (neighbor && neighbor !== '.') continue
const px = ((col + (edge > 0 ? 1 : 0)) / 9 - 0.5) * 160,
py = (row / 9 - 0.5) * 160
const a = px * cosine - halfDepth * sine,
b = px * cosine + halfDepth * sine
ctx.fillStyle = color === 'p' ? '#9070b0' : '#d9af67'
ctx.beginPath()
ctx.moveTo(a, py)
ctx.lineTo(b, py)
ctx.lineTo(b, py + 160 / 9)
ctx.lineTo(a, py + 160 / 9)
ctx.closePath()
ctx.fill()
}
ctx.translate(Math.sign(cosine) * halfDepth * sine, 0)
ctx.scale(cosine, 1)
ctx.filter = 'brightness(1.04)'
ctx.shadowColor = '#ffdc98'
ctx.shadowBlur = 9
ctx.drawImage(front, -80, -80, 160, 160)
ctx.restore()
}
function shieldLevel(t: number) {
let level = 0
for (const [start, duration, power] of shieldEvents) {
const p = (t - start) / duration
if (p >= 0 && p < 1) {
const envelope = Math.min(1, p * 14, (1 - p) * 10)
const sputter = 0.64 + 0.36 * rand(Math.floor(t * 19) + start * 91)
level = Math.max(level, power * envelope * sputter)
}
}
return level
}
function makeVanillaCloudField() {
// Minecraft 1.20.6's unmodified clouds.png supplies every occupied cell.
// One texel is a 12 x 12 block footprint with the vanilla 4-block thickness.
const textureCanvas = makeLayer(256, 256),
tc = textureCanvas.getContext('2d')!
tc.drawImage(cloudTexture, 0, 0)
const pixels = tc.getImageData(0, 0, 256, 256).data
const occupied = (x: number, z: number) =>
pixels[(((z + 256) % 256) * 256 + ((x + 256) % 256)) * 4 + 3] > 127
const layer = makeLayer()
const context = layer.getContext('webgl', {
alpha: true,
antialias: true,
premultipliedAlpha: true,
})
if (!context) return
const gl = context
const vertices: number[] = []
const quad = (a: Vertex, b: Vertex, c: Vertex, d: Vertex, shade: number) => {
for (const point of [a, b, c, a, c, d]) vertices.push(...point, shade)
}
for (let z = 8; z < 96; z++)
for (let x = 0; x < 256; x++) {
if (!occupied(x, z + 36)) continue
const l = (x - 128) * 12,
r = l + 12,
n = z * 12,
f = n + 12,
y = 84,
h = y + 4
quad([l, y, n], [r, y, n], [r, y, f], [l, y, f], 0.7)
if (!occupied(x - 1, z + 36)) quad([l, y, n], [l, y, f], [l, h, f], [l, h, n], 0.9)
if (!occupied(x + 1, z + 36)) quad([r, y, f], [r, y, n], [r, h, n], [r, h, f], 0.9)
if (!occupied(x, z + 35)) quad([r, y, n], [l, y, n], [l, h, n], [r, h, n], 0.8)
if (!occupied(x, z + 37)) quad([l, y, f], [r, y, f], [r, h, f], [l, h, f], 0.8)
}
const shader = (kind: number, source: string) => {
const result = gl.createShader(kind)
if (!result) throw new Error('Unable to allocate cloud shader')
gl.shaderSource(result, source)
gl.compileShader(result)
if (!gl.getShaderParameter(result, gl.COMPILE_STATUS))
throw new Error(gl.getShaderInfoLog(result) ?? 'Cloud shader compilation failed')
return result
}
const vertex = shader(
gl.VERTEX_SHADER,
`
attribute vec4 vertex; uniform float offset;
varying float shade; varying float distance;
void main(){
vec3 p=vertex.xyz+vec3(offset,0.0,0.0);
float focal=650.0; float horizon=350.0;
gl_Position=vec4(2.0*focal*p.x/2048.0,
(1.0-2.0*horizon/682.6667)*p.z+2.0*focal*p.y/682.6667,
1.020202*p.z-28.282828,p.z);
shade=vertex.w; distance=p.z;
}
`,
)
const fragment = shader(
gl.FRAGMENT_SHADER,
`
precision mediump float; varying float shade; varying float distance;
void main(){
float alpha=.70*(1.0-smoothstep(480.0,1150.0,distance));
gl_FragColor=vec4(vec3(.34,.40,.50)*shade*alpha,alpha);
}
`,
)
const program = gl.createProgram()
if (!program) throw new Error('Unable to allocate cloud program')
gl.attachShader(program, vertex)
gl.attachShader(program, fragment)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS))
throw new Error(gl.getProgramInfoLog(program) ?? 'Cloud program linking failed')
gl.useProgram(program)
const buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW)
const attribute = gl.getAttribLocation(program, 'vertex')
gl.enableVertexAttribArray(attribute)
gl.vertexAttribPointer(attribute, 4, gl.FLOAT, false, 16, 0)
const offset = gl.getUniformLocation(program, 'offset')
gl.enable(gl.DEPTH_TEST)
gl.disable(gl.BLEND)
gl.clearColor(0, 0, 0, 0)
gl.viewport(0, 0, layer.width, layer.height)
return {
draw(elapsed: number) {
if (gl.isContextLost()) return
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
const travel = (elapsed * 2.4) % 3072
for (const tile of [-3072, 0, 3072]) {
gl.uniform1f(offset, tile + travel)
gl.drawArrays(gl.TRIANGLES, 0, vertices.length / 4)
}
return layer
},
resize(width: number, height: number) {
layer.width = width
layer.height = height
gl.viewport(0, 0, width, height)
},
dispose() {
gl.deleteBuffer(buffer)
gl.deleteProgram(program)
gl.deleteShader(vertex)
gl.deleteShader(fragment)
gl.getExtension('WEBGL_lose_context')?.loseContext()
},
}
}
function drawShield(t: number) {
const level = shieldLevel(t)
canvas.dataset.shieldIntensity = level.toFixed(3)
if (level < 0.003) return
const surface = shield.getContext('2d')!
surface.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
surface.clearRect(0, 0, W, H + 1)
surface.save()
surface.globalCompositeOperation = 'source-over'
const trace = (points: Point[]) => {
surface.beginPath()
points.forEach((p, i) => (i ? surface.lineTo(...p) : surface.moveTo(...p)))
surface.closePath()
}
// Tight fractured-cavity contour: blue armor remains on the surrounding ribs.
surface.beginPath()
surface.rect(0, 0, W, H)
wound.forEach((p, i) => (i ? surface.lineTo(p[0], p[1]) : surface.moveTo(p[0], p[1])))
surface.closePath()
surface.clip('evenodd')
witherShieldSurfaces.forEach(({ plane: quad, outline = quad, shade = 1 }, index) => {
const map = (u: number, v: number): Point => [
(1 - v) * ((1 - u) * quad[0][0] + u * quad[1][0]) +
v * ((1 - u) * quad[3][0] + u * quad[2][0]),
(1 - v) * ((1 - u) * quad[0][1] + u * quad[1][1]) +
v * ((1 - u) * quad[3][1] + u * quad[2][1]),
]
const alpha = level * shade * (0.79 + 0.21 * rand(index + Math.floor(t * 7)))
trace(outline)
surface.fillStyle = `rgba(55,126,203,${alpha * 0.38})`
surface.fill()
surface.save()
trace(outline)
surface.clip()
for (let row = 0; row < 8; row++)
for (let col = 0; col < 8; col++) {
const grain = rand(row * 17 + col * 5 + index * 139)
if (grain < 0.56) continue
trace([
map(col / 8, row / 8),
map((col + 1) / 8, row / 8),
map((col + 1) / 8, (row + 1) / 8),
map(col / 8, (row + 1) / 8),
])
surface.fillStyle = `rgba(104,167,223,${alpha * (grain - 0.4) * 0.42})`
surface.fill()
}
// Stepped translucent bands wrap each actual surface, like the supplied armor image.
for (let band = -1; band < 3; band++)
for (let col = 0; col < 8; col++) {
const v = band * 0.48 + (t / 24) * 0.48 + Math.floor(rand(col + index * 11) * 3) / 32
const height = 0.034 + rand(col * 3 + band + index) * 0.022
trace([
map(col / 8, v),
map((col + 1) / 8, v),
map((col + 1) / 8, v + height),
map(col / 8, v + height),
])
surface.fillStyle = `rgba(210,229,172,${alpha * 0.75})`
surface.fill()
}
surface.restore()
})
surface.restore()
// Composite the fitted faces once, without bright overlaps at shared bone edges.
ctx.save()
ctx.globalCompositeOperation = 'screen'
ctx.drawImage(shield, 0, 0, W, H)
ctx.restore()
}
function drawClouds(elapsed: number) {
ctx.save()
// Clouds pass behind the character silhouette, never across the face or armor.
ctx.beginPath()
ctx.rect(0, 0, W, H)
;[
[687, 7],
[907, 80],
[884, 175],
[918, 182],
[921, 208],
[980, 246],
[980, 299],
[945, 306],
[899, 280],
[864, 260],
[861, 325],
[573, 325],
[594, 241],
[588, 238],
[607, 159],
[650, 139],
].forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])))
ctx.closePath()
ctx.clip('evenodd')
const clouds = cloudField?.draw(elapsed)
if (clouds) ctx.drawImage(clouds, 0, 0, W, H)
ctx.restore()
canvas.dataset.cloudOffset = (elapsed * 2.4).toFixed(2)
}
function drawMoon() {
ctx.save()
ctx.globalCompositeOperation = 'screen'
ctx.imageSmoothingEnabled = false
ctx.filter = 'brightness(2.15)'
// Vanilla waning-crescent tile, rendered square-on with no skew or side extrusion.
// Retain its original pixel shading and native halo; black texels add no light.
ctx.drawImage(moonTexture, 96, 0, 32, 32, 45, -179, 576, 576)
ctx.restore()
}
function draw(elapsed: number) {
const t = elapsed % 24
const ratio = canvas.width / W
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
ctx.clearRect(0, 0, W, H)
const cycle = (t * TAU) / 24
ctx.save()
// Keep the static plate pixel-stable; animate only the independent effects.
// The full-resolution master is sampled once, without a floating camera zoom.
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(base, 0, 0, W, H)
drawMoon()
drawClouds(elapsed)
drawShield(t)
drawSword(t)
const bob = 5.2 * Math.sin(cycle * 3) + 1.3 * Math.sin(cycle * 6 + 0.4)
const sx = 1150,
sy = 247 + bob
const pulse = 0.8 + 0.12 * Math.sin(cycle * 3 + 0.5) + 0.06 * Math.sin(cycle * 7)
ctx.save()
ctx.globalCompositeOperation = 'screen'
glow(sx, sy, 195, '255,195,102', 0.42 * pulse)
glow(sx, sy, 88, '255,232,177', 0.15 * pulse)
glow(1134, 495, 165, '255,194,101', 0.13 * pulse)
glow(790, 191, 90, '255,201,129', 0.055 * pulse)
// Narrow rays breathe with the star, instead of rotating the whole scene.
ctx.translate(sx, sy)
for (let i = 0; i < 8; i++) {
ctx.save()
ctx.rotate((i * TAU) / 8 + 0.08 * Math.sin(cycle))
const g = ctx.createLinearGradient(0, 35, 0, 155)
g.addColorStop(0, `rgba(255,228,165,${0.13 * pulse})`)
g.addColorStop(1, 'transparent')
ctx.fillStyle = g
ctx.beginPath()
ctx.moveTo(-2, 30)
ctx.lineTo(-6, 155)
ctx.lineTo(6, 155)
ctx.lineTo(2, 30)
ctx.fill()
ctx.restore()
}
ctx.restore()
ctx.save()
ctx.globalCompositeOperation = 'screen'
for (const polygon of armor) {
ctx.save()
path(polygon)
ctx.clip()
const x = ((t / 8) % 1) * 1350 - 240
const glint = ctx.createLinearGradient(x - 100, 0, x + 150, 450)
glint.addColorStop(0, 'transparent')
glint.addColorStop(0.4, 'rgba(160,88,255,0)')
glint.addColorStop(0.5, 'rgba(206,149,255,.28)')
glint.addColorStop(0.58, 'rgba(136,81,250,.09)')
glint.addColorStop(1, 'transparent')
ctx.fillStyle = glint
ctx.fillRect(550, 0, 650, 520)
ctx.restore()
}
ctx.restore()
// Staggered particles have invisible births/deaths, so the loop has no pop.
dust.forEach((d) => {
const q = (elapsed / d.life + d.phase) % 1,
alpha = Math.pow(Math.sin(Math.PI * q), 0.65) * 0.9
const x =
1145 +
(rand(d.seed) - 0.5) * 170 +
Math.sin(q * 4 + d.seed) * 24 +
q * (45 + rand(d.seed + 2) * 85)
const y = 490 - q * (180 + rand(d.seed + 9) * 130)
const size = d.size * (1 - q * 0.4)
ctx.fillStyle = `rgba(7,10,16,${alpha})`
ctx.fillRect(x, y, size, size)
if (d.seed % 3 === 0) {
ctx.fillRect(x - size * 0.65, y + size * 0.4, size * 0.7, size * 0.65)
ctx.fillRect(x + size * 0.65, y - size * 0.5, size * 0.55, size * 0.6)
}
})
ctx.save()
ctx.globalCompositeOperation = 'screen'
sparks.forEach((d) => {
const q = (t / d.life + d.phase) % 1,
alpha = Math.pow(Math.sin(Math.PI * q), 1.7) * 0.55
const x = 1140 + (rand(d.seed + 7) - 0.5) * 150 + Math.sin(q * 4 + d.seed) * 14
const y = 440 - q * (130 + rand(d.seed + 19) * 120)
ctx.fillStyle = `rgba(255,207,122,${alpha})`
ctx.fillRect(x, y, d.size * 0.45, d.size * 0.45)
})
ctx.restore()
const angle = cycle * 2 + 0.12 * Math.sin(cycle * 2)
drawStar(t, angle, sx, sy)
ctx.restore()
canvas.dataset.animationTime = elapsed.toFixed(3)
}
function canAnimate() {
return ready && !destroyed && !paused && visible && !document.hidden && !reducedMotion.matches
}
function tick(now: number) {
frame = 0
if (!canAnimate()) return
if (last) time += Math.min((now - last) / 1000, 0.1)
last = now
draw(time)
frame = requestAnimationFrame(tick)
}
function updatePlayback() {
if (frame) cancelAnimationFrame(frame)
frame = 0
last = 0
if (canAnimate()) frame = requestAnimationFrame(tick)
}
function resize() {
if (destroyed) return
const width = canvas.getBoundingClientRect().width
if (width <= 0) return
canvas.width = Math.min(
artworkWidth,
Math.max(1, Math.round(width * Math.min(2, devicePixelRatio || 1))),
)
canvas.height = Math.round(canvas.width / 3)
cloudField?.resize(canvas.width, canvas.height)
if (ready) draw(time)
}
const observer = new ResizeObserver(resize)
observer.observe(canvas)
const intersection = new IntersectionObserver((entries) => {
visible = entries[0].isIntersecting
updatePlayback()
})
intersection.observe(canvas)
document.addEventListener('visibilitychange', updatePlayback)
reducedMotion.addEventListener('change', updatePlayback)
const loaded = (img: HTMLImageElement, url: string) =>
new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject(new Error('Unable to load about-page artwork'))
img.src = url
})
const imageAssets = [
original,
clean,
swordTexture,
correctedPose,
cloudTexture,
moonTexture,
rebuiltWither,
removedBeams,
]
const loading = Promise.all([
loaded(original, originalUrl),
loaded(clean, cleanUrl),
loaded(swordTexture, swordTextureUrl),
loaded(correctedPose, correctedPoseUrl),
loaded(cloudTexture, cloudTextureUrl),
loaded(moonTexture, moonTextureUrl),
loaded(rebuiltWither, rebuiltWitherUrl),
loaded(removedBeams, removedBeamsUrl),
])
.then(() => {
if (destroyed) return
prepare()
ready = true
resize()
updatePlayback()
})
.finally(() => {
for (const img of imageAssets) {
img.onload = null
img.onerror = null
}
})
return {
ready: loading,
setPaused(value: boolean) {
paused = value
updatePlayback()
},
dispose() {
if (destroyed) return
destroyed = true
updatePlayback()
observer.disconnect()
intersection.disconnect()
document.removeEventListener('visibilitychange', updatePlayback)
reducedMotion.removeEventListener('change', updatePlayback)
cloudField?.dispose()
cloudField = undefined
for (const layer of [base, shield, star, starFace, swordFace, grippingFingers]) {
layer.width = 1
layer.height = 1
}
},
}
}
@@ -61,7 +61,7 @@ const historyAnnouncements = computed(() =>
<UpdateAnnouncementContent
:announcement="currentAnnouncement"
:version="currentVersion"
:external-url="currentAnnouncement?.externalUrl ?? AxolotlBrandConfig.releasesUrl"
:external-url="currentAnnouncement?.externalUrl ?? AxolotlBrandConfig.website"
/>
</div>
@@ -102,7 +102,7 @@ const historyAnnouncements = computed(() =>
<UpdateAnnouncementContent
:announcement="announcement"
:show-header="false"
:external-url="announcement.externalUrl ?? `${AxolotlBrandConfig.releasesUrl}/tag/v${announcement.version}`"
:external-url="announcement.externalUrl"
/>
</div>
</Accordion>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { Avatar } from '@modrinth/ui'
import { onScopeDispose, ref } from 'vue'
defineProps<{ src: string; name: string; href?: string }>()
const emit = defineEmits<{ activate: [] }>()
const holding = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
let origin = { x: 0, y: 0 }
let suppressClick = false
function cancel() {
clearTimeout(timer)
timer = undefined
holding.value = false
}
function begin() {
cancel()
suppressClick = false
holding.value = true
timer = setTimeout(() => {
cancel()
suppressClick = true
emit('activate')
}, 800)
}
function pointerDown(event: PointerEvent) {
if (event.button !== 0 || !event.isPrimary) return
origin = { x: event.clientX, y: event.clientY }
begin()
}
function pointerMove(event: PointerEvent) {
if (Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > 8) cancel()
}
function click(event: MouseEvent) {
if (!suppressClick) return
event.preventDefault()
event.stopPropagation()
suppressClick = false
}
function keydown(event: KeyboardEvent) {
if (event.code === 'Space') {
event.preventDefault()
if (!event.repeat) begin()
}
}
window.addEventListener('blur', cancel)
onScopeDispose(() => {
cancel()
window.removeEventListener('blur', cancel)
})
</script>
<template>
<a
:href="href"
target="_blank"
rel="noopener noreferrer"
class="mine-avatar"
:class="{ holding }"
@pointerdown="pointerDown"
@pointermove="pointerMove"
@pointerup="cancel"
@pointercancel="cancel"
@pointerleave="cancel"
@blur="cancel"
@click="click"
@contextmenu.prevent
@dragstart.prevent
@keydown="keydown"
@keyup.space.prevent="cancel"
>
<Avatar :src="src" :alt="name" size="2.5rem" circle no-shadow loading="lazy" />
</a>
</template>
<style scoped>
.mine-avatar {
display: inline-flex;
flex-shrink: 0;
border-radius: 50%;
user-select: none;
touch-action: manipulation;
}
.mine-avatar.holding {
outline: 2px solid var(--color-brand);
outline-offset: 3px;
}
.mine-avatar:focus-visible {
outline: 2px solid var(--color-contrast);
outline-offset: 3px;
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { NewButton as Button, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onMounted, onScopeDispose, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onScopeDispose, ref } from 'vue'
import type { Puzzle } from './engine'
import { messages } from './messages'
@@ -20,10 +20,7 @@ const emit = defineEmits<{
}>()
const { formatMessage } = useVIntl()
const viewport = ref<HTMLElement>()
const grid = ref<HTMLElement>()
const view = ref({ left: 0, top: 0, width: 0, height: 0 })
const brushCursor = ref<string>()
const brushActive = computed(() => props.playing && props.selected >= 0)
const large = computed(() => props.puzzle.difficulty !== 'easy')
const visibleCells = computed(() =>
props.puzzle.answer.flatMap((color, i) =>
@@ -88,22 +85,6 @@ function onWheel(event: WheelEvent) {
void changeZoom(props.zoom + (event.deltaY < 0 ? 0.1 : -0.1))
}
let cursorColor = ''
function updateBrushCursor() {
if (!brushActive.value || !grid.value) return
const color = getComputedStyle(grid.value).color
if (color === cursorColor) return
cursorColor = color
// Let the native cursor follow the pointer independently of Vue and board rendering.
// The hotspot is the same brush tip as the previous 32px floating SVG.
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="${color}" d="M358.681 586.386s-90.968 49.4-94.488 126.827c-3.519 77.428-77.427 133.74-102.063 140.778s360.157 22.971 332.002-142.444l-135.45-125.16zm169.099 52.56c14.016 13.601 17.565 32.675 7.929 42.606-9.635 9.93-28.81 6.954-42.823-6.647l-92.767-88.518c-14.015-13.6-17.565-32.675-7.929-42.605 9.636-9.93 28.81-6.955 42.824 6.646l92.766 88.518zm321.734-465.083c-25.144-17.055-47.741-1.763-57.477 3.805-29.097 19.485-237.243 221.77-327.69 315.194-11.105 14.8-18.59 26.294 34.663 79.546 44.95 44.95 65.896 42.012 88.66 22.603 37.906-37.906 199.299-262.926 258.92-348.713 9.792-14.092 29.851-54.17 2.924-72.435z"/></svg>`
brushCursor.value = `url("data:image/svg+xml,${encodeURIComponent(svg)}") 5 27, crosshair`
}
watch(() => [props.playing, props.selected], updateBrushCursor, {
flush: 'post',
})
function label(index: number) {
const values = {
row: Math.floor(index / props.puzzle.size) + 1,
@@ -120,7 +101,6 @@ function label(index: number) {
let observer: ResizeObserver | undefined
onMounted(() => {
updateBrushCursor()
observer = new ResizeObserver(updateView)
if (viewport.value) observer.observe(viewport.value)
})
@@ -128,10 +108,7 @@ onScopeDispose(() => observer?.disconnect())
defineExpose({
centerFirstClue,
setView,
getView: () => ({
left: viewport.value?.scrollLeft ?? 0,
top: viewport.value?.scrollTop ?? 0,
}),
getView: () => ({ left: viewport.value?.scrollLeft ?? 0, top: viewport.value?.scrollTop ?? 0 }),
})
</script>
@@ -139,20 +116,10 @@ defineExpose({
<div class="mine-navigation" :class="{ 'mine-large': large }">
<div ref="viewport" class="mine-viewport" @scroll.passive="updateView" @wheel="onWheel">
<div
ref="grid"
class="mine-grid"
:class="{
'mine-grid-small': !large,
'mine-grid-brush-active': brushActive,
}"
:style="{
'--mine-size': puzzle.size,
'--mine-cell-size': `${40 * zoom}px`,
'--mine-brush-cursor': brushCursor,
color: selected >= 0 ? `var(--mine-color-${selected})` : undefined,
}"
:class="{ 'mine-grid-small': !large }"
:style="{ '--mine-size': puzzle.size, '--mine-cell-size': `${40 * zoom}px` }"
:aria-label="formatMessage(messages.board, { size: puzzle.size })"
@pointerenter="updateBrushCursor"
>
<button
v-for="(_, i) in puzzle.answer"
@@ -233,10 +200,7 @@ defineExpose({
<style scoped>
.mine-navigation {
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
display: grid;
gap: var(--gap-md);
}
@@ -244,9 +208,6 @@ defineExpose({
grid-template-columns: minmax(0, 1fr) 7rem;
}
.mine-viewport {
box-sizing: border-box;
width: 100%;
max-width: 100%;
overflow: auto;
max-height: min(52vh, 32rem);
min-width: 0;
@@ -283,14 +244,10 @@ defineExpose({
color: var(--color-contrast);
font: inherit;
font-weight: 700;
cursor: pointer;
cursor: crosshair;
user-select: none;
touch-action: manipulation;
}
.mine-grid-brush-active,
.mine-grid-brush-active .mine-cell:not(.mine-open):not(:disabled) {
cursor: var(--mine-brush-cursor, crosshair);
}
.mine-cell:hover:not(:disabled) {
background: var(--surface-5);
border-color: var(--mine-ink);
@@ -301,7 +301,6 @@ defineExpose({ show })
:header="formatMessage(messages.title)"
width="56rem"
max-width="56rem"
scrollable
:closable="false"
:close-on-esc="false"
actions-divider
@@ -456,21 +455,15 @@ defineExpose({ show })
justify-content: space-between;
}
.mine-frame {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
border: 2px solid var(--mine-ink);
border-radius: var(--radius-md);
padding: var(--gap-md);
background: var(--surface-2);
}
.mine-status {
min-width: 0;
max-width: 100%;
margin: 0 0 var(--gap-md);
color: var(--color-contrast);
font-size: 0.875rem;
overflow-wrap: anywhere;
}
.mine-actions {
justify-content: flex-end;
@@ -16,8 +16,6 @@ import { basename, dirname, join } from '@tauri-apps/api/path'
import { computed, type Ref, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
import InstancePlayerSettings from '@/components/instance/InstancePlayerSettings.vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { install_duplicate_instance } from '@/helpers/install'
@@ -231,7 +229,7 @@ async function setGameDirMode(mode: GameDirMode) {
}
const editInstanceObject = computed(() => ({
name: title.value.trim(),
name: title.value.trim().substring(0, 32) ?? 'Instance',
}))
watch(
@@ -381,8 +379,6 @@ const messages = defineMessages({
</script>
<template>
<InstanceModeSettings class="mb-6" :instance-id="instance.id" />
<InstancePlayerSettings :instance-id="instance.id" />
<ConfirmDeleteInstanceModal
ref="deleteConfirmModal"
:symlink-target="instance.symlink_target"
@@ -441,6 +437,7 @@ const messages = defineMessages({
id="instance-name"
v-model="title"
autocomplete="off"
:maxlength="80"
wrapper-class="flex-grow"
/>
</div>
@@ -61,7 +61,6 @@ import {
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
RadioButtons,
StyledInput,
@@ -73,7 +72,6 @@ import { edit, list } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const props = defineProps<{
instanceIds: string[]
@@ -150,7 +148,7 @@ async function deleteGroup(group: string) {
for (const instance of allInstances.value) {
if (instance.groups.includes(group)) {
const newGroups = instance.groups.filter((g) => g !== group)
await edit(instance.id, { groups: newGroups }).catch(handleError)
await edit(instance.id, { groups: newGroups }).catch(() => {})
instance.groups = newGroups
}
}
@@ -169,7 +167,7 @@ async function confirm() {
const groups = selectedGroup.value ? [selectedGroup.value.trim().substring(0, 32)] : []
for (const instanceId of props.instanceIds) {
await edit(instanceId, { groups }).catch(handleError)
await edit(instanceId, { groups }).catch(() => {})
}
emit('applied')
@@ -67,7 +67,6 @@ withDefaults(
}>(),
{
count: 1,
symlinkTarget: null,
},
)
@@ -127,7 +127,7 @@ interface IconBackground {
style: Record<string, string>
}
function makeBackground(
function background(
id: string,
name: MessageDescriptor,
top: string,
@@ -171,16 +171,16 @@ const backgrounds = [
backgroundSize: '16px 16px',
},
} satisfies IconBackground,
makeBackground('grass', backgroundNames.grass, '#7fc95b', '#2f7d32'),
makeBackground('ocean', backgroundNames.ocean, '#55b7ff', '#3157c8'),
makeBackground('amethyst', backgroundNames.amethyst, '#c084fc', '#6d28d9'),
makeBackground('sunset', backgroundNames.sunset, '#ffba52', '#e94b64'),
makeBackground('cherry', backgroundNames.cherry, '#ff9ec4', '#b93670'),
makeBackground('nether', backgroundNames.nether, '#ef5a46', '#6d1717'),
makeBackground('slime', backgroundNames.slime, '#b6ee55', '#44972f'),
makeBackground('deep-dark', backgroundNames.deepDark, '#245369', '#0c1f2b'),
makeBackground('stone', backgroundNames.stone, '#aeb5bd', '#525b66'),
makeBackground('midnight', backgroundNames.midnight, '#4d5f8f', '#171b2d'),
background('grass', backgroundNames.grass, '#7fc95b', '#2f7d32'),
background('ocean', backgroundNames.ocean, '#55b7ff', '#3157c8'),
background('amethyst', backgroundNames.amethyst, '#c084fc', '#6d28d9'),
background('sunset', backgroundNames.sunset, '#ffba52', '#e94b64'),
background('cherry', backgroundNames.cherry, '#ff9ec4', '#b93670'),
background('nether', backgroundNames.nether, '#ef5a46', '#6d1717'),
background('slime', backgroundNames.slime, '#b6ee55', '#44972f'),
background('deep-dark', backgroundNames.deepDark, '#245369', '#0c1f2b'),
background('stone', backgroundNames.stone, '#aeb5bd', '#525b66'),
background('midnight', backgroundNames.midnight, '#4d5f8f', '#171b2d'),
]
const { formatMessage } = useVIntl()
@@ -35,7 +35,7 @@ const progressWidth = computed(() => `${Math.min(100, (props.current / props.tot
<h2 :id="`onboarding-title-${step.id}`">{{ formatMessage(step.title) }}</h2>
</div>
<ButtonStyled circular type="transparent">
<button data-onboarding-skip :aria-label="formatMessage(onboardingMessages.skip)" @click.stop="$emit('skip')">
<button :aria-label="formatMessage(onboardingMessages.skip)" @click.stop="$emit('skip')">
<XIcon />
</button>
</ButtonStyled>
@@ -170,7 +170,7 @@ const {
top: auto !important;
right: 0;
bottom: 0;
left: var(--left-bar-width, 4rem) !important;
left: 0 !important;
z-index: 2;
width: auto;
min-height: 0;
@@ -41,6 +41,24 @@ export const onboardingMessages = defineMessages({
defaultMessage: 'Your last next launcher.',
},
start: { id: 'app.onboarding.action.start', defaultMessage: 'Take the tour' },
homeWidgetsTitle: {
id: 'app.onboarding.home-widgets.title',
defaultMessage: 'Your Home, your layout',
},
homeWidgetsDescription: {
id: 'app.onboarding.home-widgets.description',
defaultMessage:
'Information Home is built from widgets for recent activity, playtime, instances, worlds, and servers. The grid reflows as the window or account sidebar changes.',
},
homeCustomizeTitle: {
id: 'app.onboarding.home-customize.title',
defaultMessage: 'Arrange it your way',
},
homeCustomizeDescription: {
id: 'app.onboarding.home-customize.description',
defaultMessage:
'Use the bottom-right edit control to add, resize, and configure widgets. While editing, switch between an automatically packed grid and a free grid that preserves empty cells.',
},
discoverTitle: { id: 'app.onboarding.discover.title', defaultMessage: 'Find something new' },
discoverDescription: {
id: 'app.onboarding.discover.description',
@@ -72,12 +90,12 @@ export const onboardingMessages = defineMessages({
},
homeLayoutTitle: {
id: 'app.onboarding.home-layout.title',
defaultMessage: 'Switch your home',
defaultMessage: 'Change the amount of detail',
},
homeLayoutDescription: {
id: 'app.onboarding.home-layout.description',
defaultMessage:
'Use the bottom-right control to switch between the StarLight skin site home and the focused instance launcher.',
'Use the bottom-right control to switch between Information Home and Minimal Home. Widget editing stays with Information Home.',
},
continueArea: {
id: 'app.onboarding.action.continue-area',
@@ -87,7 +105,7 @@ export const onboardingMessages = defineMessages({
skinsDescription: {
id: 'app.onboarding.skins.description',
defaultMessage:
'Choose a skin site player or Minecraft account, then preview and apply the skins available to that profile.',
'Keep your Minecraft skins together. Signing in can wait until you feel like it.',
},
clickSkins: {
id: 'app.onboarding.action.click-skins',
@@ -96,8 +114,16 @@ export const onboardingMessages = defineMessages({
skinsPageTitle: { id: 'app.onboarding.skins-page.title', defaultMessage: 'Your skin drawer' },
skinsPageDescription: {
id: 'app.onboarding.skins-page.description',
defaultMessage: 'Add, preview, sort, and apply skins here. No pressure to sign in just yet.',
},
accountTitle: {
id: 'app.onboarding.account.title',
defaultMessage: 'Accounts, on your schedule',
},
accountDescription: {
id: 'app.onboarding.account.description',
defaultMessage:
'Select a profile, preview its available skins, and apply changes when that account supports skin management.',
'When you are ready, sign in, switch accounts, or open your profile here. No deadline.',
},
downloadsTitle: { id: 'app.onboarding.downloads.title', defaultMessage: 'Download control room' },
downloadsDescription: {
@@ -134,6 +160,35 @@ export const onboardingMessages = defineMessages({
defaultMessage:
'Theme, accent, backgrounds, and window effects all live here. Make the launcher feel familiar.',
},
languageTitle: { id: 'app.onboarding.language.title', defaultMessage: 'Speak your language' },
languageDescription: {
id: 'app.onboarding.language.description',
defaultMessage: 'Pick the launcher language and manage translations. No decoder ring required.',
},
translationTitle: {
id: 'app.onboarding.translation.title',
defaultMessage: 'Translation, the Starlight way',
},
translationDescription: {
id: 'app.onboarding.translation.description',
defaultMessage:
'Translate Modrinth project titles, summaries, and descriptions while you browse. Keep the original, show both, or make the translation the main character.',
},
aiTitle: {
id: 'app.onboarding.ai.title',
defaultMessage: 'Bring your own AI provider',
},
aiDescription: {
id: 'app.onboarding.ai.description',
defaultMessage:
'Connect text-model providers once, choose the models you want available, or switch every AI feature off in one place.',
},
javaTitle: { id: 'app.onboarding.java.title', defaultMessage: 'Java, under the hood' },
javaDescription: {
id: 'app.onboarding.java.description',
defaultMessage:
'The Java runtimes that start Minecraft live here. Technical, but well-behaved.',
},
defaultsTitle: { id: 'app.onboarding.defaults.title', defaultMessage: 'Start ahead' },
defaultsDescription: {
id: 'app.onboarding.defaults.description',
@@ -149,6 +204,11 @@ export const onboardingMessages = defineMessages({
defaultMessage:
'Choose how content downloads and installs, from download sources to safety checks.',
},
updatesTitle: { id: 'app.onboarding.updates.title', defaultMessage: 'Stay in the loop' },
updatesDescription: {
id: 'app.onboarding.updates.description',
defaultMessage: 'Choose when Starlight checks for updates and whether it installs them for you.',
},
clickTab: { id: 'app.onboarding.action.click-tab', defaultMessage: 'Click this tab to continue' },
libraryTitle: { id: 'app.onboarding.library.title', defaultMessage: 'Your launch shelf' },
libraryDescription: {
@@ -167,7 +227,7 @@ export const onboardingMessages = defineMessages({
libraryPageDescription: {
id: 'app.onboarding.library-page.description',
defaultMessage:
'Switch between all instances, modpacks, and custom setups, then open any instance to manage it.',
'Filter by modpack, server, or custom setup, then open any instance to manage it.',
},
createTitle: { id: 'app.onboarding.create.title', defaultMessage: 'Make a fresh start' },
createDescription: {
@@ -179,15 +239,6 @@ export const onboardingMessages = defineMessages({
defaultMessage: 'Click Create new instance to continue',
},
creationTitle: { id: 'app.onboarding.creation.title', defaultMessage: 'Pick your route' },
instanceModeTitle: {
id: 'app.onboarding.instance-mode.title',
defaultMessage: 'Choose your instance type',
},
instanceModeDescription: {
id: 'app.onboarding.instance-mode.description',
defaultMessage:
'StarLight automatically installs the administrator-selected modpack and versions, then checks and completes updates before every launch. A StarLight login is required. Choose Local to select your own versions and modpacks for other servers or single-player.',
},
creationDescription: {
id: 'app.onboarding.creation.description',
defaultMessage:
@@ -242,7 +293,7 @@ export const onboardingMessages = defineMessages({
instanceActionsDescription: {
id: 'app.onboarding.instance-actions.description',
defaultMessage:
'Launch or configure this instance here. You can launch another window while it is running; use the top bar to stop an individual window. Choose a player on first launch; the instance remembers your choice until you switch it in settings.',
'Launch, stop, repair, configure, export, or open the instance from its header.',
},
instanceTabsTitle: {
id: 'app.onboarding.instance-tabs.title',
@@ -252,42 +303,6 @@ export const onboardingMessages = defineMessages({
id: 'app.onboarding.instance-tabs.description',
defaultMessage: 'Use these tabs for content, files, screenshots, worlds, and logs. Tidy chaos.',
},
instanceContentTabTitle: {
id: 'app.onboarding.instance-content-tab.title',
defaultMessage: 'Content',
},
instanceContentTabDescription: {
id: 'app.onboarding.instance-content-tab.description',
defaultMessage:
'Manage this instance’s mods, resource packs, and shaders here. Search, enable or disable entries, check for updates, and browse for more.',
},
instanceFilesTabTitle: {
id: 'app.onboarding.instance-files-tab.title',
defaultMessage: 'Files',
},
instanceFilesTabDescription: {
id: 'app.onboarding.instance-files-tab.description',
defaultMessage:
'Browse the instance folder and open configs or logs in the built-in editor without leaving the launcher.',
},
instanceWorldsTabTitle: {
id: 'app.onboarding.instance-worlds-tab.title',
defaultMessage: 'Worlds',
},
instanceWorldsTabDescription: {
id: 'app.onboarding.instance-worlds-tab.description',
defaultMessage:
'Manage saved worlds: back them up, rename, edit world settings, and open the world folder.',
},
instanceLogsTabTitle: {
id: 'app.onboarding.instance-logs-tab.title',
defaultMessage: 'Logs',
},
instanceLogsTabDescription: {
id: 'app.onboarding.instance-logs-tab.description',
defaultMessage:
'Read the instance’s live logs to trace crashes and errors without digging through files.',
},
labTitle: {
id: 'app.onboarding.lab.title',
defaultMessage: 'Useful tools, built in',
@@ -295,7 +310,7 @@ export const onboardingMessages = defineMessages({
labDescription: {
id: 'app.onboarding.lab.description',
defaultMessage:
'The Lab keeps Minecraft creation, world, and maintenance tools inside the launcher.',
'The Lab keeps local Minecraft tools inside the launcher, without another website or account.',
},
clickLab: {
id: 'app.onboarding.action.click-lab',
@@ -308,52 +323,66 @@ export const onboardingMessages = defineMessages({
labToolsDescription: {
id: 'app.onboarding.lab-tools.description',
defaultMessage:
'Create and edit skins, generate formatted text and recipes, explore seeds, inspect schematics, and translate mods locally.',
'Create formatted text and recipe data packs, explore Java worlds, and inspect schematic builds without leaving the launcher.',
},
openGradientText: {
id: 'app.onboarding.action.open-gradient-text',
defaultMessage: 'Open Gradient text generator to continue',
},
labEditorTitle: {
id: 'app.onboarding.lab-editor.title',
defaultMessage: 'Build and copy in one place',
},
labEditorDescription: {
id: 'app.onboarding.lab-editor.description',
defaultMessage:
'Edit text, choose colors, preview the result, and copy the format your Minecraft setup expects.',
},
labSeedMapTitle: {
id: 'app.onboarding.lab-seed-map.title',
defaultMessage: 'Find a world before you load it',
},
labSeedMapDescription: {
id: 'app.onboarding.lab-seed-map.description',
defaultMessage:
'Enter a seed or load one from an instance, then inspect biomes, structures, and ore layers on the local map.',
},
returnToLab: {
id: 'app.onboarding.action.return-lab',
defaultMessage: 'Click Lab to continue',
},
openSeedMap: {
id: 'app.onboarding.action.open-seed-map',
defaultMessage: 'Open Seed map to continue',
},
openSchematicWorkshop: {
id: 'app.onboarding.action.open-schematic-workshop',
defaultMessage: 'Open Schematic workshop to continue',
},
openRecipeGenerator: {
id: 'app.onboarding.action.open-recipe-generator',
defaultMessage: 'Open Recipe generator to continue',
},
labRecipeGeneratorTitle: {
id: 'app.onboarding.lab-recipe-generator.title',
defaultMessage: 'Craft data pack recipes',
},
labRecipeGeneratorDescription: {
id: 'app.onboarding.lab-recipe-generator.description',
defaultMessage:
'Pick a Java version, fill the recipe slots, and copy or export the JSON locally.',
},
labSchematicTitle: {
id: 'app.onboarding.lab-schematic.title',
defaultMessage: 'Inspect a build before placing it',
},
labSchematicDescription: {
id: 'app.onboarding.lab-schematic.description',
defaultMessage:
'Open a local .litematic or .schem file, or choose one from an installed instance. The 3D workspace keeps viewing, measurement, layer controls, materials, and local edits together.',
},
skip: { id: 'app.onboarding.action.skip', defaultMessage: 'Leave the tour' },
mascotAlt: { id: 'app.onboarding.mascot-alt', defaultMessage: 'Starlight guide' },
homeIntroTitle: { id: 'app.onboarding.home-intro.title', defaultMessage: 'Welcome home' },
homeIntroDescription: {
id: 'app.onboarding.home-intro.description',
defaultMessage:
'This is your home page. It shows your instances, recent activity, and quick shortcuts to get playing.',
},
accountLoginTitle: {
id: 'app.onboarding.account-login.title',
defaultMessage: 'Sign in to StarLight',
},
accountLoginDescription: {
id: 'app.onboarding.account-login.description',
defaultMessage:
'Sign in to the StarLight skin site here to use your StarLight players, install hosted modpacks, and sync your account.',
},
announcementTitle: {
id: 'app.onboarding.announcement.title',
defaultMessage: 'Latest announcements',
},
announcementDescription: {
id: 'app.onboarding.announcement.description',
defaultMessage:
'Open the announcement entry in the sidebar to read the latest news, rules, and server notices.',
},
createStarlightTitle: {
id: 'app.onboarding.create-starlight.title',
defaultMessage: 'Create a StarLight instance',
},
createStarlightDescription: {
id: 'app.onboarding.create-starlight.description',
defaultMessage:
'Use Create new instance here. A StarLight instance installs the server-selected modpack and versions, then keeps them updated before each launch.',
},
settingsUpdatesTitle: {
id: 'app.onboarding.settings-updates.title',
defaultMessage: 'Stay up to date',
},
settingsUpdatesDescription: {
id: 'app.onboarding.settings-updates.description',
defaultMessage:
'The Updates tab controls the update channel, checks for new launcher versions, and shows the changelog.',
},
})
const step = (
@@ -409,33 +438,32 @@ const settingsTourSteps: Array<[string, string, MessageDescriptor, MessageDescri
export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
main: [
inspect(
'home-intro',
'home-widget-grid',
onboardingMessages.homeIntroTitle,
onboardingMessages.homeIntroDescription,
),
inspect(
'account-login',
'sidebar-accounts',
onboardingMessages.accountLoginTitle,
onboardingMessages.accountLoginDescription,
),
inspect(
'announcement',
'home-announcement',
onboardingMessages.announcementTitle,
onboardingMessages.announcementDescription,
),
step(
'create-starlight',
'navigate',
'welcome',
'manual',
copy(
onboardingMessages.createStarlightTitle,
onboardingMessages.createStarlightDescription,
onboardingMessages.clickCreate,
onboardingMessages.welcomeTitle,
onboardingMessages.welcomeDescription,
onboardingMessages.start,
),
control('create-instance', '/create'),
),
inspect(
'home-widget-grid',
'home-widget-grid',
onboardingMessages.homeWidgetsTitle,
onboardingMessages.homeWidgetsDescription,
),
inspect(
'home-widget-customize',
'home-widget-customize',
onboardingMessages.homeCustomizeTitle,
onboardingMessages.homeCustomizeDescription,
),
inspect(
'home-layout-switch',
'home-layout-switch',
onboardingMessages.homeLayoutTitle,
onboardingMessages.homeLayoutDescription,
),
step(
'discover-navigation',
@@ -445,7 +473,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.discoverDescription,
onboardingMessages.clickDiscover,
),
control('nav-discover', '/browse'),
control('nav-discover', '/browse/modpack'),
),
inspect(
'discover-content',
@@ -453,6 +481,22 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.browseTitle,
onboardingMessages.browseDescription,
),
step(
'discover-favorites-navigation',
'navigate',
copy(
onboardingMessages.favoritesTitle,
onboardingMessages.favoritesDescription,
onboardingMessages.clickFavorites,
),
control('browse-favorites-tab', '/browse/favorites'),
),
inspect(
'discover-favorites-content',
'browse-favorites-content',
onboardingMessages.favoritesTitle,
onboardingMessages.favoritesDescription,
),
step(
'skins-navigation',
'navigate',
@@ -470,20 +514,14 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.skinsPageDescription,
),
step(
'library-navigation',
'navigate',
'account',
'inspect',
copy(
onboardingMessages.libraryTitle,
onboardingMessages.libraryDescription,
onboardingMessages.clickLibrary,
onboardingMessages.accountTitle,
onboardingMessages.accountDescription,
onboardingMessages.continueArea,
),
control('nav-library', '/library'),
),
inspect(
'library-content',
'library-content',
onboardingMessages.libraryPageTitle,
onboardingMessages.libraryPageDescription,
control('account-entry'),
),
step(
'lab-navigation',
@@ -501,6 +539,90 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.labToolsTitle,
onboardingMessages.labToolsDescription,
),
step(
'lab-gradient-text-navigation',
'navigate',
copy(
onboardingMessages.labEditorTitle,
onboardingMessages.labEditorDescription,
onboardingMessages.openGradientText,
),
control('lab-gradient-text-card', '/lab/gradient-text'),
),
inspect(
'lab-gradient-text-editor',
'lab-gradient-text-editor',
onboardingMessages.labEditorTitle,
onboardingMessages.labEditorDescription,
),
step(
'lab-return-navigation',
'navigate',
copy(
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
onboardingMessages.returnToLab,
),
control('nav-lab', '/lab'),
),
step(
'lab-seed-map-navigation',
'navigate',
copy(
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
onboardingMessages.openSeedMap,
),
control('lab-seed-map-card', '/lab/seed-map'),
),
inspect(
'lab-seed-map-workspace',
'seed-map-workspace',
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
),
step(
'lab-return-schematic-navigation',
'navigate',
copy(
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
onboardingMessages.returnToLab,
),
control('nav-lab', '/lab'),
),
step(
'lab-schematic-navigation',
'navigate',
copy(
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
onboardingMessages.openSchematicWorkshop,
),
control('lab-schematic-preview-card', '/lab/schematic-preview'),
),
inspect(
'lab-schematic-workspace',
'schematic-preview-workspace',
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
),
step(
'lab-recipe-generator-navigation',
'navigate',
copy(
onboardingMessages.labRecipeGeneratorTitle,
onboardingMessages.labRecipeGeneratorDescription,
onboardingMessages.openRecipeGenerator,
),
control('lab-recipe-generator-card', '/lab/recipe-generator'),
),
inspect(
'lab-recipe-generator-workspace',
'recipe-generator-workspace',
onboardingMessages.labRecipeGeneratorTitle,
onboardingMessages.labRecipeGeneratorDescription,
),
step(
'downloads-navigation',
'navigate',
@@ -519,7 +641,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
),
step(
'settings-navigation',
'navigate',
'activate',
copy(
onboardingMessages.settingsTitle,
onboardingMessages.settingsDescription,
@@ -527,15 +649,88 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
),
control('nav-settings', '/settings'),
),
...settingsTourSteps.map(([id, targetId, title, description], index) =>
step(id, 'activate', copy(title, description, onboardingMessages.clickTab), {
...control(targetId),
closeSettingsAfter: index === settingsTourSteps.length - 1,
}),
),
step(
'settings-updates',
'library-navigation',
'navigate',
copy(
onboardingMessages.libraryTitle,
onboardingMessages.libraryDescription,
onboardingMessages.clickLibrary,
),
control('nav-library', '/library'),
),
inspect(
'library-content',
'library-content',
onboardingMessages.libraryPageTitle,
onboardingMessages.libraryPageDescription,
),
step(
'create-instance',
'navigate',
copy(
onboardingMessages.createTitle,
onboardingMessages.createDescription,
onboardingMessages.clickCreate,
),
control('create-instance', '/create'),
),
step(
'creation-flow',
'activate',
copy(
onboardingMessages.settingsUpdatesTitle,
onboardingMessages.settingsUpdatesDescription,
onboardingMessages.clickTab,
onboardingMessages.creationTitle,
onboardingMessages.creationDescription,
onboardingMessages.clickCreationMethod,
),
control('settings-tab-updates'),
{
targetId: 'creation-methods',
branchByTarget: {
'creation-method-custom': { creationPath: 'custom', next: 'creation-name' },
'creation-method-import': { next: 'complete' },
},
},
),
inspect(
'creation-name',
'creation-name',
onboardingMessages.creationNameTitle,
onboardingMessages.creationNameDescription,
),
inspect(
'creation-loader',
'creation-loader',
onboardingMessages.creationLoaderTitle,
onboardingMessages.creationLoaderDescription,
),
step(
'creation-version',
'inspect',
copy(
onboardingMessages.creationVersionTitle,
onboardingMessages.creationVersionDescription,
onboardingMessages.continueArea,
),
{
targetId: 'creation-game-version',
nextByCreationPath: { custom: 'creation-confirm' },
},
),
step(
'creation-confirm',
'inspect',
copy(
onboardingMessages.creationConfirmTitle,
onboardingMessages.creationConfirmDescription,
onboardingMessages.finishArea,
),
{ targetId: 'creation-confirm' },
),
],
instance: [
@@ -545,29 +740,15 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.instanceActionsTitle,
onboardingMessages.instanceActionsDescription,
),
inspect(
'instance-content-tab',
'instance-tab-content',
onboardingMessages.instanceContentTabTitle,
onboardingMessages.instanceContentTabDescription,
),
inspect(
'instance-files-tab',
'instance-tab-files',
onboardingMessages.instanceFilesTabTitle,
onboardingMessages.instanceFilesTabDescription,
),
inspect(
'instance-worlds-tab',
'instance-tab-worlds',
onboardingMessages.instanceWorldsTabTitle,
onboardingMessages.instanceWorldsTabDescription,
),
inspect(
'instance-logs-tab',
'instance-tab-logs',
onboardingMessages.instanceLogsTabTitle,
onboardingMessages.instanceLogsTabDescription,
step(
'instance-tabs',
'inspect',
copy(
onboardingMessages.instanceTabsTitle,
onboardingMessages.instanceTabsDescription,
onboardingMessages.finishArea,
),
{ targetId: 'instance-tabs' },
),
],
}
@@ -146,14 +146,6 @@ export function useOnboardingTour(
function scheduleMissingTargetRetry(stepId: string) {
if (targetRetryCount >= missingTargetRetryLimit) {
// 超过重试上限:inspect 步(介绍性步骤)不自动跳过,
// 保持无锚点状态,让气泡停靠底部等用户手动继续。
if (step.value.interaction === 'inspect') {
targetElement.value = undefined
targetRect.value = null
clearModalReservation()
return
}
void advance()
return
}
@@ -279,11 +271,8 @@ export function useOnboardingTour(
function handleDocumentClick(event: MouseEvent) {
if (!visible.value) return
const clickedElement = event.target instanceof Element ? event.target : null
if (clickedElement?.closest('[data-onboarding-overlay-ui]')) return
// 跳过/关闭按钮由其自身处理,不在全局点击里推进
if (clickedElement?.closest('[data-onboarding-skip]')) return
// inspect 步:点击任意位置(含导览气泡本身)都继续下一步
if (step.value.interaction === 'inspect') {
event.preventDefault()
event.stopImmediatePropagation()
@@ -291,14 +280,11 @@ export function useOnboardingTour(
return
}
// 非 inspect 步:点在导览 UI 自身时不处理
if (clickedElement?.closest('[data-onboarding-overlay-ui]')) return
if (!step.value.targetId || !['navigate', 'activate'].includes(step.value.interaction)) return
if (clickedElement && handleBranchClick(clickedElement)) return
if (!targetElement.value?.contains(event.target as Node)) return
if (step.value.expectedPath && !route.path.startsWith(step.value.expectedPath)) {
if (step.value.expectedPath && route.path !== step.value.expectedPath) {
waitingForRoute.value = true
return
}
@@ -315,7 +301,7 @@ export function useOnboardingTour(
() => route.path,
(path) => {
if (!waitingForRoute.value || !step.value.expectedPath) return
if (!path.startsWith(step.value.expectedPath)) return
if (path !== step.value.expectedPath) return
waitingForRoute.value = false
void advance()
},
@@ -1,289 +0,0 @@
<script lang="ts"></script>
<script setup lang="ts">
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
import { CheckIcon, ClipboardCopyIcon, EditIcon, MoreHorizontalIcon } from '@modrinth/assets'
import {
defineMessages,
IconButton,
useDebugLogger,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useImageThumbnail } from '@/composables/use-image-thumbnail'
import type { InstanceScreenshot } from '@/helpers/instance'
const props = defineProps<{
screenshot: InstanceScreenshot
selectionKey: string
selected: boolean
selectionActive: boolean
activeDragged: boolean
canDrag: boolean
showInstanceName: boolean
highlighted: boolean
copied: boolean
}>()
const emit = defineEmits<{
(e: 'activate', event: MouseEvent | KeyboardEvent): void
(e: 'copy' | 'edit' | 'toggle-selection'): void
(e: 'more', event: MouseEvent): void
}>()
const card = ref<HTMLElement>()
const thumbnail = useImageThumbnail(
() => ({
instanceId: props.screenshot.instance_id,
filePath: `screenshots/${props.screenshot.file_name}`,
path: props.screenshot.path,
}),
512,
() => props.screenshot.modified_at,
)
const image = ref<HTMLImageElement>()
const imageReady = ref(false)
const { formatMessage } = useVIntl()
const debugImage = useDebugLogger('Screenshots:Card')
const formatTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
let loadStartedAt = performance.now()
let loadGeneration = 0
const messages = defineMessages({
select: { id: 'app.screenshots.select', defaultMessage: 'Select {name}' },
deselect: { id: 'app.screenshots.deselect', defaultMessage: 'Deselect {name}' },
copy: { id: 'app.screenshots.copy', defaultMessage: 'Copy image' },
copied: { id: 'app.screenshots.copied', defaultMessage: 'Copied' },
edit: { id: 'app.screenshots.edit', defaultMessage: 'Edit screenshot' },
moreActions: { id: 'app.screenshots.more-actions', defaultMessage: 'More actions' },
})
const sensors = [
PointerSensor.configure({
preventActivation: () => false,
}),
KeyboardSensor,
]
useDraggable({
id: computed(() => `screenshot:${props.selectionKey}`),
element: card,
disabled: computed(() => !props.canDrag),
sensors,
data: computed(() => ({
selectionKey: props.selectionKey,
instanceId: props.screenshot.instance_id,
})),
})
function activate(event: MouseEvent | KeyboardEvent) {
if (event instanceof KeyboardEvent) {
if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) {
return
}
event.preventDefault()
}
emit('activate', event)
}
async function markImageLoaded() {
const loadedImage = image.value
const loadedUrl = thumbnail.value
const generation = loadGeneration
if (!loadedImage) return
try {
await loadedImage.decode()
} catch {
if (!loadedImage.complete || loadedImage.naturalWidth === 0) return
}
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => window.requestAnimationFrame(() => resolve()))
})
if (
generation !== loadGeneration ||
image.value !== loadedImage ||
thumbnail.value !== loadedUrl
) {
return
}
const wasReady = imageReady.value
imageReady.value = true
if (!wasReady) {
debugImage('image loaded', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loadDurationMs: performance.now() - loadStartedAt,
naturalWidth: image.value?.naturalWidth,
naturalHeight: image.value?.naturalHeight,
})
}
}
function markImageFailed(event: Event) {
debugImage('image failed', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loadDurationMs: performance.now() - loadStartedAt,
event,
})
}
onMounted(() => {
debugImage('mounted', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
complete: image.value?.complete,
})
if (image.value?.complete && image.value.naturalWidth > 0) markImageLoaded()
})
onBeforeUnmount(() => {
loadGeneration += 1
debugImage('unmounted', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loaded: imageReady.value,
})
})
watch(thumbnail, (url, previousUrl) => {
loadGeneration += 1
loadStartedAt = performance.now()
imageReady.value = false
debugImage('source changed', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
previousUrl,
url,
})
})
</script>
<template>
<article
ref="card"
role="button"
tabindex="0"
class="group relative isolate aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
:class="{
'!border-brand brightness-110': selected,
'!border-brand ring-2 ring-brand animate-pulse': highlighted,
'opacity-50': activeDragged,
'cursor-grab active:cursor-grabbing': canDrag,
}"
data-screenshot-card
:data-screenshot-id="screenshot.id"
:data-selection-key="selectionKey"
:aria-label="
selectionActive
? formatMessage(selected ? messages.deselect : messages.select, {
name: screenshot.file_name,
})
: screenshot.file_name
"
:aria-pressed="selectionActive ? selected : undefined"
@click="activate"
@contextmenu.prevent.stop="emit('more', $event)"
@keydown="activate"
>
<button
type="button"
class="selection-button group/selection absolute right-0.5 top-0 z-[3] flex size-[50px] cursor-pointer items-start justify-center border-0 bg-transparent p-0 pt-4"
:aria-label="
formatMessage(selected ? messages.deselect : messages.select, {
name: screenshot.file_name,
})
"
:aria-pressed="selected"
@click.stop="emit('toggle-selection')"
>
<span
class="relative flex size-6 items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover:opacity-100 group-focus-within:opacity-100 group-hover/selection:brightness-125"
:class="
selected ? 'border-0 !opacity-100' : 'border-2 border-solid border-primary bg-transparent'
"
>
<span v-if="selected" class="absolute inset-0 rounded-full bg-brand" />
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
</span>
</button>
<img
v-if="thumbnail"
ref="image"
:src="thumbnail"
:alt="screenshot.file_name"
loading="eager"
decoding="async"
draggable="false"
class="screenshot-card-fade absolute inset-0 z-[1] h-full w-full object-cover"
:class="imageReady ? 'opacity-100' : 'opacity-0'"
@load="markImageLoaded"
@error="markImageFailed"
/>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-0 bg-surface-4"
:class="{ 'animate-pulse': !imageReady }"
/>
<div
class="absolute inset-x-0 bottom-0 z-[2] flex items-end justify-between gap-2 bg-gradient-to-t from-surface-1 to-transparent p-3 pt-[120px] text-[var(--color-text-primary)] opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
>
<div class="min-w-0">
<div v-tooltip="screenshot.file_name" class="truncate text-sm font-semibold">
{{ screenshot.file_name }}
</div>
<div class="truncate text-xs text-[var(--color-text-tertiary)]">
{{ showInstanceName ? screenshot.instance_name : formatTime(screenshot.created_at) }}
</div>
</div>
<div
v-if="!selectionActive"
class="flex shrink-0 translate-y-1 gap-1 opacity-0 transition group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100"
@click.stop
>
<IconButton
v-tooltip="formatMessage(messages.edit)"
:label="formatMessage(messages.edit)"
type="quiet"
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
@click="emit('edit')"
>
<EditIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(copied ? messages.copied : messages.copy)"
:label="formatMessage(copied ? messages.copied : messages.copy)"
type="quiet"
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
@click="emit('copy')"
>
<CheckIcon v-if="copied" class="text-green" />
<ClipboardCopyIcon v-else />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.moreActions)"
:label="formatMessage(messages.moreActions)"
type="quiet"
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
@click="emit('more', $event)"
>
<MoreHorizontalIcon />
</IconButton>
</div>
</div>
</article>
</template>
<style scoped>
.screenshot-card-fade {
transition: opacity 350ms ease-in-out;
}
</style>
@@ -1,96 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import ScreenshotDragPreview from './drag-preview.vue'
import { gatherDuration, type ScreenshotDragGatherItem } from './use-screenshot-drag-gather'
const props = defineProps<{
items: ScreenshotDragGatherItem[]
target: {
x: number
y: number
}
}>()
const emit = defineEmits<{
complete: []
}>()
const gathered = ref(false)
const finishing = ref(false)
let animationFrame: number | undefined
let completionTimer: number | undefined
const itemContainerStyles = computed(() =>
props.items.map((_, index) => ({
left: `${props.target.x}px`,
top: `${props.target.y}px`,
zIndex: `${index}`,
})),
)
const itemStyles = computed(() =>
props.items.map((item, index) => {
const originX = item.rect.left - props.target.x
const originY = item.rect.top - props.target.y
return {
width: `${item.rect.width}px`,
height: `${item.rect.height}px`,
transform: gathered.value
? `translate3d(${-item.rect.width / 2}px, ${-item.rect.height / 2}px, 0)`
: `translate3d(${originX}px, ${originY}px, 0)`,
transitionDuration: `${gatherDuration}ms`,
transitionDelay: `${Math.min(index, 8) * 10}ms`,
}
}),
)
onMounted(() => {
animationFrame = requestAnimationFrame(() => {
animationFrame = requestAnimationFrame(() => {
gathered.value = true
completionTimer = window.setTimeout(
() => {
finishing.value = true
emit('complete')
},
Math.max(gatherDuration - 250, 0),
)
})
})
})
onBeforeUnmount(() => {
if (animationFrame !== undefined) {
cancelAnimationFrame(animationFrame)
}
if (completionTimer !== undefined) {
clearTimeout(completionTimer)
}
})
</script>
<template>
<Teleport to="body">
<div
class="pointer-events-none fixed inset-0 z-[9998] overflow-hidden transition-opacity duration-150 ease-out"
:class="finishing ? 'opacity-0' : 'opacity-100'"
aria-hidden="true"
>
<div
v-for="(item, index) in items"
:key="item.selectionKey"
class="fixed"
:style="itemContainerStyles[index]"
>
<div
class="origin-center transition-transform ease-[cubic-bezier(0.2,0.8,0.2,1)]"
:style="itemStyles[index]"
>
<ScreenshotDragPreview :screenshot="item.screenshot" />
</div>
</div>
</div>
</Teleport>
</template>
@@ -1,75 +0,0 @@
<script setup lang="ts">
import { TagItem } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useImageThumbnail } from '@/composables/use-image-thumbnail'
import type { InstanceScreenshot } from '@/helpers/instance'
import { gatherDuration } from './use-screenshot-drag-gather'
const props = withDefaults(
defineProps<{
screenshot: InstanceScreenshot
count?: number
}>(),
{
count: 1,
},
)
const thumbnail = useImageThumbnail(
() => ({
instanceId: props.screenshot.instance_id,
filePath: `screenshots/${props.screenshot.file_name}`,
path: props.screenshot.path,
}),
512,
() => props.screenshot.modified_at,
)
const showGatheredCount = ref(false)
let countTimer: number | undefined
onMounted(() => {
if (props.count <= 1 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
showGatheredCount.value = true
return
}
countTimer = window.setTimeout(() => {
showGatheredCount.value = true
countTimer = undefined
}, gatherDuration - 150)
})
onBeforeUnmount(() => {
if (countTimer !== undefined) {
clearTimeout(countTimer)
}
})
</script>
<template>
<div aria-hidden="true" class="relative w-full select-none">
<div
v-if="count > 1"
class="absolute inset-x-3 -bottom-2 top-2 rounded-xl border border-solid border-surface-5 bg-surface-2 shadow-md motion-safe:transition-opacity motion-safe:duration-200 motion-safe:delay-100 motion-safe:ease-out"
:class="showGatheredCount ? 'opacity-60' : 'opacity-0'"
/>
<div
v-if="count > 1"
class="absolute inset-x-1.5 -bottom-1 top-1 rounded-xl border border-solid border-surface-4 bg-surface-3 shadow-md motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
:class="showGatheredCount ? 'opacity-80' : 'opacity-0'"
/>
<div
class="relative aspect-video w-full overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 opacity-90 shadow-lg"
>
<img v-if="thumbnail" :src="thumbnail" alt="" class="h-full w-full object-cover" />
<TagItem
v-if="count > 1"
class="!absolute right-3 top-3 z-[2] border-surface-5 bg-surface-4 tabular-nums text-[var(--color-text-tertiary)] motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
>
{{ count }}
</TagItem>
</div>
</div>
</template>
@@ -1,243 +0,0 @@
<script setup lang="ts">
import { useDroppable } from '@dnd-kit/vue'
import { defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
import ScreenshotCard from './card.vue'
import ScreenshotSection from './section.vue'
const props = defineProps<{
id: string
title: string
screenshots: InstanceScreenshot[]
renderedScreenshots?: InstanceScreenshot[]
virtualGridHeight?: number
virtualGridTop?: number
selectedKeys: ReadonlySet<string>
selectionActive: boolean
activeDraggedKeys: ReadonlySet<string>
showDropOutline: boolean
canDrag: boolean
dropInstanceId?: string
dropCustomGroup?: boolean
dropCustomGroupId?: string
showInstanceName: boolean
highlightedScreenshotId?: string
copiedScreenshotIds: ReadonlySet<string>
forceOpen: boolean
hideHeader?: boolean
editableTitle?: boolean
startEditingTitle?: boolean
maxTitleLength?: number
validateTitle?: (value: string) => boolean
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
}>()
const collapsed = defineModel<boolean>('collapsed', { required: true })
const dropTarget = ref<HTMLElement>()
const { formatMessage } = useVIntl()
const debugLayout = useDebugLogger('Screenshots:Group')
const messages = defineMessages({
emptyGroup: {
id: 'app.screenshots.group.empty',
defaultMessage: 'Drag and drop to add screenshots.',
},
})
const shouldShowGrid = computed(() =>
Boolean(props.hideHeader || props.forceOpen || !collapsed.value),
)
const visibleScreenshots = ref<InstanceScreenshot[]>(props.renderedScreenshots ?? props.screenshots)
const renderGrid = ref(shouldShowGrid.value)
const virtualGridStyle = computed(() =>
props.virtualGridHeight === undefined ? undefined : { height: `${props.virtualGridHeight}px` },
)
const visibleGridStyle = computed(() =>
props.virtualGridTop === undefined
? undefined
: { transform: `translateY(${props.virtualGridTop}px)` },
)
let unmountGridTimeout: ReturnType<typeof setTimeout> | undefined
let resizeObserver: ResizeObserver | undefined
async function logGeometry(reason: string) {
await nextTick()
const rect = dropTarget.value?.getBoundingClientRect()
debugLayout(reason, {
id: props.id,
title: props.title,
collapsed: collapsed.value,
shouldShowGrid: shouldShowGrid.value,
renderGrid: renderGrid.value,
screenshotCount: props.screenshots.length,
renderedCount: visibleScreenshots.value.length,
firstScreenshotId: visibleScreenshots.value.at(0)?.id,
lastScreenshotId: visibleScreenshots.value.at(-1)?.id,
virtualGridHeight: props.virtualGridHeight,
virtualGridTop: props.virtualGridTop,
actual: rect
? { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
: undefined,
})
}
watch(
() => props.renderedScreenshots ?? props.screenshots,
(screenshots) => {
if (shouldShowGrid.value) visibleScreenshots.value = screenshots
void logGeometry('rendered screenshots changed')
},
)
watch(
() => [props.virtualGridHeight, props.virtualGridTop] as const,
() => void logGeometry('virtual grid geometry changed'),
)
watch(
shouldShowGrid,
(showGrid, previouslyShown) => {
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
if (showGrid) {
visibleScreenshots.value = props.renderedScreenshots ?? props.screenshots
renderGrid.value = true
void logGeometry('grid shown')
return
}
if (!previouslyShown) {
renderGrid.value = false
return
}
unmountGridTimeout = setTimeout(() => {
renderGrid.value = false
void logGeometry('grid unmounted after collapse')
unmountGridTimeout = undefined
}, 300)
},
{ flush: 'post' },
)
onMounted(() => {
debugLayout('mounted', { id: props.id, title: props.title })
void logGeometry('mounted geometry')
if (dropTarget.value) {
resizeObserver = new ResizeObserver(([entry]) => {
const rect = entry?.target.getBoundingClientRect()
debugLayout('actual size changed', {
id: props.id,
title: props.title,
width: rect?.width,
height: rect?.height,
expectedHeight:
(props.hideHeader ? 0 : 40) +
(shouldShowGrid.value ? 10 + (props.virtualGridHeight ?? 0) : 0) +
12,
})
})
resizeObserver.observe(dropTarget.value)
}
})
onBeforeUnmount(() => {
debugLayout('unmounted', {
id: props.id,
title: props.title,
renderedCount: visibleScreenshots.value.length,
})
resizeObserver?.disconnect()
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
})
const emit = defineEmits<{
(e: 'activate', screenshot: InstanceScreenshot, event: MouseEvent | KeyboardEvent): void
(e: 'toggle-selection' | 'copy' | 'edit', screenshot: InstanceScreenshot): void
(e: 'more', screenshot: InstanceScreenshot, event: MouseEvent): void
}>()
useDroppable({
id: computed(() => `screenshot-group:${props.id}`),
element: dropTarget,
disabled: computed(() => !props.dropInstanceId && !props.dropCustomGroup),
data: computed(() =>
props.dropCustomGroup
? { groupId: props.id, customGroupId: props.dropCustomGroupId ?? null }
: { groupId: props.id, instanceId: props.dropInstanceId },
),
})
function getSelectionKey(screenshot: InstanceScreenshot) {
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
}
</script>
<template>
<div
ref="dropTarget"
class="group/instance-container relative select-none pb-3 transition-colors"
>
<Transition
enter-active-class="transition-opacity duration-150 ease-out"
enter-from-class="!opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="!opacity-0"
>
<div
v-if="showDropOutline"
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 border-dashed border-brand bg-brand-highlight opacity-40"
/>
</Transition>
<ScreenshotSection
v-model:collapsed="collapsed"
:title="title"
:count="screenshots.length"
:force-open="forceOpen"
:hide-header="hideHeader"
:editable="editableTitle"
:start-editing="startEditingTitle"
:max-title-length="maxTitleLength"
:validate-title="validateTitle"
:on-title-change="onTitleChange"
>
<template #actions="{ startEditing }">
<slot name="actions" :start-editing="startEditing" />
</template>
<div v-if="renderGrid" class="relative min-h-[45px] w-full" :style="virtualGridStyle">
<div
class="grid min-h-[45px] w-full grid-cols-1 gap-3 sm:grid-cols-2 2xl:grid-cols-4"
:class="{ 'absolute inset-x-0 top-0': virtualGridHeight !== undefined }"
:style="visibleGridStyle"
>
<ScreenshotCard
v-for="screenshot in visibleScreenshots"
:key="getSelectionKey(screenshot)"
:screenshot="screenshot"
:selection-key="getSelectionKey(screenshot)"
:selected="selectedKeys.has(getSelectionKey(screenshot))"
:selection-active="selectionActive"
:active-dragged="activeDraggedKeys.has(getSelectionKey(screenshot))"
:can-drag="canDrag"
:show-instance-name="showInstanceName"
:highlighted="highlightedScreenshotId === screenshot.id"
:copied="copiedScreenshotIds.has(screenshot.id)"
@activate="(event) => emit('activate', screenshot, event)"
@toggle-selection="emit('toggle-selection', screenshot)"
@copy="emit('copy', screenshot)"
@edit="emit('edit', screenshot)"
@more="(event) => emit('more', screenshot, event)"
/>
<p
v-if="screenshots.length === 0"
key="empty-group"
class="col-span-full m-0 pl-0.5 pt-1 text-base font-base text-[var(--color-text-tertiary)] opacity-80"
>
{{ formatMessage(messages.emptyGroup) }}
</p>
</div>
</div>
</ScreenshotSection>
</div>
</template>
File diff suppressed because it is too large. Load diff
@@ -1,169 +0,0 @@
<script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets'
import {
Accordion,
commonMessages,
defineMessages,
InlineEditableText,
TagItem,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
title: string
count: number
collapsed?: boolean
forceOpen?: boolean
hideHeader?: boolean
editable?: boolean
startEditing?: boolean
maxTitleLength?: number
validateTitle?: (value: string) => boolean
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
}>(),
{
collapsed: false,
forceOpen: false,
hideHeader: false,
editable: false,
startEditing: false,
},
)
const emit = defineEmits<{
(e: 'update:collapsed', collapsed: boolean): void
}>()
const { formatMessage } = useVIntl()
const debugLayout = useDebugLogger('Screenshots:Section')
const accordion = ref<InstanceType<typeof Accordion>>()
const titleInput = ref<InstanceType<typeof InlineEditableText>>()
const titleModel = ref(props.title)
const messages = defineMessages({
collapse: { id: 'app.screenshots.group.collapse', defaultMessage: 'Collapse group' },
expand: { id: 'app.screenshots.group.expand', defaultMessage: 'Expand group' },
})
function toggle() {
debugLayout('toggle requested', {
title: props.title,
count: props.count,
isOpen: accordion.value?.isOpen,
})
if (accordion.value?.isOpen) {
accordion.value.close()
} else {
accordion.value?.open()
}
}
function handleOpen() {
debugLayout('opened', { title: props.title, count: props.count })
emit('update:collapsed', false)
}
function handleClose() {
debugLayout('closed', { title: props.title, count: props.count })
emit('update:collapsed', true)
}
onMounted(() => {
debugLayout('mounted', {
title: props.title,
count: props.count,
collapsed: props.collapsed,
hideHeader: props.hideHeader,
forceOpen: props.forceOpen,
})
})
onBeforeUnmount(() => {
debugLayout('unmounted', { title: props.title, count: props.count })
})
async function startTitleEditing() {
if (!props.editable) return
await titleInput.value?.startEditing()
}
watch(
() => props.title,
(title) => {
titleModel.value = title
},
)
watch(
() => props.startEditing,
async (shouldStartEditing) => {
if (!shouldStartEditing) return
await nextTick()
await startTitleEditing()
},
{ immediate: true, flush: 'post' },
)
</script>
<template>
<section class="flex w-full flex-col">
<div
v-if="!hideHeader"
class="group/header flex h-10 w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
>
<div class="group/open-target flex min-w-0 cursor-pointer items-center gap-2" @click="toggle">
<button
type="button"
class="flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0"
:aria-expanded="accordion?.isOpen"
:aria-label="formatMessage(accordion?.isOpen ? messages.collapse : messages.expand)"
@click.stop="toggle"
>
<DropdownIcon
class="size-5 shrink-0 text-[var(--color-text-tertiary)] transition-all duration-300 group-hover/open-target:text-[var(--color-text-default)]"
:class="{ 'rotate-180': accordion?.isOpen }"
/>
</button>
<InlineEditableText
v-if="editable"
ref="titleInput"
v-model="titleModel"
activation-mode="manual"
class="!h-10 select-none text-base font-semibold text-[var(--color-text-default)] group-hover/open-target:text-[var(--color-text-primary)]"
:edit-label="formatMessage(commonMessages.renameButton)"
max-width="24rem"
icon-text-class="select-none"
:max-length="maxTitleLength"
:validate="validateTitle"
:on-change="onTitleChange"
/>
<span
v-else
class="select-none truncate text-base font-semibold text-[var(--color-text-default)] group-hover/open-target:text-[var(--color-text-primary)]"
>
{{ title }}
</span>
<TagItem v-if="count" class="shrink-0 border-surface-3 bg-surface-2">
{{ count }}
</TagItem>
</div>
<div class="min-w-0 flex-1" />
<slot name="actions" :start-editing="startTitleEditing" />
</div>
<Accordion
ref="accordion"
:open-by-default="hideHeader || !props.collapsed"
:force-open="forceOpen"
overflow-visible
class="w-full"
@on-open="handleOpen"
@on-close="handleClose"
>
<div class="mt-2.5">
<slot />
</div>
</Accordion>
</section>
</template>
@@ -1,87 +0,0 @@
<script setup lang="ts">
import { ArrowUpDownIcon, GridIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import {
Button,
Combobox,
type ComboboxOption,
defineMessages,
StyledInput,
useVIntl,
} from '@modrinth/ui'
const search = defineModel<string>('search', { required: true })
const sort = defineModel<string>('sort', { required: true })
const group = defineModel<string>('group', { required: true })
defineProps<{
sortOptions: ComboboxOption<string>[]
groupOptions: ComboboxOption<string>[]
}>()
const emit = defineEmits<{
(e: 'new-group'): void
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
search: { id: 'app.screenshots.search', defaultMessage: 'Search' },
newGroup: { id: 'app.screenshots.group.new', defaultMessage: 'New group' },
sortBy: { id: 'app.screenshots.sort-by', defaultMessage: 'Sort by' },
groupBy: { id: 'app.screenshots.group-by', defaultMessage: 'Group by' },
})
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2">
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.search)"
clearable
wrapper-class="min-w-[16rem] flex-1"
/>
<Button @click="emit('new-group')">
<PlusIcon />
{{ formatMessage(messages.newGroup) }}
</Button>
</div>
<div class="flex flex-wrap items-center gap-2">
<Combobox
v-model="sort"
class="w-max"
:options="sortOptions"
:show-icon-in-selected="false"
dropdown-min-width="160px"
>
<template #prefix>
<ArrowUpDownIcon
class="size-5 text-[var(--color-text-default)]"
:aria-label="formatMessage(messages.sortBy)"
/>
</template>
<template #selected="{ label }">
<span>{{ label }}</span>
</template>
</Combobox>
<Combobox
v-model="group"
class="w-max"
:options="groupOptions"
:show-icon-in-selected="false"
dropdown-min-width="160px"
>
<template #prefix>
<GridIcon
class="size-5 text-[var(--color-text-default)]"
:aria-label="formatMessage(messages.groupBy)"
/>
</template>
<template #selected="{ label }">
<span>{{ label }}</span>
</template>
</Combobox>
</div>
</div>
</template>
@@ -1,130 +0,0 @@
import { onBeforeUnmount, type Ref, ref } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
type Point = {
x: number
y: number
}
export type ActiveScreenshotDrag = {
primarySelectionKey: string
selectionKeys: string[]
}
export const gatherDuration = 500
export type ScreenshotDragGatherItem = {
screenshot: InstanceScreenshot
selectionKey: string
rect: {
left: number
top: number
width: number
height: number
}
}
function getSelectionKey(screenshot: InstanceScreenshot) {
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
}
export function useScreenshotDragGather(screenshots: Ref<InstanceScreenshot[]>) {
const items = ref<ScreenshotDragGatherItem[]>([])
const target = ref<Point>({ x: 0, y: 0 })
const targetOffset = ref<Point>({ x: 0, y: 0 })
const isGathering = ref(false)
let cleanupTimer: number | undefined
const clearCleanupTimer = () => {
if (cleanupTimer !== undefined) {
clearTimeout(cleanupTimer)
cleanupTimer = undefined
}
}
const clear = () => {
clearCleanupTimer()
items.value = []
targetOffset.value = { x: 0, y: 0 }
isGathering.value = false
}
const updateTarget = (pointer: Point) => {
target.value = {
x: pointer.x + targetOffset.value.x,
y: pointer.y + targetOffset.value.y,
}
}
const start = (drag: ActiveScreenshotDrag | null, pointer: Point) => {
clear()
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!drag || drag.selectionKeys.length < 2 || reduceMotion) return
const screenshotCards = Array.from(
document.querySelectorAll<HTMLElement>('[data-screenshot-card]'),
)
const sourceCard = screenshotCards.find(
(card) => card.dataset.selectionKey === drag.primarySelectionKey,
)
if (sourceCard) {
const sourceRect = sourceCard.getBoundingClientRect()
targetOffset.value = {
x: sourceRect.left + sourceRect.width / 2 - pointer.x,
y: sourceRect.top + sourceRect.height / 2 - pointer.y,
}
}
const screenshotsByKey = new Map(
screenshots.value.map((screenshot) => [getSelectionKey(screenshot), screenshot]),
)
items.value = drag.selectionKeys.flatMap((selectionKey) => {
const screenshot = screenshotsByKey.get(selectionKey)
const card = screenshotCards.find(
(candidate) =>
candidate.dataset.selectionKey === selectionKey && candidate.getClientRects().length > 0,
)
if (!screenshot || !card) return []
const rect = card.getBoundingClientRect()
return [
{
screenshot,
selectionKey,
rect: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
},
]
})
updateTarget(pointer)
isGathering.value = items.value.length > 0
}
const finish = () => {
isGathering.value = false
clearCleanupTimer()
cleanupTimer = window.setTimeout(() => {
items.value = []
cleanupTimer = undefined
}, 400)
}
onBeforeUnmount(clearCleanupTimer)
return {
items,
target,
isGathering,
start,
updateTarget,
clear,
finish,
}
}
@@ -5,6 +5,7 @@ import { getVersion } from '@tauri-apps/api/app'
import { openUrl } from '@tauri-apps/plugin-opener'
import { defineAsyncComponent, inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
import ColorMineAvatar from '@/components/ui/easteregg/color-mine/ColorMineAvatar.vue'
import EasterEggContributorsModal from '@/components/ui/easteregg/EasterEggContributorsModal.vue'
import EasterEggGameModal from '@/components/ui/easteregg/EasterEggGameModal.vue'
import { AxolotlBrandConfig } from '@/config'
@@ -16,7 +17,7 @@ import { type AboutMemberExperience, getAboutMemberExperience } from './about-me
const { formatMessage } = useVIntl()
const version = await getVersion()
const experienceHost = ref<HTMLElement>()
const activeMemberExperience = shallowRef<Extract<AboutMemberExperience, { kind: 'scene' }>>()
const activeMemberExperience = shallowRef<AboutMemberExperience>()
const pressingMemberName = ref<string>()
let longPressTimer: number | undefined
let pressStart = { x: 0, y: 0 }
@@ -40,13 +41,9 @@ function startMemberLongPress(member: TeamMember, event: PointerEvent) {
pressStart = { x: event.clientX, y: event.clientY }
pressingMemberName.value = member.name
longPressTimer = window.setTimeout(async () => {
activeMemberExperience.value = experience
suppressNextMemberClick = true
cancelMemberLongPress()
if (experience.kind === 'color-mine') {
openColorMine()
return
}
activeMemberExperience.value = experience
await nextTick()
experienceHost.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, experience.longPressDuration)
@@ -219,13 +216,13 @@ const messages = defineMessages({
<div class="flex flex-col items-center gap-4">
<div
ref="experienceHost"
class="relative m-0 aspect-[3/1] w-full overflow-hidden rounded-xl"
class="relative m-0 w-full overflow-hidden h-64 rounded-xl"
style="
mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
"
>
<AboutScene :paused="Boolean(activeMemberExperience)" />
<AboutScene />
<component
:is="activeMemberExperience?.component"
v-if="activeMemberExperience"
@@ -303,37 +300,56 @@ const messages = defineMessages({
{{ formatMessage(messages.developmentTeam) }}
</h3>
<div class="grid gap-3 sm:grid-cols-2">
<a
v-for="member in teamMembers"
:key="member.name"
:href="member.url ?? undefined"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
:class="{ 'member-card-holding': pressingMemberName === member.name }"
@pointerdown="startMemberLongPress(member, $event)"
@pointermove="moveMemberLongPress"
@pointerup="cancelMemberLongPress"
@pointercancel="cancelMemberLongPress"
@pointerleave="cancelMemberLongPress"
@blur="cancelMemberLongPress"
@click="handleMemberClick"
@contextmenu="handleMemberContextMenu(member, $event)"
@dragstart.prevent
>
<Avatar
:src="member.avatarUrl"
:alt="member.name"
size="2.5rem"
circle
no-shadow
loading="lazy"
/>
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">
{{ member.name }}
</span>
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
</a>
<template v-for="member in teamMembers" :key="member.name">
<div
v-if="member.name === 'Disy920'"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
>
<ColorMineAvatar
:src="member.avatarUrl"
:name="member.name"
:href="member.url"
@activate="openColorMine"
/>
<a
:href="member.url"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 flex-1 items-center gap-3"
>
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">{{
member.name
}}</span>
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
</a>
</div>
<a
v-else
:href="member.url ?? undefined"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
@pointerdown="startMemberLongPress(member, $event)"
@pointermove="moveMemberLongPress"
@pointerup="cancelMemberLongPress"
@pointerleave="cancelMemberLongPress"
@click="handleMemberClick"
@contextmenu="handleMemberContextMenu(member, $event)"
>
<Avatar
:src="member.avatarUrl"
:alt="member.name"
size="2.5rem"
circle
no-shadow
loading="lazy"
/>
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">
{{ member.name }}
</span>
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
</a>
</template>
</div>
</section>
<details class="group pt-4 about-settings-details">
@@ -401,11 +417,6 @@ const messages = defineMessages({
padding: var(--gap-lg);
}
.member-card-holding {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.about-page :deep(.rounded-xl.bg-surface-4) {
border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
@@ -26,7 +26,6 @@ import { computed, ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils'
import { setUiScale, uiScale } from '@/helpers/ui-scale'
import { useTheming } from '@/store/state'
import {
type AccentColor,
@@ -210,15 +209,6 @@ const messages = defineMessages({
defaultMessage:
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.',
},
uiScaleTitle: {
id: 'app.appearance-settings.ui-scale.title',
defaultMessage: 'Launcher scale',
},
uiScaleDescription: {
id: 'app.appearance-settings.ui-scale.description',
defaultMessage:
'Scale the whole launcher interface. A smaller scale fits more content, which helps when embedded pages are cut off.',
},
pageTransitionsTitle: {
id: 'app.appearance-settings.page-transitions.title',
defaultMessage: 'Page transition animations',
@@ -373,12 +363,6 @@ const customBackgroundPreview = computed(() =>
: null,
)
function updateUiScale(value: number) {
const clamped = Math.min(200, Math.max(50, Math.round(value)))
setUiScale(clamped)
settings.value.ui_scale = clamped
}
const accentColorOptions: Array<{
value: AccentColor
color: string
@@ -1036,43 +1020,6 @@ watch(
/>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>
<span id="settings-target-appearance-ui-scale" tabindex="-1">
{{ formatMessage(messages.uiScaleTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.uiScaleDescription) }}</template>
<template #control>
<div class="flex w-full flex-col gap-3">
<div class="flex flex-wrap gap-2">
<button
v-for="preset in [100, 110, 125, 150]"
:key="preset"
type="button"
class="min-w-16 rounded-lg border border-solid px-3 py-1.5 text-sm font-semibold transition-colors"
:class="
uiScale === preset
? 'border-brand bg-brand-highlight text-brand'
: 'border-surface-4 bg-surface-3 text-secondary hover:border-surface-5 hover:text-contrast'
"
@click="updateUiScale(preset)"
>
{{ preset }}%
</button>
</div>
<Slider
id="ui-scale"
:model-value="uiScale"
:min="50"
:max="200"
:step="5"
unit="%"
@update:model-value="(value) => updateUiScale(Number(value))"
/>
</div>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-appearance-page-transitions" tabindex="-1">
@@ -12,15 +12,6 @@ import SettingsSection from './SettingsSection.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
forceUnicodeFont: {
id: 'app.settings.defaults.force-unicode-font',
defaultMessage: 'Force Unicode font',
},
forceUnicodeFontDescription: {
id: 'app.settings.defaults.force-unicode-font-description',
defaultMessage:
'Use the Unicode font when initializing a new instance. Off by default; existing font settings are preserved.',
},
fullscreen: { id: 'app.settings.defaults.fullscreen', defaultMessage: 'Fullscreen' },
fullscreenDescription: {
id: 'app.settings.defaults.fullscreen-description',
@@ -214,24 +205,6 @@ watch(
</SettingsRow>
</SettingsSection>
<SettingsSection>
<SettingsRow>
<template #label>
<span id="settings-target-defaults-unicode-font" tabindex="-1">
{{ formatMessage(messages.forceUnicodeFont) }}
</span>
</template>
<template #description>{{ formatMessage(messages.forceUnicodeFontDescription) }}</template>
<template #control>
<Toggle
id="force-unicode-font"
v-model="settings.force_unicode_font"
:aria-label="formatMessage(messages.forceUnicodeFont)"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection>
<SettingsRow stacked>
<template #label>
@@ -7,7 +7,7 @@ import {
Toggle,
useVIntl,
} from '@modrinth/ui'
import { inject, ref, watch } from 'vue'
import { inject, watch } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { isDev } from '@/helpers/utils'
@@ -54,25 +54,6 @@ const messages = defineMessages({
},
})
const featureFlagLabels: Record<FeatureFlag, string> = {
project_background: '项目背景',
page_path: '页面路径',
worlds_tab: '世界标签页',
worlds_in_home: '主页显示世界',
server_project_qa: '服务器项目问答',
show_version_environment_column: '显示版本环境列',
server_ram_as_bytes_always_on: '服务器内存始终按字节显示',
always_show_app_controls: '始终显示窗口控件',
skip_non_essential_warnings: '跳过非必要警告',
skip_unknown_pack_warning: '跳过未知整合包警告',
i18n_debug: '国际化调试',
show_instance_play_time: '显示实例游玩时间',
page_transitions: '页面切换动画',
advanced_filters_collapsed: '高级筛选默认折叠',
multiplayer_in_app: '多人联机',
auto_install_dependencies: '自动安装依赖',
}
const settings = ref(await getSettings())
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
@@ -104,7 +85,7 @@ watch(
<template>
<SettingsSection>
<SettingsRow v-for="option in options" :key="option">
<template #label>{{ featureFlagLabels[option] ?? option }}</template>
<template #label>{{ option.replaceAll('_', ' ') }}</template>
<template #control>
<div class="flex items-center gap-2">
<Button
@@ -143,4 +124,4 @@ watch(
</Button>
</div>
</SettingsSection>
</template>
</template>
@@ -12,13 +12,13 @@ import {
import { getVersion } from '@tauri-apps/api/app'
import { invoke } from '@tauri-apps/api/core'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { nextTick, ref, watch } from 'vue'
import { inject, nextTick, ref, watch } from 'vue'
import UpdateAnnouncementHistory from '@/components/ui/announcement/UpdateAnnouncementHistory.vue'
import {
betaDatabaseExists,
copyDatabaseBetweenChannels,
copyReleaseDatabaseToBeta,
copyDatabaseBetweenChannels,
getCurrentAppDatabasePath,
getUpdateChannel,
getUpdatePreferences,
@@ -294,9 +294,7 @@ async function loadLatestChannelVersions() {
const versions = await Promise.all(
(['release', 'beta'] as const).map(async (channel) => {
try {
const response = await tauriFetch(
`https://skin.starlight.cool/starlight/launcher/latest?channel=${channel}`,
)
const response = await tauriFetch(`https://update.axlmc.org/latest?channel=${channel}`)
if (!response.ok) return [channel, undefined] as const
const payload = (await response.json()) as { version?: string }
return [channel, payload.version] as const
@@ -446,14 +444,11 @@ async function confirmDatabaseOperation() {
}),
})
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
handleError(
new Error(
`${
targetChannel === activeChannel
? formatMessage(messages.databaseOperationActiveTarget)
: formatMessage(messages.databaseOperationFailed)
}: ${detail}`,
targetChannel === activeChannel
? formatMessage(messages.databaseOperationActiveTarget)
: formatMessage(messages.databaseOperationFailed),
),
)
} finally {
@@ -471,7 +466,7 @@ function cancelDatabaseOperation() {
<div class="flex flex-col gap-6">
<SettingsSection :title="formatMessage(messages.title)">
<div class="update-channel-panel">
<p id="settings-target-updates-source" class="m-0 text-sm leading-[1.45] text-secondary">
<p id="settings-target-updates-channel" class="m-0 text-sm leading-[1.45] text-secondary">
{{ formatMessage(messages.description) }}
</p>
<div
@@ -1,31 +1,21 @@
import type { Component } from 'vue'
import AboutEasterEgg from '../AboutEasterEgg.vue'
export type AboutMemberExperience =
| {
kind: 'scene'
component: Component
longPressDuration: number
}
| {
kind: 'color-mine'
longPressDuration: number
}
export type AboutMemberExperience = {
component: Component
longPressDuration: number
}
// 长按「关于」页成员卡片触发的彩蛋体验。
// 长按「关于」页成员名字触发的彩蛋体验。
// 下界之星合成彩蛋(AboutEasterEgg.vue),由长按成员名 / 暗号 / Konami 秘技触发。
// 若要为特定成员挂载自定义彩蛋,在这里新增条目即可。
const memberExperiences: Record<string, AboutMemberExperience> = {
'easter-egg': {
kind: 'scene',
component: AboutEasterEgg,
longPressDuration: 800,
},
'color-mine': {
kind: 'color-mine',
longPressDuration: 800,
},
}
export function getAboutMemberExperience(experience: unknown): AboutMemberExperience | undefined {
@@ -1,4 +1,4 @@
import type { MessageDescriptor } from '@modrinth/ui'
import { defineMessage, type MessageDescriptor } from '@modrinth/ui'
export type SettingsCategoryId =
| 'interface'
@@ -28,100 +28,100 @@ export interface SettingsCategoryDefinition {
export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
{
id: 'interface',
name: {
name: defineMessage({
id: 'app.settings.tabs.interface',
defaultMessage: 'Interface & appearance',
},
}),
group: 'launcher',
onboardingId: 'settings-tab-interface',
},
{
id: 'home-navigation',
name: {
name: defineMessage({
id: 'app.settings.tabs.home-navigation',
defaultMessage: 'Home & navigation',
},
}),
group: 'launcher',
onboardingId: 'settings-tab-home-navigation',
},
{
id: 'language-translation',
name: {
name: defineMessage({
id: 'app.settings.tabs.language-translation',
defaultMessage: 'Language & translation',
},
}),
group: 'launcher',
onboardingId: 'settings-tab-language-translation',
},
{
id: 'ai',
name: { id: 'app.settings.tabs.ai', defaultMessage: 'AI' },
name: defineMessage({ id: 'app.settings.tabs.ai', defaultMessage: 'AI' }),
group: 'launcher',
flushContent: true,
onboardingId: 'settings-tab-ai',
},
{
id: 'java-performance',
name: {
name: defineMessage({
id: 'app.settings.tabs.java-performance',
defaultMessage: 'Java & performance',
},
}),
group: 'game',
onboardingId: 'settings-tab-java-performance',
},
{
id: 'launch-defaults',
name: {
name: defineMessage({
id: 'app.settings.tabs.launch-defaults',
defaultMessage: 'Launch & instance defaults',
},
}),
group: 'game',
onboardingId: 'settings-tab-launch-defaults',
},
{
id: 'content-downloads',
name: {
name: defineMessage({
id: 'app.settings.tabs.content-downloads',
defaultMessage: 'Content & downloads',
},
}),
group: 'game',
onboardingId: 'settings-tab-content-downloads',
},
{
id: 'network-multiplayer',
name: {
name: defineMessage({
id: 'app.settings.tabs.network-multiplayer',
defaultMessage: 'Network & multiplayer',
},
}),
group: 'game',
onboardingId: 'settings-tab-network-multiplayer',
},
{
id: 'storage-backups',
name: {
name: defineMessage({
id: 'app.settings.tabs.storage-backups',
defaultMessage: 'Storage & backups',
},
}),
group: 'data-privacy',
onboardingId: 'settings-tab-storage-backups',
},
{
id: 'updates',
name: { id: 'app.settings.tabs.updates', defaultMessage: 'Updates' },
name: defineMessage({ id: 'app.settings.tabs.updates', defaultMessage: 'Updates' }),
group: 'support',
onboardingId: 'settings-tab-updates',
},
{
id: 'about',
name: { id: 'app.settings.tabs.about', defaultMessage: 'About' },
name: defineMessage({ id: 'app.settings.tabs.about', defaultMessage: 'About' }),
group: 'support',
},
{
id: 'feature-flags',
name: {
name: defineMessage({
id: 'settings.feature-flags.title',
defaultMessage: 'Feature flags',
},
}),
group: 'developer',
developerOnly: true,
},
@@ -15,8 +15,7 @@ import {
UsersIcon,
} from '@modrinth/assets'
import { commonMessages, defineMessages, type MessageDescriptor } from '@modrinth/ui'
import { type Component, defineAsyncComponent } from 'vue'
import { defineAsyncComponent, type Component } from 'vue'
import {
getVisibleSettingsCategoryDefinitions,
type SettingsCategoryDefinition,
@@ -254,16 +254,6 @@ export const settingsSearchEntries: SettingsSearchEntry[] = [
targetId: 'settings-target-defaults-environment',
label: message('app.settings.defaults.environment-variables', 'Environment variables'),
},
{
id: 'defaults-unicode-font',
categoryId: 'launch-defaults',
targetId: 'settings-target-defaults-unicode-font',
label: message('app.settings.defaults.force-unicode-font', 'Force Unicode font'),
description: message(
'app.settings.defaults.force-unicode-font-description',
'Use the Unicode font when initializing a new instance. Off by default; existing font settings are preserved.',
),
},
{
id: 'defaults-launch-hooks',
categoryId: 'launch-defaults',
@@ -104,28 +104,6 @@ test('settings search index has unique entries with categories', () => {
assert.deepEqual(validateSettingsSearchEntries(), [])
})
test('Unicode font is searchable in Chinese and English', () => {
const entry = settingsSearchEntries.find((entry) => entry.id === 'defaults-unicode-font')!
assert.equal(entry.categoryId, 'launch-defaults')
assert.ok(
readFileSync(new URL('./DefaultInstanceSettings.vue', import.meta.url), 'utf8').includes(
`id="${getSettingsSearchTargetId(entry)}"`,
),
)
for (const translated of [false, true]) {
const documents = settingsSearchEntries.map((entry) => ({
item: entry,
text: translated
? (chineseLocale[entry.label.id]?.message ?? entry.label.defaultMessage ?? '')
: (entry.label.defaultMessage ?? ''),
}))
for (const query of translated ? ['字体', 'Unicode'] : ['font', 'Unicode']) {
const matches = filterSettingsSearchDocuments(query, documents)
assert.ok(matches.some(({ item }) => item.id === 'defaults-unicode-font'))
}
}
})
test('settings search keywords are valid message descriptors', () => {
for (const entry of settingsSearchEntries) {
for (const keyword of entry.keywords ?? []) {
@@ -84,7 +84,6 @@ const props = defineProps<{
isSkinActive: (skin: Skin) => boolean
isAddSkinButtonDragActive: boolean
readOnly?: boolean
manageSavedSkins?: boolean
activeTab?: 'saved' | 'default'
}>()
@@ -183,9 +182,7 @@ const sections = computed<SkinSection[]>(() => {
const draggableSavedSkins = ref<Skin[]>([])
const isDraggingSavedSkin = ref(false)
const canReorderSavedSkins = computed(
() => props.manageSavedSkins !== false && draggableSavedSkins.value.length > 1,
)
const canReorderSavedSkins = computed(() => draggableSavedSkins.value.length > 1)
const fixedSavedSkins = computed(() => props.savedSkins.filter((skin) => !canDragSavedSkin(skin)))
const sectionLayouts = computed(() => {
@@ -489,7 +486,7 @@ defineExpose({ getAddSkinButtonElement })
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)"
>
<template v-if="!readOnly && manageSavedSkins !== false" #overlay-buttons>
<template v-if="!readOnly" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
@@ -529,7 +526,7 @@ defineExpose({ getAddSkinButtonElement })
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)"
>
<template v-if="!readOnly && manageSavedSkins !== false" #overlay-buttons>
<template v-if="!readOnly" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
@@ -571,7 +568,7 @@ defineExpose({ getAddSkinButtonElement })
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)"
>
<template v-if="manageSavedSkins !== false" #overlay-buttons>
<template #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
@@ -29,7 +29,6 @@ import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { get_project } from '@/helpers/cache'
import { process_listener } from '@/helpers/events'
import { kill, run } from '@/helpers/instance'
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { showInstanceInFolder } from '@/helpers/utils'
@@ -47,8 +46,10 @@ const messages = defineMessages({
defaultMessage: 'Loading modpack...',
},
viewInstance: { id: 'app.instance-item.view-instance', defaultMessage: 'View instance' },
launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
stopAll: { id: 'app.instance.stop-all', defaultMessage: 'Stop all windows of this instance' },
alreadyOpen: {
id: 'app.instance-item.already-open',
defaultMessage: 'Instance is already open',
},
})
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
@@ -90,32 +91,33 @@ const loader = computed(() => {
}
})
const stopping = ref(false)
const loading = computed(() => stopping.value || isInstanceLaunching(props.instance.id))
const loading = ref(false)
const internalPlaying = ref(false)
const isPlaying = computed(() => props.playing ?? internalPlaying.value)
const play = async (event: MouseEvent) => {
event?.stopPropagation()
if (loading.value) return
await run(props.instance.id).catch(async (err) => {
const handled = await handleMinecraftLaunchError(err, {
instance_id: props.instance.id,
instance_name: props.instance.name,
loading.value = true
await run(props.instance.id)
.catch(async (err) => {
const handled = await handleMinecraftLaunchError(err, {
instance_id: props.instance.id,
instance_name: props.instance.name,
})
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
})
.finally(() => {
})
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
})
emit('play')
await checkProcess()
loading.value = false
}
const stop = async (event: MouseEvent) => {
event?.stopPropagation()
stopping.value = true
loading.value = true
await kill(props.instance.id).catch(handleError)
emit('stop')
stopping.value = false
await checkProcess()
loading.value = false
}
const unlistenProcesses =
@@ -129,7 +131,7 @@ const checkProcess = async () => {
if (props.playing !== undefined) return
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError)
if (Array.isArray(runningProcesses)) internalPlaying.value = runningProcesses.length > 0
internalPlaying.value = runningProcesses.length > 0
}
onMounted(() => {
@@ -153,8 +155,7 @@ onUnmounted(() => {
:class="[
flat ? 'px-2 py-2 hover:bg-button-bg' : 'card-shadow bg-bg-raised p-3',
{
'instance-item-dashboard-compact grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 p-1.5':
dashboardDensity === 'compact',
'instance-item-dashboard-compact grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 p-1.5': dashboardDensity === 'compact',
'instance-item-dashboard-comfortable p-2': dashboardDensity === 'comfortable',
},
]"
@@ -213,25 +214,23 @@ onUnmounted(() => {
color="red"
:circular="dashboardDensity === 'compact'"
>
<button v-tooltip="formatMessage(messages.stopAll)" @click="stop">
<button @click="stop">
<StopCircleIcon aria-hidden="true" />
<span v-if="dashboardDensity !== 'compact'">
{{ formatMessage(messages.stopAll) }}
{{ formatMessage(commonMessages.stopButton) }}
</span>
</button>
</ButtonStyled>
<ButtonStyled :circular="dashboardDensity === 'compact'">
<ButtonStyled v-else :circular="dashboardDensity === 'compact'">
<button
v-tooltip="
formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
"
:disabled="loading"
v-tooltip="isPlaying ? formatMessage(messages.alreadyOpen) : null"
:disabled="isPlaying || loading"
@click="play"
>
<SpinnerIcon v-if="loading" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
<span v-if="dashboardDensity !== 'compact'">
{{ formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton) }}
{{ formatMessage(commonMessages.playButton) }}
</span>
</button>
</ButtonStyled>
@@ -281,4 +280,5 @@ onUnmounted(() => {
.instance-item-dashboard-compact > :last-child {
gap: 0.125rem;
}
</style>
@@ -13,6 +13,7 @@ import type { Router } from 'vue-router'
import {
install_job_dismiss,
install_job_repair_cache_and_retry,
install_job_retry,
install_job_support_details,
installJobInstanceId,
type InstallJobSnapshot,
@@ -20,7 +21,6 @@ import {
type InstallPhaseId,
type InstallProgress,
} from '@/helpers/install'
import { createInstallJobNotificationFilter } from '@/helpers/install-job-notification-visibility'
import { effectiveInstallProgress, hasDeterminateInstallProgress } from '@/helpers/install-progress'
import { get_many as getInstances } from '@/helpers/instance'
import type { DownloadManager } from '@/providers/download-manager'
@@ -258,6 +258,15 @@ const failureSummaryMessages = defineMessages({
},
})
const visibleJobStatuses = new Set<InstallJobStatus>([
'queued',
'running',
'canceling',
'waiting_for_user',
'failed',
'interrupted',
])
const retainedJobStatuses = new Set<InstallJobStatus>(['succeeded', 'canceled'])
const activeJobStatuses = new Set<InstallJobStatus>([
'queued',
'running',
@@ -673,7 +682,7 @@ export async function useInstallJobNotifications(opts: {
action: async () => {
if (repairingJobIds.value.has(job.job_id)) return
if (!requiresCacheRepair) {
await opts.manager.retry(job.job_id).catch(opts.handleError)
await install_job_retry(job.job_id).catch(opts.handleError)
await refresh()
return
}
@@ -715,8 +724,6 @@ export async function useInstallJobNotifications(opts: {
return buttons
}
const filterVisibleJobs = createInstallJobNotificationFilter(opts.manager.jobs.value)
function setJobs(nextJobs: InstallJobSnapshot[]) {
for (const job of nextJobs) {
if (!jobOrder.has(job.job_id)) {
@@ -724,7 +731,12 @@ export async function useInstallJobNotifications(opts: {
}
}
const visibleJobs = filterVisibleJobs(nextJobs)
const currentJobIds = new Set(jobs.value.map((job) => job.job_id))
const visibleJobs = nextJobs.filter(
(job) =>
visibleJobStatuses.has(job.status) ||
(retainedJobStatuses.has(job.status) && currentJobIds.has(job.job_id)),
)
syncProgressSnapshots(visibleJobs)
jobs.value = visibleJobs.sort(
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { mergeLiveLogHistory } from './console-history.ts'
test('retains the persisted latest.log history and appends a live-only tail', () => {
assert.equal(
mergeLiveLogHistory('first\nsecond\nthird', 'second\nthird\nfourth'),
'first\nsecond\nthird\nfourth',
)
})
test('does not duplicate a buffer already persisted in latest.log', () => {
assert.equal(mergeLiveLogHistory('first\nsecond\nthird', 'second\nthird'), 'first\nsecond\nthird')
})
test('keeps live output when it is not present in latest.log', () => {
assert.equal(mergeLiveLogHistory('minecraft', 'launcher'), 'minecraft\nlauncher')
})
test('accepts an unavailable latest.log while the instance is starting', () => {
assert.equal(mergeLiveLogHistory('', 'launcher output'), 'launcher output')
})
@@ -0,0 +1,31 @@
function nonEmptyLines(content: string): string[] {
return content.split(/\r\n|\r|\n/).filter(Boolean)
}
function overlapLength(history: readonly string[], liveBuffer: readonly string[]): number {
const maximum = Math.min(history.length, liveBuffer.length)
for (let length = maximum; length > 0; length--) {
let overlaps = true
for (let index = 0; index < length; index++) {
if (history[history.length - length + index] !== liveBuffer[index]) {
overlaps = false
break
}
}
if (overlaps) return length
}
return 0
}
/**
* Combines the persisted Minecraft log with output that may not have reached
* latest.log yet. The ring buffer commonly repeats the tail of latest.log, so
* retain only its non-overlapping suffix.
*/
export function mergeLiveLogHistory(latestLog: string, liveBuffer: string): string {
const history = nonEmptyLines(latestLog)
const buffer = nonEmptyLines(liveBuffer)
return [...history, ...buffer.slice(overlapLength(history, buffer))].join('\n')
}
@@ -1,70 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
createLiveLogReconciler,
type LiveLogSnapshot,
} from './live-log-reconciler.ts'
test('merges the authoritative snapshot with queued events without gaps or duplicates', async () => {
let resolveSnapshot!: (snapshot: LiveLogSnapshot) => void
const displayed: number[] = []
const reconciler = createLiveLogReconciler(
() =>
new Promise((resolve) => {
resolveSnapshot = resolve
}),
(snapshot) => {
displayed.splice(
0,
displayed.length,
...snapshot.lines.map((line) => line.sequence),
)
},
(event) => displayed.push(event.last_sequence),
)
const syncing = reconciler.sync()
reconciler.accept({ first_sequence: 3, last_sequence: 3 })
resolveSnapshot({
lines: [
{ sequence: 1, message: 'one' },
{ sequence: 2, message: 'two' },
],
last_sequence: 2,
})
await syncing
assert.deepEqual(displayed, [1, 2, 3])
await reconciler.accept({ first_sequence: 3, last_sequence: 3 })
assert.deepEqual(displayed, [1, 2, 3])
})
test('resynchronizes when an event sequence reveals a missed batch', async () => {
const displayed: number[] = []
let loads = 0
const reconciler = createLiveLogReconciler(
async () => {
loads++
return {
lines: [1, 2, 3, 4].map((sequence) => ({
sequence,
message: String(sequence),
})),
last_sequence: 4,
}
},
(snapshot) => {
displayed.splice(
0,
displayed.length,
...snapshot.lines.map((line) => line.sequence),
)
},
(event) => displayed.push(event.last_sequence),
)
await reconciler.accept({ first_sequence: 4, last_sequence: 4 })
assert.equal(loads, 1)
assert.deepEqual(displayed, [1, 2, 3, 4])
})
@@ -1,81 +0,0 @@
export interface LiveLogSnapshot {
lines: Array<{ sequence: number; message: string }>
last_sequence: number
}
export interface SequencedLogEvent {
first_sequence: number
last_sequence: number
}
export function createLiveLogReconciler<T extends SequencedLogEvent>(
loadSnapshot: () => Promise<LiveLogSnapshot>,
replace: (snapshot: LiveLogSnapshot) => void,
append: (event: T) => void,
) {
let lastSequence = 0
let queued: T[] = []
let syncing: Promise<void> | null = null
let disposed = false
function drainQueued(): boolean {
const events = queued
queued = []
for (let index = 0; index < events.length; index++) {
const event = events[index]!
if (event.last_sequence <= lastSequence) continue
if (event.first_sequence > lastSequence + 1) {
queued.push(...events.slice(index))
return false
}
append(event)
lastSequence = event.last_sequence
}
return true
}
function sync(): Promise<void> {
if (syncing) return syncing
const request = (async () => {
do {
const snapshot = await loadSnapshot()
if (disposed) return
replace(snapshot)
lastSequence = snapshot.last_sequence
} while (!drainQueued())
})()
const completion = request.finally(() => {
if (syncing === completion) syncing = null
})
syncing = completion
return completion
}
function resync(): Promise<void> {
return syncing ? syncing.then(sync) : sync()
}
function accept(event: T): Promise<void> | undefined {
if (disposed || event.last_sequence <= lastSequence) return
if (syncing) {
queued.push(event)
return syncing
}
if (event.first_sequence > lastSequence + 1) {
queued.push(event)
return sync()
}
append(event)
lastSequence = event.last_sequence
}
return {
accept,
resync,
sync,
dispose() {
disposed = true
queued = []
},
}
}
@@ -1,607 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
clearHostedSession,
HOSTED_ACCOUNT_REQUIRED_MESSAGE,
hostedCreate,
hostedDefault,
hostedSync,
onHostedPackAttemptStarted,
setInstanceMode,
} from '../helpers/hosted-packs.ts'
import {
openSkinSiteLogin,
receiveSkinSiteMessage,
requestSkinSiteDownloadToken,
requestSkinSiteLuck,
requestSkinSitePlayers,
requestSkinSiteSkinUpdate,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
skinSiteFrameUrl,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} from './skin-site-session.ts'
import { forgetHostedCreation, useHostedCreation } from './useHostedCreation.ts'
import { useHostedSync } from './useHostedSync.ts'
test('reconnecting the same skin site frame waits for verification instead of reporting signed out', () => {
const frame = { postMessage() {} } as unknown as Window
resetSkinSiteSession()
setSkinSiteFrame(frame)
resetSkinSiteSession()
setSkinSiteFrame(frame)
assert.equal(skinSiteStatus.value, 'checking')
setSkinSiteFrame(null)
resetSkinSiteSession()
})
test('hosted installation sends the JWT to native commands while Local needs no session', async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
const calls: Array<{ command: string; args: Record<string, unknown> }> = []
let syncResponse: (() => Promise<unknown>) | undefined
let createdId = 'instance'
let instanceExists = false
let tokenRequests = 0
let accounts: Array<{
account_type: string
yggdrasil?: { api_root: string }
}> = [{ account_type: 'microsoft' }]
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type !== 'starlight-pack-token-request') return
tokenRequests++
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-pack-token-result',
requestId: data.requestId,
token: 'site.jwt.secret',
},
} as MessageEvent,
frame,
)
},
} as unknown as Window
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
__TAURI_INTERNALS__: {
async invoke(command: string, args: Record<string, unknown>) {
calls.push({ command, args })
if (command === 'plugin:install|hosted_has_game_account')
return accounts.some(
(account) =>
account.account_type === 'microsoft' ||
(account.account_type === 'yggdrasil' &&
account.yggdrasil?.api_root.replace(/\/+$/, '') ===
'https://skin.starlight.cool/yggdrasil'),
)
if (command === 'plugin:install|hosted_sync' && syncResponse) return syncResponse()
if (command === 'plugin:instance|instance_get')
return instanceExists ? { id: args.instanceId } : null
return command === 'plugin:install|hosted_create' ? createdId : null
},
},
},
})
try {
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'site', username: 'Site' },
},
} as MessageEvent,
frame,
)
await hostedDefault()
assert.equal(await hostedCreate(), 'instance')
await hostedSync('instance')
await setInstanceMode('instance', 'starlight')
assert.equal(tokenRequests, 4)
assert.deepEqual(
calls
.map(({ command }) => command)
.filter(
(command) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
),
[
'hosted_set_session',
'hosted_default',
'hosted_set_session',
'hosted_create',
'hosted_set_session',
'hosted_sync',
'hosted_set_session',
'hosted_set_instance_mode',
].map((name) => `plugin:install|${name}`),
)
for (const call of calls.filter(({ command }) => command.endsWith('hosted_set_session'))) {
assert.equal(call.args.token, 'site.jwt.secret')
}
let rejectSync!: (error: unknown) => void
let syncCalls = 0
syncResponse = () => {
syncCalls++
return new Promise((_, reject) => {
rejectSync = reject
})
}
const originalPage = useHostedSync(() => 'shared-progress-instance')
const pendingSync = originalPage.sync()
const reopenedPage = useHostedSync(() => 'shared-progress-instance')
assert.equal(reopenedPage.busy.value, true)
assert.equal(useHostedSync(() => 'other-instance').busy.value, false)
await reopenedPage.sync()
await new Promise((resolve) => setImmediate(resolve))
assert.equal(syncCalls, 1)
rejectSync({ message: 'download interrupted' })
await pendingSync
assert.equal(reopenedPage.busy.value, false)
assert.match(reopenedPage.error.value, /download interrupted/)
assert.equal(
useHostedSync(() => 'shared-progress-instance').error.value,
reopenedPage.error.value,
)
syncResponse = async () => ({ revision: 'new-version' })
await reopenedPage.sync()
assert.equal(originalPage.error.value, '')
assert.deepEqual(originalPage.result.value, { revision: 'new-version' })
const creation = useHostedCreation()
createdId = 'deleted-instance'
syncResponse = async () => {
throw new Error('download failed')
}
await creation.install()
assert.equal(creation.createdInstance.value, 'deleted-instance')
assert.match(creation.installError.value, /download failed/)
createdId = 'replacement-instance'
syncResponse = async () => ({
version: '1',
downloadedBytes: 0,
changedFiles: 0,
preservedFiles: [],
})
const retryCallStart = calls.length
assert.equal(await creation.install(), 'replacement-instance')
assert.equal(calls[retryCallStart].command, 'plugin:instance|instance_get')
assert.equal(
calls.filter((call) => call.command.endsWith('|hosted_sync')).at(-1)?.args.instanceId,
'replacement-instance',
)
assert.equal(creation.installError.value, '')
forgetHostedCreation('unrelated-instance')
assert.equal(creation.completed.value, true)
forgetHostedCreation('replacement-instance')
assert.equal(creation.createdInstance.value, undefined)
assert.equal(creation.completed.value, false)
createdId = 'third-instance'
syncResponse = () =>
new Promise((_, reject) => {
rejectSync = reject
})
const deletedDuringSync = creation.install()
await new Promise((resolve) => setImmediate(resolve))
forgetHostedCreation('third-instance')
rejectSync(new Error('Unknown instance'))
await deletedDuringSync
assert.equal(creation.installError.value, '')
assert.equal(creation.createdInstance.value, undefined)
createdId = 'existing-instance'
syncResponse = async () => {
throw new Error('download interrupted')
}
await creation.install()
instanceExists = true
syncResponse = async () => ({
version: '1',
downloadedBytes: 0,
changedFiles: 0,
preservedFiles: [],
})
const createCount = calls.filter((call) => call.command.endsWith('|hosted_create')).length
assert.equal(await creation.install(), 'existing-instance')
assert.equal(
calls.filter((call) => call.command.endsWith('|hosted_create')).length,
createCount,
)
creation.acknowledge('existing-instance')
const requestsBeforeLogout = tokenRequests
resetSkinSiteSession()
await clearHostedSession()
assert.equal(calls.at(-1)!.args.token, null)
await setInstanceMode('instance', 'local')
assert.equal(tokenRequests, requestsBeforeLogout)
const attempts: string[] = []
const stopListening = onHostedPackAttemptStarted((id) => attempts.push(id))
accounts = []
const installCallsBeforeAccountFailure = calls.filter(
({ command }) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
).length
await assert.rejects(hostedSync('failed-instance'), {
message: HOSTED_ACCOUNT_REQUIRED_MESSAGE,
})
await assert.rejects(hostedCreate(), {
message: HOSTED_ACCOUNT_REQUIRED_MESSAGE,
})
assert.deepEqual(attempts, [])
assert.equal(
calls.filter(
({ command }) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
).length,
installCallsBeforeAccountFailure,
)
const unauthenticatedCreation = useHostedCreation()
await unauthenticatedCreation.install()
assert.equal(unauthenticatedCreation.installError.value, HOSTED_ACCOUNT_REQUIRED_MESSAGE)
assert.equal(unauthenticatedCreation.createdInstance.value, undefined)
accounts = [{ account_type: 'microsoft' }]
const unauthenticatedRetry = hostedSync('failed-instance')
await assert.rejects(unauthenticatedRetry)
assert.deepEqual(attempts, ['failed-instance'])
stopListening()
await assert.rejects(hostedSync('failed-instance'))
assert.equal(attempts.length, 1)
await assert.rejects(hostedCreate(), /无需选择玩家/)
} finally {
resetSkinSiteSession()
setSkinSiteFrame(null)
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
else Reflect.deleteProperty(globalThis, 'window')
}
})
test('pack download gets the site JWT without choosing a player and rejects stale replies', async () => {
const sent: Array<{ type: string; requestId: string }> = []
const frame = {
postMessage(data: { type: string; requestId: string }) {
sent.push(data)
},
} as unknown as Window
const receive = (data: unknown, origin = SKIN_SITE_ORIGIN) =>
receiveSkinSiteMessage(
{
origin,
source: frame,
data,
} as MessageEvent,
frame,
)
resetSkinSiteSession()
setSkinSiteFrame(frame)
await assert.rejects(requestSkinSiteDownloadToken(), /无需选择玩家/)
receive({
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'site-user', username: 'Site' },
})
const token = requestSkinSiteDownloadToken()
const requestId = sent.at(-1)!.requestId
const reply = { type: 'starlight-pack-token-result', requestId, token: 'site.jwt.secret' }
assert.equal(receive(reply, 'https://evil.example'), false)
assert.equal(receive(reply), true)
assert.equal(await token, 'site.jwt.secret')
assert.equal(skinSitePlayers.value.length, 0)
assert.equal(JSON.stringify(skinSiteUser.value).includes('secret'), false)
const stale = requestSkinSiteDownloadToken()
const staleId = sent.at(-1)!.requestId
const rejected = assert.rejects(stale, /登录状态已变化/)
resetSkinSiteSession()
await rejected
assert.equal(receive({ ...reply, requestId: staleId }), false)
setSkinSiteFrame(null)
})
test('skin site session accepts only the embedded origin and window, and redirects after verification', () => {
const frame = {} as Window
const message = (status: string, user?: unknown) =>
({
origin: SKIN_SITE_ORIGIN,
source: frame,
data: { type: 'starlight-skin-session', status, user },
}) as MessageEvent
resetSkinSiteSession()
openSkinSiteLogin()
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/login`)
const signedIn = message('signed-in', {
uuid: 'test-user',
username: '测试用户',
jwt: 'must-not-copy',
})
assert.equal(
receiveSkinSiteMessage({ ...signedIn, origin: 'https://evil.example' } as MessageEvent, frame),
false,
)
assert.equal(receiveSkinSiteMessage(signedIn, {} as Window), false)
assert.equal(receiveSkinSiteMessage(message('signed-in', {}), frame), false)
assert.equal(skinSiteUser.value, null)
assert.equal(receiveSkinSiteMessage(signedIn, frame), true)
assert.deepEqual(skinSiteUser.value, { uuid: 'test-user', username: '测试用户' })
assert.equal(skinSiteStatus.value, 'signed-in')
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/profile`)
assert.equal(receiveSkinSiteMessage(message('checking'), frame), true)
assert.equal(skinSiteUser.value, null)
receiveSkinSiteMessage(message('signed-in', { uuid: 'second', username: '另一个账号' }), frame)
assert.deepEqual(skinSiteUser.value, { uuid: 'second', username: '另一个账号' })
receiveSkinSiteMessage(message('signed-out'), frame)
assert.equal(skinSiteUser.value, null)
assert.equal(skinSiteStatus.value, 'signed-out')
receiveSkinSiteMessage(message('error'), frame)
assert.equal(skinSiteStatus.value, 'error')
resetSkinSiteSession()
})
test('luck requests use only the connected skin site frame and validate its result', async () => {
const sent: Array<{ data: unknown; targetOrigin: string }> = []
const frame = {
postMessage(data: unknown, targetOrigin: string) {
sent.push({ data, targetOrigin })
},
} as unknown as Window
const sessionMessage = {
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'lucky-user', username: 'Lucky' },
},
} as MessageEvent
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(sessionMessage, frame)
const automaticPlayersRequest = sent.at(-1)?.data as { type: string; requestId: string }
assert.equal(automaticPlayersRequest.type, 'starlight-skin-players-request')
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: automaticPlayersRequest.requestId,
ok: true,
players: [],
},
} as MessageEvent,
frame,
)
const result = requestSkinSiteLuck()
assert.equal(sent.at(-1)?.targetOrigin, SKIN_SITE_ORIGIN)
const request = sent.at(-1)?.data as { type: string; requestId: string }
assert.equal(request.type, 'starlight-skin-luck-request')
assert.match(request.requestId, /^skin-luck-\d+-\d+$/)
assert.equal(
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-luck-result',
requestId: request.requestId,
ok: true,
luck: 88,
},
} as MessageEvent,
frame,
),
true,
)
assert.equal(await result, 88)
setSkinSiteFrame(null)
resetSkinSiteSession()
})
test('player requests validate identities and replace the collection atomically', async () => {
const sent: Array<{ data: unknown; targetOrigin: string }> = []
const frame = {
postMessage(data: unknown, targetOrigin: string) {
sent.push({ data, targetOrigin })
},
} as unknown as Window
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'skin-user', username: 'Skin User' },
},
} as MessageEvent,
frame,
)
const automaticRequest = sent.at(-1)?.data as { requestId: string }
assert.equal(skinSitePlayersStatus.value, 'checking')
assert.equal(
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: automaticRequest.requestId,
ok: true,
players: [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne',
isMojang: false,
skinState: 'ready',
headDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
skinDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
model: 'slim',
},
{
uuid: 'fedcba9876543210fedcba9876543210',
name: 'NoSkinPlayer',
isMojang: false,
skinState: 'empty',
},
{ uuid: 'invalid', name: 'Ignored', isMojang: false },
],
},
} as MessageEvent,
frame,
),
true,
)
assert.deepEqual(skinSitePlayers.value, [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne',
isMojang: false,
skinState: 'ready',
headDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
skinDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
model: 'slim',
},
{
uuid: 'fedcba9876543210fedcba9876543210',
name: 'NoSkinPlayer',
isMojang: false,
skinState: 'empty',
},
])
assert.equal(skinSitePlayersStatus.value, 'ready')
const transientFailure = requestSkinSitePlayers()
const transientFailureRequest = sent.at(-1)?.data as { requestId: string }
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: transientFailureRequest.requestId,
ok: true,
players: [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne Renamed',
isMojang: false,
skinState: 'error',
},
],
},
} as MessageEvent,
frame,
)
assert.equal((await transientFailure)[0].skinState, 'ready')
assert.equal(skinSitePlayers.value[0].name, 'PlayerOne Renamed')
assert.equal(skinSitePlayers.value[0].headDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
const retry = requestSkinSitePlayers()
const retryRequest = sent.at(-1)?.data as { requestId: string }
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: retryRequest.requestId,
ok: true,
players: [],
},
} as MessageEvent,
frame,
)
assert.deepEqual(await retry, [])
assert.deepEqual(skinSitePlayers.value, [])
setSkinSiteFrame(null)
resetSkinSiteSession()
})
test('skin updates are restricted to known non-Mojang players and validated results', async () => {
const sent: Array<{ data: unknown; targetOrigin: string }> = []
const frame = {
postMessage(data: unknown, targetOrigin: string) {
sent.push({ data, targetOrigin })
},
} as unknown as Window
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'skin-user', username: 'Skin User' },
},
} as MessageEvent,
frame,
)
const automaticRequest = sent.at(-1)?.data as { requestId: string }
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: automaticRequest.requestId,
ok: true,
players: [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne',
isMojang: false,
skinState: 'empty',
},
],
},
} as MessageEvent,
frame,
)
const result = requestSkinSiteSkinUpdate(
'0123456789abcdef0123456789abcdef',
'data:image/png;base64,SEVBRERBVEE=',
'slim',
)
const request = sent.at(-1)?.data as { type: string; requestId: string; playerId: string }
assert.equal(request.type, 'starlight-skin-update-request')
assert.equal(request.playerId, '0123456789abcdef0123456789abcdef')
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: { type: 'starlight-skin-update-result', requestId: request.requestId, ok: true },
} as MessageEvent,
frame,
)
await result
await assert.rejects(
requestSkinSiteSkinUpdate(
'fedcba9876543210fedcba9876543210',
'data:image/png;base64,SEVBRERBVEE=',
'default',
),
)
setSkinSiteFrame(null)
resetSkinSiteSession()
})
@@ -1,437 +0,0 @@
import { readonly, ref, watch } from 'vue'
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
export type SkinSiteUser = { uuid: string; username: string }
export type SkinSiteStatus = 'checking' | 'signed-out' | 'signed-in' | 'error'
export type SkinSitePlayer = {
uuid: string
name: string
isMojang: boolean
skinState: 'ready' | 'empty' | 'error'
headDataUrl?: string
skinDataUrl?: string
model?: 'default' | 'slim'
}
export type SkinSitePlayersStatus = 'idle' | 'checking' | 'ready' | 'error'
const user = ref<SkinSiteUser | null>(null)
const status = ref<SkinSiteStatus>('signed-out')
const players = ref<SkinSitePlayer[]>([])
const playersStatus = ref<SkinSitePlayersStatus>('idle')
const selectedPlayerId = ref<string | null>(null)
const frameUrl = ref(`${SKIN_SITE_ORIGIN}/`)
let connectedFrame: Window | null = null
let luckRequestSequence = 0
let playersRequestSequence = 0
let skinUpdateRequestSequence = 0
let packTokenRequestSequence = 0
const pendingPackTokens = new Map<
string,
{
resolve: (token: string) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
>()
function rejectPendingPackTokens() {
for (const request of pendingPackTokens.values()) {
clearTimeout(request.timeout)
request.reject(new Error('StarLight 登录状态已变化,请重试。'))
}
pendingPackTokens.clear()
}
export function requestSkinSiteDownloadToken(): Promise<string> {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(
new Error('请先在启动器中登录 StarLight 皮肤站,再下载整合包。无需选择玩家。'),
)
}
const requestId = `pack-token-${Date.now()}-${++packTokenRequestSequence}`
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pendingPackTokens.delete(requestId)
reject(new Error('获取 StarLight 登录凭据超时,请重试。'))
}, 10_000)
pendingPackTokens.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-pack-token-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch {
clearTimeout(timeout)
pendingPackTokens.delete(requestId)
reject(new Error('无法读取 StarLight 登录状态,请重新登录。'))
}
})
}
type PendingLuckRequest = {
resolve: (luck: number) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
const pendingLuckRequests = new Map<string, PendingLuckRequest>()
type PendingPlayersRequest = {
resolve: (players: SkinSitePlayer[]) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
const pendingPlayersRequests = new Map<string, PendingPlayersRequest>()
type PendingSkinUpdateRequest = {
resolve: () => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
const pendingSkinUpdateRequests = new Map<string, PendingSkinUpdateRequest>()
function isSafePngDataUrl(value: unknown, maxLength: number) {
return (
typeof value === 'string' &&
value.length <= maxLength &&
/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i.test(value)
)
}
function hasSafeSkinHead(player: SkinSitePlayer) {
if (!['ready', 'empty', 'error'].includes(player.skinState)) return false
if (player.skinState !== 'ready') return player.headDataUrl === undefined
return (
isSafePngDataUrl(player.headDataUrl, 100_000) &&
(player.skinDataUrl === undefined || isSafePngDataUrl(player.skinDataUrl, 2_000_000)) &&
(player.model === undefined || player.model === 'default' || player.model === 'slim')
)
}
export const skinSiteUser = readonly(user)
export const skinSiteStatus = readonly(status)
export const skinSitePlayers = readonly(players)
export const skinSitePlayersStatus = readonly(playersStatus)
export const selectedSkinSitePlayerId = readonly(selectedPlayerId)
export const skinSiteFrameUrl = readonly(frameUrl)
export async function waitForSkinSiteSession() {
if (status.value !== 'checking') return
await new Promise<void>((resolve, reject) => {
const stop = watch(status, next => {
if (next === 'checking') return
clearTimeout(timer)
stop()
resolve()
})
const timer = setTimeout(() => {
stop()
reject(new Error('皮肤站登录状态仍在检查,请稍后重试。'))
}, 10_000)
})
}
function rejectPendingLuckRequests(message: string) {
for (const request of pendingLuckRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
}
pendingLuckRequests.clear()
}
function rejectPendingPlayersRequests(message: string) {
for (const request of pendingPlayersRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
}
pendingPlayersRequests.clear()
}
function rejectPendingSkinUpdateRequests(message: string) {
for (const request of pendingSkinUpdateRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
}
pendingSkinUpdateRequests.clear()
}
export function setSkinSiteFrame(frame: Window | null) {
if (frame && !user.value) status.value = 'checking'
if (connectedFrame === frame) return
rejectPendingPackTokens()
rejectPendingLuckRequests('The skin site connection changed.')
rejectPendingPlayersRequests('The skin site connection changed.')
rejectPendingSkinUpdateRequests('The skin site connection changed.')
connectedFrame = frame
}
export function selectSkinSitePlayer(playerId: string | null) {
if (playerId === null) {
selectedPlayerId.value = null
return
}
if (players.value.some((player) => player.uuid === playerId)) selectedPlayerId.value = playerId
}
export function openSkinSiteLogin() {
frameUrl.value = `${SKIN_SITE_ORIGIN}/login`
}
export function resetSkinSiteSession() {
rejectPendingPackTokens()
rejectPendingLuckRequests('The skin site session ended.')
rejectPendingPlayersRequests('The skin site session ended.')
rejectPendingSkinUpdateRequests('The skin site session ended.')
user.value = null
status.value = 'signed-out'
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
export function requestSkinSiteSkinUpdate(
playerId: string,
textureDataUrl: string,
model: 'default' | 'slim',
) {
const player = players.value.find((candidate) => candidate.uuid === playerId)
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(new Error('Sign in to the skin site before changing a skin.'))
}
if (!player || player.isMojang) {
return Promise.reject(new Error('This skin site player cannot be changed.'))
}
if (!isSafePngDataUrl(textureDataUrl, 2_000_000)) {
return Promise.reject(new Error('The selected skin texture is invalid or too large.'))
}
const requestId = `skin-update-${Date.now()}-${++skinUpdateRequestSequence}`
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingSkinUpdateRequests.delete(requestId)
reject(new Error('The skin site update timed out.'))
}, 20_000)
pendingSkinUpdateRequests.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{
type: 'starlight-skin-update-request',
requestId,
playerId,
textureDataUrl,
model,
},
SKIN_SITE_ORIGIN,
)
} catch (error) {
clearTimeout(timeout)
pendingSkinUpdateRequests.delete(requestId)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
export function requestSkinSitePlayers() {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(new Error('Sign in to the skin site before requesting players.'))
}
playersStatus.value = 'checking'
const requestId = `skin-players-${Date.now()}-${++playersRequestSequence}`
return new Promise<SkinSitePlayer[]>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingPlayersRequests.delete(requestId)
playersStatus.value = 'error'
reject(new Error('The skin site player request timed out.'))
}, 30_000)
pendingPlayersRequests.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-skin-players-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch (error) {
clearTimeout(timeout)
pendingPlayersRequests.delete(requestId)
playersStatus.value = 'error'
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
export function requestSkinSiteLuck() {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(new Error('Sign in to the skin site before requesting luck.'))
}
const requestId = `skin-luck-${Date.now()}-${++luckRequestSequence}`
return new Promise<number>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingLuckRequests.delete(requestId)
reject(new Error('The skin site luck request timed out.'))
}, 12_000)
pendingLuckRequests.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-skin-luck-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch (error) {
clearTimeout(timeout)
pendingLuckRequests.delete(requestId)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null) {
if (!frame || event.source !== frame || event.origin !== SKIN_SITE_ORIGIN) return false
const data = event.data
if (!data || typeof data !== 'object') return false
if (data.type === 'starlight-pack-token-result') {
const pending = pendingPackTokens.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingPackTokens.delete(data.requestId)
if (
status.value === 'signed-in' &&
typeof data.token === 'string' &&
data.token.length > 0 &&
data.token.length <= 16_384 &&
!/\s/.test(data.token)
) {
pending.resolve(data.token)
} else pending.reject(new Error('StarLight 登录凭据不可用,请重新登录。'))
return true
}
if (data.type === 'starlight-skin-players-result') {
if (typeof data.requestId !== 'string') return false
const pending = pendingPlayersRequests.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingPlayersRequests.delete(data.requestId)
if (data.ok === true && Array.isArray(data.players)) {
const nextPlayers = data.players.filter(
(player: unknown): player is SkinSitePlayer =>
typeof player === 'object' &&
player !== null &&
typeof (player as SkinSitePlayer).uuid === 'string' &&
/^[0-9a-f-]{32,36}$/i.test((player as SkinSitePlayer).uuid) &&
typeof (player as SkinSitePlayer).name === 'string' &&
(player as SkinSitePlayer).name.length > 0 &&
(player as SkinSitePlayer).name.length <= 64 &&
typeof (player as SkinSitePlayer).isMojang === 'boolean' &&
hasSafeSkinHead(player as SkinSitePlayer),
)
const previousPlayers = new Map(players.value.map((player) => [player.uuid, player]))
players.value = nextPlayers.map((player) => {
const previous = previousPlayers.get(player.uuid)
return player.skinState === 'error' && previous?.skinState === 'ready'
? { ...player, ...previous, name: player.name, isMojang: player.isMojang }
: player
})
if (
selectedPlayerId.value &&
!nextPlayers.some((player) => player.uuid === selectedPlayerId.value)
) {
selectedPlayerId.value = null
}
playersStatus.value = 'ready'
pending.resolve(players.value)
} else {
playersStatus.value = 'error'
pending.reject(
new Error(
typeof data.error === 'string' && data.error
? data.error.slice(0, 300)
: 'The skin site returned an invalid player list.',
),
)
}
return true
}
if (data.type === 'starlight-skin-luck-result') {
if (typeof data.requestId !== 'string') return false
const pending = pendingLuckRequests.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingLuckRequests.delete(data.requestId)
if (
data.ok === true &&
typeof data.luck === 'number' &&
Number.isFinite(data.luck) &&
data.luck >= 0 &&
data.luck <= 100
) {
pending.resolve(data.luck)
} else {
pending.reject(
new Error(
typeof data.error === 'string' && data.error
? data.error.slice(0, 300)
: 'The skin site returned an invalid luck result.',
),
)
}
return true
}
if (data.type === 'starlight-skin-update-result') {
if (typeof data.requestId !== 'string') return false
const pending = pendingSkinUpdateRequests.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingSkinUpdateRequests.delete(data.requestId)
if (data.ok === true) pending.resolve()
else {
pending.reject(
new Error(
typeof data.error === 'string' && data.error
? data.error.slice(0, 300)
: 'The skin site rejected the skin update.',
),
)
}
return true
}
if (data.type !== 'starlight-skin-session') return false
if (!['checking', 'signed-out', 'signed-in', 'error'].includes(data.status)) return false
let refreshPlayers = false
if (data.status === 'signed-in') {
if (
!data.user ||
typeof data.user.uuid !== 'string' ||
typeof data.user.username !== 'string' ||
!data.user.uuid ||
!data.user.username ||
data.user.username.length > 256
)
return false
if (user.value?.uuid && user.value.uuid !== data.user.uuid) {
rejectPendingPackTokens()
rejectPendingPlayersRequests('The skin site account changed.')
rejectPendingSkinUpdateRequests('The skin site account changed.')
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
user.value = { uuid: data.user.uuid, username: data.user.username }
if ([`${SKIN_SITE_ORIGIN}/`, `${SKIN_SITE_ORIGIN}/login`].includes(frameUrl.value)) {
frameUrl.value = `${SKIN_SITE_ORIGIN}/profile`
}
refreshPlayers = true
} else {
rejectPendingPackTokens()
rejectPendingLuckRequests('The skin site session is unavailable.')
rejectPendingPlayersRequests('The skin site session is unavailable.')
rejectPendingSkinUpdateRequests('The skin site session is unavailable.')
user.value = null
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
status.value = data.status
if (refreshPlayers) void requestSkinSitePlayers().catch(() => {})
return true
}
@@ -1,17 +0,0 @@
import { onUnmounted } from 'vue'
import { instance_listener } from '@/helpers/events.js'
export interface InstanceAppEvent {
event: string
instance_id?: string
}
export function useAppEvent(type: 'instance', callback: (event: InstanceAppEvent) => void) {
if (type !== 'instance') return
let unlisten: (() => void) | undefined
void instance_listener(callback).then((dispose) => {
unlisten = dispose
})
onUnmounted(() => unlisten?.())
}
@@ -1,154 +0,0 @@
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { type MaybeRefOrGetter, ref, toValue, watch } from 'vue'
export type ImageThumbnailSource = {
instanceId: string
filePath: string
path: string
}
type ThumbnailCacheEntry = {
promise: Promise<string>
url?: string
users: number
lastUsed: number
}
const MAX_CONCURRENT_THUMBNAILS = 4
const MAX_CACHED_THUMBNAILS = 256
const thumbnailCache = new Map<string, ThumbnailCacheEntry>()
const thumbnailQueue: Array<() => void> = []
let activeThumbnailRequests = 0
function runThumbnailQueue() {
while (activeThumbnailRequests < MAX_CONCURRENT_THUMBNAILS && thumbnailQueue.length > 0) {
activeThumbnailRequests += 1
thumbnailQueue.shift()?.()
}
}
function enqueueThumbnail<T>(task: () => Promise<T>) {
return new Promise<T>((resolve, reject) => {
thumbnailQueue.push(() => {
void task()
.then(resolve, reject)
.finally(() => {
activeThumbnailRequests -= 1
runThumbnailQueue()
})
})
runThumbnailQueue()
})
}
function removeThumbnail(cacheKey: string, entry: ThumbnailCacheEntry) {
if (thumbnailCache.get(cacheKey) !== entry) return
thumbnailCache.delete(cacheKey)
if (entry.url) {
URL.revokeObjectURL(entry.url)
} else {
void entry.promise.then(
(url) => URL.revokeObjectURL(url),
() => undefined,
)
}
}
function trimThumbnailCache() {
if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) return
const unused = [...thumbnailCache.entries()]
.filter(([, entry]) => entry.users === 0)
.sort(([, left], [, right]) => left.lastUsed - right.lastUsed)
for (const [cacheKey, entry] of unused) {
if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) break
removeThumbnail(cacheKey, entry)
}
}
function acquireThumbnail(source: ImageThumbnailSource, maxDimension: number, revision: unknown) {
const cacheKey = JSON.stringify([
source.instanceId,
source.filePath,
maxDimension,
revision ?? null,
])
let entry = thumbnailCache.get(cacheKey)
if (!entry) {
entry = {
users: 0,
lastUsed: performance.now(),
promise: enqueueThumbnail(async () => {
const bytes = await invoke<ArrayBuffer>('plugin:files|screenshot_thumbnail', {
instanceId: source.instanceId,
filePath: source.filePath,
maxDimension,
})
return URL.createObjectURL(new Blob([bytes]))
}),
}
thumbnailCache.set(cacheKey, entry)
void entry.promise.then(
(url) => {
entry!.url = url
trimThumbnailCache()
},
() => {
if (thumbnailCache.get(cacheKey) === entry) thumbnailCache.delete(cacheKey)
},
)
}
entry.users += 1
entry.lastUsed = performance.now()
return {
promise: entry.promise,
release: () => {
entry!.users = Math.max(0, entry!.users - 1)
entry!.lastUsed = performance.now()
trimThumbnailCache()
},
}
}
export function useImageThumbnail(
source: MaybeRefOrGetter<ImageThumbnailSource | null | undefined>,
maxDimension: MaybeRefOrGetter<number> = 512,
revision: MaybeRefOrGetter<string | number | undefined> = undefined,
) {
const thumbnail = ref('')
watch(
[() => toValue(source), () => toValue(maxDimension), () => toValue(revision)],
([currentSource, currentMaxDimension, currentRevision], _, onCleanup) => {
thumbnail.value = ''
if (!currentSource) return
let active = true
const fallbackUrl = convertFileSrc(currentSource.path)
const acquired = acquireThumbnail(
currentSource,
Math.max(1, Math.round(currentMaxDimension)),
currentRevision,
)
onCleanup(() => {
active = false
acquired.release()
})
void acquired.promise.then(
(url) => {
if (active) thumbnail.value = url
},
(error) => {
console.warn('Could not create image thumbnail', error)
if (active) thumbnail.value = fallbackUrl
},
)
},
{ immediate: true },
)
return thumbnail
}
@@ -13,9 +13,9 @@ import type {
SymlinkMethodChoice,
} from '@modrinth/ui'
import { useDebugLogger, useGlobalDrop, useInstanceContext, useVIntl } from '@modrinth/ui'
import { join } from '@tauri-apps/api/path'
import { computed, type ComputedRef, nextTick, ref } from 'vue'
import type { Router } from 'vue-router'
import { join } from '@tauri-apps/api/path'
import {
classifyDroppedItem,
@@ -1412,7 +1412,10 @@ export function useDropImport(options: DropImportOptions) {
cleanupLauncherZipTemp()
}
function resolvedInstancePath(inst: SelectedInstance): string | undefined {
function resolvedInstancePath(
inst: SelectedInstance,
ctx: ImportContext | null,
): string | undefined {
if (inst.compatibleMode) return inst.versionPath
return inst.path
}
@@ -1,77 +0,0 @@
import { invoke } from '@tauri-apps/api/core'
import { ref } from 'vue'
import { hostedCreate } from '../helpers/hosted-packs.ts'
import { runHostedSync } from './useHostedSync.ts'
const installing = ref(false)
const installError = ref('')
const createdInstance = ref<string>()
const completed = ref(false)
let generation = 0
function errorMessage(cause: unknown) {
return cause instanceof Error ? cause.message : String(cause)
}
export function forgetHostedCreation(instanceId: string) {
if (createdInstance.value !== instanceId) return
generation++
createdInstance.value = undefined
completed.value = false
installError.value = ''
}
export function markHostedCreationCompleted(instanceId: string) {
if (createdInstance.value !== instanceId) return
installError.value = ''
completed.value = true
}
export function markHostedCreationFailed(instanceId: string, cause: unknown) {
if (createdInstance.value !== instanceId) return
completed.value = false
installError.value = errorMessage(cause)
}
export function useHostedCreation() {
function acknowledge(instanceId: string) {
if (completed.value && createdInstance.value === instanceId) {
createdInstance.value = undefined
completed.value = false
}
}
async function install(gameDirRoot?: string | null) {
if (installing.value) return
installing.value = true
installError.value = ''
const attempt = generation
try {
if (createdInstance.value) {
const instance = await invoke<unknown | null>('plugin:instance|instance_get', {
instanceId: createdInstance.value,
})
if (attempt !== generation) return
if (!instance) {
createdInstance.value = undefined
completed.value = false
}
}
if (completed.value) return createdInstance.value
createdInstance.value ??= await hostedCreate(gameDirRoot)
const instanceId = createdInstance.value
await runHostedSync(instanceId)
if (attempt !== generation) return
markHostedCreationCompleted(instanceId)
return instanceId
} catch (cause) {
if (attempt === generation) {
if (createdInstance.value) markHostedCreationFailed(createdInstance.value, cause)
else installError.value = errorMessage(cause)
}
} finally {
installing.value = false
}
}
return { installing, installError, createdInstance, completed, acknowledge, install }
}
@@ -1,66 +0,0 @@
import { computed, reactive } from 'vue'
import { hostedSync, type HostedSyncResult } from '../helpers/hosted-packs.ts'
const tasks = reactive(
new Map<string, { busy: boolean; result: HostedSyncResult | null; error: string }>(),
)
const inFlight = new Map<string, Promise<HostedSyncResult>>()
function getTask(instanceId: string) {
if (!tasks.has(instanceId)) tasks.set(instanceId, { busy: false, result: null, error: '' })
return tasks.get(instanceId)!
}
/**
* Run one authoritative StarLight synchronization per instance. Every retry
* surface uses this function so the full pack transaction, its progress and
* its final result cannot diverge between pages.
*/
export function runHostedSync(instanceId: string): Promise<HostedSyncResult> {
const existing = inFlight.get(instanceId)
if (existing) return existing
const current = getTask(instanceId)
current.busy = true
current.error = ''
current.result = null
const request = hostedSync(instanceId)
.then((result) => {
current.result = result
return result
})
.catch((error) => {
current.error = String(error)
throw error
})
.finally(() => {
current.busy = false
inFlight.delete(instanceId)
})
inFlight.set(instanceId, request)
return request
}
export function useHostedSync(instanceId: () => string) {
const task = computed(() => {
return getTask(instanceId())
})
async function sync() {
const id = instanceId()
if (task.value.busy) return undefined
try {
return await runHostedSync(id)
} catch {
return undefined
}
}
return {
busy: computed(() => task.value.busy),
result: computed(() => task.value.result),
error: computed(() => task.value.error),
sync,
}
}
@@ -2,14 +2,12 @@ import { createConsoleState } from '@modrinth/ui'
import {
clear_log_buffer,
get_live_log_snapshot,
get_live_log_buffer,
get_logs,
get_minecraft_latest_log_cursor,
} from '@/helpers/logs'
import {
createLiveLogReconciler,
type SequencedLogEvent,
} from './live-log-reconciler'
import { mergeLiveLogHistory } from './console-history'
type ConsoleState = ReturnType<typeof createConsoleState>
@@ -27,17 +25,7 @@ interface InstanceConsoleEntry {
historicalConsole: ConsoleState
historicalCache: Map<string, string>
logList: LogEntry[] | null
liveLog: ReturnType<typeof createLiveLogReconciler<ProcessLogPayload>>
}
interface ProcessLogPayload extends SequencedLogEvent {
type: 'log4j' | 'legacy'
message?: string
logger_name?: string
level?: string
thread_name?: string
timestamp_millis?: number
throwable?: string
liveHistoryHydration: Promise<void> | null
}
const instances = new Map<string, InstanceConsoleEntry>()
@@ -46,29 +34,12 @@ function getOrCreate(instanceId: string): InstanceConsoleEntry {
let entry = instances.get(instanceId)
if (entry) return entry
const liveConsole = createConsoleState()
const liveLog = createLiveLogReconciler<ProcessLogPayload>(
() => get_live_log_snapshot(instanceId),
(snapshot) => {
liveConsole.clear()
void liveConsole.addLegacyLog(
snapshot.lines.map((line) => line.message).join('\n'),
)
},
(payload) => {
if (payload.type === 'log4j') {
liveConsole.addLog4jEvent(payload)
} else if (payload.message !== undefined) {
void liveConsole.addLegacyLog(payload.message)
}
},
)
entry = {
liveConsole,
liveConsole: createConsoleState(),
historicalConsole: createConsoleState(),
historicalCache: new Map(),
logList: null,
liveLog,
liveHistoryHydration: null,
}
instances.set(instanceId, entry)
return entry
@@ -76,7 +47,36 @@ function getOrCreate(instanceId: string): InstanceConsoleEntry {
async function hydrate(instanceId: string): Promise<void> {
const entry = getOrCreate(instanceId)
await entry.liveLog.sync()
if (entry.liveConsole.output.value.length > 0) return
if (entry.liveHistoryHydration) {
return entry.liveHistoryHydration
}
const hydration = (async () => {
const [latestLog, buffer] = await Promise.all([
get_minecraft_latest_log_cursor(instanceId, 0)
.then((result) => result.output)
.catch(() => ''),
get_live_log_buffer(instanceId),
])
if (entry.liveConsole.output.value.length > 0) return
const history = mergeLiveLogHistory(latestLog, buffer)
if (history) {
await entry.liveConsole.addLegacyLog(history)
}
})()
entry.liveHistoryHydration = hydration
try {
await hydration
} finally {
if (entry.liveHistoryHydration === hydration) {
entry.liveHistoryHydration = null
}
}
}
async function getHistoricalLogs(instanceId: string): Promise<LogEntry[]> {
@@ -108,13 +108,13 @@ function invalidate(instanceId: string): void {
async function clearLive(instanceId: string): Promise<void> {
const entry = getOrCreate(instanceId)
await clear_log_buffer(instanceId)
await entry.liveLog.resync()
entry.liveConsole.clear()
await clear_log_buffer(instanceId).catch(() => {})
}
function destroy(instanceId: string): void {
instances.get(instanceId)?.liveLog.dispose()
async function destroy(instanceId: string): Promise<void> {
instances.delete(instanceId)
await clear_log_buffer(instanceId).catch(() => {})
}
export function useInstanceConsole(instanceId: string) {
@@ -127,8 +127,6 @@ export function useInstanceConsole(instanceId: string) {
getHistoricalContent: (filename: string) => getHistoricalContent(instanceId, filename),
invalidate: () => invalidate(instanceId),
clearLive: () => clearLive(instanceId),
appendLive: (payload: ProcessLogPayload) => entry.liveLog.accept(payload),
resyncLive: () => entry.liveLog.resync(),
destroy: () => destroy(instanceId),
}
}
@@ -1,27 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type MaybeRefOrGetter, toValue } from 'vue'
import { getInstanceMode, type InstanceMode, setInstanceMode } from '@/helpers/hosted-packs'
export const instanceModeKey = (instanceId: string) => ['instance-mode', instanceId] as const
export function useInstanceMode(instanceId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => instanceModeKey(toValue(instanceId))),
queryFn: () => getInstanceMode(toValue(instanceId)),
enabled: computed(() => !!toValue(instanceId)),
staleTime: 30_000,
})
}
export function useSetInstanceMode() {
const client = useQueryClient()
return useMutation({
mutationFn: ({ instanceId, mode }: { instanceId: string; mode: InstanceMode }) =>
setInstanceMode(instanceId, mode),
onMutate: ({ instanceId }) => client.cancelQueries({ queryKey: instanceModeKey(instanceId) }),
onSuccess: (_, { instanceId, mode }) => client.setQueryData(instanceModeKey(instanceId), mode),
onError: (_, { instanceId }) =>
client.invalidateQueries({ queryKey: instanceModeKey(instanceId) }),
})
}
+1 -2
View File
@@ -4,8 +4,7 @@ export const AxolotlBrandConfig = Object.freeze({
productName: 'Starlight Launcher',
shortProductName: 'Starlight',
website: 'https://skin.starlight.cool/',
repositoryUrl: 'https://github.com/ApTx2012/Starlight_Lancher',
releasesUrl: 'https://github.com/ApTx2012/Starlight_Lancher/releases',
repositoryUrl: 'https://git.starlight.cool/Disy/StarLight-Yggdrasil-Server',
supportUrl: 'https://skin.starlight.cool/',
qqGroupNumber: '000000000',
qqChannelUrl: 'https://skin.starlight.cool/',
+2 -2
View File
@@ -1,3 +1,4 @@
[
{
"name": "Ax_Tps",
@@ -8,7 +9,6 @@
{
"name": "Disy920",
"avatar": "Disy920.png",
"url": "https://space.bilibili.com/231241189?spm_id_from=333.337.0.0",
"experience": "color-mine"
"url": "https://space.bilibili.com/231241189?spm_id_from=333.337.0.0"
}
]
Loaded 100 of 325 files, more files were not shown because too many files have changed in this diff. Show more