mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-09-10 01:17:01 +00:00
fix(extension): harden peer link privacy and navigation races
This commit is contained in:
+58
-17
@@ -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
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -435,6 +435,7 @@
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user