Skip to content

Commit c3ad1ed

Browse files
committed
Linux: register GVFS-mounted SMB shares as volumes
- Parse smb-share:server=X,share=Y GVFS directories in list_locations() - Add inotify watcher on /run/user/<uid>/gvfs/ for live mount/unmount - Extract share name in tab titles instead of raw GVFS dirname - Fix pre-existing ESLint + scrollIntoView test issues
1 parent 40cc1a9 commit c3ad1ed

6 files changed

Lines changed: 258 additions & 32 deletions

File tree

apps/desktop/src-tauri/src/volumes_linux/CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ Linux volume and location discovery, plus live mount/unmount watching via inotif
66

77
| File | Purpose |
88
|---|---|
9-
| `mod.rs` | `LocationInfo`, `LocationCategory`, `VolumeSpaceInfo` types (mirrors macOS `volumes/mod.rs` JSON shape). `list_locations()`, `get_volume_space()`, `get_mounted_volumes()`, cloud drive detection. Uses `linux_mounts::parse_proc_mounts()` for mount enumeration. |
10-
| `watcher.rs` | `notify` (inotify) watcher on `/proc/mounts`. Detects mount/unmount by diffing real mounts against `KNOWN_MOUNTS`. Registers/unregisters with `VolumeManager`. Emits `volume-mounted` / `volume-unmounted` Tauri events. |
9+
| `mod.rs` | `LocationInfo`, `LocationCategory`, `VolumeSpaceInfo` types (mirrors macOS `volumes/mod.rs` JSON shape). `list_locations()`, `get_volume_space()`, `get_mounted_volumes()`, cloud drive detection, GVFS SMB share detection. Uses `linux_mounts::parse_proc_mounts()` for mount enumeration. |
10+
| `watcher.rs` | Two inotify watchers: `/proc/mounts` for standard mounts, `/run/user/<uid>/gvfs/` for GVFS SMB shares. Detects mount/unmount by diffing against known state. Registers/unregisters with `VolumeManager`. Emits `volume-mounted` / `volume-unmounted` Tauri events. |
1111

1212
## Location categories
1313

