fix(qr): accept a one-frame invitation; release v5.9.1
CodeQL Analysis / Analyze CodeQL (push) Canceled after 0s
Deploy Application / deploy (push) Canceled after 0s
Mirror to Codeberg / mirror (push) Canceled after 0s
Mirror to PrivacyGuides / mirror (push) Canceled after 0s

The scanner waited for four frames when shown a single one. Its chunk assembler
was written for SB1, which the generator always cuts into exactly four frames,
and its fallback branch claims any non-JSON string longer than 100 characters —
which a 151-character SBQ2 invitation is. A complete invitation was filed as
chunk 1 of 4, and the scan never finished.

SBQ2 payloads are now recognised as complete before any assembly runs, in both
the text and raw-byte forms, and the hard-coded frame count is marked as
belonging to SB1 so it is not read as a general rule.
This commit is contained in:
lockbitchat
2026-08-06 23:32:20 -04:00
parent 808fd99b73
commit 556727eb6f
13 changed files with 179 additions and 43 deletions
+13
View File
@@ -1,5 +1,18 @@
# Changelog # Changelog
## v5.9.1 — Scanning a one-frame invitation
### Fixed
- The QR scanner waited for four frames when shown a single one. Its chunk
assembler was written for SB1, which the generator always cuts into exactly
four frames, and its fallback branch claimed any non-JSON string longer than
100 characters — which a 151-character SBQ2 invitation is. A complete
invitation was filed as chunk 1 of 4 and the scan never finished. SBQ2 payloads
are now recognised as complete before any assembly runs, in both the text and
raw-byte forms.
## v5.9.0 — The invitation is now one small QR code ## v5.9.0 — The invitation is now one small QR code
The connection descriptor moves to SBQ2 and the key material moves onto the The connection descriptor moves to SBQ2 and the key material moves onto the
+1 -1
View File
@@ -9,7 +9,7 @@
No accounts. No servers storing your messages. No installation required. No accounts. No servers storing your messages. No installation required.
[![License: MIT](https://img.shields.io/badge/License-MIT-f0892a.svg)](LICENSE) [![License: MIT](https://img.shields.io/badge/License-MIT-f0892a.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-5.9.0-3ecf8e.svg)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-5.9.1-3ecf8e.svg)](CHANGELOG.md)
[![PWA](https://img.shields.io/badge/PWA-installable-3ecf8e.svg)](#install-as-an-app) [![PWA](https://img.shields.io/badge/PWA-installable-3ecf8e.svg)](#install-as-an-app)
[![Encryption](https://img.shields.io/badge/crypto-ECDH%20P--384%20%C2%B7%20AES--256--GCM-blue.svg)](#security-model) [![Encryption](https://img.shields.io/badge/crypto-ECDH%20P--384%20%C2%B7%20AES--256--GCM-blue.svg)](#security-model)
[![Forward secrecy](https://img.shields.io/badge/forward%20secrecy-Double%20Ratchet-3ecf8e.svg)](#forward-secrecy) [![Forward secrecy](https://img.shields.io/badge/forward%20secrecy-Double%20Ratchet-3ecf8e.svg)](#forward-secrecy)
+1 -1
View File
@@ -21266,7 +21266,7 @@ var SecureMasterKeyManager = class {
var import_NotificationIntegration = __toESM(require_NotificationIntegration()); var import_NotificationIntegration = __toESM(require_NotificationIntegration());
// package.json // package.json
var version = "5.9.0"; var version = "5.9.1";
// src/components/ui/Header.jsx // src/components/ui/Header.jsx
var APP_VERSION = `v${version}`; var APP_VERSION = `v${version}`;
+1 -1
View File
File diff suppressed because one or more lines are too long
Vendored
+22 -2
View File
@@ -4231,14 +4231,31 @@ var EnhancedSecureP2PChat = () => {
try { try {
console.log("QR Code scanned:", scannedData.substring(0, 100) + "..."); console.log("QR Code scanned:", scannedData.substring(0, 100) + "...");
console.log("Current buffer state:", qrChunksBufferRef.current); console.log("Current buffer state:", qrChunksBufferRef.current);
if (scannedData.startsWith("SB2:") || scannedData.charCodeAt(0) === 2) {
qrChunksBufferRef.current = { id: null, total: 0, seen: /* @__PURE__ */ new Set(), items: [] };
if (showOfferStep) {
setAnswerInput(scannedData);
} else {
setOfferInput(scannedData);
}
setMessages((prev) => [...prev, {
message: "Invitation captured.",
type: "success"
}]);
setShowQRScannerModal(false);
return Promise.resolve(true);
}
if (scannedData.startsWith("SB1:bin:") || qrChunksBufferRef.current && qrChunksBufferRef.current.id) { if (scannedData.startsWith("SB1:bin:") || qrChunksBufferRef.current && qrChunksBufferRef.current.id) {
console.log("Binary chunk detected:", scannedData.substring(0, 50) + "..."); console.log("Binary chunk detected:", scannedData.substring(0, 50) + "...");
if (!qrChunksBufferRef.current.id) { if (!qrChunksBufferRef.current.id) {
console.log("Initializing buffer for binary chunks"); console.log("Initializing buffer for binary chunks");
qrChunksBufferRef.current = { qrChunksBufferRef.current = {
id: `bin_${Date.now()}`, id: `bin_${Date.now()}`,
// SB1 payloads are split into exactly four
// frames by the generator above. SBQ2 never
// reaches here — it is one frame and is
// handled at the top of this function.
total: 4, total: 4,
// We expect 4 chunks
seen: /* @__PURE__ */ new Set(), seen: /* @__PURE__ */ new Set(),
items: [], items: [],
lastUpdateMs: Date.now() lastUpdateMs: Date.now()
@@ -4296,8 +4313,11 @@ var EnhancedSecureP2PChat = () => {
console.log("Initializing buffer for potential binary chunks"); console.log("Initializing buffer for potential binary chunks");
qrChunksBufferRef.current = { qrChunksBufferRef.current = {
id: `bin_${Date.now()}`, id: `bin_${Date.now()}`,
// SB1 payloads are split into exactly four
// frames by the generator above. SBQ2 never
// reaches here — it is one frame and is
// handled at the top of this function.
total: 4, total: 4,
// We expect 4 chunks
seen: /* @__PURE__ */ new Set(), seen: /* @__PURE__ */ new Set(),
items: [], items: [],
lastUpdateMs: Date.now() lastUpdateMs: Date.now()
+2 -2
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,7 +7,7 @@ this document describes.
| | | | | |
| --- | --- | | --- | --- |
| Release | v5.9.0 | | Release | v5.9.1 |
| Protocol version | 4.1 | | Protocol version | 4.1 |
| Ratchet wire version | 1 | | Ratchet wire version | 1 |
@@ -238,5 +238,5 @@ worse than one that reports nothing.
## Scope ## Scope
This describes the browser implementation as it stands in v5.9.0. It is not a This describes the browser implementation as it stands in v5.9.1. It is not a
substitute for independent cryptographic review. substitute for independent cryptographic review.
+22 -22
View File
@@ -24,7 +24,7 @@
<!-- PWA Manifest --> <!-- PWA Manifest -->
<link rel="manifest" href="./manifest.json"> <link rel="manifest" href="./manifest.json">
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1786056807121"> <link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1786072827999">
<!-- PWA Meta Tags --> <!-- PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
@@ -90,7 +90,7 @@
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="./logo/splash/splash_screens/8.3__iPad_Mini_portrait.png"> <link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="./logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
<!-- Apple Touch Icons --> <!-- Apple Touch Icons -->
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1786056807121"> <link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1786072827999">
<link rel="apple-touch-icon" sizes="57x57" href="./logo/icon-57x57.png"> <link rel="apple-touch-icon" sizes="57x57" href="./logo/icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="./logo/icon-60x60.png"> <link rel="apple-touch-icon" sizes="60x60" href="./logo/icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="./logo/icon-72x72.png"> <link rel="apple-touch-icon" sizes="72x72" href="./logo/icon-72x72.png">
@@ -99,7 +99,7 @@
<link rel="apple-touch-icon" sizes="120x120" href="./logo/icon-120x120.png"> <link rel="apple-touch-icon" sizes="120x120" href="./logo/icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="./logo/icon-144x144.png"> <link rel="apple-touch-icon" sizes="144x144" href="./logo/icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="./logo/icon-152x152.png"> <link rel="apple-touch-icon" sizes="152x152" href="./logo/icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1786056807121"> <link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1786072827999">
<!-- Microsoft Tiles --> <!-- Microsoft Tiles -->
<meta name="msapplication-TileColor" content="#ff6b35"> <meta name="msapplication-TileColor" content="#ff6b35">
@@ -183,7 +183,7 @@
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts <!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
both execute in document order after parsing, so React still runs before the both execute in document order after parsing, so React still runs before the
app modules below, but the parser / first paint is no longer blocked. --> app modules below, but the parser / first paint is no longer blocked. -->
<script defer src="config/ice-servers.js?v=1786056807121"></script> <script defer src="config/ice-servers.js?v=1786072827999"></script>
<script defer src="libs/react/react.production.min.js"></script> <script defer src="libs/react/react.production.min.js"></script>
<script defer src="libs/react-dom/react-dom.production.min.js"></script> <script defer src="libs/react-dom/react-dom.production.min.js"></script>
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only — <!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
@@ -191,8 +191,8 @@
Its CSS is loaded async via load-async-css.js (not paint-critical). --> Its CSS is loaded async via load-async-css.js (not paint-critical). -->
<script defer src="libs/prism/prism.js"></script> <script defer src="libs/prism/prism.js"></script>
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). --> <!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
<link rel="stylesheet" href="assets/tailwind.css?v=1786056807121"> <link rel="stylesheet" href="assets/tailwind.css?v=1786072827999">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1786056807121"> <link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1786072827999">
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI <!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
first screen). Inter latin 400/700 cover body text and headings/buttons. --> first screen). Inter latin 400/700 cover body text and headings/buttons. -->
@@ -200,31 +200,31 @@
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/assets/fonts/inter/inter.css"> <link rel="stylesheet" href="/assets/fonts/inter/inter.css">
<link rel="stylesheet" href="src/styles/main.css?v=1786056807121"> <link rel="stylesheet" href="src/styles/main.css?v=1786072827999">
<link rel="stylesheet" href="src/styles/animations.css?v=1786056807121"> <link rel="stylesheet" href="src/styles/animations.css?v=1786072827999">
<link rel="stylesheet" href="src/styles/components.css?v=1786056807121"> <link rel="stylesheet" href="src/styles/components.css?v=1786072827999">
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. --> <!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
<script defer src="src/scripts/load-async-css.js?v=1786056807121"></script> <script defer src="src/scripts/load-async-css.js?v=1786072827999"></script>
<noscript> <noscript>
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css"> <link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
<link rel="stylesheet" href="libs/prism/prism.css"> <link rel="stylesheet" href="libs/prism/prism.css">
</noscript> </noscript>
<script defer src="src/scripts/fa-check.js?v=1786056807121"></script> <script defer src="src/scripts/fa-check.js?v=1786072827999"></script>
<!-- Update Manager - система принудительного обновления --> <!-- Update Manager - система принудительного обновления -->
<script defer src="src/utils/updateManager.js?v=1786056807121"></script> <script defer src="src/utils/updateManager.js?v=1786072827999"></script>
<script type="module" src="src/components/UpdateChecker.jsx?v=1786056807121"></script> <script type="module" src="src/components/UpdateChecker.jsx?v=1786072827999"></script>
<script type="module" src="dist/qr-local.js?v=1786056807121"></script> <script type="module" src="dist/qr-local.js?v=1786072827999"></script>
<script type="module" src="src/components/QRScanner.js?v=1786056807121"></script> <script type="module" src="src/components/QRScanner.js?v=1786072827999"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="dist/app-boot.js?v=1786056807121"></script> <script type="module" src="dist/app-boot.js?v=1786072827999"></script>
<script type="module" src="dist/app.js?v=1786056807121"></script> <script type="module" src="dist/app.js?v=1786072827999"></script>
<script defer src="src/scripts/pwa-register.js?v=1786056807121"></script> <script defer src="src/scripts/pwa-register.js?v=1786072827999"></script>
<script src="./src/pwa/install-prompt.js?v=1786056807121" type="module"></script> <script src="./src/pwa/install-prompt.js?v=1786072827999" type="module"></script>
<script src="./src/pwa/pwa-manager.js?v=1786056807121" type="module"></script> <script src="./src/pwa/pwa-manager.js?v=1786072827999" type="module"></script>
<script defer src="./src/scripts/pwa-offline-test.js?v=1786056807121"></script> <script defer src="./src/scripts/pwa-offline-test.js?v=1786072827999"></script>
<link rel="stylesheet" href="./src/styles/pwa.css?v=1786056807121"> <link rel="stylesheet" href="./src/styles/pwa.css?v=1786072827999">
</body> </body>
</html> </html>
+7 -7
View File
@@ -1,10 +1,10 @@
{ {
"version": "1786056807121", "version": "1786072827999",
"buildVersion": "1786056807121", "buildVersion": "1786072827999",
"appVersion": "5.9.0", "appVersion": "5.9.1",
"buildTime": "2026-08-06T22:53:27.174Z", "buildTime": "2026-08-07T03:20:28.043Z",
"buildId": "1786056807121-7754f6b", "buildId": "1786072827999-808fd99",
"gitHash": "7754f6b", "gitHash": "808fd99",
"generated": true, "generated": true,
"generatedAt": "2026-08-06T22:53:27.175Z" "generatedAt": "2026-08-07T03:20:28.044Z"
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "securebit-chat", "name": "securebit-chat",
"version": "5.9.0", "version": "5.9.1",
"description": "Secure P2P Communication Application with End-to-End Encryption", "description": "Secure P2P Communication Application with End-to-End Encryption",
"main": "index.html", "main": "index.html",
"scripts": { "scripts": {
@@ -11,7 +11,7 @@
"dev": "npm run build && python -m http.server 8000", "dev": "npm run build && python -m http.server 8000",
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch", "watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
"serve": "npx http-server -p 8000", "serve": "npx http-server -p 8000",
"test": "node tests/sas-verification.test.mjs && node tests/verification-gate.test.mjs && node tests/inbound-frame-authentication.test.mjs && node tests/control-frame-authorization.test.mjs && node tests/security-level-shape.test.mjs && node tests/desktop-download-links.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/notification-meta-forwarding.test.mjs && node tests/notification-ephemeral-privacy.test.mjs && node tests/key-derivation-compat.test.mjs && node tests/key-exchange-e2e.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/voice-auto-accept.test.mjs && node tests/legacy-offer-purge.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs && node tests/sessions-reducer.test.mjs && node tests/webrtc-sdp.test.mjs && node tests/webrtc-video.test.mjs && node tests/webrtc-adaptation.test.mjs && node tests/session-recovery.test.mjs && node tests/qr-zip-bomb.test.mjs && node tests/ice-gathering-patience.test.mjs && node tests/version-consistency.test.mjs && node tests/double-ratchet.test.mjs && node tests/ratchet-integration.test.mjs && node tests/descriptor-sbq2.test.mjs && node tests/sbq2-key-exchange.test.mjs" "test": "node tests/sas-verification.test.mjs && node tests/verification-gate.test.mjs && node tests/inbound-frame-authentication.test.mjs && node tests/control-frame-authorization.test.mjs && node tests/security-level-shape.test.mjs && node tests/desktop-download-links.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/notification-meta-forwarding.test.mjs && node tests/notification-ephemeral-privacy.test.mjs && node tests/key-derivation-compat.test.mjs && node tests/key-exchange-e2e.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/voice-auto-accept.test.mjs && node tests/legacy-offer-purge.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs && node tests/sessions-reducer.test.mjs && node tests/webrtc-sdp.test.mjs && node tests/webrtc-video.test.mjs && node tests/webrtc-adaptation.test.mjs && node tests/session-recovery.test.mjs && node tests/qr-zip-bomb.test.mjs && node tests/ice-gathering-patience.test.mjs && node tests/version-consistency.test.mjs && node tests/double-ratchet.test.mjs && node tests/ratchet-integration.test.mjs && node tests/descriptor-sbq2.test.mjs && node tests/sbq2-key-exchange.test.mjs && node tests/qr-scan-single-frame.test.mjs"
}, },
"keywords": [ "keywords": [
"p2p", "p2p",
+34 -2
View File
@@ -4231,6 +4231,30 @@ import {
console.log('QR Code scanned:', scannedData.substring(0, 100) + '...'); console.log('QR Code scanned:', scannedData.substring(0, 100) + '...');
console.log('Current buffer state:', qrChunksBufferRef.current); console.log('Current buffer state:', qrChunksBufferRef.current);
// An SBQ2 invitation is ONE frame, always. It has to be
// recognised before any chunk logic runs: the assembler
// below assumes a fixed four-frame split (that is what
// an SB1 payload is cut into) and the fallback branch
// claims every non-JSON string over 100 characters a
// 151-character SB2: code matches, is filed as chunk 1
// of 4, and the scanner waits for three frames that do
// not exist.
if (scannedData.startsWith('SB2:') || scannedData.charCodeAt(0) === 0x02) {
qrChunksBufferRef.current = { id: null, total: 0, seen: new Set(), items: [] };
if (showOfferStep) {
setAnswerInput(scannedData);
} else {
setOfferInput(scannedData);
}
setMessages(prev => [...prev, {
message: 'Invitation captured.',
type: 'success'
}]);
setShowQRScannerModal(false);
return Promise.resolve(true);
}
// Check if this is a binary chunk (starts with SB1:bin: or is a raw binary chunk) // Check if this is a binary chunk (starts with SB1:bin: or is a raw binary chunk)
if (scannedData.startsWith('SB1:bin:') || (qrChunksBufferRef.current && qrChunksBufferRef.current.id)) { if (scannedData.startsWith('SB1:bin:') || (qrChunksBufferRef.current && qrChunksBufferRef.current.id)) {
console.log('Binary chunk detected:', scannedData.substring(0, 50) + '...'); console.log('Binary chunk detected:', scannedData.substring(0, 50) + '...');
@@ -4241,7 +4265,11 @@ import {
// Initialize buffer for binary chunks // Initialize buffer for binary chunks
qrChunksBufferRef.current = { qrChunksBufferRef.current = {
id: `bin_${Date.now()}`, id: `bin_${Date.now()}`,
total: 4, // We expect 4 chunks // SB1 payloads are split into exactly four
// frames by the generator above. SBQ2 never
// reaches here it is one frame and is
// handled at the top of this function.
total: 4,
seen: new Set(), seen: new Set(),
items: [], items: [],
lastUpdateMs: Date.now() lastUpdateMs: Date.now()
@@ -4321,7 +4349,11 @@ import {
console.log('Initializing buffer for potential binary chunks'); console.log('Initializing buffer for potential binary chunks');
qrChunksBufferRef.current = { qrChunksBufferRef.current = {
id: `bin_${Date.now()}`, id: `bin_${Date.now()}`,
total: 4, // We expect 4 chunks // SB1 payloads are split into exactly four
// frames by the generator above. SBQ2 never
// reaches here it is one frame and is
// handled at the top of this function.
total: 4,
seen: new Set(), seen: new Set(),
items: [], items: [],
lastUpdateMs: Date.now() lastUpdateMs: Date.now()
+1 -1
View File
@@ -11,7 +11,7 @@ let DYNAMIC_CACHE = 'securebit-pwa-dynamic-v4.7.56';
// Build stamp — rewritten by scripts/post-build.js on every release so this file's // Build stamp — rewritten by scripts/post-build.js on every release so this file's
// bytes change each deploy. That is what makes the browser detect a new Service Worker, // bytes change each deploy. That is what makes the browser detect a new Service Worker,
// reinstall it, drop stale caches and (via controllerchange) prompt the page to update. // reinstall it, drop stale caches and (via controllerchange) prompt the page to update.
const SW_BUILD_VERSION = '1786056807121'; const SW_BUILD_VERSION = '1786072827999';
// Load version from meta.json on install // Load version from meta.json on install
async function getAppVersion() { async function getAppVersion() {
+71
View File
@@ -0,0 +1,71 @@
// An SBQ2 invitation is one QR frame. The scanner must treat it as complete on
// sight.
//
// This test exists because of a real bug. The chunk assembler in handleQRScan
// was written for SB1, which the generator always cuts into exactly four frames,
// so the expected count is hard-coded to 4. Its fallback branch claims *any*
// non-JSON string longer than 100 characters — and an `SB2:` payload is 151 to
// 170 characters. A single, complete invitation was therefore filed as chunk 1
// of 4, and the scanner sat waiting for three frames that do not exist.
//
// The assertions are structural rather than behavioural: handleQRScan lives
// inside a 5,900-line JSX component and cannot be invoked in isolation, but the
// ordering that makes it correct can still be pinned.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const src = readFileSync(new URL('../src/app.jsx', import.meta.url), 'utf8');
const scanIdx = src.indexOf('const handleQRScan = async (scannedData) => {');
assert.notEqual(scanIdx, -1, 'handleQRScan must exist in src/app.jsx');
// Everything from the start of the handler to the end of the file is enough:
// what matters is the order of the branches inside it.
const body = src.slice(scanIdx);
const sbq2Idx = body.indexOf("scannedData.startsWith('SB2:')");
assert.notEqual(sbq2Idx, -1, 'handleQRScan must recognise an SB2 payload');
// The raw-byte form must be recognised too — the two families never collide,
// since any SB1 payload begins with ASCII 'S'.
assert.ok(/scannedData\.charCodeAt\(0\) === 0x02/.test(body.slice(sbq2Idx, sbq2Idx + 400)),
'the raw-byte SBQ2 form must be recognised alongside the text form');
// The SBQ2 branch must come before BOTH chunk-assembly branches: the explicit
// SB1:bin: one and the "long non-JSON string" fallback that actually caught it.
const binChunkIdx = body.indexOf("scannedData.startsWith('SB1:bin:')");
const lengthHeuristicIdx = body.indexOf('scannedData.length > 100');
assert.notEqual(binChunkIdx, -1, 'the SB1 chunk branch must still exist');
assert.notEqual(lengthHeuristicIdx, -1, 'the long-string fallback must still exist');
assert.ok(sbq2Idx < binChunkIdx,
'SBQ2 must be handled before the SB1:bin chunk assembler');
assert.ok(sbq2Idx < lengthHeuristicIdx,
'SBQ2 must be handled before the "long non-JSON string" fallback, which a ' +
'151-character SB2 payload otherwise matches');
// The branch must terminate the scan rather than fall through into assembly.
const sbq2Branch = body.slice(sbq2Idx, binChunkIdx);
assert.ok(/setShowQRScannerModal\(false\)/.test(sbq2Branch),
'capturing a single-frame invitation must close the scanner');
assert.ok(/return Promise\.resolve\(true\)/.test(sbq2Branch),
'the SBQ2 branch must report completion, not "waiting for more"');
assert.ok(/qrChunksBufferRef\.current = \{ id: null/.test(sbq2Branch),
'any partial chunk buffer must be cleared, so a stray earlier frame cannot ' +
'be spliced onto a complete invitation');
// Both destinations must be fed, because the same scanner serves both steps.
assert.ok(/setAnswerInput\(scannedData\)/.test(sbq2Branch) &&
/setOfferInput\(scannedData\)/.test(sbq2Branch),
'the scanned code must reach the answer field on the offering side and the ' +
'offer field on the joining side');
// The hard-coded four must stay explicitly tied to SB1, so it is not read as a
// general rule the next time the format changes.
for (const m of body.matchAll(/total: 4,/g)) {
const context = body.slice(Math.max(0, m.index - 400), m.index);
assert.ok(/SB1/.test(context),
'a hard-coded frame count must say which format it belongs to');
}
console.log('qr-scan-single-frame: all assertions passed');