| 1 | import 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 | */ |
| 8 | export 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 | */ |
| 25 | export interface RemoteDesktopUpdateAttempts { |
| 26 | readonly checks: number; |
| 27 | readonly downloads: number; |
| 28 | } |
| 29 | |
| 30 | export const MAX_REMOTE_UPDATE_CHECKS = 2; |
| 31 | export const MAX_REMOTE_UPDATE_DOWNLOADS = 3; |
| 32 | |
| 33 | /** Same predicate DesktopUpdates.installDownloadedUpdate uses for admission. */ |
| 34 | function 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 | |
| 43 | export 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 | */ |
| 125 | export function normalizeRemoteUpdateReason(reason: string | undefined): string | undefined { |
| 126 | const trimmed = reason?.trim(); |
| 127 | return trimmed ? trimmed : undefined; |
| 128 | } |