Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c693342
feat(desktop): add manual check-for-updates from web settings UI
AriajSarkar Mar 15, 2026
1c05006
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 15, 2026
def0d17
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 16, 2026
f57619d
refactor: use useQuery for update state instead of manual useEffect
AriajSarkar Mar 16, 2026
d50e306
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 16, 2026
e840966
refactor: use queryOptions pattern with real-time sync and multi-purp…
AriajSarkar Mar 16, 2026
7c4ac99
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 17, 2026
9875ec0
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 17, 2026
945774b
feat(web): add tooltip to update button in settings
AriajSarkar Mar 17, 2026
8d2480a
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 17, 2026
69e9ac7
Merge remote-tracking branch 'upstream/main' into feat/manual-update-…
AriajSarkar Mar 18, 2026
0f4cc76
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 18, 2026
3dd5250
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 19, 2026
4e5586b
Merge remote-tracking branch 'upstream/main' into feat/manual-update-…
AriajSarkar Mar 21, 2026
b2ce524
feat(desktop-update): improve update visibility and add restart confi…
AriajSarkar Mar 21, 2026
837ccff
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 23, 2026
29f1484
Merge upstream/main into feat/manual-update-check - resolve settings …
AriajSarkar Mar 24, 2026
a146f06
fix(settings): remove redundant getCheckForUpdateButtonLabel, use act…
AriajSarkar Mar 24, 2026
5d49448
Merge branch 'main' into feat/manual-update-check
AriajSarkar Mar 24, 2026
b216503
fix: tooltip fallback returns 'Up to date' for idle/up-to-date states…
AriajSarkar Mar 24, 2026
789f91f
Merge upstream/main into feat/manual-update-check - resolve conflicts
AriajSarkar Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import * as Effect from "effect/Effect";
import type {
DesktopTheme,
DesktopUpdateActionResult,
DesktopUpdateCheckResult,
DesktopUpdateState,
} from "@t3tools/contracts";
import { autoUpdater } from "electron-updater";
Expand Down Expand Up @@ -56,6 +57,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";
const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
const UPDATE_CHECK_CHANNEL = "desktop:update-check";
const STATE_DIR =
process.env.T3CODE_STATE_DIR?.trim() || Path.join(OS.homedir(), ".t3", "userdata");
const DESKTOP_SCHEME = "t3";
Expand Down Expand Up @@ -1211,6 +1213,21 @@ function registerIpcHandlers(): void {
state: updateState,
} satisfies DesktopUpdateActionResult;
});

ipcMain.removeHandler(UPDATE_CHECK_CHANNEL);
ipcMain.handle(UPDATE_CHECK_CHANNEL, async () => {
if (!updaterConfigured) {
return {
checked: false,
state: updateState,
} satisfies DesktopUpdateCheckResult;
}
await checkForUpdates("web-ui");
return {
checked: true,
state: updateState,
} satisfies DesktopUpdateCheckResult;
Comment thread
cursor[bot] marked this conversation as resolved.
});
}

function getIconOption(): { icon: string } | Record<string, never> {
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
const MENU_ACTION_CHANNEL = "desktop:menu-action";
const UPDATE_STATE_CHANNEL = "desktop:update-state";
const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
const UPDATE_CHECK_CHANNEL = "desktop:update-check";
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
Expand All @@ -32,6 +33,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
};
},
getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),
checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),
downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL),
installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL),
onUpdateState: (listener) => {
Expand Down
101 changes: 101 additions & 0 deletions apps/web/src/components/desktopUpdate.logic.test.ts
Comment thread
AriajSarkar marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest";
import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";

import {
canCheckForUpdate,
getArm64IntelBuildWarningDescription,
getCheckForUpdateButtonLabel,
getDesktopUpdateActionError,
getDesktopUpdateButtonTooltip,
isDesktopUpdateButtonDisabled,
Expand Down Expand Up @@ -207,3 +209,102 @@ describe("desktop update UI helpers", () => {
expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update");
});
});

describe("canCheckForUpdate", () => {
it("returns false for null state", () => {
expect(canCheckForUpdate(null)).toBe(false);
});

it("returns false when updates are disabled", () => {
expect(canCheckForUpdate({ ...baseState, enabled: false, status: "disabled" })).toBe(false);
});

it("returns false while checking", () => {
expect(canCheckForUpdate({ ...baseState, status: "checking" })).toBe(false);
});

it("returns false while downloading", () => {
expect(canCheckForUpdate({ ...baseState, status: "downloading", downloadPercent: 50 })).toBe(
false,
);
});

it("returns true when idle", () => {
expect(canCheckForUpdate({ ...baseState, status: "idle" })).toBe(true);
});

it("returns true when up-to-date", () => {
expect(canCheckForUpdate({ ...baseState, status: "up-to-date" })).toBe(true);
});

it("returns true when an update is available", () => {
expect(
canCheckForUpdate({ ...baseState, status: "available", availableVersion: "1.1.0" }),
).toBe(true);
});

it("returns true on error so the user can retry", () => {
expect(
canCheckForUpdate({
...baseState,
status: "error",
errorContext: "check",
message: "network",
}),
).toBe(true);
});
});

describe("getCheckForUpdateButtonLabel", () => {
it("returns the default label for null state", () => {
expect(getCheckForUpdateButtonLabel(null)).toBe("Check for Updates");
});

it("returns 'Checking…' while checking", () => {
expect(getCheckForUpdateButtonLabel({ ...baseState, status: "checking" })).toBe("Checking…");
});

it("returns 'Up to Date' when up-to-date", () => {
expect(getCheckForUpdateButtonLabel({ ...baseState, status: "up-to-date" })).toBe("Up to Date");
});

it("returns 'Download Update' when an update is available", () => {
expect(
getCheckForUpdateButtonLabel({
...baseState,
status: "available",
availableVersion: "1.2.0",
}),
).toBe("Download Update");
});

it("returns 'Downloading…' while downloading", () => {
expect(
getCheckForUpdateButtonLabel({ ...baseState, status: "downloading", downloadPercent: 30 }),
).toBe("Downloading…");
});

it("returns 'Install Update' when downloaded", () => {
expect(
getCheckForUpdateButtonLabel({
...baseState,
status: "downloaded",
downloadedVersion: "1.2.0",
}),
).toBe("Install Update");
});

it("returns the default label for idle and error states", () => {
expect(getCheckForUpdateButtonLabel({ ...baseState, status: "idle" })).toBe(
"Check for Updates",
);
expect(
getCheckForUpdateButtonLabel({
...baseState,
status: "error",
errorContext: "check",
message: "fail",
}),
).toBe("Check for Updates");
});
});
17 changes: 17 additions & 0 deletions apps/web/src/components/desktopUpdate.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,20 @@ export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | nu
if (!state || state.status !== "error") return false;
return state.errorContext === "download" || state.errorContext === "install";
}

Comment thread
AriajSarkar marked this conversation as resolved.
export function canCheckForUpdate(state: DesktopUpdateState | null): boolean {
if (!state || !state.enabled) return false;
return (
state.status !== "checking" && state.status !== "downloading" && state.status !== "disabled"
);
}

export function getCheckForUpdateButtonLabel(state: DesktopUpdateState | null): string {
if (!state) return "Check for Updates";
if (state.status === "checking") return "Checking…";
if (state.status === "up-to-date") return "Up to Date";
if (state.status === "available") return "Download Update";
if (state.status === "downloading") return "Downloading…";
if (state.status === "downloaded") return "Install Update";
return "Check for Updates";
}
Comment thread
AriajSarkar marked this conversation as resolved.
Outdated
18 changes: 18 additions & 0 deletions apps/web/src/lib/desktopUpdateReactQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { queryOptions } from "@tanstack/react-query";

export const desktopUpdateQueryKeys = {
all: ["desktop", "update"] as const,
state: () => ["desktop", "update", "state"] as const,
};

export function desktopUpdateStateQueryOptions() {
return queryOptions({
queryKey: desktopUpdateQueryKeys.state(),
queryFn: async () => {
const bridge = window.desktopBridge;
if (!bridge || typeof bridge.getUpdateState !== "function") return null;
return bridge.getUpdateState();
},
staleTime: Infinity,
});
}
127 changes: 118 additions & 9 deletions apps/web/src/routes/_chat.settings.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";
import { type ProviderKind } from "@t3tools/contracts";
import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
Expand All @@ -20,6 +20,12 @@ import {
} from "../components/ui/select";
import { Switch } from "../components/ui/switch";
import { APP_VERSION } from "../branding";
import {
canCheckForUpdate,
getCheckForUpdateButtonLabel,
resolveDesktopUpdateButtonAction,
} from "../components/desktopUpdate.logic";
import { desktopUpdateStateQueryOptions } from "../lib/desktopUpdateReactQuery";
import { SidebarInset } from "~/components/ui/sidebar";

const THEME_OPTIONS = [
Expand Down Expand Up @@ -92,6 +98,105 @@ function patchCustomModels(provider: ProviderKind, models: string[]) {
}
}

