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());
+ }
+}