diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 745254f6eb4..3c4d5646057 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -68,7 +68,6 @@ { "url": "http://localhost:*" } ] }, - "clipboard-manager:allow-write-text", - "opener:allow-reveal-item-in-dir" + "clipboard-manager:allow-write-text" ] } diff --git a/apps/desktop/src-tauri/src/diagnostics.rs b/apps/desktop/src-tauri/src/diagnostics.rs index 286023f0add..102c735e423 100644 --- a/apps/desktop/src-tauri/src/diagnostics.rs +++ b/apps/desktop/src-tauri/src/diagnostics.rs @@ -6,7 +6,6 @@ use std::{ sync::atomic::{AtomicBool, Ordering}, }; use tauri::{AppHandle, Manager}; -use tauri_plugin_opener::OpenerExt; use tauri_specta::Event; use tokio::io::AsyncBufReadExt; use tracing::{error, info, warn}; @@ -548,8 +547,8 @@ pub async fn upload_diagnostic_report(app: AppHandle, report_path: String) -> Re pub async fn reveal_diagnostic_report(app: AppHandle, report_path: String) -> Result<(), String> { let path = validate_report_path(&app, &report_path)?; - app.opener() - .reveal_item_in_dir(&path) + crate::reveal_in_dir(app, path) + .await .map_err(|e| format!("Failed to reveal diagnostic report: {e}")) } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index a78c370641e..b97277971b9 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -4656,6 +4656,25 @@ async fn open_file_path(_app: AppHandle, path: PathBuf) -> Result<(), String> { Ok(()) } +/// The opener's Linux backend makes blocking zbus calls, which panic inside an async task. +pub(crate) async fn reveal_in_dir(app: AppHandle, path: PathBuf) -> Result<(), String> { + cap_utils::run_on_dedicated_thread("reveal-item-in-dir", move || { + app.opener().reveal_item_in_dir(path) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string()) +} + +#[tauri::command] +#[specta::specta] +#[instrument(skip(app))] +async fn reveal_item_in_dir(app: AppHandle, path: PathBuf) -> Result<(), String> { + reveal_in_dir(app, path) + .await + .map_err(|e| format!("Failed to reveal item: {e}")) +} + #[derive(Deserialize, specta::Type, tauri_specta::Event, Debug, Clone)] struct RenderFrameEvent { frame_number: u32, @@ -6810,6 +6829,7 @@ fn specta_builder() -> tauri_specta::Builder { copy_image_to_clipboard, copy_rendered_screenshot_to_clipboard, open_file_path, + reveal_item_in_dir, get_video_metadata, create_editor_instance, editor_preparing::create_preparing_editor_frame, diff --git a/apps/desktop/src/routes/editor/EditorErrorScreen.tsx b/apps/desktop/src/routes/editor/EditorErrorScreen.tsx index cc2a21478c0..b22fe189a0c 100644 --- a/apps/desktop/src/routes/editor/EditorErrorScreen.tsx +++ b/apps/desktop/src/routes/editor/EditorErrorScreen.tsx @@ -2,7 +2,6 @@ import { Button } from "@cap/ui-solid"; import { createMutation } from "@tanstack/solid-query"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { type as ostype } from "@tauri-apps/plugin-os"; import { createEffect, onCleanup, Show } from "solid-js"; import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; @@ -165,7 +164,7 @@ export function EditorErrorScreen(props: { }); const handleOpenFolder = () => { - revealItemInDir(props.projectPath); + commands.revealItemInDir(props.projectPath); }; return ( diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index db72fa9ac19..a49d41889e6 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -1,5 +1,4 @@ import { ask } from "@tauri-apps/plugin-dialog"; -import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { @@ -129,7 +128,7 @@ export function Header(props: { clearTimelineSelection(); console.log({ path: `${editorInstance.path}/` }); - revealItemInDir(`${editorInstance.path}/`); + commands.revealItemInDir(`${editorInstance.path}/`); }} tooltipText="Open recording bundle" leftIcon={} diff --git a/apps/desktop/src/routes/screenshot-editor/Header.tsx b/apps/desktop/src/routes/screenshot-editor/Header.tsx index a41171e16a5..8489ffcf573 100644 --- a/apps/desktop/src/routes/screenshot-editor/Header.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Header.tsx @@ -2,12 +2,12 @@ import { DropdownMenu } from "@kobalte/core/dropdown-menu"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { ask } from "@tauri-apps/plugin-dialog"; import { remove } from "@tauri-apps/plugin-fs"; -import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { createEffect, onCleanup, Suspense } from "solid-js"; import CaptionControlsMacOS from "~/components/titlebar/controls/CaptionControlsMacOS"; import CaptionControlsWindows11 from "~/components/titlebar/controls/CaptionControlsWindows11"; +import { commands } from "~/utils/tauri"; import IconCapCrop from "~icons/cap/crop"; import IconCapTrash from "~icons/cap/trash"; import IconLucideCopy from "~icons/lucide/copy"; @@ -184,7 +184,7 @@ export function Header() { > { - revealItemInDir(path()); + commands.revealItemInDir(path()); }} > diff --git a/apps/desktop/src/utils/recording.test.ts b/apps/desktop/src/utils/recording.test.ts index b4e37244490..8766b5a0478 100644 --- a/apps/desktop/src/utils/recording.test.ts +++ b/apps/desktop/src/utils/recording.test.ts @@ -8,7 +8,6 @@ import { vi.mock("@tauri-apps/api/event", () => ({ emit: vi.fn() })); vi.mock("@tauri-apps/plugin-dialog", () => ({ message: vi.fn() })); -vi.mock("@tauri-apps/plugin-opener", () => ({ revealItemInDir: vi.fn() })); vi.mock("./tauri", () => ({ commands: {} })); describe("recording start cancellation", () => { diff --git a/apps/desktop/src/utils/recording.ts b/apps/desktop/src/utils/recording.ts index 4fde8b32777..4cb91207973 100644 --- a/apps/desktop/src/utils/recording.ts +++ b/apps/desktop/src/utils/recording.ts @@ -1,6 +1,5 @@ import { emit } from "@tauri-apps/api/event"; import * as dialog from "@tauri-apps/plugin-dialog"; -import { revealItemInDir } from "@tauri-apps/plugin-opener"; import type { createOptionsQuery } from "./queries"; import { commands, @@ -125,5 +124,5 @@ export async function openRecordingFolder( if (openedContent) return; - await revealItemInDir(`${path}/`); + await commands.revealItemInDir(`${path}/`); } diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 6f27d9856cb..767b14c177a 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -217,6 +217,9 @@ async copyRenderedScreenshotToClipboard() : Promise { async openFilePath(path: string) : Promise { return await TAURI_INVOKE("open_file_path", { path }); }, +async revealItemInDir(path: string) : Promise { + return await TAURI_INVOKE("reveal_item_in_dir", { path }); +}, async getVideoMetadata(path: string) : Promise { return await TAURI_INVOKE("get_video_metadata", { path }); }, diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 622f6ca9421..fe0e2b2e33d 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -38,5 +38,8 @@ workspace-hack = { version = "0.1", path = "../workspace-hack" } tracing-appender = "0.2.3" tempfile = "3" +[target.'cfg(target_os = "linux")'.dev-dependencies] +zbus = { version = "5.18.0", default-features = false, features = ["tokio", "blocking-api"] } + [lints] workspace = true diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 1e510081e11..9a4e9e0149b 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -34,6 +34,25 @@ where tokio::spawn(future.with_current_subscriber().in_current_span()) } +/// Runs blocking native work on a new thread outside every Tokio runtime context. +/// Some blocking APIs, such as zbus on Linux, start their own runtime and panic when +/// called from an async task. +pub async fn run_on_dedicated_thread(name: &str, work: F) -> std::io::Result +where + F: FnOnce() -> T + Send + 'static, + T: Send + 'static, +{ + let (sender, receiver) = tokio::sync::oneshot::channel(); + std::thread::Builder::new() + .name(name.to_string()) + .spawn(move || { + let _ = sender.send(work()); + })?; + receiver + .await + .map_err(|_| std::io::Error::other(format!("{name} thread stopped before finishing"))) +} + pub fn ensure_dir(path: &PathBuf) -> Result { std::fs::create_dir_all(path)?; Ok(path.clone()) @@ -427,3 +446,67 @@ mod tests { assert_eq!(result, "test (2).cap"); } } + +#[cfg(test)] +mod dedicated_thread_tests { + use super::run_on_dedicated_thread; + + async fn assert_work_can_start_its_own_runtime() { + let value = run_on_dedicated_thread("runtime-test", || { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(async { 7 }) + }) + .await + .unwrap(); + assert_eq!(value, 7); + } + + #[tokio::test] + async fn work_runs_outside_a_current_thread_runtime() { + assert_work_can_start_its_own_runtime().await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn work_runs_outside_a_multi_thread_runtime() { + assert_work_can_start_its_own_runtime().await; + } + + #[tokio::test] + async fn work_results_are_returned_unchanged() { + let result: Result<(), std::io::Error> = run_on_dedicated_thread("error-test", || { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "denied", + )) + }) + .await + .unwrap(); + let error = result.unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + assert_eq!(error.to_string(), "denied"); + } + + #[tokio::test] + async fn panicking_work_becomes_an_error() { + let error = run_on_dedicated_thread::<(), _>("panic-test", || panic!("worker failed")) + .await + .unwrap_err(); + assert_eq!( + error.to_string(), + "panic-test thread stopped before finishing" + ); + } + + #[cfg(target_os = "linux")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn blocking_zbus_runs_from_an_async_task() { + // A headless host may have no session bus; only a thread failure is a regression. + let result = run_on_dedicated_thread("zbus-test", || { + zbus::blocking::Connection::session().map(drop) + }) + .await; + assert!(result.is_ok()); + } +}