mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Fix display duplication on WebSocket reconnect
Server-side: when a device reconnects on a fresh socket while the old TCP zombie is still around, the old socket's eventual disconnect handler flipped the device offline and removed the new heartbeat entry. Now we proactively evict any prior socket on register and ignore disconnects from sockets that are no longer the registered one for that device_id. Frontend: dedupe devices by id from the API response and only render each device in the first group it belongs to (multi-group membership is still tracked for the Manage modal). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
05f70b7910
commit
ee6888e737
|
|
@ -246,7 +246,6 @@ export function render(container) {
|
||||||
};
|
};
|
||||||
|
|
||||||
screenshotHandler = (data) => {
|
screenshotHandler = (data) => {
|
||||||
// Update all instances of this device's preview (may appear in multiple groups)
|
|
||||||
document.querySelectorAll(`#preview-${data.device_id}`).forEach(preview => {
|
document.querySelectorAll(`#preview-${data.device_id}`).forEach(preview => {
|
||||||
const imgSrc = data.image_data || (data.url + '&token=' + localStorage.getItem('token'));
|
const imgSrc = data.image_data || (data.url + '&token=' + localStorage.getItem('token'));
|
||||||
const img = preview.querySelector('img');
|
const img = preview.querySelector('img');
|
||||||
|
|
@ -286,7 +285,13 @@ async function loadDashboard() {
|
||||||
if (!main) return;
|
if (!main) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [devices, groups, playlists] = await Promise.all([api.getDevices(), api.getGroups(), api.getPlaylists()]);
|
const [rawDevices, groups, playlists] = await Promise.all([api.getDevices(), api.getGroups(), api.getPlaylists()]);
|
||||||
|
|
||||||
|
// Deduplicate devices by id — a stale reconnect race can briefly cause the same
|
||||||
|
// device to appear twice in the list. Last-write-wins keeps the freshest state.
|
||||||
|
const seen = new Map();
|
||||||
|
for (const d of rawDevices) seen.set(d.id, d);
|
||||||
|
const devices = Array.from(seen.values());
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
const online = devices.filter(d => d.status === 'online').length;
|
const online = devices.filter(d => d.status === 'online').length;
|
||||||
|
|
@ -339,10 +344,17 @@ async function loadDashboard() {
|
||||||
return { ...g, devices: fullDevices, memberIds };
|
return { ...g, devices: fullDevices, memberIds };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Find ungrouped devices
|
// Render each device exactly once: the first group it belongs to wins.
|
||||||
const allGroupedIds = new Set();
|
// memberIds is preserved for the Manage modal so multi-group membership info stays accurate.
|
||||||
groupsWithDevices.forEach(g => g.memberIds.forEach(id => allGroupedIds.add(id)));
|
const renderedIds = new Set();
|
||||||
const ungrouped = devices.filter(d => !allGroupedIds.has(d.id));
|
for (const g of groupsWithDevices) {
|
||||||
|
g.devices = g.devices.filter(d => {
|
||||||
|
if (renderedIds.has(d.id)) return false;
|
||||||
|
renderedIds.add(d.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const ungrouped = devices.filter(d => !renderedIds.has(d.id));
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,19 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
const deviceNs = io.of('/device');
|
const deviceNs = io.of('/device');
|
||||||
const dashboardNs = io.of('/dashboard');
|
const dashboardNs = io.of('/dashboard');
|
||||||
|
|
||||||
|
// Disconnect any existing socket that is currently registered for this device_id.
|
||||||
|
// Called when a fresh registration comes in for the same device so the old (likely
|
||||||
|
// half-dead) socket can't fire its disconnect handler and clobber the new entry.
|
||||||
|
function evictPriorSocket(deviceId, exceptSocketId) {
|
||||||
|
const prior = heartbeat.getConnection(deviceId);
|
||||||
|
if (!prior || prior.socketId === exceptSocketId) return;
|
||||||
|
const oldSocket = deviceNs.sockets.get(prior.socketId);
|
||||||
|
if (oldSocket) {
|
||||||
|
console.log(`Evicting prior socket ${prior.socketId} for device ${deviceId}`);
|
||||||
|
try { oldSocket.disconnect(true); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deviceNs.on('connection', (socket) => {
|
deviceNs.on('connection', (socket) => {
|
||||||
console.log(`Device socket connected: ${socket.id}`);
|
console.log(`Device socket connected: ${socket.id}`);
|
||||||
let currentDeviceId = null;
|
let currentDeviceId = null;
|
||||||
|
|
@ -145,6 +158,7 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
db.prepare('UPDATE devices SET device_token = ? WHERE id = ?').run(newToken, existing.device_id);
|
db.prepare('UPDATE devices SET device_token = ? WHERE id = ?').run(newToken, existing.device_id);
|
||||||
console.log(`Fingerprint match: linking reinstalled app to existing device ${existing.device_id} (new token issued)`);
|
console.log(`Fingerprint match: linking reinstalled app to existing device ${existing.device_id} (new token issued)`);
|
||||||
authenticated = true;
|
authenticated = true;
|
||||||
|
evictPriorSocket(existing.device_id, socket.id);
|
||||||
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
||||||
.run(getClientIp(socket), existing.device_id);
|
.run(getClientIp(socket), existing.device_id);
|
||||||
socket.emit('device:registered', { device_id: existing.device_id, device_token: newToken, status: 'online' });
|
socket.emit('device:registered', { device_id: existing.device_id, device_token: newToken, status: 'online' });
|
||||||
|
|
@ -189,6 +203,7 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
|
|
||||||
currentDeviceId = device_id;
|
currentDeviceId = device_id;
|
||||||
authenticated = true;
|
authenticated = true;
|
||||||
|
evictPriorSocket(device_id, socket.id);
|
||||||
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
||||||
.run(getClientIp(socket), device_id);
|
.run(getClientIp(socket), device_id);
|
||||||
|
|
||||||
|
|
@ -384,6 +399,15 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
if (currentDeviceId) {
|
if (currentDeviceId) {
|
||||||
|
// If a newer socket has already taken over this device_id, this is a stale
|
||||||
|
// disconnect from a replaced socket — skip the offline transition so we don't
|
||||||
|
// flip an actively-connected device offline or clobber the new heartbeat entry.
|
||||||
|
const activeConn = heartbeat.getConnection(currentDeviceId);
|
||||||
|
if (activeConn && activeConn.socketId !== socket.id) {
|
||||||
|
console.log(`Stale disconnect for ${currentDeviceId} (socket ${socket.id}); active is ${activeConn.socketId}, skipping offline`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Device disconnected: ${currentDeviceId}`);
|
console.log(`Device disconnected: ${currentDeviceId}`);
|
||||||
db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now') WHERE id = ?")
|
db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now') WHERE id = ?")
|
||||||
.run(currentDeviceId);
|
.run(currentDeviceId);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue