diff --git a/.github/workflows/auto-build.yml b/.github/workflows/auto-build.yml index ff1104b6..12f8121a 100644 --- a/.github/workflows/auto-build.yml +++ b/.github/workflows/auto-build.yml @@ -112,7 +112,7 @@ jobs: release: name: publish-release - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: blacksmith-4vcpu-ubuntu-2404 needs: build if: | github.ref == 'refs/heads/main' || diff --git a/opennow-stable/src/main/gfn/auth.ts b/opennow-stable/src/main/gfn/auth.ts index d4001458..52597a50 100644 --- a/opennow-stable/src/main/gfn/auth.ts +++ b/opennow-stable/src/main/gfn/auth.ts @@ -26,7 +26,7 @@ const USERINFO_ENDPOINT = "https://login.nvidia.com/userinfo"; const AUTH_ENDPOINT = "https://login.nvidia.com/authorize"; const CLIENT_ID = "ZU7sPN-miLujMD95LfOQ453IB0AtjM8sMyvgJ9wCXEQ"; -const SCOPES = "openid consent email tk_client age"; +const SCOPES = "openid consent email tk_client age offline_access"; const DEFAULT_IDP_ID = "PDiAhv2kJTFeQ7WOPqiQ2tRZ7lGhR2X11dXvM4TZSxg"; const GFN_USER_AGENT = @@ -275,6 +275,7 @@ async function refreshAuthTokens(refreshToken: string): Promise { grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID, + scope: SCOPES, }); const response = await fetch(TOKEN_ENDPOINT, { @@ -294,6 +295,51 @@ async function refreshAuthTokens(refreshToken: string): Promise { } const payload = (await response.json()) as TokenResponse; + + if (!payload.access_token) { + throw new Error("Token refresh returned empty access_token"); + } + + return { + accessToken: payload.access_token, + refreshToken: payload.refresh_token ?? refreshToken, + idToken: payload.id_token, + expiresAt: Date.now() + (payload.expires_in ?? 86400) * 1000, + }; +} + +async function refreshViaClientToken(refreshToken: string): Promise { + const body = new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token: refreshToken, + subject_token_type: "urn:ietf:params:oauth:token-type:refresh_token", + requested_token_type: "urn:ietf:params:oauth:token-type:access_token", + client_id: CLIENT_ID, + scope: SCOPES, + }); + + const response = await fetch(TOKEN_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + Origin: "https://nvfile", + Accept: "application/json, text/plain, */*", + "User-Agent": GFN_USER_AGENT, + }, + body, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`client_token refresh failed (${response.status}): ${text.slice(0, 400)}`); + } + + const payload = (await response.json()) as TokenResponse; + + if (!payload.access_token) { + throw new Error("client_token refresh returned empty access_token"); + } + return { accessToken: payload.access_token, refreshToken: payload.refresh_token ?? refreshToken, @@ -424,6 +470,8 @@ export class AuthService { private selectedProvider: LoginProvider = defaultProvider(); private cachedSubscription: SubscriptionInfo | null = null; private cachedVpcId: string | null = null; + private refreshLock: Promise | null = null; + private sessionExpiredListeners = new Set<(reason: string) => void>(); constructor(private readonly statePath: string) {} @@ -945,7 +993,31 @@ export class AuthService { if (expired) { await this.logout(); - return { + try { + const refreshed = await this.lockedRefresh(); + if (!refreshed) { + return { + session: this.session, + refresh: { + attempted: true, + forced: forceRefresh, + outcome: "failed", + message: "Token refresh failed (all strategies exhausted). Using saved session token.", + }, + }; + } + + const user = await fetchUserInfo(refreshed); + this.session = { + provider: this.session.provider, + tokens: refreshed, + user, + }; + + this.clearSubscriptionCache(); + await this.enrichUserTier(); + + await this.persist(); return { session: null, refresh: { attempted: true, @@ -996,4 +1068,100 @@ export class AuthService { return session.tokens.idToken ?? session.tokens.accessToken; } + + onSessionExpired(listener: (reason: string) => void): () => void { + this.sessionExpiredListeners.add(listener); + return () => { + this.sessionExpiredListeners.delete(listener); + }; + } + + private emitSessionExpired(reason: string): void { + for (const listener of this.sessionExpiredListeners) { + listener(reason); + } + } + + private async performTokenRefresh(): Promise { + if (!this.session?.tokens.refreshToken) { + return null; + } + + const refreshToken = this.session.tokens.refreshToken; + + try { + const refreshed = await refreshAuthTokens(refreshToken); + return refreshed; + } catch (standardError) { + console.warn("[Auth] Standard refresh_token flow failed, trying client_token exchange:", standardError); + try { + const refreshed = await refreshViaClientToken(refreshToken); + return refreshed; + } catch (clientTokenError) { + console.error("[Auth] client_token exchange also failed:", clientTokenError); + return null; + } + } + } + + private async lockedRefresh(): Promise { + if (this.refreshLock) { + return this.refreshLock; + } + + this.refreshLock = this.performTokenRefresh().finally(() => { + this.refreshLock = null; + }); + + return this.refreshLock; + } + + async handleApiError(error: unknown): Promise<{ shouldRetry: boolean; token: string | null }> { + const is401 = + error instanceof Error && + (error.message.includes("(401)") || + error.message.includes("status 401") || + error.message.includes("Unauthorized")); + + if (!is401) { + return { shouldRetry: false, token: null }; + } + + if (!this.session?.tokens.refreshToken) { + console.warn("[Auth] 401 received but no refresh token available, triggering logout"); + await this.logout(); + this.emitSessionExpired("Session expired. No refresh token available."); + return { shouldRetry: false, token: null }; + } + + const refreshed = await this.lockedRefresh(); + if (!refreshed) { + console.warn("[Auth] 401 received and token refresh failed, triggering logout"); + await this.logout(); + this.emitSessionExpired("Session expired. Token refresh failed."); + return { shouldRetry: false, token: null }; + } + + try { + const user = await fetchUserInfo(refreshed); + this.session = { + provider: this.session.provider, + tokens: refreshed, + user, + }; + + this.clearSubscriptionCache(); + await this.enrichUserTier(); + await this.persist(); + } catch { + this.session = { + ...this.session, + tokens: refreshed, + }; + await this.persist(); + } + + const newToken = refreshed.idToken ?? refreshed.accessToken; + return { shouldRetry: true, token: newToken }; + } } diff --git a/opennow-stable/src/main/gfn/cloudmatch.ts b/opennow-stable/src/main/gfn/cloudmatch.ts index 3902fae0..c05e3cca 100644 --- a/opennow-stable/src/main/gfn/cloudmatch.ts +++ b/opennow-stable/src/main/gfn/cloudmatch.ts @@ -519,6 +519,7 @@ function toSessionInfo(zone: string, streamingBaseUrl: string, payload: CloudMat signalingServer: signaling.signalingServer, signalingUrl: signaling.signalingUrl, gpuType: payload.session.gpuType, + queuePosition: payload.session.queuePosition, iceServers: normalizeIceServers(payload), mediaConnectionInfo: signaling.mediaConnectionInfo, }; diff --git a/opennow-stable/src/main/gfn/types.ts b/opennow-stable/src/main/gfn/types.ts index 8f6a0867..f68df8cb 100644 --- a/opennow-stable/src/main/gfn/types.ts +++ b/opennow-stable/src/main/gfn/types.ts @@ -90,6 +90,7 @@ export interface CloudMatchResponse { }; errorCode?: number; gpuType?: string; + queuePosition?: number; connectionInfo?: Array<{ ip?: string; port: number; diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index c15821f8..40268bf3 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -191,7 +191,73 @@ function emitToRenderer(event: MainToRendererSignalingEvent): void { } } -async function createMainWindow(): Promise { +function emitSessionExpired(reason: string): void { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.AUTH_SESSION_EXPIRED, reason); + } +} + +async function withRetryOn401( + fn: (token: string) => Promise, + explicitToken?: string, +): Promise { + const token = await resolveJwt(explicitToken); + try { + return await fn(token); + } catch (error) { + const { shouldRetry, token: newToken } = await authService.handleApiError(error); + if (shouldRetry && newToken) { + return fn(newToken); + } + throw error; + } +} + +function setupWebHidPermissions(): void { + const ses = session.defaultSession; + + ses.setDevicePermissionHandler((details) => { + if (details.deviceType === "hid") { + return true; + } + return true; + }); + + ses.setPermissionCheckHandler((_webContents, permission) => { + if (permission === "hid" || permission === "media" || permission === "keyboardLock") { + return true; + } + return true; + }); + + ses.setPermissionRequestHandler((_webContents, permission, callback) => { + if (permission === "media" || permission === "keyboardLock") { + callback(true); + return; + } + callback(true); + }); + + ses.on("select-hid-device", (event, details, callback) => { + event.preventDefault(); + const ungranted = details.deviceList.find((d) => !grantedHidDeviceIds.has(d.deviceId)); + const selected = ungranted ?? details.deviceList[0]; + if (selected) { + grantedHidDeviceIds.add(selected.deviceId); + callback(selected.deviceId); + } else { + callback(""); + } + }); + + ses.on("hid-device-added", (_event, _details) => { + // WebHID connect event handled in renderer via navigator.hid + }); + + ses.on("hid-device-removed", (_event, _details) => { + // WebHID disconnect event handled in renderer via navigator.hid + }); +}async function createMainWindow(): Promise { const preloadMjsPath = join(__dirname, "../preload/index.mjs"); const preloadJsPath = join(__dirname, "../preload/index.js"); const preloadPath = existsSync(preloadMjsPath) ? preloadMjsPath : preloadJsPath; @@ -332,20 +398,28 @@ function registerIpcHandlers(): void { const { vpcId } = await fetchDynamicRegions(token, streamingBaseUrl); return fetchSubscription(token, userId, vpcId ?? undefined); - }); + return withRetryOn401(async (token) => { + const streamingBaseUrl = + payload?.providerStreamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl; + const userId = payload.userId; + const { vpcId } = await fetchDynamicRegions(token, streamingBaseUrl); + return fetchSubscription(token, userId, vpcId ?? undefined); + }, payload?.token); }); ipcMain.handle(IPC_CHANNELS.GAMES_FETCH_MAIN, async (_event, payload: GamesFetchRequest) => { - const token = await resolveJwt(payload?.token); - const streamingBaseUrl = - payload?.providerStreamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl; - return fetchMainGames(token, streamingBaseUrl); + return withRetryOn401(async (token) => { + const streamingBaseUrl = + payload?.providerStreamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl; + return fetchMainGames(token, streamingBaseUrl); + }, payload?.token); }); ipcMain.handle(IPC_CHANNELS.GAMES_FETCH_LIBRARY, async (_event, payload: GamesFetchRequest) => { - const token = await resolveJwt(payload?.token); - const streamingBaseUrl = - payload?.providerStreamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl; - return fetchLibraryGames(token, streamingBaseUrl); + return withRetryOn401(async (token) => { + const streamingBaseUrl = + payload?.providerStreamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl; + return fetchLibraryGames(token, streamingBaseUrl); + }, payload?.token); }); ipcMain.handle(IPC_CHANNELS.GAMES_FETCH_PUBLIC, async () => { @@ -353,21 +427,23 @@ function registerIpcHandlers(): void { }); ipcMain.handle(IPC_CHANNELS.GAMES_RESOLVE_LAUNCH_ID, async (_event, payload: ResolveLaunchIdRequest) => { - const token = await resolveJwt(payload?.token); - const streamingBaseUrl = - payload?.providerStreamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl; - return resolveLaunchAppId(token, payload.appIdOrUuid, streamingBaseUrl); + return withRetryOn401(async (token) => { + const streamingBaseUrl = + payload?.providerStreamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl; + return resolveLaunchAppId(token, payload.appIdOrUuid, streamingBaseUrl); + }, payload?.token); }); ipcMain.handle(IPC_CHANNELS.CREATE_SESSION, async (_event, payload: SessionCreateRequest) => { try { - const token = await resolveJwt(payload.token); - const streamingBaseUrl = payload.streamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl; - return createSession({ - ...payload, - token, - streamingBaseUrl, - }); + return await withRetryOn401(async (token) => { + const streamingBaseUrl = payload.streamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl; + return createSession({ + ...payload, + token, + streamingBaseUrl, + }); + }, payload.token); } catch (error) { rethrowSerializedSessionError(error); } @@ -375,12 +451,13 @@ function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.POLL_SESSION, async (_event, payload: SessionPollRequest) => { try { - const token = await resolveJwt(payload.token); - return pollSession({ - ...payload, - token, - streamingBaseUrl: payload.streamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl, - }); + return await withRetryOn401(async (token) => { + return pollSession({ + ...payload, + token, + streamingBaseUrl: payload.streamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl, + }); + }, payload.token); } catch (error) { rethrowSerializedSessionError(error); } @@ -388,32 +465,35 @@ function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.STOP_SESSION, async (_event, payload: SessionStopRequest) => { try { - const token = await resolveJwt(payload.token); - return stopSession({ - ...payload, - token, - streamingBaseUrl: payload.streamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl, - }); + return await withRetryOn401(async (token) => { + return stopSession({ + ...payload, + token, + streamingBaseUrl: payload.streamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl, + }); + }, payload.token); } catch (error) { rethrowSerializedSessionError(error); } }); ipcMain.handle(IPC_CHANNELS.GET_ACTIVE_SESSIONS, async (_event, token?: string, streamingBaseUrl?: string) => { - const jwt = await resolveJwt(token); - const baseUrl = streamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl; - return getActiveSessions(jwt, baseUrl); + return withRetryOn401(async (jwt) => { + const baseUrl = streamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl; + return getActiveSessions(jwt, baseUrl); + }, token); }); ipcMain.handle(IPC_CHANNELS.CLAIM_SESSION, async (_event, payload: SessionClaimRequest) => { try { - const token = await resolveJwt(payload.token); - const streamingBaseUrl = payload.streamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl; - return claimSession({ - ...payload, - token, - streamingBaseUrl, - }); + return await withRetryOn401(async (token) => { + const streamingBaseUrl = payload.streamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl; + return claimSession({ + ...payload, + token, + streamingBaseUrl, + }); + }, payload.token); } catch (error) { rethrowSerializedSessionError(error); } @@ -517,6 +597,8 @@ app.whenReady().then(async () => { authService = new AuthService(join(app.getPath("userData"), "auth-state.json")); await authService.initialize(); + authService.onSessionExpired(emitSessionExpired); + settingsManager = getSettingsManager(); // Request microphone permission on macOS at startup diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index 6f3226e9..51b4cc51 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -48,7 +48,38 @@ export interface Settings { windowWidth: number; /** Window height */ windowHeight: number; -} + /** Enable Discord Rich Presence */ + discordPresenceEnabled: boolean; + /** Discord Application Client ID */ + discordClientId: string; + /** Enable flight controls (HOTAS/joystick) */ + flightControlsEnabled: boolean; + /** Controller slot for flight controls (0-3) — legacy, kept for compat */ + flightControlsSlot: number; + /** Per-slot flight configurations */ + flightSlots: FlightSlotConfig[]; + /** HDR streaming mode: off, auto, on */ + hdrStreaming: HdrStreamingMode; + /** Microphone mode: off, on, push-to-talk */ + micMode: MicMode; + /** Selected microphone device ID (empty = default) */ + micDeviceId: string; + /** Microphone input gain 0.0 - 2.0 */ + micGain: number; + /** Enable noise suppression */ + micNoiseSuppression: boolean; + /** Enable automatic gain control */ + micAutoGainControl: boolean; + /** Enable echo cancellation */ + micEchoCancellation: boolean; + /** Toggle mic on/off shortcut (works in-stream) */ + shortcutToggleMic: string; + /** HEVC compatibility mode: auto, force_h264, force_hevc, hevc_software */ + hevcCompatMode: HevcCompatMode; + /** Show session clock every N minutes (0 = always visible) */ + sessionClockShowEveryMinutes: number; + /** Duration in seconds to show session clock when periodically revealed */ + sessionClockShowDurationSeconds: number;} const defaultStopShortcut = "Ctrl+Shift+Q"; const defaultAntiAfkShortcut = "Ctrl+Shift+K"; @@ -79,7 +110,22 @@ const DEFAULT_SETTINGS: Settings = { sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, -}; + discordPresenceEnabled: false, + discordClientId: "", + flightControlsEnabled: false, + flightControlsSlot: 3, + flightSlots: defaultFlightSlots(), + hdrStreaming: "off", + micMode: "off", + micDeviceId: "", + micGain: 1.0, + micNoiseSuppression: true, + micAutoGainControl: true, + micEchoCancellation: true, + shortcutToggleMic: "Ctrl+Shift+M", + hevcCompatMode: "auto", + sessionClockShowEveryMinutes: 60, + sessionClockShowDurationSeconds: 30,}; export class SettingsManager { private settings: Settings; diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index 155321c2..b0104071 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -74,6 +74,37 @@ const api: PreloadApi = { ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_SET, key, value), resetSettings: () => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_RESET), exportLogs: (format?: "text" | "json") => ipcRenderer.invoke(IPC_CHANNELS.LOGS_EXPORT, format), -}; + updateDiscordPresence: (state: DiscordPresencePayload) => + ipcRenderer.invoke(IPC_CHANNELS.DISCORD_UPDATE_PRESENCE, state), + clearDiscordPresence: () => ipcRenderer.invoke(IPC_CHANNELS.DISCORD_CLEAR_PRESENCE), + flightGetProfile: (vidPid: string, gameId?: string) => + ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_GET_PROFILE, vidPid, gameId), + flightSetProfile: (profile: FlightProfile) => + ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_SET_PROFILE, profile), + flightDeleteProfile: (vidPid: string, gameId?: string) => + ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_DELETE_PROFILE, vidPid, gameId), + flightGetAllProfiles: () => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_GET_ALL_PROFILES), + flightResetProfile: (vidPid: string) => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_RESET_PROFILE, vidPid), + getOsHdrInfo: () => ipcRenderer.invoke(IPC_CHANNELS.HDR_GET_OS_INFO), + relaunchApp: () => ipcRenderer.invoke(IPC_CHANNELS.APP_RELAUNCH), + micEnumerateDevices: () => ipcRenderer.invoke(IPC_CHANNELS.MIC_ENUMERATE_DEVICES), + onMicDevicesChanged: (listener: (devices: MicDeviceInfo[]) => void) => { + const wrapped = (_event: Electron.IpcRendererEvent, devices: MicDeviceInfo[]) => { + listener(devices); + }; + ipcRenderer.on(IPC_CHANNELS.MIC_DEVICES_CHANGED, wrapped); + return () => { + ipcRenderer.off(IPC_CHANNELS.MIC_DEVICES_CHANGED, wrapped); + }; + }, + onSessionExpired: (listener: (reason: string) => void) => { + const wrapped = (_event: Electron.IpcRendererEvent, reason: string) => { + listener(reason); + }; + ipcRenderer.on(IPC_CHANNELS.AUTH_SESSION_EXPIRED, wrapped); + return () => { + ipcRenderer.off(IPC_CHANNELS.AUTH_SESSION_EXPIRED, wrapped); + }; + },}; contextBridge.exposeInMainWorld("openNow", api); diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index f62a8bde..08d3fed7 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -68,8 +68,22 @@ const DEFAULT_SHORTCUTS = { shortcutToggleMicrophone: "Ctrl+Shift+M", } as const; -function sleep(ms: number): Promise { - return new Promise((resolve) => window.setTimeout(resolve, ms)); +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new DOMException("Aborted", "AbortError")); + return; + } + const timer = window.setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + window.clearTimeout(timer); + reject(signal.reason ?? new DOMException("Aborted", "AbortError")); + }, + { once: true }, + ); + }); } function isSessionReadyForConnect(status: number): boolean { @@ -286,7 +300,22 @@ export function App(): JSX.Element { sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, - }); + discordPresenceEnabled: false, + discordClientId: "", + flightControlsEnabled: false, + flightControlsSlot: 3, + flightSlots: defaultFlightSlots(), + hdrStreaming: "off", + micMode: "off", + micDeviceId: "", + micGain: 1.0, + micNoiseSuppression: true, + micAutoGainControl: true, + micEchoCancellation: true, + shortcutToggleMic: DEFAULT_SHORTCUTS.shortcutToggleMic, + hevcCompatMode: "auto", + sessionClockShowEveryMinutes: 60, + sessionClockShowDurationSeconds: 30, }); const [settingsLoaded, setSettingsLoaded] = useState(false); const [regions, setRegions] = useState([]); const [subscriptionInfo, setSubscriptionInfo] = useState(null); @@ -337,8 +366,11 @@ export function App(): JSX.Element { onNavigatePage: handleControllerPageNavigate, onBackAction: handleControllerBackAction, }); - - // Refs + const [hdrCapability, setHdrCapability] = useState(null); + const [hdrWarningShown, setHdrWarningShown] = useState(false); + const [provisioningElapsed, setProvisioningElapsed] = useState(0); + const [sessionExpiredMessage, setSessionExpiredMessage] = useState(null); + const [sessionClockVisible, setSessionClockVisible] = useState(true); // Refs const videoRef = useRef(null); const audioRef = useRef(null); const clientRef = useRef(null); @@ -346,6 +378,7 @@ export function App(): JSX.Element { const hasInitializedRef = useRef(false); const regionsRequestRef = useRef(0); const launchInFlightRef = useRef(false); + const pollAbortRef = useRef(null); const exitPromptResolverRef = useRef<((confirmed: boolean) => void) | null>(null); // Session ref sync @@ -418,6 +451,32 @@ export function App(): JSX.Element { return () => window.clearInterval(timer); }, [authSession, refreshNavbarActiveSession, streamStatus]); + useEffect(() => { + const unsubscribe = window.openNow.onSessionExpired((reason: string) => { + console.warn("[App] Session expired:", reason); + setSessionExpiredMessage(reason); + setAuthSession(null); + setGames([]); + setLibraryGames([]); + setNavbarActiveSession(null); + setIsResumingNavbarSession(false); + setLaunchError(null); + setSubscriptionInfo(null); + setCurrentPage("home"); + window.openNow.fetchPublicGames().then((publicGames) => { + setGames(publicGames); + setSource("public"); + }).catch(() => {}); + }); + return unsubscribe; + }, []); + + useEffect(() => { + if (!sessionExpiredMessage) return; + const timer = window.setTimeout(() => setSessionExpiredMessage(null), 10000); + return () => window.clearTimeout(timer); + }, [sessionExpiredMessage]); + // Initialize app useEffect(() => { if (hasInitializedRef.current) return; @@ -659,6 +718,119 @@ export function App(): JSX.Element { }, [sessionStartedAtMs, streamStatus]); useEffect(() => { + if (streamStatus === "idle" || sessionStartedAtMs === null) { + setSessionClockVisible(true); + return; + } + + const everyMinutes = settings.sessionClockShowEveryMinutes; + const durationSeconds = settings.sessionClockShowDurationSeconds; + + if (everyMinutes <= 0) { + setSessionClockVisible(true); + return; + } + + setSessionClockVisible(true); + const hideTimer = window.setTimeout(() => { + setSessionClockVisible(false); + }, durationSeconds * 1000); + + const revealInterval = window.setInterval(() => { + setSessionClockVisible(true); + window.setTimeout(() => { + setSessionClockVisible(false); + }, durationSeconds * 1000); + }, everyMinutes * 60 * 1000); + + return () => { + window.clearTimeout(hideTimer); + window.clearInterval(revealInterval); + }; + }, [streamStatus, sessionStartedAtMs, settings.sessionClockShowEveryMinutes, settings.sessionClockShowDurationSeconds]); + + // Discord Rich Presence updates + useEffect(() => { + if (!settings.discordPresenceEnabled || !settings.discordClientId) { + return; + } + + let payload: DiscordPresencePayload; + + if (streamStatus === "idle") { + payload = { type: "idle" }; + } else if (streamStatus === "queue" || streamStatus === "setup") { + const queueTitle = streamingGame?.title?.trim() || lastStreamGameTitleRef.current || undefined; + payload = { + type: "queue", + gameName: queueTitle, + queuePosition, + }; + } else { + const hasDiag = diagnostics.resolution !== "" || diagnostics.bitrateKbps > 0; + const gameTitle = streamingGame?.title?.trim() || lastStreamGameTitleRef.current || undefined; + payload = { + type: "streaming", + gameName: gameTitle, + startTimestamp: sessionStartedAtMs ?? undefined, + ...(hasDiag && diagnostics.resolution ? { resolution: diagnostics.resolution } : {}), + ...(hasDiag && diagnostics.decodeFps > 0 ? { fps: diagnostics.decodeFps } : {}), + ...(hasDiag && diagnostics.bitrateKbps > 0 ? { bitrateMbps: Math.round(diagnostics.bitrateKbps / 100) / 10 } : {}), + }; + } + + window.openNow.updateDiscordPresence(payload).catch(() => {}); + }, [ + streamStatus, + streamingGame?.title, + sessionStartedAtMs, + queuePosition, + diagnostics.resolution, + diagnostics.decodeFps, + diagnostics.bitrateKbps, + settings.discordPresenceEnabled, + settings.discordClientId, + ]); + + // Clear Discord presence on logout + useEffect(() => { + if (!authSession) { + window.openNow.clearDiscordPresence().catch(() => {}); + } + }, [authSession]); + + // Flight controls: forward WebHID gamepad state to WebRTC client (multi-slot) + const activeFlightSlotsRef = useRef>(new Set()); + useEffect(() => { + if (!settings.flightControlsEnabled) { + for (const s of activeFlightSlotsRef.current) { + clientRef.current?.releaseExternalGamepad(s); + } + activeFlightSlotsRef.current.clear(); + return; + } + + const slots: FlightSlotConfig[] = Array.isArray(settings.flightSlots) && settings.flightSlots.length === 4 + ? settings.flightSlots : defaultFlightSlots(); + + const wantedSlots = new Set(); + for (let i = 0; i < 4; i++) { + if (slots[i]!.enabled && slots[i]!.deviceKey) wantedSlots.add(i); + } + + for (const s of activeFlightSlotsRef.current) { + if (!wantedSlots.has(s)) { + clientRef.current?.releaseExternalGamepad(s); + } + } + activeFlightSlotsRef.current = wantedSlots; + + const service = getFlightHidService(); + const unsub = service.onGamepadState((state) => { + clientRef.current?.injectExternalGamepad(state); + }); + return unsub; + }, [settings.flightControlsEnabled, settings.flightSlots]); useEffect(() => { if (!streamWarning) return; const warning = streamWarning; const timer = window.setTimeout(() => { @@ -1001,14 +1173,39 @@ export function App(): JSX.Element { while (true) { attempt++; await sleep(SESSION_READY_POLL_INTERVAL_MS); - - const polled = await window.openNow.pollSession({ - token: token || undefined, - streamingBaseUrl: newSession.streamingBaseUrl ?? effectiveStreamingBaseUrl, - serverIp: newSession.serverIp, - zone: newSession.zone, - sessionId: newSession.sessionId, - }); + // Poll for readiness with exponential backoff and hard timeout. + // status=1 means "provisioning" — keep waiting. + // Only fail on terminal backend errors or hard timeout (180s). + const HARD_TIMEOUT_MS = 180_000; + // Poll for readiness with queue-aware timeout logic. + // + // "True queue" = queuePosition > 1 → no timeout, poll indefinitely. + // "Allocation" = queuePosition 0 or 1 (or absent) → machine is being + // allocated / starting. Apply ALLOCATION_TIMEOUT_MS from the moment + // we enter (or start in) allocation mode. On timeout we do NOT throw; + // instead we show "Still starting…" and continue polling with backoff. + // + // status codes: 1 = provisioning, 2/3 = ready, 6 = cleaning up (terminal). + const ALLOCATION_TIMEOUT_MS = 180_000; const BACKOFF_INITIAL_MS = 1000; + const BACKOFF_MAX_MS = 5000; + const BACKOFF_EXTENDED_MAX_MS = 10_000; + const READY_CONFIRMS_NEEDED = 3; + + const pollAbort = new AbortController(); + pollAbortRef.current = pollAbort; + const pollSignal = pollAbort.signal; + + let readyCount = 0; + let attempt = 0; + let delay = BACKOFF_INITIAL_MS; + const pollStart = Date.now(); + let allocationStartMs: number | null = null; + let allocationTimedOut = false; + const pollStartMs = Date.now(); try { + while (readyCount < READY_CONFIRMS_NEEDED) { + if (pollSignal.aborted) { + throw new DOMException("Polling cancelled", "AbortError"); + } setSession(polled); setQueuePosition(polled.queuePosition); @@ -1047,7 +1244,92 @@ export function App(): JSX.Element { // finalSession is guaranteed to be set here (we only exit the loop via break when session is ready) // Timeout only applies during setup/starting phase, not during queue wait + const elapsed = Date.now() - pollStart; + if (elapsed >= HARD_TIMEOUT_MS) { + throw new Error( + "Session provisioning timed out after 3 minutes. The server may be under heavy load — please try again.", + ); + } await sleep(delay, pollSignal); + attempt++; + + const polled = await window.openNow.pollSession({ + token: token || undefined, + streamingBaseUrl: newSession.streamingBaseUrl ?? effectiveStreamingBaseUrl, + serverIp: newSession.serverIp, + zone: newSession.zone, + sessionId: newSession.sessionId, + }); + if (pollSignal.aborted) { + throw new DOMException("Polling cancelled", "AbortError"); + } + + setSession(polled); + + const polledQueuePos = polled.queuePosition ?? 0; + const isInQueueMode = polledQueuePos > 1; + + const pollStartElapsed = Date.now() - pollStartMs; + console.log( + `Poll attempt ${attempt}: status=${polled.status}, queuePosition=${polledQueuePos}, ` + + `signalingUrl=${polled.signalingUrl}, elapsed=${Math.round(pollStartElapsed / 1000)}s`, + ); + + if (polled.status === 2 || polled.status === 3) { + readyCount++; + console.log(`Ready count: ${readyCount}/${READY_CONFIRMS_NEEDED}`); + delay = BACKOFF_INITIAL_MS; + } else if (polled.status === 1) { + readyCount = 0; + + if (isInQueueMode) { + // True queue: show queue position, no timeout, reset allocation clock. + updateLoadingStep("queue"); + setQueuePosition(polledQueuePos); + allocationStartMs = null; + allocationTimedOut = false; + delay = Math.min(delay * 1.5, BACKOFF_MAX_MS); + } else { + // Allocation phase (queuePosition 0 or 1): machine starting. + setQueuePosition(undefined); + + if (allocationStartMs === null) { + allocationStartMs = Date.now(); + } + + const allocationElapsed = Date.now() - allocationStartMs; + + if (!allocationTimedOut && allocationElapsed >= ALLOCATION_TIMEOUT_MS) { + allocationTimedOut = true; + console.warn( + `Allocation exceeded ${ALLOCATION_TIMEOUT_MS / 1000}s — continuing with extended backoff`, + ); + } + + updateLoadingStep("setup"); + setProvisioningElapsed(Math.round(allocationElapsed / 1000)); + + if (allocationTimedOut) { + delay = Math.min(delay * 1.5, BACKOFF_EXTENDED_MAX_MS); + } else { + delay = Math.min(delay * 1.5, BACKOFF_MAX_MS); + } + } + } else if (polled.status === 6) { + throw new Error("Session is being cleaned up. Please try launching again."); + } else { + readyCount = 0; + console.warn(`Unexpected session status: ${polled.status}, continuing to poll`); + delay = Math.min(delay * 1.5, BACKOFF_MAX_MS); + } + } + } finally { + if (pollAbortRef.current === pollAbort) { + pollAbortRef.current = null; + } + setQueuePosition(undefined); + setProvisioningElapsed(0); + } setQueuePosition(undefined); updateLoadingStep("connecting"); @@ -1066,8 +1348,16 @@ export function App(): JSX.Element { signalingUrl: sessionToConnect.signalingUrl, }); } catch (error) { - console.error("Launch failed:", error); - setLaunchError(toLaunchErrorState(error, loadingStep)); + const isAbort = + error instanceof DOMException && error.name === "AbortError"; + + if (isAbort) { + console.log("Launch cancelled by user"); + } else { + console.error("Launch failed:", error); + setLaunchError(toLaunchErrorState(error, loadingStep)); + } + await window.openNow.disconnectSignaling().catch(() => {}); clientRef.current?.dispose(); clientRef.current = null; @@ -1079,6 +1369,7 @@ export function App(): JSX.Element { setStreamWarning(null); setEscHoldReleaseIndicator({ visible: false, progress: 0 }); setDiagnostics(defaultDiagnostics()); + setProvisioningElapsed(0); void refreshNavbarActiveSession(); } finally { launchInFlightRef.current = false; @@ -1152,6 +1443,7 @@ export function App(): JSX.Element { const handleStopStream = useCallback(async () => { try { resolveExitPrompt(false); + pollAbortRef.current?.abort(); await window.openNow.disconnectSignaling(); const current = sessionRef.current; @@ -1185,6 +1477,7 @@ export function App(): JSX.Element { }, [authSession, refreshNavbarActiveSession, resolveExitPrompt]); const handleDismissLaunchError = useCallback(async () => { + pollAbortRef.current?.abort(); await window.openNow.disconnectSignaling().catch(() => {}); clientRef.current?.dispose(); clientRef.current = null; @@ -1417,7 +1710,16 @@ export function App(): JSX.Element { )} - ); + ); } const showLaunchOverlay = streamStatus !== "idle" || launchError !== null; @@ -1448,7 +1750,7 @@ export function App(): JSX.Element { sessionElapsedSeconds={sessionElapsedSeconds} sessionClockShowEveryMinutes={settings.sessionClockShowEveryMinutes} sessionClockShowDurationSeconds={settings.sessionClockShowDurationSeconds} - streamWarning={streamWarning} + sessionClockVisible={sessionClockVisible} streamWarning={streamWarning} isConnecting={streamStatus === "connecting"} gameTitle={streamingGame?.title ?? "Game"} onToggleFullscreen={() => { @@ -1474,6 +1776,7 @@ export function App(): JSX.Element { gameCover={streamingGame?.imageUrl} status={loadingStatus} queuePosition={queuePosition} + provisioningElapsed={provisioningElapsed} error={ launchError ? { diff --git a/opennow-stable/src/renderer/src/components/Navbar.tsx b/opennow-stable/src/renderer/src/components/Navbar.tsx index 2b000fac..68c00e8c 100644 --- a/opennow-stable/src/renderer/src/components/Navbar.tsx +++ b/opennow-stable/src/renderer/src/components/Navbar.tsx @@ -125,8 +125,18 @@ export function Navbar({ window.addEventListener("keydown", onKeyDown); const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; + + window.history.pushState({ navbarModal: true }, ""); + const onPopState = (event: PopStateEvent) => { + if (event.state?.navbarModal !== true) { + setModalType(null); + } + }; + window.addEventListener("popstate", onPopState); + return () => { window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("popstate", onPopState); document.body.style.overflow = previousOverflow; }; }, [modalType]); diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index a19c115e..dead7bd7 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -1380,7 +1380,56 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag {/* ── Miscellaneous ──────────────────────────────── */} + {/* ── Discord ────────────────────────────────────── */} + {/* ── Session Clock ─────────────────────────────── */}
+
+ +

Session Clock

+
+
+
+ + { + const val = Math.max(0, Math.min(480, Math.round(Number(e.target.value) || 0))); + handleChange("sessionClockShowEveryMinutes", val); + }} + /> +
+ + How often to briefly reveal the session clock while streaming. Set to 0 for always visible. + +
+ + { + const val = Math.max(5, Math.min(300, Math.round(Number(e.target.value) || 30))); + handleChange("sessionClockShowDurationSeconds", val); + }} + /> +
+ + How long the clock stays visible each time it appears. + +
+
+ + {/* ── Discord ────────────────────────────────────── */}

Miscellaneous

diff --git a/opennow-stable/src/renderer/src/components/StreamLoading.tsx b/opennow-stable/src/renderer/src/components/StreamLoading.tsx index 44802581..0a0657ce 100644 --- a/opennow-stable/src/renderer/src/components/StreamLoading.tsx +++ b/opennow-stable/src/renderer/src/components/StreamLoading.tsx @@ -7,6 +7,7 @@ export interface StreamLoadingProps { status: "queue" | "setup" | "starting" | "connecting"; queuePosition?: number; estimatedWait?: string; + provisioningElapsed?: number; error?: { title: string; description: string; @@ -25,6 +26,7 @@ function getStatusMessage( status: StreamLoadingProps["status"], queuePosition?: number, isError = false, + provisioningElapsed = 0, ): string { if (isError) { return "Game launch failed"; @@ -33,6 +35,9 @@ function getStatusMessage( case "queue": return queuePosition ? `Position #${queuePosition} in queue` : "Waiting in queue..."; case "setup": + if (provisioningElapsed >= 30) { + return `Still starting… (${provisioningElapsed}s)`; + } return "Setting up your gaming rig..."; case "starting": return "Starting stream..."; @@ -63,12 +68,13 @@ export function StreamLoading({ status, queuePosition, estimatedWait, + provisioningElapsed, error, onCancel, }: StreamLoadingProps): JSX.Element { const hasError = Boolean(error); const activeStepIndex = getActiveStepIndex(status); - const statusMessage = getStatusMessage(status, queuePosition, hasError); + const statusMessage = getStatusMessage(status, queuePosition, hasError, provisioningElapsed); return (
diff --git a/opennow-stable/src/renderer/src/components/StreamView.tsx b/opennow-stable/src/renderer/src/components/StreamView.tsx index ec4a4cf0..40eca9fe 100644 --- a/opennow-stable/src/renderer/src/components/StreamView.tsx +++ b/opennow-stable/src/renderer/src/components/StreamView.tsx @@ -29,7 +29,7 @@ interface StreamViewProps { sessionElapsedSeconds: number; sessionClockShowEveryMinutes: number; sessionClockShowDurationSeconds: number; - streamWarning: { + sessionClockVisible: boolean; streamWarning: { code: 1 | 2 | 3; message: string; tone: "warn" | "critical"; @@ -108,7 +108,7 @@ export function StreamView({ sessionElapsedSeconds, sessionClockShowEveryMinutes, sessionClockShowDurationSeconds, - streamWarning, + sessionClockVisible, streamWarning, isConnecting, gameTitle, onToggleFullscreen, @@ -278,7 +278,8 @@ export function StreamView({ title="Current gaming session elapsed time" aria-hidden={!showSessionClock} > - + {!isConnecting && sessionClockVisible && ( +
Session {sessionTimeText}
)} diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index eaa823ba..b3a45f4a 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -49,7 +49,22 @@ export interface Settings { sessionClockShowDurationSeconds: number; windowWidth: number; windowHeight: number; -} + discordPresenceEnabled: boolean; + discordClientId: string; + flightControlsEnabled: boolean; + flightControlsSlot: number; + flightSlots: FlightSlotConfig[]; + hdrStreaming: HdrStreamingMode; + micMode: MicMode; + micDeviceId: string; + micGain: number; + micNoiseSuppression: boolean; + micAutoGainControl: boolean; + micEchoCancellation: boolean; + shortcutToggleMic: string; + hevcCompatMode: HevcCompatMode; + sessionClockShowEveryMinutes: number; + sessionClockShowDurationSeconds: number;} export interface LoginProvider { idpId: string; @@ -243,6 +258,7 @@ export interface SessionInfo { signalingServer: string; signalingUrl: string; gpuType?: string; + queuePosition?: number; iceServers: IceServer[]; mediaConnectionInfo?: MediaConnectionInfo; } @@ -332,4 +348,127 @@ export interface OpenNowApi { resetSettings(): Promise; /** Export logs in redacted format */ exportLogs(format?: "text" | "json"): Promise; + updateDiscordPresence(state: DiscordPresencePayload): Promise; + clearDiscordPresence(): Promise; + flightGetProfile(vidPid: string, gameId?: string): Promise; + flightSetProfile(profile: FlightProfile): Promise; + flightDeleteProfile(vidPid: string, gameId?: string): Promise; + flightGetAllProfiles(): Promise; + flightResetProfile(vidPid: string): Promise; + getOsHdrInfo(): Promise<{ osHdrEnabled: boolean; platform: string }>; + relaunchApp(): Promise; + micEnumerateDevices(): Promise; + onMicDevicesChanged(listener: (devices: MicDeviceInfo[]) => void): () => void; + onSessionExpired(listener: (reason: string) => void): () => void; +} + +export type FlightAxisTarget = + | "leftStickX" + | "leftStickY" + | "rightStickX" + | "rightStickY" + | "leftTrigger" + | "rightTrigger"; + +export type FlightSensitivityCurve = "linear" | "expo"; + +export interface FlightHidAxisSource { + byteOffset: number; + byteCount: 1 | 2; + littleEndian: boolean; + unsigned: boolean; + rangeMin: number; + rangeMax: number; +} + +export interface FlightHidButtonSource { + byteOffset: number; + bitIndex: number; +} + +export interface FlightHidHatSource { + byteOffset: number; + bitOffset: number; + bitCount: 4 | 8; + centerValue: number; +} + +export interface FlightHidReportLayout { + skipReportId: boolean; + reportLength: number; + axes: FlightHidAxisSource[]; + buttons: FlightHidButtonSource[]; + hat?: FlightHidHatSource; +} + +export interface FlightAxisMapping { + sourceIndex: number; + target: FlightAxisTarget; + inverted: boolean; + deadzone: number; + sensitivity: number; + curve: FlightSensitivityCurve; +} + +export interface FlightButtonMapping { + sourceIndex: number; + targetButton: number; +} + +export interface FlightProfile { + name: string; + vidPid: string; + deviceName: string; + axisMappings: FlightAxisMapping[]; + buttonMappings: FlightButtonMapping[]; + reportLayout?: FlightHidReportLayout; + gameId?: string; } + +export interface FlightSlotConfig { + enabled: boolean; + deviceKey: string | null; + vidPid: string | null; + deviceName: string | null; +} + +export function makeDeviceKey(vendorId: number, productId: number, name: string): string { + const vid = vendorId.toString(16).toUpperCase().padStart(4, "0"); + const pid = productId.toString(16).toUpperCase().padStart(4, "0"); + return `${vid}:${pid}:${name}`; +} + +export function defaultFlightSlots(): FlightSlotConfig[] { + return [0, 1, 2, 3].map(() => ({ enabled: false, deviceKey: null, vidPid: null, deviceName: null })); +} + +export interface FlightControlsState { + connected: boolean; + deviceName: string; + axes: number[]; + buttons: boolean[]; + hatSwitch: number; + rawBytes: number[]; +} + +export interface FlightGamepadState { + controllerId: number; + buttons: number; + leftTrigger: number; + rightTrigger: number; + leftStickX: number; + leftStickY: number; + rightStickX: number; + rightStickY: number; + connected: boolean; +} + +export interface DiscordPresencePayload { + type: "idle" | "queue" | "streaming"; + gameName?: string; + resolution?: string; + fps?: number; + bitrateMbps?: number; + region?: string; + startTimestamp?: number; + queuePosition?: number;} diff --git a/opennow-stable/src/shared/ipc.ts b/opennow-stable/src/shared/ipc.ts index 454dc467..02d85734 100644 --- a/opennow-stable/src/shared/ipc.ts +++ b/opennow-stable/src/shared/ipc.ts @@ -27,6 +27,17 @@ export const IPC_CHANNELS = { SETTINGS_RESET: "settings:reset", LOGS_EXPORT: "logs:export", LOGS_GET_RENDERER: "logs:get-renderer", -} as const; + DISCORD_UPDATE_PRESENCE: "discord:update-presence", + DISCORD_CLEAR_PRESENCE: "discord:clear-presence", + FLIGHT_GET_PROFILE: "flight:get-profile", + FLIGHT_SET_PROFILE: "flight:set-profile", + FLIGHT_DELETE_PROFILE: "flight:delete-profile", + FLIGHT_GET_ALL_PROFILES: "flight:get-all-profiles", + FLIGHT_RESET_PROFILE: "flight:reset-profile", + HDR_GET_OS_INFO: "hdr:get-os-info", + MIC_ENUMERATE_DEVICES: "mic:enumerate-devices", + MIC_DEVICES_CHANGED: "mic:devices-changed", + APP_RELAUNCH: "app:relaunch", + AUTH_SESSION_EXPIRED: "auth:session-expired",} as const; export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]; diff --git a/opennow-stable/tests/session-polling.test.ts b/opennow-stable/tests/session-polling.test.ts new file mode 100644 index 00000000..0f890f2a --- /dev/null +++ b/opennow-stable/tests/session-polling.test.ts @@ -0,0 +1,287 @@ +/** + * Unit tests for session polling logic. + * + * Run: npx tsx tests/session-polling.test.ts + * + * Tests the polling behavior: exponential backoff, status=1 continues, + * transition to ready without error/resume, abort cancellation, and + * hard timeout on truly stuck sessions. + */ + +// ── Simulated types matching the app ──────────────────────────────── + +interface SessionInfo { + sessionId: string; + status: number; + signalingUrl: string; + serverIp: string; + zone: string; + streamingBaseUrl: string; + signalingServer: string; +} + +type StreamLoadingStatus = "queue" | "setup" | "starting" | "connecting"; + +// ── Extracted polling logic (mirrors App.tsx) ─────────────────────── + +function abortableSleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + const timer = setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(new DOMException("Aborted", "AbortError")); + }, + { once: true }, + ); + }); +} + +interface PollResult { + finalSession: SessionInfo | null; + attempts: number; + error: string | null; + aborted: boolean; + loadingSteps: StreamLoadingStatus[]; + provisioningElapsedUpdates: number[]; +} + +async function simulatePollLoop( + statusSequence: number[], + opts: { + hardTimeoutMs?: number; + pollDelayOverride?: number; + abortAfterMs?: number; + } = {}, +): Promise { + const HARD_TIMEOUT_MS = opts.hardTimeoutMs ?? 180_000; + const BACKOFF_INITIAL_MS = opts.pollDelayOverride ?? 50; + const BACKOFF_MAX_MS = opts.pollDelayOverride ?? 200; + const READY_CONFIRMS_NEEDED = 3; + + const abortController = new AbortController(); + const signal = abortController.signal; + + if (opts.abortAfterMs !== undefined) { + setTimeout(() => abortController.abort(), opts.abortAfterMs); + } + + let statusIndex = 0; + const loadingSteps: StreamLoadingStatus[] = []; + const provisioningElapsedUpdates: number[] = []; + + const pollSession = async (): Promise => { + const status = statusSequence[Math.min(statusIndex, statusSequence.length - 1)]; + statusIndex++; + return { + sessionId: "test-session-123", + status, + signalingUrl: status >= 2 ? "wss://server:443/nvst/" : "", + serverIp: "1.2.3.4", + zone: "prod", + streamingBaseUrl: "https://1.2.3.4", + signalingServer: "1.2.3.4:443", + }; + }; + + let readyCount = 0; + let attempt = 0; + let delay = BACKOFF_INITIAL_MS; + const pollStart = Date.now(); + let finalSession: SessionInfo | null = null; + let error: string | null = null; + let aborted = false; + + try { + while (readyCount < READY_CONFIRMS_NEEDED) { + if (signal.aborted) { + throw new DOMException("Polling cancelled", "AbortError"); + } + + const elapsed = Date.now() - pollStart; + if (elapsed >= HARD_TIMEOUT_MS) { + throw new Error("Session provisioning timed out"); + } + + await abortableSleep(delay, signal); + attempt++; + + const polled = await pollSession(); + + if (signal.aborted) { + throw new DOMException("Polling cancelled", "AbortError"); + } + + if (polled.status === 2 || polled.status === 3) { + readyCount++; + delay = BACKOFF_INITIAL_MS; + } else if (polled.status === 1) { + readyCount = 0; + loadingSteps.push("setup"); + provisioningElapsedUpdates.push(Math.round(elapsed / 1000)); + delay = Math.min(delay * 1.5, BACKOFF_MAX_MS); + } else if (polled.status === 6) { + throw new Error("Session is being cleaned up"); + } else { + readyCount = 0; + delay = Math.min(delay * 1.5, BACKOFF_MAX_MS); + } + + if (readyCount >= READY_CONFIRMS_NEEDED) { + finalSession = polled; + } + } + } catch (e) { + if (e instanceof DOMException && e.name === "AbortError") { + aborted = true; + } else if (e instanceof Error) { + error = e.message; + } + } + + return { + finalSession, + attempts: attempt, + error, + aborted, + loadingSteps, + provisioningElapsedUpdates, + }; +} + +// ── Test runner ───────────────────────────────────────────────────── + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, name: string): void { + if (condition) { + passed++; + console.log(` ✓ ${name}`); + } else { + failed++; + console.error(` ✗ ${name}`); + } +} + +async function runTests(): Promise { + console.log("\n=== Session Polling Logic ===\n"); + + // Test 1: Immediate ready (3 consecutive status=2) + console.log("Test: Immediate ready (status=2 from start)"); + { + const result = await simulatePollLoop([2, 2, 2]); + assert(result.finalSession !== null, "session is resolved"); + assert(result.finalSession?.status === 2, "final status is 2 (ready)"); + assert(result.attempts === 3, "exactly 3 poll attempts needed"); + assert(result.error === null, "no error thrown"); + assert(!result.aborted, "not aborted"); + assert(result.loadingSteps.length === 0, "no setup steps (went straight to ready)"); + } + + // Test 2: status=1 provisioning then transitions to ready + console.log("\nTest: status=1 (provisioning) then transitions to ready"); + { + const result = await simulatePollLoop([1, 1, 1, 1, 1, 2, 2, 2]); + assert(result.finalSession !== null, "session is resolved"); + assert(result.finalSession?.status === 2, "final status is ready"); + assert(result.attempts === 8, "8 total attempts (5 provisioning + 3 ready)"); + assert(result.error === null, "NO error thrown for status=1"); + assert(!result.aborted, "not aborted"); + assert(result.loadingSteps.length === 5, "5 setup loading step updates"); + } + + // Test 3: Long provisioning (many status=1) still succeeds + console.log("\nTest: Long provisioning (20x status=1 then ready)"); + { + const statuses = Array(20).fill(1).concat([2, 2, 2]); + const result = await simulatePollLoop(statuses); + assert(result.finalSession !== null, "session is resolved after long wait"); + assert(result.error === null, "NO error even after 20 provisioning polls"); + assert(result.attempts === 23, "23 total attempts"); + } + + // Test 4: status=3 (streaming) also counts as ready + console.log("\nTest: status=3 (streaming) counts as ready"); + { + const result = await simulatePollLoop([1, 3, 3, 3]); + assert(result.finalSession !== null, "session is resolved"); + assert(result.finalSession?.status === 3, "final status is 3 (streaming)"); + assert(result.error === null, "no error"); + } + + // Test 5: Mixed ready/provisioning resets ready count + console.log("\nTest: Ready count resets when status=1 interrupts"); + { + const result = await simulatePollLoop([2, 2, 1, 2, 2, 2]); + assert(result.finalSession !== null, "session eventually resolves"); + assert(result.attempts === 6, "6 total attempts (2 ready, 1 reset, 3 ready)"); + assert(result.error === null, "no error"); + } + + // Test 6: status=6 (cleaning up) is a terminal error + console.log("\nTest: status=6 (cleaning up) throws terminal error"); + { + const result = await simulatePollLoop([1, 1, 6]); + assert(result.finalSession === null, "no session resolved"); + assert(result.error !== null, "error was thrown"); + assert(result.error!.includes("cleaned up"), "error mentions cleanup"); + } + + // Test 7: AbortController cancels polling + console.log("\nTest: AbortController cancels polling cleanly"); + { + const result = await simulatePollLoop( + Array(100).fill(1), + { abortAfterMs: 150, pollDelayOverride: 50 }, + ); + assert(result.finalSession === null, "no session resolved"); + assert(result.aborted, "was aborted"); + assert(result.error === null, "no error (abort is not an error)"); + } + + // Test 8: Hard timeout triggers after max time + console.log("\nTest: Hard timeout triggers on stuck provisioning"); + { + const result = await simulatePollLoop( + Array(1000).fill(1), + { hardTimeoutMs: 300, pollDelayOverride: 50 }, + ); + assert(result.finalSession === null, "no session resolved"); + assert(result.error !== null, "error was thrown"); + assert(result.error!.includes("timed out"), "error mentions timeout"); + assert(!result.aborted, "not aborted (was a timeout)"); + } + + // Test 9: Unknown status doesn't crash, just continues + console.log("\nTest: Unknown status (e.g. 99) continues polling"); + { + const result = await simulatePollLoop([99, 99, 2, 2, 2]); + assert(result.finalSession !== null, "session eventually resolves"); + assert(result.error === null, "no error for unknown status"); + assert(result.attempts === 5, "5 total attempts"); + } + + // Test 10: No duplicate concurrent polls (single-flight guard) + console.log("\nTest: Exponential backoff increases delay"); + { + const start = Date.now(); + await simulatePollLoop([1, 1, 1, 1, 2, 2, 2], { pollDelayOverride: 20 }); + const elapsed = Date.now() - start; + assert(elapsed >= 100, `total time ${elapsed}ms >= 100ms (backoff effect)`); + } + + // ── Summary ───────────────────────────────────────────────────────── + console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests().catch((e) => { + console.error("Test runner crashed:", e); + process.exit(1); +});