Skip to content

Commit 00c5f18

Browse files
committed
Bugfix: Fix Linux compilation for SMB code
- Move `SmbConnectionState`, `path_to_id`, `DEFAULT_VOLUME_ID` from macOS-only `volumes` to shared `file_system::volume` module - Move `unicode-normalization` from macOS-only deps to cross-platform (needed for SMB path normalization on Linux too) - Add `get_smb_mount_info` to `volumes_linux` (reads `/proc/mounts` for CIFS entries) - Add stub `unmount_smb_shares_from_host` to `mount_linux.rs` - Use cfg-switched imports for `get_smb_mount_info` in `commands/network.rs` and `file_system/mod.rs` - oxfmt reformatting in `CHANGELOG.md`
1 parent ac5ec4d commit 00c5f18

10 files changed

Lines changed: 138 additions & 61 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,7 @@ The format is based on [keep a changelog](https://keepachangelog.com/en/1.1.0/),
4343
### Fixed
4444

4545
- Copy progress: per-file counter now increments per individual file during directory copies, not per top-level item.
46-
Stale scan events from previous operations are rejected
47-
([d10d9cc](https://github.com/vdavid/cmdr/commit/d10d9cc))
46+
Stale scan events from previous operations are rejected ([d10d9cc](https://github.com/vdavid/cmdr/commit/d10d9cc))
4847
- SMB faster deletes — skip stat round-trip, halves round-trips for bulk deletes. Rollback now includes the in-progress
4948
item ([0e7f072](https://github.com/vdavid/cmdr/commit/0e7f072))
5049
- Copy cancellation — directory tree copies now check cancellation between each file instead of looping without checking
@@ -76,13 +75,13 @@ The format is based on [keep a changelog](https://keepachangelog.com/en/1.1.0/),
7675

7776
### Non-app
7877

79-
- Replace `smb`/`smb-rpc` crates with custom `smb2` crate — cleaner API, proper error types, no NDR debug-format
80-
parsing hacks ([2d7904f](https://github.com/vdavid/cmdr/commit/2d7904f))
78+
- Replace `smb`/`smb-rpc` crates with custom `smb2` crate — cleaner API, proper error types, no NDR debug-format parsing
79+
hacks ([2d7904f](https://github.com/vdavid/cmdr/commit/2d7904f))
8180
- CI: upgrade actions to Node.js 24 ([e5820bb](https://github.com/vdavid/cmdr/commit/e5820bb))
8281
- Testing: fix multiple E2E flakes — MTP cache staleness, theme race, hidden files toggle, Docker shell quoting
8382
([5009971](https://github.com/vdavid/cmdr/commit/5009971), [52faf43](https://github.com/vdavid/cmdr/commit/52faf43))
84-
- Suppress noisy `tao` and indexing dev logs
85-
([21b041b](https://github.com/vdavid/cmdr/commit/21b041b), [9bf0e00](https://github.com/vdavid/cmdr/commit/9bf0e00))
83+
- Suppress noisy `tao` and indexing dev logs ([21b041b](https://github.com/vdavid/cmdr/commit/21b041b),
84+
[9bf0e00](https://github.com/vdavid/cmdr/commit/9bf0e00))
8685

8786
## [0.10.0] - 2026-04-08
8887

apps/desktop/src-tauri/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,10 @@ bytes = "1"
117117
mdns-sd = { version = "0.18", features = ["logging"] }
118118
# SMB2/3 protocol client for share enumeration (pure Rust, pipelined I/O)
119119
smb2 = { git = "https://github.com/vdavid/smb2", branch = "main" }
120+
# NFD normalization for APFS collation and SMB path normalization
121+
unicode-normalization = "0.1"
120122

121123
[target.'cfg(target_os = "macos")'.dependencies]
122-
# Drive indexing: NFD normalization for APFS case-insensitive collation
123-
unicode-normalization = "0.1"
124124
file_icon_provider = "1.0.0"
125125
core-foundation = "0.10.1"
126126
core-services = "1.0.0"

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ pub(crate) async fn register_smb_volume(
345345

346346
match connect_smb_volume(share, mount_path, server, share, username, password, port).await {
347347
Ok(volume) => {
348-
let volume_id = crate::volumes::path_to_id(mount_path);
348+
let volume_id = crate::file_system::volume::path_to_id(mount_path);
349349
// Use register (overwrite) so SmbVolume always wins over any
350350
// LocalPosixVolume the watcher may have registered in the race window.
351351
get_volume_manager().register(&volume_id, Arc::new(volume));
@@ -372,7 +372,10 @@ pub(crate) async fn register_smb_volume(
372372
#[tauri::command]
373373
pub async fn upgrade_to_smb_volume(volume_id: String) -> Result<String, String> {
374374
use crate::file_system::get_volume_manager;
375+
#[cfg(target_os = "macos")]
375376
use crate::volumes::get_smb_mount_info;
377+
#[cfg(target_os = "linux")]
378+
use crate::volumes_linux::get_smb_mount_info;
376379

377380
let manager = get_volume_manager();
378381

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,11 @@ pub fn get_volume_manager() -> &'static VolumeManager {
162162
/// a parallel smb2 session for each. Non-blocking: failures are logged and skipped.
163163
#[cfg(any(target_os = "macos", target_os = "linux"))]
164164
pub fn upgrade_existing_smb_mounts() {
165+
#[cfg(target_os = "macos")]
166+
use crate::volumes::get_smb_mount_info;
167+
#[cfg(target_os = "linux")]
168+
use crate::volumes_linux::get_smb_mount_info;
169+
165170
if !is_direct_smb_enabled() {
166171
log::debug!("Direct SMB connections disabled, skipping startup upgrade");
167172
return;
@@ -181,7 +186,7 @@ pub fn upgrade_existing_smb_mounts() {
181186
}
182187
let path = vol.root().to_string_lossy().to_string();
183188
// Check if it's an SMB mount
184-
let info = crate::volumes::get_smb_mount_info(&path)?;
189+
let info = get_smb_mount_info(&path)?;
185190
let _ = info; // We just need to know it's SMB
186191
Some((id, path))
187192
})
@@ -206,7 +211,7 @@ pub fn upgrade_existing_smb_mounts() {
206211

207212
let mut any_upgraded = false;
208213
for (_volume_id, mount_path) in volumes_to_upgrade {
209-
let info = match crate::volumes::get_smb_mount_info(&mount_path) {
214+
let info = match get_smb_mount_info(&mount_path) {
210215
Some(info) => info,
211216
None => continue,
212217
};

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,33 @@ use std::path::Path;
1515
use std::sync::atomic::AtomicBool;
1616
use tokio::sync::mpsc;
1717

18+
/// SMB connection state for the frontend indicator.
19+
///
20+
/// `Direct` means Cmdr's smb2 session is active (fast path).
21+
/// `OsMount` means only the OS mount is alive (fallback path).
22+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23+
#[serde(rename_all = "snake_case")]
24+
pub enum SmbConnectionState {
25+
/// smb2 session active — fast path (green indicator).
26+
Direct,
27+
/// Using OS mount only — slower fallback (yellow indicator).
28+
OsMount,
29+
}
30+
31+
/// Default volume ID for the root filesystem.
32+
pub const DEFAULT_VOLUME_ID: &str = "root";
33+
34+
/// Convert a mount path to a safe ID string.
35+
pub(crate) fn path_to_id(path: &str) -> String {
36+
if path == "/" {
37+
return DEFAULT_VOLUME_ID.to_string();
38+
}
39+
path.chars()
40+
.filter(|c| c.is_alphanumeric() || *c == '-')
41+
.collect::<String>()
42+
.to_lowercase()
43+
}
44+
1845
/// Describes what mutation occurred, so `notify_mutation` can update the listing cache.
1946
pub enum MutationEvent {
2047
/// A file or directory was created. Contains the entry name.
@@ -330,7 +357,7 @@ pub trait Volume: Send + Sync {
330357
///
331358
/// Only `SmbVolume` returns `Some`. Used by the frontend to show a connection
332359
/// quality indicator (green = direct smb2, yellow = OS mount fallback).
333-
fn smb_connection_state(&self) -> Option<crate::volumes::SmbConnectionState> {
360+
fn smb_connection_state(&self) -> Option<SmbConnectionState> {
334361
None
335362
}
336363

apps/desktop/src-tauri/src/file_system/volume/smb.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
//! but all Cmdr file operations go through smb2's pipelined I/O for better
66
//! performance and fail-fast behavior.
77
8-
use super::{CopyScanResult, ScanConflict, SourceItemInfo, SpaceInfo, Volume, VolumeError};
8+
use super::{
9+
CopyScanResult, ScanConflict, SmbConnectionState, SourceItemInfo, SpaceInfo, Volume, VolumeError, path_to_id,
10+
};
911
use crate::file_system::listing::FileEntry;
1012
use log::{debug, info, warn};
1113
use smb2::client::tree::Tree;
@@ -1028,10 +1030,10 @@ impl Volume for SmbVolume {
10281030
Ok(conflicts)
10291031
}
10301032

1031-
fn smb_connection_state(&self) -> Option<crate::volumes::SmbConnectionState> {
1033+
fn smb_connection_state(&self) -> Option<SmbConnectionState> {
10321034
match self.connection_state() {
1033-
ConnectionState::Direct => Some(crate::volumes::SmbConnectionState::Direct),
1034-
ConnectionState::OsMount => Some(crate::volumes::SmbConnectionState::OsMount),
1035+
ConnectionState::Direct => Some(SmbConnectionState::Direct),
1036+
ConnectionState::OsMount => Some(SmbConnectionState::OsMount),
10351037
ConnectionState::Disconnected => None,
10361038
}
10371039
}
@@ -1481,7 +1483,7 @@ pub async fn connect_smb_volume(
14811483
let mut client = SmbClient::connect(config).await?;
14821484
let tree = client.connect_share(share_name).await?;
14831485
let runtime_handle = tokio::runtime::Handle::current();
1484-
let volume_id = crate::volumes::path_to_id(mount_path);
1486+
let volume_id = path_to_id(mount_path);
14851487

14861488
let vol = SmbVolume::new(
14871489
name,

apps/desktop/src-tauri/src/mtp/connection/file_ops.rs

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ impl MtpConnectionManager {
4242
///
4343
/// The `on_progress` callback receives `(bytes_done, bytes_total)` and returns `ControlFlow::Break(())`
4444
/// to cancel the transfer. On cancellation, the partial file is removed.
45-
#[allow(clippy::too_many_arguments, reason = "mirrors download_file with added on_progress callback")]
45+
#[allow(
46+
clippy::too_many_arguments,
47+
reason = "mirrors download_file with added on_progress callback"
48+
)]
4649
pub async fn download_file_with_progress(
4750
&self,
4851
device_id: &str,
@@ -134,7 +137,10 @@ impl MtpConnectionManager {
134137
message: format!("Failed to create local file: {}", e),
135138
})?;
136139

137-
// Write chunks to file (must complete before releasing device lock)
140+
// Write chunks to file (must complete before releasing device lock).
141+
// MTP USB transfers must be fully consumed — breaking mid-stream corrupts
142+
// the session and makes the device unresponsive for subsequent operations.
143+
// On cancel: stop writing to disk, but keep draining the USB stream.
138144
let mut bytes_written = 0u64;
139145
let mut cancelled = false;
140146
while let Some(chunk_result) = download.next_chunk().await {
@@ -143,19 +149,23 @@ impl MtpConnectionManager {
143149
message: format!("Download error: {}", e),
144150
})?;
145151

146-
file.write_all(&chunk).await.map_err(|e| MtpConnectionError::Other {
147-
device_id: device_id.to_string(),
148-
message: format!("Failed to write local file: {}", e),
149-
})?;
152+
if !cancelled {
153+
file.write_all(&chunk).await.map_err(|e| MtpConnectionError::Other {
154+
device_id: device_id.to_string(),
155+
message: format!("Failed to write local file: {}", e),
156+
})?;
150157

151-
bytes_written += chunk.len() as u64;
158+
bytes_written += chunk.len() as u64;
152159

153-
// Report progress and check for cancellation
154-
if let Some(ref cb) = on_progress
155-
&& cb(bytes_written, total_size).is_break() {
160+
// Report progress and check for cancellation
161+
if let Some(ref cb) = on_progress
162+
&& cb(bytes_written, total_size).is_break()
163+
{
156164
cancelled = true;
157-
break;
165+
// Don't break — keep draining the USB stream to keep the session healthy
158166
}
167+
}
168+
// else: cancelled, just drain remaining chunks without writing
159169
}
160170

161171
// Release device lock after download completes

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,14 @@ pub async fn mount_share(
248248
}
249249
}
250250

251+
/// Unmounts all SMB shares from a given host.
252+
///
253+
/// Linux GVFS unmount via `gio mount -u` is not wired up yet — returns empty.
254+
pub fn unmount_smb_shares_from_host(_server_name: &str, _server_ip: Option<&str>) -> Vec<String> {
255+
log::debug!("unmount_smb_shares_from_host not yet implemented on Linux");
256+
Vec::new()
257+
}
258+
251259
#[cfg(test)]
252260
mod tests {
253261
use super::*;

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

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,7 @@ use serde::{Deserialize, Serialize};
1313
use std::collections::HashSet;
1414
use std::path::Path;
1515

16-
/// SMB connection state for the frontend indicator.
17-
///
18-
/// Only set for volumes backed by an `SmbVolume` in the `VolumeManager`.
19-
/// `Direct` means Cmdr's smb2 session is active (fast path).
20-
/// `OsMount` means only the OS mount is alive (fallback path).
21-
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22-
#[serde(rename_all = "snake_case")]
23-
pub enum SmbConnectionState {
24-
/// smb2 session active — fast path (green indicator).
25-
Direct,
26-
/// Using OS mount only — slower fallback (yellow indicator).
27-
OsMount,
28-
}
16+
pub use crate::file_system::volume::SmbConnectionState;
2917

3018
/// Category of a location item.
3119
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -655,16 +643,7 @@ fn get_volume_name(url: &objc2_foundation::NSURL, path: &str) -> String {
655643
}
656644
}
657645

658-
/// Convert path to a safe ID.
659-
pub(crate) fn path_to_id(path: &str) -> String {
660-
if path == "/" {
661-
return DEFAULT_VOLUME_ID.to_string();
662-
}
663-
path.chars()
664-
.filter(|c| c.is_alphanumeric() || *c == '-')
665-
.collect::<String>()
666-
.to_lowercase()
667-
}
646+
pub(crate) use crate::file_system::volume::path_to_id;
668647

669648
/// Get icon for a path as base64-encoded WebP.
670649
fn get_icon_for_path(path: &str) -> Option<String> {

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

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,64 @@
1010
1111
pub mod watcher;
1212

13+
pub use crate::file_system::volume::SmbConnectionState;
14+
1315
use crate::file_system::linux_mounts::{self, MountEntry};
1416
use serde::{Deserialize, Serialize};
1517
use std::collections::HashSet;
1618
use std::path::Path;
1719

20+
/// Information about an SMB mount extracted from `/proc/mounts`.
21+
#[derive(Debug, Clone)]
22+
pub struct SmbMountInfo {
23+
/// Server hostname or IP (for example, "192.168.1.111").
24+
pub server: String,
25+
/// Share name (for example, "naspi").
26+
pub share: String,
27+
/// Username if present in the mount source (for example, "david").
28+
pub username: Option<String>,
29+
}
30+
31+
/// Extracts SMB server, share, and username from a mount path via `/proc/mounts`.
32+
///
33+
/// On Linux, CIFS mounts have a device field like:
34+
/// - `//192.168.1.111/share` (no credentials in device)
35+
/// - `//user@192.168.1.111/share` (some configurations)
36+
///
37+
/// Returns `None` if the path is not a CIFS mount or parsing fails.
38+
pub fn get_smb_mount_info(mount_path: &str) -> Option<SmbMountInfo> {
39+
let mounts = linux_mounts::parse_proc_mounts();
40+
let entry = mounts
41+
.iter()
42+
.filter(|e| e.fstype == "cifs")
43+
.find(|e| e.mountpoint == mount_path)?;
44+
parse_smb_mount_source(&entry.device)
45+
}
46+
47+
/// Parses an SMB mount source string like `//user@host/share` or `//host/share`.
48+
fn parse_smb_mount_source(source: &str) -> Option<SmbMountInfo> {
49+
let rest = source.strip_prefix("//")?;
50+
let (server_part, share) = rest.split_once('/')?;
51+
if share.is_empty() {
52+
return None;
53+
}
54+
55+
let (username, server) = if let Some((user, host)) = server_part.split_once('@') {
56+
(Some(user.to_string()), host.to_string())
57+
} else {
58+
(None, server_part.to_string())
59+
};
60+
61+
// Strip port if present (for example, "192.168.1.111:9445")
62+
let server = server.split(':').next().unwrap_or(&server).to_string();
63+
64+
Some(SmbMountInfo {
65+
server,
66+
share: share.to_string(),
67+
username,
68+
})
69+
}
70+
1871
/// Category of a location item.
1972
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2073
#[serde(rename_all = "snake_case")]
@@ -454,16 +507,7 @@ pub fn find_volume_for_path(path: &str) -> Option<String> {
454507
.map(|loc| loc.id.clone())
455508
}
456509

457-
/// Convert a mount path to a safe ID string.
458-
pub(crate) fn path_to_id(path: &str) -> String {
459-
if path == "/" {
460-
return DEFAULT_VOLUME_ID.to_string();
461-
}
462-
path.chars()
463-
.filter(|c| c.is_alphanumeric() || *c == '-')
464-
.collect::<String>()
465-
.to_lowercase()
466-
}
510+
pub(crate) use crate::file_system::volume::path_to_id;
467511

468512
/// Extract a display name from a mount path.
469513
fn mount_display_name(mountpoint: &str) -> String {

0 commit comments

Comments
 (0)