Compare commits

...

213 Commits

Author SHA1 Message Date
cmyk
f2c37095ef docs(b0.4): add scene debug outputs and file-first laser workflow 2026-03-12 08:43:19 +01:00
cmyk
6f9be6a146 docs(b0.4): replace legacy recovery checklist with laser GRBL integration plan 2026-03-12 08:38:04 +01:00
cmyk
f0ea6211d1 Added Trademark 2026-03-12 08:32:04 +01:00
cmyk
1a9f078829 fix(fountain): harden seq length handling and validate decoded headers 2026-03-04 18:45:34 +01:00
cmyk
feccdc668c docs: fixed numbering 2026-03-04 16:37:38 +01:00
cmyk
f8c1ccc756 docs: fixed some typos and wording 2026-03-04 16:34:37 +01:00
cmyk
7a57d3ecc6 Merge release/0.3.0-beta into main 2026-03-04 16:10:26 +01:00
cmyk
9990a742d7 docs: update workflow and add PDF export tooling 2026-03-04 15:21:47 +01:00
₿ainter
5485ddc3d9
Merge pull request #38 from cmyk/docs/update-for-b0.3
docs: refresh b0.3 docs and add Apache-2.0 licensing/compliance files
2026-03-04 14:03:48 +01:00
cmyk
406efb49f9 docs: corrected silicone sheet size to 110x110mm to allow overlap. 2026-03-04 12:02:05 +01:00
cmyk
583b5c1566 docs: linked youtube channel instead of specific video 2026-03-04 11:19:45 +01:00
cmyk
c29b417f0d docs: missed oven setup 2026-03-04 01:28:55 +01:00
cmyk
95f23da17f doc: formatting 2026-03-04 00:29:12 +01:00
cmyk
627d17cd91 doc: fixed empty line 2026-03-04 00:24:49 +01:00
cmyk
71cc1ca4d5 doc: 2026-03-04 00:23:26 +01:00
cmyk
2dd3548cac fix: transparent background 2026-03-04 00:21:09 +01:00
cmyk
d8058ce02d docs: expand transfer workflow visuals and passivation guidance 2026-03-04 00:12:17 +01:00
cmyk
35871a9a53 docs: make local machine notes contributor-agnostic 2026-03-03 21:47:15 +01:00
cmyk
2d3566d8ff docs(release): add release checklist and refresh fixture list 2026-03-03 21:31:08 +01:00
cmyk
23e0a96257 docs(compliance): add third-party license tracker and release-note template 2026-03-03 21:26:22 +01:00
cmyk
3d42f6e895 license: migrate to Apache-2.0 and add SeedEtcher font OFL metadata 2026-03-03 21:02:35 +01:00
cmyk
f49ccf0ef2 docs: update release notes and contributor docs 2026-03-03 20:37:50 +01:00
₿ainter
8f1db2a6b6
Merge pull request #37 from cmyk/fix/release-tooling
Release Tooling: fix mkRelease flow, version stamping, and output path
2026-03-03 20:28:57 +01:00
cmyk
f7b7c549dc release tooling: streamline mkRelease and stamped image output 2026-03-03 20:26:27 +01:00
₿ainter
40df517c0e
Merge pull request #36 from cmyk/feature/transfer-cutbox-layout
Transfer Cutbox Layout + Host PCL/PS Alignment + 2x2 Progress/Batch Fixes
2026-03-03 19:10:21 +01:00
cmyk
391528daa5 printer: align direct PCL/PS output with host-mode cutbox geometry 2026-03-03 14:53:39 +01:00
cmyk
e3cae9b541 Fix 2x2 progress math and add nested script fixtures 2026-03-03 14:31:43 +01:00
cmyk
4de129d347 printer: add transfer cutbox layout and thickness-aware etch stats 2026-03-02 20:36:47 +01:00
cmyk
3f4a908684 started upading docs 2026-03-02 18:46:17 +01:00
₿ainter
f64fd372d2
Merge pull request #34 from cmyk/fix/seedplate-layout-tweak
Increased size of seedQR for normal layouts to 32mm
2026-02-26 19:00:34 +01:00
cmyk
0f0419988c increased size of seedQR for normal layouts to 32mm 2026-02-26 18:59:29 +01:00
₿ainter
c53811a6c1
Merge pull request #33 from cmyk/integration/release-0.3-cups
Integrate on-demand Brother HBP runtime for release 0.3 (print pipeline hardening, progress/UI fixes, diagnostics, cleanup)
2026-02-26 18:27:43 +01:00
cmyk
2698309458 Updated printers.md 2026-02-26 18:26:12 +01:00
cmyk
bdade9f1a2 chore(go): tidy module deps after printer cleanup 2026-02-26 18:02:24 +01:00
cmyk
c82d66003d refactor(printer): remove legacy vector PDF path from printer.go 2026-02-26 18:00:43 +01:00
cmyk
5e62dffc49 fix(capture): handle multi-batch HBP raster streams and avoid gadget write hangs 2026-02-26 17:18:25 +01:00
cmyk
f0de9e3c55 docs(changelog): summarize HBP runtime integration and related tooling 2026-02-26 16:43:54 +01:00
cmyk
3daad2e861 docs(hbp): replace spike feasibility notes with runtime implementation guide 2026-02-26 16:34:23 +01:00
cmyk
9400599ba6 refactor(cups): finish runtime queue symbol rename 2026-02-26 16:26:13 +01:00
cmyk
ed85e4a17a refactor(cups): rename spike runtime paths to cups-runtime 2026-02-26 16:24:14 +01:00
cmyk
374d7b0d22 style: normalize non-UI dummy platform error strings 2026-02-26 15:43:25 +01:00
cmyk
003789e263 refactor: trim dead shard-set path and fix print preview loop control 2026-02-26 15:24:58 +01:00
cmyk
af192f03ec docs(cups): update printer list source note and cleanup GC hint 2026-02-26 15:15:32 +01:00
cmyk
20e4271cd8 chore(cups): restore nix image cleanup script and printer list docs 2026-02-26 15:04:02 +01:00
cmyk
e296c5f5f9 chore(dev): add go bloat analysis script and ignore tmp reports 2026-02-26 14:58:21 +01:00
cmyk
c633932d04 refactor(print): consolidate progress plumbing and clean gui warnings 2026-02-26 14:45:24 +01:00
cmyk
f7c4eafafc fix(print): stabilize progress page across PCL/PS/HBP 2026-02-26 14:14:25 +01:00
cmyk
22fc0f8fe6 refactor(print): remove dead helpers and trim host batch plumbing 2026-02-26 12:01:21 +01:00
cmyk
5a0719a73e refactor(print): unify PCL/PS host batch driver and drop dead HBP legacy path 2026-02-26 11:44:18 +01:00
cmyk
97a7e1921a fix(hbp): batch stats path to avoid legacy full-memory OOM 2026-02-26 11:36:12 +01:00
cmyk
bd097d1d23 refactor(print): share host batching and stats helper utilities 2026-02-26 11:06:25 +01:00
cmyk
c9940be0de refactor(print): share PCL/PS batch plate rendering path 2026-02-26 11:05:24 +01:00
cmyk
c930658ce9 refactor(print): share host render-plan setup across PCL and PS 2026-02-26 11:03:02 +01:00
cmyk
07606ae891 test(printer): add wallet-data and backend parity unit coverage 2026-02-26 10:54:56 +01:00
cmyk
78b6e22f8a feat(print): batch PostScript rendering/sending and remove legacy 1200 cap 2026-02-26 10:36:57 +01:00
cmyk
f046a97d28 fix(print): compute etch stats incrementally in batched PCL path 2026-02-26 10:03:55 +01:00
cmyk
8878623878 fix(gui): clarify HBP gate copy and left-align lead text on startup choice 2026-02-25 22:36:08 +01:00
cmyk
9fcd360a8c fix(gui): lock print flow to HBP@600 when HBP runtime is enabled 2026-02-25 22:21:22 +01:00
cmyk
ffb92a403d fix(build): remove hard brlaser drop-in artifact dependency from cups-spike image 2026-02-25 21:50:28 +01:00
cmyk
89f75b27a2
feat(debug): support HBP gadget capture and auto raster-to-pdf conversion 2026-02-25 21:03:17 +01:00
cmyk
25ea1ea67e
fix(print): restore gadget capture by removing host-only printer precheck 2026-02-25 21:03:16 +01:00
cmyk
720e2723c2
build(flake): add gadget cups-spike image outputs 2026-02-25 21:03:16 +01:00
cmyk
1e89644f76
fix(controller): restore split-file imports for arm build 2026-02-25 21:03:16 +01:00
cmyk
3ee756ede2
refactor(controller): split platform_rpi into print/sd/notify modules 2026-02-25 21:03:16 +01:00
cmyk
9f9606922a
fix(debug): skip PJL on boot exports using reason-based export 2026-02-25 21:03:15 +01:00
cmyk
6e81ba0214
fix(debug): make PJL snapshot deterministic and parse resolution cleanly 2026-02-25 21:03:15 +01:00
cmyk
ddc85c6f21
feat(debug): trigger rate-limited SD log export on UI errors 2026-02-25 21:03:15 +01:00
cmyk
ec5b4d849c
feat(debug): capture best-effort PJL snapshot in SD log export 2026-02-25 21:03:14 +01:00
cmyk
40404e3c0d
fix(gui): keep HBP preparing status in body area 2026-02-25 21:03:14 +01:00
cmyk
4323df0d22
fix(gui): show HBP ready message in body area, not bottom status line 2026-02-25 21:03:14 +01:00
cmyk
677858e8c6
fix(gui): avoid SD prep screen flash in PCL/PS-only startup path 2026-02-25 21:03:13 +01:00
cmyk
4773bcf7b8
fix(debug): make SD log export privacy-first allowlist 2026-02-25 21:03:13 +01:00
cmyk
6b549ef228
fix(debug): restore HOST var in log export manifest 2026-02-25 21:03:13 +01:00
cmyk
870dc33f5a
fix(debug): force mmc boot mount at /mnt before log export 2026-02-25 21:03:13 +01:00
cmyk
3c72507366
fix(gui): match progress status baseline to choice screen lead 2026-02-25 21:03:12 +01:00
cmyk
f6dc4556e2
fix(debug): use screenshot-style SD mount path for log export 2026-02-25 21:03:12 +01:00
cmyk
ba870ea8e7
fix(gui): lower print status lines to match expected baseline 2026-02-25 21:03:12 +01:00
cmyk
4d7531b1c1
fix(gui): align HBP prepare screen with print progress layout 2026-02-25 21:03:11 +01:00
cmyk
e2a9a37dd3
fix(debug): make SD export path deterministic and mount robust 2026-02-25 21:03:11 +01:00
cmyk
f9e2aa23e3
fix(gui): align print progress status line with standard lead position 2026-02-25 21:03:11 +01:00
cmyk
2a3e37501a
fix(debug): attempt log export immediately at boot before retries 2026-02-25 21:03:10 +01:00
cmyk
9f4122cba0
fix(debug): retry boot log export and use SE-LOGS root folder 2026-02-25 21:03:10 +01:00
cmyk
bf284e4fe5
fix(debug): auto-export logs after boot and on controller exit 2026-02-25 21:03:10 +01:00
cmyk
e0b4e3040c
chore(gui): simplify send-stage progress label 2026-02-25 21:03:10 +01:00
cmyk
a973433061
fix(debug): auto-export logs on controller exit to boot root 2026-02-25 21:03:09 +01:00
cmyk
d8f4d08385
chore(debug): add debug-only export-logs-to-sd helper 2026-02-25 21:03:09 +01:00
cmyk
10a27116d5
chore(controller): remove dead platform_rpi code 2026-02-25 21:03:09 +01:00
cmyk
efc0b6d293
fix(print): auto-fallback host PCL 1200->600 on lp0 EIO 2026-02-25 21:03:08 +01:00
cmyk
398706c4d6
fix(init): restore minimal shell tty setup for UART reliability 2026-02-25 21:03:08 +01:00
cmyk
c47436ec46
fix(init): keep shell UART echo enabled 2026-02-25 21:03:08 +01:00
cmyk
d8682612f3
chore(cups): remove eager boot path and default to quiet serial logging 2026-02-25 21:03:08 +01:00
cmyk
c3d9ef2c01
fix(cups): make ram-feasibility stage idempotent on RAM-backed nix 2026-02-25 21:03:07 +01:00
cmyk
887514ff6e
chore(init): remove duplicate embedded cups-spike-ram-feasibility helper 2026-02-25 21:03:07 +01:00
cmyk
88603f78fb chore(init): remove duplicate embedded print-hbp-pdf helper 2026-02-25 21:03:07 +01:00
cmyk
cc67a97e54
fix(gui): improve HBP prep progress bar and remove on success 2026-02-25 21:03:03 +01:00
cmyk
bceae91abd
fix(gui): correct HBP progress stage reporting and labels 2026-02-25 21:03:03 +01:00
cmyk
c64cba47f5 fix(hbp): enforce 600dpi and use plate-pdf path to reduce memory 2026-02-25 21:03:03 +01:00
cmyk
fe2457842f feat(hbp): batch hbp rendering path with guarded 1200 test flag 2026-02-25 21:02:58 +01:00
cmyk
50b82dbfee fix(hbp): force 600dpi path and align lazy bootstrap/runtime behavior 2026-02-25 21:02:53 +01:00
cmyk
f51848f7c7
fix(hbp): restore 1200 DPI for one-page jobs 2026-02-25 21:02:48 +01:00
cmyk
83f8ed51e3
fix(hbp): force 600 DPI for reliable print output 2026-02-25 21:02:48 +01:00
cmyk
f92f4d6ac6
chore(debug): add sd-removal-dump tool to initramfs for UART capture 2026-02-25 21:02:47 +01:00
cmyk
2ae4d592c1
fix(print): reopen usblp device per job to avoid stale fd after reconnect 2026-02-25 21:02:47 +01:00
cmyk
5f9673e7ba
fix(hbp): make CUPS bootstrap explicit in HBP prep only 2026-02-25 21:02:47 +01:00
cmyk
435350ad88 refactor(hbp): switch cups spike to lazy on-demand bootstrap 2026-02-25 21:02:47 +01:00
cmyk
3de8b7d9c3
fix(sd): force-detach mmc mounts and skip RAM /nix restore in PCL/PS prep 2026-02-25 21:02:35 +01:00
cmyk
104d9a1e74
fix(sd): run SD detach prep in PCL/PS flow before removal prompt 2026-02-25 21:02:34 +01:00
cmyk
b6ea3183d1
fix(init): disable CUPS queue retry loop by default for SD-safe PCL/PS mode 2026-02-25 21:02:34 +01:00
cmyk
6847eef18d
fix(print): stream PS from plates and stabilize progress stage order 2026-02-25 21:02:27 +01:00
cmyk
e60a09286f fix(print): only apply PS 1200 fallback when HBP runtime is enabled 2026-02-25 21:02:27 +01:00
cmyk
87f634d7ed fix(print): guard low-memory jobs and harden HBP runtime path 2026-02-25 21:01:55 +01:00
cmyk
0a0ce37787 feat(hbp): startup prep gate, stable UI, and robust SD detach 2026-02-25 21:01:27 +01:00
₿ainter
5b7e70ccbf
Update SeedEtcher-Workflow.md 2026-02-23 15:33:43 +01:00
₿ainter
833548224d
Merge pull request #27 from cmyk/chore/b0.3-docs-flash-spike-notes
docs/spike + scripts: record CUPS-musl findings and speed up macOS SD flashing
2026-02-23 15:24:27 +01:00
cmyk
7deb23a298
scripts(flash): use /dev/rdiskX for faster macOS flashing 2026-02-23 15:17:38 +01:00
cmyk
9909eedba3
docs(spike): record CUPS musl findings and current status 2026-02-23 15:17:16 +01:00
₿ainter
63cb9fd908
Update SeedEtcher-Workflow.md 2026-02-23 01:38:00 +01:00
₿ainter
529db96c7f
Merge pull request #26 from cmyk/feature/b0.3-printer-language-select
feat(print): add printer language selector (PCL/PS) and native host-mode PostScript pipeline
2026-02-23 01:08:03 +01:00
cmyk
e37faa2f5a
docs(b0.3): update changelog and checklist for PCL/PS host printing 2026-02-23 01:04:15 +01:00
cmyk
c5240f14ac
feat(print): add PS mode selector and optimize host PostScript pipeline 2026-02-23 00:33:32 +01:00
cmyk
329e17a49d
fix(print): remove external PDF->PS dependency in host PS mode 2026-02-22 23:05:38 +01:00
cmyk
891ec42ced
feat(print): add PCL/PS language selector with host-mode PS path 2026-02-22 20:48:45 +01:00
cmyk
c52c0086d8
feat(printer): add configurable rounded QR pattern styling 2026-02-21 20:30:40 +01:00
₿ainter
dd3ffb10f9
Merge pull request #25 from cmyk/feature/b0.3-ur-xor-family-support
feat(b0.3): UR/XOR family support, SE1 fallback removal, and raster renderer refactor
2026-02-21 19:18:26 +01:00
cmyk
c808e19956
docs(checklist): mark UR/XOR vectors and 3of5 combo tests complete 2026-02-21 19:14:21 +01:00
cmyk
da4753a20c
test(urxor): add golden payload vectors and full 3of5 recovery combinations 2026-02-21 19:11:43 +01:00
cmyk
d69b9406b1
docs: consolidate UR/XOR share spec and align b0.3/b0.4 docs 2026-02-21 19:06:39 +01:00
cmyk
053e5d9879
test(printer): add table-driven descriptor share matrix for scripts and networks 2026-02-21 18:21:39 +01:00
cmyk
ae9a4c214e
testutils: add multisig-4of7 and multisig-5of7 wallet fixtures 2026-02-21 18:15:34 +01:00
cmyk
75fda99e3c
testutils: add multisig-2of2 and multisig-3of4 wallet fixtures 2026-02-21 18:03:18 +01:00
cmyk
4d107c4a09
feat(backup): remove SE1 fallback and warn on unsupported UR/XOR schemes 2026-02-21 17:44:30 +01:00
cmyk
b7c25efaad
refactor(printer): split raster helper clusters into focused files 2026-02-21 17:17:12 +01:00
cmyk
87c5352553
refactor(printer): extract seed plate renderer and seed layout specs 2026-02-21 16:56:10 +01:00
cmyk
2c291ec82a
refactor(tags): use canonical urtypes tag helpers in printer metadata 2026-02-21 16:49:26 +01:00
cmyk
7c5cf0c29b
refactor(printer): split descriptor renderer and add layout geometry tests 2026-02-21 16:44:29 +01:00
cmyk
91ae12aea8
refactor(printer): extract descriptor share payload logic and layout specs 2026-02-21 16:34:31 +01:00
cmyk
1023356ac0
refactor(printer): add text layout API and migrate descriptor/compact blocks 2026-02-21 16:22:18 +01:00
cmyk
493f953870
Removed AGENTS.md 2026-02-21 15:48:10 +01:00
cmyk
f5c160e880
style(layout): adjust single-QR seed plate vertical anchor 2026-02-21 14:57:24 +01:00
cmyk
c6c1bfc145
test(fixtures): make singlesig fixture single-copy by default 2026-02-21 14:48:42 +01:00
cmyk
049068add9
test(fixtures): add multisig 2-of-4 wallet fixture for CLI testing 2026-02-21 14:38:12 +01:00
cmyk
596b8e4045
feat(printer): support dual-QR descriptor shares for UR/XOR families 2026-02-21 14:31:33 +01:00
cmyk
c8dc371571
feat(ur): generalize SeedHammer-style UR/XOR split and recovery paths 2026-02-21 12:06:50 +01:00
cmyk
1fd3fdee90
docs(gui): remove recovery screen copy details 2026-02-21 10:46:12 +01:00
cmyk
d2e21b0451
docs(gui): align flowchart and notes with current backup/print UX 2026-02-21 10:43:16 +01:00
cmyk
8c995e74c5
fix(gui): restore scan progress and singlesig label back path 2026-02-21 10:40:42 +01:00
cmyk
248c213fd2
fix(gui): stabilize print setup backflow and singlesig review path 2026-02-21 10:27:11 +01:00
cmyk
63e5615662
feat(gui): polish print summary wording and page count display 2026-02-21 09:38:51 +01:00
cmyk
1c02a14e3c
feat(gui): refine singlesig print-layout picker descriptions 2026-02-21 09:04:37 +01:00
cmyk
26a03081b9
fix(host-print): preserve singlesig descriptor metadata on seed plates 2026-02-20 22:47:23 +01:00
cmyk
2f604312a2
docs(checklist): add UR/XOR family-support roadmap for b0.3 2026-02-20 19:50:24 +01:00
₿ainter
e7716baa00
Merge pull request #24 from cmyk/feature/b0.3-ur-xor-2of3
feat(b0.3): default 2-of-3 descriptor shares to UR/XOR + compact single-sided layout
2026-02-20 19:45:21 +01:00
cmyk
f8717260d6
docs(b0.3): align UR/XOR flow docs and rename SE2 spec as experimental 2026-02-20 19:39:55 +01:00
cmyk
6b71fb46da
fix(gui): scope UR/XOR share counter to recovery mode only 2026-02-20 19:15:49 +01:00
cmyk
3c7f2e0ccc
feat(gui): hide WID/SET copy and show UR/XOR share capture count 2026-02-20 18:51:18 +01:00
cmyk
b07faabb91
feat(layout): make descriptor QR placement explicit and finalize compact tuning 2026-02-20 18:02:30 +01:00
cmyk
e9a8aca9ed
refactor(raster): remove unused CW90 text renderer 2026-02-20 17:45:44 +01:00
cmyk
4188f4fa7f
refactor(printer): clean dead raster helpers and normalize derivation paths 2026-02-20 17:38:38 +01:00
cmyk
5c59b33024
chore(font): update SeedEtcher glyph set for compact warning symbols 2026-02-20 17:11:16 +01:00
cmyk
caa81b324c
feat(layout): refine compact 2-of-3 plate typography and QR geometry 2026-02-20 17:10:53 +01:00
cmyk
c088291bbc
feat(layout): remove compact plate WID/SET metadata block 2026-02-20 15:12:23 +01:00
cmyk
11d099b8f6
fix(recovery): scan UR/XOR shares as raw fragments in recover flow 2026-02-20 13:05:15 +01:00
cmyk
85f88b6df3
feat(print): add compact 2-of-3 layout toggle to CLI/controller paths 2026-02-20 12:49:03 +01:00
cmyk
39bc973601
feat(b0.3): add UR/XOR 2of3 descriptor share generation and recovery 2026-02-20 12:39:38 +01:00
cmyk
757499d036
docs(b0.3): define UR/XOR 2of3 migration scope and spec 2026-02-20 12:30:03 +01:00
cmyk
c2965697a9
docs(spec): finalize compact 2-of-3 decisions and QR sizing targets 2026-02-19 13:45:08 +01:00
cmyk
57d8708dd9
docs(spec): add draft compact 2-of-3 descriptor-share proposal 2026-02-19 13:36:14 +01:00
₿ainter
2f7dc12f13
Merge pull request #23 from cmyk/feature/b0.3-etch-stats-page
feat(b0.3): add optional etch stats page with per-plate area coverage and PSU current guidance
2026-02-19 13:06:15 +01:00
cmyk
f292ab6324
docs(b0.3): update changelog, checklist, GUI flow, and CLI flags for etch stats 2026-02-19 12:55:54 +01:00
cmyk
8276f7caeb
chore(etch): set default bath temperature to 34C for DIY heater compatibility 2026-02-19 12:36:23 +01:00
cmyk
be656915c7
feat(etch): add area+percent and PSU guidance tables to stats page 2026-02-19 11:48:45 +01:00
cmyk
63f4b9ea01
docs(checklist): add b0.3 item for optional host-mode etch stats page 2026-02-19 10:26:06 +01:00
cmyk
638efa8073
docs(checklist): check off completed b0.3 layout and QR milestones 2026-02-18 18:53:01 +01:00
cmyk
76713ad468
docs(checklist): add b0.3 QR dot-scale calibration item 2026-02-18 18:51:40 +01:00
cmyk
8de37d873a
docs: record QR dot-scale tuning and etch rationale 2026-02-18 18:50:33 +01:00
cmyk
ab4d0956f9
feat(qr): reduce circular module dot scale to 0.7 for etch headroom 2026-02-18 18:49:17 +01:00
cmyk
c5959a8dd9
docs(changelog): note singlesig two-copy default with 1/1 marker semantics 2026-02-18 18:20:32 +01:00
cmyk
b9d503e8ec
feat(print): default singlesig to two copies while preserving 1/1 key semantics 2026-02-18 18:19:58 +01:00
₿ainter
89ac8de426
Merge pull request #22 from cmyk/feature/b0.3-singlesig-seed-only-layout
feat(layout): singlesig seed-only plates with optional right-edge metadata
2026-02-18 17:44:27 +01:00
cmyk
2cd927037d
docs(changelog): record singlesig seed-only layout and prompt copy update 2026-02-18 17:41:31 +01:00
cmyk
df0074f069
copy(gui): clarify seed scan prompt as QR-only 2026-02-18 17:41:01 +01:00
cmyk
d1387aa892
feat(layout): add singlesig seed-only variant with optional right-side metadata 2026-02-18 17:40:28 +01:00
₿ainter
3c493eedcb
Merge pull request #21 from cmyk/feature/b0.3-descriptor-meta-format
feat(layout): descriptor metadata format + larger bottom-anchored descriptor QR
2026-02-18 16:20:08 +01:00
cmyk
49d28a4216
docs(changelog): note descriptor metadata and QR sizing update 2026-02-18 16:16:28 +01:00
cmyk
7521879b4c
feat(layout): simplify descriptor metadata line and enlarge bottom-anchored descriptor QR 2026-02-18 16:06:00 +01:00
₿ainter
dff4edde02
Merge pull request #19 from cmyk/chore/remove-legacy-backupwalletflow
chore(gui): remove legacy backupWalletFlow and clean flow references
2026-02-18 14:33:37 +01:00
cmyk
4a41e001fc
chore(gui): remove legacy backupWalletFlow and stale references 2026-02-18 14:27:24 +01:00
cmyk
0e175ba5b0
docs(gui,changelog): document print options flow and wording clarifications 2026-02-18 14:16:31 +01:00
₿ainter
bc840c0136
Merge pull request #18 from cmyk/feature/b0.3-print-options-ui
feat(print): add print options UI (DPI/invert/mirror) + updated SeedEtcher font glyph
2026-02-18 14:05:13 +01:00
cmyk
e66bde3813
Updated S glyph 2026-02-18 13:29:28 +01:00
cmyk
d482ce6c04
chore(font): update SeedEtcher regular glyph 0 2026-02-18 13:25:25 +01:00
cmyk
97a30b3653
feat(print): add UI print options for DPI, invert, and mirror 2026-02-18 13:24:36 +01:00
cmyk
f1399edbfe
chore(release): prepare v0.3.0-beta.1 2026-02-18 11:42:14 +01:00
₿ainter
a3c6f85f1f
Merge pull request #17 from cmyk/feature/b0.3-1bpp-pcl-pipeline
b0.3 print pipeline + plate layout overhaul (host 1bpp PCL, Letter 2x2, DPI split)
2026-02-18 11:34:52 +01:00
cmyk
dd627130b3
docs(changelog): add latest b0.3 print and layout updates 2026-02-18 11:19:11 +01:00
cmyk
89d713b87f
chore(plates): apply layout tuning, fixtures, and label limit updates 2026-02-18 11:17:51 +01:00
cmyk
c9bf43936e
fix(print): honor selected paper size across host and gadget paths 2026-02-18 10:19:37 +01:00
cmyk
c4aae862e4
docs(changelog): summarize b0.3 print and layout work 2026-02-18 09:56:20 +01:00
cmyk
6abe21df94
refactor(print): export shard and PCL estimate helpers for host batching 2026-02-18 09:53:21 +01:00
cmyk
38b1160d3f
feat(print): use 1200dpi on host PCL and 600dpi on gadget fallback 2026-02-18 09:52:25 +01:00
cmyk
02470bb85f
fix(progress): stabilize host batched print progress totals 2026-02-18 09:47:20 +01:00
cmyk
b10c62ac65
docs(print): clarify host 1bpp PCL path vs gadget PDF fallback 2026-02-18 09:25:34 +01:00
cmyk
7cdf5a9851
feat(print): add direct 1bpp PCL plate streaming path 2026-02-17 19:51:37 +01:00
cmyk
378d51a942
fix(image): include SeedEtcher font in initramfs 2026-02-17 19:41:25 +01:00
cmyk
1b55660e57
feat(layout): use 2x2 on Letter and disable plate scaling 2026-02-17 19:04:38 +01:00
cmyk
1ed5e27dcd
fix(invert): preserve black plate border while inverting interior 2026-02-17 18:50:58 +01:00
cmyk
ce26e5b9ab
feat(layout): increase plate gap to 4mm and top-anchor partial pages 2026-02-17 18:33:22 +01:00
cmyk
951b315206
fix(qr): stabilize island detection for circular plate QR rendering 2026-02-17 18:22:05 +01:00
cmyk
8f1f269133
test(fixtures): add 15/18/21 word seed-only wallets 2026-02-17 18:07:04 +01:00
cmyk
d8866c86db
feat(plates): finalize seed layout anchors and add 12-word fixture 2026-02-17 17:58:43 +01:00
cmyk
dfb09f2a1a
chore(font): add SeedEtcher plate font 2026-02-17 17:48:39 +01:00
cmyk
1bc9f7536e
feat(plates): implement b0.3 seed/descriptor layout and etched QR styling 2026-02-17 17:48:01 +01:00
₿ainter
2839b890bb
Merge pull request #16 from cmyk/release/0.2.0-beta.2
chore(release): finalize v0.2.0-beta.2 metadata on main
2026-02-17 13:39:39 +01:00
105 changed files with 11593 additions and 2444 deletions

2
.gitignore vendored
View File

@ -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/

View File

@ -1,53 +0,0 @@
# SeedEtcher Quick Operator Notes
## Purpose
- Airgapped 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.

View File

@ -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
View 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
View File

@ -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
View File

@ -0,0 +1,4 @@
SeedEtcher
Copyright (c) 2025-2026 Bainter (@BainterSAT) and contributors
This product includes software developed by Bainter and contributors.

View File

@ -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.52h.
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 Zerobased 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.
---

View File

@ -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
View 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
View 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.

View File

@ -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 {

View File

@ -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
View 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))
}
}
}

View File

@ -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))

View File

@ -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} {

View File

@ -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 {

View File

@ -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

View File

@ -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")
}

View File

@ -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()
}

View 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 ""
}

View 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
}

View 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)
}

View 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
}

View 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
View 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.

View File

@ -1,33 +1,49 @@
# SeedEtcher Workflow
A word of warning:\
This process is involved. Its not a machine that you flip on and off and be done with it.\
But the idea was to create a process that doesnt require a $500 machine. Most of the items you need you might already have. Also, you wont 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. Its not a machine that you flip on and off and be done with it.\
But the idea was to create a process that doesnt require a $500 machine. Most of the items you need you might already have. Also, you wont 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 (its the one closer to the center) to the printers 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 (its the one closer to the center) to the printers 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 240320 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. Its 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!
![Transfer paper placement](assets/workflow/transfer-paper-placement.png)
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.
![SeedEtcher Transfer Stack™](assets/workflow/seedetcher-transfer-stack.png)
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 wifes 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 wasnt 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
Dont be frustrated if it doesnt 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 doesnt 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 wasnt 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
Youll need:
### Youll 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 2025°C warm water and 12 tablespoons (1530 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 3040°C warm water and 12 tablespoons (1530 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. Dont let it drip into your kitchen sink, youll 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! (its 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. Dont 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 35 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. Dont let it drip into your kitchen sink, youll 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! (its 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 its 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View 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;
}

