Initial implementation of Claude desktop app (Tauri v2)

Standalone native desktop app wrapping claude.ai with:
- Direct webview navigation to claude.ai (not iframe)
- System tray with close-to-tray behavior
- Native notification bridge (JS overrides browser Notification API)
- Navigation filter allowing only Claude, Anthropic, and OAuth domains
- External links open in system browser
- Session persistence via WebKitGTK cookies/localStorage
- Builds .deb and AppImage bundles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-02-16 11:46:01 +01:00
commit c024717934
17 changed files with 5757 additions and 0 deletions

5455
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

20
src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,20 @@
[package]
name = "claude-app"
version = "1.0.0"
description = "Claude desktop app"
edition = "2021"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-notification = "2"
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
url = "2"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]

3
src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

View file

@ -0,0 +1,20 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:default",
"core:window:allow-close",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-set-focus",
"core:webview:default",
"opener:default",
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify"
]
}

View file

@ -0,0 +1,16 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "remote",
"description": "Grants claude.ai IPC access for notifications",
"remote": {
"urls": ["https://claude.ai/*", "https://*.claude.ai/*"]
},
"permissions": [
"core:default",
"core:event:default",
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

187
src-tauri/src/lib.rs Normal file
View file

@ -0,0 +1,187 @@
use tauri::{
image::Image,
menu::{Menu, MenuItem},
tray::TrayIconBuilder,
webview::{NewWindowResponse, WebviewWindowBuilder},
Manager, RunEvent, WebviewUrl, WindowEvent,
};
use url::Url;
/// JavaScript injected into every page load to bridge browser Notification API
/// to native notifications via Tauri IPC.
const NOTIFICATION_BRIDGE_JS: &str = r#"
(function() {
// Only override if __TAURI_INTERNALS__ is available
if (!window.__TAURI_INTERNALS__) return;
const invoke = window.__TAURI_INTERNALS__.invoke;
class TauriNotification {
constructor(title, options = {}) {
this.title = title;
this.body = options.body || '';
invoke('send_notification', { title: this.title, body: this.body });
}
static get permission() {
return 'granted';
}
static requestPermission() {
return Promise.resolve('granted');
}
}
// Replace the browser Notification with our bridge
Object.defineProperty(window, 'Notification', {
value: TauriNotification,
writable: false,
configurable: false,
});
})();
"#;
/// Tauri command invoked from JS to show a native notification.
#[tauri::command]
fn send_notification(app: tauri::AppHandle, title: String, body: String) {
use tauri_plugin_notification::NotificationExt;
let _ = app
.notification()
.builder()
.title(&title)
.body(&body)
.show();
}
/// Returns true if the URL should be allowed to load inside the app webview.
/// Allows claude.ai, Anthropic domains, and common OAuth providers.
fn is_allowed_navigation(url: &Url) -> bool {
let host = match url.host_str() {
Some(h) => h.to_lowercase(),
None => return false,
};
// Claude / Anthropic
if host == "claude.ai" || host.ends_with(".claude.ai") {
return true;
}
if host == "anthropic.com" || host.ends_with(".anthropic.com") {
return true;
}
// Google OAuth
if host == "accounts.google.com"
|| host.ends_with(".google.com")
|| host.ends_with(".googleapis.com")
|| host.ends_with(".gstatic.com")
{
return true;
}
// Apple OAuth
if host == "appleid.apple.com" || host.ends_with(".apple.com") {
return true;
}
// Allow about:blank and similar
if url.scheme() == "about" || url.scheme() == "data" || url.scheme() == "blob" {
return true;
}
false
}
pub fn run() {
let app = tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![send_notification])
.setup(|app| {
// -- Create main window navigating directly to claude.ai --
//
// on_navigation and on_new_window are builder methods, so they must
// be chained before .build(). The close-to-tray handler is set on
// the built window via on_window_event.
let webview_window = WebviewWindowBuilder::new(
app,
"main",
WebviewUrl::External("https://claude.ai".parse().unwrap()),
)
.title("Claude")
.inner_size(1200.0, 800.0)
.min_inner_size(400.0, 300.0)
// Standard WebKit user agent so claude.ai serves the full experience
.user_agent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15")
.initialization_script(NOTIFICATION_BRIDGE_JS)
.zoom_hotkeys_enabled(true)
.enable_clipboard_access()
// Navigation filter: only allow approved domains in-app
.on_navigation(|url| is_allowed_navigation(url))
// External links (target=_blank) open in system browser
.on_new_window(|url, _features| {
let _ = tauri_plugin_opener::open_url(url.as_str(), None::<&str>);
NewWindowResponse::Deny
})
.build()?;
// -- Close-to-tray: hide window instead of quitting --
let win_clone = webview_window.clone();
webview_window.on_window_event(move |event| {
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = win_clone.hide();
}
});
// -- System tray --
let show_item =
MenuItem::with_id(app, "show", "Show Claude", true, None::<&str>)?;
let quit_item =
MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
let tray_icon = Image::from_bytes(include_bytes!("../icons/32x32.png"))?;
let tray_handle = app.handle().clone();
TrayIconBuilder::new()
.icon(tray_icon)
.tooltip("Claude")
.menu(&menu)
.on_menu_event(move |app_handle, event| match event.id().as_ref() {
"show" => {
if let Some(win) = app_handle.get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
}
}
"quit" => {
app_handle.exit(0);
}
_ => {}
})
.on_tray_icon_event(move |_tray, event| {
if let tauri::tray::TrayIconEvent::Click {
button: tauri::tray::MouseButton::Left,
..
} = event
{
if let Some(win) = tray_handle.get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
}
}
})
.build(app)?;
Ok(())
})
.build(tauri::generate_context!())
.expect("error building tauri application");
// -- Keep app alive when all windows are hidden --
app.run(|_app, event| {
if let RunEvent::ExitRequested { api, .. } = event {
api.prevent_exit();
}
});
}

6
src-tauri/src/main.rs Normal file
View file

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
claude_app::run();
}

34
src-tauri/tauri.conf.json Normal file
View file

@ -0,0 +1,34 @@
{
"productName": "Claude",
"version": "1.0.0",
"identifier": "no.naiv.claude-app",
"build": {
"frontendDist": "../src"
},
"app": {
"withGlobalTauri": true,
"windows": [],
"security": {
"dangerousDisableAssetCspModification": true,
"assetProtocol": {
"enable": false
}
}
},
"bundle": {
"active": true,
"targets": ["deb", "appimage"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"linux": {
"deb": {
"depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1"]
}
}
}
}