Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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 the available version when an update is available", () => {
expect(
getCheckForUpdateButtonLabel({
...baseState,
status: "available",
availableVersion: "1.2.0",
}),
).toBe("Update Available: 1.2.0");
});

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

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

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 `Update Available: ${state.availableVersion ?? ""}`;
if (state.status === "downloading") return "Downloading…";
if (state.status === "downloaded") return "Update Ready to Install";
return "Check for Updates";
}
114 changes: 105 additions & 9 deletions apps/web/src/routes/_chat.settings.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { type ProviderKind } from "@t3tools/contracts";
import { useCallback, useEffect, useState } from "react";
import { type ProviderKind, type DesktopUpdateState } from "@t3tools/contracts";
import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
import { resolveAndPersistPreferredEditor } from "../editorPreferences";
Expand All @@ -20,6 +20,7 @@ import {
} from "../components/ui/select";
import { Switch } from "../components/ui/switch";
import { APP_VERSION } from "../branding";
import { canCheckForUpdate, getCheckForUpdateButtonLabel } from "../components/desktopUpdate.logic";
import { SidebarInset } from "~/components/ui/sidebar";

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

function DesktopUpdateCheckSection() {
const [updateState, setUpdateState] = useState<DesktopUpdateState | null>(null);
const [checkError, setCheckError] = useState<string | null>(null);

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

let disposed = false;
let receivedSubscriptionUpdate = false;
const unsubscribe = bridge.onUpdateState((nextState) => {
if (disposed) return;
receivedSubscriptionUpdate = true;
setUpdateState(nextState);
});

void bridge
.getUpdateState()
.then((nextState) => {
if (disposed || receivedSubscriptionUpdate) return;
setUpdateState(nextState);
})
.catch(() => undefined);

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

const handleCheckForUpdate = useCallback(() => {
const bridge = window.desktopBridge;
if (!bridge || typeof bridge.checkForUpdate !== "function") return;
setCheckError(null);

void bridge
.checkForUpdate()
.then((result) => {
setUpdateState(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.");
});
}, []);

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={handleCheckForUpdate}
>
{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 +766,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