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<T>(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).
This commit is contained in:
tomdebone
2026-07-07 17:31:09 +02:00
parent 6a85f44bab
commit 550b89673d
4 changed files with 190 additions and 5 deletions
+94
View File
@@ -2435,6 +2435,25 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" 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]] [[package]]
name = "iterator-sorted" name = "iterator-sorted"
version = "0.1.0" version = "0.1.0"
@@ -2735,6 +2754,7 @@ dependencies = [
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-keyring-store", "tauri-plugin-keyring-store",
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-shell",
"tauri-plugin-updater", "tauri-plugin-updater",
"tauri-plugin-window-state", "tauri-plugin-window-state",
"tokio", "tokio",
@@ -3286,6 +3306,17 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" 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]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.81" version = "0.10.81"
@@ -3345,6 +3376,16 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "osakit" name = "osakit"
version = "0.3.1" version = "0.3.1"
@@ -4526,12 +4567,44 @@ dependencies = [
"lazy_static", "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]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" 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]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.8" version = "1.4.8"
@@ -5087,6 +5160,27 @@ dependencies = [
"url", "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]] [[package]]
name = "tauri-plugin-updater" name = "tauri-plugin-updater"
version = "2.10.1" version = "2.10.1"
+1
View File
@@ -22,6 +22,7 @@ tauri-plugin-notification = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-window-state = "2" tauri-plugin-window-state = "2"
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
+66 -1
View File
@@ -444,6 +444,28 @@ async fn pick_and_upload_image(
Ok(Some(resp)) 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 /// Map a file extension to a MIME type. Returns
/// `application/octet-stream` when the extension is unrecognised — /// `application/octet-stream` when the extension is unrecognised —
/// the PDS's magic-byte sniffer will take over from there. /// the PDS's magic-byte sniffer will take over from there.
@@ -529,6 +551,7 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_window_state::Builder::default().build()) .plugin(tauri_plugin_window_state::Builder::default().build())
.manage(state) .manage(state)
@@ -551,6 +574,14 @@ pub fn run() {
None::<&str>, None::<&str>,
) )
.map_err(|e| format!("failed to build show menu item: {e}"))?; .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( let compose_item = tauri::menu::MenuItem::with_id(
app, app,
"tray_compose", "tray_compose",
@@ -559,6 +590,22 @@ pub fn run() {
None::<&str>, None::<&str>,
) )
.map_err(|e| format!("failed to build compose menu item: {e}"))?; .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( let quit_item = tauri::menu::MenuItem::with_id(
app, app,
"tray_quit", "tray_quit",
@@ -572,7 +619,15 @@ pub fn run() {
let tray_menu = tauri::menu::Menu::with_items( let tray_menu = tauri::menu::Menu::with_items(
app, 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}"))?; .map_err(|e| format!("failed to build tray menu: {e}"))?;
@@ -586,9 +641,18 @@ pub fn run() {
"tray_show" => { "tray_show" => {
let _ = tauri::Emitter::emit(app, "app://show", ()); let _ = tauri::Emitter::emit(app, "app://show", ());
} }
"tray_home" => {
let _ = tauri::Emitter::emit(app, "app://navigate", "home");
}
"tray_compose" => { "tray_compose" => {
let _ = tauri::Emitter::emit(app, "app://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" => { "tray_quit" => {
app.exit(0); app.exit(0);
} }
@@ -634,6 +698,7 @@ pub fn run() {
fetch_blob, fetch_blob,
pick_and_upload_image, pick_and_upload_image,
show_notification, show_notification,
open_external_url,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running maarcadetweet"); .expect("error while running maarcadetweet");
+29 -4
View File
@@ -51,7 +51,7 @@ function createSessionStore() {
return { return {
subscribe, subscribe,
async load() { async load() {
const s = await tauriCall<Session | null>("current_session", undefined, null); const s = await tauriCall<Session | null>("current_session", null);
set(s); set(s);
}, },
async login(handle: string, password: string) { async login(handle: string, password: string) {
@@ -355,18 +355,43 @@ export async function showNotification(
/// The handler receives the event payload (empty object for these /// The handler receives the event payload (empty object for these
/// cases). Returns an unsubscribe function. /// cases). Returns an unsubscribe function.
export async function listenTrayEvents( export async function listenTrayEvents(
handler: (event: "show" | "compose") => void, handler: (event: "show" | "home" | "compose" | "profile" | "search") => void,
): Promise<() => void> { ): Promise<() => void> {
const { listen } = await import("@tauri-apps/api/event"); const { listen } = await import("@tauri-apps/api/event");
const unlisteners: Array<() => void> = []; const unlisteners: Array<() => void> = [];
const u1 = await listen("app://show", () => handler("show")); const u1 = await listen("app://show", () => handler("show"));
const u2 = await listen("app://compose", () => handler("compose")); const u2 = await listen("app://navigate", (e) => {
unlisteners.push(u1, u2); handler(e.payload as "home" | "profile" | "search");
});
const u3 = await listen("app://compose", () => handler("compose"));
unlisteners.push(u1, u2, u3);
return () => { return () => {
for (const u of unlisteners) u(); 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<void> {
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 /// Subscribe to `app://notification` events emitted by the Rust
/// `show_notification` command. Used to focus the window and /// `show_notification` command. Used to focus the window and
/// navigate when the user clicks the notification. /// navigate when the user clicks the notification.