diff --git a/AI_INIT.md b/AI_INIT.md index 6baaa3d..09d6df3 100644 --- a/AI_INIT.md +++ b/AI_INIT.md @@ -3,7 +3,8 @@ Welcome to the KoalaSync project. This file is the primary entry point for any developer or AI agent working on this codebase. It defines the architecture, non-negotiables, and workflows required to maintain the stability and security of the system. > [!IMPORTANT] -> **Privacy & Data Sovereignty**: KoalaSync follows a strict "Zero-External-Requests" policy. No fonts, scripts, or assets (images/icons) may be loaded from 3rd-party CDNs or external servers. Everything must be served locally or from the project's own infrastructure. +> **Privacy & Data Sovereignty**: KoalaSync follows a strict **Zero-External-Requests Policy**: The extension and website must not make requests to any third-party domains (Google Fonts, CDNs, etc.). All assets (fonts, icons, scripts) must be self-hosted or use system defaults. +> - **Font Stack**: Use a modern system font stack (e.g., -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif) to maintain a premium look without external dependencies. Prohibit the use of `@import` or `` for external font services. --- diff --git a/extension/background.js b/extension/background.js index eac563b..a0f2196 100644 --- a/extension/background.js +++ b/extension/background.js @@ -11,6 +11,7 @@ let currentTabId = null; let currentTabTitle = null; // New: for Smart Matching let logs = []; let history = []; // New: for Action History +let reconnectTimer = null; // Force Sync Coordination let isForceSyncInitiator = false; @@ -53,7 +54,12 @@ function addLog(message, type = 'info') { // --- WebSocket Client --- async function connect() { - if (isConnecting || (socket && socket.readyState === WebSocket.OPEN)) return; + if (isConnecting) return; + if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return; + if (!navigator.onLine) { + addLog('Browser is offline. Waiting...', 'warn'); + return; + } if (!peerId) peerId = await getPeerId(); const settings = await getSettings(); @@ -61,31 +67,52 @@ async function connect() { isConnecting = true; broadcastConnectionStatus('connecting'); const isCustomServer = settings.serverUrl && settings.useCustomServer; - const finalUrl = isCustomServer ? settings.serverUrl : OFFICIAL_SERVER_URL; + let finalUrl = isCustomServer ? settings.serverUrl : OFFICIAL_SERVER_URL; + + // Robustness: Ensure finalUrl is not empty and has a protocol + if (isCustomServer) { + finalUrl = finalUrl.trim(); + if (!finalUrl.includes('://')) { + finalUrl = 'ws://' + finalUrl; + } + } addLog(`Connecting to ${isCustomServer ? finalUrl : 'Official Server'}...`, 'info'); - const url = new URL(finalUrl); - url.pathname = '/socket.io/'; - url.searchParams.set('EIO', '4'); - url.searchParams.set('transport', 'websocket'); - url.searchParams.set('version', APP_VERSION); + try { + const url = new URL(finalUrl); + url.pathname = '/socket.io/'; + url.searchParams.set('EIO', '4'); + url.searchParams.set('transport', 'websocket'); + url.searchParams.set('version', APP_VERSION); - if (!isCustomServer) { - url.searchParams.set('token', OFFICIAL_SERVER_TOKEN); - } + if (!isCustomServer) { + url.searchParams.set('token', OFFICIAL_SERVER_TOKEN); + } else { + // Self-hosted servers use the same official token by design. + // This allows users to run their own relay while still using + // the official extension — the token is public and not a secret. + url.searchParams.set('token', OFFICIAL_SERVER_TOKEN); + } - socket = new WebSocket(url.toString()); + socket = new WebSocket(url.toString()); - socket.onopen = () => { + socket.onopen = () => { + isConnecting = false; + reconnectDelay = 1000; + addLog('WebSocket Connection Opened', 'success'); + broadcastConnectionStatus('connected'); + + // Socket.IO Handshake: Send "40" to join default namespace + socket.send('40'); + }; + } catch (e) { isConnecting = false; - reconnectDelay = 1000; - addLog('WebSocket Connection Opened', 'success'); - broadcastConnectionStatus('connected'); - - // Socket.IO Handshake: Send "40" to join default namespace - socket.send('40'); - }; + addLog(`Invalid Server URL: ${finalUrl}`, 'error'); + broadcastConnectionStatus('disconnected'); + scheduleReconnect(); + return; + } socket.onmessage = (event) => { const msg = event.data; @@ -169,7 +196,10 @@ function showNotification(senderName, action) { } function scheduleReconnect() { - setTimeout(() => { + if (reconnectTimer) return; // Already scheduled + + reconnectTimer = setTimeout(() => { + reconnectTimer = null; reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY); connect(); }, reconnectDelay); @@ -182,6 +212,17 @@ function emit(event, data) { } } +function addToHistory(action, senderId) { + const historyEntry = { + action, + senderId: senderId || 'You', + timestamp: new Date().toISOString() + }; + history.unshift(historyEntry); + if (history.length > 20) history.pop(); + chrome.runtime.sendMessage({ type: 'HISTORY_UPDATE', history }).catch(() => {}); +} + // --- Event Handlers --- function handleServerEvent(event, data) { // console.log(`[RECV] ${event}`, data); @@ -203,7 +244,7 @@ function handleServerEvent(event, data) { break; case EVENTS.ERROR: addLog(`Server Error: ${data.message}`, 'error'); - chrome.notifications.create({ + chrome.notifications.create(`error_${Date.now()}`, { type: 'basic', iconUrl: 'icons/icon128.png', title: 'KoalaSync Error', @@ -220,6 +261,10 @@ function handleServerEvent(event, data) { case EVENTS.PAUSE: case EVENTS.SEEK: case EVENTS.FORCE_SYNC_PREPARE: + if (data.senderId) { + addToHistory(event, data.senderId); + showNotification(data.senderId, event); + } routeToContent(event, data); break; case EVENTS.FORCE_SYNC_ACK: @@ -234,6 +279,10 @@ function handleServerEvent(event, data) { } break; case EVENTS.FORCE_SYNC_EXECUTE: + if (data.senderId) { + addToHistory(event, data.senderId); + showNotification(data.senderId, event); + } routeToContent(event, data); break; case EVENTS.PEER_STATUS: @@ -263,19 +312,7 @@ function handleServerEvent(event, data) { } break; default: - // History Tracking - const historyEntry = { - action: event, - senderId: data.senderId || 'You', - timestamp: new Date().toISOString() - }; - history.unshift(historyEntry); - if (history.length > 20) history.pop(); - chrome.runtime.sendMessage({ type: 'HISTORY_UPDATE', history }).catch(() => {}); - - // Notification for remote actions - if (data.senderId) showNotification(data.senderId, event); - routeToContent(event, data); + addLog(`Received unknown event from server: ${event}`, 'warn'); break; } } @@ -303,8 +340,8 @@ async function routeToContent(action, payload) { action, payload }).catch(err => { - // Auto-Reinject if content script is missing - if (err.message.includes('Receiving end does not exist')) { + // Auto-Reinject if content script is missing or extension was reloaded + if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) { chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] @@ -335,8 +372,9 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'CONNECT') { connect(); } else if (message.type === 'GET_STATUS') { - const status = socket ? (socket.readyState === WebSocket.OPEN ? 'connected' : (isConnecting ? 'connecting' : 'disconnected')) : 'disconnected'; - sendResponse({ status, peers: currentRoom ? currentRoom.peers : [] }); + const status = socket ? (socket.readyState === WebSocket.OPEN ? 'connected' : (isConnecting || socket.readyState === WebSocket.CONNECTING ? 'connecting' : 'disconnected')) : 'disconnected'; + sendResponse({ status, peerId, peers: currentRoom ? currentRoom.peers : [] }); + // Global return true at the end handles this } else if (message.type === 'LEAVE_ROOM') { emit(EVENTS.LEAVE_ROOM, { peerId }); currentRoom = null; @@ -377,6 +415,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } }, 5000); } + addToHistory(message.action, 'You'); emit(message.action, { ...message.payload, peerId }); } else if (message.type === 'FORCE_SYNC_ACK') { emit(EVENTS.FORCE_SYNC_ACK, { peerId }); @@ -388,7 +427,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { // Peer status heartbeat from content script emit(EVENTS.PEER_STATUS, { ...message.payload, peerId, tabTitle: currentTabTitle }); } - return true; + return true; // Keep channel open for async responses }); // Tab removal listener diff --git a/extension/content.js b/extension/content.js index 091ac7c..8522d73 100644 --- a/extension/content.js +++ b/extension/content.js @@ -145,7 +145,8 @@ } // Heartbeat - setInterval(() => { + let heartbeatErrorCount = 0; + const heartbeatInterval = setInterval(() => { const video = findVideo(); if (video) { chrome.runtime.sendMessage({ @@ -154,7 +155,15 @@ playbackState: video.paused ? 'paused' : 'playing', currentTime: video.currentTime } - }).catch(() => {}); + }).catch(err => { + if (err.message.includes('Extension context invalidated')) { + heartbeatErrorCount++; + if (heartbeatErrorCount === 1) { + console.warn('KoalaSync: Extension reloaded. Please refresh the page if sync stops working.'); + } + clearInterval(heartbeatInterval); + } + }); } }, 15000); diff --git a/extension/manifest.json b/extension/manifest.json index 302c6be..f375f55 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -8,7 +8,8 @@ "tabs", "scripting", "alarms", - "activeTab" + "activeTab", + "notifications" ], "host_permissions": [ "" diff --git a/extension/popup.js b/extension/popup.js index b9d0f47..b802838 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -1,8 +1,16 @@ import { EVENTS, OFFICIAL_LANDING_PAGE_URL } from './shared/constants.js'; import { BLACKLIST_DOMAINS } from './shared/blacklist.js'; +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + const elements = { - tabs: document.querySelectorAll('.tab-btn'), + tabs: document.querySelectorAll('.tabs .tab-btn'), contents: document.querySelectorAll('.tab-content'), copyInvite: document.getElementById('copyInvite'), targetTab: document.getElementById('targetTab'), @@ -30,6 +38,8 @@ const elements = { roomError: document.getElementById('roomError') }; +let localPeerId = null; + // --- Initialization --- async function init() { // Load Settings @@ -55,6 +65,7 @@ async function init() { // Initial Status Check chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => { if (res) { + localPeerId = res.peerId; applyConnectionStatus(res.status); updatePeerList(res.peers); } @@ -78,24 +89,36 @@ function updateUI(roomId, password) { } function updatePeerList(peers) { - if (!peers) return; - elements.peerList.innerHTML = peers.map(id => ` -
- 👤 ${id} -
- `).join(''); + if (!peers || !elements.peerList) return; + elements.peerList.innerHTML = peers.map(p => { + const id = escapeHtml(typeof p === 'object' ? p.peerId : p); + const titleText = (typeof p === 'object' && p.tabTitle) ? escapeHtml(p.tabTitle) : ''; + const title = titleText ? `
${titleText}
` : ''; + return ` +
+
+ 👤 ${id} + ${id === escapeHtml(localPeerId) ? 'YOU' : ''} +
+ ${title} +
+ `; + }).join(''); // Re-populate tabs to update Star Matching when peers change - populateTabs(); + populateTabs(peers); } -async function populateTabs() { +async function populateTabs(providedPeers = null) { const data = await chrome.storage.sync.get(['targetTabId', 'filterNoise']); const isFilterActive = data.filterNoise !== false; const currentTargetTabId = data.targetTabId; - - // Get current peers from background to do matching - const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r)); - const peerIds = status?.peers || []; + + // Use provided peers or fetch if missing + let peerIds = providedPeers; + if (!peerIds) { + const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r)); + peerIds = status?.peers || []; + } const tabs = await chrome.tabs.query({}); @@ -165,8 +188,9 @@ function updateHistory(history) { } elements.historyList.innerHTML = history.map(item => { const time = new Date(item.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); - const actionLabel = item.action.toUpperCase().replace('FORCE_SYNC_', ''); - const sender = item.senderId === 'You' ? 'You' : item.senderId; + const actionLabel = escapeHtml(item.action.toUpperCase().replace('FORCE_SYNC_', '')); + const senderIdEscaped = escapeHtml(item.senderId); + const sender = item.senderId === 'You' ? 'You' : senderIdEscaped; return `
[${time}] ${actionLabel} by ${sender}
`; @@ -185,9 +209,12 @@ function updateRoomList(rooms) { return; } elements.publicRooms.innerHTML = rooms.map(r => ` -
- ${r.id} - ${r.peerCount} peers +
+
+ ${escapeHtml(r.id)} + ${r.hasPassword ? '🔒' : ''} +
+ ${parseInt(r.peerCount)} peers
`).join(''); @@ -232,6 +259,19 @@ elements.filterNoise.addEventListener('change', () => { }); }); +elements.serverUrl.addEventListener('input', () => { + chrome.storage.sync.set({ serverUrl: elements.serverUrl.value }); +}); + +elements.serverUrl.addEventListener('change', () => { + let url = elements.serverUrl.value.trim(); + if (url && !url.includes('://')) { + url = 'ws://' + url; + elements.serverUrl.value = url; + chrome.storage.sync.set({ serverUrl: url }); + } +}); + elements.tabs.forEach(btn => { btn.addEventListener('click', () => { elements.tabs.forEach(b => b.classList.remove('active')); diff --git a/server/index.js b/server/index.js index c6d8dad..1c85d36 100644 --- a/server/index.js +++ b/server/index.js @@ -178,6 +178,7 @@ io.on('connection', (socket) => { passwordHash, peers: new Set(), peerIds: new Map(), + peerData: new Map(), // socketId -> { peerId, tabTitle } lastActivity: Date.now() }; rooms.set(roomId, room); @@ -199,12 +200,13 @@ io.on('connection', (socket) => { socket.join(roomId); room.peers.add(socket.id); room.peerIds.set(socket.id, peerId); + room.peerData.set(socket.id, { peerId, tabTitle: null }); socketToRoom.set(socket.id, { roomId, peerId }); socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'joined' }); socket.emit(EVENTS.ROOM_DATA, { roomId, - peers: Array.from(room.peers).map(sid => room.peerIds.get(sid)) + peers: Array.from(room.peers).map(sid => room.peerData.get(sid)) }); log('ROOM', `Peer ${peerId} joined: ${roomId}`); } catch (err) { @@ -231,7 +233,13 @@ io.on('connection', (socket) => { const mapping = socketToRoom.get(socket.id); if (mapping) { const room = rooms.get(mapping.roomId); - if (room) room.lastActivity = Date.now(); + if (room) { + room.lastActivity = Date.now(); + // Update metadata if it's a peer_status (heartbeat) + if (eventName === EVENTS.PEER_STATUS && data.tabTitle) { + room.peerData.set(socket.id, { peerId: mapping.peerId, tabTitle: data.tabTitle }); + } + } socket.to(mapping.roomId).emit(eventName, { ...data, senderId: mapping.peerId }); } }); @@ -240,7 +248,8 @@ io.on('connection', (socket) => { socket.on(EVENTS.GET_ROOMS, () => { const list = Array.from(rooms.entries()).map(([id, r]) => ({ id, - peerCount: r.peers.size + peerCount: r.peers.size, + hasPassword: !!r.passwordHash })); socket.emit(EVENTS.ROOM_LIST, { rooms: list }); }); @@ -254,6 +263,7 @@ io.on('connection', (socket) => { if (room) { room.peers.delete(socket.id); room.peerIds.delete(socket.id); + room.peerData.delete(socket.id); socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' }); if (room.peers.size === 0) { rooms.delete(roomId); @@ -273,6 +283,7 @@ io.on('connection', (socket) => { if (room) { room.peers.delete(socket.id); room.peerIds.delete(socket.id); + room.peerData.delete(socket.id); socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' }); if (room.peers.size === 0) { rooms.delete(roomId); diff --git a/shared/constants.js b/shared/constants.js index 7e7c1f1..2e3da01 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -7,7 +7,7 @@ export const APP_VERSION = "1.0.0"; export const OFFICIAL_SERVER_URL = 'wss://sync.shik3i.net'; export const OFFICIAL_LANDING_PAGE_URL = 'https://koalasync.shik3i.net'; -export const OFFICIAL_SERVER_TOKEN = 'koala_secure_access_2026'; +export const OFFICIAL_SERVER_TOKEN = '62170b705234c4f4807a9b22420bb93cf1a2aacfa4c5d3b47804482babb8eb50'; export const EVENTS = { // Connection & Room