Skip to content

Commit 76671bf

Browse files
committed
SMB: Mount disambiguation for same-name shares
- Detect when `/Volumes/{share}` is taken by a different server (via `statfs` port comparison) and use `kNetFSForceNewSessionKey` so macOS creates a disambiguated mount point (`public-1`, etc.) - Add `port` field to `SmbMountInfo` (macOS + Linux) — previously stripped and discarded during parsing - Volume switcher shows `{share} on {server}` for SMB mounts so the user knows which server each volume belongs to - Fix SvelteKit HMR crash: catch `component` TDZ error from UnoCSS-triggered root layout updates and reload cleanly - Update `network/CLAUDE.md` with disambiguation docs
1 parent 017b704 commit 76671bf

4 files changed

Lines changed: 113 additions & 14 deletions

File tree

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,11 @@ Manual server IDs use the format `manual-{address}-{port}` with dots/colons repl
106106

107107
### Mount path disambiguation for same-name shares
108108

109-
When two servers have a share with the same name (for example, two NAS devices both sharing `public`), macOS creates
110-
disambiguated mount paths (`/Volumes/public`, `/Volumes/public-1`). The mount code reads the actual path from
111-
`NetFSMountURLSync`'s `mountpoints` array on both success and EEXIST. If the array is empty (some macOS versions don't
112-
populate it on EEXIST), `find_mount_path_for_share` scans `/Volumes/` and uses `statfs` to match the server+share.
109+
When two servers have a share with the same name (for example, two NAS devices both sharing `public`), the mount code
110+
detects the collision before calling `NetFSMountURLSync`. `disambiguated_mount_path` checks if `/Volumes/{share}` is
111+
already taken by a different server (via `statfs`), and if so picks `/Volumes/{share}-1`, `-2`, etc. (Finder's
112+
convention) and passes it as an explicit mount point to `NetFSMountURLSync`. The volume switcher shows
113+
`{share} on {server}` for SMB mounts so the user knows which server each volume belongs to.
113114

114115
## Gotchas
115116

apps/desktop/src-tauri/src/network/mount.rs

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,14 +161,44 @@ pub fn mount_share_sync(
161161
let cf_user = username.map(CFString::new);
162162
let cf_pass = password.map(CFString::new);
163163

164+
// Check if the default mount path is already taken by a different server.
165+
// If so, pick a disambiguated path (public-1, public-2, ...) like Finder does.
166+
let explicit_mount_path = disambiguated_mount_path(server, share, port);
167+
168+
// When disambiguating, force a new SMB session so macOS doesn't reuse
169+
// the existing session to the same hostname (different port = different server).
170+
let open_options = if explicit_mount_path.is_some() {
171+
unsafe {
172+
let dict = core_foundation::dictionary::CFDictionaryCreateMutable(
173+
ptr::null(),
174+
1,
175+
&core_foundation::dictionary::kCFTypeDictionaryKeyCallBacks,
176+
&core_foundation::dictionary::kCFTypeDictionaryValueCallBacks,
177+
);
178+
let key = CFString::new("ForceNewSession");
179+
let value = core_foundation::boolean::kCFBooleanTrue;
180+
core_foundation::dictionary::CFDictionarySetValue(
181+
dict,
182+
key.as_concrete_TypeRef() as *const c_void,
183+
value as *const c_void,
184+
);
185+
dict as *const c_void
186+
}
187+
} else {
188+
ptr::null()
189+
};
190+
164191
// Prepare output array for mount points
165192
let mut mountpoints: *const c_void = ptr::null();
166193

167-
// Call NetFSMountURLSync
194+
// Call NetFSMountURLSync. Mount path is NULL even when disambiguating —
195+
// NetFS auto-creates the mount point in /Volumes/ (we can't mkdir there).
196+
// With ForceNewSession, NetFS treats this as a separate server and picks
197+
// a disambiguated name (public-1, public-2, etc.) automatically.
168198
let result = unsafe {
169199
NetFSMountURLSync(
170200
cf_url.as_concrete_TypeRef() as *const c_void,
171-
ptr::null(), // NULL for auto mount path
201+
ptr::null(), // Let NetFS choose/create the mount point
172202
cf_user
173203
.as_ref()
174204
.map(|s| s.as_concrete_TypeRef() as *const c_void)
@@ -177,12 +207,17 @@ pub fn mount_share_sync(
177207
.as_ref()
178208
.map(|s| s.as_concrete_TypeRef() as *const c_void)
179209
.unwrap_or(ptr::null()),
180-
ptr::null(), // No special open options
210+
open_options,
181211
ptr::null(), // No special mount options
182212
&mut mountpoints,
183213
)
184214
};
185215

216+
// Release open options dictionary if we created one
217+
if !open_options.is_null() {
218+
unsafe { core_foundation::base::CFRelease(open_options) };
219+
}
220+
186221
// Check result
187222
if result != 0 && result != EEXIST {
188223
return Err(error_from_code(result, share, server));
@@ -194,7 +229,10 @@ pub fn mount_share_sync(
194229
// EEXIST (17), macOS may return the actual path (which can be disambiguated,
195230
// for example `/Volumes/public-1` when `/Volumes/public` is already taken by
196231
// a different server). Fall back to scanning /Volumes/ for the mount.
197-
let mount_path = extract_mount_path(mountpoints)
232+
// Prefer: explicit path we chose → NetFS output → /Volumes/ scan → hardcoded fallback.
233+
// The explicit path is most reliable because we already validated it.
234+
let mount_path = explicit_mount_path
235+
.or_else(|| extract_mount_path(mountpoints))
198236
.or_else(|| find_mount_path_for_share(server, share))
199237
.unwrap_or_else(|| format!("/Volumes/{}", share));
200238

@@ -260,6 +298,52 @@ fn extract_mount_path(mountpoints: *const c_void) -> Option<String> {
260298
}
261299
}
262300

301+
/// Returns a disambiguated mount path if `/Volumes/{share}` is already taken by a
302+
/// different server. Returns `None` if the default path is available or already
303+
/// belongs to this server (EEXIST case).
304+
///
305+
/// Follows Finder's convention: `public-1`, `public-2`, etc.
306+
fn disambiguated_mount_path(server: &str, share: &str, port: u16) -> Option<String> {
307+
use crate::volumes::get_smb_mount_info;
308+
309+
let default_path = format!("/Volumes/{}", share);
310+
if !std::path::Path::new(&default_path).exists() {
311+
return None; // Default path is free
312+
}
313+
314+
// Check if the existing mount is from the same server+port
315+
if let Some(info) = get_smb_mount_info(&default_path)
316+
&& info.server.to_lowercase() == server.to_lowercase()
317+
&& info.share == share
318+
&& info.port == port
319+
{
320+
return None; // Same server — let NetFS handle EEXIST
321+
}
322+
323+
// Collision: find the next available suffix
324+
for n in 1..100 {
325+
let candidate = format!("/Volumes/{}-{}", share, n);
326+
if !std::path::Path::new(&candidate).exists() {
327+
log::info!(
328+
"Mount path /Volumes/{} taken by another server, using {}",
329+
share,
330+
candidate
331+
);
332+
return Some(candidate);
333+
}
334+
// If this suffixed path exists and belongs to this server, reuse it
335+
if let Some(info) = get_smb_mount_info(&candidate)
336+
&& info.server.to_lowercase() == server.to_lowercase()
337+
&& info.share == share
338+
&& info.port == port
339+
{
340+
return Some(candidate); // Already mounted here
341+
}
342+
}
343+
344+
None // Give up after 100 attempts, let NetFS handle it
345+
}
346+
263347
/// Finds the mount path for a server+share by scanning `/Volumes/` with `statfs`.
264348
///
265349
/// Handles disambiguated paths: if `server` has share `public` but `/Volumes/public`

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,11 +486,20 @@ pub fn get_attached_volumes() -> Vec<LocationInfo> {
486486
continue;
487487
}
488488

489-
let name = get_volume_name(&url, &path);
489+
let mut name = get_volume_name(&url, &path);
490490
let is_ejectable = get_bool_resource(&url, "NSURLVolumeIsEjectableKey").unwrap_or(false);
491491
let fs_type = get_fs_type(&path);
492492
let supports_trash = supports_trash_for_fs_type(fs_type.as_deref());
493493

494+
// For SMB mounts, show "share on server" so the user knows which
495+
// server they're browsing (especially when multiple servers share
496+
// the same share name).
497+
if is_smb_fs_type(fs_type.as_deref())
498+
&& let Some(info) = get_smb_mount_info(&path)
499+
{
500+
name = format!("{} on {}", info.share, info.server);
501+
}
502+
494503
volumes.push(LocationInfo {
495504
id: path_to_id(&path),
496505
name,

apps/desktop/src/routes/+layout.svelte

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@
99
import '../app.css'
1010
import { initLogger } from '$lib/logging/logger'
1111
12-
// When the root layout or its dependencies (virtual:uno.css, app.css) change,
1312
// SvelteKit's client router crashes with "Cannot access 'component' before
14-
// initialization." Force a clean page reload instead of the broken HMR path.
13+
// initialization" when HMR updates hit the root layout (virtual:uno.css, app.css).
14+
// Catch the crash and force a clean page reload.
1515
if (import.meta.hot) {
16-
const hot = import.meta.hot
17-
hot.accept(() => {
18-
hot.invalidate()
16+
window.addEventListener('unhandledrejection', (event) => {
17+
if (
18+
event.reason instanceof ReferenceError &&
19+
event.reason.message.includes('component')
20+
) {
21+
event.preventDefault()
22+
location.reload()
23+
}
1924
})
2025
}
2126

0 commit comments

Comments
 (0)