fix: theme native system and check os support blur

This commit is contained in:
Faisal Amir 2025-10-02 12:32:52 +07:00
parent 80ee8fd2b2
commit aa0c4b0d1b
25 changed files with 464 additions and 59 deletions

View File

@ -3,10 +3,13 @@
"identifier": "logs-app-window", "identifier": "logs-app-window",
"description": "enables permissions for the logs app window", "description": "enables permissions for the logs app window",
"windows": ["logs-app-window"], "windows": ["logs-app-window"],
"platforms": ["linux", "macOS", "windows"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-set-theme", "core:window:allow-set-theme",
"core:window:allow-get-all-windows",
"core:event:allow-listen",
"log:default", "log:default",
"core:webview:allow-create-webview-window", "core:webview:allow-create-webview-window",
"core:window:allow-set-focus" "core:window:allow-set-focus"

View File

@ -3,10 +3,13 @@
"identifier": "logs-window", "identifier": "logs-window",
"description": "enables permissions for the logs window", "description": "enables permissions for the logs window",
"windows": ["logs-window-local-api-server"], "windows": ["logs-window-local-api-server"],
"platforms": ["linux", "macOS", "windows"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-set-theme", "core:window:allow-set-theme",
"core:window:allow-get-all-windows",
"core:event:allow-listen",
"log:default", "log:default",
"core:webview:allow-create-webview-window", "core:webview:allow-create-webview-window",
"core:window:allow-set-focus" "core:window:allow-set-focus"

View File

@ -8,6 +8,8 @@
"core:default", "core:default",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-set-theme", "core:window:allow-set-theme",
"core:window:allow-get-all-windows",
"core:event:allow-listen",
"log:default", "log:default",
"core:webview:allow-create-webview-window", "core:webview:allow-create-webview-window",
"core:window:allow-set-focus", "core:window:allow-set-focus",
@ -15,6 +17,18 @@
"hardware:allow-get-system-usage", "hardware:allow-get-system-usage",
"llamacpp:allow-get-devices", "llamacpp:allow-get-devices",
"llamacpp:allow-read-gguf-metadata", "llamacpp:allow-read-gguf-metadata",
"deep-link:allow-get-current" "deep-link:allow-get-current",
{
"identifier": "http:default",
"allow": [
{
"url": "https://*:*"
},
{
"url": "http://*:*"
}
],
"deny": []
}
] ]
} }

View File

@ -7,7 +7,7 @@ use std::{
}; };
use tar::Archive; use tar::Archive;
use tauri::{ use tauri::{
App, Emitter, Manager, Runtime, Wry App, Emitter, Manager, Runtime, Wry, WindowEvent
}; };
#[cfg(desktop)] #[cfg(desktop)]
@ -270,3 +270,32 @@ pub fn setup_tray(app: &App) -> tauri::Result<TrayIcon> {
}) })
.build(app) .build(app)
} }
pub fn setup_theme_listener<R: Runtime>(app: &App<R>) -> tauri::Result<()> {
// Setup theme listener for main window
if let Some(window) = app.get_webview_window("main") {
setup_window_theme_listener(app.handle().clone(), window);
}
Ok(())
}
fn setup_window_theme_listener<R: Runtime>(
app_handle: tauri::AppHandle<R>,
window: tauri::WebviewWindow<R>,
) {
let window_label = window.label().to_string();
let app_handle_clone = app_handle.clone();
window.on_window_event(move |event| {
if let WindowEvent::ThemeChanged(theme) = event {
let theme_str = match theme {
tauri::Theme::Light => "light",
tauri::Theme::Dark => "dark",
_ => "auto",
};
log::info!("System theme changed to: {} for window: {}", theme_str, window_label);
let _ = app_handle_clone.emit("theme-changed", theme_str);
}
});
}

View File

@ -117,3 +117,108 @@ pub fn is_library_available(library: &str) -> bool {
} }
} }
} }
// Check if the system supports blur/acrylic effects
// - Windows: Checks build version (17134+ for acrylic support)
// - Linux: Checks for KWin (KDE) or compositor with blur support
// - macOS: Always supported
#[tauri::command]
pub fn supports_blur_effects() -> bool {
#[cfg(target_os = "windows")]
{
// Windows 10 build 17134 (1803) and later support acrylic effects
// Windows 11 (build 22000+) has better support
use std::process::Command;
if let Ok(output) = Command::new("cmd")
.args(&["/C", "ver"])
.output()
{
if let Ok(version_str) = String::from_utf8(output.stdout) {
// Parse Windows version from output like "Microsoft Windows [Version 10.0.22631.4602]"
if let Some(version_part) = version_str.split("Version ").nth(1) {
if let Some(build_str) = version_part.split('.').nth(2) {
if let Ok(build) = build_str.split(']').next().unwrap_or("0").trim().parse::<u32>() {
// Windows 10 build 17134+ or Windows 11 build 22000+ support blur
let supports_blur = build >= 17134;
if supports_blur {
log::info!("✅ Windows build {} detected - Blur/Acrylic effects SUPPORTED", build);
} else {
log::warn!("❌ Windows build {} detected - Blur/Acrylic effects NOT SUPPORTED (requires build 17134+)", build);
}
return supports_blur;
}
}
}
}
}
// If we can't detect version, assume it doesn't support blur for safety
log::warn!("❌ Could not detect Windows version - Assuming NO blur support for safety");
false
}
#[cfg(target_os = "linux")]
{
use std::process::Command;
// Check for KDE Plasma with KWin (best blur support)
if let Ok(output) = Command::new("kwin_x11").arg("--version").output() {
if output.status.success() {
log::info!("✅ KDE/KWin detected - Blur effects SUPPORTED");
return true;
}
}
// Check for Wayland KWin
if let Ok(output) = Command::new("kwin_wayland").arg("--version").output() {
if output.status.success() {
log::info!("✅ KDE/KWin Wayland detected - Blur effects SUPPORTED");
return true;
}
}
// Check for GNOME with blur extensions (less reliable)
if std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default().contains("GNOME") {
log::info!("🔍 GNOME detected - Blur support depends on extensions");
// GNOME might have blur through extensions, allow it
return true;
}
// Check for Compiz (older but has blur)
if let Ok(_) = Command::new("compiz").arg("--version").output() {
log::info!("✅ Compiz compositor detected - Blur effects SUPPORTED");
return true;
}
// Check for Picom with blur (common X11 compositor)
if let Ok(output) = Command::new("picom").arg("--version").output() {
if output.status.success() {
log::info!("✅ Picom compositor detected - Blur effects SUPPORTED");
return true;
}
}
// Check environment variable for compositor
if let Ok(compositor) = std::env::var("COMPOSITOR") {
log::info!("🔍 Compositor detected: {} - Assuming blur support", compositor);
return true;
}
log::warn!("❌ No known blur-capable compositor detected on Linux");
false
}
#[cfg(target_os = "macos")]
{
// macOS always supports blur/vibrancy effects
log::info!("✅ macOS detected - Blur/Vibrancy effects SUPPORTED");
true
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
log::warn!("❌ Unknown platform - Assuming NO blur support");
false
}
}

View File

@ -78,6 +78,7 @@ pub fn run() {
core::system::commands::factory_reset, core::system::commands::factory_reset,
core::system::commands::read_logs, core::system::commands::read_logs,
core::system::commands::is_library_available, core::system::commands::is_library_available,
core::system::commands::supports_blur_effects,
// Server commands // Server commands
core::server::commands::start_server, core::server::commands::start_server,
core::server::commands::stop_server, core::server::commands::stop_server,
@ -193,6 +194,7 @@ pub fn run() {
} }
setup_mcp(app); setup_mcp(app);
setup::setup_theme_listener(app)?;
Ok(()) Ok(())
}) })
.build(tauri::generate_context!()) .build(tauri::generate_context!())

View File

