diff --git a/extension/background.js b/extension/background.js index 3025f6d..a261029 100644 --- a/extension/background.js +++ b/extension/background.js @@ -291,10 +291,19 @@ let linkSession = null; let linkSessionIdentity = ''; let linkTask = Promise.resolve(); let linkSendTimer = null; +let linkSharingGeneration = 0; +let linkSessionGeneration = 0; +function revokeLocalPeerUrl() { + linkSharingGeneration++; + peerUrls.delete(peerId); + // publish clears an obsolete queued URL synchronously, before encryption. + linkSession?.publish(null).then(pumpPeerLinks).catch(() => {}); +} function peersWithUrls() { return (currentRoom?.peers || []).map(p => ({ ...p, tabUrl: peerUrls.get(p.peerId) || null })); } function resetPeerLinks() { + linkSessionGeneration++; linkSession?.close(); linkSession = null; linkSessionIdentity = ''; @@ -304,16 +313,28 @@ function resetPeerLinks() { } function pumpPeerLinks() { if (linkSendTimer || !linkSession?.pending) return; - linkSendTimer = setTimeout(() => { - linkSendTimer = null; - if (!linkSession?.pending || !currentRoom || socket?.readyState !== WebSocket.OPEN || !isNamespaceJoined) return; - const limit = chatSendLimiter.take(); - if (limit.allowed) { - const ciphertext = linkSession.nextPacket(); - if (ciphertext) emitLive(EVENTS.CHAT_MESSAGE, { ciphertext }); + const timer = setTimeout(async () => { + const session = linkSession; + const expected = connectionGeneration; + try { + const { shareVideoUrl, roomId } = await chrome.storage.local.get(['shareVideoUrl', 'roomId']); + if (!session || session !== linkSession || expected !== connectionGeneration || roomId !== currentRoom?.roomId) return; + if (shareVideoUrl !== true || normalizeTabId(currentTabId) !== normalizeTabId(userSelectedTabId)) await session.publish(null); + if (session !== linkSession || expected !== connectionGeneration || !session.pending || !currentRoom || socket?.readyState !== WebSocket.OPEN || !isNamespaceJoined) return; + const limit = chatSendLimiter.take(); + if (limit.allowed) { + const ciphertext = session.nextPacket(); + if (ciphertext) emitLive(EVENTS.CHAT_MESSAGE, { ciphertext }); + } + } catch (_) { /* Keep the queued packet until settings are readable. */ } + finally { + if (linkSendTimer === timer) { + linkSendTimer = null; + pumpPeerLinks(); + } } - pumpPeerLinks(); }, 1200); + linkSendTimer = timer; } function updatePeerLinks(packet = null, senderId = null, announce = false) { const expected = connectionGeneration; @@ -324,6 +345,7 @@ function updatePeerLinks(packet = null, senderId = null, announce = false) { const identity = `${expected}|${currentRoom.roomId}|${peerId}|${settings.chatKey}`; if (identity !== linkSessionIdentity) { resetPeerLinks(); + const sessionGeneration = linkSessionGeneration; const session = await createPeerLinkSession({ roomId: currentRoom.roomId, peerId, chatSecret: settings.chatKey, onUrl(id, url) { if (linkSession !== session) return; @@ -331,20 +353,25 @@ function updatePeerLinks(packet = null, senderId = null, announce = false) { chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: peersWithUrls() }).catch(() => {}); } }); - if (expected !== connectionGeneration || !currentRoom) { session.close(); return; } + if (expected !== connectionGeneration || !currentRoom || sessionGeneration !== linkSessionGeneration) { session.close(); return; } linkSession = session; linkSessionIdentity = identity; session.announce(); } const session = linkSession; session.setPeers(currentRoom.peers.map(p => p.peerId)); + const sharingGeneration = linkSharingGeneration; const selected = normalizeTabId(currentTabId); const { shareVideoUrl } = await chrome.storage.local.get('shareVideoUrl'); const tab = selected && shareVideoUrl === true ? await chrome.tabs.get(selected).catch(() => null) : null; if (expected !== connectionGeneration || session !== linkSession) return; - const url = selected === normalizeTabId(currentTabId) ? normalizePeerUrl(tab?.url) : null; - await session.publish(url); - if (url) peerUrls.set(peerId, url); else peerUrls.delete(peerId); + if (sharingGeneration === linkSharingGeneration) { + const url = selected === normalizeTabId(currentTabId) && selected === normalizeTabId(userSelectedTabId) ? normalizePeerUrl(tab?.url) : null; + await session.publish(url); + if (sharingGeneration === linkSharingGeneration && session === linkSession) { + if (url) peerUrls.set(peerId, url); else peerUrls.delete(peerId); + } + } if (announce) session.announce(); if (packet) await session.receive(senderId, packet).catch(() => {}); pumpPeerLinks(); @@ -356,13 +383,17 @@ const peerNavigator = createPeerNavigator({ getSelection: () => normalizeTabId(userSelectedTabId), getRoomId: () => currentRoom?.roomId || null, select: rememberUserSelection, - async suspend() { + async suspend(isCurrent) { const tabId = normalizeTabId(currentTabId); + const contentTarget = currentContentTarget(); + revokeLocalPeerUrl(); invalidateTargetActivations(); + const expectedGeneration = targetActivationGeneration; + const shouldContinue = () => isCurrent() && expectedGeneration === targetActivationGeneration; currentTabId = null; clearCurrentContentTarget(); await chrome.storage.session.set({ currentTabId: null }); - if (tabId) await deactivateTargetTab(tabId); + if (tabId && shouldContinue()) await deactivateTargetTab(tabId, contentTarget, { shouldContinue }); updatePeerLinks(); }, activate: activateTargetTab, @@ -441,7 +472,6 @@ function clearCanonicalMediaRecovery() { function invalidateChatSession() { chatSessionGeneration++; chatReceiveQueue = Promise.resolve(); - chatSendLimiter.reset(); chatEchoTracker.reset(); clearChatKeyCache(); } @@ -1633,6 +1663,7 @@ async function connect() { // --- Phase 5: Event Listeners --- connectionSocket.onopen = () => { if (generation !== connectionGeneration || socket !== connectionSocket) return; + chatSendLimiter.reset(); reconnectAttempts = 0; reconnectStartTime = null; reconnectFailed = false; @@ -3773,12 +3804,13 @@ async function deactivateMediaFrameMonitors(tabId) { })); } -async function deactivateTargetTab(tabId, contentTarget = null, { deactivateMonitor = true } = {}) { +async function deactivateTargetTab(tabId, contentTarget = null, { deactivateMonitor = true, shouldContinue = () => true } = {}) { const normalizedTabId = normalizeTabId(tabId); - if (normalizedTabId === null) return; + if (normalizedTabId === null || !shouldContinue()) return; if (deactivateMonitor) { await deactivateMediaFrameMonitors(normalizedTabId); } + if (!shouldContinue()) return; const target = contentTarget || (normalizedTabId === normalizeTabId(currentTabId) ? currentContentTarget() : null) || (normalizedTabId === normalizeTabId(activeTargetActivation?.tabId) @@ -3796,6 +3828,7 @@ async function deactivateTargetTab(tabId, contentTarget = null, { deactivateMoni null, target.documentId ).catch(() => {}); + if (!shouldContinue()) return; await sendMessageToFrame( normalizedTabId, target.frameId, @@ -3805,6 +3838,7 @@ async function deactivateTargetTab(tabId, contentTarget = null, { deactivateMoni ).catch(() => {}); // The overlay lives in the top document whenever the player is nested, so // clearing only the media frame would leave a stale chat behind on Drive. + if (!shouldContinue()) return; if (normalizeFrameId(target.frameId) !== 0) { await sendMessageToFrame( normalizedTabId, @@ -4332,6 +4366,7 @@ function expireStuckActivation() { async function rememberUserSelection(tabId, tabTitle) { const normalizedTabId = normalizeTabId(tabId); if (normalizedTabId === null) return false; + if (normalizedTabId !== normalizeTabId(userSelectedTabId)) revokeLocalPeerUrl(); userSelectedTabId = normalizedTabId; userSelectedTabTitle = typeof tabTitle === 'string' ? tabTitle : null; userSelectionErrorTabId = null; @@ -4382,6 +4417,7 @@ async function clearUserSelection(expectedTabId = null) { } function resetUserSelectionState() { + revokeLocalPeerUrl(); userSelectedTabId = null; userSelectedTabTitle = null; userSelectionErrorTabId = null; @@ -5075,6 +5111,11 @@ chrome.storage.onChanged.addListener((changes, area) => { if (changes.browserNotifications && currentTabId) { sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); } + if (changes.shareVideoUrl) { + linkSharingGeneration++; + if (changes.shareVideoUrl.newValue !== true) revokeLocalPeerUrl(); + } + if (changes.chatKey || changes.roomId) resetPeerLinks(); if (changes.shareVideoUrl || changes.chatKey) updatePeerLinks(); if (!changes.roomId && !changes.chatKey && !changes.chatEnabled) return; if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue); diff --git a/extension/peer-links.js b/extension/peer-links.js index 9b89d4b..64c3bcb 100644 --- a/extension/peer-links.js +++ b/extension/peer-links.js @@ -80,9 +80,19 @@ export async function createPeerLinkSession({ roomId, peerId, chatSecret = '', o entry.revision = value.r; onUrl(id, value.u); } + async function queueUrl() { + const expectedRevision = revision; + const c = await encrypt(senderKey, { r: revision, u: url }, aad(peerId, epoch, 'url')); + if (expectedRevision === revision) queue('url', { t: 'url', s: epoch, c }); + } async function sendKey(id, entry) { - const c = await encrypt(entry.pair, { k: encode(senderRaw), r: revision, u: url }, aad(peerId, epoch, 'key', `${id}|${entry.epoch}`)); - if (peers.get(id) === entry) queue(`key:${id}`, { t: 'key', s: epoch, to: id, d: entry.epoch, c }); + // Key distribution must never retain a URL that can subsequently be + // withdrawn. The separately coalesced URL frame supplies late joiners. + const c = await encrypt(entry.pair, { k: encode(senderRaw) }, aad(peerId, epoch, 'key', `${id}|${entry.epoch}`)); + if (peers.get(id) === entry) { + queue(`key:${id}`, { t: 'key', s: epoch, to: id, d: entry.epoch, c }); + await queueUrl(); + } } async function receive(id, packet) { if (closed || !members.has(id) || id === peerId || !packet || typeof packet.s !== 'string' || !/^[\w-]{22}$/.test(packet.s)) return; @@ -106,7 +116,6 @@ export async function createPeerLinkSession({ roomId, peerId, chatSecret = '', o const raw = decode(value.k); if (raw.length !== 32) return; entry.key = await subtle.importKey('raw', raw, 'AES-GCM', false, ['decrypt']); - accept(id, entry, value); if (entry.pending) { const pending = entry.pending; entry.pending = null; @@ -133,14 +142,18 @@ export async function createPeerLinkSession({ roomId, peerId, chatSecret = '', o if (next === url || closed) return; url = next; revision++; - queue('url', { t: 'url', s: epoch, c: await encrypt(senderKey, { r: revision, u: url }, aad(peerId, epoch, 'url')) }); + outbox.delete('url'); + await queueUrl(); }, receive, nextPacket() { - const next = outbox.entries().next().value; - if (!next) return null; - outbox.delete(next[0]); - return next[1]; + // Public identity must precede keys, and keys must precede URLs. + const id = outbox.has('hello') ? 'hello' + : [...outbox.keys()].find(key => key.startsWith('key:')) || (outbox.has('url') ? 'url' : null); + if (id === null) return null; + const packet = outbox.get(id); + outbox.delete(id); + return packet; }, get pending() { return outbox.size > 0; }, close() { closed = true; outbox.clear(); peers.clear(); members.clear(); senderRaw.fill(0); } diff --git a/extension/peer-links.test.mjs b/extension/peer-links.test.mjs index d4ce7b5..4beacfc 100644 --- a/extension/peer-links.test.mjs +++ b/extension/peer-links.test.mjs @@ -1,18 +1,25 @@ import { Buffer } from 'node:buffer'; -import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import { describe, it, expect, vi } from 'vitest'; import { createPeerLinkSession, normalizePeerUrl, readPeerLinkPacket } from './peer-links.js'; import { createChatEnvelope } from '../server/chat.js'; import { decryptChatMessage, generateChatSecret } from './chat-crypto.js'; +import { createChatSendLimiter } from './chat-session.js'; -async function network(secrets = ['', '', '']) { +async function network(secrets = ['', '', ''], cryptoOverrides = []) { const sessions = []; const seen = []; const wire = []; + const observations = []; async function add(secret = '') { const id = String(sessions.length + 1); const urls = new Map(); seen.push(urls); - const session = await createPeerLinkSession({ roomId: 'test-room', peerId: id, chatSecret: secret, onUrl: (peer, url) => urls.set(peer, url) }); + const session = await createPeerLinkSession({ roomId: 'test-room', peerId: id, chatSecret: secret, + cryptoImpl: cryptoOverrides[sessions.length] || globalThis.crypto, + onUrl: (peer, url) => { urls.set(peer, url); observations.push({ recipient: id, peer, url }); } + }); sessions.push(session); for (const s of sessions) s.setPeers(sessions.map((_, i) => String(i + 1))); session.announce(); @@ -24,7 +31,7 @@ async function network(secrets = ['', '', '']) { for (let i = 0; i < sessions.length; i++) { const ciphertext = sessions[i].nextPacket(); if (!ciphertext) continue; - expect(++frames).toBeLessThan(500); + expect(++frames).toBeLessThan(2000); const envelope = createChatEnvelope({ ciphertext, senderId: 'spoof' }, String(i + 1)); expect(envelope).not.toBeNull(); const packet = readPeerLinkPacket(envelope.ciphertext); @@ -34,10 +41,111 @@ async function network(secrets = ['', '', '']) { } } for (const secret of secrets) await add(secret); - return { sessions, seen, wire, drain, add }; + return { sessions, seen, wire, drain, add, observations }; } describe('encrypted room-wide peer links through the unchanged chat envelope', () => { + function backgroundHarness() { + const source = fs.readFileSync(new URL('./background.js', import.meta.url), 'utf8'); + const block = source.slice(source.indexOf('const peerUrls ='), source.indexOf('const peerNavigator =')); + const callbacks = []; + let published = null; + const session = { + publish: vi.fn(async value => { published = value; }), + pending: true, setPeers() {}, announce() {}, close() {}, + nextPacket: vi.fn(() => published === null ? 'withdrawn' : 'url'), + receive: vi.fn() + }; + const env = { + connectionGeneration: 1, currentRoom: { roomId: 'room', peers: [] }, peerId: 'me', + currentTabId: 1, userSelectedTabId: 1, normalizeTabId: v => v, normalizePeerUrl, + serverSupportsChat: () => true, getSettings: async () => ({ roomId: 'room', chatKey: '' }), + createPeerLinkSession: async () => session, addLog: vi.fn(), + setTimeout: fn => { callbacks.push(fn); return callbacks.length; }, clearTimeout() {}, + chrome: { storage: { local: { get: vi.fn(async () => ({ roomId: 'room', shareVideoUrl: true })) } }, + tabs: { get: vi.fn(async () => ({ url: 'https://example.org/private' })) }, + runtime: { sendMessage: async () => {} } }, + socket: { readyState: 1 }, WebSocket: { OPEN: 1 }, isNamespaceJoined: true, + chatSendLimiter: { take: () => ({ allowed: true }) }, EVENTS: { CHAT_MESSAGE: 'chat' }, emitLive: vi.fn() + }; + vm.runInNewContext(block + '\nglobalThis.harness = { updatePeerLinks, revokeLocalPeerUrl };', env); + return { ...env.harness, env, session, callbacks }; + } + it('does not republish a tab URL whose lookup finishes after privacy revocation', async () => { + const h = backgroundHarness(); + let release; + const gate = new Promise(resolve => { release = resolve; }); + let reached; + const started = new Promise(resolve => { reached = resolve; }); + h.env.chrome.tabs.get.mockImplementationOnce(async () => { reached(); await gate; return { url: 'https://example.org/private' }; }); + const updating = h.updatePeerLinks(); + await started; + h.revokeLocalPeerUrl(); + release(); await updating; + expect(h.session.publish.mock.calls).toEqual([[null]]); + }); + it('checks the persisted privacy setting again before dispatching a queued URL', async () => { + const h = backgroundHarness(); + await h.updatePeerLinks(); + h.env.chrome.storage.local.get.mockResolvedValue({ roomId: 'room', shareVideoUrl: false }); + await h.callbacks[0](); + expect(h.env.emitLive).toHaveBeenCalledWith('chat', { ciphertext: 'withdrawn' }); + }); + it('converges for 25 participants with bounded key distribution', async () => { + const n = await network(Array(25).fill('')); + for (let i = 0; i < 25; i++) await n.sessions[i].publish(`https://example.org/${i}`); + await n.drain(); + for (let i = 0; i < 25; i++) for (let j = 0; j < 25; j++) { + if (i !== j) expect(n.seen[i].get(String(j + 1))).toBe(`https://example.org/${j}`); + } + expect(n.wire.length).toBeLessThan(750); + }); + it('does not disclose a withdrawn URL through a queued sender-key snapshot', async () => { + const n = await network(['', '']); + await n.sessions[0].publish('https://example.org/withdrawn'); + const hello0 = readPeerLinkPacket(n.sessions[0].nextPacket()); + const hello1 = readPeerLinkPacket(n.sessions[1].nextPacket()); + await n.sessions[0].receive('2', hello1); + await n.sessions[1].receive('1', hello0); + await n.sessions[0].publish(null); + await n.drain(); + expect(n.observations.some(o => o.url === 'https://example.org/withdrawn')).toBe(false); + }); + + it('keeps the newest URL if older encryption finishes last', async () => { + let release; + const gate = new Promise(resolve => { release = resolve; }); + const real = globalThis.crypto; + const delayed = { + getRandomValues: bytes => real.getRandomValues(bytes), + subtle: new Proxy(real.subtle, { get(target, key) { + if (key === 'encrypt') return async (...args) => { + if (new globalThis.TextDecoder().decode(args[2]).includes('/older')) await gate; + return target.encrypt(...args); + }; + return target[key].bind(target); + } }) + }; + const n = await network(['', ''], [delayed]); + await n.drain(); + const old = n.sessions[0].publish('https://example.org/older'); + await n.sessions[0].publish('https://example.org/newest'); + release(); await old; await n.drain(); + expect(n.seen[1].get('1')).toBe('https://example.org/newest'); + }); + + it('does not replenish the socket send budget when chat settings change', () => { + const source = fs.readFileSync(new URL('./background.js', import.meta.url), 'utf8'); + const start = source.indexOf('function invalidateChatSession()'); + const code = source.slice(start, source.indexOf('\n}', start) + 2); + const limiter = createChatSendLimiter({ now: () => 100 }); + for (let i = 0; i < 10; i++) expect(limiter.take().allowed).toBe(true); + vm.runInNewContext(code + '\ninvalidateChatSession();', { + chatSessionGeneration: 0, chatReceiveQueue: null, chatSendLimiter: limiter, + chatEchoTracker: { reset() {} }, clearChatKeyCache() {} + }); + expect(limiter.take().allowed).toBe(false); + }); it.each(['manual', 'shared', 'mixed'])('exchanges dynamic links for every pair: %s', async mode => { const secret = generateChatSecret(); const n = await network(mode === 'manual' ? ['', '', ''] : mode === 'shared' ? [secret, secret, secret] : [secret, '', secret]); @@ -70,13 +178,14 @@ describe('encrypted room-wide peer links through the unchanged chat envelope', ( it('rejects tampering and replay, coalesces updates, and ignores departed peers', async () => { const n = await network(['', '']); await n.drain(); + const firstUpdate = n.wire.length; await n.sessions[0].publish('https://example.org/old'); await n.drain(); - const old = n.wire.find(f => f.id === '1' && f.packet.t === 'url'); + const old = n.wire.slice(firstUpdate).find(f => f.id === '1' && f.packet.t === 'url'); await n.sessions[0].publish('https://example.org/middle'); await n.sessions[0].publish('https://example.org/new'); await n.drain(); - expect(n.wire.filter(f => f.packet.t === 'url')).toHaveLength(2); + expect(n.wire.slice(firstUpdate).filter(f => f.packet.t === 'url')).toHaveLength(2); await n.sessions[1].receive('1', old.packet); expect(n.seen[1].get('1')).toBe('https://example.org/new'); const bytes = Buffer.from(old.packet.c, 'base64url'); bytes[20] ^= 1; diff --git a/extension/peer-navigation.js b/extension/peer-navigation.js index bb89edc..9678820 100644 --- a/extension/peer-navigation.js +++ b/extension/peer-navigation.js @@ -8,6 +8,8 @@ export function createPeerNavigator({ api, getSelection, getRoomId, select, susp let queuedCompletion = null; let starting = null; const key = 'pendingPeerNavigation'; + const isCurrent = (job, expected) => expected === generation + && job.roomId === getRoomId() && job.tabId === getSelection(); async function cancel() { const token = ++generation; await api.storage.session.remove(key); @@ -17,8 +19,9 @@ export function createPeerNavigator({ api, getSelection, getRoomId, select, susp if (completing) { queuedCompletion = tabId; return; } completing = true; const expected = generation; + let job; try { - const job = (await api.storage.session.get(key))[key]; + job = (await api.storage.session.get(key))[key]; if (!job || job.tabId !== tabId) return; if (expected !== generation) return; if (job.roomId !== getRoomId() || job.tabId !== getSelection()) { @@ -28,15 +31,15 @@ export function createPeerNavigator({ api, getSelection, getRoomId, select, susp if (now() - job.started > 45000) throw new Error('Peer navigation timed out'); if (!job.issued) return; const tab = await api.tabs.get(tabId); - if (expected !== generation) return; + if (!isCurrent(job, expected)) return; if (tab.status !== 'complete' || tab.url === 'about:blank' || tab.pendingUrl) return; if (!normalizePeerUrl(tab.url)) throw new Error('Invalid navigation destination'); const response = await activate(tabId, tab.title || null); - if (expected !== generation) return; + if (!isCurrent(job, expected)) return; await api.storage.session.remove(key); if (response.status !== 'ok' && response.status !== 'superseded') await failure(tabId, response); } catch (error) { - if (expected === generation) { + if (job && isCurrent(job, expected)) { await api.storage.session.remove(key); await failure(tabId, error); } @@ -59,10 +62,10 @@ export function createPeerNavigator({ api, getSelection, getRoomId, select, susp && now() - job.started <= 45000 && normalizePeerUrl(job.url)) { try { await api.tabs.update(job.tabId, { url: job.url, active: true }); - if (expected !== generation) return; + if (!isCurrent(job, expected)) return; await api.storage.session.set({ [key]: { ...job, issued: true } }); } catch (error) { - if (expected === generation) { + if (isCurrent(job, expected)) { await api.storage.session.remove(key); await failure(job.tabId, error); } @@ -78,26 +81,30 @@ export function createPeerNavigator({ api, getSelection, getRoomId, select, susp if (expected !== generation) return { status: 'superseded' }; const roomId = getRoomId(); let tabId = getSelection(); + let expectedSelection = tabId; + const current = () => expected === generation && roomId === getRoomId() && expectedSelection === getSelection(); starting = expected; try { + const originalSelection = tabId; let tab = tabId ? await api.tabs.get(tabId).catch(() => null) : null; - if (expected !== generation || roomId !== getRoomId()) return { status: 'superseded' }; + if (expected !== generation || roomId !== getRoomId() || originalSelection !== getSelection()) return { status: 'superseded' }; if (!tab) tab = await api.tabs.create({ url: 'about:blank', active: true }); tabId = tab.id; - if (expected !== generation || roomId !== getRoomId()) return { status: 'superseded' }; - await suspend(); - if (expected !== generation || roomId !== getRoomId()) return { status: 'superseded' }; + if (!current()) return { status: 'superseded' }; + await suspend(current); + if (!current()) return { status: 'superseded' }; + expectedSelection = tabId; await select(tabId, tab.title || null); - if (expected !== generation || roomId !== getRoomId()) return { status: 'superseded' }; + if (!current()) return { status: 'superseded' }; await api.storage.session.set({ [key]: { tabId, roomId, started: now(), url } }); - if (expected !== generation || roomId !== getRoomId()) return { status: 'superseded' }; + if (!current()) return { status: 'superseded' }; await api.tabs.update(tabId, tab.url === url ? { active: true } : { url, active: true }); - if (expected !== generation || roomId !== getRoomId()) return { status: 'superseded' }; + if (!current()) return { status: 'superseded' }; await api.storage.session.set({ [key]: { tabId, roomId, started: now(), url, issued: true } }); await complete(tabId); return { status: 'navigating', tabId }; } catch (error) { - if (expected === generation) { + if (current()) { await api.storage.session.remove(key); if (tabId) await failure(tabId, error); } diff --git a/extension/peer-navigation.test.mjs b/extension/peer-navigation.test.mjs index 3cf8a1d..f16c1e5 100644 --- a/extension/peer-navigation.test.mjs +++ b/extension/peer-navigation.test.mjs @@ -1,4 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; +import fs from 'node:fs'; +import vm from 'node:vm'; import { createPeerNavigator } from './peer-navigation.js'; function setup(selected = 1) { @@ -28,6 +30,48 @@ function setup(selected = 1) { } describe('peer click navigation', () => { + it.each(['monitor', 'target'])('stops old cleanup when selection changes during %s deactivation', async phase => { + const source = fs.readFileSync(new URL('./background.js', import.meta.url), 'utf8'); + const start = source.indexOf('async function deactivateTargetTab('); + const code = source.slice(start, source.indexOf('\nfunction createHostAccessRequiredError', start)); + let current = true; + const sendMessageToFrame = vi.fn(async () => { if (phase === 'target') current = false; }); + const env = { + normalizeTabId: id => id, normalizeFrameId: id => id, + deactivateMediaFrameMonitors: async () => { if (phase === 'monitor') current = false; }, + resetAudioProcessingInTab: vi.fn(), sendMessageToFrame, + shouldContinue: () => current + }; + await vm.runInNewContext(code + '\ndeactivateTargetTab(1, { frameId: 2 }, { shouldContinue });', env); + expect(sendMessageToFrame.mock.calls.some(call => call[2].type === 'CHAT_DESTROY')).toBe(false); + expect(sendMessageToFrame).toHaveBeenCalledTimes(phase === 'monitor' ? 0 : 1); + }); + it('ignores a late tab error after switching rooms', async () => { + const h = setup(); + await h.navigator.navigate('https://example.org/new'); + h.api.tabs.get.mockImplementationOnce(async () => { h.setRoom('other'); throw new Error('Tab closed'); }); + await h.navigator.complete(1); + expect(h.failure).not.toHaveBeenCalled(); + }); + it('does not override a selection made while creating the new tab', async () => { + const h = setup(null); + h.api.tabs.create.mockImplementationOnce(async () => { h.setSelection(3); return { id: 2 }; }); + expect(await h.navigator.navigate('https://example.org/new')).toMatchObject({ status: 'superseded' }); + expect(h.options.getSelection()).toBe(3); + expect(h.suspend).not.toHaveBeenCalled(); + expect(h.api.tabs.update).not.toHaveBeenCalled(); + }); + it.each(['room', 'selection'])('revalidates %s after the browser tab lookup', async changed => { + const h = setup(); + await h.navigator.navigate('https://example.org/new'); + h.tabs.get(1).status = 'complete'; + h.api.tabs.get.mockImplementationOnce(async () => { + if (changed === 'room') h.setRoom('other'); else h.setSelection(2); + return { ...h.tabs.get(1) }; + }); + await h.navigator.complete(1); + expect(h.activate).not.toHaveBeenCalled(); + }); it.each([1, null])('navigates and activates the requested target, selection=%s', async selection => { const h = setup(selection); const response = await h.navigator.navigate('https://example.org/new'); diff --git a/extension/popup.html b/extension/popup.html index ec5b4c0..fd7d079 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -435,6 +435,7 @@ } button:focus-visible, + [role="button"]:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { diff --git a/extension/popup.js b/extension/popup.js index c1f4fe0..0b3e4ab 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -942,7 +942,7 @@ function updatePeerList(peers) { vol: p.volume, muted: p.muted })); - const currentPeersJson = JSON.stringify(stateToHash); + const currentPeersJson = JSON.stringify([getMessage('PEER_LINK_OPEN'), stateToHash]); if (currentPeersJson === lastPeersJson) return; lastPeersJson = currentPeersJson; diff --git a/tests/e2e/peer-links.spec.mjs b/tests/e2e/peer-links.spec.mjs index 2d1fd80..45fff87 100644 --- a/tests/e2e/peer-links.spec.mjs +++ b/tests/e2e/peer-links.spec.mjs @@ -22,6 +22,24 @@ async function select(page, url) { }, url); } +test('video-link privacy setting defaults off and persists through popup reopening', async ({ context, extensionId }) => { + const page = await control(context, extensionId); + await page.evaluate(() => chrome.storage.sync.set({ onboardingComplete: true })); + await page.goto(`chrome-extension://${extensionId}/popup.html`); + await page.locator('#tab-settings-button').click(); + await page.locator('summary[data-i18n="LABEL_PRIVACY_SETTINGS"]').click(); + await expect(page.locator('#shareVideoUrl')).not.toBeChecked(); + await expect(page.locator('label[for="shareVideoUrl"]')).toHaveAttribute('title', /.+/); + await page.locator('label.toggle-switch:has(#shareVideoUrl)').click(); + await expect.poll(() => page.evaluate(async () => (await chrome.storage.local.get('shareVideoUrl')).shareVideoUrl)).toBe(true); + await page.reload(); + await page.locator('#tab-settings-button').click(); + await page.locator('summary[data-i18n="LABEL_PRIVACY_SETTINGS"]').click(); + await expect(page.locator('#shareVideoUrl')).toBeChecked(); + await page.locator('label.toggle-switch:has(#shareVideoUrl)').click(); + await expect.poll(() => page.evaluate(async () => (await chrome.storage.local.get('shareVideoUrl')).shareVideoUrl)).toBe(false); +}); + test('chat appears on first selection and URL clicks work for every peer without invitation keys', async ({ context, extensionId, baseURL }) => { test.setTimeout(150000); const others = []; @@ -58,6 +76,18 @@ test('chat appears on first selection and URL clicks work for every peer without const popup = await third.context.newPage(); await popup.goto(`chrome-extension://${third.extensionId}/popup.html`); await popup.locator('#tab-sync-button').click(); + const aliceLink = popup.locator('#peerListSync [role="button"]').filter({ hasText: 'Alice' }); + await expect(aliceLink).toHaveAttribute('title', `Open this participant’s video: ${aUrl}`); + await aliceLink.hover(); + await popup.keyboard.press('Tab'); + await aliceLink.focus(); + await expect(aliceLink).toBeFocused(); + expect(await aliceLink.evaluate(el => globalThis.getComputedStyle(el).outlineStyle)).toBe('solid'); + await popup.locator('#tab-settings-button').click(); + await popup.locator('#langSelector').selectOption('de'); + await popup.locator('#tab-sync-button').click(); + await expect(aliceLink).toHaveAttribute('title', `Video dieses Teilnehmers öffnen: ${aUrl}`); + await expect(aliceLink).toHaveAttribute('aria-label', 'Video dieses Teilnehmers öffnen: Alice'); const newPageEvent = third.context.waitForEvent('page'); await popup.locator('#peerListSync [role="button"]').filter({ hasText: 'Alice' }).click(); const created = await newPageEvent; @@ -71,6 +101,12 @@ test('chat appears on first selection and URL clicks work for every peer without await expect(created).toHaveURL(bUrl); await expect.poll(() => status(pages[2])).toMatchObject({ targetTabId: selectedId, targetReady: true }); await expect.poll(async () => (await status(pages[0])).peers.find(p => p.username === 'Charlie')?.tabUrl, { timeout: 20000 }).toBe(bUrl); + await aliceLink.press('Space'); + await expect(created).toHaveURL(aUrl); + await expect.poll(() => status(pages[2])).toMatchObject({ targetTabId: selectedId, targetReady: true }); + await popup.locator('#peerListSync [role="button"]').filter({ hasText: 'Bob' }).click(); + await expect(created).toHaveURL(bUrl); + await expect.poll(() => status(pages[2])).toMatchObject({ targetTabId: selectedId, targetReady: true }); // Same-document URL changes are published without reloading the video. const updated = `${aUrl}#episode-two`;