Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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 |
Expand Down
42 changes: 41 additions & 1 deletion src/offloader/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down
36 changes: 34 additions & 2 deletions src/offloader/gui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand Down
8 changes: 7 additions & 1 deletion src/offloader/gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
180 changes: 180 additions & 0 deletions src/offloader/shellicon.py
Original file line number Diff line number Diff line change
@@ -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("<BBBBHHII", dimension, dimension, 0, 0, 1, 32,
len(image), offset)
offset += len(image)
return (struct.pack("<HHH", 0, 1, len(images)) + entries
+ b"".join(images))


def default_path() -> 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
Loading
Loading