evOo-local/apps/mobile/lib/computer-refresh.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

149 lines
4.5 KiB
TypeScript

import type { ComputerStatus } from "@rakazo/contracts";
/**
* Every `computer/screenUrl` call seals a fresh capability, and the viewer is keyed by that URL.
* Re-reading it on each poll would remount the viewer every two seconds, so polls only read the
* screen when the status that shapes it changed, when the held URL nears expiry, or after the
* viewer reported the current URL unusable.
*/
export const SCREEN_URL_RENEW_MS = 50 * 60_000;
function screenKey(status: ComputerStatus) {
return [
status.state,
status.mode,
status.kind,
status.controlHolder,
status.controlBotId,
status.screenAvailable,
status.screenWidth,
status.screenHeight,
status.homeRevision,
].join("|");
}
/** One polling lifecycle per mounted computer target. Explicit refreshes supersede older reads. */
export function createComputerRefresh(options: {
readStatus: () => Promise<ComputerStatus>;
readScreen: (attempts: number) => Promise<string | null>;
onStatus: (status: ComputerStatus) => void;
onScreen: (url: string | null) => void;
onReady: () => void;
onInitialError: (error: unknown) => void;
}) {
let active = false;
let revision = 0;
let lifetime = 0;
let activeActions = 0;
let pendingRevision: number | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
// A completed read is cached even when it returned null (no screen for this status), so the
// status alone decides when to ask again; the viewer clears the cache when a URL stops working.
let screenLoaded = false;
let screenStatus: string | undefined;
let screenReadAt = 0;
function screenStale(status: ComputerStatus, force: boolean) {
return (
force ||
!screenLoaded ||
screenStatus !== screenKey(status) ||
Date.now() - screenReadAt >= SCREEN_URL_RENEW_MS
);
}
function invalidate() {
revision += 1;
clearTimeout(timer);
}
function schedule() {
clearTimeout(timer);
if (!active || activeActions > 0 || pendingRevision === revision) return;
timer = setTimeout(() => void refresh({ poll: true }).catch(() => undefined), 2000);
}
async function refresh({ screenAttempts = 1, poll = false } = {}) {
if (!active) return;
invalidate();
const requestRevision = revision;
pendingRevision = requestRevision;
const current = () => active && requestRevision === revision;
try {
const status = await options.readStatus();
if (!current()) return;
options.onStatus(status);
if (screenStale(status, !poll)) {
try {
const url = await options.readScreen(screenAttempts);
if (!current()) return;
screenLoaded = true;
screenStatus = screenKey(status);
screenReadAt = Date.now();
options.onScreen(url);
} catch {
// Keep the last URL after a failed read; a successful null clears it.
}
}
if (!current()) return;
options.onReady();
return status;
} catch (error) {
if (current()) throw error;
} finally {
if (pendingRevision === requestRevision) pendingRevision = undefined;
if (current()) schedule();
}
}
return {
refresh: (input?: { screenAttempts?: number }) => refresh(input),
/** The viewer could not use the current URL: read a fresh one on the next poll. */
invalidateScreen() {
screenLoaded = false;
},
isActive: () => active,
beginAction() {
const started = active;
const actionLifetime = lifetime;
let finished = false;
const isActive = () => started && active && actionLifetime === lifetime;
if (started) {
activeActions += 1;
invalidate();
}
return {
isActive,
refresh: (input?: { screenAttempts?: number }) =>
isActive() && !finished ? refresh(input) : Promise.resolve(undefined),
finish() {
if (finished) return;
finished = true;
if (!isActive()) return;
activeActions -= 1;
schedule();
},
};
},
start() {
active = true;
const initial = refresh();
const initialRevision = revision;
void initial.catch((error) => {
if (active && revision === initialRevision) {
options.onInitialError(error);
options.onReady();
}
});
},
dispose() {
active = false;
lifetime += 1;
activeActions = 0;
screenLoaded = false;
screenStatus = undefined;
invalidate();
},
};
}