Compare commits

...

12 Commits

Author SHA1 Message Date
Timo d2d4f5f3ea Localize extension store metadata 2026-07-16 11:50:21 +02:00
GitHub Action 6e625e3a38 chore(release): update versions to v2.6.3 [skip ci] 2026-07-15 11:37:32 +00:00
Timo 6aa2f53e6b Fix access guide spacing and user help link 2026-07-15 13:34:35 +02:00
Timo 2b485fed53 chore(release): update versions to v2.6.3 [skip ci] 2026-07-15 13:34:24 +02:00
Timo fe13275b5d Harden host access recovery races 2026-07-15 13:31:56 +02:00
Timo 61d0d64c7d Remove developer link from user access guide 2026-07-15 13:28:20 +02:00
Timo abcb10a1c3 Scope access help to Chromium rollout 2026-07-15 13:23:54 +02:00
Timo 03121477a8 Add dismissible localized access banner 2026-07-15 13:20:00 +02:00
Timo 285c04e00d Align browser help cards 2026-07-15 13:09:50 +02:00
Timo 1989eb639b Clarify sudden Chrome site access rollout 2026-07-15 13:07:48 +02:00
Timo fd48ce8481 Fix site access banner overlay and immediate guidance 2026-07-15 13:05:24 +02:00
GitHub Action 649c8796a9 chore(release): update versions to v2.6.2 [skip ci] 2026-07-15 10:47:24 +00:00
47 changed files with 852 additions and 323 deletions
+2 -2
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.6.1-blue?logo=github" alt="GitHub release"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.6.3-blue?logo=github" alt="GitHub release"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue" alt="License"></a>
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white" alt="Firefox Add-on"></a>
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
@@ -14,7 +14,7 @@
<p align="center"><i>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 <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.6.1 Release!</b> — See what's changed</a></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.6.3 Release!</b> — See what's changed</a></p>
### 🌟 Why KoalaSync?
+7
View File
@@ -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
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "YouTube、Netflix、Emby、Jellyfin、その他HTML5サイトでの動画再生を友達とリアルタイムで同期します。"
"message": "プライベートなウォッチパーティーを作成し、YouTube、Netflix、Jellyfin、EmbyやほぼすべてのHTML5サイトで友達と動画を同期再生できます。"
}
}
+2 -2
View File
@@ -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 사이트에서 친구들과 영상을 동기화하세요."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "与朋友实时同步YouTube、Netflix、Emby、Jellyfin任何HTML5网站上的视频播放。"
"message": "创建私人观影派对,与好友在 YouTube、Netflix、Jellyfin、Emby 及几乎任何 HTML5 视频网站同步观看。"
}
}
+430 -130
View File
@@ -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('<all_urls>')) {
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
+33 -7
View File
@@ -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);
}
+4 -3
View File
@@ -1,9 +1,10 @@
{
"manifest_version": 3,
"default_locale": "en",
"name": "KoalaSync",
"version": "2.6.1",
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
"name": "__MSG_appName__",
"short_name": "KoalaSync",
"version": "2.6.3",
"description": "__MSG_appDesc__",
"permissions": [
"storage",
"tabs",
+8 -72
View File
@@ -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(() => {});
}
});
}
+103 -24
View File
@@ -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') {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "koalasync",
"version": "2.6.1",
"version": "2.6.3",
"description": "KoalaSync Build Scripts",
"private": true,
"type": "module",
+53
View File
@@ -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)) {
+63
View File
@@ -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');
+1 -1
View File
@@ -7,7 +7,7 @@
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "2.6.1";
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';
+8
View File
@@ -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',
+1
View File
@@ -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 <span class='hero-highlight'>Watch Party</span>.",
"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",
+1
View File
@@ -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 <span class='hero-highlight'>watch party</span>.",
"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",
+1
View File
@@ -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 <span class='hero-highlight'>watch party</span>.",
"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",
+1
View File
@@ -16,6 +16,7 @@
"NAV_HOW_IT_WORKS": "Comment ça marche",
"SUPPORT_BANNER_TEXT": "KoalaSync naccède pas à votre site vidéo ?",
"SUPPORT_BANNER_CTA": "Réparer laccès au site",
"SUPPORT_BANNER_DISMISS": "Fermer lavertissement",
"HERO_TITLE": "Transformez n'importe quelle vidéo en <span class='hero-highlight'>watch party</span>.",
"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",
+1
View File
@@ -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 laccesso al sito",
"SUPPORT_BANNER_DISMISS": "Chiudi avviso",
"HERO_TITLE": "Trasforma qualsiasi video in una <span class='hero-highlight'>watch party</span>.",
"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",
+1
View File
@@ -16,6 +16,7 @@
"NAV_HOW_IT_WORKS": "仕組み",
"SUPPORT_BANNER_TEXT": "KoalaSync が動画サイトにアクセスできませんか?",
"SUPPORT_BANNER_CTA": "サイトアクセスを修復",
"SUPPORT_BANNER_DISMISS": "通知を閉じる",
"HERO_TITLE": "どんな動画も<span class='hero-highlight'>ウォッチパーティー</span>に。",
"HERO_SUBTITLE": "離れていても一緒に映画ナイト。アカウント不要、トラッキングなし、広告なし。リンクを共有して、一緒に再生するだけ。",
"HERO_MASCOT_ALT": "ダウンロードボタンを見下ろしているかわいいコアラ",
+1
View File
@@ -16,6 +16,7 @@
"NAV_HOW_IT_WORKS": "작동 방식",
"SUPPORT_BANNER_TEXT": "KoalaSync가 동영상 사이트에 액세스할 수 없나요?",
"SUPPORT_BANNER_CTA": "사이트 액세스 복구",
"SUPPORT_BANNER_DISMISS": "알림 닫기",
"HERO_TITLE": "어떤 영상이든 <span class='hero-highlight'>워치 파티</span>로.",
"HERO_SUBTITLE": "지연 없는 원격 영화의 밤. 계정 없음, 추적 없음, 광고 없음. 링크만 공유하고 함께 재생하세요.",
"HERO_MASCOT_ALT": "다운로드 버튼을 내려다보고 서 있는 귀여운 코알라",
+1
View File
@@ -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 <span class='hero-highlight'>watch party</span>.",
"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",
+1
View File
@@ -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 <span class='hero-highlight'>watch party</span>.",
"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",
+1
View File
@@ -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 <span class='hero-highlight'>watch party</span>.",
"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",
+1
View File
@@ -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 <span class='hero-highlight'>watch party</span>.",
"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",
+1
View File
@@ -16,6 +16,7 @@
"NAV_HOW_IT_WORKS": "Как это работает",
"SUPPORT_BANNER_TEXT": "KoalaSync не может получить доступ к сайту с видео?",
"SUPPORT_BANNER_CTA": "Исправить доступ к сайту",
"SUPPORT_BANNER_DISMISS": "Закрыть уведомление",
"HERO_TITLE": "Преврати любое видео в <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "Ваш киновечер на расстоянии, без задержек. Без аккаунта, без слежки, без рекламы. Просто поделитесь ссылкой и нажмите play вместе.",
"HERO_MASCOT_ALT": "Милый коала стоит и смотрит вниз на кнопки загрузки",
+1
View File
@@ -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 <span class='hero-highlight'>watch party</span>'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",
+1
View File
@@ -16,6 +16,7 @@
"NAV_HOW_IT_WORKS": "Як це працює",
"SUPPORT_BANNER_TEXT": "KoalaSync не має доступу до сайту з відео?",
"SUPPORT_BANNER_CTA": "Виправити доступ до сайту",
"SUPPORT_BANNER_DISMISS": "Закрити сповіщення",
"HERO_TITLE": "Перетвори будь-яке відео на <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "Ваш кіновечір на відстані, без затримок. Без акаунта, без стеження, без реклами. Просто поділіться посиланням і натисніть play разом.",
"HERO_MASCOT_ALT": "Мила коала стоїть і дивиться вниз на кнопки завантаження",
+1
View File
@@ -16,6 +16,7 @@
"NAV_HOW_IT_WORKS": "它是如何运作的",
"SUPPORT_BANNER_TEXT": "KoalaSync 无法访问视频网站?",
"SUPPORT_BANNER_CTA": "修复网站访问权限",
"SUPPORT_BANNER_DISMISS": "关闭提示",
"HERO_TITLE": "把任意视频变成<span class='hero-highlight'>一起看派对</span>。",
"HERO_SUBTITLE": "远程电影之夜,毫无延迟。无需账号、无跟踪、无广告。分享链接,一起播放。",
"HERO_MASCOT_ALT": "一只可爱的考拉站着,低头看着下载按钮",
+31 -42
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KoalaSync Cannot Access Your Video Site? Fix Website Access</title>
<meta name="description" content="Restore KoalaSync website access in Chrome, Edge, Brave, Opera, or Firefox when a video tab cannot be linked or controlled.">
<meta name="description" content="Restore KoalaSync website access immediately in Chrome, Edge, Brave, Opera, or Vivaldi when a video tab cannot be linked or controlled.">
<meta name="robots" content="index, follow">
<link rel="preload" href="style.min.css" as="style">
<link rel="stylesheet" href="style.min.css">
@@ -63,62 +63,51 @@
<main id="main-content" class="support-content">
<header class="support-hero">
<span class="support-status">Website access troubleshooting</span>
<h1>Connected, but the video tab does nothing?</h1>
<p class="support-lead">Your browser may be withholding KoalaSyncs access to that website. The room connection can still look healthy while KoalaSync cannot read or control the player.</p>
<span class="support-status">KoalaSync cannot see your video</span>
<h1>KoalaSync opens, but your video does nothing?</h1>
<p class="support-lead">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.</p>
</header>
<section class="support-card" aria-labelledby="quick-fix-title">
<span class="support-eyebrow">Start here</span>
<h2 id="quick-fix-title">Use the built-in one-click fix</h2>
<span class="support-eyebrow">Fix it now — no update required</span>
<h2 id="quick-fix-title">Turn KoalaSync back on for the video website</h2>
<ol class="support-steps">
<li>Open the video website and then open the KoalaSync popup.</li>
<li>Select the video tab. If access is blocked, KoalaSync shows an access notice instead of silently pretending the tab is connected.</li>
<li>Select <strong>Allow access</strong> and approve the browser prompt. KoalaSync retries the tab automatically.</li>
<li>If the prompt does not appear, use the manual browser steps below and reload the video page once.</li>
<li>Leave the video website open.</li>
<li>Look at the top-right of the browser, next to the long box that shows the website address. Click the <strong>Extensions</strong> button (usually a puzzle-piece icon). If you cannot see it, open the three-dot menu first and choose <strong>Extensions</strong>.</li>
<li>Look for <strong>Access requested</strong>. Click <strong>KoalaSync</strong> underneath it to allow access.</li>
<li>If you do not see <strong>Access requested</strong>, find KoalaSync in the same menu, open its three-dot menu, choose <strong>This can read and change site data</strong>, then choose <strong>On this site</strong>. You can also choose <strong>On all sites</strong> if you want KoalaSync to work automatically on every supported website.</li>
<li>Return to the video and click the browsers reload button (the circular arrow).</li>
<li>Open KoalaSync and select the video tab again.</li>
</ol>
<p>You do not need to wait for a KoalaSync update. These steps work immediately.</p>
<p><a class="support-link" href="https://support.google.com/chrome/answer/2664769?hl=en#:~:text=Let%20extensions%20read%20and%20change%20site%20data" target="_blank" rel="noopener">Open Googles official step-by-step instructions for changing an extensions website access</a></p>
</section>
<div class="support-callout" role="note">
<span class="support-callout-icon" aria-hidden="true">i</span>
<div>
<strong>This is not a new KoalaSync permission.</strong>
<p>Modern browsers let users, profiles, and administrators withhold extension access per website. Chromes 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.</p>
<strong>KoalaSync did not add a new kind of permission.</strong>
<p>Chrome changed how it handles an extensions 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.</p>
</div>
</div>
<div class="support-grid">
<section class="support-card" aria-labelledby="chromium-title">
<span class="support-eyebrow">Chrome, Edge, Brave, Opera</span>
<h2 id="chromium-title">Allow KoalaSync on the current site</h2>
<ol>
<li>Keep the affected video tab open.</li>
<li>Open the browsers extensions menu near the address bar.</li>
<li>Under <strong>Access requested</strong>, select KoalaSync and allow access.</li>
<li>If needed, open KoalaSyncs extension details and change site access to <strong>On this site</strong> or <strong>On all sites</strong>.</li>
<li>Reload the video tab and select it again in KoalaSync.</li>
</ol>
<p><a class="support-link" href="https://support.google.com/chrome/answer/2664769?hl=en" target="_blank" rel="noopener">Googles official site-access instructions</a></p>
</section>
<section class="support-card" aria-labelledby="firefox-title">
<span class="support-eyebrow">Firefox</span>
<h2 id="firefox-title">Check extension permissions</h2>
<ol>
<li>Open <strong>Add-ons and themes</strong>, then select <strong>Extensions</strong>.</li>
<li>Open KoalaSync and select <strong>Permissions</strong> or <strong>Permissions and data</strong>.</li>
<li>Allow the requested website access.</li>
<li>Return to the video site, reload it, and select the tab again.</li>
</ol>
<p><a class="support-link" href="https://support.mozilla.org/en-US/kb/manage-optional-permissions-extensions" target="_blank" rel="noopener">Mozillas official extension-permissions instructions</a></p>
</section>
</div>
<section class="support-card" aria-labelledby="chromium-title">
<span class="support-eyebrow">Chrome, Edge, Brave, Opera, Vivaldi</span>
<h2 id="chromium-title">Where to click</h2>
<ol>
<li>Keep the video tab open so the browser knows which website you want to allow.</li>
<li>Open the <strong>Extensions</strong> menu at the top-right of the browser.</li>
<li>Under <strong>Access requested</strong>, click <strong>KoalaSync</strong>.</li>
<li>If your browser shows the older menu, open KoalaSyncs three-dot menu and choose <strong>This can read and change site data</strong><strong>On this site</strong>.</li>
<li>Reload the video page, open KoalaSync, and select that tab again.</li>
</ol>
</section>
<section class="support-card" aria-labelledby="why-title">
<span class="support-eyebrow">Why access is needed</span>
<h2 id="why-title">KoalaSync must reach the player you selected</h2>
<p>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.</p>
<p>Access is requested for the website origin shown in the popup. Browser-internal pages such as <strong>chrome://</strong>, <strong>edge://</strong>, and <strong>about:</strong> cannot be controlled. Local files may also require the separate <strong>Allow access to file URLs</strong> switch in the extension details.</p>
<span class="support-eyebrow">Why this suddenly happened</span>
<h2 id="why-title">Chrome changed this without a KoalaSync update</h2>
<p>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.</p>
<p>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.</p>
</section>
<section class="support-card" aria-labelledby="still-title">
+45 -4
View File
@@ -93,15 +93,23 @@
}
.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 {
position: relative;
min-height: 50px;
display: flex;
align-items: center;
@@ -111,6 +119,8 @@
font-size: 0.9rem;
font-weight: 650;
text-align: center;
padding-right: 4rem;
padding-left: 4rem;
}
.site-access-banner-icon {
@@ -141,14 +151,41 @@
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 {
margin-top: 76px;
top: 73px;
}
nav.nav-scrolled + .site-access-banner {
top: 59px;
}
.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;
@@ -159,6 +196,10 @@
height: 1.3rem;
font-size: 0.75rem;
}
.site-access-banner-dismiss {
right: 0.75rem;
}
}
* {
+8 -2
View File
@@ -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 {
@@ -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;
+8 -3
View File
@@ -116,7 +116,7 @@
"priceCurrency": "EUR"
},
"description": "{{SCHEMA_APP_DESC}}",
"softwareVersion": "2.6.1",
"softwareVersion": "2.6.3",
"license": "https://opensource.org/licenses/MIT",
"sameAs": "https://github.com/Shik3i/KoalaSync",
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
@@ -210,7 +210,7 @@
</style>
</noscript>
</head>
<body class="landing-with-support-banner">
<body>
<div class="scroll-progress-bar"></div>
<a class="skip-link" href="#main-content">Skip to main content</a>
@@ -366,8 +366,13 @@
<span id="site-access-banner-text">{{SUPPORT_BANNER_TEXT}}</span>
<a href="/site-access-help.html" class="site-access-banner-link">
<span>{{SUPPORT_BANNER_CTA}}</span>
<span aria-hidden="true"></span>
</a>
<button class="site-access-banner-dismiss" type="button" aria-label="{{SUPPORT_BANNER_DISMISS}}">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<line x1="6" y1="6" x2="18" y2="18" />
<line x1="18" y1="6" x2="6" y2="18" />
</svg>
</button>
</div>
</aside>
+2 -2
View File
@@ -1,4 +1,4 @@
{
"version": "2.6.1",
"date": "2026-07-15T03:57:56Z"
"version": "2.6.3",
"date": "2026-07-15T11:37:32Z"
}