Skip to content

Chore/rebrand squatchlab 78#3

Open
squatchlab wants to merge 33 commits into
mainfrom
chore/rebrand-squatchlab-78
Open

Chore/rebrand squatchlab 78#3
squatchlab wants to merge 33 commits into
mainfrom
chore/rebrand-squatchlab-78

Conversation

@squatchlab
Copy link
Copy Markdown
Owner

No description provided.

bmags and others added 30 commits April 30, 2026 16:31
Closes the QSettings collision the audit flagged. The bare brand
'HDZero/Programmer' was shared with the macOS upstream — a host running
both forks would see one tool's autobackup toggle clobber the other's.

New scope: lab.squatch / hdzero-programmer-linux. File now lands at
~/.config/lab.squatch/hdzero-programmer-linux.conf.

Migration: app_settings.migrate_settings_once() runs in main() after
_install_excepthook(). On first launch under the new scope it copies all
keys from the legacy QSettings('HDZero', 'Programmer') store into the
new one, then writes a sentinel '_migrated_from_legacy_org' so reruns
short-circuit. Existing keys in the new scope are NOT overwritten — if a
user somehow set the new scope before migration ran, their explicit
choice wins. Legacy file is left intact so a downgrade still finds its
data.

New module app_settings.py centralizes the org/app constants so
main.py and internet_panel.py stop duplicating them. Added to
[tool.setuptools].py-modules and the AppImage inject list. CLAUDE.md
module list bumps from five to six modules and the autobackup gotcha
points at the new helper.

Tests: 5 new in tests/test_app_settings.py covering scope path, legacy
copy, idempotency via sentinel, no-overwrite of new-scope values, and
no-legacy-data noop.

Closes #33.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PyQt6 6.8 wheels dlopen libGL.so.1 from QtGui at import time, even when
QT_QPA_PLATFORM=offscreen. Earlier CI runs all sat queued because no
Forgejo runner was registered, so this never surfaced. With the runner
now online (lab01-hdzero), the smoke job's pytest step fails on:

  E   ImportError: libGL.so.1: cannot open shared object file:
      No such file or directory

caught at tests/test_excepthook.py and tests/test_version_consistency.py
collection time (both import main, which imports PyQt6.QtGui).

Adds libgl1 to the apt-get list. Comment updated to flag why this
package is required despite headless Qt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CI surfaced a real bug now that the runner is online:

  FAILED tests/test_app_settings.py::test_migrate_copies_legacy_keys_once
  AssertionError: assert True is False

Two distinct fixes, both in scope for the same problem:

1. `migrate_settings_once()` now calls `new.sync()` after writing the
   migrated keys + sentinel. QSettings auto-syncs lazily; a same-process
   reader constructing a fresh `QSettings(SETTINGS_ORG, SETTINGS_APP)`
   right after migration would otherwise miss the in-flight values until
   the timer fires (or the original instance gets GC'd). The first user
   launch under v0.3 hits this path exactly once, so getting it right
   matters even outside the test.

2. `tests/test_app_settings.py::isolated_config` now pins the QSettings
   user-scope IniFormat path via `QSettings.setPath()` instead of relying
   on `XDG_CONFIG_HOME` alone. PyQt6's QStandardPaths caches the resolved
   config dir per-process, so an env var monkeypatched mid-suite doesn't
   always reach a QSettings constructed later — which is why the legacy
   keys appeared empty to `migrate_settings_once()` under pytest. Reset
   on teardown so the override doesn't leak to a subsequent test.

Refs #33 (the QSettings reverse-DNS scope feature this test is exercising).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tests

PR #61's first attempt (XDG isolation + QSettings.setPath) didn't work:
PyQt6's QStandardPaths cache + the autouse QCoreApplication fixture
race meant the QSettings instances inside migrate_settings_once still
read from a path the test couldn't predict. CI still showed:

  FAILED tests/test_app_settings.py::test_migrate_copies_legacy_keys_once
  AssertionError: assert True is False

Refactor:

1. `migrate_settings_once()` now takes optional `new=` and `old=`
   QSettings kwargs. Production path is unchanged (defaults call
   `settings()` and construct the legacy scope), but tests can inject
   QSettings instances pinned to explicit on-disk paths via the
   `QSettings(filename, IniFormat)` overload — sidestepping
   QStandardPaths entirely.

2. Test suite rewritten to use a `settings_pair` fixture that returns
   two empty IniFormat QSettings backed by tmp_path files. Hermetic;
   no env var monkeypatching required. Added one extra test that
   confirms `settings()` returns a handle with the right organization
   + application names (so the reverse-DNS pin is enforced from both
   directions).

Refs #33.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
silent child

`for line in proc.stdout:` blocks on readline until EOF. A wedged
flashrom that prints nothing after starting (or a test like
`echo started; sleep 30`) would burn through the entire wall-clock
budget before the deadline check ever ran — which is why
test_run_admin_streaming_terminates_on_timeout has been failing on the
new Forgejo runner with `timeout path took too long: 30.0s` (real
sleep duration).

Replaces the readline loop with a `select.select(..., poll=1.0)` loop
that wakes up at least once per second to re-check the deadline. Reads
are now via `os.read` on the raw fd; line splitting happens in-process
on a small buffer. EOF handling preserved (any unterminated tail is
flushed via line_cb before falling through to proc.wait).

Also factored the SIGTERM->SIGKILL escalation into a small inner
helper so both the streaming-loop timeout path and the
post-stdout-close timeout path share the exact same cleanup.

Behaviour-equivalent for happy-path runs that print regularly; only
changes the silent-child case from "hangs until EOF" to "kills on
deadline." Production matters too: a flashrom that wedges mid-erase
without writing further markers could otherwise hold the GUI for the
default 600s timeout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CI smoke + appimage jobs use actions/upload-artifact@v4. With the
Forgejo runner online (lab01-hdzero), the action errors with:

  GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+
  and download-artifact@v4+ are not currently supported on GHES.

Forgejo emulates the GitHub Actions API enough to run most workflows,
but the v4 line of upload-artifact requires GitHub's hosted artifact
service that GHES (and Forgejo by extension) doesn't expose. The v3
line uses the older API and works.

The bump from v3 -> v4 (commit f039ee5, "chore: NIT bundle +
actions/upload-artifact v4") was speculative — CI was queued forever
on a missing runner at that point so the regression never showed up.
Now that CI executes, roll back to v3 across all three usages: the
coverage-xml upload in smoke, and the AppImage uploads in both ci.yml
and release.yml.

If we ever migrate to a hosted-artifact-capable Forgejo (or move to
forgejo/upload-artifact@v4 which is API-compatible with v4), this can
be re-bumped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
python-appimage==0.34 imports distutils.dir_util in fs.py. distutils
was removed from the standard library in Python 3.12, so the appimage
job's `pipx install 'python-appimage==0.34'` succeeds but the build
crashes immediately:

  ModuleNotFoundError: No module named 'distutils'

Caught the moment the runner came online (CI was queued forever
before, so the regression sat hidden). 1.4+ uses pathlib/shutil
instead of distutils. Bound to <2 so a major-version SemVer break
trips a deliberate revisit.

Both ci.yml (PR + main pushes) and release.yml (v* tag pushes) bumped
in lockstep so a tag-cut release doesn't fall off a different cliff.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
python-appimage 1.4+ uses an externally-installed appimagetool which
in turn needs `mksquashfs` from the squashfs-tools package. The bump
to 1.x exposed:

  mksquashfs command is missing but required, please install it

0.34 may have shipped its own bundled tool; 1.x explicitly delegates.
Both ci.yml (smoke + appimage) and release.yml (v* tag pushes) need
the package since both run the same build script.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same root cause as the smoke job (fixed in PR #60): PyQt6.QtGui
dlopens libGL.so.1 even when the eventual exec is `--version` /
`--check-rule` and never paints. The smoke launch step:

  out=$(./dist/HDZeroProgrammer-x86_64.AppImage --version)

triggers the imports before argparse runs, so it errors with:

  ImportError: libGL.so.1: cannot open shared object file

Both ci.yml's `appimage` job and release.yml's `release` job build +
launch-smoke the AppImage; both need libgl1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous round added libgl1; surfaced the next missing one:

  ImportError: libEGL.so.1: cannot open shared object file

PyQt6.QtGui dlopens an entire Qt platform stack at import. The smoke
job already installed libgl1 + libegl1 + libxkbcommon0 + libdbus-1-3 +
libfontconfig1 + libxcb-cursor0; the appimage and release jobs need
the same set because the launch-smoke step (`--version` / `--check-rule`)
imports PyQt6 before argparse can short-circuit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…5, #37)

Closes the architecture+ops bundle from the audit:

- #25: Four near-duplicate `*Worker(QThread)` classes
  (LoadDevices/LoadFirmwares/LoadImage/DownloadFirmware) in
  internet_panel.py shared identical shape but had drifted timeouts
  (15/15/10/30s, copy-pasted) and no shared retry policy.
- #37: A single transient network blip forced the user to click
  Retry. Quality-of-life fix paired with the collapse.

Replaces all four with one `HttpWorker(QThread)` taking a `parser`
hook (JSON-shaped result), `stream_to_temp_bin` (firmware download),
or neither (raw bytes — image fetch). Existing call sites updated to
the same shape; per-call timeout is preserved (image keeps 10s,
download keeps 30s, list endpoints take the new 15s default).

Retry policy: `retries` default 2 + `backoff` default 1.0s gives a
1s, 2s exponential ramp before giving up. `time.sleep` runs on the
worker thread so the GUI doesn't block. Each retry surfaces via a new
`log` signal that InternetPanel forwards into `status_box`, so a user
investigating a flake sees the attempts.

internet_panel.py LOC: 491 → 423 (-68 lines, easily clears the ≥25
acceptance criterion).

Tests: 7 new in tests/test_http_worker.py covering parser path, raw
bytes, streamed download, retry-then-success, retry-exhausted,
default retry count, default timeout. Same `worker.run()`-synchronous
pattern the flash integration tests use; `requests.get` and
`time.sleep` monkeypatched per-test.

Closes #25, closes #37.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closes the BLOCKER from the audit. Every test up to now mocked
flashrom via tests/fixtures/fake_flashrom.sh; a flashrom upgrade that
broke the argv contract or the stdout phase markers FlashWorker greps
for would ship a broken AppImage with green CI.

New `.forgejo/workflows/hw-test.yml` runs the read -> pad -> write ->
verify -> restore round-trip against real silicon. Gating:

- `runs-on: [ubuntu-22.04, hdzero-hw]` so the job is naturally inert
  on the default `lab01-hdzero` runner (which advertises only
  `ubuntu-22.04`). Adding a hardware runner is opt-in.
- `on: push: branches: [main]` + `workflow_dispatch` only. PR runs
  are deliberately excluded — running arbitrary contributor changes
  against real hardware is a bricking foot-gun.
- `restore` step runs with `if: always()` so an aborted job still
  leaves the VTX bootable. Acceptance criterion: idempotent teardown.
- All four flashrom transcripts (read/write/verify/restore) upload
  as a build artifact (`hw-test-transcripts`) for post-mortem.

The workflow uses `make_padded_image_1mib` from flash_ops to produce
the 1 MiB-padded blob, which means a regression in the padding logic
would surface here too rather than only in unit tests.

`docs/HARDWARE-CI.md` documents the runner setup: hardware wiring,
udev rule install, Quadlet stanza with USB pass-through, the
`/var/lib/hdzero-hw/known-good-firmware.bin` staging path, label
matrix, and the rationale for not gating on PRs. References the
existing CH341A udev rule and packaging/install-udev.sh so the
hw-runner setup reuses what's already in the repo.

Closes #22.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Audit MINOR ACQUISITION ticket. The fork uses the bare brand "HDZero"
across pyproject name, README, app title, AppImage filename, and
Forgejo repo slug. Divimath Inc. owns the product trademark; this
project is unaffiliated. An acquirer's diligence team will ask the
question — better to find a documented "open with contingency" than
a silent gap.

`docs/legal/trademark.md`:
- Catalogues every place the brand surfaces (so a future renaming pass
  has a checklist).
- Lists the open research questions: USPTO TESS lookup, Divimath
  trademark policy, maintainer risk-tolerance call.
- Names a concrete rename contingency (`vtx-programmer-linux`) with
  the file-by-file delta — including reuse of `migrate_settings_once`
  for the QSettings scope hop.
- States the disposition: keep the name today, point diligence at
  this doc, execute the contingency on a cease-and-desist trigger.

CONTRIBUTING.md gets a one-line pointer at the trademark file from
the License section so contributors see the exposure before mailing
patches with their preferred branding.

Closes #43.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closes the QUALITY MAJOR from the audit (#28). Pyflakes catches
undefined names; mypy catches signature mismatches, Optional handling,
and the long tail of "None where Path was expected" bugs that ship as
AttributeError at runtime — and for a tool that flashes hardware,
type discipline on FlashWorker, the privilege-escalation argv
builder, and `_open_flash_log` is cheap insurance.

Config:

- `[tool.mypy]` checks all six top-level modules.
- `[[tool.mypy.overrides]]` for `flash_ops`/`udev_check`/`app_logging`/
  `app_settings` runs strict mode (full signature typing,
  Optional-strict, no implicit Any).
- `[[tool.mypy.overrides]]` for `main`/`internet_panel` enforces
  `disallow_untyped_defs` — every function signature is typed but UI
  glue avoids the Any ocean a full strict pass would produce.
- PyQt6 namespace marked `ignore_missing_imports`. Community
  PyQt6-stubs lags the wheel cadence and breaks CI when out of sync;
  treating Qt symbols as Any is a deliberate trade-off documented in
  the config comments.

Dev deps add `mypy>=1.10` + `types-requests`. CI runs `mypy` after
ruff and before gitleaks; first stable run becomes the new floor.

Also: `py_compile` step adds `app_settings.py` (missed in the original
list when the module landed in PR #58).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
First mypy run on the Forgejo runner surfaced 55 errors across main.py
and internet_panel.py — every Qt callback would need a return-type
annotation under `disallow_untyped_defs = true`. The strict block on
the four safety-critical modules (flash_ops / udev_check / app_logging
/ app_settings) passed cleanly with zero errors.

Loosens the UI override to `disallow_untyped_defs = false` +
`check_untyped_defs = false`. The result: mypy still catches cross-
module signature drift (e.g., a flash_ops.py change that breaks main.py
callers — the original concrete value of typing UI code at all) but
doesn't force an annotation sweep through every QWidget subclass as
part of this PR.

Tightening main + internet_panel to full strict typing is a deliberate
follow-up — call out in `pyproject.toml [tool.mypy]` comments. The
first stable mypy run becomes the new floor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rict misses

Second mypy run on the runner surfaced the real shape of the gap.

flash_ops.py was supposed to be strict-clean but had four genuine
strict-mode misses:
- `run_admin` returned `subprocess.CompletedProcess` without the
  `[str]` type argument (the function does pass `text=True`).
- `FlashWorker.run`, `BackupWorker.run`, and the inner `on_line`
  callback all lacked `-> None`. QThread's `.run()` is overridden, so
  mypy can't infer the return type from a base class with `Any`-typed
  signatures (PyQt6 has no stubs). Added explicit `-> None` to all
  three.

internet_panel.py errors were 13 missing-return-type warnings on Qt
slots and helpers. The previous attempt (`disallow_untyped_defs =
false` + `check_untyped_defs = false` override) did not suppress them
— mypy 1.10's override resolution evidently still applies the
`no-untyped-def` check despite both flags being false. Switched to
`ignore_errors = true`. mypy still parses both modules so a real
import-time break surfaces, but per-function annotation gaps stop
failing CI. A follow-up sweep can re-enable the gate later.

The strict block on flash_ops, udev_check, app_logging, app_settings
is unchanged and (after this PR's fixes to flash_ops) passes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… (#68)

Replaces the workaround `ignore_errors = true` from PR #65 with full
`disallow_untyped_defs = true` + `check_untyped_defs = true`.

Annotated 17 functions in main.py and 18 in internet_panel.py — most
are Qt slots / callbacks that simply gain `-> None`. A few cases
needed real type information:

- `LocalPanel.__init__` callbacks now typed as
  `Callable[[], None]` and `Callable[[str, bool], None]`.
- `_install_excepthook`'s inner `hook(exc_type, exc, tb)` typed
  per the `sys.excepthook` contract: `type[BaseException]`,
  `BaseException`, `Optional[TracebackType]`.
- `main(argv=...)` typed as `Optional[list[str]]` matching argparse.
- `on_devices_ok` / `on_fw_ok` parameterized their `list` arg as
  `list[dict[str, Any]]` (the API JSON shape).

The strict block on the four safety-critical modules
(flash_ops/udev_check/app_logging/app_settings) is unchanged; this
just brings UI plumbing under the same kind of signature-checking
discipline. Cross-module drift between flash_ops and main now
surfaces from mypy rather than at runtime as an AttributeError.

Closes #68.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CI mypy round on PR #69 surfaced two real type gaps:

- main.py:316 — `open_flash_log` returns `Tuple[Path, IO[str]]`, so
  mypy infers `_flash_log_path: Path` and `_flash_log_fh: IO[str]`.
  The OSError fallback assigning `None` then errors. The intent was
  always Optional — the close path uses `getattr(... None)` and the
  write path early-returns when `fh is None`. Declare both attributes
  upfront in `MainWindow.__init__` as `Optional[Path]` /
  `Optional[IO[str]]`. Adds `IO` to the typing import.
- internet_panel.py:140-141 — `List[dict]` is bare-generic. The API
  shape is `list[dict[str, Any]]` (per the slot signatures already
  fixed in this branch). Parameterize for consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…)' (#69) from ticket-68-mypy-tighten into main
…RITY)

Sweep four top-level docs against the post-audit batch (8+ PRs, app_settings.py
landed, HW-CI workflow shipped). Fixes:

CLAUDE.md
- open-blocker backlog #19-#23 -> #19-#21 (#22/#23 closed)
- "five modules" py_compile list -> six (app_settings.py)
- CI cascade missing mypy step now noted with override rationale
- hw-test workflow added to CI/release section

README.md
- "Five Python modules" -> six, with app_settings.py bullet
- "43 tests" -> 64
- CI section gains mypy + gitleaks + AppImage smoke flags

CHANGELOG.md ([Unreleased])
- Add app_settings reverse-DNS migration (#33), HttpWorker collapse
  (#25/#37), mypy gate (#26/#68), hw-test workflow (#22),
  trademark.md (#43), python-appimage 1.x bump, full Qt runtime libs
  in CI, select.select() fix in run_admin_streaming
- Move "upload-artifact bumped to v4" to a Reverted section -- Forgejo
  doesn't support the v4 upload protocol yet (rolled back to v3)

SECURITY.md
- In-scope module list missing app_settings.py

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…DME, CHANGELOG, SECURITY)' (#70) from docs/post-marathon-drift-refresh into main
Bump pyproject + main.__version__ to 0.3.0. Move CHANGELOG
[Unreleased] body into [0.3.0] - 2026-04-30 block. SECURITY.md
supported-versions table → 0.3.x. README reorder: lead with
AppImage download (recommended), demote pip-install to from-source
appendix; collapse local AppImage-build section to maintainer note.

Closes the audit-batch documentation cycle. Firmware-trust trio
(#19/#20/#21) remains gating set for v0.4.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ctrl-C on the terminal eventually unwinds out of app.exec(), and the
excepthook installed by _install_excepthook then routed it through the
crash-dialog path — popping "HDZero Programmer crashed" with a
traceback ending at `return app.exec()`.

Special-case KeyboardInterrupt at the top of the hook: call
QApplication.quit() and return early, skipping the dialog and the
stderr dump. Real crashes still get the dialog + transcript-dir hint.

Closes #74

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The "From source" and Development sections recommended `pip install
--user`, which fails on modern distros under PEP 668 with
`error: externally-managed-environment`. They also omitted system-level
deps (xcb-util-cursor for Qt 6.5+) and the graphical-session prereq,
leaving headless/TTY users stuck on `qt.qpa.xcb: could not connect to
display`.

Rewrite around `python3 -m venv .venv`, document the system deps in
Requirements, add a one-line graphical-session note pointing at
Moonlight/Sunshine/RDP/VNC for headless boxes, and drop the stale
"64 tests" count from Development.

AppImage path (Option A) unchanged.

Closes #76

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…#78)

The window title carried "by Gunther_FPV" since the upstream macOS port.
Fork has matured beyond drop-in adapter; rebrand the visible title to
SquatchLab while keeping Gunther's attribution intact in every place
MIT actually requires it (LICENSE, LICENSE-NOTES.md, README Credits) and
in places users actually read it (Help tab renders README at runtime, so
the Credits section remains visible inside the running app).

Also append a small SquatchLab footer to the README so the Help tab
inside the app renders a link to squatchlab.com — a more useful target
for click-through than the title bar.

Closes #78

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
bmags and others added 3 commits May 3, 2026 00:45
… (#74)' (#75) from fix/excepthook-keyboardinterrupt-74 into main
…v + system deps (#76)' (#77) from docs/readme-fromsource-venv-76 into main
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant