feat(#1034): channel QR generate + scan module (PR 2/3) (#1035)

## PR #2 of channel UX redesign (#1034) — QR generation + scanning

Self-contained QR module for MeshCore channel sharing. Wirable but **not
wired** — PR #3 wires this into the modal placeholders shipped by PR #1.

### What's in
- **`public/channel-qr.js`** — new module exporting `window.ChannelQR`:
- `buildUrl(name, secretHex)` →
`meshcore://channel/add?name=<urlencoded>&secret=<32hex>`
- `parseChannelUrl(url)` → `{name, secret}` or `null` (strict: scheme,
path, hex32 secret)
- `generate(name, secretHex, target)` — renders QR (via vendored
qrcode.js) + the URL string + a "Copy Key" button into `target`
- `scan()` → `Promise<{name, secret} | null>` — opens a camera overlay,
decodes with jsQR, parses, auto-closes on first valid match. Graceful
no-camera/permission-denied fallback ("Camera not available — paste key
manually").
- **`public/vendor/jsqr.min.js`** — vendored jsQR 1.4.0
- **`public/index.html`** — loads `vendor/jsqr.min.js` + `channel-qr.js`
after `channel-decrypt.js`
- **`test-channel-qr.js`** + wired into `test-all.sh` — 16 assertions on
`buildUrl` / `parseChannelUrl` (DOM/camera paths covered by Playwright
in #3)

### TDD
- Red commit `d6ba89e` — stub module + failing assertions on `buildUrl`
/ `parseChannelUrl` (compiles, runs, fails on assertion)
- Green commit `25328ac` — real impl, 16/16 pass

### License note
Brief specified jsQR as MIT — it's actually **Apache-2.0**
(https://github.com/cozmo/jsQR/blob/master/package.json). Apache-2.0 is
permissive and compatible with the repo's ISC license; flagging here so
reviewers can confirm. Cited in the file header.

### Independence guarantees
- Does **not** touch `channels.js` or `channel-decrypt.js`
- Does not call any UI from `channels.js`; PR #3 will call
`ChannelQR.generate(...)` into `#qr-output` and wire `#scan-qr-btn` to
`ChannelQR.scan()`

Refs #1034

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
This commit is contained in:
Kpa-clawbot
2026-05-04 18:29:48 -07:00
committed by GitHub
parent d967170dd3
commit c1d0daf200
5 changed files with 10453 additions and 0 deletions
+256
View File
@@ -0,0 +1,256 @@
/**
* channel-qr.js — QR code generation + scanning for MeshCore channels.
*
* URL format (per firmware spec):
* meshcore://channel/add?name=<urlencoded>&secret=<32hex>
*
* Public API (window.ChannelQR):
* buildUrl(name, secretHex) → string
* parseChannelUrl(url) → {name, secret} | null
* generate(name, secretHex, target) → renders QR + URL + Copy Key into `target`
* scan() → Promise<{name, secret} | null>
*
* Self-contained: does NOT touch channels.js / channel-decrypt.js.
* The PR that wires the modal into this module is #3.
*
* Vendored deps (loaded by index.html):
* - public/vendor/qrcode.js (davidshimjs/qrcodejs, MIT) — QR rendering
* - public/vendor/jsqr.min.js (cozmo/jsQR, Apache-2.0) — QR decoding from camera
*/
(function (root) {
'use strict';
const SCHEME_PREFIX = 'meshcore://channel/add';
const HEX32_RE = /^[0-9a-fA-F]{32}$/;
function buildUrl(name, secretHex) {
return SCHEME_PREFIX + '?name=' + encodeURIComponent(String(name)) +
'&secret=' + String(secretHex);
}
/**
* parseChannelUrl(url) → { name, secret } | null
* Strict: scheme must be `meshcore:`, host+path `//channel/add`,
* both `name` and `secret` query params present, secret must be 32 hex chars.
*/
function parseChannelUrl(url) {
if (!url || typeof url !== 'string') return null;
if (url.indexOf(SCHEME_PREFIX) !== 0) return null;
// Strip prefix → query string
const rest = url.slice(SCHEME_PREFIX.length);
if (rest[0] !== '?' && rest !== '') return null;
const qs = rest.slice(1);
if (!qs) return null;
const params = {};
const pairs = qs.split('&');
for (let i = 0; i < pairs.length; i++) {
const eq = pairs[i].indexOf('=');
if (eq < 0) continue;
const k = pairs[i].slice(0, eq);
const v = pairs[i].slice(eq + 1);
try { params[k] = decodeURIComponent(v); }
catch (_e) { return null; }
}
if (!params.name || !params.secret) return null;
if (!HEX32_RE.test(params.secret)) return null;
return { name: params.name, secret: params.secret.toLowerCase() };
}
// ---------- DOM helpers (browser-only) ----------
function _hasDom() {
return typeof document !== 'undefined' && document.createElement;
}
/**
* Render QR + URL + Copy Key button into `target`.
* Requires window.QRCode (vendor/qrcode.js) loaded.
*/
function generate(name, secretHex, target) {
if (!_hasDom() || !target) return;
target.innerHTML = '';
const url = buildUrl(name, secretHex);
const qrBox = document.createElement('div');
qrBox.className = 'channel-qr-canvas';
qrBox.style.display = 'inline-block';
target.appendChild(qrBox);
if (typeof root.QRCode === 'function') {
try {
// davidshimjs/qrcodejs API: new QRCode(el, {text, width, height, ...})
new root.QRCode(qrBox, {
text: url,
width: 192,
height: 192,
correctLevel: root.QRCode.CorrectLevel ? root.QRCode.CorrectLevel.M : 0,
});
} catch (e) {
qrBox.textContent = '[QR render failed: ' + (e && e.message || e) + ']';
}
} else {
qrBox.textContent = '[QR library not loaded]';
}
const urlLine = document.createElement('div');
urlLine.className = 'channel-qr-url';
urlLine.style.cssText = 'font-family:monospace;font-size:11px;word-break:break-all;margin-top:6px;';
urlLine.textContent = url;
target.appendChild(urlLine);
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'channel-qr-copy';
copyBtn.textContent = '📋 Copy Key';
copyBtn.style.cssText = 'margin-top:6px;';
copyBtn.addEventListener('click', function () {
const text = secretHex;
const done = function () {
const orig = copyBtn.textContent;
copyBtn.textContent = '✓ Copied';
setTimeout(function () { copyBtn.textContent = orig; }, 1200);
};
if (root.navigator && root.navigator.clipboard && root.navigator.clipboard.writeText) {
root.navigator.clipboard.writeText(text).then(done, function () {
// Fallback: select text in a temp input
_fallbackCopy(text); done();
});
} else {
_fallbackCopy(text); done();
}
});
target.appendChild(copyBtn);
}
function _fallbackCopy(text) {
if (!_hasDom()) return;
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0;';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (_e) {}
document.body.removeChild(ta);
}
// ---------- Camera scan ----------
/**
* scan() → Promise<{name, secret} | null>
*
* Opens a small modal with a live camera preview, decodes via jsQR,
* resolves with the parsed channel info on first valid match. Closes
* camera on resolve/reject. Resolves with `null` if user cancels or
* camera permission is denied (graceful fallback path).
*/
function scan() {
if (!_hasDom()) return Promise.resolve(null);
const nav = root.navigator;
if (!nav || !nav.mediaDevices || !nav.mediaDevices.getUserMedia ||
typeof root.jsQR !== 'function') {
_showCameraFallback();
return Promise.resolve(null);
}
return new Promise(function (resolve) {
const overlay = document.createElement('div');
overlay.className = 'channel-qr-scan-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);' +
'display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:99999;';
const video = document.createElement('video');
video.setAttribute('playsinline', 'true');
video.style.cssText = 'max-width:90vw;max-height:60vh;background:#000;';
overlay.appendChild(video);
const status = document.createElement('div');
status.style.cssText = 'color:#fff;margin-top:12px;font-family:sans-serif;';
status.textContent = 'Point camera at a MeshCore channel QR…';
overlay.appendChild(status);
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.textContent = 'Cancel';
cancelBtn.style.cssText = 'margin-top:12px;';
overlay.appendChild(cancelBtn);
document.body.appendChild(overlay);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
let stream = null;
let rafId = 0;
let done = false;
function cleanup(result) {
if (done) return;
done = true;
if (rafId) cancelAnimationFrame(rafId);
if (stream) {
stream.getTracks().forEach(function (t) { try { t.stop(); } catch (_e) {} });
}
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
resolve(result);
}
cancelBtn.addEventListener('click', function () { cleanup(null); });
function tick() {
if (done) return;
if (video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
let imgData;
try { imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); }
catch (_e) { rafId = requestAnimationFrame(tick); return; }
const code = root.jsQR(imgData.data, imgData.width, imgData.height, {
inversionAttempts: 'dontInvert',
});
if (code && code.data) {
const parsed = parseChannelUrl(code.data);
if (parsed) { cleanup(parsed); return; }
status.textContent = 'QR found but not a MeshCore channel — keep trying…';
}
}
rafId = requestAnimationFrame(tick);
}
nav.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
.then(function (s) {
stream = s;
video.srcObject = s;
video.play().then(function () { tick(); }, function () { tick(); });
})
.catch(function () {
status.textContent = 'Camera not available — paste key manually.';
setTimeout(function () { cleanup(null); }, 1800);
});
});
}
function _showCameraFallback() {
if (!_hasDom()) return;
const note = document.createElement('div');
note.className = 'channel-qr-fallback';
note.style.cssText = 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);' +
'background:#222;color:#fff;padding:10px 14px;border-radius:6px;z-index:99999;';
note.textContent = 'Camera not available — paste key manually.';
document.body.appendChild(note);
setTimeout(function () {
if (note.parentNode) note.parentNode.removeChild(note);
}, 2500);
}
root.ChannelQR = {
buildUrl: buildUrl,
parseChannelUrl: parseChannelUrl,
generate: generate,
scan: scan,
};
})(typeof window !== 'undefined' ? window : globalThis);
+2
View File
@@ -103,6 +103,8 @@
<script src="vendor/aes-ecb.js?v=__BUST__"></script>
<script src="vendor/sha256-hmac.js?v=__BUST__"></script>
<script src="channel-decrypt.js?v=__BUST__"></script>
<script src="vendor/jsqr.min.js"></script>
<script src="channel-qr.js?v=__BUST__"></script>
<script src="channel-colors.js?v=__BUST__"></script>
<script src="channel-color-picker.js?v=__BUST__"></script>
<script src="packets.js?v=__BUST__"></script>
+10108
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -16,6 +16,7 @@ node test-perf-go-runtime.js
node test-channel-psk-ux.js
node test-channel-sidebar-layout.js
node test-channel-decrypt-insecure-context.js
node test-channel-qr.js
echo ""
echo "═══════════════════════════════════════"
+86
View File
@@ -0,0 +1,86 @@
/**
* Tests for public/channel-qr.js — the QR generation/scanning module
* for the channel UX redesign (#1034, PR #2 of 3).
*
* Pure-JS assertions only: covers buildUrl, parseChannelUrl. The DOM
* (generate) and camera (scan) paths are exercised by Playwright E2E
* elsewhere in the redesign series.
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const path = require('path');
let passed = 0;
let failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
function loadChannelQR() {
const sandbox = {
window: {}, console, Date, JSON, parseInt, Math, String, Number,
Object, Array, RegExp, Error, Promise, setTimeout, encodeURIComponent,
decodeURIComponent, URL, URLSearchParams,
};
sandbox.window = sandbox;
sandbox.self = sandbox;
vm.createContext(sandbox);
const src = fs.readFileSync(path.join(__dirname, 'public/channel-qr.js'), 'utf8');
vm.runInContext(src, sandbox);
return sandbox.window.ChannelQR;
}
console.log('── ChannelQR — URL helpers ──');
const ChannelQR = loadChannelQR();
assert(ChannelQR && typeof ChannelQR.buildUrl === 'function',
'ChannelQR.buildUrl is exported');
assert(typeof ChannelQR.parseChannelUrl === 'function',
'ChannelQR.parseChannelUrl is exported');
assert(typeof ChannelQR.generate === 'function',
'ChannelQR.generate is exported');
assert(typeof ChannelQR.scan === 'function',
'ChannelQR.scan is exported');
// --- buildUrl ---
const SECRET = '8b3387e1c4be1bbf09c1a4cd5c0fa5a3';
const url1 = ChannelQR.buildUrl('Public', SECRET);
assert(url1 === 'meshcore://channel/add?name=Public&secret=' + SECRET,
'buildUrl produces canonical URL for plain name');
const url2 = ChannelQR.buildUrl('My Channel & Stuff', SECRET);
assert(url2 === 'meshcore://channel/add?name=My%20Channel%20%26%20Stuff&secret=' + SECRET,
'buildUrl URL-encodes spaces and ampersands in name');
// --- parseChannelUrl ---
const p1 = ChannelQR.parseChannelUrl(url1);
assert(p1 && p1.name === 'Public' && p1.secret === SECRET,
'parseChannelUrl extracts name + secret from canonical URL');
const p2 = ChannelQR.parseChannelUrl(url2);
assert(p2 && p2.name === 'My Channel & Stuff' && p2.secret === SECRET,
'parseChannelUrl URL-decodes name correctly');
assert(ChannelQR.parseChannelUrl(null) === null, 'parseChannelUrl(null) → null');
assert(ChannelQR.parseChannelUrl('') === null, 'parseChannelUrl("") → null');
assert(ChannelQR.parseChannelUrl('https://example.com') === null,
'parseChannelUrl rejects non-meshcore scheme');
assert(ChannelQR.parseChannelUrl('meshcore://channel/add?name=Foo') === null,
'parseChannelUrl rejects URL missing secret');
assert(ChannelQR.parseChannelUrl('meshcore://channel/add?secret=' + SECRET) === null,
'parseChannelUrl rejects URL missing name');
assert(ChannelQR.parseChannelUrl('meshcore://other/add?name=Foo&secret=' + SECRET) === null,
'parseChannelUrl rejects wrong host/path');
assert(ChannelQR.parseChannelUrl('meshcore://channel/add?name=Foo&secret=zz') === null,
'parseChannelUrl rejects non-hex secret');
assert(ChannelQR.parseChannelUrl('meshcore://channel/add?name=Foo&secret=' + SECRET.slice(0, 30)) === null,
'parseChannelUrl rejects short secret (must be 32 hex chars)');
console.log('');
console.log(` ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);