Compare commits
213 Commits
v0.2.0-bet
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2c37095ef | ||
|
|
6f9be6a146 | ||
|
|
f0ea6211d1 | ||
|
|
1a9f078829 | ||
|
|
feccdc668c | ||
|
|
f8c1ccc756 | ||
|
|
7a57d3ecc6 | ||
|
|
9990a742d7 | ||
|
|
5485ddc3d9 | ||
|
|
406efb49f9 | ||
|
|
583b5c1566 | ||
|
|
c29b417f0d | ||
|
|
95f23da17f | ||
|
|
627d17cd91 | ||
|
|
71cc1ca4d5 | ||
|
|
2dd3548cac | ||
|
|
d8058ce02d | ||
|
|
35871a9a53 | ||
|
|
2d3566d8ff | ||
|
|
23e0a96257 | ||
|
|
3d42f6e895 | ||
|
|
f49ccf0ef2 | ||
|
|
8f1db2a6b6 | ||
|
|
f7b7c549dc | ||
|
|
40df517c0e | ||
|
|
391528daa5 | ||
|
|
e3cae9b541 | ||
|
|
4de129d347 | ||
|
|
3f4a908684 | ||
|
|
f64fd372d2 | ||
|
|
0f0419988c | ||
|
|
c53811a6c1 | ||
|
|
2698309458 | ||
|
|
bdade9f1a2 | ||
|
|
c82d66003d | ||
|
|
5e62dffc49 | ||
|
|
f0de9e3c55 | ||
|
|
3daad2e861 | ||
|
|
9400599ba6 | ||
|
|
ed85e4a17a | ||
|
|
374d7b0d22 | ||
|
|
003789e263 | ||
|
|
af192f03ec | ||
|
|
20e4271cd8 | ||
|
|
e296c5f5f9 | ||
|
|
c633932d04 | ||
|
|
f7c4eafafc | ||
|
|
22fc0f8fe6 | ||
|
|
5a0719a73e | ||
|
|
97a7e1921a | ||
|
|
bd097d1d23 | ||
|
|
c9940be0de | ||
|
|
c930658ce9 | ||
|
|
07606ae891 | ||
|
|
78b6e22f8a | ||
|
|
f046a97d28 | ||
|
|
8878623878 | ||
|
|
9fcd360a8c | ||
|
|
ffb92a403d | ||
|
|
89f75b27a2 | ||
|
|
25ea1ea67e | ||
|
|
720e2723c2 | ||
|
|
1e89644f76 | ||
|
|
3ee756ede2 | ||
|
|
9f9606922a | ||
|
|
6e81ba0214 | ||
|
|
ddc85c6f21 | ||
|
|
ec5b4d849c | ||
|
|
40404e3c0d | ||
|
|
4323df0d22 | ||
|
|
677858e8c6 | ||
|
|
4773bcf7b8 | ||
|
|
6b549ef228 | ||
|
|
870dc33f5a | ||
|
|
3c72507366 | ||
|
|
f6dc4556e2 | ||
|
|
ba870ea8e7 | ||
|
|
4d7531b1c1 | ||
|
|
e2a9a37dd3 | ||
|
|
f9e2aa23e3 | ||
|
|
2a3e37501a | ||
|
|
9f4122cba0 | ||
|
|
bf284e4fe5 | ||
|
|
e0b4e3040c | ||
|
|
a973433061 | ||
|
|
d8f4d08385 | ||
|
|
10a27116d5 | ||
|
|
efc0b6d293 | ||
|
|
398706c4d6 | ||
|
|
c47436ec46 | ||
|
|
d8682612f3 | ||
|
|
c3d9ef2c01 | ||
|
|
887514ff6e | ||
|
|
88603f78fb | ||
|
|
cc67a97e54 | ||
|
|
bceae91abd | ||
|
|
c64cba47f5 | ||
|
|
fe2457842f | ||
|
|
50b82dbfee | ||
|
|
f51848f7c7 | ||
|
|
83f8ed51e3 | ||
|
|
f92f4d6ac6 | ||
|
|
2ae4d592c1 | ||
|
|
5f9673e7ba | ||
|
|
435350ad88 | ||
|
|
3de8b7d9c3 | ||
|
|
104d9a1e74 | ||
|
|
b6ea3183d1 | ||
|
|
6847eef18d | ||
|
|
e60a09286f | ||
|
|
87f634d7ed | ||
|
|
0a0ce37787 | ||
|
|
5b7e70ccbf | ||
|
|
833548224d | ||
|
|
7deb23a298 | ||
|
|
9909eedba3 | ||
|
|
63cb9fd908 | ||
|
|
529db96c7f | ||
|
|
e37faa2f5a | ||
|
|
c5240f14ac | ||
|
|
329e17a49d | ||
|
|
891ec42ced | ||
|
|
c52c0086d8 | ||
|
|
dd3ffb10f9 | ||
|
|
c808e19956 | ||
|
|
da4753a20c | ||
|
|
d69b9406b1 | ||
|
|
053e5d9879 | ||
|
|
ae9a4c214e | ||
|
|
75fda99e3c | ||
|
|
4d107c4a09 | ||
|
|
b7c25efaad | ||
|
|
87c5352553 | ||
|
|
2c291ec82a | ||
|
|
7c5cf0c29b | ||
|
|
91ae12aea8 | ||
|
|
1023356ac0 | ||
|
|
493f953870 | ||
|
|
f5c160e880 | ||
|
|
c6c1bfc145 | ||
|
|
049068add9 | ||
|
|
596b8e4045 | ||
|
|
c8dc371571 | ||
|
|
1fd3fdee90 | ||
|
|
d2e21b0451 | ||
|
|
8c995e74c5 | ||
|
|
248c213fd2 | ||
|
|
63e5615662 | ||
|
|
1c02a14e3c | ||
|
|
26a03081b9 | ||
|
|
2f604312a2 | ||
|
|
e7716baa00 | ||
|
|
f8717260d6 | ||
|
|
6b71fb46da | ||
|
|
3c7f2e0ccc | ||
|
|
b07faabb91 | ||
|
|
e9a8aca9ed | ||
|
|
4188f4fa7f | ||
|
|
5c59b33024 | ||
|
|
caa81b324c | ||
|
|
c088291bbc | ||
|
|
11d099b8f6 | ||
|
|
85f88b6df3 | ||
|
|
39bc973601 | ||
|
|
757499d036 | ||
|
|
c2965697a9 | ||
|
|
57d8708dd9 | ||
|
|
2f7dc12f13 | ||
|
|
f292ab6324 | ||
|
|
8276f7caeb | ||
|
|
be656915c7 | ||
|
|
63f4b9ea01 | ||
|
|
638efa8073 | ||
|
|
76713ad468 | ||
|
|
8de37d873a | ||
|
|
ab4d0956f9 | ||
|
|
c5959a8dd9 | ||
|
|
b9d503e8ec | ||
|
|
89ac8de426 | ||
|
|
2cd927037d | ||
|
|
df0074f069 | ||
|
|
d1387aa892 | ||
|
|
3c493eedcb | ||
|
|
49d28a4216 | ||
|
|
7521879b4c | ||
|
|
dff4edde02 | ||
|
|
4a41e001fc | ||
|
|
0e175ba5b0 | ||
|
|
bc840c0136 | ||
|
|
e66bde3813 | ||
|
|
d482ce6c04 | ||
|
|
97a30b3653 | ||
|
|
f1399edbfe | ||
|
|
a3c6f85f1f | ||
|
|
dd627130b3 | ||
|
|
89d713b87f | ||
|
|
c9bf43936e | ||
|
|
c4aae862e4 | ||
|
|
6abe21df94 | ||
|
|
38b1160d3f | ||
|
|
02470bb85f | ||
|
|
b10c62ac65 | ||
|
|
7cdf5a9851 | ||
|
|
378d51a942 | ||
|
|
1b55660e57 | ||
|
|
1ed5e27dcd | ||
|
|
ce26e5b9ab | ||
|
|
951b315206 | ||
|
|
8f1f269133 | ||
|
|
d8866c86db | ||
|
|
dfb09f2a1a | ||
|
|
1bc9f7536e | ||
|
|
2839b890bb |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,5 +1,6 @@
|
||||
.DS_Store
|
||||
/result
|
||||
/release
|
||||
debug.log
|
||||
output.log
|
||||
verbose.log
|
||||
@ -13,3 +14,4 @@ tmux-client-14578.log
|
||||
~/PDF/seed1.pdf
|
||||
tmux-client*
|
||||
**/*.afdesign~lock~
|
||||
.tmp/
|
||||
|
||||
53
AGENTS.md
53
AGENTS.md
@ -1,53 +0,0 @@
|
||||
# SeedEtcher – Quick Operator Notes
|
||||
|
||||
## Purpose
|
||||
- Air‑gapped Pi Zero firmware (fork of SeedHammer) to scan SeedQR/CompactSeedQR or manual mnemonics, validate them, and produce PDF seed plates for toner-transfer → acid etch. Seed generation is out of scope.
|
||||
|
||||
## What Works (last confirmed Mar 2024)
|
||||
- Scan QR seed phrases on the Pi.
|
||||
- Generate and capture PDFs from the Zero via USB using `scripts/capture_print.sh`.
|
||||
|
||||
## Environments
|
||||
- Dev host: macOS; builds run inside an Ubuntu VM with Nix.
|
||||
- Target: Raspberry Pi Zero (ARMv6, musl).
|
||||
|
||||
## Images & USB modes
|
||||
- `image` (prod) vs `image-debug` (dev with serial/reload hooks). Both boot in USB gadget mode for shell access; see `docs/dev/build-matrix.md` for the full matrix.
|
||||
- Host-mode variants: `image-host` / `image-host-debug` switch OTG to host and load `usblp` for `/dev/usb/lp0` printers; use UART for shell in host mode.
|
||||
|
||||
## Build → Flash
|
||||
- Build image in Ubuntu VM: `nix build .#image-debug --impure` (outputs `result/seedetcher-debug.img`).
|
||||
- Flash from mac: `scripts/flash-sdcard.sh` pointing at the built image.
|
||||
|
||||
## Run/Debug on Pi
|
||||
- Start controller: `./controller < /dev/ttyGS1 >> /log/debug.log 2>> /log/debug.log &`
|
||||
- Capture print (PDF over USB serial): `scripts/capture_print.sh`
|
||||
- Test page layout without GUI: `./controller -test-createPageLayout ...` (see flags in `cmd/controller/main.go` and `testutils`).
|
||||
|
||||
## Testing
|
||||
- Lightweight, hardware-free: `GOCACHE=/tmp/gocache ./scripts/test-lite.sh` (skips libcamera/wshat CGO deps; mostly sanity-builds packages).
|
||||
- Full hardware-dependent tests are not wired up; avoid `driver/libcamera` locally unless deps installed.
|
||||
- GUI loop now runs via the `Screen` state machine starting at `MainMenuScreen`; add new UI by implementing `Screen` and wiring transitions instead of expanding the old `mainFlow`.
|
||||
- Host-side tests: use `GOCACHE=/tmp/gocache ./scripts/test-lite.sh` to skip hardware/CGO deps (`driver/libcamera`, `driver/wshat`).
|
||||
|
||||
## Host CLI (no hardware)
|
||||
- Generate plates locally: `go run cmd/cli/main.go -w singlesig|multisig -o ~/outdir [-verbose]`
|
||||
|
||||
## Key Directories
|
||||
- `cmd/controller/` – GUI entrypoint; `platform_rpi.go` (camera/display/printer), `platform_dummy.go` for non-ARM.
|
||||
- `cmd/cli/` – host PDF generator.
|
||||
- `gui/` – UI flow, layout, widgets.
|
||||
- `seedqr/` – SeedQR & CompactSeedQR encode/decode.
|
||||
- `bip39/`, `bc/` – mnemonic and descriptor parsing/validation.
|
||||
- `printer/` – plate rendering and page layout (pdfcpu/gofpdf).
|
||||
- `driver/` – libcamera, zbar, DRM LCD, wshat buttons (no engraver stack).
|
||||
- `scripts/` – flash, capture print, printer helpers.
|
||||
- `docs/` – overview and development notes (build/debug/Nix tips).
|
||||
|
||||
## Hardware/IO Notes
|
||||
- Printer is accessed via `/dev/usb/lp0` (see `scripts/print_file.sh`, `scripts/query_printer.sh`).
|
||||
- Camera uses libcamera + zbar; display via DRM LCD driver; input via wshat buttons/joystick.
|
||||
|
||||
## State / Gaps
|
||||
- No automated tests run recently; manual flows verified up to QR→PDF capture.
|
||||
- Flake pin may need hash bumps when deps change; see `docs/development.md` for update commands.
|
||||
127
CHANGELOG.md
127
CHANGELOG.md
@ -1,7 +1,130 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
- (no changes yet)
|
||||
## Release v0.3.0-beta.1
|
||||
### User-facing highlights (since v0.2.0-beta.2)
|
||||
- New SeedEtcher Transfer Stack workflow cuts prep and etch time in half.
|
||||
- Plate layout overhaul (seed + descriptor sides) with etch-first styling:
|
||||
- custom-designed `SeedEtcher-Regular` plate font integration,
|
||||
- circular QR modules with square islands,
|
||||
- updated seed/descriptor text anchors, margins, and metadata placement.
|
||||
- Print options UI before printing: selectable `DPI` (`1200`/`600`), `Invert`, and `Mirror`.
|
||||
- Host print language selection: `PCL` (default) or `PS` (PostScript), plus a native PS writer path.
|
||||
- Brother HBP support integrated into standard images (`image`, `image-debug`, `image-gadget`, `image-gadget-debug`) with lazy on-demand runtime prep.
|
||||
- HBP-enabled sessions lock print flow to Brother HBP at `600 DPI` and bypass language/DPI selection for that session.
|
||||
- SD detach/runtime handling was hardened for HBP and host print paths.
|
||||
- Multisig descriptor-share mode migrated to interoperability-first `UR/XOR` for supported families, with explicit fallback to full descriptor UR for unsupported families.
|
||||
- `SE1` fallback was removed from active backup output path.
|
||||
- Optional etch stats page added to print output (CLI + UI toggle), with area/coverage and PSU guide sections.
|
||||
- Print progress behavior was stabilized across `PCL`/`PS`/`HBP` (including etch-stats page accounting and stage ordering).
|
||||
- Added debug-only `Load Test Wallet` action flow to speed testing without repeated scan loops.
|
||||
- Transfer cutbox layout/workflow introduced for easier masking and cutting.
|
||||
- Fixed deterministic 2x2 page/batch/count math across UI and controller host/HBP paths.
|
||||
- Direct host PCL/PS output alignment corrected to match intended cutbox placement (top/cut-mark clipping and PCL horizontal shift fixes).
|
||||
|
||||
### Detailed notes
|
||||
- HBP runtime integration is now part of the standard image outputs (`image`, `image-debug`, `image-gadget`, `image-gadget-debug`); separate runtime/spike image variants are removed.
|
||||
- Internal CUPS/HBP runtime naming was normalized from `cups-spike` to `cups-runtime` across flake wiring, init/runtime env, helper scripts, and controller call sites.
|
||||
- Runtime helper/script names are now:
|
||||
- `cups-runtime-bootstrap`
|
||||
- `cups-runtime-ram-feasibility`
|
||||
- `/cups-runtime.env`
|
||||
- `/cups-runtime-store-paths`
|
||||
- Added release-focused HBP operations doc: `docs/dev/hbp-runtime.md` and removed the old feasibility spike diary doc.
|
||||
- Added `docs/printers.md` as a consolidated Brother printer capability reference used for HBP/PCL/PS field validation and support triage.
|
||||
- Added a debug-only `Load Test Wallet` action flow in UI to inject fixture wallets without repeated scan loops during print/recovery testing.
|
||||
- HBP-enabled startup path now locks print flow to Brother HBP at `600 DPI` and bypasses language/DPI choice screens for that session.
|
||||
- Print progress behavior was stabilized across `PCL`/`PS`/`HBP`, including correct etch-stats page accounting and stage ordering.
|
||||
- Gadget-mode HBP capture path was restored and `scripts/capture_print.sh` now supports replay/conversion flow for captured CUPS raster jobs.
|
||||
- HBP bootstrap/runtime path is lazy on-demand (not eager at boot) and SD-detach handling was hardened for both HBP and PCL/PS flows.
|
||||
- Build/runtime no longer depends on an external `brlaser-root.tar.gz` artifact; integrated packaged runtime is used by default with optional drop-in fallback.
|
||||
- Added developer utility scripts: `scripts/analyze-go-bloat.sh` and `scripts/cleanup-nix-images.sh`.
|
||||
- Debug-image diagnostics:
|
||||
- added `export-logs-to-sd` helper to write a privacy-first, plain-directory snapshot on the boot partition (`SE-LOGS-LATEST`),
|
||||
- export payload is now strict allowlist only: `init_debug.log`, CUPS logs, `dmesg`, and manifest metadata (no `/proc`/`/tmp` dump),
|
||||
- export now attempts a best-effort PJL capability snapshot from `INFO VARIABLES` when printer access is available (skipped for boot-triggered exports),
|
||||
- UI error screens now trigger best-effort SD log export (rate-limited) to improve field-debug capture on failures,
|
||||
- debug images now auto-run this export with boot-time retries and again when `controller` exits, so UART/manual shell access is not required to collect logs,
|
||||
- default export folder name is now `SE-LOGS-<timestamp>` at boot-partition root for easy retrieval on desktop OSes,
|
||||
- wired as debug-only tooling (not shipped in non-debug images).
|
||||
- Host-mode SD-detach safety note: diagnostic export is on-demand and best-effort; if SD is detached/unavailable, printing/runtime flow is unchanged and only log export fails.
|
||||
- Printing now supports explicit host-mode printer language selection:
|
||||
- `PCL` (default) or `PS` (PostScript) from print settings UI.
|
||||
- guidance shown in UI: if `PCL` prints blank pages, try `PS`.
|
||||
- Added native PostScript writer path for host mode (`/dev/usb/lp0`) with no external `gs`/`pdftops` dependency on device.
|
||||
- Host-mode PCL robustness:
|
||||
- when a `1200 DPI` send fails with `/dev/usb/lp0` `EIO`, controller now auto-retries the job once at `600 DPI`,
|
||||
- after such a failure, host PCL stays on `600 DPI` for the current printer session (reset on replug) to avoid repeated hard failures.
|
||||
- PostScript pipeline fixes and performance improvements:
|
||||
- corrected PS `imagemask` geometry/polarity behavior to match canonical page layout,
|
||||
- optimized PS streaming on Pi Zero (buffered writes + fast row hex encoding),
|
||||
- PS mode now honors selected DPI (600/1200) in print settings.
|
||||
- Descriptor share mode is now interoperability-first:
|
||||
- UR/XOR used for supported families (`2/2`, `2/3`, `2/4`, `3/5`, and generic `n-1/n`),
|
||||
- unsupported families fall back to full descriptor `UR:CRYPTO-OUTPUT` per descriptor plate.
|
||||
- SE1 fallback removed from active backup output path.
|
||||
- Backup flow now shows a `Warning` (not `Error`) for XOR-unsupported multisig families, explaining full-descriptor fallback behavior.
|
||||
- Compact single-sided 2-of-3 layout is wired to UR/XOR shares (seed QR + descriptor-share QR on one plate).
|
||||
- Compact layout tuning:
|
||||
- 24-word split `10/7/7`, 12-word split `6/6`,
|
||||
- descriptor-side warning block and updated custom font glyph support for arrows,
|
||||
- derivation path rendering uses apostrophe hardened markers (`'`) in plate text.
|
||||
- Descriptor plate QR placement is now explicit by top-left coordinates and size (including safe zone), with default size now aligned to `80 mm`.
|
||||
- Backup/recovery UX cleanup:
|
||||
- removed WID/SET lines from active screens,
|
||||
- scan UI shows deterministic `x/2` only in dedicated UR/XOR share-capture mode,
|
||||
- generic animated UR scans continue to show `%` progress.
|
||||
- Fingerprints review screen updates:
|
||||
- bold fingerprint rows,
|
||||
- 7 entries per page,
|
||||
- pagination line shown only when there are multiple pages.
|
||||
- Print confirmation screen now shows `Compact 2/3` line only for eligible `sortedmulti 2/3` jobs.
|
||||
- Added table-driven descriptor-share matrix tests across script/network variants for representative families (`2/3`, `2/4`, `3/5`, `4/5`, fallback `7/10`).
|
||||
- Added CLI/test fixtures for additional families: `multisig-2of2`, `multisig-3of4`, `multisig-4of7`, `multisig-5of7`.
|
||||
- Optional etch stats page added to print output (`-etch-stats-page` in CLI; toggle in print options UI).
|
||||
- Etch stats now include two sections:
|
||||
- area/coverage table per plate (`TONER`, `EXPOSED90`, `EXPOSED+MARGIN`, `%MASKED`, `%UNMASKED`),
|
||||
- PSU guide table per plate with `SET A MASKED` / `SET A UNMASKED`.
|
||||
- Etch stats model now assumes a fixed physical plate size of `100x100 mm` and reports both scenarios:
|
||||
- masked margin (only `90x90` center exposed),
|
||||
- unmasked margin (`90x90` exposure + outer margin exposure).
|
||||
- Etch defaults block added to stats page for bench setup:
|
||||
- `Na2SO4 100 g/L`, `34C`, `15 mm` electrode gap, `12 V` limit, `J=0.04 A/cm2`.
|
||||
|
||||
- Invert behavior fix: plate border remains black while interior content is inverted.
|
||||
- Page layout updates:
|
||||
- 4 mm inter-plate spacing,
|
||||
- top-anchored placement for partial pages,
|
||||
- no automatic plate scaling (plates remain true 90x90mm),
|
||||
- Letter layout switched to 2x2 (4 plates/page) to preserve fixed plate size.
|
||||
- Pi image packaging fix: include `font/seedetcher/SeedEtcher-Regular.ttf` in initramfs.
|
||||
- Host-mode print pipeline performance/memory refactor:
|
||||
- direct 1bpp plate-to-PCL streaming path (`/dev/usb/lp0`) without full-page raster buffers,
|
||||
- batched host rendering/sending to reduce peak RAM on larger jobs,
|
||||
- host default DPI set to 1200, gadget fallback path kept at 600.
|
||||
- Host print-progress behavior stabilized for batched sending (continuous/monotonic progress, compose marked once).
|
||||
- Test fixtures expanded with additional seed-only wallets: 12/15/18/21 words.
|
||||
- Added `singlesig-longwords` seed-only fixture for plate layout stress testing.
|
||||
- Docs updated to clarify host (direct 1bpp PCL) vs gadget (raster-to-PDF fallback) print paths.
|
||||
- Paper-size selection is now honored end-to-end in both host and gadget print pipelines (Letter stays 2x2, A4 stays 2x3).
|
||||
- Wallet label input limit reduced from 20 to 15 characters.
|
||||
- Additional plate typography tuning:
|
||||
- 11pt metadata/descriptor tracking support,
|
||||
- tighter number-column tracking on seed plates,
|
||||
- wider gutter between seed index numbers and words.
|
||||
- Print options UI added before printing:
|
||||
- selectable `DPI` (`1200`/`600`), `Invert` (`On`/`Off`), and `Mirror` (`On`/`Off`),
|
||||
- defaults set to `1200`, `On`, `On`,
|
||||
- options are now passed through controller print flow; non-PCL fallback remains capped at `600 DPI`.
|
||||
- Descriptor-side metadata/QR update:
|
||||
- top descriptor line now uses explicit concise fields (`TYPE/SCRIPT/NET/THRESHOLD/KEYS/KEY`) without full xpub text,
|
||||
- descriptor QR is bottom-anchored, centered on X, rendered with quiet zone, and enlarged (up to 80mm including quiet zone).
|
||||
- Singlesig layout update:
|
||||
- singlesig now renders as seed-only plates (no descriptor-side plate),
|
||||
- singlesig seed side uses a shifted word/QR layout variant with optional right-edge metadata (`path/script/net`) when a descriptor is present,
|
||||
- when descriptor is skipped in singlesig flow, no right-edge metadata line is printed.
|
||||
- Seed scan prompt copy clarified to `SeedQR or Mnemonic QR` to avoid implying OCR/manual-word camera entry.
|
||||
- Singlesig print default updated to produce two physical seed plates per job while preserving key-pagination semantics (`1/1` marker on both copies, not `1/2`/`2/2`).
|
||||
- Plate QR rendering tuning: circular data-module dot scale reduced to `0.7` (finder/alignment islands remain square) to increase etch-process headroom.
|
||||
|
||||
## Release v0.2.0-beta.2
|
||||
- Security dependencies bumped to address Dependabot alerts: `github.com/btcsuite/btcd` -> `v0.25.0`, `github.com/btcsuite/btcd/btcec/v2` -> `v2.3.6`, `github.com/btcsuite/btcd/btcutil` -> `v1.1.6`, and `golang.org/x/crypto` -> `v0.45.0` (plus related `x/sys`/`x/text` updates).
|
||||
|
||||
53
CONTRIBUTING.md
Normal file
53
CONTRIBUTING.md
Normal file
@ -0,0 +1,53 @@
|
||||
# Contributing
|
||||
|
||||
## DCO / Signoff Required
|
||||
|
||||
By contributing, you agree to the [Developer Certificate of Origin (DCO)](https://developercertificate.org/).
|
||||
|
||||
Every commit must include a `Signed-off-by` trailer that matches the commit author, for example:
|
||||
|
||||
```text
|
||||
Signed-off-by: Jane Doe <jane@example.com>
|
||||
```
|
||||
|
||||
Use Git signoff to add it automatically:
|
||||
|
||||
```bash
|
||||
git commit -s -m "your message"
|
||||
```
|
||||
|
||||
To make signoff automatic for all future commits:
|
||||
|
||||
```bash
|
||||
git config --global format.signoff true
|
||||
```
|
||||
|
||||
If you forgot signoff on the latest commit:
|
||||
|
||||
```bash
|
||||
git commit --amend -s --no-edit
|
||||
```
|
||||
|
||||
## Commit Message Style
|
||||
|
||||
Use lightweight Conventional Commits for new commits:
|
||||
|
||||
- `feat:` new behavior
|
||||
- `fix:` bug fix
|
||||
- `refactor:` internal change with same behavior
|
||||
- `docs:` documentation-only changes
|
||||
- `test:` tests-only changes
|
||||
- `build:` packaging/tooling/dependency changes
|
||||
- `chore:` maintenance tasks
|
||||
|
||||
Preferred format:
|
||||
|
||||
```text
|
||||
type(scope): short imperative summary
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
fix(release): write stamped image to release/ dir
|
||||
```
|
||||
218
LICENSE
218
LICENSE
@ -1,24 +1,202 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
1. Definitions.
|
||||
|
||||
For more information, please refer to <https://unlicense.org>
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
4
NOTICE
Normal file
4
NOTICE
Normal file
@ -0,0 +1,4 @@
|
||||
SeedEtcher
|
||||
Copyright (c) 2025-2026 Bainter (@BainterSAT) and contributors
|
||||
|
||||
This product includes software developed by Bainter and contributors.
|
||||
49
README.md
49
README.md
@ -2,15 +2,33 @@
|
||||
|
||||
SeedEtcher is an open-source, air-gapped system for creating durable Bitcoin backups by printing seed phrases, descriptors, and QR codes with a standard laser printer and permanently etching them into metal.
|
||||
It minimizes trust and attack surface by relying on offline hardware, simple materials, and a transparent, reproducible workflow instead of expensive dedicated machines.
|
||||
Once you get the hang of the workflow a double-sided plate can be done in 1.5–2h.
|
||||
|
||||
The project consists of:
|
||||
Starting with ***version b0.3*** prep-time and etch time halved!
|
||||
|
||||
## 1) SeedEtcher Controller
|
||||
The following things were substantially improved or added:
|
||||
|
||||
- Multisig uses descriptor-share backups (no full descriptor on a single plate). These wallet configs default to UR/XOR-compatible shares: `1/2`, `2/2`, `2/3`, `2/4`, `4/4`, `3/5`, and any `n-1/n`. This replaces b0.2's custom SE1 Shamir share method for interoperability. All other wallet types stay on full descriptor.
|
||||
- All Brother lasers are supported (even host-based). PCL/PS remains the recommended way to print. HBP (host based printing) is capped to 600dpi (memory limit of pi zero)
|
||||
All other brands that support true PCL or PostScript should work too. See: [printers.md](docs/printers.md)
|
||||
- Print output can be sent non-inverted and non-mirrored for checking before printing to transfer paper.
|
||||
- A new method (SeedEtcher Transfer Stack™) leverages the use of silicone sheets to reliably transfer toner masks to both sides of a metal plate at once.
|
||||
This means, you can also etch both sides at once!
|
||||
- A new plate layout design optimizes for etching. All rounded forms, including a custom designed font face and QRs with circle modules. Also the mask area now covers the whole plate except for the side where you tape it for transfer. This means you only need to tape one side before etching.
|
||||
- I designed a 3d printable etching container for optimal etching performance. No manual movement required. It will be released after geyser.io campaign, presumably.
|
||||
- Improved etching method by using 30% FeCl3 at 40°C. It can be made from 40% by diluting it with distilled water.
|
||||
- For folks who want to electro etch, there is an optional stats page with A/cm2 calculations that can be printed additionally. (I am still researching the optimal electro etching workflow.)
|
||||
|
||||
---
|
||||
|
||||
## The project consists of:
|
||||
|
||||
### 1) SeedEtcher Controller
|
||||
|
||||
Raspberry Pi Zero–based controller firmware that drives a standard laser printer over USB.
|
||||
Scan seed and descriptor QR codes offline and print deterministic layouts for etching.
|
||||
|
||||
## 2) SeedEtcher Workflow
|
||||
### 2) SeedEtcher Workflow
|
||||
|
||||
A documented, repeatable workflow for chemically etching printed layouts onto steel.
|
||||
|
||||
@ -23,13 +41,13 @@ A documented, repeatable workflow for chemically etching printed layouts onto st
|
||||
---
|
||||
|
||||
## Features
|
||||
- b0.2 uses Shamir descriptor shares. No single plate contains the full descriptor. An m-of-n wallet uses `t=m` descriptor shares for recovery (e.g., a 2/3 wallet needs 2 descriptor shares).
|
||||
- The SeedEtcher controller has a descriptor recovery mode. TODO: cross-platform binaries will make this inheritance-friendly.
|
||||
- Manual mnemonic input with validation (`bip39`).
|
||||
- GUI-driven, with physical button navigation.
|
||||
- Multisig uses descriptor-share backups (no full descriptor on a single plate). These wallet configs default to UR/XOR-compatible shares: `1/2`, `2/2`, `2/3`, `2/4`, `4/4`, `3/5`, and any `n-1/n`. Other multisig configurations output the full descriptor.
|
||||
- The SeedEtcher controller has a descriptor recovery mode and is able to scan them directly from the metal plates.
|
||||
- Manual mnemonic input with validation (`bip39`)
|
||||
- GUI-driven, with physical button navigation
|
||||
- Outputs plates layouts with words + QR codes directly via serial USB
|
||||
- Laser print → toner transfer → acid etching for steel backup.
|
||||
- Debugging via serial shell and PDF capture on host.
|
||||
- Laser print → toner transfer → acid etching for steel backup
|
||||
- Debugging via serial shell and PDF capture on host or UART in host mode
|
||||
|
||||
---
|
||||
|
||||
@ -54,7 +72,7 @@ diskutil eject /dev/diskX
|
||||
```
|
||||
---
|
||||
|
||||
## Build & Deploy (Quick Start)
|
||||
## Build & Deploy for debugging (Quick Start)
|
||||
|
||||
(see [build-matrix.md](docs/dev/build-matrix.md) for target builds)
|
||||
|
||||
@ -68,12 +86,19 @@ diskutil eject /dev/diskX
|
||||
```
|
||||
|
||||
Run controller on Pi:
|
||||
./controller < /dev/ttyGS1 >> /log/debug.log 2>> /log/debug.log &
|
||||
```./controller < /dev/ttyGS1 >> /log/debug.log 2>> /log/debug.log &```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
Unlicense license: [LICENSE](LICENSE)
|
||||
Licensed under Apache License 2.0: [LICENSE](LICENSE) and [NOTICE](NOTICE)
|
||||
Third-party component licenses: [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md)
|
||||
|
||||
SeedEtcher™ and SeedEtcher Transfer Stack™ are trademarks of the SeedEtcher project.
|
||||
|
||||
The Apache License 2.0 applies only to the code, documentation, and design
|
||||
files in this repository and does not grant rights to use the SeedEtcher
|
||||
name, logos, or branding.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,127 +0,0 @@
|
||||
# SeedHammer controller program
|
||||
|
||||
This repository contains the source code to run the controller program for the
|
||||
[SeedHammer](https://seedetcher.com) engraving machine. It runs on the same hardware
|
||||
as the [SeedSigner](https://seedsigner.com/hardware): Raspberry Pi Zero or Zero W, a
|
||||
WaveShare 1.3 inch 240x240 LCD hat and a Pi Zero compatible camera with a OV5647
|
||||
sensor.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Write `seedetcher-vX.Y.X.img` to an SD-card and insert that into the SD-card
|
||||
slot on the Raspberry Pi.
|
||||
|
||||
### Linux
|
||||
|
||||
The `dd` command writes the image to the block device `/dev/sdX`:
|
||||
|
||||
```sh
|
||||
$ dd if=result/seedetcher-vX.Y.Z.img of=/dev/sdX bs=1M
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
Use a similar command as for Linux or a GUI tool such as [balenaEtcher](https://www.balena.io/etcher/).
|
||||
|
||||
|
||||
### Building from source
|
||||
|
||||
To build a complete `seedetcher.img` image, [Nix](https://nixos.org/) with flakes enabled is required.
|
||||
The default Nix package in `flake.nix` builds the image:
|
||||
|
||||
```sh
|
||||
$ nix build github:seedetcher/seedetcher
|
||||
$ ls result/seedetcher.img
|
||||
```
|
||||
|
||||
The `seedetcher.img` image contains the Pi Zero firmware, the Linux kernel and drivers, and the
|
||||
`controller` program that drives the Pi hardware and engraver.
|
||||
|
||||
To build a versioned image, use the `mkrelease` script and specify a tag:
|
||||
|
||||
```sh
|
||||
$ nix run github:seedetcher/seedetcher#mkrelease vx.y.z
|
||||
```
|
||||
|
||||
the resulting image will embed the version. The command also accepts git branches or commits.
|
||||
|
||||
### Reproducible builds
|
||||
|
||||
The build process is designed to be deterministic; that is, images produced with the above steps
|
||||
should match the released images bit-for-bit. If not, please open an issue.
|
||||
|
||||
Use a tool such as `shasum` or `sha256sum` to verify that the release binary matches.
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
### Update through USB
|
||||
|
||||
There is a crude facility to replace and restart the controller binary on a running device. First,
|
||||
build and prepare a debug build of the image
|
||||
|
||||
```
|
||||
$ nix build .#image-debug
|
||||
```
|
||||
|
||||
from a local clone of this repository. Then write `result/seedetcher-debug.img` to an SD-card.
|
||||
Connect the device to your machine with a USB cable to the USB port closest to the mini-HDMI
|
||||
port of the device; that is, the port usually used to communicate with the engraver.
|
||||
|
||||
Then, to upload and run a new version of the controller binary, run
|
||||
|
||||
```
|
||||
$ export USBDEV=/dev/cu.usbmodem101 # Or (usually) /dev/ttyUSB0 on Linux.
|
||||
$ nix run .#reload $USBDEV
|
||||
```
|
||||
|
||||
In debug mode, logging output from the controller is routed through the USB serial device.
|
||||
Use
|
||||
|
||||
```
|
||||
$ cat $USBDEV
|
||||
```
|
||||
|
||||
to show the log on your terminal. The `nix .#reload` command automatically does this after reloading.
|
||||
|
||||
### Remote control
|
||||
|
||||
There are few commands available to remote control, or script, the device in debug mode.
|
||||
|
||||
```
|
||||
$ echo "input up" > $USBDEV
|
||||
```
|
||||
|
||||
sends one or more button events to the device. Available buttons are: `up`, `down`, `left`, `right`, `center`,
|
||||
`b1`, `b2`, `b3`.
|
||||
|
||||
```
|
||||
$ echo "runes ACCIDENT" > $USBDEV
|
||||
```
|
||||
|
||||
sends text to the device, where every space and the newline sends an implicit `input b2`. Useful for scripting the input of seeds.
|
||||
|
||||
```
|
||||
$ echo "screenshot" > $USBDEV
|
||||
```
|
||||
|
||||
instructs the controller to dump a screenshot to the SD card.
|
||||
|
||||
## Dry-run engraving
|
||||
|
||||
Testing the engraving process without actually spending a plate can be done in dry-run mode. It's activated
|
||||
by long-pressing the middle button on the engraving screen. When dry-run is enabled, a small notice is shown
|
||||
in the lower right corner of the screen.
|
||||
|
||||
### License
|
||||
|
||||
The files is this repository are in the public domain as described in the [LICENSE](LICENSE) file,
|
||||
except files in directories with their own LICENSE files.
|
||||
|
||||
### Contributions
|
||||
|
||||
Contributors must agree to the [developer certificate of origin](https://developercertificate.org/),
|
||||
to ensure their work is compatible with the the LICENSE. Sign your commits with
|
||||
Signed-off-by statements to show your agreement with the `git commit --signoff` (or `-s`)
|
||||
command.
|
||||
70
THIRD_PARTY_LICENSES.md
Normal file
70
THIRD_PARTY_LICENSES.md
Normal file
@ -0,0 +1,70 @@
|
||||
# Third-Party Components and Licenses
|
||||
|
||||
This project is licensed under Apache-2.0 for SeedEtcher-authored code.
|
||||
|
||||
Release images also include third-party components that keep their own licenses.
|
||||
Those licenses apply to those components.
|
||||
|
||||
## Included Components (Release Images)
|
||||
|
||||
| Component | License | Notes | Upstream |
|
||||
|---|---|---|---|
|
||||
| Linux kernel (raspberrypi/linux) | GPL-2.0-only | Kernel image and modules | https://github.com/raspberrypi/linux |
|
||||
| Raspberry Pi firmware boot files | Raspberry Pi firmware redistribution terms | `bootcode.bin`, `start.elf`, `fixup.dat` | https://github.com/raspberrypi/firmware |
|
||||
| BusyBox (static) | GPL-2.0-only | Base userland in initramfs (`/bin/*`) | https://busybox.net |
|
||||
| binutils (`readelf`) | GPL-3.0-or-later | `readelf` is copied into initramfs | https://www.gnu.org/software/binutils/ |
|
||||
| libcamera + camera runtime libs | See upstream libcamera licensing | Camera stack shipped in runtime libs | https://libcamera.org |
|
||||
| CUPS | Apache-2.0 (with CUPS exception) | Printing system/scheduler (`cupsd`, CLI tools) | https://github.com/OpenPrinting/cups |
|
||||
| cups-filters | GPL-2.0-or-later | CUPS filter stack used by print pipeline | https://github.com/OpenPrinting/cups-filters |
|
||||
| Ghostscript | AGPL-3.0-or-later (or commercial) | PDF/PS/raster conversion toolchain | https://ghostscript.com/licensing/ |
|
||||
| poppler-utils (`pdftops`) | GPL-2.0-or-later | PDF conversion utility used in print flow | https://poppler.freedesktop.org |
|
||||
| brlaser | GPL-2.0-only | Brother laser CUPS raster filter/PPD data | https://github.com/pdewacht/brlaser |
|
||||
| Martian Mono font | OFL-1.1 | Bundled TTF used by UI/print layouts | https://github.com/evilmartians/mono |
|
||||
| Poppins font | OFL-1.1 | Used for generated bitmap font assets | https://github.com/itfoundry/Poppins |
|
||||
| Comfortaa font | OFL-1.1 | Used for generated bitmap font assets | https://github.com/alexeiva/comfortaa |
|
||||
|
||||
SeedEtcher builds package versions from pinned Nix inputs (`flake.lock`), so exact versions are reproducible per release build.
|
||||
|
||||
## Included Components (Debug Images)
|
||||
|
||||
| Component | License | Notes |
|
||||
|---|---|---|
|
||||
| strace | LGPL-2.1-or-later and GPL-2.0-or-later | Copied into initramfs for debug/troubleshooting |
|
||||
|
||||
## What This Means for Releases
|
||||
|
||||
For each public release image:
|
||||
|
||||
1. Keep this file in the repository and include/update it in release documentation.
|
||||
2. Keep third-party license texts available to recipients for distributed components.
|
||||
3. Provide corresponding source information for GPL/AGPL components in the release context:
|
||||
- upstream source references,
|
||||
- any local patches/modifications,
|
||||
- build scripts/derivations used to produce shipped binaries.
|
||||
4. Keep `flake.lock` in the tagged release so dependency revisions stay auditable/reproducible.
|
||||
|
||||
## Maintainer Check (Per Release)
|
||||
|
||||
1. Confirm release tag and commit include current `flake.lock`.
|
||||
2. Confirm third-party component list above still matches what image builds include.
|
||||
3. If component set changed, update this file before publishing.
|
||||
4. In release notes, link to:
|
||||
- source tag/commit,
|
||||
- this third-party license file,
|
||||
- any patch locations for bundled GPL/AGPL components.
|
||||
|
||||
## Optional Version Audit Commands
|
||||
|
||||
```bash
|
||||
# Build image (if needed)
|
||||
nix build .#image
|
||||
|
||||
# Inspect closure for relevant components (versions appear in store path names)
|
||||
nix path-info -r .#image | rg 'cups|cups-filters|ghostscript|brlaser-se-runtime'
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- This file is a practical compliance tracker, not legal advice.
|
||||
- If distribution model changes (for example hosted network services using AGPL components),
|
||||
re-check obligations before release.
|
||||
66
TRADEMARK.md
Normal file
66
TRADEMARK.md
Normal file
@ -0,0 +1,66 @@
|
||||
# SeedEtcher Trademark Policy
|
||||
|
||||
SeedEtcher™ and SeedEtcher Transfer Stack™ are names and brands of the SeedEtcher project.
|
||||
|
||||
The SeedEtcher name, SeedEtcher Transfer Stack name, logos, wordmarks,
|
||||
branding, website identity, and other project identifiers are used to
|
||||
identify the official project, documentation, releases, and hardware kits.
|
||||
|
||||
No trademark rights are granted under the Apache License 2.0. That license
|
||||
applies only to the code, design files, and documentation contained in this
|
||||
repository.
|
||||
|
||||
## Permitted Use
|
||||
|
||||
You may:
|
||||
|
||||
- Use the SeedEtcher name to refer to the project truthfully.
|
||||
- Link to the official SeedEtcher repository, website, or documentation.
|
||||
- State that a product, fork, or derivative is compatible with or based on
|
||||
SeedEtcher, so long as this does not imply endorsement.
|
||||
|
||||
Examples:
|
||||
|
||||
- "Compatible with SeedEtcher"
|
||||
- "Based on the SeedEtcher project"
|
||||
|
||||
## Restricted Use
|
||||
|
||||
You may NOT:
|
||||
|
||||
- Sell products using the SeedEtcher name.
|
||||
- Distribute hardware kits branded as SeedEtcher.
|
||||
- Use the SeedEtcher logo, wordmark, or other branding.
|
||||
- Use the SeedEtcher name in a way that implies endorsement, affiliation,
|
||||
sponsorship, certification, approval, or official status.
|
||||
- Use confusingly similar names, branding, domains, social media handles,
|
||||
storefronts, or campaign pages that could cause users to believe a product
|
||||
or service is official SeedEtcher.
|
||||
- Present a fork or derivative as the official SeedEtcher project.
|
||||
|
||||
## Forks and Derivatives
|
||||
|
||||
Forks and derivatives are permitted under the Apache License 2.0, but they
|
||||
must use a different name and different branding unless explicit permission
|
||||
is granted.
|
||||
|
||||
Example:
|
||||
|
||||
- A fork of SeedEtcher must use its own product name and branding.
|
||||
|
||||
## Official Products
|
||||
|
||||
Only products distributed by the SeedEtcher project or by explicitly
|
||||
authorized sellers may use the SeedEtcher name or logo.
|
||||
|
||||
## Permission Requests
|
||||
|
||||
For permission requests regarding use of the SeedEtcher trademark, open an
|
||||
issue in the project repository.
|
||||
|
||||
## Changes to This Policy
|
||||
|
||||
This policy may be updated from time to time. Continued use of the
|
||||
SeedEtcher name or branding must comply with the latest published version.
|
||||
|
||||
SeedEtcher™ is a trademark of the SeedEtcher project.
|
||||
@ -27,6 +27,9 @@ type Decoder struct {
|
||||
}
|
||||
|
||||
func Encode(message []byte, seqNum, seqLen int) []byte {
|
||||
if seqLen <= 0 {
|
||||
panic("seqLen out of range")
|
||||
}
|
||||
if seqLen == 1 {
|
||||
return message
|
||||
}
|
||||
@ -108,6 +111,9 @@ func (d *Decoder) Add(data []byte) error {
|
||||
if err := mode.Unmarshal(data, p); err != nil {
|
||||
return fmt.Errorf("fountain: failed to decode fragment: %w", err)
|
||||
}
|
||||
if err := validatePartHeader(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if d.header.SeqLen > 0 {
|
||||
if d.header != p.partHeader {
|
||||
return fmt.Errorf("fountain: incompatible fragment")
|
||||
@ -227,6 +233,9 @@ func Checksum(data []byte) uint32 {
|
||||
|
||||
// SeqNumFor searches for a seqNum that outpus the xor of fragments.
|
||||
func SeqNumFor(seqLen int, checksum uint32, fragments []int) int {
|
||||
if seqLen <= 0 {
|
||||
panic("seqLen out of range")
|
||||
}
|
||||
seqNum := 1
|
||||
sort.Ints(fragments)
|
||||
for {
|
||||
@ -240,7 +249,10 @@ func SeqNumFor(seqLen int, checksum uint32, fragments []int) int {
|
||||
}
|
||||
|
||||
func chooseFragments(seqNum uint32, seqLen int, checksum uint32) []int {
|
||||
if seqNum <= uint32(seqLen) {
|
||||
if seqLen <= 0 {
|
||||
return nil
|
||||
}
|
||||
if uint64(seqNum) <= uint64(seqLen) {
|
||||
return []int{int(seqNum - 1)}
|
||||
} else {
|
||||
seed := binary.BigEndian.AppendUint32(nil, seqNum)
|
||||
@ -258,6 +270,16 @@ func chooseFragments(seqNum uint32, seqLen int, checksum uint32) []int {
|
||||
}
|
||||
}
|
||||
|
||||
func validatePartHeader(p *part) error {
|
||||
if p.SeqLen <= 0 {
|
||||
return fmt.Errorf("fountain: invalid sequence length")
|
||||
}
|
||||
if p.MessageLen < 0 {
|
||||
return fmt.Errorf("fountain: invalid message length")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shuffle(items []int, rng *xoshiro256.Source) []int {
|
||||
var result []int
|
||||
for len(items) > 0 {
|
||||
|
||||
@ -7,8 +7,10 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"seedetcher.com/bc/xoshiro256"
|
||||
)
|
||||
|
||||
@ -161,3 +163,61 @@ func TestChooseFragments(t *testing.T) {
|
||||
t.Errorf("mismatched fragment indexes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecoderRejectsInvalidHeaders(t *testing.T) {
|
||||
enc, err := cbor.CoreDetEncOptions().EncMode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
p part
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "seq len zero",
|
||||
p: part{
|
||||
SeqNum: 1,
|
||||
partHeader: partHeader{
|
||||
SeqLen: 0,
|
||||
MessageLen: 1,
|
||||
Checksum: 1,
|
||||
},
|
||||
Data: []byte{0},
|
||||
},
|
||||
want: "invalid sequence length",
|
||||
},
|
||||
{
|
||||
name: "message len negative",
|
||||
p: part{
|
||||
SeqNum: 1,
|
||||
partHeader: partHeader{
|
||||
SeqLen: 1,
|
||||
MessageLen: -1,
|
||||
Checksum: 1,
|
||||
},
|
||||
Data: []byte{0},
|
||||
},
|
||||
want: "invalid message length",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
b, err := enc.Marshal(tc.p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var d Decoder
|
||||
err = d.Add(b)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q", tc.want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("got error %q, want substring %q", err.Error(), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
26
bc/ur/split_test.go
Normal file
26
bc/ur/split_test.go
Normal file
@ -0,0 +1,26 @@
|
||||
package ur
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplit2of3OneURPerShare(t *testing.T) {
|
||||
data := Data{Data: []byte("hello-world"), Threshold: 2, Shards: 3}
|
||||
for i := 0; i < 3; i++ {
|
||||
got := Split(data, i)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("share %d: got %d URs, want 1", i+1, len(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplit3of5TwoURPerShare(t *testing.T) {
|
||||
data := Data{Data: []byte("hello-world"), Threshold: 3, Shards: 5}
|
||||
for i := 0; i < 5; i++ {
|
||||
got := Split(data, i)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("share %d: got %d URs, want 2", i+1, len(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
61
bc/ur/ur.go
61
bc/ur/ur.go
@ -13,6 +13,67 @@ import (
|
||||
"seedetcher.com/bc/fountain"
|
||||
)
|
||||
|
||||
type Data struct {
|
||||
Data []byte
|
||||
Threshold int
|
||||
Shards int
|
||||
}
|
||||
|
||||
// Split implements SeedHammer-style fragment assignments over UR fountain
|
||||
// fragments for selected m-of-n schemes.
|
||||
func Split(data Data, keyIdx int) (urs []string) {
|
||||
var shares [][]int
|
||||
var seqLen int
|
||||
n, m := data.Shards, data.Threshold
|
||||
switch {
|
||||
case n-m <= 1:
|
||||
// Optimal: 1 part per share, seqLen m.
|
||||
seqLen = m
|
||||
if keyIdx < m {
|
||||
shares = [][]int{{keyIdx}}
|
||||
} else {
|
||||
all := make([]int, 0, m)
|
||||
for i := range m {
|
||||
all = append(all, i)
|
||||
}
|
||||
shares = [][]int{all}
|
||||
}
|
||||
case n == 4 && m == 2:
|
||||
// Optimal, but 2 parts per share.
|
||||
seqLen = m * 2
|
||||
switch keyIdx {
|
||||
case 0:
|
||||
shares = [][]int{{0}, {1}}
|
||||
case 1:
|
||||
shares = [][]int{{2}, {3}}
|
||||
case 2:
|
||||
shares = [][]int{{0, 2}, {1, 3}}
|
||||
case 3:
|
||||
shares = [][]int{{0, 2, 1}, {1, 3, 2}}
|
||||
}
|
||||
case n == 5 && m == 3:
|
||||
// Optimal, but 2 parts per share.
|
||||
seqLen = m * 2
|
||||
second := []int{
|
||||
n,
|
||||
(keyIdx + n - 1) % n,
|
||||
(keyIdx + 1) % n,
|
||||
}
|
||||
shares = [][]int{{keyIdx}, second}
|
||||
default:
|
||||
// Fallback: full data per share.
|
||||
seqLen = 1
|
||||
shares = [][]int{{0}}
|
||||
}
|
||||
check := fountain.Checksum(data.Data)
|
||||
for _, frag := range shares {
|
||||
seqNum := fountain.SeqNumFor(seqLen, check, frag)
|
||||
qr := strings.ToUpper(Encode("crypto-output", data.Data, seqNum, seqLen))
|
||||
urs = append(urs, qr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func Encode(_type string, message []byte, seqNum, seqLen int) string {
|
||||
if seqLen == 1 {
|
||||
return fmt.Sprintf("ur:%s/%s", _type, bytewords.Encode(message))
|
||||
|
||||
@ -87,6 +87,28 @@ func (s Script) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// Tag returns the canonical short script identifier used in compact metadata.
|
||||
func (s Script) Tag() string {
|
||||
switch s {
|
||||
case P2SH:
|
||||
return "P2SH"
|
||||
case P2SH_P2WSH:
|
||||
return "P2SH-P2WSH"
|
||||
case P2SH_P2WPKH:
|
||||
return "P2SH-P2WPKH"
|
||||
case P2PKH:
|
||||
return "P2PKH"
|
||||
case P2WSH:
|
||||
return "P2WSH"
|
||||
case P2WPKH:
|
||||
return "P2WPKH"
|
||||
case P2TR:
|
||||
return "P2TR"
|
||||
default:
|
||||
return "UNKNOWN"
|
||||
}
|
||||
}
|
||||
|
||||
type MultisigType int
|
||||
|
||||
const (
|
||||
@ -105,6 +127,18 @@ func (m MultisigType) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// Tag returns the canonical short descriptor type identifier used in compact metadata.
|
||||
func (m MultisigType) Tag() string {
|
||||
switch m {
|
||||
case Singlesig:
|
||||
return "SINGLESIG"
|
||||
case SortedMulti:
|
||||
return "SORTEDMULTI"
|
||||
default:
|
||||
return fmt.Sprintf("TYPE%d", int(m))
|
||||
}
|
||||
}
|
||||
|
||||
// Singlesig reports whether the script is for single-sig.
|
||||
func (s Script) Singlesig() bool {
|
||||
for _, s2 := range []Script{P2PKH, P2WPKH, P2SH_P2WPKH, P2TR} {
|
||||
|
||||
@ -19,6 +19,7 @@ func main() {
|
||||
if f.WalletName != "" {
|
||||
printer.SetWalletLabel(f.WalletName)
|
||||
}
|
||||
printer.SetCompactDescriptor2of3Enabled(f.Compact2of3)
|
||||
|
||||
usr, err := user.Current()
|
||||
if err != nil {
|
||||
@ -82,20 +83,34 @@ func main() {
|
||||
|
||||
printer.SetDescriptorQRSize(f.DescQRMM)
|
||||
opts := printer.RasterOptions{
|
||||
DPI: float64(f.DPI),
|
||||
Mirror: f.Mirror,
|
||||
Invert: f.Invert,
|
||||
DPI: float64(f.DPI),
|
||||
Mirror: f.Mirror,
|
||||
Invert: f.Invert,
|
||||
EtchStatsPage: f.EtchStatsPage,
|
||||
}
|
||||
seedImgs, descImgs, err := printer.CreatePlateBitmaps(mnemonics, desc, 0, opts, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating raster plates: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
pages, err := printer.ComposePages(seedImgs, descImgs, printer.PaperSize(f.PaperSize), opts.DPI, nil)
|
||||
pages, err := printer.ComposePagesWithInvert(seedImgs, descImgs, printer.PaperSize(f.PaperSize), opts.DPI, opts.Invert, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("Error composing pages: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if opts.EtchStatsPage {
|
||||
report, err := printer.BuildEtchStatsReport(seedImgs, descImgs, opts.DPI, printer.PaperSize(f.PaperSize))
|
||||
if err != nil {
|
||||
fmt.Printf("Error building etch stats report: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
statsPage, err := printer.RenderEtchStatsPage(report, printer.PaperSize(f.PaperSize), opts.DPI)
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering etch stats page: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
pages = append(pages, statsPage)
|
||||
}
|
||||
|
||||
file, err := os.Create(filepath.Join(outputDir, config.Name+".pdf"))
|
||||
if err != nil {
|
||||
|
||||
@ -1,14 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/bip39"
|
||||
"seedetcher.com/gui"
|
||||
"seedetcher.com/logutil"
|
||||
"seedetcher.com/printer"
|
||||
@ -73,27 +78,60 @@ func runCLI(f *testutils.Flags) error {
|
||||
printer.SetDescriptorQRSize(f.DescQRMM)
|
||||
|
||||
opts := printer.RasterOptions{
|
||||
DPI: float64(f.DPI),
|
||||
Mirror: f.Mirror,
|
||||
Invert: f.Invert,
|
||||
DPI: float64(f.DPI),
|
||||
Mirror: f.Mirror,
|
||||
Invert: f.Invert,
|
||||
EtchStatsPage: f.EtchStatsPage,
|
||||
}
|
||||
printer.SetCompactDescriptor2of3Enabled(f.Compact2of3)
|
||||
defer printer.SetCompactDescriptor2of3Enabled(false)
|
||||
forceCLIDPI := os.Getenv("SE_CLI_FORCE_DPI") == "1"
|
||||
if !forceCLIDPI {
|
||||
if adjusted, note, err := adjustDPILowMem(mnemonics, desc, printer.PaperSize(f.PaperSize), opts, f.Compact2of3); err != nil {
|
||||
return err
|
||||
} else if adjusted != opts.DPI {
|
||||
opts.DPI = adjusted
|
||||
fmt.Fprintln(os.Stderr, note)
|
||||
}
|
||||
}
|
||||
|
||||
seedImgs, descImgs, err := printer.CreatePlateBitmaps(mnemonics, desc, 0, opts, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render bitmaps: %w", err)
|
||||
}
|
||||
pages, err := printer.ComposePages(seedImgs, descImgs, printer.PaperSize(f.PaperSize), opts.DPI, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose pages: %w", err)
|
||||
}
|
||||
|
||||
const outPDF = "/tmp/test_output.pdf"
|
||||
pdfFile, err := os.Create(outPDF)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output PDF: %v", err)
|
||||
}
|
||||
if err := printer.WritePDFRaster(pdfFile, pages, printer.PaperSize(f.PaperSize)); err != nil {
|
||||
pdfFile.Close()
|
||||
return fmt.Errorf("write PDF: %w", err)
|
||||
pclPath := strings.TrimSpace(f.PCLOut)
|
||||
paper := printer.PaperSize(f.PaperSize)
|
||||
if !opts.EtchStatsPage {
|
||||
if err := printer.WritePDFPlatesWithInvert(pdfFile, seedImgs, descImgs, paper, opts.DPI, opts.Invert); err != nil {
|
||||
pdfFile.Close()
|
||||
return fmt.Errorf("write PDF: %w", err)
|
||||
}
|
||||
} else {
|
||||
pages, err := printer.ComposePagesWithInvert(seedImgs, descImgs, paper, opts.DPI, opts.Invert, nil)
|
||||
if err != nil {
|
||||
pdfFile.Close()
|
||||
return fmt.Errorf("compose pages: %w", err)
|
||||
}
|
||||
report, err := printer.BuildEtchStatsReport(seedImgs, descImgs, opts.DPI, paper)
|
||||
if err != nil {
|
||||
pdfFile.Close()
|
||||
return fmt.Errorf("build etch stats report: %w", err)
|
||||
}
|
||||
statsPage, err := printer.RenderEtchStatsPage(report, paper, opts.DPI)
|
||||
if err != nil {
|
||||
pdfFile.Close()
|
||||
return fmt.Errorf("render etch stats page: %w", err)
|
||||
}
|
||||
pages = append(pages, statsPage)
|
||||
if err := printer.WritePDFRaster(pdfFile, pages, paper); err != nil {
|
||||
pdfFile.Close()
|
||||
return fmt.Errorf("write PDF: %w", err)
|
||||
}
|
||||
}
|
||||
if err := pdfFile.Close(); err != nil {
|
||||
return fmt.Errorf("close output PDF: %w", err)
|
||||
@ -102,8 +140,22 @@ func runCLI(f *testutils.Flags) error {
|
||||
logutil.DebugLog("PDF generated at %s", outPDF)
|
||||
}
|
||||
|
||||
pclPath := strings.TrimSpace(f.PCLOut)
|
||||
if pclPath != "" {
|
||||
pages, err := printer.ComposePagesWithInvert(seedImgs, descImgs, paper, opts.DPI, opts.Invert, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compose pages: %w", err)
|
||||
}
|
||||
if opts.EtchStatsPage {
|
||||
report, err := printer.BuildEtchStatsReport(seedImgs, descImgs, opts.DPI, paper)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build etch stats report: %w", err)
|
||||
}
|
||||
statsPage, err := printer.RenderEtchStatsPage(report, paper, opts.DPI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render etch stats page: %w", err)
|
||||
}
|
||||
pages = append(pages, statsPage)
|
||||
}
|
||||
if strings.HasSuffix(pclPath, "/") || isDir(pclPath) {
|
||||
pclPath = filepath.Join(strings.TrimRight(pclPath, "/"), config.Name+".pcl")
|
||||
}
|
||||
@ -114,7 +166,7 @@ func runCLI(f *testutils.Flags) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("create PCL output file: %w", err)
|
||||
}
|
||||
if err := printer.WritePCL(pclFile, pages, opts.DPI, printer.PaperSize(f.PaperSize), nil); err != nil {
|
||||
if err := printer.WritePCL(pclFile, pages, opts.DPI, paper, nil); err != nil {
|
||||
pclFile.Close()
|
||||
return fmt.Errorf("write PCL: %w", err)
|
||||
}
|
||||
@ -129,9 +181,161 @@ func runCLI(f *testutils.Flags) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func adjustDPILowMem(mnemonics []bip39.Mnemonic, desc *urtypes.OutputDescriptor, paper printer.PaperSize, opts printer.RasterOptions, compact2of3 bool) (float64, string, error) {
|
||||
avail, err := memAvailableBytes()
|
||||
if err != nil || avail <= 0 {
|
||||
return opts.DPI, "", nil
|
||||
}
|
||||
|
||||
estimate := func(dpi float64) (int64, error) {
|
||||
if dpi <= 0 {
|
||||
return 0, fmt.Errorf("invalid dpi: %.0f", dpi)
|
||||
}
|
||||
totalShares, seedPlates, descPlates := rasterPlateCounts(mnemonics, desc, opts, compact2of3)
|
||||
if totalShares <= 0 || seedPlates <= 0 {
|
||||
return 0, fmt.Errorf("no shares to render")
|
||||
}
|
||||
pageCount := rasterPageCount(seedPlates, descPlates, paper)
|
||||
if opts.EtchStatsPage {
|
||||
pageCount++
|
||||
}
|
||||
pw, ph, err := paperPixelDims(paper, dpi)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
side := mmToPx(90.0, dpi)
|
||||
plateBytes := int64(side * side)
|
||||
pageBytes := int64(pw * ph)
|
||||
base := plateBytes*int64(seedPlates+descPlates) + pageBytes*int64(pageCount)
|
||||
// Safety factor for transient allocations in compose/encode path.
|
||||
estimate := int64(float64(base)*1.35) + 24*1024*1024
|
||||
return estimate, nil
|
||||
}
|
||||
|
||||
const budgetRatio = 0.75
|
||||
budget := int64(float64(avail) * budgetRatio)
|
||||
need, err := estimate(opts.DPI)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if need <= budget {
|
||||
return opts.DPI, "", nil
|
||||
}
|
||||
|
||||
if opts.DPI > 600 {
|
||||
need600, err := estimate(600)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if need600 <= budget {
|
||||
return 600, fmt.Sprintf("controller CLI: requested %.0fdpi needs ~%dMB with %dMB available; using 600dpi to avoid OOM", opts.DPI, need/(1024*1024), avail/(1024*1024)), nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, "", fmt.Errorf("insufficient RAM for %.0fdpi render: need ~%dMB, available %dMB (budget %dMB)", opts.DPI, need/(1024*1024), avail/(1024*1024), budget/(1024*1024))
|
||||
}
|
||||
|
||||
func rasterPlateCounts(mnemonics []bip39.Mnemonic, desc *urtypes.OutputDescriptor, opts printer.RasterOptions, compact2of3 bool) (totalShares, seedPlates, descPlates int) {
|
||||
totalShares = len(mnemonics)
|
||||
if totalShares <= 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
isSinglesigDesc := desc != nil && len(desc.Keys) == 1 && desc.Type == urtypes.Singlesig
|
||||
includeSinglesigDescriptorSide := isSinglesigDesc && opts.SinglesigLayout == printer.SinglesigLayoutSeedWithDescriptorQR
|
||||
if desc != nil && len(desc.Keys) > 0 && !isSinglesigDesc {
|
||||
totalShares = len(desc.Keys)
|
||||
}
|
||||
|
||||
seedPlates = totalShares
|
||||
hasDesc := desc != nil && len(desc.Keys) > 0 && (!isSinglesigDesc || includeSinglesigDescriptorSide)
|
||||
if hasDesc {
|
||||
descPlates = totalShares
|
||||
}
|
||||
compactSingleSided := hasDesc &&
|
||||
compact2of3 &&
|
||||
desc != nil &&
|
||||
desc.Type == urtypes.SortedMulti &&
|
||||
desc.Threshold == 2 &&
|
||||
len(desc.Keys) == 3 &&
|
||||
totalShares == 3
|
||||
if compactSingleSided {
|
||||
descPlates = 0
|
||||
}
|
||||
return totalShares, seedPlates, descPlates
|
||||
}
|
||||
|
||||
func rasterPageCount(seedPlates, descPlates int, _ printer.PaperSize) int {
|
||||
slots := seedPlates + descPlates
|
||||
perPage := 4 // Fixed 2x2 layout on both A4 and Letter.
|
||||
if perPage <= 0 {
|
||||
perPage = 1
|
||||
}
|
||||
pages := (slots + perPage - 1) / perPage
|
||||
if pages <= 0 {
|
||||
pages = 1
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
func mmToPx(mm, dpi float64) int {
|
||||
return int(math.Round(mm * dpi / 25.4))
|
||||
}
|
||||
|
||||
func paperPixelDims(p printer.PaperSize, dpi float64) (int, int, error) {
|
||||
var wmm, hmm float64
|
||||
switch p {
|
||||
case printer.PaperA4:
|
||||
wmm, hmm = 210, 297
|
||||
case printer.PaperLetter:
|
||||
wmm, hmm = 216, 279
|
||||
default:
|
||||
return 0, 0, fmt.Errorf("unsupported paper size: %v", p)
|
||||
}
|
||||
w := mmToPx(wmm, dpi)
|
||||
h := mmToPx(hmm, dpi)
|
||||
if w <= 0 || h <= 0 {
|
||||
return 0, 0, fmt.Errorf("invalid paper dimensions: %dx%d", w, h)
|
||||
}
|
||||
return w, h, nil
|
||||
}
|
||||
|
||||
func memAvailableBytes() (int64, error) {
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if !strings.HasPrefix(line, "MemAvailable:") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
break
|
||||
}
|
||||
v, err := strconv.ParseInt(fields[1], 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// /proc/meminfo reports kB.
|
||||
return v * 1024, nil
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 0, fmt.Errorf("MemAvailable not found in /proc/meminfo")
|
||||
}
|
||||
|
||||
func run() error {
|
||||
log.SetFlags(log.Flags() &^ (log.Ldate | log.Ltime))
|
||||
version := os.Getenv("sh_version")
|
||||
version := os.Getenv("se_version")
|
||||
if version == "" {
|
||||
// Backward compatibility for older stamped images.
|
||||
version = os.Getenv("sh_version")
|
||||
}
|
||||
p, err := initPlatform()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/bip39"
|
||||
"seedetcher.com/gui"
|
||||
"seedetcher.com/printer"
|
||||
)
|
||||
|
||||
type Platform struct{}
|
||||
@ -54,9 +55,17 @@ func (p *Platform) CameraFrame(dims image.Point) {
|
||||
}
|
||||
|
||||
func (p *Platform) ScanQR(img *image.Gray) ([][]byte, error) {
|
||||
return nil, errors.New("ScanQR not implemented")
|
||||
return nil, errors.New("scan qr not implemented")
|
||||
}
|
||||
|
||||
func (p *Platform) CreatePlates(ctx *gui.Context, mnemonic bip39.Mnemonic, desc *urtypes.OutputDescriptor, keyIdx int) error {
|
||||
return errors.New("CreatePlates not implemented")
|
||||
func (p *Platform) PrepareHBPForSDRemoval() error {
|
||||
return errors.New("brother hbp runtime prep is not supported on this platform")
|
||||
}
|
||||
|
||||
func (p *Platform) PrepareSDForRemoval() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Platform) CreatePlates(ctx *gui.Context, mnemonic bip39.Mnemonic, desc *urtypes.OutputDescriptor, keyIdx int, paper printer.PaperSize, opts printer.RasterOptions) error {
|
||||
return errors.New("create plates not implemented")
|
||||
}
|
||||
|
||||
@ -3,30 +3,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/bip39"
|
||||
rdebug "runtime/debug"
|
||||
"seedetcher.com/driver/drm"
|
||||
"seedetcher.com/driver/libcamera"
|
||||
"seedetcher.com/driver/wshat"
|
||||
"seedetcher.com/gui"
|
||||
"seedetcher.com/logutil"
|
||||
"seedetcher.com/printer"
|
||||
"seedetcher.com/zbar"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Debug hooks (ensure unique per build tag if needed, but keep as is for now).
|
||||
@ -34,31 +27,6 @@ var (
|
||||
initHook func(p *Platform) error
|
||||
)
|
||||
|
||||
// queryPrinterCapabilities sends a PJL query and parses the response for PCL/PostScript support
|
||||
func queryPrinterCapabilities(w io.Writer, r io.Reader) (supportsPCL, supportsPostScript bool, err error) {
|
||||
// PJL query for printer language
|
||||
query := []byte("\033%-12345X@PJL INFO VARIABLES\r\n\033%-12345X")
|
||||
if _, err = w.Write(query); err != nil {
|
||||
logutil.DebugLog("PJL query failed: %v", err)
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
// Read response (simplified, assumes line-based response)
|
||||
buf := make([]byte, 1024)
|
||||
n, err := r.Read(buf)
|
||||
if err != nil {
|
||||
logutil.DebugLog("Failed to read PJL response: %v", err)
|
||||
return false, false, err
|
||||
}
|
||||
response := string(buf[:n])
|
||||
logutil.DebugLog("PJL response: %s", response)
|
||||
|
||||
// Parse for PCL and PostScript (simplified, adjust for actual printer response)
|
||||
supportsPCL = strings.Contains(response, "PCL")
|
||||
supportsPostScript = strings.Contains(response, "POSTSCRIPT")
|
||||
return supportsPCL, supportsPostScript, nil
|
||||
}
|
||||
|
||||
type Platform struct {
|
||||
display *drm.LCD
|
||||
events chan gui.Event
|
||||
@ -71,10 +39,10 @@ type Platform struct {
|
||||
close func()
|
||||
active bool
|
||||
}
|
||||
printerCached io.Writer
|
||||
supportsPCL bool
|
||||
supportsPostScript bool
|
||||
printing bool // Add flag to track printing state
|
||||
printerCached io.Writer
|
||||
supportsPCL bool
|
||||
hostPCLForce600 bool
|
||||
printing bool // Add flag to track printing state
|
||||
}
|
||||
|
||||
func Init() (*Platform, error) {
|
||||
@ -124,19 +92,6 @@ func (p *Platform) Wakeup() {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Platform) PrinterStatus() (bool, string) {
|
||||
for i := 0; i < 3; i++ {
|
||||
matches, _ := filepath.Glob("/dev/usb/lp*")
|
||||
if len(matches) > 0 {
|
||||
return true, readPrinterModel()
|
||||
}
|
||||
if i < 2 {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func (p *Platform) AppendEvents(deadline time.Time, evts []gui.Event) []gui.Event {
|
||||
c := &p.camera
|
||||
if c.close != nil {
|
||||
@ -233,8 +188,16 @@ func (p *Platform) CameraFrame(dims image.Point) {
|
||||
c.active = true
|
||||
}
|
||||
|
||||
// In platform_rpi.go, replace Printer function
|
||||
func (p *Platform) Printer() io.Writer {
|
||||
// usblp can disconnect/re-enumerate between jobs; always reopen fresh in host mode
|
||||
// to avoid writing through a stale file descriptor.
|
||||
if p.printerCached != nil && p.supportsPCL {
|
||||
if f, ok := p.printerCached.(*os.File); ok {
|
||||
_ = f.Close()
|
||||
}
|
||||
p.printerCached = nil
|
||||
}
|
||||
|
||||
// If we previously failed and cached a non-PCL writer, but lp0 exists now,
|
||||
// clear the cache and try again.
|
||||
if p.printerCached != nil {
|
||||
@ -242,7 +205,7 @@ func (p *Platform) Printer() io.Writer {
|
||||
return p.printerCached
|
||||
}
|
||||
if _, err := os.Stat("/dev/usb/lp0"); err == nil {
|
||||
if f, ok := p.printerCached.(*os.File); ok && f != os.Stderr {
|
||||
if f, ok := p.printerCached.(*os.File); ok {
|
||||
_ = f.Close()
|
||||
}
|
||||
p.printerCached = nil
|
||||
@ -265,299 +228,14 @@ func (p *Platform) Printer() io.Writer {
|
||||
} else {
|
||||
p.supportsPCL = false
|
||||
}
|
||||
p.supportsPostScript = false
|
||||
p.printerCached = printer
|
||||
logutil.DebugLog("Printer initialized: dev=%s PCL=%v PS=%v", dev, p.supportsPCL, p.supportsPostScript)
|
||||
logutil.DebugLog("Printer initialized: dev=%s PCL=%v", dev, p.supportsPCL)
|
||||
return printer
|
||||
}
|
||||
p.printerCached = os.Stderr
|
||||
return p.printerCached
|
||||
}
|
||||
func (p *Platform) CreatePlates(ctx *gui.Context, mnemonic bip39.Mnemonic, desc *urtypes.OutputDescriptor, keyIdx int) error {
|
||||
logutil.DebugLog("Entering CreatePlates with mnemonic length: %d, desc: %v, keyIdx: %d", len(mnemonic), desc != nil, keyIdx)
|
||||
printerDev := p.Printer()
|
||||
if printerDev == nil {
|
||||
logutil.DebugLog("Printer is nil")
|
||||
return fmt.Errorf("no printer available")
|
||||
}
|
||||
if p.supportsPCL {
|
||||
logutil.DebugLog("Printer acquired (PCL), preparing to write job")
|
||||
} else {
|
||||
logutil.DebugLog("Printer acquired (non-PCL), using raster-to-PDF path")
|
||||
}
|
||||
|
||||
p.printing = true
|
||||
defer func() { p.printing = false }()
|
||||
|
||||
var mnemonics []bip39.Mnemonic
|
||||
if desc == nil {
|
||||
mnemonics = []bip39.Mnemonic{mnemonic}
|
||||
} else if ctx == nil { // Add this
|
||||
mnemonics = []bip39.Mnemonic{mnemonic} // Use passed mnemonic
|
||||
} else {
|
||||
mnemonics = make([]bip39.Mnemonic, len(desc.Keys))
|
||||
i := 0
|
||||
for _, k := range desc.Keys {
|
||||
if m, ok := ctx.Keystores[k.MasterFingerprint]; ok {
|
||||
mnemonics[i] = m
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progress := func(stage printer.PrintStage, current, total int64) {
|
||||
if ctx != nil && ctx.PrintProgress != nil && total > 0 {
|
||||
ctx.PrintProgress(stage, current, total)
|
||||
}
|
||||
}
|
||||
|
||||
opts := printer.RasterOptions{
|
||||
DPI: 600, // Safe default for Zero; adjust if needed
|
||||
Mirror: true,
|
||||
Invert: true,
|
||||
}
|
||||
seedImgs, descImgs, err := printer.CreatePlateBitmaps(mnemonics, desc, keyIdx, opts, progress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render: plate bitmaps: %w", err)
|
||||
}
|
||||
pages, err := printer.ComposePages(seedImgs, descImgs, printer.PaperA4, opts.DPI, progress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render: compose pages: %w", err)
|
||||
}
|
||||
|
||||
if p.supportsPCL {
|
||||
// Default to PCL in host mode (usblp).
|
||||
if err := printer.WritePCL(printerDev, pages, opts.DPI, printer.PaperA4, progress); err != nil {
|
||||
return fmt.Errorf("pcl: write: %w", err)
|
||||
}
|
||||
logutil.DebugLog("PCL write complete (pages=%d dpi=%.0f)", len(pages), opts.DPI)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback: serialize canonical raster pages as PDF (gadget capture/dev).
|
||||
var pdf bytes.Buffer
|
||||
if err := printer.WritePDFRaster(&pdf, pages, printer.PaperA4); err != nil {
|
||||
return fmt.Errorf("pdf: write: %w", err)
|
||||
}
|
||||
data := pdf.Bytes()
|
||||
logutil.DebugLog("Raster-based PDF generated, size: %d bytes", len(data))
|
||||
if len(data) == 0 {
|
||||
logutil.DebugLog("Generated PDF is empty")
|
||||
return fmt.Errorf("no data to write to printer")
|
||||
}
|
||||
|
||||
const chunkSize = 1024
|
||||
total := int64(len(data))
|
||||
written := int64(0)
|
||||
if progress != nil && total > 0 {
|
||||
progress(printer.StageSend, 0, total)
|
||||
}
|
||||
for i := 0; i < len(data); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(data) {
|
||||
end = len(data)
|
||||
}
|
||||
chunk := data[i:end]
|
||||
n, err := printerDev.Write(chunk)
|
||||
if err != nil {
|
||||
logutil.DebugLog("Write chunk %d failed: %v, wrote %d bytes", i/chunkSize, err, n)
|
||||
return err
|
||||
}
|
||||
logutil.DebugLog("Wrote chunk %d, %d bytes", i/chunkSize, n)
|
||||
written += int64(n)
|
||||
if progress != nil && total > 0 {
|
||||
progress(printer.StageSend, written, total)
|
||||
}
|
||||
}
|
||||
written = total
|
||||
if progress != nil && total > 0 {
|
||||
progress(printer.StageSend, written, total)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
p.printerCached = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Platform) initSDCardNotifier() error {
|
||||
fd, err := unix.InotifyInit1(unix.IN_CLOEXEC)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inotify_init1: %w", err)
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), "inotify")
|
||||
var flags uint32 = unix.IN_CREATE | unix.IN_DELETE
|
||||
const dev = "/dev"
|
||||
if _, err = unix.InotifyAddWatch(fd, dev, flags); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("inotify_add_watch: %w", err)
|
||||
}
|
||||
const sdcName = "mmcblk0"
|
||||
inserted := true
|
||||
if _, err := os.Stat(filepath.Join(dev, sdcName)); os.IsNotExist(err) {
|
||||
inserted = false
|
||||
}
|
||||
go func() {
|
||||
defer f.Close()
|
||||
p.events <- gui.SDCardEvent{
|
||||
Inserted: inserted,
|
||||
}.Event()
|
||||
var buf [(unix.SizeofInotifyEvent + unix.PathMax + 1) * 100]byte
|
||||
for {
|
||||
n, err := f.Read(buf[:])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
evts := buf[:n]
|
||||
for len(evts) > 0 {
|
||||
evt := (*unix.InotifyEvent)(unsafe.Pointer(&evts[0]))
|
||||
evts = evts[unix.SizeofInotifyEvent:]
|
||||
var name string
|
||||
if evt.Len > 0 {
|
||||
nameb := evts[:evt.Len-1]
|
||||
evts = evts[evt.Len:]
|
||||
nameb = bytes.TrimRight(nameb, "\000")
|
||||
name = string(nameb)
|
||||
}
|
||||
if name == sdcName {
|
||||
switch {
|
||||
case evt.Mask&unix.IN_CREATE != 0:
|
||||
p.events <- gui.SDCardEvent{Inserted: true}.Event()
|
||||
case evt.Mask&unix.IN_DELETE != 0:
|
||||
p.events <- gui.SDCardEvent{Inserted: false}.Event()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Platform) initPrinterNotifier() error {
|
||||
const devDir = "/dev/usb"
|
||||
_ = os.MkdirAll(devDir, 0o755)
|
||||
fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
|
||||
if err != nil {
|
||||
return fmt.Errorf("printer inotify_init1: %w", err)
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), "inotify-printer")
|
||||
var flags uint32 = unix.IN_CREATE | unix.IN_DELETE
|
||||
if _, err = unix.InotifyAddWatch(fd, devDir, flags); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("inotify_add_watch (%s): %w", devDir, err)
|
||||
}
|
||||
|
||||
initialModel := readPrinterModel()
|
||||
initial := initialModel != ""
|
||||
go func() {
|
||||
defer f.Close()
|
||||
p.events <- gui.PrinterEvent{Connected: initial, Model: initialModel}.Event()
|
||||
p.Wakeup()
|
||||
var buf [(unix.SizeofInotifyEvent + unix.PathMax + 1) * 20]byte
|
||||
for {
|
||||
n, err := f.Read(buf[:])
|
||||
if err != nil {
|
||||
logutil.DebugLog("printer notifier read err: %v", err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
evts := buf[:n]
|
||||
for len(evts) > 0 {
|
||||
evt := (*unix.InotifyEvent)(unsafe.Pointer(&evts[0]))
|
||||
evts = evts[unix.SizeofInotifyEvent:]
|
||||
var name string
|
||||
if evt.Len > 0 {
|
||||
nameb := evts[:evt.Len-1]
|
||||
evts = evts[evt.Len:]
|
||||
nameb = bytes.TrimRight(nameb, "\000")
|
||||
name = string(nameb)
|
||||
}
|
||||
if !strings.HasPrefix(name, "lp") {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case evt.Mask&unix.IN_CREATE != 0:
|
||||
model := readPrinterModel()
|
||||
p.printerCached = nil
|
||||
p.supportsPCL = false
|
||||
logutil.DebugLog("Printer event: connected model=%s", model)
|
||||
p.events <- gui.PrinterEvent{Connected: true, Model: model}.Event()
|
||||
p.Wakeup()
|
||||
case evt.Mask&unix.IN_DELETE != 0:
|
||||
p.printerCached = nil
|
||||
p.supportsPCL = false
|
||||
logutil.DebugLog("Printer event: disconnected")
|
||||
p.events <- gui.PrinterEvent{Connected: false}.Event()
|
||||
p.Wakeup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Fallback poll in case inotify misses events.
|
||||
go func() {
|
||||
prevConnected, prevModel := initial, initialModel
|
||||
for {
|
||||
connected, model := p.PrinterStatus()
|
||||
if connected != prevConnected || (model != "" && model != prevModel) {
|
||||
prevConnected, prevModel = connected, model
|
||||
p.printerCached = nil
|
||||
p.supportsPCL = false
|
||||
logutil.DebugLog("Printer poll: connected=%v model=%s", connected, model)
|
||||
p.events <- gui.PrinterEvent{Connected: connected, Model: model}.Event()
|
||||
p.Wakeup()
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func readPrinterModel() string {
|
||||
paths := []string{}
|
||||
if matches, err := filepath.Glob("/sys/class/usb/lp*/device/ieee1284_id"); err == nil {
|
||||
paths = append(paths, matches...)
|
||||
}
|
||||
if matches, err := filepath.Glob("/sys/bus/usb/devices/*/ieee1284_id"); err == nil {
|
||||
paths = append(paths, matches...)
|
||||
}
|
||||
for _, p := range paths {
|
||||
if data, err := os.ReadFile(p); err == nil {
|
||||
if m := parseIEEE1284(string(data)); m != "" {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
// fallback to product string
|
||||
for _, p := range []string{"/sys/class/usb/lp0/device/product"} {
|
||||
if data, err := os.ReadFile(p); err == nil {
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseIEEE1284(s string) string {
|
||||
fields := strings.Split(strings.TrimSpace(s), ";")
|
||||
var mfg, mdl string
|
||||
for _, f := range fields {
|
||||
parts := strings.SplitN(f, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
switch strings.ToUpper(parts[0]) {
|
||||
case "MFG":
|
||||
mfg = parts[1]
|
||||
case "MDL":
|
||||
mdl = parts[1]
|
||||
}
|
||||
}
|
||||
if mfg != "" && mdl != "" {
|
||||
return fmt.Sprintf("%s %s", mfg, mdl)
|
||||
}
|
||||
if mdl != "" {
|
||||
return mdl
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mountFS() error {
|
||||
devices := []struct {
|
||||
path string
|
||||
@ -578,9 +256,7 @@ func mountFS() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
func releaseMemory() {
|
||||
runtime.GC()
|
||||
rdebug.FreeOSMemory()
|
||||
}
|
||||
|
||||
214
cmd/controller/platform_rpi_notify.go
Normal file
214
cmd/controller/platform_rpi_notify.go
Normal file
@ -0,0 +1,214 @@
|
||||
//go:build linux && arm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
"seedetcher.com/gui"
|
||||
"seedetcher.com/logutil"
|
||||
)
|
||||
|
||||
func (p *Platform) PrinterStatus() (bool, string) {
|
||||
for i := 0; i < 3; i++ {
|
||||
matches, _ := filepath.Glob("/dev/usb/lp*")
|
||||
if len(matches) > 0 {
|
||||
return true, readPrinterModel()
|
||||
}
|
||||
if i < 2 {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func (p *Platform) initSDCardNotifier() error {
|
||||
fd, err := unix.InotifyInit1(unix.IN_CLOEXEC)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inotify_init1: %w", err)
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), "inotify")
|
||||
var flags uint32 = unix.IN_CREATE | unix.IN_DELETE
|
||||
const dev = "/dev"
|
||||
if _, err = unix.InotifyAddWatch(fd, dev, flags); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("inotify_add_watch: %w", err)
|
||||
}
|
||||
const sdcName = "mmcblk0"
|
||||
inserted := true
|
||||
if _, err := os.Stat(filepath.Join(dev, sdcName)); os.IsNotExist(err) {
|
||||
inserted = false
|
||||
}
|
||||
go func() {
|
||||
defer f.Close()
|
||||
p.events <- gui.SDCardEvent{
|
||||
Inserted: inserted,
|
||||
}.Event()
|
||||
var buf [(unix.SizeofInotifyEvent + unix.PathMax + 1) * 100]byte
|
||||
for {
|
||||
n, err := f.Read(buf[:])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
evts := buf[:n]
|
||||
for len(evts) > 0 {
|
||||
evt := (*unix.InotifyEvent)(unsafe.Pointer(&evts[0]))
|
||||
evts = evts[unix.SizeofInotifyEvent:]
|
||||
var name string
|
||||
if evt.Len > 0 {
|
||||
nameb := evts[:evt.Len-1]
|
||||
evts = evts[evt.Len:]
|
||||
nameb = bytes.TrimRight(nameb, "\000")
|
||||
name = string(nameb)
|
||||
}
|
||||
if name == sdcName {
|
||||
switch {
|
||||
case evt.Mask&unix.IN_CREATE != 0:
|
||||
p.events <- gui.SDCardEvent{Inserted: true}.Event()
|
||||
case evt.Mask&unix.IN_DELETE != 0:
|
||||
p.events <- gui.SDCardEvent{Inserted: false}.Event()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Platform) initPrinterNotifier() error {
|
||||
const devDir = "/dev/usb"
|
||||
_ = os.MkdirAll(devDir, 0o755)
|
||||
fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
|
||||
if err != nil {
|
||||
return fmt.Errorf("printer inotify_init1: %w", err)
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), "inotify-printer")
|
||||
var flags uint32 = unix.IN_CREATE | unix.IN_DELETE
|
||||
if _, err = unix.InotifyAddWatch(fd, devDir, flags); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("inotify_add_watch (%s): %w", devDir, err)
|
||||
}
|
||||
|
||||
initialModel := readPrinterModel()
|
||||
initial := initialModel != ""
|
||||
go func() {
|
||||
defer f.Close()
|
||||
p.events <- gui.PrinterEvent{Connected: initial, Model: initialModel}.Event()
|
||||
p.Wakeup()
|
||||
var buf [(unix.SizeofInotifyEvent + unix.PathMax + 1) * 20]byte
|
||||
for {
|
||||
n, err := f.Read(buf[:])
|
||||
if err != nil {
|
||||
logutil.DebugLog("printer notifier read err: %v", err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
evts := buf[:n]
|
||||
for len(evts) > 0 {
|
||||
evt := (*unix.InotifyEvent)(unsafe.Pointer(&evts[0]))
|
||||
evts = evts[unix.SizeofInotifyEvent:]
|
||||
var name string
|
||||
if evt.Len > 0 {
|
||||
nameb := evts[:evt.Len-1]
|
||||
evts = evts[evt.Len:]
|
||||
nameb = bytes.TrimRight(nameb, "\000")
|
||||
name = string(nameb)
|
||||
}
|
||||
if !strings.HasPrefix(name, "lp") {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case evt.Mask&unix.IN_CREATE != 0:
|
||||
model := readPrinterModel()
|
||||
p.printerCached = nil
|
||||
p.supportsPCL = false
|
||||
p.hostPCLForce600 = false
|
||||
logutil.DebugLog("Printer event: connected model=%s", model)
|
||||
p.events <- gui.PrinterEvent{Connected: true, Model: model}.Event()
|
||||
p.Wakeup()
|
||||
case evt.Mask&unix.IN_DELETE != 0:
|
||||
p.printerCached = nil
|
||||
p.supportsPCL = false
|
||||
p.hostPCLForce600 = false
|
||||
logutil.DebugLog("Printer event: disconnected")
|
||||
p.events <- gui.PrinterEvent{Connected: false}.Event()
|
||||
p.Wakeup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Fallback poll in case inotify misses events.
|
||||
go func() {
|
||||
prevConnected, prevModel := initial, initialModel
|
||||
for {
|
||||
connected, model := p.PrinterStatus()
|
||||
if connected != prevConnected || (model != "" && model != prevModel) {
|
||||
prevConnected, prevModel = connected, model
|
||||
p.printerCached = nil
|
||||
p.supportsPCL = false
|
||||
p.hostPCLForce600 = false
|
||||
logutil.DebugLog("Printer poll: connected=%v model=%s", connected, model)
|
||||
p.events <- gui.PrinterEvent{Connected: connected, Model: model}.Event()
|
||||
p.Wakeup()
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func readPrinterModel() string {
|
||||
paths := []string{}
|
||||
if matches, err := filepath.Glob("/sys/class/usb/lp*/device/ieee1284_id"); err == nil {
|
||||
paths = append(paths, matches...)
|
||||
}
|
||||
if matches, err := filepath.Glob("/sys/bus/usb/devices/*/ieee1284_id"); err == nil {
|
||||
paths = append(paths, matches...)
|
||||
}
|
||||
for _, p := range paths {
|
||||
if data, err := os.ReadFile(p); err == nil {
|
||||
if m := parseIEEE1284(string(data)); m != "" {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
// fallback to product string
|
||||
for _, p := range []string{"/sys/class/usb/lp0/device/product"} {
|
||||
if data, err := os.ReadFile(p); err == nil {
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseIEEE1284(s string) string {
|
||||
fields := strings.Split(strings.TrimSpace(s), ";")
|
||||
var mfg, mdl string
|
||||
for _, f := range fields {
|
||||
parts := strings.SplitN(f, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
switch strings.ToUpper(parts[0]) {
|
||||
case "MFG":
|
||||
mfg = parts[1]
|
||||
case "MDL":
|
||||
mdl = parts[1]
|
||||
}
|
||||
}
|
||||
if mfg != "" && mdl != "" {
|
||||
return fmt.Sprintf("%s %s", mfg, mdl)
|
||||
}
|
||||
if mdl != "" {
|
||||
return mdl
|
||||
}
|
||||
return ""
|
||||
}
|
||||
802
cmd/controller/platform_rpi_print.go
Normal file
802
cmd/controller/platform_rpi_print.go
Normal file
@ -0,0 +1,802 @@
|
||||
//go:build linux && arm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/bip39"
|
||||
"seedetcher.com/gui"
|
||||
"seedetcher.com/logutil"
|
||||
"seedetcher.com/printer"
|
||||
)
|
||||
|
||||
func isDeviceWriteEIO(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, syscall.EIO) {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "/dev/usb/lp0") && strings.Contains(msg, "input/output error")
|
||||
}
|
||||
|
||||
type hostRenderPlan struct {
|
||||
descForHost *urtypes.OutputDescriptor
|
||||
totalShares int
|
||||
compactSingleSided bool
|
||||
shardQRPayloads [][]string
|
||||
}
|
||||
|
||||
func prepareHostRenderPlan(desc *urtypes.OutputDescriptor, totalMnemonicCount int, isSinglesigDesc, singlesigWithDescriptorSide bool) (hostRenderPlan, error) {
|
||||
plan := hostRenderPlan{
|
||||
descForHost: desc,
|
||||
totalShares: totalMnemonicCount,
|
||||
}
|
||||
if isSinglesigDesc && !singlesigWithDescriptorSide {
|
||||
// Singlesig descriptor is seed-side metadata only; no descriptor-side plates.
|
||||
plan.descForHost = nil
|
||||
}
|
||||
if plan.descForHost != nil && len(plan.descForHost.Keys) > 0 && !isSinglesigDesc {
|
||||
plan.totalShares = len(plan.descForHost.Keys)
|
||||
}
|
||||
if plan.totalShares <= 0 {
|
||||
return hostRenderPlan{}, fmt.Errorf("no shares to print")
|
||||
}
|
||||
|
||||
plan.compactSingleSided = plan.descForHost != nil &&
|
||||
printer.CompactDescriptor2of3Enabled() &&
|
||||
plan.descForHost.Type == urtypes.SortedMulti &&
|
||||
plan.descForHost.Threshold == 2 &&
|
||||
len(plan.descForHost.Keys) == 3 &&
|
||||
plan.totalShares == 3
|
||||
|
||||
if plan.descForHost != nil && len(plan.descForHost.Keys) > 0 {
|
||||
if isSinglesigDesc && singlesigWithDescriptorSide {
|
||||
qrPayload := printer.DescriptorQRPayload(plan.descForHost)
|
||||
if qrPayload == "" {
|
||||
return hostRenderPlan{}, fmt.Errorf("render: empty singlesig descriptor qr payload")
|
||||
}
|
||||
plan.shardQRPayloads = make([][]string, plan.totalShares)
|
||||
for i := range plan.shardQRPayloads {
|
||||
plan.shardQRPayloads[i] = []string{qrPayload}
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
plan.shardQRPayloads = make([][]string, plan.totalShares)
|
||||
for i := 0; i < plan.totalShares; i++ {
|
||||
descKeyIdx := i % len(plan.descForHost.Keys)
|
||||
plan.shardQRPayloads[i], err = printer.DescriptorShardQRPayloadsForShare(plan.descForHost, plan.totalShares, descKeyIdx)
|
||||
if err != nil {
|
||||
return hostRenderPlan{}, fmt.Errorf("render: descriptor shard qrs: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
type hostRenderedBatch struct {
|
||||
seedBatch []*image.Paletted
|
||||
descBatch []*image.Paletted
|
||||
statsRows []printer.EtchPlateStat
|
||||
}
|
||||
|
||||
func renderHostBatch(
|
||||
mnemonics []bip39.Mnemonic,
|
||||
seedInfoDesc *urtypes.OutputDescriptor,
|
||||
plan hostRenderPlan,
|
||||
isSinglesigJob bool,
|
||||
singlesigWithInfo bool,
|
||||
opts printer.RasterOptions,
|
||||
start, end int,
|
||||
progress func(stage printer.PrintStage, current, total int64),
|
||||
prepareDone *int64,
|
||||
prepareTotal int64,
|
||||
collectStats bool,
|
||||
) (hostRenderedBatch, error) {
|
||||
batchSize := end - start
|
||||
out := hostRenderedBatch{
|
||||
seedBatch: make([]*image.Paletted, 0, batchSize),
|
||||
}
|
||||
if plan.descForHost != nil && !plan.compactSingleSided {
|
||||
out.descBatch = make([]*image.Paletted, 0, batchSize)
|
||||
}
|
||||
if collectStats {
|
||||
statsCap := batchSize
|
||||
if plan.descForHost != nil && !plan.compactSingleSided {
|
||||
statsCap *= 2
|
||||
}
|
||||
out.statsRows = make([]printer.EtchPlateStat, 0, statsCap)
|
||||
}
|
||||
|
||||
for i := start; i < end; i++ {
|
||||
m := mnemonics[i%len(mnemonics)]
|
||||
seedShareNum, seedShareTotal := i+1, plan.totalShares
|
||||
if isSinglesigJob {
|
||||
seedShareNum, seedShareTotal = 1, 1
|
||||
}
|
||||
var seedDesc *urtypes.OutputDescriptor
|
||||
if singlesigWithInfo {
|
||||
seedDesc = seedInfoDesc
|
||||
}
|
||||
seedImg, err := printer.RenderSeedPlateBitmapWithDescriptor(m, seedShareNum, seedShareTotal, seedDesc, opts)
|
||||
if err != nil {
|
||||
return hostRenderedBatch{}, fmt.Errorf("render: seed plate %d: %w", i+1, err)
|
||||
}
|
||||
if plan.compactSingleSided {
|
||||
descKeyIdx := i % len(plan.descForHost.Keys)
|
||||
descQR := ""
|
||||
if i < len(plan.shardQRPayloads) && len(plan.shardQRPayloads[i]) > 0 {
|
||||
descQR = plan.shardQRPayloads[i][0]
|
||||
}
|
||||
seedImg, err = printer.RenderCompact2of3PlateBitmap(m, plan.descForHost, descKeyIdx, opts, descQR)
|
||||
if err != nil {
|
||||
return hostRenderedBatch{}, fmt.Errorf("render: compact plate %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
out.seedBatch = append(out.seedBatch, seedImg)
|
||||
if collectStats {
|
||||
out.statsRows = append(out.statsRows, printer.ComputeEtchPlateStat(seedImg, i+1, "seed", opts.DPI))
|
||||
}
|
||||
*prepareDone = *prepareDone + 1
|
||||
if progress != nil && prepareTotal > 0 {
|
||||
progress(printer.StagePrepare, *prepareDone, prepareTotal)
|
||||
}
|
||||
|
||||
if plan.descForHost != nil && !plan.compactSingleSided {
|
||||
descKeyIdx := i % len(plan.descForHost.Keys)
|
||||
var descQRs []string
|
||||
if i < len(plan.shardQRPayloads) {
|
||||
descQRs = plan.shardQRPayloads[i]
|
||||
}
|
||||
descImg, err := printer.RenderDescriptorPlateBitmap(plan.descForHost, descKeyIdx, i+1, plan.totalShares, opts, descQRs)
|
||||
if err != nil {
|
||||
return hostRenderedBatch{}, fmt.Errorf("render: descriptor plate %d: %w", i+1, err)
|
||||
}
|
||||
out.descBatch = append(out.descBatch, descImg)
|
||||
if collectStats {
|
||||
out.statsRows = append(out.statsRows, printer.ComputeEtchPlateStat(descImg, i+1, "descriptor", opts.DPI))
|
||||
}
|
||||
*prepareDone = *prepareDone + 1
|
||||
if progress != nil && prepareTotal > 0 {
|
||||
progress(printer.StagePrepare, *prepareDone, prepareTotal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func hostSharesPerBatch(plan hostRenderPlan) int {
|
||||
sharesPerBatch := 2 // Fixed 2x2 layout with descriptor side (2 shares/page).
|
||||
if plan.descForHost == nil || plan.compactSingleSided {
|
||||
sharesPerBatch = 4 // Fixed 2x2 seed-only path (4 shares/page).
|
||||
}
|
||||
if sharesPerBatch < 1 {
|
||||
return 1
|
||||
}
|
||||
return sharesPerBatch
|
||||
}
|
||||
|
||||
func hostPrepareTotal(plan hostRenderPlan, totalShares int) int64 {
|
||||
prepareTotal := int64(totalShares)
|
||||
if plan.descForHost != nil && !plan.compactSingleSided {
|
||||
prepareTotal *= 2
|
||||
}
|
||||
return prepareTotal
|
||||
}
|
||||
|
||||
func hostStatsCap(plan hostRenderPlan, totalShares int) int {
|
||||
statsCap := totalShares
|
||||
if plan.descForHost != nil && !plan.compactSingleSided {
|
||||
statsCap *= 2
|
||||
}
|
||||
return statsCap
|
||||
}
|
||||
|
||||
func buildStatsPageFromRows(statsRows []printer.EtchPlateStat, dpi float64, paper printer.PaperSize) (*image.Paletted, error) {
|
||||
report, err := printer.BuildEtchStatsReportFromStats(statsRows, dpi, paper)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return printer.RenderEtchStatsPage(report, paper, dpi)
|
||||
}
|
||||
|
||||
func runHostRenderBatches(
|
||||
mnemonics []bip39.Mnemonic,
|
||||
seedInfoDesc *urtypes.OutputDescriptor,
|
||||
plan hostRenderPlan,
|
||||
isSinglesigJob bool,
|
||||
singlesigWithInfo bool,
|
||||
opts printer.RasterOptions,
|
||||
progress func(stage printer.PrintStage, current, total int64),
|
||||
collectStats bool,
|
||||
onBatch func(batch hostRenderedBatch, start, end, batchIndex, numBatches int) error,
|
||||
) ([]printer.EtchPlateStat, error) {
|
||||
totalShares := plan.totalShares
|
||||
sharesPerBatch := hostSharesPerBatch(plan)
|
||||
numBatches := hostBatchCount(totalShares, sharesPerBatch)
|
||||
prepareDone := int64(0)
|
||||
prepareTotal := hostPrepareTotal(plan, totalShares)
|
||||
var statsRows []printer.EtchPlateStat
|
||||
if collectStats {
|
||||
statsRows = make([]printer.EtchPlateStat, 0, hostStatsCap(plan, totalShares))
|
||||
}
|
||||
|
||||
batchIndex := 0
|
||||
for start := 0; start < totalShares; start += sharesPerBatch {
|
||||
end := start + sharesPerBatch
|
||||
if end > totalShares {
|
||||
end = totalShares
|
||||
}
|
||||
batch, err := renderHostBatch(
|
||||
mnemonics,
|
||||
seedInfoDesc,
|
||||
plan,
|
||||
isSinglesigJob,
|
||||
singlesigWithInfo,
|
||||
opts,
|
||||
start,
|
||||
end,
|
||||
progress,
|
||||
&prepareDone,
|
||||
prepareTotal,
|
||||
collectStats,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if collectStats {
|
||||
statsRows = append(statsRows, batch.statsRows...)
|
||||
}
|
||||
batchIndex++
|
||||
if onBatch != nil {
|
||||
if err := onBatch(batch, start, end, batchIndex, numBatches); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return statsRows, nil
|
||||
}
|
||||
|
||||
type hostSendTracker struct {
|
||||
progress func(stage printer.PrintStage, current, total int64)
|
||||
sendDone int64
|
||||
sendTotal int64
|
||||
sendBatchBytes int64
|
||||
statsSendBudget int64
|
||||
}
|
||||
|
||||
func newHostSendTracker(progress func(stage printer.PrintStage, current, total int64)) hostSendTracker {
|
||||
return hostSendTracker{
|
||||
progress: progress,
|
||||
sendBatchBytes: -1,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *hostSendTracker) ensureBatchBudget(batchBytes int64, numBatches int, includeStats bool) {
|
||||
if t.sendBatchBytes >= 0 {
|
||||
return
|
||||
}
|
||||
t.sendBatchBytes = batchBytes
|
||||
t.sendTotal = batchBytes * int64(numBatches)
|
||||
if includeStats {
|
||||
// Keep send-total stable from the beginning so progress never jumps backward.
|
||||
t.statsSendBudget = batchBytes
|
||||
t.sendTotal += t.statsSendBudget
|
||||
}
|
||||
}
|
||||
|
||||
func (t *hostSendTracker) notifySend() {
|
||||
if t.progress != nil && t.sendTotal > 0 {
|
||||
t.progress(printer.StageSend, t.sendDone, t.sendTotal)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *hostSendTracker) batchProgress(baseDone int64) func(stage printer.PrintStage, current, total int64) {
|
||||
return func(stage printer.PrintStage, current, total int64) {
|
||||
if stage != printer.StageSend || t.progress == nil || t.sendTotal <= 0 || total <= 0 {
|
||||
return
|
||||
}
|
||||
globalCurrent := baseDone + current
|
||||
if globalCurrent > t.sendTotal {
|
||||
globalCurrent = t.sendTotal
|
||||
}
|
||||
t.progress(printer.StageSend, globalCurrent, t.sendTotal)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *hostSendTracker) statsProgress(baseDone int64) func(stage printer.PrintStage, current, total int64) {
|
||||
return func(stage printer.PrintStage, current, total int64) {
|
||||
if stage != printer.StageSend || t.progress == nil || t.sendTotal <= 0 || total <= 0 {
|
||||
return
|
||||
}
|
||||
scaled := current
|
||||
if t.statsSendBudget > 0 {
|
||||
scaled = (current * t.statsSendBudget) / total
|
||||
}
|
||||
globalCurrent := baseDone + scaled
|
||||
if globalCurrent > t.sendTotal {
|
||||
globalCurrent = t.sendTotal
|
||||
}
|
||||
t.progress(printer.StageSend, globalCurrent, t.sendTotal)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *hostSendTracker) finishBatch() {
|
||||
t.sendDone += t.sendBatchBytes
|
||||
if t.sendDone > t.sendTotal {
|
||||
t.sendDone = t.sendTotal
|
||||
}
|
||||
}
|
||||
|
||||
func (t *hostSendTracker) finishStats() {
|
||||
if t.statsSendBudget > 0 {
|
||||
t.sendDone += t.statsSendBudget
|
||||
} else {
|
||||
t.sendDone = t.sendTotal
|
||||
}
|
||||
if t.sendDone > t.sendTotal {
|
||||
t.sendDone = t.sendTotal
|
||||
}
|
||||
}
|
||||
|
||||
type pclNeed600RetryError struct {
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *pclNeed600RetryError) Error() string {
|
||||
if e == nil || e.cause == nil {
|
||||
return "pcl 1200->600 retry required"
|
||||
}
|
||||
return e.cause.Error()
|
||||
}
|
||||
|
||||
func (e *pclNeed600RetryError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.cause
|
||||
}
|
||||
|
||||
func (p *Platform) CreatePlates(ctx *gui.Context, mnemonic bip39.Mnemonic, desc *urtypes.OutputDescriptor, keyIdx int, paper printer.PaperSize, opts printer.RasterOptions) error {
|
||||
logutil.DebugLog("Entering CreatePlates with mnemonic length: %d, desc: %v, keyIdx: %d", len(mnemonic), desc != nil, keyIdx)
|
||||
|
||||
releaseMemory()
|
||||
p.printing = true
|
||||
defer func() {
|
||||
p.printing = false
|
||||
releaseMemory()
|
||||
}()
|
||||
|
||||
var mnemonics []bip39.Mnemonic
|
||||
isSinglesigDesc := desc != nil && len(desc.Keys) == 1 && desc.Type == urtypes.Singlesig
|
||||
singlesigWithDescriptorSide := isSinglesigDesc && opts.SinglesigLayout == printer.SinglesigLayoutSeedWithDescriptorQR
|
||||
singlesigWithInfo := isSinglesigDesc && opts.SinglesigLayout == printer.SinglesigLayoutSeedWithInfo
|
||||
isSinglesigJob := desc == nil || isSinglesigDesc
|
||||
if isSinglesigJob {
|
||||
// Singlesig default: print two identical seed plates.
|
||||
mnemonics = []bip39.Mnemonic{mnemonic, mnemonic}
|
||||
} else if ctx == nil { // Add this
|
||||
mnemonics = []bip39.Mnemonic{mnemonic} // Use passed mnemonic
|
||||
} else {
|
||||
mnemonics = make([]bip39.Mnemonic, len(desc.Keys))
|
||||
i := 0
|
||||
for _, k := range desc.Keys {
|
||||
if m, ok := ctx.Keystores[k.MasterFingerprint]; ok {
|
||||
mnemonics[i] = m
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progress := func(stage printer.PrintStage, current, total int64) {
|
||||
if ctx != nil && ctx.PrintProgress != nil && total > 0 {
|
||||
ctx.PrintProgress(stage, current, total)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.DPI <= 0 {
|
||||
opts.DPI = 1200
|
||||
}
|
||||
if opts.PrinterLang == printer.PrinterLangBrotherHBP {
|
||||
if opts.DPI != 600 {
|
||||
logutil.DebugLog("HBP path: forcing 600 DPI")
|
||||
}
|
||||
opts.DPI = 600
|
||||
return p.createPlatesHBP(ctx, mnemonics, desc, keyIdx, paper, opts, progress)
|
||||
}
|
||||
|
||||
printerDev := p.Printer()
|
||||
if printerDev == nil {
|
||||
logutil.DebugLog("Printer is nil")
|
||||
return fmt.Errorf("no printer available")
|
||||
}
|
||||
if p.supportsPCL {
|
||||
logutil.DebugLog("Printer acquired (PCL), preparing to write job")
|
||||
} else {
|
||||
logutil.DebugLog("Printer acquired (non-PCL), using raster-to-PDF path")
|
||||
}
|
||||
|
||||
if !p.supportsPCL && opts.DPI > 600 {
|
||||
// Gadget fallback path is heavier (raster->PDF); keep it conservative.
|
||||
opts.DPI = 600
|
||||
}
|
||||
if p.supportsPCL && opts.PrinterLang == printer.PrinterLangPS {
|
||||
return p.createPlatesPostScript(ctx, mnemonics, desc, keyIdx, paper, opts, progress)
|
||||
}
|
||||
if p.supportsPCL {
|
||||
if p.hostPCLForce600 && opts.DPI > 600 {
|
||||
logutil.DebugLog("PCL host path: forcing 600 DPI due to prior 1200 write failure")
|
||||
opts.DPI = 600
|
||||
}
|
||||
// Host-mode PCL path: render and send in page-sized batches to reduce peak RAM.
|
||||
retryPCL:
|
||||
plan, err := prepareHostRenderPlan(desc, len(mnemonics), isSinglesigDesc, singlesigWithDescriptorSide)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
totalShares := plan.totalShares
|
||||
composeTotal := int64(hostBatchCount(totalShares, hostSharesPerBatch(plan)))
|
||||
if opts.EtchStatsPage {
|
||||
composeTotal++
|
||||
}
|
||||
send := newHostSendTracker(progress)
|
||||
statsRows, err := runHostRenderBatches(
|
||||
mnemonics,
|
||||
desc,
|
||||
plan,
|
||||
isSinglesigJob,
|
||||
singlesigWithInfo,
|
||||
opts,
|
||||
progress,
|
||||
opts.EtchStatsPage,
|
||||
func(batch hostRenderedBatch, start, end, batchIndex, numBatches int) error {
|
||||
if progress != nil {
|
||||
progress(printer.StageCompose, int64(batchIndex), composeTotal)
|
||||
}
|
||||
if send.sendBatchBytes < 0 {
|
||||
batchBytes, err := printer.EstimatePCLPlatesBytes(batch.seedBatch, batch.descBatch, opts.DPI, paper)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pcl: estimate batch %d-%d: %w", start+1, end, err)
|
||||
}
|
||||
send.ensureBatchBudget(batchBytes, numBatches, opts.EtchStatsPage)
|
||||
}
|
||||
batchProgress := send.batchProgress(send.sendDone)
|
||||
send.notifySend()
|
||||
if err := printer.WritePCLPlatesWithInvert(printerDev, batch.seedBatch, batch.descBatch, opts.DPI, paper, opts.Invert, batchProgress); err != nil {
|
||||
if send.sendDone == 0 && opts.DPI > 600 && isDeviceWriteEIO(err) {
|
||||
return &pclNeed600RetryError{cause: err}
|
||||
}
|
||||
return fmt.Errorf("pcl: write batch %d-%d: %w", start+1, end, err)
|
||||
}
|
||||
send.finishBatch()
|
||||
send.notifySend()
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
var need600 *pclNeed600RetryError
|
||||
if errors.As(err, &need600) {
|
||||
logutil.DebugLog("PCL host path: write failed at %.0fdpi with EIO; retrying at 600dpi", opts.DPI)
|
||||
p.hostPCLForce600 = true
|
||||
opts.DPI = 600
|
||||
printerDev = p.Printer()
|
||||
if printerDev == nil {
|
||||
return fmt.Errorf("no printer available after 1200->600 fallback")
|
||||
}
|
||||
goto retryPCL
|
||||
}
|
||||
return err
|
||||
}
|
||||
if opts.EtchStatsPage {
|
||||
if progress != nil {
|
||||
progress(printer.StageCompose, composeTotal, composeTotal)
|
||||
}
|
||||
statsPage, err := buildStatsPageFromRows(statsRows, opts.DPI, paper)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stats: build/render page: %w", err)
|
||||
}
|
||||
send.notifySend()
|
||||
statsProgress := send.statsProgress(send.sendDone)
|
||||
if err := printer.WritePCL(printerDev, []*image.Paletted{statsPage}, opts.DPI, paper, statsProgress); err != nil {
|
||||
return fmt.Errorf("stats: write pcl page: %w", err)
|
||||
}
|
||||
send.finishStats()
|
||||
send.notifySend()
|
||||
}
|
||||
logutil.DebugLog("PCL write complete (shares=%d dpi=%.0f, batched)", totalShares, opts.DPI)
|
||||
return nil
|
||||
}
|
||||
|
||||
seedImgs, descImgs, err := printer.CreatePlateBitmaps(mnemonics, desc, keyIdx, opts, progress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render: plate bitmaps: %w", err)
|
||||
}
|
||||
|
||||
pages, err := printer.ComposePagesWithInvert(seedImgs, descImgs, paper, opts.DPI, opts.Invert, progress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render: compose pages: %w", err)
|
||||
}
|
||||
if opts.EtchStatsPage {
|
||||
report, err := printer.BuildEtchStatsReport(seedImgs, descImgs, opts.DPI, paper)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stats: build report: %w", err)
|
||||
}
|
||||
statsPage, err := printer.RenderEtchStatsPage(report, paper, opts.DPI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stats: render page: %w", err)
|
||||
}
|
||||
pages = append(pages, statsPage)
|
||||
}
|
||||
|
||||
// Fallback: serialize canonical raster pages as PDF (gadget capture/dev).
|
||||
var pdf bytes.Buffer
|
||||
if err := printer.WritePDFRaster(&pdf, pages, paper); err != nil {
|
||||
return fmt.Errorf("pdf: write: %w", err)
|
||||
}
|
||||
data := pdf.Bytes()
|
||||
logutil.DebugLog("Raster-based PDF generated, size: %d bytes", len(data))
|
||||
if len(data) == 0 {
|
||||
logutil.DebugLog("Generated PDF is empty")
|
||||
return fmt.Errorf("no data to write to printer")
|
||||
}
|
||||
|
||||
const chunkSize = 1024
|
||||
total := int64(len(data))
|
||||
written := int64(0)
|
||||
if progress != nil && total > 0 {
|
||||
progress(printer.StageSend, 0, total)
|
||||
}
|
||||
for i := 0; i < len(data); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(data) {
|
||||
end = len(data)
|
||||
}
|
||||
chunk := data[i:end]
|
||||
n, err := printerDev.Write(chunk)
|
||||
if err != nil {
|
||||
logutil.DebugLog("Write chunk %d failed: %v, wrote %d bytes", i/chunkSize, err, n)
|
||||
return err
|
||||
}
|
||||
logutil.DebugLog("Wrote chunk %d, %d bytes", i/chunkSize, n)
|
||||
written += int64(n)
|
||||
if progress != nil && total > 0 {
|
||||
progress(printer.StageSend, written, total)
|
||||
}
|
||||
}
|
||||
written = total
|
||||
if progress != nil && total > 0 {
|
||||
progress(printer.StageSend, written, total)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Platform) createPlatesHBP(_ *gui.Context, mnemonics []bip39.Mnemonic, desc *urtypes.OutputDescriptor, _ int, paper printer.PaperSize, opts printer.RasterOptions, progress func(stage printer.PrintStage, current, total int64)) error {
|
||||
isSinglesigDesc := desc != nil && len(desc.Keys) == 1 && desc.Type == urtypes.Singlesig
|
||||
singlesigWithDescriptorSide := isSinglesigDesc && opts.SinglesigLayout == printer.SinglesigLayoutSeedWithDescriptorQR
|
||||
singlesigWithInfo := isSinglesigDesc && opts.SinglesigLayout == printer.SinglesigLayoutSeedWithInfo
|
||||
isSinglesigJob := desc == nil || isSinglesigDesc
|
||||
plan, err := prepareHostRenderPlan(desc, len(mnemonics), isSinglesigDesc, singlesigWithDescriptorSide)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
totalShares := plan.totalShares
|
||||
|
||||
sharesPerBatch := hostSharesPerBatch(plan)
|
||||
numBatches := hostBatchCount(totalShares, sharesPerBatch)
|
||||
composeTotal := int64(numBatches)
|
||||
sendTotal := int64(numBatches)
|
||||
if opts.EtchStatsPage {
|
||||
composeTotal++
|
||||
sendTotal++
|
||||
}
|
||||
|
||||
prepareDone := int64(0)
|
||||
prepareTotal := hostPrepareTotal(plan, totalShares)
|
||||
var statsRows []printer.EtchPlateStat
|
||||
if opts.EtchStatsPage {
|
||||
statsRows = make([]printer.EtchPlateStat, 0, hostStatsCap(plan, totalShares))
|
||||
}
|
||||
progressStep := int64(0)
|
||||
|
||||
for start := 0; start < totalShares; start += sharesPerBatch {
|
||||
end := start + sharesPerBatch
|
||||
if end > totalShares {
|
||||
end = totalShares
|
||||
}
|
||||
batch, err := renderHostBatch(
|
||||
mnemonics,
|
||||
desc,
|
||||
plan,
|
||||
isSinglesigJob,
|
||||
singlesigWithInfo,
|
||||
opts,
|
||||
start,
|
||||
end,
|
||||
progress,
|
||||
&prepareDone,
|
||||
prepareTotal,
|
||||
opts.EtchStatsPage,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.EtchStatsPage {
|
||||
statsRows = append(statsRows, batch.statsRows...)
|
||||
}
|
||||
|
||||
progressStep++
|
||||
if progress != nil && composeTotal > 0 {
|
||||
progress(printer.StageCompose, progressStep, composeTotal)
|
||||
}
|
||||
outFile, err := os.CreateTemp("/tmp", "seedetcher-hbp-*.pdf")
|
||||
if err != nil {
|
||||
return fmt.Errorf("hbp: create temp pdf: %w", err)
|
||||
}
|
||||
outPath := outFile.Name()
|
||||
if err := printer.WritePDFPlatesWithInvert(outFile, batch.seedBatch, batch.descBatch, paper, opts.DPI, opts.Invert); err != nil {
|
||||
outFile.Close()
|
||||
_ = os.Remove(outPath)
|
||||
return fmt.Errorf("hbp: write temp pdf batch %d-%d: %w", start+1, end, err)
|
||||
}
|
||||
if err := outFile.Close(); err != nil {
|
||||
_ = os.Remove(outPath)
|
||||
return fmt.Errorf("hbp: close temp pdf batch %d-%d: %w", start+1, end, err)
|
||||
}
|
||||
|
||||
dpiArg := fmt.Sprintf("%.0f", opts.DPI)
|
||||
cmdOut, err := runCommandWithOutput("/bin/print-hbp-pdf", outPath, dpiArg)
|
||||
_ = os.Remove(outPath)
|
||||
if cmdOut != "" {
|
||||
logutil.DebugLog("HBP print helper output (batch %d-%d):\n%s", start+1, end, cmdOut)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if progress != nil && sendTotal > 0 {
|
||||
progress(printer.StageSend, progressStep, sendTotal)
|
||||
}
|
||||
|
||||
releaseMemory()
|
||||
}
|
||||
if opts.EtchStatsPage {
|
||||
statsPage, err := buildStatsPageFromRows(statsRows, opts.DPI, paper)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stats: build/render page: %w", err)
|
||||
}
|
||||
progressStep++
|
||||
if progress != nil && composeTotal > 0 {
|
||||
progress(printer.StageCompose, progressStep, composeTotal)
|
||||
}
|
||||
|
||||
outFile, err := os.CreateTemp("/tmp", "seedetcher-hbp-stats-*.pdf")
|
||||
if err != nil {
|
||||
return fmt.Errorf("hbp: create stats pdf: %w", err)
|
||||
}
|
||||
outPath := outFile.Name()
|
||||
if err := printer.WritePDFRaster(outFile, []*image.Paletted{statsPage}, paper); err != nil {
|
||||
outFile.Close()
|
||||
_ = os.Remove(outPath)
|
||||
return fmt.Errorf("hbp: write stats pdf: %w", err)
|
||||
}
|
||||
if err := outFile.Close(); err != nil {
|
||||
_ = os.Remove(outPath)
|
||||
return fmt.Errorf("hbp: close stats pdf: %w", err)
|
||||
}
|
||||
|
||||
dpiArg := fmt.Sprintf("%.0f", opts.DPI)
|
||||
cmdOut, err := runCommandWithOutput("/bin/print-hbp-pdf", outPath, dpiArg)
|
||||
_ = os.Remove(outPath)
|
||||
if cmdOut != "" {
|
||||
logutil.DebugLog("HBP print helper output (stats page):\n%s", cmdOut)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if progress != nil && sendTotal > 0 {
|
||||
progress(printer.StageSend, progressStep, sendTotal)
|
||||
}
|
||||
releaseMemory()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Platform) createPlatesPostScript(ctx *gui.Context, mnemonics []bip39.Mnemonic, desc *urtypes.OutputDescriptor, _ int, paper printer.PaperSize, opts printer.RasterOptions, progress func(stage printer.PrintStage, current, total int64)) error {
|
||||
isSinglesigDesc := desc != nil && len(desc.Keys) == 1 && desc.Type == urtypes.Singlesig
|
||||
singlesigWithDescriptorSide := isSinglesigDesc && opts.SinglesigLayout == printer.SinglesigLayoutSeedWithDescriptorQR
|
||||
singlesigWithInfo := isSinglesigDesc && opts.SinglesigLayout == printer.SinglesigLayoutSeedWithInfo
|
||||
isSinglesigJob := desc == nil || isSinglesigDesc
|
||||
plan, err := prepareHostRenderPlan(desc, len(mnemonics), isSinglesigDesc, singlesigWithDescriptorSide)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
totalShares := plan.totalShares
|
||||
|
||||
printerDev := p.Printer()
|
||||
if printerDev == nil {
|
||||
return fmt.Errorf("no printer available")
|
||||
}
|
||||
|
||||
numBatches := hostBatchCount(totalShares, hostSharesPerBatch(plan))
|
||||
composeTotal := int64(numBatches)
|
||||
if opts.EtchStatsPage {
|
||||
composeTotal++
|
||||
}
|
||||
send := newHostSendTracker(progress)
|
||||
|
||||
statsRows, err := runHostRenderBatches(
|
||||
mnemonics,
|
||||
desc,
|
||||
plan,
|
||||
isSinglesigJob,
|
||||
singlesigWithInfo,
|
||||
opts,
|
||||
progress,
|
||||
opts.EtchStatsPage,
|
||||
func(batch hostRenderedBatch, start, end, batchIndex, _ int) error {
|
||||
if progress != nil {
|
||||
progress(printer.StageCompose, int64(batchIndex), composeTotal)
|
||||
}
|
||||
if send.sendBatchBytes < 0 {
|
||||
batchBytes, err := printer.EstimatePSPlatesBytes(batch.seedBatch, batch.descBatch, paper, opts.DPI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ps: estimate batch %d-%d: %w", start+1, end, err)
|
||||
}
|
||||
send.ensureBatchBudget(batchBytes, numBatches, opts.EtchStatsPage)
|
||||
}
|
||||
batchProgress := send.batchProgress(send.sendDone)
|
||||
send.notifySend()
|
||||
if err := printer.WritePSPlatesWithInvert(printerDev, batch.seedBatch, batch.descBatch, paper, opts.DPI, opts.Invert, nil, batchProgress); err != nil {
|
||||
return fmt.Errorf("ps: write batch %d-%d: %w", start+1, end, err)
|
||||
}
|
||||
send.finishBatch()
|
||||
send.notifySend()
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.EtchStatsPage {
|
||||
if progress != nil {
|
||||
progress(printer.StageCompose, composeTotal, composeTotal)
|
||||
}
|
||||
statsPage, err := buildStatsPageFromRows(statsRows, opts.DPI, paper)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stats: build/render page: %w", err)
|
||||
}
|
||||
send.notifySend()
|
||||
statsProgress := send.statsProgress(send.sendDone)
|
||||
if err := printer.WritePS(printerDev, []*image.Paletted{statsPage}, paper, statsProgress); err != nil {
|
||||
return fmt.Errorf("stats: write ps page: %w", err)
|
||||
}
|
||||
send.finishStats()
|
||||
send.notifySend()
|
||||
}
|
||||
logutil.DebugLog("PS write complete (shares=%d dpi=%.0f, batched)", totalShares, opts.DPI)
|
||||
return nil
|
||||
}
|
||||
|
||||
func hostBatchCount(totalShares, sharesPerBatch int) int {
|
||||
if sharesPerBatch < 1 {
|
||||
sharesPerBatch = 1
|
||||
}
|
||||
n := (totalShares + sharesPerBatch - 1) / sharesPerBatch
|
||||
if n < 1 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
270
cmd/controller/platform_rpi_sd.go
Normal file
270
cmd/controller/platform_rpi_sd.go
Normal file
@ -0,0 +1,270 @@
|
||||
//go:build linux && arm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
"seedetcher.com/logutil"
|
||||
)
|
||||
|
||||
func summarizeCommandOutput(out []byte) string {
|
||||
text := strings.TrimSpace(string(out))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
const maxLines = 24
|
||||
if len(lines) > maxLines {
|
||||
lines = lines[len(lines)-maxLines:]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func runCommandWithOutput(name string, args ...string) (string, error) {
|
||||
cmd := exec.Command(name, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
summary := summarizeCommandOutput(out)
|
||||
if err != nil {
|
||||
if summary != "" {
|
||||
return summary, fmt.Errorf("%s %s failed: %w\n%s", name, strings.Join(args, " "), err, summary)
|
||||
}
|
||||
return "", fmt.Errorf("%s %s failed: %w", name, strings.Join(args, " "), err)
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func nixMountIsRAMBacked() bool {
|
||||
f, err := os.Open("/proc/mounts")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
if fields[1] != "/nix" {
|
||||
continue
|
||||
}
|
||||
src, fstype := fields[0], fields[2]
|
||||
if src == "/run/hbp-ram-runtime/nix" {
|
||||
return true
|
||||
}
|
||||
if fstype == "tmpfs" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type mmcMount struct {
|
||||
dev string
|
||||
target string
|
||||
}
|
||||
|
||||
func mountedMMCPartitions() ([]mmcMount, error) {
|
||||
f, err := os.Open("/proc/mounts")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
re := regexp.MustCompile(`^/dev/mmcblk0p[0-9]+$`)
|
||||
seen := make(map[string]struct{})
|
||||
var out []mmcMount
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
dev, target := fields[0], fields[1]
|
||||
if !re.MatchString(dev) {
|
||||
continue
|
||||
}
|
||||
key := dev + "@" + target
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, mmcMount{dev: dev, target: target})
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func bindNixFromRAM() error {
|
||||
if _, err := os.Stat("/run/hbp-ram-runtime/nix"); err != nil {
|
||||
return fmt.Errorf("missing RAM nix root: %w", err)
|
||||
}
|
||||
if _, err := runCommandWithOutput("mount", "--bind", "/run/hbp-ram-runtime/nix", "/nix"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatMMCMounts(entries []mmcMount) string {
|
||||
parts := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
parts = append(parts, fmt.Sprintf("%s -> %s", e.dev, e.target))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func detachMountTarget(target string) bool {
|
||||
if target == "" {
|
||||
return false
|
||||
}
|
||||
if err := unix.Unmount(target, 0); err == nil {
|
||||
return true
|
||||
}
|
||||
if err := unix.Unmount(target, unix.MNT_DETACH); err == nil {
|
||||
return true
|
||||
}
|
||||
if _, err := runCommandWithOutput("umount", target); err == nil {
|
||||
return true
|
||||
}
|
||||
if _, err := runCommandWithOutput("umount", "-l", target); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func detachSDCardMountsFallback(restoreRAMNix bool) error {
|
||||
syscall.Sync()
|
||||
if _, err := exec.LookPath("blockdev"); err == nil {
|
||||
_, _ = runCommandWithOutput("blockdev", "--flushbufs", "/dev/mmcblk0")
|
||||
}
|
||||
|
||||
for pass := 0; pass < 6; pass++ {
|
||||
parts, err := mountedMMCPartitions()
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan mmc mounts: %w", err)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
if restoreRAMNix && !nixMountIsRAMBacked() {
|
||||
if err := bindNixFromRAM(); err != nil {
|
||||
return fmt.Errorf("restore RAM /nix bind: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
progress := false
|
||||
|
||||
// If /nix is currently RAM-backed but a lower mmc mount still exists on /nix,
|
||||
// drop the top layer once so the lower mount can be detached.
|
||||
if nixMountIsRAMBacked() {
|
||||
for _, p := range parts {
|
||||
if p.target == "/nix" {
|
||||
if detachMountTarget("/nix") {
|
||||
logutil.DebugLog("HBP prep fallback: unmounted top /nix layer to expose lower mount")
|
||||
progress = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parts, err = mountedMMCPartitions()
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan mmc mounts: %w", err)
|
||||
}
|
||||
for _, p := range parts {
|
||||
if detachMountTarget(p.target) {
|
||||
progress = true
|
||||
}
|
||||
}
|
||||
|
||||
if restoreRAMNix && !nixMountIsRAMBacked() {
|
||||
if err := bindNixFromRAM(); err == nil {
|
||||
progress = true
|
||||
}
|
||||
}
|
||||
|
||||
if !progress {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
syscall.Sync()
|
||||
remain, err := mountedMMCPartitions()
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan remaining mmc mounts: %w", err)
|
||||
}
|
||||
if len(remain) > 0 {
|
||||
for _, p := range remain {
|
||||
_ = detachMountTarget(p.target)
|
||||
}
|
||||
remain, _ = mountedMMCPartitions()
|
||||
if len(remain) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("fallback detach incomplete: mounted partitions remain: %s", formatMMCMounts(remain))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Platform) PrepareHBPForSDRemoval() error {
|
||||
if _, err := os.Stat("/bin/cups-runtime-bootstrap"); err != nil {
|
||||
return fmt.Errorf("missing /bin/cups-runtime-bootstrap: %w", err)
|
||||
}
|
||||
if _, err := os.Stat("/bin/cups-runtime-ram-feasibility"); err != nil {
|
||||
return fmt.Errorf("missing /bin/cups-runtime-ram-feasibility: %w", err)
|
||||
}
|
||||
|
||||
out, err := runCommandWithOutput("/bin/cups-runtime-bootstrap")
|
||||
if out != "" {
|
||||
logutil.DebugLog("HBP bootstrap output:\n%s", out)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err = runCommandWithOutput("/bin/cups-runtime-ram-feasibility", "stage", "core")
|
||||
if out != "" {
|
||||
logutil.DebugLog("HBP prep stage output:\n%s", out)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err = runCommandWithOutput("/bin/cups-runtime-ram-feasibility", "detach-sd")
|
||||
if out != "" {
|
||||
logutil.DebugLog("HBP prep detach output:\n%s", out)
|
||||
}
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
if nixMountIsRAMBacked() && (strings.Contains(msg, "/nix is not RAM-backed") || strings.Contains(msg, "mmc partitions still mounted")) {
|
||||
logutil.DebugLog("HBP prep: detach helper failed with RAM-backed /nix, applying fallback SD detach")
|
||||
return detachSDCardMountsFallback(true)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Platform) PrepareSDForRemoval() error {
|
||||
// PCL/PS-only flow: make SD removal safe by detaching mmc-backed mounts.
|
||||
// Best-effort stop of cupsd first so unmount can complete cleanly.
|
||||
if _, err := exec.LookPath("killall"); err == nil {
|
||||
if out, err := runCommandWithOutput("killall", "cupsd"); err == nil && out != "" {
|
||||
logutil.DebugLog("SD prep: stopped cupsd:\n%s", out)
|
||||
}
|
||||
}
|
||||
return detachSDCardMountsFallback(false)
|
||||
}
|
||||
222
descriptor/urxor2of3/urxor2of3.go
Normal file
222
descriptor/urxor2of3/urxor2of3.go
Normal file
@ -0,0 +1,222 @@
|
||||
package urxor2of3
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"seedetcher.com/bc/ur"
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/descriptor/legacy"
|
||||
)
|
||||
|
||||
var ErrInsufficientShares = errors.New("insufficient shares")
|
||||
|
||||
const (
|
||||
MinShares = 2
|
||||
)
|
||||
|
||||
// SupportsScheme reports whether the SeedHammer-compatible UR/XOR fragment
|
||||
// assignment is supported for this threshold/share count.
|
||||
func SupportsScheme(threshold, totalShares int) bool {
|
||||
switch {
|
||||
case totalShares-threshold <= 1:
|
||||
return true
|
||||
case totalShares == 4 && threshold == 2:
|
||||
return true
|
||||
case totalShares == 5 && threshold == 3:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SplitDescriptor returns one UR share per key for canonical sortedmulti
|
||||
// descriptors when the selected UR/XOR scheme encodes one fragment per share.
|
||||
// For schemes that require multiple fragments per share (for example 3-of-5),
|
||||
// use SplitDescriptorForShare.
|
||||
func SplitDescriptor(desc *urtypes.OutputDescriptor) ([]string, error) {
|
||||
if desc == nil {
|
||||
return nil, fmt.Errorf("descriptor is nil")
|
||||
}
|
||||
shares := make([]string, 0, len(desc.Keys))
|
||||
for i := range desc.Keys {
|
||||
frags, err := SplitDescriptorForShare(desc, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(frags) != 1 {
|
||||
return nil, fmt.Errorf("scheme requires %d fragments per share for %d-of-%d", len(frags), desc.Threshold, len(desc.Keys))
|
||||
}
|
||||
shares = append(shares, frags[0])
|
||||
}
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
// SplitDescriptorForShare returns UR share fragment(s) for a specific cosigner index.
|
||||
// For 2-of-3 this is one fragment. For some schemes (e.g. 3-of-5) this can be two.
|
||||
func SplitDescriptorForShare(desc *urtypes.OutputDescriptor, keyIdx int) ([]string, error) {
|
||||
payload, err := canonicalURPayload(desc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if keyIdx < 0 || keyIdx >= len(desc.Keys) {
|
||||
return nil, fmt.Errorf("invalid key index %d", keyIdx)
|
||||
}
|
||||
return ur.Split(ur.Data{
|
||||
Data: payload,
|
||||
Threshold: desc.Threshold,
|
||||
Shards: len(desc.Keys),
|
||||
}, keyIdx), nil
|
||||
}
|
||||
|
||||
// Combine recovers the canonical descriptor payload from 2 or more UR shares.
|
||||
func Combine(shares []string) ([]byte, error) {
|
||||
if len(shares) < MinShares {
|
||||
return nil, ErrInsufficientShares
|
||||
}
|
||||
var d ur.Decoder
|
||||
seqLen := 0
|
||||
for _, s := range shares {
|
||||
typ, _, n, ok := ParseShare(s)
|
||||
if !ok || typ != "crypto-output" || n < 2 {
|
||||
return nil, fmt.Errorf("invalid ur share")
|
||||
}
|
||||
if seqLen == 0 {
|
||||
seqLen = n
|
||||
} else if seqLen != n {
|
||||
return nil, fmt.Errorf("mixed ur share set")
|
||||
}
|
||||
if err := d.Add(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
typ, payload, err := d.Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload == nil {
|
||||
return nil, ErrInsufficientShares
|
||||
}
|
||||
if typ != "crypto-output" {
|
||||
return nil, fmt.Errorf("unexpected ur type: %s", typ)
|
||||
}
|
||||
if _, err := urtypes.Parse("crypto-output", payload); err != nil {
|
||||
return nil, fmt.Errorf("recovered payload parse failed: %w", err)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// ParseShare extracts UR multipart metadata for a share. ok=false means invalid
|
||||
// or non-multipart UR payload.
|
||||
func ParseShare(raw string) (typ string, seqNum int, seqLen int, ok bool) {
|
||||
s := strings.TrimSpace(strings.ToLower(raw))
|
||||
if !strings.HasPrefix(s, "ur:") {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
parts := strings.SplitN(s[len("ur:"):], "/", 3)
|
||||
if len(parts) != 3 {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
typ = parts[0]
|
||||
var n, m int
|
||||
if _, err := fmt.Sscanf(parts[1], "%d-%d", &n, &m); err != nil {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
if n <= 0 || m <= 0 {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
return typ, n, m, true
|
||||
}
|
||||
|
||||
func canonicalURPayload(desc *urtypes.OutputDescriptor) ([]byte, error) {
|
||||
if desc == nil {
|
||||
return nil, fmt.Errorf("descriptor is nil")
|
||||
}
|
||||
if desc.Type != urtypes.SortedMulti {
|
||||
return nil, fmt.Errorf("ur/xor supports sortedmulti only")
|
||||
}
|
||||
canonical := canonicalizeSortedMultiDescriptor(desc)
|
||||
normalized := legacy.NormalizeDescriptorForLegacyUR(canonical)
|
||||
return normalized.Encode(), nil
|
||||
}
|
||||
|
||||
func canonicalizeSortedMultiDescriptor(desc *urtypes.OutputDescriptor) urtypes.OutputDescriptor {
|
||||
out := *desc
|
||||
out.Keys = make([]urtypes.KeyDescriptor, len(desc.Keys))
|
||||
for i, k := range desc.Keys {
|
||||
kc := k
|
||||
kc.KeyData = append([]byte(nil), k.KeyData...)
|
||||
kc.ChainCode = append([]byte(nil), k.ChainCode...)
|
||||
kc.DerivationPath = append(urtypes.Path(nil), k.DerivationPath...)
|
||||
kc.Children = append([]urtypes.Derivation(nil), k.Children...)
|
||||
out.Keys[i] = kc
|
||||
}
|
||||
normalizeSortedMultiChildren(&out)
|
||||
sort.Slice(out.Keys, func(i, j int) bool {
|
||||
return bytes.Compare(keyDescriptorSortKey(out.Keys[i]), keyDescriptorSortKey(out.Keys[j])) < 0
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeSortedMultiChildren(desc *urtypes.OutputDescriptor) {
|
||||
const (
|
||||
changeStart = uint32(0)
|
||||
changeEnd = uint32(1)
|
||||
)
|
||||
for i := range desc.Keys {
|
||||
if len(desc.Keys[i].Children) != 0 {
|
||||
continue
|
||||
}
|
||||
desc.Keys[i].Children = []urtypes.Derivation{
|
||||
{Type: urtypes.RangeDerivation, Index: changeStart, End: changeEnd},
|
||||
{Type: urtypes.WildcardDerivation},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keyDescriptorSortKey(k urtypes.KeyDescriptor) []byte {
|
||||
out := make([]byte, 0, 128+len(k.KeyData)+len(k.ChainCode))
|
||||
var b4 [4]byte
|
||||
binary.BigEndian.PutUint32(b4[:], k.MasterFingerprint)
|
||||
out = append(out, b4[:]...)
|
||||
binary.BigEndian.PutUint32(b4[:], k.ParentFingerprint)
|
||||
out = append(out, b4[:]...)
|
||||
if k.Network != nil {
|
||||
out = append(out, []byte(k.Network.Name)...)
|
||||
}
|
||||
out = append(out, 0x00)
|
||||
|
||||
binary.BigEndian.PutUint32(b4[:], uint32(len(k.DerivationPath)))
|
||||
out = append(out, b4[:]...)
|
||||
for _, p := range k.DerivationPath {
|
||||
binary.BigEndian.PutUint32(b4[:], p)
|
||||
out = append(out, b4[:]...)
|
||||
}
|
||||
|
||||
binary.BigEndian.PutUint32(b4[:], uint32(len(k.Children)))
|
||||
out = append(out, b4[:]...)
|
||||
for _, c := range k.Children {
|
||||
out = append(out, byte(c.Type))
|
||||
if c.Hardened {
|
||||
out = append(out, 1)
|
||||
} else {
|
||||
out = append(out, 0)
|
||||
}
|
||||
binary.BigEndian.PutUint32(b4[:], c.Index)
|
||||
out = append(out, b4[:]...)
|
||||
binary.BigEndian.PutUint32(b4[:], c.End)
|
||||
out = append(out, b4[:]...)
|
||||
}
|
||||
|
||||
binary.BigEndian.PutUint32(b4[:], uint32(len(k.KeyData)))
|
||||
out = append(out, b4[:]...)
|
||||
out = append(out, k.KeyData...)
|
||||
binary.BigEndian.PutUint32(b4[:], uint32(len(k.ChainCode)))
|
||||
out = append(out, b4[:]...)
|
||||
out = append(out, k.ChainCode...)
|
||||
return out
|
||||
}
|
||||
291
descriptor/urxor2of3/urxor2of3_test.go
Normal file
291
descriptor/urxor2of3/urxor2of3_test.go
Normal file
@ -0,0 +1,291 @@
|
||||
package urxor2of3
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"seedetcher.com/bc/ur"
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/testutils"
|
||||
)
|
||||
|
||||
func testDescriptor2of3(t *testing.T) *urtypes.OutputDescriptor {
|
||||
t.Helper()
|
||||
cfg := testutils.WalletConfigs["multisig-mainnet-2of3"]
|
||||
_, desc, err := testutils.ParseWallet(cfg, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parse wallet: %v", err)
|
||||
}
|
||||
if desc == nil {
|
||||
t.Fatal("descriptor is nil")
|
||||
}
|
||||
return desc
|
||||
}
|
||||
|
||||
func TestSplitDescriptorProducesThreeURMultipartShares(t *testing.T) {
|
||||
desc := testDescriptor2of3(t)
|
||||
shares, err := SplitDescriptor(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("split descriptor: %v", err)
|
||||
}
|
||||
if len(shares) != len(desc.Keys) {
|
||||
t.Fatalf("got %d shares, want %d", len(shares), len(desc.Keys))
|
||||
}
|
||||
for i, s := range shares {
|
||||
typ, _, seqLen, ok := ParseShare(s)
|
||||
if !ok {
|
||||
t.Fatalf("share %d not multipart ur: %q", i+1, s)
|
||||
}
|
||||
if typ != "crypto-output" {
|
||||
t.Fatalf("share %d type=%q", i+1, typ)
|
||||
}
|
||||
if seqLen != desc.Threshold {
|
||||
t.Fatalf("share %d seqLen=%d want %d", i+1, seqLen, desc.Threshold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllPairsRecoverCanonicalPayload(t *testing.T) {
|
||||
desc := testDescriptor2of3(t)
|
||||
shares, err := SplitDescriptor(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("split descriptor: %v", err)
|
||||
}
|
||||
expected, err := canonicalURPayload(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("canonical payload: %v", err)
|
||||
}
|
||||
|
||||
pairs := [][2]int{{0, 1}, {0, 2}, {1, 2}}
|
||||
for _, p := range pairs {
|
||||
got, err := Combine([]string{shares[p[0]], shares[p[1]]})
|
||||
if err != nil {
|
||||
t.Fatalf("combine pair %v: %v", p, err)
|
||||
}
|
||||
if !bytes.Equal(got, expected) {
|
||||
t.Fatalf("pair %v payload mismatch", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitCanonicalizationStableAcrossReorderAndMissingChildren(t *testing.T) {
|
||||
desc := testDescriptor2of3(t)
|
||||
reordered := *desc
|
||||
reordered.Keys = append([]urtypes.KeyDescriptor(nil), desc.Keys...)
|
||||
reordered.Keys[0], reordered.Keys[2] = reordered.Keys[2], reordered.Keys[0]
|
||||
for i := range reordered.Keys {
|
||||
reordered.Keys[i].Children = nil
|
||||
}
|
||||
a, err := SplitDescriptor(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("split A: %v", err)
|
||||
}
|
||||
b, err := SplitDescriptor(&reordered)
|
||||
if err != nil {
|
||||
t.Fatalf("split B: %v", err)
|
||||
}
|
||||
for i := range len(desc.Keys) {
|
||||
if a[i] != b[i] {
|
||||
t.Fatalf("share %d mismatch", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineRejectsSingleShare(t *testing.T) {
|
||||
desc := testDescriptor2of3(t)
|
||||
shares, err := SplitDescriptor(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("split descriptor: %v", err)
|
||||
}
|
||||
if _, err := Combine([]string{shares[0]}); err != ErrInsufficientShares {
|
||||
t.Fatalf("got err=%v want ErrInsufficientShares", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitDescriptorForShareProducesTwoFragmentsFor3of5(t *testing.T) {
|
||||
cfg := testutils.WalletConfigs["multisig-3of5"]
|
||||
_, desc, err := testutils.ParseWallet(cfg, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parse wallet: %v", err)
|
||||
}
|
||||
if desc == nil {
|
||||
t.Fatal("descriptor is nil")
|
||||
}
|
||||
for i := range desc.Keys {
|
||||
frags, err := SplitDescriptorForShare(desc, i)
|
||||
if err != nil {
|
||||
t.Fatalf("split share %d: %v", i+1, err)
|
||||
}
|
||||
if len(frags) != 2 {
|
||||
t.Fatalf("share %d: got %d fragments want 2", i+1, len(frags))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineRecovers3of5FromThreeShares(t *testing.T) {
|
||||
cfg := testutils.WalletConfigs["multisig-3of5"]
|
||||
_, desc, err := testutils.ParseWallet(cfg, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parse wallet: %v", err)
|
||||
}
|
||||
if desc == nil {
|
||||
t.Fatal("descriptor is nil")
|
||||
}
|
||||
expected, err := canonicalURPayload(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("canonical payload: %v", err)
|
||||
}
|
||||
var parts []string
|
||||
for _, idx := range []int{0, 2, 4} {
|
||||
frags, err := SplitDescriptorForShare(desc, idx)
|
||||
if err != nil {
|
||||
t.Fatalf("split share %d: %v", idx+1, err)
|
||||
}
|
||||
parts = append(parts, frags...)
|
||||
}
|
||||
got, err := Combine(parts)
|
||||
if err != nil {
|
||||
t.Fatalf("combine 3-of-5: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, expected) {
|
||||
t.Fatal("combined payload mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineRecovers3of5AllTenCombinations(t *testing.T) {
|
||||
cfg := testutils.WalletConfigs["multisig-3of5"]
|
||||
_, desc, err := testutils.ParseWallet(cfg, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parse wallet: %v", err)
|
||||
}
|
||||
if desc == nil {
|
||||
t.Fatal("descriptor is nil")
|
||||
}
|
||||
expected, err := canonicalURPayload(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("canonical payload: %v", err)
|
||||
}
|
||||
for a := 0; a < len(desc.Keys)-2; a++ {
|
||||
for b := a + 1; b < len(desc.Keys)-1; b++ {
|
||||
for c := b + 1; c < len(desc.Keys); c++ {
|
||||
t.Run(fmt.Sprintf("%d-%d-%d", a+1, b+1, c+1), func(t *testing.T) {
|
||||
var parts []string
|
||||
for _, idx := range []int{a, b, c} {
|
||||
frags, err := SplitDescriptorForShare(desc, idx)
|
||||
if err != nil {
|
||||
t.Fatalf("split share %d: %v", idx+1, err)
|
||||
}
|
||||
parts = append(parts, frags...)
|
||||
}
|
||||
got, err := Combine(parts)
|
||||
if err != nil {
|
||||
t.Fatalf("combine shares (%d,%d,%d): %v", a+1, b+1, c+1, err)
|
||||
}
|
||||
if !bytes.Equal(got, expected) {
|
||||
t.Fatalf("payload mismatch for shares (%d,%d,%d)", a+1, b+1, c+1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableSharePayloadVectors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
walletKey string
|
||||
want [][]string // per-share payloads
|
||||
}{
|
||||
{
|
||||
name: "2of3_mainnet",
|
||||
walletKey: "multisig-mainnet-2of3",
|
||||
want: [][]string{
|
||||
{"UR:CRYPTO-OUTPUT/1-2/LPADAOCFADFZCYFPNLSAHHHDNBTAADMETAADMSOEADAOAOLSTAADDLOXAXHDCLAOMHEYADJKJEGWOLLRPAPEHFKOFDHPYACTNBDNAAENFWSWWFPROXPEURDEZMTOEHJYAAHDCXLOTSSKCEDKRSDLSBTELRDTSAOESSUYNSHSDWGUKKDWLPEHJNWSZOEMCSCNEOVEAYAMTAADDYOEADLOCSDYYKAEYKAEYKAOYKAOCYFTFZVTGAAYCYISLRBYTITAADDLOXAXHDCLAXNBMHKNLDGYCXVLLNNNDWFMFNADRNTIEHJTHTAOFYPARLBSPYMOPRRHQDIDLDMYDAAAHDCXYALEWTMUJNCACM"},
|
||||
{"UR:CRYPTO-OUTPUT/2-2/LPAOAOCFADFZCYFPNLSAHHHDNBMOPDZEAATIKKTEOEURRSKGSWYLYKWNSFFNLELKPELRRSWSBGFXROVAPMKSAMTAADDYOEADLOCSDYYKAEYKAEYKAOYKAOCYNDENSPVSAYCYSKDMFHLKTAADDLOXAXHDCLAXHGMSLUSWTSGWDPGWCFECKERFOSWKSOMUBAPMSSCWTSFDLTFEAHTNJKDYFZGHUERTAAHDCXCWFGHFADWYJZAXLPCAGWKORFTNKEUYNLBTCXDLTIVEIMDMDPDEPRWYKBMEEOYARSAMTAADDYOEADLOCSDYYKAEYKAEYKAOYKAOCYONMSDRGLAYCYGAAEKKTPIDLNGSEO"},
|
||||
{"UR:CRYPTO-OUTPUT/3-2/LPAXAOCFADFZCYFPNLSAHHHDNBGRPTJLUTTTWYJSOTUTRYYACTYNTNGOTKIEPYMNFHRPRNNSKKBNCKIDCETSGDPEGAJEHTCKDEEOEESRFWEOWFFLOLHTUTEYIEYAYTNSBNFWVWOLVSGASKDAMHLUSPLUONDRMDECGWCAGRDMADCEHNCFYTLGSGCWEYOXCMMNYLZMURGLHYFYECKSJPROHDIEDNRTWNHDTLCFQDGHCWTYDWVLSFBZGOCKETSBPSAOMKCPLRDWLOSKINMNRYGMFRRSHYJPREIYMUETVWAEMNJPDYVAFWEYPAPAFWBSHYMHFLRKPTSTCKONJEBNFWINYAWFDEGRWSJKWT"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "3of5_testnet",
|
||||
walletKey: "multisig-3of5",
|
||||
want: [][]string{
|
||||
{
|
||||
"UR:CRYPTO-OUTPUT/1-6/LPADAMCFAOEHCYZTWLDWLNHDHYTAADMETAADMSOEADAXAOLPTAADDLONAXHDCLAXGWWZHNLEKKPDCHVYPLATLEGEFXTESAETFZVTFHFYJZBNGYBZFHGYCNPEAXFPBYLFAAHDCXTLJTLEIHJTEMWTSENTWLVECPPYZTADEMINAHIHBSDIBZJLFRNTENDLBBGEIYMHMNAHTAADEHOYAOADAMFEPDFNCS",
|
||||
"UR:CRYPTO-OUTPUT/206-6/LPCSTOAMCFAOEHCYZTWLDWLNHDHYTTFROTENOSSPHPMEONVYCNJOWMIHOEWKHKLDWZESOEKEUTSOZMLGBWTSHNFRWZPYSTVSDAGRGDETMTFMKEADOSREVSFLWDDAKTJZSBCNUTGRKIPFNTPFFWDNMDMSVSCWCESEIOTYESNBKPBDFEAMJNENMUSBGWGWUTBBVSRFGEJSUYHHIMIHVTLYLRHNGDFTGUCX",
|
||||
},
|
||||
{
|
||||
"UR:CRYPTO-OUTPUT/2-6/LPAOAMCFAOEHCYZTWLDWLNHDHYTAADDYOEADLOCSDYYKADYKAEYKAOYKAOCYBYGHFYFXAYCYBDBAINDYTAADDLONAXHDCLAOZOZMOTWYHGFHSBEYMKKITPLPGTEYMKFRMKRLRLAASFJNISRPCYSEMSCECTURETNSAAHDCXKTRYMEMWGRWSIOVACTGLNESBPECYPKCHVEPTAAJZBNLUBBCELBROHFJP",
|
||||
"UR:CRYPTO-OUTPUT/85-6/LPCSGOAMCFAOEHCYZTWLDWLNHDHYIEBGWTWSDMPMNYYTIMSTGRCAMTCSKOTTNTDTJLRNPDWPSGINEYJZVEVWEHIOUTFPLUZSWLGOENKPOTMYGUJKMSZSIEMEIEJTLPSKFZJZBSLNDTTNATSNAAOYAYSBTILYKNTNBACEVTRKTKFMBBCETEQDFDWZPENSHHWNVOLTIMOLMEUYZMQDKBGDWFHFGSCMCKNE",
|
||||
},
|
||||
{
|
||||
"UR:CRYPTO-OUTPUT/3-6/LPAXAMCFAOEHCYZTWLDWLNHDHYMYMOGRWEGSKBCNKKAHTAADEHOYAOADAMTAADDYOEADLOCSDYYKADYKAEYKAOYKAOCYLGVDFGBZAYCYEHCFEHCMTAADDLONAXHDCLAOJSFNTDNTYALOJSJEOSHKAYGSJTFLYTMDFWVTAARNBDLRBGADOLTDSBSTPDINCWWEAAHDCXMNPALNATVSGMWZGDDNYAGUQZ",
|
||||
"UR:CRYPTO-OUTPUT/16-6/LPBEAMCFAOEHCYZTWLDWLNHDHYHDPLZTCEPSASHKPMNSNLFHBZVYCYTYDKGTEMKGGTVODTGMZEIESGSEOTIASASWAHSRMDAABKFSINBDRELKTAGULRRFFLCKCLQZOYONPANBSBLBCHSSHYPSWZHSRDROCYHYCSPLHHWYKIKEJZRHLRDWJZVYTIBDMSTETDASLDIECYLBFGTSDNZTZTFTATHNSAEYBN",
|
||||
},
|
||||
{
|
||||
"UR:CRYPTO-OUTPUT/4-6/LPAAAMCFAOEHCYZTWLDWLNHDHYQDDMVAIHTOSKHTCEAHLRAHVTCPDPWFWZGRDLJKHTZSDABETLAHTAADEHOYAOADAMTAADDYOEADLOCSDYYKADYKAEYKAOYKAOCYSFHYDYKEAYCYMSPSWSCWTAADDLONAXHDCLAOZSRLTLBWVYUTBYMOLSJKEEVYUYHFVDVLKOJZAHLYHHPYSASTKTDMCWLGIOREKK",
|
||||
"UR:CRYPTO-OUTPUT/507-6/LPCFADZOAMCFAOEHCYZTWLDWLNHDHYLTPDTPKKWDFMHNTPGOESTSFPRSIHHFWTNYNLMTURVTZTURWZAAVWTBBAMWCMOEPKLPFYRTYNRDMUIDHDHTZOLSWKMWPFSGJECATLWZSGHFDMVELRKSPTNEMTBTAYROIMLRAEJTMOLYLRRFRYGDLADILBDSVAMSPTDNSSPKOEROFGPAFYVSBAAAHDIDDWEHPFIMSS",
|
||||
},
|
||||
{
|
||||
"UR:CRYPTO-OUTPUT/5-6/LPAHAMCFAOEHCYZTWLDWLNHDHYFTRKRHGWSKAAHDCXFNZTCFLPDEGMLPCPHEMEZSDMRDJONEVONNNNTEFEOEZOECPDUTKEBYVLJZTALPRKAHTAADEHOYAOADAMTAADDYOEADLOCSDYYKADYKAEYKAOYKAOCYYASBJPHNAYCYLNCLMUTETAADDLONAXHDCLAOFXFWJTDAFGCMLKUYBKMHKECPDLTNWY",
|
||||
"UR:CRYPTO-OUTPUT/117-6/LPCSKPAMCFAOEHCYZTWLDWLNHDHYHDPLHLIOPSCMVLNSIMNYGWSFBZEMLRDABSATDWFGGUFPSALKSAQZBETYIHIODTFEFDKOFMPACPYKOYMNRSFXJYCNMHRFEEJLSTDECEDPGWHHPLRECNGUJYURGDWPESWPIHAONLOXRLIMIDTYGTCTFZMTWLBTLDWSIABTWPYKHYLSNNENTDKOGYKPDLCAPEYKJKRP",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, desc, err := testutils.ParseWallet(testutils.WalletConfigs[tc.walletKey], "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parse wallet: %v", err)
|
||||
}
|
||||
if desc == nil {
|
||||
t.Fatal("descriptor is nil")
|
||||
}
|
||||
if len(tc.want) != len(desc.Keys) {
|
||||
t.Fatalf("bad test vector shape: got %d shares want %d", len(tc.want), len(desc.Keys))
|
||||
}
|
||||
for i := range desc.Keys {
|
||||
got, err := SplitDescriptorForShare(desc, i)
|
||||
if err != nil {
|
||||
t.Fatalf("split share %d: %v", i+1, err)
|
||||
}
|
||||
if len(got) != len(tc.want[i]) {
|
||||
t.Fatalf("share %d fragments=%d want %d", i+1, len(got), len(tc.want[i]))
|
||||
}
|
||||
for j := range got {
|
||||
if got[j] != tc.want[i][j] {
|
||||
t.Fatalf("share %d fragment %d mismatch", i+1, j+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombinedPayloadParsesAsCryptoOutput(t *testing.T) {
|
||||
desc := testDescriptor2of3(t)
|
||||
shares, err := SplitDescriptor(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("split descriptor: %v", err)
|
||||
}
|
||||
payload, err := Combine([]string{shares[0], shares[2]})
|
||||
if err != nil {
|
||||
t.Fatalf("combine: %v", err)
|
||||
}
|
||||
enc := ur.Encode("crypto-output", payload, 1, 1)
|
||||
var d ur.Decoder
|
||||
if err := d.Add(enc); err != nil {
|
||||
t.Fatalf("decoder add: %v", err)
|
||||
}
|
||||
typ, out, err := d.Result()
|
||||
if err != nil {
|
||||
t.Fatalf("decoder result: %v", err)
|
||||
}
|
||||
if typ != "crypto-output" {
|
||||
t.Fatalf("typ=%q", typ)
|
||||
}
|
||||
if !bytes.Equal(out, payload) {
|
||||
t.Fatal("decoded payload mismatch")
|
||||
}
|
||||
}
|
||||
40
docs/FeCl3-40to30.md
Normal file
40
docs/FeCl3-40to30.md
Normal file
@ -0,0 +1,40 @@
|
||||
# Mixing 30% from 40% FeCl3 solution
|
||||
|
||||
30% solution seems more effective because it flows better.
|
||||
40% is more available than 30%.
|
||||
But it is easy enough to mix 30% from 40% by diluting it with distilled water.
|
||||
|
||||
## Equipment
|
||||
|
||||
- 1L 40% Ferric Chloride
|
||||
- 1L Distilled water
|
||||
- 1L HDPE/PP Bottle (do NOT use PET!)
|
||||
- 1-1.5L plastic beaker
|
||||
- Funnel
|
||||
- Glass or plastic pipette
|
||||
- Nitrile gloves
|
||||
- Eye protection
|
||||
- Accurate scale (+/-0.1g)
|
||||
|
||||
## To get 0.5L 30% Solution
|
||||
|
||||
You'll need:
|
||||
- 161.5g distilled water
|
||||
- 484.5g ferric chloride 40%
|
||||
|
||||
## How to mix
|
||||
|
||||
***IMPORTANT***\
|
||||
NEVER ADD water to Ferric Chloride. Add Ferric Chloride to water.
|
||||
It produces heat.
|
||||
This order IS IMPORTANT!
|
||||
|
||||
1) Put the beaker on the scale and tare (set it to 0)
|
||||
2) Add 161.5g distilled water. Use pipette to get it accurate.
|
||||
3) Tara (set scale to 0) with beaker still on.
|
||||
4) Slowly pour Ferric Chloride into water. DO NOT GO TOO FAST, to prevent the solution from heating up too fast.
|
||||
5) Don't overshoot: approach 480g very slowly.
|
||||
6) Use the pipette or a smaller plastic cup to add the rest up to 484.5g.
|
||||
7) Use a funnel to pour the finished solution into a HDPE/PP bottle.
|
||||
|
||||
My 3D printed etching container is designed for exactly 0.5L.
|
||||
@ -1,33 +1,49 @@
|
||||
# SeedEtcher Workflow
|
||||
|
||||
A word of warning:\
|
||||
This process is involved. It’s not a machine that you flip on and off and be done with it.\
|
||||
But the idea was to create a process that doesn’t require a $500 machine. Most of the items you need you might already have. Also, you won’t create multisig backups very often.
|
||||
So, investing a bit more time instead of money might be for you.
|
||||
This workflow has many unknown variables (like the laser printer or iron you use). I tried to rule out as many variables as possible. Still, your mileage may vary. Do not expect this to work on your first try.
|
||||
This process is somewhat involved. It’s not a machine that you flip on and off and be done with it.\
|
||||
But the idea was to create a process that doesn’t require a $500 machine. Most of the items you need you might already have. Also, you won’t create multisig backups very often. Besides, how about adding a new skill to your skill set: etching metal!
|
||||
This workflow has many unknown variables (like the laser printer/toner (original or cheap replacement?) or iron you use). I tried to rule out as many variables as possible. With b0.3 the process got a lot more reliable. Still, your mileage may vary. Do not expect this to work on your first try.
|
||||
|
||||
Here's a video of the process: https://youtu.be/O1ZcKIli9hk?si=wur4efhf88QD2LMY \
|
||||
The video DOES NOT REPLACE this guide. Please do read this guide for best results.
|
||||
Here's SeedEtcher's YouTube channel: https://www.youtube.com/@SeedEtcher \
|
||||
A video DOES NOT REPLACE this guide. Please do read this guide for best results AND security/safety instructions.
|
||||
|
||||
What you need:
|
||||
## A Note on Metal
|
||||
|
||||
- [ ] Raspi Pi Zero with screen and cam (same hardware as SeedSigner)
|
||||
316L is the most corrosion resistant type of steel. So if you want maximum longevity, don't cheap out. 304 works too but it absolutely needs citric passivation. Citric passivation is also recommended for 316L but not as urgently. See notes on citric acid bath below (post processing).
|
||||
Titanium doesn't etch well with FeCl3. Electro etching could be an option. Stay tuned.
|
||||
As for thickness: 1.5mm is the minimum. 2mm is substantial.
|
||||
|
||||
## What you need:
|
||||
|
||||
- [ ] Raspberry Pi Zero with screen and cam (same hardware as SeedSigner)
|
||||
- [ ] micro SD-Card
|
||||
- [ ] SeedEtcher Firmware
|
||||
- [ ] Laser Printer (air gapped), I only tested Brother HL series, avoid eco toners, needs to understand PCL
|
||||
- [ ] Laser Printer (air gapped), I only tested Brother HL-L5000D, L2360DN and HL-L2400D so far. But all Brother printers should work. Printer needs to understand PCL or PS (not emulated!) or Host Based Printing (HBP). Avoid eco toners.
|
||||
- [ ] Micro-USB male to USB-A female ([amazon](https://a.co/d/drLFF49))
|
||||
- [ ] Steel Plates, 10x10cm (make sure they are really flat). You can get them on ebay or amazon or cut your own.
|
||||
- [ ] Steel Plates, 304/316L, 10x10cm (make sure they are really flat). You can get them on ebay or amazon or cut your own.
|
||||
- [ ] Iron (for ironing clothes)
|
||||
- [ ] 0.5-2mm thick silicone sheet ([amazon](https://a.co/d/2F59LSZ)) for better heat and pressure transfer
|
||||
- [ ] 0.5-2mm thick silicone sheet ([amazon](https://a.co/d/2F59LSZ)) for SeedEtcher Transfer Stack™, cut 2 pieces of 110x110mm.
|
||||
- [ ] Wood board, cork mat (optional)
|
||||
- [ ] Toner Transfer Paper ([amazon](https://a.co/d/dmR4RUL))
|
||||
- [ ] Anti-etching pens ([amazon](https://a.co/d/5DnOhRR)), stop out ground e.g. from [Lascaux](https://lascaux.ch/en/products/brushes-printmaking-sets-various/lascaux-etching?shp3_product=1704) or [Charbonnel](https://intaglioprintmaker.com/product/charbonnel-lamour-black-covering-varnish/) or nail polish
|
||||
- [ ] Packaging or electrical tape
|
||||
- [ ] Masking tape
|
||||
- [ ] Acetone
|
||||
- [ ] Isopropyl Alcohol
|
||||
- [ ] Ferric Chloride Etching 40% solution ([amazon](https://a.co/d/h497Xaa))\
|
||||
- [ ] Ferric Chloride Etching 40% solution ([amazon](https://a.co/d/h497Xaa)), you can also find it in art supply stores, or on ebay.com\
|
||||
If you can, get 30% solution, it's more efficient (viscosity). But you can create 30% from 40% solution by adding distilled water: [FeCl3-40to30.md](FeCl3-40to30.md)
|
||||
Note: FeCl is for etching brass, copper and steel. It does not work for titanium! Etching titanium with hydrofluoric acid is not recommended unless you have a lab and know what you are doing.
|
||||
- [ ] Nitrile Gloves, eye protection
|
||||
- [ ] HDPE/PP etching container
|
||||
- [ ] Container for water bath (40°C)
|
||||
- [ ] Baking Soda (sodium bicarbonate, NaHCO₃), NOT baking powder (contains acids + starch)
|
||||
- [ ] Citric acid 50-100g (aka citric acid powder, food grade citric acid, sour salt) [amazon](https://a.co/d/04FpymMQ)
|
||||
|
||||
Really nice to have:
|
||||
- [ ] Thermometer
|
||||
- [ ] Aquarium air pump
|
||||
- [ ] Aquarium heater
|
||||
|
||||
## Flash SeedEtcher to SD-card
|
||||
|
||||
@ -43,139 +59,134 @@ diskutil eject /dev/diskX
|
||||
|
||||
## Load Descriptor and Seedphrases
|
||||
|
||||
There is multiple ways of doing this and it is beyond the scope of this guide.\
|
||||
There are multiple ways of doing this and it is beyond the scope of this guide.\
|
||||
SeedEtcher just needs a QR of the descriptor. The seedphrase(s) can be input via QR or manually.
|
||||
You generally use a coordinator like [sparrow](https://www.sparrowwallet.com) to create the descriptor.
|
||||
Note: Sparrow just needs the xpubs of the seedphrases for this and not the actual seedphrase(s).
|
||||
Example: Create the 3 seedphrases for a 2/3 multisig on [SeedSigner](https://seedsigner.com). Use sparrow to create the descriptor.
|
||||
Scan the descriptor from sparrow with SeedEtcher and then each seedphrase QR from the SeedSigner. Tip: use a magnifiying glass in front of the SeedEtcher cam to scan the tiny dotted QR from the SeedSigner.
|
||||
Scan the descriptor from sparrow with SeedEtcher and then each seedphrase QR from the SeedSigner. Tip: use a magnifying glass in front of the SeedEtcher cam to scan the tiny dotted QR from the SeedSigner.
|
||||
|
||||
## Descriptor Sharding (b0.2)
|
||||
## Descriptor Shares
|
||||
|
||||
For multisig backups, SeedEtcher now prints descriptor shares (`SE1:`) instead of a full descriptor on each plate.
|
||||
For multisig backups, SeedEtcher prints UR/XOR descriptor shares instead of a full descriptor on each plate for these multisig wallets configurations:
|
||||
`1/2`, `2/2`, `2/3`, `2/4`, `4/4`, `3/5`, and any `n-1/n`. No single plate reveals the full descriptor.
|
||||
For all other n/m variants we print the full descriptor.
|
||||
|
||||
- No single plate reveals the full descriptor.
|
||||
- You must scan at least `t` descriptor shares to reconstruct and export the descriptor QR.
|
||||
- For this flow, descriptor-share threshold matches the wallet signing threshold: an m-of-n wallet uses t=m descriptor shares for recovery.
|
||||
- Singlesig backup flow is unchanged (no descriptor sharding required).
|
||||
- Recovery QR is sensitive: once reconstructed, treat it like wallet metadata and keep cameras/devices away.
|
||||
|
||||
### Backup flow (on device)
|
||||
|
||||
For multisig backups, the on-device review/setup flow is:
|
||||
|
||||
1. Confirm wallet
|
||||
1. Confirm wallet (check receive/change addresses)
|
||||
2. Fingerprints review (all cosigner fingerprints, paged)
|
||||
3. Descriptor shares summary (`t/n`, `WID`, `SET`)
|
||||
4. Wallet label
|
||||
5. Paper size
|
||||
6. Print
|
||||
|
||||
### Recovery (cold-room flow)
|
||||
|
||||
1. Open `Recover Descr.` on SeedEtcher.
|
||||
2. Scan descriptor share QRs until threshold is reached.
|
||||
3. Choose `Single QR` or `Multipart UR`.
|
||||
4. Scan the exported descriptor QR with Sparrow.
|
||||
5. Verify first receive/change addresses before trusting the backup.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- Mixed share sets:
|
||||
- Error: `share set mismatch: different wallet or shard set`
|
||||
- Cause: shares from different backups mixed together.
|
||||
- Checksum/invalid share:
|
||||
- Error includes `invalid share QR` or `combine shares failed`.
|
||||
- Cause: damaged/partial scan or wrong share.
|
||||
- QR too dense:
|
||||
- Use `Multipart UR` on the 240x240 recovery screen.
|
||||
- Current practical etched-plate target is `n <= 10`.
|
||||
- Sparrow red derivation/network fields:
|
||||
- Ensure Sparrow is in Testnet mode for testnet descriptors.
|
||||
- Keep xpub/tpub serialization and coin-type path consistent.
|
||||
3. Wallet label
|
||||
4. Choose print settings
|
||||
5. Print
|
||||
|
||||
## Printing the Layouts
|
||||
|
||||
Connect the dataport of the Pi Zero (it’s the one closer to the center) to the printer’s USB port. Connect a power source to the other port. SeedEtcher sends a bitmap via this USB serial connection using PCL that most laser printers understand.
|
||||
Tip: Print the layout to paper first to check.
|
||||
Connect the dataport of the Pi Zero (it’s the one closer to the center) to the printer’s USB port. Connect a power source to the other port. SeedEtcher sends a bitmap via this USB connection using PCL, PostScript or HBP (Brother host based printers protocol).
|
||||
Tip: Print the layout to paper first to check. You can set inverted and mirrored off for that to save toner.
|
||||
For the real print remember to use inverted and mirrored!
|
||||
Use the manual feed to print onto the transfer paper. Make sure it prints onto the glossy side!
|
||||
|
||||
### Printer Settings
|
||||
|
||||
- Set resolution to highest (bitmap is sent at 600dpi)
|
||||
- Set resolution to highest your printer supports (bitmap is sent at 1200dpi or 600dpi) (Caveat: Brother HQ1200 is not true 1200. If you send a 1200dpi bitmap to a printer set to HQ1200/600 via PCL, it will print it 200% in size. Just send 600dpi in that case.) HBP only supports 600dpi.
|
||||
- Shut off toner saving options.
|
||||
- If it has a silent mode option, turn it on (prints slower which is good).
|
||||
- Set density to 0 (neutral, not +, not -).
|
||||
- Configure manual feeder to be prioritized, makes it more convenient.
|
||||
|
||||
### A Note on Printing and Security
|
||||
|
||||
SeedEtcher is an air-gapped workflow. Therefore, your printer should obviously also be air-gapped. You should not use a networked printer for this. Albeit, it is very unlikely that an attacker would be able to extract the print layout, it is not impossible. So, keep that in mind.
|
||||
SeedEtcher is an air-gapped workflow. Therefore, your printer should obviously also be air-gapped. You should not use a networked printer for this. Albeit, it is very unlikely that an attacker would be able to extract the print layout, it is not impossible. So, keep that in mind. Some printers have a memory wipe option.
|
||||
Also, no cameras should be present where you do this process. That includes cell phones.
|
||||
|
||||
## Transferring Laser Print to Steel Plate
|
||||
|
||||
Important: Do not touch the printed surface! Oils from your fingers will prevent the toner from sticking to the metal.
|
||||
***IMPORTANT:*** Do not touch the printed surface! Oils from your fingers will prevent the toner from sticking to the metal.
|
||||
|
||||
Note: When doing 2-sided plates, keep in mind that you have to etch one side at a time. You cannot transfer toner to both sides because the heat would destroy the other side.
|
||||
1) ### Sand and clean metal plates
|
||||
Use a scotch brite, steel wool or 240–320 grit sand paper to thoroughly clean the plate. If you like the brushed metal look, sand it that way. Then thoroughly clean it with warm water and dish soap. Then clean it with acetone. Optionally, clean it with isopropyl alcohol after the acetone. Do not touch it after that. Oil on surface is your enemy. Let it dry.
|
||||
|
||||
Cut the print with a 1-2mm edge around the black square. It’s easier to get off this way after transferring.
|
||||
Pre-heat the iron to around 170°C. (the temperature has to be between 150°C and 180°C). Tip: Use a thermometer to figure out how hot your iron gets.
|
||||
2) ### Cut and fasten transfer paper to plates
|
||||
Cut the plate layouts as indicated by the cut marks. Use a clean surface (fresh piece of paper on a cutting mat), a clean metal ruler and a sharp cutter.
|
||||
The new layout is designed for maximal mask coverage of a 100x100mm plate. Put the transfer paper with the laser side down on the plate. Pay attention what side should be flush with the plate edge! The left side (when looking at the plate with the transfer paper laid down) is intentionally 5mm shorter so you can tape it down with a small strip of masking tape. (Tip: put a piece of tape on the cutting mat and cut it to thin strips of 5x20mm).
|
||||
|
||||
Use a scotch brite, steel wool or 600 grit sand paper to thoroughly clean the plate. If you like the brushed metal look, sand it that way. Then thoroughly clean it with warm water and dish soap. Then clean it with acetone. Optionally, clean it with isopropyl alcohol after the acetone. Do not touch it after that. Oil on surface is your enemy. Let it dry.
|
||||
Tape the transfer paper with the laser print face down onto the steel plate. Masking tape works well for this, since it is easily removable after transfer. Only tape one side, preferebly the side where there is just black (left or right side). A tiny strip is enough. ONLY use masking tape for holding the transfer paper, not for etch masking!
|
||||

|
||||
|
||||
Put 2 layers of a thin cotton rag (no texture!) over the plate.
|
||||
TIP (highly recommended!): Get some 0.5-2mm thick silicone sheet ([amazon](https://a.co/d/2F59LSZ)). Cut it to 10x10cm and use that instead of the cotton rag. Pressure and heat distribution is better with silicone.
|
||||
*If you sanded to a brushed look, the direction of the brushed lines is important. Light breaks differently on brushed metal depending on the direction it runs. If you hold a plate towards a light source, horizontal lines will diffuse the light and reflections, vertical lines will reflect more. So, the brushed lines should run horizontally to your plate layout. The QR code is easier to scan when looking normally at the plate. This is a detail but it is worth mentioning.*
|
||||
|
||||
Put the plate on a wood block on the floor. Some household paper folded 4 times underneath the plate helps to keep it from sliding. Your contraption for this should be stable, no wiggling.
|
||||
3) ### SeedEtcher Transfer Stack™
|
||||
With the SeedEtcher Transfer Stack it is now possible to heat transfer both sides of the plate at once.
|
||||
|
||||

|
||||
|
||||
Pre-heat the iron to around 175°C. (the temperature has to be between 150°C and 180°C but no more than 180°C). Tip: Use a thermometer to figure out how hot your iron gets.
|
||||
Put a paper towel folded 4 times on your wood board (optionally use cork, even better insulator). The board should be on the floor and it should not wiggle.
|
||||
Put a silicone sheet, then the plate, another silicone sheet, then a 10x10cm piece of paper towel.
|
||||
The silicone sheets distribute pressure and heat vastly better. You will regret it if you leave this out!
|
||||
|
||||
4) ### Heat Transfer
|
||||
Set a timer to 180 seconds. Start the timer. Press the iron onto the plate with increasing pressure, covering the whole plate with the iron for 60s. Do not slide the iron!
|
||||
Lift, press down on left half of plate, 30s. Then right side 30s. Then top 30s. Then bottom.
|
||||
Pressure is important. Do it on the floor where you can really lean onto the iron. But be careful to not slide while pressing! And please, do not break your wife’s iron.
|
||||
|
||||
Optional: Put a stack of steel plates on top of the hot plate (heat sink). It seems to help moving the heat off the transfer paper quickly, causing the toner to be released onto the steel fully.
|
||||
Let it cool off completely! The transfer paper should buckle and lift off the metal all by itself. The transfer paper should come off without any toner sticking to it.
|
||||
Optional: Put a stack of steel plates on top of the hot plate (heat sink).
|
||||
Let it cool off completely! It will take a while (20m). The transfer paper should buckle and lift off the metal all by itself. The transfer paper should come off without any toner sticking to it.
|
||||
|
||||
5) ### Bake plate in oven (don't skip this step)
|
||||
Bake for 12 minutes at 180°C, no airflow.
|
||||
This reflows the toner and makes it stick even more to the plate and closes pinholes.
|
||||
***IMPORTANT:*** With the new layout most of the plate is covered in toner mask to the edges. Use a baking tray, put two silicon sheets next to each other (they are heat resistant). Place a tee cup that is high enough to lean the plate against on one sheet. The silcone prevents the cup and plate from sliding. Put Bottom edge of the plate on the silicone and the lean the top edege against the cup.
|
||||
Make the setup in cold oven.
|
||||
Let this cool down when done baking and only then move it. If the plate falls while it's hot your toner mask is ruined.
|
||||
|
||||
6) ### Repairs
|
||||
If the transfer wasn’t perfect you can do repairs by using nail polish or stop out ground and a small brush or anti-etching pens ([amazon](https://a.co/d/5DnOhRR))
|
||||
|
||||
### Transfer Troubleshooting
|
||||
Don’t be frustrated if it doesn’t work the first time. This takes practice.
|
||||
Common culprits:
|
||||
- not enough pressure (most of the toner sticks to the paper)
|
||||
- not enough heat (most of the toner sticks to the paper)
|
||||
- plate not clean enough (toner doesn’t stick everywhere)
|
||||
- touching the print toner surface or plate (oils!)
|
||||
|
||||
Bake plate in oven.
|
||||
Pre-heat to 170°C, no airflow.
|
||||
Bake for 8 minutes.
|
||||
This reflows the toner and makes it stick even more to the plate.
|
||||
|
||||
Repairs:\
|
||||
If the transfer wasn’t perfect you can do repairs by using nail polish or stop out ground and a small brush or anti-etching pens ([amazon](https://a.co/d/5DnOhRR))
|
||||
|
||||
- smeared transfer: your iron was too hot, or you moved/sheared the stack
|
||||
|
||||
## Etching
|
||||
|
||||
You’ll need:
|
||||
### You’ll need:
|
||||
|
||||
- [ ] Container to hold Ferric Chloride. Food containers made from HDPE work well. Choose a size that allows to fully submerge the metal plate in 1L of solution. Tip: Test with water first.
|
||||
- [ ] Plastic bowl with 1L of 20–25°C warm water and 1–2 tablespoons (15–30 g) of baking soda (NaHCO₃) dissolved into it.
|
||||
- [ ] Container to hold Ferric Chloride. Food containers made from HDPE/PP work well. NO METAL containers, obviously! Choose a size that allows to fully submerge the metal plate in 1 liter (or less) solution. Ideally the plate is vertical, especially when you want to etch both sides at the same time. Tip: Test with water first.\
|
||||
I designed a 3d printed container for etching both sides holding exactly 0.5l of etchant. But I will not release the files just yet.
|
||||
- [ ] Plastic container to hold a 40°C water bath. You put the etching container into it.
|
||||
- [ ] Thermos with hot water to top up when the bath gets too cold
|
||||
- [ ] Plastic bowl with 1L of 30–40°C warm water and 1–2 tablespoons (15–30 g) of baking soda (NaHCO₃) dissolved into it.
|
||||
- [ ] Gloves and eye protection
|
||||
- [ ] Timer
|
||||
- [ ] Close access to running water
|
||||
|
||||
Warning:
|
||||
Ferric Chloride stains EVERYTHING it comes in contact with. Don’t let it drip into your kitchen sink, you’ll ruin the sink.
|
||||
Always first put the plate into the baking soda solution bowl before rinsing it with water. This prevents acid carryover into sink and flash rust.
|
||||
### Safety
|
||||
|
||||
1. Prepare the plate. You have to mask off the other side that should not be etched. Make sure you mask it off properly or etchant will get to it. Normal packaging tape or electrical tape works. Avoid masking tape! (it’s not water proof)\
|
||||
Tip: make a holding flap from tape, so you can hold the plate easily.
|
||||
2. Make sure the etchant is around 20-25°C. Too warm will make the etchant too aggressive and it will attack the toner mask more quickly. Too cold (10-15°C) and it etches much slower. Tip: Put the etching container in a hot water bath to achieve the temperature.
|
||||
3. Set timer to 5 minutes and start. Submerge the plate fully into the FeCl, ideally keep it vertical. Get the FeCl moving slightly by either moving the container or the plate. Don’t go crazy, a slight movement of the fluid every 30s is enough.
|
||||
4. Take the plate out, let it drip off, submerge it into the baking soda bowl. This neutralises the acidic ferric-chloride residue.
|
||||
5. Rinse the plate under running water (no hot water!). \
|
||||
You can use a very soft brush to clean the plate carefully from etching remains. Just be careful to not destroy the mask!\
|
||||
This prevents the neutralizer to mess with your ferric chloride.
|
||||
1. Repeat steps 3–5 three to four times. This depends on how deep you want to etch and how well your toner mask is holding up.\
|
||||
Important: Never put the plate back into the acid right after neutralizing it. The etchant solution will be slowly destroyed by this.\
|
||||
And obviously: Do not etch unattended!
|
||||
Do work in a well ventilated area or outdoors. Albeit the fumes from FeCl3 aren't strong, I just recommend it.
|
||||
Do wear protection gear: nitrile gloves, eye protection (important!). And maybe don't wear your favorite Bitcoin t-shirt.
|
||||
|
||||
***W A R N I N G***\
|
||||
Ferric Chloride stains EVERYTHING it comes in contact with. Don’t let it drip into your kitchen sink, you’ll ruin the sink.
|
||||
Neutralize everything with the baking soda water solution!
|
||||
|
||||
1. Prepare the plate. You have to mask off the unmasked strip on the left side. Make sure you mask it off properly or etchant will get to it. Normal packaging tape or electrical tape works. Avoid masking tape! (it’s not waterproof)\
|
||||
Tip: make a holding flap from tape, so you can hold the plate easily from top.
|
||||
2. Make sure the etchant is around 40°C. Tip: Put the etching container in a slightly warmer water bath to achieve the temperature. Use hot water from thermos to adjust it. Nice to have: Use an aquarium heater.
|
||||
3. Set timer to 60 minutes. Submerge the plate fully into the FeCl, ideally keep it vertical. Get the FeCl moving slightly by either moving the container or the plate\
|
||||
Etchant needs to be moving, or no fresh etchant will get to where it is supposed to. So, either take a plastic or glass stick and stir or use a fishtank air pump to produce bubbles from the bottom of the etch tank. If the bubbles are too strong, clamp the silicone tube slightly. This is a very comfortable setup.
|
||||
4. Check the plate every 20 minutes. Check the mask. It is best not to do multiple sessions with neutralizing bath and water rinse, I found. It tends to destroy the mask.
|
||||
60 minutes should get you 0.2mm etch depth. If the mask looks fine after 60 you can go futher. All this depends on how good your mask holds up.
|
||||
5. When desired etch depth has been reached, take the plate out, let it drip off, submerge it into the baking soda bowl. This neutralises the acidic ferric-chloride residue.
|
||||
6. Rinse the plate under running water.
|
||||
Do not etch unattended, check on it every 20 minutes.
|
||||
|
||||
One liter of FeCl should last you for plenty of plates. I etched 16 plates and it still works fine.\
|
||||
When etch times double: replace.
|
||||
@ -186,21 +197,34 @@ So, re-use the etchant and when it’s done dispose of it properly.
|
||||
|
||||
You could use salt water and 12V/1amp to etch.\
|
||||
However, I do strongly advise you NOT to do that. Etching stainless steel with salt water can produce chlorine gas and other toxic chlorine compounds.\
|
||||
You do NOT want chlorine gas in your lungs. There are hundreds of youtube videos on etching like this, and none of them cares to give you that warning.
|
||||
You do NOT want chlorine gas in your lungs. There are hundreds of YouTube videos on etching like this, and none of them cares to give you that warning.
|
||||
Etching copper or brass this way is fine.
|
||||
Using Na2SO4 seems the way to go. But I need to do more testing.
|
||||
|
||||
## Post processing
|
||||
|
||||
Remove the toner with acetone.
|
||||
Remove the toner with a stainless steel scrubber with dish soap and running water. Super efficient!
|
||||
Clean rest with acetone.
|
||||
|
||||
Optionally wipe it down with vinegar (prevents corrosion).
|
||||
If you etch 304 steel, citric acid passivation is recommended to restore corrosion resistance.
|
||||
For 316L, citric passivation is optional but still recommended for best long-term stability.
|
||||
A vinegar wipe is better than nothing, but less effective than citric passivation.
|
||||
|
||||
If the etching started to etch surfaces that should have been masked, you can often correct it by using 1200 or finer grit sandpaper with a sanding block.
|
||||
### Citric passivation (quick recipe)
|
||||
|
||||
1. Mix a 5-10% citric acid solution (50-100 g citric acid per 1 L water) in a plastic or glass container.
|
||||
2. Warm solution to about 50-60°C if possible.
|
||||
3. Soak the plate for:
|
||||
- 20-30 minutes at 50-60°C, or
|
||||
- 60-120 minutes at room temperature.
|
||||
4. Rinse thoroughly with clean water and dry completely.
|
||||
|
||||
***Safety:*** Citric acid is relatively low hazard, but it is still an acid. Avoid breathing powder dust, avoid eye contact, and wear gloves and eye protection. Never mix with bleach/chlorine cleaners.
|
||||
Reuse/disposal: You can reuse the citric solution multiple times if it remains reasonably clean. Store it in a labeled HDPE/PP (PET is fine as well here) plastic bottle. Replace when performance drops or it becomes visibly contaminated. For disposal, follow local rules; heavily metal-contaminated solution should be treated as hazardous waste.
|
||||
|
||||
If the etching started to etch surfaces that should have been masked, you can often correct it by using 240-320 grit sandpaper with a sanding block.
|
||||
Carefully sand the etched plate until the undesired etching errors are mostly gone.
|
||||
|
||||
Do not keep failed prints or transfer sheets: destroy immediately!
|
||||
|
||||
And lastly: Please do test your backup before calling it done.
|
||||
|
||||
|
||||
|
||||
|
||||
BIN
docs/assets/workflow/seedetcher-transfer-stack.png
Normal file
BIN
docs/assets/workflow/seedetcher-transfer-stack.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/assets/workflow/transfer-paper-placement.png
Normal file
BIN
docs/assets/workflow/transfer-paper-placement.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
31
docs/assets/workflow/workflow-pdf.css
Normal file
31
docs/assets/workflow/workflow-pdf.css
Normal file
@ -0,0 +1,31 @@
|
||||
/* PDF export tweaks for docs/SeedEtcher-Workflow.md */
|
||||
|
||||
/* Keep checkbox-style task lists, but use hanging indent so wrapped lines
|
||||
align with text instead of the marker. */
|
||||
ul.task-list[class] {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
ul.task-list li {
|
||||
position: relative;
|
||||
padding-left: 1.35em;
|
||||
margin: 0.18em 0;
|
||||
}
|
||||
|
||||
ul.task-list li input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ul.task-list li label {
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul.task-list li label::before {
|
||||
content: "☐";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.02em;
|
||||
}
|
||||
@ -20,6 +20,8 @@ Build directly if needed: `nix build .#controller` or `nix build .#controller-de
|
||||
| `image-gadget` | `controller` | Gadget (`dwc2,g_serial`) | Console on `ttyGS0`/HDMI; no debug hooks. |
|
||||
| `image-gadget-debug` | `controller-debug` | Gadget (`dwc2,g_serial`) | Adds serial console + reload flow via `/dev/ttyGS1`. |
|
||||
|
||||
All four image outputs above include the integrated HBP/CUPS runtime support.
|
||||
|
||||
Build commands (examples):
|
||||
- `nix build .#image` → `result/seedetcher.img`
|
||||
- `nix build .#image-debug` → `result/seedetcher-debug.img`
|
||||
@ -37,7 +39,10 @@ Flash via `./scripts/flash-sdcard.sh -i seedetcher-debug.img` from macOS, pointi
|
||||
- Development with serial shell/hot reload: use `image-gadget-debug`.
|
||||
- Production/field use without shell: use `image`.
|
||||
- Printing directly to USB printer (no host PC in the loop): use `image`/`image-debug` (switches OTG to host and loads `usblp` so `/dev/usb/lp0` appears).
|
||||
- For HBP runtime behavior and operational details, see `docs/dev/hbp-runtime.md`.
|
||||
|
||||
## Host-mode notes
|
||||
- Host images set `dr_mode=host` (no `g_serial` in `cmdline.txt`) and auto-load `usblp`.
|
||||
- Host mode shell/debug path is UART (no gadget shell).
|
||||
- Host printing path uses direct 1bpp PCL streaming to `/dev/usb/lp0`.
|
||||
- Gadget images use raster-to-PDF fallback path for capture/dev (`/dev/ttyGS1`), not direct PCL.
|
||||
|
||||
@ -7,44 +7,108 @@
|
||||
- In scope:
|
||||
- New plate typography/layout using custom etching font.
|
||||
- Circular QR module rendering on plate outputs.
|
||||
- Keep descriptor/seed content and share semantics unchanged.
|
||||
- Adopt interoperable descriptor share encoding for 2-of-3 multisig (UR/XOR).
|
||||
- Validate print readability and recovery scan reliability.
|
||||
- Out of scope:
|
||||
- New sharding formats.
|
||||
- Finalizing custom compact descriptor share formats (`SE1`/`SE2`) as release defaults.
|
||||
- Recovery CLI/break-glass tooling (moved to b0.4).
|
||||
|
||||
## Milestones
|
||||
|
||||
### 1) Font integration and layout update
|
||||
- [ ] Add/integrate custom etching font asset(s).
|
||||
- [ ] Wire font into plate renderer(s) used for print output.
|
||||
- [ ] Increase type size and rebalance spacing for available plate area.
|
||||
- [ ] Ensure no clipping at plate edges across A4/Letter page layouts.
|
||||
- [ ] Keep bitmap output canonical and match host-mode print path.
|
||||
- [x] Add/integrate custom etching font asset(s).
|
||||
- [x] Wire font into plate renderer(s) used for print output.
|
||||
- [x] Increase type size and rebalance spacing for available plate area.
|
||||
- [x] Ensure no clipping at plate edges across A4/Letter page layouts.
|
||||
- [x] Keep bitmap output canonical and match host-mode print path.
|
||||
|
||||
### 2) Circular QR rendering on plates
|
||||
- [ ] Implement hybrid QR rendering for plate output:
|
||||
- [ ] data modules rendered as circular dots
|
||||
- [ ] structural modules remain square (finder/alignment/timing and other required islands)
|
||||
- [ ] Preserve scanner reliability while using circular data modules.
|
||||
- [ ] Keep quiet zone and module spacing standards-compliant.
|
||||
- [ ] Verify mirrored/inverted print flags still behave correctly.
|
||||
- [x] Implement hybrid QR rendering for plate output:
|
||||
- [x] data modules rendered as circular dots
|
||||
- [x] structural modules remain square (finder/alignment/timing and other required islands)
|
||||
- [x] Preserve scanner reliability while using circular data modules.
|
||||
- [x] Keep quiet zone and module spacing standards-compliant.
|
||||
- [x] Calibrate circular data-module dot scale for etch growth headroom (current target: `0.7`) while keeping finder/alignment islands square.
|
||||
- [x] Verify mirrored/inverted print flags still behave correctly.
|
||||
|
||||
### 3) Output parity and regression checks
|
||||
- [ ] Ensure captured print output matches intended plate geometry.
|
||||
- [ ] Verify singlesig and multisig plate outputs against current fixtures.
|
||||
- [ ] Confirm descriptor-share QR decode/recover still works end-to-end.
|
||||
- [ ] Confirm no controller crashes/regressions in print/recover flows.
|
||||
- [x] Ensure captured print output matches intended plate geometry.
|
||||
- [x] Verify singlesig and multisig plate outputs against current fixtures.
|
||||
- [x] Confirm descriptor-share QR decode/recover still works end-to-end.
|
||||
- [x] Confirm no controller crashes/regressions in print/recover flows.
|
||||
|
||||
### 4) Test artifacts and docs
|
||||
- [ ] Add visual reference fixtures for new layout (seed + descriptor plates).
|
||||
- [ ] Add manual QA checklist for scan/readability on real laser prints.
|
||||
- [ ] Update docs (`docs/dev/gui.md` or dedicated layout doc) with new design constraints.
|
||||
- [ ] Update CHANGELOG.md
|
||||
- [ ] Document known scanner limits/tradeoffs for hybrid QR rendering (square islands + circular data dots).
|
||||
- [x] Add visual reference fixtures for new layout (seed + descriptor plates).
|
||||
- [x] Add manual QA checklist for scan/readability on real laser prints.
|
||||
- [x] Add optional etch stats page (additional print page) across print paths (CLI/controller host+gadget) with clear per-plate coverage metrics (`mm²` and `%`) mapped to each printed plate side.
|
||||
- [x] Fixed physical plate model: `100x100 mm` basis with masked/unmasked margin scenarios.
|
||||
- [x] Include operator-ready PSU guidance table per plate (`Set A masked` / `Set A unmasked`) derived from exposed area.
|
||||
- [x] Include global bench defaults block (`Na2SO4 100 g/L`, `34C`, `15 mm` gap, `12 V` limit, `J=0.04 A/cm²`).
|
||||
- [x] Update docs (`docs/dev/gui.md` or dedicated layout doc) with new design constraints.
|
||||
- [x] Update CHANGELOG.md
|
||||
- [x] Document known scanner limits/tradeoffs for hybrid QR rendering (square islands + circular data dots).
|
||||
|
||||
### 5) Release prep
|
||||
- [ ] Validate at least one full physical run: print -> transfer mask -> recovery scan.
|
||||
- [ ] Record printer model(s), toner settings, and DPI used for acceptance.
|
||||
- [ ] Validate at least one full physical run: print -> transfer mask -> etch -> recovery scan.
|
||||
- [x] Record printer model(s), toner settings, and DPI used for acceptance.
|
||||
- [ ] Freeze b0.3 layout constants after acceptance testing.
|
||||
- [ ] Bumb version
|
||||
- [x] Bumb version
|
||||
|
||||
### 6) UR/XOR 2-of-3 migration (interoperability-first)
|
||||
- [x] Mark `SE1`/`SE2` path as experimental-only (non-release default for 2-of-3).
|
||||
- [x] Implement UR/XOR descriptor share generation for 2-of-3:
|
||||
- [x] deterministic split assignment `A`, `B`, `A⊕B`
|
||||
- [x] deterministic descriptor canonicalization before split
|
||||
- [x] Implement UR/XOR descriptor share recovery for 2-of-3:
|
||||
- [x] accept any 2 shares
|
||||
- [x] reconstruct full descriptor payload deterministically
|
||||
- [x] reject mixed/invalid share sets with clear UI message
|
||||
- [x] Wire UR/XOR into backup/recover GUI flow as the default 2-of-3 path.
|
||||
- [x] Reuse compact single-sided 2-of-3 layout with UR/XOR payloads.
|
||||
- [ ] Add test vectors and regression tests:
|
||||
- [x] stable share payload strings for fixture wallet(s)
|
||||
- [x] all pairwise recovery combinations pass (`C(3,2)=3`)
|
||||
- [ ] Validate interoperability in external wallets (Sparrow, Nunchuk, BlueWallet).
|
||||
- [x] Update docs/changelog for UR/XOR migration and experimental status of `SE1`/`SE2`.
|
||||
|
||||
### 7) UR/XOR family support (SeedHammer II parity)
|
||||
- [ ] Define and document supported UR/XOR wallet families for b0.3:
|
||||
- [x] `1/1` stays singlesig-special (no UR/XOR share mode)
|
||||
- [x] `2/2`
|
||||
- [x] `2/3` (already complete)
|
||||
- [x] `2/4`
|
||||
- [x] `3/5`
|
||||
- [x] generic `m = n-1` family
|
||||
- [x] Implement UR/XOR descriptor-share generation for `3/5`:
|
||||
- [x] deterministic descriptor canonicalization (same rules as `2/3`)
|
||||
- [x] deterministic part assignment per share
|
||||
- [x] stable share ordering and payload encoding
|
||||
- [x] Implement UR/XOR descriptor-share recovery for `3/5`:
|
||||
- [x] accept any 3 of 5 shares
|
||||
- [x] reject duplicates/mixed sets/invalid fragments with explicit errors
|
||||
- [x] deterministic reconstruction output
|
||||
- [ ] Add tests for `3/5`:
|
||||
- [x] all pairwise/combination recovery tests (`C(5,3)=10`)
|
||||
- [x] stable share payload vectors for fixture wallet(s)
|
||||
- [x] matrix regression coverage added for representative script/network families
|
||||
- [x] Implement generic `m = n-1` UR/XOR support:
|
||||
- [x] generation path
|
||||
- [x] recovery path
|
||||
- [x] capability guardrails in UI/CLI (clear supported/unsupported messaging)
|
||||
- [x] Fallback policy for unsupported families:
|
||||
- [x] no SE1/SE2 release fallback
|
||||
- [x] full descriptor UR per descriptor plate
|
||||
- [x] explicit warning shown during backup flow
|
||||
- [x] Interop validation matrix for expanded families:
|
||||
- [x] Sparrow
|
||||
- [x] Nunchuk
|
||||
- [x] BlueWallet
|
||||
- [x] Docs/changelog updates for expanded UR/XOR family support.
|
||||
|
||||
### 8) Printer language selector (host mode)
|
||||
- [x] Add explicit printer-language choice in print flow: `PCL` (default) / `PS`.
|
||||
- [x] Add user guidance text: if `PCL` prints blank pages, try `PS`.
|
||||
- [x] Implement host-mode `PS` path: render canonical pages -> native PostScript -> raw USB send (no external converters).
|
||||
- [x] Optimize PS send path on Pi Zero (buffered writes + fast hex encoding).
|
||||
- [x] Validate physical PS printing on Brother HL-L5000D (600 and 1200 DPI).
|
||||
- [ ] Validate on additional physical models (beyond HL-L5000D).
|
||||
|
||||
@ -1,59 +1,88 @@
|
||||
# SeedEtcher b0.4 Checklist
|
||||
|
||||
## Goal
|
||||
- Deliver a standalone, cross-platform, offline recovery tool for descriptor shares (`SE1:`), suitable for inheritance and break-glass recovery without Pi hardware.
|
||||
- Add a direct laser-ablation path (Acmer K1 / GRBL) from SeedEtcher plate data while preserving current print behavior.
|
||||
- Move toward a single source of truth for layout: vector-first `PlateScene`, then backend renderers.
|
||||
|
||||
## Scope
|
||||
- In scope:
|
||||
- Host CLI to recover descriptor payload from `SE1:` shares.
|
||||
- Cross-platform builds (macOS, Linux, Windows).
|
||||
- Offline-first docs and reproducible release artifacts.
|
||||
- Out of scope:
|
||||
- Webcam/camera scanning.
|
||||
- Network services.
|
||||
- Browser app as canonical recovery path.
|
||||
- Single-plate laser output (`100x100mm`), no paper/cutbox pagination in laser mode.
|
||||
- Direct GRBL streaming in host mode over USB serial.
|
||||
- Vector-first layout model and renderer split.
|
||||
- Maintain visual morphology parity with current etched print style (dot QR + rounded islands + font geometry).
|
||||
- Out of scope (initial b0.4):
|
||||
- LightBurn dependency/workflow.
|
||||
- Multi-plate nesting/auto-arrange in one bed job.
|
||||
- Camera/G-code preview UI.
|
||||
|
||||
## Milestones
|
||||
|
||||
### 1) CLI recovery command
|
||||
- [ ] Add `cmd/recover/main.go`.
|
||||
- [ ] Accept share input via file and stdin.
|
||||
- [ ] Parse and validate `SE1:` shares.
|
||||
- [ ] Reconstruct payload via `descriptor/shard`.
|
||||
- [ ] Output:
|
||||
- [ ] descriptor text
|
||||
- [ ] `UR:CRYPTO-OUTPUT`
|
||||
- [ ] Add clear, deterministic error messages for:
|
||||
- [ ] duplicate share index
|
||||
- [ ] mixed set IDs
|
||||
- [ ] insufficient shares
|
||||
- [ ] malformed share payload
|
||||
### 1) Architecture foundation (single source of truth)
|
||||
- [ ] Define `PlateScene` domain model (mm-based coordinates):
|
||||
- [ ] primitives: path, circle, rounded-rect, text outline, transform/group
|
||||
- [ ] layer tags: `mask`, `guide` (guide disabled by default)
|
||||
- [ ] deterministic origin and bounds (`100x100mm`)
|
||||
- [ ] Define renderer interfaces:
|
||||
- [ ] `SceneRasterRenderer` (for print path parity checks)
|
||||
- [ ] `SceneGCodeRenderer` (for GRBL output)
|
||||
- [ ] optional `SceneSVGRenderer` (debug only)
|
||||
- [ ] Add visual/debug CLI outputs from the same scene:
|
||||
- [ ] `-scene-json-out <file>` (canonical scene dump for diff/tests)
|
||||
- [ ] `-svg-out <dir>` (human visual inspection)
|
||||
- [ ] `-png-out <dir>` from scene raster renderer (quick morphology parity)
|
||||
- [ ] Keep current print pipeline untouched in this phase.
|
||||
|
||||
### 2) Test vectors and verification
|
||||
- [ ] Add roundtrip tests for CLI using known fixture shares.
|
||||
- [ ] Add negative tests (bad/mixed/incomplete share sets).
|
||||
- [ ] Verify CLI output matches controller recovery output for same inputs.
|
||||
### 2) PlateScene builder (layout migration)
|
||||
- [ ] Build scene for seed plate from existing layout rules.
|
||||
- [ ] Build scene for descriptor plate from existing layout rules.
|
||||
- [ ] Preserve current styling semantics:
|
||||
- [ ] QR data modules as circles (dot scale parity)
|
||||
- [ ] registration/finder islands as rounded squares
|
||||
- [ ] exact text anchors/margins/tracking behavior
|
||||
- [ ] Add scene-level geometry snapshot tests for key fixtures.
|
||||
|
||||
### 3) Build and release artifacts
|
||||
- [ ] Add `scripts/build-recover-cli.sh`.
|
||||
- [ ] Build targets:
|
||||
- [ ] `darwin/arm64`
|
||||
- [ ] `darwin/amd64`
|
||||
- [ ] `linux/amd64`
|
||||
- [ ] `linux/arm64`
|
||||
- [ ] `windows/amd64`
|
||||
- [ ] Use `CGO_ENABLED=0` for portable binaries.
|
||||
- [ ] Generate `SHA256SUMS` for all artifacts.
|
||||
### 3) Direct G-code backend (GRBL)
|
||||
- [ ] Add CLI output mode for laser:
|
||||
- [ ] `-gcode-out <dir>`
|
||||
- [ ] `-side seed|desc|both`
|
||||
- [ ] `-plate-mm` (default `100`)
|
||||
- [ ] laser params (`-laser-max-s`, `-laser-feed`, `-rapid-feed`)
|
||||
- [ ] no-send mode by default (generate files first, stream later)
|
||||
- [ ] Emit GRBL-safe preamble/footer:
|
||||
- [ ] `G21`, `G90`, `M4`/`M5`, sane feed defaults
|
||||
- [ ] configurable power scaling `S`
|
||||
- [ ] Implement fill/trace strategy for closed vector regions.
|
||||
- [ ] Add deterministic output tests for small canonical plate fixtures.
|
||||
|
||||
### 4) Documentation
|
||||
- [ ] Add `docs/dev/recover-cli.md`:
|
||||
- [ ] offline usage workflow
|
||||
- [ ] sample commands
|
||||
- [ ] inheritance/break-glass instructions
|
||||
- [ ] compatibility limits (`SE1:` is SeedEtcher-native)
|
||||
- [ ] Update top-level docs index to link b0.4 checklist and recovery CLI doc.
|
||||
### 4) USB serial transport to K1 (host mode)
|
||||
- [ ] Add GRBL serial transport (`/dev/ttyACM*` or `/dev/ttyUSB*`):
|
||||
- [ ] line-by-line send with `ok`/`error` ack handling
|
||||
- [ ] timeout and retry policy
|
||||
- [ ] alarm/reset handling (`?`, soft reset, unlock flow)
|
||||
- [ ] Ensure transport is isolated from printer `/dev/usb/lp*` path.
|
||||
- [ ] Add a dry-run mode that writes G-code only (no device send).
|
||||
|
||||
### 5) Stretch goals (if time allows)
|
||||
- [ ] Optional output file formats (`.txt`, `.json`).
|
||||
- [ ] Optional strict mode requiring exact threshold count.
|
||||
- [ ] Optional share order normalization output for auditing.
|
||||
### 5) Validation and parity
|
||||
- [ ] Visual parity tests (scene vs current raster) for:
|
||||
- [ ] singlesig
|
||||
- [ ] multisig 2-of-3
|
||||
- [ ] multisig 3-of-5
|
||||
- [ ] Physical validation on K1:
|
||||
- [ ] seed side readability (words + QR)
|
||||
- [ ] descriptor side readability
|
||||
- [ ] scan success in Sparrow/Seed tools
|
||||
- [ ] Regression check: existing PCL/PS/HBP print flows unchanged.
|
||||
|
||||
### 6) Documentation and release
|
||||
- [ ] Add `docs/dev/laser-grbl.md`:
|
||||
- [ ] machine setup
|
||||
- [ ] CLI examples
|
||||
- [ ] safe power/feed starting profiles
|
||||
- [ ] troubleshooting (`error`, `alarm`, mis-scale, offsets)
|
||||
- [ ] Update `docs/development.md` with laser output flags.
|
||||
- [ ] Add changelog entry when laser path is user-ready.
|
||||
|
||||
## Stretch goals
|
||||
- [ ] Real-time progress reporting for GRBL send in UI.
|
||||
- [ ] Optional SVG export from `PlateScene` for inspection/debug.
|
||||
- [ ] Optional path optimization pass (travel reduction).
|
||||
|
||||
@ -13,27 +13,34 @@ flowchart TD
|
||||
D[Descriptor input<br>Scan or Skip or Reuse<br>Validate descriptor and duplicates] --> M{Descriptor present}
|
||||
B --> D
|
||||
M -->|No singlesig| F1[Seed input confirm<br>1 of 1]
|
||||
M -->|Yes multisig| F2
|
||||
M -->|Yes descriptor scanned| F2
|
||||
F2[Seeds loop<br>Scan or manual per key<br>Confirm and no duplicates] --> G1[Confirm wallet<br>choose key index]
|
||||
G1 --> FP[Fingerprints review<br>All cosigner fingerprints<br>5 per page]
|
||||
FP --> S2[Descriptor shares review<br>t/n + wallet_id/set_id]
|
||||
S2 --> H2[Add wallet label]
|
||||
H2 --> PS[Select paper size]
|
||||
PS --> P2[Print flow with descriptor shares per plate]
|
||||
G1 --> FP[Fingerprints review<br>All cosigner fingerprints<br>7 per page]
|
||||
FP --> S2{Descriptor shares exist?}
|
||||
S2 -->|Yes| S3[Descriptor shares review<br>t/n summary]
|
||||
S2 -->|No| H2
|
||||
S3 --> H2[Add wallet label]
|
||||
H2 --> MODE{Wallet mode selector}
|
||||
MODE -->|Singlesig descriptor| SM[Singlesig layout<br>Seed Only / Seed + Info / Seed + Descr QR]
|
||||
MODE -->|2 of 3 multisig| CM[Compact 2/3<br>Off or On]
|
||||
MODE -->|Other wallets| PS
|
||||
SM --> PS[Select paper size]
|
||||
CM --> PS
|
||||
PS --> PO[Print settings<br>DPI, Invert, Mirror, Etch stats page]
|
||||
PO --> P2[Print flow with descriptor shares per plate]
|
||||
P2 --> A
|
||||
|
||||
F1 --> H3[Add wallet label]
|
||||
H3 --> P3[Print flow singlesig]
|
||||
F1 --> FP1[Fingerprints review<br>single seed fingerprint]
|
||||
FP1 --> H3[Add wallet label]
|
||||
H3 --> SM1[Singlesig layout<br>Seed Only / Seed + Info / Seed + Descr QR]
|
||||
SM1 --> PS1[Select paper size]
|
||||
PS1 --> PO1[Print settings<br>DPI, Invert, Mirror, Etch stats page]
|
||||
PO1 --> P3[Print flow singlesig]
|
||||
P3 --> A
|
||||
|
||||
R0 --> R1[Scan input]
|
||||
R1 --> RX{Input type}
|
||||
RX -->|Plain descriptor QR| R4[Validate descriptor]
|
||||
R4 --> R6[Export/confirm screen]
|
||||
R6 --> A
|
||||
|
||||
RX -->|Sharded share QR| R2[Collect shares<br>Progress k of t]
|
||||
R2 --> R3[Validate set version network<br>Checksum and duplicate checks]
|
||||
R1 --> R2[Collect descriptor shares<br>Progress k of t]
|
||||
R2 --> R3[Validate format/set/duplicates]
|
||||
R3 -->|k gte t| R5[Reconstruct descriptor in RAM]
|
||||
R5 --> R6
|
||||
```
|
||||
@ -44,20 +51,22 @@ Notes:
|
||||
- `Run` enters the Screen state machine at `MainMenuScreen`.
|
||||
- Colors: `singleTheme` on menu; `descriptorTheme` for backup flow and warnings.
|
||||
- All helper logic lives alongside screens (`gui/screen_*.go` and `gui/screen_helpers.go`).
|
||||
- Multisig backup uses sharded descriptor mode only in b0.2.
|
||||
- Multisig backup uses sharded descriptor mode only since since v0.2.0-beta.1.
|
||||
- Singlesig backup stays non-sharded.
|
||||
- Backup review sequence for multisig is:
|
||||
- `Confirm wallet` -> `Fingerprints` -> `Descriptor shares` -> `Wallet label` -> `Paper size` -> `Print`.
|
||||
- `Fingerprints` uses page navigation (left/right arrows) and keeps back/check nav buttons.
|
||||
- Recovery mode accepts both sharded shares and plain descriptor QR input.
|
||||
- Plain descriptor QR input bypasses shard threshold accumulation and goes directly to export/confirm.
|
||||
- Recovery QR screen copy:
|
||||
- Title: `Recovered Descriptor QR`
|
||||
- Body: `Scan with your coordinator, then choose:`
|
||||
- `Back = show QR again`
|
||||
- `Trash = delete and restart`
|
||||
- `Confirm wallet` -> `Fingerprints` -> optional `Descriptor shares` -> `Wallet label` -> wallet-mode selector (`Compact 2/3` when eligible, `Singlesig layout` for singlesig descriptor) -> `Paper size` -> print settings -> `Print`.
|
||||
- Backup review sequence for singlesig (descriptor skipped) is:
|
||||
- `Seed input` -> `Fingerprints` -> `Wallet label` -> `Singlesig layout` -> `Paper size` -> print settings -> `Print`.
|
||||
- Back from `Fingerprints` opens `Restart Process?`; decline returns to `Fingerprints`.
|
||||
- `Fingerprints` uses page navigation (left/right arrows) and keeps back/check nav buttons (`7` entries/page).
|
||||
- In backup descriptor scan, recognized UR/XOR fragments show deterministic `x/t` capture progress (not `%`).
|
||||
- For unsupported UR/XOR families (fallback full descriptor UR), there is no share-fragment counter.
|
||||
- Print setup order is wallet-mode selector (if applicable) -> `Paper size` -> `DPI` -> `Invert` -> `Mirror` -> `Etch stats page`.
|
||||
- When `Etch stats page` is enabled, one additional stats page is appended after plate pages:
|
||||
- area/coverage table per printed plate side (`mm²` and `%`),
|
||||
- per-plate PSU current guide (`Set A masked` / `Set A unmasked`) using bench defaults.
|
||||
- Recovery mode is descriptor-share recovery; plain descriptor QR input is rejected with an explicit message.
|
||||
|
||||
Implementation note:
|
||||
- Active flow already uses explicit `Screen` structs (`MainMenuScreen` -> `BackupFlowScreen` stages).
|
||||
- `backupWalletFlow` in `gui/screen_backup.go` is legacy/helper code and should not be expanded for new work.
|
||||
- Active flow uses explicit `Screen` structs (`MainMenuScreen` -> `BackupFlowScreen` stages).
|
||||
- Keep testing on device via `nix run .#reload $USBDEV1` after each step.
|
||||
|
||||
58
docs/dev/hbp-runtime.md
Normal file
58
docs/dev/hbp-runtime.md
Normal file
@ -0,0 +1,58 @@
|
||||
# HBP Runtime (Release)
|
||||
|
||||
This document describes the integrated Brother HBP runtime used in release builds.
|
||||
|
||||
## Scope
|
||||
- HBP runtime support is integrated into standard image outputs.
|
||||
- PCL/PS remain preferred when the printer supports them.
|
||||
- HBP is an opt-in path for printers that do not reliably support PCL/PS.
|
||||
|
||||
## Runtime Components
|
||||
- Bootstrap script: `/bin/cups-runtime-bootstrap`
|
||||
- RAM staging helper: `/bin/cups-runtime-ram-feasibility`
|
||||
- HBP print helper: `/bin/print-hbp-pdf`
|
||||
- Runtime env file in initramfs: `/cups-runtime.env`
|
||||
|
||||
## High-Level Flow
|
||||
1. Device boots normally; HBP runtime is not preloaded.
|
||||
2. User chooses `Enable HBP` in the startup gate.
|
||||
3. Controller runs:
|
||||
- `cups-runtime-bootstrap`
|
||||
- `cups-runtime-ram-feasibility stage core`
|
||||
- `cups-runtime-ram-feasibility detach-sd`
|
||||
4. UI marks HBP runtime ready; SD can be removed.
|
||||
5. HBP print jobs use `print-hbp-pdf` + CUPS queue `test-hbp`.
|
||||
|
||||
## Printer Language Behavior
|
||||
- Preferred path: `PCL` or `PS`.
|
||||
- HBP path is capped to 600 DPI.
|
||||
|
||||
Reason: current `brlaser` HBP path has incorrect print geometry at 1200 DPI on tested models; 600 DPI is used for correct layout.
|
||||
|
||||
## Memory and SD Behavior
|
||||
- Runtime binaries/libs for CUPS/GS/brlaser are staged to RAM (`/run/hbp-ram-runtime`).
|
||||
- `/nix` is rebound to RAM-backed content before SD detach.
|
||||
- SD detach is verified so `/dev/mmcblk0p*` mounts are removed.
|
||||
|
||||
## Debugging and Logs (Debug Images)
|
||||
- Debug images include log export tooling.
|
||||
- Logs are exported to SD under `SE-LOGS-LATEST`.
|
||||
- PJL snapshot is captured during error export when printer device is present.
|
||||
|
||||
## Known Limitations
|
||||
- HBP 1200 DPI is not enabled in release path due geometry mismatch.
|
||||
- HBP queue depends on CUPS runtime availability after staging.
|
||||
- USB printer reconnect timing can affect first queue provisioning attempt; bootstrap includes retry behavior.
|
||||
|
||||
## Quick Validation Checklist
|
||||
1. Build and flash a standard image (`image-debug` recommended for bring-up).
|
||||
2. In UI, choose `Enable HBP` and wait for ready confirmation.
|
||||
3. Remove SD card and confirm no `/dev/mmcblk0p*` mounts remain.
|
||||
4. Print:
|
||||
- singlesig
|
||||
- multisig (e.g. 3/5, 7/10)
|
||||
- with and without etch stats page
|
||||
5. Verify layout correctness and successful job completion.
|
||||
|
||||
## Historical Note
|
||||
Feasibility exploration and failed approaches (early initramfs-only experiments) are tracked in the experimental branch history.
|
||||
62
docs/dev/release-checklist.md
Normal file
62
docs/dev/release-checklist.md
Normal file
@ -0,0 +1,62 @@
|
||||
# Release Checklist
|
||||
|
||||
Use this checklist for every public release.
|
||||
|
||||
## 1) Preflight
|
||||
|
||||
1. Work from `release/0.3.0-beta` (or the current release branch).
|
||||
2. Ensure working tree is clean:
|
||||
- `git status`
|
||||
3. Ensure `flake.lock` is committed (changed or unchanged).
|
||||
|
||||
## 2) Version + Changelog
|
||||
|
||||
1. Update `version/version.go`:
|
||||
- `const Tag = "vX.Y.Z"`
|
||||
2. Update `CHANGELOG.md` for this release.
|
||||
|
||||
## 3) Build Release Artifact
|
||||
|
||||
```bash
|
||||
nix run .#mkRelease
|
||||
```
|
||||
|
||||
Expected artifact:
|
||||
|
||||
```text
|
||||
release/seedetcher-vX.Y.Z.img
|
||||
```
|
||||
|
||||
## 4) Verify Artifact
|
||||
|
||||
```bash
|
||||
sha256sum release/seedetcher-vX.Y.Z.img
|
||||
# macOS:
|
||||
shasum -a 256 release/seedetcher-vX.Y.Z.img
|
||||
```
|
||||
|
||||
Optional: boot/smoke test before tagging.
|
||||
|
||||
## 5) Third-Party License/Source Check
|
||||
|
||||
1. Review `THIRD_PARTY_LICENSES.md`.
|
||||
2. Update it only if bundled components changed.
|
||||
3. If local patches were applied to bundled GPL/AGPL components, note patch locations in release notes.
|
||||
|
||||
## 6) Tag + Publish
|
||||
|
||||
1. Create annotated tag (example):
|
||||
- `git tag -a vX.Y.Z -m "vX.Y.Z"`
|
||||
2. Push branch + tag:
|
||||
- `git push`
|
||||
- `git push origin vX.Y.Z`
|
||||
3. Create GitHub release from tag.
|
||||
|
||||
## 7) Minimal Release Notes Block
|
||||
|
||||
```md
|
||||
## Source and Licensing
|
||||
- Source tag: <TAG>
|
||||
- Third-party licenses: THIRD_PARTY_LICENSES.md
|
||||
- GPL/AGPL local patches: none
|
||||
```
|
||||
128
docs/dev/spec-se2-compact-descriptor-2of3-experimental.md
Normal file
128
docs/dev/spec-se2-compact-descriptor-2of3-experimental.md
Normal file
@ -0,0 +1,128 @@
|
||||
# SE2 Compact Descriptor Shares (2-of-3) Spec (Experimental)
|
||||
|
||||
## Status
|
||||
Experimental draft. Not release-default.
|
||||
Current release path for descriptor shares is UR/XOR (`docs/dev/spec-ur-xor-descriptor-shares-b0.3.md`).
|
||||
|
||||
## Goal
|
||||
Reduce descriptor-share payload size for 2-of-3 multisig backups by avoiding storage of all xpubs in every descriptor shard, while still allowing full descriptor reconstruction from any 2 plates.
|
||||
|
||||
## Motivation
|
||||
Current sharded-descriptor payloads embed full descriptor material, which increases QR density and etch complexity. In 2-of-3 backups, two recovered seeds already provide two xpubs deterministically; only the missing xpub must be recoverable from shard data.
|
||||
|
||||
## Scope
|
||||
- Wallet type: `sortedmulti(2, ...3 keys...)` only.
|
||||
- Plate format: one seed QR + one compact descriptor share QR per plate.
|
||||
- Recovery target: full coordinator-importable descriptor string.
|
||||
|
||||
## Non-goals
|
||||
- General `m-of-n` compact coding in this spec.
|
||||
- Replacing existing SE1 sharded-descriptor mode.
|
||||
- Backward compatibility in the same payload prefix.
|
||||
|
||||
## Threat model assumptions
|
||||
- Plate compromise of fewer than threshold plates should not reveal the full descriptor.
|
||||
- Recovery environment is offline and trusted.
|
||||
- Seeds remain required for full wallet recovery.
|
||||
|
||||
## High-level construction
|
||||
Let canonical key records be `X1`, `X2`, `X3` (equal-length byte arrays).
|
||||
|
||||
1. Compute parity record:
|
||||
- `P = X1 XOR X2 XOR X3`
|
||||
2. Split `P` with existing Shamir byte-split (`t=2, n=3`):
|
||||
- `S1`, `S2`, `S3`
|
||||
3. Plate `i` stores:
|
||||
- seed material for key `i` (existing seed QR)
|
||||
- compact share `Si`
|
||||
- compact metadata (wallet/script/path/network/order/checksum)
|
||||
|
||||
Recover from any two plates `i,j`:
|
||||
1. Derive `Xi, Xj` from scanned seeds and descriptor metadata (path/network/script).
|
||||
2. Reconstruct `P` from `Si,Sj`.
|
||||
3. Recover missing key record `Xk = P XOR Xi XOR Xj`.
|
||||
4. Reassemble all 3 keys in canonical order and emit full descriptor.
|
||||
|
||||
## Canonical key record encoding
|
||||
Each key record `Xi` MUST be binary-canonical and fixed-width or length-prefixed with strict parsing.
|
||||
|
||||
Recommended fields:
|
||||
- key fingerprint (4 bytes)
|
||||
- derivation origin/path canonical bytes
|
||||
- xpub serialized payload (network-aware)
|
||||
- child derivation template marker (`/<0;1>/*` equivalent canonical token)
|
||||
|
||||
Rules:
|
||||
- Same descriptor MUST always produce byte-identical `Xi` records.
|
||||
- Key order MUST be canonical (descriptor order after normalization).
|
||||
- Any mismatch in fingerprint/path/network MUST fail recovery.
|
||||
- Implementation decision:
|
||||
- Use a dedicated compact `Xi` schema (versioned for this protocol), not full `urtypes` serialization.
|
||||
- Rationale: tighter payload size, explicit field control, and stable long-term wire compatibility.
|
||||
|
||||
## Compact share payload format (new prefix)
|
||||
Use a new prefix, e.g. `SE2:` (do not reuse `SE1:`).
|
||||
|
||||
Required fields:
|
||||
- `version` (u8)
|
||||
- `scheme` = `compact-2of3`
|
||||
- `wallet_id` (deterministic hash, short)
|
||||
- `set_id` (deterministic for same canonical descriptor)
|
||||
- `share_index` (1..3)
|
||||
- `threshold` (=2)
|
||||
- `total` (=3)
|
||||
- `script_type` (e.g. `P2WSH`)
|
||||
- `network` (`MAIN`/`TEST`)
|
||||
- `path_template` (canonical, shared across keys)
|
||||
- `key_order_fingerprints` (3 entries)
|
||||
- `payload` (Shamir share bytes of `P`)
|
||||
- integrity checksum/MAC (scheme-defined)
|
||||
- `set_id` policy:
|
||||
- Deterministic only in compact mode (derived from canonical descriptor context).
|
||||
- No user override.
|
||||
|
||||
## QR encoding target
|
||||
- Design target for compact descriptor share QR: ECC level `Q`.
|
||||
- QR sizing convention in this spec: stated QR size is data area only (quiet zone excluded).
|
||||
- Seed-side layout budget: `28 mm` QR data area.
|
||||
- Physical layout budget under discussion for compact descriptor-share QR: single-sided `37x37 mm` QR data area.
|
||||
- Final payload encoding MUST be validated against this budget after wire format is frozen:
|
||||
- keep module size in a scan-safe range for etched output,
|
||||
- confirm end-to-end scan/recovery on real printed plates.
|
||||
|
||||
## Determinism
|
||||
For identical canonical descriptor input, compact share outputs SHOULD be deterministic if deterministic set IDs are enabled in controller policy.
|
||||
|
||||
## Validation rules (recovery)
|
||||
Reject with explicit errors for:
|
||||
- non-`SE2` payload in compact mode
|
||||
- mixed `wallet_id` / `set_id`
|
||||
- wrong threshold/total/scheme
|
||||
- duplicate share index
|
||||
- checksum/integrity failure
|
||||
- derived seed key record mismatch to expected fingerprint/path/network
|
||||
- reconstructed `Xk` failing key-record parse or checksum
|
||||
|
||||
## Security notes
|
||||
- One plate contains one seed + one compact share: insufficient for full descriptor by design.
|
||||
- Two plates reveal two seeds and allow descriptor reconstruction (aligned with 2-of-3 policy).
|
||||
- This scheme is custom and must be treated as protocol-critical code.
|
||||
- Integrity decision:
|
||||
- Use CRC32C for corruption detection plus strict structural/semantic validation.
|
||||
- No keyed MAC in compact mode v1 (avoids extra key-management complexity in offline plate workflow).
|
||||
|
||||
## Interoperability
|
||||
- External wallets will not parse `SE2` directly.
|
||||
- SeedEtcher recovery flow (and planned b0.4 host binaries) reconstruct full standard descriptor output for external import.
|
||||
|
||||
## Testing requirements
|
||||
- Deterministic vectors for at least 3 fixed 2-of-3 wallets.
|
||||
- Property tests for all 3 choose 2 recovery combinations.
|
||||
- Negative tests: mixed sets, swapped metadata, corrupted share, corrupted seed, wrong network/path.
|
||||
- Cross-tool recovery parity (controller vs host recovery binary).
|
||||
|
||||
## Rollout plan
|
||||
1. Implement parser/encoder under new package path (no SE1 changes).
|
||||
2. Add explicit mode toggle in backup flow for 2-of-3 wallets only.
|
||||
3. Keep SE1 as default until physical QA confirms scan/etch gains.
|
||||
4. Promote compact mode after successful field validation.
|
||||
59
docs/dev/spec-ur-xor-descriptor-shares-b0.3.md
Normal file
59
docs/dev/spec-ur-xor-descriptor-shares-b0.3.md
Normal file
@ -0,0 +1,59 @@
|
||||
# SeedEtcher Spec: UR/XOR Descriptor Shares (b0.3)
|
||||
|
||||
## Goal
|
||||
- Use interoperable UR/XOR descriptor shares wherever SeedHammer-compatible schemes exist.
|
||||
- Avoid non-interoperable default formats in release flow.
|
||||
|
||||
## Scope
|
||||
- In scope:
|
||||
- `sortedmulti` UR/XOR share generation + recovery for supported families.
|
||||
- Deterministic descriptor canonicalization before share generation.
|
||||
- Explicit fallback for unsupported families.
|
||||
- Out of scope:
|
||||
- New custom descriptor-share formats (`SE1`/`SE2`) as release defaults.
|
||||
|
||||
## Supported Families (release path)
|
||||
- UR/XOR supported:
|
||||
- `n-1/n` (for example `2/3`, `4/5`, `9/10`)
|
||||
- `2/4`
|
||||
- `3/5`
|
||||
- Singlesig `1/1`:
|
||||
- no descriptor share-splitting mode; descriptor QR behavior remains singlesig-specific.
|
||||
|
||||
## Unsupported Families
|
||||
- For multisig families outside supported UR/XOR schemes (for example `7/10`):
|
||||
- do **not** use SE1/SE2 fallback.
|
||||
- print full descriptor `UR:CRYPTO-OUTPUT` on each descriptor plate.
|
||||
- show an explicit `WARNING` in backup flow:
|
||||
- descriptor sharding not supported for this `m/n`;
|
||||
- full descriptor QR will be printed on each descriptor plate.
|
||||
|
||||
## Canonicalization
|
||||
- Before UR/XOR split:
|
||||
- canonicalize sortedmulti keys deterministically;
|
||||
- normalize missing children for sortedmulti as needed (`/<0;1>/*`), then apply UR export compatibility rules;
|
||||
- encode canonical descriptor payload bytes.
|
||||
|
||||
## Recovery Rules
|
||||
- UR/XOR sets:
|
||||
- accept valid fragments from same set;
|
||||
- reject mixed or invalid sets with explicit UI errors;
|
||||
- reconstruct deterministic descriptor payload.
|
||||
- Full-UR fallback sets:
|
||||
- any one full descriptor UR is sufficient to reconstruct descriptor payload.
|
||||
|
||||
## Determinism and Reprint
|
||||
- For same canonical descriptor input and same supported UR/XOR family:
|
||||
- share payloads should be deterministic.
|
||||
- For unsupported families in fallback mode:
|
||||
- descriptor UR payload is deterministic for same canonical descriptor.
|
||||
|
||||
## Tests
|
||||
- Table-driven descriptor-share matrix across:
|
||||
- scripts: `P2WSH`, `P2SH-P2WSH`
|
||||
- networks: `main`, `test`
|
||||
- representative families: `2/3`, `2/4`, `3/5`, `n-1/n` (`4/5`), fallback (`7/10`)
|
||||
- Assertions:
|
||||
- UR/XOR families produce multipart UR fragments and reconstruct correctly.
|
||||
- Unsupported families produce full single-part descriptor UR (not SE1, not multipart).
|
||||
|
||||
@ -13,111 +13,12 @@ nix build .#image-gadget-debug
|
||||
If build fails because of has error use `--impure`
|
||||
Debug builds use `--print-build-logs`
|
||||
|
||||
## Local Machine Notes
|
||||
|
||||
## Developemnt Environmet
|
||||
Host/VM-specific setup notes are intentionally kept outside this repository.
|
||||
|
||||
Ubuntu VM on Mac.
|
||||
|
||||
### USB-GADET DETECTION on VM
|
||||
|
||||
#### Recap of Added/Modified Files & Reloading udevadm
|
||||
|
||||
##### 1. Added/Modified Files
|
||||
|
||||
##### 1.1 /etc/udev/rules.d/99-serial-settings.rules
|
||||
- This is the `udev` rule that detects the Pi Zero’s USB serial interfaces and triggers the update script.
|
||||
- Example rule:
|
||||
|
||||
`ACTION=="add", SUBSYSTEM=="tty", ATTRS{idVendor}=="0525", ATTRS{idProduct}=="a4a7", KERNEL=="ttyACM*", SYMLINK+="usbzero%n", RUN+="/usr/local/bin/usbdev_checker.sh"`
|
||||
|
||||
##### 1.2 /usr/local/bin/usbdev_checker.sh
|
||||
- This script ensures both serial devices are present before running `update_usbdevs.sh`.
|
||||
- It prevents duplicate script execution.
|
||||
|
||||
##### 1.3 /usr/local/bin/update_usbdevs.sh
|
||||
- This script assigns the detected serial devices and updates the environment variables.
|
||||
- It logs device assignments and prevents duplicate messages.
|
||||
|
||||
**(ATTENTION: run source ~/.bashrc to update the USBDEVx shell vars)**
|
||||
|
||||
#### 2. How to Reload udevadm
|
||||
|
||||
##### Reload udev rules:
|
||||
|
||||
```bash
|
||||
sudo udevadm control --reload-rules
|
||||
```
|
||||
|
||||
##### Apply changes immediately:
|
||||
|
||||
```bash
|
||||
sudo udevadm trigger
|
||||
```
|
||||
|
||||
##### Check if udev triggered the script:
|
||||
|
||||
```bash
|
||||
journalctl -u systemd-udevd --no-pager | grep usbdev_checker.sh
|
||||
```
|
||||
|
||||
##### Disabled ModemManager
|
||||
|
||||
```bash
|
||||
sudo systemctl stop ModemManager
|
||||
sudo systemctl disable ModemManager
|
||||
```
|
||||
|
||||
##### Apparmor:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop apparmor
|
||||
sudo systemctl disable apparmor
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
|
||||
## NixOS Stuff
|
||||
|
||||
Install multiuser NixOS
|
||||
|
||||
```bash
|
||||
sudo systemctl restart nix-daemon
|
||||
sudo systemctl status nix-daemon
|
||||
|
||||
sudo nvim /etc/nix/nix.conf
|
||||
>>
|
||||
extra-experimental-features = nix-command flakes
|
||||
trusted-users = root <user>
|
||||
keep-outputs = true
|
||||
keep-derivations = true
|
||||
auto-optimise-store = true
|
||||
```
|
||||
|
||||
If you want to remove all temporary build artifacts (like failed derivations), run:
|
||||
nix-store --gc --print-dead | xargs nix-store --delete
|
||||
nix build .#<package-name> --show-trace
|
||||
|
||||
nix-store --gc --print-dead
|
||||
nix-store --gc
|
||||
|
||||
|
||||
## Ubuntu config changes
|
||||
|
||||
If you want /tmp to be stored in RAM (makes it faster but non-persistent):
|
||||
|
||||
1️) Edit /etc/fstab:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/fstab
|
||||
```
|
||||
|
||||
2️) Add this line:
|
||||
|
||||
```bash
|
||||
tmpfs /tmp tmpfs defaults,noatime,mode=1777 0 0
|
||||
```
|
||||
|
||||
3) Reboot
|
||||
- keep private notes in your own local/private repo (example: `~/seedetcher-private-notes`)
|
||||
- optional symlink from this repo: `.tmp/private-notes`
|
||||
|
||||
## GO Dependecies trouble?
|
||||
|
||||
@ -165,14 +66,16 @@ go run cmd/cli/main.go -w multisig \
|
||||
- `-o` (default: `/home/cmyk/PDF`): output directory (PDF)
|
||||
- `-papersize` (default: `A4`): paper size (`A4` or `Letter`)
|
||||
- `-verbose` (default: `false`): verbose logging
|
||||
- `-w` (default: `multisig`): wallet fixture (`singlesig`, `multisig`, `multisig-mainnet-2of3`, `multisig-3of5`, `multisig-7of10`)
|
||||
- `-png-out` (default: empty): optional output directory for plate PNGs
|
||||
- `-dpi` (default: `600`): raster output DPI
|
||||
- `-w` (default: `multisig`): wallet fixture (`seed-12`, `seed-15`, `seed-18`, `seed-21`, `singlesig`, `singlesig-longwords`, `singlesig-nested-p2sh-p2wpkh`, `multisig`, `multisig-mainnet-2of3`, `multisig-nested-2of3`, `multisig-2of2`, `multisig-2of4`, `multisig-3of4`, `multisig-3of5`, `multisig-4of7`, `multisig-5of7`, `multisig-7of10`)
|
||||
- `-png-out` (default: empty): optional output directory for plate PNGs (mirrored/inverted if set)
|
||||
- `-dpi` (default: `600`): raster output DPI when using `-png-out`
|
||||
- `-mirror` (default: `false`): mirror raster output horizontally (toner transfer)
|
||||
- `-invert` (default: `false`): invert raster output (white/black swap)
|
||||
- `-desc-qr-mm` (default: `75.0`): maximum descriptor QR size in millimeters
|
||||
- `-desc-qr-mm` (default: `80.0`): descriptor QR size in millimeters (includes safe zone)
|
||||
- `-pcl-out` (default: empty): optional output path for raw PCL
|
||||
- `-wallet-name` (default: empty): optional wallet name printed on plates (defaults to `SEEDETCHER`)
|
||||
- `-etch-stats-page` (default: `false`): append an additional etch stats page with per-plate coverage and PSU guidance
|
||||
- `-compact-2of3` (default: `false`): use compact single-sided layout for `sortedmulti` 2-of-3 descriptor shares
|
||||
|
||||
### Host-mode printer check (usblp)
|
||||
- `image`/`image-debug` load `usblp` automatically (CONFIG_USB_PRINTER). With a USB printer attached you should see dmesg like `usblp0: USB Bidirectional printer` and `/dev/usb/lp0` present.
|
||||
@ -187,6 +90,9 @@ go run cmd/cli/main.go -w multisig \
|
||||
- -png-out/-dpi/-mirror/-invert/-desc-qr-mm apply to raster plate generation; resulting PDF, PNG, and PCL outputs all reflect those settings.
|
||||
- `-pcl-out <path|dir>` writes raw PCL (mirrored/inverted if flags set). If a directory or trailing `/` is provided, the file is auto-named `<wallet>.pcl` inside it.
|
||||
- Send PCL over USB: `scripts/print_pcl.sh <file.pcl> [printer_dev]` (defaults `/dev/usb/lp0`, resets channel and streams with `dd bs=16k`).
|
||||
- On Pi host mode (`/dev/usb/lp0`), controller printing uses direct 1bpp plate-to-PCL streaming (lower memory, faster).
|
||||
- Gadget mode fallback (`/dev/ttyGS1`) still uses raster page composition + PDF serialization for capture/dev flows.
|
||||
- Plate QR circular data modules are intentionally undersized (`plateQRDotScale = 0.7` in `printer/raster.go`) to leave etch-growth headroom; structural islands (finder/alignment) remain square.
|
||||
|
||||
## Versioning
|
||||
|
||||
@ -198,6 +104,66 @@ go run cmd/cli/main.go -w multisig \
|
||||
`version.String()` prefers `Build` when set, otherwise falls back to `Tag`.
|
||||
- The plate renderer uses `version.String()`.
|
||||
|
||||
### Release image builds (`mkRelease`)
|
||||
|
||||
Set the release tag in `version/version.go` and run:
|
||||
|
||||
```bash
|
||||
nix run .#mkRelease
|
||||
```
|
||||
|
||||
This builds from the current checkout by default (works on forks), then writes:
|
||||
|
||||
```text
|
||||
release/seedetcher-vX.Y.Z.img
|
||||
```
|
||||
|
||||
To override version explicitly:
|
||||
|
||||
```bash
|
||||
nix run .#mkRelease -- vX.Y.Z
|
||||
```
|
||||
|
||||
To build/stamp from a different flake ref, set:
|
||||
|
||||
```bash
|
||||
SE_RELEASE_FLAKE=github:seedetcher/seedetcher/vX.Y.Z nix run .#mkRelease -- vX.Y.Z
|
||||
```
|
||||
|
||||
### Reproducibility check
|
||||
|
||||
Release images are intended to be deterministic. Verify your local build hash against the published artifact hash:
|
||||
|
||||
```bash
|
||||
sha256sum release/seedetcher-vX.Y.Z.img
|
||||
# or on macOS:
|
||||
shasum -a 256 release/seedetcher-vX.Y.Z.img
|
||||
```
|
||||
|
||||
### Third-party license release check
|
||||
|
||||
Release images include third-party printing/runtime components (CUPS, cups-filters,
|
||||
Ghostscript, brlaser). Before publishing a release:
|
||||
|
||||
1. Review/update [`THIRD_PARTY_LICENSES.md`](../THIRD_PARTY_LICENSES.md).
|
||||
2. Ensure `flake.lock` is committed in the release tag.
|
||||
3. In release notes, link to:
|
||||
- the source tag/commit,
|
||||
- `THIRD_PARTY_LICENSES.md`,
|
||||
- any local patch/build changes for bundled GPL/AGPL components.
|
||||
|
||||
Minimal release-note snippet:
|
||||
|
||||
```md
|
||||
## Source and Licensing
|
||||
- Source tag: <TAG>
|
||||
- Third-party licenses: THIRD_PARTY_LICENSES.md
|
||||
- GPL/AGPL local patches: none
|
||||
```
|
||||
|
||||
For the complete release flow, see:
|
||||
`docs/dev/release-checklist.md`
|
||||
|
||||
## Shell Commands on Zero
|
||||
|
||||
Use `-test-createPageLayout` to run controller in headless render-test mode (no GUI), so `-w/-mnemonic/-descriptor/...` flags are applied.
|
||||
|
||||
@ -34,4 +34,4 @@ Seed generation is **not** included — only processing of existing seeds.
|
||||
- `printer/raster.go` – canonical plate bitmap rendering.
|
||||
- `printer/pcl.go` – PCL output writer.
|
||||
- `printer/pdf_raster.go` – PDF writer from raster pages.
|
||||
- `printer/printer.go` – legacy/deprecated helpers (kept for compatibility).
|
||||
- `printer/printer.go` – shared print primitives (paper constants, descriptor QR payload, font loading).
|
||||
|
||||
119
docs/printers.md
Normal file
119
docs/printers.md
Normal file
@ -0,0 +1,119 @@
|
||||
# Supported Printers
|
||||
|
||||
**Important:** For SeedEtcher a laser printer MUST have USB. All printers in this list do.
|
||||
PCL and PostScript capable printers should generally work well with SeedEtcher.
|
||||
Gotchas: If your print comes out 2x scale, you probably set the printer to a different resolution than what you're sending.
|
||||
|
||||
## Brother
|
||||
|
||||
***PCL capability is preferable***, since you won't need to load the extra HBP (Brother's Host Based Printer) support (which is heavy on a zero).
|
||||
A printer without Wi-Fi is preferable (air-gapped security). Wi-Fi can be shut off, however.
|
||||
|
||||
Brother's suffix meanings:
|
||||
D = Duplex
|
||||
W = Wireless
|
||||
N = Network
|
||||
C = Color
|
||||
|
||||
Therefore:
|
||||
- DN = duplex, network
|
||||
- DW = duplex, wireless
|
||||
|
||||
|
||||
This table is primarily generated from (`brlaser.drv`) and kept as one list ordered by model name.
|
||||
Capabilities are initially derived from brlaser data (including 1284DeviceID CMD tokens) and then manually corrected where verified by vendor specs or real-world testing.
|
||||
Some rows are manual additions for known models not present in the current `brlaser.drv` export.
|
||||
`PCL`/`PS`/`HBP` may be manually corrected for models with known support not reflected in `CMD:`.
|
||||
|
||||
| Brand | Model Name | USB | PCL | PS | HBP | Tested |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Brother | DCP-1510 series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-1600 series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-1610W series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-7010 | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-7020 | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-7030 | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-7040 | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-7055 | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-7055W | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-7060D | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-7065DN | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-7070DW | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-7080 | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-7080D | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-8065DN | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-B7500D series | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-L2500D series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-L2510D series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-L2520D series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-L2520DW series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-L2537DW | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-L2540DW series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-L2550DW series | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | DCP-L2560DW series | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| FX | DocuPrint P265 dw | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | FAX-2820 | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | FAX-2840 | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-1110 series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-1200 series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-2030 series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-2130 series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-2140 series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-2220 series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-2230 series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-2240 series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-2240D series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-2250DN series | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-2260 | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-2260D | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-2270DW series | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-2280DW | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-5030 series | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-5040 series | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-5140 series | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-5370DW series | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-5450DN series | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-L2300D series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-L2305 series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-L2310D series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-L2320D series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-L2335D series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-L2340D series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-L2350DW series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-L2360D series | 🟢 | 🔴 | 🔴 | 🟢 | 🟢 |
|
||||
| Brother | HL-L2370DN series | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-L2375DW series | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-L2380DW series | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-L2390DW | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-L2400D (alias of HL-L2400DW) | 🟢 | 🔴 | 🔴 | 🟢 | 🟢 |
|
||||
| Brother | HL-L2400DW | 🟢 | 🔴 | 🔴 | 🟢 | 🟢 |
|
||||
| Brother | HL-L2402D | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-L2405W | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | HL-L5000D series | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
|
||||
| Lenovo | LJ2650DN | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-1810 series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-1910W series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-7240 | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-7320 | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-7340 | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-7360N | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-7365DN | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-7420 | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-7440N | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-7460DN | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-7860DW | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-8440 | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-8710DW | 🟢 | 🟢 | 🟢 | 🟢 | 🔴 |
|
||||
| Brother | MFC-8860DN | 🟢 | 🟢 | 🟢 | 🟢 | 🔴 |
|
||||
| Brother | MFC-9140CDN (manual entry) | 🟢 | 🟢 | 🟢 | 🟢 | 🔴 |
|
||||
| Brother | MFC-9160 | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-L2690DW | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-L2700DN series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-L2700DW series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-L2710DN series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-L2710DW series | 🟢 | 🔴 | 🔴 | 🟢 | 🔴 |
|
||||
| Brother | MFC-L2750DW series | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 |
|
||||
|
||||
## Other Printers
|
||||
|
||||
For non-Brother models, only printers with true PCL or PostScript are currently supported.
|
||||
347
flake.nix
347
flake.nix
@ -134,9 +134,10 @@
|
||||
|
||||
./scripts/config --set-str EXTRA_FIRMWARE panel.bin
|
||||
./scripts/config --set-str EXTRA_FIRMWARE_DIR ${panel-firmware}
|
||||
# Disable networking (including bluetooth).
|
||||
./scripts/config --disable NET
|
||||
./scripts/config --disable INET
|
||||
# Enable minimal networking/socket support (needed for CUPS runtime).
|
||||
./scripts/config --enable NET
|
||||
./scripts/config --enable INET
|
||||
./scripts/config --enable UNIX
|
||||
./scripts/config --disable NETFILTER
|
||||
./scripts/config --disable PROC_SYSCTL
|
||||
./scripts/config --disable FSCACHE
|
||||
@ -145,8 +146,8 @@
|
||||
./scripts/config --disable SECURITY
|
||||
# Disable sound support.
|
||||
./scripts/config --disable SOUND
|
||||
# Disable features we don't need.
|
||||
./scripts/config --disable EXT4_FS
|
||||
# Keep ext4 enabled (used by SD-rootfs CUPS runtime).
|
||||
./scripts/config --enable EXT4_FS
|
||||
./scripts/config --disable F2FS_FS
|
||||
./scripts/config --disable PSTORE
|
||||
./scripts/config --disable INPUT_TOUCHSCREEN
|
||||
@ -271,16 +272,24 @@
|
||||
${pkgs.python3}/bin/python3 ${firmware-converter} $out/panel.bin ${firmware}
|
||||
'';
|
||||
};
|
||||
mkinitramfs = debug:
|
||||
mkinitramfs = { debug, cupsRuntime ? false }:
|
||||
let
|
||||
pkgs = hostPkgs;
|
||||
busyboxStatic = crossPkgs.pkgsStatic.busybox;
|
||||
bashStatic = crossPkgs.pkgsStatic.bash;
|
||||
straceStatic = crossPkgs.pkgsStatic.strace;
|
||||
binutilsStatic = crossPkgs.pkgsStatic.binutils; #for readelf
|
||||
mupdfHeadlessStatic = self.packages.${system}.mupdf_headless;
|
||||
cupsPkg = crossPkgs.cups;
|
||||
cupsFiltersPkg = crossPkgs.cups-filters;
|
||||
ghostscriptPkg = crossPkgs.ghostscript;
|
||||
popplerUtilsPkg = crossPkgs.poppler_utils;
|
||||
brlaserPkg = self.packages.${system}.brlaser-se-runtime;
|
||||
cupsRuntimeStoreClosure = pkgs.closureInfo {
|
||||
rootPaths = [ crossPkgs.cups crossPkgs.cups-filters crossPkgs.ghostscript crossPkgs.poppler_utils brlaserPkg ];
|
||||
};
|
||||
|
||||
fontFile = ./font/martianmono/MartianMono_Condensed-Regular.ttf;
|
||||
seedEtcherFontFile = ./font/seedetcher/SeedEtcher-Regular.ttf;
|
||||
|
||||
controller =
|
||||
if debug then
|
||||
@ -317,6 +326,10 @@
|
||||
cp ${fontFile} initramfs/font/martianmono/MartianMono_Condensed-Regular.ttf
|
||||
chmod 0644 initramfs/font/martianmono/MartianMono_Condensed-Regular.ttf
|
||||
${pkgs.coreutils}/bin/touch -d '${timestamp}' initramfs/font/martianmono/MartianMono_Condensed-Regular.ttf
|
||||
mkdir -p initramfs/font/seedetcher
|
||||
cp ${seedEtcherFontFile} initramfs/font/seedetcher/SeedEtcher-Regular.ttf
|
||||
chmod 0644 initramfs/font/seedetcher/SeedEtcher-Regular.ttf
|
||||
${pkgs.coreutils}/bin/touch -d '${timestamp}' initramfs/font/seedetcher/SeedEtcher-Regular.ttf
|
||||
|
||||
# Create essential initramfs directories
|
||||
mkdir -p initramfs/{bin,lib,share,dev,proc,sys,run,tmp} # Added tmp here
|
||||
@ -342,6 +355,14 @@
|
||||
cp -a ${straceStatic}/bin/strace initramfs/bin/
|
||||
cp -L --no-preserve=mode ${binutilsStatic}/bin/readelf initramfs/bin/readelf
|
||||
chmod +x initramfs/bin/readelf
|
||||
cp ${./scripts/debug/sd-removal-dump} initramfs/bin/sd-removal-dump
|
||||
chmod 0755 initramfs/bin/sd-removal-dump
|
||||
${if debug then ''
|
||||
cp ${./scripts/debug/export-logs-to-sd} initramfs/bin/export-logs-to-sd
|
||||
cp ${./scripts/debug/pjl-snapshot} initramfs/bin/pjl-snapshot
|
||||
chmod 0755 initramfs/bin/export-logs-to-sd
|
||||
chmod 0755 initramfs/bin/pjl-snapshot
|
||||
'' else ""}
|
||||
|
||||
# Debug output
|
||||
echo "Verifying readelf:"
|
||||
@ -353,8 +374,35 @@
|
||||
cp -L --no-preserve=mode "$target" initramfs/bin/readelf || echo "Failed to copy readelf"
|
||||
chmod +x initramfs/bin/readelf 2>/dev/null || echo "Warning: Could not change permissions of readelf"
|
||||
|
||||
cp -a ${mupdfHeadlessStatic}/bin/mutool initramfs/bin/ || echo "Failed to copy mutool from ${mupdfHeadlessStatic}/bin/"
|
||||
chmod +x initramfs/bin/mutool || echo "chmod failed, listing dir: $(ls -alh initramfs/bin/)"
|
||||
# Optional CUPS/Ghostscript tooling for HBP runtime support.
|
||||
${if cupsRuntime then ''
|
||||
mkdir -p initramfs/etc/cups initramfs/var/spool/cups initramfs/var/run/cups initramfs/nix/store
|
||||
if [ -d ${cupsPkg}/etc/cups ]; then
|
||||
cp -a ${cupsPkg}/etc/cups/* initramfs/etc/cups/
|
||||
fi
|
||||
for f in cupsd lp lpstat lpadmin lpinfo cupsfilter ppdc; do
|
||||
if [ -x ${cupsPkg}/bin/$f ]; then
|
||||
ln -sf ${cupsPkg}/bin/$f initramfs/bin/$f
|
||||
fi
|
||||
done
|
||||
if [ -x ${ghostscriptPkg}/bin/gs ]; then
|
||||
ln -sf ${ghostscriptPkg}/bin/gs initramfs/bin/gs
|
||||
fi
|
||||
if [ -x ${popplerUtilsPkg}/bin/pdftops ]; then
|
||||
ln -sf ${popplerUtilsPkg}/bin/pdftops initramfs/bin/pdftops
|
||||
fi
|
||||
cp ${./scripts/cups/cups-runtime-bootstrap} initramfs/bin/cups-runtime-bootstrap
|
||||
cp ${./scripts/cups/cups-runtime-ram-feasibility} initramfs/bin/cups-runtime-ram-feasibility
|
||||
cp ${./scripts/cups/print-hbp-pdf} initramfs/bin/print-hbp-pdf
|
||||
chmod 0755 initramfs/bin/cups-runtime-bootstrap initramfs/bin/cups-runtime-ram-feasibility initramfs/bin/print-hbp-pdf
|
||||
echo "CUPS_RUNTIME=1" > initramfs/cups-runtime.env
|
||||
echo "BRLASER_ROOT=${brlaserPkg}" >> initramfs/cups-runtime.env
|
||||
echo "CUPS_FILTERS_ROOT=${cupsFiltersPkg}" >> initramfs/cups-runtime.env
|
||||
${pkgs.coreutils}/bin/touch -d '${timestamp}' initramfs/cups-runtime.env
|
||||
cp ${cupsRuntimeStoreClosure}/store-paths initramfs/cups-runtime-store-paths
|
||||
chmod 0644 initramfs/cups-runtime-store-paths
|
||||
${pkgs.coreutils}/bin/touch -d '${timestamp}' initramfs/cups-runtime-store-paths
|
||||
'' else ""}
|
||||
|
||||
|
||||
# Only create symlinks if they do not already exist
|
||||
@ -390,7 +438,7 @@
|
||||
|
||||
allowedReferences = [ ];
|
||||
};
|
||||
mkimage = { debug, usbMode ? "gadget" }:
|
||||
mkimage = { debug, usbMode ? "gadget", cupsRuntime ? false }:
|
||||
let
|
||||
pkgs = hostPkgs;
|
||||
firmware = self.packages.${system}.firmware;
|
||||
@ -400,12 +448,16 @@
|
||||
else
|
||||
self.packages.${system}.kernel;
|
||||
|
||||
initramfs = self.lib.${system}.mkinitramfs debug;
|
||||
initramfs = self.lib.${system}.mkinitramfs { inherit debug cupsRuntime; };
|
||||
cupsRuntimeStoreClosure = pkgs.closureInfo {
|
||||
rootPaths = [ crossPkgs.cups crossPkgs.cups-filters crossPkgs.ghostscript crossPkgs.poppler_utils self.packages.${system}.brlaser-se-runtime ];
|
||||
};
|
||||
img-name =
|
||||
let
|
||||
base = if usbMode == "host" then "seedetcher" else "seedetcher-gadget";
|
||||
modeBase = if usbMode == "host" then "seedetcher" else "seedetcher-gadget";
|
||||
in
|
||||
if debug then "${base}-debug.img" else "${base}.img";
|
||||
if debug then "${modeBase}-debug.img" else "${modeBase}.img";
|
||||
imageSizeMB = 64;
|
||||
|
||||
cmdlinetxt =
|
||||
let
|
||||
@ -458,19 +510,57 @@
|
||||
}
|
||||
|
||||
# Create disk image.
|
||||
dd if=/dev/zero of=disk.img bs=1M count=64
|
||||
${if cupsRuntime then ''
|
||||
# Auto-size rootfs partition from closure size to avoid huge sparse images.
|
||||
CLOSURE_BYTES=0
|
||||
while IFS= read -r p; do
|
||||
[ -e "$p" ] || continue
|
||||
sz="$(${pkgs.coreutils}/bin/du -sb "$p" | ${pkgs.coreutils}/bin/cut -f1)"
|
||||
CLOSURE_BYTES=$((CLOSURE_BYTES + sz))
|
||||
done < ${cupsRuntimeStoreClosure}/store-paths
|
||||
|
||||
# Add overhead for filesystem metadata + runtime writable paths.
|
||||
ROOTFS_BYTES_EST=$(( (CLOSURE_BYTES * 130) / 100 + 128 * 1024 * 1024 ))
|
||||
MIN_ROOTFS_BYTES=$((256 * 1024 * 1024))
|
||||
if [ "$ROOTFS_BYTES_EST" -lt "$MIN_ROOTFS_BYTES" ]; then
|
||||
ROOTFS_BYTES_EST=$MIN_ROOTFS_BYTES
|
||||
fi
|
||||
ROOTFS_SECTORS=$(( (ROOTFS_BYTES_EST + 511) / 512 ))
|
||||
|
||||
BOOT_SECTORS=114688
|
||||
START1=2048
|
||||
START2=$((START1 + BOOT_SECTORS))
|
||||
ALIGN_SECTORS=2048
|
||||
TOTAL_SECTORS=$((START2 + ROOTFS_SECTORS + ALIGN_SECTORS))
|
||||
IMAGE_BYTES=$((TOTAL_SECTORS * 512))
|
||||
IMAGE_MB=$(( (IMAGE_BYTES + 1024*1024 - 1) / (1024*1024) ))
|
||||
|
||||
dd if=/dev/zero of=disk.img bs=1M count="$IMAGE_MB"
|
||||
${pkgs.util-linux}/bin/sfdisk disk.img <<EOF
|
||||
label: dos
|
||||
label-id: 0xceedb0ad
|
||||
|
||||
disk.img1 : size=$BOOT_SECTORS, type=c, bootable
|
||||
disk.img2 : size=$ROOTFS_SECTORS, type=83
|
||||
EOF
|
||||
'' else ''
|
||||
dd if=/dev/zero of=disk.img bs=1M count=${toString imageSizeMB}
|
||||
${pkgs.util-linux}/bin/sfdisk disk.img <<EOF
|
||||
label: dos
|
||||
label-id: 0xceedb0ad
|
||||
|
||||
disk.img1 : type=c, bootable
|
||||
EOF
|
||||
''}
|
||||
|
||||
# Create boot partition.
|
||||
START=$(${pkgs.util-linux}/bin/fdisk -l -o Start disk.img|tail -n 1)
|
||||
SECTORS=$(${pkgs.util-linux}/bin/fdisk -l -o Sectors disk.img|tail -n 1)
|
||||
${pkgs.dosfstools}/bin/mkfs.vfat --invariant -i deadbeef -n seedetcher disk.img --offset $START $(sectorsToBlocks $SECTORS)
|
||||
OFFSET=$(sectorsToBytes $START)
|
||||
PART_INFO="$(${pkgs.util-linux}/bin/fdisk -l -o Device,Start,Sectors disk.img)"
|
||||
START1=$(echo "$PART_INFO" | awk '$1=="disk.img1"{print $2}')
|
||||
SECTORS1=$(echo "$PART_INFO" | awk '$1=="disk.img1"{print $3}')
|
||||
BOOT_BYTES=$((SECTORS1 * 512))
|
||||
dd if=/dev/zero of=boot.vfat bs=1 count=0 seek="$BOOT_BYTES"
|
||||
${pkgs.dosfstools}/bin/mkfs.vfat --invariant -i deadbeef -n seedetcher boot.vfat
|
||||
OFFSET=0
|
||||
|
||||
# Copy boot files.
|
||||
mkdir -p boot/overlays overlays
|
||||
@ -484,10 +574,29 @@
|
||||
|
||||
chmod 0755 `find boot overlays`
|
||||
${pkgs.coreutils}/bin/touch -d '${timestamp}' `find boot overlays`
|
||||
${pkgs.mtools}/bin/mcopy -bpm -i "disk.img@@$OFFSET" boot/* ::
|
||||
${pkgs.mtools}/bin/mcopy -bpm -i "boot.vfat@@$OFFSET" boot/* ::
|
||||
# mcopy doesn't copy directories deterministically, so rely on sorted shell globbing
|
||||
# instead.
|
||||
${pkgs.mtools}/bin/mcopy -bpm -i "disk.img@@$OFFSET" overlays/* ::overlays
|
||||
${pkgs.mtools}/bin/mcopy -bpm -i "boot.vfat@@$OFFSET" overlays/* ::overlays
|
||||
dd if=boot.vfat of=disk.img bs=512 seek="$START1" conv=notrunc status=none
|
||||
|
||||
${if cupsRuntime then ''
|
||||
START2=$(echo "$PART_INFO" | awk '$1=="disk.img2"{print $2}')
|
||||
SECTORS2=$(echo "$PART_INFO" | awk '$1=="disk.img2"{print $3}')
|
||||
mkdir -p rootfsdir/store rootfsdir/etc/cups rootfsdir/var/run/cups rootfsdir/var/spool/cups rootfsdir/var/log/cups
|
||||
while IFS= read -r p; do
|
||||
rel="''${p#/nix}"
|
||||
mkdir -p "rootfsdir$(dirname "$rel")"
|
||||
cp -a "$p" "rootfsdir$rel"
|
||||
done < ${cupsRuntimeStoreClosure}/store-paths
|
||||
if [ -d ${crossPkgs.cups}/etc/cups ]; then
|
||||
cp -a ${crossPkgs.cups}/etc/cups/* rootfsdir/etc/cups/
|
||||
fi
|
||||
ROOTFS_BYTES=$((SECTORS2 * 512))
|
||||
dd if=/dev/zero of=rootfs.ext4 bs=1 count=0 seek="$ROOTFS_BYTES"
|
||||
${pkgs.e2fsprogs}/sbin/mke2fs -q -F -t ext4 -d rootfsdir rootfs.ext4 "$(sectorsToBlocks "$SECTORS2")"
|
||||
dd if=rootfs.ext4 of=disk.img bs=512 seek="$START2" conv=notrunc status=none
|
||||
'' else ""}
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
@ -495,7 +604,7 @@
|
||||
cp disk.img $out/${img-name}
|
||||
'';
|
||||
|
||||
allowedReferences = [ ];
|
||||
allowedReferences = if cupsRuntime then null else [ ];
|
||||
};
|
||||
mkcontroller = debug:
|
||||
let
|
||||
@ -573,6 +682,90 @@
|
||||
outputHashAlgo = "sha256";
|
||||
outputHash = "sha256-Ixy4/6AGUNJknBYVMYf5LJB1AscPKlD1CtVHYeHcqbI=";
|
||||
};
|
||||
brlaser-se-runtime =
|
||||
let
|
||||
pkgs = crossPkgs;
|
||||
in
|
||||
pkgs.stdenv.mkDerivation rec {
|
||||
pname = "brlaser-se-runtime";
|
||||
version = "6.2.7";
|
||||
|
||||
src = pkgs.fetchFromGitHub {
|
||||
owner = "Owl-Maintain";
|
||||
repo = "brlaser";
|
||||
tag = "v${version}";
|
||||
hash = "sha256-a+TjLmjqBz0b7v6kW1uxh4BGzrYOQ8aMdVo4orZeMT4=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs.buildPackages; [
|
||||
cmake
|
||||
pkg-config
|
||||
cups
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
pkgs.zlib
|
||||
pkgs.cups.dev
|
||||
pkgs.cups.lib
|
||||
];
|
||||
|
||||
preConfigure = ''
|
||||
mkdir -p .fake-bin
|
||||
cat > .fake-bin/cups-config <<EOF
|
||||
#!/bin/sh
|
||||
case "$1" in
|
||||
--datadir) echo share/cups ;;
|
||||
--serverbin) echo lib/cups ;;
|
||||
--cflags) echo -I${pkgs.cups.dev}/include ;;
|
||||
--ldflags) echo -L${pkgs.cups.lib}/lib ;;
|
||||
--image) shift; [ "$1" = "--libs" ] && echo -lcupsimage -lcups || echo ;;
|
||||
--libs) echo -lcups ;;
|
||||
*) echo ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x .fake-bin/cups-config
|
||||
export PATH="$PWD/.fake-bin:$PATH"
|
||||
'';
|
||||
|
||||
cmakeFlags = [
|
||||
"-DBUILD_TESTING=OFF"
|
||||
"-DBUILD_TESTS=OFF"
|
||||
"-DCMAKE_INSTALL_PREFIX=/"
|
||||
"-DCUPS_CFLAGS=-I${pkgs.cups.dev}/include"
|
||||
"-DCUPS_LDFLAGS=-L${pkgs.cups.lib}/lib"
|
||||
"-DCUPS_LIBS=-lcupsimage;-lcups"
|
||||
];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
make install DESTDIR="$out"
|
||||
# Normalize layout to what init overlay expects.
|
||||
mkdir -p "$out/lib/cups/filter" "$out/share/cups/drv" "$out/share/cups/model"
|
||||
# Carry CUPS ppdc/drv include defs so ppdc can resolve includes in brlaser.drv.
|
||||
if [ -d ${pkgs.cups.lib}/share/cups/drv ]; then
|
||||
cp -a ${pkgs.cups.lib}/share/cups/drv/. "$out/share/cups/drv/" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -d ${pkgs.cups.lib}/share/cups/ppdc ]; then
|
||||
mkdir -p "$out/share/cups/ppdc"
|
||||
cp -a ${pkgs.cups.lib}/share/cups/ppdc/. "$out/share/cups/ppdc/" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -f "$out/filter/rastertobrlaser" ]; then
|
||||
cp -a "$out/filter/rastertobrlaser" "$out/lib/cups/filter/rastertobrlaser"
|
||||
fi
|
||||
if [ -f "$out/drv/brlaser.drv" ]; then
|
||||
cp -a "$out/drv/brlaser.drv" "$out/share/cups/drv/brlaser.drv"
|
||||
# Pre-generate PPDs at build time to avoid runtime drv:/// dependency.
|
||||
${pkgs.buildPackages.cups}/bin/ppdc \
|
||||
-I "$out/share/cups/ppdc" \
|
||||
-I "$out/share/cups/drv" \
|
||||
-d "$out/share/cups/model" \
|
||||
"$out/share/cups/drv/brlaser.drv" >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$out/filter" "$out/drv"
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
};
|
||||
controller = self.lib.${system}.mkcontroller false;
|
||||
controller-debug = self.lib.${system}.mkcontroller true;
|
||||
libcamera =
|
||||
@ -696,12 +889,13 @@
|
||||
sparseCheckout = [ "boot" ];
|
||||
hash = "sha256-rSZ3sUnSmBcsIqc+K91GDs5qlqiP+j9zf9gM2lqzr8w=";
|
||||
};
|
||||
initramfs = self.lib.${system}.mkinitramfs false;
|
||||
initramfs-debug = self.lib.${system}.mkinitramfs true;
|
||||
image = self.lib.${system}.mkimage { debug = false; usbMode = "host"; };
|
||||
image-debug = self.lib.${system}.mkimage { debug = true; usbMode = "host"; };
|
||||
image-gadget = self.lib.${system}.mkimage { debug = false; usbMode = "gadget"; };
|
||||
image-gadget-debug = self.lib.${system}.mkimage { debug = true; usbMode = "gadget"; };
|
||||
# Release-integrated defaults: all main initramfs/images include HBP/CUPS runtime.
|
||||
initramfs = self.lib.${system}.mkinitramfs { debug = false; cupsRuntime = true; };
|
||||
initramfs-debug = self.lib.${system}.mkinitramfs { debug = true; cupsRuntime = true; };
|
||||
image = self.lib.${system}.mkimage { debug = false; usbMode = "host"; cupsRuntime = true; };
|
||||
image-debug = self.lib.${system}.mkimage { debug = true; usbMode = "host"; cupsRuntime = true; };
|
||||
image-gadget = self.lib.${system}.mkimage { debug = false; usbMode = "gadget"; cupsRuntime = true; };
|
||||
image-gadget-debug = self.lib.${system}.mkimage { debug = true; usbMode = "gadget"; cupsRuntime = true; };
|
||||
# reload the controller binary to a running seedetcher debug image.
|
||||
reload = let pkgs = hostPkgs; in pkgs.writeShellScriptBin "reload" ''
|
||||
#!/bin/sh
|
||||
@ -722,8 +916,8 @@
|
||||
PROG="${self.packages.${system}.controller-debug}/bin/controller"
|
||||
|
||||
# Ensure USB serial is in raw mode before reloading
|
||||
stty -F $USBDEV1 raw -echo
|
||||
echo "" > $USBDEV1
|
||||
stty -F "$USBDEV" raw -echo
|
||||
echo "" > "$USBDEV"
|
||||
|
||||
echo "reload $(wc -c < "$PROG")" > "$USBDEV"
|
||||
cat "$PROG" > "$USBDEV"
|
||||
@ -756,15 +950,36 @@
|
||||
mkRelease = let pkgs = hostPkgs; in pkgs.writeShellScriptBin "mk-release" ''
|
||||
set -eu
|
||||
|
||||
VERSION=$1
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "error: specify version"
|
||||
exit 1
|
||||
# Default to the current checkout (fork-friendly). Override if needed:
|
||||
# SE_RELEASE_FLAKE=github:seedetcher/seedetcher/vX.Y.Z
|
||||
flake_ref="''${SE_RELEASE_FLAKE:-.}"
|
||||
tag="$(awk -F'\"' '/^const Tag = / { print $2; exit }' version/version.go 2>/dev/null || true)"
|
||||
if [ "$#" -gt 1 ]; then
|
||||
echo "usage: mk-release [VERSION]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
flake="github:seedetcher/seedetcher/$VERSION"
|
||||
nix build "$flake"
|
||||
nix run "$flake"#stamp-release $VERSION
|
||||
if [ "$#" -eq 1 ] && [ -n "$1" ]; then
|
||||
VERSION="$1"
|
||||
else
|
||||
VERSION="$tag"
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "error: no VERSION provided and version.Tag not found"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$flake_ref" != "." ] && [ "$#" -eq 0 ]; then
|
||||
echo "error: when SE_RELEASE_FLAKE is set, pass explicit VERSION"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$flake_ref" = "." ]; then
|
||||
if [ -n "$tag" ] && [ "$tag" != "$VERSION" ]; then
|
||||
echo "error: VERSION=$VERSION does not match version.Tag=$tag"
|
||||
echo " update version/version.go or pass matching VERSION"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
nix build "$flake_ref"#image
|
||||
nix run "$flake_ref"#stamp-release -- "$VERSION"
|
||||
|
||||
if [[ -v SSH_SIGNING_KEY ]]; then
|
||||
ssh-keygen -Y sign -f "$SSH_SIGNING_KEY" -n seedetcher.img seedetcher-"$VERSION".img
|
||||
@ -784,13 +999,19 @@
|
||||
trap 'rm -rf -- "$TMPDIR"' EXIT
|
||||
|
||||
src="result/seedetcher.img"
|
||||
dst="seedetcher-$VERSION.img"
|
||||
outdir="release"
|
||||
mkdir -p "$outdir"
|
||||
dst="$outdir/seedetcher-$VERSION.img"
|
||||
|
||||
# Append the version string to the kernel cmdline, to be read by the controller binary.
|
||||
START=$(${pkgs.util-linux}/bin/fdisk -l -o Start $src|tail -n 1)
|
||||
START=$(${pkgs.util-linux}/bin/fdisk -l -o Start "$src" | awk '/^[[:space:]]*[0-9]+[[:space:]]*$/ { gsub(/[[:space:]]/, "", $0); print; exit }')
|
||||
if [ -z "$START" ]; then
|
||||
echo "error: failed to detect boot partition start in $src"
|
||||
exit 1
|
||||
fi
|
||||
OFFSET=$(( $START*512 ))
|
||||
${pkgs.mtools}/bin/mcopy -bpm -i "$src@@$OFFSET" ::cmdline.txt "$TMPDIR/"
|
||||
echo -n " sh_version=$VERSION" >> "$TMPDIR/cmdline.txt"
|
||||
echo -n " se_version=$VERSION" >> "$TMPDIR/cmdline.txt"
|
||||
# preserve attributes for determinism.
|
||||
chmod 0755 "$TMPDIR/cmdline.txt"
|
||||
${pkgs.coreutils}/bin/touch -d '${timestamp}' "$TMPDIR/cmdline.txt"
|
||||
@ -800,52 +1021,6 @@
|
||||
${pkgs.mtools}/bin/mcopy -bpm -i "$dst@@$OFFSET" "$TMPDIR/cmdline.txt" ::
|
||||
'';
|
||||
|
||||
mupdf_headless = crossPkgs.stdenv.mkDerivation {
|
||||
name = "mupdf-headless";
|
||||
version = "1.25.4";
|
||||
|
||||
src = crossPkgs.fetchurl {
|
||||
url = "https://mupdf.com/downloads/archive/mupdf-1.25.4-source.tar.gz";
|
||||
# Replace with the correct hash from your build logs or nix-prefetch-url
|
||||
hash = "sha256-dLlDA4/oFZS/f8ViHGC8pYiyhH8NRvsumWUqIfoNlJE="; # Update this!
|
||||
};
|
||||
|
||||
nativeBuildInputs = with crossPkgs.buildPackages; [
|
||||
pkg-config
|
||||
];
|
||||
buildInputs = with crossPkgs.pkgsStatic; [
|
||||
zlib
|
||||
freetype
|
||||
libpng
|
||||
musl # Explicitly include musl for static linking
|
||||
];
|
||||
|
||||
# Ensure static linking by passing -static and adjusting LDFLAGS
|
||||
buildPhase = ''
|
||||
make HAVE_X11=no HAVE_GLUT=no HAVE_GLFW=no prefix=/usr \
|
||||
CC="${crossPkgs.stdenv.cc.targetPrefix}cc -static" \
|
||||
AR=${crossPkgs.stdenv.cc.targetPrefix}ar \
|
||||
LDFLAGS="-static -L${crossPkgs.pkgsStatic.zlib}/lib -L${crossPkgs.pkgsStatic.freetype}/lib -L${crossPkgs.pkgsStatic.libpng}/lib -L${crossPkgs.pkgsStatic.musl}/lib" \
|
||||
libs # Build libmupdf first
|
||||
make HAVE_X11=no HAVE_GLUT=no HAVE_GLFW=no prefix=/usr \
|
||||
CC="${crossPkgs.stdenv.cc.targetPrefix}cc -static" \
|
||||
AR=${crossPkgs.stdenv.cc.targetPrefix}ar \
|
||||
LDFLAGS="-static -L${crossPkgs.pkgsStatic.zlib}/lib -L${crossPkgs.pkgsStatic.freetype}/lib -L${crossPkgs.pkgsStatic.libpng}/lib -L${crossPkgs.pkgsStatic.musl}/lib" \
|
||||
apps # Build mutool statically
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/bin $out/lib
|
||||
cp build/release/libmupdf.a $out/lib/
|
||||
cp build/release/mutool $out/bin/
|
||||
${crossPkgs.stdenv.cc.targetPrefix}strip $out/bin/mutool
|
||||
'';
|
||||
|
||||
hardeningDisable = ["all"];
|
||||
dontStrip = false;
|
||||
allowedReferences = []; # Keep this for now, should be empty if fully static
|
||||
};
|
||||
|
||||
default = self.packages.${system}.image;
|
||||
};
|
||||
# developer shell for running .#reload-fast.
|
||||
|
||||
8
font/seedetcher/FONTLOG.txt
Normal file
8
font/seedetcher/FONTLOG.txt
Normal file
@ -0,0 +1,8 @@
|
||||
FONTLOG for SeedEtcher
|
||||
======================
|
||||
|
||||
This file records changes for the SeedEtcher font family.
|
||||
|
||||
v1.0
|
||||
----
|
||||
- initial release for SeedEtcher etching optimized plate rendering
|
||||
94
font/seedetcher/OFL.txt
Normal file
94
font/seedetcher/OFL.txt
Normal file
@ -0,0 +1,94 @@
|
||||
Copyright 2025-2026 Bainter (@BainterSAT)
|
||||
with Reserved Font Name "SeedEtcher" and "SeedEtcher Regular".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
15
font/seedetcher/README.md
Normal file
15
font/seedetcher/README.md
Normal file
@ -0,0 +1,15 @@
|
||||
# SeedEtcher Font
|
||||
|
||||
`SeedEtcher-Regular.ttf` is the custom SeedEtcher plate font.
|
||||
|
||||
- Designer/Author: Bainter (@BainterSAT)
|
||||
- Project URL: https://seedetcher.com
|
||||
- Origin: original design from scratch
|
||||
- License: SIL Open Font License 1.1
|
||||
- Reserved Font Names (RFN): "SeedEtcher", "SeedEtcher Regular"
|
||||
|
||||
## Included Files
|
||||
|
||||
- `SeedEtcher-Regular.ttf`
|
||||
- `OFL.txt`
|
||||
- `FONTLOG.txt`
|
||||
BIN
font/seedetcher/SeedEtcher-Regular.ttf
Normal file
BIN
font/seedetcher/SeedEtcher-Regular.ttf
Normal file
Binary file not shown.
7
go.mod
7
go.mod
@ -10,7 +10,6 @@ require (
|
||||
github.com/fxamacker/cbor/v2 v2.4.0
|
||||
github.com/jung-kurt/gofpdf/v2 v2.17.3
|
||||
github.com/kortschak/qr v0.3.0
|
||||
github.com/pdfcpu/pdfcpu v0.9.1
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/image v0.24.0
|
||||
golang.org/x/sys v0.38.0
|
||||
@ -22,15 +21,9 @@ require (
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect
|
||||
github.com/hhrutter/lzw v1.0.0 // indirect
|
||||
github.com/hhrutter/tiff v1.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/kortschak/qr => github.com/seedhammer/kortschak-qr v0.0.0-20240113235555-375796488df0
|
||||
|
||||
16
go.sum
16
go.sum
@ -55,10 +55,6 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
|
||||
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
|
||||
github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0=
|
||||
github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
@ -68,8 +64,6 @@ github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlT
|
||||
github.com/jung-kurt/gofpdf/v2 v2.17.3 h1:otZXZby2gXJ7uU6pzprXHq/R57lsHLi0WtH79VabWxY=
|
||||
github.com/jung-kurt/gofpdf/v2 v2.17.3/go.mod h1:Qx8ZNg4cNsO5i6uLDiBngnm+ii/FjtAqjRNO6drsoYU=
|
||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
@ -79,15 +73,8 @@ github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/pdfcpu/pdfcpu v0.9.1 h1:q8/KlBdHjkE7ZJU4ofhKG5Rjf7M6L324CVM6BMDySao=
|
||||
github.com/pdfcpu/pdfcpu v0.9.1/go.mod h1:fVfOloBzs2+W2VJCCbq60XIxc3yJHAZ0Gahv1oO0gyI=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/seedhammer/kortschak-qr v0.0.0-20240113235555-375796488df0 h1:C/GBca2LVCIeBQWOMBgrbcMV70hW2S5gO8aSAgSLJOc=
|
||||
github.com/seedhammer/kortschak-qr v0.0.0-20240113235555-375796488df0/go.mod h1:l0kMewIexD8HRZ8iW+lFf8y74IvP7652/0wAlaRf22U=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@ -140,15 +127,12 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
31
gui/gui.go
31
gui/gui.go
@ -27,17 +27,20 @@ type Context struct {
|
||||
// Global UI state.
|
||||
Version string
|
||||
// PrintProgress, if set, receives stage progress updates (current, total).
|
||||
PrintProgress func(stage printer.PrintStage, current, total int64)
|
||||
Calibrated bool
|
||||
EmptySDSlot bool
|
||||
PrinterConnected bool
|
||||
PrinterModel string
|
||||
RotateCamera bool
|
||||
LastDescriptor *urtypes.OutputDescriptor
|
||||
Keystores map[uint32]bip39.Mnemonic // Fingerprint -> Mnemonic
|
||||
events []Event
|
||||
toasts []toastMsg
|
||||
dirty bool
|
||||
PrintProgress func(stage printer.PrintStage, current, total int64)
|
||||
Calibrated bool
|
||||
EmptySDSlot bool
|
||||
PrinterConnected bool
|
||||
PrinterModel string
|
||||
HBPRuntimeReady bool
|
||||
SDRemovalPrepared bool
|
||||
LastErrorExport time.Time
|
||||
RotateCamera bool
|
||||
LastDescriptor *urtypes.OutputDescriptor
|
||||
Keystores map[uint32]bip39.Mnemonic // Fingerprint -> Mnemonic
|
||||
events []Event
|
||||
toasts []toastMsg
|
||||
dirty bool
|
||||
}
|
||||
|
||||
type toastMsg struct {
|
||||
@ -186,7 +189,9 @@ type Platform interface {
|
||||
ScanQR(qr *image.Gray) ([][]byte, error)
|
||||
Debug() bool
|
||||
Printer() io.Writer
|
||||
CreatePlates(ctx *Context, mnemonic bip39.Mnemonic, desc *urtypes.OutputDescriptor, keyIdx int) error // Updated
|
||||
PrepareHBPForSDRemoval() error
|
||||
PrepareSDForRemoval() error
|
||||
CreatePlates(ctx *Context, mnemonic bip39.Mnemonic, desc *urtypes.OutputDescriptor, keyIdx int, paper printer.PaperSize, opts printer.RasterOptions) error // Updated
|
||||
}
|
||||
|
||||
type FrameEvent struct {
|
||||
@ -270,7 +275,7 @@ func (b Button) String() string {
|
||||
}
|
||||
|
||||
// mintues to screensaver activation
|
||||
const idleTimeout = 2 * time.Minute
|
||||
const idleTimeout = 3 * time.Minute
|
||||
|
||||
func Run(pl Platform, version string) func(yield func() bool) {
|
||||
return func(yield func() bool) {
|
||||
|
||||
@ -12,10 +12,14 @@ func (s *ActionChoiceScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
if th == nil {
|
||||
th = &singleTheme
|
||||
}
|
||||
choices := []string{"BACKUP WALLET", "RECOVER DESCR."}
|
||||
if debugLoadTestWalletEnabled(ctx) {
|
||||
choices = append(choices, "LOAD TEST WALLET")
|
||||
}
|
||||
cs := &ChoiceScreen{
|
||||
Title: "Action",
|
||||
Lead: "Choose action",
|
||||
Choices: []string{"BACKUP WALLET", "RECOVER DESCR."},
|
||||
Choices: choices,
|
||||
}
|
||||
choice, ok := cs.Choose(ctx, ops, th)
|
||||
if !ok {
|
||||
@ -26,6 +30,11 @@ func (s *ActionChoiceScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
return &BackupFlowScreen{Theme: th}
|
||||
case 1:
|
||||
return &RecoverDescriptorFlowScreen{Theme: th}
|
||||
case 2:
|
||||
if debugLoadTestWalletEnabled(ctx) {
|
||||
return newLoadTestWalletScreen(th)
|
||||
}
|
||||
return &MainMenuScreen{}
|
||||
default:
|
||||
return &MainMenuScreen{}
|
||||
}
|
||||
|
||||
128
gui/screen_action_testwallet_debug.go
Normal file
128
gui/screen_action_testwallet_debug.go
Normal file
@ -0,0 +1,128 @@
|
||||
//go:build debug
|
||||
|
||||
package gui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/bip39"
|
||||
"seedetcher.com/gui/op"
|
||||
"seedetcher.com/printer"
|
||||
"seedetcher.com/testutils"
|
||||
)
|
||||
|
||||
type loadTestWalletScreen struct {
|
||||
Theme *Colors
|
||||
}
|
||||
|
||||
type testWalletFixture struct {
|
||||
Name string
|
||||
Key string
|
||||
}
|
||||
|
||||
var debugTestWalletFixtures = []testWalletFixture{
|
||||
{Name: "Singlesig", Key: "singlesig"},
|
||||
{Name: "Multisig 3/5", Key: "multisig-3of5"},
|
||||
{Name: "Multisig 7/10", Key: "multisig-7of10"},
|
||||
}
|
||||
|
||||
func debugLoadTestWalletEnabled(ctx *Context) bool {
|
||||
return ctx != nil && ctx.Platform != nil && ctx.Platform.Debug()
|
||||
}
|
||||
|
||||
func newLoadTestWalletScreen(th *Colors) Screen {
|
||||
return &loadTestWalletScreen{Theme: th}
|
||||
}
|
||||
|
||||
func (s *loadTestWalletScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
th := s.Theme
|
||||
if th == nil {
|
||||
th = &singleTheme
|
||||
}
|
||||
choices := make([]string, 0, len(debugTestWalletFixtures))
|
||||
for _, f := range debugTestWalletFixtures {
|
||||
choices = append(choices, f.Name)
|
||||
}
|
||||
cs := &ChoiceScreen{
|
||||
Title: "Load Test Wallet",
|
||||
Lead: "Choose wallet type",
|
||||
Choices: choices,
|
||||
}
|
||||
choice, ok := cs.Choose(ctx, ops, th)
|
||||
if !ok {
|
||||
return &ActionChoiceScreen{Theme: th}
|
||||
}
|
||||
if choice < 0 || choice >= len(debugTestWalletFixtures) {
|
||||
return &ActionChoiceScreen{Theme: th}
|
||||
}
|
||||
flow, keystores, err := buildDebugLoadedBackupFlow(debugTestWalletFixtures[choice].Key, th)
|
||||
if err != nil {
|
||||
showError(ctx, ops, th, err)
|
||||
return &ActionChoiceScreen{Theme: th}
|
||||
}
|
||||
// Mirror the manual-scan state handoff before entering post-scan flow.
|
||||
ctx.Keystores = keystores
|
||||
ctx.LastDescriptor = flow.desc
|
||||
flow.printDesc = flow.desc
|
||||
return flow
|
||||
}
|
||||
|
||||
func buildDebugLoadedBackupFlow(fixtureKey string, th *Colors) (*BackupFlowScreen, map[uint32]bip39.Mnemonic, error) {
|
||||
cfg, ok := testutils.WalletConfigs[fixtureKey]
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("unknown test wallet fixture: %s", fixtureKey)
|
||||
}
|
||||
mnemonics, desc, err := testutils.ParseWallet(cfg, "", "")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load test wallet: %w", err)
|
||||
}
|
||||
if desc == nil {
|
||||
return nil, nil, fmt.Errorf("fixture %s has no descriptor", fixtureKey)
|
||||
}
|
||||
keystores, err := deriveFixtureKeystores(desc, mnemonics)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
flow := &BackupFlowScreen{
|
||||
Theme: th,
|
||||
stage: stageConfirm,
|
||||
desc: desc,
|
||||
totalSeeds: len(desc.Keys),
|
||||
currentSeed: len(desc.Keys),
|
||||
label: printer.DefaultWalletLabel,
|
||||
printDesc: desc,
|
||||
}
|
||||
if setID, ok := deriveShardSetID(desc); ok {
|
||||
flow.shardSetID = setID
|
||||
}
|
||||
flow.shardShares = buildShardShares(desc, flow.shardSetID)
|
||||
// Stash derived seeds keyed by MFP into descriptor order.
|
||||
flow.printMnemonic = keystores[desc.Keys[0].MasterFingerprint]
|
||||
return flow, keystores, nil
|
||||
}
|
||||
|
||||
func deriveFixtureKeystores(desc *urtypes.OutputDescriptor, mnemonics []bip39.Mnemonic) (map[uint32]bip39.Mnemonic, error) {
|
||||
if desc == nil {
|
||||
return nil, fmt.Errorf("missing descriptor")
|
||||
}
|
||||
network := &chaincfg.MainNetParams
|
||||
if len(desc.Keys) > 0 && desc.Keys[0].Network != nil {
|
||||
network = desc.Keys[0].Network
|
||||
}
|
||||
keystores := make(map[uint32]bip39.Mnemonic, len(mnemonics))
|
||||
for _, m := range mnemonics {
|
||||
mfp, err := masterFingerprintFor(m, network)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive test wallet fingerprint: %w", err)
|
||||
}
|
||||
keystores[mfp] = m
|
||||
}
|
||||
for _, k := range desc.Keys {
|
||||
if _, ok := keystores[k.MasterFingerprint]; !ok {
|
||||
return nil, fmt.Errorf("fixture seed set does not match descriptor (%08x missing)", k.MasterFingerprint)
|
||||
}
|
||||
}
|
||||
return keystores, nil
|
||||
}
|
||||
12
gui/screen_action_testwallet_stub.go
Normal file
12
gui/screen_action_testwallet_stub.go
Normal file
@ -0,0 +1,12 @@
|
||||
//go:build !debug
|
||||
|
||||
package gui
|
||||
|
||||
func debugLoadTestWalletEnabled(ctx *Context) bool {
|
||||
_ = ctx
|
||||
return false
|
||||
}
|
||||
|
||||
func newLoadTestWalletScreen(th *Colors) Screen {
|
||||
return &ActionChoiceScreen{Theme: th}
|
||||
}
|
||||
@ -8,156 +8,11 @@ import (
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/bip39"
|
||||
"seedetcher.com/gui/assets"
|
||||
"seedetcher.com/gui/op"
|
||||
"seedetcher.com/logutil"
|
||||
"seedetcher.com/printer"
|
||||
"seedetcher.com/seedqr"
|
||||
)
|
||||
|
||||
// backupWalletFlow drives the main backup/print flow.
|
||||
func backupWalletFlow(ctx *Context, ops op.Ctx, th *Colors) {
|
||||
logutil.DebugLog("backupWalletFlow: Starting")
|
||||
|
||||
descLoop:
|
||||
for {
|
||||
desc, ok := inputDescriptorFlow(ctx, ops, th)
|
||||
logutil.DebugLog("backupWalletFlow: After inputDescriptorFlow, desc=%v, ok=%v", desc != nil, ok)
|
||||
if !ok {
|
||||
logutil.DebugLog("backupWalletFlow: inputDescriptorFlow failed, exiting")
|
||||
return
|
||||
}
|
||||
if desc == nil {
|
||||
// Singlesig path
|
||||
for {
|
||||
mnemonic, ok := newMnemonicFlow(ctx, ops, th, 1, 1) // Singlesig: 1/1
|
||||
if !ok {
|
||||
logutil.DebugLog("backupWalletFlow: newMnemonicFlow failed")
|
||||
continue descLoop
|
||||
}
|
||||
logutil.DebugLog("backupWalletFlow: Seed flow done")
|
||||
if !new(SeedScreen).Confirm(ctx, ops, th, mnemonic) {
|
||||
logutil.DebugLog("backupWalletFlow: SeedScreen.Confirm failed")
|
||||
continue descLoop
|
||||
}
|
||||
logutil.DebugLog("backupWalletFlow: Seed confirmed")
|
||||
mfp, err := masterFingerprintFor(mnemonic, &chaincfg.MainNetParams)
|
||||
if err != nil {
|
||||
logutil.DebugLog("backupWalletFlow: Fingerprint error: %v", err)
|
||||
showError(ctx, ops, th, fmt.Errorf("Failed to compute fingerprint: %v", err))
|
||||
continue descLoop
|
||||
}
|
||||
ctx.Keystores[mfp] = mnemonic
|
||||
logutil.DebugLog("backupWalletFlow: Keystore updated, printing singlesig")
|
||||
printScreen := &PrintSeedScreen{}
|
||||
if printScreen.Print(ctx, ops, th, mnemonic, nil, 0, printer.PaperA4, printer.DefaultWalletLabel) {
|
||||
logutil.DebugLog("backupWalletFlow: Print succeeded")
|
||||
return
|
||||
}
|
||||
logutil.DebugLog("backupWalletFlow: Print failed")
|
||||
continue descLoop
|
||||
}
|
||||
}
|
||||
|
||||
logutil.DebugLog("backupWalletFlow: Descriptor present with %d keys", len(desc.Keys))
|
||||
totalSeeds := len(desc.Keys)
|
||||
for i := 1; i <= totalSeeds; i++ {
|
||||
seedLoop:
|
||||
for {
|
||||
mnemonic, ok := newMnemonicFlow(ctx, ops, th, i, totalSeeds)
|
||||
if !ok {
|
||||
logutil.DebugLog("backupWalletFlow: newMnemonicFlow failed, retrying seed %d", i)
|
||||
confirm := &ConfirmWarningScreen{
|
||||
Title: "Restart Process?",
|
||||
Body: "Do you want to restart and clear all scanned data?\n\nHold button to confirm.",
|
||||
Icon: assets.IconDiscard,
|
||||
}
|
||||
if confirmWarning(ctx, ops, th, confirm) {
|
||||
logutil.DebugLog("backupWalletFlow: User confirmed restart, clearing data")
|
||||
ctx.LastDescriptor = nil
|
||||
ctx.Keystores = make(map[uint32]bip39.Mnemonic)
|
||||
continue descLoop
|
||||
}
|
||||
logutil.DebugLog("backupWalletFlow: User declined restart, continuing seed input")
|
||||
continue seedLoop
|
||||
}
|
||||
logutil.DebugLog("backupWalletFlow: Seed flow done for seed %d", i)
|
||||
if !new(SeedScreen).Confirm(ctx, ops, th, mnemonic) {
|
||||
logutil.DebugLog("backupWalletFlow: SeedScreen.Confirm failed for seed %d", i)
|
||||
confirm := &ConfirmWarningScreen{
|
||||
Title: "Restart Process?",
|
||||
Body: "Do you want to restart and clear all scanned data?\n\nHold button to confirm.",
|
||||
Icon: assets.IconDiscard,
|
||||
}
|
||||
if confirmWarning(ctx, ops, th, confirm) {
|
||||
logutil.DebugLog("backupWalletFlow: User confirmed restart, clearing data")
|
||||
ctx.LastDescriptor = nil
|
||||
ctx.Keystores = make(map[uint32]bip39.Mnemonic)
|
||||
continue descLoop
|
||||
}
|
||||
logutil.DebugLog("backupWalletFlow: User declined restart, continuing seed input")
|
||||
continue seedLoop
|
||||
}
|
||||
logutil.DebugLog("backupWalletFlow: Seed confirmed for seed %d", i)
|
||||
mfp, err := masterFingerprintFor(mnemonic, &chaincfg.MainNetParams)
|
||||
if err != nil {
|
||||
logutil.DebugLog("backupWalletFlow: Fingerprint error: %v", err)
|
||||
showError(ctx, ops, th, fmt.Errorf("Failed to compute fingerprint: %v", err))
|
||||
continue seedLoop
|
||||
}
|
||||
if _, exists := ctx.Keystores[mfp]; exists {
|
||||
logutil.DebugLog("backupWalletFlow: Duplicate seed %.8x detected", mfp)
|
||||
showError(ctx, ops, th, fmt.Errorf("Seed was entered already"))
|
||||
continue seedLoop
|
||||
}
|
||||
_, matched := descriptorKeyIdx(*desc, mnemonic, "")
|
||||
if !matched {
|
||||
logutil.DebugLog("backupWalletFlow: Seed fingerprint %.8x doesn’t match descriptor", mfp)
|
||||
showError(ctx, ops, th, fmt.Errorf("Seed doesn’t match wallet descriptor"))
|
||||
continue seedLoop
|
||||
}
|
||||
ctx.Keystores[mfp] = mnemonic
|
||||
logutil.DebugLog("backupWalletFlow: Keystore updated, seeds scanned: %d/%d", len(ctx.Keystores), len(desc.Keys))
|
||||
break seedLoop
|
||||
}
|
||||
if len(ctx.Keystores) >= len(desc.Keys) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
confirmLoop:
|
||||
for {
|
||||
ds := &DescriptorScreen{Descriptor: *desc, Mnemonic: ctx.Keystores[desc.Keys[0].MasterFingerprint]}
|
||||
confirmKeyIdx, ok := ds.Confirm(ctx, ops, th)
|
||||
logutil.DebugLog("backupWalletFlow: Confirm returned keyIdx=%d, ok=%v", confirmKeyIdx, ok)
|
||||
if !ok {
|
||||
logutil.DebugLog("backupWalletFlow: Descriptor not confirmed, prompting restart")
|
||||
confirm := &ConfirmWarningScreen{
|
||||
Title: "Restart Process?",
|
||||
Body: "Do you want to restart and clear all scanned data?\n\nHold button to confirm.",
|
||||
Icon: assets.IconDiscard,
|
||||
}
|
||||
if confirmWarning(ctx, ops, th, confirm) {
|
||||
logutil.DebugLog("backupWalletFlow: User confirmed restart, clearing data")
|
||||
ctx.LastDescriptor = nil
|
||||
ctx.Keystores = make(map[uint32]bip39.Mnemonic)
|
||||
continue descLoop
|
||||
}
|
||||
logutil.DebugLog("backupWalletFlow: User declined restart, returning to confirm")
|
||||
continue confirmLoop
|
||||
}
|
||||
logutil.DebugLog("backupWalletFlow: All %d seeds collected, printing with keyIdx=%d", len(desc.Keys), confirmKeyIdx)
|
||||
printScreen := &PrintSeedScreen{}
|
||||
if printScreen.Print(ctx, ops, th, ds.Mnemonic, desc, confirmKeyIdx, printer.PaperA4, printer.DefaultWalletLabel) {
|
||||
logutil.DebugLog("backupWalletFlow: Print succeeded")
|
||||
return
|
||||
}
|
||||
logutil.DebugLog("backupWalletFlow: Print failed")
|
||||
continue confirmLoop // Back to Confirm Wallet, not descLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newMnemonicFlow(ctx *Context, ops op.Ctx, th *Colors, current, total int) (bip39.Mnemonic, bool) {
|
||||
cs := &ChoiceScreen{
|
||||
Title: fmt.Sprintf("Input Seed %d/%d", current, total), // Display "Seed X/Y"
|
||||
@ -187,7 +42,7 @@ outer:
|
||||
case 0: // Camera.
|
||||
res, ok := (&ScanScreen{
|
||||
Title: fmt.Sprintf("Scan Seed %d/%d", current, total), // Update ScanScreen title
|
||||
Lead: "SeedQR or Mnemonic",
|
||||
Lead: "SeedQR or Mnemonic QR",
|
||||
}).Scan(ctx, ops)
|
||||
if !ok {
|
||||
continue
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/descriptor/urxor2of3"
|
||||
"seedetcher.com/gui/op"
|
||||
"seedetcher.com/logutil"
|
||||
)
|
||||
@ -50,8 +51,9 @@ func inputDescriptorFlow(ctx *Context, ops op.Ctx, th *Colors) (*urtypes.OutputD
|
||||
switch choice {
|
||||
case 0: // Scan
|
||||
res, ok := (&ScanScreen{
|
||||
Title: "Scan",
|
||||
Lead: "Descriptor",
|
||||
Title: "Scan",
|
||||
Lead: "Descriptor",
|
||||
ShowURXOR2of3: true,
|
||||
}).Scan(ctx, ops)
|
||||
if !ok {
|
||||
logutil.DebugLog("inputDescriptorFlow: Scan returned false")
|
||||
@ -66,6 +68,19 @@ func inputDescriptorFlow(ctx *Context, ops op.Ctx, th *Colors) (*urtypes.OutputD
|
||||
}
|
||||
desc.Title = sanitizeTitle(desc.Title)
|
||||
logutil.DebugLog("inputDescriptorFlow: Returning desc with %d keys", len(desc.Keys))
|
||||
if desc.Type == urtypes.SortedMulti && desc.Threshold >= 2 && !urxor2of3.SupportsScheme(desc.Threshold, len(desc.Keys)) {
|
||||
scr := &ErrorScreen{
|
||||
Title: "Warning",
|
||||
Body: "Descriptor sharding not supported for this m-of-n.\nFull descriptor QR will be printed on each descriptor plate.",
|
||||
}
|
||||
for {
|
||||
dims := ctx.Platform.DisplaySize()
|
||||
if scr.Layout(ctx, ops, th, dims) {
|
||||
break
|
||||
}
|
||||
ctx.Frame()
|
||||
}
|
||||
}
|
||||
ctx.LastDescriptor = &desc
|
||||
return &desc, true
|
||||
case 1: // Skip
|
||||
|
||||
@ -12,7 +12,7 @@ import (
|
||||
"seedetcher.com/gui/widget"
|
||||
)
|
||||
|
||||
const fingerprintsPerPage = 5
|
||||
const fingerprintsPerPage = 7
|
||||
|
||||
// FingerprintsScreen reviews all cosigner fingerprints with pagination.
|
||||
type FingerprintsScreen struct {
|
||||
@ -97,8 +97,10 @@ func (s *FingerprintsScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
fp := strings.ToUpper(fmt.Sprintf("%08x", s.Descriptor.Keys[i].MasterFingerprint))
|
||||
lines = append(lines, fmt.Sprintf("%d. %s", i+1, fp))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, fmt.Sprintf("Page %d/%d", s.Page+1, totalPages))
|
||||
if totalPages > 1 {
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, fmt.Sprintf("Page %d/%d", s.Page+1, totalPages))
|
||||
}
|
||||
body := strings.Join(lines, "\n")
|
||||
arrowW := assets.ArrowLeft.Bounds().Dx()
|
||||
navW := assets.NavBtnPrimary.Bounds().Dx()
|
||||
@ -112,7 +114,7 @@ func (s *FingerprintsScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
if width < 80 {
|
||||
width = 80
|
||||
}
|
||||
style := ctx.Styles.body
|
||||
style := ctx.Styles.subtitle
|
||||
style.Alignment = text.AlignStart
|
||||
bodySize := widget.Labelwf(ops.Begin(), style, width, th.Text, "%s", body)
|
||||
bodyPos := image.Pt(leftPad, title.Max.Y+10)
|
||||
|
||||
@ -14,7 +14,7 @@ import (
|
||||
"seedetcher.com/printer"
|
||||
)
|
||||
|
||||
const labelMaxLen = 20
|
||||
const labelMaxLen = 15
|
||||
|
||||
// LabelInputScreen collects an optional wallet label to print on plates.
|
||||
type LabelInputScreen struct {
|
||||
|
||||
@ -33,9 +33,15 @@ func (s *MainMenuScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
case Button1:
|
||||
return s // No-op back on root
|
||||
case Button3:
|
||||
return &SDCardGateScreen{
|
||||
if ctx != nil {
|
||||
ctx.SDRemovalPrepared = false
|
||||
}
|
||||
return &HBPStartupGateScreen{
|
||||
Theme: &singleTheme,
|
||||
Next: &ActionChoiceScreen{Theme: &singleTheme},
|
||||
Next: &SDCardGateScreen{
|
||||
Theme: &singleTheme,
|
||||
Next: &ActionChoiceScreen{Theme: &singleTheme},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -47,8 +53,12 @@ func (s *MainMenuScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
icon := ops.Begin()
|
||||
op.ImageOp(icon, assets.SeedetcherLogo, false)
|
||||
op.Position(ops, ops.End(), logoPos)
|
||||
// Version badge bottom-left.
|
||||
vlabel := fmt.Sprintf("SeedEtcher %s", version.String())
|
||||
// Version badge bottom-left: prefer stamped runtime version when present.
|
||||
v := ctx.Version
|
||||
if v == "" {
|
||||
v = version.String()
|
||||
}
|
||||
vlabel := fmt.Sprintf("SeedEtcher %s", v)
|
||||
sz := widget.Labelf(ops.Begin(), ctx.Styles.debug, singleTheme.Text, "%s", vlabel)
|
||||
op.Position(ops, ops.End(), image.Pt(6, dims.Y-sz.Y-6))
|
||||
|
||||
@ -59,7 +69,65 @@ func (s *MainMenuScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
}
|
||||
}
|
||||
|
||||
// BackupFlowScreen wraps the legacy backup flow inside the Screen loop.
|
||||
// HBPStartupGateScreen runs before SD removal and allows optional Brother runtime prep.
|
||||
type HBPStartupGateScreen struct {
|
||||
Theme *Colors
|
||||
Next Screen
|
||||
}
|
||||
|
||||
func (s *HBPStartupGateScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
th := s.Theme
|
||||
if th == nil {
|
||||
th = &singleTheme
|
||||
}
|
||||
if ctx != nil && ctx.HBPRuntimeReady {
|
||||
if s.Next != nil {
|
||||
return s.Next
|
||||
}
|
||||
return &MainMenuScreen{}
|
||||
}
|
||||
|
||||
choice, ok := (&ChoiceScreen{
|
||||
Title: "Brother HBP",
|
||||
Lead: "Prefer PCL/PS: faster and lower RAM.\nUse HBP (600dpi cap) only if printer lacks PCL/PS.",
|
||||
Choices: []string{"PCL/PS only", "Enable HBP"},
|
||||
LeadLeft: true,
|
||||
choice: 0,
|
||||
}).Choose(ctx, ops, th)
|
||||
if !ok {
|
||||
return &MainMenuScreen{}
|
||||
}
|
||||
if choice == 0 {
|
||||
if ctx != nil {
|
||||
ctx.HBPRuntimeReady = false
|
||||
if err := ctx.Platform.PrepareSDForRemoval(); err != nil {
|
||||
showError(ctx, ops, th, fmt.Errorf("SD removal prep failed: %v", err))
|
||||
return &MainMenuScreen{}
|
||||
}
|
||||
ctx.SDRemovalPrepared = true
|
||||
}
|
||||
if s.Next != nil {
|
||||
return s.Next
|
||||
}
|
||||
return &MainMenuScreen{}
|
||||
}
|
||||
|
||||
prep := &HBPRuntimePrepareScreen{}
|
||||
if err := prep.Show(ctx, ops, th); err != nil {
|
||||
showError(ctx, ops, th, err)
|
||||
return &MainMenuScreen{}
|
||||
}
|
||||
if ctx != nil {
|
||||
ctx.HBPRuntimeReady = true
|
||||
ctx.SDRemovalPrepared = false
|
||||
}
|
||||
if s.Next != nil {
|
||||
return s.Next
|
||||
}
|
||||
return &MainMenuScreen{}
|
||||
}
|
||||
|
||||
// BackupFlowScreen drives backup and print stages in the Screen loop.
|
||||
type BackupFlowScreen struct {
|
||||
Theme *Colors
|
||||
stage backupStage
|
||||
@ -70,6 +138,7 @@ type BackupFlowScreen struct {
|
||||
confirmKeyIdx int
|
||||
printDesc *urtypes.OutputDescriptor
|
||||
label string
|
||||
printSeedMFP uint32
|
||||
shardSetID [16]byte
|
||||
shardShares []shard.Share
|
||||
}
|
||||
@ -106,18 +175,19 @@ func (s *BackupFlowScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
if desc == nil {
|
||||
s.totalSeeds = 1
|
||||
s.stage = stageSeeds
|
||||
} else {
|
||||
s.totalSeeds = len(desc.Keys)
|
||||
if setID, ok := deriveShardSetID(desc); ok {
|
||||
s.shardSetID = setID
|
||||
} else {
|
||||
s.totalSeeds = len(desc.Keys)
|
||||
if setID, ok := deriveShardSetID(desc); ok {
|
||||
s.shardSetID = setID
|
||||
} else {
|
||||
s.shardSetID = [16]byte{}
|
||||
}
|
||||
s.shardShares = buildShardShares(desc, s.shardSetID)
|
||||
s.stage = stageSeeds
|
||||
s.shardSetID = [16]byte{}
|
||||
}
|
||||
s.shardShares = buildShardShares(desc, s.shardSetID)
|
||||
s.stage = stageSeeds
|
||||
}
|
||||
s.currentSeed = 1
|
||||
s.printMnemonic = nil
|
||||
s.printSeedMFP = 0
|
||||
return s
|
||||
},
|
||||
}
|
||||
@ -146,8 +216,9 @@ func (s *BackupFlowScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
ctx.Keystores[mfp] = mnemonic
|
||||
if s.desc == nil {
|
||||
s.printMnemonic = mnemonic
|
||||
s.printSeedMFP = mfp
|
||||
s.confirmKeyIdx = 0
|
||||
s.stage = stageLabel
|
||||
s.stage = stageFingerprints
|
||||
return s
|
||||
}
|
||||
if len(ctx.Keystores) >= s.totalSeeds {
|
||||
@ -190,18 +261,49 @@ func (s *BackupFlowScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
},
|
||||
}
|
||||
case stageFingerprints:
|
||||
if s.desc == nil {
|
||||
var fpDesc *urtypes.OutputDescriptor
|
||||
if s.desc != nil {
|
||||
fpDesc = s.desc
|
||||
} else if s.printSeedMFP != 0 {
|
||||
tmp := &urtypes.OutputDescriptor{
|
||||
Type: urtypes.Singlesig,
|
||||
Threshold: 1,
|
||||
Keys: []urtypes.KeyDescriptor{
|
||||
{MasterFingerprint: s.printSeedMFP},
|
||||
},
|
||||
}
|
||||
fpDesc = tmp
|
||||
}
|
||||
if fpDesc == nil {
|
||||
s.stage = stageLabel
|
||||
return s
|
||||
}
|
||||
return &FingerprintsScreen{
|
||||
Theme: th,
|
||||
Descriptor: s.desc,
|
||||
Descriptor: fpDesc,
|
||||
OnBack: func() Screen {
|
||||
if s.desc == nil {
|
||||
if maybeRestart(ctx, ops, th, func() {
|
||||
ctx.LastDescriptor = nil
|
||||
ctx.Keystores = make(map[uint32]bip39.Mnemonic)
|
||||
s.desc = nil
|
||||
s.label = printer.DefaultWalletLabel
|
||||
s.printSeedMFP = 0
|
||||
s.stage = stageDescriptor
|
||||
}) {
|
||||
return s
|
||||
}
|
||||
s.stage = stageFingerprints
|
||||
return s
|
||||
}
|
||||
s.stage = stageConfirm
|
||||
return s
|
||||
},
|
||||
OnContinue: func() Screen {
|
||||
if s.desc == nil {
|
||||
s.stage = stageLabel
|
||||
return s
|
||||
}
|
||||
s.stage = stageShardInfo
|
||||
return s
|
||||
},
|
||||
@ -229,10 +331,10 @@ func (s *BackupFlowScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
Default: printer.DefaultWalletLabel,
|
||||
Value: s.label,
|
||||
OnCancel: func() Screen {
|
||||
if s.desc != nil {
|
||||
if s.desc != nil && len(s.shardShares) > 0 {
|
||||
s.stage = stageShardInfo
|
||||
} else {
|
||||
s.stage = stageSeeds
|
||||
s.stage = stageFingerprints
|
||||
}
|
||||
return s
|
||||
},
|
||||
@ -257,19 +359,14 @@ func (s *BackupFlowScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
}
|
||||
job = FromDescriptor(desc, ctx.Keystores[desc.Keys[0].MasterFingerprint], s.confirmKeyIdx, label)
|
||||
}
|
||||
if s.desc != nil {
|
||||
printer.SetDescriptorShardSetID(&s.shardSetID)
|
||||
}
|
||||
return &PrintFlowScreen{
|
||||
Theme: th,
|
||||
Job: job,
|
||||
OnSuccess: func() Screen {
|
||||
printer.SetDescriptorShardSetID(nil)
|
||||
return &MainMenuScreen{}
|
||||
},
|
||||
OnRetry: func() Screen {
|
||||
printer.SetDescriptorShardSetID(nil)
|
||||
s.stage = stageConfirm
|
||||
s.stage = stageLabel
|
||||
return s
|
||||
},
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package gui
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"time"
|
||||
|
||||
"seedetcher.com/bc/urtypes"
|
||||
@ -19,21 +20,71 @@ type PrintSeedScreen struct {
|
||||
inp InputTracker
|
||||
}
|
||||
|
||||
type printOptions struct {
|
||||
DPI int
|
||||
Invert bool
|
||||
Mirror bool
|
||||
EtchStats bool
|
||||
Compact2of3 bool
|
||||
Singlesig printer.SinglesigLayoutMode
|
||||
PrinterLang printer.PrinterLanguage
|
||||
}
|
||||
|
||||
type printSetupState struct {
|
||||
PaperChoice int
|
||||
SinglesigChoice int
|
||||
DPIChoice int
|
||||
InvertChoice int
|
||||
MirrorChoice int
|
||||
StatsChoice int
|
||||
CompactChoice int
|
||||
PrinterLang int
|
||||
}
|
||||
|
||||
var lastPrintSetupState = printSetupState{
|
||||
PaperChoice: 0,
|
||||
SinglesigChoice: 1,
|
||||
DPIChoice: 0,
|
||||
InvertChoice: 0,
|
||||
MirrorChoice: 0,
|
||||
StatsChoice: 0,
|
||||
CompactChoice: 0,
|
||||
PrinterLang: 0,
|
||||
}
|
||||
|
||||
func loadPrintSetupState() printSetupState {
|
||||
return lastPrintSetupState
|
||||
}
|
||||
|
||||
func savePrintSetupState(s printSetupState) {
|
||||
lastPrintSetupState = s
|
||||
}
|
||||
|
||||
func (s *PrintSeedScreen) Print(ctx *Context, ops op.Ctx, th *Colors, mnemonic bip39.Mnemonic, desc *urtypes.OutputDescriptor, keyIdx int, paperFormat printer.PaperSize, label string) bool {
|
||||
if label == "" {
|
||||
label = printer.DefaultWalletLabel
|
||||
}
|
||||
printer.SetWalletLabel(label)
|
||||
inp := &s.inp
|
||||
paperChoice := &ChoiceScreen{
|
||||
Title: "Select Paper Size",
|
||||
Lead: "Choose your printer's\npaper size",
|
||||
Choices: []string{"A4", "Letter"},
|
||||
state := loadPrintSetupState()
|
||||
hbpLocked := ctx != nil && ctx.HBPRuntimeReady
|
||||
setupSteps := make([]string, 0, 8)
|
||||
if isSinglesigDescriptor(desc) {
|
||||
setupSteps = append(setupSteps, "singlesig")
|
||||
}
|
||||
choice, ok := paperChoice.Choose(ctx, ops, th)
|
||||
if !ok {
|
||||
return false
|
||||
if isCompact2of3Eligible(desc) {
|
||||
setupSteps = append(setupSteps, "compact")
|
||||
}
|
||||
setupSteps = append(setupSteps, "paper")
|
||||
if !hbpLocked {
|
||||
setupSteps = append(setupSteps, "dpi")
|
||||
}
|
||||
setupSteps = append(setupSteps, "invert", "mirror", "stats")
|
||||
if !hbpLocked {
|
||||
setupSteps = append(setupSteps, "printerlang")
|
||||
}
|
||||
stepIdx := 0
|
||||
|
||||
updatePrinterStatus := func() {
|
||||
if ctx != nil {
|
||||
connected, model := ctx.Platform.PrinterStatus()
|
||||
@ -43,12 +94,128 @@ func (s *PrintSeedScreen) Print(ctx *Context, ops op.Ctx, th *Colors, mnemonic b
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedPaper := printer.PaperA4
|
||||
if choice == 1 {
|
||||
selectedPaper = printer.PaperLetter
|
||||
chooseWithInitial := func(title, lead string, choices []string, initial int) (int, bool) {
|
||||
cs := &ChoiceScreen{
|
||||
Title: title,
|
||||
Lead: lead,
|
||||
Choices: choices,
|
||||
choice: initial,
|
||||
}
|
||||
return cs.Choose(ctx, ops, th)
|
||||
}
|
||||
|
||||
inSetup := true
|
||||
for {
|
||||
if inSetup {
|
||||
step := setupSteps[stepIdx]
|
||||
var ok bool
|
||||
switch step {
|
||||
case "paper":
|
||||
var next int
|
||||
next, ok = chooseWithInitial("Select Paper Size", "Choose paper size", []string{"A4", "Letter"}, state.PaperChoice)
|
||||
if ok {
|
||||
state.PaperChoice = next
|
||||
}
|
||||
case "singlesig":
|
||||
var next int
|
||||
next, ok = chooseSinglesigLayoutOption(ctx, ops, th, state.SinglesigChoice)
|
||||
if ok {
|
||||
state.SinglesigChoice = next
|
||||
}
|
||||
case "dpi":
|
||||
var next int
|
||||
next, ok = chooseWithInitial("Print DPI", "Choose print resolution", []string{"1200", "600"}, state.DPIChoice)
|
||||
if ok {
|
||||
state.DPIChoice = next
|
||||
}
|
||||
case "invert":
|
||||
var next int
|
||||
next, ok = chooseWithInitial("Invert", "Invert plate output?", []string{"On", "Off"}, state.InvertChoice)
|
||||
if ok {
|
||||
state.InvertChoice = next
|
||||
}
|
||||
case "mirror":
|
||||
var next int
|
||||
next, ok = chooseWithInitial("Mirror", "Mirror plate output?", []string{"On", "Off"}, state.MirrorChoice)
|
||||
if ok {
|
||||
state.MirrorChoice = next
|
||||
}
|
||||
case "stats":
|
||||
var next int
|
||||
next, ok = chooseWithInitial("Etch Stats Page", "Append etch stats page?", []string{"Off", "On"}, state.StatsChoice)
|
||||
if ok {
|
||||
state.StatsChoice = next
|
||||
}
|
||||
case "compact":
|
||||
var next int
|
||||
next, ok = chooseWithInitial("Compact 2/3", "Use compact single-sided\n2-of-3 layout?", []string{"Off", "On"}, state.CompactChoice)
|
||||
if ok {
|
||||
state.CompactChoice = next
|
||||
}
|
||||
case "printerlang":
|
||||
var next int
|
||||
next, ok = choosePrinterLanguageOption(ctx, ops, th, state.PrinterLang, ctx != nil && ctx.HBPRuntimeReady)
|
||||
if ok {
|
||||
state.PrinterLang = next
|
||||
}
|
||||
default:
|
||||
ok = true
|
||||
}
|
||||
if !ok {
|
||||
if stepIdx == 0 {
|
||||
return false
|
||||
}
|
||||
stepIdx--
|
||||
continue
|
||||
}
|
||||
if stepIdx < len(setupSteps)-1 {
|
||||
savePrintSetupState(state)
|
||||
stepIdx++
|
||||
continue
|
||||
}
|
||||
savePrintSetupState(state)
|
||||
inSetup = false
|
||||
continue
|
||||
}
|
||||
|
||||
selectedPaper := printer.PaperA4
|
||||
if state.PaperChoice == 1 {
|
||||
selectedPaper = printer.PaperLetter
|
||||
}
|
||||
opts := printOptions{
|
||||
DPI: 1200,
|
||||
Invert: state.InvertChoice == 0,
|
||||
Mirror: state.MirrorChoice == 0,
|
||||
EtchStats: state.StatsChoice == 1,
|
||||
Compact2of3: state.CompactChoice == 1,
|
||||
Singlesig: printer.SinglesigLayoutSeedWithInfo,
|
||||
PrinterLang: printer.PrinterLangPCL,
|
||||
}
|
||||
if hbpLocked {
|
||||
opts.DPI = 600
|
||||
opts.PrinterLang = printer.PrinterLangBrotherHBP
|
||||
state.DPIChoice = 1
|
||||
state.PrinterLang = 2
|
||||
} else {
|
||||
if state.DPIChoice == 1 {
|
||||
opts.DPI = 600
|
||||
}
|
||||
if state.PrinterLang == 1 {
|
||||
opts.PrinterLang = printer.PrinterLangPS
|
||||
}
|
||||
if state.PrinterLang == 2 {
|
||||
opts.PrinterLang = printer.PrinterLangBrotherHBP
|
||||
}
|
||||
}
|
||||
switch state.SinglesigChoice {
|
||||
case 0:
|
||||
opts.Singlesig = printer.SinglesigLayoutSeedOnly
|
||||
case 2:
|
||||
opts.Singlesig = printer.SinglesigLayoutSeedWithDescriptorQR
|
||||
}
|
||||
|
||||
updatePrinterStatus()
|
||||
printPreviewLoop:
|
||||
for {
|
||||
e, ok := inp.Next(ctx, Button1, Button3)
|
||||
if !ok {
|
||||
@ -57,12 +224,24 @@ func (s *PrintSeedScreen) Print(ctx *Context, ops op.Ctx, th *Colors, mnemonic b
|
||||
switch e.Button {
|
||||
case Button1:
|
||||
if inp.Clicked(e.Button) {
|
||||
return false
|
||||
inSetup = true
|
||||
stepIdx = len(setupSteps) - 1
|
||||
break printPreviewLoop
|
||||
}
|
||||
case Button3:
|
||||
if inp.Clicked(e.Button) {
|
||||
printOpts := opts
|
||||
if opts.PrinterLang == printer.PrinterLangBrotherHBP {
|
||||
if ctx == nil || !ctx.HBPRuntimeReady {
|
||||
s.showError(ctx, ops, th, fmt.Errorf("Brother HBP runtime is not prepared.\nReturn to start screen and enable HBP before SD removal"))
|
||||
continue
|
||||
}
|
||||
if printOpts.DPI != 600 {
|
||||
printOpts.DPI = 600
|
||||
}
|
||||
}
|
||||
progress := &PrintProgressScreen{}
|
||||
success, err := progress.Show(ctx, ops, th, mnemonic, desc, keyIdx, selectedPaper)
|
||||
success, err := progress.Show(ctx, ops, th, mnemonic, desc, keyIdx, selectedPaper, printOpts)
|
||||
if err != nil && err.Error() != "print canceled" {
|
||||
s.showError(ctx, ops, th, err)
|
||||
}
|
||||
@ -77,6 +256,9 @@ func (s *PrintSeedScreen) Print(ctx *Context, ops op.Ctx, th *Colors, mnemonic b
|
||||
}
|
||||
}
|
||||
}
|
||||
if inSetup {
|
||||
continue
|
||||
}
|
||||
dims := ctx.Platform.DisplaySize()
|
||||
op.ColorOp(ops, th.Background)
|
||||
title := "Print Seed"
|
||||
@ -87,15 +269,58 @@ func (s *PrintSeedScreen) Print(ctx *Context, ops op.Ctx, th *Colors, mnemonic b
|
||||
status := "Printer: Not connected"
|
||||
if ctx.PrinterConnected {
|
||||
if ctx.PrinterModel != "" {
|
||||
status = fmt.Sprintf("Printer: Connected (%s)", ctx.PrinterModel)
|
||||
status = fmt.Sprintf("Printer: %s", ctx.PrinterModel)
|
||||
} else {
|
||||
status = "Printer: Connected"
|
||||
}
|
||||
}
|
||||
lead := fmt.Sprintf("%s\nPaper size: %s\n\nPress Print to continue.", status, selectedPaper)
|
||||
if desc != nil {
|
||||
lead = fmt.Sprintf("%s\nPaper size: %s\n\nPrinting %d wallet shares.\nPress Print to continue.", status, selectedPaper, len(desc.Keys))
|
||||
showCompactLine := isCompact2of3Eligible(desc)
|
||||
showSinglesigLine := isSinglesigDescriptor(desc)
|
||||
effectiveDPI := opts.DPI
|
||||
if opts.PrinterLang == printer.PrinterLangBrotherHBP {
|
||||
effectiveDPI = 600
|
||||
} else if ctx != nil && ctx.HBPRuntimeReady && opts.PrinterLang == printer.PrinterLangPS && opts.DPI > 600 {
|
||||
pages := estimateJobPages(desc, selectedPaper, opts)
|
||||
if pages > 1 {
|
||||
effectiveDPI = 600
|
||||
}
|
||||
}
|
||||
lead := fmt.Sprintf("%s\nPaper:%s @%d dpi\nInvert: %s, Mirror: %s\nEtch stats page: %s\nPrinter lang: %s", status, selectedPaper, effectiveDPI, onOff(opts.Invert), onOff(opts.Mirror), onOff(opts.EtchStats), printerLangLabel(opts.PrinterLang))
|
||||
if showCompactLine {
|
||||
lead += fmt.Sprintf("\nCompact 2/3: %s", onOff(opts.Compact2of3))
|
||||
}
|
||||
if showSinglesigLine {
|
||||
lead += fmt.Sprintf("\nSinglesig layout: %s", singlesigLayoutLabel(opts.Singlesig))
|
||||
}
|
||||
walletShares := 1
|
||||
if desc != nil {
|
||||
walletShares = len(desc.Keys)
|
||||
}
|
||||
maxSlotsPerPage := 4 // Fixed 2x2 layout on both A4 and Letter.
|
||||
slotsPerShare := 2
|
||||
if desc == nil {
|
||||
slotsPerShare = 1
|
||||
}
|
||||
if showCompactLine && opts.Compact2of3 {
|
||||
slotsPerShare = 1
|
||||
}
|
||||
if showSinglesigLine && opts.Singlesig != printer.SinglesigLayoutSeedWithDescriptorQR {
|
||||
slotsPerShare = 1
|
||||
}
|
||||
sharesPerPage := maxSlotsPerPage / slotsPerShare
|
||||
if sharesPerPage < 1 {
|
||||
sharesPerPage = 1
|
||||
}
|
||||
totalPages := (walletShares + sharesPerPage - 1) / sharesPerPage
|
||||
statsSuffix := ""
|
||||
if opts.EtchStats {
|
||||
statsSuffix = " (+1)"
|
||||
}
|
||||
jobLabel := "seed shares"
|
||||
if desc != nil {
|
||||
jobLabel = "wallet shares"
|
||||
}
|
||||
lead += fmt.Sprintf("\n\nPrinting %d %s\nTotal pages: %d%s", walletShares, jobLabel, totalPages, statsSuffix)
|
||||
layoutBodyLeftUnderTitle(ctx, ops, dims, th.Text, titleRect, lead)
|
||||
layoutNavigation(ctx, inp, ops, th, dims, []NavButton{
|
||||
{Button: Button1, Style: StyleSecondary, Icon: assets.IconBack},
|
||||
@ -105,8 +330,209 @@ func (s *PrintSeedScreen) Print(ctx *Context, ops op.Ctx, th *Colors, mnemonic b
|
||||
}
|
||||
}
|
||||
|
||||
func chooseSinglesigLayoutOption(ctx *Context, ops op.Ctx, th *Colors, initialChoice int) (int, bool) {
|
||||
inp := new(InputTracker)
|
||||
choice := initialChoice
|
||||
if choice < 0 || choice > 2 {
|
||||
choice = 1
|
||||
}
|
||||
labels := []string{
|
||||
"Seed Only",
|
||||
"Seed + Info",
|
||||
"Seed + Descr QR",
|
||||
}
|
||||
details := []string{
|
||||
"1-sided, 2 copies",
|
||||
"1-sided, 2 copies",
|
||||
"2-sided, 2 copies",
|
||||
}
|
||||
|
||||
for {
|
||||
for {
|
||||
e, ok := inp.Next(ctx, Button1, Button3, Center, Up, Down, CCW, CW)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
switch e.Button {
|
||||
case Button1:
|
||||
if inp.Clicked(e.Button) {
|
||||
return 0, false
|
||||
}
|
||||
case Button3, Center:
|
||||
if inp.Clicked(e.Button) {
|
||||
return choice, true
|
||||
}
|
||||
case Up, CCW:
|
||||
if e.Pressed && choice > 0 {
|
||||
choice--
|
||||
}
|
||||
case Down, CW:
|
||||
if e.Pressed && choice < len(labels)-1 {
|
||||
choice++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dims := ctx.Platform.DisplaySize()
|
||||
op.ColorOp(ops, th.Background)
|
||||
titleRect := layoutTitle(ctx, ops, dims.X, th.Text, "Singlesig layout")
|
||||
infoRect := layoutBodyLeftUnderTitle(ctx, ops, dims, th.Text, titleRect, "Choose print layout:")
|
||||
|
||||
choicesMinY := infoRect.Max.Y + 4
|
||||
choicesMaxY := dims.Y - leadingSize - 30
|
||||
if choicesMaxY < choicesMinY+10 {
|
||||
choicesMaxY = choicesMinY + 10
|
||||
}
|
||||
content := layout.Rectangle(image.Rect(16, choicesMinY, dims.X-16, choicesMaxY))
|
||||
|
||||
children := make([]struct {
|
||||
Size image.Point
|
||||
W op.CallOp
|
||||
}, len(labels))
|
||||
maxW := 0
|
||||
for i, c := range labels {
|
||||
style := ctx.Styles.button
|
||||
col := th.Text
|
||||
if i == choice {
|
||||
col = th.Background
|
||||
}
|
||||
sz := widget.Labelf(ops.Begin(), style, col, "%s", c)
|
||||
ch := ops.End()
|
||||
children[i].Size = sz
|
||||
children[i].W = ch
|
||||
if sz.X > maxW {
|
||||
maxW = sz.X
|
||||
}
|
||||
}
|
||||
inner := ops.Begin()
|
||||
h := 0
|
||||
for i, c := range children {
|
||||
xoff := (maxW - c.Size.X) / 2
|
||||
pos := image.Pt(xoff, h)
|
||||
txt := c.W
|
||||
if i == choice {
|
||||
bg := image.Rectangle{Max: c.Size}
|
||||
bg.Min.X -= xoff
|
||||
bg.Max.X += xoff
|
||||
assets.ButtonFocused.Add(inner.Begin(), bg, true)
|
||||
op.ColorOp(inner, th.Text)
|
||||
txt.Add(inner)
|
||||
txt = inner.End()
|
||||
}
|
||||
op.Position(inner, txt, pos)
|
||||
h += c.Size.Y
|
||||
}
|
||||
op.Position(ops, ops.End(), content.Center(image.Pt(maxW, h)))
|
||||
descRect := image.Rectangle{
|
||||
Min: image.Pt(10, choicesMaxY+4),
|
||||
Max: image.Pt(dims.X-10, dims.Y-leadingSize-6),
|
||||
}
|
||||
if descRect.Dy() > 0 {
|
||||
style := ctx.Styles.body
|
||||
widget.Labelwf(ops.Begin(), style, descRect.Dx(), th.Text, "%s", details[choice])
|
||||
op.Position(ops, ops.End(), descRect.Min)
|
||||
}
|
||||
layoutNavigation(ctx, inp, ops, th, dims, []NavButton{
|
||||
{Button: Button1, Style: StyleSecondary, Icon: assets.IconBack},
|
||||
{Button: Button3, Style: StylePrimary, Icon: assets.IconCheckmark},
|
||||
}...)
|
||||
ctx.Frame()
|
||||
}
|
||||
}
|
||||
|
||||
func choosePrinterLanguageOption(ctx *Context, ops op.Ctx, th *Colors, initialChoice int, hbpReady bool) (int, bool) {
|
||||
choices := []string{"PCL", "PS"}
|
||||
if hbpReady {
|
||||
choices = append(choices, "Brother HBP")
|
||||
}
|
||||
choice := initialChoice
|
||||
if choice < 0 || choice >= len(choices) {
|
||||
choice = 0
|
||||
}
|
||||
cs := &ChoiceScreen{
|
||||
Title: "Printer Language",
|
||||
Lead: "Choose language",
|
||||
Choices: choices,
|
||||
choice: choice,
|
||||
}
|
||||
return cs.Choose(ctx, ops, th)
|
||||
}
|
||||
|
||||
func isCompact2of3Eligible(desc *urtypes.OutputDescriptor) bool {
|
||||
if desc == nil {
|
||||
return false
|
||||
}
|
||||
return desc.Type == urtypes.SortedMulti && desc.Threshold == 2 && len(desc.Keys) == 3
|
||||
}
|
||||
|
||||
func isSinglesigDescriptor(desc *urtypes.OutputDescriptor) bool {
|
||||
if desc == nil {
|
||||
return false
|
||||
}
|
||||
return desc.Type == urtypes.Singlesig && len(desc.Keys) == 1
|
||||
}
|
||||
|
||||
func singlesigLayoutLabel(mode printer.SinglesigLayoutMode) string {
|
||||
switch mode {
|
||||
case printer.SinglesigLayoutSeedOnly:
|
||||
return "Seed Only"
|
||||
case printer.SinglesigLayoutSeedWithDescriptorQR:
|
||||
return "Seed + Descr QR"
|
||||
default:
|
||||
return "Seed + Info"
|
||||
}
|
||||
}
|
||||
|
||||
func onOff(v bool) string {
|
||||
if v {
|
||||
return "On"
|
||||
}
|
||||
return "Off"
|
||||
}
|
||||
|
||||
func printerLangLabel(lang printer.PrinterLanguage) string {
|
||||
if lang == printer.PrinterLangPS {
|
||||
return "PS"
|
||||
}
|
||||
if lang == printer.PrinterLangBrotherHBP {
|
||||
return "HBP"
|
||||
}
|
||||
return "PCL"
|
||||
}
|
||||
|
||||
func estimateJobPages(desc *urtypes.OutputDescriptor, paper printer.PaperSize, opts printOptions) int {
|
||||
walletShares := 1
|
||||
if desc != nil {
|
||||
walletShares = len(desc.Keys)
|
||||
}
|
||||
maxSlotsPerPage := 4 // Fixed 2x2 layout on both A4 and Letter.
|
||||
slotsPerShare := 2
|
||||
if desc == nil {
|
||||
slotsPerShare = 1
|
||||
}
|
||||
if isCompact2of3Eligible(desc) && opts.Compact2of3 {
|
||||
slotsPerShare = 1
|
||||
}
|
||||
if isSinglesigDescriptor(desc) && opts.Singlesig != printer.SinglesigLayoutSeedWithDescriptorQR {
|
||||
slotsPerShare = 1
|
||||
}
|
||||
sharesPerPage := maxSlotsPerPage / slotsPerShare
|
||||
if sharesPerPage < 1 {
|
||||
sharesPerPage = 1
|
||||
}
|
||||
totalPages := (walletShares + sharesPerPage - 1) / sharesPerPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
if opts.EtchStats {
|
||||
totalPages++
|
||||
}
|
||||
return totalPages
|
||||
}
|
||||
|
||||
func (s *PrintSeedScreen) showError(ctx *Context, ops op.Ctx, th *Colors, err error) {
|
||||
logutil.DebugLog("showError called with error: %v", err)
|
||||
triggerErrorLogExport(ctx, err)
|
||||
errScr := NewErrorScreen(err)
|
||||
for {
|
||||
dims := ctx.Platform.DisplaySize()
|
||||
@ -162,112 +588,408 @@ type PrintProgressScreen struct {
|
||||
inp InputTracker
|
||||
}
|
||||
|
||||
func (s *PrintProgressScreen) Show(ctx *Context, ops op.Ctx, th *Colors, mnemonic bip39.Mnemonic, desc *urtypes.OutputDescriptor, keyIdx int, paperFormat printer.PaperSize) (bool, error) {
|
||||
type HBPRuntimePrepareScreen struct {
|
||||
inp InputTracker
|
||||
}
|
||||
|
||||
var lastHBPPrepareDuration = 32 * time.Second
|
||||
|
||||
func (s *HBPRuntimePrepareScreen) Show(ctx *Context, ops op.Ctx, th *Colors) error {
|
||||
if ctx == nil {
|
||||
return fmt.Errorf("missing UI context")
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- ctx.Platform.PrepareHBPForSDRemoval()
|
||||
}()
|
||||
|
||||
var (
|
||||
finished bool
|
||||
prepErr error
|
||||
)
|
||||
startedAt := ctx.Platform.Now()
|
||||
for {
|
||||
if !finished {
|
||||
select {
|
||||
case prepErr = <-done:
|
||||
finished = true
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
e, ok := s.inp.Next(ctx, Button3)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if finished && prepErr == nil && s.inp.Clicked(e.Button) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
dims := ctx.Platform.DisplaySize()
|
||||
op.ColorOp(ops, th.Background)
|
||||
titleRect := layoutTitle(ctx, ops, dims.X, th.Text, "Preparing HBP")
|
||||
status := "Preparing Brother HBP runtime..."
|
||||
|
||||
if !finished {
|
||||
barW := dims.X - 48
|
||||
if barW < 120 {
|
||||
barW = dims.X - 24
|
||||
}
|
||||
barH := 10
|
||||
barX := (dims.X - barW) / 2
|
||||
barY := dims.Y/2 - barH/2
|
||||
barRect := image.Rect(barX, barY, barX+barW, barY+barH)
|
||||
|
||||
track := color.NRGBA{R: th.Text.R, G: th.Text.G, B: th.Text.B, A: 70}
|
||||
op.ClipOp(barRect).Add(ops.Begin())
|
||||
op.ColorOp(ops, track)
|
||||
barBg := ops.End()
|
||||
barBg.Add(ops)
|
||||
|
||||
eta := lastHBPPrepareDuration
|
||||
if eta < 5*time.Second {
|
||||
eta = 5 * time.Second
|
||||
}
|
||||
elapsed := ctx.Platform.Now().Sub(startedAt)
|
||||
progress := float32(elapsed) / float32(eta)
|
||||
if progress > 0.95 {
|
||||
progress = 0.95
|
||||
}
|
||||
if progress < 0 {
|
||||
progress = 0
|
||||
}
|
||||
fillW := int(float32(barW) * progress)
|
||||
if fillW < 4 {
|
||||
fillW = 4
|
||||
}
|
||||
fillRect := image.Rect(barX, barY, barX+fillW, barY+barH)
|
||||
op.ClipOp(fillRect).Add(ops.Begin())
|
||||
op.ColorOp(ops, th.Text)
|
||||
barFill := ops.End()
|
||||
barFill.Add(ops)
|
||||
}
|
||||
|
||||
if finished && prepErr == nil {
|
||||
layoutBodyLeftUnderTitle(ctx, ops, dims, th.Text, titleRect, "Brother HBP is ready.\nSD card can now be removed safely.")
|
||||
} else {
|
||||
layoutBodyLeftUnderTitle(ctx, ops, dims, th.Text, titleRect, status)
|
||||
}
|
||||
|
||||
if finished && prepErr != nil {
|
||||
return prepErr
|
||||
}
|
||||
if finished && prepErr == nil {
|
||||
duration := ctx.Platform.Now().Sub(startedAt)
|
||||
if duration > 0 {
|
||||
lastHBPPrepareDuration = duration
|
||||
}
|
||||
layoutNavigation(ctx, &s.inp, ops, th, dims, []NavButton{
|
||||
{Button: Button3, Style: StylePrimary, Icon: assets.IconCheckmark},
|
||||
}...)
|
||||
}
|
||||
if !finished {
|
||||
ctx.WakeupAt(ctx.Platform.Now().Add(200 * time.Millisecond))
|
||||
}
|
||||
ctx.Frame()
|
||||
}
|
||||
}
|
||||
|
||||
type printProgressUpdate struct {
|
||||
stage printer.PrintStage
|
||||
current int64
|
||||
total int64
|
||||
}
|
||||
|
||||
type printProgressDisplay struct {
|
||||
buildFrac float32
|
||||
sendFrac float32
|
||||
buildLabel string
|
||||
sendLabelTitle string
|
||||
sendLabelDetail string
|
||||
}
|
||||
|
||||
var printProgressStageOrder = [...]printer.PrintStage{
|
||||
printer.StagePrepare,
|
||||
printer.StageCompose,
|
||||
printer.StageSend,
|
||||
}
|
||||
|
||||
const (
|
||||
sendProgressByteThreshold int64 = 8192
|
||||
sendProgressCoalesceMin int64 = 64 * 1024
|
||||
sendProgressCoalesceDiv int64 = 120
|
||||
)
|
||||
|
||||
func clampPrintProgressCount(cur, total int64) int64 {
|
||||
if total <= 0 {
|
||||
return 0
|
||||
}
|
||||
if cur < 0 {
|
||||
return 0
|
||||
}
|
||||
if cur > total {
|
||||
return total
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
func formatPrintProgressBytes(v int64) string {
|
||||
if v < 1024 {
|
||||
return fmt.Sprintf("%d B", v)
|
||||
}
|
||||
if v < 1024*1024 {
|
||||
return fmt.Sprintf("%.1f KB", float64(v)/1024.0)
|
||||
}
|
||||
return fmt.Sprintf("%.1f MB", float64(v)/(1024.0*1024.0))
|
||||
}
|
||||
|
||||
func shouldDropPrintSendUpdate(current, total int64, lastCurrent, lastTotal *int64) bool {
|
||||
if total < sendProgressByteThreshold {
|
||||
return false
|
||||
}
|
||||
if total != *lastTotal {
|
||||
*lastTotal = total
|
||||
*lastCurrent = -1
|
||||
}
|
||||
step := total / sendProgressCoalesceDiv
|
||||
if step < sendProgressCoalesceMin {
|
||||
step = sendProgressCoalesceMin
|
||||
}
|
||||
if current < total && *lastCurrent >= 0 && (current-*lastCurrent) < step {
|
||||
return true
|
||||
}
|
||||
*lastCurrent = current
|
||||
return false
|
||||
}
|
||||
|
||||
func drawPrintProgressBar(ops op.Ctx, rect image.Rectangle, frac float32, text color.NRGBA) {
|
||||
if frac < 0 {
|
||||
frac = 0
|
||||
}
|
||||
if frac > 1 {
|
||||
frac = 1
|
||||
}
|
||||
track := color.NRGBA{R: text.R, G: text.G, B: text.B, A: 70}
|
||||
op.ClipOp(rect).Add(ops.Begin())
|
||||
op.ColorOp(ops, track)
|
||||
bg := ops.End()
|
||||
bg.Add(ops)
|
||||
fillW := int(float32(rect.Dx()) * frac)
|
||||
if fillW < 0 {
|
||||
fillW = 0
|
||||
}
|
||||
if fillW > rect.Dx() {
|
||||
fillW = rect.Dx()
|
||||
}
|
||||
if fillW == 0 && frac > 0 {
|
||||
fillW = 1
|
||||
}
|
||||
fillRect := image.Rect(rect.Min.X, rect.Min.Y, rect.Min.X+fillW, rect.Max.Y)
|
||||
op.ClipOp(fillRect).Add(ops.Begin())
|
||||
op.ColorOp(ops, text)
|
||||
fg := ops.End()
|
||||
fg.Add(ops)
|
||||
}
|
||||
|
||||
func drainPrintProgressUpdates(progressCh <-chan printProgressUpdate, stageState map[printer.PrintStage]printProgressUpdate, lastBuildStage *printer.PrintStage) {
|
||||
for {
|
||||
select {
|
||||
case p := <-progressCh:
|
||||
if prev, ok := stageState[p.stage]; ok {
|
||||
// Keep stage counters monotonic to avoid UI regressions under bursty updates.
|
||||
if p.total < prev.total {
|
||||
p.total = prev.total
|
||||
}
|
||||
if p.current < prev.current {
|
||||
p.current = prev.current
|
||||
}
|
||||
}
|
||||
stageState[p.stage] = p
|
||||
if p.stage == printer.StagePrepare || p.stage == printer.StageCompose {
|
||||
*lastBuildStage = p.stage
|
||||
}
|
||||
// Mark earlier stages complete when we reach a later stage.
|
||||
for _, st := range printProgressStageOrder {
|
||||
if st == p.stage {
|
||||
break
|
||||
}
|
||||
if _, ok := stageState[st]; !ok {
|
||||
stageState[st] = printProgressUpdate{stage: st, current: 1, total: 1}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildPrintProgressDisplay(stageState map[printer.PrintStage]printProgressUpdate, printOpts printOptions, lastBuildStage *printer.PrintStage, finished bool) printProgressDisplay {
|
||||
prepareUpd := stageState[printer.StagePrepare]
|
||||
composeUpd := stageState[printer.StageCompose]
|
||||
sendUpd := stageState[printer.StageSend]
|
||||
|
||||
buildCurrent := int64(0)
|
||||
buildTotal := int64(0)
|
||||
if prepareUpd.total > 0 {
|
||||
buildCurrent += clampPrintProgressCount(prepareUpd.current, prepareUpd.total)
|
||||
buildTotal += prepareUpd.total
|
||||
}
|
||||
if composeUpd.total > 0 {
|
||||
buildCurrent += clampPrintProgressCount(composeUpd.current, composeUpd.total)
|
||||
buildTotal += composeUpd.total
|
||||
}
|
||||
buildFrac := float32(0)
|
||||
if buildTotal > 0 {
|
||||
buildFrac = float32(buildCurrent) / float32(buildTotal)
|
||||
}
|
||||
if buildFrac > 1 {
|
||||
buildFrac = 1
|
||||
}
|
||||
|
||||
sendFrac := float32(0)
|
||||
if sendUpd.total > 0 {
|
||||
sendFrac = float32(clampPrintProgressCount(sendUpd.current, sendUpd.total)) / float32(sendUpd.total)
|
||||
if sendFrac > 1 {
|
||||
sendFrac = 1
|
||||
}
|
||||
}
|
||||
if finished {
|
||||
buildFrac = 1
|
||||
sendFrac = 1
|
||||
}
|
||||
|
||||
buildLabel := "Preparing print job"
|
||||
switch *lastBuildStage {
|
||||
case printer.StagePrepare:
|
||||
if prepareUpd.total > 0 {
|
||||
buildLabel = fmt.Sprintf("Creating plates %d/%d", clampPrintProgressCount(prepareUpd.current, prepareUpd.total), prepareUpd.total)
|
||||
}
|
||||
if prepareUpd.total > 0 && clampPrintProgressCount(prepareUpd.current, prepareUpd.total) >= prepareUpd.total && composeUpd.total > 0 {
|
||||
*lastBuildStage = printer.StageCompose
|
||||
}
|
||||
case printer.StageCompose:
|
||||
if composeUpd.total > 0 {
|
||||
composeCur := clampPrintProgressCount(composeUpd.current, composeUpd.total)
|
||||
if printOpts.EtchStats && composeUpd.total > 1 && composeCur >= composeUpd.total {
|
||||
buildLabel = "Created stats page"
|
||||
} else {
|
||||
buildLabel = fmt.Sprintf("Created pages %d/%d", composeCur, composeUpd.total)
|
||||
}
|
||||
}
|
||||
}
|
||||
if finished {
|
||||
buildLabel = "Creation complete"
|
||||
}
|
||||
|
||||
sendLabelTitle := "Sending to printer"
|
||||
sendLabelDetail := "Waiting to send..."
|
||||
if sendUpd.total > 0 {
|
||||
sendCur := clampPrintProgressCount(sendUpd.current, sendUpd.total)
|
||||
if sendUpd.total >= sendProgressByteThreshold {
|
||||
sendLabelDetail = fmt.Sprintf("%s / %s", formatPrintProgressBytes(sendCur), formatPrintProgressBytes(sendUpd.total))
|
||||
} else {
|
||||
sendLabelDetail = fmt.Sprintf("%d / %d", sendCur, sendUpd.total)
|
||||
}
|
||||
} else if finished {
|
||||
sendLabelDetail = "Complete"
|
||||
}
|
||||
|
||||
return printProgressDisplay{
|
||||
buildFrac: buildFrac,
|
||||
sendFrac: sendFrac,
|
||||
buildLabel: buildLabel,
|
||||
sendLabelTitle: sendLabelTitle,
|
||||
sendLabelDetail: sendLabelDetail,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PrintProgressScreen) Show(ctx *Context, ops op.Ctx, th *Colors, mnemonic bip39.Mnemonic, desc *urtypes.OutputDescriptor, keyIdx int, paperFormat printer.PaperSize, printOpts printOptions) (bool, error) {
|
||||
var (
|
||||
printErr error
|
||||
done = make(chan struct{})
|
||||
)
|
||||
finished := false
|
||||
var finishedAt time.Time
|
||||
type progressUpdate struct {
|
||||
stage printer.PrintStage
|
||||
current int64
|
||||
total int64
|
||||
}
|
||||
progressCh := make(chan progressUpdate, 8)
|
||||
stageState := make(map[printer.PrintStage]progressUpdate)
|
||||
progressVal := float32(0)
|
||||
lastStage := printer.StagePrepare
|
||||
progressCh := make(chan printProgressUpdate, 64)
|
||||
stageState := make(map[printer.PrintStage]printProgressUpdate)
|
||||
lastBuildStage := printer.StagePrepare
|
||||
if ctx != nil {
|
||||
lastSendCurrent := int64(-1)
|
||||
lastSendTotal := int64(0)
|
||||
ctx.PrintProgress = func(stage printer.PrintStage, current, total int64) {
|
||||
if total <= 0 {
|
||||
return
|
||||
}
|
||||
if stage == printer.StageSend && shouldDropPrintSendUpdate(current, total, &lastSendCurrent, &lastSendTotal) {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case progressCh <- progressUpdate{stage: stage, current: current, total: total}:
|
||||
case progressCh <- printProgressUpdate{stage: stage, current: current, total: total}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
defer func() { ctx.PrintProgress = nil }()
|
||||
}
|
||||
go func() {
|
||||
printErr = ctx.Platform.CreatePlates(ctx, mnemonic, desc, keyIdx)
|
||||
opts := printer.RasterOptions{
|
||||
DPI: float64(printOpts.DPI),
|
||||
Mirror: printOpts.Mirror,
|
||||
Invert: printOpts.Invert,
|
||||
PrinterLang: printOpts.PrinterLang,
|
||||
SinglesigLayout: printOpts.Singlesig,
|
||||
EtchStatsPage: printOpts.EtchStats,
|
||||
}
|
||||
printer.SetCompactDescriptor2of3Enabled(printOpts.Compact2of3)
|
||||
defer printer.SetCompactDescriptor2of3Enabled(false)
|
||||
printErr = ctx.Platform.CreatePlates(ctx, mnemonic, desc, keyIdx, paperFormat, opts)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
for {
|
||||
dims := ctx.Platform.DisplaySize()
|
||||
op.ColorOp(ops, th.Background)
|
||||
layoutTitle(ctx, ops, dims.X, th.Text, "Printing")
|
||||
titleRect := layoutTitle(ctx, ops, dims.X, th.Text, "Printing")
|
||||
|
||||
if !finished {
|
||||
select {
|
||||
case <-done:
|
||||
finished = true
|
||||
finishedAt = ctx.Platform.Now()
|
||||
if progressVal < 1 {
|
||||
progressVal = 1
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case p := <-progressCh:
|
||||
stageState[p.stage] = p
|
||||
lastStage = p.stage
|
||||
// Mark earlier stages complete if we reached a later stage.
|
||||
ordered := []printer.PrintStage{printer.StagePrepare, printer.StageCompose, printer.StageSend}
|
||||
for _, st := range ordered {
|
||||
if st == p.stage {
|
||||
break
|
||||
}
|
||||
if _, ok := stageState[st]; !ok {
|
||||
stageState[st] = progressUpdate{stage: st, current: 1, total: 1}
|
||||
}
|
||||
}
|
||||
// Compute overall progress as the average of stage fractions.
|
||||
sum := float32(0)
|
||||
for _, st := range ordered {
|
||||
if upd, ok := stageState[st]; ok && upd.total > 0 {
|
||||
f := float32(upd.current) / float32(upd.total)
|
||||
if f < 0 {
|
||||
f = 0
|
||||
}
|
||||
if f > 1 {
|
||||
f = 1
|
||||
}
|
||||
sum += f
|
||||
}
|
||||
}
|
||||
if len(ordered) > 0 {
|
||||
progressVal = sum / float32(len(ordered))
|
||||
}
|
||||
default:
|
||||
}
|
||||
|
||||
drainPrintProgressUpdates(progressCh, stageState, &lastBuildStage)
|
||||
ctx.WakeupAt(ctx.Platform.Now().Add(100 * time.Millisecond))
|
||||
display := buildPrintProgressDisplay(stageState, printOpts, &lastBuildStage, finished)
|
||||
|
||||
content := layout.Rectangle{Max: dims}.Shrink(leadingSize, 0, leadingSize, 0)
|
||||
op.Offset(ops, content.Center(assets.ProgressCircle.Bounds().Size()))
|
||||
(&ProgressImage{Progress: progressVal, Src: assets.ProgressCircle}).Add(ops)
|
||||
op.ColorOp(ops, th.Text)
|
||||
percentLabel := fmt.Sprintf("%d%%", int(progressVal*100+0.5))
|
||||
pctSz := widget.Labelwf(ops.Begin(), ctx.Styles.lead, assets.ProgressCircle.Bounds().Dx(), th.Text, "%s", percentLabel)
|
||||
op.Position(ops, ops.End(), content.Center(pctSz))
|
||||
label := "Preparing..."
|
||||
if upd, ok := stageState[lastStage]; ok {
|
||||
switch lastStage {
|
||||
case printer.StagePrepare:
|
||||
label = fmt.Sprintf("Rendering plates %d/%d", upd.current, upd.total)
|
||||
case printer.StageCompose:
|
||||
label = "Composing pages..."
|
||||
case printer.StageSend:
|
||||
label = "Sending to printer..."
|
||||
}
|
||||
left := 16
|
||||
right := dims.X - 16
|
||||
if right-left < 120 {
|
||||
left = 8
|
||||
right = dims.X - 8
|
||||
}
|
||||
sz := widget.Labelwf(ops.Begin(), ctx.Styles.lead, dims.X-16, th.Text, "%s", label)
|
||||
op.Position(ops, ops.End(), content.Center(sz).Add(image.Pt(0, assets.ProgressCircle.Bounds().Dy()/2+17)))
|
||||
barH := 10
|
||||
y := titleRect.Max.Y + 16
|
||||
|
||||
label1 := widget.Labelwf(ops.Begin(), ctx.Styles.lead, right-left, th.Text, "%s", display.buildLabel)
|
||||
op.Position(ops, ops.End(), image.Pt(left, y))
|
||||
y += label1.Y + 6
|
||||
bar1 := image.Rect(left, y, right, y+barH)
|
||||
drawPrintProgressBar(ops, bar1, display.buildFrac, th.Text)
|
||||
y = bar1.Max.Y + 16
|
||||
|
||||
label2a := widget.Labelwf(ops.Begin(), ctx.Styles.lead, right-left, th.Text, "%s", display.sendLabelTitle)
|
||||
op.Position(ops, ops.End(), image.Pt(left, y))
|
||||
y += label2a.Y + 2
|
||||
label2b := widget.Labelwf(ops.Begin(), ctx.Styles.lead, right-left, th.Text, "%s", display.sendLabelDetail)
|
||||
op.Position(ops, ops.End(), image.Pt(left, y))
|
||||
y += label2b.Y + 6
|
||||
bar2 := image.Rect(left, y, right, y+barH)
|
||||
drawPrintProgressBar(ops, bar2, display.sendFrac, th.Text)
|
||||
|
||||
layoutNavigation(ctx, &s.inp, ops, th, dims)
|
||||
ctx.Frame()
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
@ -14,6 +15,7 @@ import (
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/descriptor/legacy"
|
||||
"seedetcher.com/descriptor/shard"
|
||||
"seedetcher.com/descriptor/urxor2of3"
|
||||
"seedetcher.com/gui/assets"
|
||||
"seedetcher.com/gui/op"
|
||||
"seedetcher.com/gui/text"
|
||||
@ -23,9 +25,13 @@ import (
|
||||
|
||||
// SDCardGateScreen enforces SD-card removal before entering sensitive flows.
|
||||
type SDCardGateScreen struct {
|
||||
Theme *Colors
|
||||
Next Screen
|
||||
warn *ConfirmWarningScreen
|
||||
Theme *Colors
|
||||
Next Screen
|
||||
warn *ConfirmWarningScreen
|
||||
prepStarted bool
|
||||
prepDone bool
|
||||
prepErr error
|
||||
prepCh chan error
|
||||
}
|
||||
|
||||
func (s *SDCardGateScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
@ -39,6 +45,41 @@ func (s *SDCardGateScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
if th == nil {
|
||||
th = &singleTheme
|
||||
}
|
||||
// In PCL/PS-only mode we still mount SD-backed runtime at boot in host images.
|
||||
// Detach those mounts before asking the user to physically pull the card.
|
||||
if ctx != nil && !ctx.HBPRuntimeReady && !ctx.SDRemovalPrepared {
|
||||
if !s.prepStarted {
|
||||
s.prepStarted = true
|
||||
s.prepCh = make(chan error, 1)
|
||||
go func() {
|
||||
s.prepCh <- ctx.Platform.PrepareSDForRemoval()
|
||||
}()
|
||||
}
|
||||
if !s.prepDone {
|
||||
select {
|
||||
case err := <-s.prepCh:
|
||||
s.prepDone = true
|
||||
s.prepErr = err
|
||||
default:
|
||||
}
|
||||
}
|
||||
if s.prepDone && s.prepErr != nil {
|
||||
showError(ctx, ops, th, fmt.Errorf("SD removal prep failed: %v", s.prepErr))
|
||||
return &MainMenuScreen{}
|
||||
}
|
||||
if s.prepDone && s.prepErr == nil {
|
||||
ctx.SDRemovalPrepared = true
|
||||
}
|
||||
if !s.prepDone {
|
||||
dims := ctx.Platform.DisplaySize()
|
||||
op.ColorOp(ops, th.Background)
|
||||
titleRect := layoutTitle(ctx, ops, dims.X, th.Text, "Preparing SD removal")
|
||||
layoutBodyLeftUnderTitle(ctx, ops, dims, th.Text, titleRect, "Detaching SD-backed runtime.\nPlease wait...")
|
||||
ctx.WakeupAt(ctx.Platform.Now().Add(200 * time.Millisecond))
|
||||
ctx.Frame()
|
||||
return s
|
||||
}
|
||||
}
|
||||
if s.warn == nil {
|
||||
s.warn = &ConfirmWarningScreen{
|
||||
Title: "Remove SD card",
|
||||
@ -91,6 +132,8 @@ type RecoverDescriptorFlowScreen struct {
|
||||
recoveredURPayload []byte
|
||||
recoveredQR image.Image
|
||||
decodedShares map[uint8]shard.Share
|
||||
decodedURShares map[string]struct{}
|
||||
decodedURSeqLen int
|
||||
modeChoice int
|
||||
displayMode recoverDisplayMode
|
||||
viewQR image.Image
|
||||
@ -112,6 +155,8 @@ func (s *RecoverDescriptorFlowScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
s.recoveredURPayload = nil
|
||||
s.recoveredQR = nil
|
||||
s.decodedShares = make(map[uint8]shard.Share)
|
||||
s.decodedURShares = make(map[string]struct{})
|
||||
s.decodedURSeqLen = 0
|
||||
}
|
||||
}()
|
||||
|
||||
@ -122,6 +167,9 @@ func (s *RecoverDescriptorFlowScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
if s.decodedShares == nil {
|
||||
s.decodedShares = make(map[uint8]shard.Share)
|
||||
}
|
||||
if s.decodedURShares == nil {
|
||||
s.decodedURShares = make(map[string]struct{})
|
||||
}
|
||||
|
||||
switch s.stage {
|
||||
case recoverStageScan:
|
||||
@ -148,8 +196,9 @@ func (s *RecoverDescriptorFlowScreen) scanStep(ctx *Context, ops op.Ctx, th *Col
|
||||
}()
|
||||
|
||||
res, ok := (&ScanScreen{
|
||||
Title: "Recover Descriptor",
|
||||
Lead: s.scanLead(),
|
||||
Title: "Recover Descriptor",
|
||||
Lead: s.scanLead(),
|
||||
RawURXOR2of3Only: true,
|
||||
}).Scan(ctx, ops)
|
||||
if !ok {
|
||||
if s.returnScreen != nil {
|
||||
@ -160,12 +209,70 @@ func (s *RecoverDescriptorFlowScreen) scanStep(ctx *Context, ops op.Ctx, th *Col
|
||||
|
||||
switch v := res.(type) {
|
||||
case urtypes.OutputDescriptor:
|
||||
showError(ctx, ops, th, fmt.Errorf("Not part of a shamir split descriptor!"))
|
||||
showError(ctx, ops, th, fmt.Errorf("Not part of a descriptor share set!"))
|
||||
return s
|
||||
case []byte:
|
||||
raw := strings.TrimSpace(string(v))
|
||||
up := strings.ToUpper(raw)
|
||||
if typ, _, seqLen, ok := urxor2of3.ParseShare(raw); ok && typ == "crypto-output" && seqLen >= urxor2of3.MinShares {
|
||||
if len(s.decodedShares) > 0 {
|
||||
showError(ctx, ops, th, fmt.Errorf("share set mismatch: mixed descriptor share formats"))
|
||||
return s
|
||||
}
|
||||
if s.decodedURSeqLen == 0 {
|
||||
s.decodedURSeqLen = seqLen
|
||||
} else if s.decodedURSeqLen != seqLen {
|
||||
showError(ctx, ops, th, fmt.Errorf("share set mismatch: mixed descriptor share sets"))
|
||||
return s
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(raw))
|
||||
if _, exists := s.decodedURShares[key]; exists {
|
||||
showError(ctx, ops, th, fmt.Errorf("share already scanned"))
|
||||
return s
|
||||
}
|
||||
s.decodedURShares[key] = struct{}{}
|
||||
parts := make([]string, 0, len(s.decodedURShares))
|
||||
for p := range s.decodedURShares {
|
||||
parts = append(parts, p)
|
||||
}
|
||||
payload, err := urxor2of3.Combine(parts)
|
||||
if err != nil {
|
||||
if errors.Is(err, urxor2of3.ErrInsufficientShares) {
|
||||
ctx.addToast(fmt.Sprintf("Captured share (%d/%d)", len(s.decodedURShares), s.decodedURSeqLen), 1700)
|
||||
return s
|
||||
}
|
||||
delete(s.decodedURShares, key)
|
||||
showError(ctx, ops, th, fmt.Errorf("combine shares failed: %v", err))
|
||||
return s
|
||||
}
|
||||
rawPayload := append([]byte(nil), payload...)
|
||||
recoveredUR, err := safeEncodeURPayload(rawPayload)
|
||||
if err != nil {
|
||||
showError(ctx, ops, th, fmt.Errorf("failed to encode recovered descriptor"))
|
||||
logutil.DebugLog("recover UR encode failed: %v", err)
|
||||
return s
|
||||
}
|
||||
s.recoveredUR = recoveredUR
|
||||
s.recoveredText = buildDescriptorText(rawPayload)
|
||||
s.recoveredPayloadRaw = rawPayload
|
||||
s.recoveredURPayload = rawPayload
|
||||
dims := ctx.Platform.DisplaySize()
|
||||
qrSize := dims.X
|
||||
if dims.Y < qrSize {
|
||||
qrSize = dims.Y
|
||||
}
|
||||
qrSize -= 8
|
||||
s.recoveredQR = renderQRImage(s.recoveredUR, qrSize)
|
||||
s.returnScreen = &ActionChoiceScreen{Theme: th}
|
||||
s.stage = recoverStageExport
|
||||
ctx.addToast("Descriptor recovered", 1200)
|
||||
return s
|
||||
}
|
||||
if strings.HasPrefix(up, shard.Prefix) {
|
||||
if len(s.decodedURShares) > 0 {
|
||||
showError(ctx, ops, th, fmt.Errorf("share set mismatch: mixed descriptor share formats"))
|
||||
return s
|
||||
}
|
||||
sh, err := shard.Decode(up)
|
||||
if err != nil {
|
||||
showError(ctx, ops, th, fmt.Errorf("invalid share QR: %v", err))
|
||||
@ -222,13 +329,13 @@ func (s *RecoverDescriptorFlowScreen) scanStep(ctx *Context, ops op.Ctx, th *Col
|
||||
ctx.addToast("Descriptor recovered", 1200)
|
||||
return s
|
||||
}
|
||||
showError(ctx, ops, th, fmt.Errorf("Not part of a shamir split descriptor!"))
|
||||
showError(ctx, ops, th, fmt.Errorf("Not part of a descriptor share set!"))
|
||||
return s
|
||||
case string:
|
||||
showError(ctx, ops, th, fmt.Errorf("Not part of a shamir split descriptor!"))
|
||||
showError(ctx, ops, th, fmt.Errorf("Not part of a descriptor share set!"))
|
||||
return s
|
||||
default:
|
||||
showError(ctx, ops, th, fmt.Errorf("Not part of a shamir split descriptor!"))
|
||||
showError(ctx, ops, th, fmt.Errorf("Not part of a descriptor share set!"))
|
||||
return s
|
||||
}
|
||||
}
|
||||
@ -257,6 +364,8 @@ func (s *RecoverDescriptorFlowScreen) exportStep(ctx *Context, ops op.Ctx, th *C
|
||||
s.recoveredURPayload = nil
|
||||
s.recoveredQR = nil
|
||||
s.decodedShares = make(map[uint8]shard.Share)
|
||||
s.decodedURShares = make(map[string]struct{})
|
||||
s.decodedURSeqLen = 0
|
||||
s.stage = recoverStageScan
|
||||
ctx.addToast("Recovery state deleted", 1200)
|
||||
if s.returnScreen != nil {
|
||||
@ -285,15 +394,7 @@ func (s *RecoverDescriptorFlowScreen) exportStep(ctx *Context, ops op.Ctx, th *C
|
||||
titleY := 8 // fixed top offset
|
||||
op.Position(ops, ops.End(), image.Pt((dims.X-tsz.X)/2, titleY))
|
||||
|
||||
wid, sid := "", ""
|
||||
if base, ok := firstDecodedShare(s.decodedShares); ok {
|
||||
wid = strings.ToUpper(fmt.Sprintf("%x", base.WalletID))
|
||||
sid = strings.ToUpper(fmt.Sprintf("%x", base.SetID[:4]))
|
||||
}
|
||||
lead := "Shares reconstructed successfully."
|
||||
if wid != "" && sid != "" {
|
||||
lead = fmt.Sprintf("Shares reconstructed successfully.\nWID: %s\nSET: %s", wid, sid)
|
||||
}
|
||||
leadStyle := ctx.Styles.lead
|
||||
leadStyle.Alignment = text.AlignStart
|
||||
lsz := widget.Labelwf(ops.Begin(), leadStyle, textW, th.Text, "%s", lead)
|
||||
@ -406,6 +507,13 @@ func (s *RecoverDescriptorFlowScreen) updateViewerQR(ctx *Context, dims image.Po
|
||||
}
|
||||
|
||||
func (s *RecoverDescriptorFlowScreen) scanLead() string {
|
||||
if len(s.decodedURShares) > 0 {
|
||||
total := s.decodedURSeqLen
|
||||
if total <= 0 {
|
||||
total = urxor2of3.MinShares
|
||||
}
|
||||
return fmt.Sprintf("Captured %d/%d descriptor shares", len(s.decodedURShares), total)
|
||||
}
|
||||
if len(s.decodedShares) == 0 {
|
||||
return "Scan descriptor share"
|
||||
}
|
||||
@ -580,8 +688,7 @@ func buildDescriptorText(payload []byte) string {
|
||||
}
|
||||
|
||||
func formatDescriptorText(desc urtypes.OutputDescriptor) string {
|
||||
var wrap func(urtypes.Script, string) string
|
||||
wrap = func(script urtypes.Script, inner string) string {
|
||||
wrap := func(script urtypes.Script, inner string) string {
|
||||
switch script {
|
||||
case urtypes.P2WSH:
|
||||
return "wsh(" + inner + ")"
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
|
||||
"seedetcher.com/bc/ur"
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/descriptor/urxor2of3"
|
||||
"seedetcher.com/gui/assets"
|
||||
"seedetcher.com/gui/layout"
|
||||
"seedetcher.com/gui/op"
|
||||
@ -125,15 +126,19 @@ func (d *QRDecoder) parseQR(qr []byte) (any, bool) {
|
||||
|
||||
// ScanScreen handles QR scanning flow.
|
||||
type ScanScreen struct {
|
||||
Title string
|
||||
Lead string
|
||||
Title string
|
||||
Lead string
|
||||
RawURXOR2of3Only bool
|
||||
ShowURXOR2of3 bool
|
||||
}
|
||||
|
||||
func (s *ScanScreen) Scan(ctx *Context, ops op.Ctx) (any, bool) {
|
||||
var (
|
||||
feed, feed2, gray *image.Gray
|
||||
cameraErr error
|
||||
decoder QRDecoder
|
||||
feed, feed2, gray *image.Gray
|
||||
cameraErr error
|
||||
decoder QRDecoder
|
||||
seenURXOR2of3Fragments map[int]struct{}
|
||||
urSeqLen int
|
||||
)
|
||||
inp := new(InputTracker)
|
||||
for {
|
||||
@ -178,6 +183,24 @@ func (s *ScanScreen) Scan(ctx *Context, ops op.Ctx) (any, bool) {
|
||||
scaleRot(feed, gray, ctx.RotateCamera)
|
||||
results, _ := ctx.Platform.ScanQR(gray)
|
||||
for _, res := range results {
|
||||
if s.RawURXOR2of3Only || s.ShowURXOR2of3 {
|
||||
raw := strings.ToUpper(strings.TrimSpace(string(res)))
|
||||
if typ, seqNum, seqLen, ok := urxor2of3.ParseShare(raw); ok && typ == "crypto-output" && seqLen >= urxor2of3.MinShares {
|
||||
if seenURXOR2of3Fragments == nil {
|
||||
seenURXOR2of3Fragments = make(map[int]struct{})
|
||||
}
|
||||
if urSeqLen == 0 {
|
||||
urSeqLen = seqLen
|
||||
}
|
||||
seenURXOR2of3Fragments[seqNum] = struct{}{}
|
||||
}
|
||||
}
|
||||
if s.RawURXOR2of3Only {
|
||||
raw := strings.ToUpper(strings.TrimSpace(string(res)))
|
||||
if typ, _, seqLen, ok := urxor2of3.ParseShare(raw); ok && typ == "crypto-output" && seqLen >= urxor2of3.MinShares {
|
||||
return []byte(raw), true
|
||||
}
|
||||
}
|
||||
if v, ok := decoder.parseQR(res); ok {
|
||||
return v, true
|
||||
}
|
||||
@ -219,11 +242,25 @@ func (s *ScanScreen) Scan(ctx *Context, ops op.Ctx) (any, bool) {
|
||||
background(ops, ops.End(), image.Rectangle{Min: pos, Max: pos.Add(sz)}, pos)
|
||||
|
||||
// Progress
|
||||
if progress := decoder.Progress(); progress > 0 {
|
||||
sz = widget.Labelwf(ops.Begin(), ctx.Styles.lead, width, th.Text, "%d%%", progress)
|
||||
if (s.RawURXOR2of3Only || s.ShowURXOR2of3) && len(seenURXOR2of3Fragments) > 0 {
|
||||
captured := len(seenURXOR2of3Fragments)
|
||||
if urSeqLen <= 0 {
|
||||
urSeqLen = urxor2of3.MinShares
|
||||
}
|
||||
if captured > urSeqLen {
|
||||
captured = urSeqLen
|
||||
}
|
||||
sz = widget.Labelwf(ops.Begin(), ctx.Styles.lead, width, th.Text, "%d/%d", captured, urSeqLen)
|
||||
_, percent := top.CutBottom(sz.Y)
|
||||
pos := percent.Center(sz)
|
||||
background(ops, ops.End(), image.Rectangle{Min: pos, Max: pos.Add(sz)}, pos)
|
||||
} else {
|
||||
if progress := decoder.Progress(); progress > 0 {
|
||||
sz = widget.Labelwf(ops.Begin(), ctx.Styles.lead, width, th.Text, "%d%%", progress)
|
||||
_, percent := top.CutBottom(sz.Y)
|
||||
pos := percent.Center(sz)
|
||||
background(ops, ops.End(), image.Rectangle{Min: pos, Max: pos.Add(sz)}, pos)
|
||||
}
|
||||
}
|
||||
|
||||
nav := func(btn Button, icn image.RGBA64Image) {
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/descriptor/shard"
|
||||
@ -63,12 +61,7 @@ func (s *ShardedPolicyScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
op.ColorOp(ops, th.Background)
|
||||
title := layoutTitle(ctx, ops, dims.X, th.Text, "Sharding")
|
||||
|
||||
walletID := "N/A"
|
||||
setID := strings.ToUpper(hex.EncodeToString(s.SetID[:4]))
|
||||
if len(s.Shares) > 0 {
|
||||
walletID = strings.ToUpper(hex.EncodeToString(s.Shares[0].WalletID[:]))
|
||||
}
|
||||
body := fmt.Sprintf("Using descriptor values:\n\nt = %d\nn = %d\nWID: %s\nSET: %s", desc.Threshold, len(desc.Keys), walletID, setID)
|
||||
body := fmt.Sprintf("Using descriptor values:\n\nt = %d\nn = %d", desc.Threshold, len(desc.Keys))
|
||||
layoutBodyLeftUnderTitle(ctx, ops, dims, th.Text, title, body)
|
||||
|
||||
layoutNavigation(ctx, inp, ops, th, dims,
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/descriptor/shard"
|
||||
@ -102,14 +100,10 @@ func (s *ShardPreviewScreen) Update(ctx *Context, ops op.Ctx) Screen {
|
||||
title := layoutTitle(ctx, ops, dims.X, th.Text, "Descriptor Shares")
|
||||
|
||||
sh := s.Shares[0]
|
||||
wid := strings.ToUpper(hex.EncodeToString(sh.WalletID[:]))
|
||||
sid := strings.ToUpper(hex.EncodeToString(sh.SetID[:4]))
|
||||
body := fmt.Sprintf(
|
||||
"Need %d of %d descriptor shares to recover.\n\nWID: %s\nSET: %s\n\nContinue to wallet label and print setup.",
|
||||
"Need %d of %d descriptor shares to recover.\n\nContinue to wallet label and print setup.",
|
||||
sh.Threshold,
|
||||
sh.Total,
|
||||
wid,
|
||||
sid,
|
||||
)
|
||||
layoutBodyLeftUnderTitle(ctx, ops, dims, th.Text, title, body)
|
||||
|
||||
|
||||
@ -12,10 +12,11 @@ import (
|
||||
)
|
||||
|
||||
type ChoiceScreen struct {
|
||||
Title string
|
||||
Lead string
|
||||
Choices []string
|
||||
choice int
|
||||
Title string
|
||||
Lead string
|
||||
Choices []string
|
||||
LeadLeft bool
|
||||
choice int
|
||||
}
|
||||
|
||||
func (s *ChoiceScreen) Choose(ctx *Context, ops op.Ctx, th *Colors) (int, bool) {
|
||||
@ -64,9 +65,38 @@ func (s *ChoiceScreen) Draw(ctx *Context, ops op.Ctx, th *Colors, dims image.Poi
|
||||
layoutTitle(ctx, ops, dims.X, th.Text, "%s", s.Title)
|
||||
|
||||
_, bottom := r.CutTop(leadingSize)
|
||||
sz := widget.Labelwf(ops.Begin(), ctx.Styles.lead, dims.X-2*8, th.Text, "%s", s.Lead)
|
||||
content, lead := bottom.CutBottom(leadingSize)
|
||||
op.Position(ops, ops.End(), lead.Center(sz))
|
||||
leadStyle := ctx.Styles.lead
|
||||
leadWidth := dims.X - 2*8
|
||||
leadPosX := 8
|
||||
if s.LeadLeft {
|
||||
const (
|
||||
leftPad = 10
|
||||
rightPad = 10
|
||||
navGap = 2
|
||||
)
|
||||
rightReserved := assets.NavBtnPrimary.Bounds().Dx() + navGap
|
||||
leadWidth = dims.X - leftPad - rightPad - rightReserved
|
||||
if leadWidth < 80 {
|
||||
leadWidth = 80
|
||||
}
|
||||
leadPosX = leftPad
|
||||
leadStyle.Alignment = text.AlignStart
|
||||
}
|
||||
sz := widget.Labelwf(ops.Begin(), leadStyle, leadWidth, th.Text, "%s", s.Lead)
|
||||
leadHeight := leadingSize
|
||||
if sz.Y > leadHeight {
|
||||
leadHeight = sz.Y
|
||||
}
|
||||
content, lead := bottom.CutBottom(leadHeight)
|
||||
if s.LeadLeft {
|
||||
y := lead.Max.Y - sz.Y
|
||||
if y < lead.Min.Y {
|
||||
y = lead.Min.Y
|
||||
}
|
||||
op.Position(ops, ops.End(), image.Pt(leadPosX, y))
|
||||
} else {
|
||||
op.Position(ops, ops.End(), lead.Center(sz))
|
||||
}
|
||||
|
||||
content = content.Shrink(16, 0, 16, 0)
|
||||
|
||||
@ -126,7 +156,7 @@ type NavButton struct {
|
||||
Progress float32
|
||||
}
|
||||
|
||||
func layoutNavigation(ctx *Context, inp *InputTracker, ops op.Ctx, th *Colors, dims image.Point, btns ...NavButton) image.Rectangle {
|
||||
func layoutNavigation(_ *Context, inp *InputTracker, ops op.Ctx, th *Colors, dims image.Point, btns ...NavButton) image.Rectangle {
|
||||
navsz := assets.NavBtnPrimary.Bounds().Size()
|
||||
button := func(ops op.Ctx, b NavButton, pressed bool) {
|
||||
if b.Style == StyleNone {
|
||||
@ -201,7 +231,7 @@ func layoutBodyLeftUnderTitle(ctx *Context, ops op.Ctx, dims image.Point, col co
|
||||
leftPad = 10
|
||||
rightPad = 10
|
||||
titleBodyGap = 10
|
||||
navGap = 6
|
||||
navGap = 2
|
||||
)
|
||||
rightReserved := assets.NavBtnPrimary.Bounds().Dx() + navGap
|
||||
width := dims.X - leftPad - rightPad - rightReserved
|
||||
|
||||
@ -5,12 +5,14 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"seedetcher.com/gui/assets"
|
||||
"seedetcher.com/gui/op"
|
||||
"seedetcher.com/gui/widget"
|
||||
"seedetcher.com/logutil"
|
||||
)
|
||||
|
||||
type errDuplicateKey struct {
|
||||
@ -43,6 +45,7 @@ func NewErrorScreen(err error) *ErrorScreen {
|
||||
}
|
||||
|
||||
func showError(ctx *Context, ops op.Ctx, th *Colors, err error) {
|
||||
triggerErrorLogExport(ctx, err)
|
||||
scr := NewErrorScreen(err)
|
||||
for {
|
||||
dims := ctx.Platform.DisplaySize()
|
||||
@ -53,6 +56,26 @@ func showError(ctx *Context, ops op.Ctx, th *Colors, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
func triggerErrorLogExport(ctx *Context, err error) {
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
now := ctx.Platform.Now()
|
||||
// Avoid excessive SD writes when the same failing action repeats.
|
||||
if !ctx.LastErrorExport.IsZero() && now.Sub(ctx.LastErrorExport) < 60*time.Second {
|
||||
return
|
||||
}
|
||||
ctx.LastErrorExport = now
|
||||
go func(msg string) {
|
||||
cmd := exec.Command("/bin/export-logs-to-sd", "ui-error")
|
||||
if out, runErr := cmd.CombinedOutput(); runErr != nil {
|
||||
logutil.DebugLog("error-export failed: %v; out=%s", runErr, strings.TrimSpace(string(out)))
|
||||
return
|
||||
}
|
||||
logutil.DebugLog("error-export done for UI error: %s", msg)
|
||||
}(err.Error())
|
||||
}
|
||||
|
||||
type ErrorScreen struct {
|
||||
Title string
|
||||
Body string
|
||||
|
||||
77
init.sh
77
init.sh
@ -27,16 +27,37 @@ if [ -w /proc/sys/kernel/sysrq ]; then
|
||||
echo "SysRq=$(cat /proc/sys/kernel/sysrq 2>/dev/null)" >> /log/init_debug.log
|
||||
fi
|
||||
|
||||
# Pick a debug sink as early as possible
|
||||
DEBUG_TTY="/dev/ttyAMA0"
|
||||
[ -c /dev/ttyGS0 ] && DEBUG_TTY="/dev/ttyGS0"
|
||||
[ -c /dev/tty1 ] && [ ! -c "$DEBUG_TTY" ] && DEBUG_TTY="/dev/tty1"
|
||||
# Quiet-by-default debug policy:
|
||||
# - always log to /log/init_debug.log
|
||||
# - only mirror to /dev/kmsg when INIT_DEBUG_TO_KMSG=1
|
||||
# - only mirror to a TTY when INIT_DEBUG_TO_TTY=1
|
||||
INIT_DEBUG_TO_KMSG="${INIT_DEBUG_TO_KMSG:-0}"
|
||||
INIT_DEBUG_TO_TTY="${INIT_DEBUG_TO_TTY:-0}"
|
||||
DEBUG_TTY="${INIT_DEBUG_TTY:-/dev/null}"
|
||||
if [ -f /cups-runtime.env ]; then
|
||||
# shellcheck source=/dev/null
|
||||
. /cups-runtime.env
|
||||
INIT_DEBUG_TO_KMSG="${INIT_DEBUG_TO_KMSG:-0}"
|
||||
INIT_DEBUG_TO_TTY="${INIT_DEBUG_TO_TTY:-0}"
|
||||
DEBUG_TTY="${INIT_DEBUG_TTY:-$DEBUG_TTY}"
|
||||
fi
|
||||
if [ "$INIT_DEBUG_TO_TTY" = "1" ] && [ "$DEBUG_TTY" = "/dev/null" ]; then
|
||||
if [ -c /dev/ttyGS0 ]; then
|
||||
DEBUG_TTY="/dev/ttyGS0"
|
||||
elif [ -c /dev/tty1 ]; then
|
||||
DEBUG_TTY="/dev/tty1"
|
||||
elif [ -c /dev/ttyAMA0 ]; then
|
||||
DEBUG_TTY="/dev/ttyAMA0"
|
||||
fi
|
||||
fi
|
||||
|
||||
debug_echo() {
|
||||
msg="DEBUG: $1"
|
||||
echo "$msg" >> /log/init_debug.log
|
||||
echo "$msg" > /dev/kmsg
|
||||
if [ -c "$DEBUG_TTY" ]; then
|
||||
if [ "$INIT_DEBUG_TO_KMSG" = "1" ]; then
|
||||
echo "$msg" > /dev/kmsg 2>/dev/null || true
|
||||
fi
|
||||
if [ "$INIT_DEBUG_TO_TTY" = "1" ] && [ -c "$DEBUG_TTY" ]; then
|
||||
echo "$msg" > "$DEBUG_TTY"
|
||||
fi
|
||||
}
|
||||
@ -68,6 +89,9 @@ wait_for "$SHELL_TTY" 30
|
||||
wait_for "$CTRL_TTY" 30
|
||||
|
||||
debug_echo "Shell TTY: ${SHELL_TTY:-none}, Controller TTY: ${CTRL_TTY:-none}"
|
||||
if [ -f /cups-runtime.env ]; then
|
||||
debug_echo "CUPS runtime: lazy bootstrap mode (boot init skipped)"
|
||||
fi
|
||||
|
||||
# Fix DRM framebuffer permissions
|
||||
debug_echo "Fixing /dev/dri permissions..."
|
||||
@ -85,10 +109,47 @@ fi
|
||||
|
||||
debug_echo "Checking /controller existence and permissions..."
|
||||
ls -l /controller >> /log/init_debug.log 2>&1
|
||||
file /controller >> /log/init_debug.log 2>&1
|
||||
[ -x /bin/file ] && file /controller >> /log/init_debug.log 2>&1
|
||||
debug_echo "Starting controller..."
|
||||
# Ensure controller’s stdout/stderr go to log; stdin from controller TTY if present
|
||||
/controller < "$CTRL_TTY" >> /log/debug.log 2>> /log/debug.log &
|
||||
CONTROLLER_PID=$!
|
||||
|
||||
run_log_export() {
|
||||
reason="$1"
|
||||
if [ ! -x /bin/export-logs-to-sd ]; then
|
||||
return 0
|
||||
fi
|
||||
echo "log-export: reason=${reason} start" >> /log/init_debug.log
|
||||
if /bin/export-logs-to-sd "$reason" >> /log/init_debug.log 2>&1; then
|
||||
echo "log-export: reason=${reason} done" >> /log/init_debug.log
|
||||
return 0
|
||||
fi
|
||||
echo "log-export: reason=${reason} failed" >> /log/init_debug.log
|
||||
return 1
|
||||
}
|
||||
|
||||
# Debug image helper: export logs once after boot and again if controller exits.
|
||||
# This ensures beta users can retrieve logs from SD without UART access.
|
||||
if [ -x /bin/export-logs-to-sd ]; then
|
||||
(
|
||||
n=0
|
||||
while [ "$n" -lt 8 ]; do
|
||||
if run_log_export "boot-$n"; then
|
||||
break
|
||||
fi
|
||||
n=$((n + 1))
|
||||
sleep 15
|
||||
done
|
||||
) &
|
||||
(
|
||||
pid="$CONTROLLER_PID"
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
run_log_export "controller-exit"
|
||||
) &
|
||||
fi
|
||||
|
||||
# Wait until the controller process is fully running
|
||||
while ! pidof controller > /dev/null; do
|
||||
@ -112,7 +173,7 @@ debug_echo "Init finished. Starting shell..."
|
||||
if [ -n "$SHELL_TTY" ] && [ -c "$SHELL_TTY" ]; then
|
||||
debug_echo "Launching getty on $SHELL_TTY..."
|
||||
echo "seedetcher init: shell on $SHELL_TTY" > "$SHELL_TTY"
|
||||
stty -F "$SHELL_TTY" sane cread clocal 115200 cs8 -parenb -cstopb -ixon -ixoff -echo
|
||||
stty -F "$SHELL_TTY" sane echo
|
||||
exec /bin/busybox getty -L -n -l /bin/sh 115200 "$SHELL_TTY"
|
||||
else
|
||||
debug_echo "No shell TTY found; exec'ing controller only"
|
||||
|
||||
127
printer/backend_parity_test.go
Normal file
127
printer/backend_parity_test.go
Normal file
@ -0,0 +1,127 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"seedetcher.com/testutils"
|
||||
)
|
||||
|
||||
func TestWalletPageDataParityAcrossPrintBackends(t *testing.T) {
|
||||
const (
|
||||
wallet = "multisig-mainnet-2of3"
|
||||
dpi = 150.0
|
||||
paper = PaperA4
|
||||
)
|
||||
|
||||
seedPlates, descPlates, pages := walletPagesForTest(t, wallet, dpi, paper)
|
||||
|
||||
var pclComposed bytes.Buffer
|
||||
if err := WritePCL(&pclComposed, pages, dpi, paper, nil); err != nil {
|
||||
t.Fatalf("WritePCL: %v", err)
|
||||
}
|
||||
var pclDirect bytes.Buffer
|
||||
if err := WritePCLPlates(&pclDirect, seedPlates, descPlates, dpi, paper, nil); err != nil {
|
||||
t.Fatalf("WritePCLPlates: %v", err)
|
||||
}
|
||||
if !bytes.Equal(pclComposed.Bytes(), pclDirect.Bytes()) {
|
||||
t.Fatalf("PCL mismatch: composed=%d direct=%d", pclComposed.Len(), pclDirect.Len())
|
||||
}
|
||||
|
||||
var psComposed bytes.Buffer
|
||||
if err := WritePS(&psComposed, pages, paper, nil); err != nil {
|
||||
t.Fatalf("WritePS: %v", err)
|
||||
}
|
||||
var psDirect bytes.Buffer
|
||||
if err := WritePSPlates(&psDirect, seedPlates, descPlates, paper, dpi, nil, nil); err != nil {
|
||||
t.Fatalf("WritePSPlates: %v", err)
|
||||
}
|
||||
if !bytes.Equal(psComposed.Bytes(), psDirect.Bytes()) {
|
||||
t.Fatalf("PS mismatch: composed=%d direct=%d", psComposed.Len(), psDirect.Len())
|
||||
}
|
||||
|
||||
var pdf bytes.Buffer
|
||||
if err := WritePDFRaster(&pdf, pages, paper); err != nil {
|
||||
t.Fatalf("WritePDFRaster: %v", err)
|
||||
}
|
||||
if pdf.Len() == 0 {
|
||||
t.Fatal("WritePDFRaster: empty output")
|
||||
}
|
||||
if !bytes.HasPrefix(pdf.Bytes(), []byte("%PDF-")) {
|
||||
t.Fatal("WritePDFRaster: output is not a PDF header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEtchStatsIncrementalMatchesLegacyForWalletPlates(t *testing.T) {
|
||||
const (
|
||||
wallet = "multisig-mainnet-2of3"
|
||||
dpi = 150.0
|
||||
paper = PaperA4
|
||||
)
|
||||
|
||||
seedPlates, descPlates, _ := walletPagesForTest(t, wallet, dpi, paper)
|
||||
|
||||
legacyReport, err := BuildEtchStatsReport(seedPlates, descPlates, dpi, paper)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEtchStatsReport: %v", err)
|
||||
}
|
||||
|
||||
statsRows := make([]EtchPlateStat, 0, len(seedPlates)*2)
|
||||
for i, seed := range seedPlates {
|
||||
if seed != nil {
|
||||
statsRows = append(statsRows, ComputeEtchPlateStat(seed, i+1, "seed", dpi))
|
||||
}
|
||||
if i < len(descPlates) && descPlates[i] != nil {
|
||||
statsRows = append(statsRows, ComputeEtchPlateStat(descPlates[i], i+1, "descriptor", dpi))
|
||||
}
|
||||
}
|
||||
|
||||
incrementalReport, err := BuildEtchStatsReportFromStats(statsRows, dpi, paper)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEtchStatsReportFromStats: %v", err)
|
||||
}
|
||||
|
||||
if len(legacyReport.Stats) != len(incrementalReport.Stats) {
|
||||
t.Fatalf("stats length mismatch: legacy=%d incremental=%d", len(legacyReport.Stats), len(incrementalReport.Stats))
|
||||
}
|
||||
for i := range legacyReport.Stats {
|
||||
if legacyReport.Stats[i] != incrementalReport.Stats[i] {
|
||||
t.Fatalf("stats row %d mismatch: legacy=%+v incremental=%+v", i, legacyReport.Stats[i], incrementalReport.Stats[i])
|
||||
}
|
||||
}
|
||||
|
||||
legacyPage, err := RenderEtchStatsPage(legacyReport, paper, dpi)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderEtchStatsPage legacy: %v", err)
|
||||
}
|
||||
incrementalPage, err := RenderEtchStatsPage(incrementalReport, paper, dpi)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderEtchStatsPage incremental: %v", err)
|
||||
}
|
||||
if !bytes.Equal(legacyPage.Pix, incrementalPage.Pix) {
|
||||
t.Fatal("rendered stats page mismatch between legacy and incremental paths")
|
||||
}
|
||||
}
|
||||
|
||||
func walletPagesForTest(t *testing.T, wallet string, dpi float64, paper PaperSize) (seedPlates, descPlates, pages []*image.Paletted) {
|
||||
t.Helper()
|
||||
|
||||
cfg, ok := testutils.WalletConfigs[wallet]
|
||||
if !ok {
|
||||
t.Fatalf("wallet fixture not found: %s", wallet)
|
||||
}
|
||||
mnemonics, desc, err := testutils.ParseWallet(cfg, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWallet(%s): %v", wallet, err)
|
||||
}
|
||||
seedPlates, descPlates, err = CreatePlateBitmaps(mnemonics, desc, 0, RasterOptions{DPI: dpi}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePlateBitmaps(%s): %v", wallet, err)
|
||||
}
|
||||
pages, err = ComposePages(seedPlates, descPlates, paper, dpi, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ComposePages(%s): %v", wallet, err)
|
||||
}
|
||||
return seedPlates, descPlates, pages
|
||||
}
|
||||
195
printer/descriptor_share_matrix_test.go
Normal file
195
printer/descriptor_share_matrix_test.go
Normal file
@ -0,0 +1,195 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
"seedetcher.com/bc/ur"
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/descriptor/urxor2of3"
|
||||
"seedetcher.com/testutils"
|
||||
)
|
||||
|
||||
type descriptorShareMode int
|
||||
|
||||
const (
|
||||
shareModeURXOR descriptorShareMode = iota
|
||||
shareModeFullUR
|
||||
)
|
||||
|
||||
func TestDescriptorShareMatrix_RepresentativeScriptsNetworks(t *testing.T) {
|
||||
type baseCase struct {
|
||||
name string
|
||||
walletKey string
|
||||
mutate func(*urtypes.OutputDescriptor)
|
||||
expect descriptorShareMode
|
||||
}
|
||||
bases := []baseCase{
|
||||
{name: "2of3", walletKey: "multisig", expect: shareModeURXOR},
|
||||
{name: "2of4", walletKey: "multisig-2of4", expect: shareModeURXOR},
|
||||
{name: "3of5", walletKey: "multisig-3of5", expect: shareModeURXOR},
|
||||
{
|
||||
name: "nMinusOne_4of5",
|
||||
walletKey: "multisig-3of5",
|
||||
mutate: func(d *urtypes.OutputDescriptor) {
|
||||
d.Threshold = 4
|
||||
},
|
||||
expect: shareModeURXOR,
|
||||
},
|
||||
{name: "fallback_7of10", walletKey: "multisig-7of10", expect: shareModeFullUR},
|
||||
}
|
||||
|
||||
scripts := []struct {
|
||||
name string
|
||||
script urtypes.Script
|
||||
}{
|
||||
{name: "p2wsh", script: urtypes.P2WSH},
|
||||
{name: "p2sh_p2wsh", script: urtypes.P2SH_P2WSH},
|
||||
}
|
||||
networks := []struct {
|
||||
name string
|
||||
net *chaincfg.Params
|
||||
}{
|
||||
{name: "main", net: &chaincfg.MainNetParams},
|
||||
{name: "test", net: &chaincfg.TestNet3Params},
|
||||
}
|
||||
|
||||
for _, b := range bases {
|
||||
_, parsed, err := testutils.ParseWallet(testutils.WalletConfigs[b.walletKey], "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parse wallet %s: %v", b.walletKey, err)
|
||||
}
|
||||
if parsed == nil {
|
||||
t.Fatalf("wallet %s has no descriptor", b.walletKey)
|
||||
}
|
||||
|
||||
for _, s := range scripts {
|
||||
for _, n := range networks {
|
||||
name := fmt.Sprintf("%s/%s/%s", b.name, s.name, n.name)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
desc := cloneDescriptor(parsed)
|
||||
if b.mutate != nil {
|
||||
b.mutate(&desc)
|
||||
}
|
||||
desc.Script = s.script
|
||||
for i := range desc.Keys {
|
||||
desc.Keys[i].Network = n.net
|
||||
}
|
||||
|
||||
payloadsByShare, mode := collectSharePayloads(t, &desc)
|
||||
if mode != b.expect {
|
||||
t.Fatalf("mode=%v want=%v", mode, b.expect)
|
||||
}
|
||||
|
||||
selected := payloadsByShare[:desc.Threshold]
|
||||
recovered := recoverDescriptorPayload(t, selected, mode)
|
||||
v, err := urtypes.Parse("crypto-output", recovered)
|
||||
if err != nil {
|
||||
t.Fatalf("parse recovered payload: %v", err)
|
||||
}
|
||||
out, ok := v.(urtypes.OutputDescriptor)
|
||||
if !ok {
|
||||
t.Fatalf("recovered type %T", v)
|
||||
}
|
||||
if out.Type != desc.Type {
|
||||
t.Fatalf("type mismatch: got %v want %v", out.Type, desc.Type)
|
||||
}
|
||||
if out.Script != desc.Script {
|
||||
t.Fatalf("script mismatch: got %v want %v", out.Script, desc.Script)
|
||||
}
|
||||
if out.Threshold != desc.Threshold {
|
||||
t.Fatalf("threshold mismatch: got %d want %d", out.Threshold, desc.Threshold)
|
||||
}
|
||||
if len(out.Keys) != len(desc.Keys) {
|
||||
t.Fatalf("key count mismatch: got %d want %d", len(out.Keys), len(desc.Keys))
|
||||
}
|
||||
wantFP := fingerprintSet(desc.Keys)
|
||||
gotFP := fingerprintSet(out.Keys)
|
||||
if strings.Join(gotFP, ",") != strings.Join(wantFP, ",") {
|
||||
t.Fatalf("fingerprint set mismatch: got=%v want=%v", gotFP, wantFP)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectSharePayloads(t *testing.T, desc *urtypes.OutputDescriptor) ([][]string, descriptorShareMode) {
|
||||
t.Helper()
|
||||
total := len(desc.Keys)
|
||||
all := make([][]string, total)
|
||||
mode := shareModeFullUR
|
||||
for i := 0; i < total; i++ {
|
||||
payloads, err := descriptorShardQRPayloadsForShare(desc, total, i)
|
||||
if err != nil {
|
||||
t.Fatalf("share %d payloads: %v", i+1, err)
|
||||
}
|
||||
if len(payloads) == 0 {
|
||||
t.Fatalf("share %d returned zero payloads", i+1)
|
||||
}
|
||||
all[i] = payloads
|
||||
if typ, _, seqLen, ok := urxor2of3.ParseShare(payloads[0]); ok && typ == "crypto-output" && seqLen >= urxor2of3.MinShares {
|
||||
mode = shareModeURXOR
|
||||
}
|
||||
}
|
||||
return all, mode
|
||||
}
|
||||
|
||||
func recoverDescriptorPayload(t *testing.T, selected [][]string, mode descriptorShareMode) []byte {
|
||||
t.Helper()
|
||||
flat := make([]string, 0, len(selected)*2)
|
||||
for _, frags := range selected {
|
||||
flat = append(flat, frags...)
|
||||
}
|
||||
if mode == shareModeURXOR {
|
||||
payload, err := urxor2of3.Combine(flat)
|
||||
if err != nil {
|
||||
t.Fatalf("ur/xor combine: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
var d ur.Decoder
|
||||
for _, s := range flat {
|
||||
if err := d.Add(s); err != nil {
|
||||
t.Fatalf("ur add: %v", err)
|
||||
}
|
||||
}
|
||||
typ, payload, err := d.Result()
|
||||
if err != nil {
|
||||
t.Fatalf("ur result: %v", err)
|
||||
}
|
||||
if typ != "crypto-output" {
|
||||
t.Fatalf("ur type=%q want crypto-output", typ)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func cloneDescriptor(in *urtypes.OutputDescriptor) urtypes.OutputDescriptor {
|
||||
out := *in
|
||||
out.Keys = make([]urtypes.KeyDescriptor, len(in.Keys))
|
||||
for i := range in.Keys {
|
||||
k := in.Keys[i]
|
||||
k.KeyData = append([]byte(nil), k.KeyData...)
|
||||
k.ChainCode = append([]byte(nil), k.ChainCode...)
|
||||
k.DerivationPath = append(urtypes.Path(nil), k.DerivationPath...)
|
||||
k.Children = append([]urtypes.Derivation(nil), k.Children...)
|
||||
out.Keys[i] = k
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fingerprintSet(keys []urtypes.KeyDescriptor) []string {
|
||||
out := make([]string, len(keys))
|
||||
for i, k := range keys {
|
||||
out[i] = fmt.Sprintf("%08x", k.MasterFingerprint)
|
||||
}
|
||||
// tiny deterministic insertion sort; avoids pulling extra deps.
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && out[j-1] > out[j]; j-- {
|
||||
out[j-1], out[j] = out[j], out[j-1]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
57
printer/descriptor_share_payloads.go
Normal file
57
printer/descriptor_share_payloads.go
Normal file
@ -0,0 +1,57 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/descriptor/urxor2of3"
|
||||
)
|
||||
|
||||
func descriptorShardQRPayloadsForShare(desc *urtypes.OutputDescriptor, totalShares, keyIdx int) ([]string, error) {
|
||||
if desc == nil {
|
||||
return nil, fmt.Errorf("descriptor is nil")
|
||||
}
|
||||
if totalShares <= 0 {
|
||||
return nil, fmt.Errorf("invalid share count: %d", totalShares)
|
||||
}
|
||||
if keyIdx < 0 || keyIdx >= totalShares {
|
||||
return nil, fmt.Errorf("invalid key index: %d", keyIdx)
|
||||
}
|
||||
threshold := desc.Threshold
|
||||
if threshold == 1 && totalShares == 1 {
|
||||
qr := createDescriptorQR(desc)
|
||||
if qr == "" {
|
||||
return nil, fmt.Errorf("empty descriptor QR content")
|
||||
}
|
||||
return []string{qr}, nil
|
||||
}
|
||||
if threshold < 2 || threshold > totalShares {
|
||||
return nil, fmt.Errorf("invalid descriptor threshold %d for %d shares", threshold, totalShares)
|
||||
}
|
||||
if desc.Type == urtypes.SortedMulti && threshold >= 2 && urxor2of3.SupportsScheme(threshold, totalShares) {
|
||||
if payloads, err := urxor2of3.SplitDescriptorForShare(desc, keyIdx); err == nil {
|
||||
return payloads, nil
|
||||
}
|
||||
}
|
||||
// Interoperable fallback: full descriptor UR on each plate.
|
||||
qr := createDescriptorQR(desc)
|
||||
if qr == "" {
|
||||
return nil, fmt.Errorf("empty descriptor QR content")
|
||||
}
|
||||
return []string{qr}, nil
|
||||
}
|
||||
|
||||
func descriptorShardQRCodes(desc *urtypes.OutputDescriptor, totalShares int) ([]string, error) {
|
||||
out := make([]string, totalShares)
|
||||
for i := 0; i < totalShares; i++ {
|
||||
payloads, err := descriptorShardQRPayloadsForShare(desc, totalShares, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(payloads) != 1 {
|
||||
return nil, fmt.Errorf("share %d has %d fragments (expected 1)", i+1, len(payloads))
|
||||
}
|
||||
out[i] = payloads[0]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
274
printer/etch_stats.go
Normal file
274
printer/etch_stats.go
Normal file
@ -0,0 +1,274 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
)
|
||||
|
||||
type EtchPlateStat struct {
|
||||
PlateIndex int
|
||||
Side string // "seed" or "descriptor"
|
||||
|
||||
BlackPixels int64
|
||||
WhitePixels int64
|
||||
|
||||
BlackMM2 float64 // Toner area within 90x90 mask
|
||||
WhiteMM2 float64 // Exposed area within 90x90 mask
|
||||
|
||||
ExposedMM2 float64 // Exposed area on the plate
|
||||
ExposedPct float64 // of full 100x100 steel plate
|
||||
}
|
||||
|
||||
type EtchStatsReport struct {
|
||||
DPI float64
|
||||
Paper PaperSize
|
||||
|
||||
SteelAreaMM2 float64 // 100x100 physical plate area
|
||||
|
||||
Stats []EtchPlateStat
|
||||
}
|
||||
|
||||
const (
|
||||
steelAreaMM2 = 100.0 * 100.0
|
||||
plateEdgeMM = 2.0 * (100.0 + 100.0) // Perimeter of 100x100mm plate.
|
||||
|
||||
// Bench defaults for the operator section on the stats page.
|
||||
etchGapMM = 15.0
|
||||
etchTempC = 34.0
|
||||
etchSulfateGPerL = 100.0
|
||||
etchVoltageLimitV = 12.0
|
||||
etchCurrentDensity = 0.04 // A/cm^2 default for "set current"
|
||||
)
|
||||
|
||||
var etchThicknessMM = []float64{1.0, 1.5, 2.0, 3.0}
|
||||
|
||||
func BuildEtchStatsReport(seedPlates, descPlates []*image.Paletted, dpi float64, paper PaperSize) (EtchStatsReport, error) {
|
||||
if len(seedPlates) == 0 {
|
||||
return EtchStatsReport{}, fmt.Errorf("no seed plates")
|
||||
}
|
||||
stats := make([]EtchPlateStat, 0, len(seedPlates)*2)
|
||||
for i, sp := range seedPlates {
|
||||
if sp != nil {
|
||||
stats = append(stats, plateStat(sp, i+1, "seed", dpi))
|
||||
}
|
||||
if len(descPlates) > 0 && i < len(descPlates) {
|
||||
p := descPlates[i]
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
stats = append(stats, plateStat(p, i+1, "descriptor", dpi))
|
||||
}
|
||||
}
|
||||
return BuildEtchStatsReportFromStats(stats, dpi, paper)
|
||||
}
|
||||
|
||||
func BuildEtchStatsReportFromStats(stats []EtchPlateStat, dpi float64, paper PaperSize) (EtchStatsReport, error) {
|
||||
if len(stats) == 0 {
|
||||
return EtchStatsReport{}, fmt.Errorf("no plate stats")
|
||||
}
|
||||
r := EtchStatsReport{
|
||||
DPI: dpi,
|
||||
Paper: paper,
|
||||
SteelAreaMM2: steelAreaMM2,
|
||||
Stats: make([]EtchPlateStat, len(stats)),
|
||||
}
|
||||
copy(r.Stats, stats)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func ComputeEtchPlateStat(p *image.Paletted, plateIdx int, side string, dpi float64) EtchPlateStat {
|
||||
return plateStat(p, plateIdx, side, dpi)
|
||||
}
|
||||
|
||||
func plateStat(p *image.Paletted, plateIdx int, side string, dpi float64) EtchPlateStat {
|
||||
var black, white int64
|
||||
b := p.Bounds()
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
row := p.Pix[y*p.Stride:]
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
if row[x] == 1 {
|
||||
black++
|
||||
} else {
|
||||
white++
|
||||
}
|
||||
}
|
||||
}
|
||||
pxArea := (25.4 / dpi) * (25.4 / dpi)
|
||||
blackMM2 := float64(black) * pxArea
|
||||
whiteMM2 := float64(white) * pxArea
|
||||
exposed := whiteMM2
|
||||
return EtchPlateStat{
|
||||
PlateIndex: plateIdx,
|
||||
Side: side,
|
||||
BlackPixels: black,
|
||||
WhitePixels: white,
|
||||
BlackMM2: blackMM2,
|
||||
WhiteMM2: whiteMM2,
|
||||
ExposedMM2: exposed,
|
||||
ExposedPct: pct(exposed, steelAreaMM2),
|
||||
}
|
||||
}
|
||||
|
||||
func pct(v, denom float64) float64 {
|
||||
if denom <= 0 {
|
||||
return 0
|
||||
}
|
||||
return 100.0 * v / denom
|
||||
}
|
||||
|
||||
func RenderEtchStatsPage(report EtchStatsReport, paper PaperSize, dpi float64) (*image.Paletted, error) {
|
||||
pageWmm, pageHmm, ok := paperDimsMM(paper)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported paper size: %v", paper)
|
||||
}
|
||||
page := image.NewPaletted(image.Rect(0, 0, mmToPx(pageWmm, dpi), mmToPx(pageHmm, dpi)), bwPalette)
|
||||
draw.Draw(page, page.Bounds(), &image.Uniform{bwPalette[0]}, image.Point{}, draw.Src)
|
||||
|
||||
faceTitle := loadFaceMedium(12, dpi)
|
||||
faceBody := loadFaceMedium(9, dpi)
|
||||
titleTrack := 0.04 * 12.0 * dpi / 72.0
|
||||
bodyTrack := 0.02 * 9.0 * dpi / 72.0
|
||||
|
||||
x := 8.0
|
||||
y := 10.0 + capBaselineOffsetMM(faceTitle, dpi)
|
||||
drawTrackedText(page, faceTitle, dpi, x, y, "ETCH STATS", titleTrack)
|
||||
|
||||
y += 6.0
|
||||
meta := fmt.Sprintf("DPI: %.0f PAPER: %s PHYSICAL PLATE: 100x100 mm", report.DPI, report.Paper)
|
||||
drawTrackedText(page, faceBody, dpi, x, y, meta, bodyTrack)
|
||||
|
||||
y += 5.5
|
||||
defaults := fmt.Sprintf("DEFAULTS: Na2SO4 %.0fg/L TEMP %.0fC GAP %.0fmm V-LIMIT %.0fV J %.2fA/cm2",
|
||||
etchSulfateGPerL, etchTempC, etchGapMM, etchVoltageLimitV, etchCurrentDensity)
|
||||
drawTrackedText(page, faceBody, dpi, x, y, defaults, bodyTrack)
|
||||
y += 4.0
|
||||
drawTrackedText(page, faceBody, dpi, x, y, "If you only etch one side, please mask the other side completely with tape.", bodyTrack)
|
||||
y += 4.0
|
||||
psuExplain := "The MASKED column in PSU GUIDE TABLE means you did mask the side walls of the plate. If you leave the walls unmasked refer to t (plate thickness)."
|
||||
noteLines := wrapTextTracked(faceBody, dpi, psuExplain, pageWmm-2*x, bodyTrack)
|
||||
for _, ln := range noteLines {
|
||||
drawTrackedText(page, faceBody, dpi, x, y, ln, bodyTrack)
|
||||
y += 4.0
|
||||
}
|
||||
drawTrackedText(page, faceBody, dpi, x, y, "If you etch both sides of the plate (SEED and DESC) together using 2 cathodes,", bodyTrack)
|
||||
y += 4.0
|
||||
drawTrackedText(page, faceBody, dpi, x, y, "sum both sides' A.", bodyTrack)
|
||||
y += 4.0
|
||||
|
||||
y += 4.0
|
||||
drawTrackedText(page, faceBody, dpi, x, y, "AREA TABLE (t=plate thickness)", bodyTrack)
|
||||
y += 4.5
|
||||
const (
|
||||
plateColW = 8
|
||||
areaColW = 11
|
||||
pctColW = 9
|
||||
curColW = 10
|
||||
)
|
||||
areaHeader := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*s %*s",
|
||||
plateColW, "PLATE",
|
||||
areaColW, "TONER(cm2)",
|
||||
areaColW, "EXPOSED(cm2)",
|
||||
pctColW, "MASKED",
|
||||
pctColW, "t=1 +%",
|
||||
pctColW, "t=1.5 +%",
|
||||
pctColW, "t=2 +%",
|
||||
pctColW, "t=3 +%",
|
||||
)
|
||||
drawTrackedText(page, faceBody, dpi, x, y, areaHeader, bodyTrack)
|
||||
y += 4.0
|
||||
|
||||
for _, s := range report.Stats {
|
||||
side := "SEED"
|
||||
if s.Side == "descriptor" {
|
||||
side = "DESC"
|
||||
}
|
||||
plateID := fmt.Sprintf("%02d %s", s.PlateIndex, side)
|
||||
line := fmt.Sprintf("%-*s %*.2f %*.2f %*s %*s %*s %*s %*s",
|
||||
plateColW, plateID,
|
||||
areaColW, s.BlackMM2/100.0,
|
||||
areaColW, s.ExposedMM2/100.0,
|
||||
pctColW, fmtPct(s.ExposedPct),
|
||||
pctColW, fmtPct(edgePctForThickness(etchThicknessMM[0])),
|
||||
pctColW, fmtPct(edgePctForThickness(etchThicknessMM[1])),
|
||||
pctColW, fmtPct(edgePctForThickness(etchThicknessMM[2])),
|
||||
pctColW, fmtPct(edgePctForThickness(etchThicknessMM[3])),
|
||||
)
|
||||
drawTrackedText(page, faceBody, dpi, x, y, line, bodyTrack)
|
||||
y += 4.0
|
||||
if y > pageHmm-20.0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
y += 5.0
|
||||
drawTrackedText(page, faceBody, dpi, x, y, "PSU GUIDE TABLE (set A to)", bodyTrack)
|
||||
y += 4.5
|
||||
psuHeader := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s",
|
||||
plateColW, "PLATE",
|
||||
curColW, "MASKED",
|
||||
curColW, "t=1mm",
|
||||
curColW, "t=1.5mm",
|
||||
curColW, "t=2mm",
|
||||
curColW, "t=3mm",
|
||||
)
|
||||
drawTrackedText(page, faceBody, dpi, x, y, psuHeader, bodyTrack)
|
||||
y += 4.0
|
||||
for _, s := range report.Stats {
|
||||
side := "SEED"
|
||||
if s.Side == "descriptor" {
|
||||
side = "DESC"
|
||||
}
|
||||
plateID := fmt.Sprintf("%02d %s", s.PlateIndex, side)
|
||||
line := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s",
|
||||
plateColW, plateID,
|
||||
curColW, fmtCurrentA(setCurrentA(s.ExposedMM2, etchCurrentDensity)),
|
||||
curColW, fmtCurrentA(setCurrentA(exposedMM2ForThickness(s.ExposedMM2, etchThicknessMM[0]), etchCurrentDensity)),
|
||||
curColW, fmtCurrentA(setCurrentA(exposedMM2ForThickness(s.ExposedMM2, etchThicknessMM[1]), etchCurrentDensity)),
|
||||
curColW, fmtCurrentA(setCurrentA(exposedMM2ForThickness(s.ExposedMM2, etchThicknessMM[2]), etchCurrentDensity)),
|
||||
curColW, fmtCurrentA(setCurrentA(exposedMM2ForThickness(s.ExposedMM2, etchThicknessMM[3]), etchCurrentDensity)),
|
||||
)
|
||||
drawTrackedText(page, faceBody, dpi, x, y, line, bodyTrack)
|
||||
y += 4.0
|
||||
if y > pageHmm-8.0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func setCurrentA(exposedMM2, currentDensityAperCM2 float64) float64 {
|
||||
if exposedMM2 <= 0 || currentDensityAperCM2 <= 0 {
|
||||
return 0
|
||||
}
|
||||
exposedCM2 := exposedMM2 / 100.0
|
||||
return exposedCM2 * currentDensityAperCM2
|
||||
}
|
||||
|
||||
func edgeAreaMM2ForThickness(thicknessMM float64) float64 {
|
||||
if thicknessMM <= 0 {
|
||||
return 0
|
||||
}
|
||||
return plateEdgeMM * thicknessMM
|
||||
}
|
||||
|
||||
func edgePctForThickness(thicknessMM float64) float64 {
|
||||
return pct(edgeAreaMM2ForThickness(thicknessMM), steelAreaMM2)
|
||||
}
|
||||
|
||||
func exposedMM2ForThickness(topExposedMM2, thicknessMM float64) float64 {
|
||||
return topExposedMM2 + edgeAreaMM2ForThickness(thicknessMM)
|
||||
}
|
||||
|
||||
func exposedPctForThickness(topExposedMM2, thicknessMM float64) float64 {
|
||||
return pct(exposedMM2ForThickness(topExposedMM2, thicknessMM), steelAreaMM2)
|
||||
}
|
||||
|
||||
func fmtPct(v float64) string {
|
||||
return fmt.Sprintf("%.1f%%", v)
|
||||
}
|
||||
|
||||
func fmtCurrentA(v float64) string {
|
||||
return fmt.Sprintf("%.2fA", v)
|
||||
}
|
||||
41
printer/layout_descriptor.go
Normal file
41
printer/layout_descriptor.go
Normal file
@ -0,0 +1,41 @@
|
||||
package printer
|
||||
|
||||
type descriptorSingleQRLayoutSpec struct {
|
||||
MarginMM float64
|
||||
LineGapMM float64
|
||||
PathGapMM float64
|
||||
QRXMM float64
|
||||
QRYMM float64
|
||||
DefaultSize float64
|
||||
}
|
||||
|
||||
type descriptorDualQRLayoutSpec struct {
|
||||
MarginMM float64
|
||||
LineGapMM float64
|
||||
GuideGapMM float64
|
||||
QRTopLimitMM float64
|
||||
QuietModules int
|
||||
OverlapIters int
|
||||
OverlapEpsilon float64
|
||||
}
|
||||
|
||||
var (
|
||||
descriptorSingleQRLayout = descriptorSingleQRLayoutSpec{
|
||||
MarginMM: 3.0,
|
||||
LineGapMM: 4.2,
|
||||
PathGapMM: 4.2,
|
||||
QRXMM: 12.0,
|
||||
QRYMM: 12.0,
|
||||
DefaultSize: 80.0,
|
||||
}
|
||||
|
||||
descriptorDualQRLayout = descriptorDualQRLayoutSpec{
|
||||
MarginMM: 3.0,
|
||||
LineGapMM: 4.2,
|
||||
GuideGapMM: 1.5,
|
||||
QRTopLimitMM: 24.0,
|
||||
QuietModules: 4,
|
||||
OverlapIters: 8,
|
||||
OverlapEpsilon: 0.001,
|
||||
}
|
||||
)
|
||||
67
printer/layout_descriptor_test.go
Normal file
67
printer/layout_descriptor_test.go
Normal file
@ -0,0 +1,67 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/kortschak/qr"
|
||||
)
|
||||
|
||||
func TestDescriptorSingleQRPlacement_Default(t *testing.T) {
|
||||
x, y, size := descriptorSingleQRPlacement(0)
|
||||
if size != descriptorSingleQRLayout.DefaultSize {
|
||||
t.Fatalf("size=%v want %v", size, descriptorSingleQRLayout.DefaultSize)
|
||||
}
|
||||
// Default anchor may clamp when default size would exceed plate bounds.
|
||||
expX, expY := descriptorSingleQRLayout.QRXMM, descriptorSingleQRLayout.QRYMM
|
||||
if expX+size > plateSizeMM {
|
||||
expX = plateSizeMM - size
|
||||
}
|
||||
if expY+size > plateSizeMM {
|
||||
expY = plateSizeMM - size
|
||||
}
|
||||
if x != expX || y != expY {
|
||||
t.Fatalf("xy=(%v,%v) want (%v,%v)", x, y, expX, expY)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescriptorSingleQRPlacement_Clamped(t *testing.T) {
|
||||
x, y, size := descriptorSingleQRPlacement(200)
|
||||
if size != 200 {
|
||||
t.Fatalf("size=%v want 200", size)
|
||||
}
|
||||
if x+size != plateSizeMM || y+size != plateSizeMM {
|
||||
t.Fatalf("placement not clamped to plate bounds: x=%v y=%v size=%v", x, y, size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescriptorDualQRPlacement(t *testing.T) {
|
||||
l, err := qr.Encode("UR:CRYPTO-OUTPUT/1-6/AAAAAAAAAAAAAAAAAAAAAAAAAAAA", descriptorQRECC)
|
||||
if err != nil {
|
||||
t.Fatalf("encode left: %v", err)
|
||||
}
|
||||
r, err := qr.Encode("UR:CRYPTO-OUTPUT/2-6/BBBBBBBBBBBBBBBBBBBBBBBBBBBB", descriptorQRECC)
|
||||
if err != nil {
|
||||
t.Fatalf("encode right: %v", err)
|
||||
}
|
||||
size, leftX, rightX, y := descriptorDualQRPlacement(l, r)
|
||||
if size <= 0 {
|
||||
t.Fatalf("size=%v", size)
|
||||
}
|
||||
if leftX != 0 {
|
||||
t.Fatalf("leftX=%v want 0", leftX)
|
||||
}
|
||||
if rightX <= leftX {
|
||||
t.Fatalf("rightX=%v <= leftX=%v", rightX, leftX)
|
||||
}
|
||||
if rightX+size > plateSizeMM+1e-6 {
|
||||
t.Fatalf("right qr spills plate: rightX=%v size=%v", rightX, size)
|
||||
}
|
||||
if math.Abs((y+size)-plateSizeMM) > 1e-6 {
|
||||
t.Fatalf("bottom alignment broken: y=%v size=%v", y, size)
|
||||
}
|
||||
// Shared quiet-zone collapse: second QR starts before full-size offset.
|
||||
if !(rightX < size) {
|
||||
t.Fatalf("expected shared quiet-zone overlap, rightX=%v size=%v", rightX, size)
|
||||
}
|
||||
}
|
||||
33
printer/layout_seed.go
Normal file
33
printer/layout_seed.go
Normal file
@ -0,0 +1,33 @@
|
||||
package printer
|
||||
|
||||
type seedPlateLayout struct {
|
||||
LeftColXMM float64
|
||||
RightColXMM float64
|
||||
QRLeftMM float64
|
||||
RightMetaText string
|
||||
ShareNum int
|
||||
ShareTotal int
|
||||
}
|
||||
|
||||
const (
|
||||
seedQRYBottomMarginMM = 10.0
|
||||
seedQRSizeMM = 32.0
|
||||
)
|
||||
|
||||
func defaultSeedPlateLayout(totalShares int, singlesigVariant bool) seedPlateLayout {
|
||||
layout := seedPlateLayout{
|
||||
LeftColXMM: 10.0,
|
||||
RightColXMM: 49.0,
|
||||
QRLeftMM: 49.0,
|
||||
}
|
||||
if totalShares == 1 || singlesigVariant {
|
||||
layout.LeftColXMM = 8.0
|
||||
layout.RightColXMM = 47.0
|
||||
layout.QRLeftMM = 47.0
|
||||
}
|
||||
return layout
|
||||
}
|
||||
|
||||
func seedQRYMM(qrSizeMM float64) float64 {
|
||||
return plateSizeMM - seedQRYBottomMarginMM - qrSizeMM
|
||||
}
|
||||
25
printer/layout_seed_test.go
Normal file
25
printer/layout_seed_test.go
Normal file
@ -0,0 +1,25 @@
|
||||
package printer
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultSeedPlateLayout_Multisig(t *testing.T) {
|
||||
l := defaultSeedPlateLayout(3, false)
|
||||
if l.LeftColXMM != 10.0 || l.RightColXMM != 49.0 || l.QRLeftMM != 49.0 {
|
||||
t.Fatalf("unexpected multisig layout: %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSeedPlateLayout_SinglesigVariant(t *testing.T) {
|
||||
l := defaultSeedPlateLayout(1, true)
|
||||
if l.LeftColXMM != 8.0 || l.RightColXMM != 47.0 || l.QRLeftMM != 47.0 {
|
||||
t.Fatalf("unexpected singlesig layout: %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedQRYMM(t *testing.T) {
|
||||
y := seedQRYMM(seedQRSizeMM)
|
||||
want := plateSizeMM - seedQRYBottomMarginMM - seedQRSizeMM
|
||||
if y != want {
|
||||
t.Fatalf("seedQRYMM=%v want %v", y, want)
|
||||
}
|
||||
}
|
||||
269
printer/page_overlay.go
Normal file
269
printer/page_overlay.go
Normal file
@ -0,0 +1,269 @@
|
||||
package printer
|
||||
|
||||
import "image"
|
||||
|
||||
const (
|
||||
transferOuterMarginMM = 5.0
|
||||
transferPlateInsetLeftMM = 5.0
|
||||
transferPlateInsetTopMM = 5.0
|
||||
transferPlateInsetBottomMM = 5.0
|
||||
transferRowGapMM = 0.0
|
||||
transferCutMarkLenMM = 1.8
|
||||
transferInstructionGapMM = 6.0
|
||||
transferInstructionMarginMM = 8.0
|
||||
)
|
||||
|
||||
func renderPlannedRow(rowPix []uint8, y int, page pagePlacement, invert bool) {
|
||||
for i := range rowPix {
|
||||
rowPix[i] = 0
|
||||
}
|
||||
|
||||
for _, box := range page.cutBoxes {
|
||||
if y < box.Min.Y || y >= box.Max.Y {
|
||||
continue
|
||||
}
|
||||
if invert {
|
||||
setBlackRange(rowPix, box.Min.X, box.Max.X-1)
|
||||
continue
|
||||
}
|
||||
if y == box.Min.Y || y == box.Max.Y-1 {
|
||||
setBlackRange(rowPix, box.Min.X, box.Max.X-1)
|
||||
continue
|
||||
}
|
||||
if box.Min.X >= 0 && box.Min.X < len(rowPix) {
|
||||
rowPix[box.Min.X] = 1
|
||||
}
|
||||
xr := box.Max.X - 1
|
||||
if xr >= 0 && xr < len(rowPix) {
|
||||
rowPix[xr] = 1
|
||||
}
|
||||
}
|
||||
|
||||
for _, slot := range page.slots {
|
||||
if slot.plate == nil {
|
||||
continue
|
||||
}
|
||||
pb := slot.plate.Bounds()
|
||||
ly := y - slot.y
|
||||
if ly < 0 || ly >= pb.Dy() {
|
||||
continue
|
||||
}
|
||||
src := slot.plate.Pix[(pb.Min.Y+ly)*slot.plate.Stride+pb.Min.X : (pb.Min.Y+ly)*slot.plate.Stride+pb.Min.X+pb.Dx()]
|
||||
dstStart := slot.x
|
||||
srcStart := 0
|
||||
n := len(src)
|
||||
if dstStart < 0 {
|
||||
srcStart = -dstStart
|
||||
dstStart = 0
|
||||
}
|
||||
if dstStart >= len(rowPix) || srcStart >= n {
|
||||
continue
|
||||
}
|
||||
n -= srcStart
|
||||
if dstStart+n > len(rowPix) {
|
||||
n = len(rowPix) - dstStart
|
||||
}
|
||||
if n > 0 {
|
||||
copy(rowPix[dstStart:dstStart+n], src[srcStart:srcStart+n])
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range page.marks {
|
||||
drawCutMarkRow(rowPix, y, m)
|
||||
}
|
||||
for _, ov := range page.overlays {
|
||||
blendOverlayRow(rowPix, y, ov)
|
||||
}
|
||||
}
|
||||
|
||||
func setBlackRange(rowPix []uint8, x0, x1 int) {
|
||||
if x0 > x1 {
|
||||
x0, x1 = x1, x0
|
||||
}
|
||||
if x1 < 0 || x0 >= len(rowPix) {
|
||||
return
|
||||
}
|
||||
if x0 < 0 {
|
||||
x0 = 0
|
||||
}
|
||||
if x1 >= len(rowPix) {
|
||||
x1 = len(rowPix) - 1
|
||||
}
|
||||
for x := x0; x <= x1; x++ {
|
||||
rowPix[x] = 1
|
||||
}
|
||||
}
|
||||
|
||||
func drawCutMarkRow(rowPix []uint8, y int, m cutMark) {
|
||||
x0, x1 := m.x0, m.x1
|
||||
y0, y1 := m.y0, m.y1
|
||||
if x0 > x1 {
|
||||
x0, x1 = x1, x0
|
||||
}
|
||||
if y0 > y1 {
|
||||
y0, y1 = y1, y0
|
||||
}
|
||||
|
||||
if y < y0 || y > y1 {
|
||||
return
|
||||
}
|
||||
if y0 == y1 {
|
||||
setBlackRange(rowPix, x0, x1)
|
||||
return
|
||||
}
|
||||
if x0 == x1 && x0 >= 0 && x0 < len(rowPix) {
|
||||
rowPix[x0] = 1
|
||||
}
|
||||
}
|
||||
|
||||
func blendOverlayRow(rowPix []uint8, y int, ov placedPlate) {
|
||||
if ov.plate == nil {
|
||||
return
|
||||
}
|
||||
b := ov.plate.Bounds()
|
||||
ly := y - ov.y
|
||||
if ly < 0 || ly >= b.Dy() {
|
||||
return
|
||||
}
|
||||
src := ov.plate.Pix[(b.Min.Y+ly)*ov.plate.Stride+b.Min.X : (b.Min.Y+ly)*ov.plate.Stride+b.Min.X+b.Dx()]
|
||||
for i, v := range src {
|
||||
if v == 0 {
|
||||
continue
|
||||
}
|
||||
x := ov.x + i
|
||||
if x < 0 || x >= len(rowPix) {
|
||||
continue
|
||||
}
|
||||
rowPix[x] = 1
|
||||
}
|
||||
}
|
||||
|
||||
func buildTransferCutMarks(grid image.Rectangle, vCuts, hCuts []int, dpi float64) []cutMark {
|
||||
markLen := mmToPx(transferCutMarkLenMM, dpi)
|
||||
if markLen < 1 {
|
||||
markLen = 1
|
||||
}
|
||||
left := grid.Min.X
|
||||
top := grid.Min.Y
|
||||
right := grid.Max.X - 1
|
||||
bottom := grid.Max.Y - 1
|
||||
marks := make([]cutMark, 0, 2*(len(vCuts)+len(hCuts)))
|
||||
|
||||
for _, x := range vCuts {
|
||||
xx := x
|
||||
if xx > left {
|
||||
xx--
|
||||
}
|
||||
marks = append(marks, cutMark{x0: xx, y0: top - markLen, x1: xx, y1: top - 1})
|
||||
marks = append(marks, cutMark{x0: xx, y0: bottom + 1, x1: xx, y1: bottom + markLen})
|
||||
}
|
||||
for _, y := range hCuts {
|
||||
yy := y
|
||||
if yy > top {
|
||||
yy--
|
||||
}
|
||||
marks = append(marks, cutMark{x0: left - markLen, y0: yy, x1: left - 1, y1: yy})
|
||||
marks = append(marks, cutMark{x0: right + 1, y0: yy, x1: right + markLen, y1: yy})
|
||||
}
|
||||
return marks
|
||||
}
|
||||
|
||||
func buildTransferInstructionOverlay(pageWpx, pageHpx int, dpi float64, gridBottom int, tapeXs []int, tapeLabelCenterAbsX, leftTextAbsX int) (placedPlate, bool) {
|
||||
marginPx := mmToPx(transferInstructionMarginMM, dpi)
|
||||
topPx := gridBottom + mmToPx(transferInstructionGapMM, dpi)
|
||||
width := pageWpx - 2*marginPx
|
||||
height := pageHpx - topPx - marginPx
|
||||
if width <= 0 || height <= 0 {
|
||||
return placedPlate{}, false
|
||||
}
|
||||
|
||||
img := image.NewPaletted(image.Rect(0, 0, width, height), bwPalette)
|
||||
face := loadFaceMedium(8, dpi)
|
||||
track := 0.02 * 8.0 * dpi / 72.0
|
||||
stepMM := 3.8
|
||||
pageWMM := float64(width) * 25.4 / dpi
|
||||
pxToMM := func(px int) float64 {
|
||||
return float64(px) * 25.4 / dpi
|
||||
}
|
||||
|
||||
yMM := 3.0 + capBaselineOffsetMM(face, dpi)
|
||||
caret := "^"
|
||||
caretWMM := trackedTextWidthMM(face, dpi, caret, track)
|
||||
for _, xAbs := range tapeXs {
|
||||
xRel := xAbs - marginPx
|
||||
if xRel < 0 || xRel >= width {
|
||||
continue
|
||||
}
|
||||
xMM := float64(xRel)*25.4/dpi - (caretWMM / 2)
|
||||
drawTrackedText(img, face, dpi, xMM, yMM, caret, track)
|
||||
}
|
||||
|
||||
tapeLine := "Add tape along these sides"
|
||||
tapeLine2 := "(left side when placed face down on plate)"
|
||||
tapeWMM := trackedTextWidthMM(face, dpi, tapeLine, track)
|
||||
tapeWMM2 := trackedTextWidthMM(face, dpi, tapeLine2, track)
|
||||
tapeCenterRel := tapeLabelCenterAbsX - marginPx
|
||||
if tapeCenterRel < 0 {
|
||||
tapeCenterRel = 0
|
||||
}
|
||||
if tapeCenterRel >= width {
|
||||
tapeCenterRel = width - 1
|
||||
}
|
||||
tapeXMM := pxToMM(tapeCenterRel) - tapeWMM/2
|
||||
if tapeXMM < 0 {
|
||||
tapeXMM = 0
|
||||
}
|
||||
maxTapeXMM := pageWMM - tapeWMM
|
||||
if tapeXMM > maxTapeXMM {
|
||||
tapeXMM = maxTapeXMM
|
||||
}
|
||||
tapeXMM2 := pxToMM(tapeCenterRel) - tapeWMM2/2
|
||||
if tapeXMM2 < 0 {
|
||||
tapeXMM2 = 0
|
||||
}
|
||||
maxTapeXMM2 := pageWMM - tapeWMM2
|
||||
if tapeXMM2 > maxTapeXMM2 {
|
||||
tapeXMM2 = maxTapeXMM2
|
||||
}
|
||||
drawTrackedText(img, face, dpi, tapeXMM, yMM, tapeLine, track)
|
||||
yMM += stepMM
|
||||
drawTrackedText(img, face, dpi, tapeXMM2, yMM, tapeLine2, track)
|
||||
|
||||
yMM += stepMM + 1.5
|
||||
leftRel := leftTextAbsX - marginPx
|
||||
if leftRel < 0 {
|
||||
leftRel = 0
|
||||
}
|
||||
if leftRel >= width {
|
||||
leftRel = width - 1
|
||||
}
|
||||
leftXMM := pxToMM(leftRel)
|
||||
maxTextWidthMM := pageWMM * 2.0 / 3.0
|
||||
if leftXMM+maxTextWidthMM > pageWMM {
|
||||
maxTextWidthMM = pageWMM - leftXMM
|
||||
}
|
||||
if maxTextWidthMM < 5.0 {
|
||||
maxTextWidthMM = pageWMM - leftXMM
|
||||
}
|
||||
|
||||
messages := []string{
|
||||
"Cut layouts at cut marks.",
|
||||
"To position on plate, flip the mask left-right (mirror), place it face down, and align the top and right edges to the plate.",
|
||||
"Add a small thin strip of masking tape along the left side",
|
||||
"to secure the mask before transfer.",
|
||||
"DO NOT TOUCH THE TONER! Use a clean surface and ruler to cut.",
|
||||
"Do not scratch the toner mask.",
|
||||
}
|
||||
for _, msg := range messages {
|
||||
lines := wrapTextTracked(face, dpi, msg, maxTextWidthMM, track)
|
||||
for _, ln := range lines {
|
||||
if mmToPx(yMM, dpi) >= height-2 {
|
||||
break
|
||||
}
|
||||
drawTrackedText(img, face, dpi, leftXMM, yMM, ln, track)
|
||||
yMM += stepMM
|
||||
}
|
||||
}
|
||||
|
||||
return placedPlate{plate: img, x: marginPx, y: topPx}, true
|
||||
}
|
||||
533
printer/pcl.go
533
printer/pcl.go
@ -3,11 +3,8 @@ package printer
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"io"
|
||||
"math"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
type progressWriter struct {
|
||||
@ -18,6 +15,75 @@ type progressWriter struct {
|
||||
progress ProgressFunc
|
||||
}
|
||||
|
||||
type placedPlate struct {
|
||||
plate *image.Paletted
|
||||
x int
|
||||
y int
|
||||
}
|
||||
|
||||
type pagePlacement struct {
|
||||
slots []placedPlate
|
||||
cutBoxes []image.Rectangle
|
||||
marks []cutMark
|
||||
overlays []placedPlate
|
||||
}
|
||||
|
||||
type cutMark struct {
|
||||
x0 int
|
||||
y0 int
|
||||
x1 int
|
||||
y1 int
|
||||
}
|
||||
|
||||
type placementPlan struct {
|
||||
pageWpx int
|
||||
pageHpx int
|
||||
pages []pagePlacement
|
||||
}
|
||||
|
||||
const (
|
||||
// Direct host backends (raw PCL/PS) tend to clip near page top versus CUPS/HBP.
|
||||
// Keep HBP/layout geometry unchanged and apply a small emit-time translation here.
|
||||
hostDirectTopOffsetMM = 5.0
|
||||
// Raw PCL path is typically shifted right; compensate left to restore centering.
|
||||
hostPCLLeftOffsetMM = -5.0
|
||||
)
|
||||
|
||||
func offsetPagePlacement(p pagePlacement, dx, dy int) pagePlacement {
|
||||
out := pagePlacement{
|
||||
slots: make([]placedPlate, 0, len(p.slots)),
|
||||
cutBoxes: make([]image.Rectangle, 0, len(p.cutBoxes)),
|
||||
marks: make([]cutMark, 0, len(p.marks)),
|
||||
overlays: make([]placedPlate, 0, len(p.overlays)),
|
||||
}
|
||||
for _, s := range p.slots {
|
||||
out.slots = append(out.slots, placedPlate{
|
||||
plate: s.plate,
|
||||
x: s.x + dx,
|
||||
y: s.y + dy,
|
||||
})
|
||||
}
|
||||
for _, b := range p.cutBoxes {
|
||||
out.cutBoxes = append(out.cutBoxes, b.Add(image.Pt(dx, dy)))
|
||||
}
|
||||
for _, m := range p.marks {
|
||||
out.marks = append(out.marks, cutMark{
|
||||
x0: m.x0 + dx,
|
||||
y0: m.y0 + dy,
|
||||
x1: m.x1 + dx,
|
||||
y1: m.y1 + dy,
|
||||
})
|
||||
}
|
||||
for _, ov := range p.overlays {
|
||||
out.overlays = append(out.overlays, placedPlate{
|
||||
plate: ov.plate,
|
||||
x: ov.x + dx,
|
||||
y: ov.y + dy,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newProgressWriter(stage PrintStage, w io.Writer, total int64, progress ProgressFunc) *progressWriter {
|
||||
return &progressWriter{
|
||||
w: w,
|
||||
@ -36,111 +102,139 @@ func (pw *progressWriter) Write(b []byte) (int, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ComposePages assembles plate bitmaps into A4/Letter pages (2x3 grid), matching the PDF layout.
|
||||
// ComposePages assembles plate bitmaps into transfer-mask pages.
|
||||
// Mirrors/inversion should be handled at the plate level via RasterOptions.
|
||||
// progress, if set, receives StageCompose updates as slots are placed.
|
||||
func ComposePages(seedPlates, descPlates []*image.Paletted, paper PaperSize, dpi float64, progress ProgressFunc) ([]*image.Paletted, error) {
|
||||
if len(seedPlates) == 0 {
|
||||
return nil, fmt.Errorf("no seed plates to compose")
|
||||
}
|
||||
pageWmm, pageHmm, ok := paperDimsMM(paper)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported paper size: %v", paper)
|
||||
}
|
||||
pageWpx := mmToPx(pageWmm, dpi)
|
||||
pageHpx := mmToPx(pageHmm, dpi)
|
||||
targetGapPx := mmToPx(2, dpi) // desired gap between plates
|
||||
targetMarginPx := mmToPx(5, dpi) // desired margin to page edges
|
||||
return ComposePagesWithInvert(seedPlates, descPlates, paper, dpi, false, progress)
|
||||
}
|
||||
|
||||
hasDesc := descPlates != nil && len(descPlates) == len(seedPlates)
|
||||
totalShares := len(seedPlates)
|
||||
sharesPerPage := 3 // matches PDF layout logic
|
||||
|
||||
// Total slots we expect to place (for progress).
|
||||
totalSlots := totalShares
|
||||
if hasDesc {
|
||||
totalSlots *= 2
|
||||
// ComposePagesWithInvert composes pages and applies transfer-mask overlay behavior
|
||||
// that depends on inverted plate rendering.
|
||||
func ComposePagesWithInvert(seedPlates, descPlates []*image.Paletted, paper PaperSize, dpi float64, invert bool, progress ProgressFunc) ([]*image.Paletted, error) {
|
||||
plan, err := buildPlacementPlan(seedPlates, descPlates, paper, dpi, invert, progress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if progress != nil && totalSlots > 0 {
|
||||
progress(StageCompose, 0, int64(totalSlots))
|
||||
}
|
||||
placed := int64(0)
|
||||
|
||||
var pages []*image.Paletted
|
||||
for page := 0; page*sharesPerPage < totalShares; page++ {
|
||||
start := page * sharesPerPage
|
||||
end := minInt(start+sharesPerPage, totalShares)
|
||||
var slots []*image.Paletted
|
||||
|
||||
// Build the page slot list in the same order as PDF layout
|
||||
if hasDesc {
|
||||
for i := start; i < end; i++ {
|
||||
slots = append(slots, seedPlates[i], descPlates[i])
|
||||
}
|
||||
} else {
|
||||
for i := start; i < end; i++ {
|
||||
slots = append(slots, seedPlates[i])
|
||||
}
|
||||
}
|
||||
|
||||
pageImg := image.NewPaletted(image.Rect(0, 0, pageWpx, pageHpx), bwPalette)
|
||||
draw.Draw(pageImg, pageImg.Bounds(), &image.Uniform{bwPalette[0]}, image.Point{}, draw.Src)
|
||||
|
||||
// Determine rows/cols for this page
|
||||
slotsThisPage := len(slots)
|
||||
cols := 2
|
||||
rows := (slotsThisPage + cols - 1) / cols
|
||||
|
||||
// Baseline plate size (assume all plates same dims)
|
||||
var baseW, baseH int
|
||||
for _, pl := range slots {
|
||||
if pl != nil {
|
||||
b := pl.Bounds()
|
||||
baseW, baseH = b.Dx(), b.Dy()
|
||||
break
|
||||
}
|
||||
}
|
||||
if baseW == 0 || baseH == 0 {
|
||||
return nil, fmt.Errorf("invalid plate dimensions")
|
||||
}
|
||||
|
||||
// Compute scaling to fit within margins/gaps
|
||||
availW := pageWpx - 2*targetMarginPx - targetGapPx*(cols-1)
|
||||
availH := pageHpx - 2*targetMarginPx - targetGapPx*(rows-1)
|
||||
scale := math.Min(1, math.Min(float64(availW)/(float64(baseW)*float64(cols)), float64(availH)/(float64(baseH)*float64(rows))))
|
||||
plateW := int(math.Round(float64(baseW) * scale))
|
||||
plateH := int(math.Round(float64(baseH) * scale))
|
||||
gapPx := targetGapPx
|
||||
marginX := (pageWpx - (cols*plateW + (cols-1)*gapPx)) / 2
|
||||
marginY := (pageHpx - (rows*plateH + (rows-1)*gapPx)) / 2
|
||||
|
||||
// Place slots
|
||||
for slotIdx, plate := range slots {
|
||||
if plate == nil {
|
||||
continue
|
||||
}
|
||||
row := slotIdx / 2
|
||||
col := slotIdx % 2
|
||||
dst := image.NewPaletted(image.Rect(0, 0, plateW, plateH), plate.Palette)
|
||||
xdraw.NearestNeighbor.Scale(dst, dst.Bounds(), plate, plate.Bounds(), xdraw.Src, nil)
|
||||
|
||||
offset := image.Point{
|
||||
X: marginX + col*(plateW+gapPx),
|
||||
Y: marginY + row*(plateH+gapPx),
|
||||
}
|
||||
r := image.Rectangle{Min: offset, Max: offset.Add(dst.Bounds().Size())}
|
||||
draw.Draw(pageImg, r, dst, image.Point{}, draw.Src)
|
||||
|
||||
placed++
|
||||
if progress != nil && totalSlots > 0 {
|
||||
progress(StageCompose, placed, int64(totalSlots))
|
||||
}
|
||||
for _, page := range plan.pages {
|
||||
pageImg := image.NewPaletted(image.Rect(0, 0, plan.pageWpx, plan.pageHpx), bwPalette)
|
||||
for y := 0; y < plan.pageHpx; y++ {
|
||||
row := pageImg.Pix[y*pageImg.Stride : y*pageImg.Stride+plan.pageWpx]
|
||||
renderPlannedRow(row, y, page, invert)
|
||||
}
|
||||
pages = append(pages, pageImg)
|
||||
}
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
// WritePCLPlates composes seed/descriptor plates directly into a PCL raster job
|
||||
// without creating full-page intermediate images.
|
||||
func WritePCLPlates(w io.Writer, seedPlates, descPlates []*image.Paletted, dpi float64, paper PaperSize, progress ProgressFunc) error {
|
||||
return WritePCLPlatesWithInvert(w, seedPlates, descPlates, dpi, paper, false, progress)
|
||||
}
|
||||
|
||||
// WritePCLPlatesWithInvert composes plates directly into a PCL stream with
|
||||
// transfer-mask overlays controlled by invert mode.
|
||||
func WritePCLPlatesWithInvert(w io.Writer, seedPlates, descPlates []*image.Paletted, dpi float64, paper PaperSize, invert bool, progress ProgressFunc) error {
|
||||
plan, err := buildPlacementPlan(seedPlates, descPlates, paper, dpi, invert, progress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paperCode, ok := paperCode(paper)
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported paper size: %v", paper)
|
||||
}
|
||||
totalBytes, err := estimatePCLBytesForPlan(plan, dpi, paper)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pw := newProgressWriter(StageSend, w, totalBytes, progress)
|
||||
if progress != nil && totalBytes > 0 {
|
||||
progress(StageSend, 0, totalBytes)
|
||||
}
|
||||
|
||||
uel := []byte{0x1b, '%', '-', '1', '2', '3', '4', '5', 'X'}
|
||||
if _, err := pw.Write(uel); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := pw.Write([]byte("@PJL ENTER LANGUAGE = PCL\r\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
width := plan.pageWpx
|
||||
height := plan.pageHpx
|
||||
rowBytes := (width + 7) / 8
|
||||
rowPix := make([]uint8, width)
|
||||
rowPacked := make([]byte, rowBytes)
|
||||
dx := mmToPx(hostPCLLeftOffsetMM, dpi)
|
||||
dy := mmToPx(hostDirectTopOffsetMM, dpi)
|
||||
|
||||
for _, page := range plan.pages {
|
||||
if _, err := fmt.Fprintf(pw, "\x1bE"); err != nil { // reset
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(pw, "\x1b&l%dA", paperCode); err != nil { // paper size
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(pw, "\x1b&l0E"); err != nil { // top margin = 0 lines
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(pw, "\x1b*t%dR", int(math.Round(dpi))); err != nil { // resolution
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(pw, "\x1b*r%dS", width); err != nil { // source width (pixels)
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(pw, "\x1b*r%dT", height); err != nil { // source height (rows)
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(pw, "\x1b*p0x0Y"); err != nil { // move to origin
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(pw, "\x1b*b0M"); err != nil { // compression: unencoded
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(pw, "\x1b*r0F"); err != nil { // start raster graphics
|
||||
return err
|
||||
}
|
||||
renderPage := offsetPagePlacement(page, dx, dy)
|
||||
|
||||
for y := 0; y < height; y++ {
|
||||
renderPlannedRow(rowPix, y, renderPage, invert)
|
||||
packBits(rowPacked, rowPix)
|
||||
if _, err := fmt.Fprintf(pw, "\x1b*b%dW", rowBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := pw.Write(rowPacked); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(pw, "\x1b*rC"); err != nil { // end raster graphics
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(pw, "\x0c"); err != nil { // form feed
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := pw.Write(uel); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EstimatePCLPlatesBytes estimates the raw PCL byte size for a plate-set job.
|
||||
// Useful for aggregating multi-batch send progress.
|
||||
func EstimatePCLPlatesBytes(seedPlates, descPlates []*image.Paletted, dpi float64, paper PaperSize) (int64, error) {
|
||||
plan, err := buildPlacementPlan(seedPlates, descPlates, paper, dpi, false, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return estimatePCLBytesForPlan(plan, dpi, paper)
|
||||
}
|
||||
|
||||
func estimatePCLBytes(pages []*image.Paletted, dpi float64, paper PaperSize) (int64, error) {
|
||||
paperCode, ok := paperCode(paper)
|
||||
if !ok {
|
||||
@ -249,9 +343,12 @@ func WritePCL(w io.Writer, pages []*image.Paletted, dpi float64, paper PaperSize
|
||||
|
||||
rowBytes := (width + 7) / 8
|
||||
buf := make([]byte, rowBytes)
|
||||
rowPix := make([]uint8, width)
|
||||
dx := mmToPx(hostPCLLeftOffsetMM, dpi)
|
||||
dy := mmToPx(hostDirectTopOffsetMM, dpi)
|
||||
for y := 0; y < height; y++ {
|
||||
pix := page.Pix[y*page.Stride : y*page.Stride+width]
|
||||
packBits(buf, pix)
|
||||
renderShiftedPageRow(rowPix, page, y, dx, dy)
|
||||
packBits(buf, rowPix)
|
||||
if _, err := fmt.Fprintf(pw, "\x1b*b%dW", rowBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -308,9 +405,249 @@ func packBits(dst []byte, row []uint8) {
|
||||
}
|
||||
}
|
||||
|
||||
func renderShiftedPageRow(dst []uint8, page *image.Paletted, y, dx, dy int) {
|
||||
for i := range dst {
|
||||
dst[i] = 0
|
||||
}
|
||||
if page == nil {
|
||||
return
|
||||
}
|
||||
b := page.Bounds()
|
||||
srcY := y - dy
|
||||
if srcY < 0 || srcY >= b.Dy() {
|
||||
return
|
||||
}
|
||||
src := page.Pix[(b.Min.Y+srcY)*page.Stride+b.Min.X : (b.Min.Y+srcY)*page.Stride+b.Min.X+b.Dx()]
|
||||
dstStart := dx
|
||||
srcStart := 0
|
||||
n := len(src)
|
||||
if dstStart < 0 {
|
||||
srcStart = -dstStart
|
||||
dstStart = 0
|
||||
}
|
||||
if dstStart >= len(dst) || srcStart >= n {
|
||||
return
|
||||
}
|
||||
n -= srcStart
|
||||
if dstStart+n > len(dst) {
|
||||
n = len(dst) - dstStart
|
||||
}
|
||||
if n > 0 {
|
||||
copy(dst[dstStart:dstStart+n], src[srcStart:srcStart+n])
|
||||
}
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func buildPlacementPlan(seedPlates, descPlates []*image.Paletted, paper PaperSize, dpi float64, invert bool, progress ProgressFunc) (placementPlan, error) {
|
||||
if len(seedPlates) == 0 {
|
||||
return placementPlan{}, fmt.Errorf("no seed plates to compose")
|
||||
}
|
||||
pageWmm, pageHmm, ok := paperDimsMM(paper)
|
||||
if !ok {
|
||||
return placementPlan{}, fmt.Errorf("unsupported paper size: %v", paper)
|
||||
}
|
||||
pageWpx := mmToPx(pageWmm, dpi)
|
||||
pageHpx := mmToPx(pageHmm, dpi)
|
||||
outerMarginPx := mmToPx(transferOuterMarginMM, dpi)
|
||||
rowGapPx := mmToPx(transferRowGapMM, dpi)
|
||||
insetLeftPx := mmToPx(transferPlateInsetLeftMM, dpi)
|
||||
insetTopPx := mmToPx(transferPlateInsetTopMM, dpi)
|
||||
insetBottomPx := mmToPx(transferPlateInsetBottomMM, dpi)
|
||||
if outerMarginPx < 0 {
|
||||
outerMarginPx = 0
|
||||
}
|
||||
if rowGapPx < 0 {
|
||||
rowGapPx = 0
|
||||
}
|
||||
if insetLeftPx < 0 {
|
||||
insetLeftPx = 0
|
||||
}
|
||||
if insetTopPx < 0 {
|
||||
insetTopPx = 0
|
||||
}
|
||||
if insetBottomPx < 0 {
|
||||
insetBottomPx = 0
|
||||
}
|
||||
|
||||
hasDesc := descPlates != nil && len(descPlates) == len(seedPlates)
|
||||
totalShares := len(seedPlates)
|
||||
maxSlotsPerPage := 4 // Fixed 2x2 transfer-mask layout on both A4 and Letter.
|
||||
slotsPerShare := 1
|
||||
if hasDesc {
|
||||
slotsPerShare = 2
|
||||
}
|
||||
sharesPerPage := maxSlotsPerPage / slotsPerShare
|
||||
if sharesPerPage < 1 {
|
||||
sharesPerPage = 1
|
||||
}
|
||||
|
||||
totalSlots := totalShares
|
||||
if hasDesc {
|
||||
totalSlots *= 2
|
||||
}
|
||||
if progress != nil && totalSlots > 0 {
|
||||
progress(StageCompose, 0, int64(totalSlots))
|
||||
}
|
||||
placed := int64(0)
|
||||
|
||||
var pages []pagePlacement
|
||||
for page := 0; page*sharesPerPage < totalShares; page++ {
|
||||
start := page * sharesPerPage
|
||||
end := minInt(start+sharesPerPage, totalShares)
|
||||
var slots []*image.Paletted
|
||||
if hasDesc {
|
||||
for i := start; i < end; i++ {
|
||||
slots = append(slots, seedPlates[i], descPlates[i])
|
||||
}
|
||||
} else {
|
||||
for i := start; i < end; i++ {
|
||||
slots = append(slots, seedPlates[i])
|
||||
}
|
||||
}
|
||||
slotsThisPage := len(slots)
|
||||
cols := minInt(2, slotsThisPage)
|
||||
if cols < 1 {
|
||||
cols = 1
|
||||
}
|
||||
rows := (slotsThisPage + cols - 1) / cols
|
||||
baseW, baseH, err := basePlateDims(slots)
|
||||
if err != nil {
|
||||
return placementPlan{}, err
|
||||
}
|
||||
plateW := baseW
|
||||
plateH := baseH
|
||||
cutBoxW := plateW + insetLeftPx
|
||||
cutBoxH := plateH + insetTopPx + insetBottomPx
|
||||
gapX := 0
|
||||
gridW := cols*cutBoxW + (cols-1)*gapX
|
||||
gridH := rows*cutBoxH + (rows-1)*rowGapPx
|
||||
reqW := gridW + 2*outerMarginPx
|
||||
reqH := gridH + 2*outerMarginPx
|
||||
if reqW > pageWpx || reqH > pageHpx {
|
||||
return placementPlan{}, fmt.Errorf("plates do not fit page at fixed size (paper=%s req=%dx%d page=%dx%d)", paper, reqW, reqH, pageWpx, pageHpx)
|
||||
}
|
||||
marginX := (pageWpx - gridW) / 2
|
||||
marginY := outerMarginPx
|
||||
|
||||
pp := pagePlacement{
|
||||
slots: make([]placedPlate, 0, len(slots)),
|
||||
cutBoxes: make([]image.Rectangle, 0, len(slots)),
|
||||
}
|
||||
for slotIdx, plate := range slots {
|
||||
if plate == nil {
|
||||
continue
|
||||
}
|
||||
b := plate.Bounds()
|
||||
if b.Dx() != plateW || b.Dy() != plateH {
|
||||
return placementPlan{}, fmt.Errorf("mismatched plate dimensions: got %dx%d want %dx%d", b.Dx(), b.Dy(), plateW, plateH)
|
||||
}
|
||||
row := slotIdx / cols
|
||||
col := slotIdx % cols
|
||||
cellX := marginX + col*(cutBoxW+gapX)
|
||||
cellY := marginY + row*(cutBoxH+rowGapPx)
|
||||
pp.slots = append(pp.slots, placedPlate{
|
||||
plate: plate,
|
||||
x: cellX + insetLeftPx,
|
||||
y: cellY + insetTopPx,
|
||||
})
|
||||
pp.cutBoxes = append(pp.cutBoxes, image.Rect(cellX, cellY, cellX+cutBoxW, cellY+cutBoxH))
|
||||
placed++
|
||||
if progress != nil && totalSlots > 0 {
|
||||
progress(StageCompose, placed, int64(totalSlots))
|
||||
}
|
||||
}
|
||||
|
||||
grid := image.Rect(marginX, marginY, marginX+gridW, marginY+gridH)
|
||||
vCuts := make([]int, 0, cols+1)
|
||||
for c := 0; c <= cols; c++ {
|
||||
vCuts = append(vCuts, marginX+c*(cutBoxW+gapX))
|
||||
}
|
||||
hCuts := make([]int, 0, rows+1)
|
||||
for r := 0; r <= rows; r++ {
|
||||
hCuts = append(hCuts, marginY+r*(cutBoxH+rowGapPx))
|
||||
}
|
||||
pp.marks = buildTransferCutMarks(grid, vCuts, hCuts, dpi)
|
||||
|
||||
tapeXs := make([]int, 0, cols)
|
||||
for c := 0; c < cols; c++ {
|
||||
tapeXs = append(tapeXs, marginX+(c+1)*(cutBoxW+gapX))
|
||||
}
|
||||
tapeLabelCenterX := marginX + cutBoxW/2
|
||||
if len(pp.cutBoxes) > 0 {
|
||||
rightBox := pp.cutBoxes[0]
|
||||
for _, b := range pp.cutBoxes[1:] {
|
||||
if b.Min.X > rightBox.Min.X {
|
||||
rightBox = b
|
||||
}
|
||||
}
|
||||
tapeLabelCenterX = rightBox.Min.X + rightBox.Dx()/2
|
||||
}
|
||||
if overlay, ok := buildTransferInstructionOverlay(pageWpx, pageHpx, dpi, grid.Max.Y, tapeXs, tapeLabelCenterX, grid.Min.X); ok {
|
||||
pp.overlays = append(pp.overlays, overlay)
|
||||
}
|
||||
pages = append(pages, pp)
|
||||
}
|
||||
return placementPlan{pageWpx: pageWpx, pageHpx: pageHpx, pages: pages}, nil
|
||||
}
|
||||
|
||||
func basePlateDims(slots []*image.Paletted) (int, int, error) {
|
||||
for _, pl := range slots {
|
||||
if pl != nil {
|
||||
b := pl.Bounds()
|
||||
if b.Dx() <= 0 || b.Dy() <= 0 {
|
||||
return 0, 0, fmt.Errorf("invalid plate dimensions")
|
||||
}
|
||||
return b.Dx(), b.Dy(), nil
|
||||
}
|
||||
}
|
||||
return 0, 0, fmt.Errorf("invalid plate dimensions")
|
||||
}
|
||||
|
||||
func estimatePCLBytesForPlan(plan placementPlan, dpi float64, paper PaperSize) (int64, error) {
|
||||
if _, ok := paperCode(paper); !ok {
|
||||
return 0, fmt.Errorf("unsupported paper size: %v", paper)
|
||||
}
|
||||
if plan.pageWpx <= 0 || plan.pageHpx <= 0 {
|
||||
return 0, fmt.Errorf("invalid page dimensions")
|
||||
}
|
||||
total := int64(0)
|
||||
uel := []byte{0x1b, '%', '-', '1', '2', '3', '4', '5', 'X'}
|
||||
total += int64(len(uel))
|
||||
total += int64(len([]byte("@PJL ENTER LANGUAGE = PCL\r\n")))
|
||||
rowBytes := (plan.pageWpx + 7) / 8
|
||||
perRowPrefix := fmt.Sprintf("\x1b*b%dW", rowBytes)
|
||||
for range plan.pages {
|
||||
resetSeq := []string{"\x1bE", fmt.Sprintf("\x1b&l%dA", mustPaperCode(paper)), "\x1b&l0E"}
|
||||
for _, seq := range resetSeq {
|
||||
total += int64(len(seq))
|
||||
}
|
||||
pageSeq := []string{
|
||||
fmt.Sprintf("\x1b*t%dR", int(math.Round(dpi))),
|
||||
fmt.Sprintf("\x1b*r%dS", plan.pageWpx),
|
||||
fmt.Sprintf("\x1b*r%dT", plan.pageHpx),
|
||||
"\x1b*p0x0Y",
|
||||
"\x1b*b0M",
|
||||
"\x1b*r0F",
|
||||
}
|
||||
for _, seq := range pageSeq {
|
||||
total += int64(len(seq))
|
||||
}
|
||||
rowChunk := int64(len(perRowPrefix) + rowBytes)
|
||||
total += int64(plan.pageHpx) * rowChunk
|
||||
total += int64(len("\x1b*rC"))
|
||||
total += int64(len("\x0c"))
|
||||
}
|
||||
total += int64(len(uel))
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func mustPaperCode(p PaperSize) int {
|
||||
code, _ := paperCode(p)
|
||||
return code
|
||||
}
|
||||
|
||||
51
printer/pcl_test.go
Normal file
51
printer/pcl_test.go
Normal file
@ -0,0 +1,51 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWritePCLPlatesMatchesComposedWritePCL(t *testing.T) {
|
||||
seedPlates := []*image.Paletted{
|
||||
testPlate(20, 20, 1),
|
||||
testPlate(20, 20, 2),
|
||||
testPlate(20, 20, 3),
|
||||
}
|
||||
descPlates := []*image.Paletted{
|
||||
testPlate(20, 20, 4),
|
||||
testPlate(20, 20, 5),
|
||||
testPlate(20, 20, 6),
|
||||
}
|
||||
const dpi = 10.0
|
||||
|
||||
pages, err := ComposePages(seedPlates, descPlates, PaperLetter, dpi, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ComposePages: %v", err)
|
||||
}
|
||||
var composed bytes.Buffer
|
||||
if err := WritePCL(&composed, pages, dpi, PaperLetter, nil); err != nil {
|
||||
t.Fatalf("WritePCL: %v", err)
|
||||
}
|
||||
|
||||
var direct bytes.Buffer
|
||||
if err := WritePCLPlates(&direct, seedPlates, descPlates, dpi, PaperLetter, nil); err != nil {
|
||||
t.Fatalf("WritePCLPlates: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(composed.Bytes(), direct.Bytes()) {
|
||||
t.Fatalf("PCL mismatch: composed=%d bytes direct=%d bytes", composed.Len(), direct.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func testPlate(w, h, pattern int) *image.Paletted {
|
||||
img := image.NewPaletted(image.Rect(0, 0, w, h), bwPalette)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
if ((x+y)*pattern)%7 < 3 {
|
||||
img.Pix[y*img.Stride+x] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
@ -52,3 +52,117 @@ func WritePDFRaster(w io.Writer, pages []*image.Paletted, paper PaperSize) error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WritePDFPlates writes seed/descriptor plate bitmaps into a PDF without
|
||||
// materializing full-page raster images first.
|
||||
func WritePDFPlates(w io.Writer, seedPlates, descPlates []*image.Paletted, paper PaperSize, dpi float64) error {
|
||||
return WritePDFPlatesWithInvert(w, seedPlates, descPlates, paper, dpi, false)
|
||||
}
|
||||
|
||||
// WritePDFPlatesWithInvert writes plates with transfer-mask overlays controlled
|
||||
// by invert mode, without materializing full-page raster images first.
|
||||
func WritePDFPlatesWithInvert(w io.Writer, seedPlates, descPlates []*image.Paletted, paper PaperSize, dpi float64, invert bool) error {
|
||||
plan, err := buildPlacementPlan(seedPlates, descPlates, paper, dpi, invert, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(plan.pages) == 0 {
|
||||
return fmt.Errorf("no pages to write")
|
||||
}
|
||||
pageWmm, pageHmm, ok := paperDimsMM(paper)
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported paper size: %v", paper)
|
||||
}
|
||||
|
||||
pdf := gofpdf.NewCustom(&gofpdf.InitType{
|
||||
UnitStr: "mm",
|
||||
Size: gofpdf.SizeType{Wd: pageWmm, Ht: pageHmm},
|
||||
})
|
||||
pdf.SetMargins(0, 0, 0)
|
||||
pdf.SetAutoPageBreak(false, 0)
|
||||
|
||||
imgOpts := gofpdf.ImageOptions{
|
||||
ImageType: "PNG",
|
||||
ReadDpi: false,
|
||||
}
|
||||
imgIdx := 0
|
||||
pxToMM := func(px int) float64 {
|
||||
return float64(px) * 25.4 / dpi
|
||||
}
|
||||
|
||||
for _, page := range plan.pages {
|
||||
pdf.AddPage()
|
||||
pdf.SetDrawColor(0, 0, 0)
|
||||
pdf.SetLineWidth(0.2)
|
||||
if invert {
|
||||
pdf.SetFillColor(0, 0, 0)
|
||||
}
|
||||
for _, box := range page.cutBoxes {
|
||||
x := pxToMM(box.Min.X)
|
||||
y := pxToMM(box.Min.Y)
|
||||
wmm := pxToMM(box.Dx())
|
||||
hmm := pxToMM(box.Dy())
|
||||
if invert {
|
||||
pdf.Rect(x, y, wmm, hmm, "F")
|
||||
} else {
|
||||
pdf.Rect(x, y, wmm, hmm, "D")
|
||||
}
|
||||
}
|
||||
for _, slot := range page.slots {
|
||||
if slot.plate == nil {
|
||||
continue
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, slot.plate); err != nil {
|
||||
return fmt.Errorf("encode plate image: %w", err)
|
||||
}
|
||||
name := fmt.Sprintf("plate-%d", imgIdx)
|
||||
imgIdx++
|
||||
pdf.RegisterImageOptionsReader(name, imgOpts, bytes.NewReader(buf.Bytes()))
|
||||
b := slot.plate.Bounds()
|
||||
pdf.ImageOptions(
|
||||
name,
|
||||
pxToMM(slot.x),
|
||||
pxToMM(slot.y),
|
||||
pxToMM(b.Dx()),
|
||||
pxToMM(b.Dy()),
|
||||
false,
|
||||
imgOpts,
|
||||
0,
|
||||
"",
|
||||
)
|
||||
}
|
||||
for _, mark := range page.marks {
|
||||
pdf.Line(pxToMM(mark.x0), pxToMM(mark.y0), pxToMM(mark.x1), pxToMM(mark.y1))
|
||||
}
|
||||
for _, ov := range page.overlays {
|
||||
if ov.plate == nil {
|
||||
continue
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, ov.plate); err != nil {
|
||||
return fmt.Errorf("encode overlay image: %w", err)
|
||||
}
|
||||
name := fmt.Sprintf("overlay-%d", imgIdx)
|
||||
imgIdx++
|
||||
pdf.RegisterImageOptionsReader(name, imgOpts, bytes.NewReader(buf.Bytes()))
|
||||
b := ov.plate.Bounds()
|
||||
pdf.ImageOptions(
|
||||
name,
|
||||
pxToMM(ov.x),
|
||||
pxToMM(ov.y),
|
||||
pxToMM(b.Dx()),
|
||||
pxToMM(b.Dy()),
|
||||
false,
|
||||
imgOpts,
|
||||
0,
|
||||
"",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := pdf.Output(w); err != nil {
|
||||
return fmt.Errorf("write pdf: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
231
printer/plate_descriptor.go
Normal file
231
printer/plate_descriptor.go
Normal file
@ -0,0 +1,231 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/kortschak/qr"
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/descriptor/shard"
|
||||
)
|
||||
|
||||
// RenderDescriptorPlateBitmap mirrors the descriptor PDF layout at 600dpi as a 1-bit paletted image.
|
||||
func RenderDescriptorPlateBitmap(desc *urtypes.OutputDescriptor, keyIdx, shareNum, totalShares int, opts RasterOptions, qrPayloads []string) (*image.Paletted, error) {
|
||||
if desc == nil {
|
||||
return nil, fmt.Errorf("descriptor is nil")
|
||||
}
|
||||
dpi := opts.dpi()
|
||||
canvas := newPlateCanvas(dpi)
|
||||
blackIdx := uint8(1)
|
||||
|
||||
border := mmToPx(borderWidthMM, dpi)
|
||||
if border < 1 {
|
||||
border = 1
|
||||
}
|
||||
strokeRect(canvas, 0, 0, canvas.Bounds().Dx(), canvas.Bounds().Dy(), border, blackIdx)
|
||||
|
||||
descriptorFace := loadFace(11, dpi)
|
||||
pathStr := derivationPathForKey(desc.Keys[keyIdx], desc.Script)
|
||||
pathText := fmt.Sprintf("PATH:%s", pathStr)
|
||||
descTrackPx := 0.04 * 11.0 * dpi / 72.0 // Affinity tracking as percent of em
|
||||
|
||||
key := desc.Keys[keyIdx]
|
||||
typeTag := fmt.Sprintf("TYPE:%s", desc.Type.Tag())
|
||||
scriptTag := fmt.Sprintf("SCRIPT:%s", desc.Script.Tag())
|
||||
netTag := fmt.Sprintf("NET:%s", descriptorNetworkTag(key.Network))
|
||||
thresholdTag := fmt.Sprintf("THRESHOLD:%d", desc.Threshold)
|
||||
keysTag := fmt.Sprintf("KEYS:%d", len(desc.Keys))
|
||||
keyTag := fmt.Sprintf("KEY:%d", keyIdx+1)
|
||||
|
||||
margin := descriptorSingleQRLayout.MarginMM
|
||||
ascentMM := capBaselineOffsetMM(descriptorFace, dpi)
|
||||
maxMetaWidth := plateSizeMM - 2*margin
|
||||
line1 := strings.Join([]string{typeTag, scriptTag, netTag}, " / ")
|
||||
line2 := strings.Join([]string{thresholdTag, keysTag, keyTag}, " / ")
|
||||
// Deterministic fixed-line layout; avoid mid-token wrapping.
|
||||
if trackedTextWidthMM(descriptorFace, dpi, line1, descTrackPx) > maxMetaWidth ||
|
||||
trackedTextWidthMM(descriptorFace, dpi, line2, descTrackPx) > maxMetaWidth {
|
||||
line1 = strings.Join([]string{typeTag, scriptTag, netTag}, "/")
|
||||
line2 = strings.Join([]string{thresholdTag, keysTag, keyTag}, "/")
|
||||
}
|
||||
if trackedTextWidthMM(descriptorFace, dpi, line1, descTrackPx) > maxMetaWidth ||
|
||||
trackedTextWidthMM(descriptorFace, dpi, line2, descTrackPx) > maxMetaWidth {
|
||||
line1 = strings.Join([]string{typeTag, scriptTag}, "/")
|
||||
line2 = strings.Join([]string{netTag, fmt.Sprintf("THR:%d", desc.Threshold), keysTag, keyTag}, "/")
|
||||
}
|
||||
lineSpacing := descriptorSingleQRLayout.LineGapMM
|
||||
y := margin + ascentMM
|
||||
res := DrawTextBlock(canvas, dpi, TextBlock{
|
||||
Face: descriptorFace,
|
||||
Tracking: descTrackPx,
|
||||
LeadingMM: lineSpacing,
|
||||
WidthMM: maxMetaWidth,
|
||||
Align: TextAlignStart,
|
||||
OriginXMM: margin,
|
||||
OriginYMM: y,
|
||||
}, line1+"\n"+line2)
|
||||
y = res.NextBaselineYMM - lineSpacing
|
||||
|
||||
if len(qrPayloads) == 0 {
|
||||
qrPayloads = []string{createDescriptorQR(desc)}
|
||||
}
|
||||
qrPayloads = trimNonEmpty(qrPayloads)
|
||||
if len(qrPayloads) == 0 {
|
||||
return nil, fmt.Errorf("empty descriptor QR content")
|
||||
}
|
||||
|
||||
dualQRLayout := len(qrPayloads) == 2
|
||||
if dualQRLayout {
|
||||
y += descriptorSingleQRLayout.PathGapMM
|
||||
DrawMetaLine(canvas, dpi, margin, y, descriptorFace, descTrackPx, pathText)
|
||||
guide := fmt.Sprintf("RECOVER: SCAN BOTH QRS FROM >=%d PLATES", desc.Threshold)
|
||||
gy := y + lineSpacing + descriptorDualQRLayout.GuideGapMM
|
||||
_ = DrawTextBlock(canvas, dpi, TextBlock{
|
||||
Face: descriptorFace,
|
||||
Tracking: descTrackPx,
|
||||
LeadingMM: descriptorDualQRLayout.LineGapMM,
|
||||
WidthMM: plateSizeMM - 2*margin,
|
||||
Align: TextAlignStart,
|
||||
OriginXMM: margin,
|
||||
OriginYMM: gy,
|
||||
}, guide)
|
||||
}
|
||||
|
||||
switch len(qrPayloads) {
|
||||
case 1:
|
||||
qrCode, err := qr.Encode(qrPayloads[0], descriptorQRECC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
qrX, qrY, qrSize := descriptorSingleQRPlacement(descriptorQRSizeMM)
|
||||
drawPlateQR(canvas, qrCode, dpi, qrX, qrY, qrSize, blackIdx, plateQROptions{
|
||||
QuietModules: descriptorDualQRLayout.QuietModules,
|
||||
Shape: plateQRCircle,
|
||||
KeepIslandsSquare: true,
|
||||
})
|
||||
case 2:
|
||||
qrL, err := qr.Encode(qrPayloads[0], descriptorQRECC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
qrR, err := qr.Encode(qrPayloads[1], descriptorQRECC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
qrSize, leftX, rightX, qrY := descriptorDualQRPlacement(qrL, qrR)
|
||||
drawPlateQR(canvas, qrL, dpi, leftX, qrY, qrSize, blackIdx, plateQROptions{
|
||||
QuietModules: descriptorDualQRLayout.QuietModules,
|
||||
Shape: plateQRCircle,
|
||||
KeepIslandsSquare: true,
|
||||
})
|
||||
drawPlateQR(canvas, qrR, dpi, rightX, qrY, qrSize, blackIdx, plateQROptions{
|
||||
QuietModules: descriptorDualQRLayout.QuietModules,
|
||||
Shape: plateQRCircle,
|
||||
KeepIslandsSquare: true,
|
||||
})
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported descriptor QR payload count: %d", len(qrPayloads))
|
||||
}
|
||||
|
||||
if !dualQRLayout {
|
||||
_, pathRotH := rotatedTextSizeMMTracked(descriptorFace, dpi, pathText, descTrackPx)
|
||||
pathX := margin
|
||||
pathY := plateSizeMM - margin - pathRotH
|
||||
if pathY < margin {
|
||||
pathY = margin
|
||||
}
|
||||
DrawRotatedLabel(canvas, dpi, pathX, pathY, descriptorFace, descTrackPx, blackIdx, pathText)
|
||||
}
|
||||
|
||||
if len(qrPayloads) == 1 {
|
||||
if shMeta := decodeShardMeta(qrPayloads[0]); shMeta != nil {
|
||||
wid := strings.ToUpper(hex.EncodeToString(shMeta.WalletID[:4]))
|
||||
sid := strings.ToUpper(hex.EncodeToString(shMeta.SetID[:4]))
|
||||
meta := fmt.Sprintf("WID:%s SET:%s %d/%d", wid, sid, shMeta.Index, shMeta.Threshold)
|
||||
metaRotW, metaRotH := rotatedInkSizeMMTracked(descriptorFace, dpi, meta, descTrackPx)
|
||||
metaX := plateSizeMM - margin - metaRotW
|
||||
if metaX+metaRotW > plateSizeMM-margin {
|
||||
metaX = plateSizeMM - margin - metaRotW
|
||||
}
|
||||
if metaX < margin {
|
||||
metaX = margin
|
||||
}
|
||||
metaY := plateSizeMM - margin - metaRotH
|
||||
if metaY < margin {
|
||||
metaY = margin
|
||||
}
|
||||
if metaY+metaRotH > plateSizeMM-margin {
|
||||
metaY = plateSizeMM - margin - metaRotH
|
||||
}
|
||||
DrawRotatedLabel(canvas, dpi, metaX, metaY, descriptorFace, descTrackPx, blackIdx, meta)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Invert {
|
||||
invertInterior(canvas, border)
|
||||
}
|
||||
applyPostProcess(canvas, opts)
|
||||
return canvas, nil
|
||||
}
|
||||
|
||||
func decodeShardMeta(payload string) *shard.Share {
|
||||
if !strings.HasPrefix(strings.ToUpper(payload), shard.Prefix) {
|
||||
return nil
|
||||
}
|
||||
sh, err := shard.Decode(strings.ToUpper(payload))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &sh
|
||||
}
|
||||
|
||||
func descriptorSingleQRPlacement(sizeOverride float64) (x, y, size float64) {
|
||||
size = sizeOverride
|
||||
if size <= 0 {
|
||||
size = descriptorSingleQRLayout.DefaultSize
|
||||
}
|
||||
if size < 5.0 {
|
||||
size = 5.0
|
||||
}
|
||||
x = descriptorSingleQRLayout.QRXMM
|
||||
y = descriptorSingleQRLayout.QRYMM
|
||||
if x < 0 {
|
||||
x = 0
|
||||
}
|
||||
if y < 0 {
|
||||
y = 0
|
||||
}
|
||||
if x+size > plateSizeMM {
|
||||
x = plateSizeMM - size
|
||||
}
|
||||
if y+size > plateSizeMM {
|
||||
y = plateSizeMM - size
|
||||
}
|
||||
return x, y, size
|
||||
}
|
||||
|
||||
func descriptorDualQRPlacement(qrL, qrR *qr.Code) (size, leftX, rightX, y float64) {
|
||||
usableH := plateSizeMM - descriptorDualQRLayout.QRTopLimitMM
|
||||
size = math.Min(plateSizeMM/2, usableH)
|
||||
quietModules := descriptorDualQRLayout.QuietModules
|
||||
for i := 0; i < descriptorDualQRLayout.OverlapIters; i++ {
|
||||
overlap := math.Min(quietZoneMM(qrL, size, quietModules), quietZoneMM(qrR, size, quietModules))
|
||||
maxByWidth := (plateSizeMM + overlap) / 2
|
||||
next := math.Min(maxByWidth, usableH)
|
||||
if math.Abs(next-size) < descriptorDualQRLayout.OverlapEpsilon {
|
||||
break
|
||||
}
|
||||
size = next
|
||||
}
|
||||
if size < 5.0 {
|
||||
size = 5.0
|
||||
}
|
||||
overlap := math.Min(quietZoneMM(qrL, size, quietModules), quietZoneMM(qrR, size, quietModules))
|
||||
leftX = 0.0
|
||||
rightX = leftX + size - overlap
|
||||
y = plateSizeMM - size
|
||||
return size, leftX, rightX, y
|
||||
}
|
||||
173
printer/plate_seed.go
Normal file
173
printer/plate_seed.go
Normal file
@ -0,0 +1,173 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"strings"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
"github.com/kortschak/qr"
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/bip39"
|
||||
"seedetcher.com/seedqr"
|
||||
"seedetcher.com/version"
|
||||
)
|
||||
|
||||
// RenderSeedPlateBitmap mirrors the PDF layout at 600dpi as a 1-bit paletted image.
|
||||
func RenderSeedPlateBitmap(mnemonic bip39.Mnemonic, shareNum, totalShares int, opts RasterOptions) (*image.Paletted, error) {
|
||||
return renderSeedPlateBitmapWithLayout(mnemonic, shareNum, totalShares, opts, defaultSeedPlateLayout(totalShares, false))
|
||||
}
|
||||
|
||||
// RenderSeedPlateBitmapWithDescriptor renders a seed plate and applies
|
||||
// descriptor-derived singlesig metadata when a singlesig descriptor is provided.
|
||||
func RenderSeedPlateBitmapWithDescriptor(mnemonic bip39.Mnemonic, shareNum, totalShares int, desc *urtypes.OutputDescriptor, opts RasterOptions) (*image.Paletted, error) {
|
||||
isSinglesigDesc := desc != nil && len(desc.Keys) == 1 && desc.Type == urtypes.Singlesig
|
||||
layout := defaultSeedPlateLayout(totalShares, isSinglesigDesc)
|
||||
if isSinglesigDesc {
|
||||
path := strings.ToUpper(derivationPathForKey(desc.Keys[0], desc.Script))
|
||||
layout.RightMetaText = fmt.Sprintf("%s/%s/NET:%s", path, desc.Script.Tag(), descriptorNetworkTag(desc.Keys[0].Network))
|
||||
// Marker is wallet-key pagination, not physical copy count.
|
||||
layout.ShareNum = 1
|
||||
layout.ShareTotal = 1
|
||||
}
|
||||
return renderSeedPlateBitmapWithLayout(mnemonic, shareNum, totalShares, opts, layout)
|
||||
}
|
||||
|
||||
func renderSeedPlateBitmapWithLayout(mnemonic bip39.Mnemonic, shareNum, totalShares int, opts RasterOptions, layout seedPlateLayout) (*image.Paletted, error) {
|
||||
dpi := opts.dpi()
|
||||
canvas := newPlateCanvas(dpi)
|
||||
blackIdx := uint8(1)
|
||||
|
||||
border := mmToPx(borderWidthMM, dpi)
|
||||
if border < 1 {
|
||||
border = 1
|
||||
}
|
||||
strokeRect(canvas, 0, 0, canvas.Bounds().Dx(), canvas.Bounds().Dy(), border, blackIdx)
|
||||
|
||||
wordFace := loadFace(14, dpi)
|
||||
metaFace := loadFace(11, dpi)
|
||||
const (
|
||||
marginMM = 3.0
|
||||
wordTrackEm = 0.12 // word-list tracking
|
||||
numTrackEm = 0.05 // tighter tracking for index numbers
|
||||
numWordGap = 0.5 // extra gutter (mm) between number and word columns
|
||||
)
|
||||
leadingMM := 15.2 * 25.4 / 72.0
|
||||
wordTrackPx := wordTrackEm * 14.0 * dpi / 72.0
|
||||
numTrackPx := numTrackEm * 14.0 * dpi / 72.0
|
||||
metaTrackPx := 0.04 * 11.0 * dpi / 72.0 // Affinity tracking as percent of em
|
||||
wordStartBaseline := marginMM + capBaselineOffsetMM(wordFace, dpi)
|
||||
|
||||
seed := bip39.MnemonicSeed(mnemonic, "")
|
||||
var fingerprintHex string
|
||||
if seed != nil {
|
||||
masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
|
||||
if err == nil {
|
||||
if masterPubKey, err := masterKey.Neuter(); err == nil {
|
||||
if pubKey, err := masterPubKey.ECPubKey(); err == nil {
|
||||
fp := btcutil.Hash160(pubKey.SerializeCompressed())[:4]
|
||||
fingerprintHex = fmt.Sprintf("%X", fp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Word columns: right-aligned numbers + one space + left-aligned words.
|
||||
numColWMM := trackedTextWidthMM(wordFace, dpi, "24", numTrackPx)
|
||||
spaceWMM := trackedTextWidthMM(wordFace, dpi, " ", wordTrackPx) + numWordGap
|
||||
yLeft := wordStartBaseline
|
||||
for i := 0; i < 16 && i < len(mnemonic); i++ {
|
||||
if mnemonic[i] == -1 {
|
||||
continue
|
||||
}
|
||||
num := fmt.Sprintf("%d", i+1)
|
||||
word := strings.ToUpper(bip39.LabelFor(mnemonic[i]))
|
||||
numW := trackedTextWidthMM(wordFace, dpi, num, numTrackPx)
|
||||
drawTrackedText(canvas, wordFace, dpi, layout.LeftColXMM+numColWMM-numW, yLeft, num, numTrackPx)
|
||||
drawTrackedText(canvas, wordFace, dpi, layout.LeftColXMM+numColWMM+spaceWMM, yLeft, word, wordTrackPx)
|
||||
yLeft += leadingMM
|
||||
}
|
||||
yRight := wordStartBaseline
|
||||
for i := 16; i < 24 && i < len(mnemonic); i++ {
|
||||
if mnemonic[i] == -1 {
|
||||
continue
|
||||
}
|
||||
num := fmt.Sprintf("%d", i+1)
|
||||
word := strings.ToUpper(bip39.LabelFor(mnemonic[i]))
|
||||
numW := trackedTextWidthMM(wordFace, dpi, num, numTrackPx)
|
||||
drawTrackedText(canvas, wordFace, dpi, layout.RightColXMM+numColWMM-numW, yRight, num, numTrackPx)
|
||||
drawTrackedText(canvas, wordFace, dpi, layout.RightColXMM+numColWMM+spaceWMM, yRight, word, wordTrackPx)
|
||||
yRight += leadingMM
|
||||
}
|
||||
|
||||
if seed != nil {
|
||||
qrContent := seedqr.QR(mnemonic)
|
||||
if len(qrContent) > 0 {
|
||||
qrCode, err := qr.Encode(string(qrContent), qr.M)
|
||||
if err == nil {
|
||||
qrX := layout.QRLeftMM + 0.5
|
||||
qrY := seedQRYMM(seedQRSizeMM)
|
||||
drawPlateQR(canvas, qrCode, dpi, qrX, qrY, seedQRSizeMM, blackIdx, plateQROptions{
|
||||
QuietModules: 0,
|
||||
Shape: plateQRCircle,
|
||||
KeepIslandsSquare: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
title := walletLabel()
|
||||
titleW := trackedTextWidthMM(metaFace, dpi, title, metaTrackPx)
|
||||
titleX := plateSizeMM - marginMM - titleW
|
||||
titleY := plateSizeMM - marginMM
|
||||
drawTrackedText(canvas, metaFace, dpi, titleX, titleY, title, metaTrackPx)
|
||||
}
|
||||
|
||||
showShareNum := shareNum
|
||||
showShareTotal := totalShares
|
||||
if layout.ShareNum > 0 && layout.ShareTotal > 0 {
|
||||
showShareNum = layout.ShareNum
|
||||
showShareTotal = layout.ShareTotal
|
||||
}
|
||||
shareText := fmt.Sprintf("%d/%d", showShareNum, showShareTotal)
|
||||
_, shareRotH := rotatedTextSizeMMTracked(metaFace, dpi, shareText, metaTrackPx)
|
||||
shareX := marginMM
|
||||
shareY := plateSizeMM - marginMM - shareRotH
|
||||
DrawRotatedLabel(canvas, dpi, shareX, shareY, metaFace, metaTrackPx, blackIdx, shareText)
|
||||
|
||||
if fingerprintHex != "" {
|
||||
_, fpRotH := rotatedTextSizeMMTracked(metaFace, dpi, fingerprintHex, metaTrackPx)
|
||||
fpX := marginMM
|
||||
fpY := (plateSizeMM - fpRotH) / 2
|
||||
DrawRotatedLabel(canvas, dpi, fpX, fpY, metaFace, metaTrackPx, blackIdx, fingerprintHex)
|
||||
}
|
||||
|
||||
verText := version.String()
|
||||
_, verRotH := rotatedTextSizeMMTracked(metaFace, dpi, verText, metaTrackPx)
|
||||
verX := marginMM
|
||||
verY := marginMM
|
||||
if verY+verRotH > plateSizeMM-marginMM {
|
||||
verY = plateSizeMM - marginMM - verRotH
|
||||
}
|
||||
DrawRotatedLabel(canvas, dpi, verX, verY, metaFace, metaTrackPx, blackIdx, verText)
|
||||
if layout.RightMetaText != "" {
|
||||
meta := strings.ToUpper(layout.RightMetaText)
|
||||
metaRotW, metaRotH := rotatedInkSizeMMTracked(metaFace, dpi, meta, metaTrackPx)
|
||||
metaX := plateSizeMM - marginMM - metaRotW
|
||||
if metaX < marginMM {
|
||||
metaX = marginMM
|
||||
}
|
||||
metaY := marginMM
|
||||
if metaY+metaRotH > plateSizeMM-marginMM {
|
||||
metaY = plateSizeMM - marginMM - metaRotH
|
||||
}
|
||||
DrawRotatedLabel(canvas, dpi, metaX, metaY, metaFace, metaTrackPx, blackIdx, meta)
|
||||
}
|
||||
|
||||
if opts.Invert {
|
||||
invertInterior(canvas, border)
|
||||
}
|
||||
applyPostProcess(canvas, opts)
|
||||
return canvas, nil
|
||||
}
|
||||
@ -1,28 +1,14 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
"github.com/jung-kurt/gofpdf/v2"
|
||||
"github.com/kortschak/qr"
|
||||
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
|
||||
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/types"
|
||||
"seedetcher.com/bc/ur"
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/bip39"
|
||||
"seedetcher.com/descriptor/legacy"
|
||||
"seedetcher.com/logutil"
|
||||
"seedetcher.com/seedqr"
|
||||
"seedetcher.com/version"
|
||||
)
|
||||
|
||||
// PaperSize defines the supported paper formats for printing.
|
||||
@ -33,13 +19,14 @@ const (
|
||||
PaperLetter PaperSize = "Letter" // Letter paper size (216x279mm)
|
||||
)
|
||||
|
||||
// Load Fonts
|
||||
var martianMono = "font/martianmono/MartianMono_Condensed-Regular.ttf" // Path to the UTF-8 TrueType font file
|
||||
var martianMonoMedium = "font/martianmono/static/MartianMono-Medium.ttf"
|
||||
var descriptorQRSizeMM = 0.0 // Max descriptor QR size in mm (0 = no cap)
|
||||
var descriptorQRECC = qr.L // Error correction level for descriptor QR
|
||||
var (
|
||||
martianMono = "font/martianmono/MartianMono_Condensed-Regular.ttf"
|
||||
martianMonoMedium = "font/martianmono/static/MartianMono-Medium.ttf"
|
||||
descriptorQRSizeMM float64 = 0.0
|
||||
descriptorQRECC = qr.L
|
||||
)
|
||||
|
||||
// Load font binary data
|
||||
// loadFontData reads font bytes from disk for raster rendering.
|
||||
func loadFontData(fontPath string) []byte {
|
||||
logutil.DebugLog("Attempting to load font from %s", fontPath)
|
||||
data, err := os.ReadFile(fontPath)
|
||||
@ -51,232 +38,7 @@ func loadFontData(fontPath string) []byte {
|
||||
return data
|
||||
}
|
||||
|
||||
// PlateData holds the data needed to generate individual plates.
|
||||
type PlateData struct {
|
||||
Mnemonic bip39.Mnemonic
|
||||
Desc *urtypes.OutputDescriptor
|
||||
KeyIdx int
|
||||
ShareNum int
|
||||
TotalShares int
|
||||
IsDescriptor bool
|
||||
}
|
||||
|
||||
// createSeedPlate generates a square PDF plate for a seed phrase with the original layout.
|
||||
func createSeedPlate(mnemonic bip39.Mnemonic, shareNum int, totalShares int) (*gofpdf.Fpdf, *bytes.Buffer, error) {
|
||||
pdf := gofpdf.NewCustom(&gofpdf.InitType{UnitStr: "mm", Size: gofpdf.SizeType{Wd: plateSizeMM, Ht: plateSizeMM}})
|
||||
pdf.AddPage()
|
||||
pdf.SetMargins(0, 0, 0)
|
||||
pdf.SetLineWidth(0.2)
|
||||
|
||||
var (
|
||||
fontName = "MartianMono"
|
||||
fontNameMedium = "MartianMonoMedium"
|
||||
mediumName = fontName
|
||||
)
|
||||
fontData := loadFontData(martianMono)
|
||||
fontDataMedium := loadFontData(martianMonoMedium)
|
||||
if fontData == nil {
|
||||
pdf.SetFont("Courier", "", 8)
|
||||
} else {
|
||||
pdf.AddUTF8FontFromBytes(fontName, "", fontData)
|
||||
if pdf.Err() {
|
||||
pdf.SetFont("Courier", "", 8)
|
||||
} else {
|
||||
pdf.SetFont(fontName, "", 8)
|
||||
if fontDataMedium != nil {
|
||||
pdf.AddUTF8FontFromBytes(fontNameMedium, "", fontDataMedium)
|
||||
if !pdf.Err() {
|
||||
mediumName = fontNameMedium
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plateSize := plateSizeMM
|
||||
pdf.Rect(0, 0, plateSize, plateSize, "D")
|
||||
|
||||
pdf.SetFont(mediumName, "", 6)
|
||||
shareText := fmt.Sprintf("%d/%d", shareNum, totalShares)
|
||||
pdf.Text(5.0, 5.0, shareText)
|
||||
|
||||
// Revert font size to 8pt and leading to 4mm
|
||||
pdf.SetFont(fontName, "", 8)
|
||||
yLeft := 15.0
|
||||
for i := 0; i < 16 && i < len(mnemonic); i++ {
|
||||
if mnemonic[i] == -1 {
|
||||
continue
|
||||
}
|
||||
wordStr := strings.ToUpper(bip39.LabelFor(mnemonic[i]))
|
||||
pdf.Text(12.0, yLeft, fmt.Sprintf("%2d %s", i+1, wordStr))
|
||||
yLeft += 4.0
|
||||
}
|
||||
yRight := 15.0
|
||||
for i := 16; i < 24 && i < len(mnemonic); i++ {
|
||||
if mnemonic[i] == -1 {
|
||||
continue
|
||||
}
|
||||
wordStr := strings.ToUpper(bip39.LabelFor(mnemonic[i]))
|
||||
pdf.Text(45.0, yRight, fmt.Sprintf("%2d %s", i+1, wordStr))
|
||||
yRight += 4.0
|
||||
}
|
||||
|
||||
seed := bip39.MnemonicSeed(mnemonic, "")
|
||||
if seed != nil {
|
||||
masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create master key: %v", err)
|
||||
}
|
||||
masterPubKey, err := masterKey.Neuter()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to neuter master key: %v", err)
|
||||
}
|
||||
pubKey, err := masterPubKey.ECPubKey()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get EC pub key: %v", err)
|
||||
}
|
||||
fingerprint := btcutil.Hash160(pubKey.SerializeCompressed())[:4]
|
||||
fingerprintHex := fmt.Sprintf("%X", fingerprint)
|
||||
|
||||
pdf.SetFont(mediumName, "", 6)
|
||||
pdf.Text(40.0, 5.0, fingerprintHex)
|
||||
pdf.Text(70.0, 5.0, version.String())
|
||||
|
||||
qrContent := seedqr.QR(mnemonic)
|
||||
if len(qrContent) > 0 {
|
||||
qrCode, err := qr.Encode(string(qrContent), qr.M)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to encode QR: %v", err)
|
||||
}
|
||||
qrSize := 28.0
|
||||
// Align QR bottom to the 16th word baseline (yLeft base + 15*4mm).
|
||||
qrY := (15.0 + float64(15)*4.0) - qrSize
|
||||
const quiet = 4
|
||||
step := qrSize / float64(qrCode.Size+2*quiet)
|
||||
offset := float64(quiet) * step
|
||||
qrX := 46.0 - offset
|
||||
for y := 0; y < qrCode.Size; y++ {
|
||||
for x := 0; x < qrCode.Size; x++ {
|
||||
if !qrCode.Black(x, y) {
|
||||
continue
|
||||
}
|
||||
startX := qrX + offset + (float64(x) * step)
|
||||
startY := qrY + offset + (float64(y) * step)
|
||||
pdf.Rect(startX, startY, step, step, "F")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pdf.SetFont(mediumName, "", 6)
|
||||
label := walletLabel()
|
||||
pdf.Text((plateSize-pdf.GetStringWidth(label))/2, plateSize-3.0, label)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := pdf.Output(&buf); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to generate PDF: %v", err)
|
||||
}
|
||||
return pdf, &buf, nil
|
||||
}
|
||||
|
||||
// createDescriptorPlate generates a square PDF plate for a descriptor with all details and a square QR code.
|
||||
func createDescriptorPlate(desc *urtypes.OutputDescriptor, keyIdx int, shareNum int, totalShares int) (*gofpdf.Fpdf, error) {
|
||||
pdf := gofpdf.NewCustom(&gofpdf.InitType{UnitStr: "mm", Size: gofpdf.SizeType{Wd: plateSizeMM, Ht: plateSizeMM}})
|
||||
pdf.AddPage()
|
||||
pdf.SetMargins(10, 10, 10) // 10mm margins
|
||||
pdf.SetLineWidth(0.2)
|
||||
|
||||
var (
|
||||
fontName = "MartianMono"
|
||||
fontNameMedium = "MartianMonoMedium"
|
||||
mediumName = fontName
|
||||
)
|
||||
fontData := loadFontData(martianMono)
|
||||
fontDataMedium := loadFontData(martianMonoMedium)
|
||||
if fontData == nil {
|
||||
pdf.SetFont("Courier", "", 8)
|
||||
} else {
|
||||
pdf.AddUTF8FontFromBytes(fontName, "", fontData)
|
||||
if pdf.Err() {
|
||||
pdf.SetFont("Courier", "", 8)
|
||||
} else {
|
||||
pdf.SetFont(fontName, "", 8)
|
||||
if fontDataMedium != nil {
|
||||
pdf.AddUTF8FontFromBytes(fontNameMedium, "", fontDataMedium)
|
||||
if !pdf.Err() {
|
||||
mediumName = fontNameMedium
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plateSize := plateSizeMM
|
||||
pdf.Rect(0, 0, plateSize, plateSize, "D")
|
||||
|
||||
pdf.SetFont(mediumName, "", 6)
|
||||
shareText := fmt.Sprintf("%d/%d", shareNum, totalShares)
|
||||
pathStr := derivationPathForKey(desc.Keys[keyIdx], desc.Script)
|
||||
pathWidth := pdf.GetStringWidth(fmt.Sprintf("Path:%s", pathStr))
|
||||
pdf.Text(5.0, 5.0, shareText)
|
||||
pdf.Text(plateSize-pathWidth-5.0, 5.0, fmt.Sprintf("Path:%s", pathStr))
|
||||
|
||||
pdf.SetFont(fontName, "", 8)
|
||||
pdf.SetXY(20.0, 8.0)
|
||||
key := desc.Keys[keyIdx]
|
||||
allText := fmt.Sprintf("Type:%v/Script:%s/Threshold:%d/Keys:%d/Key%d:%s", desc.Type, strings.Replace(desc.Script.String(), " ", "", -1), desc.Threshold, len(desc.Keys), keyIdx+1, key.String())
|
||||
lines := pdf.SplitText(allText, plateSize-10.0) // 5mm margins
|
||||
lineHeightMM := pdf.PointConvert(8) // current font size height in mm
|
||||
lineSpacing := 3.5 // mm between baselines
|
||||
y := 10.0 // baseline of first line
|
||||
for i, line := range lines {
|
||||
pdf.Text(5.0, y, line)
|
||||
if i < len(lines)-1 {
|
||||
y += lineSpacing
|
||||
}
|
||||
}
|
||||
// QR at bottom, 10mm from edge
|
||||
qrContent := createDescriptorQR(desc)
|
||||
if len(qrContent) == 0 {
|
||||
return nil, fmt.Errorf("failed to generate descriptor QR: empty content")
|
||||
}
|
||||
qrCode, err := qr.Encode(qrContent, descriptorQRECC)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode descriptor QR: %v", err)
|
||||
}
|
||||
textLines := float64(len(lines))
|
||||
textBlockHeight := lineHeightMM
|
||||
if textLines > 1 {
|
||||
textBlockHeight += (textLines - 1) * lineSpacing
|
||||
}
|
||||
textBottom := 7.0 + textBlockHeight
|
||||
qrGap := 2.0 // gap between text block and QR
|
||||
qrBottom := 0.0 // bottom margin
|
||||
qrSize := plateSize - textBottom - qrGap - qrBottom
|
||||
if qrSize > descriptorQRSizeMM && descriptorQRSizeMM > 0 {
|
||||
qrSize = descriptorQRSizeMM
|
||||
}
|
||||
if qrSize < 5 {
|
||||
qrSize = 5 // Prevent too-small QR
|
||||
}
|
||||
qrX := (plateSize - qrSize) / 2 // Left margin
|
||||
qrY := textBottom + qrGap
|
||||
const quiet = 4
|
||||
step := qrSize / float64(qrCode.Size+2*quiet)
|
||||
offset := float64(quiet) * step
|
||||
for y := 0; y < qrCode.Size; y++ {
|
||||
for x := 0; x < qrCode.Size; x++ {
|
||||
if !qrCode.Black(x, y) {
|
||||
continue
|
||||
}
|
||||
startX := qrX + offset + (float64(x) * step)
|
||||
startY := qrY + offset + (float64(y) * step)
|
||||
pdf.Rect(startX, startY, step, step, "F")
|
||||
}
|
||||
}
|
||||
|
||||
return pdf, nil
|
||||
}
|
||||
|
||||
// createDescriptorQR constructs a QR string for the descriptor.
|
||||
// createDescriptorQR constructs a canonical UR descriptor payload.
|
||||
func createDescriptorQR(desc *urtypes.OutputDescriptor) string {
|
||||
if desc == nil {
|
||||
return ""
|
||||
@ -286,11 +48,22 @@ func createDescriptorQR(desc *urtypes.OutputDescriptor) string {
|
||||
return ur.Encode("crypto-output", normalized.Encode(), 1, 1)
|
||||
}
|
||||
|
||||
// DescriptorQRPayload returns the canonical descriptor QR payload for a full
|
||||
// descriptor (single-part UR:crypto-output).
|
||||
func DescriptorQRPayload(desc *urtypes.OutputDescriptor) string {
|
||||
return createDescriptorQR(desc)
|
||||
}
|
||||
|
||||
func derivationPathForKey(key urtypes.KeyDescriptor, script urtypes.Script) string {
|
||||
if len(key.DerivationPath) > 0 {
|
||||
return key.DerivationPath.String()
|
||||
normalize := func(path string) string {
|
||||
path = strings.ReplaceAll(path, "H", "'")
|
||||
path = strings.ReplaceAll(path, "h", "'")
|
||||
return path
|
||||
}
|
||||
return script.DerivationPath().String()
|
||||
if len(key.DerivationPath) > 0 {
|
||||
return normalize(key.DerivationPath.String())
|
||||
}
|
||||
return normalize(script.DerivationPath().String())
|
||||
}
|
||||
|
||||
// SetDescriptorQRSize overrides the maximum descriptor QR size in millimeters.
|
||||
@ -300,183 +73,3 @@ func SetDescriptorQRSize(mm float64) {
|
||||
descriptorQRSizeMM = mm
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated: legacy vector-PDF plate generator.
|
||||
// Use CreatePlateBitmaps + ComposePages + WritePDFRaster/WritePCL instead.
|
||||
func CreatePlates(w io.Writer, mnemonics []bip39.Mnemonic, desc *urtypes.OutputDescriptor, keyIdx int, supportsPCL, supportsPostScript bool) ([]string, []string, string, error) {
|
||||
logutil.DebugLog("Starting CreatePlates with %d mnemonics, desc=%v, keyIdx=%d", len(mnemonics), desc != nil, keyIdx)
|
||||
tempDir := filepath.Join(os.TempDir(), "seedetcher-plates")
|
||||
if err := os.Mkdir(tempDir, 0700); err != nil && !os.IsExist(err) {
|
||||
return nil, nil, "", fmt.Errorf("failed to create temp dir %s: %v", tempDir, err)
|
||||
}
|
||||
logutil.DebugLog("Using directory: %s", tempDir)
|
||||
// No defer os.RemoveAll(tempDir) here—caller will handle cleanup
|
||||
|
||||
totalShares := len(mnemonics)
|
||||
if desc != nil && len(desc.Keys) > 0 {
|
||||
totalShares = len(desc.Keys)
|
||||
logutil.DebugLog("Calculated totalShares: %d based on desc.Keys length: %d", totalShares, len(desc.Keys))
|
||||
}
|
||||
|
||||
seedPaths := make([]string, totalShares)
|
||||
descPaths := make([]string, totalShares)
|
||||
for i := 0; i < totalShares; i++ {
|
||||
mnemonic := mnemonics[i%len(mnemonics)]
|
||||
logutil.DebugLog("Generating seed plate %d", i+1)
|
||||
_, seedBuf, err := createSeedPlate(mnemonic, i+1, totalShares)
|
||||
if err != nil {
|
||||
return nil, nil, tempDir, fmt.Errorf("failed to generate seed plate %d: %v", i, err)
|
||||
}
|
||||
seedFile := filepath.Join(tempDir, fmt.Sprintf("seed_%d.pdf", i))
|
||||
if err := os.WriteFile(seedFile, seedBuf.Bytes(), 0644); err != nil {
|
||||
return nil, nil, tempDir, fmt.Errorf("failed to write seed plate %d: %v", i, err)
|
||||
}
|
||||
seedPaths[i] = seedFile
|
||||
logutil.DebugLog("Generated seed plate %d at %s, size: %d bytes", i+1, seedFile, seedBuf.Len())
|
||||
if desc != nil && len(desc.Keys) > 0 {
|
||||
descKeyIdx := i % len(desc.Keys) // rotate keys per plate
|
||||
logutil.DebugLog("Generating descriptor plate %d", i+1)
|
||||
pdf, err := createDescriptorPlate(desc, descKeyIdx, i+1, totalShares)
|
||||
if err != nil {
|
||||
return nil, nil, tempDir, fmt.Errorf("failed to generate descriptor plate %d: %v", i, err)
|
||||
}
|
||||
descFile := filepath.Join(tempDir, fmt.Sprintf("desc_%d.pdf", i))
|
||||
if err := pdf.OutputFileAndClose(descFile); err != nil {
|
||||
return nil, nil, tempDir, fmt.Errorf("failed to write descriptor plate %d: %v", i, err)
|
||||
}
|
||||
descPaths[i] = descFile
|
||||
if info, err := os.Stat(descFile); err == nil {
|
||||
logutil.DebugLog("Generated descriptor plate %d at %s, size: %d bytes", i+1, descFile, info.Size())
|
||||
}
|
||||
} else {
|
||||
descPaths[i] = ""
|
||||
}
|
||||
}
|
||||
return seedPaths, descPaths, tempDir, nil
|
||||
}
|
||||
|
||||
// Deprecated: legacy vector-PDF n-up page layout path.
|
||||
// Use CreatePlateBitmaps + ComposePages + WritePDFRaster/WritePCL instead.
|
||||
func CreatePageLayout(w io.Writer, tempDir string, paperFormat PaperSize, seedPaths, descPaths []string) error {
|
||||
logutil.DebugLog("Starting CreatePageLayout with tempDir: %s", tempDir)
|
||||
|
||||
if len(seedPaths) != len(descPaths) {
|
||||
return fmt.Errorf("mismatch in seed and desc paths: %d vs %d", len(seedPaths), len(descPaths))
|
||||
}
|
||||
totalShares := len(seedPaths)
|
||||
if totalShares == 0 {
|
||||
logutil.DebugLog("No plates to merge")
|
||||
return fmt.Errorf("no plates to merge")
|
||||
}
|
||||
|
||||
var pageSize string
|
||||
switch paperFormat {
|
||||
case PaperA4:
|
||||
pageSize = "A4"
|
||||
case PaperLetter:
|
||||
pageSize = "Letter"
|
||||
default:
|
||||
return fmt.Errorf("unsupported paper size: %v", paperFormat)
|
||||
}
|
||||
|
||||
slotsPerPage := 6
|
||||
numPages := (totalShares*2 + slotsPerPage - 1) / slotsPerPage
|
||||
logutil.DebugLog("Total shares: %d, generating %d pages", totalShares, numPages)
|
||||
|
||||
for page := 0; page < numPages; page++ {
|
||||
startIdx := page * (slotsPerPage / 2)
|
||||
endIdx := min(startIdx+(slotsPerPage/2), totalShares)
|
||||
pageShares := endIdx - startIdx
|
||||
logutil.DebugLog("Page %d: shares %d to %d (%d shares)", page+1, startIdx+1, endIdx, pageShares)
|
||||
|
||||
allFiles := make([]string, 0, slotsPerPage)
|
||||
hasDesc := false
|
||||
for _, path := range descPaths[startIdx:endIdx] {
|
||||
if path != "" {
|
||||
hasDesc = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasDesc {
|
||||
for i := startIdx; i < endIdx; i++ {
|
||||
allFiles = append(allFiles, seedPaths[i], descPaths[i])
|
||||
}
|
||||
// No padding with empty strings here
|
||||
} else {
|
||||
for i := startIdx; i < endIdx; i++ {
|
||||
allFiles = append(allFiles, seedPaths[i])
|
||||
}
|
||||
}
|
||||
logutil.DebugLog("Page %d files (before filter): %v", page+1, allFiles)
|
||||
|
||||
// Filter out empty strings
|
||||
var filteredFiles []string
|
||||
for _, f := range allFiles {
|
||||
if f != "" {
|
||||
filteredFiles = append(filteredFiles, f)
|
||||
}
|
||||
}
|
||||
logutil.DebugLog("Page %d files (after filter): %v", page+1, filteredFiles)
|
||||
|
||||
// Debug: Verify files exist before merge
|
||||
for _, f := range filteredFiles {
|
||||
if info, err := os.Stat(f); err == nil {
|
||||
logutil.DebugLog("Before merge: %s exists, size: %d bytes", f, info.Size())
|
||||
} else {
|
||||
logutil.DebugLog("Before merge: %s error: %v", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
tempConcatFile := filepath.Join(tempDir, fmt.Sprintf("concat_page_%d.pdf", page))
|
||||
logutil.DebugLog("Attempting to merge into %s", tempConcatFile)
|
||||
if err := api.MergeCreateFile(filteredFiles, tempConcatFile, false, nil); err != nil {
|
||||
logutil.DebugLog("Failed to merge PDFs for page %d: %v", page+1, err)
|
||||
os.Remove(tempConcatFile)
|
||||
return fmt.Errorf("failed to merge PDFs for page %d: %v", page+1, err)
|
||||
}
|
||||
logutil.DebugLog("Merged PDFs into %s", tempConcatFile)
|
||||
if info, err := os.Stat(tempConcatFile); err == nil {
|
||||
logutil.DebugLog("Size of concat_page_%d.pdf: %d bytes", page, info.Size())
|
||||
} else {
|
||||
logutil.DebugLog("Failed to stat merged file %s: %v", tempConcatFile, err)
|
||||
}
|
||||
defer os.Remove(tempConcatFile)
|
||||
|
||||
tempNUpFile := filepath.Join(tempDir, fmt.Sprintf("nup_page_%d.pdf", page))
|
||||
nupConfig := model.DefaultNUpConfig()
|
||||
nupConfig.PageSize = pageSize
|
||||
nupConfig.Grid = &types.Dim{Width: 2, Height: 3}
|
||||
nupConfig.UserDim = true
|
||||
nupConfig.PageDim = types.PaperSize[pageSize]
|
||||
if err := api.NUpFile([]string{tempConcatFile}, tempNUpFile, nil, nupConfig, nil); err != nil {
|
||||
logutil.DebugLog("Failed to create NUp layout for page %d: %v", page+1, err)
|
||||
os.Remove(tempNUpFile)
|
||||
return fmt.Errorf("failed to create NUp layout for page %d: %v", page+1, err)
|
||||
}
|
||||
logutil.DebugLog("Created NUp layout at %s", tempNUpFile)
|
||||
if info, err := os.Stat(tempNUpFile); err == nil {
|
||||
logutil.DebugLog("Size of nup_page_%d.pdf: %d bytes", page, info.Size())
|
||||
}
|
||||
defer os.Remove(tempNUpFile)
|
||||
|
||||
nupBytes, err := os.ReadFile(tempNUpFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read NUp PDF for page %d: %v", page+1, err)
|
||||
}
|
||||
logutil.DebugLog("nupBytes length for page %d before write: %d", page+1, len(nupBytes))
|
||||
if _, err := w.Write(nupBytes); err != nil {
|
||||
return fmt.Errorf("failed to write NUp PDF for page %d: %v", page+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
logutil.DebugLog("Wrote %d pages to output", numPages)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper function for min
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
367
printer/ps.go
Normal file
367
printer/ps.go
Normal file
@ -0,0 +1,367 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
)
|
||||
|
||||
// WritePS streams mono bitmaps as a PostScript job (one page per image).
|
||||
// It prepends PJL language selection for printers that honor PJL over USB raw.
|
||||
func WritePS(w io.Writer, pages []*image.Paletted, paper PaperSize, progress ProgressFunc) error {
|
||||
if len(pages) == 0 {
|
||||
return fmt.Errorf("no pages to write")
|
||||
}
|
||||
pageWmm, pageHmm, ok := paperDimsMM(paper)
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported paper size: %v", paper)
|
||||
}
|
||||
totalBytes, err := estimatePSBytes(pages, pageWmm, pageHmm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pw := newProgressWriter(StageSend, w, totalBytes, progress)
|
||||
if progress != nil && totalBytes > 0 {
|
||||
progress(StageSend, 0, totalBytes)
|
||||
}
|
||||
bw := bufio.NewWriterSize(pw, 128*1024)
|
||||
defer bw.Flush()
|
||||
|
||||
pjlHeader := []byte("\x1b%-12345X@PJL JOB NAME=\"SE-PS\"\r\n@PJL SET PERSONALITY=POSTSCRIPT\r\n@PJL ENTER LANGUAGE=POSTSCRIPT\r\n")
|
||||
pjlFooter := []byte("\n\x1b%-12345X@PJL EOJ NAME=\"SE-PS\"\r\n\x1b%-12345X")
|
||||
if _, err := bw.Write(pjlHeader); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "%%!PS-Adobe-3.0\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "%%%%Pages: %d\n", len(pages)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "%%%%EndComments\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pageWpt := pageWmm * 72.0 / 25.4
|
||||
pageHpt := pageHmm * 72.0 / 25.4
|
||||
|
||||
for i, page := range pages {
|
||||
if page == nil {
|
||||
return fmt.Errorf("page %d is nil", i)
|
||||
}
|
||||
b := page.Bounds()
|
||||
width, height := b.Dx(), b.Dy()
|
||||
if width <= 0 || height <= 0 {
|
||||
return fmt.Errorf("page %d has invalid dimensions", i)
|
||||
}
|
||||
rowBytes := (width + 7) / 8
|
||||
buf := make([]byte, rowBytes)
|
||||
rowPix := make([]uint8, width)
|
||||
effectiveDPI := float64(width) * 25.4 / pageWmm
|
||||
dy := mmToPx(hostDirectTopOffsetMM, effectiveDPI)
|
||||
if _, err := fmt.Fprintf(bw, "%%%%Page: %d %d\n", i+1, i+1); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "<< /PageSize [%.2f %.2f] >> setpagedevice\n", pageWpt, pageHpt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "gsave\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "0 setgray\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Render full-page image mask directly in page user space (points).
|
||||
if _, err := fmt.Fprintf(bw, "%.4f %.4f scale\n", pageWpt, pageHpt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "/picstr %d string def\n", rowBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "%d %d true\n", width, height); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "[%d 0 0 -%d 0 %d]\n", width, height, height); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "{ currentfile picstr readhexstring pop } imagemask\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
hexRow := make([]byte, rowBytes*2+1)
|
||||
for y := 0; y < height; y++ {
|
||||
renderShiftedPageRow(rowPix, page, y, 0, dy)
|
||||
packBits(buf, rowPix)
|
||||
hex.Encode(hexRow[:rowBytes*2], buf)
|
||||
hexRow[rowBytes*2] = '\n'
|
||||
if _, err := bw.Write(hexRow); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "grestore\nshowpage\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(bw, "%%%%EOF\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := bw.Write(pjlFooter); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WritePSPlates composes seed/descriptor plates directly into a PostScript job
|
||||
// without materializing all full-page bitmaps in memory at once.
|
||||
// extraPages can be used to append already-rendered full pages (e.g. stats page).
|
||||
func WritePSPlates(w io.Writer, seedPlates, descPlates []*image.Paletted, paper PaperSize, dpi float64, extraPages []*image.Paletted, progress ProgressFunc) error {
|
||||
return WritePSPlatesWithInvert(w, seedPlates, descPlates, paper, dpi, false, extraPages, progress)
|
||||
}
|
||||
|
||||
// WritePSPlatesWithInvert composes plates directly into a PostScript job and
|
||||
// applies transfer-mask overlays controlled by invert mode.
|
||||
func WritePSPlatesWithInvert(w io.Writer, seedPlates, descPlates []*image.Paletted, paper PaperSize, dpi float64, invert bool, extraPages []*image.Paletted, progress ProgressFunc) error {
|
||||
plan, err := buildPlacementPlan(seedPlates, descPlates, paper, dpi, invert, progress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(plan.pages) == 0 && len(extraPages) == 0 {
|
||||
return fmt.Errorf("no pages to write")
|
||||
}
|
||||
pageWmm, pageHmm, ok := paperDimsMM(paper)
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported paper size: %v", paper)
|
||||
}
|
||||
totalBytes, err := estimatePSBytesForPlan(plan, pageWmm, pageHmm, extraPages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pw := newProgressWriter(StageSend, w, totalBytes, progress)
|
||||
if progress != nil && totalBytes > 0 {
|
||||
progress(StageSend, 0, totalBytes)
|
||||
}
|
||||
bw := bufio.NewWriterSize(pw, 128*1024)
|
||||
defer bw.Flush()
|
||||
|
||||
pjlHeader := []byte("\x1b%-12345X@PJL JOB NAME=\"SE-PS\"\r\n@PJL SET PERSONALITY=POSTSCRIPT\r\n@PJL ENTER LANGUAGE=POSTSCRIPT\r\n")
|
||||
pjlFooter := []byte("\n\x1b%-12345X@PJL EOJ NAME=\"SE-PS\"\r\n\x1b%-12345X")
|
||||
if _, err := bw.Write(pjlHeader); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "%%!PS-Adobe-3.0\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
totalPages := len(plan.pages) + len(extraPages)
|
||||
if _, err := fmt.Fprintf(bw, "%%%%Pages: %d\n", totalPages); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "%%%%EndComments\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pageWpt := pageWmm * 72.0 / 25.4
|
||||
pageHpt := pageHmm * 72.0 / 25.4
|
||||
|
||||
width := plan.pageWpx
|
||||
height := plan.pageHpx
|
||||
rowBytes := 0
|
||||
if width > 0 {
|
||||
rowBytes = (width + 7) / 8
|
||||
}
|
||||
rowPix := make([]uint8, width)
|
||||
rowPacked := make([]byte, rowBytes)
|
||||
hexRow := make([]byte, rowBytes*2+1)
|
||||
dy := mmToPx(hostDirectTopOffsetMM, dpi)
|
||||
|
||||
pageNum := 1
|
||||
for _, page := range plan.pages {
|
||||
if _, err := fmt.Fprintf(bw, "%%%%Page: %d %d\n", pageNum, pageNum); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "<< /PageSize [%.2f %.2f] >> setpagedevice\n", pageWpt, pageHpt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "gsave\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "0 setgray\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "%.4f %.4f scale\n", pageWpt, pageHpt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "/picstr %d string def\n", rowBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "%d %d true\n", width, height); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "[%d 0 0 -%d 0 %d]\n", width, height, height); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "{ currentfile picstr readhexstring pop } imagemask\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
renderPage := offsetPagePlacement(page, 0, dy)
|
||||
|
||||
for y := 0; y < height; y++ {
|
||||
renderPlannedRow(rowPix, y, renderPage, invert)
|
||||
packBits(rowPacked, rowPix)
|
||||
hex.Encode(hexRow[:rowBytes*2], rowPacked)
|
||||
hexRow[rowBytes*2] = '\n'
|
||||
if _, err := bw.Write(hexRow); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "grestore\nshowpage\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
pageNum++
|
||||
}
|
||||
|
||||
for _, page := range extraPages {
|
||||
if page == nil {
|
||||
return fmt.Errorf("extra page is nil")
|
||||
}
|
||||
b := page.Bounds()
|
||||
if b.Dx() != width || b.Dy() != height {
|
||||
return fmt.Errorf("extra page has unexpected dimensions: got %dx%d want %dx%d", b.Dx(), b.Dy(), width, height)
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "%%%%Page: %d %d\n", pageNum, pageNum); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "<< /PageSize [%.2f %.2f] >> setpagedevice\n", pageWpt, pageHpt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "gsave\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "0 setgray\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "%.4f %.4f scale\n", pageWpt, pageHpt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "/picstr %d string def\n", rowBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "%d %d true\n", width, height); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "[%d 0 0 -%d 0 %d]\n", width, height, height); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "{ currentfile picstr readhexstring pop } imagemask\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
for y := 0; y < height; y++ {
|
||||
renderShiftedPageRow(rowPix, page, y, 0, dy)
|
||||
packBits(rowPacked, rowPix)
|
||||
hex.Encode(hexRow[:rowBytes*2], rowPacked)
|
||||
hexRow[rowBytes*2] = '\n'
|
||||
if _, err := bw.Write(hexRow); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(bw, "grestore\nshowpage\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
pageNum++
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(bw, "%%%%EOF\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := bw.Write(pjlFooter); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EstimatePSPlatesBytes returns the estimated PostScript job size for a plate batch.
|
||||
func EstimatePSPlatesBytes(seedPlates, descPlates []*image.Paletted, paper PaperSize, dpi float64) (int64, error) {
|
||||
plan, err := buildPlacementPlan(seedPlates, descPlates, paper, dpi, false, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
pageWmm, pageHmm, ok := paperDimsMM(paper)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unsupported paper size: %v", paper)
|
||||
}
|
||||
return estimatePSBytesForPlan(plan, pageWmm, pageHmm, nil)
|
||||
}
|
||||
|
||||
func estimatePSBytes(pages []*image.Paletted, pageWmm, pageHmm float64) (int64, error) {
|
||||
total := int64(0)
|
||||
pjlHeader := []byte("\x1b%-12345X@PJL JOB NAME=\"SE-PS\"\r\n@PJL SET PERSONALITY=POSTSCRIPT\r\n@PJL ENTER LANGUAGE=POSTSCRIPT\r\n")
|
||||
pjlFooter := []byte("\n\x1b%-12345X@PJL EOJ NAME=\"SE-PS\"\r\n\x1b%-12345X")
|
||||
total += int64(len(pjlHeader))
|
||||
total += int64(len("%!PS-Adobe-3.0\n"))
|
||||
total += int64(len(fmt.Sprintf("%%%%Pages: %d\n", len(pages))))
|
||||
total += int64(len("%%EndComments\n"))
|
||||
pageWpt := pageWmm * 72.0 / 25.4
|
||||
pageHpt := pageHmm * 72.0 / 25.4
|
||||
|
||||
for i, page := range pages {
|
||||
if page == nil {
|
||||
return 0, fmt.Errorf("page %d is nil", i)
|
||||
}
|
||||
b := page.Bounds()
|
||||
width, height := b.Dx(), b.Dy()
|
||||
if width <= 0 || height <= 0 {
|
||||
return 0, fmt.Errorf("page %d has invalid dimensions", i)
|
||||
}
|
||||
rowBytes := (width + 7) / 8
|
||||
total += int64(len(fmt.Sprintf("%%%%Page: %d %d\n", i+1, i+1)))
|
||||
total += int64(len(fmt.Sprintf("<< /PageSize [%.2f %.2f] >> setpagedevice\n", pageWpt, pageHpt)))
|
||||
total += int64(len("gsave\n"))
|
||||
total += int64(len(fmt.Sprintf("%d %d scale\n", width, height)))
|
||||
total += int64(len(fmt.Sprintf("%d %d true\n", width, height)))
|
||||
total += int64(len(fmt.Sprintf("[%d 0 0 -%d 0 %d]\n", width, height, height)))
|
||||
total += int64(len(fmt.Sprintf("/picstr %d string def\n", rowBytes)))
|
||||
total += int64(len("{ currentfile picstr readhexstring pop } imagemask\n"))
|
||||
total += int64(height * (rowBytes*2 + 1)) // hex + newline
|
||||
total += int64(len("grestore\nshowpage\n"))
|
||||
}
|
||||
total += int64(len("%%EOF\n"))
|
||||
total += int64(len(pjlFooter))
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func estimatePSBytesForPlan(plan placementPlan, pageWmm, pageHmm float64, extraPages []*image.Paletted) (int64, error) {
|
||||
if plan.pageWpx <= 0 || plan.pageHpx <= 0 {
|
||||
return 0, fmt.Errorf("invalid page dimensions")
|
||||
}
|
||||
total := int64(0)
|
||||
pjlHeader := []byte("\x1b%-12345X@PJL JOB NAME=\"SE-PS\"\r\n@PJL SET PERSONALITY=POSTSCRIPT\r\n@PJL ENTER LANGUAGE=POSTSCRIPT\r\n")
|
||||
pjlFooter := []byte("\n\x1b%-12345X@PJL EOJ NAME=\"SE-PS\"\r\n\x1b%-12345X")
|
||||
total += int64(len(pjlHeader))
|
||||
total += int64(len("%!PS-Adobe-3.0\n"))
|
||||
totalPages := len(plan.pages) + len(extraPages)
|
||||
total += int64(len(fmt.Sprintf("%%%%Pages: %d\n", totalPages)))
|
||||
total += int64(len("%%EndComments\n"))
|
||||
pageWpt := pageWmm * 72.0 / 25.4
|
||||
pageHpt := pageHmm * 72.0 / 25.4
|
||||
|
||||
width := plan.pageWpx
|
||||
height := plan.pageHpx
|
||||
rowBytes := (width + 7) / 8
|
||||
perPage := int64(0)
|
||||
perPage += int64(len(fmt.Sprintf("%%%%Page: %d %d\n", 1, 1)))
|
||||
perPage += int64(len(fmt.Sprintf("<< /PageSize [%.2f %.2f] >> setpagedevice\n", pageWpt, pageHpt)))
|
||||
perPage += int64(len("gsave\n"))
|
||||
perPage += int64(len("0 setgray\n"))
|
||||
perPage += int64(len(fmt.Sprintf("%.4f %.4f scale\n", pageWpt, pageHpt)))
|
||||
perPage += int64(len(fmt.Sprintf("/picstr %d string def\n", rowBytes)))
|
||||
perPage += int64(len(fmt.Sprintf("%d %d true\n", width, height)))
|
||||
perPage += int64(len(fmt.Sprintf("[%d 0 0 -%d 0 %d]\n", width, height, height)))
|
||||
perPage += int64(len("{ currentfile picstr readhexstring pop } imagemask\n"))
|
||||
perPage += int64(height * (rowBytes*2 + 1))
|
||||
perPage += int64(len("grestore\nshowpage\n"))
|
||||
total += int64(totalPages) * perPage
|
||||
total += int64(len("%%EOF\n"))
|
||||
total += int64(len(pjlFooter))
|
||||
return total, nil
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
@ -11,19 +10,13 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
"github.com/kortschak/qr"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/basicfont"
|
||||
"golang.org/x/image/font/opentype"
|
||||
"golang.org/x/image/math/fixed"
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/bip39"
|
||||
"seedetcher.com/descriptor/shard"
|
||||
"seedetcher.com/seedqr"
|
||||
"seedetcher.com/version"
|
||||
)
|
||||
|
||||
// RasterOptions controls the bitmap output used for raw printer jobs.
|
||||
@ -31,16 +24,65 @@ type RasterOptions struct {
|
||||
DPI float64 // target resolution; defaults to 600 if unset
|
||||
Mirror bool // mirror horizontally (for toner transfer)
|
||||
Invert bool // swap black/white for negative output
|
||||
// PrinterLang selects the host printer language path.
|
||||
// Default zero value is PCL.
|
||||
PrinterLang PrinterLanguage
|
||||
// SinglesigLayout controls singlesig plate rendering strategy.
|
||||
// Zero value keeps the current default: seed + descriptor info (single-sided).
|
||||
SinglesigLayout SinglesigLayoutMode
|
||||
// EtchStatsPage appends an additional page with per-plate coverage metrics.
|
||||
EtchStatsPage bool
|
||||
}
|
||||
|
||||
type PrinterLanguage uint8
|
||||
|
||||
const (
|
||||
PrinterLangPCL PrinterLanguage = iota
|
||||
PrinterLangPS
|
||||
PrinterLangBrotherHBP
|
||||
)
|
||||
|
||||
type SinglesigLayoutMode uint8
|
||||
|
||||
const (
|
||||
// Default zero-value behavior.
|
||||
SinglesigLayoutSeedWithInfo SinglesigLayoutMode = iota
|
||||
SinglesigLayoutSeedOnly
|
||||
SinglesigLayoutSeedWithDescriptorQR
|
||||
)
|
||||
|
||||
type plateQRShape uint8
|
||||
|
||||
const (
|
||||
plateQRSquare plateQRShape = iota
|
||||
plateQRCircle
|
||||
)
|
||||
|
||||
type plateQROptions struct {
|
||||
QuietModules int
|
||||
Shape plateQRShape
|
||||
KeepIslandsSquare bool
|
||||
// PatternCornerRadiusRatio rounds structural QR modules (finder/alignment)
|
||||
// as a fraction of module size. 0 keeps sharp corners; max useful is 0.5.
|
||||
PatternCornerRadiusRatio float64
|
||||
}
|
||||
|
||||
const (
|
||||
plateSizeMM = 90.0
|
||||
borderWidthMM = 0.2
|
||||
// Relative circle diameter for non-island QR modules on plate render.
|
||||
// 1.0 fills the whole module cell, smaller values leave more white margin.
|
||||
plateQRDotScale = 0.7
|
||||
// Default structural-module corner radius ratio (code-only switch).
|
||||
// Keep at 0.0 for current sharp-corner behavior.
|
||||
plateQRPatternCornerRadiusRatio = 0.5
|
||||
)
|
||||
|
||||
var (
|
||||
bwPalette = color.Palette{color.White, color.Black}
|
||||
|
||||
plateFontPrimary = "font/seedetcher/SeedEtcher-Regular.ttf"
|
||||
|
||||
fontOnce sync.Once
|
||||
fontFaceData *opentype.Font
|
||||
fontErr error
|
||||
@ -53,310 +95,160 @@ var (
|
||||
faceMuMedium sync.Mutex
|
||||
faceCacheMedium = make(map[[2]float64]font.Face) // key: {sizePt, dpi}
|
||||
|
||||
shardSetMu sync.RWMutex
|
||||
forcedShardSet *[16]byte
|
||||
compactMu sync.RWMutex
|
||||
compact2of3On bool
|
||||
)
|
||||
|
||||
// CreatePlateBitmaps renders seed/descriptor plates to 1-bit bitmaps using the existing layout.
|
||||
func CreatePlateBitmaps(mnemonics []bip39.Mnemonic, desc *urtypes.OutputDescriptor, keyIdx int, opts RasterOptions, progress ProgressFunc) ([]*image.Paletted, []*image.Paletted, error) {
|
||||
totalShares := len(mnemonics)
|
||||
if desc != nil && len(desc.Keys) > 0 {
|
||||
isSinglesigDesc := desc != nil && len(desc.Keys) == 1 && desc.Type == urtypes.Singlesig
|
||||
includeSinglesigInfo := isSinglesigDesc && opts.SinglesigLayout == SinglesigLayoutSeedWithInfo
|
||||
includeSinglesigDescriptorSide := isSinglesigDesc && opts.SinglesigLayout == SinglesigLayoutSeedWithDescriptorQR
|
||||
isSinglesigJob := desc == nil || isSinglesigDesc
|
||||
if desc != nil && len(desc.Keys) > 0 && !isSinglesigDesc {
|
||||
totalShares = len(desc.Keys)
|
||||
}
|
||||
// Singlesig seed-side variant: give space for optional right-edge metadata.
|
||||
seedLayout := defaultSeedPlateLayout(totalShares, isSinglesigDesc)
|
||||
if isSinglesigJob {
|
||||
// Plate marker is wallet-key pagination, not physical copy count.
|
||||
seedLayout.ShareNum = 1
|
||||
seedLayout.ShareTotal = 1
|
||||
}
|
||||
if includeSinglesigInfo {
|
||||
path := strings.ToUpper(derivationPathForKey(desc.Keys[0], desc.Script))
|
||||
seedLayout.RightMetaText = fmt.Sprintf("%s/%s/NET:%s", path, desc.Script.Tag(), descriptorNetworkTag(desc.Keys[0].Network))
|
||||
}
|
||||
|
||||
seedImgs := make([]*image.Paletted, totalShares)
|
||||
var descImgs []*image.Paletted
|
||||
hasDesc := desc != nil && len(desc.Keys) > 0
|
||||
hasDesc := desc != nil && len(desc.Keys) > 0 && (!isSinglesigDesc || includeSinglesigDescriptorSide)
|
||||
if hasDesc {
|
||||
descImgs = make([]*image.Paletted, totalShares)
|
||||
}
|
||||
var shardQRCodes []string
|
||||
var shardQRPayloads [][]string
|
||||
if hasDesc {
|
||||
var err error
|
||||
shardQRCodes, err = descriptorShardQRCodes(desc, totalShares)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
if isSinglesigDesc && includeSinglesigDescriptorSide {
|
||||
qrPayload := createDescriptorQR(desc)
|
||||
if qrPayload == "" {
|
||||
return nil, nil, fmt.Errorf("empty descriptor QR content")
|
||||
}
|
||||
shardQRPayloads = make([][]string, totalShares)
|
||||
for i := range shardQRPayloads {
|
||||
shardQRPayloads[i] = []string{qrPayload}
|
||||
}
|
||||
} else {
|
||||
shardQRPayloads = make([][]string, totalShares)
|
||||
for i := 0; i < totalShares; i++ {
|
||||
descKeyIdx := i % len(desc.Keys)
|
||||
payloads, err := descriptorShardQRPayloadsForShare(desc, totalShares, descKeyIdx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
shardQRPayloads[i] = payloads
|
||||
}
|
||||
}
|
||||
}
|
||||
compactSingleSided := hasDesc &&
|
||||
CompactDescriptor2of3Enabled() &&
|
||||
desc.Type == urtypes.SortedMulti &&
|
||||
desc.Threshold == 2 &&
|
||||
len(desc.Keys) == 3 &&
|
||||
totalShares == 3 &&
|
||||
len(shardQRPayloads) == 3
|
||||
if compactSingleSided {
|
||||
descImgs = nil
|
||||
}
|
||||
|
||||
prepareTotal := int64(totalShares)
|
||||
if hasDesc && !compactSingleSided {
|
||||
prepareTotal *= 2
|
||||
}
|
||||
prepareDone := int64(0)
|
||||
|
||||
for i := 0; i < totalShares; i++ {
|
||||
mnemonic := mnemonics[i%len(mnemonics)]
|
||||
seedImg, err := RenderSeedPlateBitmap(mnemonic, i+1, totalShares, opts)
|
||||
seedImg, err := renderSeedPlateBitmapWithLayout(mnemonic, i+1, totalShares, opts, seedLayout)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
seedImgs[i] = seedImg
|
||||
|
||||
if hasDesc {
|
||||
if compactSingleSided {
|
||||
descKeyIdx := i % len(desc.Keys)
|
||||
descQR := ""
|
||||
if i < len(shardQRCodes) {
|
||||
descQR = shardQRCodes[i]
|
||||
sharePayload := ""
|
||||
if i < len(shardQRPayloads) && len(shardQRPayloads[i]) > 0 {
|
||||
sharePayload = shardQRPayloads[i][0]
|
||||
}
|
||||
descImg, err := RenderDescriptorPlateBitmap(desc, descKeyIdx, i+1, totalShares, opts, descQR)
|
||||
seedImg, err = renderCompact2of3PlateBitmap(mnemonic, desc, descKeyIdx, opts, sharePayload)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
seedImgs[i] = seedImg
|
||||
prepareDone++
|
||||
if progress != nil && prepareTotal > 0 {
|
||||
progress(StagePrepare, prepareDone, prepareTotal)
|
||||
}
|
||||
|
||||
if hasDesc && !compactSingleSided {
|
||||
descKeyIdx := i % len(desc.Keys)
|
||||
var descQRs []string
|
||||
if i < len(shardQRPayloads) {
|
||||
descQRs = shardQRPayloads[i]
|
||||
}
|
||||
descImg, err := RenderDescriptorPlateBitmap(desc, descKeyIdx, i+1, totalShares, opts, descQRs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
descImgs[i] = descImg
|
||||
}
|
||||
|
||||
if progress != nil {
|
||||
progress(StagePrepare, int64(i+1), int64(totalShares))
|
||||
prepareDone++
|
||||
if progress != nil && prepareTotal > 0 {
|
||||
progress(StagePrepare, prepareDone, prepareTotal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return seedImgs, descImgs, nil
|
||||
}
|
||||
|
||||
// RenderSeedPlateBitmap mirrors the PDF layout at 600dpi as a 1-bit paletted image.
|
||||
func RenderSeedPlateBitmap(mnemonic bip39.Mnemonic, shareNum, totalShares int, opts RasterOptions) (*image.Paletted, error) {
|
||||
dpi := opts.dpi()
|
||||
canvas := newPlateCanvas(dpi)
|
||||
blackIdx := uint8(1)
|
||||
|
||||
border := mmToPx(borderWidthMM, dpi)
|
||||
if border < 1 {
|
||||
border = 1
|
||||
}
|
||||
strokeRect(canvas, 0, 0, canvas.Bounds().Dx(), canvas.Bounds().Dy(), border, blackIdx)
|
||||
|
||||
shareFace := loadFaceMedium(6, dpi)
|
||||
mainFace := loadFace(8, dpi)
|
||||
|
||||
drawText(canvas, shareFace, dpi, 5.0, 5.0, fmt.Sprintf("%d/%d", shareNum, totalShares))
|
||||
|
||||
seed := bip39.MnemonicSeed(mnemonic, "")
|
||||
if seed != nil {
|
||||
masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
|
||||
if err == nil {
|
||||
if masterPubKey, err := masterKey.Neuter(); err == nil {
|
||||
if pubKey, err := masterPubKey.ECPubKey(); err == nil {
|
||||
fp := btcutil.Hash160(pubKey.SerializeCompressed())[:4]
|
||||
fingerprintHex := fmt.Sprintf("%X", fp)
|
||||
fpWidth := textWidthMM(shareFace, dpi, fingerprintHex)
|
||||
fpX := (plateSizeMM - fpWidth) / 2
|
||||
verWidth := textWidthMM(shareFace, dpi, version.String())
|
||||
verX := plateSizeMM - 5.0 - verWidth
|
||||
drawText(canvas, shareFace, dpi, fpX, 5.0, fingerprintHex)
|
||||
drawText(canvas, shareFace, dpi, verX, 5.0, version.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Word columns
|
||||
yLeft := 15.0
|
||||
for i := 0; i < 16 && i < len(mnemonic); i++ {
|
||||
if mnemonic[i] == -1 {
|
||||
continue
|
||||
}
|
||||
word := strings.ToUpper(bip39.LabelFor(mnemonic[i]))
|
||||
drawText(canvas, mainFace, dpi, 12.0, yLeft, fmt.Sprintf("%2d %s", i+1, word))
|
||||
yLeft += 4.0
|
||||
}
|
||||
yRight := 15.0
|
||||
for i := 16; i < 24 && i < len(mnemonic); i++ {
|
||||
if mnemonic[i] == -1 {
|
||||
continue
|
||||
}
|
||||
word := strings.ToUpper(bip39.LabelFor(mnemonic[i]))
|
||||
drawText(canvas, mainFace, dpi, 45.0, yRight, fmt.Sprintf("%2d %s", i+1, word))
|
||||
yRight += 4.0
|
||||
}
|
||||
|
||||
qrRegions := []image.Rectangle{}
|
||||
if seed != nil {
|
||||
qrContent := seedqr.QR(mnemonic)
|
||||
if len(qrContent) > 0 {
|
||||
qrCode, err := qr.Encode(string(qrContent), qr.M)
|
||||
if err == nil {
|
||||
qrSize := 28.0
|
||||
const quiet = 4
|
||||
step := qrSize / float64(qrCode.Size+2*quiet)
|
||||
offset := float64(quiet) * step
|
||||
qrX := 48.5 - offset
|
||||
// Align QR bottom to the 16th word baseline (yLeft base + 15*4mm).
|
||||
qrY := (15.0 + float64(15)*4.0) - qrSize
|
||||
drawQR(canvas, qrCode, dpi, qrX, qrY, qrSize, blackIdx)
|
||||
qrRegions = append(qrRegions, image.Rect(mmToPx(qrX, dpi), mmToPx(qrY, dpi), mmToPx(qrX+qrSize, dpi), mmToPx(qrY+qrSize, dpi)))
|
||||
}
|
||||
}
|
||||
|
||||
title := walletLabel()
|
||||
titleFace := loadFaceMedium(6, dpi)
|
||||
titleY := plateSizeMM - 3.0
|
||||
drawCenteredText(canvas, titleFace, dpi, titleY, title)
|
||||
}
|
||||
|
||||
if opts.Invert {
|
||||
invertExcept(canvas, qrRegions)
|
||||
}
|
||||
applyPostProcess(canvas, opts)
|
||||
return canvas, nil
|
||||
// RenderCompact2of3PlateBitmap renders a single-sided compact 2-of-3 plate
|
||||
// containing both seed and descriptor-share QR payloads.
|
||||
func RenderCompact2of3PlateBitmap(mnemonic bip39.Mnemonic, desc *urtypes.OutputDescriptor, keyIdx int, opts RasterOptions, descQR string) (*image.Paletted, error) {
|
||||
return renderCompact2of3PlateBitmap(mnemonic, desc, keyIdx, opts, descQR)
|
||||
}
|
||||
|
||||
// RenderDescriptorPlateBitmap mirrors the descriptor PDF layout at 600dpi as a 1-bit paletted image.
|
||||
func RenderDescriptorPlateBitmap(desc *urtypes.OutputDescriptor, keyIdx, shareNum, totalShares int, opts RasterOptions, qrPayload string) (*image.Paletted, error) {
|
||||
if desc == nil {
|
||||
return nil, fmt.Errorf("descriptor is nil")
|
||||
func descriptorNetworkTag(net *chaincfg.Params) string {
|
||||
if net != nil && net.Net == chaincfg.MainNetParams.Net {
|
||||
return "MAIN"
|
||||
}
|
||||
dpi := opts.dpi()
|
||||
canvas := newPlateCanvas(dpi)
|
||||
blackIdx := uint8(1)
|
||||
|
||||
border := mmToPx(borderWidthMM, dpi)
|
||||
if border < 1 {
|
||||
border = 1
|
||||
}
|
||||
strokeRect(canvas, 0, 0, canvas.Bounds().Dx(), canvas.Bounds().Dy(), border, blackIdx)
|
||||
|
||||
smallFace := loadFaceMedium(6, dpi)
|
||||
mainFace := loadFace(8, dpi)
|
||||
drawText(canvas, smallFace, dpi, 5.0, 5.0, fmt.Sprintf("%d/%d", shareNum, totalShares))
|
||||
pathStr := derivationPathForKey(desc.Keys[keyIdx], desc.Script)
|
||||
pathWidth := textWidthMM(smallFace, dpi, fmt.Sprintf("Path:%s", pathStr))
|
||||
pathX := plateSizeMM - 5.0 - pathWidth
|
||||
drawText(canvas, smallFace, dpi, pathX, 5.0, fmt.Sprintf("Path:%s", pathStr))
|
||||
|
||||
key := desc.Keys[keyIdx]
|
||||
allText := fmt.Sprintf("Type:%v/Script:%s/Threshold:%d/Keys:%d/Key%d:%s",
|
||||
desc.Type, strings.Replace(desc.Script.String(), " ", "", -1), desc.Threshold, len(desc.Keys), keyIdx+1, key.String())
|
||||
|
||||
lines := wrapText(mainFace, dpi, allText, plateSizeMM-10.0)
|
||||
lineHeightPx := float64(mainFace.Metrics().Height.Ceil())
|
||||
lineHeightMM := lineHeightPx * 25.4 / dpi
|
||||
lineSpacing := 3.5
|
||||
y := 10.0
|
||||
for i, line := range lines {
|
||||
drawText(canvas, mainFace, dpi, 5.0, y, line)
|
||||
if i < len(lines)-1 {
|
||||
y += lineSpacing
|
||||
}
|
||||
}
|
||||
qrContent := qrPayload
|
||||
if qrContent == "" {
|
||||
qrContent = createDescriptorQR(desc)
|
||||
}
|
||||
if len(qrContent) == 0 {
|
||||
return nil, fmt.Errorf("empty descriptor QR content")
|
||||
}
|
||||
var shMeta *shard.Share
|
||||
if strings.HasPrefix(strings.ToUpper(qrContent), shard.Prefix) {
|
||||
if sh, err := shard.Decode(strings.ToUpper(qrContent)); err == nil {
|
||||
shMeta = &sh
|
||||
}
|
||||
}
|
||||
qrCode, err := qr.Encode(qrContent, descriptorQRECC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
textLines := float64(len(lines))
|
||||
textBlockHeight := lineHeightMM
|
||||
if textLines > 1 {
|
||||
textBlockHeight += (textLines - 1) * lineSpacing
|
||||
}
|
||||
textBottom := 7.0 + textBlockHeight
|
||||
qrGap := 2.0 // gap between text and QR
|
||||
qrBottom := 1.0 // bottom margin
|
||||
qrSize := plateSizeMM - textBottom - qrGap - qrBottom
|
||||
if qrSize > descriptorQRSizeMM && descriptorQRSizeMM > 0 {
|
||||
qrSize = descriptorQRSizeMM
|
||||
}
|
||||
if qrSize < 5.0 {
|
||||
qrSize = 5.0 // Prevent degenerate QR
|
||||
}
|
||||
qrX := (plateSizeMM - qrSize) / 2
|
||||
qrY := textBottom + qrGap
|
||||
drawQR(canvas, qrCode, dpi, qrX, qrY, qrSize, blackIdx)
|
||||
if shMeta != nil {
|
||||
wid := strings.ToUpper(hex.EncodeToString(shMeta.WalletID[:4]))
|
||||
sid := strings.ToUpper(hex.EncodeToString(shMeta.SetID[:4]))
|
||||
meta := fmt.Sprintf("WID:%s SET:%s %d/%d", wid, sid, shMeta.Index, shMeta.Threshold)
|
||||
drawRotatedSideMeta(canvas, smallFace, dpi, qrX, qrY, qrSize, meta, blackIdx)
|
||||
}
|
||||
qrRegions := []image.Rectangle{
|
||||
image.Rect(mmToPx(qrX, dpi), mmToPx(qrY, dpi), mmToPx(qrX+qrSize, dpi), mmToPx(qrY+qrSize, dpi)),
|
||||
}
|
||||
if opts.Invert {
|
||||
invertExcept(canvas, qrRegions)
|
||||
}
|
||||
applyPostProcess(canvas, opts)
|
||||
return canvas, nil
|
||||
return "TEST"
|
||||
}
|
||||
|
||||
func descriptorShardQRCodes(desc *urtypes.OutputDescriptor, totalShares int) ([]string, error) {
|
||||
if desc == nil {
|
||||
return nil, fmt.Errorf("descriptor is nil")
|
||||
}
|
||||
if totalShares <= 0 {
|
||||
return nil, fmt.Errorf("invalid share count: %d", totalShares)
|
||||
}
|
||||
threshold := desc.Threshold
|
||||
if threshold == 1 && totalShares == 1 {
|
||||
qr := createDescriptorQR(desc)
|
||||
if qr == "" {
|
||||
return nil, fmt.Errorf("empty descriptor QR content")
|
||||
}
|
||||
return []string{qr}, nil
|
||||
}
|
||||
if threshold < 2 || threshold > totalShares {
|
||||
return nil, fmt.Errorf("invalid descriptor threshold %d for %d shares", threshold, totalShares)
|
||||
}
|
||||
if threshold > math.MaxUint8 {
|
||||
return nil, fmt.Errorf("descriptor threshold too large: %d", threshold)
|
||||
}
|
||||
if totalShares > math.MaxUint8 {
|
||||
return nil, fmt.Errorf("descriptor share count too large: %d", totalShares)
|
||||
}
|
||||
payload := desc.Encode()
|
||||
if len(payload) == 0 {
|
||||
return nil, fmt.Errorf("empty descriptor payload")
|
||||
}
|
||||
threshold8 := uint8(threshold)
|
||||
totalShares8 := uint8(totalShares)
|
||||
opts := shard.SplitOptions{
|
||||
Threshold: threshold8,
|
||||
Total: totalShares8,
|
||||
}
|
||||
if setID, ok := forcedDescriptorShardSetID(); ok {
|
||||
opts.SetID = setID
|
||||
}
|
||||
shares, err := shard.SplitPayloadBytes(payload, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("split descriptor payload: %w", err)
|
||||
}
|
||||
out := make([]string, len(shares))
|
||||
for i, sh := range shares {
|
||||
enc, err := shard.Encode(sh)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode share %d: %w", i+1, err)
|
||||
}
|
||||
out[i] = enc
|
||||
}
|
||||
return out, nil
|
||||
// DescriptorShardQRCodes returns descriptor QR payloads (or shard payloads) for each share.
|
||||
// This is exported for batched host-mode printing paths that need deterministic per-share payloads.
|
||||
func DescriptorShardQRCodes(desc *urtypes.OutputDescriptor, totalShares int) ([]string, error) {
|
||||
return descriptorShardQRCodes(desc, totalShares)
|
||||
}
|
||||
|
||||
// SetDescriptorShardSetID forces the descriptor shard set_id used during plate
|
||||
// generation. Pass nil to clear and return to random per-job set IDs.
|
||||
func SetDescriptorShardSetID(id *[16]byte) {
|
||||
shardSetMu.Lock()
|
||||
defer shardSetMu.Unlock()
|
||||
if id == nil {
|
||||
forcedShardSet = nil
|
||||
return
|
||||
}
|
||||
v := *id
|
||||
forcedShardSet = &v
|
||||
// DescriptorShardQRPayloadsForShare returns one or more descriptor QR payloads for a
|
||||
// given share index. UR/XOR families such as 3-of-5 return two payloads.
|
||||
func DescriptorShardQRPayloadsForShare(desc *urtypes.OutputDescriptor, totalShares, keyIdx int) ([]string, error) {
|
||||
return descriptorShardQRPayloadsForShare(desc, totalShares, keyIdx)
|
||||
}
|
||||
|
||||
func forcedDescriptorShardSetID() ([16]byte, bool) {
|
||||
shardSetMu.RLock()
|
||||
defer shardSetMu.RUnlock()
|
||||
if forcedShardSet == nil {
|
||||
return [16]byte{}, false
|
||||
}
|
||||
return *forcedShardSet, true
|
||||
// SetCompactDescriptor2of3Enabled toggles compact single-sided 2-of-3 plate rendering.
|
||||
func SetCompactDescriptor2of3Enabled(on bool) {
|
||||
compactMu.Lock()
|
||||
defer compactMu.Unlock()
|
||||
compact2of3On = on
|
||||
}
|
||||
|
||||
// CompactDescriptor2of3Enabled reports whether compact single-sided 2-of-3
|
||||
// rendering is enabled.
|
||||
func CompactDescriptor2of3Enabled() bool {
|
||||
compactMu.RLock()
|
||||
defer compactMu.RUnlock()
|
||||
return compact2of3On
|
||||
}
|
||||
|
||||
// SavePNG writes a paletted image to disk.
|
||||
@ -391,226 +283,25 @@ func mmToPxFloat(mm, dpi float64) float64 {
|
||||
return mm / 25.4 * dpi
|
||||
}
|
||||
|
||||
func textWidthMM(face font.Face, dpi float64, text string) float64 {
|
||||
d := font.Drawer{
|
||||
Face: face,
|
||||
}
|
||||
wPx := d.MeasureString(text).Round()
|
||||
return float64(wPx) * 25.4 / dpi
|
||||
}
|
||||
|
||||
func strokeRect(img *image.Paletted, x, y, w, h, thickness int, idx uint8) {
|
||||
fillRect(img, x, y, w, thickness, idx) // top
|
||||
fillRect(img, x, y+h-thickness, w, thickness, idx) // bottom
|
||||
fillRect(img, x, y, thickness, h, idx) // left
|
||||
fillRect(img, x+w-thickness, y, thickness, h, idx) // right
|
||||
}
|
||||
|
||||
func fillRect(img *image.Paletted, x, y, w, h int, idx uint8) {
|
||||
b := img.Bounds()
|
||||
x0, y0 := clamp(x, b.Min.X, b.Max.X), clamp(y, b.Min.Y, b.Max.Y)
|
||||
x1, y1 := clamp(x+w, b.Min.X, b.Max.X), clamp(y+h, b.Min.Y, b.Max.Y)
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return
|
||||
}
|
||||
for yy := y0; yy < y1; yy++ {
|
||||
row := img.Pix[yy*img.Stride:]
|
||||
for xx := x0; xx < x1; xx++ {
|
||||
row[xx] = idx
|
||||
func trimNonEmpty(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clamp(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
func quietZoneMM(code *qr.Code, qrSizeMM float64, quietModules int) float64 {
|
||||
if quietModules <= 0 || code == nil || code.Size <= 0 || qrSizeMM <= 0 {
|
||||
return 0
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func drawText(img *image.Paletted, face font.Face, dpi, xMm, yMm float64, text string) {
|
||||
d := font.Drawer{
|
||||
Dst: img,
|
||||
Src: image.NewUniform(color.Black),
|
||||
Face: face,
|
||||
Dot: fixed.Point26_6{
|
||||
X: fixed.I(int(math.Round(mmToPxFloat(xMm, dpi)))),
|
||||
Y: fixed.I(int(math.Round(mmToPxFloat(yMm, dpi)))),
|
||||
},
|
||||
}
|
||||
d.DrawString(text)
|
||||
}
|
||||
|
||||
func drawCenteredText(img *image.Paletted, face font.Face, dpi, yMm float64, text string) {
|
||||
d := font.Drawer{
|
||||
Dst: img,
|
||||
Src: image.NewUniform(color.Black),
|
||||
Face: face,
|
||||
}
|
||||
textWidth := d.MeasureString(text).Round()
|
||||
xPx := (img.Bounds().Dx() - textWidth) / 2
|
||||
d.Dot = fixed.Point26_6{
|
||||
X: fixed.I(xPx),
|
||||
Y: fixed.I(int(math.Round(mmToPxFloat(yMm, dpi)))),
|
||||
}
|
||||
d.DrawString(text)
|
||||
}
|
||||
|
||||
func drawRotatedSideMeta(img *image.Paletted, face font.Face, dpi, qrX, qrY, qrSize float64, text string, idx uint8) {
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
const (
|
||||
sideMarginMM = 1.2
|
||||
qrGapMM = 1.2
|
||||
)
|
||||
rotWmm, rotHmm := rotatedTextSizeMM(face, dpi, text)
|
||||
if rotWmm <= 0 || rotHmm <= 0 {
|
||||
return
|
||||
}
|
||||
leftAvail := (qrX - qrGapMM) - sideMarginMM
|
||||
rightAvail := (plateSizeMM - sideMarginMM) - (qrX + qrSize + qrGapMM)
|
||||
if rotWmm > leftAvail && rotWmm > rightAvail {
|
||||
return // no safe strip wide enough; never overlap QR
|
||||
}
|
||||
xMm := sideMarginMM
|
||||
if rightAvail >= rotWmm && rightAvail >= leftAvail {
|
||||
xMm = qrX + qrSize + qrGapMM
|
||||
} else {
|
||||
xMm = sideMarginMM + (leftAvail-rotWmm)/2
|
||||
}
|
||||
yMm := qrY + (qrSize-rotHmm)/2
|
||||
if yMm < sideMarginMM {
|
||||
yMm = sideMarginMM
|
||||
}
|
||||
maxY := plateSizeMM - sideMarginMM - rotHmm
|
||||
if yMm > maxY {
|
||||
yMm = maxY
|
||||
}
|
||||
drawTextRotatedCW90(img, face, dpi, xMm, yMm, text, idx)
|
||||
}
|
||||
|
||||
func rotatedTextSizeMM(face font.Face, dpi float64, text string) (wMm, hMm float64) {
|
||||
if text == "" {
|
||||
return 0, 0
|
||||
}
|
||||
d := font.Drawer{Face: face}
|
||||
wPx := d.MeasureString(text).Ceil()
|
||||
if wPx <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
m := face.Metrics()
|
||||
hPx := (m.Ascent + m.Descent).Ceil()
|
||||
if hPx <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
// Rotated CW 90: width/height swap.
|
||||
return float64(hPx) * 25.4 / dpi, float64(wPx) * 25.4 / dpi
|
||||
}
|
||||
|
||||
func drawTextRotatedCW90(img *image.Paletted, face font.Face, dpi, xMm, yMm float64, text string, idx uint8) {
|
||||
d := font.Drawer{Face: face}
|
||||
srcW := d.MeasureString(text).Ceil()
|
||||
if srcW <= 0 {
|
||||
return
|
||||
}
|
||||
metrics := face.Metrics()
|
||||
srcH := (metrics.Ascent + metrics.Descent).Ceil()
|
||||
if srcH <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
src := image.NewAlpha(image.Rect(0, 0, srcW, srcH))
|
||||
d = font.Drawer{
|
||||
Dst: src,
|
||||
Src: image.NewUniform(color.Alpha{A: 0xff}),
|
||||
Face: face,
|
||||
Dot: fixed.Point26_6{
|
||||
X: 0,
|
||||
Y: fixed.I(metrics.Ascent.Ceil()),
|
||||
},
|
||||
}
|
||||
d.DrawString(text)
|
||||
|
||||
x0 := mmToPx(xMm, dpi)
|
||||
y0 := mmToPx(yMm, dpi)
|
||||
b := img.Bounds()
|
||||
for sy := 0; sy < srcH; sy++ {
|
||||
for sx := 0; sx < srcW; sx++ {
|
||||
if src.AlphaAt(sx, sy).A == 0 {
|
||||
continue
|
||||
}
|
||||
dx := srcH - 1 - sy
|
||||
dy := sx
|
||||
x := x0 + dx
|
||||
y := y0 + dy
|
||||
if x < b.Min.X || x >= b.Max.X || y < b.Min.Y || y >= b.Max.Y {
|
||||
continue
|
||||
}
|
||||
img.Pix[y*img.Stride+x] = idx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drawQR(img *image.Paletted, code *qr.Code, dpi, xMm, yMm, sizeMm float64, idx uint8) {
|
||||
if code == nil {
|
||||
return
|
||||
}
|
||||
x0 := mmToPx(xMm, dpi)
|
||||
y0 := mmToPx(yMm, dpi)
|
||||
sizePx := mmToPx(sizeMm, dpi)
|
||||
const quiet = 4
|
||||
step := float64(sizePx) / float64(code.Size+2*quiet)
|
||||
offset := int(math.Round(float64(quiet) * step))
|
||||
|
||||
for y := 0; y < code.Size; y++ {
|
||||
yStart := y0 + offset + int(math.Round(float64(y)*step))
|
||||
yEnd := y0 + offset + int(math.Round(float64(y+1)*step))
|
||||
for x := 0; x < code.Size; x++ {
|
||||
if !code.Black(x, y) {
|
||||
continue
|
||||
}
|
||||
xStart := x0 + offset + int(math.Round(float64(x)*step))
|
||||
xEnd := x0 + offset + int(math.Round(float64(x+1)*step))
|
||||
fillRect(img, xStart, yStart, xEnd-xStart, yEnd-yStart, idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wrapText performs character-level wrapping to ensure long descriptors fit even without spaces.
|
||||
func wrapText(face font.Face, dpi float64, text string, maxWidthMm float64) []string {
|
||||
var lines []string
|
||||
if maxWidthMm <= 0 {
|
||||
return []string{text}
|
||||
}
|
||||
maxPx := int(math.Round(mmToPxFloat(maxWidthMm, dpi)))
|
||||
if maxPx <= 0 {
|
||||
return []string{text}
|
||||
}
|
||||
|
||||
d := font.Drawer{Face: face}
|
||||
var buf []rune
|
||||
for _, r := range text {
|
||||
buf = append(buf, r)
|
||||
if d.MeasureString(string(buf)).Ceil() > maxPx {
|
||||
// Overflow: push previous run and start new line with current rune.
|
||||
if len(buf) > 1 {
|
||||
lines = append(lines, string(buf[:len(buf)-1]))
|
||||
buf = buf[len(buf)-1:]
|
||||
} else {
|
||||
// Single rune too wide; force as line.
|
||||
lines = append(lines, string(buf))
|
||||
buf = buf[:0]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(buf) > 0 {
|
||||
lines = append(lines, string(buf))
|
||||
}
|
||||
return lines
|
||||
totalModules := float64(code.Size + 2*quietModules)
|
||||
moduleMM := qrSizeMM / totalModules
|
||||
return moduleMM * float64(quietModules)
|
||||
}
|
||||
|
||||
func loadFace(sizePt, dpi float64) font.Face {
|
||||
@ -623,9 +314,9 @@ func loadFace(sizePt, dpi float64) font.Face {
|
||||
faceMu.Unlock()
|
||||
|
||||
fontOnce.Do(func() {
|
||||
data := loadFontData(martianMono)
|
||||
data := loadFirstFontData(plateFontPrimary, martianMono)
|
||||
if data == nil {
|
||||
fontErr = fmt.Errorf("font data %s not found", martianMono)
|
||||
fontErr = fmt.Errorf("font data not found (tried %s, %s)", plateFontPrimary, martianMono)
|
||||
return
|
||||
}
|
||||
fontFaceData, fontErr = opentype.Parse(data)
|
||||
@ -646,50 +337,6 @@ func loadFace(sizePt, dpi float64) font.Face {
|
||||
return basicfont.Face7x13
|
||||
}
|
||||
|
||||
func applyPostProcess(img *image.Paletted, opts RasterOptions) {
|
||||
if opts.Mirror {
|
||||
mirrorHorizontal(img)
|
||||
}
|
||||
}
|
||||
|
||||
func invertExcept(img *image.Paletted, keep []image.Rectangle) {
|
||||
keepMask := make([]bool, img.Bounds().Dx()*img.Bounds().Dy())
|
||||
for _, r := range keep {
|
||||
for y := r.Min.Y; y < r.Max.Y; y++ {
|
||||
for x := r.Min.X; x < r.Max.X; x++ {
|
||||
idx := y*img.Stride + x
|
||||
if idx >= 0 && idx < len(keepMask) {
|
||||
keepMask[idx] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ {
|
||||
row := img.Pix[y*img.Stride:]
|
||||
for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ {
|
||||
idx := y*img.Stride + x
|
||||
if keepMask[idx] {
|
||||
continue
|
||||
}
|
||||
if row[x] == 0 {
|
||||
row[x] = 1
|
||||
} else if row[x] == 1 {
|
||||
row[x] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mirrorHorizontal(img *image.Paletted) {
|
||||
w, h := img.Bounds().Dx(), img.Bounds().Dy()
|
||||
for y := 0; y < h; y++ {
|
||||
row := img.Pix[y*img.Stride:]
|
||||
for x := 0; x < w/2; x++ {
|
||||
row[x], row[w-1-x] = row[w-1-x], row[x]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadFaceMedium(sizePt, dpi float64) font.Face {
|
||||
key := [2]float64{sizePt, dpi}
|
||||
faceMuMedium.Lock()
|
||||
@ -700,9 +347,9 @@ func loadFaceMedium(sizePt, dpi float64) font.Face {
|
||||
faceMuMedium.Unlock()
|
||||
|
||||
fontOnceMedium.Do(func() {
|
||||
data := loadFontData(martianMonoMedium)
|
||||
data := loadFirstFontData(martianMonoMedium, martianMono, plateFontPrimary)
|
||||
if data == nil {
|
||||
fontErrMedium = fmt.Errorf("font data %s not found", martianMonoMedium)
|
||||
fontErrMedium = fmt.Errorf("font data not found (tried %s, %s, %s)", martianMonoMedium, martianMono, plateFontPrimary)
|
||||
return
|
||||
}
|
||||
fontFaceDataMedium, fontErrMedium = opentype.Parse(data)
|
||||
@ -723,3 +370,15 @@ func loadFaceMedium(sizePt, dpi float64) font.Face {
|
||||
|
||||
return loadFace(sizePt, dpi)
|
||||
}
|
||||
|
||||
func loadFirstFontData(paths ...string) []byte {
|
||||
for _, p := range paths {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if data := loadFontData(p); data != nil {
|
||||
return data
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
164
printer/raster_compact2of3.go
Normal file
164
printer/raster_compact2of3.go
Normal file
@ -0,0 +1,164 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"strings"
|
||||
|
||||
"github.com/kortschak/qr"
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/bip39"
|
||||
"seedetcher.com/seedqr"
|
||||
)
|
||||
|
||||
func renderCompact2of3PlateBitmap(mnemonic bip39.Mnemonic, desc *urtypes.OutputDescriptor, keyIdx int, opts RasterOptions, descQR string) (*image.Paletted, error) {
|
||||
dpi := opts.dpi()
|
||||
canvas := newPlateCanvas(dpi)
|
||||
blackIdx := uint8(1)
|
||||
|
||||
border := mmToPx(borderWidthMM, dpi)
|
||||
if border < 1 {
|
||||
border = 1
|
||||
}
|
||||
strokeRect(canvas, 0, 0, canvas.Bounds().Dx(), canvas.Bounds().Dy(), border, blackIdx)
|
||||
|
||||
metaFace := loadFace(10, dpi)
|
||||
wordFace := loadFace(11, dpi)
|
||||
metaTrackPx := 0.08 * 10.0 * dpi / 72.0
|
||||
metaTrackPxNm := 0 * 10.0 * dpi / 72.0
|
||||
wordTrackPx := 0.04 * 11.0 * dpi / 72.0
|
||||
wordLeadingMM := 9.8 * 25.4 / 72.0
|
||||
|
||||
const (
|
||||
topMarginMM = 3.0
|
||||
topLeftXMM = 8.5
|
||||
topRightRightMM = plateSizeMM - 3.0
|
||||
leftPathXMM = 3.0
|
||||
wordsStartTopCapYMM = 8.0
|
||||
col1WordsXMM = 8.0
|
||||
col2WordsXMM = 34.0
|
||||
col3WordsXMM = 61.0
|
||||
descQRSizeMM = 59.0
|
||||
seedQRSizeMM = 27.0
|
||||
qrPairRightMarginMM = 3.0
|
||||
)
|
||||
|
||||
fpText := strings.ToUpper(fmt.Sprintf("%08x", desc.Keys[keyIdx].MasterFingerprint))
|
||||
topBaselineY := topMarginMM + capBaselineOffsetMM(metaFace, dpi)
|
||||
DrawMetaLine(canvas, dpi, topLeftXMM, topBaselineY, metaFace, metaTrackPx, fpText)
|
||||
label := strings.ToUpper(walletLabel())
|
||||
labelW := trackedTextWidthMM(metaFace, dpi, label, metaTrackPx)
|
||||
DrawMetaLine(canvas, dpi, topRightRightMM-labelW, topBaselineY, metaFace, metaTrackPx, label)
|
||||
|
||||
path := strings.ToUpper(derivationPathForKey(desc.Keys[keyIdx], desc.Script))
|
||||
leftMeta := fmt.Sprintf("%s/%s/NET:%s", path, desc.Script.Tag(), descriptorNetworkTag(desc.Keys[keyIdx].Network))
|
||||
DrawRotatedLabel(canvas, dpi, leftPathXMM, topMarginMM, metaFace, metaTrackPx, blackIdx, leftMeta)
|
||||
|
||||
nm := fmt.Sprintf("%d/%d(%d/%d)", keyIdx+1, len(desc.Keys), desc.Threshold, len(desc.Keys))
|
||||
_, nmRotH := rotatedTextSizeMM(metaFace, dpi, nm)
|
||||
nmY := plateSizeMM - topMarginMM - nmRotH
|
||||
if nmY < topMarginMM {
|
||||
nmY = topMarginMM
|
||||
}
|
||||
DrawRotatedLabel(canvas, dpi, leftPathXMM, nmY, metaFace, metaTrackPxNm, blackIdx, nm)
|
||||
|
||||
descQRX := plateSizeMM - qrPairRightMarginMM - descQRSizeMM + 3
|
||||
descQRY := plateSizeMM - descQRSizeMM
|
||||
seedQRX := descQRX - seedQRSizeMM + 2.5
|
||||
seedQRY := plateSizeMM - seedQRSizeMM
|
||||
|
||||
wordStartBaselineY := wordsStartTopCapYMM + capBaselineOffsetMM(wordFace, dpi)
|
||||
numColW := trackedTextWidthMM(wordFace, dpi, "24", wordTrackPx)
|
||||
spaceW := trackedTextWidthMM(wordFace, dpi, " ", wordTrackPx) + 0.1 // space between numbers col and word col
|
||||
y1 := wordStartBaselineY
|
||||
y2 := wordStartBaselineY
|
||||
y3 := wordStartBaselineY
|
||||
leading := wordLeadingMM
|
||||
col1Count := len(mnemonic) / 2
|
||||
col2Count := len(mnemonic) - col1Count
|
||||
col3Count := 0
|
||||
if len(mnemonic) == 24 {
|
||||
col1Count, col2Count, col3Count = 10, 7, 7
|
||||
} else if len(mnemonic) == 12 {
|
||||
col1Count, col2Count, col3Count = 6, 6, 0
|
||||
}
|
||||
for i := 0; i < len(mnemonic); i++ {
|
||||
if mnemonic[i] == -1 {
|
||||
continue
|
||||
}
|
||||
num := fmt.Sprintf("%d", i+1)
|
||||
word := strings.ToUpper(bip39.LabelFor(mnemonic[i]))
|
||||
numW := trackedTextWidthMM(wordFace, dpi, num, wordTrackPx)
|
||||
if i < col1Count {
|
||||
drawTrackedText(canvas, wordFace, dpi, col1WordsXMM+numColW-numW, y1, num, wordTrackPx)
|
||||
drawTrackedText(canvas, wordFace, dpi, col1WordsXMM+numColW+spaceW, y1, word, wordTrackPx)
|
||||
y1 += leading
|
||||
continue
|
||||
}
|
||||
if i < col1Count+col2Count {
|
||||
drawTrackedText(canvas, wordFace, dpi, col2WordsXMM+numColW-numW, y2, num, wordTrackPx)
|
||||
drawTrackedText(canvas, wordFace, dpi, col2WordsXMM+numColW+spaceW, y2, word, wordTrackPx)
|
||||
y2 += leading
|
||||
continue
|
||||
}
|
||||
if col3Count > 0 {
|
||||
drawTrackedText(canvas, wordFace, dpi, col3WordsXMM+numColW-numW, y3, num, wordTrackPx)
|
||||
drawTrackedText(canvas, wordFace, dpi, col3WordsXMM+numColW+spaceW, y3, word, wordTrackPx)
|
||||
y3 += leading
|
||||
}
|
||||
}
|
||||
|
||||
// Compact warning block in lower-left free space.
|
||||
warnFace := loadFace(10, dpi)
|
||||
warnTrackPx := 0.04 * 10.0 * dpi / 72.0
|
||||
warnLeadingMM := 9.7 * 25.4 / 72.0
|
||||
descrX := 18.0
|
||||
descrBaselineY := 47.0 + capBaselineOffsetMM(warnFace, dpi)
|
||||
DrawMetaLine(canvas, dpi, descrX, descrBaselineY, warnFace, warnTrackPx, "DESCR→")
|
||||
|
||||
warnX := 9.0
|
||||
warnBaselineY := 50.0 + capBaselineOffsetMM(warnFace, dpi)
|
||||
_ = DrawTextBlock(canvas, dpi, TextBlock{
|
||||
Face: warnFace,
|
||||
Tracking: warnTrackPx,
|
||||
LeadingMM: warnLeadingMM,
|
||||
WidthMM: 24.0,
|
||||
Align: TextAlignStart,
|
||||
OriginXMM: warnX,
|
||||
OriginYMM: warnBaselineY,
|
||||
}, "↑\nNEVER SCAN\nWITH ONLINE\nDEVICE↓")
|
||||
|
||||
seedPayload := seedqr.QR(mnemonic)
|
||||
if len(seedPayload) > 0 {
|
||||
if seedCode, err := qr.Encode(string(seedPayload), qr.M); err == nil {
|
||||
drawPlateQR(canvas, seedCode, dpi, seedQRX, seedQRY, seedQRSizeMM, blackIdx, plateQROptions{
|
||||
QuietModules: 4,
|
||||
Shape: plateQRCircle,
|
||||
KeepIslandsSquare: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
qrContent := descQR
|
||||
if qrContent == "" {
|
||||
qrContent = createDescriptorQR(desc)
|
||||
}
|
||||
if qrContent == "" {
|
||||
return nil, fmt.Errorf("empty descriptor QR content")
|
||||
}
|
||||
descCode, err := qr.Encode(qrContent, descriptorQRECC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
drawPlateQR(canvas, descCode, dpi, descQRX, descQRY, descQRSizeMM, blackIdx, plateQROptions{
|
||||
QuietModules: 4,
|
||||
Shape: plateQRCircle,
|
||||
KeepIslandsSquare: true,
|
||||
})
|
||||
|
||||
if opts.Invert {
|
||||
invertInterior(canvas, border)
|
||||
}
|
||||
applyPostProcess(canvas, opts)
|
||||
return canvas, nil
|
||||
}
|
||||
95
printer/raster_draw.go
Normal file
95
printer/raster_draw.go
Normal file
@ -0,0 +1,95 @@
|
||||
package printer
|
||||
|
||||
import "image"
|
||||
|
||||
func strokeRect(img *image.Paletted, x, y, w, h, thickness int, idx uint8) {
|
||||
fillRect(img, x, y, w, thickness, idx) // top
|
||||
fillRect(img, x, y+h-thickness, w, thickness, idx) // bottom
|
||||
fillRect(img, x, y, thickness, h, idx) // left
|
||||
fillRect(img, x+w-thickness, y, thickness, h, idx) // right
|
||||
}
|
||||
|
||||
func fillRect(img *image.Paletted, x, y, w, h int, idx uint8) {
|
||||
b := img.Bounds()
|
||||
x0, y0 := clampInt(x, b.Min.X, b.Max.X), clampInt(y, b.Min.Y, b.Max.Y)
|
||||
x1, y1 := clampInt(x+w, b.Min.X, b.Max.X), clampInt(y+h, b.Min.Y, b.Max.Y)
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return
|
||||
}
|
||||
for yy := y0; yy < y1; yy++ {
|
||||
row := img.Pix[yy*img.Stride:]
|
||||
for xx := x0; xx < x1; xx++ {
|
||||
row[xx] = idx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clampInt(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func absInt(v int) int {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func fillRectWithRoundedCorners(img *image.Paletted, x, y, w, h, radius int, fg, bg uint8, cornerMask uint8) {
|
||||
if w <= 0 || h <= 0 {
|
||||
return
|
||||
}
|
||||
fillRect(img, x, y, w, h, fg)
|
||||
if radius <= 0 || cornerMask == 0 {
|
||||
return
|
||||
}
|
||||
if radius > w/2 {
|
||||
radius = w / 2
|
||||
}
|
||||
if radius > h/2 {
|
||||
radius = h / 2
|
||||
}
|
||||
if radius <= 0 {
|
||||
return
|
||||
}
|
||||
r2 := radius * radius
|
||||
for dy := 0; dy < radius; dy++ {
|
||||
for dx := 0; dx < radius; dx++ {
|
||||
// Keep pixels inside quarter-circle, clear outside.
|
||||
inQuarter := dx*dx+dy*dy <= r2
|
||||
if inQuarter {
|
||||
continue
|
||||
}
|
||||
if cornerMask&cornerTL != 0 {
|
||||
px, py := x+radius-1-dx, y+radius-1-dy
|
||||
if px >= img.Rect.Min.X && px < img.Rect.Max.X && py >= img.Rect.Min.Y && py < img.Rect.Max.Y {
|
||||
img.Pix[py*img.Stride+px] = bg
|
||||
}
|
||||
}
|
||||
if cornerMask&cornerTR != 0 {
|
||||
px, py := x+w-radius+dx, y+radius-1-dy
|
||||
if px >= img.Rect.Min.X && px < img.Rect.Max.X && py >= img.Rect.Min.Y && py < img.Rect.Max.Y {
|
||||
img.Pix[py*img.Stride+px] = bg
|
||||
}
|
||||
}
|
||||
if cornerMask&cornerBL != 0 {
|
||||
px, py := x+radius-1-dx, y+h-radius+dy
|
||||
if px >= img.Rect.Min.X && px < img.Rect.Max.X && py >= img.Rect.Min.Y && py < img.Rect.Max.Y {
|
||||
img.Pix[py*img.Stride+px] = bg
|
||||
}
|
||||
}
|
||||
if cornerMask&cornerBR != 0 {
|
||||
px, py := x+w-radius+dx, y+h-radius+dy
|
||||
if px >= img.Rect.Min.X && px < img.Rect.Max.X && py >= img.Rect.Min.Y && py < img.Rect.Max.Y {
|
||||
img.Pix[py*img.Stride+px] = bg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
printer/raster_postprocess.go
Normal file
58
printer/raster_postprocess.go
Normal file
@ -0,0 +1,58 @@
|
||||
package printer
|
||||
|
||||
import "image"
|
||||
|
||||
func applyPostProcess(img *image.Paletted, opts RasterOptions) {
|
||||
if opts.Mirror {
|
||||
mirrorHorizontal(img)
|
||||
}
|
||||
}
|
||||
|
||||
func invertAll(img *image.Paletted) {
|
||||
for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ {
|
||||
row := img.Pix[y*img.Stride:]
|
||||
for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ {
|
||||
if row[x] == 0 {
|
||||
row[x] = 1
|
||||
} else if row[x] == 1 {
|
||||
row[x] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// invertInterior flips black/white inside the plate while preserving the outer border.
|
||||
func invertInterior(img *image.Paletted, borderPx int) {
|
||||
if borderPx <= 0 {
|
||||
invertAll(img)
|
||||
return
|
||||
}
|
||||
b := img.Bounds()
|
||||
x0 := b.Min.X + borderPx
|
||||
y0 := b.Min.Y + borderPx
|
||||
x1 := b.Max.X - borderPx
|
||||
y1 := b.Max.Y - borderPx
|
||||
if x0 >= x1 || y0 >= y1 {
|
||||
return
|
||||
}
|
||||
for y := y0; y < y1; y++ {
|
||||
row := img.Pix[y*img.Stride:]
|
||||
for x := x0; x < x1; x++ {
|
||||
if row[x] == 0 {
|
||||
row[x] = 1
|
||||
} else if row[x] == 1 {
|
||||
row[x] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mirrorHorizontal(img *image.Paletted) {
|
||||
w, h := img.Bounds().Dx(), img.Bounds().Dy()
|
||||
for y := 0; y < h; y++ {
|
||||
row := img.Pix[y*img.Stride:]
|
||||
for x := 0; x < w/2; x++ {
|
||||
row[x], row[w-1-x] = row[w-1-x], row[x]
|
||||
}
|
||||
}
|
||||
}
|
||||
315
printer/raster_qr_helpers.go
Normal file
315
printer/raster_qr_helpers.go
Normal file
@ -0,0 +1,315 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"image"
|
||||
"math"
|
||||
|
||||
"github.com/kortschak/qr"
|
||||
)
|
||||
|
||||
func drawPlateQR(img *image.Paletted, code *qr.Code, dpi, xMm, yMm, sizeMm float64, idx uint8, opts plateQROptions) {
|
||||
if code == nil {
|
||||
return
|
||||
}
|
||||
if opts.QuietModules < 0 {
|
||||
opts.QuietModules = 0
|
||||
}
|
||||
x0 := mmToPx(xMm, dpi)
|
||||
y0 := mmToPx(yMm, dpi)
|
||||
sizePx := mmToPx(sizeMm, dpi)
|
||||
quiet := opts.QuietModules
|
||||
step := float64(sizePx) / float64(code.Size+2*quiet)
|
||||
offset := int(math.Round(float64(quiet) * step))
|
||||
var islandMask []bool
|
||||
if opts.KeepIslandsSquare {
|
||||
islandMask = buildQRIslandMask(code)
|
||||
}
|
||||
patternRadiusRatio := opts.PatternCornerRadiusRatio
|
||||
if patternRadiusRatio < 0 {
|
||||
patternRadiusRatio = 0
|
||||
}
|
||||
if patternRadiusRatio == 0 {
|
||||
patternRadiusRatio = plateQRPatternCornerRadiusRatio
|
||||
}
|
||||
if patternRadiusRatio > 0.5 {
|
||||
patternRadiusRatio = 0.5
|
||||
}
|
||||
|
||||
for y := 0; y < code.Size; y++ {
|
||||
yStart := y0 + offset + int(math.Round(float64(y)*step))
|
||||
yEnd := y0 + offset + int(math.Round(float64(y+1)*step))
|
||||
for x := 0; x < code.Size; x++ {
|
||||
if !code.Black(x, y) {
|
||||
continue
|
||||
}
|
||||
xStart := x0 + offset + int(math.Round(float64(x)*step))
|
||||
xEnd := x0 + offset + int(math.Round(float64(x+1)*step))
|
||||
|
||||
useSquare := opts.Shape == plateQRSquare
|
||||
isPatternModule := opts.KeepIslandsSquare && islandMask[y*code.Size+x]
|
||||
if isPatternModule {
|
||||
useSquare = true
|
||||
}
|
||||
if useSquare {
|
||||
fillRect(img, xStart, yStart, xEnd-xStart, yEnd-yStart, idx)
|
||||
} else {
|
||||
fillModuleCircleAtStep(img, xStart, yStart, xEnd, yEnd, idx, plateQRDotScale, step)
|
||||
}
|
||||
}
|
||||
}
|
||||
if opts.KeepIslandsSquare && patternRadiusRatio > 0 {
|
||||
applyFinderCornerRounding(img, code, x0+offset, y0+offset, step, idx, oppositeBWIndex(idx), patternRadiusRatio)
|
||||
}
|
||||
}
|
||||
|
||||
func minIntQR(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func oppositeBWIndex(idx uint8) uint8 {
|
||||
if idx == 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func moduleRect(origin int, step float64, startModule, endModule int) (int, int) {
|
||||
start := origin + int(math.Round(float64(startModule)*step))
|
||||
end := origin + int(math.Round(float64(endModule)*step))
|
||||
return start, end - start
|
||||
}
|
||||
|
||||
func finderModuleCornerRadius(step, ratio float64) int {
|
||||
modulePx := int(math.Round(step))
|
||||
if modulePx < 1 {
|
||||
modulePx = 1
|
||||
}
|
||||
// Map 0..0.5 => 0..1 module corner radius.
|
||||
r := int(math.Round(float64(modulePx) * (ratio * 2.0)))
|
||||
if r < 1 {
|
||||
r = 1
|
||||
}
|
||||
if r > modulePx {
|
||||
r = modulePx
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func roundPatternModule(img *image.Paletted, code *qr.Code, originX, originY int, step float64, x, y int, corners uint8, blackIdx, whiteIdx uint8, radius int) {
|
||||
if x < 0 || y < 0 || x >= code.Size || y >= code.Size || corners == 0 {
|
||||
return
|
||||
}
|
||||
xStart, w := moduleRect(originX, step, x, x+1)
|
||||
yStart, h := moduleRect(originY, step, y, y+1)
|
||||
fg, bg := whiteIdx, blackIdx
|
||||
if code.Black(x, y) {
|
||||
fg, bg = blackIdx, whiteIdx
|
||||
}
|
||||
fillRectWithRoundedCorners(img, xStart, yStart, w, h, radius, fg, bg, corners)
|
||||
}
|
||||
|
||||
func applyFinderCornerRoundingAt(img *image.Paletted, code *qr.Code, originX, originY int, step float64, fx, fy int, blackIdx, whiteIdx uint8, radius int) {
|
||||
// Outer black ring corners.
|
||||
roundPatternModule(img, code, originX, originY, step, fx+0, fy+0, cornerTL, blackIdx, whiteIdx, radius)
|
||||
roundPatternModule(img, code, originX, originY, step, fx+6, fy+0, cornerTR, blackIdx, whiteIdx, radius)
|
||||
roundPatternModule(img, code, originX, originY, step, fx+0, fy+6, cornerBL, blackIdx, whiteIdx, radius)
|
||||
roundPatternModule(img, code, originX, originY, step, fx+6, fy+6, cornerBR, blackIdx, whiteIdx, radius)
|
||||
// White ring corners.
|
||||
roundPatternModule(img, code, originX, originY, step, fx+1, fy+1, cornerTL, blackIdx, whiteIdx, radius)
|
||||
roundPatternModule(img, code, originX, originY, step, fx+5, fy+1, cornerTR, blackIdx, whiteIdx, radius)
|
||||
roundPatternModule(img, code, originX, originY, step, fx+1, fy+5, cornerBL, blackIdx, whiteIdx, radius)
|
||||
roundPatternModule(img, code, originX, originY, step, fx+5, fy+5, cornerBR, blackIdx, whiteIdx, radius)
|
||||
// Inner black (3x3) corners.
|
||||
roundPatternModule(img, code, originX, originY, step, fx+2, fy+2, cornerTL, blackIdx, whiteIdx, radius)
|
||||
roundPatternModule(img, code, originX, originY, step, fx+4, fy+2, cornerTR, blackIdx, whiteIdx, radius)
|
||||
roundPatternModule(img, code, originX, originY, step, fx+2, fy+4, cornerBL, blackIdx, whiteIdx, radius)
|
||||
roundPatternModule(img, code, originX, originY, step, fx+4, fy+4, cornerBR, blackIdx, whiteIdx, radius)
|
||||
}
|
||||
|
||||
func inFinderArea(x, y, size int) bool {
|
||||
inTL := x >= 0 && x <= 6 && y >= 0 && y <= 6
|
||||
inTR := x >= size-7 && x <= size-1 && y >= 0 && y <= 6
|
||||
inBL := x >= 0 && x <= 6 && y >= size-7 && y <= size-1
|
||||
return inTL || inTR || inBL
|
||||
}
|
||||
|
||||
func applyAlignmentPatternStyling(img *image.Paletted, code *qr.Code, originX, originY int, step float64, blackIdx, whiteIdx uint8, radius int) {
|
||||
size := code.Size
|
||||
for cy := 2; cy <= size-3; cy++ {
|
||||
for cx := 2; cx <= size-3; cx++ {
|
||||
if inFinderArea(cx, cy, size) || !isAlignmentCenter(code, cx, cy) {
|
||||
continue
|
||||
}
|
||||
// 5x5 outer black corners.
|
||||
roundPatternModule(img, code, originX, originY, step, cx-2, cy-2, cornerTL, blackIdx, whiteIdx, radius)
|
||||
roundPatternModule(img, code, originX, originY, step, cx+2, cy-2, cornerTR, blackIdx, whiteIdx, radius)
|
||||
roundPatternModule(img, code, originX, originY, step, cx-2, cy+2, cornerBL, blackIdx, whiteIdx, radius)
|
||||
roundPatternModule(img, code, originX, originY, step, cx+2, cy+2, cornerBR, blackIdx, whiteIdx, radius)
|
||||
// 3x3 white ring corners.
|
||||
roundPatternModule(img, code, originX, originY, step, cx-1, cy-1, cornerTL, blackIdx, whiteIdx, radius)
|
||||
roundPatternModule(img, code, originX, originY, step, cx+1, cy-1, cornerTR, blackIdx, whiteIdx, radius)
|
||||
roundPatternModule(img, code, originX, originY, step, cx-1, cy+1, cornerBL, blackIdx, whiteIdx, radius)
|
||||
roundPatternModule(img, code, originX, originY, step, cx+1, cy+1, cornerBR, blackIdx, whiteIdx, radius)
|
||||
|
||||
// Center module as an explicit circle at plateQRDotScale.
|
||||
xStart, w := moduleRect(originX, step, cx, cx+1)
|
||||
yStart, h := moduleRect(originY, step, cy, cy+1)
|
||||
fillRect(img, xStart, yStart, w, h, whiteIdx)
|
||||
fillModuleCircleAtStep(img, xStart, yStart, xStart+w, yStart+h, blackIdx, plateQRDotScale, step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyFinderCornerRounding(img *image.Paletted, code *qr.Code, originX, originY int, step float64, blackIdx, whiteIdx uint8, radiusRatio float64) {
|
||||
size := code.Size
|
||||
r := finderModuleCornerRadius(step, radiusRatio)
|
||||
applyFinderCornerRoundingAt(img, code, originX, originY, step, 0, 0, blackIdx, whiteIdx, r)
|
||||
applyFinderCornerRoundingAt(img, code, originX, originY, step, size-7, 0, blackIdx, whiteIdx, r)
|
||||
applyFinderCornerRoundingAt(img, code, originX, originY, step, 0, size-7, blackIdx, whiteIdx, r)
|
||||
applyAlignmentPatternStyling(img, code, originX, originY, step, blackIdx, whiteIdx, r)
|
||||
}
|
||||
|
||||
const (
|
||||
cornerTL uint8 = 1 << iota
|
||||
cornerTR
|
||||
cornerBL
|
||||
cornerBR
|
||||
)
|
||||
|
||||
func fillModuleCircle(img *image.Paletted, xStart, yStart, xEnd, yEnd int, idx uint8) {
|
||||
fillModuleCircleScaled(img, xStart, yStart, xEnd, yEnd, idx, plateQRDotScale)
|
||||
}
|
||||
|
||||
func fillModuleCircleAtStep(img *image.Paletted, xStart, yStart, xEnd, yEnd int, idx uint8, scale, step float64) {
|
||||
w := xEnd - xStart
|
||||
h := yEnd - yStart
|
||||
if w <= 0 || h <= 0 {
|
||||
return
|
||||
}
|
||||
if scale <= 0 {
|
||||
scale = 1.0
|
||||
}
|
||||
if scale > 1.0 {
|
||||
scale = 1.0
|
||||
}
|
||||
d := int(math.Round(step * scale))
|
||||
if d < 1 {
|
||||
d = 1
|
||||
}
|
||||
cellMin := minIntQR(w, h)
|
||||
if d > cellMin {
|
||||
d = cellMin
|
||||
}
|
||||
// Center a fixed-size circle in the cell, then render via scaled helper.
|
||||
cx := xStart + w/2
|
||||
cy := yStart + h/2
|
||||
left := cx - d/2
|
||||
top := cy - d/2
|
||||
fillModuleCircleScaled(img, left, top, left+d, top+d, idx, 1.0)
|
||||
}
|
||||
|
||||
func fillModuleCircleScaled(img *image.Paletted, xStart, yStart, xEnd, yEnd int, idx uint8, scale float64) {
|
||||
w := xEnd - xStart
|
||||
h := yEnd - yStart
|
||||
if w <= 0 || h <= 0 {
|
||||
return
|
||||
}
|
||||
b := img.Bounds()
|
||||
if xEnd <= b.Min.X || xStart >= b.Max.X || yEnd <= b.Min.Y || yStart >= b.Max.Y {
|
||||
return
|
||||
}
|
||||
d := w
|
||||
if h < d {
|
||||
d = h
|
||||
}
|
||||
if scale <= 0 {
|
||||
scale = 1.0
|
||||
}
|
||||
if scale > 1.0 {
|
||||
scale = 1.0
|
||||
}
|
||||
scaled := int(math.Round(float64(d) * scale))
|
||||
if scaled < 1 {
|
||||
scaled = 1
|
||||
}
|
||||
d = scaled
|
||||
if d <= 1 {
|
||||
fillRect(img, xStart, yStart, w, h, idx)
|
||||
return
|
||||
}
|
||||
cx2 := 2*xStart + w
|
||||
cy2 := 2*yStart + h
|
||||
r := d - 1
|
||||
r2 := r * r
|
||||
y0 := clampInt(yStart, b.Min.Y, b.Max.Y)
|
||||
y1 := clampInt(yEnd, b.Min.Y, b.Max.Y)
|
||||
x0 := clampInt(xStart, b.Min.X, b.Max.X)
|
||||
x1 := clampInt(xEnd, b.Min.X, b.Max.X)
|
||||
for y := y0; y < y1; y++ {
|
||||
dy2 := 2*y + 1 - cy2
|
||||
row := img.Pix[y*img.Stride:]
|
||||
for x := x0; x < x1; x++ {
|
||||
dx2 := 2*x + 1 - cx2
|
||||
if dx2*dx2+dy2*dy2 <= r2 {
|
||||
row[x] = idx
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildQRIslandMask(code *qr.Code) []bool {
|
||||
size := code.Size
|
||||
mask := make([]bool, size*size)
|
||||
markRect := func(x0, y0, w, h int) {
|
||||
for y := y0; y < y0+h; y++ {
|
||||
if y < 0 || y >= size {
|
||||
continue
|
||||
}
|
||||
row := y * size
|
||||
for x := x0; x < x0+w; x++ {
|
||||
if x < 0 || x >= size {
|
||||
continue
|
||||
}
|
||||
mask[row+x] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finder patterns are always 7x7 in 3 corners.
|
||||
markRect(0, 0, 7, 7)
|
||||
markRect(size-7, 0, 7, 7)
|
||||
markRect(0, size-7, 7, 7)
|
||||
|
||||
// Detect alignment patterns directly from the encoded module matrix.
|
||||
// This avoids version-center math drift and keeps islands stable across sizes.
|
||||
for cy := 2; cy <= size-3; cy++ {
|
||||
for cx := 2; cx <= size-3; cx++ {
|
||||
if !isAlignmentCenter(code, cx, cy) {
|
||||
continue
|
||||
}
|
||||
markRect(cx-2, cy-2, 5, 5)
|
||||
}
|
||||
}
|
||||
return mask
|
||||
}
|
||||
|
||||
func isAlignmentCenter(code *qr.Code, cx, cy int) bool {
|
||||
size := code.Size
|
||||
if cx-2 < 0 || cy-2 < 0 || cx+2 >= size || cy+2 >= size {
|
||||
return false
|
||||
}
|
||||
for dy := -2; dy <= 2; dy++ {
|
||||
for dx := -2; dx <= 2; dx++ {
|
||||
ax := absInt(dx)
|
||||
ay := absInt(dy)
|
||||
wantBlack := ax == 2 || ay == 2 || (ax == 0 && ay == 0)
|
||||
if code.Black(cx+dx, cy+dy) != wantBlack {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@ -1,14 +1,16 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"seedetcher.com/descriptor/shard"
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/descriptor/urxor2of3"
|
||||
"seedetcher.com/testutils"
|
||||
)
|
||||
|
||||
func TestDescriptorShardQRCodesAreSE1AndConsistentSet(t *testing.T) {
|
||||
func TestDescriptorShardQRCodes2of3UseURXORAndRecover(t *testing.T) {
|
||||
cfg := testutils.WalletConfigs["multisig"]
|
||||
_, desc, err := testutils.ParseWallet(cfg, "", "")
|
||||
if err != nil {
|
||||
@ -24,29 +26,34 @@ func TestDescriptorShardQRCodesAreSE1AndConsistentSet(t *testing.T) {
|
||||
if len(qrs) != len(desc.Keys) {
|
||||
t.Fatalf("got %d qrs, want %d", len(qrs), len(desc.Keys))
|
||||
}
|
||||
var base shard.Share
|
||||
want, err := urxor2of3.Combine([]string{qrs[0], qrs[1]})
|
||||
if err != nil {
|
||||
t.Fatalf("combine first two shares: %v", err)
|
||||
}
|
||||
for i, q := range qrs {
|
||||
if !strings.HasPrefix(strings.ToUpper(q), shard.Prefix) {
|
||||
t.Fatalf("share %d has non-shard payload: %q", i+1, q)
|
||||
typ, _, seqLen, ok := urxor2of3.ParseShare(q)
|
||||
if !ok || typ != "crypto-output" || seqLen != desc.Threshold {
|
||||
t.Fatalf("share %d has non ur/xor payload: %q", i+1, q)
|
||||
}
|
||||
sh, err := shard.Decode(strings.ToUpper(q))
|
||||
got, err := urxor2of3.Combine([]string{qrs[i], qrs[(i+1)%len(qrs)]})
|
||||
if err != nil {
|
||||
t.Fatalf("decode share %d: %v", i+1, err)
|
||||
t.Fatalf("combine pair for share %d: %v", i+1, err)
|
||||
}
|
||||
if i == 0 {
|
||||
base = sh
|
||||
}
|
||||
if sh.SetID != base.SetID {
|
||||
t.Fatalf("share %d set_id mismatch", i+1)
|
||||
}
|
||||
if sh.Threshold != uint8(desc.Threshold) || sh.Total != uint8(len(desc.Keys)) {
|
||||
t.Fatalf("share %d threshold/total mismatch: got %d/%d want %d/%d", i+1, sh.Threshold, sh.Total, desc.Threshold, len(desc.Keys))
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("share %d recovered payload mismatch", i+1)
|
||||
}
|
||||
}
|
||||
v, err := urtypes.Parse("crypto-output", want)
|
||||
if err != nil {
|
||||
t.Fatalf("parse recovered payload: %v", err)
|
||||
}
|
||||
if _, ok := v.(urtypes.OutputDescriptor); !ok {
|
||||
t.Fatalf("recovered payload type: %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescriptorShardQRCodesRespectForcedSetID(t *testing.T) {
|
||||
cfg := testutils.WalletConfigs["multisig-3of5"]
|
||||
func TestDescriptorShardQRCodesUnsupportedFallbacksToFullDescriptorUR(t *testing.T) {
|
||||
cfg := testutils.WalletConfigs["multisig-7of10"]
|
||||
_, desc, err := testutils.ParseWallet(cfg, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parse wallet: %v", err)
|
||||
@ -54,21 +61,21 @@ func TestDescriptorShardQRCodesRespectForcedSetID(t *testing.T) {
|
||||
if desc == nil {
|
||||
t.Fatal("missing descriptor")
|
||||
}
|
||||
set := [16]byte{1, 2, 3, 4}
|
||||
SetDescriptorShardSetID(&set)
|
||||
defer SetDescriptorShardSetID(nil)
|
||||
|
||||
qrs, err := descriptorShardQRCodes(desc, len(desc.Keys))
|
||||
if err != nil {
|
||||
t.Fatalf("descriptorShardQRCodes: %v", err)
|
||||
}
|
||||
for i, q := range qrs {
|
||||
sh, err := shard.Decode(strings.ToUpper(q))
|
||||
if err != nil {
|
||||
t.Fatalf("decode share %d: %v", i+1, err)
|
||||
if strings.HasPrefix(strings.ToUpper(q), "SE1:") {
|
||||
t.Fatalf("share %d unexpectedly used SE1 fallback: %q", i+1, q)
|
||||
}
|
||||
if sh.SetID != set {
|
||||
t.Fatalf("share %d set_id mismatch", i+1)
|
||||
typ, _, _, ok := urxor2of3.ParseShare(q)
|
||||
if ok && typ == "crypto-output" {
|
||||
t.Fatalf("share %d unexpectedly looks like multipart UR/XOR: %q", i+1, q)
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(q)), "ur:crypto-output/") {
|
||||
t.Fatalf("share %d missing full descriptor UR prefix: %q", i+1, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -93,7 +100,7 @@ func TestDescriptorShardQRCodesSinglesigUsesDescriptorQR(t *testing.T) {
|
||||
if len(qrs) != 1 {
|
||||
t.Fatalf("got %d qrs, want 1", len(qrs))
|
||||
}
|
||||
if strings.HasPrefix(strings.ToUpper(qrs[0]), shard.Prefix) {
|
||||
if strings.HasPrefix(strings.ToUpper(qrs[0]), "SE1:") {
|
||||
t.Fatalf("singlesig descriptor QR unexpectedly sharded: %q", qrs[0])
|
||||
}
|
||||
}
|
||||
|
||||
249
printer/raster_text_helpers.go
Normal file
249
printer/raster_text_helpers.go
Normal file
@ -0,0 +1,249 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
func drawTrackedText(img *image.Paletted, face font.Face, dpi, xMm, yMm float64, text string, trackingPx float64) {
|
||||
d := font.Drawer{
|
||||
Dst: img,
|
||||
Src: image.NewUniform(color.Black),
|
||||
Face: face,
|
||||
Dot: fixed.Point26_6{
|
||||
X: fixed.I(int(math.Round(mmToPxFloat(xMm, dpi)))),
|
||||
Y: fixed.I(int(math.Round(mmToPxFloat(yMm, dpi)))),
|
||||
},
|
||||
}
|
||||
rs := []rune(text)
|
||||
trackFixed := fixed.I(int(math.Round(trackingPx)))
|
||||
for i, r := range rs {
|
||||
d.DrawString(string(r))
|
||||
if i < len(rs)-1 && trackingPx > 0 {
|
||||
d.Dot.X += trackFixed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func trackedTextWidthMM(face font.Face, dpi float64, text string, trackingPx float64) float64 {
|
||||
rs := []rune(text)
|
||||
if len(rs) == 0 {
|
||||
return 0
|
||||
}
|
||||
var width fixed.Int26_6
|
||||
trackFixed := fixed.I(int(math.Round(trackingPx)))
|
||||
for i, r := range rs {
|
||||
if adv, ok := face.GlyphAdvance(r); ok {
|
||||
width += adv
|
||||
}
|
||||
if i < len(rs)-1 && trackingPx > 0 {
|
||||
width += trackFixed
|
||||
}
|
||||
}
|
||||
return float64(width.Ceil()) * 25.4 / dpi
|
||||
}
|
||||
|
||||
func rotatedTextSizeMM(face font.Face, dpi float64, text string) (wMm, hMm float64) {
|
||||
return rotatedTextSizeMMTracked(face, dpi, text, 0)
|
||||
}
|
||||
|
||||
func rotatedTextSizeMMTracked(face font.Face, dpi float64, text string, trackingPx float64) (wMm, hMm float64) {
|
||||
if text == "" {
|
||||
return 0, 0
|
||||
}
|
||||
wPx := trackedTextWidthPx(face, text, trackingPx)
|
||||
if wPx <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
m := face.Metrics()
|
||||
hPx := (m.Ascent + m.Descent).Ceil()
|
||||
if hPx <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
// Rotated CW 90: width/height swap.
|
||||
return float64(hPx) * 25.4 / dpi, float64(wPx) * 25.4 / dpi
|
||||
}
|
||||
|
||||
func rotatedInkSizeMMTracked(face font.Face, dpi float64, text string, trackingPx float64) (wMm, hMm float64) {
|
||||
src := rasterizeTextAlpha(face, text, trackingPx)
|
||||
if src == nil {
|
||||
return 0, 0
|
||||
}
|
||||
minX, minY, maxX, maxY, ok := alphaInkBounds(src)
|
||||
if !ok {
|
||||
return 0, 0
|
||||
}
|
||||
trimW := maxX - minX + 1
|
||||
trimH := maxY - minY + 1
|
||||
return float64(trimH) * 25.4 / dpi, float64(trimW) * 25.4 / dpi
|
||||
}
|
||||
|
||||
func drawTextRotatedCCW90Tracked(img *image.Paletted, face font.Face, dpi, xMm, yMm float64, text string, idx uint8, trackingPx float64) {
|
||||
src := rasterizeTextAlpha(face, text, trackingPx)
|
||||
if src == nil {
|
||||
return
|
||||
}
|
||||
minX, minY, maxX, maxY, ok := alphaInkBounds(src)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
trimW := maxX - minX + 1
|
||||
trimH := maxY - minY + 1
|
||||
|
||||
x0 := mmToPx(xMm, dpi)
|
||||
y0 := mmToPx(yMm, dpi)
|
||||
b := img.Bounds()
|
||||
for sy := 0; sy < trimH; sy++ {
|
||||
for sx := 0; sx < trimW; sx++ {
|
||||
tx := minX + sx
|
||||
ty := minY + sy
|
||||
if src.AlphaAt(tx, ty).A == 0 {
|
||||
continue
|
||||
}
|
||||
dx := sy
|
||||
dy := trimW - 1 - sx
|
||||
x := x0 + dx
|
||||
y := y0 + dy
|
||||
if x < b.Min.X || x >= b.Max.X || y < b.Min.Y || y >= b.Max.Y {
|
||||
continue
|
||||
}
|
||||
img.Pix[y*img.Stride+x] = idx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func alphaInkBounds(src *image.Alpha) (minX, minY, maxX, maxY int, ok bool) {
|
||||
b := src.Bounds()
|
||||
minX, minY = b.Max.X, b.Max.Y
|
||||
maxX, maxY = b.Min.X, b.Min.Y
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
row := src.Pix[y*src.Stride:]
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
if row[x] == 0 {
|
||||
continue
|
||||
}
|
||||
if x < minX {
|
||||
minX = x
|
||||
}
|
||||
if x > maxX {
|
||||
maxX = x
|
||||
}
|
||||
if y < minY {
|
||||
minY = y
|
||||
}
|
||||
if y > maxY {
|
||||
maxY = y
|
||||
}
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func trackedTextWidthPx(face font.Face, text string, trackingPx float64) int {
|
||||
rs := []rune(text)
|
||||
if len(rs) == 0 {
|
||||
return 0
|
||||
}
|
||||
var width fixed.Int26_6
|
||||
trackFixed := fixed.I(int(math.Round(trackingPx)))
|
||||
for i, r := range rs {
|
||||
if adv, ok := face.GlyphAdvance(r); ok {
|
||||
width += adv
|
||||
}
|
||||
if i < len(rs)-1 && trackingPx > 0 {
|
||||
width += trackFixed
|
||||
}
|
||||
}
|
||||
return width.Ceil()
|
||||
}
|
||||
|
||||
func rasterizeTextAlpha(face font.Face, text string, trackingPx float64) *image.Alpha {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
srcW := trackedTextWidthPx(face, text, trackingPx)
|
||||
if srcW <= 0 {
|
||||
return nil
|
||||
}
|
||||
metrics := face.Metrics()
|
||||
srcH := (metrics.Ascent + metrics.Descent).Ceil()
|
||||
if srcH <= 0 {
|
||||
return nil
|
||||
}
|
||||
src := image.NewAlpha(image.Rect(0, 0, srcW, srcH))
|
||||
d := font.Drawer{
|
||||
Dst: src,
|
||||
Src: image.NewUniform(color.Alpha{A: 0xff}),
|
||||
Face: face,
|
||||
Dot: fixed.Point26_6{
|
||||
X: 0,
|
||||
Y: fixed.I(metrics.Ascent.Ceil()),
|
||||
},
|
||||
}
|
||||
rs := []rune(text)
|
||||
trackFixed := fixed.I(int(math.Round(trackingPx)))
|
||||
for i, r := range rs {
|
||||
d.DrawString(string(r))
|
||||
if i < len(rs)-1 && trackingPx > 0 {
|
||||
d.Dot.X += trackFixed
|
||||
}
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
func capBaselineOffsetMM(face font.Face, dpi float64) float64 {
|
||||
// Anchor by uppercase cap height so the visible top of uppercase letters
|
||||
// sits on the requested margin.
|
||||
b, _ := font.BoundString(face, "H")
|
||||
minYpx := float64(b.Min.Y) / 64.0
|
||||
if minYpx >= 0 {
|
||||
return float64(face.Metrics().Ascent.Ceil()) * 25.4 / dpi
|
||||
}
|
||||
return (-minYpx) * 25.4 / dpi
|
||||
}
|
||||
|
||||
func wrapTextTracked(face font.Face, dpi float64, text string, maxWidthMm float64, trackingPx float64) []string {
|
||||
if maxWidthMm <= 0 {
|
||||
return []string{text}
|
||||
}
|
||||
maxPx := int(math.Round(mmToPxFloat(maxWidthMm, dpi)))
|
||||
if maxPx <= 0 {
|
||||
return []string{text}
|
||||
}
|
||||
|
||||
parts := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
|
||||
lines := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
words := strings.Fields(part)
|
||||
if len(words) == 0 {
|
||||
continue
|
||||
}
|
||||
line := words[0]
|
||||
for _, w := range words[1:] {
|
||||
candidate := line + " " + w
|
||||
if trackedTextWidthPx(face, candidate, trackingPx) <= maxPx {
|
||||
line = candidate
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
line = w
|
||||
}
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return []string{text}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
86
printer/text_layout.go
Normal file
86
printer/text_layout.go
Normal file
@ -0,0 +1,86 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"image"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/image/font"
|
||||
)
|
||||
|
||||
type TextAlign uint8
|
||||
|
||||
const (
|
||||
TextAlignStart TextAlign = iota
|
||||
TextAlignCenter
|
||||
TextAlignEnd
|
||||
)
|
||||
|
||||
type TextBlock struct {
|
||||
Face font.Face
|
||||
Tracking float64
|
||||
LeadingMM float64
|
||||
WidthMM float64
|
||||
Align TextAlign
|
||||
OriginXMM float64
|
||||
OriginYMM float64 // baseline of first line
|
||||
}
|
||||
|
||||
type TextBlockResult struct {
|
||||
Lines []string
|
||||
BoundsMM image.Rectangle
|
||||
NextBaselineYMM float64
|
||||
}
|
||||
|
||||
func DrawMetaLine(img *image.Paletted, dpi float64, xMM, baselineYMM float64, face font.Face, tracking float64, text string) {
|
||||
drawTrackedText(img, face, dpi, xMM, baselineYMM, text, tracking)
|
||||
}
|
||||
|
||||
func DrawRotatedLabel(img *image.Paletted, dpi float64, xMM, yMM float64, face font.Face, tracking float64, idx uint8, text string) {
|
||||
drawTextRotatedCCW90Tracked(img, face, dpi, xMM, yMM, text, idx, tracking)
|
||||
}
|
||||
|
||||
func DrawTextBlock(img *image.Paletted, dpi float64, block TextBlock, text string) TextBlockResult {
|
||||
if block.Face == nil {
|
||||
return TextBlockResult{Lines: nil, NextBaselineYMM: block.OriginYMM}
|
||||
}
|
||||
leading := block.LeadingMM
|
||||
if leading <= 0 {
|
||||
m := block.Face.Metrics()
|
||||
leading = float64((m.Ascent + m.Descent).Ceil()) * 25.4 / dpi
|
||||
}
|
||||
lines := wrapTrackedParagraphs(block.Face, dpi, text, block.WidthMM, block.Tracking)
|
||||
y := block.OriginYMM
|
||||
for _, line := range lines {
|
||||
lineW := trackedTextWidthMM(block.Face, dpi, line, block.Tracking)
|
||||
x := block.OriginXMM
|
||||
switch block.Align {
|
||||
case TextAlignCenter:
|
||||
x += (block.WidthMM - lineW) / 2
|
||||
case TextAlignEnd:
|
||||
x += block.WidthMM - lineW
|
||||
}
|
||||
drawTrackedText(img, block.Face, dpi, x, y, line, block.Tracking)
|
||||
y += leading
|
||||
}
|
||||
return TextBlockResult{
|
||||
Lines: lines,
|
||||
BoundsMM: image.Rect(mmToPx(block.OriginXMM, dpi), mmToPx(block.OriginYMM, dpi), mmToPx(block.OriginXMM+block.WidthMM, dpi), mmToPx(y, dpi)),
|
||||
NextBaselineYMM: y,
|
||||
}
|
||||
}
|
||||
|
||||
func wrapTrackedParagraphs(face font.Face, dpi float64, text string, maxWidthMm float64, trackingPx float64) []string {
|
||||
parts := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, wrapTextTracked(face, dpi, p, maxWidthMm, trackingPx)...)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
return out
|
||||
}
|
||||
112
printer/transfer_layout_test.go
Normal file
112
printer/transfer_layout_test.go
Normal file
@ -0,0 +1,112 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"image"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildPlacementPlanFixedSlotsAcrossPapers(t *testing.T) {
|
||||
const dpi = 300.0
|
||||
seed := make([]*image.Paletted, 7)
|
||||
for i := range seed {
|
||||
seed[i] = newTestPlate(100, 100, 1)
|
||||
}
|
||||
|
||||
for _, paper := range []PaperSize{PaperA4, PaperLetter} {
|
||||
plan, err := buildPlacementPlan(seed, nil, paper, dpi, false, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildPlacementPlan(%s): %v", paper, err)
|
||||
}
|
||||
if len(plan.pages) != 2 {
|
||||
t.Fatalf("%s: expected 2 pages for 7 shares in 2x2 layout, got %d", paper, len(plan.pages))
|
||||
}
|
||||
for pi, page := range plan.pages {
|
||||
if got := len(page.slots); got > 4 {
|
||||
t.Fatalf("%s: page %d has %d slots, want <=4", paper, pi, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlacementPlanUses95x100CutBoxAndPlateInset(t *testing.T) {
|
||||
const dpi = 300.0
|
||||
plateW, plateH := 120, 90
|
||||
seed := []*image.Paletted{newTestPlate(plateW, plateH, 1)}
|
||||
|
||||
plan, err := buildPlacementPlan(seed, nil, PaperA4, dpi, false, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildPlacementPlan: %v", err)
|
||||
}
|
||||
if len(plan.pages) != 1 || len(plan.pages[0].cutBoxes) != 1 {
|
||||
t.Fatalf("unexpected plan shape: pages=%d cutBoxes=%d", len(plan.pages), len(plan.pages[0].cutBoxes))
|
||||
}
|
||||
|
||||
box := plan.pages[0].cutBoxes[0]
|
||||
wantW := plateW + mmToPx(transferPlateInsetLeftMM, dpi)
|
||||
if box.Dx() != wantW {
|
||||
t.Fatalf("cut-box width=%d, want=%d", box.Dx(), wantW)
|
||||
}
|
||||
wantH := plateH + mmToPx(transferPlateInsetTopMM, dpi) + mmToPx(transferPlateInsetBottomMM, dpi)
|
||||
if box.Dy() != wantH {
|
||||
t.Fatalf("cut-box height=%d, want=%d", box.Dy(), wantH)
|
||||
}
|
||||
slot := plan.pages[0].slots[0]
|
||||
wantX := box.Min.X + mmToPx(transferPlateInsetLeftMM, dpi)
|
||||
wantY := box.Min.Y + mmToPx(transferPlateInsetTopMM, dpi)
|
||||
if slot.x != wantX || slot.y != wantY {
|
||||
t.Fatalf("slot origin=(%d,%d), want=(%d,%d)", slot.x, slot.y, wantX, wantY)
|
||||
}
|
||||
if slot.x+plateW != box.Max.X {
|
||||
t.Fatalf("plate right edge=%d, want cut-box right edge=%d", slot.x+plateW, box.Max.X)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPlannedRowInvertFillsTransferBox(t *testing.T) {
|
||||
const dpi = 300.0
|
||||
plateW, plateH := 120, 90
|
||||
seed := []*image.Paletted{newTestPlate(plateW, plateH, 1)}
|
||||
|
||||
plan, err := buildPlacementPlan(seed, nil, PaperA4, dpi, true, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildPlacementPlan: %v", err)
|
||||
}
|
||||
page := plan.pages[0]
|
||||
box := page.cutBoxes[0]
|
||||
insideInsetX := box.Min.X + mmToPx(transferPlateInsetLeftMM, dpi)/2
|
||||
insideInsetY := box.Min.Y + mmToPx(transferPlateInsetTopMM, dpi)/2
|
||||
if insideInsetX <= box.Min.X {
|
||||
insideInsetX = box.Min.X + 1
|
||||
}
|
||||
if insideInsetY <= box.Min.Y {
|
||||
insideInsetY = box.Min.Y + 1
|
||||
}
|
||||
if insideInsetX >= box.Max.X-1 {
|
||||
insideInsetX = box.Max.X - 2
|
||||
}
|
||||
if insideInsetY >= box.Max.Y-1 {
|
||||
insideInsetY = box.Max.Y - 2
|
||||
}
|
||||
|
||||
row := make([]uint8, plan.pageWpx)
|
||||
renderPlannedRow(row, insideInsetY, page, true)
|
||||
for x := box.Min.X; x < box.Max.X; x++ {
|
||||
if row[x] != 1 {
|
||||
t.Fatalf("invert row x=%d inside transfer box is %d, want black(1)", x, row[x])
|
||||
}
|
||||
}
|
||||
|
||||
renderPlannedRow(row, insideInsetY, page, false)
|
||||
if row[insideInsetX] != 0 {
|
||||
t.Fatalf("non-invert inset interior x=%d is %d, want white(0)", insideInsetX, row[insideInsetX])
|
||||
}
|
||||
}
|
||||
|
||||
func newTestPlate(w, h int, fill uint8) *image.Paletted {
|
||||
img := image.NewPaletted(image.Rect(0, 0, w, h), bwPalette)
|
||||
if fill != 0 {
|
||||
for i := range img.Pix {
|
||||
img.Pix[i] = fill
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
255
printer/wallet_render_data_test.go
Normal file
255
printer/wallet_render_data_test.go
Normal file
@ -0,0 +1,255 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"seedetcher.com/bc/ur"
|
||||
"seedetcher.com/bc/urtypes"
|
||||
"seedetcher.com/bip39"
|
||||
"seedetcher.com/descriptor/urxor2of3"
|
||||
"seedetcher.com/seedqr"
|
||||
"seedetcher.com/testutils"
|
||||
)
|
||||
|
||||
func TestSeedQRPayloadRoundTripForWalletFixtures(t *testing.T) {
|
||||
for _, wallet := range []string{
|
||||
"singlesig",
|
||||
"singlesig-nested-p2sh-p2wpkh",
|
||||
"multisig-mainnet-2of3",
|
||||
"multisig-nested-2of3",
|
||||
"multisig-7of10",
|
||||
} {
|
||||
cfg, ok := testutils.WalletConfigs[wallet]
|
||||
if !ok {
|
||||
t.Fatalf("wallet fixture not found: %s", wallet)
|
||||
}
|
||||
mnemonics, _, err := testutils.ParseWallet(cfg, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWallet(%s): %v", wallet, err)
|
||||
}
|
||||
for i, m := range mnemonics {
|
||||
got, ok := seedqr.Parse(seedqr.QR(m))
|
||||
if !ok {
|
||||
t.Fatalf("%s[%d]: seedqr roundtrip parse failed", wallet, i)
|
||||
}
|
||||
if !sameMnemonic(got, m) {
|
||||
t.Fatalf("%s[%d]: mnemonic mismatch after seedqr roundtrip", wallet, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescriptorSharePayloadsRecoverOriginalDescriptor(t *testing.T) {
|
||||
for _, wallet := range []string{
|
||||
"singlesig",
|
||||
"singlesig-nested-p2sh-p2wpkh",
|
||||
"multisig-mainnet-2of3",
|
||||
"multisig-nested-2of3",
|
||||
"multisig-7of10",
|
||||
} {
|
||||
cfg, ok := testutils.WalletConfigs[wallet]
|
||||
if !ok {
|
||||
t.Fatalf("wallet fixture not found: %s", wallet)
|
||||
}
|
||||
_, desc, err := testutils.ParseWallet(cfg, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWallet(%s): %v", wallet, err)
|
||||
}
|
||||
if desc == nil {
|
||||
continue
|
||||
}
|
||||
shares, err := collectDescriptorSharePayloads(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: collect shares: %v", wallet, err)
|
||||
}
|
||||
th := desc.Threshold
|
||||
if th < 1 || th > len(shares) {
|
||||
t.Fatalf("%s: invalid threshold=%d shares=%d", wallet, th, len(shares))
|
||||
}
|
||||
recovered, err := recoverDescriptorFromShares(shares[:th])
|
||||
if err != nil {
|
||||
t.Fatalf("%s: recover descriptor: %v", wallet, err)
|
||||
}
|
||||
assertDescriptorEquivalent(t, wallet, *desc, recovered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposePagesPreservesPlatePixels(t *testing.T) {
|
||||
const (
|
||||
wallet = "multisig-mainnet-2of3"
|
||||
dpi = 150.0
|
||||
paper = PaperA4
|
||||
)
|
||||
cfg, ok := testutils.WalletConfigs[wallet]
|
||||
if !ok {
|
||||
t.Fatalf("wallet fixture not found: %s", wallet)
|
||||
}
|
||||
mnemonics, desc, err := testutils.ParseWallet(cfg, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWallet(%s): %v", wallet, err)
|
||||
}
|
||||
seedPlates, descPlates, err := CreatePlateBitmaps(mnemonics, desc, 0, RasterOptions{DPI: dpi}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePlateBitmaps: %v", err)
|
||||
}
|
||||
pages, err := ComposePages(seedPlates, descPlates, paper, dpi, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ComposePages: %v", err)
|
||||
}
|
||||
plan, err := buildPlacementPlan(seedPlates, descPlates, paper, dpi, false, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildPlacementPlan: %v", err)
|
||||
}
|
||||
if len(pages) != len(plan.pages) {
|
||||
t.Fatalf("page count mismatch: pages=%d plan=%d", len(pages), len(plan.pages))
|
||||
}
|
||||
for pi, p := range plan.pages {
|
||||
page := pages[pi]
|
||||
for si, slot := range p.slots {
|
||||
if slot.plate == nil {
|
||||
continue
|
||||
}
|
||||
if !pageRegionEqualsPlate(page, slot.plate, slot.x, slot.y) {
|
||||
t.Fatalf("page[%d] slot[%d]: composed page pixels differ from source plate", pi, si)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectDescriptorSharePayloads(desc *urtypes.OutputDescriptor) ([][]string, error) {
|
||||
total := len(desc.Keys)
|
||||
out := make([][]string, total)
|
||||
if total == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
for i := 0; i < total; i++ {
|
||||
payloads, err := DescriptorShardQRPayloadsForShare(desc, total, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(payloads) == 0 {
|
||||
return nil, fmt.Errorf("share payloads are empty")
|
||||
}
|
||||
out[i] = payloads
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func recoverDescriptorFromShares(selected [][]string) (urtypes.OutputDescriptor, error) {
|
||||
flat := make([]string, 0, len(selected)*2)
|
||||
for _, frags := range selected {
|
||||
flat = append(flat, frags...)
|
||||
}
|
||||
if len(flat) == 0 {
|
||||
return urtypes.OutputDescriptor{}, fmt.Errorf("no share payloads provided")
|
||||
}
|
||||
typ, _, seqLen, urxor := urxor2of3.ParseShare(flat[0])
|
||||
var payload []byte
|
||||
if urxor && typ == "crypto-output" && seqLen >= urxor2of3.MinShares {
|
||||
p, err := urxor2of3.Combine(flat)
|
||||
if err != nil {
|
||||
return urtypes.OutputDescriptor{}, err
|
||||
}
|
||||
payload = p
|
||||
} else {
|
||||
var d ur.Decoder
|
||||
for _, s := range flat {
|
||||
if err := d.Add(s); err != nil {
|
||||
return urtypes.OutputDescriptor{}, err
|
||||
}
|
||||
}
|
||||
outTyp, p, err := d.Result()
|
||||
if err != nil {
|
||||
return urtypes.OutputDescriptor{}, err
|
||||
}
|
||||
if outTyp != "crypto-output" {
|
||||
return urtypes.OutputDescriptor{}, fmt.Errorf("unexpected ur type: %s", outTyp)
|
||||
}
|
||||
payload = p
|
||||
}
|
||||
v, err := urtypes.Parse("crypto-output", payload)
|
||||
if err != nil {
|
||||
return urtypes.OutputDescriptor{}, err
|
||||
}
|
||||
parsed, ok := v.(urtypes.OutputDescriptor)
|
||||
if !ok {
|
||||
return urtypes.OutputDescriptor{}, fmt.Errorf("recovered payload is not output descriptor: %T", v)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func assertDescriptorEquivalent(t *testing.T, wallet string, want, got urtypes.OutputDescriptor) {
|
||||
t.Helper()
|
||||
if got.Type != want.Type {
|
||||
t.Fatalf("%s: descriptor type mismatch: got=%v want=%v", wallet, got.Type, want.Type)
|
||||
}
|
||||
if got.Script != want.Script {
|
||||
t.Fatalf("%s: descriptor script mismatch: got=%v want=%v", wallet, got.Script, want.Script)
|
||||
}
|
||||
if got.Threshold != want.Threshold {
|
||||
t.Fatalf("%s: descriptor threshold mismatch: got=%d want=%d", wallet, got.Threshold, want.Threshold)
|
||||
}
|
||||
if len(got.Keys) != len(want.Keys) {
|
||||
t.Fatalf("%s: descriptor key count mismatch: got=%d want=%d", wallet, len(got.Keys), len(want.Keys))
|
||||
}
|
||||
gotFP := descriptorFingerprintSet(got.Keys)
|
||||
wantFP := descriptorFingerprintSet(want.Keys)
|
||||
if len(gotFP) != len(wantFP) {
|
||||
t.Fatalf("%s: descriptor key fingerprint length mismatch: got=%d want=%d", wallet, len(gotFP), len(wantFP))
|
||||
}
|
||||
for i := range gotFP {
|
||||
if gotFP[i] != wantFP[i] {
|
||||
t.Fatalf("%s: descriptor key fingerprint set mismatch: got=%v want=%v", wallet, gotFP, wantFP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func descriptorFingerprintSet(keys []urtypes.KeyDescriptor) []string {
|
||||
out := make([]string, len(keys))
|
||||
for i, k := range keys {
|
||||
out[i] = fingerprintHex(k.MasterFingerprint)
|
||||
}
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && out[j-1] > out[j]; j-- {
|
||||
out[j-1], out[j] = out[j], out[j-1]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pageRegionEqualsPlate(page, plate *image.Paletted, x, y int) bool {
|
||||
b := plate.Bounds()
|
||||
for py := 0; py < b.Dy(); py++ {
|
||||
pageRow := page.Pix[(y+py)*page.Stride+x : (y+py)*page.Stride+x+b.Dx()]
|
||||
plateRow := plate.Pix[(b.Min.Y+py)*plate.Stride+b.Min.X : (b.Min.Y+py)*plate.Stride+b.Min.X+b.Dx()]
|
||||
if !bytes.Equal(pageRow, plateRow) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func fingerprintHex(v uint32) string {
|
||||
const hexd = "0123456789abcdef"
|
||||
out := [8]byte{}
|
||||
for i := 7; i >= 0; i-- {
|
||||
out[i] = hexd[v&0xF]
|
||||
v >>= 4
|
||||
}
|
||||
return string(out[:])
|
||||
}
|
||||
|
||||
func sameMnemonic(a, b bip39.Mnemonic) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
159
scripts/analyze-go-bloat.sh
Executable file
159
scripts/analyze-go-bloat.sh
Executable file
@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
GO_BIN="$(go env GOPATH 2>/dev/null)/bin"
|
||||
|
||||
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||
OUT_DIR="${OUT_DIR:-$ROOT/.tmp/go-bloat-$timestamp}"
|
||||
CYCLO_OVER="${CYCLO_OVER:-12}"
|
||||
DUPL_THRESHOLD="${DUPL_THRESHOLD:-80}"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
info() {
|
||||
printf '[analyze] %s\n' "$*"
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf '[analyze] WARN: %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
have() {
|
||||
command -v "$1" >/dev/null 2>&1 || [ -x "$GO_BIN/$1" ]
|
||||
}
|
||||
|
||||
tool_path() {
|
||||
if command -v "$1" >/dev/null 2>&1; then
|
||||
command -v "$1"
|
||||
return 0
|
||||
fi
|
||||
if [ -x "$GO_BIN/$1" ]; then
|
||||
printf '%s\n' "$GO_BIN/$1"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
run_capture() {
|
||||
local name="$1"
|
||||
shift
|
||||
local log="$OUT_DIR/$name.log"
|
||||
info "running $name"
|
||||
set +e
|
||||
"$@" >"$log" 2>&1
|
||||
local rc=$?
|
||||
set -e
|
||||
printf '%s %d\n' "$name" "$rc" >>"$OUT_DIR/status.txt"
|
||||
}
|
||||
|
||||
# Same package exclusions as scripts/test-lite.sh.
|
||||
mapfile -t PKGS < <(go list ./... | grep -v 'driver/libcamera' | grep -v 'driver/wshat')
|
||||
if [ "${#PKGS[@]}" -eq 0 ]; then
|
||||
warn "no packages found"
|
||||
exit 1
|
||||
fi
|
||||
MODULE_PATH="$(go list -m)"
|
||||
LINT_PKGS=()
|
||||
for p in "${PKGS[@]}"; do
|
||||
rel="${p#"$MODULE_PATH"}"
|
||||
if [ "$rel" = "$p" ]; then
|
||||
LINT_PKGS+=("$p")
|
||||
continue
|
||||
fi
|
||||
if [ -z "$rel" ]; then
|
||||
LINT_PKGS+=(".")
|
||||
else
|
||||
LINT_PKGS+=(".$rel")
|
||||
fi
|
||||
done
|
||||
|
||||
find . -name '*.go' -not -path './vendor/*' -print0 \
|
||||
| xargs -0 wc -l \
|
||||
| sort -nr \
|
||||
>"$OUT_DIR/top_files_loc.txt"
|
||||
|
||||
if have gocyclo; then
|
||||
GOCYCLO_BIN="$(tool_path gocyclo)"
|
||||
set +e
|
||||
find . -name '*.go' -not -path './vendor/*' -print0 \
|
||||
| xargs -0 "$GOCYCLO_BIN" -over "$CYCLO_OVER" \
|
||||
| sort -nr \
|
||||
>"$OUT_DIR/top_cyclo.txt"
|
||||
rc=$?
|
||||
set -e
|
||||
printf '%s %d\n' "gocyclo" "$rc" >>"$OUT_DIR/status.txt"
|
||||
else
|
||||
warn "gocyclo not installed; skipping complexity ranking"
|
||||
fi
|
||||
|
||||
if have dupl; then
|
||||
DUPL_BIN="$(tool_path dupl)"
|
||||
run_capture dupl "$DUPL_BIN" -threshold "$DUPL_THRESHOLD" .
|
||||
else
|
||||
warn "dupl not installed; skipping duplicate-block scan"
|
||||
fi
|
||||
|
||||
if have staticcheck; then
|
||||
STATICCHECK_BIN="$(tool_path staticcheck)"
|
||||
run_capture staticcheck "$STATICCHECK_BIN" "${PKGS[@]}"
|
||||
else
|
||||
warn "staticcheck not installed; skipping"
|
||||
fi
|
||||
|
||||
if have gocritic; then
|
||||
GOCRITIC_BIN="$(tool_path gocritic)"
|
||||
run_capture gocritic "$GOCRITIC_BIN" check "${PKGS[@]}"
|
||||
else
|
||||
warn "gocritic not installed; skipping"
|
||||
fi
|
||||
|
||||
if have golangci-lint; then
|
||||
GOLANGCI_BIN="$(tool_path golangci-lint)"
|
||||
run_capture golangci_lint "$GOLANGCI_BIN" run "${LINT_PKGS[@]}"
|
||||
else
|
||||
warn "golangci-lint not installed; skipping"
|
||||
fi
|
||||
|
||||
run_capture go_vet go vet "${PKGS[@]}"
|
||||
|
||||
{
|
||||
echo "Go bloat analysis"
|
||||
echo "repo: $ROOT"
|
||||
echo "out: $OUT_DIR"
|
||||
echo
|
||||
echo "tool status (name rc):"
|
||||
if [ -f "$OUT_DIR/status.txt" ]; then
|
||||
cat "$OUT_DIR/status.txt"
|
||||
else
|
||||
echo "none"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "largest go files (top 20):"
|
||||
head -n 20 "$OUT_DIR/top_files_loc.txt"
|
||||
echo
|
||||
|
||||
if [ -f "$OUT_DIR/top_cyclo.txt" ]; then
|
||||
echo "complexity hotspots (gocyclo, top 30):"
|
||||
head -n 30 "$OUT_DIR/top_cyclo.txt"
|
||||
echo
|
||||
fi
|
||||
|
||||
if [ -f "$OUT_DIR/dupl.log" ]; then
|
||||
clones="$(grep -Ec '^found [0-9]+ clones:' "$OUT_DIR/dupl.log" || true)"
|
||||
echo "dupl clone-groups: $clones (see dupl.log)"
|
||||
echo
|
||||
fi
|
||||
|
||||
for t in staticcheck gocritic golangci_lint go_vet; do
|
||||
if [ -f "$OUT_DIR/$t.log" ]; then
|
||||
count="$(grep -cvE '^[[:space:]]*$' "$OUT_DIR/$t.log" || true)"
|
||||
echo "$t non-empty lines: $count (see $t.log)"
|
||||
fi
|
||||
done
|
||||
} >"$OUT_DIR/summary.txt"
|
||||
|
||||
info "done"
|
||||
info "summary: $OUT_DIR/summary.txt"
|
||||
@ -4,16 +4,114 @@ PDF_DIR="/home/cmyk/PDF"
|
||||
OUTPUT_FILE="$PDF_DIR/test_output"
|
||||
USBDEV="/dev/ttyACM1" # Default USB device
|
||||
VERBOSE=false
|
||||
REPLAY_QUEUE="${REPLAY_QUEUE:-}"
|
||||
REPLAY_SERVER="${REPLAY_SERVER:-}"
|
||||
# HBP gadget prints are often sent as multiple batches with render gaps between
|
||||
# writes. Use a short idle window, but require multiple consecutive idle windows
|
||||
# before stopping capture.
|
||||
IDLE_TIMEOUT_SEC="${IDLE_TIMEOUT_SEC:-10}"
|
||||
IDLE_WINDOWS_REQUIRED="${IDLE_WINDOWS_REQUIRED:-3}"
|
||||
CONVERT_RAS_TO_PDF="${CONVERT_RAS_TO_PDF:-1}"
|
||||
|
||||
# Parse command-line options
|
||||
while getopts "v" opt; do
|
||||
while getopts "vq:s:" opt; do
|
||||
case "$opt" in
|
||||
v) VERBOSE=true ;;
|
||||
*) echo "Usage: $0 [-v] [USBDEV]"; exit 1 ;;
|
||||
q) REPLAY_QUEUE="$OPTARG" ;;
|
||||
s) REPLAY_SERVER="$OPTARG" ;;
|
||||
*) echo "Usage: $0 [-v] [-q QUEUE] [-s CUPS_SERVER] [USBDEV]"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
shift $((OPTIND - 1))
|
||||
|
||||
convert_ras_to_pdf() {
|
||||
ras_file="$1"
|
||||
pdf_out="$2"
|
||||
pwg_tmp="$(mktemp /tmp/capture_print.XXXXXX.pwg)"
|
||||
|
||||
if ! [ -x /usr/lib/cups/filter/rastertopwg ] || ! [ -x /usr/lib/cups/filter/pwgtopdf ]; then
|
||||
echo "Skipping RAS->PDF conversion: rastertopwg/pwgtopdf not available."
|
||||
rm -f "$pwg_tmp"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if CONTENT_TYPE=application/vnd.cups-raster \
|
||||
/usr/lib/cups/filter/rastertopwg 1 "${USER:-capture}" test 1 "media=A4" \
|
||||
"$ras_file" > "$pwg_tmp"; then
|
||||
if CONTENT_TYPE=image/pwg-raster \
|
||||
/usr/lib/cups/filter/pwgtopdf 1 "${USER:-capture}" test 1 "media=A4" \
|
||||
"$pwg_tmp" > "$pdf_out"; then
|
||||
echo "Converted raster PDF saved as $pdf_out"
|
||||
rm -f "$pwg_tmp"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "RAS->PDF conversion failed (kept raster at $ras_file)."
|
||||
rm -f "$pwg_tmp"
|
||||
return 0
|
||||
}
|
||||
|
||||
split_concat_raster_streams() {
|
||||
ras_file="$1"
|
||||
out_prefix="$2"
|
||||
|
||||
# CUPS raster magic:
|
||||
# - little-endian: RaS3
|
||||
# - big-endian: 3SaR
|
||||
offsets_tmp="$(mktemp /tmp/capture_print.XXXXXX.offsets)"
|
||||
parts_tmp="$(mktemp /tmp/capture_print.XXXXXX.parts)"
|
||||
trap 'rm -f "$offsets_tmp" "$parts_tmp"' RETURN
|
||||
|
||||
{
|
||||
LC_ALL=C grep -abo "RaS3" "$ras_file" 2>/dev/null | cut -d: -f1 || true
|
||||
LC_ALL=C grep -abo "3SaR" "$ras_file" 2>/dev/null | cut -d: -f1 || true
|
||||
} | sort -n | awk 'BEGIN{last=-1} {if($1!=last){print $1; last=$1}}' > "$offsets_tmp"
|
||||
|
||||
# Not concatenated (or no recognizable header offsets): keep single stream.
|
||||
if [ ! -s "$offsets_tmp" ] || [ "$(wc -l < "$offsets_tmp")" -le 1 ]; then
|
||||
echo "$ras_file" > "$parts_tmp"
|
||||
cat "$parts_tmp"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mapfile -t offs < "$offsets_tmp"
|
||||
total_bytes=$(stat -c%s "$ras_file")
|
||||
part_idx=1
|
||||
emitted=0
|
||||
|
||||
for i in "${!offs[@]}"; do
|
||||
start="${offs[$i]}"
|
||||
if [ "$start" -ge "$total_bytes" ]; then
|
||||
continue
|
||||
fi
|
||||
if [ "$i" -lt "$((${#offs[@]} - 1))" ]; then
|
||||
next="${offs[$((i + 1))]}"
|
||||
count=$((next - start))
|
||||
else
|
||||
count=$((total_bytes - start))
|
||||
fi
|
||||
if [ "$count" -le 0 ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
part_file="${out_prefix}-part${part_idx}.ras"
|
||||
dd if="$ras_file" of="$part_file" bs=1 skip="$start" count="$count" status=none
|
||||
if file "$part_file" 2>/dev/null | grep -qi "Cups\? Raster"; then
|
||||
echo "$part_file" >> "$parts_tmp"
|
||||
part_idx=$((part_idx + 1))
|
||||
emitted=$((emitted + 1))
|
||||
else
|
||||
rm -f "$part_file"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$emitted" -eq 0 ]; then
|
||||
echo "$ras_file" > "$parts_tmp"
|
||||
fi
|
||||
cat "$parts_tmp"
|
||||
}
|
||||
|
||||
# Enable debugging only if verbose mode is enabled
|
||||
if [ "$VERBOSE" = true ]; then
|
||||
set -x
|
||||
@ -48,16 +146,30 @@ fi
|
||||
|
||||
CAPTURE_PID=$!
|
||||
|
||||
# Monitor file growth and stop when data stops arriving
|
||||
# Monitor file growth and stop only after sustained idle period.
|
||||
LAST_SIZE=0
|
||||
IDLE_TICKS=0
|
||||
IDLE_WINDOWS=0
|
||||
IDLE_LIMIT=$((IDLE_TIMEOUT_SEC * 2))
|
||||
while true; do
|
||||
sleep 0.5
|
||||
CURRENT_SIZE=$(stat -c%s "$OUTPUT_FILE")
|
||||
|
||||
if [ "$CURRENT_SIZE" -eq "$LAST_SIZE" ] && [ "$CURRENT_SIZE" -gt 0 ]; then
|
||||
echo "No more data received, stopping capture."
|
||||
kill "$CAPTURE_PID" 2>/dev/null
|
||||
break
|
||||
IDLE_TICKS=$((IDLE_TICKS + 1))
|
||||
if [ "$IDLE_TICKS" -ge "$IDLE_LIMIT" ]; then
|
||||
IDLE_WINDOWS=$((IDLE_WINDOWS + 1))
|
||||
IDLE_TICKS=0
|
||||
if [ "$IDLE_WINDOWS" -ge "$IDLE_WINDOWS_REQUIRED" ]; then
|
||||
total_idle=$((IDLE_TIMEOUT_SEC * IDLE_WINDOWS_REQUIRED))
|
||||
echo "No more data received for ${total_idle}s (${IDLE_WINDOWS_REQUIRED}x${IDLE_TIMEOUT_SEC}s), stopping capture."
|
||||
kill "$CAPTURE_PID" 2>/dev/null
|
||||
break
|
||||
fi
|
||||
fi
|
||||
else
|
||||
IDLE_TICKS=0
|
||||
IDLE_WINDOWS=0
|
||||
fi
|
||||
LAST_SIZE=$CURRENT_SIZE
|
||||
done
|
||||
@ -78,15 +190,21 @@ if [ -s "$OUTPUT_FILE" ]; then
|
||||
FILE_TYPE=$(file "$OUTPUT_FILE")
|
||||
echo "Detected file type: $FILE_TYPE"
|
||||
|
||||
# Determine extension, only allow PDF, PS, and PCL
|
||||
# Determine extension, allow PDF/PS/PCL/CUPS-raster.
|
||||
RAS_MAGIC="$(dd if="$OUTPUT_FILE" bs=4 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n')"
|
||||
case "$FILE_TYPE" in
|
||||
*"PDF document"*) EXT="pdf" ;;
|
||||
*"PostScript"*) EXT="ps" ;;
|
||||
*"PCL printer data"*) EXT="pcl" ;; # Now properly detects PCL
|
||||
*"CUPS Raster"*|*"Cups Raster"*|*"cups raster"*) EXT="ras" ;;
|
||||
*)
|
||||
echo "Unknown format, ignoring."
|
||||
rm -f "$OUTPUT_FILE"
|
||||
exit 1
|
||||
if [ "$RAS_MAGIC" = "52615332" ] || [ "$RAS_MAGIC" = "52615333" ]; then
|
||||
EXT="ras"
|
||||
else
|
||||
echo "Unknown format, ignoring."
|
||||
rm -f "$OUTPUT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
@ -94,6 +212,47 @@ if [ -s "$OUTPUT_FILE" ]; then
|
||||
mv "$OUTPUT_FILE" "$FINAL_FILE"
|
||||
echo "Saved as $FINAL_FILE"
|
||||
|
||||
if [ "$EXT" = "ras" ] && [ "$CONVERT_RAS_TO_PDF" != "0" ]; then
|
||||
mapfile -t ras_parts < <(split_concat_raster_streams "$FINAL_FILE" "${FINAL_FILE%.ras}")
|
||||
|
||||
if [ "${#ras_parts[@]}" -gt 1 ]; then
|
||||
echo "Detected concatenated raster streams: ${#ras_parts[@]} parts."
|
||||
pdf_parts=()
|
||||
part_num=1
|
||||
for p in "${ras_parts[@]}"; do
|
||||
out_pdf="${FINAL_FILE%.ras}-from-ras-part${part_num}.pdf"
|
||||
convert_ras_to_pdf "$p" "$out_pdf"
|
||||
if [ -s "$out_pdf" ]; then
|
||||
pdf_parts+=("$out_pdf")
|
||||
fi
|
||||
part_num=$((part_num + 1))
|
||||
done
|
||||
if [ "${#pdf_parts[@]}" -gt 1 ] && command -v pdfunite >/dev/null 2>&1; then
|
||||
merged="${FINAL_FILE%.ras}-from-ras.pdf"
|
||||
if pdfunite "${pdf_parts[@]}" "$merged"; then
|
||||
echo "Merged raster PDF saved as $merged"
|
||||
fi
|
||||
elif [ "${#pdf_parts[@]}" -eq 1 ]; then
|
||||
cp "${pdf_parts[0]}" "${FINAL_FILE%.ras}-from-ras.pdf"
|
||||
fi
|
||||
else
|
||||
convert_ras_to_pdf "$FINAL_FILE" "${FINAL_FILE%.ras}-from-ras.pdf"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$EXT" = "ras" ] && [ -n "$REPLAY_QUEUE" ]; then
|
||||
echo "Replaying CUPS raster to queue '$REPLAY_QUEUE'..."
|
||||
if [ -n "$REPLAY_SERVER" ]; then
|
||||
lp -h "$REPLAY_SERVER" -d "$REPLAY_QUEUE" \
|
||||
-o document-format=application/vnd.cups-raster \
|
||||
"$FINAL_FILE"
|
||||
else
|
||||
lp -d "$REPLAY_QUEUE" \
|
||||
-o document-format=application/vnd.cups-raster \
|
||||
"$FINAL_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Extra diagnostics (only in verbose mode)
|
||||
if [ "$VERBOSE" = true ]; then
|
||||
echo "Captured file content (Hex Dump):"
|
||||
@ -109,4 +268,4 @@ if [ -s "$OUTPUT_FILE" ]; then
|
||||
else
|
||||
echo "No data received, exiting."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
154
scripts/cleanup-nix-images.sh
Executable file
154
scripts/cleanup-nix-images.sh
Executable file
@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/cleanup-nix-images.sh [options]
|
||||
|
||||
Remove old SeedEtcher disk-image outputs from /nix/store.
|
||||
Defaults to dry-run.
|
||||
|
||||
Options:
|
||||
--apply Actually delete paths (default: dry-run)
|
||||
--gc Run nix garbage collection after delete attempt
|
||||
--no-keep-result Do not automatically keep the current ./result target
|
||||
--keep PATH Keep an extra /nix/store path (repeatable)
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
scripts/cleanup-nix-images.sh
|
||||
scripts/cleanup-nix-images.sh --apply
|
||||
EOF
|
||||
}
|
||||
|
||||
APPLY=0
|
||||
RUN_GC=0
|
||||
KEEP_RESULT=1
|
||||
declare -a KEEP_PATHS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--apply)
|
||||
APPLY=1
|
||||
shift
|
||||
;;
|
||||
--gc)
|
||||
RUN_GC=1
|
||||
shift
|
||||
;;
|
||||
--no-keep-result)
|
||||
KEEP_RESULT=0
|
||||
shift
|
||||
;;
|
||||
--keep)
|
||||
[[ $# -ge 2 ]] || { echo "error: --keep needs a path" >&2; exit 2; }
|
||||
KEEP_PATHS+=("$(readlink -f "$2")")
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "error: unknown option: $1" >&2
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ $KEEP_RESULT -eq 1 && -L result ]]; then
|
||||
KEEP_PATHS+=("$(readlink -f result)")
|
||||
fi
|
||||
|
||||
in_keep_list() {
|
||||
local needle="$1"
|
||||
local p
|
||||
for p in "${KEEP_PATHS[@]}"; do
|
||||
[[ "$needle" == "$p" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
is_seedetcher_disk_image_dir() {
|
||||
local d="$1"
|
||||
compgen -G "$d/seedetcher*.img" >/dev/null
|
||||
}
|
||||
|
||||
mapfile -t ALL_DISK_IMAGE_DIRS < <(find /nix/store -maxdepth 1 -type d -name '*-disk-image' | sort)
|
||||
|
||||
if [[ ${#ALL_DISK_IMAGE_DIRS[@]} -eq 0 ]]; then
|
||||
echo "No /nix/store/*-disk-image paths found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
declare -a CANDIDATES=()
|
||||
declare -a SKIPPED_KEEP=()
|
||||
declare -a SKIPPED_ALIVE=()
|
||||
declare -a DELETABLE=()
|
||||
|
||||
for path in "${ALL_DISK_IMAGE_DIRS[@]}"; do
|
||||
if ! is_seedetcher_disk_image_dir "$path"; then
|
||||
continue
|
||||
fi
|
||||
CANDIDATES+=("$path")
|
||||
if in_keep_list "$path"; then
|
||||
SKIPPED_KEEP+=("$path")
|
||||
continue
|
||||
fi
|
||||
roots="$(nix-store --query --roots "$path" 2>/dev/null || true)"
|
||||
if [[ -n "$roots" ]]; then
|
||||
SKIPPED_ALIVE+=("$path")
|
||||
continue
|
||||
fi
|
||||
DELETABLE+=("$path")
|
||||
done
|
||||
|
||||
echo "SeedEtcher disk-image paths found: ${#CANDIDATES[@]}"
|
||||
echo "Kept explicitly: ${#SKIPPED_KEEP[@]}"
|
||||
echo "Still alive (GC roots): ${#SKIPPED_ALIVE[@]}"
|
||||
echo "Deletable: ${#DELETABLE[@]}"
|
||||
echo
|
||||
|
||||
if [[ ${#DELETABLE[@]} -eq 0 ]]; then
|
||||
echo "Nothing deletable."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Deletable paths:"
|
||||
printf ' %s\n' "${DELETABLE[@]}"
|
||||
|
||||
if [[ $APPLY -eq 0 ]]; then
|
||||
echo
|
||||
echo "Dry run only. Re-run with --apply to delete."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Deleting..."
|
||||
FAILED=0
|
||||
for path in "${DELETABLE[@]}"; do
|
||||
if ! nix store delete "$path"; then
|
||||
FAILED=$((FAILED + 1))
|
||||
echo "warn: failed to delete $path"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $RUN_GC -eq 1 ]]; then
|
||||
echo
|
||||
echo "Running garbage collection..."
|
||||
nix-collect-garbage -d || true
|
||||
fi
|
||||
|
||||
if [[ $FAILED -gt 0 ]]; then
|
||||
echo
|
||||
echo "Some paths could not be deleted because they are still alive via GC roots."
|
||||
echo "Inspect one path with:"
|
||||
echo " nix-store --query --roots <path>"
|
||||
echo " nix-store --query --referrers <path>"
|
||||
echo
|
||||
echo "If outputs still refuse deletion, run as root to include daemon-owned roots:"
|
||||
echo " nix-collect-garbage -d"
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
486
scripts/cups/cups-runtime-bootstrap
Executable file
486
scripts/cups/cups-runtime-bootstrap
Executable file
@ -0,0 +1,486 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SOCK="${CUPS_SERVER_SOCK:-/var/run/cups/cups.sock}"
|
||||
CUPS_RUNTIME_DEBUG_TO_KMSG="${CUPS_RUNTIME_DEBUG_TO_KMSG:-0}"
|
||||
|
||||
log() {
|
||||
msg="$*"
|
||||
echo "$msg" >> /log/cups.log
|
||||
if [ "$CUPS_RUNTIME_DEBUG_TO_KMSG" = "1" ]; then
|
||||
echo "DEBUG: CUPS bootstrap: $msg" > /dev/kmsg 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
debug_echo() {
|
||||
log "$*"
|
||||
}
|
||||
|
||||
is_mounted() {
|
||||
mnt="$1"
|
||||
awk -v m="$mnt" '$2==m{found=1} END{exit found?0:1}' /proc/mounts
|
||||
}
|
||||
|
||||
mount_nix_from_sd() {
|
||||
mkdir -p /nix
|
||||
if is_mounted /nix; then
|
||||
return 0
|
||||
fi
|
||||
tries=10
|
||||
while [ "$tries" -gt 0 ] && [ ! -b /dev/mmcblk0p2 ]; do
|
||||
sleep 1
|
||||
tries=$((tries - 1))
|
||||
done
|
||||
if [ ! -b /dev/mmcblk0p2 ]; then
|
||||
echo "cups-runtime-bootstrap: /dev/mmcblk0p2 not found" >&2
|
||||
return 1
|
||||
fi
|
||||
mount -t ext4 /dev/mmcblk0p2 /nix >> /log/cups.log 2>&1
|
||||
}
|
||||
|
||||
if [ -f /cups-runtime.env ]; then
|
||||
# shellcheck source=/dev/null
|
||||
. /cups-runtime.env
|
||||
CUPS_RUNTIME_DEBUG_TO_KMSG="${CUPS_RUNTIME_DEBUG_TO_KMSG:-0}"
|
||||
fi
|
||||
|
||||
BRLASER_DROPIN_ROOT=""
|
||||
mkdir -p /mnt/boot /var/cups-extra
|
||||
BRLASER_RUNTIME_ROOT=/var/cups-extra/brlaser-runtime
|
||||
mkdir -p "$BRLASER_RUNTIME_ROOT/filter" "$BRLASER_RUNTIME_ROOT/lib"
|
||||
SHOULD_LOAD_DROPIN=0
|
||||
case "${CUPS_RUNTIME_LOAD_DROPIN:-auto}" in
|
||||
1|yes|true|on)
|
||||
SHOULD_LOAD_DROPIN=1
|
||||
;;
|
||||
0|no|false|off)
|
||||
SHOULD_LOAD_DROPIN=0
|
||||
;;
|
||||
*)
|
||||
[ -z "${BRLASER_ROOT:-}" ] && SHOULD_LOAD_DROPIN=1
|
||||
;;
|
||||
esac
|
||||
if [ "$SHOULD_LOAD_DROPIN" -eq 1 ] && [ -b /dev/mmcblk0p1 ]; then
|
||||
mount -t vfat /dev/mmcblk0p1 /mnt/boot >/dev/null 2>&1 || true
|
||||
for cand in /mnt/boot/brlaser-root.tar.gz /mnt/boot/brlaser-root.tgz /mnt/boot/brlaser-root.tar; do
|
||||
if [ -f "$cand" ]; then
|
||||
rm -rf /var/cups-extra/brlaser-root
|
||||
mkdir -p /var/cups-extra/brlaser-root
|
||||
if tar -xf "$cand" -C /var/cups-extra/brlaser-root >/dev/null 2>&1; then
|
||||
BRLASER_DROPIN_ROOT=/var/cups-extra/brlaser-root
|
||||
if [ -d /var/cups-extra/brlaser-root/brlaser-root ]; then
|
||||
BRLASER_DROPIN_ROOT=/var/cups-extra/brlaser-root/brlaser-root
|
||||
fi
|
||||
debug_echo "loaded brlaser drop-in from $(basename "$cand")"
|
||||
else
|
||||
debug_echo "failed to extract brlaser drop-in $(basename "$cand")"
|
||||
fi
|
||||
break
|
||||
fi
|
||||
done
|
||||
umount /mnt/boot >/dev/null 2>&1 || true
|
||||
elif [ "$SHOULD_LOAD_DROPIN" -eq 0 ]; then
|
||||
debug_echo "drop-in loading disabled"
|
||||
else
|
||||
debug_echo "no boot partition for drop-in loading"
|
||||
fi
|
||||
|
||||
# Minimal identity DB expected by cupsd in initramfs.
|
||||
if [ ! -f /etc/group ]; then
|
||||
cat > /etc/group <<'EOF_GROUP'
|
||||
root:x:0:
|
||||
sys:x:3:
|
||||
lp:x:7:
|
||||
lpadmin:x:19:
|
||||
EOF_GROUP
|
||||
fi
|
||||
if [ ! -f /etc/passwd ]; then
|
||||
cat > /etc/passwd <<'EOF_PASSWD'
|
||||
root:x:0:0:root:/root:/bin/sh
|
||||
lp:x:7:7:CUPS:/var/spool/cups:/bin/false
|
||||
EOF_PASSWD
|
||||
fi
|
||||
grep -q '^lpadmin:' /etc/group || echo 'lpadmin:x:19:' >> /etc/group
|
||||
grep -q '^lp:' /etc/group || echo 'lp:x:7:' >> /etc/group
|
||||
grep -q '^sys:' /etc/group || echo 'sys:x:3:' >> /etc/group
|
||||
grep -q '^root:' /etc/group || echo 'root:x:0:' >> /etc/group
|
||||
grep -q '^lp:' /etc/passwd || echo 'lp:x:7:7:CUPS:/var/spool/cups:/bin/false' >> /etc/passwd
|
||||
|
||||
mkdir -p /etc/cups /var/cache/cups /var/spool/cups/tmp /run/cups /var/run/cups /var/log/cups
|
||||
chmod 1777 /var/spool/cups/tmp
|
||||
|
||||
mount_nix_from_sd
|
||||
|
||||
CUPS_BIN_ROOT="$(readlink /bin/cupsd 2>/dev/null | sed 's#/bin/cupsd$##')"
|
||||
[ -z "$CUPS_BIN_ROOT" ] && CUPS_BIN_ROOT="/nix/store"
|
||||
CUPS_DATA_ROOT="$CUPS_BIN_ROOT"
|
||||
if [ ! -d "${CUPS_DATA_ROOT}/share/cups" ] && [ -d "${CUPS_BIN_ROOT}-lib/share/cups" ]; then
|
||||
CUPS_DATA_ROOT="${CUPS_BIN_ROOT}-lib"
|
||||
fi
|
||||
|
||||
repair_elf_runtime() {
|
||||
elf_bin="$1"
|
||||
[ -x "$elf_bin" ] || return 0
|
||||
[ -x /bin/readelf ] || return 0
|
||||
|
||||
interp_path="$(/bin/readelf -l "$elf_bin" 2>/dev/null | sed -n 's@.*Requesting program interpreter: \(.*\)]@\1@p' | head -n 1)"
|
||||
if [ -n "$interp_path" ] && [ ! -e "$interp_path" ] && [ -e /lib/ld-musl-armhf.so.1 ]; then
|
||||
mkdir -p "$(dirname "$interp_path")"
|
||||
ln -snf /lib/ld-musl-armhf.so.1 "$interp_path" 2>/dev/null || true
|
||||
debug_echo "repaired interp for $(basename "$elf_bin") -> $interp_path"
|
||||
fi
|
||||
|
||||
runpaths="$(/bin/readelf -d "$elf_bin" 2>/dev/null | sed -n 's@.*Library runpath: \[\(.*\)\]@\1@p' | head -n 1)"
|
||||
[ -n "$runpaths" ] || return 0
|
||||
|
||||
gcc_lib_path="$(find /nix/store -path '*gcc*lib*/armv6l-unknown-linux-musleabihf/lib' 2>/dev/null | head -n 1)"
|
||||
cups_lib_path=""
|
||||
if [ -d "${CUPS_BIN_ROOT}/lib" ]; then
|
||||
cups_lib_path="${CUPS_BIN_ROOT}/lib"
|
||||
elif [ -d "${CUPS_BIN_ROOT}-lib/lib" ]; then
|
||||
cups_lib_path="${CUPS_BIN_ROOT}-lib/lib"
|
||||
fi
|
||||
|
||||
oldifs="$IFS"
|
||||
IFS=':'
|
||||
for rp in $runpaths; do
|
||||
[ -n "$rp" ] || continue
|
||||
[ -e "$rp" ] && continue
|
||||
target=""
|
||||
case "$rp" in
|
||||
*musl*"/lib") target="/lib" ;;
|
||||
*cups*"/lib") target="$cups_lib_path" ;;
|
||||
*gcc*"/armv6l-unknown-linux-musleabihf/lib") target="$gcc_lib_path" ;;
|
||||
esac
|
||||
if [ -n "$target" ] && [ -d "$target" ]; then
|
||||
mkdir -p "$(dirname "$rp")"
|
||||
rm -rf "$rp" 2>/dev/null || true
|
||||
ln -snf "$target" "$rp" 2>/dev/null || true
|
||||
debug_echo "repaired runpath for $(basename "$elf_bin") -> $rp"
|
||||
fi
|
||||
done
|
||||
IFS="$oldifs"
|
||||
}
|
||||
|
||||
install_brlaser_wrapper() {
|
||||
filter_bin="/var/cups-serverbin/lib/cups/filter/rastertobrlaser"
|
||||
runtime_filter="$BRLASER_RUNTIME_ROOT/filter/rastertobrlaser.real"
|
||||
[ -x "$filter_bin" ] || return 0
|
||||
|
||||
cp -a "$filter_bin" "$runtime_filter" 2>/dev/null || return 0
|
||||
|
||||
cat > "$filter_bin" <<'EOF_WRAP'
|
||||
#!/bin/sh
|
||||
RUNTIME_ROOT="/var/cups-extra/brlaser-runtime"
|
||||
REAL_FILTER="$RUNTIME_ROOT/filter/rastertobrlaser.real"
|
||||
[ -x "$REAL_FILTER" ] || exit 127
|
||||
LD_PATHS="$RUNTIME_ROOT/lib:/lib:/usr/lib"
|
||||
if [ -n "${BRLASER_LD_LIBRARY_PATH:-}" ]; then
|
||||
LD_PATHS="$BRLASER_LD_LIBRARY_PATH:$LD_PATHS"
|
||||
fi
|
||||
if [ -n "${LD_LIBRARY_PATH:-}" ]; then
|
||||
LD_PATHS="$LD_LIBRARY_PATH:$LD_PATHS"
|
||||
fi
|
||||
export LD_LIBRARY_PATH="$LD_PATHS"
|
||||
exec "$REAL_FILTER" "$@"
|
||||
EOF_WRAP
|
||||
chmod 555 "$filter_bin" 2>/dev/null || true
|
||||
debug_echo "installed brlaser wrapper at $filter_bin"
|
||||
}
|
||||
|
||||
# Copy CUPS serverbin to writable storage.
|
||||
mkdir -p /var/cups-serverbin/lib
|
||||
rm -rf /var/cups-serverbin/lib/cups
|
||||
if [ -d "${CUPS_BIN_ROOT}/lib/cups" ]; then
|
||||
cp -a "${CUPS_BIN_ROOT}/lib/cups" /var/cups-serverbin/lib/
|
||||
fi
|
||||
for extra_root in "$BRLASER_DROPIN_ROOT" "${BRLASER_ROOT:-}" "${CUPS_FILTERS_ROOT:-}"; do
|
||||
[ -n "$extra_root" ] || continue
|
||||
if [ -d "$extra_root/lib/cups" ]; then
|
||||
cp -a "$extra_root/lib/cups/." /var/cups-serverbin/lib/cups/ 2>/dev/null || true
|
||||
fi
|
||||
if [ -d "$extra_root/lib" ]; then
|
||||
find "$extra_root/lib" -maxdepth 2 -type f \( -name '*.so' -o -name '*.so.*' \) -exec cp -a {} "$BRLASER_RUNTIME_ROOT/lib/" \; 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
if [ -d /var/cups-serverbin/lib/cups ]; then
|
||||
chown root:root /var/cups-serverbin/lib/cups 2>/dev/null || true
|
||||
chown root:root /var/cups-serverbin/lib/cups/backend 2>/dev/null || true
|
||||
chown root:root /var/cups-serverbin/lib/cups/filter 2>/dev/null || true
|
||||
chown root:root /var/cups-serverbin/lib/cups/driver 2>/dev/null || true
|
||||
chown root:root /var/cups-serverbin/lib/cups/daemon 2>/dev/null || true
|
||||
chmod 755 /var/cups-serverbin/lib/cups 2>/dev/null || true
|
||||
chmod 755 /var/cups-serverbin/lib/cups/backend 2>/dev/null || true
|
||||
chmod 755 /var/cups-serverbin/lib/cups/filter 2>/dev/null || true
|
||||
chmod 755 /var/cups-serverbin/lib/cups/driver 2>/dev/null || true
|
||||
chmod 755 /var/cups-serverbin/lib/cups/daemon 2>/dev/null || true
|
||||
chown root:root /var/cups-serverbin/lib/cups/backend/* 2>/dev/null || true
|
||||
chown root:root /var/cups-serverbin/lib/cups/filter/* 2>/dev/null || true
|
||||
chown root:root /var/cups-serverbin/lib/cups/driver/* 2>/dev/null || true
|
||||
chown root:root /var/cups-serverbin/lib/cups/daemon/* 2>/dev/null || true
|
||||
chmod 555 /var/cups-serverbin/lib/cups/backend/* 2>/dev/null || true
|
||||
chmod 555 /var/cups-serverbin/lib/cups/filter/* 2>/dev/null || true
|
||||
chmod 555 /var/cups-serverbin/lib/cups/driver/* 2>/dev/null || true
|
||||
chmod 555 /var/cups-serverbin/lib/cups/daemon/* 2>/dev/null || true
|
||||
fi
|
||||
if [ -d /var/cups-serverbin/lib/cups/backend ]; then
|
||||
chmod 700 /var/cups-serverbin/lib/cups/backend/* 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Build writable CUPS data dir and merge optional models/PPDs.
|
||||
CUPS_RUNTIME_DATA=/var/cups-data
|
||||
rm -rf "$CUPS_RUNTIME_DATA"
|
||||
mkdir -p "$CUPS_RUNTIME_DATA"
|
||||
if [ -d "${CUPS_DATA_ROOT}/share/cups" ]; then
|
||||
if [ "${CUPS_RUNTIME_DATA_COPY:-minimal}" = "full" ]; then
|
||||
cp -a "${CUPS_DATA_ROOT}/share/cups/." "$CUPS_RUNTIME_DATA/" 2>/dev/null || true
|
||||
else
|
||||
for d in mime usb drv ppdc; do
|
||||
if [ -d "${CUPS_DATA_ROOT}/share/cups/$d" ]; then
|
||||
mkdir -p "$CUPS_RUNTIME_DATA/$d"
|
||||
cp -a "${CUPS_DATA_ROOT}/share/cups/$d/." "$CUPS_RUNTIME_DATA/$d/" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
for extra_root in "$BRLASER_DROPIN_ROOT" "${BRLASER_ROOT:-}" "${CUPS_FILTERS_ROOT:-}"; do
|
||||
[ -n "$extra_root" ] || continue
|
||||
if [ -d "$extra_root/share/cups/drv" ]; then
|
||||
mkdir -p "$CUPS_RUNTIME_DATA/drv"
|
||||
cp -a "$extra_root/share/cups/drv/." "$CUPS_RUNTIME_DATA/drv/" 2>/dev/null || true
|
||||
fi
|
||||
if [ -d "$extra_root/share/cups/model" ]; then
|
||||
mkdir -p "$CUPS_RUNTIME_DATA/model"
|
||||
cp -a "$extra_root/share/cups/model/." "$CUPS_RUNTIME_DATA/model/" 2>/dev/null || true
|
||||
fi
|
||||
if [ -d "$extra_root/share/cups/ppdc" ]; then
|
||||
mkdir -p "$CUPS_RUNTIME_DATA/ppdc"
|
||||
cp -a "$extra_root/share/cups/ppdc/." "$CUPS_RUNTIME_DATA/ppdc/" 2>/dev/null || true
|
||||
fi
|
||||
if [ -d "$extra_root/share/ppd" ]; then
|
||||
mkdir -p "$CUPS_RUNTIME_DATA/model"
|
||||
cp -a "$extra_root/share/ppd/." "$CUPS_RUNTIME_DATA/model/" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
mkdir -p "$CUPS_RUNTIME_DATA/model" "$CUPS_RUNTIME_DATA/drv" "$CUPS_RUNTIME_DATA/usb" "$CUPS_RUNTIME_DATA/mime" "$CUPS_RUNTIME_DATA/ppdc"
|
||||
chown root:root "$CUPS_RUNTIME_DATA" "$CUPS_RUNTIME_DATA/model" "$CUPS_RUNTIME_DATA/drv" "$CUPS_RUNTIME_DATA/usb" "$CUPS_RUNTIME_DATA/mime" "$CUPS_RUNTIME_DATA/ppdc" 2>/dev/null || true
|
||||
chmod 755 "$CUPS_RUNTIME_DATA" "$CUPS_RUNTIME_DATA/model" "$CUPS_RUNTIME_DATA/drv" "$CUPS_RUNTIME_DATA/usb" "$CUPS_RUNTIME_DATA/mime" "$CUPS_RUNTIME_DATA/ppdc" 2>/dev/null || true
|
||||
|
||||
if [ "${CUPS_RUNTIME_REPAIR_ALL_FILTERS:-0}" = "1" ] && [ -d /var/cups-serverbin/lib/cups/filter ]; then
|
||||
for f in /var/cups-serverbin/lib/cups/filter/*; do
|
||||
[ -f "$f" ] || continue
|
||||
repair_elf_runtime "$f"
|
||||
done
|
||||
if [ -d /var/cups-serverbin/lib/cups/driver ]; then
|
||||
for f in /var/cups-serverbin/lib/cups/driver/*; do
|
||||
[ -f "$f" ] || continue
|
||||
repair_elf_runtime "$f"
|
||||
done
|
||||
fi
|
||||
else
|
||||
repair_elf_runtime /var/cups-serverbin/lib/cups/filter/rastertobrlaser
|
||||
repair_elf_runtime /var/cups-serverbin/lib/cups/driver/drv
|
||||
repair_elf_runtime /var/cups-serverbin/lib/cups/driver/cups-driverd
|
||||
fi
|
||||
install_brlaser_wrapper
|
||||
|
||||
run_ppdc_brlaser() {
|
||||
out_dir="$1"
|
||||
drv_file="$2"
|
||||
timeout_secs="${3:-10}"
|
||||
[ -n "$out_dir" ] && [ -n "$drv_file" ] || return 1
|
||||
|
||||
set -- /bin/ppdc -d "$out_dir"
|
||||
for inc in "$CUPS_RUNTIME_DATA/ppdc" "$CUPS_RUNTIME_DATA/drv" "$CUPS_DATA_ROOT/share/cups/ppdc"; do
|
||||
[ -d "$inc" ] || continue
|
||||
set -- "$@" -I "$inc"
|
||||
done
|
||||
set -- "$@" "$drv_file"
|
||||
/bin/timeout "$timeout_secs" "$@"
|
||||
}
|
||||
|
||||
BRLASER_DRV="$CUPS_RUNTIME_DATA/drv/brlaser.drv"
|
||||
if [ "${CUPS_RUNTIME_ENABLE_PPDC:-0}" = "1" ] && [ -f "$BRLASER_DRV" ] && [ -x /bin/ppdc ]; then
|
||||
mkdir -p "$CUPS_RUNTIME_DATA/model"
|
||||
run_ppdc_brlaser "$CUPS_RUNTIME_DATA/model" "$BRLASER_DRV" 10 >/dev/null 2>&1 || debug_echo "ppdc model generation timed out/failed"
|
||||
fi
|
||||
|
||||
cat > /etc/cups/cups-files.conf <<EOF_CFG
|
||||
SystemGroup root lpadmin
|
||||
FileDevice Yes
|
||||
RequestRoot /var/spool/cups
|
||||
ServerRoot /etc/cups
|
||||
CacheDir /var/cache/cups
|
||||
DataDir ${CUPS_RUNTIME_DATA}
|
||||
ServerBin /var/cups-serverbin/lib/cups
|
||||
EOF_CFG
|
||||
|
||||
if [ ! -f /etc/cups/cupsd.conf ]; then
|
||||
cat > /etc/cups/cupsd.conf <<'EOF_CUPSD'
|
||||
LogLevel warn
|
||||
Listen 0.0.0.0:631
|
||||
Browsing Off
|
||||
DefaultAuthType None
|
||||
WebInterface No
|
||||
<Location />
|
||||
Order allow,deny
|
||||
Allow all
|
||||
</Location>
|
||||
EOF_CUPSD
|
||||
fi
|
||||
|
||||
BRLASER_BLOCK_REASON=""
|
||||
brlaser_filter_usable() {
|
||||
filter_bin="/var/cups-serverbin/lib/cups/filter/rastertobrlaser"
|
||||
BRLASER_BLOCK_REASON=""
|
||||
[ -x "$filter_bin" ] || { BRLASER_BLOCK_REASON="filter missing/not executable"; return 1; }
|
||||
[ -x /bin/readelf ] || { BRLASER_BLOCK_REASON="readelf unavailable for probe"; return 1; }
|
||||
|
||||
interp_path="$(/bin/readelf -l "$filter_bin" 2>/dev/null | sed -n 's@.*Requesting program interpreter: \(.*\)]@\1@p' | head -n 1)"
|
||||
if [ -n "$interp_path" ] && [ ! -e "$interp_path" ]; then
|
||||
BRLASER_BLOCK_REASON="missing interpreter: $interp_path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
runpaths="$(/bin/readelf -d "$filter_bin" 2>/dev/null | sed -n 's@.*Library runpath: \[\(.*\)\]@\1@p' | head -n 1)"
|
||||
needed_libs="$(/bin/readelf -d "$filter_bin" 2>/dev/null | sed -n 's@.*Shared library: \[\(.*\)\]@\1@p')"
|
||||
search_paths="$runpaths:/lib:/usr/lib"
|
||||
for lib in $needed_libs; do
|
||||
found=""
|
||||
for p in $(echo "$search_paths" | tr ':' '\n'); do
|
||||
[ -n "$p" ] || continue
|
||||
if [ -e "$p/$lib" ]; then
|
||||
found=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$found" ]; then
|
||||
BRLASER_BLOCK_REASON="missing shared lib: $lib"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
probe_err="/tmp/brlaser-probe.err"
|
||||
: > "$probe_err"
|
||||
/bin/timeout 2 "$filter_bin" >/dev/null 2>"$probe_err"
|
||||
rc="$?"
|
||||
if grep -q -E "Error loading shared library|Error relocating|not found" "$probe_err"; then
|
||||
BRLASER_BLOCK_REASON="ABI mismatch (relocation/shared-lib errors)"
|
||||
return 1
|
||||
fi
|
||||
if [ "$rc" -eq 126 ] || [ "$rc" -eq 127 ]; then
|
||||
BRLASER_BLOCK_REASON="filter failed to execute (rc=$rc)"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
find_runtime_brlaser_ppd() {
|
||||
model="$1"
|
||||
model_dir="$CUPS_RUNTIME_DATA/model"
|
||||
[ -d "$model_dir" ] || return 0
|
||||
|
||||
if [ -n "$model" ]; then
|
||||
ppd="$(find "$model_dir" -type f \( -iname "*${model}*.ppd" -o -iname "*${model}*.ppd.gz" \) | head -n 1)"
|
||||
[ -n "$ppd" ] && { echo "$ppd"; return 0; }
|
||||
|
||||
ppd="$(grep -RilsF "ModelName: \"Brother $model\"" "$model_dir" 2>/dev/null | head -n 1)"
|
||||
[ -n "$ppd" ] && { echo "$ppd"; return 0; }
|
||||
|
||||
ppd="$(grep -RilsF "MDL:$model;" "$model_dir" 2>/dev/null | head -n 1)"
|
||||
[ -n "$ppd" ] && { echo "$ppd"; return 0; }
|
||||
fi
|
||||
|
||||
find "$model_dir" -type f \
|
||||
\( -iname 'brl*.ppd' -o -iname 'brl*.ppd.gz' -o -iname '*Brother*HL-*.ppd' -o -iname '*Brother*HL-*.ppd.gz' \) \
|
||||
| head -n 1
|
||||
}
|
||||
|
||||
provision_hbp_queue() {
|
||||
queue_uri="$1"
|
||||
[ -n "$queue_uri" ] || return 1
|
||||
|
||||
if ! brlaser_filter_usable; then
|
||||
debug_echo "HBP blocked: skipping test-hbp queue (${BRLASER_BLOCK_REASON})"
|
||||
return 0
|
||||
fi
|
||||
|
||||
ppd=""
|
||||
model_name="$(printf '%s' "$queue_uri" | sed -n 's@^usb://Brother/\([^?]*\).*$@\1@p' | sed 's/%20/ /g')"
|
||||
if [ -d "$CUPS_RUNTIME_DATA/model" ]; then
|
||||
ppd="$(find_runtime_brlaser_ppd "$model_name")"
|
||||
fi
|
||||
if [ -z "$ppd" ] && [ -x /bin/ppdc ] && [ -f "$CUPS_RUNTIME_DATA/drv/brlaser.drv" ]; then
|
||||
mkdir -p "$CUPS_RUNTIME_DATA/model"
|
||||
run_ppdc_brlaser "$CUPS_RUNTIME_DATA/model" "$CUPS_RUNTIME_DATA/drv/brlaser.drv" 10 >/dev/null 2>&1 || true
|
||||
ppd="$(find_runtime_brlaser_ppd "$model_name")"
|
||||
fi
|
||||
|
||||
if [ -n "$ppd" ]; then
|
||||
/bin/lpadmin -h "$SOCK" -x test-hbp >/dev/null 2>&1 || true
|
||||
/bin/lpadmin -h "$SOCK" -p test-hbp -E -v "$queue_uri" -P "$ppd" >> /log/cups.log 2>&1 || true
|
||||
debug_echo "queue test-hbp configured ppd=$ppd"
|
||||
else
|
||||
debug_echo "no brlaser PPD found for non-raw queue"
|
||||
fi
|
||||
}
|
||||
|
||||
maybe_provision_hbp_async() {
|
||||
queue_uri="$1"
|
||||
[ -n "$queue_uri" ] || return 0
|
||||
if [ "${CUPS_RUNTIME_HBP_ASYNC:-1}" = "1" ]; then
|
||||
(
|
||||
provision_hbp_queue "$queue_uri"
|
||||
) &
|
||||
debug_echo "scheduling test-hbp provisioning in background"
|
||||
else
|
||||
provision_hbp_queue "$queue_uri"
|
||||
fi
|
||||
}
|
||||
|
||||
provision_runtime_queue() {
|
||||
[ -x /bin/lpadmin ] || return 1
|
||||
[ -S "$SOCK" ] || return 1
|
||||
|
||||
queue_uri=""
|
||||
usb_backend="/var/cups-serverbin/lib/cups/backend/usb"
|
||||
if [ -x "$usb_backend" ]; then
|
||||
queue_uri="$($usb_backend 2>/dev/null | awk '$1=="direct" && $2 ~ /^usb:\/\// {print $2; exit}')"
|
||||
fi
|
||||
if [ -z "$queue_uri" ] && [ -c /dev/usb/lp0 ]; then
|
||||
queue_uri="file:///dev/usb/lp0"
|
||||
fi
|
||||
[ -n "$queue_uri" ] || return 1
|
||||
|
||||
/bin/lpadmin -h "$SOCK" -x test >/dev/null 2>&1 || true
|
||||
/bin/lpadmin -h "$SOCK" -p test -E -v "$queue_uri" -m raw >> /log/cups.log 2>&1 || return 1
|
||||
debug_echo "queue test configured uri=$queue_uri (raw)"
|
||||
maybe_provision_hbp_async "$queue_uri"
|
||||
return 0
|
||||
}
|
||||
|
||||
if /bin/cupsd >> /log/cups.log 2>&1; then
|
||||
if ! provision_runtime_queue; then
|
||||
if [ "${CUPS_RUNTIME_QUEUE_RETRY:-0}" = "1" ]; then
|
||||
debug_echo "no printer URI discovered; starting retry loop"
|
||||
(
|
||||
retries=90
|
||||
while [ "$retries" -gt 0 ]; do
|
||||
sleep 2
|
||||
if provision_runtime_queue; then
|
||||
exit 0
|
||||
fi
|
||||
retries=$((retries - 1))
|
||||
done
|
||||
debug_echo "queue provisioning timed out (no URI discovered)"
|
||||
) &
|
||||
else
|
||||
debug_echo "no printer URI discovered; retry loop disabled"
|
||||
fi
|
||||
fi
|
||||
debug_echo "scheduler started"
|
||||
else
|
||||
debug_echo "failed to start cupsd"
|
||||
fi
|
||||
|
||||
log "bootstrap complete"
|
||||
435
scripts/cups/cups-runtime-ram-feasibility
Executable file
435
scripts/cups/cups-runtime-ram-feasibility
Executable file
@ -0,0 +1,435 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
RAM_ROOT="${HBP_RAM_ROOT:-/run/hbp-ram-runtime}"
|
||||
MIN_AVAIL_MB="${HBP_RAM_MIN_AVAIL_MB:-100}"
|
||||
SOCK="${CUPS_SERVER_SOCK:-/var/run/cups/cups.sock}"
|
||||
TMP_RAW="/tmp/hbp-ram-roots.raw.$$"
|
||||
TMP_LIST="/tmp/hbp-ram-roots.list.$$"
|
||||
TMP_DONE_FILES="/tmp/hbp-ram-done-files.$$"
|
||||
trap 'rm -f "$TMP_RAW" "$TMP_LIST" "$TMP_DONE_FILES"' EXIT INT TERM
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
usage:
|
||||
cups-runtime-ram-feasibility estimate [core|full]
|
||||
cups-runtime-ram-feasibility stage [core|full]
|
||||
cups-runtime-ram-feasibility check
|
||||
cups-runtime-ram-feasibility status
|
||||
cups-runtime-ram-feasibility detach-sd
|
||||
cups-runtime-ram-feasibility unstage
|
||||
|
||||
notes:
|
||||
- core: curated runtime roots for HBP print path.
|
||||
- full: all paths from /cups-runtime-store-paths (requires latest image build).
|
||||
- env:
|
||||
HBP_RAM_ROOT=/run/hbp-ram-runtime
|
||||
HBP_RAM_SIZE=<tmpfs size, e.g. 320m> (stage only)
|
||||
HBP_RAM_MIN_AVAIL_MB=100 (check gate)
|
||||
USAGE
|
||||
}
|
||||
|
||||
store_root_of() {
|
||||
p="${1:-}"
|
||||
[ -n "$p" ] || return 1
|
||||
rp="$(readlink -f "$p" 2>/dev/null || true)"
|
||||
[ -n "$rp" ] || return 1
|
||||
printf '%s\n' "$rp" | sed -n 's#^\(/nix/store/[^/]*-[^/]*\).*#\1#p' | head -n 1
|
||||
}
|
||||
|
||||
add_root() {
|
||||
r="${1:-}"
|
||||
[ -n "$r" ] || return 0
|
||||
case "$r" in
|
||||
/nix/store/*)
|
||||
[ -e "$r" ] && echo "$r" >> "$TMP_RAW"
|
||||
case "$r" in
|
||||
*-lib) ;;
|
||||
*)
|
||||
# Nix frequently splits runtime libraries into sibling -lib outputs.
|
||||
if [ -e "${r}-lib" ]; then
|
||||
echo "${r}-lib" >> "$TMP_RAW"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
mem_field_mb() {
|
||||
key="$1"
|
||||
awk -v k="$key" '$1==k":" {printf "%d", $2/1024; exit}' /proc/meminfo
|
||||
}
|
||||
|
||||
report_mem() {
|
||||
tag="$1"
|
||||
echo "mem[$tag]: total=$(mem_field_mb MemTotal)MB avail=$(mem_field_mb MemAvailable)MB free=$(mem_field_mb MemFree)MB"
|
||||
}
|
||||
|
||||
resolve_lib_path() {
|
||||
lib="$1"
|
||||
runpaths="$2"
|
||||
oldifs="$IFS"
|
||||
IFS=':'
|
||||
for d in $runpaths /lib /usr/lib; do
|
||||
[ -n "$d" ] || continue
|
||||
if [ -e "$d/$lib" ]; then
|
||||
IFS="$oldifs"
|
||||
readlink -f "$d/$lib" 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
IFS="$oldifs"
|
||||
|
||||
# BusyBox find behavior can vary; keep fallback lookup simple and robust.
|
||||
p="$(find /nix/store -name "$lib" 2>/dev/null | head -n 1 || true)"
|
||||
if [ -n "$p" ]; then
|
||||
readlink -f "$p" 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
collect_loader_roots() {
|
||||
f="$1"
|
||||
[ -n "$f" ] || return 0
|
||||
[ -x /lib/ld-musl-armhf.so.1 ] || return 0
|
||||
|
||||
/lib/ld-musl-armhf.so.1 --list "$f" 2>/dev/null \
|
||||
| awk '{for(i=1;i<=NF;i++) if($i ~ /^\/nix\/store\//) print $i}' \
|
||||
| while read -r p; do
|
||||
add_root "$(store_root_of "$p" || true)"
|
||||
done
|
||||
}
|
||||
|
||||
collect_file_deps() {
|
||||
f="$1"
|
||||
[ -n "$f" ] || return 0
|
||||
f="$(readlink -f "$f" 2>/dev/null || true)"
|
||||
[ -n "$f" ] || return 0
|
||||
[ -e "$f" ] || return 0
|
||||
|
||||
grep -Fxq "$f" "$TMP_DONE_FILES" 2>/dev/null && return 0
|
||||
echo "$f" >> "$TMP_DONE_FILES"
|
||||
add_root "$(store_root_of "$f" || true)"
|
||||
collect_loader_roots "$f"
|
||||
|
||||
[ -x /bin/readelf ] || return 0
|
||||
|
||||
interp="$(/bin/readelf -l "$f" 2>/dev/null | sed -n 's@.*Requesting program interpreter: \(.*\)]@\1@p' | head -n 1 || true)"
|
||||
[ -n "$interp" ] && collect_file_deps "$interp"
|
||||
|
||||
runpaths="$(/bin/readelf -d "$f" 2>/dev/null | sed -n 's@.*Library runpath: \[\(.*\)\]@\1@p' | head -n 1 || true)"
|
||||
if [ -n "$runpaths" ]; then
|
||||
oldifs="$IFS"
|
||||
IFS=':'
|
||||
for rp in $runpaths; do
|
||||
case "$rp" in
|
||||
/nix/store/*) add_root "$(store_root_of "$rp" || true)" ;;
|
||||
esac
|
||||
done
|
||||
IFS="$oldifs"
|
||||
fi
|
||||
|
||||
needed="$(/bin/readelf -d "$f" 2>/dev/null | sed -n 's@.*Shared library: \[\(.*\)\]@\1@p' || true)"
|
||||
[ -n "$needed" ] || return 0
|
||||
for lib in $needed; do
|
||||
libp="$(resolve_lib_path "$lib" "$runpaths" || true)"
|
||||
[ -n "$libp" ] || continue
|
||||
collect_file_deps "$libp"
|
||||
done
|
||||
}
|
||||
|
||||
collect_core_roots() {
|
||||
: > "$TMP_RAW"
|
||||
: > "$TMP_DONE_FILES"
|
||||
for cmd in cupsd lp lpadmin lpstat lpinfo ppdc gs pdftops cupsfilter; do
|
||||
p="$(command -v "$cmd" 2>/dev/null || true)"
|
||||
[ -n "$p" ] || continue
|
||||
rp="$(readlink -f "$p" 2>/dev/null || true)"
|
||||
[ -n "$rp" ] && collect_file_deps "$rp"
|
||||
done
|
||||
for p in \
|
||||
/var/cups-serverbin/lib/cups/filter/rastertobrlaser \
|
||||
/var/cups-serverbin/lib/cups/backend/usb \
|
||||
/var/cups-serverbin/lib/cups/driver/cups-driverd \
|
||||
/var/cups-serverbin/lib/cups/driver/drv
|
||||
do
|
||||
[ -e "$p" ] || continue
|
||||
rp="$(readlink -f "$p" 2>/dev/null || true)"
|
||||
[ -n "$rp" ] && collect_file_deps "$rp"
|
||||
done
|
||||
if [ -f /cups-runtime.env ]; then
|
||||
# shellcheck source=/dev/null
|
||||
. /cups-runtime.env
|
||||
add_root "${BRLASER_ROOT:-}"
|
||||
add_root "${CUPS_FILTERS_ROOT:-}"
|
||||
fi
|
||||
cupsd_real="$(readlink -f /bin/cupsd 2>/dev/null || true)"
|
||||
cupsd_root="$(store_root_of "$cupsd_real" || true)"
|
||||
add_root "$cupsd_root"
|
||||
if [ -n "$cupsd_root" ] && [ -d "${cupsd_root}-lib" ]; then
|
||||
add_root "${cupsd_root}-lib"
|
||||
fi
|
||||
if [ -f /etc/cups/cups-files.conf ]; then
|
||||
awk '{for(i=1;i<=NF;i++) if($i ~ /^\/nix\/store\//) print $i}' /etc/cups/cups-files.conf \
|
||||
| while read -r p; do
|
||||
add_root "$(store_root_of "$p" || true)"
|
||||
done
|
||||
fi
|
||||
sort -u "$TMP_RAW" > "$TMP_LIST"
|
||||
}
|
||||
|
||||
collect_full_roots() {
|
||||
: > "$TMP_RAW"
|
||||
if [ ! -f /cups-runtime-store-paths ]; then
|
||||
echo "error: /cups-runtime-store-paths missing (rebuild with latest flake/initramfs)" >&2
|
||||
exit 1
|
||||
fi
|
||||
while read -r p; do
|
||||
[ -n "$p" ] || continue
|
||||
case "$p" in
|
||||
/nix/store/*) add_root "$p" ;;
|
||||
esac
|
||||
done < /cups-runtime-store-paths
|
||||
sort -u "$TMP_RAW" > "$TMP_LIST"
|
||||
}
|
||||
|
||||
estimate_kib() {
|
||||
total=0
|
||||
while read -r r; do
|
||||
[ -e "$r" ] || continue
|
||||
sz="$(du -sk "$r" 2>/dev/null | awk '{print $1}')"
|
||||
[ -n "$sz" ] || sz=0
|
||||
total=$((total + sz))
|
||||
done < "$TMP_LIST"
|
||||
echo "$total"
|
||||
}
|
||||
|
||||
default_tmpfs_size() {
|
||||
kib="$1"
|
||||
# +20% copy overhead and +64 MiB headroom.
|
||||
size_kib=$(( (kib * 120) / 100 + 65536 ))
|
||||
echo "${size_kib}k"
|
||||
}
|
||||
|
||||
is_mounted() {
|
||||
mnt="$1"
|
||||
awk -v m="$mnt" '$2==m{found=1} END{exit found?0:1}' /proc/mounts
|
||||
}
|
||||
|
||||
setup_tmpfs() {
|
||||
size_opt="$1"
|
||||
mkdir -p "$RAM_ROOT"
|
||||
if ! is_mounted "$RAM_ROOT"; then
|
||||
mount -t tmpfs -o "size=$size_opt,mode=0755" tmpfs "$RAM_ROOT"
|
||||
fi
|
||||
mkdir -p "$RAM_ROOT/nix/store"
|
||||
}
|
||||
|
||||
copy_roots() {
|
||||
n=0
|
||||
while read -r r; do
|
||||
[ -e "$r" ] || continue
|
||||
n=$((n + 1))
|
||||
dest="$RAM_ROOT/nix/store/$(basename "$r")"
|
||||
if [ -e "$dest" ]; then
|
||||
src_real="$(readlink -f "$r" 2>/dev/null || true)"
|
||||
dst_real="$(readlink -f "$dest" 2>/dev/null || true)"
|
||||
if [ -n "$src_real" ] && [ "$src_real" = "$dst_real" ]; then
|
||||
echo "copy[$n]: $r (already staged)"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
echo "copy[$n]: $r"
|
||||
cp -a "$r" "$RAM_ROOT/nix/store/"
|
||||
done < "$TMP_LIST"
|
||||
}
|
||||
|
||||
bind_nix() {
|
||||
src="$(awk '$2=="/nix"{print $1}' /proc/mounts | tail -n 1)"
|
||||
if [ "$src" = "$RAM_ROOT/nix" ]; then
|
||||
return 0
|
||||
fi
|
||||
mount --bind "$RAM_ROOT/nix" /nix
|
||||
}
|
||||
|
||||
unbind_nix() {
|
||||
src="$(awk '$2=="/nix"{print $1}' /proc/mounts | tail -n 1)"
|
||||
if [ "$src" = "$RAM_ROOT/nix" ]; then
|
||||
umount /nix
|
||||
fi
|
||||
}
|
||||
|
||||
teardown_tmpfs() {
|
||||
if is_mounted "$RAM_ROOT"; then
|
||||
umount "$RAM_ROOT"
|
||||
fi
|
||||
}
|
||||
|
||||
status() {
|
||||
src="$(awk '$2=="/nix"{print $1 " (" $3 ")"}' /proc/mounts | tail -n 1)"
|
||||
[ -n "$src" ] || src="(unmounted)"
|
||||
echo "nix-mount: $src"
|
||||
if is_mounted "$RAM_ROOT"; then
|
||||
rsrc="$(awk -v m="$RAM_ROOT" '$2==m{print $1 " (" $3 ")"}' /proc/mounts | tail -n 1)"
|
||||
echo "ram-root: $rsrc"
|
||||
else
|
||||
echo "ram-root: not-mounted"
|
||||
fi
|
||||
report_mem "status"
|
||||
}
|
||||
|
||||
enforce_mem_gate() {
|
||||
avail="$(mem_field_mb MemAvailable)"
|
||||
if [ "$avail" -lt "$MIN_AVAIL_MB" ]; then
|
||||
echo "FAIL: MemAvailable=${avail}MB < ${MIN_AVAIL_MB}MB gate" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "PASS: MemAvailable=${avail}MB >= ${MIN_AVAIL_MB}MB gate"
|
||||
}
|
||||
|
||||
summarize_estimate() {
|
||||
mode="$1"
|
||||
count="$(wc -l < "$TMP_LIST" | tr -d ' ')"
|
||||
kib="$(estimate_kib)"
|
||||
mib=$((kib / 1024))
|
||||
echo "mode=$mode roots=$count size=${mib}MiB"
|
||||
}
|
||||
|
||||
run_check() {
|
||||
status
|
||||
enforce_mem_gate
|
||||
echo "[queues]"
|
||||
lpstat -h "$SOCK" -p -v || true
|
||||
}
|
||||
|
||||
detach_sd() {
|
||||
src="$(awk '$2=="/nix"{print $1}' /proc/mounts | tail -n 1)"
|
||||
fstype="$(awk '$2=="/nix"{print $3}' /proc/mounts | tail -n 1)"
|
||||
if [ "$src" != "$RAM_ROOT/nix" ] && [ "$fstype" != "tmpfs" ]; then
|
||||
echo "error: /nix is not RAM-backed; run 'stage' first (src=$src fstype=$fstype)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
sync
|
||||
if command -v blockdev >/dev/null 2>&1 && [ -b /dev/mmcblk0 ]; then
|
||||
blockdev --flushbufs /dev/mmcblk0 >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
pass=0
|
||||
while [ "$pass" -lt 6 ]; do
|
||||
pass=$((pass + 1))
|
||||
remain="$(awk '$1 ~ /^\/dev\/mmcblk0p[0-9]+$/ {print $1 " " $2}' /proc/mounts)"
|
||||
[ -z "$remain" ] && break
|
||||
|
||||
# Handle stacked /nix mounts: if top /nix is RAM-backed but an mmc mount
|
||||
# still exists at /nix, drop top layer once to expose lower mount.
|
||||
if echo "$remain" | awk '$2=="/nix"{found=1} END{exit found?0:1}'; then
|
||||
src="$(awk '$2=="/nix"{print $1}' /proc/mounts | tail -n 1)"
|
||||
fstype="$(awk '$2=="/nix"{print $3}' /proc/mounts | tail -n 1)"
|
||||
if [ "$src" = "$RAM_ROOT/nix" ] || [ "$fstype" = "tmpfs" ]; then
|
||||
umount /nix >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
remain="$(awk '$1 ~ /^\/dev\/mmcblk0p[0-9]+$/ {print $1 " " $2}' /proc/mounts)"
|
||||
[ -z "$remain" ] && break
|
||||
tmp_mmc="/tmp/hbp-ram-mmc.$$.${pass}"
|
||||
echo "$remain" > "$tmp_mmc"
|
||||
while read -r dev mnt; do
|
||||
[ -n "$dev" ] || continue
|
||||
[ -n "$mnt" ] || continue
|
||||
echo "detach: unmounting $dev ($mnt)"
|
||||
umount "$mnt" >/dev/null 2>&1 || umount -l "$mnt" >/dev/null 2>&1 || \
|
||||
echo "warn: failed to unmount $dev ($mnt)" >&2
|
||||
done < "$tmp_mmc"
|
||||
rm -f "$tmp_mmc"
|
||||
|
||||
# Ensure /nix is RAM-backed after detach steps.
|
||||
src="$(awk '$2=="/nix"{print $1}' /proc/mounts | tail -n 1)"
|
||||
fstype="$(awk '$2=="/nix"{print $3}' /proc/mounts | tail -n 1)"
|
||||
if [ "$src" != "$RAM_ROOT/nix" ] && [ "$fstype" != "tmpfs" ] && [ -d "$RAM_ROOT/nix/store" ]; then
|
||||
mount --bind "$RAM_ROOT/nix" /nix >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
|
||||
sync
|
||||
remain="$(awk '$1 ~ /^\/dev\/mmcblk0p[0-9]+$/ {print $1 " -> " $2}' /proc/mounts)"
|
||||
if [ -n "$remain" ]; then
|
||||
echo "FAIL: mmc partitions still mounted:" >&2
|
||||
echo "$remain" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "SD detach prep complete: no mmcblk0p* mounts remain."
|
||||
}
|
||||
|
||||
nix_is_ram_backed() {
|
||||
src="$(awk '$2=="/nix"{print $1}' /proc/mounts | tail -n 1)"
|
||||
fstype="$(awk '$2=="/nix"{print $3}' /proc/mounts | tail -n 1)"
|
||||
if [ "$src" = "$RAM_ROOT/nix" ] || [ "$fstype" = "tmpfs" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ -d /nix/store ] && [ -d "$RAM_ROOT/nix/store" ]; then
|
||||
nix_store="$(readlink -f /nix/store 2>/dev/null || true)"
|
||||
ram_store="$(readlink -f "$RAM_ROOT/nix/store" 2>/dev/null || true)"
|
||||
if [ -n "$nix_store" ] && [ "$nix_store" = "$ram_store" ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
ACTION="${1:-}"
|
||||
MODE="${2:-core}"
|
||||
|
||||
case "$ACTION" in
|
||||
estimate)
|
||||
case "$MODE" in
|
||||
core) collect_core_roots ;;
|
||||
full) collect_full_roots ;;
|
||||
*) usage; exit 2 ;;
|
||||
esac
|
||||
summarize_estimate "$MODE"
|
||||
;;
|
||||
stage)
|
||||
case "$MODE" in
|
||||
core) collect_core_roots ;;
|
||||
full) collect_full_roots ;;
|
||||
*) usage; exit 2 ;;
|
||||
esac
|
||||
summarize_estimate "$MODE"
|
||||
if nix_is_ram_backed; then
|
||||
echo "stage: /nix already RAM-backed; skipping copy"
|
||||
report_mem "after-stage"
|
||||
run_check
|
||||
exit 0
|
||||
fi
|
||||
kib="$(estimate_kib)"
|
||||
size_opt="${HBP_RAM_SIZE:-$(default_tmpfs_size "$kib")}"
|
||||
report_mem "before-stage"
|
||||
setup_tmpfs "$size_opt"
|
||||
copy_roots
|
||||
bind_nix
|
||||
report_mem "after-stage"
|
||||
run_check
|
||||
;;
|
||||
check)
|
||||
run_check
|
||||
;;
|
||||
status)
|
||||
status
|
||||
;;
|
||||
detach-sd)
|
||||
detach_sd
|
||||
;;
|
||||
unstage)
|
||||
unbind_nix
|
||||
teardown_tmpfs
|
||||
report_mem "after-unstage"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
208
scripts/cups/print-hbp-pdf
Executable file
208
scripts/cups/print-hbp-pdf
Executable file
@ -0,0 +1,208 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
SOCK="${CUPS_SERVER_SOCK:-/var/run/cups/cups.sock}"
|
||||
QUEUE="${HBP_QUEUE:-test-hbp}"
|
||||
GADGET_DEV="${HBP_GADGET_DEV:-/dev/ttyGS1}"
|
||||
GADGET_WRITE_TIMEOUT_SEC="${HBP_GADGET_WRITE_TIMEOUT_SEC:-45}"
|
||||
PDF="${1:-}"
|
||||
DPI="${2:-600}"
|
||||
if [ -z "$PDF" ] || [ ! -f "$PDF" ]; then
|
||||
echo "usage: print-hbp-pdf /path/to/file.pdf [dpi]" >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$DPI" in
|
||||
''|*[!0-9]*)
|
||||
echo "invalid dpi: '$DPI'" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
if [ "$DPI" -lt 300 ] || [ "$DPI" -gt 1200 ]; then
|
||||
echo "dpi out of range: $DPI (expected 300..1200)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Brother HBP via brlaser currently produces wrong geometry when fed 1200dpi
|
||||
# cups-raster in this environment. Keep raster path at 600dpi for correctness.
|
||||
EFFECTIVE_DPI="$DPI"
|
||||
if [ "$DPI" -gt 600 ]; then
|
||||
EFFECTIVE_DPI=600
|
||||
echo "print-hbp-pdf: requested ${DPI}dpi, using ${EFFECTIVE_DPI}dpi for correct geometry on HBP path" >&2
|
||||
fi
|
||||
|
||||
ensure_runtime_tools() {
|
||||
command -v lpadmin >/dev/null 2>&1 && command -v lpstat >/dev/null 2>&1 && return 0
|
||||
|
||||
# Recovery path after SD detach: restore /nix from RAM-backed stage, if present.
|
||||
if [ -d /run/hbp-ram-runtime/nix/store ]; then
|
||||
mount --bind /run/hbp-ram-runtime/nix /nix >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
command -v lpadmin >/dev/null 2>&1 && command -v lpstat >/dev/null 2>&1
|
||||
}
|
||||
|
||||
OUTPUT_MODE=""
|
||||
detect_output_mode() {
|
||||
if ls /dev/usb/lp* >/dev/null 2>&1; then
|
||||
OUTPUT_MODE="cups"
|
||||
return 0
|
||||
fi
|
||||
if [ -c "$GADGET_DEV" ]; then
|
||||
OUTPUT_MODE="gadget"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if ! detect_output_mode; then
|
||||
echo "print-hbp-pdf: no printer path available (/dev/usb/lp* or ${GADGET_DEV})" >&2
|
||||
exit 5
|
||||
fi
|
||||
|
||||
ensure_cupsd_running() {
|
||||
if [ -S "$SOCK" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ -x /bin/cupsd ]; then
|
||||
/bin/cupsd >/log/cups.log 2>&1 || true
|
||||
sleep 1
|
||||
fi
|
||||
[ -S "$SOCK" ]
|
||||
}
|
||||
|
||||
ensure_hbp_queue() {
|
||||
lpstat -h "$SOCK" -p "$QUEUE" >/dev/null 2>&1 && return 0
|
||||
|
||||
URI=""
|
||||
if lpstat -h "$SOCK" -p test >/dev/null 2>&1; then
|
||||
URI="$(lpstat -h "$SOCK" -v 2>/dev/null | awk '$1=="device" && $3=="test:" {print $4; exit}')"
|
||||
fi
|
||||
if [ -z "$URI" ] && [ -x /var/cups-serverbin/lib/cups/backend/usb ]; then
|
||||
URI="$(/var/cups-serverbin/lib/cups/backend/usb 2>/dev/null | awk '$1=="direct" && $2 ~ /^usb:\/\// {print $2; exit}')"
|
||||
fi
|
||||
[ -n "$URI" ] || return 1
|
||||
|
||||
model_from_uri() {
|
||||
# usb://Brother/HL-L5000D%20series?serial=... -> HL-L5000D series
|
||||
printf '%s' "$1" | sed -n 's@^usb://Brother/\([^?]*\).*$@\1@p' | sed 's/%20/ /g'
|
||||
}
|
||||
MODEL_NAME="$(model_from_uri "$URI")"
|
||||
find_brlaser_ppd() {
|
||||
model="$1"
|
||||
model_dir="/var/cups-data/model"
|
||||
[ -d "$model_dir" ] || return 0
|
||||
|
||||
if [ -n "$model" ]; then
|
||||
# Fast path: direct filename match.
|
||||
ppd="$(find "$model_dir" -type f \( -iname "*${model}*.ppd" -o -iname "*${model}*.ppd.gz" \) | head -n 1)"
|
||||
[ -n "$ppd" ] && { echo "$ppd"; return 0; }
|
||||
|
||||
# ppdc-generated brlaser files are often named like brl5000d.ppd, so also match content.
|
||||
ppd="$(grep -RilsF "ModelName: \"Brother $model\"" "$model_dir" 2>/dev/null | head -n 1)"
|
||||
[ -n "$ppd" ] && { echo "$ppd"; return 0; }
|
||||
|
||||
ppd="$(grep -RilsF "MDL:$model;" "$model_dir" 2>/dev/null | head -n 1)"
|
||||
[ -n "$ppd" ] && { echo "$ppd"; return 0; }
|
||||
fi
|
||||
|
||||
find "$model_dir" -type f \
|
||||
\( -iname 'brl*.ppd' -o -iname 'brl*.ppd.gz' -o -iname '*Brother*HL-*.ppd' -o -iname '*Brother*HL-*.ppd.gz' \) \
|
||||
| head -n 1
|
||||
}
|
||||
run_ppdc_brlaser() {
|
||||
out_dir="$1"
|
||||
drv_file="$2"
|
||||
[ -n "$out_dir" ] && [ -n "$drv_file" ] || return 1
|
||||
set -- /bin/ppdc -d "$out_dir"
|
||||
for inc in /var/cups-data/ppdc /var/cups-data/drv; do
|
||||
[ -d "$inc" ] || continue
|
||||
set -- "$@" -I "$inc"
|
||||
done
|
||||
CUPS_BIN_ROOT="$(readlink /bin/cupsd 2>/dev/null | sed 's#/bin/cupsd$##')"
|
||||
if [ -n "$CUPS_BIN_ROOT" ] && [ -d "$CUPS_BIN_ROOT/share/cups/ppdc" ]; then
|
||||
set -- "$@" -I "$CUPS_BIN_ROOT/share/cups/ppdc"
|
||||
elif [ -n "$CUPS_BIN_ROOT" ] && [ -d "${CUPS_BIN_ROOT}-lib/share/cups/ppdc" ]; then
|
||||
set -- "$@" -I "${CUPS_BIN_ROOT}-lib/share/cups/ppdc"
|
||||
fi
|
||||
set -- "$@" "$drv_file"
|
||||
/bin/timeout 60 "$@" >/tmp/ppdc-hbp.out 2>/tmp/ppdc-hbp.err
|
||||
}
|
||||
|
||||
# Prefer PPD path to avoid cups-driverd/dvr:/// dependency.
|
||||
PPD=""
|
||||
if [ -d /var/cups-data/model ]; then
|
||||
PPD="$(find_brlaser_ppd "$MODEL_NAME")"
|
||||
fi
|
||||
if [ -z "$PPD" ] && [ -x /bin/ppdc ] && [ -f /var/cups-data/drv/brlaser.drv ]; then
|
||||
mkdir -p /var/cups-data/model
|
||||
run_ppdc_brlaser /var/cups-data/model /var/cups-data/drv/brlaser.drv || true
|
||||
PPD="$(find_brlaser_ppd "$MODEL_NAME")"
|
||||
fi
|
||||
|
||||
echo "print-hbp-pdf: queue='$QUEUE' model='$MODEL_NAME' uri='$URI' ppd='${PPD:-<none>}' dpi='$DPI' effective_dpi='$EFFECTIVE_DPI'" >&2
|
||||
lpadmin -h "$SOCK" -x "$QUEUE" >/dev/null 2>&1 || true
|
||||
if [ -n "$PPD" ]; then
|
||||
if ! lpadmin -h "$SOCK" -p "$QUEUE" -E -v "$URI" -P "$PPD" >/tmp/lpadmin-hbp.out 2>/tmp/lpadmin-hbp.err; then
|
||||
echo "lpadmin failed while creating '$QUEUE' (see /tmp/lpadmin-hbp.err)" >&2
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
echo "no usable PPD for '$QUEUE' (ppdc may have failed; see /tmp/ppdc-hbp.err)" >&2
|
||||
return 1
|
||||
fi
|
||||
if ! lpstat -h "$SOCK" -p "$QUEUE" >/dev/null 2>&1; then
|
||||
echo "queue '$QUEUE' still missing after lpadmin (see /tmp/lpadmin-hbp.err)" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_queue_ready() {
|
||||
lpadmin -h "$SOCK" -p "$QUEUE" -E >/dev/null 2>&1 || true
|
||||
if command -v cupsenable >/dev/null 2>&1; then
|
||||
cupsenable -h "$SOCK" "$QUEUE" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v cupsaccept >/dev/null 2>&1; then
|
||||
cupsaccept -h "$SOCK" "$QUEUE" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
RAS="/tmp/print-hbp.ras"
|
||||
gs -q -dSAFER -dBATCH -dNOPAUSE -sDEVICE=cups -sOutputFile="$RAS" -r"$EFFECTIVE_DPI" -dDEVICEWIDTHPOINTS=595 -dDEVICEHEIGHTPOINTS=842 -dFIXEDMEDIA -dPDFFitPage "$PDF"
|
||||
|
||||
if [ "$OUTPUT_MODE" = "gadget" ]; then
|
||||
if [ ! -s "$RAS" ]; then
|
||||
echo "print-hbp-pdf: generated raster is empty" >&2
|
||||
exit 6
|
||||
fi
|
||||
echo "print-hbp-pdf: writing CUPS raster to gadget device ${GADGET_DEV}" >&2
|
||||
if ! /bin/timeout "$GADGET_WRITE_TIMEOUT_SEC" sh -c "cat \"$RAS\" > \"$GADGET_DEV\""; then
|
||||
echo "print-hbp-pdf: gadget write timed out after ${GADGET_WRITE_TIMEOUT_SEC}s (is host capture active?)" >&2
|
||||
exit 7
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! ensure_runtime_tools; then
|
||||
echo "print-hbp-pdf: CUPS tools unavailable (likely /nix not mounted). Run 'cups-runtime-ram-feasibility stage core' before SD removal." >&2
|
||||
exit 4
|
||||
fi
|
||||
if ! ensure_cupsd_running; then
|
||||
echo "print-hbp-pdf: cupsd socket '$SOCK' not available" >&2
|
||||
exit 4
|
||||
fi
|
||||
if ! ensure_hbp_queue; then
|
||||
echo "queue '$QUEUE' not found on $SOCK and auto-create failed" >&2
|
||||
exit 3
|
||||
fi
|
||||
ensure_queue_ready
|
||||
|
||||
# SeedEtcher expects one immediate physical print, not backlog replay.
|
||||
# Clear any stale pending jobs on this queue before submitting the new one.
|
||||
if command -v cancel >/dev/null 2>&1; then
|
||||
cancel -h "$SOCK" -a "$QUEUE" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
lp -h "$SOCK" -d "$QUEUE" \
|
||||
-o media=A4 \
|
||||
-o Resolution="${EFFECTIVE_DPI}dpi" \
|
||||
-o document-format=application/vnd.cups-raster \
|
||||
"$RAS"
|
||||
94
scripts/debug/export-logs-to-sd
Normal file
94
scripts/debug/export-logs-to-sd
Normal file
@ -0,0 +1,94 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
BOOT_DEV="${SE_LOG_EXPORT_BOOT_DEV:-/dev/mmcblk0p1}"
|
||||
MNT="${SE_LOG_EXPORT_MNT:-/mnt}"
|
||||
OUT_DIR_REL="${SE_LOG_EXPORT_DIR:-}"
|
||||
REASON="${1:-manual}"
|
||||
TS="$(date +%Y%m%d-%H%M%S 2>/dev/null || echo unknown)"
|
||||
OUT_BASE="${SE_LOG_EXPORT_BASENAME:-SE-LOGS}"
|
||||
HOST="$(uname -n 2>/dev/null || echo seedetcher)"
|
||||
|
||||
need_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || {
|
||||
echo "missing required command: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
need_cmd mkdir
|
||||
need_cmd cp
|
||||
|
||||
if [ ! -b "$BOOT_DEV" ]; then
|
||||
echo "boot device not found: $BOOT_DEV" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mounted_here=0
|
||||
mkdir -p "$MNT"
|
||||
# Force /mnt to be the boot partition mount for deterministic behavior.
|
||||
if awk -v m="$MNT" '$2==m{found=1} END{exit found?0:1}' /proc/mounts; then
|
||||
umount "$MNT" >/dev/null 2>&1 || true
|
||||
fi
|
||||
mount -t vfat "$BOOT_DEV" "$MNT"
|
||||
mounted_here=1
|
||||
|
||||
cleanup() {
|
||||
if [ "$mounted_here" = "1" ]; then
|
||||
umount "$MNT" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
if [ -n "$OUT_DIR_REL" ]; then
|
||||
OUT_DIR="$MNT/$OUT_DIR_REL"
|
||||
else
|
||||
OUT_DIR="$MNT"
|
||||
fi
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
WORK="/tmp/log-export-${OUT_BASE}"
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK"
|
||||
|
||||
mkdir -p "$WORK/log" "$WORK/var-log-cups"
|
||||
|
||||
# Privacy-first allowlist: avoid exporting broad debug/tmp/proc data.
|
||||
cp /log/init_debug.log "$WORK/log/init_debug.log" 2>/dev/null || true
|
||||
cp /log/cups.log "$WORK/log/cups.log" 2>/dev/null || true
|
||||
cp /var/log/cups/error_log "$WORK/var-log-cups/error_log" 2>/dev/null || true
|
||||
cp /var/log/cups/access_log "$WORK/var-log-cups/access_log" 2>/dev/null || true
|
||||
|
||||
if command -v dmesg >/dev/null 2>&1; then
|
||||
dmesg > "$WORK/dmesg.txt" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
case "$REASON" in
|
||||
boot*) ;;
|
||||
*)
|
||||
if [ -x /bin/pjl-snapshot ]; then
|
||||
/bin/pjl-snapshot "$WORK" >/dev/null 2>&1 || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
{
|
||||
echo "timestamp=${TS}"
|
||||
echo "reason=${REASON}"
|
||||
echo "host=${HOST}"
|
||||
echo "boot_dev=${BOOT_DEV}"
|
||||
echo "uname=$(uname -a 2>/dev/null || true)"
|
||||
} > "$WORK/manifest.txt"
|
||||
|
||||
DEST_DIR="$OUT_DIR/${OUT_BASE}-LATEST"
|
||||
rm -rf "$DEST_DIR"
|
||||
mkdir -p "$DEST_DIR"
|
||||
cp -r "$WORK"/. "$DEST_DIR"/
|
||||
|
||||
{
|
||||
echo "timestamp=${TS}"
|
||||
} > "$DEST_DIR/export.timestamp"
|
||||
|
||||
rm -rf "$WORK"
|
||||
sync
|
||||
echo "exported logs: $DEST_DIR"
|
||||
46
scripts/debug/pjl-snapshot
Normal file
46
scripts/debug/pjl-snapshot
Normal file
@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
OUT_DIR="${1:-}"
|
||||
DEV="${PJL_DEV:-/dev/usb/lp0}"
|
||||
READ_TIMEOUT="${PJL_READ_TIMEOUT:-2}"
|
||||
|
||||
if [ -z "$OUT_DIR" ]; then
|
||||
echo "usage: pjl-snapshot /path/to/outdir" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
if [ ! -c "$DEV" ]; then
|
||||
echo "device not found: $DEV" > "$OUT_DIR/pjl.error"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! exec 3<>"$DEV"; then
|
||||
echo "device busy/unavailable: $DEV" > "$OUT_DIR/pjl.error"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
out="$OUT_DIR/pjl_variables.txt"
|
||||
err="$OUT_DIR/pjl_variables.err"
|
||||
printf '\033%%-12345X@PJL INFO VARIABLES\r\n\033%%-12345X' >&3 || true
|
||||
sleep 1
|
||||
timeout "$READ_TIMEOUT" cat <&3 > "$out" 2> "$err" || true
|
||||
|
||||
# Derive clean RESOLUTION block from INFO VARIABLES payload.
|
||||
awk '
|
||||
/^RESOLUTION=/ {capture=1}
|
||||
capture {print}
|
||||
capture && /^[^[:space:]]/ && $0 !~ /^RESOLUTION=/ {exit}
|
||||
' "$out" | sed '$d' > "$OUT_DIR/pjl_resolution.txt" 2>/dev/null || true
|
||||
|
||||
# Keep a compact one-line summary for quick triage.
|
||||
awk '
|
||||
/^RESOLUTION=/ {print; exit}
|
||||
' "$out" > "$OUT_DIR/pjl_summary.txt" 2>/dev/null || true
|
||||
|
||||
exec 3>&-
|
||||
exec 3<&-
|
||||
|
||||
exit 0
|
||||
84
scripts/debug/sd-removal-dump
Normal file
84
scripts/debug/sd-removal-dump
Normal file
@ -0,0 +1,84 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
MODE="${1:-snapshot}"
|
||||
OUT="${2:-/tmp/sd-removal-dump.log}"
|
||||
|
||||
now() {
|
||||
date 2>/dev/null || echo "no-date"
|
||||
}
|
||||
|
||||
header() {
|
||||
echo
|
||||
echo "=== $1 ==="
|
||||
echo "time=$(now)"
|
||||
cat /proc/uptime 2>/dev/null | awk '{print "uptime_s=" $1}' || true
|
||||
}
|
||||
|
||||
print_mounts() {
|
||||
echo "--- mounts ---"
|
||||
grep -E 'mmcblk0p| /nix ' /proc/mounts || true
|
||||
}
|
||||
|
||||
print_ps() {
|
||||
echo "--- ps ---"
|
||||
ps || true
|
||||
}
|
||||
|
||||
print_fuser() {
|
||||
echo "--- fuser ---"
|
||||
if command -v fuser >/dev/null 2>&1; then
|
||||
fuser -vm /nix /dev/mmcblk0 /dev/mmcblk0p1 /dev/mmcblk0p2 2>&1 || true
|
||||
else
|
||||
echo "fuser: not installed"
|
||||
fi
|
||||
}
|
||||
|
||||
print_proc_holders() {
|
||||
echo "--- proc holders (/nix, /dev/mmcblk0*) ---"
|
||||
for p in /proc/[0-9]*; do
|
||||
[ -d "$p" ] || continue
|
||||
pid="${p#/proc/}"
|
||||
cmd="$(tr '\000' ' ' < "$p/cmdline" 2>/dev/null || true)"
|
||||
[ -n "$cmd" ] || cmd="[kernel]"
|
||||
|
||||
for k in root cwd exe; do
|
||||
t="$(readlink "$p/$k" 2>/dev/null || true)"
|
||||
case "$t" in
|
||||
/nix*|/dev/mmcblk0*|/run/hbp-ram-runtime/*)
|
||||
echo "pid=$pid $k=$t cmd=$cmd"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
for fd in "$p"/fd/*; do
|
||||
[ -e "$fd" ] || continue
|
||||
t="$(readlink "$fd" 2>/dev/null || true)"
|
||||
case "$t" in
|
||||
/nix*|/dev/mmcblk0*|/run/hbp-ram-runtime/*)
|
||||
echo "pid=$pid fd=$(basename "$fd")=$t cmd=$cmd"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
print_logs() {
|
||||
echo "--- dmesg tail ---"
|
||||
dmesg | tail -n 220 || true
|
||||
echo "--- /log/debug.log tail ---"
|
||||
tail -n 260 /log/debug.log 2>/dev/null || true
|
||||
echo "--- /log/cups.log tail ---"
|
||||
tail -n 260 /log/cups.log 2>/dev/null || true
|
||||
}
|
||||
|
||||
{
|
||||
header "$MODE"
|
||||
print_mounts
|
||||
print_ps
|
||||
print_fuser
|
||||
print_proc_holders
|
||||
print_logs
|
||||
} >> "$OUT" 2>&1
|
||||
|
||||
echo "$OUT"
|
||||
25
scripts/export-workflow-pdf.sh
Executable file
25
scripts/export-workflow-pdf.sh
Executable file
@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
version="${1:-}"
|
||||
if [ -z "$version" ]; then
|
||||
version="$(sed -nE 's/^const Tag = "([^"]+)".*/\1/p' "$ROOT_DIR/version/version.go" | head -n1 || true)"
|
||||
fi
|
||||
if [ -z "$version" ]; then
|
||||
version="dev"
|
||||
fi
|
||||
|
||||
output_path="${2:-$ROOT_DIR/release/SeedEtcher-Workflow-${version}.pdf}"
|
||||
mkdir -p "$(dirname "$output_path")"
|
||||
|
||||
ROOT_DIR="$ROOT_DIR" OUTPUT_PATH="$output_path" nix shell nixpkgs#pandoc nixpkgs#python3Packages.weasyprint -c bash -lc '
|
||||
cd "$ROOT_DIR/docs"
|
||||
pandoc SeedEtcher-Workflow.md -f gfm \
|
||||
-o "$OUTPUT_PATH" \
|
||||
--pdf-engine=weasyprint \
|
||||
--css="$ROOT_DIR/docs/assets/workflow/workflow-pdf.css"
|
||||
'
|
||||
|
||||
echo "Generated: $output_path"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user