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..f942e40 --- /dev/null +++ b/tests/test_shell.py @@ -0,0 +1,367 @@ +"""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()) + + +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 + + +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('"') + + +# ----------------------------------------------------- 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 + 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_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", + f"{shellmenu.CLASSES}\\Drive\\shell\\OffloaderAbsent") + monkeypatch.setattr( + shellmenu, "DIRECTORY_KEY", + f"{shellmenu.CLASSES}\\Directory\\shell\\OffloaderAbsent") + assert shellmenu.uninstall() == []