diff --git a/cmd/emulator/main.go b/cmd/emulator/main.go index ef19873..0376679 100644 --- a/cmd/emulator/main.go +++ b/cmd/emulator/main.go @@ -2,116 +2,250 @@ // Command emulator is a browser-based SeedHammer v1 firmware runner. // -// Phase 2 scaffolding stage. This binary boots a 240×240 canvas-backed -// LCD mock and an 8-button input layer that maps keyboard events to the -// v1's joystick + 3 keys. The real firmware GUI lift (upstream's gui/ -// package + its assets/layout/op/saver/text/widget subpackages) lands in -// a follow-up commit; today this stage proves the build pipeline + the -// platform/v1.Platform interface contract. +// Loads the upstream v1.3.0 gui package and drives it through a +// browser-side Platform implementation: +// - Display: a 240×240 canvas, painted from Go RGBA via JS callback +// - Input: keyboard + on-screen button events through gui.ButtonEvent +// - Engraver: a no-op stub (the browser doesn't drive real hardware) +// - Camera: stub that emits empty FrameEvents (real QR-scan handoff +// lands once the SeedSigner sim wiring lands in Phase 2.5) // // Build: // // GOOS=js GOARCH=wasm go build -o ./web/emulator/emulator.wasm ./cmd/emulator -// -// The static shell (HTML/CSS/JS) lives under web/emulator/. JS exports -// documented in web/emulator/app.js. package main import ( - "fmt" + "errors" "image" "image/color" "image/draw" - "strconv" + "sync" "syscall/js" + "time" - "github.com/mineracks/seedhammer-v1-companion/platform/v1" + "github.com/mineracks/seedhammer-v1-companion/backup" + "github.com/mineracks/seedhammer-v1-companion/engrave" + "github.com/mineracks/seedhammer-v1-companion/gui" + v1 "github.com/mineracks/seedhammer-v1-companion/platform/v1" ) -const emulatorVersion = "v0.1-phase2-stub" +const emulatorVersion = "v0.2-phase2-gui" -// v1 hardware native LCD resolution. const ( lcdWidth = 240 lcdHeight = 240 ) -// browserPlatform implements v1.Platform against the JS host. -// -// Display() draws into an *image.RGBA buffer then ships it to JS via a -// Uint8ClampedArray that the JS shell paints onto a . Events() -// returns a channel fed by exportPushEvent calls from JS. +// browserPlatform implements gui.Platform against the JS host. type browserPlatform struct { frame *image.RGBA events chan v1.Event + + mu sync.Mutex + pending []gui.Event + dirtyRect image.Rectangle + chunkSent bool } func newBrowserPlatform() *browserPlatform { return &browserPlatform{ frame: image.NewRGBA(image.Rect(0, 0, lcdWidth, lcdHeight)), - events: make(chan v1.Event, 32), + events: make(chan v1.Event, 64), } } -func (p *browserPlatform) Events() <-chan v1.Event { return p.events } +// ─── gui.Platform impl ──────────────────────────────────────────────────── -func (p *browserPlatform) Display(frame image.Image) { - draw.Draw(p.frame, p.frame.Bounds(), frame, frame.Bounds().Min, draw.Src) - // Convert RGBA buffer → JS Uint8ClampedArray and call back into JS. +func (p *browserPlatform) Events(deadline time.Time) []gui.Event { + // Drain the v1.Event channel into gui.ButtonEvents. If no events + // pending, block (briefly) waiting for one or until deadline. + wait := time.Until(deadline) + if wait < 0 { + wait = 0 + } + out := p.drainPending() + if len(out) > 0 { + return out + } + if wait == 0 { + return nil + } + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case ev := <-p.events: + out = append(out, p.toGuiEvent(ev)) + case <-timer.C: + } + // Drain any extras that piled up while we were waiting. + for { + select { + case ev := <-p.events: + out = append(out, p.toGuiEvent(ev)) + default: + return out + } + } +} + +func (p *browserPlatform) drainPending() []gui.Event { + p.mu.Lock() + out := p.pending + p.pending = nil + p.mu.Unlock() + return out +} + +func (p *browserPlatform) toGuiEvent(ev v1.Event) gui.Event { + return gui.ButtonEvent{ + Button: gui.Button(ev.Button), // enum order matches by construction + Pressed: ev.Pressed, + }.Event() +} + +// push is called from the JS bridge — feeds the buffered channel. +func (p *browserPlatform) push(button v1.Button, pressed bool) { + select { + case p.events <- v1.Event{Button: button, Pressed: pressed}: + default: + } +} + +func (p *browserPlatform) Wakeup() { + // no-op — JS-driven runtime; nothing to wake from. +} + +func (p *browserPlatform) PlateSizes() []backup.PlateSize { + // Mirror what's defined in backup.PlateSize. v1.3.0 ships + // SquarePlate and LargePlate. + return []backup.PlateSize{backup.SquarePlate, backup.LargePlate} +} + +func (p *browserPlatform) Engraver() (gui.Engraver, error) { + // The browser can't engrave anything. Return an Engraver that + // politely says "no" if the GUI ever tries to drive it. + return nullEngraver{}, nil +} + +func (p *browserPlatform) EngraverParams() engrave.Params { + // Values copied from upstream driver/mjolnir.Params at v1.3.0. + // We can't import the mjolnir package here because it transitively + // pulls in tarm/serial, which doesn't compile to GOOS=js (uses + // OS-specific syscalls). The layout math doesn't need the serial + // driver — just these two constants. + return engrave.Params{ + StrokeWidth: 38, + Millimeter: 126, + } +} + +func (p *browserPlatform) CameraFrame(size image.Point) { + // Stub: no camera in the browser yet. Emit an error FrameEvent so + // the gui's QR-scan screen stays in its "no camera" state instead + // of waiting forever. + p.mu.Lock() + p.pending = append(p.pending, gui.FrameEvent{Error: errCameraStubbed}.Event()) + p.mu.Unlock() +} + +func (p *browserPlatform) Now() time.Time { return time.Now() } + +func (p *browserPlatform) DisplaySize() image.Point { + return image.Pt(lcdWidth, lcdHeight) +} + +func (p *browserPlatform) Dirty(r image.Rectangle) error { + p.mu.Lock() + p.dirtyRect = r.Intersect(p.frame.Bounds()) + p.chunkSent = false + p.mu.Unlock() + return nil +} + +func (p *browserPlatform) NextChunk() (draw.RGBA64Image, bool) { + p.mu.Lock() + if p.chunkSent || p.dirtyRect.Empty() { + p.mu.Unlock() + // One-chunk model: we ship the whole frame to JS in a single + // JS callback when the gui completes a Dirty/NextChunk cycle. + p.flushFrame() + return nil, false + } + p.chunkSent = true + r := p.dirtyRect + p.mu.Unlock() + // gui writes into the returned RGBA64Image — sub-image of our frame + // for the dirty rect. Our buffer is RGBA which satisfies + // draw.RGBA64Image via the standard image package. + return p.frame.SubImage(r).(*image.RGBA), true +} + +// flushFrame ships the current frame buffer to JS. Called after each +// Dirty/NextChunk render cycle. +func (p *browserPlatform) flushFrame() { jsBuf := js.Global().Get("Uint8ClampedArray").New(len(p.frame.Pix)) js.CopyBytesToJS(jsBuf, p.frame.Pix) js.Global().Call("emulatorPaint", jsBuf, lcdWidth, lcdHeight) } -// Push an event from JS into the events channel. -func (p *browserPlatform) push(button v1.Button, pressed bool) { - select { - case p.events <- v1.Event{Button: button, Pressed: pressed}: - default: - // Drop on full — shouldn't happen with the modest buffer the - // firmware needs, but better than blocking the JS thread. - } +func (p *browserPlatform) ScanQR(qr *image.Gray) ([][]byte, error) { + // Stub: no decodes. Real implementation lands when SeedSigner sim + // handoff wires up — the mock camera reads a sibling pane's canvas. + return nil, nil } +func (p *browserPlatform) Debug() bool { return false } + +var errCameraStubbed = errors.New("camera not implemented in browser stub") + +// ─── nullEngraver ───────────────────────────────────────────────────────── + +type nullEngraver struct{} + +func (nullEngraver) Engrave(_ backup.PlateSize, _ engrave.Plan, _ <-chan struct{}) error { + return errors.New("engraver not connected (browser emulator)") +} +func (nullEngraver) Close() {} + +// ─── JS bridge ──────────────────────────────────────────────────────────── + var plat *browserPlatform func main() { plat = newBrowserPlatform() + // Initial paint so the canvas isn't blank during gui bring-up. + clearBlack(plat.frame) + plat.flushFrame() + js.Global().Set("emulatorVersion", js.FuncOf(exportVersion)) js.Global().Set("emulatorPushEvent", js.FuncOf(exportPushEvent)) - js.Global().Set("emulatorBootScreen", js.FuncOf(exportBootScreen)) js.Global().Set("emulatorLCDSize", js.ValueOf(map[string]any{ - "w": lcdWidth, - "h": lcdHeight, + "w": lcdWidth, "h": lcdHeight, })) - // Show the placeholder boot screen so the canvas isn't blank on load. - drawBootScreen(plat.frame) - plat.Display(plat.frame) + app, err := gui.NewApp(plat, emulatorVersion) + if err != nil { + js.Global().Get("console").Call("error", "gui.NewApp failed: "+err.Error()) + select {} + } - // Future: wire plat to gui.Loop or whatever the lifted GUI exposes. - // For now, just consume events so the channel doesn't fill up. + // Drive frames in a goroutine. Each Frame call processes events + // and may render a new frame via Dirty + NextChunk. go func() { - for ev := range plat.events { - // Echo to console for debugging; the real GUI will replace this. - js.Global().Get("console").Call("log", - fmt.Sprintf("emu: %s %s", - buttonName(ev.Button), - boolStr(ev.Pressed, "press", "release"), - ), - ) + for { + app.Frame() } }() - select {} // keep the runtime alive + select {} } func exportVersion(this js.Value, args []js.Value) any { return emulatorVersion } -// exportPushEvent: emulatorPushEvent(buttonId:number, pressed:boolean) func exportPushEvent(this js.Value, args []js.Value) any { if len(args) != 2 { return nil @@ -125,66 +259,6 @@ func exportPushEvent(this js.Value, args []js.Value) any { return nil } -// exportBootScreen redraws the boot placeholder, useful for re-testing -// after manual mucking. -func exportBootScreen(this js.Value, args []js.Value) any { - drawBootScreen(plat.frame) - plat.Display(plat.frame) - return nil -} - -// drawBootScreen fills the frame with a minimal welcome image so users see -// SOMETHING the moment the WASM finishes loading. Future: this is replaced -// by gui.Loop()'s first frame. -func drawBootScreen(dst *image.RGBA) { - // Background. - draw.Draw(dst, dst.Bounds(), &image.Uniform{color.RGBA{0x12, 0x12, 0x12, 0xff}}, image.Point{}, draw.Src) - - // Frame border to make the LCD area unambiguous. - border := color.RGBA{0xff, 0x88, 0x00, 0xff} // accent orange - for x := 0; x < lcdWidth; x++ { - dst.SetRGBA(x, 0, border) - dst.SetRGBA(x, lcdHeight-1, border) - } - for y := 0; y < lcdHeight; y++ { - dst.SetRGBA(0, y, border) - dst.SetRGBA(lcdWidth-1, y, border) - } - - // Eight tick marks around the perimeter to show the button positions. - // Just a visual cue that this is real hardware-resolution rendering. - tick := color.RGBA{0xaa, 0xaa, 0xaa, 0xff} - for i := 0; i < 16; i++ { - dst.SetRGBA(lcdWidth/2-1+i-8, lcdHeight/2, tick) - dst.SetRGBA(lcdWidth/2, lcdHeight/2-1+i-8, tick) - } -} - -func buttonName(b v1.Button) string { - switch b { - case v1.ButtonUp: - return "Up" - case v1.ButtonDown: - return "Down" - case v1.ButtonLeft: - return "Left" - case v1.ButtonRight: - return "Right" - case v1.ButtonCenter: - return "Center" - case v1.Button1: - return "Button1" - case v1.Button2: - return "Button2" - case v1.Button3: - return "Button3" - } - return "btn-" + strconv.Itoa(int(b)) -} - -func boolStr(b bool, t, f string) string { - if b { - return t - } - return f +func clearBlack(dst *image.RGBA) { + draw.Draw(dst, dst.Bounds(), &image.Uniform{color.RGBA{0, 0, 0, 0xff}}, image.Point{}, draw.Src) } diff --git a/gui/assets/arrow-down.bin b/gui/assets/arrow-down.bin new file mode 100644 index 0000000..9709edd Binary files /dev/null and b/gui/assets/arrow-down.bin differ diff --git a/gui/assets/arrow-down.png b/gui/assets/arrow-down.png new file mode 100644 index 0000000..fe2d52c Binary files /dev/null and b/gui/assets/arrow-down.png differ diff --git a/gui/assets/arrow-left.bin b/gui/assets/arrow-left.bin new file mode 100644 index 0000000..bba9176 Binary files /dev/null and b/gui/assets/arrow-left.bin differ diff --git a/gui/assets/arrow-left.png b/gui/assets/arrow-left.png new file mode 100644 index 0000000..feb3d49 Binary files /dev/null and b/gui/assets/arrow-left.png differ diff --git a/gui/assets/arrow-right.bin b/gui/assets/arrow-right.bin new file mode 100644 index 0000000..dff3f16 Binary files /dev/null and b/gui/assets/arrow-right.bin differ diff --git a/gui/assets/arrow-right.png b/gui/assets/arrow-right.png new file mode 100644 index 0000000..4d01f41 Binary files /dev/null and b/gui/assets/arrow-right.png differ diff --git a/gui/assets/arrow-up.bin b/gui/assets/arrow-up.bin new file mode 100644 index 0000000..e082cf3 Binary files /dev/null and b/gui/assets/arrow-up.bin differ diff --git a/gui/assets/arrow-up.png b/gui/assets/arrow-up.png new file mode 100644 index 0000000..5b96606 Binary files /dev/null and b/gui/assets/arrow-up.png differ diff --git a/gui/assets/button-focused.9.png b/gui/assets/button-focused.9.png new file mode 100644 index 0000000..7530e1f Binary files /dev/null and b/gui/assets/button-focused.9.png differ diff --git a/gui/assets/button-focused.bin b/gui/assets/button-focused.bin new file mode 100644 index 0000000..1ff2622 Binary files /dev/null and b/gui/assets/button-focused.bin differ diff --git a/gui/assets/camera-corners.9.png b/gui/assets/camera-corners.9.png new file mode 100644 index 0000000..989a8b0 Binary files /dev/null and b/gui/assets/camera-corners.9.png differ diff --git a/gui/assets/camera-corners.bin b/gui/assets/camera-corners.bin new file mode 100644 index 0000000..7bc106c Binary files /dev/null and b/gui/assets/camera-corners.bin differ diff --git a/gui/assets/circle-filled.bin b/gui/assets/circle-filled.bin new file mode 100644 index 0000000..82089e8 Binary files /dev/null and b/gui/assets/circle-filled.bin differ diff --git a/gui/assets/circle-filled.png b/gui/assets/circle-filled.png new file mode 100644 index 0000000..c0b8e6b Binary files /dev/null and b/gui/assets/circle-filled.png differ diff --git a/gui/assets/circle.bin b/gui/assets/circle.bin new file mode 100644 index 0000000..6b6a513 Binary files /dev/null and b/gui/assets/circle.bin differ diff --git a/gui/assets/circle.png b/gui/assets/circle.png new file mode 100644 index 0000000..81bb9fd Binary files /dev/null and b/gui/assets/circle.png differ diff --git a/gui/assets/embed.go b/gui/assets/embed.go new file mode 100644 index 0000000..c239e3e --- /dev/null +++ b/gui/assets/embed.go @@ -0,0 +1,276 @@ +// Code generated by gui/assets/gen.go; DO NOT EDIT. +package assets + +import ( + _ "embed" + "unsafe" + + "github.com/mineracks/seedhammer-v1-companion/image/ninepatch" + "github.com/mineracks/seedhammer-v1-companion/image/paletted" +) + +var ( + ArrowDown = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(ArrowDownData[:135]), len(ArrowDownData[:135])), + Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 15, MaxY: 9}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(ArrowDownData[135:]), len(ArrowDownData[135:]))), + } + //go:embed arrow-down.bin + ArrowDownData string + + ArrowLeft = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(ArrowLeftData[:135]), len(ArrowLeftData[:135])), + Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 9, MaxY: 15}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(ArrowLeftData[135:]), len(ArrowLeftData[135:]))), + } + //go:embed arrow-left.bin + ArrowLeftData string + + ArrowRight = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(ArrowRightData[:135]), len(ArrowRightData[:135])), + Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 9, MaxY: 15}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(ArrowRightData[135:]), len(ArrowRightData[135:]))), + } + //go:embed arrow-right.bin + ArrowRightData string + + ArrowUp = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(ArrowUpData[:135]), len(ArrowUpData[:135])), + Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 15, MaxY: 9}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(ArrowUpData[135:]), len(ArrowUpData[135:]))), + } + //go:embed arrow-up.bin + ArrowUpData string + + ButtonFocused = ninepatch.New(&paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(ButtonFocusedData[:850]), len(ButtonFocusedData[:850])), + Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 34, MaxY: 25}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(ButtonFocusedData[850:]), len(ButtonFocusedData[850:]))), + }) + //go:embed button-focused.bin + ButtonFocusedData string + + CameraCorners = ninepatch.New(&paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(CameraCornersData[:3249]), len(CameraCornersData[:3249])), + Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 57, MaxY: 57}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(CameraCornersData[3249:]), len(CameraCornersData[3249:]))), + }) + //go:embed camera-corners.bin + CameraCornersData string + + CircleFilled = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(CircleFilledData[:169]), len(CircleFilledData[:169])), + Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 13, MaxY: 13}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(CircleFilledData[169:]), len(CircleFilledData[169:]))), + } + //go:embed circle-filled.bin + CircleFilledData string + + Circle = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(CircleData[:169]), len(CircleData[:169])), + Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 13, MaxY: 13}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(CircleData[169:]), len(CircleData[169:]))), + } + //go:embed circle.bin + CircleData string + + Hammer = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(HammerData[:14760]), len(HammerData[:14760])), + Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 120, MaxY: 123}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(HammerData[14760:]), len(HammerData[14760:]))), + } + //go:embed hammer.bin + HammerData string + + IconBack = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(IconBackData[:270]), len(IconBackData[:270])), + Rect: paletted.Rectangle{MinX: 8, MinY: 10, MaxX: 26, MaxY: 25}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconBackData[270:]), len(IconBackData[270:]))), + } + //go:embed icon-back.bin + IconBackData string + + IconBackspace = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(IconBackspaceData[:260]), len(IconBackspaceData[:260])), + Rect: paletted.Rectangle{MinX: 7, MinY: 11, MaxX: 27, MaxY: 24}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconBackspaceData[260:]), len(IconBackspaceData[260:]))), + } + //go:embed icon-backspace.bin + IconBackspaceData string + + IconCheckmark = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(IconCheckmarkData[:414]), len(IconCheckmarkData[:414])), + Rect: paletted.Rectangle{MinX: 6, MinY: 8, MaxX: 29, MaxY: 26}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconCheckmarkData[414:]), len(IconCheckmarkData[414:]))), + } + //go:embed icon-checkmark.bin + IconCheckmarkData string + + IconDiscard = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(IconDiscardData[:460]), len(IconDiscardData[:460])), + Rect: paletted.Rectangle{MinX: 7, MinY: 6, MaxX: 27, MaxY: 29}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconDiscardData[460:]), len(IconDiscardData[460:]))), + } + //go:embed icon-discard.bin + IconDiscardData string + + IconDot = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(IconDotData[:121]), len(IconDotData[:121])), + Rect: paletted.Rectangle{MinX: 12, MinY: 12, MaxX: 23, MaxY: 23}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconDotData[121:]), len(IconDotData[121:]))), + } + //go:embed icon-dot.bin + IconDotData string + + IconEdit = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(IconEditData[:441]), len(IconEditData[:441])), + Rect: paletted.Rectangle{MinX: 7, MinY: 7, MaxX: 28, MaxY: 28}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconEditData[441:]), len(IconEditData[441:]))), + } + //go:embed icon-edit.bin + IconEditData string + + IconFlip = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(IconFlipData[:360]), len(IconFlipData[:360])), + Rect: paletted.Rectangle{MinX: 8, MinY: 9, MaxX: 28, MaxY: 27}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconFlipData[360:]), len(IconFlipData[360:]))), + } + //go:embed icon-flip.bin + IconFlipData string + + IconHammer = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(IconHammerData[:506]), len(IconHammerData[:506])), + Rect: paletted.Rectangle{MinX: 6, MinY: 7, MaxX: 29, MaxY: 29}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconHammerData[506:]), len(IconHammerData[506:]))), + } + //go:embed icon-hammer.bin + IconHammerData string + + IconInfo = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(IconInfoData[:152]), len(IconInfoData[:152])), + Rect: paletted.Rectangle{MinX: 14, MinY: 8, MaxX: 22, MaxY: 27}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconInfoData[152:]), len(IconInfoData[152:]))), + } + //go:embed icon-info.bin + IconInfoData string + + IconLeft = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(IconLeftData[:270]), len(IconLeftData[:270])), + Rect: paletted.Rectangle{MinX: 8, MinY: 10, MaxX: 26, MaxY: 25}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconLeftData[270:]), len(IconLeftData[270:]))), + } + //go:embed icon-left.bin + IconLeftData string + + IconProgress = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(IconProgressData[:529]), len(IconProgressData[:529])), + Rect: paletted.Rectangle{MinX: 6, MinY: 6, MaxX: 29, MaxY: 29}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconProgressData[529:]), len(IconProgressData[529:]))), + } + //go:embed icon-progress.bin + IconProgressData string + + IconRight = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(IconRightData[:270]), len(IconRightData[:270])), + Rect: paletted.Rectangle{MinX: 9, MinY: 10, MaxX: 27, MaxY: 25}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconRightData[270:]), len(IconRightData[270:]))), + } + //go:embed icon-right.bin + IconRightData string + + IconSkip = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(IconSkipData[:270]), len(IconSkipData[:270])), + Rect: paletted.Rectangle{MinX: 8, MinY: 10, MaxX: 26, MaxY: 25}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(IconSkipData[270:]), len(IconSkipData[270:]))), + } + //go:embed icon-skip.bin + IconSkipData string + + KeyActive = ninepatch.New(&paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(KeyActiveData[:112]), len(KeyActiveData[:112])), + Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 8, MaxY: 14}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(KeyActiveData[112:]), len(KeyActiveData[112:]))), + }) + //go:embed key-active.bin + KeyActiveData string + + KeyBackspace = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(KeyBackspaceData[:187]), len(KeyBackspaceData[:187])), + Rect: paletted.Rectangle{MinX: 2, MinY: 0, MaxX: 19, MaxY: 11}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(KeyBackspaceData[187:]), len(KeyBackspaceData[187:]))), + } + //go:embed key-backspace.bin + KeyBackspaceData string + + Key = ninepatch.New(&paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(KeyData[:112]), len(KeyData[:112])), + Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 8, MaxY: 14}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(KeyData[112:]), len(KeyData[112:]))), + }) + //go:embed key.bin + KeyData string + + LogoSmall = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(LogoSmallData[:441]), len(LogoSmallData[:441])), + Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 21, MaxY: 21}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(LogoSmallData[441:]), len(LogoSmallData[441:]))), + } + //go:embed logo-small.bin + LogoSmallData string + + NavBtnPrimary = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(NavBtnPrimaryData[:1225]), len(NavBtnPrimaryData[:1225])), + Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 35, MaxY: 35}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(NavBtnPrimaryData[1225:]), len(NavBtnPrimaryData[1225:]))), + } + //go:embed nav-btn-primary.bin + NavBtnPrimaryData string + + NavBtnSecondary = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(NavBtnSecondaryData[:1225]), len(NavBtnSecondaryData[:1225])), + Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 35, MaxY: 35}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(NavBtnSecondaryData[1225:]), len(NavBtnSecondaryData[1225:]))), + } + //go:embed nav-btn-secondary.bin + NavBtnSecondaryData string + + ProgressCircle = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(ProgressCircleData[:16384]), len(ProgressCircleData[:16384])), + Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 128, MaxY: 128}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(ProgressCircleData[16384:]), len(ProgressCircleData[16384:]))), + } + //go:embed progress-circle.bin + ProgressCircleData string + + Sh02 = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(Sh02Data[:5720]), len(Sh02Data[:5720])), + Rect: paletted.Rectangle{MinX: 1, MinY: 0, MaxX: 89, MaxY: 65}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(Sh02Data[5720:]), len(Sh02Data[5720:]))), + } + //go:embed sh02.bin + Sh02Data string + + Sh03 = &paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(Sh03Data[:5720]), len(Sh03Data[:5720])), + Rect: paletted.Rectangle{MinX: 1, MinY: 0, MaxX: 89, MaxY: 65}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(Sh03Data[5720:]), len(Sh03Data[5720:]))), + } + //go:embed sh03.bin + Sh03Data string + + WarningBoxBg = ninepatch.New(&paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(WarningBoxBgData[:405]), len(WarningBoxBgData[:405])), + Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 27, MaxY: 15}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(WarningBoxBgData[405:]), len(WarningBoxBgData[405:]))), + }) + //go:embed warning-box-bg.bin + WarningBoxBgData string + + WarningBoxBorder = ninepatch.New(&paletted.Image{ + Pix: unsafe.Slice(unsafe.StringData(WarningBoxBorderData[:405]), len(WarningBoxBorderData[:405])), + Rect: paletted.Rectangle{MinX: 0, MinY: 0, MaxX: 27, MaxY: 15}, + Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(WarningBoxBorderData[405:]), len(WarningBoxBorderData[405:]))), + }) + //go:embed warning-box-border.bin + WarningBoxBorderData string +) diff --git a/gui/assets/gen.go b/gui/assets/gen.go new file mode 100644 index 0000000..7686a54 --- /dev/null +++ b/gui/assets/gen.go @@ -0,0 +1,3 @@ +package assets + +//go:generate go run generator.go diff --git a/gui/assets/generator.go b/gui/assets/generator.go new file mode 100644 index 0000000..4846788 --- /dev/null +++ b/gui/assets/generator.go @@ -0,0 +1,151 @@ +//go:build ignore + +// gen converts the PNG assets to embedded image literals. +package main + +import ( + "bytes" + "fmt" + "go/format" + "image" + "image/color" + "image/draw" + _ "image/png" + "log" + "os" + "path/filepath" + "strings" + "unicode" + + simage "github.com/mineracks/seedhammer-v1-companion/image" + "github.com/mineracks/seedhammer-v1-companion/image/rgb565" +) + +func main() { + pngs, err := filepath.Glob("*.png") + if err != nil { + log.Fatal(err) + } + // out is the generated embed.go file. + out := new(bytes.Buffer) + // data is the binary embed.bin containing image data. + fmt.Fprintf(out, "// Code generated by gui/assets/gen.go; DO NOT EDIT.\n") + fmt.Fprintf(out, "package assets\n\n") + fmt.Fprintf(out, "import (\n") + fmt.Fprintf(out, "_ \"embed\"\n") + fmt.Fprintf(out, "\"unsafe\"\n\n") + fmt.Fprintf(out, "\"github.com/mineracks/seedhammer-v1-companion/image/ninepatch\"\n") + fmt.Fprintf(out, "\"github.com/mineracks/seedhammer-v1-companion/image/paletted\"\n\n") + fmt.Fprintf(out, ")\n\n") + fmt.Fprintf(out, "var (\n") + for _, p := range pngs { + r, err := os.Open(p) + if err != nil { + log.Fatal(err) + } + img, _, err := image.Decode(r) + r.Close() + if err != nil { + log.Fatal(err) + } + // Convert RGBA images to image.Paletted. + if nrgba, ok := img.(*image.NRGBA); ok { + // Convert to alpha pre-multiplied RGBA. + rgba := image.NewRGBA(nrgba.Bounds()) + draw.Draw(rgba, rgba.Bounds(), nrgba, nrgba.Bounds().Min, draw.Src) + paletteMap := make(map[color.RGBA64]uint8) + b := rgba.Bounds() + index := uint8(0) + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + c := rgba.RGBA64At(x, y) + idx, ok := paletteMap[c] + if !ok { + idx = index + paletteMap[c] = idx + index++ + if index < idx { + log.Fatalf("too many colors in %q:", p) + } + } + } + } + palette := make([]color.Color, len(paletteMap)) + for col, idx := range paletteMap { + palette[idx] = col + } + pimg := image.NewPaletted(rgba.Bounds(), palette) + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + c := rgba.RGBA64At(x, y) + idx := paletteMap[c] + pimg.SetColorIndex(x, y, idx) + } + } + img = pimg + } + name := p[:len(p)-len(filepath.Ext(p))] + ninePatchPrefix, ninePatchSuffix := "", "" + if ext := filepath.Ext(name); ext == ".9" { + name = name[:len(name)-len(ext)] + ninePatchPrefix, ninePatchSuffix = "ninepatch.New(", ")" + } + goName := filenameToGoName(name) + fmt.Fprintf(out, "%s = %s&", goName, ninePatchPrefix) + data := new(bytes.Buffer) + switch img := img.(type) { + case *image.Paletted: + r := simage.Crop(img) + crop := image.NewPaletted(r, img.Palette) + draw.Draw(crop, crop.Rect, img, crop.Rect.Min, draw.Src) + img = crop + data.Write(img.Pix) + start := data.Len() + // Write palette. + for _, c := range img.Palette { + r, g, b, a := c.RGBA() + rgb565 := rgb565.RGB888ToRGB565(uint8(r>>8), uint8(g>>8), uint8(b>>8)) + data.Write([]byte{rgb565[0], rgb565[1], uint8(a >> 8)}) + } + b := img.Rect + fmt.Fprintf(out, "paletted.Image{\n") + fmt.Fprintf(out, "Pix: unsafe.Slice(unsafe.StringData(%sData[:%d]), len(%[1]sData[:%[2]d])),\n", goName, start) + fmt.Fprintf(out, "Rect: paletted.Rectangle{MinX: %d, MinY: %d, MaxX: %d, MaxY: %d},\n", b.Min.X, b.Min.Y, b.Max.X, b.Max.Y) + fmt.Fprintf(out, "Palette: paletted.Palette(unsafe.Slice(unsafe.StringData(%sData[%d:]), len(%[1]sData[%[2]d:]))),\n", goName, start) + default: + log.Fatalf("unsupported image format for %q: %T\n", p, img) + } + fmt.Fprintf(out, "}%s\n", ninePatchSuffix) + binName := fmt.Sprintf("%s.bin", name) + fmt.Fprintf(out, "//go:embed %s\n", binName) + fmt.Fprintf(out, "%sData string\n\n", goName) + if err := os.WriteFile(binName, data.Bytes(), 0o644); err != nil { + log.Fatal(err) + } + } + fmt.Fprintf(out, ")\n\n") + src, err := format.Source(out.Bytes()) + if err != nil { + log.Fatal(err) + } + if err := os.WriteFile("embed.go", src, 0o644); err != nil { + log.Fatal(err) + } +} + +func filenameToGoName(n string) string { + var name strings.Builder + toTitle := true + for _, ch := range n { + if !unicode.IsLetter(ch) && !unicode.IsDigit(ch) { + toTitle = true + continue + } + if toTitle { + toTitle = false + ch = unicode.ToTitle(ch) + } + name.WriteRune(ch) + } + return name.String() +} diff --git a/gui/assets/hammer.bin b/gui/assets/hammer.bin new file mode 100644 index 0000000..c2095c8 Binary files /dev/null and b/gui/assets/hammer.bin differ diff --git a/gui/assets/hammer.png b/gui/assets/hammer.png new file mode 100644 index 0000000..0e06f4a Binary files /dev/null and b/gui/assets/hammer.png differ diff --git a/gui/assets/icon-back.bin b/gui/assets/icon-back.bin new file mode 100644 index 0000000..3cdb255 Binary files /dev/null and b/gui/assets/icon-back.bin differ diff --git a/gui/assets/icon-back.png b/gui/assets/icon-back.png new file mode 100644 index 0000000..f1db239 Binary files /dev/null and b/gui/assets/icon-back.png differ diff --git a/gui/assets/icon-backspace.bin b/gui/assets/icon-backspace.bin new file mode 100644 index 0000000..94cfddc Binary files /dev/null and b/gui/assets/icon-backspace.bin differ diff --git a/gui/assets/icon-backspace.png b/gui/assets/icon-backspace.png new file mode 100644 index 0000000..ad22910 Binary files /dev/null and b/gui/assets/icon-backspace.png differ diff --git a/gui/assets/icon-checkmark.bin b/gui/assets/icon-checkmark.bin new file mode 100644 index 0000000..09b2c41 Binary files /dev/null and b/gui/assets/icon-checkmark.bin differ diff --git a/gui/assets/icon-checkmark.png b/gui/assets/icon-checkmark.png new file mode 100644 index 0000000..9f8e331 Binary files /dev/null and b/gui/assets/icon-checkmark.png differ diff --git a/gui/assets/icon-discard.bin b/gui/assets/icon-discard.bin new file mode 100644 index 0000000..87780fb Binary files /dev/null and b/gui/assets/icon-discard.bin differ diff --git a/gui/assets/icon-discard.png b/gui/assets/icon-discard.png new file mode 100644 index 0000000..6a5018c Binary files /dev/null and b/gui/assets/icon-discard.png differ diff --git a/gui/assets/icon-dot.bin b/gui/assets/icon-dot.bin new file mode 100644 index 0000000..55744ac Binary files /dev/null and b/gui/assets/icon-dot.bin differ diff --git a/gui/assets/icon-dot.png b/gui/assets/icon-dot.png new file mode 100644 index 0000000..5b6c8f0 Binary files /dev/null and b/gui/assets/icon-dot.png differ diff --git a/gui/assets/icon-edit.bin b/gui/assets/icon-edit.bin new file mode 100644 index 0000000..70245da Binary files /dev/null and b/gui/assets/icon-edit.bin differ diff --git a/gui/assets/icon-edit.png b/gui/assets/icon-edit.png new file mode 100644 index 0000000..7f75c75 Binary files /dev/null and b/gui/assets/icon-edit.png differ diff --git a/gui/assets/icon-flip.bin b/gui/assets/icon-flip.bin new file mode 100644 index 0000000..ce6c143 Binary files /dev/null and b/gui/assets/icon-flip.bin differ diff --git a/gui/assets/icon-flip.png b/gui/assets/icon-flip.png new file mode 100644 index 0000000..26661be Binary files /dev/null and b/gui/assets/icon-flip.png differ diff --git a/gui/assets/icon-hammer.bin b/gui/assets/icon-hammer.bin new file mode 100644 index 0000000..ca322c0 Binary files /dev/null and b/gui/assets/icon-hammer.bin differ diff --git a/gui/assets/icon-hammer.png b/gui/assets/icon-hammer.png new file mode 100644 index 0000000..7b364a7 Binary files /dev/null and b/gui/assets/icon-hammer.png differ diff --git a/gui/assets/icon-info.bin b/gui/assets/icon-info.bin new file mode 100644 index 0000000..fbd4203 Binary files /dev/null and b/gui/assets/icon-info.bin differ diff --git a/gui/assets/icon-info.png b/gui/assets/icon-info.png new file mode 100644 index 0000000..8f46802 Binary files /dev/null and b/gui/assets/icon-info.png differ diff --git a/gui/assets/icon-left.bin b/gui/assets/icon-left.bin new file mode 100644 index 0000000..e88c1c5 Binary files /dev/null and b/gui/assets/icon-left.bin differ diff --git a/gui/assets/icon-left.png b/gui/assets/icon-left.png new file mode 100644 index 0000000..1eba395 Binary files /dev/null and b/gui/assets/icon-left.png differ diff --git a/gui/assets/icon-progress.bin b/gui/assets/icon-progress.bin new file mode 100644 index 0000000..ab38197 Binary files /dev/null and b/gui/assets/icon-progress.bin differ diff --git a/gui/assets/icon-progress.png b/gui/assets/icon-progress.png new file mode 100644 index 0000000..8cdc07f Binary files /dev/null and b/gui/assets/icon-progress.png differ diff --git a/gui/assets/icon-right.bin b/gui/assets/icon-right.bin new file mode 100644 index 0000000..76d104c Binary files /dev/null and b/gui/assets/icon-right.bin differ diff --git a/gui/assets/icon-right.png b/gui/assets/icon-right.png new file mode 100644 index 0000000..532efd0 Binary files /dev/null and b/gui/assets/icon-right.png differ diff --git a/gui/assets/icon-skip.bin b/gui/assets/icon-skip.bin new file mode 100644 index 0000000..f9498ea Binary files /dev/null and b/gui/assets/icon-skip.bin differ diff --git a/gui/assets/icon-skip.png b/gui/assets/icon-skip.png new file mode 100644 index 0000000..4523b15 Binary files /dev/null and b/gui/assets/icon-skip.png differ diff --git a/gui/assets/key-active.9.png b/gui/assets/key-active.9.png new file mode 100644 index 0000000..7030e5c Binary files /dev/null and b/gui/assets/key-active.9.png differ diff --git a/gui/assets/key-active.bin b/gui/assets/key-active.bin new file mode 100644 index 0000000..5c95ba6 Binary files /dev/null and b/gui/assets/key-active.bin differ diff --git a/gui/assets/key-backspace.bin b/gui/assets/key-backspace.bin new file mode 100644 index 0000000..64ec41e Binary files /dev/null and b/gui/assets/key-backspace.bin differ diff --git a/gui/assets/key-backspace.png b/gui/assets/key-backspace.png new file mode 100644 index 0000000..95875dc Binary files /dev/null and b/gui/assets/key-backspace.png differ diff --git a/gui/assets/key.9.png b/gui/assets/key.9.png new file mode 100644 index 0000000..f5f2cf4 Binary files /dev/null and b/gui/assets/key.9.png differ diff --git a/gui/assets/key.bin b/gui/assets/key.bin new file mode 100644 index 0000000..5223b7f Binary files /dev/null and b/gui/assets/key.bin differ diff --git a/gui/assets/logo-small.bin b/gui/assets/logo-small.bin new file mode 100644 index 0000000..aa8a5e1 Binary files /dev/null and b/gui/assets/logo-small.bin differ diff --git a/gui/assets/logo-small.png b/gui/assets/logo-small.png new file mode 100644 index 0000000..e2f35ea Binary files /dev/null and b/gui/assets/logo-small.png differ diff --git a/gui/assets/nav-btn-primary.bin b/gui/assets/nav-btn-primary.bin new file mode 100644 index 0000000..44da849 Binary files /dev/null and b/gui/assets/nav-btn-primary.bin differ diff --git a/gui/assets/nav-btn-primary.png b/gui/assets/nav-btn-primary.png new file mode 100644 index 0000000..0d13010 Binary files /dev/null and b/gui/assets/nav-btn-primary.png differ diff --git a/gui/assets/nav-btn-secondary.bin b/gui/assets/nav-btn-secondary.bin new file mode 100644 index 0000000..571fe30 Binary files /dev/null and b/gui/assets/nav-btn-secondary.bin differ diff --git a/gui/assets/nav-btn-secondary.png b/gui/assets/nav-btn-secondary.png new file mode 100644 index 0000000..1c736ae Binary files /dev/null and b/gui/assets/nav-btn-secondary.png differ diff --git a/gui/assets/progress-circle.bin b/gui/assets/progress-circle.bin new file mode 100644 index 0000000..bae0a53 Binary files /dev/null and b/gui/assets/progress-circle.bin differ diff --git a/gui/assets/progress-circle.png b/gui/assets/progress-circle.png new file mode 100644 index 0000000..b4dac18 Binary files /dev/null and b/gui/assets/progress-circle.png differ diff --git a/gui/assets/sh02.bin b/gui/assets/sh02.bin new file mode 100644 index 0000000..cad7196 Binary files /dev/null and b/gui/assets/sh02.bin differ diff --git a/gui/assets/sh02.png b/gui/assets/sh02.png new file mode 100644 index 0000000..c084613 Binary files /dev/null and b/gui/assets/sh02.png differ diff --git a/gui/assets/sh03.bin b/gui/assets/sh03.bin new file mode 100644 index 0000000..82b594c Binary files /dev/null and b/gui/assets/sh03.bin differ diff --git a/gui/assets/sh03.png b/gui/assets/sh03.png new file mode 100644 index 0000000..dc86abc Binary files /dev/null and b/gui/assets/sh03.png differ diff --git a/gui/assets/warning-box-bg.9.png b/gui/assets/warning-box-bg.9.png new file mode 100644 index 0000000..41eb74d Binary files /dev/null and b/gui/assets/warning-box-bg.9.png differ diff --git a/gui/assets/warning-box-bg.bin b/gui/assets/warning-box-bg.bin new file mode 100644 index 0000000..f5c533e Binary files /dev/null and b/gui/assets/warning-box-bg.bin differ diff --git a/gui/assets/warning-box-border.9.png b/gui/assets/warning-box-border.9.png new file mode 100644 index 0000000..e8f9b9c Binary files /dev/null and b/gui/assets/warning-box-border.9.png differ diff --git a/gui/assets/warning-box-border.bin b/gui/assets/warning-box-border.bin new file mode 100644 index 0000000..a0e6ab9 Binary files /dev/null and b/gui/assets/warning-box-border.bin differ diff --git a/gui/doc.go b/gui/doc.go deleted file mode 100644 index e5e3ec9..0000000 --- a/gui/doc.go +++ /dev/null @@ -1,22 +0,0 @@ -// Package gui is the v1 controller's on-device UI — screen drawing, menu -// navigation, page state machine. -// -// Self-contained, hardware-abstracted: it talks to platform/ for input -// events and frame output. That abstraction lets the same gui run on a -// real Pi (via driver/wshat + driver/drm) and in the browser emulator -// (via platform/v1 + canvas). -// -// Screen flow follows the v1 controller's existing conventions: -// -// - Joystick (Up/Down/Left/Right/Center) = navigation -// - Button1 = back -// - Button2 = secondary action (hold-to-arm dry-run) -// - Button3 = primary confirm (hold-to-engrave) -// -// Hold-to-confirm distinguishes Pressed from Click with a confirmDelay -// timer (see gui.go in upstream). -// -// Status: STUB — to be lifted from upstream seedhammer/seedhammer at v1.3.0. -// Browser-emulator-specific tweaks (mock camera, keyboard event source) -// live in platform/v1, NOT in this package. -package gui diff --git a/gui/gui.go b/gui/gui.go new file mode 100644 index 0000000..f5cb866 --- /dev/null +++ b/gui/gui.go @@ -0,0 +1,2951 @@ +// package gui implements the SeedHammer controller user interface. +package gui + +import ( + "errors" + "fmt" + "image" + "image/color" + "image/draw" + "log" + "math" + "strings" + "time" + "unicode/utf8" + + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" + "github.com/mineracks/seedhammer-v1-companion/address" + "github.com/mineracks/seedhammer-v1-companion/backup" + "github.com/mineracks/seedhammer-v1-companion/bc/ur" + "github.com/mineracks/seedhammer-v1-companion/bc/urtypes" + "github.com/mineracks/seedhammer-v1-companion/bip32" + "github.com/mineracks/seedhammer-v1-companion/bip39" + "github.com/mineracks/seedhammer-v1-companion/engrave" + "github.com/mineracks/seedhammer-v1-companion/font/constant" + "github.com/mineracks/seedhammer-v1-companion/gui/assets" + "github.com/mineracks/seedhammer-v1-companion/gui/layout" + "github.com/mineracks/seedhammer-v1-companion/gui/op" + "github.com/mineracks/seedhammer-v1-companion/gui/saver" + "github.com/mineracks/seedhammer-v1-companion/gui/text" + "github.com/mineracks/seedhammer-v1-companion/gui/widget" + "github.com/mineracks/seedhammer-v1-companion/nonstandard" + "github.com/mineracks/seedhammer-v1-companion/seedqr" +) + +const nbuttons = 8 + +type Context struct { + Platform Platform + Styles Styles + Wakeup time.Time + Frame func() + + // Global UI state. + Version string + Calibrated bool + EmptySDSlot bool + RotateCamera bool + LastDescriptor *urtypes.OutputDescriptor + + events []Event +} + +func NewContext(pl Platform) *Context { + c := &Context{ + Platform: pl, + Styles: NewStyles(), + } + return c +} + +func (c *Context) WakeupAt(t time.Time) { + if c.Wakeup.IsZero() || t.Before(c.Wakeup) { + c.Wakeup = t + } +} + +const repeatStartDelay = 400 * time.Millisecond +const repeatDelay = 100 * time.Millisecond + +func isRepeatButton(b Button) bool { + switch b { + case Up, Down, Right, Left: + return true + } + return false +} + +func (c *Context) Reset() { + c.events = c.events[:0] + c.Wakeup = time.Time{} +} + +func (c *Context) Events(evts ...Event) { + c.events = append(c.events, evts...) +} + +func (c *Context) FrameEvent() (FrameEvent, bool) { + for i, e := range c.events { + if e, ok := e.AsFrame(); ok { + c.events = append(c.events[:i], c.events[i+1:]...) + return e, true + } + } + return FrameEvent{}, false +} + +func (c *Context) Next(btns ...Button) (ButtonEvent, bool) { + for i, e := range c.events { + e, ok := e.AsButton() + if !ok { + continue + } + for _, btn := range btns { + if e.Button == btn { + c.events = append(c.events[:i], c.events[i+1:]...) + return e, true + } + } + } + return ButtonEvent{}, false +} + +type InputTracker struct { + Pressed [nbuttons]bool + clicked [nbuttons]bool + repeats [nbuttons]time.Time +} + +func (t *InputTracker) Next(c *Context, btns ...Button) (ButtonEvent, bool) { + now := c.Platform.Now() + for _, b := range btns { + if !isRepeatButton(b) { + continue + } + if !t.Pressed[b] { + t.repeats[b] = time.Time{} + continue + } + wakeup := t.repeats[b] + if wakeup.IsZero() { + wakeup = now.Add(repeatStartDelay) + } + repeat := !now.Before(wakeup) + if repeat { + wakeup = now.Add(repeatDelay) + } + t.repeats[b] = wakeup + c.WakeupAt(wakeup) + if repeat { + return ButtonEvent{Button: b, Pressed: true}, true + } + } + + e, ok := c.Next(btns...) + if !ok { + return ButtonEvent{}, false + } + if int(e.Button) < len(t.clicked) { + t.clicked[e.Button] = !e.Pressed && t.Pressed[e.Button] + t.Pressed[e.Button] = e.Pressed + } + return e, true +} + +func (t *InputTracker) Clicked(b Button) bool { + c := t.clicked[b] + t.clicked[b] = false + return c +} + +const longestWord = "TOMORROW" + +type program int + +const ( + backupWallet program = iota +) + +type linePos struct { + W op.CallOp + Y int +} + +type richText struct { + Lines []linePos + Y int +} + +func (r *richText) Add(ops op.Ctx, style text.Style, width int, col color.NRGBA, txt string) { + lines, _ := text.Style{ + Face: style.Face, + Alignment: style.Alignment, + LineHeight: style.LineHeight, + }.Layout(width, txt) + for _, line := range lines { + doty := line.Dot.Y + r.Y + (&op.TextOp{ + Src: image.NewUniform(col), + Face: style.Face, + Dot: image.Pt(line.Dot.X, doty), + Txt: line.Text, + }).Add(ops.Begin()) + r.Lines = append(r.Lines, linePos{ops.End(), doty}) + } + r.Y += lines[len(lines)-1].Dot.Y +} + +type AddressesScreen struct { + addresses [2][]string + page int + scroll int +} + +func NewAddressesScreen(desc urtypes.OutputDescriptor) *AddressesScreen { + s := new(AddressesScreen) + for i := 0; i < 20; i++ { + addr, err := address.Receive(desc, uint32(i)) + if err != nil { + // Very unlikely. + continue + } + const addrLen = 12 + s.addresses[0] = append(s.addresses[0], shortenAddress(addrLen, addr)) + change, err := address.Change(desc, uint32(i)) + if err != nil { + continue + } + s.addresses[1] = append(s.addresses[1], shortenAddress(addrLen, change)) + } + return s +} + +func (s *AddressesScreen) Show(ctx *Context, ops op.Ctx, th *Colors) { + const linesPerPage = 8 + const linesPerScroll = linesPerPage - 3 + + const maxPage = len(s.addresses) + inp := new(InputTracker) + for { + for { + e, ok := inp.Next(ctx, Button1, Left, Right, Up, Down) + if !ok { + break + } + switch e.Button { + case Button1: + if inp.Clicked(e.Button) { + return + } + case Left: + if e.Pressed { + s.page = (s.page - 1 + maxPage) % maxPage + s.scroll = 0 + } + case Right: + if e.Pressed { + s.page = (s.page + 1) % maxPage + s.scroll = 0 + } + case Up: + if e.Pressed { + s.scroll -= linesPerScroll + } + case Down: + if e.Pressed { + s.scroll += linesPerScroll + } + } + } + op.ColorOp(ops, th.Background) + dims := ctx.Platform.DisplaySize() + + // Title. + r := layout.Rectangle{Max: dims} + title := "Receive" + if s.page == 1 { + title = "Change" + } + layoutTitle(ctx, ops, dims.X, th.Text, title) + + op.MaskOp(ops.Begin(), assets.ArrowLeft) + op.ColorOp(ops, th.Text) + left := ops.End() + + op.MaskOp(ops.Begin(), assets.ArrowRight) + op.ColorOp(ops, th.Text) + right := ops.End() + + leftsz := assets.ArrowLeft.Bounds().Size() + rightsz := assets.ArrowRight.Bounds().Size() + + content := r.Shrink(0, 12, 0, 12) + body := content.Shrink(leadingSize, rightsz.X+12, 0, leftsz.X+12) + inner := body.Shrink(scrollFadeDist, 0, scrollFadeDist, 0) + + bodyst := ctx.Styles.body + var bodytxt richText + addrs := s.addresses[s.page] + for i, addr := range addrs { + bodytxt.Add(ops, bodyst, body.Dx(), th.Text, fmt.Sprintf("%d: %s", i+1, addr)) + } + + op.Position(ops, left, content.W(leftsz)) + op.Position(ops, right, content.E(rightsz)) + maxScroll := len(bodytxt.Lines) - linesPerPage + if s.scroll > maxScroll { + s.scroll = maxScroll + } + if s.scroll < 0 { + s.scroll = 0 + } + off := bodytxt.Lines[s.scroll].Y - bodytxt.Lines[0].Y + ops.Begin() + for _, l := range bodytxt.Lines { + op.Position(ops, l.W, inner.Min.Sub(image.Pt(0, off))) + } + fadeClip(ops, ops.End(), image.Rectangle(body)) + + layoutNavigation(inp, ops, th, dims, []NavButton{{Button: Button1, Style: StyleSecondary, Icon: assets.IconBack}}...) + ctx.Frame() + } +} + +func shortenAddress(n int, addr string) string { + if len(addr) <= n { + return addr + } + return addr[:n/2] + "......" + addr[len(addr)-n/2:] +} + +func descriptorKeyIdx(desc urtypes.OutputDescriptor, m bip39.Mnemonic, pass string) (int, bool) { + if len(desc.Keys) == 0 { + return 0, false + } + network := desc.Keys[0].Network + seed := bip39.MnemonicSeed(m, pass) + mk, err := hdkeychain.NewMaster(seed, network) + if err != nil { + return 0, false + } + for i, k := range desc.Keys { + _, xpub, err := bip32.Derive(mk, k.DerivationPath) + if err != nil { + // A derivation that generates an invalid key is by itself very unlikely, + // but also means that the seed doesn't match this xpub. + continue + } + if k.String() == xpub.String() { + return i, true + } + } + return 0, false +} + +func deriveMasterKey(m bip39.Mnemonic, net *chaincfg.Params) (*hdkeychain.ExtendedKey, bool) { + seed := bip39.MnemonicSeed(m, "") + mk, err := hdkeychain.NewMaster(seed, net) + // Err is only non-nil if the seed generates an invalid key, or we made a mistake. + // According to [0] the odds of encountering a seed that generates + // an invalid key by chance is 1 in 2^127. + // + // [0] https://bitcoin.stackexchange.com/questions/53180/bip-32-seed-resulting-in-an-invalid-private-key + return mk, err == nil +} + +type ScanScreen struct { + Title string + Lead string +} + +func (s *ScanScreen) Scan(ctx *Context, ops op.Ctx) (any, bool) { + var ( + feed *image.Gray + cameraErr error + decoder QRDecoder + ) + inp := new(InputTracker) + for { + const cameraFrameScale = 3 + for { + e, ok := inp.Next(ctx, Button1, Button2) + if !ok { + break + } + if !inp.Clicked(e.Button) { + continue + } + switch e.Button { + case Button1: + return nil, false + case Button2: + ctx.RotateCamera = !ctx.RotateCamera + } + } + + dims := ctx.Platform.DisplaySize() + if feed == nil || dims != feed.Bounds().Size() { + feed = image.NewGray(image.Rectangle{Max: dims}) + } + ctx.Platform.CameraFrame(dims.Mul(cameraFrameScale)) + for { + f, ok := ctx.FrameEvent() + if !ok { + break + } + cameraErr = f.Error + if cameraErr == nil { + ycbcr := f.Image.(*image.YCbCr) + gray := &image.Gray{Pix: ycbcr.Y, Stride: ycbcr.YStride, Rect: ycbcr.Bounds()} + + scaleRot(feed, gray, ctx.RotateCamera) + // Re-create image (but not backing store) to ensure redraw. + copy := *feed + feed = © + results, _ := ctx.Platform.ScanQR(gray) + for _, res := range results { + if v, ok := decoder.parseQR(res); ok { + return v, true + } + } + } + } + th := &cameraTheme + r := layout.Rectangle{Max: dims} + + op.ImageOp(ops, feed) + + corners := assets.CameraCorners.For(image.Rect(0, 0, 132, 132)) + op.ImageOp(ops.Begin(), corners) + op.Position(ops, ops.End(), r.Center(corners.Bounds().Size())) + + underlay := assets.ButtonFocused + background := func(ops op.Ctx, w op.CallOp, dst image.Rectangle, pos image.Point) { + op.MaskOp(ops.Begin(), underlay.For(dst)) + op.ColorOp(ops, color.NRGBA{A: theme.overlayMask}) + op.Position(ops, ops.End(), image.Point{}) + op.Position(ops, w, pos) + } + + title := layoutTitle(ctx, ops.Begin(), dims.X, th.Text, s.Title) + title.Min.Y += 4 + title.Max.Y -= 4 + background(ops, ops.End(), title, image.Point{}) + + // Camera error, if any. + if err := cameraErr; err != nil { + sz := widget.LabelW(ops.Begin(), ctx.Styles.body, dims.X-2*16, th.Text, err.Error()) + op.Position(ops, ops.End(), r.Center(sz)) + } + + width := dims.X - 2*8 + // Lead text. + sz := widget.LabelW(ops.Begin(), ctx.Styles.lead, width, th.Text, s.Lead) + top, footer := r.CutBottom(sz.Y + 2*12) + pos := footer.Center(sz) + background(ops, ops.End(), image.Rectangle{Min: pos, Max: pos.Add(sz)}, pos) + + // Progress + if progress := decoder.Progress(); progress > 0 { + sz = widget.LabelW(ops.Begin(), ctx.Styles.lead, width, th.Text, fmt.Sprintf("%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) { + nav := layoutNavigation(inp, ops.Begin(), th, dims, []NavButton{{Button: btn, Style: StyleSecondary, Icon: icn}}...) + nav = image.Rectangle(layout.Rectangle(nav).Shrink(underlay.Padding()).Shrink(-2, -4, -2, -2)) + background(ops, ops.End(), nav, image.Point{}) + } + nav(Button1, assets.IconBack) + nav(Button2, assets.IconFlip) + ctx.Frame() + } +} + +// scaleRot is a specialized function for fast scaling and rotation of +// the camera frames for display. +func scaleRot(dst, src *image.Gray, rot180 bool) { + db := dst.Bounds() + sb := src.Bounds() + if db.Empty() { + return + } + scale := sb.Dx() / db.Dx() + for y := 0; y < db.Dy(); y++ { + sx := sb.Max.X - 1 - y*scale + dy := db.Max.Y - y + if rot180 { + dy = y + db.Min.Y + } + for x := 0; x < db.Dx(); x++ { + sy := x*scale + sb.Min.Y + c := src.GrayAt(sx, sy) + dx := db.Max.X - 1 - x + if rot180 { + dx = x + db.Min.X + } + dst.SetGray(dx, dy, c) + } + } +} + +type QRDecoder struct { + decoder ur.Decoder + nsdecoder nonstandard.Decoder +} + +func (d *QRDecoder) Progress() int { + progress := int(100 * d.decoder.Progress()) + if progress == 0 { + progress = int(100 * d.nsdecoder.Progress()) + } + return progress +} + +func (d *QRDecoder) parseNonStandard(qr []byte) (any, bool) { + if err := d.nsdecoder.Add(string(qr)); err != nil { + d.nsdecoder = nonstandard.Decoder{} + return qr, true + } + enc := d.nsdecoder.Result() + if enc == nil { + return nil, false + } + return enc, true +} + +func (d *QRDecoder) parseQR(qr []byte) (any, bool) { + uqr := strings.ToUpper(string(qr)) + if !strings.HasPrefix(uqr, "UR:") { + d.decoder = ur.Decoder{} + return d.parseNonStandard(qr) + } + d.nsdecoder = nonstandard.Decoder{} + if err := d.decoder.Add(uqr); err != nil { + // Incompatible fragment. Reset decoder and try again. + d.decoder = ur.Decoder{} + d.decoder.Add(uqr) + } + typ, enc, err := d.decoder.Result() + if err != nil { + d.decoder = ur.Decoder{} + return nil, false + } + if enc == nil { + return nil, false + } + d.decoder = ur.Decoder{} + v, err := urtypes.Parse(typ, enc) + if err != nil { + return nil, true + } + return v, true +} + +type ErrorScreen struct { + Title string + Body string + w Warning + inp InputTracker +} + +func (s *ErrorScreen) Layout(ctx *Context, ops op.Ctx, th *Colors, dims image.Point) bool { + for { + e, ok := s.inp.Next(ctx, Button3) + if !ok { + break + } + switch e.Button { + case Button3: + if s.inp.Clicked(e.Button) { + return true + } + } + } + s.w.Layout(ctx, ops, th, dims, s.Title, s.Body) + layoutNavigation(&s.inp, ops, th, dims, []NavButton{{Button: Button3, Style: StylePrimary, Icon: assets.IconCheckmark}}...) + return false +} + +type ConfirmWarningScreen struct { + Title string + Body string + Icon image.RGBA64Image + + warning Warning + confirm ConfirmDelay + inp InputTracker +} + +type Warning struct { + scroll int + txtclip int + inp InputTracker +} + +type ConfirmResult int + +const ( + ConfirmNone ConfirmResult = iota + ConfirmNo + ConfirmYes +) + +type ConfirmDelay struct { + timeout time.Time +} + +func (c *ConfirmDelay) Start(ctx *Context, delay time.Duration) { + c.timeout = ctx.Platform.Now().Add(delay) +} + +func (c *ConfirmDelay) Progress(ctx *Context) float32 { + if c.timeout.IsZero() { + return 0. + } + now := ctx.Platform.Now() + d := c.timeout.Sub(now) + if d <= 0 { + return 1. + } + ctx.Platform.Wakeup() + return 1. - float32(d.Seconds()/confirmDelay.Seconds()) +} + +const confirmDelay = 1 * time.Second + +func (w *Warning) Layout(ctx *Context, ops op.Ctx, th *Colors, dims image.Point, title, txt string) image.Point { + for { + e, ok := w.inp.Next(ctx, Up, Down) + if !ok { + break + } + switch e.Button { + case Up: + if e.Pressed { + w.scroll -= w.txtclip / 2 + } + case Down: + if e.Pressed { + w.scroll += w.txtclip / 2 + } + } + } + const btnMargin = 4 + const boxMargin = 6 + + op.ColorOp(ops, color.NRGBA{A: theme.overlayMask}) + + wbbg := assets.WarningBoxBg + wbout := assets.WarningBoxBorder + ptop, pend, pbottom, pstart := wbbg.Padding() + r := image.Rectangle{ + Min: image.Pt(pstart+boxMargin, ptop+boxMargin), + Max: image.Pt(dims.X-pend-boxMargin, dims.Y-pbottom-boxMargin), + } + box := wbbg.For(r) + op.MaskOp(ops, box) + op.ColorOp(ops, th.Background) + op.MaskOp(ops, wbout.For(r)) + op.ColorOp(ops, th.Text) + + btnOff := assets.NavBtnPrimary.Bounds().Dx() + btnMargin + titlesz := widget.LabelW(ops.Begin(), ctx.Styles.warning, dims.X-btnOff*2, th.Text, strings.ToTitle(title)) + titlew := ops.End() + op.Position(ops, titlew, image.Pt((dims.X-titlesz.X)/2, r.Min.Y)) + + bodyClip := image.Rectangle{ + Min: image.Pt(pstart+boxMargin, ptop+titlesz.Y), + Max: image.Pt(dims.X-btnOff, dims.Y-pbottom-boxMargin), + } + bodysz := widget.LabelW(ops.Begin(), ctx.Styles.body, bodyClip.Dx(), th.Text, txt) + body := ops.End() + innerCtx := ops.Begin() + w.txtclip = bodyClip.Dy() + maxScroll := bodysz.Y - (bodyClip.Dy() - 2*scrollFadeDist) + if w.scroll > maxScroll { + w.scroll = maxScroll + } + if w.scroll < 0 { + w.scroll = 0 + } + op.Position(innerCtx, body, image.Pt(bodyClip.Min.X, bodyClip.Min.Y+scrollFadeDist-w.scroll)) + fadeClip(ops, ops.End(), image.Rectangle(bodyClip)) + + return box.Bounds().Size() +} + +func (s *ConfirmWarningScreen) Layout(ctx *Context, ops op.Ctx, th *Colors, dims image.Point) ConfirmResult { + var progress float32 + for { + progress = s.confirm.Progress(ctx) + if progress == 1 { + return ConfirmYes + } + e, ok := s.inp.Next(ctx, Button3, Button1) + if !ok { + break + } + switch e.Button { + case Button1: + if s.inp.Clicked(e.Button) { + return ConfirmNo + } + case Button3: + if e.Pressed { + s.confirm.Start(ctx, confirmDelay) + } else { + s.confirm = ConfirmDelay{} + } + } + } + s.warning.Layout(ctx, ops, th, dims, s.Title, s.Body) + layoutNavigation(&s.inp, ops, th, dims, []NavButton{ + {Button: Button1, Style: StyleSecondary, Icon: assets.IconBack}, + {Button: Button3, Style: StylePrimary, Icon: s.Icon, Progress: progress}, + }...) + return ConfirmNone +} + +type ProgressImage struct { + Progress float32 + Src image.RGBA64Image +} + +func (p ProgressImage) ColorModel() color.Model { + return color.AlphaModel +} + +func (p ProgressImage) Bounds() image.Rectangle { + return p.Src.Bounds() +} + +func (p ProgressImage) At(x, y int) color.Color { + return p.RGBA64At(x, y) +} + +func (p ProgressImage) RGBA64At(x, y int) color.RGBA64 { + c := p.Bounds().Max.Add(p.Bounds().Min).Div(2) + d := image.Pt(x, y).Sub(c) + angle := float32(math.Atan2(float64(d.X), float64(d.Y))) + angle = math.Pi - angle + if angle > 2*math.Pi*p.Progress { + return color.RGBA64{} + } + return p.Src.RGBA64At(x, y) +} + +type errDuplicateKey struct { + Fingerprint uint32 +} + +func (e *errDuplicateKey) Error() string { + return fmt.Sprintf("descriptor contains a duplicate share: %.8x", e.Fingerprint) +} + +func (e *errDuplicateKey) Is(target error) bool { + _, ok := target.(*errDuplicateKey) + return ok +} + +func NewErrorScreen(err error) *ErrorScreen { + var errDup *errDuplicateKey + switch { + case errors.As(err, &errDup): + return &ErrorScreen{ + Title: "Duplicated Share", + Body: fmt.Sprintf("The share %.8x is listed more than once in the wallet.", errDup.Fingerprint), + } + case errors.Is(err, backup.ErrDescriptorTooLarge): + return &ErrorScreen{ + Title: "Too Large", + Body: "The descriptor cannot fit any plate size.", + } + default: + return &ErrorScreen{ + Title: "Error", + Body: err.Error(), + } + } +} + +func validateDescriptor(params engrave.Params, desc urtypes.OutputDescriptor) error { + keys := make(map[string]bool) + for _, k := range desc.Keys { + xpub := k.String() + if keys[xpub] { + return &errDuplicateKey{ + Fingerprint: k.MasterFingerprint, + } + } + keys[xpub] = true + } + // Do a dummy engrave to see whether the backup fits any plate. + descPlate := backup.Descriptor{ + Descriptor: desc, + KeyIdx: 0, + Font: constant.Font, + Size: backup.LargePlate, + } + _, err := backup.EngraveDescriptor(params, descPlate) + if err != nil { + return err + } + // Verify that every permutation of desc.Threshold shares can recover the + // descriptor. Note that this is impossible by construction and by exhaustive + // tests, but it's good to be paranoid. + if !backup.Recoverable(desc) { + return errors.New("Descriptor is not recoverable. This is a bug in the program; please report it.") + } + return nil +} + +type Plate struct { + Size backup.PlateSize + MasterFingerprint uint32 + Sides []engrave.Plan +} + +func engraveSeed(sizes []backup.PlateSize, params engrave.Params, m bip39.Mnemonic) (Plate, error) { + mfp, err := masterFingerprintFor(m, &chaincfg.MainNetParams) + if err != nil { + return Plate{}, err + } + var lastErr error + for _, sz := range sizes { + seedDesc := backup.Seed{ + KeyIdx: 0, + Mnemonic: m, + Keys: 1, + MasterFingerprint: mfp, + Font: constant.Font, + Size: sz, + } + seedSide, err := backup.EngraveSeed(params, seedDesc) + if err != nil { + lastErr = err + continue + } + return Plate{ + Sides: []engrave.Plan{seedSide}, + Size: sz, + MasterFingerprint: mfp, + }, nil + } + return Plate{}, lastErr +} + +func masterFingerprintFor(m bip39.Mnemonic, network *chaincfg.Params) (uint32, error) { + mk, ok := deriveMasterKey(m, network) + if !ok { + return 0, errors.New("failed to derive mnemonic master key") + } + mfp, _, err := bip32.Derive(mk, urtypes.Path{0}) + if err != nil { + return 0, err + } + return mfp, nil +} + +func engravePlate(sizes []backup.PlateSize, params engrave.Params, desc urtypes.OutputDescriptor, keyIdx int, m bip39.Mnemonic) (Plate, error) { + mfp, err := masterFingerprintFor(m, desc.Keys[keyIdx].Network) + if err != nil { + return Plate{}, err + } + var lastErr error + for _, sz := range sizes { + descPlate := backup.Descriptor{ + Descriptor: desc, + KeyIdx: keyIdx, + Font: constant.Font, + Size: sz, + } + descSide, err := backup.EngraveDescriptor(params, descPlate) + if err != nil { + lastErr = err + continue + } + seedDesc := backup.Seed{ + Title: desc.Title, + KeyIdx: keyIdx, + Mnemonic: m, + Keys: len(desc.Keys), + MasterFingerprint: mfp, + Font: constant.Font, + Size: sz, + } + seedSide, err := backup.EngraveSeed(params, seedDesc) + if err != nil { + lastErr = err + continue + } + return Plate{ + Size: sz, + MasterFingerprint: mfp, + Sides: []engrave.Plan{descSide, seedSide}, + }, nil + } + return Plate{}, lastErr +} + +func plateImage(p backup.PlateSize) image.RGBA64Image { + switch p { + case backup.SquarePlate: + return assets.Sh02 + case backup.LargePlate: + return assets.Sh03 + default: + panic("unsupported plate") + } +} + +func plateName(p backup.PlateSize) string { + switch p { + case backup.SquarePlate: + return "SH02" + case backup.LargePlate: + return "SH03" + default: + panic("unsupported plate") + } +} + +type InstructionType int + +const ( + PrepareInstruction InstructionType = iota + ConnectInstruction + EngraveInstruction +) + +type Instruction struct { + Body string + Lead string + Type InstructionType + Side int + Image image.RGBA64Image + + resolvedBody string +} + +var ( + EngraveFirstSideA = []Instruction{ + { + Body: "Make sure the fingerprint above represents the intended share.", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#1", + }, + { + Body: "Turn off the engraver and disconnect it from this device.", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#2", + }, + { + Body: "Manually move the hammerhead to the far upper left position.", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#3", + }, + { + Body: "Place a {{.Name}} on the machine.", + Image: assets.Sh02, + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#4", + }, + { + Body: "Tighten the nuts firmly.", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#4", + }, + { + Body: "Loosen the hammerhead finger screw. Adjust needle distance to ~1.5 mm above the plate.", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#5", + }, + { + Body: "Tighten the hammerhead finger screw and make sure the depth selector is set to \"Strong\".", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#6", + }, + { + Body: "Turn on the engraving machine and connect this device via the middle port.", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#7", + }, + { + Body: "Hold button to start the engraving process. The process is loud, use hearing protection.", + Type: ConnectInstruction, + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#8", + }, + { + Lead: "Engraving plate", + Type: EngraveInstruction, + Side: 0, + }, + } + + EngraveSideA = []Instruction{ + { + Body: "Make sure the fingerprint above represents the intended share.", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#1", + }, + { + Body: "Place a {{.Name}} on the machine.", + Image: assets.Sh02, + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#4", + }, + { + Body: "Tighten the nuts firmly.", + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#4", + }, + { + Body: "Hold button to start the engraving process. The process is loud, use hearing protection.", + Type: ConnectInstruction, + Lead: "github.com/mineracks/seedhammer-v1-companion/tip#8", + }, + { + Lead: "Engraving plate", + Type: EngraveInstruction, + Side: 0, + }, + } + + EngraveSideB = []Instruction{ + { + Body: "Unscrew the 4 nuts and flip the top metal plate horizontally.", + }, + { + Body: "Tighten the nuts firmly.", + }, + { + Body: "Hold button to start the engraving process. The process is loud, use hearing protection.", + Type: ConnectInstruction, + }, + { + Lead: "Engraving plate", + Type: EngraveInstruction, + Side: 1, + }, + } + + EngraveSuccess = []Instruction{ + { + Body: "Engraving completed successfully.", + }, + } +) + +func isEmptyMnemonic(m bip39.Mnemonic) bool { + for _, w := range m { + if w != -1 { + return false + } + } + return true +} + +func emptyMnemonic(nwords int) bip39.Mnemonic { + m := make(bip39.Mnemonic, nwords) + for i := range m { + m[i] = -1 + } + return m +} + +const scrollFadeDist = 16 + +func fadeClip(ops op.Ctx, w op.CallOp, r image.Rectangle) { + op.MaskOp(ops, scrollMask(r)) + op.Position(ops, w, image.Pt(0, 0)) +} + +type scrollMask image.Rectangle + +func (n scrollMask) At(x, y int) color.Color { + return n.RGBA64At(x, y) +} + +func (n scrollMask) RGBA64At(x, y int) color.RGBA64 { + alpha := 0xffff + b := n.Bounds() + if d := y - b.Min.Y; d < scrollFadeDist { + alpha = 0xffff * d / scrollFadeDist + } else if d := b.Max.Y - y; d < scrollFadeDist { + alpha = 0xffff * d / scrollFadeDist + } + a16 := uint16(alpha) + return color.RGBA64{A: a16} +} + +func (n scrollMask) Bounds() image.Rectangle { + return image.Rectangle(n) +} + +func (_ scrollMask) ColorModel() color.Model { + return color.AlphaModel +} + +func inputWordsFlow(ctx *Context, ops op.Ctx, th *Colors, mnemonic bip39.Mnemonic, selected int) { + kbd := NewKeyboard(ctx) + inp := new(InputTracker) + for { + for { + kbd.Update(ctx) + e, ok := inp.Next(ctx, Button1, Button2) + if !ok { + break + } + switch e.Button { + case Button1: + if inp.Clicked(e.Button) { + return + } + case Button2: + if !inp.Clicked(e.Button) { + break + } + w, complete := kbd.Complete() + if !complete { + break + } + kbd.Clear() + mnemonic[selected] = w + for { + selected++ + if selected == len(mnemonic) { + return + } + if mnemonic[selected] == -1 { + break + } + } + } + } + dims := ctx.Platform.DisplaySize() + completedWord, complete := kbd.Complete() + op.ColorOp(ops, th.Background) + layoutTitle(ctx, ops, dims.X, th.Text, "Input Words") + + screen := layout.Rectangle{Max: dims} + _, content := screen.CutTop(leadingSize) + content, _ = content.CutBottom(8) + + kbdsz := kbd.Layout(ctx, ops.Begin(), th) + op.Position(ops, ops.End(), content.S(kbdsz)) + + layoutWord := func(ops op.Ctx, n int, word string) image.Point { + style := ctx.Styles.word + txt := fmt.Sprintf("%2d: %s", n, word) + return widget.Label(ops, style, th.Background, txt) + } + + longest := layoutWord(op.Ctx{}, 24, longestWord) + hint := kbd.Word + if complete { + hint = strings.ToUpper(bip39.LabelFor(completedWord)) + } + layoutWord(ops.Begin(), selected+1, hint) + word := ops.End() + r := image.Rectangle{Max: longest} + r.Min.Y -= 3 + op.MaskOp(ops.Begin(), assets.ButtonFocused.For(r)) + op.ColorOp(ops, th.Text) + word.Add(ops) + top, _ := content.CutBottom(kbdsz.Y) + op.Position(ops, ops.End(), top.Center(longest)) + + layoutNavigation(inp, ops, th, dims, []NavButton{{Button: Button1, Style: StyleSecondary, Icon: assets.IconBack}}...) + if complete { + layoutNavigation(inp, ops, th, dims, []NavButton{{Button: Button2, Style: StylePrimary, Icon: assets.IconCheckmark}}...) + } + ctx.Frame() + } +} + +var kbdKeys = [...][]rune{ + []rune("QWERTYUIOP"), + []rune("ASDFGHJKL"), + []rune("ZXCVBNM⌫"), +} + +type Keyboard struct { + Word string + + nvalid int + positions [len(kbdKeys)][]image.Point + bginact image.Image + bgact image.Image + bsinact image.Image + bsact image.Image + widest image.Point + backspace image.Point + size image.Point + + mask uint32 + row, col int + inp InputTracker +} + +func NewKeyboard(ctx *Context) *Keyboard { + k := new(Keyboard) + _, k.widest = ctx.Styles.keyboard.Layout(math.MaxInt, "W") + bsb := assets.KeyBackspace.Bounds() + bsWidth := bsb.Min.X*2 + bsb.Dx() + k.backspace = image.Pt(bsWidth, k.widest.Y) + k.bginact = assets.Key.For(image.Rectangle{Max: k.widest}) + k.bgact = assets.KeyActive.For(image.Rectangle{Max: k.widest}) + k.bsinact = assets.Key.For(image.Rectangle{Max: k.backspace}) + k.bsact = assets.KeyActive.For(image.Rectangle{Max: k.backspace}) + bgbnds := k.bginact.Bounds() + const margin = 2 + bgsz := bgbnds.Size().Add(image.Pt(margin, margin)) + longest := 0 + for _, row := range kbdKeys { + if n := len(row); n > longest { + longest = n + } + } + maxw := longest*bgsz.X - margin + for i, row := range kbdKeys { + n := len(row) + if i == len(kbdKeys)-1 { + // Center row without the backspace key. + n-- + } + w := bgsz.X*n - margin + off := image.Pt((maxw-w)/2, 0) + for j := range row { + pos := image.Pt(j*bgsz.X, i*bgsz.Y) + pos = pos.Add(off) + pos = pos.Sub(bgbnds.Min) + k.positions[i] = append(k.positions[i], pos) + } + } + k.size = image.Point{ + X: maxw, + Y: len(kbdKeys)*bgsz.Y - margin, + } + k.Clear() + return k +} + +func (k *Keyboard) Complete() (bip39.Word, bool) { + word := strings.ToLower(k.Word) + w, ok := bip39.ClosestWord(word) + if !ok { + return -1, false + } + // The word is complete if it's in the word list or is the only option. + return w, k.nvalid == 1 || word == bip39.LabelFor(w) +} + +func (k *Keyboard) Clear() { + k.Word = "" + k.updateMask() + k.row = len(kbdKeys) / 2 + k.col = len(kbdKeys[k.row]) / 2 + k.adjust(false) +} + +func (k *Keyboard) updateMask() { + k.mask = ^uint32(0) + word := strings.ToLower(k.Word) + w, valid := bip39.ClosestWord(word) + if !valid { + return + } + k.nvalid = 0 + for ; w < bip39.NumWords; w++ { + bip39w := bip39.LabelFor(w) + if !strings.HasPrefix(bip39w, word) { + break + } + k.nvalid++ + suffix := bip39w[len(word):] + if len(suffix) > 0 { + r := rune(strings.ToUpper(suffix)[0]) + idx, valid := k.idxForRune(r) + if !valid { + panic("valid by construction") + } + k.mask &^= 1 << idx + } + } + if k.nvalid == 1 { + k.mask = ^uint32(0) + } +} + +func (k *Keyboard) idxForRune(r rune) (int, bool) { + idx := int(r - 'A') + if idx < 0 || idx >= 32 { + return 0, false + } + return idx, true +} + +func (k *Keyboard) Valid(r rune) bool { + if r == '⌫' { + return len(k.Word) > 0 + } + idx, valid := k.idxForRune(r) + return valid && k.mask&(1< 0 { + s.choice-- + } + } + case Down, CW: + if e.Pressed { + if s.choice < len(s.Choices)-1 { + s.choice++ + } + } + } + } + + dims := ctx.Platform.DisplaySize() + s.Draw(ctx, ops, th, dims) + + layoutNavigation(inp, ops, th, dims, []NavButton{ + {Button: Button1, Style: StyleSecondary, Icon: assets.IconBack}, + {Button: Button3, Style: StylePrimary, Icon: assets.IconCheckmark}, + }...) + ctx.Frame() + } +} + +func (s *ChoiceScreen) Draw(ctx *Context, ops op.Ctx, th *Colors, dims image.Point) { + r := layout.Rectangle{Max: dims} + op.ColorOp(ops, th.Background) + + layoutTitle(ctx, ops, dims.X, th.Text, s.Title) + + _, bottom := r.CutTop(leadingSize) + sz := widget.LabelW(ops.Begin(), ctx.Styles.lead, dims.X-2*8, th.Text, s.Lead) + content, lead := bottom.CutBottom(leadingSize) + op.Position(ops, ops.End(), lead.Center(sz)) + + content = content.Shrink(16, 0, 16, 0) + + children := make([]struct { + Size image.Point + W op.CallOp + }, len(s.Choices)) + maxW := 0 + for i, c := range s.Choices { + style := ctx.Styles.button + col := th.Text + if i == s.choice { + col = th.Background + } + sz := widget.Label(ops.Begin(), style, col, 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 == s.choice { + bg := image.Rectangle{Max: c.Size} + bg.Min.X -= xoff + bg.Max.X += xoff + op.MaskOp(inner.Begin(), assets.ButtonFocused.For(bg)) + 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))) +} + +func mainFlow(ctx *Context, ops op.Ctx) { + var page program + inp := new(InputTracker) + for { + dims := ctx.Platform.DisplaySize() + events: + for { + e, ok := inp.Next(ctx, Button3, Center, Left, Right) + if !ok { + break + } + switch e.Button { + case Button3, Center: + if !inp.Clicked(e.Button) { + break + } + ws := &ConfirmWarningScreen{ + Title: "Remove SD card", + Body: "Remove SD card to continue.\n\nHold button to ignore this warning.", + Icon: assets.IconRight, + } + th := mainScreenTheme(page) + loop: + for !ctx.EmptySDSlot { + res := ws.Layout(ctx, ops.Begin(), th, dims) + dialog := ops.End() + switch res { + case ConfirmYes: + break loop + case ConfirmNo: + continue events + } + drawMainScreen(ctx, ops, dims, page) + dialog.Add(ops) + ctx.Frame() + } + ctx.EmptySDSlot = true + switch page { + case backupWallet: + backupWalletFlow(ctx, ops, th) + } + case Left: + if !e.Pressed { + break + } + page-- + if page < 0 { + page = backupWallet + } + case Right: + if !e.Pressed { + break + } + page++ + if page > backupWallet { + page = 0 + } + } + } + drawMainScreen(ctx, ops, dims, page) + layoutNavigation(inp, ops, mainScreenTheme(page), dims, []NavButton{ + {Button: Button3, Style: StylePrimary, Icon: assets.IconCheckmark}, + }...) + ctx.Frame() + } +} + +func mainScreenTheme(page program) *Colors { + switch page { + case backupWallet: + return &descriptorTheme + default: + panic("invalid page") + } +} + +func drawMainScreen(ctx *Context, ops op.Ctx, dims image.Point, page program) { + var th *Colors + var title string + th = mainScreenTheme(page) + switch page { + case backupWallet: + title = "Backup Wallet" + } + op.ColorOp(ops, th.Background) + + layoutTitle(ctx, ops, dims.X, th.Text, title) + + r := layout.Rectangle{Max: dims} + sz := layoutMainPage(ops.Begin(), th, dims.X, page) + op.Position(ops, ops.End(), r.Center(sz)) + + sz = layoutMainPager(ops.Begin(), th, page) + _, footer := r.CutBottom(leadingSize) + op.Position(ops, ops.End(), footer.Center(sz)) + + versz := widget.LabelW(ops.Begin(), ctx.Styles.debug, 100, th.Text, ctx.Version) + op.Position(ops, ops.End(), r.SE(versz.Add(image.Pt(4, 0)))) + shsz := widget.LabelW(ops.Begin(), ctx.Styles.debug, 100, th.Text, "SeedHammer") + op.Position(ops, ops.End(), r.SW(shsz).Add(image.Pt(3, 0))) +} + +func layoutTitle(ctx *Context, ops op.Ctx, width int, col color.NRGBA, title string) image.Rectangle { + const margin = 8 + sz := widget.LabelW(ops.Begin(), ctx.Styles.title, width-2*16, col, title) + pos := image.Pt((width-sz.X)/2, margin) + op.Position(ops, ops.End(), pos) + return image.Rectangle{ + Min: pos, + Max: pos.Add(sz), + } +} + +type ButtonStyle int + +const ( + StyleNone ButtonStyle = iota + StyleSecondary + StylePrimary +) + +type NavButton struct { + Button Button + Style ButtonStyle + Icon image.Image + Progress float32 +} + +func layoutNavigation(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) { + if b.Style == StyleNone { + return + } + switch b.Style { + case StyleSecondary: + op.MaskOp(ops, assets.NavBtnPrimary) + op.ColorOp(ops, th.Background) + op.MaskOp(ops, assets.NavBtnSecondary) + op.ColorOp(ops, th.Text) + case StylePrimary: + op.MaskOp(ops, assets.NavBtnPrimary) + op.ColorOp(ops, th.Primary) + } + icn := b.Icon + if b.Progress > 0 { + icn = ProgressImage{ + Progress: b.Progress, + Src: assets.IconProgress, + } + } + op.MaskOp(ops, icn) + switch b.Style { + case StyleSecondary: + op.ColorOp(ops, th.Text) + case StylePrimary: + op.ColorOp(ops, th.Text) + } + if b.Progress == 0 && inp.Pressed[b.Button] { + op.MaskOp(ops, assets.NavBtnPrimary) + op.ColorOp(ops, color.NRGBA{A: theme.activeMask}) + } + } + btnsz := assets.NavBtnPrimary.Bounds().Size() + ys := [3]int{ + leadingSize, + (dims.Y - btnsz.Y) / 2, + dims.Y - leadingSize - btnsz.Y, + } + var r image.Rectangle + for _, b := range btns { + idx := int(b.Button - Button1) + button(ops.Begin(), b) + y := ys[idx] + pos := image.Pt(dims.X-btnsz.X, y) + op.Position(ops, ops.End(), pos) + r = r.Union(image.Rectangle{ + Min: pos, + Max: pos.Add(navsz), + }) + } + return r +} + +func layoutMainPage(ops op.Ctx, th *Colors, width int, page program) image.Point { + var h layout.Align + + op.MaskOp(ops.Begin(), assets.ArrowLeft) + op.ColorOp(ops, th.Text) + left := ops.End() + leftsz := h.Add(assets.ArrowLeft.Bounds().Size()) + + op.MaskOp(ops.Begin(), assets.ArrowRight) + op.ColorOp(ops, th.Text) + right := ops.End() + rightsz := h.Add(assets.ArrowRight.Bounds().Size()) + + contentsz := h.Add(layoutMainPlates(ops.Begin(), page)) + content := ops.End() + + const margin = 16 + + op.Position(ops, content, image.Pt((width-contentsz.X)/2, 8+h.Y(contentsz))) + const npage = int(backupWallet) + 1 + if npage > 1 { + op.Position(ops, left, image.Pt(margin, h.Y(leftsz))) + op.Position(ops, right, image.Pt(width-margin-rightsz.X, h.Y(rightsz))) + } + + return image.Pt(width, h.Size.Y) +} + +func layoutMainPlates(ops op.Ctx, page program) image.Point { + switch page { + case backupWallet: + img := assets.Hammer + op.ImageOp(ops, img) + return img.Bounds().Size() + } + panic("invalid page") +} + +func layoutMainPager(ops op.Ctx, th *Colors, page program) image.Point { + const npages = int(backupWallet) + 1 + const space = 4 + if npages <= 1 { + return image.Point{} + } + sz := assets.CircleFilled.Bounds().Size() + for i := 0; i < npages; i++ { + op.Offset(ops, image.Pt((sz.X+space)*i, 0)) + mask := assets.Circle + if i == int(page) { + mask = assets.CircleFilled + } + op.MaskOp(ops, mask) + op.ColorOp(ops, th.Text) + } + return image.Pt((sz.X+space)*npages-space, sz.Y) +} + +func backupWalletFlow(ctx *Context, ops op.Ctx, th *Colors) { + mnemonic, ok := newMnemonicFlow(ctx, ops, th) + if !ok { + return + } + ss := new(SeedScreen) + for { + if !ss.Confirm(ctx, ops, th, mnemonic) { + return + } + desc, ok := inputDescriptorFlow(ctx, ops, th, mnemonic) + if !ok { + continue + } + if desc == nil { + plate, err := engraveSeed(ctx.Platform.PlateSizes(), ctx.Platform.EngraverParams(), mnemonic) + if err != nil { + errScr := NewErrorScreen(err) + for { + dims := ctx.Platform.DisplaySize() + dismissed := errScr.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + if dismissed { + break + } + ss.Draw(ctx, ops, th, dims, mnemonic) + d.Add(ops) + ctx.Frame() + } + continue + } + completed := NewEngraveScreen(ctx, plate).Engrave(ctx, ops, &engraveTheme) + if completed { + return + } + continue + } + + ds := &DescriptorScreen{ + Descriptor: *desc, + Mnemonic: mnemonic, + } + for { + keyIdx, ok := ds.Confirm(ctx, ops, th) + if !ok { + break + } + plate, err := engravePlate(ctx.Platform.PlateSizes(), ctx.Platform.EngraverParams(), *desc, keyIdx, mnemonic) + if err != nil { + errScr := NewErrorScreen(err) + for { + dims := ctx.Platform.DisplaySize() + dismissed := errScr.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + if dismissed { + break + } + ss.Draw(ctx, ops, th, dims, mnemonic) + d.Add(ops) + ctx.Frame() + } + continue + } + completed := NewEngraveScreen(ctx, plate).Engrave(ctx, ops, &engraveTheme) + if completed { + return + } + } + } +} + +func newMnemonicFlow(ctx *Context, ops op.Ctx, th *Colors) (bip39.Mnemonic, bool) { + cs := &ChoiceScreen{ + Title: "Input Seed", + Lead: "Choose input method", + Choices: []string{"KEYBOARD", "CAMERA"}, + } + showErr := func(errScreen *ErrorScreen) { + for { + dims := ctx.Platform.DisplaySize() + dismissed := errScreen.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + if dismissed { + break + } + cs.Draw(ctx, ops, th, dims) + d.Add(ops) + ctx.Frame() + } + } +outer: + for { + choice, ok := cs.Choose(ctx, ops, th) + if !ok { + return nil, false + } + switch choice { + case 0: // Keyboard. + cs := &ChoiceScreen{ + Title: "Input Seed", + Lead: "Choose number of words", + Choices: []string{"12 WORDS", "24 WORDS"}, + } + for { + choice, ok := cs.Choose(ctx, ops, th) + if !ok { + continue outer + } + mnemonic := emptyMnemonic([]int{12, 24}[choice]) + inputWordsFlow(ctx, ops, th, mnemonic, 0) + if !isEmptyMnemonic(mnemonic) { + return mnemonic, true + } + } + case 1: // Camera. + res, ok := (&ScanScreen{ + Title: "Scan", + Lead: "SeedQR or Mnemonic", + }).Scan(ctx, ops) + if !ok { + continue + } + if b, ok := res.([]byte); ok { + if sqr, ok := seedqr.Parse(b); ok { + res = sqr + } else if sqr, err := bip39.ParseMnemonic(strings.ToLower(string(b))); err == nil || errors.Is(err, bip39.ErrInvalidChecksum) { + res = sqr + } + } + seed, ok := res.(bip39.Mnemonic) + if !ok { + showErr(&ErrorScreen{ + Title: "Invalid Seed", + Body: "The scanned data does not represent a seed.", + }) + continue + } + return seed, true + } + } +} + +type SeedScreen struct { + selected int +} + +func (s *SeedScreen) Confirm(ctx *Context, ops op.Ctx, th *Colors, mnemonic bip39.Mnemonic) bool { + inp := new(InputTracker) + for { + events: + for { + e, ok := inp.Next(ctx, Button1, Button2, Center, Button3, Up, Down) + if !ok { + break + } + switch e.Button { + case Button1: + if !inp.Clicked(e.Button) { + break + } + if isEmptyMnemonic(mnemonic) { + return false + } + confirm := &ConfirmWarningScreen{ + Title: "Discard Seed?", + Body: "Going back will discard the seed.\n\nHold button to confirm.", + Icon: assets.IconDiscard, + } + for { + dims := ctx.Platform.DisplaySize() + res := confirm.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + switch res { + case ConfirmNo: + continue events + case ConfirmYes: + return false + } + s.Draw(ctx, ops, th, dims, mnemonic) + d.Add(ops) + ctx.Frame() + } + case Button2, Center: + if !inp.Clicked(e.Button) { + break + } + inputWordsFlow(ctx, ops, th, mnemonic, s.selected) + continue + case Button3: + if !inp.Clicked(e.Button) || !isMnemonicComplete(mnemonic) { + break + } + showErr := func(scr *ErrorScreen) { + for { + dims := ctx.Platform.DisplaySize() + dismissed := scr.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + if dismissed { + break + } + s.Draw(ctx, ops, th, dims, mnemonic) + d.Add(ops) + ctx.Frame() + } + } + if !mnemonic.Valid() { + scr := &ErrorScreen{ + Title: "Invalid Seed", + } + var words []string + for _, w := range mnemonic { + words = append(words, bip39.LabelFor(w)) + } + if nonstandard.ElectrumSeed(strings.Join(words, " ")) { + scr.Body = "Electrum seeds are not supported." + } else { + scr.Body = "The seed phrase is invalid.\n\nCheck the words and try again." + } + showErr(scr) + break + } + _, ok = deriveMasterKey(mnemonic, &chaincfg.MainNetParams) + if !ok { + showErr(&ErrorScreen{ + Title: "Invalid Seed", + Body: "The seed is invalid.", + }) + break + } + return true + case Down: + if e.Pressed && s.selected < len(mnemonic)-1 { + s.selected++ + } + case Up: + if e.Pressed && s.selected > 0 { + s.selected-- + } + } + } + + dims := ctx.Platform.DisplaySize() + s.Draw(ctx, ops, th, dims, mnemonic) + + layoutNavigation(inp, ops, th, dims, []NavButton{ + {Button: Button1, Style: StyleSecondary, Icon: assets.IconBack}, + {Button: Button2, Style: StyleSecondary, Icon: assets.IconEdit}, + }...) + if isMnemonicComplete(mnemonic) { + layoutNavigation(inp, ops, th, dims, []NavButton{ + {Button: Button3, Style: StylePrimary, Icon: assets.IconCheckmark}, + }...) + } + ctx.Frame() + } +} + +func isMnemonicComplete(m bip39.Mnemonic) bool { + for _, w := range m { + if w == -1 { + return false + } + } + return len(m) > 0 +} + +func (s *SeedScreen) Draw(ctx *Context, ops op.Ctx, th *Colors, dims image.Point, mnemonic bip39.Mnemonic) { + op.ColorOp(ops, th.Background) + layoutTitle(ctx, ops, dims.X, th.Text, "Confirm Seed") + + style := ctx.Styles.word + _, longestPrefix := style.Layout(math.MaxInt, "24: ") + layoutWord := func(ops op.Ctx, col color.NRGBA, n int, word string) image.Point { + prefix := widget.Label(ops.Begin(), style, col, fmt.Sprintf("%d: ", n)) + op.Position(ops, ops.End(), image.Pt(longestPrefix.X-prefix.X, 0)) + txt := widget.Label(ops.Begin(), style, col, word) + op.Position(ops, ops.End(), image.Pt(longestPrefix.X, 0)) + return image.Pt(longestPrefix.X+txt.X, txt.Y) + } + + y := 0 + longest := layoutWord(op.Ctx{}, color.NRGBA{}, 24, longestWord) + r := layout.Rectangle{Max: dims} + navw := assets.NavBtnPrimary.Bounds().Dx() + list := r.Shrink(leadingSize, 0, 0, 0) + content := list.Shrink(scrollFadeDist, navw, scrollFadeDist, navw) + lineHeight := longest.Y + 2 + linesPerPage := content.Dy() / lineHeight + scroll := s.selected - linesPerPage/2 + maxScroll := len(mnemonic) - linesPerPage + if scroll > maxScroll { + scroll = maxScroll + } + if scroll < 0 { + scroll = 0 + } + off := content.Min.Add(image.Pt(0, -scroll*lineHeight)) + { + ops := ops.Begin() + for i, w := range mnemonic { + ops.Begin() + col := th.Text + if i == s.selected { + col = th.Background + r := image.Rectangle{Max: longest} + r.Min.Y -= 3 + op.MaskOp(ops, assets.ButtonFocused.For(r)) + op.ColorOp(ops, th.Text) + } + word := strings.ToUpper(bip39.LabelFor(w)) + layoutWord(ops, col, i+1, word) + pos := image.Pt(0, y).Add(off) + op.Position(ops, ops.End(), pos) + y += lineHeight + } + } + fadeClip(ops, ops.End(), image.Rectangle(list)) +} + +func inputDescriptorFlow(ctx *Context, ops op.Ctx, th *Colors, mnemonic bip39.Mnemonic) (*urtypes.OutputDescriptor, bool) { + cs := &ChoiceScreen{ + Title: "Descriptor", + Lead: "Choose input method", + Choices: []string{"SCAN", "SKIP"}, + } + if ctx.LastDescriptor != nil { + if _, match := descriptorKeyIdx(*ctx.LastDescriptor, mnemonic, ""); match { + cs.Choices = append(cs.Choices, "RE-USE") + } + } + showErr := func(errScreen *ErrorScreen) { + for { + dims := ctx.Platform.DisplaySize() + dismissed := errScreen.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + if dismissed { + break + } + cs.Draw(ctx, ops, th, dims) + d.Add(ops) + ctx.Frame() + } + } + for { + choice, ok := cs.Choose(ctx, ops, th) + if !ok { + return nil, false + } + switch choice { + case 0: // Scan. + res, ok := (&ScanScreen{ + Title: "Scan", + Lead: "Wallet Output Descriptor", + }).Scan(ctx, ops) + if !ok { + continue + } + desc, ok := res.(urtypes.OutputDescriptor) + if !ok { + if b, isbytes := res.([]byte); isbytes { + d, err := nonstandard.OutputDescriptor(b) + desc, ok = d, err == nil + } + } + if !ok { + showErr(&ErrorScreen{ + Title: "Invalid Descriptor", + Body: "The scanned data does not represent a wallet output descriptor or XPUB key.", + }) + continue + } + if !address.Supported(desc) { + showErr(&ErrorScreen{ + Title: "Invalid Descriptor", + Body: "The scanned descriptor is not supported.", + }) + continue + } + if len(desc.Keys) == 1 && desc.Keys[0].MasterFingerprint == 0 { + mfp, _ := masterFingerprintFor(mnemonic, &chaincfg.MainNetParams) + desc.Keys[0].MasterFingerprint = mfp + } + desc.Title = backup.TitleString(constant.Font, desc.Title) + ctx.LastDescriptor = &desc + return &desc, true + case 1: // Skip descriptor. + return nil, true + case 2: // Re-use. + return ctx.LastDescriptor, true + } + } +} + +type DescriptorScreen struct { + Descriptor urtypes.OutputDescriptor + Mnemonic bip39.Mnemonic +} + +func (s *DescriptorScreen) Confirm(ctx *Context, ops op.Ctx, th *Colors) (int, bool) { + showErr := func(errScreen *ErrorScreen) { + for { + dims := ctx.Platform.DisplaySize() + dismissed := errScreen.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + if dismissed { + break + } + s.Draw(ctx, ops, th, dims) + d.Add(ops) + ctx.Frame() + } + } + inp := new(InputTracker) + for { + for { + e, ok := inp.Next(ctx, Button1, Button2, Button3) + if !ok { + break + } + switch e.Button { + case Button1: + if inp.Clicked(e.Button) { + return 0, false + } + case Button2: + if !inp.Clicked(e.Button) { + break + } + NewAddressesScreen(s.Descriptor).Show(ctx, ops, th) + case Button3: + if !inp.Clicked(e.Button) { + break + } + if err := validateDescriptor(ctx.Platform.EngraverParams(), s.Descriptor); err != nil { + showErr(NewErrorScreen(err)) + continue + } + keyIdx, ok := descriptorKeyIdx(s.Descriptor, s.Mnemonic, "") + if !ok { + // Passphrase protected seeds don't match the descriptor, so + // allow the user to ignore the mismatch. Don't allow this for + // multisig descriptors where we can't know which key the seed + // belongs to. + if len(s.Descriptor.Keys) == 1 { + confirm := &ConfirmWarningScreen{ + Title: "Unknown Wallet", + Body: "The wallet does not match the seed.\n\nIf it is passphrase protected, long press to confirm.", + Icon: assets.IconCheckmark, + } + loop: + for { + dims := ctx.Platform.DisplaySize() + res := confirm.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + switch res { + case ConfirmYes: + return 0, true + case ConfirmNo: + break loop + } + s.Draw(ctx, ops, th, dims) + d.Add(ops) + ctx.Frame() + } + } else { + showErr(&ErrorScreen{ + Title: "Unknown Wallet", + Body: "The wallet does not match the seed or is passphrase protected.", + }) + } + continue + } + return keyIdx, true + } + } + + dims := ctx.Platform.DisplaySize() + s.Draw(ctx, ops, th, dims) + layoutNavigation(inp, ops, th, dims, []NavButton{ + {Button: Button1, Style: StyleSecondary, Icon: assets.IconBack}, + {Button: Button2, Style: StyleSecondary, Icon: assets.IconInfo}, + {Button: Button3, Style: StylePrimary, Icon: assets.IconCheckmark}, + }...) + ctx.Frame() + } +} + +func (s *DescriptorScreen) Draw(ctx *Context, ops op.Ctx, th *Colors, dims image.Point) { + const infoSpacing = 8 + + desc := s.Descriptor + op.ColorOp(ops, th.Background) + + // Title. + r := layout.Rectangle{Max: dims} + layoutTitle(ctx, ops, dims.X, th.Text, "Confirm Wallet") + + btnw := assets.NavBtnPrimary.Bounds().Dx() + body := r.Shrink(leadingSize, btnw, 0, btnw) + + type linePos struct { + w op.CallOp + y int + } + var bodytxt richText + + bodyst := ctx.Styles.body + subst := ctx.Styles.subtitle + if desc.Title != "" { + bodytxt.Add(ops, subst, body.Dx(), th.Text, "Title") + bodytxt.Add(ops, bodyst, body.Dx(), th.Text, desc.Title) + bodytxt.Y += infoSpacing + } + bodytxt.Add(ops, subst, body.Dx(), th.Text, "Type") + var typetxt string + switch desc.Type { + case urtypes.Singlesig: + typetxt = "Singlesig" + default: + typetxt = fmt.Sprintf("%d-of-%d multisig", desc.Threshold, len(desc.Keys)) + } + if len(desc.Keys) > 0 && desc.Keys[0].Network != &chaincfg.MainNetParams { + typetxt += " (testnet)" + } + bodytxt.Add(ops, bodyst, body.Dx(), th.Text, typetxt) + bodytxt.Y += infoSpacing + bodytxt.Add(ops, subst, body.Dx(), th.Text, "Script") + bodytxt.Add(ops, bodyst, body.Dx(), th.Text, desc.Script.String()) + + ops.Begin() + for _, l := range bodytxt.Lines { + l.W.Add(ops) + } + op.Position(ops, ops.End(), body.Min.Add(image.Pt(0, scrollFadeDist))) +} + +func NewEngraveScreen(ctx *Context, plate Plate) *EngraveScreen { + var ins []Instruction + if !ctx.Calibrated { + ins = append(ins, EngraveFirstSideA...) + } else { + ins = append(ins, EngraveSideA...) + } + if len(plate.Sides) > 1 { + ins = append(ins, EngraveSideB...) + } + ins = append(ins, EngraveSuccess...) + s := &EngraveScreen{ + plate: plate, + instructions: ins, + } + for i, ins := range s.instructions { + repl := strings.NewReplacer( + "{{.Name}}", plateName(plate.Size), + ) + s.instructions[i].resolvedBody = repl.Replace(ins.Body) + // As a special case, the Sh02 image is a placeholder for the plate-specific image. + if ins.Image == assets.Sh02 { + s.instructions[i].Image = plateImage(plate.Size) + } + } + return s +} + +type EngraveScreen struct { + instructions []Instruction + plate Plate + + step int + dryRun struct { + timeout time.Time + enabled bool + } + engrave engraveState +} + +type engraveState struct { + dev Engraver + cancel chan struct{} + progress chan float32 + errs chan error + lastProgress float32 +} + +func (s *EngraveScreen) showError(ctx *Context, ops op.Ctx, th *Colors, errScr *ErrorScreen) { + for { + dims := ctx.Platform.DisplaySize() + dismissed := errScr.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + if dismissed { + break + } + s.draw(ctx, ops, th, dims) + d.Add(ops) + ctx.Frame() + } +} + +func (s *EngraveScreen) moveStep(ctx *Context, ops op.Ctx, th *Colors) bool { + ins := s.instructions[s.step] + if ins.Type == ConnectInstruction { + if s.engrave.dev != nil { + return false + } + s.engrave = engraveState{} + dev, err := ctx.Platform.Engraver() + if err != nil { + log.Printf("gui: failed to connect to engraver: %v", err) + s.showError(ctx, ops, th, &ErrorScreen{ + Title: "Connection Error", + Body: fmt.Sprintf("Ensure the engraver is turned on and verify that it is connected to the middle port of this device.\n\nError details: %v", err), + }) + return false + } + s.engrave.dev = dev + } + s.step++ + if s.step == len(s.instructions) { + return true + } + ins = s.instructions[s.step] + if ins.Type == EngraveInstruction { + plan := s.plate.Sides[ins.Side] + if s.dryRun.enabled { + plan = engrave.DryRun(plan) + } + totalDist := 0 + pen := image.Point{} + plan(func(cmd engrave.Command) { + totalDist += engrave.ManhattanDist(pen, cmd.Coord) + pen = cmd.Coord + }) + cancel := make(chan struct{}) + errs := make(chan error, 1) + progress := make(chan float32, 1) + s.engrave.cancel = cancel + s.engrave.errs = errs + s.engrave.progress = progress + dev := s.engrave.dev + wakeup := ctx.Platform.Wakeup + go func() { + defer wakeup() + defer dev.Close() + pplan := func(yield func(cmd engrave.Command)) { + dist := 0 + completed := 0 + pen := image.Point{} + plan(func(cmd engrave.Command) { + yield(cmd) + completed++ + dist += engrave.ManhattanDist(pen, cmd.Coord) + pen = cmd.Coord + // Don't spam the progress channel. + if completed%10 != 0 && dist < totalDist { + return + } + select { + case <-progress: + default: + } + p := float32(dist) / float32(totalDist) + progress <- p + wakeup() + }) + } + errs <- dev.Engrave(s.plate.Size, pplan, cancel) + }() + } + return false +} + +func (s *EngraveScreen) canPrev() bool { + return s.step > 0 && s.instructions[s.step-1].Type == PrepareInstruction +} + +func (s *EngraveScreen) Engrave(ctx *Context, ops op.Ctx, th *Colors) bool { + defer func() { + if s.engrave.cancel != nil { + close(s.engrave.cancel) + } + s.engrave = engraveState{} + }() + inp := new(InputTracker) + for { + loop: + for { + select { + case p := <-s.engrave.progress: + s.engrave.lastProgress = p + case err := <-s.engrave.errs: + s.engrave = engraveState{} + if err != nil { + log.Printf("gui: connection lost to engraver: %v", err) + s.step-- + s.showError(ctx, ops, th, &ErrorScreen{ + Title: "Connection Error", + Body: fmt.Sprintf("Turn off the engraver and disconnect this device from it. Wait 10 seconds, then turn on the engraver and reconnect.\n\nError details: %v", err), + }) + break + } + ctx.Calibrated = true + s.step++ + if s.step == len(s.instructions) { + return true + } + default: + break loop + } + } + + outer: + for { + ins := s.instructions[s.step] + if !s.dryRun.timeout.IsZero() { + now := ctx.Platform.Now() + d := s.dryRun.timeout.Sub(now) + if d <= 0 { + s.dryRun.timeout = time.Time{} + s.dryRun.enabled = !s.dryRun.enabled + } + } + e, ok := inp.Next(ctx, Button1, Button2, Button3) + if !ok { + break + } + switch e.Button { + case Button1: + if !inp.Clicked(e.Button) { + break + } + if s.canPrev() { + s.step-- + } else { + confirm := &ConfirmWarningScreen{ + Title: "Cancel?", + Body: "This will cancel the engraving process.\n\nHold button to confirm.", + Icon: assets.IconDiscard, + } + loop2: + for { + dims := ctx.Platform.DisplaySize() + res := confirm.Layout(ctx, ops.Begin(), th, dims) + d := ops.End() + switch res { + case ConfirmNo: + break loop2 + case ConfirmYes: + return false + } + s.draw(ctx, ops, th, dims) + d.Add(ops) + ctx.Frame() + } + } + case Button2: + if e.Pressed { + t := ctx.Platform.Now().Add(confirmDelay) + s.dryRun.timeout = t + ctx.WakeupAt(t) + } else { + s.dryRun.timeout = time.Time{} + } + case Button3: + switch ins.Type { + case ConnectInstruction: + if !e.Pressed { + continue + } + confirm := new(ConfirmDelay) + confirm.Start(ctx, confirmDelay) + inp.Pressed[e.Button] = false + for { + p := confirm.Progress(ctx) + if p == 1. { + break + } + for { + e, ok := inp.Next(ctx, Button3) + if !ok { + break + } + if e.Button == Button3 && !e.Pressed { + continue outer + } + } + dims := ctx.Platform.DisplaySize() + s.draw(ctx, ops, th, dims) + s.drawNav(inp, ops, th, dims, p) + ctx.Frame() + } + case EngraveInstruction: + continue + default: + if !inp.Clicked(e.Button) { + continue + } + } + if s.moveStep(ctx, ops, th) { + return true + } + } + } + + dims := ctx.Platform.DisplaySize() + s.draw(ctx, ops, th, dims) + s.drawNav(inp, ops, th, dims, 0) + + ctx.Frame() + } +} + +func (s *EngraveScreen) draw(ctx *Context, ops op.Ctx, th *Colors, dims image.Point) { + op.ColorOp(ops, th.Background) + layoutTitle(ctx, ops, dims.X, th.Text, fmt.Sprintf("Engrave Plate")) + + r := layout.Rectangle{Max: dims} + _, subt := r.CutTop(leadingSize) + subtsz := widget.Label(ops.Begin(), ctx.Styles.body, th.Text, fmt.Sprintf("%.8x", s.plate.MasterFingerprint)) + op.Position(ops, ops.End(), subt.N(subtsz).Sub(image.Pt(0, 4))) + + const margin = 8 + _, content := r.CutTop(leadingSize) + ins := s.instructions[s.step] + if ins.Type == EngraveInstruction { + progress := fmt.Sprintf("%d%%", int(s.engrave.lastProgress*100)) + _, content = subt.CutTop(subtsz.Y) + middle, _ := content.CutBottom(leadingSize) + op.Offset(ops, middle.Center(assets.ProgressCircle.Bounds().Size())) + op.MaskOp(ops, ProgressImage{ + Progress: s.engrave.lastProgress, + Src: assets.ProgressCircle, + }) + op.ColorOp(ops, th.Text) + sz := widget.Label(ops.Begin(), ctx.Styles.progress, th.Text, progress) + op.Position(ops, ops.End(), middle.Center(sz)) + } + content = content.Shrink(0, margin, 0, margin) + content, lead := content.CutBottom(leadingSize) + bodysz := widget.LabelW(ops.Begin(), ctx.Styles.lead, content.Dx(), th.Text, ins.resolvedBody) + if img := ins.Image; img != nil { + sz := img.Bounds().Size() + op.Offset(ops, image.Pt((bodysz.X-sz.X)/2, bodysz.Y)) + op.ImageOp(ops, img) + if sz.X > bodysz.X { + bodysz.X = sz.X + } + bodysz.Y += sz.Y + } + op.Position(ops, ops.End(), content.Center(bodysz)) + leadsz := widget.LabelW(ops.Begin(), ctx.Styles.lead, dims.X-2*margin, th.Text, ins.Lead) + op.Position(ops, ops.End(), lead.Center(leadsz)) + + progressw := dims.X * (s.step + 1) / len(s.instructions) + op.ClipOp(image.Rectangle{Max: image.Pt(progressw, 2)}).Add(ops) + op.ColorOp(ops, th.Text) + + if s.dryRun.enabled { + sz := widget.Label(ops.Begin(), ctx.Styles.debug, th.Text, "dry-run") + op.Position(ops, ops.End(), r.SE(sz).Sub(image.Pt(4, 0))) + } +} + +func (s *EngraveScreen) drawNav(inp *InputTracker, ops op.Ctx, th *Colors, dims image.Point, progress float32) { + icnBack := assets.IconBack + if s.canPrev() { + icnBack = assets.IconLeft + } + layoutNavigation(inp, ops, th, dims, []NavButton{{Button: Button1, Style: StyleSecondary, Icon: icnBack}}...) + ins := s.instructions[s.step] + switch ins.Type { + case EngraveInstruction: + case ConnectInstruction: + layoutNavigation(inp, ops, th, dims, []NavButton{{Button: Button3, Style: StylePrimary, Icon: assets.IconHammer, Progress: progress}}...) + default: + layoutNavigation(inp, ops, th, dims, []NavButton{{ + Button: Button3, + Style: StylePrimary, + Icon: assets.IconRight, + Progress: progress, + }}...) + } +} + +type Platform interface { + Events(deadline time.Time) []Event + Wakeup() + PlateSizes() []backup.PlateSize + Engraver() (Engraver, error) + EngraverParams() engrave.Params + CameraFrame(size image.Point) + Now() time.Time + DisplaySize() image.Point + // Dirty begins a refresh of the content + // specified by r. + Dirty(r image.Rectangle) error + // NextChunk returns the next chunk of the refresh. + NextChunk() (draw.RGBA64Image, bool) + ScanQR(qr *image.Gray) ([][]byte, error) + Debug() bool +} + +type Engraver interface { + Engrave(sz backup.PlateSize, plan engrave.Plan, quit <-chan struct{}) error + Close() +} + +type FrameEvent struct { + Error error + Image image.Image +} + +type Event struct { + typ int + data [4]uint32 + refs [2]any +} + +const ( + buttonEvent = 1 + iota + sdcardEvent + frameEvent +) + +type ButtonEvent struct { + Button Button + Pressed bool + // Rune is only valid if Button is Rune. + Rune rune +} + +type SDCardEvent struct { + Inserted bool +} + +type Button int + +const ( + Up Button = iota + Down + Left + Right + Center + Button1 + Button2 + Button3 + CCW + CW + // Synthetic keys only generated in debug mode. + Rune // Enter rune. +) + +func (b Button) String() string { + switch b { + case Up: + return "up" + case Down: + return "down" + case Left: + return "left" + case Right: + return "right" + case Center: + return "center" + case Button1: + return "b1" + case Button2: + return "b2" + case Button3: + return "b3" + case CCW: + return "ccw" + case CW: + return "cw" + case Rune: + return "rune" + default: + panic("invalid button") + } +} + +type App struct { + root op.Ops + ctx *Context + idle struct { + start time.Time + active bool + state saver.State + } +} + +func NewApp(pl Platform, version string) (*App, error) { + ctx := NewContext(pl) + ctx.Version = version + a := &App{ + ctx: ctx, + } + a.idle.start = pl.Now() + frameCh := make(chan struct{}) + ctx.Frame = func() { + frameCh <- struct{}{} + <-frameCh + } + go func() { + <-frameCh + mainFlow(ctx, a.root.Context()) + }() + return a, nil +} + +const idleTimeout = 3 * time.Minute + +func (a *App) Frame() { + wakeup := a.ctx.Wakeup + a.ctx.Reset() + now := a.ctx.Platform.Now() + for _, e := range a.ctx.Platform.Events(wakeup) { + a.idle.start = now + if se, ok := e.AsSDCard(); ok { + a.ctx.EmptySDSlot = !se.Inserted + } else { + a.ctx.Events(e) + } + wakeup = time.Time{} + } + a.ctx.WakeupAt(a.idle.start.Add(idleTimeout)) + idle := now.Sub(a.idle.start) >= idleTimeout + if a.idle.active != idle { + a.idle.active = idle + if idle { + a.idle.state = saver.State{} + } else { + // The screen saver has invalidated the cached + // frame content. + a.root = op.Ops{} + } + } + if a.idle.active { + a.idle.state.Draw(a.ctx.Platform) + return + } + dims := a.ctx.Platform.DisplaySize() + start := time.Now() + a.root.Reset() + a.ctx.Frame() + dirty := a.root.Clip(image.Rectangle{Max: dims}) + layoutTime := time.Now() + renderTime := time.Now() + if err := a.ctx.Platform.Dirty(dirty); err != nil { + panic(err) + } + for { + fb, ok := a.ctx.Platform.NextChunk() + if !ok { + break + } + a.root.Draw(fb) + } + drawTime := time.Now() + if a.ctx.Platform.Debug() { + log.Printf("frame: %v layout: %v render: %v draw: %v %v", + drawTime.Sub(start), layoutTime.Sub(start), renderTime.Sub(layoutTime), drawTime.Sub(renderTime), dirty) + } +} + +func rgb(c uint32) color.NRGBA { + return argb(0xff000000 | c) +} + +func argb(c uint32) color.NRGBA { + return color.NRGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)} +} + +func (f FrameEvent) Event() Event { + e := Event{typ: frameEvent} + e.refs[0] = f.Error + e.refs[1] = f.Image + return e +} + +func (b ButtonEvent) Event() Event { + pressed := uint32(0) + if b.Pressed { + pressed = 1 + } + e := Event{typ: buttonEvent} + e.data[0] = uint32(b.Button) + e.data[1] = pressed + e.data[2] = uint32(b.Rune) + return e +} + +func (s SDCardEvent) Event() Event { + e := Event{typ: sdcardEvent} + if s.Inserted { + e.data[0] = 1 + } + return e +} + +func (e Event) AsFrame() (FrameEvent, bool) { + if e.typ != frameEvent { + return FrameEvent{}, false + } + f := FrameEvent{} + if r := e.refs[0]; r != nil { + f.Error = r.(error) + } + if r := e.refs[1]; r != nil { + f.Image = r.(image.Image) + } + return f, true +} + +func (e Event) AsButton() (ButtonEvent, bool) { + if e.typ != buttonEvent { + return ButtonEvent{}, false + } + return ButtonEvent{ + Button: Button(e.data[0]), + Pressed: e.data[1] != 0, + Rune: rune(e.data[2]), + }, true +} + +func (e Event) AsSDCard() (SDCardEvent, bool) { + if e.typ != sdcardEvent { + return SDCardEvent{}, false + } + return SDCardEvent{ + Inserted: e.data[0] != 0, + }, true +} diff --git a/gui/gui_test.go b/gui/gui_test.go new file mode 100644 index 0000000..b1cf712 --- /dev/null +++ b/gui/gui_test.go @@ -0,0 +1,960 @@ +package gui + +import ( + "errors" + "fmt" + "image" + "image/draw" + "io" + "reflect" + "strings" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" + "github.com/kortschak/qr" + "github.com/mineracks/seedhammer-v1-companion/backup" + "github.com/mineracks/seedhammer-v1-companion/bc/urtypes" + "github.com/mineracks/seedhammer-v1-companion/bip32" + "github.com/mineracks/seedhammer-v1-companion/bip39" + "github.com/mineracks/seedhammer-v1-companion/driver/mjolnir" + "github.com/mineracks/seedhammer-v1-companion/engrave" + "github.com/mineracks/seedhammer-v1-companion/font/constant" + "github.com/mineracks/seedhammer-v1-companion/gui/op" + "github.com/mineracks/seedhammer-v1-companion/nonstandard" + "github.com/mineracks/seedhammer-v1-companion/seedqr" +) + +func TestDescriptorScreenError(t *testing.T) { + dupDesc := urtypes.OutputDescriptor{ + Script: urtypes.P2WSH, + Type: urtypes.SortedMulti, + Threshold: 2, + Keys: make([]urtypes.KeyDescriptor, 2), + } + dupMnemonic := fillDescriptor(t, dupDesc, dupDesc.Script.DerivationPath(), 12, 0) + dupDesc.Keys[1] = dupDesc.Keys[0] + smallDesc := urtypes.OutputDescriptor{ + Script: urtypes.P2WSH, + Type: urtypes.SortedMulti, + Threshold: 2, + Keys: make([]urtypes.KeyDescriptor, 5), + } + smallMnemonic := fillDescriptor(t, smallDesc, smallDesc.Script.DerivationPath(), 12, 0) + okDesc := urtypes.OutputDescriptor{ + Script: urtypes.P2WSH, + Type: urtypes.SortedMulti, + Threshold: 3, + Keys: make([]urtypes.KeyDescriptor, 5), + } + okMnemonic := fillDescriptor(t, okDesc, okDesc.Script.DerivationPath(), 12, 0) + tests := []struct { + name string + desc urtypes.OutputDescriptor + mnemonic bip39.Mnemonic + ok bool + }{ + {"duplicate key", dupDesc, dupMnemonic, false}, + {"small threshold", smallDesc, smallMnemonic, false}, + {"ok descriptor", okDesc, okMnemonic, true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + scr := &DescriptorScreen{ + Descriptor: test.desc, + Mnemonic: test.mnemonic, + } + ctx := NewContext(newPlatform()) + // Ok descriptor, ok error message, back. + ctxButton(ctx, Button3, Button3, Button1) + quit := runUI(ctx, func() { + if _, ok := scr.Confirm(ctx, op.Ctx{}, &descriptorTheme); ok != test.ok { + t.Fatalf("DescriptorScreen.Confirm returned %v, expected %v", ok, test.ok) + } + }) + defer quit() + }) + } +} + +func TestValidateDescriptor(t *testing.T) { + // Duplicate key. + dup := urtypes.OutputDescriptor{ + Script: urtypes.P2WSH, + Threshold: 1, + Type: urtypes.SortedMulti, + Keys: make([]urtypes.KeyDescriptor, 2), + } + fillDescriptor(t, dup, dup.Script.DerivationPath(), 12, 0) + dup.Keys[1] = dup.Keys[0] + + // Threshold too small. + smallDesc := urtypes.OutputDescriptor{ + Script: urtypes.P2WSH, + Threshold: 2, + Type: urtypes.SortedMulti, + Keys: make([]urtypes.KeyDescriptor, 5), + } + fillDescriptor(t, smallDesc, smallDesc.Script.DerivationPath(), 12, 0) + + tests := []struct { + name string + desc urtypes.OutputDescriptor + err error + }{ + {"duplicate key", dup, new(errDuplicateKey)}, + {"threshold too small", smallDesc, backup.ErrDescriptorTooLarge}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateDescriptor(mjolnir.Params, test.desc) + if err == nil { + t.Fatal("validateDescriptor accepted an unsupported descriptor") + } + if !errors.Is(err, test.err) { + t.Fatalf("validateDescriptor returned %v, expected %v", err, test.err) + } + }) + } +} + +func runUI(ctx *Context, f func()) func() { + token := new(int) + frameCh := make(chan struct{}) + closed := make(chan struct{}) + frames := 0 + ctx.Frame = func() { + frames++ + if frames > 1000 { + panic("UI is not making progress") + } + frameCh <- struct{}{} + select { + case <-frameCh: + case <-closed: + } + } + go func() { + defer func() { + if v := recover(); v != nil && v != token { + panic(v) + } + }() + defer close(closed) + <-frameCh + f() + }() + quit := func() { + ctx.Frame = func() { + panic(token) + } + close(frameCh) + <-closed + ctx.Frame = nil + } + return quit +} + +func resetOps(ops *op.Ops, f func()) func() { + return func() { + ops.Reset() + f() + } +} + +func opsContains(ops *op.Ops, str string) bool { + clip := image.Rectangle{Max: image.Pt(testDisplayDim, testDisplayDim)} + str = strings.ToLower(str) + txt := strings.Join(ops.ExtractText(clip), " ") + return strings.Index(strings.ToLower(txt), str) != -1 +} + +func TestMainScreen(t *testing.T) { + p := newPlatform() + ctx := NewContext(p) + + ops := new(op.Ops) + quit := runUI(ctx, func() { + mainFlow(ctx, ops.Context()) + }) + defer quit() + frame := resetOps(ops, ctx.Frame) + // Test sd card warning. + ctxButton(ctx, Button3) + frame() + if !opsContains(ops, "Remove SD") { + t.Fatal("MainScreen ignored SD card present") + } + ctx.EmptySDSlot = true + frame() + if opsContains(ops, "Remove SD") { + t.Fatal("MainScreen ignored SD card ejected") + } + // Input method camera + ctxButton(ctx, Down, Button3) + // Scan xpub as descriptor. + ctxQR(t, ctx, p, "xpub6F148LnjUhGrHfEN6Pa8VkwF8L6FJqYALxAkuHfacfVhMLVY4MRuUVMxr9pguAv67DHx1YFxqoKN8s4QfZtD9sR2xRCffTqi9E8FiFLAYk8") + frame() + if !opsContains(ops, "Invalid Seed") { + t.Fatal("MainScreen accepted invalid data for a Seed") + } +} + +func TestNonParticipatingSeed(t *testing.T) { + // Enter seed not part of the descriptor. + mnemonic := make(bip39.Mnemonic, 12) + for i := range mnemonic { + mnemonic[i] = bip39.RandomWord() + } + mnemonic = mnemonic.FixChecksum() + scr := &DescriptorScreen{ + Mnemonic: mnemonic, + Descriptor: twoOfThree.Descriptor, + } + ctx := NewContext(newPlatform()) + + // Accept descriptor. + ctxButton(ctx, Button3) + + ops := new(op.Ops) + quit := runUI(ctx, func() { + if _, ok := scr.Confirm(ctx, ops.Context(), &descriptorTheme); ok { + t.Fatal("a non-participating seed was accepted") + } + }) + defer quit() + ctx.Frame() + if !opsContains(ops, "Unknown Wallet") { + t.Fatal("a non-participating seed was accepted") + } +} + +func newTestEngraveScreen(t *testing.T, ctx *Context) *EngraveScreen { + desc := twoOfThree.Descriptor + const keyIdx = 0 + plate, err := engravePlate(plateSizes, mjolnir.Params, desc, keyIdx, twoOfThree.Mnemonic) + if err != nil { + t.Fatal(err) + } + return NewEngraveScreen( + ctx, + plate, + ) +} + +func TestEngraveScreenCancel(t *testing.T) { + p := newPlatform() + ctx := NewContext(p) + scr := newTestEngraveScreen(t, ctx) + + // Back. + ctxButton(ctx, Button1) + // Hold confirm. + ctxPress(ctx, Button3) + var cancelled bool + quit := runUI(ctx, func() { + cancelled = !scr.Engrave(ctx, op.Ctx{}, &engraveTheme) + }) + defer quit() + ctx.Frame() + if cancelled { + t.Error("exited screen without confirmation") + } + p.timeOffset += confirmDelay + ctx.Frame() + if !cancelled { + t.Error("failed to exit screen") + } +} + +func TestEngraveError(t *testing.T) { + nonstdPath := []uint32{ + hdkeychain.HardenedKeyStart + 86, + hdkeychain.HardenedKeyStart + 0, + hdkeychain.HardenedKeyStart + 0, + } + tests := []struct { + name string + threshold int + keys int + path []uint32 + err error + }{ + {"threshold too small", 1, 5, nonstdPath, backup.ErrDescriptorTooLarge}, + } + for i, test := range tests { + name := fmt.Sprintf("%d-%d-of-%d", i, test.threshold, test.keys) + t.Run(name, func(t *testing.T) { + desc := urtypes.OutputDescriptor{ + Script: urtypes.P2WSH, + Threshold: test.threshold, + Type: urtypes.SortedMulti, + Keys: make([]urtypes.KeyDescriptor, test.keys), + } + mnemonic := fillDescriptor(t, desc, test.path, 12, 0) + _, err := engravePlate(plateSizes, mjolnir.Params, desc, 0, mnemonic) + if err == nil { + t.Fatal("invalid descriptor succeeded") + } + if !errors.Is(err, test.err) { + t.Fatalf("got error %v, expected %v", err, test.err) + } + }) + } +} + +func TestEngraveScreenConnectionError(t *testing.T) { + p := newPlatform() + p.engrave.closed = make(chan []mjolnir.Cmd, 1) + p.engrave.connErr = errors.New("failed to connect") + ctx := NewContext(p) + scr := newTestEngraveScreen(t, ctx) + ops := new(op.Ops) + quit := runUI(ctx, func() { + scr.Engrave(ctx, ops.Context(), &engraveTheme) + }) + defer quit() + frame := resetOps(ops, ctx.Frame) + // Press next until connect is reached. + for scr.instructions[scr.step].Type != ConnectInstruction { + ctxButton(ctx, Button3) + ctx.Frame() + } + // Hold connect. + ctxPress(ctx, Button3) + frame() + p.timeOffset += confirmDelay + frame() + if !opsContains(ops, p.engrave.connErr.Error()) { + t.Fatal("engraver error did not propagate to screen") + } + // Dismiss error. + ctxButton(ctx, Button3) + // Successfully connect, but fail during engraving. + p.engrave.connErr = nil + p.engrave.ioErr = errors.New("error during engraving") + delivered := make(chan struct{}) + p.engrave.ioErrDelivered = delivered + // Hold connect. + ctxPress(ctx, Button3) + frame() + p.timeOffset += confirmDelay + frame() + if opsContains(ops, "error") { + t.Fatal("screen reported error for connection success") + } + <-delivered + for { + frame() + if opsContains(ops, p.engrave.ioErr.Error()) { + // t.Fatal("screen didn't report engraver error") + break + } + } + // Dismiss error and verify screen exits. + ctxButton(ctx, Button3) + frame() + if opsContains(ops, "error") { + t.Fatal("screen didn't exit after fatal engraver error") + } + // Verify device was closed. + <-p.engrave.closed +} + +func TestScanScreenConnectError(t *testing.T) { + p := newPlatform() + // Fail on connect. + ctx := NewContext(p) + scr := &ScanScreen{} + camErr := errors.New("failed to open camera") + ctx.events = append(ctx.events, FrameEvent{Error: camErr}.Event()) + ops := new(op.Ops) + quit := runUI(ctx, func() { + scr.Scan(ctx, ops.Context()) + }) + defer quit() + frame := resetOps(ops, ctx.Frame) + frame() + if !opsContains(ops, camErr.Error()) { + t.Fatal("initial camera error not reported") + } +} + +func TestScanScreenStreamError(t *testing.T) { + p := newPlatform() + ctx := NewContext(p) + // Fail during streaming. + scr := &ScanScreen{} + // Connect. + camErr := errors.New("error during streaming") + ctx.events = append(ctx.events, FrameEvent{Error: camErr}.Event()) + ops := new(op.Ops) + quit := runUI(ctx, func() { + scr.Scan(ctx, ops.Context()) + }) + defer quit() + ctx.Frame() + if !opsContains(ops, camErr.Error()) { + t.Fatal("streaming camera error not reported") + } +} + +func TestWordKeyboardScreen(t *testing.T) { + ctx := NewContext(newPlatform()) + for i := bip39.Word(0); i < bip39.NumWords; i++ { + w := bip39.LabelFor(i) + ctxString(ctx, strings.ToUpper(w)) + ctxButton(ctx, Button2) + m := make(bip39.Mnemonic, 1) + inputWordsFlow(ctx, op.Ctx{}, &descriptorTheme, m, 0) + if got := bip39.LabelFor(m[0]); got != w { + t.Errorf("keyboard mapped %q to %q", w, got) + } + } +} + +func ctxMnemonic(ctx *Context, m bip39.Mnemonic) { + for _, word := range m { + ctxString(ctx, strings.ToUpper(bip39.LabelFor(word))) + ctxButton(ctx, Button2) + } +} + +func ctxQR(t *testing.T, ctx *Context, p *testPlatform, qrs ...string) { + t.Helper() + for _, qr := range qrs { + ctx.events = append(ctx.events, qrFrame(t, p, qr).Event()) + } +} + +func TestSeedScreenScan(t *testing.T) { + p := newPlatform() + ctx := NewContext(p) + // Select camera. + ctxButton(ctx, Down, Button3) + want, err := bip39.ParseMnemonic("attack pizza motion avocado network gather crop fresh patrol unusual wild holiday candy pony ranch winter theme error hybrid van cereal salon goddess expire") + if err != nil { + t.Fatal(err) + } + ctxQR(t, ctx, p, string(seedqr.QR(want))) + got, ok := newMnemonicFlow(ctx, op.Ctx{}, &descriptorTheme) + if !ok { + t.Errorf("no mnemonic from scanned seed") + } + if !reflect.DeepEqual(got, want) { + t.Errorf("scanned %v, want %v", got, want) + } +} + +func TestSeedScreenScanInvalid(t *testing.T) { + p := newPlatform() + ctx := NewContext(p) + // Select camera. + ctxButton(ctx, Down, Button3) + ctxQR(t, ctx, p, "UR:CRYPTO-SEED/OYADGDIYWLAMAEJSZSWDWYTLTIFEENFTLNMNWKBDHNSSRO") + ops := new(op.Ops) + quit := runUI(ctx, func() { + newMnemonicFlow(ctx, ops.Context(), &descriptorTheme) + }) + defer quit() + ctx.Frame() + if !opsContains(ops, "invalid seed") { + t.Error("invalid seed accepted") + } +} + +func TestSeedScreenInvalidSeed(t *testing.T) { + p := newPlatform() + ctx := NewContext(p) + m := append(bip39.Mnemonic{}, twoOfThree.Mnemonic...) + // Invalidate seed. + m[0] = 0 + // Accept seed. + ctxButton(ctx, Button3) + scr := new(SeedScreen) + var confirmed bool + ops := new(op.Ops) + var exited bool + quit := runUI(ctx, func() { + scr.Confirm(ctx, ops.Context(), &singleTheme, m) + exited = true + }) + defer quit() + frame := resetOps(ops, ctx.Frame) + frame() + if confirmed || !opsContains(ops, "invalid seed") { + t.Fatal("invalid seed accepted") + } + // Dismiss error. + ctxButton(ctx, Button3) + + // Back. + ctxButton(ctx, Button1) + // Hold confirm. + ctxPress(ctx, Button3) + frame() + if exited { + t.Error("exited screen without confirmation") + } + p.timeOffset += confirmDelay + frame() + if !exited { + t.Error("failed to exit screen") + } +} + +func TestXpubMasterFingerprintSinglesig(t *testing.T) { + const mnemonic = "upset toe sheriff cotton vibrant shock torch waste congress innocent company review" + const descriptor = "zpub6qiC7jMrWkhNEu7YamFTWx8YHQaDFynLYQCUmxjCWpBiLQ4Qp6c6PEwpZpkN27XmUtBjX7hVLyyBKa7zhgaB5B2qvdckaP21ADwx7oYgYD6" + + m, err := bip39.ParseMnemonic(mnemonic) + if err != nil { + t.Fatal(err) + } + + p := newPlatform() + ctx := NewContext(p) + ops := new(op.Ops) + ctxQR(t, ctx, p, descriptor) + ctxButton(ctx, Button3) + got, parsed := inputDescriptorFlow(ctx, ops.Context(), &descriptorTheme, m) + + if !parsed { + t.Error("failed to parse descriptor") + } + + want, err := nonstandard.OutputDescriptor([]byte(descriptor)) + if err != nil { + t.Fatal(err) + } + mfp, err := masterFingerprintFor(m, &chaincfg.MainNetParams) + if err != nil { + t.Fatal(err) + } + want.Keys[0].MasterFingerprint = mfp + if !reflect.DeepEqual(want, *got) { + t.Error("descriptors don't match") + } +} + +func TestSeed(t *testing.T) { + p := newPlatform() + ctx := NewContext(p) + + const mnemonic = "doll clerk nice coast caught valid shallow taxi buyer economy lunch roof" + m, err := bip39.ParseMnemonic(mnemonic) + if err != nil { + t.Fatal(err) + } + mk, ok := deriveMasterKey(m, &chaincfg.MainNetParams) + if !ok { + t.Fatal("failed to derive master key") + } + mfp, _, err := bip32.Derive(mk, urtypes.Path{0}) + if err != nil { + t.Fatal(err) + } + seedDesc := backup.Seed{ + KeyIdx: 0, + Mnemonic: m, + Keys: 1, + MasterFingerprint: mfp, + Font: constant.Font, + Size: backup.SquarePlate, + } + side, err := backup.EngraveSeed(p.EngraverParams(), seedDesc) + if err != nil { + t.Fatal(err) + } + plate := Plate{ + Sides: []engrave.Plan{side}, + Size: backup.SquarePlate, + MasterFingerprint: mfp, + } + + var completed bool + scr := NewEngraveScreen(ctx, plate) + quit := runUI(ctx, func() { + completed = scr.Engrave(ctx, op.Ctx{}, &engraveTheme) + }) + defer quit() + + testEngraving(t, p, ctx, scr, side) + for !completed { + ctxButton(ctx, Button3) + ctx.Frame() + } +} + +func TestMulti(t *testing.T) { + const oneOfTwoDesc = "wsh(sortedmulti(1,[94631f99/48h/0h/0h/2h]xpub6ENfRaMWq2UoFy5FrLRMwiEkdgFdMgjEoikR34RBGzhsx8JzAkn7fyQeR5odirEwERvmxhSEv7rsmV7nuzjSKKKJHBP2aQZVu3R2d5ERgcw,[4bbaa801/48h/0h/0h/2h]xpub6E8mpiqJiVKuJZqxtu5SbHQnwUWWPQpZEy9CVtvfU1gxXZnbb9DG2AvZyMHvyVRtUPAEmu6BuRCy4LK2rKMeNr7jQKXsCyFfr1osgFCMYpc))" + mnemonics := []string{ + "doll clerk nice coast caught valid shallow taxi buyer economy lunch roof", + "road lend lyrics shift rabbit amazing fetch impulse provide reopen sphere network", + } + + for i, mnemonic := range mnemonics { + p := newPlatform() + ctx := NewContext(p) + + m, err := bip39.ParseMnemonic(mnemonic) + if err != nil { + t.Fatal(err) + } + + oneOfTwo, err := nonstandard.OutputDescriptor([]byte(oneOfTwoDesc)) + if err != nil { + t.Fatal(err) + } + const size = backup.LargePlate + descPlate := backup.Descriptor{ + Descriptor: oneOfTwo, + KeyIdx: i, + Font: constant.Font, + Size: size, + } + descSide, err := backup.EngraveDescriptor(p.EngraverParams(), descPlate) + if err != nil { + t.Fatal(err) + } + seedDesc := backup.Seed{ + Title: oneOfTwo.Title, + KeyIdx: i, + Mnemonic: m, + Keys: len(oneOfTwo.Keys), + MasterFingerprint: oneOfTwo.Keys[i].MasterFingerprint, + Font: constant.Font, + Size: size, + } + seedSide, err := backup.EngraveSeed(p.EngraverParams(), seedDesc) + if err != nil { + t.Fatal(err) + } + plate := Plate{ + Size: size, + Sides: []engrave.Plan{descSide, seedSide}, + } + var completed bool + scr := NewEngraveScreen(ctx, plate) + quit := runUI(ctx, func() { + completed = scr.Engrave(ctx, op.Ctx{}, &engraveTheme) + }) + defer quit() + for _, side := range plate.Sides { + testEngraving(t, p, ctx, scr, side) + } + for !completed { + ctxButton(ctx, Button3) + ctx.Frame() + } + } +} + +func fillDescriptor(t *testing.T, desc urtypes.OutputDescriptor, path urtypes.Path, seedlen int, keyIdx int) bip39.Mnemonic { + var mnemonic bip39.Mnemonic + for i := range desc.Keys { + m := make(bip39.Mnemonic, seedlen) + for j := range m { + m[j] = bip39.Word(i*seedlen + j) + } + m = m.FixChecksum() + seed := bip39.MnemonicSeed(m, "") + network := &chaincfg.MainNetParams + mk, err := hdkeychain.NewMaster(seed, network) + if err != nil { + t.Fatal(err) + } + mfp, xpub, err := bip32.Derive(mk, path) + if err != nil { + t.Fatal(err) + } + pub, err := xpub.ECPubKey() + if err != nil { + t.Fatal(err) + } + desc.Keys[i] = urtypes.KeyDescriptor{ + Network: network, + MasterFingerprint: mfp, + DerivationPath: path, + KeyData: pub.SerializeCompressed(), + ChainCode: xpub.ChainCode(), + ParentFingerprint: xpub.ParentFingerprint(), + } + if i == keyIdx { + mnemonic = m + } + } + return mnemonic +} + +type testPlatform struct { + events []Event + + engrave struct { + closed chan []mjolnir.Cmd + connErr error + ioErr error + ioErrDelivered chan<- struct{} + } + + timeOffset time.Duration + qrImages map[*uint8][]byte +} + +func (t *testPlatform) ScanQR(img *image.Gray) ([][]byte, error) { + if content, ok := t.qrImages[&img.Pix[0]]; ok { + return [][]byte{content}, nil + } + return nil, errors.New("no QR code") +} + +const testDisplayDim = 240 + +func (*testPlatform) DisplaySize() image.Point { + return image.Pt(testDisplayDim, testDisplayDim) +} + +func (*testPlatform) Dirty(r image.Rectangle) error { + return nil +} + +func (*testPlatform) NextChunk() (draw.RGBA64Image, bool) { + return nil, false +} + +func (t *testPlatform) Now() time.Time { + return time.Now().Add(t.timeOffset) +} + +func (*testPlatform) Debug() bool { + return false +} + +func ctxString(ctx *Context, str string) { + for _, r := range str { + ctx.Events( + ButtonEvent{ + Button: Rune, + Rune: r, + Pressed: true, + }.Event(), + ) + } +} + +func ctxPress(ctx *Context, bs ...Button) { + for _, b := range bs { + ctx.Events( + ButtonEvent{ + Button: b, + Pressed: true, + }.Event(), + ) + } +} + +func ctxButton(ctx *Context, bs ...Button) { + for _, b := range bs { + ctx.Events( + ButtonEvent{ + Button: b, + Pressed: true, + }.Event(), + ButtonEvent{ + Button: b, + Pressed: false, + }.Event(), + ) + } +} + +func (p *testPlatform) Wakeup() { +} + +func (p *testPlatform) Events(deadline time.Time) []Event { + evts := p.events + p.events = nil + return evts +} + +type wrappedEngraver struct { + dev *mjolnir.Simulator + closed chan<- []mjolnir.Cmd + ioErr error + ioErrDelivered chan<- struct{} +} + +func (w *wrappedEngraver) Read(p []byte) (int, error) { + n, err := w.dev.Read(p) + if err == nil && w.ioErr != nil { + err = w.ioErr + w.ioErr = nil + close(w.ioErrDelivered) + } + return n, err +} + +func (w *wrappedEngraver) Write(p []byte) (int, error) { + n, err := w.dev.Write(p) + if err == nil && w.ioErr != nil { + err = w.ioErr + w.ioErr = nil + close(w.ioErrDelivered) + } + return n, err +} + +func (w *wrappedEngraver) Close() error { + if w.closed != nil { + w.closed <- w.dev.Cmds + } + return w.dev.Close() +} + +func (p *testPlatform) EngraverParams() engrave.Params { + return mjolnir.Params +} + +var plateSizes = []backup.PlateSize{backup.SquarePlate, backup.LargePlate} + +func (p *testPlatform) PlateSizes() []backup.PlateSize { + return plateSizes +} + +func (p *testPlatform) Engraver() (Engraver, error) { + if err := p.engrave.connErr; err != nil { + return nil, err + } + sim := mjolnir.NewSimulator() + return &engraver{ + dev: &wrappedEngraver{sim, p.engrave.closed, p.engrave.ioErr, p.engrave.ioErrDelivered}, + }, nil +} + +type engraver struct { + dev io.ReadWriteCloser +} + +func (e *engraver) Engrave(sz backup.PlateSize, plan engrave.Plan, quit <-chan struct{}) error { + return mjolnir.Engrave(e.dev, mjolnir.Options{}, plan, quit) +} + +func (e *engraver) Close() { + e.dev.Close() +} + +func (p *testPlatform) CameraFrame(dims image.Point) { +} + +func newPlatform() *testPlatform { + return &testPlatform{} +} + +func qrFrame(t *testing.T, p *testPlatform, content string) FrameEvent { + qr, err := qr.Encode(content, qr.L) + if err != nil { + t.Fatal(err) + } + qrImg := qr.Image() + b := qrImg.Bounds() + frameImg := image.NewYCbCr(b, image.YCbCrSubsampleRatio420) + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + off := frameImg.YOffset(x, y) + r, _, _, _ := qrImg.At(x, y).RGBA() + frameImg.Y[off] = uint8(r >> 8) + } + } + if p.qrImages == nil { + p.qrImages = make(map[*byte][]byte) + } + p.qrImages[&frameImg.Y[0]] = []byte(content) + return FrameEvent{ + Image: frameImg, + } +} + +func testEngraving(t *testing.T, p *testPlatform, ctx *Context, scr *EngraveScreen, side engrave.Plan) { + p.engrave.closed = make(chan []mjolnir.Cmd) +done: + for { + switch scr.instructions[scr.step].Type { + case EngraveInstruction: + break done + case ConnectInstruction: + // Hold connect. + ctxPress(ctx, Button3) + ctx.Frame() + p.timeOffset += confirmDelay + ctx.Frame() + default: + ctxButton(ctx, Button3) + ctx.Frame() + } + } + got := <-p.engrave.closed + // Verify the step is advanced after engrave completion. + for scr.instructions[scr.step].Type == EngraveInstruction { + ctx.Frame() + } + want := simEngrave(t, side) + if !reflect.DeepEqual(want, got) { + t.Fatalf("engraver commands mismatch for side %v", side) + } +} + +func simEngrave(t *testing.T, plate engrave.Plan) []mjolnir.Cmd { + sim := mjolnir.NewSimulator() + defer sim.Close() + if err := mjolnir.Engrave(sim, mjolnir.Options{}, plate, nil); err != nil { + t.Fatal(err) + } + return sim.Cmds +} + +func mnemonicFor(phrase string) bip39.Mnemonic { + m, err := bip39.ParseMnemonic(phrase) + if err != nil { + panic(err) + } + return m +} + +var twoOfThree = struct { + Descriptor urtypes.OutputDescriptor + Mnemonic bip39.Mnemonic +}{ + Mnemonic: mnemonicFor("flip begin artist fringe online release swift genre wool general transfer arm"), + Descriptor: urtypes.OutputDescriptor{ + Script: urtypes.P2WSH, + Threshold: 2, + Type: urtypes.SortedMulti, + Keys: []urtypes.KeyDescriptor{ + { + Network: &chaincfg.MainNetParams, + MasterFingerprint: 0x5a0804e3, + DerivationPath: urtypes.Path{0x80000030, 0x80000000, 0x80000000, 0x80000002}, + KeyData: []byte{0x3, 0xa9, 0x39, 0x4a, 0x2f, 0x1a, 0x4f, 0x99, 0x61, 0x3a, 0x71, 0x69, 0x56, 0xc8, 0x54, 0xf, 0x6d, 0xba, 0x6f, 0x18, 0x93, 0x1c, 0x26, 0x39, 0x10, 0x72, 0x21, 0xb2, 0x67, 0xd7, 0x40, 0xaf, 0x23}, + ChainCode: []byte{0xdb, 0xe8, 0xc, 0xbb, 0x4e, 0xe, 0x41, 0x8b, 0x6, 0xf4, 0x70, 0xd2, 0xaf, 0xe7, 0xa8, 0xc1, 0x7b, 0xe7, 0x1, 0xab, 0x20, 0x6c, 0x59, 0xa6, 0x5e, 0x65, 0xa8, 0x24, 0x1, 0x6a, 0x6c, 0x70}, + ParentFingerprint: 0xc7bce7a8, + }, + { + Network: &chaincfg.MainNetParams, + MasterFingerprint: 0xdd4fadee, + DerivationPath: urtypes.Path{0x80000030, 0x80000000, 0x80000000, 0x80000002}, + KeyData: []byte{0x2, 0x21, 0x96, 0xad, 0xc2, 0x5f, 0xde, 0x16, 0x9f, 0xe9, 0x2e, 0x70, 0x76, 0x90, 0x59, 0x10, 0x22, 0x75, 0xd2, 0xb4, 0xc, 0xc9, 0x87, 0x76, 0xea, 0xab, 0x92, 0xb8, 0x2a, 0x86, 0x13, 0x5e, 0x92}, + ChainCode: []byte{0x43, 0x8e, 0xff, 0x7b, 0x3b, 0x36, 0xb6, 0xd1, 0x1a, 0x60, 0xa2, 0x2c, 0xcb, 0x93, 0x6, 0xee, 0xa3, 0x5, 0xb0, 0x43, 0x9f, 0x1e, 0xa0, 0x9d, 0x59, 0x28, 0x1, 0x5d, 0xe3, 0x73, 0x81, 0x16}, + ParentFingerprint: 0x22969377, + }, + { + Network: &chaincfg.MainNetParams, + MasterFingerprint: 0x9bacd5c0, + DerivationPath: urtypes.Path{0x80000030, 0x80000000, 0x80000000, 0x80000002}, + KeyData: []byte{0x2, 0xfb, 0x72, 0x50, 0x7f, 0xc2, 0xd, 0xdb, 0xa9, 0x29, 0x91, 0xb1, 0x7c, 0x4b, 0xb4, 0x66, 0x13, 0xa, 0xd9, 0x3a, 0x88, 0x6e, 0x73, 0x17, 0x50, 0x33, 0xbb, 0x43, 0xe3, 0xbc, 0x78, 0x5a, 0x6d}, + ChainCode: []byte{0x95, 0xb3, 0x49, 0x13, 0x93, 0x7f, 0xa5, 0xf1, 0xc6, 0x20, 0x5b, 0x52, 0x5b, 0xb5, 0x7d, 0xe1, 0x51, 0x76, 0x25, 0xe0, 0x45, 0x86, 0xb5, 0x95, 0xbe, 0x68, 0xe7, 0x13, 0x62, 0xd3, 0xed, 0xc5}, + ParentFingerprint: 0x97ec38f9, + }, + }, + }, +} diff --git a/gui/layout/layout.go b/gui/layout/layout.go new file mode 100644 index 0000000..88b362c --- /dev/null +++ b/gui/layout/layout.go @@ -0,0 +1,160 @@ +package layout + +import ( + "image" +) + +type Rectangle image.Rectangle + +func (r Rectangle) Shrink(top, end, bottom, start int) Rectangle { + r2 := Rectangle{ + Min: r.Min.Add(image.Pt(start, top)), + Max: r.Max.Sub(image.Pt(end, bottom)), + } + if r2.Min.X > r.Max.X { + r2.Min.X = r.Max.X + } + if r2.Max.X < r.Min.X { + r2.Max.X = r.Min.X + } + if r2.Min.Y > r.Max.Y { + r2.Min.Y = r.Max.Y + } + if r2.Max.Y < r.Min.Y { + r2.Max.Y = r.Min.Y + } + return r2 +} + +func (r Rectangle) Center(sz image.Point) image.Point { + off := r.Size().Sub(sz).Div(2) + return r.Min.Add(off) +} + +func (r Rectangle) E(sz image.Point) image.Point { + return image.Point{ + X: r.Max.X - sz.X, + Y: (r.Max.Y + r.Min.Y - sz.Y) / 2, + } +} + +func (r Rectangle) N(sz image.Point) image.Point { + return image.Point{ + X: (r.Max.X + r.Min.X - sz.X) / 2, + Y: r.Min.Y, + } +} + +func (r Rectangle) W(sz image.Point) image.Point { + return image.Point{ + X: r.Min.X, + Y: (r.Max.Y + r.Min.Y - sz.Y) / 2, + } +} + +func (r Rectangle) S(sz image.Point) image.Point { + return image.Point{ + X: (r.Max.X + r.Min.X - sz.X) / 2, + Y: r.Max.Y - sz.Y, + } +} + +func (r Rectangle) NW(sz image.Point) image.Point { + return r.Min +} + +func (r Rectangle) NE(sz image.Point) image.Point { + return image.Point{ + X: r.Max.X - sz.X, + Y: r.Min.Y, + } +} + +func (r Rectangle) SW(sz image.Point) image.Point { + return image.Point{ + X: r.Min.X, + Y: r.Max.Y - sz.Y, + } +} + +func (r Rectangle) SE(sz image.Point) image.Point { + return r.Max.Sub(sz) +} + +func (r Rectangle) Dx() int { + return image.Rectangle(r).Dx() +} + +func (r Rectangle) Dy() int { + return image.Rectangle(r).Dy() +} + +func (r Rectangle) Size() image.Point { + return image.Rectangle(r).Size() +} + +func (r Rectangle) CutTop(height int) (top Rectangle, bottom Rectangle) { + cuty := r.Min.Y + height + if cuty > r.Max.Y { + cuty = r.Max.Y + } + return r.cutY(cuty) +} + +func (r Rectangle) CutBottom(height int) (top Rectangle, bottom Rectangle) { + cuty := r.Max.Y - height + if cuty < r.Min.Y { + cuty = r.Min.Y + } + return r.cutY(cuty) +} + +func (r Rectangle) cutY(cuty int) (top Rectangle, bottom Rectangle) { + top = Rectangle(image.Rect(r.Min.X, r.Min.Y, r.Max.X, cuty)) + bottom = Rectangle(image.Rect(r.Min.X, cuty, r.Max.X, r.Max.Y)) + return top, bottom +} + +func (r Rectangle) CutStart(width int) (start Rectangle, end Rectangle) { + cutx := r.Min.X + width + if cutx > r.Max.X { + cutx = r.Max.X + } + return r.cutX(cutx) +} + +func (r Rectangle) CutEnd(width int) (start Rectangle, end Rectangle) { + cuty := r.Max.X - width + if cuty < r.Min.X { + cuty = r.Min.X + } + return r.cutX(cuty) +} + +func (r Rectangle) cutX(cutx int) (start Rectangle, end Rectangle) { + start = Rectangle(image.Rect(r.Min.X, r.Min.Y, cutx, r.Max.Y)) + end = Rectangle(image.Rect(cutx, r.Min.Y, r.Max.X, r.Max.Y)) + return start, end +} + +type Align struct { + Size image.Point +} + +func (h *Align) Add(sz image.Point) image.Point { + if sz.Y > h.Size.Y { + h.Size.Y = sz.Y + } + if sz.X > h.Size.X { + h.Size.X = sz.X + } + return sz +} + +func (h *Align) X(sz image.Point) int { + return (h.Size.X - sz.X) / 2 +} + +func (h *Align) Y(sz image.Point) int { + return (h.Size.Y - sz.Y) / 2 +} diff --git a/gui/op/op.go b/gui/op/op.go new file mode 100644 index 0000000..2eac3ea --- /dev/null +++ b/gui/op/op.go @@ -0,0 +1,372 @@ +package op + +import ( + "image" + "image/color" + + "golang.org/x/image/draw" + "golang.org/x/image/math/fixed" + "github.com/mineracks/seedhammer-v1-companion/font/bitmap" + "github.com/mineracks/seedhammer-v1-companion/image/alpha4" + "github.com/mineracks/seedhammer-v1-companion/image/ninepatch" + "github.com/mineracks/seedhammer-v1-companion/image/rgb565" +) + +type Ops struct { + ops []any + uniforms map[color.Color]*image.Uniform + ninep map[ninepatch.Image]*ninepatch.Image + colors map[color.NRGBA]*image.Uniform + + prevOps map[frameOp]bool + frameOps map[frameOp]bool + frame []frameOp + + scratch scratch +} + +type Ctx struct { + beginIdx int + ops *Ops +} + +type frameOp struct { + state drawState + op drawOp +} + +func (o *Ctx) add(op any) { + if o.ops == nil { + return + } + o.ops.ops = append(o.ops.ops, op) +} + +func (o *Ctx) Begin() Ctx { + if o.ops == nil { + return Ctx{} + } + o.add(beginOp{}) + o.beginIdx = len(o.ops.ops) + return Ctx{ops: o.ops} +} + +func (o *Ctx) End() CallOp { + if o.ops == nil { + return CallOp{} + } + if o.beginIdx == 0 { + panic("End without a Begin") + } + o.add(endOp{}) + call := CallOp{startIdx: o.beginIdx} + o.beginIdx = 0 + return call +} + +func (o *Ops) Context() Ctx { + return Ctx{ops: o} +} + +func (o *Ops) Reset() { + o.ops = o.ops[:0] + if o.frameOps == nil { + o.frameOps = make(map[frameOp]bool) + } + if o.prevOps == nil { + o.prevOps = make(map[frameOp]bool) + } + o.frameOps, o.prevOps = o.prevOps, o.frameOps + // Clear for GC. + for i := range o.frameOps { + delete(o.frameOps, i) + } + for i := range o.frame { + o.frame[i] = frameOp{} + } + o.frame = o.frame[:0] +} + +func (o *Ops) nrgba(c color.NRGBA) *image.Uniform { + if o == nil { + return image.NewUniform(c) + } + if o.colors == nil { + o.colors = make(map[color.NRGBA]*image.Uniform) + } + if u, ok := o.colors[c]; ok { + return u + } + u := image.NewUniform(c) + o.colors[c] = u + return u +} + +func (o *Ops) intern(img image.Image) image.Image { + if o == nil { + return img + } + switch img := img.(type) { + case *image.Uniform: + if o.uniforms == nil { + o.uniforms = make(map[color.Color]*image.Uniform) + } + if img, ok := o.uniforms[img.C]; ok { + return img + } + o.uniforms[img.C] = img + case *ninepatch.Image: + if o.ninep == nil { + o.ninep = make(map[ninepatch.Image]*ninepatch.Image) + } + if img, ok := o.ninep[*img]; ok { + return img + } + o.ninep[*img] = img + } + return img +} + +type drawState struct { + pos image.Point + clip image.Rectangle + mask image.Image + maskp image.Point +} + +type scratch struct { + glyph alpha4.Image +} + +func (o *Ops) ExtractText(dst image.Rectangle) []string { + o.serialize(drawState{clip: dst}, 0) + var text []string + for _, op := range o.frame { + if op, ok := op.op.(TextOp); ok { + text = append(text, op.Txt) + } + } + return text +} + +func (o *Ops) Clip(dst image.Rectangle) image.Rectangle { + o.serialize(drawState{clip: dst}, 0) + var clip image.Rectangle + for _, op := range o.frame { + o.frameOps[op] = true + if !o.prevOps[op] { + clip = clip.Union(op.state.clip) + } else { + delete(o.prevOps, op) + } + } + for op := range o.prevOps { + clip = clip.Union(op.state.clip) + } + return clip +} + +func (o *Ops) Draw(dst draw.Image) { + b := dst.Bounds() + for _, op := range o.frame { + clip := b.Intersect(op.state.clip) + if clip.Empty() { + continue + } + pos := clip.Min.Sub(op.state.pos) + maskp := clip.Min.Sub(op.state.maskp) + op.op.draw(&o.scratch, dst, clip, op.state.mask, maskp, pos) + } +} + +func (o *Ops) serialize(state drawState, from int) { + macros := 0 + origState := state + for _, op := range o.ops[from:] { + switch op.(type) { + case beginOp: + macros++ + continue + case endOp: + if macros == 0 { + return + } + macros-- + continue + } + if macros > 0 { + continue + } + switch op := op.(type) { + case offsetOp: + state.pos = state.pos.Add(image.Point(op)) + continue + case ClipOp: + r := image.Rectangle(op).Add(state.pos) + state.clip = state.clip.Intersect(r) + continue + case maskOp: + r := op.src.Bounds().Add(state.pos) + state.clip = state.clip.Intersect(r) + state.mask = op.src + state.maskp = state.pos + continue + case CallOp: + o.serialize(state, op.startIdx) + case drawOp: + r := op.bounds(&o.scratch).Add(state.pos) + state.clip = state.clip.Intersect(r) + if !state.clip.Empty() { + o.frame = append(o.frame, frameOp{state, op}) + } + } + state = origState + } +} + +type offsetOp image.Point + +func (o offsetOp) Add(ops Ctx) { + ops.add(o) +} + +func Offset(ops Ctx, off image.Point) { + offsetOp(off).Add(ops) +} + +func Position(ops Ctx, c CallOp, off image.Point) { + Offset(ops, off) + c.Add(ops) +} + +type ClipOp image.Rectangle + +func (c ClipOp) Add(ops Ctx) { + ops.add(c) +} + +func ColorOp(ops Ctx, col color.NRGBA) { + ops.add(imageOp{ops.ops.nrgba(col)}) +} + +func MaskOp(ops Ctx, img image.Image) { + ops.add(maskOp{ops.ops.intern(img)}) +} + +type maskOp struct { + src image.Image +} + +func ImageOp(ops Ctx, img image.Image) { + ops.add(imageOp{ops.ops.intern(img)}) +} + +type imageOp struct { + src image.Image +} + +func (im imageOp) bounds(scr *scratch) image.Rectangle { + return im.src.Bounds() +} + +func (im imageOp) draw(_ *scratch, dst draw.Image, dr image.Rectangle, mask image.Image, maskp, pos image.Point) { + drawMask(dst, dr, im.src, pos, mask, maskp) +} + +func drawMask(dst draw.Image, dr image.Rectangle, src image.Image, pos image.Point, mask image.Image, maskOff image.Point) { + // Optimize special cases. + if rgb, ok := dst.(*rgb565.Image); ok { + if mask == nil { + rgb.Draw(dr, src, pos, draw.Over) + return + } + } + + // General case. + draw.DrawMask( + dst, dr, + src, pos, + mask, maskOff, + draw.Over, + ) +} + +type CallOp struct { + startIdx int +} + +func (c CallOp) Add(ops Ctx) { + if c.startIdx > 0 { + ops.add(c) + } +} + +type beginOp struct{} + +type endOp struct{} + +type drawOp interface { + bounds(scratch *scratch) image.Rectangle + draw(scratch *scratch, dst draw.Image, dr image.Rectangle, mask image.Image, maskp, pos image.Point) +} + +type TextOp struct { + Src image.Image + Face *bitmap.Face + Dot image.Point + Txt string + LetterSpacing int +} + +func (t TextOp) bounds(scr *scratch) image.Rectangle { + b := t.drawBounds(scr, nil, image.Rectangle{}, nil, image.Point{}, image.Point{}) + return b.Intersect(t.Src.Bounds()) +} + +func (t TextOp) draw(scr *scratch, dst draw.Image, dr image.Rectangle, mask image.Image, maskp, pos image.Point) { + t.drawBounds(scr, dst, dr, mask, maskp, pos) +} + +func (t TextOp) drawBounds(scr *scratch, dst draw.Image, dr image.Rectangle, mask image.Image, maskp, pos image.Point) image.Rectangle { + var orig draw.Image + src := t.Src + tpos := pos + if dst != nil && mask != nil { + orig = dst + src = mask + dst = image.NewAlpha(dr) + tpos = maskp + } + prevC := rune(-1) + dot := fixed.I(t.Dot.X) + var bounds image.Rectangle + for _, c := range t.Txt { + if prevC >= 0 { + dot += t.Face.Kern(prevC, c) + } + mask, advance, ok := t.Face.Glyph(c) + if !ok { + continue + } + off := image.Pt(dot.Round(), t.Dot.Y) + gdr := mask.Bounds().Add(off) + advance += fixed.I(t.LetterSpacing) + bounds = bounds.Union(gdr) + if dst != nil { + scr.glyph = mask + drawMask(dst, dr, src, tpos, &scr.glyph, pos.Sub(off)) + } + dot += advance + prevC = c + } + if orig != nil { + drawMask(orig, dr, t.Src, pos, dst, dr.Min) + } + return bounds +} + +func (t TextOp) Add(ops Ctx) { + t2 := t + t2.Src = ops.ops.intern(t2.Src) + ops.add(t2) +} diff --git a/gui/saver/saver.go b/gui/saver/saver.go new file mode 100644 index 0000000..a91eaa3 --- /dev/null +++ b/gui/saver/saver.go @@ -0,0 +1,495 @@ +package saver + +import ( + "image" + "image/color" + "image/draw" + "math/rand" + "time" + + "golang.org/x/image/math/fixed" + "github.com/mineracks/seedhammer-v1-companion/gui/assets" + "github.com/mineracks/seedhammer-v1-companion/image/rgb565" +) + +type State struct { + before time.Time + prev struct { + snake image.Rectangle + logo image.Rectangle + } + snake []joint + food struct { + color int + image.Point + } + dx, dy int + shY fixed.Int26_6 + shV fixed.Int26_6 + sY fixed.Int26_6 + sV fixed.Int26_6 + shTop int + mode mode + delay int + rand *rand.Rand + + clear struct { + x int + y int + } +} + +type mode int + +const ( + modeClear mode = iota + modeSnake + modeGameOver +) + +type joint struct { + filled bool + image.Point +} + +const gridSize = 8 +const snakeLen = 5 + +type logo struct { + Bounds image.Rectangle + Boxes []image.Point +} + +var ( + tail = rgb(0xd9d9d9) + white = rgb(0xffffff) +) + +var colors = []image.Image{ + rgb(0xff0000), // Red + rgb(0xffa202), // Orange Peel + rgb(0xffff00), // Yellow + rgb(0x00ff00), // Green + rgb(0x00fff2), // Cyan / Aqua + rgb(0x0097fe), // Azure Radiance + rgb(0xe000ff), // Electric Violet + rgb(0xff00aa), // Hollywood Cerise +} + +func logoFor(width int) logo { + return buildLogo(width > 400) +} + +func (s *State) reset(dims image.Point) { + s.delay = 20 + s.shY = 0 + s.shV = fixed.I(-20) + s.sY = 0 + s.sV = 0 + s.mode = modeSnake + s.rand = rand.New(rand.NewSource(time.Now().UnixNano())) + location := image.Point{ + X: s.rand.Intn(dims.X / gridSize), + Y: s.rand.Intn(dims.Y / gridSize), + } + switch s.rand.Intn(4) { + case 0: + s.dx = 0 + s.dy = -1 + location.Y = dims.Y + snakeLen + case 1: + s.dx = 1 + s.dy = 0 + location.X = -snakeLen - 1 + case 2: + s.dx = 0 + s.dy = 1 + location.Y = -snakeLen - 1 + case 3: + s.dx = -1 + s.dy = 0 + location.X = dims.X + snakeLen + } + + s.snake = []joint{} + for len(s.snake) < snakeLen { + location.X += s.dx + location.Y += s.dy + s.snake = append(s.snake, joint{Point: location}) + } + placeFood(s, dims) +} + +func placeFood(s *State, dims image.Point) { +outer: + for { + s.food.X = s.rand.Intn(dims.X/gridSize-2*1) + 1 + s.food.Y = s.rand.Intn(dims.Y/gridSize-2*1) + 1 + for _, j := range s.snake { + if j.Point == s.food.Point { + continue outer + } + } + break + } +} + +func (s *State) stepClear(dims image.Point) { + for i := 0; i < 3; i++ { + s.snake = append(s.snake, joint{Point: image.Point{ + s.clear.x, + s.clear.y, + }}) + if len(s.snake) > snakeLen { + tail := s.snake[0] + if tail.Y*gridSize == dims.Y { + s.mode = modeSnake + s.reset(dims) + return + } + s.snake = append(s.snake[:0], s.snake[1:]...) + } + if s.clear.y%2 == 0 { + s.clear.x += 1 + } else { + s.clear.x -= 1 + } + if s.clear.x*gridSize >= dims.X || s.clear.x*gridSize < 0 { + s.clear.y += 1 + } + } +} + +func (s *State) update(dims image.Point) { + if s.delay > 0 { + s.delay -= 1 + return + } + if s.mode == modeClear { + s.stepClear(dims) + return + } + + head := s.snake[len(s.snake)-1] + switch { + case s.food.X < head.X: + s.dx = -1 + case s.food.X > head.X: + s.dx = 1 + case s.food.X == head.X: + s.dx = 0 + } + switch { + case s.food.Y < head.Y: + s.dy = -1 + case s.food.Y > head.Y: + s.dy = 1 + case s.food.Y == head.Y: + s.dy = 0 + } + if s.dx != 0 { + s.dy = 0 + } + +update: + for s.mode == modeSnake { + newHead := image.Point{X: head.X + s.dx, Y: head.Y + s.dy} + if newHead.X < 0 { + newHead.X = dims.X/gridSize - 1 + } else if newHead.X > dims.X/gridSize-1 { + newHead.X = 0 + } + if newHead.Y < 0 { + newHead.Y = dims.Y/gridSize - 1 + } else if newHead.Y > dims.Y/gridSize-1 { + newHead.Y = 0 + } + neck := s.snake[len(s.snake)-2] + + if neck.Point == newHead { + s.dx *= -1 + s.dy *= -1 + continue + } + for _, j := range s.snake { + if j.Point == newHead { + s.mode = modeGameOver + continue update + } + + } + j := joint{ + Point: newHead, + } + if newHead == s.food.Point { + s.snake = append(s.snake, j) + placeFood(s, dims) + for { + color := s.rand.Intn(len(colors)) + if color != s.food.color { + s.food.color = color + break + } + } + } else { + s.snake = append(s.snake[:0], s.snake[1:]...) + s.snake = append(s.snake, j) + } + break + } + if s.mode == modeGameOver { + minY := 1000 + for _, c := range s.snake { + if c.Y < minY { + minY = c.Y + } + } + const a = fixed.Int26_6(1.7*10*64) / 10 + const b = fixed.Int26_6(-3.5*10*64) / 10 + s.shV += a + s.shY += s.shV + s.sV += b + if s.sV < 0 { + s.sV = 0 + } + s.sY += s.sV + sTop := fixed.I(minY*gridSize) + s.sY + if sTop < s.shY && sTop < fixed.I(dims.Y) { + s.shY = fixed.I(minY*gridSize) + s.sY + s.sV = s.shV + const k = fixed.Int26_6(0.8*10*64) / 10 + s.shV = -s.shV.Mul(k) + } + l := logoFor(dims.X) + s.shTop = s.shY.Round() - l.Bounds.Dy() + if s.shTop > dims.Y { + s.reset(dims) + } + } +} + +type Screen interface { + DisplaySize() image.Point + // Dirty begins a refresh of the content + // specified by r. + Dirty(r image.Rectangle) error + // NextChunk returns the next chunk of the refresh. + NextChunk() (draw.RGBA64Image, bool) + Now() time.Time +} + +func drawScreen(screen Screen, dr image.Rectangle, f func(chunk draw.RGBA64Image)) { + screen.Dirty(dr) + for { + c, ok := screen.NextChunk() + if !ok { + break + } + imageDraw(c, c.Bounds(), image.NewUniform(color.Black), image.Point{}, draw.Src) + f(c) + } +} + +func imageDraw(dst draw.RGBA64Image, dr image.Rectangle, src image.Image, sp image.Point, op draw.Op) { + switch dst := dst.(type) { + case *rgb565.Image: + dst.Draw(dr, src, sp, op) + return + } + draw.Draw(dst, dr, src, sp, op) +} + +func (s *State) Draw(screen Screen) { + // Throttle frame time. + now := screen.Now() + d := now.Sub(s.before) + s.before = now + const minFrameTime = 40 * time.Millisecond + if sleep := minFrameTime - d; sleep > 0 { + time.Sleep(sleep) + } + + dims := screen.DisplaySize() + s.update(dims) + lr := s.prev.logo + s.prev.logo = image.Rectangle{} + var logo logo + if s.mode == modeGameOver { + logo = logoFor(dims.X) + centerx := (dims.X - logo.Bounds.Dx()) / 2 + s.prev.logo = logo.Bounds.Add(image.Pt(centerx, s.shTop)) + lr = lr.Union(s.prev.logo) + } + drawScreen(screen, lr, func(screen draw.RGBA64Image) { + if s.mode == modeGameOver { + b := s.prev.logo + drawBoxes(screen, logo.Boxes, b.Min.X, b.Min.Y) + } + }) + var snake image.Rectangle + for _, j := range s.snake { + m := image.Pt(j.X*gridSize, j.Y*gridSize+s.sY.Round()) + snake = snake.Union(image.Rectangle{ + Min: m, + Max: m.Add(image.Pt(boxSize, boxSize)), + }) + } + food := assets.LogoSmall.Bounds().Add(image.Pt(s.food.X*gridSize-6, s.food.Y*gridSize-3)) + if s.mode == modeSnake { + snake = snake.Union(food) + } + drawScreen(screen, snake.Union(s.prev.snake), func(screen draw.RGBA64Image) { + s.drawSnake(screen) + if s.mode == modeSnake { + draw.DrawMask( + screen, + food, + colors[s.food.color], + image.Pt(0, 0), + assets.LogoSmall, + image.Pt(0, 0), + draw.Over, + ) + } + }) + s.prev.snake = snake +} + +func (s *State) drawSnake(screen draw.RGBA64Image) { + for i, j := range s.snake { + color := tail + if i == len(s.snake)-1 { + color = white + } + dr := image.Rectangle{ + Min: image.Pt(j.X*gridSize, j.Y*gridSize+s.sY.Round()), + } + dr.Max = dr.Min.Add(image.Pt(boxSize, boxSize)) + if j.filled { + clearBox(screen, dr.Min.X, dr.Min.Y, color) + } else { + drawBox(screen, dr.Min.X, dr.Min.Y, color) + } + } +} + +func buildLogo(wide bool) logo { + S := []image.Point{ + {0, 0}, {1, 0}, {2, 0}, + {0, 1}, + {0, 2}, {1, 2}, {2, 2}, + {2, 3}, + {0, 4}, {1, 4}, {2, 4}, + } + E := []image.Point{ + {0, 0}, {1, 0}, {2, 0}, + {0, 1}, + {0, 2}, {1, 2}, {2, 2}, + {0, 3}, + {0, 4}, {1, 4}, {2, 4}, + } + + D := []image.Point{ + {0, 0}, {1, 0}, {2, 0}, + {0, 1}, {3, 1}, + {0, 2}, {3, 2}, + {0, 3}, {3, 3}, + {0, 4}, {1, 4}, {2, 4}, + } + A := []image.Point{ + {1, 0}, {2, 0}, + {0, 1}, {3, 1}, + {0, 2}, {1, 2}, {2, 2}, {3, 2}, + {0, 3}, {3, 3}, + {0, 4}, {3, 4}, + } + H := []image.Point{ + {0, 0}, {3, 0}, + {0, 1}, {3, 1}, + {0, 2}, {1, 2}, {2, 2}, {3, 2}, + {0, 3}, {3, 3}, + {0, 4}, {3, 4}, + } + M := []image.Point{ + {0, 0}, {4, 0}, + {0, 1}, {1, 1}, {3, 1}, {4, 1}, + {0, 2}, {1, 2}, {2, 2}, {3, 2}, {4, 2}, + {0, 3}, {2, 3}, {4, 3}, + {0, 4}, {4, 4}, + } + + R := []image.Point{ + {0, 0}, {1, 0}, {2, 0}, + {0, 1}, {3, 1}, + {0, 2}, {1, 2}, {2, 2}, + {0, 3}, {3, 3}, + {0, 4}, {3, 4}, + } + + seedOff := 7 + hammerOff := image.Pt(0, 6) + + if wide { + seedOff = 0 + hammerOff = image.Pt(12+seedOff+5, 0) + } + logo := logo{ + Bounds: image.Rectangle{Min: image.Pt(10000, 10000)}, + } + buildBoxes := func(boxes []image.Point, x, y int) { + for _, b := range boxes { + b = b.Add(image.Pt(x, y)) + logo.Bounds = logo.Bounds.Union(image.Rectangle{ + Min: image.Pt(b.X, b.Y), + Max: image.Pt(b.X+1, b.Y+1), + }) + logo.Boxes = append(logo.Boxes, b) + } + } + buildBoxes(S, 0+seedOff, 0) + buildBoxes(E, 4+seedOff, 0) + buildBoxes(E, 8+seedOff, 0) + buildBoxes(D, 12+seedOff, 0) + + buildBoxes(H, 0+hammerOff.X, hammerOff.Y) + buildBoxes(A, 5+hammerOff.X, hammerOff.Y) + buildBoxes(M, 10+hammerOff.X, hammerOff.Y) + buildBoxes(M, 16+hammerOff.X, hammerOff.Y) + buildBoxes(E, 22+hammerOff.X, hammerOff.Y) + buildBoxes(R, 26+hammerOff.X, hammerOff.Y) + logo.Bounds = logo.Bounds.Canon() + logo.Bounds = image.Rectangle{ + Min: logo.Bounds.Min.Mul(gridSize), + Max: logo.Bounds.Max.Mul(gridSize), + } + + return logo +} + +func drawBoxes(screen draw.RGBA64Image, boxes []image.Point, x, y int) { + for _, c := range boxes { + drawBox(screen, c.X*gridSize+x, c.Y*gridSize+y, white) + } +} + +const boxSize = gridSize + +func clearBox(screen draw.RGBA64Image, x, y int, img image.Image) { + dr := image.Rect(x, y, x+boxSize, y+boxSize) + imageDraw(screen, dr, img, image.Point{}, draw.Src) +} + +func drawBox(screen draw.RGBA64Image, x, y int, img image.Image) { + const boxSize = gridSize - 1 + dr := image.Rect(x+1, y+1, x+boxSize, y+boxSize) + imageDraw(screen, dr, img, image.Point{}, draw.Src) +} + +func rgb(c uint32) image.Image { + r := uint8(c >> 16) + g := uint8(c >> 8) + b := uint8(c) + return image.NewUniform(color.RGBA{ + A: 0xff, R: r, G: g, B: b, + }) +} diff --git a/gui/text/text.go b/gui/text/text.go new file mode 100644 index 0000000..6330833 --- /dev/null +++ b/gui/text/text.go @@ -0,0 +1,118 @@ +package text + +import ( + "image" + "unicode" + "unicode/utf8" + + "golang.org/x/image/math/fixed" + "github.com/mineracks/seedhammer-v1-companion/font/bitmap" +) + +type Line struct { + Text string + Width int + Dot image.Point +} + +type Style struct { + Face *bitmap.Face + Alignment Alignment + LineHeight float32 + LetterSpacing int +} + +type Alignment int + +const ( + AlignStart Alignment = iota + AlignEnd + AlignCenter +) + +func (l Style) Layout(maxWidth int, txt string) ([]Line, image.Point) { + var lines []Line + prevC := rune(-1) + adv := fixed.I(0) + wordAdv := fixed.I(0) + wordIdx := 0 + maxAdv := 0 + prev := 0 + idx := 0 + m := l.Face.Metrics() + asc, desc := m.Ascent, m.Descent + lheight := m.Height.Ceil() + if l.LineHeight != 0 { + lheight = int(float32(lheight) * l.LineHeight) + } + doty := asc.Ceil() + endLine := func() { + prevC = -1 + if a := adv.Ceil(); a > maxAdv { + maxAdv = a + } + lines = append(lines, Line{ + Text: txt[prev:idx], + Width: adv.Ceil(), + Dot: image.Pt(0, doty), + }) + wordIdx = 0 + wordAdv = 0 + doty += lheight + } + for idx < len(txt) { + c, n := utf8.DecodeRuneInString(txt[idx:]) + a, ok := l.Face.GlyphAdvance(c) + if !ok { + prevC = -1 + idx += n + continue + } + softnl := unicode.IsSpace(c) + if softnl { + wordIdx = idx + wordAdv = adv + } + if prevC >= 0 { + a += l.Face.Kern(prevC, c) + } + a += fixed.I(l.LetterSpacing) + if c == '\n' || (idx > prev && (adv+a).Ceil() > maxWidth) { + if wordIdx > 0 { + idx = wordIdx + adv = wordAdv + _, n = utf8.DecodeRuneInString(txt[idx:]) + softnl = true + } + endLine() + prev = idx + idx += n + adv = a + if softnl { + // Skip space or newline. + prev += n + adv = 0 + } + continue + } + idx += n + prevC = c + adv += a + } + idx = len(txt) + if prev < idx { + endLine() + } + for i, line := range lines { + switch l.Alignment { + case AlignCenter: + lines[i].Dot.X = (maxAdv - line.Width) / 2 + case AlignEnd: + lines[i].Dot.X = maxAdv - line.Width + } + } + return lines, image.Point{ + X: maxAdv, + Y: doty - lheight + desc.Ceil(), + } +} diff --git a/gui/theme.go b/gui/theme.go new file mode 100644 index 0000000..c416229 --- /dev/null +++ b/gui/theme.go @@ -0,0 +1,120 @@ +package gui + +import ( + "image/color" + + "github.com/mineracks/seedhammer-v1-companion/font/comfortaa" + "github.com/mineracks/seedhammer-v1-companion/font/poppins" + "github.com/mineracks/seedhammer-v1-companion/gui/text" +) + +var theme struct { + overlayMask uint8 + activeMask uint8 + inactiveMask uint8 +} + +type Styles struct { + title text.Style + subtitle text.Style + body text.Style + lead text.Style + button text.Style + word text.Style + keyboard text.Style + warning text.Style + nav text.Style + debug text.Style + progress text.Style +} + +type Colors struct { + Background color.NRGBA + Text color.NRGBA + Primary color.NRGBA +} + +var ( + descriptorTheme Colors + singleTheme Colors + engraveTheme Colors + cameraTheme Colors +) + +const leadingSize = 44 + +func init() { + prim := rgb(0x02427d) + descriptorTheme = Colors{ + Background: rgb(0x267f26), + Text: rgb(0xe9f2ea), + Primary: prim, + } + singleTheme = Colors{ + Background: rgb(0xdd9700), + Text: rgb(0xfbf4e8), + Primary: prim, + } + engraveTheme = Colors{ + Background: rgb(0xd1e83cb), + Text: rgb(0xdffffff), + Primary: prim, + } + cameraTheme = Colors{ + Text: rgb(0xfbf4e8), + } + theme.overlayMask = 0x55 + theme.activeMask = 0x55 + theme.inactiveMask = 0x55 +} + +func NewStyles() Styles { + return Styles{ + title: text.Style{ + Face: poppins.Bold23, + Alignment: text.AlignCenter, + LetterSpacing: -1, + LineHeight: 0.75, + }, + body: text.Style{ + Face: poppins.Regular16, + LineHeight: 0.75, + }, + debug: text.Style{ + Face: poppins.Bold10, + }, + warning: text.Style{ + Face: poppins.Bold23, + LineHeight: 0.75, + Alignment: text.AlignCenter, + }, + lead: text.Style{ + Face: poppins.Regular16, + LineHeight: 0.9, + Alignment: text.AlignCenter, + }, + subtitle: text.Style{ + Face: poppins.Bold16, + LineHeight: 0.9, + }, + nav: text.Style{ + Face: poppins.Bold23, + }, + button: text.Style{ + Face: poppins.Bold20, + Alignment: text.AlignCenter, + LineHeight: 0.70, + }, + word: text.Style{ + Face: comfortaa.Bold17, + }, + keyboard: text.Style{ + Face: poppins.Bold16, + }, + progress: text.Style{ + Face: poppins.Boldprogress45, + Alignment: text.AlignCenter, + LetterSpacing: -1, + }, + } +} diff --git a/gui/widget/label.go b/gui/widget/label.go new file mode 100644 index 0000000..4c8ca2f --- /dev/null +++ b/gui/widget/label.go @@ -0,0 +1,28 @@ +package widget + +import ( + "image" + "image/color" + "math" + + "github.com/mineracks/seedhammer-v1-companion/gui/op" + "github.com/mineracks/seedhammer-v1-companion/gui/text" +) + +func Label(ops op.Ctx, l text.Style, col color.NRGBA, txt string) image.Point { + return LabelW(ops, l, math.MaxInt, col, txt) +} + +func LabelW(ops op.Ctx, l text.Style, width int, col color.NRGBA, txt string) image.Point { + lines, sz := l.Layout(width, txt) + for _, line := range lines { + (&op.TextOp{ + Src: image.NewUniform(col), + Face: l.Face, + Dot: image.Pt(line.Dot.X, line.Dot.Y), + Txt: line.Text, + LetterSpacing: l.LetterSpacing, + }).Add(ops) + } + return sz +}