Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions apps/desktop-tauri/scripts/proof-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
// Scenario steps (scripts/proof-scenarios.json), run in order:
// expectText / expectNoText page text includes / excludes each string
// expectSelector a DOM selector matches
// expectTheme documentElement dataset.theme
// expectTheme documentElement dataset.theme (waits for the value)
// expectField a native <select> by label: value, options, enabled
// expectCall a backend command was invoked (optional args match)
// expectNoCall a backend command was not invoked (waits 300 ms first)
Expand Down Expand Up @@ -384,8 +384,11 @@ async function runStep(page, step, ctx) {
.map((el) => el.textContent)`);
if (clipped.length > 0) throw new Error(`text clipped in ${step.expectNoOverflow}: ${js(clipped)}`);
} else if (step.expectTheme !== undefined) {
const theme = await page.eval("document.documentElement.dataset.theme");
if (theme !== step.expectTheme) throw new Error(`theme is ${js(theme)}, expected ${js(step.expectTheme)}`);
// Polled: under `auto` the theme arrives after an async shell read.
const theme = "document.documentElement.dataset.theme";
if (!(await page.poll(`${theme} === ${js(step.expectTheme)}`))) {
throw new Error(`theme is ${js(await page.eval(theme))}, expected ${js(step.expectTheme)}`);
}
} else if (step.click !== undefined) {
// `near` picks, among same-label matches, the one whose closest ancestor
// containing that text is nearest (e.g. the Switch button of one account row).
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop-tauri/scripts/proof-scenarios.json
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,28 @@
{ "screenshot": "settings-general-light" }
]
},
{
"name": "settings-auto-follows-windows-light",
"query": "window=settings&tab=general&theme=auto&system=light",
"size": [880, 620],
"steps": [
{ "expectCall": { "cmd": "get_system_theme" } },
{ "expectTheme": "light" },
{ "expectText": "Refresh interval" },
{ "screenshot": "settings-auto-follows-windows-light" }
]
},
{
"name": "settings-auto-follows-windows-dark",
"query": "window=settings&tab=general&theme=auto&system=dark",
"size": [880, 620],
"steps": [
{ "expectCall": { "cmd": "get_system_theme" } },
{ "expectTheme": "dark" },
{ "expectText": "Refresh interval" },
{ "screenshot": "settings-auto-follows-windows-dark" }
]
},
{
"name": "settings-providers",
"query": "window=settings&tab=providers&theme=dark",
Expand Down
53 changes: 53 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,48 @@ fn abgr_to_hex(value: u32) -> String {
format!("#{r:02x}{g:02x}{b:02x}")
}

/// The Windows app-theme setting (`"light"` or `"dark"`) for the Settings
/// window under the `auto` theme.
///
/// Every native window is pinned to Dark (see `shell/settings_window.rs`), so
/// `prefers-color-scheme` inside the webview always reports dark. This reads
/// the OS setting directly instead. In proof mode, `CODEXBAR_PROOF_SYSTEM_THEME`
/// replaces the OS read so the `auto` theme can be proven in both directions.
#[tauri::command]
pub fn get_system_theme() -> String {
if let Some(theme) = crate::proof_harness::system_theme_override() {
return theme.to_string();
}
theme_from_apps_use_light(apps_use_light_theme_value()).to_string()
}

/// Maps `HKCU\...\Themes\Personalize\AppsUseLightTheme` to a theme name.
/// Only a non-zero value means light. A zero, a missing value or a read error
/// resolves to dark, the theme these windows used before the OS was read.
fn theme_from_apps_use_light(value: Option<u32>) -> &'static str {
match value {
Some(0) | None => "dark",
Some(_) => "light",
}
}

#[cfg(windows)]
fn apps_use_light_theme_value() -> Option<u32> {
use winreg::RegKey;
use winreg::enums::HKEY_CURRENT_USER;

RegKey::predef(HKEY_CURRENT_USER)
.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize")
.ok()?
.get_value::<u32, _>("AppsUseLightTheme")
.ok()
}

#[cfg(not(windows))]
fn apps_use_light_theme_value() -> Option<u32> {
None
}

#[tauri::command]
pub fn play_notification_sound(
event: codexbar::sound::NotificationSoundEvent,
Expand Down Expand Up @@ -394,4 +436,15 @@ mod tests {
Some("https://openrouter.ai/activity")
);
}

#[test]
fn apps_use_light_theme_nonzero_reads_as_light() {
assert_eq!(theme_from_apps_use_light(Some(1)), "light");
}

#[test]
fn apps_use_light_theme_zero_or_unreadable_reads_as_dark() {
assert_eq!(theme_from_apps_use_light(Some(0)), "dark");
assert_eq!(theme_from_apps_use_light(None), "dark");
}
}
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ fn main() {
let proof_config = proof_harness::ProofConfig::from_env();
let is_proof_mode = proof_config.is_some();
proof_harness::install_window_origin_from_env(is_proof_mode);
proof_harness::install_system_theme_from_env(is_proof_mode);
let force_start_visible = std::env::var_os("CODEXBAR_START_VISIBLE").is_some();
let mut settings = codexbar::settings::Settings::load();
settings.apply_display_mode_fold_migration();
Expand Down Expand Up @@ -340,6 +341,7 @@ fn main() {
commands::unregister_global_shortcut,
commands::get_work_area_rect,
commands::get_system_accent_color,
commands::get_system_theme,
commands::play_notification_sound,
commands::open_external_url,
commands::reanchor_tray_panel,
Expand Down
65 changes: 65 additions & 0 deletions apps/desktop-tauri/src-tauri/src/proof_harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
//! opens the flyout, Settings and float bar windows at that point instead of
//! their normal placement, so proof runs can keep every window on one chosen
//! monitor.
//!
//! `CODEXBAR_PROOF_SYSTEM_THEME=light|dark` (proof mode only) makes
//! `get_system_theme` report that Windows app theme, so the `auto` theme can be
//! proven in both directions without changing the real OS setting. Any other
//! value is ignored.

use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
Expand Down Expand Up @@ -315,6 +320,48 @@ pub fn is_proof_mode(app: &AppHandle) -> bool {

// ── Window origin (CODEXBAR_PROOF_WINDOW_ORIGIN) ─────────────────────

// ── System theme (CODEXBAR_PROOF_SYSTEM_THEME) ───────────────────────

/// Environment variable naming the app theme (`light` or `dark`) that
/// `get_system_theme` reports. Honored only in proof mode.
pub const SYSTEM_THEME_ENV_VAR: &str = "CODEXBAR_PROOF_SYSTEM_THEME";

static SYSTEM_THEME: OnceLock<&'static str> = OnceLock::new();

/// Read `CODEXBAR_PROOF_SYSTEM_THEME` once at startup. Outside proof mode,
/// or when the value is unset or invalid, nothing is installed and
/// `get_system_theme` reads the OS setting.
pub fn install_system_theme_from_env(proof_mode: bool) {
let raw = std::env::var(SYSTEM_THEME_ENV_VAR).ok();
if let Some(theme) = proof_system_theme(proof_mode, raw.as_deref()) {
tracing::info!("proof-harness: system theme {theme}");
let _ = SYSTEM_THEME.set(theme);
}
}

/// The proof theme override, when one was installed at startup.
pub fn system_theme_override() -> Option<&'static str> {
SYSTEM_THEME.get().copied()
}

/// Gate for `CODEXBAR_PROOF_SYSTEM_THEME`. Returns `None` outside proof mode,
/// when the value is unset or blank, and when it is not `light` or `dark`.
fn proof_system_theme(proof_mode: bool, raw: Option<&str>) -> Option<&'static str> {
let raw = raw.map(str::trim).filter(|raw| !raw.is_empty())?;
if !proof_mode {
tracing::warn!("{SYSTEM_THEME_ENV_VAR} is ignored outside proof mode");
return None;
}
match raw.to_ascii_lowercase().as_str() {
"light" => Some("light"),
"dark" => Some("dark"),
_ => {
tracing::warn!("{SYSTEM_THEME_ENV_VAR}: expected 'light' or 'dark', ignoring");
None
}
}
}

/// Environment variable with a `<x>,<y>` physical-pixel origin for proof
/// windows. Honored only in proof mode.
pub const WINDOW_ORIGIN_ENV_VAR: &str = "CODEXBAR_PROOF_WINDOW_ORIGIN";
Expand Down Expand Up @@ -1112,6 +1159,24 @@ mod tests {
}
}

#[test]
fn system_theme_is_honored_only_in_proof_mode() {
assert_eq!(proof_system_theme(true, Some("light")), Some("light"));
assert_eq!(proof_system_theme(true, Some(" Dark ")), Some("dark"));
assert_eq!(proof_system_theme(false, Some("light")), None);
assert_eq!(proof_system_theme(false, Some("dark")), None);
}

#[test]
fn system_theme_ignores_unset_blank_and_invalid_values() {
assert_eq!(proof_system_theme(true, None), None);
assert_eq!(proof_system_theme(true, Some("")), None);
assert_eq!(proof_system_theme(true, Some(" ")), None);
for raw in ["auto", "system", "lightish", "1"] {
assert_eq!(proof_system_theme(true, Some(raw)), None, "{raw}");
}
}

#[test]
fn window_origin_env_is_honored_only_in_proof_mode() {
with_window_origin_env(Some("-3600,-1188"), || {
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop-tauri/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ function AppInner() {
const [error, setError] = useState<string | null>(null);
const [themePreference, setThemePreference] = useState<ThemePreference>("dark");

useTheme(themePreference);
// Settings follows the Windows app theme under `auto`; the other surfaces
// keep the pinned-Dark media query.
useTheme(themePreference, {
followSystem: isSettingsWindow() || surface.mode === "settings",
});
useDeepSeekPricingStatus();

const reloadBootstrapState = useCallback(
Expand Down
149 changes: 147 additions & 2 deletions apps/desktop-tauri/src/hooks/useTheme.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
import { describe, expect, it, vi } from "vitest";
import { resolveTheme } from "./useTheme";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getSystemTheme } from "../lib/tauri";
import { resolveTheme, useTheme } from "./useTheme";

vi.mock("../lib/tauri", () => ({ getSystemTheme: vi.fn() }));

const mockedGetSystemTheme = vi.mocked(getSystemTheme);

/** The webview's own media query, which every pinned window reports as dark. */
function stubMediaQuery(lightPreferred: boolean) {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: query === "(prefers-color-scheme: light)" && lightPreferred,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
}

function setTauriBridge(present: boolean) {
if (present) {
Object.defineProperty(window, "__TAURI_INTERNALS__", {
value: {},
configurable: true,
writable: true,
});
} else {
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
}
}

beforeEach(() => {
mockedGetSystemTheme.mockReset();
document.documentElement.removeAttribute("data-theme");
});

afterEach(() => {
cleanup();
setTauriBridge(false);
vi.unstubAllGlobals();
});

describe("resolveTheme", () => {
it("uses the OS light preference in auto mode", () => {
Expand All @@ -19,4 +57,111 @@ describe("resolveTheme", () => {

vi.unstubAllGlobals();
});

it("takes the system signal from the caller when one is given", () => {
expect(resolveTheme("auto", true)).toBe("light");
expect(resolveTheme("auto", false)).toBe("dark");
expect(resolveTheme("light", false)).toBe("light");
expect(resolveTheme("dark", true)).toBe("dark");
});
});

describe("useTheme", () => {
it("applies the Windows app theme under Tauri when following the system", async () => {
setTauriBridge(true);
stubMediaQuery(false);
mockedGetSystemTheme.mockResolvedValue("light");

renderHook(() => useTheme("auto", { followSystem: true }));

await waitFor(() => expect(document.documentElement.dataset.theme).toBe("light"));
expect(document.documentElement.style.colorScheme).toBe("light");
expect(mockedGetSystemTheme).toHaveBeenCalled();
});

it("keeps the media query for windows that do not follow the system", async () => {
setTauriBridge(true);
stubMediaQuery(false);
mockedGetSystemTheme.mockResolvedValue("light");

renderHook(() => useTheme("auto"));

expect(document.documentElement.dataset.theme).toBe("dark");
expect(mockedGetSystemTheme).not.toHaveBeenCalled();
});

it("uses the media query when following the system outside Tauri", () => {
setTauriBridge(false);
stubMediaQuery(true);

renderHook(() => useTheme("auto", { followSystem: true }));

expect(document.documentElement.dataset.theme).toBe("light");
expect(mockedGetSystemTheme).not.toHaveBeenCalled();
});

it("ignores the system theme for explicit overrides", () => {
setTauriBridge(true);
mockedGetSystemTheme.mockResolvedValue("light");

renderHook(() => useTheme("dark", { followSystem: true }));

expect(document.documentElement.dataset.theme).toBe("dark");
expect(mockedGetSystemTheme).not.toHaveBeenCalled();
});

it("re-reads the Windows theme when settings change", async () => {
setTauriBridge(true);
mockedGetSystemTheme.mockResolvedValueOnce("dark").mockResolvedValueOnce("light");

renderHook(() => useTheme("auto", { followSystem: true }));
await waitFor(() => expect(document.documentElement.dataset.theme).toBe("dark"));

act(() => {
window.dispatchEvent(new Event("codexbar:settings-updated"));
});

await waitFor(() => expect(document.documentElement.dataset.theme).toBe("light"));
expect(mockedGetSystemTheme).toHaveBeenCalledTimes(2);
});

it("re-reads the Windows theme when the window regains focus", async () => {
setTauriBridge(true);
mockedGetSystemTheme.mockResolvedValueOnce("dark").mockResolvedValueOnce("light");

renderHook(() => useTheme("auto", { followSystem: true }));
await waitFor(() => expect(document.documentElement.dataset.theme).toBe("dark"));

act(() => {
window.dispatchEvent(new Event("focus"));
});

await waitFor(() => expect(document.documentElement.dataset.theme).toBe("light"));
});

it("does not let an older read that finishes last replace a newer one", async () => {
setTauriBridge(true);
let resolveFirst: (theme: "light" | "dark") => void = () => {};
mockedGetSystemTheme
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve;
}),
)
.mockResolvedValueOnce("light");

renderHook(() => useTheme("auto", { followSystem: true }));
act(() => {
window.dispatchEvent(new Event("focus"));
});
await waitFor(() => expect(document.documentElement.dataset.theme).toBe("light"));

await act(async () => {
resolveFirst("dark");
await Promise.resolve();
});

expect(document.documentElement.dataset.theme).toBe("light");
});
});
Loading