Skip to content

Commit 40cc1a9

Browse files
committed
Linux: distro-specific install hint for smbclient
- Add LinuxDistro detection (reads /etc/os-release once, OnceLock-cached) with DistroFamily enum and install_command(package) for apt/dnf/pacman/zypper - Add MissingDependency variant to ShareListError with optional installCommand - Refactor ShareListError from adjacently tagged to internally tagged serde (same JSON wire format, enables multi-field variants) - Show copyable install command in ShareBrowser when smbclient is missing - Extract and reuse command-box pattern from PtpcameradDialog (mono font, Copy button)
1 parent 4bbcbb0 commit 40cc1a9

17 files changed

Lines changed: 452 additions & 149 deletions

apps/desktop/coverage-allowlist.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"files": {
44
"accent-color.ts": { "reason": "Depends on Tauri invoke and listen APIs" },
55
"ui/AlertDialog.svelte": { "reason": "Simple UI modal for informational messages" },
6+
"ui/CommandBox.svelte": { "reason": "Simple UI component, clipboard + 2s timeout" },
67
"ui/dialog-registry.ts": { "reason": "Pure constant and type definition, no logic to test" },
78
"ui/ModalDialog.svelte": { "reason": "Shared modal dialog wrapper, depends on DOM APIs and Tauri commands" },
89
"updates/UpdateToastContent.svelte": { "reason": "UI component, toast content for update prompt" },

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ mod font_metrics;
7979
pub mod icons;
8080
mod indexing;
8181
pub mod licensing;
82+
#[cfg(target_os = "linux")]
83+
pub(crate) mod linux_distro;
8284
#[cfg(target_os = "macos")]
8385
mod macos_icons;
8486
mod mcp;
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
//! Linux distro detection via `/etc/os-release`.
2+
//!
3+
//! Provides distro family classification and package manager install commands.
4+
//! Detected once and cached for the lifetime of the process.
5+
6+
use std::sync::OnceLock;
7+
8+
/// Parsed Linux distribution info from `/etc/os-release`.
9+
#[derive(Debug)]
10+
pub struct LinuxDistro {
11+
pub id: String,
12+
pub id_like: Vec<String>,
13+
pub pretty_name: String,
14+
}
15+
16+
/// High-level distro family, determines the package manager.
17+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18+
pub enum DistroFamily {
19+
Debian,
20+
Fedora,
21+
Arch,
22+
Suse,
23+
Unknown,
24+
}
25+
26+
static DETECTED: OnceLock<Option<LinuxDistro>> = OnceLock::new();
27+
28+
impl LinuxDistro {
29+
/// Returns the detected distro, reading `/etc/os-release` once.
30+
/// Returns `None` if the file is missing or unparseable.
31+
#[cfg(target_os = "linux")]
32+
pub fn detect() -> Option<&'static Self> {
33+
DETECTED
34+
.get_or_init(|| {
35+
let content = std::fs::read_to_string("/etc/os-release").ok()?;
36+
Self::parse(&content)
37+
})
38+
.as_ref()
39+
}
40+
41+
/// Parses the content of an os-release file.
42+
fn parse(content: &str) -> Option<Self> {
43+
let mut id = String::new();
44+
let mut id_like = String::new();
45+
let mut pretty_name = String::new();
46+
47+
for line in content.lines() {
48+
if let Some(val) = line.strip_prefix("ID=") {
49+
id = val.trim_matches('"').to_lowercase();
50+
} else if let Some(val) = line.strip_prefix("ID_LIKE=") {
51+
id_like = val.trim_matches('"').to_lowercase();
52+
} else if let Some(val) = line.strip_prefix("PRETTY_NAME=") {
53+
pretty_name = val.trim_matches('"').to_string();
54+
}
55+
}
56+
57+
if id.is_empty() {
58+
return None;
59+
}
60+
61+
Some(Self {
62+
id,
63+
id_like: id_like.split_whitespace().map(String::from).collect(),
64+
pretty_name,
65+
})
66+
}
67+
68+
/// Classifies this distro into a package-manager family.
69+
pub fn family(&self) -> DistroFamily {
70+
let tokens: Vec<&str> = std::iter::once(self.id.as_str())
71+
.chain(self.id_like.iter().map(String::as_str))
72+
.collect();
73+
74+
for t in &tokens {
75+
if *t == "debian" || *t == "ubuntu" {
76+
return DistroFamily::Debian;
77+
}
78+
if *t == "fedora" || *t == "rhel" || *t == "centos" {
79+
return DistroFamily::Fedora;
80+
}
81+
if *t == "arch" {
82+
return DistroFamily::Arch;
83+
}
84+
if *t == "suse" || *t == "opensuse" || t.starts_with("opensuse") {
85+
return DistroFamily::Suse;
86+
}
87+
}
88+
89+
DistroFamily::Unknown
90+
}
91+
92+
/// Returns the distro-specific install command for the given package, or `None` if unknown.
93+
pub fn install_command(&self, package: &str) -> Option<String> {
94+
match self.family() {
95+
DistroFamily::Debian => Some(format!("sudo apt install {}", package)),
96+
DistroFamily::Fedora => Some(format!("sudo dnf install {}", package)),
97+
DistroFamily::Arch => Some(format!("sudo pacman -S {}", package)),
98+
DistroFamily::Suse => Some(format!("sudo zypper install {}", package)),
99+
DistroFamily::Unknown => None,
100+
}
101+
}
102+
}
103+
104+
#[cfg(test)]
105+
mod tests {
106+
use super::*;
107+
108+
fn distro(content: &str) -> Option<LinuxDistro> {
109+
LinuxDistro::parse(content)
110+
}
111+
112+
#[test]
113+
fn test_ubuntu() {
114+
let d = distro("ID=ubuntu\nID_LIKE=debian\nPRETTY_NAME=\"Ubuntu 22.04 LTS\"\n").unwrap();
115+
assert_eq!(d.id, "ubuntu");
116+
assert_eq!(d.id_like, vec!["debian"]);
117+
assert_eq!(d.pretty_name, "Ubuntu 22.04 LTS");
118+
assert_eq!(d.family(), DistroFamily::Debian);
119+
assert_eq!(d.install_command("smbclient").unwrap(), "sudo apt install smbclient");
120+
}
121+
122+
#[test]
123+
fn test_fedora() {
124+
let d = distro("ID=fedora\nVERSION_ID=39\nPRETTY_NAME=\"Fedora Linux 39\"\n").unwrap();
125+
assert_eq!(d.family(), DistroFamily::Fedora);
126+
assert_eq!(
127+
d.install_command("samba-client").unwrap(),
128+
"sudo dnf install samba-client"
129+
);
130+
}
131+
132+
#[test]
133+
fn test_arch() {
134+
let d = distro("ID=arch\nBUILD_ID=rolling\nPRETTY_NAME=\"Arch Linux\"\n").unwrap();
135+
assert_eq!(d.family(), DistroFamily::Arch);
136+
assert_eq!(d.install_command("smbclient").unwrap(), "sudo pacman -S smbclient");
137+
}
138+
139+
#[test]
140+
fn test_opensuse() {
141+
let d = distro("ID=opensuse-tumbleweed\nID_LIKE=\"suse\"\nPRETTY_NAME=\"openSUSE Tumbleweed\"\n").unwrap();
142+
assert_eq!(d.family(), DistroFamily::Suse);
143+
assert_eq!(
144+
d.install_command("samba-client").unwrap(),
145+
"sudo zypper install samba-client"
146+
);
147+
}
148+
149+
#[test]
150+
fn test_rhel_derivative() {
151+
let d = distro("ID=rocky\nID_LIKE=\"rhel centos fedora\"\nPRETTY_NAME=\"Rocky Linux 9\"\n").unwrap();
152+
assert_eq!(d.family(), DistroFamily::Fedora);
153+
assert_eq!(d.id_like, vec!["rhel", "centos", "fedora"]);
154+
}
155+
156+
#[test]
157+
fn test_linux_mint() {
158+
let d = distro("ID=linuxmint\nID_LIKE=ubuntu\nPRETTY_NAME=\"Linux Mint 21\"\n").unwrap();
159+
assert_eq!(d.family(), DistroFamily::Debian);
160+
}
161+
162+
#[test]
163+
fn test_unknown_distro() {
164+
let d = distro("ID=nixos\nPRETTY_NAME=\"NixOS 23.11\"\n").unwrap();
165+
assert_eq!(d.family(), DistroFamily::Unknown);
166+
assert!(d.install_command("smbclient").is_none());
167+
}
168+
169+
#[test]
170+
fn test_empty_content() {
171+
assert!(distro("").is_none());
172+
}
173+
174+
#[test]
175+
fn test_quoted_id() {
176+
let d = distro("ID=\"ubuntu\"\nPRETTY_NAME=\"Ubuntu\"\n").unwrap();
177+
assert_eq!(d.id, "ubuntu");
178+
assert_eq!(d.family(), DistroFamily::Debian);
179+
}
180+
181+
#[test]
182+
fn test_different_packages() {
183+
let d = distro("ID=ubuntu\nID_LIKE=debian\n").unwrap();
184+
assert_eq!(d.install_command("gvfs-smb").unwrap(), "sudo apt install gvfs-smb");
185+
assert_eq!(d.install_command("gio").unwrap(), "sudo apt install gio");
186+
}
187+
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ Discover, browse, and mount SMB network shares. Works on macOS and Linux.
1111
- `smb_cache.rs` — 30-second in-memory cache for share lists, keyed by server address
1212
- `smb_smbutil.rs``smbutil view -G` fallback for older Samba/NAS servers (macOS); on Linux delegates to `smb_smbclient`
1313
- `smb_smbclient.rs``smbclient -L` fallback for Linux (requires `samba-client` package)
14-
- `smb_types.rs` — Shared types (`SmbShare`, `AuthMode`, `SmbError`, etc.)
14+
- `linux_distro.rs` — Thin wrapper calling `crate::linux_distro::LinuxDistro` for smbclient install hints; `cfg(target_os = "linux")` gated
15+
- `smb_types.rs` — Shared types (`ShareInfo`, `AuthMode`, `ShareListError`, etc.)
1516
- `smb_util.rs` — Helpers: hostname derivation, IP resolution, account-name normalization
1617
- **Mounting** (platform-specific via `#[path]` in `mod.rs`):
1718
- `mount.rs` — macOS `NetFSMountURLSync` for native `/Volumes/` mounts
@@ -48,7 +49,7 @@ smb-rs doesn't resolve `.local` hostnames reliably (std lib DNS doesn't handle m
4849

4950
`smb` crate fails on older Samba servers (for example, Raspberry Pi) with RPC incompatibility. Classify error as `ProtocolError`, then try a platform-specific CLI fallback:
5051
- **macOS:** `smbutil view -G` (built-in).
51-
- **Linux:** `smbclient -L` (from `samba-client` package). If `smbclient` is not installed, returns a helpful error message. The `smb_smbutil.rs` Linux stubs delegate to `smb_smbclient.rs`.
52+
- **Linux:** `smbclient -L` (from `samba-client` package). If `smbclient` is not installed, returns a `MissingDependency` error with a distro-specific install command (detected via `/etc/os-release`). The `smb_smbutil.rs` Linux stubs delegate to `smb_smbclient.rs`.
5253
- **Other platforms:** stubs return `ProtocolError`.
5354

5455
### No persistent connection pool
@@ -75,3 +76,4 @@ On Linux, `keychain_linux.rs` tries Secret Service (GNOME Keyring / KDE Wallet)
7576
- **mDNS service type must include `.local.`**: `mdns-sd` requires full form `"_smb._tcp.local."` (trailing dot). Without it, browse() fails silently.
7677
- **Account name is lowercase**: `make_account_name` lowercases server name for consistency. Prevents duplicate entries for "SERVER" vs "server".
7778
- **Linux `gio mount` requires GVFS**: The `gvfs-smb` package must be installed. Standard on Ubuntu/Fedora GNOME desktops. KDE desktops may need it explicitly.
79+
- **`ShareListError` uses internally tagged serde format** (`#[serde(tag = "type")]`) with struct variants. This keeps a flat JSON shape (`{ "type": "protocol_error", "message": "..." }`). The `MissingDependency` variant adds an optional `installCommand` field. When adding new variants, use struct syntax (not tuple).
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
//! Smbclient install hint, delegating to the crate-level `LinuxDistro`.
2+
3+
/// Returns the distro-specific install command for smbclient, or `None` if unknown.
4+
#[cfg(target_os = "linux")]
5+
pub fn smbclient_install_command() -> Option<String> {
6+
crate::linux_distro::LinuxDistro::detect()?.install_command("smbclient")
7+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ pub mod mount;
2929
pub mod smb_client;
3030

3131
// SMB submodules - these are implementation details of smb_client
32+
#[cfg(target_os = "linux")]
33+
mod linux_distro;
3234
mod smb_cache;
3335
mod smb_connection;
3436
#[cfg(target_os = "linux")]

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,13 @@ async fn list_shares_uncached(
8484
// Try smb-rs first
8585
match list_shares_smb_rs(hostname, ip_address, port, credentials, timeout).await {
8686
Ok(result) => Ok(result),
87-
Err(ShareListError::ProtocolError(ref msg)) => {
87+
Err(ShareListError::ProtocolError { ref message }) => {
8888
// Protocol error (likely RPC incompatibility with Samba)
8989
// Try smbutil fallback on macOS
90-
debug!("smb-rs failed with protocol error: {}, trying smbutil fallback", msg);
90+
debug!(
91+
"smb-rs failed with protocol error: {}, trying smbutil fallback",
92+
message
93+
);
9194
list_shares_smbutil(hostname, ip_address, port).await
9295
}
9396
Err(e) => Err(e),
@@ -187,9 +190,9 @@ async fn list_shares_smb_rs(
187190
}
188191
Err(e) => {
189192
debug!("smbutil with Keychain failed: {:?}, requiring manual login", e);
190-
Err(ShareListError::AuthRequired(
191-
"This server requires authentication to list shares".to_string(),
192-
))
193+
Err(ShareListError::AuthRequired {
194+
message: "This server requires authentication to list shares".to_string(),
195+
})
193196
}
194197
};
195198
}

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

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,24 @@ pub async fn run_smbclient_list(
3838
cmd.output()
3939
})
4040
.await
41-
.map_err(|e| ShareListError::ProtocolError(format!("Failed to spawn smbclient task: {}", e)))?
41+
.map_err(|e| ShareListError::ProtocolError {
42+
message: format!("Failed to spawn smbclient task: {}", e),
43+
})?
4244
.map_err(|e| {
4345
if e.kind() == std::io::ErrorKind::NotFound {
44-
ShareListError::ProtocolError(
45-
"smbclient not found. Install the samba-client package to connect to this server.".to_string(),
46-
)
46+
#[cfg(target_os = "linux")]
47+
let install_command = super::linux_distro::smbclient_install_command();
48+
#[cfg(not(target_os = "linux"))]
49+
let install_command: Option<String> = None;
50+
51+
ShareListError::MissingDependency {
52+
message: "smbclient is not installed. It's needed to connect to this server.".to_string(),
53+
install_command,
54+
}
4755
} else {
48-
ShareListError::ProtocolError(format!("Failed to run smbclient: {}", e))
56+
ShareListError::ProtocolError {
57+
message: format!("Failed to run smbclient: {}", e),
58+
}
4959
}
5060
})?;
5161

@@ -75,24 +85,34 @@ fn classify_smbclient_error(stdout: &str, stderr: &str, host: &str, has_creds: b
7585
|| combined.contains("NT_STATUS_WRONG_PASSWORD")
7686
{
7787
return if has_creds {
78-
ShareListError::AuthFailed("Invalid username or password".to_string())
88+
ShareListError::AuthFailed {
89+
message: "Invalid username or password".to_string(),
90+
}
7991
} else {
80-
ShareListError::AuthRequired("This server requires authentication".to_string())
92+
ShareListError::AuthRequired {
93+
message: "This server requires authentication".to_string(),
94+
}
8195
};
8296
}
8397

8498
if combined.contains("NT_STATUS_HOST_UNREACHABLE")
8599
|| combined.contains("NT_STATUS_CONNECTION_REFUSED")
86100
|| (combined.contains("Connection to") && combined.contains("failed"))
87101
{
88-
return ShareListError::HostUnreachable(format!("Cannot reach {}", host));
102+
return ShareListError::HostUnreachable {
103+
message: format!("Cannot reach {}", host),
104+
};
89105
}
90106

91107
if combined.contains("NT_STATUS_IO_TIMEOUT") {
92-
return ShareListError::Timeout(format!("Connection to {} timed out", host));
108+
return ShareListError::Timeout {
109+
message: format!("Connection to {} timed out", host),
110+
};
93111
}
94112

95-
ShareListError::ProtocolError(format!("smbclient failed: {}", stderr.trim()))
113+
ShareListError::ProtocolError {
114+
message: format!("smbclient failed: {}", stderr.trim()),
115+
}
96116
}
97117

98118
/// Parses `smbclient -L` output to extract share information.
@@ -237,21 +257,21 @@ mod tests {
237257
#[test]
238258
fn test_classify_auth_errors() {
239259
let err = classify_smbclient_error("", "NT_STATUS_ACCESS_DENIED", "host", false);
240-
assert!(matches!(err, ShareListError::AuthRequired(_)));
260+
assert!(matches!(err, ShareListError::AuthRequired { .. }));
241261

242262
let err = classify_smbclient_error("", "NT_STATUS_LOGON_FAILURE", "host", true);
243-
assert!(matches!(err, ShareListError::AuthFailed(_)));
263+
assert!(matches!(err, ShareListError::AuthFailed { .. }));
244264
}
245265

246266
#[test]
247267
fn test_classify_network_errors() {
248268
let err = classify_smbclient_error("", "NT_STATUS_HOST_UNREACHABLE", "host", false);
249-
assert!(matches!(err, ShareListError::HostUnreachable(_)));
269+
assert!(matches!(err, ShareListError::HostUnreachable { .. }));
250270

251271
let err = classify_smbclient_error("", "Connection to host failed", "host", false);
252-
assert!(matches!(err, ShareListError::HostUnreachable(_)));
272+
assert!(matches!(err, ShareListError::HostUnreachable { .. }));
253273

254274
let err = classify_smbclient_error("", "NT_STATUS_IO_TIMEOUT", "host", false);
255-
assert!(matches!(err, ShareListError::Timeout(_)));
275+
assert!(matches!(err, ShareListError::Timeout { .. }));
256276
}
257277
}

0 commit comments

Comments
 (0)