Skip to content

Commit ffed79f

Browse files
committed
v0.1.8: Add Eject button and improved disk detection
- Add Eject button to safely power down disks via diskutil eject - Add polling fallback (every 3s) to detect Linux-only disk changes - Recognize "Linux Filesystem" partition type as supported - Merge native and MS fallback disk detection results
1 parent f4f4eb1 commit ffed79f

10 files changed

Lines changed: 150 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22

33
All notable changes to this project will be documented in this file.
44

5-
## [0.1.7] - 2025-01-27
5+
## [0.1.8] - 2025-01-27
6+
7+
### Added
8+
- Eject button to safely power down and remove disks
69

710
### Fixed
811
- Disk detection now merges native and MS fallback results
912
- Linux-only cards now properly detected (not filtered by -m flag)
1013
- Cards with broken GUID tables still work via MS fallback
1114
- "Linux Filesystem" partition type now recognized as supported
15+
- Disk watcher now polls for physical disk changes (catches Linux-only disks)
1216

1317
## [0.1.6] - 2025-01-27
1418

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "anylinuxfs-gui",
3-
"version": "0.1.7",
3+
"version": "0.1.8",
44
"private": true,
55
"type": "module",
66
"scripts": {

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "anylinuxfs-gui"
3-
version = "0.1.7"
3+
version = "0.1.8"
44
edition = "2021"
55

66
[lib]

src-tauri/src/commands/disk.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,27 @@ pub async fn unmount_disk() -> Result<String, String> {
551551
.map_err(|e| format!("Task error: {}", e))?
552552
}
553553

554+
#[tauri::command]
555+
pub async fn eject_disk(device: String) -> Result<String, String> {
556+
// Eject (power down) a disk using diskutil
557+
// This is useful for Linux-only disks that aren't auto-ejected
558+
tokio::task::spawn_blocking(move || {
559+
let output = Command::new("diskutil")
560+
.args(["eject", &device])
561+
.output()
562+
.map_err(|e| format!("Failed to run diskutil: {}", e))?;
563+
564+
if output.status.success() {
565+
Ok(format!("Ejected {}", device))
566+
} else {
567+
let stderr = String::from_utf8_lossy(&output.stderr);
568+
Err(format!("Failed to eject: {}", stderr))
569+
}
570+
})
571+
.await
572+
.map_err(|e| format!("Task error: {}", e))?
573+
}
574+
554575
#[tauri::command]
555576
pub async fn force_cleanup() -> Result<String, String> {
556577
// Force kill orphaned anylinuxfs/krun processes

src-tauri/src/commands/log.rs

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,10 +206,23 @@ pub fn start_disk_watcher(app: AppHandle) -> Result<(), String> {
206206
return;
207207
}
208208

209+
// Also watch /dev for physical disk connect/disconnect events
210+
// This catches Linux-only disks that don't get mounted to /Volumes
211+
let dev_path = PathBuf::from("/dev");
212+
if watcher.watch(&dev_path, RecursiveMode::NonRecursive).is_err() {
213+
eprintln!("Failed to watch /dev (continuing with /Volumes only)");
214+
// Don't return - /Volumes watching is still useful
215+
}
216+
209217
// Track pending event - we wait for events to settle before emitting
210218
let mut pending_event: Option<Instant> = None;
211219
let settle_duration = Duration::from_millis(1500); // Wait 1.5s after last event
212220

221+
// Track disk count for polling fallback (for Linux-only disks not in /Volumes)
222+
let mut last_disk_count = count_disks();
223+
let mut last_poll = Instant::now();
224+
let poll_interval = Duration::from_secs(3); // Poll every 3 seconds
225+
213226
loop {
214227
// Check if we should stop
215228
if state_clone.disk_watcher_stop.load(Ordering::SeqCst) {
@@ -220,8 +233,18 @@ pub fn start_disk_watcher(app: AppHandle) -> Result<(), String> {
220233
Ok(Ok(event)) => {
221234
match event.kind {
222235
EventKind::Create(_) | EventKind::Remove(_) => {
223-
// Mark that we have a pending event, reset settle timer
224-
pending_event = Some(Instant::now());
236+
// Filter /dev events to only disk-related changes
237+
let is_disk_event = event.paths.iter().any(|p| {
238+
let path_str = p.to_string_lossy();
239+
// Match /Volumes/* or /dev/disk*
240+
path_str.starts_with("/Volumes/") ||
241+
(path_str.starts_with("/dev/disk") && !path_str.contains("s"))
242+
});
243+
244+
if is_disk_event {
245+
// Mark that we have a pending event, reset settle timer
246+
pending_event = Some(Instant::now());
247+
}
225248
}
226249
_ => {}
227250
}
@@ -236,8 +259,20 @@ pub fn start_disk_watcher(app: AppHandle) -> Result<(), String> {
236259
// Events have settled, emit and clear
237260
let _ = app.emit("disks-changed", ());
238261
pending_event = None;
262+
last_disk_count = count_disks(); // Update count after emit
239263
}
240264
}
265+
266+
// Polling fallback: check disk count periodically
267+
// This catches Linux-only disks that don't trigger /Volumes events
268+
if last_poll.elapsed() >= poll_interval {
269+
let current_count = count_disks();
270+
if current_count != last_disk_count {
271+
pending_event = Some(Instant::now());
272+
last_disk_count = current_count;
273+
}
274+
last_poll = Instant::now();
275+
}
241276
}
242277
}
243278
}
@@ -248,6 +283,24 @@ pub fn start_disk_watcher(app: AppHandle) -> Result<(), String> {
248283
Ok(())
249284
}
250285

286+
/// Count physical disks by checking /dev/disk* entries
287+
fn count_disks() -> usize {
288+
std::fs::read_dir("/dev")
289+
.map(|entries| {
290+
entries
291+
.filter_map(|e| e.ok())
292+
.filter(|e| {
293+
let name = e.file_name();
294+
let name_str = name.to_string_lossy();
295+
// Match disk0, disk1, etc. but not disk0s1 (partitions)
296+
name_str.starts_with("disk") &&
297+
name_str[4..].chars().all(|c| c.is_ascii_digit())
298+
})
299+
.count()
300+
})
301+
.unwrap_or(0)
302+
}
303+
251304
#[tauri::command]
252305
pub fn stop_watchers(app: AppHandle) -> Result<(), String> {
253306
let state = app.state::<Arc<WatcherState>>();

src-tauri/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ mod commands;
33

44
use std::sync::{Arc, Mutex};
55
use commands::{
6-
list_disks, mount_disk, unmount_disk, force_cleanup,
6+
list_disks, mount_disk, unmount_disk, eject_disk, force_cleanup,
77
get_mount_status, check_cli,
88
get_log_content, start_log_stream, start_disk_watcher, stop_watchers,
99
get_config, update_config,
@@ -23,6 +23,7 @@ pub fn run() {
2323
list_disks,
2424
mount_disk,
2525
unmount_disk,
26+
eject_disk,
2627
force_cleanup,
2728
get_mount_status,
2829
check_cli,

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "anylinuxfs-gui",
4-
"version": "0.1.7",
4+
"version": "0.1.8",
55
"identifier": "com.anylinuxfs.gui",
66
"build": {
77
"beforeDevCommand": "npm run dev",

src/components/DiskList.svelte

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
import PassphraseDialog from './PassphraseDialog.svelte';
66
import { onMount } from 'svelte';
77
import { listen } from '@tauri-apps/api/event';
8-
import { startDiskWatcher } from '$lib/api';
8+
import { startDiskWatcher, ejectDisk } from '$lib/api';
9+
10+
let ejectingDevice: string | null = $state(null);
911
1012
let passphraseDevice: string | null = $state(null);
1113
@@ -59,6 +61,19 @@
5961
disks.setAdminMode(checked);
6062
disks.refresh(checked);
6163
}
64+
65+
async function handleEject(device: string) {
66+
ejectingDevice = device;
67+
try {
68+
await ejectDisk(device);
69+
// Refresh after successful eject
70+
disks.refresh();
71+
} catch (e) {
72+
console.error('Failed to eject:', e);
73+
} finally {
74+
ejectingDevice = null;
75+
}
76+
}
6277
</script>
6378

6479
<div class="disk-list">
@@ -120,6 +135,18 @@
120135
<span class="disk-model">{disk.model}</span>
121136
{/if}
122137
<span class="disk-size">{disk.size}</span>
138+
<button
139+
class="eject-btn"
140+
onclick={() => handleEject(disk.device)}
141+
disabled={ejectingDevice === disk.device}
142+
title="Eject disk (safely remove)"
143+
>
144+
{#if ejectingDevice === disk.device}
145+
<span class="spinner small"></span>
146+
{:else}
147+
148+
{/if}
149+
</button>
123150
</div>
124151
<div class="partitions">
125152
{#each disk.partitions as partition}
@@ -332,6 +359,37 @@
332359
margin-left: auto;
333360
}
334361
362+
.eject-btn {
363+
padding: 4px 8px;
364+
border-radius: 4px;
365+
border: 1px solid var(--border-color);
366+
background: var(--button-secondary-bg);
367+
color: var(--text-secondary);
368+
font-size: 14px;
369+
cursor: pointer;
370+
transition: all 0.15s;
371+
display: flex;
372+
align-items: center;
373+
justify-content: center;
374+
min-width: 32px;
375+
}
376+
377+
.eject-btn:hover:not(:disabled) {
378+
background: var(--button-secondary-hover);
379+
color: var(--text-primary);
380+
}
381+
382+
.eject-btn:disabled {
383+
opacity: 0.6;
384+
cursor: not-allowed;
385+
}
386+
387+
.spinner.small {
388+
width: 12px;
389+
height: 12px;
390+
border-width: 2px;
391+
}
392+
335393
.partitions {
336394
display: flex;
337395
flex-direction: column;

src/lib/api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export async function unmountDisk(): Promise<string> {
1717
return await invoke<string>('unmount_disk');
1818
}
1919

20+
export async function ejectDisk(device: string): Promise<string> {
21+
return await invoke<string>('eject_disk', { device });
22+
}
23+
2024
export async function forceCleanup(): Promise<string> {
2125
return await invoke<string>('force_cleanup');
2226
}

0 commit comments

Comments
 (0)