Skip to content

Commit d2ae517

Browse files
committed
Network: Lazy-start mDNS, add networking toggle
- Add `network.enabled` setting (top of `Settings > Network > SMB/Network shares`, default on). When off, the picker shows "Network (disabled)" and clicking it deep-links to the Settings section instead of navigating - Defer mDNS browse and direct-smb2 mount upgrade until first user network action — fresh installs no longer trigger macOS's "Cmdr wants to find devices on local networks" prompt at app launch - Hidden internal `network.firstTriggerDone` flag persists once we've fired the prompt, so returning users still get eager startup (no re-prompt regression) - Lazy trigger fires from `NetworkBrowser` mount, `ConnectToServerDialog` open, and the picker's OS-mount → direct-SMB upgrade click. Single chokepoint in `lazy-trigger.ts` - Add `NSLocalNetworkUsageDescription` to `Info.plist` so the system prompt has user-friendly copy - Settings panel surfaces a "Local Network access" info card with a deep link to System Settings > Privacy & Security > Local Network - Settings window gains an optional `section` deep-link (JSON-encoded URL param + cross-window event) - Hidden settings flag (`hidden: true`) for internal-only state that needs the same persistence as user settings without showing in the UI - Backend: new `ensure_network_discovery_started` and `set_network_enabled` Tauri commands. mDNS startup gate consolidated into a single `should_start_network_at_launch` boolean covering both daemon start and existing-mount upgrade - E2E: new `network-toggle.spec.ts` covers the four toggle UX states end-to-end - Project rule: don't bump `file-length-allowlist.json` as a side effect of unrelated changes
1 parent f95441d commit d2ae517

35 files changed

Lines changed: 682 additions & 35 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
❌ NEVER modify `scripts/check/checks/file-length-allowlist.json` unless the user explicitly asks for it. The
2+
file-length check is warn-only — it doesn't fail the suite. The allowlist exists to track current file sizes; bumping it
3+
as a side effect of a feature change hides growth that should be addressed by trimming the file or splitting it. If a
4+
file you're touching exceeds its allowlisted count and the warning is annoying, leave it as a warning and surface it to
5+
the user, don't silently raise the limit.

