mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-12 04:01:01 +00:00
135 lines
5.9 KiB
TypeScript
135 lines
5.9 KiB
TypeScript
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<string, ServerUpdateState>();
|
|
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<ServerUpdateState>) {
|
|
states.set(id, { ...get(id), ...next });
|
|
listeners.forEach((l) => l());
|
|
}
|
|
|
|
export async function checkServerUpdate(id: string, background = false): Promise<void> {
|
|
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<UpdateTxn | null> {
|
|
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<string, "keep" | "replace">; source?: string }): Promise<boolean> {
|
|
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),
|
|
);
|
|
}
|