mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: desktop app auto updates with automatic, ask first or never modes
This commit is contained in:
1 parent
8403449669
commit
1aa1bde5f7
33 files changed
+1728
-177
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user