From ae6d762b7bbc86fbc78c576a0e07ba9599930ccc Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:11:17 -0400 Subject: [PATCH 1/2] Offload a card from the Explorer right-click menu, with a drawn icon Right-clicking a drive and choosing "Offload this card" is how an operator expects to reach a tool like this, and the app had no shell presence at all. The verbs live under HKEY_CURRENT_USER\Software\Classes, which is the point of the choice: installing needs no administrator, touches nothing another account can see, and uninstalling is the deletion of two keys. The command line uses %V rather than %1, because a drive root is the case the entry exists for and %1 does not carry it, and it prefers the windowed offloader-gui script so clicking the entry opens the app rather than flashing a console. Finding that script goes through sysconfig rather than guessing beside sys.executable: a --user install puts it under the user scheme, nowhere near the interpreter, which is exactly the layout a first attempt missed. The window now takes an optional source, so the right-clicked path arrives already in the field. Without that the menu entry would be pointless. The icon is drawn, not shipped, for the same reason the PDF's glyphs are: the filmstrip from the report header, rendered to a multi-size .ico with zlib and struct alone. No Pillow, no new dependency, no binary artwork in the repository, and the shell entry, the window and the report header now agree on what this tool looks like. Small sizes simplify rather than shrink, since six sprocket holes and three outlined frames are mush at 16 px. What a registry verb cannot do is appear in the short Windows 11 menu, which is built from packaged COM handlers. The command says so, because a menu entry the operator cannot find is indistinguishable from one that failed to install. --- CHANGELOG.md | 13 +++ README.md | 22 ++++ src/offloader/cli.py | 42 +++++++- src/offloader/gui/app.py | 36 ++++++- src/offloader/gui/main_window.py | 8 +- src/offloader/shellicon.py | 180 +++++++++++++++++++++++++++++++ src/offloader/shellmenu.py | 155 ++++++++++++++++++++++++++ tests/test_gui_window.py | 42 ++++++++ tests/test_shell.py | 177 ++++++++++++++++++++++++++++++ 9 files changed, 671 insertions(+), 4 deletions(-) create mode 100644 src/offloader/shellicon.py create mode 100644 src/offloader/shellmenu.py create mode 100644 tests/test_shell.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 61a5ee5..1cc4de2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ project uses [semantic versioning][semver]. ### Added +- **An Explorer right-click entry: `offloader shell --install`.** Adds "Offload + this card" to a drive's context menu and "Offload this folder" to a + directory's, each opening the app with that path already in the source field. + Written under `HKEY_CURRENT_USER`, so it needs no administrator and touches + no other account, and removed again by `--uninstall`. Windows 11 lists + registry verbs under "Show more options", which the command says rather than + leaving the operator hunting a menu that looks like it failed to install. +- **An icon, drawn rather than shipped.** The filmstrip from the PDF header, + rendered to a multi-size `.ico` (16 to 256, simplifying as it shrinks) with + `zlib` and `struct` — no Pillow, no new dependency and no binary artwork in + the repository. The desktop app now uses the same mark as its window icon, + built in memory, so the shell entry, the window and the report header finally + agree on what this tool looks like. - **A stall is reported instead of looking like a slow link.** A hung network handle raises nothing — it just stops returning bytes — so nothing could be retried and the job sat at a stale throughput figure. Source reads are now diff --git a/README.md b/README.md index 34810a3..e36c90d 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ offloader verify D:\video\080426\A001 | `report` | regenerate paperwork for an existing tree, copying nothing | | `info` | show tool and environment status | | `gui` | launch the desktop app (also `offloader-gui`) | +| `shell` | add or remove the Explorer right-click entry (Windows) | ### `offload` and `report` @@ -128,6 +129,27 @@ hashes, which is the only check that catches a rename or a moved file — every file involved still hashes exactly as recorded. See [`docs/ascmhl.md`](docs/ascmhl.md#directory-hashes). +### `shell` — the Explorer right-click entry + +```sh +offloader shell --install # add it, and write the icon it uses +offloader shell # say whether it is installed +offloader shell --uninstall # remove it +``` + +Adds **Offload this card** to a drive's context menu and **Offload this folder** +to a directory's, both opening the desktop app with that path already in the +source field. Everything goes under `HKEY_CURRENT_USER`, so it needs no +administrator and affects no other account. + +The icon is the same filmstrip the PDF header draws, rendered to a multi-size +`.ico` at install time rather than shipped — the repository carries no binary +artwork. + +Windows 11 builds its short context menu from packaged COM handlers, so a +registry verb like this one appears under **Show more options** (or Shift+F10, +which opens the classic menu directly). + ### Verification modes | Mode | What it does | Catches | diff --git a/src/offloader/cli.py b/src/offloader/cli.py index 7e793d8..e89feca 100644 --- a/src/offloader/cli.py +++ b/src/offloader/cli.py @@ -250,6 +250,14 @@ def build_parser() -> argparse.ArgumentParser: sub.add_parser("info", help="show tool and environment status") sub.add_parser("gui", help="launch the desktop interface") + + shell = sub.add_parser( + "shell", help="manage the Explorer right-click entry (Windows)") + shell.add_argument("--install", action="store_true", + help="add 'Offload this card' to the context menu for " + "drives and folders, and write the icon it uses") + shell.add_argument("--uninstall", action="store_true", + help="remove the context-menu entry") return parser @@ -403,10 +411,42 @@ def cmd_gui(_args: argparse.Namespace) -> int: return gui_main([sys.argv[0]]) +def cmd_shell(args: argparse.Namespace) -> int: + from . import shellicon, shellmenu + + if args.install and args.uninstall: + print("error: --install and --uninstall are opposites", file=sys.stderr) + return 2 + + if args.uninstall: + removed = shellmenu.uninstall() + print("Context-menu entry removed." if removed + else "Nothing to remove; the entry was not installed.") + return 0 + + if args.install: + icon = shellicon.write() + for entry in shellmenu.install(icon): + print(f" {entry.label} (on right-clicking a {entry.applies_to})") + print(f"\nIcon written to {icon}") + print(f"Runs: {shellmenu.launcher()}") + # Said plainly: Windows 11's short menu is built from packaged COM + # handlers, so an operator looking only there concludes this failed. + print("\nWindows 11 lists registry verbs under 'Show more options' " + "(or Shift+F10 for the classic menu directly).") + return 0 + + print(f"Context-menu entry: {'installed' if shellmenu.installed() else 'not installed'}") + print(" offloader shell --install add it") + print(" offloader shell --uninstall remove it") + return 0 + + def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) handlers = {"offload": cmd_offload, "report": cmd_report, - "verify": cmd_verify, "info": cmd_info, "gui": cmd_gui} + "verify": cmd_verify, "info": cmd_info, "gui": cmd_gui, + "shell": cmd_shell} try: return handlers[args.command](args) except KeyboardInterrupt: diff --git a/src/offloader/gui/app.py b/src/offloader/gui/app.py index b361032..99daff5 100644 --- a/src/offloader/gui/app.py +++ b/src/offloader/gui/app.py @@ -3,10 +3,31 @@ from __future__ import annotations import sys +from pathlib import Path from .. import PRODUCT_NAME, __version__ +def _app_icon(): + """The same filmstrip the shell entry and the PDF header use. + + Built in memory rather than read from a file: the mark is drawn by + `shellicon`, so launching the app does not need to have written it to disk + first. Only the small sizes — the 256 costs a per-pixel Python loop for + something no title bar will show. + """ + from PySide6.QtGui import QIcon, QPixmap + + from .. import shellicon + + icon = QIcon() + for size in (16, 32, 48): + pixmap = QPixmap() + pixmap.loadFromData(shellicon.png_at(size), "PNG") + icon.addPixmap(pixmap) + return icon + + def main(argv: list[str] | None = None) -> int: try: from PySide6.QtWidgets import QApplication @@ -21,13 +42,24 @@ def main(argv: list[str] | None = None) -> int: from . import theme from .main_window import MainWindow - app = QApplication(argv if argv is not None else sys.argv) + args = list(argv if argv is not None else sys.argv) + # A single trailing path is a source to start from, which is what the + # Explorer context-menu entry passes. Anything Qt wants is left for it. + source: Path | None = None + if len(args) > 1 and not args[-1].startswith("-"): + candidate = Path(args[-1]) + if candidate.exists(): + source = candidate + args = args[:-1] + + app = QApplication(args) app.setApplicationName(PRODUCT_NAME) app.setApplicationVersion(__version__) app.setOrganizationName(PRODUCT_NAME) + app.setWindowIcon(_app_icon()) theme.apply(app) - window = MainWindow() + window = MainWindow(source) window.show() return app.exec() diff --git a/src/offloader/gui/main_window.py b/src/offloader/gui/main_window.py index 8eab0ff..06fa395 100644 --- a/src/offloader/gui/main_window.py +++ b/src/offloader/gui/main_window.py @@ -38,7 +38,9 @@ class MainWindow(QMainWindow): - def __init__(self) -> None: + def __init__(self, source: Path | None = None) -> None: + """`source` pre-fills the source field — what the Explorer context-menu + entry passes, so a right-clicked card arrives already selected.""" super().__init__() self.setWindowTitle(f"{PRODUCT_NAME} {__version__}") self.resize(1280, 840) @@ -125,6 +127,10 @@ def __init__(self) -> None: self._set_mode(1 if self.settings.get("mode") == "simple" else 0) self.drives.start() + # Last, so the panels it fills are built and the mode is settled. + if source is not None: + self._set_source(Path(source)) + # ---------------------------------------------------------------- chrome def _build_menu(self) -> None: job_menu = self.menuBar().addMenu("&Job") diff --git a/src/offloader/shellicon.py b/src/offloader/shellicon.py new file mode 100644 index 0000000..98b9157 --- /dev/null +++ b/src/offloader/shellicon.py @@ -0,0 +1,180 @@ +"""The product mark, rendered to a Windows `.ico`. + +Drawn rather than shipped, for the same reason `reports.icons` draws the PDF's +glyphs: the repository carries no binary artwork, and the shell entry, the +window and the report header then take their identity from one filmstrip rather +than three files that drift apart. + +Encoded with `zlib` and `struct` because the base install has neither Pillow +nor any other raster library, and reportlab writes PDFs. An ICO is a short +header, one directory entry per size and a PNG per size, all of which the +standard library can produce. +""" + +from __future__ import annotations + +import os +import struct +import sys +import zlib +from pathlib import Path + +#: The filmstrip palette, matching `reports.icons.draw_filmstrip`. Defined here +#: rather than imported so a shell icon does not drag in the report layer. +BODY = (0x1C, 0x1C, 0x1C, 0xFF) +SPROCKET = (0xF2, 0xF2, 0xF2, 0xFF) +FRAME = (0xF0, 0xA9, 0x2B, 0xFF) + +#: Sizes the shell chooses between: 16 for the context menu and small views, +#: 32 and 48 for the medium ones, 256 for the large. Windows scales from the +#: nearest, so supplying the exact sizes avoids it resampling 256 down to 16 and +#: turning the sprocket holes to mush. +ICO_SIZES = (16, 32, 48, 64, 128, 256) + + +class _Canvas: + """A little RGBA raster. Transparent until something is drawn on it.""" + + def __init__(self, size: int) -> None: + self.size = size + self.pixels = bytearray(size * size * 4) + + def _put(self, x: int, y: int, colour: tuple[int, int, int, int]) -> None: + if 0 <= x < self.size and 0 <= y < self.size: + at = (y * self.size + x) * 4 + self.pixels[at:at + 4] = bytes(colour) + + def rect(self, x: int, y: int, width: int, height: int, + colour: tuple[int, int, int, int]) -> None: + for row in range(y, y + height): + for col in range(x, x + width): + self._put(col, row, colour) + + def round_rect(self, x: int, y: int, width: int, height: int, radius: int, + colour: tuple[int, int, int, int]) -> None: + """A filled rectangle with the corners taken off. + + No anti-aliasing: at 16 px a soft edge reads as a smudge, and the shell + composites the icon over backgrounds of either polarity. + """ + radius = max(0, min(radius, width // 2, height // 2)) + for row in range(y, y + height): + for col in range(x, x + width): + dx = min(col - x, x + width - 1 - col) + dy = min(row - y, y + height - 1 - row) + if dx < radius and dy < radius: + off_x = radius - 1 - dx + off_y = radius - 1 - dy + if off_x * off_x + off_y * off_y > radius * radius: + continue + self._put(col, row, colour) + + +def _draw(size: int) -> _Canvas: + """The mark at one size, simplified as it gets smaller. + + Six sprocket holes and three outlined frames are legible at 48 px and + above. Below that they collapse into each other, so the count drops and the + frames fill rather than outline — the silhouette survives, which is all a + 16 px icon can carry. + """ + canvas = _Canvas(size) + + margin = max(1, round(size * 0.055)) + body = size - 2 * margin + canvas.round_rect(margin, margin, body, body, + max(1, round(size * 0.16)), BODY) + + hole_w = max(1, round(size * 0.10)) + hole_h = max(1, round(size * 0.105)) + inset = margin + max(1, round(size * 0.055)) + rows = 6 if size >= 48 else 4 + gap = (body - rows * hole_h) / (rows + 1) + for row in range(rows): + top = margin + round(gap + row * (hole_h + gap)) + canvas.rect(inset, top, hole_w, hole_h, SPROCKET) + canvas.rect(size - inset - hole_w, top, hole_w, hole_h, SPROCKET) + + frames = 3 if size >= 48 else (2 if size >= 24 else 1) + frame_w = max(2, round(size * 0.34)) + frame_h = max(1, round(size * 0.13)) + frame_gap = max(1, round(size * 0.07)) + left = (size - frame_w) // 2 + stack = frames * frame_h + (frames - 1) * frame_gap + top = (size - stack) // 2 + # Stroke scales with the icon: a hairline that reads as a crisp edge at + # 48 px is a thread at 256, and one fixed width cannot be both. + stroke = max(1, round(size * 0.012)) + outline = size >= 48 and frame_h > 2 * stroke + 1 + for index in range(frames): + at = top + index * (frame_h + frame_gap) + canvas.rect(left, at, frame_w, frame_h, FRAME) + if outline: + canvas.rect(left + stroke, at + stroke, + frame_w - 2 * stroke, frame_h - 2 * stroke, BODY) + return canvas + + +def _chunk(tag: bytes, data: bytes) -> bytes: + return (struct.pack(">I", len(data)) + tag + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)) + + +def png_bytes(canvas: _Canvas) -> bytes: + """`canvas` as a PNG: 8-bit RGBA, no interlacing, filter 0 on every row.""" + stride = canvas.size * 4 + raw = bytearray() + for row in range(canvas.size): + raw.append(0) # filter: none + raw += canvas.pixels[row * stride:(row + 1) * stride] + header = struct.pack(">IIBBBBB", canvas.size, canvas.size, 8, 6, 0, 0, 0) + return (b"\x89PNG\r\n\x1a\n" + + _chunk(b"IHDR", header) + + _chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + + _chunk(b"IEND", b"")) + + +def png_at(size: int) -> bytes: + """The mark at one size, as a PNG. For callers that want a raster rather + than an icon file — the desktop app's window icon, for instance.""" + return png_bytes(_draw(size)) + + +def ico_bytes(sizes: tuple[int, ...] = ICO_SIZES) -> bytes: + """A multi-resolution icon holding the mark at each of `sizes`.""" + images = [png_bytes(_draw(size)) for size in sizes] + offset = 6 + 16 * len(images) + entries = b"" + for size, image in zip(sizes, images, strict=True): + # 0 means 256 in an icon directory: the field is one byte. + dimension = 0 if size >= 256 else size + entries += struct.pack(" Path: + """Where the icon is kept for the shell to read it. + + Not inside the package: a registry value points at this path for as long as + the menu entry exists, and a path under `site-packages` dies with the next + reinstall or virtual environment. + """ + if sys.platform == "win32": + base = Path(os.environ.get("LOCALAPPDATA") + or Path.home() / "AppData" / "Local") + else: + base = Path(os.environ.get("XDG_CACHE_HOME") + or Path.home() / ".cache") + return base / "Offloader" / "offloader.ico" + + +def write(path: Path | None = None, + sizes: tuple[int, ...] = ICO_SIZES) -> Path: + """Render the icon to `path` (default `default_path()`) and return it.""" + target = Path(path) if path is not None else default_path() + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(ico_bytes(sizes)) + return target diff --git a/src/offloader/shellmenu.py b/src/offloader/shellmenu.py new file mode 100644 index 0000000..ef7044b --- /dev/null +++ b/src/offloader/shellmenu.py @@ -0,0 +1,155 @@ +"""The Explorer context-menu entry: right-click a card, offload it. + +Everything lives under `HKEY_CURRENT_USER\\Software\\Classes`, which is the +per-user half of the same tree `HKEY_CLASSES_ROOT` presents. That choice is the +point: installing the entry needs no administrator, touches nothing another +account can see, and uninstalling is the deletion of two keys. + +What it cannot do is appear in the *short* Windows 11 menu. That list is built +from packaged `IExplorerCommand` handlers — a COM object in an MSIX identity — +and a registry verb is by definition a classic one, so it shows under "Show +more options" (or Shift+F10, which opens the classic menu directly). Stated +here and in the command's own output, because a menu entry the operator cannot +find is indistinguishable from one that failed to install. +""" + +from __future__ import annotations + +import sys +import sysconfig +from dataclasses import dataclass +from pathlib import Path + +#: Under HKCU. `Drive` is the card as Explorer presents it — the thing an +#: operator right-clicks in "This PC" — and `Directory` covers a card copied to +#: a folder, or any other tree worth offloading. +CLASSES = "Software\\Classes" +DRIVE_KEY = f"{CLASSES}\\Drive\\shell\\Offloader" +DIRECTORY_KEY = f"{CLASSES}\\Directory\\shell\\Offloader" + + +@dataclass(frozen=True) +class Entry: + """One verb: where it lives, what it says, what it runs.""" + + key: str + label: str + command: str + icon: str + + @property + def command_key(self) -> str: + return f"{self.key}\\command" + + @property + def applies_to(self) -> str: + """The shell class this verb hangs off — "Drive" or "Directory".""" + return self.key.split("\\")[2] + + +def script_dirs() -> list[Path]: + """Every directory this interpreter might have put entry points in. + + `sysconfig` rather than a guess from `sys.executable`: a `pip install + --user` — which is what an editable install without a virtual environment + usually is — puts the scripts under the user scheme, nowhere near the + interpreter. Guessing missed exactly that case. + """ + found: list[Path] = [] + for scheme in ("nt_user", "posix_user"): + if scheme in sysconfig.get_scheme_names(): + found.append(Path(sysconfig.get_path("scripts", scheme=scheme))) + found.insert(0, Path(sysconfig.get_path("scripts"))) + here = Path(sys.executable).parent + found += [here, here / "Scripts"] + # Preserve order, drop repeats: the default scheme is often one of these. + return list(dict.fromkeys(found)) + + +def launcher() -> str: + """The executable the verb should run, quoted for a registry command. + + Prefers the windowed `offloader-gui` script pip installs, so clicking the + entry opens the app rather than flashing a console. `pythonw` with the + module is the fallback for a checkout that was never installed. + """ + name = "offloader-gui.exe" if sys.platform == "win32" else "offloader-gui" + for directory in script_dirs(): + candidate = directory / name + if candidate.exists(): + return f'"{candidate}"' + here = Path(sys.executable).parent + windowed = here / "pythonw.exe" + runner = windowed if windowed.exists() else Path(sys.executable) + return f'"{runner}" -m offloader.gui.app' + + +def entries(icon: str | Path, command: str | None = None) -> list[Entry]: + """The verbs to write, as data. Pure, so the plan is testable anywhere.""" + run = command if command is not None else launcher() + # %V is the clicked item. Unlike %1 it is also correct for a drive root, + # which is the case this entry exists for. + invoke = f'{run} "%V"' + return [ + Entry(DRIVE_KEY, "Offload this card…", invoke, str(icon)), + Entry(DIRECTORY_KEY, "Offload this folder…", invoke, str(icon)), + ] + + +def _require_windows() -> None: + if sys.platform != "win32": + raise OSError("the Explorer context menu is a Windows feature; " + "nothing to install on this platform") + + +def install(icon: str | Path, command: str | None = None) -> list[Entry]: + """Write the verbs. Returns what was written.""" + _require_windows() + import winreg + + written = entries(icon, command) + for entry in written: + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, entry.key) as key: + winreg.SetValueEx(key, None, 0, winreg.REG_SZ, entry.label) + winreg.SetValueEx(key, "Icon", 0, winreg.REG_SZ, entry.icon) + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, entry.command_key) as key: + winreg.SetValueEx(key, None, 0, winreg.REG_SZ, entry.command) + return written + + +def uninstall() -> list[str]: + """Remove the verbs. Returns the keys that were actually there. + + Deletes the `command` subkey first: `DeleteKey` refuses a key with + children, and a half-removed verb is worse than none. + """ + _require_windows() + import winreg + + removed: list[str] = [] + for entry in entries(""): + for key in (entry.command_key, entry.key): + try: + winreg.DeleteKey(winreg.HKEY_CURRENT_USER, key) + except FileNotFoundError: + continue + except OSError: + # Left in place rather than reported gone, which is the honest + # outcome for a key something else is holding open. + continue + removed.append(key) + return removed + + +def installed() -> bool: + """Whether the entry is present, by the one key that must exist.""" + if sys.platform != "win32": + return False + import winreg + + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, + f"{DRIVE_KEY}\\command"): + return True + except OSError: + return False diff --git a/tests/test_gui_window.py b/tests/test_gui_window.py index bdf1fc1..232d45a 100644 --- a/tests/test_gui_window.py +++ b/tests/test_gui_window.py @@ -45,6 +45,48 @@ def window(qapp, tmp_path, monkeypatch): window.close() +def test_a_source_given_at_startup_is_prefilled(qapp, tmp_path, monkeypatch): + """What the Explorer context-menu entry passes. A right-clicked card that + arrived as an argument and then had to be picked again by hand would make + the menu entry pointless.""" + from offloader import history as history_module + from offloader import presets as presets_module + + monkeypatch.setattr(presets_module, "config_file", lambda n: tmp_path / n) + monkeypatch.setattr(history_module, "config_file", lambda n: tmp_path / n) + monkeypatch.setattr(mw, "config_file", lambda n: tmp_path / n) + + card = tmp_path / "A001" + card.mkdir() + window = mw.MainWindow(card) + window.drives.stop() + try: + # Both panels, because the window remembers which mode it was left in + # and the entry cannot know which one the operator will see. + assert window.simple.drop_zone.path == card + assert window.presets.drop_zone.path == card + finally: + window.controller.shutdown(2000) + window.close() + + +def test_no_source_leaves_the_drop_zone_empty(qapp, tmp_path, monkeypatch): + from offloader import history as history_module + from offloader import presets as presets_module + + monkeypatch.setattr(presets_module, "config_file", lambda n: tmp_path / n) + monkeypatch.setattr(history_module, "config_file", lambda n: tmp_path / n) + monkeypatch.setattr(mw, "config_file", lambda n: tmp_path / n) + + window = mw.MainWindow() + window.drives.stop() + try: + assert window.simple.drop_zone.path is None + finally: + window.controller.shutdown(2000) + window.close() + + @pytest.fixture def prompts(monkeypatch): """Capture message boxes instead of showing them.""" diff --git a/tests/test_shell.py b/tests/test_shell.py new file mode 100644 index 0000000..4f28537 --- /dev/null +++ b/tests/test_shell.py @@ -0,0 +1,177 @@ +"""The drawn icon and the Explorer context-menu entry. + +The icon is checked as a file format rather than by eye: an ICO the shell cannot +parse fails silently, showing a generic page instead of the mark, and nothing in +a normal run would say so. The menu entry is checked as a *plan* — the registry +writes are Windows-only, but what gets written is a pure function and should be +wrong in the same way on every platform or not at all. +""" + +from __future__ import annotations + +import struct +import sys +import zlib +from pathlib import Path + +import pytest + +from offloader import shellicon, shellmenu + +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + + +def _ico_entries(blob: bytes) -> list[tuple[int, int, int, int]]: + """(width, height, byte length, offset) per image in an icon directory.""" + reserved, kind, count = struct.unpack("II", image[16:24]) + assert (declared_w, declared_h) == (width, height) + + +def test_the_png_chunks_carry_sound_checksums(): + """zlib.crc32 is what a decoder checks; a bad one is a corrupt file.""" + image = shellicon.png_at(32) + assert image[:8] == PNG_MAGIC + at = 8 + tags = [] + while at < len(image): + length = struct.unpack(">I", image[at:at + 4])[0] + tag = image[at + 4:at + 8] + body = image[at + 8:at + 8 + length] + stored = struct.unpack(">I", image[at + 8 + length:at + 12 + length])[0] + assert stored == zlib.crc32(tag + body) & 0xFFFFFFFF, f"{tag!r} crc" + tags.append(tag) + at += 12 + length + assert tags == [b"IHDR", b"IDAT", b"IEND"] + + +def test_the_mark_is_drawn_not_blank(): + """A silhouette of nothing would pass every structural check above.""" + canvas = shellicon._draw(48) + opaque = sum(1 for i in range(3, len(canvas.pixels), 4) + if canvas.pixels[i] != 0) + assert opaque > 48 * 48 * 0.5, "most of the tile should be the body" + + colours = {tuple(canvas.pixels[at:at + 4]) + for at in range(0, len(canvas.pixels), 4)} + assert shellicon.BODY in colours + assert shellicon.SPROCKET in colours + assert shellicon.FRAME in colours + + +def test_the_corners_are_transparent(): + """Rounded, so the icon does not read as a square tile against the shell's + own background.""" + canvas = shellicon._draw(64) + assert canvas.pixels[3] == 0, "top-left corner is opaque" + + +def test_write_puts_an_icon_where_it_says(tmp_path: Path): + target = shellicon.write(tmp_path / "nested" / "mark.ico") + assert target.exists() + assert _ico_entries(target.read_bytes()) + + +# ------------------------------------------------------------- the menu entry + + +def test_both_verbs_are_planned(): + entries = shellmenu.entries("C:\\icons\\offloader.ico", command='"app.exe"') + + assert [entry.applies_to for entry in entries] == ["Drive", "Directory"] + for entry in entries: + assert entry.key.startswith("Software\\Classes\\") + assert entry.command_key == entry.key + "\\command" + assert entry.icon == "C:\\icons\\offloader.ico" + + +def test_the_command_passes_the_clicked_path(): + """%V rather than %1: a drive root is the case the entry exists for, and + %1 does not carry it.""" + entry = shellmenu.entries("i.ico", command='"app.exe"')[0] + assert entry.command == '"app.exe" "%V"' + + +def test_the_launcher_is_quoted_for_a_path_with_spaces(): + """Registry commands are parsed by the shell; an unquoted Program Files + path becomes two arguments.""" + assert shellmenu.launcher().startswith('"') + + +def test_nothing_is_claimed_installed_off_windows(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + assert shellmenu.installed() is False + with pytest.raises(OSError, match="Windows"): + shellmenu.install("i.ico") + + +@pytest.mark.skipif(sys.platform != "win32", reason="registry is Windows-only") +def test_install_then_uninstall_round_trips(monkeypatch): + """Against the real registry, under HKCU, which needs no administrator. + + Writes to a key of its own so a developer's actual menu entry is neither + read nor removed by the test. + """ + suffix = "\\shell\\OffloaderTest" + monkeypatch.setattr(shellmenu, "DRIVE_KEY", + f"{shellmenu.CLASSES}\\Drive{suffix}") + monkeypatch.setattr(shellmenu, "DIRECTORY_KEY", + f"{shellmenu.CLASSES}\\Directory{suffix}") + import winreg + + try: + shellmenu.install("C:\\icons\\offloader.ico") + assert shellmenu.installed() + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, + shellmenu.DRIVE_KEY) as key: + assert winreg.QueryValueEx(key, "Icon")[0] == \ + "C:\\icons\\offloader.ico" + assert "Offload" in winreg.QueryValue(key, None) + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, + shellmenu.DRIVE_KEY + "\\command") as key: + assert "%V" in winreg.QueryValue(key, None) + finally: + shellmenu.uninstall() + + assert not shellmenu.installed() + + +@pytest.mark.skipif(sys.platform != "win32", reason="registry is Windows-only") +def test_uninstalling_what_is_not_there_is_not_an_error(monkeypatch): + monkeypatch.setattr(shellmenu, "DRIVE_KEY", + f"{shellmenu.CLASSES}\\Drive\\shell\\OffloaderAbsent") + monkeypatch.setattr( + shellmenu, "DIRECTORY_KEY", + f"{shellmenu.CLASSES}\\Directory\\shell\\OffloaderAbsent") + assert shellmenu.uninstall() == [] From 693063635cec566174f1f50a2ade2e490ac81d1f Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:24:35 -0400 Subject: [PATCH 2/2] Test how the launcher is found, and the half-removed entry Locating the launcher is the part of this that was actually wrong once: the first version guessed beside sys.executable and missed a --user install, whose entry points live under the user scheme, so it never found the GUI script and fell back to the module without saying so. It only had an assertion that the result was quoted. Now the user scheme is required to be searched, the interpreter's own directory to win over it, duplicates to be absent, and both the preferred-script and fallback paths to be exercised. Uninstall gets the test it needed most: DeleteKey refuses a key with children, so the command subkey has to go first, and the wrong order leaves the verb's parent behind with its command intact. That is worse than not removing it at all, because the menu item survives while installed() keeps answering yes. Re-installing over an existing entry is pinned too, since an upgrade has to repoint the command rather than fail. The icon gains the checks its size-dependent drawing implies: 256 written as zero in the directory, since the field is one byte; the mark staying inside its own canvas at every size, because the canvas silently drops pixels that fall outside and a clipped mark would have no other symptom; the frame count dropping as the tile shrinks, which is the whole claim about simplifying rather than scaling; and a radius larger than the box being clamped. --- tests/test_shell.py | 190 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/tests/test_shell.py b/tests/test_shell.py index 4f28537..f942e40 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -103,6 +103,78 @@ def test_write_puts_an_icon_where_it_says(tmp_path: Path): assert _ico_entries(target.read_bytes()) +def test_256_is_stored_as_zero_in_the_directory(): + """The width and height fields are one byte each, so 256 cannot be written + literally. A 256 there truncates to 0 by accident and happens to be right; + writing it deliberately is the difference between that and a 255-pixel + icon nobody asked for.""" + blob = shellicon.ico_bytes((256,)) + assert blob[6] == 0 and blob[7] == 0 + + +def test_a_custom_size_list_is_honoured(): + blob = shellicon.ico_bytes((32, 48)) + assert [w for w, _, _, _ in _ico_entries(blob)] == [32, 48] + + +@pytest.mark.parametrize("size", shellicon.ICO_SIZES) +def test_every_size_draws_inside_its_own_canvas(size: int): + """The mark is drawn from proportions of `size`, and rounding each of them + independently is how a rectangle ends up one pixel past the edge. The + canvas would not complain — `_put` drops out-of-range pixels — so the only + symptom would be a clipped mark at one size.""" + canvas = shellicon._draw(size) + assert len(canvas.pixels) == size * size * 4 + opaque = sum(1 for i in range(3, len(canvas.pixels), 4) + if canvas.pixels[i] != 0) + assert opaque > 0, "nothing was drawn" + assert opaque < size * size, "the mark fills the tile, so it is not rounded" + + +def _frame_bands(canvas) -> int: + """How many separate amber frames the mark has, by counting runs of rows + that contain any frame pixel. + + Deliberately not a scan down one column: above 48 px the frames are drawn + as outlines, so a single column crosses each one's top and bottom edge and + counts it twice. Every row within a frame's height has frame pixels + somewhere — its left and right edges if nothing else — which makes this + measurement indifferent to whether they are filled or outlined. + """ + bands = 0 + inside = False + for row in range(canvas.size): + start = row * canvas.size * 4 + row_pixels = canvas.pixels[start:start + canvas.size * 4] + has_frame = any( + tuple(row_pixels[at:at + 4]) == shellicon.FRAME + for at in range(0, len(row_pixels), 4) + ) + if has_frame and not inside: + bands += 1 + inside = has_frame + return bands + + +@pytest.mark.parametrize("size,frames", [(16, 1), (32, 2), (48, 3), (256, 3)]) +def test_the_mark_simplifies_as_it_shrinks(size: int, frames: int): + """Six sprocket holes and three outlined frames are mush at 16 px, so the + detail is meant to drop rather than shrink. Counting the frames is the + cheapest way to hold that intent still.""" + assert _frame_bands(shellicon._draw(size)) == frames + + +def test_a_radius_larger_than_the_box_does_not_invert_it(): + """`round_rect` is called with a proportion of the size, so a small enough + tile asks for a radius wider than the rectangle. Clamped, it rounds fully; + unclamped, the corner test runs on negative offsets.""" + canvas = shellicon._Canvas(4) + canvas.round_rect(0, 0, 4, 4, radius=99, colour=shellicon.BODY) + opaque = sum(1 for i in range(3, len(canvas.pixels), 4) + if canvas.pixels[i] != 0) + assert 0 < opaque <= 16 + + # ------------------------------------------------------------- the menu entry @@ -129,6 +201,76 @@ def test_the_launcher_is_quoted_for_a_path_with_spaces(): assert shellmenu.launcher().startswith('"') +# ----------------------------------------------------- finding the launcher + + +def test_the_user_scripts_directory_is_searched(): + """The bug this had. A `pip install --user` puts entry points under the + user scheme, nowhere near `sys.executable`, and a first version guessed + only beside the interpreter — so it never found the installed GUI script + and silently fell back to `pythonw -m`.""" + import sysconfig + + scheme = "nt_user" if sys.platform == "win32" else "posix_user" + if scheme not in sysconfig.get_scheme_names(): + pytest.skip(f"{scheme} is not a scheme on this platform") + + expected = Path(sysconfig.get_path("scripts", scheme=scheme)) + assert expected in shellmenu.script_dirs() + + +def test_the_interpreters_own_scripts_directory_is_searched_first(): + """A virtual environment's script must win over a stale user-scheme copy + of the same name, or the entry launches the wrong installation.""" + import sysconfig + + assert shellmenu.script_dirs()[0] == Path(sysconfig.get_path("scripts")) + + +def test_script_directories_are_not_repeated(): + """The default scheme is often one of the fallbacks too, and a duplicate + means the same directory is stat'd twice on every install.""" + found = shellmenu.script_dirs() + assert len(found) == len(set(found)) + + +def test_the_gui_script_is_preferred_when_it_exists(tmp_path, monkeypatch): + """So clicking the entry opens the app rather than flashing a console + window, which is what `python.exe` would do.""" + name = "offloader-gui.exe" if sys.platform == "win32" else "offloader-gui" + script = tmp_path / name + script.write_text("", encoding="utf-8") + monkeypatch.setattr(shellmenu, "script_dirs", lambda: [tmp_path]) + + assert shellmenu.launcher() == f'"{script}"' + + +def test_the_module_is_the_fallback_when_no_script_is_installed(monkeypatch, + tmp_path): + """A checkout that was never installed still gets a working entry.""" + monkeypatch.setattr(shellmenu, "script_dirs", lambda: [tmp_path]) + + command = shellmenu.launcher() + assert "-m offloader.gui.app" in command + assert command.startswith('"') + + +def test_the_fallback_runs_the_windowed_interpreter_if_there_is_one( + monkeypatch, tmp_path): + """`python.exe` would leave a console window behind the app for as long as + it runs; `pythonw.exe` beside it is the same interpreter without one.""" + if sys.platform != "win32": + pytest.skip("pythonw is a Windows interpreter") + + fake_root = tmp_path / "Python" + fake_root.mkdir() + (fake_root / "pythonw.exe").write_text("", encoding="utf-8") + monkeypatch.setattr(shellmenu, "script_dirs", lambda: [tmp_path]) + monkeypatch.setattr(sys, "executable", str(fake_root / "python.exe")) + + assert "pythonw.exe" in shellmenu.launcher() + + def test_nothing_is_claimed_installed_off_windows(monkeypatch): monkeypatch.setattr(sys, "platform", "linux") assert shellmenu.installed() is False @@ -167,6 +309,54 @@ def test_install_then_uninstall_round_trips(monkeypatch): assert not shellmenu.installed() +@pytest.mark.skipif(sys.platform != "win32", reason="registry is Windows-only") +def test_uninstall_leaves_no_orphaned_command_key(monkeypatch): + """`DeleteKey` refuses a key that still has children, so the command + subkey has to go first. Wrong order leaves the verb's parent behind with + its command intact — a half-removed entry, which is worse than none, + because the menu item survives and `installed()` keeps saying yes.""" + suffix = "\\shell\\OffloaderOrphanTest" + monkeypatch.setattr(shellmenu, "DRIVE_KEY", + f"{shellmenu.CLASSES}\\Drive{suffix}") + monkeypatch.setattr(shellmenu, "DIRECTORY_KEY", + f"{shellmenu.CLASSES}\\Directory{suffix}") + import winreg + + shellmenu.install("C:\\icons\\offloader.ico") + shellmenu.uninstall() + + for key in (shellmenu.DRIVE_KEY + "\\command", shellmenu.DRIVE_KEY, + shellmenu.DIRECTORY_KEY + "\\command", + shellmenu.DIRECTORY_KEY): + with pytest.raises(OSError): + winreg.OpenKey(winreg.HKEY_CURRENT_USER, key).Close() + + +@pytest.mark.skipif(sys.platform != "win32", reason="registry is Windows-only") +def test_installing_twice_replaces_rather_than_duplicates(monkeypatch): + """Re-running `shell --install` after an upgrade must repoint the command + at the new location, not fail and not leave the old one.""" + suffix = "\\shell\\OffloaderTwiceTest" + monkeypatch.setattr(shellmenu, "DRIVE_KEY", + f"{shellmenu.CLASSES}\\Drive{suffix}") + monkeypatch.setattr(shellmenu, "DIRECTORY_KEY", + f"{shellmenu.CLASSES}\\Directory{suffix}") + import winreg + + try: + shellmenu.install("one.ico", command='"first.exe"') + shellmenu.install("two.ico", command='"second.exe"') + + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, + shellmenu.DRIVE_KEY) as key: + assert winreg.QueryValueEx(key, "Icon")[0] == "two.ico" + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, + shellmenu.DRIVE_KEY + "\\command") as key: + assert winreg.QueryValue(key, None) == '"second.exe" "%V"' + finally: + shellmenu.uninstall() + + @pytest.mark.skipif(sys.platform != "win32", reason="registry is Windows-only") def test_uninstalling_what_is_not_there_is_not_an_error(monkeypatch): monkeypatch.setattr(shellmenu, "DRIVE_KEY",