diff --git a/docs/host-control-mode-EDGECASES.md b/docs/host-control-mode-EDGECASES.md index ebb425d..1233015 100644 --- a/docs/host-control-mode-EDGECASES.md +++ b/docs/host-control-mode-EDGECASES.md @@ -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 diff --git a/docs/host-control-mode-TESTING.md b/docs/host-control-mode-TESTING.md new file mode 100644 index 0000000..7ad63e0 --- /dev/null +++ b/docs/host-control-mode-TESTING.md @@ -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-` = 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 --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. diff --git a/extension/background.js b/extension/background.js index f280fe8..d9fa4fa 100644 --- a/extension/background.js +++ b/extension/background.js @@ -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. diff --git a/extension/popup.js b/extension/popup.js index 27facaf..63da0f4 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -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') { diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index 1e87365..6f0fd4a 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -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 diff --git a/server/index.js b/server/index.js index 548c74c..678f6af 100644 --- a/server/index.js +++ b/server/index.js @@ -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 { diff --git a/shared/constants.js b/shared/constants.js index f6016cc..2281098 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -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