mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +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 @@
|
||||
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
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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\
|
||||
Executable
+12
@@ -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/"
|
||||
@@ -0,0 +1,4 @@
|
||||
PORT=3000
|
||||
MIN_VERSION=1.0.0
|
||||
MAX_ROOMS=1000
|
||||
MAX_PEERS_PER_ROOM=50
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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}`);
|
||||
});
|
||||
Generated
+1103
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
];
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user