Compare commits

..

8 Commits

Author SHA1 Message Date
b273113dfb feat: embed StarLight skin site and sync login state
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-09-13 21:34:09 +08:00
fa96226beb refactor: remove telemetry and fix launcher workflows
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Deploy telemetry dashboard / deploy (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
2026-09-13 20:32:57 +08:00
ccc7f7e16b feat: add Starlight Mine Chromatic Realms easter egg 2026-09-13 20:32:57 +08:00
f8aa654198 fix:修复了一下皮肤站的登录逻辑
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-09-13 20:18:04 +08:00
89aa661e75 修复了角色列表未能完全同步的问题
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-09-13 18:56:53 +08:00
e53c0bf1fd fix:关于页 tooltip 残留
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-09-13 18:40:47 +08:00
8e9b0894ba fix:一些显示bug
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-09-13 18:25:36 +08:00
f81a89adc9 feat:神秘彩蛋 2026-09-13 18:16:18 +08:00
209 changed files with 3559 additions and 12607 deletions

51
Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -57,6 +57,7 @@ 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'
@ -84,7 +85,6 @@ import InstanceIconPickerModal from '@/components/ui/modal/InstanceIconPickerMod
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'
@ -97,7 +97,6 @@ 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'
@ -118,16 +117,13 @@ 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,
@ -368,8 +364,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)
@ -764,9 +758,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',
@ -1054,9 +1048,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,
@ -1100,7 +1091,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
@ -1131,16 +1121,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()
@ -1273,42 +1254,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)
@ -1319,7 +1265,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
@ -1428,9 +1373,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({
@ -1446,30 +1388,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()
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) {
@ -1506,7 +1429,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
@ -1622,7 +1544,6 @@ const dropImport = useDropImport({
onSkinsPage, onSkinsPage,
onSchematicWorkshopPage, onSchematicWorkshopPage,
isSchematicFile, isSchematicFile,
trackEvent,
router, router,
}) })
@ -1834,9 +1755,6 @@ 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)
@ -2551,7 +2469,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" />
@ -2575,7 +2493,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)"

View File

