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
12 changes: 12 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
"electron-updater": "^6.8.3",
"lucide-react": "^0.563.0",
"motion": "^12.35.1",
"nitro": "^3.0.1-alpha.2",
Expand All @@ -58,6 +59,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vite-tsconfig-paths": "^6.1.1",
Expand Down Expand Up @@ -115,18 +117,19 @@
"mac": {
"category": "public.app-category.developer-tools",
"target": [
"dmg",
"zip"
]
},
"linux": {
"category": "Development",
"target": [
"zip"
"AppImage"
]
},
"win": {
"target": [
"zip"
"nsis"
]
},
"publish": [
Expand Down
171 changes: 171 additions & 0 deletions packages/desktop-shell/src/app-updater.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { app, BrowserWindow } from "electron";
import { autoUpdater } from "electron-updater";

const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
const UPDATE_STATE_CHANNEL = "app-updater:state-changed";

export type AppUpdateState = {
availableVersion: string | null;
currentVersion: string;
status:
| "checking"
| "downloaded"
| "downloading"
| "error"
| "idle"
| "unavailable"
| "up-to-date"
| "update-available";
};

type AppUpdaterController = {
getState: () => AppUpdateState;
quitAndInstall: () => void;
start: () => void;
stop: () => void;
};

export function createAppUpdaterController(): AppUpdaterController {
let checkForUpdatesPromise: Promise<AppUpdateState> | null = null;
let hasStarted = false;
let intervalId: NodeJS.Timeout | null = null;
let state: AppUpdateState = {
availableVersion: null,
currentVersion: app.getVersion(),
status: app.isPackaged ? "idle" : "unavailable",
};

autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.allowPrerelease = app.getVersion().includes("-");

autoUpdater.on("checking-for-update", () => {
updateState({
status: "checking",
});
});

autoUpdater.on("update-available", (info) => {
updateState({
availableVersion: info.version,
status: "update-available",
});
});

autoUpdater.on("download-progress", () => {
updateState({
status: "downloading",
});
});

autoUpdater.on("update-downloaded", (info) => {
updateState({
availableVersion: info.version,
status: "downloaded",
});
});

autoUpdater.on("update-not-available", () => {
updateState({
availableVersion: null,
status: "up-to-date",
});
});

autoUpdater.on("error", () => {
updateState({
status: "error",
});
});

function start(): void {
if (hasStarted) {
return;
}

hasStarted = true;

if (!app.isPackaged) {
broadcastState();
return;
}

void checkForUpdates();
intervalId = setInterval(() => {
void checkForUpdates();
}, UPDATE_CHECK_INTERVAL_MS);
}

async function checkForUpdates(): Promise<AppUpdateState> {
if (!app.isPackaged) {
updateState({
status: "unavailable",
});
return state;
}

if (checkForUpdatesPromise) {
return await checkForUpdatesPromise;
}

checkForUpdatesPromise = autoUpdater
.checkForUpdates()
.then(() => state)
.catch(() => {
updateState({
status: "error",
});
return state;
})
.finally(() => {
checkForUpdatesPromise = null;
});

return await checkForUpdatesPromise;
}

function quitAndInstall(): void {
if (state.status !== "downloaded") {
throw new Error("No downloaded update is ready to install.");
}

autoUpdater.quitAndInstall();
}

function getState(): AppUpdateState {
return state;
}

function stop(): void {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
}

function updateState(nextPartialState: Partial<AppUpdateState>): void {
state = {
...state,
...nextPartialState,
currentVersion: app.getVersion(),
};
broadcastState();
}

function broadcastState(): void {
for (const window of BrowserWindow.getAllWindows()) {
if (window.isDestroyed()) {
continue;
}

window.webContents.send(UPDATE_STATE_CHANNEL, state);
}
}

return {
getState,
quitAndInstall,
start,
stop,
};
}
17 changes: 16 additions & 1 deletion packages/desktop-shell/src/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { app, BrowserWindow, ipcMain, shell } from "electron";
import { createAppServerController } from "./app-server.mjs";
import { createAppUpdaterController } from "./app-updater.mjs";
import { createDesktopRunnerController } from "./desktop-runner.mjs";

const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
let appServerController: ReturnType<typeof createAppServerController> | null = null;
let desktopRunnerController: ReturnType<typeof createDesktopRunnerController> | null = null;
const appUpdaterController = createAppUpdaterController();

let isQuitting = false;
let isInstallingUpdate = false;

function resolveWorkspaceRoot(): string {
if (app.isPackaged) {
Expand Down Expand Up @@ -60,6 +63,16 @@ function registerIpcHandlers(): void {
ipcMain.handle("desktop-runner:prompt-task", async (_event, args) => {
return await getDesktopRunnerController().promptRunnerTask(args);
});

ipcMain.handle("app-updater:get-state", () => {
return appUpdaterController.getState();
});

ipcMain.handle("app-updater:quit-and-install", async () => {
isInstallingUpdate = true;
await disposeControllers();
appUpdaterController.quitAndInstall();
});
}

function isExternalUrl(targetUrl: string, appUrl: string): boolean {
Expand Down Expand Up @@ -151,6 +164,7 @@ async function createMainWindow(): Promise<BrowserWindow> {
}

async function disposeControllers(): Promise<void> {
appUpdaterController.stop();
await Promise.allSettled([appServerController?.stop(), desktopRunnerController?.stop()]);
}

Expand All @@ -159,6 +173,7 @@ registerIpcHandlers();
app
.whenReady()
.then(async () => {
appUpdaterController.start();
await createMainWindow();

app.on("activate", async () => {
Expand All @@ -181,7 +196,7 @@ app.on("window-all-closed", () => {
});

app.on("before-quit", (event) => {
if (isQuitting) {
if (isQuitting || isInstallingUpdate) {
return;
}

Expand Down
16 changes: 16 additions & 0 deletions packages/desktop-shell/src/preload.mts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,22 @@ contextBridge.exposeInMainWorld("clankiDesktop", {
deleteRunnerWorkspace(workspaceDirectory: string) {
return ipcRenderer.invoke("desktop-runner:delete-workspace", { workspaceDirectory });
},
getAppUpdateState() {
return ipcRenderer.invoke("app-updater:get-state");
},
listRunnerModels(args: { directory: string }) {
return ipcRenderer.invoke("desktop-runner:list-models", args);
},
onAppUpdateStateChange(listener: (state: unknown) => void) {
const wrappedListener = (_event: unknown, state: unknown) => {
listener(state);
};

ipcRenderer.on("app-updater:state-changed", wrappedListener);
return () => {
ipcRenderer.off("app-updater:state-changed", wrappedListener);
};
},
openWorkspaceInEditor(args: { editor: "cursor" | "vscode" | "zed"; workspaceDirectory: string }) {
return ipcRenderer.invoke("desktop-runner:open-workspace-in-editor", args);
},
Expand All @@ -25,4 +38,7 @@ contextBridge.exposeInMainWorld("clankiDesktop", {
}) {
return ipcRenderer.invoke("desktop-runner:prompt-task", args);
},
quitAndInstallAppUpdate() {
return ipcRenderer.invoke("app-updater:quit-and-install");
},
});
Loading