1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5import { Atom } from "effect/reactivity";
6import { useEffect } from "react";
7
8import { APP_VERSION } from "~/branding";
9import { appAtomRegistry } from "~/rpc/atomRegistry";
10import { useDesktopUpdateState } from "~/state/desktopUpdate";
11import { stackedThreadToast, toastManager } from "../ui/toast";
12import {
13 getUpdateEverythingToastView,
14 type UpdateEverythingResult,
15 type UpdateEverythingRun,
16} from "./updateEverything.logic";
17
18/** The running "update everything" pass, or null. Outlives the app menu. */
19const updateEverythingRunAtom = Atom.make<UpdateEverythingRun | null>(null).pipe(
20 Atom.keepAlive,
21 Atom.withLabel("update-everything-run"),
22);
23
24export function useUpdateEverythingRun(): UpdateEverythingRun | null {
25 return useAtomValue(updateEverythingRunAtom);
26}
27
28/** Starts, changes, or ends (null) the running pass. */
29export function setUpdateEverythingRun(
30 update: (run: UpdateEverythingRun | null) => UpdateEverythingRun | null,
31): void {
32 appAtomRegistry.update(updateEverythingRunAtom, update);
33}
34
35/** Records the result of every update for one machine in the running pass. */
36export function settleUpdateRunMachine(environmentId: EnvironmentId, failed: boolean): void {
37 setUpdateEverythingRun(
38 (run) =>
39 run && {
40 ...run,
41 machines: run.machines.map((machine) =>
42 machine.environmentId === environmentId
43 ? { ...machine, result: failed ? "failed" : "done" }
44 : machine,
45 ),
46 },
47 );
48}
49
50const SAVED_RESULT_KEY = "t3code:update-everything-result:v1";
51// A result older than this belongs to an install that never relaunched the app.
52const SAVED_RESULT_MAX_AGE_MS = 10 * 60 * 1000;
53
54const SavedResult = Schema.Struct({
55 savedAt: Schema.Number,
56 localVersion: Schema.NullOr(Schema.String),
57 updatedServers: Schema.Number,
58 updatedProviders: Schema.Number,
59 failures: Schema.Array(Schema.String),
60});
61const decodeSavedResult = Schema.decodeUnknownOption(Schema.fromJsonString(SavedResult));
62
63/** Keeps a result through this app's relaunch. Call it right before the install. */
64export function saveUpdateEverythingResult(result: UpdateEverythingResult): void {
65 try {
66 window.localStorage.setItem(
67 SAVED_RESULT_KEY,
68 JSON.stringify({ ...result, savedAt: Date.now() } satisfies typeof SavedResult.Type),
69 );
70 } catch {
71 // Without storage the summary is lost with the relaunch; the update still runs.
72 }
73}
74
75export function clearSavedUpdateEverythingResult(): void {
76 try {
77 window.localStorage.removeItem(SAVED_RESULT_KEY);
78 } catch {
79 // Nothing to clear.
80 }
81}
82
83function takeSavedUpdateEverythingResult(): UpdateEverythingResult | null {
84 try {
85 const raw = window.localStorage.getItem(SAVED_RESULT_KEY);
86 if (raw === null) return null;
87 window.localStorage.removeItem(SAVED_RESULT_KEY);
88 return Option.getOrNull(
89 Option.filter(
90 decodeSavedResult(raw),
91 (saved) => Date.now() - saved.savedAt < SAVED_RESULT_MAX_AGE_MS,
92 ),
93 );
94 } catch {
95 return null;
96 }
97}
98
99/** Shows the one summary toast for a finished pass. */
100export function showUpdateEverythingResult(result: UpdateEverythingResult): void {
101 const view = getUpdateEverythingToastView(result, APP_VERSION);
102 if (!view) return;
103 toastManager.add(
104 stackedThreadToast({
105 type: view.type,
106 title: view.title,
107 ...(view.description
108 ? { description: <span className="whitespace-pre-line">{view.description}</span> }
109 : {}),
110 }),
111 );
112}
113
114let notifiedNotAppliedVersion: string | null = null;
115
116/**
117 * Desktop launch notices for updates: the summary of a pass that ended with
118 * this app's relaunch, and a warning when the last install did not apply.
119 * Mount once at the app root.
120 */
121export function DesktopUpdateLaunchNotice() {
122 const notAppliedVersion = useDesktopUpdateState()?.notAppliedVersion ?? null;
123
124 useEffect(() => {
125 const result = takeSavedUpdateEverythingResult();
126 if (result) showUpdateEverythingResult(result);
127 }, []);
128
129 useEffect(() => {
130 if (notAppliedVersion === null || notifiedNotAppliedVersion === notAppliedVersion) return;
131 notifiedNotAppliedVersion = notAppliedVersion;
132 toastManager.add(
133 stackedThreadToast({
134 type: "error",
135 title: "Update did not apply",
136 description: `The Knife is still on ${APP_VERSION}. Install ${notAppliedVersion} again from the app menu.`,
137 }),
138 );
139 }, [notAppliedVersion]);
140
141 return null;
142}