function DesktopUpdateCheckSection() {
const queryClient = useQueryClient();
const updateStateQuery = useQuery(desktopUpdateStateQueryOptions());
const [checkError, setCheckError] = useState<string | null>(null);

const updateState = updateStateQuery.data ?? null;

useEffect(() => {
const bridge = window.desktopBridge;
if (!bridge || typeof bridge.onUpdateState !== "function") return;

const opts = desktopUpdateStateQueryOptions();
const unsubscribe = bridge.onUpdateState((nextState) => {
queryClient.setQueryData(opts.queryKey, nextState);
});

return () => {
unsubscribe();
};
}, [queryClient]);

Comment thread
AriajSarkar marked this conversation as resolved.
const handleButtonClick = useCallback(() => {
const bridge = window.desktopBridge;
if (!bridge) return;
setCheckError(null);

const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none";
const opts = desktopUpdateStateQueryOptions();

if (action === "download") {
void bridge
.downloadUpdate()
.then((result) => {
queryClient.setQueryData(opts.queryKey, result.state);
})
.catch((error: unknown) => {
setCheckError(error instanceof Error ? error.message : "Download failed.");
});
return;
}

if (action === "install") {
void bridge
.installUpdate()
.then((result) => {
queryClient.setQueryData(opts.queryKey, result.state);
})
.catch((error: unknown) => {
setCheckError(error instanceof Error ? error.message : "Install failed.");
});
return;
}

if (typeof bridge.checkForUpdate !== "function") return;
void bridge
.checkForUpdate()
.then((result) => {
queryClient.setQueryData(opts.queryKey, result.state);
if (!result.checked) {
setCheckError(
result.state.message ?? "Automatic updates are not available in this build.",
);
}
})
.catch((error: unknown) => {
setCheckError(error instanceof Error ? error.message : "Update check failed.");
});
}, [queryClient, updateState]);

const buttonLabel = getCheckForUpdateButtonLabel(updateState);
const buttonDisabled = !canCheckForUpdate(updateState);

return (
<div className="space-y-2">
<div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
<div>
<p className="text-sm font-medium text-foreground">Updates</p>
<p className="text-xs text-muted-foreground">
{updateState?.checkedAt
? `Last checked: ${new Date(updateState.checkedAt).toLocaleString()}`
: "Check for available updates."}
</p>
</div>
<Button size="xs" variant="outline" disabled={buttonDisabled} onClick={handleButtonClick}>
{buttonLabel}
</Button>
Comment thread
AriajSarkar marked this conversation as resolved.
Outdated
</div>

{checkError ? <p className="text-xs text-destructive">{checkError}</p> : null}

{updateState?.status === "error" && updateState.errorContext === "check" ? (
<p className="text-xs text-destructive">
{updateState.message ?? "Could not check for updates."}
</p>
) : null}
</div>
);
}

function SettingsRouteView() {
const { theme, setTheme, resolvedTheme } = useTheme();
const { settings, defaults, updateSettings } = useAppSettings();
Expand Down Expand Up @@ -674,14 +779,18 @@ function SettingsRouteView() {
</p>
</div>

<div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
<div>
<p className="text-sm font-medium text-foreground">Version</p>
<p className="text-xs text-muted-foreground">
Current version of the application.
</p>
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
<div>
<p className="text-sm font-medium text-foreground">Version</p>
<p className="text-xs text-muted-foreground">
Current version of the application.
</p>
</div>
<code className="text-xs font-medium text-muted-foreground">{APP_VERSION}</code>
</div>
<code className="text-xs font-medium text-muted-foreground">{APP_VERSION}</code>

{isElectron ? <DesktopUpdateCheckSection /> : null}
</div>
</section>
</div>
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ export interface DesktopUpdateActionResult {
state: DesktopUpdateState;
}

export interface DesktopUpdateCheckResult {
checked: boolean;
state: DesktopUpdateState;
}

export interface DesktopBridge {
getWsUrl: () => string | null;
pickFolder: () => Promise<string | null>;
Expand All @@ -106,6 +111,7 @@ export interface DesktopBridge {
openExternal: (url: string) => Promise<boolean>;
onMenuAction: (listener: (action: string) => void) => () => void;
getUpdateState: () => Promise<DesktopUpdateState>;
checkForUpdate: () => Promise<DesktopUpdateCheckResult>;
downloadUpdate: () => Promise<DesktopUpdateActionResult>;
installUpdate: () => Promise<DesktopUpdateActionResult>;
onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void;
Expand Down