Compare commits
22 Commits
67638df07f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 42b0c7e7af | |||
| 405d980311 | |||
| 905e905eca | |||
| cda7bb284a | |||
| ffe529df57 | |||
| 984f3ddbd6 | |||
| a477b1fb9a | |||
| a47d309103 | |||
| 392f01ea4b | |||
| 5006f432dc | |||
| 51fb0c30f7 | |||
| bd09fed0ed | |||
| bc904065c3 | |||
| 5d473ebfbc | |||
| 0b30fc96a0 | |||
| 2a1dfa3c42 | |||
| fbcaed528b | |||
| 9dd2b013af | |||
| c50595b252 | |||
| 3471cd3da2 | |||
| 1593ac7a7c | |||
| 1fa56add19 |
11
.github/workflows/axolotl-release.yml
vendored
@ -198,7 +198,7 @@ jobs:
|
|||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$version = "${{ github.ref_name }}" -replace '^v', ''
|
$version = "${{ github.ref_name }}" -replace '^v', ''
|
||||||
$appName = "Axolotl Launcher"
|
$appName = (Get-Content apps/app/tauri.conf.json -Raw | ConvertFrom-Json).mainBinaryName
|
||||||
$outDir = "target/release/bundle/nsis"
|
$outDir = "target/release/bundle/nsis"
|
||||||
$buildDir = "target/portable-build"
|
$buildDir = "target/portable-build"
|
||||||
$publishDir = Join-Path $env:RUNNER_TEMP "windows-assets"
|
$publishDir = Join-Path $env:RUNNER_TEMP "windows-assets"
|
||||||
@ -249,6 +249,15 @@ jobs:
|
|||||||
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl" | Out-Null
|
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl" | Out-Null
|
||||||
Copy-Item -Force $appExe "$buildDir\Axolotl\"
|
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
|
# Create empty .Axolotl folder to trigger portable mode
|
||||||
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl\.Axolotl" | Out-Null
|
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl\.Axolotl" | Out-Null
|
||||||
|
|
||||||
|
|||||||
51
Cargo.lock
generated
@ -106,7 +106,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -707,7 +707,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"fnv",
|
"fnv",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1431,21 +1431,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "discord-rich-presence"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"serde_json",
|
|
||||||
"serde_repr",
|
|
||||||
"thiserror 2.0.19",
|
|
||||||
"uuid 0.8.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dispatch2"
|
name = "dispatch2"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@ -3570,7 +3555,7 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"tagptr",
|
"tagptr",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -5300,7 +5285,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"url",
|
"url",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -5904,7 +5889,7 @@ dependencies = [
|
|||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -5984,7 +5969,7 @@ dependencies = [
|
|||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
"whoami",
|
"whoami",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -6022,7 +6007,7 @@ dependencies = [
|
|||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
"whoami",
|
"whoami",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -6048,7 +6033,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -6454,7 +6439,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"time",
|
"time",
|
||||||
"url",
|
"url",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -6762,7 +6747,7 @@ dependencies = [
|
|||||||
"toml 1.1.3+spec-1.1.0",
|
"toml 1.1.3+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
"urlpattern",
|
"urlpattern",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -6833,7 +6818,6 @@ dependencies = [
|
|||||||
"data-url",
|
"data-url",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"dirs",
|
"dirs",
|
||||||
"discord-rich-presence",
|
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"dunce",
|
"dunce",
|
||||||
"either",
|
"either",
|
||||||
@ -6901,7 +6885,7 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
"whoami",
|
"whoami",
|
||||||
"windows 0.61.3",
|
"windows 0.61.3",
|
||||||
@ -6967,7 +6951,7 @@ dependencies = [
|
|||||||
"trash",
|
"trash",
|
||||||
"url",
|
"url",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
"windows 0.61.3",
|
"windows 0.61.3",
|
||||||
"zip 6.0.0",
|
"zip 6.0.0",
|
||||||
]
|
]
|
||||||
@ -7691,15 +7675,6 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "uuid"
|
|
||||||
version = "0.8.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.2.17",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.24.0"
|
version = "1.24.0"
|
||||||
@ -8868,7 +8843,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uds_windows",
|
"uds_windows",
|
||||||
"uuid 1.24.0",
|
"uuid",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
"winnow 1.0.4",
|
"winnow 1.0.4",
|
||||||
"zbus_macros",
|
"zbus_macros",
|
||||||
|
|||||||
@ -41,7 +41,6 @@ dashmap = "6.1.0"
|
|||||||
data-url = "0.3.2"
|
data-url = "0.3.2"
|
||||||
derive_more = "2.1.1"
|
derive_more = "2.1.1"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
discord-rich-presence = "1.0.0"
|
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
dunce = "1.0.5"
|
dunce = "1.0.5"
|
||||||
either = "1.15.0"
|
either = "1.15.0"
|
||||||
|
|||||||
@ -22,6 +22,10 @@ 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.
|
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
|
## 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).
|
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).
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { AuthFeature, TauriModrinthClient, VerboseLoggingFeature } from '@modrin
|
|||||||
import {
|
import {
|
||||||
ChangeSkinIcon,
|
ChangeSkinIcon,
|
||||||
CompassIcon,
|
CompassIcon,
|
||||||
GlobeIcon,
|
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
ExternalIcon,
|
ExternalIcon,
|
||||||
FlaskConicalIcon,
|
FlaskConicalIcon,
|
||||||
@ -57,13 +56,13 @@ import SymlinkMethodCards from '@modrinth/ui/src/components/flows/drop/SymlinkMe
|
|||||||
import { useQuery } from '@tanstack/vue-query'
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
import { getVersion } from '@tauri-apps/api/app'
|
import { getVersion } from '@tauri-apps/api/app'
|
||||||
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
|
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
|
||||||
import { hideAllPoppers } from 'floating-vue'
|
|
||||||
import { listen } from '@tauri-apps/api/event'
|
import { listen } from '@tauri-apps/api/event'
|
||||||
import { Effect, getCurrentWindow } from '@tauri-apps/api/window'
|
import { Effect, getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
import { type as getOsType } from '@tauri-apps/plugin-os'
|
import { type as getOsType } from '@tauri-apps/plugin-os'
|
||||||
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
|
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 { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue'
|
||||||
import { type RouteLocationNormalizedLoaded, RouterView, useRoute, useRouter } from 'vue-router'
|
import { type RouteLocationNormalizedLoaded, RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
@ -80,25 +79,26 @@ import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/
|
|||||||
import MinecraftCrashModal from '@/components/ui/MinecraftCrashModal.vue'
|
import MinecraftCrashModal from '@/components/ui/MinecraftCrashModal.vue'
|
||||||
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
|
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
|
||||||
import CurseForgeManualDownloadsModal from '@/components/ui/modal/CurseForgeManualDownloadsModal.vue'
|
import CurseForgeManualDownloadsModal from '@/components/ui/modal/CurseForgeManualDownloadsModal.vue'
|
||||||
|
import TaggedModDownloadsModal from '@/components/instance/TaggedModDownloadsModal.vue'
|
||||||
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
|
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
|
||||||
import InstanceIconPickerModal from '@/components/ui/modal/InstanceIconPickerModal.vue'
|
import InstanceIconPickerModal from '@/components/ui/modal/InstanceIconPickerModal.vue'
|
||||||
import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue'
|
import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue'
|
||||||
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
|
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
|
||||||
import ModpackInstallModal from '@/components/ui/modal/ModpackInstallModal.vue'
|
import ModpackInstallModal from '@/components/ui/modal/ModpackInstallModal.vue'
|
||||||
import PrivacyConsentModal from '@/components/ui/modal/PrivacyConsentModal.vue'
|
|
||||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||||
import NavButton from '@/components/ui/NavButton.vue'
|
import NavButton from '@/components/ui/NavButton.vue'
|
||||||
import NavRail from '@/components/ui/NavRail.vue'
|
import NavRail from '@/components/ui/NavRail.vue'
|
||||||
import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
|
import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
|
||||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||||
|
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
|
||||||
|
import InstancePlayerModal from '@/components/instance/InstancePlayerModal.vue'
|
||||||
import WindowControls from '@/components/ui/WindowControls.vue'
|
import WindowControls from '@/components/ui/WindowControls.vue'
|
||||||
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||||
import { useDropImport } from '@/composables/useDropImport'
|
import { useDropImport } from '@/composables/useDropImport'
|
||||||
import { minecraftLaunchErrorKey } from '@/composables/useMinecraftLaunchError'
|
import { minecraftLaunchErrorKey } from '@/composables/useMinecraftLaunchError'
|
||||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||||
import { AxolotlBrandConfig, config, getOfficialLabrinthBaseUrl } from '@/config'
|
import { AxolotlBrandConfig, config, getOfficialLabrinthBaseUrl } from '@/config'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { check_reachable } from '@/helpers/auth.js'
|
import { check_reachable } from '@/helpers/auth.js'
|
||||||
import { get_user, get_version } from '@/helpers/cache.js'
|
import { get_user, get_version } from '@/helpers/cache.js'
|
||||||
import { configureCurseForgeManualDownloadWatcher } from '@/helpers/curseforge'
|
import { configureCurseForgeManualDownloadWatcher } from '@/helpers/curseforge'
|
||||||
@ -113,22 +113,20 @@ import {
|
|||||||
} from '@/helpers/events.js'
|
} from '@/helpers/events.js'
|
||||||
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
|
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
|
||||||
import { type DirectLinkSyncReport, get as getInstance, run } from '@/helpers/instance'
|
import { type DirectLinkSyncReport, get as getInstance, run } from '@/helpers/instance'
|
||||||
|
import { PlayerSelectionNavigatedAwayError } from '@/helpers/instance-player'
|
||||||
import { reconcileMojangAuthSourceAtStartup } from '@/helpers/mojang-auth'
|
import { reconcileMojangAuthSourceAtStartup } from '@/helpers/mojang-auth'
|
||||||
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
|
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
|
||||||
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
|
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
|
||||||
import {
|
import {
|
||||||
get as getSettings,
|
get as getSettings,
|
||||||
getLastBrowseContentProjectType,
|
getLastBrowseContentProjectType,
|
||||||
getPrivacySettings,
|
|
||||||
getUpdateChannel,
|
getUpdateChannel,
|
||||||
getUpdatePreferences,
|
getUpdatePreferences,
|
||||||
isBrowseContentProjectType,
|
isBrowseContentProjectType,
|
||||||
type PrivacySettings,
|
|
||||||
savePrivacySettings,
|
|
||||||
set as setSettings,
|
set as setSettings,
|
||||||
} from '@/helpers/settings.ts'
|
} from '@/helpers/settings.ts'
|
||||||
import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts'
|
import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts'
|
||||||
import { get_opening_command, initialize_state, set_discord_activity } from '@/helpers/state'
|
import { get_opening_command, initialize_state } from '@/helpers/state'
|
||||||
import {
|
import {
|
||||||
areUpdatesEnabled,
|
areUpdatesEnabled,
|
||||||
backupAppDbForUpdate,
|
backupAppDbForUpdate,
|
||||||
@ -369,8 +367,6 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const stateInitialized = ref(false)
|
const stateInitialized = ref(false)
|
||||||
const privacyConsentModal = ref<InstanceType<typeof PrivacyConsentModal>>()
|
|
||||||
const privacyConsentPending = ref(false)
|
|
||||||
const closeChoiceModal = ref<InstanceType<typeof NewModal>>()
|
const closeChoiceModal = ref<InstanceType<typeof NewModal>>()
|
||||||
const closeChoiceOpen = ref(false)
|
const closeChoiceOpen = ref(false)
|
||||||
const closeChoiceRemember = ref(false)
|
const closeChoiceRemember = ref(false)
|
||||||
@ -737,10 +733,6 @@ const messages = defineMessages({
|
|||||||
id: 'app.navigation.skin-selector',
|
id: 'app.navigation.skin-selector',
|
||||||
defaultMessage: 'Skin selector',
|
defaultMessage: 'Skin selector',
|
||||||
},
|
},
|
||||||
starlightSkin: {
|
|
||||||
id: 'app.navigation.starlight-skin',
|
|
||||||
defaultMessage: '斯达莱特',
|
|
||||||
},
|
|
||||||
library: {
|
library: {
|
||||||
id: 'app.navigation.library',
|
id: 'app.navigation.library',
|
||||||
defaultMessage: 'Library',
|
defaultMessage: 'Library',
|
||||||
@ -765,9 +757,9 @@ const messages = defineMessages({
|
|||||||
id: 'app.account.signed-in-as',
|
id: 'app.account.signed-in-as',
|
||||||
defaultMessage: 'Signed in as',
|
defaultMessage: 'Signed in as',
|
||||||
},
|
},
|
||||||
playingAs: {
|
userInformation: {
|
||||||
id: 'app.minecraft.playing-as',
|
id: 'app.minecraft.user-information',
|
||||||
defaultMessage: 'Playing as',
|
defaultMessage: 'User information',
|
||||||
},
|
},
|
||||||
collapseSidebar: {
|
collapseSidebar: {
|
||||||
id: 'app.sidebar.collapse',
|
id: 'app.sidebar.collapse',
|
||||||
@ -1055,9 +1047,6 @@ async function setupApp() {
|
|||||||
theme,
|
theme,
|
||||||
accent_color,
|
accent_color,
|
||||||
locale,
|
locale,
|
||||||
telemetry,
|
|
||||||
telemetry_consent_version,
|
|
||||||
discord_rpc,
|
|
||||||
collapsed_navigation,
|
collapsed_navigation,
|
||||||
hide_nametag_skins_page,
|
hide_nametag_skins_page,
|
||||||
advanced_rendering,
|
advanced_rendering,
|
||||||
@ -1101,7 +1090,6 @@ async function setupApp() {
|
|||||||
const dev = await isDev()
|
const dev = await isDev()
|
||||||
isDevEnvironment.value = dev
|
isDevEnvironment.value = dev
|
||||||
if (!onboarded && route.path !== '/') await router.replace('/')
|
if (!onboarded && route.path !== '/') await router.replace('/')
|
||||||
privacyConsentPending.value = telemetry_consent_version < 1
|
|
||||||
showOnboarding.value = false
|
showOnboarding.value = false
|
||||||
onboardingSettings.value = initialSettings
|
onboardingSettings.value = initialSettings
|
||||||
|
|
||||||
@ -1132,16 +1120,7 @@ async function setupApp() {
|
|||||||
themeStore.devMode = developer_mode
|
themeStore.devMode = developer_mode
|
||||||
themeStore.featureFlags = feature_flags
|
themeStore.featureFlags = feature_flags
|
||||||
stateInitialized.value = true
|
stateInitialized.value = true
|
||||||
if (privacyConsentPending.value) {
|
showOnboarding.value = !onboarded
|
||||||
await nextTick()
|
|
||||||
privacyConsentModal.value?.show({
|
|
||||||
telemetry,
|
|
||||||
discord_rpc,
|
|
||||||
consent_version: telemetry_consent_version,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
showOnboarding.value = !onboarded
|
|
||||||
}
|
|
||||||
void reconcileMojangAuthSourceAtStartup().catch(handleError)
|
void reconcileMojangAuthSourceAtStartup().catch(handleError)
|
||||||
|
|
||||||
isMaximized.value = await getCurrentWindow().isMaximized()
|
isMaximized.value = await getCurrentWindow().isMaximized()
|
||||||
@ -1274,42 +1253,7 @@ async function closeOnboardingSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function scheduleStartupDialogs() {
|
async function scheduleStartupDialogs() {
|
||||||
if (!stateInitialized.value || privacyConsentPending.value || showOnboarding.value) return
|
if (!stateInitialized.value || showOnboarding.value) return
|
||||||
}
|
|
||||||
|
|
||||||
async function handlePrivacyConsentSaved(privacy: PrivacySettings) {
|
|
||||||
privacyConsentPending.value = false
|
|
||||||
if (onboardingSettings.value) {
|
|
||||||
onboardingSettings.value.telemetry = privacy.telemetry
|
|
||||||
onboardingSettings.value.discord_rpc = privacy.discord_rpc
|
|
||||||
onboardingSettings.value.telemetry_consent_version = privacy.consent_version
|
|
||||||
}
|
|
||||||
if (!onboardingSettings.value?.onboarded) {
|
|
||||||
startOnboarding('main')
|
|
||||||
} else {
|
|
||||||
await scheduleStartupDialogs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function previewPrivacyConsentModal() {
|
|
||||||
try {
|
|
||||||
const current = await getPrivacySettings()
|
|
||||||
const privacy = await savePrivacySettings({
|
|
||||||
telemetry: false,
|
|
||||||
discord_rpc: current.discord_rpc,
|
|
||||||
consent_version: 0,
|
|
||||||
})
|
|
||||||
privacyConsentPending.value = true
|
|
||||||
if (onboardingSettings.value) {
|
|
||||||
onboardingSettings.value.telemetry = privacy.telemetry
|
|
||||||
onboardingSettings.value.discord_rpc = privacy.discord_rpc
|
|
||||||
onboardingSettings.value.telemetry_consent_version = privacy.consent_version
|
|
||||||
}
|
|
||||||
await nextTick()
|
|
||||||
privacyConsentModal.value?.show(privacy)
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
provide('replayOnboarding', replayOnboarding)
|
provide('replayOnboarding', replayOnboarding)
|
||||||
@ -1320,7 +1264,6 @@ provide(
|
|||||||
)
|
)
|
||||||
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
|
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
|
||||||
provide('showLauncherPopup', (_request: unknown) => {})
|
provide('showLauncherPopup', (_request: unknown) => {})
|
||||||
provide('previewPrivacyConsentModal', previewPrivacyConsentModal)
|
|
||||||
|
|
||||||
const stateFailed = ref(false)
|
const stateFailed = ref(false)
|
||||||
stateInitialization
|
stateInitialization
|
||||||
@ -1429,9 +1372,6 @@ loading.setEnabled(false)
|
|||||||
let initialLoadToken = loading.begin()
|
let initialLoadToken = loading.begin()
|
||||||
let routerToken = null
|
let routerToken = null
|
||||||
let suspenseToken = null
|
let suspenseToken = null
|
||||||
let lastDiscordActivity = null
|
|
||||||
let discordActivityUpdate = Promise.resolve()
|
|
||||||
|
|
||||||
let suspensePending = false
|
let suspensePending = false
|
||||||
|
|
||||||
const sidebarOverlayScrollbarsOptions = Object.freeze({
|
const sidebarOverlayScrollbarsOptions = Object.freeze({
|
||||||
@ -1447,31 +1387,11 @@ router.beforeEach(() => {
|
|||||||
routerToken = loading.begin()
|
routerToken = loading.begin()
|
||||||
})
|
})
|
||||||
|
|
||||||
function syncDiscordActivity(to: RouteLocationNormalizedLoaded) {
|
|
||||||
const activity =
|
|
||||||
typeof to.meta.discordActivity === 'string' ? to.meta.discordActivity : 'Idling...'
|
|
||||||
if (activity === lastDiscordActivity) return
|
|
||||||
|
|
||||||
lastDiscordActivity = activity
|
|
||||||
discordActivityUpdate = discordActivityUpdate
|
|
||||||
.then(() => set_discord_activity(activity))
|
|
||||||
.catch((error) => {
|
|
||||||
if (lastDiscordActivity === activity) lastDiscordActivity = null
|
|
||||||
console.error('Failed to update Discord activity', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
router.afterEach((to, from, failure) => {
|
router.afterEach((to, from, failure) => {
|
||||||
hideAllPoppers()
|
hideAllPoppers()
|
||||||
if (!failure) void invoke('lightweight_mode_set_route', { route: to.fullPath })
|
if (!failure) void invoke('lightweight_mode_set_route', { route: to.fullPath })
|
||||||
trackEvent('PageView', {
|
|
||||||
path: to.path,
|
|
||||||
fromPath: from.path,
|
|
||||||
failed: failure,
|
|
||||||
})
|
|
||||||
if (!failure) {
|
if (!failure) {
|
||||||
void directLinkSync?.()
|
void directLinkSync?.()
|
||||||
if (stateInitialized.value) syncDiscordActivity(to)
|
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!suspensePending && stateInitialized.value) {
|
if (!suspensePending && stateInitialized.value) {
|
||||||
@ -1508,7 +1428,6 @@ watch(
|
|||||||
stateInitialized,
|
stateInitialized,
|
||||||
(ready) => {
|
(ready) => {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
syncDiscordActivity(router.currentRoute.value)
|
|
||||||
if (initialLoadToken) {
|
if (initialLoadToken) {
|
||||||
loading.end(initialLoadToken)
|
loading.end(initialLoadToken)
|
||||||
initialLoadToken = null
|
initialLoadToken = null
|
||||||
@ -1624,7 +1543,6 @@ const dropImport = useDropImport({
|
|||||||
onSkinsPage,
|
onSkinsPage,
|
||||||
onSchematicWorkshopPage,
|
onSchematicWorkshopPage,
|
||||||
isSchematicFile,
|
isSchematicFile,
|
||||||
trackEvent,
|
|
||||||
router,
|
router,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -1836,13 +1754,14 @@ async function handleCommand(e) {
|
|||||||
} else {
|
} else {
|
||||||
await install_create_modpack_instance(location).catch(handleError)
|
await install_create_modpack_instance(location).catch(handleError)
|
||||||
}
|
}
|
||||||
trackEvent('InstanceCreate', {
|
|
||||||
source: 'CreationModalFileDrop',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} else if (e.event === 'LaunchInstance') {
|
} else if (e.event === 'LaunchInstance') {
|
||||||
const instance = await getInstance(e.id).catch(() => null)
|
const instance = await getInstance(e.id).catch(() => null)
|
||||||
const handleLaunchCommandError = async (launchError) => {
|
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 =
|
const handled =
|
||||||
(await minecraftCrashModal.value?.handleLaunchError(launchError, {
|
(await minecraftCrashModal.value?.handleLaunchError(launchError, {
|
||||||
instance_id: e.id,
|
instance_id: e.id,
|
||||||
@ -2240,6 +2159,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
||||||
|
<SkinSiteSessionFrame v-if="stateInitialized" />
|
||||||
|
<InstancePlayerModal v-if="stateInitialized" />
|
||||||
<div id="teleports"></div>
|
<div id="teleports"></div>
|
||||||
<div
|
<div
|
||||||
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"
|
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"
|
||||||
@ -2321,13 +2242,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
>
|
>
|
||||||
<ChangeSkinIcon />
|
<ChangeSkinIcon />
|
||||||
</NavButton>
|
</NavButton>
|
||||||
<NavButton
|
|
||||||
v-tooltip.right="formatMessage(messages.starlightSkin)"
|
|
||||||
data-onboarding-id="nav-starlight-skin"
|
|
||||||
to="/starlight-skin"
|
|
||||||
>
|
|
||||||
<GlobeIcon />
|
|
||||||
</NavButton>
|
|
||||||
<NavButton
|
<NavButton
|
||||||
v-tooltip.right="formatMessage(messages.library)"
|
v-tooltip.right="formatMessage(messages.library)"
|
||||||
data-onboarding-id="nav-library"
|
data-onboarding-id="nav-library"
|
||||||
@ -2553,7 +2467,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
<div class="sidebar-default-content hidden" :class="{ 'sidebar-enabled': sidebarVisible }">
|
<div class="sidebar-default-content hidden" :class="{ 'sidebar-enabled': sidebarVisible }">
|
||||||
<div 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">
|
<h3 class="text-base text-primary font-medium m-0">
|
||||||
{{ formatMessage(messages.playingAs) }}
|
{{ formatMessage(messages.userInformation) }}
|
||||||
</h3>
|
</h3>
|
||||||
<suspense>
|
<suspense>
|
||||||
<AccountsCard ref="accounts" />
|
<AccountsCard ref="accounts" />
|
||||||
@ -2577,7 +2491,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
/>
|
/>
|
||||||
<MinecraftCrashModal ref="minecraftCrashModal" @error="handleError" />
|
<MinecraftCrashModal ref="minecraftCrashModal" @error="handleError" />
|
||||||
<JavaDownloadConfirmationModal ref="javaDownloadConfirmationModal" />
|
<JavaDownloadConfirmationModal ref="javaDownloadConfirmationModal" />
|
||||||
<PrivacyConsentModal ref="privacyConsentModal" @saved="handlePrivacyConsentSaved" />
|
|
||||||
<NewModal
|
<NewModal
|
||||||
ref="closeChoiceModal"
|
ref="closeChoiceModal"
|
||||||
:header="formatMessage(messages.closeLauncherTitle)"
|
:header="formatMessage(messages.closeLauncherTitle)"
|
||||||
@ -2664,6 +2577,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
@install="handleContentInstallModpackInstall"
|
@install="handleContentInstallModpackInstall"
|
||||||
@cancel="handleContentInstallModpackInstallCancel"
|
@cancel="handleContentInstallModpackInstallCancel"
|
||||||
/>
|
/>
|
||||||
|
<TaggedModDownloadsModal />
|
||||||
<CurseForgeManualDownloadsModal
|
<CurseForgeManualDownloadsModal
|
||||||
ref="contentInstallCurseForgeManualDownloadsModal"
|
ref="contentInstallCurseForgeManualDownloadsModal"
|
||||||
@view-instance="handleContentInstallModpackDuplicateGoToInstance"
|
@view-instance="handleContentInstallModpackDuplicateGoToInstance"
|
||||||
|
|||||||
BIN
apps/app-frontend/src/assets/about-scene/clouds.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
apps/app-frontend/src/assets/about-scene/moon-phases.png
Normal file
|
After Width: | Height: | Size: 835 B |
BIN
apps/app-frontend/src/assets/about-scene/netherite-sword.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
apps/app-frontend/src/assets/about-scene/victory-clean.jpg
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
apps/app-frontend/src/assets/about-scene/victory-master.jpg
Normal file
|
After Width: | Height: | Size: 386 KiB |
BIN
apps/app-frontend/src/assets/about-scene/victory-pose.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
@ -0,0 +1,31 @@
|
|||||||
|
# 凋灵局部清理素材
|
||||||
|
|
||||||
|
## 横梁删除修正(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.
|
||||||
|
After Width: | Height: | Size: 1.7 MiB |
41
apps/app-frontend/src/assets/icons/lucky-clover.svg
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/app-frontend/src/assets/illustrations/instance-thinking.png
Normal file
|
After Width: | Height: | Size: 740 KiB |
@ -28,7 +28,6 @@ import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
|
|||||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { install_duplicate_instance } from '@/helpers/install'
|
import { install_duplicate_instance } from '@/helpers/install'
|
||||||
import { kill, remove, run } from '@/helpers/instance'
|
import { kill, remove, run } from '@/helpers/instance'
|
||||||
import { get_by_instance_id } from '@/helpers/process.js'
|
import { get_by_instance_id } from '@/helpers/process.js'
|
||||||
@ -158,17 +157,9 @@ const handleOptionsClick = async (args) => {
|
|||||||
})
|
})
|
||||||
if (!handled) handleSevereError(err, { instanceId: args.item.id })
|
if (!handled) handleSevereError(err, { instanceId: args.item.id })
|
||||||
})
|
})
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: args.item.loader,
|
|
||||||
game_version: args.item.game_version,
|
|
||||||
})
|
|
||||||
break
|
break
|
||||||
case 'stop':
|
case 'stop':
|
||||||
await kill(args.item.id).catch(handleError)
|
await kill(args.item.id).catch(handleError)
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: args.item.loader,
|
|
||||||
game_version: args.item.game_version,
|
|
||||||
})
|
|
||||||
break
|
break
|
||||||
case 'add_content':
|
case 'add_content':
|
||||||
await router.push({
|
await router.push({
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import { computed, ref, watch } from 'vue'
|
|||||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import {
|
import {
|
||||||
type DailyPlaytime,
|
type DailyPlaytime,
|
||||||
type DailyPlaytimeEntry,
|
type DailyPlaytimeEntry,
|
||||||
@ -200,11 +199,6 @@ function selectDay(dateKey: string) {
|
|||||||
async function playInstance(instance: GameInstance) {
|
async function playInstance(instance: GameInstance) {
|
||||||
try {
|
try {
|
||||||
await run(instance.id)
|
await run(instance.id)
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomeCalendar',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: instance.id,
|
instance_id: instance.id,
|
||||||
@ -216,11 +210,6 @@ async function playInstance(instance: GameInstance) {
|
|||||||
|
|
||||||
async function stopInstance(instance: GameInstance) {
|
async function stopInstance(instance: GameInstance) {
|
||||||
await kill(instance.id).catch(handleError)
|
await kill(instance.id).catch(handleError)
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomeCalendar',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => anchor.value.getTime(), refreshPlaytime, { immediate: true })
|
watch(() => anchor.value.getTime(), refreshPlaytime, { immediate: true })
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DownloadIcon } from '@modrinth/assets'
|
import { DownloadIcon } from '@modrinth/assets'
|
||||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
import { defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
|
||||||
import { computed, onUnmounted, ref } from 'vue'
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
import { loading_listener } from '@/helpers/events'
|
import { loading_listener } from '@/helpers/events'
|
||||||
import type { LoadingBar } from '@/helpers/state'
|
import type { LoadingBar } from '@/helpers/state'
|
||||||
|
import { progress_bars_list } from '@/helpers/state'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
@ -15,6 +15,7 @@ const messages = defineMessages({
|
|||||||
})
|
})
|
||||||
|
|
||||||
interface LoadingEventPayload {
|
interface LoadingEventPayload {
|
||||||
|
total?: number | null
|
||||||
event: LoadingBar['bar_type']
|
event: LoadingBar['bar_type']
|
||||||
loader_uuid: string
|
loader_uuid: string
|
||||||
fraction: number | null
|
fraction: number | null
|
||||||
@ -22,6 +23,7 @@ interface LoadingEventPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface LaunchProgressItem {
|
interface LaunchProgressItem {
|
||||||
|
waiting: boolean
|
||||||
key: string
|
key: string
|
||||||
message: string
|
message: string
|
||||||
fraction: number | null
|
fraction: number | null
|
||||||
@ -29,6 +31,7 @@ interface LaunchProgressItem {
|
|||||||
|
|
||||||
// 启动相关(以及准备)阶段会发的 loading 类型;安装/下载也一并显示,便于用户看到进度
|
// 启动相关(以及准备)阶段会发的 loading 类型;安装/下载也一并显示,便于用户看到进度
|
||||||
const LAUNCH_BAR_TYPES = new Set([
|
const LAUNCH_BAR_TYPES = new Set([
|
||||||
|
'hosted_pack_sync',
|
||||||
'minecraft_download',
|
'minecraft_download',
|
||||||
'instance_update',
|
'instance_update',
|
||||||
'zip_extract',
|
'zip_extract',
|
||||||
@ -54,6 +57,7 @@ function applyEvent(payload: LoadingEventPayload) {
|
|||||||
if (!isVisible(payload.event)) return
|
if (!isVisible(payload.event)) return
|
||||||
|
|
||||||
activeMap.set(payload.loader_uuid, {
|
activeMap.set(payload.loader_uuid, {
|
||||||
|
waiting: payload.total === 0,
|
||||||
key: payload.loader_uuid,
|
key: payload.loader_uuid,
|
||||||
message: payload.message,
|
message: payload.message,
|
||||||
fraction: payload.fraction,
|
fraction: payload.fraction,
|
||||||
@ -64,13 +68,28 @@ function applyEvent(payload: LoadingEventPayload) {
|
|||||||
const hasProgress = computed(() => progressItems.value.length > 0)
|
const hasProgress = computed(() => progressItems.value.length > 0)
|
||||||
|
|
||||||
function percent(item: LaunchProgressItem): string {
|
function percent(item: LaunchProgressItem): string {
|
||||||
if (item.fraction == null || !Number.isFinite(item.fraction)) return ''
|
if (item.waiting || item.fraction == null || !Number.isFinite(item.fraction)) return ''
|
||||||
return `${Math.round(Math.max(0, Math.min(1, item.fraction)) * 100)}%`
|
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) => {
|
const unlistenLoading = await loading_listener((payload: LoadingEventPayload) => {
|
||||||
applyEvent(payload)
|
if (initializing) buffered.push(payload)
|
||||||
|
else 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(() => {
|
onUnmounted(() => {
|
||||||
unlistenLoading?.()
|
unlistenLoading?.()
|
||||||
@ -96,12 +115,7 @@ onUnmounted(() => {
|
|||||||
{{ percent(item) }}
|
{{ percent(item) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-1 w-full overflow-hidden rounded-full bg-surface-4">
|
<ProgressBar :progress="item.fraction ?? 0" :waiting="item.waiting" full-width />
|
||||||
<div
|
|
||||||
class="h-full rounded-full bg-brand transition-[width] duration-200"
|
|
||||||
:style="{ width: percent(item) || '100%' }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,35 +1,70 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ExternalIcon } from '@modrinth/assets'
|
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
|
||||||
|
import LuckyCloverIcon from '@/assets/icons/lucky-clover.svg'
|
||||||
|
import {
|
||||||
|
openSkinSiteLogin,
|
||||||
|
requestSkinSiteLuck,
|
||||||
|
skinSiteUser,
|
||||||
|
} from '@/composables/skin-site-session'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const { handleError } = injectNotificationManager()
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
widgetTitle: { id: 'app.home.skin-site.title', defaultMessage: 'StarLight skin site' },
|
widgetTitle: { id: 'app.home.luck.title', defaultMessage: 'Daily luck index' },
|
||||||
luckyIndex: { id: 'app.home.skin-site.lucky-index', defaultMessage: 'Lucky index' },
|
getLuck: { id: 'app.home.luck.get', defaultMessage: 'Get with one click' },
|
||||||
jump: { id: 'app.home.skin-site.jump', defaultMessage: 'Jump to StarLight skin site' },
|
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' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const SKIN_PROFILE_URL = 'https://skin.starlight.cool/profile'
|
const luck = ref<number | null>(null)
|
||||||
const SKIN_HOME_URL = 'https://skin.starlight.cool/'
|
const loading = ref(false)
|
||||||
|
const failed = ref(false)
|
||||||
|
const waitingForLogin = ref(false)
|
||||||
|
const signedIn = computed(() => Boolean(skinSiteUser.value))
|
||||||
|
|
||||||
async function openProfile() {
|
async function fetchLuck() {
|
||||||
|
if (loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
failed.value = false
|
||||||
try {
|
try {
|
||||||
await openUrl(SKIN_PROFILE_URL)
|
luck.value = await requestSkinSiteLuck()
|
||||||
} catch (error) {
|
} catch {
|
||||||
handleError(error)
|
failed.value = true
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSkinSite() {
|
function handlePrimaryAction() {
|
||||||
try {
|
if (signedIn.value) {
|
||||||
await openUrl(SKIN_HOME_URL)
|
void fetchLuck()
|
||||||
} catch (error) {
|
return
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -37,32 +72,72 @@ async function openSkinSite() {
|
|||||||
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
|
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">
|
<div class="flex items-center gap-2">
|
||||||
<ExternalIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
<LuckyCloverIcon class="lucky-clover size-5 shrink-0" aria-hidden="true" />
|
||||||
<h2 class="m-0 truncate text-lg">
|
<h2 class="m-0 truncate text-lg">
|
||||||
{{ formatMessage(messages.widgetTitle) }}
|
{{ formatMessage(messages.widgetTitle) }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-3">
|
||||||
<button
|
<div v-if="loading" class="luck-feedback" role="status" aria-live="polite">
|
||||||
type="button"
|
<LuckyCloverIcon
|
||||||
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"
|
class="lucky-clover lucky-clover-loading size-7 shrink-0"
|
||||||
@click="openProfile"
|
aria-hidden="true"
|
||||||
>
|
/>
|
||||||
<span class="text-sm font-semibold text-contrast">
|
<span>{{ formatMessage(messages.loading) }}</span>
|
||||||
{{ formatMessage(messages.luckyIndex) }}
|
</div>
|
||||||
</span>
|
<div v-else-if="luck !== null" class="luck-feedback" aria-live="polite">
|
||||||
<ExternalIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
<strong class="text-3xl leading-none text-contrast">{{ luck }}</strong>
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
<button
|
<ButtonStyled color="brand" class="w-full">
|
||||||
type="button"
|
<button type="button" class="w-full" :disabled="loading" @click="handlePrimaryAction">
|
||||||
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"
|
{{ formatMessage(signedIn ? messages.getLuck : messages.loginToGet) }}
|
||||||
@click="openSkinSite"
|
</button>
|
||||||
>
|
</ButtonStyled>
|
||||||
<ExternalIcon class="size-4 shrink-0" aria-hidden="true" />
|
|
||||||
<span>{{ formatMessage(messages.jump) }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@ -25,7 +25,6 @@ import HomeGreeting from '@/components/home/HomeGreeting.vue'
|
|||||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||||
import { kill, run } from '@/helpers/instance'
|
import { kill, run } from '@/helpers/instance'
|
||||||
@ -126,11 +125,6 @@ async function playInstance() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await run(instance.id)
|
await run(instance.id)
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomeMinimal',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: instance.id,
|
instance_id: instance.id,
|
||||||
@ -149,11 +143,6 @@ async function stopInstance() {
|
|||||||
|
|
||||||
await kill(instance.id).catch(handleError)
|
await kill(instance.id).catch(handleError)
|
||||||
running.value = false
|
running.value = false
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomeMinimal',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installInstance() {
|
async function installInstance() {
|
||||||
|
|||||||
@ -22,7 +22,6 @@ import { computed, ref } from 'vue'
|
|||||||
import type { HomeWidgetSize } from '@/components/home/home-dashboard'
|
import type { HomeWidgetSize } from '@/components/home/home-dashboard'
|
||||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { kill } from '@/helpers/instance'
|
import { kill } from '@/helpers/instance'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
import {
|
import {
|
||||||
@ -107,11 +106,6 @@ async function joinServer(world: ServerWorld & WorldWithInstance, instance: Game
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await start_join_server(world.instance_id, world.address)
|
await start_join_server(world.instance_id, world.address)
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomePinnedServer',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: instance.id,
|
instance_id: instance.id,
|
||||||
@ -125,11 +119,6 @@ async function joinServer(world: ServerWorld & WorldWithInstance, instance: Game
|
|||||||
|
|
||||||
async function stopInstance(instance: GameInstance) {
|
async function stopInstance(instance: GameInstance) {
|
||||||
await kill(instance.id).catch(handleError)
|
await kill(instance.id).catch(handleError)
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomePinnedServer',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unpinServer(world: ServerWorld & WorldWithInstance) {
|
async function unpinServer(world: ServerWorld & WorldWithInstance) {
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import { getHomeWidgetCardDensity, type HomeWidgetSize } from '@/components/home
|
|||||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { kill, run } from '@/helpers/instance'
|
import { kill, run } from '@/helpers/instance'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
import {
|
import {
|
||||||
@ -73,11 +72,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
|||||||
try {
|
try {
|
||||||
await start_join_singleplayer_world(world.instance_id, world.path)
|
await start_join_singleplayer_world(world.instance_id, world.path)
|
||||||
playingWorldKey.value = key
|
playingWorldKey.value = key
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomePinnedWorld',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: instance.id,
|
instance_id: instance.id,
|
||||||
@ -92,11 +86,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
|||||||
async function playInstance(instance: GameInstance) {
|
async function playInstance(instance: GameInstance) {
|
||||||
try {
|
try {
|
||||||
await run(instance.id)
|
await run(instance.id)
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomePinnedWorld',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: instance.id,
|
instance_id: instance.id,
|
||||||
@ -109,11 +98,6 @@ async function playInstance(instance: GameInstance) {
|
|||||||
async function stopInstance(instance: GameInstance) {
|
async function stopInstance(instance: GameInstance) {
|
||||||
await kill(instance.id).catch(handleError)
|
await kill(instance.id).catch(handleError)
|
||||||
playingWorldKey.value = null
|
playingWorldKey.value = null
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomePinnedWorld',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtim
|
|||||||
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
|
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
|
||||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { kill, run } from '@/helpers/instance'
|
import { kill, run } from '@/helpers/instance'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
import {
|
import {
|
||||||
@ -117,11 +116,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
|||||||
await start_join_singleplayer_world(world.instance_id, world.path)
|
await start_join_singleplayer_world(world.instance_id, world.path)
|
||||||
}
|
}
|
||||||
playingWorldKey.value = key
|
playingWorldKey.value = key
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomeRecentWorld',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: instance.id,
|
instance_id: instance.id,
|
||||||
@ -136,11 +130,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
|||||||
async function playInstance(instance: GameInstance) {
|
async function playInstance(instance: GameInstance) {
|
||||||
try {
|
try {
|
||||||
await run(instance.id)
|
await run(instance.id)
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomeRecentWorld',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: instance.id,
|
instance_id: instance.id,
|
||||||
@ -153,11 +142,6 @@ async function playInstance(instance: GameInstance) {
|
|||||||
async function stopInstance(instance: GameInstance) {
|
async function stopInstance(instance: GameInstance) {
|
||||||
await kill(instance.id).catch(handleError)
|
await kill(instance.id).catch(handleError)
|
||||||
playingWorldKey.value = null
|
playingWorldKey.value = null
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: instance.loader,
|
|
||||||
game_version: instance.game_version,
|
|
||||||
source: 'HomeRecentWorld',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,6 @@ import type { HomeWidgetPlacement, HomeWidgetSize } from '@/components/home/home
|
|||||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { kill, run } from '@/helpers/instance'
|
import { kill, run } from '@/helpers/instance'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
import {
|
import {
|
||||||
@ -205,11 +204,6 @@ async function playInstance(targetInstance: GameInstance) {
|
|||||||
starting.value = true
|
starting.value = true
|
||||||
try {
|
try {
|
||||||
await run(targetInstance.id)
|
await run(targetInstance.id)
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: targetInstance.loader,
|
|
||||||
game_version: targetInstance.game_version,
|
|
||||||
source: 'HomeInstanceWidget',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: targetInstance.id,
|
instance_id: targetInstance.id,
|
||||||
@ -230,11 +224,6 @@ async function playWorld() {
|
|||||||
} else {
|
} else {
|
||||||
await start_join_singleplayer_world(instance.value.id, world.value.path)
|
await start_join_singleplayer_world(instance.value.id, world.value.path)
|
||||||
}
|
}
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
source: 'HomeShortcutWidget',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const handled = await handleMinecraftLaunchError(error, {
|
const handled = await handleMinecraftLaunchError(error, {
|
||||||
instance_id: instance.value.id,
|
instance_id: instance.value.id,
|
||||||
|
|||||||
161
apps/app-frontend/src/components/instance/HostedGameDirModal.vue
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
<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>
|
||||||
208
apps/app-frontend/src/components/instance/HostedModpacks.vue
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ProgressBar, defineMessages, 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>
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,249 @@
|
|||||||
|
<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 {
|
||||||
|
PlayerSelectionNavigatedAwayError,
|
||||||
|
registerInstancePlayerPicker,
|
||||||
|
saveInstancePlayer,
|
||||||
|
waitForSkinSiteSession,
|
||||||
|
type InstancePlayer,
|
||||||
|
type PlayerChoice,
|
||||||
|
} 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>
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ButtonStyled, useVIntl } from '@modrinth/ui'
|
||||||
|
import { onUnmounted, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
chooseInstancePlayer,
|
||||||
|
getInstancePlayer,
|
||||||
|
onInstancePlayerChanged,
|
||||||
|
type InstancePlayer,
|
||||||
|
} 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>
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,150 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NewModal, ProgressBar, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||||
|
import { onMounted, onUnmounted, nextTick, ref, shallowRef } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { loading_listener } from '@/helpers/events'
|
||||||
|
import { progress_bars_list } from '@/helpers/state'
|
||||||
|
import { onHostedPackAttemptStarted } from '@/helpers/hosted-packs'
|
||||||
|
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>
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
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' },
|
||||||
|
})
|
||||||
@ -1,318 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<canvas id="about_scene" class="size-full" />
|
<canvas ref="canvas" class="about-scene" aria-hidden="true" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import * as THREE from 'three'
|
import { onActivated, onDeactivated, onMounted, onScopeDispose, useTemplateRef, watch } from 'vue'
|
||||||
import { onMounted, onScopeDispose, useTemplateRef } from 'vue'
|
|
||||||
|
|
||||||
import { useTheming } from '@/store/theme'
|
import { createWitherVictoryScene } from './about-scene/wither-victory'
|
||||||
|
|
||||||
const themeStore = useTheming()
|
const props = defineProps<{ paused?: boolean }>()
|
||||||
function isDarkMode() {
|
const canvas = useTemplateRef<HTMLCanvasElement>('canvas')
|
||||||
if (themeStore.selectedTheme == 'system') {
|
let scene: ReturnType<typeof createWitherVictoryScene> | undefined
|
||||||
return matchMedia('(prefers-color-scheme: dark)').matches
|
let active = true
|
||||||
}
|
|
||||||
return ['dark', 'oled'].includes(themeStore.selectedTheme)
|
function updatePlayback() {
|
||||||
|
scene?.setPaused(!active || Boolean(props.paused))
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTip(position: THREE.Vector3, color: THREE.ColorRepresentation = 0x00ff00) {
|
onMounted(() => {
|
||||||
const tipGeometry = new THREE.SphereGeometry(2)
|
if (!canvas.value) return
|
||||||
const tipMaterial = new THREE.MeshBasicMaterial({ color })
|
scene = createWitherVictoryScene(canvas.value)
|
||||||
const tipMesh = new THREE.Mesh(tipGeometry, tipMaterial)
|
updatePlayback()
|
||||||
tipMesh.position.copy(position)
|
scene.ready.catch((error: unknown) => console.warn('Unable to load about-page scene', error))
|
||||||
return tipMesh
|
})
|
||||||
}
|
watch(() => props.paused, updatePlayback)
|
||||||
|
onActivated(() => {
|
||||||
function createWaterMaterial(): THREE.ShaderMaterial {
|
active = true
|
||||||
return new THREE.ShaderMaterial({
|
updatePlayback()
|
||||||
uniforms: {
|
})
|
||||||
time: { value: 0 },
|
onDeactivated(() => {
|
||||||
seed: { value: Math.random() * 83 + 17 },
|
active = false
|
||||||
color: { value: new THREE.Color(0.3, 0.3, 1.0) },
|
updatePlayback()
|
||||||
},
|
})
|
||||||
transparent: true,
|
onScopeDispose(() => scene?.dispose())
|
||||||
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>
|
</script>
|
||||||
<style>
|
|
||||||
#about_scene {
|
<style scoped>
|
||||||
background: linear-gradient(
|
.about-scene {
|
||||||
to bottom,
|
display: block;
|
||||||
color-mix(in srgb, var(--color-brand) 36%, var(--surface-1) 100%),
|
width: 100%;
|
||||||
#00000000 40%
|
height: 100%;
|
||||||
);
|
background: #0c1729;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,10 +1,106 @@
|
|||||||
<template>
|
<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
|
<div
|
||||||
v-if="offline"
|
v-if="offline"
|
||||||
class="flex flex-col gap-1 bg-highlight-orange border border-solid border-orange rounded-xl p-3 mt-2"
|
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="font-semibold text-contrast">{{ formatMessage(messages.offlineMode) }}</span>
|
||||||
<span class="text-sm text-secondary">{{ formatMessage(messages.offlineModeDescription) }}</span>
|
<span class="text-sm text-secondary">
|
||||||
|
{{
|
||||||
|
formatMessage(
|
||||||
|
browserOffline
|
||||||
|
? messages.offlineModeNoInternetDescription
|
||||||
|
: messages.offlineModeServerUnavailableDescription,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
<ButtonStyled>
|
<ButtonStyled>
|
||||||
<button class="mt-1" :disabled="refreshingNetwork" @click="refreshNetworkStatus()">
|
<button class="mt-1" :disabled="refreshingNetwork" @click="refreshNetworkStatus()">
|
||||||
<SpinnerIcon v-if="refreshingNetwork" class="animate-spin" />
|
<SpinnerIcon v-if="refreshingNetwork" class="animate-spin" />
|
||||||
@ -17,18 +113,18 @@
|
|||||||
v-if="accounts.length === 0"
|
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"
|
class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2"
|
||||||
>
|
>
|
||||||
<span>{{ formatMessage(messages.notSignedIn) }}</span>
|
<span v-if="skinSiteStatus === 'signed-out'">{{ formatMessage(messages.notSignedIn) }}</span>
|
||||||
<ButtonStyled v-if="!offline" color="brand">
|
<ButtonStyled v-if="!offline && !skinSiteUser" color="brand">
|
||||||
<button color="primary" :disabled="loginDisabled" @click="login()">
|
<button color="primary" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
|
||||||
<LogInIcon v-if="!loginDisabled" />
|
<LogInIcon v-if="!loginDisabled" />
|
||||||
<SpinnerIcon v-else class="animate-spin" />
|
<SpinnerIcon v-else class="animate-spin" />
|
||||||
{{ formatMessage(messages.signInToMinecraft) }}
|
{{ formatMessage(messages.signInToStarlight) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled v-if="!offline">
|
<ButtonStyled v-if="!offline">
|
||||||
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
<button :disabled="loginDisabled" @click="login()">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
{{ formatMessage(messages.addThirdPartyAccount) }}
|
{{ formatMessage(messages.addMicrosoftAccount) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
@ -142,109 +238,10 @@
|
|||||||
{{ formatMessage(messages.addMicrosoftAccount) }}
|
{{ formatMessage(messages.addMicrosoftAccount) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
|
|
||||||
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
|
||||||
<PlusIcon />
|
|
||||||
{{ formatMessage(messages.addThirdPartyAccount) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<MinecraftLoginModal ref="minecraftLoginModal" @complete="onMicrosoftLogin" />
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@ -262,49 +259,59 @@ import {
|
|||||||
Accordion,
|
Accordion,
|
||||||
Avatar,
|
Avatar,
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
commonMessages,
|
|
||||||
defineMessages,
|
defineMessages,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
StyledInput,
|
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { useQueryClient } from '@tanstack/vue-query'
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
import { listen } from '@tauri-apps/api/event'
|
import { listen } from '@tauri-apps/api/event'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import axolotlLogo from '@/assets/netherstar.png'
|
import axolotlLogo from '@/assets/netherstar.png'
|
||||||
import steveSkinTexture from '@/assets/skins/steve.png?inline'
|
|
||||||
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
||||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
import {
|
||||||
|
openSkinSiteLogin,
|
||||||
|
requestSkinSitePlayers,
|
||||||
|
selectedSkinSitePlayerId,
|
||||||
|
selectSkinSitePlayer,
|
||||||
|
skinSitePlayers,
|
||||||
|
skinSitePlayersStatus,
|
||||||
|
skinSiteStatus,
|
||||||
|
skinSiteUser,
|
||||||
|
} from '@/composables/skin-site-session'
|
||||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||||
import { compareMinecraftAccounts } from '@/helpers/accounts'
|
import { compareMinecraftAccounts } from '@/helpers/accounts'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import {
|
import {
|
||||||
begin_yggdrasil_login,
|
|
||||||
delete_yggdrasil_password,
|
|
||||||
finish_yggdrasil_login,
|
|
||||||
get_default_user,
|
get_default_user,
|
||||||
get_yggdrasil_password,
|
|
||||||
list_yggdrasil_saved_logins,
|
|
||||||
login as loginToMinecraft,
|
login as loginToMinecraft,
|
||||||
remove_user,
|
remove_user,
|
||||||
set_default_user,
|
set_default_user,
|
||||||
set_yggdrasil_password,
|
|
||||||
users,
|
users,
|
||||||
} from '@/helpers/auth'
|
} from '@/helpers/auth'
|
||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
|
import { registerSkinSitePlayers } from '@/helpers/instance-player'
|
||||||
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||||
import type { Skin } from '@/helpers/skins'
|
import type { Skin } from '@/helpers/skins'
|
||||||
import { get_available_skins } from '@/helpers/skins'
|
import { get_available_skins } from '@/helpers/skins'
|
||||||
import { handleSevereError } from '@/store/error.js'
|
import { handleSevereError } from '@/store/error.js'
|
||||||
|
import { useTheming } from '@/store/state'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
const { offline, refreshBrowserOffline } = useNetworkStatus()
|
const { browserOffline, offline, refreshBrowserOffline } = useNetworkStatus()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const route = useRoute()
|
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)
|
const refreshingNetwork = ref(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -348,20 +355,6 @@ type MinecraftCredential = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 STARLIGHT_YGGDRASIL_API_ROOT = 'https://skin.starlight.cool/yggdrasil'
|
||||||
|
|
||||||
const accounts: Ref<MinecraftCredential[]> = ref([])
|
const accounts: Ref<MinecraftCredential[]> = ref([])
|
||||||
@ -376,34 +369,13 @@ let refreshGeneration = 0
|
|||||||
let headRefreshTimer: ReturnType<typeof setTimeout> | undefined
|
let headRefreshTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
let defaultUserUpdateQueue = Promise.resolve()
|
let defaultUserUpdateQueue = Promise.resolve()
|
||||||
const minecraftLoginModal = ref<InstanceType<typeof MinecraftLoginModal> | null>(null)
|
const minecraftLoginModal = ref<InstanceType<typeof MinecraftLoginModal> | null>(null)
|
||||||
const yggdrasilAccountModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
|
async function refreshSkinSitePlayers() {
|
||||||
const yggdrasilProfileModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
|
await requestSkinSitePlayers().catch(() => {})
|
||||||
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('&', '&')
|
|
||||||
.replaceAll('"', '"')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
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)}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSteveHeadUrl = createSkinHeadDataUrl(steveSkinTexture)
|
function setSkinSitePlayer(playerId: string) {
|
||||||
|
selectSkinSitePlayer(playerId)
|
||||||
|
}
|
||||||
|
|
||||||
const HEAD_REFRESH_RETRY_DELAYS = [1500, 5000, 15000, 30000] as const
|
const HEAD_REFRESH_RETRY_DELAYS = [1500, 5000, 15000, 30000] as const
|
||||||
const HEAD_REFRESH_CONTINUOUS_DELAY = 60_000
|
const HEAD_REFRESH_CONTINUOUS_DELAY = 60_000
|
||||||
@ -648,6 +620,7 @@ function persistDefaultUser(userId: string) {
|
|||||||
async function setAccount(account: MinecraftCredential) {
|
async function setAccount(account: MinecraftCredential) {
|
||||||
const userId = account.profile.id
|
const userId = account.profile.id
|
||||||
refreshGeneration += 1
|
refreshGeneration += 1
|
||||||
|
selectSkinSitePlayer(null)
|
||||||
defaultUser.value = userId
|
defaultUser.value = userId
|
||||||
equippedSkin.value = null
|
equippedSkin.value = null
|
||||||
|
|
||||||
@ -657,6 +630,27 @@ async function setAccount(account: MinecraftCredential) {
|
|||||||
if (defaultUser.value === userId) notifyAccountChange()
|
if (defaultUser.value === userId) notifyAccountChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[skinSitePlayers, defaultUser, skinSiteUser],
|
||||||
|
([availablePlayers, selectedLocalUser, siteUser]) => {
|
||||||
|
if (!selectedLocalUser && !selectedSkinSitePlayerId.value && availablePlayers.length > 0) {
|
||||||
|
selectSkinSitePlayer(availablePlayers[0].uuid)
|
||||||
|
}
|
||||||
|
// Register skin-site players as launcher accounts as soon as they are
|
||||||
|
// available, so the account picker shows them without requiring a first
|
||||||
|
// launch. `registerSkinSitePlayers` is idempotent and best-effort.
|
||||||
|
if (siteUser?.uuid && availablePlayers.length > 0) {
|
||||||
|
const pendingIds = availablePlayers.map((player) => player.uuid)
|
||||||
|
void registerSkinSitePlayers(pendingIds, siteUser.uuid)
|
||||||
|
.then(() => refreshValues())
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('Failed to register skin site players:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
if (offline.value) return
|
if (offline.value) return
|
||||||
loginDisabled.value = true
|
loginDisabled.value = true
|
||||||
@ -678,7 +672,6 @@ async function onMicrosoftLogin(account: MinecraftCredential) {
|
|||||||
loginDisabled.value = true
|
loginDisabled.value = true
|
||||||
try {
|
try {
|
||||||
await setAccount(account)
|
await setAccount(account)
|
||||||
trackEvent('AccountLogIn')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleSevereError(error)
|
handleSevereError(error)
|
||||||
} finally {
|
} finally {
|
||||||
@ -686,187 +679,6 @@ 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)
|
|
||||||
trackEvent('YggdrasilAccountAdd')
|
|
||||||
} 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)
|
|
||||||
trackEvent('YggdrasilAccountAdd')
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error as Error)
|
|
||||||
} finally {
|
|
||||||
loginDisabled.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout(account: MinecraftCredential) {
|
async function logout(account: MinecraftCredential) {
|
||||||
await remove_user(account.profile.id).catch(handleError)
|
await remove_user(account.profile.id).catch(handleError)
|
||||||
await refreshValues()
|
await refreshValues()
|
||||||
@ -875,7 +687,6 @@ async function logout(account: MinecraftCredential) {
|
|||||||
} else {
|
} else {
|
||||||
notifyAccountChange()
|
notifyAccountChange()
|
||||||
}
|
}
|
||||||
trackEvent('AccountLogOut')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyAccountUuid(account: MinecraftCredential) {
|
async function copyAccountUuid(account: MinecraftCredential) {
|
||||||
@ -902,14 +713,47 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const messages = defineMessages({
|
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: {
|
offlineMode: {
|
||||||
id: 'minecraft-account.offline-mode',
|
id: 'minecraft-account.offline-mode',
|
||||||
defaultMessage: 'Offline mode',
|
defaultMessage: 'Offline mode',
|
||||||
},
|
},
|
||||||
offlineModeDescription: {
|
offlineModeNoInternetDescription: {
|
||||||
id: 'minecraft-account.offline-mode.description',
|
id: 'minecraft-account.offline-mode.description.no-internet',
|
||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Only offline accounts are available. You can launch fully downloaded instances.',
|
'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.",
|
||||||
},
|
},
|
||||||
refreshNetworkStatus: {
|
refreshNetworkStatus: {
|
||||||
id: 'minecraft-account.offline-mode.refresh',
|
id: 'minecraft-account.offline-mode.refresh',
|
||||||
@ -921,11 +765,11 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
addMicrosoftAccount: {
|
addMicrosoftAccount: {
|
||||||
id: 'minecraft-account.add-microsoft-account',
|
id: 'minecraft-account.add-microsoft-account',
|
||||||
defaultMessage: 'Add Microsoft account',
|
defaultMessage: 'Add your own Minecraft account',
|
||||||
},
|
},
|
||||||
addThirdPartyAccount: {
|
signInToStarlight: {
|
||||||
id: 'minecraft-account.add-third-party-account',
|
id: 'minecraft-account.sign-in-starlight',
|
||||||
defaultMessage: 'Add third-party account',
|
defaultMessage: 'Sign in to StarLight Skin Site',
|
||||||
},
|
},
|
||||||
thirdPartyAccount: {
|
thirdPartyAccount: {
|
||||||
id: 'minecraft-account.third-party-account',
|
id: 'minecraft-account.third-party-account',
|
||||||
@ -935,58 +779,6 @@ const messages = defineMessages({
|
|||||||
id: 'minecraft-account.third-party-badge',
|
id: 'minecraft-account.third-party-badge',
|
||||||
defaultMessage: 'StarLight skin',
|
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: {
|
offlineAccount: {
|
||||||
id: 'minecraft-account.offline-account',
|
id: 'minecraft-account.offline-account',
|
||||||
defaultMessage: 'Offline Minecraft account',
|
defaultMessage: 'Offline Minecraft account',
|
||||||
@ -1069,10 +861,6 @@ const messages = defineMessages({
|
|||||||
id: 'minecraft-account.label',
|
id: 'minecraft-account.label',
|
||||||
defaultMessage: 'Minecraft account',
|
defaultMessage: 'Minecraft account',
|
||||||
},
|
},
|
||||||
signInToMinecraft: {
|
|
||||||
id: 'minecraft-account.sign-in',
|
|
||||||
defaultMessage: 'Sign in to Minecraft',
|
|
||||||
},
|
|
||||||
loginTrouble: {
|
loginTrouble: {
|
||||||
id: 'minecraft-login.trouble',
|
id: 'minecraft-login.trouble',
|
||||||
defaultMessage: 'Having trouble?',
|
defaultMessage: 'Having trouble?',
|
||||||
|
|||||||
@ -212,7 +212,6 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
|
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
|
||||||
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
|
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
|
||||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { loading_listener, process_listener } from '@/helpers/events'
|
import { loading_listener, process_listener } from '@/helpers/events'
|
||||||
import { get_many as getInstances } from '@/helpers/instance'
|
import { get_many as getInstances } from '@/helpers/instance'
|
||||||
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
|
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
|
||||||
@ -313,6 +312,7 @@ interface RunningProcess {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface LoadingEventPayload {
|
interface LoadingEventPayload {
|
||||||
|
total?: number | null
|
||||||
event: LoadingBar['bar_type']
|
event: LoadingBar['bar_type']
|
||||||
loader_uuid: string
|
loader_uuid: string
|
||||||
fraction: number | null
|
fraction: number | null
|
||||||
@ -436,12 +436,6 @@ const unlistenProcess = await process_listener(async () => {
|
|||||||
const stop = async (process: RunningProcess) => {
|
const stop = async (process: RunningProcess) => {
|
||||||
try {
|
try {
|
||||||
await killProcess(process.uuid).catch(handleError)
|
await killProcess(process.uuid).catch(handleError)
|
||||||
|
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: process.instance.loader,
|
|
||||||
game_version: process.instance.game_version,
|
|
||||||
source: 'AppBar',
|
|
||||||
})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
@ -635,6 +629,7 @@ function isVisibleLoadingBar(loadingBar: LoadingBar): boolean {
|
|||||||
return (
|
return (
|
||||||
loadingBar.bar_type?.type !== 'launcher_update' &&
|
loadingBar.bar_type?.type !== 'launcher_update' &&
|
||||||
[
|
[
|
||||||
|
'hosted_pack_sync',
|
||||||
'java_download',
|
'java_download',
|
||||||
'pack_file_download',
|
'pack_file_download',
|
||||||
'pack_download',
|
'pack_download',
|
||||||
@ -661,8 +656,8 @@ function applyLoadingEvent(payload: LoadingEventPayload): boolean {
|
|||||||
const loadingBar = formatLoadingBars({
|
const loadingBar = formatLoadingBars({
|
||||||
loading_bar_uuid: payload.loader_uuid,
|
loading_bar_uuid: payload.loader_uuid,
|
||||||
message: payload.message,
|
message: payload.message,
|
||||||
current: payload.fraction,
|
current: payload.fraction * (payload.total ?? 1),
|
||||||
total: 1,
|
total: payload.total ?? 1,
|
||||||
bar_type: payload.event,
|
bar_type: payload.event,
|
||||||
})
|
})
|
||||||
if (!isVisibleLoadingBar(loadingBar)) return false
|
if (!isVisibleLoadingBar(loadingBar)) return false
|
||||||
|
|||||||
@ -23,7 +23,6 @@ import { computed, ref } from 'vue'
|
|||||||
import { ChatIcon } from '@/assets/icons'
|
import { ChatIcon } from '@/assets/icons'
|
||||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
import { AxolotlBrandConfig } from '@/config'
|
import { AxolotlBrandConfig } from '@/config'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||||
import { install_existing_instance } from '@/helpers/install'
|
import { install_existing_instance } from '@/helpers/install'
|
||||||
import { cancel_directory_change } from '@/helpers/settings.ts'
|
import { cancel_directory_change } from '@/helpers/settings.ts'
|
||||||
@ -241,7 +240,6 @@ async function loginMinecraft() {
|
|||||||
await set_default_user(loggedIn.profile.id).catch(handleError)
|
await set_default_user(loggedIn.profile.id).catch(handleError)
|
||||||
}
|
}
|
||||||
|
|
||||||
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
|
|
||||||
loadingMinecraft.value = false
|
loadingMinecraft.value = false
|
||||||
errorModal.value.hide()
|
errorModal.value.hide()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -23,7 +23,6 @@ import { useRouter } from 'vue-router'
|
|||||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||||
import { kill, run } from '@/helpers/instance'
|
import { kill, run } from '@/helpers/instance'
|
||||||
@ -121,11 +120,6 @@ const play = async (e, context) => {
|
|||||||
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
|
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: props.instance.loader,
|
|
||||||
game_version: props.instance.game_version,
|
|
||||||
source: context,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@ -136,11 +130,6 @@ const stop = async (e, context) => {
|
|||||||
|
|
||||||
await kill(props.instance.id).catch(handleError)
|
await kill(props.instance.id).catch(handleError)
|
||||||
|
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: props.instance.loader,
|
|
||||||
game_version: props.instance.game_version,
|
|
||||||
source: context,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const repair = async (e) => {
|
const repair = async (e) => {
|
||||||
|
|||||||
@ -59,7 +59,6 @@ import {
|
|||||||
import { onUnmounted, ref } from 'vue'
|
import { onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { java_discovery_listener } from '@/helpers/events'
|
import { java_discovery_listener } from '@/helpers/events'
|
||||||
import { find_filtered_jres } from '@/helpers/jre.js'
|
import { find_filtered_jres } from '@/helpers/jre.js'
|
||||||
|
|
||||||
@ -131,9 +130,5 @@ const emit = defineEmits(['submit'])
|
|||||||
function setJavaInstall(javaInstall) {
|
function setJavaInstall(javaInstall) {
|
||||||
emit('submit', javaInstall)
|
emit('submit', javaInstall)
|
||||||
detectJavaModal.value.hide()
|
detectJavaModal.value.hide()
|
||||||
trackEvent('JavaAutoDetect', {
|
|
||||||
path: javaInstall.path,
|
|
||||||
version: javaInstall.version,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -112,7 +112,6 @@ import { computed, ref, watch } from 'vue'
|
|||||||
|
|
||||||
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
|
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
|
||||||
import useJavaTest from '@/composables/useJavaTest'
|
import useJavaTest from '@/composables/useJavaTest'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
|
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
|
||||||
|
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
@ -196,9 +195,9 @@ const recommendedInstalled = computed(() => {
|
|||||||
let hasInitialized = false
|
let hasInitialized = false
|
||||||
|
|
||||||
async function runTest(path) {
|
async function runTest(path) {
|
||||||
await testJavaInstallation(path, testVersion.value, true)
|
await testJavaInstallation(path, testVersion.value)
|
||||||
if (props.version != null) {
|
if (props.version != null) {
|
||||||
await recommendedJavaTest.testJavaInstallation(path, props.version, false)
|
await recommendedJavaTest.testJavaInstallation(path, props.version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,9 +211,9 @@ watch(
|
|||||||
(newPath) => {
|
(newPath) => {
|
||||||
if (newPath) {
|
if (newPath) {
|
||||||
if (!hasInitialized) {
|
if (!hasInitialized) {
|
||||||
testJavaInstallation(newPath, testVersion.value, false)
|
testJavaInstallation(newPath, testVersion.value)
|
||||||
if (props.version != null) {
|
if (props.version != null) {
|
||||||
recommendedJavaTest.testJavaInstallation(newPath, props.version, false)
|
recommendedJavaTest.testJavaInstallation(newPath, props.version)
|
||||||
}
|
}
|
||||||
hasInitialized = true
|
hasInitialized = true
|
||||||
} else {
|
} else {
|
||||||
@ -242,9 +241,6 @@ async function handleJavaFileInput() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
trackEvent('JavaManualSelect', {
|
|
||||||
version: props.version,
|
|
||||||
})
|
|
||||||
|
|
||||||
commitSelection(result)
|
commitSelection(result)
|
||||||
}
|
}
|
||||||
@ -279,7 +275,6 @@ async function reinstallJava() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
trackEvent('JavaReInstall', { path: path, version: props.version })
|
|
||||||
commitSelection(result)
|
commitSelection(result)
|
||||||
runTest(result.path)
|
runTest(result.path)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
22
apps/app-frontend/src/components/ui/SkinSiteFrame.vue
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<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"
|
||||||
|
:title="formatMessage(messages.frameTitle)"
|
||||||
|
class="block h-full min-h-0 w-full border-0"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
65
apps/app-frontend/src/components/ui/SkinSiteSessionFrame.vue
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { clearHostedSession } from '@/helpers/hosted-packs'
|
||||||
|
|
||||||
|
import {
|
||||||
|
receiveSkinSiteMessage,
|
||||||
|
resetSkinSiteSession,
|
||||||
|
setSkinSiteFrame,
|
||||||
|
SKIN_SITE_ORIGIN,
|
||||||
|
skinSiteStatus,
|
||||||
|
skinSiteUser,
|
||||||
|
} from '@/composables/skin-site-session'
|
||||||
|
|
||||||
|
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>
|
||||||
274
apps/app-frontend/src/components/ui/about-scene/wither-shield.ts
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
]
|
||||||
@ -0,0 +1,953 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,366 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NewButton as Button, useVIntl } from '@modrinth/ui'
|
||||||
|
import { computed, nextTick, onMounted, onScopeDispose, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { Puzzle } from './engine'
|
||||||
|
import { messages } from './messages'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
puzzle: Puzzle
|
||||||
|
revealed: boolean[]
|
||||||
|
selected: number
|
||||||
|
playing: boolean
|
||||||
|
lost: boolean
|
||||||
|
mistake: number
|
||||||
|
zoom: number
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
paint: [index: number]
|
||||||
|
'update:zoom': [value: number]
|
||||||
|
}>()
|
||||||
|
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) =>
|
||||||
|
props.revealed[i] || props.lost ? [{ color, i }] : [],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const letter = (color: number) => String.fromCharCode(65 + color)
|
||||||
|
|
||||||
|
function updateView() {
|
||||||
|
const el = viewport.value
|
||||||
|
if (!el) return
|
||||||
|
const size = props.puzzle.size
|
||||||
|
view.value = {
|
||||||
|
left: (el.scrollLeft / el.scrollWidth) * size,
|
||||||
|
top: (el.scrollTop / el.scrollHeight) * size,
|
||||||
|
width: Math.min(size, (el.clientWidth / el.scrollWidth) * size),
|
||||||
|
height: Math.min(size, (el.clientHeight / el.scrollHeight) * size),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setView(left: number, top: number) {
|
||||||
|
await nextTick()
|
||||||
|
viewport.value?.scrollTo(left, top)
|
||||||
|
updateView()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function centerFirstClue() {
|
||||||
|
await nextTick()
|
||||||
|
const el = viewport.value
|
||||||
|
if (!el || !large.value) return setView(0, 0)
|
||||||
|
const i = props.revealed.findIndex(Boolean)
|
||||||
|
await setView(
|
||||||
|
((i % props.puzzle.size) + 0.5) * (40 * props.zoom + 3) - el.clientWidth / 2,
|
||||||
|
(Math.floor(i / props.puzzle.size) + 0.5) * (40 * props.zoom + 3) - el.clientHeight / 2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(event: MouseEvent) {
|
||||||
|
const rect = (event.currentTarget as SVGElement).getBoundingClientRect()
|
||||||
|
const el = viewport.value
|
||||||
|
if (!el) return
|
||||||
|
void setView(
|
||||||
|
((event.clientX - rect.left) / rect.width) * el.scrollWidth - el.clientWidth / 2,
|
||||||
|
((event.clientY - rect.top) / rect.height) * el.scrollHeight - el.clientHeight / 2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeZoom(value: number) {
|
||||||
|
const el = viewport.value
|
||||||
|
if (!el) return
|
||||||
|
const zoom = Math.max(0.7, Math.min(1.6, Math.round(value * 10) / 10))
|
||||||
|
const ratio = (40 * zoom + 3) / (40 * props.zoom + 3)
|
||||||
|
const x = (el.scrollLeft + el.clientWidth / 2) * ratio - el.clientWidth / 2
|
||||||
|
const y = (el.scrollTop + el.clientHeight / 2) * ratio - el.clientHeight / 2
|
||||||
|
emit('update:zoom', zoom)
|
||||||
|
await setView(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(event: WheelEvent) {
|
||||||
|
if (!large.value || !event.ctrlKey) return
|
||||||
|
event.preventDefault()
|
||||||
|
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,
|
||||||
|
column: (index % props.puzzle.size) + 1,
|
||||||
|
}
|
||||||
|
return props.revealed[index] || props.lost
|
||||||
|
? formatMessage(messages.revealed, {
|
||||||
|
...values,
|
||||||
|
color: letter(props.puzzle.answer[index]),
|
||||||
|
number: props.puzzle.numbers[index],
|
||||||
|
})
|
||||||
|
: formatMessage(messages.hidden, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
let observer: ResizeObserver | undefined
|
||||||
|
onMounted(() => {
|
||||||
|
updateBrushCursor()
|
||||||
|
observer = new ResizeObserver(updateView)
|
||||||
|
if (viewport.value) observer.observe(viewport.value)
|
||||||
|
})
|
||||||
|
onScopeDispose(() => observer?.disconnect())
|
||||||
|
defineExpose({
|
||||||
|
centerFirstClue,
|
||||||
|
setView,
|
||||||
|
getView: () => ({
|
||||||
|
left: viewport.value?.scrollLeft ?? 0,
|
||||||
|
top: viewport.value?.scrollTop ?? 0,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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,
|
||||||
|
}"
|
||||||
|
:aria-label="formatMessage(messages.board, { size: puzzle.size })"
|
||||||
|
@pointerenter="updateBrushCursor"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="(_, i) in puzzle.answer"
|
||||||
|
:key="i"
|
||||||
|
type="button"
|
||||||
|
class="mine-cell"
|
||||||
|
:class="{
|
||||||
|
'mine-open': revealed[i] || lost,
|
||||||
|
'mine-answer': lost && !revealed[i],
|
||||||
|
'mine-mistake': mistake === i,
|
||||||
|
'mine-sampled': revealed[i] && selected === puzzle.answer[i],
|
||||||
|
}"
|
||||||
|
:style="
|
||||||
|
revealed[i] || lost
|
||||||
|
? { '--mine-cell-color': `var(--mine-color-${puzzle.answer[i]})` }
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:disabled="!playing"
|
||||||
|
:aria-label="label(i)"
|
||||||
|
@click="emit('paint', i)"
|
||||||
|
>
|
||||||
|
<template v-if="revealed[i] || lost">
|
||||||
|
<span>{{ puzzle.numbers[i] }}</span
|
||||||
|
><small>{{ letter(puzzle.answer[i]) }}</small>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="large" class="mine-overview">
|
||||||
|
<svg
|
||||||
|
class="mine-map"
|
||||||
|
:viewBox="`0 0 ${puzzle.size} ${puzzle.size}`"
|
||||||
|
role="img"
|
||||||
|
:aria-label="formatMessage(messages.map)"
|
||||||
|
@click="navigate"
|
||||||
|
>
|
||||||
|
<rect width="100%" height="100%" fill="var(--surface-1)" />
|
||||||
|
<rect
|
||||||
|
v-for="cell in visibleCells"
|
||||||
|
:key="cell.i"
|
||||||
|
:x="cell.i % puzzle.size"
|
||||||
|
:y="Math.floor(cell.i / puzzle.size)"
|
||||||
|
width="1"
|
||||||
|
height="1"
|
||||||
|
:fill="`var(--mine-color-${cell.color})`"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
:x="view.left"
|
||||||
|
:y="view.top"
|
||||||
|
:width="view.width"
|
||||||
|
:height="view.height"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-contrast)"
|
||||||
|
stroke-width="2"
|
||||||
|
vector-effect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="mine-zoom">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:disabled="zoom <= 0.7"
|
||||||
|
:aria-label="formatMessage(messages.zoomOut)"
|
||||||
|
@click="changeZoom(zoom - 0.1)"
|
||||||
|
>−</Button
|
||||||
|
>
|
||||||
|
<span>{{ Math.round(zoom * 100) }}%</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:disabled="zoom >= 1.6"
|
||||||
|
:aria-label="formatMessage(messages.zoomIn)"
|
||||||
|
@click="changeZoom(zoom + 0.1)"
|
||||||
|
>+</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mine-navigation {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--gap-md);
|
||||||
|
}
|
||||||
|
.mine-large {
|
||||||
|
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;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
.mine-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--mine-size), var(--mine-cell-size));
|
||||||
|
/* Scrolling and minimap coordinates use a fixed square pitch at every zoom level. */
|
||||||
|
grid-auto-rows: var(--mine-cell-size);
|
||||||
|
gap: 3px;
|
||||||
|
width: max-content;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
.mine-grid-small {
|
||||||
|
grid-template-columns: repeat(var(--mine-size), minmax(0, 1fr));
|
||||||
|
grid-auto-rows: auto;
|
||||||
|
width: min(100%, 26rem, 48vh);
|
||||||
|
margin: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.mine-cell {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--mine-ink) 38%, var(--surface-5));
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-4);
|
||||||
|
color: var(--color-contrast);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
.mine-cell:focus-visible {
|
||||||
|
outline: 2px solid var(--color-contrast);
|
||||||
|
outline-offset: -3px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.mine-open {
|
||||||
|
background: color-mix(in srgb, var(--mine-cell-color) 34%, var(--surface-2));
|
||||||
|
border-color: var(--mine-cell-color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mine-open:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--mine-cell-color) 50%, var(--surface-2));
|
||||||
|
}
|
||||||
|
.mine-sampled {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--mine-cell-color);
|
||||||
|
}
|
||||||
|
.mine-cell small {
|
||||||
|
position: absolute;
|
||||||
|
right: 3px;
|
||||||
|
bottom: 1px;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.mine-answer {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.mine-cell:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.mine-mistake {
|
||||||
|
opacity: 1;
|
||||||
|
outline: 3px solid var(--color-red);
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
.mine-overview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap-md);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.mine-map {
|
||||||
|
display: block;
|
||||||
|
width: 7rem;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: 1px solid var(--surface-5);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.mine-zoom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--gap-xs);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.mine-large {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.mine-overview {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.mine-map {
|
||||||
|
width: 4.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,489 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import './palette.css'
|
||||||
|
|
||||||
|
import { NewButton as Button, NewModal, useVIntl } from '@modrinth/ui'
|
||||||
|
import { computed, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
|
||||||
|
|
||||||
|
import ColorMineBoard from './ColorMineBoard.vue'
|
||||||
|
import { type Difficulty, LEVELS, type Puzzle } from './engine'
|
||||||
|
import { messages } from './messages'
|
||||||
|
import {
|
||||||
|
parseSave,
|
||||||
|
readBest,
|
||||||
|
recordBest,
|
||||||
|
SAVE_KEY,
|
||||||
|
type SavedGame,
|
||||||
|
saveGame,
|
||||||
|
validateResume,
|
||||||
|
} from './storage'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
const board = ref<InstanceType<typeof ColorMineBoard>>()
|
||||||
|
const prompt = ref<HTMLElement>()
|
||||||
|
const puzzle = shallowRef<Puzzle>()
|
||||||
|
const revealed = ref<boolean[]>([])
|
||||||
|
const selected = ref(-1)
|
||||||
|
const mistake = ref(-1)
|
||||||
|
const elapsed = ref(0)
|
||||||
|
const zoom = ref(1)
|
||||||
|
const best = ref<number | null>(null)
|
||||||
|
const difficulty = ref<Difficulty>('easy')
|
||||||
|
const levels = Object.keys(LEVELS) as Difficulty[]
|
||||||
|
const screen = ref<'loading' | 'board' | 'saved' | 'error'>('loading')
|
||||||
|
const result = ref<'playing' | 'lost' | 'won'>('playing')
|
||||||
|
const confirmation = ref<'leave' | 'replace' | null>(null)
|
||||||
|
const saved = shallowRef<SavedGame | null>(null)
|
||||||
|
const storageError = ref(false)
|
||||||
|
const recordError = ref(false)
|
||||||
|
const needsColor = ref(false)
|
||||||
|
let nextDifficulty: Difficulty = 'easy'
|
||||||
|
let worker: Worker | undefined
|
||||||
|
let active = false
|
||||||
|
let closing = false
|
||||||
|
let started = false
|
||||||
|
let heldView = { left: 0, top: 0 }
|
||||||
|
let lastTick = performance.now()
|
||||||
|
const count = computed(() => revealed.value.filter(Boolean).length)
|
||||||
|
const letter = (color: number) => String.fromCharCode(65 + color)
|
||||||
|
const timeText = (time: number) => {
|
||||||
|
const seconds = Math.floor(time / 1000)
|
||||||
|
return `${Math.floor(seconds / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
const status = computed(() => {
|
||||||
|
if (result.value === 'won') return formatMessage(messages.won)
|
||||||
|
if (result.value === 'lost' && puzzle.value)
|
||||||
|
return formatMessage(messages.lost, {
|
||||||
|
row: Math.floor(mistake.value / puzzle.value.size) + 1,
|
||||||
|
column: (mistake.value % puzzle.value.size) + 1,
|
||||||
|
selected: letter(selected.value),
|
||||||
|
correct: letter(puzzle.value.answer[mistake.value]),
|
||||||
|
})
|
||||||
|
return selected.value < 0 || needsColor.value
|
||||||
|
? formatMessage(messages.choose)
|
||||||
|
: formatMessage(messages.selected, { color: letter(selected.value) })
|
||||||
|
})
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const now = performance.now()
|
||||||
|
if (
|
||||||
|
active &&
|
||||||
|
started &&
|
||||||
|
screen.value === 'board' &&
|
||||||
|
result.value === 'playing' &&
|
||||||
|
!confirmation.value &&
|
||||||
|
!document.hidden
|
||||||
|
) {
|
||||||
|
elapsed.value += now - lastTick
|
||||||
|
}
|
||||||
|
lastTick = now
|
||||||
|
}
|
||||||
|
let timer: number | undefined
|
||||||
|
function visibilityChanged() {
|
||||||
|
lastTick = performance.now()
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', visibilityChanged)
|
||||||
|
|
||||||
|
async function show() {
|
||||||
|
if (active || closing) return
|
||||||
|
active = true
|
||||||
|
lastTick = performance.now()
|
||||||
|
timer = window.setInterval(tick, 250)
|
||||||
|
modal.value?.show()
|
||||||
|
loadSaved()
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSaved() {
|
||||||
|
storageError.value = false
|
||||||
|
saved.value = null
|
||||||
|
try {
|
||||||
|
saved.value = parseSave(localStorage.getItem(SAVE_KEY))
|
||||||
|
if (saved.value) {
|
||||||
|
difficulty.value = saved.value.difficulty
|
||||||
|
screen.value = 'saved'
|
||||||
|
} else start(difficulty.value)
|
||||||
|
} catch {
|
||||||
|
screen.value = 'saved'
|
||||||
|
storageError.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(level: Difficulty, resume?: SavedGame) {
|
||||||
|
worker?.terminate()
|
||||||
|
confirmation.value = null
|
||||||
|
storageError.value = false
|
||||||
|
recordError.value = false
|
||||||
|
screen.value = 'loading'
|
||||||
|
difficulty.value = level
|
||||||
|
started = false
|
||||||
|
try {
|
||||||
|
const pending = new Worker(new URL('./generator.worker.ts', import.meta.url), {
|
||||||
|
type: 'module',
|
||||||
|
})
|
||||||
|
worker = pending
|
||||||
|
pending.onerror = () => {
|
||||||
|
if (worker !== pending) return
|
||||||
|
pending.terminate()
|
||||||
|
worker = undefined
|
||||||
|
screen.value = 'error'
|
||||||
|
}
|
||||||
|
pending.onmessage = async (event: MessageEvent<{ puzzle?: Puzzle; error?: boolean }>) => {
|
||||||
|
if (worker !== pending || !active) return
|
||||||
|
pending.terminate()
|
||||||
|
worker = undefined
|
||||||
|
if (!event.data.puzzle) {
|
||||||
|
screen.value = 'error'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const generated = event.data.puzzle
|
||||||
|
if (resume) {
|
||||||
|
try {
|
||||||
|
validateResume(resume, generated)
|
||||||
|
// Consume the save before playing so losing cannot reload an older, safe state.
|
||||||
|
localStorage.removeItem(SAVE_KEY)
|
||||||
|
} catch {
|
||||||
|
screen.value = 'saved'
|
||||||
|
storageError.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saved.value = null
|
||||||
|
puzzle.value = generated
|
||||||
|
revealed.value = resume?.revealed.slice() ?? generated.clues.slice()
|
||||||
|
selected.value = resume?.selected ?? -1
|
||||||
|
elapsed.value = resume?.elapsed ?? 0
|
||||||
|
started = elapsed.value > 0
|
||||||
|
lastTick = performance.now()
|
||||||
|
zoom.value = resume?.zoom ?? 1
|
||||||
|
mistake.value = -1
|
||||||
|
needsColor.value = false
|
||||||
|
result.value = 'playing'
|
||||||
|
screen.value = 'board'
|
||||||
|
try {
|
||||||
|
best.value = readBest(localStorage, level)
|
||||||
|
} catch {
|
||||||
|
best.value = null
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
if (resume) await board.value?.setView(resume.left, resume.top)
|
||||||
|
else await board.value?.centerFirstClue()
|
||||||
|
}
|
||||||
|
pending.postMessage({
|
||||||
|
seed: resume?.seed ?? crypto.getRandomValues(new Uint32Array(1))[0],
|
||||||
|
difficulty: level,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
worker?.terminate()
|
||||||
|
worker = undefined
|
||||||
|
screen.value = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function paint(index: number) {
|
||||||
|
const current = puzzle.value
|
||||||
|
if (!current || confirmation.value || result.value !== 'playing') return
|
||||||
|
if (revealed.value[index]) {
|
||||||
|
selected.value = current.answer[index]
|
||||||
|
needsColor.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selected.value < 0) {
|
||||||
|
needsColor.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
started = true
|
||||||
|
if (selected.value !== current.answer[index]) {
|
||||||
|
mistake.value = index
|
||||||
|
result.value = 'lost'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
revealed.value[index] = true
|
||||||
|
if (revealed.value.every(Boolean)) {
|
||||||
|
result.value = 'won'
|
||||||
|
try {
|
||||||
|
best.value = recordBest(localStorage, current.difficulty, elapsed.value)
|
||||||
|
} catch {
|
||||||
|
recordError.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ask(kind: 'leave' | 'replace') {
|
||||||
|
tick()
|
||||||
|
heldView = board.value?.getView() ?? { left: 0, top: 0 }
|
||||||
|
confirmation.value = kind
|
||||||
|
await nextTick()
|
||||||
|
prompt.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestNew(level: Difficulty = difficulty.value) {
|
||||||
|
nextDifficulty = level
|
||||||
|
if (screen.value === 'board' && result.value === 'playing') void ask('replace')
|
||||||
|
else start(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestClose() {
|
||||||
|
if (confirmation.value) {
|
||||||
|
confirmation.value = null
|
||||||
|
lastTick = performance.now()
|
||||||
|
void nextTick(() => board.value?.setView(heldView.left, heldView.top))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (screen.value === 'board' && result.value === 'playing') void ask('leave')
|
||||||
|
else void close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function close() {
|
||||||
|
if (closing) return
|
||||||
|
closing = true
|
||||||
|
active = false
|
||||||
|
window.clearInterval(timer)
|
||||||
|
timer = undefined
|
||||||
|
worker?.terminate()
|
||||||
|
worker = undefined
|
||||||
|
await modal.value?.hide()
|
||||||
|
confirmation.value = null
|
||||||
|
puzzle.value = undefined
|
||||||
|
closing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAndLeave() {
|
||||||
|
if (!puzzle.value) return
|
||||||
|
try {
|
||||||
|
saveGame(localStorage, {
|
||||||
|
version: 1,
|
||||||
|
seed: puzzle.value.seed,
|
||||||
|
difficulty: puzzle.value.difficulty,
|
||||||
|
revealed: revealed.value.slice(),
|
||||||
|
selected: selected.value,
|
||||||
|
elapsed: elapsed.value,
|
||||||
|
zoom: zoom.value,
|
||||||
|
left: heldView.left,
|
||||||
|
top: heldView.top,
|
||||||
|
})
|
||||||
|
await close()
|
||||||
|
} catch {
|
||||||
|
storageError.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardSaved() {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(SAVE_KEY)
|
||||||
|
saved.value = null
|
||||||
|
start(difficulty.value)
|
||||||
|
} catch {
|
||||||
|
storageError.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
requestClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onScopeDispose(() => {
|
||||||
|
worker?.terminate()
|
||||||
|
window.clearInterval(timer)
|
||||||
|
document.removeEventListener('visibilitychange', visibilityChanged)
|
||||||
|
})
|
||||||
|
defineExpose({ show })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NewModal
|
||||||
|
ref="modal"
|
||||||
|
:header="formatMessage(messages.title)"
|
||||||
|
width="56rem"
|
||||||
|
max-width="56rem"
|
||||||
|
scrollable
|
||||||
|
:closable="false"
|
||||||
|
:close-on-esc="false"
|
||||||
|
actions-divider
|
||||||
|
@keydown.stop="keydown"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="color-mine"
|
||||||
|
:style="{
|
||||||
|
'--mine-ink': selected >= 0 ? `var(--mine-color-${selected})` : 'var(--color-brand)',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div v-if="confirmation" ref="prompt" class="mine-prompt" tabindex="-1" role="alert">
|
||||||
|
<h3>
|
||||||
|
{{
|
||||||
|
formatMessage(confirmation === 'leave' ? messages.leaveTitle : messages.replaceTitle)
|
||||||
|
}}
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
{{ formatMessage(confirmation === 'leave' ? messages.leaveText : messages.replaceText) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="screen === 'board' && puzzle" v-show="!confirmation" class="mine-play">
|
||||||
|
<div class="mine-toolbar">
|
||||||
|
<div class="mine-levels">
|
||||||
|
<Button
|
||||||
|
v-for="level in levels"
|
||||||
|
:key="level"
|
||||||
|
size="sm"
|
||||||
|
:type="difficulty === level ? 'outlined' : 'base'"
|
||||||
|
:aria-pressed="difficulty === level"
|
||||||
|
@click="level !== difficulty && requestNew(level)"
|
||||||
|
>{{ formatMessage(messages[level]) }}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span class="mine-time">{{ timeText(elapsed) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mine-frame">
|
||||||
|
<p class="mine-status" role="status">{{ status }}</p>
|
||||||
|
<ColorMineBoard
|
||||||
|
ref="board"
|
||||||
|
v-model:zoom="zoom"
|
||||||
|
:puzzle="puzzle"
|
||||||
|
:revealed="revealed"
|
||||||
|
:selected="selected"
|
||||||
|
:playing="result === 'playing'"
|
||||||
|
:lost="result === 'lost'"
|
||||||
|
:mistake="mistake"
|
||||||
|
@paint="paint"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mine-stats">
|
||||||
|
<span>{{ formatMessage(messages.progress, { count, total: revealed.length }) }}</span>
|
||||||
|
<span v-if="best !== null">{{
|
||||||
|
formatMessage(messages.best, { time: timeText(best) })
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="result === 'lost'" class="mine-meta">{{ formatMessage(messages.review) }}</p>
|
||||||
|
<p v-if="recordError" role="alert">{{ formatMessage(messages.recordError) }}</p>
|
||||||
|
<details class="mine-rules">
|
||||||
|
<summary>{{ formatMessage(messages.rulesTitle) }}</summary>
|
||||||
|
<p>{{ formatMessage(messages.rules) }}</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="screen === 'loading'" role="status">{{ formatMessage(messages.loading) }}</p>
|
||||||
|
<div v-else-if="screen === 'saved'" class="mine-prompt">
|
||||||
|
<h3>{{ formatMessage(messages.savedTitle) }}</h3>
|
||||||
|
<p v-if="saved">
|
||||||
|
{{ formatMessage(messages[saved.difficulty]) }} · {{ timeText(saved.elapsed) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p v-else role="alert">{{ formatMessage(messages.generateError) }}</p>
|
||||||
|
<p v-if="storageError" role="alert">{{ formatMessage(messages.storageError) }}</p>
|
||||||
|
</div>
|
||||||
|
<template #actions>
|
||||||
|
<div class="mine-actions">
|
||||||
|
<template v-if="confirmation">
|
||||||
|
<Button @click="requestClose">{{ formatMessage(messages.continue) }}</Button>
|
||||||
|
<Button v-if="confirmation === 'leave'" @click="close">{{
|
||||||
|
formatMessage(messages.discard)
|
||||||
|
}}</Button>
|
||||||
|
<Button
|
||||||
|
type="colored"
|
||||||
|
color="brand"
|
||||||
|
@click="confirmation === 'leave' ? saveAndLeave() : start(nextDifficulty)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
formatMessage(confirmation === 'leave' ? messages.saveLeave : messages.newGame)
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<Button @click="requestClose">{{ formatMessage(messages.close) }}</Button>
|
||||||
|
<template v-if="screen === 'saved'">
|
||||||
|
<Button @click="discardSaved">{{ formatMessage(messages.discard) }}</Button>
|
||||||
|
<Button
|
||||||
|
type="colored"
|
||||||
|
color="brand"
|
||||||
|
@click="saved ? start(saved.difficulty, saved) : loadSaved()"
|
||||||
|
>
|
||||||
|
{{ formatMessage(saved ? messages.resume : messages.retry) }}</Button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<Button
|
||||||
|
v-else-if="screen !== 'loading'"
|
||||||
|
:type="result === 'playing' && screen === 'board' ? 'base' : 'colored'"
|
||||||
|
color="brand"
|
||||||
|
@click="screen === 'error' && saved ? start(saved.difficulty, saved) : requestNew()"
|
||||||
|
>
|
||||||
|
{{ formatMessage(screen === 'error' ? messages.retry : messages.newGame) }}</Button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.color-mine {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap-md);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.mine-play {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap-md);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.mine-toolbar,
|
||||||
|
.mine-levels,
|
||||||
|
.mine-stats,
|
||||||
|
.mine-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--gap-sm);
|
||||||
|
}
|
||||||
|
.mine-time {
|
||||||
|
margin-left: auto;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-contrast);
|
||||||
|
}
|
||||||
|
.mine-meta,
|
||||||
|
.mine-stats,
|
||||||
|
.mine-rules {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.mine-stats {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.mine-prompt h3,
|
||||||
|
.mine-prompt p {
|
||||||
|
margin: 0 0 var(--gap-md);
|
||||||
|
}
|
||||||
|
.mine-rules summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mine-rules p {
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { type Board, createEngine, type Difficulty, generate, LEVELS } from './engine.ts'
|
||||||
|
|
||||||
|
function connected(board: Board) {
|
||||||
|
const { cross } = createEngine(board.size, board.colors)
|
||||||
|
for (let color = 0; color < board.colors; color++) {
|
||||||
|
const source = board.answer.indexOf(color)
|
||||||
|
if (source < 0) return false
|
||||||
|
const visited = new Set([source])
|
||||||
|
const pending = [source]
|
||||||
|
while (pending.length) {
|
||||||
|
for (const neighbor of cross[pending.pop()!]) {
|
||||||
|
if (board.answer[neighbor] === color && !visited.has(neighbor)) {
|
||||||
|
visited.add(neighbor)
|
||||||
|
pending.push(neighbor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (visited.size !== board.answer.filter((c) => c === color).length) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const difficulty of Object.keys(LEVELS) as Difficulty[]) {
|
||||||
|
test(`${difficulty}: 20 seeds have connected colors, correct numbers and a complete no-guess path`, () => {
|
||||||
|
for (let seed = 1; seed <= 20; seed++) {
|
||||||
|
const puzzle = generate(seed, difficulty)
|
||||||
|
const engine = createEngine(puzzle.size, puzzle.colors)
|
||||||
|
assert.ok(connected(puzzle))
|
||||||
|
assert.ok(puzzle.clues.some((v) => !v))
|
||||||
|
assert.ok(
|
||||||
|
puzzle.clues.filter(Boolean).length >=
|
||||||
|
Math.ceil(puzzle.size ** 2 * LEVELS[difficulty].density),
|
||||||
|
)
|
||||||
|
for (let color = 0; color < puzzle.colors; color++) {
|
||||||
|
assert.ok(puzzle.clues.some((v, i) => v && puzzle.answer[i] === color))
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
puzzle.numbers,
|
||||||
|
puzzle.answer.map(
|
||||||
|
(color, i) => engine.around[i].filter((j) => puzzle.answer[j] === color).length,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert.ok(engine.solve(puzzle, puzzle.clues).every(Boolean))
|
||||||
|
if (difficulty === 'hard') {
|
||||||
|
for (let i = 0; i < puzzle.clues.length; i++) {
|
||||||
|
if (
|
||||||
|
!puzzle.clues[i] ||
|
||||||
|
puzzle.clues.filter((v, j) => v && puzzle.answer[j] === puzzle.answer[i]).length <= 1
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
const fewer = puzzle.clues.slice()
|
||||||
|
fewer[i] = false
|
||||||
|
assert.ok(
|
||||||
|
engine.solve(puzzle, fewer).some((v) => !v),
|
||||||
|
`Redundant clue at ${i}, seed ${seed}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('seeded generation is deterministic for saved games', () => {
|
||||||
|
assert.deepEqual(generate(4294967295, 'normal'), generate(4294967295, 'normal'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deductions agree with exhaustive valid 3 × 3 boards; unrevealed numbers never leak', () => {
|
||||||
|
const engine = createEngine(3, 2)
|
||||||
|
const boards: Board[] = []
|
||||||
|
for (let bits = 1; bits < 511; bits++) {
|
||||||
|
const answer = Array.from({ length: 9 }, (_, i) => (bits >> i) & 1)
|
||||||
|
const board = {
|
||||||
|
size: 3,
|
||||||
|
colors: 2,
|
||||||
|
answer,
|
||||||
|
numbers: answer.map((c, i) => engine.around[i].filter((j) => answer[j] === c).length),
|
||||||
|
}
|
||||||
|
if (connected(board)) boards.push(board)
|
||||||
|
}
|
||||||
|
for (const board of boards) {
|
||||||
|
const revealed = board.answer.map((color, i) => i === board.answer.indexOf(color))
|
||||||
|
const masks = engine.infer(board, revealed)
|
||||||
|
const candidates = boards.filter((candidate) =>
|
||||||
|
revealed.every(
|
||||||
|
(open, i) =>
|
||||||
|
!open ||
|
||||||
|
(candidate.answer[i] === board.answer[i] && candidate.numbers[i] === board.numbers[i]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
candidate.answer.forEach((color, i) => assert.ok(masks[i] & (1 << color)))
|
||||||
|
}
|
||||||
|
const hiddenScrambled = {
|
||||||
|
...board,
|
||||||
|
answer: board.answer.map((c, i) => (revealed[i] ? c : 1 - c)),
|
||||||
|
numbers: board.numbers.map((n, i) => (revealed[i] ? n : 99)),
|
||||||
|
}
|
||||||
|
assert.deepEqual(engine.infer(hiddenScrambled, revealed), masks)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('diagonal contact does not connect a color', () => {
|
||||||
|
assert.equal(
|
||||||
|
connected({ size: 2, colors: 2, answer: [0, 1, 1, 0], numbers: [1, 1, 1, 1] }),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
@ -0,0 +1,275 @@
|
|||||||
|
export const LEVELS = {
|
||||||
|
easy: { size: 10, colors: 4, density: 0.35 },
|
||||||
|
normal: { size: 25, colors: 6, density: 0.15 },
|
||||||
|
hard: { size: 60, colors: 10, density: 0 },
|
||||||
|
} as const
|
||||||
|
export type Difficulty = keyof typeof LEVELS
|
||||||
|
export interface Board {
|
||||||
|
size: number
|
||||||
|
colors: number
|
||||||
|
answer: number[]
|
||||||
|
numbers: number[]
|
||||||
|
}
|
||||||
|
export interface Puzzle extends Board {
|
||||||
|
clues: boolean[]
|
||||||
|
seed: number
|
||||||
|
difficulty: Difficulty
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleton = (mask: number) => mask > 0 && (mask & (mask - 1)) === 0
|
||||||
|
export const colorOf = (mask: number) => 31 - Math.clz32(mask)
|
||||||
|
|
||||||
|
function randomSource(seed: number) {
|
||||||
|
return () => {
|
||||||
|
seed = (seed + 0x6d2b79f5) | 0
|
||||||
|
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffle<T>(items: T[], random: () => number) {
|
||||||
|
for (let i = items.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(random() * (i + 1))
|
||||||
|
;[items[i], items[j]] = [items[j], items[i]]
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEngine(size: number, colors: number) {
|
||||||
|
const count = size * size
|
||||||
|
const all = (1 << colors) - 1
|
||||||
|
const indices = Array.from({ length: count }, (_, i) => i)
|
||||||
|
const around = indices.map((i) => {
|
||||||
|
const neighbors: number[] = []
|
||||||
|
for (let dy = -1; dy <= 1; dy++) {
|
||||||
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
const x = (i % size) + dx
|
||||||
|
const y = Math.floor(i / size) + dy
|
||||||
|
if ((dx || dy) && x >= 0 && x < size && y >= 0 && y < size) {
|
||||||
|
neighbors.push(y * size + x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return neighbors
|
||||||
|
})
|
||||||
|
const distance = (a: number, b: number) =>
|
||||||
|
Math.abs((a % size) - (b % size)) + Math.abs(Math.floor(a / size) - Math.floor(b / size))
|
||||||
|
const cross = around.map((neighbors, i) => neighbors.filter((j) => distance(i, j) === 1))
|
||||||
|
|
||||||
|
function grow(random: () => number): Board {
|
||||||
|
const answer: number[] = Array(count).fill(-1)
|
||||||
|
const areas: number[] = Array(colors).fill(0)
|
||||||
|
const seeds: number[] = []
|
||||||
|
for (let color = 0; color < colors; color++) {
|
||||||
|
const candidates = shuffle(
|
||||||
|
indices.filter((i) => answer[i] < 0),
|
||||||
|
random,
|
||||||
|
).slice(0, 80)
|
||||||
|
if (color) {
|
||||||
|
candidates.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Math.min(...seeds.map((s) => distance(b, s))) -
|
||||||
|
Math.min(...seeds.map((s) => distance(a, s))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const seed = candidates[0]
|
||||||
|
answer[seed] = color
|
||||||
|
areas[color]++
|
||||||
|
seeds.push(seed)
|
||||||
|
}
|
||||||
|
const frontier: number[][] = Array.from({ length: colors }, () => [])
|
||||||
|
const queued = Array.from({ length: colors }, () => new Uint8Array(count))
|
||||||
|
function extend(i: number, color: number) {
|
||||||
|
for (const j of cross[i]) {
|
||||||
|
if (answer[j] < 0 && !queued[color][j]) {
|
||||||
|
frontier[color].push(j)
|
||||||
|
queued[color][j] = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seeds.forEach(extend)
|
||||||
|
let left = count - colors
|
||||||
|
while (left) {
|
||||||
|
const available = areas.map((_, c) => c).filter((c) => frontier[c].length)
|
||||||
|
available.sort((a, b) => areas[a] - areas[b])
|
||||||
|
if (!available.length) throw new Error('Incomplete region growth')
|
||||||
|
const color = available[Math.floor(random() * Math.min(2, available.length))]
|
||||||
|
const list = frontier[color]
|
||||||
|
const position = Math.floor(random() * list.length)
|
||||||
|
const i = list[position]
|
||||||
|
list[position] = list[list.length - 1]
|
||||||
|
list.pop()
|
||||||
|
if (answer[i] >= 0) continue
|
||||||
|
answer[i] = color
|
||||||
|
areas[color]++
|
||||||
|
left--
|
||||||
|
extend(i, color)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
size,
|
||||||
|
colors,
|
||||||
|
answer,
|
||||||
|
numbers: answer.map((color, i) => around[i].filter((j) => answer[j] === color).length),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only revealed cells supply numbers. Deduced cells become new clues after a safe paint.
|
||||||
|
function propagate(board: Board, revealed: boolean[], masks: number[]) {
|
||||||
|
let changed = true
|
||||||
|
while (changed) {
|
||||||
|
changed = false
|
||||||
|
for (const i of indices) {
|
||||||
|
if (!revealed[i]) continue
|
||||||
|
const bit = 1 << board.answer[i]
|
||||||
|
const target = board.numbers[i]
|
||||||
|
let sure = 0
|
||||||
|
const optional: number[] = []
|
||||||
|
for (const j of around[i]) {
|
||||||
|
if (masks[j] === bit) sure++
|
||||||
|
else if (masks[j] & bit) optional.push(j)
|
||||||
|
}
|
||||||
|
if (sure > target || sure + optional.length < target) throw new Error('Contradictory clue')
|
||||||
|
if (sure === target || sure + optional.length === target) {
|
||||||
|
for (const j of optional) {
|
||||||
|
masks[j] = sure === target ? masks[j] & ~bit : bit
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectivity(masks: number[]) {
|
||||||
|
let changed = false
|
||||||
|
for (let color = 0; color < colors; color++) {
|
||||||
|
const bit = 1 << color
|
||||||
|
const source = masks.indexOf(bit)
|
||||||
|
if (source < 0) continue
|
||||||
|
// Iterative Tarjan traversal avoids a recursive stack overflow on the 60 × 60 board.
|
||||||
|
const order = new Int32Array(count)
|
||||||
|
const low = new Int32Array(count)
|
||||||
|
const parent = new Int32Array(count).fill(-1)
|
||||||
|
const terminals = new Int32Array(count)
|
||||||
|
const edge = new Uint8Array(count)
|
||||||
|
const stack = [source]
|
||||||
|
let time = 1
|
||||||
|
order[source] = low[source] = terminals[source] = 1
|
||||||
|
while (stack.length) {
|
||||||
|
const u = stack[stack.length - 1]
|
||||||
|
if (edge[u] < cross[u].length) {
|
||||||
|
const v = cross[u][edge[u]++]
|
||||||
|
if (!(masks[v] & bit)) continue
|
||||||
|
if (!order[v]) {
|
||||||
|
parent[v] = u
|
||||||
|
order[v] = low[v] = ++time
|
||||||
|
terminals[v] = masks[v] === bit ? 1 : 0
|
||||||
|
stack.push(v)
|
||||||
|
} else if (v !== parent[u]) low[u] = Math.min(low[u], order[v])
|
||||||
|
} else {
|
||||||
|
stack.pop()
|
||||||
|
const p = parent[u]
|
||||||
|
if (p < 0) continue
|
||||||
|
low[p] = Math.min(low[p], low[u])
|
||||||
|
terminals[p] += terminals[u]
|
||||||
|
// A bridge must be this color if its removal separates two known same-color cells.
|
||||||
|
if (p !== source && low[u] >= order[p] && terminals[u] > 0 && masks[p] !== bit) {
|
||||||
|
masks[p] = bit
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const i of indices) {
|
||||||
|
if (masks[i] & bit && !order[i]) {
|
||||||
|
masks[i] &= ~bit
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (masks.some((mask) => mask === 0)) throw new Error('Empty color domain')
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
function infer(board: Board, revealed: boolean[]) {
|
||||||
|
const masks = board.answer.map((color, i) => (revealed[i] ? 1 << color : all))
|
||||||
|
do {
|
||||||
|
propagate(board, revealed, masks)
|
||||||
|
} while (connectivity(masks))
|
||||||
|
return masks
|
||||||
|
}
|
||||||
|
|
||||||
|
function solve(board: Board, clues: boolean[]) {
|
||||||
|
const revealed = clues.slice()
|
||||||
|
const masks = board.answer.map((color, i) => (revealed[i] ? 1 << color : all))
|
||||||
|
while (true) {
|
||||||
|
propagate(board, revealed, masks)
|
||||||
|
const next = indices.filter((i) => !revealed[i] && singleton(masks[i]))
|
||||||
|
if (!next.length) {
|
||||||
|
if (!connectivity(masks)) return revealed
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const i of next) {
|
||||||
|
if (colorOf(masks[i]) !== board.answer[i]) throw new Error('Unsound deduction')
|
||||||
|
revealed[i] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { around, cross, grow, infer, solve }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generate(seed: number, difficulty: Difficulty): Puzzle {
|
||||||
|
const level = LEVELS[difficulty]
|
||||||
|
const random = randomSource(seed)
|
||||||
|
const engine = createEngine(level.size, level.colors)
|
||||||
|
const board = engine.grow(random)
|
||||||
|
const indices = board.answer.map((_, i) => i)
|
||||||
|
const clues: boolean[] = indices.map(() => false)
|
||||||
|
for (let color = 0; color < level.colors; color++) {
|
||||||
|
const cells = shuffle(
|
||||||
|
indices.filter((i) => board.answer[i] === color),
|
||||||
|
random,
|
||||||
|
)
|
||||||
|
cells.sort((a, b) => board.numbers[b] - board.numbers[a])
|
||||||
|
clues[cells[0]] = true
|
||||||
|
}
|
||||||
|
let solved = engine.solve(board, clues)
|
||||||
|
while (solved.some((value) => !value)) {
|
||||||
|
const cells = shuffle(
|
||||||
|
indices.filter((i) => !solved[i]),
|
||||||
|
random,
|
||||||
|
)
|
||||||
|
cells.sort(
|
||||||
|
(a, b) =>
|
||||||
|
engine.around[b].filter((j) => !solved[j]).length -
|
||||||
|
engine.around[a].filter((j) => !solved[j]).length,
|
||||||
|
)
|
||||||
|
clues[cells[0]] = true
|
||||||
|
solved = engine.solve(board, clues)
|
||||||
|
}
|
||||||
|
// Greedy irredundancy under these deduction rules, not a claim of global minimum clue count.
|
||||||
|
for (const i of shuffle(
|
||||||
|
indices.filter((i) => clues[i]),
|
||||||
|
random,
|
||||||
|
)) {
|
||||||
|
if (indices.filter((j) => clues[j] && board.answer[j] === board.answer[i]).length <= 1) continue
|
||||||
|
clues[i] = false
|
||||||
|
if (engine.solve(board, clues).some((value) => !value)) clues[i] = true
|
||||||
|
}
|
||||||
|
const target = Math.ceil(indices.length * level.density)
|
||||||
|
const pools = Array.from({ length: level.colors }, (_, color) =>
|
||||||
|
shuffle(
|
||||||
|
indices.filter((i) => board.answer[i] === color && !clues[i]),
|
||||||
|
random,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
let total = clues.filter(Boolean).length
|
||||||
|
while (total < target) {
|
||||||
|
for (const pool of pools) {
|
||||||
|
if (pool.length && total < target) {
|
||||||
|
clues[pool.pop()!] = true
|
||||||
|
total++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...board, clues, seed, difficulty }
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { type Difficulty, generate } from './engine'
|
||||||
|
|
||||||
|
self.onmessage = (event: MessageEvent<{ seed: number; difficulty: Difficulty }>) => {
|
||||||
|
try {
|
||||||
|
self.postMessage({ puzzle: generate(event.data.seed, event.data.difficulty) })
|
||||||
|
} catch {
|
||||||
|
self.postMessage({ error: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
import { defineMessages } from '@modrinth/ui'
|
||||||
|
|
||||||
|
export const messages = defineMessages({
|
||||||
|
title: { id: 'app.easteregg.color-mine.title', defaultMessage: 'Starlight Mine: Chromatic Realms' },
|
||||||
|
easy: { id: 'app.easteregg.color-mine.easy', defaultMessage: 'Easy' },
|
||||||
|
normal: { id: 'app.easteregg.color-mine.normal', defaultMessage: 'Normal' },
|
||||||
|
hard: { id: 'app.easteregg.color-mine.hard', defaultMessage: 'Hard' },
|
||||||
|
newGame: { id: 'app.easteregg.color-mine.newGame', defaultMessage: 'New board' },
|
||||||
|
close: { id: 'app.easteregg.color-mine.close', defaultMessage: 'Close' },
|
||||||
|
choose: {
|
||||||
|
id: 'app.easteregg.color-mine.choose',
|
||||||
|
defaultMessage: 'Click a revealed tile to sample its color, then paint a gray tile.',
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
id: 'app.easteregg.color-mine.selected',
|
||||||
|
defaultMessage: 'Color {color} selected · Click a colored tile to change your brush.',
|
||||||
|
},
|
||||||
|
rulesTitle: { id: 'app.easteregg.color-mine.rulesTitle', defaultMessage: 'How to play' },
|
||||||
|
rules: {
|
||||||
|
id: 'app.easteregg.color-mine.rules',
|
||||||
|
defaultMessage:
|
||||||
|
'Numbers count same-color tiles in the eight surrounding spaces, excluding the tile itself. Each color forms one connected region using only horizontal and vertical edges. Sample a revealed tile to select a brush; a correct paint reveals a new number, but one wrong paint ends the game. Every board has a step-by-step deduction path without guessing.',
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
id: 'app.easteregg.color-mine.loading',
|
||||||
|
defaultMessage: 'Generating regions and checking the deduction path…',
|
||||||
|
},
|
||||||
|
generateError: {
|
||||||
|
id: 'app.easteregg.color-mine.generateError',
|
||||||
|
defaultMessage: 'Could not generate this board. Try again.',
|
||||||
|
},
|
||||||
|
retry: { id: 'app.easteregg.color-mine.retry', defaultMessage: 'Try again' },
|
||||||
|
progress: {
|
||||||
|
id: 'app.easteregg.color-mine.progress',
|
||||||
|
defaultMessage: '{count} / {total} revealed',
|
||||||
|
},
|
||||||
|
best: { id: 'app.easteregg.color-mine.best', defaultMessage: 'Best · {time}' },
|
||||||
|
won: { id: 'app.easteregg.color-mine.won', defaultMessage: 'All colors restored!' },
|
||||||
|
lost: {
|
||||||
|
id: 'app.easteregg.color-mine.lost',
|
||||||
|
defaultMessage:
|
||||||
|
'Wrong color at row {row}, column {column}: selected {selected}, correct {correct}. Game over.',
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
id: 'app.easteregg.color-mine.review',
|
||||||
|
defaultMessage: 'Unrevealed answers are now shown faded for review.',
|
||||||
|
},
|
||||||
|
leaveTitle: { id: 'app.easteregg.color-mine.leaveTitle', defaultMessage: 'Leave this board?' },
|
||||||
|
leaveText: {
|
||||||
|
id: 'app.easteregg.color-mine.leaveText',
|
||||||
|
defaultMessage: 'Save to continue later, or discard this unfinished board.',
|
||||||
|
},
|
||||||
|
saveLeave: { id: 'app.easteregg.color-mine.saveLeave', defaultMessage: 'Save and leave' },
|
||||||
|
discard: { id: 'app.easteregg.color-mine.discard', defaultMessage: 'Discard' },
|
||||||
|
continue: { id: 'app.easteregg.color-mine.continue', defaultMessage: 'Keep playing' },
|
||||||
|
replaceTitle: {
|
||||||
|
id: 'app.easteregg.color-mine.replaceTitle',
|
||||||
|
defaultMessage: 'Start a new board?',
|
||||||
|
},
|
||||||
|
replaceText: {
|
||||||
|
id: 'app.easteregg.color-mine.replaceText',
|
||||||
|
defaultMessage: 'The current board will be discarded.',
|
||||||
|
},
|
||||||
|
savedTitle: {
|
||||||
|
id: 'app.easteregg.color-mine.savedTitle',
|
||||||
|
defaultMessage: 'An unfinished board is saved',
|
||||||
|
},
|
||||||
|
resume: { id: 'app.easteregg.color-mine.resume', defaultMessage: 'Resume saved game' },
|
||||||
|
storageError: {
|
||||||
|
id: 'app.easteregg.color-mine.storageError',
|
||||||
|
defaultMessage:
|
||||||
|
'Could not read or write the saved game. Your current board has been kept. Retry or explicitly discard it.',
|
||||||
|
},
|
||||||
|
recordError: {
|
||||||
|
id: 'app.easteregg.color-mine.recordError',
|
||||||
|
defaultMessage: 'Finished, but the best time could not be saved.',
|
||||||
|
},
|
||||||
|
zoomIn: { id: 'app.easteregg.color-mine.zoomIn', defaultMessage: 'Zoom in' },
|
||||||
|
zoomOut: { id: 'app.easteregg.color-mine.zoomOut', defaultMessage: 'Zoom out' },
|
||||||
|
map: { id: 'app.easteregg.color-mine.map', defaultMessage: 'Board overview. Click to navigate.' },
|
||||||
|
board: {
|
||||||
|
id: 'app.easteregg.color-mine.board',
|
||||||
|
defaultMessage: '{size} by {size} color deduction board',
|
||||||
|
},
|
||||||
|
hidden: {
|
||||||
|
id: 'app.easteregg.color-mine.hidden',
|
||||||
|
defaultMessage: 'Row {row}, column {column}, unrevealed',
|
||||||
|
},
|
||||||
|
revealed: {
|
||||||
|
id: 'app.easteregg.color-mine.revealed',
|
||||||
|
defaultMessage: 'Row {row}, column {column}, color {color}, {number} same-color neighbors',
|
||||||
|
},
|
||||||
|
})
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
/* Semantic colors stay identifiable when the launcher's theme/accent changes. */
|
||||||
|
.color-mine {
|
||||||
|
--mine-color-0: var(--color-red);
|
||||||
|
--mine-color-1: #d7ac37;
|
||||||
|
--mine-color-2: var(--color-green);
|
||||||
|
--mine-color-3: var(--color-blue);
|
||||||
|
--mine-color-4: var(--color-purple);
|
||||||
|
--mine-color-5: var(--color-orange);
|
||||||
|
--mine-color-6: #97ba35;
|
||||||
|
--mine-color-7: #22b6b8;
|
||||||
|
--mine-color-8: var(--color-pink);
|
||||||
|
--mine-color-9: #8999ac;
|
||||||
|
}
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { generate } from './engine.ts'
|
||||||
|
import {
|
||||||
|
parseSave,
|
||||||
|
readBest,
|
||||||
|
recordBest,
|
||||||
|
SAVE_KEY,
|
||||||
|
type SavedGame,
|
||||||
|
saveGame,
|
||||||
|
validateResume,
|
||||||
|
} from './storage.ts'
|
||||||
|
|
||||||
|
const puzzle = generate(123, 'easy')
|
||||||
|
const saved: SavedGame = {
|
||||||
|
version: 1,
|
||||||
|
seed: puzzle.seed,
|
||||||
|
difficulty: puzzle.difficulty,
|
||||||
|
revealed: puzzle.clues.slice(),
|
||||||
|
selected: -1,
|
||||||
|
elapsed: 4321,
|
||||||
|
zoom: 1,
|
||||||
|
left: 12,
|
||||||
|
top: 34,
|
||||||
|
}
|
||||||
|
function memoryStorage() {
|
||||||
|
const values = new Map<string, string>()
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
values.set(key, value)
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
values.delete(key)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('save round trip keeps board progress, brush, timer and viewport', () => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
saveGame(storage, saved)
|
||||||
|
assert.deepEqual(parseSave(storage.getItem(SAVE_KEY)), saved)
|
||||||
|
validateResume(saved, puzzle)
|
||||||
|
assert.equal(parseSave(null), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('malformed and incompatible saves are rejected without overwriting data', () => {
|
||||||
|
for (const bad of [
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
{ ...saved, version: 2 },
|
||||||
|
{ ...saved, difficulty: '__proto__' },
|
||||||
|
{ ...saved, revealed: [true] },
|
||||||
|
{ ...saved, elapsed: -1 },
|
||||||
|
{ ...saved, selected: 15 },
|
||||||
|
{ ...saved, zoom: 5 },
|
||||||
|
{ ...saved, seed: 1.2 },
|
||||||
|
{ ...saved, top: -1 },
|
||||||
|
{ ...saved, revealed: saved.revealed.map(() => true) },
|
||||||
|
]) {
|
||||||
|
assert.throws(() => parseSave(JSON.stringify(bad)))
|
||||||
|
}
|
||||||
|
assert.throws(() => parseSave('{invalid'))
|
||||||
|
assert.throws(() => validateResume({ ...saved, seed: 9 }, puzzle))
|
||||||
|
assert.throws(() =>
|
||||||
|
validateResume({ ...saved, revealed: saved.revealed.map(() => false) }, puzzle),
|
||||||
|
)
|
||||||
|
const storage = memoryStorage()
|
||||||
|
saveGame(storage, saved)
|
||||||
|
assert.throws(() => saveGame(storage, { ...saved, elapsed: -1 }))
|
||||||
|
assert.deepEqual(parseSave(storage.getItem(SAVE_KEY)), saved)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('storage failures are surfaced so the UI can keep the active board', () => {
|
||||||
|
assert.throws(() =>
|
||||||
|
saveGame(
|
||||||
|
{
|
||||||
|
...memoryStorage(),
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error('Quota exceeded')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
saved,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('best times are independent per difficulty and only improve', () => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
assert.equal(readBest(storage, 'easy'), null)
|
||||||
|
assert.equal(recordBest(storage, 'easy', 1000), 1000)
|
||||||
|
assert.equal(recordBest(storage, 'easy', 2000), 1000)
|
||||||
|
assert.equal(recordBest(storage, 'easy', 500), 500)
|
||||||
|
assert.equal(recordBest(storage, 'hard', 9000), 9000)
|
||||||
|
assert.equal(readBest(storage, 'easy'), 500)
|
||||||
|
assert.throws(() => recordBest(storage, 'easy', -1))
|
||||||
|
})
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
import { type Difficulty, LEVELS, type Puzzle } from './engine.ts'
|
||||||
|
|
||||||
|
export const SAVE_KEY = 'starlight.color-mine.save.v1'
|
||||||
|
const BEST_KEY = 'starlight.color-mine.best.v1'
|
||||||
|
export interface SavedGame {
|
||||||
|
version: 1
|
||||||
|
seed: number
|
||||||
|
difficulty: Difficulty
|
||||||
|
revealed: boolean[]
|
||||||
|
selected: number
|
||||||
|
elapsed: number
|
||||||
|
zoom: number
|
||||||
|
left: number
|
||||||
|
top: number
|
||||||
|
}
|
||||||
|
type StoragePort = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
|
||||||
|
const finite = (value: unknown): value is number =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value)
|
||||||
|
export function parseSave(raw: string | null): SavedGame | null {
|
||||||
|
if (raw === null) return null
|
||||||
|
const value = JSON.parse(raw) as Partial<SavedGame> | null
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
value.version !== 1 ||
|
||||||
|
!value.difficulty ||
|
||||||
|
!Object.keys(LEVELS).includes(value.difficulty)
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid saved game')
|
||||||
|
}
|
||||||
|
const level = LEVELS[value.difficulty]
|
||||||
|
if (
|
||||||
|
!finite(value.seed) ||
|
||||||
|
!Number.isInteger(value.seed) ||
|
||||||
|
value.seed < 0 ||
|
||||||
|
value.seed > 0xffffffff ||
|
||||||
|
!Array.isArray(value.revealed) ||
|
||||||
|
value.revealed.length !== level.size ** 2 ||
|
||||||
|
value.revealed.some((v) => typeof v !== 'boolean') ||
|
||||||
|
value.revealed.every(Boolean) ||
|
||||||
|
!finite(value.selected) ||
|
||||||
|
!Number.isInteger(value.selected) ||
|
||||||
|
value.selected < -1 ||
|
||||||
|
value.selected >= level.colors ||
|
||||||
|
!finite(value.elapsed) ||
|
||||||
|
value.elapsed < 0 ||
|
||||||
|
!finite(value.zoom) ||
|
||||||
|
value.zoom < 0.7 ||
|
||||||
|
value.zoom > 1.6 ||
|
||||||
|
!finite(value.left) ||
|
||||||
|
value.left < 0 ||
|
||||||
|
!finite(value.top) ||
|
||||||
|
value.top < 0
|
||||||
|
)
|
||||||
|
throw new Error('Invalid saved game')
|
||||||
|
return value as SavedGame
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateResume(saved: SavedGame, puzzle: Puzzle) {
|
||||||
|
if (
|
||||||
|
saved.seed !== puzzle.seed ||
|
||||||
|
saved.difficulty !== puzzle.difficulty ||
|
||||||
|
puzzle.clues.some((clue, i) => clue && !saved.revealed[i]) ||
|
||||||
|
(saved.selected >= 0 &&
|
||||||
|
!saved.revealed.some((open, i) => open && puzzle.answer[i] === saved.selected))
|
||||||
|
) {
|
||||||
|
throw new Error('Saved game does not match puzzle')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveGame(storage: StoragePort, saved: SavedGame) {
|
||||||
|
const raw = JSON.stringify(saved)
|
||||||
|
parseSave(raw)
|
||||||
|
storage.setItem(SAVE_KEY, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBest(storage: StoragePort, difficulty: Difficulty): number | null {
|
||||||
|
const raw = storage.getItem(BEST_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const value: unknown = JSON.parse(raw)
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const time = (value as Record<string, unknown>)[difficulty]
|
||||||
|
return finite(time) && time >= 0 ? time : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordBest(storage: StoragePort, difficulty: Difficulty, elapsed: number) {
|
||||||
|
if (!finite(elapsed) || elapsed < 0) throw new Error('Invalid time')
|
||||||
|
const best = Object.fromEntries(
|
||||||
|
Object.keys(LEVELS).map((key) => [key, readBest(storage, key as Difficulty)]),
|
||||||
|
)
|
||||||
|
best[difficulty] = Math.min(best[difficulty] ?? Infinity, elapsed)
|
||||||
|
storage.setItem(BEST_KEY, JSON.stringify(best))
|
||||||
|
return best[difficulty]
|
||||||
|
}
|
||||||
@ -14,7 +14,6 @@ import { computed, ref } from 'vue'
|
|||||||
|
|
||||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { list } from '@/helpers/instance'
|
import { list } from '@/helpers/instance'
|
||||||
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
|
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
|
||||||
|
|
||||||
@ -91,7 +90,6 @@ defineExpose({
|
|||||||
instances.value = instanceValues
|
instances.value = instanceValues
|
||||||
modal.value.show()
|
modal.value.show()
|
||||||
|
|
||||||
trackEvent('AddServerToInstanceStart', { source: 'AddServerToInstanceModal' })
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -102,11 +100,6 @@ async function addServer(instance) {
|
|||||||
instance.added = true
|
instance.added = true
|
||||||
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] })
|
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] })
|
||||||
|
|
||||||
trackEvent('AddServerToInstance', {
|
|
||||||
server_name: serverName.value,
|
|
||||||
instance_name: instance.name,
|
|
||||||
source: 'AddServerToInstanceModal',
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err)
|
handleError(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,9 +16,10 @@ import { basename, dirname, join } from '@tauri-apps/api/path'
|
|||||||
import { computed, type Ref, ref, watch } from 'vue'
|
import { computed, type Ref, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
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 InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { install_duplicate_instance } from '@/helpers/install'
|
import { install_duplicate_instance } from '@/helpers/install'
|
||||||
import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance'
|
import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance'
|
||||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||||
@ -49,10 +50,6 @@ const installing = computed(() => instance.value.install_stage !== 'installed')
|
|||||||
|
|
||||||
async function duplicateInstance() {
|
async function duplicateInstance() {
|
||||||
await install_duplicate_instance(instance.value.id).catch(handleError)
|
await install_duplicate_instance(instance.value.id).catch(handleError)
|
||||||
trackEvent('InstanceDuplicate', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatReleaseChannelLabel(channel: ReleaseChannel) {
|
function formatReleaseChannelLabel(channel: ReleaseChannel) {
|
||||||
@ -104,7 +101,6 @@ watch(selectedReleaseChannel, async (channel, previousChannel) => {
|
|||||||
async function resetIcon() {
|
async function resetIcon() {
|
||||||
icon.value = undefined
|
icon.value = undefined
|
||||||
await edit_icon(instance.value.id, null).catch(handleError)
|
await edit_icon(instance.value.id, null).catch(handleError)
|
||||||
trackEvent('InstanceRemoveIcon')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setIcon() {
|
async function setIcon() {
|
||||||
@ -116,7 +112,6 @@ async function setIcon() {
|
|||||||
icon.value = picked.path
|
icon.value = picked.path
|
||||||
try {
|
try {
|
||||||
await edit_icon(instance.value.id, picked.path)
|
await edit_icon(instance.value.id, picked.path)
|
||||||
trackEvent('InstanceSetIcon')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
icon.value = previousIcon
|
icon.value = previousIcon
|
||||||
handleError(error)
|
handleError(error)
|
||||||
@ -236,7 +231,7 @@ async function setGameDirMode(mode: GameDirMode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const editInstanceObject = computed(() => ({
|
const editInstanceObject = computed(() => ({
|
||||||
name: title.value.trim().substring(0, 32) ?? 'Instance',
|
name: title.value.trim(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@ -253,10 +248,6 @@ async function removeInstance() {
|
|||||||
removing.value = true
|
removing.value = true
|
||||||
const path = instance.value.id
|
const path = instance.value.id
|
||||||
|
|
||||||
trackEvent('InstanceRemove', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
})
|
|
||||||
|
|
||||||
await router.push({ path: '/' })
|
await router.push({ path: '/' })
|
||||||
await remove(path).catch(handleError)
|
await remove(path).catch(handleError)
|
||||||
@ -390,6 +381,8 @@ const messages = defineMessages({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<InstanceModeSettings class="mb-6" :instance-id="instance.id" />
|
||||||
|
<InstancePlayerSettings :instance-id="instance.id" />
|
||||||
<ConfirmDeleteInstanceModal
|
<ConfirmDeleteInstanceModal
|
||||||
ref="deleteConfirmModal"
|
ref="deleteConfirmModal"
|
||||||
:symlink-target="instance.symlink_target"
|
:symlink-target="instance.symlink_target"
|
||||||
@ -448,7 +441,6 @@ const messages = defineMessages({
|
|||||||
id="instance-name"
|
id="instance-name"
|
||||||
v-model="title"
|
v-model="title"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:maxlength="80"
|
|
||||||
wrapper-class="flex-grow"
|
wrapper-class="flex-grow"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -22,7 +22,6 @@ import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
|
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { get_project_versions, get_version } from '@/helpers/cache'
|
import { get_project_versions, get_version } from '@/helpers/cache'
|
||||||
import { type CurseForgeFile, updateManagedCurseForgeModpack } from '@/helpers/curseforge'
|
import { type CurseForgeFile, updateManagedCurseForgeModpack } from '@/helpers/curseforge'
|
||||||
import {
|
import {
|
||||||
@ -333,10 +332,6 @@ provideInstallationSettings({
|
|||||||
afterSave: async () => {
|
afterSave: async () => {
|
||||||
debug('afterSave: installing', { instanceId: instance.value.id })
|
debug('afterSave: installing', { instanceId: instance.value.id })
|
||||||
await install_existing_instance(instance.value.id, false).catch(handleError)
|
await install_existing_instance(instance.value.id, false).catch(handleError)
|
||||||
trackEvent('InstanceRepair', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
})
|
|
||||||
debug('afterSave: done')
|
debug('afterSave: done')
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -345,40 +340,27 @@ provideInstallationSettings({
|
|||||||
repairing.value = true
|
repairing.value = true
|
||||||
await install_existing_instance(instance.value.id, true).catch(handleError)
|
await install_existing_instance(instance.value.id, true).catch(handleError)
|
||||||
repairing.value = false
|
repairing.value = false
|
||||||
trackEvent('InstanceRepair', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
})
|
|
||||||
debug('repair: done')
|
debug('repair: done')
|
||||||
},
|
},
|
||||||
|
|
||||||
async reinstallModpack() {
|
async reinstallModpack() {
|
||||||
debug('reinstallModpack: called', { instanceId: instance.value.id })
|
debug('reinstallModpack: called', { instanceId: instance.value.id })
|
||||||
reinstalling.value = true
|
reinstalling.value = true
|
||||||
let shouldTrack = false
|
|
||||||
try {
|
try {
|
||||||
if (isImportedModpack.value) {
|
if (isImportedModpack.value) {
|
||||||
shouldTrack = await installLocalModpackFromPicker()
|
await installLocalModpackFromPicker()
|
||||||
} else if (isCurseForgeLinkedModpack.value) {
|
} else if (isCurseForgeLinkedModpack.value) {
|
||||||
const fileId = Number(instance.value.link?.version_id)
|
const fileId = Number(instance.value.link?.version_id)
|
||||||
if (!Number.isFinite(fileId)) {
|
if (!Number.isFinite(fileId)) {
|
||||||
throw new Error('Invalid CurseForge file ID')
|
throw new Error('Invalid CurseForge file ID')
|
||||||
}
|
}
|
||||||
await updateManagedCurseForgeModpack(instance.value.id, fileId).catch(handleError)
|
await updateManagedCurseForgeModpack(instance.value.id, fileId).catch(handleError)
|
||||||
shouldTrack = true
|
|
||||||
} else {
|
} else {
|
||||||
await update_repair_modrinth(instance.value.id).catch(handleError)
|
await update_repair_modrinth(instance.value.id).catch(handleError)
|
||||||
shouldTrack = true
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
reinstalling.value = false
|
reinstalling.value = false
|
||||||
}
|
}
|
||||||
if (shouldTrack) {
|
|
||||||
trackEvent('InstanceRepair', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
debug('reinstallModpack: done')
|
debug('reinstallModpack: done')
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -386,13 +368,7 @@ provideInstallationSettings({
|
|||||||
debug('swapModpack: called', { instanceId: instance.value.id })
|
debug('swapModpack: called', { instanceId: instance.value.id })
|
||||||
reinstalling.value = true
|
reinstalling.value = true
|
||||||
try {
|
try {
|
||||||
const installed = await installLocalModpackFromPicker()
|
await installLocalModpackFromPicker()
|
||||||
if (installed) {
|
|
||||||
trackEvent('InstanceRepair', {
|
|
||||||
loader: instance.value.loader,
|
|
||||||
game_version: instance.value.game_version,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
reinstalling.value = false
|
reinstalling.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,145 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ExternalIcon, ShieldIcon, SpinnerIcon } from '@modrinth/assets'
|
|
||||||
import {
|
|
||||||
ButtonStyled,
|
|
||||||
defineMessages,
|
|
||||||
injectNotificationManager,
|
|
||||||
NewModal,
|
|
||||||
Toggle,
|
|
||||||
useVIntl,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
import { type PrivacySettings, savePrivacySettings } from '@/helpers/settings'
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
saved: [privacy: PrivacySettings]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const CONSENT_VERSION = 1
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const { handleError } = injectNotificationManager()
|
|
||||||
const modal = ref<InstanceType<typeof NewModal>>()
|
|
||||||
const telemetry = ref(true)
|
|
||||||
const discordRpc = ref(true)
|
|
||||||
const saving = ref(false)
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
title: {
|
|
||||||
id: 'app.privacy-consent.title',
|
|
||||||
defaultMessage: 'Privacy & security',
|
|
||||||
},
|
|
||||||
intro: {
|
|
||||||
id: 'app.privacy-consent.intro',
|
|
||||||
defaultMessage:
|
|
||||||
'Choose what Starlight may send or display. Nothing is sent until you confirm these choices.',
|
|
||||||
},
|
|
||||||
telemetry: {
|
|
||||||
id: 'app.privacy-consent.telemetry',
|
|
||||||
defaultMessage: 'Allow anonymous telemetry',
|
|
||||||
},
|
|
||||||
telemetryDescription: {
|
|
||||||
id: 'app.privacy-consent.telemetry-description',
|
|
||||||
defaultMessage:
|
|
||||||
'Helps count opted-in installations and daily active users. Full Minecraft logs and account credentials are never uploaded.',
|
|
||||||
},
|
|
||||||
discordRpc: {
|
|
||||||
id: 'app.privacy-consent.discord-rpc',
|
|
||||||
defaultMessage: 'Discord Rich Presence',
|
|
||||||
},
|
|
||||||
discordRpcDescription: {
|
|
||||||
id: 'app.privacy-consent.discord-rpc-description',
|
|
||||||
defaultMessage:
|
|
||||||
'Shows your current launcher or game activity in Discord when Discord is running.',
|
|
||||||
},
|
|
||||||
privacyPolicy: {
|
|
||||||
id: 'app.privacy-consent.privacy-policy',
|
|
||||||
defaultMessage: 'Read the privacy policy',
|
|
||||||
},
|
|
||||||
continue: {
|
|
||||||
id: 'app.privacy-consent.continue',
|
|
||||||
defaultMessage: 'Save and continue',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function show(current: PrivacySettings) {
|
|
||||||
telemetry.value = true
|
|
||||||
discordRpc.value = current.discord_rpc
|
|
||||||
modal.value?.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
if (saving.value) return
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const privacy = await savePrivacySettings({
|
|
||||||
telemetry: telemetry.value,
|
|
||||||
discord_rpc: discordRpc.value,
|
|
||||||
consent_version: CONSENT_VERSION,
|
|
||||||
})
|
|
||||||
modal.value?.hide()
|
|
||||||
emit('saved', privacy)
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error)
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ show })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NewModal ref="modal" :header="formatMessage(messages.title)" :closable="false" max-width="600px">
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<ShieldIcon class="mt-0.5 size-6 shrink-0 text-brand" />
|
|
||||||
<p class="m-0 leading-relaxed text-primary">
|
|
||||||
{{ formatMessage(messages.intro) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-5">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<label for="consent-telemetry" class="font-semibold text-contrast">
|
|
||||||
{{ formatMessage(messages.telemetry) }}
|
|
||||||
</label>
|
|
||||||
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
|
|
||||||
{{ formatMessage(messages.telemetryDescription) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Toggle id="consent-telemetry" v-model="telemetry" :disabled="saving" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-5">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<label for="consent-discord-rpc" class="font-semibold text-contrast">
|
|
||||||
{{ formatMessage(messages.discordRpc) }}
|
|
||||||
</label>
|
|
||||||
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
|
|
||||||
{{ formatMessage(messages.discordRpcDescription) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Toggle id="consent-discord-rpc" v-model="discordRpc" :disabled="saving" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<ButtonStyled type="transparent">
|
|
||||||
<button type="button" :disabled="saving" @click="openUrl('https://axlmc.org/privacy')">
|
|
||||||
<ExternalIcon />
|
|
||||||
{{ formatMessage(messages.privacyPolicy) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button type="button" :disabled="saving" @click="save">
|
|
||||||
<SpinnerIcon v-if="saving" class="animate-spin" />
|
|
||||||
{{ formatMessage(messages.continue) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NewModal>
|
|
||||||
</template>
|
|
||||||
@ -110,7 +110,8 @@ const { formatMessage } = useVIntl()
|
|||||||
color: var(--color-contrast);
|
color: var(--color-contrast);
|
||||||
font-size: 4.5rem;
|
font-size: 4.5rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1;
|
// Keep descenders inside the line box clipped by the wordmark reveal animation.
|
||||||
|
line-height: normal;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
animation: onboarding-welcome-wordmark-reveal 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
animation: onboarding-welcome-wordmark-reveal 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
|||||||
@ -41,24 +41,6 @@ export const onboardingMessages = defineMessages({
|
|||||||
defaultMessage: 'Your last next launcher.',
|
defaultMessage: 'Your last next launcher.',
|
||||||
},
|
},
|
||||||
start: { id: 'app.onboarding.action.start', defaultMessage: 'Take the tour' },
|
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' },
|
discoverTitle: { id: 'app.onboarding.discover.title', defaultMessage: 'Find something new' },
|
||||||
discoverDescription: {
|
discoverDescription: {
|
||||||
id: 'app.onboarding.discover.description',
|
id: 'app.onboarding.discover.description',
|
||||||
@ -90,12 +72,12 @@ export const onboardingMessages = defineMessages({
|
|||||||
},
|
},
|
||||||
homeLayoutTitle: {
|
homeLayoutTitle: {
|
||||||
id: 'app.onboarding.home-layout.title',
|
id: 'app.onboarding.home-layout.title',
|
||||||
defaultMessage: 'Change the amount of detail',
|
defaultMessage: 'Switch your home',
|
||||||
},
|
},
|
||||||
homeLayoutDescription: {
|
homeLayoutDescription: {
|
||||||
id: 'app.onboarding.home-layout.description',
|
id: 'app.onboarding.home-layout.description',
|
||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Use the bottom-right control to switch between Information Home and Minimal Home. Widget editing stays with Information Home.',
|
'Use the bottom-right control to switch between the StarLight skin site home and the focused instance launcher.',
|
||||||
},
|
},
|
||||||
continueArea: {
|
continueArea: {
|
||||||
id: 'app.onboarding.action.continue-area',
|
id: 'app.onboarding.action.continue-area',
|
||||||
@ -105,7 +87,7 @@ export const onboardingMessages = defineMessages({
|
|||||||
skinsDescription: {
|
skinsDescription: {
|
||||||
id: 'app.onboarding.skins.description',
|
id: 'app.onboarding.skins.description',
|
||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Keep your Minecraft skins together. Signing in can wait until you feel like it.',
|
'Choose a skin site player or Minecraft account, then preview and apply the skins available to that profile.',
|
||||||
},
|
},
|
||||||
clickSkins: {
|
clickSkins: {
|
||||||
id: 'app.onboarding.action.click-skins',
|
id: 'app.onboarding.action.click-skins',
|
||||||
@ -114,16 +96,8 @@ export const onboardingMessages = defineMessages({
|
|||||||
skinsPageTitle: { id: 'app.onboarding.skins-page.title', defaultMessage: 'Your skin drawer' },
|
skinsPageTitle: { id: 'app.onboarding.skins-page.title', defaultMessage: 'Your skin drawer' },
|
||||||
skinsPageDescription: {
|
skinsPageDescription: {
|
||||||
id: 'app.onboarding.skins-page.description',
|
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:
|
defaultMessage:
|
||||||
'When you are ready, sign in, switch accounts, or open your profile here. No deadline.',
|
'Select a profile, preview its available skins, and apply changes when that account supports skin management.',
|
||||||
},
|
},
|
||||||
downloadsTitle: { id: 'app.onboarding.downloads.title', defaultMessage: 'Download control room' },
|
downloadsTitle: { id: 'app.onboarding.downloads.title', defaultMessage: 'Download control room' },
|
||||||
downloadsDescription: {
|
downloadsDescription: {
|
||||||
@ -160,35 +134,6 @@ export const onboardingMessages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Theme, accent, backgrounds, and window effects all live here. Make the launcher feel familiar.',
|
'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' },
|
defaultsTitle: { id: 'app.onboarding.defaults.title', defaultMessage: 'Start ahead' },
|
||||||
defaultsDescription: {
|
defaultsDescription: {
|
||||||
id: 'app.onboarding.defaults.description',
|
id: 'app.onboarding.defaults.description',
|
||||||
@ -204,20 +149,6 @@ export const onboardingMessages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Choose how content downloads and installs, from download sources to safety checks.',
|
'Choose how content downloads and installs, from download sources to safety checks.',
|
||||||
},
|
},
|
||||||
privacyTitle: {
|
|
||||||
id: 'app.onboarding.privacy.title',
|
|
||||||
defaultMessage: 'Your data, your call',
|
|
||||||
},
|
|
||||||
privacyDescription: {
|
|
||||||
id: 'app.onboarding.privacy.description',
|
|
||||||
defaultMessage:
|
|
||||||
'Manage anonymous telemetry, Discord Rich Presence, and the Minecraft log analysis service whenever you need to.',
|
|
||||||
},
|
|
||||||
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' },
|
clickTab: { id: 'app.onboarding.action.click-tab', defaultMessage: 'Click this tab to continue' },
|
||||||
libraryTitle: { id: 'app.onboarding.library.title', defaultMessage: 'Your launch shelf' },
|
libraryTitle: { id: 'app.onboarding.library.title', defaultMessage: 'Your launch shelf' },
|
||||||
libraryDescription: {
|
libraryDescription: {
|
||||||
@ -236,7 +167,7 @@ export const onboardingMessages = defineMessages({
|
|||||||
libraryPageDescription: {
|
libraryPageDescription: {
|
||||||
id: 'app.onboarding.library-page.description',
|
id: 'app.onboarding.library-page.description',
|
||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Filter by modpack, server, or custom setup, then open any instance to manage it.',
|
'Switch between all instances, modpacks, and custom setups, then open any instance to manage it.',
|
||||||
},
|
},
|
||||||
createTitle: { id: 'app.onboarding.create.title', defaultMessage: 'Make a fresh start' },
|
createTitle: { id: 'app.onboarding.create.title', defaultMessage: 'Make a fresh start' },
|
||||||
createDescription: {
|
createDescription: {
|
||||||
@ -248,6 +179,15 @@ export const onboardingMessages = defineMessages({
|
|||||||
defaultMessage: 'Click Create new instance to continue',
|
defaultMessage: 'Click Create new instance to continue',
|
||||||
},
|
},
|
||||||
creationTitle: { id: 'app.onboarding.creation.title', defaultMessage: 'Pick your route' },
|
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: {
|
creationDescription: {
|
||||||
id: 'app.onboarding.creation.description',
|
id: 'app.onboarding.creation.description',
|
||||||
defaultMessage:
|
defaultMessage:
|
||||||
@ -302,7 +242,7 @@ export const onboardingMessages = defineMessages({
|
|||||||
instanceActionsDescription: {
|
instanceActionsDescription: {
|
||||||
id: 'app.onboarding.instance-actions.description',
|
id: 'app.onboarding.instance-actions.description',
|
||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Launch, stop, repair, configure, export, or open the instance from its header.',
|
'Launch or configure this instance here. Choose a player on first launch; the instance remembers your choice until you switch it in settings.',
|
||||||
},
|
},
|
||||||
instanceTabsTitle: {
|
instanceTabsTitle: {
|
||||||
id: 'app.onboarding.instance-tabs.title',
|
id: 'app.onboarding.instance-tabs.title',
|
||||||
@ -319,7 +259,7 @@ export const onboardingMessages = defineMessages({
|
|||||||
labDescription: {
|
labDescription: {
|
||||||
id: 'app.onboarding.lab.description',
|
id: 'app.onboarding.lab.description',
|
||||||
defaultMessage:
|
defaultMessage:
|
||||||
'The Lab keeps local Minecraft tools inside the launcher, without another website or account.',
|
'The Lab keeps Minecraft creation, world, and maintenance tools inside the launcher.',
|
||||||
},
|
},
|
||||||
clickLab: {
|
clickLab: {
|
||||||
id: 'app.onboarding.action.click-lab',
|
id: 'app.onboarding.action.click-lab',
|
||||||
@ -332,63 +272,7 @@ export const onboardingMessages = defineMessages({
|
|||||||
labToolsDescription: {
|
labToolsDescription: {
|
||||||
id: 'app.onboarding.lab-tools.description',
|
id: 'app.onboarding.lab-tools.description',
|
||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Create formatted text and recipe data packs, explore Java worlds, and inspect schematic builds without leaving the launcher.',
|
'Create and edit skins, generate formatted text and recipes, explore seeds, inspect schematics, and translate mods locally.',
|
||||||
},
|
|
||||||
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' },
|
skip: { id: 'app.onboarding.action.skip', defaultMessage: 'Leave the tour' },
|
||||||
mascotAlt: { id: 'app.onboarding.mascot-alt', defaultMessage: 'Starlight guide' },
|
mascotAlt: { id: 'app.onboarding.mascot-alt', defaultMessage: 'Starlight guide' },
|
||||||
@ -456,18 +340,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
|||||||
onboardingMessages.start,
|
onboardingMessages.start,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
inspect(
|
|
||||||
'home-widget-grid',
|
|
||||||
'home-widget-grid',
|
|
||||||
onboardingMessages.homeWidgetsTitle,
|
|
||||||
onboardingMessages.homeWidgetsDescription,
|
|
||||||
),
|
|
||||||
inspect(
|
|
||||||
'home-widget-customize',
|
|
||||||
'home-widget-customize',
|
|
||||||
onboardingMessages.homeCustomizeTitle,
|
|
||||||
onboardingMessages.homeCustomizeDescription,
|
|
||||||
),
|
|
||||||
inspect(
|
inspect(
|
||||||
'home-layout-switch',
|
'home-layout-switch',
|
||||||
'home-layout-switch',
|
'home-layout-switch',
|
||||||
@ -522,16 +394,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
|||||||
onboardingMessages.skinsPageTitle,
|
onboardingMessages.skinsPageTitle,
|
||||||
onboardingMessages.skinsPageDescription,
|
onboardingMessages.skinsPageDescription,
|
||||||
),
|
),
|
||||||
step(
|
|
||||||
'account',
|
|
||||||
'inspect',
|
|
||||||
copy(
|
|
||||||
onboardingMessages.accountTitle,
|
|
||||||
onboardingMessages.accountDescription,
|
|
||||||
onboardingMessages.continueArea,
|
|
||||||
),
|
|
||||||
control('account-entry'),
|
|
||||||
),
|
|
||||||
step(
|
step(
|
||||||
'lab-navigation',
|
'lab-navigation',
|
||||||
'navigate',
|
'navigate',
|
||||||
@ -548,90 +410,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
|||||||
onboardingMessages.labToolsTitle,
|
onboardingMessages.labToolsTitle,
|
||||||
onboardingMessages.labToolsDescription,
|
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(
|
step(
|
||||||
'downloads-navigation',
|
'downloads-navigation',
|
||||||
'navigate',
|
'navigate',
|
||||||
@ -690,6 +468,12 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
|||||||
),
|
),
|
||||||
control('create-instance', '/create'),
|
control('create-instance', '/create'),
|
||||||
),
|
),
|
||||||
|
inspect(
|
||||||
|
'creation-instance-mode',
|
||||||
|
'creation-instance-mode',
|
||||||
|
onboardingMessages.instanceModeTitle,
|
||||||
|
onboardingMessages.instanceModeDescription,
|
||||||
|
),
|
||||||
step(
|
step(
|
||||||
'creation-flow',
|
'creation-flow',
|
||||||
'activate',
|
'activate',
|
||||||
@ -701,6 +485,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
|||||||
{
|
{
|
||||||
targetId: 'creation-methods',
|
targetId: 'creation-methods',
|
||||||
branchByTarget: {
|
branchByTarget: {
|
||||||
|
'creation-method-starlight': { next: 'complete' },
|
||||||
'creation-method-custom': { creationPath: 'custom', next: 'creation-name' },
|
'creation-method-custom': { creationPath: 'custom', next: 'creation-name' },
|
||||||
'creation-method-import': { next: 'complete' },
|
'creation-method-import': { next: 'complete' },
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,20 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import { ChevronDownIcon, ExternalIcon, ScaleIcon, UsersIcon } from '@modrinth/assets'
|
||||||
|
|
||||||
ChevronDownIcon,
|
|
||||||
ExternalIcon,
|
|
||||||
ScaleIcon,
|
|
||||||
UsersIcon,
|
|
||||||
} from '@modrinth/assets'
|
|
||||||
import { Avatar, defineMessages, NewButton as Button, useVIntl } from '@modrinth/ui'
|
import { Avatar, defineMessages, NewButton as Button, useVIntl } from '@modrinth/ui'
|
||||||
import { getVersion } from '@tauri-apps/api/app'
|
import { getVersion } from '@tauri-apps/api/app'
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
import { inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
|
import { defineAsyncComponent, inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
|
||||||
|
|
||||||
import EasterEggContributorsModal from '@/components/ui/easteregg/EasterEggContributorsModal.vue'
|
import EasterEggContributorsModal from '@/components/ui/easteregg/EasterEggContributorsModal.vue'
|
||||||
import EasterEggGameModal from '@/components/ui/easteregg/EasterEggGameModal.vue'
|
import EasterEggGameModal from '@/components/ui/easteregg/EasterEggGameModal.vue'
|
||||||
import { AxolotlBrandConfig } from '@/config'
|
import { AxolotlBrandConfig } from '@/config'
|
||||||
|
import { contributors, type TeamMember, teamMembers } from '@/data/about'
|
||||||
import { contributors, teamMembers, type TeamMember } from '@/data/about'
|
|
||||||
|
|
||||||
import AboutScene from '../AboutScene.vue'
|
import AboutScene from '../AboutScene.vue'
|
||||||
import { type AboutMemberExperience, getAboutMemberExperience } from './about-member-experiences'
|
import { type AboutMemberExperience, getAboutMemberExperience } from './about-member-experiences'
|
||||||
@ -22,9 +16,9 @@ import { type AboutMemberExperience, getAboutMemberExperience } from './about-me
|
|||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const version = await getVersion()
|
const version = await getVersion()
|
||||||
const experienceHost = ref<HTMLElement>()
|
const experienceHost = ref<HTMLElement>()
|
||||||
const activeMemberExperience = shallowRef<AboutMemberExperience>()
|
const activeMemberExperience = shallowRef<Extract<AboutMemberExperience, { kind: 'scene' }>>()
|
||||||
const pressingMemberName = ref<string>()
|
const pressingMemberName = ref<string>()
|
||||||
let longPressTimer: ReturnType<typeof window.setTimeout> | undefined
|
let longPressTimer: number | undefined
|
||||||
let pressStart = { x: 0, y: 0 }
|
let pressStart = { x: 0, y: 0 }
|
||||||
let suppressNextMemberClick = false
|
let suppressNextMemberClick = false
|
||||||
const replayOnboarding = inject<(mode: 'main' | 'instance') => Promise<void>>('replayOnboarding')
|
const replayOnboarding = inject<(mode: 'main' | 'instance') => Promise<void>>('replayOnboarding')
|
||||||
@ -46,9 +40,13 @@ function startMemberLongPress(member: TeamMember, event: PointerEvent) {
|
|||||||
pressStart = { x: event.clientX, y: event.clientY }
|
pressStart = { x: event.clientX, y: event.clientY }
|
||||||
pressingMemberName.value = member.name
|
pressingMemberName.value = member.name
|
||||||
longPressTimer = window.setTimeout(async () => {
|
longPressTimer = window.setTimeout(async () => {
|
||||||
activeMemberExperience.value = experience
|
|
||||||
suppressNextMemberClick = true
|
suppressNextMemberClick = true
|
||||||
cancelMemberLongPress()
|
cancelMemberLongPress()
|
||||||
|
if (experience.kind === 'color-mine') {
|
||||||
|
openColorMine()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeMemberExperience.value = experience
|
||||||
await nextTick()
|
await nextTick()
|
||||||
experienceHost.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
experienceHost.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
}, experience.longPressDuration)
|
}, experience.longPressDuration)
|
||||||
@ -78,6 +76,24 @@ function closeMemberExperience() {
|
|||||||
|
|
||||||
const gameModal = ref<InstanceType<typeof EasterEggGameModal> | null>(null)
|
const gameModal = ref<InstanceType<typeof EasterEggGameModal> | null>(null)
|
||||||
const contributorsModal = ref<InstanceType<typeof EasterEggContributorsModal> | null>(null)
|
const contributorsModal = ref<InstanceType<typeof EasterEggContributorsModal> | null>(null)
|
||||||
|
const ColorMineModal = defineAsyncComponent(
|
||||||
|
() => import('@/components/ui/easteregg/color-mine/ColorMineModal.vue'),
|
||||||
|
)
|
||||||
|
const colorMineModal = ref<{ show: () => void }>()
|
||||||
|
const colorMineMounted = ref(false)
|
||||||
|
const colorMinePending = ref(false)
|
||||||
|
|
||||||
|
function openColorMine() {
|
||||||
|
colorMineMounted.value = true
|
||||||
|
if (colorMineModal.value) colorMineModal.value.show()
|
||||||
|
else colorMinePending.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorMineReady() {
|
||||||
|
if (!colorMinePending.value) return
|
||||||
|
colorMinePending.value = false
|
||||||
|
colorMineModal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
let typedBuffer = ''
|
let typedBuffer = ''
|
||||||
const secretCodes = ['starlight']
|
const secretCodes = ['starlight']
|
||||||
@ -166,7 +182,8 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
attribution: {
|
attribution: {
|
||||||
id: 'app.settings.about.attribution',
|
id: 'app.settings.about.attribution',
|
||||||
defaultMessage: 'Starlight Launcher is a modified version of the Axolotl Launcher, which is based on the open-source Modrinth codebase.',
|
defaultMessage:
|
||||||
|
'Starlight Launcher is a modified version of the Axolotl Launcher, which is based on the open-source Modrinth codebase.',
|
||||||
},
|
},
|
||||||
notAffiliated: {
|
notAffiliated: {
|
||||||
id: 'app.settings.about.not-affiliated',
|
id: 'app.settings.about.not-affiliated',
|
||||||
@ -194,7 +211,6 @@ const messages = defineMessages({
|
|||||||
defaultMessage: '{count, plural, one {# contributor} other {# contributors}}',
|
defaultMessage: '{count, plural, one {# contributor} other {# contributors}}',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -203,13 +219,13 @@ const messages = defineMessages({
|
|||||||
<div class="flex flex-col items-center gap-4">
|
<div class="flex flex-col items-center gap-4">
|
||||||
<div
|
<div
|
||||||
ref="experienceHost"
|
ref="experienceHost"
|
||||||
class="relative m-0 w-full overflow-hidden h-64 rounded-xl"
|
class="relative m-0 aspect-[3/1] w-full overflow-hidden rounded-xl"
|
||||||
style="
|
style="
|
||||||
mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
|
mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
|
||||||
-webkit-mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
|
-webkit-mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<AboutScene />
|
<AboutScene :paused="Boolean(activeMemberExperience)" />
|
||||||
<component
|
<component
|
||||||
:is="activeMemberExperience?.component"
|
:is="activeMemberExperience?.component"
|
||||||
v-if="activeMemberExperience"
|
v-if="activeMemberExperience"
|
||||||
@ -237,7 +253,6 @@ const messages = defineMessages({
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 class="m-0 mb-3 flex items-center gap-2 text-base font-semibold text-contrast">
|
<h3 class="m-0 mb-3 flex items-center gap-2 text-base font-semibold text-contrast">
|
||||||
<ScaleIcon class="size-5 text-secondary" />
|
<ScaleIcon class="size-5 text-secondary" />
|
||||||
@ -295,12 +310,16 @@ const messages = defineMessages({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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="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)"
|
@pointerdown="startMemberLongPress(member, $event)"
|
||||||
@pointermove="moveMemberLongPress"
|
@pointermove="moveMemberLongPress"
|
||||||
@pointerup="cancelMemberLongPress"
|
@pointerup="cancelMemberLongPress"
|
||||||
|
@pointercancel="cancelMemberLongPress"
|
||||||
@pointerleave="cancelMemberLongPress"
|
@pointerleave="cancelMemberLongPress"
|
||||||
|
@blur="cancelMemberLongPress"
|
||||||
@click="handleMemberClick"
|
@click="handleMemberClick"
|
||||||
@contextmenu="handleMemberContextMenu(member, $event)"
|
@contextmenu="handleMemberContextMenu(member, $event)"
|
||||||
|
@dragstart.prevent
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
:src="member.avatarUrl"
|
:src="member.avatarUrl"
|
||||||
@ -360,6 +379,7 @@ const messages = defineMessages({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EasterEggGameModal ref="gameModal" />
|
<EasterEggGameModal ref="gameModal" />
|
||||||
|
<ColorMineModal v-if="colorMineMounted" ref="colorMineModal" @vue:mounted="colorMineReady" />
|
||||||
<EasterEggContributorsModal ref="contributorsModal" @open-game="onEasterEggOpenGame" />
|
<EasterEggContributorsModal ref="contributorsModal" @open-game="onEasterEggOpenGame" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -381,6 +401,11 @@ const messages = defineMessages({
|
|||||||
padding: var(--gap-lg);
|
padding: var(--gap-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-card-holding {
|
||||||
|
outline: 2px solid var(--color-brand);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.about-page :deep(.rounded-xl.bg-surface-4) {
|
.about-page :deep(.rounded-xl.bg-surface-4) {
|
||||||
border: 1px solid
|
border: 1px solid
|
||||||
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
|
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
|
||||||
|
|||||||
@ -12,6 +12,15 @@ import SettingsSection from './SettingsSection.vue'
|
|||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
const messages = defineMessages({
|
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' },
|
fullscreen: { id: 'app.settings.defaults.fullscreen', defaultMessage: 'Fullscreen' },
|
||||||
fullscreenDescription: {
|
fullscreenDescription: {
|
||||||
id: 'app.settings.defaults.fullscreen-description',
|
id: 'app.settings.defaults.fullscreen-description',
|
||||||
@ -205,6 +214,24 @@ watch(
|
|||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsSection>
|
</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>
|
<SettingsSection>
|
||||||
<SettingsRow stacked>
|
<SettingsRow stacked>
|
||||||
<template #label>
|
<template #label>
|
||||||
|
|||||||
@ -21,7 +21,6 @@ import JetBrainsLogo from '@/assets/java-vendors/jetbrains.png'
|
|||||||
import MicrosoftLogo from '@/assets/java-vendors/microsoft.png'
|
import MicrosoftLogo from '@/assets/java-vendors/microsoft.png'
|
||||||
import OracleLogo from '@/assets/java-vendors/oracle.png'
|
import OracleLogo from '@/assets/java-vendors/oracle.png'
|
||||||
import SapLogo from '@/assets/java-vendors/sap.png'
|
import SapLogo from '@/assets/java-vendors/sap.png'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { download_java, list_java_feed_vendors, list_java_feed_versions } from '@/helpers/jre'
|
import { download_java, list_java_feed_vendors, list_java_feed_versions } from '@/helpers/jre'
|
||||||
|
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
@ -117,7 +116,6 @@ function backToVendors() {
|
|||||||
|
|
||||||
async function downloadVersion(info) {
|
async function downloadVersion(info) {
|
||||||
downloading.value = info.major_version
|
downloading.value = info.major_version
|
||||||
trackEvent('JavaDownload', { vendor: info.vendor, version: info.major_version })
|
|
||||||
modal.value?.hide()
|
modal.value?.hide()
|
||||||
|
|
||||||
const job = await download_java(info.vendor, info.major_version).catch(handleError)
|
const job = await download_java(info.vendor, info.major_version).catch(handleError)
|
||||||
|
|||||||
@ -23,7 +23,6 @@ const { formatMessage } = useVIntl()
|
|||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
const isDevEnvironment = await isDev()
|
const isDevEnvironment = await isDev()
|
||||||
const previewMinecraftCrashModal = inject<() => void>('previewMinecraftCrashModal')
|
const previewMinecraftCrashModal = inject<() => void>('previewMinecraftCrashModal')
|
||||||
const previewPrivacyConsentModal = inject<() => Promise<void>>('previewPrivacyConsentModal')
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
resetToDefault: {
|
resetToDefault: {
|
||||||
id: 'app.settings.feature-flags.reset-to-default',
|
id: 'app.settings.feature-flags.reset-to-default',
|
||||||
@ -53,10 +52,6 @@ const messages = defineMessages({
|
|||||||
id: 'app.settings.about.preview-minecraft-crash-modal',
|
id: 'app.settings.about.preview-minecraft-crash-modal',
|
||||||
defaultMessage: 'Preview Minecraft crash window',
|
defaultMessage: 'Preview Minecraft crash window',
|
||||||
},
|
},
|
||||||
previewPrivacyConsentModal: {
|
|
||||||
id: 'app.settings.about.preview-privacy-consent-modal',
|
|
||||||
defaultMessage: 'Preview privacy & security modal',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const settings = ref(await getSettings())
|
const settings = ref(await getSettings())
|
||||||
@ -127,9 +122,6 @@ watch(
|
|||||||
<Button type="base" @click="previewMinecraftCrashModal?.()">
|
<Button type="base" @click="previewMinecraftCrashModal?.()">
|
||||||
<WrenchIcon /> {{ formatMessage(messages.previewMinecraftCrashModal) }}
|
<WrenchIcon /> {{ formatMessage(messages.previewMinecraftCrashModal) }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="base" @click="previewPrivacyConsentModal?.()">
|
|
||||||
<WrenchIcon /> {{ formatMessage(messages.previewPrivacyConsentModal) }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -19,7 +19,6 @@ import MemoryAllocationDisplay from '@/components/ui/MemoryAllocationDisplay.vue
|
|||||||
import DownloadJavaModal from '@/components/ui/settings/DownloadJavaModal.vue'
|
import DownloadJavaModal from '@/components/ui/settings/DownloadJavaModal.vue'
|
||||||
import InstalledJavaModal from '@/components/ui/settings/InstalledJavaModal.vue'
|
import InstalledJavaModal from '@/components/ui/settings/InstalledJavaModal.vue'
|
||||||
import useMemorySlider from '@/composables/useMemorySlider'
|
import useMemorySlider from '@/composables/useMemorySlider'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { collectGcContext } from '@/helpers/gc/context'
|
import { collectGcContext } from '@/helpers/gc/context'
|
||||||
import { wait_for_install_job } from '@/helpers/install'
|
import { wait_for_install_job } from '@/helpers/install'
|
||||||
import { getJavaArgumentPresets } from '@/helpers/java-argument-presets'
|
import { getJavaArgumentPresets } from '@/helpers/java-argument-presets'
|
||||||
@ -249,7 +248,6 @@ async function runScan(exhaustive) {
|
|||||||
|
|
||||||
scanning.value = true
|
scanning.value = true
|
||||||
scanMode.value = 'quick'
|
scanMode.value = 'quick'
|
||||||
trackEvent('JavaQuickScan', { source: 'settings' })
|
|
||||||
try {
|
try {
|
||||||
await find_filtered_jres(null, false, true, false).catch(handleError)
|
await find_filtered_jres(null, false, true, false).catch(handleError)
|
||||||
} finally {
|
} finally {
|
||||||
@ -262,7 +260,6 @@ async function confirmDeepScan() {
|
|||||||
showDeepScanConfirm.value = false
|
showDeepScanConfirm.value = false
|
||||||
scanning.value = true
|
scanning.value = true
|
||||||
scanMode.value = 'deep'
|
scanMode.value = 'deep'
|
||||||
trackEvent('JavaDeepScan', { source: 'settings' })
|
|
||||||
try {
|
try {
|
||||||
await find_filtered_jres(null, true, true, true).catch(handleError)
|
await find_filtered_jres(null, true, true, true).catch(handleError)
|
||||||
} finally {
|
} finally {
|
||||||
@ -280,7 +277,6 @@ async function handleManualAdd() {
|
|||||||
if (!javaInfo) return
|
if (!javaInfo) return
|
||||||
|
|
||||||
await set_java_version(javaInfo).catch(handleError)
|
await set_java_version(javaInfo).catch(handleError)
|
||||||
trackEvent('JavaManualSelect', { path: filePath })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onJavaDownloaded(job) {
|
async function onJavaDownloaded(job) {
|
||||||
|
|||||||
@ -1,156 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { defineMessages, injectNotificationManager, Toggle, useVIntl } from '@modrinth/ui'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
|
|
||||||
import { getPrivacySettings, setDiscordRpcEnabled, setTelemetryEnabled } from '@/helpers/settings'
|
|
||||||
|
|
||||||
import SettingsRow from './SettingsRow.vue'
|
|
||||||
import SettingsSaveStatus from './SettingsSaveStatus.vue'
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
|
||||||
const { handleError } = injectNotificationManager()
|
|
||||||
const privacy = ref(await getPrivacySettings())
|
|
||||||
const telemetrySaving = ref(false)
|
|
||||||
const discordSaving = ref(false)
|
|
||||||
const lastSaveState = ref<'idle' | 'saved' | 'error'>('idle')
|
|
||||||
const retrySave = ref<(() => void) | undefined>()
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
telemetry: {
|
|
||||||
id: 'app.settings.privacy.telemetry',
|
|
||||||
defaultMessage: 'Allow telemetry',
|
|
||||||
},
|
|
||||||
telemetryDescription: {
|
|
||||||
id: 'app.settings.privacy.telemetry-description',
|
|
||||||
defaultMessage:
|
|
||||||
'Send one anonymous daily activity signal to improve usage statistics. Minecraft logs and account credentials are never uploaded.',
|
|
||||||
},
|
|
||||||
discordRpc: {
|
|
||||||
id: 'app.settings.privacy.discord-rpc',
|
|
||||||
defaultMessage: 'Discord Rich Presence',
|
|
||||||
},
|
|
||||||
discordRpcDescription: {
|
|
||||||
id: 'app.settings.privacy.discord-rpc-description',
|
|
||||||
defaultMessage: 'Show your current launcher or game activity in Discord.',
|
|
||||||
},
|
|
||||||
dataHandling: {
|
|
||||||
id: 'app.settings.privacy.data-handling',
|
|
||||||
defaultMessage:
|
|
||||||
'Telemetry uses a random installation identifier and sends only a daily activity signal. Turning telemetry off clears pending data immediately.',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const saveStatus = computed(() => {
|
|
||||||
if (telemetrySaving.value || discordSaving.value) return 'saving'
|
|
||||||
return lastSaveState.value
|
|
||||||
})
|
|
||||||
|
|
||||||
async function updateTelemetry(value: boolean) {
|
|
||||||
if (telemetrySaving.value) return
|
|
||||||
const previous = privacy.value.telemetry
|
|
||||||
privacy.value.telemetry = value
|
|
||||||
telemetrySaving.value = true
|
|
||||||
lastSaveState.value = 'idle'
|
|
||||||
retrySave.value = undefined
|
|
||||||
try {
|
|
||||||
const saved = await setTelemetryEnabled(value)
|
|
||||||
privacy.value.telemetry = saved.telemetry
|
|
||||||
privacy.value.consent_version = saved.consent_version
|
|
||||||
lastSaveState.value = 'saved'
|
|
||||||
} catch (error) {
|
|
||||||
privacy.value.telemetry = previous
|
|
||||||
retrySave.value = () => void updateTelemetry(value)
|
|
||||||
lastSaveState.value = 'error'
|
|
||||||
handleError(error)
|
|
||||||
} finally {
|
|
||||||
telemetrySaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateDiscordRpc(value: boolean) {
|
|
||||||
if (discordSaving.value) return
|
|
||||||
const previous = privacy.value.discord_rpc
|
|
||||||
privacy.value.discord_rpc = value
|
|
||||||
discordSaving.value = true
|
|
||||||
lastSaveState.value = 'idle'
|
|
||||||
retrySave.value = undefined
|
|
||||||
try {
|
|
||||||
const saved = await setDiscordRpcEnabled(value)
|
|
||||||
privacy.value.discord_rpc = saved.discord_rpc
|
|
||||||
lastSaveState.value = 'saved'
|
|
||||||
} catch (error) {
|
|
||||||
privacy.value.discord_rpc = previous
|
|
||||||
retrySave.value = () => void updateDiscordRpc(value)
|
|
||||||
lastSaveState.value = 'error'
|
|
||||||
handleError(error)
|
|
||||||
} finally {
|
|
||||||
discordSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex w-full flex-col gap-4">
|
|
||||||
<header class="settings-page-header">
|
|
||||||
<SettingsSaveStatus :status="saveStatus" :retry="retrySave" />
|
|
||||||
</header>
|
|
||||||
<div class="settings-page-card">
|
|
||||||
<SettingsRow>
|
|
||||||
<template #label>
|
|
||||||
<span id="settings-target-privacy-telemetry" tabindex="-1">
|
|
||||||
{{ formatMessage(messages.telemetry) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template #description>{{ formatMessage(messages.telemetryDescription) }}</template>
|
|
||||||
<template #control>
|
|
||||||
<Toggle
|
|
||||||
id="privacy-telemetry"
|
|
||||||
:model-value="privacy.telemetry"
|
|
||||||
:disabled="telemetrySaving"
|
|
||||||
@update:model-value="(value) => updateTelemetry(!!value)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</SettingsRow>
|
|
||||||
<SettingsRow>
|
|
||||||
<template #label>
|
|
||||||
<span id="settings-target-privacy-discord-rpc" tabindex="-1">
|
|
||||||
{{ formatMessage(messages.discordRpc) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template #description>{{ formatMessage(messages.discordRpcDescription) }}</template>
|
|
||||||
<template #control>
|
|
||||||
<Toggle
|
|
||||||
id="privacy-discord-rpc"
|
|
||||||
:model-value="privacy.discord_rpc"
|
|
||||||
:disabled="discordSaving"
|
|
||||||
@update:model-value="(value) => updateDiscordRpc(!!value)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</SettingsRow>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="settings-page-note">{{ formatMessage(messages.dataHandling) }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.settings-page-card {
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid
|
|
||||||
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--surface-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-page-header {
|
|
||||||
display: flex;
|
|
||||||
min-height: 0;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-page-note {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-secondary);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -294,7 +294,9 @@ async function loadLatestChannelVersions() {
|
|||||||
const versions = await Promise.all(
|
const versions = await Promise.all(
|
||||||
(['release', 'beta'] as const).map(async (channel) => {
|
(['release', 'beta'] as const).map(async (channel) => {
|
||||||
try {
|
try {
|
||||||
const response = await tauriFetch(`https://update.axlmc.org/latest?channel=${channel}`)
|
const response = await tauriFetch(
|
||||||
|
`https://skin.starlight.cool/starlight/launcher/latest?channel=${channel}`,
|
||||||
|
)
|
||||||
if (!response.ok) return [channel, undefined] as const
|
if (!response.ok) return [channel, undefined] as const
|
||||||
const payload = (await response.json()) as { version?: string }
|
const payload = (await response.json()) as { version?: string }
|
||||||
return [channel, payload.version] as const
|
return [channel, payload.version] as const
|
||||||
|
|||||||
@ -1,21 +1,31 @@
|
|||||||
|
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
import AboutEasterEgg from '../AboutEasterEgg.vue'
|
import AboutEasterEgg from '../AboutEasterEgg.vue'
|
||||||
|
|
||||||
export type AboutMemberExperience = {
|
export type AboutMemberExperience =
|
||||||
component: Component
|
| {
|
||||||
longPressDuration: number
|
kind: 'scene'
|
||||||
}
|
component: Component
|
||||||
|
longPressDuration: number
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'color-mine'
|
||||||
|
longPressDuration: number
|
||||||
|
}
|
||||||
|
|
||||||
// 长按「关于」页成员名字触发的彩蛋体验。
|
// 长按「关于」页成员卡片触发的彩蛋体验。
|
||||||
// 下界之星合成彩蛋(AboutEasterEgg.vue),由长按成员名 / 暗号 / Konami 秘技触发。
|
// 下界之星合成彩蛋(AboutEasterEgg.vue),由长按成员名 / 暗号 / Konami 秘技触发。
|
||||||
// 若要为特定成员挂载自定义彩蛋,在这里新增条目即可。
|
// 若要为特定成员挂载自定义彩蛋,在这里新增条目即可。
|
||||||
const memberExperiences: Record<string, AboutMemberExperience> = {
|
const memberExperiences: Record<string, AboutMemberExperience> = {
|
||||||
'easter-egg': {
|
'easter-egg': {
|
||||||
|
kind: 'scene',
|
||||||
component: AboutEasterEgg,
|
component: AboutEasterEgg,
|
||||||
longPressDuration: 800,
|
longPressDuration: 800,
|
||||||
},
|
},
|
||||||
|
'color-mine': {
|
||||||
|
kind: 'color-mine',
|
||||||
|
longPressDuration: 800,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAboutMemberExperience(experience: unknown): AboutMemberExperience | undefined {
|
export function getAboutMemberExperience(experience: unknown): AboutMemberExperience | undefined {
|
||||||
|
|||||||
@ -10,7 +10,6 @@ export type SettingsCategoryId =
|
|||||||
| 'content-downloads'
|
| 'content-downloads'
|
||||||
| 'network-multiplayer'
|
| 'network-multiplayer'
|
||||||
| 'storage-backups'
|
| 'storage-backups'
|
||||||
| 'privacy-data'
|
|
||||||
| 'updates'
|
| 'updates'
|
||||||
| 'about'
|
| 'about'
|
||||||
| 'feature-flags'
|
| 'feature-flags'
|
||||||
@ -106,15 +105,6 @@ export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
|
|||||||
group: 'data-privacy',
|
group: 'data-privacy',
|
||||||
onboardingId: 'settings-tab-storage-backups',
|
onboardingId: 'settings-tab-storage-backups',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'privacy-data',
|
|
||||||
name: defineMessage({
|
|
||||||
id: 'app.settings.tabs.privacy-data',
|
|
||||||
defaultMessage: 'Privacy & data sharing',
|
|
||||||
}),
|
|
||||||
group: 'data-privacy',
|
|
||||||
onboardingId: 'settings-tab-privacy-data',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'updates',
|
id: 'updates',
|
||||||
name: defineMessage({ id: 'app.settings.tabs.updates', defaultMessage: 'Updates' }),
|
name: defineMessage({ id: 'app.settings.tabs.updates', defaultMessage: 'Updates' }),
|
||||||
|
|||||||
@ -72,10 +72,6 @@ const categoryContent: Record<SettingsCategoryId, Pick<SettingsCategory, 'icon'
|
|||||||
icon: ArchiveIcon,
|
icon: ArchiveIcon,
|
||||||
content: defineAsyncComponent(() => import('./StorageBackupSettings.vue')),
|
content: defineAsyncComponent(() => import('./StorageBackupSettings.vue')),
|
||||||
},
|
},
|
||||||
'privacy-data': {
|
|
||||||
icon: ShieldIcon,
|
|
||||||
content: defineAsyncComponent(() => import('./PrivacySettings.vue')),
|
|
||||||
},
|
|
||||||
updates: {
|
updates: {
|
||||||
icon: RefreshCwIcon,
|
icon: RefreshCwIcon,
|
||||||
content: defineAsyncComponent(() => import('./UpdateSettings.vue')),
|
content: defineAsyncComponent(() => import('./UpdateSettings.vue')),
|
||||||
|
|||||||
@ -221,18 +221,6 @@ export const settingsSearchEntries: SettingsSearchEntry[] = [
|
|||||||
label: message('app.crash-analysis.ai.settings.title', 'Crash AI explanation'),
|
label: message('app.crash-analysis.ai.settings.title', 'Crash AI explanation'),
|
||||||
keywords: [message('app.settings.tabs.launch-defaults', 'Launch & instance defaults')],
|
keywords: [message('app.settings.tabs.launch-defaults', 'Launch & instance defaults')],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'privacy-telemetry',
|
|
||||||
categoryId: 'privacy-data',
|
|
||||||
targetId: 'settings-target-privacy-telemetry',
|
|
||||||
label: message('app.settings.privacy.telemetry', 'Telemetry'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'privacy-discord-rpc',
|
|
||||||
categoryId: 'privacy-data',
|
|
||||||
targetId: 'settings-target-privacy-discord-rpc',
|
|
||||||
label: message('app.settings.privacy.discord-rpc', 'Discord rich presence'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'java-installations',
|
id: 'java-installations',
|
||||||
categoryId: 'java-performance',
|
categoryId: 'java-performance',
|
||||||
@ -266,6 +254,16 @@ export const settingsSearchEntries: SettingsSearchEntry[] = [
|
|||||||
targetId: 'settings-target-defaults-environment',
|
targetId: 'settings-target-defaults-environment',
|
||||||
label: message('app.settings.defaults.environment-variables', 'Environment variables'),
|
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',
|
id: 'defaults-launch-hooks',
|
||||||
categoryId: 'launch-defaults',
|
categoryId: 'launch-defaults',
|
||||||
|
|||||||
@ -28,7 +28,6 @@ const settingsComponentFiles = {
|
|||||||
'content-downloads': ['./AppearanceSettings.vue', './ResourceManagementSettings.vue'],
|
'content-downloads': ['./AppearanceSettings.vue', './ResourceManagementSettings.vue'],
|
||||||
'network-multiplayer': ['./ResourceManagementSettings.vue', './MultiplayerSettings.vue'],
|
'network-multiplayer': ['./ResourceManagementSettings.vue', './MultiplayerSettings.vue'],
|
||||||
'storage-backups': ['./ResourceManagementSettings.vue', './StorageSettings.vue'],
|
'storage-backups': ['./ResourceManagementSettings.vue', './StorageSettings.vue'],
|
||||||
'privacy-data': ['./PrivacySettings.vue'],
|
|
||||||
updates: ['./UpdateSettings.vue'],
|
updates: ['./UpdateSettings.vue'],
|
||||||
about: ['./AboutSettings.vue'],
|
about: ['./AboutSettings.vue'],
|
||||||
'feature-flags': ['./FeatureFlagSettings.vue'],
|
'feature-flags': ['./FeatureFlagSettings.vue'],
|
||||||
@ -105,6 +104,28 @@ test('settings search index has unique entries with categories', () => {
|
|||||||
assert.deepEqual(validateSettingsSearchEntries(), [])
|
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', () => {
|
test('settings search keywords are valid message descriptors', () => {
|
||||||
for (const entry of settingsSearchEntries) {
|
for (const entry of settingsSearchEntries) {
|
||||||
for (const keyword of entry.keywords ?? []) {
|
for (const keyword of entry.keywords ?? []) {
|
||||||
@ -169,7 +190,7 @@ test('developer-only settings stay out of the normal search categories', () => {
|
|||||||
'content-downloads',
|
'content-downloads',
|
||||||
'network-multiplayer',
|
'network-multiplayer',
|
||||||
])
|
])
|
||||||
assert.deepEqual(categoriesForGroup('data-privacy'), ['storage-backups', 'privacy-data'])
|
assert.deepEqual(categoriesForGroup('data-privacy'), ['storage-backups'])
|
||||||
assert.deepEqual(categoriesForGroup('support'), ['updates', 'about'])
|
assert.deepEqual(categoriesForGroup('support'), ['updates', 'about'])
|
||||||
assert.deepEqual(categoriesForGroup('developer'), [])
|
assert.deepEqual(categoriesForGroup('developer'), [])
|
||||||
assert.deepEqual(categoriesForGroup('developer', true), ['feature-flags'])
|
assert.deepEqual(categoriesForGroup('developer', true), ['feature-flags'])
|
||||||
|
|||||||
@ -84,6 +84,7 @@ const props = defineProps<{
|
|||||||
isSkinActive: (skin: Skin) => boolean
|
isSkinActive: (skin: Skin) => boolean
|
||||||
isAddSkinButtonDragActive: boolean
|
isAddSkinButtonDragActive: boolean
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
|
manageSavedSkins?: boolean
|
||||||
activeTab?: 'saved' | 'default'
|
activeTab?: 'saved' | 'default'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@ -182,7 +183,9 @@ const sections = computed<SkinSection[]>(() => {
|
|||||||
|
|
||||||
const draggableSavedSkins = ref<Skin[]>([])
|
const draggableSavedSkins = ref<Skin[]>([])
|
||||||
const isDraggingSavedSkin = ref(false)
|
const isDraggingSavedSkin = ref(false)
|
||||||
const canReorderSavedSkins = computed(() => draggableSavedSkins.value.length > 1)
|
const canReorderSavedSkins = computed(
|
||||||
|
() => props.manageSavedSkins !== false && draggableSavedSkins.value.length > 1,
|
||||||
|
)
|
||||||
const fixedSavedSkins = computed(() => props.savedSkins.filter((skin) => !canDragSavedSkin(skin)))
|
const fixedSavedSkins = computed(() => props.savedSkins.filter((skin) => !canDragSavedSkin(skin)))
|
||||||
|
|
||||||
const sectionLayouts = computed(() => {
|
const sectionLayouts = computed(() => {
|
||||||
@ -486,7 +489,7 @@ defineExpose({ getAddSkinButtonElement })
|
|||||||
:is-dragging="isDraggingSavedSkin"
|
:is-dragging="isDraggingSavedSkin"
|
||||||
@select="emit('select', skin)"
|
@select="emit('select', skin)"
|
||||||
>
|
>
|
||||||
<template v-if="!readOnly" #overlay-buttons>
|
<template v-if="!readOnly && manageSavedSkins !== false" #overlay-buttons>
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button
|
<button
|
||||||
:aria-label="formatMessage(messages.editSkinButton)"
|
:aria-label="formatMessage(messages.editSkinButton)"
|
||||||
@ -526,7 +529,7 @@ defineExpose({ getAddSkinButtonElement })
|
|||||||
:is-dragging="isDraggingSavedSkin"
|
:is-dragging="isDraggingSavedSkin"
|
||||||
@select="emit('select', skin)"
|
@select="emit('select', skin)"
|
||||||
>
|
>
|
||||||
<template v-if="!readOnly" #overlay-buttons>
|
<template v-if="!readOnly && manageSavedSkins !== false" #overlay-buttons>
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button
|
<button
|
||||||
:aria-label="formatMessage(messages.editSkinButton)"
|
:aria-label="formatMessage(messages.editSkinButton)"
|
||||||
@ -568,7 +571,7 @@ defineExpose({ getAddSkinButtonElement })
|
|||||||
:is-dragging="isDraggingSavedSkin"
|
:is-dragging="isDraggingSavedSkin"
|
||||||
@select="emit('select', skin)"
|
@select="emit('select', skin)"
|
||||||
>
|
>
|
||||||
<template #overlay-buttons>
|
<template v-if="manageSavedSkins !== false" #overlay-buttons>
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button
|
<button
|
||||||
:aria-label="formatMessage(messages.editSkinButton)"
|
:aria-label="formatMessage(messages.editSkinButton)"
|
||||||
|
|||||||
@ -26,7 +26,6 @@ import { useRouter } from 'vue-router'
|
|||||||
|
|
||||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { get_project } from '@/helpers/cache'
|
import { get_project } from '@/helpers/cache'
|
||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
import { kill, run } from '@/helpers/instance'
|
import { kill, run } from '@/helpers/instance'
|
||||||
@ -108,11 +107,6 @@ const play = async (event: MouseEvent) => {
|
|||||||
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
|
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
trackEvent('InstanceStart', {
|
|
||||||
loader: props.instance.loader,
|
|
||||||
game_version: props.instance.game_version,
|
|
||||||
source: 'InstanceItem',
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
emit('play')
|
emit('play')
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@ -122,11 +116,6 @@ const stop = async (event: MouseEvent) => {
|
|||||||
event?.stopPropagation()
|
event?.stopPropagation()
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await kill(props.instance.id).catch(handleError)
|
await kill(props.instance.id).catch(handleError)
|
||||||
trackEvent('InstanceStop', {
|
|
||||||
loader: props.instance.loader,
|
|
||||||
game_version: props.instance.game_version,
|
|
||||||
source: 'InstanceItem',
|
|
||||||
})
|
|
||||||
emit('stop')
|
emit('stop')
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import type { Router } from 'vue-router'
|
|||||||
import {
|
import {
|
||||||
install_job_dismiss,
|
install_job_dismiss,
|
||||||
install_job_repair_cache_and_retry,
|
install_job_repair_cache_and_retry,
|
||||||
install_job_retry,
|
|
||||||
install_job_support_details,
|
install_job_support_details,
|
||||||
installJobInstanceId,
|
installJobInstanceId,
|
||||||
type InstallJobSnapshot,
|
type InstallJobSnapshot,
|
||||||
@ -21,6 +20,7 @@ import {
|
|||||||
type InstallPhaseId,
|
type InstallPhaseId,
|
||||||
type InstallProgress,
|
type InstallProgress,
|
||||||
} from '@/helpers/install'
|
} from '@/helpers/install'
|
||||||
|
import { createInstallJobNotificationFilter } from '@/helpers/install-job-notification-visibility'
|
||||||
import { effectiveInstallProgress, hasDeterminateInstallProgress } from '@/helpers/install-progress'
|
import { effectiveInstallProgress, hasDeterminateInstallProgress } from '@/helpers/install-progress'
|
||||||
import { get_many as getInstances } from '@/helpers/instance'
|
import { get_many as getInstances } from '@/helpers/instance'
|
||||||
import type { DownloadManager } from '@/providers/download-manager'
|
import type { DownloadManager } from '@/providers/download-manager'
|
||||||
@ -258,15 +258,6 @@ 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>([
|
const activeJobStatuses = new Set<InstallJobStatus>([
|
||||||
'queued',
|
'queued',
|
||||||
'running',
|
'running',
|
||||||
@ -682,7 +673,7 @@ export async function useInstallJobNotifications(opts: {
|
|||||||
action: async () => {
|
action: async () => {
|
||||||
if (repairingJobIds.value.has(job.job_id)) return
|
if (repairingJobIds.value.has(job.job_id)) return
|
||||||
if (!requiresCacheRepair) {
|
if (!requiresCacheRepair) {
|
||||||
await install_job_retry(job.job_id).catch(opts.handleError)
|
await opts.manager.retry(job.job_id).catch(opts.handleError)
|
||||||
await refresh()
|
await refresh()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -724,6 +715,8 @@ export async function useInstallJobNotifications(opts: {
|
|||||||
return buttons
|
return buttons
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filterVisibleJobs = createInstallJobNotificationFilter(opts.manager.jobs.value)
|
||||||
|
|
||||||
function setJobs(nextJobs: InstallJobSnapshot[]) {
|
function setJobs(nextJobs: InstallJobSnapshot[]) {
|
||||||
for (const job of nextJobs) {
|
for (const job of nextJobs) {
|
||||||
if (!jobOrder.has(job.job_id)) {
|
if (!jobOrder.has(job.job_id)) {
|
||||||
@ -731,12 +724,7 @@ export async function useInstallJobNotifications(opts: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentJobIds = new Set(jobs.value.map((job) => job.job_id))
|
const visibleJobs = filterVisibleJobs(nextJobs)
|
||||||
const visibleJobs = nextJobs.filter(
|
|
||||||
(job) =>
|
|
||||||
visibleJobStatuses.has(job.status) ||
|
|
||||||
(retainedJobStatuses.has(job.status) && currentJobIds.has(job.job_id)),
|
|
||||||
)
|
|
||||||
syncProgressSnapshots(visibleJobs)
|
syncProgressSnapshots(visibleJobs)
|
||||||
|
|
||||||
jobs.value = visibleJobs.sort(
|
jobs.value = visibleJobs.sort(
|
||||||
|
|||||||
562
apps/app-frontend/src/composables/skin-site-session.test.ts
Normal file
@ -0,0 +1,562 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { useHostedSync } from './useHostedSync.ts'
|
||||||
|
import { useHostedCreation, forgetHostedCreation } from './useHostedCreation.ts'
|
||||||
|
import {
|
||||||
|
clearHostedSession,
|
||||||
|
hostedCreate,
|
||||||
|
hostedDefault,
|
||||||
|
hostedSync,
|
||||||
|
onHostedPackAttemptStarted,
|
||||||
|
setInstanceMode,
|
||||||
|
} from '../helpers/hosted-packs.ts'
|
||||||
|
|
||||||
|
import {
|
||||||
|
openSkinSiteLogin,
|
||||||
|
receiveSkinSiteMessage,
|
||||||
|
requestSkinSiteLuck,
|
||||||
|
requestSkinSiteDownloadToken,
|
||||||
|
requestSkinSitePlayers,
|
||||||
|
requestSkinSiteSkinUpdate,
|
||||||
|
resetSkinSiteSession,
|
||||||
|
setSkinSiteFrame,
|
||||||
|
SKIN_SITE_ORIGIN,
|
||||||
|
skinSiteFrameUrl,
|
||||||
|
skinSitePlayers,
|
||||||
|
skinSitePlayersStatus,
|
||||||
|
skinSiteStatus,
|
||||||
|
skinSiteUser,
|
||||||
|
} from './skin-site-session.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
|
||||||
|
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_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),
|
||||||
|
[
|
||||||
|
'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))
|
||||||
|
const unauthenticatedRetry = hostedSync('failed-instance')
|
||||||
|
assert.deepEqual(attempts, ['failed-instance'])
|
||||||
|
await assert.rejects(unauthenticatedRetry)
|
||||||
|
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()
|
||||||
|
})
|
||||||
437
apps/app-frontend/src/composables/skin-site-session.ts
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@ -114,8 +114,6 @@ export interface DropImportOptions {
|
|||||||
onSchematicWorkshopPage: ComputedRef<boolean>
|
onSchematicWorkshopPage: ComputedRef<boolean>
|
||||||
/** Check if path is a schematic file */
|
/** Check if path is a schematic file */
|
||||||
isSchematicFile: (path: string) => boolean
|
isSchematicFile: (path: string) => boolean
|
||||||
/** Track analytics event */
|
|
||||||
trackEvent: (name: string, properties?: Record<string, unknown>) => void
|
|
||||||
/** Route to push */
|
/** Route to push */
|
||||||
router: Router
|
router: Router
|
||||||
}
|
}
|
||||||
@ -135,7 +133,6 @@ export interface DropImportOptions {
|
|||||||
* onSkinsPage,
|
* onSkinsPage,
|
||||||
* onSchematicWorkshopPage,
|
* onSchematicWorkshopPage,
|
||||||
* isSchematicFile,
|
* isSchematicFile,
|
||||||
* trackEvent,
|
|
||||||
* router,
|
* router,
|
||||||
* route,
|
* route,
|
||||||
* })
|
* })
|
||||||
@ -151,7 +148,6 @@ export function useDropImport(options: DropImportOptions) {
|
|||||||
onSkinsPage,
|
onSkinsPage,
|
||||||
onSchematicWorkshopPage,
|
onSchematicWorkshopPage,
|
||||||
isSchematicFile,
|
isSchematicFile,
|
||||||
trackEvent,
|
|
||||||
router,
|
router,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
@ -906,7 +902,6 @@ export function useDropImport(options: DropImportOptions) {
|
|||||||
|
|
||||||
clearDropProcessingNotification()
|
clearDropProcessingNotification()
|
||||||
await installModpackFromPath(filePath, fileName, { persistUntilDone: true })
|
await installModpackFromPath(filePath, fileName, { persistUntilDone: true })
|
||||||
trackEvent('InstanceCreate', { source: 'DropConfirmModpack' })
|
|
||||||
await router.push('/library')
|
await router.push('/library')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
73
apps/app-frontend/src/composables/useHostedCreation.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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 = String(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 = String(cause)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
installing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { installing, installError, createdInstance, completed, acknowledge, install }
|
||||||
|
}
|
||||||
66
apps/app-frontend/src/composables/useHostedSync.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
27
apps/app-frontend/src/composables/useInstanceMode.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
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,6 +1,5 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { get_jre, test_jre } from '@/helpers/jre.js'
|
import { get_jre, test_jre } from '@/helpers/jre.js'
|
||||||
|
|
||||||
export default function useJavaTest() {
|
export default function useJavaTest() {
|
||||||
@ -8,7 +7,7 @@ export default function useJavaTest() {
|
|||||||
const javaTestResult = ref<boolean | null>(null)
|
const javaTestResult = ref<boolean | null>(null)
|
||||||
let testDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
let testDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
async function runJavaTest(path: string, version: number | null, track = true) {
|
async function runJavaTest(path: string, version: number | null) {
|
||||||
if (testDebounceTimer) {
|
if (testDebounceTimer) {
|
||||||
clearTimeout(testDebounceTimer)
|
clearTimeout(testDebounceTimer)
|
||||||
testDebounceTimer = null
|
testDebounceTimer = null
|
||||||
@ -28,10 +27,6 @@ export default function useJavaTest() {
|
|||||||
javaTestResult.value = false
|
javaTestResult.value = false
|
||||||
}
|
}
|
||||||
testingJava.value = false
|
testingJava.value = false
|
||||||
|
|
||||||
if (track) {
|
|
||||||
trackEvent('JavaTest', { path, success: javaTestResult.value })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function testJavaInstallationDebounced(path: string, version: number | null, delay = 600) {
|
function testJavaInstallationDebounced(path: string, version: number | null, delay = 600) {
|
||||||
@ -43,8 +38,8 @@ export default function useJavaTest() {
|
|||||||
testDebounceTimer = setTimeout(() => runJavaTest(path, version, false), delay)
|
testDebounceTimer = setTimeout(() => runJavaTest(path, version, false), delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testJavaInstallation(path: string, version: number | null, track = false) {
|
async function testJavaInstallation(path: string, version: number | null) {
|
||||||
await runJavaTest(path, version, track)
|
await runJavaTest(path, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": "Ax_Tps",
|
"name": "Ax_Tps",
|
||||||
@ -9,6 +8,7 @@
|
|||||||
{
|
{
|
||||||
"name": "Disy920",
|
"name": "Disy920",
|
||||||
"avatar": "Disy920.png",
|
"avatar": "Disy920.png",
|
||||||
"url": "https://space.bilibili.com/231241189?spm_id_from=333.337.0.0"
|
"url": "https://space.bilibili.com/231241189?spm_id_from=333.337.0.0",
|
||||||
|
"experience": "color-mine"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,75 +0,0 @@
|
|||||||
interface InstanceProperties {
|
|
||||||
loader: string
|
|
||||||
game_version: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectProperties extends InstanceProperties {
|
|
||||||
id: string
|
|
||||||
project_type: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AnalyticsEventMap = {
|
|
||||||
Launched: { version: string; dev: boolean; onboarded: boolean }
|
|
||||||
PageView: { path: string; fromPath: string; failed: unknown }
|
|
||||||
InstanceCreate: { source: string }
|
|
||||||
InstanceCreateStart: { source: string }
|
|
||||||
InstanceStart: InstanceProperties & { source: string }
|
|
||||||
InstanceStop: Partial<InstanceProperties> & { source?: string }
|
|
||||||
InstanceDuplicate: InstanceProperties
|
|
||||||
InstanceRepair: InstanceProperties
|
|
||||||
InstanceSetIcon: Record<string, never>
|
|
||||||
InstanceRemoveIcon: Record<string, never>
|
|
||||||
InstanceUpdateAll: InstanceProperties & { count: number; selected: boolean }
|
|
||||||
InstanceProjectUpdate: InstanceProperties & { id: string; name: string; project_type: string }
|
|
||||||
InstanceProjectDisable: InstanceProperties & {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
project_type: string
|
|
||||||
disabled: boolean
|
|
||||||
}
|
|
||||||
InstanceProjectRemove: InstanceProperties & { id: string; name: string; project_type: string }
|
|
||||||
ProjectInstall: ProjectProperties & { version_id: string; title: string; source: string }
|
|
||||||
ProjectInstallStart: { source: string }
|
|
||||||
PackInstall: { id: string; version_id: string; title: string; source: string }
|
|
||||||
PackInstallStart: Record<string, never>
|
|
||||||
AccountLogIn: { source?: string }
|
|
||||||
AccountLogOut: Record<string, never>
|
|
||||||
JavaTest: { path: string; success: boolean }
|
|
||||||
JavaManualSelect: { version: string }
|
|
||||||
JavaAutoDetect: { path: string; version: string }
|
|
||||||
GalleryImageNext: { project_id: string; url: string }
|
|
||||||
GalleryImagePrevious: { project_id: string; url: unknown }
|
|
||||||
GalleryImageExpand: { project_id: string; url: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AnalyticsEvent = keyof AnalyticsEventMap
|
|
||||||
|
|
||||||
let optedIn = false
|
|
||||||
let debugEnabled = false
|
|
||||||
|
|
||||||
export const initAnalytics = () => {
|
|
||||||
optedIn = true
|
|
||||||
}
|
|
||||||
|
|
||||||
export const debugAnalytics = () => {
|
|
||||||
debugEnabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
export const optOutAnalytics = () => {
|
|
||||||
optedIn = false
|
|
||||||
}
|
|
||||||
|
|
||||||
export const optInAnalytics = () => {
|
|
||||||
optedIn = true
|
|
||||||
}
|
|
||||||
|
|
||||||
type OptionalArgs<T> = Record<string, never> extends T ? [properties?: T] : [properties: T]
|
|
||||||
|
|
||||||
export const trackEvent = <E extends AnalyticsEvent>(
|
|
||||||
eventName: E,
|
|
||||||
...args: OptionalArgs<AnalyticsEventMap[E]>
|
|
||||||
) => {
|
|
||||||
if (optedIn && debugEnabled) {
|
|
||||||
console.debug('[Axolotl telemetry disabled]', eventName, args[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -14,8 +14,8 @@ import { invoke } from '@tauri-apps/api/core'
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the authentication servers are reachable, throwing an exception if
|
* Check if the StarLight authentication server is reachable, throwing an
|
||||||
* not reachable.
|
* exception if it is not reachable.
|
||||||
*/
|
*/
|
||||||
export async function check_reachable() {
|
export async function check_reachable() {
|
||||||
await invoke('plugin:auth|check_reachable')
|
await invoke('plugin:auth|check_reachable')
|
||||||
|
|||||||
@ -0,0 +1,51 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { createHostedDownloadFailures } from './hosted-download-failures.ts'
|
||||||
|
|
||||||
|
test('retry clears only its own failure before progress arrives and ignores late old events', () => {
|
||||||
|
const state = createHostedDownloadFailures()
|
||||||
|
const failed = (instanceId: string, loader: string, error: string) => ({
|
||||||
|
fraction: null,
|
||||||
|
loader_uuid: loader,
|
||||||
|
event: { type: 'hosted_pack_sync', instance_id: instanceId, instance_name: instanceId, error },
|
||||||
|
})
|
||||||
|
const oldFailure = failed('one', 'old', 'old failure')
|
||||||
|
state.update(oldFailure)
|
||||||
|
state.update(failed('two', 'other', 'other failure'))
|
||||||
|
state.begin('one', state.values())
|
||||||
|
assert.equal(state.has('one'), false)
|
||||||
|
assert.equal(state.has('two'), true)
|
||||||
|
assert.equal(state.isRetired('old'), true)
|
||||||
|
state.update(oldFailure)
|
||||||
|
assert.equal(state.has('one'), false)
|
||||||
|
state.update(failed('one', 'new', 'new failure'))
|
||||||
|
state.update({ ...oldFailure, fraction: 0.5 })
|
||||||
|
assert.equal(
|
||||||
|
state.values().find((bar) => bar.bar_type?.instance_id === 'one')?.message,
|
||||||
|
'new failure',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a new native task retires the previous failed task without a frontend retry', () => {
|
||||||
|
const state = createHostedDownloadFailures()
|
||||||
|
const old = {
|
||||||
|
fraction: null,
|
||||||
|
loader_uuid: 'old',
|
||||||
|
event: { type: 'hosted_pack_sync', instance_id: 'instance', error: 'old failure' },
|
||||||
|
}
|
||||||
|
state.update(old)
|
||||||
|
state.update({ fraction: 0, loader_uuid: 'new', event: { ...old.event, error: null } })
|
||||||
|
state.update(old)
|
||||||
|
assert.equal(state.has('instance'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('late failure from an older active task cannot replace the new task', () => {
|
||||||
|
const state = createHostedDownloadFailures()
|
||||||
|
const event = { type: 'hosted_pack_sync', instance_id: 'instance' }
|
||||||
|
state.update({ fraction: 0.5, loader_uuid: 'old', event })
|
||||||
|
state.update({ fraction: 0, loader_uuid: 'new', event })
|
||||||
|
state.update({ fraction: null, loader_uuid: 'old', event: { ...event, error: 'old failure' } })
|
||||||
|
assert.equal(state.has('instance'), false)
|
||||||
|
assert.equal(state.isRetired('old'), true)
|
||||||
|
assert.equal(state.isRetired('new'), false)
|
||||||
|
})
|
||||||
53
apps/app-frontend/src/helpers/hosted-download-failures.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import type { LoadingBar } from './state.ts'
|
||||||
|
|
||||||
|
export function createHostedDownloadFailures() {
|
||||||
|
const failures = new Map<string, LoadingBar>()
|
||||||
|
const retired = new Set<string>()
|
||||||
|
const currentTasks = new Map<string, string>()
|
||||||
|
return {
|
||||||
|
values: () => [...failures.values()],
|
||||||
|
has: (instanceId: string) => failures.has(instanceId),
|
||||||
|
isRetired: (id: string) => retired.has(id),
|
||||||
|
begin(instanceId: string, bars: LoadingBar[]) {
|
||||||
|
const current = currentTasks.get(instanceId)
|
||||||
|
if (current) retired.add(current)
|
||||||
|
currentTasks.delete(instanceId)
|
||||||
|
const previous = failures.get(instanceId)
|
||||||
|
if (previous) retired.add(String(previous.loading_bar_uuid))
|
||||||
|
for (const bar of bars) {
|
||||||
|
if (bar.bar_type?.type === 'hosted_pack_sync' && bar.bar_type.instance_id === instanceId) {
|
||||||
|
retired.add(String(bar.loading_bar_uuid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failures.delete(instanceId)
|
||||||
|
},
|
||||||
|
update(payload: {
|
||||||
|
fraction: number | null
|
||||||
|
loader_uuid: string
|
||||||
|
event: LoadingBar['bar_type']
|
||||||
|
}) {
|
||||||
|
const event = payload.event
|
||||||
|
if (
|
||||||
|
event?.type !== 'hosted_pack_sync' ||
|
||||||
|
!event.instance_id ||
|
||||||
|
retired.has(payload.loader_uuid)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
const current = currentTasks.get(event.instance_id)
|
||||||
|
if (current && current !== payload.loader_uuid) retired.add(current)
|
||||||
|
currentTasks.set(event.instance_id, payload.loader_uuid)
|
||||||
|
if (payload.fraction === null && event.error) {
|
||||||
|
failures.set(event.instance_id, {
|
||||||
|
loading_bar_uuid: payload.loader_uuid,
|
||||||
|
bar_type: event,
|
||||||
|
title: event.instance_name,
|
||||||
|
message: event.error,
|
||||||
|
total: 0,
|
||||||
|
current: 0,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
failures.delete(event.instance_id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
124
apps/app-frontend/src/helpers/hosted-install-retry.test.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
hostedRetryRoute,
|
||||||
|
hostedRuntimeRetryInstanceId,
|
||||||
|
retryInstallJob,
|
||||||
|
} from './hosted-install-retry.ts'
|
||||||
|
import type { InstallJobSnapshot } from './install.ts'
|
||||||
|
|
||||||
|
function job(overrides: Partial<InstallJobSnapshot> = {}): InstallJobSnapshot {
|
||||||
|
return {
|
||||||
|
job_id: 'job',
|
||||||
|
instance_id: 'local:instance',
|
||||||
|
source_instance_id: null,
|
||||||
|
instance_deleted: false,
|
||||||
|
kind: 'install_existing_instance',
|
||||||
|
status: 'failed',
|
||||||
|
execution_mode: 'normal',
|
||||||
|
provider: 'minecraft',
|
||||||
|
target: { type: 'existing_instance', instance_id: 'local:instance' },
|
||||||
|
phase: 'downloading_minecraft',
|
||||||
|
progress: null,
|
||||||
|
details: { type: 'empty' },
|
||||||
|
parallel: null,
|
||||||
|
display: null,
|
||||||
|
error: null,
|
||||||
|
rollback_error: null,
|
||||||
|
pause_reason: null,
|
||||||
|
upgrade_result: null,
|
||||||
|
created: '2026-09-18T00:00:00Z',
|
||||||
|
modified: '2026-09-18T00:00:00Z',
|
||||||
|
finished: '2026-09-18T00:00:01Z',
|
||||||
|
summary: {
|
||||||
|
files_completed: 0,
|
||||||
|
files_total: null,
|
||||||
|
bytes_downloaded: 0,
|
||||||
|
bytes_total: null,
|
||||||
|
speed_bytes_per_second: null,
|
||||||
|
eta_seconds: null,
|
||||||
|
source: null,
|
||||||
|
fallback_count: 0,
|
||||||
|
},
|
||||||
|
items: [],
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('StarLight runtime retries keep the existing instance identity', () => {
|
||||||
|
assert.equal(hostedRuntimeRetryInstanceId(job()), 'local:instance')
|
||||||
|
assert.equal(
|
||||||
|
hostedRuntimeRetryInstanceId(
|
||||||
|
job({ instance_id: null, target: { type: 'existing_instance', instance_id: 'target' } }),
|
||||||
|
),
|
||||||
|
'target',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deleted and unrelated install jobs do not enter the StarLight runtime retry path', () => {
|
||||||
|
assert.equal(hostedRuntimeRetryInstanceId(job({ instance_deleted: true })), null)
|
||||||
|
assert.equal(hostedRuntimeRetryInstanceId(job({ kind: 'create_instance' })), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the hosted retry link targets the current instance content route', () => {
|
||||||
|
assert.equal(
|
||||||
|
hostedRetryRoute('local:instance/with space'),
|
||||||
|
'/instance/local%3Ainstance%2Fwith%20space',
|
||||||
|
)
|
||||||
|
assert.doesNotMatch(hostedRetryRoute('local:instance'), /\/mods$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a StarLight runtime failure retries the complete hosted transaction only', async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
const result = await retryInstallJob(job(), {
|
||||||
|
getInstanceMode: async (instanceId) => {
|
||||||
|
calls.push(`mode:${instanceId}`)
|
||||||
|
return 'starlight'
|
||||||
|
},
|
||||||
|
retryHosted: async (instanceId, sourceJobId) => {
|
||||||
|
calls.push(`hosted:${instanceId}:${sourceJobId}`)
|
||||||
|
},
|
||||||
|
retryGeneric: async (jobId) => {
|
||||||
|
calls.push(`generic:${jobId}`)
|
||||||
|
return job()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.equal(result, null)
|
||||||
|
assert.deepEqual(calls, ['mode:local:instance', 'hosted:local:instance:job'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a Local runtime failure keeps the generic retry path', async () => {
|
||||||
|
const replacement = job({ job_id: 'replacement', status: 'queued' })
|
||||||
|
const calls: string[] = []
|
||||||
|
const result = await retryInstallJob(job(), {
|
||||||
|
getInstanceMode: async () => 'local',
|
||||||
|
retryHosted: async () => {
|
||||||
|
calls.push('hosted')
|
||||||
|
},
|
||||||
|
retryGeneric: async (jobId) => {
|
||||||
|
calls.push(`generic:${jobId}`)
|
||||||
|
return replacement
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.equal(result, replacement)
|
||||||
|
assert.deepEqual(calls, ['generic:job'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('mode lookup failures never fall back to an incomplete generic retry', async () => {
|
||||||
|
let genericCalls = 0
|
||||||
|
await assert.rejects(
|
||||||
|
retryInstallJob(job(), {
|
||||||
|
getInstanceMode: async () => {
|
||||||
|
throw new Error('instance unavailable')
|
||||||
|
},
|
||||||
|
retryHosted: async () => {},
|
||||||
|
retryGeneric: async () => {
|
||||||
|
genericCalls++
|
||||||
|
return job()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/instance unavailable/,
|
||||||
|
)
|
||||||
|
assert.equal(genericCalls, 0)
|
||||||
|
})
|
||||||
34
apps/app-frontend/src/helpers/hosted-install-retry.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import type { InstallJobSnapshot } from './install.ts'
|
||||||
|
|
||||||
|
interface InstallRetryDependencies {
|
||||||
|
getInstanceMode: (instanceId: string) => Promise<'starlight' | 'local'>
|
||||||
|
retryHosted: (instanceId: string, sourceJobId: string) => Promise<void>
|
||||||
|
retryGeneric: (jobId: string) => Promise<InstallJobSnapshot>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minecraft installation jobs started by StarLight synchronization are only
|
||||||
|
* one stage of the full operation. Retrying that stage alone leaves the
|
||||||
|
* pending pack journal unapplied, so these jobs must resume through
|
||||||
|
* `hostedSync` instead of the generic install-job retry command.
|
||||||
|
*/
|
||||||
|
export function hostedRuntimeRetryInstanceId(job: InstallJobSnapshot): string | null {
|
||||||
|
if (job.kind !== 'install_existing_instance' || job.instance_deleted) return null
|
||||||
|
return job.instance_id ?? job.target.instance_id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hostedRetryRoute(instanceId: string): string {
|
||||||
|
return `/instance/${encodeURIComponent(instanceId)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryInstallJob(
|
||||||
|
job: InstallJobSnapshot,
|
||||||
|
dependencies: InstallRetryDependencies,
|
||||||
|
): Promise<InstallJobSnapshot | null> {
|
||||||
|
const instanceId = hostedRuntimeRetryInstanceId(job)
|
||||||
|
if (instanceId && (await dependencies.getInstanceMode(instanceId)) === 'starlight') {
|
||||||
|
await dependencies.retryHosted(instanceId, job.job_id)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return dependencies.retryGeneric(job.job_id)
|
||||||
|
}
|
||||||
111
apps/app-frontend/src/helpers/hosted-packs.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import {
|
||||||
|
requestSkinSiteDownloadToken,
|
||||||
|
skinSiteStatus,
|
||||||
|
skinSiteUser,
|
||||||
|
waitForSkinSiteSession,
|
||||||
|
} from '../composables/skin-site-session.ts'
|
||||||
|
|
||||||
|
let sessionUpdate: Promise<void> = Promise.resolve()
|
||||||
|
const attemptListeners = new Set<(instanceId: string) => void>()
|
||||||
|
|
||||||
|
export function onHostedPackAttemptStarted(listener: (instanceId: string) => void) {
|
||||||
|
attemptListeners.add(listener)
|
||||||
|
return () => {
|
||||||
|
attemptListeners.delete(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHostedPackAttempt(instanceId: string) {
|
||||||
|
for (const listener of attemptListeners) listener(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHostedSession(): Promise<void> {
|
||||||
|
const update = sessionUpdate
|
||||||
|
.catch(() => {})
|
||||||
|
.then(() => invokeHosted<void>('plugin:install|hosted_set_session', { token: null }))
|
||||||
|
sessionUpdate = update
|
||||||
|
return update
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareHostedSession(): Promise<void> {
|
||||||
|
await waitForSkinSiteSession()
|
||||||
|
const userId = skinSiteUser.value?.uuid
|
||||||
|
const token = await requestSkinSiteDownloadToken()
|
||||||
|
const update = sessionUpdate
|
||||||
|
.catch(() => {})
|
||||||
|
.then(async () => {
|
||||||
|
if (skinSiteStatus.value !== 'signed-in' || skinSiteUser.value?.uuid !== userId) {
|
||||||
|
throw new Error('StarLight 登录状态已变化,请重试。')
|
||||||
|
}
|
||||||
|
await invokeHosted<void>('plugin:install|hosted_set_session', { token })
|
||||||
|
})
|
||||||
|
sessionUpdate = update
|
||||||
|
await update
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeWithSession<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
await prepareHostedSession()
|
||||||
|
return invokeHosted<T>(command, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeHosted<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await invoke<T>(command, args)
|
||||||
|
} catch (cause) {
|
||||||
|
if (
|
||||||
|
!(cause instanceof Error) &&
|
||||||
|
typeof cause === 'object' &&
|
||||||
|
cause !== null &&
|
||||||
|
'message' in cause &&
|
||||||
|
typeof cause.message === 'string'
|
||||||
|
) {
|
||||||
|
throw new Error(cause.message, { cause })
|
||||||
|
}
|
||||||
|
throw cause
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InstanceMode = 'starlight' | 'local'
|
||||||
|
export const getInstanceMode = (instanceId: string) =>
|
||||||
|
invokeHosted<InstanceMode>('plugin:install|hosted_instance_mode', { instanceId })
|
||||||
|
export const setInstanceMode = (instanceId: string, mode: InstanceMode) => {
|
||||||
|
if (mode === 'starlight') startHostedPackAttempt(instanceId)
|
||||||
|
return (mode === 'starlight' ? invokeWithSession<void> : invokeHosted<void>)(
|
||||||
|
'plugin:install|hosted_set_instance_mode',
|
||||||
|
{ instanceId, mode },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostedPublication {
|
||||||
|
packId: string
|
||||||
|
releaseId: number
|
||||||
|
manifest: {
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
format: string
|
||||||
|
runtime: { gameVersion: string; loader: string; loaderVersion: string | null }
|
||||||
|
files: { path: string; size: number; sha256: string }[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export interface HostedBinding {
|
||||||
|
publication: HostedPublication
|
||||||
|
}
|
||||||
|
export interface HostedSyncResult {
|
||||||
|
version: string
|
||||||
|
downloadedBytes: number
|
||||||
|
changedFiles: number
|
||||||
|
preservedFiles: string[]
|
||||||
|
}
|
||||||
|
export const hostedDefault = () =>
|
||||||
|
invokeWithSession<HostedPublication>('plugin:install|hosted_default')
|
||||||
|
export const hostedCreate = (gameDirRoot?: string | null) =>
|
||||||
|
invokeWithSession<string>('plugin:install|hosted_create', {
|
||||||
|
gameDirRoot: gameDirRoot ?? null,
|
||||||
|
})
|
||||||
|
export const hostedBinding = (instanceId: string) =>
|
||||||
|
invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
|
||||||
|
export const hostedSync = (instanceId: string) => {
|
||||||
|
startHostedPackAttempt(instanceId)
|
||||||
|
return invokeWithSession<HostedSyncResult>('plugin:install|hosted_sync', { instanceId })
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import type { InstallJobSnapshot, InstallJobStatus } from './install.ts'
|
||||||
|
import { createInstallJobNotificationFilter } from './install-job-notification-visibility.ts'
|
||||||
|
|
||||||
|
function job(jobId: string, status: InstallJobStatus): InstallJobSnapshot {
|
||||||
|
return { job_id: jobId, status } as InstallJobSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
test('does not resurrect failures that finished before the notification surface started', () => {
|
||||||
|
const oldFailure = job('old-failure', 'failed')
|
||||||
|
const filter = createInstallJobNotificationFilter([oldFailure, job('old-success', 'succeeded')])
|
||||||
|
|
||||||
|
assert.deepEqual(filter([oldFailure, job('old-success', 'succeeded')]), [])
|
||||||
|
assert.deepEqual(filter([oldFailure, job('current', 'running')]).map((item) => item.job_id), [
|
||||||
|
'current',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps an observed task visible when it finishes', () => {
|
||||||
|
const filter = createInstallJobNotificationFilter([job('old-failure', 'failed')])
|
||||||
|
|
||||||
|
assert.deepEqual(filter([job('current', 'running')]).map((item) => item.job_id), ['current'])
|
||||||
|
assert.deepEqual(filter([job('current', 'failed')]).map((item) => item.job_id), ['current'])
|
||||||
|
assert.deepEqual(filter([job('current', 'succeeded')]).map((item) => item.job_id), ['current'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows a newly received failure even if its active phase completed too quickly to observe', () => {
|
||||||
|
const filter = createInstallJobNotificationFilter([job('old-failure', 'failed')])
|
||||||
|
|
||||||
|
assert.deepEqual(filter([job('new-failure', 'failed')]).map((item) => item.job_id), [
|
||||||
|
'new-failure',
|
||||||
|
])
|
||||||
|
})
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import type { InstallJobSnapshot, InstallJobStatus } from './install.ts'
|
||||||
|
|
||||||
|
const activeStatuses = new Set<InstallJobStatus>([
|
||||||
|
'queued',
|
||||||
|
'running',
|
||||||
|
'canceling',
|
||||||
|
'waiting_for_user',
|
||||||
|
])
|
||||||
|
const failureStatuses = new Set<InstallJobStatus>(['failed', 'interrupted'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the popup scoped to work the user could actually have observed.
|
||||||
|
* Finished jobs already present when the action bar starts belong to download
|
||||||
|
* history; they must not be resurrected by an unrelated loading event.
|
||||||
|
*/
|
||||||
|
export function createInstallJobNotificationFilter(initialJobs: InstallJobSnapshot[]) {
|
||||||
|
const missedFinishedJobIds = new Set(
|
||||||
|
initialJobs.filter((job) => !activeStatuses.has(job.status)).map((job) => job.job_id),
|
||||||
|
)
|
||||||
|
let visibleJobIds = new Set<string>()
|
||||||
|
|
||||||
|
return (nextJobs: InstallJobSnapshot[]) => {
|
||||||
|
const visibleJobs = nextJobs.filter((job) => {
|
||||||
|
if (activeStatuses.has(job.status)) return true
|
||||||
|
if (visibleJobIds.has(job.job_id)) return true
|
||||||
|
return failureStatuses.has(job.status) && !missedFinishedJobIds.has(job.job_id)
|
||||||
|
})
|
||||||
|
|
||||||
|
visibleJobIds = new Set(visibleJobs.map((job) => job.job_id))
|
||||||
|
return visibleJobs
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,6 +32,7 @@ export interface InstallModpackPreview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface InstallCreateInstanceRequest {
|
export interface InstallCreateInstanceRequest {
|
||||||
|
instanceMode?: 'starlight' | 'local'
|
||||||
name: string
|
name: string
|
||||||
gameVersion: string
|
gameVersion: string
|
||||||
loader: InstanceLoader
|
loader: InstanceLoader
|
||||||
|
|||||||
164
apps/app-frontend/src/helpers/instance-player.test.ts
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { useNetworkStatus } from '../composables/useNetworkStatus.ts'
|
||||||
|
import {
|
||||||
|
setSkinSiteFrame,
|
||||||
|
resetSkinSiteSession,
|
||||||
|
receiveSkinSiteMessage,
|
||||||
|
SKIN_SITE_ORIGIN,
|
||||||
|
} from '../composables/skin-site-session.ts'
|
||||||
|
import {
|
||||||
|
prepareInstancePlayer,
|
||||||
|
registerInstancePlayerPicker,
|
||||||
|
saveInstancePlayer,
|
||||||
|
onInstancePlayerChanged,
|
||||||
|
type InstancePlayer,
|
||||||
|
} from './instance-player.ts'
|
||||||
|
|
||||||
|
test('instance player selection persists independently from the globally selected account', async () => {
|
||||||
|
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
|
||||||
|
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } })
|
||||||
|
useNetworkStatus().refreshBrowserOffline()
|
||||||
|
const saved: InstancePlayer = { id: 'saved', name: 'Saved', account_type: 'microsoft' }
|
||||||
|
let binding: InstancePlayer | null = saved
|
||||||
|
let accounts = [
|
||||||
|
{ profile: { id: 'other', name: 'Other' }, account_type: 'microsoft' },
|
||||||
|
{ profile: { id: 'saved', name: 'Saved' }, account_type: 'microsoft' },
|
||||||
|
]
|
||||||
|
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
__TAURI_INTERNALS__: {
|
||||||
|
invoke: async (command: string) => {
|
||||||
|
if (command === 'plugin:auth|get_instance_player') return binding
|
||||||
|
if (command === 'plugin:auth|get_users') return accounts
|
||||||
|
throw new Error(`Unexpected command ${command}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const requests: Array<InstancePlayer | null> = []
|
||||||
|
const unregister = registerInstancePlayerPicker(async (_id, locked) => {
|
||||||
|
requests.push(locked)
|
||||||
|
binding = saved
|
||||||
|
return saved
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
setSkinSiteFrame({ postMessage() {} } as unknown as Window)
|
||||||
|
await prepareInstancePlayer('instance')
|
||||||
|
assert.equal(requests.length, 0)
|
||||||
|
setSkinSiteFrame(null)
|
||||||
|
binding = null
|
||||||
|
await Promise.all([prepareInstancePlayer('instance'), prepareInstancePlayer('instance')])
|
||||||
|
assert.deepEqual(requests, [null])
|
||||||
|
await prepareInstancePlayer('instance')
|
||||||
|
assert.equal(requests.length, 1)
|
||||||
|
accounts = accounts.filter((account) => account.profile.id !== saved.id)
|
||||||
|
await prepareInstancePlayer('instance')
|
||||||
|
assert.equal(
|
||||||
|
requests[1],
|
||||||
|
saved,
|
||||||
|
'Missing saved player must remain locked, not fall back to the other account',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
unregister()
|
||||||
|
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator)
|
||||||
|
else Reflect.deleteProperty(globalThis, 'navigator')
|
||||||
|
if (previous) Object.defineProperty(globalThis, 'window', previous)
|
||||||
|
else Reflect.deleteProperty(globalThis, 'window')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('signed-in skin site renews the saved player without prompting and only notifies after saving', async () => {
|
||||||
|
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||||
|
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
|
||||||
|
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } })
|
||||||
|
useNetworkStatus().refreshBrowserOffline()
|
||||||
|
const player: InstancePlayer = {
|
||||||
|
id: '12345678-1234-1234-1234-123456789abc',
|
||||||
|
name: 'Skin',
|
||||||
|
account_type: 'yggdrasil',
|
||||||
|
skin_site_user: 'owner',
|
||||||
|
}
|
||||||
|
const calls: string[] = []
|
||||||
|
let failSave = false
|
||||||
|
const frame = {
|
||||||
|
postMessage(data: { type: string; requestId: string }) {
|
||||||
|
if (data.type === 'starlight-pack-token-request')
|
||||||
|
receiveSkinSiteMessage(
|
||||||
|
{
|
||||||
|
origin: SKIN_SITE_ORIGIN,
|
||||||
|
source: frame,
|
||||||
|
data: {
|
||||||
|
type: 'starlight-pack-token-result',
|
||||||
|
requestId: data.requestId,
|
||||||
|
token: 'fixture.jwt',
|
||||||
|
},
|
||||||
|
} as unknown 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)
|
||||||
|
if (command.endsWith('get_instance_player')) return player
|
||||||
|
if (command.endsWith('login_skin_site_player')) {
|
||||||
|
assert.equal(args.playerId, player.id)
|
||||||
|
assert.equal(args.userId, 'owner')
|
||||||
|
assert.equal(args.token, 'fixture.jwt')
|
||||||
|
} else if (command.endsWith('set_instance_player') && failSave)
|
||||||
|
throw new Error('Save failed')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
let prompts = 0
|
||||||
|
const unregister = registerInstancePlayerPicker(async (_id, locked) => {
|
||||||
|
prompts++
|
||||||
|
assert.equal(locked, player)
|
||||||
|
return player
|
||||||
|
})
|
||||||
|
const changes: string[] = []
|
||||||
|
const stop = onInstancePlayerChanged((id) => changes.push(id))
|
||||||
|
try {
|
||||||
|
resetSkinSiteSession()
|
||||||
|
setSkinSiteFrame(frame)
|
||||||
|
receiveSkinSiteMessage(
|
||||||
|
{
|
||||||
|
origin: SKIN_SITE_ORIGIN,
|
||||||
|
source: frame,
|
||||||
|
data: {
|
||||||
|
type: 'starlight-skin-session',
|
||||||
|
status: 'signed-in',
|
||||||
|
user: { uuid: 'owner', username: 'Owner' },
|
||||||
|
},
|
||||||
|
} as unknown as MessageEvent,
|
||||||
|
frame,
|
||||||
|
)
|
||||||
|
await prepareInstancePlayer('one')
|
||||||
|
assert.equal(prompts, 0)
|
||||||
|
assert.ok(calls.includes('plugin:auth|login_skin_site_player'))
|
||||||
|
await saveInstancePlayer('one', player)
|
||||||
|
assert.deepEqual(changes, ['one'])
|
||||||
|
failSave = true
|
||||||
|
await assert.rejects(saveInstancePlayer('two', player), /Save failed/)
|
||||||
|
assert.deepEqual(changes, ['one'])
|
||||||
|
resetSkinSiteSession()
|
||||||
|
await prepareInstancePlayer('one')
|
||||||
|
assert.equal(prompts, 1)
|
||||||
|
} finally {
|
||||||
|
stop()
|
||||||
|
unregister()
|
||||||
|
resetSkinSiteSession()
|
||||||
|
setSkinSiteFrame(null)
|
||||||
|
if (previous) Object.defineProperty(globalThis, 'window', previous)
|
||||||
|
else Reflect.deleteProperty(globalThis, 'window')
|
||||||
|
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator)
|
||||||
|
else Reflect.deleteProperty(globalThis, 'navigator')
|
||||||
|
}
|
||||||
|
})
|
||||||
162
apps/app-frontend/src/helpers/instance-player.ts
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
|
||||||
|
import {
|
||||||
|
requestSkinSiteDownloadToken,
|
||||||
|
skinSiteStatus,
|
||||||
|
skinSiteUser,
|
||||||
|
waitForSkinSiteSession,
|
||||||
|
} from '../composables/skin-site-session.ts'
|
||||||
|
export { waitForSkinSiteSession } from '../composables/skin-site-session.ts'
|
||||||
|
import { users } from './auth.js'
|
||||||
|
import { isOfflineMode } from '../composables/useNetworkStatus.ts'
|
||||||
|
|
||||||
|
export type InstancePlayer = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
account_type: 'microsoft' | 'yggdrasil' | 'offline'
|
||||||
|
skin_site_user?: string | null
|
||||||
|
}
|
||||||
|
export type PlayerChoice = InstancePlayer & { head?: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the player picker is dismissed because the user navigated to the
|
||||||
|
* skin-site login page. Callers should treat this as a silent launch abort
|
||||||
|
* (the user will pick a player next time), not as a real failure.
|
||||||
|
*/
|
||||||
|
export class PlayerSelectionNavigatedAwayError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('已跳转至皮肤站登录,请登录后重新启动。')
|
||||||
|
this.name = 'PlayerSelectionNavigatedAwayError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type Picker = (instanceId: string, locked: InstancePlayer | null) => Promise<InstancePlayer>
|
||||||
|
let picker: Picker | undefined
|
||||||
|
const preparing = new Map<string, Promise<void>>()
|
||||||
|
const listeners = new Set<(instanceId: string, player: InstancePlayer) => void>()
|
||||||
|
|
||||||
|
export function onInstancePlayerChanged(
|
||||||
|
listener: (instanceId: string, player: InstancePlayer) => void,
|
||||||
|
) {
|
||||||
|
listeners.add(listener)
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerInstancePlayerPicker(value: Picker) {
|
||||||
|
picker = value
|
||||||
|
return () => {
|
||||||
|
if (picker === value) picker = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const getInstancePlayer = (instanceId: string) =>
|
||||||
|
invoke<InstancePlayer | null>('plugin:auth|get_instance_player', { instanceId })
|
||||||
|
|
||||||
|
export async function authenticateInstancePlayer(player: InstancePlayer) {
|
||||||
|
if (!player.skin_site_user) return
|
||||||
|
if (skinSiteStatus.value !== 'signed-in' || skinSiteUser.value?.uuid !== player.skin_site_user) {
|
||||||
|
throw new Error(`请登录实例玩家 ${player.name} 所属的皮肤站账号。`)
|
||||||
|
}
|
||||||
|
const token = await requestSkinSiteDownloadToken()
|
||||||
|
await invoke('plugin:auth|login_skin_site_player', {
|
||||||
|
token,
|
||||||
|
playerId: player.id,
|
||||||
|
userId: player.skin_site_user,
|
||||||
|
})
|
||||||
|
if (skinSiteUser.value?.uuid !== player.skin_site_user || skinSiteStatus.value !== 'signed-in') {
|
||||||
|
throw new Error('皮肤站账号已变化,请重试。')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers every skin-site player as a launcher account so they show up in the
|
||||||
|
* account picker immediately after signing in to the skin site, instead of only
|
||||||
|
* after a first launch. Idempotent: players that already exist in the account
|
||||||
|
* list (matched by profile UUID) are skipped, and already-signed-in players are
|
||||||
|
* not re-requested. Best-effort: a single player failing does not abort the rest.
|
||||||
|
*/
|
||||||
|
export async function registerSkinSitePlayers(
|
||||||
|
playerIds: string[],
|
||||||
|
userUuid: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (playerIds.length === 0) return
|
||||||
|
if (skinSiteStatus.value !== 'signed-in' || skinSiteUser.value?.uuid !== userUuid) return
|
||||||
|
|
||||||
|
let known = new Set<string>()
|
||||||
|
try {
|
||||||
|
const existing = await users()
|
||||||
|
known = new Set(
|
||||||
|
(existing as Array<{ profile?: { id?: string } }>)
|
||||||
|
.map((account) => account?.profile?.id)
|
||||||
|
.filter((id): id is string => typeof id === 'string'),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// If the account list cannot be read, still attempt to register; the
|
||||||
|
// backend upsert is idempotent.
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const playerId of playerIds) {
|
||||||
|
if (known.has(playerId)) continue
|
||||||
|
if (skinSiteStatus.value !== 'signed-in' || skinSiteUser.value?.uuid !== userUuid) return
|
||||||
|
try {
|
||||||
|
// Request a fresh download token per player: the skin site login
|
||||||
|
// endpoint may bind a token to a single player id.
|
||||||
|
const token = await requestSkinSiteDownloadToken()
|
||||||
|
await invoke('plugin:auth|login_skin_site_player', {
|
||||||
|
token,
|
||||||
|
playerId,
|
||||||
|
userId: userUuid,
|
||||||
|
})
|
||||||
|
known.add(playerId)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to register skin site player ${playerId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveInstancePlayer(instanceId: string, player: InstancePlayer) {
|
||||||
|
await authenticateInstancePlayer(player)
|
||||||
|
await invoke('plugin:auth|set_instance_player', { instanceId, player })
|
||||||
|
for (const listener of listeners) listener(instanceId, player)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chooseInstancePlayer(
|
||||||
|
instanceId: string,
|
||||||
|
locked: InstancePlayer | null = null,
|
||||||
|
) {
|
||||||
|
if (!picker) throw new Error('玩家选择界面尚未就绪,请稍后重试。')
|
||||||
|
return picker(instanceId, locked)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareInstancePlayer(instanceId: string): Promise<void> {
|
||||||
|
if (isOfflineMode()) return Promise.resolve()
|
||||||
|
const current = preparing.get(instanceId)
|
||||||
|
if (current) return current
|
||||||
|
const task = (async () => {
|
||||||
|
const saved = await getInstancePlayer(instanceId)
|
||||||
|
if (saved && !saved.skin_site_user) {
|
||||||
|
const available = await users()
|
||||||
|
if (
|
||||||
|
available.some(
|
||||||
|
(account) =>
|
||||||
|
account.profile.id === saved.id && account.account_type === saved.account_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (saved?.skin_site_user) await waitForSkinSiteSession()
|
||||||
|
if (
|
||||||
|
saved?.skin_site_user &&
|
||||||
|
skinSiteUser.value?.uuid === saved.skin_site_user &&
|
||||||
|
skinSiteStatus.value === 'signed-in'
|
||||||
|
) {
|
||||||
|
await authenticateInstancePlayer(saved)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await chooseInstancePlayer(instanceId, saved)
|
||||||
|
})().finally(() => {
|
||||||
|
preparing.delete(instanceId)
|
||||||
|
})
|
||||||
|
preparing.set(instanceId, task)
|
||||||
|
return task
|
||||||
|
}
|
||||||
@ -17,6 +17,8 @@ import { collectGcContext } from '@/helpers/gc/context'
|
|||||||
import { detectGcStrategy, GC_STRATEGY_DEFINITIONS } from '@/helpers/gc/strategies'
|
import { detectGcStrategy, GC_STRATEGY_DEFINITIONS } from '@/helpers/gc/strategies'
|
||||||
import type { GcContext, ResolvedGcStrategyId } from '@/helpers/gc/types'
|
import type { GcContext, ResolvedGcStrategyId } from '@/helpers/gc/types'
|
||||||
import { setLastGcLaunchReport } from '@/helpers/gc-notice'
|
import { setLastGcLaunchReport } from '@/helpers/gc-notice'
|
||||||
|
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
|
||||||
|
import { prepareInstancePlayer } from '@/helpers/instance-player'
|
||||||
import { AUTO_GC_PRESET_ARG } from '@/helpers/java-arguments'
|
import { AUTO_GC_PRESET_ARG } from '@/helpers/java-arguments'
|
||||||
import { get_jre, get_memory_status } from '@/helpers/jre.js'
|
import { get_jre, get_memory_status } from '@/helpers/jre.js'
|
||||||
import { get as getSettings } from '@/helpers/settings'
|
import { get as getSettings } from '@/helpers/settings'
|
||||||
@ -652,10 +654,6 @@ export async function update_project(instanceId: string, projectPath: string): P
|
|||||||
return await invoke('plugin:instance|instance_update_project', { instanceId, projectPath })
|
return await invoke('plugin:instance|instance_update_project', { instanceId, projectPath })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a project to an instance from a version
|
|
||||||
// Returns a path to the new project file
|
|
||||||
export type DownloadReason = 'standalone' | 'dependency' | 'modpack' | 'update'
|
|
||||||
|
|
||||||
export interface ResolutionPreferences {
|
export interface ResolutionPreferences {
|
||||||
game_versions?: string[]
|
game_versions?: string[]
|
||||||
loaders?: string[]
|
loaders?: string[]
|
||||||
@ -691,14 +689,10 @@ export interface ResolveContentPlan {
|
|||||||
export async function add_project_from_version(
|
export async function add_project_from_version(
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
versionId: string,
|
versionId: string,
|
||||||
reason: DownloadReason,
|
|
||||||
dependentOnVersionId?: string,
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return await invoke('plugin:instance|instance_add_project_from_version', {
|
return await invoke('plugin:instance|instance_add_project_from_version', {
|
||||||
instanceId,
|
instanceId,
|
||||||
versionId,
|
versionId,
|
||||||
reason,
|
|
||||||
dependentOnVersionId,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1081,6 +1075,8 @@ export async function run(
|
|||||||
instanceId: string,
|
instanceId: string,
|
||||||
serverAddress: string | null = null,
|
serverAddress: string | null = null,
|
||||||
): Promise<InstanceRunResult> {
|
): Promise<InstanceRunResult> {
|
||||||
|
await prepareInstancePlayer(instanceId)
|
||||||
|
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession()
|
||||||
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
|
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
|
||||||
const result = await invoke<InstanceRunResult>('plugin:instance|instance_run', {
|
const result = await invoke<InstanceRunResult>('plugin:instance|instance_run', {
|
||||||
instanceId,
|
instanceId,
|
||||||
|
|||||||
@ -175,9 +175,6 @@ export type AppSettings = {
|
|||||||
home_widgets: HomeDashboardConfig | null
|
home_widgets: HomeDashboardConfig | null
|
||||||
terracotta_public_nodes: string[]
|
terracotta_public_nodes: string[]
|
||||||
|
|
||||||
telemetry: boolean
|
|
||||||
telemetry_consent_version: number
|
|
||||||
discord_rpc: boolean
|
|
||||||
onboarded: boolean
|
onboarded: boolean
|
||||||
onboarding_version: number
|
onboarding_version: number
|
||||||
onboarding_instance_tour_completed: boolean
|
onboarding_instance_tour_completed: boolean
|
||||||
@ -186,6 +183,7 @@ export type AppSettings = {
|
|||||||
custom_env_vars: [string, string][]
|
custom_env_vars: [string, string][]
|
||||||
memory: MemorySettings
|
memory: MemorySettings
|
||||||
force_fullscreen: boolean
|
force_fullscreen: boolean
|
||||||
|
force_unicode_font: boolean
|
||||||
maximize_window: boolean
|
maximize_window: boolean
|
||||||
game_resolution: [number, number]
|
game_resolution: [number, number]
|
||||||
hide_on_process_start: boolean
|
hide_on_process_start: boolean
|
||||||
@ -207,12 +205,6 @@ export type AppSettings = {
|
|||||||
version: number
|
version: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PrivacySettings = {
|
|
||||||
telemetry: boolean
|
|
||||||
discord_rpc: boolean
|
|
||||||
consent_version: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type LegacyMirrorSettings = {
|
type LegacyMirrorSettings = {
|
||||||
use_minecraft_mirror?: boolean
|
use_minecraft_mirror?: boolean
|
||||||
use_modrinth_mirror?: boolean
|
use_modrinth_mirror?: boolean
|
||||||
@ -236,6 +228,7 @@ function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings)
|
|||||||
settings.auto_concurrent_downloads ??= true
|
settings.auto_concurrent_downloads ??= true
|
||||||
settings.download_engine ??= 'legacy'
|
settings.download_engine ??= 'legacy'
|
||||||
settings.auto_set_java_high_performance_mode ??= true
|
settings.auto_set_java_high_performance_mode ??= true
|
||||||
|
settings.force_unicode_font ??= false
|
||||||
settings.minecraft_metadata_source ??=
|
settings.minecraft_metadata_source ??=
|
||||||
usesLegacyDefaults || !hasLegacySettings ? 'auto' : legacySource(settings.use_minecraft_mirror)
|
usesLegacyDefaults || !hasLegacySettings ? 'auto' : legacySource(settings.use_minecraft_mirror)
|
||||||
settings.minecraft_file_source ??=
|
settings.minecraft_file_source ??=
|
||||||
@ -301,22 +294,6 @@ export async function cancel_directory_change(): Promise<void> {
|
|||||||
return await invoke('plugin:settings|cancel_directory_change')
|
return await invoke('plugin:settings|cancel_directory_change')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPrivacySettings(): Promise<PrivacySettings> {
|
|
||||||
return await invoke('plugin:settings|privacy_get')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function savePrivacySettings(privacy: PrivacySettings): Promise<PrivacySettings> {
|
|
||||||
return await invoke('plugin:settings|privacy_set', { privacy })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setTelemetryEnabled(enabled: boolean): Promise<PrivacySettings> {
|
|
||||||
return await invoke('plugin:settings|telemetry_set', { enabled })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setDiscordRpcEnabled(enabled: boolean): Promise<PrivacySettings> {
|
|
||||||
return await invoke('plugin:settings|discord_rpc_set', { enabled })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProxyConfig(): Promise<ProxyConfig> {
|
export async function getProxyConfig(): Promise<ProxyConfig> {
|
||||||
return await invoke('plugin:settings|proxy_get')
|
return await invoke('plugin:settings|proxy_get')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -118,6 +118,10 @@ export async function get_available_skins(): Promise<Skin[]> {
|
|||||||
return invoke('plugin:minecraft-skins|get_available_skins', {})
|
return invoke('plugin:minecraft-skins|get_available_skins', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function get_default_skins(): Promise<Skin[]> {
|
||||||
|
return invoke('plugin:minecraft-skins|get_default_skins', {})
|
||||||
|
}
|
||||||
|
|
||||||
export async function add_and_equip_custom_skin(
|
export async function add_and_equip_custom_skin(
|
||||||
textureBlob: Uint8Array,
|
textureBlob: Uint8Array,
|
||||||
variant: SkinModel,
|
variant: SkinModel,
|
||||||
|
|||||||
@ -6,6 +6,9 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
|
||||||
export interface LoadingBarType {
|
export interface LoadingBarType {
|
||||||
|
batch_id?: string
|
||||||
|
file_name?: string
|
||||||
|
error?: string | null
|
||||||
type?: string
|
type?: string
|
||||||
version?: string
|
version?: string
|
||||||
instance_id?: string
|
instance_id?: string
|
||||||
@ -25,12 +28,7 @@ export interface LoadingBar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type OpeningCommandEvent =
|
export type OpeningCommandEvent =
|
||||||
| 'RunMRPack'
|
'RunMRPack' | 'InstallServer' | 'InstallVersion' | 'InstallMod' | 'InstallModpack' | string
|
||||||
| 'InstallServer'
|
|
||||||
| 'InstallVersion'
|
|
||||||
| 'InstallMod'
|
|
||||||
| 'InstallModpack'
|
|
||||||
| string
|
|
||||||
|
|
||||||
export interface OpeningCommand {
|
export interface OpeningCommand {
|
||||||
event: OpeningCommandEvent
|
event: OpeningCommandEvent
|
||||||
@ -44,10 +42,6 @@ export async function initialize_state() {
|
|||||||
return await invoke<void>('initialize_state')
|
return await invoke<void>('initialize_state')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function set_discord_activity(activity: string) {
|
|
||||||
return await invoke<void>('set_discord_activity', { activity })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gets active progress bars
|
// Gets active progress bars
|
||||||
export async function progress_bars_list() {
|
export async function progress_bars_list() {
|
||||||
return await invoke<Record<string, LoadingBar>>('plugin:utils|progress_bars_list')
|
return await invoke<Record<string, LoadingBar>>('plugin:utils|progress_bars_list')
|
||||||
|
|||||||