Skip to content

Commit a6ab2ca

Browse files
committed
SMB: Auto-upgrade mounts to direct connections
- Auto-upgrade all pre-existing SMB mounts at startup (background, after mDNS resolves) - Auto-upgrade newly detected SMB mounts via the `/Volumes` watcher - Poll for mDNS `Active` state before startup upgrade (Keychain lookup needs hostname resolution) - Emit `volumes-changed` after background upgrades so the frontend indicator updates - Add `network.directSmbConnection` setting (default: on) to control auto-upgrade behavior - Expose `register_smb_volume`, `get_keychain_password`, `resolve_ip_to_hostname` as `pub(crate)`
1 parent b1addfd commit a6ab2ca

8 files changed

Lines changed: 201 additions & 12 deletions

File tree

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

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ pub async fn mount_network_share(
323323
///
324324
/// Best-effort: logs a warning and returns quietly on failure. The FSEvents
325325
/// watcher will register a `LocalPosixVolume` as fallback.
326-
async fn register_smb_volume(
326+
pub(crate) async fn register_smb_volume(
327327
server: &str,
328328
share: &str,
329329
mount_path: &str,
@@ -419,9 +419,10 @@ pub async fn upgrade_to_smb_volume(volume_id: String) -> Result<String, String>
419419

420420
// Check if it worked
421421
if let Some(vol) = manager.get(&volume_id)
422-
&& vol.smb_connection_state().is_some() {
423-
return Ok("direct".to_string());
424-
}
422+
&& vol.smb_connection_state().is_some()
423+
{
424+
return Ok("direct".to_string());
425+
}
425426

426427
Err(format!(
427428
"Failed to establish direct smb2 connection to {}/{}",
@@ -432,7 +433,7 @@ pub async fn upgrade_to_smb_volume(volume_id: String) -> Result<String, String>
432433
/// Looks up the mDNS hostname for an IP address from discovered hosts.
433434
///
434435
/// Returns the hostname (like "naspolya") without `.local` suffix.
435-
fn resolve_ip_to_hostname(ip: &str) -> Option<String> {
436+
pub(crate) fn resolve_ip_to_hostname(ip: &str) -> Option<String> {
436437
let hosts = get_discovered_hosts();
437438
for host in &hosts {
438439
if host.ip_address.as_deref() == Some(ip) {
@@ -447,7 +448,7 @@ fn resolve_ip_to_hostname(ip: &str) -> Option<String> {
447448
///
448449
/// Tries multiple keys: by IP (from statfs), by hostname (from mDNS discovery),
449450
/// at both share-level and server-level.
450-
async fn get_keychain_password(
451+
pub(crate) async fn get_keychain_password(
451452
server_ip: &str,
452453
hostname: Option<&str>,
453454
share: &str,
@@ -479,12 +480,7 @@ async fn get_keychain_password(
479480
}
480481
}
481482

482-
log::debug!(
483-
"No Keychain credentials for {:?} / {} / {}",
484-
hostname,
485-
server_ip,
486-
share
487-
);
483+
log::debug!("No Keychain credentials for {:?} / {} / {}", hostname, server_ip, share);
488484
None
489485
})
490486
.await

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

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub mod volume;
1818
pub(crate) mod watcher;
1919
pub(crate) mod write_operations;
2020

21+
use std::sync::atomic::{AtomicBool, Ordering};
2122
use std::sync::{Arc, LazyLock};
2223

2324
// Re-export public types from the listing module
@@ -66,6 +67,20 @@ pub use write_operations::{
6667
/// Global volume manager instance
6768
static VOLUME_MANAGER: LazyLock<VolumeManager> = LazyLock::new(VolumeManager::new);
6869

70+
/// Whether to auto-upgrade SMB mounts to direct smb2 connections.
71+
/// Set from the `network.directSmbConnection` setting at startup.
72+
static DIRECT_SMB_ENABLED: AtomicBool = AtomicBool::new(true);
73+
74+
/// Sets the direct SMB connection preference. Call from app setup after loading settings.
75+
pub fn set_direct_smb_enabled(enabled: bool) {
76+
DIRECT_SMB_ENABLED.store(enabled, Ordering::Relaxed);
77+
}
78+
79+
/// Returns whether direct SMB connection is enabled.
80+
pub fn is_direct_smb_enabled() -> bool {
81+
DIRECT_SMB_ENABLED.load(Ordering::Relaxed)
82+
}
83+
6984
/// Initializes the global volume manager with all discovered volumes.
7085
///
7186
/// This should be called during app startup (after init_watcher_manager).
@@ -126,5 +141,112 @@ pub fn get_volume_manager() -> &'static VolumeManager {
126141
&VOLUME_MANAGER
127142
}
128143

144+
/// Upgrades all existing SMB mounts to direct smb2 connections (background task).
145+
///
146+
/// Scans all registered volumes, finds those on `smbfs`, and tries to establish
147+
/// a parallel smb2 session for each. Non-blocking: failures are logged and skipped.
148+
#[cfg(any(target_os = "macos", target_os = "linux"))]
149+
pub fn upgrade_existing_smb_mounts() {
150+
if !is_direct_smb_enabled() {
151+
log::debug!("Direct SMB connections disabled, skipping startup upgrade");
152+
return;
153+
}
154+
155+
// Collect SMB volume paths to upgrade (don't hold the manager lock during async work)
156+
let volumes_to_upgrade: Vec<(String, String)> = {
157+
let all_volumes = VOLUME_MANAGER.list_volumes();
158+
all_volumes
159+
.into_iter()
160+
.map(|(id, _name)| id)
161+
.filter_map(|id| {
162+
let vol = VOLUME_MANAGER.get(&id)?;
163+
// Skip volumes that are already SmbVolume
164+
if vol.smb_connection_state().is_some() {
165+
return None;
166+
}
167+
let path = vol.root().to_string_lossy().to_string();
168+
// Check if it's an SMB mount
169+
let info = crate::volumes::get_smb_mount_info(&path)?;
170+
let _ = info; // We just need to know it's SMB
171+
Some((id, path))
172+
})
173+
.collect()
174+
};
175+
176+
if volumes_to_upgrade.is_empty() {
177+
log::debug!("No SMB mounts to upgrade at startup");
178+
return;
179+
}
180+
181+
log::info!(
182+
"Found {} SMB mount(s) to upgrade to direct connections",
183+
volumes_to_upgrade.len()
184+
);
185+
186+
// Use tauri's runtime spawn — this runs during setup() before Tokio is fully available.
187+
// Wait for mDNS discovery to reach Active state (initial burst complete) so hostname
188+
// resolution is available for Keychain lookup.
189+
tauri::async_runtime::spawn(async move {
190+
wait_for_mdns_ready().await;
191+
192+
let mut any_upgraded = false;
193+
for (_volume_id, mount_path) in volumes_to_upgrade {
194+
let info = match crate::volumes::get_smb_mount_info(&mount_path) {
195+
Some(info) => info,
196+
None => continue,
197+
};
198+
199+
// Resolve hostname from mDNS for Keychain lookup
200+
let hostname = crate::commands::network::resolve_ip_to_hostname(&info.server);
201+
202+
// Try Keychain creds
203+
let creds =
204+
crate::commands::network::get_keychain_password(&info.server, hostname.as_deref(), &info.share).await;
205+
206+
let (username, password) = match &creds {
207+
Some((u, p)) => (Some(u.as_str()), Some(p.as_str())),
208+
None => (None, None),
209+
};
210+
211+
crate::commands::network::register_smb_volume(
212+
&info.server,
213+
&info.share,
214+
&mount_path,
215+
username,
216+
password,
217+
445,
218+
)
219+
.await;
220+
any_upgraded = true;
221+
}
222+
223+
// Notify frontend to refresh volume list so indicators update from yellow to green
224+
if any_upgraded {
225+
crate::volume_broadcast::emit_volumes_changed();
226+
}
227+
});
228+
}
229+
230+
/// Waits until mDNS discovery reaches the `Active` state (initial burst complete).
231+
///
232+
/// Polls every 500ms for up to 15 seconds. If discovery never reaches Active,
233+
/// proceeds anyway — the upgrade will try without hostname resolution and may
234+
/// fall back to guest access.
235+
#[cfg(any(target_os = "macos", target_os = "linux"))]
236+
async fn wait_for_mdns_ready() {
237+
use crate::network::{DiscoveryState, get_discovery_state_value};
238+
239+
for _ in 0..30 {
240+
match get_discovery_state_value() {
241+
DiscoveryState::Active => {
242+
log::debug!("mDNS discovery is Active, proceeding with SMB upgrades");
243+
return;
244+
}
245+
_ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
246+
}
247+
}
248+
log::debug!("mDNS discovery didn't reach Active within 15s, proceeding anyway");
249+
}
250+
129251
#[cfg(test)]
130252
mod watcher_test;

apps/desktop/src-tauri/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,13 @@ pub fn run() {
353353
// Load persisted settings to initialize menu with correct state
354354
let saved_settings = settings::load_settings(app.handle());
355355

356+
// Apply direct SMB connection setting (default: true)
357+
file_system::set_direct_smb_enabled(saved_settings.direct_smb_connection.unwrap_or(true));
358+
359+
// Upgrade existing SMB mounts to direct smb2 connections (background, non-blocking)
360+
#[cfg(any(target_os = "macos", target_os = "linux"))]
361+
file_system::upgrade_existing_smb_mounts();
362+
356363
// Check if there's an existing license (for menu text)
357364
let has_existing_license = licensing::get_license_info(app.handle()).is_some();
358365

apps/desktop/src-tauri/src/settings/loader.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ pub struct Settings {
4949
#[serde(alias = "developer.verboseLogging", default)]
5050
#[allow(dead_code, reason = "Included in crash reports for feature correlation")]
5151
pub verbose_logging: Option<bool>,
52+
#[serde(alias = "network.directSmbConnection", default)]
53+
pub direct_smb_connection: Option<bool>,
5254
}
5355

5456
fn default_show_hidden() -> bool {
@@ -66,6 +68,7 @@ impl Default for Settings {
6668
crash_reports_enabled: None,
6769
ai_provider: None,
6870
verbose_logging: None,
71+
direct_smb_connection: None,
6972
}
7073
}
7174
}
@@ -112,6 +115,7 @@ fn parse_settings(contents: &str) -> Result<Settings, serde_json::Error> {
112115
let crash_reports_enabled = json.get("updates.crashReports").and_then(|v| v.as_bool());
113116
let ai_provider = json.get("ai.provider").and_then(|v| v.as_str()).map(String::from);
114117
let verbose_logging = json.get("developer.verboseLogging").and_then(|v| v.as_bool());
118+
let direct_smb_connection = json.get("network.directSmbConnection").and_then(|v| v.as_bool());
115119

116120
Ok(Settings {
117121
show_hidden_files,
@@ -122,5 +126,6 @@ fn parse_settings(contents: &str) -> Result<Settings, serde_json::Error> {
122126
crash_reports_enabled,
123127
ai_provider,
124128
verbose_logging,
129+
direct_smb_connection,
125130
})
126131
}

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ fn emit_volume_mounted(volume_path: &str) {
157157
// Register the new volume with VolumeManager so it can be used for file operations
158158
register_volume_with_manager(volume_path);
159159

160+
// If it's an SMB mount and direct connections are enabled, try to upgrade
161+
#[cfg(any(target_os = "macos", target_os = "linux"))]
162+
try_upgrade_smb_mount(volume_path);
163+
160164
if let Some(app) = APP_HANDLE.get() {
161165
let payload = VolumeEventPayload {
162166
volume_path: volume_path.to_string(),
@@ -276,6 +280,36 @@ fn spawn_mount_settle_watcher(volume_path: String) {
276280
});
277281
}
278282

283+
/// Tries to upgrade an SMB mount to a direct smb2 connection in the background.
284+
///
285+
/// Best-effort: if the upgrade fails, the volume stays as a `LocalPosixVolume`.
286+
#[cfg(any(target_os = "macos", target_os = "linux"))]
287+
fn try_upgrade_smb_mount(volume_path: &str) {
288+
use crate::file_system::is_direct_smb_enabled;
289+
use crate::volumes::get_smb_mount_info;
290+
291+
if !is_direct_smb_enabled() {
292+
return;
293+
}
294+
295+
let Some(info) = get_smb_mount_info(volume_path) else {
296+
return; // Not an SMB mount
297+
};
298+
299+
let mount_path = volume_path.to_string();
300+
tokio::spawn(async move {
301+
let hostname = crate::commands::network::resolve_ip_to_hostname(&info.server);
302+
let creds =
303+
crate::commands::network::get_keychain_password(&info.server, hostname.as_deref(), &info.share).await;
304+
let (username, password) = match &creds {
305+
Some((u, p)) => (Some(u.as_str()), Some(p.as_str())),
306+
None => (None, None),
307+
};
308+
crate::commands::network::register_smb_volume(&info.server, &info.share, &mount_path, username, password, 445)
309+
.await;
310+
});
311+
}
312+
279313
#[cfg(test)]
280314
mod tests {
281315
use super::*;

apps/desktop/src/lib/settings/sections/NetworkSection.svelte

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import SettingsSection from '../components/SettingsSection.svelte'
33
import SettingRow from '../components/SettingRow.svelte'
4+
import SettingSwitch from '../components/SettingSwitch.svelte'
45
import SettingSelect from '../components/SettingSelect.svelte'
56
import SettingRadioGroup from '../components/SettingRadioGroup.svelte'
67
import SettingNumberInput from '../components/SettingNumberInput.svelte'
@@ -16,11 +17,23 @@
1617
const shouldShow = $derived(createShouldShow(searchQuery))
1718
1819
const defaultDef = { label: '', description: '' }
20+
const directSmbDef = getSettingDefinition('network.directSmbConnection') ?? defaultDef
1921
const cacheDurationDef = getSettingDefinition('network.shareCacheDuration') ?? defaultDef
2022
const timeoutModeDef = getSettingDefinition('network.timeoutMode') ?? defaultDef
2123
</script>
2224

2325
<SettingsSection title="SMB/Network shares">
26+
{#if shouldShow('network.directSmbConnection')}
27+
<SettingRow
28+
id="network.directSmbConnection"
29+
label={directSmbDef.label}
30+
description={directSmbDef.description}
31+
{searchQuery}
32+
>
33+
<SettingSwitch id="network.directSmbConnection" />
34+
</SettingRow>
35+
{/if}
36+
2437
{#if shouldShow('network.shareCacheDuration')}
2538
<SettingRow
2639
id="network.shareCacheDuration"

apps/desktop/src/lib/settings/settings-registry.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,17 @@ export const settingsRegistry: SettingDefinition[] = [
249249
// ========================================================================
250250
// Network › SMB/Network shares
251251
// ========================================================================
252+
{
253+
id: 'network.directSmbConnection',
254+
section: ['Network', 'SMB/Network shares'],
255+
label: 'Connect directly to SMB shares',
256+
description:
257+
'When enabled, Cmdr establishes a direct connection to SMB shares for faster file operations. The system mount stays for Finder and other apps.',
258+
keywords: ['smb', 'direct', 'fast', 'connection', 'network', 'performance', 'smb2'],
259+
type: 'boolean',
260+
default: true,
261+
component: 'switch',
262+
},
252263
{
253264
id: 'network.shareCacheDuration',
254265
section: ['Network', 'SMB/Network shares'],

apps/desktop/src/lib/settings/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export interface SettingsValues {
109109
'updates.crashReports': boolean
110110

111111
// Network
112+
'network.directSmbConnection': boolean
112113
'network.shareCacheDuration': number
113114
'network.timeoutMode': NetworkTimeoutMode
114115
'network.customTimeout': number

0 commit comments

Comments
 (0)