fix: harden room teardown and popup containment

This commit is contained in:
KoalaDev
2026-08-25 01:10:41 +02:00
parent 7286a6db3d
commit 204f1d0a8f
18 changed files with 238 additions and 86 deletions
+50 -69
View File
@@ -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 = [];
+4 -1
View File
@@ -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();
+1 -1
View File
@@ -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",
+1
View File
@@ -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] })
+3
View File
@@ -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 }
});
+20
View File
@@ -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);
});
});
+14
View File
@@ -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;
+33
View File
@@ -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*\}\)/);