| author | |
| committer | |
| log | f82bae16680206e4be20baf3d835941aa68611cf |
| tree | 91eddb476206554a07a0c0687f68d3040809523d |
| parent | 9e60597174dcd69ae405d3966a374213c745388a |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Jono Kemball <jono-kemball@idexx.com>
Co-authored-by: Ariaj Sarkar <rajsarkar02205@gmail.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>11 files changed, 575 insertions(+), 83 deletions(-)
apps/desktop/src/main.ts+22-3| ... | @@ -20,6 +20,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -20,6 +20,7 @@ import * as Effect from "effect/Effect"; |
| 20 | import type { | 20 | import type { |
| 21 | DesktopTheme, | 21 | DesktopTheme, |
| 22 | DesktopUpdateActionResult, | 22 | DesktopUpdateActionResult, |
| 23 | DesktopUpdateCheckResult, | ||
| 23 | DesktopUpdateState, | 24 | DesktopUpdateState, |
| 24 | } from "@t3tools/contracts"; | 25 | } from "@t3tools/contracts"; |
| 25 | import { autoUpdater } from "electron-updater"; | 26 | import { autoUpdater } from "electron-updater"; |
| ... | @@ -56,6 +57,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; | ... | @@ -56,6 +57,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| 56 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; | 57 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 57 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; | 58 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 58 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; | 59 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 60 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; | ||
| 59 | const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; | 61 | const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; |
| 60 | const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); | 62 | const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); |
| 61 | const STATE_DIR = Path.join(BASE_DIR, "userdata"); | 63 | const STATE_DIR = Path.join(BASE_DIR, "userdata"); |
| ... | @@ -756,13 +758,13 @@ function shouldEnableAutoUpdates(): boolean { | ... | @@ -756,13 +758,13 @@ function shouldEnableAutoUpdates(): boolean { |
| 756 | ); | 758 | ); |
| 757 | } | 759 | } |
| 758 | 760 | ||
| 759 | async function checkForUpdates(reason: string): Promise<void> { | 761 | async function checkForUpdates(reason: string): Promise<boolean> { |
| 760 | if (isQuitting || !updaterConfigured || updateCheckInFlight) return; | 762 | if (isQuitting || !updaterConfigured || updateCheckInFlight) return false; |
| 761 | if (updateState.status === "downloading" || updateState.status === "downloaded") { | 763 | if (updateState.status === "downloading" || updateState.status === "downloaded") { |
| 762 | console.info( | 764 | console.info( |
| 763 | `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`, | 765 | `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`, |
| 764 | ); | 766 | ); |
| 765 | return; | 767 | return false; |
| 766 | } | 768 | } |
| 767 | updateCheckInFlight = true; | 769 | updateCheckInFlight = true; |
| 768 | setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString())); | 770 | setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString())); |
| ... | @@ -770,12 +772,14 @@ async function checkForUpdates(reason: string): Promise<void> { | ... | @@ -770,12 +772,14 @@ async function checkForUpdates(reason: string): Promise<void> { |
| 770 | 772 | ||
| 771 | try { | 773 | try { |
| 772 | await autoUpdater.checkForUpdates(); | 774 | await autoUpdater.checkForUpdates(); |
| 775 | return true; | ||
| 773 | } catch (error: unknown) { | 776 | } catch (error: unknown) { |
| 774 | const message = error instanceof Error ? error.message : String(error); | 777 | const message = error instanceof Error ? error.message : String(error); |
| 775 | setUpdateState( | 778 | setUpdateState( |
| 776 | reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()), | 779 | reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()), |
| 777 | ); | 780 | ); |
| 778 | console.error(`[desktop-updater] Failed to check for updates: ${message}`); | 781 | console.error(`[desktop-updater] Failed to check for updates: ${message}`); |
| 782 | return true; | ||
| 779 | } finally { | 783 | } finally { |
| 780 | updateCheckInFlight = false; | 784 | updateCheckInFlight = false; |
| 781 | } | 785 | } |
| ... | @@ -1263,6 +1267,21 @@ function registerIpcHandlers(): void { | ... | @@ -1263,6 +1267,21 @@ function registerIpcHandlers(): void { |
| 1263 | state: updateState, | 1267 | state: updateState, |
| 1264 | } satisfies DesktopUpdateActionResult; | 1268 | } satisfies DesktopUpdateActionResult; |
| 1265 | }); | 1269 | }); |
| 1270 | |||
| 1271 | ipcMain.removeHandler(UPDATE_CHECK_CHANNEL); | ||
| 1272 | ipcMain.handle(UPDATE_CHECK_CHANNEL, async () => { | ||
| 1273 | if (!updaterConfigured) { | ||
| 1274 | return { | ||
| 1275 | checked: false, | ||
| 1276 | state: updateState, | ||
| 1277 | } satisfies DesktopUpdateCheckResult; | ||
| 1278 | } | ||
| 1279 | const checked = await checkForUpdates("web-ui"); | ||
| 1280 | return { | ||
| 1281 | checked, | ||
| 1282 | state: updateState, | ||
| 1283 | } satisfies DesktopUpdateCheckResult; | ||
| 1284 | }); | ||
| 1266 | } | 1285 | } |
| 1267 | 1286 | ||
| 1268 | function getIconOption(): { icon: string } | Record<string, never> { | 1287 | function getIconOption(): { icon: string } | Record<string, never> { |
apps/desktop/src/preload.ts+2| ... | @@ -9,6 +9,7 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; | ... | @@ -9,6 +9,7 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 9 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; | 9 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 10 | const UPDATE_STATE_CHANNEL = "desktop:update-state"; | 10 | const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| 11 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; | 11 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 12 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; | ||
| 12 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; | 13 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 13 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; | 14 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 14 | const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; | 15 | const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; |
| ... | @@ -35,6 +36,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -35,6 +36,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 35 | }; | 36 | }; |
| 36 | }, | 37 | }, |
| 37 | getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL), | 38 | getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL), |
| 39 | checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL), | ||
| 38 | downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL), | 40 | downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL), |
| 39 | installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL), | 41 | installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL), |
| 40 | onUpdateState: (listener) => { | 42 | onUpdateState: (listener) => { |
apps/web/src/components/Sidebar.tsx+10-45| ... | @@ -5,7 +5,6 @@ import { | ... | @@ -5,7 +5,6 @@ import { |
| 5 | FolderIcon, | 5 | FolderIcon, |
| 6 | GitPullRequestIcon, | 6 | GitPullRequestIcon, |
| 7 | PlusIcon, | 7 | PlusIcon, |
| 8 | RocketIcon, | ||
| 9 | SettingsIcon, | 8 | SettingsIcon, |
| 10 | SquarePenIcon, | 9 | SquarePenIcon, |
| 11 | TerminalIcon, | 10 | TerminalIcon, |
| ... | @@ -78,12 +77,10 @@ import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; | ... | @@ -78,12 +77,10 @@ import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; |
| 78 | import { | 77 | import { |
| 79 | getArm64IntelBuildWarningDescription, | 78 | getArm64IntelBuildWarningDescription, |
| 80 | getDesktopUpdateActionError, | 79 | getDesktopUpdateActionError, |
| 81 | getDesktopUpdateButtonTooltip, | 80 | getDesktopUpdateInstallConfirmationMessage, |
| 82 | isDesktopUpdateButtonDisabled, | 81 | isDesktopUpdateButtonDisabled, |
| 83 | resolveDesktopUpdateButtonAction, | 82 | resolveDesktopUpdateButtonAction, |
| 84 | shouldShowArm64IntelBuildWarning, | 83 | shouldShowArm64IntelBuildWarning, |
| 85 | shouldHighlightDesktopUpdateError, | ||
| 86 | shouldShowDesktopUpdateButton, | ||
| 87 | shouldToastDesktopUpdateActionResult, | 84 | shouldToastDesktopUpdateActionResult, |
| 88 | } from "./desktopUpdate.logic"; | 85 | } from "./desktopUpdate.logic"; |
| 89 | import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; | 86 | import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; |
| ... | @@ -120,6 +117,7 @@ import { | ... | @@ -120,6 +117,7 @@ import { |
| 120 | sortProjectsForSidebar, | 117 | sortProjectsForSidebar, |
| 121 | sortThreadsForSidebar, | 118 | sortThreadsForSidebar, |
| 122 | } from "./Sidebar.logic"; | 119 | } from "./Sidebar.logic"; |
| 120 | import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; | ||
| 123 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | 121 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 124 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; | 122 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; |
| 125 | 123 | ||
| ... | @@ -1661,12 +1659,6 @@ export default function Sidebar() { | ... | @@ -1661,12 +1659,6 @@ export default function Sidebar() { |
| 1661 | }; | 1659 | }; |
| 1662 | }, []); | 1660 | }, []); |
| 1663 | 1661 | ||
| 1664 | const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState); | ||
| 1665 | |||
| 1666 | const desktopUpdateTooltip = desktopUpdateState | ||
| 1667 | ? getDesktopUpdateButtonTooltip(desktopUpdateState) | ||
| 1668 | : "Update available"; | ||
| 1669 | |||
| 1670 | const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); | 1662 | const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); |
| 1671 | const desktopUpdateButtonAction = desktopUpdateState | 1663 | const desktopUpdateButtonAction = desktopUpdateState |
| 1672 | ? resolveDesktopUpdateButtonAction(desktopUpdateState) | 1664 | ? resolveDesktopUpdateButtonAction(desktopUpdateState) |
| ... | @@ -1677,17 +1669,6 @@ export default function Sidebar() { | ... | @@ -1677,17 +1669,6 @@ export default function Sidebar() { |
| 1677 | desktopUpdateState && showArm64IntelBuildWarning | 1669 | desktopUpdateState && showArm64IntelBuildWarning |
| 1678 | ? getArm64IntelBuildWarningDescription(desktopUpdateState) | 1670 | ? getArm64IntelBuildWarningDescription(desktopUpdateState) |
| 1679 | : null; | 1671 | : null; |
| 1680 | const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled | ||
| 1681 | ? "cursor-not-allowed opacity-60" | ||
| 1682 | : "hover:bg-accent hover:text-foreground"; | ||
| 1683 | const desktopUpdateButtonClasses = | ||
| 1684 | desktopUpdateState?.status === "downloaded" | ||
| 1685 | ? "text-emerald-500" | ||
| 1686 | : desktopUpdateState?.status === "downloading" | ||
| 1687 | ? "text-sky-400" | ||
| 1688 | : shouldHighlightDesktopUpdateError(desktopUpdateState) | ||
| 1689 | ? "text-rose-500 animate-pulse" | ||
| 1690 | : "text-amber-500 animate-pulse"; | ||
| 1691 | const newThreadShortcutLabel = | 1672 | const newThreadShortcutLabel = |
| 1692 | shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ?? | 1673 | shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ?? |
| 1693 | shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions); | 1674 | shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions); |
| ... | @@ -1728,6 +1709,10 @@ export default function Sidebar() { | ... | @@ -1728,6 +1709,10 @@ export default function Sidebar() { |
| 1728 | } | 1709 | } |
| 1729 | 1710 | ||
| 1730 | if (desktopUpdateButtonAction === "install") { | 1711 | if (desktopUpdateButtonAction === "install") { |
| 1712 | const confirmed = window.confirm( | ||
| 1713 | getDesktopUpdateInstallConfirmationMessage(desktopUpdateState), | ||
| 1714 | ); | ||
| 1715 | if (!confirmed) return; | ||
| 1731 | void bridge | 1716 | void bridge |
| 1732 | .installUpdate() | 1717 | .installUpdate() |
| 1733 | .then((result) => { | 1718 | .then((result) => { |
| ... | @@ -1795,30 +1780,9 @@ export default function Sidebar() { | ... | @@ -1795,30 +1780,9 @@ export default function Sidebar() { |
| 1795 | return ( | 1780 | return ( |
| 1796 | <> | 1781 | <> |
| 1797 | {isElectron ? ( | 1782 | {isElectron ? ( |
| 1798 | <> | 1783 | <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]"> |
| 1799 | <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]"> | 1784 | {wordmark} |
| 1800 | {wordmark} | 1785 | </SidebarHeader> |
| 1801 | {showDesktopUpdateButton && ( | ||
| 1802 | <Tooltip> | ||
| 1803 | <TooltipTrigger | ||
| 1804 | render={ | ||
| 1805 | <button | ||
| 1806 | type="button" | ||
| 1807 | aria-label={desktopUpdateTooltip} | ||
| 1808 | aria-disabled={desktopUpdateButtonDisabled || undefined} | ||
| 1809 | disabled={desktopUpdateButtonDisabled} | ||
| 1810 | className={`inline-flex size-7 ml-auto mt-1.5 items-center justify-center rounded-md text-muted-foreground transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`} | ||
| 1811 | onClick={handleDesktopUpdateButtonClick} | ||
| 1812 | > | ||
| 1813 | <RocketIcon className="size-3.5" /> | ||
| 1814 | </button> | ||
| 1815 | } | ||
| 1816 | /> | ||
| 1817 | <TooltipPopup side="bottom">{desktopUpdateTooltip}</TooltipPopup> | ||
| 1818 | </Tooltip> | ||
| 1819 | )} | ||
| 1820 | </SidebarHeader> | ||
| 1821 | </> | ||
| 1822 | ) : ( | 1786 | ) : ( |
| 1823 | <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3"> | 1787 | <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3"> |
| 1824 | {wordmark} | 1788 | {wordmark} |
| ... | @@ -2006,6 +1970,7 @@ export default function Sidebar() { | ... | @@ -2006,6 +1970,7 @@ export default function Sidebar() { |
| 2006 | 1970 | ||
| 2007 | <SidebarSeparator /> | 1971 | <SidebarSeparator /> |
| 2008 | <SidebarFooter className="p-2"> | 1972 | <SidebarFooter className="p-2"> |
| 1973 | <SidebarUpdatePill /> | ||
| 2009 | <SidebarMenu> | 1974 | <SidebarMenu> |
| 2010 | <SidebarMenuItem> | 1975 | <SidebarMenuItem> |
| 2011 | <SidebarMenuButton | 1976 | <SidebarMenuButton |
apps/web/src/components/desktopUpdate.logic.test.ts+95-20| ... | @@ -2,12 +2,13 @@ import { describe, expect, it } from "vitest"; | ... | @@ -2,12 +2,13 @@ import { describe, expect, it } from "vitest"; |
| 2 | import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; | 2 | import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | canCheckForUpdate, | ||
| 5 | getArm64IntelBuildWarningDescription, | 6 | getArm64IntelBuildWarningDescription, |
| 6 | getDesktopUpdateActionError, | 7 | getDesktopUpdateActionError, |
| 7 | getDesktopUpdateButtonTooltip, | 8 | getDesktopUpdateButtonTooltip, |
| 9 | getDesktopUpdateInstallConfirmationMessage, | ||
| 8 | isDesktopUpdateButtonDisabled, | 10 | isDesktopUpdateButtonDisabled, |
| 9 | resolveDesktopUpdateButtonAction, | 11 | resolveDesktopUpdateButtonAction, |
| 10 | shouldHighlightDesktopUpdateError, | ||
| 11 | shouldShowArm64IntelBuildWarning, | 12 | shouldShowArm64IntelBuildWarning, |
| 12 | shouldShowDesktopUpdateButton, | 13 | shouldShowDesktopUpdateButton, |
| 13 | shouldToastDesktopUpdateActionResult, | 14 | shouldToastDesktopUpdateActionResult, |
| ... | @@ -69,6 +70,16 @@ describe("desktop update button state", () => { | ... | @@ -69,6 +70,16 @@ describe("desktop update button state", () => { |
| 69 | expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); | 70 | expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); |
| 70 | }); | 71 | }); |
| 71 | 72 | ||
| 73 | it("prefers install when a downloaded version already exists", () => { | ||
| 74 | const state: DesktopUpdateState = { | ||
| 75 | ...baseState, | ||
| 76 | status: "available", | ||
| 77 | availableVersion: "1.1.0", | ||
| 78 | downloadedVersion: "1.1.0", | ||
| 79 | }; | ||
| 80 | expect(resolveDesktopUpdateButtonAction(state)).toBe("install"); | ||
| 81 | }); | ||
| 82 | |||
| 72 | it("hides the button for non-actionable check errors", () => { | 83 | it("hides the button for non-actionable check errors", () => { |
| 73 | const state: DesktopUpdateState = { | 84 | const state: DesktopUpdateState = { |
| 74 | ...baseState, | 85 | ...baseState, |
| ... | @@ -169,25 +180,6 @@ describe("desktop update UI helpers", () => { | ... | @@ -169,25 +180,6 @@ describe("desktop update UI helpers", () => { |
| 169 | ).toBe(false); | 180 | ).toBe(false); |
| 170 | }); | 181 | }); |
| 171 | 182 | ||
| 172 | it("highlights only actionable updater errors", () => { | ||
| 173 | expect( | ||
| 174 | shouldHighlightDesktopUpdateError({ | ||
| 175 | ...baseState, | ||
| 176 | status: "error", | ||
| 177 | errorContext: "download", | ||
| 178 | canRetry: true, | ||
| 179 | }), | ||
| 180 | ).toBe(true); | ||
| 181 | expect( | ||
| 182 | shouldHighlightDesktopUpdateError({ | ||
| 183 | ...baseState, | ||
| 184 | status: "error", | ||
| 185 | errorContext: "check", | ||
| 186 | canRetry: true, | ||
| 187 | }), | ||
| 188 | ).toBe(false); | ||
| 189 | }); | ||
| 190 | |||
| 191 | it("shows an Apple Silicon warning for Intel builds under Rosetta", () => { | 183 | it("shows an Apple Silicon warning for Intel builds under Rosetta", () => { |
| 192 | const state: DesktopUpdateState = { | 184 | const state: DesktopUpdateState = { |
| 193 | ...baseState, | 185 | ...baseState, |
| ... | @@ -213,4 +205,87 @@ describe("desktop update UI helpers", () => { | ... | @@ -213,4 +205,87 @@ describe("desktop update UI helpers", () => { |
| 213 | 205 | ||
| 214 | expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update"); | 206 | expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update"); |
| 215 | }); | 207 | }); |
| 208 | |||
| 209 | it("includes the downloaded version in the install confirmation copy", () => { | ||
| 210 | expect( | ||
| 211 | getDesktopUpdateInstallConfirmationMessage({ | ||
| 212 | availableVersion: "1.1.0", | ||
| 213 | downloadedVersion: "1.1.1", | ||
| 214 | }), | ||
| 215 | ).toContain("Install update 1.1.1 and restart T3 Code?"); | ||
| 216 | }); | ||
| 217 | |||
| 218 | it("falls back to generic install confirmation copy when no version is available", () => { | ||
| 219 | expect( | ||
| 220 | getDesktopUpdateInstallConfirmationMessage({ | ||
| 221 | availableVersion: null, | ||
| 222 | downloadedVersion: null, | ||
| 223 | }), | ||
| 224 | ).toContain("Install update and restart T3 Code?"); | ||
| 225 | }); | ||
| 226 | }); | ||
| 227 | |||
| 228 | describe("canCheckForUpdate", () => { | ||
| 229 | it("returns false for null state", () => { | ||
| 230 | expect(canCheckForUpdate(null)).toBe(false); | ||
| 231 | }); | ||
| 232 | |||
| 233 | it("returns false when updates are disabled", () => { | ||
| 234 | expect(canCheckForUpdate({ ...baseState, enabled: false, status: "disabled" })).toBe(false); | ||
| 235 | }); | ||
| 236 | |||
| 237 | it("returns false while checking", () => { | ||
| 238 | expect(canCheckForUpdate({ ...baseState, status: "checking" })).toBe(false); | ||
| 239 | }); | ||
| 240 | |||
| 241 | it("returns false while downloading", () => { | ||
| 242 | expect(canCheckForUpdate({ ...baseState, status: "downloading", downloadPercent: 50 })).toBe( | ||
| 243 | false, | ||
| 244 | ); | ||
| 245 | }); | ||
| 246 | |||
| 247 | it("returns false once an update has been downloaded", () => { | ||
| 248 | expect( | ||
| 249 | canCheckForUpdate({ | ||
| 250 | ...baseState, | ||
| 251 | status: "downloaded", | ||
| 252 | availableVersion: "1.1.0", | ||
| 253 | downloadedVersion: "1.1.0", | ||
| 254 | }), | ||
| 255 | ).toBe(false); | ||
| 256 | }); | ||
| 257 | |||
| 258 | it("returns true when idle", () => { | ||
| 259 | expect(canCheckForUpdate({ ...baseState, status: "idle" })).toBe(true); | ||
| 260 | }); | ||
| 261 | |||
| 262 | it("returns true when up-to-date", () => { | ||
| 263 | expect(canCheckForUpdate({ ...baseState, status: "up-to-date" })).toBe(true); | ||
| 264 | }); | ||
| 265 | |||
| 266 | it("returns true when an update is available", () => { | ||
| 267 | expect( | ||
| 268 | canCheckForUpdate({ ...baseState, status: "available", availableVersion: "1.1.0" }), | ||
| 269 | ).toBe(true); | ||
| 270 | }); | ||
| 271 | |||
| 272 | it("returns true on error so the user can retry", () => { | ||
| 273 | expect( | ||
| 274 | canCheckForUpdate({ | ||
| 275 | ...baseState, | ||
| 276 | status: "error", | ||
| 277 | errorContext: "check", | ||
| 278 | message: "network", | ||
| 279 | }), | ||
| 280 | ).toBe(true); | ||
| 281 | }); | ||
| 282 | }); | ||
| 283 | |||
| 284 | describe("getDesktopUpdateButtonTooltip", () => { | ||
| 285 | it("returns 'Up to date' for non-actionable states", () => { | ||
| 286 | expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "idle" })).toBe("Up to date"); | ||
| 287 | expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "up-to-date" })).toBe( | ||
| 288 | "Up to date", | ||
| 289 | ); | ||
| 290 | }); | ||
| 216 | }); | 291 | }); |
apps/web/src/components/desktopUpdate.logic.ts+21-7| ... | @@ -5,16 +5,13 @@ export type DesktopUpdateButtonAction = "download" | "install" | "none"; | ... | @@ -5,16 +5,13 @@ export type DesktopUpdateButtonAction = "download" | "install" | "none"; |
| 5 | export function resolveDesktopUpdateButtonAction( | 5 | export function resolveDesktopUpdateButtonAction( |
| 6 | state: DesktopUpdateState, | 6 | state: DesktopUpdateState, |
| 7 | ): DesktopUpdateButtonAction { | 7 | ): DesktopUpdateButtonAction { |
| 8 | if (state.downloadedVersion) { | ||
| 9 | return "install"; | ||
| 10 | } | ||
| 8 | if (state.status === "available") { | 11 | if (state.status === "available") { |
| 9 | return "download"; | 12 | return "download"; |
| 10 | } | 13 | } |
| 11 | if (state.status === "downloaded") { | ||
| 12 | return "install"; | ||
| 13 | } | ||
| 14 | if (state.status === "error") { | 14 | if (state.status === "error") { |
| 15 | if (state.errorContext === "install" && state.downloadedVersion) { | ||
| 16 | return "install"; | ||
| 17 | } | ||
| 18 | if (state.errorContext === "download" && state.availableVersion) { | 15 | if (state.errorContext === "download" && state.availableVersion) { |
| 19 | return "download"; | 16 | return "download"; |
| 20 | } | 17 | } |
| ... | @@ -76,7 +73,14 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string | ... | @@ -76,7 +73,14 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string |
| 76 | } | 73 | } |
| 77 | return state.message ?? "Update failed"; | 74 | return state.message ?? "Update failed"; |
| 78 | } | 75 | } |
| 79 | return "Update available"; | 76 | return "Up to date"; |
| 77 | } | ||
| 78 | |||
| 79 | export function getDesktopUpdateInstallConfirmationMessage( | ||
| 80 | state: Pick<DesktopUpdateState, "availableVersion" | "downloadedVersion">, | ||
| 81 | ): string { | ||
| 82 | const version = state.downloadedVersion ?? state.availableVersion; | ||
| 83 | return `Install update${version ? ` ${version}` : ""} and restart T3 Code?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.`; | ||
| 80 | } | 84 | } |
| 81 | 85 | ||
| 82 | export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null { | 86 | export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null { |
| ... | @@ -94,3 +98,13 @@ export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | nu | ... | @@ -94,3 +98,13 @@ export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | nu |
| 94 | if (!state || state.status !== "error") return false; | 98 | if (!state || state.status !== "error") return false; |
| 95 | return state.errorContext === "download" || state.errorContext === "install"; | 99 | return state.errorContext === "download" || state.errorContext === "install"; |
| 96 | } | 100 | } |
| 101 | |||
| 102 | export function canCheckForUpdate(state: DesktopUpdateState | null): boolean { | ||
| 103 | if (!state || !state.enabled) return false; | ||
| 104 | return ( | ||
| 105 | state.status !== "checking" && | ||
| 106 | state.status !== "downloading" && | ||
| 107 | state.status !== "downloaded" && | ||
| 108 | state.status !== "disabled" | ||
| 109 | ); | ||
| 110 | } |
apps/web/src/components/settings/SettingsPanels.tsx+151-7| ... | @@ -22,12 +22,24 @@ import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | ... | @@ -22,12 +22,24 @@ import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 22 | import { normalizeModelSlug } from "@t3tools/shared/model"; | 22 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 23 | import { Equal } from "effect"; | 23 | import { Equal } from "effect"; |
| 24 | import { APP_VERSION } from "../../branding"; | 24 | import { APP_VERSION } from "../../branding"; |
| 25 | import { | ||
| 26 | canCheckForUpdate, | ||
| 27 | getDesktopUpdateButtonTooltip, | ||
| 28 | getDesktopUpdateInstallConfirmationMessage, | ||
| 29 | isDesktopUpdateButtonDisabled, | ||
| 30 | resolveDesktopUpdateButtonAction, | ||
| 31 | } from "../../components/desktopUpdate.logic"; | ||
| 25 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; | 32 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; |
| 26 | import { TraitsPicker } from "../chat/TraitsPicker"; | 33 | import { TraitsPicker } from "../chat/TraitsPicker"; |
| 27 | import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; | 34 | import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; |
| 35 | import { isElectron } from "../../env"; | ||
| 28 | import { useTheme } from "../../hooks/useTheme"; | 36 | import { useTheme } from "../../hooks/useTheme"; |
| 29 | import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; | 37 | import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; |
| 30 | import { useThreadActions } from "../../hooks/useThreadActions"; | 38 | import { useThreadActions } from "../../hooks/useThreadActions"; |
| 39 | import { | ||
| 40 | setDesktopUpdateStateQueryData, | ||
| 41 | useDesktopUpdateState, | ||
| 42 | } from "../../lib/desktopUpdateReactQuery"; | ||
| 31 | import { serverConfigQueryOptions, serverQueryKeys } from "../../lib/serverReactQuery"; | 43 | import { serverConfigQueryOptions, serverQueryKeys } from "../../lib/serverReactQuery"; |
| 32 | import { | 44 | import { |
| 33 | MAX_CUSTOM_MODEL_LENGTH, | 45 | MAX_CUSTOM_MODEL_LENGTH, |
| ... | @@ -236,7 +248,7 @@ function SettingsRow({ | ... | @@ -236,7 +248,7 @@ function SettingsRow({ |
| 236 | control, | 248 | control, |
| 237 | children, | 249 | children, |
| 238 | }: { | 250 | }: { |
| 239 | title: string; | 251 | title: ReactNode; |
| 240 | description: string; | 252 | description: string; |
| 241 | status?: ReactNode; | 253 | status?: ReactNode; |
| 242 | resetAction?: ReactNode; | 254 | resetAction?: ReactNode; |
| ... | @@ -299,6 +311,133 @@ function SettingsPageContainer({ children }: { children: ReactNode }) { | ... | @@ -299,6 +311,133 @@ function SettingsPageContainer({ children }: { children: ReactNode }) { |
| 299 | ); | 311 | ); |
| 300 | } | 312 | } |
| 301 | 313 | ||
| 314 | function AboutVersionTitle() { | ||
| 315 | return ( | ||
| 316 | <span className="inline-flex items-center gap-2"> | ||
| 317 | <span>Version</span> | ||
| 318 | <code className="text-[11px] font-medium text-muted-foreground">{APP_VERSION}</code> | ||
| 319 | </span> | ||
| 320 | ); | ||
| 321 | } | ||
| 322 | |||
| 323 | function AboutVersionSection() { | ||
| 324 | const queryClient = useQueryClient(); | ||
| 325 | const updateStateQuery = useDesktopUpdateState(); | ||
| 326 | |||
| 327 | const updateState = updateStateQuery.data ?? null; | ||
| 328 | |||
| 329 | const handleButtonClick = useCallback(() => { | ||
| 330 | const bridge = window.desktopBridge; | ||
| 331 | if (!bridge) return; | ||
| 332 | |||
| 333 | const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; | ||
| 334 | |||
| 335 | if (action === "download") { | ||
| 336 | void bridge | ||
| 337 | .downloadUpdate() | ||
| 338 | .then((result) => { | ||
| 339 | setDesktopUpdateStateQueryData(queryClient, result.state); | ||
| 340 | }) | ||
| 341 | .catch((error: unknown) => { | ||
| 342 | toastManager.add({ | ||
| 343 | type: "error", | ||
| 344 | title: "Could not download update", | ||
| 345 | description: error instanceof Error ? error.message : "Download failed.", | ||
| 346 | }); | ||
| 347 | }); | ||
| 348 | return; | ||
| 349 | } | ||
| 350 | |||
| 351 | if (action === "install") { | ||
| 352 | const confirmed = window.confirm( | ||
| 353 | getDesktopUpdateInstallConfirmationMessage( | ||
| 354 | updateState ?? { availableVersion: null, downloadedVersion: null }, | ||
| 355 | ), | ||
| 356 | ); | ||
| 357 | if (!confirmed) return; | ||
| 358 | void bridge | ||
| 359 | .installUpdate() | ||
| 360 | .then((result) => { | ||
| 361 | setDesktopUpdateStateQueryData(queryClient, result.state); | ||
| 362 | }) | ||
| 363 | .catch((error: unknown) => { | ||
| 364 | toastManager.add({ | ||
| 365 | type: "error", | ||
| 366 | title: "Could not install update", | ||
| 367 | description: error instanceof Error ? error.message : "Install failed.", | ||
| 368 | }); | ||
| 369 | }); | ||
| 370 | return; | ||
| 371 | } | ||
| 372 | |||
| 373 | if (typeof bridge.checkForUpdate !== "function") return; | ||
| 374 | void bridge | ||
| 375 | .checkForUpdate() | ||
| 376 | .then((result) => { | ||
| 377 | setDesktopUpdateStateQueryData(queryClient, result.state); | ||
| 378 | if (!result.checked) { | ||
| 379 | toastManager.add({ | ||
| 380 | type: "error", | ||
| 381 | title: "Could not check for updates", | ||
| 382 | description: | ||
| 383 | result.state.message ?? "Automatic updates are not available in this build.", | ||
| 384 | }); | ||
| 385 | } | ||
| 386 | }) | ||
| 387 | .catch((error: unknown) => { | ||
| 388 | toastManager.add({ | ||
| 389 | type: "error", | ||
| 390 | title: "Could not check for updates", | ||
| 391 | description: error instanceof Error ? error.message : "Update check failed.", | ||
| 392 | }); | ||
| 393 | }); | ||
| 394 | }, [queryClient, updateState]); | ||
| 395 | |||
| 396 | const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; | ||
| 397 | const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null; | ||
| 398 | const buttonDisabled = | ||
| 399 | action === "none" | ||
| 400 | ? !canCheckForUpdate(updateState) | ||
| 401 | : isDesktopUpdateButtonDisabled(updateState); | ||
| 402 | |||
| 403 | const actionLabel: Record<string, string> = { download: "Download", install: "Install" }; | ||
| 404 | const statusLabel: Record<string, string> = { | ||
| 405 | checking: "Checking…", | ||
| 406 | downloading: "Downloading…", | ||
| 407 | "up-to-date": "Up to Date", | ||
| 408 | }; | ||
| 409 | const buttonLabel = | ||
| 410 | actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates"; | ||
| 411 | const description = | ||
| 412 | action === "download" || action === "install" | ||
| 413 | ? "Update available." | ||
| 414 | : "Current version of the application."; | ||
| 415 | |||
| 416 | return ( | ||
| 417 | <SettingsRow | ||
| 418 | title={<AboutVersionTitle />} | ||
| 419 | description={description} | ||
| 420 | control={ | ||
| 421 | <Tooltip> | ||
| 422 | <TooltipTrigger | ||
| 423 | render={ | ||
| 424 | <Button | ||
| 425 | size="xs" | ||
| 426 | variant={action === "install" ? "default" : "outline"} | ||
| 427 | disabled={buttonDisabled} | ||
| 428 | onClick={handleButtonClick} | ||
| 429 | > | ||
| 430 | {buttonLabel} | ||
| 431 | </Button> | ||
| 432 | } | ||
| 433 | /> | ||
| 434 | {buttonTooltip ? <TooltipPopup>{buttonTooltip}</TooltipPopup> : null} | ||
| 435 | </Tooltip> | ||
| 436 | } | ||
| 437 | /> | ||
| 438 | ); | ||
| 439 | } | ||
| 440 | |||
| 302 | export function useSettingsRestore(onRestored?: () => void) { | 441 | export function useSettingsRestore(onRestored?: () => void) { |
| 303 | const { theme, setTheme } = useTheme(); | 442 | const { theme, setTheme } = useTheme(); |
| 304 | const settings = useSettings(); | 443 | const settings = useSettings(); |
| ... | @@ -1258,12 +1397,17 @@ export function GeneralSettingsPanel() { | ... | @@ -1258,12 +1397,17 @@ export function GeneralSettingsPanel() { |
| 1258 | </Button> | 1397 | </Button> |
| 1259 | } | 1398 | } |
| 1260 | /> | 1399 | /> |
| 1400 | </SettingsSection> | ||
| 1261 | 1401 | ||
| 1262 | <SettingsRow | 1402 | <SettingsSection title="About"> |
| 1263 | title="Version" | 1403 | {isElectron ? ( |
| 1264 | description="Current application version." | 1404 | <AboutVersionSection /> |
| 1265 | control={<code className="text-xs font-medium text-muted-foreground">{APP_VERSION}</code>} | 1405 | ) : ( |
| 1266 | /> | 1406 | <SettingsRow |
| 1407 | title={<AboutVersionTitle />} | ||
| 1408 | description="Current version of the application." | ||
| 1409 | /> | ||
| 1410 | )} | ||
| 1267 | </SettingsSection> | 1411 | </SettingsSection> |
| 1268 | </SettingsPageContainer> | 1412 | </SettingsPageContainer> |
| 1269 | ); | 1413 | ); |
| ... | @@ -1325,7 +1469,7 @@ export function ArchivedThreadsPanel() { | ... | @@ -1325,7 +1469,7 @@ export function ArchivedThreadsPanel() { |
| 1325 | <SettingsPageContainer> | 1469 | <SettingsPageContainer> |
| 1326 | {archivedGroups.length === 0 ? ( | 1470 | {archivedGroups.length === 0 ? ( |
| 1327 | <SettingsSection title="Archived threads"> | 1471 | <SettingsSection title="Archived threads"> |
| 1328 | <Empty className="min-h-[22rem]"> | 1472 | <Empty className="min-h-88"> |
| 1329 | <EmptyMedia variant="icon"> | 1473 | <EmptyMedia variant="icon"> |
| 1330 | <ArchiveIcon /> | 1474 | <ArchiveIcon /> |
| 1331 | </EmptyMedia> | 1475 | </EmptyMedia> |
apps/web/src/components/settings/SettingsSidebarNav.tsx+1-1| ... | @@ -20,7 +20,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ | ... | @@ -20,7 +20,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ |
| 20 | icon: ComponentType<{ className?: string }>; | 20 | icon: ComponentType<{ className?: string }>; |
| 21 | }> = [ | 21 | }> = [ |
| 22 | { label: "General", to: "/settings/general", icon: Settings2Icon }, | 22 | { label: "General", to: "/settings/general", icon: Settings2Icon }, |
| 23 | { label: "Archived threads", to: "/settings/archived", icon: ArchiveIcon }, | 23 | { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, |
| 24 | ]; | 24 | ]; |
| 25 | 25 | ||
| 26 | export function SettingsSidebarNav({ pathname }: { pathname: string }) { | 26 | export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
apps/web/src/components/sidebar/SidebarUpdatePill.tsx created+176| ... | @@ -0,0 +1,176 @@ | ||
| 1 | import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react"; | ||
| 2 | import { useQueryClient } from "@tanstack/react-query"; | ||
| 3 | import { useCallback, useState } from "react"; | ||
| 4 | import { isElectron } from "../../env"; | ||
| 5 | import { | ||
| 6 | setDesktopUpdateStateQueryData, | ||
| 7 | useDesktopUpdateState, | ||
| 8 | } from "../../lib/desktopUpdateReactQuery"; | ||
| 9 | import { toastManager } from "../ui/toast"; | ||
| 10 | import { | ||
| 11 | getArm64IntelBuildWarningDescription, | ||
| 12 | getDesktopUpdateActionError, | ||
| 13 | getDesktopUpdateButtonTooltip, | ||
| 14 | getDesktopUpdateInstallConfirmationMessage, | ||
| 15 | isDesktopUpdateButtonDisabled, | ||
| 16 | resolveDesktopUpdateButtonAction, | ||
| 17 | shouldShowArm64IntelBuildWarning, | ||
| 18 | shouldShowDesktopUpdateButton, | ||
| 19 | shouldToastDesktopUpdateActionResult, | ||
| 20 | } from "../desktopUpdate.logic"; | ||
| 21 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; | ||
| 22 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 23 | |||
| 24 | export function SidebarUpdatePill() { | ||
| 25 | const queryClient = useQueryClient(); | ||
| 26 | const state = useDesktopUpdateState().data ?? null; | ||
| 27 | const [dismissed, setDismissed] = useState(false); | ||
| 28 | |||
| 29 | const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed; | ||
| 30 | const tooltip = state ? getDesktopUpdateButtonTooltip(state) : "Update available"; | ||
| 31 | const disabled = isDesktopUpdateButtonDisabled(state); | ||
| 32 | const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; | ||
| 33 | |||
| 34 | const showArm64Warning = isElectron && shouldShowArm64IntelBuildWarning(state); | ||
| 35 | const arm64Description = | ||
| 36 | state && showArm64Warning ? getArm64IntelBuildWarningDescription(state) : null; | ||
| 37 | |||
| 38 | const handleAction = useCallback(() => { | ||
| 39 | const bridge = window.desktopBridge; | ||
| 40 | if (!bridge || !state) return; | ||
| 41 | if (disabled || action === "none") return; | ||
| 42 | |||
| 43 | if (action === "download") { | ||
| 44 | void bridge | ||
| 45 | .downloadUpdate() | ||
| 46 | .then((result) => { | ||
| 47 | setDesktopUpdateStateQueryData(queryClient, result.state); | ||
| 48 | if (result.completed) { | ||
| 49 | toastManager.add({ | ||
| 50 | type: "success", | ||
| 51 | title: "Update downloaded", | ||
| 52 | description: "Restart the app from the update button to install it.", | ||
| 53 | }); | ||
| 54 | } | ||
| 55 | if (!shouldToastDesktopUpdateActionResult(result)) return; | ||
| 56 | const actionError = getDesktopUpdateActionError(result); | ||
| 57 | if (!actionError) return; | ||
| 58 | toastManager.add({ | ||
| 59 | type: "error", | ||
| 60 | title: "Could not download update", | ||
| 61 | description: actionError, | ||
| 62 | }); | ||
| 63 | }) | ||
| 64 | .catch((error) => { | ||
| 65 | toastManager.add({ | ||
| 66 | type: "error", | ||
| 67 | title: "Could not start update download", | ||
| 68 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | ||
| 69 | }); | ||
| 70 | }); | ||
| 71 | return; | ||
| 72 | } | ||
| 73 | |||
| 74 | if (action === "install") { | ||
| 75 | const confirmed = window.confirm(getDesktopUpdateInstallConfirmationMessage(state)); | ||
| 76 | if (!confirmed) return; | ||
| 77 | void bridge | ||
| 78 | .installUpdate() | ||
| 79 | .then((result) => { | ||
| 80 | setDesktopUpdateStateQueryData(queryClient, result.state); | ||
| 81 | if (!shouldToastDesktopUpdateActionResult(result)) return; | ||
| 82 | const actionError = getDesktopUpdateActionError(result); | ||
| 83 | if (!actionError) return; | ||
| 84 | toastManager.add({ | ||
| 85 | type: "error", | ||
| 86 | title: "Could not install update", | ||
| 87 | description: actionError, | ||
| 88 | }); | ||
| 89 | }) | ||
| 90 | .catch((error) => { | ||
| 91 | toastManager.add({ | ||
| 92 | type: "error", | ||
| 93 | title: "Could not install update", | ||
| 94 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | ||
| 95 | }); | ||
| 96 | }); | ||
| 97 | } | ||
| 98 | }, [action, disabled, queryClient, state]); | ||
| 99 | |||
| 100 | if (!visible && !showArm64Warning) return null; | ||
| 101 | |||
| 102 | return ( | ||
| 103 | <div className="flex flex-col gap-1"> | ||
| 104 | {showArm64Warning && arm64Description && ( | ||
| 105 | <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8 text-xs"> | ||
| 106 | <TriangleAlertIcon /> | ||
| 107 | <AlertTitle>Intel build on Apple Silicon</AlertTitle> | ||
| 108 | <AlertDescription>{arm64Description}</AlertDescription> | ||
| 109 | </Alert> | ||
| 110 | )} | ||
| 111 | {visible && ( | ||
| 112 | <div | ||
| 113 | className={`group/update relative flex h-7 w-full items-center rounded-lg bg-primary/15 text-xs font-medium text-primary ${ | ||
| 114 | disabled ? " cursor-not-allowed opacity-60" : "" | ||
| 115 | }`} | ||
| 116 | > | ||
| 117 | <div className="pointer-events-none absolute inset-0 rounded-lg transition-colors group-has-[button.update-main:hover]/update:bg-primary/22" /> | ||
| 118 | <Tooltip> | ||
| 119 | <TooltipTrigger | ||
| 120 | render={ | ||
| 121 | <button | ||
| 122 | type="button" | ||
| 123 | aria-label={tooltip} | ||
| 124 | aria-disabled={disabled || undefined} | ||
| 125 | disabled={disabled} | ||
| 126 | className="update-main relative flex h-full flex-1 items-center gap-2 px-2 enabled:cursor-pointer" | ||
| 127 | onClick={handleAction} | ||
| 128 | > | ||
| 129 | {action === "install" ? ( | ||
| 130 | <> | ||
| 131 | <RotateCwIcon className="size-3.5" /> | ||
| 132 | <span>Restart to update</span> | ||
| 133 | </> | ||
| 134 | ) : state?.status === "downloading" ? ( | ||
| 135 | <> | ||
| 136 | <DownloadIcon className="size-3.5" /> | ||
| 137 | <span> | ||
| 138 | Downloading | ||
| 139 | {typeof state.downloadPercent === "number" | ||
| 140 | ? ` (${Math.floor(state.downloadPercent)}%)` | ||
| 141 | : "…"} | ||
| 142 | </span> | ||
| 143 | </> | ||
| 144 | ) : ( | ||
| 145 | <> | ||
| 146 | <DownloadIcon className="size-3.5" /> | ||
| 147 | <span>Update available</span> | ||
| 148 | </> | ||
| 149 | )} | ||
| 150 | </button> | ||
| 151 | } | ||
| 152 | /> | ||
| 153 | <TooltipPopup side="top">{tooltip}</TooltipPopup> | ||
| 154 | </Tooltip> | ||
| 155 | {action === "download" && ( | ||
| 156 | <Tooltip> | ||
| 157 | <TooltipTrigger | ||
| 158 | render={ | ||
| 159 | <button | ||
| 160 | type="button" | ||
| 161 | aria-label="Dismiss update" | ||
| 162 | className="mr-1 inline-flex size-5 items-center justify-center rounded-md text-primary/60 transition-colors hover:text-primary" | ||
| 163 | onClick={() => setDismissed(true)} | ||
| 164 | > | ||
| 165 | <XIcon className="size-3.5" /> | ||
| 166 | </button> | ||
| 167 | } | ||
| 168 | /> | ||
| 169 | <TooltipPopup side="top">Dismiss until next launch</TooltipPopup> | ||
| 170 | </Tooltip> | ||
| 171 | )} | ||
| 172 | </div> | ||
| 173 | )} | ||
| 174 | </div> | ||
| 175 | ); | ||
| 176 | } | ||
apps/web/src/lib/desktopUpdateReactQuery.test.ts created+49| ... | @@ -0,0 +1,49 @@ | ||
| 1 | import { QueryClient } from "@tanstack/react-query"; | ||
| 2 | import { describe, expect, it } from "vitest"; | ||
| 3 | import type { DesktopUpdateState } from "@t3tools/contracts"; | ||
| 4 | import { | ||
| 5 | desktopUpdateQueryKeys, | ||
| 6 | desktopUpdateStateQueryOptions, | ||
| 7 | setDesktopUpdateStateQueryData, | ||
| 8 | } from "./desktopUpdateReactQuery"; | ||
| 9 | |||
| 10 | const baseState: DesktopUpdateState = { | ||
| 11 | enabled: true, | ||
| 12 | status: "idle", | ||
| 13 | currentVersion: "1.0.0", | ||
| 14 | hostArch: "x64", | ||
| 15 | appArch: "x64", | ||
| 16 | runningUnderArm64Translation: false, | ||
| 17 | availableVersion: null, | ||
| 18 | downloadedVersion: null, | ||
| 19 | downloadPercent: null, | ||
| 20 | checkedAt: null, | ||
| 21 | message: null, | ||
| 22 | errorContext: null, | ||
| 23 | canRetry: false, | ||
| 24 | }; | ||
| 25 | |||
| 26 | describe("desktopUpdateStateQueryOptions", () => { | ||
| 27 | it("always refetches on mount so Settings does not reuse stale desktop update state", () => { | ||
| 28 | const options = desktopUpdateStateQueryOptions(); | ||
| 29 | |||
| 30 | expect(options.staleTime).toBe(Infinity); | ||
| 31 | expect(options.refetchOnMount).toBe("always"); | ||
| 32 | }); | ||
| 33 | }); | ||
| 34 | |||
| 35 | describe("setDesktopUpdateStateQueryData", () => { | ||
| 36 | it("writes desktop update state into the shared cache key", () => { | ||
| 37 | const queryClient = new QueryClient(); | ||
| 38 | const nextState: DesktopUpdateState = { | ||
| 39 | ...baseState, | ||
| 40 | status: "downloaded", | ||
| 41 | availableVersion: "1.1.0", | ||
| 42 | downloadedVersion: "1.1.0", | ||
| 43 | }; | ||
| 44 | |||
| 45 | setDesktopUpdateStateQueryData(queryClient, nextState); | ||
| 46 | |||
| 47 | expect(queryClient.getQueryData(desktopUpdateQueryKeys.state())).toEqual(nextState); | ||
| 48 | }); | ||
| 49 | }); | ||
apps/web/src/lib/desktopUpdateReactQuery.ts created+42| ... | @@ -0,0 +1,42 @@ | ||
| 1 | import { queryOptions, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; | ||
| 2 | import { useEffect } from "react"; | ||
| 3 | import type { DesktopUpdateState } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | export const desktopUpdateQueryKeys = { | ||
| 6 | all: ["desktop", "update"] as const, | ||
| 7 | state: () => ["desktop", "update", "state"] as const, | ||
| 8 | }; | ||
| 9 | |||
| 10 | export const setDesktopUpdateStateQueryData = ( | ||
| 11 | queryClient: QueryClient, | ||
| 12 | state: DesktopUpdateState | null, | ||
| 13 | ) => queryClient.setQueryData(desktopUpdateQueryKeys.state(), state); | ||
| 14 | |||
| 15 | export function desktopUpdateStateQueryOptions() { | ||
| 16 | return queryOptions({ | ||
| 17 | queryKey: desktopUpdateQueryKeys.state(), | ||
| 18 | queryFn: async () => { | ||
| 19 | const bridge = window.desktopBridge; | ||
| 20 | if (!bridge || typeof bridge.getUpdateState !== "function") return null; | ||
| 21 | return bridge.getUpdateState(); | ||
| 22 | }, | ||
| 23 | staleTime: Infinity, | ||
| 24 | refetchOnMount: "always", | ||
| 25 | }); | ||
| 26 | } | ||
| 27 | |||
| 28 | export function useDesktopUpdateState() { | ||
| 29 | const queryClient = useQueryClient(); | ||
| 30 | const query = useQuery(desktopUpdateStateQueryOptions()); | ||
| 31 | |||
| 32 | useEffect(() => { | ||
| 33 | const bridge = window.desktopBridge; | ||
| 34 | if (!bridge || typeof bridge.onUpdateState !== "function") return; | ||
| 35 | |||
| 36 | return bridge.onUpdateState((nextState) => { | ||
| 37 | setDesktopUpdateStateQueryData(queryClient, nextState); | ||
| 38 | }); | ||
| 39 | }, [queryClient]); | ||
| 40 | |||
| 41 | return query; | ||
| 42 | } | ||
packages/contracts/src/ipc.ts+6| ... | @@ -101,6 +101,11 @@ export interface DesktopUpdateActionResult { | ... | @@ -101,6 +101,11 @@ export interface DesktopUpdateActionResult { |
| 101 | state: DesktopUpdateState; | 101 | state: DesktopUpdateState; |
| 102 | } | 102 | } |
| 103 | 103 | ||
| 104 | export interface DesktopUpdateCheckResult { | ||
| 105 | checked: boolean; | ||
| 106 | state: DesktopUpdateState; | ||
| 107 | } | ||
| 108 | |||
| 104 | export interface DesktopBridge { | 109 | export interface DesktopBridge { |
| 105 | getWsUrl: () => string | null; | 110 | getWsUrl: () => string | null; |
| 106 | pickFolder: () => Promise<string | null>; | 111 | pickFolder: () => Promise<string | null>; |
| ... | @@ -113,6 +118,7 @@ export interface DesktopBridge { | ... | @@ -113,6 +118,7 @@ export interface DesktopBridge { |
| 113 | openExternal: (url: string) => Promise<boolean>; | 118 | openExternal: (url: string) => Promise<boolean>; |
| 114 | onMenuAction: (listener: (action: string) => void) => () => void; | 119 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 115 | getUpdateState: () => Promise<DesktopUpdateState>; | 120 | getUpdateState: () => Promise<DesktopUpdateState>; |
| 121 | checkForUpdate: () => Promise<DesktopUpdateCheckResult>; | ||
| 116 | downloadUpdate: () => Promise<DesktopUpdateActionResult>; | 122 | downloadUpdate: () => Promise<DesktopUpdateActionResult>; |
| 117 | installUpdate: () => Promise<DesktopUpdateActionResult>; | 123 | installUpdate: () => Promise<DesktopUpdateActionResult>; |
| 118 | onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; | 124 | onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; |