feat(chat): add client encryption and key lifecycle

This commit is contained in:
KoalaDev
2026-07-15 07:10:32 +02:00
parent f2eee6dc41
commit ee0d68d8d3
7 changed files with 371 additions and 61 deletions
+106 -9
View File
@@ -4,6 +4,8 @@ import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js';
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 './page-api-seek-overrides.js';
// --- Uninstall URL Initialization ---
@@ -79,6 +81,7 @@ let hostPeerId = null; // peerId of the room host (creator /
// Features the connected relay advertises in ROOM_DATA. Empty against an older
// relay (no capabilities field) → host-control UI/behavior stays unavailable.
let serverCapabilities = [];
let chatSecretGuard = '';
function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); }
// Local peer's desync state (content.js reports it via HCM_DESYNC_STATE). Relayed
// in heartbeats so the host's popup UI can show "Solo" instead of silently
@@ -364,7 +367,7 @@ async function getSettings() {
// (username) must NEVER come from storage.sync — syncing them across devices
// both leaks them and resurrects dead rooms on reinstall (a fresh install
// has empty local storage but sync survives in the user's Google account).
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']);
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']);
let username = data.username;
if (!username) {
username = generateUsername();
@@ -372,11 +375,14 @@ async function getSettings() {
}
const legacyTitlePrivacyMode = normalizeTitlePrivacyMode(data.titlePrivacyMode);
const mediaTitlePrivacyMode = normalizeTitlePrivacyMode(data.mediaTitlePrivacyMode || legacyTitlePrivacyMode);
const chatKey = validateChatSecret(data.chatKey);
chatSecretGuard = chatKey;
return {
serverUrl: data.serverUrl || '',
useCustomServer: data.useCustomServer || false,
roomId: data.roomId || '',
password: data.password || '',
chatKey,
username,
sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode),
mediaTitlePrivacyMode
@@ -412,7 +418,7 @@ function emitEpisodeLobbyForCurrentPrivacy() {
// removes legacy keys that older versions wrote to sync (and that would
// otherwise be redistributed across devices and resurrected on reinstall).
const LEGACY_SYNC_KEYS = [
'serverUrl', 'useCustomServer', 'roomId', 'password', 'username',
'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username',
'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode',
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings',
'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode'
@@ -558,7 +564,9 @@ async function leaveRoomAfterIdleGrace(reason) {
episodeLobby: null,
hcmDesynced: false
}).catch(() => {});
await chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {});
chatSecretGuard = '';
clearChatKeyCache();
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
addLog(reason, 'info');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
updateBadgeStatus();
@@ -686,7 +694,7 @@ async function connect() {
try {
const payload = JSON.parse(msg.substring(2));
try {
handleServerEvent(payload[0], payload[1]);
await handleServerEvent(payload[0], payload[1]);
} catch (handlerErr) {
addLog(`Handler error for ${payload[0]}: ${handlerErr.message}`, 'error');
}
@@ -880,10 +888,14 @@ function scheduleReconnect() {
function emit(event, data) {
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
const msg = `42${JSON.stringify([event, data])}`;
try {
const msg = encodeSocketEvent(event, data, chatSecretGuard);
socket.send(msg);
} catch (e) {
if (e.message === 'Refusing to send chat secret to relay') {
addLog(e.message, 'error');
return;
}
// The socket can close between the readyState check and send()
// (race with a server-side disconnect). Re-queue so the event is
// retried on the next successful (re)connect instead of being lost.
@@ -895,6 +907,17 @@ function emit(event, data) {
}
}
function emitLive(event, data) {
if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) return false;
try {
socket.send(encodeSocketEvent(event, data, chatSecretGuard));
return true;
} catch (e) {
addLog(e.message === 'Refusing to send chat secret to relay' ? e.message : `Live send failed for ${event}: ${e.message}`, 'error');
return false;
}
}
function queueEvent(event, data) {
eventQueue.push({ event, data });
if (eventQueue.length > 50) {
@@ -1000,7 +1023,7 @@ function stopPing() {
}
// --- Event Handlers ---
function handleServerEvent(event, data) {
async function handleServerEvent(event, data) {
if (!data) {
addLog(`Ignored server event ${event} due to empty payload`, 'warn');
return;
@@ -1103,6 +1126,38 @@ function handleServerEvent(event, data) {
case EVENTS.ROOM_LIST:
chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {});
break;
case EVENTS.CHAT_MESSAGE: {
if (!currentRoom || !serverSupports(CAPABILITIES.CHAT) || !currentTabId) break;
const settings = await getSettings();
if (!settings.chatKey) break;
try {
const text = await decryptChatMessage({
ciphertext: data.ciphertext,
roomId: currentRoom.roomId,
senderId: data.senderId,
secret: settings.chatKey
});
const senderPeer = currentRoom.peers?.find(candidate =>
(typeof candidate === 'object' ? candidate.peerId : candidate) === data.senderId
);
const tabId = Number(currentTabId);
if (Number.isInteger(tabId)) {
chrome.tabs.sendMessage(tabId, {
type: 'CHAT_MESSAGE',
message: {
id: data.id,
senderId: data.senderId,
username: typeof senderPeer === 'object' ? senderPeer.username : null,
timestamp: data.timestamp,
text
}
}).catch(() => {});
}
} catch (_) {
addLog('Discarded chat message that failed authentication', 'warn');
}
break;
}
case EVENTS.ERROR:
isConnecting = false;
// If we get a server error before successfully joining a room,
@@ -1851,6 +1906,7 @@ function leaveOldRoomIfSwitching(newRoomId) {
hostPeerId = null;
controllers = [];
serverCapabilities = [];
clearChatKeyCache();
hcmDesynced = false;
// Notify content.js/popup so they drop any guest-side HCM state from the
// previous room (badge/dialog/desync) — H-2/H-3.
@@ -1973,8 +2029,43 @@ async function handleAsyncMessage(message, sender, sendResponse) {
amHost: amHost(),
amController: amController(),
hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL),
coHostSupported: serverSupports(CAPABILITIES.CO_HOST)
coHostSupported: serverSupports(CAPABILITIES.CO_HOST),
chatSupported: serverSupports(CAPABILITIES.CHAT),
hasChatKey: !!(await getSettings()).chatKey
});
} else if (message.type === 'CHAT_SEND') {
const senderTabId = sender.tab?.id;
if (!currentRoom || !currentTabId || senderTabId !== Number(currentTabId)) {
sendResponse({ status: 'invalid_tab' });
return;
}
if (!serverSupports(CAPABILITIES.CHAT)) {
sendResponse({ status: 'unsupported' });
return;
}
const settings = await getSettings();
if (!settings.chatKey) {
sendResponse({ status: 'missing_key' });
return;
}
try {
const ciphertext = await encryptChatMessage({
text: message.text,
roomId: currentRoom.roomId,
senderId: peerId,
secret: settings.chatKey
});
const sent = emitLive(EVENTS.CHAT_MESSAGE, buildChatRelayPayload(ciphertext));
sendResponse({ status: sent ? 'ok' : 'disconnected' });
} catch (err) {
sendResponse({ status: err instanceof RangeError ? 'too_long' : 'invalid_message' });
}
} else if (message.type === 'CREATE_CHAT_KEY') {
const chatKey = generateChatSecret();
chatSecretGuard = chatKey;
clearChatKeyCache();
await chrome.storage.local.set({ chatKey });
sendResponse({ status: 'ok', chatKey });
} else if (message.type === 'SET_CONTROL_MODE') {
// Popup (host) toggles the room control mode. Server validates host authority
// and broadcasts CONTROL_MODE back, which updates our local state + UI.
@@ -2087,7 +2178,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
expectedAcksCount: 0,
hcmDesynced: false
});
chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {});
chatSecretGuard = '';
clearChatKeyCache();
chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
addLog('Left Room', 'info');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
forceDisconnect();
@@ -2103,8 +2196,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
emit(EVENTS.GET_ROOMS, {});
sendResponse({ status: 'ok' });
} else if (message.type === 'WEB_JOIN_REQUEST') {
const { roomId: rawRoomId, password, useCustomServer, serverUrl } = message;
const { roomId: rawRoomId, password, chatKey: rawChatKey, useCustomServer, serverUrl } = message;
const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : '';
const chatKey = validateChatSecret(rawChatKey);
if (!roomId) {
const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' };
chrome.runtime.sendMessage(errMsg).catch(() => {});
@@ -2118,9 +2212,12 @@ async function handleAsyncMessage(message, sender, sendResponse) {
chrome.storage.local.set({
roomId,
password,
chatKey,
useCustomServer: !!useCustomServer,
serverUrl: serverUrl || ''
}, async () => {
chatSecretGuard = chatKey;
clearChatKeyCache();
const settings = await getSettings();
const desiredUrl = resolveServerUrl(settings);
+112
View File
@@ -0,0 +1,112 @@
const CHAT_INFO = 'KoalaSync E2E Chat v1';
const SECRET_BYTES = 16;
const IV_BYTES = 12;
const MIN_ENCRYPTED_BYTES = 29;
const MAX_ENCRYPTED_BYTES = 2028;
let cachedKey = null;
let cachedRoomId = '';
let cachedSecret = '';
function bytesToBase64Url(bytes) {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return globalThis.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function base64UrlToBytes(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return null;
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
try {
const binary = globalThis.atob(padded);
const bytes = Uint8Array.from(binary, char => char.charCodeAt(0));
return bytesToBase64Url(bytes) === value ? bytes : null;
} catch (_) {
return null;
}
}
export function generateChatSecret(cryptoImpl = globalThis.crypto) {
const bytes = new Uint8Array(SECRET_BYTES);
cryptoImpl.getRandomValues(bytes);
return bytesToBase64Url(bytes);
}
export function validateChatSecret(value) {
const bytes = base64UrlToBytes(value);
return bytes?.length === SECRET_BYTES ? value : '';
}
export function countCodePoints(value) {
return [...String(value ?? '')].length;
}
export function normalizeOutgoingChatText(value) {
if (typeof value !== 'string') throw new TypeError('Chat message must be text');
const text = value.trim();
if (!text) throw new TypeError('Chat message must not be empty');
if (countCodePoints(text) > 500) throw new RangeError('Chat message exceeds 500 Unicode code points');
return text;
}
export function clearChatKeyCache() {
cachedKey = null;
cachedRoomId = '';
cachedSecret = '';
}
export async function deriveChatKey(roomId, secret, cryptoImpl = globalThis.crypto) {
const validSecret = validateChatSecret(secret);
if (!roomId || !validSecret) throw new TypeError('Valid roomId and chat secret are required');
if (cachedKey && cachedRoomId === roomId && cachedSecret === validSecret) return cachedKey;
const encoder = new globalThis.TextEncoder();
const material = await cryptoImpl.subtle.importKey(
'raw', base64UrlToBytes(validSecret), { name: 'HKDF' }, false, ['deriveKey']
);
cachedKey = await cryptoImpl.subtle.deriveKey({
name: 'HKDF',
hash: 'SHA-256',
salt: encoder.encode(roomId),
info: encoder.encode(CHAT_INFO)
}, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
cachedRoomId = roomId;
cachedSecret = validSecret;
return cachedKey;
}
export async function encryptChatMessage({ text, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) {
const plaintext = normalizeOutgoingChatText(text);
if (!senderId) throw new TypeError('senderId is required');
const key = await deriveChatKey(roomId, secret, cryptoImpl);
const encoder = new globalThis.TextEncoder();
const iv = new Uint8Array(IV_BYTES);
cryptoImpl.getRandomValues(iv);
const encrypted = new Uint8Array(await cryptoImpl.subtle.encrypt({
name: 'AES-GCM',
iv,
additionalData: encoder.encode(`${roomId}|${senderId}`)
}, key, encoder.encode(plaintext)));
const envelope = new Uint8Array(iv.length + encrypted.length);
envelope.set(iv);
envelope.set(encrypted, iv.length);
return bytesToBase64Url(envelope);
}
export async function decryptChatMessage({ ciphertext, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) {
if (!senderId) throw new TypeError('senderId is required');
const bytes = base64UrlToBytes(ciphertext);
if (!bytes || bytes.length < MIN_ENCRYPTED_BYTES || bytes.length > MAX_ENCRYPTED_BYTES) {
throw new TypeError('Invalid chat ciphertext');
}
const key = await deriveChatKey(roomId, secret, cryptoImpl);
const encoder = new globalThis.TextEncoder();
const plaintext = await cryptoImpl.subtle.decrypt({
name: 'AES-GCM',
iv: bytes.slice(0, IV_BYTES),
additionalData: encoder.encode(`${roomId}|${senderId}`)
}, key, bytes.slice(IV_BYTES));
const text = new globalThis.TextDecoder().decode(plaintext);
if (!text || countCodePoints(text) > 500) throw new RangeError('Decrypted chat message exceeds client limits');
return text;
}
+64
View File
@@ -0,0 +1,64 @@
import { webcrypto } from 'node:crypto';
import { Buffer } from 'node:buffer';
import { afterEach, describe, expect, it } from 'vitest';
import {
clearChatKeyCache,
countCodePoints,
decryptChatMessage,
deriveChatKey,
encryptChatMessage,
generateChatSecret,
normalizeOutgoingChatText,
validateChatSecret
} from './chat-crypto.js';
afterEach(clearChatKeyCache);
describe('chat crypto', () => {
it('generates canonical 128-bit secrets', () => {
const secret = generateChatSecret(webcrypto);
expect(secret).toMatch(/^[A-Za-z0-9_-]{22}$/);
expect(validateChatSecret(secret)).toBe(secret);
expect(validateChatSecret(`${secret}=`)).toBe('');
});
it('round-trips Unicode with a cached room key and fresh IVs', async () => {
const secret = generateChatSecret(webcrypto);
const firstKey = await deriveChatKey('ROOM-1', secret, webcrypto);
const secondKey = await deriveChatKey('ROOM-1', secret, webcrypto);
expect(secondKey).toBe(firstKey);
const input = { text: 'Hello 🐨 **world**', roomId: 'ROOM-1', senderId: 'alice', secret };
const first = await encryptChatMessage(input, webcrypto);
const second = await encryptChatMessage(input, webcrypto);
expect(second).not.toBe(first);
await expect(decryptChatMessage({ ciphertext: first, ...input }, webcrypto)).resolves.toBe(input.text);
});
it('rejects relabeling, cross-room replay, and the wrong secret', async () => {
const secret = generateChatSecret(webcrypto);
const ciphertext = await encryptChatMessage({ text: 'secret', roomId: 'ROOM-1', senderId: 'alice', secret }, webcrypto);
clearChatKeyCache();
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'bob', secret }, webcrypto)).rejects.toThrow();
clearChatKeyCache();
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-2', senderId: 'alice', secret }, webcrypto)).rejects.toThrow();
clearChatKeyCache();
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'alice', secret: generateChatSecret(webcrypto) }, webcrypto)).rejects.toThrow();
});
it('enforces 500 Unicode code points before encryption', () => {
expect(countCodePoints('😀'.repeat(500))).toBe(500);
expect(normalizeOutgoingChatText(` ${'😀'.repeat(500)} `)).toBe('😀'.repeat(500));
expect(() => normalizeOutgoingChatText('😀'.repeat(501))).toThrow(RangeError);
expect(() => normalizeOutgoingChatText(' ')).toThrow(TypeError);
});
it('keeps the worst-case 500-codepoint payload within the relay byte bound', async () => {
const secret = generateChatSecret(webcrypto);
const ciphertext = await encryptChatMessage({
text: '😀'.repeat(500), roomId: 'ROOM-1', senderId: 'alice', secret
}, webcrypto);
const bytes = Buffer.from(ciphertext, 'base64url');
expect(bytes).toHaveLength(2028);
});
});
+11
View File
@@ -0,0 +1,11 @@
export function buildChatRelayPayload(ciphertext) {
return { ciphertext };
}
export function encodeSocketEvent(event, data, forbiddenSecret = '') {
const payload = JSON.stringify([event, data]);
if (forbiddenSecret && payload.includes(forbiddenSecret)) {
throw new Error('Refusing to send chat secret to relay');
}
return `42${payload}`;
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js';
describe('chat wire boundary', () => {
const secret = 'R5Ti1nxp0crfAFHf3gVncw';
it('sends only ciphertext for chat messages', () => {
expect(buildChatRelayPayload('ciphertext')).toEqual({ ciphertext: 'ciphertext' });
});
it('rejects the secret anywhere in any outgoing relay event', () => {
for (const [event, payload] of [
['join_room', { roomId: 'ROOM', password: 'PASS', chatKey: secret }],
['peer_status', { status: 'heartbeat', nested: { secret } }],
['chat_message', { ciphertext: `prefix-${secret}-suffix` }]
]) {
expect(() => encodeSocketEvent(event, payload, secret)).toThrow('Refusing to send chat secret');
}
expect(encodeSocketEvent('join_room', { roomId: 'ROOM', password: 'PASS' }, secret)).not.toContain(secret);
});
});
+54 -51
View File
@@ -3,7 +3,14 @@ import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js';
import './shared/invite-links.js';
let pendingInviteRoomId = '';
let pendingInviteChatKey = '';
function normalizeChatKey(value) {
return typeof value === 'string' && /^[A-Za-z0-9_-]{21}[AQgw]$/.test(value) ? value : '';
}
const elements = {
tabs: document.querySelectorAll('.tabs .tab-btn'),
@@ -324,7 +331,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', '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', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
let activeLang = localData.locale;
if (!activeLang) {
@@ -380,7 +387,7 @@ async function init() {
}
toggleUIState(!!localData.roomId);
updateUI(localData.roomId, localData.password, localData.useCustomServer, localData.serverUrl);
updateUI(localData.roomId, localData.password, localData.useCustomServer, localData.serverUrl, localData.chatKey);
refreshLogs();
refreshHistory();
@@ -552,15 +559,20 @@ if (elements.hostControlToggle) {
});
}
function updateUI(roomId, password, useCustomServer = false, serverUrl = '') {
function updateUI(roomId, password, useCustomServer = false, serverUrl = '', chatKey = '') {
const inRoom = !!roomId;
toggleUIState(inRoom);
if (inRoom) {
let invite = `${OFFICIAL_LANDING_PAGE_URL}/join.html#join:${roomId}:${password}`;
if (useCustomServer) {
const encodedUrl = encodeURIComponent(serverUrl || '');
invite += `:1:${encodedUrl}`;
}
const validChatKey = normalizeChatKey(chatKey);
let invite = validChatKey
? `${OFFICIAL_LANDING_PAGE_URL}/join.html${globalThis.KoalaSyncInviteLinks.buildJ2Hash({
roomId,
password,
chatKey: validChatKey,
serverUrl: useCustomServer ? serverUrl : ''
})}`
: `${OFFICIAL_LANDING_PAGE_URL}/join.html#join:${roomId}:${password}`;
if (!validChatKey && useCustomServer) invite += `:1:${encodeURIComponent(serverUrl || '')}`;
elements.inviteLink.value = invite;
if (window.justCreatedRoom) {
@@ -1289,41 +1301,21 @@ function updateRoomList(rooms) {
function checkInviteLink() {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tab = tabs[0];
if (tab && tab.url && tab.url.startsWith(OFFICIAL_LANDING_PAGE_URL + '/') && tab.url.includes('#join:')) {
if (tab && tab.url && tab.url.startsWith(OFFICIAL_LANDING_PAGE_URL + '/')) {
try {
const rawHash = tab.url.split('#join:')[1];
if (!rawHash) return;
const parts = rawHash.split(':');
if (parts.length >= 2) {
const roomId = parts.shift();
let useCustomServer = false;
let serverUrl = '';
const invite = globalThis.KoalaSyncInviteLinks.parseInviteHash(new URL(tab.url).hash);
if (!invite) return;
elements.roomId.value = invite.roomId;
elements.password.value = invite.password;
pendingInviteRoomId = invite.roomId;
pendingInviteChatKey = normalizeChatKey(invite.chatKey);
const last = parts[parts.length - 1];
const secondToLast = parts[parts.length - 2];
const decodedLast = decodeURIComponent(last || '');
const isCustom = secondToLast === '1' && (decodedLast.startsWith('ws://') || decodedLast.startsWith('wss://'));
const isOfficial = secondToLast === '0' && last === '';
elements.serverUrl.value = invite.serverUrl;
setServerMode(invite.useCustomServer);
chrome.storage.local.set({ serverUrl: invite.serverUrl, useCustomServer: invite.useCustomServer });
if (parts.length >= 3 && (isCustom || isOfficial)) {
serverUrl = decodeURIComponent(parts.pop());
useCustomServer = parts.pop() === '1';
}
const password = parts.join(':');
elements.roomId.value = roomId;
elements.password.value = password;
if (serverUrl || useCustomServer) {
elements.serverUrl.value = serverUrl;
setServerMode(useCustomServer);
chrome.storage.local.set({ serverUrl, useCustomServer });
}
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000);
}
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000);
} catch (_e) {
// Malformed invite link, ignore
}
@@ -1468,15 +1460,15 @@ if (elements.langSelector) {
lastKnownPeers = res.peers || [];
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
const data = await chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl']);
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
await populateTabs(res.peers, res.targetTabId);
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
} else {
applyConnectionStatus('disconnected');
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
const data = await chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl']);
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
await populateTabs();
}
});
@@ -1605,6 +1597,10 @@ function showError(msg) {
// --- Action Handlers ---
elements.roomId.addEventListener('input', () => {
elements.roomId.value = elements.roomId.value.replace(/[^a-zA-Z0-9\-]/g, '');
if (pendingInviteRoomId && elements.roomId.value !== pendingInviteRoomId) {
pendingInviteRoomId = '';
pendingInviteChatKey = '';
}
});
elements.joinBtn.addEventListener('click', async () => {
@@ -1676,17 +1672,24 @@ elements.joinBtn.addEventListener('click', async () => {
self.crypto.getRandomValues(array);
password = Array.from(array, byte => chars[byte % chars.length]).join('');
elements.password.value = password;
window.justCreatedRoom = true;
}
let chatKey = roomId === pendingInviteRoomId ? pendingInviteChatKey : '';
if (isCreating) {
const created = await new Promise(resolve => chrome.runtime.sendMessage({ type: 'CREATE_CHAT_KEY' }, resolve));
chatKey = normalizeChatKey(created?.chatKey);
}
if (isCreating) window.justCreatedRoom = true;
pendingInviteRoomId = '';
pendingInviteChatKey = '';
await chrome.storage.local.set({ serverUrl, roomId, password, useCustomServer: useCustom });
await chrome.storage.local.set({ serverUrl, roomId, password, chatKey, useCustomServer: useCustom });
elements.roomId.value = roomId;
// Tell background to connect
chrome.runtime.sendMessage({ type: 'CONNECT' });
// UI Feedback: Immediately switch state for better responsiveness
updateUI(roomId, password, useCustom, serverUrl);
updateUI(roomId, password, useCustom, serverUrl, chatKey);
});
elements.leaveBtn.addEventListener('click', async () => {
@@ -1694,7 +1697,7 @@ elements.leaveBtn.addEventListener('click', async () => {
isProcessingConnection = true;
clearConnectionErrorTimer();
chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' });
await chrome.storage.local.set({ roomId: '', password: '' });
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' });
elements.roomId.value = '';
elements.password.value = '';
lastKnownPeers = [];
@@ -2167,15 +2170,15 @@ chrome.runtime.onMessage.addListener((msg) => {
if (msg.success) {
// Final confirmation of join from background
chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl'], (data) => {
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
if (syncTabBtn) syncTabBtn.click();
showSelectVideoHint();
});
} else {
// Join failed: reset UI state
chrome.storage.local.set({ roomId: '', password: '' });
chrome.storage.local.set({ roomId: '', password: '', chatKey: '' });
elements.roomId.value = '';
elements.password.value = '';
updateUI(null, null);
+3 -1
View File
@@ -8,7 +8,9 @@ export default defineConfig({
'server/**/*.test.js',
'server/**/*.test.mjs',
'shared/**/*.test.js',
'shared/**/*.test.mjs'
'shared/**/*.test.mjs',
'extension/**/*.test.js',
'extension/**/*.test.mjs'
],
coverage: {
provider: 'v8',