From 1f90b12fe5f97e4dcd95f57a0dd5876ab1a4e8ff Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sun, 10 Aug 2025 14:10:06 +0400 Subject: [PATCH] refactor: simplify system theme detection --- src-tauri/src/lib.rs | 6 +- src-tauri/src/theme_detector.rs | 645 ------------------------------ src/components/theme-provider.tsx | 145 +------ 3 files changed, 23 insertions(+), 773 deletions(-) delete mode 100644 src-tauri/src/theme_detector.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9eb1622..6a930d0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,7 +25,7 @@ mod profile; mod profile_importer; mod proxy_manager; mod settings_manager; -mod theme_detector; +// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme mod version_updater; extern crate lazy_static; @@ -62,7 +62,7 @@ use app_auto_updater::{ use profile_importer::{detect_existing_profiles, import_browser_profile}; -use theme_detector::get_system_theme; +// use theme_detector::get_system_theme; use group_manager::{ assign_profiles_to_group, create_profile_group, delete_profile_group, delete_selected_profiles, @@ -528,7 +528,7 @@ pub fn run() { check_for_app_updates, check_for_app_updates_manual, download_and_install_app_update, - get_system_theme, + // get_system_theme, // removed detect_existing_profiles, import_browser_profile, check_missing_binaries, diff --git a/src-tauri/src/theme_detector.rs b/src-tauri/src/theme_detector.rs deleted file mode 100644 index 0900bc4..0000000 --- a/src-tauri/src/theme_detector.rs +++ /dev/null @@ -1,645 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::process::Command; - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct SystemTheme { - pub theme: String, // "light", "dark", or "unknown" -} - -pub struct ThemeDetector; - -impl ThemeDetector { - fn new() -> Self { - Self - } - - pub fn instance() -> &'static ThemeDetector { - &THEME_DETECTOR - } - - /// Detect the system theme preference - pub fn detect_system_theme(&self) -> SystemTheme { - #[cfg(target_os = "linux")] - return linux::detect_system_theme(); - - #[cfg(target_os = "macos")] - return macos::detect_system_theme(); - - #[cfg(target_os = "windows")] - return windows::detect_system_theme(); - - #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] - return SystemTheme { - theme: "unknown".to_string(), - }; - } -} - -#[cfg(target_os = "linux")] -mod linux { - use super::*; - - pub fn detect_system_theme() -> SystemTheme { - // Try multiple methods in order of preference - - // 1. Try GNOME/GTK settings via gsettings - if let Ok(theme) = detect_gnome_theme() { - return SystemTheme { theme }; - } - - // 2. Try KDE Plasma settings via kreadconfig5/kreadconfig6 - if let Ok(theme) = detect_kde_theme() { - return SystemTheme { theme }; - } - - // 3. Try XFCE settings via xfconf-query - if let Ok(theme) = detect_xfce_theme() { - return SystemTheme { theme }; - } - - // 4. Try looking at current GTK theme name - if let Ok(theme) = detect_gtk_theme() { - return SystemTheme { theme }; - } - - // 5. Try dconf directly (fallback for GNOME-based systems) - if let Ok(theme) = detect_dconf_theme() { - return SystemTheme { theme }; - } - - // 6. Try environment variables - if let Ok(theme) = detect_env_theme() { - return SystemTheme { theme }; - } - - // 7. Try freedesktop portal - if let Ok(theme) = detect_portal_theme() { - return SystemTheme { theme }; - } - - // 8. Try looking at system color scheme files - if let Ok(theme) = detect_system_files_theme() { - return SystemTheme { theme }; - } - - // Fallback to unknown - SystemTheme { - theme: "unknown".to_string(), - } - } - - fn detect_gnome_theme() -> Result> { - // Check if gsettings is available - if !is_command_available("gsettings") { - return Err("gsettings not available".into()); - } - - // Try GNOME color scheme first (modern way) - if let Ok(output) = Command::new("gsettings") - .args(["get", "org.gnome.desktop.interface", "color-scheme"]) - .output() - { - if output.status.success() { - let scheme = String::from_utf8_lossy(&output.stdout).trim().to_string(); - match scheme.as_str() { - "'prefer-dark'" => return Ok("dark".to_string()), - "'prefer-light'" => return Ok("light".to_string()), - _ => {} - } - } - } - - // Fallback to GTK theme name detection - if let Ok(output) = Command::new("gsettings") - .args(["get", "org.gnome.desktop.interface", "gtk-theme"]) - .output() - { - if output.status.success() { - let theme_name = String::from_utf8_lossy(&output.stdout) - .trim() - .trim_matches('\'') - .to_lowercase(); - - if theme_name.contains("dark") || theme_name.contains("night") { - return Ok("dark".to_string()); - } else if theme_name.contains("light") || theme_name.contains("adwaita") { - return Ok("light".to_string()); - } - } - } - - Err("Could not detect GNOME theme".into()) - } - - fn detect_kde_theme() -> Result> { - // Try KDE Plasma 6 first - if is_command_available("kreadconfig6") { - if let Ok(output) = Command::new("kreadconfig6") - .args([ - "--file", - "kdeglobals", - "--group", - "KDE", - "--key", - "LookAndFeelPackage", - ]) - .output() - { - if output.status.success() { - let theme = String::from_utf8_lossy(&output.stdout) - .trim() - .to_lowercase(); - if theme.contains("dark") || theme.contains("breezedark") { - return Ok("dark".to_string()); - } else if theme.contains("light") || theme.contains("breeze") { - return Ok("light".to_string()); - } - } - } - - // Try color scheme as well - if let Ok(output) = Command::new("kreadconfig6") - .args([ - "--file", - "kdeglobals", - "--group", - "General", - "--key", - "ColorScheme", - ]) - .output() - { - if output.status.success() { - let scheme = String::from_utf8_lossy(&output.stdout) - .trim() - .to_lowercase(); - if scheme.contains("dark") || scheme.contains("breezedark") { - return Ok("dark".to_string()); - } else if scheme.contains("light") || scheme.contains("breeze") { - return Ok("light".to_string()); - } - } - } - } - - // Try KDE Plasma 5 as fallback - if is_command_available("kreadconfig5") { - if let Ok(output) = Command::new("kreadconfig5") - .args([ - "--file", - "kdeglobals", - "--group", - "KDE", - "--key", - "LookAndFeelPackage", - ]) - .output() - { - if output.status.success() { - let theme = String::from_utf8_lossy(&output.stdout) - .trim() - .to_lowercase(); - if theme.contains("dark") || theme.contains("breezedark") { - return Ok("dark".to_string()); - } else if theme.contains("light") || theme.contains("breeze") { - return Ok("light".to_string()); - } - } - } - } - - Err("Could not detect KDE theme".into()) - } - - fn detect_xfce_theme() -> Result> { - if !is_command_available("xfconf-query") { - return Err("xfconf-query not available".into()); - } - - // Check XFCE theme - if let Ok(output) = Command::new("xfconf-query") - .args(["-c", "xsettings", "-p", "/Net/ThemeName"]) - .output() - { - if output.status.success() { - let theme = String::from_utf8_lossy(&output.stdout) - .trim() - .to_lowercase(); - if theme.contains("dark") || theme.contains("night") { - return Ok("dark".to_string()); - } else if theme.contains("light") { - return Ok("light".to_string()); - } - } - } - - // Check XFCE window manager theme as backup - if let Ok(output) = Command::new("xfconf-query") - .args(["-c", "xfwm4", "-p", "/general/theme"]) - .output() - { - if output.status.success() { - let theme = String::from_utf8_lossy(&output.stdout) - .trim() - .to_lowercase(); - if theme.contains("dark") || theme.contains("night") { - return Ok("dark".to_string()); - } else if theme.contains("light") { - return Ok("light".to_string()); - } - } - } - - Err("Could not detect XFCE theme".into()) - } - - fn detect_gtk_theme() -> Result> { - // Try to read GTK3 settings file - if let Ok(home) = std::env::var("HOME") { - let gtk3_settings = std::path::Path::new(&home).join(".config/gtk-3.0/settings.ini"); - if gtk3_settings.exists() { - if let Ok(content) = std::fs::read_to_string(gtk3_settings) { - for line in content.lines() { - if line.starts_with("gtk-theme-name=") { - let theme_name = line.split('=').nth(1).unwrap_or("").trim().to_lowercase(); - if theme_name.contains("dark") || theme_name.contains("night") { - return Ok("dark".to_string()); - } else if theme_name.contains("light") || theme_name.contains("adwaita") { - return Ok("light".to_string()); - } - } - } - } - } - - // Try GTK4 settings - let gtk4_settings = std::path::Path::new(&home).join(".config/gtk-4.0/settings.ini"); - if gtk4_settings.exists() { - if let Ok(content) = std::fs::read_to_string(gtk4_settings) { - for line in content.lines() { - if line.starts_with("gtk-theme-name=") { - let theme_name = line.split('=').nth(1).unwrap_or("").trim().to_lowercase(); - if theme_name.contains("dark") || theme_name.contains("night") { - return Ok("dark".to_string()); - } else if theme_name.contains("light") || theme_name.contains("adwaita") { - return Ok("light".to_string()); - } - } - } - } - } - } - - Err("Could not detect GTK theme".into()) - } - - fn detect_dconf_theme() -> Result> { - if !is_command_available("dconf") { - return Err("dconf not available".into()); - } - - // Try reading color scheme directly from dconf - if let Ok(output) = Command::new("dconf") - .args(["read", "/org/gnome/desktop/interface/color-scheme"]) - .output() - { - if output.status.success() { - let scheme = String::from_utf8_lossy(&output.stdout).trim().to_string(); - match scheme.as_str() { - "'prefer-dark'" => return Ok("dark".to_string()), - "'prefer-light'" => return Ok("light".to_string()), - _ => {} - } - } - } - - // Try reading GTK theme from dconf - if let Ok(output) = Command::new("dconf") - .args(["read", "/org/gnome/desktop/interface/gtk-theme"]) - .output() - { - if output.status.success() { - let theme_name = String::from_utf8_lossy(&output.stdout) - .trim() - .trim_matches('\'') - .to_lowercase(); - - if theme_name.contains("dark") || theme_name.contains("night") { - return Ok("dark".to_string()); - } else if theme_name.contains("light") || theme_name.contains("adwaita") { - return Ok("light".to_string()); - } - } - } - - Err("Could not detect dconf theme".into()) - } - - fn detect_env_theme() -> Result> { - // Check common environment variables - if let Ok(theme) = std::env::var("GTK_THEME") { - let theme_lower = theme.to_lowercase(); - if theme_lower.contains("dark") || theme_lower.contains("night") { - return Ok("dark".to_string()); - } else if theme_lower.contains("light") { - return Ok("light".to_string()); - } - } - - if let Ok(theme) = std::env::var("QT_STYLE_OVERRIDE") { - let theme_lower = theme.to_lowercase(); - if theme_lower.contains("dark") || theme_lower.contains("night") { - return Ok("dark".to_string()); - } else if theme_lower.contains("light") { - return Ok("light".to_string()); - } - } - - Err("Could not detect theme from environment".into()) - } - - fn detect_portal_theme() -> Result> { - if !is_command_available("busctl") { - return Err("busctl not available".into()); - } - - // Try to query the color scheme via org.freedesktop.portal.Settings - if let Ok(output) = Command::new("busctl") - .args([ - "--user", - "call", - "org.freedesktop.portal.Desktop", - "/org/freedesktop/portal/desktop", - "org.freedesktop.portal.Settings", - "Read", - "ss", - "org.freedesktop.appearance", - "color-scheme", - ]) - .output() - { - if output.status.success() { - let response = String::from_utf8_lossy(&output.stdout); - // Parse DBus response - look for preference values - if response.contains(" 1 ") { - return Ok("dark".to_string()); - } else if response.contains(" 2 ") { - return Ok("light".to_string()); - } - } - } - - Err("Could not detect portal theme".into()) - } - - fn detect_system_files_theme() -> Result> { - // Check if we're in a dark terminal (heuristic) - if let Ok(term) = std::env::var("TERM") { - let term_lower = term.to_lowercase(); - if term_lower.contains("dark") || term_lower.contains("night") { - return Ok("dark".to_string()); - } - } - - // Check if we can determine from desktop session - if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") { - let desktop_lower = desktop.to_lowercase(); - // Some desktops default to dark - if desktop_lower.contains("i3") || desktop_lower.contains("sway") { - // Window managers often use dark themes by default - return Ok("dark".to_string()); - } - } - - Err("Could not detect theme from system files".into()) - } - - pub fn is_command_available(command: &str) -> bool { - Command::new("which") - .arg(command) - .output() - .map(|output| output.status.success()) - .unwrap_or(false) - } -} - -#[cfg(target_os = "macos")] -mod macos { - use super::*; - - pub fn detect_system_theme() -> SystemTheme { - // macOS theme detection using osascript - if let Ok(output) = Command::new("osascript") - .args([ - "-e", - "tell application \"System Events\" to tell appearance preferences to get dark mode", - ]) - .output() - { - if output.status.success() { - let result = String::from_utf8_lossy(&output.stdout).to_string(); - let result = result.trim(); - match result { - "true" => { - return SystemTheme { - theme: "dark".to_string(), - } - } - "false" => { - return SystemTheme { - theme: "light".to_string(), - } - } - _ => {} - } - } - } - - // Fallback method using defaults - if let Ok(output) = Command::new("defaults") - .args(["read", "-g", "AppleInterfaceStyle"]) - .output() - { - if output.status.success() { - let style = String::from_utf8_lossy(&output.stdout).to_string(); - let style = style.trim(); - if style.to_lowercase() == "dark" { - return SystemTheme { - theme: "dark".to_string(), - }; - } - } - } - - // Default to light if we can't determine - SystemTheme { - theme: "light".to_string(), - } - } -} - -#[cfg(target_os = "windows")] -mod windows { - use super::*; - - pub fn detect_system_theme() -> SystemTheme { - // Windows theme detection via registry - // This is a simplified implementation - you might want to use winreg crate for better registry access - if let Ok(output) = Command::new("reg") - .args([ - "query", - "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", - "/v", - "AppsUseLightTheme", - ]) - .output() - { - if output.status.success() { - let result = String::from_utf8_lossy(&output.stdout); - if result.contains("0x0") { - return SystemTheme { - theme: "dark".to_string(), - }; - } else if result.contains("0x1") { - return SystemTheme { - theme: "light".to_string(), - }; - } - } - } - - // Default to light if we can't determine - SystemTheme { - theme: "light".to_string(), - } - } -} - -// Command to expose this functionality to the frontend -#[tauri::command] -pub fn get_system_theme() -> SystemTheme { - let detector = ThemeDetector::instance(); - detector.detect_system_theme() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_theme_detector_creation() { - let detector = ThemeDetector::instance(); - - // Should not panic when creating detector - assert!( - std::ptr::eq(detector, ThemeDetector::instance()), - "Should return same instance (singleton)" - ); - } - - #[test] - fn test_detect_system_theme_returns_valid_value() { - let detector = ThemeDetector::instance(); - let theme = detector.detect_system_theme(); - - // Should return a valid theme string - assert!( - matches!(theme.theme.as_str(), "light" | "dark" | "unknown"), - "Theme should be one of: light, dark, unknown. Got: {}", - theme.theme - ); - - // Theme string should not be empty - assert!(!theme.theme.is_empty(), "Theme string should not be empty"); - } - - #[test] - fn test_get_system_theme_command() { - let theme = get_system_theme(); - - assert!( - matches!(theme.theme.as_str(), "light" | "dark" | "unknown"), - "Command should return valid theme. Got: {}", - theme.theme - ); - - // Should be consistent with direct detector call - let detector = ThemeDetector::instance(); - let direct_theme = detector.detect_system_theme(); - assert_eq!( - theme.theme, direct_theme.theme, - "Command and direct call should return same theme" - ); - } - - #[test] - fn test_system_theme_serialization() { - let theme = SystemTheme { - theme: "dark".to_string(), - }; - - // Test serialization - let serialized = serde_json::to_string(&theme); - assert!( - serialized.is_ok(), - "Should serialize SystemTheme successfully" - ); - - let json_str = serialized.unwrap(); - assert!( - json_str.contains("dark"), - "Serialized JSON should contain theme value" - ); - - // Test deserialization - let deserialized: Result = serde_json::from_str(&json_str); - assert!( - deserialized.is_ok(), - "Should deserialize SystemTheme successfully" - ); - - let theme_back = deserialized.unwrap(); - assert_eq!( - theme_back.theme, "dark", - "Deserialized theme should match original" - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn test_linux_command_availability_check() { - use super::linux::is_command_available; - - // Test with a command that should exist on most systems - let ls_available = is_command_available("ls"); - assert!(ls_available, "ls command should be available on Linux"); - - // Test with a command that definitely doesn't exist - let fake_available = is_command_available("definitely_nonexistent_command_12345"); - assert!(!fake_available, "Fake command should not be available"); - } - - #[test] - fn test_theme_detector_consistency() { - let detector = ThemeDetector::instance(); - - // Call detect_system_theme multiple times - should be consistent - let theme1 = detector.detect_system_theme(); - let theme2 = detector.detect_system_theme(); - let theme3 = detector.detect_system_theme(); - - assert_eq!( - theme1.theme, theme2.theme, - "Multiple calls should return consistent results" - ); - assert_eq!( - theme2.theme, theme3.theme, - "Multiple calls should return consistent results" - ); - } -} - -// Global singleton instance -lazy_static::lazy_static! { - static ref THEME_DETECTOR: ThemeDetector = ThemeDetector::new(); -} diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx index 160a09b..42def71 100644 --- a/src/components/theme-provider.tsx +++ b/src/components/theme-provider.tsx @@ -1,15 +1,10 @@ "use client"; -import { invoke } from "@tauri-apps/api/core"; import { ThemeProvider } from "next-themes"; import { useEffect, useState } from "react"; interface AppSettings { - show_settings_on_startup: boolean; - theme: string; -} - -interface SystemTheme { + set_as_default_browser: boolean; theme: string; } @@ -17,40 +12,10 @@ interface CustomThemeProviderProps { children: React.ReactNode; } -// Helper function to detect system dark mode preference -function getSystemTheme(): string { - if (typeof window !== "undefined") { - const isDarkMode = window.matchMedia( - "(prefers-color-scheme: dark)", - ).matches; - return isDarkMode ? "dark" : "light"; - } - return "light"; -} - -// Function to get native system theme (fallback to CSS media query) -async function getNativeSystemTheme(): Promise { - try { - const systemTheme = await invoke("get_system_theme"); - if (systemTheme.theme === "dark" || systemTheme.theme === "light") { - return systemTheme.theme; - } - // Fallback to CSS media query if native detection returns "unknown" - return getSystemTheme(); - } catch (error) { - console.warn( - "Failed to get native system theme, falling back to CSS media query:", - error, - ); - // Fallback to CSS media query - return getSystemTheme(); - } -} - export function CustomThemeProvider({ children }: CustomThemeProviderProps) { const [isLoading, setIsLoading] = useState(true); const [defaultTheme, setDefaultTheme] = useState("system"); - const [mounted, setMounted] = useState(false); + const [_mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); @@ -59,30 +24,24 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) { useEffect(() => { const loadTheme = async () => { try { + // Lazy import to avoid pulling Tauri API on SSR + const { invoke } = await import("@tauri-apps/api/core"); const settings = await invoke("get_app_settings"); - setDefaultTheme(settings.theme); - } catch (error) { - console.error("Failed to load theme settings:", error); - // For first-time users, detect system preference and apply it - const systemTheme = await getNativeSystemTheme(); - console.log( - "First-time user detected, applying system theme:", - systemTheme, - ); - - // Save the detected theme as the default - try { - await invoke("save_app_settings", { - settings: { - show_settings_on_startup: true, - theme: "system", - auto_updates_enabled: true, - }, - }); - } catch (saveError) { - console.error("Failed to save initial theme settings:", saveError); + if ( + settings?.theme === "light" || + settings?.theme === "dark" || + settings?.theme === "system" + ) { + setDefaultTheme(settings.theme); + } else { + setDefaultTheme("system"); } - + } catch (error) { + // Failed to load settings; fall back to system (handled by next-themes) + console.warn( + "Failed to load theme settings; defaulting to system:", + error, + ); setDefaultTheme("system"); } finally { setIsLoading(false); @@ -92,73 +51,9 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) { void loadTheme(); }, []); - // Monitor system theme changes when using "system" theme - useEffect(() => { - if (!mounted || defaultTheme !== "system") { - return; - } - - const checkSystemTheme = async () => { - try { - const currentSystemTheme = await getNativeSystemTheme(); - // Force re-evaluation by toggling the theme - const html = document.documentElement; - - // Apply the system theme class - if (currentSystemTheme === "dark") { - if (!html.classList.contains("dark")) { - html.classList.add("dark"); - html.classList.remove("light"); - } - } else { - if ( - !html.classList.contains("light") || - html.classList.contains("dark") - ) { - html.classList.add("light"); - html.classList.remove("dark"); - } - } - } catch (error) { - console.warn("Failed to check system theme:", error); - } - }; - - // Check system theme every 2 seconds when using system theme - const intervalId = setInterval(() => void checkSystemTheme(), 2000); - - // Initial check - void checkSystemTheme(); - - return () => { - clearInterval(intervalId); - }; - }, [mounted, defaultTheme]); - if (isLoading) { - // Use a consistent loading screen that doesn't depend on system theme during SSR - // This prevents hydration mismatch by ensuring server and client render the same initially - let loadingBgColor = "bg-white"; - let spinnerColor = "border-gray-900"; - - // Only apply system theme detection after component is mounted (client-side only) - if (mounted) { - // Use CSS media query for loading screen since async call would complicate this - const systemTheme = getSystemTheme(); - loadingBgColor = systemTheme === "dark" ? "bg-gray-900" : "bg-white"; - spinnerColor = - systemTheme === "dark" ? "border-white" : "border-gray-900"; - } - - return ( -
-
-
- ); + // Keep UI simple during initial settings load to avoid flicker + return null; } return (