Skip to content

Commit bef2d43

Browse files
sanchitrkclaude
andauthored
feat(desktop): Electron app with tiled widget frames (#57)
* feat(apps): scaffold web and desktop apps Add apps/web (Vite+ + React + TanStack file-based routing, hello world) and apps/desktop (Electron shell embedding the web build via a privileged codework:// scheme, dev-server proxy via CODEWORK_WEB_URL), wired into the pnpm workspace with vite-plus-core as the vite alias. * fix * feat(desktop): add one-command dev loop `pnpm dev:desktop` runs the web dev server and main-process watcher, launches Electron once both are ready, and restarts it when the main or preload bundle rebuilds. - Drop the forwarded Origin header in the dev proxy; it made net.fetch treat module scripts as CORS requests the dev server rejects. - Launch `electron .` so the app path resolves preload and web assets. - Pin the web dev server to 127.0.0.1:5173. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): tile widget frames with webwm and Tailwind Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): drag to snap, pin and maximize widgets Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): resize master and stack columns Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): add workspace switcher Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(desktop): add app server and file explorer widget Add apps/server, an Effect app server the desktop runs as a child process (Electron binary in Node mode). It serves Effect RPC over a loopback WebSocket on an OS-chosen port and keeps app state in SQLite under Electron's userData (@effect/sql-sqlite-node, built on node:sqlite). First RPCs: fs.list and package-scoped kv.get/kv.set. Electron main starts the server, waits for its ready line, then opens the window and hands the RPC URL to the page through the preload. Main and preload are bundled separately because a sandboxed preload cannot load a shared chunk. The dev runner also watch-builds the server. Restructure apps/web/src into OS domains: kernel (server client), shell (chrome, workspace switcher), wm (tiling, frames, instances), sdk (widget contract) and widgets (built-ins plus registry). defineWidget declares a widget with a props schema; the window manager renders instances through the registry and validates saved props. The first built-in is an explorer powered by @pierre/trees: folders load lazily on expand, one query atom per path, and the expanded set is saved per instance in SQLite. It is the master widget on Main. Also add the pierre trees agent skill to skills-lock.json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 00a873a commit bef2d43

43 files changed

Lines changed: 1479 additions & 37 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/desktop/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"main": "out/main.cjs",
77
"scripts": {
88
"build": "vp pack",
9-
"dev": "vp pack --watch",
9+
"dev": "node scripts/dev.mjs",
1010
"start": "node scripts/start-electron.mjs",
1111
"typecheck": "tsc --noEmit"
1212
},

‎apps/desktop/scripts/dev.mjs‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { spawn } from "node:child_process";
2+
import { watch } from "node:fs";
3+
import { stat } from "node:fs/promises";
4+
import path from "node:path";
5+
import { setTimeout as sleep } from "node:timers/promises";
6+
import { fileURLToPath } from "node:url";
7+
8+
const desktopDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9+
const webDir = path.resolve(desktopDir, "../web");
10+
const serverDir = path.resolve(desktopDir, "../server");
11+
const outDir = path.join(desktopDir, "out");
12+
const serverOutDir = path.join(serverDir, "dist");
13+
const WEB_URL = "http://127.0.0.1:5173";
14+
const startedAt = Date.now();
15+
16+
const children = new Set();
17+
18+
// Each child leads its own process group so shutdown also reaches the
19+
// grandchildren (pnpm -> vp, start-electron -> Electron).
20+
function run(command, args, options) {
21+
const child = spawn(command, args, { stdio: "inherit", detached: true, ...options });
22+
children.add(child);
23+
child.on("exit", () => children.delete(child));
24+
return child;
25+
}
26+
27+
function stop(child) {
28+
try {
29+
process.kill(-child.pid, "SIGTERM");
30+
} catch {}
31+
}
32+
33+
function shutdown() {
34+
for (const child of children) stop(child);
35+
process.exit(0);
36+
}
37+
process.on("SIGINT", shutdown);
38+
process.on("SIGTERM", shutdown);
39+
40+
// Electron is useless without any of them, so losing one ends the session (this
41+
// also catches Vite refusing an occupied port).
42+
for (const [args, cwd] of [
43+
[["run", "dev"], webDir],
44+
[["exec", "vp", "pack", "--watch"], desktopDir],
45+
[["exec", "vp", "pack", "--watch"], serverDir],
46+
]) {
47+
run("pnpm", args, { cwd }).on("exit", shutdown);
48+
}
49+
50+
// Wait for fresh bundles and a reachable web server, so Electron
51+
// does not boot on a stale bundle and immediately restart.
52+
const bundles = [path.join(outDir, "main.cjs"), path.join(outDir, "preload.cjs"), path.join(serverOutDir, "index.mjs")];
53+
54+
async function ready() {
55+
for (const file of bundles) {
56+
const built = await stat(file).catch(() => null);
57+
if (built === null || built.mtimeMs < startedAt) return false;
58+
}
59+
return fetch(WEB_URL).then(
60+
() => true,
61+
() => false,
62+
);
63+
}
64+
while (!(await ready())) await sleep(100);
65+
66+
let electron;
67+
function startElectron() {
68+
electron = run("node", ["scripts/start-electron.mjs"], {
69+
cwd: desktopDir,
70+
env: { ...process.env, CODEWORK_WEB_URL: WEB_URL },
71+
});
72+
// Quitting the app window ends the dev session; restarts are not quits.
73+
const current = electron;
74+
current.on("exit", () => {
75+
if (electron === current) shutdown();
76+
});
77+
}
78+
79+
// Main supervises the app server, so restarting Electron also restarts it.
80+
let restartTimer;
81+
const onRebuild = (dir) => (_event, file) => {
82+
if (!bundles.includes(path.join(dir, String(file)))) return;
83+
clearTimeout(restartTimer);
84+
restartTimer = setTimeout(() => {
85+
const previous = electron;
86+
if (previous === undefined) return;
87+
electron = undefined;
88+
stop(previous);
89+
previous.once("exit", startElectron);
90+
}, 100);
91+
};
92+
watch(outDir, onRebuild(outDir));
93+
watch(serverOutDir, onRebuild(serverOutDir));
94+
95+
startElectron();

‎apps/desktop/scripts/start-electron.mjs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,21 @@ const electronPath = createRequire(import.meta.url)("electron");
99
const env = { ...process.env };
1010
delete env.ELECTRON_RUN_AS_NODE;
1111

12-
const child = spawn(electronPath, ["out/main.cjs"], {
12+
const child = spawn(electronPath, ["."], {
1313
stdio: "inherit",
1414
cwd: desktopDir,
1515
env,
1616
});
1717

18+
// Stay alive until Electron has quit, so callers waiting on this process never
19+
// overlap a still-running instance.
20+
const forward = (signal) => child.kill(signal);
21+
process.on("SIGINT", forward);
22+
process.on("SIGTERM", forward);
23+
1824
child.on("exit", (code, signal) => {
1925
if (signal) {
26+
process.off(signal, forward);
2027
process.kill(process.pid, signal);
2128
return;
2229
}

‎apps/desktop/src/ipc.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** Synchronous IPC: the renderer reads the app server's RPC URL once, at load. */
2+
export const SERVER_CHANNEL = "codework:server";

‎apps/desktop/src/main.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { app, BrowserWindow, net, protocol } from "electron";
22
import { readFile, stat } from "node:fs/promises";
33
import path from "node:path";
4+
import { startServer } from "./server.ts";
45

56
const SCHEME = app.isPackaged ? "codework" : "codework-dev";
67
const HOST = "app";
@@ -87,9 +88,13 @@ function handleRequest(request: Request): Promise<Response> {
8788
if (DEV_SERVER_URL) {
8889
const url = new URL(request.url);
8990
if (url.host !== HOST) return Promise.resolve(new Response(null, { status: 404 }));
91+
// Module scripts carry `Origin: codework-dev://app`; forwarding it makes
92+
// net.fetch a CORS request the dev server never allows, failing every script.
93+
const headers = new Headers(request.headers);
94+
headers.delete("origin");
9095
return net.fetch(new URL(`${url.pathname}${url.search}`, DEV_SERVER_URL).toString(), {
9196
method: request.method,
92-
headers: request.headers,
97+
headers,
9398
body: request.method === "GET" || request.method === "HEAD" ? null : request.body,
9499
duplex: "half",
95100
} as RequestInit);
@@ -101,6 +106,12 @@ function createMainWindow(): BrowserWindow {
101106
const window = new BrowserWindow({
102107
width: 1440,
103108
height: 900,
109+
// The web shell draws its own title bar (workspace switcher) beside the
110+
// inset traffic lights, which sit centered in its 44px height.
111+
...(process.platform === "darwin" && {
112+
titleBarStyle: "hiddenInset",
113+
trafficLightPosition: { x: 14, y: 16 },
114+
}),
104115
webPreferences: {
105116
preload: path.join(app.getAppPath(), "out/preload.cjs"),
106117
sandbox: true,
@@ -110,8 +121,9 @@ function createMainWindow(): BrowserWindow {
110121
return window;
111122
}
112123

113-
void app.whenReady().then(() => {
124+
void app.whenReady().then(async () => {
114125
protocol.handle(SCHEME, handleRequest);
126+
await startServer();
115127
createMainWindow();
116128

117129
app.on("activate", () => {

‎apps/desktop/src/preload.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import { contextBridge } from "electron";
1+
import { contextBridge, ipcRenderer } from "electron";
2+
import { SERVER_CHANNEL } from "./ipc.ts";
23

34
contextBridge.exposeInMainWorld("desktopBridge", {
45
platform: process.platform,
6+
// Main creates the window only after the server is ready, so this is set.
7+
server: ipcRenderer.sendSync(SERVER_CHANNEL) as string,
58
});

‎apps/desktop/src/server.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { app, ipcMain } from "electron";
2+
import { spawn } from "node:child_process";
3+
import path from "node:path";
4+
import { createInterface } from "node:readline";
5+
import { SERVER_CHANNEL } from "./ipc.ts";
6+
7+
// Dev layout: the server bundle sits beside this app in the workspace.
8+
const ENTRY = path.resolve(app.getAppPath(), "../server/dist/index.mjs");
9+
10+
/** Starts the app server and resolves with its RPC URL once it listens. */
11+
export function startServer(): Promise<string> {
12+
// Electron's own binary in Node mode: no system Node needed, and the child
13+
// does not become a second GUI app instance.
14+
const child = spawn(process.execPath, [ENTRY], {
15+
env: {
16+
...process.env,
17+
ELECTRON_RUN_AS_NODE: "1",
18+
CODEWORK_DATABASE: path.join(app.getPath("userData"), "app.db"),
19+
},
20+
stdio: ["ignore", "pipe", "inherit"],
21+
});
22+
23+
app.on("will-quit", () => child.kill());
24+
25+
return new Promise((resolve, reject) => {
26+
let ready = false;
27+
child.once("exit", (code, signal) => {
28+
if (!ready) return reject(new Error(`server exited before ready (code ${code}, signal ${signal})`));
29+
// No restart yet: the app is unusable without its server, so it ends
30+
// with it and the dev runner (or the user) starts it again.
31+
console.error(`server exited (code ${code}, signal ${signal})`);
32+
app.quit();
33+
});
34+
35+
createInterface({ input: child.stdout }).on("line", (line) => {
36+
if (!ready && line.startsWith('{"type":"ready"')) {
37+
ready = true;
38+
const { port } = JSON.parse(line) as { port: number };
39+
const url = `ws://127.0.0.1:${port}/rpc`;
40+
ipcMain.on(SERVER_CHANNEL, (event) => {
41+
event.returnValue = url;
42+
});
43+
resolve(url);
44+
return;
45+
}
46+
console.log(`[server] ${line}`);
47+
});
48+
});
49+
}

‎apps/desktop/vite.config.ts‎

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,22 @@ import { defineConfig } from "vite-plus";
44
// stay external — everything else is inlined into the artifact.
55
const isExternal = (id: string) => id === "electron" || id.startsWith("electron/") || id.startsWith("node:");
66

7+
// One config per entry: a sandboxed preload cannot require sibling files, so
8+
// modules shared with main must be inlined rather than split into a chunk.
9+
// Neither cleans the shared out dir, or one build would delete the other's file.
10+
const bundle = (entry: string) => ({
11+
format: "cjs" as const,
12+
outDir: "out",
13+
dts: false,
14+
sourcemap: true,
15+
outExtensions: () => ({ js: ".cjs" }),
16+
entry: [entry],
17+
clean: false,
18+
deps: {
19+
neverBundle: isExternal,
20+
},
21+
});
22+
723
export default defineConfig({
8-
pack: [
9-
{
10-
format: "cjs",
11-
outDir: "out",
12-
dts: false,
13-
sourcemap: true,
14-
outExtensions: () => ({ js: ".cjs" }),
15-
entry: ["src/main.ts", "src/preload.ts"],
16-
clean: true,
17-
deps: {
18-
neverBundle: isExternal,
19-
},
20-
},
21-
],
24+
pack: [bundle("src/main.ts"), bundle("src/preload.ts")],
2225
});

‎apps/server/package.json‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "@codeworksh/server",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"exports": {
7+
"./contract": "./src/contract.ts"
8+
},
9+
"scripts": {
10+
"build": "vp pack",
11+
"dev": "vp pack --watch",
12+
"typecheck": "tsc --noEmit"
13+
},
14+
"dependencies": {
15+
"@effect/platform-node": "4.0.1",
16+
"@effect/sql-sqlite-node": "4.0.1",
17+
"effect": "4.0.1"
18+
},
19+
"devDependencies": {
20+
"@types/node": "catalog:",
21+
"typescript": "catalog:",
22+
"vite-plus": "catalog:"
23+
}
24+
}

‎apps/server/src/contract.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Schema } from "effect";
2+
import { Rpc, RpcGroup } from "effect/rpc";
3+
4+
export const EntryKind = Schema.Literals(["file", "directory"]);
5+
export type EntryKind = typeof EntryKind.Type;
6+
7+
/** One direct child of a listed directory. */
8+
export const Entry = Schema.Struct({ name: Schema.String, kind: EntryKind });
9+
export type Entry = typeof Entry.Type;
10+
11+
export class FsError extends Schema.TaggedError<FsError>()("Server.FsError", {
12+
path: Schema.String,
13+
message: Schema.String,
14+
}) {}
15+
16+
/** Package-scoped JSON storage: `package` namespaces keys so packages never collide. */
17+
const KvKey = { package: Schema.String, key: Schema.String };
18+
19+
export const Api = RpcGroup.make(
20+
Rpc.make("fs.list", {
21+
payload: { path: Schema.String },
22+
success: Schema.Array(Entry),
23+
error: FsError,
24+
}),
25+
Rpc.make("kv.get", {
26+
payload: KvKey,
27+
success: Schema.OptionFromNullOr(Schema.Json),
28+
}),
29+
Rpc.make("kv.set", {
30+
payload: { ...KvKey, value: Schema.Json },
31+
success: Schema.Void,
32+
}),
33+
);
34+
35+
export * as Contract from "./contract.ts";

0 commit comments

Comments
 (0)