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
4 changes: 2 additions & 2 deletions apps/desktop-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,17 @@
"@vitejs/plugin-react": "^5.0.4",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^38.2.2",
"electron": "^39.0.0",
"electron-builder": "^26.0.12",
"rimraf": "^6.0.1",
"ts-node": "^10.9.2",
"typescript": "^5.6.3",
"vite": "^7.1.7"
},
"dependencies": {
"@bros2/runner": "workspace:*",
"@bros2/runtime": "workspace:*",
"@bros2/shared": "workspace:*",
"@bros2/runner": "workspace:*",
"@bros2/ui": "workspace:*",
"@bros2/validation": "workspace:*",
"bootstrap": "^5.3.8",
Expand Down
125 changes: 125 additions & 0 deletions apps/desktop-app/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import * as electron from "electron"; // ✅ NOTE: namespace import
import type { BrowserWindow } from "electron";
import express from "express";
Expand All @@ -8,6 +9,7 @@ import dotenv from "dotenv";
import type { BlockGraph } from "@bros2/ui";
import type { IR } from "@bros2/shared";
import type { Runner as RunnerInstance } from "@bros2/runner";
import type { WorkspaceDocument, WorkspaceSummary } from "./shared/workspace";

const { app, ipcMain, shell, BrowserWindow: BrowserWindowCtor } = electron;

Expand All @@ -20,6 +22,46 @@ let runner: RunnerInstance | null = null;
let runnerProjectKey: string | null = null;
let mainWindow: BrowserWindow | null = null;

let workspaceRoot: string | null = null;

function resolveWorkspaceRoot(): string {
if (workspaceRoot) return workspaceRoot;

const candidates = [
path.join(app.getPath("documents"), "BROS2", "workspaces"),
path.join(app.getPath("userData"), "workspaces"),
];

for (const candidate of candidates) {
try {
fs.mkdirSync(candidate, { recursive: true });
workspaceRoot = candidate;
if (candidate !== candidates[0]) {
console.warn(
`[workspace] Falling back to userData directory: ${candidate}. Documents directory was not accessible.`
);
}
return workspaceRoot;
} catch (err: any) {
if (err?.code === "EACCES" || err?.code === "EPERM") {
console.warn(
`[workspace] Cannot access ${candidate} (permission denied). Trying next fallback.`
);
continue;
}
throw err;
}
}

throw new Error(
"Unable to create workspace directory. Please check filesystem permissions."
);
}

function workspaceFilePath(id: string) {
return path.join(resolveWorkspaceRoot(), `${id}.json`);
}

dotenv.config();

// Lazy require: keep startup fast and avoid hard deps in dev
Expand Down Expand Up @@ -121,6 +163,87 @@ ipcMain.handle("ir:validate", async (_event, irData: IR) => {
return validateIrFn(irData);
});

// --- IPC: Workspace storage ---
ipcMain.handle("workspace:list", async () => {
const dir = resolveWorkspaceRoot();
try {
const entries = await fileSystem.readdir(dir);
const summaries: WorkspaceSummary[] = [];
for (const fileName of entries) {
if (!fileName.endsWith(".json")) continue;
const raw = await fileSystem.readFile(path.join(dir, fileName), "utf-8");
try {
const doc = JSON.parse(raw) as WorkspaceDocument;
summaries.push({
id: doc.id,
name: doc.name,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
});
} catch (err) {
console.warn(`[workspace] Failed to parse ${fileName}:`, err);
}
}
summaries.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
return summaries;
} catch (err) {
console.error("[workspace:list] failed:", err);
throw err;
}
});

ipcMain.handle(
"workspace:create",
async (
_event,
payload: { name?: string; template?: Partial<WorkspaceDocument> | null; meta?: WorkspaceDocument["meta"] } = {}
) => {
const dir = resolveWorkspaceRoot();
const now = new Date().toISOString();
const id = randomUUID();

const template = payload?.template ?? null;

const baseDoc: WorkspaceDocument = {
id,
name: payload?.name?.trim() || template?.name?.trim() || "Untitled Workspace",
createdAt: now,
updatedAt: now,
nodes: template?.nodes ?? [],
meta: template?.meta ?? payload?.meta ?? undefined,
};

const filePath = workspaceFilePath(id);
await fileSystem.writeFile(filePath, JSON.stringify(baseDoc, null, 2), "utf-8");
return baseDoc;
}
);

ipcMain.handle("workspace:load", async (_event, id: string) => {
if (!id) throw new Error("workspace:load requires an id");
const filePath = workspaceFilePath(id);
const raw = await fileSystem.readFile(filePath, "utf-8");
return JSON.parse(raw) as WorkspaceDocument;
});

ipcMain.handle(
"workspace:save",
async (_event, payload: { id: string; data: WorkspaceDocument }) => {
const { id, data } = payload || ({} as { id: string; data: WorkspaceDocument });
if (!id || !data) throw new Error("workspace:save requires an id and data payload");

const nextDoc: WorkspaceDocument = {
...data,
id,
updatedAt: new Date().toISOString(),
};

const filePath = workspaceFilePath(id);
await fileSystem.writeFile(filePath, JSON.stringify(nextDoc, null, 2), "utf-8");
return nextDoc;
}
);

// --- IPC: OAuth Login ---
ipcMain.handle("oauth-login", async () => {
const CLIENT_ID = process.env.GITHUB_CLIENT_ID!;
Expand Down Expand Up @@ -215,6 +338,7 @@ ipcMain.handle("oauth-login-google", async () => {

// --- App lifecycle ---
app.whenReady().then(() => {
resolveWorkspaceRoot();
createWindow();

app.on("activate", () => {
Expand All @@ -225,3 +349,4 @@ app.whenReady().then(() => {
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
const fileSystem = fs.promises;
29 changes: 28 additions & 1 deletion apps/desktop-app/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,35 @@ loadBridge("runtime-bridge.cjs");

// 2) Keep your existing OAuth helpers under window.electron
import { contextBridge, ipcRenderer } from "electron";
import type { WorkspaceDocument, WorkspaceSummary } from "./shared/workspace";

contextBridge.exposeInMainWorld("electron", {
function safeExpose(key: string, api: Record<string, unknown>) {
try {
contextBridge.exposeInMainWorld(key, api);
} catch (err: any) {
if (err?.message?.includes("Cannot bind an API on top of an existing property")) {
return;
}
throw err;
}
}

safeExpose("electron", {
login: () => ipcRenderer.invoke("oauth-login"), // GitHub
loginGoogle: () => ipcRenderer.invoke("oauth-login-google") // Google
});

safeExpose("workspace", {
list: (): Promise<WorkspaceSummary[]> => ipcRenderer.invoke("workspace:list"),
create: (
payload?: {
name?: string;
template?: Partial<WorkspaceDocument> | null;
meta?: WorkspaceDocument["meta"];
}
): Promise<WorkspaceDocument> =>
ipcRenderer.invoke("workspace:create", payload),
load: (id: string): Promise<WorkspaceDocument> => ipcRenderer.invoke("workspace:load", id),
save: (id: string, data: WorkspaceDocument): Promise<WorkspaceDocument> =>
ipcRenderer.invoke("workspace:save", { id, data }),
});
82 changes: 77 additions & 5 deletions apps/desktop-app/src/renderer/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,42 @@
import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom";
import {
BrowserRouter,
Routes,
Route,
useLocation,
useNavigate,
} from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import AuthPage from "./pages/Auth";
import Dashboard from "./pages/Dashboard";
import WorkspacePage from "./pages/Workspace";
import { AnimatePresence, motion } from "framer-motion";
import React from "react";

function AnimatedRoutes({ handleLoginGitHub, handleLoginGoogle }: any) {
const location = useLocation();
const navigate = useNavigate();

const handleGitHubAndRedirect = React.useCallback(() => {
(async () => {
const result = await handleLoginGitHub();
if (result?.success) {
navigate("/dashboard");
}
})().catch((err) => {
console.error("GitHub redirect error:", err);
});
}, [handleLoginGitHub, navigate]);

const handleGoogleAndRedirect = React.useCallback(() => {
(async () => {
const result = await handleLoginGoogle();
if (result?.success) {
navigate("/dashboard");
}
})().catch((err) => {
console.error("Google redirect error:", err);
});
}, [handleLoginGoogle, navigate]);

return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
Expand All @@ -31,7 +62,6 @@ function AnimatedRoutes({ handleLoginGitHub, handleLoginGoogle }: any) {
</motion.div>
}
/>

{/* Auth Page */}
<Route
path="/auth"
Expand All @@ -49,12 +79,50 @@ function AnimatedRoutes({ handleLoginGitHub, handleLoginGoogle }: any) {
}}
>
<AuthPage
onLoginGitHub={handleLoginGitHub}
onLoginGoogle={handleLoginGoogle}
onLoginGitHub={handleGitHubAndRedirect}
onLoginGoogle={handleGoogleAndRedirect}
/>
</motion.div>
}
/>
<Route
path="/dashboard"
element={
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.6, ease: "easeInOut" }}
style={{
position: "absolute",
width: "100%",
height: "100%",
background: "#f5f7fb",
}}
>
<Dashboard />
</motion.div>
}
/>
<Route
path="/workspace/:id"
element={
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.6, ease: "easeInOut" }}
style={{
position: "absolute",
width: "100%",
height: "100%",
background: "#050505",
}}
>
<WorkspacePage />
</motion.div>
}
/>
</Routes>
</AnimatePresence>
</div>
Expand All @@ -72,17 +140,19 @@ function App() {
} else {
alert("❌ GitHub login failed: " + result.error);
}
return result;
} catch (err) {
console.error("GitHub login error:", err);
alert("Unexpected GitHub login error");
return { success: false, error: "Unexpected GitHub login error" };
}
};

const handleLoginGoogle = async () => {
try {
if (!window.electron.loginGoogle) {
alert("Google login is not available yet.");
return;
return { success: false, error: "Google login unavailable" };
}

const result = await window.electron.loginGoogle();
Expand All @@ -93,9 +163,11 @@ function App() {
} else {
alert("❌ Google login failed: " + result.error);
}
return result;
} catch (err) {
console.error("Google login error:", err);
alert("Unexpected Google login error");
return { success: false, error: "Unexpected Google login error" };
}
};

Expand Down
23 changes: 23 additions & 0 deletions apps/desktop-app/src/renderer/index.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,24 @@
import type { WorkspaceDocument, WorkspaceSummary } from "../shared/workspace";

declare global {
interface Window {
electron: {
login: () => Promise<any>;
loginGoogle: () => Promise<any>;
};
workspace: {
list: () => Promise<WorkspaceSummary[]>;
create: (
payload?: {
name?: string;
template?: Partial<WorkspaceDocument> | null;
meta?: WorkspaceDocument["meta"];
}
) => Promise<WorkspaceDocument>;
load: (id: string) => Promise<WorkspaceDocument>;
save: (id: string, data: WorkspaceDocument) => Promise<WorkspaceDocument>;
};
}
}

export {};
Loading