Skip to content
Merged
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
24 changes: 23 additions & 1 deletion apps/desktop/electron/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import {
getBaseDir,
getWorkflowDir,
readAiBackendOverride,
readAiApiProfilesForUi,
readMobileAccessEnabled,
saveAiBackendOverride,
saveAiApiProfile,
deleteAiApiProfile,
saveMobileAccessEnabled,
} from "../../../packages/core-models/config";
import {
Expand Down Expand Up @@ -56,13 +59,18 @@ export async function getWorkflowConfig() {
const workflow = getWorkflow();
const mobileAccessEnabled = await readMobileAccessEnabled();
const aiBackendOverride = await readAiBackendOverride();
const aiApiProfiles = await readAiApiProfilesForUi();
if (!workflow) {
return buildEmptyWorkflowConfig(mobileAccessEnabled, aiBackendOverride);
return {
...buildEmptyWorkflowConfig(mobileAccessEnabled, aiBackendOverride),
aiApiProfiles,
};
}
return {
...getWorkflowConfigShape(workflow),
mobileAccessEnabled,
aiBackendOverride,
aiApiProfiles,
};
}

Expand All @@ -76,6 +84,20 @@ export async function setAiBackendOverride(backend) {
return getWorkflowConfig();
}

export async function listAiApiProfiles() {
return { profiles: await readAiApiProfilesForUi() };
}

export async function saveAiApi(profile) {
const profiles = await saveAiApiProfile(profile);
return { profiles };
}

export async function deleteAiApi(id) {
const profiles = await deleteAiApiProfile(id);
return { profiles };
}

export async function listWorkflows() {
const workflowDir = await ensureWorkflowDir();
try {
Expand Down
102 changes: 75 additions & 27 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import { mkdirSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { killAllChildren } from "../../../packages/core-lib/claude";
import { setRuntimeBaseDir, setRuntimeStorageDir } from "../../../packages/core-models/config";
import { getDefaultDesktopUserDataDir, setRuntimeBaseDir, setRuntimeStorageDir } from "../../../packages/core-models/config";
import {
pickFolder,
getWorkflowConfig,
setMobileAccessEnabled,
setAiBackendOverride,
listAiApiProfiles,
saveAiApi,
deleteAiApi,
listWorkflows,
getWorkflowByFilename,
setWorkflowVisible,
Expand Down Expand Up @@ -38,20 +41,25 @@ import {
approveWorkflow,
rejectWorkflow,
sendWorkflowMessage,
restartWorkflowPhase,
resumeWorkflowPhase,
retryWorkflowPhase,
pauseWorkflowPhase,
detachWorkflowSender,
} from "./workflow-runtime";

let mainWindow;
let mainWindow: BrowserWindow | null = null;
const __dirname = dirname(fileURLToPath(import.meta.url));
const rendererDevServerUrl = process.env.ELECTRON_RENDERER_URL;
const isDev = Boolean(rendererDevServerUrl);
const userDataDirOverride = process.env.DEV_WORKFLOW_USER_DATA_DIR;
const userDataDir = getDefaultDesktopUserDataDir();

if (userDataDirOverride) {
mkdirSync(userDataDirOverride, { recursive: true });
app.setPath("userData", userDataDirOverride);
mkdirSync(userDataDir, { recursive: true });
app.setPath("userData", userDataDir);

const gotSingleInstanceLock = app.requestSingleInstanceLock();

if (!gotSingleInstanceLock) {
app.quit();
}

function spawnDetached(command, args) {
Expand All @@ -68,17 +76,39 @@ function spawnDetached(command, args) {
});
}

async function openInCode(targetPath) {
const EDITOR_OPENERS = {
code: {
command: "code",
args: (targetPath) => [targetPath],
darwinApp: "Visual Studio Code",
label: "VS Code",
},
sublime: {
command: "subl",
args: (targetPath) => [targetPath],
darwinApp: "Sublime Text",
label: "Sublime Text",
},
zed: {
command: "zed",
args: (targetPath) => [targetPath],
darwinApp: "Zed",
label: "Zed",
},
};

async function openInEditor(targetPath, editor = "code") {
if (!targetPath) throw new Error("path required");
const opener = EDITOR_OPENERS[editor] || EDITOR_OPENERS.code;
try {
await spawnDetached("code", [targetPath]);
await spawnDetached(opener.command, opener.args(targetPath));
return { ok: true };
} catch (error) {
if (process.platform === "darwin") {
await spawnDetached("open", ["-a", "Visual Studio Code", targetPath]);
await spawnDetached("open", ["-a", opener.darwinApp, targetPath]);
return { ok: true };
}
throw new Error(error?.message || "failed to open VS Code");
throw new Error(error?.message || `failed to open ${opener.label}`);
}
}

Expand Down Expand Up @@ -132,13 +162,20 @@ async function createWindow() {
} else {
await mainWindow.loadFile(join(__dirname, "..", "renderer", "dist", "index.html"));
}

mainWindow.on("closed", () => {
mainWindow = null;
});
}

function registerIpcHandlers() {
ipcMain.handle("app:pick-folder", (event) => pickFolder(BrowserWindow.fromWebContents(event.sender)));
ipcMain.handle("app:get-workflow-config", () => getWorkflowConfig());
ipcMain.handle("app:set-mobile-access-enabled", (_event, enabled) => setMobileAccessEnabled(enabled));
ipcMain.handle("app:set-ai-backend-override", (_event, backend) => setAiBackendOverride(backend));
ipcMain.handle("app:list-ai-api-profiles", () => listAiApiProfiles());
ipcMain.handle("app:save-ai-api-profile", (_event, profile) => saveAiApi(profile));
ipcMain.handle("app:delete-ai-api-profile", (_event, id) => deleteAiApi(id));
ipcMain.handle("app:list-workflows", () => listWorkflows());
ipcMain.handle("app:get-workflow", (_event, filename) => getWorkflowByFilename(filename));
ipcMain.handle("app:set-workflow-visible", (_event, filename, visible) => setWorkflowVisible(filename, visible));
Expand All @@ -157,9 +194,9 @@ function registerIpcHandlers() {
ipcMain.handle("app:add-workfolder", (_event, path) => addWorkFolder(path));
ipcMain.handle("app:remove-workfolder", (_event, path) => removeWorkFolder(path));
ipcMain.handle("app:get-task-state", (_event, taskId, runId) => getTaskState(taskId, runId));
ipcMain.handle("app:open-in-code", (_event, targetPath) => openInCode(targetPath));
ipcMain.handle("app:open-in-code", (_event, targetPath, editor) => openInEditor(targetPath, editor));
ipcMain.handle("app:open-task-output-in-code", async (_event, taskId, runId, phaseId, outputKey) =>
openInCode(await getTaskOutputPath(taskId, runId, phaseId, outputKey))
openInEditor(await getTaskOutputPath(taskId, runId, phaseId, outputKey))
);
ipcMain.handle("app:remove-task", (_event, taskId, runId, options) => removeTask(taskId, runId, options));
ipcMain.handle("app:remove-task-worktree", (_event, taskId, runId) => removeTaskWorktreeOnly(taskId, runId));
Expand All @@ -184,8 +221,11 @@ function registerIpcHandlers() {
ipcMain.handle("app:send-workflow-message", (event, taskId, text, images, runId) =>
sendWorkflowMessage(taskId, text, images, (message) => event.sender.send("workflow:event", message), runId)
);
ipcMain.handle("app:restart-workflow-phase", (event, taskId, phase, runId) =>
restartWorkflowPhase(taskId, phase, (message) => event.sender.send("workflow:event", message), runId)
ipcMain.handle("app:resume-workflow-phase", (event, taskId, phase, runId) =>
resumeWorkflowPhase(taskId, phase, (message) => event.sender.send("workflow:event", message), runId)
);
ipcMain.handle("app:retry-workflow-phase", (event, taskId, phase, runId) =>
retryWorkflowPhase(taskId, phase, (message) => event.sender.send("workflow:event", message), runId)
);
ipcMain.handle("app:pause-workflow-phase", (event, taskId, phase, runId) =>
pauseWorkflowPhase(taskId, phase, (message) => event.sender.send("workflow:event", message), runId)
Expand All @@ -195,18 +235,26 @@ function registerIpcHandlers() {
});
}

app.whenReady().then(async () => {
const userDataDir = app.getPath("userData");
setRuntimeStorageDir(userDataDir);
setRuntimeBaseDir(join(userDataDir, "tasks"));
registerIpcHandlers();
try {
await createWindow();
} catch (err) {
console.error(err);
app.quit();
}
});
if (gotSingleInstanceLock) {
app.on("second-instance", () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
});

app.whenReady().then(async () => {
const userDataDir = app.getPath("userData");
setRuntimeStorageDir(userDataDir);
setRuntimeBaseDir(join(userDataDir, "tasks"));
registerIpcHandlers();
try {
await createWindow();
} catch (err) {
console.error(err);
app.quit();
}
});
}

app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/electron/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ contextBridge.exposeInMainWorld("desktopApi", {
getWorkflowConfig: () => ipcRenderer.invoke("app:get-workflow-config"),
setMobileAccessEnabled: (enabled) => ipcRenderer.invoke("app:set-mobile-access-enabled", enabled),
setAiBackendOverride: (backend) => ipcRenderer.invoke("app:set-ai-backend-override", backend),
listAiApiProfiles: () => ipcRenderer.invoke("app:list-ai-api-profiles"),
saveAiApiProfile: (profile) => ipcRenderer.invoke("app:save-ai-api-profile", profile),
deleteAiApiProfile: (id) => ipcRenderer.invoke("app:delete-ai-api-profile", id),
listWorkflows: () => ipcRenderer.invoke("app:list-workflows"),
getWorkflow: (filename) => ipcRenderer.invoke("app:get-workflow", filename),
setWorkflowVisible: (filename, visible) => ipcRenderer.invoke("app:set-workflow-visible", filename, visible),
Expand All @@ -29,7 +32,7 @@ contextBridge.exposeInMainWorld("desktopApi", {
addWorkFolder: (path) => ipcRenderer.invoke("app:add-workfolder", path),
removeWorkFolder: (path) => ipcRenderer.invoke("app:remove-workfolder", path),
getTaskState: (taskId, runId) => ipcRenderer.invoke("app:get-task-state", taskId, runId),
openInCode: (targetPath) => ipcRenderer.invoke("app:open-in-code", targetPath),
openInCode: (targetPath, editor) => ipcRenderer.invoke("app:open-in-code", targetPath, editor),
openTaskOutputInCode: (taskId, runId, phaseId, outputKey) => ipcRenderer.invoke("app:open-task-output-in-code", taskId, runId, phaseId, outputKey),
removeTask: (taskId, runId, options) => ipcRenderer.invoke("app:remove-task", taskId, runId, options),
removeTaskWorktree: (taskId, runId) => ipcRenderer.invoke("app:remove-task-worktree", taskId, runId),
Expand All @@ -38,7 +41,8 @@ contextBridge.exposeInMainWorld("desktopApi", {
approveWorkflow: (taskId, runId) => ipcRenderer.invoke("app:approve-workflow", taskId, runId),
rejectWorkflow: (taskId, rejectTo, reason, runId) => ipcRenderer.invoke("app:reject-workflow", taskId, rejectTo, reason, runId),
sendWorkflowMessage: (taskId, text, images, runId) => ipcRenderer.invoke("app:send-workflow-message", taskId, text, images, runId),
restartWorkflowPhase: (taskId, phase, runId) => ipcRenderer.invoke("app:restart-workflow-phase", taskId, phase, runId),
resumeWorkflowPhase: (taskId, phase, runId) => ipcRenderer.invoke("app:resume-workflow-phase", taskId, phase, runId),
retryWorkflowPhase: (taskId, phase, runId) => ipcRenderer.invoke("app:retry-workflow-phase", taskId, phase, runId),
pauseWorkflowPhase: (taskId, phase, runId) => ipcRenderer.invoke("app:pause-workflow-phase", taskId, phase, runId),
onWorkflowEvent: subscribeWorkflowEvents,
detachWorkflow: (taskId, runId) => ipcRenderer.send("workflow:detach", taskId, runId),
Expand Down
Loading
Loading