@kubohiroya/turbowarp-webrtc-qrcode-pairing 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md CHANGED
@@ -15,7 +15,7 @@ QRコードを使ってWebRTCの接続情報を交換するTurboWarp拡張です
15
15
  - Answerがどの Offer への応答かを追跡し、2台のカメラ側端末の接続情報が入れ替わらないようにします。
16
16
  - 搬送の進捗と接続状態を別々の状態として報告します。エラー、取消、再試行、期限にも対応します。
17
17
 
18
- `0.2.0`では、メッセージを連結QRコードで運び、1枚のQRを既定でOfferはversion 15、Answerはversion 20までに抑え、読んだペアリング用のQRをすべて報告します。`turbowarp-jsqr` 0.4.0以降が必要です。`0.2.1`では、QRがカメラに写り続けている間の読取りがページの処理を止めてしまう不具合を直しました。
18
+ `0.2.0`では、メッセージを連結QRコードで運び、1枚のQRを既定でOfferはversion 15、Answerはversion 20までに抑え、読んだペアリング用のQRをすべて報告します。`turbowarp-jsqr` 0.4.0以降が必要です。`0.2.1`では、QRがカメラに写り続けている間の読取りがページの処理を止めてしまう不具合を直しました。`0.2.2`では、1枚目に収まらないheaderを先頭から続くQRで読み、読取りを1つずつ処理して接続情報を一度だけ渡し、規格に合わないQRを読んでも集めていたものを消さないようにしました。
19
19
 
20
20
  > [!NOTE]
21
21
  > ペアリング用ブロックは起動時固定のフィーチャーフラグの内側にあり、既定は無効です。
@@ -62,9 +62,9 @@ Offerは統合側端末がループ表示し、カメラ側端末は順不同で
62
62
 
63
63
  ペアリング用メッセージは`twqr/2`形式のテキストです。protocol、JSONのheader、接続情報を1行ずつ並べます。headerには、session、送信側と受信側のpeer名、メッセージ種別、message ID、reply-to、接続情報の長さとSHA-256ハッシュを含みます。
64
64
 
