fix(extension): harden target and chat recovery

This commit is contained in:
Timo
2026-08-17 21:44:41 +02:00
parent bce582b568
commit 8eaf026487
5 changed files with 49 additions and 24 deletions
+22
View File
@@ -4,6 +4,28 @@ All notable changes to the KoalaSync browser extension and relay server.
---
## [v3.1.3] — Unreleased
This release hardens target selection and chat visibility across player-frame
recovery, failed tab switches and short connection interruptions.
### Fixed
- **Extension: Atomic target switching** — Keeps the previously working target
active until the newly selected tab has been injected successfully, so a
failed selection cannot leave the room without a target.
- **Extension: Target recovery** — Keeps the selected tab recoverable when a
transient content-script or embedded-player refresh fails.
- **Extension: Chat visibility persistence** — Remembers the user's manual
open/closed state across popup reopen, content refresh and reconnect cycles.
### Testing
- **Release gate** — Unit, server/WebSocket, locale, theme, lint, production
dependency audit, Chrome/Firefox build, AMO validation and website build
pass locally.
- **Browser E2E** — 35 extension and player lifecycle scenarios pass.
---
## [v3.1.2] — 2026-08-17
This release adds generic control for HTML5 players inside cross-origin frames.
+20 -21
View File
@@ -2658,9 +2658,6 @@ async function activateTargetTab(tabId, tabTitle, {
let injectedContentTarget = { frameId: 0, documentId: null, hasVideo: false };
try {
if (previousTabId && previousTabId !== selectedTabId) {
await deactivateTargetTab(previousTabId);
}
if (activationGeneration !== targetActivationGeneration) {
return { status: 'superseded' };
}
@@ -2696,26 +2693,26 @@ async function activateTargetTab(tabId, tabTitle, {
);
throw error;
}
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
const failedContentTarget = error?.contentTarget || injectedContentTarget;
await deactivateTargetTab(selectedTabId, failedContentTarget);
if (previousTabId && (previousTabId !== selectedTabId
|| !sameContentTarget(previousContentTarget, failedContentTarget))) {
await deactivateTargetTab(previousTabId, previousContentTarget);
if (previousTabId === null) {
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
await chrome.storage.session.set({
currentTabId: null,
currentTabTitle: null,
currentTargetFrameId: 0,
currentTargetDocumentId: null,
currentTargetHasVideo: false,
roomIdleSince,
lastContentHeartbeatAt: null
});
} else {
addLog(`Target switch to tab ${selectedTabId} failed; keeping tab ${previousTabId} selected`, 'warn');
}
await chrome.storage.session.set({
currentTabId: null,
currentTabTitle: null,
currentTargetFrameId: 0,
currentTargetDocumentId: null,
currentTargetHasVideo: false,
roomIdleSince,
lastContentHeartbeatAt: null
});
if (activationGeneration !== targetActivationGeneration) {
return { status: 'superseded' };
}
@@ -2765,7 +2762,9 @@ async function activateTargetTab(tabId, tabTitle, {
if (currentTabId !== selectedTabId) await deactivateTargetTab(selectedTabId, injectedContentTarget);
return { status: 'superseded' };
}
if (previousTabId === selectedTabId
if (previousTabId && previousTabId !== selectedTabId) {
await deactivateTargetTab(previousTabId, previousContentTarget);
} else if (previousTabId === selectedTabId
&& !sameContentTarget(previousContentTarget, injectedContentTarget)) {
await deactivateTargetTab(previousTabId, previousContentTarget, { deactivateMonitor: false });
}
+2
View File
@@ -181,6 +181,8 @@ describe('chat overlay contract', () => {
expect(overlaySource).toContain('setOpened(false, false)');
expect(overlaySource).toContain('setOpened(lastUserOpenState ?? (chatStartMode === \'open\'), false)');
expect(overlaySource).toContain('typeof data[openStateKey] === \'boolean\'');
expect(overlaySource).toContain('const previousEnabled = context?.enabled === true');
expect(overlaySource).toContain('(!startStateApplied || !previousEnabled)');
});
it('keeps chat hidden by default without discarding the room chat key', () => {
+2 -1
View File
@@ -596,6 +596,7 @@
function applyContext(next) {
const previousRoomId = context?.roomId;
const previousEnabled = context?.enabled === true;
context = next || null;
const supported = !!context?.supported;
const optedIn = !!context?.enabled;
@@ -611,7 +612,7 @@
if (!optedIn) startStateApplied = false;
if (!context?.enabled) {
setOpened(false, false);
} else if (preferencesLoaded && !startStateApplied) {
} else if (preferencesLoaded && (!startStateApplied || !previousEnabled)) {
startStateApplied = true;
setOpened(lastUserOpenState ?? (chatStartMode === 'open'), false);
}
+3 -2
View File
@@ -22,9 +22,10 @@ describe('target tab lifecycle', () => {
const activationStart = backgroundSource.indexOf('async function activateTargetTab');
const activationEnd = backgroundSource.indexOf('async function reactivateCurrentTarget', activationStart);
const activationSource = backgroundSource.slice(activationStart, activationEnd);
expect(activationSource.indexOf('await deactivateTargetTab(previousTabId)'))
.toBeLessThan(activationSource.indexOf('await injectContentScript(selectedTabId'));
expect(activationSource.indexOf('await injectContentScript(selectedTabId'))
.toBeLessThan(activationSource.indexOf('await deactivateTargetTab(previousTabId, previousContentTarget)'));
expect(activationSource).toContain('previousTabId !== selectedTabId');
expect(activationSource).toContain('keeping tab ${previousTabId} selected');
expect(contentSource).toContain('if (window.koalaSyncInjected && chrome.runtime.id)');
expect(overlaySource).toContain('if (window.koalaSyncChatOverlay?.refresh)');
});