Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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;
});
}

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
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";
}

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";
}
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]);

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>
</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