From 60bfa2edd530d69ff9488a32c6b610d76605cb36 Mon Sep 17 00:00:00 2001 From: Shanu Date: Thu, 3 Sep 2026 22:33:47 +0530 Subject: [PATCH 1/3] fix(memory-sources): pick folders with the native chooser, never store a bare name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browse on the folder memory source was an ``. That element cannot report where the directory it returned lives: the handler read `File.path`, which is an Electron extension that none of Wry's renderers (WKWebView, WebView2, WebKitGTK) implement, and fell through to `webkitRelativePath.split('/')[0]` — the directory's bare name, location discarded. The stored source then looked configured and could never sync. The reader anchors a relative path on the workspace, so it failed once per cycle, forever, with `folder does not exist: docs`. Nothing downstream could repair it, because `docs` is not a relative path to the chosen directory. Browse now calls a native chooser in the shell, which returns an absolute path on every platform because the OS owns the selection. When no absolute path can be obtained the field reports it and stays as it was: a visible error is recoverable, a silently stored name is not. Hand-typed paths are untouched, relative ones included — those resolve against the workspace at read time, which this field deliberately does not second-guess. Closes #5831 --- app/src-tauri/Cargo.lock | 142 ++++++++++++++++- app/src-tauri/Cargo.toml | 41 +++++ app/src-tauri/capabilities/default.json | 1 + .../permissions/allow-directory-picker.toml | 11 ++ app/src-tauri/src/directory_picker.rs | 111 ++++++++++++++ app/src-tauri/src/lib.rs | 6 + .../intelligence/AddMemorySourceFields.tsx | 57 +------ .../intelligence/FolderField.test.tsx | 143 ++++++++++++++++++ .../components/intelligence/FolderField.tsx | 93 ++++++++++++ app/src/lib/i18n/ar.ts | 2 + app/src/lib/i18n/bn.ts | 2 + app/src/lib/i18n/de.ts | 2 + app/src/lib/i18n/en.ts | 2 + app/src/lib/i18n/es.ts | 2 + app/src/lib/i18n/fr.ts | 2 + app/src/lib/i18n/hi.ts | 2 + app/src/lib/i18n/id.ts | 2 + app/src/lib/i18n/it.ts | 2 + app/src/lib/i18n/ko.ts | 2 + app/src/lib/i18n/pl.ts | 2 + app/src/lib/i18n/pt.ts | 2 + app/src/lib/i18n/ru.ts | 2 + app/src/lib/i18n/zh-CN.ts | 1 + .../tauriCommands/directoryPicker.test.ts | 70 +++++++++ .../utils/tauriCommands/directoryPicker.ts | 52 +++++++ app/src/utils/tauriCommands/index.ts | 1 + 26 files changed, 698 insertions(+), 57 deletions(-) create mode 100644 app/src-tauri/permissions/allow-directory-picker.toml create mode 100644 app/src-tauri/src/directory_picker.rs create mode 100644 app/src/components/intelligence/FolderField.test.tsx create mode 100644 app/src/components/intelligence/FolderField.tsx create mode 100644 app/src/utils/tauriCommands/directoryPicker.test.ts create mode 100644 app/src/utils/tauriCommands/directoryPicker.ts 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..eed045fac0 --- /dev/null +++ b/app/src-tauri/src/directory_picker.rs @@ -0,0 +1,111 @@ +//! 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 is not absolute. +/// +/// Split from the command so it is testable: the dialog itself needs a user +/// and a window server, but this rule is the part worth pinning. The OS +/// choosers all hand back absolute paths, so the rejection is belt-and-braces +/// rather than a branch we expect to take. It exists because a relative value +/// reaching the store is the entire defect this command was written to remove +/// — an error here is recoverable and visible, whereas a stored relative path +/// is neither. +fn absolute_path_string(path: &Path) -> Result { + if !path.is_absolute() { + return Err(format!( + "the directory chooser returned a non-absolute path: {}", + path.display() + )); + } + Ok(path.display().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 somehow yielded a relative +/// path. 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::info!("[directory_picker] pick_directory_via_dialog cancelled by user"); + return Ok(None); + }; + + let picked = absolute_path_string(dir.path())?; + log::info!("[directory_picker] pick_directory_via_dialog chose {picked}"); + 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()); + } +} 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 (