From c718d75774be64fe6c7d6e82c2568d07fc9bca24 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sat, 10 Oct 2026 21:02:34 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=20Minecraft=20=E7=AA=97=E5=8F=A3=E6=A0=87?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全局开关 + 默认标题,实例可覆盖;Win32 重命名窗口。前端设置页同步。 --- .../ui/instance_settings/WindowSettings.vue | 39 ++++++++- .../ui/settings/DefaultInstanceSettings.vue | 50 ++++++++++++ apps/app-frontend/src/helpers/types.d.ts | 3 + .../app-frontend/src/locales/en-US/index.json | 24 ++++++ .../app-frontend/src/locales/zh-CN/index.json | 10 ++- apps/app/src/lightweight_mode.rs | 80 ++++++++++++++++++- .../20261004000000_custom-window-title.sql | 2 + packages/app-lib/src/api/instance/run.rs | 12 +++ packages/app-lib/src/event/emit.rs | 2 + packages/app-lib/src/event/mod.rs | 2 + packages/app-lib/src/launcher/mod.rs | 2 + .../state/instances/commands/edit_instance.rs | 9 +++ .../src/state/instances/model/launch.rs | 7 ++ packages/app-lib/src/state/process.rs | 5 ++ packages/app-lib/src/state/settings.rs | 30 +++++++ 15 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 packages/app-lib/migrations/20261004000000_custom-window-title.sql diff --git a/apps/app-frontend/src/components/ui/instance_settings/WindowSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/WindowSettings.vue index a7d8968..c28b576 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/WindowSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/WindowSettings.vue @@ -36,24 +36,29 @@ const fullscreenSetting: Ref = 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...', + }, }) @@ -200,5 +217,25 @@ const messages = defineMessages({ :placeholder="formatMessage(messages.enterHeight)" /> +
+
+

+ {{ formatMessage(messages.windowTitle) }} +

+

+ {{ formatMessage(messages.windowTitleDescription) }} +

