mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-26 02:27:11 +00:00
Initial commit: Production-ready KoalaSync Monorepo with Manifest V3, Socket.IO Relay, and Two-Phase Sync
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# KoalaSync Chrome Extension
|
||||
|
||||
A Manifest V3 Chrome Extension for synchronized video playback.
|
||||
|
||||
## Key Features
|
||||
- **Manifest V3**: Using a modern Service Worker architecture.
|
||||
- **Native WebSockets**: No heavy libraries, uses the browser's native API.
|
||||
|
||||
## Privacy & Permissions
|
||||
KoalaSync requires `<all_urls>` permission to detect and interact with video elements (`<video>`) on any website.
|
||||
- **No Browsing History**: We do not track which sites you visit.
|
||||
- **No Telemetry**: There are no analytics or tracking scripts included.
|
||||
- **Local State**: Settings (Server URL, Room ID, Password) are stored only locally in your browser using `chrome.storage`.
|
||||
|
||||
## Installation
|
||||
1. Go to `chrome://extensions/`.
|
||||
2. Enable **Developer mode**.
|
||||
3. Click **Load unpacked** and select this folder.
|
||||
|
||||
## Development
|
||||
If you change `shared/constants.js`, remember to run the synchronization script:
|
||||
- Windows: `..\scripts\sync-constants.bat`
|
||||
- Linux/macOS: `../scripts/sync-constants.sh`
|
||||
@@ -0,0 +1,375 @@
|
||||
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION } from './shared/constants.js';
|
||||
|
||||
// --- State Management ---
|
||||
let socket = null;
|
||||
let reconnectDelay = 1000;
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
let isConnecting = false;
|
||||
let peerId = null; // initialized via getPeerId()
|
||||
let currentRoom = null;
|
||||
let currentTabId = null;
|
||||
let currentTabTitle = null; // New: for Smart Matching
|
||||
let logs = [];
|
||||
let history = []; // New: for Action History
|
||||
|
||||
// Force Sync Coordination
|
||||
let isForceSyncInitiator = false;
|
||||
let forceSyncAcks = new Set();
|
||||
let forceSyncTimeout = null;
|
||||
|
||||
// --- Storage Utils ---
|
||||
async function getPeerId() {
|
||||
const data = await chrome.storage.local.get(['peerId']);
|
||||
if (data.peerId) return data.peerId;
|
||||
const newId = self.crypto.randomUUID().substring(0, 8);
|
||||
await chrome.storage.local.set({ peerId: newId });
|
||||
return newId;
|
||||
}
|
||||
|
||||
async function getSettings() {
|
||||
return new Promise(resolve => {
|
||||
chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'targetTabId'], (data) => {
|
||||
resolve({
|
||||
serverUrl: data.serverUrl || '',
|
||||
useCustomServer: data.useCustomServer || false,
|
||||
roomId: data.roomId || '',
|
||||
password: data.password || '',
|
||||
targetTabId: data.targetTabId || null
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addLog(message, type = 'info') {
|
||||
const log = {
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
type
|
||||
};
|
||||
logs.unshift(log);
|
||||
if (logs.length > 50) logs.pop();
|
||||
chrome.runtime.sendMessage({ type: 'LOG_UPDATE', log }).catch(() => {});
|
||||
}
|
||||
|
||||
// --- WebSocket Client ---
|
||||
async function connect() {
|
||||
if (isConnecting || (socket && socket.readyState === WebSocket.OPEN)) return;
|
||||
|
||||
if (!peerId) peerId = await getPeerId();
|
||||
const settings = await getSettings();
|
||||
|
||||
isConnecting = true;
|
||||
broadcastConnectionStatus('connecting');
|
||||
const isCustomServer = settings.serverUrl && settings.useCustomServer;
|
||||
const finalUrl = isCustomServer ? settings.serverUrl : OFFICIAL_SERVER_URL;
|
||||
|
||||
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);
|
||||
|
||||
if (!isCustomServer) {
|
||||
url.searchParams.set('token', OFFICIAL_SERVER_TOKEN);
|
||||
}
|
||||
|
||||
socket = new WebSocket(url.toString());
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const msg = event.data;
|
||||
|
||||
// Engine.IO Ping/Pong
|
||||
if (msg === '2') {
|
||||
socket.send('3'); // Pong
|
||||
return;
|
||||
}
|
||||
|
||||
// Socket.IO Handshake / Packet parsing
|
||||
if (msg.startsWith('0')) {
|
||||
addLog(`Socket.IO Handshake: ${msg}`, 'info');
|
||||
} else if (msg.startsWith('40')) {
|
||||
addLog('Joined Namespace /', 'success');
|
||||
// Auto-rejoin room if we have one in settings
|
||||
if (settings.roomId) {
|
||||
emit(EVENTS.JOIN_ROOM, {
|
||||
roomId: settings.roomId,
|
||||
password: settings.password,
|
||||
peerId,
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
});
|
||||
}
|
||||
} else if (msg.startsWith('42')) {
|
||||
// Event: 42["event", data]
|
||||
try {
|
||||
const payload = JSON.parse(msg.substring(2));
|
||||
handleServerEvent(payload[0], payload[1]);
|
||||
} catch (e) {
|
||||
addLog(`Failed to parse message: ${msg}`, 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
isConnecting = false;
|
||||
if (currentRoom) {
|
||||
currentRoom.peers = [];
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
}
|
||||
broadcastConnectionStatus('disconnected');
|
||||
addLog(`Disconnected. Retrying in ${reconnectDelay / 1000}s...`, 'warn');
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = (err) => {
|
||||
broadcastConnectionStatus('disconnected');
|
||||
addLog('WebSocket Error', 'error');
|
||||
socket.close();
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastConnectionStatus(status) {
|
||||
chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status }).catch(() => {});
|
||||
updateBadgeStatus();
|
||||
}
|
||||
|
||||
function updateBadgeStatus() {
|
||||
if (currentTabId) {
|
||||
chrome.action.setBadgeText({ text: 'ON' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#22c55e' });
|
||||
} else {
|
||||
chrome.action.setBadgeText({ text: '' });
|
||||
}
|
||||
}
|
||||
|
||||
function showNotification(senderName, action) {
|
||||
const label = action === 'play' ? 'started playback' :
|
||||
action === 'pause' ? 'paused playback' :
|
||||
action === 'seek' ? 'seeked the video' :
|
||||
action === 'force_sync_execute' ? 'synchronized everyone' : action;
|
||||
|
||||
chrome.notifications.create(`sync_${Date.now()}`, {
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: 'KoalaSync',
|
||||
message: `${senderName || 'A peer'} ${label}.`,
|
||||
priority: 1
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
setTimeout(() => {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
}
|
||||
|
||||
function emit(event, data) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
const msg = `42${JSON.stringify([event, data])}`;
|
||||
socket.send(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event Handlers ---
|
||||
function handleServerEvent(event, data) {
|
||||
// console.log(`[RECV] ${event}`, data);
|
||||
|
||||
switch (event) {
|
||||
case EVENTS.ROOM_DATA:
|
||||
currentRoom = data;
|
||||
addLog(`Joined Room: ${data.roomId}`, 'success');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: data.peers }).catch(() => {});
|
||||
break;
|
||||
case EVENTS.ERROR:
|
||||
addLog(`Server Error: ${data.message}`, 'error');
|
||||
break;
|
||||
case EVENTS.PLAY:
|
||||
case EVENTS.PAUSE:
|
||||
case EVENTS.SEEK:
|
||||
case EVENTS.FORCE_SYNC_PREPARE:
|
||||
routeToContent(event, data);
|
||||
break;
|
||||
case EVENTS.FORCE_SYNC_ACK:
|
||||
if (isForceSyncInitiator) {
|
||||
forceSyncAcks.add(data.senderId);
|
||||
addLog(`Received ACK from ${data.senderId} (${forceSyncAcks.size})`, 'info');
|
||||
// Check if all peers responded (minus ourselves)
|
||||
const peerCount = currentRoom ? currentRoom.peers.length : 1;
|
||||
if (forceSyncAcks.size >= peerCount - 1) {
|
||||
executeForceSync();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case EVENTS.FORCE_SYNC_EXECUTE:
|
||||
routeToContent(event, data);
|
||||
break;
|
||||
case EVENTS.PEER_STATUS:
|
||||
if (currentRoom) {
|
||||
if (data.status === 'joined') {
|
||||
if (!currentRoom.peers.find(p => (p.peerId || p) === data.peerId)) {
|
||||
currentRoom.peers.push({ peerId: data.peerId, tabTitle: data.tabTitle });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
}
|
||||
} else if (data.status === 'left') {
|
||||
currentRoom.peers = currentRoom.peers.filter(p => (p.peerId || p) !== data.peerId);
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
} else {
|
||||
// Heartbeat/Update: Update tabTitle for matching
|
||||
const peer = currentRoom.peers.find(p => (p.peerId || p) === data.peerId);
|
||||
if (peer) {
|
||||
if (typeof peer === 'object') {
|
||||
peer.tabTitle = data.tabTitle;
|
||||
} else {
|
||||
// Migration: replace string with object
|
||||
const idx = currentRoom.peers.indexOf(peer);
|
||||
currentRoom.peers[idx] = { peerId: data.peerId, tabTitle: data.tabTitle };
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function executeForceSync() {
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
isForceSyncInitiator = false;
|
||||
emit(EVENTS.FORCE_SYNC_EXECUTE, {});
|
||||
routeToContent(EVENTS.FORCE_SYNC_EXECUTE, {});
|
||||
addLog('Force Sync Executed', 'success');
|
||||
}
|
||||
|
||||
async function routeToContent(action, payload) {
|
||||
if (!currentTabId) {
|
||||
const settings = await getSettings();
|
||||
currentTabId = settings.targetTabId;
|
||||
}
|
||||
if (!currentTabId) return;
|
||||
|
||||
const tabId = parseInt(currentTabId);
|
||||
if (isNaN(tabId)) return;
|
||||
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
type: 'SERVER_COMMAND',
|
||||
action,
|
||||
payload
|
||||
}).catch(err => {
|
||||
// Auto-Reinject if content script is missing
|
||||
if (err.message.includes('Receiving end does not exist')) {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
}).then(() => {
|
||||
setTimeout(() => routeToContent(action, payload), 500);
|
||||
});
|
||||
} else {
|
||||
addLog(`Content Script not responding in tab ${tabId}`, 'warn');
|
||||
currentTabId = null;
|
||||
updateBadgeStatus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Keep-Alive Mechanism ---
|
||||
chrome.alarms.create('keepAlive', { periodInMinutes: 0.25 }); // every 15s
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === 'keepAlive') {
|
||||
// console.log('SW KeepAlive Heartbeat');
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
connect();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Extension Message Listeners ---
|
||||
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 : [] });
|
||||
} else if (message.type === 'LEAVE_ROOM') {
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
currentRoom = null;
|
||||
addLog('Left Room', 'info');
|
||||
} else if (message.type === 'CLEAR_LOGS') {
|
||||
logs = [];
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'GET_LOGS') {
|
||||
sendResponse(logs);
|
||||
} else if (message.type === 'GET_HISTORY') {
|
||||
sendResponse(history);
|
||||
} else if (message.type === 'CONTENT_EVENT') {
|
||||
if (sender.tab) {
|
||||
currentTabId = sender.tab.id;
|
||||
currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null;
|
||||
updateBadgeStatus();
|
||||
}
|
||||
// Events coming from content script (manual play/pause)
|
||||
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
isForceSyncInitiator = true;
|
||||
forceSyncAcks.clear();
|
||||
addLog('Initiating Force Sync...', 'info');
|
||||
// Timeout if not everyone ACKs
|
||||
forceSyncTimeout = setTimeout(() => {
|
||||
if (isForceSyncInitiator) {
|
||||
addLog('Force Sync: Timeout waiting for ACKs, executing anyway...', 'warn');
|
||||
executeForceSync();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
emit(message.action, { ...message.payload, peerId });
|
||||
} else if (message.type === 'FORCE_SYNC_ACK') {
|
||||
emit(EVENTS.FORCE_SYNC_ACK, { peerId });
|
||||
} else if (message.type === 'HEARTBEAT') {
|
||||
if (sender.tab) {
|
||||
currentTabId = sender.tab.id;
|
||||
currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null;
|
||||
}
|
||||
// Peer status heartbeat from content script
|
||||
emit(EVENTS.PEER_STATUS, { ...message.payload, peerId, tabTitle: currentTabTitle });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Tab removal listener
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
if (tabId === currentTabId) {
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
chrome.storage.sync.set({ targetTabId: null });
|
||||
updateBadgeStatus();
|
||||
addLog('Target tab closed.', 'warn');
|
||||
}
|
||||
});
|
||||
|
||||
// Initial Connect
|
||||
connect();
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* KoalaSync Content Script
|
||||
* Injected into video tabs to control playback and detect events.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
if (window.koalaSyncInjected) return;
|
||||
window.koalaSyncInjected = true;
|
||||
|
||||
let isProcessingCommand = false;
|
||||
|
||||
// --- Helper: find the best video element on the page ---
|
||||
function findVideo() {
|
||||
const videos = document.querySelectorAll('video');
|
||||
return videos.length > 0 ? videos[0] : null;
|
||||
}
|
||||
|
||||
// --- Helper: YouTube/Twitch specific actions ---
|
||||
function tryMediaAction(action, data) {
|
||||
const video = findVideo();
|
||||
if (!video) return;
|
||||
|
||||
isProcessingCommand = true;
|
||||
try {
|
||||
const host = window.location.hostname.toLowerCase();
|
||||
const isYouTube = host.includes('youtube.com');
|
||||
const isTwitch = host.includes('twitch.tv');
|
||||
|
||||
if (isYouTube) {
|
||||
const ytButton = document.querySelector('.ytp-play-button');
|
||||
if (ytButton) {
|
||||
const title = ytButton.getAttribute('aria-label') || '';
|
||||
const isCurrentlyPlaying = title.toLowerCase().includes('pause');
|
||||
if ((action === 'play' && !isCurrentlyPlaying) || (action === 'pause' && isCurrentlyPlaying)) {
|
||||
ytButton.click();
|
||||
}
|
||||
}
|
||||
if (action === 'seek') video.currentTime = data.targetTime;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTwitch) {
|
||||
const twitchButton = document.querySelector('[data-a-target="player-play-pause-button"]');
|
||||
if (twitchButton) {
|
||||
const label = twitchButton.getAttribute('aria-label')?.toLowerCase() || '';
|
||||
// Check for common localized labels (pause, stoppen, arrête)
|
||||
const isCurrentlyPlaying = label.includes('pause') || label.includes('stoppen') || label.includes('arrête');
|
||||
if ((action === 'play' && !isCurrentlyPlaying) || (action === 'pause' && isCurrentlyPlaying)) {
|
||||
twitchButton.click();
|
||||
}
|
||||
}
|
||||
if (action === 'seek') video.currentTime = data.targetTime;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback for native HTML5
|
||||
if (action === 'play') {
|
||||
video.play().catch(() => {});
|
||||
} else if (action === 'pause') {
|
||||
video.pause();
|
||||
} else if (action === 'seek') {
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('KoalaSync Media Action Error:', e);
|
||||
} finally {
|
||||
// Guarantee reset even on early returns in YouTube/Twitch blocks
|
||||
setTimeout(() => { isProcessingCommand = false; }, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper: Wait until video is ready for playback (buffered & seeked) ---
|
||||
function pollSeekReady(targetTime, timeoutMs = 8000) {
|
||||
return new Promise((resolve) => {
|
||||
const video = findVideo();
|
||||
if (!video) { resolve(false); return; }
|
||||
|
||||
const interval = 150;
|
||||
let elapsed = 0;
|
||||
const timer = setInterval(() => {
|
||||
elapsed += interval;
|
||||
const timeDiff = Math.abs(video.currentTime - targetTime);
|
||||
const ready = video.readyState >= 3 && timeDiff < 1.0;
|
||||
if (ready) {
|
||||
clearInterval(timer);
|
||||
resolve(true);
|
||||
} else if (elapsed >= timeoutMs) {
|
||||
clearInterval(timer);
|
||||
resolve(false);
|
||||
}
|
||||
}, interval);
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for commands from background.js
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'SERVER_COMMAND') {
|
||||
const { action, payload } = message;
|
||||
|
||||
if (action === 'play') {
|
||||
tryMediaAction('play');
|
||||
} else if (action === 'pause') {
|
||||
tryMediaAction('pause');
|
||||
} else if (action === 'seek') {
|
||||
tryMediaAction('seek', payload);
|
||||
} else if (action === 'force_sync_prepare') {
|
||||
const video = findVideo();
|
||||
if (video) {
|
||||
video.pause();
|
||||
video.currentTime = payload.targetTime;
|
||||
pollSeekReady(payload.targetTime).then(() => {
|
||||
chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' });
|
||||
});
|
||||
}
|
||||
} else if (action === 'force_sync_execute') {
|
||||
tryMediaAction('play');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Detect native events
|
||||
function reportEvent(action) {
|
||||
if (isProcessingCommand) return;
|
||||
const video = findVideo();
|
||||
if (!video) return;
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'CONTENT_EVENT',
|
||||
action,
|
||||
payload: {
|
||||
currentTime: video.currentTime,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupListeners() {
|
||||
const video = findVideo();
|
||||
if (video && !video.dataset.koalaAttached) {
|
||||
video.addEventListener('play', () => reportEvent('play'));
|
||||
video.addEventListener('pause', () => reportEvent('pause'));
|
||||
video.addEventListener('seeked', () => reportEvent('seek'));
|
||||
video.dataset.koalaAttached = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
// Heartbeat
|
||||
setInterval(() => {
|
||||
const video = findVideo();
|
||||
if (video) {
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'HEARTBEAT',
|
||||
payload: {
|
||||
playbackState: video.paused ? 'paused' : 'playing',
|
||||
currentTime: video.currentTime
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
const observer = new MutationObserver(() => setupListeners());
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
setupListeners();
|
||||
|
||||
})();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 670 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 670 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 670 KiB |
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KoalaSync",
|
||||
"version": "1.0.0",
|
||||
"description": "Synchronize video playback across different tabs and users.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"tabs",
|
||||
"scripting",
|
||||
"alarms",
|
||||
"activeTab"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"run_at": "document_idle",
|
||||
"all_frames": false
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>KoalaSync</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--card: #1e293b;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
--text: #f8fafc;
|
||||
--text-muted: #94a3b8;
|
||||
--success: #22c55e;
|
||||
--error: #ef4444;
|
||||
--radius: 12px;
|
||||
--star: #fbbf24;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 320px;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--accent);
|
||||
text-align: center;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--card);
|
||||
padding: 4px;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input, select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--card);
|
||||
border: 1px solid #334155;
|
||||
color: white;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #334155;
|
||||
border: none;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Info Cards */
|
||||
.info-card {
|
||||
background: var(--card);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.peer-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.peer-item:last-child { border: 0; }
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.status-online { background: var(--success); box-shadow: 0 0 8px var(--success); }
|
||||
.status-offline { background: var(--error); }
|
||||
|
||||
/* Logs */
|
||||
#logList {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
background: #000;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.log-entry { margin-bottom: 4px; }
|
||||
.log-error { color: var(--error); }
|
||||
.log-success { color: var(--success); }
|
||||
|
||||
/* Invite Link */
|
||||
.invite-box {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.invite-box input { flex: 1; font-size: 11px; }
|
||||
.invite-box button { width: 40px; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>KoalaSync</h1>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="tab-room">Room</button>
|
||||
<button class="tab-btn" data-tab="tab-sync">Sync</button>
|
||||
<button class="tab-btn" data-tab="tab-dev">Dev</button>
|
||||
</div>
|
||||
|
||||
<!-- Room Tab -->
|
||||
<div id="tab-room" class="tab-content active">
|
||||
<div class="form-group">
|
||||
<label>Server</label>
|
||||
<div style="display:flex; gap:4px; margin-bottom:8px;">
|
||||
<button id="serverOfficial" class="tab-btn active" style="flex:1; padding:6px; font-size:11px;">Official</button>
|
||||
<button id="serverCustom" class="tab-btn" style="flex:1; padding:6px; font-size:11px;">Custom</button>
|
||||
</div>
|
||||
<input type="text" id="serverUrl" placeholder="wss://your-server:3000" style="display:none;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Room ID</label>
|
||||
<input type="text" id="roomId" placeholder="Leave empty to create">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password (Optional)</label>
|
||||
<input type="password" id="password" placeholder="Room password">
|
||||
</div>
|
||||
<button id="joinBtn" class="primary">Join / Create Room</button>
|
||||
|
||||
<div id="roomInfo" style="display:none; margin-top: 20px;">
|
||||
<label>Invite Link</label>
|
||||
<div class="invite-box">
|
||||
<input type="text" id="inviteLink" readonly>
|
||||
<button id="copyInvite" class="secondary">📋</button>
|
||||
</div>
|
||||
<button id="leaveBtn" class="secondary" style="color: var(--error);">Leave Room</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Tab -->
|
||||
<div id="tab-sync" class="tab-content">
|
||||
<div class="form-group">
|
||||
<label>Target Tab (Video Source)</label>
|
||||
<select id="targetTab">
|
||||
<option value="">-- Select a Tab --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button id="forceSyncBtn" class="primary" style="background: linear-gradient(135deg, #6366f1, #a855f7);">
|
||||
⚡ Force Sync Everyone
|
||||
</button>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<label>Peers in Room</label>
|
||||
<div id="peerList" class="info-card">
|
||||
<div style="text-align:center; color: var(--text-muted); font-size: 12px;">No peers connected</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>Recent Activity</label>
|
||||
<div id="historyList" class="info-card" style="max-height: 120px; overflow-y: auto; font-size: 11px; color: var(--text-muted);">
|
||||
<!-- History will be injected here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dev Tab -->
|
||||
<div id="tab-dev" class="tab-content">
|
||||
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px;">
|
||||
<label style="margin-bottom: 0;">Filter Noise Tabs</label>
|
||||
<input type="checkbox" id="filterNoise" style="width: auto;" checked>
|
||||
</div>
|
||||
|
||||
<label>Connection Status</label>
|
||||
<div id="connStatus" class="info-card" style="display:flex; align-items:center;">
|
||||
<span id="connDot" class="status-dot status-offline"></span>
|
||||
<span id="connText" style="flex:1;">Disconnected</span>
|
||||
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px;">Copy Logs</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px;">
|
||||
<label>Logs (Last 50)</label>
|
||||
<button id="clearLogs" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;">CLEAR</button>
|
||||
</div>
|
||||
<div id="logList"></div>
|
||||
</div>
|
||||
|
||||
<script src="popup.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,289 @@
|
||||
import { EVENTS } from './shared/constants.js';
|
||||
import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
|
||||
|
||||
const elements = {
|
||||
tabs: document.querySelectorAll('.tab-btn'),
|
||||
contents: document.querySelectorAll('.tab-content'),
|
||||
copyInvite: document.getElementById('copyInvite'),
|
||||
targetTab: document.getElementById('targetTab'),
|
||||
forceSyncBtn: document.getElementById('forceSyncBtn'),
|
||||
peerList: document.getElementById('peerList'),
|
||||
logList: document.getElementById('logList'),
|
||||
clearLogs: document.getElementById('clearLogs'),
|
||||
connDot: document.getElementById('connDot'),
|
||||
connText: document.getElementById('connText'),
|
||||
serverUrl: document.getElementById('serverUrl'),
|
||||
serverOfficial: document.getElementById('serverOfficial'),
|
||||
serverCustom: document.getElementById('serverCustom'),
|
||||
roomId: document.getElementById('roomId'),
|
||||
password: document.getElementById('password'),
|
||||
joinBtn: document.getElementById('joinBtn'),
|
||||
leaveBtn: document.getElementById('leaveBtn'),
|
||||
roomInfo: document.getElementById('roomInfo'),
|
||||
inviteLink: document.getElementById('inviteLink'),
|
||||
filterNoise: document.getElementById('filterNoise'),
|
||||
historyList: document.getElementById('historyList'),
|
||||
copyLogs: document.getElementById('copyLogs')
|
||||
};
|
||||
|
||||
// --- Initialization ---
|
||||
async function init() {
|
||||
// Load Settings
|
||||
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'targetTabId', 'filterNoise']);
|
||||
elements.serverUrl.value = data.serverUrl || '';
|
||||
elements.roomId.value = data.roomId || '';
|
||||
elements.password.value = data.password || '';
|
||||
elements.filterNoise.checked = data.filterNoise !== false;
|
||||
|
||||
if (data.useCustomServer) {
|
||||
setServerMode(true);
|
||||
} else {
|
||||
setServerMode(false);
|
||||
}
|
||||
|
||||
// Populate Tabs
|
||||
await populateTabs();
|
||||
|
||||
updateUI(data.roomId, data.password);
|
||||
refreshLogs();
|
||||
refreshHistory();
|
||||
|
||||
// Initial Status Check
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||||
if (res) {
|
||||
applyConnectionStatus(res.status);
|
||||
updatePeerList(res.peers);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- UI Logic ---
|
||||
function updateUI(roomId, password) {
|
||||
const inRoom = !!roomId;
|
||||
elements.roomInfo.style.display = inRoom ? 'block' : 'none';
|
||||
if (inRoom) {
|
||||
elements.inviteLink.value = `${roomId}${password ? '#' + password : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
function updatePeerList(peers) {
|
||||
if (!peers) return;
|
||||
elements.peerList.innerHTML = peers.map(id => `
|
||||
<div class="peer-item">
|
||||
<span>👤 ${id}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
// Re-populate tabs to update Star Matching when peers change
|
||||
populateTabs();
|
||||
}
|
||||
|
||||
async function populateTabs() {
|
||||
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 || [];
|
||||
|
||||
const tabs = await chrome.tabs.query({});
|
||||
|
||||
// Clear existing options except placeholder
|
||||
while (elements.targetTab.options.length > 1) {
|
||||
elements.targetTab.remove(1);
|
||||
}
|
||||
|
||||
const filteredTabs = tabs.filter(tab => {
|
||||
if (!tab.url || tab.url.startsWith('chrome://')) return false;
|
||||
if (isFilterActive && tab.id !== parseInt(currentTargetTabId)) {
|
||||
const urlStr = tab.url.toLowerCase();
|
||||
if (BLACKLIST_DOMAINS.some(d => urlStr.includes(d.toLowerCase()))) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
filteredTabs.forEach(tab => {
|
||||
const option = document.createElement('option');
|
||||
option.value = tab.id;
|
||||
const title = (tab.title || 'Loading...');
|
||||
|
||||
// Smart Matching Logic
|
||||
const peerTitles = peerIds.map(p => p.tabTitle).filter(t => t && t.length > 3);
|
||||
const isMatch = peerTitles.some(pt => {
|
||||
const t1 = title.toLowerCase();
|
||||
const t2 = pt.toLowerCase();
|
||||
return t1.includes(t2) || t2.includes(t1);
|
||||
});
|
||||
|
||||
let label = title.substring(0, 45) + (title.length > 45 ? '...' : '');
|
||||
if (isMatch) {
|
||||
label = `⭐ MATCH: ${label}`;
|
||||
option.style.fontWeight = 'bold';
|
||||
option.style.color = 'var(--star)';
|
||||
}
|
||||
|
||||
option.textContent = label;
|
||||
elements.targetTab.appendChild(option);
|
||||
});
|
||||
|
||||
// Sort: Matches first
|
||||
const options = Array.from(elements.targetTab.options);
|
||||
const placeholder = options.shift(); // Remove placeholder
|
||||
options.sort((a, b) => (b.textContent.includes('⭐') ? 1 : 0) - (a.textContent.includes('⭐') ? 1 : 0));
|
||||
elements.targetTab.innerHTML = '';
|
||||
elements.targetTab.appendChild(placeholder);
|
||||
options.forEach(opt => elements.targetTab.appendChild(opt));
|
||||
|
||||
if (currentTargetTabId) {
|
||||
elements.targetTab.value = currentTargetTabId;
|
||||
}
|
||||
}
|
||||
|
||||
function applyConnectionStatus(status) {
|
||||
const connected = status === 'connected';
|
||||
const connecting = status === 'connecting';
|
||||
elements.connDot.className = 'status-dot ' + (connected ? 'status-online' : 'status-offline');
|
||||
elements.connText.textContent = connected ? 'Connected' : (connecting ? 'Connecting...' : 'Disconnected');
|
||||
}
|
||||
|
||||
function updateHistory(history) {
|
||||
if (!history || !elements.historyList) return;
|
||||
if (history.length === 0) {
|
||||
elements.historyList.innerHTML = '<div style="text-align:center; padding: 10px;">No activity yet</div>';
|
||||
return;
|
||||
}
|
||||
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' ? '<span style="color:var(--accent)">You</span>' : item.senderId;
|
||||
return `<div style="margin-bottom: 4px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 2px;">
|
||||
<span style="color:#64748b">[${time}]</span> <b>${actionLabel}</b> by ${sender}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function refreshHistory() {
|
||||
chrome.runtime.sendMessage({ type: 'GET_HISTORY' }, (res) => {
|
||||
if (res) updateHistory(res);
|
||||
});
|
||||
}
|
||||
|
||||
function setServerMode(custom) {
|
||||
elements.serverOfficial.classList.toggle('active', !custom);
|
||||
elements.serverCustom.classList.toggle('active', custom);
|
||||
elements.serverUrl.style.display = custom ? 'block' : 'none';
|
||||
chrome.storage.sync.set({ useCustomServer: custom });
|
||||
}
|
||||
|
||||
elements.serverOfficial.addEventListener('click', () => setServerMode(false));
|
||||
elements.serverCustom.addEventListener('click', () => setServerMode(true));
|
||||
|
||||
elements.filterNoise.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ filterNoise: elements.filterNoise.checked }, () => {
|
||||
populateTabs();
|
||||
});
|
||||
});
|
||||
|
||||
elements.tabs.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
elements.tabs.forEach(b => b.classList.remove('active'));
|
||||
elements.contents.forEach(c => c.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById(btn.dataset.tab).classList.add('active');
|
||||
if (btn.dataset.tab === 'tab-sync') refreshHistory();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Action Handlers ---
|
||||
elements.joinBtn.addEventListener('click', async () => {
|
||||
const serverUrl = elements.serverUrl.value;
|
||||
const roomId = elements.roomId.value || Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
const password = elements.password.value;
|
||||
|
||||
await chrome.storage.sync.set({ serverUrl, roomId, password });
|
||||
elements.roomId.value = roomId;
|
||||
|
||||
// Tell background to connect
|
||||
chrome.runtime.sendMessage({ type: 'CONNECT' });
|
||||
updateUI(roomId, password);
|
||||
});
|
||||
|
||||
elements.leaveBtn.addEventListener('click', async () => {
|
||||
chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' });
|
||||
await chrome.storage.sync.set({ roomId: '', password: '' });
|
||||
elements.roomId.value = '';
|
||||
elements.password.value = '';
|
||||
updateUI(null, null);
|
||||
});
|
||||
|
||||
elements.targetTab.addEventListener('change', async () => {
|
||||
await chrome.storage.sync.set({ targetTabId: elements.targetTab.value });
|
||||
});
|
||||
|
||||
elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
const settings = await chrome.storage.sync.get(['targetTabId']);
|
||||
if (!settings.targetTabId) return;
|
||||
|
||||
chrome.tabs.sendMessage(parseInt(settings.targetTabId), { action: 'get_current_time' }, (response) => {
|
||||
if (response && response.currentTime !== undefined) {
|
||||
const time = parseFloat(response.currentTime);
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'CONTENT_EVENT',
|
||||
action: EVENTS.FORCE_SYNC_PREPARE,
|
||||
payload: { targetTime: time }
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
elements.clearLogs.addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ type: 'CLEAR_LOGS' }, () => {
|
||||
elements.logList.innerHTML = '';
|
||||
});
|
||||
});
|
||||
|
||||
elements.copyInvite.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(elements.inviteLink.value);
|
||||
elements.copyInvite.textContent = '✅';
|
||||
setTimeout(() => { elements.copyInvite.textContent = '📋'; }, 2000);
|
||||
});
|
||||
|
||||
// --- Logs & Status ---
|
||||
async function refreshLogs() {
|
||||
chrome.runtime.sendMessage({ type: 'GET_LOGS' }, (logs) => {
|
||||
if (logs) {
|
||||
elements.logList.innerHTML = logs.map(log => `
|
||||
<div class="log-entry log-${log.type}">
|
||||
[${log.timestamp.split('T')[1].split('.')[0]}] ${log.message}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (msg.type === 'LOG_UPDATE') {
|
||||
refreshLogs();
|
||||
} else if (msg.type === 'PEER_UPDATE') {
|
||||
updatePeerList(msg.peers);
|
||||
} else if (msg.type === 'CONNECTION_STATUS') {
|
||||
applyConnectionStatus(msg.status);
|
||||
} else if (msg.type === 'HISTORY_UPDATE') {
|
||||
updateHistory(msg.history);
|
||||
}
|
||||
});
|
||||
|
||||
elements.copyLogs.addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ type: 'GET_LOGS' }, (logs) => {
|
||||
if (!logs || logs.length === 0) return;
|
||||
const text = logs.map(l => `[${l.timestamp}] [${l.type}] ${l.message}`).join('\n');
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const original = elements.copyLogs.textContent;
|
||||
elements.copyLogs.textContent = 'Copied!';
|
||||
setTimeout(() => elements.copyLogs.textContent = original, 2000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
init();
|
||||
setInterval(refreshLogs, 5000);
|
||||
Reference in New Issue
Block a user