From 739c6e1711624e6a697d6f8ca067cc5b763ab929 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 11 Sep 2026 16:05:21 +0800 Subject: [PATCH] feat(theme): support system appearance across desktop platforms - Use Tauri native theme APIs with Linux desktop preference support - Fall back to WebView theme detection for startup and browser preview - Add a follow-system option to both standard and beginner modes - Persist preferences and handle resume events and async race conditions - Add translations and theme behavior and UI regression tests --- index.html | 16 +- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 + src-tauri/capabilities/default.json | 1 + src-tauri/src/agents/deepseek_harness.rs | 35 +- src-tauri/src/desktop_theme.rs | 57 +++ src-tauri/src/main.rs | 2 + .../src/tests/deepseek_harness_catalog.rs | 130 ++++++- src/App.tsx | 19 +- src/i18n/ja.ts | 3 + src/i18n/locales/en.ts | 3 + src/i18n/locales/zh-CN.ts | 3 + src/i18n/traditional.ts | 3 + src/main.tsx | 4 +- src/pages/EasyModePage.tsx | 23 +- src/services/deepSeekHarnessCatalog.ts | 2 +- src/styles.css | 4 + src/theme.ts | 103 +++-- src/themeController.ts | 157 ++++++++ tests/deepSeekHarnessCatalog.test.ts | 6 + tests/fixtures/theme.html | 5 + tests/fixtures/theme.tsx | 63 +++ tests/i18n.test.ts | 9 + tests/theme-ui.cjs | 85 ++++ tests/theme.test.ts | 367 ++++++++++++++++++ 25 files changed, 1051 insertions(+), 53 deletions(-) create mode 100644 src-tauri/src/desktop_theme.rs create mode 100644 src/themeController.ts create mode 100644 tests/fixtures/theme.html create mode 100644 tests/fixtures/theme.tsx create mode 100644 tests/theme-ui.cjs create mode 100644 tests/theme.test.ts diff --git a/index.html b/index.html index b35e535..075dd72 100644 --- a/index.html +++ b/index.html @@ -5,17 +5,17 @@ EasyCLIProxyAPI diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 03bd51d..144e55e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -597,6 +597,7 @@ version = "0.2.81" dependencies = [ "base64 0.22.1", "chrono", + "dbus", "flate2", "futures-util", "getrandom 0.3.4", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 408697f..da28590 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,6 +41,9 @@ zip = { version = "2", default-features = false, features = ["deflate"] } [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(target_os = "linux")'.dependencies] +dbus = "0.9" + [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSEvent", "NSRunningApplication"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e06f87d..3ddf213 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ "core:event:allow-listen", "core:event:allow-unlisten", "core:window:allow-set-theme", + "core:window:allow-theme", "core:window:allow-set-background-color", "core:webview:allow-set-webview-background-color", "dialog:allow-open" diff --git a/src-tauri/src/agents/deepseek_harness.rs b/src-tauri/src/agents/deepseek_harness.rs index d3259fe..5b0614d 100644 --- a/src-tauri/src/agents/deepseek_harness.rs +++ b/src-tauri/src/agents/deepseek_harness.rs @@ -94,6 +94,36 @@ fn import_changes( let previous = generated.and_then(|fields| fields.get(key)); if current != previous { if let Some(value) = current { + if key == "compat" { + if let Some(fields) = value.as_object() { + let previous = previous.and_then(Value::as_object); + let mut imported = overrides + .get(key) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let keys = fields + .keys() + .chain(previous.into_iter().flat_map(|fields| fields.keys())) + .collect::>(); + for field in keys { + let current = fields.get(field); + if current != previous.and_then(|fields| fields.get(field)) { + if let Some(value) = current { + imported.insert(field.clone(), value.clone()); + } else { + imported.remove(field); + } + } + } + if imported.is_empty() { + overrides.remove(key); + } else { + overrides.insert(key.into(), Value::Object(imported)); + } + continue; + } + } overrides.insert(key.into(), value.clone()); } else { overrides.remove(key); @@ -327,7 +357,7 @@ fn validate_harness_field( "reasoning" => { value == &json!(false) || value.as_object().is_some_and(|values| { - !values.is_empty() + values.keys().any(|key| key != "off") && values.iter().all(|(key, value)| { ["off", "minimal", "low", "medium", "high", "xhigh", "max"] .contains(&key.as_str()) @@ -376,6 +406,9 @@ fn render_harness_profiles( models: &[AgentModelOption], state: &mut DeepSeekHarnessCatalogState, ) -> Result { + if models.is_empty() { + return Err("DSH 模型列表为空,已保留现有配置,请刷新后重试".into()); + } let root = harness_root(existing)?; let old = harness_provider(&root); let api = state diff --git a/src-tauri/src/desktop_theme.rs b/src-tauri/src/desktop_theme.rs new file mode 100644 index 0000000..3bf3299 --- /dev/null +++ b/src-tauri/src/desktop_theme.rs @@ -0,0 +1,57 @@ +#[tauri::command] +pub(crate) async fn get_linux_system_theme() -> Option { + #[cfg(target_os = "linux")] + { + tauri::async_runtime::spawn_blocking(read_portal_theme) + .await + .ok() + .flatten() + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +#[cfg(target_os = "linux")] +fn read_portal_theme() -> Option { + use dbus::{arg::Variant, blocking::Connection}; + use std::time::Duration; + + let connection = Connection::new_session().ok()?; + let proxy = connection.with_proxy( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + Duration::from_secs(1), + ); + let (value,): (Variant>,) = proxy + .method_call( + "org.freedesktop.portal.Settings", + "Read", + ("org.freedesktop.appearance", "color-scheme"), + ) + .ok()?; + portal_color_scheme(value.0 .0) +} + +#[cfg(any(target_os = "linux", test))] +fn portal_color_scheme(value: u32) -> Option { + match value { + 1 => Some(tauri::Theme::Dark), + 2 => Some(tauri::Theme::Light), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn follows_the_freedesktop_color_scheme_values() { + assert_eq!(portal_color_scheme(1), Some(tauri::Theme::Dark)); + assert_eq!(portal_color_scheme(2), Some(tauri::Theme::Light)); + assert_eq!(portal_color_scheme(0), None); + assert_eq!(portal_color_scheme(3), None); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5deaa41..b68b58f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -9,6 +9,7 @@ mod codex_sessions; mod configuration_watcher; mod core_config; mod core_runtime; +mod desktop_theme; mod instance_lock; mod management_api; mod oauth_browser; @@ -2499,6 +2500,7 @@ fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + desktop_theme::get_linux_system_theme, health_check, detect_core_platform, get_core_status, diff --git a/src-tauri/src/tests/deepseek_harness_catalog.rs b/src-tauri/src/tests/deepseek_harness_catalog.rs index 7f32d70..f842007 100644 --- a/src-tauri/src/tests/deepseek_harness_catalog.rs +++ b/src-tauri/src/tests/deepseek_harness_catalog.rs @@ -248,6 +248,54 @@ fn harness_saves_before_connection_and_applies_documented_protocol() { fs::remove_dir_all(home).unwrap(); } +#[test] +fn harness_rejects_empty_catalog_without_modifying_configured_files() { + let home = agent_test_home("harness-empty-catalog"); + apply(&home, &models()); + let mut paths = agent_config_paths(AgentClient::DeepSeekHarness, &home); + paths.push(deepseek_harness_catalog_state_path(&paths).unwrap()); + for selection in [ + root(&home).get("agent-default-model").cloned(), + Some(json!({"provider":"deepseek","model":"deepseek-chat"})), + None, + ] { + let mut document = root(&home); + if let Some(selection) = selection { + document["agent-default-model"] = selection; + } else { + document + .as_object_mut() + .unwrap() + .remove("agent-default-model"); + } + fs::write(&paths[0], serde_norway::to_string(&document).unwrap()).unwrap(); + let before = config_images(&paths).unwrap(); + let mut edit = request(harness_editor_snapshot(&home, &[], 8317).unwrap()); + edit.provider.insert("timeoutMs".into(), json!(60000)); + assert!(save_harness_editor(&home, &[], 8317, edit).is_err()); + assert_eq!(config_images(&paths).unwrap(), before); + assert_eq!(provider(&home)["models"].as_array().unwrap().len(), 3); + } + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn harness_allows_empty_catalog_for_unconnected_provider_drafts() { + let home = agent_test_home("harness-empty-catalog-draft"); + let mut edit = request(harness_editor_snapshot(&home, &[], 8317).unwrap()); + edit.provider.insert("timeoutMs".into(), json!(60000)); + save_harness_editor(&home, &[], 8317, edit).unwrap(); + let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home); + assert!(paths.iter().all(|path| !path.exists())); + assert_eq!( + harness_editor_snapshot(&home, &[], 8317).unwrap().provider["timeoutMs"], + 60000 + ); + apply(&home, &models()); + assert_eq!(provider(&home)["timeoutMs"], 60000); + fs::remove_dir_all(home).unwrap(); +} + #[test] fn harness_rejects_stale_catalog_and_invalid_fields_without_writing() { let home = agent_test_home("harness-catalog-validation"); @@ -260,13 +308,17 @@ fn harness_rejects_stale_catalog_and_invalid_fields_without_writing() { .err() .unwrap() .contains("DSH_MODEL_CATALOG_CHANGED")); - let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home); + let mut paths = agent_config_paths(AgentClient::DeepSeekHarness, &home); + paths.push(deepseek_harness_catalog_state_path(&paths).unwrap()); let before = config_images(&paths).unwrap(); for configuration in [ json!({"contextWindow":0}), json!({"maxTokens":1.5}), json!({"input":["audio"]}), json!({"reasoningEfforts":{"high":null}}), + json!({"reasoningEfforts":{"off":null}}), + json!({"reasoningEfforts":{"off":"none"}}), + json!({"reasoningEfforts":{}}), json!({"compat":{"supportsTemperature":true}}), json!({"compat":{"supportsStore":null}}), json!({"compat":{"vllmPriority":0.5}}), @@ -328,6 +380,82 @@ fn harness_imports_external_edits_and_keeps_default_selection_and_other_settings fs::remove_dir_all(home).unwrap(); } +#[test] +fn harness_imports_compat_changes_without_freezing_other_api_defaults() { + let home = agent_test_home("harness-compat-import"); + let mut models = models(); + models[0].harness_metadata = harness_api_metadata(&json!({"compat":{ + "supportsStore":true,"supportsDeveloperRole":true,"supportsStrictMode":true + }})); + apply(&home, &models); + let mut edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap()); + edit.models[0] + .configuration + .insert("compat".into(), json!({"supportsStrictMode":false})); + save_harness_editor(&home, &models, 8317, edit).unwrap(); + let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home); + let mut document = root(&home); + document["llm-pi-ai"]["providers"][DEEPSEEK_HARNESS_PROVIDER_ID]["models"][0]["compat"] + ["supportsStore"] = json!(false); + fs::write(&paths[0], serde_norway::to_string(&document).unwrap()).unwrap(); + let edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap()); + assert_eq!( + edit.models[0].configuration["compat"], + json!({ + "supportsStore":false,"supportsStrictMode":false + }) + ); + save_harness_editor(&home, &models, 8317, edit).unwrap(); + models[0].harness_metadata = harness_api_metadata(&json!({"compat":{ + "supportsStore":true,"supportsDeveloperRole":false,"supportsStrictMode":true + }})); + apply(&home, &models); + assert_eq!( + provider(&home)["models"][0]["compat"], + json!({ + "supportsStore":false,"supportsDeveloperRole":false,"supportsStrictMode":false + }) + ); + + let mut document = root(&home); + let compat = document["llm-pi-ai"]["providers"][DEEPSEEK_HARNESS_PROVIDER_ID]["models"][0] + ["compat"] + .as_object_mut() + .unwrap(); + compat.remove("supportsStore"); + compat.insert("maxTokensField".into(), json!("max_tokens")); + fs::write(&paths[0], serde_norway::to_string(&document).unwrap()).unwrap(); + let edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap()); + assert_eq!( + edit.models[0].configuration["compat"], + json!({ + "supportsStrictMode":false,"maxTokensField":"max_tokens" + }) + ); + save_harness_editor(&home, &models, 8317, edit).unwrap(); + assert_eq!( + provider(&home)["models"][0]["compat"]["supportsStore"], + true + ); + + let mut document = root(&home); + document["llm-pi-ai"]["providers"][DEEPSEEK_HARNESS_PROVIDER_ID]["models"][0] + .as_object_mut() + .unwrap() + .remove("compat"); + fs::write(&paths[0], serde_norway::to_string(&document).unwrap()).unwrap(); + let edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap()); + assert!(!edit.models[0].configuration.contains_key("compat")); + save_harness_editor(&home, &models, 8317, edit).unwrap(); + assert_eq!( + provider(&home)["models"][0]["compat"], + json!({ + "supportsStore":true,"supportsDeveloperRole":false,"supportsStrictMode":true + }) + ); + fs::remove_dir_all(home).unwrap(); +} + #[test] fn harness_state_failure_rolls_back_settings_and_credentials() { let home = agent_test_home("harness-catalog-rollback"); diff --git a/src/App.tsx b/src/App.tsx index bb26b7c..fb4bb6a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,7 +33,7 @@ import { languageOptions, useI18n } from './i18n'; import { AppUpdateDialog, AppUpdateProvider, useAppUpdate } from './appUpdate'; import { appUpdateIndicatorState } from './appUpdateModel'; import { canOpenAppPage, isAlwaysAvailablePage } from './navigation'; -import { detectInitialTheme, saveTheme, type AppTheme } from './theme'; +import { useThemePreference } from './theme'; const CONTACT_URL = 'https://qm.qq.com/q/3queDaIG'; @@ -128,7 +128,7 @@ function AppContent() { const { latest: coreLatest, hasUpdate: coreHasUpdate } = useCoreUpdate(); const [active, setActive] = useState('home'); const [languageMenuOpen, setLanguageMenuOpen] = useState(false); - const [theme, setTheme] = useState(detectInitialTheme); + const [theme, setTheme] = useThemePreference(); const [windowsClosePrompt, setWindowsClosePrompt] = useState(null); const closeDialogRef = useRef(null); const languageMenuRef = useRef(null); @@ -147,10 +147,6 @@ function AppContent() { ? `${t('kernel.versions.coreCardTitle')}: ${t('kernel.update.available')} ${coreLatest?.version ?? ''}`.trim() : '', ].filter(Boolean).join(' · '); - useEffect(() => { - saveTheme(theme); - }, [theme]); - useEffect(() => { if (!canOpenAppPage(active, coreRunning)) { setActive('home'); @@ -344,7 +340,7 @@ function AppContent() {
+
+
) : null} diff --git a/src/services/deepSeekHarnessCatalog.ts b/src/services/deepSeekHarnessCatalog.ts index 12edbb8..cecf0cd 100644 --- a/src/services/deepSeekHarnessCatalog.ts +++ b/src/services/deepSeekHarnessCatalog.ts @@ -82,7 +82,7 @@ export function parseHarnessDraft(draft: HarnessDraft, group: string, api: strin if (['modalities', 'strings'].includes(field.kind)) valid = Array.isArray(value) && (value.length > 0 || field.name === 'input') && value.every(v => typeof v === 'string' && v.trim() && (field.kind !== 'modalities' || ['text', 'image'].includes(v))) && new Set(value).size === value.length; - if (field.kind === 'reasoning') valid = value === false || (isHarnessRecord(value) && Object.keys(value).length > 0 + if (field.kind === 'reasoning') valid = value === false || (isHarnessRecord(value) && Object.keys(value).some(level => level !== 'off') && Object.entries(value).every(([level, wire]) => harnessReasoningLevels.includes(level) && ((level === 'off' && wire === null) || (typeof wire === 'string' && wire.trim())))); if (field.kind === 'headers') valid = isHarnessRecord(value) diff --git a/src/styles.css b/src/styles.css index d1e6955..c71a66d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -660,6 +660,10 @@ textarea:focus-visible { white-space: nowrap; } +.sidebar-theme-selector button:last-child { + grid-column: 1 / -1; +} + .sidebar-theme-selector button:hover { background: var(--theme-e8e5dc); color: var(--theme-2d2a26); diff --git a/src/theme.ts b/src/theme.ts index f1be694..05437b7 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -1,7 +1,10 @@ +import { useSyncExternalStore } from 'react'; +import { invoke, isTauri } from '@tauri-apps/api/core'; import { getCurrentWebview } from '@tauri-apps/api/webview'; import { getCurrentWindow } from '@tauri-apps/api/window'; +import { createThemeController, type AppTheme, type ThemePreference } from './themeController'; -export type AppTheme = 'light' | 'dark'; +export type { AppTheme, ThemePreference } from './themeController'; const STORAGE_KEY = 'easy-cli-proxy-api.theme'; const WINDOW_BACKGROUND: Record = { @@ -9,46 +12,88 @@ const WINDOW_BACKGROUND: Record = { dark: '#111412', }; -async function applyNativeTheme(theme: AppTheme): Promise { - const background = WINDOW_BACKGROUND[theme]; - const currentWindow = getCurrentWindow(); - const currentWebview = getCurrentWebview(); - - await Promise.allSettled([ - currentWindow.setTheme(theme), - currentWindow.setBackgroundColor(background), - currentWebview.setBackgroundColor(background), - ]); -} - -export function detectInitialTheme(): AppTheme { - if (typeof window === 'undefined') return 'light'; - +export function detectThemePreference(): ThemePreference { try { const saved = window.localStorage.getItem(STORAGE_KEY); - if (saved === 'light' || saved === 'dark') return saved; + if (saved === 'light' || saved === 'dark' || saved === 'system') return saved; } catch { - // Storage may be unavailable in a restricted WebView; use the OS preference. } - - return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + return 'system'; } -export function applyTheme(theme: AppTheme): void { +function applyTheme(theme: AppTheme): void { document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = theme; document.documentElement.style.backgroundColor = WINDOW_BACKGROUND[theme]; if (document.body) { document.body.style.backgroundColor = WINDOW_BACKGROUND[theme]; } - void applyNativeTheme(theme); } -export function saveTheme(theme: AppTheme): void { - applyTheme(theme); - try { - window.localStorage.setItem(STORAGE_KEY, theme); - } catch { - // The in-memory theme still works when persistent storage is unavailable. - } +let controller: ReturnType | undefined; + +export function initializeTheme() { + if (controller) return controller; + const media = window.matchMedia?.('(prefers-color-scheme: dark)'); + controller = createThemeController({ + readPreference: detectThemePreference, + savePreference(preference) { + try { + window.localStorage.setItem(STORAGE_KEY, preference); + } catch { + } + }, + readMediaTheme: () => media?.matches ? 'dark' : 'light', + listenMedia(listener) { + if (!media) return () => {}; + if (typeof media.addEventListener === 'function') { + media.addEventListener('change', listener); + return () => media.removeEventListener('change', listener); + } + media.addListener(listener); + return () => media.removeListener(listener); + }, + listenResume(listener) { + const visible = () => { if (document.visibilityState === 'visible') listener(); }; + window.addEventListener('focus', listener); + document.addEventListener('visibilitychange', visible); + return () => { + window.removeEventListener('focus', listener); + document.removeEventListener('visibilitychange', visible); + }; + }, + applyTheme, + async connectNative() { + if (!isTauri()) return null; + const currentWindow = getCurrentWindow(); + const currentWebview = getCurrentWebview(); + const { os } = await invoke<{ os: string }>('detect_core_platform'); + return { + explicitSystemAppearance: os === 'linux', + setTheme: (theme) => currentWindow.setTheme(theme), + readTheme: () => os === 'linux' + ? invoke('get_linux_system_theme') + : currentWindow.theme(), + listen: (listener) => currentWindow.onThemeChanged(({ payload }) => listener(payload)), + async setBackground(theme) { + const background = WINDOW_BACKGROUND[theme]; + await Promise.allSettled([ + currentWindow.setBackgroundColor(background), + currentWebview.setBackgroundColor(background), + ]); + }, + }; + }, + }); + return controller; +} + +export function useThemePreference() { + const themeController = initializeTheme(); + const preference = useSyncExternalStore(themeController.subscribe, themeController.getPreference); + return [preference, themeController.setPreference] as const; +} + +if (import.meta.hot) { + import.meta.hot.dispose(() => controller?.dispose()); } diff --git a/src/themeController.ts b/src/themeController.ts new file mode 100644 index 0000000..52a1303 --- /dev/null +++ b/src/themeController.ts @@ -0,0 +1,157 @@ +export type AppTheme = 'light' | 'dark'; +export type ThemePreference = AppTheme | 'system'; +type Unlisten = () => void; + +export interface NativeThemeSource { + setTheme(theme: AppTheme | null): Promise; + readTheme(): Promise; + listen(listener: (theme: AppTheme) => void): Promise; + setBackground(theme: AppTheme): Promise; + explicitSystemAppearance: boolean; +} + +export interface ThemeEnvironment { + readPreference(): ThemePreference; + savePreference(preference: ThemePreference): void; + readMediaTheme(): AppTheme; + listenMedia(listener: () => void): Unlisten; + listenResume(listener: () => void): Unlisten; + applyTheme(theme: AppTheme): void; + connectNative(): Promise; +} + +export function createThemeController(environment: ThemeEnvironment) { + let preference = environment.readPreference(); + let native: NativeThemeSource | null = null; + let disposed = false; + let generation = 0; + let observation = 0; + let configuring = false; + let nativeAppearance: AppTheme | null | undefined; + let queue = Promise.resolve(); + const subscribers = new Set(); + const cleanup: Unlisten[] = []; + const current = (version: number) => !disposed && version === generation; + const apply = (theme: AppTheme) => environment.applyTheme(theme); + + function enqueue(work: () => Promise) { + queue = queue.then(work).catch(() => { + }); + } + + async function background(theme: AppTheme, version: number) { + if (current(version) && native) await native.setBackground(theme); + } + + async function readSystem(version: number) { + if (!current(version) || preference !== 'system' || !native) return; + const reading = ++observation; + const theme = await native.readTheme().catch(() => null); + if (!current(version) || reading !== observation) return; + const resolved = theme ?? environment.readMediaTheme(); + apply(resolved); + if (theme && native.explicitSystemAppearance && nativeAppearance !== theme) { + configuring = true; + try { + nativeAppearance = theme; + await native.setTheme(theme); + } catch { + nativeAppearance = undefined; + } finally { + configuring = false; + } + if (nativeAppearance && nativeAppearance !== theme) refreshSystem(); + } + await background(resolved, version); + } + + function refreshSystem() { + if (disposed || preference !== 'system') return; + if (!native) { + apply(environment.readMediaTheme()); + return; + } + const version = generation; + enqueue(() => readSystem(version)); + } + + function configureNative() { + const version = generation; + enqueue(async () => { + if (!current(version) || !native) return; + configuring = true; + try { + nativeAppearance = preference === 'system' ? null : preference; + await native.setTheme(nativeAppearance); + } catch { + nativeAppearance = undefined; + } finally { + configuring = false; + } + if (!current(version)) return; + if (preference === 'system') await readSystem(version); + else { + if (nativeAppearance && nativeAppearance !== preference) configureNative(); + await background(preference, version); + } + }); + } + + function nativeChanged(theme: AppTheme) { + if (disposed || !native) return; + nativeAppearance = theme; + if (configuring) return; + if (preference !== 'system') { + if (theme !== preference) configureNative(); + return; + } + if (native.explicitSystemAppearance) { + refreshSystem(); + return; + } + ++observation; + apply(theme); + const version = generation; + enqueue(() => background(theme, version)); + } + + apply(preference === 'system' ? environment.readMediaTheme() : preference); + cleanup.push(environment.listenMedia(refreshSystem), environment.listenResume(refreshSystem)); + void environment.connectNative().then(async (source) => { + if (disposed || !source) return; + native = source; + try { + const unlisten = await source.listen(nativeChanged); + if (disposed) unlisten(); + else cleanup.push(unlisten); + } catch { + } + if (!disposed) configureNative(); + }).catch(() => { + }); + + return { + getPreference: () => preference, + subscribe(listener: Unlisten) { + subscribers.add(listener); + return () => { subscribers.delete(listener); }; + }, + setPreference(next: ThemePreference) { + if (disposed || preference === next) return; + preference = next; + ++generation; + ++observation; + environment.savePreference(next); + if (next !== 'system') apply(next); + else if (!native) apply(environment.readMediaTheme()); + configureNative(); + subscribers.forEach((listener) => listener()); + }, + dispose() { + disposed = true; + ++generation; + cleanup.forEach((unlisten) => unlisten()); + subscribers.clear(); + }, + }; +} diff --git a/tests/deepSeekHarnessCatalog.test.ts b/tests/deepSeekHarnessCatalog.test.ts index a7f2fd5..5d03558 100644 --- a/tests/deepSeekHarnessCatalog.test.ts +++ b/tests/deepSeekHarnessCatalog.test.ts @@ -21,6 +21,12 @@ describe('DSH catalog overrides', () => { expect(() => parseHarnessDraft({ reasoningEfforts: '{"high":null}' }, 'model', 'openai-completions')).toThrow(); expect(() => parseHarnessDraft({ reasoningEfforts: '{}' }, 'model', 'openai-completions')).toThrow(); }); + test('reasoning maps require a thinking level beyond off', () => { + for (const reasoningEfforts of ['{"off":null}', '{"off":"none"}']) { + expect(() => parseHarnessDraft({ reasoningEfforts }, 'model', 'openai-completions')).toThrow('reasoningEfforts'); + } + expect(parseHarnessDraft({ reasoningEfforts: '{"high":"high"}' }, 'model', 'openai-completions')).toEqual({ reasoningEfforts: { high: 'high' } }); + }); test('invalid numbers, JSON, headers and incompatible protocol switches are rejected', () => { for (const draft of [{ maxTokens: '-1' }, { input: '["audio"]' }, { contextWindow: '2.5' }, { 'compat.supportsStore': 'null' }, { 'compat.supportsTemperature': 'true' }]) { expect(() => parseHarnessDraft(draft, 'model', 'openai-completions')).toThrow(); diff --git a/tests/fixtures/theme.html b/tests/fixtures/theme.html new file mode 100644 index 0000000..8bf1f94 --- /dev/null +++ b/tests/fixtures/theme.html @@ -0,0 +1,5 @@ + + + +
+ diff --git a/tests/fixtures/theme.tsx b/tests/fixtures/theme.tsx new file mode 100644 index 0000000..c831246 --- /dev/null +++ b/tests/fixtures/theme.tsx @@ -0,0 +1,63 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { mockIPC, mockWindows } from '@tauri-apps/api/mocks'; +import { emit } from '@tauri-apps/api/event'; +import App from '../../src/App'; +import { I18nProvider } from '../../src/i18n'; +import { initializeTheme } from '../../src/theme'; +import type { AppTheme } from '../../src/themeController'; +import '../../src/styles.css'; + +const params = new URLSearchParams(location.search); +const platform = params.get('platform') || 'windows'; +const storageKey = 'easy-cli-proxy-api.theme'; +if (params.has('reset')) localStorage.removeItem(storageKey); +if (params.has('saved')) localStorage.setItem(storageKey, params.get('saved')!); +localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') || 'zh-CN'); +let system: AppTheme = params.get('system') === 'light' ? 'light' : 'dark'; +let override: AppTheme | null = null; +let appearance: AppTheme = system; +const calls: { cmd: string; args: unknown }[] = []; +const notify = (theme: AppTheme) => emit('tauri://theme-changed', theme); +mockWindows('main'); +mockIPC(async (cmd, args: any) => { + calls.push({ cmd, args }); + if (cmd === 'detect_core_platform') return { os: platform, arch: 'x86_64' }; + if (cmd === 'get_linux_system_theme') return params.has('no-portal') ? null : system; + if (cmd === 'plugin:window|theme') return override ?? system; + if (cmd === 'plugin:window|set_theme') { + override = args.value ?? null; + appearance = override ?? (platform === 'linux' ? 'light' : system); + const changed = appearance; + setTimeout(() => void notify(changed), 0); + return; + } + if (cmd.includes('background_color') || cmd === 'set_app_locale') return; + if (cmd === 'plugin:app|version') return '0.0.0'; + if (cmd === 'get_core_status') return { installed: false, running: false, starting: false, managed: false }; + if (cmd === 'get_gui_settings') return { host: '127.0.0.1', port: 8317, closeBehavior: 'ask' }; + if (cmd === 'get_core_config_settings') return { apiKeys: [] }; + if (cmd === 'get_core_tls_settings') return { enabled: false }; + if (cmd === 'check_app_update') return { updateAvailable: false, currentVersion: '0.0.0' }; + if (cmd === 'get_app_update_task') return { running: false, phase: 'idle' }; + if (cmd === 'check_latest_core') return { version: '0.0.0' }; + throw new Error(`Theme fixture does not implement ${cmd}`); +}, { shouldMockEvents: true }); +Object.assign(window, { + isTauri: platform !== 'browser', + themeFixture: { + calls, + get appearance() { return appearance; }, + async setSystem(theme: AppTheme) { + system = theme; + if (platform === 'linux' || override === null) { + appearance = theme; + await notify(theme); + } + }, + }, +}); +initializeTheme(); +createRoot(document.getElementById('root')!).render( + , +); diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index 29c0f2f..ae24720 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -4,6 +4,15 @@ import { en, ja, zhCN, zhTW, type MessageKey } from '../src/i18n/resources'; import { jaOverrides } from '../src/i18n/ja'; describe('i18n', () => { + it('localizes system appearance controls in every language', () => { + expect(translate('zh-CN', 'app.theme.system')).toBe('跟随系统'); + expect(translate('zh-TW', 'app.theme.system')).toBe('跟隨系統'); + expect(translate('zh-TW', 'app.theme.label')).toBe('外觀模式'); + expect(translate('zh-TW', 'app.theme.switchToSystem')).toBe('隨系統自動切換日間和夜間模式'); + expect(translate('en', 'app.theme.system')).toBe('Follow system'); + expect(translate('ja', 'app.theme.system')).toBe('システムに従う'); + }); + it('uses credential-file terminology throughout both Chinese locales', () => { for (const key of ['app.nav.authFiles', 'authFiles.title'] as const) { expect(translate('zh-CN', key)).toBe('凭证文件'); diff --git a/tests/theme-ui.cjs b/tests/theme-ui.cjs new file mode 100644 index 0000000..d7e4280 --- /dev/null +++ b/tests/theme-ui.cjs @@ -0,0 +1,85 @@ +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const assert = require('node:assert/strict'); + +(async () => { + const browser = await chromium.launch({ channel: 'msedge', headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, colorScheme: 'light' }); + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + page.setDefaultTimeout(10000); + const open = query => page.goto(`http://127.0.0.1:1422/tests/fixtures/theme.html?${query}`); + const theme = expected => page.waitForFunction(expected => document.documentElement.dataset.theme === expected, expected); + const sidebar = () => page.locator('.sidebar-theme-selector'); + const select = index => sidebar().getByRole('button').nth(index).click(); + const system = value => page.evaluate(value => window.themeFixture.setSystem(value), value); + const stored = () => page.evaluate(() => localStorage.getItem('easy-cli-proxy-api.theme')); + const nativeReady = () => page.waitForFunction(() => window.themeFixture?.calls.some(call => call.cmd.includes('background_color'))); + + for (const platform of ['windows', 'macos', 'linux']) { + console.log(`Checking ${platform}`); + await open(`reset&platform=${platform}`); + await nativeReady(); + await theme('dark'); + assert.equal(await sidebar().getByRole('button').nth(2).getAttribute('aria-pressed'), 'true'); + assert.equal(await stored(), null); + await system('light'); + await theme('light'); + await select(1); + await theme('dark'); + await system('light'); + assert.equal(await stored(), 'dark'); + await page.waitForFunction(() => window.themeFixture.appearance === 'dark'); + await select(2); + await theme('light'); + assert.equal(await stored(), 'system'); + await open(`platform=${platform}&system=dark`); + await nativeReady(); + await theme('dark'); + assert.equal(await stored(), 'system'); + await page.locator('.sidebar-easy-entry').click(); + const easy = page.locator('.simple-mode-theme-group'); + assert.equal(await easy.getByRole('button').count(), 3); + assert.equal(await easy.getByRole('button').nth(2).getAttribute('aria-pressed'), 'true'); + await easy.getByRole('button').nth(0).click(); + await theme('light'); + await page.locator('.simple-mode-exit-btn').click(); + assert.equal(await sidebar().getByRole('button').nth(0).getAttribute('aria-pressed'), 'true'); + const writes = await page.evaluate(() => window.themeFixture.calls.filter(call => call.cmd === 'plugin:window|set_theme').length); + assert.ok(writes < 30, `${platform}: native theme echo loop (${writes} writes)`); + } + + await open('reset&platform=browser'); + await theme('light'); + await page.emulateMedia({ colorScheme: 'dark' }); + await theme('dark'); + await select(0); + await theme('light'); + await page.emulateMedia({ colorScheme: 'light' }); + await page.emulateMedia({ colorScheme: 'dark' }); + assert.equal(await page.locator('html').getAttribute('data-theme'), 'light'); + await select(2); + await theme('dark'); + await open('platform=browser'); + await theme('dark'); + assert.equal(await stored(), 'system'); + + for (const locale of ['zh-CN', 'zh-TW', 'en', 'ja']) for (const width of [1280, 640]) { + await page.setViewportSize({ width, height: 900 }); + await open(`reset&platform=windows&locale=${locale}`); + await theme('dark'); + const fits = await sidebar().getByRole('button').evaluateAll(buttons => buttons.every(button => button.scrollWidth <= button.clientWidth)); + assert.ok(fits, `${locale} at ${width}px: theme label clipped`); + if (locale === 'zh-CN' && width === 1280 && process.env.THEME_SCREENSHOT_DIR) { + await page.screenshot({ path: `${process.env.THEME_SCREENSHOT_DIR}/theme-dark.png`, animations: 'disabled' }); + await system('light'); + await theme('light'); + await page.screenshot({ path: `${process.env.THEME_SCREENSHOT_DIR}/theme-light.png`, animations: 'disabled' }); + } + } + assert.deepEqual(errors, []); + console.log('Theme UI passed: Windows/macOS/Linux IPC, browser media, persistence, both selectors, 4 locales at 2 widths.'); + } finally { + await browser.close(); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/theme.test.ts b/tests/theme.test.ts new file mode 100644 index 0000000..2bdfb07 --- /dev/null +++ b/tests/theme.test.ts @@ -0,0 +1,367 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import { createThemeController, type AppTheme, type NativeThemeSource, type ThemePreference } from '../src/themeController'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +async function settle() { + for (let i = 0; i < 40; i++) await Promise.resolve(); +} + +function setup(options: { + preference?: ThemePreference; + system?: AppTheme; + media?: AppTheme; + platform?: 'windows' | 'macos' | 'linux' | 'browser'; +} = {}) { + let saved = options.preference ?? 'system'; + let system: AppTheme | null = options.system ?? 'dark'; + let media = options.media ?? 'light'; + let displayed: AppTheme | undefined; + let shell: AppTheme | undefined; + let backdrop: AppTheme | undefined; + let mediaListener: (() => void) | undefined; + let resumeListener: (() => void) | undefined; + let nativeListener: ((theme: AppTheme) => void) | undefined; + let removals = 0; + const writes: ThemePreference[] = []; + const nativeWrites: (AppTheme | null)[] = []; + const native: NativeThemeSource = { + explicitSystemAppearance: options.platform === 'linux', + async setTheme(theme) { + nativeWrites.push(theme); + shell = theme ?? system ?? 'light'; + }, + async readTheme() { return system; }, + async listen(listener) { + nativeListener = listener; + return () => { nativeListener = undefined; removals++; }; + }, + async setBackground(theme) { backdrop = theme; }, + }; + const connection = deferred(); + const controller = createThemeController({ + readPreference: () => saved, + savePreference(value) { saved = value; writes.push(value); }, + readMediaTheme: () => media, + listenMedia(listener) { + mediaListener = listener; + return () => { mediaListener = undefined; removals++; }; + }, + listenResume(listener) { + resumeListener = listener; + return () => { resumeListener = undefined; removals++; }; + }, + applyTheme(theme) { displayed = theme; }, + connectNative: () => connection.promise, + }); + return { + controller, native, writes, nativeWrites, + get displayed() { return displayed; }, + get shell() { return shell; }, + get backdrop() { return backdrop; }, + get saved() { return saved; }, + get removals() { return removals; }, + setSystem(theme: AppTheme | null) { system = theme; }, + emitNative(theme: AppTheme) { shell = theme; nativeListener?.(theme); }, + emitMedia(theme: AppTheme) { media = theme; mediaListener?.(); }, + resume() { resumeListener?.(); }, + async connect() { + connection.resolve(options.platform === 'browser' ? null : native); + await settle(); + }, + }; +} + +describe('theme preferences and native synchronization', () => { + for (const platform of ['windows', 'macos'] as const) { + test(`${platform}: native startup and events win without locking system mode`, async () => { + const app = setup({ platform, media: 'light', system: 'dark' }); + expect(app.displayed).toBe('light'); + await app.connect(); + expect(app.displayed).toBe('dark'); + expect(app.backdrop).toBe('dark'); + expect(app.nativeWrites).toEqual([null]); + app.setSystem('light'); + app.emitNative('light'); + await settle(); + expect(app.displayed).toBe('light'); + expect(app.backdrop).toBe('light'); + expect(app.controller.getPreference()).toBe('system'); + expect(app.writes).toEqual([]); + expect(app.nativeWrites).toEqual([null]); + app.controller.dispose(); + }); + } + + test('native reading wins over a conflicting WebView change', async () => { + const app = setup({ system: 'light' }); + await app.connect(); + app.emitMedia('dark'); + await settle(); + expect(app.displayed).toBe('light'); + app.controller.dispose(); + }); + + test('preserves an existing manual preference and ignores system/media changes', async () => { + const app = setup({ preference: 'light', system: 'dark' }); + await app.connect(); + app.emitMedia('dark'); + app.emitNative('dark'); + await settle(); + expect(app.displayed).toBe('light'); + expect(app.shell).toBe('light'); + expect(app.writes).toEqual([]); + app.controller.dispose(); + }); + + test('switches manual → system → manual and persists the preference, not its result', async () => { + const app = setup({ preference: 'light', system: 'dark' }); + await app.connect(); + app.controller.setPreference('system'); + await settle(); + expect(app.displayed).toBe('dark'); + expect(app.saved).toBe('system'); + expect(app.nativeWrites).toEqual(['light', null]); + app.controller.setPreference('light'); + app.setSystem('dark'); + app.emitNative('dark'); + await settle(); + expect(app.displayed).toBe('light'); + expect(app.shell).toBe('light'); + expect(app.saved).toBe('light'); + app.controller.dispose(); + }); + + test('browser preview follows media changes, including after a manual override', async () => { + const app = setup({ platform: 'browser' }); + await app.connect(); + app.emitMedia('dark'); + expect(app.displayed).toBe('dark'); + app.controller.setPreference('light'); + app.emitMedia('dark'); + expect(app.displayed).toBe('light'); + app.controller.setPreference('system'); + expect(app.displayed).toBe('dark'); + app.emitMedia('light'); + expect(app.displayed).toBe('light'); + app.controller.dispose(); + }); + + test('failed native reads and listeners retain the media fallback', async () => { + const app = setup(); + app.native.readTheme = async () => { throw new Error('unavailable'); }; + app.native.listen = async () => { throw new Error('unsupported'); }; + app.native.setTheme = async () => { throw new Error('unsupported'); }; + await app.connect(); + app.emitMedia('dark'); + await settle(); + expect(app.displayed).toBe('dark'); + expect(app.controller.getPreference()).toBe('system'); + app.controller.dispose(); + }); + + test('refreshes after resuming from tray/sleep when a notification was missed', async () => { + const app = setup(); + await app.connect(); + app.setSystem('light'); + app.resume(); + await settle(); + expect(app.displayed).toBe('light'); + expect(app.backdrop).toBe('light'); + app.controller.dispose(); + }); + + test('a delayed initial read cannot overwrite a newer native notification', async () => { + const app = setup(); + const reading = deferred(); + app.native.readTheme = () => reading.promise; + await app.connect(); + app.emitNative('dark'); + reading.resolve('light'); + await settle(); + expect(app.displayed).toBe('dark'); + expect(app.backdrop).toBe('dark'); + app.controller.dispose(); + }); + + test('a delayed system read cannot overwrite a newer manual selection', async () => { + const app = setup(); + const reading = deferred(); + app.native.readTheme = () => reading.promise; + await app.connect(); + app.controller.setPreference('light'); + reading.resolve('dark'); + await settle(); + expect(app.displayed).toBe('light'); + expect(app.shell).toBe('light'); + expect(app.backdrop).toBe('light'); + app.controller.dispose(); + }); + + test('serializes slow native writes during rapid preference changes', async () => { + const app = setup(); + const write = deferred(); + const original = app.native.setTheme; + app.native.setTheme = async (theme) => { + if (theme === null) await write.promise; + await original(theme); + }; + await app.connect(); + app.controller.setPreference('light'); + app.controller.setPreference('dark'); + app.controller.setPreference('system'); + app.controller.setPreference('light'); + write.resolve(); + await settle(); + expect(app.nativeWrites).toEqual([null, 'light']); + expect(app.displayed).toBe('light'); + expect(app.shell).toBe('light'); + app.controller.dispose(); + }); + + test('Linux reads the portal after clearing a manual override and applies GTK appearance', async () => { + const app = setup({ platform: 'linux', preference: 'light', system: 'dark' }); + await app.connect(); + app.controller.setPreference('system'); + await settle(); + expect(app.displayed).toBe('dark'); + expect(app.shell).toBe('dark'); + expect(app.saved).toBe('system'); + expect(app.nativeWrites).toEqual(['light', null, 'dark']); + app.emitNative('light'); + await settle(); + expect(app.displayed).toBe('dark'); + expect(app.shell).toBe('dark'); + const writeCount = app.nativeWrites.length; + app.emitNative('dark'); + app.emitMedia('dark'); + await settle(); + expect(app.nativeWrites.length).toBe(writeCount); + app.setSystem('light'); + app.emitNative('light'); + await settle(); + expect(app.displayed).toBe('light'); + app.controller.dispose(); + }); + + test('Linux without a portal preference falls back to WebKit', async () => { + const app = setup({ platform: 'linux' }); + app.setSystem(null); + await app.connect(); + app.emitMedia('dark'); + await settle(); + expect(app.displayed).toBe('dark'); + expect(app.nativeWrites).toEqual([null]); + app.controller.dispose(); + }); + + test('Linux retains a manual appearance when the portal sends a different theme', async () => { + const app = setup({ platform: 'linux', preference: 'dark' }); + await app.connect(); + app.setSystem('light'); + app.emitNative('light'); + await settle(); + expect(app.displayed).toBe('dark'); + expect(app.shell).toBe('dark'); + app.controller.dispose(); + }); + + test('Linux reconciles a system change delivered during an in-flight GTK appearance write', async () => { + const app = setup({ platform: 'linux', system: 'dark' }); + const write = deferred(); + const original = app.native.setTheme; + app.native.setTheme = async (theme) => { + await original(theme); + if (theme === 'dark') await write.promise; + }; + await app.connect(); + app.setSystem('light'); + app.emitNative('light'); + write.resolve(); + await settle(); + expect(app.displayed).toBe('light'); + expect(app.shell).toBe('light'); + expect(app.backdrop).toBe('light'); + app.controller.dispose(); + }); + + test('Linux reasserts a manual mode when the portal changes GTK during the manual write', async () => { + const app = setup({ platform: 'linux', preference: 'dark' }); + const write = deferred(); + const original = app.native.setTheme; + app.native.setTheme = async (theme) => { + await original(theme); + await write.promise; + }; + await app.connect(); + app.setSystem('light'); + app.emitNative('light'); + write.resolve(); + await settle(); + expect(app.displayed).toBe('dark'); + expect(app.shell).toBe('dark'); + expect(app.backdrop).toBe('dark'); + app.controller.dispose(); + }); + + test('disposes all observers and ignores late async reads', async () => { + const app = setup(); + const reading = deferred(); + app.native.readTheme = () => reading.promise; + await app.connect(); + app.controller.dispose(); + reading.resolve('dark'); + app.emitMedia('dark'); + await settle(); + expect(app.removals).toBe(3); + expect(app.displayed).toBe('light'); + }); + + test('cleans up a native listener that attaches after disposal', async () => { + const app = setup(); + const registration = deferred<() => void>(); + let removed = false; + app.native.listen = () => registration.promise; + await app.connect(); + app.controller.dispose(); + registration.resolve(() => { removed = true; }); + await settle(); + expect(removed).toBe(true); + expect(app.nativeWrites).toEqual([]); + }); +}); + +describe('first paint', () => { + const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); + const script = html.match(/