+
+ +
diff --git a/apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue b/apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue index fc73d9c..68c5089 100644 --- a/apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue +++ b/apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue @@ -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( /> + + + + + + + + + + diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts index 22a28bb..f05cc8f 100644 --- a/apps/app-frontend/src/helpers/types.d.ts +++ b/apps/app-frontend/src/helpers/types.d.ts @@ -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 diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index ec78130..399d63b 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -11431,5 +11431,29 @@ }, "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..." } } diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index 8760bfe..edb61ed 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -11351,5 +11351,13 @@ "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": "输入默认窗口标题..." } } diff --git a/apps/app/src/lightweight_mode.rs b/apps/app/src/lightweight_mode.rs index e5772b4..7887e16 100644 --- a/apps/app/src/lightweight_mode.rs +++ b/apps/app/src/lightweight_mode.rs @@ -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, event: String, crashed: Option, #[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 = 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( diff --git a/packages/app-lib/migrations/20261004000000_custom-window-title.sql b/packages/app-lib/migrations/20261004000000_custom-window-title.sql new file mode 100644 index 0000000..5e361a7 --- /dev/null +++ b/packages/app-lib/migrations/20261004000000_custom-window-title.sql @@ -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'; \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/run.rs b/packages/app-lib/src/api/instance/run.rs index b32fcb8..d5ebea2 100644 --- a/packages/app-lib/src/api/instance/run.rs +++ b/packages/app-lib/src/api/instance/run.rs @@ -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, diff --git a/packages/app-lib/src/event/emit.rs b/packages/app-lib/src/event/emit.rs index 9b0f15a..96e21c5 100644 --- a/packages/app-lib/src/event/emit.rs +++ b/packages/app-lib/src/event/emit.rs @@ -400,6 +400,7 @@ pub async fn emit_process( uuid: Uuid, pid: u32, maximize_window: bool, + window_title: Option, event: ProcessPayloadType, message: &str, crashed: Option, @@ -416,6 +417,7 @@ pub async fn emit_process( uuid, pid, maximize_window, + window_title, event, message: message.to_string(), crashed, diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs index a12e48f..fc4b130 100644 --- a/packages/app-lib/src/event/mod.rs +++ b/packages/app-lib/src/event/mod.rs @@ -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, pub event: ProcessPayloadType, pub message: String, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs index dd2f3be..4637aa1 100644 --- a/packages/app-lib/src/launcher/mod.rs +++ b/packages/app-lib/src/launcher/mod.rs @@ -1520,6 +1520,7 @@ pub async fn launch_minecraft( memory: &MemorySettings, resolution: &WindowSize, maximize_window: bool, + window_title: Option, credentials: &Credentials, post_exit_hook: Option, 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(), diff --git a/packages/app-lib/src/state/instances/commands/edit_instance.rs b/packages/app-lib/src/state/instances/commands/edit_instance.rs index e2af145..d6de374 100644 --- a/packages/app-lib/src/state/instances/commands/edit_instance.rs +++ b/packages/app-lib/src/state/instances/commands/edit_instance.rs @@ -103,6 +103,12 @@ pub struct InstanceLaunchOverridesPatch { skip_serializing_if = "Option::is_none", with = "serde_with::rust::double_option" )] + pub window_title: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + )] pub launch_preparation_timeout: Option>, pub hooks: Option, } @@ -371,6 +377,9 @@ 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); } diff --git a/packages/app-lib/src/state/instances/model/launch.rs b/packages/app-lib/src/state/instances/model/launch.rs index 848866b..8d01a81 100644 --- a/packages/app-lib/src/state/instances/model/launch.rs +++ b/packages/app-lib/src/state/instances/model/launch.rs @@ -38,6 +38,8 @@ pub struct InstanceLaunchOverrides { pub maximize_window: Option, pub game_resolution: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub window_title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub launch_preparation_timeout: Option, 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, #[serde(default, skip_serializing_if = "Option::is_none")] + pub window_title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub launch_preparation_timeout: Option, #[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(), } diff --git a/packages/app-lib/src/state/process.rs b/packages/app-lib/src/state/process.rs index adf5a7d..bd34aa6 100644 --- a/packages/app-lib/src/state/process.rs +++ b/packages/app-lib/src/state/process.rs @@ -312,6 +312,7 @@ impl ProcessManager { mut mc_command: Command, post_exit_command: Option, maximize_window: bool, + window_title: Option, 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, 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), diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs index 8ded758..5e75252 100644 --- a/packages/app-lib/src/state/settings.rs +++ b/packages/app-lib/src/state/settings.rs @@ -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,16 @@ 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) From e36329ff0ecdc8b7b277aff8fe38df4c29132ee1 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sat, 10 Oct 2026 21:30:15 +0800 Subject: [PATCH 2/6] =?UTF-8?q?style:=20=E7=AA=97=E5=8F=A3=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E6=94=B9=E5=8A=A8=E7=9A=84=20cargo=20fmt=20=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/app/src/lightweight_mode.rs | 4 ++-- .../src/state/instances/commands/edit_instance.rs | 3 ++- packages/app-lib/src/state/settings.rs | 10 ++++++---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/app/src/lightweight_mode.rs b/apps/app/src/lightweight_mode.rs index 7887e16..90db111 100644 --- a/apps/app/src/lightweight_mode.rs +++ b/apps/app/src/lightweight_mode.rs @@ -453,7 +453,8 @@ async fn rename_minecraft_window(pid: u32, title: &str) { unsafe { use windows::Win32::Foundation::LPARAM; use windows::Win32::UI::WindowsAndMessaging::EnumWindows; - let _ = EnumWindows(Some(rename_if_owned_by_process), LPARAM(0)); + let _ = + EnumWindows(Some(rename_if_owned_by_process), LPARAM(0)); } RENAME_WINDOW_HANDLE.load(Ordering::Relaxed) }; @@ -478,7 +479,6 @@ async fn rename_minecraft_window(pid: u32, title: &str) { #[cfg(not(target_os = "windows"))] async fn rename_minecraft_window(_pid: u32, _title: &str) {} - #[tauri::command] pub fn lightweight_mode_frontend_ready( app: AppHandle, diff --git a/packages/app-lib/src/state/instances/commands/edit_instance.rs b/packages/app-lib/src/state/instances/commands/edit_instance.rs index d6de374..881efd7 100644 --- a/packages/app-lib/src/state/instances/commands/edit_instance.rs +++ b/packages/app-lib/src/state/instances/commands/edit_instance.rs @@ -378,7 +378,8 @@ fn apply_launch_overrides_patch( overrides.game_resolution = game_resolution; } if let Some(window_title) = patch.window_title { - overrides.window_title = window_title.filter(|value| !value.trim().is_empty()); + overrides.window_title = + window_title.filter(|value| !value.trim().is_empty()); } if let Some(mode) = patch.instance_mode { overrides.instance_mode = Some(mode); diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs index 5e75252..b309fd7 100644 --- a/packages/app-lib/src/state/settings.rs +++ b/packages/app-lib/src/state/settings.rs @@ -689,10 +689,12 @@ impl Settings { .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 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) From 2477fc62cabbce830b847a8cefc0fdf59adead26 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sat, 10 Oct 2026 21:48:20 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E8=A1=A5=E7=AA=97=E5=8F=A3=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E5=AD=97=E6=AE=B5=E7=BC=BA=E5=A4=B1=E7=9A=84=E6=9E=84?= =?UTF-8?q?=E9=80=A0=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修 E0063:InstanceLaunchOverridesPatch 和 InstanceLaunchOverrides 的两处完整构造漏了 window_title。 --- packages/app-lib/src/api/pack/import/axolotl.rs | 1 + packages/app-lib/src/state/legacy_converter.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/app-lib/src/api/pack/import/axolotl.rs b/packages/app-lib/src/api/pack/import/axolotl.rs index c6f0f7e..c24b512 100644 --- a/packages/app-lib/src/api/pack/import/axolotl.rs +++ b/packages/app-lib/src/api/pack/import/axolotl.rs @@ -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, ), diff --git a/packages/app-lib/src/state/legacy_converter.rs b/packages/app-lib/src/state/legacy_converter.rs index ebe4902..c82ce26 100644 --- a/packages/app-lib/src/state/legacy_converter.rs +++ b/packages/app-lib/src/state/legacy_converter.rs @@ -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, }; From 1406f7ded3ac4475e3c077fff10e3f5412395e42 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sat, 10 Oct 2026 22:01:42 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E8=A1=A5=20ProcessMetadata=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=9E=84=E9=80=A0=E7=82=B9=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E7=9A=84=20window=5Ftitle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/app-lib/src/state/process.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app-lib/src/state/process.rs b/packages/app-lib/src/state/process.rs index bd34aa6..5ca6ab4 100644 --- a/packages/app-lib/src/state/process.rs +++ b/packages/app-lib/src/state/process.rs @@ -1456,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(), From 7f3ab9d79b75e7af9ee6f213a5cc5f6bfa9d3bb4 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sat, 10 Oct 2026 22:23:49 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E8=A1=A5=20EditInstance=20DTO=20?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E7=9A=84=20window=5Ftitle=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E4=B8=8E=E9=80=8F=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/app/src/api/instance/mod.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/app/src/api/instance/mod.rs b/apps/app/src/api/instance/mod.rs index 83e4f38..ea81611 100644 --- a/apps/app/src/api/instance/mod.rs +++ b/apps/app/src/api/instance/mod.rs @@ -333,6 +333,12 @@ pub struct EditInstance { )] pub launch_preparation_timeout: Option>, pub hooks: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + )] + pub window_title: Option>, #[serde( default, @@ -531,7 +537,8 @@ fn edit_to_core(edit_instance: EditInstance) -> Result { || 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 { launch_preparation_timeout: edit_instance .launch_preparation_timeout, hooks: edit_instance.hooks, + window_title: edit_instance.window_title, }) } else { None From cadb7bbdcda91ae50778fc04422fe990895fa3c8 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sat, 10 Oct 2026 22:34:00 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E7=9A=AE?= =?UTF-8?q?=E8=82=A4=E7=AB=99=E5=85=AC=E5=91=8A=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ui/RemoteAnnouncements.vue | 38 ++++++- .../src/helpers/remote-announcements.test.ts | 78 +++++++++++++ .../src/helpers/remote-announcements.ts | 104 ++++++++++++++++-- 3 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 apps/app-frontend/src/helpers/remote-announcements.test.ts diff --git a/apps/app-frontend/src/components/ui/RemoteAnnouncements.vue b/apps/app-frontend/src/components/ui/RemoteAnnouncements.vue index 4e23f3c..e357a9a 100644 --- a/apps/app-frontend/src/components/ui/RemoteAnnouncements.vue +++ b/apps/app-frontend/src/components/ui/RemoteAnnouncements.vue @@ -8,12 +8,14 @@ import { 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, @@ -21,6 +23,12 @@ import { 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() @@ -61,7 +69,7 @@ const queuedThisSession = new Set() const startupNotified = new Set() let items: RemoteAnnouncement[] = [] let pending: RemoteAnnouncement[] = [] -let cacheKey = '' +let cacheKey = 'starlight-remote-announcements-cache-v1' let cacheLoaded = false let disposed = false let interval: ReturnType | undefined @@ -202,11 +210,35 @@ function loadCache() { // 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() { - // Remote fetching is disabled until Starlight has its own announcements API. - // We still load any previously cached items so the center has content to show. 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) diff --git a/apps/app-frontend/src/helpers/remote-announcements.test.ts b/apps/app-frontend/src/helpers/remote-announcements.test.ts new file mode 100644 index 0000000..1b82f9f --- /dev/null +++ b/apps/app-frontend/src/helpers/remote-announcements.test.ts @@ -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') +}) diff --git a/apps/app-frontend/src/helpers/remote-announcements.ts b/apps/app-frontend/src/helpers/remote-announcements.ts index 92f7bfe..ddfd85b 100644 --- a/apps/app-frontend/src/helpers/remote-announcements.ts +++ b/apps/app-frontend/src/helpers/remote-announcements.ts @@ -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 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()) { @@ -67,3 +83,73 @@ export function announcementKey(item: RemoteAnnouncement) { 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() + 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), + ) +}