feat(extension): add encrypted peer links and fix initial chat activation

This commit is contained in:
Timo
2026-09-09 01:51:36 +02:00
parent 358de748b5
commit 2b8bbf2316
24 changed files with 864 additions and 32 deletions
+142 -15
View File
@@ -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(() => {});
});
+5 -1
View File
@@ -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."
}
+5 -1
View File
@@ -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 participants video",
"PEER_LINK_UNAVAILABLE": "Video link unavailable or navigation failed."
}
+6 -2
View File
@@ -316,5 +316,9 @@
"DEBUG_YES": "Sí",
"DEBUG_NO": "No",
"DEBUG_YES_CHECKED": "Sí (comprobado)",
"DEBUG_NA": "n/d"
}
"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."
}
+5 -1
View File
@@ -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 lURL de longlet 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."
}
+5 -1
View File
@@ -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 lURL 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."
}
+5 -1
View File
@@ -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": "動画リンクが利用できないか、移動に失敗しました。"
}
+5 -1
View File
@@ -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": "동영상 링크를 사용할 수 없거나 이동하지 못했습니다."
}
+5 -1
View File
@@ -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."
}
+5 -1
View File
@@ -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ę."
}
+5 -1
View File
@@ -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."
}
+5 -1
View File
@@ -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."
}
+5 -1
View File
@@ -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": "Ссылка недоступна или переход не удался."
}
+5 -1
View File
@@ -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."
}
+5 -1
View File
@@ -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": "Посилання недоступне або перехід не вдався."
}
+5 -1
View File
@@ -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": "视频链接不可用或跳转失败。"
}
+148
View File
@@ -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); }
};
}
+125
View File
@@ -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);
});
});
+108
View File
@@ -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; }
}
};
}
+117
View File
@@ -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();
});
});
+7
View File
@@ -1682,6 +1682,13 @@
<details class="form-group" name="settings-accordion">
<summary title="Choose which titles are sent to the room." data-i18n="LABEL_PRIVACY_SETTINGS" data-i18n-title="LABEL_PRIVACY_SETTINGS_TOOLTIP">Privacy</summary>
<div class="details-content">
<div class="settings-row">
<label for="shareVideoUrl" data-i18n="LABEL_SHARE_VIDEO_URL" data-i18n-title="LABEL_SHARE_VIDEO_URL_TOOLTIP">Share video link</label>
<label class="toggle-switch">
<input type="checkbox" id="shareVideoUrl">
<span class="slider"></span>
</label>
</div>
<div class="settings-row">
<label for="sendTabTitle" title="Send the selected browser tab title to the room." data-i18n="LABEL_SEND_TAB_TITLE" data-i18n-title="LABEL_SEND_TAB_TITLE_TOOLTIP">Send tab title</label>
<label class="toggle-switch">
+34 -1
View File
@@ -1,3 +1,4 @@
import { normalizePeerUrl } from './peer-links.js';
import { EVENTS, OFFICIAL_LANDING_PAGE_URL, SUPPORT_URL, getReviewUrl } from './shared/constants.js';
import {
BLACKLIST_OVERRIDES_STORAGE_KEY,
@@ -94,6 +95,7 @@ const elements = {
chatStartMode: document.getElementById('chatStartMode'),
chatReactionDisplay: document.getElementById('chatReactionDisplay'),
sendTabTitle: document.getElementById('sendTabTitle'),
shareVideoUrl: document.getElementById('shareVideoUrl'),
mediaTitlePrivacyMode: document.getElementById('mediaTitlePrivacyMode'),
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
lobbyTitle: document.getElementById('lobbyTitle'),
@@ -362,7 +364,7 @@ function setRoomRefreshCooldown() {
async function init() {
// Local-only by design — settings and room credentials never come from
// storage.sync (only onboardingComplete + dismissedHints live there).
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'shareVideoUrl', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
let activeLang = localData.locale;
if (!activeLang) {
@@ -403,6 +405,7 @@ async function init() {
syncChatSettingsState();
const legacyTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.titlePrivacyMode) ? localData.titlePrivacyMode : TITLE_PRIVACY_MODES.FULL;
const mediaTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.mediaTitlePrivacyMode) ? localData.mediaTitlePrivacyMode : legacyTitlePrivacyMode;
if (elements.shareVideoUrl) elements.shareVideoUrl.checked = localData.shareVideoUrl === true;
if (elements.sendTabTitle) elements.sendTabTitle.checked = normalizeSendTabTitle(localData.sendTabTitle, legacyTitlePrivacyMode);
if (elements.mediaTitlePrivacyMode) elements.mediaTitlePrivacyMode.value = mediaTitlePrivacyMode;
if (elements.forceSyncMode) elements.forceSyncMode.value = localData.forceSyncMode || 'jump-to-others';
@@ -933,6 +936,7 @@ function updatePeerList(peers) {
id: p.peerId,
user: p.username,
tab: p.tabTitle,
url: p.tabUrl,
media: p.mediaTitle,
state: p.playbackState,
vol: p.volume,
@@ -979,6 +983,29 @@ function updatePeerList(peers) {
nameSpan.textContent = `${avatar} ${pId}`;
}
const peerUrl = normalizePeerUrl(p.tabUrl);
if (peerUrl && pId !== localPeerId) {
nameSpan.setAttribute('role', 'button');
nameSpan.tabIndex = 0;
nameSpan.title = `${getMessage('PEER_LINK_OPEN')}: ${peerUrl}`;
nameSpan.setAttribute('aria-label', `${getMessage('PEER_LINK_OPEN')}: ${pUsername || pId}`);
nameSpan.style.cursor = 'pointer';
nameSpan.style.textDecoration = 'underline dotted';
let opening = false;
const openPeer = async () => {
if (opening) return;
opening = true;
try {
const response = await chrome.runtime.sendMessage({ type: 'NAVIGATE_TO_PEER', peerId: pId });
if (!['ok', 'navigating', 'superseded'].includes(response?.status)) showToast(getMessage('PEER_LINK_UNAVAILABLE'), 'error');
} catch (_) { showToast(getMessage('PEER_LINK_UNAVAILABLE'), 'error'); }
finally { opening = false; }
};
nameSpan.addEventListener('click', openPeer);
nameSpan.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openPeer(); }
});
}
header.appendChild(nameSpan);
// Right-side badges + actions, kept in one group so they sit together
@@ -1622,6 +1649,12 @@ if (elements.chatReactionDisplay) {
});
}
if (elements.shareVideoUrl) {
elements.shareVideoUrl.addEventListener('change', () => {
chrome.storage.local.set({ shareVideoUrl: elements.shareVideoUrl.checked });
});
}
if (elements.sendTabTitle) {
elements.sendTabTitle.addEventListener('change', () => {
chrome.storage.local.set({ sendTabTitle: elements.sendTabTitle.checked }, () => {
+2
View File
@@ -17,6 +17,8 @@ export const VITEST_COVERAGE_INCLUDE = Object.freeze([
'extension/media-frame-target.js',
'extension/offline-media-intent.js',
'extension/title-privacy.js',
'extension/peer-links.js',
'extension/peer-navigation.js',
'scripts/release-artifact-checks.mjs'
]);
+105
View File
@@ -0,0 +1,105 @@
import { expect, launchExtensionContext, test, terminateServiceWorker } from './helpers/extension-fixture.mjs';
import { reservePort, startRelay, stopRelay } from './helpers/relay-process.mjs';
async function control(context, extensionId) {
const page = await context.newPage();
await page.goto(`chrome-extension://${extensionId}/audio-options.html`);
return page;
}
const status = page => page.evaluate(() => chrome.runtime.sendMessage({ type: 'GET_STATUS' }));
async function connect(page, serverUrl, username, chatKey = '') {
await page.evaluate(async settings => {
await chrome.storage.sync.set({ onboardingComplete: true });
await chrome.storage.local.set({ ...settings, roomId: 'peer-links-e2e', password: '', useCustomServer: true, shareVideoUrl: true, chatEnabled: !!settings.chatKey, chatStartMode: 'open', locale: 'en' });
await chrome.runtime.sendMessage({ type: 'CONNECT' });
}, { serverUrl, username, chatKey });
await expect.poll(() => status(page)).toMatchObject({ status: 'connected' });
}
async function select(page, url) {
return page.evaluate(async target => {
const [tab] = await chrome.tabs.query({ url: target });
return chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId: tab.id, tabTitle: tab.title });
}, url);
}
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 = [];
let relay;
try {
const second = await launchExtensionContext(); others.push(second);
const third = await launchExtensionContext(); others.push(third);
const pages = await Promise.all([control(context, extensionId), control(second.context, second.extensionId), control(third.context, third.extensionId)]);
const port = await reservePort(); relay = await startRelay(port);
const relayUrl = `ws://127.0.0.1:${port}`;
const chatKey = 'AQEBAQEBAQEBAQEBAQEBAQ';
await connect(pages[0], relayUrl, 'Alice', chatKey);
await connect(pages[1], relayUrl, 'Bob', chatKey);
const aUrl = `${baseURL}/pages/simple-player.html?peer=alice`;
const bUrl = `${baseURL}/pages/iframe-player.html?peer=bob`;
const a = await context.newPage(); const b = await second.context.newPage();
await a.goto(aUrl); await b.goto(bUrl);
expect(await select(pages[0], aUrl)).toMatchObject({ status: 'ok' });
expect(await select(pages[1], bUrl)).toMatchObject({ status: 'ok' });
// No reload or repeated selection: both top-level and iframe players.
await expect(a.locator('#koalasync-chat-overlay-host')).toBeVisible();
await expect(b.locator('#koalasync-chat-overlay-host')).toBeVisible();
await expect(b.locator('#koalasync-chat-overlay-host textarea')).toBeEnabled();
await b.locator('#koalasync-chat-overlay-host textarea').fill('First-selection chat');
await b.locator('#koalasync-chat-overlay-host textarea').press('Enter');
await expect(a.getByText('First-selection chat', { exact: true })).toBeVisible();
// Late manual join, chat disabled, no target selected.
await connect(pages[2], relayUrl, 'Charlie');
await expect.poll(async () => (await status(pages[2])).peers.filter(p => p.tabUrl).length, { timeout: 25000 }).toBe(2);
const aliceId = (await status(pages[0])).peerId;
const bobId = (await status(pages[1])).peerId;
expect((await status(pages[2])).targetTabId).toBeNull();
const popup = await third.context.newPage();
await popup.goto(`chrome-extension://${third.extensionId}/popup.html`);
await popup.locator('#tab-sync-button').click();
const newPageEvent = third.context.waitForEvent('page');
await popup.locator('#peerListSync [role="button"]').filter({ hasText: 'Alice' }).click();
const created = await newPageEvent;
await expect(created).toHaveURL(aUrl);
await expect.poll(() => status(pages[2])).toMatchObject({ targetReady: true });
const selectedId = (await status(pages[2])).targetTabId;
// A later click reuses that selected tab, not the popup/active tab.
await popup.bringToFront();
await popup.locator('#peerListSync [role="button"]').filter({ hasText: 'Bob' }).press('Enter');
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);
// Same-document URL changes are published without reloading the video.
const updated = `${aUrl}#episode-two`;
await a.evaluate(url => history.pushState({}, '', url), updated);
await expect.poll(async () => (await status(pages[1])).peers.find(p => p.peerId === aliceId)?.tabUrl, { timeout: 20000 }).toBe(updated);
await expect.poll(async () => (await status(pages[2])).peers.find(p => p.peerId === aliceId)?.tabUrl).toBe(updated);
await pages[1].evaluate(() => chrome.storage.local.set({ shareVideoUrl: false }));
await expect.poll(async () => (await status(pages[2])).peers.find(p => p.peerId === bobId)?.tabUrl, { timeout: 20000 }).toBeNull();
// Stop the worker while the destination is still loading. Its pending
// navigation and selection must survive independently of the popup.
let releaseNavigation;
let navigationReached;
const navigationGate = new Promise(resolve => { releaseNavigation = resolve; });
const reached = new Promise(resolve => { navigationReached = resolve; });
await third.context.route(aUrl, async route => {
navigationReached();
await navigationGate;
await route.continue();
});
await pages[2].evaluate(id => chrome.runtime.sendMessage({ type: 'NAVIGATE_TO_PEER', peerId: id }), aliceId);
await reached;
await terminateServiceWorker(third.context, third.extensionId);
releaseNavigation();
await expect(created).toHaveURL(updated);
await expect.poll(() => status(pages[2]), { timeout: 30000 }).toMatchObject({ targetTabId: selectedId, targetReady: true });
await expect.poll(async () => (await status(pages[2])).peers.find(p => p.peerId === aliceId)?.tabUrl, { timeout: 30000 }).toBe(updated);
} finally {
for (const other of others) await other.close();
await stopRelay(relay);
}
});