From 7e7e60f267a39ba43f0e0ac219e8aaa06bcf3f06 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:24:59 +0200 Subject: [PATCH 01/17] Fix blocked website access and add troubleshooting page --- extension/background.js | 301 ++++++++++++++++++++++++------ extension/host-access.js | 88 +++++++++ extension/locales/de.json | 2 + extension/locales/en.json | 2 + extension/locales/es.json | 2 + extension/locales/fr.json | 2 + extension/locales/it.json | 2 + extension/locales/ja.json | 2 + extension/locales/ko.json | 2 + extension/locales/nl.json | 2 + extension/locales/pl.json | 2 + extension/locales/pt-BR.json | 2 + extension/locales/pt.json | 2 + extension/locales/ru.json | 2 + extension/locales/tr.json | 2 + extension/locales/uk.json | 2 + extension/locales/zh.json | 2 + extension/popup.html | 22 +++ extension/popup.js | 149 ++++++++++++--- scripts/test-host-access.mjs | 99 ++++++++++ scripts/test-site-access-help.mjs | 57 ++++++ scripts/verify-release.mjs | 2 + website/build.cjs | 8 + website/locales/de.json | 2 + website/locales/en.json | 2 + website/locales/es.json | 2 + website/locales/fr.json | 2 + website/locales/it.json | 2 + website/locales/ja.json | 2 + website/locales/ko.json | 2 + website/locales/nl.json | 2 + website/locales/pl.json | 2 + website/locales/pt-BR.json | 2 + website/locales/pt.json | 2 + website/locales/ru.json | 2 + website/locales/tr.json | 2 + website/locales/uk.json | 2 + website/locales/zh.json | 2 + website/site-access-help.html | 169 +++++++++++++++++ website/styles/foundation.css | 69 +++++++ website/styles/support.css | 246 ++++++++++++++++++++++++ website/template.html | 13 +- 42 files changed, 1203 insertions(+), 80 deletions(-) create mode 100644 extension/host-access.js create mode 100644 scripts/test-host-access.mjs create mode 100644 scripts/test-site-access-help.mjs create mode 100644 website/site-access-help.html create mode 100644 website/styles/support.css diff --git a/extension/background.js b/extension/background.js index aa1a32f..44ccfec 100644 --- a/extension/background.js +++ b/extension/background.js @@ -4,6 +4,7 @@ import { loadLocale, getMessage, getSystemLanguage } from './i18n.js'; import { sameEpisode, extractEpisodeId } from './episode-utils.js'; import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js'; import { initTabManager } from './modules/tab-manager.js'; +import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js'; import './page-api-seek-overrides.js'; // --- Uninstall URL Initialization --- @@ -61,6 +62,7 @@ let peerId = null; // initialized via getPeerId() let currentRoom = null; let currentTabId = null; let currentTabTitle = null; // New: for Smart Matching +let targetActivationGeneration = 0; let logs = []; let history = []; // New: for Action History let storageInitialized = false; @@ -517,13 +519,20 @@ function markRoomPotentiallyIdle() { } function clearTargetTabForIdle() { + targetActivationGeneration++; + clearPendingTarget().catch(() => {}); currentTabId = null; currentTabTitle = null; lastContentHeartbeatAt = null; if (currentRoom) { roomIdleSince = Date.now(); } - chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt }).catch(() => {}); + chrome.storage.session.set({ + currentTabId, + currentTabTitle, + roomIdleSince, + lastContentHeartbeatAt + }).catch(() => {}); updateBadgeStatus(); } @@ -544,11 +553,13 @@ async function leaveRoomAfterIdleGrace(reason) { // Notify content.js/popup BEFORE currentTabId is cleared so they can reset // any stale guest-side HCM state (dialog/badge/desync) — H-2. broadcastControlMode(); + targetActivationGeneration++; currentTabId = null; currentTabTitle = null; roomIdleSince = null; lastContentHeartbeatAt = null; clearEpisodeLobbyState(); + await clearPendingTarget(); await chrome.storage.session.set({ currentRoom: null, currentTabId: null, @@ -1734,47 +1745,208 @@ function setPageApiSeekEnabled(enabled) { window.KOALA_PAGE_API_SEEK_ENABLED = enabled === true; } -async function injectContentScript(tabId) { +function createHostAccessRequiredError(access, requestAdded, cause) { + const error = new Error(`Host access required for ${access.host || 'this website'}`); + error.code = HOST_ACCESS_REQUIRED_STATUS; + error.tabId = access.tab?.id || null; + error.host = access.host || null; + error.originPattern = access.originPattern || null; + error.requestAdded = requestAdded === true; + error.cause = cause; + return error; +} + +function injectionFailureResponse(error) { + if (error?.code === HOST_ACCESS_REQUIRED_STATUS) { + return { + status: HOST_ACCESS_REQUIRED_STATUS, + tabId: error.tabId, + host: error.host, + originPattern: error.originPattern, + requestAdded: error.requestAdded === true + }; + } + return { status: 'error', message: error?.message || 'Script injection failed' }; +} + +async function injectContentScript(tabId, { requestHostAccess = true } = {}) { let needsPageApiSeek = false; let pageApiSeekReady = false; + let access = null; try { - const tab = await chrome.tabs.get(tabId); - const url = tab?.url || ''; + access = await inspectTabHostAccess(chrome, tabId); + const url = access.url || ''; needsPageApiSeek = shouldUsePageApiSeek(url); } catch (_e) { // Fall through to the generic content script injection. } - if (needsPageApiSeek) { - try { - await chrome.scripting.executeScript({ - target: { tabId }, - world: 'MAIN', - files: ['page-api-seek-overrides.js'] - }); - await chrome.scripting.executeScript({ - target: { tabId }, - world: 'MAIN', - func: installPageApiSeekBridge - }); - pageApiSeekReady = true; - } catch (err) { - addLog(`Page API seek bridge injection failed: ${err.message}`, 'warn'); + try { + if (needsPageApiSeek) { + try { + await chrome.scripting.executeScript({ + target: { tabId }, + world: 'MAIN', + files: ['page-api-seek-overrides.js'] + }); + await chrome.scripting.executeScript({ + target: { tabId }, + world: 'MAIN', + func: installPageApiSeekBridge + }); + pageApiSeekReady = true; + } catch (err) { + addLog(`Page API seek bridge injection failed: ${err.message}`, 'warn'); + } } + + await chrome.scripting.executeScript({ + target: { tabId }, + files: ['page-api-seek-overrides.js'] + }); + await chrome.scripting.executeScript({ + target: { tabId }, + func: setPageApiSeekEnabled, + args: [pageApiSeekReady] + }); + return await chrome.scripting.executeScript({ + target: { tabId }, + files: ['content.js'] + }); + } catch (error) { + // A temporary activeTab grant is intentionally allowed to win: even if + // permissions.contains() reports false, a successful injection above is + // valid. Only convert an actual injection failure into a host-access UX. + if (access?.originPattern && access.granted === false) { + const requestAdded = requestHostAccess + ? await addTabHostAccessRequest(chrome, tabId) + : false; + throw createHostAccessRequiredError(access, requestAdded, error); + } + throw error; + } +} + +async function rememberPendingTarget(tabId, tabTitle, error) { + const previous = await chrome.storage.session.get('pendingTargetTabId'); + const previousTabId = normalizeTabId(previous.pendingTargetTabId); + if (previousTabId !== null && previousTabId !== tabId) { + await removeTabHostAccessRequest(chrome, previousTabId); + } + await chrome.storage.session.set({ + pendingTargetTabId: tabId, + pendingTargetTabTitle: tabTitle || null, + pendingTargetHost: error?.host || null, + pendingTargetOriginPattern: error?.originPattern || null + }); +} + +async function clearPendingTarget() { + const pending = await chrome.storage.session.get('pendingTargetTabId'); + const pendingTabId = normalizeTabId(pending.pendingTargetTabId); + if (pendingTabId !== null) { + await removeTabHostAccessRequest(chrome, pendingTabId); + } + await chrome.storage.session.set({ + pendingTargetTabId: null, + pendingTargetTabTitle: null, + pendingTargetHost: null, + pendingTargetOriginPattern: null + }); +} + +async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true } = {}) { + const selectedTabId = normalizeTabId(tabId); + if (selectedTabId === null) { + return { status: 'invalid_tab' }; } - await chrome.scripting.executeScript({ - target: { tabId }, - files: ['page-api-seek-overrides.js'] + const activationGeneration = ++targetActivationGeneration; + const previousTabId = currentTabId; + + try { + await injectContentScript(selectedTabId, { requestHostAccess }); + } catch (error) { + if (activationGeneration !== targetActivationGeneration) { + return { status: 'superseded' }; + } + currentTabId = null; + currentTabTitle = null; + if (currentRoom) roomIdleSince = Date.now(); + if (previousTabId) { + resetAudioProcessingInTab(previousTabId); + } + await chrome.storage.session.set({ + currentTabId: null, + currentTabTitle: null, + roomIdleSince, + lastContentHeartbeatAt: null + }); + updateBadgeStatus(); + + if (error?.code === HOST_ACCESS_REQUIRED_STATUS) { + await rememberPendingTarget(selectedTabId, tabTitle, error); + } else { + await clearPendingTarget(); + } + throw error; + } + + if (activationGeneration !== targetActivationGeneration) { + if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); + return { status: 'superseded' }; + } + + currentTabId = selectedTabId; + currentTabTitle = tabTitle || null; + lastContentHeartbeatAt = null; + if (currentRoom) roomIdleSince = Date.now(); + if (previousTabId && previousTabId !== selectedTabId) { + resetAudioProcessingInTab(previousTabId); + } + await applyAudioSettingsToTab(selectedTabId); + await clearPendingTarget(); + await chrome.storage.session.set({ + currentTabId, + currentTabTitle, + roomIdleSince, + lastContentHeartbeatAt }); - await chrome.scripting.executeScript({ - target: { tabId }, - func: setPageApiSeekEnabled, - args: [pageApiSeekReady] - }); - return chrome.scripting.executeScript({ - target: { tabId }, - files: ['content.js'] + updateBadgeStatus(); + return { status: 'ok', tabId: selectedTabId }; +} + +async function retryPendingTarget() { + const pending = await chrome.storage.session.get([ + 'pendingTargetTabId', + 'pendingTargetTabTitle' + ]); + const tabId = normalizeTabId(pending.pendingTargetTabId); + if (tabId === null) return null; + + try { + const response = await activateTargetTab(tabId, pending.pendingTargetTabTitle, { + requestHostAccess: false + }); + if (response.status === 'ok') { + addLog(`Website access granted; selected tab ${tabId}`, 'success'); + chrome.runtime.sendMessage({ type: 'TARGET_TAB_READY', tabId }).catch(() => {}); + } + return response; + } catch (error) { + if (error?.code !== HOST_ACCESS_REQUIRED_STATUS) { + await clearPendingTarget(); + addLog(`Pending tab activation failed: ${error.message}`, 'warn'); + } + return injectionFailureResponse(error); + } +} + +if (chrome.permissions?.onAdded?.addListener) { + chrome.permissions.onAdded.addListener(() => { + ensureState() + .then(() => retryPendingTarget()) + .catch(error => addLog(`Website access retry failed: ${error.message}`, 'warn')); }); } @@ -1948,6 +2120,14 @@ async function handleAsyncMessage(message, sender, sendResponse) { connect(); sendResponse({ status: 'ok' }); } else if (message.type === 'GET_STATUS') { + if (message.retryPendingTarget === true) { + await retryPendingTarget(); + } + const pendingTarget = await chrome.storage.session.get([ + 'pendingTargetTabId', + 'pendingTargetHost', + 'pendingTargetOriginPattern' + ]); const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined; const isReconnecting = !isConnected && reconnectAttempts > 0; let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected')); @@ -1959,6 +2139,9 @@ async function handleAsyncMessage(message, sender, sendResponse) { peers: currentRoom ? currentRoom.peers : [], lastActionState, targetTabId: currentTabId, + pendingTargetTabId: pendingTarget.pendingTargetTabId || null, + pendingTargetHost: pendingTarget.pendingTargetHost || null, + pendingTargetOriginPattern: pendingTarget.pendingTargetOriginPattern || null, episodeLobby: episodeLobby, reconnectAttempts, reconnectSlowMode: reconnectFailed, @@ -2059,6 +2242,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { // Notify content.js/popup BEFORE currentTabId is cleared so they drop any // stale guest-side HCM state (dialog/badge/desync) — H-2/H-3. broadcastControlMode(); + targetActivationGeneration++; currentTabId = null; currentTabTitle = null; roomIdleSince = null; @@ -2073,6 +2257,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { // Cancel any active episode lobby clearEpisodeLobbyState(); + await clearPendingTarget(); chrome.storage.session.set({ currentRoom: null, @@ -2427,8 +2612,8 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ status: 'ok' }); }); } else if (message.type === 'INJECT_CONTENT_SCRIPT') { - const tabId = Number(message.tabId); - if (!Number.isInteger(tabId)) { + const tabId = normalizeTabId(message.tabId); + if (tabId === null) { sendResponse({ status: 'invalid_tab' }); return true; } @@ -2437,34 +2622,37 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ status: 'ok' }); }).catch(err => { addLog(`Failed to inject into tab: ${err.message}`, 'warn'); - sendResponse({ status: 'error', message: err.message }); + sendResponse(injectionFailureResponse(err)); }); return true; } else if (message.type === 'SET_TARGET_TAB') { - const previousTabId = currentTabId; - currentTabId = message.tabId; - currentTabTitle = message.tabTitle; - lastContentHeartbeatAt = null; - if (currentRoom) { - roomIdleSince = Date.now(); + if (message.tabId === null || message.tabId === undefined || message.tabId === '') { + const previousTabId = currentTabId; + targetActivationGeneration++; + currentTabId = null; + currentTabTitle = null; + lastContentHeartbeatAt = null; + if (currentRoom) roomIdleSince = Date.now(); + if (previousTabId) resetAudioProcessingInTab(previousTabId); + await clearPendingTarget(); + await chrome.storage.session.set({ + currentTabId: null, + currentTabTitle: null, + roomIdleSince, + lastContentHeartbeatAt: null + }); + updateBadgeStatus(); + sendResponse({ status: 'ok', tabId: null }); + return; } - chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt }); - updateBadgeStatus(); - if (previousTabId && previousTabId !== currentTabId) { - resetAudioProcessingInTab(previousTabId); + try { + const response = await activateTargetTab(message.tabId, message.tabTitle); + sendResponse(response); + } catch (error) { + addLog(`Failed to select tab: ${error.message}`, 'warn'); + sendResponse(injectionFailureResponse(error)); } - - if (currentTabId) { - const selectedTabId = currentTabId; - injectContentScript(selectedTabId) - .then(() => applyAudioSettingsToTab(selectedTabId)) - .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' }); @@ -2647,7 +2835,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { initTabManager({ getCurrentTabId: () => currentTabId, - setCurrentTabId: (val) => { currentTabId = val; }, + setCurrentTabId: (val) => { + if (val !== currentTabId) targetActivationGeneration++; + currentTabId = val; + }, setCurrentTabTitle: (val) => { currentTabTitle = val; }, setLastContentHeartbeatAt: (val) => { lastContentHeartbeatAt = val; }, setRoomIdleSince: (val) => { roomIdleSince = val; }, diff --git a/extension/host-access.js b/extension/host-access.js new file mode 100644 index 0000000..d47b1a8 --- /dev/null +++ b/extension/host-access.js @@ -0,0 +1,88 @@ +export const HOST_ACCESS_REQUIRED_STATUS = 'host_permission_required'; + +export function normalizeTabId(value) { + if (value === null || value === undefined || value === '') return null; + const tabId = Number(value); + return Number.isInteger(tabId) && tabId > 0 ? tabId : null; +} + +export function describeTabUrl(rawUrl) { + if (typeof rawUrl !== 'string' || !rawUrl) return null; + + try { + const url = new URL(rawUrl); + if (url.protocol === 'http:' || url.protocol === 'https:') { + return { + url: rawUrl, + host: url.host, + originPattern: `${url.origin}/*` + }; + } + if (url.protocol === 'file:') { + return { + url: rawUrl, + host: 'local file', + originPattern: 'file:///*' + }; + } + } catch (_e) { + // Invalid and browser-internal URLs cannot receive host access. + } + + return null; +} + +export async function inspectTabHostAccess(chromeApi, tabId) { + const tab = await chromeApi.tabs.get(tabId); + const descriptor = describeTabUrl(tab?.url || ''); + if (!descriptor) { + return { + tab, + url: tab?.url || '', + host: null, + originPattern: null, + granted: null + }; + } + + if (typeof chromeApi.permissions?.contains !== 'function') { + return { tab, ...descriptor, granted: null }; + } + + try { + const granted = await chromeApi.permissions.contains({ + origins: [descriptor.originPattern] + }); + return { tab, ...descriptor, granted: granted === true }; + } catch (_e) { + // Permission inspection is advisory. The actual script injection below + // remains the source of truth, including temporary activeTab access. + return { tab, ...descriptor, granted: null }; + } +} + +export async function addTabHostAccessRequest(chromeApi, tabId) { + if (typeof chromeApi.permissions?.addHostAccessRequest !== 'function') { + return false; + } + + try { + await chromeApi.permissions.addHostAccessRequest({ tabId }); + return true; + } catch (_e) { + return false; + } +} + +export async function removeTabHostAccessRequest(chromeApi, tabId) { + if (typeof chromeApi.permissions?.removeHostAccessRequest !== 'function') { + return false; + } + + try { + await chromeApi.permissions.removeHostAccessRequest({ tabId }); + return true; + } catch (_e) { + return false; + } +} diff --git a/extension/locales/de.json b/extension/locales/de.json index 6507be7..80877ae 100644 --- a/extension/locales/de.json +++ b/extension/locales/de.json @@ -148,6 +148,8 @@ "ERR_IDENTITY_NOT_LOADED": "Identität noch nicht geladen. Warte kurz und versuche es erneut.", "ERR_NO_PEERS_TIME": "Keine anderen Teilnehmer mit bekannter Zeit. Wechsle zu 'Zu mir springen'.", "ERR_NO_VIDEO_TAB": "Verbindung zum Video-Tab fehlgeschlagen.", + "SITE_ACCESS_REQUIRED": "Dein Browser blockiert KoalaSync auf {host}. Klicke unten auf „Zugriff erlauben“. Falls kein Dialog erscheint, erlaube KoalaSync in den Erweiterungs- oder Websiteberechtigungen für diese Seite.", + "BTN_RETRY_ACCESS": "Zugriff erlauben", "ERR_SELECT_VIDEO": "Bitte wähle zuerst ein Video aus!", "TOAST_INVITE_COPIED": "Einladungslink kopiert!", "TOAST_COPY_FAILED": "Kopieren in Zwischenablage fehlgeschlagen", diff --git a/extension/locales/en.json b/extension/locales/en.json index 25f5c04..b4274c2 100644 --- a/extension/locales/en.json +++ b/extension/locales/en.json @@ -148,6 +148,8 @@ "ERR_IDENTITY_NOT_LOADED": "Identity not yet loaded. Wait a moment and try again.", "ERR_NO_PEERS_TIME": "No other peers with a known time. Switch to 'Jump to Me'.", "ERR_NO_VIDEO_TAB": "Could not connect to video tab.", + "SITE_ACCESS_REQUIRED": "Your browser is blocking KoalaSync on {host}. Click \"Allow access\" below. If no dialog appears, allow KoalaSync on this site in your extension or site permissions.", + "BTN_RETRY_ACCESS": "Allow access", "ERR_SELECT_VIDEO": "Please select a video first!", "TOAST_INVITE_COPIED": "Invite link copied!", "TOAST_COPY_FAILED": "Failed to copy to clipboard", diff --git a/extension/locales/es.json b/extension/locales/es.json index 624e419..dca55b0 100644 --- a/extension/locales/es.json +++ b/extension/locales/es.json @@ -148,6 +148,8 @@ "ERR_IDENTITY_NOT_LOADED": "Identidad no cargada aún. Espera un momento e inténtalo de nuevo.", "ERR_NO_PEERS_TIME": "No hay otros participantes con posición conocida. Cambia a 'Traerlos a mi posición'.", "ERR_NO_VIDEO_TAB": "No se pudo conectar a la pestaña de video.", + "SITE_ACCESS_REQUIRED": "Tu navegador está bloqueando KoalaSync en {host}. Haz clic en «Permitir acceso». Si no aparece ningún diálogo, permite KoalaSync en este sitio desde los permisos de extensiones o del sitio.", + "BTN_RETRY_ACCESS": "Permitir acceso", "ERR_SELECT_VIDEO": "¡Selecciona un video primero!", "TOAST_INVITE_COPIED": "¡Enlace de invitación copiado!", "TOAST_COPY_FAILED": "Error al copiar al portapapeles", diff --git a/extension/locales/fr.json b/extension/locales/fr.json index 67e0408..9f8ea18 100644 --- a/extension/locales/fr.json +++ b/extension/locales/fr.json @@ -148,6 +148,8 @@ "ERR_IDENTITY_NOT_LOADED": "Identifiant non encore chargé. Veuillez patienter et réessayer.", "ERR_NO_PEERS_TIME": "Aucun autre membre avec une position connue. Basculez sur 'Les amener à moi'.", "ERR_NO_VIDEO_TAB": "Impossible de se connecter à l'onglet vidéo.", + "SITE_ACCESS_REQUIRED": "Votre navigateur bloque KoalaSync sur {host}. Cliquez sur « Autoriser l’accès ». Si aucune boîte de dialogue n’apparaît, autorisez KoalaSync sur ce site dans les permissions des extensions ou du site.", + "BTN_RETRY_ACCESS": "Autoriser l’accès", "ERR_SELECT_VIDEO": "Veuillez d'abord sélectionner une vidéo !", "TOAST_INVITE_COPIED": "Lien d'invitation copié !", "TOAST_COPY_FAILED": "Échec de la copie dans le presse-papiers", diff --git a/extension/locales/it.json b/extension/locales/it.json index bb72c95..3eda6a2 100644 --- a/extension/locales/it.json +++ b/extension/locales/it.json @@ -148,6 +148,8 @@ "ERR_IDENTITY_NOT_LOADED": "Identità non ancora caricata. Attendi un momento.", "ERR_NO_PEERS_TIME": "Nessun altro partecipante con posizione nota. Passa a 'Portali alla mia posizione'.", "ERR_NO_VIDEO_TAB": "Impossibile connettersi alla scheda del video.", + "SITE_ACCESS_REQUIRED": "Il browser sta bloccando KoalaSync su {host}. Fai clic su «Consenti accesso». Se non appare alcuna finestra, consenti KoalaSync su questo sito nelle autorizzazioni dell’estensione o del sito.", + "BTN_RETRY_ACCESS": "Consenti accesso", "ERR_SELECT_VIDEO": "Seleziona prima un video!", "TOAST_INVITE_COPIED": "Link di invito copiato!", "TOAST_COPY_FAILED": "Impossibile copiare negli appunti", diff --git a/extension/locales/ja.json b/extension/locales/ja.json index 70162a8..1047ab4 100644 --- a/extension/locales/ja.json +++ b/extension/locales/ja.json @@ -148,6 +148,8 @@ "ERR_IDENTITY_NOT_LOADED": "IDがまだロードされていません。しばらく待ってから再試行してください。", "ERR_NO_PEERS_TIME": "既知の時間を持つ他のメンバーがいません。「自分に合わせる」に切り替えてください。", "ERR_NO_VIDEO_TAB": "ビデオタブに接続できませんでした。", + "SITE_ACCESS_REQUIRED": "ブラウザーが {host} で KoalaSync をブロックしています。下の「アクセスを許可」をクリックしてください。ダイアログが表示されない場合は、拡張機能またはサイトの権限でこのサイトの KoalaSync を許可してください。", + "BTN_RETRY_ACCESS": "アクセスを許可", "ERR_SELECT_VIDEO": "最初にビデオを選択してください!", "TOAST_INVITE_COPIED": "招待リンクをコピーしました!", "TOAST_COPY_FAILED": "クリップボードへのコピーに失敗しました", diff --git a/extension/locales/ko.json b/extension/locales/ko.json index d40a320..5e8ad89 100644 --- a/extension/locales/ko.json +++ b/extension/locales/ko.json @@ -148,6 +148,8 @@ "ERR_IDENTITY_NOT_LOADED": "ID가 아직 로드되지 않았습니다. 잠시 후 다시 시도해 주세요.", "ERR_NO_PEERS_TIME": "시간을 알고 있는 다른 피어가 없습니다. '나에게 이동'으로 전환하세요.", "ERR_NO_VIDEO_TAB": "비디오 탭에 연결할 수 없습니다.", + "SITE_ACCESS_REQUIRED": "브라우저가 {host}에서 KoalaSync를 차단하고 있습니다. 아래의 ‘액세스 허용’을 클릭하세요. 대화상자가 표시되지 않으면 확장 프로그램 또는 사이트 권한에서 이 사이트의 KoalaSync를 허용하세요.", + "BTN_RETRY_ACCESS": "액세스 허용", "ERR_SELECT_VIDEO": "먼저 비디오를 선택하세요!", "TOAST_INVITE_COPIED": "초대 링크가 복사되었습니다!", "TOAST_COPY_FAILED": "클립보드 복사에 실패했습니다", diff --git a/extension/locales/nl.json b/extension/locales/nl.json index dd2a201..c7ca7c6 100644 --- a/extension/locales/nl.json +++ b/extension/locales/nl.json @@ -148,6 +148,8 @@ "ERR_IDENTITY_NOT_LOADED": "Identiteit nog niet geladen. Wacht een moment en probeer het opnieuw.", "ERR_NO_PEERS_TIME": "Geen andere deelnemers met een bekende tijd. Schakel over naar 'Spring naar mij'.", "ERR_NO_VIDEO_TAB": "Kon geen verbinding maken met videotabblad.", + "SITE_ACCESS_REQUIRED": "Je browser blokkeert KoalaSync op {host}. Klik hieronder op ‘Toegang toestaan’. Als er geen dialoog verschijnt, sta KoalaSync voor deze site toe via de extensie- of siterechten.", + "BTN_RETRY_ACCESS": "Toegang toestaan", "ERR_SELECT_VIDEO": "Selecteer eerst een video!", "TOAST_INVITE_COPIED": "Uitnodigingslink gekopieerd!", "TOAST_COPY_FAILED": "Kopiëren naar klembord mislukt", diff --git a/extension/locales/pl.json b/extension/locales/pl.json index dde3e3d..d9cbc79 100644 --- a/extension/locales/pl.json +++ b/extension/locales/pl.json @@ -148,6 +148,8 @@ "ERR_IDENTITY_NOT_LOADED": "Tożsamość nie została jeszcze załadowana. Odczekaj chwilę i spróbuj ponownie.", "ERR_NO_PEERS_TIME": "Brak innych uczestników ze znanym czasem. Przełącz na 'Skocz do mnie'.", "ERR_NO_VIDEO_TAB": "Nie można połączyć się z kartą wideo.", + "SITE_ACCESS_REQUIRED": "Przeglądarka blokuje KoalaSync na {host}. Kliknij poniżej „Zezwól na dostęp”. Jeśli nie pojawi się okno, zezwól KoalaSync na tej stronie w uprawnieniach rozszerzenia lub witryny.", + "BTN_RETRY_ACCESS": "Zezwól na dostęp", "ERR_SELECT_VIDEO": "Najpierw wybierz wideo!", "TOAST_INVITE_COPIED": "Link zaproszenia skopiowany!", "TOAST_COPY_FAILED": "Nie udało się skopiować do schowka", diff --git a/extension/locales/pt-BR.json b/extension/locales/pt-BR.json index 1d07782..ff34219 100644 --- a/extension/locales/pt-BR.json +++ b/extension/locales/pt-BR.json @@ -148,6 +148,8 @@ "ERR_IDENTITY_NOT_LOADED": "Identidade não carregada ainda. Aguarde um momento.", "ERR_NO_PEERS_TIME": "Nenhum participante com posição conhecida. Mude para 'Trazer para a minha posição'.", "ERR_NO_VIDEO_TAB": "Não foi possível conectar à aba do vídeo.", + "SITE_ACCESS_REQUIRED": "Seu navegador está bloqueando o KoalaSync em {host}. Clique em «Permitir acesso» abaixo. Se nenhuma caixa de diálogo aparecer, permita o KoalaSync neste site nas permissões da extensão ou do site.", + "BTN_RETRY_ACCESS": "Permitir acesso", "ERR_SELECT_VIDEO": "Selecione um vídeo primeiro!", "TOAST_INVITE_COPIED": "Link de convite copiado!", "TOAST_COPY_FAILED": "Falha ao copiar para a área de transferência", diff --git a/extension/locales/pt.json b/extension/locales/pt.json index b360401..97847fd 100644 --- a/extension/locales/pt.json +++ b/extension/locales/pt.json @@ -148,6 +148,8 @@ "ERR_IDENTITY_NOT_LOADED": "Identidade ainda não carregada. Aguarde um momento.", "ERR_NO_PEERS_TIME": "Nenhum participante com hora conhecida. Mude para 'Trazer para a minha posição'.", "ERR_NO_VIDEO_TAB": "Não foi possível aceder ao separador do vídeo.", + "SITE_ACCESS_REQUIRED": "O seu navegador está a bloquear o KoalaSync em {host}. Clique em «Permitir acesso» abaixo. Se não aparecer uma caixa de diálogo, permita o KoalaSync neste site nas permissões da extensão ou do site.", + "BTN_RETRY_ACCESS": "Permitir acesso", "ERR_SELECT_VIDEO": "Selecione primeiro um vídeo!", "TOAST_INVITE_COPIED": "Link de convite copiado!", "TOAST_COPY_FAILED": "Falha ao copiar para a área de transferência", diff --git a/extension/locales/ru.json b/extension/locales/ru.json index 7aa0e35..7b679e7 100644 --- a/extension/locales/ru.json +++ b/extension/locales/ru.json @@ -148,6 +148,8 @@ "ERR_IDENTITY_NOT_LOADED": "Идентификатор еще не загружен. Подождите немного и повторите попытку.", "ERR_NO_PEERS_TIME": "Нет других участников с известной позицией плеера. Выберите 'Синхронизировать по мне'.", "ERR_NO_VIDEO_TAB": "Не удалось подключиться к вкладке с видео.", + "SITE_ACCESS_REQUIRED": "Браузер блокирует KoalaSync на {host}. Нажмите «Разрешить доступ» ниже. Если диалог не появится, разрешите KoalaSync для этого сайта в настройках расширения или разрешениях сайта.", + "BTN_RETRY_ACCESS": "Разрешить доступ", "ERR_SELECT_VIDEO": "Пожалуйста, сначала выберите вкладку с видео!", "TOAST_INVITE_COPIED": "Ссылка скопирована в буфер обмена!", "TOAST_COPY_FAILED": "Не удалось скопировать ссылку в буфер обмена", diff --git a/extension/locales/tr.json b/extension/locales/tr.json index b5b29da..e69a99d 100644 --- a/extension/locales/tr.json +++ b/extension/locales/tr.json @@ -148,6 +148,8 @@ "ERR_IDENTITY_NOT_LOADED": "Kimlik henüz yüklenmedi. Biraz bekleyin ve tekrar deneyin.", "ERR_NO_PEERS_TIME": "Bilinen bir zamanı olan başka bağlantı yok. 'Bana Atla' seçeneğine geçin.", "ERR_NO_VIDEO_TAB": "Video sekmesine bağlanılamadı.", + "SITE_ACCESS_REQUIRED": "Tarayıcınız {host} üzerinde KoalaSync'i engelliyor. Aşağıdaki “Erişime izin ver” düğmesine tıklayın. İletişim kutusu görünmezse uzantı veya site izinlerinden bu site için KoalaSync'e izin verin.", + "BTN_RETRY_ACCESS": "Erişime izin ver", "ERR_SELECT_VIDEO": "Lütfen önce bir video seçin!", "TOAST_INVITE_COPIED": "Davet bağlantısı kopyalandı!", "TOAST_COPY_FAILED": "Panoya kopyalanamadı", diff --git a/extension/locales/uk.json b/extension/locales/uk.json index 22aa7d5..a26676b 100644 --- a/extension/locales/uk.json +++ b/extension/locales/uk.json @@ -148,6 +148,8 @@ "ERR_IDENTITY_NOT_LOADED": "Посвідчення особи ще не завантажено. Зачекайте хвилинку та повторіть спробу.", "ERR_NO_PEERS_TIME": "Немає інших аналогів із відомим часом. Переключіться на «Перейти до мене».", "ERR_NO_VIDEO_TAB": "Не вдалося підключитися до вкладки відео.", + "SITE_ACCESS_REQUIRED": "Браузер блокує KoalaSync на {host}. Натисніть «Дозволити доступ» нижче. Якщо діалог не з’явиться, дозвольте KoalaSync для цього сайту в налаштуваннях розширення або дозволах сайту.", + "BTN_RETRY_ACCESS": "Дозволити доступ", "ERR_SELECT_VIDEO": "Спочатку виберіть відео!", "TOAST_INVITE_COPIED": "Посилання на запрошення скопійовано!", "TOAST_COPY_FAILED": "Не вдалося скопіювати в буфер обміну", diff --git a/extension/locales/zh.json b/extension/locales/zh.json index dda1b53..786cf4b 100644 --- a/extension/locales/zh.json +++ b/extension/locales/zh.json @@ -148,6 +148,8 @@ "ERR_IDENTITY_NOT_LOADED": "身份尚未加载。稍等片刻,然后重试。", "ERR_NO_PEERS_TIME": "没有其他已知时间的同行。切换到“跳到我这里”。", "ERR_NO_VIDEO_TAB": "无法连接到视频选项卡。", + "SITE_ACCESS_REQUIRED": "浏览器正在阻止 KoalaSync 访问 {host}。请点击下方的“允许访问”。如果未显示对话框,请在扩展程序或网站权限中允许 KoalaSync 访问此网站。", + "BTN_RETRY_ACCESS": "允许访问", "ERR_SELECT_VIDEO": "请先选择视频!", "TOAST_INVITE_COPIED": "邀请链接已复制!", "TOAST_COPY_FAILED": "无法复制到剪贴板", diff --git a/extension/popup.html b/extension/popup.html index 33db0ff..162711d 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -453,6 +453,24 @@ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.08); } + .site-access-notice { + display: none; + margin-top: 8px; + margin-bottom: 0; + border-left: 4px solid var(--warning); + background: color-mix(in oklch, var(--warning), var(--card) 90%); + color: var(--text); + font-size: 11px; + line-height: 1.45; + } + + .site-access-notice button { + width: auto; + margin-top: 8px; + padding: 6px 10px; + font-size: 11px; + } + .peer-item { padding: 10px 0; border-bottom: 1px solid var(--border-strong); @@ -1432,6 +1450,10 @@ Select your video here!
+
diff --git a/extension/popup.js b/extension/popup.js index c7af580..de7fae0 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -10,6 +10,9 @@ const elements = { contents: document.querySelectorAll('.tab-content'), copyInvite: document.getElementById('copyInvite'), targetTab: document.getElementById('targetTab'), + siteAccessNotice: document.getElementById('siteAccessNotice'), + siteAccessMessage: document.getElementById('siteAccessMessage'), + siteAccessRetry: document.getElementById('siteAccessRetry'), forceSyncBtn: document.getElementById('forceSyncBtn'), forceSyncMode: document.getElementById('forceSyncMode'), peerList: document.getElementById('peerList'), @@ -387,7 +390,7 @@ async function init() { // Initial Status Check (status shows via GET_STATUS below) // Initial Status Check - chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => { + chrome.runtime.sendMessage({ type: 'GET_STATUS', retryPendingTarget: true }, async (res) => { if (chrome.runtime.lastError) { console.warn('[Popup] Background not responding:', chrome.runtime.lastError.message); await populateTabs(); @@ -410,13 +413,20 @@ async function init() { applyConnectionStatus('connecting'); } - // Populate Tabs using the background's targetTabId - await populateTabs(res.peers, res.targetTabId); + if (res.pendingTargetTabId) { + showSiteAccessNotice(res.pendingTargetHost, res.pendingTargetOriginPattern); + } else { + hideSiteAccessNotice(); + } + + // Keep a denied selection visible while Chrome waits for the user + // to grant access; it becomes active automatically after approval. + await populateTabs(res.peers, res.targetTabId || res.pendingTargetTabId); // Render lobby status if active if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers); - if (res.status === 'connected' && !res.targetTabId && localData.roomId) { + if (res.status === 'connected' && !res.targetTabId && !res.pendingTargetTabId && localData.roomId) { const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]'); if (syncTabBtn) syncTabBtn.click(); showSelectVideoHint(); @@ -463,6 +473,9 @@ function toggleUIState(inRoom) { // --- Host Control Mode UI --- // True when we're a guest in a host-only room → remote-control buttons are locked. let hcmGuestLocked = false; +let siteAccessBlocked = false; +let siteAccessHost = null; +let siteAccessOriginPattern = null; // Co-Host state mirrored for the peer-list renderer (promote/demote + role badges). let hcmAmOwner = false; @@ -524,12 +537,15 @@ function updateHostControlUI(state) { } function setRemoteControlsLocked(locked) { + const effectiveLocked = locked || siteAccessBlocked; [elements.playBtn, elements.pauseBtn, elements.forceSyncBtn].forEach(btn => { if (!btn) return; - btn.disabled = locked; - btn.style.opacity = locked ? '0.5' : ''; - btn.style.cursor = locked ? 'not-allowed' : ''; - btn.title = locked ? (getMessage('NOTICE_HOST_CONTROLS') || 'The host controls playback for everyone.') : ''; + btn.disabled = effectiveLocked; + btn.style.opacity = effectiveLocked ? '0.5' : ''; + btn.style.cursor = effectiveLocked ? 'not-allowed' : ''; + btn.title = siteAccessBlocked + ? (elements.siteAccessMessage?.textContent || getMessage('ERR_NO_VIDEO_TAB')) + : (locked ? (getMessage('NOTICE_HOST_CONTROLS') || 'The host controls playback for everyone.') : ''); }); // Always restore the default labels. The action handlers leave the text in a // transitional state ("Playing..." / "Pausing...") and the 2.5s safety reset @@ -540,6 +556,64 @@ function setRemoteControlsLocked(locked) { if (elements.pauseBtn) elements.pauseBtn.textContent = getMessage('BTN_PAUSE') || 'Pause'; } +function showSiteAccessNotice(host, originPattern = null) { + siteAccessBlocked = true; + siteAccessHost = host || siteAccessHost || 'website'; + siteAccessOriginPattern = originPattern || siteAccessOriginPattern; + if (elements.siteAccessMessage) { + elements.siteAccessMessage.textContent = getMessage('SITE_ACCESS_REQUIRED', { + host: siteAccessHost + }); + } + if (elements.siteAccessNotice) elements.siteAccessNotice.style.display = 'block'; + setRemoteControlsLocked(hcmGuestLocked); +} + +function hideSiteAccessNotice() { + siteAccessBlocked = false; + siteAccessHost = null; + siteAccessOriginPattern = null; + if (elements.siteAccessNotice) elements.siteAccessNotice.style.display = 'none'; + setRemoteControlsLocked(hcmGuestLocked); +} + +function handleTargetTabResponse(response) { + if (response?.status === 'host_permission_required') { + showSiteAccessNotice(response.host, response.originPattern); + return false; + } + if (response?.status === 'ok') { + hideSiteAccessNotice(); + return true; + } + if (response?.status === 'superseded') return false; + showToast(response?.message || getMessage('ERR_NO_VIDEO_TAB'), 'error', 5000); + return false; +} + +async function requestSiteAccess(originPattern) { + if (!originPattern || typeof chrome.permissions?.request !== 'function') return null; + try { + return await chrome.permissions.request({ origins: [originPattern] }); + } catch (error) { + console.warn('[Popup] Host permission request failed:', error.message); + return false; + } +} + +function selectTargetTab(tabId, tabTitle) { + return new Promise(resolve => { + chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle }, response => { + if (chrome.runtime.lastError) { + showToast(chrome.runtime.lastError.message || getMessage('ERR_NO_VIDEO_TAB'), 'error', 5000); + resolve(false); + return; + } + resolve(handleTargetTabResponse(response)); + }); + }); +} + if (elements.hostControlToggle) { elements.hostControlToggle.addEventListener('change', () => { const mode = elements.hostControlToggle.checked ? 'host-only' : 'everyone'; @@ -998,7 +1072,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) { if (populateTabsToken !== token) return; currentTargetTabId = null; } else { - currentTargetTabId = status?.targetTabId; + currentTargetTabId = status?.targetTabId || status?.pendingTargetTabId; } } @@ -1118,7 +1192,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) { if (matchOpt && elements.targetTab.options.length > 1) { elements.targetTab.value = matchOpt.value; const tabTitle = matchOpt.dataset.originalTitle || null; - chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId: parseInt(matchOpt.value), tabTitle }); + selectTargetTab(parseInt(matchOpt.value), tabTitle); } } } @@ -1471,7 +1545,12 @@ if (elements.langSelector) { const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']); updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl); - await populateTabs(res.peers, res.targetTabId); + if (res.pendingTargetTabId) { + showSiteAccessNotice(res.pendingTargetHost, res.pendingTargetOriginPattern); + } else { + hideSiteAccessNotice(); + } + await populateTabs(res.peers, res.targetTabId || res.pendingTargetTabId); if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers); } else { applyConnectionStatus('disconnected'); @@ -1749,18 +1828,34 @@ elements.retryBtn.addEventListener('click', () => { chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' }); }); -elements.targetTab.addEventListener('change', () => { +elements.targetTab.addEventListener('change', async () => { const hint = document.getElementById('targetTabHint'); if (hint) hint.style.display = 'none'; const val = elements.targetTab.value; const tabId = val ? parseInt(val) : null; const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || null; - chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle }); + await selectTargetTab(tabId, tabTitle); }); +if (elements.siteAccessRetry) { + elements.siteAccessRetry.addEventListener('click', async () => { + const val = elements.targetTab.value; + const tabId = val ? parseInt(val) : null; + const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || null; + if (!tabId) { + showToast(getMessage('ERR_SELECT_VIDEO'), 'warning'); + return; + } + elements.siteAccessRetry.disabled = true; + await requestSiteAccess(siteAccessOriginPattern); + await selectTargetTab(tabId, tabTitle); + elements.siteAccessRetry.disabled = false; + }); +} + elements.forceSyncBtn.addEventListener('click', async () => { - if (hcmGuestLocked) return; // guest in host-only room — backstop (M-2/L-3) + if (hcmGuestLocked || siteAccessBlocked) return; // host/site-access backstop if (elements.forceSyncBtn.disabled) return; const originalText = elements.forceSyncBtn.textContent; @@ -1806,7 +1901,7 @@ elements.forceSyncBtn.addEventListener('click', async () => { // Don't unlock a button that's locked because we became a guest mid-flight — // hcmGuestLocked is the source of truth for the lock state, and the next // CONTROL_MODE update restores the correct label. - if (!forceSyncDone && !hcmGuestLocked) { + if (!forceSyncDone && !hcmGuestLocked && !siteAccessBlocked) { elements.forceSyncBtn.disabled = false; elements.forceSyncBtn.textContent = originalText; } @@ -1818,7 +1913,7 @@ elements.forceSyncBtn.addEventListener('click', async () => { if (forceSyncResetTimer) { clearTimeout(forceSyncResetTimer); forceSyncResetTimer = null; } showError(getMessage('ERR_NO_VIDEO_TAB')); forceSyncDone = true; - elements.forceSyncBtn.disabled = false; + if (!hcmGuestLocked && !siteAccessBlocked) elements.forceSyncBtn.disabled = false; elements.forceSyncBtn.textContent = originalText; }; @@ -1857,6 +1952,9 @@ elements.forceSyncBtn.addEventListener('click', async () => { if (chrome.runtime.lastError || !response) { chrome.runtime.sendMessage({ type: 'INJECT_CONTENT_SCRIPT', tabId }, (injectResponse) => { if (chrome.runtime.lastError || !injectResponse || injectResponse.status !== 'ok') { + if (injectResponse?.status === 'host_permission_required') { + showSiteAccessNotice(injectResponse.host, injectResponse.originPattern); + } failForceSyncTime(); return; } @@ -1872,7 +1970,7 @@ elements.forceSyncBtn.addEventListener('click', async () => { }); elements.playBtn.addEventListener('click', () => { - if (hcmGuestLocked) return; // guest in host-only room — backstop + if (hcmGuestLocked || siteAccessBlocked) return; // host/site-access backstop if (!elements.targetTab.value) { showToast(getMessage('ERR_SELECT_VIDEO'), 'warning'); return; @@ -1886,12 +1984,12 @@ elements.playBtn.addEventListener('click', () => { }, (response) => { if (response && response.status === 'ok_solo') { elements.playBtn.textContent = getMessage('BTN_PLAY'); - elements.playBtn.disabled = false; + if (!hcmGuestLocked && !siteAccessBlocked) elements.playBtn.disabled = false; } }); // Safety reset: restore button after 2.5s in case no peers respond setTimeout(() => { - if (elements.playBtn.disabled && !hcmGuestLocked) { + if (elements.playBtn.disabled && !hcmGuestLocked && !siteAccessBlocked) { elements.playBtn.textContent = getMessage('BTN_PLAY'); elements.playBtn.disabled = false; } @@ -1899,7 +1997,7 @@ elements.playBtn.addEventListener('click', () => { }); elements.pauseBtn.addEventListener('click', () => { - if (hcmGuestLocked) return; // guest in host-only room — backstop + if (hcmGuestLocked || siteAccessBlocked) return; // host/site-access backstop if (!elements.targetTab.value) { showToast(getMessage('ERR_SELECT_VIDEO'), 'warning'); return; @@ -1913,12 +2011,12 @@ elements.pauseBtn.addEventListener('click', () => { }, (response) => { if (response && response.status === 'ok_solo') { elements.pauseBtn.textContent = getMessage('BTN_PAUSE'); - elements.pauseBtn.disabled = false; + if (!hcmGuestLocked && !siteAccessBlocked) elements.pauseBtn.disabled = false; } }); // Safety reset: restore button after 2.5s in case no peers respond setTimeout(() => { - if (elements.pauseBtn.disabled && !hcmGuestLocked) { + if (elements.pauseBtn.disabled && !hcmGuestLocked && !siteAccessBlocked) { elements.pauseBtn.textContent = getMessage('BTN_PAUSE'); elements.pauseBtn.disabled = false; } @@ -2083,7 +2181,7 @@ chrome.runtime.onMessage.addListener((msg) => { if (state.acks && state.acks.length >= peerCount) { btn.textContent = getMessage('BTN_STATE_SYNCED'); setTimeout(() => { - btn.disabled = false; + if (!hcmGuestLocked && !siteAccessBlocked) btn.disabled = false; btn.textContent = state.action === 'play' ? getMessage('BTN_PLAY') : getMessage('BTN_PAUSE'); }, 2000); } @@ -2098,7 +2196,7 @@ chrome.runtime.onMessage.addListener((msg) => { forceSyncResetTimer = null; } if (elements.forceSyncBtn) { - elements.forceSyncBtn.disabled = false; + if (!hcmGuestLocked && !siteAccessBlocked) elements.forceSyncBtn.disabled = false; elements.forceSyncBtn.textContent = getMessage('BTN_STATE_SYNCED'); setTimeout(() => { elements.forceSyncBtn.textContent = getMessage('BTN_SYNC'); @@ -2150,6 +2248,9 @@ chrome.runtime.onMessage.addListener((msg) => { } }); } + } else if (msg.type === 'TARGET_TAB_READY') { + hideSiteAccessNotice(); + populateTabs(null, msg.tabId); } else if (msg.type === 'PING_UPDATE') { updatePingDisplay(msg.ping); } else if (msg.type === 'HISTORY_UPDATE') { diff --git a/scripts/test-host-access.mjs b/scripts/test-host-access.mjs new file mode 100644 index 0000000..42efae4 --- /dev/null +++ b/scripts/test-host-access.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { cwd } from 'node:process'; +import { + HOST_ACCESS_REQUIRED_STATUS, + addTabHostAccessRequest, + describeTabUrl, + inspectTabHostAccess, + normalizeTabId, + removeTabHostAccessRequest +} from '../extension/host-access.js'; + +assert.equal(HOST_ACCESS_REQUIRED_STATUS, 'host_permission_required'); +assert.equal(normalizeTabId(null), null); +assert.equal(normalizeTabId(undefined), null); +assert.equal(normalizeTabId(''), null); +assert.equal(normalizeTabId(0), null); +assert.equal(normalizeTabId('42'), 42); +assert.deepEqual(describeTabUrl('https://emby.example:8443/web/index.html'), { + url: 'https://emby.example:8443/web/index.html', + host: 'emby.example:8443', + originPattern: 'https://emby.example:8443/*' +}); +assert.deepEqual(describeTabUrl('http://localhost:8096/web/'), { + url: 'http://localhost:8096/web/', + host: 'localhost:8096', + originPattern: 'http://localhost:8096/*' +}); +assert.equal(describeTabUrl('chrome://extensions/'), null); +assert.equal(describeTabUrl('not a url'), null); + +let containsRequest = null; +const deniedChrome = { + tabs: { + get: async tabId => ({ id: tabId, url: 'https://video.example/watch' }) + }, + permissions: { + contains: async request => { + containsRequest = request; + return false; + } + } +}; +const access = await inspectTabHostAccess(deniedChrome, 42); +assert.equal(access.granted, false); +assert.equal(access.host, 'video.example'); +assert.deepEqual(containsRequest, { origins: ['https://video.example/*'] }); + +let requestedTabId = null; +const requestChrome = { + permissions: { + addHostAccessRequest: async ({ tabId }) => { requestedTabId = tabId; } + } +}; +assert.equal(await addTabHostAccessRequest(requestChrome, 42), true); +assert.equal(requestedTabId, 42); +assert.equal(await addTabHostAccessRequest({ permissions: {} }, 42), false); + +let removedTabId = null; +const removeRequestChrome = { + permissions: { + removeHostAccessRequest: async ({ tabId }) => { removedTabId = tabId; } + } +}; +assert.equal(await removeTabHostAccessRequest(removeRequestChrome, 42), true); +assert.equal(removedTabId, 42); +assert.equal(await removeTabHostAccessRequest({ permissions: {} }, 42), false); + +const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8'); +const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8'); +const popupHtml = fs.readFileSync(path.join(cwd(), 'extension', 'popup.html'), 'utf8'); + +assert.match(background, /await activateTargetTab\(message\.tabId, message\.tabTitle\)/, + 'SET_TARGET_TAB must await successful activation before acknowledging it'); +assert.match(background, /addTabHostAccessRequest\(chrome, tabId\)/, + 'failed injection must register Chrome host-access request'); +assert.match(background, /retryPendingTarget\(\)/, + 'pending target must resume after the user grants access'); +assert.match(background, /activationGeneration !== targetActivationGeneration/, + 'stale concurrent tab activations must not overwrite the newest selection'); +const activateTargetBody = background.slice( + background.indexOf('async function activateTargetTab'), + background.indexOf('async function retryPendingTarget') +); +assert.ok( + activateTargetBody.indexOf('await injectContentScript') < activateTargetBody.indexOf('currentTabId = selectedTabId'), + 'a tab must not become current until its content script injection succeeds' +); +assert.match(background, /removeTabHostAccessRequest\(chrome, pendingTabId\)/, + 'clearing a pending target must also clear Chrome toolbar access requests'); +assert.match(popup, /response\?\.status === 'host_permission_required'/, + 'popup must render the structured host-access failure'); +assert.match(popup, /chrome\.permissions\.request\(\{ origins: \[originPattern\] \}\)/, + 'retry button must request withheld host access directly'); +assert.match(popupHtml, /id="siteAccessNotice"/, + 'popup must contain a persistent site-access notice'); + +console.log('host access recovery tests passed'); diff --git a/scripts/test-site-access-help.mjs b/scripts/test-site-access-help.mjs new file mode 100644 index 0000000..cf5daa0 --- /dev/null +++ b/scripts/test-site-access-help.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const read = relativePath => fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); + +const template = read('website/template.html'); +const foundationCss = read('website/styles/foundation.css'); +const supportCss = read('website/styles/support.css'); +const helpPage = read('website/site-access-help.html'); +const build = read('website/build.cjs'); +const localesDir = path.join(repoRoot, 'website', 'locales'); + +assert.equal((template.match(/class="site-access-banner"/g) || []).length, 1, + 'localized landing template must contain exactly one support banner'); +assert.match(template, /href="\/site-access-help\.html"/, + 'every compiled language must link to the English root help page'); +assert.match(template, /\{\{SUPPORT_BANNER_TEXT\}\}/); +assert.match(template, /\{\{SUPPORT_BANNER_CTA\}\}/); +assert.match(foundationCss, /\.site-access-banner\s*\{/, + 'landing bundle must style the support banner without deferred CSS'); + +for (const file of fs.readdirSync(localesDir).filter(file => file.endsWith('.json'))) { + const locale = JSON.parse(fs.readFileSync(path.join(localesDir, file), 'utf8')); + assert.ok(locale.SUPPORT_BANNER_TEXT?.trim(), `${file} is missing SUPPORT_BANNER_TEXT`); + assert.ok(locale.SUPPORT_BANNER_CTA?.trim(), `${file} is missing SUPPORT_BANNER_CTA`); +} + +assert.match(helpPage, /monthly 0.3 + + https://sync.koalastuff.net/site-access-help.html + ${today} + weekly + 0.8 + https://sync.koalastuff.net/de/impressum ${today} diff --git a/website/locales/de.json b/website/locales/de.json index 2b6fd3f..ee5eb3a 100644 --- a/website/locales/de.json +++ b/website/locales/de.json @@ -14,6 +14,8 @@ "TWITTER_DESCRIPTION": "Starte eine Watch Party auf Netflix, Emby, Jellyfin, YouTube, Twitch und fast jedem HTML5-Video. Open Source, datenschutzfreundlich und ohne Konto.", "NAV_FEATURES": "Funktionen", "NAV_HOW_IT_WORKS": "So funktioniert's", + "SUPPORT_BANNER_TEXT": "KoalaSync kann nicht auf deine Videoseite zugreifen?", + "SUPPORT_BANNER_CTA": "Websitezugriff reparieren", "HERO_TITLE": "Mach jedes Video zur Watch Party.", "HERO_SUBTITLE": "Dein Kino-Abend auf Distanz, ohne Lags. Kein Account, kein Tracking, keine Werbung. Einfach Link teilen und gemeinsam Play drücken.", "HERO_MASCOT_ALT": "Ein niedlicher Koala steht da und schaut nach unten auf die Download-Buttons", diff --git a/website/locales/en.json b/website/locales/en.json index f8f64f1..1028902 100644 --- a/website/locales/en.json +++ b/website/locales/en.json @@ -14,6 +14,8 @@ "TWITTER_DESCRIPTION": "Host a watch party on Netflix, Emby, Jellyfin, YouTube, Twitch and almost any HTML5 video. Open source, privacy-first, and account-free.", "NAV_FEATURES": "Features", "NAV_HOW_IT_WORKS": "How it works", + "SUPPORT_BANNER_TEXT": "KoalaSync cannot access your video site?", + "SUPPORT_BANNER_CTA": "Fix website access", "HERO_TITLE": "Turn any video into a watch party.", "HERO_SUBTITLE": "Your remote movie night without lags. No account, no tracking, no ads. Just share a link and press play together.", "HERO_MASCOT_ALT": "A cute koala standing and looking down at the download buttons", diff --git a/website/locales/es.json b/website/locales/es.json index ed53a60..13f70ba 100644 --- a/website/locales/es.json +++ b/website/locales/es.json @@ -14,6 +14,8 @@ "TWITTER_DESCRIPTION": "Organiza una watch party en Netflix, Emby, Jellyfin, YouTube, Twitch y casi cualquier vídeo HTML5. Open source, privada y sin cuenta.", "NAV_FEATURES": "Características", "NAV_HOW_IT_WORKS": "Cómo funciona", + "SUPPORT_BANNER_TEXT": "¿KoalaSync no puede acceder al sitio del vídeo?", + "SUPPORT_BANNER_CTA": "Reparar acceso al sitio", "HERO_TITLE": "Convierte cualquier vídeo en una watch party.", "HERO_SUBTITLE": "Tu noche de cine a distancia, sin retrasos. Sin cuenta, sin rastreo, sin anuncios. Comparte un enlace y dale al play juntos.", "HERO_MASCOT_ALT": "Un lindo koala de pie mirando hacia abajo a los botones de descarga", diff --git a/website/locales/fr.json b/website/locales/fr.json index eec36dd..5a6aa01 100644 --- a/website/locales/fr.json +++ b/website/locales/fr.json @@ -14,6 +14,8 @@ "TWITTER_DESCRIPTION": "Organisez une watch party sur Netflix, Emby, Jellyfin, YouTube, Twitch et presque toute vidéo HTML5. Open source, privé et sans compte.", "NAV_FEATURES": "Fonctionnalités", "NAV_HOW_IT_WORKS": "Comment ça marche", + "SUPPORT_BANNER_TEXT": "KoalaSync n’accède pas à votre site vidéo ?", + "SUPPORT_BANNER_CTA": "Réparer l’accès au site", "HERO_TITLE": "Transformez n'importe quelle vidéo en watch party.", "HERO_SUBTITLE": "Votre soirée ciné à distance, sans décalage. Pas de compte, pas de tracking, pas de pub. Partagez un lien et lancez la lecture ensemble.", "HERO_MASCOT_ALT": "Un koala mignon debout et regardant vers le bas les boutons de téléchargement", diff --git a/website/locales/it.json b/website/locales/it.json index bc37ac9..87424f2 100644 --- a/website/locales/it.json +++ b/website/locales/it.json @@ -14,6 +14,8 @@ "TWITTER_DESCRIPTION": "Avvia una watch party su Netflix, Emby, Jellyfin, YouTube, Twitch e quasi ogni video HTML5. Open source, privata e senza account.", "NAV_FEATURES": "Funzionalità", "NAV_HOW_IT_WORKS": "Come Funziona", + "SUPPORT_BANNER_TEXT": "KoalaSync non riesce ad accedere al sito video?", + "SUPPORT_BANNER_CTA": "Ripristina l’accesso al sito", "HERO_TITLE": "Trasforma qualsiasi video in una watch party.", "HERO_SUBTITLE": "La tua serata film a distanza, senza lag. Niente account, niente tracciamento, niente pubblicità. Condividi un link e premete play insieme.", "HERO_MASCOT_ALT": "Un simpatico koala che guarda i pulsanti di download", diff --git a/website/locales/ja.json b/website/locales/ja.json index 1ff4522..19e7bea 100644 --- a/website/locales/ja.json +++ b/website/locales/ja.json @@ -14,6 +14,8 @@ "TWITTER_DESCRIPTION": "Netflix、Emby、Jellyfin、YouTube、Twitch、ほぼすべてのHTML5動画でウォッチパーティーを開催。オープンソース、プライバシー重視、アカウント不要。", "NAV_FEATURES": "機能", "NAV_HOW_IT_WORKS": "仕組み", + "SUPPORT_BANNER_TEXT": "KoalaSync が動画サイトにアクセスできませんか?", + "SUPPORT_BANNER_CTA": "サイトアクセスを修復", "HERO_TITLE": "どんな動画もウォッチパーティーに。", "HERO_SUBTITLE": "離れていても一緒に映画ナイト。アカウント不要、トラッキングなし、広告なし。リンクを共有して、一緒に再生するだけ。", "HERO_MASCOT_ALT": "ダウンロードボタンを見下ろしているかわいいコアラ", diff --git a/website/locales/ko.json b/website/locales/ko.json index c3c1fc2..6434372 100644 --- a/website/locales/ko.json +++ b/website/locales/ko.json @@ -14,6 +14,8 @@ "TWITTER_DESCRIPTION": "Netflix, Emby, Jellyfin, YouTube, Twitch 및 거의 모든 HTML5 영상에서 워치 파티를 열어보세요. 오픈소스, 개인정보 보호 중심, 계정 불필요.", "NAV_FEATURES": "특징", "NAV_HOW_IT_WORKS": "작동 방식", + "SUPPORT_BANNER_TEXT": "KoalaSync가 동영상 사이트에 액세스할 수 없나요?", + "SUPPORT_BANNER_CTA": "사이트 액세스 복구", "HERO_TITLE": "어떤 영상이든 워치 파티로.", "HERO_SUBTITLE": "지연 없는 원격 영화의 밤. 계정 없음, 추적 없음, 광고 없음. 링크만 공유하고 함께 재생하세요.", "HERO_MASCOT_ALT": "다운로드 버튼을 내려다보고 서 있는 귀여운 코알라", diff --git a/website/locales/nl.json b/website/locales/nl.json index f5edee4..41a8a6d 100644 --- a/website/locales/nl.json +++ b/website/locales/nl.json @@ -14,6 +14,8 @@ "TWITTER_DESCRIPTION": "Start een watch party op Netflix, Emby, Jellyfin, YouTube, Twitch en bijna elke HTML5-video. Open source, privacyvriendelijk en zonder account.", "NAV_FEATURES": "Functies", "NAV_HOW_IT_WORKS": "Hoe het werkt", + "SUPPORT_BANNER_TEXT": "Heeft KoalaSync geen toegang tot je videosite?", + "SUPPORT_BANNER_CTA": "Websitetoegang herstellen", "HERO_TITLE": "Maak van elke video een watch party.", "HERO_SUBTITLE": "Jouw filmavond op afstand, zonder vertraging. Geen account, geen tracking, geen advertenties. Deel een link en druk samen op play.", "HERO_MASCOT_ALT": "Een schattige koala die staat en naar beneden kijkt naar de downloadknoppen", diff --git a/website/locales/pl.json b/website/locales/pl.json index ef3bcdb..2d2ab69 100644 --- a/website/locales/pl.json +++ b/website/locales/pl.json @@ -14,6 +14,8 @@ "TWITTER_DESCRIPTION": "Zorganizuj watch party na Netflix, Emby, Jellyfin, YouTube, Twitch i prawie każdym wideo HTML5. Open source, prywatnie i bez konta.", "NAV_FEATURES": "Funkcje", "NAV_HOW_IT_WORKS": "Jak to działa", + "SUPPORT_BANNER_TEXT": "KoalaSync nie ma dostępu do strony z filmem?", + "SUPPORT_BANNER_CTA": "Napraw dostęp do strony", "HERO_TITLE": "Zamień dowolne wideo w watch party.", "HERO_SUBTITLE": "Twój zdalny wieczór filmowy bez opóźnień. Bez konta, bez śledzenia, bez reklam. Udostępnij link i naciśnijcie play razem.", "HERO_MASCOT_ALT": "Uroczy koala stojący i patrzący w dół na przyciski pobierania", diff --git a/website/locales/pt-BR.json b/website/locales/pt-BR.json index 3bd9c15..1906f5d 100644 --- a/website/locales/pt-BR.json +++ b/website/locales/pt-BR.json @@ -14,6 +14,8 @@ "TWITTER_DESCRIPTION": "Crie uma watch party na Netflix, Emby, Jellyfin, YouTube, Twitch e quase qualquer vídeo HTML5. Open source, privado e sem conta.", "NAV_FEATURES": "Recursos", "NAV_HOW_IT_WORKS": "Como Funciona", + "SUPPORT_BANNER_TEXT": "O KoalaSync não consegue acessar o site do vídeo?", + "SUPPORT_BANNER_CTA": "Corrigir acesso ao site", "HERO_TITLE": "Transforme qualquer vídeo em uma watch party.", "HERO_SUBTITLE": "Sua sessão de cinema à distância, sem atrasos. Sem conta, sem rastreamento, sem anúncios. Compartilhe um link e deem play juntos.", "HERO_MASCOT_ALT": "Um coala fofo olhando para os botões de download", diff --git a/website/locales/pt.json b/website/locales/pt.json index a9be5f6..7567526 100644 --- a/website/locales/pt.json +++ b/website/locales/pt.json @@ -14,6 +14,8 @@ "TWITTER_DESCRIPTION": "Cria uma watch party na Netflix, Emby, Jellyfin, YouTube, Twitch e quase qualquer vídeo HTML5. Open source, privado e sem conta.", "NAV_FEATURES": "Funcionalidades", "NAV_HOW_IT_WORKS": "Como Funciona", + "SUPPORT_BANNER_TEXT": "O KoalaSync não consegue aceder ao site do vídeo?", + "SUPPORT_BANNER_CTA": "Corrigir acesso ao site", "HERO_TITLE": "Transforma qualquer vídeo numa watch party.", "HERO_SUBTITLE": "A tua noite de cinema à distância, sem atrasos. Sem conta, sem rastreio, sem anúncios. Partilha um link e carreguem no play juntos.", "HERO_MASCOT_ALT": "Um coala fofo a olhar para os botões de download", diff --git a/website/locales/ru.json b/website/locales/ru.json index 33c5883..a8eb060 100644 --- a/website/locales/ru.json +++ b/website/locales/ru.json @@ -14,6 +14,8 @@ "TWITTER_DESCRIPTION": "Запускайте watch party в Netflix, Emby, Jellyfin, YouTube, Twitch и почти любом HTML5-видео. Open source, приватно и без аккаунта.", "NAV_FEATURES": "Функции", "NAV_HOW_IT_WORKS": "Как это работает", + "SUPPORT_BANNER_TEXT": "KoalaSync не может получить доступ к сайту с видео?", + "SUPPORT_BANNER_CTA": "Исправить доступ к сайту", "HERO_TITLE": "Преврати любое видео в watch party.", "HERO_SUBTITLE": "Ваш киновечер на расстоянии, без задержек. Без аккаунта, без слежки, без рекламы. Просто поделитесь ссылкой и нажмите play вместе.", "HERO_MASCOT_ALT": "Милый коала стоит и смотрит вниз на кнопки загрузки", diff --git a/website/locales/tr.json b/website/locales/tr.json index 7fa52b2..55506c5 100644 --- a/website/locales/tr.json +++ b/website/locales/tr.json @@ -14,6 +14,8 @@ "TWITTER_DESCRIPTION": "Netflix, Emby, Jellyfin, YouTube, Twitch ve neredeyse tüm HTML5 videolarında watch party başlatın. Açık kaynak, gizlilik odaklı ve hesapsız.", "NAV_FEATURES": "Özellikler", "NAV_HOW_IT_WORKS": "Nasıl Çalışır", + "SUPPORT_BANNER_TEXT": "KoalaSync video sitesine erişemiyor mu?", + "SUPPORT_BANNER_CTA": "Site erişimini düzelt", "HERO_TITLE": "Herhangi bir videoyu watch party'ye dönüştür.", "HERO_SUBTITLE": "Gecikmesiz uzaktan film gecen. Hesap yok, takip yok, reklam yok. Sadece bir bağlantı paylaş ve birlikte oynatın.", "HERO_MASCOT_ALT": "İndirme düğmelerine bakan sevimli bir koala", diff --git a/website/locales/uk.json b/website/locales/uk.json index a44ad85..4912b53 100644 --- a/website/locales/uk.json +++ b/website/locales/uk.json @@ -14,6 +14,8 @@ "TWITTER_DESCRIPTION": "Запускайте watch party у Netflix, Emby, Jellyfin, YouTube, Twitch і майже будь-якому HTML5-відео. Open source, приватно й без акаунта.", "NAV_FEATURES": "особливості", "NAV_HOW_IT_WORKS": "Як це працює", + "SUPPORT_BANNER_TEXT": "KoalaSync не має доступу до сайту з відео?", + "SUPPORT_BANNER_CTA": "Виправити доступ до сайту", "HERO_TITLE": "Перетвори будь-яке відео на watch party.", "HERO_SUBTITLE": "Ваш кіновечір на відстані, без затримок. Без акаунта, без стеження, без реклами. Просто поділіться посиланням і натисніть play разом.", "HERO_MASCOT_ALT": "Мила коала стоїть і дивиться вниз на кнопки завантаження", diff --git a/website/locales/zh.json b/website/locales/zh.json index 1a0fab4..cfe3e44 100644 --- a/website/locales/zh.json +++ b/website/locales/zh.json @@ -14,6 +14,8 @@ "TWITTER_DESCRIPTION": "在 Netflix、Emby、Jellyfin、YouTube、Twitch 和几乎任意 HTML5 视频上发起一起看派对。开源、重视隐私、无需账号。", "NAV_FEATURES": "特征", "NAV_HOW_IT_WORKS": "它是如何运作的", + "SUPPORT_BANNER_TEXT": "KoalaSync 无法访问视频网站?", + "SUPPORT_BANNER_CTA": "修复网站访问权限", "HERO_TITLE": "把任意视频变成一起看派对。", "HERO_SUBTITLE": "远程电影之夜,毫无延迟。无需账号、无跟踪、无广告。分享链接,一起播放。", "HERO_MASCOT_ALT": "一只可爱的考拉站着,低头看着下载按钮", diff --git a/website/site-access-help.html b/website/site-access-help.html new file mode 100644 index 0000000..ccbbc0b --- /dev/null +++ b/website/site-access-help.html @@ -0,0 +1,169 @@ + + + + + + KoalaSync Cannot Access Your Video Site? Fix Website Access + + + + + + + + + + + + + + + + + + +
+ + + + +
+
+ Website access troubleshooting +

Connected, but the video tab does nothing?

+

Your browser may be withholding KoalaSync’s access to that website. The room connection can still look healthy while KoalaSync cannot read or control the player.

+
+ +
+ Start here +

Use the built-in one-click fix

+
    +
  1. Open the video website and then open the KoalaSync popup.
  2. +
  3. Select the video tab. If access is blocked, KoalaSync shows an access notice instead of silently pretending the tab is connected.
  4. +
  5. Select Allow access and approve the browser prompt. KoalaSync retries the tab automatically.
  6. +
  7. If the prompt does not appear, use the manual browser steps below and reload the video page once.
  8. +
+
+ +
+ +
+ This is not a new KoalaSync permission. +

Modern browsers let users, profiles, and administrators withhold extension access per website. Chrome’s newer extension menu makes that existing control much more visible. KoalaSync now detects the blocked state and asks for access only after you select a video tab.

+
+
+ +
+
+ Chrome, Edge, Brave, Opera +

Allow KoalaSync on the current site

+
    +
  1. Keep the affected video tab open.
  2. +
  3. Open the browser’s extensions menu near the address bar.
  4. +
  5. Under Access requested, select KoalaSync and allow access.
  6. +
  7. If needed, open KoalaSync’s extension details and change site access to On this site or On all sites.
  8. +
  9. Reload the video tab and select it again in KoalaSync.
  10. +
+

Google’s official site-access instructions

+
+ +
+ Firefox +

Check extension permissions

+
    +
  1. Open Add-ons and themes, then select Extensions.
  2. +
  3. Open KoalaSync and select Permissions or Permissions and data.
  4. +
  5. Allow the requested website access.
  6. +
  7. Return to the video site, reload it, and select the tab again.
  8. +
+

Mozilla’s official extension-permissions instructions

+
+
+ +
+ Why access is needed +

KoalaSync must reach the player you selected

+

KoalaSync injects a local content script into the selected tab so it can find the HTML5 player, read its playback position, and apply play, pause, and seek actions. The video and audio stream never leave your browser. Only synchronization state is sent through the relay.

+

Access is requested for the website origin shown in the popup. Browser-internal pages such as chrome://, edge://, and about: cannot be controlled. Local files may also require the separate Allow access to file URLs switch in the extension details.

+
+ +
+ Still not working? +

Run these checks in order

+
    +
  • Update KoalaSync in both browsers. Friends using different releases should update before testing again.
  • +
  • Reload the video page after granting access. An already-open page may still have the old blocked state.
  • +
  • Open KoalaSync and reselect the exact tab containing the player.
  • +
  • Confirm the debug report says Video Found. A page without a standard player may need site-specific support.
  • +
  • Temporarily test without another extension that also modifies the player.
  • +
  • If it still fails, collect a debug report from each participant before leaving the room.
  • +
+
+ +
+ Separate issue +

“Peers (1)” only shows yourself

+

Website access does not control the relay participant list. If both people see the same room name but each report lists only themselves, the relay has not placed both connections into the same live room state.

+
    +
  • Open the same invite link again and verify the room name, password, and server URL match.
  • +
  • Update both extensions, leave the room, and reconnect once.
  • +
  • When self-hosting, use a single relay process unless room state is shared between instances; KoalaSync’s default relay state lives in process memory.
  • +
  • If the problem remains, save both debug reports and the relay logs from the same timestamp.
  • +
+
+ + +
+ +
+
+ + + +
+
+ + + + diff --git a/website/styles/foundation.css b/website/styles/foundation.css index b9a3714..a8eeb15 100644 --- a/website/styles/foundation.css +++ b/website/styles/foundation.css @@ -92,6 +92,75 @@ transform: scale(1.05); } +.site-access-banner { + position: relative; + z-index: 900; + margin-top: 96px; + border-block: 1px solid color-mix(in oklch, var(--accent) 42%, transparent); + background: color-mix(in oklch, var(--accent) 16%, var(--bg)); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.16); +} + +.site-access-banner-inner { + min-height: 50px; + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + color: var(--text); + font-size: 0.9rem; + font-weight: 650; + text-align: center; +} + +.site-access-banner-icon { + flex: 0 0 auto; + display: inline-grid; + place-items: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 999px; + background: var(--accent); + color: var(--text-on-green); + font-size: 0.9rem; + font-weight: 900; +} + +.site-access-banner-link { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: var(--accent); + font-weight: 800; + text-decoration: underline; + text-underline-offset: 0.18em; + white-space: nowrap; +} + +.site-access-banner-link:hover { + color: var(--text); +} + +@media (max-width: 768px) { + .site-access-banner { + margin-top: 76px; + } + + .site-access-banner-inner { + min-height: 64px; + padding-block: 0.65rem; + gap: 0.5rem; + flex-wrap: wrap; + font-size: 0.8rem; + } + + .site-access-banner-icon { + width: 1.3rem; + height: 1.3rem; + font-size: 0.75rem; + } +} + * { margin: 0; padding: 0; diff --git a/website/styles/support.css b/website/styles/support.css new file mode 100644 index 0000000..ca900c2 --- /dev/null +++ b/website/styles/support.css @@ -0,0 +1,246 @@ +/* --- Site-access troubleshooting page --- */ +.support-page { + min-height: 100vh; +} + +.support-page::before { + content: ''; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background: + radial-gradient(circle at 15% 8%, color-mix(in oklch, var(--accent) 15%, transparent), transparent 32rem), + radial-gradient(circle at 85% 30%, color-mix(in oklch, var(--accent-terracotta) 10%, transparent), transparent 30rem); +} + +.support-content { + width: min(980px, calc(100% - 2rem)); + margin: 0 auto; + padding: 9rem 0 4rem; +} + +.support-hero { + max-width: 760px; + margin: 0 auto 2rem; + text-align: center; +} + +.support-eyebrow, +.support-status { + display: inline-flex; + align-items: center; + gap: 0.45rem; + border: 1px solid color-mix(in oklch, var(--accent) 35%, transparent); + border-radius: 999px; + background: color-mix(in oklch, var(--accent) 12%, var(--card)); + color: var(--accent); + padding: 0.4rem 0.8rem; + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.support-status::before { + content: ''; + width: 0.55rem; + height: 0.55rem; + border-radius: 999px; + background: var(--accent); + box-shadow: 0 0 0 0.3rem color-mix(in oklch, var(--accent) 16%, transparent); +} + +.support-hero h1 { + margin: 1rem 0; + color: var(--text); + font-size: clamp(2.35rem, 6vw, 4.3rem); + line-height: 1.05; + letter-spacing: -0.045em; +} + +.support-lead { + color: var(--text-muted); + font-size: clamp(1rem, 2vw, 1.18rem); + line-height: 1.75; +} + +.support-card, +.support-callout { + border: 1px solid var(--card-border); + border-radius: 22px; + background: var(--card-surface); + box-shadow: 0 18px 55px rgba(0, 0, 0, 0.15); + backdrop-filter: blur(14px); +} + +.support-card { + padding: clamp(1.35rem, 4vw, 2.25rem); +} + +.support-card + .support-card, +.support-grid + .support-card, +.support-card + .support-grid, +.support-callout + .support-card { + margin-top: 1.25rem; +} + +.support-card h2, +.support-card h3 { + color: var(--text); + line-height: 1.25; +} + +.support-card h2 { + margin-bottom: 0.8rem; + font-size: clamp(1.35rem, 3vw, 1.75rem); +} + +.support-card h3 { + margin-bottom: 0.55rem; + font-size: 1.05rem; +} + +.support-card p, +.support-card li, +.support-callout p { + color: var(--text-muted); +} + +.support-card p + p, +.support-card p + ul, +.support-card p + ol, +.support-card ul + p, +.support-card ol + p { + margin-top: 0.85rem; +} + +.support-card ul, +.support-card ol { + padding-left: 1.2rem; +} + +.support-card li + li { + margin-top: 0.6rem; +} + +.support-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.25rem; + margin-top: 1.25rem; +} + +.support-steps { + list-style: none; + padding: 0 !important; + counter-reset: support-step; +} + +.support-steps li { + position: relative; + min-height: 2.4rem; + padding-left: 3.2rem; + counter-increment: support-step; +} + +.support-steps li::before { + content: counter(support-step); + position: absolute; + top: -0.15rem; + left: 0; + display: grid; + place-items: center; + width: 2.2rem; + height: 2.2rem; + border-radius: 12px; + background: var(--accent); + color: var(--text-on-green); + font-weight: 900; +} + +.support-callout { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 1rem; + align-items: start; + margin: 1.25rem 0; + padding: 1.25rem; + border-color: color-mix(in oklch, var(--accent) 40%, transparent); + background: color-mix(in oklch, var(--accent) 10%, var(--card-surface)); +} + +.support-callout-icon { + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + border-radius: 999px; + background: var(--accent); + color: var(--text-on-green); + font-weight: 900; +} + +.support-callout strong { + display: block; + margin-bottom: 0.3rem; + color: var(--text); +} + +.support-link { + color: var(--accent); + font-weight: 700; + text-decoration: underline; + text-underline-offset: 0.18em; +} + +.support-link:hover { + color: var(--text); +} + +.support-actions { + display: flex; + justify-content: center; + gap: 0.8rem; + flex-wrap: wrap; + margin-top: 2rem; +} + +.support-actions .btn { + min-width: 180px; +} + +.support-separate-issue { + border-color: color-mix(in oklch, var(--accent-terracotta) 45%, transparent); +} + +.support-separate-issue .support-eyebrow { + border-color: color-mix(in oklch, var(--accent-terracotta) 40%, transparent); + background: color-mix(in oklch, var(--accent-terracotta) 10%, var(--card)); + color: var(--accent-terracotta); +} + +html.theme-light .support-card, +html.theme-light .support-callout { + background-color: var(--card-surface); + box-shadow: 0 18px 45px rgba(44, 62, 37, 0.1); +} + +@media (max-width: 720px) { + .support-content { + width: min(100% - 1rem, 980px); + padding-top: 7.5rem; + } + + .support-grid { + grid-template-columns: 1fr; + } + + .support-card { + border-radius: 18px; + } + + .support-callout { + grid-template-columns: 1fr; + } +} diff --git a/website/template.html b/website/template.html index 70e391c..1bb5007 100644 --- a/website/template.html +++ b/website/template.html @@ -210,7 +210,7 @@ - +
@@ -360,6 +360,17 @@
+ +
From a334605dd579dda3a985691398903aa538a9a7cc Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:27:59 +0200 Subject: [PATCH 02/17] Remove unrelated relay troubleshooting from help page --- scripts/test-site-access-help.mjs | 4 ++-- website/site-access-help.html | 12 ------------ website/styles/support.css | 10 ---------- 3 files changed, 2 insertions(+), 24 deletions(-) diff --git a/scripts/test-site-access-help.mjs b/scripts/test-site-access-help.mjs index cf5daa0..e9c8424 100644 --- a/scripts/test-site-access-help.mjs +++ b/scripts/test-site-access-help.mjs @@ -38,8 +38,8 @@ assert.match(helpPage, /Access requested/, 'help page must explain the new Chromium access-request UI'); assert.match(helpPage, /Firefox/, 'help page must include Firefox-specific recovery instructions'); -assert.match(helpPage, /“Peers \(1\)” only shows yourself/, - 'help page must distinguish relay peer visibility from website access'); +assert.doesNotMatch(helpPage, /Peers \(1\)|Separate issue/, + 'website-access help must not include unrelated relay troubleshooting'); assert.match(helpPage, /video and audio stream never leave your browser/, 'help page must explain the privacy boundary of the permission'); assert.match(helpPage, /support\.google\.com\/chrome/); diff --git a/website/site-access-help.html b/website/site-access-help.html index ccbbc0b..a2c0650 100644 --- a/website/site-access-help.html +++ b/website/site-access-help.html @@ -134,18 +134,6 @@ -
- Separate issue -

“Peers (1)” only shows yourself

-

Website access does not control the relay participant list. If both people see the same room name but each report lists only themselves, the relay has not placed both connections into the same live room state.

-
    -
  • Open the same invite link again and verify the room name, password, and server URL match.
  • -
  • Update both extensions, leave the room, and reconnect once.
  • -
  • When self-hosting, use a single relay process unless room state is shared between instances; KoalaSync’s default relay state lives in process memory.
  • -
  • If the problem remains, save both debug reports and the relay logs from the same timestamp.
  • -
-
-
Back to KoalaSync Report a reproducible issue diff --git a/website/styles/support.css b/website/styles/support.css index ca900c2..bb698e0 100644 --- a/website/styles/support.css +++ b/website/styles/support.css @@ -210,16 +210,6 @@ min-width: 180px; } -.support-separate-issue { - border-color: color-mix(in oklch, var(--accent-terracotta) 45%, transparent); -} - -.support-separate-issue .support-eyebrow { - border-color: color-mix(in oklch, var(--accent-terracotta) 40%, transparent); - background: color-mix(in oklch, var(--accent-terracotta) 10%, var(--card)); - color: var(--accent-terracotta); -} - html.theme-light .support-card, html.theme-light .support-callout { background-color: var(--card-surface); From 142153a1316339515baeb865d6275bb3173b2ebf Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:46:57 +0200 Subject: [PATCH 03/17] Harden host access recovery for v2.6.2 --- docs/CHANGELOG.md | 12 +++ extension/background.js | 157 ++++++++++++++++++++++++------ extension/host-access.js | 104 +++++++++++++++++--- extension/popup.js | 34 ++++--- scripts/test-host-access.mjs | 38 ++++++-- scripts/test-site-access-help.mjs | 57 ----------- scripts/verify-release.mjs | 1 - 7 files changed, 278 insertions(+), 125 deletions(-) delete mode 100644 scripts/test-site-access-help.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d52ae48..cfc558d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to the KoalaSync browser extension and relay server. --- +## [v2.6.2] — 2026-07-15 + +### Added +- **Website: Site-access recovery guide** — Added an English help page and a localized banner across every landing-page language. + +### Fixed +- **Extension: Withheld website access recovery** — Detects browser-withheld host access, shows a localized allow-access action, requests only the selected website origin, and resumes the selected tab after access is granted. +- **Extension: Target-tab reliability** — Hardened permission recovery against rapid tab changes, navigation, closed tabs, service-worker restoration, and stale pending state. +- **Browser compatibility** — Uses Chrome's toolbar access request only when available, with a direct user-gesture permission fallback for Firefox and older Chromium browsers. + +--- + ## [v2.6.1] — 2026-07-15 ### Fixed diff --git a/extension/background.js b/extension/background.js index 44ccfec..ce93f85 100644 --- a/extension/background.js +++ b/extension/background.js @@ -4,7 +4,7 @@ import { loadLocale, getMessage, getSystemLanguage } from './i18n.js'; import { sameEpisode, extractEpisodeId } from './episode-utils.js'; import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js'; import { initTabManager } from './modules/tab-manager.js'; -import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js'; +import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js'; import './page-api-seek-overrides.js'; // --- Uninstall URL Initialization --- @@ -170,8 +170,12 @@ function ensureState() { ], (data) => { clearTimeout(storageTimeout); if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount; - if (data.currentTabId !== undefined) currentTabId = data.currentTabId; - if (data.currentTabTitle !== undefined) currentTabTitle = data.currentTabTitle; + if (data.currentTabId !== undefined) currentTabId = normalizeTabId(data.currentTabId); + if (data.currentTabTitle !== undefined) { + currentTabTitle = currentTabId !== null && typeof data.currentTabTitle === 'string' + ? data.currentTabTitle + : null; + } // 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, 200); @@ -1770,6 +1774,9 @@ function injectionFailureResponse(error) { } async function injectContentScript(tabId, { requestHostAccess = true } = {}) { + const normalizedTabId = normalizeTabId(tabId); + if (normalizedTabId === null) throw new Error('Invalid tab ID'); + tabId = normalizedTabId; let needsPageApiSeek = false; let pageApiSeekReady = false; let access = null; @@ -1817,9 +1824,18 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) { // A temporary activeTab grant is intentionally allowed to win: even if // permissions.contains() reports false, a successful injection above is // valid. Only convert an actual injection failure into a host-access UX. - if (access?.originPattern && access.granted === false) { + try { + // The tab may have crossed origins between the initial permission + // check and executeScript(). Always report/request the current URL. + access = await inspectTabHostAccess(chrome, tabId); + } catch (_inspectionError) { + access = null; + } + const accessIsMissing = access?.granted === false + || (access?.granted === null && isHostAccessError(error)); + if (access?.originPattern && accessIsMissing) { const requestAdded = requestHostAccess - ? await addTabHostAccessRequest(chrome, tabId) + ? await addTabHostAccessRequest(chrome, tabId, access.originPattern) : false; throw createHostAccessRequiredError(access, requestAdded, error); } @@ -1827,31 +1843,61 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) { } } +let pendingTargetMutation = Promise.resolve(); + +function mutatePendingTarget(operation) { + const result = pendingTargetMutation.catch(() => {}).then(operation); + pendingTargetMutation = result.catch(() => {}); + return result; +} + async function rememberPendingTarget(tabId, tabTitle, error) { - const previous = await chrome.storage.session.get('pendingTargetTabId'); - const previousTabId = normalizeTabId(previous.pendingTargetTabId); - if (previousTabId !== null && previousTabId !== tabId) { - await removeTabHostAccessRequest(chrome, previousTabId); - } - await chrome.storage.session.set({ - pendingTargetTabId: tabId, - pendingTargetTabTitle: tabTitle || null, - pendingTargetHost: error?.host || null, - pendingTargetOriginPattern: error?.originPattern || null + return mutatePendingTarget(async () => { + const previous = await chrome.storage.session.get([ + 'pendingTargetTabId', + 'pendingTargetOriginPattern' + ]); + const previousTabId = normalizeTabId(previous.pendingTargetTabId); + const nextOriginPattern = error?.originPattern || null; + if (previousTabId !== null && ( + previousTabId !== tabId + || previous.pendingTargetOriginPattern !== nextOriginPattern + )) { + await removeTabHostAccessRequest( + chrome, + previousTabId, + previous.pendingTargetOriginPattern || null + ); + } + await chrome.storage.session.set({ + pendingTargetTabId: tabId, + pendingTargetTabTitle: typeof tabTitle === 'string' ? tabTitle : null, + pendingTargetHost: error?.host || null, + pendingTargetOriginPattern: nextOriginPattern + }); }); } async function clearPendingTarget() { - const pending = await chrome.storage.session.get('pendingTargetTabId'); - const pendingTabId = normalizeTabId(pending.pendingTargetTabId); - if (pendingTabId !== null) { - await removeTabHostAccessRequest(chrome, pendingTabId); - } - await chrome.storage.session.set({ - pendingTargetTabId: null, - pendingTargetTabTitle: null, - pendingTargetHost: null, - pendingTargetOriginPattern: null + return mutatePendingTarget(async () => { + const pending = await chrome.storage.session.get([ + 'pendingTargetTabId', + 'pendingTargetOriginPattern' + ]); + const pendingTabId = normalizeTabId(pending.pendingTargetTabId); + if (pendingTabId !== null) { + await removeTabHostAccessRequest( + chrome, + pendingTabId, + pending.pendingTargetOriginPattern || null + ); + } + await chrome.storage.session.set({ + pendingTargetTabId: null, + pendingTargetTabTitle: null, + pendingTargetHost: null, + pendingTargetOriginPattern: null + }); }); } @@ -1862,7 +1908,7 @@ async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true } = } const activationGeneration = ++targetActivationGeneration; - const previousTabId = currentTabId; + const previousTabId = normalizeTabId(currentTabId); try { await injectContentScript(selectedTabId, { requestHostAccess }); @@ -1872,6 +1918,7 @@ async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true } = } currentTabId = null; currentTabTitle = null; + lastContentHeartbeatAt = null; if (currentRoom) roomIdleSince = Date.now(); if (previousTabId) { resetAudioProcessingInTab(previousTabId); @@ -1882,10 +1929,17 @@ async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true } = roomIdleSince, lastContentHeartbeatAt: null }); + if (activationGeneration !== targetActivationGeneration) { + return { status: 'superseded' }; + } updateBadgeStatus(); if (error?.code === HOST_ACCESS_REQUIRED_STATUS) { await rememberPendingTarget(selectedTabId, tabTitle, error); + chrome.runtime.sendMessage({ + type: 'TARGET_TAB_ACCESS_REQUIRED', + ...injectionFailureResponse(error) + }).catch(() => {}); } else { await clearPendingTarget(); } @@ -1897,15 +1951,23 @@ async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true } = return { status: 'superseded' }; } + await applyAudioSettingsToTab(selectedTabId); + if (activationGeneration !== targetActivationGeneration) { + if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); + return { status: 'superseded' }; + } + await clearPendingTarget(); + if (activationGeneration !== targetActivationGeneration) { + if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); + return { status: 'superseded' }; + } currentTabId = selectedTabId; - currentTabTitle = tabTitle || null; + currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null; lastContentHeartbeatAt = null; if (currentRoom) roomIdleSince = Date.now(); if (previousTabId && previousTabId !== selectedTabId) { resetAudioProcessingInTab(previousTabId); } - await applyAudioSettingsToTab(selectedTabId); - await clearPendingTarget(); await chrome.storage.session.set({ currentTabId, currentTabTitle, @@ -1950,6 +2012,36 @@ if (chrome.permissions?.onAdded?.addListener) { }); } +if (chrome.tabs?.onRemoved?.addListener) { + chrome.tabs.onRemoved.addListener((removedTabId) => { + ensureState().then(async () => { + const tabId = normalizeTabId(removedTabId); + if (tabId === null) return; + const pending = await chrome.storage.session.get('pendingTargetTabId'); + const isCurrent = normalizeTabId(currentTabId) === tabId; + const isPending = normalizeTabId(pending.pendingTargetTabId) === tabId; + if (!isCurrent && !isPending) return; + + targetActivationGeneration++; + if (isCurrent) { + currentTabId = null; + currentTabTitle = null; + lastContentHeartbeatAt = null; + if (currentRoom) roomIdleSince = Date.now(); + } + if (isPending) await clearPendingTarget(); + await chrome.storage.session.set({ + currentTabId, + currentTabTitle, + roomIdleSince, + lastContentHeartbeatAt + }); + updateBadgeStatus(); + chrome.runtime.sendMessage({ type: 'TARGET_TAB_CLEARED', tabId }).catch(() => {}); + }).catch(error => addLog(`Closed target-tab cleanup failed: ${error.message}`, 'warn')); + }); +} + function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries) { chrome.tabs.sendMessage(tabId, { type: 'SERVER_COMMAND', @@ -1964,7 +2056,8 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman return; } if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) { - injectContentScript(tabId).then(() => { + activateTargetTab(tabId, currentTabTitle).then(response => { + if (response?.status !== 'ok') return; setTimeout(() => _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries + 1), 500); }).catch(_err => { addLog(`Auto-reinject failed for tab ${tabId}`, 'warn'); @@ -2618,8 +2711,8 @@ async function handleAsyncMessage(message, sender, sendResponse) { return true; } - injectContentScript(tabId).then(() => { - sendResponse({ status: 'ok' }); + activateTargetTab(tabId, currentTabTitle).then(response => { + sendResponse(response); }).catch(err => { addLog(`Failed to inject into tab: ${err.message}`, 'warn'); sendResponse(injectionFailureResponse(err)); diff --git a/extension/host-access.js b/extension/host-access.js index d47b1a8..9f801b1 100644 --- a/extension/host-access.js +++ b/extension/host-access.js @@ -1,9 +1,15 @@ export const HOST_ACCESS_REQUIRED_STATUS = 'host_permission_required'; export function normalizeTabId(value) { - if (value === null || value === undefined || value === '') return null; - const tabId = Number(value); - return Number.isInteger(tabId) && tabId > 0 ? tabId : null; + if (typeof value === 'number') { + return Number.isSafeInteger(value) && value > 0 ? value : null; + } + if (typeof value !== 'string') return null; + + const normalized = value.trim(); + if (!/^[1-9]\d*$/.test(normalized)) return null; + const tabId = Number(normalized); + return Number.isSafeInteger(tabId) ? tabId : null; } export function describeTabUrl(rawUrl) { @@ -34,7 +40,7 @@ export function describeTabUrl(rawUrl) { export async function inspectTabHostAccess(chromeApi, tabId) { const tab = await chromeApi.tabs.get(tabId); - const descriptor = describeTabUrl(tab?.url || ''); + const descriptor = describeTabUrl(tab?.pendingUrl || tab?.url || ''); if (!descriptor) { return { tab, @@ -50,10 +56,16 @@ export async function inspectTabHostAccess(chromeApi, tabId) { } try { - const granted = await chromeApi.permissions.contains({ - origins: [descriptor.originPattern] - }); - return { tab, ...descriptor, granted: granted === true }; + const granted = await callBooleanPermissionMethod( + chromeApi, + 'contains', + { origins: [descriptor.originPattern] } + ); + return { + tab, + ...descriptor, + granted: granted === null ? null : granted === true + }; } catch (_e) { // Permission inspection is advisory. The actual script injection below // remains the source of truth, including temporary activeTab access. @@ -61,26 +73,94 @@ export async function inspectTabHostAccess(chromeApi, tabId) { } } -export async function addTabHostAccessRequest(chromeApi, tabId) { +function callBooleanPermissionMethod(chromeApi, methodName, request) { + const method = chromeApi.permissions?.[methodName]; + if (typeof method !== 'function') return Promise.resolve(null); + + return new Promise((resolve, reject) => { + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + resolve(value === true); + }; + const fail = (error) => { + if (settled) return; + settled = true; + reject(error instanceof Error ? error : new Error(String(error || 'Permission request failed'))); + }; + const callback = (value) => { + const lastError = chromeApi.runtime?.lastError; + if (lastError) { + fail(new Error(lastError.message || String(lastError))); + return; + } + finish(value); + }; + + try { + const result = method.call(chromeApi.permissions, request, callback); + if (result && typeof result.then === 'function') { + result.then(finish, fail); + } else if (typeof result === 'boolean') { + finish(result); + } + // Callback-based Chromium/Firefox APIs return undefined and settle + // through callback. Promise-based implementations settle above. + } catch (error) { + fail(error); + } + }); +} + +export function requestOriginPermission(chromeApi, originPattern) { + if (typeof originPattern !== 'string' || !originPattern) { + return Promise.resolve(null); + } + return callBooleanPermissionMethod( + chromeApi, + 'request', + { origins: [originPattern] } + ).catch(() => false); +} + +export function isHostAccessError(error) { + const message = typeof error?.message === 'string' ? error.message : String(error || ''); + return /host permission|permission to access (?:this|the|respective) host|cannot access contents of/i.test(message); +} + +function hostAccessRequestDetails(tabId, originPattern) { + const request = { tabId }; + if (typeof originPattern === 'string' && originPattern) { + request.pattern = originPattern; + } + return request; +} + +export async function addTabHostAccessRequest(chromeApi, tabId, originPattern = null) { if (typeof chromeApi.permissions?.addHostAccessRequest !== 'function') { return false; } try { - await chromeApi.permissions.addHostAccessRequest({ tabId }); + await chromeApi.permissions.addHostAccessRequest( + hostAccessRequestDetails(tabId, originPattern) + ); return true; } catch (_e) { return false; } } -export async function removeTabHostAccessRequest(chromeApi, tabId) { +export async function removeTabHostAccessRequest(chromeApi, tabId, originPattern = null) { if (typeof chromeApi.permissions?.removeHostAccessRequest !== 'function') { return false; } try { - await chromeApi.permissions.removeHostAccessRequest({ tabId }); + await chromeApi.permissions.removeHostAccessRequest( + hostAccessRequestDetails(tabId, originPattern) + ); return true; } catch (_e) { return false; diff --git a/extension/popup.js b/extension/popup.js index de7fae0..22abc97 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -3,6 +3,7 @@ import { BLACKLIST_DOMAINS } from './shared/blacklist.js'; import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js'; import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js'; import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js'; +import { requestOriginPermission } from './host-access.js'; const elements = { @@ -591,16 +592,6 @@ function handleTargetTabResponse(response) { return false; } -async function requestSiteAccess(originPattern) { - if (!originPattern || typeof chrome.permissions?.request !== 'function') return null; - try { - return await chrome.permissions.request({ origins: [originPattern] }); - } catch (error) { - console.warn('[Popup] Host permission request failed:', error.message); - return false; - } -} - function selectTargetTab(tabId, tabTitle) { return new Promise(resolve => { chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle }, response => { @@ -1847,10 +1838,21 @@ if (elements.siteAccessRetry) { showToast(getMessage('ERR_SELECT_VIDEO'), 'warning'); return; } + const requestedOriginPattern = siteAccessOriginPattern; elements.siteAccessRetry.disabled = true; - await requestSiteAccess(siteAccessOriginPattern); - await selectTargetTab(tabId, tabTitle); - elements.siteAccessRetry.disabled = false; + elements.targetTab.disabled = true; + try { + await requestOriginPermission(chrome, requestedOriginPattern); + const selectedTabId = parseInt(elements.targetTab.value); + // permissions.onAdded may already have completed activation, or a + // newer selection may have replaced this request while it was open. + if (!siteAccessBlocked || selectedTabId !== tabId + || siteAccessOriginPattern !== requestedOriginPattern) return; + await selectTargetTab(tabId, tabTitle); + } finally { + elements.siteAccessRetry.disabled = false; + elements.targetTab.disabled = false; + } }); } @@ -2251,6 +2253,12 @@ chrome.runtime.onMessage.addListener((msg) => { } else if (msg.type === 'TARGET_TAB_READY') { hideSiteAccessNotice(); populateTabs(null, msg.tabId); + } else if (msg.type === 'TARGET_TAB_ACCESS_REQUIRED') { + showSiteAccessNotice(msg.host, msg.originPattern); + populateTabs(null, msg.tabId); + } else if (msg.type === 'TARGET_TAB_CLEARED') { + hideSiteAccessNotice(); + populateTabs(); } else if (msg.type === 'PING_UPDATE') { updatePingDisplay(msg.ping); } else if (msg.type === 'HISTORY_UPDATE') { diff --git a/scripts/test-host-access.mjs b/scripts/test-host-access.mjs index 42efae4..8866abd 100644 --- a/scripts/test-host-access.mjs +++ b/scripts/test-host-access.mjs @@ -7,8 +7,10 @@ import { addTabHostAccessRequest, describeTabUrl, inspectTabHostAccess, + isHostAccessError, normalizeTabId, - removeTabHostAccessRequest + removeTabHostAccessRequest, + requestOriginPermission } from '../extension/host-access.js'; assert.equal(HOST_ACCESS_REQUIRED_STATUS, 'host_permission_required'); @@ -17,6 +19,11 @@ assert.equal(normalizeTabId(undefined), null); assert.equal(normalizeTabId(''), null); assert.equal(normalizeTabId(0), null); assert.equal(normalizeTabId('42'), 42); +assert.equal(normalizeTabId(true), null); +assert.equal(normalizeTabId([42]), null); +assert.equal(normalizeTabId('42.5'), null); +assert.equal(normalizeTabId(' 42 '), 42); +assert.equal(normalizeTabId(Number.MAX_SAFE_INTEGER + 1), null); assert.deepEqual(describeTabUrl('https://emby.example:8443/web/index.html'), { url: 'https://emby.example:8443/web/index.html', host: 'emby.example:8443', @@ -50,30 +57,41 @@ assert.deepEqual(containsRequest, { origins: ['https://video.example/*'] }); let requestedTabId = null; const requestChrome = { permissions: { - addHostAccessRequest: async ({ tabId }) => { requestedTabId = tabId; } + addHostAccessRequest: async request => { requestedTabId = request; } } }; -assert.equal(await addTabHostAccessRequest(requestChrome, 42), true); -assert.equal(requestedTabId, 42); +assert.equal(await addTabHostAccessRequest(requestChrome, 42, 'https://video.example/*'), true); +assert.deepEqual(requestedTabId, { tabId: 42, pattern: 'https://video.example/*' }); assert.equal(await addTabHostAccessRequest({ permissions: {} }, 42), false); let removedTabId = null; const removeRequestChrome = { permissions: { - removeHostAccessRequest: async ({ tabId }) => { removedTabId = tabId; } + removeHostAccessRequest: async request => { removedTabId = request; } } }; -assert.equal(await removeTabHostAccessRequest(removeRequestChrome, 42), true); -assert.equal(removedTabId, 42); +assert.equal(await removeTabHostAccessRequest(removeRequestChrome, 42, 'https://video.example/*'), true); +assert.deepEqual(removedTabId, { tabId: 42, pattern: 'https://video.example/*' }); assert.equal(await removeTabHostAccessRequest({ permissions: {} }, 42), false); +assert.equal(isHostAccessError(new Error('Missing host permission for the tab')), true); +assert.equal(isHostAccessError(new Error('No tab with id: 42')), false); +const callbackPermissionChrome = { + runtime: {}, + permissions: { + request: (_request, callback) => { callback(true); } + } +}; +assert.equal(await requestOriginPermission(callbackPermissionChrome, 'https://video.example/*'), true); +assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.example/*'), null); + const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8'); const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8'); const popupHtml = fs.readFileSync(path.join(cwd(), 'extension', 'popup.html'), 'utf8'); assert.match(background, /await activateTargetTab\(message\.tabId, message\.tabTitle\)/, 'SET_TARGET_TAB must await successful activation before acknowledging it'); -assert.match(background, /addTabHostAccessRequest\(chrome, tabId\)/, +assert.match(background, /addTabHostAccessRequest\(chrome, tabId, access\.originPattern\)/, 'failed injection must register Chrome host-access request'); assert.match(background, /retryPendingTarget\(\)/, 'pending target must resume after the user grants access'); @@ -87,11 +105,11 @@ assert.ok( activateTargetBody.indexOf('await injectContentScript') < activateTargetBody.indexOf('currentTabId = selectedTabId'), 'a tab must not become current until its content script injection succeeds' ); -assert.match(background, /removeTabHostAccessRequest\(chrome, pendingTabId\)/, +assert.match(background, /removeTabHostAccessRequest\([\s\S]*pendingTabId/, 'clearing a pending target must also clear Chrome toolbar access requests'); assert.match(popup, /response\?\.status === 'host_permission_required'/, 'popup must render the structured host-access failure'); -assert.match(popup, /chrome\.permissions\.request\(\{ origins: \[originPattern\] \}\)/, +assert.match(popup, /requestOriginPermission\(chrome, requestedOriginPattern\)/, 'retry button must request withheld host access directly'); assert.match(popupHtml, /id="siteAccessNotice"/, 'popup must contain a persistent site-access notice'); diff --git a/scripts/test-site-access-help.mjs b/scripts/test-site-access-help.mjs deleted file mode 100644 index e9c8424..0000000 --- a/scripts/test-site-access-help.mjs +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env node - -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const read = relativePath => fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); - -const template = read('website/template.html'); -const foundationCss = read('website/styles/foundation.css'); -const supportCss = read('website/styles/support.css'); -const helpPage = read('website/site-access-help.html'); -const build = read('website/build.cjs'); -const localesDir = path.join(repoRoot, 'website', 'locales'); - -assert.equal((template.match(/class="site-access-banner"/g) || []).length, 1, - 'localized landing template must contain exactly one support banner'); -assert.match(template, /href="\/site-access-help\.html"/, - 'every compiled language must link to the English root help page'); -assert.match(template, /\{\{SUPPORT_BANNER_TEXT\}\}/); -assert.match(template, /\{\{SUPPORT_BANNER_CTA\}\}/); -assert.match(foundationCss, /\.site-access-banner\s*\{/, - 'landing bundle must style the support banner without deferred CSS'); - -for (const file of fs.readdirSync(localesDir).filter(file => file.endsWith('.json'))) { - const locale = JSON.parse(fs.readFileSync(path.join(localesDir, file), 'utf8')); - assert.ok(locale.SUPPORT_BANNER_TEXT?.trim(), `${file} is missing SUPPORT_BANNER_TEXT`); - assert.ok(locale.SUPPORT_BANNER_CTA?.trim(), `${file} is missing SUPPORT_BANNER_CTA`); -} - -assert.match(helpPage, / Date: Wed, 15 Jul 2026 10:47:24 +0000 Subject: [PATCH 04/17] chore(release): update versions to v2.6.2 [skip ci] --- README.md | 4 ++-- extension/manifest.base.json | 2 +- package.json | 2 +- shared/constants.js | 2 +- website/template.html | 2 +- website/version.json | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7887789..99b1e19 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

Release Status - GitHub release + GitHub release License Firefox Add-on Chrome Extension @@ -14,7 +14,7 @@

KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on Data Sovereignty and Performance.

-

New v2.6.1 Release! — See what's changed

+

New v2.6.2 Release! — See what's changed

### 🌟 Why KoalaSync? diff --git a/extension/manifest.base.json b/extension/manifest.base.json index 58bb963..d40acac 100644 --- a/extension/manifest.base.json +++ b/extension/manifest.base.json @@ -2,7 +2,7 @@ "manifest_version": 3, "default_locale": "en", "name": "KoalaSync", - "version": "2.6.1", + "version": "2.6.2", "description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.", "permissions": [ "storage", diff --git a/package.json b/package.json index 2f5b7d4..958cc54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "koalasync", - "version": "2.6.1", + "version": "2.6.2", "description": "KoalaSync Build Scripts", "private": true, "type": "module", diff --git a/shared/constants.js b/shared/constants.js index a23f5e0..fa3cb8d 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -7,7 +7,7 @@ */ export const PROTOCOL_VERSION = "1.0.0"; -export const APP_VERSION = "2.6.1"; +export const APP_VERSION = "2.6.2"; export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net'; export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net'; diff --git a/website/template.html b/website/template.html index 1bb5007..3cabda0 100644 --- a/website/template.html +++ b/website/template.html @@ -116,7 +116,7 @@ "priceCurrency": "EUR" }, "description": "{{SCHEMA_APP_DESC}}", - "softwareVersion": "2.6.1", + "softwareVersion": "2.6.2", "license": "https://opensource.org/licenses/MIT", "sameAs": "https://github.com/Shik3i/KoalaSync", "image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp", diff --git a/website/version.json b/website/version.json index 33a1c27..d37220a 100644 --- a/website/version.json +++ b/website/version.json @@ -1,4 +1,4 @@ { - "version": "2.6.1", - "date": "2026-07-15T03:57:56Z" + "version": "2.6.2", + "date": "2026-07-15T10:47:23Z" } From fd48ce8481ea49cfb62307f57ab042c0e39ee7b5 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:05:24 +0200 Subject: [PATCH 05/17] Fix site access banner overlay and immediate guidance --- website/site-access-help.html | 21 ++++++++++++--------- website/styles/foundation.css | 17 ++++++++++++++--- website/template.html | 3 +-- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/website/site-access-help.html b/website/site-access-help.html index a2c0650..c153785 100644 --- a/website/site-access-help.html +++ b/website/site-access-help.html @@ -4,7 +4,7 @@ KoalaSync Cannot Access Your Video Site? Fix Website Access - + @@ -69,32 +69,35 @@
- Start here -

Use the built-in one-click fix

+ Immediate workaround — no extension update required +

Allow KoalaSync in your browser now

    -
  1. Open the video website and then open the KoalaSync popup.
  2. -
  3. Select the video tab. If access is blocked, KoalaSync shows an access notice instead of silently pretending the tab is connected.
  4. -
  5. Select Allow access and approve the browser prompt. KoalaSync retries the tab automatically.
  6. -
  7. If the prompt does not appear, use the manual browser steps below and reload the video page once.
  8. +
  9. Keep the affected video website open.
  10. +
  11. Open the browser’s extensions menu next to the address bar.
  12. +
  13. If KoalaSync appears under Access requested, select KoalaSync to grant access.
  14. +
  15. If that section is missing, open KoalaSync’s menu or extension details and set website access to On this site or On all sites.
  16. +
  17. Reload the video page once, open KoalaSync, and select the video tab again.
+

This manual workaround works even while KoalaSync 2.6.2 is still waiting for approval or rollout in your browser’s extension store.

This is not a new KoalaSync permission. -

Modern browsers let users, profiles, and administrators withhold extension access per website. Chrome’s newer extension menu makes that existing control much more visible. KoalaSync now detects the blocked state and asks for access only after you select a video tab.

+

Modern browsers let users, profiles, and administrators withhold extension access per website. Chrome’s newer extension menu makes that existing control much more visible. KoalaSync 2.6.2 detects the blocked state and adds a guided recovery action after the store update reaches you; the manual steps above work immediately on older KoalaSync releases.

- Chrome, Edge, Brave, Opera + Chrome, Edge, Brave, Opera, Vivaldi

Allow KoalaSync on the current site

  1. Keep the affected video tab open.
  2. Open the browser’s extensions menu near the address bar.
  3. Under Access requested, select KoalaSync and allow access.
  4. +
  5. In browsers with the older menu, right-click KoalaSync and choose This can read and change site data, then allow the current site.
  6. If needed, open KoalaSync’s extension details and change site access to On this site or On all sites.
  7. Reload the video tab and select it again in KoalaSync.
diff --git a/website/styles/foundation.css b/website/styles/foundation.css index a8eeb15..6f6faae 100644 --- a/website/styles/foundation.css +++ b/website/styles/foundation.css @@ -93,12 +93,19 @@ } .site-access-banner { - position: relative; + position: fixed; + top: 89px; + left: 0; + width: 100%; z-index: 900; - margin-top: 96px; border-block: 1px solid color-mix(in oklch, var(--accent) 42%, transparent); background: color-mix(in oklch, var(--accent) 16%, var(--bg)); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.16); + transition: top 0.3s ease; +} + +nav.nav-scrolled + .site-access-banner { + top: 59px; } .site-access-banner-inner { @@ -143,7 +150,11 @@ @media (max-width: 768px) { .site-access-banner { - margin-top: 76px; + top: 73px; + } + + nav.nav-scrolled + .site-access-banner { + top: 59px; } .site-access-banner-inner { diff --git a/website/template.html b/website/template.html index 3cabda0..aaf39d5 100644 --- a/website/template.html +++ b/website/template.html @@ -210,7 +210,7 @@ - +
@@ -366,7 +366,6 @@ {{SUPPORT_BANNER_TEXT}} {{SUPPORT_BANNER_CTA}} -
From 1989eb639b02f40c4e727b9d277c26b25ed3cd62 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:07:48 +0200 Subject: [PATCH 06/17] Clarify sudden Chrome site access rollout --- website/site-access-help.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/site-access-help.html b/website/site-access-help.html index c153785..e799155 100644 --- a/website/site-access-help.html +++ b/website/site-access-help.html @@ -118,10 +118,10 @@
- Why access is needed -

KoalaSync must reach the player you selected

-

KoalaSync injects a local content script into the selected tab so it can find the HTML5 player, read its playback position, and apply play, pause, and seek actions. The video and audio stream never leave your browser. Only synchronization state is sent through the relay.

-

Access is requested for the website origin shown in the popup. Browser-internal pages such as chrome://, edge://, and about: cannot be controlled. Local files may also require the separate Allow access to file URLs switch in the extension details.

+ Why this suddenly happened +

Your browser started withholding website access

+

Chrome is gradually rolling out new per-site controls for extensions. The rollout can reach individual browsers or profiles without a KoalaSync update, so KoalaSync may appear to stop working from one day to the next while another browser still works normally.

+

The room connection can remain online, but the browser blocks KoalaSync from reaching the video player. Granting KoalaSync access to the affected video website and reloading that tab restores playback synchronization.

From 285c04e00de180ffdad53ccf67f67d02a82f1bde Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:09:50 +0200 Subject: [PATCH 07/17] Align browser help cards --- website/styles/support.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/styles/support.css b/website/styles/support.css index bb698e0..2d4fb7c 100644 --- a/website/styles/support.css +++ b/website/styles/support.css @@ -78,7 +78,7 @@ padding: clamp(1.35rem, 4vw, 2.25rem); } -.support-card + .support-card, +.support-content > .support-card + .support-card, .support-grid + .support-card, .support-card + .support-grid, .support-callout + .support-card { From 03121477a8ad6551b4d2872e08a8cce1db047ebc Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:20:00 +0200 Subject: [PATCH 08/17] Add dismissible localized access banner --- website/app.js | 8 ++++++++ website/locales/de.json | 1 + website/locales/en.json | 1 + website/locales/es.json | 1 + website/locales/fr.json | 1 + website/locales/it.json | 1 + website/locales/ja.json | 1 + website/locales/ko.json | 1 + website/locales/nl.json | 1 + website/locales/pl.json | 1 + website/locales/pt-BR.json | 1 + website/locales/pt.json | 1 + website/locales/ru.json | 1 + website/locales/tr.json | 1 + website/locales/uk.json | 1 + website/locales/zh.json | 1 + website/site-access-help.html | 3 ++- website/styles/foundation.css | 32 +++++++++++++++++++++++++++++++- website/template.html | 6 ++++++ 19 files changed, 62 insertions(+), 2 deletions(-) diff --git a/website/app.js b/website/app.js index 3a5fa7e..31fd2a4 100644 --- a/website/app.js +++ b/website/app.js @@ -1,6 +1,14 @@ // KoalaSync Landing Page Logic document.addEventListener('DOMContentLoaded', () => { + const siteAccessBanner = document.querySelector('.site-access-banner'); + const siteAccessBannerDismiss = document.querySelector('.site-access-banner-dismiss'); + if (siteAccessBanner && siteAccessBannerDismiss) { + siteAccessBannerDismiss.addEventListener('click', () => { + siteAccessBanner.hidden = true; + }); + } + // Mockup Video Title Randomization on Load const SERIES_NAMES = [ 'Stranger Things', diff --git a/website/locales/de.json b/website/locales/de.json index ee5eb3a..be323ed 100644 --- a/website/locales/de.json +++ b/website/locales/de.json @@ -16,6 +16,7 @@ "NAV_HOW_IT_WORKS": "So funktioniert's", "SUPPORT_BANNER_TEXT": "KoalaSync kann nicht auf deine Videoseite zugreifen?", "SUPPORT_BANNER_CTA": "Websitezugriff reparieren", + "SUPPORT_BANNER_DISMISS": "Hinweis schließen", "HERO_TITLE": "Mach jedes Video zur Watch Party.", "HERO_SUBTITLE": "Dein Kino-Abend auf Distanz, ohne Lags. Kein Account, kein Tracking, keine Werbung. Einfach Link teilen und gemeinsam Play drücken.", "HERO_MASCOT_ALT": "Ein niedlicher Koala steht da und schaut nach unten auf die Download-Buttons", diff --git a/website/locales/en.json b/website/locales/en.json index 1028902..818ac01 100644 --- a/website/locales/en.json +++ b/website/locales/en.json @@ -16,6 +16,7 @@ "NAV_HOW_IT_WORKS": "How it works", "SUPPORT_BANNER_TEXT": "KoalaSync cannot access your video site?", "SUPPORT_BANNER_CTA": "Fix website access", + "SUPPORT_BANNER_DISMISS": "Dismiss notification", "HERO_TITLE": "Turn any video into a watch party.", "HERO_SUBTITLE": "Your remote movie night without lags. No account, no tracking, no ads. Just share a link and press play together.", "HERO_MASCOT_ALT": "A cute koala standing and looking down at the download buttons", diff --git a/website/locales/es.json b/website/locales/es.json index 13f70ba..607ec0b 100644 --- a/website/locales/es.json +++ b/website/locales/es.json @@ -16,6 +16,7 @@ "NAV_HOW_IT_WORKS": "Cómo funciona", "SUPPORT_BANNER_TEXT": "¿KoalaSync no puede acceder al sitio del vídeo?", "SUPPORT_BANNER_CTA": "Reparar acceso al sitio", + "SUPPORT_BANNER_DISMISS": "Cerrar aviso", "HERO_TITLE": "Convierte cualquier vídeo en una watch party.", "HERO_SUBTITLE": "Tu noche de cine a distancia, sin retrasos. Sin cuenta, sin rastreo, sin anuncios. Comparte un enlace y dale al play juntos.", "HERO_MASCOT_ALT": "Un lindo koala de pie mirando hacia abajo a los botones de descarga", diff --git a/website/locales/fr.json b/website/locales/fr.json index 5a6aa01..db2923a 100644 --- a/website/locales/fr.json +++ b/website/locales/fr.json @@ -16,6 +16,7 @@ "NAV_HOW_IT_WORKS": "Comment ça marche", "SUPPORT_BANNER_TEXT": "KoalaSync n’accède pas à votre site vidéo ?", "SUPPORT_BANNER_CTA": "Réparer l’accès au site", + "SUPPORT_BANNER_DISMISS": "Fermer l’avertissement", "HERO_TITLE": "Transformez n'importe quelle vidéo en watch party.", "HERO_SUBTITLE": "Votre soirée ciné à distance, sans décalage. Pas de compte, pas de tracking, pas de pub. Partagez un lien et lancez la lecture ensemble.", "HERO_MASCOT_ALT": "Un koala mignon debout et regardant vers le bas les boutons de téléchargement", diff --git a/website/locales/it.json b/website/locales/it.json index 87424f2..ed08769 100644 --- a/website/locales/it.json +++ b/website/locales/it.json @@ -16,6 +16,7 @@ "NAV_HOW_IT_WORKS": "Come Funziona", "SUPPORT_BANNER_TEXT": "KoalaSync non riesce ad accedere al sito video?", "SUPPORT_BANNER_CTA": "Ripristina l’accesso al sito", + "SUPPORT_BANNER_DISMISS": "Chiudi avviso", "HERO_TITLE": "Trasforma qualsiasi video in una watch party.", "HERO_SUBTITLE": "La tua serata film a distanza, senza lag. Niente account, niente tracciamento, niente pubblicità. Condividi un link e premete play insieme.", "HERO_MASCOT_ALT": "Un simpatico koala che guarda i pulsanti di download", diff --git a/website/locales/ja.json b/website/locales/ja.json index 19e7bea..9df4a0e 100644 --- a/website/locales/ja.json +++ b/website/locales/ja.json @@ -16,6 +16,7 @@ "NAV_HOW_IT_WORKS": "仕組み", "SUPPORT_BANNER_TEXT": "KoalaSync が動画サイトにアクセスできませんか?", "SUPPORT_BANNER_CTA": "サイトアクセスを修復", + "SUPPORT_BANNER_DISMISS": "通知を閉じる", "HERO_TITLE": "どんな動画もウォッチパーティーに。", "HERO_SUBTITLE": "離れていても一緒に映画ナイト。アカウント不要、トラッキングなし、広告なし。リンクを共有して、一緒に再生するだけ。", "HERO_MASCOT_ALT": "ダウンロードボタンを見下ろしているかわいいコアラ", diff --git a/website/locales/ko.json b/website/locales/ko.json index 6434372..06bca60 100644 --- a/website/locales/ko.json +++ b/website/locales/ko.json @@ -16,6 +16,7 @@ "NAV_HOW_IT_WORKS": "작동 방식", "SUPPORT_BANNER_TEXT": "KoalaSync가 동영상 사이트에 액세스할 수 없나요?", "SUPPORT_BANNER_CTA": "사이트 액세스 복구", + "SUPPORT_BANNER_DISMISS": "알림 닫기", "HERO_TITLE": "어떤 영상이든 워치 파티로.", "HERO_SUBTITLE": "지연 없는 원격 영화의 밤. 계정 없음, 추적 없음, 광고 없음. 링크만 공유하고 함께 재생하세요.", "HERO_MASCOT_ALT": "다운로드 버튼을 내려다보고 서 있는 귀여운 코알라", diff --git a/website/locales/nl.json b/website/locales/nl.json index 41a8a6d..ff21ac5 100644 --- a/website/locales/nl.json +++ b/website/locales/nl.json @@ -16,6 +16,7 @@ "NAV_HOW_IT_WORKS": "Hoe het werkt", "SUPPORT_BANNER_TEXT": "Heeft KoalaSync geen toegang tot je videosite?", "SUPPORT_BANNER_CTA": "Websitetoegang herstellen", + "SUPPORT_BANNER_DISMISS": "Melding sluiten", "HERO_TITLE": "Maak van elke video een watch party.", "HERO_SUBTITLE": "Jouw filmavond op afstand, zonder vertraging. Geen account, geen tracking, geen advertenties. Deel een link en druk samen op play.", "HERO_MASCOT_ALT": "Een schattige koala die staat en naar beneden kijkt naar de downloadknoppen", diff --git a/website/locales/pl.json b/website/locales/pl.json index 2d2ab69..06fef2b 100644 --- a/website/locales/pl.json +++ b/website/locales/pl.json @@ -16,6 +16,7 @@ "NAV_HOW_IT_WORKS": "Jak to działa", "SUPPORT_BANNER_TEXT": "KoalaSync nie ma dostępu do strony z filmem?", "SUPPORT_BANNER_CTA": "Napraw dostęp do strony", + "SUPPORT_BANNER_DISMISS": "Zamknij powiadomienie", "HERO_TITLE": "Zamień dowolne wideo w watch party.", "HERO_SUBTITLE": "Twój zdalny wieczór filmowy bez opóźnień. Bez konta, bez śledzenia, bez reklam. Udostępnij link i naciśnijcie play razem.", "HERO_MASCOT_ALT": "Uroczy koala stojący i patrzący w dół na przyciski pobierania", diff --git a/website/locales/pt-BR.json b/website/locales/pt-BR.json index 1906f5d..bd4b41d 100644 --- a/website/locales/pt-BR.json +++ b/website/locales/pt-BR.json @@ -16,6 +16,7 @@ "NAV_HOW_IT_WORKS": "Como Funciona", "SUPPORT_BANNER_TEXT": "O KoalaSync não consegue acessar o site do vídeo?", "SUPPORT_BANNER_CTA": "Corrigir acesso ao site", + "SUPPORT_BANNER_DISMISS": "Fechar aviso", "HERO_TITLE": "Transforme qualquer vídeo em uma watch party.", "HERO_SUBTITLE": "Sua sessão de cinema à distância, sem atrasos. Sem conta, sem rastreamento, sem anúncios. Compartilhe um link e deem play juntos.", "HERO_MASCOT_ALT": "Um coala fofo olhando para os botões de download", diff --git a/website/locales/pt.json b/website/locales/pt.json index 7567526..773cab9 100644 --- a/website/locales/pt.json +++ b/website/locales/pt.json @@ -16,6 +16,7 @@ "NAV_HOW_IT_WORKS": "Como Funciona", "SUPPORT_BANNER_TEXT": "O KoalaSync não consegue aceder ao site do vídeo?", "SUPPORT_BANNER_CTA": "Corrigir acesso ao site", + "SUPPORT_BANNER_DISMISS": "Fechar aviso", "HERO_TITLE": "Transforma qualquer vídeo numa watch party.", "HERO_SUBTITLE": "A tua noite de cinema à distância, sem atrasos. Sem conta, sem rastreio, sem anúncios. Partilha um link e carreguem no play juntos.", "HERO_MASCOT_ALT": "Um coala fofo a olhar para os botões de download", diff --git a/website/locales/ru.json b/website/locales/ru.json index a8eb060..50657a1 100644 --- a/website/locales/ru.json +++ b/website/locales/ru.json @@ -16,6 +16,7 @@ "NAV_HOW_IT_WORKS": "Как это работает", "SUPPORT_BANNER_TEXT": "KoalaSync не может получить доступ к сайту с видео?", "SUPPORT_BANNER_CTA": "Исправить доступ к сайту", + "SUPPORT_BANNER_DISMISS": "Закрыть уведомление", "HERO_TITLE": "Преврати любое видео в watch party.", "HERO_SUBTITLE": "Ваш киновечер на расстоянии, без задержек. Без аккаунта, без слежки, без рекламы. Просто поделитесь ссылкой и нажмите play вместе.", "HERO_MASCOT_ALT": "Милый коала стоит и смотрит вниз на кнопки загрузки", diff --git a/website/locales/tr.json b/website/locales/tr.json index 55506c5..7e515ec 100644 --- a/website/locales/tr.json +++ b/website/locales/tr.json @@ -16,6 +16,7 @@ "NAV_HOW_IT_WORKS": "Nasıl Çalışır", "SUPPORT_BANNER_TEXT": "KoalaSync video sitesine erişemiyor mu?", "SUPPORT_BANNER_CTA": "Site erişimini düzelt", + "SUPPORT_BANNER_DISMISS": "Bildirimi kapat", "HERO_TITLE": "Herhangi bir videoyu watch party'ye dönüştür.", "HERO_SUBTITLE": "Gecikmesiz uzaktan film gecen. Hesap yok, takip yok, reklam yok. Sadece bir bağlantı paylaş ve birlikte oynatın.", "HERO_MASCOT_ALT": "İndirme düğmelerine bakan sevimli bir koala", diff --git a/website/locales/uk.json b/website/locales/uk.json index 4912b53..f11f04a 100644 --- a/website/locales/uk.json +++ b/website/locales/uk.json @@ -16,6 +16,7 @@ "NAV_HOW_IT_WORKS": "Як це працює", "SUPPORT_BANNER_TEXT": "KoalaSync не має доступу до сайту з відео?", "SUPPORT_BANNER_CTA": "Виправити доступ до сайту", + "SUPPORT_BANNER_DISMISS": "Закрити сповіщення", "HERO_TITLE": "Перетвори будь-яке відео на watch party.", "HERO_SUBTITLE": "Ваш кіновечір на відстані, без затримок. Без акаунта, без стеження, без реклами. Просто поділіться посиланням і натисніть play разом.", "HERO_MASCOT_ALT": "Мила коала стоїть і дивиться вниз на кнопки завантаження", diff --git a/website/locales/zh.json b/website/locales/zh.json index cfe3e44..6a06ec8 100644 --- a/website/locales/zh.json +++ b/website/locales/zh.json @@ -16,6 +16,7 @@ "NAV_HOW_IT_WORKS": "它是如何运作的", "SUPPORT_BANNER_TEXT": "KoalaSync 无法访问视频网站?", "SUPPORT_BANNER_CTA": "修复网站访问权限", + "SUPPORT_BANNER_DISMISS": "关闭提示", "HERO_TITLE": "把任意视频变成一起看派对。", "HERO_SUBTITLE": "远程电影之夜,毫无延迟。无需账号、无跟踪、无广告。分享链接,一起播放。", "HERO_MASCOT_ALT": "一只可爱的考拉站着,低头看着下载按钮", diff --git a/website/site-access-help.html b/website/site-access-help.html index e799155..0518e90 100644 --- a/website/site-access-help.html +++ b/website/site-access-help.html @@ -101,12 +101,13 @@
  • If needed, open KoalaSync’s extension details and change site access to On this site or On all sites.
  • Reload the video tab and select it again in KoalaSync.
  • -

    Google’s official site-access instructions

    +

    Google’s official explanation of the new access menu

    Firefox

    Check extension permissions

    +

    Firefox does not use Chrome’s new access menu. Follow these steps only if Firefox reports that KoalaSync lacks website access.

    1. Open Add-ons and themes, then select Extensions.
    2. Open KoalaSync and select Permissions or Permissions and data.
    3. diff --git a/website/styles/foundation.css b/website/styles/foundation.css index 6f6faae..78638d4 100644 --- a/website/styles/foundation.css +++ b/website/styles/foundation.css @@ -109,6 +109,7 @@ nav.nav-scrolled + .site-access-banner { } .site-access-banner-inner { + position: relative; min-height: 50px; display: flex; align-items: center; @@ -118,6 +119,8 @@ nav.nav-scrolled + .site-access-banner { font-size: 0.9rem; font-weight: 650; text-align: center; + padding-right: 4rem; + padding-left: 4rem; } .site-access-banner-icon { @@ -148,6 +151,29 @@ nav.nav-scrolled + .site-access-banner { color: var(--text); } +.site-access-banner-dismiss { + position: absolute; + right: 2rem; + top: 50%; + transform: translateY(-50%); + display: inline-grid; + place-items: center; + width: 2rem; + height: 2rem; + padding: 0; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.site-access-banner-dismiss:hover, +.site-access-banner-dismiss:focus-visible { + background: color-mix(in oklch, var(--accent) 18%, transparent); + color: var(--text); +} + @media (max-width: 768px) { .site-access-banner { top: 73px; @@ -159,7 +185,7 @@ nav.nav-scrolled + .site-access-banner { .site-access-banner-inner { min-height: 64px; - padding-block: 0.65rem; + padding: 0.65rem 3rem 0.65rem 1rem; gap: 0.5rem; flex-wrap: wrap; font-size: 0.8rem; @@ -170,6 +196,10 @@ nav.nav-scrolled + .site-access-banner { height: 1.3rem; font-size: 0.75rem; } + + .site-access-banner-dismiss { + right: 0.75rem; + } } * { diff --git a/website/template.html b/website/template.html index aaf39d5..6ff6762 100644 --- a/website/template.html +++ b/website/template.html @@ -367,6 +367,12 @@ {{SUPPORT_BANNER_CTA}} + From abcb10a1c34708356716ca38bf39fc3bb4541f45 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:23:54 +0200 Subject: [PATCH 09/17] Scope access help to Chromium rollout --- website/site-access-help.html | 45 ++++++++++++----------------------- 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/website/site-access-help.html b/website/site-access-help.html index 0518e90..3df0c5c 100644 --- a/website/site-access-help.html +++ b/website/site-access-help.html @@ -4,7 +4,7 @@ KoalaSync Cannot Access Your Video Site? Fix Website Access - + @@ -85,38 +85,23 @@
      This is not a new KoalaSync permission. -

      Modern browsers let users, profiles, and administrators withhold extension access per website. Chrome’s newer extension menu makes that existing control much more visible. KoalaSync 2.6.2 detects the blocked state and adds a guided recovery action after the store update reaches you; the manual steps above work immediately on older KoalaSync releases.

      +

      Chrome lets users, profiles, and administrators withhold extension access per website. Its newer extension menu makes that existing control much more visible. KoalaSync 2.6.2 detects the blocked state and adds a guided recovery action after the store update reaches you; the manual steps above work immediately on older KoalaSync releases.

      -
      -
      - Chrome, Edge, Brave, Opera, Vivaldi -

      Allow KoalaSync on the current site

      -
        -
      1. Keep the affected video tab open.
      2. -
      3. Open the browser’s extensions menu near the address bar.
      4. -
      5. Under Access requested, select KoalaSync and allow access.
      6. -
      7. In browsers with the older menu, right-click KoalaSync and choose This can read and change site data, then allow the current site.
      8. -
      9. If needed, open KoalaSync’s extension details and change site access to On this site or On all sites.
      10. -
      11. Reload the video tab and select it again in KoalaSync.
      12. -
      -

      Google’s official explanation of the new access menu

      -
      - -
      - Firefox -

      Check extension permissions

      -

      Firefox does not use Chrome’s new access menu. Follow these steps only if Firefox reports that KoalaSync lacks website access.

      -
        -
      1. Open Add-ons and themes, then select Extensions.
      2. -
      3. Open KoalaSync and select Permissions or Permissions and data.
      4. -
      5. Allow the requested website access.
      6. -
      7. Return to the video site, reload it, and select the tab again.
      8. -
      -

      Mozilla’s official extension-permissions instructions

      -
      -
      +
      + Chrome, Edge, Brave, Opera, Vivaldi +

      Allow KoalaSync on the current site

      +
        +
      1. Keep the affected video tab open.
      2. +
      3. Open the browser’s extensions menu near the address bar.
      4. +
      5. Under Access requested, select KoalaSync and allow access.
      6. +
      7. In browsers with the older menu, right-click KoalaSync and choose This can read and change site data, then allow the current site.
      8. +
      9. If needed, open KoalaSync’s extension details and change site access to On this site or On all sites.
      10. +
      11. Reload the video tab and select it again in KoalaSync.
      12. +
      +

      Chrome’s official host-access-request reference

      +
      Why this suddenly happened From 61d0d64c7d2ed01dbc7bcafdd5d9c95f5dc552e4 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:26:36 +0200 Subject: [PATCH 10/17] Remove developer link from user access guide --- website/site-access-help.html | 47 +++++++++++++++++------------------ 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/website/site-access-help.html b/website/site-access-help.html index 3df0c5c..03b0b1e 100644 --- a/website/site-access-help.html +++ b/website/site-access-help.html @@ -63,51 +63,50 @@
      - Website access troubleshooting -

      Connected, but the video tab does nothing?

      -

      Your browser may be withholding KoalaSync’s access to that website. The room connection can still look healthy while KoalaSync cannot read or control the player.

      + KoalaSync cannot see your video +

      KoalaSync opens, but your video does nothing?

      +

      Chrome may have switched off KoalaSync for that video website. You can still look connected to a room, but KoalaSync cannot see or control the video until you allow access again.

      - Immediate workaround — no extension update required -

      Allow KoalaSync in your browser now

      + Fix it now — no update required +

      Turn KoalaSync back on for the video website

        -
      1. Keep the affected video website open.
      2. -
      3. Open the browser’s extensions menu next to the address bar.
      4. -
      5. If KoalaSync appears under Access requested, select KoalaSync to grant access.
      6. -
      7. If that section is missing, open KoalaSync’s menu or extension details and set website access to On this site or On all sites.
      8. -
      9. Reload the video page once, open KoalaSync, and select the video tab again.
      10. +
      11. Leave the video website open.
      12. +
      13. Look at the top-right of the browser, next to the long box that shows the website address. Click the Extensions button (usually a puzzle-piece icon). If you cannot see it, open the three-dot menu first and choose Extensions.
      14. +
      15. Look for Access requested. Click KoalaSync underneath it to allow access.
      16. +
      17. If you do not see Access requested, find KoalaSync in the same menu, open its three-dot menu, choose This can read and change site data, then choose On this site. You can also choose On all sites if you want KoalaSync to work automatically on every supported website.
      18. +
      19. Return to the video and click the browser’s reload button (the circular arrow).
      20. +
      21. Open KoalaSync and select the video tab again.
      -

      This manual workaround works even while KoalaSync 2.6.2 is still waiting for approval or rollout in your browser’s extension store.

      +

      You do not need to wait for a KoalaSync update. These steps work immediately.

      - This is not a new KoalaSync permission. -

      Chrome lets users, profiles, and administrators withhold extension access per website. Its newer extension menu makes that existing control much more visible. KoalaSync 2.6.2 detects the blocked state and adds a guided recovery action after the store update reaches you; the manual steps above work immediately on older KoalaSync releases.

      + KoalaSync did not add a new kind of permission. +

      Chrome changed how it handles an extension’s existing access to websites. This change can switch KoalaSync off for a website even though KoalaSync itself was not updated. Allowing KoalaSync on the video website turns it back on.

      Chrome, Edge, Brave, Opera, Vivaldi -

      Allow KoalaSync on the current site

      +

      Where to click

        -
      1. Keep the affected video tab open.
      2. -
      3. Open the browser’s extensions menu near the address bar.
      4. -
      5. Under Access requested, select KoalaSync and allow access.
      6. -
      7. In browsers with the older menu, right-click KoalaSync and choose This can read and change site data, then allow the current site.
      8. -
      9. If needed, open KoalaSync’s extension details and change site access to On this site or On all sites.
      10. -
      11. Reload the video tab and select it again in KoalaSync.
      12. +
      13. Keep the video tab open so the browser knows which website you want to allow.
      14. +
      15. Open the Extensions menu at the top-right of the browser.
      16. +
      17. Under Access requested, click KoalaSync.
      18. +
      19. If your browser shows the older menu, open KoalaSync’s three-dot menu and choose This can read and change site dataOn this site.
      20. +
      21. Reload the video page, open KoalaSync, and select that tab again.
      -

      Chrome’s official host-access-request reference

      Why this suddenly happened -

      Your browser started withholding website access

      -

      Chrome is gradually rolling out new per-site controls for extensions. The rollout can reach individual browsers or profiles without a KoalaSync update, so KoalaSync may appear to stop working from one day to the next while another browser still works normally.

      -

      The room connection can remain online, but the browser blocks KoalaSync from reaching the video player. Granting KoalaSync access to the affected video website and reloading that tab restores playback synchronization.

      +

      Chrome changed this without a KoalaSync update

      +

      Chrome is gradually giving people a new extensions menu. It can arrive on one computer today and on another computer later. That is why KoalaSync can suddenly stop working in one browser while it still works in another.

      +

      Your room can still show as connected because the room connection and access to the video are two separate things. KoalaSync cannot control the video until Chrome allows it on that website again.

      From fe13275b5d9590732b941da3f2eeae5b43929378 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:31:56 +0200 Subject: [PATCH 11/17] Harden host access recovery races --- extension/background.js | 560 ++++++++++++++++++++++++------- extension/host-access.js | 40 ++- extension/modules/tab-manager.js | 80 +---- extension/popup.js | 127 +++++-- scripts/test-host-access.mjs | 63 ++++ 5 files changed, 637 insertions(+), 233 deletions(-) diff --git a/extension/background.js b/extension/background.js index ce93f85..150a872 100644 --- a/extension/background.js +++ b/extension/background.js @@ -63,6 +63,7 @@ let currentRoom = null; let currentTabId = null; let currentTabTitle = null; // New: for Smart Matching let targetActivationGeneration = 0; +let activeTargetActivation = null; let logs = []; let history = []; // New: for Action History let storageInitialized = false; @@ -522,8 +523,27 @@ function markRoomPotentiallyIdle() { } } -function clearTargetTabForIdle() { +function invalidateTargetActivations() { targetActivationGeneration++; + activeTargetActivation = null; + return targetActivationGeneration; +} + +function isCurrentTargetIdentity(tabId, generation) { + return normalizeTabId(currentTabId) === normalizeTabId(tabId) + && targetActivationGeneration === generation; +} + +function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) { + if (expectedTabId !== null && normalizeTabId(currentTabId) !== normalizeTabId(expectedTabId)) { + return false; + } + if (expectedGeneration !== null && targetActivationGeneration !== expectedGeneration) { + return false; + } + + completeForceSyncBeforeTargetChange(null); + invalidateTargetActivations(); clearPendingTarget().catch(() => {}); currentTabId = null; currentTabTitle = null; @@ -538,6 +558,7 @@ function clearTargetTabForIdle() { lastContentHeartbeatAt }).catch(() => {}); updateBadgeStatus(); + return true; } async function leaveRoomAfterIdleGrace(reason) { @@ -546,6 +567,7 @@ async function leaveRoomAfterIdleGrace(reason) { reconnectFailed = false; reconnectAttempts = 0; chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }); + completeForceSyncBeforeTargetChange(null); emit(EVENTS.LEAVE_ROOM, { peerId }); forceDisconnect(); currentRoom = null; @@ -557,7 +579,7 @@ async function leaveRoomAfterIdleGrace(reason) { // Notify content.js/popup BEFORE currentTabId is cleared so they can reset // any stale guest-side HCM state (dialog/badge/desync) — H-2. broadcastControlMode(); - targetActivationGeneration++; + invalidateTargetActivations(); currentTabId = null; currentTabTitle = null; roomIdleSince = null; @@ -1455,6 +1477,16 @@ function executeForceSync() { addLog('Force Sync Executed', 'success'); } +function completeForceSyncBeforeTargetChange(nextTabId) { + if (!isForceSyncInitiator) return; + const selectedTabId = normalizeTabId(currentTabId); + const normalizedNextTabId = normalizeTabId(nextTabId); + if (selectedTabId !== null && selectedTabId === normalizedNextTabId) return; + + addLog('Finishing Force Sync before target change', 'info'); + executeForceSync(); +} + // --- Episode Auto-Sync Lobby Functions --- function persistEpisodeLobby() { if (storageInitialized) chrome.storage.session.set({ episodeLobby }); @@ -1602,13 +1634,14 @@ function updateLastAction(action, senderId, timestamp = Date.now()) { async function routeToContent(action, payload) { if (!currentTabId) return; - const tabId = parseInt(currentTabId); - if (isNaN(tabId)) return; + const tabId = normalizeTabId(currentTabId); + if (tabId === null) return; + const targetGeneration = targetActivationGeneration; const actionTimestamp = payload?.actionTimestamp || Date.now(); const commandSenderId = payload?.senderId || null; - _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, 0); + _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, 0, targetGeneration); } function getTabVideoState(tabId) { @@ -1623,12 +1656,21 @@ function getTabVideoState(tabId) { }); } -async function getReadyTabVideoState(tabId) { +async function getReadyTabVideoState(tabId, expectedGeneration = targetActivationGeneration) { let state = await getTabVideoState(tabId); if (!state || state.error) { - await injectContentScript(tabId); + const activation = await reactivateCurrentTarget(tabId, { expectedGeneration }); + if (activation?.status !== 'ok') { + return { error: 'Target tab changed before content script recovery completed' }; + } await new Promise(resolve => setTimeout(resolve, 250)); + if (!isCurrentTargetIdentity(tabId, activation.generation)) { + return { error: 'Target tab changed before video state could be read' }; + } state = await getTabVideoState(tabId); + if (!isCurrentTargetIdentity(tabId, activation.generation)) { + return { error: 'Target tab changed while video state was being read' }; + } } return state; } @@ -1845,18 +1887,80 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) { let pendingTargetMutation = Promise.resolve(); +const PENDING_TARGET_KEYS = [ + 'pendingTargetTabId', + 'pendingTargetTabTitle', + 'pendingTargetHost', + 'pendingTargetOriginPattern', + 'pendingTargetRequestId' +]; + +function createPendingTargetRequestId() { + return globalThis.crypto?.randomUUID?.() + || `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function emptyPendingTargetState() { + return { + pendingTargetTabId: null, + pendingTargetTabTitle: null, + pendingTargetHost: null, + pendingTargetOriginPattern: null, + pendingTargetRequestId: null + }; +} + function mutatePendingTarget(operation) { const result = pendingTargetMutation.catch(() => {}).then(operation); pendingTargetMutation = result.catch(() => {}); return result; } -async function rememberPendingTarget(tabId, tabTitle, error) { +async function readPendingTarget() { return mutatePendingTarget(async () => { - const previous = await chrome.storage.session.get([ - 'pendingTargetTabId', - 'pendingTargetOriginPattern' - ]); + const stored = await chrome.storage.session.get(PENDING_TARGET_KEYS); + const tabId = normalizeTabId(stored.pendingTargetTabId); + const originPattern = typeof stored.pendingTargetOriginPattern === 'string' + && stored.pendingTargetOriginPattern.length > 0 + ? stored.pendingTargetOriginPattern + : null; + + if (tabId === null || originPattern === null) { + if (Object.values(stored).some(value => value !== null && value !== undefined)) { + if (tabId !== null) { + await removeTabHostAccessRequest(chrome, tabId, originPattern); + } + await chrome.storage.session.set(emptyPendingTargetState()); + } + return null; + } + + const requestId = typeof stored.pendingTargetRequestId === 'string' + && stored.pendingTargetRequestId.length > 0 + ? stored.pendingTargetRequestId + : createPendingTargetRequestId(); + if (stored.pendingTargetRequestId !== requestId) { + await chrome.storage.session.set({ pendingTargetRequestId: requestId }); + } + + return { + tabId, + tabTitle: typeof stored.pendingTargetTabTitle === 'string' + ? stored.pendingTargetTabTitle + : null, + host: typeof stored.pendingTargetHost === 'string' + ? stored.pendingTargetHost + : null, + originPattern, + requestId + }; + }); +} + +async function rememberPendingTarget(tabId, tabTitle, error, expectedGeneration) { + return mutatePendingTarget(async () => { + if (targetActivationGeneration !== expectedGeneration) return null; + const previous = await chrome.storage.session.get(PENDING_TARGET_KEYS); const previousTabId = normalizeTabId(previous.pendingTargetTabId); const nextOriginPattern = error?.originPattern || null; if (previousTabId !== null && ( @@ -1869,22 +1973,48 @@ async function rememberPendingTarget(tabId, tabTitle, error) { previous.pendingTargetOriginPattern || null ); } + if (targetActivationGeneration !== expectedGeneration) return null; + const requestId = previousTabId === tabId + && previous.pendingTargetOriginPattern === nextOriginPattern + && typeof previous.pendingTargetRequestId === 'string' + && previous.pendingTargetRequestId.length > 0 + ? previous.pendingTargetRequestId + : createPendingTargetRequestId(); await chrome.storage.session.set({ pendingTargetTabId: tabId, pendingTargetTabTitle: typeof tabTitle === 'string' ? tabTitle : null, pendingTargetHost: error?.host || null, - pendingTargetOriginPattern: nextOriginPattern + pendingTargetOriginPattern: nextOriginPattern, + pendingTargetRequestId: requestId }); + if (targetActivationGeneration !== expectedGeneration) { + const current = await chrome.storage.session.get(PENDING_TARGET_KEYS); + if (current.pendingTargetRequestId === requestId) { + await removeTabHostAccessRequest(chrome, tabId, nextOriginPattern); + await chrome.storage.session.set(emptyPendingTargetState()); + } + return null; + } + return { + tabId, + tabTitle: typeof tabTitle === 'string' ? tabTitle : null, + host: error?.host || null, + originPattern: nextOriginPattern, + requestId + }; }); } -async function clearPendingTarget() { +async function clearPendingTarget({ expectedRequestId = null, expectedTabId = null } = {}) { return mutatePendingTarget(async () => { - const pending = await chrome.storage.session.get([ - 'pendingTargetTabId', - 'pendingTargetOriginPattern' - ]); + const pending = await chrome.storage.session.get(PENDING_TARGET_KEYS); const pendingTabId = normalizeTabId(pending.pendingTargetTabId); + if (expectedRequestId !== null && pending.pendingTargetRequestId !== expectedRequestId) { + return false; + } + if (expectedTabId !== null && pendingTabId !== normalizeTabId(expectedTabId)) { + return false; + } if (pendingTabId !== null) { await removeTabHostAccessRequest( chrome, @@ -1892,112 +2022,200 @@ async function clearPendingTarget() { pending.pendingTargetOriginPattern || null ); } - await chrome.storage.session.set({ - pendingTargetTabId: null, - pendingTargetTabTitle: null, - pendingTargetHost: null, - pendingTargetOriginPattern: null - }); + await chrome.storage.session.set(emptyPendingTargetState()); + return true; }); } -async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true } = {}) { +async function activateTargetTab(tabId, tabTitle, { + requestHostAccess = true, + expectedGeneration = null, + expectedCurrentTabId = null +} = {}) { const selectedTabId = normalizeTabId(tabId); if (selectedTabId === null) { return { status: 'invalid_tab' }; } + if (expectedGeneration !== null && targetActivationGeneration !== expectedGeneration) { + return { status: 'superseded' }; + } + if (expectedCurrentTabId !== null + && normalizeTabId(currentTabId) !== normalizeTabId(expectedCurrentTabId)) { + return { status: 'superseded' }; + } + completeForceSyncBeforeTargetChange(selectedTabId); const activationGeneration = ++targetActivationGeneration; + activeTargetActivation = { generation: activationGeneration, tabId: selectedTabId }; const previousTabId = normalizeTabId(currentTabId); try { - await injectContentScript(selectedTabId, { requestHostAccess }); - } catch (error) { + try { + await injectContentScript(selectedTabId, { requestHostAccess }); + } catch (error) { + if (activationGeneration !== targetActivationGeneration) { + if (error?.code === HOST_ACCESS_REQUIRED_STATUS + && error.requestAdded === true + && activeTargetActivation?.tabId !== selectedTabId) { + await removeTabHostAccessRequest( + chrome, + selectedTabId, + error.originPattern || null + ); + } + return { status: 'superseded' }; + } + currentTabId = null; + currentTabTitle = null; + lastContentHeartbeatAt = null; + if (currentRoom) roomIdleSince = Date.now(); + if (previousTabId) { + resetAudioProcessingInTab(previousTabId); + } + await chrome.storage.session.set({ + currentTabId: null, + currentTabTitle: null, + roomIdleSince, + lastContentHeartbeatAt: null + }); + if (activationGeneration !== targetActivationGeneration) { + return { status: 'superseded' }; + } + updateBadgeStatus(); + + if (error?.code === HOST_ACCESS_REQUIRED_STATUS) { + const pending = await rememberPendingTarget( + selectedTabId, + tabTitle, + error, + activationGeneration + ); + if (!pending || activationGeneration !== targetActivationGeneration) { + return { status: 'superseded' }; + } + chrome.runtime.sendMessage({ + type: 'TARGET_TAB_ACCESS_REQUIRED', + requestId: pending.requestId, + ...injectionFailureResponse(error) + }).catch(() => {}); + } else { + await clearPendingTarget(); + if (activationGeneration !== targetActivationGeneration) { + return { status: 'superseded' }; + } + } + throw error; + } + if (activationGeneration !== targetActivationGeneration) { + if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); return { status: 'superseded' }; } - currentTabId = null; - currentTabTitle = null; + + await applyAudioSettingsToTab(selectedTabId); + if (activationGeneration !== targetActivationGeneration) { + if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); + return { status: 'superseded' }; + } + await removeTabHostAccessRequest(chrome, selectedTabId); + if (activationGeneration !== targetActivationGeneration) { + if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); + return { status: 'superseded' }; + } + await clearPendingTarget(); + if (activationGeneration !== targetActivationGeneration) { + if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); + return { status: 'superseded' }; + } + currentTabId = selectedTabId; + currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null; lastContentHeartbeatAt = null; if (currentRoom) roomIdleSince = Date.now(); - if (previousTabId) { + if (previousTabId && previousTabId !== selectedTabId) { resetAudioProcessingInTab(previousTabId); } await chrome.storage.session.set({ - currentTabId: null, - currentTabTitle: null, + currentTabId, + currentTabTitle, roomIdleSince, - lastContentHeartbeatAt: null + lastContentHeartbeatAt }); if (activationGeneration !== targetActivationGeneration) { return { status: 'superseded' }; } updateBadgeStatus(); - - if (error?.code === HOST_ACCESS_REQUIRED_STATUS) { - await rememberPendingTarget(selectedTabId, tabTitle, error); - chrome.runtime.sendMessage({ - type: 'TARGET_TAB_ACCESS_REQUIRED', - ...injectionFailureResponse(error) - }).catch(() => {}); - } else { - await clearPendingTarget(); + return { status: 'ok', tabId: selectedTabId, generation: activationGeneration }; + } finally { + if (activeTargetActivation?.generation === activationGeneration) { + activeTargetActivation = null; } - throw error; } - - if (activationGeneration !== targetActivationGeneration) { - if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); - return { status: 'superseded' }; - } - - await applyAudioSettingsToTab(selectedTabId); - if (activationGeneration !== targetActivationGeneration) { - if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); - return { status: 'superseded' }; - } - await clearPendingTarget(); - if (activationGeneration !== targetActivationGeneration) { - if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); - return { status: 'superseded' }; - } - currentTabId = selectedTabId; - currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null; - lastContentHeartbeatAt = null; - if (currentRoom) roomIdleSince = Date.now(); - if (previousTabId && previousTabId !== selectedTabId) { - resetAudioProcessingInTab(previousTabId); - } - await chrome.storage.session.set({ - currentTabId, - currentTabTitle, - roomIdleSince, - lastContentHeartbeatAt - }); - updateBadgeStatus(); - return { status: 'ok', tabId: selectedTabId }; } -async function retryPendingTarget() { - const pending = await chrome.storage.session.get([ - 'pendingTargetTabId', - 'pendingTargetTabTitle' - ]); - const tabId = normalizeTabId(pending.pendingTargetTabId); - if (tabId === null) return null; +async function reactivateCurrentTarget(tabId, { expectedGeneration = targetActivationGeneration } = {}) { + const selectedTabId = normalizeTabId(tabId); + if (selectedTabId === null || !isCurrentTargetIdentity(selectedTabId, expectedGeneration)) { + return { status: 'superseded' }; + } + if (activeTargetActivation && activeTargetActivation.tabId !== selectedTabId) { + return { status: 'superseded' }; + } + return activateTargetTab(selectedTabId, currentTabTitle, { + expectedGeneration, + expectedCurrentTabId: selectedTabId + }); +} + +async function retryPendingTarget({ expectedRequestId = null, requireGrantedAccess = false } = {}) { + let pending = await readPendingTarget(); + if (!pending || (expectedRequestId !== null && pending.requestId !== expectedRequestId)) { + return null; + } + if (activeTargetActivation && activeTargetActivation.tabId !== pending.tabId) { + return { status: 'superseded' }; + } + + if (requireGrantedAccess) { + let access; + try { + access = await inspectTabHostAccess(chrome, pending.tabId); + } catch { + await clearPendingTarget({ + expectedRequestId: pending.requestId, + expectedTabId: pending.tabId + }); + return { status: 'invalid_tab' }; + } + if (access.granted !== true || access.originPattern !== pending.originPattern) { + return { status: 'permission_not_granted' }; + } + pending = await readPendingTarget(); + if (!pending || pending.requestId !== expectedRequestId) { + return { status: 'superseded' }; + } + } try { - const response = await activateTargetTab(tabId, pending.pendingTargetTabTitle, { - requestHostAccess: false + const expectedGeneration = targetActivationGeneration; + const response = await activateTargetTab(pending.tabId, pending.tabTitle, { + requestHostAccess: false, + expectedGeneration }); if (response.status === 'ok') { - addLog(`Website access granted; selected tab ${tabId}`, 'success'); - chrome.runtime.sendMessage({ type: 'TARGET_TAB_READY', tabId }).catch(() => {}); + addLog(`Website access granted; selected tab ${pending.tabId}`, 'success'); + chrome.runtime.sendMessage({ + type: 'TARGET_TAB_READY', + tabId: pending.tabId, + requestId: pending.requestId + }).catch(() => {}); } return response; } catch (error) { if (error?.code !== HOST_ACCESS_REQUIRED_STATUS) { - await clearPendingTarget(); + await clearPendingTarget({ + expectedRequestId: pending.requestId, + expectedTabId: pending.tabId + }); addLog(`Pending tab activation failed: ${error.message}`, 'warn'); } return injectionFailureResponse(error); @@ -2005,10 +2223,21 @@ async function retryPendingTarget() { } if (chrome.permissions?.onAdded?.addListener) { - chrome.permissions.onAdded.addListener(() => { - ensureState() - .then(() => retryPendingTarget()) - .catch(error => addLog(`Website access retry failed: ${error.message}`, 'warn')); + chrome.permissions.onAdded.addListener((addedPermissions) => { + ensureState().then(async () => { + const pending = await readPendingTarget(); + if (!pending) return; + const addedOrigins = Array.isArray(addedPermissions?.origins) + ? addedPermissions.origins + : []; + if (!addedOrigins.includes(pending.originPattern) && !addedOrigins.includes('')) { + return; + } + await retryPendingTarget({ + expectedRequestId: pending.requestId, + requireGrantedAccess: true + }); + }).catch(error => addLog(`Website access retry failed: ${error.message}`, 'warn')); }); } @@ -2017,19 +2246,30 @@ if (chrome.tabs?.onRemoved?.addListener) { ensureState().then(async () => { const tabId = normalizeTabId(removedTabId); if (tabId === null) return; - const pending = await chrome.storage.session.get('pendingTargetTabId'); + const pending = await readPendingTarget(); const isCurrent = normalizeTabId(currentTabId) === tabId; - const isPending = normalizeTabId(pending.pendingTargetTabId) === tabId; - if (!isCurrent && !isPending) return; + const isPending = pending?.tabId === tabId; + const isActivating = activeTargetActivation?.tabId === tabId; + if (!isCurrent && !isPending && !isActivating) return; - targetActivationGeneration++; + const hasReplacementActivation = activeTargetActivation + && activeTargetActivation.tabId !== tabId; + if (isCurrent) completeForceSyncBeforeTargetChange(null); + if (isActivating || (isCurrent && !hasReplacementActivation)) { + invalidateTargetActivations(); + } if (isCurrent) { currentTabId = null; currentTabTitle = null; lastContentHeartbeatAt = null; if (currentRoom) roomIdleSince = Date.now(); } - if (isPending) await clearPendingTarget(); + if (isPending) { + await clearPendingTarget({ + expectedRequestId: pending.requestId, + expectedTabId: tabId + }); + } await chrome.storage.session.set({ currentTabId, currentTabTitle, @@ -2038,11 +2278,44 @@ if (chrome.tabs?.onRemoved?.addListener) { }); updateBadgeStatus(); chrome.runtime.sendMessage({ type: 'TARGET_TAB_CLEARED', tabId }).catch(() => {}); + if (isCurrent) { + addLog('Target tab closed.', 'warn'); + if (currentRoom) { + const roomAtClose = currentRoom; + getSettings().then(settings => { + if (currentRoom !== roomAtClose) return; + emit(EVENTS.PEER_STATUS, { + peerId, + playbackState: 'paused', + currentTime: null, + mediaTitle: null, + username: settings.username, + tabTitle: null + }); + const me = currentRoom?.peers?.find(p => (p.peerId || p) === peerId); + if (me && typeof me === 'object') { + me.playbackState = 'paused'; + me.currentTime = null; + me.mediaTitle = null; + me.tabTitle = null; + me.lastHeartbeat = Date.now(); + if (storageInitialized) { + chrome.storage.session.set({ currentRoom }); + } + chrome.runtime.sendMessage({ + type: 'PEER_UPDATE', + peers: currentRoom.peers + }).catch(() => {}); + } + }).catch(() => {}); + } + } }).catch(error => addLog(`Closed target-tab cleanup failed: ${error.message}`, 'warn')); }); } -function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries) { +function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries, targetGeneration) { + if (!isCurrentTargetIdentity(tabId, targetGeneration)) return; chrome.tabs.sendMessage(tabId, { type: 'SERVER_COMMAND', action, @@ -2050,21 +2323,30 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman actionTimestamp, commandSenderId }).catch(err => { + if (!isCurrentTargetIdentity(tabId, targetGeneration)) return; if (retries >= 3) { addLog(`Content Script not responding in tab ${tabId} after ${retries} retries`, 'warn'); - clearTargetTabForIdle(); + clearTargetTabForIdle(tabId, targetGeneration); return; } if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) { - activateTargetTab(tabId, currentTabTitle).then(response => { + reactivateCurrentTarget(tabId, { expectedGeneration: targetGeneration }).then(response => { if (response?.status !== 'ok') return; - setTimeout(() => _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries + 1), 500); + setTimeout(() => _routeToContentInternal( + tabId, + action, + payload, + actionTimestamp, + commandSenderId, + retries + 1, + response.generation + ), 500); }).catch(_err => { addLog(`Auto-reinject failed for tab ${tabId}`, 'warn'); }); } else { addLog(`Content Script not responding in tab ${tabId}`, 'warn'); - clearTargetTabForIdle(); + clearTargetTabForIdle(tabId, targetGeneration); } }); } @@ -2216,11 +2498,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (message.retryPendingTarget === true) { await retryPendingTarget(); } - const pendingTarget = await chrome.storage.session.get([ - 'pendingTargetTabId', - 'pendingTargetHost', - 'pendingTargetOriginPattern' - ]); + const pendingTarget = await readPendingTarget(); const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined; const isReconnecting = !isConnected && reconnectAttempts > 0; let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected')); @@ -2232,9 +2510,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { peers: currentRoom ? currentRoom.peers : [], lastActionState, targetTabId: currentTabId, - pendingTargetTabId: pendingTarget.pendingTargetTabId || null, - pendingTargetHost: pendingTarget.pendingTargetHost || null, - pendingTargetOriginPattern: pendingTarget.pendingTargetOriginPattern || null, + pendingTargetTabId: pendingTarget?.tabId ?? null, + pendingTargetHost: pendingTarget?.host ?? null, + pendingTargetOriginPattern: pendingTarget?.originPattern ?? null, + pendingTargetRequestId: pendingTarget?.requestId ?? null, episodeLobby: episodeLobby, reconnectAttempts, reconnectSlowMode: reconnectFailed, @@ -2286,6 +2565,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { // "Solo" to the host (stale-badge split-brain). sendResponse({ controlMode, hostPeerId, controllers, amHost: amHost(), amController: amController(), desynced: hcmDesynced, hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL), coHostSupported: serverSupports(CAPABILITIES.CO_HOST) }); } else if (message.type === 'REQUEST_HOST_SYNC') { + if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) { + sendResponse({ status: 'ignored_unselected_tab', target: null }); + return; + } // content.js resync: hand back the host's extrapolated current position. sendResponse({ target: getHostSyncTarget() }); } else if (message.type === 'GET_HCM_STRINGS') { @@ -2309,7 +2592,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { } else if (message.type === 'HCM_DESYNC_STATE') { // content.js tells us whether the local user chose to watch on their own. // Only accept from the currently selected tab. - if (sender.tab && currentTabId && currentTabId !== sender.tab.id) { + if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) { sendResponse({ status: 'ignored_unselected_tab' }); return; } @@ -2320,6 +2603,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (storageInitialized) chrome.storage.session.set({ hcmDesynced }); sendResponse({ status: 'ok' }); } else if (message.type === 'LEAVE_ROOM') { + completeForceSyncBeforeTargetChange(null); connectIntent = false; reconnectFailed = false; reconnectAttempts = 0; @@ -2335,7 +2619,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { // Notify content.js/popup BEFORE currentTabId is cleared so they drop any // stale guest-side HCM state (dialog/badge/desync) — H-2/H-3. broadcastControlMode(); - targetActivationGeneration++; + invalidateTargetActivations(); currentTabId = null; currentTabTitle = null; roomIdleSince = null; @@ -2475,6 +2759,13 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ status: 'error', message: err.message }); }); } else if (message.type === 'CONTENT_EVENT') { + if (!sender?.tab && message.expectedTabId !== undefined) { + const expectedTabId = normalizeTabId(message.expectedTabId); + if (expectedTabId === null || normalizeTabId(currentTabId) !== expectedTabId) { + sendResponse({ status: 'stale_target' }); + return; + } + } const processEvent = async () => { // Host Control Mode (sender-side): a non-controller in host-only mode must // not drive the room. Don't broadcast; hand the action back to content.js so @@ -2618,6 +2909,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { }); } } else if (message.type === 'FORCE_SYNC_ACK') { + if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) { + sendResponse({ status: 'ignored_unselected_tab' }); + return; + } if (isForceSyncInitiator) { forceSyncAcks.add(peerId); chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) }); @@ -2642,6 +2937,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { } sendResponse({ status: 'ok' }); } else if (message.type === 'CMD_ACK') { + if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) { + sendResponse({ status: 'ignored_unselected_tab' }); + return; + } const commandSenderId = message.commandSenderId; // Only ACK if the command sender is still a known peer in our room. // If we've already seen their PEER_STATUS 'left', skip the ACK — it would @@ -2711,7 +3010,17 @@ async function handleAsyncMessage(message, sender, sendResponse) { return true; } - activateTargetTab(tabId, currentTabTitle).then(response => { + const expectedCurrentTabId = normalizeTabId(message.expectedCurrentTabId); + if (expectedCurrentTabId === null + || tabId !== expectedCurrentTabId + || normalizeTabId(currentTabId) !== expectedCurrentTabId) { + sendResponse({ status: 'stale_target' }); + return true; + } + + reactivateCurrentTarget(tabId, { + expectedGeneration: targetActivationGeneration + }).then(response => { sendResponse(response); }).catch(err => { addLog(`Failed to inject into tab: ${err.message}`, 'warn'); @@ -2721,7 +3030,8 @@ async function handleAsyncMessage(message, sender, sendResponse) { } else if (message.type === 'SET_TARGET_TAB') { if (message.tabId === null || message.tabId === undefined || message.tabId === '') { const previousTabId = currentTabId; - targetActivationGeneration++; + completeForceSyncBeforeTargetChange(null); + invalidateTargetActivations(); currentTabId = null; currentTabTitle = null; lastContentHeartbeatAt = null; @@ -2741,6 +3051,12 @@ async function handleAsyncMessage(message, sender, sendResponse) { try { const response = await activateTargetTab(message.tabId, message.tabTitle); + if (response?.status === 'ok') { + chrome.runtime.sendMessage({ + type: 'TARGET_TAB_READY', + tabId: response.tabId + }).catch(() => {}); + } sendResponse(response); } catch (error) { addLog(`Failed to select tab: ${error.message}`, 'warn'); @@ -2928,24 +3244,8 @@ async function handleAsyncMessage(message, sender, sendResponse) { initTabManager({ getCurrentTabId: () => currentTabId, - setCurrentTabId: (val) => { - if (val !== currentTabId) targetActivationGeneration++; - currentTabId = val; - }, - setCurrentTabTitle: (val) => { currentTabTitle = val; }, - setLastContentHeartbeatAt: (val) => { lastContentHeartbeatAt = val; }, - setRoomIdleSince: (val) => { roomIdleSince = val; }, - getCurrentRoom: () => currentRoom, - getPeerId: () => peerId, - getStorageInitialized: () => storageInitialized, - updateBadgeStatus, - addLog, - getSettings, - emit, - applyAudioSettingsToTab, - injectContentScript, - ensureState, - EVENTS + reactivateCurrentTarget, + ensureState }); // Initial Connect — only if user has an active room configuration diff --git a/extension/host-access.js b/extension/host-access.js index 9f801b1..4f66ed3 100644 --- a/extension/host-access.js +++ b/extension/host-access.js @@ -12,16 +12,23 @@ export function normalizeTabId(value) { return Number.isSafeInteger(tabId) ? tabId : null; } -export function describeTabUrl(rawUrl) { +function browserSupportsPortMatchPatterns(chromeApi) { + // runtime.getBrowserInfo is a Firefox-only WebExtension API. Firefox does + // not accept ports in match patterns, while Chromium does. + return typeof chromeApi?.runtime?.getBrowserInfo !== 'function'; +} + +export function describeTabUrl(rawUrl, { includePort = true } = {}) { if (typeof rawUrl !== 'string' || !rawUrl) return null; try { const url = new URL(rawUrl); if (url.protocol === 'http:' || url.protocol === 'https:') { + const permissionHost = includePort ? url.host : url.hostname; return { url: rawUrl, host: url.host, - originPattern: `${url.origin}/*` + originPattern: `${url.protocol}//${permissionHost}/*` }; } if (url.protocol === 'file:') { @@ -40,7 +47,11 @@ export function describeTabUrl(rawUrl) { export async function inspectTabHostAccess(chromeApi, tabId) { const tab = await chromeApi.tabs.get(tabId); - const descriptor = describeTabUrl(tab?.pendingUrl || tab?.url || ''); + // executeScript targets the committed document. pendingUrl may already point + // at another origin while tab.url is still the document being injected into. + const descriptor = describeTabUrl(tab?.url || tab?.pendingUrl || '', { + includePort: browserSupportsPortMatchPatterns(chromeApi) + }); if (!descriptor) { return { tab, @@ -59,7 +70,8 @@ export async function inspectTabHostAccess(chromeApi, tabId) { const granted = await callBooleanPermissionMethod( chromeApi, 'contains', - { origins: [descriptor.originPattern] } + { origins: [descriptor.originPattern] }, + { timeoutMs: 1000 } ); return { tab, @@ -73,20 +85,29 @@ export async function inspectTabHostAccess(chromeApi, tabId) { } } -function callBooleanPermissionMethod(chromeApi, methodName, request) { +function callBooleanPermissionMethod(chromeApi, methodName, request, { timeoutMs = null } = {}) { const method = chromeApi.permissions?.[methodName]; if (typeof method !== 'function') return Promise.resolve(null); return new Promise((resolve, reject) => { let settled = false; + let timeout = null; + const clearSettlementTimeout = () => { + if (timeout !== null) { + clearTimeout(timeout); + timeout = null; + } + }; const finish = (value) => { if (settled) return; settled = true; - resolve(value === true); + clearSettlementTimeout(); + resolve(value === true ? true : value === false ? false : null); }; const fail = (error) => { if (settled) return; settled = true; + clearSettlementTimeout(); reject(error instanceof Error ? error : new Error(String(error || 'Permission request failed'))); }; const callback = (value) => { @@ -98,6 +119,10 @@ function callBooleanPermissionMethod(chromeApi, methodName, request) { finish(value); }; + if (Number.isFinite(timeoutMs) && timeoutMs > 0) { + timeout = setTimeout(() => finish(null), timeoutMs); + } + try { const result = method.call(chromeApi.permissions, request, callback); if (result && typeof result.then === 'function') { @@ -120,7 +145,8 @@ export function requestOriginPermission(chromeApi, originPattern) { return callBooleanPermissionMethod( chromeApi, 'request', - { origins: [originPattern] } + { origins: [originPattern] }, + { timeoutMs: 60000 } ).catch(() => false); } diff --git a/extension/modules/tab-manager.js b/extension/modules/tab-manager.js index 8fcbc22..18cde2f 100644 --- a/extension/modules/tab-manager.js +++ b/extension/modules/tab-manager.js @@ -1,20 +1,7 @@ export function initTabManager({ getCurrentTabId, - setCurrentTabId, - setCurrentTabTitle, - setLastContentHeartbeatAt, - setRoomIdleSince, - getCurrentRoom, - getPeerId, - getStorageInitialized, - updateBadgeStatus, - addLog, - getSettings, - emit, - applyAudioSettingsToTab, - injectContentScript, - ensureState, - EVENTS + reactivateCurrentTarget, + ensureState }) { chrome.storage.onChanged.addListener(async (changes, area) => { if (area !== 'local' || !changes.audioSettings) return; @@ -28,65 +15,14 @@ export function initTabManager({ }).catch(() => {}); }); - chrome.tabs.onRemoved.addListener(async (tabId) => { - await ensureState(); - if (tabId === getCurrentTabId()) { - const wasInRoom = !!getCurrentRoom(); - setCurrentTabId(null); - setCurrentTabTitle(null); - setLastContentHeartbeatAt(null); - const now = Date.now(); - setRoomIdleSince(now); - chrome.storage.session.set({ - currentTabId: null, - currentTabTitle: null, - roomIdleSince: now, - lastContentHeartbeatAt: null - }); - updateBadgeStatus(); - addLog('Target tab closed.', 'warn'); - - if (wasInRoom) { - const roomAtClose = getCurrentRoom(); - getSettings().then(settings => { - if (getCurrentRoom() !== roomAtClose) return; - - emit(EVENTS.PEER_STATUS, { - peerId: getPeerId(), - playbackState: 'paused', - currentTime: null, - mediaTitle: null, - username: settings.username, - tabTitle: null - }); - - const room = getCurrentRoom(); - if (room && Array.isArray(room.peers)) { - const me = room.peers.find(p => (p.peerId || p) === getPeerId()); - if (me && typeof me === 'object') { - me.playbackState = 'paused'; - me.currentTime = null; - me.mediaTitle = null; - me.tabTitle = null; - me.lastHeartbeat = Date.now(); - if (getStorageInitialized()) { - chrome.storage.session.set({ currentRoom: room }); - } - chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: room.peers }).catch(() => {}); - } - } - }).catch(() => {}); - } - } - }); - chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => { await ensureState(); - const curTabId = getCurrentTabId(); - if (curTabId && tabId === parseInt(curTabId) && changeInfo.status === 'complete') { - injectContentScript(tabId) - .then(() => applyAudioSettingsToTab(tabId)) - .catch(() => {}); + const currentTabId = Number(getCurrentTabId()); + if (Number.isInteger(currentTabId) + && currentTabId > 0 + && tabId === currentTabId + && changeInfo.status === 'complete') { + reactivateCurrentTarget(tabId).catch(() => {}); } }); } diff --git a/extension/popup.js b/extension/popup.js index 22abc97..028a47e 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -3,7 +3,7 @@ import { BLACKLIST_DOMAINS } from './shared/blacklist.js'; import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js'; import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js'; import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js'; -import { requestOriginPermission } from './host-access.js'; +import { normalizeTabId, requestOriginPermission } from './host-access.js'; const elements = { @@ -415,7 +415,11 @@ async function init() { } if (res.pendingTargetTabId) { - showSiteAccessNotice(res.pendingTargetHost, res.pendingTargetOriginPattern); + showSiteAccessNotice( + res.pendingTargetHost, + res.pendingTargetOriginPattern, + res.pendingTargetTabId + ); } else { hideSiteAccessNotice(); } @@ -477,6 +481,7 @@ let hcmGuestLocked = false; let siteAccessBlocked = false; let siteAccessHost = null; let siteAccessOriginPattern = null; +let siteAccessTabId = null; // Co-Host state mirrored for the peer-list renderer (promote/demote + role badges). let hcmAmOwner = false; @@ -557,15 +562,21 @@ function setRemoteControlsLocked(locked) { if (elements.pauseBtn) elements.pauseBtn.textContent = getMessage('BTN_PAUSE') || 'Pause'; } -function showSiteAccessNotice(host, originPattern = null) { +function showSiteAccessNotice(host, originPattern = null, tabId = null) { siteAccessBlocked = true; - siteAccessHost = host || siteAccessHost || 'website'; - siteAccessOriginPattern = originPattern || siteAccessOriginPattern; + siteAccessHost = typeof host === 'string' && host.length > 0 ? host : 'website'; + siteAccessOriginPattern = typeof originPattern === 'string' && originPattern.length > 0 + ? originPattern + : null; + siteAccessTabId = normalizeTabId(tabId); if (elements.siteAccessMessage) { elements.siteAccessMessage.textContent = getMessage('SITE_ACCESS_REQUIRED', { host: siteAccessHost }); } + if (elements.siteAccessRetry) { + elements.siteAccessRetry.disabled = !siteAccessOriginPattern || siteAccessTabId === null; + } if (elements.siteAccessNotice) elements.siteAccessNotice.style.display = 'block'; setRemoteControlsLocked(hcmGuestLocked); } @@ -574,13 +585,15 @@ function hideSiteAccessNotice() { siteAccessBlocked = false; siteAccessHost = null; siteAccessOriginPattern = null; + siteAccessTabId = null; + if (elements.siteAccessRetry) elements.siteAccessRetry.disabled = false; if (elements.siteAccessNotice) elements.siteAccessNotice.style.display = 'none'; setRemoteControlsLocked(hcmGuestLocked); } function handleTargetTabResponse(response) { if (response?.status === 'host_permission_required') { - showSiteAccessNotice(response.host, response.originPattern); + showSiteAccessNotice(response.host, response.originPattern, response.tabId); return false; } if (response?.status === 'ok') { @@ -605,6 +618,32 @@ function selectTargetTab(tabId, tabTitle) { }); } +async function refreshTargetAccessState() { + const status = await new Promise(resolve => { + chrome.runtime.sendMessage({ type: 'GET_STATUS' }, response => { + if (chrome.runtime.lastError) { + resolve(null); + return; + } + resolve(response || null); + }); + }); + if (!status) return; + if (status.pendingTargetTabId) { + showSiteAccessNotice( + status.pendingTargetHost, + status.pendingTargetOriginPattern, + status.pendingTargetTabId + ); + } else { + hideSiteAccessNotice(); + } + await populateTabs( + status.peers, + status.targetTabId ?? status.pendingTargetTabId ?? null + ); +} + if (elements.hostControlToggle) { elements.hostControlToggle.addEventListener('change', () => { const mode = elements.hostControlToggle.checked ? 'host-only' : 'everyone'; @@ -1537,7 +1576,11 @@ if (elements.langSelector) { updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl); if (res.pendingTargetTabId) { - showSiteAccessNotice(res.pendingTargetHost, res.pendingTargetOriginPattern); + showSiteAccessNotice( + res.pendingTargetHost, + res.pendingTargetOriginPattern, + res.pendingTargetTabId + ); } else { hideSiteAccessNotice(); } @@ -1831,26 +1874,29 @@ elements.targetTab.addEventListener('change', async () => { if (elements.siteAccessRetry) { elements.siteAccessRetry.addEventListener('click', async () => { - const val = elements.targetTab.value; - const tabId = val ? parseInt(val) : null; + const tabId = normalizeTabId(elements.targetTab.value); const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || null; - if (!tabId) { + if (tabId === null || siteAccessTabId !== tabId || !siteAccessOriginPattern) { showToast(getMessage('ERR_SELECT_VIDEO'), 'warning'); return; } const requestedOriginPattern = siteAccessOriginPattern; + const requestedTabId = siteAccessTabId; elements.siteAccessRetry.disabled = true; elements.targetTab.disabled = true; try { - await requestOriginPermission(chrome, requestedOriginPattern); - const selectedTabId = parseInt(elements.targetTab.value); + const granted = await requestOriginPermission(chrome, requestedOriginPattern); + if (granted !== true) return; + const selectedTabId = normalizeTabId(elements.targetTab.value); // permissions.onAdded may already have completed activation, or a // newer selection may have replaced this request while it was open. if (!siteAccessBlocked || selectedTabId !== tabId + || siteAccessTabId !== requestedTabId || siteAccessOriginPattern !== requestedOriginPattern) return; await selectTargetTab(tabId, tabTitle); } finally { - elements.siteAccessRetry.disabled = false; + elements.siteAccessRetry.disabled = siteAccessBlocked + && (!siteAccessOriginPattern || siteAccessTabId === null); elements.targetTab.disabled = false; } }); @@ -1868,6 +1914,11 @@ elements.forceSyncBtn.addEventListener('click', async () => { elements.forceSyncBtn.disabled = false; return; } + const tabId = normalizeTabId(status.targetTabId); + if (tabId === null) { + elements.forceSyncBtn.disabled = false; + return; + } const mode = elements.forceSyncMode.value; let targetTime = null; @@ -1909,9 +1960,9 @@ elements.forceSyncBtn.addEventListener('click', async () => { } }; forceSyncResetTimer = setTimeout(forceSyncReset, syncTimeoutMs); - const tabId = parseInt(status.targetTabId); const failForceSyncTime = () => { + if (forceSyncDone) return; if (forceSyncResetTimer) { clearTimeout(forceSyncResetTimer); forceSyncResetTimer = null; } showError(getMessage('ERR_NO_VIDEO_TAB')); forceSyncDone = true; @@ -1919,7 +1970,17 @@ elements.forceSyncBtn.addEventListener('click', async () => { elements.forceSyncBtn.textContent = originalText; }; - const sendForceSync = (time) => { + const targetIsStillCurrent = () => new Promise(resolve => { + chrome.runtime.sendMessage({ type: 'GET_STATUS' }, currentStatus => { + if (chrome.runtime.lastError) { + resolve(false); + return; + } + resolve(normalizeTabId(currentStatus?.targetTabId) === tabId); + }); + }); + + const sendForceSync = async (time) => { if (time === null || time === undefined) { failForceSyncTime(); return; @@ -1929,15 +1990,28 @@ elements.forceSyncBtn.addEventListener('click', async () => { failForceSyncTime(); return; } + if (!(await targetIsStillCurrent())) { + failForceSyncTime(); + return; + } chrome.runtime.sendMessage({ type: 'CONTENT_EVENT', action: EVENTS.FORCE_SYNC_PREPARE, + expectedTabId: tabId, payload: { targetTime: target } + }, response => { + if (chrome.runtime.lastError || response?.status === 'stale_target') { + failForceSyncTime(); + } }); }; if (mode === 'jump-to-me') { - const retryQueryTime = () => { + const retryQueryTime = async () => { + if (!(await targetIsStillCurrent())) { + failForceSyncTime(); + return; + } chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => { if (chrome.runtime.lastError || !retryResponse || !Number.isFinite(retryResponse.currentTime)) { failForceSyncTime(); @@ -1952,10 +2026,18 @@ elements.forceSyncBtn.addEventListener('click', async () => { return; } if (chrome.runtime.lastError || !response) { - chrome.runtime.sendMessage({ type: 'INJECT_CONTENT_SCRIPT', tabId }, (injectResponse) => { + chrome.runtime.sendMessage({ + type: 'INJECT_CONTENT_SCRIPT', + tabId, + expectedCurrentTabId: tabId + }, (injectResponse) => { if (chrome.runtime.lastError || !injectResponse || injectResponse.status !== 'ok') { if (injectResponse?.status === 'host_permission_required') { - showSiteAccessNotice(injectResponse.host, injectResponse.originPattern); + showSiteAccessNotice( + injectResponse.host, + injectResponse.originPattern, + injectResponse.tabId + ); } failForceSyncTime(); return; @@ -2251,14 +2333,11 @@ chrome.runtime.onMessage.addListener((msg) => { }); } } else if (msg.type === 'TARGET_TAB_READY') { - hideSiteAccessNotice(); - populateTabs(null, msg.tabId); + refreshTargetAccessState().catch(() => {}); } else if (msg.type === 'TARGET_TAB_ACCESS_REQUIRED') { - showSiteAccessNotice(msg.host, msg.originPattern); - populateTabs(null, msg.tabId); + refreshTargetAccessState().catch(() => {}); } else if (msg.type === 'TARGET_TAB_CLEARED') { - hideSiteAccessNotice(); - populateTabs(); + refreshTargetAccessState().catch(() => {}); } else if (msg.type === 'PING_UPDATE') { updatePingDisplay(msg.ping); } else if (msg.type === 'HISTORY_UPDATE') { diff --git a/scripts/test-host-access.mjs b/scripts/test-host-access.mjs index 8866abd..f62c524 100644 --- a/scripts/test-host-access.mjs +++ b/scripts/test-host-access.mjs @@ -34,6 +34,11 @@ assert.deepEqual(describeTabUrl('http://localhost:8096/web/'), { host: 'localhost:8096', originPattern: 'http://localhost:8096/*' }); +assert.deepEqual(describeTabUrl('http://localhost:8096/web/', { includePort: false }), { + url: 'http://localhost:8096/web/', + host: 'localhost:8096', + originPattern: 'http://localhost/*' +}); assert.equal(describeTabUrl('chrome://extensions/'), null); assert.equal(describeTabUrl('not a url'), null); @@ -54,6 +59,39 @@ assert.equal(access.granted, false); assert.equal(access.host, 'video.example'); assert.deepEqual(containsRequest, { origins: ['https://video.example/*'] }); +let firefoxContainsRequest = null; +const firefoxChrome = { + runtime: { getBrowserInfo: async () => ({ name: 'Firefox' }) }, + tabs: { + get: async tabId => ({ + id: tabId, + url: 'http://localhost:8096/web/', + pendingUrl: 'https://different.example/loading' + }) + }, + permissions: { + contains: async request => { + firefoxContainsRequest = request; + return false; + } + } +}; +const firefoxAccess = await inspectTabHostAccess(firefoxChrome, 42); +assert.equal(firefoxAccess.host, 'localhost:8096'); +assert.equal(firefoxAccess.originPattern, 'http://localhost/*'); +assert.deepEqual(firefoxContainsRequest, { origins: ['http://localhost/*'] }); + +const unknownPermissionChrome = { + runtime: {}, + tabs: { + get: async tabId => ({ id: tabId, url: 'https://video.example/watch' }) + }, + permissions: { + contains: (_request, callback) => { callback(undefined); } + } +}; +assert.equal((await inspectTabHostAccess(unknownPermissionChrome, 42)).granted, null); + let requestedTabId = null; const requestChrome = { permissions: { @@ -88,6 +126,7 @@ assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.e const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8'); const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8'); const popupHtml = fs.readFileSync(path.join(cwd(), 'extension', 'popup.html'), 'utf8'); +const tabManager = fs.readFileSync(path.join(cwd(), 'extension', 'modules', 'tab-manager.js'), 'utf8'); assert.match(background, /await activateTargetTab\(message\.tabId, message\.tabTitle\)/, 'SET_TARGET_TAB must await successful activation before acknowledging it'); @@ -97,6 +136,18 @@ assert.match(background, /retryPendingTarget\(\)/, 'pending target must resume after the user grants access'); assert.match(background, /activationGeneration !== targetActivationGeneration/, 'stale concurrent tab activations must not overwrite the newest selection'); +assert.match(background, /pendingTargetRequestId/, + 'pending access recovery must use an identity token'); +assert.match(background, /addedOrigins\.includes\(pending\.originPattern\)/, + 'unrelated permission grants must not activate a pending target'); +assert.match(background, /isCurrentTargetIdentity\(tabId, targetGeneration\)/, + 'stale content-routing retries must not reactivate an old target'); +assert.match(background, /message\.expectedTabId/, + 'popup playback events must be rejected after their target changes'); +assert.match(background, /completeForceSyncBeforeTargetChange\(selectedTabId\)/, + 'a target switch must finish an in-flight force sync on the old target'); +assert.match(background, /FORCE_SYNC_ACK'[\s\S]*ignored_unselected_tab/, + 'stale content scripts must not acknowledge force sync for a new target'); const activateTargetBody = background.slice( background.indexOf('async function activateTargetTab'), background.indexOf('async function retryPendingTarget') @@ -111,6 +162,18 @@ assert.match(popup, /response\?\.status === 'host_permission_required'/, 'popup must render the structured host-access failure'); assert.match(popup, /requestOriginPermission\(chrome, requestedOriginPattern\)/, 'retry button must request withheld host access directly'); +assert.match(popup, /expectedCurrentTabId: tabId/, + 'manual reinjection must be tied to the selected target identity'); +assert.match(popup, /expectedTabId: tabId/, + 'force sync must be tied to the tab whose time was sampled'); +assert.doesNotMatch(tabManager, /injectContentScript/, + 'tab reload recovery must use the guarded background activation path'); +assert.equal( + (background.match(/tabs\.onRemoved\.addListener/g) || []).length + + (tabManager.match(/tabs\.onRemoved\.addListener/g) || []).length, + 1, + 'target-tab closure must have exactly one state owner' +); assert.match(popupHtml, /id="siteAccessNotice"/, 'popup must contain a persistent site-access notice'); From 2b485fed5317a75f1058e35e39171cfa81e9fa51 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:34:24 +0200 Subject: [PATCH 12/17] chore(release): update versions to v2.6.3 [skip ci] --- README.md | 4 ++-- docs/CHANGELOG.md | 7 +++++++ extension/manifest.base.json | 2 +- package.json | 2 +- shared/constants.js | 2 +- website/template.html | 2 +- website/version.json | 4 ++-- 7 files changed, 15 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 99b1e19..2b26478 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

      Release Status - GitHub release + GitHub release License Firefox Add-on Chrome Extension @@ -14,7 +14,7 @@

      KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on Data Sovereignty and Performance.

      -

      New v2.6.2 Release! — See what's changed

      +

      New v2.6.3 Release! — See what's changed

      ### 🌟 Why KoalaSync? diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cfc558d..07f9184 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to the KoalaSync browser extension and relay server. --- +## [v2.6.3] — 2026-07-15 + +### Fixed +- **Extension: Host-access recovery races** — Prevents stale permission grants, reinjection retries, closed tabs, and rapid target changes from reactivating or clearing the wrong video tab. +- **Extension: Cross-browser host patterns** — Uses Firefox-compatible match patterns for local servers while preserving exact Chromium port scopes. +- **Extension: Force-sync target integrity** — Keeps sampled playback time and acknowledgements bound to the selected tab during recovery and target changes. + ## [v2.6.2] — 2026-07-15 ### Added diff --git a/extension/manifest.base.json b/extension/manifest.base.json index d40acac..aa61218 100644 --- a/extension/manifest.base.json +++ b/extension/manifest.base.json @@ -2,7 +2,7 @@ "manifest_version": 3, "default_locale": "en", "name": "KoalaSync", - "version": "2.6.2", + "version": "2.6.3", "description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.", "permissions": [ "storage", diff --git a/package.json b/package.json index 958cc54..19cceb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "koalasync", - "version": "2.6.2", + "version": "2.6.3", "description": "KoalaSync Build Scripts", "private": true, "type": "module", diff --git a/shared/constants.js b/shared/constants.js index fa3cb8d..5eccaf9 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -7,7 +7,7 @@ */ export const PROTOCOL_VERSION = "1.0.0"; -export const APP_VERSION = "2.6.2"; +export const APP_VERSION = "2.6.3"; export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net'; export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net'; diff --git a/website/template.html b/website/template.html index 6ff6762..367fd0d 100644 --- a/website/template.html +++ b/website/template.html @@ -116,7 +116,7 @@ "priceCurrency": "EUR" }, "description": "{{SCHEMA_APP_DESC}}", - "softwareVersion": "2.6.2", + "softwareVersion": "2.6.3", "license": "https://opensource.org/licenses/MIT", "sameAs": "https://github.com/Shik3i/KoalaSync", "image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp", diff --git a/website/version.json b/website/version.json index d37220a..c4bcc3d 100644 --- a/website/version.json +++ b/website/version.json @@ -1,4 +1,4 @@ { - "version": "2.6.2", - "date": "2026-07-15T10:47:23Z" + "version": "2.6.3", + "date": "2026-07-15T11:31:00Z" } From 6aa2f53e6b7b89fdb5ba15d3da1a132c9e5c2a3b Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:34:35 +0200 Subject: [PATCH 13/17] Fix access guide spacing and user help link --- website/site-access-help.html | 1 + website/styles/support.css | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/website/site-access-help.html b/website/site-access-help.html index 03b0b1e..40570e3 100644 --- a/website/site-access-help.html +++ b/website/site-access-help.html @@ -80,6 +80,7 @@
    4. Open KoalaSync and select the video tab again.

    You do not need to wait for a KoalaSync update. These steps work immediately.

    +

    Open Google’s official step-by-step instructions for changing an extension’s website access

    diff --git a/website/styles/support.css b/website/styles/support.css index 2d4fb7c..e9ff701 100644 --- a/website/styles/support.css +++ b/website/styles/support.css @@ -92,10 +92,16 @@ } .support-card h2 { - margin-bottom: 0.8rem; + margin: 0 0 0.8rem; font-size: clamp(1.35rem, 3vw, 1.75rem); } +.support-card > .support-eyebrow { + display: flex; + width: fit-content; + margin: 0 0 1rem; +} + .support-card h3 { margin-bottom: 0.55rem; font-size: 1.05rem; From 6e625e3a386ea71f08b14d4407a6686346b2d407 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 15 Jul 2026 11:37:32 +0000 Subject: [PATCH 14/17] chore(release): update versions to v2.6.3 [skip ci] --- website/version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/version.json b/website/version.json index c4bcc3d..8580727 100644 --- a/website/version.json +++ b/website/version.json @@ -1,4 +1,4 @@ { "version": "2.6.3", - "date": "2026-07-15T11:31:00Z" + "date": "2026-07-15T11:37:32Z" } From d2d4f5f3ea1da32310c630857ac52d622ef3c869 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:50:21 +0200 Subject: [PATCH 15/17] Localize extension store metadata --- extension/_locales/de/messages.json | 4 +- extension/_locales/en/messages.json | 4 +- extension/_locales/es/messages.json | 4 +- extension/_locales/fr/messages.json | 4 +- extension/_locales/it/messages.json | 4 +- extension/_locales/ja/messages.json | 4 +- extension/_locales/ko/messages.json | 4 +- extension/_locales/nl/messages.json | 4 +- extension/_locales/pl/messages.json | 4 +- extension/_locales/pt_BR/messages.json | 4 +- extension/_locales/pt_PT/messages.json | 4 +- extension/_locales/ru/messages.json | 4 +- extension/_locales/tr/messages.json | 4 +- extension/_locales/uk/messages.json | 4 +- extension/_locales/zh_CN/messages.json | 4 +- extension/manifest.base.json | 5 ++- scripts/build-extension.cjs | 53 ++++++++++++++++++++++++++ 17 files changed, 86 insertions(+), 32 deletions(-) diff --git a/extension/_locales/de/messages.json b/extension/_locales/de/messages.json index 8b84354..f7bf757 100644 --- a/extension/_locales/de/messages.json +++ b/extension/_locales/de/messages.json @@ -1,8 +1,8 @@ { "appName": { - "message": "KoalaSync" + "message": "Watch Party - KoalaSync" }, "appDesc": { - "message": "Synchronisiere die Videowiedergabe auf YouTube, Netflix, Emby, Jellyfin und jeder HTML5-Seite in Echtzeit mit Freunden." + "message": "Erstelle private Watch Partys und synchronisiere Videos mit Freunden auf YouTube, Netflix, Jellyfin, Emby und fast jeder Website." } } diff --git a/extension/_locales/en/messages.json b/extension/_locales/en/messages.json index 3132906..8be2507 100644 --- a/extension/_locales/en/messages.json +++ b/extension/_locales/en/messages.json @@ -1,8 +1,8 @@ { "appName": { - "message": "KoalaSync" + "message": "Watch Party - KoalaSync" }, "appDesc": { - "message": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends." + "message": "Create private watch parties and sync videos with friends on YouTube, Netflix, Jellyfin, Emby, and almost any HTML5 website." } } diff --git a/extension/_locales/es/messages.json b/extension/_locales/es/messages.json index d716d3e..9125cab 100644 --- a/extension/_locales/es/messages.json +++ b/extension/_locales/es/messages.json @@ -1,8 +1,8 @@ { "appName": { - "message": "KoalaSync" + "message": "Watch Party - KoalaSync" }, "appDesc": { - "message": "Mira videos junto a tus amigos en perfecta sincronización. Compatible con Netflix, YouTube, Twitch, Prime Video, Disney+ y cualquier reproductor HTML5." + "message": "Crea fiestas privadas y sincroniza vídeos con amigos en YouTube, Netflix, Jellyfin, Emby y casi cualquier sitio HTML5." } } diff --git a/extension/_locales/fr/messages.json b/extension/_locales/fr/messages.json index 37cbe71..ce17b61 100644 --- a/extension/_locales/fr/messages.json +++ b/extension/_locales/fr/messages.json @@ -1,8 +1,8 @@ { "appName": { - "message": "KoalaSync" + "message": "Watch Party - KoalaSync" }, "appDesc": { - "message": "Synchronisez la lecture vidéo sur YouTube, Netflix, Emby, Jellyfin et tout site HTML5 en temps réel avec vos amis." + "message": "Créez des watch parties privées et regardez en synchro entre amis sur YouTube, Netflix, Jellyfin, Emby et presque tout site HTML5." } } diff --git a/extension/_locales/it/messages.json b/extension/_locales/it/messages.json index 118ff90..4f6aa8a 100644 --- a/extension/_locales/it/messages.json +++ b/extension/_locales/it/messages.json @@ -1,8 +1,8 @@ { "appName": { - "message": "KoalaSync" + "message": "Watch Party - KoalaSync" }, "appDesc": { - "message": "Sincronizza la riproduzione video su YouTube, Netflix, Emby, Jellyfin e qualsiasi sito HTML5 in tempo reale con gli amici." + "message": "Crea watch party private e sincronizza i video con gli amici su YouTube, Netflix, Jellyfin, Emby e quasi ogni sito HTML5." } } diff --git a/extension/_locales/ja/messages.json b/extension/_locales/ja/messages.json index 5833c2f..dc17ce0 100644 --- a/extension/_locales/ja/messages.json +++ b/extension/_locales/ja/messages.json @@ -1,8 +1,8 @@ { "appName": { - "message": "KoalaSync" + "message": "Watch Party - KoalaSync" }, "appDesc": { - "message": "YouTube、Netflix、Emby、Jellyfin、その他HTML5サイトでの動画再生を友達とリアルタイムで同期します。" + "message": "プライベートなウォッチパーティーを作成し、YouTube、Netflix、Jellyfin、EmbyやほぼすべてのHTML5サイトで友達と動画を同期再生できます。" } } diff --git a/extension/_locales/ko/messages.json b/extension/_locales/ko/messages.json index 12f71e4..8ad21bb 100644 --- a/extension/_locales/ko/messages.json +++ b/extension/_locales/ko/messages.json @@ -1,8 +1,8 @@ { "appName": { - "message": "KoalaSync" + "message": "Watch Party - KoalaSync" }, "appDesc": { - "message": "YouTube, Netflix, Emby, Jellyfin 및 모든 HTML5 사이트에서 친구들과 실시간으로 비디오 재생을 동기화하세요." + "message": "비공개 Watch Party를 만들고 YouTube, Netflix, Jellyfin, Emby 및 거의 모든 HTML5 사이트에서 친구들과 영상을 동기화하세요." } } diff --git a/extension/_locales/nl/messages.json b/extension/_locales/nl/messages.json index ab97481..feb4563 100644 --- a/extension/_locales/nl/messages.json +++ b/extension/_locales/nl/messages.json @@ -1,8 +1,8 @@ { "appName": { - "message": "KoalaSync" + "message": "Watch Party - KoalaSync" }, "appDesc": { - "message": "Synchroniseer video-afspelen op YouTube, Netflix, Emby, Jellyfin en elke HTML5-site in realtime met vrienden." + "message": "Maak privéwatchparty's en kijk synchroon met vrienden op YouTube, Netflix, Jellyfin, Emby en vrijwel elke HTML5-website." } } diff --git a/extension/_locales/pl/messages.json b/extension/_locales/pl/messages.json index e13b068..8f50d12 100644 --- a/extension/_locales/pl/messages.json +++ b/extension/_locales/pl/messages.json @@ -1,8 +1,8 @@ { "appName": { - "message": "KoalaSync" + "message": "Watch Party - KoalaSync" }, "appDesc": { - "message": "Synchronizuj odtwarzanie wideo na YouTube, Netflix, Emby, Jellyfin i dowolnej stronie HTML5 w czasie rzeczywistym ze znajomymi." + "message": "Twórz prywatne wspólne seanse i synchronizuj filmy ze znajomymi na YouTube, Netflix, Jellyfin, Emby i niemal każdej stronie HTML5." } } diff --git a/extension/_locales/pt_BR/messages.json b/extension/_locales/pt_BR/messages.json index 7c3103e..5c2cbee 100644 --- a/extension/_locales/pt_BR/messages.json +++ b/extension/_locales/pt_BR/messages.json @@ -1,8 +1,8 @@ { "appName": { - "message": "KoalaSync" + "message": "Watch Party - KoalaSync" }, "appDesc": { - "message": "Assista a vídeos junto com seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer player HTML5." + "message": "Crie watch parties privadas e sincronize vídeos com amigos no YouTube, Netflix, Jellyfin, Emby e em quase qualquer site HTML5." } } diff --git a/extension/_locales/pt_PT/messages.json b/extension/_locales/pt_PT/messages.json index 506b8a3..d4285a4 100644 --- a/extension/_locales/pt_PT/messages.json +++ b/extension/_locales/pt_PT/messages.json @@ -1,8 +1,8 @@ { "appName": { - "message": "KoalaSync" + "message": "Watch Party - KoalaSync" }, "appDesc": { - "message": "Veja vídeos em conjunto com os seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer leitor HTML5." + "message": "Crie sessões privadas e veja vídeos sincronizados com amigos no YouTube, Netflix, Jellyfin, Emby e em quase qualquer site HTML5." } } diff --git a/extension/_locales/ru/messages.json b/extension/_locales/ru/messages.json index ff73697..5360023 100644 --- a/extension/_locales/ru/messages.json +++ b/extension/_locales/ru/messages.json @@ -1,8 +1,8 @@ { "appName": { - "message": "KoalaSync" + "message": "Watch Party - KoalaSync" }, "appDesc": { - "message": "Синхронизируйте воспроизведение видео на YouTube, Netflix, Emby, Jellyfin и любых HTML5-сайтах в реальном времени с друзьями." + "message": "Создавайте закрытые Watch Party и смотрите синхронно с друзьями на YouTube, Netflix, Jellyfin, Emby и почти любом сайте HTML5." } } diff --git a/extension/_locales/tr/messages.json b/extension/_locales/tr/messages.json index 7641824..236642c 100644 --- a/extension/_locales/tr/messages.json +++ b/extension/_locales/tr/messages.json @@ -1,8 +1,8 @@ { "appName": { - "message": "KoalaSync" + "message": "Watch Party - KoalaSync" }, "appDesc": { - "message": "YouTube, Netflix, Emby, Jellyfin ve herhangi bir HTML5 sitesinde video oynatmayı arkadaşlarınızla gerçek zamanlı olarak senkronize edin." + "message": "Özel izleme partileri kurun; YouTube, Netflix, Jellyfin, Emby ve çoğu HTML5 sitesinde arkadaşlarınızla senkron izleyin." } } diff --git a/extension/_locales/uk/messages.json b/extension/_locales/uk/messages.json index 4e1fea2..8aee6c0 100644 --- a/extension/_locales/uk/messages.json +++ b/extension/_locales/uk/messages.json @@ -1,8 +1,8 @@ { "appName": { - "message": "KoalaSync" + "message": "Watch Party - KoalaSync" }, "appDesc": { - "message": "Синхронізуйте відтворення відео на YouTube, Netflix, Emby, Jellyfin і будь-якому сайті HTML5 у режимі реального часу з друзями." + "message": "Створюйте закриті Watch Party й дивіться синхронно з друзями на YouTube, Netflix, Jellyfin, Emby та майже будь-якому сайті HTML5." } } diff --git a/extension/_locales/zh_CN/messages.json b/extension/_locales/zh_CN/messages.json index 8b40486..6717048 100644 --- a/extension/_locales/zh_CN/messages.json +++ b/extension/_locales/zh_CN/messages.json @@ -1,8 +1,8 @@ { "appName": { - "message": "KoalaSync" + "message": "Watch Party - KoalaSync" }, "appDesc": { - "message": "与朋友实时同步YouTube、Netflix、Emby、Jellyfin和任何HTML5网站上的视频播放。" + "message": "创建私人观影派对,与好友在 YouTube、Netflix、Jellyfin、Emby 及几乎任何 HTML5 视频网站同步观看。" } } diff --git a/extension/manifest.base.json b/extension/manifest.base.json index aa61218..3948b08 100644 --- a/extension/manifest.base.json +++ b/extension/manifest.base.json @@ -1,9 +1,10 @@ { "manifest_version": 3, "default_locale": "en", - "name": "KoalaSync", + "name": "__MSG_appName__", + "short_name": "KoalaSync", "version": "2.6.3", - "description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.", + "description": "__MSG_appDesc__", "permissions": [ "storage", "tabs", diff --git a/scripts/build-extension.cjs b/scripts/build-extension.cjs index b5d9ac6..2fa56ae 100644 --- a/scripts/build-extension.cjs +++ b/scripts/build-extension.cjs @@ -6,6 +6,59 @@ const rootDir = path.join(__dirname, '..'); const extDir = path.join(rootDir, 'extension'); const distDir = path.join(rootDir, 'dist'); const baseManifestPath = path.join(extDir, 'manifest.base.json'); +const localesDir = path.join(extDir, '_locales'); +const requiredAppName = 'Watch Party - KoalaSync'; +const maxAppNameLength = 75; +const maxAppDescLength = 132; + +function countUnicodeCharacters(value) { + return Array.from(value).length; +} + +function validateExtensionMetadata() { + const localeDirectories = fs.readdirSync(localesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + + for (const locale of localeDirectories) { + const messagesPath = path.join(localesDir, locale, 'messages.json'); + let messages; + + try { + messages = JSON.parse(fs.readFileSync(messagesPath, 'utf8')); + } catch (error) { + throw new Error(`CRITICAL: Invalid locale JSON at _locales/${locale}/messages.json: ${error.message}`); + } + + if (!messages || typeof messages !== 'object' || Array.isArray(messages)) { + throw new Error(`CRITICAL: _locales/${locale}/messages.json must contain a JSON object`); + } + + for (const key of ['appName', 'appDesc']) { + if (!messages[key] || typeof messages[key].message !== 'string' || messages[key].message.trim().length === 0) { + throw new Error(`CRITICAL: _locales/${locale}/messages.json is missing a valid ${key}.message`); + } + } + + const appNameLength = countUnicodeCharacters(messages.appName.message); + const appDescLength = countUnicodeCharacters(messages.appDesc.message); + + if (appNameLength > maxAppNameLength) { + throw new Error(`CRITICAL: _locales/${locale}/messages.json appName.message is ${appNameLength} characters; maximum is ${maxAppNameLength}`); + } + if (messages.appName.message !== requiredAppName) { + throw new Error(`CRITICAL: _locales/${locale}/messages.json appName.message must exactly equal "${requiredAppName}"`); + } + if (appDescLength > maxAppDescLength) { + throw new Error(`CRITICAL: _locales/${locale}/messages.json appDesc.message is ${appDescLength} characters; maximum is ${maxAppDescLength}`); + } + } + + console.log(`✓ Validated localized extension metadata for ${localeDirectories.length} locales`); +} + +validateExtensionMetadata(); // Ensure dist directory exists if (fs.existsSync(distDir)) { From 7f898e6eb4c88b88fdc350d2e423eef383163d6b Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 16 Jul 2026 09:51:25 +0000 Subject: [PATCH 16/17] chore(release): update versions to v2.6.4 [skip ci] --- README.md | 4 ++-- extension/manifest.base.json | 2 +- package.json | 2 +- shared/constants.js | 2 +- website/template.html | 2 +- website/version.json | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2b26478..7ca4bfe 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

    Release Status - GitHub release + GitHub release License Firefox Add-on Chrome Extension @@ -14,7 +14,7 @@

    KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on Data Sovereignty and Performance.

    -

    New v2.6.3 Release! — See what's changed

    +

    New v2.6.4 Release! — See what's changed

    ### 🌟 Why KoalaSync? diff --git a/extension/manifest.base.json b/extension/manifest.base.json index 3948b08..901189b 100644 --- a/extension/manifest.base.json +++ b/extension/manifest.base.json @@ -3,7 +3,7 @@ "default_locale": "en", "name": "__MSG_appName__", "short_name": "KoalaSync", - "version": "2.6.3", + "version": "2.6.4", "description": "__MSG_appDesc__", "permissions": [ "storage", diff --git a/package.json b/package.json index 19cceb0..dbe5dd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "koalasync", - "version": "2.6.3", + "version": "2.6.4", "description": "KoalaSync Build Scripts", "private": true, "type": "module", diff --git a/shared/constants.js b/shared/constants.js index 5eccaf9..65e71d7 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -7,7 +7,7 @@ */ export const PROTOCOL_VERSION = "1.0.0"; -export const APP_VERSION = "2.6.3"; +export const APP_VERSION = "2.6.4"; export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net'; export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net'; diff --git a/website/template.html b/website/template.html index 367fd0d..184f424 100644 --- a/website/template.html +++ b/website/template.html @@ -116,7 +116,7 @@ "priceCurrency": "EUR" }, "description": "{{SCHEMA_APP_DESC}}", - "softwareVersion": "2.6.3", + "softwareVersion": "2.6.4", "license": "https://opensource.org/licenses/MIT", "sameAs": "https://github.com/Shik3i/KoalaSync", "image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp", diff --git a/website/version.json b/website/version.json index 8580727..36131eb 100644 --- a/website/version.json +++ b/website/version.json @@ -1,4 +1,4 @@ { - "version": "2.6.3", - "date": "2026-07-15T11:37:32Z" + "version": "2.6.4", + "date": "2026-07-16T09:51:25Z" } From aee3588f43dd978460385a8a153d8aa9d8caeb0a Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:01:21 +0200 Subject: [PATCH 17/17] Document v2.6.4 release --- docs/CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 07f9184..06f9ef4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to the KoalaSync browser extension and relay server. --- +## [v2.6.4] — 2026-07-16 + +### Changed +- **Extension: Localized store metadata** — Renamed the public extension title to `Watch Party - KoalaSync` and added natural, store-ready descriptions for all 15 supported locales while preserving regional language variants. +- **Extension: Locale-based manifest metadata** — The extension name and description now resolve through Chrome/Firefox `_locales` messages instead of being hardcoded in the manifest. + +### Fixed +- **Build: Extension metadata validation** — Extension builds now fail clearly for invalid locale JSON, missing metadata, an incorrect extension title, or store titles and descriptions that exceed their character limits. + ## [v2.6.3] — 2026-07-15 ### Fixed