feat(host-control-mode): explicit server capabilities for feature detection

Replace the implicit "hostPeerId present ⇒ feature supported" heuristic with an
explicit capabilities list the relay advertises in ROOM_DATA. Cleaner, self-
documenting, and the extensible hook for the planned co-host feature (owner
promotes guests to extra controllers) — a future 'co-host' capability + events
slot in without a protocol bump.

- shared: CAPABILITIES { HOST_CONTROL }.
- server: SERVER_CAPABILITIES advertised in ROOM_DATA.
- background: track serverCapabilities (empty against older relay), serverSupports(),
  thread hostControlSupported through GET_STATUS / GET_CONTROL_MODE / CONTROL_MODE.
- popup: gate the host-control card on the explicit capability instead of hostPeerId.

Backwards-compatible both ways: old relay omits the field → feature stays hidden
(no errors); old client ignores the field. WS test asserts ROOM_DATA advertises
the capability. Adds docs/host-control-mode-TESTING.md (beta-server setup, wss
caveat, two-new-clients note, verification checklist).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
KoalaDev
2026-06-27 04:48:45 +02:00
parent 9feafab617
commit 9963da2ebc
7 changed files with 143 additions and 14 deletions
+10
View File
@@ -24,6 +24,16 @@ reading the code, not yet by watching them behave on each site.
Deferred by decision (see §8): host grace period on disconnect (EC-10).
### Capability detection (forward-compat hook)
The relay advertises `capabilities: ['host-control']` in `ROOM_DATA`
(`SERVER_CAPABILITIES` in server, `CAPABILITIES` in shared/constants). The client
enables host-control UI/behavior only when the flag is present, so the feature
degrades cleanly on an older relay (absent → off) and old clients ignore the
field. This is the extensible hook for the planned **co-host** feature (owner
promotes guests to additional controllers): it will add a `'co-host'` capability
+ events without a protocol bump or breaking older relays/clients. Add new flags
to `CAPABILITIES` / `SERVER_CAPABILITIES` as features land.
### Pre-test self-audit (fixed)
- **Popup remote buttons froze for guests** — in host-only a guest's Play/Pause/SYNC
click was gated server-side but the button stuck on "Playing"/disabled with no
+83
View File
@@ -0,0 +1,83 @@
# Host Control Mode — Beta Testing Guide
> Temporary doc for branch `feature/host-control-mode`. Remove before merge.
> The feature needs the **server** half too — the official relay
> (`wss://syncserver.koalastuff.net`) doesn't run it yet, so test against a beta
> server first.
## 1. Run the beta relay (Docker)
The branch publishes the server image to GHCR under non-production tags
(`:beta` = newest branch build, `:sha-<commit>` = immutable pin). `:latest` is
never touched.
```bash
# Log in (the package is private → PAT with read:packages)
echo "$GHCR_PAT" | docker login ghcr.io -u <your-gh-user> --password-stdin
# Pull + (re)create — NOTE: `docker restart` does NOT pick up a new image,
# you must remove and re-run (or use compose / Watchtower).
docker pull ghcr.io/shik3i/koalasync:beta
docker rm -f koala-beta 2>/dev/null || true
docker run -d --name koala-beta -p 3000:3000 \
-e SERVER_SALT='choose-your-own-salt' \
ghcr.io/shik3i/koalasync:beta
```
To always run the newest beta automatically, point **Watchtower** at the
container — it does the pull → recreate whenever `:beta` moves.
The `OFFICIAL_SERVER_TOKEN` is baked into `shared/constants.js`, so no token env
is needed. Set `SERVER_SALT` (used for room-password hashing).
## 2. Connect the extension to it
⚠️ The client **force-upgrades `ws://` to `wss://` for any non-localhost host**
(see background.js "Upgraded to wss:// for remote host"). So a bare
`ws://your-server:3000` will fail without TLS. Two options:
- **Quick (no TLS):** SSH-tunnel the port so it counts as local:
```bash
ssh -L 3000:localhost:3000 your-beta-server
```
then in the popup → **Manual Connect / Advanced → Custom →** `ws://localhost:3000`.
- **Proper:** put the container behind a TLS reverse proxy (Caddy does automatic
HTTPS) → `wss://beta.yourdomain`.
Then create/join a room.
## 3. ⚠️ Use two *new* clients
Test with **two browser profiles both running this branch build** (load unpacked
from `extension/`, or install the built zip). A stock release client as a guest
will be correctly gated by the server but has none of the content-side code, so
it silently desyncs with no dialog/snap-back — that's expected degradation, not a
bug, but it looks like one during testing.
## 4. Verification checklist
| # | Step | Expect |
|---|------|--------|
| 1 | Create a room (host) | Host Control card shows **Host** + the toggle |
| 2 | Second profile joins | Guest sees no card while mode is "everyone" |
| 3 | Host enables "Only I can control" | Guest's Play/Pause/SYNC buttons lock; card shows **Guest** |
| 4 | Guest presses pause/space on the video | Brief flicker, snaps back to host position |
| 5 | Guest pauses again | Dialog: "Stay in sync" / "Watch on my own" |
| 6 | Guest → "Watch on my own" | Persistent "Solo" badge; host's peer list shows **Solo** |
| 7 | Guest → "Resync" | Snaps back to host; Solo badge clears on both sides |
| 8 | Guest tries Force-Sync / seek spam | Nothing propagates to the room |
| 9 | Host disables host-only | Card hides for guest; controls unlock |
| 10 | Host leaves the room | Room falls back to "everyone"; a remaining peer becomes host |
| 11 | Reload the guest's page while desynced | Still shows Solo (state survives reload) |
| 12 | Switch the popup language | Dialog/badge text is localized |
## 5. Capability detection
The relay advertises `capabilities: ['host-control']` in `ROOM_DATA`. The client
only enables the feature when that flag is present, so:
- against this beta server → feature on;
- against the old official server → feature cleanly hidden (no errors).
This is the extensible hook for the planned **co-host** feature (owner promotes
guests to additional controllers) — it'll add a `'co-host'` capability + events
without breaking older relays/clients.
+15 -5
View File
@@ -1,4 +1,4 @@
import { EVENTS, CONTROL_MODES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
import { EVENTS, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
import { generateUsername } from './shared/names.js';
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
import { sameEpisode } from './episode-utils.js';
@@ -74,6 +74,10 @@ const lastSeqBySender = {}; // senderId → last received seq (sta
// --- Host Control Mode ---
let controlMode = CONTROL_MODES.EVERYONE; // 'everyone' | 'host-only'
let hostPeerId = null; // peerId of the room host (creator / fallback)
// Features the connected relay advertises in ROOM_DATA. Empty against an older
// relay (no capabilities field) → host-control UI/behavior stays unavailable.
let serverCapabilities = [];
function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); }
// Local peer's desync state (content.js reports it via HCM_DESYNC_STATE). Relayed
// in heartbeats so the host's popup UI can show "Solo" instead of silently
// appearing un-ACK'd.
@@ -153,9 +157,10 @@ function ensureState() {
if (data.history) history = [...history, ...data.history].slice(0, 20);
if (data.currentRoom) {
currentRoom = data.currentRoom;
// Host Control Mode: restore role/mode from persisted room.
// Host Control Mode: restore role/mode/capabilities from persisted room.
controlMode = currentRoom.controlMode || CONTROL_MODES.EVERYONE;
hostPeerId = currentRoom.hostPeerId || null;
serverCapabilities = Array.isArray(currentRoom.capabilities) ? currentRoom.capabilities : [];
}
if (data.hcmDesynced !== undefined) hcmDesynced = data.hcmDesynced;
if (data.lastActionState) lastActionState = data.lastActionState;
@@ -472,6 +477,7 @@ async function leaveRoomAfterIdleGrace(reason) {
currentRoom = null;
controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null;
serverCapabilities = [];
hcmDesynced = false;
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
@@ -695,7 +701,7 @@ function hcmEnforceDesyncInvariant() {
function broadcastControlMode() {
// Notify popup (role badge / host toggle) and the active content tab
// (so it can enable/disable the host-only guest gate).
const payload = { type: 'CONTROL_MODE', controlMode, hostPeerId, amHost: amHost() };
const payload = { type: 'CONTROL_MODE', controlMode, hostPeerId, amHost: amHost(), hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL) };
chrome.runtime.sendMessage(payload).catch(() => {});
if (currentTabId) {
const tabId = parseInt(currentTabId);
@@ -955,6 +961,7 @@ function handleServerEvent(event, data) {
// Host Control Mode: adopt room role/mode on (re)join.
controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
hostPeerId = data.hostPeerId || null;
serverCapabilities = Array.isArray(data.capabilities) ? data.capabilities : [];
hcmEnforceDesyncInvariant();
broadcastControlMode();
markRoomPotentiallyIdle();
@@ -1588,6 +1595,7 @@ function leaveOldRoomIfSwitching(newRoomId) {
currentRoom = null;
controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null;
serverCapabilities = [];
hcmDesynced = false;
// Notify content.js/popup so they drop any guest-side HCM state from the
// previous room (badge/dialog/desync) — H-2/H-3.
@@ -1706,7 +1714,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
ping: currentPingMs,
controlMode,
hostPeerId,
amHost: amHost()
amHost: amHost(),
hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL)
});
} else if (message.type === 'SET_CONTROL_MODE') {
// Popup (host) toggles the room control mode. Server validates host authority
@@ -1727,7 +1736,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
// persisted desync state so a page reload re-adopts it — otherwise a fresh
// content script would start synced while background keeps relaying us as
// "Solo" to the host (stale-badge split-brain).
sendResponse({ controlMode, hostPeerId, amHost: amHost(), desynced: hcmDesynced });
sendResponse({ controlMode, hostPeerId, amHost: amHost(), desynced: hcmDesynced, hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL) });
} else if (message.type === 'REQUEST_HOST_SYNC') {
// content.js resync: hand back the host's extrapolated current position.
sendResponse({ target: getHostSyncTarget() });
@@ -1768,6 +1777,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
currentRoom = null;
controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null;
serverCapabilities = [];
hcmDesynced = false;
// Notify content.js/popup BEFORE currentTabId is cleared so they drop any
// stale guest-side HCM state (dialog/badge/desync) — H-2/H-3.
+7 -7
View File
@@ -249,7 +249,7 @@ async function init() {
updatePingDisplay(res.ping);
updatePeerList(res.peers);
lastKnownPeers = res.peers || [];
updateHostControlUI(res.controlMode, res.amHost, res.hostPeerId, res.status === 'connected');
updateHostControlUI(res.controlMode, res.amHost, res.hostControlSupported, res.status === 'connected');
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
// If user has a room configured but background is not connected (disconnected or idle),
@@ -313,14 +313,14 @@ function toggleUIState(inRoom) {
// True when we're a guest in a host-only room → remote-control buttons are locked.
let hcmGuestLocked = false;
function updateHostControlUI(controlMode, amHost, hostPeerId, inRoom) {
function updateHostControlUI(controlMode, amHost, hostControlSupported, inRoom) {
const card = elements.hostControlCard;
if (!card) return;
const hostOnly = controlMode === 'host-only';
// The server only sends hostPeerId once it supports host control. Against an
// older relay it's absent → the feature is unavailable, so hide the card
// Explicit capability advertised by the relay in ROOM_DATA. Against an older
// relay it's false/absent → the feature is unavailable, so hide the card
// entirely instead of showing a misleading "Guest".
const serverSupportsHostControl = !!hostPeerId;
const serverSupportsHostControl = !!hostControlSupported;
// Only show the card when it's actually meaningful:
// - host: always (so they can enable/disable host-only)
// - guest: only while host-only is active (explains why they can't control)
@@ -1745,7 +1745,7 @@ chrome.runtime.onMessage.addListener((msg) => {
if (msg.peers) detectPeerChanges(msg.peers);
} else if (msg.type === 'CONTROL_MODE') {
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
updateHostControlUI(msg.controlMode, msg.amHost, msg.hostPeerId, inRoom);
updateHostControlUI(msg.controlMode, msg.amHost, msg.hostControlSupported, inRoom);
} else if (msg.type === 'CONNECTION_STATUS') {
if (msg.status === 'connected' || msg.status === 'disconnected') {
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
@@ -1763,7 +1763,7 @@ chrome.runtime.onMessage.addListener((msg) => {
if (res.peers) updatePeerList(res.peers);
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
updatePingDisplay(res.ping);
updateHostControlUI(res.controlMode, res.amHost, res.hostPeerId, true);
updateHostControlUI(res.controlMode, res.amHost, res.hostControlSupported, true);
});
}
if (msg.status === 'disconnected') {
+10
View File
@@ -63,6 +63,16 @@ try {
close();
resetConnectionRate();
// --- Capabilities: ROOM_DATA advertises server features for client detection ---
const capClient = await c();
s(capClient, 'join_room', { roomId: 'cap-'+Date.now(), peerId: 'capp', protocolVersion: '1.0.0' });
const [capEv, capData] = await a(capClient);
assert.equal(capEv, 'room_data');
assert.ok(Array.isArray(capData.capabilities) && capData.capabilities.includes('host-control'),
'ROOM_DATA advertises the host-control capability');
close();
resetConnectionRate();
// --- Host Control Mode ---
const hrid = 'host-'+Date.now();
const h1 = await c(), h2 = await c(); // h1 = host (first joiner), h2 = guest
+8 -2
View File
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
import { Server } from 'socket.io';
import crypto from 'crypto';
import dotenv from 'dotenv';
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES } from '../shared/constants.js';
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
import {
buildHealthPayload,
checkCooldown,
@@ -161,6 +161,11 @@ const HOST_ONLY_GATED_EVENTS = new Set([
EVENTS.EPISODE_LOBBY_CANCEL
]);
// Features this relay supports, advertised to clients in ROOM_DATA so they can
// enable matching UI/behavior only when the server actually backs it. Append a
// flag here when a new server-gated feature ships (e.g. co-host promotion).
const SERVER_CAPABILITIES = [CAPABILITIES.HOST_CONTROL];
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
// from generating one broadcast per toggle across all peers.
@@ -454,7 +459,8 @@ io.on('connection', (socket) => {
peers: Array.from(room.peers).map(sid => room.peerData.get(sid)),
activeLobby: room.activeLobby || null,
hostPeerId: room.hostPeerId || null,
controlMode: room.controlMode || CONTROL_MODES.EVERYONE
controlMode: room.controlMode || CONTROL_MODES.EVERYONE,
capabilities: SERVER_CAPABILITIES
});
log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`);
} finally {
+10
View File
@@ -69,6 +69,16 @@ export const CONTROL_MODES = {
HOST_ONLY: 'host-only' // only the host drives the room
};
// Server feature capabilities, advertised to clients in ROOM_DATA. Lets a client
// detect what the relay actually supports instead of inferring it from the
// presence of a data field — so new server features degrade cleanly on older
// relays (unknown/absent list → feature treated as unavailable) and old clients
// simply ignore the field. Add a flag here as each server-gated feature lands.
export const CAPABILITIES = {
HOST_CONTROL: 'host-control'
// Future: CO_HOST: 'co-host' // owner promotes guests to additional controllers
};
export const HEARTBEAT_INTERVAL = 15000; // 15s
export const FORCE_SYNC_TIMEOUT = 8500; // 8.5s timeout for force sync ACKs (must be > content.js poll timeout of 8s)
export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby