diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 9c801370fa..76f3f90e57 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -25,6 +25,7 @@ dependencies = [ "parking_lot", "rand 0.9.5", "reqwest 0.12.28", + "rfd", "rusqlite", "rustls", "sentry", @@ -187,6 +188,27 @@ dependencies = [ "password-hash", ] +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.5", + "raw-window-handle", + "serde", + "serde_repr", + "tokio", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -1544,6 +1566,15 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.7.4", +] + [[package]] name = "dlopen2" version = "0.8.2" @@ -1597,6 +1628,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -4680,7 +4717,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.14.1", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -4899,6 +4936,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.11" @@ -5255,6 +5301,31 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -5516,6 +5587,12 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -7381,6 +7458,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -8161,6 +8239,66 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.104" @@ -9156,6 +9294,7 @@ dependencies = [ "rustix", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", @@ -9372,6 +9511,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 1.0.4", "zcheapstr", "zvariant_derive", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 96ac256f75..632385c0fc 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -80,6 +80,47 @@ hex = "0.4" rustls = { version = "0.23", default-features = false, features = ["ring"] } log = "0.4" +# Native directory chooser for the folder memory-source (#5831). A directory +# picked in the renderer carries no filesystem path — `File.path` is an +# Electron extension that neither Wry's WKWebView/WebView2/WebKitGTK nor +# plain Chromium implements — so the only way to learn where a chosen folder +# lives is to ask the OS from the host process. +# +# `default-features = false` is what makes this a different proposition from +# the `rfd` that `artifact_commands` shed in #5541. That removal counted 13 +# packages: `ashpd`, `zbus`, and the `async-io`/`polling` executor stack. +# Every one of those arrives through `rfd`'s **default** `xdg-portal` feature +# (`xdg-portal = ["ashpd", "urlencoding", "pollster"]`), which is not enabled +# here. `gtk3` binds the GTK that Wry already links on Linux instead — +# `gtk-sys` is in that graph via `tray-icon` -> `tauri` whether or not this +# entry exists — and on macOS/Windows `rfd` compiles against `objc2-app-kit` +# / `windows-sys`, both already present. The marginal cost is `rfd` itself. +# +# `Cargo.lock` will nonetheless gain `ashpd` and a wayland cluster, and that +# is expected: the lockfile records the **maximal** resolution graph, +# including optional and target-gated edges nothing enables. Read the real +# graph, not the lock — +# +# cargo tree --target x86_64-unknown-linux-gnu -e normal -i ashpd +# # -> warning: nothing to print. +# +# Prefer this over `tauri-plugin-dialog`: that plugin depends on the same +# `rfd` AND drags `tauri-plugin-fs` in behind it, for one command that needs +# no filesystem permission surface at all. +# +# The feature list is `tauri-plugin-dialog`'s own, verbatim — that plugin is +# the reference integration of `rfd` into a Tauri app, and each of the three +# matters here. `gtk3` is the right Linux backend precisely *because* the host +# is GTK-based (rfd steers non-GTK apps to the portal instead), `tokio` drives +# the async dialog on the runtime a `#[tauri::command]` already runs on, and +# `common-controls-v6` mirrors the feature `tauri` itself enables above, so +# the Windows dialog matches the rest of the app's chrome. +rfd = { version = "0.15", default-features = false, features = [ + "gtk3", + "tokio", + "common-controls-v6", +] } + # Sentry for the Tauri shell (desktop host) process — separate Sentry project # from the React frontend and the Rust core sidecar. DSN is baked at compile # time via `option_env!("OPENHUMAN_TAURI_SENTRY_DSN")` in `lib.rs::run()` and diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index c3c89b7ef3..e540b87f4e 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -33,6 +33,7 @@ "allow-workspace-files", "allow-artifact-download", "allow-artifact-save", + "allow-directory-picker", "allow-app-update", "allow-loopback-oauth" ] diff --git a/app/src-tauri/permissions/allow-directory-picker.toml b/app/src-tauri/permissions/allow-directory-picker.toml new file mode 100644 index 0000000000..c4d10285e5 --- /dev/null +++ b/app/src-tauri/permissions/allow-directory-picker.toml @@ -0,0 +1,11 @@ +[[permission]] +identifier = "allow-directory-picker" +description = "Allow opening the OS-native directory chooser so the folder memory-source can store the absolute path the user picked (#5831)" + +[permission.commands] + +allow = [ + "pick_directory_via_dialog", +] + +deny = [] diff --git a/app/src-tauri/src/directory_picker.rs b/app/src-tauri/src/directory_picker.rs new file mode 100644 index 0000000000..baed8f1316 --- /dev/null +++ b/app/src-tauri/src/directory_picker.rs @@ -0,0 +1,153 @@ +//! Native directory chooser for memory-source configuration (#5831). +//! +//! The folder memory-source used to be picked with an +//! `` in the renderer. That element hands +//! back `File` objects, and a `File` carries no filesystem location: the +//! `File.path` attribute the old handler read is an Electron extension that +//! no web engine implements. Wry's WKWebView, WebView2 and WebKitGTK do not, +//! and neither does plain Chromium, so the handler always fell through to +//! `webkitRelativePath.split('/')[0]` — the chosen directory's **name**, with +//! its location discarded — and stored that. +//! +//! The resulting source looked configured and could never sync. Because the +//! reader anchors a relative path on the workspace, it failed once per sync +//! cycle, forever, with `folder does not exist: docs (resolved to +//! /docs)`. Nothing downstream could repair it: `docs` is not a +//! relative path to the chosen directory, it is a name whose location was +//! thrown away. +//! +//! A host-side dialog closes that gap by construction. It returns an absolute +//! path on every platform, in every renderer, because the OS — not the web +//! engine — owns the selection. +//! +//! ## Trust boundary +//! +//! Deliberately none. Unlike [`crate::artifact_commands`], which re-validates +//! that a renderer-supplied path sits inside the artifacts tree because there +//! the renderer *supplies* the path, this command takes no input at all and +//! returns only what the user chose in an OS-owned dialog. The renderer +//! cannot steer it at a directory, and choosing a folder to index is the +//! user's decision to make anywhere on their own disk. + +use std::path::Path; + +/// Render a chosen directory as the string the renderer will store, refusing +/// anything that cannot serve as a path. +/// +/// Split from the command so it is testable: the dialog itself needs a user +/// and a window server, but these two rules are the part worth pinning. Both +/// exist for the same reason — a value that reaches the store and cannot +/// resolve is the entire defect this command was written to remove, and an +/// error here is recoverable and visible where a stored bad path is neither. +/// +/// - **Not absolute.** Belt-and-braces: the OS choosers all hand back +/// absolute paths, so this is not a branch we expect to take. +/// - **Not UTF-8.** This one is reachable. A Unix directory name is bytes, +/// not text, and `Path::display()` would substitute U+FFFD for anything +/// that is not valid UTF-8 and hand back the corrupted result as a +/// success — recreating the failing-sync behaviour by a different route. +/// `to_str()` refuses instead. +fn absolute_path_string(path: &Path) -> Result { + if !path.is_absolute() { + return Err(format!( + "the directory chooser returned a non-absolute path: {}", + path.display() + )); + } + // The lossy rendering is deliberately not echoed back here: it would be + // mangled by definition, and a directory path carries the user's login + // name (see the logging note in `pick_directory_via_dialog`). + path.to_str() + .map(str::to_owned) + .ok_or_else(|| "the directory chooser returned a path that is not valid UTF-8".to_string()) +} + +/// Open the OS-native directory chooser and return the absolute path of the +/// directory the user selected. +/// +/// Returns: +/// - `Ok(Some(path))` — the absolute path chosen. +/// - `Ok(None)` — the user dismissed the dialog. Not an error; the caller +/// leaves the field exactly as it was. +/// - `Err(_)` — the dialog could not run, or it yielded something that cannot +/// serve as a path (see [`absolute_path_string`]). The caller surfaces this +/// rather than storing anything. +#[tauri::command] +pub async fn pick_directory_via_dialog() -> Result, String> { + // macOS: NSOpenPanel. Windows: IFileOpenDialog. Linux: the GTK chooser + // that WebKitGTK already links (see the `gtk3` feature in Cargo.toml). + // The await resolves when the user picks or cancels. + let handle = rfd::AsyncFileDialog::new().pick_folder().await; + + let Some(dir) = handle else { + log::debug!("[directory_picker] pick_directory_via_dialog cancelled by user"); + return Ok(None); + }; + + // The chosen path is deliberately NOT logged. An absolute directory path + // carries the user's login name and their private folder names, and these + // logs are written to the daily support log that users are asked to share + // (AGENTS.md: "Never log secrets or full PII"). The component depth is + // enough to tell a cancel from a pick and a shallow choice from a deep one + // without naming anything. Do not "improve" this by adding the path back. + let depth = dir.path().components().count(); + let picked = absolute_path_string(dir.path())?; + log::debug!("[directory_picker] pick_directory_via_dialog chose a directory (depth={depth})"); + Ok(Some(picked)) +} + +#[cfg(test)] +mod tests { + use super::absolute_path_string; + use std::path::Path; + + #[cfg(not(target_os = "windows"))] + const ABSOLUTE: &str = "/Users/you/notes"; + #[cfg(target_os = "windows")] + const ABSOLUTE: &str = r"C:\Users\you\notes"; + + #[test] + fn passes_an_absolute_path_through_unchanged() { + assert_eq!( + absolute_path_string(Path::new(ABSOLUTE)), + Ok(ABSOLUTE.to_string()) + ); + } + + #[test] + fn refuses_a_bare_directory_name() { + // `docs` is exactly what the old `webkitRelativePath.split('/')[0]` + // fallback stored, and what made the source unsyncable (#5831). + let err = absolute_path_string(Path::new("docs")).unwrap_err(); + assert!(err.contains("non-absolute"), "unexpected message: {err}"); + assert!(err.contains("docs"), "message should name the value: {err}"); + } + + #[test] + fn refuses_a_relative_path_with_separators() { + assert!(absolute_path_string(Path::new("notes/inner")).is_err()); + } + + /// A Unix directory name is bytes, not text. `Path::display()` would have + /// substituted U+FFFD here and returned the corrupted string as a + /// success, which is the #5831 failure mode reached by another route. + #[cfg(unix)] + #[test] + fn refuses_an_absolute_path_that_is_not_utf8() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let raw = OsStr::from_bytes(b"/Users/you/\xff\xfenotes"); + let path = Path::new(raw); + assert!(path.is_absolute(), "fixture must clear the absolute check"); + + let err = absolute_path_string(path).unwrap_err(); + assert!(err.contains("not valid UTF-8"), "unexpected message: {err}"); + // The mangled rendering must not be echoed back — it is both useless + // and carries the user's login name. + assert!( + !err.contains('\u{FFFD}'), + "lossy path leaked into the error" + ); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 0b5eef295c..2565c57719 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -48,6 +48,7 @@ mod deep_link_ipc_windows; // developer host covers them. mod deep_link_registration_check; mod dictation_hotkeys; +mod directory_picker; mod file_logging; // Routing the frontend to a core that is not the one in this process. Leaf // gated: with `gateways` off the commands are simply absent, which is what the @@ -3394,6 +3395,11 @@ pub fn run() { // too (CodeRabbit on #4127). The Save-As dialog that used to sit in // front of this went with the shell's `rfd` dependency. artifact_commands::download_artifact_to_downloads, + // Native directory chooser for the folder memory-source (#5831). + // Unlike the Save-As dialog above it, this one has no renderer-side + // substitute: a `webkitdirectory` input cannot report where the + // directory it returned actually lives. + directory_picker::pick_directory_via_dialog, check_core_update, apply_core_update, check_app_update, diff --git a/app/src/components/intelligence/AddMemorySourceFields.tsx b/app/src/components/intelligence/AddMemorySourceFields.tsx index eea9a5e48c..de7f77fda0 100644 --- a/app/src/components/intelligence/AddMemorySourceFields.tsx +++ b/app/src/components/intelligence/AddMemorySourceFields.tsx @@ -19,6 +19,7 @@ import type { ComposioConnection } from '../../lib/composio/types'; import { useT } from '../../lib/i18n/I18nContext'; import type { SourceKind } from '../../services/memorySourcesService'; import TextField from '../ui/TextField'; +import { FolderField } from './FolderField'; const log = debug('intelligence:add-memory-source-dialog'); @@ -52,62 +53,6 @@ interface FieldProps { type?: string; } -interface FolderFieldProps { - label: string; - value: string; - onChange: (v: string) => void; -} - -function FolderField({ label, value, onChange }: FolderFieldProps) { - const { t } = useT(); - return ( - - ); -} - export function Field({ label, value, onChange, placeholder, type = 'text' }: FieldProps) { return (