1import type { DesktopUpdateRemoteOutcome, DesktopUpdateState } from "@t3tools/contracts";
2
3/**
4 * What a server-triggered update run should do next, given the updater's
5 * current state. "wait" means an action (possibly started locally) is in
6 * flight and the run should ride along until the next state change.
7 */
8export type RemoteDesktopUpdateStep =
9 | { readonly action: "check" }
10 | { readonly action: "download" }
11 | { readonly action: "install" }
12 | { readonly action: "wait" }
13 | {
14 readonly action: "done";
15 readonly outcome: DesktopUpdateRemoteOutcome;
16 readonly reason?: string;
17 };
18
19/**
20 * How many times this run already issued each action. The caps are what stop
21 * a check -> up-to-date -> check loop and endless download retries; they are
22 * counts rather than booleans because a state event raced by the local
23 * 4-minute poller can re-show an already-handled status once.
24 */
25export interface RemoteDesktopUpdateAttempts {
26 readonly checks: number;
27 readonly downloads: number;
28}
29
30export const MAX_REMOTE_UPDATE_CHECKS = 2;
31export const MAX_REMOTE_UPDATE_DOWNLOADS = 3;
32
33/** Same predicate DesktopUpdates.installDownloadedUpdate uses for admission. */
34function isInstallableDesktopUpdateState(state: DesktopUpdateState): boolean {
35 return (
36 state.downloadedVersion !== null &&
37 (state.status === "downloaded" ||
38 (state.status === "error" &&
39 (state.errorContext === null || state.errorContext === "install")))
40 );
41}
42
43export function nextRemoteDesktopUpdateStep(
44 state: DesktopUpdateState,
45 attempts: RemoteDesktopUpdateAttempts,
46 disabledReason: string | null,
47): RemoteDesktopUpdateStep {
48 if (!state.enabled || state.status === "disabled") {
49 return {
50 action: "done",
51 outcome: "failed",
52 reason: disabledReason ?? "Automatic updates are disabled on this machine.",
53 };
54 }
55 // An install of this version already failed to apply here. Installing it
56 // again from afar would stop the app's threads for nothing, so only the user
57 // on this machine retries it.
58 if (
59 state.notAppliedVersion !== undefined &&
60 (state.downloadedVersion ?? state.availableVersion) === state.notAppliedVersion
61 ) {
62 return {
63 action: "done",
64 outcome: "failed",
65 reason: `Update ${state.notAppliedVersion} did not apply on this machine. Retry it from the desktop app there.`,
66 };
67 }
68 // Mirror installDownloadedUpdate's own admission rule exactly, so the run
69 // never reports an install that the updater then refuses. A download
70 // survives an unrelated background updater error (errorContext null) and
71 // a previous failed install (errorContext "install"); it does not survive
72 // a check or download error, which fall through to the error branch.
73 if (isInstallableDesktopUpdateState(state)) {
74 return { action: "install" };
75 }
76 if (state.status === "downloading" || state.status === "checking") {
77 return { action: "wait" };
78 }
79 if (state.status === "available") {
80 if (attempts.downloads >= MAX_REMOTE_UPDATE_DOWNLOADS) {
81 return {
82 action: "done",
83 outcome: "failed",
84 reason: state.message ?? "The desktop app failed to download the update.",
85 };
86 }
87 return { action: "download" };
88 }
89 // "up-to-date" and "error" are retained from earlier/background checks,
90 // so before this run has issued its own check they are stale, not
91 // terminal: the whole point of a remote request is to look again.
92 if (state.status === "up-to-date") {
93 if (attempts.checks === 0) {
94 return { action: "check" };
95 }
96 return { action: "done", outcome: "up-to-date" };
97 }
98 if (state.status === "error") {
99 if (attempts.checks === 0) {
100 return { action: "check" };
101 }
102 return {
103 action: "done",
104 outcome: "failed",
105 reason: state.message ?? "The desktop app update failed.",
106 };
107 }
108 // status === "idle"
109 if (attempts.checks >= MAX_REMOTE_UPDATE_CHECKS) {
110 return {
111 action: "done",
112 outcome: "failed",
113 reason: "The desktop app did not report an update result.",
114 };
115 }
116 return { action: "check" };
117}
118
119/**
120 * Normalizes an updater message for the `reason` wire field, which is a
121 * TrimmedNonEmptyString. Updater messages are plain strings and may be blank;
122 * a blank reason would fail encoding and silently drop the terminal report,
123 * leaving the server to wait for its timeout.
124 */
125export function normalizeRemoteUpdateReason(reason: string | undefined): string | undefined {
126 const trimmed = reason?.trim();
127 return trimmed ? trimmed : undefined;
128}