apps/desktop/knip.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
{
22
"$schema": "https://unpkg.com/knip@5/schema.json",
33
"ignoreBinaries": ["only-allow", "rustc"],
4-
"ignore": ["src/lib/tauri-commands/**"],
4+
"ignore": ["src/lib/tauri-commands/**", "src/unplugin-icons.d.ts"],
55
"ignoreUnresolved": ["~icons/.+"],
66
"ignoreDependencies": [
77
"oxlint",
88
"@tauri-apps/cli",
99
"@testing-library/svelte",
1010
"prettier-plugin-svelte",
11-
"axe-core",
1211
"@iconify-json/lucide"
1312
],
1413
"ignoreExportsUsedInFile": true,

apps/desktop/src-tauri/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,7 @@
2020
<string>Cmdr needs access to your Documents folder to browse and manage files there.</string>
2121
<key>NSDownloadsFolderUsageDescription</key>
2222
<string>Cmdr needs access to your Downloads folder to browse and manage files there.</string>
23+
<key>NSLocalNetworkUsageDescription</key>
24+
<string>Cmdr uses your local network to discover SMB file servers like NAS devices and connect to them for browsing and transferring files.</string>
2325
</dict>
2426
</plist>

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ immediately to business-logic modules. No significant logic lives here.
1313
| `volumes.rs` | Volume management (macOS) | `list_volumes`, `get_default_volume_id`, `get_volume_space`, `resolve_path_volume` (statfs-based, no volume enumeration) |
1414
| `volumes_linux.rs` | Volume management (Linux) | Same interface as `volumes.rs`, delegates to `volumes_linux` module |
1515
| `mtp.rs` | MTP devices | Full MTP command surface (connect, disconnect, list, download, upload, delete, rename, move, scan) |
16-
| `network.rs` | SMB/network shares | Discovery, share listing, keychain, mounting, direct-connection upgrade, in-place reconnect (`reconnect_smb_volume` — backend single-flighted via `Volume::attempt_reconnect`), per-volume disconnect (`disconnect_smb_volume` — macOS shells out to `diskutil unmount`, Linux drops the smb2 session). Upgrade business logic (address resolution, credential lookup, smb2 connection) lives in `network::smb_upgrade`; commands here are thin wrappers. |
16+
| `network.rs` | SMB/network shares | Discovery, share listing, keychain, mounting, direct-connection upgrade, in-place reconnect (`reconnect_smb_volume` — backend single-flighted via `Volume::attempt_reconnect`), per-volume disconnect (`disconnect_smb_volume` — macOS shells out to `diskutil unmount`, Linux drops the smb2 session). Lazy-startup hooks: `ensure_network_discovery_started` (idempotent — kicks off mDNS + manual-server load + smb-mount upgrade on first user network action) and `set_network_enabled` (live-applies the `network.enabled` toggle: stops mDNS and clears discovered hosts when off). Upgrade business logic (address resolution, credential lookup, smb2 connection) lives in `network::smb_upgrade`; commands here are thin wrappers. |
1717
| `font_metrics.rs` | Font metrics cache | `store_font_metrics`, `has_font_metrics` |
1818
| `icons.rs` | File icons | `get_icons`, `refresh_directory_icons`, cache clear |
1919
| `rename.rs` | Rename / trash | `move_to_trash` (delegates to `write_operations::trash::move_to_trash_sync`), `check_rename_permission`, `check_rename_validity`, `rename_file`. `rename_file` calls `notify_mutation` after success to update the listing cache (both local and volume-aware paths). |

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,3 +639,37 @@ pub async fn connect_to_server(address: String, app_handle: tauri::AppHandle) ->
639639
pub fn remove_manual_server(server_id: String, app_handle: tauri::AppHandle) -> Result<(), String> {
640640
manual_servers::remove_manual_server(&server_id, &app_handle)
641641
}
642+
643+
/// Idempotently starts mDNS discovery if it isn't running. Triggered by the frontend the first
644+
/// time the user takes a network action (clicks "Network", opens "Connect to server…", or
645+
/// upgrades a mounted share to direct smb2). The first call here is what triggers macOS's
646+
/// "Cmdr wants to find devices on local networks" prompt — we defer to the latest reasonable
647+
/// moment so fresh installs don't see the prompt at launch.
648+
///
649+
/// Also kicks off the existing-SMB-mount upgrade pass: if macOS auto-remounted SMB shares
650+
/// at login, this is the first moment we can open direct smb2 connections to them (TCP to a
651+
/// private IP also gates on the Local Network permission).
652+
///
653+
/// Reloads manually-added servers in case discovery was previously stopped (toggle-off path)
654+
/// and `DISCOVERY_STATE` got cleared.
655+
#[tauri::command]
656+
pub fn ensure_network_discovery_started(app_handle: tauri::AppHandle) {
657+
crate::network::start_discovery(app_handle.clone());
658+
manual_servers::load_manual_servers(&app_handle);
659+
crate::file_system::upgrade_existing_smb_mounts();
660+
661+
#[cfg(feature = "smb-e2e")]
662+
crate::network::virtual_smb_hosts::setup_virtual_smb_hosts(&app_handle);
663+
}
664+
665+
/// Live-apply the `network.enabled` toggle. When `false`, stops mDNS and clears the discovered
666+
/// host list (frontend store empties via emitted `network-host-lost` events). When `true`, this
667+
/// is a no-op — the frontend triggers `ensure_network_discovery_started` separately when the
668+
/// user takes a network action.
669+
#[tauri::command]
670+
pub fn set_network_enabled(enabled: bool, app_handle: tauri::AppHandle) {
671+
if !enabled {
672+
crate::network::mdns_discovery::stop_discovery();
673+
crate::network::clear_discovered_hosts(&app_handle);
674+
}
675+
}

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

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -322,13 +322,14 @@ pub fn run() {
322322
#[cfg(any(target_os = "macos", target_os = "linux"))]
323323
file_system::volume::smb::set_app_handle(app.handle().clone());
324324

325-
// Start network host discovery (mDNS)
326-
#[cfg(any(target_os = "macos", target_os = "linux"))]
327-
network::start_discovery(app.handle().clone());
328-
329-
// Register virtual SMB hosts for E2E testing (after discovery start so they appear alongside real hosts)
330-
#[cfg(feature = "smb-e2e")]
331-
network::virtual_smb_hosts::setup_virtual_smb_hosts(app.handle());
325+
// Network discovery (mDNS) startup is deferred — see the post-`load_settings`
326+
// block below. Starting mDNS here would trigger macOS's "Cmdr wants to find devices
327+
// on local networks" prompt at app launch even on first install before the user has
328+
// shown any interest in networking. We only start at launch for returning users (who
329+
// already answered the OS prompt at least once, tracked via `network.firstTriggerDone`).
330+
//
331+
// For E2E builds, virtual SMB hosts also live alongside discovery — they're only
332+
// injected once discovery is up.
332333

333334
// Initialize volume broadcast (must be before watchers so they can emit)
334335
volume_broadcast::init(app.handle());
@@ -390,6 +391,23 @@ pub fn run() {
390391
// Initialize font metrics for default font (system font at 12px)
391392
font_metrics::init_font_metrics(app.handle(), "system-400-12");
392393

394+
// Start mDNS network discovery only for returning users who've already answered the
395+
// OS Local Network prompt at least once. Fresh installs stay quiet at launch — the
396+
// frontend calls `ensure_network_discovery_started` lazily on first user network
397+
// action (clicks "Network", opens "Connect to server…", upgrades a mounted share).
398+
// E2E builds always start so virtual hosts are populated before tests run.
399+
#[cfg(any(target_os = "macos", target_os = "linux"))]
400+
let should_start_network_at_launch = saved_settings.network_enabled.unwrap_or(true)
401+
&& (saved_settings.network_first_trigger_done.unwrap_or(false) || cfg!(feature = "smb-e2e"));
402+
403+
#[cfg(any(target_os = "macos", target_os = "linux"))]
404+
if should_start_network_at_launch {
405+
network::start_discovery(app.handle().clone());
406+
407+
#[cfg(feature = "smb-e2e")]
408+
network::virtual_smb_hosts::setup_virtual_smb_hosts(app.handle());
409+
}
410+
393411
// Apply direct SMB connection setting (default: true)
394412
file_system::set_direct_smb_enabled(saved_settings.direct_smb_connection.unwrap_or(true));
395413
file_system::git::set_virtual_portal_enabled(saved_settings.show_virtual_git_portal.unwrap_or(true));
@@ -401,9 +419,16 @@ pub fn run() {
401419
space_poller::set_threshold_mb(saved_settings.disk_space_change_threshold_mb.unwrap_or(1));
402420
space_poller::start();
403421

404-
// Upgrade existing SMB mounts to direct smb2 connections (background, non-blocking)
422+
// Upgrade existing SMB mounts to direct smb2 connections (background, non-blocking).
423+
// Gated on the same lazy-startup conditions as mDNS above — opening a TCP socket to
424+
// a private-IP SMB server triggers macOS's Local Network prompt independently, so
425+
// we must not run this on fresh installs either. Returning users already answered
426+
// the prompt; lazy users wait until they click Network or use the picker's "Connect
427+
// directly" indicator.
405428
#[cfg(any(target_os = "macos", target_os = "linux"))]
406-
file_system::upgrade_existing_smb_mounts();
429+
if should_start_network_at_launch {
430+
file_system::upgrade_existing_smb_mounts();
431+
}
407432

408433
// Check if there's an existing license (for menu text)
409434
let has_existing_license = licensing::get_license_info(app.handle()).is_some();
@@ -994,6 +1019,14 @@ pub fn run() {
9941019
commands::network::remove_manual_server,
9951020
#[cfg(any(target_os = "macos", target_os = "linux"))]
9961021
commands::network::disconnect_network_host,
1022+
#[cfg(any(target_os = "macos", target_os = "linux"))]
1023+
commands::network::ensure_network_discovery_started,
1024+
#[cfg(any(target_os = "macos", target_os = "linux"))]
1025+
commands::network::set_network_enabled,
1026+
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
1027+
stubs::network::ensure_network_discovery_started,
1028+
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
1029+
stubs::network::set_network_enabled,
9971030
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
9981031
stubs::network::list_network_hosts,
9991032
#[cfg(not(any(target_os = "macos", target_os = "linux")))]

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,31 @@ Discover, browse, and mount SMB network shares. Works on macOS and Linux.
3636

3737
## Key decisions
3838

39+
### Lazy mDNS startup gated on user toggle and first-trigger flag
40+
41+
`network::start_discovery()` no longer fires unconditionally in `lib.rs::setup`. Instead, two settings drive the
42+
lifecycle:
43+
44+
- **`network.enabled`** (boolean, default `true`) — top-level user toggle in `Settings > Network > SMB/Network shares`.
45+
When `false`, the picker shows "Network (disabled)", no mDNS daemon runs, and no proactive smb2 upgrades happen.
46+
- **`network.firstTriggerDone`** (boolean, default `false`, hidden) — tracks whether we've already performed a gated
47+
network action. Persisted across launches.
48+
49+
At startup, mDNS starts only if `network.enabled && (firstTriggerDone || smb-e2e feature)`. On a fresh install,
50+
`firstTriggerDone == false` so we stay quiet and the macOS "Cmdr wants to find devices on local networks" prompt
51+
doesn't fire at app launch.
52+
53+
The frontend calls `ensure_network_discovery_started` (idempotent) when the user takes a network action — clicking
54+
"Network" in the picker, opening "Connect to server…", or hitting the OS-mount → direct-smb2 upgrade indicator. That
55+
first call is what triggers the OS prompt. We also flip `firstTriggerDone = true` so subsequent launches start mDNS
56+
eagerly without surprising the user.
57+
58+
`set_network_enabled(false)` stops the daemon and clears `DISCOVERY_STATE.hosts`, emitting `network-host-lost` events
59+
so the frontend store empties. `set_network_enabled(true)` is a no-op — the user must take a network action to
60+
re-trigger discovery.
61+
62+
The E2E build feature (`smb-e2e`) bypasses both gates so virtual SMB hosts are populated before tests run.
63+
3964
### `NetFSMountURLAsync` for SMB mounting (not `mount_smbfs` CLI)
4065

4166
Non-blocking (UI stays responsive), credentials passed via secure API (not exposed in process list), native Keychain

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,26 @@ pub fn get_discovery_state_value() -> DiscoveryState {
123123
state.state
124124
}
125125

126+
/// Clears all discovered hosts and resets discovery state to `Idle`.
127+
/// Called when networking is disabled via the user toggle so the frontend store empties
128+
/// without waiting for `network-host-lost` events from a stopped daemon.
129+
pub fn clear_discovered_hosts<R: tauri::Runtime>(app_handle: &impl Emitter<R>) {
130+
let removed_ids: Vec<String> = {
131+
let mut state = get_discovery_state().lock_ignore_poison();
132+
let ids: Vec<String> = state.hosts.keys().cloned().collect();
133+
state.hosts.clear();
134+
state.state = DiscoveryState::Idle;
135+
ids
136+
};
137+
for id in removed_ids {
138+
let _ = app_handle.emit("network-host-lost", serde_json::json!({ "id": id }));
139+
}
140+
let _ = app_handle.emit(
141+
"network-discovery-state-changed",
142+
serde_json::json!({ "state": DiscoveryState::Idle }),
143+
);
144+
}
145+
126146
/// Called by the mDNS discovery module when a host is discovered.
127147
pub(crate) fn on_host_found<R: tauri::Runtime>(host: NetworkHost, app_handle: &impl Emitter<R>) {
128148
let mut state = get_discovery_state().lock_ignore_poison();

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ Settings {
3030
disk_space_change_threshold_mb: Option<u64>, // from "advanced.diskSpaceChangeThreshold"
3131
max_log_storage_mb: Option<u64>, // from "advanced.maxLogStorageMb"
3232
error_reports_enabled: Option<bool>, // from "updates.errorReports" (Flow B opt-in, default off)
33+
network_enabled: Option<bool>, // from "network.enabled" (default on; off renders picker as "Network (disabled)")
34+
network_first_trigger_done: Option<bool>, // from "network.firstTriggerDone" (hidden internal flag — true if we've ever triggered the macOS Local Network prompt)
3335
}
3436
```
3537

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ pub struct Settings {
6868
pub error_reports_enabled: Option<bool>,
6969
#[serde(alias = "fileExplorer.git.showVirtualGitPortal", default)]
7070
pub show_virtual_git_portal: Option<bool>,
71+
#[serde(alias = "network.enabled", default)]
72+
pub network_enabled: Option<bool>,
73+
#[serde(alias = "network.firstTriggerDone", default)]
74+
pub network_first_trigger_done: Option<bool>,
7175
}
7276

7377
fn default_show_hidden() -> bool {
@@ -93,6 +97,8 @@ impl Default for Settings {
9397
max_log_storage_mb: None,
9498
error_reports_enabled: None,
9599
show_virtual_git_portal: None,
100+
network_enabled: None,
101+
network_first_trigger_done: None,
96102
}
97103
}
98104
}
@@ -152,6 +158,8 @@ fn parse_settings(contents: &str) -> Result<Settings, serde_json::Error> {
152158
let show_virtual_git_portal = json
153159
.get("fileExplorer.git.showVirtualGitPortal")
154160
.and_then(|v| v.as_bool());
161+
let network_enabled = json.get("network.enabled").and_then(|v| v.as_bool());
162+
let network_first_trigger_done = json.get("network.firstTriggerDone").and_then(|v| v.as_bool());
155163

156164
Ok(Settings {
157165
show_hidden_files,
@@ -170,6 +178,8 @@ fn parse_settings(contents: &str) -> Result<Settings, serde_json::Error> {
170178
max_log_storage_mb,
171179
error_reports_enabled,
172180
show_virtual_git_portal,
181+
network_enabled,
182+
network_first_trigger_done,
173183
})
174184
}
175185

0 commit comments

Comments
 (0)