diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4e3d49c3..c1018e9a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -470,6 +470,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -1934,7 +1940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -2051,6 +2057,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2469,6 +2488,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.17.1" @@ -2484,7 +2513,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -3250,6 +3279,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -3392,6 +3434,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + [[package]] name = "quick-xml" version = "0.38.4" @@ -4742,6 +4790,7 @@ dependencies = [ "heck 0.5.0", "http", "http-range", + "image", "jni", "libc", "log", @@ -4809,7 +4858,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -5290,6 +5339,9 @@ dependencies = [ "gray_matter", "log", "notify", + "objc2", + "objc2-app-kit", + "objc2-foundation", "regex", "reqwest 0.12.28", "sentry", @@ -5517,7 +5569,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 32066683..a74794e2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,7 +21,7 @@ serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.9" log = "0.4" notify = "6.1" -tauri = { version = "2.10.0", features = ["protocol-asset", "devtools"] } +tauri = { version = "2.10.0", features = ["protocol-asset", "devtools", "image-png"] } tauri-plugin-log = "2" gray_matter = "0.2" walkdir = "2" @@ -41,4 +41,9 @@ uuid = { version = "1", features = ["v4"] } tempfile = "3" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSImage"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSData", "NSThread"] } + [dev-dependencies] diff --git a/src-tauri/icons/512x512-dark.png b/src-tauri/icons/512x512-dark.png new file mode 100644 index 00000000..d28d1242 Binary files /dev/null and b/src-tauri/icons/512x512-dark.png differ diff --git a/src-tauri/icons/icon-source-dark.svg b/src-tauri/icons/icon-source-dark.svg new file mode 100644 index 00000000..4953c934 --- /dev/null +++ b/src-tauri/icons/icon-source-dark.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/src/app_icon.rs b/src-tauri/src/app_icon.rs new file mode 100644 index 00000000..14b4f26a --- /dev/null +++ b/src-tauri/src/app_icon.rs @@ -0,0 +1,97 @@ +use tauri::Manager; + +const LIGHT_ICON_BYTES: &[u8] = include_bytes!("../icons/512x512.png"); +const DARK_ICON_BYTES: &[u8] = include_bytes!("../icons/512x512-dark.png"); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AppIconMode { + Light, + Dark, +} + +impl AppIconMode { + fn parse(value: &str) -> Result { + match value { + "light" => Ok(Self::Light), + "dark" => Ok(Self::Dark), + _ => Err(format!("Unsupported app icon theme mode: {value}")), + } + } + + fn png_bytes(self) -> &'static [u8] { + match self { + Self::Light => LIGHT_ICON_BYTES, + Self::Dark => DARK_ICON_BYTES, + } + } +} + +pub fn update_app_icon_for_theme( + app_handle: &tauri::AppHandle, + theme_mode: &str, +) -> Result<(), String> { + let icon_bytes = AppIconMode::parse(theme_mode)?.png_bytes(); + let image = tauri::image::Image::from_bytes(icon_bytes) + .map_err(|err| format!("Failed to decode app icon: {err}"))?; + + for window in app_handle.webview_windows().into_values() { + window + .set_icon(image.clone()) + .map_err(|err| format!("Failed to update window icon: {err}"))?; + } + + apply_platform_app_icon(app_handle, icon_bytes) +} + +#[cfg(target_os = "macos")] +fn apply_platform_app_icon( + app_handle: &tauri::AppHandle, + icon_bytes: &'static [u8], +) -> Result<(), String> { + app_handle + .run_on_main_thread(move || { + if let Err(err) = set_macos_application_icon(icon_bytes) { + log::warn!("Failed to update macOS application icon: {err}"); + } + }) + .map_err(|err| format!("Failed to schedule macOS app icon update: {err}")) +} + +#[cfg(target_os = "macos")] +fn set_macos_application_icon(icon_bytes: &[u8]) -> Result<(), String> { + use objc2::AllocAnyThread; + use objc2_app_kit::{NSApplication, NSImage}; + use objc2_foundation::{MainThreadMarker, NSData}; + + let marker = unsafe { MainThreadMarker::new_unchecked() }; + let app = NSApplication::sharedApplication(marker); + let data = NSData::with_bytes(icon_bytes); + let app_icon = NSImage::initWithData(NSImage::alloc(), &data) + .ok_or_else(|| "Failed to create macOS app icon image".to_string())?; + unsafe { app.setApplicationIconImage(Some(&app_icon)) }; + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn apply_platform_app_icon( + _app_handle: &tauri::AppHandle, + _icon_bytes: &'static [u8], +) -> Result<(), String> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::AppIconMode; + + #[test] + fn parses_supported_icon_modes() { + assert_eq!(AppIconMode::parse("light"), Ok(AppIconMode::Light)); + assert_eq!(AppIconMode::parse("dark"), Ok(AppIconMode::Dark)); + } + + #[test] + fn rejects_unknown_icon_modes() { + assert!(AppIconMode::parse("system").is_err()); + } +} diff --git a/src-tauri/src/commands/app_icon.rs b/src-tauri/src/commands/app_icon.rs new file mode 100644 index 00000000..d591ad34 --- /dev/null +++ b/src-tauri/src/commands/app_icon.rs @@ -0,0 +1,11 @@ +#[cfg(desktop)] +#[tauri::command] +pub fn update_app_icon(app_handle: tauri::AppHandle, theme_mode: String) -> Result<(), String> { + crate::app_icon::update_app_icon_for_theme(&app_handle, &theme_mode) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn update_app_icon(_theme_mode: String) -> Result<(), String> { + Ok(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 55203d21..f94a37fd 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ mod ai; +mod app_icon; mod clipboard; mod delete; mod folders; @@ -14,6 +15,7 @@ mod version; use std::borrow::Cow; pub use ai::*; +pub use app_icon::*; pub use clipboard::*; pub use delete::*; pub use folders::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8b5a8da4..f410ac47 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ pub mod ai_agents; pub mod ai_models; +mod app_icon; pub mod app_updater; pub mod claude_cli; mod claude_invocation; @@ -501,6 +502,7 @@ macro_rules! app_invoke_handler { commands::get_settings, commands::check_for_app_update, commands::update_menu_state, + commands::update_app_icon, commands::trigger_menu_command, commands::update_current_window_min_size, commands::perform_current_window_titlebar_double_click, diff --git a/src/hooks/useThemeMode.ts b/src/hooks/useThemeMode.ts index 39e003c2..4406687d 100644 --- a/src/hooks/useThemeMode.ts +++ b/src/hooks/useThemeMode.ts @@ -7,6 +7,7 @@ import { writeStoredThemeMode, type ThemeMode, } from '../lib/themeMode' +import { syncAppIconThemeMode } from '../lib/appIconTheme' function resolveRuntimeThemeMode(themeMode: ThemeMode | null | undefined): ThemeMode { if (themeMode) return themeMode @@ -26,8 +27,9 @@ function writeThemeModeMirror(themeMode: ThemeMode): void { } function applySelectedThemeMode(themeMode: ThemeMode): void { - applyThemeSelectionToDocument(document, themeMode, currentMatchMedia()) + const resolvedMode = applyThemeSelectionToDocument(document, themeMode, currentMatchMedia()) writeThemeModeMirror(themeMode) + void syncAppIconThemeMode(resolvedMode) } function getSystemThemeMediaQueryList(): MediaQueryList | null { diff --git a/src/lib/appIconTheme.test.ts b/src/lib/appIconTheme.test.ts new file mode 100644 index 00000000..4d4bde78 --- /dev/null +++ b/src/lib/appIconTheme.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { syncAppIconThemeMode } from './appIconTheme' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + isTauri: vi.fn(), +})) + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: mocks.invoke, +})) + +vi.mock('../mock-tauri', () => ({ + isTauri: mocks.isTauri, +})) + +describe('syncAppIconThemeMode', () => { + beforeEach(() => { + mocks.invoke.mockReset() + mocks.isTauri.mockReset() + }) + + it('skips browser runs', async () => { + mocks.isTauri.mockReturnValue(false) + + await syncAppIconThemeMode('dark') + + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('sends the resolved theme mode to Tauri', async () => { + mocks.isTauri.mockReturnValue(true) + mocks.invoke.mockResolvedValue(undefined) + + await syncAppIconThemeMode('dark') + + expect(mocks.invoke).toHaveBeenCalledWith('update_app_icon', { themeMode: 'dark' }) + }) +}) diff --git a/src/lib/appIconTheme.ts b/src/lib/appIconTheme.ts new file mode 100644 index 00000000..2378581d --- /dev/null +++ b/src/lib/appIconTheme.ts @@ -0,0 +1,13 @@ +import { invoke } from '@tauri-apps/api/core' +import { isTauri } from '../mock-tauri' +import type { ResolvedThemeMode } from './themeMode' + +export async function syncAppIconThemeMode(themeMode: ResolvedThemeMode): Promise { + if (!isTauri()) return + + try { + await invoke('update_app_icon', { themeMode }) + } catch (error) { + console.warn('Failed to update app icon for theme mode', error) + } +}