From 4785c9625c35b1e85b4efd08df0aa5b5f2cc1919 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:58:19 +0200 Subject: [PATCH] chore(extension): bump version, fix targeted injection and race conditions --- extension/background.js | 89 +++++++++++++++++++++++++---------------- extension/content.js | 46 ++++++++++----------- extension/manifest.json | 12 +----- extension/popup.js | 37 +++++++++++------ shared/constants.js | 2 +- 5 files changed, 102 insertions(+), 84 deletions(-) diff --git a/extension/background.js b/extension/background.js index 0ff570a..f8a7fad 100644 --- a/extension/background.js +++ b/extension/background.js @@ -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 || '' }); }); @@ -587,10 +588,6 @@ function updateLastAction(action, senderId, timestamp = Date.now()) { } async function routeToContent(action, payload) { - const settings = await getSettings(); - if (settings.targetTabId) { - currentTabId = settings.targetTabId; - } if (!currentTabId) return; const tabId = parseInt(currentTabId); @@ -819,20 +816,17 @@ async function handleAsyncMessage(message, sender, sendResponse) { }; if (sender.tab) { - getSettings().then(settings => { - const savedTargetId = parseInt(settings.targetTabId); - const senderTabId = sender.tab.id; - - if (!savedTargetId || savedTargetId !== senderTabId) { - sendResponse({ status: 'ignored_unselected_tab' }); - return; - } - - currentTabId = senderTabId; - currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null; - updateBadgeStatus(); - processEvent(); - }); + 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(); @@ -861,21 +855,20 @@ async function handleAsyncMessage(message, sender, sendResponse) { } sendResponse({ status: 'ok' }); } else if (message.type === 'HEARTBEAT') { - getSettings().then(settings => { - if (sender.tab) { - const savedTargetId = parseInt(settings.targetTabId); - const senderTabId = sender.tab.id; - - if (!savedTargetId || savedTargetId !== senderTabId) { - sendResponse({ status: 'ignored_unselected_tab' }); - return; - } - - currentTabId = senderTabId; - currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null; - updateBadgeStatus(); + 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(); + } + getSettings().then(settings => { const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle }; emit(EVENTS.PEER_STATUS, statusPayload); @@ -890,6 +883,22 @@ async function handleAsyncMessage(message, sender, sendResponse) { } 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' }); @@ -904,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(); diff --git a/extension/content.js b/extension/content.js index 269a380..6c8b444 100644 --- a/extension/content.js +++ b/extension/content.js @@ -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 } diff --git a/extension/manifest.json b/extension/manifest.json index 1bc2ab7..8622618 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "KoalaSync", - "version": "1.1.1", + "version": "1.1.2", "description": "Synchronize video playback across different tabs and users.", "permissions": [ "storage", @@ -27,16 +27,6 @@ "type": "module" }, "content_scripts": [ - { - "matches": [ - "" - ], - "js": [ - "content.js" - ], - "run_at": "document_idle", - "all_frames": false - }, { "matches": ["https://koalasync.shik3i.net/*"], "js": ["bridge.js"], diff --git a/extension/popup.js b/extension/popup.js index 4153e71..259bc40 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -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(); } }); @@ -267,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; @@ -646,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; @@ -665,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({ diff --git a/shared/constants.js b/shared/constants.js index 75454ec..d97876b 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -3,7 +3,7 @@ */ export const PROTOCOL_VERSION = "1.0.0"; -export const APP_VERSION = "1.1.1"; +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';