@ -32,6 +32,68 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
] ]
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
id: 'launcher-1.0.0-beta1',
version: '1.0.0-beta1',
publishedAt: '2026-09-13',
title: {
'en-US': 'Starlight Launcher 1.0.0-beta1',
'zh-CN': 'Starlight Launcher 1.0.0-beta1',
},
changes: {
changed: [
{
'en-US':
'The launcher has been rebranded from Axolotl Launcher to Starlight Launcher, with a new icon and a refreshed about page.',
'zh-CN': '启动器已从 Axolotl Launcher 更名为 Starlight Launcher并更换了全新图标与关于页。',
},
{
'en-US':
'The "Powered by" and copyright notices now correctly credit Axolotl Launcher as the upstream project.',
'zh-CN': '版权与 "Powered by" 署名现在正确标注上游项目 Axolotl Launcher。',
},
{
'en-US': 'The developer list on the about page now shows Ax_Tps and Disy920.',
'zh-CN': '关于页的开发组现在显示 Ax_Tps 与 Disy920。',
},
],
added: [
{
'en-US': 'A "Starlight" entry was added to the sidebar, which embeds the StarLight skin site.',
'zh-CN': '侧边栏新增「斯达莱特」入口,内嵌 StarLight 皮肤站。',
},
{
'en-US':
'A launch progress panel was added to the home sidebar, showing the current launch stage and progress.',
'zh-CN': '主页侧边栏新增启动进度面板,显示当前启动阶段与进度。',
},
{
'en-US':
'An easter egg mini-game (star merge) was added, reachable via the secret code "starlight", the Konami code, or a long press on a developer name on the about page.',
'zh-CN':
'新增彩蛋小游戏(下界之星合成),可通过暗号 starlight、Konami 秘技或长按关于页开发者名字触发。',
},
],
removed: [
{
'en-US':
'The hard-coded Starlight official server was removed from "Pinned servers"; only servers you favorite yourself are shown now.',
'zh-CN': '移除了「固定的服务器」中写死的 Starlight 官方服务器,现在只显示你自己收藏的服务器。',
},
{
'en-US': 'The swimming axolotl animation on the about page was replaced with a static nether star.',
'zh-CN': '关于页中游动的美西螈动画已替换为静止的下界之星。',
},
],
fixed: [
{
'en-US':
'Minecraft now launches in a maximized window when the maximize option is enabled.',
'zh-CN': '启用最大化选项后Minecraft 现在会以最大化窗口启动。',
},
],
},
},
{ {
id: 'launcher-1.9.6-beta.3', id: 'launcher-1.9.6-beta.3',
version: '1.9.6-beta.3', version: '1.9.6-beta.3',

View File

@ -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({

View File

@ -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 })

View File

@ -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() {

View File

@ -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) {

View File

@ -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>

View File

@ -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>

View File

@ -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,

View File

@ -1,4 +1,23 @@
<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>
<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"
@ -17,18 +36,24 @@
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>
</ButtonStyled>
<ButtonStyled v-if="!offline && skinSiteUser">
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
<PlusIcon />
{{ formatMessage(messages.addSkinGameAccount) }}
</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>
@ -137,15 +162,15 @@
</template> </template>
<div class="flex flex-col gap-2 px-2 pt-2"> <div class="flex flex-col gap-2 px-2 pt-2">
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full"> <ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
<button :disabled="loginDisabled" @click="login()"> <button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
<PlusIcon /> <PlusIcon />
{{ formatMessage(messages.addMicrosoftAccount) }} {{ formatMessage(messages.addSkinGameAccount) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full"> <ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()"> <button :disabled="loginDisabled" @click="login()">
<PlusIcon /> <PlusIcon />
{{ formatMessage(messages.addThirdPartyAccount) }} {{ formatMessage(messages.addMicrosoftAccount) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@ -262,6 +287,7 @@ import {
Accordion, Accordion,
Avatar, Avatar,
ButtonStyled, ButtonStyled,
Checkbox,
commonMessages, commonMessages,
defineMessages, defineMessages,
injectNotificationManager, injectNotificationManager,
@ -272,15 +298,15 @@ 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 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 ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { openSkinSiteLogin, 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, begin_yggdrasil_login,
delete_yggdrasil_password, delete_yggdrasil_password,
@ -299,12 +325,22 @@ 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 { 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)
/** /**
@ -678,7 +714,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 {
@ -833,7 +868,6 @@ async function addYggdrasilAccount() {
await persistYggdrasilPasswordPreference() await persistYggdrasilPasswordPreference()
yggdrasilAccountModal.value?.hide() yggdrasilAccountModal.value?.hide()
await setAccount(result.credentials) await setAccount(result.credentials)
trackEvent('YggdrasilAccountAdd')
} else { } else {
pendingYggdrasilFlowId.value = result.flow_id pendingYggdrasilFlowId.value = result.flow_id
pendingYggdrasilProfiles.value = result.profiles pendingYggdrasilProfiles.value = result.profiles
@ -859,7 +893,6 @@ async function selectYggdrasilProfile(profileId: string) {
await persistYggdrasilPasswordPreference() await persistYggdrasilPasswordPreference()
yggdrasilProfileModal.value?.hide() yggdrasilProfileModal.value?.hide()
await setAccount(account) await setAccount(account)
trackEvent('YggdrasilAccountAdd')
} catch (error) { } catch (error) {
handleError(error as Error) handleError(error as Error)
} finally { } finally {
@ -875,7 +908,6 @@ async function logout(account: MinecraftCredential) {
} else { } else {
notifyAccountChange() notifyAccountChange()
} }
trackEvent('AccountLogOut')
} }
async function copyAccountUuid(account: MinecraftCredential) { async function copyAccountUuid(account: MinecraftCredential) {
@ -902,6 +934,22 @@ 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.',
},
addSkinGameAccount: {
id: 'minecraft-account.add-skin-game-account',
defaultMessage: 'Add skin site game account',
},
offlineMode: { offlineMode: {
id: 'minecraft-account.offline-mode', id: 'minecraft-account.offline-mode',
defaultMessage: 'Offline mode', defaultMessage: 'Offline mode',
@ -921,11 +969,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',
@ -1069,10 +1117,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?',

View File

@ -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'
@ -437,11 +436,6 @@ 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)
} }

View File

@ -225,12 +225,21 @@ const breadcrumbs = computed<Breadcrumb[]>(() => {
function resolveLabel(name: string): string { function resolveLabel(name: string): string {
return resolveBreadcrumbLabel( return resolveBreadcrumbLabel(
name, name,
(key) => breadcrumbData.getName(key), (key) => breadcrumbData.getName(key) || fallbackDynamicLabel(key),
staticLabels, staticLabels,
(message) => formatMessage(message), (message) => formatMessage(message),
) )
} }
// 动态面包屑(?Xxx在页面异步 setName 之前,回退到静态标签,避免图标与文字不同步
function fallbackDynamicLabel(key: string): string {
const fallback: Record<string, keyof typeof staticLabels> = {
BrowseTitle: 'Discover content',
}
const staticKey = fallback[key]
return staticKey ? formatMessage(staticLabels[staticKey]) : ''
}
function resolveIcon(breadcrumb: Breadcrumb): Component | undefined { function resolveIcon(breadcrumb: Breadcrumb): Component | undefined {
if (breadcrumb.iconUrl || breadcrumbData.getIcon(breadcrumb.name.slice(1))) return undefined if (breadcrumb.iconUrl || breadcrumbData.getIcon(breadcrumb.name.slice(1))) return undefined
const dynamicIcons: Record<string, Component> = { const dynamicIcons: Record<string, Component> = {

View File

@ -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) {

View File

@ -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) => {

View File

@ -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>

View File

@ -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 {

View File

@ -0,0 +1,58 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, onUnmounted, ref } from 'vue'
import {
receiveSkinSiteSession,
resetSkinSiteSession,
SKIN_SITE_ORIGIN,
skinSiteFrameUrl,
} from '@/composables/skin-site-session'
const { formatMessage } = useVIntl()
const messages = defineMessages({
frameTitle: {
id: 'app.starlight-skin.frame-title',
defaultMessage: 'StarLight Skin Site',
},
})
const frame = ref<HTMLIFrameElement>()
let lastMessage = 0
let expiryTimer: ReturnType<typeof setInterval> | undefined
function connect() {
frame.value?.contentWindow?.postMessage(
{ type: 'starlight-skin-session-connect' },
SKIN_SITE_ORIGIN,
)
}
function receive(event: MessageEvent) {
if (receiveSkinSiteSession(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)
resetSkinSiteSession()
})
</script>
<template>
<iframe
ref="frame"
:src="skinSiteFrameUrl"
:title="formatMessage(messages.frameTitle)"
class="block h-full min-h-0 w-full border-0"
@load="connect"
/>
</template>

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import { Avatar } from '@modrinth/ui'
import { onScopeDispose, ref } from 'vue'
defineProps<{ src: string; name: string; href?: string }>()
const emit = defineEmits<{ activate: [] }>()
const holding = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
let origin = { x: 0, y: 0 }
let suppressClick = false
function cancel() {
clearTimeout(timer)
timer = undefined
holding.value = false
}
function begin() {
cancel()
suppressClick = false
holding.value = true
timer = setTimeout(() => {
cancel()
suppressClick = true
emit('activate')
}, 800)
}
function pointerDown(event: PointerEvent) {
if (event.button !== 0 || !event.isPrimary) return
origin = { x: event.clientX, y: event.clientY }
begin()
}
function pointerMove(event: PointerEvent) {
if (Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > 8) cancel()
}
function click(event: MouseEvent) {
if (!suppressClick) return
event.preventDefault()
event.stopPropagation()
suppressClick = false
}
function keydown(event: KeyboardEvent) {
if (event.code === 'Space') {
event.preventDefault()
if (!event.repeat) begin()
}
}
window.addEventListener('blur', cancel)
onScopeDispose(() => {
cancel()
window.removeEventListener('blur', cancel)
})
</script>
<template>
<a
:href="href"
target="_blank"
rel="noopener noreferrer"
class="mine-avatar"
:class="{ holding }"
@pointerdown="pointerDown"
@pointermove="pointerMove"
@pointerup="cancel"
@pointercancel="cancel"
@pointerleave="cancel"
@blur="cancel"
@click="click"
@contextmenu.prevent
@dragstart.prevent
@keydown="keydown"
@keyup.space.prevent="cancel"
>
<Avatar :src="src" :alt="name" size="2.5rem" circle no-shadow loading="lazy" />
</a>
</template>
<style scoped>
.mine-avatar {
display: inline-flex;
flex-shrink: 0;
border-radius: 50%;
user-select: none;
touch-action: manipulation;
}
.mine-avatar.holding {
outline: 2px solid var(--color-brand);
outline-offset: 3px;
}
.mine-avatar:focus-visible {
outline: 2px solid var(--color-contrast);
outline-offset: 3px;
}
</style>

View File

@ -0,0 +1,323 @@
<script setup lang="ts">
import { NewButton as Button, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onMounted, onScopeDispose, ref } 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 view = ref({ left: 0, top: 0, width: 0, height: 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))
}
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(() => {
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
class="mine-grid"
:class="{ 'mine-grid-small': !large }"
:style="{ '--mine-size': puzzle.size, '--mine-cell-size': `${40 * zoom}px` }"
:aria-label="formatMessage(messages.board, { size: puzzle.size })"
>
<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 {
min-width: 0;
display: grid;
gap: var(--gap-md);
}
.mine-large {
grid-template-columns: minmax(0, 1fr) 7rem;
}
.mine-viewport {
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: crosshair;
user-select: none;
touch-action: manipulation;
}
.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>

View File

@ -0,0 +1,482 @@
<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"
: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 {
border: 2px solid var(--mine-ink);
border-radius: var(--radius-md);
padding: var(--gap-md);
background: var(--surface-2);
}
.mine-status {
margin: 0 0 var(--gap-md);
color: var(--color-contrast);
font-size: 0.875rem;
}
.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>

View File

@ -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,
)
})

View File

@ -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 }
}

View File

@ -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 })
}
}

View File

@ -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',
},
})

View File

@ -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;
}

View File

@ -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))
})

View File

@ -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]
}

View File

@ -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)
} }

View File

@ -18,7 +18,6 @@ import { useRouter } from 'vue-router'
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 +48,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 +99,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 +110,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)
@ -253,10 +246,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)

View File

@ -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
} }

View File

@ -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>

View File

@ -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;

View File

@ -204,15 +204,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' }, updatesTitle: { id: 'app.onboarding.updates.title', defaultMessage: 'Stay in the loop' },
updatesDescription: { updatesDescription: {
id: 'app.onboarding.updates.description', id: 'app.onboarding.updates.description',

View File

@ -1,19 +1,15 @@
<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 { inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue' import { openUrl } from '@tauri-apps/plugin-opener'
import { defineAsyncComponent, inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
import ColorMineAvatar from '@/components/ui/easteregg/color-mine/ColorMineAvatar.vue'
import EasterEggContributorsModal from '@/components/ui/easteregg/EasterEggContributorsModal.vue' import 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'
@ -23,7 +19,7 @@ const version = await getVersion()
const experienceHost = ref<HTMLElement>() const experienceHost = ref<HTMLElement>()
const activeMemberExperience = shallowRef<AboutMemberExperience>() const activeMemberExperience = shallowRef<AboutMemberExperience>()
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')
@ -77,9 +73,28 @@ 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']
const YUANSHEN_URL = 'https://ys.mihoyo.com/cloud/'
const konamiSequence = [ const konamiSequence = [
'ArrowUp', 'ArrowUp',
@ -101,6 +116,11 @@ function handleEasterEggKeydown(event: KeyboardEvent) {
if (typedBuffer.length > maxCodeLen) { if (typedBuffer.length > maxCodeLen) {
typedBuffer = typedBuffer.slice(-maxCodeLen) typedBuffer = typedBuffer.slice(-maxCodeLen)
} }
if (typedBuffer.endsWith('yuanshen')) {
typedBuffer = ''
openUrl(YUANSHEN_URL).catch(() => {})
return
}
if (secretCodes.some((code) => typedBuffer.endsWith(code))) { if (secretCodes.some((code) => typedBuffer.endsWith(code))) {
typedBuffer = '' typedBuffer = ''
gameModal.value?.show() gameModal.value?.show()
@ -159,7 +179,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',
@ -187,7 +208,6 @@ const messages = defineMessages({
defaultMessage: '{count, plural, one {# contributor} other {# contributors}}', defaultMessage: '{count, plural, one {# contributor} other {# contributors}}',
}, },
}) })
</script> </script>
<template> <template>
@ -230,7 +250,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" />
@ -281,33 +300,56 @@ const messages = defineMessages({
{{ formatMessage(messages.developmentTeam) }} {{ formatMessage(messages.developmentTeam) }}
</h3> </h3>
<div class="grid gap-3 sm:grid-cols-2"> <div class="grid gap-3 sm:grid-cols-2">
<a <template v-for="member in teamMembers" :key="member.name">
v-for="member in teamMembers" <div
:key="member.name" v-if="member.name === 'Disy920'"
:href="member.url ?? undefined" class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
target="_blank" >
rel="noopener noreferrer" <ColorMineAvatar
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5" :src="member.avatarUrl"
@pointerdown="startMemberLongPress(member, $event)" :name="member.name"
@pointermove="moveMemberLongPress" :href="member.url"
@pointerup="cancelMemberLongPress" @activate="openColorMine"
@pointerleave="cancelMemberLongPress" />
@click="handleMemberClick" <a
@contextmenu="handleMemberContextMenu(member, $event)" :href="member.url"
> target="_blank"
<Avatar rel="noopener noreferrer"
:src="member.avatarUrl" class="flex min-w-0 flex-1 items-center gap-3"
:alt="member.name" >
size="2.5rem" <span class="min-w-0 flex-1 truncate font-semibold text-contrast">{{
circle member.name
no-shadow }}</span>
loading="lazy" <ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
/> </a>
<span class="min-w-0 flex-1 truncate font-semibold text-contrast"> </div>
{{ member.name }} <a
</span> v-else
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" /> :href="member.url ?? undefined"
</a> target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
@pointerdown="startMemberLongPress(member, $event)"
@pointermove="moveMemberLongPress"
@pointerup="cancelMemberLongPress"
@pointerleave="cancelMemberLongPress"
@click="handleMemberClick"
@contextmenu="handleMemberContextMenu(member, $event)"
>
<Avatar
:src="member.avatarUrl"
:alt="member.name"
size="2.5rem"
circle
no-shadow
loading="lazy"
/>
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">
{{ member.name }}
</span>
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
</a>
</template>
</div> </div>
</section> </section>
<details class="group pt-4 about-settings-details"> <details class="group pt-4 about-settings-details">
@ -353,6 +395,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>

View File

@ -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)

View File

@ -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>

View File

@ -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) {

View File

@ -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>

View File

@ -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' }),

View File

@ -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')),

View File

@ -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',

View File

@ -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'],
@ -169,7 +168,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'])

View File

@ -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
} }

View File

@ -0,0 +1,51 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
openSkinSiteLogin,
receiveSkinSiteSession,
resetSkinSiteSession,
SKIN_SITE_ORIGIN,
skinSiteFrameUrl,
skinSiteStatus,
skinSiteUser,
} from './skin-site-session.ts'
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(
receiveSkinSiteSession({ ...signedIn, origin: 'https://evil.example' } as MessageEvent, frame),
false,
)
assert.equal(receiveSkinSiteSession(signedIn, {} as Window), false)
assert.equal(receiveSkinSiteSession(message('signed-in', {}), frame), false)
assert.equal(skinSiteUser.value, null)
assert.equal(receiveSkinSiteSession(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(receiveSkinSiteSession(message('checking'), frame), true)
assert.equal(skinSiteUser.value, null)
receiveSkinSiteSession(message('signed-in', { uuid: 'second', username: '另一个账号' }), frame)
assert.deepEqual(skinSiteUser.value, { uuid: 'second', username: '另一个账号' })
receiveSkinSiteSession(message('signed-out'), frame)
assert.equal(skinSiteUser.value, null)
assert.equal(skinSiteStatus.value, 'signed-out')
receiveSkinSiteSession(message('error'), frame)
assert.equal(skinSiteStatus.value, 'error')
resetSkinSiteSession()
})

View File

@ -0,0 +1,48 @@
import { readonly, ref } 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'
const user = ref<SkinSiteUser | null>(null)
const status = ref<SkinSiteStatus>('signed-out')
const frameUrl = ref(`${SKIN_SITE_ORIGIN}/`)
export const skinSiteUser = readonly(user)
export const skinSiteStatus = readonly(status)
export const skinSiteFrameUrl = readonly(frameUrl)
export function openSkinSiteLogin() {
frameUrl.value = `${SKIN_SITE_ORIGIN}/login`
}
export function resetSkinSiteSession() {
user.value = null
status.value = 'signed-out'
}
export function receiveSkinSiteSession(event: MessageEvent, frame: Window | null) {
if (!frame || event.source !== frame || event.origin !== SKIN_SITE_ORIGIN) return false
const data = event.data
if (!data || data.type !== 'starlight-skin-session') return false
if (!['checking', 'signed-out', 'signed-in', 'error'].includes(data.status)) return 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
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`
}
} else {
user.value = null
}
status.value = data.status
return true
}

View File

@ -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
} }

View File

@ -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 {

View File

@ -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])
}
}

View File

@ -652,10 +652,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 +687,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,
}) })
} }

View File

@ -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
@ -207,12 +204,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
@ -301,22 +292,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')
} }

View File

@ -44,10 +44,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')

View File

@ -1,7 +0,0 @@
import { invoke } from '@tauri-apps/api/core'
export function installTelemetryHandlers(): void {
window.addEventListener('online', () => {
void invoke('plugin:telemetry|notify_online').catch(() => undefined)
})
}

View File

@ -216,8 +216,6 @@ type AppSettings = {
close_behavior: 'ask' | 'close' | 'lightweight' close_behavior: 'ask' | 'close' | 'lightweight'
home_widgets: import('@/components/home/home-dashboard').HomeDashboardConfig | null home_widgets: import('@/components/home/home-dashboard').HomeDashboardConfig | null
telemetry: boolean
discord_rpc: boolean
developer_mode: boolean developer_mode: boolean
onboarded: boolean onboarded: boolean

View File

@ -1,4 +1,113 @@
{ {
"minecraft-account.skin-site.signed-in": { "message": "Signed in to StarLight Skin Site" },
"minecraft-account.skin-site.checking": { "message": "Checking skin site session…" },
"minecraft-account.skin-site.sync-error": { "message": "Could not verify the skin site session. Retrying automatically." },
"minecraft-account.add-skin-game-account": { "message": "Add skin site game account" },
"app.easteregg.color-mine.title": {
"message": "Starlight Mine: Chromatic Realms"
},
"app.easteregg.color-mine.easy": {
"message": "Easy"
},
"app.easteregg.color-mine.normal": {
"message": "Normal"
},
"app.easteregg.color-mine.hard": {
"message": "Hard"
},
"app.easteregg.color-mine.newGame": {
"message": "New board"
},
"app.easteregg.color-mine.close": {
"message": "Close"
},
"app.easteregg.color-mine.choose": {
"message": "Click a revealed tile to sample its color, then paint a gray tile."
},
"app.easteregg.color-mine.selected": {
"message": "Color {color} selected · Click a colored tile to change your brush."
},
"app.easteregg.color-mine.rulesTitle": {
"message": "How to play"
},
"app.easteregg.color-mine.rules": {
"message": "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."
},
"app.easteregg.color-mine.loading": {
"message": "Generating regions and checking the deduction path…"
},
"app.easteregg.color-mine.generateError": {
"message": "Could not generate this board. Try again."
},
"app.easteregg.color-mine.retry": {
"message": "Try again"
},
"app.easteregg.color-mine.progress": {
"message": "{count} / {total} revealed"
},
"app.easteregg.color-mine.best": {
"message": "Best · {time}"
},
"app.easteregg.color-mine.won": {
"message": "All colors restored!"
},
"app.easteregg.color-mine.lost": {
"message": "Wrong color at row {row}, column {column}: selected {selected}, correct {correct}. Game over."
},
"app.easteregg.color-mine.review": {
"message": "Unrevealed answers are now shown faded for review."
},
"app.easteregg.color-mine.leaveTitle": {
"message": "Leave this board?"
},
"app.easteregg.color-mine.leaveText": {
"message": "Save to continue later, or discard this unfinished board."
},
"app.easteregg.color-mine.saveLeave": {
"message": "Save and leave"
},
"app.easteregg.color-mine.discard": {
"message": "Discard"
},
"app.easteregg.color-mine.continue": {
"message": "Keep playing"
},
"app.easteregg.color-mine.replaceTitle": {
"message": "Start a new board?"
},
"app.easteregg.color-mine.replaceText": {
"message": "The current board will be discarded."
},
"app.easteregg.color-mine.savedTitle": {
"message": "An unfinished board is saved"
},
"app.easteregg.color-mine.resume": {
"message": "Resume saved game"
},
"app.easteregg.color-mine.storageError": {
"message": "Could not read or write the saved game. Your current board has been kept. Retry or explicitly discard it."
},
"app.easteregg.color-mine.recordError": {
"message": "Finished, but the best time could not be saved."
},
"app.easteregg.color-mine.zoomIn": {
"message": "Zoom in"
},
"app.easteregg.color-mine.zoomOut": {
"message": "Zoom out"
},
"app.easteregg.color-mine.map": {
"message": "Board overview. Click to navigate."
},
"app.easteregg.color-mine.board": {
"message": "{size} by {size} color deduction board"
},
"app.easteregg.color-mine.hidden": {
"message": "Row {row}, column {column}, unrevealed"
},
"app.easteregg.color-mine.revealed": {
"message": "Row {row}, column {column}, color {color}, {number} same-color neighbors"
},
"app.account.signed-in-as": { "app.account.signed-in-as": {
"message": "Signed in as" "message": "Signed in as"
}, },
@ -5174,8 +5283,8 @@
"app.minecraft-crash.view-mod-changes": { "app.minecraft-crash.view-mod-changes": {
"message": "View Mod changes" "message": "View Mod changes"
}, },
"app.minecraft.playing-as": { "app.minecraft.user-information": {
"message": "Playing as" "message": "User information"
}, },
"app.modal.install-to-play.content-required": { "app.modal.install-to-play.content-required": {
"message": "Content required" "message": "Content required"
@ -5807,12 +5916,6 @@
"app.onboarding.mascot-alt": { "app.onboarding.mascot-alt": {
"message": "Starlight guide" "message": "Starlight guide"
}, },
"app.onboarding.privacy.description": {
"message": "Manage anonymous telemetry, Discord Rich Presence, and the Minecraft log analysis service whenever you need to."
},
"app.onboarding.privacy.title": {
"message": "Your data, your call"
},
"app.onboarding.resources.description": { "app.onboarding.resources.description": {
"message": "Choose how content downloads and installs, from download sources to safety checks." "message": "Choose how content downloads and installs, from download sources to safety checks."
}, },
@ -5858,30 +5961,6 @@
"app.onboarding.welcome.title": { "app.onboarding.welcome.title": {
"message": "Everything is ready" "message": "Everything is ready"
}, },
"app.privacy-consent.continue": {
"message": "Save and continue"
},
"app.privacy-consent.discord-rpc": {
"message": "Discord Rich Presence"
},
"app.privacy-consent.discord-rpc-description": {
"message": "Shows your current launcher or game activity in Discord when Discord is running."
},
"app.privacy-consent.intro": {
"message": "Choose what Starlight may send or display. Nothing is sent until you confirm these choices."
},
"app.privacy-consent.privacy-policy": {
"message": "Read the privacy policy"
},
"app.privacy-consent.telemetry": {
"message": "Allow anonymous telemetry"
},
"app.privacy-consent.telemetry-description": {
"message": "Helps count opted-in installations and daily active users. Full Minecraft logs and account credentials are never uploaded."
},
"app.privacy-consent.title": {
"message": "Privacy & security"
},
"app.project.curseforge.loading": { "app.project.curseforge.loading": {
"message": "Loading CurseForge project…" "message": "Loading CurseForge project…"
}, },
@ -6701,9 +6780,6 @@
"app.settings.about.preview-minecraft-crash-modal": { "app.settings.about.preview-minecraft-crash-modal": {
"message": "Preview Minecraft crash window" "message": "Preview Minecraft crash window"
}, },
"app.settings.about.preview-privacy-consent-modal": {
"message": "Preview privacy & security modal"
},
"app.settings.about.product-title": { "app.settings.about.product-title": {
"message": "About {productName}" "message": "About {productName}"
}, },
@ -6965,21 +7041,6 @@
"app.settings.java.view-installed": { "app.settings.java.view-installed": {
"message": "View installed Java" "message": "View installed Java"
}, },
"app.settings.privacy.data-handling": {
"message": "Telemetry uses a random installation identifier and sends only a daily activity signal. Turning telemetry off clears pending data immediately."
},
"app.settings.privacy.discord-rpc": {
"message": "Discord Rich Presence"
},
"app.settings.privacy.discord-rpc-description": {
"message": "Show your current launcher or game activity in Discord."
},
"app.settings.privacy.telemetry": {
"message": "Allow telemetry"
},
"app.settings.privacy.telemetry-description": {
"message": "Send one anonymous daily activity signal to improve usage statistics. Minecraft logs and account credentials are never uploaded."
},
"app.settings.resources.add-minecraft-directory": { "app.settings.resources.add-minecraft-directory": {
"message": "Add .minecraft directory" "message": "Add .minecraft directory"
}, },
@ -7361,9 +7422,6 @@
"app.settings.tabs.network-multiplayer": { "app.settings.tabs.network-multiplayer": {
"message": "Network & multiplayer" "message": "Network & multiplayer"
}, },
"app.settings.tabs.privacy-data": {
"message": "Privacy & data sharing"
},
"app.settings.tabs.storage": { "app.settings.tabs.storage": {
"message": "Storage" "message": "Storage"
}, },
@ -10020,13 +10078,13 @@
"message": "World is in use" "message": "World is in use"
}, },
"minecraft-account.add-microsoft-account": { "minecraft-account.add-microsoft-account": {
"message": "Add Microsoft account" "message": "Add your own Minecraft account"
}, },
"minecraft-account.add-offline-account": { "minecraft-account.add-offline-account": {
"message": "Add offline account" "message": "Add offline account"
}, },
"minecraft-account.add-third-party-account": { "minecraft-account.sign-in-starlight": {
"message": "Add third-party account" "message": "Sign in to StarLight Skin Site"
}, },
"minecraft-account.copy-uuid": { "minecraft-account.copy-uuid": {
"message": "Copy UUID" "message": "Copy UUID"

View File

@ -1,22 +1,149 @@
{ {
"app.settings.developer.announcement-preview": { "message": "公告样式预览" }, "minecraft-account.skin-site.signed-in": { "message": "已登录 StarLight皮肤站" },
"minecraft-account.skin-site.checking": { "message": "正在验证皮肤站登录状态…" },
"minecraft-account.skin-site.sync-error": { "message": "暂时无法验证皮肤站登录状态,将自动重试。" },
"minecraft-account.add-skin-game-account": { "message": "添加皮肤站游戏账号" },
"app.easteregg.color-mine.title": {
"message": "星光矿井:彩域"
},
"app.easteregg.color-mine.easy": {
"message": "简单"
},
"app.easteregg.color-mine.normal": {
"message": "普通"
},
"app.easteregg.color-mine.hard": {
"message": "困难"
},
"app.easteregg.color-mine.newGame": {
"message": "新的一局"
},
"app.easteregg.color-mine.close": {
"message": "关闭"
},
"app.easteregg.color-mine.choose": {
"message": "先点击已揭示的色块取色,再给灰格染色。"
},
"app.easteregg.color-mine.selected": {
"message": "已取色 {color} · 点击其他色块切换画笔。"
},
"app.easteregg.color-mine.rulesTitle": {
"message": "玩法说明"
},
"app.easteregg.color-mine.rules": {
"message": "数字表示周围八格中同色格的数量,不含自身。每种颜色必须组成一个上下左右连通的区域,斜角不算连接。点击已揭示的格子取色,染对会显示新数字,染错则本局结束。每局均经过逐步推理验证,无需猜测。"
},
"app.easteregg.color-mine.loading": {
"message": "正在生成色域并校验推理路径…"
},
"app.easteregg.color-mine.generateError": {
"message": "棋盘生成失败,请重试。"
},
"app.easteregg.color-mine.retry": {
"message": "重试"
},
"app.easteregg.color-mine.progress": {
"message": "已揭示 {count} / {total}"
},
"app.easteregg.color-mine.best": {
"message": "最佳 · {time}"
},
"app.easteregg.color-mine.won": {
"message": "全部色域已还原!"
},
"app.easteregg.color-mine.lost": {
"message": "第 {row} 行、第 {column} 列染错了:选择了 {selected},正确颜色为 {correct}。本局结束。"
},
"app.easteregg.color-mine.review": {
"message": "未揭示的答案已淡色显示,可查看棋盘复盘。"
},
"app.easteregg.color-mine.leaveTitle": {
"message": "要离开当前棋盘吗?"
},
"app.easteregg.color-mine.leaveText": {
"message": "可以保存,下次从这里继续;也可以放弃本局。"
},
"app.easteregg.color-mine.saveLeave": {
"message": "保存并离开"
},
"app.easteregg.color-mine.discard": {
"message": "放弃本局"
},
"app.easteregg.color-mine.continue": {
"message": "继续这局"
},
"app.easteregg.color-mine.replaceTitle": {
"message": "开始新的一局?"
},
"app.easteregg.color-mine.replaceText": {
"message": "当前这局的进度将被放弃。"
},
"app.easteregg.color-mine.savedTitle": {
"message": "有一局尚未完成的棋盘"
},
"app.easteregg.color-mine.resume": {
"message": "继续上次的棋盘"
},
"app.easteregg.color-mine.storageError": {
"message": "无法读取或保存存档,当前棋盘已保留。可重试,或选择放弃存档。"
},
"app.easteregg.color-mine.recordError": {
"message": "已完成,但最佳成绩未能保存。"
},
"app.easteregg.color-mine.zoomIn": {
"message": "放大"
},
"app.easteregg.color-mine.zoomOut": {
"message": "缩小"
},
"app.easteregg.color-mine.map": {
"message": "棋盘总览,点击定位"
},
"app.easteregg.color-mine.board": {
"message": "{size} × {size} 彩色推理棋盘"
},
"app.easteregg.color-mine.hidden": {
"message": "第 {row} 行,第 {column} 列,未揭示"
},
"app.easteregg.color-mine.revealed": {
"message": "第 {row} 行,第 {column} 列,颜色 {color},周围同色 {number} 格"
},
"app.settings.developer.announcement-preview": {
"message": "公告样式预览"
},
"app.settings.developer.announcement-preview-description": { "app.settings.developer.announcement-preview-description": {
"message": "使用本地示例预览真实公告组件,不请求远端公告,也不改变真实公告的已读状态。" "message": "使用本地示例预览真实公告组件,不请求远端公告,也不改变真实公告的已读状态。"
}, },
"app.settings.developer.preview-with-action": { "message": "显示可选的外部链接按钮" }, "app.settings.developer.preview-with-action": {
"app.settings.developer.preview-announcement-modal": { "message": "预览启动弹窗" }, "message": "显示可选的外部链接按钮"
"app.settings.developer.preview-announcement-popup": { "message": "预览 Popup 通知" }, },
"app.remote-announcements.preview-title": { "message": "公告样式预览" }, "app.settings.developer.preview-announcement-modal": {
"message": "预览启动弹窗"
},
"app.settings.developer.preview-announcement-popup": {
"message": "预览 Popup 通知"
},
"app.remote-announcements.preview-title": {
"message": "公告样式预览"
},
"app.remote-announcements.preview-summary": { "app.remote-announcements.preview-summary": {
"message": "这是一条本地预览通知,点击“查看公告”可查看完整 Markdown 正文。" "message": "这是一条本地预览通知,点击“查看公告”可查看完整 Markdown 正文。"
}, },
"app.remote-announcements.preview-action": { "message": "访问官网" }, "app.remote-announcements.preview-action": {
"message": "访问官网"
},
"app.remote-announcements.preview-content": { "app.remote-announcements.preview-content": {
"message": "## 公告预览\n\n这是**示例正文**,不是已发布公告。\n\n- 支持标题、列表和链接\n- 默认自带关闭按钮\n\n> 预览不会影响真实公告的已读状态。\n\n| 类型 | 展示方式 |\n| --- | --- |\n| 启动弹窗 | 完整 Markdown 正文 |\n| Popup 通知 | 先显示摘要,再查看全文 |\n\n[访问官网](https://axlmc.org)" "message": "## 公告预览\n\n这是**示例正文**,不是已发布公告。\n\n- 支持标题、列表和链接\n- 默认自带关闭按钮\n\n> 预览不会影响真实公告的已读状态。\n\n| 类型 | 展示方式 |\n| --- | --- |\n| 启动弹窗 | 完整 Markdown 正文 |\n| Popup 通知 | 先显示摘要,再查看全文 |\n\n[访问官网](https://axlmc.org)"
}, },
"app.remote-announcements.view": { "message": "查看公告" }, "app.remote-announcements.view": {
"app.remote-announcements.unread": { "message": "未读" }, "message": "查看公告"
"app.remote-announcements.read-all": { "message": "将所有公告标为已读" }, },
"app.remote-announcements.unread": {
"message": "未读"
},
"app.remote-announcements.read-all": {
"message": "将所有公告标为已读"
},
"app.account.signed-in-as": { "app.account.signed-in-as": {
"message": "登录身份:" "message": "登录身份:"
}, },
@ -5246,8 +5373,8 @@
"app.minecraft-crash.view-mod-changes": { "app.minecraft-crash.view-mod-changes": {
"message": "查看 Mod 变化" "message": "查看 Mod 变化"
}, },
"app.minecraft.playing-as": { "app.minecraft.user-information": {
"message": "当前游玩账号:" "message": "用户信息"
}, },
"app.modal.install-to-play.content-required": { "app.modal.install-to-play.content-required": {
"message": "需求内容" "message": "需求内容"
@ -5879,12 +6006,6 @@
"app.onboarding.mascot-alt": { "app.onboarding.mascot-alt": {
"message": "引导吉祥物" "message": "引导吉祥物"
}, },
"app.onboarding.privacy.description": {
"message": "匿名遥测、Discord 活动状态与 Minecraft 日志分析服务都可以在这里调整。"
},
"app.onboarding.privacy.title": {
"message": "你的数据,由你决定"
},
"app.onboarding.resources.description": { "app.onboarding.resources.description": {
"message": "选择内容的下载与安装方式,包括下载源和安全确认。" "message": "选择内容的下载与安装方式,包括下载源和安全确认。"
}, },
@ -5930,30 +6051,6 @@
"app.onboarding.welcome.title": { "app.onboarding.welcome.title": {
"message": "一切就绪" "message": "一切就绪"
}, },
"app.privacy-consent.continue": {
"message": "保存并继续"
},
"app.privacy-consent.discord-rpc": {
"message": "Discord 活动状态"
},
"app.privacy-consent.discord-rpc-description": {
"message": "Discord 运行时显示你当前使用启动器或游玩的状态。"
},
"app.privacy-consent.intro": {
"message": "选择 Starlight 可以发送或展示的内容。确认前不会发送任何遥测。"
},
"app.privacy-consent.privacy-policy": {
"message": "阅读隐私政策"
},
"app.privacy-consent.telemetry": {
"message": "允许匿名遥测"
},
"app.privacy-consent.telemetry-description": {
"message": "用于统计选择加入的安装量和每日活跃用户。绝不上传完整 Minecraft 日志或账户凭据。"
},
"app.privacy-consent.title": {
"message": "隐私与安全"
},
"app.project.curseforge.loading": { "app.project.curseforge.loading": {
"message": "正在加载 CurseForge 项目…" "message": "正在加载 CurseForge 项目…"
}, },
@ -6746,9 +6843,6 @@
"app.settings.about.preview-minecraft-crash-modal": { "app.settings.about.preview-minecraft-crash-modal": {
"message": "预览 Minecraft 崩溃窗口" "message": "预览 Minecraft 崩溃窗口"
}, },
"app.settings.about.preview-privacy-consent-modal": {
"message": "预览隐私与安全弹窗"
},
"app.settings.about.product-title": { "app.settings.about.product-title": {
"message": "关于 {productName}" "message": "关于 {productName}"
}, },
@ -6995,21 +7089,6 @@
"app.settings.java.view-installed": { "app.settings.java.view-installed": {
"message": "查看已安装 Java" "message": "查看已安装 Java"
}, },
"app.settings.privacy.data-handling": {
"message": "遥测使用随机安装标识,仅发送每日活跃信号。关闭遥测会立即清空待发送数据。"
},
"app.settings.privacy.discord-rpc": {
"message": "Discord 活动状态"
},
"app.settings.privacy.discord-rpc-description": {
"message": "在 Discord 中显示你当前使用启动器或游玩的状态。"
},
"app.settings.privacy.telemetry": {
"message": "允许遥测"
},
"app.settings.privacy.telemetry-description": {
"message": "发送匿名每日活跃信号以优化使用统计。绝不上传 Minecraft 日志或账户凭据。"
},
"app.settings.resources.app-cache": { "app.settings.resources.app-cache": {
"message": "应用缓存" "message": "应用缓存"
}, },
@ -7400,9 +7479,6 @@
"app.settings.tabs.network-multiplayer": { "app.settings.tabs.network-multiplayer": {
"message": "网络与多人游戏" "message": "网络与多人游戏"
}, },
"app.settings.tabs.privacy-data": {
"message": "隐私与数据共享"
},
"app.settings.tabs.resource-management": { "app.settings.tabs.resource-management": {
"message": "资源管理" "message": "资源管理"
}, },
@ -10083,13 +10159,13 @@
"message": "世界正在使用中" "message": "世界正在使用中"
}, },
"minecraft-account.add-microsoft-account": { "minecraft-account.add-microsoft-account": {
"message": "添加 Microsoft 账户" "message": "添加自己的正版账号"
}, },
"minecraft-account.add-offline-account": { "minecraft-account.add-offline-account": {
"message": "添加离线账户" "message": "添加离线账户"
}, },
"minecraft-account.add-third-party-account": { "minecraft-account.sign-in-starlight": {
"message": "添加第三方账号" "message": "登录 StarLight皮肤站"
}, },
"minecraft-account.copy-uuid": { "minecraft-account.copy-uuid": {
"message": "复制 UUID" "message": "复制 UUID"
@ -10309,6 +10385,5 @@
}, },
"app.settings.about.easteregg.game-title": { "app.settings.about.easteregg.game-title": {
"message": "彩蛋小游戏" "message": "彩蛋小游戏"
} }
} }

View File

@ -1,4 +1,113 @@
{ {
"minecraft-account.skin-site.signed-in": { "message": "已登入 StarLight皮膚站" },
"minecraft-account.skin-site.checking": { "message": "正在驗證皮膚站登入狀態…" },
"minecraft-account.skin-site.sync-error": { "message": "暫時無法驗證皮膚站登入狀態,將自動重試。" },
"minecraft-account.add-skin-game-account": { "message": "新增皮膚站遊戲帳號" },
"app.easteregg.color-mine.title": {
"message": "星光礦井:彩域"
},
"app.easteregg.color-mine.easy": {
"message": "簡單"
},
"app.easteregg.color-mine.normal": {
"message": "普通"
},
"app.easteregg.color-mine.hard": {
"message": "困難"
},
"app.easteregg.color-mine.newGame": {
"message": "新的一局"
},
"app.easteregg.color-mine.close": {
"message": "關閉"
},
"app.easteregg.color-mine.choose": {
"message": "先點擊已揭示的色塊取色,再為灰格染色。"
},
"app.easteregg.color-mine.selected": {
"message": "已取色 {color} · 點擊其他色塊切換畫筆。"
},
"app.easteregg.color-mine.rulesTitle": {
"message": "玩法說明"
},
"app.easteregg.color-mine.rules": {
"message": "數字表示周圍八格中同色格的數量,不含自身。每種顏色必須組成一個上下左右連通的區域,斜角不算連接。點擊已揭示的格子取色,染對會顯示新數字,染錯則本局結束。每局均經過逐步推理驗證,無需猜測。"
},
"app.easteregg.color-mine.loading": {
"message": "正在生成色域並校驗推理路徑…"
},
"app.easteregg.color-mine.generateError": {
"message": "棋盤生成失敗,請重試。"
},
"app.easteregg.color-mine.retry": {
"message": "重試"
},
"app.easteregg.color-mine.progress": {
"message": "已揭示 {count} / {total}"
},
"app.easteregg.color-mine.best": {
"message": "最佳 · {time}"
},
"app.easteregg.color-mine.won": {
"message": "全部色域已還原!"
},
"app.easteregg.color-mine.lost": {
"message": "第 {row} 行、第 {column} 列染錯了:選擇了 {selected},正確顏色為 {correct}。本局結束。"
},
"app.easteregg.color-mine.review": {
"message": "未揭示的答案已淡色顯示,可查看棋盤複盤。"
},
"app.easteregg.color-mine.leaveTitle": {
"message": "要離開目前棋盤嗎?"
},
"app.easteregg.color-mine.leaveText": {
"message": "可以儲存,下次從這裡繼續;也可以放棄本局。"
},
"app.easteregg.color-mine.saveLeave": {
"message": "儲存並離開"
},
"app.easteregg.color-mine.discard": {
"message": "放棄本局"
},
"app.easteregg.color-mine.continue": {
"message": "繼續這局"
},
"app.easteregg.color-mine.replaceTitle": {
"message": "開始新的一局?"
},
"app.easteregg.color-mine.replaceText": {
"message": "目前這局的進度將被放棄。"
},
"app.easteregg.color-mine.savedTitle": {
"message": "有一局尚未完成的棋盤"
},
"app.easteregg.color-mine.resume": {
"message": "繼續上次的棋盤"
},
"app.easteregg.color-mine.storageError": {
"message": "無法讀取或儲存存檔,目前棋盤已保留。可重試,或選擇放棄存檔。"
},
"app.easteregg.color-mine.recordError": {
"message": "已完成,但最佳成績未能儲存。"
},
"app.easteregg.color-mine.zoomIn": {
"message": "放大"
},
"app.easteregg.color-mine.zoomOut": {
"message": "縮小"
},
"app.easteregg.color-mine.map": {
"message": "棋盤總覽,點擊定位"
},
"app.easteregg.color-mine.board": {
"message": "{size} × {size} 彩色推理棋盤"
},
"app.easteregg.color-mine.hidden": {
"message": "第 {row} 行,第 {column} 列,未揭示"
},
"app.easteregg.color-mine.revealed": {
"message": "第 {row} 行,第 {column} 列,顏色 {color},周圍同色 {number} 格"
},
"app.account.signed-in-as": { "app.account.signed-in-as": {
"message": "登入身分:" "message": "登入身分:"
}, },
@ -5015,8 +5124,8 @@
"app.minecraft-crash.view-mod-changes": { "app.minecraft-crash.view-mod-changes": {
"message": "查看 Mod 變更" "message": "查看 Mod 變更"
}, },
"app.minecraft.playing-as": { "app.minecraft.user-information": {
"message": "當前遊玩帳號:" "message": "使用者資訊"
}, },
"app.modal.install-to-play.content-required": { "app.modal.install-to-play.content-required": {
"message": "所需內容" "message": "所需內容"
@ -5618,12 +5727,6 @@
"app.onboarding.mascot-alt": { "app.onboarding.mascot-alt": {
"message": "引導吉祥物" "message": "引導吉祥物"
}, },
"app.onboarding.privacy.description": {
"message": "匿名遙測、Discord 活動狀態與 Minecraft 日誌分析服務都可以在這裡調整。"
},
"app.onboarding.privacy.title": {
"message": "你的資料,由你決定"
},
"app.onboarding.resources.description": { "app.onboarding.resources.description": {
"message": "調節下載、儲存和啟動器資源。給風扇一點尊嚴。" "message": "調節下載、儲存和啟動器資源。給風扇一點尊嚴。"
}, },
@ -5669,30 +5772,6 @@
"app.onboarding.welcome.title": { "app.onboarding.welcome.title": {
"message": "一切就緒" "message": "一切就緒"
}, },
"app.privacy-consent.continue": {
"message": "儲存並繼續"
},
"app.privacy-consent.discord-rpc": {
"message": "Discord 活動狀態"
},
"app.privacy-consent.discord-rpc-description": {
"message": "Discord 執行時顯示你當前使用啟動器或遊玩的狀態。"
},
"app.privacy-consent.intro": {
"message": "選擇 Starlight 可以傳送或展示的內容。確認前不會傳送任何遙測。"
},
"app.privacy-consent.privacy-policy": {
"message": "閱讀隱私政策"
},
"app.privacy-consent.telemetry": {
"message": "允許匿名遙測"
},
"app.privacy-consent.telemetry-description": {
"message": "透過脫敏且限長的報告統計選擇加入的安裝量並排查啟動器錯誤。不會上傳完整 Minecraft 日誌或賬戶憑據。"
},
"app.privacy-consent.title": {
"message": "隱私與安全"
},
"app.project.curseforge.loading": { "app.project.curseforge.loading": {
"message": "正在載入 CurseForge 專案..." "message": "正在載入 CurseForge 專案..."
}, },
@ -6461,9 +6540,6 @@
"app.settings.about.preview-minecraft-crash-modal": { "app.settings.about.preview-minecraft-crash-modal": {
"message": "預覽 Minecraft 崩潰窗口" "message": "預覽 Minecraft 崩潰窗口"
}, },
"app.settings.about.preview-privacy-consent-modal": {
"message": "預覽隱私與安全彈窗"
},
"app.settings.about.product-title": { "app.settings.about.product-title": {
"message": "關於 {productName}" "message": "關於 {productName}"
}, },
@ -6710,21 +6786,6 @@
"app.settings.java.view-installed": { "app.settings.java.view-installed": {
"message": "查看已安裝 Java" "message": "查看已安裝 Java"
}, },
"app.settings.privacy.data-handling": {
"message": "遙測使用隨機安裝標識。錯誤上下文離開裝置前會經過脫敏和長度限制。關閉遙測會立即清空待傳送報告。"
},
"app.settings.privacy.discord-rpc": {
"message": "Discord 活動狀態"
},
"app.settings.privacy.discord-rpc-description": {
"message": "在 Discord 中顯示你當前使用啟動器或遊玩的狀態。"
},
"app.settings.privacy.telemetry": {
"message": "允許遙測"
},
"app.settings.privacy.telemetry-description": {
"message": "傳送匿名每日活躍訊號和脫敏的啟動器錯誤報告。絕不上傳 Minecraft 日誌或賬戶憑據。"
},
"app.settings.resources.app-cache": { "app.settings.resources.app-cache": {
"message": "應用程式快取" "message": "應用程式快取"
}, },
@ -7115,9 +7176,6 @@
"app.settings.tabs.network-multiplayer": { "app.settings.tabs.network-multiplayer": {
"message": "網路與多人遊戲" "message": "網路與多人遊戲"
}, },
"app.settings.tabs.privacy-data": {
"message": "隱私與資料共享"
},
"app.settings.tabs.resource-management": { "app.settings.tabs.resource-management": {
"message": "資源管理" "message": "資源管理"
}, },
@ -9747,13 +9805,13 @@
"message": "世界正在使用中" "message": "世界正在使用中"
}, },
"minecraft-account.add-microsoft-account": { "minecraft-account.add-microsoft-account": {
"message": "新增 Microsoft 帳號" "message": "新增自己的正版帳號"
}, },
"minecraft-account.add-offline-account": { "minecraft-account.add-offline-account": {
"message": "新增離線帳號" "message": "新增離線帳號"
}, },
"minecraft-account.add-third-party-account": { "minecraft-account.sign-in-starlight": {
"message": "新增第三方帳號" "message": "登入 StarLight皮膚站"
}, },
"minecraft-account.copy-uuid": { "minecraft-account.copy-uuid": {
"message": "複製 UUID" "message": "複製 UUID"

View File

@ -9,7 +9,6 @@ import { createApp } from 'vue'
import App from '@/App.vue' import App from '@/App.vue'
import { overlayScrollbarsDirective } from '@/directives/overlayScrollbars' import { overlayScrollbarsDirective } from '@/directives/overlayScrollbars'
import { installTelemetryHandlers } from '@/helpers/telemetry'
import i18nPlugin from '@/plugins/i18n' import i18nPlugin from '@/plugins/i18n'
import i18nDebugPlugin from '@/plugins/i18n-debug' import i18nDebugPlugin from '@/plugins/i18n-debug'
import router from '@/routes' import router from '@/routes'
@ -18,8 +17,6 @@ const pinia = createPinia()
const app = createApp(App) const app = createApp(App)
installTelemetryHandlers()
app.use(VueQueryPlugin) app.use(VueQueryPlugin)
app.use(router) app.use(router)
app.use(pinia) app.use(pinia)

View File

@ -137,11 +137,11 @@
</template> </template>
</div> </div>
<div <div
v-if="downloadTelemetry(job).length" v-if="downloadDetails(job).length"
class="mt-1 flex flex-wrap items-center gap-2 text-sm text-secondary" class="mt-1 flex flex-wrap items-center gap-2 text-sm text-secondary"
> >
<template <template
v-for="(metric, index) in downloadTelemetry(job)" v-for="(metric, index) in downloadDetails(job)"
:key="`${index}-${metric}`" :key="`${index}-${metric}`"
> >
<BulletDivider v-if="index > 0" /> <BulletDivider v-if="index > 0" />
@ -1058,7 +1058,7 @@ function totalRequiredFiles(job: InstallJobSnapshot) {
return Math.max(job.summary.files_total ?? 0, job.items.length, completed + missing) return Math.max(job.summary.files_total ?? 0, job.items.length, completed + missing)
} }
function downloadTelemetry(job: InstallJobSnapshot) { function downloadDetails(job: InstallJobSnapshot) {
const summary = job.summary const summary = job.summary
const metrics: string[] = [] const metrics: string[] = []
if (summary.source && !isRecoveryValidation(job)) { if (summary.source && !isRecoveryValidation(job)) {

View File

@ -1,14 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { LayoutTemplateIcon, MinimizeIcon } from '@modrinth/assets'
CheckIcon,
GridIcon,
LayoutTemplateIcon,
MinimizeIcon,
MoveIcon,
PencilIcon,
PlusIcon,
RotateCounterClockwiseIcon,
} from '@modrinth/assets'
import { import {
defineMessages, defineMessages,
injectNotificationManager, injectNotificationManager,
@ -18,20 +9,14 @@ import {
import { computed, onUnmounted, ref } from 'vue' import { computed, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import {
createDefaultHomeDashboard,
createHomeDashboardSaveQueue,
type HomeDashboardConfig,
normalizeHomeDashboard,
} from '@/components/home/home-dashboard'
import { getActivePlayerName } from '@/components/home/home-utils' import { getActivePlayerName } from '@/components/home/home-utils'
import HomeDailyChallenge from '@/components/home/HomeDailyChallenge.vue' import HomeDailyChallenge from '@/components/home/HomeDailyChallenge.vue'
import HomeDashboard from '@/components/home/HomeDashboard.vue'
import HomeInstancePickerModal from '@/components/home/HomeInstancePickerModal.vue' import HomeInstancePickerModal from '@/components/home/HomeInstancePickerModal.vue'
import HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue'
import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue' import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue'
import HomeMinimal from '@/components/home/HomeMinimal.vue' import HomeMinimal from '@/components/home/HomeMinimal.vue'
import HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue'
import HomePlayInsights from '@/components/home/HomePlayInsights.vue' import HomePlayInsights from '@/components/home/HomePlayInsights.vue'
import SkinSiteFrame from '@/components/ui/SkinSiteFrame.vue'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { get_default_user, users } from '@/helpers/auth' import { get_default_user, users } from '@/helpers/auth'
import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync' import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync'
@ -41,7 +26,7 @@ import { get as getSettings, set as setSettings } from '@/helpers/settings'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { useBreadcrumbs } from '@/store/breadcrumbs' import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/state' import { useTheming } from '@/store/state'
import type { FeatureFlag, HomeLayout } from '@/store/theme' import type { HomeLayout } from '@/store/theme'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const route = useRoute() const route = useRoute()
@ -66,58 +51,15 @@ const messages = defineMessages({
id: 'app.home.layout.toggle', id: 'app.home.layout.toggle',
defaultMessage: 'Minimal Home', defaultMessage: 'Minimal Home',
}, },
switchToGridWidgetLayout: {
id: 'app.home.widgets.layout.switch-to-grid',
defaultMessage: 'Switch to grid widget layout',
},
switchToFreeWidgetLayout: {
id: 'app.home.widgets.layout.switch-to-free',
defaultMessage: 'Switch to free widget layout',
},
widgetLayoutToggle: {
id: 'app.home.widgets.layout.toggle',
defaultMessage: 'Widget layout mode',
},
resetWidgets: {
id: 'app.home.widgets.reset-confirm',
defaultMessage: 'Restore the default widget layout?',
},
customizeWidgets: {
id: 'app.home.widgets.customize',
defaultMessage: 'Customize widgets',
},
doneEditing: { id: 'app.home.widgets.done', defaultMessage: 'Finish editing' },
addWidget: { id: 'app.home.widgets.add', defaultMessage: 'Add widget' },
resetWidgetLayout: {
id: 'app.home.widgets.reset',
defaultMessage: 'Restore default widgets',
},
}) })
const recentProjectsInHomeFlag: FeatureFlag = 'worlds_in_home'
breadcrumbs.setRootContext({ name: formatMessage(messages.home), link: route.path }) breadcrumbs.setRootContext({ name: formatMessage(messages.home), link: route.path })
const instances = ref<GameInstance[]>([]) const instances = ref<GameInstance[]>([])
const playerName = ref<string | null>(null) const playerName = ref<string | null>(null)
const dashboardConfig = ref<HomeDashboardConfig | null>(null)
const dashboard = ref<InstanceType<typeof HomeDashboard>>()
const dashboardEditing = ref(false)
const instancePicker = ref<InstanceType<typeof HomeInstancePickerModal>>() const instancePicker = ref<InstanceType<typeof HomeInstancePickerModal>>()
const isMinimal = computed(() => themeStore.homeLayout === 'minimal') const isMinimal = computed(() => themeStore.homeLayout === 'minimal')
const isFreeWidgetLayout = computed(() => dashboardConfig.value?.layout === 'free')
const switchingLayout = ref(false) const switchingLayout = ref(false)
const dashboardSaveQueue = createHomeDashboardSaveQueue(
async (config) => {
const settings = await getSettings()
settings.home_widgets = config
await setSettings(settings)
},
(config) => {
dashboardConfig.value = config
},
handleError,
)
const floatingControlsStyle = computed(() => ({ const floatingControlsStyle = computed(() => ({
bottom: themeStore.getFeatureFlag('page_path') ? '3.5rem' : '1rem', bottom: themeStore.getFeatureFlag('page_path') ? '3.5rem' : '1rem',
right: `calc(${pageContext.floatingActionBarOffsets?.right.value ?? '0px'} + 1rem)`, right: `calc(${pageContext.floatingActionBarOffsets?.right.value ?? '0px'} + 1rem)`,
@ -162,38 +104,6 @@ async function fetchPlayerName() {
playerName.value = getActivePlayerName(selectedUser, accounts) playerName.value = getActivePlayerName(selectedUser, accounts)
} }
async function loadDashboardConfig() {
try {
const settings = await getSettings()
const normalized = normalizeHomeDashboard(settings.home_widgets)
if (normalized) {
dashboardConfig.value = normalized
return
}
const config = createDefaultHomeDashboard(themeStore.getFeatureFlag(recentProjectsInHomeFlag))
dashboardConfig.value = config
settings.home_widgets = config
await setSettings(settings)
} catch (error) {
dashboardConfig.value = createDefaultHomeDashboard(
themeStore.getFeatureFlag(recentProjectsInHomeFlag),
)
handleError(error)
}
}
function updateDashboardConfig(config: HomeDashboardConfig) {
const previous = dashboardConfig.value ?? config
dashboardConfig.value = config
void dashboardSaveQueue.enqueue(config, previous)
}
function resetDashboardConfig() {
if (!window.confirm(formatMessage(messages.resetWidgets))) return
updateDashboardConfig(createDefaultHomeDashboard())
}
async function selectMinimalInstance(instance: GameInstance) { async function selectMinimalInstance(instance: GameInstance) {
try { try {
const settings = await getSettings() const settings = await getSettings()
@ -214,10 +124,8 @@ async function toggleHomeLayout() {
const previousLayout = themeStore.homeLayout const previousLayout = themeStore.homeLayout
const nextLayout: HomeLayout = previousLayout === 'minimal' ? 'standard' : 'minimal' const nextLayout: HomeLayout = previousLayout === 'minimal' ? 'standard' : 'minimal'
const previousEditing = dashboardEditing.value
switchingLayout.value = true switchingLayout.value = true
themeStore.homeLayout = nextLayout themeStore.homeLayout = nextLayout
if (nextLayout === 'minimal') dashboardEditing.value = false
try { try {
const settings = await getSettings() const settings = await getSettings()
@ -225,28 +133,14 @@ async function toggleHomeLayout() {
await setSettings(settings) await setSettings(settings)
} catch (error) { } catch (error) {
themeStore.homeLayout = previousLayout themeStore.homeLayout = previousLayout
dashboardEditing.value = previousEditing
handleError(error) handleError(error)
} finally { } finally {
switchingLayout.value = false switchingLayout.value = false
} }
} }
function toggleDashboardEditing() {
dashboardEditing.value = !dashboardEditing.value
}
function toggleWidgetLayout() {
dashboard.value?.setLayout(isFreeWidgetLayout.value ? 'grid' : 'free')
}
function openWidgetPicker() {
dashboard.value?.openWidgetPicker()
}
const instancesLoaded = await fetchInstances() const instancesLoaded = await fetchInstances()
if (!instancesLoaded || instances.value.length > 0) void fetchPlayerName() if (!instancesLoaded || instances.value.length > 0) void fetchPlayerName()
await loadDashboardConfig()
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, fetchInstances) window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, fetchInstances)
@ -267,16 +161,8 @@ onUnmounted(() => {
:selected-instance-id="themeStore.minimalHomeInstanceId" :selected-instance-id="themeStore.minimalHomeInstanceId"
@select="selectMinimalInstance" @select="selectMinimalInstance"
/> />
<div class="min-h-full"> <div class="home-content" :class="{ 'is-minimal': isMinimal }">
<HomeDashboard <SkinSiteFrame v-if="!isMinimal" />
v-if="!isMinimal && dashboardConfig"
ref="dashboard"
:config="dashboardConfig"
:instances="instances"
:player-name="playerName"
:editing="dashboardEditing"
@change="updateDashboardConfig"
/>
<HomeMinimal <HomeMinimal
v-else v-else
@ -288,71 +174,6 @@ onUnmounted(() => {
/> />
</div> </div>
<div class="home-floating-controls" :style="floatingControlsStyle"> <div class="home-floating-controls" :style="floatingControlsStyle">
<template v-if="!isMinimal">
<button
v-if="dashboardEditing"
v-tooltip="formatMessage(messages.addWidget)"
type="button"
class="home-floating-action"
:aria-label="formatMessage(messages.addWidget)"
@click="openWidgetPicker"
>
<PlusIcon />
</button>
<button
v-if="dashboardEditing"
v-tooltip="formatMessage(messages.resetWidgetLayout)"
type="button"
class="home-floating-action"
:aria-label="formatMessage(messages.resetWidgetLayout)"
@click="resetDashboardConfig"
>
<RotateCounterClockwiseIcon />
</button>
<button
v-tooltip="
formatMessage(dashboardEditing ? messages.doneEditing : messages.customizeWidgets)
"
data-onboarding-id="home-widget-customize"
type="button"
class="home-floating-action"
:class="{ 'is-active': dashboardEditing }"
:aria-label="
formatMessage(dashboardEditing ? messages.doneEditing : messages.customizeWidgets)
"
:aria-pressed="dashboardEditing"
@click="toggleDashboardEditing"
>
<CheckIcon v-if="dashboardEditing" />
<PencilIcon v-else />
</button>
<button
v-if="dashboardEditing"
v-tooltip="
formatMessage(
isFreeWidgetLayout
? messages.switchToGridWidgetLayout
: messages.switchToFreeWidgetLayout,
)
"
type="button"
role="switch"
class="home-layout-switch home-widget-layout-switch"
:class="{ 'is-free': isFreeWidgetLayout }"
:aria-checked="isFreeWidgetLayout"
:aria-label="formatMessage(messages.widgetLayoutToggle)"
@click="toggleWidgetLayout"
>
<span class="home-layout-switch-option home-widget-layout-grid" aria-hidden="true">
<GridIcon />
</span>
<span class="home-layout-switch-thumb" aria-hidden="true" />
<span class="home-layout-switch-option home-widget-layout-free" aria-hidden="true">
<MoveIcon />
</span>
</button>
<span class="home-floating-divider" aria-hidden="true" />
</template>
<button <button
v-tooltip="formatMessage(isMinimal ? messages.switchToInformation : messages.switchToMinimal)" v-tooltip="formatMessage(isMinimal ? messages.switchToInformation : messages.switchToMinimal)"
data-onboarding-id="home-layout-switch" data-onboarding-id="home-layout-switch"
@ -388,6 +209,17 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
.home-content {
/* The embedded site owns scrolling within the launcher viewport. */
height: calc(100dvh - var(--top-bar-height));
min-width: 0;
}
.home-content.is-minimal {
height: auto;
min-height: calc(100dvh - var(--top-bar-height));
}
.home-floating-controls { .home-floating-controls {
position: fixed; position: fixed;
z-index: 40; z-index: 40;
@ -406,57 +238,11 @@ onUnmounted(() => {
isolation: isolate; isolation: isolate;
} }
.home-floating-action {
display: flex;
width: 2rem;
height: 2rem;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 9999px;
background: transparent;
color: var(--color-secondary);
cursor: pointer;
transition:
background-color 120ms ease,
color 120ms ease,
filter 150ms ease,
transform 150ms ease;
}
.home-floating-action:hover {
background: var(--color-button-bg);
color: var(--color-contrast);
}
.home-floating-action:active {
transform: scale(0.96);
}
.home-floating-action:focus-visible,
.home-layout-switch:focus-visible { .home-layout-switch:focus-visible {
outline: none; outline: none;
box-shadow: 0 0 0 4px var(--color-brand-shadow); box-shadow: 0 0 0 4px var(--color-brand-shadow);
} }
.home-floating-action.is-active {
background: var(--color-brand);
color: var(--color-accent-contrast);
}
.home-floating-action :deep(svg) {
width: 1rem;
height: 1rem;
}
.home-floating-divider {
width: 1px;
height: 1.25rem;
margin: 0 0.125rem;
background: var(--color-divider);
}
.home-layout-switch { .home-layout-switch {
position: relative; position: relative;
display: grid; display: grid;
@ -505,10 +291,6 @@ onUnmounted(() => {
transform: translateX(2rem); transform: translateX(2rem);
} }
.home-widget-layout-switch.is-free .home-layout-switch-thumb {
transform: translateX(2rem);
}
.home-layout-switch-option { .home-layout-switch-option {
position: relative; position: relative;
z-index: 1; z-index: 1;
@ -527,9 +309,7 @@ onUnmounted(() => {
} }
.home-layout-switch:not(.is-minimal) .home-layout-switch-information, .home-layout-switch:not(.is-minimal) .home-layout-switch-information,
.home-layout-switch.is-minimal .home-layout-switch-minimal, .home-layout-switch.is-minimal .home-layout-switch-minimal {
.home-widget-layout-switch:not(.is-free) .home-widget-layout-grid,
.home-widget-layout-switch.is-free .home-widget-layout-free {
color: var(--color-accent-contrast); color: var(--color-accent-contrast);
} }
</style> </style>

View File

@ -1,41 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui' import SkinSiteFrame from '@/components/ui/SkinSiteFrame.vue'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'app.starlight-skin.title',
defaultMessage: '斯达莱特',
},
frameTitle: {
id: 'app.starlight-skin.frame-title',
defaultMessage: 'StarLight Skin Site',
},
})
const SKIN_SITE_URL = 'https://skin.starlight.cool/'
const loading = ref(true)
function onLoad() {
loading.value = false
}
</script> </script>
<template> <template>
<div class="flex h-full min-h-0 flex-col"> <div class="h-full min-h-0">
<div v-if="loading" class="flex flex-1 items-center justify-center"> <SkinSiteFrame />
<span class="text-secondary">{{ formatMessage(messages.title) }}</span>
</div>
<iframe
:src="SKIN_SITE_URL"
:title="formatMessage(messages.frameTitle)"
class="h-full min-h-0 w-full flex-1 border-0"
:class="loading ? 'hidden' : ''"
@load="onLoad"
/>
</div> </div>
</template> </template>

View File

@ -419,7 +419,6 @@ import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice' import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice'
import { useSymlinkWarningDismiss } from '@/composables/useSymlinkWarningDismiss' import { useSymlinkWarningDismiss } from '@/composables/useSymlinkWarningDismiss'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js' import { get_project_v3 } from '@/helpers/cache.js'
import { instance_listener, process_listener } from '@/helpers/events' import { instance_listener, process_listener } from '@/helpers/events'
import { import {
@ -776,11 +775,6 @@ const startInstance = async (context: string) => {
loading.value = false loading.value = false
} }
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: context,
})
} }
const stopInstance = async (context: string) => { const stopInstance = async (context: string) => {
@ -790,11 +784,6 @@ const stopInstance = async (context: string) => {
playing.value = false playing.value = false
if (!instance.value) return if (!instance.value) return
trackEvent('InstanceStop', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: context,
})
} }
const handlePlayServer = async () => { const handlePlayServer = async () => {

View File

@ -264,7 +264,6 @@ import ContentToggleDependenciesModal from '@/components/ui/modal/ContentToggleD
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue' import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice' import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice'
import { useWorldDatapacks } from '@/composables/useWorldDatapacks' import { useWorldDatapacks } from '@/composables/useWorldDatapacks'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js' import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
import { applyContentItemUpdates, matchesContentItem } from '@/helpers/content-item-state' import { applyContentItemUpdates, matchesContentItem } from '@/helpers/content-item-state'
import { lookupContentWikiIds, translateContentItemTitles } from '@/helpers/content-search' import { lookupContentWikiIds, translateContentItemTitles } from '@/helpers/content-search'
@ -1580,14 +1579,6 @@ async function toggleDisableMod(
const enabled = desiredEnabled ?? !mod.enabled const enabled = desiredEnabled ?? !mod.enabled
try { try {
await toggleWorldDatapackItem(mod, enabled) await toggleWorldDatapackItem(mod, enabled)
trackEvent('InstanceProjectDisable', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
disabled: !enabled,
})
} catch (err) { } catch (err) {
handleError(err as Error) handleError(err as Error)
} }
@ -1646,14 +1637,6 @@ async function applyToggleDisableMod(mod: ContentItem, enabled: boolean) {
enabled: actualEnabled, enabled: actualEnabled,
}) })
trackEvent('InstanceProjectDisable', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
disabled: !actualEnabled,
})
} catch (err) { } catch (err) {
applyContentItemToggleState(mod, operation.originalFileName, originalFilePath, { applyContentItemToggleState(mod, operation.originalFileName, originalFilePath, {
file_path: originalFilePath, file_path: originalFilePath,
@ -1733,14 +1716,6 @@ async function applyToggleDisableBatch(items: ContentItem[], enabled: boolean) {
) )
for (const operation of operations) { for (const operation of operations) {
trackEvent('InstanceProjectDisable', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: operation.item.project?.id,
name: operation.item.project?.title ?? operation.originalFileName,
project_type: operation.item.project_type,
disabled: !enabled,
})
} }
} catch (error) { } catch (error) {
for (const operation of operations) { for (const operation of operations) {
@ -1831,13 +1806,6 @@ async function removeMod(mod: ContentItem) {
projects.value = projects.value.filter((x) => removedPath !== x.file_path) projects.value = projects.value.filter((x) => removedPath !== x.file_path)
} }
trackEvent('InstanceProjectRemove', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) { } catch (err) {
handleError(err as Error) handleError(err as Error)
} finally { } finally {
@ -1999,13 +1967,6 @@ async function updateProject(mod: ContentItem) {
try { try {
await update_content_entry(props.instance.id, contentId) await update_content_entry(props.instance.id, contentId)
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) { } catch (err) {
handleError(err as Error) handleError(err as Error)
throw err throw err
@ -2024,13 +1985,6 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
try { try {
await switch_content_entry_version(props.instance.id, contentId, version.id) await switch_content_entry_version(props.instance.id, contentId, version.id)
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) { } catch (err) {
handleError(err as Error) handleError(err as Error)
} finally { } finally {
@ -2046,13 +2000,6 @@ async function handleRollbackContent(mod: ContentItem) {
try { try {
await rollback_project(props.instance.id, mod.file_path) await rollback_project(props.instance.id, mod.file_path)
trackEvent('InstanceProjectRollback', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) { } catch (err) {
handleError(err as Error) handleError(err as Error)
} finally { } finally {

View File

@ -204,7 +204,6 @@ import ConfirmRemoveWorldModal from '@/components/ui/world/modal/ConfirmRemoveWo
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue' import EditServerModal from '@/components/ui/world/modal/EditServerModal.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 { get_project, get_project_v3 } from '@/helpers/cache.js' import { get_project, get_project_v3 } from '@/helpers/cache.js'
import { instance_listener } from '@/helpers/events' import { instance_listener } from '@/helpers/events'
import { get_game_versions } from '@/helpers/tags' import { get_game_versions } from '@/helpers/tags'
@ -592,20 +591,10 @@ async function joinWorld(world: World) {
const managedProjectId = instance.value.link?.project_id const managedProjectId = instance.value.link?.project_id
if (managedProjectId && isManagedServerWorld(world)) { if (managedProjectId && isManagedServerWorld(world)) {
await playServerProject(managedProjectId).catch(handleJoinError) await playServerProject(managedProjectId).catch(handleJoinError)
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: 'WorldsPage',
})
startingInstance.value = false startingInstance.value = false
return return
} }
await start_join_server(instance.value.id, world.address).catch(handleJoinError) await start_join_server(instance.value.id, world.address).catch(handleJoinError)
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: 'WorldsPage',
})
} else if (world.type === 'singleplayer') { } else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(instance.value.id, world.path).catch(handleJoinError) await start_join_singleplayer_world(instance.value.id, world.path).catch(handleJoinError)
} }

View File

@ -18,7 +18,6 @@ import {
} from '@modrinth/ui' } from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { trackEvent } from '@/helpers/analytics'
import { import {
type ProjectGalleryCaptionField, type ProjectGalleryCaptionField,
projectGalleryTranslationSegmentId, projectGalleryTranslationSegmentId,
@ -130,10 +129,6 @@ function viewImage(entry: GalleryEntry) {
zoomedIn.value = false zoomedIn.value = false
viewerModal.value?.show() viewerModal.value?.show()
trackEvent('GalleryImageExpand', {
project_id: props.project.id,
url: entry.image.url,
})
} }
function changeImage(offset: number) { function changeImage(offset: number) {
@ -146,10 +141,6 @@ function changeImage(offset: number) {
selectedGalleryItem.value = filteredGallery.value[nextIndex] selectedGalleryItem.value = filteredGallery.value[nextIndex]
zoomedIn.value = false zoomedIn.value = false
trackEvent(offset > 0 ? 'GalleryImageNext' : 'GalleryImagePrevious', {
project_id: props.project.id,
url: selectedGalleryItem.value.image.url,
})
} }
function handleViewerHide() { function handleViewerHide() {

View File

@ -210,25 +210,6 @@
{{ installButtonLabel }} {{ installButtonLabel }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<!-- 开服功能暂有问题,隐藏该按钮
<Transition name="start-server">
<ButtonStyled
v-if="serverCapableModpack"
key="modpack-start-server"
size="large"
type="outlined"
>
<button
v-tooltip="formatMessage(messages.startServer)"
type="button"
@click="openModpackServerFlow"
>
<ServerIcon />
{{ formatMessage(messages.startServer) }}
</button>
</ButtonStyled>
</Transition>
-->
<ButtonStyled size="large" circular type="transparent"> <ButtonStyled size="large" circular type="transparent">
<OverflowMenu <OverflowMenu
:tooltip="`More options`" :tooltip="`More options`"
@ -339,7 +320,6 @@
:translations="translations" :translations="translations"
:translation-mode="translationMode" :translation-mode="translationMode"
:translation-style="translationStyle" :translation-style="translationStyle"
:start-server="(version) => openModpackServerFlow(version)"
/> />
</template> </template>
<template v-else> Project data couldn't not be loaded. </template> <template v-else> Project data couldn't not be loaded. </template>
@ -408,7 +388,6 @@ import {
PlusIcon, PlusIcon,
ReportIcon, ReportIcon,
SearchIcon, SearchIcon,
ServerIcon,
SpinnerIcon, SpinnerIcon,
StopCircleIcon, StopCircleIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
@ -424,7 +403,6 @@ import {
getLatestMatchingInstallVersion, getLatestMatchingInstallVersion,
getTargetInstallPreferences, getTargetInstallPreferences,
injectNotificationManager, injectNotificationManager,
injectPopupNotificationManager,
NavTabs, NavTabs,
OverflowMenu, OverflowMenu,
ProjectBackgroundGradient, ProjectBackgroundGradient,
@ -501,7 +479,6 @@ import UpgradeProjectReturnBar from './UpgradeProjectReturnBar.vue'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
const { addNotification, handleError } = injectNotificationManager() const { addNotification, handleError } = injectNotificationManager()
const popupNotificationManager = injectPopupNotificationManager()
const { install: installVersion } = injectContentInstall() const { install: installVersion } = injectContentInstall()
const contentSelection = injectContentSelection() const contentSelection = injectContentSelection()
const route = useRoute() const route = useRoute()
@ -603,22 +580,6 @@ const messages = defineMessages({
id: 'project.actions.view-modpacks', id: 'project.actions.view-modpacks',
defaultMessage: 'View modpacks', defaultMessage: 'View modpacks',
}, },
startServer: {
id: 'app.project.modpack-server.start',
defaultMessage: 'Start server',
},
serverCreated: {
id: 'app.project.modpack-server.created-title',
defaultMessage: 'Server created',
},
serverCreatedDescription: {
id: 'app.project.modpack-server.created-description',
defaultMessage: '{name} is ready. Configure and start it in Multiplayer.',
},
openServer: {
id: 'app.project.modpack-server.open',
defaultMessage: 'Open server',
},
}) })
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } = const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
@ -642,15 +603,6 @@ const favoriteSaved = computed(() =>
data.value ? contentFavorites.isFavorite('modrinth', data.value.id) : false, data.value ? contentFavorites.isFavorite('modrinth', data.value.id) : false,
) )
const serverCapableModpack = computed(
() => data.value?.project_type === 'modpack' && data.value.server_side !== 'unsupported',
)
async function openModpackServerFlow() {
// 服务器创建弹窗CreateModpackServerModal已在上游移除此流程暂不可用。
// 如需恢复:重新实现该组件,并在此处调用其 show() 方法。
}
async function toggleFavorite() { async function toggleFavorite() {
if (!data.value || !favoriteSupported.value || favoritePending.value) return if (!data.value || !favoriteSupported.value || favoritePending.value) return
await contentFavorites await contentFavorites

View File

@ -30,16 +30,6 @@
<CheckIcon v-else /> <CheckIcon v-else />
</button> </button>
</ButtonStyled> </ButtonStyled>
<!-- 开服功能暂有问题,隐藏该按钮
<ButtonStyled v-if="serverCapable && startServer" circular type="transparent">
<button
v-tooltip="formatMessage(messages.startServer)"
@click.stop="() => startServer(version)"
>
<ServerIcon />
</button>
</ButtonStyled>
-->
<ButtonStyled circular type="transparent"> <ButtonStyled circular type="transparent">
<OverflowMenu <OverflowMenu
v-if="false" v-if="false"
@ -87,7 +77,6 @@ import {
DownloadIcon, DownloadIcon,
ExternalIcon, ExternalIcon,
MoreVerticalIcon, MoreVerticalIcon,
ServerIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import {
ButtonStyled, ButtonStyled,
@ -98,7 +87,7 @@ import {
ProjectPageVersions, ProjectPageVersions,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { computed, ref } from 'vue' import { ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { SwapIcon } from '@/assets/icons/index.js' import { SwapIcon } from '@/assets/icons/index.js'
@ -117,10 +106,6 @@ const messages = defineMessages({
id: 'app.project.versions.add-to-another-instance', id: 'app.project.versions.add-to-another-instance',
defaultMessage: 'Add to another instance', defaultMessage: 'Add to another instance',
}, },
startServer: {
id: 'app.project.versions.start-server',
defaultMessage: 'Create server',
},
}) })
const props = defineProps({ const props = defineProps({
@ -152,16 +137,8 @@ const props = defineProps({
type: String, type: String,
default: null, default: null,
}, },
startServer: {
type: Function,
default: null,
},
}) })
const serverCapable = computed(
() => props.project?.project_type === 'modpack' && props.project?.server_side !== 'unsupported',
)
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const route = useRoute() const route = useRoute()

View File

@ -15,7 +15,6 @@ import type { Router } from 'vue-router'
import type ContentInstallPreviewModal from '@/components/ui/ContentInstallPreviewModal.vue' import type ContentInstallPreviewModal from '@/components/ui/ContentInstallPreviewModal.vue'
import type { ContentInstallPreviewData } from '@/components/ui/ContentInstallPreviewModal.vue' import type { ContentInstallPreviewData } from '@/components/ui/ContentInstallPreviewModal.vue'
import type { ModpackInstallModalData } from '@/components/ui/modal/ModpackInstallModal.vue' import type { ModpackInstallModalData } from '@/components/ui/modal/ModpackInstallModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { import {
get_organization, get_organization,
get_project, get_project,
@ -868,12 +867,6 @@ export function createContentInstall(opts: {
}) })
removeInstallingItems(createdInstanceId, [project.id]) removeInstallingItems(createdInstanceId, [project.id])
markInstanceContentChanged(createdInstanceId) markInstanceContentChanged(createdInstanceId)
trackEvent('PackInstall', {
id: project.id,
version_id: version.id,
title: project.title,
source,
})
callback(version.id, [project.id]) callback(version.id, [project.id])
} catch (err) { } catch (err) {
debugState('createAndInstallCurseForgeModpack ERR', { err: String(err), createdInstanceId }) debugState('createAndInstallCurseForgeModpack ERR', { err: String(err), createdInstanceId })
@ -997,7 +990,6 @@ export function createContentInstall(opts: {
if (sessionId !== currentSessionId) return if (sessionId !== currentSessionId) return
contentInstallModalOpen = true contentInstallModalOpen = true
modalRef?.show() modalRef?.show()
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
} }
get_game_versions() get_game_versions()
@ -1068,7 +1060,6 @@ export function createContentInstall(opts: {
await nextTick() await nextTick()
contentInstallModalOpen = true contentInstallModalOpen = true
modalRef?.show() modalRef?.show()
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
return sessionId return sessionId
} }
@ -1688,15 +1679,6 @@ export function createContentInstall(opts: {
if (storeInstance) storeInstance.installing = true if (storeInstance) storeInstance.installing = true
try { try {
await queueCurrentCurseForgeWorld(selectedInstance) await queueCurrentCurseForgeWorld(selectedInstance)
trackEvent('ProjectInstall', {
loader: selectedInstance.loader,
game_version: selectedInstance.game_version,
id: currentProject.id,
version_id: currentWorldFileId ?? '',
project_type: 'world',
title: currentProject.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(currentWorldFileId ?? undefined, [currentProject.id]) settleCurrentCallback(currentWorldFileId ?? undefined, [currentProject.id])
hideContentInstallModal() hideContentInstallModal()
} catch (err) { } catch (err) {
@ -1758,15 +1740,6 @@ export function createContentInstall(opts: {
{ ...request, excluded_project_ids: excludedProjectIds }, { ...request, excluded_project_ids: excludedProjectIds },
{ title: currentProject.title, iconUrl: currentProject.icon_url }, { title: currentProject.title, iconUrl: currentProject.icon_url },
) )
trackEvent('ProjectInstall', {
loader: selectedInstance.loader,
game_version: selectedInstance.game_version,
id: currentProject.id,
version_id: version.id,
project_type: currentProject.project_type,
title: currentProject.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(version.id, [currentProject.id]) settleCurrentCallback(version.id, [currentProject.id])
hideContentInstallModal() hideContentInstallModal()
} catch (err) { } catch (err) {
@ -1786,15 +1759,6 @@ export function createContentInstall(opts: {
settleCurrentCallback() settleCurrentCallback()
return return
} }
trackEvent('ProjectInstall', {
loader: selectedInstance.loader,
game_version: selectedInstance.game_version,
id: currentProject.id,
version_id: version.id,
project_type: currentProject.project_type,
title: currentProject.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(version.id, [currentProject.id]) settleCurrentCallback(version.id, [currentProject.id])
hideContentInstallModal() hideContentInstallModal()
} catch (err) { } catch (err) {
@ -1830,15 +1794,6 @@ export function createContentInstall(opts: {
storeInstance.installed = primaryInstalled storeInstance.installed = primaryInstalled
storeInstance.installing = false storeInstance.installing = false
} }
trackEvent('ProjectInstall', {
loader: selectedInstance.loader,
game_version: selectedInstance.game_version,
id: currentProject!.id,
version_id: version.id,
project_type: currentProject!.project_type,
title: currentProject!.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(primaryInstalled ? version.id : undefined, installedProjectIds) settleCurrentCallback(primaryInstalled ? version.id : undefined, installedProjectIds)
} catch (err) { } catch (err) {
if (storeInstance) storeInstance.installing = false if (storeInstance) storeInstance.installing = false
@ -1878,7 +1833,6 @@ export function createContentInstall(opts: {
await nextTick() await nextTick()
incompatibilityWarningModalRef?.show(version.id) incompatibilityWarningModalRef?.show(version.id)
trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
} }
async function handleIncompatibilityWarningInstall(version: Labrinth.Versions.v2.Version) { async function handleIncompatibilityWarningInstall(version: Labrinth.Versions.v2.Version) {
@ -1931,15 +1885,6 @@ export function createContentInstall(opts: {
incompatibilityWarningModalRef?.hide() incompatibilityWarningModalRef?.hide()
removeInstallingItems(instance.id, [project.id]) removeInstallingItems(instance.id, [project.id])
trackEvent('ProjectInstall', {
loader: instance.loader ?? '',
game_version: instance.game_version ?? '',
id: project.id,
version_id: version.id,
project_type: project.project_type,
title: project.title,
source: 'ProjectIncompatibilityWarningModal',
})
} }
function handleIncompatibilityWarningCancel() { function handleIncompatibilityWarningCancel() {
@ -2056,16 +2001,6 @@ export function createContentInstall(opts: {
}, },
{ title: currentProject!.title, iconUrl: currentProject!.icon_url }, { title: currentProject!.title, iconUrl: currentProject!.icon_url },
) )
trackEvent('InstanceCreate', { source: 'ProjectInstallModal' })
trackEvent('ProjectInstall', {
loader: data.loader,
game_version: data.gameVersion,
id: currentProject!.id,
version_id: version.id,
project_type: currentProject!.project_type,
title: currentProject!.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(version.id, [currentProject!.id]) settleCurrentCallback(version.id, [currentProject!.id])
hideContentInstallModal() hideContentInstallModal()
return return
@ -2085,16 +2020,6 @@ export function createContentInstall(opts: {
excludedCurseForgeProjectIds, excludedCurseForgeProjectIds,
) )
if (!job) return if (!job) return
trackEvent('InstanceCreate', { source: 'ProjectInstallModal' })
trackEvent('ProjectInstall', {
loader: data.loader,
game_version: data.gameVersion,
id: currentProject!.id,
version_id: version.id,
project_type: currentProject!.project_type,
title: currentProject!.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(version.id, [currentProject!.id]) settleCurrentCallback(version.id, [currentProject!.id])
hideContentInstallModal() hideContentInstallModal()
return return
@ -2131,18 +2056,6 @@ export function createContentInstall(opts: {
: `/instance/${encodeURIComponent(id)}`, : `/instance/${encodeURIComponent(id)}`,
) )
trackEvent('InstanceCreate', {
source: 'ProjectInstallModal',
})
trackEvent('ProjectInstall', {
loader: data.loader,
game_version: data.gameVersion,
id: currentProject!.id,
version_id: version.id,
project_type: currentProject!.project_type,
title: currentProject!.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(version.id, installedProjectIds) settleCurrentCallback(version.id, installedProjectIds)
modalRef?.hide() modalRef?.hide()
@ -2282,15 +2195,6 @@ export function createContentInstall(opts: {
{ ...installRequest, excluded_project_ids: excludedProjectIds }, { ...installRequest, excluded_project_ids: excludedProjectIds },
{ title: project.title, iconUrl: project.icon_url }, { title: project.title, iconUrl: project.icon_url },
) )
trackEvent('ProjectInstall', {
loader: instance.loader,
game_version: instance.game_version,
id: project.id,
project_type: project.project_type,
version_id: version.id,
title: project.title,
source,
})
callback(version.id, [project.id]) callback(version.id, [project.id])
} else { } else {
await showIncompatibilityWarning(instance, project, projectVersions, version, callback) await showIncompatibilityWarning(instance, project, projectVersions, version, callback)
@ -2402,15 +2306,6 @@ export function createContentInstall(opts: {
if (isVersionCompatible(version, project, instance)) { if (isVersionCompatible(version, project, instance)) {
const job = await queueCurrentCurseForgeVersion(instance, project, version) const job = await queueCurrentCurseForgeVersion(instance, project, version)
if (!job) return if (!job) return
trackEvent('ProjectInstall', {
loader: instance.loader,
game_version: instance.game_version,
id: project.id,
project_type: project.project_type,
version_id: version.id,
title: project.title,
source,
})
callback(version.id, [project.id]) callback(version.id, [project.id])
} else { } else {
await showIncompatibilityWarning(instance, project, versions, version, callback) await showIncompatibilityWarning(instance, project, versions, version, callback)
@ -2457,7 +2352,6 @@ export function createContentInstall(opts: {
if (sessionId !== currentSessionId) return if (sessionId !== currentSessionId) return
contentInstallModalOpen = true contentInstallModalOpen = true
modalRef?.show() modalRef?.show()
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
try { try {
const candidates = (await list()).filter( const candidates = (await list()).filter(
@ -2537,15 +2431,6 @@ export function createContentInstall(opts: {
const instance = await get(instanceId) const instance = await get(instanceId)
if (!instance) throw new Error(formatMessage(curseForgeWorldUnknownInstanceMessage)) if (!instance) throw new Error(formatMessage(curseForgeWorldUnknownInstanceMessage))
await queueCurrentCurseForgeWorld(instance) await queueCurrentCurseForgeWorld(instance)
trackEvent('ProjectInstall', {
loader: instance.loader,
game_version: instance.game_version,
id: currentProject!.id,
version_id: file.id.toString(),
project_type: 'world',
title: currentProject!.title,
source,
})
callback(file.id.toString(), [currentProject!.id]) callback(file.id.toString(), [currentProject!.id])
} }
@ -2700,12 +2585,6 @@ export function createContentInstall(opts: {
if (instanceId) { if (instanceId) {
createInstanceCallback(instanceId) createInstanceCallback(instanceId)
} }
trackEvent('PackInstall', {
id: project.id,
version_id: versionId,
title: project.title,
source,
})
callback(versionId) callback(versionId)
}, },
handleModpackInstallCancel() { handleModpackInstallCancel() {

View File

@ -4,7 +4,6 @@ import { createContext } from '@modrinth/ui'
import { type Ref, ref } from 'vue' import { type Ref, ref } from 'vue'
import type { Router } from 'vue-router' import type { Router } from 'vue-router'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_project_v3, get_version } from '@/helpers/cache.js' import { get_project, get_project_v3, get_version } from '@/helpers/cache.js'
import { import {
install_create_instance, install_create_instance,
@ -163,11 +162,6 @@ export function createServerInstall(opts: {
action: async () => { action: async () => {
try { try {
await joinServer(project.id, serverAddress) await joinServer(project.id, serverAddress)
trackEvent('InstanceStart', {
loader: project.loader,
game_version: project.game_version,
source: 'ServerProject',
})
} catch (err) { } catch (err) {
handleSevereError(err, { instanceId: project.id }) handleSevereError(err, { instanceId: project.id })
} }
@ -198,11 +192,6 @@ export function createServerInstall(opts: {
action: async () => { action: async () => {
try { try {
if (serverAddress) await start_join_server(instance.id, serverAddress) if (serverAddress) await start_join_server(instance.id, serverAddress)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'ServerProject',
})
} catch (err) { } catch (err) {
handleSevereError(err, { instanceId: instance.id }) handleSevereError(err, { instanceId: instance.id })
} }
@ -348,11 +337,6 @@ export function createServerInstall(opts: {
// Join server // Join server
try { try {
await joinServer(instance.id, serverAddress) await joinServer(instance.id, serverAddress)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'ServerProject',
})
} catch (err) { } catch (err) {
handleSevereError(err, { instanceId: instance.id }) handleSevereError(err, { instanceId: instance.id })
} }

View File

@ -13,7 +13,6 @@ import { useRouter } from 'vue-router'
import type UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue' import type UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
import type ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue' import type ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_search_results } from '@/helpers/cache.js' import { get_project_versions, get_search_results } from '@/helpers/cache.js'
import { getCurseForgeFiles, hasCompatibleCurseForgeFile } from '@/helpers/curseforge' import { getCurseForgeFiles, hasCompatibleCurseForgeFile } from '@/helpers/curseforge'
import { install_job_listener } from '@/helpers/events.js' import { install_job_listener } from '@/helpers/events.js'
@ -143,7 +142,6 @@ export function setupCreationModal(
}, },
{ name }, { name },
).catch(handleError) ).catch(handleError)
trackEvent('InstanceCreate', { source: 'CreationModalModpack' })
} }
async function handleCreate(config: CreationFlowContextValue) { async function handleCreate(config: CreationFlowContextValue) {
@ -222,7 +220,6 @@ export function setupCreationModal(
handleError(error) handleError(error)
} }
} }
trackEvent('InstanceCreate', { source: 'CreationModalImport' })
return return
} }
@ -238,7 +235,6 @@ export function setupCreationModal(
const splitPath = config.modpackFilePath.value.split(/[\\/]/) const splitPath = config.modpackFilePath.value.split(/[\\/]/)
const fileName = splitPath ? splitPath[splitPath.length - 1] : config.modpackFilePath.value const fileName = splitPath ? splitPath[splitPath.length - 1] : config.modpackFilePath.value
await installModpackFromPath(config.modpackFilePath.value, fileName) await installModpackFromPath(config.modpackFilePath.value, fileName)
trackEvent('InstanceCreate', { source: 'CreationModalModpackFile' })
return return
} }
@ -281,9 +277,6 @@ export function setupCreationModal(
gameDirOverride, gameDirOverride,
}).catch(handleError) }).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModal',
})
} catch (err) { } catch (err) {
handleError(err as Error) handleError(err as Error)
} }

View File

@ -509,10 +509,6 @@ fn main() {
.commands(&[ .commands(&[
"settings_get", "settings_get",
"settings_set", "settings_set",
"privacy_get",
"privacy_set",
"telemetry_set",
"discord_rpc_set",
"download_engine_set", "download_engine_set",
"cancel_directory_change", "cancel_directory_change",
"proxy_get", "proxy_get",

View File

@ -6,7 +6,6 @@ use path_util::SafeRelativeUtf8UnixPathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use theseus::DownloadReason;
use theseus::data::{ use theseus::data::{
AppliedContentSetPatch, ContentItem, Dependency, AppliedContentSetPatch, ContentItem, Dependency,
EditInstance as CoreEditInstance, InstanceInstallCandidate, EditInstance as CoreEditInstance, InstanceInstallCandidate,
@ -1116,16 +1115,11 @@ pub async fn instance_update_project(
pub async fn instance_add_project_from_version( pub async fn instance_add_project_from_version(
instance_id: &str, instance_id: &str,
version_id: &str, version_id: &str,
reason: DownloadReason,
dependent_on_version_id: Option<String>,
) -> Result<String> { ) -> Result<String> {
Ok(theseus::instance::add_project_from_version( Ok(
instance_id, theseus::instance::add_project_from_version(instance_id, version_id)
version_id, .await?,
reason,
dependent_on_version_id,
) )
.await?)
} }
#[tauri::command] #[tauri::command]

View File

@ -25,7 +25,6 @@ pub mod shortcuts;
pub mod storage; pub mod storage;
pub mod system_accent; pub mod system_accent;
pub mod tags; pub mod tags;
pub mod telemetry;
pub mod terracotta; pub mod terracotta;
pub mod translation; pub mod translation;
pub mod utils; pub mod utils;

View File

@ -8,10 +8,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
settings_get, settings_get,
settings_set, settings_set,
privacy_get,
privacy_set,
telemetry_set,
discord_rpc_set,
download_engine_set, download_engine_set,
cancel_directory_change, cancel_directory_change,
proxy_get, proxy_get,
@ -41,26 +37,6 @@ pub async fn settings_set(
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn privacy_get() -> Result<PrivacySettings> {
Ok(settings::get_privacy().await?)
}
#[tauri::command]
pub async fn privacy_set(privacy: PrivacySettings) -> Result<PrivacySettings> {
Ok(settings::set_privacy(privacy).await?)
}
#[tauri::command]
pub async fn telemetry_set(enabled: bool) -> Result<PrivacySettings> {
Ok(settings::set_telemetry(enabled).await?)
}
#[tauri::command]
pub async fn discord_rpc_set(enabled: bool) -> Result<PrivacySettings> {
Ok(settings::set_discord_rpc(enabled).await?)
}
#[tauri::command] #[tauri::command]
pub async fn download_engine_set( pub async fn download_engine_set(
engine: settings::DownloadEngine, engine: settings::DownloadEngine,

View File

@ -1,10 +0,0 @@
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("telemetry")
.invoke_handler(tauri::generate_handler![notify_online,])
.build()
}
#[tauri::command]
pub fn notify_online() {
theseus::telemetry::notify_online();
}

View File

@ -389,16 +389,6 @@ async fn backup_app_db_for_update(
Ok(()) Ok(())
} }
#[tauri::command]
async fn set_discord_activity(activity: String) -> api::Result<()> {
let state = State::get().await?;
state
.discord_rpc
.set_launcher_activity(&activity, true)
.await?;
Ok(())
}
// Should be call once Vue has mounted the app // Should be call once Vue has mounted the app
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
#[tauri::command] #[tauri::command]
@ -698,6 +688,13 @@ fn main() {
); );
builder = builder builder = builder
.plugin(
tauri::plugin::Builder::<tauri::Wry>::new("skin-site-session")
.js_init_script_on_all_frames(include_str!(
"skin_site_bridge.js"
))
.build(),
)
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
if let Some(payload) = args.get(1) { if let Some(payload) = args.get(1) {
tracing::info!("Handling deep link from arg {payload}"); tracing::info!("Handling deep link from arg {payload}");
@ -823,7 +820,6 @@ fn main() {
.plugin(api::schematic_preview::init()) .plugin(api::schematic_preview::init())
.plugin(api::shortcuts::init()) .plugin(api::shortcuts::init())
.plugin(api::tags::init()) .plugin(api::tags::init())
.plugin(api::telemetry::init())
.plugin(api::translation::init()) .plugin(api::translation::init())
.plugin(api::utils::init()) .plugin(api::utils::init())
.plugin(api::cache::init()) .plugin(api::cache::init())
@ -851,7 +847,6 @@ fn main() {
copy_database_between_channels, copy_database_between_channels,
beta_database_exists, beta_database_exists,
backup_app_db_for_update, backup_app_db_for_update,
set_discord_activity,
is_dev, is_dev,
portable::is_portable_mode, portable::is_portable_mode,
are_updates_enabled, are_updates_enabled,

View File

@ -0,0 +1,98 @@
// Runs inside the embedded skin site only. The JWT never leaves that origin.
(() => {
if (location.origin !== 'https://skin.starlight.cool' || window.parent === window) return
const launcherOrigins = new Set([
'http://localhost:5201',
'http://tauri.localhost',
'https://tauri.localhost',
'tauri://localhost',
])
let parentOrigin
let lastToken
let lastCheck = 0
let generation = 0
let pending
let snapshot = { status: 'checking', user: null }
function publish(status, user = null) {
snapshot = { status, user }
if (parentOrigin) {
window.parent.postMessage({ type: 'starlight-skin-session', ...snapshot }, parentOrigin)
}
}
async function checkSession(force = false) {
if (!parentOrigin) return
let token
try {
token = localStorage.getItem('loginToken') || ''
} catch {
publish('error')
return
}
const changed = token !== lastToken
if (!changed && (pending || (!force && Date.now() - lastCheck < 60_000))) return
const revision = ++generation
pending?.abort()
pending = undefined
lastToken = token
lastCheck = Date.now()
if (!token) {
publish('signed-out')
return
}
if (changed) publish('checking')
const controller = new AbortController()
pending = controller
const timeout = setTimeout(() => controller.abort(), 10_000)
try {
const response = await fetch('/starlight/user', {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
cache: 'no-store',
redirect: 'error',
})
const body = response.ok ? await response.json() : null
// A logout or account switch must win over an older in-flight response.
if (revision !== generation || localStorage.getItem('loginToken') !== token) return
if (response.status === 401 || response.status === 403 || body?.payload?.banned) {
publish('signed-out')
} else if (
response.ok && typeof body?.payload?.uuid === 'string' &&
typeof body.payload.username === 'string' && body.payload.username.length > 0
) {
publish('signed-in', { uuid: body.payload.uuid, username: body.payload.username })
} else {
publish('error')
}
} catch {
if (revision === generation) publish('error')
} finally {
clearTimeout(timeout)
if (revision === generation) pending = undefined
}
}
window.addEventListener('message', (event) => {
if (event.source !== window.parent || !launcherOrigins.has(event.origin)) return
if (event.data?.type !== 'starlight-skin-session-connect') return
parentOrigin = event.origin
publish(snapshot.status, snapshot.user)
void checkSession(true)
})
// Storage events cover other tabs; polling also covers same-document SPA login/logout.
window.addEventListener('storage', () => void checkSession())
let timer = setInterval(() => void checkSession(), 1000)
window.addEventListener('pagehide', () => {
clearInterval(timer)
++generation
pending?.abort()
pending = undefined
})
window.addEventListener('pageshow', (event) => {
if (!event.persisted) return
timer = setInterval(() => void checkSession(), 1000)
void checkSession(true)
})
})()

View File

@ -0,0 +1,67 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const test = require('node:test')
const source = fs.readFileSync(require('node:path').join(__dirname, 'skin_site_bridge.js'), 'utf8')
function harness(origin = 'https://skin.starlight.cool') {
let token = null
const listeners = {}, messages = [], requests = []
let tick
let result = async () => ({ ok: true, status: 200, json: async () => ({ payload: { uuid: 'one', username: 'One' } }) })
const parent = { postMessage(data, target) { messages.push({ data, target }) } }
const context = {
location: { origin },
window: { parent, addEventListener(type, callback) { listeners[type] = callback } },
localStorage: { getItem() { return token } },
fetch(...args) { requests.push(args); return result(...args) },
AbortController, Date, setTimeout, clearTimeout,
setInterval(callback) { tick = callback; return 1 }, clearInterval() {},
}
vm.runInNewContext(source, context)
return {
messages, requests, listeners, parent,
token(value) { token = value }, result(value) { result = value },
async tick() { tick?.(); await new Promise(setImmediate) },
async connect(origin = 'http://localhost:5201', source = parent) {
listeners.message?.({ source, origin, data: { type: 'starlight-skin-session-connect' } })
await new Promise(setImmediate)
},
}
}
test('bridge is restricted to the skin origin and an allowlisted parent', async () => {
assert.equal(harness('https://evil.example').listeners.message, undefined)
const h = harness(); h.token('test-token')
await h.connect('https://evil.example'); await h.connect('http://localhost:5201', {})
assert.equal(h.requests.length, 0); assert.equal(h.messages.length, 0)
await h.connect()
assert.equal(h.requests[0][0], '/starlight/user')
assert.equal(h.requests[0][1].headers.Authorization, 'Bearer test-token')
assert.equal(h.requests[0][1].redirect, 'error')
assert.equal(h.messages.at(-1).data.status, 'signed-in')
assert.ok(h.messages.every(m => m.target === 'http://localhost:5201'))
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
})
test('logout, invalid token, network error and account switching replace old state', async () => {
const h = harness(); await h.connect()
assert.equal(h.messages.at(-1).data.status, 'signed-out')
h.token('first'); await h.tick(); assert.equal(h.messages.at(-1).data.user.username, 'One')
h.result(async () => ({ ok: false, status: 401 }))
h.token('expired'); await h.tick(); assert.equal(h.messages.at(-1).data.status, 'signed-out')
h.result(async () => { throw Error('offline') })
h.token('network-error'); await h.tick(); assert.equal(h.messages.at(-1).data.status, 'error')
h.token(null); await h.tick(); assert.equal(h.messages.at(-1).data.status, 'signed-out')
})
test('a delayed response cannot restore the user after logout', async () => {
const h = harness(); let finish
h.result(() => new Promise(resolve => { finish = resolve }))
h.token('first'); await h.connect()
h.token(null); await h.tick()
finish({ ok: true, status: 200, json: async () => ({ payload: { uuid: 'one', username: 'One' } }) })
await new Promise(setImmediate)
assert.equal(h.messages.at(-1).data.status, 'signed-out')
assert.ok(!h.messages.some(m => m.data.status === 'signed-in'))
})

View File

@ -1,9 +0,0 @@
# Fictional local values only. Never commit a real Access token or audience.
TELEMETRY_ADMIN_MOCK_AUTH=true
TELEMETRY_ADMIN_MOCK_SCENARIO=normal
NUXT_ACCESS_TEAM_DOMAIN=example
NUXT_ACCESS_AUDIENCE=fixture-audience
# Cloudflare GraphQL Analytics API (system page account usage check, local dev only)
# CLOUDFLARE_ACCOUNT_ID=
# CLOUDFLARE_ANALYTICS_TOKEN=

View File

@ -1,2 +0,0 @@
.vercel
.env*

View File

@ -1,5 +0,0 @@
.nuxt/
.output/
.wrangler/
node_modules/
coverage/

View File

@ -1,106 +0,0 @@
# Starlight Telemetry Dashboard
Read-only administration UI for the existing `axolotl-telemetry` D1 database and
`axolotl-telemetry-errors` R2 bucket. It does not share routes or code with the public telemetry
collector.
## Local development
Mock authentication is accepted only when `NODE_ENV=development` and
`TELEMETRY_ADMIN_MOCK_AUTH=true` are both present.
Use the production D1 and R2 resources through Wrangler remote bindings while keeping authentication
local to the development server:
```powershell
$env:TELEMETRY_ADMIN_MOCK_AUTH='true'
$env:TELEMETRY_ADMIN_REMOTE_BINDINGS='true'
pnpm --filter @axolotl/telemetry-dashboard dev
```
Remote mode is read-only at the application layer: the Admin API issues only `SELECT` statements
and a single registered-object R2 `GET`. It never exposes Cloudflare credentials to the browser.
For deterministic fixture scenarios, leave `TELEMETRY_ADMIN_REMOTE_BINDINGS` unset:
```powershell
$env:TELEMETRY_ADMIN_MOCK_AUTH='true'
$env:TELEMETRY_ADMIN_MOCK_SCENARIO='normal'
pnpm --filter @axolotl/telemetry-dashboard dev
```
Supported deterministic scenarios are `normal`, `empty`, `api-error`, `no-sample`,
`budget-reached`, `unconfigured-auth`, and `forbidden`. All mock values are synthetic fixtures.
## Production deployment (Cloudflare Workers)
The dashboard runs on the `axolotl-telemetry-dashboard` Worker with the custom domain
`admin.axlmc.org`. It uses the native D1 and R2 bindings declared in `wrangler.toml`; no data
credentials are sent to the browser.
Do not deploy the dashboard until every item below is complete:
1. Create a GitHub OAuth App controlled by the `Axolotl-Launcher` organization. Use
`https://admin.axlmc.org` as its homepage and the callback URL shown by Cloudflare Zero Trust,
normally `https://<team-name>.cloudflareaccess.com/cdn-cgi/access/callback`.
2. Add GitHub as a Cloudflare Zero Trust identity provider using that Client ID and Client Secret.
Keep the Client Secret only in GitHub and Cloudflare.
3. Create a self-hosted Access application for `admin.axlmc.org` with an 8-hour session duration.
4. Create one Allow policy with `Include -> GitHub Organization -> Axolotl-Launcher`. Do not add a
bypass policy or a broader include rule.
5. Keep the Worker custom domain `admin.axlmc.org` bound to `axolotl-telemetry-dashboard`. The DNS
records for `axlmc.org` stay proxied through Cloudflare.
Build and deploy from `apps/telemetry-dashboard`:
```powershell
pnpm --filter @axolotl/telemetry-dashboard build
pnpm --filter @axolotl/telemetry-dashboard exec wrangler deploy
```
`nuxi build` uses Nitro's `cloudflare-module` preset by default and emits
`.output/server/index.mjs` plus `.output/public`, exactly what `wrangler.toml` references.
### Automatic deployment
`.github/workflows/deploy-telemetry-dashboard.yml` deploys on every push to `main` that touches
`apps/telemetry-dashboard/**`, and can also be triggered manually. It requires one repository
secret:
- `CLOUDFLARE_API_TOKEN`: an API token with Workers Scripts Edit and Workers Routes Edit
permission for the account that owns `axolotl-telemetry-dashboard`.
Do not add `[skip ci]` to commits that should reach production: GitHub Actions skips all workflows
for such commits, including this deployment.
### Cloudflare account usage check
The system page reports account-wide Workers, D1, and R2 usage through the GraphQL Analytics API.
Enable it once with two Worker secrets; secrets survive CI deploys because they live on the Worker,
not in the repository:
1. Create an API token at `dash.cloudflare.com` → My Profile → API Tokens with the
`Account Analytics: Read` permission for the account that owns the telemetry resources.
2. From `apps/telemetry-dashboard`, run
`pnpm exec wrangler secret put CLOUDFLARE_ANALYTICS_TOKEN` and paste that token.
3. Run `pnpm exec wrangler secret put CLOUDFLARE_ACCOUNT_ID` and paste the 32-character account ID.
The check stays fail-closed: without both values the row reports 尚未配置, and any dataset failure
only degrades the row. The token is used server-side for three read-only GraphQL queries per system
page load (Workers invocations 24h, D1 rows today, R2 operations 30d) and is never sent to the
browser.
### Unused Vercel target
An earlier Vercel project (`axolotl-telemetry-dashboard`) exists, but its `admin.axlmc.org` domain
was never pointed at Vercel and traffic is served by the Cloudflare Worker. The Nitro `vercel`
preset and `server/utils/vercel-data-source.ts` remain as an offline fallback and are not part of
the production path. Do not enable both targets for the same hostname.
Verify an unauthenticated request is intercepted by Cloudflare Access, then verify
`/api/admin/session` reports `dataSource: production` after authentication.
The server fails closed when Access or the remote data source configuration is absent. A production
build ignores the mock provider even if a mock environment variable is present. All Admin API D1
operations are restricted to `SELECT`/`WITH` queries, and R2 reads require an object key previously
registered in D1.

View File

@ -1,6 +0,0 @@
<template>
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

View File

@ -1,162 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 210 18% 97%;
--foreground: 220 20% 11%;
--card: 0 0% 100%;
--card-foreground: 220 18% 12%;
--popover: 0 0% 100%;
--popover-foreground: 220 18% 12%;
--primary: 164 64% 27%;
--primary-foreground: 0 0% 100%;
--secondary: 210 16% 93%;
--secondary-foreground: 220 16% 18%;
--muted: 210 16% 94%;
--muted-foreground: 215 12% 43%;
--accent: 38 92% 52%;
--accent-foreground: 30 30% 12%;
--destructive: 0 72% 48%;
--destructive-foreground: 0 0% 100%;
--border: 214 16% 86%;
--input: 214 16% 78%;
--ring: 164 64% 27%;
--surface-1: 210 18% 97%;
--surface-2: 0 0% 100%;
--surface-3: 210 17% 94%;
--surface-4: 214 16% 86%;
--surface-5: 214 15% 72%;
--chart-green: #17845f;
--chart-gold: #d19213;
--chart-coral: #d85d4a;
--chart-cyan: #278ba8;
}
.dark {
--background: 220 16% 8%;
--foreground: 210 16% 92%;
--card: 220 15% 12%;
--card-foreground: 210 16% 92%;
--popover: 220 16% 12%;
--popover-foreground: 210 16% 92%;
--primary: 160 54% 48%;
--primary-foreground: 220 18% 8%;
--secondary: 218 14% 17%;
--secondary-foreground: 210 16% 90%;
--muted: 218 14% 16%;
--muted-foreground: 215 12% 64%;
--accent: 40 88% 56%;
--accent-foreground: 30 32% 10%;
--destructive: 0 62% 51%;
--destructive-foreground: 0 0% 100%;
--border: 218 13% 23%;
--input: 218 13% 27%;
--ring: 160 54% 48%;
--surface-1: 220 16% 8%;
--surface-2: 220 15% 11%;
--surface-3: 218 14% 15%;
--surface-4: 218 13% 23%;
--surface-5: 216 12% 34%;
--chart-green: #40c995;
--chart-gold: #e6ad3e;
--chart-coral: #ef7b68;
--chart-cyan: #52b6d0;
}
* {
@apply border-border;
}
html,
body,
#__nuxt {
@apply min-h-full;
}
body {
@apply m-0 overflow-x-hidden bg-surface-1 font-sans text-foreground antialiased;
letter-spacing: 0;
}
button,
input,
select {
letter-spacing: 0;
}
}
@layer utilities {
.tabular-nums {
font-variant-numeric: tabular-nums;
}
}
.dashboard-page-enter-active,
.dashboard-page-leave-active {
transition:
opacity 160ms ease,
transform 160ms ease;
}
.dashboard-page-enter-from {
opacity: 0;
transform: translateY(4px);
}
.dashboard-page-leave-to {
opacity: 0;
transform: translateY(-2px);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 160ms ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.sheet-overlay[data-state='open'] {
animation: overlay-in 180ms ease-out;
}
.sheet-panel[data-state='open'] {
animation: sheet-in 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes overlay-in {
from {
opacity: 0;
}
}
@keyframes sheet-in {
from {
transform: translateX(20px);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.dashboard-page-enter-active,
.dashboard-page-leave-active,
.sheet-overlay[data-state='open'],
.sheet-panel[data-state='open'] {
animation: none;
transition: none;
}
}
.unovis-xy-container text {
fill: hsl(var(--muted-foreground));
font-size: 11px;
}
.unovis-xy-container .axis line,
.unovis-xy-container .axis path {
stroke: hsl(var(--surface-4));
}

View File

@ -1,16 +0,0 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "assets/styles/tailwind.css",
"baseColor": "zinc",
"cssVariables": true
},
"aliases": {
"components": "~/components",
"utils": "~/lib/utils",
"ui": "~/components/ui"
}
}

View File

@ -1,57 +0,0 @@
<script setup lang="ts">
import { Ban, LogIn, RefreshCw, ServerOff } from 'lucide-vue-next'
import { computed } from 'vue'
import Button from '~/components/ui/Button.vue'
const props = defineProps<{
kind: 'error' | 'unauthenticated' | 'forbidden'
compact?: boolean
}>()
const emit = defineEmits<{ retry: [] }>()
const content = computed(() => {
if (props.kind === 'unauthenticated') {
return {
icon: LogIn,
title: '需要登录',
detail: '请使用 Axolotl-Launcher 组织成员账号通过 Cloudflare Access 登录。',
action: '前往登录',
}
}
if (props.kind === 'forbidden') {
return {
icon: Ban,
title: '无权访问',
detail: '当前 GitHub 身份不符合 Cloudflare Access 组织策略。',
action: null,
}
}
return {
icon: ServerOff,
title: '遥测数据暂不可用',
detail: '只读管理 API 未返回可用结果,请稍后重试。',
action: '重新加载',
}
})
</script>
<template>
<div
:class="compact ? 'py-10' : 'min-h-[60vh]'"
class="flex flex-col items-center justify-center px-4 text-center"
:data-state="kind"
>
<div class="flex size-10 items-center justify-center rounded-lg bg-muted">
<component :is="content.icon" class="size-5 text-muted-foreground" />
</div>
<h2 class="mt-4 text-base font-semibold">{{ content.title }}</h2>
<p class="mt-1 max-w-md text-sm text-muted-foreground">{{ content.detail }}</p>
<Button v-if="content.action" class="mt-4" variant="outline" @click="emit('retry')">
<RefreshCw v-if="kind === 'error'" class="size-4" />
<LogIn v-else class="size-4" />
{{ content.action }}
</Button>
</div>
</template>

View File

@ -1,50 +0,0 @@
<script setup lang="ts">
import type { Component } from 'vue'
import Card from '~/components/ui/Card.vue'
import { formatNumber } from '~/utils/format'
const props = withDefaults(
defineProps<{
title: string
value: number
detail: string
icon: Component
tone?: 'green' | 'cyan' | 'gold' | 'coral' | 'neutral'
}>(),
{ tone: 'neutral' },
)
const tones = {
green: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300',
cyan: 'bg-cyan-500/12 text-cyan-700 dark:text-cyan-300',
gold: 'bg-amber-500/12 text-amber-700 dark:text-amber-300',
coral: 'bg-rose-500/12 text-rose-700 dark:text-rose-300',
neutral: 'bg-surface-3 text-muted-foreground',
}
</script>
<template>
<Card class="min-w-0 overflow-hidden">
<div class="flex items-start justify-between gap-3 p-4 pb-3">
<div class="min-w-0">
<p class="truncate text-xs font-medium text-muted-foreground">{{ title }}</p>
<p class="mt-2 text-[28px] font-semibold tabular-nums leading-8">
{{ formatNumber(value) }}
</p>
</div>
<div
class="flex size-9 shrink-0 items-center justify-center rounded-md"
:class="tones[props.tone]"
>
<component :is="icon" class="size-[18px]" />
</div>
</div>
<p
class="truncate border-t border-surface-4 bg-surface-3/45 px-4 py-2 text-[11px] text-muted-foreground"
:title="detail"
>
{{ detail }}
</p>
</Card>
</template>

View File

@ -1,13 +0,0 @@
<script setup lang="ts">
defineProps<{ title: string; description: string }>()
</script>
<template>
<header class="mb-6 flex flex-wrap items-end justify-between gap-3">
<div class="min-w-0">
<h1 class="text-[22px] font-semibold leading-8">{{ title }}</h1>
<p class="mt-1 max-w-3xl text-sm leading-6 text-muted-foreground">{{ description }}</p>
</div>
<slot />
</header>
</template>

View File

@ -1,38 +0,0 @@
<script setup lang="ts">
import type { DistributionItemDto } from '~/shared/types/telemetry'
import { formatNumber } from '~/utils/format'
const props = defineProps<{ items: DistributionItemDto[]; color: string }>()
const maximum = computed(() => Math.max(1, ...props.items.map((item) => item.value)))
</script>
<template>
<div v-if="items.length" class="h-56 space-y-3.5 overflow-y-auto pr-1">
<div
v-for="item in items"
:key="item.label"
class="group"
:title="`${item.label}: ${formatNumber(item.value)}`"
>
<div class="mb-1 flex items-center justify-between gap-3 text-xs">
<span class="truncate font-medium">{{ item.label }}</span>
<span class="shrink-0 tabular-nums text-muted-foreground">{{
formatNumber(item.value)
}}</span>
</div>
<div class="h-1.5 overflow-hidden rounded-sm bg-surface-3">
<div
class="h-full rounded-sm transition-[width] duration-300"
:style="{ width: `${(item.value / maximum) * 100}%`, backgroundColor: color }"
></div>
</div>
</div>
</div>
<div
v-else
class="flex h-56 items-center justify-center text-sm text-muted-foreground"
data-state="empty"
>
当前 UTC 时间范围内暂无数据
</div>
</template>

View File

@ -1,87 +0,0 @@
<script setup lang="ts">
import { VisAxis, VisLine, VisXYContainer } from '@unovis/vue'
import { computed, ref } from 'vue'
import type { DailyPointDto } from '~/shared/types/telemetry'
import { formatNumber, formatUtcDay } from '~/utils/format'
const props = defineProps<{
data: DailyPointDto[]
series: Array<{
key: 'activeInstallations' | 'newInstallations'
label: string
color: string
}>
}>()
const hovered = ref<number | null>(null)
const available = computed(() =>
props.data.some((point) => props.series.some((series) => Number(point[series.key]) > 0)),
)
const yAccessors = computed(() =>
props.series.map((series) => (point: DailyPointDto) => point[series.key]),
)
const colors = computed(() => props.series.map((series) => series.color))
function move(event: PointerEvent): void {
if (!props.data.length) return
const box = (event.currentTarget as HTMLElement).getBoundingClientRect()
const ratio = Math.min(1, Math.max(0, (event.clientX - box.left) / box.width))
hovered.value = Math.min(props.data.length - 1, Math.round(ratio * (props.data.length - 1)))
}
</script>
<template>
<div class="relative h-64 min-w-0" @pointermove="move" @pointerleave="hovered = null">
<div v-if="available" class="h-56 w-full">
<VisXYContainer :data="data" :margin="{ left: 16, right: 12, top: 12, bottom: 28 }">
<VisLine
:x="(_point: DailyPointDto, index: number) => index"
:y="yAccessors"
:color="colors"
:line-width="2"
/>
<VisAxis
type="x"
:x="(_point: DailyPointDto, index: number) => index"
:tick-format="(value: number) => formatUtcDay(data[Math.round(value)]?.day || '')"
:num-ticks="4"
/>
<VisAxis type="y" :num-ticks="4" :tick-format="(value: number) => formatNumber(value)" />
</VisXYContainer>
</div>
<div
v-else
class="flex h-56 items-center justify-center text-sm text-muted-foreground"
data-state="empty"
>
当前 UTC 时间范围内暂无数据
</div>
<div
class="flex h-8 flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground"
aria-label="图表图例"
>
<span v-for="item in series" :key="item.key" class="inline-flex items-center gap-1.5">
<span class="size-2 rounded-sm" :style="{ backgroundColor: item.color }"></span>
{{ item.label }}
</span>
</div>
<div
v-if="hovered !== null && available"
class="pointer-events-none absolute right-2 top-2 z-10 min-w-40 rounded-md border bg-popover p-2 text-xs text-popover-foreground shadow-md"
role="tooltip"
>
<p class="mb-1 font-medium">{{ data[hovered].day }}UTC</p>
<p
v-for="item in series"
:key="item.key"
class="flex justify-between gap-4 py-0.5 text-muted-foreground"
>
<span>{{ item.label }}</span>
<span class="font-medium tabular-nums text-foreground">{{
formatNumber(data[hovered][item.key])
}}</span>
</p>
</div>
</div>
</template>

View File

@ -1,34 +0,0 @@
<script setup lang="ts">
import { CircleCheck, CircleMinus, TriangleAlert } from 'lucide-vue-next'
import Badge from '~/components/ui/Badge.vue'
import type { ServiceCheckDto } from '~/shared/types/telemetry'
defineProps<{ name: string; check: ServiceCheckDto }>()
const icons = { available: CircleCheck, degraded: TriangleAlert, unavailable: CircleMinus }
const variants = { available: 'success', degraded: 'warning', unavailable: 'secondary' } as const
</script>
<template>
<div
class="flex flex-wrap items-center gap-3 border-b border-surface-4 px-4 py-3.5 transition-colors last:border-0 hover:bg-surface-3/45"
>
<component
:is="icons[check.status]"
class="size-4 shrink-0"
:class="
check.status === 'available'
? 'text-emerald-600'
: check.status === 'degraded'
? 'text-amber-600'
: 'text-muted-foreground'
"
/>
<div class="min-w-48 flex-1">
<p class="text-sm font-medium">{{ name }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{{ check.detail }}</p>
</div>
<Badge :variant="variants[check.status]">{{ check.label }}</Badge>
</div>
</template>

View File

@ -1,30 +0,0 @@
<script setup lang="ts">
import { AlertCircle, CircleCheck, Info } from 'lucide-vue-next'
import { cn } from '~/lib/utils'
const props = withDefaults(
defineProps<{ variant?: 'info' | 'success' | 'destructive'; title: string; class?: string }>(),
{ variant: 'info', class: undefined },
)
const icons = { info: Info, success: CircleCheck, destructive: AlertCircle }
const styles = {
info: 'border-border bg-muted/40',
success: 'border-emerald-500/30 bg-emerald-500/10',
destructive: 'border-red-500/30 bg-red-500/10',
}
</script>
<template>
<div
role="alert"
:class="cn('flex gap-3 rounded-lg border p-3.5 text-sm', styles[variant], props.class)"
>
<component :is="icons[variant]" class="mt-0.5 size-4 shrink-0" />
<div class="min-w-0">
<p class="font-medium">{{ title }}</p>
<div class="mt-0.5 text-muted-foreground"><slot /></div>
</div>
</div>
</template>

View File

@ -1,33 +0,0 @@
<script setup lang="ts">
import { cn } from '~/lib/utils'
const props = withDefaults(
defineProps<{
variant?: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'
class?: string
}>(),
{ variant: 'secondary', class: undefined },
)
const variants = {
default: 'bg-primary text-primary-foreground',
secondary: 'bg-secondary text-secondary-foreground',
success: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
warning: 'bg-amber-500/15 text-amber-800 dark:text-amber-300',
destructive: 'bg-red-500/15 text-red-700 dark:text-red-300',
}
</script>
<template>
<span
:class="
cn(
'inline-flex items-center rounded px-2 py-0.5 text-xs font-medium',
variants[variant],
props.class,
)
"
>
<slot />
</span>
</template>

View File

@ -1,45 +0,0 @@
<script setup lang="ts">
import type { ButtonHTMLAttributes } from 'vue'
import { cn } from '~/lib/utils'
const props = withDefaults(
defineProps<{
variant?: 'default' | 'secondary' | 'ghost' | 'outline' | 'destructive'
size?: 'default' | 'sm' | 'icon'
class?: ButtonHTMLAttributes['class']
}>(),
{ variant: 'default', size: 'default', class: undefined },
)
const variants = {
default: 'bg-primary text-primary-foreground shadow-sm hover:brightness-95 active:brightness-90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/75',
ghost: 'text-foreground hover:bg-surface-3 active:bg-surface-4',
outline:
'border-surface-4 bg-surface-2 text-foreground hover:border-surface-5 hover:bg-surface-3 active:bg-surface-4',
destructive: 'bg-destructive text-destructive-foreground hover:brightness-95',
}
const sizes = {
default: 'h-9 px-3.5',
sm: 'h-8 px-2.5 text-xs',
icon: 'size-9 shrink-0 p-0',
}
</script>
<template>
<button
:data-variant="variant"
:class="
cn(
'inline-flex cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium transition-[color,background-color,border-color,box-shadow,filter] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-auto disabled:cursor-not-allowed disabled:opacity-50',
variants[props.variant],
sizes[props.size],
props.class,
)
"
>
<slot />
</button>
</template>

View File

@ -1,20 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<section
:class="
cn(
'rounded-lg border border-surface-4 bg-surface-2 text-card-foreground shadow-[0_1px_2px_rgba(15,23,42,0.04)]',
props.class,
)
"
>
<slot />
</section>
</template>

View File

@ -1,66 +0,0 @@
<script setup lang="ts">
import { BarChart3, Search, Server } from 'lucide-vue-next'
import {
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
const open = defineModel<boolean>('open', { required: true })
const query = ref('')
const router = useRouter()
const items = [
{ label: '数据总览', path: '/', icon: BarChart3 },
{ label: '系统状态', path: '/system', icon: Server },
]
const filtered = computed(() =>
items.filter((item) => item.label.toLowerCase().includes(query.value.toLowerCase())),
)
async function select(path: string): Promise<void> {
open.value = false
query.value = ''
await router.push(path)
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="sheet-overlay fixed inset-0 z-40 bg-black/45 backdrop-blur-[1px]" />
<DialogContent
class="fixed left-1/2 top-[18vh] z-50 w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 overflow-hidden rounded-lg border border-surface-5 bg-popover shadow-2xl outline-none"
>
<DialogTitle class="sr-only">快速跳转</DialogTitle>
<DialogDescription class="sr-only">搜索控制台页面</DialogDescription>
<div class="flex items-center gap-2 border-b px-3">
<Search class="size-4 text-muted-foreground" />
<input
v-model="query"
autofocus
placeholder="输入页面名称..."
class="h-12 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div class="p-1.5">
<p v-if="!filtered.length" class="px-3 py-8 text-center text-sm text-muted-foreground">
没有匹配的页面
</p>
<button
v-for="item in filtered"
:key="item.path"
type="button"
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-left text-sm hover:bg-surface-3 focus:bg-surface-3 focus:outline-none"
@click="select(item.path)"
>
<component :is="item.icon" class="size-4 text-muted-foreground" />
{{ item.label }}
</button>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@ -1,79 +0,0 @@
<script setup lang="ts">
import { Check, ChevronDown, LogOut, Monitor, Moon, Sun } from 'lucide-vue-next'
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'reka-ui'
import type { AdminSessionDto } from '~/shared/types/telemetry'
import { type ThemeMode, useTheme } from '../../composables/use-theme'
defineProps<{ session: AdminSessionDto }>()
const { mode } = useTheme()
const themes: Array<{ value: ThemeMode; label: string; icon: typeof Sun }> = [
{ value: 'light', label: '浅色', icon: Sun },
{ value: 'dark', label: '深色', icon: Moon },
{ value: 'system', label: '跟随系统', icon: Monitor },
]
</script>
<template>
<DropdownMenuRoot>
<DropdownMenuTrigger
class="inline-flex h-9 min-w-0 cursor-pointer items-center gap-2 rounded-md border border-surface-4 bg-surface-2 px-2.5 text-sm outline-none transition-colors hover:border-surface-5 hover:bg-surface-3 focus:ring-2 focus:ring-ring"
>
<span
class="flex size-6 shrink-0 items-center justify-center rounded bg-primary/15 text-xs font-semibold text-primary"
>
{{ session.identity.name.slice(0, 1).toUpperCase() }}
</span>
<span class="hidden max-w-32 truncate sm:block">{{ session.identity.name }}</span>
<ChevronDown class="size-3.5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
align="end"
:side-offset="6"
class="z-50 w-64 rounded-lg border border-surface-5 bg-popover p-1 text-popover-foreground shadow-xl outline-none"
>
<DropdownMenuLabel class="px-2 py-1.5">
<p class="truncate text-sm font-medium">{{ session.identity.name }}</p>
<p class="truncate text-xs font-normal text-muted-foreground">
{{ session.identity.email || 'Cloudflare Access 身份' }}
</p>
</DropdownMenuLabel>
<DropdownMenuSeparator class="my-1 h-px bg-border" />
<DropdownMenuLabel class="px-2 py-1 text-xs text-muted-foreground"
>界面主题</DropdownMenuLabel
>
<DropdownMenuItem
v-for="theme in themes"
:key="theme.value"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm outline-none hover:bg-surface-3 focus:bg-surface-3"
@click="mode = theme.value"
>
<component :is="theme.icon" class="size-4 text-muted-foreground" />
<span class="flex-1">{{ theme.label }}</span>
<Check v-if="mode === theme.value" class="size-4" />
</DropdownMenuItem>
<DropdownMenuSeparator class="my-1 h-px bg-border" />
<DropdownMenuItem as-child>
<a
:href="session.logoutUrl"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm outline-none hover:bg-surface-3 focus:bg-surface-3"
>
<LogOut class="size-4 text-muted-foreground" />
退出登录
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>

View File

@ -1,27 +0,0 @@
<script setup lang="ts">
import { ChevronDown } from 'lucide-vue-next'
defineProps<{
modelValue: string
label: string
options: Array<{ value: string; label: string }>
}>()
defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<label class="relative inline-flex min-w-0 items-center">
<span class="sr-only">{{ label }}</span>
<select
:value="modelValue"
class="h-9 max-w-full cursor-pointer appearance-none rounded-md border border-surface-4 bg-surface-2 py-0 pl-3 pr-8 text-sm text-foreground outline-none transition-colors hover:border-surface-5 hover:bg-surface-3 focus:ring-2 focus:ring-ring"
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
>
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<ChevronDown class="pointer-events-none absolute right-2 size-4 text-muted-foreground" />
</label>
</template>

View File

@ -1,44 +0,0 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
defineProps<{ open: boolean; title: string; description?: string }>()
defineEmits<{ 'update:open': [value: boolean] }>()
</script>
<template>
<DialogRoot :open="open" @update:open="$emit('update:open', $event)">
<DialogPortal>
<DialogOverlay class="sheet-overlay fixed inset-0 z-40 bg-black/45 backdrop-blur-[1px]" />
<DialogContent
class="sheet-panel fixed inset-y-0 right-0 z-50 flex w-full max-w-xl flex-col border-l border-surface-5 bg-surface-1 shadow-2xl focus:outline-none"
>
<header class="flex shrink-0 items-start justify-between gap-4 border-b px-5 py-4">
<div class="min-w-0">
<DialogTitle class="text-base font-semibold">{{ title }}</DialogTitle>
<DialogDescription v-if="description" class="mt-1 text-sm text-muted-foreground">
{{ description }}
</DialogDescription>
</div>
<DialogClose
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground hover:bg-surface-3 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
aria-label="关闭详情"
>
<X class="size-4" />
</DialogClose>
</header>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<slot />
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

Some files were not shown because too many files have changed in this diff Show More