65
- メッセージは1つの連結QRコードとして運びます。作成には[`@kubohiroya/qrcode-structured-append`](https://github.com/kubohiroya/qrcode-structured-append)を使います。各QRは規格のheaderに自分の位置、枚数、連結QRのparityを持つため、本拡張独自の包みなしに、読み手はどのQRを読んだかが分かります。headerの行が先頭にあるため、連結QRの1枚目だけで誰宛てのメッセージかが分かります。
65
+ メッセージは1つの連結QRコードとして運びます。作成には[`@kubohiroya/qrcode-structured-append`](https://github.com/kubohiroya/qrcode-structured-append)を使います。各QRは規格のheaderに自分の位置、枚数、連結QRのparityを持つため、本拡張独自の包みなしに、読み手はどのQRを読んだかが分かります。headerの行が先頭にあるため、全部そろう前に、先頭のQRで誰宛てのメッセージかが分かります。既定の設定では1枚目だけで、QRが小さいときや誤り訂正が高いときは先頭の数枚で分かります。
66
66
 
67
- 読取りは連結QR単位です。最初に読んだQRが収集する連結QRを決め、それ以外の連結QRのQRは`foreign`として報告して無視します。ただし収集中の連結QRの1枚目をまだ読んでいない間は、この交換に属する1枚目が読まれれば入れ替えます。古い投影のQRが1枚写り込んでも、正しいOfferの収集を妨げないためです。別の交換のものや破損していたと分かった連結QRは破棄し、表示が続いているQRから集め直します。
67
+ 読取りは連結QR単位です。最初に読んだQRが収集する連結QRを決め、それ以外の連結QRのQRは`foreign`として報告して無視します。ただし収集中の連結QRのheaderをまだ読めていない間は、別の連結QRのQRも脇で集め、そのheaderからこの交換のものと分かれば入れ替えます。古い投影のQRが1枚写り込んでも、正しいOfferの収集を妨げないためです。別の交換のものや破損していたと分かった連結QRは破棄し、表示が続いているQRから集め直します。
68
68
 
69
69
  parityは連結QRをまとめるだけのもので、256回に1回は一致してしまいます。光学搬送での破損や混在を検出するのはheader内のハッシュです。これは認証ではありません。QRを撮影できる人は誰でも同じ値を計算できます。投影された接続情報は、その投影を見られる全員に見えているものとして扱ってください。
70
70
 
package/README.md CHANGED
@@ -15,7 +15,7 @@ A TurboWarp extension for exchanging WebRTC connection information through QR co
15
15
  - Tracks which offer an answer replies to, so two camera machines never receive each other's connection.
16
16
  - Reports transport progress and connection state as separate states, plus errors, cancellation, retry and deadlines.
17
17
 
18
- Version 0.2.0 carries messages as Structured Append sequences, caps Offer codes at QR version 15 and Answer codes at 20, and reports every pairing code read. It needs `turbowarp-jsqr` 0.4.0 or later. 0.2.1 fixes a camera scan that starved the page while a code stayed in view.
18
+ Version 0.2.0 carries messages as Structured Append sequences, caps Offer codes at QR version 15 and Answer codes at 20, and reports every pairing code read. It needs `turbowarp-jsqr` 0.4.0 or later. 0.2.1 fixes a camera scan that starved the page while a code stayed in view. 0.2.2 reads a header that runs past the first code, takes reads one at a time so a code is handed over once, and keeps a sequence when a malformed code is read.
19
19
 
20
20
  > [!NOTE]
21
21
  > The pairing blocks are behind a startup feature flag that is off by default. See
@@ -62,9 +62,9 @@ A pairing session cannot be handed from one app to another. Opening a different
62
62
 
63
63
  A pairing message is text in the `twqr/2` format: the protocol, a JSON header, and the pairing code, one per line. The header names the session, the sender and target peers, the message kind, the message id, what it replies to, the code's length and its SHA-256 hash.
64
64
 
65
- The message is carried as one Structured Append sequence, made by [`@kubohiroya/qrcode-structured-append`](https://github.com/kubohiroya/qrcode-structured-append). Each code holds its position, the count and the sequence's parity in the standard header, so a reader knows which code it has without any wrapper of this extension's. The header line comes first, so the first code of a sequence already tells a reader whose message it is.
65
+ The message is carried as one Structured Append sequence, made by [`@kubohiroya/qrcode-structured-append`](https://github.com/kubohiroya/qrcode-structured-append). Each code holds its position, the count and the sequence's parity in the standard header, so a reader knows which code it has without any wrapper of this extension's. The header line comes first, so the leading codes of a sequence tell a reader whose message it is before the rest arrive: the first code alone at the default settings, the first few when codes are small or the error correction is high.
66
66
 
67
- Reading goes by sequence. The first code read decides the sequence being collected; codes of any other sequence are reported as `foreign` and ignored. Until the first code of that sequence has been read, a first code that belongs to this exchange replaces it, so a stray code of an old projection cannot block the right one. A sequence that turns out to be another exchange's, or damaged, is dropped and collected again from the codes still being shown.
67
+ Reading goes by sequence. The first code read decides the sequence being collected; codes of any other sequence are reported as `foreign` and ignored. Until that sequence's header has been read, codes of another sequence are collected on the side, and if their header shows they belong to this exchange they replace it, so a stray code of an old projection cannot block the right one. A sequence that turns out to be another exchange's, or damaged, is dropped and collected again from the codes still being shown.
68
68
 
69
69
  The parity only groups codes and collides one time in 256, so the hash in the header is what detects optical damage and mixed sequences. It is not authentication: anyone who can photograph the codes can recompute it. Treat a projected pairing code as visible to everyone who can see the projection.
70
70
 
@@ -116,7 +116,7 @@ pnpm run dev
116
116
 
117
117
  Build output: `dist/webrtc-qrcode-pairing.js` and `dist/extension-manifest.json`. Load the JavaScript file as a custom TurboWarp extension. The manifest always records every declared block, including blocks the feature flag hides at runtime: it is the build-time contract, not the runtime state.
118
118
 
119
- Package name and version: `@kubohiroya/turbowarp-webrtc-qrcode-pairing@0.2.1`.
119
+ Package name and version: `@kubohiroya/turbowarp-webrtc-qrcode-pairing@0.2.2`.
120
120
 
121
121
  ## Block reference
122
122
 
@@ -3021,30 +3021,43 @@
3021
3021
  if (await sha256Base64Url(message.payload) !== message.header.messageHash) throw new QrPairingError("hash-mismatch", "The pairing code failed its hash check.");
3022
3022
  return message;
3023
3023
  }
3024
- /** The header, if the given first code of a sequence holds all of it. */
3025
- function headerOfFirstCode(read) {
3026
- return parseHeader(latin1(read.bytes));
3027
- }
3028
3024
  /**
3029
3025
  * Collects the codes of one pairing message, in any order.
3030
3026
  *
3031
- * The first code read decides the sequence. Until the first code of that
3032
- * sequence has shown whose message it is, another sequence whose first code
3033
- * does belong to this exchange may take its place: a camera may well see an
3034
- * old projection before the right one.
3027
+ * The first code read decides the sequence. The header comes first in the
3028
+ * message, so the codes from the first onwards tell whose message it is before
3029
+ * all of them have arrived — in the first code alone at the default settings,
3030
+ * across the first few when codes are small or the error correction is high.
3035
3031
  */
3036
3032
  var PairingAssembler = class {
3037
3033
  constructor() {
3038
3034
  this.inner = new StructuredAppendAssembler();
3035
+ this.shares = /* @__PURE__ */ new Map();
3039
3036
  this.headerVerified = false;
3040
3037
  }
3041
3038
  /** Throws `conflicting-part` for a position that arrives twice with different content. */
3042
3039
  add(read) {
3040
+ let outcome;
3043
3041
  try {
3044
- return this.inner.add(read);
3042
+ outcome = this.inner.add(read);
3045
3043
  } catch (error) {
3046
3044
  throw translate(error);
3047
3045
  }
3046
+ if (outcome.result === "accepted") this.shares.set(read.index, read.bytes);
3047
+ return outcome;
3048
+ }
3049
+ /**
3050
+ * The header, once the codes from the first onwards hold all of it.
3051
+ * Undefined while they do not; throws as soon as they cannot be a pairing
3052
+ * message, or the header is malformed.
3053
+ */
3054
+ header() {
3055
+ let prefix = "";
3056
+ for (let index = 0; this.shares.has(index); index += 1) prefix += latin1(this.shares.get(index) ?? /* @__PURE__ */ new Uint8Array());
3057
+ return prefix === "" ? void 0 : parseHeader(prefix);
3058
+ }
3059
+ isEmpty() {
3060
+ return this.inner.received() === 0;
3048
3061
  }
3049
3062
  receivedCount() {
3050
3063
  return this.inner.received();
@@ -3072,6 +3085,7 @@
3072
3085
  }
3073
3086
  clear() {
3074
3087
  this.inner.clear();
3088
+ this.shares.clear();
3075
3089
  this.headerVerified = false;
3076
3090
  }
3077
3091
  };
@@ -3419,13 +3433,35 @@
3419
3433
  *
3420
3434
  * A lone code — one that is not part of a sequence — is read as a whole
3421
3435
  * message, the way `ingestQrText` reads one.
3436
+ *
3437
+ * Reads of one session are taken one at a time, in the order they came, so
3438
+ * two scripts feeding codes at once cannot both complete the message.
3422
3439
  */
3423
3440
  async ingestQrRead(sessionKey, read) {
3424
3441
  this.requireEnabled();
3425
- const session = this.requireReceiving(sessionKey);
3442
+ const session = this.requireSession(sessionKey);
3443
+ return this.inTurn(session, () => this.takeRead(session, read));
3444
+ }
3445
+ /**
3446
+ * Accepts a whole pairing message as text, the way it would arrive through
3447
+ * something other than a camera, or in one QR code.
3448
+ */
3449
+ async ingestQrText(sessionKey, text) {
3450
+ this.requireEnabled();
3451
+ const session = this.requireSession(sessionKey);
3452
+ return this.inTurn(session, () => this.takeText(session, text));
3453
+ }
3454
+ /** Runs `work` after every read of the session that came before it has finished. */
3455
+ inTurn(session, work) {
3456
+ const turn = session.readQueue.then(work);
3457
+ session.readQueue = turn.catch(() => void 0);
3458
+ return turn;
3459
+ }
3460
+ async takeRead(session, read) {
3461
+ this.requireOpen(session);
3426
3462
  const position = read.structuredAppend;
3427
3463
  if (position === null) {
3428
- await this.ingestQrText(sessionKey, read.text);
3464
+ await this.takeText(session, read.text);
3429
3465
  return;
3430
3466
  }
3431
3467
  const symbol = {
@@ -3433,42 +3469,40 @@
3433
3469
  bytes: read.bytes
3434
3470
  };
3435
3471
  const part = `${symbol.index + 1} / ${symbol.count}`;
3436
- const assembler = session.assembler;
3437
- let outcome;
3438
- try {
3439
- outcome = assembler.add(symbol);
3440
- if (outcome.result === "foreign" && this.replacesUnverifiedSequence(session, symbol)) {
3441
- assembler.clear();
3442
- outcome = assembler.add(symbol);
3472
+ if (session.delivered) {
3473
+ let repeat = false;
3474
+ try {
3475
+ repeat = session.assembler.add(symbol).result === "duplicate";
3476
+ } catch (error) {
3477
+ if (!isReported(error)) throw error;
3443
3478
  }
3444
- } catch (error) {
3445
- assembler.clear();
3446
- this.noteRead(session, "foreign", codeOf(error));
3447
- throw error;
3479
+ this.noteRead(session, repeat ? "duplicate" : "foreign", repeat ? part : "message-mismatch");
3480
+ throw alreadyAccepted();
3448
3481
  }
3482
+ let outcome = this.addTo(session, session.assembler, symbol);
3483
+ if (outcome.result === "foreign" && !session.assembler.headerVerified) outcome = this.offerToCandidate(session, symbol) ?? outcome;
3449
3484
  if (outcome.result === "foreign") {
3450
3485
  this.noteRead(session, "foreign", "message-mismatch");
3451
3486
  throw new QrPairingError("message-mismatch", "QR code belongs to another sequence than the one being collected.");
3452
3487
  }
3453
- if (session.delivered) {
3454
- this.noteRead(session, "duplicate", part);
3455
- throw alreadyAccepted();
3456
- }
3457
- if (outcome.result === "accepted" && symbol.index === 0) try {
3458
- const header = headerOfFirstCode(symbol);
3488
+ const assembler = session.assembler;
3489
+ if (outcome.result === "accepted" && !assembler.headerVerified) try {
3490
+ const header = assembler.header();
3459
3491
  if (header) {
3460
3492
  this.verifyHeader(session, header);
3461
3493
  assembler.headerVerified = true;
3462
3494
  }
3463
3495
  } catch (error) {
3464
3496
  assembler.clear();
3465
- if (error instanceof QrPairingError && unreportedCodes.has(error.code)) throw error;
3497
+ if (!isReported(error)) throw error;
3466
3498
  this.noteRead(session, "foreign", codeOf(error));
3467
3499
  throw error;
3468
3500
  }
3469
- this.noteRead(session, outcome.result, part);
3470
3501
  session.phase = session.role === "hub" ? "awaiting-answer" : "receiving";
3471
- if (!assembler.isComplete()) return;
3502
+ if (!assembler.isComplete()) {
3503
+ this.noteRead(session, outcome.result, part);
3504
+ return;
3505
+ }
3472
3506
  const epoch = session.epoch;
3473
3507
  let message;
3474
3508
  try {
@@ -3482,15 +3516,11 @@
3482
3516
  throw error;
3483
3517
  }
3484
3518
  assembler.headerVerified = true;
3519
+ this.noteRead(session, "accepted", part);
3485
3520
  await this.deliver(session, message, epoch);
3486
3521
  }
3487
- /**
3488
- * Accepts a whole pairing message as text, the way it would arrive through
3489
- * something other than a camera, or in one QR code.
3490
- */
3491
- async ingestQrText(sessionKey, text) {
3492
- this.requireEnabled();
3493
- const session = this.requireReceiving(sessionKey);
3522
+ async takeText(session, text) {
3523
+ this.requireOpen(session);
3494
3524
  const epoch = session.epoch;
3495
3525
  const message = await readPairingMessage(text);
3496
3526
  if (this.isStale(session, epoch)) return;
@@ -3508,22 +3538,48 @@
3508
3538
  await this.deliver(session, message, epoch);
3509
3539
  }
3510
3540
  /**
3511
- * Whether a code of another sequence should replace the one being collected.
3541
+ * Adds a code to a sequence. A position read twice with different content
3542
+ * means this sequence is damaged, so it is dropped and collected again from
3543
+ * the codes still being shown. Anything else wrong with the code leaves the
3544
+ * sequence as it was.
3545
+ */
3546
+ addTo(session, assembler, symbol) {
3547
+ try {
3548
+ return assembler.add(symbol);
3549
+ } catch (error) {
3550
+ if (codeOf(error) !== "conflicting-part") throw error;
3551
+ assembler.clear();
3552
+ this.noteRead(session, "foreign", "conflicting-part");
3553
+ throw error;
3554
+ }
3555
+ }
3556
+ /**
3557
+ * Collects a code of another sequence on the side, while the sequence held
3558
+ * has not shown whose it is. If the other sequence's header shows it is this
3559
+ * exchange's, it takes the place of the one held.
3512
3560
  *
3513
- * Only while the sequence held has not shown whose it is, and only by the
3514
- * first code of a sequence that shows it is this exchange's. A camera that
3515
- * first catches a stray code of an old projection would otherwise wait for
3516
- * the rest of that old sequence forever.
3561
+ * A camera that first catches a stray code of an old projection would
3562
+ * otherwise wait for the rest of that old sequence forever. Returns the
3563
+ * outcome in the sequence that took over, or undefined when nothing changed.
3517
3564
  */
3518
- replacesUnverifiedSequence(session, symbol) {
3519
- if (symbol.index !== 0 || session.assembler.headerVerified || session.delivered) return false;
3565
+ offerToCandidate(session, symbol) {
3566
+ const candidate = session.candidate;
3520
3567
  try {
3521
- const header = headerOfFirstCode(symbol);
3522
- if (!header) return false;
3568
+ let outcome = candidate.add(symbol);
3569
+ if (outcome.result === "foreign") {
3570
+ candidate.clear();
3571
+ outcome = candidate.add(symbol);
3572
+ }
3573
+ const header = candidate.header();
3574
+ if (!header) return void 0;
3523
3575
  this.verifyHeader(session, header);
3524
- return true;
3576
+ candidate.headerVerified = true;
3577
+ session.assembler = candidate;
3578
+ session.candidate = new PairingAssembler();
3579
+ return outcome;
3525
3580
  } catch {
3526
- return false;
3581
+ candidate.clear();
3582
+ return;
3527
3583
  }
3528
3584
  }
3529
3585
  /** Hands a checked pairing code to WebRTC, once per exchange. */
@@ -3535,10 +3591,8 @@
3535
3591
  if (session.role === "hub") await this.acceptAnswer(session, message.payload, epoch);
3536
3592
  else await this.acceptOfferAndPrepareAnswer(session, message.payload, epoch);
3537
3593
  }
3538
- requireReceiving(sessionKey) {
3539
- const session = this.requireSession(sessionKey);
3594
+ requireOpen(session) {
3540
3595
  if (isTerminalPhase(session.phase)) throw new QrPairingError(session.phase === "connected" ? "already-accepted" : "stale-exchange", `Pairing session ${session.sessionKey} is no longer receiving codes.`);
3541
- return session;
3542
3596
  }
3543
3597
  /** Records what a pairing code turned out to be, for the application to show. */
3544
3598
  noteRead(session, result, detail) {
@@ -3861,6 +3915,8 @@
3861
3915
  outgoingSvgs: [],
3862
3916
  outgoingCurrentIndex: -1,
3863
3917
  assembler: new PairingAssembler(),
3918
+ candidate: new PairingAssembler(),
3919
+ readQueue: Promise.resolve(),
3864
3920
  delivered: false,
3865
3921
  peerCreated: false,
3866
3922
  timeoutMilliseconds: 6e5,
@@ -3887,6 +3943,7 @@
3887
3943
  session.outgoingSvgs = [];
3888
3944
  session.outgoingCurrentIndex = -1;
3889
3945
  session.assembler = new PairingAssembler();
3946
+ session.candidate = new PairingAssembler();
3890
3947
  session.delivered = false;
3891
3948
  session.peerCreated = false;
3892
3949
  session.startedAtMonotonic = this.clock.nowMilliseconds();
@@ -3946,6 +4003,7 @@
3946
4003
  this.releaseDisplay(session);
3947
4004
  if (!connected) {
3948
4005
  session.assembler.clear();
4006
+ session.candidate.clear();
3949
4007
  if (session.peerCreated) {
3950
4008
  try {
3951
4009
  this.webrtc().closePeer(session.remotePeerId);
@@ -4029,6 +4087,10 @@
4029
4087
  function alreadyAccepted() {
4030
4088
  return new QrPairingError("already-accepted", "The pairing code for this exchange has already been accepted.");
4031
4089
  }
4090
+ /** Whether a failure is about a pairing code, rather than about text that is not one at all. */
4091
+ function isReported(error) {
4092
+ return !(error instanceof QrPairingError && unreportedCodes.has(error.code));
4093
+ }
4032
4094
  //#endregion
4033
4095
  //#region src/extension.ts
4034
4096
  var blockDefinitions = block_definitions_default.blocks;
@@ -150,12 +150,13 @@ say (join (join (current pairing QR part of [pairing-1]) " / ") (pairing QR part
150
150
  害はありません。別の連結QRのQRは報告して無視し、交換は終わらせません。ポスターなど、ペアリング用
151
151
  ではないQRは何も言わずに読み飛ばします。
152
152
 
153
- 最初に読んだQRで、どの連結QRを集めるかが決まります。各連結QRの1枚目にはheaderが入っているので、
154
- それを読めば誰宛てのメッセージかが分かります。
153
+ 最初に読んだQRで、どの連結QRを集めるかが決まります。headerはメッセージの先頭にあるので、先頭のQRを
154
+ 読めば誰宛てのメッセージかが分かります。既定の設定では1枚目だけで、QRが小さいときや誤り訂正が高いときは
155
+ 先頭の数枚で分かります。
155
156
 
156
157
  - 別の交換、別の相手、逆向きのものなら、その連結QRを破棄し、理由を付けて`foreign`と報告します。
157
- - 集めている連結QRの1枚目をまだ読んでいない間は、この交換に属する連結QRの1枚目が読まれれば、
158
- そちらに入れ替えます。古い投影のQRが1枚写り込んでも、正しいOfferの収集を妨げません。
158
+ - 集めている連結QRのheaderをまだ読めていない間は、別の連結QRのQRも脇で集め、そのheaderからこの交換の
159
+ ものと分かれば、そちらに入れ替えます。古い投影のQRが1枚写り込んでも、正しいOfferの収集を妨げません。
159
160
  - 全QRが揃うと、メッセージをハッシュで検査します。壊れていた連結QRは破棄して`foreign`
160
161
  (`hash-mismatch`)と報告し、表示が続いているQRから集め直します。
161
162
 
@@ -156,14 +156,15 @@ Reading the same code repeatedly is normal and costs nothing. A code of another
156
156
  and ignored without ending the exchange. QR codes that are not pairing codes at all, such as a
157
157
  poster, are skipped without a word.
158
158
 
159
- The first code read decides which sequence the session collects. The first code of each sequence
160
- carries its header, so once that one is read the session knows whose message it is:
159
+ The first code read decides which sequence the session collects. The header comes first in the
160
+ message, so once the leading codes are read the session knows whose message it is — the first code
161
+ alone at the default settings, the first few when codes are small or the error correction is high:
161
162
 
162
163
  - if it belongs to another exchange, another pair of peers or the other direction, the sequence is
163
164
  dropped and reported as `foreign` with the reason;
164
- - while the first code of the sequence being collected has not been read yet, the first code of a
165
- sequence that does belong to this exchange takes its place. A stray code of an old projection
166
- therefore cannot block the right one;
165
+ - while the header of the sequence being collected has not been read yet, codes of another sequence
166
+ are collected on the side, and a sequence whose header shows it belongs to this exchange takes its
167
+ place. A stray code of an old projection therefore cannot block the right one;
167
168
  - when every code has arrived, the message is checked against its hash. A damaged sequence is
168
169
  dropped, reported as `foreign` with `hash-mismatch`, and collected again from the codes still being
169
170
  cycled.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-webrtc-qrcode-pairing",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "TurboWarp extension that pairs WebRTC peers by carrying offers and answers as QR codes.",
5
5
  "author": "Hiroya Kubo <hiroya@cuc.ac.jp>",
6
6
  "license": "MPL-2.0",
@@ -5,7 +5,6 @@ import {QrPairingError, type PairingErrorCode} from '../errors.js';
5
5
  import type {AddOutcome, StructuredAppendRead} from '@kubohiroya/qrcode-structured-append';
6
6
  import {
7
7
  createPairingCodes,
8
- headerOfFirstCode,
9
8
  PairingAssembler,
10
9
  readPairingMessage,
11
10
  type CreateCodesOptions,
@@ -186,32 +185,58 @@ export class PairingController {
186
185
  *
187
186
  * A lone code — one that is not part of a sequence — is read as a whole
188
187
  * message, the way `ingestQrText` reads one.
188
+ *
189
+ * Reads of one session are taken one at a time, in the order they came, so
190
+ * two scripts feeding codes at once cannot both complete the message.
189
191
  */
190
192
  public async ingestQrRead(sessionKey: string, read: QrRead): Promise<void> {
191
193
  this.requireEnabled();
192
- const session = this.requireReceiving(sessionKey);
194
+ const session = this.requireSession(sessionKey);
195
+ return this.inTurn(session, () => this.takeRead(session, read));
196
+ }
197
+
198
+ /**
199
+ * Accepts a whole pairing message as text, the way it would arrive through
200
+ * something other than a camera, or in one QR code.
201
+ */
202
+ public async ingestQrText(sessionKey: string, text: string): Promise<void> {
203
+ this.requireEnabled();
204
+ const session = this.requireSession(sessionKey);
205
+ return this.inTurn(session, () => this.takeText(session, text));
206
+ }
207
+
208
+ /** Runs `work` after every read of the session that came before it has finished. */
209
+ private inTurn(session: PairingSessionState, work: () => Promise<void>): Promise<void> {
210
+ const turn = session.readQueue.then(work);
211
+ session.readQueue = turn.catch(() => undefined);
212
+ return turn;
213
+ }
214
+
215
+ private async takeRead(session: PairingSessionState, read: QrRead): Promise<void> {
216
+ this.requireOpen(session);
193
217
  const position = read.structuredAppend;
194
218
  if (position === null) {
195
- await this.ingestQrText(sessionKey, read.text);
219
+ await this.takeText(session, read.text);
196
220
  return;
197
221
  }
198
222
  const symbol = {...position, bytes: read.bytes};
199
223
  const part = `${symbol.index + 1} / ${symbol.count}`;
200
- const assembler = session.assembler;
201
224
 
202
- let outcome: AddOutcome;
203
- try {
204
- outcome = assembler.add(symbol);
205
- if (outcome.result === 'foreign' && this.replacesUnverifiedSequence(session, symbol)) {
206
- assembler.clear();
207
- outcome = assembler.add(symbol);
225
+ if (session.delivered) {
226
+ // The code has been handed over; a read can only be a repeat or something else.
227
+ let repeat = false;
228
+ try {
229
+ repeat = session.assembler.add(symbol).result === 'duplicate';
230
+ } catch (error) {
231
+ if (!isReported(error)) throw error;
208
232
  }
209
- } catch (error) {
210
- // A position read twice with different content: this sequence is damaged,
211
- // so it is dropped and collected again from the codes still being shown.
212
- assembler.clear();
213
- this.noteRead(session, 'foreign', codeOf(error));
214
- throw error;
233
+ this.noteRead(session, repeat ? 'duplicate' : 'foreign', repeat ? part : 'message-mismatch');
234
+ throw alreadyAccepted();
235
+ }
236
+
237
+ let outcome = this.addTo(session, session.assembler, symbol);
238
+ if (outcome.result === 'foreign' && !session.assembler.headerVerified) {
239
+ outcome = this.offerToCandidate(session, symbol) ?? outcome;
215
240
  }
216
241
  if (outcome.result === 'foreign') {
217
242
  this.noteRead(session, 'foreign', 'message-mismatch');
@@ -220,28 +245,28 @@ export class PairingController {
220
245
  'QR code belongs to another sequence than the one being collected.'
221
246
  );
222
247
  }
223
- if (session.delivered) {
224
- this.noteRead(session, 'duplicate', part);
225
- throw alreadyAccepted();
226
- }
227
- if (outcome.result === 'accepted' && symbol.index === 0) {
248
+ const assembler = session.assembler;
249
+ if (outcome.result === 'accepted' && !assembler.headerVerified) {
228
250
  try {
229
- const header = headerOfFirstCode(symbol);
251
+ const header = assembler.header();
230
252
  if (header) {
231
253
  this.verifyHeader(session, header);
232
254
  assembler.headerVerified = true;
233
255
  }
234
256
  } catch (error) {
235
257
  assembler.clear();
236
- if (error instanceof QrPairingError && unreportedCodes.has(error.code)) throw error;
258
+ if (!isReported(error)) throw error;
237
259
  this.noteRead(session, 'foreign', codeOf(error));
238
260
  throw error;
239
261
  }
240
262
  }
241
- this.noteRead(session, outcome.result, part);
242
263
  session.phase = session.role === 'hub' ? 'awaiting-answer' : 'receiving';
243
- if (!assembler.isComplete()) return;
264
+ if (!assembler.isComplete()) {
265
+ this.noteRead(session, outcome.result, part);
266
+ return;
267
+ }
244
268
 
269
+ // The last code is reported once, as what the whole message turned out to be.
245
270
  const epoch = session.epoch;
246
271
  let message: PairingMessage;
247
272
  try {
@@ -255,16 +280,12 @@ export class PairingController {
255
280
  throw error;
256
281
  }
257
282
  assembler.headerVerified = true;
283
+ this.noteRead(session, 'accepted', part);
258
284
  await this.deliver(session, message, epoch);
259
285
  }
260
286
 
261
- /**
262
- * Accepts a whole pairing message as text, the way it would arrive through
263
- * something other than a camera, or in one QR code.
264
- */
265
- public async ingestQrText(sessionKey: string, text: string): Promise<void> {
266
- this.requireEnabled();
267
- const session = this.requireReceiving(sessionKey);
287
+ private async takeText(session: PairingSessionState, text: string): Promise<void> {
288
+ this.requireOpen(session);
268
289
  // Text that is not a pairing message at all throws here and is not reported.
269
290
  const epoch = session.epoch;
270
291
  const message = await readPairingMessage(text);
@@ -284,25 +305,56 @@ export class PairingController {
284
305
  }
285
306
 
286
307
  /**
287
- * Whether a code of another sequence should replace the one being collected.
308
+ * Adds a code to a sequence. A position read twice with different content
309
+ * means this sequence is damaged, so it is dropped and collected again from
310
+ * the codes still being shown. Anything else wrong with the code leaves the
311
+ * sequence as it was.
312
+ */
313
+ private addTo(
314
+ session: PairingSessionState,
315
+ assembler: PairingAssembler,
316
+ symbol: StructuredAppendRead
317
+ ): AddOutcome {
318
+ try {
319
+ return assembler.add(symbol);
320
+ } catch (error) {
321
+ if (codeOf(error) !== 'conflicting-part') throw error;
322
+ assembler.clear();
323
+ this.noteRead(session, 'foreign', 'conflicting-part');
324
+ throw error;
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Collects a code of another sequence on the side, while the sequence held
330
+ * has not shown whose it is. If the other sequence's header shows it is this
331
+ * exchange's, it takes the place of the one held.
288
332
  *
289
- * Only while the sequence held has not shown whose it is, and only by the
290
- * first code of a sequence that shows it is this exchange's. A camera that
291
- * first catches a stray code of an old projection would otherwise wait for
292
- * the rest of that old sequence forever.
333
+ * A camera that first catches a stray code of an old projection would
334
+ * otherwise wait for the rest of that old sequence forever. Returns the
335
+ * outcome in the sequence that took over, or undefined when nothing changed.
293
336
  */
294
- private replacesUnverifiedSequence(
337
+ private offerToCandidate(
295
338
  session: PairingSessionState,
296
339
  symbol: StructuredAppendRead
297
- ): boolean {
298
- if (symbol.index !== 0 || session.assembler.headerVerified || session.delivered) return false;
340
+ ): AddOutcome | undefined {
341
+ const candidate = session.candidate;
299
342
  try {
300
- const header = headerOfFirstCode(symbol);
301
- if (!header) return false;
343
+ let outcome = candidate.add(symbol);
344
+ if (outcome.result === 'foreign') {
345
+ candidate.clear();
346
+ outcome = candidate.add(symbol);
347
+ }
348
+ const header = candidate.header();
349
+ if (!header) return undefined;
302
350
  this.verifyHeader(session, header);
303
- return true;
351
+ candidate.headerVerified = true;
352
+ session.assembler = candidate;
353
+ session.candidate = new PairingAssembler();
354
+ return outcome;
304
355
  } catch {
305
- return false;
356
+ candidate.clear();
357
+ return undefined;
306
358
  }
307
359
  }
308
360
 
@@ -325,17 +377,16 @@ export class PairingController {
325
377
  }
326
378
  }
327
379
 
328
- private requireReceiving(sessionKey: string): PairingSessionState {
329
- const session = this.requireSession(sessionKey);
380
+ private requireOpen(session: PairingSessionState): void {
330
381
  if (isTerminalPhase(session.phase)) {
331
382
  throw new QrPairingError(
332
383
  session.phase === 'connected' ? 'already-accepted' : 'stale-exchange',
333
384
  `Pairing session ${session.sessionKey} is no longer receiving codes.`
334
385
  );
335
386
  }
336
- return session;
337
387
  }
338
388
 
389
+
339
390
  /** Records what a pairing code turned out to be, for the application to show. */
340
391
  private noteRead(
341
392
  session: PairingSessionState,
@@ -779,6 +830,8 @@ export class PairingController {
779
830
  outgoingSvgs: [],
780
831
  outgoingCurrentIndex: -1,
781
832
  assembler: new PairingAssembler(),
833
+ candidate: new PairingAssembler(),
834
+ readQueue: Promise.resolve(),
782
835
  delivered: false,
783
836
  peerCreated: false,
784
837
  timeoutMilliseconds: DEFAULT_TIMEOUT_SECONDS * 1000,
@@ -806,6 +859,7 @@ export class PairingController {
806
859
  session.outgoingSvgs = [];
807
860
  session.outgoingCurrentIndex = -1;
808
861
  session.assembler = new PairingAssembler();
862
+ session.candidate = new PairingAssembler();
809
863
  session.delivered = false;
810
864
  session.peerCreated = false;
811
865
  session.startedAtMonotonic = this.clock.nowMilliseconds();
@@ -879,6 +933,7 @@ export class PairingController {
879
933
  this.releaseDisplay(session);
880
934
  if (!connected) {
881
935
  session.assembler.clear();
936
+ session.candidate.clear();
882
937
  if (session.peerCreated) {
883
938
  try {
884
939
  this.webrtc().closePeer(session.remotePeerId);
@@ -1004,3 +1059,8 @@ function alreadyAccepted(): QrPairingError {
1004
1059
  'The pairing code for this exchange has already been accepted.'
1005
1060
  );
1006
1061
  }
1062
+
1063
+ /** Whether a failure is about a pairing code, rather than about text that is not one at all. */
1064
+ function isReported(error: unknown): boolean {
1065
+ return !(error instanceof QrPairingError && unreportedCodes.has(error.code));
1066
+ }
@@ -103,6 +103,13 @@ export interface PairingSessionState {
103
103
  /** Zero-based index into `outgoingSvgs`, or -1 when nothing is selected. */
104
104
  outgoingCurrentIndex: number;
105
105
  assembler: PairingAssembler;
106
+ /**
107
+ * Another sequence, collected on the side while the one in `assembler` has
108
+ * not shown whose it is, in case it turns out to be this exchange's.
109
+ */
110
+ candidate: PairingAssembler;
111
+ /** Settles when every read taken so far has been handled; reads wait their turn on it. */
112
+ readQueue: Promise<void>;
106
113
  /** True once the reassembled code has been handed to WebRTC. Prevents a second delivery. */
107
114
  delivered: boolean;
108
115
  /** True once a WebRTC peer exists for `remotePeerId`, so its state is worth polling. */
package/src/qr/courier.ts CHANGED
@@ -125,31 +125,48 @@ export async function readPairingMessage(text: string): Promise<PairingMessage>
125
125
  return message;
126
126
  }
127
127
 
128
- /** The header, if the given first code of a sequence holds all of it. */
129
- export function headerOfFirstCode(read: StructuredAppendRead): PairingMessageHeader | undefined {
130
- return parseHeader(latin1(read.bytes));
131
- }
132
-
133
128
  /**
134
129
  * Collects the codes of one pairing message, in any order.
135
130
  *
136
- * The first code read decides the sequence. Until the first code of that
137
- * sequence has shown whose message it is, another sequence whose first code
138
- * does belong to this exchange may take its place: a camera may well see an
139
- * old projection before the right one.
131
+ * The first code read decides the sequence. The header comes first in the
132
+ * message, so the codes from the first onwards tell whose message it is before
133
+ * all of them have arrived — in the first code alone at the default settings,
134
+ * across the first few when codes are small or the error correction is high.
140
135
  */
141
136
  export class PairingAssembler {
142
137
  private readonly inner = new StructuredAppendAssembler();
143
- /** True once the sequence's first code showed a header this exchange accepts. */
138
+ /** Each position's share of the message, as it arrived. */
139
+ private readonly shares = new Map<number, Uint8Array>();
140
+ /** True once the header showed this sequence belongs to the exchange. */
144
141
  public headerVerified = false;
145
142
 
146
143
  /** Throws `conflicting-part` for a position that arrives twice with different content. */
147
144
  public add(read: StructuredAppendRead): AddOutcome {
145
+ let outcome: AddOutcome;
148
146
  try {
149
- return this.inner.add(read);
147
+ outcome = this.inner.add(read);
150
148
  } catch (error) {
151
149
  throw translate(error);
152
150
  }
151
+ if (outcome.result === 'accepted') this.shares.set(read.index, read.bytes);
152
+ return outcome;
153
+ }
154
+
155
+ /**
156
+ * The header, once the codes from the first onwards hold all of it.
157
+ * Undefined while they do not; throws as soon as they cannot be a pairing
158
+ * message, or the header is malformed.
159
+ */
160
+ public header(): PairingMessageHeader | undefined {
161
+ let prefix = '';
162
+ for (let index = 0; this.shares.has(index); index += 1) {
163
+ prefix += latin1(this.shares.get(index) ?? new Uint8Array());
164
+ }
165
+ return prefix === '' ? undefined : parseHeader(prefix);
166
+ }
167
+
168
+ public isEmpty(): boolean {
169
+ return this.inner.received() === 0;
153
170
  }
154
171
 
155
172
  public receivedCount(): number {
@@ -183,6 +200,7 @@ export class PairingAssembler {
183
200
 
184
201
  public clear(): void {
185
202
  this.inner.clear();
203
+ this.shares.clear();
186
204
  this.headerVerified = false;
187
205
  }
188
206
  }