Skip to content

Improve Explorer experience with disk space, shell notifications, and double-click mount#50

Merged
reinauer merged 17 commits into
reinauer:mainfrom
tbdye:phase9/explorer-robustness
Jul 3, 2026
Merged

Improve Explorer experience with disk space, shell notifications, and double-click mount#50
reinauer merged 17 commits into
reinauer:mainfrom
tbdye:phase9/explorer-robustness

Conversation

@tbdye

@tbdye tbdye commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Makes AmiFUSE behave as a first-class Windows drive in File Explorer:

  • Disk space reportingstatfs implementation so Explorer Properties and df show correct volume size and free space, backed by ACTION_DISK_INFO from the Amiga handler
  • Explorer refresh on mount/unmountSHChangeNotify notifications so the sidebar updates immediately without manual refresh
  • Double-click mount — Double-clicking an .hdf/.adf in Explorer mounts the image (read-write by default) and opens an Explorer window to the drive root
  • Installer hardening — Admin privilege check, broken venv recovery, --uninstall switch for clean removal, and a install.bat wrapper with UAC auto-elevation

Also fixes three low-severity bugs: SCSI WRITE(10) short-buffer validation, cache locking for multi-threaded read-only mounts, and duplicated resume logic in the startup runner.

What changed

Area Files What
Disk space fuse_fs.py, startup_runner.py statfs() via ACTION_DISK_INFO; infinite TTL for read-only, 30s for writable
Shell notifications platform.py, fuse_fs.py, tray.py SHChangeNotify on mount/unmount with crash-recovery redundancy
Double-click mount windows_shell.py, launcher.py Drive letter allocation, mount polling, Explorer auto-open, simplified to 2 context menu verbs
Installer install-windows.ps1, install.bat Admin check, venv recovery, --uninstall, UAC wrapper
SCSI fix scsi_device.py Short-buffer validation with proper sense data
Cache locking fuse_fs.py RLock protecting stat/dir/neg caches in multi-threaded mode
Startup refactor startup_runner.py Deduplicated resume logic into _finalize_burst_blocked
Tests test_statfs.py, test_write_persistence.py, + 6 more 65+ new tests; integration tests for statfs, write persistence, unmount lifecycle
Metadata pyproject.toml, README.md Windows classifiers, updated install/usage docs

Test plan

  • pytest — 555 passed, 1 skipped (all green locally on Windows)
  • Mount image, right-click drive > Properties — correct disk size shown
  • Mount/unmount — Explorer sidebar updates within ~2s
  • Double-click .adf — mounts r/w + Explorer opens to drive root
  • install.bat double-click — UAC prompt, full install, doctor reports ready
  • install.bat -Uninstall — clean removal, idempotent on re-run
  • CI (Windows + macOS + Linux)

tbdye added 11 commits June 29, 2026 15:19
Validate that the data buffer length is sufficient for the requested
transfer count before issuing a SCSI WRITE. Previously, a short buffer
was silently accepted, potentially writing incomplete data.

Returns CHECK CONDITION with ILLEGAL_REQUEST / INVALID FIELD IN CDB
sense data when the buffer is shorter than xfer_blocks * block_size.
Protect _stat_cache, _dir_cache, and _neg_cache with a single RLock
to prevent undefined behavior when concurrent FUSE threads read/expire
cache entries simultaneously. RLock chosen over Lock for FUSE callback
reentrancy in multi-threaded mode (use_threads=True for read-only).

Lock is never held during bridge calls (bridge has its own RLock),
eliminating deadlock risk.
Extract the near-identical blocked-state handling from both the
run_state.error and run_state.done branches of run_burst into a
single _finalize_burst_blocked method.

Pure refactor with one intentional normalization: exit_count is now
reset uniformly in both branches when a blocked handler resumes
(previously only reset in the done branch, which was an oversight).
Add HandlerBridge.get_disk_info() which sends ACTION_DISK_INFO to the
Amiga handler and reads id_NumBlocks/id_NumBlocksUsed/id_BytesPerBlock
from the InfoDataStruct response.

AmigaFuseFS.statfs() maps these to POSIX statvfs fields so Explorer
and df show correct disk geometry. Caching strategy: infinite for
read-only mounts (geometry never changes), 30s TTL for writable.
Task 7a: Add admin-privilege detection (exits with re-launch guidance
if not elevated), execution policy bypass for current process, and
enhanced venv recovery that detects missing python.exe.

Task 7b: Add -Uninstall switch that cleanly removes venv, registry
keys (ProgIDs, file associations), and scheduled tasks. Runs amifuse
unregister before venv removal. Idempotent and prints removal summary.
Add platform.notify_shell_drive_change() that fires SHCNE_DRIVEADD or
SHCNE_DRIVEREMOVED so Explorer's sidebar updates immediately without
manual refresh.

