fix(extension): harden peer link privacy and navigation races

This commit is contained in:
Timo
2026-09-09 02:19:18 +02:00
parent 2b8bbf2316
commit cd7353c408
8 changed files with 298 additions and 47 deletions
+58 -17
View File
@@ -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);
+21 -8
View File
@@ -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); }
+116 -7
View File
@@ -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;
+21 -14
View File
@@ -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);
}
+44
View File
@@ -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');
+1
View File
@@ -435,6 +435,7 @@
}
button:focus-visible,
[role="button"]:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible {
+1 -1
View File
@@ -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;
+36
View File
@@ -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 participants 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`;