View File

@ -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.

View File

@ -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).

View File

@ -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).

View File

@ -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
View 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.

View 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
```

View 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.

View 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).

View File

@ -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 Zeros 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.

View File

@ -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
View 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
View File

@ -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.

View 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
View 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
View 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`

Binary file not shown.

7
go.mod
View File

@ -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
View File

@ -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=

View File

@ -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) {

View File

@ -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{}
}

View 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
}

View 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}
}

View File

@ -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 doesnt match descriptor", mfp)
showError(ctx, ops, th, fmt.Errorf("Seed doesnt 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

View File

@ -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

View File

@ -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)

View File

@ -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 {

View File

@ -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
},
}

View File

@ -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()

View File

@ -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 + ")"

View File

@ -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) {

View File

@ -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,

View File

@ -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)

View File

@ -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

View File

@ -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
View File

@ -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 controllers 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"

View 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
}

View 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
}

View 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
View 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)
}

View 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,
}
)

View 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
View 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
}

View 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
View 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
}

View File

@ -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
View 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
}

View File

@ -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
View 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
View 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
}

View File

@ -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
View 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
}

View File

@ -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
}

View 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
View 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
}
}
}
}
}

View 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]
}
}
}

View 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
}

View File

@ -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])
}
}

View 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
View 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
}

View 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
}

View 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
View 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"

View File

@ -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
View 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."

View 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"

View 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
View 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"

View 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"

View 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

View 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
View 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