Initial commit: Production-ready KoalaSync Monorepo with Manifest V3, Socket.IO Relay, and Two-Phase Sync

This commit is contained in:
MacBook
2026-04-21 07:49:37 +02:00
commit 14033244e5
27 changed files with 3068 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
changelog:
exclude:
labels:
- ignore-for-release
authors:
- dependabot
categories:
- title: "🚀 Features"
labels:
- feature
- enhancement
- title: "🐛 Bug Fixes"
labels:
- bug
- fix
- title: "📝 Documentation"
labels:
- documentation
- docs
- title: "🏠 Internal"
labels:
- internal
- chore
+59
View File
@@ -0,0 +1,59 @@
name: Release KoalaSync
on:
push:
tags:
- 'v*'
jobs:
release-server:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: server/Dockerfile
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/koala-sync-server:latest
ghcr.io/${{ github.repository_owner }}/koala-sync-server:${{ github.ref_name }}
release-extension:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Create Extension Zip
run: |
zip -r koala-sync-extension.zip extension/ -x "*.DS_Store*"
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
files: koala-sync-extension.zip
name: Release ${{ github.ref_name }}
generate_release_notes: true
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+41
View File
@@ -0,0 +1,41 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Environment Variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
server/.env
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS Metadata
.DS_Store
.DS_Store?
._*
Thumbs.db
# IDEs
.vscode/
.idea/
*.swp
*.swo
# Build / Derived Files
dist/
build/
coverage/
# KoalaSync Specific
# We ignore the synced files in the extension folder to ensure
# the root 'shared/' remains the Single Source of Truth.
extension/shared/
+82
View File
@@ -0,0 +1,82 @@
# KoalaSync AI Onboarding (AI_INIT.md)
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.
---
## 1. Project Overview
KoalaSync is a specialized tool for **synchronized video playback** across multiple remote peers. It supports YouTube, Twitch, and native HTML5 video elements.
- **Users**: Friends or groups wanting to watch synchronized content together.
- **Workflow**: A user creates a room, shares an invite link (RoomID#Password), and all peers in that room are synchronized via a Node.js relay server.
## 2. Repository Structure
- `extension/`: Chrome Extension (Manifest V3). Contains background service worker, content scripts, and popup UI.
- `server/`: Node.js Relay Server using Socket.IO (WebSocket-only).
- `shared/`: **Single Source of Truth** for protocol constants and event names.
- `scripts/`: Utility scripts (e.g., `sync-constants.sh`).
- `docker-compose.yml`: Root-level orchestration for the relay server.
> [!IMPORTANT]
> `shared/constants.js` and `shared/blacklist.js` must be synchronized to the `extension/shared/` directory after every modification by running `./scripts/sync-constants.sh`.
## 3. Mandatory Reading
Before touching any code, you MUST read the following documents in order:
1. [ARCHITECTURE.md](ARCHITECTURE.md) Detailed communication flows and two-phase sync protocol.
2. [shared/README.md](shared/README.md) Protocol constants and synchronization requirements.
3. [extension/README.md](extension/README.md) Extension components and loading process.
4. [server/README.md](server/README.md) Server setup, Docker configuration, and security.
## 4. Design Guidelines
The popup UI follows a strict design system. Do not modify these variables or the layout structure without explicit approval.
- **Font**: 'Outfit' (Google Fonts).
- **Popup Width**: Fixed at `320px`.
- **Tab Structure**: Must maintain the **Room**, **Sync**, and **Dev** tabs.
- **CSS Variables**:
| Variable | Value | Purpose |
| :--- | :--- | :--- |
| `--bg` | `#0f172a` | Main background |
| `--card` | `#1e293b` | Form and info cards |
| `--accent` | `#6366f1` | Primary actions and branding |
| `--success` | `#22c55e` | Success states / Online dot |
| `--error` | `#ef4444` | Errors / Offline dot |
## 5. Non-Negotiables (Core Logic)
The following features are critical and must not be removed or fundamentally altered:
- **Two-Phase Force Sync**: The `Prepare``ACK``Execute` flow ensures all peers are buffered before playback resumes.
- **Platform Specifics**: Specialized click-logic for YouTube (`.ytp-play-button`) and Twitch play/pause buttons in `content.js`.
- **pollSeekReady()**: The polling mechanism that checks `video.readyState` and `currentTime` offset before acknowledging a sync command.
- **SW Keep-alive**: Use of `chrome.alarms` to prevent the Manifest V3 Service Worker from suspending.
- **Exponential Backoff**: Reconnection logic in `background.js` (1s → 30s max).
- **Rate Limiting**: IP-based connection limits (10/min) and socket-based event limits (30/10s) on the server.
- **Security**: Token validation during the initial WebSocket handshake.
- **Persistence**: `peerId` must be stored in `chrome.storage.local` to remain stable across sessions.
## 6. Technical Constraints
- **No Bundler**: The extension uses plain ES Modules. Do not introduce build steps or npm packages into the `extension/` folder.
- **Manual Protocol**: `background.js` implements a subset of the Socket.IO wire protocol (e.g., `42[...]` framing) to work with native WebSockets.
- **Server Transport**: Restricted to `websocket` only. Polling is disabled.
- **Docker Context**: The Docker build must run from the **Repo Root**, as it needs access to the `shared/` directory.
- **Manifest Settings**: `run_at` must remain `document_idle`, and `all_frames` must remain `false`.
## 7. Security & Deployment
- **Tokens**: `OFFICIAL_SERVER_TOKEN` and `OFFICIAL_SERVER_URL` are intentionally hardcoded in `constants.js` by design.
- **Environment**: `.env` is excluded via `.gitignore`. Only `.env.example` should be committed.
- **Revocation**: `MIN_VERSION` in the server configuration is the only way to deprecate old extension versions.
- **Token Rotation**: Requires updating `shared/constants.js`, running the sync script, incrementing the extension version, and re-deploying the server.
## 8. Common Workflows
### Adding a Protocol Event
1. Add the event name to `shared/constants.js`.
2. Run `./scripts/sync-constants.sh`.
3. Implement the handler in `server/index.js` and `background.js`.
### Testing Locally
1. Load `extension/` as an "Unpacked Extension" in Chrome.
2. Start the server from the root: `docker-compose up --build`.
3. Select "Custom" server in the popup and enter `ws://localhost:3000`.
### Locking Old Versions
1. Increase `APP_VERSION` in `shared/constants.js`.
2. Update `MIN_VERSION` in the server's `.env` file.
3. Restart the server.
+56
View File
@@ -0,0 +1,56 @@
# KoalaSync Architecture
This document describes the communication flows and internal logic of the KoalaSync system.
## 1. Extension Startup & Connection
- **Initialization**: On startup, `background.js` reads settings (Server URL, Last Room) from `chrome.storage.sync`.
- **WebSocket Handshake**:
1. Background creates a `new WebSocket` to `/socket.io/?EIO=4&transport=websocket&version=1.0.0&token=...`.
2. Server performs security checks:
- **IP Rate Limit**: Checks if the IP has exceeded 10 connections/min.
- **Auth Token**: If a server token is required, it must match.
- **Version Check**: Client version must be `>= MIN_VERSION`.
3. Server responds with an Engine.IO handshake (packet type `0`).
4. Background sends `40` to join the default Socket.IO namespace.
5. Server responds with `40`.
- **Room Join**: If a Room ID is stored, Background emits `42["join_room", {...}]`.
- **Reconnect Logic**: If the connection drops, Background uses an exponential backoff (1s, 2s, 4s... max 30s) to reconnect.
## 2. Media Event Synchronization
When a user presses Play/Pause in a synchronized tab:
1. **Detection**: `content.js` listens to native `play`/`pause` events on the `<video>` element.
2. **Reporting**: `content.js` sends a `CONTENT_EVENT` message to `background.js`.
3. **Emission**: `background.js` emits `42["play"|"pause", {...}]` to the server.
4. **Relay**: The Server forwards the event to all other sockets in the same room.
5. **Reception**: Other Extensions receive the event via WebSocket.
6. **Execution**: `background.js` sends a `SERVER_COMMAND` to its `content.js`.
7. **Control**: `content.js` calls `video.play()` or `video.pause()`.
- *Note*: It uses `isProcessingCommand` to prevent feedback loops.
## 3. Two-Phase Force Sync
This protocol ensures all peers are paused and buffered at the exact same timestamp before resuming playback.
1. **Initiation**: User clicks "Force Sync" in the popup.
2. **Preparation**:
- Popup asks Content Script for the current time.
- Background emits `FORCE_SYNC_PREPARE` with `targetTime`.
3. **Coordination**:
- Peers receive `PREPARE`, `content.js` pauses and seeks.
- Once `video.readyState >= 3` (buffered), `content.js` sends `FORCE_SYNC_ACK`.
- Background forwards ACK to the Initiator via Server.
4. **Execution**:
- Initiator collects ACKs. Once all peers have responded (or 5s timeout), Initiator emits `FORCE_SYNC_EXECUTE`.
- All peers receive `EXECUTE` and call `video.play()`.
## 4. Peer Lifecycle
- **Join**: Server sends `ROOM_DATA` to the joiner and `PEER_STATUS (joined)` to others.
- **Leave**:
- **Manual**: User clicks "Leave". Popup sends `LEAVE_ROOM` to Background -> Server.
- **Pruning**: If a socket disconnects, the Server automatically broadcasts `PEER_STATUS (left)` and deletes the room if empty.
- **Heartbeat**: `content.js` sends a status heartbeat every 15s to keep the peer list updated with current playback states.
## 5. Service Worker Keep-alive
Manifest V3 Service Workers are ephemeral. To keep the connection alive:
1. `chrome.alarms` triggers every 15 seconds.
2. The alarm listener checks the WebSocket `readyState`.
3. If not `OPEN`, it triggers a `connect()` attempt.
4. This keeps the background process "awake" enough to handle incoming WebSocket messages.
+62
View File
@@ -0,0 +1,62 @@
# KoalaSync
KoalaSync is a Chrome Extension and Relay Server for synchronized video playback (YouTube, Twitch, HTML5).
> [!TIP]
> **New Developers & AI Agents**: Please read [AI_INIT.md](AI_INIT.md) before starting work.
## Repository Structure
- `extension/`: Chrome Extension (Manifest V3).
- `server/`: Node.js + Socket.IO Relay Server.
- `shared/`: Shared protocol constants.
## Setup Instructions
### 1. Relay Server (Docker)
The server runs on Node.js using Socket.IO but is restricted to WebSocket transport for compatibility with native clients.
```bash
# From the root directory
docker-compose up -d --build
```
The server will be available at `ws://localhost:3000`.
### 2. Chrome Extension
1. Open Chrome and go to `chrome://extensions/`.
2. Enable **Developer mode** (top right).
3. Click **Load unpacked**.
4. Select the `extension/` folder.
## Usage
1. Open the extension popup.
2. Enter the Server URL (default: `ws://localhost:3000`).
3. Click **Join / Create Room**.
4. In the **Sync** tab, select the tab containing the video you want to sync.
5. Share the **Invite Link** (RoomID#Password) with your friends.
6. When they join, your play/pause/seek actions will be synchronized.
7. Use **Force Sync** to align everyone to your current timestamp.
## Technical Details
- **Manifest V3**: Uses a Service Worker for background tasks.
- **Native WebSockets**: The extension uses the native `WebSocket` API.
- > [!IMPORTANT]
- > **Socket.IO Compatibility**: The server must use **Socket.IO v4**. The extension implements a subset of the Engine.IO/Socket.IO wire protocol.
- **Keep-Alive**: A `chrome.alarms` mechanism keeps the Service Worker active.
- **Two-Phase Force Sync**: Uses `pollSeekReady` to ensure all peers are synchronized before resuming.
## Security & Privacy
> [!IMPORTANT]
> **Privacy by Design**: KoalaSync is built with extreme data parsimony in mind.
> - **No Databases**: The server stores absolutely nothing on disk. All room states and peer mappings exist only in RAM and are destroyed as soon as a room is empty or inactive.
> - **No Tracking**: There is zero telemetry, analytics, or user tracking in both the extension and the server.
> - **Minimal Logging**: The server logs only technical events (connections, errors, rate-limiting) with no personally identifiable information (PII).
> - **Extension Permissions**: The `<all_urls>` permission is required solely to detect and synchronize HTML5 video elements on any website you visit. No browsing history is ever transmitted or stored.
- > [!WARNING]
> **Invite Links**: Passwords in invite links (e.g., `RoomID#Password`) are shared in plaintext. This is a trade-off for convenience. For higher security, share the password via a secure channel.
## Troubleshooting
- **Logs**: Check the **Dev** tab in the extension popup for detailed connection logs.
- **Handshake**: Look for `Socket.IO Handshake: 0{...}` in the logs to verify successful connection.
- **Permissions**: Ensure you have granted the extension permission to access the video site's tab.
+13
View File
@@ -0,0 +1,13 @@
version: '3.8'
services:
server:
build:
context: .
dockerfile: server/Dockerfile
container_name: koala-sync-server
ports:
- "${PORT:-3000}:3000"
env_file:
- server/.env
restart: always
+23
View File
@@ -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`
+375
View File
@@ -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();
+165
View File
@@ -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

+45
View File
@@ -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"
}
}
+286
View File
@@ -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>
+289
View File
@@ -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);
+12
View File
@@ -0,0 +1,12 @@
@echo off
REM KoalaSync - Protocol Synchronization Script (Windows)
REM
REM This script copies the master constants.js file from the shared directory
REM to the extension directory. Since Chrome Extensions cannot load files
REM outside their root, this manual sync is required after any changes to
REM the shared protocol.
if not exist extension\shared mkdir extension\shared
copy /y shared\constants.js extension\shared\constants.js
copy /y shared\blacklist.js extension\shared\blacklist.js
echo ✓ constants.js and blacklist.js synced to extension\shared\
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# KoalaSync - Protocol Synchronization Script (Linux/macOS)
#
# This script copies the master constants.js file from the shared directory
# to the extension directory. Since Chrome Extensions cannot load files
# outside their root, this manual sync is required after any changes to
# the shared protocol.
mkdir -p extension/shared
cp shared/constants.js extension/shared/constants.js
cp shared/blacklist.js extension/shared/blacklist.js
echo "✓ constants.js and blacklist.js synced to extension/shared/"
+4
View File
@@ -0,0 +1,4 @@
PORT=3000
MIN_VERSION=1.0.0
MAX_ROOMS=1000
MAX_PEERS_PER_ROOM=50
+13
View File
@@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
# Copy shared protocol first
COPY shared/ ./shared/
# Copy server
COPY server/package*.json ./server/
RUN cd server && npm install --production
COPY server/ ./server/
WORKDIR /app/server
CMD ["node", "index.js"]
+38
View File
@@ -0,0 +1,38 @@
# KoalaSync Relay Server
A high-performance Node.js relay server for synchronized video playback.
## Key Features
- **Zero-Persistence**: No database. All state is held in RAM.
- **Privacy First**: No tracking, no logging of user data.
- **WebSocket Only**: High performance with minimal overhead.
## Setup
### Environment
Copy `.env.example` to `.env` and configure your settings.
```bash
PORT=3000
MAX_ROOMS=100
MAX_PEERS_PER_ROOM=20
MIN_VERSION=1.0.0
```
### Docker (Recommended)
The server is designed to run in a Docker container.
```bash
# Build from the repository root
docker build -t koala-sync-server -f server/Dockerfile .
```
### Manual Setup
```bash
cd server
npm install
npm start
```
## Security
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
- **Token Handshake**: Requires a valid token defined in `shared/constants.js`.
- **In-Memory**: Rooms are automatically pruned after 2 hours of inactivity.
+248
View File
@@ -0,0 +1,248 @@
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import bcrypt from 'bcryptjs';
import dotenv from 'dotenv';
import { EVENTS, OFFICIAL_SERVER_TOKEN } from '../shared/constants.js';
dotenv.config();
const PORT = process.env.PORT || 3000;
const MAX_ROOMS = parseInt(process.env.MAX_ROOMS) || 1000;
const MAX_PEERS_PER_ROOM = parseInt(process.env.MAX_PEERS_PER_ROOM) || 50;
const MIN_VERSION = process.env.MIN_VERSION || '1.0.0';
const app = express();
app.set('trust proxy', 1); // For real client IP through reverse proxy
// Health Check
app.get('/', (req, res) => res.json({ status: 'online', service: 'KoalaSync Relay' }));
const httpServer = createServer(app);
// Socket.IO setup with security constraints
const io = new Server(httpServer, {
cors: {
origin: "*",
methods: ["GET", "POST"]
},
maxHttpBufferSize: 1024, // 1KB max per message
transports: ['websocket'],
allowUpgrades: false
});
/**
* In-memory storage
*/
const rooms = new Map();
const socketToRoom = new Map();
function log(type, message, details = '') {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${type}] ${message}`, details);
}
// Rate Limiting Storage
const connectionCounts = new Map(); // ip -> { count, resetTime }
const eventCounts = new Map(); // socketId -> { count, resetTime }
function checkConnectionRate(ip) {
const now = Date.now();
const entry = connectionCounts.get(ip) || { count: 0, resetTime: now + 60000 };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
entry.count++;
connectionCounts.set(ip, entry);
return entry.count <= 10;
}
function checkEventRate(socketId) {
const now = Date.now();
const entry = eventCounts.get(socketId) || { count: 0, resetTime: now + 10000 };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 10000; }
entry.count++;
eventCounts.set(socketId, entry);
return entry.count <= 30;
}
io.on('connection', (socket) => {
const clientIp = socket.handshake.address;
// 1. Connection Rate Limit
if (!checkConnectionRate(clientIp)) {
log('SECURITY', `Rate limit exceeded for IP: ${clientIp}`);
socket.disconnect(true);
return;
}
// 2. Token & Version Validation
const clientToken = socket.handshake.query.token;
const clientVersion = socket.handshake.query.version;
if (clientToken !== OFFICIAL_SERVER_TOKEN) {
log('AUTH', `Unauthorized connection attempt from ${clientIp}`);
socket.emit(EVENTS.ERROR, { message: 'Unauthorized' });
socket.disconnect(true);
return;
}
if (clientVersion) {
const [cMaj, cMin, cPatch] = clientVersion.split('.').map(Number);
const [mMaj, mMin, mPatch] = MIN_VERSION.split('.').map(Number);
const tooOld = cMaj < mMaj || (cMaj === mMaj && cMin < mMin) || (cMaj === mMaj && cMin === mMin && cPatch < mPatch);
if (tooOld) {
log('AUTH', `Version too old (${clientVersion}) from ${clientIp}`);
socket.emit(EVENTS.ERROR, { message: `Version too old. Minimum: ${MIN_VERSION}` });
socket.disconnect(true);
return;
}
}
log('CONN', `New connection: ${socket.id} from ${clientIp}`);
socket.on(EVENTS.JOIN_ROOM, async ({ roomId, password, peerId, protocolVersion }) => {
try {
// Protocol check
if (protocolVersion !== '1.0.0') {
log('AUTH', `Protocol mismatch from ${peerId}: ${protocolVersion}`);
socket.emit(EVENTS.ERROR, { message: 'Incompatible protocol version' });
return;
}
// Cleanup old room if re-joining
const oldMapping = socketToRoom.get(socket.id);
if (oldMapping && oldMapping.roomId !== roomId) {
socket.leave(oldMapping.roomId);
const oldRoom = rooms.get(oldMapping.roomId);
if (oldRoom) {
oldRoom.peers.delete(socket.id);
oldRoom.peerIds.delete(socket.id);
socket.to(oldMapping.roomId).emit(EVENTS.PEER_STATUS, { peerId: oldMapping.peerId, status: 'left' });
if (oldRoom.peers.size === 0) rooms.delete(oldMapping.roomId);
}
}
let room = rooms.get(roomId);
if (!room) {
if (rooms.size >= MAX_ROOMS) {
socket.emit(EVENTS.ERROR, { message: "Server capacity reached" });
return;
}
const passwordHash = password ? await bcrypt.hash(password, 10) : null;
room = {
passwordHash,
peers: new Set(),
peerIds: new Map(),
lastActivity: Date.now()
};
rooms.set(roomId, room);
log('ROOM', `Created room: ${roomId}`);
} else {
if (room.passwordHash) {
if (!password || !(await bcrypt.compare(password, room.passwordHash))) {
socket.emit(EVENTS.ERROR, { message: "Invalid password" });
return;
}
}
if (room.peers.size >= MAX_PEERS_PER_ROOM) {
socket.emit(EVENTS.ERROR, { message: "Room full" });
return;
}
}
socket.join(roomId);
room.peers.add(socket.id);
room.peerIds.set(socket.id, peerId);
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))
});
log('ROOM', `Peer ${peerId} joined: ${roomId}`);
} catch (err) {
log('ERROR', `Join error for ${socket.id}`, err);
socket.emit(EVENTS.ERROR, { message: "Join error" });
}
});
// Relay Loop with Rate Limiting
const relayEvents = [
EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
EVENTS.PEER_STATUS, EVENTS.FORCE_SYNC_PREPARE,
EVENTS.FORCE_SYNC_ACK, EVENTS.FORCE_SYNC_EXECUTE
];
relayEvents.forEach(eventName => {
socket.on(eventName, (data) => {
if (!checkEventRate(socket.id)) {
log('SECURITY', `Event rate limit exceeded for socket: ${socket.id}`);
socket.disconnect(true);
return;
}
const mapping = socketToRoom.get(socket.id);
if (mapping) {
const room = rooms.get(mapping.roomId);
if (room) room.lastActivity = Date.now();
socket.to(mapping.roomId).emit(eventName, { ...data, senderId: mapping.peerId });
}
});
});
socket.on(EVENTS.LEAVE_ROOM, () => {
const mapping = socketToRoom.get(socket.id);
if (mapping) {
const { roomId, peerId } = mapping;
socket.leave(roomId);
const room = rooms.get(roomId);
if (room) {
room.peers.delete(socket.id);
room.peerIds.delete(socket.id);
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
if (room.peers.size === 0) {
rooms.delete(roomId);
log('ROOM', `Deleted empty room: ${roomId}`);
}
}
socketToRoom.delete(socket.id);
}
});
socket.on('disconnect', () => {
eventCounts.delete(socket.id);
const mapping = socketToRoom.get(socket.id);
if (mapping) {
const { roomId, peerId } = mapping;
const room = rooms.get(roomId);
if (room) {
room.peers.delete(socket.id);
room.peerIds.delete(socket.id);
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
if (room.peers.size === 0) {
rooms.delete(roomId);
log('ROOM', `Deleted empty room (after disconnect): ${roomId}`);
}
}
socketToRoom.delete(socket.id);
}
});
});
// Inactive Room Cleanup (Every 30m)
setInterval(() => {
const cutoff = Date.now() - (2 * 60 * 60 * 1000); // 2 hours
for (const [roomId, room] of rooms) {
if (room.lastActivity < cutoff) {
io.to(roomId).emit(EVENTS.ERROR, { message: 'Room closed due to inactivity' });
rooms.delete(roomId);
log('CLEANUP', `Deleted inactive room: ${roomId}`);
}
}
}, 30 * 60 * 1000);
httpServer.listen(PORT, () => {
log('SERVER', `KoalaSync Relay running on port ${PORT}`);
});
+1103
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"socket.io": "^4.8.3"
}
}
+23
View File
@@ -0,0 +1,23 @@
# KoalaSync Shared Constants
This directory contains constants and protocol definitions used by both the extension and the server.
## Syncing with the Extension
> [!IMPORTANT]
> Every time this file is modified, you must run `scripts/sync-constants.sh` to keep the extension's copy up to date.
Because Chrome Extensions cannot load files outside their root directory, `constants.js` must be copied to `extension/shared/constants.js` whenever it is modified.
## Security & Versioning Constants
- `OFFICIAL_SERVER_TOKEN`: A 32-byte hex token required to connect to the official relay server.
- `APP_VERSION`: The current version of the extension. Used by the server to enforce minimum version requirements (Revocation). This must always be in sync with `manifest.json`.
- `OFFICIAL_SERVER_URL`: The default endpoint for the official KoalaSync relay.
- `ROOM_DATA`: Server response with current room state (peers).
- `PLAY`: Sync command to start playback.
- `PAUSE`: Sync command to pause playback.
- `SEEK`: Sync command to change the current time.
- `PEER_STATUS`: Heartbeat or join/leave notification for peers.
- `FORCE_SYNC_PREPARE`: Phase 1 of Force Sync (Pause & Seek).
- `FORCE_SYNC_ACK`: Peer confirmation of Phase 1 readiness.
- `FORCE_SYNC_EXECUTE`: Phase 2 of Force Sync (Start Playback).
- `ERROR`: Generic error message from the server.
+46
View File
@@ -0,0 +1,46 @@
/**
* blacklist.js
*
* Domains to be filtered out from the tab selection dropdown to reduce "noise".
* These are typically sites that won't contain shareable video content.
*/
export const BLACKLIST_DOMAINS = [
// Mail Providers
'mail.google.com',
'outlook.live.com',
'outlook.office.com',
'gmx.net',
'web.de',
// Messengers
'web.whatsapp.com',
'web.telegram.org',
'discord.com',
'element.io',
'app.slack.com',
// Productivity & Project Management
'atlassian.net',
'jira',
'trello.com',
'notion.so',
'monday.com',
'asana.com',
'github.com',
'gitlab.com',
'bitbucket.org',
// Social Media
'linkedin.com',
'twitter.com',
'x.com',
'facebook.com',
'instagram.com',
// Development & Utilities
'timer.shik3i.net',
'localhost',
'zoom.us',
'teams.microsoft.com',
'meet.google.com'
];
+31
View File
@@ -0,0 +1,31 @@
/**
* KoalaSync Shared Constants & Protocol Definitions
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "1.0.0";
export const OFFICIAL_SERVER_URL = 'wss://sync.shik3i.net';
export const OFFICIAL_SERVER_TOKEN = '39ce48b42aa442a34b80cfe2d7314177d4c725c4316b1f83aa3a1e2f0f5c5bfd';
export const EVENTS = {
// Connection & Room
JOIN_ROOM: "join_room",
LEAVE_ROOM: "leave_room",
ROOM_DATA: "room_data", // Server -> Client: current room state
ERROR: "error",
// Media Control
PLAY: "play",
PAUSE: "pause",
SEEK: "seek",
// Sync Coordination
PEER_STATUS: "peer_status", // Heartbeat from peers
FORCE_SYNC_PREPARE: "force_sync_prepare",
FORCE_SYNC_ACK: "force_sync_ack",
FORCE_SYNC_EXECUTE: "force_sync_execute"
};
export const HEARTBEAT_INTERVAL = 15000; // 15s
export const FORCE_SYNC_TIMEOUT = 5000; // 5s timeout for ACKs