Compare commits

..
20 Commits
Author SHA1 Message Date
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
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 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
AxTps 544611738d docs: 1.0.2-beta4 公告并入盔甲预览
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
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
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Prepare pnpm cache / prepare (push) Canceled after 0s
beta4 此前未打 tag,本次发布将其与皮肤编辑器盔甲预览
移植(PR #8)合并发布,故在 beta4 公告条目补充:
- 皮肤预览新增盔甲与盔甲纹饰渲染
- 盔甲预览选择的本地持久化
2026-10-10 19:25:39 +08:00
AxTps 403a883e0b fix: CI 钉住 cargo-shear 版本为 1.13.4
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
check-rust.yml 的 shear 任务在 Rust 1.95 工具链下装最新
cargo-shear(1.14.0) 失败:其依赖 ra_ap_syntax 0.0.352 要求
rustc >= 1.98。且 binstall 走 GitHub release 下载时遭遇
api.github.com 403,回退源码编译后同样因版本过高而失败。

钉到 cargo-shear@1.13.4(依赖 ra_ap_syntax 0.0.347,要求
rustc 1.95),与 CI 的 stable 工具链兼容。
2026-10-09 22:20:48 +08:00
AxTps 53c749aaea fix: 补齐 skin-rendering barrel 的盔甲导出
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
PR #8 合并后 CI 打包报 MISSING_EXPORT:ArmorTrimPanel.vue 与
SkinPreviewRenderer.vue 从 #ui/composables/skin-rendering 导入
ARMOR_SLOTS / ARMOR_TRIM_MATERIALS / ARMOR_TRIM_PATTERNS /
armorMaterialsForSlot / createDefaultArmorPreviewConfig /
useArmorPreview,但该 barrel 未导出这些符号。

补上 armor-preview-types 与 use-armor-preview 的导出。
本地 pnpm --filter @modrinth/app-frontend build 已通过。
2026-10-09 22:12:41 +08:00
Ap_Tx addc31c7c1 Merge pull request #8 from ApTx2012/feat/axolotl-port
feat: 移植 Axolotl 皮肤盔甲预览(Armor Preview)
2026-10-09 21:50:24 +08:00
AxTps 25978bcd8f Merge remote-tracking branch 'origin2/main'
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
2026-10-09 21:46:12 +08:00
AxTps 117eaad9ff style: 对分组修复涉及的 Rust 文件补 cargo fmt 2026-10-09 21:44:22 +08:00
AxTps ae2992d7f8 feat: 移植 Axolotl 皮肤盔甲预览(Armor Preview)
Sync source to CNB / Sync Git ref (push) Canceled after 0s
从 Axolotl 1.9.7 移植皮肤编辑器的盔甲/盔甲纹饰预览功能,
让玩家在皮肤选择页可试穿盔甲并调整纹饰。

新增:
- 前端组件 ArmorTrimTab / SkinListSkeleton 及 skin-armor-preview helper
- packages/ui 组件 ArmorPreviewControls / ArmorTrimPanel
- packages/ui armor 预览 composable(armor-preview-assets/types、use-armor-preview)
- packages/ui webgl 盔甲渲染(armor-geometry / armor-preview-object / armor-rendering)
- packages/ui/src/assets/minecraft-armor 盔甲素材

改动:
- SkinPreviewRenderer 增加 armorPreview/armorControls props、
  armorConfig model 与 useArmorPreview 集成
- Skins.vue 增加盔甲 tab、状态逻辑与模板接入
- 补齐 skin/composables/utils 的导出

适配说明:ArmorPreviewControls 原版依赖 reka-ui 的 popover,
Starlight 无该依赖且只用 external 模式,故改写为 external-only,
去除 reka-ui 与 overlays.css 依赖。
2026-10-07 20:01:19 +08:00
182 changed files with 3228 additions and 57 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:
+1 -1
View File
@@ -22,5 +22,5 @@ jobs:
node-version-file: .nvmrc
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
- run: cargo binstall --no-confirm cargo-shear
- run: cargo binstall --no-confirm cargo-shear@1.13.4
- run: cargo shear
+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',
@@ -41,6 +68,16 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
'zh-CN': 'Starlight Launcher 1.0.2-beta4',
},
changes: {
added: [
{
'en-US': 'Added armor and armor-trim rendering to the skin preview, letting you try on armor pieces and trim patterns directly in the skin editor.',
'zh-CN': '皮肤预览新增盔甲与盔甲纹饰渲染,可在皮肤编辑器内直接试穿各部位盔甲并调整纹饰图案。',
},
{
'en-US': 'Armor preview selections in the skin editor are now saved locally, so they persist across sessions.',
'zh-CN': '皮肤编辑器中的盔甲预览选择现在会保存在本地,跨会话保留。',
},
],
changed: [
{
'en-US': 'Improved download scheduling to reduce concurrency automatically when tasks are waiting for capacity, making large Minecraft installations more reliable on constrained connections.',
@@ -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,420 @@
<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
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([() => 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>
@@ -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,123 @@
<script setup lang="ts">
import { CheckIcon, RotateCounterClockwiseIcon, XIcon } from '@modrinth/assets'
import {
type ArmorPreviewConfig,
ArmorTrimPanel,
Button,
defineMessages,
useVIntl,
} from '@modrinth/ui'
import { computed, onMounted, onUnmounted } from 'vue'
const messages = defineMessages({
title: { id: 'app.skins.armor.title', defaultMessage: 'Armour and trims' },
description: {
id: 'app.skins.armor.description',
defaultMessage: 'Try armour and trims on your skin. Nothing is stored until you save it.',
},
unsaved: { id: 'app.skins.armor.unsaved', defaultMessage: 'Unsaved changes' },
resetToSaved: { id: 'app.skins.armor.reset-to-saved', defaultMessage: 'Reset to saved' },
restoreDefaults: {
id: 'app.skins.armor.restore-defaults',
defaultMessage: 'Restore defaults',
},
save: { id: 'app.skins.armor.save', defaultMessage: 'Save' },
close: { id: 'app.skins.armor.close', defaultMessage: 'Close' },
})
const draft = defineModel<ArmorPreviewConfig>({ required: true })
const props = defineProps<{
saved: ArmorPreviewConfig
/** Stable id shared with the toggle so it can point `aria-controls` here. */
panelId: string
}>()
const emit = defineEmits<{
save: []
reset: []
defaults: []
close: []
}>()
const { formatMessage } = useVIntl()
/**
* The tab is mounted only while it is showing, so this listens exactly for that
* window. It listens in the capture phase because the app's own global
* shortcuts listen on `window` the same way, and a bubble-phase listener here
* would be skipped as soon as anything closer stopped the event. A modal on top
* of the page owns Escape while it is up, and focus sits inside it, so the key
* is left to it.
*/
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape') return
if (
event.target instanceof Element &&
event.target.closest('[role="dialog"], [role="alertdialog"], [aria-modal="true"]')
) {
return
}
emit('close')
}
onMounted(() => window.addEventListener('keydown', handleKeydown, true))
onUnmounted(() => window.removeEventListener('keydown', handleKeydown, true))
const isDirty = computed(() =>
Object.entries(draft.value).some(([slot, piece]) => {
const applied = props.saved[slot as keyof ArmorPreviewConfig]
return (
!applied ||
applied.material !== piece.material ||
applied.trimPattern !== piece.trimPattern ||
applied.trimMaterial !== piece.trimMaterial
)
}),
)
</script>
<template>
<div
:id="panelId"
role="region"
:aria-label="formatMessage(messages.title)"
class="flex min-h-[24rem] flex-col gap-4 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
>
<header class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<h2 class="m-0 text-lg font-bold text-[var(--color-text-primary)]">
{{ formatMessage(messages.title) }}
</h2>
<p class="m-0 mt-1 text-sm leading-6 text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.description) }}
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<span v-if="isDirty" class="text-sm font-semibold text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.unsaved) }}
</span>
<Button type="outlined" @click="emit('close')"
><XIcon />{{ formatMessage(messages.close) }}</Button
>
</div>
</header>
<ArmorTrimPanel v-model="draft" />
<footer
class="mt-auto flex flex-wrap items-center justify-between gap-2 border-0 border-t border-solid border-surface-5 pt-3"
>
<div class="flex flex-wrap items-center gap-2">
<Button type="outlined" :disabled="!isDirty" @click="emit('reset')"
><RotateCounterClockwiseIcon />{{ formatMessage(messages.resetToSaved) }}</Button
>
<Button type="outlined" @click="emit('defaults')">{{
formatMessage(messages.restoreDefaults)
}}</Button>
</div>
<Button type="colored" color="brand" :disabled="!isDirty" @click="emit('save')"
><CheckIcon />{{ formatMessage(messages.save) }}</Button
>
</footer>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
const { sections = 2, cards = 12 } = defineProps<{
sections?: number
cards?: number
}>()
</script>
<template>
<!--
Shaped after VirtualSkinSectionList: the same grid breakpoints and card
ratio, so the swap to real content does not move anything. Decorative, so
it stays out of the accessibility tree.
-->
<div class="flex flex-col gap-8" aria-hidden="true">
<div v-for="section in sections" :key="section" class="flex flex-col gap-4">
<div class="h-7 w-44 animate-pulse rounded-lg bg-surface-4" />
<div
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
>
<div
v-for="card in cards"
:key="card"
class="aspect-[31/40] w-full animate-pulse rounded-[20px] bg-surface-4"
/>
</div>
</div>
</div>
</template>
@@ -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),
)
}
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { loadSkinArmorPreview, saveSkinArmorPreview } from './skin-armor-preview.ts'
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
function withStorage(run: (values: Map<string, string>) => void): void {
const values = new Map<string, string>()
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
},
})
try {
run(values)
} finally {
if (originalStorage) Object.defineProperty(globalThis, 'localStorage', originalStorage)
else delete (globalThis as { localStorage?: Storage }).localStorage
}
}
test('armor preview selections survive storage round trips', () => {
withStorage(() => {
const config = loadSkinArmorPreview()
config.helmet.material = 'diamond'
config.helmet.trimPattern = 'sentry'
config.helmet.trimMaterial = 'gold'
saveSkinArmorPreview(config)
assert.deepEqual(loadSkinArmorPreview(), config)
})
})
test('invalid stored armor values fall back per slot and field', () => {
withStorage((values) => {
values.set(
'axolotl:skin-armor-preview',
JSON.stringify({
helmet: { material: 'diamond', trimPattern: 'invalid', trimMaterial: 'gold' },
boots: { material: 'turtle', trimPattern: 'sentry', trimMaterial: 'invalid' },
}),
)
const config = loadSkinArmorPreview()
assert.deepEqual(config.helmet, {
material: 'diamond',
trimPattern: null,
trimMaterial: 'gold',
})
assert.deepEqual(config.boots, {
material: null,
trimPattern: 'sentry',
trimMaterial: 'iron',
})
assert.equal(config.chestplate.material, null)
})
})
@@ -0,0 +1,47 @@
import {
ARMOR_SLOTS,
ARMOR_TRIM_MATERIALS,
ARMOR_TRIM_PATTERNS,
armorMaterialsForSlot,
type ArmorPreviewConfig,
createDefaultArmorPreviewConfig,
} from '@modrinth/ui'
const STORAGE_KEY = 'axolotl:skin-armor-preview'
function includes<T extends string>(values: readonly T[], value: unknown): value is T {
return typeof value === 'string' && values.some((item) => item === value)
}
export function loadSkinArmorPreview(): ArmorPreviewConfig {
const config = createDefaultArmorPreviewConfig()
try {
const saved = JSON.parse(globalThis.localStorage?.getItem(STORAGE_KEY) ?? 'null')
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return config
for (const slot of ARMOR_SLOTS) {
const piece = saved[slot]
if (!piece || typeof piece !== 'object' || Array.isArray(piece)) continue
if (piece.material === null || includes(armorMaterialsForSlot(slot), piece.material)) {
config[slot].material = piece.material
}
if (piece.trimPattern === null || includes(ARMOR_TRIM_PATTERNS, piece.trimPattern)) {
config[slot].trimPattern = piece.trimPattern
}
if (includes(ARMOR_TRIM_MATERIALS, piece.trimMaterial)) {
config[slot].trimMaterial = piece.trimMaterial
}
}
} catch {
// Invalid or unavailable local storage falls back to a fresh preview.
}
return config
}
export function saveSkinArmorPreview(config: ArmorPreviewConfig): void {
try {
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(config))
} catch {
// The preview remains usable when local storage is unavailable.
}
}
+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
+55 -1
View File
@@ -11431,5 +11431,59 @@
},
"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"
}
}
}
+13 -2
View File
@@ -11351,5 +11351,16 @@
"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": "暂无公告" }
}
+143 -29
View File
@@ -9,9 +9,13 @@ import {
SpinnerIcon,
} from '@modrinth/assets'
import {
type ArmorPreviewConfig,
ArmorPreviewControls,
ButtonStyled,
cloneArmorPreviewConfig,
commonMessages,
ConfirmModal,
createDefaultArmorPreviewConfig,
defineMessages,
injectNotificationManager,
NavTabs,
@@ -24,12 +28,14 @@ import { invoke } from '@tauri-apps/api/core'
import type { DragDropEvent } from '@tauri-apps/api/webview'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { computedAsync } from '@vueuse/core'
import type { Ref } from 'vue'
import type { ComponentPublicInstance, Ref } from 'vue'
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import ArmorTrimTab from '@/components/ui/skin/ArmorTrimTab.vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import SkinListSkeleton from '@/components/ui/skin/SkinListSkeleton.vue'
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
import {
openSkinSiteLogin,
@@ -45,6 +51,7 @@ import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { check_reachable, get_default_user, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
import { loadSkinArmorPreview, saveSkinArmorPreview } from '@/helpers/skin-armor-preview'
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
import {
determineModelType,
@@ -252,6 +259,8 @@ const editSkinModal = useTemplateRef('editSkinModal')
const addSkinFileInput = useTemplateRef<HTMLInputElement>('addSkinFileInput')
const skinSectionList = useTemplateRef<VirtualSkinSectionListExpose>('skinSectionList')
const skinPreviewArea = useTemplateRef<HTMLElement>('skinPreviewArea')
const armorTrimTab = useTemplateRef<ComponentPublicInstance>('armorTrimTab')
const armorPreviewControls = useTemplateRef<ComponentPublicInstance>('armorPreviewControls')
const { formatMessage } = useVIntl()
const router = useRouter()
@@ -391,7 +400,8 @@ const isAddSkinButtonDragActive = ref(false)
const deleteSkinModal = ref()
const skinToDelete = ref<Skin | null>(null)
const skinListTab = ref<'saved' | 'default'>('saved')
const lastSkinListTab = ref<'saved' | 'default'>('saved')
const skinListTab = ref<'saved' | 'default' | 'armor'>('saved')
const skinListTabLinks = computed(() => [
{
@@ -403,6 +413,70 @@ const skinListTabLinks = computed(() => [
label: formatMessage(messages.defaultTab),
},
])
const ARMOR_TRIM_PANEL_ID = 'skins-armor-trim-panel'
const savedArmorPreview = ref<ArmorPreviewConfig>(loadSkinArmorPreview())
const armorPreviewConfig = ref<ArmorPreviewConfig>(cloneArmorPreviewConfig(savedArmorPreview.value))
function saveArmorPreview() {
savedArmorPreview.value = cloneArmorPreviewConfig(armorPreviewConfig.value)
saveSkinArmorPreview(savedArmorPreview.value)
}
function resetArmorPreviewToSaved() {
armorPreviewConfig.value = cloneArmorPreviewConfig(savedArmorPreview.value)
}
function restoreDefaultArmorPreview() {
armorPreviewConfig.value = createDefaultArmorPreviewConfig()
}
/** A third tab behind the two list tabs, opened from the preview's action row. */
function openArmorTab() {
if (skinListTab.value !== 'armor') lastSkinListTab.value = skinListTab.value
skinListTab.value = 'armor'
}
/**
* Whether an element belongs to the armour surface: somewhere inside the open
* armour tab or the button that toggles it. Only those hold a focus ring that
* would be left behind when the tab closes.
*/
function isArmorPreviewElement(element: Element) {
const tabElement: unknown = armorTrimTab.value?.$el
const toggleElement: unknown = armorPreviewControls.value?.$el
return (
(tabElement instanceof Element && tabElement.contains(element)) ||
(toggleElement instanceof Element && toggleElement.contains(element))
)
}
function closeArmorTab() {
// Leaving the tab drops whatever was not saved on purpose.
resetArmorPreviewToSaved()
skinListTab.value = lastSkinListTab.value
// Escape and the toggle both leave the armour button holding focus, which
// keeps its focus ring drawn until something else is clicked. Only drop that
// ring when the focus sits on the armour surface, never on an unrelated
// control that happened to hold it when Escape was pressed.
const focused = document.activeElement
if (focused instanceof HTMLElement && isArmorPreviewElement(focused)) focused.blur()
}
/**
* The tab bar keeps the last list tab highlighted while the armour tab shows, so
* picking one of them leaves the armour tab. That transition has to drop the
* draft exactly like the toggle and Escape paths do, or it lingers on the model
* while the panel is gone.
*/
function selectSkinListTab(index: number) {
if (skinListTab.value === 'armor') resetArmorPreviewToSaved()
lastSkinListTab.value = index === 0 ? 'saved' : 'default'
skinListTab.value = lastSkinListTab.value
}
function confirmDeleteSkin(skin: Skin) {
if (isSkinManagementReadOnly.value) return
@@ -1211,6 +1285,9 @@ await loadSkins()
class="ml-5 mt-4 flex h-[calc(80vh-1rem)] items-center justify-center max-[700px]:h-[calc(50vh-1rem)]"
>
<SkinPreviewRenderer
v-model:armor-config="armorPreviewConfig"
armor-preview
:armor-controls="false"
:cape-src="capeTexture"
:texture-src="skinTexture || ''"
:variant="skinVariant"
@@ -1257,6 +1334,16 @@ await loadSkins()
<EditIcon />
{{ formatMessage(messages.editSkinButton) }}
</button>
<ArmorPreviewControls
v-if="!isSkinSiteProfile"
ref="armorPreviewControls"
v-model="armorPreviewConfig"
panel="external"
:panel-id="ARMOR_TRIM_PANEL_ID"
:open="skinListTab === 'armor'"
@open="openArmorTab"
@close="closeArmorTab"
/>
</template>
</SkinPreviewRenderer>
</div>
@@ -1265,14 +1352,10 @@ await loadSkins()
<div class="pt-2">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<NavTabs
:active-index="skinListTab === 'saved' ? 0 : 1"
:active-index="lastSkinListTab === 'saved' ? 0 : 1"
:links="skinListTabLinks"
mode="local"
@tab-click="
(index: number) => {
skinListTab = index === 0 ? 'saved' : 'default'
}
"
@tab-click="selectSkinListTab"
/>
<ButtonStyled v-if="!isSkinSiteProfile" color="brand">
<button @click="router.push('/lab/skin-editor')">
@@ -1281,27 +1364,45 @@ await loadSkins()
</button>
</ButtonStyled>
</div>
<VirtualSkinSectionList
ref="skinSectionList"
:active-tab="skinListTab"
:saved-skins="savedSkins"
:default-skin-sections="defaultSkinSections"
:get-baked-skin-textures="getBakedSkinTextures"
:is-skin-selected="isSkinSelected"
:is-skin-active="isSkinActive"
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
:read-only="isSkinManagementReadOnly"
:manage-saved-skins="!isSkinSiteProfile"
@select="changeSkin"
@edit="(skin, event) => !isSkinSiteProfile && editSkinModal?.show(event, skin)"
@delete="confirmDeleteSkin"
@reorder-saved-skins="reorderSavedSkins"
@add-skin="openAddSkinFileBrowser"
@add-skin-dragenter="onAddSkinDragOver"
@add-skin-dragover="onAddSkinDragOver"
@add-skin-dragleave="onAddSkinDragLeave"
@add-skin-drop="onAddSkinDrop"
/>
<Transition name="armor-tab" mode="out-in">
<div v-if="skinListTab !== 'armor'" key="list">
<SkinListSkeleton v-if="isSkinListBuffering" />
<VirtualSkinSectionList
v-show="!isSkinListBuffering"
ref="skinSectionList"
:active-tab="skinListTab"
:saved-skins="savedSkins"
:default-skin-sections="defaultSkinSections"
:get-baked-skin-textures="getBakedSkinTextures"
:is-skin-selected="isSkinSelected"
:is-skin-active="isSkinActive"
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
:read-only="isSkinManagementReadOnly"
:manage-saved-skins="!isSkinSiteProfile"
@select="changeSkin"
@edit="(skin, event) => !isSkinSiteProfile && editSkinModal?.show(event, skin)"
@delete="confirmDeleteSkin"
@reorder-saved-skins="reorderSavedSkins"
@add-skin="openAddSkinFileBrowser"
@add-skin-dragenter="onAddSkinDragOver"
@add-skin-dragover="onAddSkinDragOver"
@add-skin-dragleave="onAddSkinDragLeave"
@add-skin-drop="onAddSkinDrop"
/>
</div>
<ArmorTrimTab
v-else
ref="armorTrimTab"
key="armor"
v-model="armorPreviewConfig"
:panel-id="ARMOR_TRIM_PANEL_ID"
:saved="savedArmorPreview"
@save="saveArmorPreview"
@reset="resetArmorPreviewToSaved"
@defaults="restoreDefaultArmorPreview"
@close="closeArmorTab"
/>
</Transition>
</div>
</div>
@@ -1382,4 +1483,17 @@ await loadSkins()
grid-template-columns: 1fr;
}
}
// The armour tab crossfades with the skin list it replaces.
.armor-tab-enter-active,
.armor-tab-leave-active {
transition:
opacity 180ms ease,
transform 180ms ease;
}
.armor-tab-enter-from,
.armor-tab-leave-to {
opacity: 0;
transform: translateY(0.5rem);
}
</style>
+9 -1
View File
@@ -333,6 +333,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 +537,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 +553,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
+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';
+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,
),
+2
View File
@@ -400,6 +400,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 +417,7 @@ pub async fn emit_process(
uuid,
pid,
maximize_window,
window_title,
event,
message: message.to_string(),
crashed,
+2
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")]
+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,6 +1,5 @@
use crate::api::pack::import::{
ImportLauncherType,
direct_link::resolve_direct_link,
ImportLauncherType, direct_link::resolve_direct_link,
};
use crate::launcher::ExternalGameDirMode;
use crate::state::instances::{
@@ -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,7 +1,7 @@
use super::create_direct_link_instance::create_direct_link_instance;
use crate::api::pack::import::direct_link::{
detect_direct_link_source,
has_minecraft_version_manifest, resolve_direct_link,
detect_direct_link_source, has_minecraft_version_manifest,
resolve_direct_link,
};
use crate::event::{InstancePayloadType, emit::emit_instance};
use crate::launcher::ExternalGameDirMode;
@@ -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,
};
+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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 251 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more