@@ -16,7 +16,7 @@ Favorite — Home, ~/Desktop, ~/Documents, ~/Downloads (only if they exist
1616
MainVolume — root "/" filesystem
1717
AttachedVolume — real filesystems from /proc/mounts (filters out virtual: proc, sysfs, tmpfs, etc.)
1818
CloudDrive — ~/Dropbox, ~/Google Drive, ~/.local/share/Nextcloud, ~/OneDrive
19-
Network — not yet implemented
19+
Network — GVFS SMB shares under /run/user/<uid>/gvfs/smb-share:* (ejectable, no trash)
2020
```
2121

2222
`list_locations()` aggregates all categories in order and deduplicates by path.

apps/desktop/src-tauri/src/volumes_linux/mod.rs

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
//! - Main volume (root `/`)
66
//! - Mounted volumes (real filesystems from /proc/mounts)
77
//! - Cloud drives (Dropbox, Google Drive, Nextcloud, OneDrive)
8+
//! - Network mounts (GVFS SMB shares under /run/user/<uid>/gvfs/)
89
//! - Removable media under /run/media/ or /media/
910
1011
pub mod watcher;
@@ -138,6 +139,13 @@ pub fn list_locations() -> Vec<LocationInfo> {
138139
}
139140
}
140141

142+
// 5. Network mounts (GVFS SMB shares)
143+
for loc in get_network_mounts() {
144+
if seen_paths.insert(loc.path.clone()) {
145+
locations.push(loc);
146+
}
147+
}
148+
141149
locations
142150
}
143151

@@ -289,6 +297,71 @@ fn get_cloud_drives(mounts: &[MountEntry]) -> Vec<LocationInfo> {
289297
drives
290298
}
291299

300+
/// Parse a GVFS SMB directory name into (server, share).
301+
///
302+
/// GVFS mounts SMB shares as subdirectories under `/run/user/<uid>/gvfs/`
303+
/// with names like `smb-share:server=192.168.1.150,share=pihdd` (optionally
304+
/// with `,user=X,domain=Y` suffixes). Returns None for non-SMB entries.
305+
pub(crate) fn parse_gvfs_smb_dirname(dirname: &str) -> Option<(String, String)> {
306+
let rest = dirname.strip_prefix("smb-share:")?;
307+
let mut server = None;
308+
let mut share = None;
309+
for part in rest.split(',') {
310+
if let Some(val) = part.strip_prefix("server=") {
311+
server = Some(val.to_string());
312+
} else if let Some(val) = part.strip_prefix("share=") {
313+
share = Some(val.to_string());
314+
}
315+
}
316+
Some((server?, share?))
317+
}
318+
319+
/// Discover GVFS-mounted SMB shares as network locations.
320+
///
321+
/// Scans `/run/user/<uid>/gvfs/` for `smb-share:*` directories. Each one
322+
/// becomes a `Network` location. Skips silently if the GVFS directory
323+
/// doesn't exist (non-GNOME systems).
324+
fn get_network_mounts() -> Vec<LocationInfo> {
325+
let uid = unsafe { libc::getuid() };
326+
let gvfs_dir = format!("/run/user/{}/gvfs", uid);
327+
let gvfs_path = Path::new(&gvfs_dir);
328+
329+
if !gvfs_path.is_dir() {
330+
return Vec::new();
331+
}
332+
333+
let entries = match std::fs::read_dir(gvfs_path) {
334+
Ok(entries) => entries,
335+
Err(_) => return Vec::new(),
336+
};
337+
338+
let mut mounts = Vec::new();
339+
for entry in entries.flatten() {
340+
let name = entry.file_name();
341+
let dirname = name.to_string_lossy();
342+
if let Some((_server, share)) = parse_gvfs_smb_dirname(&dirname) {
343+
let path = entry.path().to_string_lossy().to_string();
344+
// Skip inaccessible entries (hung FUSE mount)
345+
if !entry.path().is_dir() {
346+
continue;
347+
}
348+
mounts.push(LocationInfo {
349+
id: path_to_id(&path),
350+
name: share,
351+
path,
352+
category: LocationCategory::Network,
353+
icon: None,
354+
is_ejectable: true,
355+
fs_type: None,
356+
supports_trash: false,
357+
});
358+
}
359+
}
360+
361+
mounts.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
362+
mounts
363+
}
364+
292365
/// Get space information for a volume using `statvfs`.
293366
pub fn get_volume_space(path: &str) -> Option<VolumeSpaceInfo> {
294367
use std::ffi::CString;
@@ -322,7 +395,7 @@ pub fn find_volume_for_path(path: &str) -> Option<String> {
322395
}
323396

324397
/// Convert a mount path to a safe ID string.
325-
fn path_to_id(path: &str) -> String {
398+
pub(crate) fn path_to_id(path: &str) -> String {
326399
if path == "/" {
327400
return DEFAULT_VOLUME_ID.to_string();
328401
}
@@ -636,4 +709,30 @@ share /mnt/cmdr virtiofs rw,relatime 0 0
636709
let space = get_volume_space("/nonexistent/path/does/not/exist");
637710
assert!(space.is_none());
638711
}
712+
713+
#[test]
714+
fn test_parse_gvfs_smb_dirname_basic() {
715+
let result = parse_gvfs_smb_dirname("smb-share:server=192.168.1.150,share=pihdd");
716+
assert_eq!(result, Some(("192.168.1.150".to_string(), "pihdd".to_string())));
717+
}
718+
719+
#[test]
720+
fn test_parse_gvfs_smb_dirname_with_extra_params() {
721+
let result = parse_gvfs_smb_dirname("smb-share:server=mynas.local,share=photos,user=alice,domain=WORKGROUP");
722+
assert_eq!(result, Some(("mynas.local".to_string(), "photos".to_string())));
723+
}
724+
725+
#[test]
726+
fn test_parse_gvfs_smb_dirname_non_smb() {
727+
assert_eq!(parse_gvfs_smb_dirname("dav+sd:host=example.com"), None);
728+
assert_eq!(parse_gvfs_smb_dirname("ftp:host=ftp.example.com"), None);
729+
assert_eq!(parse_gvfs_smb_dirname("some-random-dir"), None);
730+
}
731+
732+
#[test]
733+
fn test_parse_gvfs_smb_dirname_missing_fields() {
734+
assert_eq!(parse_gvfs_smb_dirname("smb-share:server=192.168.1.1"), None);
735+
assert_eq!(parse_gvfs_smb_dirname("smb-share:share=data"), None);
736+
assert_eq!(parse_gvfs_smb_dirname("smb-share:"), None);
737+
}
639738
}

apps/desktop/src-tauri/src/volumes_linux/watcher.rs

Lines changed: 143 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,37 @@
11
//! Volume mount/unmount watcher for Linux.
22
//!
3-
//! Watches `/proc/mounts` for changes using `notify` (inotify). When mounts
4-
//! change, diffs against the previous state and emits `volume-mounted` /
5-
//! `volume-unmounted` Tauri events. Also registers/unregisters volumes with
6-
//! the global `VolumeManager`.
3+
//! Two watchers run concurrently:
4+
//! - `/proc/mounts` (inotify) — detects standard mount/unmount operations
5+
//! - `/run/user/<uid>/gvfs/` (inotify) — detects GVFS SMB share mount/unmount
6+
//! (these are subdirectories of a single gvfsd-fuse mount, so they don't
7+
//! appear in `/proc/mounts`)
8+
//!
9+
//! Both diff against known state and emit `volume-mounted` / `volume-unmounted`
10+
//! Tauri events. Also registers/unregisters volumes with the global `VolumeManager`.
711
812
use crate::file_system::linux_mounts;
9-
use log::{debug, error, info};
13+
use log::{debug, error, info, warn};
1014
use notify::{Event, EventKind, RecommendedWatcher, Watcher};
11-
use std::collections::HashMap;
15+
use std::collections::{HashMap, HashSet};
1216
use std::path::Path;
1317
use std::sync::{Mutex, OnceLock};
1418
use tauri::{AppHandle, Emitter};
1519

1620
/// Global app handle for emitting events from the watcher.
1721
static APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
1822

19-
/// The watcher instance (kept alive for the app's lifetime).
23+
/// The watcher instance for /proc/mounts (kept alive for the app's lifetime).
2024
static WATCHER: OnceLock<Mutex<Option<RecommendedWatcher>>> = OnceLock::new();
2125

26+
/// The watcher instance for GVFS directory.
27+
static GVFS_WATCHER: OnceLock<Mutex<Option<RecommendedWatcher>>> = OnceLock::new();
28+
2229
/// Known mount points mapped to their filesystem type, for diffing.
2330
static KNOWN_MOUNTS: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
2431

32+
/// Known GVFS SMB mount paths, for diffing.
33+
static KNOWN_GVFS_MOUNTS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
34+
2535
/// Payload for volume mount/unmount events.
2636
#[derive(Clone, serde::Serialize)]
2737
#[serde(rename_all = "camelCase")]
@@ -71,17 +81,24 @@ pub fn start_volume_watcher(app: &AppHandle) {
7181
error!("Failed to create Linux volume watcher: {}", e);
7282
}
7383
}
84+
85+
start_gvfs_watcher();
7486
}
7587

76-
/// Stop the volume watcher.
88+
/// Stop both volume watchers (proc/mounts and GVFS).
7789
#[allow(dead_code, reason = "Symmetry with macOS, will be used for explicit cleanup")]
7890
pub fn stop_volume_watcher() {
7991
if let Some(storage) = WATCHER.get()
8092
&& let Ok(mut guard) = storage.lock()
8193
{
8294
*guard = None;
8395
}
84-
debug!("Linux volume watcher stopped");
96+
if let Some(storage) = GVFS_WATCHER.get()
97+
&& let Ok(mut guard) = storage.lock()
98+
{
99+
*guard = None;
100+
}
101+
debug!("Linux volume watchers stopped");
85102
}
86103

87104
/// Handle filesystem events on /proc/mounts.
@@ -163,6 +180,111 @@ fn get_real_mounts() -> HashMap<String, String> {
163180
.collect()
164181
}
165182

183+
/// Start watching the GVFS directory for SMB share mount/unmount.
184+
/// Skips silently if `/run/user/<uid>/gvfs/` doesn't exist (non-GNOME systems).
185+
fn start_gvfs_watcher() {
186+
let uid = unsafe { libc::getuid() };
187+
let gvfs_dir = format!("/run/user/{}/gvfs", uid);
188+
let gvfs_path = Path::new(&gvfs_dir);
189+
190+
if !gvfs_path.is_dir() {
191+
debug!("GVFS directory {} not found, skipping GVFS watcher", gvfs_dir);
192+
return;
193+
}
194+
195+
// Snapshot current GVFS SMB mounts
196+
let initial = get_current_gvfs_smb_paths(&gvfs_dir);
197+
let known = KNOWN_GVFS_MOUNTS.get_or_init(|| Mutex::new(HashSet::new()));
198+
if let Ok(mut guard) = known.lock() {
199+
debug!("Initial GVFS SMB mounts: {} entries", initial.len());
200+
*guard = initial;
201+
}
202+
203+
let gvfs_dir_owned = gvfs_dir.clone();
204+
let watcher_result = notify::recommended_watcher(move |result: Result<Event, notify::Error>| match result {
205+
Ok(event) => handle_gvfs_event(event, &gvfs_dir_owned),
206+
Err(e) => error!("GVFS watcher error: {}", e),
207+
});
208+
209+
match watcher_result {
210+
Ok(mut watcher) => {
211+
if let Err(e) = watcher.watch(gvfs_path, notify::RecursiveMode::NonRecursive) {
212+
warn!("Failed to watch GVFS directory {}: {}", gvfs_dir, e);
213+
return;
214+
}
215+
216+
let storage = GVFS_WATCHER.get_or_init(|| Mutex::new(None));
217+
if let Ok(mut guard) = storage.lock() {
218+
*guard = Some(watcher);
219+
}
220+
221+
info!("GVFS watcher started on {}", gvfs_dir);
222+
}
223+
Err(e) => {
224+
warn!("Failed to create GVFS watcher: {}", e);
225+
}
226+
}
227+
}
228+
229+
/// Handle inotify events on the GVFS directory.
230+
fn handle_gvfs_event(event: Event, gvfs_dir: &str) {
231+
match event.kind {
232+
EventKind::Create(_) | EventKind::Remove(_) => {
233+
check_for_gvfs_changes(gvfs_dir);
234+
}
235+
_ => {}
236+
}
237+
}
238+
239+
/// Diff current GVFS SMB directories against known state and emit events.
240+
fn check_for_gvfs_changes(gvfs_dir: &str) {
241+
let current = get_current_gvfs_smb_paths(gvfs_dir);
242+
243+
let known = match KNOWN_GVFS_MOUNTS.get() {
244+
Some(k) => k,
245+
None => return,
246+
};
247+
248+
let mut known_guard = match known.lock() {
249+
Ok(g) => g,
250+
Err(_) => return,
251+
};
252+
253+
// Newly mounted shares
254+
for path in &current {
255+
if !known_guard.contains(path) {
256+
debug!("GVFS SMB share mounted: {}", path);
257+
emit_volume_mounted(path);
258+
}
259+
}
260+
261+
// Unmounted shares
262+
for path in known_guard.iter() {
263+
if !current.contains(path) {
264+
debug!("GVFS SMB share unmounted: {}", path);
265+
emit_volume_unmounted(path);
266+
}
267+
}
268+
269+
*known_guard = current;
270+
}
271+
272+
/// Scan the GVFS directory for current SMB share mount paths.
273+
fn get_current_gvfs_smb_paths(gvfs_dir: &str) -> HashSet<String> {
274+
let mut paths = HashSet::new();
275+
let Ok(entries) = std::fs::read_dir(gvfs_dir) else {
276+
return paths;
277+
};
278+
for entry in entries.flatten() {
279+
let name = entry.file_name();
280+
let dirname = name.to_string_lossy();
281+
if super::parse_gvfs_smb_dirname(&dirname).is_some() {
282+
paths.insert(entry.path().to_string_lossy().to_string());
283+
}
284+
}
285+
paths
286+
}
287+
166288
/// Emit a volume-mounted event and register with VolumeManager.
167289
fn emit_volume_mounted(volume_path: &str) {
168290
register_volume_with_manager(volume_path);
@@ -201,17 +323,18 @@ fn register_volume_with_manager(volume_path: &str) {
201323
use crate::file_system::volume::LocalPosixVolume;
202324
use std::sync::Arc;
203325

204-
let volume_id: String = volume_path
205-
.chars()
206-
.filter(|c| c.is_alphanumeric() || *c == '-')
207-
.collect::<String>()
208-
.to_lowercase();
326+
let volume_id = super::path_to_id(volume_path);
209327

210-
let name = Path::new(volume_path)
211-
.file_name()
212-
.and_then(|n| n.to_str())
213-
.unwrap_or("Unknown")
214-
.to_string();
328+
// For GVFS SMB shares, extract the share name instead of the raw dirname
329+
let name = if let Some(dirname) = Path::new(volume_path).file_name().and_then(|n| n.to_str()) {
330+
if let Some((_server, share)) = super::parse_gvfs_smb_dirname(dirname) {
331+
share
332+
} else {
333+
dirname.to_string()
334+
}
335+
} else {
336+
"Unknown".to_string()
337+
};
215338

216339
let volume = Arc::new(LocalPosixVolume::new(&name, volume_path));
217340
get_volume_manager().register(&volume_id, volume);
@@ -222,12 +345,7 @@ fn register_volume_with_manager(volume_path: &str) {
222345
fn unregister_volume_from_manager(volume_path: &str) {
223346
use crate::file_system::get_volume_manager;
224347

225-
let volume_id: String = volume_path
226-
.chars()
227-
.filter(|c| c.is_alphanumeric() || *c == '-')
228-
.collect::<String>()
229-
.to_lowercase();
230-
348+
let volume_id = super::path_to_id(volume_path);
231349
get_volume_manager().unregister(&volume_id);
232350
debug!("Unregistered volume: {} ({})", volume_id, volume_path);
233351
}

0 commit comments

Comments
 (0)