feat: desktop app auto updates with automatic, ask first or never modes

This commit is contained in:
LukeGus committed 2026-10-11 12:35:43 -05:00
1 parent 8403449669
commit 1aa1bde5f7
33 files changed
+1728 -177

No files matched your search

+2
View File
@@ -42,6 +42,7 @@ import {
type EditingWidth,
} from "@/components/surface/surface-scope";
import { OnboardingHost } from "@/onboarding/OnboardingHost";
import { DesktopUpdateToasts } from "@/shell/DesktopUpdateToasts";
import { SplitView, type SplitViewActions } from "@/shell/split/SplitView";
import { SplitDropOverlay } from "@/shell/split/SplitDropOverlay";
import {
@@ -3184,6 +3185,7 @@ export function AppShell({
</Suspense>
)}
<OnboardingHost />
{isElectron() && <DesktopUpdateToasts />}
</ServerStatusProvider>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { isElectron } from "@/lib/electron";
export type DesktopUpdateMode = "automatic" | "prompt" | "never";
export type DesktopInstallKind = "auto" | "manual" | "store" | "disabled";
export type DesktopUpdateStatus =
| "idle"
| "checking"
| "available"
| "downloading"
| "downloaded"
| "up-to-date"
| "error";
export interface DesktopUpdateState {
kind: DesktopInstallKind;
mode: DesktopUpdateMode;
currentVersion: string;
status: DesktopUpdateStatus;
version: string | null;
releaseUrl: string | null;
progress: number | null;
error: string | null;
}
interface DesktopUpdatesBridge {
getState: () => Promise<DesktopUpdateState | null>;
check: () => Promise<DesktopUpdateState | null>;
download: () => Promise<DesktopUpdateState | null>;
install: () => Promise<boolean>;
setMode: (mode: DesktopUpdateMode) => Promise<DesktopUpdateState | null>;
onState: (callback: (state: DesktopUpdateState) => void) => () => void;
}
type UpdatesWindow = Window &
typeof globalThis & {
electronAPI?: { updates?: DesktopUpdatesBridge };
};
/** The main process updater, or null in the browser and on older builds. */
export function desktopUpdates(): DesktopUpdatesBridge | null {
if (!isElectron()) return null;
return (window as UpdatesWindow).electronAPI?.updates ?? null;
}
/** Whether this install has an updater the user can configure. */
export function canConfigureUpdates(
state: DesktopUpdateState | null,
): state is DesktopUpdateState {
return !!state && state.kind !== "store" && state.kind !== "disabled";
}
/** Which toast, if any, this state calls for. */
export function updateToastFor(
state: DesktopUpdateState | null,
): "prompt" | "manual" | "ready" | null {
if (!state || !state.version || state.mode === "never") return null;
if (state.status === "downloaded") return "ready";
if (state.status !== "available") return null;
if (state.kind === "manual") return "manual";
if (state.kind === "auto" && state.mode === "prompt") return "prompt";
return null;
}
export function useDesktopUpdate(): DesktopUpdateState | null {
const [state, setState] = useState<DesktopUpdateState | null>(null);
useEffect(() => {
const bridge = desktopUpdates();
if (!bridge) return;
let active = true;
bridge
.getState()
.then((next) => active && next && setState(next))
.catch(() => {});
const off = bridge.onState((next) => active && setState(next));
return () => {
active = false;
off();
};
}, []);
return state;
}
+31
View File
@@ -233,6 +233,37 @@
"desktopLinked": "Linked to {{server}}",
"pluginsDocsLink": "Read about each one"
},
"desktopUpdates": {
"title": "Desktop app",
"mode": "Updates",
"modeHint": "How this app gets new versions.",
"manualModeHint": "This copy can not update itself, so Termix only tells you when a new version is out.",
"modeAutomatic": "Automatic",
"modePrompt": "Ask first",
"modeNever": "Never",
"automaticBody": "Download new versions in the background and install them when you restart.",
"promptBody": "Tell me when a new version is out and let me choose when to download it.",
"neverBody": "Do not check for updates. You can still check by hand in Settings.",
"onboardingTitle": "App updates",
"onboardingBody": "The desktop app can now update itself. Pick how you want new versions to arrive. You can change this later in Settings, Updates.",
"onboardingManual": "This copy of the app was installed in a way that can not update itself, so Termix will tell you when a new version is out with a link to download it.",
"status": "Version {{version}}",
"statusIdle": "Not checked yet",
"statusChecking": "Checking for updates",
"statusAvailable": "Version {{version}} is available",
"statusDownloading": "Downloading {{version}} ({{progress}}%)",
"statusReady": "Version {{version}} is ready. Restart to install it.",
"statusUpToDate": "You have the newest version",
"statusError": "Could not check for updates. Try again later.",
"checkNow": "Check now",
"download": "Download",
"restart": "Restart now",
"later": "Later",
"available": "Termix {{version}} is available",
"manualHint": "Download it from the release page and install it over this one.",
"ready": "Termix {{version}} is ready to install",
"readyHint": "It installs the next time Termix restarts."
},
"nav": {
"home": "Home",
"terminal": "Terminal",
+21 -1
View File
@@ -1,10 +1,19 @@
import { LayoutTemplate, Monitor, Palette, Puzzle, Shield } from "lucide-react";
import {
LayoutTemplate,
Monitor,
MonitorDown,
Palette,
Puzzle,
Shield,
} from "lucide-react";
import { CORE_ONBOARDING_STEPS } from "@/types/onboarding";
import { PluginsStep } from "./steps/PluginsStep";
import { PresetStep } from "./steps/PresetStep";
import { AppearanceStep } from "./steps/AppearanceStep";
import { SecurityStep } from "./steps/SecurityStep";
import { DesktopSyncStep } from "./steps/DesktopSyncStep";
import { DesktopUpdatesStep } from "./steps/DesktopUpdatesStep";
import { desktopUpdates } from "@/lib/desktop-updates";
import type { OnboardingStepDef } from "./onboarding-registry";
/**
@@ -38,6 +47,17 @@ export const CORE_STEPS: OnboardingStepDef[] = [
position: 20,
isRelevant: (ctx) => ctx.isDesktop,
},
{
id: "desktop-updates",
version: CORE_ONBOARDING_STEPS["desktop-updates"],
titleKey: "desktopUpdates.onboardingTitle",
icon: MonitorDown,
Component: DesktopUpdatesStep,
audience: "all",
position: 25,
// App Store builds have no updater.
isRelevant: (ctx) => ctx.isDesktop && desktopUpdates() !== null,
},
{
id: "preset",
version: CORE_ONBOARDING_STEPS.preset,
@@ -0,0 +1,79 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { BellRing, Download, PauseCircle } from "lucide-react";
import {
desktopUpdates,
useDesktopUpdate,
type DesktopUpdateMode,
} from "@/lib/desktop-updates";
/**
* Desktop only: how the app updates itself. Can be changed later in
* Settings, Updates.
*/
export function DesktopUpdatesStep() {
const { t } = useTranslation();
const state = useDesktopUpdate();
const [picked, setPicked] = useState<DesktopUpdateMode | null>(null);
const mode = picked ?? state?.mode ?? "prompt";
const choose = (next: DesktopUpdateMode) => {
setPicked(next);
void desktopUpdates()
?.setMode(next)
.catch(() => {});
};
const options = [
{
id: "automatic" as const,
icon: Download,
title: t("desktopUpdates.modeAutomatic"),
body: t("desktopUpdates.automaticBody"),
},
{
id: "prompt" as const,
icon: BellRing,
title: t("desktopUpdates.modePrompt"),
body: t("desktopUpdates.promptBody"),
},
{
id: "never" as const,
icon: PauseCircle,
title: t("desktopUpdates.modeNever"),
body: t("desktopUpdates.neverBody"),
},
];
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
{state?.kind === "manual"
? t("desktopUpdates.onboardingManual")
: t("desktopUpdates.onboardingBody")}
</p>
<div className="flex flex-col gap-1.5">
{options.map(({ id, icon: Icon, title, body }) => (
<button
key={id}
type="button"
onClick={() => choose(id)}
className={`flex items-start gap-2.5 border p-2.5 text-left transition-colors ${
mode === id
? "border-accent-brand bg-accent-brand/10"
: "border-border bg-card hover:bg-muted/40"
}`}
>
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
<span className="flex flex-col gap-1">
<span className="text-xs font-medium">{title}</span>
<span className="text-[10px] leading-snug text-muted-foreground">
{body}
</span>
</span>
</button>
))}
</div>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ExternalLink, MonitorDown } from "lucide-react";
import { Button } from "@/components/button";
import { SectionCard, SettingRow } from "@/components/section-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import {
canConfigureUpdates,
desktopUpdates,
useDesktopUpdate,
type DesktopUpdateMode,
type DesktopUpdateState,
} from "@/lib/desktop-updates";
const MODES: DesktopUpdateMode[] = ["automatic", "prompt", "never"];
function StatusText({ state }: { state: DesktopUpdateState }) {
const { t } = useTranslation();
const version = state.version;
switch (state.status) {
case "checking":
return <>{t("desktopUpdates.statusChecking")}</>;
case "available":
return <>{t("desktopUpdates.statusAvailable", { version })}</>;
case "downloading":
return (
<>
{t("desktopUpdates.statusDownloading", {
version,
progress: state.progress ?? 0,
})}
</>
);
case "downloaded":
return <>{t("desktopUpdates.statusReady", { version })}</>;
case "up-to-date":
return <>{t("desktopUpdates.statusUpToDate")}</>;
case "error":
return <>{t("desktopUpdates.statusError")}</>;
default:
return <>{t("desktopUpdates.statusIdle")}</>;
}
}
/** Desktop only: how this install updates itself. */
export function DesktopUpdatesCard() {
const { t } = useTranslation();
const state = useDesktopUpdate();
const [busy, setBusy] = useState(false);
if (!canConfigureUpdates(state)) return null;
const bridge = desktopUpdates();
const run = async (action: () => Promise<unknown> | undefined) => {
setBusy(true);
try {
await action();
} catch {
toast.error(t("settings.updates.saveFailed"));
} finally {
setBusy(false);
}
};
const modeLabel = (mode: DesktopUpdateMode) =>
mode === "automatic"
? t("desktopUpdates.modeAutomatic")
: mode === "prompt"
? t("desktopUpdates.modePrompt")
: t("desktopUpdates.modeNever");
return (
<SectionCard
title={t("desktopUpdates.title")}
icon={<MonitorDown className="size-3.5" />}
rowId="updates-desktop"
>
<SettingRow
label={t("desktopUpdates.mode")}
description={
state.kind === "manual"
? t("desktopUpdates.manualModeHint")
: t("desktopUpdates.modeHint")
}
>
<Select
disabled={busy}
value={state.mode}
onValueChange={(next) =>
void run(() => bridge?.setMode(next as DesktopUpdateMode))
}
>
<SelectTrigger className="h-8 w-40 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODES.map((mode) => (
<SelectItem key={mode} value={mode}>
{modeLabel(mode)}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<SettingRow
label={t("desktopUpdates.status", { version: state.currentVersion })}
description={<StatusText state={state} />}
>
{state.status === "downloaded" ? (
<Button
size="sm"
className="h-8"
onClick={() => void bridge?.install()}
>
{t("desktopUpdates.restart")}
</Button>
) : state.status === "available" && state.kind === "auto" ? (
<Button
size="sm"
className="h-8"
disabled={busy}
onClick={() => void run(() => bridge?.download())}
>
{t("desktopUpdates.download")}
</Button>
) : state.status === "available" && state.releaseUrl ? (
<Button size="sm" variant="outline" className="h-8 gap-1.5" asChild>
<a
href={state.releaseUrl}
target="_blank"
rel="noopener noreferrer"
>
{t("desktopUpdates.download")}
<ExternalLink className="size-3" />
</a>
</Button>
) : (
<Button
size="sm"
variant="outline"
className="h-8"
disabled={
busy ||
state.status === "checking" ||
state.status === "downloading"
}
onClick={() => void run(() => bridge?.check())}
>
{t("desktopUpdates.checkNow")}
</Button>
)}
</SettingRow>
</SectionCard>
);
}
+1 -1
View File
@@ -387,7 +387,7 @@ export function SettingsTab({
)}
</SettingsEmbedContext.Provider>
{page.id === "keybindings" && <KeybindingsSettings />}
{page.id === "updates" && isAdmin && <UpdatesSettings />}
{page.id === "updates" && <UpdatesSettings isAdmin={isAdmin} />}
{pluginFeature && (
<>
{pluginFeature.user && (
+11 -1
View File
@@ -22,6 +22,7 @@ import { isElectron } from "@/lib/electron";
import { isPrerelease } from "@/plugins/plugin-model";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
import { docsUrl } from "@/lib/docs";
import { DesktopUpdatesCard } from "./DesktopUpdatesCard";
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
@@ -61,7 +62,7 @@ function ReleaseRow({
);
}
export function UpdatesSettings() {
function InstanceUpdates() {
const { t } = useTranslation();
const [channel, setChannel] = useState<UpdateChannelState | null>(null);
const [releases, setReleases] = useState<{
@@ -317,3 +318,12 @@ export function UpdatesSettings() {
</div>
);
}
export function UpdatesSettings({ isAdmin }: { isAdmin: boolean }) {
return (
<div className="flex flex-col gap-3">
<DesktopUpdatesCard />
{isAdmin && <InstanceUpdates />}
</div>
);
}
+6 -1
View File
@@ -38,6 +38,8 @@ export interface SettingsPage {
icon: LucideIcon;
/** Only for admins. */
admin?: boolean;
/** Also shown to non-admins in the desktop app. */
desktopToo?: boolean;
/** Left out of the desktop app. */
browserOnly?: boolean;
/** The profile section this page shows. */
@@ -135,6 +137,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
labelKey: "settings.updates.title",
icon: FlaskConical,
admin: true,
desktopToo: true,
}),
core({
id: "admin-users",
@@ -225,7 +228,9 @@ export function visibleSettingsPages(
{ isAdmin, isElectron }: { isAdmin: boolean; isElectron: boolean },
): SettingsPage[] {
return pages.filter(
(page) => (!page.admin || isAdmin) && (!page.browserOnly || !isElectron),
(page) =>
(!page.admin || isAdmin || (page.desktopToo && isElectron)) &&
(!page.browserOnly || !isElectron),
);
}
+18
View File
@@ -434,6 +434,24 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"newUi.sidebar.userProfile.updatePassword",
],
"updates": [
"desktopUpdates.checkNow",
"desktopUpdates.download",
"desktopUpdates.manualModeHint",
"desktopUpdates.mode",
"desktopUpdates.modeAutomatic",
"desktopUpdates.modeHint",
"desktopUpdates.modeNever",
"desktopUpdates.modePrompt",
"desktopUpdates.restart",
"desktopUpdates.status",
"desktopUpdates.statusAvailable",
"desktopUpdates.statusChecking",
"desktopUpdates.statusDownloading",
"desktopUpdates.statusError",
"desktopUpdates.statusIdle",
"desktopUpdates.statusReady",
"desktopUpdates.statusUpToDate",
"desktopUpdates.title",
"settings.updates.allPluginsBeta",
"settings.updates.allPluginsStable",
"settings.updates.backToStable",
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
desktopUpdates,
updateToastFor,
useDesktopUpdate,
} from "@/lib/desktop-updates";
const TOAST_ID = "desktop-update";
const LATER_KEY = "termix-desktop-update-later";
function readLater(): string | null {
try {
return localStorage.getItem(LATER_KEY);
} catch {
return null;
}
}
function saveLater(value: string) {
try {
localStorage.setItem(LATER_KEY, value);
} catch {
// Only means the toast comes back next launch.
}
}
/** Desktop only: tells the user about a new version and installs it. */
export function DesktopUpdateToasts() {
const { t } = useTranslation();
const state = useDesktopUpdate();
const shown = useRef<string | null>(null);
useEffect(() => {
const kind = updateToastFor(state);
if (!state || !kind) return;
const key = `${kind}:${state.version}`;
if (shown.current === key || readLater() === key) return;
shown.current = key;
const version = state.version;
const later = {
label: t("desktopUpdates.later"),
onClick: () => saveLater(key),
};
if (kind === "ready") {
toast(t("desktopUpdates.ready", { version }), {
id: TOAST_ID,
duration: Infinity,
description: t("desktopUpdates.readyHint"),
action: {
label: t("desktopUpdates.restart"),
onClick: () => void desktopUpdates()?.install(),
},
cancel: later,
});
} else if (kind === "prompt") {
toast(t("desktopUpdates.available", { version }), {
id: TOAST_ID,
duration: Infinity,
action: {
label: t("desktopUpdates.download"),
onClick: () => void desktopUpdates()?.download(),
},
cancel: later,
});
} else {
const url = state.releaseUrl;
toast(t("desktopUpdates.available", { version }), {
id: TOAST_ID,
duration: Infinity,
description: t("desktopUpdates.manualHint"),
action: {
label: t("desktopUpdates.download"),
onClick: () => {
if (url) window.open(url, "_blank", "noopener,noreferrer");
},
},
cancel: later,
});
}
}, [state, t]);
return null;
}
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { updateToastFor, type DesktopUpdateState } from "@/lib/desktop-updates";
function state(patch: Partial<DesktopUpdateState>): DesktopUpdateState {
return {
kind: "auto",
mode: "prompt",
currentVersion: "26.10.1",
status: "available",
version: "26.10.2",
releaseUrl: null,
progress: null,
error: null,
...patch,
};
}
describe("updateToastFor", () => {
it("asks before downloading in ask first mode", () => {
expect(updateToastFor(state({}))).toBe("prompt");
});
it("stays quiet while automatic mode downloads", () => {
expect(updateToastFor(state({ mode: "automatic" }))).toBeNull();
expect(
updateToastFor(state({ mode: "automatic", status: "downloading" })),
).toBeNull();
});
it("offers a restart once downloaded", () => {
expect(updateToastFor(state({ status: "downloaded" }))).toBe("ready");
expect(
updateToastFor(state({ mode: "automatic", status: "downloaded" })),
).toBe("ready");
});
it("links the release for installs that can not update", () => {
expect(updateToastFor(state({ kind: "manual" }))).toBe("manual");
});
it("never toasts when updates are off", () => {
expect(updateToastFor(state({ mode: "never" }))).toBeNull();
expect(
updateToastFor(state({ mode: "never", status: "downloaded" })),
).toBeNull();
expect(updateToastFor(null)).toBeNull();
});
});
@@ -93,6 +93,22 @@ describe("CORE_STEPS", () => {
]);
});
it("asks how the desktop app updates when it has an updater", () => {
const win = window as unknown as { electronAPI?: unknown };
win.electronAPI = { isElectron: true, updates: {} };
try {
expect(ids(ctx({ isDesktop: true }))).toEqual([
"desktop-sync",
"desktop-updates",
"preset",
"appearance",
]);
expect(ids(ctx())).not.toContain("desktop-updates");
} finally {
delete win.electronAPI;
}
});
it("offers account security only when there is something to set up", () => {
expect(ids(ctx({ canEnrollSecondFactor: true }))).toEqual([
"preset",
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import { DesktopUpdatesCard } from "@/settings/DesktopUpdatesCard";
import type { DesktopUpdateState } from "@/lib/desktop-updates";
type TestWindow = { electronAPI?: unknown };
function state(patch: Partial<DesktopUpdateState> = {}): DesktopUpdateState {
return {
kind: "auto",
mode: "prompt",
currentVersion: "26.10.1",
status: "idle",
version: null,
releaseUrl: null,
progress: null,
error: null,
...patch,
};
}
function bridge(initial: DesktopUpdateState) {
return {
getState: vi.fn(async () => initial),
check: vi.fn(async () => initial),
download: vi.fn(async () => initial),
install: vi.fn(async () => true),
setMode: vi.fn(async () => initial),
onState: vi.fn(() => () => {}),
};
}
async function renderWith(initial: DesktopUpdateState) {
const api = bridge(initial);
(window as TestWindow).electronAPI = { isElectron: true, updates: api };
await act(async () => {
render(<DesktopUpdatesCard />);
});
return api;
}
afterEach(() => {
cleanup();
delete (window as TestWindow).electronAPI;
});
describe("DesktopUpdatesCard", () => {
it("renders nothing in the browser", () => {
const { container } = render(<DesktopUpdatesCard />);
expect(container.innerHTML).toBe("");
});
it("checks on demand", async () => {
const api = await renderWith(state());
fireEvent.click(screen.getByText("desktopUpdates.checkNow"));
expect(api.check).toHaveBeenCalled();
});
it("offers the download in ask first mode", async () => {
const api = await renderWith(
state({ status: "available", version: "26.10.2" }),
);
fireEvent.click(screen.getByText("desktopUpdates.download"));
expect(api.download).toHaveBeenCalled();
});
it("restarts into a downloaded update", async () => {
const api = await renderWith(
state({ status: "downloaded", version: "26.10.2" }),
);
fireEvent.click(screen.getByText("desktopUpdates.restart"));
expect(api.install).toHaveBeenCalled();
});
it("links the release for installs that can not update", async () => {
await renderWith(
state({
kind: "manual",
status: "available",
version: "26.10.2",
releaseUrl: "https://example.com/r",
}),
);
expect(
screen.getByText("desktopUpdates.download").closest("a"),
).toHaveProperty("href", "https://example.com/r");
});
it("hides on dev builds", async () => {
await renderWith(state({ kind: "disabled" }));
expect(screen.queryByText("desktopUpdates.title")).toBeNull();
});
});
@@ -33,6 +33,16 @@ describe("settings pages", () => {
expect(admin).toContain("security");
});
it("shows Updates to everyone on desktop but only admins in the browser", () => {
const ids = (isAdmin: boolean, isElectron: boolean) =>
visibleSettingsPages(CORE_SETTINGS_PAGES, { isAdmin, isElectron }).map(
(page) => page.id,
);
expect(ids(false, true)).toContain("updates");
expect(ids(false, false)).not.toContain("updates");
expect(ids(true, false)).toContain("updates");
});
it("finds a page by a label shown on it, not only its name", () => {
const hits = searchSettingsPages(CORE_SETTINGS_PAGES, "theme", translate);
expect(hits.some((hit) => hit.page.id === "appearance")).toBe(true);
+5 -1
View File
@@ -38,8 +38,12 @@ describe("onboarding seen map", () => {
});
it("legacy seen covers every core step at launch", () => {
// Steps added later stay out so upgraded users still see them.
const addedLater = ["desktop-updates"];
expect(Object.keys(LEGACY_SEEN).sort()).toEqual(
Object.keys(CORE_ONBOARDING_STEPS).sort(),
Object.keys(CORE_ONBOARDING_STEPS)
.filter((key) => !addedLater.includes(key))
.sort(),
);
});
});