Skip to content

Commit edc5829

Browse files
committed
Add Windows shell integration and tray mount manager
1 parent e086091 commit edc5829

17 files changed

Lines changed: 3555 additions & 184 deletions

README.md

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ source .venv/bin/activate # On Windows: .venv\Scripts\activate
5656

5757
pip install -e './amitools[vamos]' # Install amitools from submodule (includes machine68k)
5858
pip install -e . # Install AmiFUSE
59+
pip install -e '.[windows]' # Windows only: adds pystray + Pillow for tray/shell integration
5960
```
6061

6162
### macOS-specific
@@ -132,7 +133,10 @@ amifuse uses subcommands for different operations:
132133
```bash
133134
amifuse inspect <image> # Inspect RDB partitions
134135
amifuse mount <image> # Mount a filesystem
136+
amifuse unmount <mountpoint> # Unmount a filesystem
135137
amifuse doctor # Check dependencies and configuration
138+
amifuse register # Add Windows Explorer context menu entries
139+
amifuse unregister # Remove Windows Explorer context menu entries
136140
```
137141

138142
### Inspecting Disk Images
@@ -182,8 +186,10 @@ Mount lifecycle:
182186
- `--interactive` / `--foreground` keeps AmiFUSE attached to the terminal.
183187
Use this for debugging or when you want `Ctrl+C` to unmount from the same
184188
shell.
185-
- Windows defaults to interactive mode because there is no standalone
186-
unmount command there yet.
189+
- Windows defaults to daemon mode. You can mount via the Explorer context
190+
menu (`amifuse register`), unmount from the system tray icon, or use
191+
`amifuse unmount <mountpoint>` from the CLI. Note: the WinFSP "Eject"
192+
action does not perform a clean unmount — use the tray or CLI instead.
187193
- `--profile` implies interactive mode.
188194

189195
### Diagnosing Issues
@@ -311,13 +317,37 @@ The `--icons` flag enables conversion of Amiga `.info` icon files to native Find
311317

312318
*** This feature is experimental and macOS-only. ***
313319

320+
## Windows Shell Integration
321+
322+
On Windows, AmiFUSE can integrate with Explorer for a right-click mount
323+
experience and a system tray mount manager.
324+
325+
### Context menu registration
326+
327+
```bash
328+
amifuse register # Add "Mount with AmiFUSE" to .hdf/.adf context menus
329+
amifuse unregister # Remove the context menu entries
330+
```
331+
332+
Registration writes to `HKCU` (current user only) — no administrator privileges
333+
are required.
334+
335+
### Tray mount manager
336+
337+
When a filesystem is mounted in daemon mode on Windows, the `amifuse-tray`
338+
helper appears in the system tray. From the tray icon you can see active mounts
339+
and unmount them cleanly.
340+
341+
The tray requires optional dependencies: install them with
342+
`pip install amifuse[windows]` (provides pystray and Pillow).
343+
314344
## Notes
315345

316346
- The filesystem is mounted **read-only** by default; use `--write` for experimental read-write support
317347
- macOS and Linux default to daemon mode; use `--interactive` if you want
318348
Ctrl+C to unmount from the same terminal
319349
- Use `amifuse unmount <mountpoint>` to tear down daemon mounts
320-
- Windows defaults to interactive mode until it grows a standalone unmount
321-
path
350+
- Windows defaults to daemon mode; unmount via the system tray icon or
351+
`amifuse unmount <mountpoint>`
322352
- macOS Finder/Spotlight indexing is automatically disabled to improve performance
323353
- First directory traversal may be slow as the handler processes each path; subsequent accesses are cached

amifuse/fuse_fs.py

Lines changed: 35 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,11 @@
1616
import sys
1717
import time
1818

19-
# signal.SIGKILL is not defined on Windows; fall back to SIGTERM so that
20-
# _kill_mount_owner_processes() can still compile and will use the strongest
21-
# signal available.
22-
_SIGKILL = getattr(signal, "SIGKILL", signal.SIGTERM)
2319
from pathlib import Path
2420
from typing import Dict, List, Optional, Tuple
2521

22+
from .platform import _kill_mount_owner_processes
23+
2624
try:
2725
from fuse import FUSE, FuseOSError, LoggingMixIn, Operations # type: ignore
2826
except ImportError:
@@ -1583,8 +1581,9 @@ def _track_op(self, op: str, path: str, cached: bool = False):
15831581

15841582
def _root_stat(self):
15851583
now = int(time.time())
1584+
perm = 0o777 if self.bridge._write_enabled else 0o755
15861585
return {
1587-
"st_mode": (0o755 | 0o040000), # drwxr-xr-x
1586+
"st_mode": (perm | 0o040000),
15881587
"st_nlink": 2,
15891588
"st_size": 0,
15901589
"st_ctime": now,
@@ -2501,11 +2500,6 @@ def mount_fuse(
25012500

25022501
if foreground is None:
25032502
foreground = plat.mount_runs_in_foreground_by_default()
2504-
if not foreground and not plat.get_unmount_command(mountpoint):
2505-
raise SystemExit(
2506-
"Daemon mode is not supported on this platform yet because there is "
2507-
"no standalone unmount command. Use --interactive instead."
2508-
)
25092503

25102504
# Print startup banner
25112505
print(__banner__)
@@ -2568,6 +2562,12 @@ def mount_fuse(
25682562
"fsname": f"amifuse:{volname}",
25692563
"default_permissions": True, # Let kernel handle permission checks
25702564
}
2565+
# WinFSP maps FUSE uid=0/gid=0 to Windows SID S-1-5-0 (Nobody), blocking
2566+
# write access for the current user. uid/gid=-1 tells WinFSP to use the
2567+
# mounting user's SID instead.
2568+
if sys.platform.startswith("win"):
2569+
fuse_kwargs["uid"] = -1
2570+
fuse_kwargs["gid"] = -1
25712571
# subtype is a Linux-only FUSE option; WinFSP and macFUSE don't support it
25722572
if sys.platform.startswith("linux"):
25732573
fuse_kwargs["subtype"] = "amifuse"
@@ -3802,12 +3802,13 @@ def cmd_unmount(args):
38023802
raise SystemExit(f"Mountpoint {mountpoint} is not currently mounted.")
38033803

38043804
cmd = plat.get_unmount_command(mountpoint)
3805+
_no_window = {"creationflags": 0x08000000} if sys.platform.startswith("win") else {}
38053806
if cmd:
3806-
result = subprocess.run(cmd, check=False)
3807+
result = subprocess.run(cmd, check=False, **_no_window)
38073808
if result.returncode != 0:
38083809
killed_pids = _kill_mount_owner_processes(mountpoint)
38093810
if killed_pids:
3810-
result = subprocess.run(cmd, check=False)
3811+
result = subprocess.run(cmd, check=False, **_no_window)
38113812
if result.returncode != 0:
38123813
raise SystemExit(
38133814
f"Unmount failed with exit code {result.returncode}: "
@@ -3891,84 +3892,16 @@ def cmd_doctor(args):
38913892
_cmd_doctor(args)
38923893

38933894

3894-
def _kill_mount_owner_processes(mountpoint: Path) -> List[int]:
3895-
pids = _find_mount_owner_pids(mountpoint)
3896-
if not pids:
3897-
return []
3898-
3899-
remaining = []
3900-
for pid in pids:
3901-
try:
3902-
os.kill(pid, signal.SIGTERM)
3903-
remaining.append(pid)
3904-
except (ProcessLookupError, OSError):
3905-
continue
3906-
3907-
deadline = time.time() + 1.0
3908-
while remaining and time.time() < deadline:
3909-
still_alive = []
3910-
for pid in remaining:
3911-
if _pid_exists(pid):
3912-
still_alive.append(pid)
3913-
if not still_alive:
3914-
return pids
3915-
remaining = still_alive
3916-
time.sleep(0.05)
3917-
3918-
for pid in remaining:
3919-
try:
3920-
os.kill(pid, _SIGKILL)
3921-
except (ProcessLookupError, OSError):
3922-
continue
3923-
3924-
return pids
3925-
3926-
3927-
def _find_mount_owner_pids(mountpoint: Path) -> List[int]:
3928-
"""Find PIDs of amifuse processes that own the given mountpoint.
3929-
3930-
Uses platform.find_amifuse_mounts() for process discovery, then
3931-
filters to those matching the given mountpoint.
3932-
"""
3933-
from . import platform as plat
3934-
3935-
raw_mountpoint = str(mountpoint)
3936-
abs_mountpoint = str(mountpoint.resolve(strict=False))
3937-
3938-
try:
3939-
all_mounts = plat.find_amifuse_mounts()
3940-
except OSError:
3941-
return []
3895+
def cmd_register(args):
3896+
"""Handle the 'register' subcommand."""
3897+
from .windows_shell import register
3898+
register()
39423899

3943-
pids = []
3944-
for mount in all_mounts:
3945-
mp = mount.get("mountpoint")
3946-
if mp is None:
3947-
continue
3948-
if mp == raw_mountpoint or mp == abs_mountpoint:
3949-
pids.append(mount["pid"])
3950-
continue
3951-
try:
3952-
resolved = str(Path(mp).expanduser().resolve(strict=False))
3953-
except OSError:
3954-
continue
3955-
if resolved == abs_mountpoint:
3956-
pids.append(mount["pid"])
39573900

3958-
return pids
3959-
3960-
3961-
def _pid_exists(pid: int) -> bool:
3962-
try:
3963-
os.kill(pid, 0)
3964-
except ProcessLookupError:
3965-
return False
3966-
except PermissionError:
3967-
return True
3968-
except OSError:
3969-
# Windows raises a generic OSError for invalid/dead PIDs
3970-
return False
3971-
return True
3901+
def cmd_unregister(args):
3902+
"""Handle the 'unregister' subcommand."""
3903+
from .windows_shell import unregister
3904+
unregister()
39723905

39733906

39743907
def _validate_driver_path(driver: Optional[Path]) -> None:
@@ -4017,6 +3950,9 @@ def main(argv=None):
40173950
--json Output results as JSON.
40183951
--fix Auto-fix what can be fixed.
40193952
3953+
register Add Windows Explorer context menu entries.
3954+
unregister Remove Windows Explorer context menu entries.
3955+
40203956
format <image> <partition> [volname]
40213957
Format an Amiga partition.
40223958
--driver PATH Filesystem binary (default: extract from RDB).
@@ -4176,6 +4112,17 @@ def main(argv=None):
41764112
)
41774113
doctor_parser.set_defaults(func=cmd_doctor)
41784114

4115+
# register / unregister subcommands (Windows shell integration)
4116+
register_parser = subparsers.add_parser(
4117+
"register", help="Register AmiFUSE file associations (Windows)."
4118+
)
4119+
register_parser.set_defaults(func=cmd_register)
4120+
4121+
unregister_parser = subparsers.add_parser(
4122+
"unregister", help="Remove AmiFUSE file associations (Windows)."
4123+
)
4124+
unregister_parser.set_defaults(func=cmd_unregister)
4125+
41794126
# format subcommand
41804127
format_parser = subparsers.add_parser(
41814128
"format", help="Format an Amiga partition."

amifuse/launcher.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Console-free launcher for AmiFUSE context menu actions.
2+
3+
This launcher is invoked by Explorer shell verbs. It MUST exit as fast as
4+
possible -- any delay blocks the Explorer UI thread. All file I/O uses
5+
open/write/close immediately; process exit uses os._exit() to skip Python
6+
shutdown overhead.
7+
"""
8+
9+
import argparse
10+
import ctypes
11+
import os
12+
import subprocess
13+
import sys
14+
from datetime import datetime
15+
from pathlib import Path
16+
17+
DETACHED_PROCESS = 0x00000008
18+
CREATE_NEW_PROCESS_GROUP = 0x00000200
19+
CREATE_NO_WINDOW = 0x08000000
20+
CREATE_NEW_CONSOLE = 0x00000010
21+
CREATE_BREAKAWAY_FROM_JOB = 0x01000000
22+
23+
_DETACHED_FLAGS = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
24+
25+
_LOG_DIR = Path(os.environ.get("APPDATA", "")) / "AmiFUSE"
26+
27+
28+
def _log(msg: str) -> None:
29+
"""Append a single log line, opening and closing the file immediately."""
30+
try:
31+
_LOG_DIR.mkdir(parents=True, exist_ok=True)
32+
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S,%f")[:-3]
33+
with open(str(_LOG_DIR / "launcher.log"), "a") as f:
34+
f.write(f"{ts} INFO {msg}\n")
35+
except OSError:
36+
pass
37+
38+
39+
def _spawn_detached(cmd: list[str], **kwargs) -> None:
40+
"""Spawn a fully detached process. Tries CREATE_BREAKAWAY_FROM_JOB first
41+
to escape Explorer's job object; falls back without it if the job
42+
doesn't allow breakaway."""
43+
flags = _DETACHED_FLAGS | CREATE_BREAKAWAY_FROM_JOB
44+
try:
45+
subprocess.Popen(cmd, creationflags=flags, **kwargs)
46+
except OSError:
47+
# Job doesn't allow breakaway -- retry without it
48+
subprocess.Popen(cmd, creationflags=_DETACHED_FLAGS, **kwargs)
49+
50+
51+
def main(argv=None) -> None:
52+
parser = argparse.ArgumentParser(description="AmiFUSE launcher")
53+
sub = parser.add_subparsers(dest="command", required=True)
54+
55+
mount_p = sub.add_parser("mount", help="Mount a disk image")
56+
mount_p.add_argument("image")
57+
mount_p.add_argument("--write", action="store_true")
58+
59+
inspect_p = sub.add_parser("inspect", help="Open inspect in a new console")
60+
inspect_p.add_argument("image")
61+
62+
args = parser.parse_args(argv)
63+
64+
if args.command == "mount":
65+
_do_mount(args)
66+
elif args.command == "inspect":
67+
_do_inspect(args)
68+
69+
# Force-exit immediately. Python's normal shutdown (atexit handlers,
70+
# logging.shutdown, GC, module cleanup) is unnecessary for a launcher
71+
# and can delay process exit enough to hang Explorer.
72+
os._exit(0)
73+
74+
75+
def _do_mount(args) -> None:
76+
python_dir = Path(sys.executable).parent
77+
python_exe = str(python_dir / "pythonw.exe")
78+
if not os.path.isfile(python_exe):
79+
python_exe = sys.executable
80+
81+
cmd = [python_exe, "-m", "amifuse", "mount"]
82+
if args.write:
83+
cmd.append("--write")
84+
cmd.append("--daemon")
85+
cmd.append(args.image)
86+
87+
_log(f"Launching mount: {cmd}")
88+
try:
89+
_spawn_detached(
90+
cmd,
91+
stdin=subprocess.DEVNULL,
92+
stdout=subprocess.DEVNULL,
93+
stderr=subprocess.DEVNULL,
94+
close_fds=True,
95+
)
96+
except Exception as exc:
97+
_log(f"Failed to launch mount subprocess: {exc}")
98+
return
99+
100+
_ensure_tray_running()
101+
102+
103+
def _do_inspect(args) -> None:
104+
cmd = ["cmd", "/k", sys.executable, "-m", "amifuse", "inspect", args.image]
105+
subprocess.Popen(cmd, creationflags=CREATE_NEW_CONSOLE)
106+
107+
108+
def _ensure_tray_running() -> None:
109+
handle = ctypes.windll.kernel32.OpenMutexW(
110+
0x00100000, False, "AmiFUSE_Tray_Mutex"
111+
)
112+
if handle != 0:
113+
ctypes.windll.kernel32.CloseHandle(handle)
114+
return
115+
116+
tray_exe = str(Path(sys.executable).parent / "amifuse-tray.exe")
117+
if os.path.isfile(tray_exe):
118+
cmd = [tray_exe]
119+
else:
120+
cmd = [sys.executable, "-m", "amifuse.tray"]
121+
122+
_log(f"Starting tray: {cmd}")
123+
try:
124+
_spawn_detached(
125+
cmd,
126+
stdin=subprocess.DEVNULL,
127+
stdout=subprocess.DEVNULL,
128+
stderr=subprocess.DEVNULL,
129+
close_fds=True,
130+
)
131+
except Exception as exc:
132+
_log(f"Failed to start tray: {exc}")
133+
134+
135+
if __name__ == "__main__":
136+
main()

0 commit comments

Comments
 (0)