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
This commit is contained in:
1 parent
b483d77bf8
commit
739c6e1711
25 files changed
+1051
-53
No files matched your search
+8
-8
@@ -5,17 +5,17 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script>
|
||||
(() => {
|
||||
let savedTheme;
|
||||
try {
|
||||
const savedTheme = localStorage.getItem('easy-cli-proxy-api.theme');
|
||||
const theme = savedTheme === 'light' || savedTheme === 'dark'
|
||||
? savedTheme
|
||||
: matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
document.documentElement.style.backgroundColor = theme === 'dark' ? '#111412' : '#f6f7f5';
|
||||
savedTheme = localStorage.getItem('easy-cli-proxy-api.theme');
|
||||
} catch {
|
||||
// The app applies the theme again during initialization.
|
||||
}
|
||||
const theme = savedTheme === 'light' || savedTheme === 'dark'
|
||||
? savedTheme
|
||||
: window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
document.documentElement.style.backgroundColor = theme === 'dark' ? '#111412' : '#f6f7f5';
|
||||
})();
|
||||
</script>
|
||||
<title>EasyCLIProxyAPI</title>
|
||||
|
||||
Generated
+1
@@ -597,6 +597,7 @@ version = "0.2.81"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dbus",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"getrandom 0.3.4",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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::<std::collections::BTreeSet<_>>();
|
||||
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<String, String> {
|
||||
if models.is_empty() {
|
||||
return Err("DSH 模型列表为空,已保留现有配置,请刷新后重试".into());
|
||||
}
|
||||
let root = harness_root(existing)?;
|
||||
let old = harness_provider(&root);
|
||||
let api = state
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_linux_system_theme() -> Option<tauri::Theme> {
|
||||
#[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<tauri::Theme> {
|
||||
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<Variant<u32>>,) = 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<tauri::Theme> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
+12
-7
@@ -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<PageId>('home');
|
||||
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
|
||||
const [theme, setTheme] = useState<AppTheme>(detectInitialTheme);
|
||||
const [theme, setTheme] = useThemePreference();
|
||||
const [windowsClosePrompt, setWindowsClosePrompt] = useState<WindowsClosePrompt | null>(null);
|
||||
const closeDialogRef = useRef<HTMLElement>(null);
|
||||
const languageMenuRef = useRef<HTMLDivElement>(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() {
|
||||
<div
|
||||
className="sidebar-theme-selector"
|
||||
role="group"
|
||||
aria-label={`${t('app.theme.light')} / ${t('app.theme.dark')}`}
|
||||
aria-label={t('app.theme.label')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -364,6 +360,15 @@ function AppContent() {
|
||||
>
|
||||
{t('app.theme.dark')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={theme === 'system' ? 'active' : ''}
|
||||
aria-pressed={theme === 'system'}
|
||||
title={t('app.theme.switchToSystem')}
|
||||
onClick={() => setTheme('system')}
|
||||
>
|
||||
{t('app.theme.system')}
|
||||
</button>
|
||||
</div>
|
||||
<div ref={languageMenuRef} className="sidebar-language">
|
||||
<button
|
||||
|
||||
@@ -222,6 +222,9 @@ export const jaOverrides = {
|
||||
'app.language': '言語',
|
||||
'app.theme.light': 'ライトモード',
|
||||
'app.theme.dark': 'ダークモード',
|
||||
'app.theme.label': '外観モード',
|
||||
'app.theme.system': 'システムに従う',
|
||||
'app.theme.switchToSystem': 'システムの外観に合わせて自動的に切り替え',
|
||||
'app.theme.switchToLight': 'ライトモードに切り替え',
|
||||
'app.theme.switchToDark': 'ダークモードに切り替え',
|
||||
'app.desktopConsole': 'デスクトップコンソール',
|
||||
|
||||
@@ -172,6 +172,9 @@ export const en: Record<MessageKey, string> = {
|
||||
'app.language': 'Language',
|
||||
'app.theme.light': 'Light mode',
|
||||
'app.theme.dark': 'Dark mode',
|
||||
'app.theme.label': 'Appearance',
|
||||
'app.theme.system': 'Follow system',
|
||||
'app.theme.switchToSystem': 'Automatically match the system appearance',
|
||||
'app.theme.switchToLight': 'Switch to light mode',
|
||||
'app.theme.switchToDark': 'Switch to dark mode',
|
||||
'app.desktopConsole': 'Desktop Console',
|
||||
|
||||
@@ -171,6 +171,9 @@ export const zhCN = {
|
||||
'app.language': '语言',
|
||||
'app.theme.light': '日间模式',
|
||||
'app.theme.dark': '夜间模式',
|
||||
'app.theme.label': '外观模式',
|
||||
'app.theme.system': '跟随系统',
|
||||
'app.theme.switchToSystem': '随系统自动切换日间和夜间模式',
|
||||
'app.theme.switchToLight': '切换到日间模式',
|
||||
'app.theme.switchToDark': '切换到夜间模式',
|
||||
'app.desktopConsole': '桌面控制台',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
const phraseReplacements: ReadonlyArray<readonly [string, string]> = [
|
||||
['随系统自动切换日间和夜间模式', '隨系統自動切換日間和夜間模式'],
|
||||
['跟随系统', '跟隨系統'],
|
||||
['外观模式', '外觀模式'],
|
||||
['登录系统后自动启动', '登入系統後自動啟動'],
|
||||
['系统托盘', '系統匣'],
|
||||
['开机自启', '開機自啟'],
|
||||
|
||||
+2
-2
@@ -3,10 +3,10 @@ import { createRoot } from 'react-dom/client';
|
||||
import { AppErrorBoundary } from './AppErrorBoundary';
|
||||
import App from './App';
|
||||
import { I18nProvider } from './i18n';
|
||||
import { applyTheme, detectInitialTheme } from './theme';
|
||||
import { initializeTheme } from './theme';
|
||||
import './styles.css';
|
||||
|
||||
applyTheme(detectInitialTheme());
|
||||
initializeTheme();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ChevronDown,
|
||||
Languages,
|
||||
LoaderCircle,
|
||||
Monitor,
|
||||
Moon,
|
||||
Sun,
|
||||
X,
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
type ModelOption,
|
||||
type ModelProvider,
|
||||
} from "../services/modelService";
|
||||
import { type AppTheme } from "../theme";
|
||||
import { type ThemePreference } from "../theme";
|
||||
import { AgentsPage } from "./AgentsPage";
|
||||
|
||||
import codexIcon from "../assets/icons/codex.svg";
|
||||
@@ -100,8 +101,8 @@ export function EasyModePage({
|
||||
setLocale,
|
||||
}: {
|
||||
onExit?: () => void;
|
||||
theme?: AppTheme;
|
||||
setTheme?: (theme: AppTheme) => void;
|
||||
theme?: ThemePreference;
|
||||
setTheme?: (theme: ThemePreference) => void;
|
||||
locale?: AppLocale;
|
||||
setLocale?: (locale: AppLocale) => void;
|
||||
}) {
|
||||
@@ -632,11 +633,13 @@ export function EasyModePage({
|
||||
<div className="simple-mode-topbar-right">
|
||||
{/* 主题切换 */}
|
||||
{setTheme ? (
|
||||
<div className="simple-mode-theme-group">
|
||||
<div className="simple-mode-theme-group" role="group" aria-label={t("app.theme.label")}>
|
||||
<button
|
||||
type="button"
|
||||
className={theme === "light" ? "active" : ""}
|
||||
title={t("easyMode.theme.light")}
|
||||
aria-label={t("easyMode.theme.light")}
|
||||
aria-pressed={theme === "light"}
|
||||
onClick={() => setTheme("light")}
|
||||
>
|
||||
<Sun size={15} />
|
||||
@@ -645,10 +648,22 @@ export function EasyModePage({
|
||||
type="button"
|
||||
className={theme === "dark" ? "active" : ""}
|
||||
title={t("easyMode.theme.dark")}
|
||||
aria-label={t("easyMode.theme.dark")}
|
||||
aria-pressed={theme === "dark"}
|
||||
onClick={() => setTheme("dark")}
|
||||
>
|
||||
<Moon size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={theme === "system" ? "active" : ""}
|
||||
title={t("app.theme.switchToSystem")}
|
||||
aria-label={t("app.theme.system")}
|
||||
aria-pressed={theme === "system"}
|
||||
onClick={() => setTheme("system")}
|
||||
>
|
||||
<Monitor size={15} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
+74
-29
@@ -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<AppTheme, string> = {
|
||||
@@ -9,46 +12,88 @@ const WINDOW_BACKGROUND: Record<AppTheme, string> = {
|
||||
dark: '#111412',
|
||||
};
|
||||
|
||||
async function applyNativeTheme(theme: AppTheme): Promise<void> {
|
||||
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<typeof createThemeController> | 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<AppTheme | null>('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());
|
||||
}
|
||||
@@ -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<void>;
|
||||
readTheme(): Promise<AppTheme | null>;
|
||||
listen(listener: (theme: AppTheme) => void): Promise<Unlisten>;
|
||||
setBackground(theme: AppTheme): Promise<void>;
|
||||
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<NativeThemeSource | null>;
|
||||
}
|
||||
|
||||
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<Unlisten>();
|
||||
const cleanup: Unlisten[] = [];
|
||||
const current = (version: number) => !disposed && version === generation;
|
||||
const apply = (theme: AppTheme) => environment.applyTheme(theme);
|
||||
|
||||
function enqueue(work: () => Promise<void>) {
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /></head>
|
||||
<body><div id="root"></div><script type="module" src="./theme.tsx"></script></body>
|
||||
</html>
|
||||
Vendored
+63
@@ -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(
|
||||
<StrictMode><I18nProvider><App /></I18nProvider></StrictMode>,
|
||||
);
|
||||
@@ -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('凭证文件');
|
||||
|
||||
@@ -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; });
|
||||
@@ -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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((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<NativeThemeSource | null>();
|
||||
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<AppTheme>();
|
||||
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<AppTheme>();
|
||||
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<void>();
|
||||
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<void>();
|
||||
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<void>();
|
||||
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<AppTheme>();
|
||||
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(/<script>([\s\S]*?)<\/script>/)![1];
|
||||
for (const [saved, dark, expected] of [
|
||||
[null, true, 'dark'], ['system', true, 'dark'], ['system', false, 'light'],
|
||||
['light', true, 'light'], ['dark', false, 'dark'], ['invalid', true, 'dark'],
|
||||
] as const) {
|
||||
test(`saved ${saved}, dark system ${dark} → ${expected}`, () => {
|
||||
const root = { dataset: {} as Record<string, string>, style: {} };
|
||||
runInNewContext(script, {
|
||||
localStorage: { getItem: () => saved },
|
||||
window: { matchMedia: () => ({ matches: dark }) },
|
||||
document: { documentElement: root },
|
||||
});
|
||||
expect(root.dataset.theme).toBe(expected);
|
||||
});
|
||||
}
|
||||
test('blocked storage still uses the system theme before React loads', () => {
|
||||
const root = { dataset: {} as Record<string, string>, style: {} };
|
||||
runInNewContext(script, {
|
||||
localStorage: { getItem() { throw new Error('blocked'); } },
|
||||
window: { matchMedia: () => ({ matches: true }) },
|
||||
document: { documentElement: root },
|
||||
});
|
||||
expect(root.dataset.theme).toBe('dark');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user