| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { DesktopUpdateState } from "@t3tools/contracts"; |
| 3 | import { Atom } from "effect/reactivity"; |
| 4 | import { TriangleAlertIcon } from "lucide-react"; |
| 5 | import { useMemo, useRef, useState } from "react"; |
| 6 | |
| 7 | import { isElectron } from "../../env"; |
| 8 | import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| 9 | import { useDesktopUpdateState } from "../../state/desktopUpdate"; |
| 10 | import { serverEnvironment } from "../../state/server"; |
| 11 | import { Button } from "../ui/button"; |
| 12 | import { |
| 13 | canCheckForUpdate, |
| 14 | getArm64IntelBuildWarningDescription, |
| 15 | getDesktopUpdateButtonTooltip, |
| 16 | getDesktopUpdateDownloadedVersion, |
| 17 | isDesktopUpdateButtonDisabled, |
| 18 | resolveDesktopUpdateButtonAction, |
| 19 | shouldShowArm64IntelBuildWarning, |
| 20 | } from "../desktopUpdate.logic"; |
| 21 | import { |
| 22 | downloadLocalUpdate, |
| 23 | installLocalUpdate, |
| 24 | performDesktopUpdate, |
| 25 | toastUpdateError, |
| 26 | } from "../desktopUpdate.actions"; |
| 27 | import { DesktopUpdateStatusIcon } from "./DesktopUpdateStatusIcon"; |
| 28 | import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; |
| 29 | import { UpdateEverythingDialog } from "./UpdateEverythingDialog"; |
| 30 | import { |
| 31 | describeUpdateRun, |
| 32 | resolveUpdateRunMachineStatus, |
| 33 | type UpdateEverythingRun, |
| 34 | } from "./updateEverything.logic"; |
| 35 | import { useUpdateEverythingRun } from "./updateEverythingRun"; |
| 36 | import { useUpdateEverything } from "./useUpdateEverything"; |
| 37 | |
| 38 | function useUpdateRunStatus( |
| 39 | run: UpdateEverythingRun | null, |
| 40 | state: DesktopUpdateState | null, |
| 41 | ): string | null { |
| 42 | const statusesAtom = useMemo( |
| 43 | () => |
| 44 | Atom.make((get) => |
| 45 | run === null |
| 46 | ? [] |
| 47 | : run.machines.map((machine) => ({ |
| 48 | label: machine.label, |
| 49 | status: resolveUpdateRunMachineStatus( |
| 50 | machine, |
| 51 | run.phase, |
| 52 | get(serverEnvironment.updateStateAtom(machine.environmentId)), |
| 53 | ), |
| 54 | })), |
| 55 | ), |
| 56 | [run], |
| 57 | ); |
| 58 | const statuses = useAtomValue(statusesAtom); |
| 59 | if (run === null) return null; |
| 60 | const local = |
| 61 | run.localVersion === null |
| 62 | ? null |
| 63 | : state?.status === "downloading" |
| 64 | ? `downloading${state.downloadPercent === null ? "" : ` ${Math.floor(state.downloadPercent)}%`}` |
| 65 | : run.phase === "installing" |
| 66 | ? "restarting" |
| 67 | : state && resolveDesktopUpdateButtonAction(state) === "install" |
| 68 | ? "restarts last" |
| 69 | : "waiting"; |
| 70 | return describeUpdateRun(statuses, local).join("\n"); |
| 71 | } |
| 72 | |
| 73 | export function AppMenuUpdates() { |
| 74 | const state = useDesktopUpdateState(); |
| 75 | const [isActionPending, setIsActionPending] = useState(false); |
| 76 | const [isConfirmingEverything, setIsConfirmingEverything] = useState(false); |
| 77 | const pending = useRef(false); |
| 78 | const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); |
| 79 | const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; |
| 80 | const localUpdateVersion = |
| 81 | state && action !== "none" ? getDesktopUpdateDownloadedVersion(state) : null; |
| 82 | const restartsApp = state?.installKind !== "ui"; |
| 83 | const everything = useUpdateEverything(restartsApp ? localUpdateVersion : null); |
| 84 | const run = useUpdateEverythingRun(); |
| 85 | const runStatus = useUpdateRunStatus(run, state); |
| 86 | const isDownloading = state?.status === "downloading"; |
| 87 | const isBusy = isActionPending || run !== null; |
| 88 | const hasUpdates = localUpdateVersion !== null || everything.hasUpdates; |
| 89 | const appActionLabel = |
| 90 | state?.status === "checking" |
| 91 | ? "Checking…" |
| 92 | : isDownloading |
| 93 | ? "Downloading…" |
| 94 | : action === "download" |
| 95 | ? state?.status === "error" |
| 96 | ? "Retry download" |
| 97 | : "Download" |
| 98 | : action === "install" |
| 99 | ? restartsApp |
| 100 | ? "Restart" |
| 101 | : "Reload" |
| 102 | : "Check"; |
| 103 | |
| 104 | const runEverything = async () => { |
| 105 | if (pending.current || run !== null) return; |
| 106 | pending.current = true; |
| 107 | setIsConfirmingEverything(false); |
| 108 | setIsActionPending(true); |
| 109 | const bridge = window.desktopBridge; |
| 110 | try { |
| 111 | await everything.run({ |
| 112 | ...(bridge && action === "download" |
| 113 | ? { |
| 114 | downloadLocal: (reportError) => |
| 115 | downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }), |
| 116 | } |
| 117 | : {}), |
| 118 | ...(bridge && restartsApp && action !== "none" |
| 119 | ? { |
| 120 | installLocal: (reportError) => installLocalUpdate(bridge, reportError), |
| 121 | } |
| 122 | : {}), |
| 123 | }); |
| 124 | if (bridge && !restartsApp && action === "install") await installLocalUpdate(bridge); |
| 125 | } catch (error) { |
| 126 | toastUpdateError( |
| 127 | "Could not update everything", |
| 128 | error instanceof Error ? error.message : "Try again.", |
| 129 | ); |
| 130 | } finally { |
| 131 | pending.current = false; |
| 132 | setIsActionPending(false); |
| 133 | } |
| 134 | }; |
| 135 | |
| 136 | const handleAppAction = async () => { |
| 137 | const bridge = window.desktopBridge; |
| 138 | if (!bridge || !state || pending.current || isBusy) return; |
| 139 | pending.current = true; |
| 140 | setIsActionPending(true); |
| 141 | try { |
| 142 | await performDesktopUpdate(bridge, state); |
| 143 | } finally { |
| 144 | pending.current = false; |
| 145 | setIsActionPending(false); |
| 146 | } |
| 147 | }; |
| 148 | |
| 149 | const showArchitectureWarning = shouldShowArm64IntelBuildWarning(state); |
| 150 | const appStatus = |
| 151 | state?.status === "checking" |
| 152 | ? "Checking for updates…" |
| 153 | : state |
| 154 | ? getDesktopUpdateButtonTooltip(state) |
| 155 | : "Loading update status…"; |
| 156 | const iconStatus = |
| 157 | state?.status === "checking" |
| 158 | ? "checking" |
| 159 | : isDownloading |
| 160 | ? "downloading" |
| 161 | : action === "install" |
| 162 | ? "downloaded" |
| 163 | : action === "download" |
| 164 | ? "available" |
| 165 | : "idle"; |
| 166 | |
| 167 | return ( |
| 168 | <section aria-label="Updates" className="space-y-1"> |
| 169 | <div className="flex min-h-7 items-center justify-between gap-2 px-1.5"> |
| 170 | <h3 className="text-xs font-medium text-muted-foreground">Updates</h3> |
| 171 | {hasUpdates || run !== null ? ( |
| 172 | <Button |
| 173 | size="xs" |
| 174 | variant="outline" |
| 175 | disabled={isBusy || isDownloading} |
| 176 | onClick={() => { |
| 177 | if (everything.restarts.length > 0 || (restartsApp && localUpdateVersion !== null)) { |
| 178 | setIsConfirmingEverything(true); |
| 179 | } else { |
| 180 | void runEverything(); |
| 181 | } |
| 182 | }} |
| 183 | > |
| 184 | {run !== null ? "Updating…" : "Update all"} |
| 185 | </Button> |
| 186 | ) : null} |
| 187 | </div> |
| 188 | {isElectron ? ( |
| 189 | <div className="flex min-h-9 items-center gap-2 px-1.5"> |
| 190 | {/* Icon column width; the download ring overflows it evenly. */} |
| 191 | <span aria-hidden="true" className="flex size-4 shrink-0 items-center justify-center"> |
| 192 | <DesktopUpdateStatusIcon |
| 193 | status={iconStatus} |
| 194 | downloadPercent={state?.downloadPercent ?? null} |
| 195 | isCheckAnimating={!prefersReducedMotion} |
| 196 | /> |
| 197 | </span> |
| 198 | <div className="min-w-0 flex-1"> |
| 199 | <div className="text-xs font-medium">App</div> |
| 200 | <p role="status" className="text-2xs leading-4 text-muted-foreground"> |
| 201 | {appStatus} |
| 202 | </p> |
| 203 | </div> |
| 204 | <Button |
| 205 | size="xs" |
| 206 | variant="outline" |
| 207 | disabled={ |
| 208 | isBusy || |
| 209 | (action === "none" ? !canCheckForUpdate(state) : isDesktopUpdateButtonDisabled(state)) |
| 210 | } |
| 211 | aria-label={`${appActionLabel} app update`} |
| 212 | onClick={() => void handleAppAction()} |
| 213 | > |
| 214 | {appActionLabel} |
| 215 | </Button> |
| 216 | </div> |
| 217 | ) : null} |
| 218 | {showArchitectureWarning && state ? ( |
| 219 | <p className="flex gap-2 px-1.5 text-xs text-warning-foreground"> |
| 220 | <TriangleAlertIcon aria-hidden="true" className="size-4 shrink-0" /> |
| 221 | {getArm64IntelBuildWarningDescription(state)} |
| 222 | </p> |
| 223 | ) : null} |
| 224 | {state?.channel === "nightly" && state.releaseNotes.length > 0 ? ( |
| 225 | <details className="px-1.5 text-xs"> |
| 226 | <summary className="cursor-pointer rounded-sm py-1 text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"> |
| 227 | Release notes |
| 228 | </summary> |
| 229 | <SidebarUpdateReleaseNotes |
| 230 | shell={window.desktopBridge} |
| 231 | state={state} |
| 232 | tooltip={appStatus} |
| 233 | /> |
| 234 | </details> |
| 235 | ) : null} |
| 236 | {everything.servers.map((server) => ( |
| 237 | <div |
| 238 | key={server.environmentId} |
| 239 | className="flex items-center justify-between gap-2 px-1.5 py-1 text-xs" |
| 240 | > |
| 241 | <span>{server.serverLabel}</span> |
| 242 | <span className="text-2xs text-muted-foreground">Server {server.targetVersion}</span> |
| 243 | </div> |
| 244 | ))} |
| 245 | {runStatus ? ( |
| 246 | <p role="status" className="whitespace-pre-line px-1.5 text-xs text-muted-foreground"> |
| 247 | {runStatus} |
| 248 | </p> |
| 249 | ) : null} |
| 250 | {everything.skipped.length > 0 ? ( |
| 251 | <details className="px-1.5 text-xs text-muted-foreground"> |
| 252 | <summary className="cursor-pointer rounded-sm py-1 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"> |
| 253 | Unavailable machines ({everything.skipped.length}) |
| 254 | </summary> |
| 255 | <ul className="space-y-1 py-1"> |
| 256 | {everything.skipped.map((machine) => ( |
| 257 | <li key={machine.environmentId}> |
| 258 | {machine.label}: {machine.reason} |
| 259 | </li> |
| 260 | ))} |
| 261 | </ul> |
| 262 | </details> |
| 263 | ) : null} |
| 264 | <UpdateEverythingDialog |
| 265 | open={isConfirmingEverything} |
| 266 | onOpenChange={setIsConfirmingEverything} |
| 267 | summary={everything.summary} |
| 268 | lines={everything.lines} |
| 269 | restarts={everything.restarts} |
| 270 | skipped={everything.skipped} |
| 271 | onConfirm={() => void runEverything()} |
| 272 | /> |
| 273 | </section> |
| 274 | ); |
| 275 | } |