mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-28 19:47:25 +00:00
fix: harden room teardown and popup containment
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v3.1.4-blue?logo=github" alt="GitHub release"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v3.1.5-blue?logo=github" alt="GitHub release"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue" alt="License"></a>
|
||||
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white" alt="Firefox Add-on"></a>
|
||||
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
|
||||
|
||||
+50
-69
@@ -1,4 +1,4 @@
|
||||
import { EVENTS, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
|
||||
import { EVENTS, ERROR_CODES, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
|
||||
import { generateUsername } from './shared/names.js';
|
||||
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
|
||||
@@ -988,15 +988,19 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
|
||||
return true;
|
||||
}
|
||||
|
||||
async function leaveRoomAfterIdleGrace(reason) {
|
||||
if (!currentRoom) return;
|
||||
async function endRoomSession({ notifyServer = false, reason = 'Left Room' } = {}) {
|
||||
webJoinCoordinator.invalidate();
|
||||
connectIntent = false;
|
||||
reconnectFailed = false;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
||||
reconnectStartTime = null;
|
||||
completeForceSyncBeforeTargetChange(null);
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
forceDisconnect();
|
||||
if (notifyServer) emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
|
||||
// Stop room-specific polling before the content script itself is removed.
|
||||
// Every terminal room exit must pass through the exact target identity while
|
||||
// it is still available, regardless of who initiated the exit.
|
||||
clearEpisodeLobbyState();
|
||||
currentRoom = null;
|
||||
clearChatActivity();
|
||||
controlMode = CONTROL_MODES.EVERYONE;
|
||||
@@ -1007,15 +1011,22 @@ async function leaveRoomAfterIdleGrace(reason) {
|
||||
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset
|
||||
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
|
||||
broadcastControlMode();
|
||||
if (currentTabId) await deactivateTargetTab(currentTabId);
|
||||
if (currentTabId) await deactivateTargetTab(currentTabId, currentContentTarget());
|
||||
invalidateTargetActivations();
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
clearCurrentContentTarget();
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
clearEpisodeLobbyState();
|
||||
await clearPendingTarget();
|
||||
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
expectedAcksCount = 0;
|
||||
if (forceSyncTimeout) {
|
||||
clearTimeout(forceSyncTimeout);
|
||||
forceSyncTimeout = null;
|
||||
}
|
||||
await chrome.storage.session.set({
|
||||
currentRoom: null,
|
||||
chatActivityTimeline: [],
|
||||
@@ -1026,17 +1037,30 @@ async function leaveRoomAfterIdleGrace(reason) {
|
||||
currentTargetHasVideo: false,
|
||||
roomIdleSince: null,
|
||||
lastContentHeartbeatAt: null,
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null,
|
||||
expectedAcksCount: 0,
|
||||
episodeLobby: null,
|
||||
hcmDesynced: false
|
||||
hcmDesynced: false,
|
||||
reconnectFailed: false,
|
||||
reconnectAttempts: 0,
|
||||
reconnectStartTime: null
|
||||
}).catch(() => {});
|
||||
chatSecretGuard = '';
|
||||
invalidateChatSession();
|
||||
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
|
||||
addLog(reason, 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
forceDisconnect();
|
||||
addLog(reason, 'info');
|
||||
updateBadgeStatus();
|
||||
}
|
||||
|
||||
async function leaveRoomAfterIdleGrace(reason) {
|
||||
if (!currentRoom) return;
|
||||
await endRoomSession({ notifyServer: true, reason });
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (isConnecting) return;
|
||||
isConnecting = true;
|
||||
@@ -1739,6 +1763,13 @@ async function handleServerEvent(event, data) {
|
||||
}
|
||||
case EVENTS.ERROR:
|
||||
isConnecting = false;
|
||||
const terminalRoomError = data.code === ERROR_CODES.ROOM_CLOSED
|
||||
|| data.code === ERROR_CODES.PEER_TIMED_OUT
|
||||
|| data.message === 'Room closed'
|
||||
|| data.message === 'Removed from room after inactivity';
|
||||
if (currentRoom && terminalRoomError) {
|
||||
await endRoomSession({ reason: `Room session ended: ${data.message}` });
|
||||
}
|
||||
// If we get a server error before successfully joining a room,
|
||||
// clear persisted credentials as well, otherwise service-worker
|
||||
// restart would immediately retry the rejected room.
|
||||
@@ -3340,6 +3371,14 @@ async function selectedMediaTargetMoved(tabId) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// A disappearing ad frame can make the parent-visibility handshake
|
||||
// inconclusive while still leaving one hidden mirror as the only video
|
||||
// candidate. Never rebuild toward an unconfirmed nested frame: its monitor
|
||||
// or a later clean probe will announce it again if it is genuinely visible.
|
||||
if (normalizeFrameId(resolved.frameId) !== 0 && resolved.visibilityConfirmed !== true) {
|
||||
refreshMediaFrameMonitors(tabId).catch(() => {});
|
||||
return false;
|
||||
}
|
||||
if (currentTargetHasVideo !== true) return true;
|
||||
return normalizeFrameId(resolved.frameId) !== normalizeFrameId(currentTargetFrameId)
|
||||
|| (typeof resolved.documentId === 'string'
|
||||
@@ -4118,65 +4157,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (storageInitialized) chrome.storage.session.set({ hcmDesynced });
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'LEAVE_ROOM') {
|
||||
webJoinCoordinator.invalidate();
|
||||
completeForceSyncBeforeTargetChange(null);
|
||||
connectIntent = false;
|
||||
reconnectFailed = false;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
currentRoom = null;
|
||||
clearChatActivity();
|
||||
controlMode = CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = null;
|
||||
controllers = [];
|
||||
serverCapabilities = [];
|
||||
hcmDesynced = false;
|
||||
// Notify content.js/popup BEFORE currentTabId is cleared so they drop any
|
||||
// stale guest-side HCM state (dialog/badge/desync) — H-2/H-3.
|
||||
broadcastControlMode();
|
||||
if (currentTabId) await deactivateTargetTab(currentTabId, currentContentTarget());
|
||||
invalidateTargetActivations();
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
clearCurrentContentTarget();
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
|
||||
updateBadgeStatus();
|
||||
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
expectedAcksCount = 0;
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
|
||||
// Cancel any active episode lobby
|
||||
clearEpisodeLobbyState();
|
||||
await clearPendingTarget();
|
||||
|
||||
chrome.storage.session.set({
|
||||
currentRoom: null,
|
||||
chatActivityTimeline: [],
|
||||
currentTabId: null,
|
||||
currentTabTitle: null,
|
||||
currentTargetFrameId: 0,
|
||||
currentTargetDocumentId: null,
|
||||
currentTargetHasVideo: false,
|
||||
roomIdleSince: null,
|
||||
lastContentHeartbeatAt: null,
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null,
|
||||
episodeLobby: null,
|
||||
expectedAcksCount: 0,
|
||||
hcmDesynced: false
|
||||
});
|
||||
chatSecretGuard = '';
|
||||
invalidateChatSession();
|
||||
chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
|
||||
addLog('Left Room', 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
forceDisconnect();
|
||||
await endRoomSession({ notifyServer: true, reason: 'Left Room' });
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'CLEAR_LOGS') {
|
||||
logs = [];
|
||||
|
||||
@@ -39,12 +39,15 @@ describe('chat crypto', () => {
|
||||
const secret = generateChatSecret(webcrypto);
|
||||
let deriveCalls = 0;
|
||||
let releaseDerive;
|
||||
let markDeriveStarted;
|
||||
const deriveStarted = new Promise(resolve => { markDeriveStarted = resolve; });
|
||||
const delayedCrypto = {
|
||||
...webcrypto,
|
||||
subtle: {
|
||||
importKey: (...args) => webcrypto.subtle.importKey(...args),
|
||||
deriveKey: async (...args) => {
|
||||
deriveCalls++;
|
||||
markDeriveStarted();
|
||||
await new Promise(resolve => { releaseDerive = resolve; });
|
||||
return webcrypto.subtle.deriveKey(...args);
|
||||
}
|
||||
@@ -52,7 +55,7 @@ describe('chat crypto', () => {
|
||||
};
|
||||
const first = deriveChatKey('ROOM-1', secret, delayedCrypto);
|
||||
const second = deriveChatKey('ROOM-1', secret, delayedCrypto);
|
||||
await Promise.resolve();
|
||||
await deriveStarted;
|
||||
expect(deriveCalls).toBe(1);
|
||||
clearChatKeyCache();
|
||||
releaseDerive();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"default_locale": "en",
|
||||
"name": "__MSG_appName__",
|
||||
"short_name": "KoalaSync",
|
||||
"version": "3.1.4",
|
||||
"version": "3.1.5",
|
||||
"description": "__MSG_appDesc__",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
@@ -467,6 +467,7 @@ function contentTarget(tabId, selected, discoveredFrameIds = null) {
|
||||
documentId,
|
||||
frameUrl: typeof selected?.result?.href === 'string' ? selected.result.href : null,
|
||||
hasVideo: !!selected?.result?.bestVideo,
|
||||
visibilityConfirmed: selected?.result?.parentFrameVisible === true,
|
||||
scriptTarget: documentId
|
||||
? { tabId, documentIds: [documentId] }
|
||||
: (frameId === 0 ? { tabId } : { tabId, frameIds: [frameId] })
|
||||
|
||||
@@ -184,6 +184,7 @@ describe('cross-origin media-frame targeting', () => {
|
||||
documentId: 'document-8',
|
||||
frameUrl: 'https://player-8.example/embed',
|
||||
hasVideo: true,
|
||||
visibilityConfirmed: true,
|
||||
// Reported back so the caller can address these frames directly when
|
||||
// a later all-frames sweep is rejected wholesale.
|
||||
discoveredFrameIds: [0, 8],
|
||||
@@ -242,6 +243,7 @@ describe('cross-origin media-frame targeting', () => {
|
||||
documentId: null,
|
||||
frameUrl: null,
|
||||
hasVideo: false,
|
||||
visibilityConfirmed: false,
|
||||
discoveredFrameIds: [0, 6],
|
||||
scriptTarget: { tabId: 42 }
|
||||
});
|
||||
@@ -582,6 +584,7 @@ describe('embedded player access diagnosis', () => {
|
||||
documentId: null,
|
||||
frameUrl: null,
|
||||
hasVideo: false,
|
||||
visibilityConfirmed: false,
|
||||
discoveredFrameIds: [0],
|
||||
scriptTarget: { tabId: 42 }
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.html'), 'utf8');
|
||||
|
||||
describe('popup layout containment', () => {
|
||||
it('prevents dynamic descendants from changing the 360px popup width', () => {
|
||||
expect(popupSource).toMatch(/html\s*\{[^}]*width:\s*360px;/s);
|
||||
expect(popupSource).toMatch(/html\s*\{[^}]*min-width:\s*360px;/s);
|
||||
expect(popupSource).toMatch(/html\s*\{[^}]*max-width:\s*360px;/s);
|
||||
expect(popupSource).toMatch(/html\s*\{[^}]*overflow-x:\s*hidden;/s);
|
||||
expect(popupSource).toMatch(/body\s*\{[^}]*width:\s*360px;/s);
|
||||
expect(popupSource).toMatch(/body\s*\{[^}]*max-width:\s*360px;/s);
|
||||
expect(popupSource).toMatch(/body\s*\{[^}]*contain:\s*inline-size;/s);
|
||||
expect(popupSource).toMatch(/body\s*\{[^}]*overflow-x:\s*hidden;/s);
|
||||
});
|
||||
});
|
||||
@@ -180,11 +180,25 @@
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Defensive popup boundary: descendants may be populated dynamically
|
||||
after Chrome has measured the action popup. Keep their intrinsic
|
||||
width from resizing the popup window while preserving normal vertical
|
||||
layout and scrolling inside the established 360px surface. */
|
||||
html {
|
||||
width: 360px;
|
||||
min-width: 360px;
|
||||
max-width: 360px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 360px;
|
||||
max-width: 360px;
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
box-sizing: border-box;
|
||||
contain: inline-size;
|
||||
overflow-x: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Twemoji Country Flags', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
|
||||
@@ -9,6 +9,8 @@ const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'ut
|
||||
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
|
||||
const monitorSource = fs.readFileSync(path.join(extensionDir, 'media-frame-monitor.js'), 'utf8');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(extensionDir, 'manifest.base.json'), 'utf8'));
|
||||
const sharedConstantsSource = fs.readFileSync(path.join(extensionDir, '..', 'shared', 'constants.js'), 'utf8');
|
||||
const serverSource = fs.readFileSync(path.join(extensionDir, '..', 'server', 'index.js'), 'utf8');
|
||||
|
||||
describe('target tab lifecycle', () => {
|
||||
it('injects playback and chat scripts only into the explicitly selected tab', () => {
|
||||
@@ -96,6 +98,37 @@ describe('target tab lifecycle', () => {
|
||||
expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'");
|
||||
});
|
||||
|
||||
it('routes every terminal room exit through the full target unhook', () => {
|
||||
const teardownStart = backgroundSource.indexOf('async function endRoomSession');
|
||||
const teardownEnd = backgroundSource.indexOf('async function leaveRoomAfterIdleGrace', teardownStart);
|
||||
const teardownSource = backgroundSource.slice(teardownStart, teardownEnd);
|
||||
expect(teardownSource).toContain('await deactivateTargetTab(currentTabId, currentContentTarget())');
|
||||
expect(teardownSource.indexOf('await deactivateTargetTab(currentTabId, currentContentTarget())'))
|
||||
.toBeLessThan(teardownSource.indexOf('currentTabId = null'));
|
||||
expect(teardownSource).toContain('await clearPendingTarget()');
|
||||
expect(teardownSource).toContain('forceDisconnect()');
|
||||
|
||||
expect(backgroundSource).toContain('await endRoomSession({ notifyServer: true, reason });');
|
||||
expect(backgroundSource).toContain("await endRoomSession({ notifyServer: true, reason: 'Left Room' });");
|
||||
expect(backgroundSource).toContain('data.code === ERROR_CODES.ROOM_CLOSED');
|
||||
expect(backgroundSource).toContain('data.code === ERROR_CODES.PEER_TIMED_OUT');
|
||||
expect(backgroundSource).toContain("data.message === 'Room closed'");
|
||||
expect(backgroundSource).toContain("data.message === 'Removed from room after inactivity'");
|
||||
expect(backgroundSource).toContain('await endRoomSession({ reason: `Room session ended: ${data.message}` });');
|
||||
|
||||
expect(sharedConstantsSource).toContain("ROOM_CLOSED: 'room_closed'");
|
||||
expect(sharedConstantsSource).toContain("PEER_TIMED_OUT: 'peer_timed_out'");
|
||||
expect(serverSource).toContain('code: ERROR_CODES.ROOM_CLOSED');
|
||||
expect(serverSource).toContain('code: ERROR_CODES.PEER_TIMED_OUT');
|
||||
expect(serverSource).toContain("removePeerFromRoom(sid, roomId, 'room-timeout')");
|
||||
});
|
||||
|
||||
it('does not promote a nested media target without confirmed parent visibility', () => {
|
||||
expect(backgroundSource).toContain(
|
||||
'normalizeFrameId(resolved.frameId) !== 0 && resolved.visibilityConfirmed !== true'
|
||||
);
|
||||
});
|
||||
|
||||
it('removes monitors injected by a superseded cross-tab activation', () => {
|
||||
expect(backgroundSource).toContain('function isTargetActivationSuperseded(tabId, activationGeneration)');
|
||||
expect(backgroundSource).toMatch(/navigationRetries: navigationRetries - 1,\s*activationGeneration\s*\}\)/);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "3.1.4",
|
||||
"version": "3.1.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "koalasync",
|
||||
"version": "3.1.4",
|
||||
"version": "3.1.5",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "3.1.4",
|
||||
"version": "3.1.5",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -67,6 +67,25 @@ try {
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Stale peer reaper: terminal timeout + clean rejoin ---
|
||||
const staleClient = await c();
|
||||
const staleRoomId = 'stale-'+Date.now();
|
||||
await j(staleClient, staleRoomId, 'stale-peer');
|
||||
staleClient._m.length = 0;
|
||||
const staleRoom = mod.rooms.get(staleRoomId);
|
||||
staleRoom.peerData.values().next().value.lastSeen = 1;
|
||||
mod.cleanupInactiveRooms(Date.now());
|
||||
const [staleEvent, staleData] = await a(staleClient);
|
||||
assert.equal(staleEvent, 'error');
|
||||
assert.equal(staleData.code, 'peer_timed_out');
|
||||
assert.equal(staleData.message, 'Removed from room after inactivity');
|
||||
assert.equal(mod.rooms.has(staleRoomId), false, 'stale peer room is deleted');
|
||||
staleClient._m.length = 0;
|
||||
await j(staleClient, staleRoomId, 'stale-peer');
|
||||
assert.equal(mod.rooms.has(staleRoomId), true, 'stale peer can rejoin cleanly');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Capabilities: ROOM_DATA advertises server features for client detection ---
|
||||
const capClient = await c();
|
||||
s(capClient, 'join_room', { roomId: 'cap-'+Date.now(), peerId: 'capp', protocolVersion: '1.0.0' });
|
||||
@@ -80,6 +99,27 @@ try {
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Terminal room timeout: coded error + complete membership cleanup ---
|
||||
const timeoutClient = await c();
|
||||
const timeoutRoomId = 'timeout-'+Date.now();
|
||||
await j(timeoutClient, timeoutRoomId, 'timeout-peer');
|
||||
timeoutClient._m.length = 0;
|
||||
mod.rooms.get(timeoutRoomId).lastActivity = 0;
|
||||
mod.cleanupInactiveRooms(Date.now());
|
||||
const [timeoutEvent, timeoutData] = await a(timeoutClient);
|
||||
assert.equal(timeoutEvent, 'error');
|
||||
assert.equal(timeoutData.code, 'room_closed');
|
||||
assert.equal(timeoutData.message, 'Room closed');
|
||||
assert.equal(mod.rooms.has(timeoutRoomId), false, 'inactive room is deleted');
|
||||
timeoutClient._m.length = 0;
|
||||
|
||||
// The same connected socket must be able to join that room again. This
|
||||
// proves timeout cleanup removed its stale socketToRoom membership.
|
||||
await j(timeoutClient, timeoutRoomId, 'timeout-peer');
|
||||
assert.equal(mod.rooms.has(timeoutRoomId), true, 'timed-out peer can rejoin cleanly');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Encrypted chat is a live-only canonical relay ---
|
||||
const chatRoom = 'chat-'+Date.now();
|
||||
const chat1 = await c(), chat2 = await c();
|
||||
|
||||
+24
-6
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
|
||||
import { Server } from 'socket.io';
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
|
||||
import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
|
||||
import { createChatEnvelope } from './chat.js';
|
||||
import {
|
||||
buildHealthPayload,
|
||||
@@ -922,8 +922,7 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
|
||||
// Active Room & Dead Peer Cleanup (Every 2m)
|
||||
const roomCleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
export function cleanupInactiveRooms(now = Date.now()) {
|
||||
const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours
|
||||
const peerCutoff = now - (5 * 60 * 1000); // 5 minutes
|
||||
|
||||
@@ -942,7 +941,13 @@ const roomCleanupInterval = setInterval(() => {
|
||||
}
|
||||
for (const sid of staleSids) {
|
||||
const deadSocket = io.sockets?.sockets?.get(sid);
|
||||
if (deadSocket) deadSocket.leave(roomId);
|
||||
if (deadSocket) {
|
||||
deadSocket.emit(EVENTS.ERROR, {
|
||||
code: ERROR_CODES.PEER_TIMED_OUT,
|
||||
message: 'Removed from room after inactivity'
|
||||
});
|
||||
deadSocket.leave(roomId);
|
||||
}
|
||||
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
|
||||
try {
|
||||
removePeerFromRoom(sid, roomId, 'reaper');
|
||||
@@ -954,12 +959,25 @@ const roomCleanupInterval = setInterval(() => {
|
||||
// 2. Prune empty or inactive rooms
|
||||
const currentRoom = rooms.get(roomId);
|
||||
if (currentRoom && (currentRoom.peers.size === 0 || currentRoom.lastActivity < roomCutoff)) {
|
||||
io.to(roomId).emit(EVENTS.ERROR, { message: 'Room closed' });
|
||||
io.to(roomId).emit(EVENTS.ERROR, {
|
||||
code: ERROR_CODES.ROOM_CLOSED,
|
||||
message: 'Room closed'
|
||||
});
|
||||
// A terminal room timeout is a real leave for every member. Clear
|
||||
// the same socket/peer indexes as an explicit leave so a later join
|
||||
// cannot be mistaken for the stale membership.
|
||||
for (const sid of Array.from(currentRoom.peers)) {
|
||||
const memberSocket = io.sockets?.sockets?.get(sid);
|
||||
if (memberSocket) memberSocket.leave(roomId);
|
||||
removePeerFromRoom(sid, roomId, 'room-timeout');
|
||||
}
|
||||
rooms.delete(roomId);
|
||||
log('CLEANUP', `Deleted room ${roomId.substring(0, 3)}*** (Empty/Inactive)`);
|
||||
}
|
||||
}
|
||||
}, 2 * 60 * 1000);
|
||||
}
|
||||
|
||||
const roomCleanupInterval = setInterval(cleanupInactiveRooms, 2 * 60 * 1000);
|
||||
|
||||
export function startServer(port = PORT, host) {
|
||||
if (httpServer.listening) return Promise.resolve(httpServer);
|
||||
|
||||
+9
-1
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "3.1.4";
|
||||
export const APP_VERSION = "3.1.5";
|
||||
|
||||
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
|
||||
@@ -65,6 +65,14 @@ export const EVENTS = {
|
||||
PONG: "pong" // server responds with same { t } for client RTT calculation
|
||||
};
|
||||
|
||||
// Stable server error identifiers. Clients must branch on these codes instead
|
||||
// of localized or user-facing message text whenever the error changes session
|
||||
// state.
|
||||
export const ERROR_CODES = {
|
||||
ROOM_CLOSED: 'room_closed',
|
||||
PEER_TIMED_OUT: 'peer_timed_out'
|
||||
};
|
||||
|
||||
// Room control modes (Host Control Mode feature).
|
||||
// NOTE: content.js does not import this module — it uses the string literals
|
||||
// 'everyone' / 'host-only' directly. Keep these values in sync there.
|
||||
|
||||
@@ -27,3 +27,34 @@ test('popup exposes names and keyboard access for every visible control', async
|
||||
await expect(settingsTab).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.locator('#tab-settings')).toBeVisible();
|
||||
});
|
||||
|
||||
test('popup root remains 360px when a dynamic child overflows', async ({ context, extensionId }) => {
|
||||
const page = await openPopup(context, extensionId, { openEditor: false });
|
||||
const geometry = await page.evaluate(() => {
|
||||
const probe = document.createElement('div');
|
||||
probe.id = 'popup-overflow-probe';
|
||||
probe.style.width = '1200px';
|
||||
probe.style.height = '1px';
|
||||
document.body.appendChild(probe);
|
||||
|
||||
const htmlStyle = window.getComputedStyle(document.documentElement);
|
||||
const bodyStyle = window.getComputedStyle(document.body);
|
||||
return {
|
||||
htmlWidth: document.documentElement.getBoundingClientRect().width,
|
||||
bodyWidth: document.body.getBoundingClientRect().width,
|
||||
htmlOverflowX: htmlStyle.overflowX,
|
||||
bodyOverflowX: bodyStyle.overflowX,
|
||||
bodyContain: bodyStyle.contain,
|
||||
probeWidth: probe.getBoundingClientRect().width
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry).toEqual({
|
||||
htmlWidth: 360,
|
||||
bodyWidth: 360,
|
||||
htmlOverflowX: 'hidden',
|
||||
bodyOverflowX: 'hidden',
|
||||
bodyContain: 'inline-size',
|
||||
probeWidth: 1200
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ Compatibility depends on each website's player implementation and can change whe
|
||||
|
||||
## Technical information
|
||||
|
||||
- Current website release: 3.1.4
|
||||
- Current website release: 3.1.5
|
||||
- License: MIT
|
||||
- Extension runtime: dependency-free browser extension code
|
||||
- Relay: Node.js with Socket.IO-compatible WebSocket messaging
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
"priceCurrency": "EUR"
|
||||
},
|
||||
"description": "{{SCHEMA_APP_DESC}}",
|
||||
"softwareVersion": "3.1.4",
|
||||
"softwareVersion": "3.1.5",
|
||||
"license": "https://opensource.org/licenses/MIT",
|
||||
"sameAs": "https://github.com/Shik3i/KoalaSync",
|
||||
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "3.1.4",
|
||||
"date": "2026-08-21T10:33:30Z"
|
||||
"version": "3.1.5",
|
||||
"date": "2026-08-24T21:56:28Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user