import { serverLifecycle } from "@/lib/serverLifecycle"; import { useSyncExternalStore } from "react"; import { listen } from "@tauri-apps/api/event"; import { api, asUiError, type UiError, type UpdatePreview, type UpdateTxn } from "@/lib/api"; import { isUpdateCurrent } from "./serverUpdateStatus"; import { startAfterUpdate } from "./updatePrefs"; /** * Server update status for the main screen and the navigation: checked when the app starts and every five minutes, * and applied from the main button when nothing needs a decision (files the owner changed are decided in Settings). */ export interface ServerUpdateState { preview: UpdatePreview | null; available: boolean; checking: boolean; applying: boolean; step: string | null; percent: number; error: string | null; /** The full error of the last update attempt, for the Settings card. */ uiError: UiError | null; stateError: UiError | null; stateRevision: number; /** How the last update ended; kept here so the Settings card can show it after the user left and came back. */ result: { committed: string; preexistingCharacters?: number } | { pending: UpdateTxn } | null; } const EMPTY: ServerUpdateState = { preview: null, available: false, checking: false, applying: false, step: null, percent: 0, error: null, uiError: null, stateError: null, stateRevision: 0, result: null }; const states = new Map(); const listeners = new Set<() => void>(); export const CHECK_EVERY_MS = 5 * 60 * 1000; const get = (id: string) => states.get(id) ?? EMPTY; export function serverUpdateBlocksRestart(id: string): boolean { const state = get(id); return state.applying || state.stateError !== null || !!(state.result && "pending" in state.result); } function set(id: string, next: Partial) { states.set(id, { ...get(id), ...next }); listeners.forEach((l) => l()); } export async function checkServerUpdate(id: string, background = false): Promise { const cur = get(id); if (cur.applying || cur.checking) return; set(id, { checking: true }); try { const p = await api.checkUpdate(id, undefined, background); set(id, { preview: p, available: !isUpdateCurrent(p), error: null }); } catch (e) { // A cached offer is no longer safe after an update or failed recheck. set(id, { preview: null, available: false, error: asUiError(e).human.code }); } finally { set(id, { checking: false }); } } /** Check now and then every five minutes. Returns the function that stops it. */ export function startServerUpdatePolling(id: string): () => void { void checkServerUpdate(id, true); const t = setInterval(() => void checkServerUpdate(id, true), CHECK_EVERY_MS); return () => clearInterval(t); } export function clearServerUpdateResult(id: string) { set(id, { result: null, uiError: null }); } export async function refreshServerUpdateState(id: string): Promise { try { const pending = await api.pendingUpdate(id); const current = get(id).result; set(id, { stateError: null, stateRevision: get(id).stateRevision + 1, ...(pending ? { available: false, preview: null } : {}), result: pending ? { pending } : current && "committed" in current ? current : null }); return pending; } catch (e) { set(id, { stateError: asUiError(e), stateRevision: get(id).stateRevision + 1, available: false, preview: null }); throw e; } } /** * Apply an update. Without options this is the main-screen button: the pending update, only when no file needs a * decision. With options (the Settings card) the owner has already chosen what to do with each changed file and may * point at another package source. The state lives here, not in a screen, so leaving the Settings tab and coming back * shows the update still running (and its progress) instead of an "Update now" button the server would refuse. */ export async function applyServerUpdate(id: string, opts?: { choices: Record; source?: string }): Promise { const p = get(id).preview; const current = get(id); if (current.applying || current.stateError || (current.result && "pending" in current.result)) return false; if (!opts && (!p || p.conflicts.length > 0)) return false; // The update stops a running server and proves the new build by starting it; a server that was stopped stays stopped // unless the owner asked for it to start. const wasRunning = await api.status(id).then((s) => [s.observed.mysql, s.observed.auth, s.observed.world].some((x) => x.state === "running" || x.state === "starting")).catch(() => true); serverLifecycle(id).beginMaintenance(); set(id, { applying: true, step: null, percent: 0, error: null, uiError: null, result: null }); let un: (() => void) | undefined; try { un = await listen<{ step: string; percent: number }>("update-progress", (e) => set(id, { step: e.payload.step, percent: e.payload.percent })); } catch { /* progress is optional */ } try { const out = await api.applyUpdate(id, opts?.choices ?? {}, opts?.source); set(id, { result: out.txn.state === "committed" ? { committed: out.txn.to_version ?? "", ...(out.preexisting_character_count ? { preexistingCharacters: out.preexisting_character_count } : {}) } : { pending: out.txn } }); if (out.txn.state === "committed" && !wasRunning && !startAfterUpdate("server", id)) await api.stop(id).catch(() => undefined); return true; } catch (e) { const ui = asUiError(e); set(id, { error: ui.human.code, uiError: ui }); return false; } finally { un?.(); try { await refreshServerUpdateState(id); } catch { /* The state error remains visible and prevents another apply. */ } serverLifecycle(id).endMaintenance(); set(id, { applying: false }); void checkServerUpdate(id); } } export function useServerUpdate(id: string): ServerUpdateState { return useSyncExternalStore( (l) => { listeners.add(l); return () => listeners.delete(l); }, () => get(id), ); }