-
Notifications
You must be signed in to change notification settings - Fork 3.7k
fix(web): offer Update client when the app is behind the server #5254
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
matheustimbo
wants to merge
7
commits into
pingdotgg:main
Choose a base branch
from
matheustimbo:fix/version-skew-update-direction
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
663f609
fix(web): update client when version skew leaves the app behind
matheustimbo 85d9dea
fix(web): wait for settled desktop update state before downloading
matheustimbo 2acfd53
fix(web): ignore stale update state while awaiting client check
matheustimbo 1bc2dfc
fix(web): keep client update check alive across unmount
matheustimbo db5f85b
fix(web): keep Checking UI while a client update is in flight
matheustimbo 3ed1c5c
fix(web): sync Update client pending state via external store
matheustimbo caff1ce
fix(web): join in-flight desktop update checks instead of erroring
matheustimbo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,304 @@ | ||
| import { useCallback, useSyncExternalStore } from "react"; | ||
| import type { DesktopUpdateState } from "@t3tools/contracts"; | ||
|
|
||
| import { isElectron } from "../env"; | ||
| import { useDesktopUpdateState } from "../state/desktopUpdate"; | ||
| import { | ||
| canCheckForUpdate, | ||
| getDesktopUpdateActionError, | ||
| getDesktopUpdateInstallConfirmationMessage, | ||
| isDesktopUpdateButtonDisabled, | ||
| resolveDesktopUpdateButtonAction, | ||
| shouldToastDesktopUpdateActionResult, | ||
| } from "./desktopUpdate.logic"; | ||
| import { Button } from "./ui/button"; | ||
| import { Spinner } from "./ui/spinner"; | ||
| import { stackedThreadToast, toastManager } from "./ui/toast"; | ||
|
|
||
| const CHECK_SETTLE_TIMEOUT_MS = 60_000; | ||
| const CHECK_SETTLE_POLL_MS = 200; | ||
|
|
||
| /** Module-scoped so banner dismiss / route changes cannot drop an in-flight check. */ | ||
| let clientUpdateCheckInFlight = false; | ||
| const clientUpdateCheckListeners = new Set<() => void>(); | ||
|
|
||
| function setClientUpdateCheckInFlight(next: boolean): void { | ||
| if (clientUpdateCheckInFlight === next) return; | ||
| clientUpdateCheckInFlight = next; | ||
| for (const listener of clientUpdateCheckListeners) { | ||
| listener(); | ||
| } | ||
| } | ||
|
|
||
| function subscribeClientUpdateCheckInFlight(listener: () => void): () => void { | ||
| clientUpdateCheckListeners.add(listener); | ||
| return () => { | ||
| clientUpdateCheckListeners.delete(listener); | ||
| }; | ||
| } | ||
|
|
||
| function getClientUpdateCheckInFlightSnapshot(): boolean { | ||
| return clientUpdateCheckInFlight; | ||
| } | ||
|
|
||
| function useClientUpdateCheckInFlight(): boolean { | ||
| return useSyncExternalStore( | ||
| subscribeClientUpdateCheckInFlight, | ||
| getClientUpdateCheckInFlightSnapshot, | ||
| () => false, | ||
| ); | ||
| } | ||
|
|
||
| function sleep(ms: number): Promise<void> { | ||
| return new Promise((resolve) => { | ||
| setTimeout(resolve, ms); | ||
| }); | ||
| } | ||
|
|
||
| function downloadDesktopUpdate(): void { | ||
| const bridge = window.desktopBridge; | ||
| if (!bridge) return; | ||
| void bridge | ||
| .downloadUpdate() | ||
| .then((result) => { | ||
| if (result.completed) { | ||
| toastManager.add({ | ||
| type: "success", | ||
| title: "Update downloaded", | ||
| description: "Restart the app from the update button to install it.", | ||
| }); | ||
| } | ||
| if (!shouldToastDesktopUpdateActionResult(result)) return; | ||
| const actionError = getDesktopUpdateActionError(result); | ||
| if (!actionError) return; | ||
| toastManager.add( | ||
| stackedThreadToast({ | ||
| type: "error", | ||
| title: "Could not download update", | ||
| description: actionError, | ||
| }), | ||
| ); | ||
| }) | ||
| .catch((error: unknown) => { | ||
| toastManager.add( | ||
| stackedThreadToast({ | ||
| type: "error", | ||
| title: "Could not start update download", | ||
| description: error instanceof Error ? error.message : "An unexpected error occurred.", | ||
| }), | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| function handleSettledCheckState(state: DesktopUpdateState): void { | ||
| const nextAction = resolveDesktopUpdateButtonAction(state); | ||
| if (nextAction === "download") { | ||
| downloadDesktopUpdate(); | ||
| return; | ||
| } | ||
| if (nextAction === "install") { | ||
| return; | ||
| } | ||
| if (state.status === "up-to-date") { | ||
| toastManager.add({ | ||
| type: "info", | ||
| title: "No newer desktop update found", | ||
| description: | ||
| "This build may not have a published update yet. Install a newer T3 Code desktop build to match the server.", | ||
| }); | ||
| return; | ||
| } | ||
| if (state.status === "error") { | ||
| toastManager.add( | ||
| stackedThreadToast({ | ||
| type: "error", | ||
| title: "Could not check for updates", | ||
| description: state.message ?? "Update check failed.", | ||
| }), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Runs check → wait for settled desktop update state → download. Lives outside | ||
| * React so unmounting ClientUpdateAction (dismiss banner / leave Connections) | ||
| * cannot cancel the continuation. | ||
| * | ||
| * Returns whether this call owned the in-flight work. | ||
| */ | ||
| async function checkThenDownloadDesktopUpdate( | ||
| baselineCheckedAt: string | null, | ||
| ): Promise<"owned" | "skipped"> { | ||
| const bridge = window.desktopBridge; | ||
| if (!bridge || typeof bridge.checkForUpdate !== "function") return "skipped"; | ||
| if (clientUpdateCheckInFlight) return "skipped"; | ||
|
|
||
| setClientUpdateCheckInFlight(true); | ||
| try { | ||
| const result = await bridge.checkForUpdate(); | ||
| if (!result.checked) { | ||
| // `checked: false` is not always a hard failure — desktop skips starting a | ||
| // second check while one is already in flight (or while download/install is | ||
| // active). Join the in-flight check via polling; otherwise act on current state. | ||
| if (result.state.status === "checking") { | ||
| // fall through to the settle poll below | ||
| } else { | ||
| handleSettledCheckState(result.state); | ||
| const nextAction = resolveDesktopUpdateButtonAction(result.state); | ||
| if ( | ||
| nextAction === "none" && | ||
| result.state.status !== "downloading" && | ||
| result.state.status !== "up-to-date" && | ||
| result.state.status !== "error" | ||
| ) { | ||
| toastManager.add( | ||
| stackedThreadToast({ | ||
| type: "error", | ||
| title: "Could not check for updates", | ||
| description: | ||
| result.state.message ?? "Automatic updates are not available in this build.", | ||
| }), | ||
| ); | ||
| } | ||
| return "owned"; | ||
| } | ||
| } | ||
|
|
||
| const deadline = Date.now() + CHECK_SETTLE_TIMEOUT_MS; | ||
| while (Date.now() < deadline) { | ||
| const state = await bridge.getUpdateState(); | ||
| const checkAdvanced = state.status === "checking" || state.checkedAt !== baselineCheckedAt; | ||
| if (!checkAdvanced || state.status === "checking") { | ||
| await sleep(CHECK_SETTLE_POLL_MS); | ||
| continue; | ||
| } | ||
| handleSettledCheckState(state); | ||
| return "owned"; | ||
| } | ||
|
|
||
| toastManager.add( | ||
| stackedThreadToast({ | ||
| type: "error", | ||
| title: "Could not check for updates", | ||
| description: "Timed out waiting for the desktop updater to finish checking.", | ||
| }), | ||
| ); | ||
| return "owned"; | ||
| } catch (error: unknown) { | ||
| toastManager.add( | ||
| stackedThreadToast({ | ||
| type: "error", | ||
| title: "Could not check for updates", | ||
| description: error instanceof Error ? error.message : "Update check failed.", | ||
| }), | ||
| ); | ||
| return "owned"; | ||
| } finally { | ||
| setClientUpdateCheckInFlight(false); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Call-to-action when this client is behind the connected server. On desktop, | ||
| * drives the Electron updater (check → download → install). Elsewhere, only | ||
| * guidance text is shown — there is no server-install path for this case. | ||
| */ | ||
| export function ClientUpdateAction({ label = "Update client" }: { readonly label?: string }) { | ||
| const updateState = useDesktopUpdateState(); | ||
| const checkInFlight = useClientUpdateCheckInFlight(); | ||
|
|
||
| const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; | ||
| const checking = updateState?.status === "checking" || checkInFlight; | ||
| const downloading = updateState?.status === "downloading"; | ||
| const updatesDisabled = | ||
| updateState !== null && (!updateState.enabled || updateState.status === "disabled"); | ||
| const buttonDisabled = | ||
| checking || | ||
| downloading || | ||
| (action === "none" | ||
| ? !canCheckForUpdate(updateState) | ||
| : isDesktopUpdateButtonDisabled(updateState)); | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| const buttonLabel = | ||
| action === "install" | ||
| ? "Restart to update" | ||
| : action === "download" | ||
| ? label | ||
| : downloading | ||
| ? typeof updateState?.downloadPercent === "number" | ||
| ? `Downloading (${Math.floor(updateState.downloadPercent)}%)` | ||
| : "Downloading…" | ||
| : checking | ||
| ? "Checking…" | ||
| : label; | ||
|
|
||
| const handleClick = useCallback(() => { | ||
| const bridge = window.desktopBridge; | ||
| if (!bridge) return; | ||
|
|
||
| if (action === "download") { | ||
| downloadDesktopUpdate(); | ||
| return; | ||
| } | ||
|
|
||
| if (action === "install") { | ||
| const confirmed = window.confirm( | ||
| getDesktopUpdateInstallConfirmationMessage( | ||
| updateState ?? { availableVersion: null, downloadedVersion: null }, | ||
| navigator.platform, | ||
| ), | ||
| ); | ||
| if (!confirmed) return; | ||
| void bridge | ||
| .installUpdate() | ||
| .then((result) => { | ||
| if (!shouldToastDesktopUpdateActionResult(result)) return; | ||
| const actionError = getDesktopUpdateActionError(result); | ||
| if (!actionError) return; | ||
| toastManager.add( | ||
| stackedThreadToast({ | ||
| type: "error", | ||
| title: "Could not install update", | ||
| description: actionError, | ||
| }), | ||
| ); | ||
| }) | ||
| .catch((error: unknown) => { | ||
| toastManager.add( | ||
| stackedThreadToast({ | ||
| type: "error", | ||
| title: "Could not install update", | ||
| description: error instanceof Error ? error.message : "An unexpected error occurred.", | ||
| }), | ||
| ); | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| void checkThenDownloadDesktopUpdate(updateState?.checkedAt ?? null); | ||
| }, [action, updateState]); | ||
|
|
||
| if (!isElectron) { | ||
| return ( | ||
| <span className="text-muted-foreground text-xs"> | ||
| Update or reload this client to match the server. | ||
| </span> | ||
| ); | ||
| } | ||
|
|
||
| if (updatesDisabled) { | ||
| return ( | ||
| <span className="text-muted-foreground text-xs"> | ||
| Automatic updates are unavailable in this build. Install a newer T3 Code desktop build to | ||
| match the server. | ||
| </span> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <Button size="xs" disabled={buttonDisabled} onClick={handleClick}> | ||
| {checking || downloading ? <Spinner className="size-3.5" /> : null} | ||
| {buttonLabel} | ||
| </Button> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.