mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-15 06:43:27 -06:00
Give each player install its own identity
The web player derived its fingerprint entirely from hardware traits: user agent, screen geometry, colour depth, timezone, core count, platform and a canvas raster. Every one of those describes a model rather than a unit, so two identical panels produced the same value and the server, which matches on that value globally, treated them as one device. Two UniFi Pro Displays at different sites both produced web-m73u8w-5f; the second could not be brought online, and the row ended up shared, each display evicting the other every thirty seconds. The identity a player presents is now hardware plus a random per-install salt kept in localStorage, so two identical panels differ from their first connection. This is what the Tizen player has always done; the web player is brought in line with it rather than given a new scheme. The hardware value is still sent, but only as a hint, and only to move a caller that has ALREADY authenticated with a device id and token onto its own row — which is how an existing player carries its identity across this change. A caller without credentials never resolves through it, however few rows it appears to match: one row recorded does not mean one display exists, and that distinction is the whole bug. Such a caller is provisioned a new device, which costs one pairing code and cannot be wrong. Older clients are unaffected. They send no hardware value, so they take the exact-match path exactly as before, and both keep working: the APK's fingerprint already includes ANDROID_ID and the Tizen player's is already a stored random id, so neither ever shared an identity between units. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
parent
f09dee810c
commit
2bcc46bc26
|
|
@ -332,6 +332,13 @@ const migrations = [
|
|||
"ALTER TABLE devices ADD COLUMN system_brightness REAL",
|
||||
"ALTER TABLE devices ADD COLUMN window_brightness REAL",
|
||||
"ALTER TABLE devices ADD COLUMN screen_off_timeout_ms INTEGER",
|
||||
// The hardware-derived half of a client's fingerprint, kept separately from the identity it
|
||||
// now presents. Two identical panels produce the same hardware value, so it identifies a
|
||||
// MODEL, not a unit, and can only ever be a hint for reuniting a wiped panel with its row —
|
||||
// never the thing a match is decided on. Nullable: clients that predate this send no such
|
||||
// field, and the lookup falls back to exact-match-only for them.
|
||||
"ALTER TABLE device_fingerprints ADD COLUMN hw_fingerprint TEXT",
|
||||
"CREATE INDEX IF NOT EXISTS idx_device_fingerprints_hw ON device_fingerprints(hw_fingerprint)",
|
||||
// Offline alerting is once per OUTAGE, not once per dedup window. This stores the
|
||||
// last_heartbeat value an offline alert was already sent for. Because a device that
|
||||
// reconnects advances last_heartbeat, the marker self-invalidates on recovery — a new
|
||||
|
|
|
|||
|
|
@ -643,7 +643,14 @@
|
|||
});
|
||||
|
||||
// ==================== Browser Fingerprint ====================
|
||||
function generateBrowserFingerprint() {
|
||||
// Hardware-only identity. Every input below is a property of the MODEL, not the unit: two
|
||||
// identical panels report the same user agent, the same screen geometry, the same core count
|
||||
// and the same canvas raster. So this value is shared by every display of that model
|
||||
// ANYWHERE — it was never an identity, and treating it as one meant a second identical panel
|
||||
// collided with the first (observed live: two UniFi Pro Displays at DIFFERENT sites both
|
||||
// producing web-m73u8w-5f). It is kept because it is still a useful HINT for reuniting a
|
||||
// wiped panel with its own row, but it is no longer proof of which unit is calling.
|
||||
function generateHardwareFingerprint() {
|
||||
const components = [
|
||||
navigator.userAgent,
|
||||
navigator.language,
|
||||
|
|
@ -675,6 +682,31 @@
|
|||
return 'web-' + Math.abs(hash).toString(36) + '-' + str.length.toString(36);
|
||||
}
|
||||
|
||||
// The identity the server matches on: hardware PLUS a random per-INSTALL salt, so two
|
||||
// identical panels are distinguishable from the first connection — which hardware alone can
|
||||
// never be. The salt is minted once and kept in localStorage. Clearing storage mints a new
|
||||
// one, which is correct: that IS a new install, and the hw_fingerprint sent alongside is what
|
||||
// lets the server offer the old row back when it can do so unambiguously.
|
||||
//
|
||||
// Falls back to the bare hardware value when storage is unavailable (private mode, a locked
|
||||
// down kiosk). That reintroduces the collision for those clients alone rather than leaving
|
||||
// them with no identity at all, and the server treats an ambiguous hardware value as an
|
||||
// unknown device and provisions a fresh one — the safe outcome either way.
|
||||
function generateBrowserFingerprint() {
|
||||
const hw = generateHardwareFingerprint();
|
||||
let salt = null;
|
||||
try {
|
||||
salt = localStorage.getItem('st_install_id');
|
||||
if (!salt) {
|
||||
const buf = new Uint8Array(16);
|
||||
(window.crypto || window.msCrypto).getRandomValues(buf);
|
||||
salt = Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
localStorage.setItem('st_install_id', salt);
|
||||
}
|
||||
} catch (e) { salt = null; }
|
||||
return salt ? hw + '-' + salt.slice(0, 16) : hw;
|
||||
}
|
||||
|
||||
// ==================== Boot ====================
|
||||
|
||||
// Function used by connect button and auto-connect
|
||||
|
|
@ -1383,8 +1415,11 @@
|
|||
data.client_version = PLAYER_VERSION;
|
||||
data.platform = browserPlatform();
|
||||
data.contract_version = 'v4';
|
||||
// Browser fingerprint (survives localStorage clear)
|
||||
// Device identity. `fingerprint` is per-INSTALL and is what the server matches on.
|
||||
// `hw_fingerprint` is the old hardware-only value, sent alongside so a panel whose storage
|
||||
// was wiped can still be rematched to its row — but only when that value is unambiguous.
|
||||
data.fingerprint = generateBrowserFingerprint();
|
||||
data.hw_fingerprint = generateHardwareFingerprint();
|
||||
console.log(`[register] device_id=${data.device_id || 'none'}, has_token=${!!data.device_token}, token_len=${data.device_token?.length || 0}, paired=${config.paired}, pairing_code=${data.pairing_code || 'none'}`);
|
||||
socket.emit('device:register', data);
|
||||
}
|
||||
|
|
|
|||
206
server/test/fingerprint-identity-collision.test.js
Normal file
206
server/test/fingerprint-identity-collision.test.js
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
'use strict';
|
||||
|
||||
// A display's fingerprint used to be derived ONLY from hardware traits — user agent, screen
|
||||
// geometry, colour depth, timezone, core count, platform, canvas raster. Every one of those
|
||||
// describes a MODEL, not a unit. Two identical panels therefore produced the same value, and the
|
||||
// server treated that value as an identity.
|
||||
//
|
||||
// Observed live: two UniFi Pro Displays at DIFFERENT sites both produced web-m73u8w-5f. The
|
||||
// second one could not be onboarded at all — the server saw a known identity with a live socket
|
||||
// and refused it, ten times in a row. That is the benign half. The other half is that the guard
|
||||
// which refused it is a liveness check: had the first display been offline, the second would have
|
||||
// been handed that row's identity, a freshly minted token, and its playlist and content. The
|
||||
// fingerprint lookup is global — not scoped to a workspace or an owner — so "identical hardware"
|
||||
// is the only precondition.
|
||||
//
|
||||
// The rules pinned here:
|
||||
// 1. Two installs on identical hardware get DIFFERENT identities.
|
||||
// 2. The hardware value is still sent, but only ever MIGRATES a caller that has already proved
|
||||
// itself by token onto its OWN row. A caller without credentials never resolves through it,
|
||||
// however few rows it appears to match — "exactly one row" means one row was recorded, not
|
||||
// that one display exists, and that distinction is the whole bug. Such a caller is
|
||||
// provisioned a new device: one pairing code, and it cannot be wrong.
|
||||
// 3. Clients that send no hardware value (older players, and the APK/.wgt whose fingerprint is
|
||||
// a genuinely per-unit hardware id) behave exactly as they did before.
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const fs = require('node:fs');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const DATA_DIR = path.join(os.tmpdir(), 'st-fpid-' + crypto.randomBytes(4).toString('hex'));
|
||||
fs.mkdirSync(path.join(DATA_DIR, 'db'), { recursive: true });
|
||||
process.env.DATA_DIR = DATA_DIR;
|
||||
process.env.SELF_HOSTED = 'true';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { db } = require('../db/database');
|
||||
const HTML = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8');
|
||||
|
||||
after(() => { try { fs.rmSync(DATA_DIR, { recursive: true, force: true }); } catch { /* */ } });
|
||||
|
||||
// ---------------------------------------------------------------- client: distinct identities
|
||||
|
||||
// Run the real client function against a fake localStorage, one per simulated panel.
|
||||
function makePanel(hw, store = {}) {
|
||||
const start = HTML.indexOf('function generateBrowserFingerprint()');
|
||||
assert.notEqual(start, -1);
|
||||
let depth = 0, end = -1;
|
||||
for (let j = HTML.indexOf('{', start); j < HTML.length; j++) {
|
||||
if (HTML[j] === '{') depth++;
|
||||
else if (HTML[j] === '}' && --depth === 0) { end = j + 1; break; }
|
||||
}
|
||||
const src = HTML.slice(start, end);
|
||||
const scope = {
|
||||
generateHardwareFingerprint: () => hw,
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
},
|
||||
window: { crypto: { getRandomValues: (b) => crypto.randomFillSync(b) } },
|
||||
};
|
||||
const fn = new Function(...Object.keys(scope), `${src} return generateBrowserFingerprint;`)(...Object.values(scope));
|
||||
return { fp: fn, store };
|
||||
}
|
||||
|
||||
test('THE BUG: two identical panels no longer share an identity', () => {
|
||||
const HW = 'web-m73u8w-5f'; // the value both UniFi displays actually produced
|
||||
const a = makePanel(HW), b = makePanel(HW);
|
||||
assert.notEqual(a.fp(), b.fp(), 'identical hardware, different identities');
|
||||
assert.ok(a.fp().startsWith(HW), 'the hardware value is still recognisable inside it');
|
||||
});
|
||||
|
||||
test('an identity is stable across reloads of the same install', () => {
|
||||
const p = makePanel('web-m73u8w-5f');
|
||||
assert.equal(p.fp(), p.fp(), 'same call twice');
|
||||
const again = makePanel('web-m73u8w-5f', p.store); // same storage = same install
|
||||
assert.equal(again.fp(), p.fp(), 'a reload keeps the identity');
|
||||
});
|
||||
|
||||
test('clearing storage mints a new identity — that IS a new install', () => {
|
||||
const p = makePanel('web-m73u8w-5f');
|
||||
const before = p.fp();
|
||||
delete p.store.st_install_id;
|
||||
assert.notEqual(p.fp(), before);
|
||||
});
|
||||
|
||||
test('storage being unavailable degrades to hardware rather than to nothing', () => {
|
||||
const start = HTML.indexOf('function generateBrowserFingerprint()');
|
||||
let depth = 0, end = -1;
|
||||
for (let j = HTML.indexOf('{', start); j < HTML.length; j++) {
|
||||
if (HTML[j] === '{') depth++;
|
||||
else if (HTML[j] === '}' && --depth === 0) { end = j + 1; break; }
|
||||
}
|
||||
const scope = {
|
||||
generateHardwareFingerprint: () => 'web-hw',
|
||||
localStorage: { getItem() { throw new Error('denied'); }, setItem() { throw new Error('denied'); } },
|
||||
window: { crypto: { getRandomValues: (b) => crypto.randomFillSync(b) } },
|
||||
};
|
||||
const fn = new Function(...Object.keys(scope), `${HTML.slice(start, end)} return generateBrowserFingerprint;`)(...Object.values(scope));
|
||||
assert.equal(fn(), 'web-hw', 'still identifies itself; the server treats ambiguity as unknown');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- server: the ambiguity rule
|
||||
|
||||
const mkDevice = (name) => {
|
||||
const id = crypto.randomUUID();
|
||||
db.prepare(`INSERT INTO devices (id,name,status,created_at) VALUES (?,?,'offline',strftime('%s','now'))`).run(id, name);
|
||||
return id;
|
||||
};
|
||||
const addFp = (fp, deviceId, hw) =>
|
||||
db.prepare('INSERT INTO device_fingerprints (fingerprint, device_id, hw_fingerprint) VALUES (?,?,?)').run(fp, deviceId, hw);
|
||||
|
||||
// The server's resolution rule, exercised directly. `provenDeviceId` is non-null only when the
|
||||
// caller authenticated with a valid device_id + token.
|
||||
function resolve(fingerprint, hw, provenDeviceId = null) {
|
||||
let existing = db.prepare('SELECT * FROM device_fingerprints WHERE fingerprint = ?').get(fingerprint);
|
||||
if (!existing && hw && provenDeviceId) {
|
||||
const c = db.prepare(
|
||||
'SELECT * FROM device_fingerprints WHERE (hw_fingerprint = ? OR fingerprint = ?) AND device_id = ?')
|
||||
.all(hw, hw, provenDeviceId);
|
||||
if (c.length === 1) existing = c[0];
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
test('the hardware column exists — the migration is wired up', () => {
|
||||
const cols = db.prepare('PRAGMA table_info(device_fingerprints)').all().map(c => c.name);
|
||||
assert.ok(cols.includes('hw_fingerprint'));
|
||||
});
|
||||
|
||||
test('the SHIPPED handler enforces this, not just resolve() above', () => {
|
||||
// resolve() mirrors the server's rule; without this the whole server half of the file would
|
||||
// pass against an unfixed deviceSocket.js.
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'ws', 'deviceSocket.js'), 'utf8');
|
||||
const i = src.indexOf('hw_fingerprint && tokenProven');
|
||||
assert.notEqual(i, -1, 'the hint is gated on the caller having proved identity');
|
||||
const block = src.slice(i, i + 700);
|
||||
assert.match(block, /AND device_id = \?/, 'and the lookup is bound to that proven device_id');
|
||||
assert.match(src, /const tokenProven = !!\(device_id && validateDeviceToken\(/,
|
||||
'proof is a real token check, not merely a device_id being present');
|
||||
});
|
||||
|
||||
test('THE SECURITY RULE: a caller with no credentials never resolves via hardware', () => {
|
||||
const HW = 'web-shared-model';
|
||||
const victim = mkDevice('Customer A screen');
|
||||
addFp('web-shared-model-aaaa', victim, HW);
|
||||
|
||||
// A different identical panel arrives with an identity nobody has seen and no credentials.
|
||||
// Exactly ONE row carries this hardware value — and acting on that would be the takeover.
|
||||
// One row recorded does not mean one display exists.
|
||||
assert.equal(resolve('web-shared-model-cccc', HW, null), undefined,
|
||||
'an unauthenticated caller is provisioned fresh rather than handed the row');
|
||||
});
|
||||
|
||||
test('and still not, even when several rows share the hardware', () => {
|
||||
const HW = 'web-shared-model-2';
|
||||
addFp('web-shared-model-2-aaaa', mkDevice('A'), HW);
|
||||
addFp('web-shared-model-2-bbbb', mkDevice('B'), HW);
|
||||
assert.equal(resolve('web-shared-model-2-cccc', HW, null), undefined);
|
||||
});
|
||||
|
||||
test('an AUTHENTICATED player migrates its own row to the new identity', () => {
|
||||
// The backwards-compatible path: an existing player keeps device_id + token across an update
|
||||
// and only needs its stored fingerprint moved to the salted form.
|
||||
const HW = 'web-unique-model';
|
||||
const dev = mkDevice('Existing panel');
|
||||
addFp('web-unique-model-old', dev, HW);
|
||||
const got = resolve('web-unique-model-new', HW, dev);
|
||||
assert.ok(got, 'identity was already proven by token; the hint only locates the row');
|
||||
assert.equal(got.device_id, dev, 'and only ever its OWN row');
|
||||
});
|
||||
|
||||
test('an authenticated player cannot migrate someone ELSE\'s row', () => {
|
||||
const HW = 'web-shared-model-3';
|
||||
const theirs = mkDevice('Someone else');
|
||||
addFp('web-shared-model-3-theirs', theirs, HW);
|
||||
const mine = mkDevice('Me');
|
||||
assert.equal(resolve('web-shared-model-3-mine', HW, mine), undefined,
|
||||
'the lookup is bound to the authenticated device_id');
|
||||
});
|
||||
|
||||
test('an exact identity match never consults the hint', () => {
|
||||
const dev = mkDevice('Known');
|
||||
addFp('web-exact-1234', dev, 'web-exact');
|
||||
const got = resolve('web-exact-1234', 'web-totally-different', dev);
|
||||
assert.equal(got.device_id, dev, 'the identity wins; the hint is only a fallback');
|
||||
});
|
||||
|
||||
test('a legacy row whose stored value IS the bare hardware value still migrates', () => {
|
||||
// Pre-upgrade rows have fingerprint = the hardware value and hw_fingerprint = NULL.
|
||||
const HW = 'web-legacy-9z';
|
||||
const dev = mkDevice('Legacy panel');
|
||||
db.prepare('INSERT INTO device_fingerprints (fingerprint, device_id) VALUES (?,?)').run(HW, dev);
|
||||
const got = resolve('web-legacy-9z-newsalt', HW, dev);
|
||||
assert.ok(got, 'the old bare value is found by the hint');
|
||||
assert.equal(got.device_id, dev, 'so the existing fleet is not orphaned by this change');
|
||||
});
|
||||
|
||||
test('no hint at all means exact-match-only, exactly as before', () => {
|
||||
const dev = mkDevice('Old client');
|
||||
addFp('apk-hardware-id-42', dev, null);
|
||||
assert.ok(resolve('apk-hardware-id-42', undefined, dev), 'clients that send no hint are unaffected');
|
||||
assert.equal(resolve('apk-unknown-id', undefined, dev), undefined);
|
||||
});
|
||||
|
|
@ -402,7 +402,7 @@ module.exports = function setupDeviceSocket(io) {
|
|||
|
||||
// Device registers with a pairing code (first time) or device_id + device_token (reconnect)
|
||||
socket.on('device:register', (data) => {
|
||||
const { pairing_code, device_id, device_token, device_info, fingerprint } = data;
|
||||
const { pairing_code, device_id, device_token, device_info, fingerprint, hw_fingerprint } = data;
|
||||
|
||||
// #146: resolve identity ONCE via the SNAT-safe chain (device_id -> fingerprint
|
||||
// -> token -> global anon), used by BOTH the operator block and the flap limiter.
|
||||
|
|
@ -460,7 +460,39 @@ module.exports = function setupDeviceSocket(io) {
|
|||
// Track device fingerprint to prevent reinstall abuse
|
||||
if (fingerprint) {
|
||||
try {
|
||||
const existing = db.prepare('SELECT * FROM device_fingerprints WHERE fingerprint = ?').get(fingerprint);
|
||||
let existing = db.prepare('SELECT * FROM device_fingerprints WHERE fingerprint = ?').get(fingerprint);
|
||||
|
||||
// MIGRATION ONLY, and only for a caller that has ALREADY proved who it is.
|
||||
//
|
||||
// An existing player keeps its device_id and token across an update, so it authenticates
|
||||
// by token and merely needs its stored fingerprint moved to the new salted form. That is
|
||||
// safe: identity was established before we got here, and the hint is only used to find
|
||||
// the row that identity already belongs to.
|
||||
//
|
||||
// A caller WITHOUT credentials must never resolve through the hardware hint, however
|
||||
// few rows it appears to match. The value describes a MODEL, not a unit — every
|
||||
// identical panel emits the same one — so "exactly one row" means one row was recorded,
|
||||
// not that one display exists. Two UniFi Pro Displays at DIFFERENT sites both produced
|
||||
// web-m73u8w-5f; whichever connected second would have been handed the other's row,
|
||||
// token and content. Such a caller falls through and is provisioned a new device, which
|
||||
// costs the operator one pairing code and is the only answer that cannot be wrong.
|
||||
//
|
||||
// Clients that send no hw_fingerprint (older players, and the APK/.wgt whose fingerprint
|
||||
// is a genuinely per-unit hardware id) never enter this branch at all and behave exactly
|
||||
// as before.
|
||||
const tokenProven = !!(device_id && validateDeviceToken(device_id, device_token));
|
||||
if (!existing && hw_fingerprint && tokenProven) {
|
||||
const candidates = db.prepare(
|
||||
'SELECT * FROM device_fingerprints WHERE (hw_fingerprint = ? OR fingerprint = ?) AND device_id = ?')
|
||||
.all(hw_fingerprint, hw_fingerprint, device_id);
|
||||
if (candidates.length === 1) {
|
||||
const prior = candidates[0].fingerprint;
|
||||
db.prepare('UPDATE device_fingerprints SET fingerprint = ?, hw_fingerprint = ? WHERE fingerprint = ?')
|
||||
.run(fingerprint, hw_fingerprint, prior);
|
||||
existing = { ...candidates[0], fingerprint, hw_fingerprint };
|
||||
console.log(`[fingerprint] migrated ${prior} -> per-install identity for authenticated device ${device_id}`);
|
||||
}
|
||||
}
|
||||
if (existing) {
|
||||
// device_id arrives from the client and can name a row that no longer exists (a
|
||||
// reconnect after the device was deleted — the same case that emits device:unpaired).
|
||||
|
|
@ -473,8 +505,8 @@ module.exports = function setupDeviceSocket(io) {
|
|||
const known = (id) => !!(id && db.prepare('SELECT 1 FROM devices WHERE id = ?').get(id));
|
||||
const fpDeviceId = known(device_id) ? device_id
|
||||
: (known(existing.device_id) ? existing.device_id : null);
|
||||
db.prepare("UPDATE device_fingerprints SET last_seen = strftime('%s','now'), device_id = ? WHERE fingerprint = ?")
|
||||
.run(fpDeviceId, fingerprint);
|
||||
db.prepare("UPDATE device_fingerprints SET last_seen = strftime('%s','now'), device_id = ?, hw_fingerprint = COALESCE(?, hw_fingerprint) WHERE fingerprint = ?")
|
||||
.run(fpDeviceId, hw_fingerprint || null, fingerprint);
|
||||
// If this fingerprint was previously registered to a different device, block the new registration
|
||||
if (!device_id && existing.device_id && pairing_code) {
|
||||
// Someone reinstalled - link them back to existing device
|
||||
|
|
@ -646,8 +678,8 @@ module.exports = function setupDeviceSocket(io) {
|
|||
// INSERT OR IGNORE does NOT suppress FK violations - so null out an
|
||||
// unknown id instead of letting it throw (was a caught, noisy error).
|
||||
const fpDeviceId = (device_id && db.prepare('SELECT 1 FROM devices WHERE id = ?').get(device_id)) ? device_id : null;
|
||||
db.prepare("INSERT OR IGNORE INTO device_fingerprints (fingerprint, device_id) VALUES (?, ?)")
|
||||
.run(fingerprint, fpDeviceId);
|
||||
db.prepare("INSERT OR IGNORE INTO device_fingerprints (fingerprint, device_id, hw_fingerprint) VALUES (?, ?, ?)")
|
||||
.run(fingerprint, fpDeviceId, hw_fingerprint || null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fingerprint tracking error:', e.message);
|
||||
|
|
@ -879,8 +911,8 @@ module.exports = function setupDeviceSocket(io) {
|
|||
// BEFORE the dashboard:device-added emit below so that emit carries restored values.
|
||||
if (fingerprint) {
|
||||
try {
|
||||
db.prepare("INSERT INTO device_fingerprints (fingerprint, device_id, last_seen) VALUES (?, ?, strftime('%s','now')) ON CONFLICT(fingerprint) DO UPDATE SET device_id = excluded.device_id, last_seen = excluded.last_seen")
|
||||
.run(fingerprint, id);
|
||||
db.prepare("INSERT INTO device_fingerprints (fingerprint, device_id, last_seen, hw_fingerprint) VALUES (?, ?, strftime('%s','now'), ?) ON CONFLICT(fingerprint) DO UPDATE SET device_id = excluded.device_id, last_seen = excluded.last_seen, hw_fingerprint = COALESCE(excluded.hw_fingerprint, device_fingerprints.hw_fingerprint)")
|
||||
.run(fingerprint, id, hw_fingerprint || null);
|
||||
const restored = deviceSettings.applyToDevice(id, fingerprint);
|
||||
if (restored) console.log(`[#150] restored saved settings for re-paired device ${id} (fp ${fingerprint.slice(0, 8)}…)`);
|
||||
} catch (e) { console.warn(`[#150] settings restore failed for ${id}: ${e.message}`); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue