Compare commits

...
Author SHA1 Message Date
AxTps d6fdb4f453 Merge remote-tracking branch 'origin/main' into fix/ui
Sync source to CNB / Sync Git ref (push) Skipped
# Conflicts:
#	apps/app-frontend/src/locales/en-US/index.json
#	apps/app-frontend/src/locales/zh-CN/index.json
2026-10-11 10:28:52 +08:00
AxTps 4ba1ed279f Merge pull request #14 from ApTx2012/feat/instance
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
feat: 实例分组增强(后端 JSON 存储 + 前端拖拽分组 UI)
2026-10-11 09:39:14 +08:00
AxTps afdd091971 fix: 修复远端公告不刷新与盔甲面板未翻译
Sync source to CNB / Sync Git ref (push) Skipped
Bug1 远端公告:公告只在启动时拉取一次,且点铃铛不触发刷新。
- openCenter 时补调 refresh(),点铃铛即拉最新
- watch skinSiteStatus,登录皮肤站后自动重试拉取

Bug2 盔甲翻译:补 7 个缺失的 app.skins.armor.* i18n 键
(title/save/close/description/unsaved/reset-to-saved/restore-defaults),
ui 包的 skin.preview.armor.* 46 个键已存在无需补。
2026-10-11 08:58:03 +08:00
AxTps 99e0aa9d14 Merge pull request #13 from ApTx2012/feat/backend
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
feat: 导出启动器日志
2026-10-11 08:35:07 +08:00
AxTps 22bc631293 style: 对 emit.rs 的 import 补跑 cargo fmt
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-11 08:15:28 +08:00
AxTps 3d3682023e fix: 补回 emit.rs 缺失的 futures/serde_json import
Sync source to CNB / Sync Git ref (push) Skipped
阶段1搬运时漏了 `use futures::prelude::*;` 和 `use serde_json::Value;`,
导致 cargo test 编译 theseus lib test 时报 E0425(Value) / E0405(TryStream)。
cargo check 不编 test 路径故未暴露。
2026-10-11 08:08:40 +08:00
AxTps f253d5529b feat: 实例分组增强前端(拖拽分组 UI)
Sync source to CNB / Sync Git ref (push) Skipped
移植 Axolotl 的拖拽式实例分组界面到 Starlight,接入阶段1的规范化分组后端:

- 新增 helpers/analytics.ts(trackEvent 遥测桩)
- 补全 helpers/instance-groups.ts(6 个 invoke 包装)
- 新增 composables/useInstanceGroups.ts(分组状态 + 事件监听)
- 升级 composables/useGridGrouping.ts(排序方向 isSortAscending/toggleSortDirection)
- helpers/events.js 补 instance_groups_listener(对接 instance_groups_changed 事件)
- 新增 components/ui/library/DraggableInstanceCard.vue
- 新增 components/ui/library/InstanceGroup.vue(并补 group-drag-handle 修复组拖拽重排)
- 新增 components/ui/modal/InstanceGroupModal.vue
- GridDisplay.vue 替换为 Axolotl 拖拽版(DragDropProvider + vuedraggable)
- 补 en-US/zh-CN i18n 键各 39 条
2026-10-11 07:54:48 +08:00
AxTps 253141b6ff style: 补实例分组阶段1的 cargo fmt 格式
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-11 07:17:42 +08:00
AxTps 946a477397 feat: 实例分组增强阶段1(后端 JSON 存储与命令)
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-11 07:12:15 +08:00
AxTps eabac1f11e docs: 补充 1.0.2-beta5 公告
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Prepare pnpm cache / prepare (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Windows x64) (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Starlight release / Build (Linux x64) (push) Canceled after 0s
Starlight release / Build (macOS universal) (push) Canceled after 0s
Starlight release / Verify and publish (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
2026-10-11 07:06:54 +08:00
AxTps c9a2eeb53d Merge branch 'main' into feat/backend
Sync source to CNB / Sync Git ref (push) Skipped
解决 zh-CN locale 末尾追加冲突:保留日志导出键。
2026-10-11 06:14:25 +08:00
Ax_Tps b776b99f26 Merge pull request #12 from ApTx2012/feat/ui
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
feat: 接入皮肤站公告 API
2026-10-11 06:13:00 +08:00
Ax_Tps daeedc4951 Merge pull request #10 from ApTx2012/feat/launch-customization
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
feat: 支持自定义 Minecraft 窗口标题
2026-10-11 06:12:25 +08:00
AxTps 79addd4aba feat: 移植导出启动器日志
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-11 06:12:05 +08:00
AxTps cadb7bbdcd feat: 接入皮肤站公告 API
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 22:34:00 +08:00
AxTps 7f3ab9d79b fix: 补 EditInstance DTO 缺失的 window_title 字段与透传
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 22:23:49 +08:00
AxTps 037bff2db3 Merge branch 'main' into feat/launch-customization
Sync source to CNB / Sync Git ref (push) Skipped
解决 en-US/zh-CN locale JSON 的末尾追加冲突:窗口标题键与公告中心键均保留。
2026-10-10 22:07:19 +08:00
AxTps 1406f7ded3 fix: 补 ProcessMetadata 测试构造点缺失的 window_title
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 22:01:42 +08:00
Ap_Tx c54acd2e36 fix: 补齐盔甲预览的中文翻译 (#11)
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
皮肤编辑器盔甲面板的 46 条文案此前只有英文,补上 packages/ui 的中英对照。
2026-10-10 21:54:50 +08:00
AxTps 1b6534ad8c fix: changelog 评论 workflow 补 pull-requests write 权限
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
2026-10-10 21:53:05 +08:00
AxTps 2477fc62ca fix: 补窗口标题字段缺失的构造点
Sync source to CNB / Sync Git ref (push) Skipped
修 E0063:InstanceLaunchOverridesPatch 和 InstanceLaunchOverrides 的两处完整构造漏了 window_title。
2026-10-10 21:48:20 +08:00
Ap_Tx 3f6678ac2f feat: 移植远程公告中心 (#9)
Sync source to CNB / Sync Git ref (push) Skipped
Rust checks / shear (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
从 Axolotl 搬 RemoteAnnouncements.vue,顶栏加公告铃铛。远程拉取先禁用,等接口。
2026-10-10 21:32:11 +08:00
AxTps e36329ff0e style: 窗口标题改动的 cargo fmt 格式修正
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 21:30:15 +08:00
AxTps c718d75774 feat: 支持自定义 Minecraft 窗口标题
Sync source to CNB / Sync Git ref (push) Skipped
全局开关 + 默认标题,实例可覆盖;Win32 重命名窗口。前端设置页同步。
2026-10-10 21:02:34 +08:00
54 changed files with 5300 additions and 319 deletions

No files matched your search

+2 -2
View File
@@ -12,7 +12,7 @@ on:
permissions:
issues: write
pull-requests: read
pull-requests: write
jobs:
comment:
@@ -21,7 +21,7 @@ jobs:
permissions:
issues: write
pull-requests: read
pull-requests: write
contents: read
steps:
+2
View File
@@ -95,6 +95,7 @@ import NavRail from '@/components/ui/NavRail.vue'
import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
import RemoteAnnouncements from '@/components/ui/RemoteAnnouncements.vue'
import StartupAnnouncementModal from '@/components/ui/StartupAnnouncementModal.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
@@ -2227,6 +2228,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<SkinSiteSessionFrame v-if="stateInitialized" />
<InstancePlayerModal v-if="stateInitialized" />
<StartupAnnouncementModal ref="startupAnnouncementModal" />
<RemoteAnnouncements :ready="stateInitialized && !showOnboarding && !stateFailed" />
<div id="teleports"></div>
<div
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"
@@ -32,6 +32,33 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
]
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
id: 'launcher-1.0.2-beta5',
version: '1.0.2-beta5',
publishedAt: '2026-10-11',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta5',
'zh-CN': 'Starlight Launcher 1.0.2-beta5',
},
changes: {
added: [
{
'en-US': 'Added a remote announcements center: a bell in the top bar opens a panel listing launcher announcements fetched from the Starlight skin site.',
'zh-CN': '新增远程公告中心:顶栏的铃铛可打开公告面板,展示从 Starlight 皮肤站拉取的启动器公告。',
},
{
'en-US': 'Added a custom Minecraft window title option: set a global toggle and default title, and let each instance override it. The game window is renamed on Windows.',
'zh-CN': '新增自定义 Minecraft 窗口标题:可设置全局开关与默认标题,并允许各实例单独覆盖;Windows 下会重命名游戏窗口。',
},
],
fixed: [
{
'en-US': 'Completed the Chinese translations for the armor preview feature.',
'zh-CN': '补齐盔甲预览功能的中文翻译。',
},
],
},
},
{
id: 'launcher-1.0.2-beta4',
version: '1.0.2-beta4',
File diff suppressed because it is too large. Load diff
@@ -67,6 +67,23 @@
</div>
</template>
</Dropdown>
<ButtonStyled
v-tooltip="formatMessage(messages.announcements)"
type="transparent"
circular
>
<button
:aria-label="formatMessage(messages.announcements)"
class="relative"
@click="openAnnouncementCenter"
>
<NewspaperIcon />
<span
v-if="announcementUnreadCount"
class="absolute right-0 top-0 size-2 rounded-full bg-red ring-2 ring-bg-raised"
/>
</button>
</ButtonStyled>
<ButtonStyled
v-if="!isDownloadsPage && hasActiveDownloads && !hasVisibleActiveDownloadToasts"
color="brand"
@@ -190,6 +207,7 @@
<script setup lang="ts">
import {
BellIcon,
NewspaperIcon,
DownloadIcon,
DropdownIcon,
OnlineIndicatorIcon,
@@ -211,7 +229,7 @@ import {
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { Dropdown } from 'floating-vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
@@ -222,6 +240,10 @@ import { get_many as getInstances } from '@/helpers/instance'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import type { LoadingBar } from '@/helpers/state'
import { progress_bars_list } from '@/helpers/state'
import {
OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT,
REMOTE_ANNOUNCEMENTS_UPDATED_EVENT,
} from '@/helpers/remote-announcements'
import type { GameInstance } from '@/helpers/types'
import { downloadBarTypes, injectDownloadManager } from '@/providers/download-manager'
@@ -231,6 +253,25 @@ const popupNotificationManager = injectPopupNotificationManager()
const downloadManager = injectDownloadManager()
const { formatMessage } = useVIntl()
const announcementUnreadCount = ref(0)
function openAnnouncementCenter() {
window.dispatchEvent(new CustomEvent(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT))
}
function updateAnnouncementCount(event: Event) {
const detail = (event as CustomEvent<{ unreadKeys?: string[] }>).detail
announcementUnreadCount.value = detail?.unreadKeys?.length ?? 0
}
onMounted(() => {
window.addEventListener(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, updateAnnouncementCount)
})
onBeforeUnmount(() => {
window.removeEventListener(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, updateAnnouncementCount)
})
type NotificationHistoryItem = {
key: string
createdAt?: number
@@ -370,6 +411,10 @@ const messages = defineMessages({
id: 'app.action-bar.notifications',
defaultMessage: 'Notifications',
},
announcements: {
id: 'app.action-bar.announcements',
defaultMessage: 'Announcements',
},
clearNotifications: {
id: 'app.action-bar.notifications.clear',
defaultMessage: 'Clear all',
@@ -0,0 +1,425 @@
<script setup lang="ts">
import {
Button,
commonMessages,
defineMessages,
NewModal,
useModalStack,
useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import {
announcementKey,
isAnnouncementActive,
mapBroadcastAnnouncements,
OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT,
parseAnnouncements,
REMOTE_ANNOUNCEMENTS_UPDATED_EVENT,
type RemoteAnnouncement,
safeAnnouncementUrl,
} from '@/helpers/remote-announcements'
import {
requestSkinSiteDownloadToken,
skinSiteStatus,
waitForSkinSiteSession,
} from '@/composables/skin-site-session.ts'
const props = defineProps<{ ready: boolean; previewOnly?: boolean }>()
const { formatMessage } = useVIntl()
const { hasModal } = useModalStack()
const messages = defineMessages({
view: { id: 'app.remote-announcements.view', defaultMessage: 'View announcement' },
unread: { id: 'app.remote-announcements.unread', defaultMessage: 'Unread' },
readAll: {
id: 'app.remote-announcements.read-all',
defaultMessage: 'Mark all announcements as read',
},
centerTitle: { id: 'app.remote-announcements.center-title', defaultMessage: 'Announcements' },
empty: { id: 'app.remote-announcements.empty', defaultMessage: 'No announcements' },
previewTitle: {
id: 'app.remote-announcements.preview-title',
defaultMessage: 'Announcement style preview',
},
previewSummary: {
id: 'app.remote-announcements.preview-summary',
defaultMessage:
'This is a local preview. Select View announcement to preview the full Markdown content.',
},
previewContent: {
id: 'app.remote-announcements.preview-content',
defaultMessage:
'## Announcement preview\n\nThis is **sample content**, not a published announcement.\n\n- Supports headings, lists, and links\n- Close buttons are always available\n\n> Previewing does not change real announcement read status.\n\n| Type | Display |\n| --- | --- |\n| Modal | Full Markdown content |\n| Popup | Summary, then full content |\n\n[Visit the website](https://skin.starlight.cool/)',
},
previewAction: { id: 'app.remote-announcements.preview-action', defaultMessage: 'Visit website' },
})
const modal = ref<InstanceType<typeof NewModal>>()
const selected = ref<RemoteAnnouncement | null>(null)
const active = ref(false)
const centerOpen = ref(false)
const startupNotice = ref<RemoteAnnouncement | null>(null)
const html = computed(() => renderString(selected.value?.content ?? ''))
const stateKey = 'starlight-remote-announcements-v2'
const read = reactive(new Set<string>())
const queuedThisSession = new Set<string>()
const startupNotified = new Set<string>()
let items: RemoteAnnouncement[] = []
let pending: RemoteAnnouncement[] = []
let cacheKey = 'starlight-remote-announcements-cache-v1'
let cacheLoaded = false
let disposed = false
let interval: ReturnType<typeof setInterval> | undefined
let advanceTimer: ReturnType<typeof setTimeout> | undefined
function persist() {
if (props.previewOnly) return
try {
localStorage.setItem(
stateKey,
JSON.stringify({
read: [...read].slice(-1000),
}),
)
} catch {
// localStorage may be unavailable (private mode / quota)
}
}
function emitState() {
window.dispatchEvent(
new CustomEvent(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, {
detail: {
items,
unreadKeys: items.filter((item) => !read.has(announcementKey(item))).map(announcementKey),
},
}),
)
}
function openCenter(item?: RemoteAnnouncement) {
if (!props.ready || disposed) return
void refresh()
centerOpen.value = true
if (item) {
startupNotice.value = null
selectAnnouncement(item)
} else if (!selected.value && items.length) {
selectAnnouncement(items[0])
}
void nextTick(() => modal.value?.show())
}
function handleOpenCenter() {
if (props.previewOnly) return
openCenter()
}
async function show(item: RemoteAnnouncement) {
if (
disposed ||
!props.ready ||
!isAnnouncementActive(item) ||
(hasModal.value && !active.value && !centerOpen.value)
)
return
centerOpen.value = true
active.value = true
selectAnnouncement(item)
await nextTick()
if (!disposed) modal.value?.show()
}
function selectAnnouncement(item: RemoteAnnouncement) {
selected.value = item
const key = announcementKey(item)
read.add(key)
pending = pending.filter((entry) => announcementKey(entry) !== key)
persist()
emitState()
}
function markAllRead() {
for (const item of items) {
read.add(announcementKey(item))
}
pending = []
persist()
emitState()
}
function advance() {
if (disposed || !props.ready || hasModal.value || active.value) return
const next = pending.shift()
if (next) {
void show(next)
return
}
for (const item of items) {
const key = announcementKey(item)
if (
item.type === 'notification' &&
!startupNotified.has(key) &&
!read.has(key) &&
isAnnouncementActive(item)
) {
startupNotice.value = item
startupNotified.add(key)
setTimeout(() => {
if (startupNotice.value === item) {
startupNotice.value = null
advance()
}
}, 8000)
break
}
}
}
function closed() {
active.value = false
centerOpen.value = false
if (advanceTimer) clearTimeout(advanceTimer)
advanceTimer = setTimeout(advance, 350)
}
function sync(next: RemoteAnnouncement[], fresh: boolean) {
items = next.filter((item) => isAnnouncementActive(item))
const keys = new Set(items.map(announcementKey))
pending = pending.filter((item) => keys.has(announcementKey(item)))
if (selected.value && !keys.has(announcementKey(selected.value))) selected.value = null
emitState()
if (fresh) {
for (const item of items) {
const key = announcementKey(item)
if (
item.type === 'modal' &&
!queuedThisSession.has(key) &&
(!read.has(key) || item.priority === 'critical')
) {
queuedThisSession.add(key)
pending.push(item)
}
}
advance()
}
}
function loadCache() {
if (cacheLoaded || !cacheKey) return
cacheLoaded = true
try {
const cached = JSON.parse(localStorage.getItem(cacheKey) ?? 'null')
if (cached && typeof cached.savedAt === 'number' && Date.now() - cached.savedAt < 86400000) {
const parsed = parseAnnouncements(cached.items)
if (parsed) sync(parsed, false)
}
} catch {
// Ignore malformed or expired cache payloads
}
}
function saveCache(next: RemoteAnnouncement[]) {
if (!cacheKey) return
try {
localStorage.setItem(cacheKey, JSON.stringify({ savedAt: Date.now(), items: next }))
} catch {
// localStorage may be unavailable (private mode / quota)
}
}
async function refresh() {
if (disposed) return
loadCache()
if (skinSiteStatus.value !== 'signed-in') return
try {
await waitForSkinSiteSession()
const token = await requestSkinSiteDownloadToken()
const response = await tauriFetch('https://skin.starlight.cool/starlight/skin/broadcast/all', {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) return
const data = await response.json()
const parsed = mapBroadcastAnnouncements((data as { payload?: unknown } | null)?.payload)
if (parsed && !disposed) {
sync(parsed, true)
saveCache(parsed)
}
} catch {
// Remote fetch failed; previously cached items remain displayed
}
}
async function openLink(value: unknown) {
const url = safeAnnouncementUrl(value)
if (!url) return
try {
await openUrl(url)
} catch {
// Opener may reject unknown schemes
}
}
function contentClick(event: MouseEvent) {
const link = event.target instanceof Element ? event.target.closest('a') : null
if (!link) return
event.preventDefault()
event.stopPropagation()
void openLink(link.getAttribute('href'))
}
function preview(type: RemoteAnnouncement['type'], withAction = false) {
if (!props.previewOnly || !props.ready || hasModal.value || disposed) return
const now = new Date().toISOString()
const item: RemoteAnnouncement = {
id: 'local-preview-' + type,
title: formatMessage(messages.previewTitle),
summary: formatMessage(messages.previewSummary),
content: formatMessage(messages.previewContent),
type,
priority: 'normal',
starts_at: now,
ends_at: null,
published_at: now,
action_label: withAction ? formatMessage(messages.previewAction) : null,
action_url: withAction ? 'https://skin.starlight.cool/' : null,
}
read.clear()
queuedThisSession.clear()
sync([item], true)
}
defineExpose({ preview })
onMounted(() => {
if (props.previewOnly) return
try {
const saved = JSON.parse(localStorage.getItem(stateKey) ?? 'null')
if (saved && Array.isArray(saved.read))
for (const key of saved.read) if (typeof key === 'string') read.add(key)
} catch {
// Ignore malformed local read-state
}
window.addEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
const start = () => {
if (disposed) return
void refresh()
interval = setInterval(() => {
sync(items, false)
advance()
}, 15000)
}
// Defer the startup request until after first paint.
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(start, { timeout: 2000 })
} else {
setTimeout(start, 500)
}
})
watch(skinSiteStatus, (status) => {
if (disposed || props.previewOnly) return
if (status === 'signed-in') void refresh()
})
watch([() => props.ready, hasModal], () => {
if (advanceTimer) clearTimeout(advanceTimer)
advanceTimer = setTimeout(advance, 350)
})
onUnmounted(() => {
disposed = true
if (interval) clearInterval(interval)
if (advanceTimer) clearTimeout(advanceTimer)
window.removeEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
})
</script>
<template>
<div
v-if="startupNotice"
class="fixed right-4 top-16 z-50 w-[22rem] max-w-[calc(100vw-2rem)] rounded-xl border border-surface-5 bg-surface-3 p-3 shadow-lg"
>
<button class="w-full text-left" @click="startupNotice && openCenter(startupNotice)">
<div class="mb-1 text-xs font-semibold uppercase text-brand">
{{ formatMessage(messages.unread) }}
</div>
<div class="font-semibold text-[var(--color-text-primary)]">{{ startupNotice.title }}</div>
<div
v-if="startupNotice.summary"
class="mt-1 line-clamp-2 text-sm text-[var(--color-text-tertiary)]"
>
{{ startupNotice.summary }}
</div>
</button>
</div>
<NewModal
ref="modal"
:header="formatMessage(messages.centerTitle)"
:on-hide="closed"
max-width="80vw"
width="80vw"
max-content-height="calc(80vh - 8rem)"
scrollable
>
<div class="flex h-[calc(80vh-8rem)] min-h-0 gap-3">
<aside class="flex min-h-0 w-[32%] min-w-[14rem] flex-col border-r border-surface-5 pr-3">
<div class="mb-2 flex items-center justify-between">
<span class="font-semibold text-[var(--color-text-primary)]">{{
formatMessage(messages.centerTitle)
}}</span>
<Button
v-if="items.some((item) => !read.has(announcementKey(item)))"
@click="markAllRead"
>{{ formatMessage(messages.readAll) }}</Button
>
</div>
<div
v-if="!items.length"
class="py-8 text-center text-sm text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.empty) }}
</div>
<div v-else class="flex min-h-0 flex-col gap-1 overflow-auto">
<button
v-for="item in items"
:key="announcementKey(item)"
class="flex items-start gap-2 rounded-lg p-2 text-left hover:bg-surface-4"
:class="{
'bg-surface-4': selected && announcementKey(selected) === announcementKey(item),
}"
@click="selectAnnouncement(item)"
>
<span
class="mt-1.5 size-2 shrink-0 rounded-full"
:class="read.has(announcementKey(item)) ? 'opacity-0' : 'bg-red'"
/>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium text-[var(--color-text-primary)]">{{
item.title
}}</span>
<span class="block line-clamp-2 text-xs text-[var(--color-text-tertiary)]">{{
item.summary || item.content
}}</span>
</span>
</button>
</div>
</aside>
<section v-if="selected" class="min-h-0 min-w-0 flex-1 overflow-auto px-1">
<h2 class="mb-4 text-xl font-semibold text-[var(--color-text-primary)]">
{{ selected.title }}
</h2>
<div
class="markdown-body break-words"
@click="contentClick"
@auxclick="contentClick"
v-html="html"
/>
<div class="mt-4 flex justify-end">
<Button
v-if="selected.action_url && selected.action_label"
type="colored"
color="brand"
@click="openLink(selected.action_url)"
>{{ selected.action_label }}</Button
>
</div>
</section>
<div
v-else
class="flex flex-1 items-center justify-center text-sm text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.empty) }}
</div>
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<Button @click="modal?.hide()">{{ formatMessage(commonMessages.closeButton) }} </Button>
</div>
</template>
</NewModal>
</template>
@@ -36,24 +36,29 @@ const fullscreenSetting: Ref<boolean> = ref(
instance.value.force_fullscreen ?? globalSettings.force_fullscreen,
)
const maximizeWindowSetting = ref(instance.value.maximize_window ?? globalSettings.maximize_window)
const windowTitle = ref(instance.value.window_title ?? '')
const editInstanceObject = computed(() => {
const title = windowTitle.value.trim() ? windowTitle.value.trim() : null
if (!overrideWindowSettings.value) {
return {
force_fullscreen: null,
maximize_window: null,
game_resolution: null,
// The title is independent of the window-size override checkbox.
window_title: title,
}
}
return {
force_fullscreen: fullscreenSetting.value,
maximize_window: maximizeWindowSetting.value,
game_resolution: fullscreenSetting.value ? null : resolution.value,
window_title: title,
}
})
watch(
[overrideWindowSettings, resolution, fullscreenSetting, maximizeWindowSetting],
[overrideWindowSettings, resolution, fullscreenSetting, maximizeWindowSetting, windowTitle],
async () => {
await edit(instance.value.id, editInstanceObject.value)
},
@@ -109,6 +114,18 @@ const messages = defineMessages({
id: 'instance.settings.tabs.window.height.enter',
defaultMessage: 'Enter height...',
},
windowTitle: {
id: 'instance.settings.tabs.window.window-title',
defaultMessage: 'Window title',
},
windowTitleDescription: {
id: 'instance.settings.tabs.window.window-title.description',
defaultMessage: 'Customize the Minecraft window title. Leave empty to keep the default.',
},
enterWindowTitle: {
id: 'instance.settings.tabs.window.window-title.enter',
defaultMessage: 'Enter window title...',
},
})
</script>
@@ -200,5 +217,25 @@ const messages = defineMessages({
:placeholder="formatMessage(messages.enterHeight)"
/>
</div>
<div
v-if="globalSettings.custom_window_title_enabled"
class="flex items-center gap-4 justify-between"
>
<div class="flex flex-col gap-1">
<h2 class="m-0 inline-flex items-center gap-2 text-lg font-semibold text-contrast">
{{ formatMessage(messages.windowTitle) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.windowTitleDescription) }}
</p>
</div>
<StyledInput
id="window-title"
v-model="windowTitle"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.enterWindowTitle)"
/>
</div>
</div>
</template>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
import { useEventListener } from '@vueuse/core'
import { computed, ref } from 'vue'
import type { GameInstance } from '@/helpers/types'
const props = defineProps<{
instance: GameInstance
instanceGroupId: string
disabled: boolean
}>()
const emit = defineEmits<{
(e: 'contextmenu' | 'card-click', event: MouseEvent): void
(e: 'start-long-press' | 'cancel-long-press'): void
}>()
const cardElement = ref<HTMLElement>()
const instanceCardSensors = [
PointerSensor.configure({
preventActivation: () => false,
}),
KeyboardSensor,
]
const dragDisabled = computed(() => props.disabled)
const { isDragging } = useDraggable({
id: computed(() => `instance:${props.instanceGroupId}:${props.instance.id}`),
element: cardElement,
disabled: dragDisabled,
sensors: instanceCardSensors,
data: computed(() => ({
instanceId: props.instance.id,
fromGroup: props.instanceGroupId,
})),
})
let wantsClick = false
useEventListener(window, 'pointerdown', () => {
wantsClick = true
})
useEventListener(window, 'pointerup', () => {
if (isDragging.value) {
wantsClick = false
}
})
useEventListener(window, 'blur', () => {
wantsClick = false
})
function onCardClick(event: MouseEvent) {
if (!wantsClick || isDragging.value) return
wantsClick = false
emit('card-click', event)
}
function onContextMenu(event: MouseEvent) {
emit('contextmenu', event)
}
function onMouseDown() {
if (!dragDisabled.value) {
emit('start-long-press')
}
}
function onMouseUp() {
emit('cancel-long-press')
}
</script>
<template>
<div
ref="cardElement"
class="group relative min-w-0 w-full"
@click="onCardClick"
@mousedown="onMouseDown"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
@touchstart="!dragDisabled && onMouseDown()"
@touchend="onMouseUp"
@touchcancel="onMouseUp"
@contextmenu.prevent.stop="onContextMenu"
>
<slot :is-dragging="isDragging" />
<slot v-if="!isDragging" name="overlay" />
</div>
</template>
@@ -0,0 +1,521 @@
<script setup lang="ts">
import { useDroppable } from '@dnd-kit/vue'
import {
ArrowDownIcon,
ArrowUpIcon,
DropdownIcon,
EditIcon,
PlusIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import {
Button,
Checkbox,
commonMessages,
defineMessages,
IconButton,
InlineEditableText,
NewModal,
TagItem,
useVIntl,
} from '@modrinth/ui'
import { computed, nextTick, ref, watch } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import DraggableInstanceCard from '@/components/ui/library/DraggableInstanceCard.vue'
import { UNGROUPED_GROUP_KEY } from '@/composables/useGridGrouping'
import { FAVORITES_GROUP_ID } from '@/composables/useInstanceGroups'
import type { GameInstance } from '@/helpers/types'
const MAX_GROUP_NAME_LENGTH = 256
const { formatMessage } = useVIntl()
const messages = defineMessages({
ungrouped: { id: 'app.instances.group.ungrouped', defaultMessage: 'No group' },
pinned: { id: 'app.instances.group.pinned', defaultMessage: 'Pinned' },
emptyGroup: {
id: 'app.instances.group.empty',
defaultMessage: 'Drag and drop to add instances.',
},
editGroupName: {
id: 'app.instances.group.edit-name',
defaultMessage: 'Edit group name',
},
deleteGroup: {
id: 'app.instances.group.delete-group',
defaultMessage: 'Delete group',
},
deleteGroupDescription: {
id: 'app.instances.group.delete-description',
defaultMessage: 'Instances in this group will be ungrouped.',
},
moveGroupUp: {
id: 'app.instances.group.move-up',
defaultMessage: 'Move group up',
},
moveGroupDown: {
id: 'app.instances.group.move-down',
defaultMessage: 'Move group down',
},
addToGroup: {
id: 'app.instances.group.add-to-group',
defaultMessage: 'Add to group',
},
newInstance: {
id: 'app.instances.group.new-instance',
defaultMessage: 'New instance',
},
collapseGroup: {
id: 'app.instances.group.collapse',
defaultMessage: 'Collapse group',
},
expandGroup: {
id: 'app.instances.group.expand',
defaultMessage: 'Expand group',
},
renameGroup: {
id: 'app.instances.group.rename-group',
defaultMessage: 'Rename group',
},
groupNameEmpty: {
id: 'app.instances.group.name-empty',
defaultMessage: 'Group names cannot be empty.',
},
groupNameTooLong: {
id: 'app.instances.group.name-too-long',
defaultMessage: 'Group names cannot be longer than {maxLength} characters.',
},
})
const props = defineProps<{
sectionKey: string
groupName?: string
instances: GameInstance[]
grouping: string
isCollapsed: boolean
displayMode: string
selectMode: boolean
selectedInstanceIds: Set<string>
isSortAscending?: boolean
hideHeader?: boolean
canMoveUp?: boolean
canMoveDown?: boolean
groupPendingNameEdit?: string | null
}>()
// Events are grouped only where the payload shapes genuinely match; the
// per-event names are documentation, not part of the type.
const emit = defineEmits<{
(e: 'toggleCollapse' | 'deleteGroup' | 'addToGroup' | 'startLongPress', id: string): void
(e: 'handleCheckboxClick' | 'handleCardClick', instanceId: string, event: MouseEvent): void
(e: 'handleContextMenu', event: MouseEvent, instanceId: string, sectionKey: string): void
(e: 'renameGroup', oldKey: string, newKey: string): void
(e: 'cancelLongPress' | 'newInstance' | 'renameComplete'): void
(e: 'moveGroup', groupKey: string, direction: -1 | 1): void
}>()
const groupOptions = ref<InstanceType<typeof ContextMenu>>()
const groupContainerRef = ref<HTMLElement>()
const groupNameInput = ref<InstanceType<typeof InlineEditableText>>()
const deletingGroup = ref(false)
const confirmDeleteGroupModal = ref<InstanceType<typeof NewModal>>()
const groupName = ref(props.groupName ?? props.sectionKey)
const isUngrouped = computed(() => props.sectionKey === UNGROUPED_GROUP_KEY)
const isFavorites = computed(() => props.sectionKey === FAVORITES_GROUP_ID)
const isCustomGroup = computed(
() => props.grouping === 'Group' && !isUngrouped.value && !isFavorites.value,
)
const isGroupDragActive = computed(() => props.grouping === 'Group')
const effectiveCollapsed = computed(() => (props.hideHeader ? false : props.isCollapsed))
const { isDropTarget } = useDroppable({
id: computed(() => `instance-group:${props.sectionKey}`),
element: groupContainerRef,
disabled: computed(() => props.hideHeader || !isGroupDragActive.value),
data: computed(() => ({
groupId: props.sectionKey,
})),
})
watch(
() => [props.sectionKey, props.groupName],
() => {
groupName.value = props.groupName ?? props.sectionKey
},
)
watch(
() => props.groupPendingNameEdit,
async (pendingId) => {
if (pendingId !== props.sectionKey) return
await nextTick()
groupContainerRef.value?.scrollIntoView({ block: 'nearest' })
await groupNameInput.value?.startEditing()
emit('renameComplete')
},
{ immediate: true, flush: 'post' },
)
function validateGroupName(value: string) {
const normalized = value.trim()
if (normalized.length === 0) return false
if (normalized.length > MAX_GROUP_NAME_LENGTH) return false
return true
}
async function updateGroupName(value: string) {
const newName = value.trim()
if (!newName || newName === groupName.value) return false
emit('renameGroup', props.sectionKey, newName)
return true
}
function openGroupContextMenu(event: MouseEvent) {
groupOptions.value?.showMenu(
event,
props.sectionKey,
isCustomGroup.value
? [
{ name: 'new_instance' },
{ name: 'add_instances' },
{ name: 'edit_name' },
{ type: 'divider' },
{ name: 'delete_group', color: 'danger' },
]
: [{ name: 'new_instance' }],
)
}
async function handleGroupOption({ option }: { option: string }) {
if (option === 'new_instance') {
emit('newInstance')
} else if (option === 'add_instances') {
emit('addToGroup', props.sectionKey)
} else if (option === 'edit_name') {
groupNameInput.value?.startEditing()
} else if (option === 'delete_group') {
requestGroupDeletion()
}
}
function requestGroupDeletion() {
if (isUngrouped.value || isFavorites.value) return
if (props.instances.length > 0) {
confirmDeleteGroupModal.value?.show()
} else {
void removeGroup()
}
}
async function removeGroup() {
if (deletingGroup.value) return
deletingGroup.value = true
emit('deleteGroup', props.sectionKey)
setTimeout(() => {
deletingGroup.value = false
confirmDeleteGroupModal.value?.hide()
}, 300)
}
function onCardClick(instanceId: string, event: MouseEvent) {
emit('handleCardClick', instanceId, event)
}
function onStartLongPress(instanceId: string) {
emit('startLongPress', instanceId)
}
</script>
<template>
<div
ref="groupContainerRef"
class="instance-group group/instance-container relative select-none pb-3"
@contextmenu.prevent.stop="openGroupContextMenu"
>
<div
v-if="!hideHeader && isDropTarget && isGroupDragActive"
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 border-dashed border-contrast opacity-40 bg-transparent transition-opacity duration-150"
/>
<div
v-if="!hideHeader && (grouping === 'Group' || sectionKey !== UNGROUPED_GROUP_KEY)"
class="group/header flex h-10 w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
:class="{ 'group-drag-handle cursor-grab': isCustomGroup }"
>
<div
class="flex min-w-0 cursor-pointer items-center gap-2"
@click="emit('toggleCollapse', sectionKey)"
>
<button
class="flex shrink-0 items-center border-0 bg-transparent p-0 cursor-pointer"
type="button"
:aria-expanded="!effectiveCollapsed"
:aria-label="
formatMessage(effectiveCollapsed ? messages.expandGroup : messages.collapseGroup)
"
@click.stop="emit('toggleCollapse', sectionKey)"
>
<DropdownIcon
class="size-5 shrink-0 text-[var(--color-text-tertiary)] transition-transform duration-300"
:class="{ 'rotate-180': !effectiveCollapsed }"
/>
</button>
<InlineEditableText
v-if="!isUngrouped && !isFavorites"
ref="groupNameInput"
v-model="groupName"
activation-mode="manual"
class="text-base font-semibold !h-10 text-[var(--color-text-default)] select-none"
max-width="24rem"
:max-length="MAX_GROUP_NAME_LENGTH"
:on-change="updateGroupName"
:validate="validateGroupName"
/>
<span v-else class="text-base font-semibold text-[var(--color-text-default)] select-none">
{{ isFavorites ? formatMessage(messages.pinned) : formatMessage(messages.ungrouped) }}
</span>
<TagItem v-if="instances.length" class="shrink-0 border-surface-3 bg-surface-2">
{{ instances.length }}
</TagItem>
</div>
<div class="min-w-0 flex-1" />
<div
v-if="isCustomGroup"
class="flex shrink-0 items-center opacity-0 transition-opacity duration-250 group-hover/header:opacity-100 focus-within:opacity-100"
>
<IconButton
v-tooltip="formatMessage(messages.moveGroupUp)"
:label="formatMessage(messages.moveGroupUp)"
type="quiet"
size="sm"
:disabled="!canMoveUp"
@click.stop="emit('moveGroup', sectionKey, -1)"
>
<ArrowUpIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.moveGroupDown)"
:label="formatMessage(messages.moveGroupDown)"
type="quiet"
size="sm"
:disabled="!canMoveDown"
@click.stop="emit('moveGroup', sectionKey, 1)"
>
<ArrowDownIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.editGroupName)"
:label="formatMessage(messages.editGroupName)"
type="quiet"
size="sm"
@click.stop="groupNameInput?.startEditing()"
>
<EditIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.addToGroup)"
:label="formatMessage(messages.addToGroup)"
type="quiet"
size="sm"
@click.stop="emit('addToGroup', sectionKey)"
>
<PlusIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.deleteGroup)"
:label="formatMessage(messages.deleteGroup)"
type="quiet"
size="sm"
:disabled="deletingGroup"
@click.stop="handleGroupOption({ option: 'delete_group' })"
>
<TrashIcon />
</IconButton>
</div>
</div>
<div v-if="!effectiveCollapsed" class="mt-2.5">
<section
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] w-full gap-3 mr-auto scroll-smooth overflow-y-auto"
:class="{
'grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] gap-4': displayMode === 'cards',
}"
>
<template v-for="instance in instances" :key="instance.id">
<DraggableInstanceCard
v-if="isGroupDragActive"
:instance="instance"
:instance-group-id="sectionKey"
:disabled="selectMode"
@card-click="(event: MouseEvent) => onCardClick(instance.id, event)"
@contextmenu="
(event: MouseEvent) => emit('handleContextMenu', event, instance.id, props.sectionKey)
"
@start-long-press="onStartLongPress(instance.id)"
@cancel-long-press="emit('cancelLongPress')"
>
<template #default="{ isDragging }">
<div
:class="{
'pointer-events-none': selectMode,
'relative cursor-pointer select-none rounded-lg transition-all hover:brightness-90 active:scale-[0.98]': true,
}"
@click.capture="
(event: MouseEvent) => {
if (event.shiftKey && !selectMode) {
event.stopPropagation()
emit('handleCardClick', instance.id, event)
}
}
"
>
<Instance
:instance="instance"
:disabled="selectMode || isDragging"
:variant="displayMode === 'cards' ? 'library' : 'standard'"
:class="{
'opacity-50': selectMode && !selectedInstanceIds.has(instance.id),
}"
/>
</div>
</template>
<template #overlay>
<div
class="absolute right-2 bottom-2 z-10 transition-opacity"
:class="
selectMode && selectedInstanceIds.has(instance.id)
? ''
: 'opacity-0 group-hover:opacity-100'
"
@click.stop="emit('handleCheckboxClick', instance.id, $event)"
>
<Checkbox :model-value="selectedInstanceIds.has(instance.id)" />
</div>
<div
v-if="!selectMode"
class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity"
@click.stop="
emit('handleContextMenu', $event as MouseEvent, instance.id, props.sectionKey)
"
>
<Button type="quiet" size="2xs" circular icon-only
><slot name="moreIcon" />
</Button>
</div>
</template>
</DraggableInstanceCard>
<div v-else class="group relative">
<div
class="relative cursor-pointer select-none rounded-lg transition-all hover:brightness-90 active:scale-[0.98]"
@click.capture="
(event: MouseEvent) => {
if (event.shiftKey && !selectMode) {
event.stopPropagation()
emit('handleCardClick', instance.id, event)
}
}
"
@click="emit('handleCardClick', instance.id, $event as MouseEvent)"
@mousedown="!selectMode && emit('startLongPress', instance.id)"
@mouseup="emit('cancelLongPress')"
@mouseleave="emit('cancelLongPress')"
@touchstart="!selectMode && emit('startLongPress', instance.id)"
@touchend="emit('cancelLongPress')"
@touchcancel="emit('cancelLongPress')"
>
<div :class="{ 'pointer-events-none': selectMode }">
<Instance
:instance="instance"
:disabled="selectMode"
:variant="displayMode === 'cards' ? 'library' : 'standard'"
:class="{
'opacity-50': selectMode && !selectedInstanceIds.has(instance.id),
}"
@contextmenu.prevent.stop="
(event: MouseEvent) =>
emit('handleContextMenu', event, instance.id, props.sectionKey)
"
/>
</div>
</div>
<div
class="absolute right-2 bottom-2 z-10 transition-opacity"
:class="
selectMode && selectedInstanceIds.has(instance.id)
? ''
: 'opacity-0 group-hover:opacity-100'
"
@click.stop="emit('handleCheckboxClick', instance.id, $event)"
>
<Checkbox :model-value="selectedInstanceIds.has(instance.id)" />
</div>
<div
v-if="!selectMode"
class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity"
@click.stop="
emit('handleContextMenu', $event as MouseEvent, instance.id, props.sectionKey)
"
>
<Button type="quiet" size="2xs" circular icon-only><slot name="moreIcon" /> </Button>
</div>
</div>
</template>
<p
v-if="instances.length === 0"
class="col-span-full m-0 pt-1 pl-0.5 text-base font-base text-[var(--color-text-tertiary)] opacity-80"
>
{{ formatMessage(messages.emptyGroup) }}
</p>
</section>
</div>
</div>
<ContextMenu ref="groupOptions" @option-clicked="handleGroupOption">
<template #new_instance>
<PlusIcon />
{{ formatMessage(messages.newInstance) }}
</template>
<template #add_instances>
<PlusIcon />
{{ formatMessage(messages.addToGroup) }}
</template>
<template #edit_name>
<EditIcon />
{{ formatMessage(messages.editGroupName) }}
</template>
<template #delete_group>
<TrashIcon />
{{ formatMessage(messages.deleteGroup) }}
</template>
</ContextMenu>
<NewModal
ref="confirmDeleteGroupModal"
:header="formatMessage(messages.deleteGroup)"
fade="danger"
width="500px"
>
<p class="m-0 text-base text-[var(--color-text-default)]">
{{ formatMessage(messages.deleteGroupDescription) }}
</p>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" @click="confirmDeleteGroupModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="red" :disabled="deletingGroup" @click="removeGroup">
<TrashIcon />
{{ formatMessage(messages.deleteGroup) }}
</Button>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,313 @@
<template>
<NewModal ref="modal" no-padding scrollable actions-divider max-width="560px" width="560px">
<template #title>
<span class="text-2xl font-semibold text-[var(--color-text-primary)]">
{{
existingGroupName
? formatMessage(messages.addToGroupTitle, { groupName: existingGroupName })
: formatMessage(messages.header)
}}
</span>
</template>
<template v-if="!existingGroupName">
<div class="flex flex-col gap-2.5 p-6">
<label for="new-group-name" class="font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.groupName) }}
</label>
<StyledInput
id="new-group-name"
ref="groupNameInput"
v-model="newGroupName"
:placeholder="formatMessage(messages.groupNamePlaceholder)"
:maxlength="32"
@click="groupNameInput?.select()"
/>
</div>
<div class="h-px bg-divider" />
</template>
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 py-4">
<div class="px-6">
<StyledInput
v-model="newGroupSearch"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchInstance)"
class="w-full"
/>
</div>
<div
v-if="newGroupInstances.length === 0"
class="flex items-center justify-center py-12 text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.noInstancesFound) }}
</div>
<div v-else class="flex flex-col gap-1">
<div
v-for="instance in newGroupInstances"
:key="instance.id"
class="flex items-center justify-between gap-4 px-6 py-1.5 hover:bg-surface-3"
:class="{ 'opacity-60': selectedNewGroupInstanceIds.has(instance.id) }"
>
<div class="flex min-w-0 items-center gap-2.5">
<InstanceIcon
:icon-path="instance.icon_path"
:instance-id="instance.id"
:loader="instance.loader"
class="!size-[2rem] !rounded-md"
/>
<div class="flex min-w-0 items-center gap-2">
<span class="truncate font-semibold text-[var(--color-text-primary)]">{{
instance.name
}}</span>
<TagItem v-if="instance.groups && instance.groups.length > 0" class="shrink-0">
{{ getGroupName(instance.groups[0]) }}
</TagItem>
</div>
</div>
<Button
:type="selectedNewGroupInstanceIds.has(instance.id) ? 'outlined' : 'base'"
@click="toggleNewGroupInstance(instance.id)"
>
<CheckIcon v-if="selectedNewGroupInstanceIds.has(instance.id)" />
{{
formatMessage(
selectedNewGroupInstanceIds.has(instance.id) ? messages.added : messages.add,
)
}}
</Button>
</div>
</div>
</div>
<template #actions>
<div class="flex items-center justify-end gap-2">
<Button type="outlined" @click="modal?.hide()">
<XIcon />
{{ formatMessage(messages.cancel) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="existingGroupName ? !canSaveGroups : !canCreateGroup"
@click="existingGroupName ? handleSaveGroups() : handleCreateGroup()"
>
<SpinnerIcon v-if="creatingGroup" class="animate-spin" />
<CheckIcon v-else-if="existingGroupName" />
<PlusIcon v-else />
{{ formatMessage(existingGroupName ? messages.saveChanges : messages.createGroup) }}
</Button>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { CheckIcon, PlusIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { Button, defineMessages, NewModal, StyledInput, TagItem, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { FAVORITES_GROUP_ID } from '@/composables/useInstanceGroups'
import { list } from '@/helpers/instance'
import { create_group, list_groups, set_group_memberships } from '@/helpers/instance-groups'
import type { GameInstance } from '@/helpers/types'
const { formatMessage } = useVIntl()
const props = defineProps<{
instanceIds: string[]
existingGroupName?: string
existingGroupId?: string
}>()
const emit = defineEmits<{
(e: 'applied'): void
}>()
const messages = defineMessages({
header: {
id: 'app.instances.batch-edit-groups.header',
defaultMessage: 'Create group',
},
addToGroupTitle: {
id: 'app.instances.batch-edit-groups.add-to-group-title',
defaultMessage: 'Add instances to "{groupName}"',
},
groupName: {
id: 'app.instances.batch-edit-groups.group-name',
defaultMessage: 'Group name',
},
groupNamePlaceholder: {
id: 'app.instances.batch-edit-groups.group-name-placeholder',
defaultMessage: 'Enter group name',
},
searchInstance: {
id: 'app.instances.batch-edit-groups.search-instance',
defaultMessage: 'Search instances',
},
noInstancesFound: {
id: 'app.instances.batch-edit-groups.no-instances-found',
defaultMessage: 'No instances found',
},
add: {
id: 'app.instances.batch-edit-groups.add',
defaultMessage: 'Add',
},
added: {
id: 'app.instances.batch-edit-groups.added',
defaultMessage: 'Added',
},
cancel: {
id: 'app.instances.batch-edit-groups.cancel',
defaultMessage: 'Cancel',
},
createGroup: {
id: 'app.instances.batch-edit-groups.create',
defaultMessage: 'Create group',
},
pinnedGroupName: {
id: 'app.instances.group.pinned',
defaultMessage: 'Pinned',
},
saveChanges: {
id: 'app.instances.batch-edit-groups.save-changes',
defaultMessage: 'Save',
},
})
const modal = ref<InstanceType<typeof NewModal>>()
const groupNameInput = ref<InstanceType<typeof StyledInput>>()
const newGroupName = ref('')
const newGroupSearch = ref('')
const creatingGroup = ref(false)
const allInstances = ref<GameInstance[]>([])
const groupNameMap = ref(new Map<string, string>())
const selectedNewGroupInstanceIds = ref(new Set<string>())
const canCreateGroup = computed(() => newGroupName.value.trim().length > 0)
const canSaveGroups = computed(() => {
if (!props.existingGroupName) return false
const groupId = props.existingGroupId
if (!groupId) return false
return allInstances.value.some((instance) => {
const isSelected = selectedNewGroupInstanceIds.value.has(instance.id)
const hasGroup = (instance.groups || []).includes(groupId)
return isSelected !== hasGroup
})
})
const newGroupInstances = computed(() => {
const search = newGroupSearch.value.toLowerCase()
return allInstances.value.filter((instance) => {
if (!search) return true
return instance.name.toLowerCase().includes(search)
})
})
function getGroupName(groupId: string) {
if (groupId === FAVORITES_GROUP_ID) {
return formatMessage(messages.pinnedGroupName)
}
return groupNameMap.value.get(groupId) || groupId
}
function show(ids?: string[]) {
newGroupSearch.value = ''
creatingGroup.value = false
Promise.all([list(), list_groups()]).then(([instances, groups]) => {
allInstances.value = instances as GameInstance[]
const map = new Map<string, string>()
for (const g of groups) {
map.set(g.id, g.name)
}
groupNameMap.value = map
if (!props.existingGroupName) {
const customGroups = groups.filter((g: { id: string }) => g.id !== 'group:favorites')
let groupNumber = customGroups.length + 1
const existingNames = new Set(groups.map((g: { name: string }) => g.name.toLowerCase()))
while (existingNames.has(`group ${groupNumber}`)) {
groupNumber++
}
newGroupName.value = `Group ${groupNumber}`
}
if (props.existingGroupName) {
const groupId = props.existingGroupId
selectedNewGroupInstanceIds.value = new Set(
allInstances.value.filter((i) => (i.groups || []).includes(groupId || '')).map((i) => i.id),
)
} else {
selectedNewGroupInstanceIds.value = new Set(ids ?? props.instanceIds)
}
})
modal.value?.show()
}
function toggleNewGroupInstance(instanceId: string) {
const next = new Set(selectedNewGroupInstanceIds.value)
if (next.has(instanceId)) {
next.delete(instanceId)
} else {
next.add(instanceId)
}
selectedNewGroupInstanceIds.value = next
}
async function handleCreateGroup() {
const name = newGroupName.value.trim()
if (!name || selectedNewGroupInstanceIds.value.size === 0) return
creatingGroup.value = true
const group = await create_group(name).catch(() => null)
if (!group) {
creatingGroup.value = false
return
}
if (selectedNewGroupInstanceIds.value.size > 0) {
const updates = [...selectedNewGroupInstanceIds.value].map((instanceId) => ({
instance_id: instanceId,
group_ids: [group.id],
}))
await set_group_memberships(updates).catch(() => {})
}
creatingGroup.value = false
modal.value?.hide()
emit('applied')
}
async function handleSaveGroups() {
if (!props.existingGroupName) return
creatingGroup.value = true
const groupId = props.existingGroupId
const changedInstances = allInstances.value.filter((instance) => {
const isSelected = selectedNewGroupInstanceIds.value.has(instance.id)
const hasGroup = (instance.groups || []).includes(groupId || '')
return isSelected !== hasGroup
})
const updates = changedInstances.map((instance) => {
const isSelected = selectedNewGroupInstanceIds.value.has(instance.id)
return {
instance_id: instance.id,
group_ids: isSelected ? [groupId!] : [],
}
})
await set_group_memberships(updates).catch(() => {})
creatingGroup.value = false
modal.value?.hide()
emit('applied')
}
defineExpose({ show })
</script>
@@ -38,6 +38,26 @@ const messages = defineMessages({
id: 'app.settings.defaults.maximize-window.unsupported',
defaultMessage: 'Not supported on this operating system.',
},
windowTitleEnabled: {
id: 'app.settings.defaults.window-title.enabled',
defaultMessage: 'Custom Minecraft window title',
},
windowTitleEnabledDescription: {
id: 'app.settings.defaults.window-title.enabled-description',
defaultMessage: 'Allow instances to customize the Minecraft window title.',
},
defaultWindowTitle: {
id: 'app.settings.defaults.window-title.default',
defaultMessage: 'Default window title',
},
defaultWindowTitleDescription: {
id: 'app.settings.defaults.window-title.default-description',
defaultMessage: 'Used when an instance leaves its window title empty.',
},
defaultWindowTitlePlaceholder: {
id: 'app.settings.defaults.window-title.default-placeholder',
defaultMessage: 'Enter default window title...',
},
width: { id: 'app.settings.defaults.width', defaultMessage: 'Width' },
widthDescription: {
id: 'app.settings.defaults.width-description',
@@ -212,6 +232,36 @@ watch(
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-defaults-window-title" tabindex="-1">
{{ formatMessage(messages.windowTitleEnabled) }}
</span>
</template>
<template #description>{{
formatMessage(messages.windowTitleEnabledDescription)
}}</template>
<template #control>
<Toggle id="custom-window-title-enabled" v-model="settings.custom_window_title_enabled" />
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>{{ formatMessage(messages.defaultWindowTitle) }}</template>
<template #description>{{
formatMessage(messages.defaultWindowTitleDescription)
}}</template>
<template #control>
<StyledInput
id="default-window-title"
v-model="settings.default_window_title"
:disabled="!settings.custom_window_title_enabled"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.defaultWindowTitlePlaceholder)"
wrapper-class="w-full"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection>
@@ -0,0 +1,192 @@
<script setup lang="ts">
import { DownloadIcon, FolderOpenIcon, XIcon } from '@modrinth/assets'
import {
Button,
Checkbox,
Combobox,
commonMessages,
defineMessages,
injectNotificationManager,
injectPopupNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { exportLauncherLogs, highlightInFolder } from '@/helpers/utils'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const popupNotificationManager = injectPopupNotificationManager()
const modal = ref<InstanceType<typeof NewModal>>()
const exporting = ref(false)
const selectedRange = ref<'last30Minutes' | 'last2Hours' | 'all'>('last2Hours')
const selectedLevel = ref<'all' | 'debug' | 'info'>('all')
const includeSystemInfo = ref(true)
const includeInstanceLogs = ref(false)
const includeCrashAnalysis = ref(false)
const messages = defineMessages({
title: { id: 'app.settings.logs.export.modal.title', defaultMessage: 'Export logs' },
description: {
id: 'app.settings.logs.export.modal.description',
defaultMessage:
'Choose how much detail to include. Exported logs have access tokens, Minecraft tokens, and IP addresses replaced with placeholders.',
},
rangeLabel: { id: 'app.settings.logs.export.modal.range', defaultMessage: 'Time range' },
rangeLast30Minutes: {
id: 'app.settings.logs.export.modal.range.30-minutes',
defaultMessage: 'Last 30 minutes',
},
rangeLast2Hours: {
id: 'app.settings.logs.export.modal.range.2-hours',
defaultMessage: 'Last 2 hours',
},
rangeAll: { id: 'app.settings.logs.export.modal.range.all', defaultMessage: 'All sessions' },
levelLabel: { id: 'app.settings.logs.export.modal.level', defaultMessage: 'Log level' },
levelAll: { id: 'app.settings.logs.export.modal.level.all', defaultMessage: 'All levels' },
levelDebug: {
id: 'app.settings.logs.export.modal.level.debug',
defaultMessage: 'Debug and above',
},
levelInfo: { id: 'app.settings.logs.export.modal.level.info', defaultMessage: 'Info and above' },
extrasLabel: {
id: 'app.settings.logs.export.modal.extras',
defaultMessage: 'Additional information',
},
systemInfo: {
id: 'app.settings.logs.export.modal.system-info',
defaultMessage: 'System and environment summary',
},
instanceLogs: {
id: 'app.settings.logs.export.modal.instance-logs',
defaultMessage: 'Most recent instance log',
},
crashAnalysis: {
id: 'app.settings.logs.export.modal.crash-analysis',
defaultMessage: 'Crash analysis (when a crash is detected)',
},
cancel: { id: 'app.settings.logs.export.modal.cancel', defaultMessage: 'Cancel' },
confirm: { id: 'app.settings.logs.export.modal.confirm', defaultMessage: 'Export' },
exportedTitle: {
id: 'app.settings.logs.export.notification-title',
defaultMessage: 'Logs exported',
},
exportedText: {
id: 'app.settings.logs.export.notification-text',
defaultMessage: 'The log archive was saved to {path}',
},
})
const rangeOptions = computed(() => [
{ value: 'last30Minutes', label: formatMessage(messages.rangeLast30Minutes) },
{ value: 'last2Hours', label: formatMessage(messages.rangeLast2Hours) },
{ value: 'all', label: formatMessage(messages.rangeAll) },
])
const levelOptions = computed(() => [
{ value: 'all', label: formatMessage(messages.levelAll) },
{ value: 'debug', label: formatMessage(messages.levelDebug) },
{ value: 'info', label: formatMessage(messages.levelInfo) },
])
async function exportLogs() {
exporting.value = true
try {
const path = await exportLauncherLogs({
range: selectedRange.value,
level: selectedLevel.value,
includeSystemInfo: includeSystemInfo.value,
includeInstanceLogs: includeInstanceLogs.value,
includeCrashAnalysis: includeCrashAnalysis.value,
})
if (path) {
modal.value?.hide()
popupNotificationManager.addPopupNotification({
title: formatMessage(messages.exportedTitle),
text: formatMessage(messages.exportedText, { path }),
type: 'success',
buttons: [
{
label: formatMessage(commonMessages.openInFolderButton),
icon: FolderOpenIcon,
action: () => highlightInFolder(path).catch(handleError),
},
],
})
}
} catch (error) {
handleError(error)
} finally {
exporting.value = false
}
}
defineExpose({
show: () => modal.value?.show(),
hide: () => modal.value?.hide(),
})
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
:closable="true"
:close-on-click-outside="true"
width="40rem"
scrollable
>
<div class="flex flex-col gap-4">
<p class="m-0">{{ formatMessage(messages.description) }}</p>
<div class="flex flex-col gap-2">
<span class="font-semibold text-[var(--color-text-primary)]">{{
formatMessage(messages.rangeLabel)
}}</span>
<Combobox
id="log-export-range"
v-model="selectedRange"
:name="formatMessage(messages.rangeLabel)"
:options="rangeOptions"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-[var(--color-text-primary)]">{{
formatMessage(messages.levelLabel)
}}</span>
<Combobox
id="log-export-level"
v-model="selectedLevel"
:name="formatMessage(messages.levelLabel)"
:options="levelOptions"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-[var(--color-text-primary)]">{{
formatMessage(messages.extrasLabel)
}}</span>
<Checkbox v-model="includeSystemInfo" :label="formatMessage(messages.systemInfo)" />
<Checkbox v-model="includeInstanceLogs" :label="formatMessage(messages.instanceLogs)" />
<Checkbox v-model="includeCrashAnalysis" :label="formatMessage(messages.crashAnalysis)" />
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" :disabled="exporting" @click="modal?.hide()"
><XIcon />
{{ formatMessage(messages.cancel) }}
</Button>
<Button type="colored" color="brand" :disabled="exporting" @click="exportLogs"
><DownloadIcon />
{{ formatMessage(messages.confirm) }}
</Button>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { FolderOpenIcon, UploadIcon } from '@modrinth/assets'
import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
import { showLauncherLogsFolder } from '@/helpers/utils'
import LogExportModal from './LogExportModal.vue'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const exportModal = ref<InstanceType<typeof LogExportModal>>()
const messages = defineMessages({
exportTitle: { id: 'app.settings.logs.export.title', defaultMessage: 'Export logs' },
exportDescription: {
id: 'app.settings.logs.export.description',
defaultMessage:
'Package launcher logs with an optional environment summary, instance log, and crash analysis.',
},
exportButton: { id: 'app.settings.logs.export.button', defaultMessage: 'Export logs…' },
openFolder: { id: 'app.settings.logs.open-folder', defaultMessage: 'Open logs folder' },
})
async function openLogsFolder() {
try {
await showLauncherLogsFolder()
} catch (error) {
handleError(error)
}
}
</script>
<template>
<div class="flex flex-col gap-6">
<SettingsSection
:title="formatMessage(messages.exportTitle)"
:description="formatMessage(messages.exportDescription)"
title-id="settings-target-logs-export"
>
<SettingsRow stacked>
<template #label>
<span>{{ formatMessage(messages.exportTitle) }}</span>
</template>
<template #description>{{ formatMessage(messages.exportDescription) }}</template>
<template #control>
<div class="flex flex-wrap gap-2">
<Button type="colored" color="brand" @click="exportModal?.show()"
><UploadIcon />
{{ formatMessage(messages.exportButton) }}
</Button>
<Button @click="openLogsFolder"
><FolderOpenIcon />
{{ formatMessage(messages.openFolder) }}
</Button>
</div>
</template>
</SettingsRow>
</SettingsSection>
<LogExportModal ref="exportModal" />
</div>
</template>
@@ -13,6 +13,7 @@ export type SettingsCategoryId =
| 'updates'
| 'about'
| 'feature-flags'
| 'logs'
export type SettingsGroupId = 'launcher' | 'game' | 'data-privacy' | 'support' | 'developer'
@@ -116,6 +117,11 @@ export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
name: { id: 'app.settings.tabs.about', defaultMessage: 'About' },
group: 'support',
},
{
id: 'logs',
name: { id: 'app.settings.tabs.logs', defaultMessage: 'Logs' },
group: 'support',
},
{
id: 'feature-flags',
name: {
@@ -3,6 +3,7 @@ import {
BotIcon,
CoffeeIcon,
CpuIcon,
FileTextIcon,
GameIcon,
GaugeIcon,
InfoIcon,
@@ -78,6 +79,10 @@ const categoryContent: Record<SettingsCategoryId, Pick<SettingsCategory, 'icon'
content: defineAsyncComponent(() => import('./UpdateSettings.vue')),
},
about: { icon: InfoIcon, content: defineAsyncComponent(() => import('./AboutSettings.vue')) },
logs: {
icon: FileTextIcon,
content: defineAsyncComponent(() => import('./LogsSettings.vue')),
},
'feature-flags': {
icon: ToggleRightIcon,
content: defineAsyncComponent(() => import('./FeatureFlagSettings.vue')),
@@ -384,6 +384,16 @@ export const settingsSearchEntries: SettingsSearchEntry[] = [
categoryId: 'feature-flags',
label: message('settings.feature-flags.title', 'Feature flags'),
},
{
id: 'logs-export',
categoryId: 'logs',
targetId: 'settings-target-logs-export',
label: message('app.settings.logs.export.title', 'Export logs'),
description: message(
'app.settings.logs.export.description',
'Package launcher logs with an optional environment summary, instance log, and crash analysis.',
),
},
]
export function validateSettingsSearchEntries(entries = settingsSearchEntries): string[] {
@@ -7,15 +7,22 @@ export const UNGROUPED_GROUP_KEY = '__ungrouped__'
export type GridGroupingOption = 'Group' | 'Loader' | 'Game version' | 'None'
export type GridSortOption =
| 'Name'
| 'Last played'
| 'Date created'
| 'Date modified'
| 'Game version'
'Name' | 'Last played' | 'Date created' | 'Date modified' | 'Game version'
export type GridSortDirection = 'asc' | 'desc'
export const defaultSortDirections: Record<GridSortOption, GridSortDirection> = {
Name: 'asc',
'Last played': 'desc',
'Date created': 'desc',
'Date modified': 'desc',
'Game version': 'asc',
}
export interface GridDisplayState {
group: GridGroupingOption
sortBy: GridSortOption
sortDirections: Partial<Record<GridSortOption, GridSortDirection>>
collapsedGroups: string[]
}
@@ -38,6 +45,7 @@ export function useGridGrouping<T extends Record<string, unknown>>(
{
group: 'Group',
sortBy: 'Name',
sortDirections: {},
collapsedGroups: [],
},
localStorage,
@@ -66,7 +74,7 @@ export function useGridGrouping<T extends Record<string, unknown>>(
state.value.collapsedGroups = [...collapsedSections]
}
function sortInstances(instances: T[], sortBy: GridSortOption): T[] {
function sortInstances(instances: T[], sortBy: GridSortOption, ascending: boolean): T[] {
const sorted = [...instances]
const getGameVersion = options.getGameVersion ?? ((i: T) => i.game_version ?? '')
const getLastPlayed = options.getLastPlayed ?? ((i: T) => i.last_played ?? 0)
@@ -83,16 +91,19 @@ export function useGridGrouping<T extends Record<string, unknown>>(
)
break
case 'Last played':
sorted.sort((a, b) => dayjs(getLastPlayed(b)).diff(dayjs(getLastPlayed(a))))
sorted.sort((a, b) => dayjs(getLastPlayed(a)).diff(dayjs(getLastPlayed(b))))
break
case 'Date created':
sorted.sort((a, b) => dayjs(getDateCreated(b)).diff(dayjs(getDateCreated(a))))
sorted.sort((a, b) => dayjs(getDateCreated(a)).diff(dayjs(getDateCreated(b))))
break
case 'Date modified':
sorted.sort((a, b) => dayjs(getDateModified(b)).diff(dayjs(getDateModified(a))))
sorted.sort((a, b) => dayjs(getDateModified(a)).diff(dayjs(getDateModified(b))))
break
}
if (!ascending) {
sorted.reverse()
}
return sorted
}
@@ -148,11 +159,25 @@ export function useGridGrouping<T extends Record<string, unknown>>(
function sortSections(
instanceMap: Map<string, T[]>,
group: GridGroupingOption,
sortBy: GridSortOption,
): Map<string, T[]> {
if (group !== 'Group' && group !== 'Game version') {
return instanceMap
}
if (group === 'Group' && sortBy !== 'Name') {
if (instanceMap.has(UNGROUPED_GROUP_KEY) && instanceMap.size > 1) {
const ungroupedInstances = instanceMap.get(UNGROUPED_GROUP_KEY)!
instanceMap.delete(UNGROUPED_GROUP_KEY)
const newMap = new Map<string, T[]>([[UNGROUPED_GROUP_KEY, ungroupedInstances]])
for (const [key, instances] of instanceMap) {
newMap.set(key, instances)
}
return newMap
}
return instanceMap
}
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
if (group === 'Group') {
if (a[0] === UNGROUPED_GROUP_KEY && b[0] !== UNGROUPED_GROUP_KEY) return -1
@@ -166,12 +191,30 @@ export function useGridGrouping<T extends Record<string, unknown>>(
return instanceMap
}
const sortDirection = computed<GridSortDirection>(() => {
return (
state.value.sortDirections[state.value.sortBy] ?? defaultSortDirections[state.value.sortBy]
)
})
const isSortAscending = computed(() => sortDirection.value === 'asc')
const toggleSortDirection = () => {
const current = sortDirection.value
const next: GridSortDirection = current === 'asc' ? 'desc' : 'asc'
state.value.sortDirections = {
...state.value.sortDirections,
[state.value.sortBy]: next,
}
}
const filteredResults = computed(() => {
const { group = 'Group', sortBy = 'Name' } = state.value
const ascending = isSortAscending.value
const sorted = sortInstances(instances.value, sortBy)
const sorted = sortInstances(instances.value, sortBy, ascending)
const grouped = groupInstances(sorted, group)
return sortSections(grouped, group)
return sortSections(grouped, group, sortBy)
})
return {
@@ -180,6 +223,9 @@ export function useGridGrouping<T extends Record<string, unknown>>(
filteredResults,
isSectionCollapsed,
setSectionCollapsed,
sortDirection,
isSortAscending,
toggleSortDirection,
UNGROUPED_GROUP_KEY,
}
}
}
@@ -0,0 +1,182 @@
import { computed, type Ref, ref, watch } from 'vue'
import { instance_groups_listener } from '@/helpers/events'
import {
create_group,
delete_group,
type InstanceGroupDefinition,
type InstanceGroupMembershipUpdate,
list_groups,
MAX_INSTANCE_GROUP_NAME_LENGTH,
rename_group,
set_group_memberships,
set_group_order,
} from '@/helpers/instance-groups'
export const FAVORITES_GROUP_ID = 'group:favorites'
export const UNGROUPED_GROUP_ID = 'group:none'
export { MAX_INSTANCE_GROUP_NAME_LENGTH }
export { type InstanceGroupDefinition, type InstanceGroupMembershipUpdate }
export interface InstanceGroupWithMembership {
key: string
name: string
instances: string[]
}
export function useInstanceGroups(instances: Ref<{ id: string; groups: string[] }[]>) {
const libraryGroups = ref<InstanceGroupDefinition[]>([])
const libraryGroupsLoaded = ref(false)
async function fetchGroups() {
libraryGroups.value = await list_groups()
libraryGroupsLoaded.value = true
}
fetchGroups()
instance_groups_listener(() => {
fetchGroups()
})
const orderedLibraryGroupIds = ref<string[]>([])
watch(
libraryGroups,
(groups) => {
orderedLibraryGroupIds.value = groups.map((g) => g.id)
},
{ immediate: true },
)
const instanceGroups = computed(() => {
if (!libraryGroupsLoaded.value) {
return []
}
const instanceGroupMap = new Map<string, string[]>()
for (const instance of instances.value) {
const groups = instance.groups ?? []
for (const groupId of groups) {
if (!instanceGroupMap.has(groupId)) {
instanceGroupMap.set(groupId, [])
}
instanceGroupMap.get(groupId)!.push(instance.id)
}
}
const allGroupIds = new Set<string>()
const groupMap = new Map<string, string[]>()
for (const groupId of orderedLibraryGroupIds.value) {
allGroupIds.add(groupId)
groupMap.set(groupId, instanceGroupMap.get(groupId) ?? [])
}
const ungroupedIds: string[] = []
for (const instance of instances.value) {
const instanceGroups = instance.groups ?? []
const isInAnyGroup = instanceGroups.some((g) => allGroupIds.has(g))
if (!isInAnyGroup) {
ungroupedIds.push(instance.id)
}
}
if (ungroupedIds.length > 0) {
groupMap.set(UNGROUPED_GROUP_ID, ungroupedIds)
}
return [...groupMap.entries()].map(([key, instances]) => ({
key,
name:
key === UNGROUPED_GROUP_ID
? 'Ungrouped'
: (libraryGroups.value.find((g) => g.id === key)?.name ?? key),
instances,
}))
})
const creatingGroup = ref(false)
const reorderingGroups = ref(false)
async function createNewGroup(name: string): Promise<string | null> {
creatingGroup.value = true
try {
const group = await create_group(name)
libraryGroups.value = [...libraryGroups.value, group]
orderedLibraryGroupIds.value = [...orderedLibraryGroupIds.value, group.id]
return group.id
} finally {
creatingGroup.value = false
}
}
async function renameGroupById(id: string, newName: string): Promise<boolean> {
if (id === FAVORITES_GROUP_ID) return false
const result = await rename_group(id, newName)
const idx = libraryGroups.value.findIndex((g) => g.id === id)
if (idx !== -1) {
libraryGroups.value = [
...libraryGroups.value.slice(0, idx),
result,
...libraryGroups.value.slice(idx + 1),
]
}
return true
}
async function deleteGroupById(id: string): Promise<boolean> {
if (id === FAVORITES_GROUP_ID) return false
await delete_group(id)
libraryGroups.value = libraryGroups.value.filter((g) => g.id !== id)
orderedLibraryGroupIds.value = orderedLibraryGroupIds.value.filter((gid) => gid !== id)
return true
}
async function reorderGroups(groupIds: string[]): Promise<boolean> {
reorderingGroups.value = true
try {
await set_group_order(groupIds)
// Favorites is rendered separately from draggable sections. Keep it in
// the local order after a drag even though the backend accepts only the
// reorderable group ids; otherwise it appears again as an ordinary
// section until the next full group refresh.
orderedLibraryGroupIds.value = [
FAVORITES_GROUP_ID,
...groupIds.filter((id) => id !== FAVORITES_GROUP_ID),
]
return true
} finally {
reorderingGroups.value = false
}
}
async function setMemberships(updates: InstanceGroupMembershipUpdate[]): Promise<boolean> {
await set_group_memberships(updates)
return true
}
function groupNameById(id: string): string | undefined {
if (id === UNGROUPED_GROUP_ID) return 'Ungrouped'
return libraryGroups.value.find((g) => g.id === id)?.name
}
return {
libraryGroups,
libraryGroupsLoaded,
orderedLibraryGroupIds,
instanceGroups,
creatingGroup,
reorderingGroups,
createGroup: createNewGroup,
renameGroupById,
deleteGroupById,
reorderGroups,
setMemberships,
groupNameById,
}
}
@@ -0,0 +1,75 @@
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('[Starlight telemetry disabled]', eventName, args[0])
}
}
+10
View File
@@ -82,6 +82,16 @@ export async function instance_bulk_update_progress_listener(callback) {
return await listen('instance_bulk_update_progress', (event) => callback(event.payload))
}
/// Payload for the 'instance_groups_changed' event
/*
InstanceGroupsChangedPayload {
instance_ids: string[]
}
*/
export async function instance_groups_listener(callback) {
return await listen('instance_groups_changed', (event) => callback(event.payload))
}
export async function install_job_listener(callback) {
return await listen('install_job', (event) => callback(event.payload))
}
@@ -1 +1,46 @@
export const MAX_INSTANCE_GROUP_NAME_LENGTH = 256
import { invoke } from '@tauri-apps/api/core'
export const MAX_INSTANCE_GROUP_NAME_LENGTH = 256
export type InstanceGroupDefinition = {
id: string
name: string
}
export type InstanceGroupMembershipUpdate = {
instance_id: string
group_ids: string[]
}
export async function list_groups(): Promise<InstanceGroupDefinition[]> {
return await invoke('plugin:instance|instance_list_groups')
}
export async function create_group(name: string): Promise<InstanceGroupDefinition> {
return await invoke('plugin:instance|instance_create_group', { name })
}
export async function rename_group(id: string, newName: string): Promise<InstanceGroupDefinition> {
return await invoke('plugin:instance|instance_rename_group', {
id,
newName,
})
}
export async function delete_group(id: string): Promise<void> {
return await invoke('plugin:instance|instance_delete_group', { id })
}
export async function set_group_order(group_ids: string[]): Promise<void> {
return await invoke('plugin:instance|instance_set_group_order', {
groupIds: group_ids,
})
}
export async function set_group_memberships(
updates: InstanceGroupMembershipUpdate[],
): Promise<void> {
return await invoke('plugin:instance|instance_set_group_memberships', {
updates,
})
}
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { mapBroadcastAnnouncements } from './remote-announcements.ts'
test('maps a broadcast entry into a remote announcement', () => {
const result = mapBroadcastAnnouncements([
{
id: 1,
title: '维护通知',
subtitle: '今晚停机',
content: '服务器将于今晚维护。',
updateAt: 1791640873710,
star: true,
importantLevel: 9,
source: 'SKIN',
},
])
assert.equal(result?.length, 1)
const item = result?.[0]
assert.equal(item?.id, '1')
assert.equal(item?.title, '维护通知')
assert.equal(item?.summary, '今晚停机')
assert.equal(item?.priority, 'critical')
assert.equal(item?.pinned, true)
assert.equal(item?.type, 'modal')
assert.equal(item?.published_at, new Date(1791640873710).toISOString())
assert.equal(item?.ends_at, null)
})
test('empty or blank subtitle becomes null summary', () => {
const result = mapBroadcastAnnouncements([
{ id: 2, title: '公告', subtitle: ' ', content: '内容', updateAt: 1000 },
])
assert.equal(result?.[0]?.summary, null)
})
test('skips entries missing required fields', () => {
const result = mapBroadcastAnnouncements([
{ id: 3, title: '有效', content: '内容', updateAt: 1000 },
{ id: 4, title: '', content: '内容', updateAt: 1000 },
{ title: '无 id', content: '内容', updateAt: 1000 },
{ id: 5, title: '无时间', content: '内容' },
null,
'不是对象',
])
assert.equal(result?.length, 1)
assert.equal(result?.[0]?.id, '3')
})
test('clamps importantLevel and maps priority boundaries', () => {
const result = mapBroadcastAnnouncements([
{ id: 6, title: 'a', content: 'x', updateAt: 1000, importantLevel: 0 },
{ id: 7, title: 'b', content: 'x', updateAt: 1000, importantLevel: 3 },
{ id: 8, title: 'c', content: 'x', updateAt: 1000, importantLevel: 6 },
{ id: 9, title: 'd', content: 'x', updateAt: 1000, importantLevel: 999 },
])
const byId = new Map(result?.map((item) => [item.id, item.priority]))
assert.equal(byId.get('6'), 'low')
assert.equal(byId.get('7'), 'normal')
assert.equal(byId.get('8'), 'high')
assert.equal(byId.get('9'), 'critical')
})
test('returns null for non-array payloads', () => {
assert.equal(mapBroadcastAnnouncements(null), null)
assert.equal(mapBroadcastAnnouncements({ payload: [] }), null)
assert.equal(mapBroadcastAnnouncements('nope'), null)
})
test('pinned announcements sort first', () => {
const result = mapBroadcastAnnouncements([
{ id: 10, title: '普通', content: 'x', updateAt: 2000, importantLevel: 9 },
{ id: 11, title: '置顶', content: 'x', updateAt: 1000, star: true },
])
assert.equal(result?.[0]?.id, '11')
assert.equal(result?.[1]?.id, '10')
})
@@ -5,6 +5,7 @@ export type RemoteAnnouncement = {
content: string
type: 'modal' | 'notification'
priority: 'low' | 'normal' | 'high' | 'critical'
pinned?: boolean
starts_at: string
ends_at: string | null
published_at: string
@@ -32,18 +33,28 @@ export function parseAnnouncements(value: unknown): RemoteAnnouncement[] | null
if (!entry || typeof entry !== 'object') continue
const item = entry as Record<string, unknown>
if (
typeof item.id !== 'string' || item.id.length > 100 || ids.has(item.id) ||
typeof item.title !== 'string' || !item.title.trim() || item.title.length > 120 ||
typeof item.content !== 'string' || item.content.length > 20000 ||
typeof item.id !== 'string' ||
item.id.length > 100 ||
ids.has(item.id) ||
typeof item.title !== 'string' ||
!item.title.trim() ||
item.title.length > 120 ||
typeof item.content !== 'string' ||
item.content.length > 20000 ||
(item.summary != null && (typeof item.summary !== 'string' || item.summary.length > 300)) ||
!['modal', 'notification'].includes(String(item.type)) ||
!['low', 'normal', 'high', 'critical'].includes(String(item.priority)) ||
typeof item.starts_at !== 'string' || !Number.isFinite(Date.parse(item.starts_at)) ||
typeof item.published_at !== 'string' || !Number.isFinite(Date.parse(item.published_at)) ||
(item.ends_at != null && (typeof item.ends_at !== 'string' || !Number.isFinite(Date.parse(item.ends_at)))) ||
(item.action_label != null && (typeof item.action_label !== 'string' || item.action_label.length > 80)) ||
typeof item.starts_at !== 'string' ||
!Number.isFinite(Date.parse(item.starts_at)) ||
typeof item.published_at !== 'string' ||
!Number.isFinite(Date.parse(item.published_at)) ||
(item.ends_at != null &&
(typeof item.ends_at !== 'string' || !Number.isFinite(Date.parse(item.ends_at)))) ||
(item.action_label != null &&
(typeof item.action_label !== 'string' || item.action_label.length > 80)) ||
(item.action_url != null && !safeAnnouncementUrl(item.action_url))
) continue
)
continue
ids.add(item.id)
items.push({
...item,
@@ -54,7 +65,12 @@ export function parseAnnouncements(value: unknown): RemoteAnnouncement[] | null
} as RemoteAnnouncement)
}
const rank = { low: 0, normal: 1, high: 2, critical: 3 }
return items.sort((first, second) => rank[second.priority] - rank[first.priority] || Date.parse(second.published_at) - Date.parse(first.published_at))
return items.sort(
(first, second) =>
Number(second.pinned ?? false) - Number(first.pinned ?? false) ||
rank[second.priority] - rank[first.priority] ||
Date.parse(second.published_at) - Date.parse(first.published_at),
)
}
export function isAnnouncementActive(item: RemoteAnnouncement, now = Date.now()) {
@@ -64,3 +80,76 @@ export function isAnnouncementActive(item: RemoteAnnouncement, now = Date.now())
export function announcementKey(item: RemoteAnnouncement) {
return item.id + ':' + item.published_at
}
export const REMOTE_ANNOUNCEMENTS_UPDATED_EVENT = 'starlight:remote-announcements-updated'
export const OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT = 'starlight:open-remote-announcement-center'
type BroadcastAnnouncementEntry = {
title?: unknown
subtitle?: unknown
content?: unknown
updateAt?: unknown
star?: unknown
importantLevel?: unknown
id?: unknown
}
function broadcastPriority(level: number): RemoteAnnouncement['priority'] {
if (level >= 9) return 'critical'
if (level >= 6) return 'high'
if (level >= 3) return 'normal'
return 'low'
}
export function mapBroadcastAnnouncements(payload: unknown): RemoteAnnouncement[] | null {
if (!Array.isArray(payload) || payload.length > 200) return null
const items: RemoteAnnouncement[] = []
const seen = new Set<string>()
for (const entry of payload) {
if (!entry || typeof entry !== 'object') continue
const item = entry as BroadcastAnnouncementEntry
if (
typeof item.id !== 'number' ||
!Number.isFinite(item.id) ||
typeof item.title !== 'string' ||
!item.title.trim() ||
item.title.length > 120 ||
typeof item.content !== 'string' ||
item.content.length > 20000 ||
typeof item.updateAt !== 'number' ||
!Number.isFinite(item.updateAt) ||
(item.subtitle != null && typeof item.subtitle !== 'string')
)
continue
const id = String(item.id)
if (seen.has(id)) continue
seen.add(id)
const level =
typeof item.importantLevel === 'number' && Number.isFinite(item.importantLevel)
? Math.max(0, Math.min(10, Math.floor(item.importantLevel)))
: 0
const starred = item.star === true
const published = new Date(item.updateAt).toISOString()
const summary = typeof item.subtitle === 'string' && item.subtitle.trim() ? item.subtitle : null
items.push({
id,
title: item.title,
summary,
content: item.content,
type: starred || level >= 8 ? 'modal' : 'notification',
priority: broadcastPriority(level),
pinned: starred,
starts_at: published,
ends_at: null,
published_at: published,
action_url: null,
action_label: null,
})
}
const rank = { low: 0, normal: 1, high: 2, critical: 3 }
return items.sort(
(first, second) =>
Number(second.pinned ?? false) - Number(first.pinned ?? false) ||
rank[second.priority] - rank[first.priority] ||
Date.parse(second.published_at) - Date.parse(first.published_at),
)
}
+3
View File
@@ -55,6 +55,7 @@ export type GameInstance = {
force_fullscreen?: boolean
maximize_window?: boolean
game_resolution?: [number, number]
window_title?: string | null
launch_preparation_timeout?: number | null
hooks: Hooks
}
@@ -239,6 +240,8 @@ type AppSettings = {
force_fullscreen: boolean
maximize_window: boolean
game_resolution: [number, number]
custom_window_title_enabled: boolean
default_window_title: string
hide_on_process_start: boolean
enter_lightweight_mode_on_game_launch: boolean
auto_set_java_high_performance_mode: boolean
+32
View File
@@ -81,6 +81,38 @@ export async function exportErrorLogs(
return await invoke('plugin:utils|export_error_logs', { outputPath, errorMessage })
}
export async function exportLauncherLogs({
range = 'last2Hours',
level = 'all',
includeSystemInfo = true,
includeInstanceLogs = false,
includeCrashAnalysis = false,
fileNamePrefix = 'Starlight Launcher logs',
} = {}) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const outputPath = await save({
defaultPath: `${fileNamePrefix} ${timestamp}.zip`,
filters: [
{
name: 'ZIP archive',
extensions: ['zip'],
},
],
})
if (!outputPath) return null
await invoke('plugin:utils|export_launcher_logs', {
outputPath,
range,
level,
includeSystemInfo,
includeInstanceLogs,
includeCrashAnalysis,
})
return outputPath
}
export async function createInstanceShortcut(instanceName, instanceId, options = {}) {
const outputPath = await save({
defaultPath: `Modrinth - ${instanceName}`,
+170 -2
View File
@@ -11431,5 +11431,173 @@
},
"app.anniversary.banner": {
"message": "Happy {ordinal} Anniversary, Starlight!"
}
}
},
"instance.settings.tabs.window.window-title": {
"message": "Window title"
},
"instance.settings.tabs.window.window-title.description": {
"message": "Customize the Minecraft window title. Leave empty to keep the default."
},
"instance.settings.tabs.window.window-title.enter": {
"message": "Enter window title..."
},
"app.settings.defaults.window-title.enabled": {
"message": "Custom Minecraft window title"
},
"app.settings.defaults.window-title.enabled-description": {
"message": "Allow instances to customize the Minecraft window title."
},
"app.settings.defaults.window-title.default": {
"message": "Default window title"
},
"app.settings.defaults.window-title.default-description": {
"message": "Used when an instance does not provide its own title."
},
"app.settings.defaults.window-title.default-placeholder": {
"message": "Enter the default window title..."
},
"app.action-bar.announcements": {
"message": "Announcements"
},
"app.remote-announcements.center-title": {
"message": "Announcements"
},
"app.remote-announcements.empty": {
"message": "No announcements"
},
"app.remote-announcements.view": {
"message": "View announcement"
},
"app.remote-announcements.unread": {
"message": "Unread"
},
"app.remote-announcements.read-all": {
"message": "Mark all announcements as read"
},
"app.remote-announcements.preview-title": {
"message": "Announcement style preview"
},
"app.remote-announcements.preview-summary": {
"message": "This is a local preview notification. Select View announcement to see the full Markdown content."
},
"app.remote-announcements.preview-content": {
"message": "## Announcement preview\n\nThis is **sample content**, not a published announcement.\n\n- Supports headings, lists, and links\n- Close buttons are always available\n\n> Previewing does not change real announcement read status.\n\n| Type | Display |\n| --- | --- |\n| Modal | Full Markdown content |\n| Popup | Summary, then full content |\n\n[Visit the website](https://skin.starlight.cool/)"
},
"app.remote-announcements.preview-action": {
"message": "Visit website"
},
"app.settings.tabs.logs": {
"message": "Logs & diagnostics"
},
"app.settings.logs.export.title": {
"message": "Export logs"
},
"app.settings.logs.export.description": {
"message": "Package launcher logs with an optional environment summary, instance log, and crash analysis."
},
"app.settings.logs.export.button": {
"message": "Export logs…"
},
"app.settings.logs.open-folder": {
"message": "Open logs folder"
},
"app.settings.logs.export.modal.title": {
"message": "Export logs"
},
"app.settings.logs.export.modal.description": {
"message": "Choose how much detail to include. Exported logs have access tokens, Minecraft tokens, and IP addresses replaced with placeholders."
},
"app.settings.logs.export.modal.range": {
"message": "Time range"
},
"app.settings.logs.export.modal.range.30-minutes": {
"message": "Last 30 minutes"
},
"app.settings.logs.export.modal.range.2-hours": {
"message": "Last 2 hours"
},
"app.settings.logs.export.modal.range.all": {
"message": "All sessions"
},
"app.settings.logs.export.modal.level": {
"message": "Log level"
},
"app.settings.logs.export.modal.level.all": {
"message": "All levels"
},
"app.settings.logs.export.modal.level.debug": {
"message": "Debug and above"
},
"app.settings.logs.export.modal.level.info": {
"message": "Info and above"
},
"app.settings.logs.export.modal.extras": {
"message": "Additional information"
},
"app.settings.logs.export.modal.system-info": {
"message": "System and environment summary"
},
"app.settings.logs.export.modal.instance-logs": {
"message": "Most recent instance log"
},
"app.settings.logs.export.modal.crash-analysis": {
"message": "Crash analysis (when a crash is detected)"
},
"app.settings.logs.export.modal.cancel": {
"message": "Cancel"
},
"app.settings.logs.export.modal.confirm": {
"message": "Export"
},
"app.settings.logs.export.notification-title": {
"message": "Logs exported"
},
"app.settings.logs.export.notification-text": {
"message": "The log archive was saved to {path}"
},
"app.skins.armor.title": {"message": "Armour and trims"},
"app.skins.armor.save": {"message": "Save"},
"app.skins.armor.close": {"message": "Close"},
"app.skins.armor.description": {"message": "Try armour and trims on your skin. Nothing is stored until you save it."},
"app.skins.armor.unsaved": {"message": "Unsaved changes"},
"app.skins.armor.reset-to-saved": {"message": "Reset to saved"},
"app.skins.armor.restore-defaults": {"message": "Restore defaults"},
"app.instances.batch-edit-groups.add": {"message": "Add"},
"app.instances.batch-edit-groups.add-to-group-title": {"message": "Add instances to \"{groupName}\""},
"app.instances.batch-edit-groups.added": {"message": "Added"},
"app.instances.batch-edit-groups.cancel": {"message": "Cancel"},
"app.instances.batch-edit-groups.create": {"message": "Create group"},
"app.instances.batch-edit-groups.group-name": {"message": "Group name"},
"app.instances.batch-edit-groups.group-name-placeholder": {"message": "Enter group name"},
"app.instances.batch-edit-groups.no-instances-found": {"message": "No instances found"},
"app.instances.batch-edit-groups.save-changes": {"message": "Save"},
"app.instances.batch-edit-groups.search-instance": {"message": "Search instances"},
"app.instances.group.add-to-group": {"message": "Add to group"},
"app.instances.group.collapse": {"message": "Collapse group"},
"app.instances.group.delete-description": {"message": "Instances in this group will be ungrouped."},
"app.instances.group.delete-group": {"message": "Delete group"},
"app.instances.group.edit-name": {"message": "Edit group name"},
"app.instances.group.empty": {"message": "Drag and drop to add instances."},
"app.instances.group.expand": {"message": "Expand group"},
"app.instances.group.move-down": {"message": "Move group down"},
"app.instances.group.move-up": {"message": "Move group up"},
"app.instances.group.name-empty": {"message": "Group names cannot be empty."},
"app.instances.group.name-too-long": {"message": "Group names cannot be longer than {maxLength} characters."},
"app.instances.group.new-instance": {"message": "New instance"},
"app.instances.group.pinned": {"message": "Pinned"},
"app.instances.group.rename-group": {"message": "Rename group"},
"app.instances.sort-by": {"message": "Sort by"},
"app.instances.sort.direction.asc-alphabetical": {"message": "A–Z"},
"app.instances.sort.direction.asc-date": {"message": "Oldest first"},
"app.instances.sort.direction.asc-recency": {"message": "Least recent first"},
"app.instances.sort.direction.asc-version": {"message": "Oldest version first"},
"app.instances.sort.direction.desc-alphabetical": {"message": "Z–A"},
"app.instances.sort.direction.desc-date": {"message": "Newest first"},
"app.instances.sort.direction.desc-recency": {"message": "Most recent first"},
"app.instances.sort.direction.desc-version": {"message": "Newest version first"},
"app.library.context-menu.pin-instance": {"message": "Pin instance"},
"app.library.context-menu.remove-from-group": {"message": "Remove from group"},
"app.library.context-menu.unpin-instance": {"message": "Unpin instance"},
"app.library.group.new": {"message": "New group"},
"app.library.selection.new-group": {"message": "New group"},
"app.library.selection.remove-from-group": {"message": "Remove from group"}}
+81 -2
View File
@@ -11351,5 +11351,84 @@
"app.settings.synced-options.game-settings.value-off": { "message": "关闭" },
"app.settings.synced-options.game-settings.value-on": { "message": "开启" },
"app.settings.synced-options.game-settings.value-unset": { "message": "选择一个值" },
"app.anniversary.banner": { "message": "Starlight {ordinal} 周年快乐!" }
}
"app.anniversary.banner": { "message": "Starlight {ordinal} 周年快乐!" },
"instance.settings.tabs.window.window-title": { "message": "窗口标题" },
"instance.settings.tabs.window.window-title.description": { "message": "自定义 Minecraft 窗口标题。留空则使用默认标题。" },
"instance.settings.tabs.window.window-title.enter": { "message": "输入窗口标题..." },
"app.settings.defaults.window-title.enabled": { "message": "自定义 Minecraft 窗口标题" },
"app.settings.defaults.window-title.enabled-description": { "message": "允许实例自定义 Minecraft 窗口标题。" },
"app.settings.defaults.window-title.default": { "message": "默认窗口标题" },
"app.settings.defaults.window-title.default-description": { "message": "当实例未提供自己的标题时使用。" },
"app.settings.defaults.window-title.default-placeholder": { "message": "输入默认窗口标题..." },
"app.action-bar.announcements": { "message": "公告" },
"app.remote-announcements.center-title": { "message": "公告" },
"app.remote-announcements.empty": { "message": "暂无公告" },
"app.settings.tabs.logs": { "message": "日志与诊断" },
"app.settings.logs.export.title": { "message": "导出日志" },
"app.settings.logs.export.description": { "message": "将启动器日志打包,可附带环境摘要、最近的实例日志与崩溃分析。" },
"app.settings.logs.export.button": { "message": "导出日志…" },
"app.settings.logs.open-folder": { "message": "打开日志文件夹" },
"app.settings.logs.export.modal.title": { "message": "导出日志" },
"app.settings.logs.export.modal.description": { "message": "选择要包含的详细程度。导出的日志会把访问令牌、Minecraft 令牌与 IP 地址替换为占位符。" },
"app.settings.logs.export.modal.range": { "message": "时间范围" },
"app.settings.logs.export.modal.range.30-minutes": { "message": "最近 30 分钟" },
"app.settings.logs.export.modal.range.2-hours": { "message": "最近 2 小时" },
"app.settings.logs.export.modal.range.all": { "message": "全部会话" },
"app.settings.logs.export.modal.level": { "message": "日志等级" },
"app.settings.logs.export.modal.level.all": { "message": "全部等级" },
"app.settings.logs.export.modal.level.debug": { "message": "DEBUG 及以上" },
"app.settings.logs.export.modal.level.info": { "message": "INFO 及以上" },
"app.settings.logs.export.modal.extras": { "message": "附加信息" },
"app.settings.logs.export.modal.system-info": { "message": "系统与环境摘要" },
"app.settings.logs.export.modal.instance-logs": { "message": "最近的实例日志" },
"app.settings.logs.export.modal.crash-analysis": { "message": "崩溃分析(检测到崩溃时)" },
"app.settings.logs.export.modal.cancel": { "message": "取消" },
"app.settings.logs.export.modal.confirm": { "message": "导出" },
"app.settings.logs.export.notification-title": { "message": "日志已导出" },
"app.settings.logs.export.notification-text": { "message": "日志压缩包已保存到 {path}" },
"app.skins.armor.title": {"message": "盔甲与纹饰"},
"app.skins.armor.save": {"message": "保存"},
"app.skins.armor.close": {"message": "关闭"},
"app.skins.armor.description": {"message": "在皮肤上试穿盔甲与纹饰。未保存前不会写入任何内容。"},
"app.skins.armor.unsaved": {"message": "有未保存的更改"},
"app.skins.armor.reset-to-saved": {"message": "重置为已保存"},
"app.skins.armor.restore-defaults": {"message": "恢复默认"},
"app.instances.batch-edit-groups.add": {"message": "添加"},
"app.instances.batch-edit-groups.add-to-group-title": {"message": "添加实例到「{groupName}」"},
"app.instances.batch-edit-groups.added": {"message": "已添加"},
"app.instances.batch-edit-groups.cancel": {"message": "取消"},
"app.instances.batch-edit-groups.create": {"message": "创建分组"},
"app.instances.batch-edit-groups.group-name": {"message": "分组名称"},
"app.instances.batch-edit-groups.group-name-placeholder": {"message": "输入分组名称"},
"app.instances.batch-edit-groups.no-instances-found": {"message": "未找到实例"},
"app.instances.batch-edit-groups.save-changes": {"message": "保存"},
"app.instances.batch-edit-groups.search-instance": {"message": "搜索实例"},
"app.instances.group.add-to-group": {"message": "添加到分组"},
"app.instances.group.collapse": {"message": "折叠分组"},
"app.instances.group.delete-description": {"message": "此分组中的实例将被取消分组。"},
"app.instances.group.delete-group": {"message": "删除分组"},
"app.instances.group.edit-name": {"message": "编辑分组名称"},
"app.instances.group.empty": {"message": "拖放实例到此处添加。"},
"app.instances.group.expand": {"message": "展开分组"},
"app.instances.group.move-down": {"message": "下移分组"},
"app.instances.group.move-up": {"message": "上移分组"},
"app.instances.group.name-empty": {"message": "分组名称不能为空。"},
"app.instances.group.name-too-long": {"message": "分组名称不能超过 {maxLength} 个字符。"},
"app.instances.group.new-instance": {"message": "新建实例"},
"app.instances.group.pinned": {"message": "已置顶"},
"app.instances.group.rename-group": {"message": "重命名分组"},
"app.instances.sort-by": {"message": "排序方式"},
"app.instances.sort.direction.asc-alphabetical": {"message": "A–Z 正序"},
"app.instances.sort.direction.asc-date": {"message": "最早的在前"},
"app.instances.sort.direction.asc-recency": {"message": "最早游玩在前"},
"app.instances.sort.direction.asc-version": {"message": "旧版本在前"},
"app.instances.sort.direction.desc-alphabetical": {"message": "Z–A 倒序"},
"app.instances.sort.direction.desc-date": {"message": "最新的在前"},
"app.instances.sort.direction.desc-recency": {"message": "最近游玩在前"},
"app.instances.sort.direction.desc-version": {"message": "新版本在前"},
"app.library.context-menu.pin-instance": {"message": "置顶实例"},
"app.library.context-menu.remove-from-group": {"message": "移出分组"},
"app.library.context-menu.unpin-instance": {"message": "取消置顶"},
"app.library.group.new": {"message": "新建分组"},
"app.library.selection.new-group": {"message": "新建分组"},
"app.library.selection.remove-from-group": {"message": "移出分组"}}
+7
View File
@@ -534,6 +534,12 @@ fn main() {
"instance_delete_screenshot_group",
"instance_set_screenshot_group_memberships",
"instance_import_screenshot_groups",
"instance_list_groups",
"instance_create_group",
"instance_rename_group",
"instance_delete_group",
"instance_set_group_order",
"instance_set_group_memberships",
"instance_delete_screenshots",
"instance_export_screenshots",
"instance_move_screenshots",
@@ -694,6 +700,7 @@ fn main() {
"open_path",
"show_launcher_logs_folder",
"export_error_logs",
"export_launcher_logs",
"show_app_db_backups_folder",
"progress_bars_list",
"get_opening_command",
+53 -1
View File
@@ -131,6 +131,12 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
instance_delete_screenshot_group,
instance_set_screenshot_group_memberships,
instance_import_screenshot_groups,
instance_list_groups,
instance_create_group,
instance_rename_group,
instance_delete_group,
instance_set_group_order,
instance_set_group_memberships,
instance_delete_screenshots,
instance_export_screenshots,
instance_move_screenshots,
@@ -333,6 +339,12 @@ pub struct EditInstance {
)]
pub launch_preparation_timeout: Option<Option<u64>>,
pub hooks: Option<Hooks>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub window_title: Option<Option<String>>,
#[serde(
default,
@@ -531,7 +543,8 @@ fn edit_to_core(edit_instance: EditInstance) -> Result<CoreEditInstance> {
|| edit_instance.maximize_window.is_some()
|| edit_instance.game_resolution.is_some()
|| edit_instance.launch_preparation_timeout.is_some()
|| edit_instance.hooks.is_some();
|| edit_instance.hooks.is_some()
|| edit_instance.window_title.is_some();
if has_launch_change {
Some(InstanceLaunchOverridesPatch {
player: None,
@@ -546,6 +559,7 @@ fn edit_to_core(edit_instance: EditInstance) -> Result<CoreEditInstance> {
launch_preparation_timeout: edit_instance
.launch_preparation_timeout,
hooks: edit_instance.hooks,
window_title: edit_instance.window_title,
})
} else {
None
@@ -1709,6 +1723,44 @@ pub async fn instance_import_screenshot_groups(
Ok(theseus::instance::import_screenshot_groups(groups).await?)
}
#[tauri::command]
pub async fn instance_list_groups()
-> Result<Vec<theseus::instance::InstanceGroup>> {
Ok(theseus::instance::list_groups().await?)
}
#[tauri::command]
pub async fn instance_create_group(
name: String,
) -> Result<theseus::instance::InstanceGroup> {
Ok(theseus::instance::create_group(name).await?)
}
#[tauri::command]
pub async fn instance_rename_group(
id: String,
new_name: String,
) -> Result<theseus::instance::InstanceGroup> {
Ok(theseus::instance::rename_group(id, new_name).await?)
}
#[tauri::command]
pub async fn instance_delete_group(id: String) -> Result<()> {
Ok(theseus::instance::delete_group(id).await?)
}
#[tauri::command]
pub async fn instance_set_group_order(group_ids: Vec<String>) -> Result<()> {
Ok(theseus::instance::set_group_order(group_ids).await?)
}
#[tauri::command]
pub async fn instance_set_group_memberships(
updates: Vec<theseus::instance::InstanceGroupMembershipUpdate>,
) -> Result<()> {
Ok(theseus::instance::set_group_memberships(updates).await?)
}
#[tauri::command]
pub async fn instance_delete_screenshots(
keys: Vec<theseus::instance::ScreenshotKey>,
+307
View File
@@ -25,6 +25,7 @@ pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
open_path,
show_launcher_logs_folder,
export_error_logs,
export_launcher_logs,
show_app_db_backups_folder,
progress_bars_list,
get_opening_command,
@@ -164,6 +165,312 @@ pub async fn export_error_logs(
Ok(())
}
/// Time window included in a launcher log export.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LogExportRange {
Last30Minutes,
Last2Hours,
All,
}
impl LogExportRange {
fn max_age(self) -> Option<std::time::Duration> {
match self {
Self::Last30Minutes => {
Some(std::time::Duration::from_secs(30 * 60))
}
Self::Last2Hours => {
Some(std::time::Duration::from_secs(2 * 60 * 60))
}
Self::All => None,
}
}
fn label(self) -> &'static str {
match self {
Self::Last30Minutes => "last 30 minutes",
Self::Last2Hours => "last 2 hours",
Self::All => "all sessions",
}
}
}
/// Lowest level included in a launcher log export.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LogExportLevel {
All,
Debug,
Info,
}
impl LogExportLevel {
fn minimum(self) -> Option<&'static str> {
match self {
Self::All => None,
Self::Debug => Some("debug"),
Self::Info => Some("info"),
}
}
fn label(self) -> &'static str {
match self {
Self::All => "all levels",
Self::Debug => "debug and above",
Self::Info => "info and above",
}
}
}
/// Instance log tail included in an export, in bytes.
const LOG_EXPORT_INSTANCE_LOG_TAIL_BYTES: u64 = 512 * 1024;
/// Export launcher logs (filtered by time range and level) plus optional
/// system information, the most recent instance log, and a crash analysis
/// summary into a ZIP archive.
#[tauri::command]
pub async fn export_launcher_logs(
output_path: PathBuf,
range: LogExportRange,
level: LogExportLevel,
include_system_info: bool,
include_instance_logs: bool,
include_crash_analysis: bool,
) -> Result<()> {
// Censoring needs the app state (credentials, IPs). Without it an export
// would silently ship secrets, so fail rather than degrade.
let state = theseus::State::get().await?;
let archive = tokio::fs::File::create(&output_path).await?;
let mut writer = ZipFileWriter::with_tokio(archive);
let (exported_logs, skipped_logs) =
write_exported_logs(&mut writer, range, level, &state).await?;
let mut manifest = format!(
"Starlight Launcher log export\n\
Exported at: {}\n\
App version: {}\n\
Time range: {}\n\
Level filter: {}\n\
System information: {}\n\
Recent instance log: {}\n\
Crash analysis: {}\n\
Log files: {exported_logs}\n\n\
Access tokens, Minecraft tokens, and IP addresses are replaced with placeholders.\n",
chrono::Local::now().to_rfc3339(),
env!("CARGO_PKG_VERSION"),
range.label(),
level.label(),
if include_system_info {
"included"
} else {
"omitted"
},
if include_instance_logs {
"included"
} else {
"omitted"
},
if include_crash_analysis {
"included"
} else {
"omitted"
},
);
if !skipped_logs.is_empty() {
manifest.push_str("\nSkipped unreadable log files:\n");
for name in &skipped_logs {
manifest.push_str(&format!("- {name}\n"));
}
}
write_zip_entry(&mut writer, "manifest.txt", manifest.as_bytes()).await?;
if include_system_info {
let environment = build_environment_report();
let environment = censor_export_text(environment, &state).await?;
write_zip_entry(
&mut writer,
"system-information.txt",
environment.as_bytes(),
)
.await?;
}
let recent_instance = if include_instance_logs || include_crash_analysis {
newest_instance_log(&state).await
} else {
None
};
if include_instance_logs
&& let Some((instance_id, path)) = recent_instance.as_ref()
{
let tail = read_file_tail(path, LOG_EXPORT_INSTANCE_LOG_TAIL_BYTES)?;
let tail = censor_export_text(tail, &state).await?;
write_zip_entry(
&mut writer,
&format!("minecraft/{instance_id}/latest.log"),
tail.as_bytes(),
)
.await?;
}
if include_crash_analysis
&& let Some((instance_id, _)) = recent_instance.as_ref()
&& let Ok(analysis) = theseus::logs::analyze_crash(instance_id).await
&& (analysis.crashed || !analysis.findings.is_empty())
&& let Ok(report) = serde_json::to_vec_pretty(&analysis)
{
// The analysis carries raw log text and Windows event messages, so it
// goes through the same censoring as the exported logs.
let report = censor_export_text(
String::from_utf8_lossy(&report).into_owned(),
&state,
)
.await?;
write_zip_entry(
&mut writer,
&format!("minecraft/{instance_id}/crash-analysis.json"),
report.as_bytes(),
)
.await?;
}
writer.close().await.map_err(zip_error)?;
Ok(())
}
/// Writes the matching launcher logs and reports how many were written plus
/// the names of any files that could not be read.
async fn write_exported_logs(
writer: &mut ZipFileWriter<tokio::fs::File>,
range: LogExportRange,
level: LogExportLevel,
state: &theseus::State,
) -> Result<(usize, Vec<String>)> {
let Some(directories) = DirectoryInfo::global_handle_if_ready() else {
return Ok((0, Vec::new()));
};
let Some(logs_dir) = directories.launcher_logs_dir() else {
return Ok((0, Vec::new()));
};
if !tokio::fs::try_exists(&logs_dir).await? {
return Ok((0, Vec::new()));
}
let mut entries = tokio::fs::read_dir(&logs_dir).await?;
let mut paths = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
let is_session_log =
path.file_name().and_then(|name| name.to_str()).is_some_and(
|name| name.starts_with("session_") && name.ends_with(".log"),
);
if is_session_log && entry.file_type().await?.is_file() {
paths.push(path);
}
}
paths.sort();
let mut exported = 0;
let mut skipped = Vec::new();
for path in paths {
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "session.log".to_string());
// A single unreadable file must not lose the whole report.
let Ok(contents) = tokio::fs::read(&path).await else {
skipped.push(file_name);
continue;
};
let filtered = theseus::filter_log_contents(
&contents,
range.max_age(),
level.minimum(),
);
let text = String::from_utf8_lossy(&filtered).into_owned();
let censored = censor_export_text(text, state).await?;
write_zip_entry(
writer,
&format!("launcher_logs/{file_name}"),
censored.as_bytes(),
)
.await?;
exported += 1;
}
Ok((exported, skipped))
}
/// Replaces credentials and IP addresses with placeholders. Censoring failures
/// propagate so an export never ships unredacted content.
async fn censor_export_text(
text: String,
state: &theseus::State,
) -> Result<String> {
Ok(theseus::install::censor_shared_text(text, state).await?)
}
fn build_environment_report() -> String {
format!(
"Starlight Launcher environment\n\
App version: {}\n\
Operating system: {}\n\
Architecture: {}\n",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::ARCH,
)
}
/// The instance whose log was written most recently, if any.
async fn newest_instance_log(
state: &theseus::State,
) -> Option<(String, PathBuf)> {
let instances_dir = state.directories.instances_dir();
let mut entries = tokio::fs::read_dir(&instances_dir).await.ok()?;
let mut newest: Option<(std::time::SystemTime, String, PathBuf)> = None;
while let Some(entry) = entries.next_entry().await.ok()? {
if !entry.file_type().await.ok()?.is_dir() {
continue;
}
let log_path = entry.path().join("logs").join("latest.log");
let Ok(metadata) = tokio::fs::metadata(&log_path).await else {
continue;
};
let Ok(modified) = metadata.modified() else {
continue;
};
let is_newer = newest
.as_ref()
.is_none_or(|(newest_modified, ..)| modified > *newest_modified);
if is_newer {
let id = entry.file_name().to_string_lossy().into_owned();
newest = Some((modified, id, log_path));
}
}
newest.map(|(_, id, path)| (id, path))
}
fn read_file_tail(path: &Path, max_bytes: u64) -> Result<String> {
use std::io::{Read, Seek, SeekFrom};
let mut file = std::fs::File::open(path)?;
let length = file.metadata()?.len();
if length > max_bytes {
file.seek(SeekFrom::Start(length - max_bytes))?;
}
let mut contents = Vec::new();
file.read_to_end(&mut contents)?;
Ok(String::from_utf8_lossy(&contents).into_owned())
}
async fn write_zip_file(
writer: &mut ZipFileWriter<tokio::fs::File>,
filename: &str,
+79 -1
View File
@@ -253,6 +253,14 @@ impl LightweightMode {
if payload.maximize_window {
maximize_minecraft_window(payload.pid).await;
}
if let Some(title) = payload
.window_title
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
rename_minecraft_window(payload.pid, title).await;
}
let settings = match theseus::settings::get().await {
Ok(settings) => settings,
Err(error) => {
@@ -332,6 +340,8 @@ struct ProcessEventPayload {
pid: u32,
#[serde(default)]
maximize_window: bool,
#[serde(default)]
window_title: Option<String>,
event: String,
crashed: Option<bool>,
#[serde(default)]
@@ -339,7 +349,7 @@ struct ProcessEventPayload {
}
#[cfg(target_os = "windows")]
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicIsize, AtomicU32, Ordering};
#[cfg(target_os = "windows")]
static MAXIMIZE_PROCESS_ID: AtomicU32 = AtomicU32::new(0);
@@ -400,6 +410,74 @@ async fn maximize_minecraft_window(pid: u32) {
#[cfg(not(target_os = "windows"))]
async fn maximize_minecraft_window(_pid: u32) {}
#[cfg(target_os = "windows")]
static RENAME_WINDOW_HANDLE: AtomicIsize = AtomicIsize::new(0);
#[cfg(target_os = "windows")]
unsafe extern "system" fn rename_if_owned_by_process(
hwnd: windows::Win32::Foundation::HWND,
_: windows::Win32::Foundation::LPARAM,
) -> windows::core::BOOL {
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowThreadProcessId, IsWindowVisible,
};
use windows::core::BOOL;
let mut window_pid = 0;
unsafe { GetWindowThreadProcessId(hwnd, Some(&mut window_pid)) };
if window_pid == MAXIMIZE_PROCESS_ID.load(Ordering::Relaxed)
&& unsafe { IsWindowVisible(hwnd).as_bool() }
{
RENAME_WINDOW_HANDLE.store(hwnd.0 as isize, Ordering::Relaxed);
return BOOL(0);
}
BOOL(1)
}
#[cfg(target_os = "windows")]
async fn rename_minecraft_window(pid: u32, title: &str) {
if pid == 0 || title.is_empty() {
return;
}
// PCL keeps re-applying the title: FML/Quilt and some mods reset it
// while the game boots, so a one-shot rename is not enough.
let deadline =
tokio::time::Instant::now() + std::time::Duration::from_secs(45);
let mut applied = false;
loop {
let found = {
let _guard = MAXIMIZE_WINDOW_ENUMERATION.lock();
MAXIMIZE_PROCESS_ID.store(pid, Ordering::Relaxed);
RENAME_WINDOW_HANDLE.store(0, Ordering::Relaxed);
unsafe {
use windows::Win32::Foundation::LPARAM;
use windows::Win32::UI::WindowsAndMessaging::EnumWindows;
let _ =
EnumWindows(Some(rename_if_owned_by_process), LPARAM(0));
}
RENAME_WINDOW_HANDLE.load(Ordering::Relaxed)
};
if found != 0 {
let hwnd = windows::Win32::Foundation::HWND(found as *mut _);
let wide: Vec<u16> = title.encode_utf16().chain(Some(0)).collect();
unsafe {
use windows::Win32::UI::WindowsAndMessaging::SetWindowTextW;
let _ =
SetWindowTextW(hwnd, windows::core::PCWSTR(wide.as_ptr()));
}
applied = true;
}
if tokio::time::Instant::now() >= deadline {
return;
}
let _ = applied;
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
}
}
#[cfg(not(target_os = "windows"))]
async fn rename_minecraft_window(_pid: u32, _title: &str) {}
#[tauri::command]
pub fn lightweight_mode_frontend_ready(
@@ -0,0 +1,2 @@
ALTER TABLE settings ADD COLUMN custom_window_title_enabled INTEGER NOT NULL DEFAULT FALSE;
ALTER TABLE settings ADD COLUMN default_window_title TEXT NOT NULL DEFAULT 'Minecraft';
+261
View File
@@ -0,0 +1,261 @@
use crate::event::emit::emit_instance_groups_changed;
use crate::state::instance_groups;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use uuid::Uuid;
const MAX_GROUP_NAME_LENGTH: usize = 256;
pub const FAVORITES_GROUP_ID: &str = instance_groups::FAVORITES_GROUP_ID;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InstanceGroup {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InstanceGroupMembershipUpdate {
pub instance_id: String,
pub group_ids: Vec<String>,
}
fn validate_group_name(name: &str) -> crate::Result<&str> {
let name = name.trim();
if name.is_empty() {
return Err(crate::ErrorKind::InputError(
"Group name cannot be empty".to_string(),
)
.into());
}
if name.chars().count() > MAX_GROUP_NAME_LENGTH {
return Err(crate::ErrorKind::InputError(format!(
"Group name cannot exceed {MAX_GROUP_NAME_LENGTH} characters"
))
.into());
}
if name.eq_ignore_ascii_case("none") {
return Err(crate::ErrorKind::InputError(
"Group name cannot be None".to_string(),
)
.into());
}
Ok(name)
}
fn normalize_membership_updates(
mut updates: Vec<InstanceGroupMembershipUpdate>,
) -> crate::Result<Vec<InstanceGroupMembershipUpdate>> {
let mut instance_ids = HashSet::new();
for update in &mut updates {
if !instance_ids.insert(update.instance_id.clone()) {
return Err(crate::ErrorKind::InputError(format!(
"Duplicate instance {} in group membership update",
update.instance_id
))
.into());
}
let mut group_ids = HashSet::new();
update
.group_ids
.retain(|group_id| group_ids.insert(group_id.clone()));
}
Ok(updates)
}
pub async fn list_groups() -> crate::Result<Vec<InstanceGroup>> {
Ok(instance_groups::list()
.into_iter()
.map(|group| InstanceGroup {
id: group.id,
name: group.name,
})
.collect())
}
pub async fn create_group(name: String) -> crate::Result<InstanceGroup> {
let name = validate_group_name(&name)?;
let id = Uuid::new_v4().to_string();
instance_groups::create(&id, name);
Ok(InstanceGroup {
id,
name: name.to_string(),
})
}
pub async fn set_group_order(group_ids: Vec<String>) -> crate::Result<()> {
instance_groups::set_order(&group_ids);
Ok(())
}
pub async fn set_group_memberships(
updates: Vec<InstanceGroupMembershipUpdate>,
) -> crate::Result<()> {
if updates.is_empty() {
return Ok(());
}
let updates = normalize_membership_updates(updates)?;
// Note: the instances themselves are deliberately not validated here.
// JSON-backed instances (`local:` ids) have no row in the `instances` table,
// and requiring one is what used to make grouping them fail outright.
let unique_group_ids = updates
.iter()
.flat_map(|update| update.group_ids.iter())
.collect::<HashSet<_>>();
for group_id in unique_group_ids {
if !instance_groups::group_exists(group_id) {
return Err(crate::ErrorKind::InputError(format!(
"Unknown instance group {group_id}"
))
.into());
}
}
instance_groups::set_memberships(
&updates
.iter()
.map(|update| {
(update.instance_id.clone(), update.group_ids.clone())
})
.collect::<Vec<_>>(),
);
let instance_ids = updates
.into_iter()
.map(|update| update.instance_id)
.collect::<Vec<_>>();
emit_instance_groups_changed(&instance_ids).await?;
Ok(())
}
pub async fn rename_group(
id: String,
new_name: String,
) -> crate::Result<InstanceGroup> {
if id == FAVORITES_GROUP_ID {
return Err(crate::ErrorKind::InputError(
"Favorites cannot be renamed".to_string(),
)
.into());
}
let new_name = validate_group_name(&new_name)?;
let Some(instance_ids) = instance_groups::rename(&id, new_name) else {
return Err(crate::ErrorKind::InputError(format!(
"Unknown instance group {id}"
))
.into());
};
emit_instance_groups_changed(&instance_ids).await?;
Ok(InstanceGroup {
id,
name: new_name.to_string(),
})
}
pub async fn delete_group(id: String) -> crate::Result<()> {
if id == FAVORITES_GROUP_ID {
return Err(crate::ErrorKind::InputError(
"Favorites cannot be deleted".to_string(),
)
.into());
}
let Some(instance_ids) = instance_groups::delete(&id) else {
return Err(crate::ErrorKind::InputError(format!(
"Unknown instance group {id}"
))
.into());
};
emit_instance_groups_changed(&instance_ids).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
FAVORITES_GROUP_ID, InstanceGroupMembershipUpdate,
MAX_GROUP_NAME_LENGTH, normalize_membership_updates,
validate_group_name,
};
#[test]
fn group_name_validation_trims_valid_names() {
assert_eq!(validate_group_name(" My group ").unwrap(), "My group");
}
#[test]
fn group_name_validation_rejects_empty_names() {
assert!(validate_group_name(" ").is_err());
}
#[test]
fn group_name_validation_rejects_reserved_name() {
assert!(validate_group_name("NoNe").is_err());
}
#[test]
fn group_name_validation_allows_favorites() {
assert_eq!(validate_group_name("Favorites").unwrap(), "Favorites");
}
#[test]
fn group_name_validation_rejects_long_names() {
assert!(
validate_group_name(&"a".repeat(MAX_GROUP_NAME_LENGTH + 1))
.is_err()
);
}
#[test]
fn favorites_group_id_is_stable() {
assert_eq!(FAVORITES_GROUP_ID, "group:favorites");
}
#[test]
fn membership_updates_reject_duplicate_instances() {
let updates = vec![
InstanceGroupMembershipUpdate {
instance_id: "instance".to_string(),
group_ids: vec!["first".to_string()],
},
InstanceGroupMembershipUpdate {
instance_id: "instance".to_string(),
group_ids: vec!["second".to_string()],
},
];
assert!(normalize_membership_updates(updates).is_err());
}
#[test]
fn membership_updates_deduplicate_groups() {
let updates =
normalize_membership_updates(vec![InstanceGroupMembershipUpdate {
instance_id: "instance".to_string(),
group_ids: vec![
"a".to_string(),
"b".to_string(),
"a".to_string(),
"b".to_string(),
],
}])
.unwrap();
assert_eq!(updates[0].group_ids, &["a", "b"]);
}
}
+6
View File
@@ -5,6 +5,7 @@ mod content;
mod core_components;
mod export_mrpack;
mod get;
mod groups;
mod home;
mod install;
mod lifecycle;
@@ -72,6 +73,11 @@ pub use self::export_mrpack::{
create_mrpack_json, export_mrpack, get_pack_export_candidates,
};
pub use self::get::{get, get_many, list};
pub use self::groups::{
FAVORITES_GROUP_ID, InstanceGroup, InstanceGroupMembershipUpdate,
create_group, delete_group, list_groups, rename_group,
set_group_memberships, set_group_order,
};
pub use self::home::{
get_daily_playtime, get_daily_playtime_details, set_pinned,
};
+12
View File
@@ -250,6 +250,17 @@ async fn run_credentials(
.launch_overrides
.maximize_window
.unwrap_or(settings.maximize_window);
let window_title = settings
.custom_window_title_enabled
.then(|| {
context
.launch_overrides
.window_title
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| settings.default_window_title.clone())
})
.filter(|value| !value.trim().is_empty());
let env_args = context
.launch_overrides
.custom_env_vars
@@ -319,6 +330,7 @@ async fn run_credentials(
&memory,
&resolution,
maximize_window,
window_title,
credentials,
post_exit_hook,
&context,
@@ -157,6 +157,7 @@ pub(crate) async fn import_axolotl(
),
maximize_window: Some(config.launch_overrides.maximize_window),
game_resolution: Some(config.launch_overrides.game_resolution),
window_title: None,
launch_preparation_timeout: Some(
config.launch_overrides.launch_preparation_timeout,
),
+25 -2
View File
@@ -6,8 +6,9 @@ use crate::event::{
};
#[cfg(feature = "tauri")]
use crate::event::{
InstancePayload, JavaDiscoveryPayload, JavaDownloadConfirmationPayload,
LoadingPayload, ProcessPayload, ServerPayload, WarningPayload,
InstanceGroupsChangedPayload, InstancePayload, JavaDiscoveryPayload,
JavaDownloadConfirmationPayload, LoadingPayload, ProcessPayload,
ServerPayload, WarningPayload,
};
use futures::prelude::*;
use serde_json::Value;
@@ -400,6 +401,7 @@ pub async fn emit_process(
uuid: Uuid,
pid: u32,
maximize_window: bool,
window_title: Option<String>,
event: ProcessPayloadType,
message: &str,
crashed: Option<bool>,
@@ -416,6 +418,7 @@ pub async fn emit_process(
uuid,
pid,
maximize_window,
window_title,
event,
message: message.to_string(),
crashed,
@@ -486,6 +489,26 @@ pub async fn emit_friend(payload: FriendPayload) -> crate::Result<()> {
Ok(())
}
#[allow(unused_variables)]
pub async fn emit_instance_groups_changed(
instance_ids: &[String],
) -> crate::Result<()> {
#[cfg(feature = "tauri")]
{
let event_state = crate::EventState::get()?;
event_state
.app
.emit(
"instance_groups_changed",
InstanceGroupsChangedPayload {
instance_ids: instance_ids.to_vec(),
},
)
.map_err(EventError::from)?;
}
Ok(())
}
#[allow(unused_variables)]
pub async fn emit_notification(payload: Value) -> crate::Result<()> {
#[cfg(feature = "tauri")]
+6
View File
@@ -336,6 +336,8 @@ pub struct ProcessPayload {
pub uuid: Uuid,
pub pid: u32,
pub maximize_window: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub window_title: Option<String>,
pub event: ProcessPayloadType,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -393,6 +395,10 @@ pub enum ServerPayloadType {
eula_text: String,
},
}
#[derive(Serialize, Clone)]
pub struct InstanceGroupsChangedPayload {
pub instance_ids: Vec<String>,
}
#[derive(Serialize, Clone)]
#[cfg(feature = "tauri")]
+1 -1
View File
@@ -670,7 +670,7 @@ fn read_file_tail(path: &Path, max_bytes: u64) -> crate::Result<String> {
Ok(output)
}
async fn censor_support_text(
pub(crate) async fn censor_support_text(
mut text: String,
state: &State,
) -> crate::Result<String> {
+9
View File
@@ -41,3 +41,12 @@ pub use runner::{
retry_job, retry_job_as_new, skip_missing_content_and_resume_job,
update_managed_curseforge_modpack, upgrade_unmanaged_instance,
};
/// Replaces credentials and IP addresses in text the user may share publicly
/// (support reports, exported logs) with placeholders.
pub async fn censor_shared_text(
text: String,
state: &crate::State,
) -> crate::Result<String> {
diagnostics::censor_support_text(text, state).await
}
+2
View File
@@ -1520,6 +1520,7 @@ pub async fn launch_minecraft(
memory: &MemorySettings,
resolution: &WindowSize,
maximize_window: bool,
window_title: Option<String>,
credentials: &Credentials,
post_exit_hook: Option<String>,
context: &InstanceLaunchContext,
@@ -2391,6 +2392,7 @@ pub async fn launch_minecraft(
command,
post_exit_hook,
maximize_window,
window_title,
instance_path.clone(),
logs_folder,
version_info.logging.is_some(),
+1
View File
@@ -28,6 +28,7 @@ pub use event::{
EventState, LoadingBar, LoadingBarType, emit::emit_loading,
emit::init_loading,
};
pub use logger::filter_log_contents;
pub use logger::start_logger;
pub use state::db::{
backup_current_app_db_for_update, beta_database_exists,
+108
View File
@@ -240,6 +240,114 @@ fn launcher_log_is_expired(
now.duration_since(created).is_ok_and(|age| age > max_age)
}
/// Ordinal for the levels the launcher writes, ordered from most to least
/// verbose. Unknown tokens are ignored so unrecognized lines are never pruned.
fn log_level_ordinal(token: &str) -> Option<u8> {
match token {
"TRACE" => Some(0),
"DEBUG" => Some(1),
"INFO" => Some(2),
"WARN" => Some(3),
"ERROR" => Some(4),
_ => None,
}
}
fn log_level_minimum_ordinal(level: &str) -> Option<u8> {
match level {
"trace" => Some(0),
"debug" => Some(1),
"info" => Some(2),
"warn" => Some(3),
"error" => Some(4),
_ => None,
}
}
/// How a log line relates to the surrounding entries.
enum LogEntryHeader {
/// Timestamp and level parsed: the entry can be filtered.
Classified(chrono::DateTime<chrono::FixedOffset>, u8),
/// Timestamp parsed but the level is unknown: keep it rather than
/// dropping content this logic does not understand.
Unclassified,
}
/// Reads the timestamp and level of an entry line. Continuation lines of a
/// multi-line message carry no prefix and return `None`, so callers keep them
/// grouped with the entry they belong to.
fn parse_log_entry_header(line: &str) -> Option<LogEntryHeader> {
let (timestamp, rest) = line.split_once(char::is_whitespace)?;
let timestamp = chrono::DateTime::parse_from_rfc3339(timestamp).ok()?;
let level = rest.trim_start().split_whitespace().next()?;
Some(match log_level_ordinal(level) {
Some(ordinal) => LogEntryHeader::Classified(timestamp, ordinal),
None => LogEntryHeader::Unclassified,
})
}
/// Keeps or drops whole log entries by a predicate over their timestamp and
/// level ordinal, returning `None` when nothing was dropped.
///
/// Multi-line messages are kept or dropped together with their header, the
/// bytes of kept lines are preserved exactly (no re-encoding), and entries
/// whose level cannot be classified are always kept.
fn filter_log_entries<F>(contents: &[u8], keep: F) -> Option<Vec<u8>>
where
F: Fn(chrono::DateTime<chrono::FixedOffset>, u8) -> bool,
{
let mut kept = Vec::with_capacity(contents.len());
let mut changed = false;
let mut keep_entry = true;
for line in contents.split_inclusive(|byte| *byte == b'\n') {
match parse_log_entry_header(&String::from_utf8_lossy(line)) {
Some(LogEntryHeader::Classified(timestamp, ordinal)) => {
keep_entry = keep(timestamp, ordinal);
}
Some(LogEntryHeader::Unclassified) => keep_entry = true,
// Continuation line: it follows the decision of its entry.
None => {}
}
if keep_entry {
kept.extend_from_slice(line);
} else {
changed = true;
}
}
changed.then_some(kept)
}
/// Filters launcher log bytes for an export: entries older than `max_age` and
/// entries below `min_level` are removed. Pass `None` for either bound to keep
/// everything on that axis. Multi-line messages stay intact.
pub fn filter_log_contents(
contents: &[u8],
max_age: Option<std::time::Duration>,
min_level: Option<&str>,
) -> Vec<u8> {
let now = chrono::Local::now().fixed_offset();
let minimum = min_level.and_then(log_level_minimum_ordinal);
filter_log_entries(contents, |timestamp, ordinal| {
if let Some(minimum) = minimum
&& ordinal < minimum
{
return false;
}
if let Some(max_age) = max_age {
let age = (now - timestamp).to_std().unwrap_or_default();
if age > max_age {
return false;
}
}
true
})
.unwrap_or_else(|| contents.to_vec())
}
struct LogEventWriter {
writer: RotatingLogWriter,
buffer: Vec<u8>,
@@ -0,0 +1,613 @@
//! Instance grouping backed by a standalone JSON file in the app's settings
//! directory, next to `settings.json`.
//!
//! Groups used to live in SQLite (`instance_groups` /
//! `instance_group_memberships`). That layout could not express membership for
//! JSON-backed instances (`local:` ids, i.e. every `.minecraft` and
//! multi-library instance) because they have no row in the `instances` table,
//! and it duplicated membership into the per-instance sidecars. This module is
//! the single source of truth instead; the SQL tables are left untouched but
//! are no longer read or written (they are only read once, at import time).
use crate::state::State;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
use tracing::{info, warn};
pub const INSTANCE_GROUPS_FILE_NAME: &str = "instance_groups.json";
/// Current `instance_groups.json` schema version.
pub const INSTANCE_GROUPS_SCHEMA_VERSION: u32 = 1;
pub const FAVORITES_GROUP_ID: &str = "group:favorites";
const FAVORITES_GROUP_NAME: &str = "Pinned";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GroupDefinition {
pub id: String,
pub name: String,
}
/// On-disk shape. The order of `groups` *is* the display order, so no
/// `display_order` field is needed: the public API only ever communicates
/// ordering through the order of `list()` and `set_order()`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct InstanceGroupsFile {
#[serde(default)]
pub schema_version: u32,
#[serde(default)]
pub groups: Vec<GroupDefinition>,
/// instance id -> group ids.
#[serde(default)]
pub memberships: HashMap<String, Vec<String>>,
}
impl Default for InstanceGroupsFile {
fn default() -> Self {
Self {
schema_version: INSTANCE_GROUPS_SCHEMA_VERSION,
groups: vec![GroupDefinition {
id: FAVORITES_GROUP_ID.to_string(),
name: FAVORITES_GROUP_NAME.to_string(),
}],
memberships: HashMap::new(),
}
}
}
impl InstanceGroupsFile {
fn has_group(&self, id: &str) -> bool {
self.groups.iter().any(|group| group.id == id)
}
/// Guarantees the favorites sentinel exists and sits first, drops
/// memberships pointing at groups that no longer exist, and drops instances
/// left with no groups at all.
fn normalize(&mut self) {
self.schema_version = INSTANCE_GROUPS_SCHEMA_VERSION;
match self.groups.iter().position(|g| g.id == FAVORITES_GROUP_ID) {
Some(0) => {}
Some(index) => {
let favorites = self.groups.remove(index);
self.groups.insert(0, favorites);
}
None => self.groups.insert(
0,
GroupDefinition {
id: FAVORITES_GROUP_ID.to_string(),
name: FAVORITES_GROUP_NAME.to_string(),
},
),
}
let known = self
.groups
.iter()
.map(|group| group.id.clone())
.collect::<HashSet<_>>();
for group_ids in self.memberships.values_mut() {
let mut seen = HashSet::new();
group_ids
.retain(|id| known.contains(id) && seen.insert(id.clone()));
}
self.memberships
.retain(|_, group_ids| !group_ids.is_empty());
}
}
static STORE: OnceLock<RwLock<InstanceGroupsFile>> = OnceLock::new();
fn file_path() -> Option<PathBuf> {
let state = State::get_if_initialized()?;
Some(
state
.directories
.settings_dir
.join(INSTANCE_GROUPS_FILE_NAME),
)
}
/// Reads the store from disk, or `None` when it has never been written.
fn read_from_disk() -> Option<InstanceGroupsFile> {
let path = file_path()?;
if !path.exists() {
return None;
}
match fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|content| {
serde_json::from_str::<InstanceGroupsFile>(&content)
.map_err(|e| e.to_string())
}) {
Ok(mut file) => {
file.normalize();
Some(file)
}
Err(error) => {
// A corrupt file must not wipe the user's groups silently on the
// next write, so bail out to an empty store without touching disk.
warn!("Failed to read {INSTANCE_GROUPS_FILE_NAME}: {error}");
None
}
}
}
fn write_to_disk(file: &InstanceGroupsFile) {
let Some(path) = file_path() else {
return;
};
let write = serde_json::to_string_pretty(file)
.map_err(|e| e.to_string())
.and_then(|content| {
fs::write(&path, content).map_err(|e| e.to_string())
});
if let Err(error) = write {
warn!("Failed to save {INSTANCE_GROUPS_FILE_NAME}: {error}");
}
}
fn store() -> &'static RwLock<InstanceGroupsFile> {
STORE.get_or_init(|| RwLock::new(read_from_disk().unwrap_or_default()))
}
fn with_read<T>(f: impl FnOnce(&InstanceGroupsFile) -> T) -> T {
let guard = store().read().unwrap_or_else(|e| e.into_inner());
f(&guard)
}
/// Mutates the store and persists the whole file. `f` returns the instance ids
/// whose membership changed, so callers can emit the change event.
fn with_write<T>(f: impl FnOnce(&mut InstanceGroupsFile) -> T) -> T {
let mut guard = store().write().unwrap_or_else(|e| e.into_inner());
let result = f(&mut guard);
guard.normalize();
write_to_disk(&guard);
result
}
// ── Reads ───────────────────────────────────────────────────────────────────
pub fn list() -> Vec<GroupDefinition> {
with_read(|file| file.groups.clone())
}
pub fn group_exists(id: &str) -> bool {
with_read(|file| file.has_group(id))
}
/// Group ids for one instance, in the order they were assigned. Synchronous so
/// that `instance_metadata_from_instance` can call it.
pub fn group_ids_for(instance_id: &str) -> Vec<String> {
with_read(|file| {
file.memberships
.get(instance_id)
.cloned()
.unwrap_or_default()
})
}
fn instances_in_group(
file: &InstanceGroupsFile,
group_id: &str,
) -> Vec<String> {
file.memberships
.iter()
.filter(|(_, group_ids)| group_ids.iter().any(|id| id == group_id))
.map(|(instance_id, _)| instance_id.clone())
.collect()
}
// ── Writes ──────────────────────────────────────────────────────────────────
/// Adds a group at the top of the list, matching the previous SQL behavior
/// where new groups got the lowest `display_order`. Favorites stays first.
pub fn create(id: &str, name: &str) {
with_write(|file| {
let definition = GroupDefinition {
id: id.to_string(),
name: name.to_string(),
};
// `normalize` moves favorites back to index 0 afterwards.
file.groups.insert(0, definition);
});
}
/// Renames a group, returning the affected instance ids, or `None` when the
/// group does not exist.
pub fn rename(id: &str, name: &str) -> Option<Vec<String>> {
with_write(|file| {
let group = file.groups.iter_mut().find(|group| group.id == id)?;
group.name = name.to_string();
Some(instances_in_group(file, id))
})
}
/// Deletes a group and strips it from every instance, returning the affected
/// instance ids, or `None` when the group does not exist.
pub fn delete(id: &str) -> Option<Vec<String>> {
with_write(|file| {
let index = file.groups.iter().position(|group| group.id == id)?;
let affected = instances_in_group(file, id);
file.groups.remove(index);
// `normalize` prunes the now-dangling ids out of `memberships`.
Some(affected)
})
}
/// Reorders groups to match `group_ids`. Groups missing from the argument keep
/// their relative order at the end, so a stale frontend list can never drop a
/// group. Favorites is pinned first regardless.
pub fn set_order(group_ids: &[String]) {
with_write(|file| reorder_groups(&mut file.groups, group_ids));
}
/// Reorders `groups` to match `group_ids`. Groups the argument omits keep their
/// relative order after the listed ones, so a stale frontend list can never drop
/// a group. Pure so the ordering rules can be tested without the process-wide
/// store, which tests would otherwise race over.
fn reorder_groups(groups: &mut Vec<GroupDefinition>, group_ids: &[String]) {
let mut remaining = std::mem::take(groups);
let mut ordered = Vec::with_capacity(remaining.len());
for id in group_ids {
if id == FAVORITES_GROUP_ID {
continue;
}
if let Some(index) = remaining.iter().position(|group| &group.id == id)
{
ordered.push(remaining.remove(index));
}
}
ordered.extend(remaining);
*groups = ordered;
}
/// Replaces the group list of each given instance wholesale.
pub fn set_memberships(updates: &[(String, Vec<String>)]) {
with_write(|file| {
for (instance_id, group_ids) in updates {
if group_ids.is_empty() {
file.memberships.remove(instance_id);
} else {
file.memberships
.insert(instance_id.clone(), group_ids.clone());
}
}
});
}
/// Replaces the group list of a single instance.
pub fn set_instance_groups(instance_id: &str, group_ids: &[String]) {
set_memberships(&[(instance_id.to_string(), group_ids.to_vec())]);
}
/// Looks a group up by name, creating it if absent, and returns its id.
/// Used by the legacy launcher import, which only knows group names.
pub fn find_or_create_by_name(name: &str) -> String {
with_write(|file| {
if let Some(group) = file.groups.iter().find(|group| group.name == name)
{
return group.id.clone();
}
let id = uuid::Uuid::new_v4().to_string();
file.groups.push(GroupDefinition {
id: id.clone(),
name: name.to_string(),
});
id
})
}
/// Adds one group to an instance, keeping its existing groups.
pub fn add_instance_to_group(instance_id: &str, group_id: &str) {
with_write(|file| {
let entry =
file.memberships.entry(instance_id.to_string()).or_default();
if !entry.iter().any(|id| id == group_id) {
entry.push(group_id.to_string());
}
});
}
/// Drops an instance's membership entry, e.g. after the instance is deleted.
pub fn forget_instance(instance_id: &str) {
with_write(|file| {
file.memberships.remove(instance_id);
});
}
/// Re-keys an instance's membership, e.g. when a `local:` id changes because the
/// instance directory moved.
pub fn rename_instance(old_id: &str, new_id: &str) {
if old_id == new_id {
return;
}
with_write(|file| {
if let Some(group_ids) = file.memberships.remove(old_id) {
file.memberships.insert(new_id.to_string(), group_ids);
}
});
}
// ── One-time import ─────────────────────────────────────────────────────────
async fn import_sql_groups(
pool: &sqlx::SqlitePool,
) -> crate::Result<InstanceGroupsFile> {
let columns: Vec<String> = sqlx::query_scalar(
"SELECT name FROM pragma_table_info('instance_groups')",
)
.fetch_all(pool)
.await?;
let has_column = |name: &str| columns.iter().any(|column| column == name);
let mut file = InstanceGroupsFile {
schema_version: INSTANCE_GROUPS_SCHEMA_VERSION,
groups: Vec::new(),
memberships: HashMap::new(),
};
if has_column("id") && has_column("name") {
let rows = sqlx::query_as::<_, (String, String)>(
"SELECT id, name FROM instance_groups ORDER BY display_order, name, id",
)
.fetch_all(pool)
.await?;
for (id, name) in rows {
file.groups.push(GroupDefinition { id, name });
}
let has_memberships: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM sqlite_master
WHERE type = 'table' AND name = 'instance_group_memberships'
)",
)
.fetch_one(pool)
.await?;
if has_memberships {
let memberships = sqlx::query_as::<_, (String, String)>(
"SELECT instance_id, group_id FROM instance_group_memberships",
)
.fetch_all(pool)
.await?;
for (instance_id, group_id) in memberships {
file.memberships
.entry(instance_id)
.or_default()
.push(group_id);
}
}
} else if has_column("instance_id") && has_column("group_name") {
let rows = sqlx::query_as::<_, (String, String)>(
"SELECT instance_id, group_name
FROM instance_groups
ORDER BY group_name, instance_id",
)
.fetch_all(pool)
.await?;
let mut group_ids = HashMap::<String, String>::new();
for (instance_id, group_name) in rows {
let group_id = group_ids
.entry(group_name.clone())
.or_insert_with(|| uuid::Uuid::new_v4().to_string())
.clone();
file.memberships
.entry(instance_id)
.or_default()
.push(group_id.clone());
if !file.groups.iter().any(|group| group.id == group_id) {
file.groups.push(GroupDefinition {
id: group_id,
name: group_name,
});
}
}
} else if !columns.is_empty() {
warn!(
columns = ?columns,
"Skipping instance group import from an unrecognized SQLite schema"
);
}
file.normalize();
Ok(file)
}
/// Creates `instance_groups.json` from the pre-existing SQLite tables and
/// instance sidecars, the first time the app runs after this change.
///
/// The SQL access here is read-only and happens exactly once; afterwards the
/// tables are never touched again (they are kept as-is so no DB migration is
/// needed).
pub async fn ensure_imported(state: &State) -> crate::Result<()> {
let Some(path) = file_path() else {
return Ok(());
};
if path.exists() {
// Populate the in-memory cache eagerly so the first synchronous read
// does not race with a write.
let _ = list();
return Ok(());
}
let file = import_sql_groups(&state.pool).await?;
let group_count = file.groups.len();
let instance_count = file.memberships.len();
// Seed the cache with the imported data instead of letting `store()` read
// the file we are about to write.
{
let lock = store();
let mut guard = lock.write().unwrap_or_else(|e| e.into_inner());
*guard = file;
write_to_disk(&guard);
}
info!(
"Imported {group_count} instance groups covering {instance_count} instances into {INSTANCE_GROUPS_FILE_NAME}"
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
fn file_with_groups(ids: &[&str]) -> InstanceGroupsFile {
InstanceGroupsFile {
schema_version: INSTANCE_GROUPS_SCHEMA_VERSION,
groups: ids
.iter()
.map(|id| GroupDefinition {
id: id.to_string(),
name: id.to_string(),
})
.collect(),
memberships: HashMap::new(),
}
}
#[test]
fn normalize_inserts_favorites_first() {
let mut file = file_with_groups(&["a", "b"]);
file.normalize();
assert_eq!(file.groups[0].id, FAVORITES_GROUP_ID);
assert_eq!(file.groups.len(), 3);
}
#[test]
fn normalize_moves_favorites_to_front() {
let mut file = file_with_groups(&["a", FAVORITES_GROUP_ID, "b"]);
file.normalize();
assert_eq!(file.groups[0].id, FAVORITES_GROUP_ID);
assert_eq!(file.groups[1].id, "a");
assert_eq!(file.groups[2].id, "b");
}
#[test]
fn normalize_drops_orphan_memberships() {
let mut file = file_with_groups(&["a"]);
file.memberships.insert(
"instance".to_string(),
vec!["a".to_string(), "nonexistent".to_string()],
);
file.normalize();
assert_eq!(file.memberships["instance"], &["a"]);
}
#[test]
fn normalize_drops_memberships_for_nonexistent_instances() {
let mut file = file_with_groups(&["a"]);
file.memberships.insert(
"instance".to_string(),
vec!["nonexistent1".to_string(), "nonexistent2".to_string()],
);
file.normalize();
assert!(!file.memberships.contains_key("instance"));
}
fn group_ids(groups: &[GroupDefinition]) -> Vec<String> {
groups.iter().map(|group| group.id.clone()).collect()
}
#[test]
fn set_order_ignores_unknown_ids() {
let mut groups = file_with_groups(&["a", "b", "c"]).groups;
reorder_groups(&mut groups, &["unknown".to_string(), "c".to_string()]);
// The unknown id matches nothing and simply contributes no group.
assert_eq!(group_ids(&groups), vec!["c", "a", "b"]);
}
#[test]
fn set_order_retains_missing_groups_at_end() {
let mut groups = file_with_groups(&["a", "b", "c"]).groups;
groups.push(GroupDefinition {
id: "extra".to_string(),
name: "extra".to_string(),
});
reorder_groups(&mut groups, &["b".to_string()]);
// `b` moves to the front and every group the argument omitted survives
// after it, so a stale list can never drop a group. Favorites is pinned
// by `normalize`, which the store applies around this call.
assert_eq!(group_ids(&groups), vec!["b", "a", "c", "extra"]);
}
#[test]
fn set_order_keeps_favorites_out_of_the_reorder() {
let mut groups = file_with_groups(&["a", FAVORITES_GROUP_ID]).groups;
reorder_groups(&mut groups, &[FAVORITES_GROUP_ID.to_string()]);
// Favorites is skipped explicitly and survives at its previous position;
// `normalize` moves it to the front afterwards.
assert_eq!(group_ids(&groups), vec!["a", FAVORITES_GROUP_ID]);
}
#[test]
fn normalize_does_not_lose_favorites_membership() {
let mut file = file_with_groups(&[]);
file.memberships.insert(
"my_instance".to_string(),
vec![FAVORITES_GROUP_ID.to_string()],
);
file.normalize();
assert_eq!(file.memberships["my_instance"], &[FAVORITES_GROUP_ID]);
}
#[test]
fn normalize_deduplicates_group_ids() {
let mut file = file_with_groups(&["a", "b"]);
file.memberships.insert(
"instance".to_string(),
vec!["a".to_string(), "a".to_string(), "b".to_string()],
);
file.normalize();
assert_eq!(file.memberships["instance"].len(), 2);
}
#[tokio::test]
async fn imports_legacy_instance_group_schema() {
let pool = SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::query(
"CREATE TABLE instance_groups (
instance_id TEXT NOT NULL,
group_name TEXT NOT NULL,
PRIMARY KEY (instance_id, group_name)
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO instance_groups (instance_id, group_name)
VALUES ('instance-1', 'Survival'), ('instance-2', 'Survival'),
('instance-1', 'Creative')",
)
.execute(&pool)
.await
.unwrap();
let file = import_sql_groups(&pool).await.unwrap();
assert_eq!(file.groups.len(), 3);
assert_eq!(file.groups[0].id, FAVORITES_GROUP_ID);
assert_eq!(file.memberships["instance-1"].len(), 2);
assert_eq!(file.memberships["instance-2"].len(), 1);
assert!(file.groups.iter().any(|group| group.name == "Survival"));
}
}
@@ -103,6 +103,12 @@ pub struct InstanceLaunchOverridesPatch {
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub window_title: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub launch_preparation_timeout: Option<Option<u64>>,
pub hooks: Option<Hooks>,
}
@@ -371,6 +377,10 @@ fn apply_launch_overrides_patch(
if let Some(game_resolution) = patch.game_resolution {
overrides.game_resolution = game_resolution;
}
if let Some(window_title) = patch.window_title {
overrides.window_title =
window_title.filter(|value| !value.trim().is_empty());
}
if let Some(mode) = patch.instance_mode {
overrides.instance_mode = Some(mode);
}
@@ -1,3 +1,4 @@
use crate::state::instance_groups;
use crate::state::instances::{
ContentSet, Instance, InstanceLaunchOverrides, InstanceLink,
LoaderComponent,
@@ -91,11 +92,12 @@ impl InstanceMetadata {
record: instance_rows::InstanceMetadataRecord,
loader_components: Vec<LoaderComponent>,
) -> Self {
let groups = instance_groups::group_ids_for(&record.instance.id);
Self {
instance: record.instance,
applied_content_set: record.applied_content_set,
link: record.link,
groups: record.groups,
groups,
launch_overrides: record.launch_overrides,
loader_components,
synced_options: Default::default(),
@@ -38,6 +38,8 @@ pub struct InstanceLaunchOverrides {
pub maximize_window: Option<bool>,
pub game_resolution: Option<WindowSize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub window_title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub launch_preparation_timeout: Option<u64>,
pub hooks: Hooks,
}
@@ -55,6 +57,7 @@ impl InstanceLaunchOverrides {
force_fullscreen: None,
maximize_window: None,
game_resolution: None,
window_title: None,
launch_preparation_timeout: None,
hooks: Hooks {
pre_launch: None,
@@ -86,6 +89,8 @@ pub(crate) struct InstanceLaunchOverridesData {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub game_resolution: Option<WindowSize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub window_title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub launch_preparation_timeout: Option<u64>,
#[serde(default)]
pub hooks: Hooks,
@@ -107,6 +112,7 @@ impl InstanceLaunchOverridesData {
force_fullscreen: self.force_fullscreen,
maximize_window: self.maximize_window,
game_resolution: self.game_resolution,
window_title: self.window_title,
launch_preparation_timeout: self.launch_preparation_timeout,
hooks: self.hooks,
}
@@ -125,6 +131,7 @@ impl From<&InstanceLaunchOverrides> for InstanceLaunchOverridesData {
force_fullscreen: overrides.force_fullscreen,
maximize_window: overrides.maximize_window,
game_resolution: overrides.game_resolution,
window_title: overrides.window_title.clone(),
launch_preparation_timeout: overrides.launch_preparation_timeout,
hooks: overrides.hooks.clone(),
}
@@ -621,6 +621,7 @@ where
force_fullscreen: input.force_fullscreen,
maximize_window: None,
game_resolution: input.game_resolution,
window_title: None,
launch_preparation_timeout: None,
hooks: input.hooks,
};
+5
View File
@@ -26,6 +26,7 @@ pub use self::instance_types::*;
pub(crate) mod instances;
pub use self::instances::*;
pub(crate) mod instance_groups;
mod settings;
pub use self::settings::*;
@@ -487,6 +488,10 @@ impl State {
.get_or_try_init(move || Self::initialize_state(app_identifier))
.await?;
if let Err(e) = instance_groups::ensure_imported(state).await {
tracing::error!("Error importing legacy instance groups: {e}");
}
if let Err(e) =
crate::install::recovery::recover_interrupted_jobs(state).await
{
+6
View File
@@ -312,6 +312,7 @@ impl ProcessManager {
mut mc_command: Command,
post_exit_command: Option<String>,
maximize_window: bool,
window_title: Option<String>,
game_dir: PathBuf,
logs_folder: PathBuf,
xml_logging: bool,
@@ -336,6 +337,7 @@ impl ProcessManager {
uuid: Uuid::new_v4(),
pid: mc_proc.id().unwrap_or_default(),
maximize_window,
window_title: window_title.clone(),
start_time: Utc::now(),
instance_id: instance_id.to_string(),
instance_path: instance_path.to_string(),
@@ -509,6 +511,7 @@ impl ProcessManager {
metadata.uuid,
metadata.pid,
metadata.maximize_window,
metadata.window_title.clone(),
ProcessPayloadType::Launched,
"Launched Minecraft",
None,
@@ -586,6 +589,7 @@ pub struct ProcessMetadata {
pub uuid: Uuid,
pub pid: u32,
pub maximize_window: bool,
pub window_title: Option<String>,
pub instance_id: String,
pub instance_path: String,
pub instance_name: String,
@@ -1311,6 +1315,7 @@ impl Process {
uuid,
pid,
false,
None,
ProcessPayloadType::Finished,
"Exited process",
Some(!mc_exit_status.success() && !manually_killed),
@@ -1451,6 +1456,7 @@ mod post_upgrade_tests {
uuid,
pid: child.id().unwrap(),
maximize_window: false,
window_title: None,
instance_id: "first".into(),
instance_path: "first".into(),
instance_name: "First".into(),
+32
View File
@@ -176,6 +176,10 @@ pub struct Settings {
pub force_fullscreen: bool,
pub maximize_window: bool,
#[serde(default)]
pub custom_window_title_enabled: bool,
#[serde(default = "default_window_title")]
pub default_window_title: String,
#[serde(default)]
pub force_unicode_font: bool,
pub game_resolution: WindowSize,
pub hide_on_process_start: bool,
@@ -198,6 +202,10 @@ pub struct Settings {
pub version: usize,
}
fn default_window_title() -> String {
"Minecraft".to_string()
}
fn default_true() -> bool {
true
}
@@ -328,6 +336,16 @@ impl Settings {
)
.fetch_one(exec)
.await?;
let custom_window_title_enabled: bool = sqlx::query_scalar(
"SELECT custom_window_title_enabled FROM settings WHERE id = 0",
)
.fetch_one(exec)
.await?;
let default_window_title: String = sqlx::query_scalar(
"SELECT default_window_title FROM settings WHERE id = 0",
)
.fetch_one(exec)
.await?;
let settings = Self {
max_concurrent_downloads: res.max_concurrent_downloads as usize,
max_concurrent_writes: res.max_concurrent_writes as usize,
@@ -415,6 +433,8 @@ impl Settings {
},
force_fullscreen: res.mc_force_fullscreen == 1,
maximize_window: res.mc_maximize_window == 1,
custom_window_title_enabled,
default_window_title,
force_unicode_font: sqlx::query_scalar(
"SELECT mc_force_unicode_font FROM settings WHERE id = 0",
)
@@ -663,6 +683,18 @@ impl Settings {
.bind(self.ignore_ssl_errors)
.execute(exec)
.await?;
sqlx::query(
"UPDATE settings SET custom_window_title_enabled = ? WHERE id = 0",
)
.bind(self.custom_window_title_enabled)
.execute(exec)
.await?;
sqlx::query(
"UPDATE settings SET default_window_title = ? WHERE id = 0",
)
.bind(self.default_window_title.trim())
.execute(exec)
.await?;
sqlx::query("UPDATE settings SET mc_memory_optimize = ? WHERE id = 0")
.bind(self.memory.optimize_before_launch)
.execute(exec)
+1
View File
@@ -8,6 +8,7 @@ export * from './format-number'
export * from './how-ago'
export * from './i18n'
export * from './i18n-debug'
export * from './modal-stack'
export * from './page-leave-safety'
export * from './scroll-indicator'
export type { ArmorPreviewConfig } from './skin-rendering/armor-preview-types'
+139 -1
View File
@@ -4723,5 +4723,143 @@
},
"version.supplementary-resources.type": {
"defaultMessage": "Type"
}
},
"skin.preview.armor.equip-material": {
"defaultMessage": "{material} {slot}"
},
"skin.preview.armor.material": {
"defaultMessage": "Armor material"
},
"skin.preview.armor.material.chainmail": {
"defaultMessage": "Chainmail"
},
"skin.preview.armor.material.copper": {
"defaultMessage": "Copper"
},
"skin.preview.armor.material.diamond": {
"defaultMessage": "Diamond"
},
"skin.preview.armor.material.gold": {
"defaultMessage": "Gold"
},
"skin.preview.armor.material.iron": {
"defaultMessage": "Iron"
},
"skin.preview.armor.material.leather": {
"defaultMessage": "Leather"
},
"skin.preview.armor.material.netherite": {
"defaultMessage": "Netherite"
},
"skin.preview.armor.material.turtle": {
"defaultMessage": "Turtle Shell"
},
"skin.preview.armor.open": {
"defaultMessage": "Armor and trims"
},
"skin.preview.armor.piece": {
"defaultMessage": "Armor piece"
},
"skin.preview.armor.remove-piece": {
"defaultMessage": "Remove {slot}"
},
"skin.preview.armor.remove-trim": {
"defaultMessage": "No armor trim"
},
"skin.preview.armor.slot-state": {
"defaultMessage": "{slot}: {state}"
},
"skin.preview.armor.slot.boots": {
"defaultMessage": "Boots"
},
"skin.preview.armor.slot.chestplate": {
"defaultMessage": "Chestplate"
},
"skin.preview.armor.slot.helmet": {
"defaultMessage": "Helmet"
},
"skin.preview.armor.slot.leggings": {
"defaultMessage": "Leggings"
},
"skin.preview.armor.trim-material": {
"defaultMessage": "Trim material"
},
"skin.preview.armor.trim-material.amethyst": {
"defaultMessage": "Amethyst"
},
"skin.preview.armor.trim-material.emerald": {
"defaultMessage": "Emerald"
},
"skin.preview.armor.trim-material.lapis": {
"defaultMessage": "Lapis Lazuli"
},
"skin.preview.armor.trim-material.quartz": {
"defaultMessage": "Quartz"
},
"skin.preview.armor.trim-material.redstone": {
"defaultMessage": "Redstone"
},
"skin.preview.armor.trim-material.resin": {
"defaultMessage": "Resin Brick"
},
"skin.preview.armor.trim-pattern": {
"defaultMessage": "Trim pattern"
},
"skin.preview.armor.trim.bolt": {
"defaultMessage": "Bolt Armor Trim"
},
"skin.preview.armor.trim.coast": {
"defaultMessage": "Coast Armor Trim"
},
"skin.preview.armor.trim.dune": {
"defaultMessage": "Dune Armor Trim"
},
"skin.preview.armor.trim.eye": {
"defaultMessage": "Eye Armor Trim"
},
"skin.preview.armor.trim.flow": {
"defaultMessage": "Flow Armor Trim"
},
"skin.preview.armor.trim.host": {
"defaultMessage": "Host Armor Trim"
},
"skin.preview.armor.trim.raiser": {
"defaultMessage": "Raiser Armor Trim"
},
"skin.preview.armor.trim.rib": {
"defaultMessage": "Rib Armor Trim"
},
"skin.preview.armor.trim.sentry": {
"defaultMessage": "Sentry Armor Trim"
},
"skin.preview.armor.trim.shaper": {
"defaultMessage": "Shaper Armor Trim"
},
"skin.preview.armor.trim.silence": {
"defaultMessage": "Silence Armor Trim"
},
"skin.preview.armor.trim.snout": {
"defaultMessage": "Snout Armor Trim"
},
"skin.preview.armor.trim.spire": {
"defaultMessage": "Spire Armor Trim"
},
"skin.preview.armor.trim.tide": {
"defaultMessage": "Tide Armor Trim"
},
"skin.preview.armor.trim.vex": {
"defaultMessage": "Vex Armor Trim"
},
"skin.preview.armor.trim.ward": {
"defaultMessage": "Ward Armor Trim"
},
"skin.preview.armor.trim.wayfinder": {
"defaultMessage": "Wayfinder Armor Trim"
},
"skin.preview.armor.trim.wild": {
"defaultMessage": "Wild Armor Trim"
},
"skin.preview.armor.unequipped": {
"defaultMessage": "Unequipped"
}
}
+139 -1
View File
@@ -4744,5 +4744,143 @@
},
"image-viewer.previous": {
"defaultMessage": "上一张图像"
}
},
"skin.preview.armor.equip-material": {
"message": "为{slot}装备{material}"
},
"skin.preview.armor.material": {
"message": "盔甲材质"
},
"skin.preview.armor.material.chainmail": {
"message": "锁链"
},
"skin.preview.armor.material.copper": {
"message": "铜"
},
"skin.preview.armor.material.diamond": {
"message": "钻石"
},
"skin.preview.armor.material.gold": {
"message": "金"
},
"skin.preview.armor.material.iron": {
"message": "铁"
},
"skin.preview.armor.material.leather": {
"message": "皮革"
},
"skin.preview.armor.material.netherite": {
"message": "下界合金"
},
"skin.preview.armor.material.turtle": {
"message": "海龟壳"
},
"skin.preview.armor.open": {
"message": "盔甲与纹饰"
},
"skin.preview.armor.piece": {
"message": "盔甲部位"
},
"skin.preview.armor.remove-piece": {
"message": "脱下{slot}"
},
"skin.preview.armor.remove-trim": {
"message": "无盔甲纹饰"
},
"skin.preview.armor.slot-state": {
"message": "{slot}:{state}"
},
"skin.preview.armor.slot.boots": {
"message": "靴子"
},
"skin.preview.armor.slot.chestplate": {
"message": "胸甲"
},
"skin.preview.armor.slot.helmet": {
"message": "头盔"
},
"skin.preview.armor.slot.leggings": {
"message": "护腿"
},
"skin.preview.armor.trim-material": {
"message": "纹饰材料"
},
"skin.preview.armor.trim-material.amethyst": {
"message": "紫水晶"
},
"skin.preview.armor.trim-material.emerald": {
"message": "绿宝石"
},
"skin.preview.armor.trim-material.lapis": {
"message": "青金石"
},
"skin.preview.armor.trim-material.quartz": {
"message": "下界石英"
},
"skin.preview.armor.trim-material.redstone": {
"message": "红石"
},
"skin.preview.armor.trim-material.resin": {
"message": "树脂砖"
},
"skin.preview.armor.trim-pattern": {
"message": "纹饰图案"
},
"skin.preview.armor.trim.bolt": {
"message": "镶铆盔甲纹饰"
},
"skin.preview.armor.trim.coast": {
"message": "海岸盔甲纹饰"
},
"skin.preview.armor.trim.dune": {
"message": "沙丘盔甲纹饰"
},
"skin.preview.armor.trim.eye": {
"message": "眼眸盔甲纹饰"
},
"skin.preview.armor.trim.flow": {
"message": "涡流盔甲纹饰"
},
"skin.preview.armor.trim.host": {
"message": "雇主盔甲纹饰"
},
"skin.preview.armor.trim.raiser": {
"message": "牧民盔甲纹饰"
},
"skin.preview.armor.trim.rib": {
"message": "肋骨盔甲纹饰"
},
"skin.preview.armor.trim.sentry": {
"message": "哨兵盔甲纹饰"
},
"skin.preview.armor.trim.shaper": {
"message": "塑造盔甲纹饰"
},
"skin.preview.armor.trim.silence": {
"message": "幽静盔甲纹饰"
},
"skin.preview.armor.trim.snout": {
"message": "猪鼻盔甲纹饰"
},
"skin.preview.armor.trim.spire": {
"message": "尖塔盔甲纹饰"
},
"skin.preview.armor.trim.tide": {
"message": "潮汐盔甲纹饰"
},
"skin.preview.armor.trim.vex": {
"message": "恼鬼盔甲纹饰"
},
"skin.preview.armor.trim.ward": {
"message": "监守盔甲纹饰"
},
"skin.preview.armor.trim.wayfinder": {
"message": "向导盔甲纹饰"
},
"skin.preview.armor.trim.wild": {
"message": "荒野盔甲纹饰"
},
"skin.preview.armor.unequipped": {
"message": "未装备"
}
}