code refactor & sync bugfixes

This commit is contained in:
olcxja 2026-10-05 13:43:49 +02:00
commit 9edcc9841b
20 changed files with 820 additions and 812 deletions

133
src/lansync-identity.js Normal file
View file

@ -0,0 +1,133 @@
'use strict'
// Device identity for the LAN sync: the id this install is known by, the code
// other devices must type to pair with it, and the list of paired devices.
// Exported as a factory over the config file, so every launcher state (and
// every test device) gets its own identity.
const crypto = require('crypto')
const { readJson, writeJson } = require('./paths')
// The sync code is read out on one machine and typed on the other, so the
// alphabet skips the characters people mix up (0/O, 1/I/L).
const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
function randomChars(alphabet, count) {
let out = ''
while (out.length < count) {
for (const byte of crypto.randomBytes(count)) {
if (out.length >= count) break
out += alphabet[byte % alphabet.length]
}
}
return out
}
// 24 characters: the current timestamp in base36 (unique per millisecond,
// 8 chars until 2089) plus random padding, so two machines generating an id
// within the same millisecond still get different ids.
function makeDeviceId() {
const stamp = Date.now().toString(36)
return (stamp + randomChars(ID_ALPHABET, Math.max(0, 24 - stamp.length))).slice(0, 24)
}
function makeSyncCode() {
return randomChars(CODE_ALPHABET, 8)
}
function createIdentity(configFile) {
// The identity file is read once and then kept in memory: the beacon calls
// identity() every couple of seconds (and every pairing call in between),
// and a synchronous read per call is pure overhead. Nothing else writes
// this file (the ipc handlers all go through these functions), so the cache
// cannot go stale.
let cached = null
function readConfig() {
if (cached) return cached
const cfg = readJson(configFile, {})
cached = cfg && typeof cfg === 'object' ? cfg : {}
return cached
}
// The id and the code are generated on FIRST use and then kept forever:
// other devices remember the id (they pair with it) and the code (so it
// only has to be typed once).
function identity() {
const cfg = readConfig()
let changed = false
if (typeof cfg.deviceId !== 'string' || cfg.deviceId.length !== 24) {
cfg.deviceId = makeDeviceId()
changed = true
}
if (typeof cfg.syncCode !== 'string' || cfg.syncCode.length !== 8) {
cfg.syncCode = makeSyncCode()
changed = true
}
if (!cfg.known || typeof cfg.known !== 'object') {
cfg.known = {}
changed = true
}
if (changed) writeJson(configFile, cfg)
return cfg
}
// Both rotations return the whole identity ({ deviceId, syncCode, known }),
// so callers get a consistent view of this device after the change.
function rotateDeviceId() {
const cfg = identity()
const previous = cfg.deviceId
cfg.deviceId = makeDeviceId()
writeJson(configFile, cfg)
if (cfg.deviceId === previous) return rotateDeviceId()
return cfg
}
function rotateSyncCode() {
const cfg = identity()
const previous = cfg.syncCode
cfg.syncCode = makeSyncCode()
writeJson(configFile, cfg)
if (cfg.syncCode === previous) return rotateSyncCode() // never a no-op rotation
return cfg
}
// A device the user synced with successfully: its code is stored next to
// its id, so the next sync needs no typing. A rejected code is forgotten
// again, which is what drives the "type the code again" flow.
function knownDevice(id) {
return identity().known[id] || null
}
function knownPeers() {
return identity().known || {}
}
function rememberDevice(id, code, name) {
const cfg = identity()
const stored = String(code || '').trim().toUpperCase()
cfg.known = { ...cfg.known, [id]: { code: stored, syncCode: stored, name: name || '', at: Date.now() } }
writeJson(configFile, cfg)
}
function forgetDevice(id) {
const cfg = identity()
const known = { ...cfg.known }
delete known[id]
cfg.known = known
writeJson(configFile, cfg)
}
return { identity, rotateDeviceId, rotateSyncCode, knownDevice, knownPeers, rememberDevice, forgetDevice, makeDeviceId, makeSyncCode }
}
// Compare two typed codes without leaking timing: the codes are short, but a
// device may only verify once per two seconds anyway.
function sameCode(a, b) {
const x = Buffer.from(String(a == null ? '' : a).trim().toUpperCase())
const y = Buffer.from(String(b == null ? '' : b).trim().toUpperCase())
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y)
}
module.exports = { createIdentity, sameCode, makeDeviceId, makeSyncCode }