From 21b306e8409ea4d063db2672a0cce58859c5904b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Albornoz?= Date: Mon, 9 Feb 2026 17:38:04 -0500 Subject: [PATCH 1/8] Add new Compact share format --- internal/core/core_test.go | 136 ++++++++++++++++++++++++++++++ internal/core/share.go | 74 ++++++++++++++++ internal/html/assets/src/app.ts | 86 +++++++++++++++---- internal/html/assets/src/types.ts | 1 + internal/wasm/js_wrappers.go | 29 +++++++ internal/wasm/main_create.go | 1 + internal/wasm/main_recover.go | 1 + internal/wasm/recover.go | 19 +++++ 8 files changed, 332 insertions(+), 15 deletions(-) diff --git a/internal/core/core_test.go b/internal/core/core_test.go index b20a1d0..c588110 100644 --- a/internal/core/core_test.go +++ b/internal/core/core_test.go @@ -259,6 +259,142 @@ func TestShareFilename(t *testing.T) { } } +func TestCompactEncodeRoundTrip(t *testing.T) { + original := NewShare(1, 5, 3, "Alice", []byte("test-share-data-1234567890")) + + compact := original.CompactEncode() + + decoded, err := ParseCompact(compact) + if err != nil { + t.Fatalf("ParseCompact: %v", err) + } + + if decoded.Version != original.Version { + t.Errorf("version: got %d, want %d", decoded.Version, original.Version) + } + if decoded.Index != original.Index { + t.Errorf("index: got %d, want %d", decoded.Index, original.Index) + } + if decoded.Total != original.Total { + t.Errorf("total: got %d, want %d", decoded.Total, original.Total) + } + if decoded.Threshold != original.Threshold { + t.Errorf("threshold: got %d, want %d", decoded.Threshold, original.Threshold) + } + if !bytes.Equal(decoded.Data, original.Data) { + t.Errorf("data mismatch") + } + if decoded.Checksum != original.Checksum { + t.Errorf("checksum: got %q, want %q", decoded.Checksum, original.Checksum) + } +} + +func TestCompactEncodeWithRealShares(t *testing.T) { + secret := []byte("a]Zp9kR-mN2xB7qL_YwF4vC8hD6sE0jT") + shares, err := Split(secret, 5, 3) + if err != nil { + t.Fatalf("Split: %v", err) + } + + for i, shareData := range shares { + share := NewShare(i+1, 5, 3, "", shareData) + compact := share.CompactEncode() + + decoded, err := ParseCompact(compact) + if err != nil { + t.Fatalf("share %d: ParseCompact(%q): %v", i+1, compact, err) + } + if !bytes.Equal(decoded.Data, shareData) { + t.Errorf("share %d: data mismatch after round-trip", i+1) + } + } +} + +func TestCompactEncodeFormat(t *testing.T) { + share := NewShare(2, 5, 3, "Bob", []byte{0xDE, 0xAD, 0xBE, 0xEF}) + compact := share.CompactEncode() + + if !strings.HasPrefix(compact, "RM1:") { + t.Errorf("should start with RM1:, got %q", compact) + } + + parts := strings.Split(compact, ":") + if len(parts) != 6 { + t.Fatalf("expected 6 parts, got %d: %q", len(parts), compact) + } + if parts[0] != "RM1" { + t.Errorf("version prefix: got %q, want RM1", parts[0]) + } + if parts[1] != "2" { + t.Errorf("index: got %q, want 2", parts[1]) + } + if parts[2] != "5" { + t.Errorf("total: got %q, want 5", parts[2]) + } + if parts[3] != "3" { + t.Errorf("threshold: got %q, want 3", parts[3]) + } + // base64url of 0xDEADBEEF with no padding + if parts[4] != "3q2-7w" { + t.Errorf("data: got %q, want 3q2-7w", parts[4]) + } + // checksum should be 4 hex chars + if len(parts[5]) != 4 { + t.Errorf("checksum length: got %d, want 4", len(parts[5])) + } +} + +func TestParseCompactRejectsBadInput(t *testing.T) { + // Build a valid compact string to use as a base + share := NewShare(1, 5, 3, "Alice", []byte("valid-data")) + valid := share.CompactEncode() + + tests := []struct { + name string + input string + }{ + {"empty string", ""}, + {"too few fields", "RM1:1:5:3:data"}, + {"too many fields", "RM1:1:5:3:data:check:extra"}, + {"wrong prefix", "XX1:1:5:3:AAAA:0000"}, + {"bad version", "RMx:1:5:3:AAAA:0000"}, + {"zero version", "RM0:1:5:3:AAAA:0000"}, + {"negative index", "RM1:-1:5:3:AAAA:0000"}, + {"zero index", "RM1:0:5:3:AAAA:0000"}, + {"zero total", "RM1:1:0:3:AAAA:0000"}, + {"zero threshold", "RM1:1:5:0:AAAA:0000"}, + {"bad base64", "RM1:1:5:3:!!!invalid!!!:0000"}, + {"wrong checksum", valid[:len(valid)-4] + "ffff"}, + {"truncated", valid[:len(valid)/2]}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseCompact(tt.input) + if err == nil { + t.Errorf("expected error for %q, got nil", tt.input) + } + }) + } +} + +func TestCompactEncodeNoHolderOrCreated(t *testing.T) { + // Compact format intentionally omits Holder and Created metadata + // to keep the string short for QR codes + share := NewShare(3, 7, 4, "Carol with spaces", []byte("some-share-data")) + compact := share.CompactEncode() + decoded, err := ParseCompact(compact) + if err != nil { + t.Fatalf("ParseCompact: %v", err) + } + if decoded.Holder != "" { + t.Errorf("holder should be empty in compact format, got %q", decoded.Holder) + } + if !decoded.Created.IsZero() { + t.Errorf("created should be zero in compact format, got %v", decoded.Created) + } +} + // createTarGz builds a tar.gz archive in memory with arbitrary entry names. // This allows crafting malicious archives for security testing. func createTarGz(t *testing.T, entries map[string]string) []byte { diff --git a/internal/core/share.go b/internal/core/share.go index db07f0b..ed84afc 100644 --- a/internal/core/share.go +++ b/internal/core/share.go @@ -1,7 +1,9 @@ package core import ( + "crypto/sha256" "encoding/base64" + "encoding/hex" "fmt" "strconv" "strings" @@ -186,6 +188,78 @@ func (s *Share) Verify() error { return nil } +// CompactEncode returns a short string encoding of the share suitable for +// QR codes and URL fragments. Format: RM{version}:{index}:{total}:{threshold}:{base64url_data}:{short_check} +// The short_check is the first 4 hex characters of the SHA-256 of the raw share data. +func (s *Share) CompactEncode() string { + data := base64.RawURLEncoding.EncodeToString(s.Data) + check := shortChecksum(s.Data) + return fmt.Sprintf("RM%d:%d:%d:%d:%s:%s", s.Version, s.Index, s.Total, s.Threshold, data, check) +} + +// ParseCompact parses a compact-encoded share string back into a Share. +// It validates the format, decodes the data, and verifies the short checksum. +func ParseCompact(s string) (*Share, error) { + parts := strings.Split(s, ":") + if len(parts) != 6 { + return nil, fmt.Errorf("invalid compact share: expected 6 colon-separated fields, got %d", len(parts)) + } + + prefix := parts[0] + if !strings.HasPrefix(prefix, "RM") { + return nil, fmt.Errorf("invalid compact share: must start with 'RM', got %q", prefix) + } + + version, err := strconv.Atoi(prefix[2:]) + if err != nil || version < 1 { + return nil, fmt.Errorf("invalid compact share: bad version %q", prefix[2:]) + } + + index, err := strconv.Atoi(parts[1]) + if err != nil || index < 1 { + return nil, fmt.Errorf("invalid compact share: bad index %q", parts[1]) + } + + total, err := strconv.Atoi(parts[2]) + if err != nil || total < 1 { + return nil, fmt.Errorf("invalid compact share: bad total %q", parts[2]) + } + + threshold, err := strconv.Atoi(parts[3]) + if err != nil || threshold < 1 { + return nil, fmt.Errorf("invalid compact share: bad threshold %q", parts[3]) + } + + data, err := base64.RawURLEncoding.DecodeString(parts[4]) + if err != nil { + return nil, fmt.Errorf("invalid compact share: bad base64 data: %w", err) + } + if len(data) == 0 { + return nil, fmt.Errorf("invalid compact share: empty data") + } + + // Verify short checksum + expectedCheck := shortChecksum(data) + if parts[5] != expectedCheck { + return nil, fmt.Errorf("invalid compact share: checksum mismatch (got %s, want %s)", parts[5], expectedCheck) + } + + return &Share{ + Version: version, + Index: index, + Total: total, + Threshold: threshold, + Data: data, + Checksum: HashBytes(data), + }, nil +} + +// shortChecksum returns the first 4 hex characters of the SHA-256 of data. +func shortChecksum(data []byte) string { + h := sha256.Sum256(data) + return hex.EncodeToString(h[:2]) +} + // Filename returns a suggested filename for this share. func (s *Share) Filename() string { name := s.Holder diff --git a/internal/html/assets/src/app.ts b/internal/html/assets/src/app.ts index 4ea809e..881df54 100644 --- a/internal/html/assets/src/app.ts +++ b/internal/html/assets/src/app.ts @@ -90,6 +90,9 @@ declare const t: TranslationFunction; // Share regex to extract from README.txt content const shareRegex = /-----BEGIN REMEMORY SHARE-----([\s\S]*?)-----END REMEMORY SHARE-----/; + // Compact share format regex: RM{version}:{index}:{total}:{threshold}:{base64url}:{check} + const compactShareRegex = /^RM\d+:\d+:\d+:\d+:[A-Za-z0-9_-]+:[0-9a-f]{4}$/; + // ============================================ // Error Handlers // ============================================ @@ -226,6 +229,9 @@ declare const t: TranslationFunction; if (personalization) { loadPersonalizationData(); } + + // Check URL fragment for compact share (e.g. #share=RM1:2:5:3:BASE64:CHECK) + loadShareFromFragment(); } // ============================================ @@ -252,6 +258,41 @@ declare const t: TranslationFunction; checkRecoverReady(); } + // ============================================ + // URL Fragment Share Loading + // ============================================ + + function loadShareFromFragment(): void { + if (!state.wasmReady) return; + + const hash = window.location.hash; + if (!hash || !hash.startsWith('#share=')) return; + + const compact = decodeURIComponent(hash.slice('#share='.length)); + if (!compactShareRegex.test(compact)) return; + + const result = window.rememoryParseCompactShare(compact); + if (result.error || !result.share) return; + + const share = result.share; + + if (state.shares.some(s => s.index === share.index)) return; + + if (state.shares.length === 0) { + state.threshold = share.threshold; + state.total = share.total; + } + + state.shares.push(share); + updateSharesUI(); + checkRecoverReady(); + + // Clear the fragment from the URL bar to avoid re-importing on reload + if (window.history?.replaceState) { + window.history.replaceState(null, '', window.location.pathname + window.location.search); + } + } + function renderContactList(): void { if (!personalization?.otherFriends || !elements.contactList) return; @@ -461,7 +502,36 @@ declare const t: TranslationFunction; clearInlineError(elements.shareDropZone); } - if (!shareRegex.test(content)) { + // Try compact format first, then PEM format + let share: import('./types').ParsedShare | undefined; + + if (compactShareRegex.test(content.trim())) { + const result = window.rememoryParseCompactShare(content.trim()); + if (result.error || !result.share) { + showError( + result.error || t('error_invalid_share_message', t('pasted_content')), + { + title: t('error_invalid_share_title'), + guidance: t('error_invalid_share_guidance') + } + ); + return; + } + share = result.share; + } else if (shareRegex.test(content)) { + const result = window.rememoryParseShare(content); + if (result.error || !result.share) { + showError( + t('error_invalid_share_message', t('pasted_content')), + { + title: t('error_invalid_share_title'), + guidance: t('error_invalid_share_guidance') + } + ); + return; + } + share = result.share; + } else { showError( t('error_paste_no_share_message'), { @@ -472,20 +542,6 @@ declare const t: TranslationFunction; return; } - const result = window.rememoryParseShare(content); - if (result.error || !result.share) { - showError( - t('error_invalid_share_message', t('pasted_content')), - { - title: t('error_invalid_share_title'), - guidance: t('error_invalid_share_guidance') - } - ); - return; - } - - const share = result.share; - if (state.shares.some(s => s.index === share.index)) { errorHandlers.duplicateShare(share.index); return; diff --git a/internal/html/assets/src/types.ts b/internal/html/assets/src/types.ts index 8c8ba97..d67b4cd 100644 --- a/internal/html/assets/src/types.ts +++ b/internal/html/assets/src/types.ts @@ -184,6 +184,7 @@ declare global { rememoryDecryptManifest(manifest: Uint8Array, passphrase: string): DecryptResult; rememoryExtractTarGz(data: Uint8Array): ExtractResult; rememoryExtractBundle(zipData: Uint8Array): BundleExtractResult; + rememoryParseCompactShare(compact: string): ShareParseResult; // Creation functions (create.wasm) rememoryCreateBundles(config: BundleConfig): BundleCreateResult; diff --git a/internal/wasm/js_wrappers.go b/internal/wasm/js_wrappers.go index cba94e5..df74de1 100644 --- a/internal/wasm/js_wrappers.go +++ b/internal/wasm/js_wrappers.go @@ -178,6 +178,35 @@ func extractBundleJS(this js.Value, args []js.Value) any { return js.ValueOf(result) } +// parseCompactShareJS parses a compact-encoded share string (e.g. RM1:2:5:3:BASE64:CHECK). +// Args: compact (string) +// Returns: { share: {...}, error: string|null } +func parseCompactShareJS(this js.Value, args []js.Value) any { + if len(args) < 1 { + return errorResult("missing compact share argument") + } + + compact := args[0].String() + share, err := parseCompactShare(compact) + if err != nil { + return errorResult(err.Error()) + } + + return js.ValueOf(map[string]any{ + "share": map[string]any{ + "version": share.Version, + "index": share.Index, + "total": share.Total, + "threshold": share.Threshold, + "holder": share.Holder, + "created": share.Created, + "checksum": share.Checksum, + "dataB64": share.DataB64, + }, + "error": nil, + }) +} + func errorResult(msg string) any { return js.ValueOf(map[string]any{ "error": msg, diff --git a/internal/wasm/main_create.go b/internal/wasm/main_create.go index f21c8f4..7cb55ce 100644 --- a/internal/wasm/main_create.go +++ b/internal/wasm/main_create.go @@ -13,6 +13,7 @@ func main() { js.Global().Set("rememoryDecryptManifest", js.FuncOf(decryptManifestJS)) js.Global().Set("rememoryExtractTarGz", js.FuncOf(extractTarGzJS)) js.Global().Set("rememoryExtractBundle", js.FuncOf(extractBundleJS)) + js.Global().Set("rememoryParseCompactShare", js.FuncOf(parseCompactShareJS)) // Register bundle creation functions js.Global().Set("rememoryCreateBundles", js.FuncOf(createBundlesJS)) diff --git a/internal/wasm/main_recover.go b/internal/wasm/main_recover.go index f078fb9..2c44574 100644 --- a/internal/wasm/main_recover.go +++ b/internal/wasm/main_recover.go @@ -13,6 +13,7 @@ func main() { js.Global().Set("rememoryDecryptManifest", js.FuncOf(decryptManifestJS)) js.Global().Set("rememoryExtractTarGz", js.FuncOf(extractTarGzJS)) js.Global().Set("rememoryExtractBundle", js.FuncOf(extractBundleJS)) + js.Global().Set("rememoryParseCompactShare", js.FuncOf(parseCompactShareJS)) // Signal that WASM is ready js.Global().Set("rememoryReady", true) diff --git a/internal/wasm/recover.go b/internal/wasm/recover.go index 5db0f6c..1482931 100644 --- a/internal/wasm/recover.go +++ b/internal/wasm/recover.go @@ -57,6 +57,25 @@ func parseShare(content string) (*ShareInfo, error) { }, nil } +// parseCompactShare parses a compact-encoded share string. +func parseCompactShare(compact string) (*ShareInfo, error) { + share, err := core.ParseCompact(compact) + if err != nil { + return nil, err + } + + return &ShareInfo{ + Version: share.Version, + Index: share.Index, + Total: share.Total, + Threshold: share.Threshold, + Holder: share.Holder, + Created: share.Created.Format("2006-01-02T15:04:05Z07:00"), + Checksum: share.Checksum, + DataB64: base64.StdEncoding.EncodeToString(share.Data), + }, nil +} + // combineShares combines multiple shares to recover the passphrase. // Uses core.Combine for the actual combination. func combineShares(shares []ShareData) (string, error) { From 6aea5283d3db25220c44e73e7fdd306954e76418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Albornoz?= Date: Mon, 9 Feb 2026 17:51:03 -0500 Subject: [PATCH 2/8] add QR to PDF with share in short format --- go.mod | 1 + go.sum | 2 + internal/pdf/readme.go | 75 ++++++++++++++++-- internal/pdf/readme_test.go | 147 ++++++++++++++++++++++++++++++++++++ 4 files changed, 217 insertions(+), 8 deletions(-) create mode 100644 internal/pdf/readme_test.go diff --git a/go.mod b/go.mod index d0eca73..92928da 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/spf13/pflag v1.0.9 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.46.0 // indirect diff --git a/go.sum b/go.sum index 113730c..ddd0f6e 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= diff --git a/internal/pdf/readme.go b/internal/pdf/readme.go index 9512de6..fe288d6 100644 --- a/internal/pdf/readme.go +++ b/internal/pdf/readme.go @@ -7,6 +7,7 @@ import ( "time" "github.com/go-pdf/fpdf" + qrcode "github.com/skip2/go-qrcode" "github.com/eljojo/rememory/internal/core" "github.com/eljojo/rememory/internal/project" @@ -26,6 +27,7 @@ type ReadmeData struct { RecoverChecksum string Created time.Time Anonymous bool + RecoveryURL string // Optional: base URL for QR code (e.g. "https://example.com/recover.html") } // Font sizes @@ -34,8 +36,23 @@ const ( headingSize = 12.0 bodySize = 10.0 monoSize = 8.0 + smallMono = 7.0 ) +// QR code size in mm on the PDF page. +const qrSizeMM = 70.0 + +// QRContent returns the string that will be encoded in the QR code. +// If RecoveryURL is set, it returns "URL#share=COMPACT". +// Otherwise it returns just the compact share string. +func (d ReadmeData) QRContent() string { + compact := d.Share.CompactEncode() + if d.RecoveryURL != "" { + return d.RecoveryURL + "#share=" + compact + } + return compact +} + // GenerateReadme creates the README.pdf content. func GenerateReadme(data ReadmeData) ([]byte, error) { pdf := fpdf.New("P", "mm", "A4", "") @@ -148,27 +165,64 @@ func GenerateReadme(data ReadmeData) ([]byte, error) { addBody(pdf, "Usage: rememory recover share1.txt share2.txt ... --manifest MANIFEST.age") pdf.Ln(5) - // Section: Share - addSection(pdf, "YOUR SHARE (upload this file or copy-paste this block)") - pdf.SetFont(fontMono, "", monoSize) + // ============================================ + // Page 2: QR Code + Share Data + // ============================================ + pdf.AddPage() + + // QR code section header + addSection(pdf, "YOUR SHARE") + pdf.Ln(2) + + // Generate QR code PNG + qrContent := data.QRContent() + qrPNG, err := generateQRPNG(qrContent) + if err != nil { + return nil, fmt.Errorf("generating QR code: %w", err) + } + + // Register QR image and place it centered + qrReader := bytes.NewReader(qrPNG) + opts := fpdf.ImageOptions{ImageType: "PNG", ReadDpi: true} + pdf.RegisterImageOptionsReader("qrcode", opts, qrReader) + pageWidth, _ := pdf.GetPageSize() + leftMargin, _, rightMargin, _ := pdf.GetMargins() + contentWidth := pageWidth - leftMargin - rightMargin + qrX := leftMargin + (contentWidth-qrSizeMM)/2 + pdf.ImageOptions("qrcode", qrX, pdf.GetY(), qrSizeMM, qrSizeMM, false, opts, 0, "") + pdf.SetY(pdf.GetY() + qrSizeMM + 3) + + // Caption under QR code + pdf.SetFont(fontSans, "I", bodySize) + pdf.CellFormat(0, 5, "Scan this with your phone camera to import your share", "", 1, "C", false, 0, "") + pdf.Ln(2) + + // Show the compact string below the QR for reference + pdf.SetFont(fontMono, "", smallMono) + pdf.SetFillColor(245, 245, 245) + pdf.CellFormat(0, 4, qrContent, "", 1, "C", true, 0, "") + pdf.Ln(8) + + // PEM block (machine-readable format) + addSection(pdf, "MACHINE-READABLE FORMAT") + pdf.SetFont(fontMono, "", smallMono) pdf.SetFillColor(245, 245, 245) - // Draw share in a box shareText := data.Share.Encode() shareLines := strings.Split(shareText, "\n") for _, line := range shareLines { if line != "" { - pdf.CellFormat(0, 4, line, "", 1, "L", true, 0, "") + pdf.CellFormat(0, 3.5, line, "", 1, "L", true, 0, "") } else { - pdf.Ln(2) + pdf.Ln(1.5) } } pdf.Ln(5) // Footer: Metadata - pdf.SetFont(fontSans, "B", monoSize) + pdf.SetFont(fontSans, "B", smallMono) pdf.CellFormat(0, 5, "METADATA", "", 1, "L", false, 0, "") - pdf.SetFont(fontMono, "", monoSize) + pdf.SetFont(fontMono, "", smallMono) pdf.SetFillColor(245, 245, 245) addMeta(pdf, "rememory-version", data.Version) addMeta(pdf, "created", data.Created.Format(time.RFC3339)) @@ -203,3 +257,8 @@ func addBody(pdf *fpdf.Fpdf, text string) { func addMeta(pdf *fpdf.Fpdf, key, value string) { pdf.CellFormat(0, 4, fmt.Sprintf("%s: %s", key, value), "", 1, "L", true, 0, "") } + +// generateQRPNG creates a QR code PNG image for the given content string. +func generateQRPNG(content string) ([]byte, error) { + return qrcode.Encode(content, qrcode.Medium, 512) +} diff --git a/internal/pdf/readme_test.go b/internal/pdf/readme_test.go new file mode 100644 index 0000000..92f5288 --- /dev/null +++ b/internal/pdf/readme_test.go @@ -0,0 +1,147 @@ +package pdf + +import ( + "bytes" + "image/png" + "testing" + "time" + + "github.com/eljojo/rememory/internal/core" + "github.com/eljojo/rememory/internal/project" +) + +func testReadmeData() ReadmeData { + share := core.NewShare(1, 3, 2, "Alice", []byte("test-share-data-for-qr-code-12345")) + return ReadmeData{ + ProjectName: "Test Project", + Holder: "Alice", + Share: share, + OtherFriends: []project.Friend{{Name: "Bob", Contact: "bob@example.com"}}, + Threshold: 2, + Total: 3, + Version: "v0.0.1-test", + GitHubReleaseURL: "https://github.com/eljojo/rememory/releases", + ManifestChecksum: "sha256:abcdef1234567890", + RecoverChecksum: "sha256:0987654321fedcba", + Created: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } +} + +func TestGenerateReadme(t *testing.T) { + data := testReadmeData() + pdfBytes, err := GenerateReadme(data) + if err != nil { + t.Fatalf("GenerateReadme: %v", err) + } + if len(pdfBytes) == 0 { + t.Fatal("generated PDF is empty") + } + // Verify it's a valid PDF (starts with %PDF-) + if !bytes.HasPrefix(pdfBytes, []byte("%PDF-")) { + t.Error("output does not start with PDF header") + } +} + +func TestGenerateReadmeAnonymous(t *testing.T) { + data := testReadmeData() + data.Anonymous = true + data.OtherFriends = nil + pdfBytes, err := GenerateReadme(data) + if err != nil { + t.Fatalf("GenerateReadme (anonymous): %v", err) + } + if len(pdfBytes) == 0 { + t.Fatal("generated PDF is empty") + } +} + +func TestQRContent(t *testing.T) { + data := testReadmeData() + + // Without RecoveryURL: just the compact share string + content := data.QRContent() + expected := data.Share.CompactEncode() + if content != expected { + t.Errorf("QRContent without URL: got %q, want %q", content, expected) + } + + // Verify the compact string round-trips + _, err := core.ParseCompact(content) + if err != nil { + t.Fatalf("compact string from QRContent doesn't parse: %v", err) + } +} + +func TestQRContentWithRecoveryURL(t *testing.T) { + data := testReadmeData() + data.RecoveryURL = "https://example.com/recover.html" + + content := data.QRContent() + expected := "https://example.com/recover.html#share=" + data.Share.CompactEncode() + if content != expected { + t.Errorf("QRContent with URL: got %q, want %q", content, expected) + } +} + +func TestQRCodeGeneratesValidPNG(t *testing.T) { + data := testReadmeData() + + // Generate the PDF (which includes the QR code) + pdfBytes, err := GenerateReadme(data) + if err != nil { + t.Fatalf("GenerateReadme: %v", err) + } + if len(pdfBytes) == 0 { + t.Fatal("generated PDF is empty") + } + + // Also verify the QR code PNG directly + qrContent := data.QRContent() + qrPNG, err := generateQRPNG(qrContent) + if err != nil { + t.Fatalf("generateQRPNG: %v", err) + } + + // Verify it's a valid PNG + img, err := png.Decode(bytes.NewReader(qrPNG)) + if err != nil { + t.Fatalf("QR code is not valid PNG: %v", err) + } + + bounds := img.Bounds() + if bounds.Dx() == 0 || bounds.Dy() == 0 { + t.Error("QR code image has zero dimensions") + } +} + +func TestQRCodeContentMatchesCompact(t *testing.T) { + // Verify the data encoded in the QR code is exactly the compact share string + share := core.NewShare(2, 5, 3, "Bob", []byte("another-share-data-for-testing")) + data := ReadmeData{ + Share: share, + Holder: "Bob", + Threshold: 3, + Total: 5, + } + + qrContent := data.QRContent() + compact := share.CompactEncode() + + if qrContent != compact { + t.Errorf("QR content doesn't match compact encoding:\n got: %q\n want: %q", qrContent, compact) + } + + // Verify the compact string correctly round-trips + parsed, err := core.ParseCompact(qrContent) + if err != nil { + t.Fatalf("ParseCompact: %v", err) + } + if parsed.Index != share.Index || parsed.Total != share.Total || parsed.Threshold != share.Threshold { + t.Errorf("parsed share metadata mismatch: got %d/%d/%d, want %d/%d/%d", + parsed.Index, parsed.Total, parsed.Threshold, + share.Index, share.Total, share.Threshold) + } + if !bytes.Equal(parsed.Data, share.Data) { + t.Error("parsed share data mismatch") + } +} From 78b248eb9830d454e3c21c81149d99ae8ea008a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Albornoz?= Date: Mon, 9 Feb 2026 17:56:12 -0500 Subject: [PATCH 3/8] Generate recover.html on CI, point QR to it, and allow customizing --- .github/workflows/pages.yml | 1 + internal/bundle/bundle.go | 8 ++++++++ internal/cmd/bundle.go | 4 ++++ internal/cmd/demo.go | 2 +- internal/cmd/seal.go | 9 +++++++-- internal/wasm/create.go | 2 ++ 6 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index fc37b2a..b871a08 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -53,6 +53,7 @@ jobs: ./rememory html index > _site/index.html ./rememory html create > _site/maker.html ./rememory html docs > _site/docs.html + ./rememory html recover > _site/recover.html cp docs/screenshots/* _site/screenshots/ 2>/dev/null || true - name: Setup Pages diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go index ca9f797..c828891 100644 --- a/internal/bundle/bundle.go +++ b/internal/bundle/bundle.go @@ -16,11 +16,16 @@ import ( "github.com/eljojo/rememory/internal/project" ) +// DefaultRecoveryURL is the default base URL for QR codes in PDFs. +// Points to the recover.html hosted on GitHub Pages. +const DefaultRecoveryURL = "https://eljojo.github.io/rememory/recover.html" + // Config holds configuration for bundle generation. type Config struct { Version string // Tool version (e.g., "v1.0.0") GitHubReleaseURL string // URL to GitHub release for CLI download WASMBytes []byte // Compiled recover.wasm binary + RecoveryURL string // Optional: base URL for QR code (e.g. "https://example.com/recover.html") } // GenerateAll creates bundles for all friends in the project. @@ -102,6 +107,7 @@ func GenerateAll(p *project.Project, cfg Config) error { GitHubReleaseURL: cfg.GitHubReleaseURL, SealedAt: p.Sealed.At, Anonymous: p.Anonymous, + RecoveryURL: cfg.RecoveryURL, }) if err != nil { return fmt.Errorf("generating bundle for %s: %w", friend.Name, err) @@ -133,6 +139,7 @@ type BundleParams struct { GitHubReleaseURL string SealedAt time.Time Anonymous bool + RecoveryURL string } // GenerateBundle creates a single bundle ZIP file for one friend. @@ -170,6 +177,7 @@ func GenerateBundle(params BundleParams) error { RecoverChecksum: readmeData.RecoverChecksum, Created: readmeData.Created, Anonymous: readmeData.Anonymous, + RecoveryURL: params.RecoveryURL, }) if err != nil { return fmt.Errorf("generating PDF: %w", err) diff --git a/internal/cmd/bundle.go b/internal/cmd/bundle.go index 0b35bac..45483f9 100644 --- a/internal/cmd/bundle.go +++ b/internal/cmd/bundle.go @@ -30,6 +30,7 @@ Each bundle contains: } func init() { + bundleCmd.Flags().String("recovery-url", bundle.DefaultRecoveryURL, "Base URL for QR code in PDF") rootCmd.AddCommand(bundleCmd) } @@ -65,10 +66,13 @@ func runBundle(cmd *cobra.Command, args []string) error { // Generate bundles fmt.Printf("Generating bundles for %d friends...\n\n", len(p.Friends)) + recoveryURL, _ := cmd.Flags().GetString("recovery-url") + cfg := bundle.Config{ Version: version, GitHubReleaseURL: fmt.Sprintf("https://github.com/eljojo/rememory/releases/tag/%s", version), WASMBytes: wasmBytes, + RecoveryURL: recoveryURL, } if err := bundle.GenerateAll(p, cfg); err != nil { diff --git a/internal/cmd/demo.go b/internal/cmd/demo.go index 9287192..ed810d2 100644 --- a/internal/cmd/demo.go +++ b/internal/cmd/demo.go @@ -119,7 +119,7 @@ Note: In a real project, these would be your actual sensitive credentials. fmt.Printf(" %s manifest/passwords.txt\n", green("✓")) fmt.Println() - if err := sealProject(p); err != nil { + if err := sealProject(p, ""); err != nil { return err } diff --git a/internal/cmd/seal.go b/internal/cmd/seal.go index 487863c..1ee4dde 100644 --- a/internal/cmd/seal.go +++ b/internal/cmd/seal.go @@ -35,6 +35,7 @@ Run this command inside a project directory (created with 'rememory init').`, } func init() { + sealCmd.Flags().String("recovery-url", bundle.DefaultRecoveryURL, "Base URL for QR code in PDF") rootCmd.AddCommand(sealCmd) } @@ -59,7 +60,9 @@ func runSeal(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid project: %w", err) } - if err := sealProject(p); err != nil { + recoveryURL, _ := cmd.Flags().GetString("recovery-url") + + if err := sealProject(p, recoveryURL); err != nil { return err } @@ -71,7 +74,8 @@ func runSeal(cmd *cobra.Command, args []string) error { // sealProject archives, encrypts, splits, verifies, saves, and generates bundles // for an already-loaded project. Both runSeal and runDemo share this logic. -func sealProject(p *project.Project) error { +// recoveryURL is an optional base URL for QR codes in the PDF (empty = compact share only). +func sealProject(p *project.Project, recoveryURL string) error { // Check manifest directory exists and has content manifestDir := p.ManifestPath() fileCount, err := manifest.CountFiles(manifestDir) @@ -217,6 +221,7 @@ func sealProject(p *project.Project) error { Version: version, GitHubReleaseURL: fmt.Sprintf("https://github.com/eljojo/rememory/releases/tag/%s", version), WASMBytes: wasmBytes, + RecoveryURL: recoveryURL, } if err := bundle.GenerateAll(p, cfg); err != nil { diff --git a/internal/wasm/create.go b/internal/wasm/create.go index de79184..e6acfd7 100644 --- a/internal/wasm/create.go +++ b/internal/wasm/create.go @@ -269,6 +269,7 @@ func createBundles(config CreateBundlesConfig) ([]BundleOutput, error) { readmeContent := bundle.GenerateReadme(readmeData) // Generate README.pdf + // Web-created bundles always use the GitHub Pages recovery URL pdfData := pdf.ReadmeData{ ProjectName: config.ProjectName, Holder: friend.Name, @@ -282,6 +283,7 @@ func createBundles(config CreateBundlesConfig) ([]BundleOutput, error) { RecoverChecksum: recoverChecksum, Created: now, Anonymous: config.Anonymous, + RecoveryURL: bundle.DefaultRecoveryURL, } pdfContent, err := pdf.GenerateReadme(pdfData) if err != nil { From 365f7d6a43d1dbe75cb3fd17c97de35220c44a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Albornoz?= Date: Mon, 9 Feb 2026 19:49:46 -0500 Subject: [PATCH 4/8] improve recovery UX after testing with PDFs --- Makefile | 8 +- e2e/helpers.ts | 3 +- e2e/qr-scanner.spec.ts | 365 ++++++++++++++++++++++++++++++ internal/bundle/bundle.go | 4 - internal/cmd/bundle.go | 3 +- internal/cmd/seal.go | 4 +- internal/core/share.go | 4 + internal/html/assets/recover.html | 48 +++- internal/html/assets/src/app.ts | 160 ++++++++++++- internal/html/assets/src/types.ts | 19 ++ internal/html/assets/styles.css | 74 ++++++ internal/pdf/readme.go | 116 +++++----- internal/pdf/readme_test.go | 21 +- internal/wasm/create.go | 1 - 14 files changed, 738 insertions(+), 92 deletions(-) create mode 100644 e2e/qr-scanner.spec.ts diff --git a/Makefile b/Makefile index d11d3a6..f49d0af 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test test-e2e test-e2e-headed lint clean install wasm ts build-all bump-patch bump-minor bump-major man html serve +.PHONY: build test test-e2e test-e2e-headed lint clean install wasm ts build-all bump-patch bump-minor bump-major man html serve demo BINARY := rememory VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null || echo "dev") @@ -81,6 +81,12 @@ serve: html @echo "Serving at http://localhost:8000" @cd dist && python3 -m http.server 8000 +# Run demo: clean, build, and create a demo project +demo: build + rm -rf demo-recovery + ./$(BINARY) demo + open demo-recovery/output/bundles/bundle-alice.zip + # Cross-compile for all platforms (used by CI) build-all: wasm @mkdir -p dist diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 8450c5c..508c805 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -136,7 +136,8 @@ export class RecoveryPage { } async expectShareHolder(name: string): Promise { - await expect(this.page.locator('.share-item').filter({ hasText: name })).toBeVisible(); + // Use toBeAttached() since shares may be hidden when threshold is met + await expect(this.page.locator('.share-item').filter({ hasText: name })).toBeAttached(); } async expectReadyToRecover(): Promise { diff --git a/e2e/qr-scanner.spec.ts b/e2e/qr-scanner.spec.ts new file mode 100644 index 0000000..f61ec02 --- /dev/null +++ b/e2e/qr-scanner.spec.ts @@ -0,0 +1,365 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + getRememoryBin, + createTestProject, + extractBundle, + extractBundles, + RecoveryPage +} from './helpers'; + +test.describe('QR Scanner', () => { + let projectDir: string; + let bundlesDir: string; + + test.beforeAll(async () => { + const bin = getRememoryBin(); + if (!fs.existsSync(bin)) { + test.skip(); + return; + } + + projectDir = createTestProject(); + bundlesDir = path.join(projectDir, 'output', 'bundles'); + }); + + test.afterAll(async () => { + if (projectDir && fs.existsSync(projectDir)) { + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + // Extract a compact share string from a bundle's README.txt using the CLI + function getCompactShare(bundleDir: string): string { + const readmePath = path.join(bundleDir, 'README.txt'); + const content = fs.readFileSync(readmePath, 'utf8'); + + // Parse the PEM share via the CLI to get the compact format + // We can use the share content directly - extract the PEM block + const pemMatch = content.match( + /-----BEGIN REMEMORY SHARE-----([\s\S]*?)-----END REMEMORY SHARE-----/ + ); + if (!pemMatch) throw new Error('No PEM share found in README.txt'); + + // Use the binary to convert - run rememory doc compact-share with the share file + // Actually, let's just extract the share data from the output directory + const sharesDir = path.join(projectDir, 'output', 'shares'); + const shareFiles = fs.readdirSync(sharesDir); + + // We need the compact format. Let's get it via page.evaluate after WASM loads. + // For now, return the full PEM content and we'll convert in-browser. + return pemMatch[0]; + } + + test('scan button is visible when BarcodeDetector is available', async ({ page }) => { + const bundleDir = extractBundle(bundlesDir, 'Alice'); + + // Mock BarcodeDetector before page loads + await page.addInitScript(() => { + (window as any).BarcodeDetector = class { + constructor() {} + async detect() { return []; } + static async getSupportedFormats() { return ['qr_code']; } + }; + }); + + const recovery = new RecoveryPage(page, bundleDir); + await recovery.open(); + + await expect(page.locator('#scan-qr-btn')).toBeVisible(); + }); + + test('scan button is hidden when BarcodeDetector is not available', async ({ page }) => { + const bundleDir = extractBundle(bundlesDir, 'Alice'); + + // Ensure BarcodeDetector is NOT defined (default for most test environments) + await page.addInitScript(() => { + delete (window as any).BarcodeDetector; + }); + + const recovery = new RecoveryPage(page, bundleDir); + await recovery.open(); + + await expect(page.locator('#scan-qr-btn')).not.toBeVisible(); + }); + + test('clicking scan opens modal and close button dismisses it', async ({ page }) => { + const bundleDir = extractBundle(bundlesDir, 'Alice'); + + await page.addInitScript(() => { + (window as any).BarcodeDetector = class { + constructor() {} + async detect() { return []; } + static async getSupportedFormats() { return ['qr_code']; } + }; + + navigator.mediaDevices.getUserMedia = async () => { + const canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 480; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, 640, 480); + return canvas.captureStream(1); + }; + }); + + const recovery = new RecoveryPage(page, bundleDir); + await recovery.open(); + + // Modal should be hidden initially + await expect(page.locator('#qr-scanner-modal')).not.toBeVisible(); + + // Click scan button + await page.locator('#scan-qr-btn').click(); + + // Modal should be visible + await expect(page.locator('#qr-scanner-modal')).toBeVisible(); + + // Close button should dismiss modal + await page.locator('#qr-scanner-close').click(); + await expect(page.locator('#qr-scanner-modal')).not.toBeVisible(); + }); + + test('scanning a compact share adds it to the shares list', async ({ page }) => { + const [aliceDir, bobDir] = extractBundles(bundlesDir, ['Alice', 'Bob']); + + const recovery = new RecoveryPage(page, aliceDir); + + // Read Bob's PEM share + const bobReadme = fs.readFileSync(path.join(bobDir, 'README.txt'), 'utf8'); + const pemMatch = bobReadme.match( + /-----BEGIN REMEMORY SHARE-----([\s\S]*?)-----END REMEMORY SHARE-----/ + ); + if (!pemMatch) throw new Error('No PEM share found'); + const bobPemShare = pemMatch[0]; + + // Mock BarcodeDetector + getUserMedia with a real canvas-based video stream + await page.addInitScript(() => { + let detectCallCount = 0; + let compactShare = ''; + + (window as any).__qrTestSetCompact = (compact: string) => { + compactShare = compact; + }; + + (window as any).BarcodeDetector = class { + constructor() {} + async detect() { + detectCallCount++; + if (compactShare && detectCallCount > 3) { + return [{ rawValue: compactShare, format: 'qr_code', boundingBox: {}, cornerPoints: [] }]; + } + return []; + } + static async getSupportedFormats() { return ['qr_code']; } + }; + + // Use a real canvas capture stream so the video element gets readyState >= 2 + navigator.mediaDevices.getUserMedia = async () => { + const canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 480; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, 640, 480); + return canvas.captureStream(1); + }; + }); + + await recovery.open(); + await recovery.expectShareCount(1); + + // Convert Bob's PEM share to compact format via WASM + const compactShare = await page.evaluate((pem: string) => { + const result = (window as any).rememoryParseShare(pem); + if (result.error || !result.share) return ''; + const share = result.share; + const b64url = share.dataB64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const data = Uint8Array.from(atob(share.dataB64), (c: string) => c.charCodeAt(0)); + return crypto.subtle.digest('SHA-256', data).then((hash: ArrayBuffer) => { + const arr = new Uint8Array(hash); + const check = Array.from(arr.slice(0, 2)).map(b => b.toString(16).padStart(2, '0')).join(''); + return `RM1:${share.index}:${share.total}:${share.threshold}:${b64url}:${check}`; + }); + }, bobPemShare); + + expect(compactShare).toMatch(/^RM1:\d+:\d+:\d+:[A-Za-z0-9_-]+:[0-9a-f]{4}$/); + + // Verify the compact share parses correctly + const parseResult = await page.evaluate((compact: string) => { + return (window as any).rememoryParseCompactShare(compact); + }, compactShare); + expect(parseResult.error).toBeFalsy(); + + // Set the compact share for the mock BarcodeDetector to "find" + await page.evaluate((compact: string) => { + (window as any).__qrTestSetCompact(compact); + }, compactShare); + + // Open scanner + await page.locator('#scan-qr-btn').click(); + await expect(page.locator('#qr-scanner-modal')).toBeVisible(); + + // Wait for the share to be detected and added + await recovery.expectShareCount(2); + + // Modal should close after successful scan + await expect(page.locator('#qr-scanner-modal')).not.toBeVisible(); + }); + + test('scanning a URL with fragment adds the share', async ({ page }) => { + const [aliceDir, bobDir] = extractBundles(bundlesDir, ['Alice', 'Bob']); + + const bobReadme = fs.readFileSync(path.join(bobDir, 'README.txt'), 'utf8'); + const pemMatch = bobReadme.match( + /-----BEGIN REMEMORY SHARE-----([\s\S]*?)-----END REMEMORY SHARE-----/ + ); + if (!pemMatch) throw new Error('No PEM share found'); + + await page.addInitScript(() => { + let compactShare = ''; + + (window as any).__qrTestSetCompact = (compact: string) => { + compactShare = compact; + }; + + let detectCallCount = 0; + (window as any).BarcodeDetector = class { + constructor() {} + async detect() { + detectCallCount++; + if (compactShare && detectCallCount > 3) { + // Return as a URL with fragment, like the QR code from a PDF would contain + const url = `https://eljojo.github.io/rememory/recover.html#share=${encodeURIComponent(compactShare)}`; + return [{ rawValue: url, format: 'qr_code', boundingBox: {}, cornerPoints: [] }]; + } + return []; + } + static async getSupportedFormats() { return ['qr_code']; } + }; + + // Use a real canvas capture stream so the video element gets readyState >= 2 + navigator.mediaDevices.getUserMedia = async () => { + const canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 480; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, 640, 480); + return canvas.captureStream(1); + }; + }); + + const recovery = new RecoveryPage(page, aliceDir); + await recovery.open(); + + // Build compact share from PEM via in-browser conversion + const compactShare = await page.evaluate((pem: string) => { + const result = (window as any).rememoryParseShare(pem); + if (result.error || !result.share) return ''; + const share = result.share; + const b64url = share.dataB64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const data = Uint8Array.from(atob(share.dataB64), (c: string) => c.charCodeAt(0)); + return crypto.subtle.digest('SHA-256', data).then((hash: ArrayBuffer) => { + const arr = new Uint8Array(hash); + const check = Array.from(arr.slice(0, 2)).map(b => b.toString(16).padStart(2, '0')).join(''); + return `RM1:${share.index}:${share.total}:${share.threshold}:${b64url}:${check}`; + }); + }, pemMatch[0]); + + await page.evaluate((compact: string) => { + (window as any).__qrTestSetCompact(compact); + }, compactShare); + + await page.locator('#scan-qr-btn').click(); + + // Should detect the URL, extract the fragment, and add the share + await recovery.expectShareCount(2); + await expect(page.locator('#qr-scanner-modal')).not.toBeVisible(); + }); + + test('camera permission denied shows error and closes modal', async ({ page }) => { + const bundleDir = extractBundle(bundlesDir, 'Alice'); + + await page.addInitScript(() => { + (window as any).BarcodeDetector = class { + constructor() {} + async detect() { return []; } + static async getSupportedFormats() { return ['qr_code']; } + }; + + // Mock getUserMedia to reject (permission denied) + navigator.mediaDevices.getUserMedia = async () => { + throw new DOMException('Permission denied', 'NotAllowedError'); + }; + }); + + const recovery = new RecoveryPage(page, bundleDir); + await recovery.open(); + + await page.locator('#scan-qr-btn').click(); + + // Modal should close after error + await expect(page.locator('#qr-scanner-modal')).not.toBeVisible(); + + // A toast warning should appear + await expect(page.locator('.toast')).toBeVisible(); + }); + + test('camera tracks are stopped when modal is closed', async ({ page }) => { + const bundleDir = extractBundle(bundlesDir, 'Alice'); + + await page.addInitScript(() => { + (window as any).__qrTestTrackStopped = false; + + (window as any).BarcodeDetector = class { + constructor() {} + async detect() { return []; } + static async getSupportedFormats() { return ['qr_code']; } + }; + + // Use canvas capture stream but wrap tracks to detect stop() + const origGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); + navigator.mediaDevices.getUserMedia = async () => { + const canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 480; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, 640, 480); + const stream = canvas.captureStream(1); + + // Wrap track.stop() to detect when it's called + for (const track of stream.getTracks()) { + const origStop = track.stop.bind(track); + track.stop = () => { + (window as any).__qrTestTrackStopped = true; + origStop(); + }; + } + return stream; + }; + }); + + const recovery = new RecoveryPage(page, bundleDir); + await recovery.open(); + + // Open scanner + await page.locator('#scan-qr-btn').click(); + await expect(page.locator('#qr-scanner-modal')).toBeVisible(); + + // Verify track not yet stopped + let stopped = await page.evaluate(() => (window as any).__qrTestTrackStopped); + expect(stopped).toBe(false); + + // Close scanner + await page.locator('#qr-scanner-close').click(); + + // Verify track was stopped + stopped = await page.evaluate(() => (window as any).__qrTestTrackStopped); + expect(stopped).toBe(true); + }); +}); diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go index c828891..5398fe3 100644 --- a/internal/bundle/bundle.go +++ b/internal/bundle/bundle.go @@ -16,10 +16,6 @@ import ( "github.com/eljojo/rememory/internal/project" ) -// DefaultRecoveryURL is the default base URL for QR codes in PDFs. -// Points to the recover.html hosted on GitHub Pages. -const DefaultRecoveryURL = "https://eljojo.github.io/rememory/recover.html" - // Config holds configuration for bundle generation. type Config struct { Version string // Tool version (e.g., "v1.0.0") diff --git a/internal/cmd/bundle.go b/internal/cmd/bundle.go index 45483f9..475301e 100644 --- a/internal/cmd/bundle.go +++ b/internal/cmd/bundle.go @@ -6,6 +6,7 @@ import ( "path/filepath" "github.com/eljojo/rememory/internal/bundle" + "github.com/eljojo/rememory/internal/core" "github.com/eljojo/rememory/internal/html" "github.com/eljojo/rememory/internal/project" "github.com/spf13/cobra" @@ -30,7 +31,7 @@ Each bundle contains: } func init() { - bundleCmd.Flags().String("recovery-url", bundle.DefaultRecoveryURL, "Base URL for QR code in PDF") + bundleCmd.Flags().String("recovery-url", core.DefaultRecoveryURL, "Base URL for QR code in PDF") rootCmd.AddCommand(bundleCmd) } diff --git a/internal/cmd/seal.go b/internal/cmd/seal.go index 1ee4dde..dcd6985 100644 --- a/internal/cmd/seal.go +++ b/internal/cmd/seal.go @@ -35,7 +35,7 @@ Run this command inside a project directory (created with 'rememory init').`, } func init() { - sealCmd.Flags().String("recovery-url", bundle.DefaultRecoveryURL, "Base URL for QR code in PDF") + sealCmd.Flags().String("recovery-url", core.DefaultRecoveryURL, "Base URL for QR code in PDF") rootCmd.AddCommand(sealCmd) } @@ -74,7 +74,7 @@ func runSeal(cmd *cobra.Command, args []string) error { // sealProject archives, encrypts, splits, verifies, saves, and generates bundles // for an already-loaded project. Both runSeal and runDemo share this logic. -// recoveryURL is an optional base URL for QR codes in the PDF (empty = compact share only). +// recoveryURL is the base URL for QR codes in the PDF. If empty, the PDF defaults to the production URL. func sealProject(p *project.Project, recoveryURL string) error { // Check manifest directory exists and has content manifestDir := p.ManifestPath() diff --git a/internal/core/share.go b/internal/core/share.go index ed84afc..832fa30 100644 --- a/internal/core/share.go +++ b/internal/core/share.go @@ -16,6 +16,10 @@ import ( const ( ShareBegin = "-----BEGIN REMEMORY SHARE-----" ShareEnd = "-----END REMEMORY SHARE-----" + + // DefaultRecoveryURL is the default base URL for QR codes in PDFs. + // Points to the recover.html hosted on GitHub Pages. + DefaultRecoveryURL = "https://eljojo.github.io/rememory/recover.html" ) // Share represents a single Shamir share with metadata. diff --git a/internal/html/assets/recover.html b/internal/html/assets/recover.html index 9cb8850..c9ffd1e 100644 --- a/internal/html/assets/recover.html +++ b/internal/html/assets/recover.html @@ -10,6 +10,21 @@ + + +
@@ -42,6 +57,9 @@

1 Collect Sha + @@ -443,7 +445,8 @@

What Friends Receive

README.pdf - Same content, formatted for printing + Same content, formatted for printing. Also includes a QR code + that can be scanned to import the share directly.
MANIFEST.age @@ -460,16 +463,17 @@

What Friends Receive

and shows a contact list of other friends who hold shares.

-

Recovery Process

+

Path A: I Have the Bundle ZIP

- When your friends need to recover your secrets, here's what they do: + This is the easiest path. If you have the bundle ZIP file (or the individual files from it), + here's what to do:

1
-

Open recover.html

-

One friend opens the recovery tool from their bundle in any modern browser. Their share is automatically pre-loaded.

+

Extract the ZIP and open recover.html

+

Open the recovery tool in any modern browser. Your share is automatically pre-loaded.

@@ -485,7 +489,7 @@

Load the encrypted manifest

3

Coordinate with other friends

-

The tool shows a contact list with other friends' names and contact information. Reach out and ask them to send their README.txt file.

+

The tool shows a contact list with other friends' names and contact information. Reach out and ask them to send their README.txt file or share text.

@@ -493,7 +497,7 @@

Coordinate with other friends

4

Add shares from other friends

-

Drag and drop their README.txt files onto the page, or paste share text directly. A checkmark appears next to each friend's name as their share is added.

+

For each friend's share, you can: drag and drop their README.txt file, paste share text directly, or scan a QR code from their printed PDF. A checkmark appears next to each friend's name as their share is added.

@@ -505,6 +509,11 @@

Recovery happens automatically

+
+ Pro tip: If a friend sends you their entire .zip bundle file, + you can drag it directly onto the page—the manifest and their share are both imported automatically. +
+
Recovery interface - collecting shares
The recovery tool showing collected shares and contact list
@@ -515,6 +524,62 @@

Recovery happens automatically

Once threshold is met, files are decrypted and ready to download
+

Path B: I Have a Printed PDF

+

+ Some people prefer to keep just the PDF printout in a safe or filing cabinet and store the + MANIFEST.age file separately (e.g. cloud storage, email, USB drive). Since + the manifest is encrypted, it's safe to keep copies anywhere—nobody can open it without + combining enough shares. +

+

If you're starting from a printed PDF:

+ +
+
1
+
+

Open the recovery tool

+

Scan the QR code on the PDF with your phone camera—it opens the recovery website with your share pre-filled. Alternatively, visit the URL printed on the PDF and type the short code (RM1:...) shown below the QR code into the paste area.

+
+
+ +
+ Browser asking for camera permission +
Your browser will ask for permission to use the camera
+
+ +
+ Scanning a QR code from a printed PDF +
Point your camera at the QR code on the printed PDF to import the share
+
+ +
+
2
+
+

Load the encrypted manifest

+

You need the MANIFEST.age file—drag it onto the page or click to browse. If you don't have it, ask one of the other friends to send it from their bundle. Every bundle contains the same copy.

+
+
+ +
+ Selecting MANIFEST.age from a folder +
Select the MANIFEST.age file from where you stored it
+
+ +
+
3
+
+

Collect shares from other friends

+

Contact other friends and ask them to send their shares. They can send their README.txt, share text, or you can scan the QR code from their printed PDF.

+
+
+ +
+
4
+
+

Recovery happens automatically

+

Once the threshold is met, decryption starts immediately.

+
+
+
Key points about recovery:
    @@ -565,7 +630,7 @@

    CLI Alternative

    - Download CLI from GitHub + Read the CLI Guide

    From ce90ef98d801dfc3e257f7a560335390f3a07888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Albornoz?= Date: Mon, 9 Feb 2026 21:01:08 -0500 Subject: [PATCH 6/8] docs: move sidebar to left side of screen --- internal/html/assets/docs.html | 279 ++++++++++++++++++++++++--------- 1 file changed, 209 insertions(+), 70 deletions(-) diff --git a/internal/html/assets/docs.html b/internal/html/assets/docs.html index 97e7d1b..64fdfb5 100644 --- a/internal/html/assets/docs.html +++ b/internal/html/assets/docs.html @@ -17,18 +17,18 @@ -

    - - -
    -

    🧠 ReMemory User Guide

    -

    Learn how to create encrypted recovery bundles and recover your secrets

    -

    - Prefer the command line? See the CLI guide. -

    -
    - - + + +
    + + +
    +
    +

    🧠 ReMemory User Guide

    +

    Learn how to create encrypted recovery bundles and recover your secrets

    +

    + Prefer the command line? See the CLI guide. +

    +

    Overview

    @@ -685,15 +789,50 @@

    Recovery in Anonymous Mode

    You'll need to coordinate through other means.
    -
    - + +
    +
+ + From 6081bcf1a6a57f6d1b165867675d184f5125bf40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Albornoz?= Date: Mon, 9 Feb 2026 22:17:41 -0500 Subject: [PATCH 7/8] PR review --- e2e/qr-scanner.spec.ts | 45 +++-------------------------- go.mod | 2 +- internal/bundle/bundle.go | 15 ++++------ internal/html/assets/recover.html | 5 ---- internal/html/assets/src/app.ts | 23 ++------------- internal/html/assets/src/types.ts | 2 ++ internal/html/recover.go | 5 ++-- internal/pdf/readme.go | 3 +- internal/pdf/readme_test.go | 7 +++-- internal/wasm/create.go | 15 ++++------ internal/wasm/js_wrappers.go | 48 ++++++++++++------------------- internal/wasm/recover.go | 20 ++++++------- 12 files changed, 58 insertions(+), 132 deletions(-) diff --git a/e2e/qr-scanner.spec.ts b/e2e/qr-scanner.spec.ts index f61ec02..05a1add 100644 --- a/e2e/qr-scanner.spec.ts +++ b/e2e/qr-scanner.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { @@ -31,28 +30,6 @@ test.describe('QR Scanner', () => { } }); - // Extract a compact share string from a bundle's README.txt using the CLI - function getCompactShare(bundleDir: string): string { - const readmePath = path.join(bundleDir, 'README.txt'); - const content = fs.readFileSync(readmePath, 'utf8'); - - // Parse the PEM share via the CLI to get the compact format - // We can use the share content directly - extract the PEM block - const pemMatch = content.match( - /-----BEGIN REMEMORY SHARE-----([\s\S]*?)-----END REMEMORY SHARE-----/ - ); - if (!pemMatch) throw new Error('No PEM share found in README.txt'); - - // Use the binary to convert - run rememory doc compact-share with the share file - // Actually, let's just extract the share data from the output directory - const sharesDir = path.join(projectDir, 'output', 'shares'); - const shareFiles = fs.readdirSync(sharesDir); - - // We need the compact format. Let's get it via page.evaluate after WASM loads. - // For now, return the full PEM content and we'll convert in-browser. - return pemMatch[0]; - } - test('scan button is visible when BarcodeDetector is available', async ({ page }) => { const bundleDir = extractBundle(bundlesDir, 'Alice'); @@ -176,17 +153,10 @@ test.describe('QR Scanner', () => { const compactShare = await page.evaluate((pem: string) => { const result = (window as any).rememoryParseShare(pem); if (result.error || !result.share) return ''; - const share = result.share; - const b64url = share.dataB64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - const data = Uint8Array.from(atob(share.dataB64), (c: string) => c.charCodeAt(0)); - return crypto.subtle.digest('SHA-256', data).then((hash: ArrayBuffer) => { - const arr = new Uint8Array(hash); - const check = Array.from(arr.slice(0, 2)).map(b => b.toString(16).padStart(2, '0')).join(''); - return `RM1:${share.index}:${share.total}:${share.threshold}:${b64url}:${check}`; - }); + return result.share.compact; }, bobPemShare); - expect(compactShare).toMatch(/^RM1:\d+:\d+:\d+:[A-Za-z0-9_-]+:[0-9a-f]{4}$/); + expect(compactShare).toMatch(/^RM\d+:\d+:\d+:\d+:[A-Za-z0-9_-]+:[0-9a-f]{4}$/); // Verify the compact share parses correctly const parseResult = await page.evaluate((compact: string) => { @@ -256,18 +226,11 @@ test.describe('QR Scanner', () => { const recovery = new RecoveryPage(page, aliceDir); await recovery.open(); - // Build compact share from PEM via in-browser conversion + // Convert PEM share to compact format via WASM const compactShare = await page.evaluate((pem: string) => { const result = (window as any).rememoryParseShare(pem); if (result.error || !result.share) return ''; - const share = result.share; - const b64url = share.dataB64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - const data = Uint8Array.from(atob(share.dataB64), (c: string) => c.charCodeAt(0)); - return crypto.subtle.digest('SHA-256', data).then((hash: ArrayBuffer) => { - const arr = new Uint8Array(hash); - const check = Array.from(arr.slice(0, 2)).map(b => b.toString(16).padStart(2, '0')).join(''); - return `RM1:${share.index}:${share.total}:${share.threshold}:${b64url}:${check}`; - }); + return result.share.compact; }, pemMatch[0]); await page.evaluate((compact: string) => { diff --git a/go.mod b/go.mod index 92928da..3ac180d 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( filippo.io/age v1.3.1 github.com/go-pdf/fpdf v0.9.0 github.com/hashicorp/vault v1.21.2 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/spf13/cobra v1.10.2 golang.org/x/text v0.33.0 gopkg.in/yaml.v3 v3.0.1 @@ -16,7 +17,6 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/spf13/pflag v1.0.9 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.46.0 // indirect diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go index 5398fe3..2a12428 100644 --- a/internal/bundle/bundle.go +++ b/internal/bundle/bundle.go @@ -58,18 +58,15 @@ func GenerateAll(p *project.Project, cfg Config) error { var otherFriendsInfo []html.FriendInfo if !p.Anonymous { otherFriends = make([]project.Friend, 0, len(p.Friends)-1) + otherFriendsInfo = make([]html.FriendInfo, 0, len(p.Friends)-1) for j, f := range p.Friends { if j != i { otherFriends = append(otherFriends, f) - } - } - - // Convert to FriendInfo for HTML personalization - otherFriendsInfo = make([]html.FriendInfo, len(otherFriends)) - for j, f := range otherFriends { - otherFriendsInfo[j] = html.FriendInfo{ - Name: f.Name, - Contact: f.Contact, + otherFriendsInfo = append(otherFriendsInfo, html.FriendInfo{ + Name: f.Name, + Contact: f.Contact, + ShareIndex: j + 1, // 1-based share index + }) } } } diff --git a/internal/html/assets/recover.html b/internal/html/assets/recover.html index c9ffd1e..1a38161 100644 --- a/internal/html/assets/recover.html +++ b/internal/html/assets/recover.html @@ -175,7 +175,6 @@

3 Recover Fil scan_btn: "Scan QR code", scan_title: "Scan a QR code", scan_hint: "Point your camera at a QR code from a friend's PDF", - scan_unsupported: "QR scanning is not supported in this browser. You can type the short code below the QR code instead.", scan_camera_error: "Could not access the camera", // Error titles error_title: "Something went wrong", @@ -268,7 +267,6 @@

3 Recover Fil scan_btn: "Escanear QR", scan_title: "Escanear un código QR", scan_hint: "Apunta tu cámara al código QR del PDF de tu amigo", - scan_unsupported: "Tu navegador no admite escanear QR. Puedes escribir el código corto debajo del QR.", scan_camera_error: "No se pudo acceder a la cámara", error_title: "Algo salió mal", error_wasm_title: "Error al cargar la herramienta", @@ -359,7 +357,6 @@

3 Recover Fil scan_btn: "QR-Code scannen", scan_title: "QR-Code scannen", scan_hint: "Richte deine Kamera auf den QR-Code aus dem PDF deines Freundes", - scan_unsupported: "QR-Scanning wird in diesem Browser nicht unterstützt. Du kannst den kurzen Code unter dem QR-Code stattdessen eintippen.", scan_camera_error: "Kein Zugriff auf die Kamera", error_title: "Etwas ist schiefgelaufen", error_wasm_title: "Wiederherstellungstool konnte nicht geladen werden", @@ -450,7 +447,6 @@

3 Recover Fil scan_btn: "Scanner QR", scan_title: "Scanner un code QR", scan_hint: "Dirigez votre caméra vers le QR code du PDF de votre ami", - scan_unsupported: "Le scan QR n'est pas supporté dans ce navigateur. Vous pouvez taper le code court sous le QR code.", scan_camera_error: "Impossible d'accéder à la caméra", error_title: "Une erreur s'est produite", error_wasm_title: "Échec du chargement de l'outil", @@ -540,7 +536,6 @@

3 Recover Fil scan_btn: "Skeniraj QR kodo", scan_title: "Skeniraj QR kodo", scan_hint: "Usmerite kamero na QR kodo s prijateljevega PDF-ja", - scan_unsupported: "Skeniranje QR kode ni podprto v tem brskalniku. Namesto tega lahko vnesete kratko kodo pod QR kodo.", scan_camera_error: "Ni mogoče dostopati do kamere", // Error titles error_title: "Nekaj je šlo narobe", diff --git a/internal/html/assets/src/app.ts b/internal/html/assets/src/app.ts index dd49448..c37aa8e 100644 --- a/internal/html/assets/src/app.ts +++ b/internal/html/assets/src/app.ts @@ -260,9 +260,6 @@ declare const t: TranslationFunction; state.total = share.total; state.shares.push(share); - // Now that we know the holder's index, assign share indices to contact items - assignContactIndices(share.index); - updateSharesUI(); updateContactList(); } @@ -271,23 +268,6 @@ declare const t: TranslationFunction; checkRecoverReady(); } - // Assign share indices to contact list items so we can match compact shares (which lack holder names) - function assignContactIndices(holderIndex: number): void { - if (!personalization?.otherFriends || !elements.contactList) return; - - const items = elements.contactList.querySelectorAll('.contact-item'); - // otherFriends are in project order, skipping the holder. - // Share indices are 1-based: project friend[0] = share 1, friend[1] = share 2, etc. - let friendIdx = 0; - for (let shareIndex = 1; shareIndex <= state.total; shareIndex++) { - if (shareIndex === holderIndex) continue; - if (friendIdx < items.length) { - (items[friendIdx] as HTMLElement).dataset.shareIndex = String(shareIndex); - friendIdx++; - } - } - } - // ============================================ // URL Fragment Share Loading // ============================================ @@ -332,6 +312,9 @@ declare const t: TranslationFunction; const item = document.createElement('div'); item.className = 'contact-item'; item.dataset.name = friend.name; + if (friend.shareIndex) { + item.dataset.shareIndex = String(friend.shareIndex); + } const contactInfo = friend.contact ? escapeHtml(friend.contact) : ''; diff --git a/internal/html/assets/src/types.ts b/internal/html/assets/src/types.ts index f9a463b..f5555aa 100644 --- a/internal/html/assets/src/types.ts +++ b/internal/html/assets/src/types.ts @@ -11,6 +11,7 @@ export interface ParsedShare { total: number; holder?: string; dataB64: string; + compact?: string; // Compact-encoded string (e.g. RM1:2:5:3:BASE64:CHECK) isHolder?: boolean; // True if this is the current user's share } @@ -90,6 +91,7 @@ export interface ExtractResult { export interface FriendInfo { name: string; contact?: string; + shareIndex: number; // 1-based share index for this friend } export interface FriendInput { diff --git a/internal/html/recover.go b/internal/html/recover.go index 82f483c..61e1c3a 100644 --- a/internal/html/recover.go +++ b/internal/html/recover.go @@ -10,8 +10,9 @@ import ( // FriendInfo holds friend contact information for the UI. type FriendInfo struct { - Name string `json:"name"` - Contact string `json:"contact,omitempty"` + Name string `json:"name"` + Contact string `json:"contact,omitempty"` + ShareIndex int `json:"shareIndex"` // 1-based share index for this friend } // PersonalizationData holds the data to personalize recover.html for a specific friend. diff --git a/internal/pdf/readme.go b/internal/pdf/readme.go index 8a5bfdd..8612593 100644 --- a/internal/pdf/readme.go +++ b/internal/pdf/readme.go @@ -3,6 +3,7 @@ package pdf import ( "bytes" "fmt" + "net/url" "strings" "time" @@ -50,7 +51,7 @@ func (d ReadmeData) QRContent() string { if recoveryURL == "" { recoveryURL = core.DefaultRecoveryURL } - return recoveryURL + "#share=" + compact + return recoveryURL + "#share=" + url.QueryEscape(compact) } // GenerateReadme creates the README.pdf content. diff --git a/internal/pdf/readme_test.go b/internal/pdf/readme_test.go index dc4b791..0a1d83e 100644 --- a/internal/pdf/readme_test.go +++ b/internal/pdf/readme_test.go @@ -3,6 +3,7 @@ package pdf import ( "bytes" "image/png" + "net/url" "testing" "time" @@ -60,7 +61,7 @@ func TestQRContent(t *testing.T) { // Without RecoveryURL set: defaults to production URL content := data.QRContent() - expected := core.DefaultRecoveryURL + "#share=" + data.Share.CompactEncode() + expected := core.DefaultRecoveryURL + "#share=" + url.QueryEscape(data.Share.CompactEncode()) if content != expected { t.Errorf("QRContent without URL: got %q, want %q", content, expected) } @@ -71,7 +72,7 @@ func TestQRContentWithRecoveryURL(t *testing.T) { data.RecoveryURL = "https://example.com/recover.html" content := data.QRContent() - expected := "https://example.com/recover.html#share=" + data.Share.CompactEncode() + expected := "https://example.com/recover.html#share=" + url.QueryEscape(data.Share.CompactEncode()) if content != expected { t.Errorf("QRContent with URL: got %q, want %q", content, expected) } @@ -120,7 +121,7 @@ func TestQRCodeContentMatchesCompact(t *testing.T) { qrContent := data.QRContent() compact := share.CompactEncode() - expected := core.DefaultRecoveryURL + "#share=" + compact + expected := core.DefaultRecoveryURL + "#share=" + url.QueryEscape(compact) if qrContent != expected { t.Errorf("QR content doesn't match expected URL:\n got: %q\n want: %q", qrContent, expected) diff --git a/internal/wasm/create.go b/internal/wasm/create.go index 32c235e..1d62f3c 100644 --- a/internal/wasm/create.go +++ b/internal/wasm/create.go @@ -224,18 +224,15 @@ func createBundles(config CreateBundlesConfig) ([]BundleOutput, error) { var otherFriendsInfo []html.FriendInfo if !config.Anonymous { otherFriends = make([]project.Friend, 0, n-1) + otherFriendsInfo = make([]html.FriendInfo, 0, n-1) for j, f := range projectFriends { if j != i { otherFriends = append(otherFriends, f) - } - } - - // Convert to FriendInfo for HTML personalization - otherFriendsInfo = make([]html.FriendInfo, len(otherFriends)) - for j, f := range otherFriends { - otherFriendsInfo[j] = html.FriendInfo{ - Name: f.Name, - Contact: f.Contact, + otherFriendsInfo = append(otherFriendsInfo, html.FriendInfo{ + Name: f.Name, + Contact: f.Contact, + ShareIndex: j + 1, // 1-based share index + }) } } } diff --git a/internal/wasm/js_wrappers.go b/internal/wasm/js_wrappers.go index df74de1..e311a1a 100644 --- a/internal/wasm/js_wrappers.go +++ b/internal/wasm/js_wrappers.go @@ -21,16 +21,7 @@ func parseShareJS(this js.Value, args []js.Value) any { } return js.ValueOf(map[string]any{ - "share": map[string]any{ - "version": share.Version, - "index": share.Index, - "total": share.Total, - "threshold": share.Threshold, - "holder": share.Holder, - "created": share.Created, - "checksum": share.Checksum, - "dataB64": share.DataB64, - }, + "share": shareInfoToJS(share), "error": nil, }) } @@ -153,16 +144,7 @@ func extractBundleJS(this js.Value, args []js.Value) any { } result := map[string]any{ - "share": map[string]any{ - "version": bundle.Share.Version, - "index": bundle.Share.Index, - "total": bundle.Share.Total, - "threshold": bundle.Share.Threshold, - "holder": bundle.Share.Holder, - "created": bundle.Share.Created, - "checksum": bundle.Share.Checksum, - "dataB64": bundle.Share.DataB64, - }, + "share": shareInfoToJS(bundle.Share), "error": nil, } @@ -193,20 +175,26 @@ func parseCompactShareJS(this js.Value, args []js.Value) any { } return js.ValueOf(map[string]any{ - "share": map[string]any{ - "version": share.Version, - "index": share.Index, - "total": share.Total, - "threshold": share.Threshold, - "holder": share.Holder, - "created": share.Created, - "checksum": share.Checksum, - "dataB64": share.DataB64, - }, + "share": shareInfoToJS(share), "error": nil, }) } +// shareInfoToJS converts a ShareInfo to a JS-compatible map. +func shareInfoToJS(s *ShareInfo) map[string]any { + return map[string]any{ + "version": s.Version, + "index": s.Index, + "total": s.Total, + "threshold": s.Threshold, + "holder": s.Holder, + "created": s.Created, + "checksum": s.Checksum, + "dataB64": s.DataB64, + "compact": s.Compact, + } +} + func errorResult(msg string) any { return js.ValueOf(map[string]any{ "error": msg, diff --git a/internal/wasm/recover.go b/internal/wasm/recover.go index 1482931..fb20876 100644 --- a/internal/wasm/recover.go +++ b/internal/wasm/recover.go @@ -23,6 +23,7 @@ type ShareInfo struct { Created string // RFC3339 formatted Checksum string DataB64 string // Base64 encoded share data for transport + Compact string // Compact-encoded share string (e.g. RM1:2:5:3:BASE64:CHECK) } // ShareData is minimal data needed for combining. @@ -45,16 +46,7 @@ func parseShare(content string) (*ShareInfo, error) { return nil, err } - return &ShareInfo{ - Version: share.Version, - Index: share.Index, - Total: share.Total, - Threshold: share.Threshold, - Holder: share.Holder, - Created: share.Created.Format("2006-01-02T15:04:05Z07:00"), - Checksum: share.Checksum, - DataB64: base64.StdEncoding.EncodeToString(share.Data), - }, nil + return shareToInfo(share), nil } // parseCompactShare parses a compact-encoded share string. @@ -64,6 +56,11 @@ func parseCompactShare(compact string) (*ShareInfo, error) { return nil, err } + return shareToInfo(share), nil +} + +// shareToInfo converts a core.Share to a ShareInfo for JS interop. +func shareToInfo(share *core.Share) *ShareInfo { return &ShareInfo{ Version: share.Version, Index: share.Index, @@ -73,7 +70,8 @@ func parseCompactShare(compact string) (*ShareInfo, error) { Created: share.Created.Format("2006-01-02T15:04:05Z07:00"), Checksum: share.Checksum, DataB64: base64.StdEncoding.EncodeToString(share.Data), - }, nil + Compact: share.CompactEncode(), + } } // combineShares combines multiple shares to recover the passphrase. From 70ca7f894fbdee0fff354a3103b39ec39a6aee27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Albornoz?= Date: Mon, 9 Feb 2026 23:27:30 -0500 Subject: [PATCH 8/8] show nicer text when only one piece is left --- e2e/helpers.ts | 3 ++- internal/html/assets/recover.html | 18 +++++++++--------- internal/html/assets/src/app.ts | 3 ++- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 508c805..39718c9 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -145,7 +145,8 @@ export class RecoveryPage { } async expectNeedMoreShares(count: number): Promise { - await expect(this.page.locator('#threshold-info')).toContainText(`Waiting for ${count} more piece`); + const expected = count === 1 ? 'Waiting for the last piece' : `Waiting for ${count} more pieces`; + await expect(this.page.locator('#threshold-info')).toContainText(expected); } async expectManifestLoaded(): Promise { diff --git a/internal/html/assets/recover.html b/internal/html/assets/recover.html index 1a38161..c59898e 100644 --- a/internal/html/assets/recover.html +++ b/internal/html/assets/recover.html @@ -147,7 +147,8 @@

3 Recover Fil works_offline: "Works fully offline", need_help: "Need help?", download_cli: "Download CLI tool from GitHub", - need_more: "Waiting for {0} more piece(s)", + need_more: "Waiting for {0} more pieces", + need_more_one: "Waiting for the last piece", ready: "Everything's ready", shares_of: "{0} of {1} pieces", remove: "Remove", @@ -169,7 +170,6 @@

3 Recover Fil your_share_loaded: "Your piece is already here", contact_list: "Contact the others", contact_list_hint: "Reach out to these friends to gather their pieces", - shares_remaining: "{0} more piece(s) needed", contact_label: "Contact", pasted_content: "pasted text", scan_btn: "Scan QR code", @@ -239,7 +239,8 @@

3 Recover Fil works_offline: "Funciona completamente sin internet", need_help: "¿Necesitas ayuda?", download_cli: "Descarga la herramienta CLI desde GitHub", - need_more: "Faltan {0} parte(s)", + need_more: "Faltan {0} partes", + need_more_one: "Falta la última parte", ready: "Todo está listo", shares_of: "{0} de {1} partes", remove: "Eliminar", @@ -261,7 +262,6 @@

3 Recover Fil your_share_loaded: "Tu parte ya está cargada", contact_list: "Contactar a los demás", contact_list_hint: "Habla con estos amigos para reunir sus partes", - shares_remaining: "Faltan {0} parte(s)", contact_label: "Contacto", pasted_content: "texto pegado", scan_btn: "Escanear QR", @@ -329,7 +329,8 @@

3 Recover Fil works_offline: "Funktioniert komplett offline", need_help: "Brauchst du Hilfe?", download_cli: "CLI-Tool von GitHub herunterladen", - need_more: "Es fehlen noch {0} Teil(e)", + need_more: "Es fehlen noch {0} Teile", + need_more_one: "Es fehlt noch das letzte Teil", ready: "Alles ist bereit", shares_of: "{0} von {1} Teilen", remove: "Entfernen", @@ -351,7 +352,6 @@

3 Recover Fil your_share_loaded: "Dein Teil ist bereits geladen", contact_list: "Die anderen kontaktieren", contact_list_hint: "Bitte diese Freunde um ihre Teile", - shares_remaining: "Noch {0} Teil(e) nötig", contact_label: "Kontakt", pasted_content: "eingefügter Text", scan_btn: "QR-Code scannen", @@ -419,7 +419,8 @@

3 Recover Fil works_offline: "Fonctionne entièrement hors ligne", need_help: "Besoin d'aide ?", download_cli: "Télécharger l'outil CLI depuis GitHub", - need_more: "Il manque encore {0} part(s)", + need_more: "Il manque encore {0} parts", + need_more_one: "Il manque la dernière part", ready: "Tout est prêt", shares_of: "{0} sur {1} parts", remove: "Supprimer", @@ -441,7 +442,6 @@

3 Recover Fil your_share_loaded: "Votre part est déjà chargée", contact_list: "Contacter les autres", contact_list_hint: "Contactez ces amis pour réunir leurs parts", - shares_remaining: "Il manque encore {0} part(s)", contact_label: "Contact", pasted_content: "texte collé", scan_btn: "Scanner QR", @@ -509,6 +509,7 @@

3 Recover Fil need_help: "Potrebujete pomoč?", download_cli: "Prenesite CLI orodje z GitHub", need_more: "Potrebno je dodati še {0} delov", + need_more_one: "Potrebno je dodati še zadnji del", ready: "Vse je pripravljeno", shares_of: "{0} od {1} delov", remove: "Odstrani", @@ -530,7 +531,6 @@

3 Recover Fil your_share_loaded: "Vaš sveženj je že tukaj", contact_list: "Kontaktirajte druge", contact_list_hint: "Obrnite se na te prijatelje, da zberete njihove svežnje", - shares_remaining: "{0} delov še potrebnih", contact_label: "Kontakt", pasted_content: "prilepljeno besedilo", scan_btn: "Skeniraj QR kodo", diff --git a/internal/html/assets/src/app.ts b/internal/html/assets/src/app.ts index c37aa8e..17720ad 100644 --- a/internal/html/assets/src/app.ts +++ b/internal/html/assets/src/app.ts @@ -843,8 +843,9 @@ declare const t: TranslationFunction; // Update threshold info if (state.threshold > 0 && elements.thresholdInfo) { const needed = Math.max(0, state.threshold - state.shares.length); + const needLabel = needed === 1 ? t('need_more_one') : t('need_more', needed); elements.thresholdInfo.innerHTML = needed > 0 - ? `🔒 ${t('need_more', needed)} (${t('shares_of', state.shares.length, state.threshold)})` + ? `🔒 ${needLabel} (${t('shares_of', state.shares.length, state.threshold)})` : `✅ ${t('ready')} (${t('shares_of', state.shares.length, state.threshold)})`; elements.thresholdInfo.className = 'threshold-info' + (needed === 0 ? ' ready' : ''); elements.thresholdInfo.classList.remove('hidden');