114 lines
4.6 KiB
TypeScript
114 lines
4.6 KiB
TypeScript
"use client";
|
||||
|
|
|
|||
|
|
import { useEffect, useState } from "react";
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import { BrailleSpinner } from "@/components/braille-spinner";
|
|||
|
|
import { SettingsRow, Toggle } from "./controls.tsx";
|
|||
|
|
|
|||
|
|
/** Observational snapshot from electron/main.cjs; never updater permission. */
|
|||
|
|
export type UpdateState = {
|
|||
|
|
status: "idle" | "checking" | "downloading" | "up-to-date" | "downloaded" | "error" | "disabled";
|
|||
|
|
version: string | null;
|
|||
|
|
revision: number;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const APP_VERSION = process.env.LH_VERSION ?? "";
|
|||
|
|
const HOMEPAGE = process.env.LH_HOMEPAGE ?? "";
|
|||
|
|
export const MANUAL_UPDATE_HINT = "Checking downloads available updates. Downloaded updates install when you quit the app.";
|
|||
|
|
export const AUTO_UPDATE_HINT = "Check at startup and every 24 hours. Turning this off does not stop updates already in progress or ready to install.";
|
|||
|
|
const UPDATE_ERROR = "Couldn’t check for updates. Try again later.";
|
|||
|
|
|
|||
|
|
/** Subscribe before requesting the snapshot. IPC replies and pushes can
|
|||
|
|
* cross; a revision prevents an older reply from hiding a newer event.
|
|||
|
|
* The banner shares this read-only observer so late loads recover too. */
|
|||
|
|
export function useUpdateState() {
|
|||
|
|
const [state, setState] = useState<UpdateState | null>(null);
|
|||
|
|
const [failed, setFailed] = useState(false);
|
|||
|
|
useEffect(() => {
|
|||
|
|
const lh = window.lh;
|
|||
|
|
if (!lh) return;
|
|||
|
|
let live = true;
|
|||
|
|
let revision = -1;
|
|||
|
|
const accept = (next: UpdateState) => {
|
|||
|
|
if (!live || next.revision < revision) return;
|
|||
|
|
revision = next.revision;
|
|||
|
|
setState(next);
|
|||
|
|
setFailed(false);
|
|||
|
|
};
|
|||
|
|
const off = lh.onUpdateStatus?.(accept);
|
|||
|
|
void lh.getUpdateState?.().then(accept).catch(() => {
|
|||
|
|
if (live && revision === -1) setFailed(true);
|
|||
|
|
});
|
|||
|
|
return () => { live = false; off?.(); };
|
|||
|
|
}, []);
|
|||
|
|
return { state, failed };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function updateStatusText(state: UpdateState | null): string | null {
|
|||
|
|
switch (state?.status) {
|
|||
|
|
case "checking": return "Checking for updates…";
|
|||
|
|
case "downloading": return state.version ? `Downloading version ${state.version}…` : "Downloading an update…";
|
|||
|
|
case "up-to-date": return "No newer version was found at the last check.";
|
|||
|
|
case "downloaded": return `${state.version ? `Version ${state.version}` : "An update"} is ready. It will install when you quit the app.`;
|
|||
|
|
case "error": return UPDATE_ERROR;
|
|||
|
|
default: return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Version information is useful everywhere; update controls only belong
|
|||
|
|
* to a shell with an available updater. Opening this row never checks. */
|
|||
|
|
export function UpdateSettings({
|
|||
|
|
autoUpdate,
|
|||
|
|
onAutoUpdateChange,
|
|||
|
|
version = APP_VERSION,
|
|||
|
|
homepage = HOMEPAGE,
|
|||
|
|
}: {
|
|||
|
|
autoUpdate: boolean;
|
|||
|
|
onAutoUpdateChange: (on: boolean) => void;
|
|||
|
|
version?: string;
|
|||
|
|
homepage?: string;
|
|||
|
|
}) {
|
|||
|
|
const { state, failed } = useUpdateState();
|
|||
|
|
const [checkFailed, setCheckFailed] = useState(false);
|
|||
|
|
useEffect(() => setCheckFailed(false), [state]);
|
|||
|
|
const active = state !== null && state.status !== "disabled";
|
|||
|
|
const busy = state?.status === "checking" || state?.status === "downloading";
|
|||
|
|
const status = state?.status === "downloaded" ? updateStatusText(state)
|
|||
|
|
: failed || checkFailed ? UPDATE_ERROR : updateStatusText(state);
|
|||
|
|
const releaseNotes = version && homepage ? `${homepage}/releases/tag/v${version}` : null;
|
|||
|
|
const check = () => {
|
|||
|
|
setCheckFailed(false);
|
|||
|
|
void window.lh?.checkForUpdates?.().catch(() => setCheckFailed(true));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<>
|
|||
|
|
<SettingsRow
|
|||
|
|
label={`Version ${version || "unknown"}`}
|
|||
|
|
testid="row-version"
|
|||
|
|
hint={
|
|||
|
|
<>
|
|||
|
|
{status && <span className="block" role="status" data-testid="update-status">{status}</span>}
|
|||
|
|
{active && <span className="block">{MANUAL_UPDATE_HINT}</span>}
|
|||
|
|
{releaseNotes && <a href={releaseNotes} target="_blank" rel="noopener noreferrer" className="underline-offset-2 hover:text-foreground hover:underline" data-testid="release-notes">Release notes</a>}
|
|||
|
|
</>
|
|||
|
|
}
|
|||
|
|
>
|
|||
|
|
{active && (state.status === "downloaded" ? (
|
|||
|
|
<Button type="button" size="xs" className="cursor-pointer" data-testid="update-restart" onClick={() => window.lh?.installUpdate?.()}>Restart</Button>
|
|||
|
|
) : (
|
|||
|
|
<Button type="button" size="xs" variant="outline" className="cursor-pointer disabled:cursor-not-allowed" data-testid="update-check" disabled={busy} aria-busy={busy} onClick={check}>
|
|||
|
|
{busy && <BrailleSpinner label={state.status === "downloading" ? "Downloading update" : "Checking for updates"} />}
|
|||
|
|
Check for updates
|
|||
|
|
</Button>
|
|||
|
|
))}
|
|||
|
|
</SettingsRow>
|
|||
|
|
{active && (
|
|||
|
|
<SettingsRow label="Automatic updates" hint={AUTO_UPDATE_HINT} testid="row-auto-update">
|
|||
|
|
<Toggle checked={autoUpdate} label="auto update" onChange={onAutoUpdateChange} />
|
|||
|
|
</SettingsRow>
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|