Compare commits

...

8 Commits

9 changed files with 196 additions and 104 deletions
+1
View File
@@ -40,6 +40,7 @@ jobs:
context: .
file: server/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+2 -1
View File
@@ -38,7 +38,8 @@ 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/
extension/shared/*
!extension/shared/README.md
# Temporary scratch files
scratch/
+122 -54
View File
@@ -30,8 +30,10 @@ function ensureState() {
chrome.storage.session.get([
'logs', 'history', 'currentRoom', 'lastActionState',
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime'
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle'
], (data) => {
if (data.currentTabId !== undefined) currentTabId = data.currentTabId;
if (data.currentTabTitle !== undefined) currentTabTitle = data.currentTabTitle;
// Merge data from storage with any early-arriving state
// New entries (added during boot) must stay at the top (index 0)
if (data.logs) logs = [...logs, ...data.logs].slice(0, 50);
@@ -121,13 +123,12 @@ async function getPeerId() {
async function getSettings() {
return new Promise(resolve => {
chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'targetTabId', 'username'], (data) => {
chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username'], (data) => {
resolve({
serverUrl: data.serverUrl || '',
useCustomServer: data.useCustomServer || false,
roomId: data.roomId || '',
password: data.password || '',
targetTabId: data.targetTabId || null,
username: data.username || ''
});
});
@@ -334,7 +335,7 @@ function updateBadgeStatus() {
} else if (status === 'connecting') {
chrome.action.setBadgeText({ text: '...' });
chrome.action.setBadgeBackgroundColor({ color: '#fbbf24' });
} else if (status === 'connected' && currentTabId) {
} else if (status === 'connected' && currentRoom && currentTabId) {
chrome.action.setBadgeText({ text: 'ON' });
chrome.action.setBadgeBackgroundColor({ color: '#22c55e' });
} else {
@@ -518,7 +519,12 @@ 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)) {
currentRoom.peers.push({ peerId: data.peerId, username: data.username, tabTitle: data.tabTitle });
currentRoom.peers.push({
peerId: data.peerId,
username: data.username,
tabTitle: data.tabTitle,
mediaTitle: data.mediaTitle || null
});
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
}
@@ -533,10 +539,16 @@ function handleServerEvent(event, data) {
if (typeof peer === 'object') {
peer.tabTitle = data.tabTitle;
peer.username = data.username;
peer.mediaTitle = data.mediaTitle !== undefined ? data.mediaTitle : peer.mediaTitle;
} else {
// Migration: replace string with object
const idx = currentRoom.peers.indexOf(peer);
currentRoom.peers[idx] = { peerId: data.peerId, username: data.username, tabTitle: data.tabTitle };
currentRoom.peers[idx] = {
peerId: data.peerId,
username: data.username,
tabTitle: data.tabTitle,
mediaTitle: data.mediaTitle || null
};
}
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
@@ -576,10 +588,6 @@ function updateLastAction(action, senderId, timestamp = Date.now()) {
}
async function routeToContent(action, payload) {
if (!currentTabId) {
const settings = await getSettings();
currentTabId = settings.targetTabId;
}
if (!currentTabId) return;
const tabId = parseInt(currentTabId);
@@ -679,11 +687,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else {
connect();
}
sendResponse({ status: 'ok' });
} else if (message.type === 'RETRY_CONNECT') {
reconnectFailed = false;
reconnectStartTime = null;
reconnectDelay = 1000;
connect();
sendResponse({ status: 'ok' });
} else if (message.type === 'GET_STATUS') {
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : 'disconnected');
@@ -714,6 +724,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
});
addLog('Left Room', 'info');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
sendResponse({ status: 'ok' });
} else if (message.type === 'CLEAR_LOGS') {
logs = [];
sendResponse({ status: 'ok' });
@@ -725,6 +736,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(`42${JSON.stringify([EVENTS.GET_ROOMS])}`);
}
sendResponse({ status: 'ok' });
} else if (message.type === 'WEB_JOIN_REQUEST') {
const { roomId, password, useCustomServer, serverUrl } = message;
chrome.storage.sync.set({
@@ -751,7 +763,6 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
sendResponse({ status: 'ok' });
});
return true; // Keep channel open for async getSettings
} else if (message.type === 'REGENERATE_ID') {
const newId = self.crypto.randomUUID().substring(0, 8);
chrome.storage.local.set({ peerId: newId }, () => {
@@ -760,7 +771,6 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (socket) socket.close(); // Force reconnect with new ID
sendResponse({ peerId: newId });
});
return true;
} else if (message.type === 'GET_VIDEO_STATE') {
const { tabId } = message;
if (!tabId) {
@@ -774,47 +784,53 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse(res);
}
});
return true; // Keep channel open
} 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();
} else {
// Event coming from POPUP: We must also route it to our OWN content script
routeToContent(message.action, message.payload);
}
// Update local state as initiator
const timestamp = Date.now();
updateLastAction(message.action, 'You', timestamp);
message.payload.actionTimestamp = timestamp;
// Events coming from content script or popup
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
isForceSyncInitiator = true;
forceSyncAcks.clear();
const deadline = Date.now() + 5000;
chrome.storage.session.set({
isForceSyncInitiator: true,
forceSyncAcks: [],
forceSyncDeadline: deadline
});
addLog('Initiating Force Sync...', 'info');
const processEvent = () => {
const timestamp = Date.now();
updateLastAction(message.action, 'You', timestamp);
message.payload.actionTimestamp = timestamp;
// Route to our own content script so we pause and seek
routeToContent(EVENTS.FORCE_SYNC_PREPARE, message.payload);
// Timeout if not everyone ACKs
forceSyncTimeout = setTimeout(() => {
if (isForceSyncInitiator) {
addLog('Force Sync: Timeout waiting for ACKs, executing anyway...', 'warn');
executeForceSync();
}
}, 5000);
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
isForceSyncInitiator = true;
forceSyncAcks.clear();
const deadline = Date.now() + 5000;
chrome.storage.session.set({
isForceSyncInitiator: true,
forceSyncAcks: [],
forceSyncDeadline: deadline
});
addLog('Initiating Force Sync...', 'info');
routeToContent(EVENTS.FORCE_SYNC_PREPARE, message.payload);
forceSyncTimeout = setTimeout(() => {
if (isForceSyncInitiator) {
addLog('Force Sync: Timeout waiting for ACKs, executing anyway...', 'warn');
executeForceSync();
}
}, 5000);
}
addToHistory(message.action, 'You');
emit(message.action, { ...message.payload, peerId });
sendResponse({ status: 'ok' });
};
if (sender.tab) {
const senderTabId = sender.tab.id;
if (!currentTabId || currentTabId !== senderTabId) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null;
chrome.storage.session.set({ currentTabTitle });
updateBadgeStatus();
processEvent();
} else {
routeToContent(message.action, message.payload);
processEvent();
}
addToHistory(message.action, 'You');
emit(message.action, { ...message.payload, peerId });
} else if (message.type === 'FORCE_SYNC_ACK') {
if (isForceSyncInitiator) {
forceSyncAcks.add(peerId);
@@ -827,6 +843,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else {
emit(EVENTS.FORCE_SYNC_ACK, { peerId });
}
sendResponse({ status: 'ok' });
} else if (message.type === 'CMD_ACK') {
// Content script successfully ran a command. Send ACK back to the initiator.
if (currentCommandSenderId && currentCommandSenderId !== peerId) {
@@ -836,17 +853,58 @@ async function handleAsyncMessage(message, sender, sendResponse) {
actionTimestamp: message.actionTimestamp
});
}
sendResponse({ status: 'ok' });
} else if (message.type === 'HEARTBEAT') {
if (sender.tab) {
currentTabId = sender.tab.id;
const senderTabId = sender.tab.id;
if (!currentTabId || currentTabId !== senderTabId) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null;
chrome.storage.session.set({ currentTabTitle });
updateBadgeStatus();
}
// Peer status heartbeat from content script
getSettings().then(settings => {
emit(EVENTS.PEER_STATUS, { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle });
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle };
emit(EVENTS.PEER_STATUS, statusPayload);
if (currentRoom && currentRoom.peers) {
const me = currentRoom.peers.find(p => (p.peerId || p) === peerId);
if (me && typeof me === 'object') {
me.tabTitle = currentTabTitle;
me.username = settings.username;
me.mediaTitle = message.payload.mediaTitle;
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
}
}
sendResponse({ status: 'ok' });
});
} else if (message.type === 'SET_TARGET_TAB') {
currentTabId = message.tabId;
currentTabTitle = message.tabTitle;
chrome.storage.session.set({ currentTabId, currentTabTitle });
updateBadgeStatus();
if (currentTabId) {
chrome.scripting.executeScript({
target: { tabId: currentTabId },
files: ['content.js']
}).catch(err => {
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
});
}
sendResponse({ status: 'ok' });
} else if (message.type === 'LOG') {
addLog(`[Content] ${message.message}`, message.level || 'info');
sendResponse({ status: 'ok' });
} else {
// Final fallback to prevent channel hanging
sendResponse({ error: 'unhandled_message' });
}
}
@@ -855,11 +913,21 @@ chrome.tabs.onRemoved.addListener((tabId) => {
if (tabId === currentTabId) {
currentTabId = null;
currentTabTitle = null;
chrome.storage.sync.set({ targetTabId: null });
chrome.storage.session.set({ currentTabId: null, currentTabTitle: null });
updateBadgeStatus();
addLog('Target tab closed.', 'warn');
}
});
// Re-inject on full page refresh
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (currentTabId && tabId === parseInt(currentTabId) && changeInfo.status === 'complete') {
chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
}).catch(() => {});
}
});
// Initial Connect
connect();
+22 -24
View File
@@ -25,19 +25,16 @@
PEER_STATUS: "peer_status"
};
let lastTargetState = null;
let targetStateTimeout = null;
let expectedEvents = new Set();
let expectedTimeouts = {};
function setTargetState(state) {
lastTargetState = state;
if (targetStateTimeout) clearTimeout(targetStateTimeout);
if (state !== null) {
// Seek events might take longer than play/pause, using 2s for safety
const timeout = state === 'seek' ? 2000 : 1500;
targetStateTimeout = setTimeout(() => {
lastTargetState = null;
}, timeout);
}
function expectEvent(state) {
expectedEvents.add(state);
if (expectedTimeouts[state]) clearTimeout(expectedTimeouts[state]);
const timeout = state === 'seek' ? 10000 : 1500;
expectedTimeouts[state] = setTimeout(() => {
expectedEvents.delete(state);
}, timeout);
}
function reportLog(message, level = 'info') {
@@ -74,11 +71,11 @@
if (ytButton) {
const isCurrentlyPlaying = !video.paused;
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
setTargetState(action === EVENTS.PLAY ? 'playing' : 'paused');
expectEvent(action === EVENTS.PLAY ? 'playing' : 'paused');
ytButton.click();
}
if (action === EVENTS.SEEK) {
setTargetState('seek');
expectEvent('seek');
video.currentTime = data.targetTime;
}
return;
@@ -90,11 +87,11 @@
if (twitchButton) {
const isCurrentlyPlaying = !video.paused;
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
setTargetState(action === EVENTS.PLAY ? 'playing' : 'paused');
expectEvent(action === EVENTS.PLAY ? 'playing' : 'paused');
twitchButton.click();
}
if (action === EVENTS.SEEK) {
setTargetState('seek');
expectEvent('seek');
video.currentTime = data.targetTime;
}
return;
@@ -103,16 +100,16 @@
// Fallback for native HTML5
if (action === EVENTS.PLAY) {
setTargetState('playing');
expectEvent('playing');
video.play().catch((e) => {
reportLog(`Playback prevented: ${e.message}`, 'warn');
setTargetState(null);
expectedEvents.delete('playing');
});
} else if (action === EVENTS.PAUSE) {
setTargetState('paused');
expectEvent('paused');
video.pause();
} else if (action === EVENTS.SEEK) {
setTargetState('seek');
expectEvent('seek');
video.currentTime = data.targetTime;
}
} catch (e) {
@@ -135,7 +132,7 @@
elapsed += interval;
const timeDiff = Math.abs(video.currentTime - targetTime);
const ready = video.readyState >= 3 && timeDiff < 1.0;
const ready = video.readyState >= 3 && timeDiff < 2.0;
if (ready) {
clearInterval(timer);
resolve(true);
@@ -175,7 +172,8 @@
reportLog(`Media Action Error: Invalid force sync payload - ${JSON.stringify(payload)}`, 'error');
return;
}
setTargetState('paused');
expectEvent('paused');
expectEvent('seek');
video.pause();
video.currentTime = payload.targetTime;
pollSeekReady(payload.targetTime).then((ready) => {
@@ -240,8 +238,8 @@
const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null));
if (eventState && lastTargetState === eventState) {
setTargetState(null); // Consume the match
if (eventState && expectedEvents.has(eventState)) {
expectedEvents.delete(eventState); // Consume the match
return; // Ignore event caused by our programmatic action
}
+1 -11
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "KoalaSync",
"version": "1.1.0",
"version": "1.1.2",
"description": "Synchronize video playback across different tabs and users.",
"permissions": [
"storage",
@@ -27,16 +27,6 @@
"type": "module"
},
"content_scripts": [
{
"matches": [
"<all_urls>"
],
"js": [
"content.js"
],
"run_at": "document_idle",
"all_frames": false
},
{
"matches": ["https://koalasync.shik3i.net/*"],
"js": ["bridge.js"],
+25 -13
View File
@@ -49,7 +49,7 @@ let lastPeersJson = null;
// --- Initialization ---
async function init() {
// Load Settings
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'targetTabId', 'filterNoise', 'username']);
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username']);
elements.serverUrl.value = data.serverUrl || '';
elements.roomId.value = data.roomId || '';
elements.password.value = data.password || '';
@@ -62,21 +62,23 @@ async function init() {
setServerMode(false);
}
// Populate Tabs
await populateTabs();
toggleUIState(!!data.roomId);
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
refreshLogs();
refreshHistory();
// Initial Status Check
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
if (res) {
localPeerId = res.peerId;
applyConnectionStatus(res.status);
updatePeerList(res.peers);
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
// Populate Tabs using the background's targetTabId
await populateTabs(res.peers, res.targetTabId);
} else {
await populateTabs();
}
});
@@ -94,6 +96,7 @@ async function init() {
function toggleUIState(inRoom) {
if (elements.sectionJoin) elements.sectionJoin.style.display = inRoom ? 'none' : 'block';
if (elements.sectionActive) elements.sectionActive.style.display = inRoom ? 'block' : 'none';
if (elements.peerListSync) elements.peerListSync.style.display = inRoom ? 'block' : 'none';
}
function updateUI(roomId, password, useCustomServer = false, serverUrl = '') {
@@ -266,10 +269,16 @@ function updatePeerList(peers) {
populateTabs(peers);
}
async function populateTabs(providedPeers = null) {
const data = await chrome.storage.sync.get(['targetTabId', 'filterNoise']);
async function populateTabs(providedPeers = null, providedTargetTabId = null) {
const data = await chrome.storage.sync.get(['filterNoise']);
const isFilterActive = data.filterNoise !== false;
const currentTargetTabId = data.targetTabId;
// Fallback if not provided directly
let currentTargetTabId = providedTargetTabId;
if (currentTargetTabId === null) {
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
currentTargetTabId = status?.targetTabId;
}
// Use provided peers or fetch if missing
let peerIds = providedPeers;
@@ -645,15 +654,18 @@ elements.retryBtn.addEventListener('click', () => {
chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
});
elements.targetTab.addEventListener('change', async () => {
await chrome.storage.sync.set({ targetTabId: elements.targetTab.value });
elements.targetTab.addEventListener('change', () => {
const val = elements.targetTab.value;
const tabId = val ? parseInt(val) : null;
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.text.replace('⭐ MATCH: ', '') || null;
chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle });
});
elements.forceSyncBtn.addEventListener('click', async () => {
if (elements.forceSyncBtn.disabled) return;
const settings = await chrome.storage.sync.get(['targetTabId']);
if (!settings.targetTabId) return;
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
if (!status || !status.targetTabId) return;
// Lockout to prevent spamming
const originalText = elements.forceSyncBtn.textContent;
@@ -664,7 +676,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
elements.forceSyncBtn.textContent = originalText;
}, 5000);
const tabId = parseInt(settings.targetTabId);
const tabId = parseInt(status.targetTabId);
const sendForceSync = (time) => {
chrome.runtime.sendMessage({
+14
View File
@@ -0,0 +1,14 @@
# ⚠️ READ BEFORE EDITING
This directory is a **MIRROR** of the root `/shared` folder.
**DO NOT edit these files directly.** Any changes made here will be overwritten the next time the synchronization script is run.
### Proper Workflow:
1. **Edit** the source files in the root `[repo_root]/shared/` directory.
2. **Run** the synchronization script:
- **Windows**: `[repo_root]\scripts\sync-constants.bat`
- **Linux/macOS**: `[repo_root]/scripts/sync-constants.sh`
3. **Verify** that the changes have propagated to this folder.
Failure to follow this protocol will result in out-of-sync components and broken protocol logic.
+4
View File
@@ -1,6 +1,10 @@
/**
* blacklist.js
*
* ⚠️ WARNING: This is the SINGLE SOURCE OF TRUTH.
* If you edit this file, you MUST run /scripts/sync-constants.bat
* to propagate changes to the extension and relay server.
*
* Domains to be filtered out from the tab selection dropdown to reduce "noise".
* These are typically sites that won't contain shareable video content.
*/
+5 -1
View File
@@ -1,9 +1,13 @@
/**
* KoalaSync Shared Constants & Protocol Definitions
*
* ⚠️ WARNING: This is the SINGLE SOURCE OF TRUTH.
* If you edit this file, you MUST run /scripts/sync-constants.bat
* to propagate changes to the extension and relay server.
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "1.0.3";
export const APP_VERSION = "1.1.2";
export const OFFICIAL_SERVER_URL = 'wss://sync.shik3i.net';
export const OFFICIAL_LANDING_PAGE_URL = 'https://koalasync.shik3i.net';