// The desktop window once the person's bots moved to My Cloud (one-home.mjs): every path that shows a window goes // through show(), which opens the place asked for (My Cloud, or a team's Cloud from the verified session), waking My // Cloud through the Admin and signing in again by itself, or this computer's own screen at HOME_SCREEN_PATH while that // place can't be reached, its plan has a problem, or this device must sign in again. Until the move is done, and for // someone with no Cloud, show() is exactly what the app did before, except that a team's Cloud opens the same way. // // Security: this computer's frozen copy is never shown once its bots moved, and never lifted for another account or a // signed-out app (its automations would run twice). The page that counts as home is the main window's main frame at the // shown place's verified origin, nothing else. A team is only ever one the Admin's session lists, paired through the // Admin's team pairing (its claims are the Admin's), never an address a page supplied. import { cloudHomeConnectUrl, cloudSetupOf, cloudUnreachableCause, cloudUnreachableText, machineSetup, readyCloudOrigin } from "./cloud-home.mjs"; import { cloudMachine, cloudState, myCloudOf, otherOwnClouds, ownCloud } from "./cloud-list.mjs"; import { asleepAnswer, BACKGROUND_HEADERS, openSleepingCloud, SIGNED_IN_MS } from "./cloud-power.mjs"; import nativeText from "./native-text.cjs"; import { awayIn, parseJournal, sameCloud } from "./one-home.mjs"; export const HOME_SCREEN_PATH = "/home"; /** The look at an awake My Cloud before the window moves there: under 3 s, so the offline screen shows within 3 s. */ export const LOOK_MS = 2_500; /** A place whose page hasn't loaded this long after the window went there (it answered the look, then nothing) is the * offline screen, with Try again: never an endless spinner. */ export const OPEN_MS = 30_000; export const RETRY_MS = Object.freeze([2_000, 4_000, 8_000, 15_000, 30_000]); /** A brand-new Cloud's name can be missing for minutes after the Admin says it is ready: a resolver that was asked while * it was set up keeps that "no such name" (RFC 2308 negative caching; Chromium's own host cache does too). For a Cloud * ready within FRESH_MS, a name not found keeps the setup screen up and the window tries again on NAME_RETRY_MS, with * Chromium's host cache cleared each time, for NAME_WAIT_MS; after that it is the offline screen. */ export const FRESH_MS = 15 * 60_000; export const NAME_WAIT_MS = 10 * 60_000; export const NAME_RETRY_MS = Object.freeze([5_000, 10_000, 20_000, 40_000, 60_000]); /** Chromium's net errors for a name it could not find (net/base/net_error_list.h): NAME_NOT_RESOLVED, * NAME_RESOLUTION_FAILED and the DNS range. */ export const nameNotFound = (code) => code === -105 || code === -137 || (code <= -800 && code > -900); /** A new version of My Cloud's page waits for this long without typing before the window reloads it. */ export const QUIET_MS = 30_000; export const VERSION_EVERY_MS = 60_000; /** A home page that loads this soon after the window reloaded it for a newer version is that version. */ export const UPDATING_MS = 30_000; /** While the startup screen is up, a wake that takes longer hands over to this computer's screen, which says the same. */ export const SPLASH_HANDOFF_MS = 6_000; /** A team's Cloud ends each session within an hour of its pairing (T2), so the window signs in again before that, without * moving the page: every TEAM_RENEW_MS after a pairing, or TEAM_RENEW_EARLY_MS before the end its session reports. The * Admin refusing that pairing (404) means the person is no longer in the team. */ export const TEAM_RENEW_MS = 45 * 60_000; export const TEAM_RENEW_EARLY_MS = 10 * 60_000; export const TEAM_RENEW_RETRY_MS = 5 * 60_000; const SPLASH_KEYS = Object.freeze({ opening: ["native.splash.openingCloud", "native.splash.openingNamed"], waking: ["native.splash.wakingCloud", "native.splash.wakingNamed"], }); /** The startup screen's line: My Cloud's words, or the team's name. */ export function splashText(status, name = null, t = nativeText.englishText.t) { const [cloud, named] = SPLASH_KEYS[status === "waking" ? "waking" : "opening"]; return name ? t(named, { name }) : t(cloud); } export const SPLASH_TEXT = Object.freeze({ opening: splashText("opening"), waking: splashText("waking") }); const PLAN_PROBLEMS = new Set(["stopped", "payment-problem", "failed"]); /** The screens of a listed Cloud that isn't running (`cloudScreen`): nothing to look again for, the session's word moves them. */ const NOT_RUNNING = new Set(["ended", "checkout", "payment"]); const MOVING = new Set(["freeze", "final", "check"]); const DEFAULT_TIMERS = { set: (work, ms) => { const timer = setTimeout(work, ms); timer.unref?.(); return timer; }, clear: (timer) => clearTimeout(timer), every: (work, ms) => { const timer = setInterval(work, ms); timer.unref?.(); return timer; }, }; const originOf = (url) => { try { return new URL(url).origin; } catch { return null; } }; /** The Cloud this computer moved to, at the address the verified session gives My Cloud now when My Cloud is that Cloud by * its id or by an address it had before it moved to another provider, for the journal's own account; as kept otherwise. */ function followedCloud(kept, state) { const mine = myCloudOf(state); if (!mine?.origin || !kept.origin || mine.origin === kept.origin || (kept.account && kept.account !== state.account?.id)) return kept; return sameCloud(mine, kept) ? { ...kept, origin: mine.origin } : kept; } /** * Who the window opens. `journal`: one-home.json read back; `frozenCloud`: this computer's freeze marker when there is no * journal (its Cloud's origin, or true when it names none); `state`: the MausBot Cloud sign-in's state. An ended My Cloud * sends home only a computer that moved to that Cloud; a freeze that names another Cloud of the signed-in person's own list * is theirs, and opens that Cloud. * @returns {{ kind: "computer" } | { kind: "cloud", origin: string } | { kind: "signin", other?: true }} */ export function homeOf({ journal = null, frozenCloud = null, state = null }) { const kept = journal ? (awayIn(journal) ? journal.cloud : null) : frozenCloud ? { origin: typeof frozenCloud === "string" ? frozenCloud : null, account: null } : null; if (!kept) return { kind: "computer" }; const moved = followedCloud(kept, state); const connected = state?.status === "connected"; const signedInNow = state?.account?.id ?? null; // This computer moved to another account's Cloud: its frozen copy stays frozen, and this account's own Cloud is what opens. if (moved.account && signedInNow && moved.account !== signedInNow && state.status !== "signed-out" && state.status !== "connecting") { const own = connected && !state.machine?.hold ? state.machine?.origin ?? null : null; return own && own !== moved.origin ? { kind: "cloud", origin: own, otherAccount: true } : { kind: "signin", other: true }; } if (connected && state.machine?.hold && (!moved.origin || !state.machine.origin || state.machine.origin === moved.origin)) return { kind: "computer" }; const signedIn = state?.account?.id ?? null; if (!signedIn || state.status === "signed-out" || state.status === "connecting") return { kind: "signin" }; if (moved.account && moved.account !== signedIn) return { kind: "signin", other: true }; const theirs = connected ? state.machine?.origin ?? null : null; const origin = moved.origin ?? theirs; if (!origin) return { kind: "signin" }; const listed = connected && Array.isArray(state.clouds) && state.clouds.some((cloud) => ownCloud(cloud) && cloud.origin === origin); if (!moved.account && theirs && theirs !== origin && !listed) return { kind: "signin", other: true }; return { kind: "cloud", origin }; } /** Nobody here can reach the bots: nobody is signed in, or the account signed in has no way to their Cloud, so the clean * workspace opens (clean-workspace.mjs). An account the Admin can't be asked about isn't that, nor a sign-in still being read: * those are a Cloud that can't be reached yet, never an empty workspace that reads as their bots being gone. */ export function noWayHome(worked, state) { if (worked.kind !== "signin" || state?.message === "restoring") return false; return worked.other === true || !state?.account?.id || ["signed-out", "connecting", "connected"].includes(state.status); } /** A place besides My Cloud: a team's Cloud, or another Cloud of the person's own (`own`), each the session's. */ export const otherPlace = (place) => place?.kind === "team" || place?.kind === "own"; /** A place's plan problem. A team's User can't do anything about it, so the screen sends them to its owner (`member`). */ const planScreen = (machine, place) => ({ screen: "plan", machine, ...(place?.kind === "team" && place.role === "member" ? { member: true } : {}) }); /** * This computer's screen for a Cloud the session's list says isn't running, so its address is never looked up: it has ended * (stopped too; `deleteAt`, when its kept files are deleted), its checkout wasn't finished, or its payment has a problem, each * named and with Remove from list while its owner may (`removable`); or its setup's step. Null for one that opens. */ export function cloudScreen(cloud) { const state = cloudState(cloud); if (!state) return null; if (state === "setup") return { screen: "setup", ...(cloud.setup?.step ? { step: cloud.setup.step } : {}) }; return { screen: state, name: cloud.name, ...(state === "ended" && cloud.deleteAfter ? { deleteAt: cloud.deleteAfter } : {}), ...(cloud.removable ? { removable: true } : {}) }; } /** Another Cloud of the person's own, asked for (`{ cloud }`) or remembered: the session's word, as placeOf reads a team. One * the connected session no longer lists is simply My Cloud again; while the Admin can't be asked it opens as it was saved. */ function ownPlaceOf(asked, remembered, state) { const id = asked.cloud, cloud = otherOwnClouds(state?.clouds).find((entry) => entry.id === id) ?? null; if (cloud) return { kind: "own", id, name: cloud.name, role: cloud.role, origin: cloud.origin, machine: cloudMachine(cloud) }; if (state?.status === "connected") return { kind: "cloud" }; const kept = remembered?.cloud === id ? remembered : asked; return kept?.origin ? { kind: "own", id, name: kept.name ?? "the Cloud", role: null, origin: kept.origin, machine: null } : { kind: "cloud" }; } /** * The place a window is asked for. `ask`: "cloud" for My Cloud, `{ team }` for a team's Cloud, `{ cloud }` for another * Cloud of the person's own, or nothing for the place remembered from last time (`remembered`). A team is read from the * verified session (`state.teams`); one the connected session no longer lists is gone, and the answer is My Cloud with * the team's name in `left`, so the window can say so once. While the Admin can't be asked, a remembered team opens as * it was saved, on the window's own session. * @returns {{ kind: "cloud", left?: string | null } | { kind: "team" | "own", id: string, name: string, role: string | null, origin: string | null, machine: any }} */ export function placeOf({ ask = null, remembered = null, state = null }) { const asked = ask ?? remembered; if (asked && asked !== "cloud" && typeof asked.cloud === "string") return ownPlaceOf(asked, remembered, state); const id = asked && asked !== "cloud" && typeof asked.team === "string" ? asked.team : null; if (!id) return { kind: "cloud" }; const team = (Array.isArray(state?.teams) ? state.teams : []).find((entry) => entry?.id === id) ?? null; if (team) return { kind: "team", id, name: team.name, role: team.role, origin: team.machine?.origin ?? null, machine: team.machine ?? null }; if (state?.status === "connected") return { kind: "cloud", left: asked.name ?? (remembered?.team === id ? remembered.name : null) ?? null }; const kept = remembered?.team === id ? remembered : asked; return kept?.origin ? { kind: "team", id, name: kept.name ?? "the team", role: null, origin: kept.origin, machine: null } : { kind: "cloud" }; } /** When this app saw My Cloud become ready after seeing it set up, kept in `store` so a launch soon after knows too. */ export function createReadyClock({ store = null, now = Date.now } = {}) { let settingUp = false, kept; const read = () => { if (kept !== undefined) return kept; try { const value = store?.read(); kept = typeof value?.origin === "string" && Number.isSafeInteger(value.readyAt) ? { origin: value.origin, readyAt: value.readyAt } : null; } catch { kept = null; } return kept; }; return { seen(state) { if (cloudSetupOf(state)) { settingUp = true; return; } const origin = readyCloudOrigin(state); if (!origin || !settingUp) return; settingUp = false; kept = { origin, readyAt: now() }; try { store?.write(kept); } catch {} }, fresh(origin) { const at = read(); return Boolean(origin) && at?.origin === origin && now() - at.readyAt < FRESH_MS; }, }; } /** Only a same-origin path survives a sign-in: never another site, never a code. */ export function routeOf(url, origin) { try { const at = new URL(url); if (at.origin !== origin || at.pathname === "/pair") return null; const route = `${at.pathname}${at.search}`; return route === "/" ? null : route; } catch { return null; } } /** * @param {{ * journal(): unknown, * frozen(): Promise<{ frozen: boolean, cloud?: string } | null>, * account(): { state(): any, power(): any, wakeHome(): Promise, setWaking(on: boolean): void, pairHome(cloudId?: string | null): Promise<{ origin: string, code: string, expiresAt: number }>, * pairTeam?(teamId: string): Promise<{ origin: string, code: string, expiresAt: number }>, signInAgain(): Promise, begin(): Promise, signOut?(): Promise, reopen?(): Promise } | null, * restored?(): Promise, * window(): { isDestroyed(): boolean, webContents: { id: number, getURL(): string, reload(): void } } | null, * ensureWindow(): void, * localOrigin(): string, * cookieFetch: typeof fetch, * power: { asleep(): boolean, hold(): void, woke(): void }, * go(url: string, origin: string | null, place: { kind: "cloud", origin: string } | { kind: "team" | "own", id: string, name: string, role: string | null, origin: string } | null): Promise, * showLocal(leaving: boolean): unknown, * remembered?(): ({ team: string } | { cloud: string }) & { name?: string, origin?: string } | null, * remember?(place: { team: string } | { cloud: string } | null): void, * left?(name: string): void, * drop?(place: { kind: "team" | "own", id: string, name: string }): void, * splash?(): { status(text: string): void } | null, * lastInput?(): number, * publish?(view: unknown): void, * rehomed?(home: { kind: string, origin?: string }): void, * versionEveryMs?: number, * quietMs?: number, * now?(): number, * timers?: { set(work: () => void, ms: number): unknown, clear(timer: unknown): void, every(work: () => void, ms: number): unknown }, * hosts?(): { clearHostResolverCache(): Promise, resolveHost(host: string, options?: { cacheUsage?: "allowed" | "staleAllowed" | "disallowed" }): Promise<{ endpoints: unknown[] }> } | null, * readyStore?: { read(): unknown, write(value: unknown): void }, * ownMove?(): { phase: string, name: string } | null, * log?(line: string): void, * t?(key: string, params?: Record): string, * }} deps `ownMove()`: the move to a server of the person's own (own-home-main.mjs), whose freeze is never My Cloud's. * `hosts`: the window's Electron session, for its own resolver and host cache. `readyStore`: when My Cloud * became ready (createReadyClock). `go(url, origin, place)`: main's one way to move the window, to My Cloud or a team's Cloud (`origin`, with the * `place` it is) or this computer's screen (null). `showLocal(leaving)`: the window as it was before the move (this computer's page, or a saved server it * shows); `leaving`: it showed a place or this computer's screen until now, so it goes back to this computer's page. * `reopen(place, still)`: this computer's copy moved to a server of the person's own, so the window goes back to that * server (`origin`, `place`); false when it isn't saved here any more, doesn't answer, or `still()` says a newer `show()` took over. * `frozen()`: this computer's server's freeze marker. `splash()`: the startup screen while it is up. `remembered()`: the * team or other Cloud shown last time, with its name and origin as saved; `remember(place)`: what to open next time; * `left(name)`: the team shown is no longer this person's, so the window says so once; `drop(place)`: the Admin said so * (404 on its pairing), so the team (with that line) or the Cloud leaves the switcher now. `rehomed(home)`: home as worked * out changed. */ export function createHomeWindow(deps) { const { journal, frozen, account, restored = async () => {}, window, ensureWindow, localOrigin, cookieFetch, power, go, showLocal, clean = null, reopen = async () => false, remembered = () => null, remember = () => {}, left = () => {}, drop = (place) => { if (place.kind === "team") left(place.name); remember(null); }, splash = () => null, lastInput = () => 0, publish = () => {}, rehomed = () => {}, versionEveryMs = VERSION_EVERY_MS, quietMs = QUIET_MS, now = Date.now, timers = DEFAULT_TIMERS, hosts = () => null, readyStore = null, ownMove = () => null, log = () => {}, t = nativeText.englishText.t } = deps; let home = { kind: "computer" }, screen = null, status = null, opens = 0, retry = null, tries = 0, loaded = null, newer = false, repairing = null; let recovering = null; /** The profile (clean-workspace.mjs) the person opened from the rail while My Cloud is home, until they pick a place. */ let profileShown = null; /** My Cloud asked for before the move made it home (`{ open }`), until its page loads or the person goes elsewhere; * `naming`: since when, and how often, a brand-new My Cloud's name was not found. */ let asked = null, naming = null; const clock = createReadyClock({ store: readyStore, now }); /** The place the window was last sent to (My Cloud, or a team's Cloud), and the one being opened now. */ let shown = null, opening = null, renewal = null, updatingAt = null; /** The window shows a place or this computer's screen, which this computer's page replaces once home is here again. */ let away = false; const state = () => account()?.state() ?? null; const wanted = (token) => token === opens; const cloudOrigin = () => (home.kind === "cloud" ? home.origin : null); /** The Cloud at `origin` sleeps now: My Cloud by its gate (the Admin's word and its own answer), any other of the * person's Clouds by the Admin's word in the Clouds list. A request to a sleeping one would wake it. */ const asleepAt = (origin) => (origin && origin === cloudOrigin() ? power.asleep() : account()?.powerOf?.(origin)?.state === "asleep"); /** The origin of the place on screen, or on its way: a team's Cloud while one is shown, else My Cloud. */ const placeOrigin = () => (otherPlace(shown) ? (signedOut() ? null : shown.origin) : cloudOrigin()); const teamName = () => (otherPlace(opening) ? opening.name : null); const pair = (place) => (place.kind === "team" ? account()?.pairTeam?.(place.id) : place.kind === "own" ? account()?.pairHome(place.id) : account()?.pairHome()); /** The same place still the one on screen (a newer show() replaces the object, not the place). */ const still = (place) => shown?.kind === place.kind && shown.id === place.id && shown.origin === place.origin; const clearRenewal = () => { if (renewal) timers.clear(renewal); renewal = null; }; async function refresh() { clock.seen(state()); const kept = parseJournal(journal()); let frozenCloud = null, frozenPlace = null; if (!awayIn(kept)) { const marker = await frozen().catch(() => null); if (marker?.frozen && !marker.place) frozenCloud = typeof marker.cloud === "string" ? marker.cloud : true; else if (marker?.frozen && marker.place) frozenPlace = { origin: typeof marker.cloud === "string" ? marker.cloud : null, place: String(marker.place) }; } if (frozenCloud || awayIn(kept)) await restored(); const before = home; const worked = homeOf({ journal: kept, frozenCloud, state: state() }); // Home is this computer then, in its clean workspace, so a Cloud of this account's own opens from it as for anyone. home = clean && noWayHome(worked, state()) ? { kind: "computer", clean: true, ...(worked.other ? { other: true } : {}) } : worked; if (frozenPlace) home = { ...home, frozenPlace }; if (home.kind !== before.kind || home.origin !== before.origin || home.clean !== before.clean) { try { rehomed(home); } catch {} } return home; } /** What this computer's screen says: why the place isn't open, or that it is opening; `name` while it is a team's. */ function view() { const name = teamName(); const named = (value) => (name ? { ...value, name } : value); if (screen) return named(screen); if (status) return named({ screen: status }); if (home.kind === "signin" && !name) return { screen: "signin" }; const own = ownMove(); if (own && MOVING.has(own.phase)) return { screen: "moving", name: own.name }; return named({ screen: MOVING.has(parseJournal(journal())?.phase ?? "") ? "moving" : "opening" }); } const tell = () => { try { publish(view()); } catch {} }; let splashSaid = null; const onSplash = (text) => { if (text === splashSaid) return; splashSaid = text; try { splash()?.status(text); } catch {} }; function say(next) { status = next; if (next) onSplash(splashText(next, teamName(), t)); tell(); } const shows = (origin) => { const win = window(); return Boolean(win && !win.isDestroyed()) && originOf(win.webContents.getURL()) === origin; }; const onOwnPage = () => { const win = window(); const at = win && !win.isDestroyed() ? originOf(win.webContents.getURL()) : null; return !at || at === localOrigin(); }; const onScreen = () => { const win = window(); if (!win || win.isDestroyed()) return false; try { const at = new URL(win.webContents.getURL()); return at.origin === localOrigin() && at.pathname === HOME_SCREEN_PATH; } catch { return false; } }; const clearRetry = () => { if (retry) timers.clear(retry); retry = null; }; /** Whatever was being opened for an earlier click stops; the window still shows what it showed. */ function stopOpening() { opens++; recovering = null; clearRetry(); screen = null; status = null; } function goScreen() { tell(); away = true; if (!onScreen()) void Promise.resolve(go(`${localOrigin()}${HOME_SCREEN_PATH}`, null, null)).catch(() => {}); } /** The place can't be shown now: this computer's screen says why, and the startup screen, while it is up, that the place * is opening. */ function toScreen(next) { screen = next; status = null; onSplash(splashText("opening", teamName(), t)); goScreen(); } /** The place didn't answer: this computer's screen says so, with the Admin's reason for My Cloud, and looks again by itself. */ function offline(token) { if (!wanted(token)) return; const cause = otherPlace(opening) ? null : cloudUnreachableCause(state(), cloudOrigin()); toScreen({ screen: "offline", ...(cause ? { reason: cloudUnreachableText(state(), cloudOrigin()), cause } : {}) }); later(); } function later(delays = RETRY_MS, count = () => tries++) { clearRetry(); const ms = delays[Math.min(count(), delays.length - 1)]; retry = timers.set(() => { retry = null; if (screen) void show(); }, ms); } /** Whether the window's own resolver finds `origin`'s name now, its host cache bypassed. */ async function found(origin) { const resolver = hosts(); if (!resolver) return true; const slow = new Promise((done) => { const timer = setTimeout(() => done(null), 2 * LOOK_MS); timer.unref?.(); }); try { return (await Promise.race([resolver.resolveHost(new URL(origin).hostname, { cacheUsage: "disallowed" }), slow]))?.endpoints?.length > 0; } catch { return false; } } const forgetNames = () => Promise.resolve().then(() => hosts()?.clearHostResolverCache()).catch(() => {}); /** My Cloud didn't answer: a brand-new one whose name isn't found yet is still settling in (the setup screen, tried * again sooner, for NAME_WAIT_MS); anything else is the offline screen. */ async function unreachable(place, token, missing = null) { if (place.kind !== "cloud" || !(naming || clock.fresh(place.origin)) || (missing ?? !(await found(place.origin))) === false) return offline(token); if (!wanted(token)) return; naming ??= { since: now(), tries: 0 }; if (now() - naming.since >= NAME_WAIT_MS) return offline(token); log(`home: My Cloud's name isn't found yet; trying again (${naming.tries + 1})`); toScreen({ screen: "setup", step: "checking" }); later(NAME_RETRY_MS, () => naming.tries++); } /** The person went somewhere else while My Cloud was asked for: nothing pulls the window back. */ function letAskGo() { asked = null; naming = null; screen = null; status = null; away = false; clearRetry(); } const signInNeeded = () => ["reauth-required", "signed-out"].includes(state()?.status); /** This device has no MausBot Cloud sign-in to pair a team with (homeOf's rule for My Cloud). */ const signedOut = () => { const current = state(); return !current?.account?.id || ["signed-out", "connecting"].includes(current.status); }; /** The session's list's word for My Cloud (the account's primary Cloud), else null: an older Admin, or one it doesn't list. */ const myListed = () => { const current = state(); return current?.status === "connected" && Array.isArray(current.clouds) ? current.clouds.find((cloud) => cloud.primary) ?? null : null; }; /** My Cloud's own screen while the session says it isn't running: its list's word (`cloudScreen`) when the Admin sends a list * (none for a My Cloud off it: its owner removed it from their list), else its machine's (an older Admin). Null while it * runs or is set up: those open as before. */ function myCloudScreen() { const current = state(); if (current?.status !== "connected") return null; if (Array.isArray(current.clouds)) { const listed = cloudScreen(myListed()); return listed && listed.screen !== "setup" ? listed : null; } return current.machine && PLAN_PROBLEMS.has(current.machine.status) ? { screen: "plan", machine: current.machine.status } : null; } /** The session says My Cloud isn't running (its screen says so, or it was removed from the list): never looked up then. */ const notRunning = () => Boolean(myCloudScreen()) || (state()?.status === "connected" && PLAN_PROBLEMS.has(state().machine?.status)); async function answers(origin) { try { const response = await cookieFetch(`${origin}/api/health`, { credentials: "include", cache: "no-store", redirect: "manual", signal: AbortSignal.timeout(LOOK_MS), headers: { accept: "application/json", ...BACKGROUND_HEADERS } }); await response.body?.cancel?.().catch(() => {}); if (asleepAnswer(response)) return "asleep"; return response.type === "opaqueredirect" || response.status < 500 ? "yes" : "no"; } catch { return "no"; } } /** The window's own session there: `{ expiresAt }` (null when the server names no end), or null when it has none. */ async function sessionAt(origin, timeoutMs) { try { const response = await cookieFetch(`${origin}/api/auth/session`, { credentials: "include", cache: "no-store", signal: AbortSignal.timeout(timeoutMs) }); const body = response.ok ? await response.json() : null; return body?.kind === "session" ? { expiresAt: Number.isSafeInteger(body.expiresAt) ? body.expiresAt : null } : null; } catch { return null; } } const signedInAt = async (origin, timeoutMs) => Boolean(await sessionAt(origin, timeoutMs)); /** A one-time code redeemed into the window's own cookie, as lending does for My Cloud: the page doesn't move. */ async function redeem(origin, grant) { try { const response = await cookieFetch(`${origin}/api/auth/pair`, { method: "POST", credentials: "include", redirect: "error", cache: "no-store", signal: AbortSignal.timeout(15_000), headers: { "content-type": "application/json", origin }, body: JSON.stringify({ code: grant.code, cookie: true, label: "OpenMausBot app" }) }); await response.body?.cancel?.().catch(() => {}); return response.ok; } catch { return false; } } /** The next silent sign-in to the team on screen: `ends`, the end its session reports, else TEAM_RENEW_MS from now. */ function renewLater(place, ms) { clearRenewal(); renewal = timers.set(() => { renewal = null; void renew(place); }, Math.max(0, Math.min(ms, TEAM_RENEW_MS))); } /** The team on screen signs in again before its session ends, without moving the page; the Admin refusing means the * person was removed. Nothing happens for a team that is no longer on screen. */ async function renew(place = shown) { clearRenewal(); if (place?.kind !== "team" || !still(place) || !shows(place.origin)) return; let grant = null; try { grant = await pair(place); } catch (error) { if (error?.gone) { if (still(place)) await gone(place); return; } log(`home: could not sign in to ${place.name} again (${error?.message ?? error})`); } if (!still(place)) return; renewLater(place, grant && await redeem(place.origin, grant) ? TEAM_RENEW_MS : TEAM_RENEW_RETRY_MS); } /** The Admin says the team is no longer this person's: it leaves the switcher now, and My Cloud opens with one line. */ function gone(place) { drop(place); clearRenewal(); if (shown?.kind === place.kind && shown.id === place.id) shown = null; return show({ place: "cloud" }); } async function connect(place, token, open) { const origin = place.origin; // Only a window this opened as home is left as it is: one that shows My Cloud as a saved server (opened from the // switcher before the move) is opened again as home, and goes home with it if My Cloud ends. if (shows(origin) && away && !screen && !open) { shown = place; if (place.kind === "team" && !renewal) renewLater(place, TEAM_RENEW_MS); return; } clearRetry(); opening = place; const client = account(); const machine = otherPlace(place) ? place.machine : state()?.status === "connected" ? state().machine : null; // My Cloud the session's list says isn't running: its own screen, never its address, and nothing to look again for. const listed = place.kind === "cloud" ? cloudScreen(myListed()) : null; if (listed && NOT_RUNNING.has(listed.screen)) { clearRetry(); return toScreen(listed); } if (machine && (PLAN_PROBLEMS.has(machine.status) || (place.kind === "team" && machine.hold))) { toScreen(planScreen(PLAN_PROBLEMS.has(machine.status) ? machine.status : "stopped", place)); later(); return; } // Its address is never looked up while it is set up: the session's next word (changed()) opens it. const setup = machineSetup(machine); if (setup) return toScreen({ screen: "setup", ...setup }); const win = window(); const blank = !win || win.isDestroyed() || !originOf(win.webContents.getURL()); // A team's Cloud never sleeps, and the Admin wakes only My Cloud: a team that answers asleep is simply not reachable yet. let asleep = place.kind === "cloud" && power.asleep(); say(asleep ? "waking" : "opening"); const handoff = blank ? timers.set(() => { if (wanted(token) && status) goScreen(); }, SPLASH_HANDOFF_MS) : null; try { if (!asleep) { if (naming) await forgetNames(); const look = await answers(origin); if (look === "no") return unreachable(place, token); if (look === "asleep" && otherPlace(place)) return offline(token); if (look === "asleep") { power.hold(); asleep = true; if (wanted(token)) say("waking"); } } let session = null; const signedIn = asleep ? await openSleepingCloud({ power: client?.power() ?? { state: "asleep" }, wake: () => client?.wakeHome(), waking: (on) => client?.setWaking(on), signedIn: (ms) => signedInAt(origin, ms) }) : Boolean(session = await sessionAt(origin, SIGNED_IN_MS)); if (!wanted(token)) return; let grant = null; if (!signedIn) { try { grant = await pair(place); } catch (error) { if (error?.gone && wanted(token)) return gone(place); log(`home: no sign-in code for ${otherPlace(place) ? place.name : "My Cloud"} (${error?.message ?? error})`); } if (!wanted(token)) return; if (!grant) { if (signInNeeded()) return toScreen({ screen: "signin" }); return offline(token); } } // My Cloud opened before the move made it home is a saved server the window shows, as the Server menu opens one. const asHome = otherPlace(place) || home.kind !== "computer"; screen = null; status = null; tries = 0; away = asHome; shown = asHome ? place : null; // A page that never finishes loading is the offline screen after OPEN_MS, which looks again by itself and has Try again. let slow = null; const loaded = await Promise.race([Promise.resolve(go(cloudHomeConnectUrl({ origin, grant }, now(), open === "phone" ? "phone" : null), origin, place)).then(() => true, () => true), new Promise((done) => { slow = timers.set(() => done(false), OPEN_MS); })]); timers.clear(slow); if (!loaded) { log(`home: ${otherPlace(place) ? place.name : "My Cloud"} didn't load in ${OPEN_MS / 1000} s`); return offline(token); } if (place.kind === "cloud") { clearRenewal(); power.woke(); } else if (place.kind === "team") renewLater(place, grant || !session?.expiresAt ? TEAM_RENEW_MS : session.expiresAt - now() - TEAM_RENEW_EARLY_MS); else clearRenewal(); tell(); } finally { if (handoff) timers.clear(handoff); } } /** Shows the window onto the person's home. `open: "phone"` opens My Cloud's Settings on its phone pairing. `place`: * "cloud" for My Cloud, `{ team }` for a team's Cloud, `{ cloud }` for another of the person's own, else the place * remembered from last time. */ async function show({ open = null, place = null } = {}) { if (recovering) { if (!open && !place) return; recovering = null; } const token = ++opens; if (open || place) profileShown = null; const current = await refresh(); if (!wanted(token)) return; if (current.kind !== "cloud") profileShown = null; // This computer's own copy is the server again before anything of the person's Cloud opens; a profile they opened keeps // running, so going back to it is one click. if (!current.clean && clean?.active() && !clean.profile?.()) { await clean.leave(); if (!wanted(token)) return; } if (profileShown) { opening = null; asked = null; naming = null; clearRenewal(); if (await clean.open(profileShown)) { if (!wanted(token)) return; // `away`: a Cloud's page may still be on its way (a click just before this one); this page's load replaces it. const leaving = away || onScreen() || !shows(localOrigin()); ensureWindow(); clearRetry(); screen = null; status = null; shown = null; away = false; return showLocal(leaving); } profileShown = null; if (!wanted(token)) return; } // Nobody signed in has nothing to open but the clean workspace; the place stays remembered for when they're back. const target = current.clean && signedOut() ? { kind: "cloud" } : placeOf({ ask: place, remembered: remembered(), state: state() }); if (target.kind === "cloud" && target.left) { left(target.left); remember(null); } if (otherPlace(target)) { ensureWindow(); opening = target; // Chosen is remembered at once, so the screen's own retry (and the next launch) asks for the same place. remember(target.kind === "own" ? { cloud: target.id } : { team: target.id }); if (signedOut()) { clearRetry(); return toScreen({ screen: "signin" }); } // Another Cloud of their own that the session's list says isn't running: its own screen, never its address. const listed = target.kind === "own" ? cloudScreen(otherOwnClouds(state()?.clouds).find((cloud) => cloud.id === target.id)) : null; if (listed && NOT_RUNNING.has(listed.screen)) { clearRetry(); return toScreen(listed); } const setup = machineSetup(target.machine); if (setup) { clearRetry(); return toScreen({ screen: "setup", ...setup }); } if (!target.origin) { toScreen(planScreen(target.machine?.status ?? "stopped", target)); later(); return; } return connect(target, token, open); } if (place === "cloud" || target.left) remember(null); opening = null; clearRenewal(); if (current.kind === "computer" && asked) { const setup = cloudSetupOf(state()), mine = readyCloudOrigin(state()), stopped = setup || mine ? null : myCloudScreen(); if (setup || mine || stopped) ensureWindow(); if (setup) { clearRetry(); return toScreen({ screen: "setup", ...setup }); } if (mine) return connect({ kind: "cloud", origin: mine }, token, open ?? asked.open); // Not running (it ended, its checkout wasn't finished, a payment problem): its own screen, never its address. if (stopped) { clearRetry(); return toScreen(stopped); } asked = null; naming = null; } if (current.kind === "computer") { const parkedAt = current.frozenPlace; if (!current.clean && parkedAt && !ownMove() && onOwnPage()) { clearRetry(); screen = null; status = null; shown = null; away = false; ensureWindow(); let slow = null; const mine = {}; recovering = mine; try { const back = await Promise.race([ reopen(parkedAt, () => wanted(token)).catch(() => false), new Promise((done) => { slow = timers.set(() => done(false), OPEN_MS); }), ]); timers.clear(slow); if (!wanted(token)) return; if (back) return; toScreen({ screen: "offline", reason: `Your bots are on ${parkedAt.place}.` }); return later(); } finally { if (recovering === mine) recovering = null; } } if (current.clean && !(await clean.enter())) { if (wanted(token)) { ensureWindow(); clearRetry(); toScreen({ screen: "signin", ...(current.other ? { other: true } : {}) }); } return; } if (!wanted(token)) return; clearRetry(); screen = null; status = null; shown = null; const win = window(); const leaving = away || Boolean(win && !win.isDestroyed() && !originOf(win.webContents.getURL())); away = false; return showLocal(leaving); } ensureWindow(); if (current.kind === "signin") { clearRetry(); return toScreen({ screen: "signin", ...(current.other ? { other: true } : {}) }); } return connect({ kind: "cloud", origin: current.origin }, token, open); } /** The place's session in this window ended: a new one by itself, back where the page was. */ function repair() { const place = shown ?? (home.kind === "cloud" ? { kind: "cloud", origin: home.origin } : null); if (repairing || !place) return repairing ?? Promise.resolve(); const origin = place.origin; repairing = (async () => { if (await signedInAt(origin, SIGNED_IN_MS)) return; const win = window(); const route = win && !win.isDestroyed() ? routeOf(win.webContents.getURL(), origin) : null; let grant = null; try { grant = await pair(place); } catch (error) { if (error?.gone) return gone(place); log(`home: could not sign in to ${otherPlace(place) ? place.name : "My Cloud"} again (${error?.message ?? error})`); } if (placeOrigin() !== origin) return; if (!grant) return signInNeeded() ? toScreen({ screen: "signin" }) : offline(++opens); await go(cloudHomeConnectUrl({ origin, grant }, now(), null, route), origin, place); })().finally(() => { repairing = null; }); return repairing; } async function version(origin) { try { const response = await cookieFetch(`${origin}/.well-known/openmausbot/environment`, { cache: "no-store", redirect: "error", signal: AbortSignal.timeout(LOOK_MS), headers: { accept: "application/json", ...BACKGROUND_HEADERS } }); const body = response.ok ? await response.json() : null; return typeof body?.version === "string" ? body.version : null; } catch { return null; } } /** A newer page reloads at a quiet moment; a Cloud asleep is never asked (that would start it). */ async function watchVersion() { const origin = placeOrigin(); if (!origin || !shows(origin) || asleepAt(origin)) return; const current = await version(origin); if (!current) return; if (!loaded) { loaded = current; return; } if (current !== loaded) newer = true; if (!newer || now() - lastInput() < quietMs || !shows(origin)) return; newer = false; loaded = current; updatingAt = now(); window()?.webContents.reload(); } const watcher = timers.every(() => { void watchVersion(); }, versionEveryMs); function homePage(event) { const origin = placeOrigin(), win = window(); if (!origin || !win || win.isDestroyed() || event?.sender !== win.webContents || !event.senderFrame || event.senderFrame !== win.webContents.mainFrame) return false; return originOf(event.senderFrame.url) === origin; } return { show, /** Profile `id` from the rail while My Cloud is home: the window shows it, and My Cloud is one click away. False when it * can't open, and the window shows My Cloud as before. */ async openProfile(id) { if (!clean?.open || (await refresh()).kind !== "cloud") return false; profileShown = id; await show(); return profileShown === id; }, /** The profile on screen, opened from the rail, else null. */ profile: () => profileShown, /** Whether `ask` ("cloud" for My Cloud, or a team or another Cloud of the person's own as remembered) would open now, by * the session's word and nothing looked up: false for one gone from its list, not running, being set up or without an * address, and for My Cloud while it isn't home or isn't running; null while nobody is signed in. As of the last * refresh(). */ opensNow(ask) { if (signedOut()) return null; if (ask === "cloud") return home.kind === "cloud" && !notRunning(); const target = placeOf({ ask, remembered: remembered(), state: state() }); if (!otherPlace(target) || !target.origin || machineSetup(target.machine)) return false; return !target.machine || !(PLAN_PROBLEMS.has(target.machine.status) || (target.kind === "team" && target.machine.hold)); }, /** The person asked for My Cloud (the Server menu, the welcome, Settings, the ready notice). Before the move made it * home it opens as a saved server, and while the Admin sets it up the setup screen shows until it opens by itself. */ async openCloud(open = null) { if ((await refresh()).kind !== "computer") return show({ place: "cloud", open }); if (!cloudSetupOf(state()) && !readyCloudOrigin(state()) && !notRunning()) throw new Error(t("native.cloud.notReady")); asked = { open }; return show({ place: "cloud", open }); }, /** Remove from list, from this computer's screen (its own button, once the person said yes there): the Cloud the screen * shows leaves their list on the Admin (cloud-account.mjs hideCloud), and the window goes where it would without it. * Answers the outcome; nothing is asked of a Cloud the session doesn't say is `removable`. */ async remove() { const own = opening?.kind === "own" ? opening : null, cloudId = own ? own.id : myListed()?.id ?? null; const cloud = cloudId ? (state()?.clouds ?? []).find((entry) => entry.id === cloudId) ?? null : null; if (!cloud?.removable || !screen?.removable) return "unsupported"; const client = account(); const outcome = client?.hideCloud ? (await client.hideCloud(cloudId).catch(() => ({ outcome: "failed" }))).outcome : "unsupported"; if (outcome !== "removed" && outcome !== "gone") return outcome; if (own) { if (opening === own) opening = null; drop(own); } else { asked = null; naming = null; } screen = null; status = null; clearRetry(); await show({ place: "cloud" }); return outcome; }, /** My Cloud is asked for and not open yet: the window shows this computer's screen for it. */ asking: () => Boolean(asked), refresh, view, /** My Cloud's origin while it is home, else null. */ cloudOrigin, /** Whether the Cloud at `origin` sleeps now (see `asleepAt`): nothing in the background asks it then. */ asleepAt, /** The origin of the place the window shows (a team's Cloud, or My Cloud), else null. */ placeOrigin, /** Home as last worked out. */ current: () => home, /** The place the window was last sent to: My Cloud, or a team's or another own Cloud (`{ kind: "team" | "own", id, name, origin }`). */ shown: () => shown, /** The person asked again (the screen's timer, back online, or the window focused). */ retry: () => show(), /** The screen's one button: this device's MausBot Cloud sign-in, again. A sign-in already open in the browser opens * again; one with an account My Cloud isn't (`other`) ends first, so the browser can sign in another; one that is still * good only looks again. */ async signIn() { const client = account(); if (!client) return; const status = client.state().status; if (status === "reauth-required") return void await client.signInAgain(); if (status === "connecting") return void await client.reopen?.(); if (status !== "signed-out") { const current = await refresh(); if (!current.other) return void await show(); await client.signOut?.(); } await client.begin(); }, repair, /** The MausBot Cloud sign-in changed: a window that waits on it, a home that changed, or a team shown that the * session no longer lists (or lists at another address), shows again. */ async changed() { const before = home; const after = await refresh(); const next = shown && shows(shown.origin) ? placeOf({ remembered: remembered(), state: state() }) : null; const moved = Boolean(next && (next.kind !== shown.kind || (next.origin ?? null) !== (shown.origin ?? null) || (next.id ?? null) !== (shown.id ?? null))); if (moved || after.kind !== before.kind || after.origin !== before.origin || after.clean !== before.clean || screen || status) await show(); }, /** The signed-in account is about to change: nothing under way for the one leaving goes on, and the next show() takes * the window off whatever it shows of that account. */ accountLeaving() { opens++; profileShown = null; clearRetry(); clearRenewal(); asked = null; naming = null; shown = null; opening = null; screen = null; status = null; away = away || !shows(localOrigin()); }, /** A response the window got (Electron's webRequest): a 401 from the place's API means its session ended. */ responded({ url, statusCode, webContentsId }) { const origin = placeOrigin(), win = window(); if (statusCode !== 401 || !origin || !win || win.isDestroyed() || webContentsId !== win.webContents.id) return; try { const at = new URL(url); if (at.origin !== origin || !at.pathname.startsWith("/api/")) return; } catch { return; } // The place's own sign-in page is the new session on its way: its 401s until it redeems the code start nothing. try { const page = new URL(win.webContents.getURL()); if (page.origin === origin && page.pathname === "/pair") return; } catch {} void repair(); }, /** The main frame went somewhere: the place's own sign-in page means its session ended; any other page of it is * the version the window now shows. */ navigated(url) { // Only a page that loaded counts: an error page (chrome-error:) is the load that failed(). if (asked && /^https?:/.test(url)) { const at = originOf(url); if (at === readyCloudOrigin(state())) { asked = null; naming = null; } else if (!(at === localOrigin() && new URL(url).pathname === HOME_SCREEN_PATH)) letAskGo(); } // The window went to another server (the Server menu): the team it showed is no longer the place on screen. if (otherPlace(shown) && ![shown.origin, localOrigin()].includes(originOf(url))) { shown = null; clearRenewal(); } const origin = placeOrigin(); if (!origin || originOf(url) !== origin) return; const at = new URL(url); if (at.pathname === "/pair" && !/[#&]code=/.test(at.hash)) { void repair(); return; } newer = false; loaded = null; void version(origin).then((current) => { if (current && placeOrigin() === origin) loaded = current; }); }, /** The main frame could not load the place, or My Cloud before it is home (Chromium's net error `code`): this * computer's screen says so and the window tries again by itself. False for any other server. */ failed(url, code = 0) { const at = originOf(url), place = placeOrigin(), mine = readyCloudOrigin(state()); const parkedAt = home.frozenPlace; if (!place && parkedAt?.origin && at === parkedAt.origin) { ++opens; if (ownMove()) tell(); else toScreen({ screen: "offline", reason: `Your bots are on ${parkedAt.place}.` }); later(); return true; } if (!at || (at !== place && (home.kind !== "computer" || at !== mine))) return false; if (at !== place) asked ??= { open: null }; const token = ++opens; void unreachable(otherPlace(shown) && at === place ? shown : { kind: "cloud", origin: at }, token, nameNotFound(code)); return true; }, /** `event` came from the shown place's own page in this window (My Cloud's while it is home, or a team's Cloud's). */ homePage, updating(event) { if (!homePage(event)) return false; const reloaded = updatingAt !== null && now() - updatingAt < UPDATING_MS; updatingAt = null; return reloaded; }, /** `event` came from this computer's screen in this window. */ screenPage(event) { const win = window(); if (!win || win.isDestroyed() || event?.sender !== win.webContents || !event.senderFrame || event.senderFrame !== win.webContents.mainFrame) return false; return originOf(event.senderFrame.url) === localOrigin(); }, /** Signs the team on screen in again now, without moving the page (the timer's own step; also for tests). */ renew: () => renew(), cancel() { stopOpening(); shown = null; away = false; }, stopOpening, /** The person chose the place on screen again before the one they'd asked for opened: it stays as it is. */ stay() { stopOpening(); profileShown = null; }, close() { opens++; clearRetry(); clearRenewal(); timers.clear(watcher); }, }; } /** The window's IPC: this computer's screen reads why the place isn't open and asks again; the place's own page, while * it is shown, asks for a new session; either asks this device's sign-in again (from the person's click on My Cloud). */ export function installHomeWindowIpc({ ipcMain, homeWindow, cloudPage }) { const from = (channel, allowed, handler) => (event, ...args) => { if (!allowed(event)) throw new Error(`${channel} is only available in this app's window`); return handler(...args); }; const screenPage = (event) => homeWindow.screenPage(event); ipcMain.handle("home:state", from("home:state", screenPage, () => homeWindow.view())); ipcMain.handle("home:retry", from("home:retry", screenPage, () => homeWindow.retry().then(() => homeWindow.view()))); ipcMain.handle("home:remove", from("home:remove", screenPage, () => homeWindow.remove())); ipcMain.handle("home:sign-in", from("home:sign-in", (event) => screenPage(event) || cloudPage(event) === "cloud" || (homeWindow.homePage(event) && homeWindow.shown()?.kind === "own"), () => homeWindow.signIn())); ipcMain.handle("home:repair", from("home:repair", (event) => homeWindow.homePage(event), () => homeWindow.repair())); ipcMain.on("home:page", (event) => { try { event.returnValue = homeWindow.homePage(event); } catch { event.returnValue = false; } }); ipcMain.on("home:updating", (event) => { try { event.returnValue = homeWindow.updating(event); } catch { event.returnValue = false; } }); }