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)
+ }
+}