@ -40,7 +40,7 @@
} }
], ],
"security": { "security": {
"capabilities": ["default"], "capabilities": ["default", "logs-app-window", "logs-window", "system-monitor-window"],
"csp": { "csp": {
"default-src": "'self' customprotocol: asset: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*", "default-src": "'self' customprotocol: asset: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*",
"connect-src": "ipc: http://ipc.localhost http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https: http:", "connect-src": "ipc: http://ipc.localhost http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https: http:",

View File

@ -17,7 +17,7 @@
<link rel="apple-touch-icon" href="/images/jan-logo.png" /> <link rel="apple-touch-icon" href="/images/jan-logo.png" />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover, interactive-widget=resizes-visual" content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover"
/> />
<title>Jan</title> <title>Jan</title>
<!-- INJECT_GOOGLE_ANALYTICS --> <!-- INJECT_GOOGLE_ANALYTICS -->

View File

@ -1,4 +1,4 @@
import { useAppearance, isDefaultColor } from '@/hooks/useAppearance' import { useAppearance, isDefaultColor, useBlurSupport } from '@/hooks/useAppearance'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { RgbaColor, RgbaColorPicker } from 'react-colorful' import { RgbaColor, RgbaColorPicker } from 'react-colorful'
import { IconColorPicker } from '@tabler/icons-react' import { IconColorPicker } from '@tabler/icons-react'
@ -14,6 +14,12 @@ export function ColorPickerAppBgColor() {
const { appBgColor, setAppBgColor } = useAppearance() const { appBgColor, setAppBgColor } = useAppearance()
const { isDark } = useTheme() const { isDark } = useTheme()
const { t } = useTranslation() const { t } = useTranslation()
const showAlphaSlider = useBlurSupport()
// Helper to get alpha value based on blur support
const getAlpha = (defaultAlpha: number) => {
return showAlphaSlider ? defaultAlpha : 1
}
const predefineAppBgColor: RgbaColor[] = [ const predefineAppBgColor: RgbaColor[] = [
isDark isDark
@ -21,38 +27,38 @@ export function ColorPickerAppBgColor() {
r: 25, r: 25,
g: 25, g: 25,
b: 25, b: 25,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4, a: getAlpha(0.4),
} }
: { : {
r: 255, r: 255,
g: 255, g: 255,
b: 255, b: 255,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4, a: getAlpha(0.4),
}, },
{ {
r: 70, r: 70,
g: 79, g: 79,
b: 229, b: 229,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5, a: getAlpha(0.5),
}, },
{ {
r: 238, r: 238,
g: 130, g: 130,
b: 238, b: 238,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5, a: getAlpha(0.5),
}, },
{ {
r: 255, r: 255,
g: 99, g: 99,
b: 71, b: 71,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5, a: getAlpha(0.5),
}, },
{ {
r: 255, r: 255,
g: 165, g: 165,
b: 0, b: 0,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5, a: getAlpha(0.5),
}, },
] ]

View File

@ -33,7 +33,7 @@ const DropdownAssistant = () => {
return ( return (
<> <>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}> <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<div className="flex items-center justify-between gap-2 bg-main-view-fg/5 py-1 hover:bg-main-view-fg/8 px-2 rounded-sm"> <div className="inline-flex items-center justify-between gap-2 bg-main-view-fg/5 py-1 hover:bg-main-view-fg/8 px-2 rounded-sm">
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button className="font-medium cursor-pointer flex items-center gap-1.5 relative z-20 max-w-40"> <button className="font-medium cursor-pointer flex items-center gap-1.5 relative z-20 max-w-40">
<div className="text-main-view-fg/80 flex items-center gap-1"> <div className="text-main-view-fg/80 flex items-center gap-1">

View File

@ -154,7 +154,6 @@ const LeftPanel = () => {
} }
}, [setLeftPanel, open]) }, [setLeftPanel, open])
const currentPath = useRouterState({ const currentPath = useRouterState({
select: (state) => state.location.pathname, select: (state) => state.location.pathname,
}) })
@ -640,7 +639,7 @@ const LeftPanel = () => {
data-test-id={`menu-${menu.title}`} data-test-id={`menu-${menu.title}`}
activeOptions={{ exact: true }} activeOptions={{ exact: true }}
className={cn( className={cn(
'flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 px-1 rounded', 'flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 my-0.5 px-1 rounded',
isActive && 'bg-left-panel-fg/10' isActive && 'bg-left-panel-fg/10'
)} )}
> >

View File

@ -31,6 +31,8 @@ vi.mock('zustand/middleware', () => ({
// Mock global constants // Mock global constants
Object.defineProperty(global, 'IS_WINDOWS', { value: false, writable: true }) Object.defineProperty(global, 'IS_WINDOWS', { value: false, writable: true })
Object.defineProperty(global, 'IS_LINUX', { value: false, writable: true }) Object.defineProperty(global, 'IS_LINUX', { value: false, writable: true })
Object.defineProperty(global, 'IS_MACOS', { value: false, writable: true })
Object.defineProperty(global, 'IS_TAURI', { value: false, writable: true })
Object.defineProperty(global, 'IS_WEB_APP', { value: false, writable: true }) Object.defineProperty(global, 'IS_WEB_APP', { value: false, writable: true })
describe('useAppearance', () => { describe('useAppearance', () => {
@ -217,7 +219,8 @@ describe('useAppearance', () => {
result.current.setAppBgColor(testColor) result.current.setAppBgColor(testColor)
}) })
expect(result.current.appBgColor).toEqual(testColor) // In web environment (IS_TAURI=false), alpha is forced to 1
expect(result.current.appBgColor).toEqual({ ...testColor, a: 1 })
}) })
it('should handle transparent colors', () => { it('should handle transparent colors', () => {
@ -230,6 +233,36 @@ describe('useAppearance', () => {
expect(result.current.appAccentBgColor).toEqual(transparentColor) expect(result.current.appAccentBgColor).toEqual(transparentColor)
}) })
it('should preserve alpha when blur is supported (macOS)', () => {
// Mock macOS environment
Object.defineProperty(global, 'IS_MACOS', { value: true, writable: true })
Object.defineProperty(global, 'IS_TAURI', { value: true, writable: true })
Object.defineProperty(global, 'IS_WINDOWS', { value: false, writable: true })
Object.defineProperty(global, 'IS_LINUX', { value: false, writable: true })
const setPropertySpy = vi.fn()
Object.defineProperty(document.documentElement, 'style', {
value: {
setProperty: setPropertySpy,
},
writable: true,
})
const { result } = renderHook(() => useAppearance())
const testColor = { r: 128, g: 64, b: 192, a: 0.5 }
act(() => {
result.current.setAppBgColor(testColor)
})
// On macOS with Tauri, alpha should be preserved
expect(result.current.appBgColor).toEqual(testColor)
// Reset for other tests
Object.defineProperty(global, 'IS_MACOS', { value: false, writable: true })
Object.defineProperty(global, 'IS_TAURI', { value: false, writable: true })
})
}) })
describe('Edge cases', () => { describe('Edge cases', () => {

View File

@ -4,6 +4,8 @@ import { localStorageKey } from '@/constants/localStorage'
import { RgbaColor } from 'react-colorful' import { RgbaColor } from 'react-colorful'
import { rgb, oklch, formatCss } from 'culori' import { rgb, oklch, formatCss } from 'culori'
import { useTheme } from './useTheme' import { useTheme } from './useTheme'
import { useEffect, useState } from 'react'
import { getServiceHub } from '@/hooks/useServiceHub'
export type FontSize = '14px' | '15px' | '16px' | '18px' export type FontSize = '14px' | '15px' | '16px' | '18px'
export type ChatWidth = 'full' | 'compact' export type ChatWidth = 'full' | 'compact'
@ -41,19 +43,37 @@ export const fontSizeOptions = [
{ label: 'Extra Large', value: '18px' as FontSize }, { label: 'Extra Large', value: '18px' as FontSize },
] ]
// Helper to determine if blur effects are supported
// This will be dynamically checked on Windows and Linux
let blurEffectsSupported = true
if ((IS_WINDOWS || IS_LINUX) && IS_TAURI) {
// Default to false for Windows/Linux, will be checked async
blurEffectsSupported = false
}
// Helper to get the appropriate alpha value
const getAlphaValue = () => {
// Web always uses alpha = 1
if (!IS_TAURI) return 1
// Windows/Linux use 1 if blur not supported, 0.4 if supported
if ((IS_WINDOWS || IS_LINUX) && !blurEffectsSupported) return 1
// macOS and Windows/Linux with blur support use 0.4
return 0.4
}
// Default appearance settings // Default appearance settings
const defaultFontSize: FontSize = '15px' const defaultFontSize: FontSize = '15px'
const defaultAppBgColor: RgbaColor = { const defaultAppBgColor: RgbaColor = {
r: 25, r: 25,
g: 25, g: 25,
b: 25, b: 25,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4, a: getAlphaValue(),
} }
const defaultLightAppBgColor: RgbaColor = { const defaultLightAppBgColor: RgbaColor = {
r: 255, r: 255,
g: 255, g: 255,
b: 255, b: 255,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4, a: getAlphaValue(),
} }
const defaultAppMainViewBgColor: RgbaColor = { r: 25, g: 25, b: 25, a: 1 } const defaultAppMainViewBgColor: RgbaColor = { r: 25, g: 25, b: 25, a: 1 }
const defaultLightAppMainViewBgColor: RgbaColor = { const defaultLightAppMainViewBgColor: RgbaColor = {
@ -128,6 +148,45 @@ export const getDefaultTextColor = (isDark: boolean): string => {
return isDark ? defaultDarkLeftPanelTextColor : defaultLightLeftPanelTextColor return isDark ? defaultDarkLeftPanelTextColor : defaultLightLeftPanelTextColor
} }
// Hook to check if alpha slider should be shown
export const useBlurSupport = () => {
const [supportsBlur, setSupportsBlur] = useState(
IS_MACOS && IS_TAURI // Default to true only for macOS
)
useEffect(() => {
const checkBlurSupport = async () => {
if ((IS_WINDOWS || IS_LINUX) && IS_TAURI) {
try {
const supported = await getServiceHub().app().supportsBlurEffects()
blurEffectsSupported = supported
setSupportsBlur(supported)
const platform = IS_WINDOWS ? 'Windows' : 'Linux'
if (supported) {
console.log(`${platform} blur effects: SUPPORTED - Alpha slider will be shown`)
} else {
console.log(`${platform} blur effects: NOT SUPPORTED - Alpha slider will be hidden, alpha set to 1`)
}
} catch (error) {
console.error(`❌ Failed to check ${IS_WINDOWS ? 'Windows' : 'Linux'} blur support:`, error)
setSupportsBlur(false)
}
} else if (IS_MACOS && IS_TAURI) {
console.log('🍎 macOS platform: Blur effects supported, alpha slider shown')
} else if (!IS_TAURI) {
console.log('🌐 Web platform: Alpha slider hidden, alpha set to 1')
}
}
checkBlurSupport()
}, [])
// Return true if alpha slider should be shown
// Show on macOS (always), and conditionally on Windows/Linux based on detection
return IS_TAURI && (IS_MACOS || supportsBlur)
}
export const useAppearance = create<AppearanceState>()( export const useAppearance = create<AppearanceState>()(
persist( persist(
(set) => { (set) => {
@ -295,6 +354,11 @@ export const useAppearance = create<AppearanceState>()(
finalColor = isDark ? defaultAppBgColor : defaultLightAppBgColor finalColor = isDark ? defaultAppBgColor : defaultLightAppBgColor
} }
// Force alpha to 1 if blur effects are not supported
if (!blurEffectsSupported && (IS_WINDOWS || IS_LINUX || !IS_TAURI)) {
finalColor = { ...finalColor, a: 1 }
}
// Convert RGBA to a format culori can work with // Convert RGBA to a format culori can work with
const culoriRgb = rgb({ const culoriRgb = rgb({
mode: 'rgb', mode: 'rgb',

View File

@ -32,7 +32,9 @@ export const useTheme = create<ThemeState>()(
await getServiceHub().theme().setTheme(null) await getServiceHub().theme().setTheme(null)
set(() => ({ activeTheme, isDark: isDarkMode })) set(() => ({ activeTheme, isDark: isDarkMode }))
} else { } else {
await getServiceHub().theme().setTheme(activeTheme as ThemeMode) await getServiceHub()
.theme()
.setTheme(activeTheme as ThemeMode)
set(() => ({ activeTheme, isDark: activeTheme === 'dark' })) set(() => ({ activeTheme, isDark: activeTheme === 'dark' }))
} }
}, },

View File

@ -56,7 +56,6 @@
@layer base { @layer base {
body { body {
@apply overflow-hidden; @apply overflow-hidden;
background-color: white;
min-height: 100vh; min-height: 100vh;
min-height: -webkit-fill-available; min-height: -webkit-fill-available;
padding-top: env(safe-area-inset-top, 0px); padding-top: env(safe-area-inset-top, 0px);

View File

@ -1,5 +1,5 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useAppearance } from '@/hooks/useAppearance' import { useAppearance, useBlurSupport } from '@/hooks/useAppearance'
import { useTheme } from '@/hooks/useTheme' import { useTheme } from '@/hooks/useTheme'
import { import {
isDefaultColor, isDefaultColor,
@ -29,14 +29,15 @@ export function AppearanceProvider() {
appDestructiveTextColor, appDestructiveTextColor,
} = useAppearance() } = useAppearance()
const { isDark } = useTheme() const { isDark } = useTheme()
const showAlphaSlider = useBlurSupport()
// Apply appearance settings on mount and when they change // Apply appearance settings on mount and when they change
useEffect(() => { useEffect(() => {
// Apply font size // Apply font size
document.documentElement.style.setProperty('--font-size-base', fontSize) document.documentElement.style.setProperty('--font-size-base', fontSize)
// Hide alpha slider when IS_LINUX || !IS_TAURI // Hide alpha slider when blur is not supported
const shouldHideAlpha = IS_LINUX || !IS_TAURI const shouldHideAlpha = !showAlphaSlider
let alphaStyleElement = document.getElementById('alpha-slider-style') let alphaStyleElement = document.getElementById('alpha-slider-style')
if (shouldHideAlpha) { if (shouldHideAlpha) {
@ -55,12 +56,13 @@ export function AppearanceProvider() {
// Import culori functions dynamically to avoid SSR issues // Import culori functions dynamically to avoid SSR issues
import('culori').then(({ rgb, oklch, formatCss }) => { import('culori').then(({ rgb, oklch, formatCss }) => {
// Convert RGBA to a format culori can work with // Convert RGBA to a format culori can work with
// Use alpha = 1 when blur is not supported
const culoriRgb = rgb({ const culoriRgb = rgb({
mode: 'rgb', mode: 'rgb',
r: appBgColor.r / 255, r: appBgColor.r / 255,
g: appBgColor.g / 255, g: appBgColor.g / 255,
b: appBgColor.b / 255, b: appBgColor.b / 255,
alpha: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : appBgColor.a, alpha: showAlphaSlider ? appBgColor.a : 1,
}) })
const culoriRgbMainView = rgb({ const culoriRgbMainView = rgb({
@ -176,6 +178,7 @@ export function AppearanceProvider() {
appAccentTextColor, appAccentTextColor,
appDestructiveBgColor, appDestructiveBgColor,
appDestructiveTextColor, appDestructiveTextColor,
showAlphaSlider,
]) ])
// Update appearance when theme changes // Update appearance when theme changes

View File

@ -1,5 +1,6 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useTheme, checkOSDarkMode } from '@/hooks/useTheme' import { useTheme, checkOSDarkMode } from '@/hooks/useTheme'
import { isPlatformTauri } from '@/lib/platform/utils'
/** /**
* ThemeProvider ensures theme settings are applied on every page load * ThemeProvider ensures theme settings are applied on every page load
@ -11,12 +12,21 @@ export function ThemeProvider() {
// Detect OS theme on mount and apply it // Detect OS theme on mount and apply it
useEffect(() => { useEffect(() => {
// If theme is set to auto, detect OS preference // Force refresh theme on mount to handle Linux startup timing issues
const refreshTheme = () => {
if (activeTheme === 'auto') { if (activeTheme === 'auto') {
const isDarkMode = checkOSDarkMode() const isDarkMode = checkOSDarkMode()
setIsDark(isDarkMode) setIsDark(isDarkMode)
setTheme('auto') setTheme('auto')
} }
}
// Initial refresh
refreshTheme()
// On Linux, desktop environment may not be ready immediately
// Add a delayed refresh to catch the correct OS theme
const timeoutId = setTimeout(refreshTheme, 100)
// Listen for changes in OS theme preference // Listen for changes in OS theme preference
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
@ -30,12 +40,37 @@ export function ThemeProvider() {
} }
} }
// Add event listener // Add event listener for browser/web
mediaQuery.addEventListener('change', handleThemeChange) mediaQuery.addEventListener('change', handleThemeChange)
// Listen to Tauri native theme events (uses XDG Desktop Portal on Linux)
let unlistenTauri: (() => void) | undefined
if (isPlatformTauri()) {
import('@tauri-apps/api/event')
.then(({ listen }) => {
return listen<string>('theme-changed', (event) => {
if (activeTheme === 'auto') {
const isDark = event.payload === 'dark'
setIsDark(isDark)
}
})
})
.then((unlisten) => {
unlistenTauri = unlisten
})
.catch((err) => {
console.error('Failed to setup Tauri theme listener:', err)
})
}
// Clean up // Clean up
return () => { return () => {
clearTimeout(timeoutId)
mediaQuery.removeEventListener('change', handleThemeChange) mediaQuery.removeEventListener('change', handleThemeChange)
if (unlistenTauri) {
unlistenTauri()
}
} }
}, [activeTheme, setIsDark, setTheme]) }, [activeTheme, setIsDark, setTheme])

View File

@ -11,7 +11,7 @@ import SetupScreen from '@/containers/SetupScreen'
import { route } from '@/constants/routes' import { route } from '@/constants/routes'
type SearchParams = { type SearchParams = {
model?: { 'model'?: {
id: string id: string
provider: string provider: string
} }
@ -33,7 +33,10 @@ export const Route = createFileRoute(route.home as any)({
} }
// Only include temporary-chat if it's explicitly true // Only include temporary-chat if it's explicitly true
if (search[TEMPORARY_CHAT_QUERY_ID] === 'true' || search[TEMPORARY_CHAT_QUERY_ID] === true) { if (
search[TEMPORARY_CHAT_QUERY_ID] === 'true' ||
search[TEMPORARY_CHAT_QUERY_ID] === true
) {
result['temporary-chat'] = true result['temporary-chat'] = true
} }
@ -77,7 +80,7 @@ function Index() {
</HeaderPage> </HeaderPage>
<div <div
className={cn( className={cn(
'h-full overflow-y-auto flex flex-col gap-2 justify-center px-3 sm:px-4 md:px-8 py-4 md:py-0' 'h-full overflow-y-auto inline-flex flex-col gap-2 justify-center px-3 sm:px-4 md:px-8 py-4 md:py-0'
)} )}
> >
<div <div
@ -110,7 +113,9 @@ function Index() {
isMobile ? 'text-base' : 'text-lg' isMobile ? 'text-base' : 'text-lg'
)} )}
> >
{isTemporaryChat ? t('chat:temporaryChatDescription') : t('chat:description')} {isTemporaryChat
? t('chat:temporaryChatDescription')
: t('chat:description')}
</p> </p>
</div> </div>
<div className="flex-1 shrink-0"> <div className="flex-1 shrink-0">

View File

@ -31,7 +31,10 @@ function LogsViewer() {
useEffect(() => { useEffect(() => {
let lastLogsLength = 0 let lastLogsLength = 0
function updateLogs() { function updateLogs() {
serviceHub.app().readLogs().then((logData) => { serviceHub
.app()
.readLogs()
.then((logData) => {
let needScroll = false let needScroll = false
const filteredLogs = logData.filter(Boolean) as LogEntry[] const filteredLogs = logData.filter(Boolean) as LogEntry[]
if (filteredLogs.length > lastLogsLength) needScroll = true if (filteredLogs.length > lastLogsLength) needScroll = true

View File

@ -39,4 +39,9 @@ export class DefaultAppService implements AppService {
console.log('readYaml called with path:', path) console.log('readYaml called with path:', path)
throw new Error('readYaml not implemented in default app service') throw new Error('readYaml not implemented in default app service')
} }
async supportsBlurEffects(): Promise<boolean> {
// On web/non-Windows platforms, always return false
return false
}
} }

View File

@ -75,4 +75,8 @@ export class TauriAppService extends DefaultAppService {
async readYaml<T = unknown>(path: string): Promise<T> { async readYaml<T = unknown>(path: string): Promise<T> {
return await invoke<T>('read_yaml', { path }) return await invoke<T>('read_yaml', { path })
} }
async supportsBlurEffects(): Promise<boolean> {
return await invoke<boolean>('supports_blur_effects')
}
} }

View File

@ -17,4 +17,5 @@ export interface AppService {
relocateJanDataFolder(path: string): Promise<void> relocateJanDataFolder(path: string): Promise<void>
getServerStatus(): Promise<boolean> getServerStatus(): Promise<boolean>
readYaml<T = unknown>(path: string): Promise<T> readYaml<T = unknown>(path: string): Promise<T>
supportsBlurEffects(): Promise<boolean>
} }

View File

@ -45,4 +45,9 @@ export class WebAppService implements AppService {
console.log('YAML reading not available in web mode') console.log('YAML reading not available in web mode')
throw new Error('readYaml not implemented in web app service') throw new Error('readYaml not implemented in web app service')
} }
async supportsBlurEffects(): Promise<boolean> {
// Web platforms don't support blur effects
return false
}
} }

View File

@ -2,7 +2,8 @@
* Tauri Theme Service - Desktop implementation * Tauri Theme Service - Desktop implementation
*/ */
import { getCurrentWindow, Theme } from '@tauri-apps/api/window' import { Theme } from '@tauri-apps/api/window'
import { getAllWebviewWindows } from '@tauri-apps/api/webviewWindow'
import type { ThemeMode } from './types' import type { ThemeMode } from './types'
import { DefaultThemeService } from './default' import { DefaultThemeService } from './default'
@ -10,7 +11,27 @@ export class TauriThemeService extends DefaultThemeService {
async setTheme(theme: ThemeMode): Promise<void> { async setTheme(theme: ThemeMode): Promise<void> {
try { try {
const tauriTheme = theme as Theme | null const tauriTheme = theme as Theme | null
await getCurrentWindow().setTheme(tauriTheme)
// Update all open windows, not just the current one
const allWindows = await getAllWebviewWindows()
// Convert to array if it's not already
const windowsArray = Array.isArray(allWindows)
? allWindows
: Object.values(allWindows)
await Promise.all(
windowsArray.map(async (window) => {
try {
await window.setTheme(tauriTheme)
} catch (error) {
console.error(
`Failed to set theme for window ${window.label}:`,
error
)
}
})
)
} catch (error) { } catch (error) {
console.error('Error setting theme in Tauri:', error) console.error('Error setting theme in Tauri:', error)
throw error throw error
@ -21,7 +42,7 @@ export class TauriThemeService extends DefaultThemeService {
return { return {
setTheme: (theme: ThemeMode): Promise<void> => { setTheme: (theme: ThemeMode): Promise<void> => {
return this.setTheme(theme) return this.setTheme(theme)
} },
} }
} }
} }

View File

@ -7,8 +7,39 @@ import type { WindowConfig, WebviewWindowInstance } from './types'
import { DefaultWindowService } from './default' import { DefaultWindowService } from './default'
export class TauriWindowService extends DefaultWindowService { export class TauriWindowService extends DefaultWindowService {
async createWebviewWindow(config: WindowConfig): Promise<WebviewWindowInstance> { async createWebviewWindow(
config: WindowConfig
): Promise<WebviewWindowInstance> {
try { try {
// Get current theme from localStorage
const storedTheme = localStorage.getItem('jan-theme')
let theme: 'light' | 'dark' | undefined = undefined
if (storedTheme) {
try {
const themeData = JSON.parse(storedTheme)
const activeTheme = themeData?.state?.activeTheme
const isDark = themeData?.state?.isDark
// Set theme based on stored preference
if (activeTheme === 'auto') {
theme = undefined // Let OS decide
} else if (
activeTheme === 'dark' ||
(activeTheme === 'auto' && isDark)
) {
theme = 'dark'
} else if (
activeTheme === 'light' ||
(activeTheme === 'auto' && !isDark)
) {
theme = 'light'
}
} catch (e) {
console.warn('Failed to parse theme from localStorage:', e)
}
}
const webviewWindow = new WebviewWindow(config.label, { const webviewWindow = new WebviewWindow(config.label, {
url: config.url, url: config.url,
title: config.title, title: config.title,
@ -20,8 +51,12 @@ export class TauriWindowService extends DefaultWindowService {
maximizable: config.maximizable, maximizable: config.maximizable,
closable: config.closable, closable: config.closable,
fullscreen: config.fullscreen, fullscreen: config.fullscreen,
theme: theme,
}) })
// Setup theme listener for this window
this.setupThemeListenerForWindow(webviewWindow)
return { return {
label: config.label, label: config.label,
async close() { async close() {
@ -38,7 +73,7 @@ export class TauriWindowService extends DefaultWindowService {
}, },
async setTitle(title: string) { async setTitle(title: string) {
await webviewWindow.setTitle(title) await webviewWindow.setTitle(title)
} },
} }
} catch (error) { } catch (error) {
console.error('Error creating Tauri window:', error) console.error('Error creating Tauri window:', error)
@ -46,7 +81,9 @@ export class TauriWindowService extends DefaultWindowService {
} }
} }
async getWebviewWindowByLabel(label: string): Promise<WebviewWindowInstance | null> { async getWebviewWindowByLabel(
label: string
): Promise<WebviewWindowInstance | null> {
try { try {
const existingWindow = await WebviewWindow.getByLabel(label) const existingWindow = await WebviewWindow.getByLabel(label)
@ -67,7 +104,7 @@ export class TauriWindowService extends DefaultWindowService {
}, },
async setTitle(title: string) { async setTitle(title: string) {
await existingWindow.setTitle(title) await existingWindow.setTitle(title)
} },
} }
} }
@ -135,8 +172,35 @@ export class TauriWindowService extends DefaultWindowService {
center: true, center: true,
}) })
} catch (error) { } catch (error) {
console.error('Error opening local API server logs window in Tauri:', error) console.error(
'Error opening local API server logs window in Tauri:',
error
)
throw error throw error
} }
} }
private setupThemeListenerForWindow(window: WebviewWindow): void {
// Listen to theme change events from Tauri backend
import('@tauri-apps/api/event')
.then(({ listen }) => {
return listen<string>('theme-changed', async (event) => {
const theme = event.payload
try {
if (theme === 'dark') {
await window.setTheme('dark')
} else if (theme === 'light') {
await window.setTheme('light')
} else {
await window.setTheme(null)
}
} catch (err) {
console.error('Failed to update window theme:', err)
}
})
})
.catch((err) => {
console.error('Failed to setup theme listener for window:', err)
})
}
} }