fix(chat): harden client state and messaging

This commit is contained in:
KoalaDev
2026-07-15 08:40:27 +02:00
parent 36c4c492d3
commit 9e0d294758
9 changed files with 504 additions and 138 deletions
+182 -85
View File
@@ -6,6 +6,7 @@ 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 { createChatSendLimiter, createLatestTaskQueue, normalizeRoomId } from './chat-session.js';
import './page-api-seek-overrides.js';
// --- Uninstall URL Initialization ---
@@ -82,7 +83,26 @@ let hostPeerId = null; // peerId of the room host (creator /
// relay (no capabilities field) → host-control UI/behavior stays unavailable.
let serverCapabilities = [];
let chatSecretGuard = '';
let chatSessionGeneration = 0;
let chatReceiveQueue = Promise.resolve();
const chatSendLimiter = createChatSendLimiter();
const webJoinCoordinator = createLatestTaskQueue();
function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); }
function invalidateChatSession() {
chatSessionGeneration++;
chatReceiveQueue = Promise.resolve();
chatSendLimiter.reset();
clearChatKeyCache();
}
async function clearFailedJoinCredentials() {
webJoinCoordinator.invalidate();
connectIntent = false;
chatSecretGuard = '';
invalidateChatSession();
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
}
// 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
// appearing un-ACK'd.
@@ -377,10 +397,12 @@ async function getSettings() {
const mediaTitlePrivacyMode = normalizeTitlePrivacyMode(data.mediaTitlePrivacyMode || legacyTitlePrivacyMode);
const chatKey = validateChatSecret(data.chatKey);
chatSecretGuard = chatKey;
const roomId = normalizeRoomId(data.roomId);
if (data.roomId && data.roomId !== roomId) chrome.storage.local.set({ roomId }).catch(() => {});
return {
serverUrl: data.serverUrl || '',
useCustomServer: data.useCustomServer || false,
roomId: data.roomId || '',
roomId,
password: data.password || '',
chatKey,
username,
@@ -474,6 +496,7 @@ function forceDisconnect() {
currentServerUrl = null;
isConnecting = false;
isNamespaceJoined = false;
invalidateChatSession();
isForceSyncInitiator = false;
expectedAcksCount = 0;
roomIdleSince = null;
@@ -567,7 +590,7 @@ async function leaveRoomAfterIdleGrace(reason) {
hcmDesynced: false
}).catch(() => {});
chatSecretGuard = '';
clearChatKeyCache();
invalidateChatSession();
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
addLog(reason, 'info');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
@@ -709,6 +732,7 @@ async function connect() {
socket.onclose = () => {
isConnecting = false;
isNamespaceJoined = false;
invalidateChatSession();
stopPing();
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
@@ -790,6 +814,7 @@ function broadcastConnectionStatus(status) {
status = 'idle';
}
chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status }).catch(() => {});
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CONNECTION_STATUS', status }).catch(() => {});
updateBadgeStatus();
}
@@ -1045,6 +1070,7 @@ async function handleServerEvent(event, data) {
}
switch (event) {
case EVENTS.ROOM_DATA:
if (currentRoom?.roomId !== data.roomId) invalidateChatSession();
currentRoom = data;
// Host Control Mode: adopt room role/mode on (re)join.
controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
@@ -1131,42 +1157,54 @@ async function handleServerEvent(event, data) {
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(() => {});
const generation = chatSessionGeneration;
const roomId = currentRoom.roomId;
const tabId = Number(currentTabId);
const received = { ...data };
const isCurrentSession = () => generation === chatSessionGeneration &&
currentRoom?.roomId === roomId && Number(currentTabId) === tabId;
chatReceiveQueue = chatReceiveQueue.catch(() => {}).then(async () => {
if (!isCurrentSession()) return;
const settings = await getSettings();
if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) return;
const chatKey = settings.chatKey;
try {
const text = await decryptChatMessage({
ciphertext: received.ciphertext,
roomId,
senderId: received.senderId,
secret: chatKey
});
if (!isCurrentSession() || chatSecretGuard !== chatKey) return;
const senderPeer = currentRoom.peers?.find(candidate =>
(typeof candidate === 'object' ? candidate.peerId : candidate) === received.senderId
);
if (Number.isInteger(tabId)) {
chrome.tabs.sendMessage(tabId, {
type: 'CHAT_MESSAGE',
message: {
id: received.id,
senderId: received.senderId,
username: typeof senderPeer === 'object' ? senderPeer.username : null,
timestamp: received.timestamp,
text
}
}).catch(() => {});
}
} catch (_) {
if (isCurrentSession()) addLog('Discarded chat message that failed authentication', 'warn');
}
} catch (_) {
addLog('Discarded chat message that failed authentication', 'warn');
}
});
await chatReceiveQueue;
break;
}
case EVENTS.ERROR:
isConnecting = false;
// If we get a server error before successfully joining a room,
// clear connectIntent to prevent an infinite reconnect loop.
if (!currentRoom) {
connectIntent = false;
// clear persisted credentials as well, otherwise service-worker
// restart would immediately retry the rejected room.
if (!currentRoom && connectIntent) {
await clearFailedJoinCredentials();
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
reconnectAttempts = 0;
reconnectFailed = false;
@@ -1910,7 +1948,7 @@ function leaveOldRoomIfSwitching(newRoomId) {
hostPeerId = null;
controllers = [];
serverCapabilities = [];
clearChatKeyCache();
invalidateChatSession();
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.
@@ -1952,21 +1990,33 @@ async function applyAudioSettingsToTab(tabId) {
// --- Extension Message Listeners ---
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
handleAsyncMessage(message, sender, sendResponse);
handleAsyncMessage(message, sender, sendResponse).catch(error => {
addLog(`Message handler failed for ${message?.type || 'unknown'}: ${error.message}`, 'error');
try { sendResponse({ status: 'error' }); } catch (_) { /* channel already closed */ }
});
return true; // Keep channel open for async responses
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'local' || (!changes.roomId && !changes.chatKey)) return;
if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue);
invalidateChatSession();
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
});
async function handleAsyncMessage(message, sender, sendResponse) {
if (!message) return;
await ensureState();
if (message.type === 'CONNECT') {
webJoinCoordinator.invalidate();
const settings = await getSettings();
connectIntent = !!settings.roomId;
const desiredUrl = resolveServerUrl(settings);
if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
broadcastConnectionStatus('connected');
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
for (const tab of tabs) {
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
@@ -2053,6 +2103,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({
supported: serverSupports(CAPABILITIES.CHAT),
hasKey: !!settings.chatKey,
connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined),
peerId,
roomId: currentRoom.roomId,
strings: {
@@ -2082,18 +2133,39 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'unsupported' });
return;
}
if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
sendResponse({ status: 'disconnected' });
return;
}
const generation = chatSessionGeneration;
const roomId = currentRoom.roomId;
const tabId = Number(currentTabId);
const socketSnapshot = socket;
const isCurrentSession = () => generation === chatSessionGeneration &&
currentRoom?.roomId === roomId && Number(currentTabId) === tabId &&
socket === socketSnapshot && socketSnapshot.readyState === WebSocket.OPEN && isNamespaceJoined;
const settings = await getSettings();
if (!settings.chatKey) {
sendResponse({ status: 'missing_key' });
if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) {
sendResponse({ status: settings.chatKey ? 'session_changed' : 'missing_key' });
return;
}
const chatKey = settings.chatKey;
const rateLimit = chatSendLimiter.take();
if (!rateLimit.allowed) {
sendResponse({ status: 'rate_limited', retryAfterMs: rateLimit.retryAfterMs });
return;
}
try {
const ciphertext = await encryptChatMessage({
text: message.text,
roomId: currentRoom.roomId,
roomId,
senderId: peerId,
secret: settings.chatKey
secret: chatKey
});
if (!isCurrentSession() || chatSecretGuard !== chatKey) {
sendResponse({ status: 'session_changed' });
return;
}
const sent = emitLive(EVENTS.CHAT_MESSAGE, buildChatRelayPayload(ciphertext));
sendResponse({ status: sent ? 'ok' : 'disconnected' });
} catch (err) {
@@ -2102,7 +2174,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else if (message.type === 'CREATE_CHAT_KEY') {
const chatKey = generateChatSecret();
chatSecretGuard = chatKey;
clearChatKeyCache();
invalidateChatSession();
await chrome.storage.local.set({ chatKey });
sendResponse({ status: 'ok', chatKey });
} else if (message.type === 'SET_CONTROL_MODE') {
@@ -2174,6 +2246,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (storageInitialized) chrome.storage.session.set({ hcmDesynced });
sendResponse({ status: 'ok' });
} else if (message.type === 'LEAVE_ROOM') {
webJoinCoordinator.invalidate();
connectIntent = false;
reconnectFailed = false;
reconnectAttempts = 0;
@@ -2219,7 +2292,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
hcmDesynced: false
});
chatSecretGuard = '';
clearChatKeyCache();
invalidateChatSession();
chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
addLog('Left Room', 'info');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
@@ -2237,7 +2310,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'ok' });
} else if (message.type === 'WEB_JOIN_REQUEST') {
const { roomId: rawRoomId, password, chatKey: rawChatKey, useCustomServer, serverUrl } = message;
const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : '';
const roomId = normalizeRoomId(rawRoomId);
const chatKey = validateChatSecret(rawChatKey);
if (!roomId) {
const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' };
@@ -2248,53 +2321,77 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'invalid_room_id' });
return;
}
connectIntent = true;
chrome.storage.local.set({
roomId,
password,
chatKey,
useCustomServer: !!useCustomServer,
serverUrl: serverUrl || ''
}, async () => {
chatSecretGuard = chatKey;
clearChatKeyCache();
const settings = await getSettings();
const desiredUrl = resolveServerUrl(settings);
if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
broadcastConnectionStatus('connected');
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
for (const tab of tabs) {
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
}
sendResponse({ status: 'already_joined' });
return;
await webJoinCoordinator.run(async isCurrentJoin => {
if (!isCurrentJoin()) {
sendResponse({ status: 'superseded' });
return { status: 'superseded' };
}
reconnectFailed = false;
reconnectStartTime = null;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
broadcastConnectionStatus('connecting');
leaveOldRoomIfSwitching(roomId);
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
if (desiredUrl !== currentServerUrl) forceDisconnect();
connect();
} else if (roomId) {
const sharedTitles = getSharedTitleFields(settings);
emit(EVENTS.JOIN_ROOM, {
roomId,
password,
peerId,
username: settings.username,
tabTitle: sharedTitles.tabTitle,
protocolVersion: PROTOCOL_VERSION
try {
connectIntent = true;
await chrome.storage.local.set({
roomId,
password: typeof password === 'string' ? password : '',
chatKey,
useCustomServer: !!useCustomServer,
serverUrl: typeof serverUrl === 'string' ? serverUrl : ''
});
if (!isCurrentJoin()) {
sendResponse({ status: 'superseded' });
return { status: 'superseded' };
}
chatSecretGuard = chatKey;
invalidateChatSession();
const settings = await getSettings();
if (!isCurrentJoin()) {
sendResponse({ status: 'superseded' });
return { status: 'superseded' };
}
const desiredUrl = resolveServerUrl(settings);
if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
broadcastConnectionStatus('connected');
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
if (!isCurrentJoin()) {
sendResponse({ status: 'superseded' });
return { status: 'superseded' };
}
for (const tab of tabs) {
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
}
sendResponse({ status: 'already_joined' });
return { status: 'already_joined' };
}
reconnectFailed = false;
reconnectStartTime = null;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
broadcastConnectionStatus('connecting');
leaveOldRoomIfSwitching(roomId);
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
if (desiredUrl !== currentServerUrl) forceDisconnect();
connect();
} else if (roomId) {
const sharedTitles = getSharedTitleFields(settings);
emit(EVENTS.JOIN_ROOM, {
roomId,
password,
peerId,
username: settings.username,
tabTitle: sharedTitles.tabTitle,
protocolVersion: PROTOCOL_VERSION
});
}
addLog(`Joining room via link: ${roomId}`, 'info');
sendResponse({ status: 'ok' });
return { status: 'ok' };
} catch (_) {
if (isCurrentJoin()) await clearFailedJoinCredentials();
sendResponse({ status: 'storage_error' });
return { status: 'storage_error' };
}
addLog(`Joining room via link: ${roomId}`, 'info');
sendResponse({ status: 'ok' });
});
} else if (message.type === 'REGENERATE_ID') {
// Match getPeerId()'s 16-hex-char generation — see comment there.
+26 -16
View File
@@ -4,9 +4,10 @@ const IV_BYTES = 12;
const MIN_ENCRYPTED_BYTES = 29;
const MAX_ENCRYPTED_BYTES = 2028;
let cachedKey = null;
let cachedKeyPromise = null;
let cachedRoomId = '';
let cachedSecret = '';
let cacheGeneration = 0;
function bytesToBase64Url(bytes) {
let binary = '';
@@ -50,7 +51,8 @@ export function normalizeOutgoingChatText(value) {
}
export function clearChatKeyCache() {
cachedKey = null;
cacheGeneration++;
cachedKeyPromise = null;
cachedRoomId = '';
cachedSecret = '';
}
@@ -58,21 +60,30 @@ export function clearChatKeyCache() {
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;
if (cachedKeyPromise && cachedRoomId === roomId && cachedSecret === validSecret) return cachedKeyPromise;
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']);
const generation = cacheGeneration;
const keyPromise = (async () => {
const material = await cryptoImpl.subtle.importKey(
'raw', base64UrlToBytes(validSecret), { name: 'HKDF' }, false, ['deriveKey']
);
return 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']);
})();
cachedKeyPromise = keyPromise;
cachedRoomId = roomId;
cachedSecret = validSecret;
return cachedKey;
try {
return await keyPromise;
} catch (error) {
if (generation === cacheGeneration && cachedKeyPromise === keyPromise) clearChatKeyCache();
throw error;
}
}
export async function encryptChatMessage({ text, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) {
@@ -106,7 +117,6 @@ export async function decryptChatMessage({ ciphertext, roomId, senderId, secret
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;
const text = new globalThis.TextDecoder('utf-8', { fatal: true }).decode(plaintext);
return normalizeOutgoingChatText(text);
}
+50
View File
@@ -35,6 +35,33 @@ describe('chat crypto', () => {
await expect(decryptChatMessage({ ciphertext: first, ...input }, webcrypto)).resolves.toBe(input.text);
});
it('deduplicates in-flight key derivations without restoring a cleared cache', async () => {
const secret = generateChatSecret(webcrypto);
let deriveCalls = 0;
let releaseDerive;
const delayedCrypto = {
...webcrypto,
subtle: {
importKey: (...args) => webcrypto.subtle.importKey(...args),
deriveKey: async (...args) => {
deriveCalls++;
await new Promise(resolve => { releaseDerive = resolve; });
return webcrypto.subtle.deriveKey(...args);
}
}
};
const first = deriveChatKey('ROOM-1', secret, delayedCrypto);
const second = deriveChatKey('ROOM-1', secret, delayedCrypto);
await Promise.resolve();
expect(deriveCalls).toBe(1);
clearChatKeyCache();
releaseDerive();
await Promise.all([first, second]);
const fresh = deriveChatKey('ROOM-1', secret, webcrypto);
expect(fresh).not.toBe(first);
await fresh;
});
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);
@@ -61,4 +88,27 @@ describe('chat crypto', () => {
const bytes = Buffer.from(ciphertext, 'base64url');
expect(bytes).toHaveLength(2028);
});
it('rejects non-canonical plaintext and malformed UTF-8 after authentication', async () => {
const secret = generateChatSecret(webcrypto);
const roomId = 'ROOM-1';
const senderId = 'alice';
const key = await deriveChatKey(roomId, secret, webcrypto);
const encoder = new globalThis.TextEncoder();
async function encryptRaw(plaintext) {
const iv = webcrypto.getRandomValues(new Uint8Array(12));
const encrypted = new Uint8Array(await webcrypto.subtle.encrypt({
name: 'AES-GCM',
iv,
additionalData: encoder.encode(`${roomId}|${senderId}`)
}, key, plaintext));
return Buffer.concat([Buffer.from(iv), Buffer.from(encrypted)]).toString('base64url');
}
const whitespace = await encryptRaw(encoder.encode(' '));
await expect(decryptChatMessage({ ciphertext: whitespace, roomId, senderId, secret }, webcrypto)).rejects.toThrow(TypeError);
const malformed = await encryptRaw(Uint8Array.of(0xff));
await expect(decryptChatMessage({ ciphertext: malformed, roomId, senderId, secret }, webcrypto)).rejects.toThrow();
});
});
+28
View File
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
const extensionDir = path.dirname(new URL(import.meta.url).pathname);
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8');
const localeDir = path.join(extensionDir, 'locales');
const chatKeys = [
'CHAT_TITLE',
@@ -44,6 +45,33 @@ describe('chat overlay contract', () => {
expect(overlaySource).toContain("#app[data-palette=\"graphite\"][data-theme=\"light\"]");
expect(overlaySource).toContain('const MAX_MESSAGES = 200');
expect(overlaySource).toContain('while (messages.querySelectorAll(\'.message\').length > MAX_MESSAGES)');
expect(overlaySource).toContain('Math.max(1, window.innerWidth)');
expect(overlaySource).toContain('min(${MIN_WIDTH}px, calc(100vw - 16px))');
});
it('guards async refresh/send work and clears all composer state on room reset', () => {
expect(overlaySource).toContain('generation !== refreshGeneration');
expect(overlaySource).toContain('if (sending || !context?.enabled) return');
expect(overlaySource).toContain('textarea.value === submittedValue');
expect(overlaySource).toMatch(/CHAT_RESET[\s\S]*resetComposer\(\)/);
expect(overlaySource).toContain('setTimeout(() => finish(null), timeoutMs)');
expect(backgroundSource).toContain('chatReceiveQueue = chatReceiveQueue.catch(() => {}).then');
expect(backgroundSource).toContain("status: 'rate_limited'");
});
it('keeps unavailable chat controls discoverable to assistive technology', () => {
expect(overlaySource).toContain("launcher.setAttribute('aria-disabled'");
expect(overlaySource).not.toContain('launcher.disabled =');
expect(overlaySource).toContain("launcher.setAttribute('aria-describedby', launcherHint.id)");
expect(overlaySource).toContain("textarea.setAttribute('aria-describedby', 'chat-composer-count chat-composer-status')");
expect(overlaySource).toContain("status.setAttribute('role', 'status')");
});
it('creates a chat key for both generated-room entry points', () => {
expect(popupSource).toContain('let pendingRoomCreation = false');
expect(popupSource).toContain('const isCreating = pendingRoomCreation || !roomIdInput');
expect(popupSource).toMatch(/function handleCreateRoom\(\)[\s\S]*pendingRoomCreation = true[\s\S]*elements\.joinBtn\.click\(\)/);
expect(popupSource).toContain("type: 'CREATE_CHAT_KEY'");
});
it('contains every chat string in all 15 extension locales', () => {
+90 -30
View File
@@ -16,6 +16,9 @@
let destroyed = false;
let saveTimer = null;
let applyingLayout = false;
let refreshGeneration = 0;
let sendGeneration = 0;
let sending = false;
let layout = { mode: 'right', x: 24, y: 72, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, detachedInitialized: false };
let themeMode = 'system';
let themePalette = 'eucalyptus';
@@ -85,11 +88,11 @@
border-radius: 16px; background: var(--card); color: var(--text); cursor: pointer;
pointer-events: auto; box-shadow: 0 10px 28px rgb(0 0 0 / .32); font-size: 22px;
}
.launcher:hover:not(:disabled) { border-color: var(--accent); transform: translateY(-1px); }
.launcher:disabled { cursor: not-allowed; opacity: .58; }
.launcher:hover:not([aria-disabled="true"]) { border-color: var(--accent); transform: translateY(-1px); }
.launcher[aria-disabled="true"] { cursor: not-allowed; opacity: .58; }
.panel {
position: fixed; display: none; flex-direction: column; overflow: hidden;
min-width: ${MIN_WIDTH}px; min-height: ${MIN_HEIGHT}px; max-width: calc(100vw - 16px);
min-width: min(${MIN_WIDTH}px, calc(100vw - 16px)); min-height: min(${MIN_HEIGHT}px, calc(100vh - 16px)); max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px); pointer-events: auto; color: var(--text);
background: var(--card); border: 1px solid var(--border-strong); border-radius: 18px;
box-shadow: 0 18px 55px rgb(0 0 0 / .38); transform: translateZ(0);
@@ -120,6 +123,7 @@
.send { border: 0; border-radius: 10px; padding: 8px 13px; background: var(--accent); color: var(--text-on-green); font-weight: 750; cursor: pointer; }
.send:hover:not(:disabled) { background: var(--accent-hover); }
.send:disabled { opacity: .55; cursor: wait; }
.visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 520px) { .panel { max-width: calc(100vw - 12px); } }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
`;
@@ -135,6 +139,8 @@
app.id = 'app';
const launcher = element('button', 'launcher', '💬');
launcher.type = 'button';
const launcherHint = element('span', 'visually-hidden');
launcherHint.id = 'chat-launcher-hint';
const panel = element('section', 'panel');
panel.setAttribute('role', 'dialog');
const header = element('header', 'header');
@@ -155,28 +161,43 @@
const empty = element('div', 'empty');
messages.append(empty);
const composer = element('form', 'composer');
const textareaLabel = element('label', 'visually-hidden');
textareaLabel.htmlFor = 'chat-composer-input';
const textarea = element('textarea');
textarea.id = 'chat-composer-input';
textarea.setAttribute('aria-describedby', 'chat-composer-count chat-composer-status');
const composerRow = element('div', 'composer-row');
const count = element('span', 'count', '0/500');
count.id = 'chat-composer-count';
const sendButton = element('button', 'send');
sendButton.type = 'submit';
composerRow.append(count, sendButton);
const status = element('div', 'status');
composer.append(textarea, composerRow, status);
status.id = 'chat-composer-status';
status.setAttribute('role', 'status');
composer.append(textareaLabel, textarea, composerRow, status);
panel.append(header, messages, composer);
app.append(launcher, panel);
app.append(launcherHint, launcher, panel);
shadow.append(style, app);
document.documentElement.append(host);
function messageRuntime(payload) {
function messageRuntime(payload, timeoutMs = 5000) {
return new Promise(resolve => {
let settled = false;
const finish = response => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve(response || null);
};
const timeout = setTimeout(() => finish(null), timeoutMs);
try {
chrome.runtime.sendMessage(payload, response => {
if (chrome.runtime.lastError) resolve(null);
else resolve(response || null);
if (chrome.runtime.lastError) finish(null);
else finish(response);
});
} catch (_) {
resolve(null);
finish(null);
}
});
}
@@ -190,9 +211,16 @@
title.textContent = text.title || '';
subtitle.textContent = text.liveOnly || '';
launcher.setAttribute('aria-label', text.open || '');
launcher.title = context?.supported && !context?.hasKey ? (text.missingKey || '') : (text.open || '');
const unavailableHint = context?.supported && !context?.hasKey
? (text.missingKey || '')
: context?.supported && !context?.connected ? (text.sendFailed || '') : '';
launcher.title = unavailableHint || text.open || '';
launcherHint.textContent = unavailableHint;
if (unavailableHint) launcher.setAttribute('aria-describedby', launcherHint.id);
else launcher.removeAttribute('aria-describedby');
panel.setAttribute('aria-label', text.title || '');
textarea.placeholder = text.placeholder || '';
textareaLabel.textContent = text.placeholder || text.title || '';
sendButton.textContent = text.send || '';
empty.textContent = text.empty || '';
leftButton.title = text.dockLeft || '';
@@ -212,15 +240,19 @@
}
function viewportBounds() {
return { width: Math.max(320, window.innerWidth), height: Math.max(360, window.innerHeight) };
return { width: Math.max(1, window.innerWidth), height: Math.max(1, window.innerHeight) };
}
function clampDetached() {
const viewport = viewportBounds();
layout.width = Math.min(Math.max(Number(layout.width) || DEFAULT_WIDTH, MIN_WIDTH), Math.min(600, viewport.width - 16));
layout.height = Math.min(Math.max(Number(layout.height) || DEFAULT_HEIGHT, MIN_HEIGHT), viewport.height - 16);
layout.x = Math.min(Math.max(Number(layout.x) || 8, 8), Math.max(8, viewport.width - layout.width - 8));
layout.y = Math.min(Math.max(Number(layout.y) || 8, 8), Math.max(8, viewport.height - layout.height - 8));
const maxWidth = Math.max(1, Math.min(600, viewport.width - 16));
const maxHeight = Math.max(1, viewport.height - 16);
const horizontalInset = Math.min(8, Math.max(0, Math.floor((viewport.width - 1) / 2)));
const verticalInset = Math.min(8, Math.max(0, Math.floor((viewport.height - 1) / 2)));
layout.width = Math.min(Math.max(Number(layout.width) || DEFAULT_WIDTH, Math.min(MIN_WIDTH, maxWidth)), maxWidth);
layout.height = Math.min(Math.max(Number(layout.height) || DEFAULT_HEIGHT, Math.min(MIN_HEIGHT, maxHeight)), maxHeight);
layout.x = Math.min(Math.max(Number(layout.x) || horizontalInset, horizontalInset), Math.max(horizontalInset, viewport.width - layout.width - horizontalInset));
layout.y = Math.min(Math.max(Number(layout.y) || verticalInset, verticalInset), Math.max(verticalInset, viewport.height - layout.height - verticalInset));
}
function saveLayout() {
@@ -252,13 +284,16 @@
launcher.style.right = 'auto';
launcher.style.top = `${layout.y}px`;
} else {
panel.style.top = '64px';
panel.style.width = `${DEFAULT_WIDTH}px`;
panel.style.height = 'min(560px, calc(100vh - 80px))';
panel.style[layout.mode] = '16px';
launcher.style.top = 'calc(50vh - 24px)';
launcher.style.left = layout.mode === 'left' ? '16px' : 'auto';
launcher.style.right = layout.mode === 'right' ? '16px' : 'auto';
const viewport = viewportBounds();
const gutter = Math.min(16, Math.max(0, Math.floor((viewport.width - 1) / 2)));
const panelTop = viewport.height < MIN_HEIGHT + 80 ? Math.min(8, Math.max(0, viewport.height - 1)) : 64;
panel.style.top = `${panelTop}px`;
panel.style.width = `${Math.max(1, Math.min(DEFAULT_WIDTH, viewport.width - gutter * 2))}px`;
panel.style.height = `${Math.max(1, Math.min(560, viewport.height - panelTop - Math.min(16, Math.max(0, viewport.height - panelTop - 1))))}px`;
panel.style[layout.mode] = `${gutter}px`;
launcher.style.top = `${Math.max(0, Math.min(viewport.height - 48, viewport.height / 2 - 24))}px`;
launcher.style.left = layout.mode === 'left' ? `${gutter}px` : 'auto';
launcher.style.right = layout.mode === 'right' ? `${gutter}px` : 'auto';
}
globalThis.queueMicrotask(() => { applyingLayout = false; });
}
@@ -290,18 +325,22 @@
context = next || null;
const supported = !!context?.supported;
const hasKey = !!context?.hasKey;
context = context ? { ...context, enabled: supported && hasKey } : null;
const connected = !!context?.connected;
context = context ? { ...context, enabled: supported && hasKey && connected } : null;
if (previousRoomId && previousRoomId !== context?.roomId) clearMessages();
host.style.display = supported ? '' : 'none';
launcher.disabled = !hasKey;
if (!supported || !hasKey) setOpened(false);
launcher.setAttribute('aria-disabled', String(!context?.enabled));
if (!context?.enabled) setOpened(false);
applyStrings();
applyLayout();
}
async function refresh() {
if (destroyed) return;
applyContext(await messageRuntime({ type: 'GET_CHAT_CONTEXT' }));
const generation = ++refreshGeneration;
const next = await messageRuntime({ type: 'GET_CHAT_CONTEXT' });
if (destroyed || generation !== refreshGeneration) return;
applyContext(next);
}
function appendMessage(message) {
@@ -332,6 +371,16 @@
messages.replaceChildren(empty);
}
function resetComposer() {
sendGeneration++;
sending = false;
textarea.value = '';
count.textContent = '0/500';
count.style.color = '';
status.textContent = '';
sendButton.disabled = false;
}
launcher.addEventListener('click', () => setOpened(true));
closeButton.addEventListener('click', () => setOpened(false));
leftButton.addEventListener('click', () => setMode('left'));
@@ -347,24 +396,32 @@
textarea.addEventListener('keydown', event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
composer.requestSubmit();
if (!sending) composer.requestSubmit();
}
});
composer.addEventListener('submit', async event => {
event.preventDefault();
if (sending || !context?.enabled) return;
const submittedValue = textarea.value;
const text = textarea.value.trim();
if (!text) return;
if ([...text].length > 500) {
status.textContent = strings().tooLong || '';
return;
}
const generation = ++sendGeneration;
sending = true;
sendButton.disabled = true;
status.textContent = '';
const response = await messageRuntime({ type: 'CHAT_SEND', text });
if (destroyed || generation !== sendGeneration) return;
sending = false;
sendButton.disabled = false;
if (response?.status === 'ok') {
textarea.value = '';
count.textContent = '0/500';
if (textarea.value === submittedValue) {
textarea.value = '';
count.textContent = '0/500';
}
} else {
status.textContent = response?.status === 'too_long' ? (strings().tooLong || '') : (strings().sendFailed || '');
}
@@ -428,9 +485,10 @@
function handleRuntime(message) {
if (message?.type === 'CHAT_MESSAGE') appendMessage(message.message);
if (message?.type === 'CHAT_CONTEXT_UPDATE') refresh();
if (message?.type === 'CHAT_CONTEXT_UPDATE' || message?.type === 'CONNECTION_STATUS') refresh();
if (message?.type === 'CHAT_RESET') {
clearMessages();
resetComposer();
refresh();
}
if (message?.type === 'CHAT_DESTROY') destroy();
@@ -439,6 +497,8 @@
function destroy() {
if (destroyed) return;
destroyed = true;
refreshGeneration++;
sendGeneration++;
if (saveTimer) clearTimeout(saveTimer);
resizeObserver.disconnect();
document.removeEventListener('fullscreenchange', moveIntoFullscreen);
+58
View File
@@ -0,0 +1,58 @@
export const MAX_ROOM_ID_LENGTH = 64;
export const CHAT_SEND_LIMIT = 9;
export const CHAT_SEND_WINDOW_MS = 10000;
export function normalizeRoomId(value) {
if (typeof value !== 'string') return '';
return value.trim().replace(/[^a-zA-Z0-9\-]/g, '').slice(0, MAX_ROOM_ID_LENGTH);
}
export function createChatSendLimiter({
limit = CHAT_SEND_LIMIT,
windowMs = CHAT_SEND_WINDOW_MS,
now = () => Date.now()
} = {}) {
let timestamps = [];
return {
take() {
const current = now();
timestamps = timestamps.filter(timestamp => current - timestamp < windowMs);
if (timestamps.length >= limit) {
return {
allowed: false,
retryAfterMs: Math.max(1, windowMs - (current - timestamps[0]))
};
}
timestamps.push(current);
return { allowed: true, retryAfterMs: 0 };
},
reset() {
timestamps = [];
}
};
}
export function createLatestTaskQueue() {
let generation = 0;
let tail = Promise.resolve();
return {
invalidate() {
generation++;
},
async run(task) {
const requestGeneration = ++generation;
const previous = tail;
let release;
tail = new Promise(resolve => { release = resolve; });
await previous.catch(() => {});
const isCurrent = () => requestGeneration === generation;
try {
return await task(isCurrent);
} finally {
release();
}
}
};
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import {
CHAT_SEND_LIMIT,
createChatSendLimiter,
createLatestTaskQueue,
MAX_ROOM_ID_LENGTH,
normalizeRoomId
} from './chat-session.js';
describe('chat session boundaries', () => {
it('matches the relay room ID sanitizer and length limit', () => {
expect(normalizeRoomId(' ROOM_!42 ')).toBe('ROOM42');
expect(normalizeRoomId('A'.repeat(MAX_ROOM_ID_LENGTH + 10))).toBe('A'.repeat(MAX_ROOM_ID_LENGTH));
expect(normalizeRoomId(null)).toBe('');
});
it('keeps client chat bursts below the relay disconnect threshold', () => {
let current = 1000;
const limiter = createChatSendLimiter({ now: () => current });
for (let index = 0; index < CHAT_SEND_LIMIT; index++) {
expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 });
}
expect(limiter.take()).toEqual({ allowed: false, retryAfterMs: 10000 });
current += 10000;
expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 });
limiter.reset();
expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 });
});
it('serializes join work and prevents an older request from winning storage races', async () => {
const queue = createLatestTaskQueue();
let releaseFirst;
const writes = [];
const first = queue.run(async isCurrent => {
await new Promise(resolve => { releaseFirst = resolve; });
if (isCurrent()) writes.push('first');
return { status: isCurrent() ? 'ok' : 'superseded' };
});
await new Promise(resolve => setTimeout(resolve, 0));
const second = queue.run(async isCurrent => {
if (isCurrent()) writes.push('second');
return { status: 'ok' };
});
releaseFirst();
await expect(first).resolves.toEqual({ status: 'superseded' });
await expect(second).resolves.toEqual({ status: 'ok' });
expect(writes).toEqual(['second']);
});
});
+1 -1
View File
@@ -1353,7 +1353,7 @@
<!-- Room credentials — distinct group below the server picker. -->
<div class="form-group">
<label for="roomId" title="The unique identifier for your sync room" data-i18n="LABEL_ROOM_ID" data-i18n-title="LABEL_ROOM_ID_TOOLTIP">Room ID</label>
<input type="text" id="roomId" data-i18n-placeholder="PLACEHOLDER_ROOM_ID" data-i18n-title="PLACEHOLDER_ROOM_ID_TOOLTIP" placeholder="Enter Room ID" title="The unique ID of the room you want to join">
<input type="text" id="roomId" maxlength="64" data-i18n-placeholder="PLACEHOLDER_ROOM_ID" data-i18n-title="PLACEHOLDER_ROOM_ID_TOOLTIP" placeholder="Enter Room ID" title="The unique ID of the room you want to join">
</div>
<div class="form-group">
<label for="password" title="Optional password to restrict room access" data-i18n="LABEL_PASSWORD" data-i18n-title="LABEL_PASSWORD_TOOLTIP">Password (Optional)</label>
+20 -6
View File
@@ -3,10 +3,12 @@ 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 { normalizeRoomId } from './chat-session.js';
import './shared/invite-links.js';
let pendingInviteRoomId = '';
let pendingInviteChatKey = '';
let pendingRoomCreation = false;
function normalizeChatKey(value) {
return typeof value === 'string' && /^[A-Za-z0-9_-]{21}[AQgw]$/.test(value) ? value : '';
@@ -356,7 +358,7 @@ async function init() {
}
elements.serverUrl.value = localData.serverUrl || '';
elements.roomId.value = localData.roomId || '';
elements.roomId.value = normalizeRoomId(localData.roomId);
elements.password.value = localData.password || '';
elements.username.value = username;
syncDevToolsVisibility();
@@ -1305,9 +1307,9 @@ function checkInviteLink() {
try {
const invite = globalThis.KoalaSyncInviteLinks.parseInviteHash(new URL(tab.url).hash);
if (!invite) return;
elements.roomId.value = invite.roomId;
elements.roomId.value = normalizeRoomId(invite.roomId);
elements.password.value = invite.password;
pendingInviteRoomId = invite.roomId;
pendingInviteRoomId = elements.roomId.value;
pendingInviteChatKey = normalizeChatKey(invite.chatKey);
elements.serverUrl.value = invite.serverUrl;
@@ -1596,7 +1598,8 @@ function showError(msg) {
// --- Action Handlers ---
elements.roomId.addEventListener('input', () => {
elements.roomId.value = elements.roomId.value.replace(/[^a-zA-Z0-9\-]/g, '');
elements.roomId.value = normalizeRoomId(elements.roomId.value);
pendingRoomCreation = false;
if (pendingInviteRoomId && elements.roomId.value !== pendingInviteRoomId) {
pendingInviteRoomId = '';
pendingInviteChatKey = '';
@@ -1611,8 +1614,8 @@ elements.joinBtn.addEventListener('click', async () => {
isProcessingConnection = false;
return;
}
const roomIdInput = elements.roomId.value.trim();
const isCreating = !roomIdInput;
const roomIdInput = normalizeRoomId(elements.roomId.value);
const isCreating = pendingRoomCreation || !roomIdInput;
elements.joinBtn.disabled = true;
elements.joinBtn.textContent = isCreating ? getMessage('BTN_STATE_CREATING') : getMessage('BTN_STATE_JOINING');
@@ -1677,10 +1680,20 @@ elements.joinBtn.addEventListener('click', async () => {
if (isCreating) {
const created = await new Promise(resolve => chrome.runtime.sendMessage({ type: 'CREATE_CHAT_KEY' }, resolve));
chatKey = normalizeChatKey(created?.chatKey);
if (!chatKey) {
pendingRoomCreation = false;
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
isProcessingConnection = false;
showError(getMessage('CHAT_SEND_FAILED'));
return;
}
}
if (isCreating) window.justCreatedRoom = true;
pendingInviteRoomId = '';
pendingInviteChatKey = '';
pendingRoomCreation = false;
await chrome.storage.local.set({ serverUrl, roomId, password, chatKey, useCustomServer: useCustom });
elements.roomId.value = roomId;
@@ -1723,6 +1736,7 @@ function handleCreateRoom() {
const password = secureGenerateId();
elements.roomId.value = roomId;
elements.password.value = password;
pendingRoomCreation = true;
window.justCreatedRoom = true;
// Auto-connect