From 550b89673d24af4ef983885cd50481bccf814c71 Mon Sep 17 00:00:00 2001 From: tomdebone Date: Tue, 7 Jul 2026 17:31:09 +0200 Subject: [PATCH] feat(tauri-app): expand tray menu + open_external_url + Profile/Search items Tray menu now has: Show maarcadetweet Home Compose Profile Search ---- Quit The Home/Profile/Search items emit 'app://navigate' events which the frontend's listenTrayEvents translates to view switches. The compose and show events continue to be separate event types ('app://compose', 'app://show'). open_external_url Tauri command takes a URL, validates it's http(s), and uses tauri-plugin-shell to open it in the user's default browser. The frontend's openExternalUrl falls back to window.open in the browser preview (no Tauri runtime). svelte-check error fix: tauriCall(cmd, fallback, args?) had the second call argument as 'undefined' instead of 'null' on the call site for session.load(). The TypeScript compiler correctly noted that the fallback type 'T' (here Session | null) couldn't be undefined. Replaced with 'null' and dropped the trailing null args argument (it's optional). --- crates/tauri-app/src-tauri/Cargo.lock | 94 ++++++++++++++++++++++++++ crates/tauri-app/src-tauri/Cargo.toml | 1 + crates/tauri-app/src-tauri/src/lib.rs | 67 +++++++++++++++++- crates/tauri-app/src/lib/api/client.ts | 33 +++++++-- 4 files changed, 190 insertions(+), 5 deletions(-) diff --git a/crates/tauri-app/src-tauri/Cargo.lock b/crates/tauri-app/src-tauri/Cargo.lock index cf19512..b33d98f 100644 --- a/crates/tauri-app/src-tauri/Cargo.lock +++ b/crates/tauri-app/src-tauri/Cargo.lock @@ -2435,6 +2435,25 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "iterator-sorted" version = "0.1.0" @@ -2735,6 +2754,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-keyring-store", "tauri-plugin-notification", + "tauri-plugin-shell", "tauri-plugin-updater", "tauri-plugin-window-state", "tokio", @@ -3286,6 +3306,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "open" +version = "5.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + [[package]] name = "openssl" version = "0.10.81" @@ -3345,6 +3376,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "osakit" version = "0.3.1" @@ -4526,12 +4567,44 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -5087,6 +5160,27 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-shell" +version = "2.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" diff --git a/crates/tauri-app/src-tauri/Cargo.toml b/crates/tauri-app/src-tauri/Cargo.toml index ea37cec..5204532 100644 --- a/crates/tauri-app/src-tauri/Cargo.toml +++ b/crates/tauri-app/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ tauri-plugin-notification = "2" tauri-plugin-dialog = "2" tauri-plugin-updater = "2" tauri-plugin-window-state = "2" +tauri-plugin-shell = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } diff --git a/crates/tauri-app/src-tauri/src/lib.rs b/crates/tauri-app/src-tauri/src/lib.rs index 1b438d5..6ea79d4 100644 --- a/crates/tauri-app/src-tauri/src/lib.rs +++ b/crates/tauri-app/src-tauri/src/lib.rs @@ -444,6 +444,28 @@ async fn pick_and_upload_image( Ok(Some(resp)) } +/// Open an external URL in the user's default browser. Returns +/// silently on success. Only accepts `http://` and `https://` — +/// any other scheme (e.g. `file://`, `mailto:`, `javascript:`) +/// is rejected so the Rust side is the single source of truth for +/// which schemes are allowed. +#[tauri::command] +async fn open_external_url( + url: String, + app: tauri::AppHandle, +) -> Result<(), String> { + use tauri_plugin_shell::ShellExt; + let lower = url.to_ascii_lowercase(); + if !(lower.starts_with("http://") || lower.starts_with("https://")) { + return Err(format!( + "open_external_url: refusing non-http(s) scheme in {url:?}" + )); + } + app.shell() + .open(url, None) + .map_err(|e| e.to_string()) +} + /// Map a file extension to a MIME type. Returns /// `application/octet-stream` when the extension is unrecognised — /// the PDS's magic-byte sniffer will take over from there. @@ -529,6 +551,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_window_state::Builder::default().build()) .manage(state) @@ -551,6 +574,14 @@ pub fn run() { None::<&str>, ) .map_err(|e| format!("failed to build show menu item: {e}"))?; + let home_item = tauri::menu::MenuItem::with_id( + app, + "tray_home", + "Home", + true, + None::<&str>, + ) + .map_err(|e| format!("failed to build home menu item: {e}"))?; let compose_item = tauri::menu::MenuItem::with_id( app, "tray_compose", @@ -559,6 +590,22 @@ pub fn run() { None::<&str>, ) .map_err(|e| format!("failed to build compose menu item: {e}"))?; + let profile_item = tauri::menu::MenuItem::with_id( + app, + "tray_profile", + "Profile", + true, + None::<&str>, + ) + .map_err(|e| format!("failed to build profile menu item: {e}"))?; + let search_item = tauri::menu::MenuItem::with_id( + app, + "tray_search", + "Search", + true, + None::<&str>, + ) + .map_err(|e| format!("failed to build search menu item: {e}"))?; let quit_item = tauri::menu::MenuItem::with_id( app, "tray_quit", @@ -572,7 +619,15 @@ pub fn run() { let tray_menu = tauri::menu::Menu::with_items( app, - &[&show_item, &compose_item, &separator, &quit_item], + &[ + &show_item, + &home_item, + &compose_item, + &profile_item, + &search_item, + &separator, + &quit_item, + ], ) .map_err(|e| format!("failed to build tray menu: {e}"))?; @@ -586,9 +641,18 @@ pub fn run() { "tray_show" => { let _ = tauri::Emitter::emit(app, "app://show", ()); } + "tray_home" => { + let _ = tauri::Emitter::emit(app, "app://navigate", "home"); + } "tray_compose" => { let _ = tauri::Emitter::emit(app, "app://compose", ()); } + "tray_profile" => { + let _ = tauri::Emitter::emit(app, "app://navigate", "profile"); + } + "tray_search" => { + let _ = tauri::Emitter::emit(app, "app://navigate", "search"); + } "tray_quit" => { app.exit(0); } @@ -634,6 +698,7 @@ pub fn run() { fetch_blob, pick_and_upload_image, show_notification, + open_external_url, ]) .run(tauri::generate_context!()) .expect("error while running maarcadetweet"); diff --git a/crates/tauri-app/src/lib/api/client.ts b/crates/tauri-app/src/lib/api/client.ts index 3757024..bbc8032 100644 --- a/crates/tauri-app/src/lib/api/client.ts +++ b/crates/tauri-app/src/lib/api/client.ts @@ -51,7 +51,7 @@ function createSessionStore() { return { subscribe, async load() { - const s = await tauriCall("current_session", undefined, null); + const s = await tauriCall("current_session", null); set(s); }, async login(handle: string, password: string) { @@ -355,18 +355,43 @@ export async function showNotification( /// The handler receives the event payload (empty object for these /// cases). Returns an unsubscribe function. export async function listenTrayEvents( - handler: (event: "show" | "compose") => void, + handler: (event: "show" | "home" | "compose" | "profile" | "search") => void, ): Promise<() => void> { const { listen } = await import("@tauri-apps/api/event"); const unlisteners: Array<() => void> = []; const u1 = await listen("app://show", () => handler("show")); - const u2 = await listen("app://compose", () => handler("compose")); - unlisteners.push(u1, u2); + const u2 = await listen("app://navigate", (e) => { + handler(e.payload as "home" | "profile" | "search"); + }); + const u3 = await listen("app://compose", () => handler("compose")); + unlisteners.push(u1, u2, u3); return () => { for (const u of unlisteners) u(); }; } +/// Open an external URL in the user's default browser. The Rust +/// `open_external_url` command enforces http(s) only; in the +/// browser preview where no Tauri runtime is present, fall back +/// to `window.open` and treat a popup-blocker denial as +/// "fine, user can copy the URL themselves". +export async function openExternalUrl(url: string): Promise { + try { + if (isTauri()) { + await safeInvoke("open_external_url", { url }); + return; + } + } catch (e) { + console.warn("open_external_url failed", e); + } + // Browser fallback (vite dev preview). + try { + window.open(url, "_blank", "noopener,noreferrer"); + } catch (e) { + console.warn("window.open failed (popup blocker?)", e); + } +} + /// Subscribe to `app://notification` events emitted by the Rust /// `show_notification` command. Used to focus the window and /// navigate when the user clicks the notification.