diff --git a/apps/yaak-client/hooks/usePreferredAppearance.ts b/apps/yaak-client/hooks/usePreferredAppearance.ts index 8db6ec14..266e057f 100644 --- a/apps/yaak-client/hooks/usePreferredAppearance.ts +++ b/apps/yaak-client/hooks/usePreferredAppearance.ts @@ -1,9 +1,15 @@ import { useEffect, useState } from "react"; import type { Appearance } from "@yaakapp-internal/theme"; -import { getCSSAppearance, subscribeToPreferredAppearance } from "@yaakapp-internal/theme"; +import { + getCSSAppearance, + getSystemAppearance, + subscribeToPreferredAppearance, +} from "@yaakapp-internal/theme"; export function usePreferredAppearance() { - const [preferredAppearance, setPreferredAppearance] = useState(getCSSAppearance()); + const [preferredAppearance, setPreferredAppearance] = useState( + getSystemAppearance() ?? getCSSAppearance(), + ); useEffect(() => subscribeToPreferredAppearance(setPreferredAppearance), []); return preferredAppearance; } diff --git a/apps/yaak-client/theme.ts b/apps/yaak-client/theme.ts index 3d07c5dc..4803b2fc 100644 --- a/apps/yaak-client/theme.ts +++ b/apps/yaak-client/theme.ts @@ -5,30 +5,34 @@ import type { Appearance } from "@yaakapp-internal/theme"; import { applyThemeToDocument, getCSSAppearance, + getSystemAppearance, + getWindowAppearance, subscribeToPreferredAppearanceChange, - subscribeToSystemAppearanceChange, } from "@yaakapp-internal/theme"; import { getSettings } from "./lib/settings"; import { getResolvedTheme } from "./lib/themes"; -// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want -// a good appearance guess so we're not waiting too long -let preferredAppearance: Appearance = getInitialAppearance(); -let linuxSystemAppearanceAvailable = - platform.osType() === "linux" && window.__YAAK_INITIAL_APPEARANCE_SOURCE__ === "linux-system"; +// NOTE: The appearance the OS prefers (never the one the settings force). The backend +// injects it on macOS and Linux; the CSS guess is only a fallback until the async +// window value arrives below. +let preferredAppearance: Appearance = getSystemAppearance() ?? getCSSAppearance(); let configureThemeGeneration = 0; let windowShown = false; configureThemeAndShow().catch((err) => console.log("Failed to configure theme", err)); -subscribeToPreferredAppearanceChange(async (a) => { - if (linuxSystemAppearanceAvailable) return; - preferredAppearance = a; - await configureThemeAndShow(); -}); +if (getSystemAppearance() == null) { + // The initial appearance is only a guess, so confirm it with the window once it's available + getWindowAppearance() + .then(async (a) => { + if (a === preferredAppearance) return; + preferredAppearance = a; + await configureThemeAndShow(); + }) + .catch((err) => console.log("Failed to get window appearance", err)); +} -subscribeToSystemAppearanceChange(async (a) => { - linuxSystemAppearanceAvailable = true; +subscribeToPreferredAppearanceChange(async (a) => { preferredAppearance = a; await configureThemeAndShow(); }); @@ -75,18 +79,3 @@ async function configureTheme(): Promise { return true; } - -function getInitialAppearance(): Appearance { - const initialAppearance = window.__YAAK_INITIAL_APPEARANCE__; - if (initialAppearance === "dark" || initialAppearance === "light") { - return initialAppearance; - } - return getCSSAppearance(); -} - -declare global { - interface Window { - __YAAK_INITIAL_APPEARANCE__?: Appearance; - __YAAK_INITIAL_APPEARANCE_SOURCE__?: "settings" | "linux-system"; - } -} diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index 957d00a5..5698bc4f 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -160,19 +160,14 @@ fn setup_window_menu(win: &WebviewWindow) -> Result<()> { } fn initial_appearance_script(app_handle: &AppHandle) -> Option { - use yaak_system_appearance::{Appearance, InitialAppearanceSource}; - - let settings = app_handle.db().get_settings(); - let (appearance, source) = match settings.appearance.as_str() { - "dark" => (Appearance::Dark, InitialAppearanceSource::Settings), - "light" => (Appearance::Light, InitialAppearanceSource::Settings), - _ => ( - yaak_system_appearance::system_appearance()?, - InitialAppearanceSource::LinuxSystem, - ), - }; - - Some(yaak_system_appearance::initialization_script(appearance, source)) + // Only report the appearance the OS prefers. The frontend needs it to resolve the + // "automatic" setting, so the configured appearance is never a substitute for it. + // + // NOTE: The value comes from the watcher state, not a fresh detection, so the frontend + // only ever sees a snapshot when the change events that keep it fresh are also flowing. + let state = app_handle.try_state::()?; + let appearance = state.last_appearance()?; + Some(yaak_system_appearance::initialization_script(appearance)) } /// Extension trait for easily creating a PluginContext from a WebviewWindow @@ -1783,7 +1778,7 @@ pub fn run() { app.state::().inner().clone(); let app_id = app.config().identifier.to_string(); app.manage(yaak_crypto::manager::EncryptionManager::new(query_manager, app_id)); - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] if let Some(state) = yaak_system_appearance::watch(app.app_handle().clone()) { app.manage(state); } @@ -2002,7 +1997,7 @@ pub fn run() { }); } RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => { - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] if let Some(state) = app_handle.try_state::() { diff --git a/crates-tauri/yaak-system-appearance/Cargo.toml b/crates-tauri/yaak-system-appearance/Cargo.toml index 72928f1f..031c96f4 100644 --- a/crates-tauri/yaak-system-appearance/Cargo.toml +++ b/crates-tauri/yaak-system-appearance/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" publish = false -[target.'cfg(target_os = "linux")'.dependencies] +[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies] dark-light = "2.0.0" [dependencies] diff --git a/crates-tauri/yaak-system-appearance/src/lib.rs b/crates-tauri/yaak-system-appearance/src/lib.rs index b6f7466e..0b68880a 100644 --- a/crates-tauri/yaak-system-appearance/src/lib.rs +++ b/crates-tauri/yaak-system-appearance/src/lib.rs @@ -1,18 +1,17 @@ use std::sync::{Arc, Mutex}; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] use std::time::Duration; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] use log::{debug, warn}; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] use tauri::Emitter; use tauri::{AppHandle, Runtime}; pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__"; -pub const INITIAL_APPEARANCE_SOURCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE_SOURCE__"; pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change"; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1); #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -30,64 +29,53 @@ impl Appearance { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum InitialAppearanceSource { - Settings, - LinuxSystem, -} - -impl InitialAppearanceSource { - fn as_str(self) -> &'static str { - match self { - Self::Settings => "settings", - Self::LinuxSystem => "linux-system", - } - } -} - #[derive(Clone)] pub struct SystemAppearanceState { - // Only read by the Linux polling thread - #[cfg_attr(not(target_os = "linux"), allow(dead_code))] last_appearance: Arc>>, } -pub fn initialization_script(appearance: Appearance, source: InitialAppearanceSource) -> String { - let appearance = appearance.as_str(); - let source = source.as_str(); - format!( - "window.{INITIAL_APPEARANCE_GLOBAL} = {appearance:?};\ - window.{INITIAL_APPEARANCE_SOURCE_GLOBAL} = {source:?};" - ) +impl SystemAppearanceState { + pub fn last_appearance(&self) -> Option { + *self.last_appearance.lock().expect("system appearance lock poisoned") + } } -#[cfg(target_os = "linux")] +pub fn initialization_script(appearance: Appearance) -> String { + let appearance = appearance.as_str(); + format!("window.{INITIAL_APPEARANCE_GLOBAL} = {appearance:?};") +} + +/// Detect the appearance the OS prefers, independent of any appearance that has +/// been forced onto app windows (which is what the webview itself reports). +#[cfg(any(target_os = "linux", target_os = "macos"))] pub fn system_appearance() -> Option { + #[cfg(target_os = "linux")] if let Some(appearance) = gsettings_system_appearance() { return Some(appearance); } + // On macOS this reads AppleInterfaceStyle from the global user defaults match dark_light::detect() { Ok(dark_light::Mode::Dark) => Some(Appearance::Dark), Ok(dark_light::Mode::Light) => Some(Appearance::Light), Ok(dark_light::Mode::Unspecified) => None, Err(err) => { - debug!("Failed to detect Linux system appearance: {err:?}"); + debug!("Failed to detect system appearance: {err:?}"); None } } } -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "macos")))] pub fn system_appearance() -> Option { None } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] pub fn watch(app_handle: AppHandle) -> Option { let last_appearance = system_appearance(); if last_appearance.is_none() { - debug!("Linux system appearance detection unavailable"); + debug!("System appearance detection unavailable"); return None; } @@ -103,12 +91,12 @@ pub fn watch(app_handle: AppHandle) -> Option(_app_handle: AppHandle) -> Option { None } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] pub fn emit_change(app_handle: &AppHandle, state: &SystemAppearanceState) { let appearance = system_appearance(); let mut last_appearance = diff --git a/packages/theme/src/appearance.ts b/packages/theme/src/appearance.ts index 44713145..6ef5db37 100644 --- a/packages/theme/src/appearance.ts +++ b/packages/theme/src/appearance.ts @@ -4,6 +4,21 @@ export type Appearance = "light" | "dark"; const SYSTEM_APPEARANCE_CHANGE_EVENT = "system_appearance_change"; +declare global { + interface Window { + __YAAK_INITIAL_APPEARANCE__?: Appearance; + } +} + +// NOTE: On macOS and Linux the backend detects the appearance the OS prefers and injects +// it here, later emitting change events. This is required because applying a theme forces +// the window appearance, which poisons what the webview reports (CSS media queries and +// window theme events follow the override). +export function getSystemAppearance(): Appearance | null { + const appearance = window.__YAAK_INITIAL_APPEARANCE__; + return appearance === "dark" || appearance === "light" ? appearance : null; +} + export function getCSSAppearance(): Appearance { return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } @@ -41,12 +56,24 @@ export function resolveAppearance( } export function subscribeToPreferredAppearance(cb: (appearance: Appearance) => void) { + const systemAppearance = getSystemAppearance(); + if (systemAppearance != null) { + cb(systemAppearance); + return subscribeToSystemAppearanceChange(cb); + } + cb(getCSSAppearance()); void getWindowAppearance().then(cb); return subscribeToPreferredAppearanceChange(cb); } export function subscribeToPreferredAppearanceChange(cb: (appearance: Appearance) => void) { + if (getSystemAppearance() != null) { + // The CSS and window sources are poisoned by forced window appearances, so only + // listen to the backend's system appearance detection when it's available + return subscribeToSystemAppearanceChange(cb); + } + const unsubscribeCSS = subscribeToCSSAppearanceChange(cb); const unsubscribeWindow = subscribeToWindowAppearanceChange(cb); const unsubscribeSystem = subscribeToSystemAppearanceChange(cb); diff --git a/packages/theme/src/index.ts b/packages/theme/src/index.ts index a5f331b0..f92166c0 100644 --- a/packages/theme/src/index.ts +++ b/packages/theme/src/index.ts @@ -2,6 +2,7 @@ export type { Appearance } from "./appearance"; export { subscribeToCSSAppearanceChange, getCSSAppearance, + getSystemAppearance, getWindowAppearance, resolveAppearance, subscribeToPreferredAppearance,