Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -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
Expand Down
Binary file added docs/screenshots/manifest-file-picker.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/qr-camera-permission.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/qr-scanning.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 4 additions & 2 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,17 @@ export class RecoveryPage {
}

async expectShareHolder(name: string): Promise<void> {
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<void> {
await expect(this.page.locator('#threshold-info')).toHaveClass(/ready/);
}

async expectNeedMoreShares(count: number): Promise<void> {
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<void> {
Expand Down
328 changes: 328 additions & 0 deletions e2e/qr-scanner.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
import { test, expect } from '@playwright/test';
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 });
}
});

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 '';
return result.share.compact;
}, bobPemShare);

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) => {
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();

// 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 '';
return result.share.compact;
}, 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);
});
});
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading