From 2b8bbf2316f8980a8bc9704730c4ac1dce4702e5 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:51:36 +0200 Subject: [PATCH] feat(extension): add encrypted peer links and fix initial chat activation --- extension/background.js | 157 ++++++++++++++++++++++++++--- extension/locales/de.json | 6 +- extension/locales/en.json | 6 +- extension/locales/es.json | 8 +- extension/locales/fr.json | 6 +- extension/locales/it.json | 6 +- extension/locales/ja.json | 6 +- extension/locales/ko.json | 6 +- extension/locales/nl.json | 6 +- extension/locales/pl.json | 6 +- extension/locales/pt-BR.json | 6 +- extension/locales/pt.json | 6 +- extension/locales/ru.json | 6 +- extension/locales/tr.json | 6 +- extension/locales/uk.json | 6 +- extension/locales/zh.json | 6 +- extension/peer-links.js | 148 +++++++++++++++++++++++++++ extension/peer-links.test.mjs | 125 +++++++++++++++++++++++ extension/peer-navigation.js | 108 ++++++++++++++++++++ extension/peer-navigation.test.mjs | 117 +++++++++++++++++++++ extension/popup.html | 7 ++ extension/popup.js | 35 ++++++- scripts/coverage-plan.mjs | 2 + tests/e2e/peer-links.spec.mjs | 105 +++++++++++++++++++ 24 files changed, 864 insertions(+), 32 deletions(-) create mode 100644 extension/peer-links.js create mode 100644 extension/peer-links.test.mjs create mode 100644 extension/peer-navigation.js create mode 100644 extension/peer-navigation.test.mjs create mode 100644 tests/e2e/peer-links.spec.mjs diff --git a/extension/background.js b/extension/background.js index 22e33db..3025f6d 100644 --- a/extension/background.js +++ b/extension/background.js @@ -6,6 +6,8 @@ import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, norm import { initTabManager } from './modules/tab-manager.js'; import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js'; import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js'; +import { createPeerLinkSession, normalizePeerUrl, readPeerLinkPacket } from './peer-links.js'; +import { createPeerNavigator } from './peer-navigation.js'; import { createChatEchoTracker, createChatSendLimiter, createLatestTaskQueue, normalizeRoomId, shouldShowChatNotification } from './chat-session.js'; import { createChatActivityStore } from './chat-activity.js'; import { canonicalMediaStateFromRoomData, createCanonicalMediaStateTracker } from './canonical-media-state.js'; @@ -284,6 +286,88 @@ const chatSendLimiter = createChatSendLimiter(); const chatEchoTracker = createChatEchoTracker(); const chatActivityStore = createChatActivityStore(); const webJoinCoordinator = createLatestTaskQueue(); +const peerUrls = new Map(); +let linkSession = null; +let linkSessionIdentity = ''; +let linkTask = Promise.resolve(); +let linkSendTimer = null; +function peersWithUrls() { + return (currentRoom?.peers || []).map(p => ({ ...p, tabUrl: peerUrls.get(p.peerId) || null })); +} +function resetPeerLinks() { + linkSession?.close(); + linkSession = null; + linkSessionIdentity = ''; + peerUrls.clear(); + if (linkSendTimer) clearTimeout(linkSendTimer); + linkSendTimer = null; +} +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 }); + } + pumpPeerLinks(); + }, 1200); +} +function updatePeerLinks(packet = null, senderId = null, announce = false) { + const expected = connectionGeneration; + linkTask = linkTask.catch(() => {}).then(async () => { + if (expected !== connectionGeneration || !currentRoom || !serverSupportsChat()) return; + const settings = await getSettings(); + if (expected !== connectionGeneration || !currentRoom || settings.roomId !== currentRoom.roomId) return; + const identity = `${expected}|${currentRoom.roomId}|${peerId}|${settings.chatKey}`; + if (identity !== linkSessionIdentity) { + resetPeerLinks(); + const session = await createPeerLinkSession({ roomId: currentRoom.roomId, peerId, chatSecret: settings.chatKey, + onUrl(id, url) { + if (linkSession !== session) return; + if (url) peerUrls.set(id, url); else peerUrls.delete(id); + chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: peersWithUrls() }).catch(() => {}); + } + }); + if (expected !== connectionGeneration || !currentRoom) { session.close(); return; } + linkSession = session; + linkSessionIdentity = identity; + session.announce(); + } + const session = linkSession; + session.setPeers(currentRoom.peers.map(p => p.peerId)); + 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 (announce) session.announce(); + if (packet) await session.receive(senderId, packet).catch(() => {}); + pumpPeerLinks(); + }).catch(() => addLog('Peer link exchange unavailable', 'warn')); + return linkTask; +} +const peerNavigator = createPeerNavigator({ + api: chrome, + getSelection: () => normalizeTabId(userSelectedTabId), + getRoomId: () => currentRoom?.roomId || null, + select: rememberUserSelection, + async suspend() { + const tabId = normalizeTabId(currentTabId); + invalidateTargetActivations(); + currentTabId = null; + clearCurrentContentTarget(); + await chrome.storage.session.set({ currentTabId: null }); + if (tabId) await deactivateTargetTab(tabId); + updatePeerLinks(); + }, + activate: activateTargetTab, + failure: recordUserSelectionFailure +}); function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); } function serverSupportsChat() { return serverSupports(CAPABILITIES.CHAT_V1) || serverSupports(CAPABILITIES.CHAT); @@ -751,7 +835,7 @@ function updateLocalPeerState(targetPeerId, updates) { peer.lastHeartbeat = Date.now(); // reset time interpolation baseline } if (storageInitialized) chrome.storage.session.set({ currentRoom }); - chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); + chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: peersWithUrls() }).catch(() => {}); } } @@ -868,6 +952,7 @@ function resolveServerUrl(settings) { } function forceDisconnect({ preserveEventQueue = false } = {}) { + resetPeerLinks(); connectionGeneration++; resetCanonicalMediaRecoveryRetries(); if (reconnectTimer) { @@ -1051,6 +1136,11 @@ function sendMessageToContentTab(tabId, message, callback = null) { return chrome.tabs.sendMessage(tabId, message); } +function isCurrentChatSender(sender) { + return normalizeTabId(sender?.tab?.id) === normalizeTabId(currentTabId) + && normalizeTabId(currentTabId) !== null && sender.frameId === 0; +} + function isCurrentContentSender(sender) { if (!sender?.tab) return false; const senderTabId = normalizeTabId(sender.tab.id); @@ -1189,6 +1279,8 @@ async function clearTargetSelectionForLifecycle({ } resetUserSelectionState(); + peerNavigator.cancel().catch(() => {}); + updatePeerLinks(); // Persist the terminal selection state before any frame messaging or host // permission cleanup can yield. A worker stop or a concurrent new target // must never resurrect the selection this lifecycle transition removed. @@ -1603,6 +1695,8 @@ async function connect() { connectionSocket.onclose = () => { if (generation !== connectionGeneration || socket !== connectionSocket) return; + resetPeerLinks(); + chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: peersWithUrls() }).catch(() => {}); // Invalidate any async message handler that began before the // close event and is still suspended at an await boundary. connectionGeneration++; @@ -1698,7 +1792,10 @@ function broadcastConnectionStatus(status) { status = 'idle'; } chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status }).catch(() => {}); - if (currentTabId) sendMessageToCurrentContent({ type: 'CONNECTION_STATUS', status }).catch(() => {}); + if (currentTabId) { + sendMessageToCurrentContent({ type: 'CONNECTION_STATUS', status }).catch(() => {}); + sendMessageToChatOverlay({ type: 'CONNECTION_STATUS', status }).catch(() => {}); + } updateBadgeStatus(); } @@ -2361,6 +2458,8 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con currentRoom.peers = []; } + updatePeerLinks(null, null, true); + peerNavigator.resume().catch(() => {}); const roomPeerIds = new Set(currentRoom.peers.map(candidate => candidate.peerId)); const authoritativeEpisodeSyncV2 = serverSupports(CAPABILITIES.EPISODE_SYNC_V2) ? normalizeEpisodeSyncV2(data.episodeSyncV2, roomPeerIds) @@ -2447,7 +2546,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con } if (storageInitialized) chrome.storage.session.set({ currentRoom }); addLog(`Joined Room: ${data?.roomId || 'unknown'}`, 'success'); - chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: data.peers }).catch(() => {}); + chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: peersWithUrls() }).catch(() => {}); // Inform Website Bridge & Popup const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' }; @@ -2504,6 +2603,11 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {}); break; case EVENTS.CHAT_MESSAGE: { + const linkPacket = readPeerLinkPacket(data?.ciphertext); + if (linkPacket) { + updatePeerLinks(linkPacket, data.senderId); + break; + } if (!currentRoom || !serverSupportsChat() || !currentTabId) break; const generation = chatSessionGeneration; const roomId = currentRoom.roomId; @@ -2690,7 +2794,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con } }); if (storageInitialized) chrome.storage.session.set({ currentRoom }); - chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); + chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: peersWithUrls() }).catch(() => {}); } routeToContent(event, data); @@ -2730,7 +2834,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con currentRoom.peers.push(createPeerData(data)); if (storageInitialized) chrome.storage.session.set({ currentRoom }); - chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); + chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: peersWithUrls() }).catch(() => {}); sendChatActivity('joined', data.peerId, Date.now()); showNotification(data.username || data.peerId, 'joined'); @@ -2745,13 +2849,16 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con emitEpisodeLobbyForCurrentPrivacy(); } } + updatePeerLinks(null, null, true); } else if (data.status === 'left') { const departedDisplayName = chatActivityDisplayName(data.peerId); sendChatActivity('left', data.peerId, Date.now()); showNotification(departedDisplayName, 'left'); currentRoom.peers = currentRoom.peers.filter(p => (p.peerId || p) !== data.peerId); + peerUrls.delete(data.peerId); + updatePeerLinks(); if (storageInitialized) chrome.storage.session.set({ currentRoom }); - chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); + chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: peersWithUrls() }).catch(() => {}); if (episodeLobby) { checkEpisodeLobbyPeerDeparture(); @@ -2797,7 +2904,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con currentRoom.peers[idx] = createPeerData(data); } if (storageInitialized) chrome.storage.session.set({ currentRoom }); - chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); + chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: peersWithUrls() }).catch(() => {}); if (episodeLobby) { checkEpisodeLobbyCompletion(); } @@ -2893,7 +3000,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con candidate.lastReactiveUpdate = Date.now(); } }); - chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); + chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: peersWithUrls() }).catch(() => {}); } clearEpisodeSyncV2State({ notifyContent: false, reason: 'executed' }); addLog(`Episode Sync v2 executed for "${completed.expectedTitle}"`, 'success'); @@ -3051,7 +3158,7 @@ function executeForceSync() { } }); if (storageInitialized) chrome.storage.session.set({ currentRoom }); - chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); + chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: peersWithUrls() }).catch(() => {}); } const executionTimestamp = Date.now(); @@ -4449,6 +4556,8 @@ async function activateTargetTab(tabId, tabTitle, { return { status: 'superseded' }; } updateBadgeStatus(); + sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); + updatePeerLinks(); if (currentTargetHasVideo) { await tryApplyPendingCanonicalMediaState(); } @@ -4722,6 +4831,7 @@ if (chrome.tabs?.onRemoved?.addListener) { }); updateBadgeStatus(); chrome.runtime.sendMessage({ type: 'TARGET_TAB_CLEARED', tabId }).catch(() => {}); + updatePeerLinks(); if (isCurrent) { addLog('Target tab closed.', 'warn'); if (currentRoom) { @@ -4748,7 +4858,7 @@ if (chrome.tabs?.onRemoved?.addListener) { } chrome.runtime.sendMessage({ type: 'PEER_UPDATE', - peers: currentRoom.peers + peers: peersWithUrls() }).catch(() => {}); } }).catch(() => {}); @@ -4833,6 +4943,8 @@ async function _routeToContentInternal(tabId, action, payload, actionTimestamp, chrome.alarms.create('keepAlive', { periodInMinutes: 0.5 }); chrome.alarms.onAlarm.addListener(async (alarm) => { await ensureState(); + peerNavigator.resume().catch(() => {}); + updatePeerLinks(); if (alarm.name === 'keepAlive') { chrome.storage.session.get('keepAlive', () => {}); if (!socket || socket.readyState !== WebSocket.OPEN) { @@ -4963,6 +5075,7 @@ chrome.storage.onChanged.addListener((changes, area) => { if (changes.browserNotifications && currentTabId) { sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); } + if (changes.shareVideoUrl || changes.chatKey) updatePeerLinks(); if (!changes.roomId && !changes.chatKey && !changes.chatEnabled) return; if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue); invalidateChatSession(); @@ -5080,7 +5193,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ status, peerId, - peers: currentRoom ? currentRoom.peers : [], + peers: peersWithUrls(), lastActionState, targetTabId: publicTargetTabId, targetTabTitle: userSelectedTabTitle ?? currentTabTitle, @@ -5119,7 +5232,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { chatEnabled: settings.chatEnabled }); } else if (message.type === 'GET_CHAT_CONTEXT') { - if (!currentRoom || !currentTabId || !isCurrentContentSender(sender)) { + if (!currentRoom || !currentTabId || !isCurrentChatSender(sender)) { sendResponse({ supported: false, hasKey: false }); return; } @@ -5129,7 +5242,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { const isCurrentSession = () => generation === chatSessionGeneration && currentRoom?.roomId === roomId && Number(currentTabId) === tabId - && isCurrentContentSender(sender); + && isCurrentChatSender(sender); const settings = await getSettings(); const localeData = await chrome.storage.local.get(['locale', 'browserNotifications']); await loadLocale(localeData.locale || getSystemLanguage()); @@ -5177,7 +5290,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { } }); } else if (message.type === 'CHAT_SEND') { - if (!currentRoom || !currentTabId || !isCurrentContentSender(sender)) { + if (!currentRoom || !currentTabId || !isCurrentChatSender(sender)) { sendResponse({ status: 'invalid_tab' }); return; } @@ -5721,6 +5834,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { return; } const sharedTitles = getSharedTitleFields(settings, heartbeatPayload.mediaTitle); + updatePeerLinks(); const statusPayload = { ...heartbeatPayload, peerId, @@ -5744,7 +5858,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { me.muted = heartbeatPayload.muted; me.lastHeartbeat = Date.now(); if (storageInitialized) chrome.storage.session.set({ currentRoom }); - chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); + chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: peersWithUrls() }).catch(() => {}); } } sendResponse({ status: 'ok' }); @@ -5774,7 +5888,13 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse(injectionFailureResponse(err)); }); return true; + } else if (message.type === 'NAVIGATE_TO_PEER') { + if (!isExtensionPageSender(sender)) { sendResponse({ status: 'invalid_sender' }); return; } + const url = peerUrls.get(message.peerId); + const member = currentRoom?.peers.some(p => p.peerId === message.peerId); + sendResponse(member && url ? await peerNavigator.navigate(url) : { status: 'unavailable' }); } else if (message.type === 'SET_TARGET_TAB') { + await peerNavigator.cancel(); await waitForRoomTeardown(); if (message.tabId === null || message.tabId === undefined || message.tabId === '') { await clearTargetSelectionForLifecycle({ markRoomIdle: true }); @@ -6119,3 +6239,10 @@ getSettings().then(settings => { connectIntent = !!settings.roomId; if (connectIntent) connect(); }).catch(() => connectIntent = false); + +chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { + ensureState().then(async () => { + if (changeInfo.status === 'complete') await peerNavigator.complete(tabId); + if (tabId === normalizeTabId(currentTabId) && (changeInfo.url || changeInfo.status === 'complete')) updatePeerLinks(); + }).catch(() => {}); +}); diff --git a/extension/locales/de.json b/extension/locales/de.json index d9e218a..81fa13b 100644 --- a/extension/locales/de.json +++ b/extension/locales/de.json @@ -316,5 +316,9 @@ "DEBUG_YES": "JA", "DEBUG_NO": "NEIN", "DEBUG_YES_CHECKED": "JA (geprüft)", - "DEBUG_NA": "k. A." + "DEBUG_NA": "k. A.", + "LABEL_SHARE_VIDEO_URL": "Video-Link teilen", + "LABEL_SHARE_VIDEO_URL_TOOLTIP": "Die URL des ausgewählten Tabs verschlüsselt mit allen im Raum teilen.", + "PEER_LINK_OPEN": "Video dieses Teilnehmers öffnen", + "PEER_LINK_UNAVAILABLE": "Video-Link nicht verfügbar oder Navigation fehlgeschlagen." } diff --git a/extension/locales/en.json b/extension/locales/en.json index d9bc1ec..5c473d6 100644 --- a/extension/locales/en.json +++ b/extension/locales/en.json @@ -316,5 +316,9 @@ "DEBUG_YES": "YES", "DEBUG_NO": "NO", "DEBUG_YES_CHECKED": "YES (checked)", - "DEBUG_NA": "n/a" + "DEBUG_NA": "n/a", + "LABEL_SHARE_VIDEO_URL": "Share video link", + "LABEL_SHARE_VIDEO_URL_TOOLTIP": "Share the selected tab URL, encrypted, with everyone in the room.", + "PEER_LINK_OPEN": "Open this participant’s video", + "PEER_LINK_UNAVAILABLE": "Video link unavailable or navigation failed." } diff --git a/extension/locales/es.json b/extension/locales/es.json index d4fd86f..1bf72d7 100644 --- a/extension/locales/es.json +++ b/extension/locales/es.json @@ -316,5 +316,9 @@ "DEBUG_YES": "Sí", "DEBUG_NO": "No", "DEBUG_YES_CHECKED": "Sí (comprobado)", - "DEBUG_NA": "n/d" -} \ No newline at end of file + "DEBUG_NA": "n/d", + "LABEL_SHARE_VIDEO_URL": "Compartir enlace del vídeo", + "LABEL_SHARE_VIDEO_URL_TOOLTIP": "Compartir la URL cifrada de la pestaña seleccionada con toda la sala.", + "PEER_LINK_OPEN": "Abrir el vídeo de este participante", + "PEER_LINK_UNAVAILABLE": "Enlace no disponible o error de navegación." +} diff --git a/extension/locales/fr.json b/extension/locales/fr.json index da60a9c..d4ef7a8 100644 --- a/extension/locales/fr.json +++ b/extension/locales/fr.json @@ -316,5 +316,9 @@ "DEBUG_YES": "OUI", "DEBUG_NO": "NON", "DEBUG_YES_CHECKED": "OUI (vérifié)", - "DEBUG_NA": "n/d" + "DEBUG_NA": "n/d", + "LABEL_SHARE_VIDEO_URL": "Partager le lien vidéo", + "LABEL_SHARE_VIDEO_URL_TOOLTIP": "Partager l’URL de l’onglet sélectionné, chiffrée, avec tous les participants.", + "PEER_LINK_OPEN": "Ouvrir la vidéo de ce participant", + "PEER_LINK_UNAVAILABLE": "Lien vidéo indisponible ou échec de navigation." } diff --git a/extension/locales/it.json b/extension/locales/it.json index 42523ba..ba167ad 100644 --- a/extension/locales/it.json +++ b/extension/locales/it.json @@ -316,5 +316,9 @@ "DEBUG_YES": "SÌ", "DEBUG_NO": "NO", "DEBUG_YES_CHECKED": "SÌ (verificato)", - "DEBUG_NA": "n/d" + "DEBUG_NA": "n/d", + "LABEL_SHARE_VIDEO_URL": "Condividi link video", + "LABEL_SHARE_VIDEO_URL_TOOLTIP": "Condividi l’URL cifrato della scheda selezionata con tutti nella stanza.", + "PEER_LINK_OPEN": "Apri il video di questo partecipante", + "PEER_LINK_UNAVAILABLE": "Link video non disponibile o navigazione non riuscita." } diff --git a/extension/locales/ja.json b/extension/locales/ja.json index 68ad760..5db41cc 100644 --- a/extension/locales/ja.json +++ b/extension/locales/ja.json @@ -316,5 +316,9 @@ "DEBUG_YES": "はい", "DEBUG_NO": "いいえ", "DEBUG_YES_CHECKED": "はい(確認済み)", - "DEBUG_NA": "なし" + "DEBUG_NA": "なし", + "LABEL_SHARE_VIDEO_URL": "動画リンクを共有", + "LABEL_SHARE_VIDEO_URL_TOOLTIP": "選択したタブのURLを暗号化してルームの全員と共有します。", + "PEER_LINK_OPEN": "この参加者の動画を開く", + "PEER_LINK_UNAVAILABLE": "動画リンクが利用できないか、移動に失敗しました。" } diff --git a/extension/locales/ko.json b/extension/locales/ko.json index 2354f2f..82fa8c8 100644 --- a/extension/locales/ko.json +++ b/extension/locales/ko.json @@ -316,5 +316,9 @@ "DEBUG_YES": "예", "DEBUG_NO": "아니요", "DEBUG_YES_CHECKED": "예(확인됨)", - "DEBUG_NA": "없음" + "DEBUG_NA": "없음", + "LABEL_SHARE_VIDEO_URL": "동영상 링크 공유", + "LABEL_SHARE_VIDEO_URL_TOOLTIP": "선택한 탭의 URL을 암호화하여 방의 모든 참가자와 공유합니다.", + "PEER_LINK_OPEN": "이 참가자의 동영상 열기", + "PEER_LINK_UNAVAILABLE": "동영상 링크를 사용할 수 없거나 이동하지 못했습니다." } diff --git a/extension/locales/nl.json b/extension/locales/nl.json index 4cac4c9..2559734 100644 --- a/extension/locales/nl.json +++ b/extension/locales/nl.json @@ -316,5 +316,9 @@ "DEBUG_YES": "JA", "DEBUG_NO": "NEE", "DEBUG_YES_CHECKED": "JA (gecontroleerd)", - "DEBUG_NA": "n.v.t." + "DEBUG_NA": "n.v.t.", + "LABEL_SHARE_VIDEO_URL": "Videolink delen", + "LABEL_SHARE_VIDEO_URL_TOOLTIP": "Deel de URL van het geselecteerde tabblad versleuteld met iedereen in de kamer.", + "PEER_LINK_OPEN": "Video van deze deelnemer openen", + "PEER_LINK_UNAVAILABLE": "Videolink niet beschikbaar of navigatie mislukt." } diff --git a/extension/locales/pl.json b/extension/locales/pl.json index abf7622..701a2ea 100644 --- a/extension/locales/pl.json +++ b/extension/locales/pl.json @@ -316,5 +316,9 @@ "DEBUG_YES": "TAK", "DEBUG_NO": "NIE", "DEBUG_YES_CHECKED": "TAK (sprawdzono)", - "DEBUG_NA": "brak" + "DEBUG_NA": "brak", + "LABEL_SHARE_VIDEO_URL": "Udostępnij link do filmu", + "LABEL_SHARE_VIDEO_URL_TOOLTIP": "Udostępnij zaszyfrowany adres wybranej karty wszystkim w pokoju.", + "PEER_LINK_OPEN": "Otwórz film tego uczestnika", + "PEER_LINK_UNAVAILABLE": "Link niedostępny lub nawigacja nie powiodła się." } diff --git a/extension/locales/pt-BR.json b/extension/locales/pt-BR.json index 6b0fae4..70231c2 100644 --- a/extension/locales/pt-BR.json +++ b/extension/locales/pt-BR.json @@ -316,5 +316,9 @@ "DEBUG_YES": "SIM", "DEBUG_NO": "NÃO", "DEBUG_YES_CHECKED": "SIM (verificado)", - "DEBUG_NA": "n/d" + "DEBUG_NA": "n/d", + "LABEL_SHARE_VIDEO_URL": "Compartilhar link do vídeo", + "LABEL_SHARE_VIDEO_URL_TOOLTIP": "Compartilhar a URL criptografada da aba selecionada com todos na sala.", + "PEER_LINK_OPEN": "Abrir o vídeo deste participante", + "PEER_LINK_UNAVAILABLE": "Link indisponível ou falha na navegação." } diff --git a/extension/locales/pt.json b/extension/locales/pt.json index 55f6108..c3dc0f8 100644 --- a/extension/locales/pt.json +++ b/extension/locales/pt.json @@ -316,5 +316,9 @@ "DEBUG_YES": "SIM", "DEBUG_NO": "NÃO", "DEBUG_YES_CHECKED": "SIM (verificado)", - "DEBUG_NA": "n/d" + "DEBUG_NA": "n/d", + "LABEL_SHARE_VIDEO_URL": "Partilhar ligação do vídeo", + "LABEL_SHARE_VIDEO_URL_TOOLTIP": "Partilhar o URL encriptado do separador selecionado com todos na sala.", + "PEER_LINK_OPEN": "Abrir o vídeo deste participante", + "PEER_LINK_UNAVAILABLE": "Ligação indisponível ou falha na navegação." } diff --git a/extension/locales/ru.json b/extension/locales/ru.json index 80db8cb..e69d315 100644 --- a/extension/locales/ru.json +++ b/extension/locales/ru.json @@ -316,5 +316,9 @@ "DEBUG_YES": "ДА", "DEBUG_NO": "НЕТ", "DEBUG_YES_CHECKED": "ДА (проверено)", - "DEBUG_NA": "н/д" + "DEBUG_NA": "н/д", + "LABEL_SHARE_VIDEO_URL": "Делиться ссылкой на видео", + "LABEL_SHARE_VIDEO_URL_TOOLTIP": "Передавать зашифрованный адрес выбранной вкладки всем участникам комнаты.", + "PEER_LINK_OPEN": "Открыть видео этого участника", + "PEER_LINK_UNAVAILABLE": "Ссылка недоступна или переход не удался." } diff --git a/extension/locales/tr.json b/extension/locales/tr.json index d9bc14b..4eb7ae1 100644 --- a/extension/locales/tr.json +++ b/extension/locales/tr.json @@ -316,5 +316,9 @@ "DEBUG_YES": "EVET", "DEBUG_NO": "HAYIR", "DEBUG_YES_CHECKED": "EVET (kontrol edildi)", - "DEBUG_NA": "yok" + "DEBUG_NA": "yok", + "LABEL_SHARE_VIDEO_URL": "Video bağlantısını paylaş", + "LABEL_SHARE_VIDEO_URL_TOOLTIP": "Seçili sekmenin URL adresini odadaki herkesle şifreli olarak paylaş.", + "PEER_LINK_OPEN": "Bu katılımcının videosunu aç", + "PEER_LINK_UNAVAILABLE": "Video bağlantısı kullanılamıyor veya gezinme başarısız oldu." } diff --git a/extension/locales/uk.json b/extension/locales/uk.json index 12b14d7..fcdcde6 100644 --- a/extension/locales/uk.json +++ b/extension/locales/uk.json @@ -316,5 +316,9 @@ "DEBUG_YES": "ТАК", "DEBUG_NO": "НІ", "DEBUG_YES_CHECKED": "ТАК (перевірено)", - "DEBUG_NA": "н/д" + "DEBUG_NA": "н/д", + "LABEL_SHARE_VIDEO_URL": "Ділитися посиланням на відео", + "LABEL_SHARE_VIDEO_URL_TOOLTIP": "Передавати зашифровану адресу вибраної вкладки всім учасникам кімнати.", + "PEER_LINK_OPEN": "Відкрити відео цього учасника", + "PEER_LINK_UNAVAILABLE": "Посилання недоступне або перехід не вдався." } diff --git a/extension/locales/zh.json b/extension/locales/zh.json index b735cae..26738c9 100644 --- a/extension/locales/zh.json +++ b/extension/locales/zh.json @@ -316,5 +316,9 @@ "DEBUG_YES": "是", "DEBUG_NO": "否", "DEBUG_YES_CHECKED": "是(已检查)", - "DEBUG_NA": "无" + "DEBUG_NA": "无", + "LABEL_SHARE_VIDEO_URL": "分享视频链接", + "LABEL_SHARE_VIDEO_URL_TOOLTIP": "将所选标签页的网址加密分享给房间内的所有人。", + "PEER_LINK_OPEN": "打开此参与者的视频", + "PEER_LINK_UNAVAILABLE": "视频链接不可用或跳转失败。" } diff --git a/extension/peer-links.js b/extension/peer-links.js new file mode 100644 index 0000000..9b89d4b --- /dev/null +++ b/extension/peer-links.js @@ -0,0 +1,148 @@ +// An extension-only subprotocol inside the existing opaque chat relay envelope. +// URLs and sender keys are encrypted; only ephemeral public keys and routing +// metadata are public. Without a shared chat secret, ECDH trusts relay identity. +const PREFIX = 'KoalaSyncLinks1:'; +const encoder = new globalThis.TextEncoder(); +const decoder = new globalThis.TextDecoder('utf-8', { fatal: true }); +const encode = bytes => globalThis.btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +function decode(value) { + if (typeof value !== 'string' || value.length > 2800 || !/^[\w-]+$/.test(value)) throw new Error('Invalid encoding'); + const bytes = Uint8Array.from(globalThis.atob(value.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)); + if (encode(bytes) !== value) throw new Error('Noncanonical encoding'); + return bytes; +} + +export function normalizePeerUrl(value) { + if (typeof value !== 'string' || encoder.encode(value).length > 900 || /[\u0000-\u0020\u007f]/.test(value)) return null; + try { + const url = new URL(value); + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) return null; + return encoder.encode(url.href).length <= 900 ? url.href : null; + } catch (_) { return null; } +} + +export function readPeerLinkPacket(ciphertext) { + try { + const bytes = decode(ciphertext); + if (bytes.length > 2028) return null; + const text = decoder.decode(bytes); + if (!text.startsWith(PREFIX)) return null; + const data = JSON.parse(text.slice(PREFIX.length)); + return data && typeof data === 'object' && !Array.isArray(data) ? data : null; + } catch (_) { return null; } +} + +export async function createPeerLinkSession({ roomId, peerId, chatSecret = '', onUrl = () => {}, cryptoImpl = globalThis.crypto }) { + const subtle = cryptoImpl.subtle; + const random = length => cryptoImpl.getRandomValues(new Uint8Array(length)); + const epoch = encode(random(16)); + const keys = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']); + const publicKey = encode(new Uint8Array(await subtle.exportKey('raw', keys.publicKey))); + const secret = chatSecret ? decode(chatSecret) : new Uint8Array(); + const tag = secret.length ? encode(new Uint8Array(await subtle.digest('SHA-256', encoder.encode(`${roomId}|${chatSecret}`))).slice(0, 16)) : ''; + const senderRaw = random(32); + const senderKey = await subtle.importKey('raw', senderRaw, 'AES-GCM', false, ['encrypt']); + let revision = 0; + let url = null; + let closed = false; + const members = new Set(); + const peers = new Map(); + const outbox = new Map(); + const hello = { t: 'hello', s: epoch, p: publicKey, a: tag }; + + function queue(id, packet) { + if (closed) return; + const bytes = encoder.encode(PREFIX + JSON.stringify(packet)); + if (bytes.length > 2028) throw new Error('Peer link envelope too large'); + outbox.set(id, encode(bytes)); + } + const aad = (sender, session, kind, recipient = '') => encoder.encode(JSON.stringify(['peer-links-v1', roomId, sender, session, kind, recipient])); + async function encrypt(key, value, additionalData) { + const iv = random(12); + const body = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv, additionalData }, key, encoder.encode(JSON.stringify(value)))); + return encode(new Uint8Array([...iv, ...body])); + } + async function decrypt(key, value, additionalData) { + const bytes = decode(value); + return JSON.parse(decoder.decode(await subtle.decrypt({ name: 'AES-GCM', iv: bytes.slice(0, 12), additionalData }, key, bytes.slice(12)))); + } + async function pairKey(id, packet) { + const remote = await subtle.importKey('raw', decode(packet.p), { name: 'ECDH', namedCurve: 'P-256' }, false, []); + const bits = new Uint8Array(await subtle.deriveBits({ name: 'ECDH', public: remote }, keys.privateKey, 256)); + const sharedChat = tag && packet.a === tag ? secret : new Uint8Array(); + const material = await subtle.importKey('raw', new Uint8Array([...bits, ...sharedChat]), 'HKDF', false, ['deriveKey']); + const participants = [[peerId, epoch], [id, packet.s]].sort((a, b) => a[0] < b[0] ? -1 : 1); + return subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: encoder.encode(roomId), info: encoder.encode(JSON.stringify(['peer-links-v1', participants])) }, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + } + function accept(id, entry, value) { + if (closed || peers.get(id) !== entry || !Number.isSafeInteger(value.r) || value.r < 0 || value.r <= entry.revision) return; + if (value.u !== null && normalizePeerUrl(value.u) !== value.u) return; + entry.revision = value.r; + onUrl(id, value.u); + } + 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 }); + } + async function receive(id, packet) { + if (closed || !members.has(id) || id === peerId || !packet || typeof packet.s !== 'string' || !/^[\w-]{22}$/.test(packet.s)) return; + if (packet.t === 'hello') { + if (typeof packet.p !== 'string' || decode(packet.p).length !== 65 || typeof packet.a !== 'string' || packet.a.length > 22) return; + const previous = peers.get(id); + if (previous?.epoch === packet.s) return; + const pair = await pairKey(id, packet); + if (closed || !members.has(id)) return; + const entry = { epoch: packet.s, pair, key: null, revision: -1, pending: null }; + peers.set(id, entry); + onUrl(id, null); + queue('hello', hello); + await sendKey(id, entry); + return; + } + const entry = peers.get(id); + if (!entry || entry.epoch !== packet.s) return; + if (packet.t === 'key' && packet.to === peerId && packet.d === epoch) { + const value = await decrypt(entry.pair, packet.c, aad(id, packet.s, 'key', `${peerId}|${epoch}`)); + 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; + await receive(id, pending); + } + } else if (packet.t === 'url') { + if (!entry.key) { entry.pending = packet; return; } + accept(id, entry, await decrypt(entry.key, packet.c, aad(id, packet.s, 'url'))); + } + } + return { + announce() { queue('hello', hello); }, + setPeers(ids) { + members.clear(); + for (const id of ids.slice(0, 50)) if (typeof id === 'string' && id.length <= 16) members.add(id); + for (const id of peers.keys()) if (!members.has(id)) { + peers.delete(id); + outbox.delete(`key:${id}`); + onUrl(id, null); + } + }, + async publish(value) { + const next = normalizePeerUrl(value); + 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')) }); + }, + receive, + nextPacket() { + const next = outbox.entries().next().value; + if (!next) return null; + outbox.delete(next[0]); + return next[1]; + }, + 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 new file mode 100644 index 0000000..d4ce7b5 --- /dev/null +++ b/extension/peer-links.test.mjs @@ -0,0 +1,125 @@ +import { Buffer } from 'node:buffer'; +import { describe, it, expect } from 'vitest'; +import { createPeerLinkSession, normalizePeerUrl, readPeerLinkPacket } from './peer-links.js'; +import { createChatEnvelope } from '../server/chat.js'; +import { decryptChatMessage, generateChatSecret } from './chat-crypto.js'; + +async function network(secrets = ['', '', '']) { + const sessions = []; + const seen = []; + const wire = []; + 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) }); + sessions.push(session); + for (const s of sessions) s.setPeers(sessions.map((_, i) => String(i + 1))); + session.announce(); + return session; + } + async function drain() { + let frames = 0; + while (sessions.some(s => s.pending)) { + for (let i = 0; i < sessions.length; i++) { + const ciphertext = sessions[i].nextPacket(); + if (!ciphertext) continue; + expect(++frames).toBeLessThan(500); + const envelope = createChatEnvelope({ ciphertext, senderId: 'spoof' }, String(i + 1)); + expect(envelope).not.toBeNull(); + const packet = readPeerLinkPacket(envelope.ciphertext); + wire.push({ id: envelope.senderId, ciphertext, packet }); + for (const session of sessions) await session.receive(envelope.senderId, packet); + } + } + } + for (const secret of secrets) await add(secret); + return { sessions, seen, wire, drain, add }; +} + +describe('encrypted room-wide peer links through the unchanged chat envelope', () => { + 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]); + for (let i = 0; i < 3; i++) await n.sessions[i].publish(`https://example.org/video/${i}`); + await n.drain(); + for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) if (i !== j) expect(n.seen[i].get(String(j + 1))).toBe(`https://example.org/video/${j}`); + await n.sessions[1].publish('https://example.org/changed#video'); + await n.drain(); + expect(n.seen[0].get('2')).toBe('https://example.org/changed#video'); + expect(n.seen[2].get('2')).toBe('https://example.org/changed#video'); + for (const frame of n.wire) { + expect(Buffer.from(frame.ciphertext, 'base64url').toString()).not.toContain('example.org'); + await expect(decryptChatMessage({ ciphertext: frame.ciphertext, roomId: 'test-room', senderId: frame.id, secret })).rejects.toThrow(); + } + }); + + it('supplies a late joiner without a selected video and clears withdrawn links', async () => { + const n = await network(['', '']); + await n.sessions[0].publish('https://example.org/first'); + await n.drain(); + await n.add(); + await n.drain(); + expect(n.seen[2].get('1')).toBe('https://example.org/first'); + await n.sessions[0].publish(null); + await n.drain(); + expect(n.seen[1].get('1')).toBeNull(); + expect(n.seen[2].get('1')).toBeNull(); + }); + + it('rejects tampering and replay, coalesces updates, and ignores departed peers', async () => { + const n = await network(['', '']); + await n.drain(); + 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'); + 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); + 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; + await expect(n.sessions[1].receive('1', { ...old.packet, c: bytes.toString('base64url') })).rejects.toThrow(); + n.sessions[1].setPeers(['2']); + await n.sessions[1].receive('1', old.packet); + expect(n.seen[1].get('1')).toBeNull(); + for (const s of n.sessions) { s.close(); s.announce(); await s.publish('https://example.org/closed'); expect(s.nextPacket()).toBeNull(); } + }); + + it('recovers a restarted peer with a new ephemeral key and refuses unrelated epochs', async () => { + const n = await network(['', '']); + await n.sessions[0].publish('https://example.org/old'); await n.drain(); + n.sessions[0].close(); + n.sessions[0] = await createPeerLinkSession({ roomId: 'test-room', peerId: '1' }); + n.sessions[0].setPeers(['1', '2']); + n.sessions[0].announce(); + await n.sessions[0].publish('https://example.org/restarted'); await n.drain(); + expect(n.seen[1].get('1')).toBe('https://example.org/restarted'); + await n.sessions[1].receive('1', { t: 'url', s: 'A'.repeat(22), c: 'invalid' }); + expect(n.seen[1].get('1')).toBe('https://example.org/restarted'); + }); + + it('holds a URL received before its encrypted sender key', async () => { + const n = await network(['', '']); + 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('https://example.org/out-of-order'); + const packets = []; + while (n.sessions[0].pending) packets.push(readPeerLinkPacket(n.sessions[0].nextPacket())); + await n.sessions[1].receive('1', packets.find(p => p.t === 'url')); + await n.sessions[1].receive('1', packets.find(p => p.t === 'key')); + expect(n.seen[1].get('1')).toBe('https://example.org/out-of-order'); + }); + + it('bounds URLs and packets; retains content-identifying query and hash', async () => { + expect(normalizePeerUrl('https://example.org/watch?v=1#/item/2')).toBe('https://example.org/watch?v=1#/item/2'); + for (const bad of [null, '', 'javascript:alert(1)', 'file:///secret', 'https://user:pass@example.org', 'https://example.org/\n', 'https://example.org/' + 'a'.repeat(901)]) expect(normalizePeerUrl(bad)).toBeNull(); + for (const bad of [null, 'a', '%%%%', 'A'.repeat(3000), Buffer.from('ordinary chat bytes'.repeat(200)).toString('base64url')]) expect(readPeerLinkPacket(bad)).toBeNull(); + const n = await network(['', '']); + await n.sessions[0].publish('https://example.org/' + 'a'.repeat(850)); await n.drain(); + expect(n.seen[1].get('1')).toHaveLength(870); + }); +}); diff --git a/extension/peer-navigation.js b/extension/peer-navigation.js new file mode 100644 index 0000000..bb89edc --- /dev/null +++ b/extension/peer-navigation.js @@ -0,0 +1,108 @@ +import { normalizePeerUrl } from './peer-links.js'; + +// The job is stored before navigation. Closing the popup or restarting the +// service worker cannot lose the user's target selection. +export function createPeerNavigator({ api, getSelection, getRoomId, select, suspend, activate, failure, now = () => Date.now() }) { + let generation = 0; + let completing = false; + let queuedCompletion = null; + let starting = null; + const key = 'pendingPeerNavigation'; + async function cancel() { + const token = ++generation; + await api.storage.session.remove(key); + return token; + } + async function complete(tabId) { + if (completing) { queuedCompletion = tabId; return; } + completing = true; + const expected = generation; + try { + const 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()) { + await cancel(); + return; + } + 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 (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; + await api.storage.session.remove(key); + if (response.status !== 'ok' && response.status !== 'superseded') await failure(tabId, response); + } catch (error) { + if (expected === generation) { + await api.storage.session.remove(key); + await failure(tabId, error); + } + } finally { + completing = false; + const pending = queuedCompletion; + queuedCompletion = null; + if (pending !== null) await complete(pending); + } + } + return { + cancel, + complete, + async resume() { + if (starting !== null) return; + const expected = generation; + const job = (await api.storage.session.get(key))[key]; + if (!job || expected !== generation) return; + if (!job.issued && job.roomId === getRoomId() && job.tabId === getSelection() + && now() - job.started <= 45000 && normalizePeerUrl(job.url)) { + try { + await api.tabs.update(job.tabId, { url: job.url, active: true }); + if (expected !== generation) return; + await api.storage.session.set({ [key]: { ...job, issued: true } }); + } catch (error) { + if (expected === generation) { + await api.storage.session.remove(key); + await failure(job.tabId, error); + } + return; + } + } + await complete(job.tabId); + }, + async navigate(rawUrl) { + const url = normalizePeerUrl(rawUrl); + if (!url || !getRoomId()) return { status: 'unavailable' }; + const expected = await cancel(); + if (expected !== generation) return { status: 'superseded' }; + const roomId = getRoomId(); + let tabId = getSelection(); + starting = expected; + try { + let tab = tabId ? await api.tabs.get(tabId).catch(() => null) : null; + if (expected !== generation || roomId !== getRoomId()) 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' }; + await select(tabId, tab.title || null); + if (expected !== generation || roomId !== getRoomId()) return { status: 'superseded' }; + await api.storage.session.set({ [key]: { tabId, roomId, started: now(), url } }); + if (expected !== generation || roomId !== getRoomId()) return { status: 'superseded' }; + await api.tabs.update(tabId, tab.url === url ? { active: true } : { url, active: true }); + if (expected !== generation || roomId !== getRoomId()) 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) { + await api.storage.session.remove(key); + if (tabId) await failure(tabId, error); + } + return { status: 'error' }; + } finally { if (starting === expected) starting = null; } + } + }; +} diff --git a/extension/peer-navigation.test.mjs b/extension/peer-navigation.test.mjs new file mode 100644 index 0000000..3cf8a1d --- /dev/null +++ b/extension/peer-navigation.test.mjs @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createPeerNavigator } from './peer-navigation.js'; + +function setup(selected = 1) { + let selection = selected; + let room = 'room'; + let time = 100; + const storage = {}; + const tabs = new Map(selected ? [[1, { id: 1, url: 'https://example.org/old', status: 'complete', title: 'Old' }]] : []); + const api = { + storage: { session: { + get: vi.fn(async () => ({ ...storage })), + set: vi.fn(async value => Object.assign(storage, value)), + remove: vi.fn(async key => { delete storage[key]; }) + } }, + tabs: { + get: vi.fn(async id => { if (!tabs.has(id)) throw new Error('Tab closed'); return { ...tabs.get(id) }; }), + create: vi.fn(async props => { const tab = { id: 2, status: 'complete', ...props }; tabs.set(2, tab); return tab; }), + update: vi.fn(async (id, props) => Object.assign(tabs.get(id), props, props.url ? { status: 'loading' } : {})) + } + }; + const activate = vi.fn(async () => ({ status: 'ok' })); + const failure = vi.fn(); + const suspend = vi.fn(); + const options = { api, getSelection: () => selection, getRoomId: () => room, select: async id => { selection = id; }, suspend, activate, failure, now: () => time }; + const navigator = createPeerNavigator(options); + return { navigator, options, api, tabs, storage, activate, failure, suspend, setRoom: v => { room = v; }, setTime: v => { time = v; }, setSelection: v => { selection = v; } }; +} + +describe('peer click navigation', () => { + 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'); + const id = selection || 2; + expect(response).toEqual({ status: 'navigating', tabId: id }); + expect(h.options.getSelection()).toBe(id); + expect(h.activate).not.toHaveBeenCalled(); + expect(h.storage.pendingPeerNavigation.tabId).toBe(id); + h.tabs.get(id).status = 'complete'; + await h.navigator.complete(id); + expect(h.activate).toHaveBeenCalledWith(id, selection ? 'Old' : null); + expect(h.storage.pendingPeerNavigation).toBeUndefined(); + expect(h.api.tabs.create).toHaveBeenCalledTimes(selection ? 0 : 1); + }); + it('does not reload an already matching URL', async () => { + const h = setup(); + await h.navigator.navigate('https://example.org/old'); + expect(h.api.tabs.update).toHaveBeenCalledWith(1, { active: true }); + expect(h.activate).toHaveBeenCalledOnce(); + }); + it('recovers after popup/worker shutdown using the stored job', async () => { + const h = setup(); + await h.navigator.navigate('https://example.org/new'); + h.tabs.get(1).status = 'complete'; + await createPeerNavigator(h.options).resume(); + expect(h.activate).toHaveBeenCalledOnce(); + }); + it('creates a target when a previously selected tab disappeared', async () => { + const h = setup(); h.tabs.clear(); + expect(await h.navigator.navigate('https://example.org/new')).toMatchObject({ tabId: 2 }); + }); + it('resumes a worker stopped between saving the job and issuing navigation', async () => { + const h = setup(); + h.storage.pendingPeerNavigation = { tabId: 1, roomId: 'room', started: 100, url: 'https://example.org/new' }; + await h.navigator.resume(); + expect(h.api.tabs.update).toHaveBeenCalledWith(1, { url: 'https://example.org/new', active: true }); + expect(h.activate).not.toHaveBeenCalled(); + h.tabs.get(1).status = 'complete'; + await h.navigator.resume(); + expect(h.activate).toHaveBeenCalledOnce(); + }); + it.each(['room', 'selection', 'cancel'])('does not activate a superseded navigation: %s', async change => { + const h = setup(); await h.navigator.navigate('https://example.org/new'); + if (change === 'room') h.setRoom('other'); + if (change === 'selection') h.setSelection(3); + if (change === 'cancel') await h.navigator.cancel(); + h.tabs.get(1).status = 'complete'; await h.navigator.complete(1); + expect(h.activate).not.toHaveBeenCalled(); + }); + it('bounds load time and reports a closed or forbidden destination', async () => { + for (const mode of ['timeout', 'closed', 'forbidden']) { + const h = setup(); await h.navigator.navigate('https://example.org/new'); + if (mode === 'timeout') h.setTime(50000); + if (mode === 'closed') h.tabs.clear(); + if (mode === 'forbidden') Object.assign(h.tabs.get(1), { status: 'complete', url: 'chrome://settings' }); + await h.navigator.complete(1); + expect(h.failure).toHaveBeenCalledOnce(); + expect(h.storage.pendingPeerNavigation).toBeUndefined(); + } + }); + it('retains access errors for the existing site-access flow', async () => { + const h = setup(); h.activate.mockResolvedValue({ status: 'host_permission_required' }); + await h.navigator.navigate('https://example.org/old'); + expect(h.failure).toHaveBeenCalledWith(1, { status: 'host_permission_required' }); + }); + it('rejects unsafe links and does nothing outside a room', async () => { + const h = setup(); + expect(await h.navigator.navigate('javascript:alert(1)')).toEqual({ status: 'unavailable' }); + h.setRoom(null); + expect(await h.navigator.navigate('https://example.org/new')).toEqual({ status: 'unavailable' }); + expect(h.suspend).not.toHaveBeenCalled(); + }); + it('allows only the latest simultaneous click to navigate', async () => { + const h = setup(); + const results = await Promise.all([h.navigator.navigate('https://example.org/a'), h.navigator.navigate('https://example.org/b')]); + expect(results[0].status).toBe('superseded'); + expect(h.api.tabs.update).toHaveBeenCalledTimes(1); + expect(h.tabs.get(1).url).toBe('https://example.org/b'); + }); + it('does not save an obsolete job when the room changes while selection is being saved', async () => { + const h = setup(); + const navigator = createPeerNavigator({ ...h.options, select: async () => { h.setRoom('other'); } }); + expect(await navigator.navigate('https://example.org/new')).toMatchObject({ status: 'superseded' }); + expect(h.storage.pendingPeerNavigation).toBeUndefined(); + expect(h.api.tabs.update).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/popup.html b/extension/popup.html index a35fe54..ec5b4c0 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -1682,6 +1682,13 @@
Privacy
+
+ + +