Skip to content

Commit d40ea25

Browse files
committed
Add Linux MTP USB permission error handling
- Detect EACCES from nusb on Linux, emit mtp-permission-error event - Add PermissionDenied variant to MtpConnectionError - New MtpPermissionDialog with copyable udev install command - Wire up event listener in layout and mtp-store
1 parent c3ad1ed commit d40ea25

14 files changed

Lines changed: 250 additions & 22 deletions

File tree

apps/desktop/coverage-allowlist.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"icon-cache.ts": { "reason": "Depends on Tauri APIs" },
6464
"licensing/licensing-store.svelte.ts": { "reason": "Depends on Tauri store APIs" },
6565
"logging/logger.ts": { "reason": "LogTape config, initialization code only" },
66+
"mtp/MtpPermissionDialog.svelte": { "reason": "UI modal for Linux MTP udev rules" },
6667
"mtp/PtpcameradDialog.svelte": { "reason": "UI modal for macOS MTP workaround" },
6768
"mtp/mtp-store.svelte.ts": { "reason": "Depends on Tauri APIs, tests planned in Phase 6" },
6869
"licensing/AboutWindow.svelte": { "reason": "UI component" },

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use std::sync::OnceLock;
1010
pub struct LinuxDistro {
1111
pub id: String,
1212
pub id_like: Vec<String>,
13+
#[allow(dead_code, reason = "Parsed for future UI use (e.g. about dialog)")]
1314
pub pretty_name: String,
1415
}
1516

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Event loop (event_loop.rs)
5151
- **Cache-only path resolution**: `resolve_path_to_handle()` fails if the path has not appeared in a prior `list_directory()` call. There is no on-demand path walk.
5252
- **Write capability probe**: `probe_write_capability()` creates a hidden `.cmdr_write_probe` folder to detect cameras that advertise write support but reject writes at runtime (`StoreReadOnly`). Timeout or non-fatal errors are treated as writable (benefit of the doubt).
5353
- **ExclusiveAccess errors**: on macOS, when `ptpcamerad` claims a device, `connect()` emits `mtp-exclusive-access-error` with the blocking process name (from `ioreg`) so the frontend can show a dialog with the workaround command. On Linux, the blocking process is reported as `None`.
54+
- **PermissionDenied errors (Linux)**: when `open_device()` fails with "permission denied" (missing udev rules), `connect()` emits `mtp-permission-error`. Frontend shows `MtpPermissionDialog` with a copyable udev install command. Rules file at `resources/99-cmdr-mtp.rules`.
5455
- **Async recursion**: all recursive operations in `bulk_ops.rs` use `Box::pin(async move { ... })`.
5556
- **Event loop shutdown**: uses a biased `tokio::select!` so the shutdown signal (broadcast channel) is always checked first.
5657
- **Volume IDs**: MTP storage volumes use `"{device_id}:{storage_id}"` (e.g., `"mtp-336592896:65537"`).

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ pub enum MtpConnectionError {
3333
StorageFull {
3434
device_id: String,
3535
},
36+
/// Linux: USB device file not accessible (missing udev rules).
37+
PermissionDenied {
38+
device_id: String,
39+
},
3640
ObjectNotFound {
3741
device_id: String,
3842
path: String,
@@ -77,6 +81,9 @@ impl MtpConnectionError {
7781
}
7882
Self::DeviceBusy { .. } => "Device is busy. Wait a moment and try again.".to_string(),
7983
Self::StorageFull { .. } => "Device storage is full. Free up some space.".to_string(),
84+
Self::PermissionDenied { .. } => {
85+
"Can't access the USB device. Install udev rules and reconnect.".to_string()
86+
}
8087
Self::ObjectNotFound { path, .. } => {
8188
format!("File or folder not found: {}. It may have been deleted.", path)
8289
}
@@ -119,6 +126,9 @@ impl std::fmt::Display for MtpConnectionError {
119126
Self::StorageFull { device_id } => {
120127
write!(f, "Storage full on device: {device_id}")
121128
}
129+
Self::PermissionDenied { device_id } => {
130+
write!(f, "Permission denied for device: {device_id}")
131+
}
122132
Self::ObjectNotFound { device_id, path } => {
123133
write!(f, "Object not found on {device_id}: {path}")
124134
}
@@ -188,13 +198,16 @@ pub(super) fn map_mtp_error(e: mtp_rs::Error, device_id: &str) -> MtpConnectionE
188198
message: format!("I/O error: {}", io_err),
189199
},
190200
mtp_rs::Error::Usb(usb_err) => {
191-
// Check for exclusive access errors
192201
let msg = usb_err.to_string().to_lowercase();
193202
if msg.contains("exclusive access") || msg.contains("device or resource busy") {
194203
MtpConnectionError::ExclusiveAccess {
195204
device_id: device_id.to_string(),
196205
blocking_process: None,
197206
}
207+
} else if msg.contains("permission denied") || msg.contains("access denied") {
208+
MtpConnectionError::PermissionDenied {
209+
device_id: device_id.to_string(),
210+
}
198211
} else {
199212
MtpConnectionError::Other {
200213
device_id: device_id.to_string(),
@@ -378,6 +391,9 @@ mod tests {
378391
MtpConnectionError::StorageFull {
379392
device_id: "test".to_string(),
380393
},
394+
MtpConnectionError::PermissionDenied {
395+
device_id: "test".to_string(),
396+
},
381397
MtpConnectionError::ObjectNotFound {
382398
device_id: "test".to_string(),
383399
path: "/path".to_string(),
@@ -448,6 +464,19 @@ mod tests {
448464
assert!(!err.is_retryable());
449465
}
450466

467+
#[test]
468+
fn test_permission_denied_error() {
469+
let err = MtpConnectionError::PermissionDenied {
470+
device_id: "mtp-1-5".to_string(),
471+
};
472+
assert!(err.to_string().contains("Permission denied"));
473+
assert!(err.user_message().contains("udev"));
474+
assert!(!err.is_retryable());
475+
476+
let json = serde_json::to_string(&err).unwrap();
477+
assert!(json.contains("\"type\":\"permissionDenied\""), "JSON: {}", json);
478+
}
479+
451480
#[test]
452481
fn test_other_error() {
453482
let err = MtpConnectionError::Other {

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,20 @@ impl MtpConnectionManager {
228228
});
229229
}
230230

231+
// Check for permission errors (Linux: missing udev rules)
232+
#[cfg(target_os = "linux")]
233+
{
234+
let msg = e.to_string().to_lowercase();
235+
if msg.contains("permission denied") || msg.contains("access denied") {
236+
if let Some(app) = app {
237+
let _ = app.emit("mtp-permission-error", serde_json::json!({ "deviceId": device_id }));
238+
}
239+
return Err(MtpConnectionError::PermissionDenied {
240+
device_id: device_id.to_string(),
241+
});
242+
}
243+
}
244+
231245
// Map other errors
232246
return Err(map_mtp_error(e, device_id));
233247
}

apps/desktop/src/lib/mtp/CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ On macOS, `ptpcamerad` daemon auto-claims devices. When exclusive access error:
3636
```
3737
4. User runs command, clicks "Retry connection"
3838

39+
### Linux USB permission handling
40+
41+
On Linux, USB device files need udev rules to grant user access. When `open_device()` fails with EACCES:
42+
43+
1. Backend detects "permission denied" in the USB error string (`#[cfg(target_os = "linux")]`)
44+
2. Emits `mtp-permission-error` event
45+
3. Frontend shows `MtpPermissionDialog` with a copyable command to install udev rules and reload them
46+
4. User runs command, replugs device, clicks "Retry connection"
47+
48+
The udev rules file is at `src-tauri/resources/99-cmdr-mtp.rules` (for deb/rpm packaging).
49+
3950
### No Volume trait integration (yet)
4051
4152
MTP operations use dedicated Tauri commands (`listMtpDirectory`, `uploadToMtp`, `downloadFromMtp`, etc.) instead of
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<script lang="ts">
2+
import ModalDialog from '$lib/ui/ModalDialog.svelte'
3+
import CommandBox from '$lib/ui/CommandBox.svelte'
4+
import Button from '$lib/ui/Button.svelte'
5+
6+
interface Props {
7+
/** Called when the dialog is closed. */
8+
onClose: () => void
9+
/** Called when user wants to retry connecting. */
10+
onRetry: () => void
11+
}
12+
13+
const { onClose, onRetry }: Props = $props()
14+
15+
const installCommand = `echo 'SUBSYSTEM=="usb", ATTR{bInterfaceClass}=="06", MODE="0664", TAG+="uaccess"\nSUBSYSTEM=="usb", ATTR{bInterfaceClass}=="ff", ATTR{bInterfaceSubClass}=="ff", ATTR{bInterfaceProtocol}=="00", MODE="0664", TAG+="uaccess"' | sudo tee /etc/udev/rules.d/99-cmdr-mtp.rules > /dev/null && sudo udevadm control --reload-rules && sudo udevadm trigger`
16+
17+
function handleKeydown(event: KeyboardEvent) {
18+
if (event.key === 'Enter') {
19+
onRetry()
20+
}
21+
}
22+
</script>
23+
24+
<ModalDialog
25+
titleId="dialog-title"
26+
onkeydown={handleKeydown}
27+
blur
28+
dialogId="mtp-permission"
29+
onclose={onClose}
30+
containerStyle="min-width: 480px; max-width: 560px"
31+
>
32+
{#snippet title()}Can't access USB device{/snippet}
33+
34+
<div class="dialog-body">
35+
<p class="description">
36+
Cmdr doesn't have permission to access this device. Linux needs udev rules to grant MTP device access.
37+
</p>
38+
39+
<p class="explanation">Run this command in your terminal to install the rules and reload them:</p>
40+
41+
<div class="command-wrapper">
42+
<CommandBox command={installCommand} />
43+
</div>
44+
45+
<p class="help-text">After running the command, unplug and replug the device, then retry.</p>
46+
47+
<div class="actions">
48+
<Button variant="secondary" onclick={onClose}>Close</Button>
49+
<Button variant="primary" onclick={onRetry}>Retry connection</Button>
50+
</div>
51+
</div>
52+
</ModalDialog>
53+
54+
<style>
55+
.dialog-body {
56+
padding: 0 var(--spacing-2xl) var(--spacing-xl);
57+
}
58+
59+
.description {
60+
margin: 0 0 var(--spacing-md);
61+
font-size: var(--font-size-md);
62+
color: var(--color-text-secondary);
63+
line-height: 1.5;
64+
}
65+
66+
.explanation {
67+
margin: 0 0 var(--spacing-lg);
68+
font-size: var(--font-size-md);
69+
color: var(--color-text-tertiary);
70+
line-height: 1.6;
71+
}
72+
73+
.command-wrapper {
74+
margin-bottom: var(--spacing-md);
75+
}
76+
77+
.help-text {
78+
margin: 0 0 20px;
79+
font-size: var(--font-size-sm);
80+
color: var(--color-text-tertiary);
81+
line-height: 1.5;
82+
}
83+
84+
.actions {
85+
display: flex;
86+
gap: var(--spacing-md);
87+
justify-content: flex-end;
88+
}
89+
</style>

apps/desktop/src/lib/mtp/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// MTP (Android device) support components
22

3+
export { default as MtpPermissionDialog } from './MtpPermissionDialog.svelte'
34
export { default as PtpcameradDialog } from './PtpcameradDialog.svelte'
45

56
// MTP store for device state management

apps/desktop/src/lib/mtp/mtp-store.svelte.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
onMtpDeviceDisconnected,
1919
onMtpDeviceRemoved,
2020
onMtpExclusiveAccessError,
21+
onMtpPermissionError,
2122
} from '$lib/tauri-commands'
2223
import { getAppLogger } from '$lib/logging/logger'
2324

@@ -58,6 +59,7 @@ let state = $state<MtpStoreState>({
5859
let unlistenConnected: UnlistenFn | undefined
5960
let unlistenDisconnected: UnlistenFn | undefined
6061
let unlistenExclusiveAccess: UnlistenFn | undefined
62+
let unlistenPermissionError: UnlistenFn | undefined
6163
let unlistenDeviceDetected: UnlistenFn | undefined
6264
let unlistenDeviceRemoved: UnlistenFn | undefined
6365

@@ -324,6 +326,17 @@ export async function initialize(): Promise<void> {
324326
}
325327
})
326328

329+
unlistenPermissionError = await onMtpPermissionError((event) => {
330+
const deviceState = state.devices.get(event.deviceId)
331+
if (deviceState) {
332+
state.devices.set(event.deviceId, {
333+
...deviceState,
334+
connectionState: 'error',
335+
error: 'USB permission denied — install udev rules and reconnect',
336+
})
337+
}
338+
})
339+
327340
// USB hotplug: device detected
328341
unlistenDeviceDetected = await onMtpDeviceDetected((event) => {
329342
logger.info('MTP device detected via hotplug: {deviceId}', { deviceId: event.deviceId })
@@ -359,6 +372,7 @@ export function cleanup(): void {
359372
unlistenConnected?.()
360373
unlistenDisconnected?.()
361374
unlistenExclusiveAccess?.()
375+
unlistenPermissionError?.()
362376
unlistenDeviceDetected?.()
363377
unlistenDeviceRemoved?.()
364378

apps/desktop/src/lib/tauri-commands/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ export {
190190
onMtpDeviceDetected,
191191
onMtpDeviceRemoved,
192192
onMtpExclusiveAccessError,
193+
onMtpPermissionError,
193194
onMtpDeviceConnected,
194195
onMtpDeviceDisconnected,
195196
listMtpDirectory,
@@ -213,6 +214,7 @@ export type {
213214
MtpDeviceDetectedEvent,
214215
MtpDeviceRemovedEvent,
215216
MtpExclusiveAccessErrorEvent,
217+
MtpPermissionErrorEvent,
216218
MtpDeviceConnectedEvent,
217219
MtpDeviceDisconnectedEvent,
218220
MtpOperationResult,

0 commit comments

Comments
 (0)