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:
commit
c024717934
17 changed files with 5757 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
target/
|
||||||
|
node_modules/
|
||||||
|
gen/
|
||||||
|
*.AppImage
|
||||||
|
|
||||||
6
package.json
Normal file
6
package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "claude-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Claude desktop app"
|
||||||
|
}
|
||||||
5455
src-tauri/Cargo.lock
generated
Normal file
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
20
src-tauri/Cargo.toml
Normal 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
3
src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build();
|
||||||
|
}
|
||||||
20
src-tauri/capabilities/default.json
Normal file
20
src-tauri/capabilities/default.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
16
src-tauri/capabilities/remote.json
Normal file
16
src-tauri/capabilities/remote.json
Normal 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
BIN
src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
BIN
src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
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
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
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
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
187
src-tauri/src/lib.rs
Normal 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
6
src-tauri/src/main.rs
Normal 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
34
src-tauri/tauri.conf.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/index.html
Normal file
5
src/index.html
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body>Loading...</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue