conductor-remote 1.1.0 → 1.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@4.15.0",
6
6
  "description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",
package/scripts/qr.ts ADDED
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Dependency-free QR encoder — just enough to render the phone URL in the terminal.
3
+ *
4
+ * Scope is deliberately narrow: byte mode, error-correction level M, versions 1–10 (≈120 bytes max,
5
+ * and the phone URL is ~70). That keeps the version/EC tables small and hand-verifiable while covering
6
+ * every URL this relay can produce. Kept strip-clean like the rest of the repo (no enums/namespaces/
7
+ * param-property constructors) so it runs under plain `node` type-stripping with zero deps.
8
+ *
9
+ * References the ISO/IEC 18004 QR Code spec; placement/format math mirrors Nayuki's reference encoder.
10
+ */
11
+
12
+ // ── Galois field GF(256), primitive polynomial 0x11d ────────────────────────────────────────────────
13
+ const EXP = new Uint8Array(512)
14
+ const LOG = new Uint8Array(256)
15
+ let gfx = 1
16
+ for (let i = 0; i < 255; i++) {
17
+ EXP[i] = gfx
18
+ LOG[gfx] = i
19
+ gfx <<= 1
20
+ if (gfx & 0x100) gfx ^= 0x11d
21
+ }
22
+ for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]
23
+
24
+ function gfMul(a: number, b: number): number {
25
+ return a === 0 || b === 0 ? 0 : EXP[LOG[a] + LOG[b]]
26
+ }
27
+
28
+ /** Reed–Solomon generator polynomial of the given degree; coeff[0] is the leading (highest) term. */
29
+ function rsGenerator(degree: number): number[] {
30
+ let poly = [1]
31
+ for (let i = 0; i < degree; i++) {
32
+ const next = new Array(poly.length + 1).fill(0)
33
+ for (let j = 0; j < poly.length; j++) {
34
+ next[j] ^= poly[j]
35
+ next[j + 1] ^= gfMul(poly[j], EXP[i])
36
+ }
37
+ poly = next
38
+ }
39
+ return poly
40
+ }
41
+
42
+ /** The `ecLen` error-correction codewords for one data block (polynomial remainder). */
43
+ function rsRemainder(data: number[], ecLen: number): number[] {
44
+ const gen = rsGenerator(ecLen)
45
+ const res = new Array(ecLen).fill(0)
46
+ for (const d of data) {
47
+ const factor = d ^ res[0]
48
+ res.shift()
49
+ res.push(0)
50
+ if (factor !== 0) for (let i = 0; i < ecLen; i++) res[i] ^= gfMul(gen[i + 1], factor)
51
+ }
52
+ return res
53
+ }
54
+
55
+ // ── Version tables (level M only) ───────────────────────────────────────────────────────────────────
56
+ type Group = [number, number] // [block count, data codewords per block]
57
+ type VersionInfo = { align: number[]; ec: number; groups: Group[] }
58
+
59
+ const VERSIONS: Record<number, VersionInfo> = {
60
+ 1: { align: [], ec: 10, groups: [[1, 16]] },
61
+ 2: { align: [6, 18], ec: 16, groups: [[1, 28]] },
62
+ 3: { align: [6, 22], ec: 26, groups: [[1, 44]] },
63
+ 4: { align: [6, 26], ec: 18, groups: [[2, 32]] },
64
+ 5: { align: [6, 30], ec: 24, groups: [[2, 43]] },
65
+ 6: { align: [6, 34], ec: 16, groups: [[4, 27]] },
66
+ 7: { align: [6, 22, 38], ec: 18, groups: [[4, 31]] },
67
+ 8: {
68
+ align: [6, 24, 42],
69
+ ec: 22,
70
+ groups: [
71
+ [2, 38],
72
+ [2, 39]
73
+ ]
74
+ },
75
+ 9: {
76
+ align: [6, 26, 46],
77
+ ec: 22,
78
+ groups: [
79
+ [3, 36],
80
+ [2, 37]
81
+ ]
82
+ },
83
+ 10: {
84
+ align: [6, 28, 50],
85
+ ec: 26,
86
+ groups: [
87
+ [4, 43],
88
+ [1, 44]
89
+ ]
90
+ }
91
+ }
92
+
93
+ function dataCodewords(v: number): number {
94
+ return VERSIONS[v].groups.reduce((sum, [count, per]) => sum + count * per, 0)
95
+ }
96
+
97
+ function countBits(v: number): number {
98
+ return v <= 9 ? 8 : 16
99
+ }
100
+
101
+ // ── Bit helpers ─────────────────────────────────────────────────────────────────────────────────────
102
+ function pushBits(bits: number[], value: number, len: number): void {
103
+ for (let i = len - 1; i >= 0; i--) bits.push((value >> i) & 1)
104
+ }
105
+
106
+ // ── Data encoding: text → interleaved data+EC bitstream for the smallest fitting version ─────────────
107
+ function encodeText(text: string): { version: number; bits: number[] } {
108
+ const bytes = Array.from(new TextEncoder().encode(text))
109
+ let version = 0
110
+ for (let v = 1; v <= 10; v++) {
111
+ if (4 + countBits(v) + 8 * bytes.length <= dataCodewords(v) * 8) {
112
+ version = v
113
+ break
114
+ }
115
+ }
116
+ if (version === 0) throw new Error(`data too long for a v1–10 QR (${bytes.length} bytes)`)
117
+
118
+ const capacityBits = dataCodewords(version) * 8
119
+ const bits: number[] = []
120
+ pushBits(bits, 0b0100, 4) // byte mode
121
+ pushBits(bits, bytes.length, countBits(version))
122
+ for (const b of bytes) pushBits(bits, b, 8)
123
+ for (let i = 0, n = Math.min(4, capacityBits - bits.length); i < n; i++) bits.push(0) // terminator
124
+ while (bits.length % 8 !== 0) bits.push(0)
125
+ const pad = [0xec, 0x11]
126
+ for (let i = 0; bits.length < capacityBits; i++) pushBits(bits, pad[i % 2], 8)
127
+
128
+ const dataCw: number[] = []
129
+ for (let i = 0; i < bits.length; i += 8) {
130
+ let cw = 0
131
+ for (let b = 0; b < 8; b++) cw = (cw << 1) | bits[i + b]
132
+ dataCw.push(cw)
133
+ }
134
+
135
+ const { ec, groups } = VERSIONS[version]
136
+ const blocks: { data: number[]; ec: number[] }[] = []
137
+ let idx = 0
138
+ for (const [count, per] of groups) {
139
+ for (let b = 0; b < count; b++) {
140
+ const data = dataCw.slice(idx, idx + per)
141
+ idx += per
142
+ blocks.push({ data, ec: rsRemainder(data, ec) })
143
+ }
144
+ }
145
+
146
+ const out: number[] = []
147
+ const maxData = Math.max(...blocks.map(bl => bl.data.length))
148
+ for (let i = 0; i < maxData; i++) for (const bl of blocks) if (i < bl.data.length) out.push(bl.data[i])
149
+ for (let i = 0; i < ec; i++) for (const bl of blocks) out.push(bl.ec[i])
150
+
151
+ const dataBits: number[] = []
152
+ for (const cw of out) pushBits(dataBits, cw, 8)
153
+ return { version, bits: dataBits }
154
+ }
155
+
156
+ // ── Matrix construction ─────────────────────────────────────────────────────────────────────────────
157
+ function buildMatrix(version: number, dataBits: number[]): boolean[][] {
158
+ const size = version * 4 + 17
159
+ const m: boolean[][] = Array.from({ length: size }, () => new Array(size).fill(false))
160
+ const reserved: boolean[][] = Array.from({ length: size }, () => new Array(size).fill(false))
161
+
162
+ const finder = (r0: number, c0: number) => {
163
+ for (let r = -1; r <= 7; r++)
164
+ for (let c = -1; c <= 7; c++) {
165
+ const rr = r0 + r
166
+ const cc = c0 + c
167
+ if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue
168
+ const border = (r >= 0 && r <= 6 && (c === 0 || c === 6)) || (c >= 0 && c <= 6 && (r === 0 || r === 6))
169
+ const center = r >= 2 && r <= 4 && c >= 2 && c <= 4
170
+ m[rr][cc] = border || center
171
+ reserved[rr][cc] = true
172
+ }
173
+ }
174
+ finder(0, 0)
175
+ finder(0, size - 7)
176
+ finder(size - 7, 0)
177
+
178
+ // Timing patterns
179
+ for (let i = 8; i < size - 8; i++) {
180
+ const v = i % 2 === 0
181
+ if (!reserved[6][i]) {
182
+ m[6][i] = v
183
+ reserved[6][i] = true
184
+ }
185
+ if (!reserved[i][6]) {
186
+ m[i][6] = v
187
+ reserved[i][6] = true
188
+ }
189
+ }
190
+
191
+ // Alignment patterns (skip the three that collide with finders)
192
+ const pos = VERSIONS[version].align
193
+ for (let i = 0; i < pos.length; i++)
194
+ for (let j = 0; j < pos.length; j++) {
195
+ const corner = (i === 0 && j === 0) || (i === 0 && j === pos.length - 1) || (i === pos.length - 1 && j === 0)
196
+ if (corner) continue
197
+ const cr = pos[i]
198
+ const cc = pos[j]
199
+ for (let r = -2; r <= 2; r++)
200
+ for (let c = -2; c <= 2; c++) {
201
+ m[cr + r][cc + c] = Math.max(Math.abs(r), Math.abs(c)) !== 1
202
+ reserved[cr + r][cc + c] = true
203
+ }
204
+ }
205
+
206
+ // Reserve format-info strips and (v≥7) version-info blocks — filled after masking.
207
+ for (let i = 0; i <= 8; i++) {
208
+ if (i !== 6) {
209
+ reserved[8][i] = true
210
+ reserved[i][8] = true
211
+ }
212
+ }
213
+ for (let i = 0; i < 8; i++) {
214
+ reserved[8][size - 1 - i] = true
215
+ reserved[size - 1 - i][8] = true
216
+ }
217
+ reserved[size - 8][8] = true // dark module
218
+ if (version >= 7)
219
+ for (let i = 0; i < 18; i++) {
220
+ const a = size - 11 + (i % 3)
221
+ const b = Math.floor(i / 3)
222
+ reserved[b][a] = true
223
+ reserved[a][b] = true
224
+ }
225
+
226
+ // Data placement: two-column zigzag from bottom-right, skipping the timing column.
227
+ let bit = 0
228
+ let upward = true
229
+ for (let col = size - 1; col > 0; col -= 2) {
230
+ if (col === 6) col--
231
+ for (let i = 0; i < size; i++) {
232
+ const row = upward ? size - 1 - i : i
233
+ for (let k = 0; k < 2; k++) {
234
+ const c = col - k
235
+ if (reserved[row][c]) continue
236
+ m[row][c] = bit < dataBits.length ? dataBits[bit] === 1 : false
237
+ bit++
238
+ }
239
+ }
240
+ upward = !upward
241
+ }
242
+
243
+ if (version >= 7) drawVersion(m, version, size)
244
+
245
+ // Try every mask, keep the lowest-penalty one.
246
+ let best: boolean[][] | null = null
247
+ let bestScore = Number.POSITIVE_INFINITY
248
+ for (let mask = 0; mask < 8; mask++) {
249
+ const cand = m.map(row => row.slice())
250
+ for (let r = 0; r < size; r++)
251
+ for (let c = 0; c < size; c++) if (!reserved[r][c] && maskBit(mask, r, c)) cand[r][c] = !cand[r][c]
252
+ drawFormat(cand, mask, size)
253
+ const score = penalty(cand)
254
+ if (score < bestScore) {
255
+ bestScore = score
256
+ best = cand
257
+ }
258
+ }
259
+ return best as boolean[][]
260
+ }
261
+
262
+ function maskBit(mask: number, r: number, c: number): boolean {
263
+ switch (mask) {
264
+ case 0:
265
+ return (r + c) % 2 === 0
266
+ case 1:
267
+ return r % 2 === 0
268
+ case 2:
269
+ return c % 3 === 0
270
+ case 3:
271
+ return (r + c) % 3 === 0
272
+ case 4:
273
+ return (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0
274
+ case 5:
275
+ return ((r * c) % 2) + ((r * c) % 3) === 0
276
+ case 6:
277
+ return (((r * c) % 2) + ((r * c) % 3)) % 2 === 0
278
+ default:
279
+ return (((r + c) % 2) + ((r * c) % 3)) % 2 === 0
280
+ }
281
+ }
282
+
283
+ /** 15-bit format info (level M = 00) with BCH(15,5) and the 0x5412 mask. */
284
+ function drawFormat(m: boolean[][], mask: number, size: number): void {
285
+ const data = (0b00 << 3) | mask
286
+ let rem = data
287
+ for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >> 9) & 1 ? 0x537 : 0)
288
+ const bits = (((data << 10) | (rem & 0x3ff)) ^ 0x5412) & 0x7fff
289
+ const get = (i: number) => ((bits >> i) & 1) === 1
290
+ for (let i = 0; i <= 5; i++) m[i][8] = get(i)
291
+ m[7][8] = get(6)
292
+ m[8][8] = get(7)
293
+ m[8][7] = get(8)
294
+ for (let i = 9; i < 15; i++) m[8][14 - i] = get(i)
295
+ for (let i = 0; i < 8; i++) m[8][size - 1 - i] = get(i)
296
+ for (let i = 8; i < 15; i++) m[size - 15 + i][8] = get(i)
297
+ m[size - 8][8] = true // dark module
298
+ }
299
+
300
+ /** 18-bit version info (v≥7), BCH(18,6) with 0x1f25. */
301
+ function drawVersion(m: boolean[][], version: number, size: number): void {
302
+ let rem = version
303
+ for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >> 11) & 1 ? 0x1f25 : 0)
304
+ const bits = (version << 12) | (rem & 0xfff)
305
+ for (let i = 0; i < 18; i++) {
306
+ const b = ((bits >> i) & 1) === 1
307
+ const a = size - 11 + (i % 3)
308
+ const d = Math.floor(i / 3)
309
+ m[d][a] = b
310
+ m[a][d] = b
311
+ }
312
+ }
313
+
314
+ // ── Mask penalty (ISO/IEC 18004 §8.8.2) — only used to pick the mask, so exactness isn't critical ────
315
+ function penalty(m: boolean[][]): number {
316
+ const n = m.length
317
+ let score = 0
318
+ const runScore = (get: (i: number) => boolean) => {
319
+ let color = get(0)
320
+ let len = 1
321
+ for (let i = 1; i < n; i++) {
322
+ if (get(i) === color) len++
323
+ else {
324
+ if (len >= 5) score += len - 2
325
+ color = get(i)
326
+ len = 1
327
+ }
328
+ }
329
+ if (len >= 5) score += len - 2
330
+ }
331
+ for (let r = 0; r < n; r++) runScore(i => m[r][i])
332
+ for (let c = 0; c < n; c++) runScore(i => m[i][c])
333
+
334
+ for (let r = 0; r < n - 1; r++)
335
+ for (let c = 0; c < n - 1; c++) {
336
+ const v = m[r][c]
337
+ if (v === m[r][c + 1] && v === m[r + 1][c] && v === m[r + 1][c + 1]) score += 3
338
+ }
339
+
340
+ const a = [true, false, true, true, true, false, true, false, false, false, false]
341
+ const b = [false, false, false, false, true, false, true, true, true, false, true]
342
+ const match = (get: (i: number) => boolean, start: number, pat: boolean[]) => {
343
+ for (let i = 0; i < 11; i++) if (get(start + i) !== pat[i]) return false
344
+ return true
345
+ }
346
+ for (let r = 0; r < n; r++)
347
+ for (let c = 0; c <= n - 11; c++) {
348
+ if (match(i => m[r][i], c, a) || match(i => m[r][i], c, b)) score += 40
349
+ }
350
+ for (let c = 0; c < n; c++)
351
+ for (let r = 0; r <= n - 11; r++) {
352
+ if (match(i => m[i][c], r, a) || match(i => m[i][c], r, b)) score += 40
353
+ }
354
+
355
+ let dark = 0
356
+ for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) if (m[r][c]) dark++
357
+ score += Math.floor(Math.abs((dark / (n * n)) * 100 - 50) / 5) * 10
358
+ return score
359
+ }
360
+
361
+ // ── Terminal rendering ──────────────────────────────────────────────────────────────────────────────
362
+ /**
363
+ * Render the matrix as half-block lines (two modules per character row, ~square modules) with a forced
364
+ * black-on-white style so it scans correctly regardless of the terminal theme, plus the 4-module quiet
365
+ * zone the spec requires. `indent` is prepended (outside the styled region) to align with other output.
366
+ */
367
+ function render(m: boolean[][], indent: string): string[] {
368
+ const size = m.length
369
+ const quiet = 4
370
+ const dark = (r: number, c: number) => (r >= 0 && r < size && c >= 0 && c < size ? m[r][c] : false)
371
+ const lines: string[] = []
372
+ for (let r = -quiet; r < size + quiet; r += 2) {
373
+ let line = `${indent}\x1b[30;47m`
374
+ for (let c = -quiet; c < size + quiet; c++) {
375
+ const top = dark(r, c)
376
+ const bottom = dark(r + 1, c)
377
+ line += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' '
378
+ }
379
+ lines.push(`${line}\x1b[0m`)
380
+ }
381
+ return lines
382
+ }
383
+
384
+ /** Encode `text` and return the terminal lines that draw its QR code (each already indented). */
385
+ export function qrLines(text: string, indent = ' '): string[] {
386
+ const { version, bits } = encodeText(text)
387
+ return render(buildMatrix(version, bits), indent)
388
+ }
@@ -12,6 +12,7 @@ import { execFileSync } from 'node:child_process'
12
12
  import fs from 'node:fs'
13
13
  import os from 'node:os'
14
14
  import path from 'node:path'
15
+ import { qrLines } from './qr.ts'
15
16
 
16
17
  const LABEL = 'no.adluna.conductor-remote'
17
18
  const projectDir = path.resolve(import.meta.dirname, '..')
@@ -294,14 +295,29 @@ function ensureTailscale(): void {
294
295
  }
295
296
  }
296
297
 
298
+ /** Print a scannable QR of `url` (theme-independent black-on-white). Never fatal — QR is a convenience. */
299
+ function printQr(url: string): void {
300
+ try {
301
+ console.info(`\n${qrLines(url).join('\n')}`)
302
+ } catch (err) {
303
+ console.info(` (QR skipped: ${err instanceof Error ? err.message : err})`)
304
+ }
305
+ }
306
+
297
307
  function printUrl(): void {
298
- const frag = `#token=${currentToken() ?? '<starts on first run>'}`
308
+ const token = currentToken()
309
+ const frag = `#token=${token ?? '<starts on first run>'}`
299
310
  const bin = tailscaleBin()
300
311
  const dns = bin ? magicDnsName(bin) : null
301
312
  const state = bin ? tailscaleState(bin, dns) : { proxyOk: false, funnelOn: false }
302
313
  if (dns && state.proxyOk) {
303
314
  const scope = state.funnelOn ? 'public — any browser, token-gated' : 'same Tailnet only'
304
- console.info(`\n Phone URL (HTTPS, ${scope}):\n https://${dns}/${frag}`)
315
+ const url = `https://${dns}/${frag}`
316
+ console.info(`\n Phone URL (HTTPS, ${scope}):\n ${url}`)
317
+ if (token) {
318
+ console.info('\n Scan to open on your phone:')
319
+ printQr(url)
320
+ }
305
321
  return
306
322
  }
307
323
  // Nothing fronting yet — the relay is only on loopback.