Called from AmigaFuseFS.init() on mount, destroy() on unmount, and
tray unmount handlers. Tray path is intentionally redundant for crash
recovery (destroy never fires if the process dies).
Set shell\(Default) to "open" on AmiFUSE ProgIDs so double-clicking
.hdf/.adf triggers mount. The launcher finds an available drive letter,
spawns the mount daemon, polls for readiness (15s timeout), then opens
Explorer to the mounted root. Shows MessageBox on failure.
New test_statfs.py verifies mounted images report non-zero geometry via
os.statvfs (Unix) or GetDiskFreeSpaceExW (Windows).

New test_write_persistence.py exercises writable mounts: immediate
read-back, directory listing visibility, and cross-remount persistence.

Extended test_mount_lifecycle.py with unmount-path-not-accessible test
validating drive letter release on Windows / ismount on Unix.
Add Operating System :: Microsoft :: Windows and Python 3.9-3.13
version classifiers. Update README mount lifecycle bullet for
double-click mount and add Windows Shell Integration section
documenting context menu, tray, and uninstall workflows.
Add missing _check_handler_alive() to statfs, wrap SHChangeNotify and
MessageBoxW args with c_wchar_p for explicit LPCWSTR marshaling, guard
against negative free-block counts from corrupted FS metadata, and
document lock ordering invariants.
- Change open verb (double-click) from read-only to read+write mount
- Replace three context menu entries with two: "Mount & Open" (r/w default)
  and "Mount Read-Only" (secondary)
- Clean up stale verb registry keys on re-register
- Add install.bat wrapper with UAC auto-elevation for install-windows.ps1
@tbdye tbdye marked this pull request as ready for review June 30, 2026 15:55
tbdye added 6 commits July 2, 2026 09:51
…rrors

Replace os.path.exists drive-letter probing with the Win32 GetLogicalDrives
bitmask (new _windows_allocated_drive_letters helper, shared by auto-selection
and validate_mountpoint). os.path.exists false-negatives on an assigned-but-
empty removable slot (e.g. an empty card-reader D:), causing AmiFUSE to pick a
letter WinFsp then refused with "mount point in use".

Also surface launcher mount failures instead of exiting silently: _do_mount
now selects its letter, polls for the mount, and starts the tray, and both
_do_mount and _do_open show an error dialog on spawn failure or timeout with
softened "did not appear yet -- may still be starting" wording.
If amifuse.fuse_fs is imported before the fuse_mock fixture runs, it binds
FUSE=None at import time and leaves fuse_fs unusable for later tests. Have
fuse_mock rebind fuse_fs's module-level FUSE symbols when the module is
already loaded (auto-reverted at teardown), and mark the test_status classes
that import fuse_fs with usefixtures("fuse_mock") so they can't pin FUSE=None
for tests that run after them.
Run install-windows.ps1 unelevated as the standard user so all user-scoped
state (per-user Python, the venv, pip, HKCU shell registration) lands in the
double-clicking user's profile and hive; elevate only the machine-wide WinFSP
install (download MSI + SHA256-verify + elevate msiexec, pinned v2.1) rather
than winget. Provision a wheel-supported per-user Python (3.9-3.13) instead of
trusting system python, bypass setuptools_scm on the mapped-drive editable
install via SETUPTOOLS_SCM_PRETEND_VERSION_FOR_AMIFUSE, and run a final
read-only amifuse doctor health check. install.bat launches the .ps1
unelevated and holds the window open with the exit code.
On Windows, mount discovery tokenizes the command line with
shlex.split(cmdline, posix=False), which keeps the surrounding quotes
that subprocess.list2cmdline adds around paths containing spaces. Those
literal quotes leaked into the reported image and mountpoint values.

Strip only a single matched leading+trailing quote pair; leave
unbalanced input (a quote on one side only) untouched. The Unix path
(posix=True) carries no surrounding quotes, so this is a no-op there.
Introduce list_partitions plus the PartInfo and PartitionList
dataclasses to surface every partition in an RDB image, keyed by drive
name. Multi-RDB images (more than one 0x76 MBR partition) are walked
across all RDBs, mirroring the existing multi-RDB loop; names, not
indices, are the fan-out key because Partition.num restarts at 0 in each
RDB and so collides across them.

When a partition name collides across RDBs, name-keyed --partition
routing would be ambiguous, so fall back to the first partition only and
report the reason via PartitionList.fallback_reason. Both the rdisk and
the underlying block device are closed after enumeration.
Add _select_drive_letters(n), returning up to n free letters plus the
total available so the caller can mount best-effort on exhaustion;
_select_drive_letter becomes a thin N=1 wrapper over it, sharing one
drive-letter alphabet.

Rewire _do_open and _do_mount onto a shared mount_image_all fan-out that
enumerates partitions, allocates a letter each, spawns all mounts first,
then polls a single aggregate deadline for the drives to appear. open
mounts read-write and opens one Explorer window per confirmed drive;
mountro mounts read-only and opens none. Failures are reported in at
most one summary dialog, separating never-started spawns (definite) from
drives that timed out (soft, may be a slow cold mount). N=1 reproduces
the previous single-partition path byte-for-byte.
@reinauer reinauer merged commit f6fe7df into reinauer:main Jul 3, 2026
18 checks passed
@tbdye tbdye deleted the phase9/explorer-robustness branch July 3, 2026 02:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants