mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7096edd30 | |||
| e2b76b05a1 | |||
| 93acd0b44c | |||
| ce7b5c47f2 | |||
| 92fb8e2d00 | |||
| 43cde9bbef | |||
| 56955027f9 | |||
| 5ef059a94f |
@@ -85,6 +85,7 @@ The following features are critical and must not be removed or fundamentally alt
|
||||
- **Server Transport**: Restricted to `websocket` only. Polling is disabled.
|
||||
- **Docker Context**: The Docker build must run from the **Repo Root**.
|
||||
- **Manifest Settings**: `run_at` must remain `document_idle`, and `all_frames` must remain `false`.
|
||||
- **Strict Backward & Forward Compatibility (Store Delay Rule)**: Browser extensions are distributed through stores (e.g., Chrome Web Store, Firefox Add-ons) which can take up to 2 weeks to approve updates. Therefore, the server MUST NOT reject older extension clients unless a critical protocol version bump is explicitly authorized, and new extension versions MUST remain fully operational when connected to older servers (e.g., by silently falling back if a new feature is not supported). This is a core architectural requirement.
|
||||
|
||||
## 9. Security & Deployment
|
||||
- **Tokens**: Security tokens are intentionally managed via `shared/constants.js` and server `.env`.
|
||||
|
||||
+9
-20
@@ -1,26 +1,15 @@
|
||||
# KoalaSync Roadmap
|
||||
|
||||
This document tracks planned features, improvements, and their implementation details.
|
||||
Dieses Dokument erfasst zukünftige technische Pläne und Optimierungen für das KoalaSync-System.
|
||||
|
||||
---
|
||||
|
||||
## Offene technische Fragen
|
||||
## Geplante Optimierungen & Technische Roadmap
|
||||
|
||||
### 1. Service Worker Fallback bei Room-State Verlust
|
||||
Manifest V3 suspendiert den Service Worker nach ~30s Inaktivität. `chrome.alarms` weckt ihn auf, aber:
|
||||
- **Problem:** Wenn der SW neu startet, sind alle Variablen (`currentRoom`, `socket`, `isNamespaceJoined`) weg
|
||||
- **Aktueller Stand:** `chrome.storage.session` persistiert `currentRoom`, `peerId`, `eventQueue` — der SW stellt diese beim Start wieder her (`ensureState()`)
|
||||
- **Gelöst:** WebSocket wird automatisch via `connect()` neu aufgebaut. Events werden während Reconnect gequeued und nach Namespace-Join geflushed. "Reconnecting..." Status wird im Popup + Badge angezeigt. KeepAlive-Alarm auf 30s reduziert. Reconnect-Backoff: 500ms Basis, max 5s (statt vorher 1s→30s).
|
||||
|
||||
### 7. Tests für Extensions
|
||||
Stimmt, sind aufwändig. Praktische Ansätze:
|
||||
- **Unit Tests:** `jest` + `jest-chrome` (mockt `chrome.*` APIs) — testet `popup.js` Logik, Server-Logik
|
||||
- **Integration Tests:** `puppeteer` mit `--load-extension` Flag — testet Extension im echten Browser
|
||||
- **Server Tests:** `supertest` + `socket.io-client` — testet WebSocket-Flows
|
||||
- **Aufwand:** ~400-600 LOC für sinnvolle Testabdeckung der Kernlogik
|
||||
|
||||
---
|
||||
|
||||
## Zukünftige Features
|
||||
|
||||
Neue Features werden nur nach expliziter Freigabe hinzugefügt.
|
||||
### 1. Behebung des synchronen Sortierungs-Flaschenhalses bei Auth-Failure LRU-Eviction
|
||||
* **Kategorie**: Performance / DoS-Prävention
|
||||
* **Hintergrund**: Der Server schützt Räume vor Brute-Force-Angriffen durch die Nachverfolgung fehlerhafter Anmeldeversuche (`failedAuthAttempts` Map). Bei Erreichen des Limits von 50.000 Einträgen wird ein Bereinigungsverfahren gestartet, das die gesamte Map in ein Array konvertiert und dieses per `Array.from().sort()` sortiert.
|
||||
* **Problem**: Dies blockiert den Node.js-Main-Thread für mehrere Millisekunden und stellt einen potenziellen Denial-of-Service-Vektor (DoS) dar, wenn ein Angreifer gezielt Fehlversuche spamt.
|
||||
* **Geplante Lösung**:
|
||||
- Umstellung auf eine echte, $O(1)$-basierte LRU-Cache-Datenstruktur (z. B. doppelt verkettete Liste in Kombination mit einer Map).
|
||||
- Alternativ: Ein vereinfachtes zeitbasiertes Ablauf-Verfahren oder ein schrittweises Löschen von Segmenten (Chunk-Eviction), um Blockaden des Main-Threads vollständig auszuschließen.
|
||||
|
||||
+102
-14
@@ -18,6 +18,18 @@ let isNamespaceJoined = false;
|
||||
let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] };
|
||||
let localSeq = 0; // Monotonically increasing command sequence for this peer
|
||||
const lastSeqBySender = {}; // senderId → last received seq (stale command guard)
|
||||
const activePorts = new Set(); // New: track active content ports for keep-alive
|
||||
let expectedAcksCount = 0; // Snapshot of peerCount when initiating Force Sync
|
||||
|
||||
// --- Keep-Alive Port Listener ---
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
if (port.name === 'keepAlive') {
|
||||
activePorts.add(port);
|
||||
port.onDisconnect.addListener(() => {
|
||||
activePorts.delete(port);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function _persistLastSeq() {
|
||||
if (storageInitialized) chrome.storage.session.set({ lastSeqBySender });
|
||||
@@ -42,9 +54,10 @@ function ensureState() {
|
||||
'logs', 'history', 'currentRoom', 'lastActionState',
|
||||
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
|
||||
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle',
|
||||
'episodeLobby', 'localSeq', 'lastSeqBySender'
|
||||
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount'
|
||||
], (data) => {
|
||||
clearTimeout(storageTimeout);
|
||||
if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount;
|
||||
if (data.currentTabId !== undefined) currentTabId = data.currentTabId;
|
||||
if (data.currentTabTitle !== undefined) currentTabTitle = data.currentTabTitle;
|
||||
// Merge data from storage with any early-arriving state
|
||||
@@ -562,9 +575,47 @@ function handleServerEvent(event, data) {
|
||||
currentRoom = data;
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
currentRoom.peers = currentRoom.peers.map(p => typeof p === 'object' ? createPeerData(p) : { peerId: p, username: null, tabTitle: null, mediaTitle: null, playbackState: null, currentTime: null, volume: null, muted: null, lastHeartbeat: Date.now() });
|
||||
|
||||
// Clear sequence tracking for peers that are no longer in the room
|
||||
const activePeerIds = new Set(currentRoom.peers.map(p => typeof p === 'object' ? p.peerId : p));
|
||||
Object.keys(lastSeqBySender).forEach(pId => {
|
||||
if (!activePeerIds.has(pId)) {
|
||||
delete lastSeqBySender[pId];
|
||||
}
|
||||
});
|
||||
_persistLastSeq();
|
||||
} else if (currentRoom) {
|
||||
currentRoom.peers = [];
|
||||
}
|
||||
|
||||
// Recover server-tracked active Episode Lobby if present
|
||||
if (data && data.activeLobby && !episodeLobby) {
|
||||
episodeLobby = {
|
||||
expectedTitle: data.activeLobby.expectedTitle,
|
||||
initiatorPeerId: data.activeLobby.initiatorPeerId,
|
||||
readyPeers: data.activeLobby.readyPeers,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
persistEpisodeLobby();
|
||||
broadcastLobbyUpdate();
|
||||
addLog(`Recovered active episode lobby from server: "${episodeLobby.expectedTitle}"`, 'info');
|
||||
|
||||
// Notify content script to start polling
|
||||
if (currentTabId) {
|
||||
const tabId = parseInt(currentTabId);
|
||||
if (!isNaN(tabId)) {
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
type: 'EPISODE_LOBBY',
|
||||
expectedTitle: episodeLobby.expectedTitle
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule timeout if we don't already have one
|
||||
if (!episodeLobbyTimeout) {
|
||||
episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), EPISODE_LOBBY_TIMEOUT);
|
||||
}
|
||||
}
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
addLog(`Joined Room: ${data?.roomId || 'unknown'}`, 'success');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: data.peers }).catch(() => {});
|
||||
@@ -656,9 +707,9 @@ function handleServerEvent(event, data) {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if all peers responded
|
||||
const peerCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
|
||||
if (forceSyncAcks.size >= peerCount) {
|
||||
// Check if all peers responded using the snapshot count
|
||||
const targetCount = expectedAcksCount > 0 ? expectedAcksCount : (currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1);
|
||||
if (forceSyncAcks.size >= targetCount) {
|
||||
executeForceSync();
|
||||
}
|
||||
}
|
||||
@@ -716,6 +767,9 @@ function handleServerEvent(event, data) {
|
||||
if (!Array.isArray(currentRoom.peers)) currentRoom.peers = [];
|
||||
if (data.status === 'joined') {
|
||||
if (!currentRoom.peers.find(p => (p.peerId || p) === data.peerId)) {
|
||||
delete lastSeqBySender[data.peerId];
|
||||
_persistLastSeq();
|
||||
|
||||
currentRoom.peers.push(createPeerData(data));
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
@@ -734,8 +788,11 @@ function handleServerEvent(event, data) {
|
||||
}
|
||||
|
||||
if (isForceSyncInitiator) {
|
||||
const peerCount = Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
|
||||
if (forceSyncAcks.size >= peerCount) {
|
||||
forceSyncAcks.delete(data.peerId);
|
||||
chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) });
|
||||
expectedAcksCount = Math.max(1, currentRoom.peers ? currentRoom.peers.length : 1);
|
||||
chrome.storage.session.set({ expectedAcksCount });
|
||||
if (forceSyncAcks.size >= expectedAcksCount) {
|
||||
executeForceSync();
|
||||
}
|
||||
}
|
||||
@@ -815,6 +872,13 @@ function handleServerEvent(event, data) {
|
||||
}
|
||||
}
|
||||
break;
|
||||
case EVENTS.EPISODE_LOBBY_CANCEL:
|
||||
if (episodeLobby) {
|
||||
const title = episodeLobby.expectedTitle;
|
||||
clearEpisodeLobbyState();
|
||||
addLog(`Episode lobby for "${title}" cancelled by ${data.senderId || 'peer'}`, 'warn');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
addLog(`Received unknown event from server: ${event}`, 'warn');
|
||||
break;
|
||||
@@ -825,10 +889,12 @@ function executeForceSync() {
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
expectedAcksCount = 0;
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null
|
||||
forceSyncDeadline: null,
|
||||
expectedAcksCount: 0
|
||||
});
|
||||
|
||||
// Set all peers to playing and apply a reactive lock to block stale heartbeats
|
||||
@@ -882,6 +948,10 @@ function clearEpisodeLobbyState() {
|
||||
function cancelEpisodeLobby(reason) {
|
||||
if (!episodeLobby) return;
|
||||
const title = episodeLobby.expectedTitle;
|
||||
|
||||
// Broadcast cancellation to room
|
||||
emit(EVENTS.EPISODE_LOBBY_CANCEL, { peerId });
|
||||
|
||||
clearEpisodeLobbyState();
|
||||
addLog(`Episode lobby cancelled: ${reason} for "${title}"`, 'warn');
|
||||
|
||||
@@ -903,6 +973,7 @@ function executeEpisodeLobby() {
|
||||
|
||||
isForceSyncInitiator = true;
|
||||
forceSyncAcks.clear();
|
||||
expectedAcksCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
|
||||
const deadline = Date.now() + FORCE_SYNC_TIMEOUT;
|
||||
const timestamp = Date.now();
|
||||
updateLastAction(EVENTS.FORCE_SYNC_PREPARE, 'You', timestamp);
|
||||
@@ -911,7 +982,8 @@ function executeEpisodeLobby() {
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: true,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: deadline
|
||||
forceSyncDeadline: deadline,
|
||||
expectedAcksCount: expectedAcksCount
|
||||
});
|
||||
|
||||
const syncPayload = { targetTime: 0.0 };
|
||||
@@ -1046,11 +1118,13 @@ function leaveOldRoomIfSwitching(newRoomId) {
|
||||
// Reset force sync states
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
expectedAcksCount = 0;
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null
|
||||
forceSyncDeadline: null,
|
||||
expectedAcksCount: 0
|
||||
});
|
||||
|
||||
// Cancel any active episode lobby
|
||||
@@ -1125,6 +1199,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
expectedAcksCount = 0;
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
|
||||
// Cancel any active episode lobby
|
||||
@@ -1135,7 +1210,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null,
|
||||
episodeLobby: null
|
||||
episodeLobby: null,
|
||||
expectedAcksCount: 0
|
||||
});
|
||||
addLog('Left Room', 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
@@ -1151,7 +1227,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
emit(EVENTS.GET_ROOMS, {});
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'WEB_JOIN_REQUEST') {
|
||||
const { roomId, password, useCustomServer, serverUrl } = message;
|
||||
const { roomId: rawRoomId, password, useCustomServer, serverUrl } = message;
|
||||
const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : '';
|
||||
chrome.storage.sync.set({
|
||||
roomId,
|
||||
password,
|
||||
@@ -1218,11 +1295,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
isForceSyncInitiator = true;
|
||||
forceSyncAcks.clear();
|
||||
expectedAcksCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
|
||||
const deadline = Date.now() + FORCE_SYNC_TIMEOUT;
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: true,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: deadline
|
||||
forceSyncDeadline: deadline,
|
||||
expectedAcksCount: expectedAcksCount
|
||||
});
|
||||
addLog('Initiating Force Sync...', 'info');
|
||||
|
||||
@@ -1448,6 +1527,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else {
|
||||
sendResponse({ lobbyActive: false });
|
||||
}
|
||||
} else if (message.type === 'CANCEL_EPISODE_LOBBY') {
|
||||
if (episodeLobby) {
|
||||
cancelEpisodeLobby('Cancelled by user');
|
||||
sendResponse({ status: 'ok' });
|
||||
} else {
|
||||
sendResponse({ error: 'No active lobby' });
|
||||
}
|
||||
} else {
|
||||
// Final fallback to prevent channel hanging
|
||||
sendResponse({ error: 'unhandled_message' });
|
||||
@@ -1455,7 +1541,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
|
||||
// Tab removal listener
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
chrome.tabs.onRemoved.addListener(async (tabId) => {
|
||||
await ensureState();
|
||||
if (tabId === currentTabId) {
|
||||
const wasInRoom = !!currentRoom;
|
||||
currentTabId = null;
|
||||
@@ -1496,7 +1583,8 @@ chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
});
|
||||
|
||||
// Re-inject on full page refresh
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, _tab) => {
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
|
||||
await ensureState();
|
||||
if (currentTabId && tabId === parseInt(currentTabId) && changeInfo.status === 'complete') {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* global cloneInto */
|
||||
/**
|
||||
* KoalaSync Bridge Script
|
||||
* Injected into sync.koalastuff.net to facilitate communication between
|
||||
|
||||
+39
-5
@@ -52,7 +52,7 @@
|
||||
// --- Seek Relay Filtering ---
|
||||
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
|
||||
// from being relayed to peers as user-initiated seeks.
|
||||
const MIN_SEEK_DELTA = 3.0;
|
||||
const MIN_SEEK_DELTA = 2.0;
|
||||
let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK
|
||||
let seekDebounceTimer = null; // debounce timer for rapid seek events
|
||||
|
||||
@@ -81,7 +81,12 @@
|
||||
function findVideo(root = document) {
|
||||
const video = root.querySelector('video');
|
||||
if (video) return video;
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
|
||||
// Optimize: scan only potential player, video, media, and stream hosts by matching typical keywords (case-insensitive)
|
||||
// or common custom element tags. This prevents recursive scanning of thousands of standard DOM nodes (div, span, a, etc.)
|
||||
// while guaranteeing 100% airtight compatibility with all video web components in the wild.
|
||||
const potentialHosts = root.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player');
|
||||
for (const el of potentialHosts) {
|
||||
if (el.shadowRoot) {
|
||||
const found = findVideo(el.shadowRoot);
|
||||
if (found) return found;
|
||||
@@ -469,6 +474,17 @@
|
||||
|
||||
// Detect native events
|
||||
function reportEvent(action) {
|
||||
if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) {
|
||||
clearTimeout(seekDebounceTimer);
|
||||
seekDebounceTimer = null;
|
||||
const v = findVideo();
|
||||
if (v && Number.isFinite(v.currentTime)) {
|
||||
lastReportedSeekTime = v.currentTime;
|
||||
reportLog(`[Seek] Debounce flushed immediately due to ${action.toUpperCase()}`, 'info');
|
||||
reportEvent(EVENTS.SEEK);
|
||||
}
|
||||
}
|
||||
|
||||
const video = findVideo();
|
||||
if (!video) return;
|
||||
|
||||
@@ -512,7 +528,7 @@
|
||||
// pause immediately after switching back.
|
||||
let pageVisible = !document.hidden;
|
||||
let visibilityGraceUntil = 0;
|
||||
const VISIBILITY_GRACE_MS = 1000;
|
||||
const VISIBILITY_GRACE_MS = 300;
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
@@ -566,7 +582,7 @@
|
||||
}
|
||||
|
||||
// Step 4: Debounce rapid consecutive seeks (e.g. scrubbing)
|
||||
// — wait 800ms for the user to settle before relaying
|
||||
// — wait 300ms for the user to settle before relaying
|
||||
if (seekDebounceTimer) clearTimeout(seekDebounceTimer);
|
||||
seekDebounceTimer = setTimeout(() => {
|
||||
seekDebounceTimer = null;
|
||||
@@ -578,7 +594,7 @@
|
||||
lastReportedSeekTime = settled;
|
||||
reportLog(`[Seek] Relayed @ ${settled.toFixed(2)}s (${finalDeltaStr})`, 'info');
|
||||
reportEvent(EVENTS.SEEK);
|
||||
}, 800);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
|
||||
@@ -705,6 +721,24 @@
|
||||
|
||||
// Initial Setup
|
||||
setupListeners();
|
||||
|
||||
// Maintain a persistent keep-alive port connection to prevent background SW suspension
|
||||
let keepAlivePort = null;
|
||||
function connectKeepAlivePort() {
|
||||
try {
|
||||
if (chrome.runtime.id) {
|
||||
keepAlivePort = chrome.runtime.connect({ name: 'keepAlive' });
|
||||
keepAlivePort.onDisconnect.addListener(() => {
|
||||
keepAlivePort = null;
|
||||
setTimeout(connectKeepAlivePort, 1000);
|
||||
});
|
||||
}
|
||||
} catch (_e) {
|
||||
// Extension context invalidated or disabled
|
||||
}
|
||||
}
|
||||
connectKeepAlivePort();
|
||||
|
||||
schedulePeriodicHeartbeat();
|
||||
|
||||
// Immediate heartbeat on injection — populate peer data without waiting 15s
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KoalaSync",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.2",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
@@ -440,7 +440,8 @@
|
||||
<span style="font-weight: 700; color: var(--star); font-size: 12px;">EPISODE LOBBY</span>
|
||||
</div>
|
||||
<div id="lobbyTitle" style="font-size: 11px; color: var(--text); margin-bottom: 6px; font-weight: 600;"></div>
|
||||
<div id="lobbyPeerStatus" style="font-size: 10px; color: var(--text-muted);"></div>
|
||||
<div id="lobbyPeerStatus" style="font-size: 10px; color: var(--text-muted); margin-bottom: 8px;"></div>
|
||||
<button id="cancelLobbyBtn" class="secondary" style="margin-top: 4px; padding: 6px 10px; font-size: 11px; width: auto; display: block;" title="Cancel lobby and play anyway">Skip & Play anyway</button>
|
||||
</div>
|
||||
|
||||
<div id="peerListSync" class="info-card" style="display:none;"></div>
|
||||
|
||||
+53
-15
@@ -49,7 +49,8 @@ const elements = {
|
||||
lobbyPeerStatus: document.getElementById('lobbyPeerStatus'),
|
||||
browserNotifications: document.getElementById('browserNotifications'),
|
||||
autoCopyInvite: document.getElementById('autoCopyInvite'),
|
||||
syncTabCopyInvite: document.getElementById('syncTabCopyInvite')
|
||||
syncTabCopyInvite: document.getElementById('syncTabCopyInvite'),
|
||||
cancelLobbyBtn: document.getElementById('cancelLobbyBtn')
|
||||
};
|
||||
|
||||
let localPeerId = null;
|
||||
@@ -391,18 +392,20 @@ function updatePeerList(peers) {
|
||||
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding-right: 24px;';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.style.cssText = 'display: inline-flex; align-items: center; max-width: 200px; overflow: hidden; white-space: nowrap;';
|
||||
const avatar = getAvatarForName(pUsername || pId);
|
||||
if (pUsername) {
|
||||
const u = document.createElement('span');
|
||||
u.style.cssText = 'font-weight:600; color:white;';
|
||||
u.style.cssText = 'font-weight:600; color:white; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px; display: inline-block;';
|
||||
u.textContent = `${avatar} ${pUsername}`;
|
||||
const i = document.createElement('span');
|
||||
i.style.cssText = 'font-size:10px; opacity:0.6; font-style:italic;';
|
||||
i.style.cssText = 'font-size:10px; opacity:0.6; font-style:italic; white-space: nowrap; flex-shrink: 0;';
|
||||
i.textContent = ` (${pId})`;
|
||||
nameSpan.appendChild(u);
|
||||
nameSpan.appendChild(i);
|
||||
} else {
|
||||
nameSpan.style.fontWeight = '600';
|
||||
nameSpan.style.cssText = 'white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 170px;';
|
||||
nameSpan.textContent = `${avatar} ${pId}`;
|
||||
}
|
||||
|
||||
@@ -563,7 +566,9 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
const hostname = new URL(tab.url).hostname.toLowerCase();
|
||||
if (domain.endsWith('.')) return hostname.startsWith(domain) || hostname.includes('.' + domain);
|
||||
if (domain.includes('.')) return hostname === domain || hostname.endsWith('.' + domain);
|
||||
} catch {}
|
||||
} catch {
|
||||
/* ignore invalid URLs */
|
||||
}
|
||||
return urlStr.includes(domain);
|
||||
})) return false;
|
||||
}
|
||||
@@ -571,23 +576,32 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
});
|
||||
|
||||
// Smart Matching Logic — exclude own tabTitle to prevent self-match (computed once)
|
||||
const cleanTitle = (rawTitle) => {
|
||||
if (!rawTitle) return '';
|
||||
return rawTitle
|
||||
.replace(/(?:\s*[-\|•]\s*(?:YouTube|Twitch|Jellyfin|Emby|Netflix|Vimeo|Dailymotion).*)$/i, '')
|
||||
.replace(/^(?:Netflix|Twitch|YouTube|Emby|Jellyfin)\s*[-\|•]\s*/i, '')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const peerTitles = peerIds
|
||||
.filter(p => (typeof p === 'object' ? p.peerId : p) !== localPeerId)
|
||||
.map(p => (typeof p === 'object' ? p.tabTitle : null))
|
||||
.filter(t => t && t.length > 3);
|
||||
.filter(t => t && t.length > 3)
|
||||
.map(t => cleanTitle(t).toLowerCase())
|
||||
.filter(t => t.length > 3);
|
||||
|
||||
filteredTabs.forEach(tab => {
|
||||
const option = document.createElement('option');
|
||||
option.value = tab.id;
|
||||
const title = (tab.title || 'Loading...');
|
||||
const rawTitle = (tab.title || 'Loading...');
|
||||
const title = cleanTitle(rawTitle).toLowerCase();
|
||||
|
||||
const isMatch = peerTitles.some(pt => {
|
||||
const t1 = title.toLowerCase();
|
||||
const t2 = pt.toLowerCase();
|
||||
return t1.includes(t2) || t2.includes(t1);
|
||||
const isMatch = title.length > 3 && peerTitles.some(pt => {
|
||||
return title.includes(pt) || pt.includes(title);
|
||||
});
|
||||
|
||||
let label = title.substring(0, 45) + (title.length > 45 ? '...' : '');
|
||||
let label = rawTitle.substring(0, 45) + (rawTitle.length > 45 ? '...' : '');
|
||||
if (isMatch) {
|
||||
label = `⭐ MATCH: ${label}`;
|
||||
option.style.fontWeight = 'bold';
|
||||
@@ -928,6 +942,10 @@ function showError(msg) {
|
||||
}
|
||||
|
||||
// --- Action Handlers ---
|
||||
elements.roomId.addEventListener('input', () => {
|
||||
elements.roomId.value = elements.roomId.value.replace(/[^a-zA-Z0-9\-]/g, '');
|
||||
});
|
||||
|
||||
elements.joinBtn.addEventListener('click', async () => {
|
||||
if (elements.joinBtn.disabled) return;
|
||||
const roomIdInput = elements.roomId.value.trim();
|
||||
@@ -983,16 +1001,21 @@ elements.leaveBtn.addEventListener('click', async () => {
|
||||
});
|
||||
|
||||
function handleCreateRoom() {
|
||||
const generateId = () => Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
const roomId = generateId();
|
||||
const password = generateId();
|
||||
const secureGenerateId = (length = 6) => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const array = new Uint8Array(length);
|
||||
self.crypto.getRandomValues(array);
|
||||
return Array.from(array, byte => chars[byte % chars.length]).join('');
|
||||
};
|
||||
const roomId = secureGenerateId();
|
||||
const password = secureGenerateId();
|
||||
elements.roomId.value = roomId;
|
||||
elements.password.value = password;
|
||||
window.justCreatedRoom = true;
|
||||
|
||||
// Auto-connect
|
||||
elements.joinBtn.click();
|
||||
};
|
||||
}
|
||||
|
||||
elements.createRoomBtn.addEventListener('click', handleCreateRoom);
|
||||
const syncTabCreateRoomBtn = document.getElementById('syncTabCreateRoomBtn');
|
||||
@@ -1193,6 +1216,21 @@ if (elements.syncTabCopyInvite) {
|
||||
});
|
||||
}
|
||||
|
||||
if (elements.cancelLobbyBtn) {
|
||||
elements.cancelLobbyBtn.addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ type: 'CANCEL_EPISODE_LOBBY' }, (response) => {
|
||||
if (response && response.status === 'ok') {
|
||||
showToast('Episode Lobby skipped.', 'info');
|
||||
if (elements.episodeLobbyCard) {
|
||||
elements.episodeLobbyCard.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
showToast('Failed to skip lobby.', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Logs & Status ---
|
||||
async function refreshLogs() {
|
||||
chrome.runtime.sendMessage({ type: 'GET_LOGS' }, (logs) => {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.2",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
+58
-8
@@ -1,12 +1,18 @@
|
||||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../shared/constants.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
function hashPassword(password) {
|
||||
if (!password) return null;
|
||||
const salt = process.env.SERVER_SALT || 'koalasync_salt_3i';
|
||||
return crypto.createHmac('sha256', salt).update(password).digest('hex');
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -95,8 +101,24 @@ function checkAuthRate(ip, roomId) {
|
||||
|
||||
function recordAuthFailure(ip, roomId) {
|
||||
if (failedAuthAttempts.size > 50000) {
|
||||
failedAuthAttempts.clear();
|
||||
log('SECURITY', 'Cleared failedAuthAttempts map to prevent memory leak');
|
||||
const now = Date.now();
|
||||
// 1. Clear expired entries (> 15 mins)
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
failedAuthAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If still over 50k, perform LRU-style eviction on the oldest 10,000 entries
|
||||
if (failedAuthAttempts.size > 50000) {
|
||||
log('SECURITY', 'failedAuthAttempts size exceeded 50000. Performing LRU-style eviction.');
|
||||
const sortedEntries = Array.from(failedAuthAttempts.entries())
|
||||
.sort((a, b) => a[1].lastAttempt - b[1].lastAttempt);
|
||||
|
||||
for (let i = 0; i < 10000 && i < sortedEntries.length; i++) {
|
||||
failedAuthAttempts.delete(sortedEntries[i][0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
const key = `${ip}:${roomId}`;
|
||||
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
|
||||
@@ -196,7 +218,18 @@ function removePeerFromRoom(socketId, roomId, reason) {
|
||||
|
||||
// 3. Notify remaining peers (use io.to so the removed socket itself
|
||||
// doesn't receive it — it has already left or is disconnecting)
|
||||
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
|
||||
const isPeerStillConnected = Array.from(room.peerData.values()).some(data => data.peerId === peerId);
|
||||
if (!isPeerStillConnected) {
|
||||
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
|
||||
}
|
||||
|
||||
// 3.5. Clean up active lobby if a peer leaves
|
||||
if (room.activeLobby) {
|
||||
room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId);
|
||||
if (room.activeLobby.readyPeers.length <= 1 || room.activeLobby.initiatorPeerId === peerId) {
|
||||
room.activeLobby = null; // Dissolve lobby
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Delete empty room
|
||||
if (room.peers.size === 0) {
|
||||
@@ -324,7 +357,7 @@ io.on('connection', (socket) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = password ? await bcrypt.hash(password, 10) : null;
|
||||
const passwordHash = hashPassword(password);
|
||||
room = {
|
||||
passwordHash,
|
||||
peers: new Set(),
|
||||
@@ -349,7 +382,7 @@ io.on('connection', (socket) => {
|
||||
|
||||
if (!createdByMe) {
|
||||
if (room.passwordHash) {
|
||||
if (!password || !(await bcrypt.compare(password, room.passwordHash))) {
|
||||
if (!password || hashPassword(password) !== room.passwordHash) {
|
||||
recordAuthFailure(ip, roomId);
|
||||
log('AUTH', `Invalid password from ${ip} for room ${roomId.substring(0, 3)}***`);
|
||||
socket.emit(EVENTS.ERROR, { message: "Invalid password" });
|
||||
@@ -401,7 +434,8 @@ io.on('connection', (socket) => {
|
||||
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, username: username || null, tabTitle: tabTitle || null, mediaTitle: mediaTitle || null, status: 'joined' });
|
||||
socket.emit(EVENTS.ROOM_DATA, {
|
||||
roomId,
|
||||
peers: Array.from(room.peers).map(sid => room.peerData.get(sid))
|
||||
peers: Array.from(room.peers).map(sid => room.peerData.get(sid)),
|
||||
activeLobby: room.activeLobby || null
|
||||
});
|
||||
log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`);
|
||||
} catch (err) {
|
||||
@@ -417,7 +451,8 @@ io.on('connection', (socket) => {
|
||||
EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
|
||||
EVENTS.PEER_STATUS, EVENTS.FORCE_SYNC_PREPARE,
|
||||
EVENTS.FORCE_SYNC_ACK, EVENTS.FORCE_SYNC_EXECUTE,
|
||||
EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY
|
||||
EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY,
|
||||
EVENTS.EPISODE_LOBBY_CANCEL
|
||||
];
|
||||
|
||||
relayEvents.forEach(eventName => {
|
||||
@@ -477,6 +512,21 @@ io.on('connection', (socket) => {
|
||||
// Strip undefined keys for clean wire format
|
||||
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
|
||||
socket.to(mapping.roomId).emit(eventName, relayPayload);
|
||||
|
||||
// --- Side-effects: Server-side Episode Lobby Tracking ---
|
||||
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle) {
|
||||
room.activeLobby = {
|
||||
expectedTitle: relayPayload.expectedTitle,
|
||||
initiatorPeerId: mapping.peerId,
|
||||
readyPeers: [mapping.peerId]
|
||||
};
|
||||
} else if (eventName === EVENTS.EPISODE_READY && room.activeLobby) {
|
||||
if (!room.activeLobby.readyPeers.includes(mapping.peerId)) {
|
||||
room.activeLobby.readyPeers.push(mapping.peerId);
|
||||
}
|
||||
} else if ((eventName === EVENTS.FORCE_SYNC_PREPARE || eventName === EVENTS.FORCE_SYNC_EXECUTE || eventName === EVENTS.EPISODE_LOBBY_CANCEL) && room.activeLobby) {
|
||||
room.activeLobby = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
+2
-1
@@ -36,7 +36,8 @@ export const EVENTS = {
|
||||
|
||||
// Episode Auto-Sync
|
||||
EPISODE_LOBBY: "episode_lobby", // Broadcast: waiting for everyone on this episode
|
||||
EPISODE_READY: "episode_ready" // Response: loaded the episode and paused at 0:00
|
||||
EPISODE_READY: "episode_ready", // Response: loaded the episode and paused at 0:00
|
||||
EPISODE_LOBBY_CANCEL: "episode_lobby_cancel" // Broadcast: cancel active lobby and resume
|
||||
};
|
||||
|
||||
export const HEARTBEAT_INTERVAL = 15000; // 15s
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "1.8.10",
|
||||
"date": "2026-05-26T15:42:24Z"
|
||||
"version": "1.9.2",
|
||||
"date": "2026-05-29T23:35:36Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user