diff --git a/README.md b/README.md index d4b449a..fcd8fa1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

Release Status - GitHub release + GitHub release License Firefox Add-on Chrome Extension diff --git a/extension/background.js b/extension/background.js index 656e049..c747bbe 100644 --- a/extension/background.js +++ b/extension/background.js @@ -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 = []; diff --git a/extension/chat-crypto.test.mjs b/extension/chat-crypto.test.mjs index a2e205d..46caf3b 100644 --- a/extension/chat-crypto.test.mjs +++ b/extension/chat-crypto.test.mjs @@ -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(); diff --git a/extension/manifest.base.json b/extension/manifest.base.json index 960a184..126c82f 100644 --- a/extension/manifest.base.json +++ b/extension/manifest.base.json @@ -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", diff --git a/extension/media-frame-target.js b/extension/media-frame-target.js index bab8780..cf403c8 100644 --- a/extension/media-frame-target.js +++ b/extension/media-frame-target.js @@ -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] }) diff --git a/extension/media-frame-target.test.mjs b/extension/media-frame-target.test.mjs index 3695a5d..03a0570 100644 --- a/extension/media-frame-target.test.mjs +++ b/extension/media-frame-target.test.mjs @@ -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 } }); diff --git a/extension/popup-layout.test.mjs b/extension/popup-layout.test.mjs new file mode 100644 index 0000000..aad9f1a --- /dev/null +++ b/extension/popup-layout.test.mjs @@ -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); + }); +}); diff --git a/extension/popup.html b/extension/popup.html index 05af8db..a35fe54 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -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; diff --git a/extension/target-tab-lifecycle.test.mjs b/extension/target-tab-lifecycle.test.mjs index 1611219..9ff87a0 100644 --- a/extension/target-tab-lifecycle.test.mjs +++ b/extension/target-tab-lifecycle.test.mjs @@ -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*\}\)/); diff --git a/package-lock.json b/package-lock.json index 63b6f7a..a5f9d4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 1f2a46e..5370a5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "koalasync", - "version": "3.1.4", + "version": "3.1.5", "description": "KoalaSync Build Scripts", "private": true, "type": "module", diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index 3b7a509..7881b6f 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -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(); diff --git a/server/index.js b/server/index.js index e98da03..fc5bd88 100644 --- a/server/index.js +++ b/server/index.js @@ -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); diff --git a/shared/constants.js b/shared/constants.js index 7cf8361..ee43007 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -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. diff --git a/tests/e2e/popup-accessibility.spec.mjs b/tests/e2e/popup-accessibility.spec.mjs index c86e3e6..b2e6c52 100644 --- a/tests/e2e/popup-accessibility.spec.mjs +++ b/tests/e2e/popup-accessibility.spec.mjs @@ -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 + }); +}); diff --git a/website/llms.txt b/website/llms.txt index 6401891..5ec8ba4 100644 --- a/website/llms.txt +++ b/website/llms.txt @@ -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 diff --git a/website/template.html b/website/template.html index 41a316a..b204377 100644 --- a/website/template.html +++ b/website/template.html @@ -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", diff --git a/website/version.json b/website/version.json index fed662e..1a06b0e 100644 --- a/website/version.json +++ b/website/version.json @@ -1,4 +1,4 @@ { - "version": "3.1.4", - "date": "2026-08-21T10:33:30Z" + "version": "3.1.5", + "date": "2026-08-24T21:56:28Z" }