mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-09-10 01:17:01 +00:00
feat(extension): add encrypted peer links and fix initial chat activation
This commit is contained in:
+142
-15
@@ -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(() => {});
|
||||
});
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "動画リンクが利用できないか、移動に失敗しました。"
|
||||
}
|
||||
|
||||
@@ -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": "동영상 링크를 사용할 수 없거나 이동하지 못했습니다."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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ę."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "Ссылка недоступна или переход не удался."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "Посилання недоступне або перехід не вдався."
|
||||
}
|
||||
|
||||
@@ -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": "视频链接不可用或跳转失败。"
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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; }
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 }, () => {
|
||||
|
||||
@@ -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'
|
||||
]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user