wip-electron

This commit is contained in:
Ramnique Singh 2025-12-23 18:26:32 +05:30
parent c637cb49ac
commit 2491bacea1
17 changed files with 8098 additions and 517 deletions

2
apps/electron/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
out/

View file

@ -0,0 +1,44 @@
const { FusesPlugin } = require('@electron-forge/plugin-fuses');
const { FuseV1Options, FuseVersion } = require('@electron/fuses');
module.exports = {
packagerConfig: {
asar: true,
},
rebuildConfig: {},
makers: [
{
name: '@electron-forge/maker-squirrel',
config: {},
},
{
name: '@electron-forge/maker-zip',
platforms: ['darwin'],
},
{
name: '@electron-forge/maker-deb',
config: {},
},
{
name: '@electron-forge/maker-rpm',
config: {},
},
],
plugins: [
{
name: '@electron-forge/plugin-auto-unpack-natives',
config: {},
},
// Fuses are used to enable/disable various Electron functionality
// at package time, before code signing the application
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
],
};

18
apps/electron/main.js Normal file
View file

@ -0,0 +1,18 @@
const { app, BrowserWindow } = require('electron');
const path = require('node:path');
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
},
});
win.loadURL('http://localhost:8080');
};
app.whenReady().then(() => {
createWindow();
});

7869
apps/electron/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
{
"name": "electron",
"version": "1.0.0",
"main": "main.js",
"scripts": {
"start": "electron-forge start",
"test": "echo \"Error: no test specified\" && exit 1",
"package": "electron-forge package",
"make": "electron-forge make"
},
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@electron-forge/cli": "^7.10.2",
"@electron-forge/maker-deb": "^7.10.2",
"@electron-forge/maker-rpm": "^7.10.2",
"@electron-forge/maker-squirrel": "^7.10.2",
"@electron-forge/maker-zip": "^7.10.2",
"@electron-forge/plugin-auto-unpack-natives": "^7.10.2",
"@electron-forge/plugin-fuses": "^7.10.2",
"@electron/fuses": "^1.8.0",
"electron": "^39.2.7"
},
"dependencies": {
"electron-squirrel-startup": "^1.0.1"
}
}

5
apps/electron/preload.js Normal file
View file

@ -0,0 +1,5 @@
const { contextBridge } = require('electron');
contextBridge.exposeInMainWorld('config', {
apiBase: process.env.API_BASE,
});

View file

@ -1,71 +0,0 @@
import { NextRequest } from "next/server";
const BACKEND = process.env.CLI_BACKEND_URL || "http://localhost:3000";
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
async function forward(req: NextRequest, method: string, segments?: string[]) {
const search = req.nextUrl.search || "";
const targetPath = (segments || []).join("/");
const target = `${BACKEND}/${targetPath}${search}`;
const init: RequestInit = {
method,
headers: {
"Content-Type": req.headers.get("content-type") || "application/json",
},
};
if (method !== "GET" && method !== "HEAD") {
init.body = await req.text();
}
const res = await fetch(target, init);
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"content-type": res.headers.get("content-type") || "application/json",
...CORS_HEADERS,
},
});
}
export async function GET(
req: NextRequest,
context: { params: Promise<{ path?: string[] }> }
) {
const { path } = await context.params;
return forward(req, "GET", path);
}
export async function POST(
req: NextRequest,
context: { params: Promise<{ path?: string[] }> }
) {
const { path } = await context.params;
return forward(req, "POST", path);
}
export async function PUT(
req: NextRequest,
context: { params: Promise<{ path?: string[] }> }
) {
const { path } = await context.params;
return forward(req, "PUT", path);
}
export async function DELETE(
req: NextRequest,
context: { params: Promise<{ path?: string[] }> }
) {
const { path } = await context.params;
return forward(req, "DELETE", path);
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}

View file

@ -1,64 +0,0 @@
import { NextRequest } from "next/server";
import os from "os";
import path from "path";
import { promises as fs } from "fs";
const AGENTS_ROOT = path.join(os.homedir(), ".rowboat", "agents");
function resolveAgentPath(fileParam: string): { target: string; relative: string } {
// Normalize and strip any attempted path traversal.
const normalized = path.normalize(fileParam).replace(/^(\.\.(\/|\\|$))+/, "");
const target = path.join(AGENTS_ROOT, normalized);
if (!target.startsWith(AGENTS_ROOT)) {
throw new Error("Invalid path");
}
return { target, relative: normalized };
}
export async function GET(req: NextRequest) {
const fileParam = req.nextUrl.searchParams.get("file");
if (!fileParam) {
return Response.json({ error: "file param required" }, { status: 400 });
}
try {
const { target, relative } = resolveAgentPath(fileParam);
const content = await fs.readFile(target, "utf8");
return Response.json({ file: relative, content, raw: content });
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException;
if (err?.code === "ENOENT") {
return Response.json({ error: "File not found" }, { status: 404 });
}
if (err instanceof Error && err.message === "Invalid path") {
return Response.json({ error: "Invalid file path" }, { status: 400 });
}
console.error("Failed to read agent file", error);
return Response.json({ error: "Failed to read agent file" }, { status: 500 });
}
}
export async function PUT(req: NextRequest) {
const fileParam = req.nextUrl.searchParams.get("file");
if (!fileParam) {
return Response.json({ error: "file param required" }, { status: 400 });
}
try {
const { target, relative } = resolveAgentPath(fileParam);
const content = await req.text();
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, content, "utf8");
return Response.json({ file: relative, success: true });
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException;
if (err?.code === "ENOENT") {
return Response.json({ error: "File not found" }, { status: 404 });
}
if (err instanceof Error && err.message === "Invalid path") {
return Response.json({ error: "Invalid file path" }, { status: 400 });
}
console.error("Failed to write agent file", error);
return Response.json({ error: "Failed to write agent file" }, { status: 500 });
}
}

View file

@ -1,74 +0,0 @@
import { NextRequest } from "next/server";
import os from "os";
import path from "path";
import { promises as fs } from "fs";
const CONFIG_ROOT = path.join(os.homedir(), ".rowboat", "config");
function resolveConfigPath(fileParam: string): { target: string; relative: string } {
const normalized = path.normalize(fileParam).replace(/^(\.\.(\/|\\|$))+/, "");
const target = path.join(CONFIG_ROOT, normalized);
if (!target.startsWith(CONFIG_ROOT)) {
throw new Error("Invalid path");
}
return { target, relative: normalized };
}
export async function GET(req: NextRequest) {
const fileParam = req.nextUrl.searchParams.get("file");
if (!fileParam) {
return Response.json({ error: "file param required" }, { status: 400 });
}
try {
const { target, relative } = resolveConfigPath(fileParam);
const content = await fs.readFile(target, "utf8");
return Response.json({ file: relative, content, raw: content });
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException;
if (err?.code === "ENOENT") {
return Response.json(
{ error: "File not found" },
{ status: 404 }
);
}
if (error instanceof Error && error.message === "Invalid path") {
return Response.json(
{ error: "Invalid file path" },
{ status: 400 }
);
}
console.error("Failed to read config file", error);
return Response.json(
{ error: "Failed to read config file" },
{ status: 500 }
);
}
}
export async function PUT(req: NextRequest) {
const fileParam = req.nextUrl.searchParams.get("file");
if (!fileParam) {
return Response.json({ error: "file param required" }, { status: 400 });
}
try {
const { target, relative } = resolveConfigPath(fileParam);
const content = await req.text();
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, content, "utf8");
return Response.json({ file: relative, success: true });
} catch (error: unknown) {
if (error instanceof Error && error.message === "Invalid path") {
return Response.json(
{ error: "Invalid file path" },
{ status: 400 }
);
}
console.error("Failed to write config file", error);
return Response.json(
{ error: "Failed to write config file" },
{ status: 500 }
);
}
}

View file

@ -1,46 +0,0 @@
import { NextRequest } from "next/server";
import os from "os";
import path from "path";
import { promises as fs } from "fs";
const ROWBOAT_ROOT = path.join(os.homedir(), ".rowboat", "runs");
function resolveRunPath(fileParam: string): { target: string; relative: string } {
const normalized = path.normalize(fileParam).replace(/^(\.\.(\/|\\|$))+/, "");
const target = path.join(ROWBOAT_ROOT, normalized);
if (!target.startsWith(ROWBOAT_ROOT)) {
throw new Error("Invalid path");
}
return { target, relative: normalized };
}
export async function GET(req: NextRequest) {
const fileParam = req.nextUrl.searchParams.get("file");
if (!fileParam) {
return Response.json({ error: "file param required" }, { status: 400 });
}
try {
const { target, relative } = resolveRunPath(fileParam);
const content = await fs.readFile(target, "utf8");
let parsed: unknown = null;
try {
parsed = JSON.parse(content);
} catch {
parsed = null;
}
return Response.json({ file: relative, parsed, raw: content });
} catch (error: unknown) {
if (error instanceof Error && error.message === "Invalid path") {
return Response.json(
{ error: "Invalid file path" },
{ status: 400 }
);
}
console.error("Failed to read run file", error);
return Response.json(
{ error: "Failed to read run file" },
{ status: 500 }
);
}
}

View file

@ -1,43 +0,0 @@
import path from "path";
import os from "os";
import { promises as fs } from "fs";
const ROWBOAT_ROOT = path.join(os.homedir(), ".rowboat");
async function listRecursive(dir: string): Promise<string[]> {
const root = path.join(ROWBOAT_ROOT, dir);
const walk = async (current: string, prefix = ""): Promise<string[]> => {
const results: string[] = [];
try {
const entries = await fs.readdir(current, { withFileTypes: true });
for (const entry of entries) {
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
results.push(...(await walk(path.join(current, entry.name), relPath)));
} else if (entry.isFile()) {
results.push(relPath);
}
}
} catch {
return results;
}
return results;
};
return walk(root);
}
export async function GET() {
const agents = await listRecursive("agents");
const config = await listRecursive("config");
const runs = await listRecursive("runs");
return Response.json({
agents,
config,
runs,
});
}

View file

@ -1,113 +0,0 @@
import { NextRequest } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
const CLI_BASE_URL = process.env.CLI_BACKEND_URL || 'http://localhost:3000';
/**
* GET /api/stream
* Proxy SSE stream from CLI backend to frontend
*/
export async function GET(request: NextRequest) {
const encoder = new TextEncoder();
const customReadable = new ReadableStream({
async start(controller) {
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
let isClosed = false;
// Handle client disconnect
request.signal.addEventListener('abort', () => {
isClosed = true;
reader?.cancel();
try {
controller.close();
} catch {
// Already closed, ignore
}
});
try {
// Connect to CLI backend SSE stream
const response = await fetch(`${CLI_BASE_URL}/stream`, {
headers: {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
signal: request.signal, // Forward abort signal
});
if (!response.ok) {
throw new Error(`Failed to connect to backend: ${response.statusText}`);
}
const body = response.body;
if (!body) {
throw new Error('No response body');
}
reader = body.getReader();
// Read and forward stream
while (!isClosed) {
const { done, value } = await reader.read();
if (done) {
break;
}
// Only enqueue if controller is still open
if (!isClosed) {
try {
controller.enqueue(value);
} catch {
// Controller closed, stop reading
break;
}
}
}
} catch (error: unknown) {
// Only log non-abort errors
if ((error as Error).name !== 'AbortError') {
console.error('Stream error:', error);
}
// Try to send error message if controller is still open
if (!isClosed) {
try {
const errorMessage = `data: ${JSON.stringify({ type: 'error', error: String(error) })}\n\n`;
controller.enqueue(encoder.encode(errorMessage));
} catch {
// Controller already closed, ignore
}
}
} finally {
// Clean up
if (reader) {
try {
await reader.cancel();
} catch {
// Ignore cancel errors
}
}
if (!isClosed) {
try {
controller.close();
} catch {
// Already closed, ignore
}
}
}
},
});
return new Response(customReadable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}

View file

@ -46,7 +46,7 @@ import {
ArtifactHeader,
ArtifactTitle,
} from "@/components/ai-elements/artifact";
import { useState, useEffect, useRef, type ReactNode } from "react";
import { useState, useEffect, useRef, type ReactNode, useCallback } from "react";
import { MicIcon, Save, Loader2, Lock } from "lucide-react";
import {
Select,
@ -108,7 +108,7 @@ type RunEvent = {
};
function PageBody() {
const apiBase = "/api/cli";
const [apiBase, setApiBase] = useState<string>("http://localhost:3000")
const streamUrl = "/api/stream";
const [text, setText] = useState<string>("");
const [useMicrophone, setUseMicrophone] = useState<boolean>(false);
@ -141,13 +141,16 @@ function PageBody() {
const detectFileType = (name: string): "json" | "markdown" =>
name.toLowerCase().match(/\.(md|markdown)$/) ? "markdown" : "json";
const requestJson = async (
useEffect(() => {
setApiBase(window.config.apiBase);
}, []);
const requestJson = useCallback(async (
url: string,
options?: (RequestInit & { allow404?: boolean }) | undefined
) => {
const fullUrl = url.startsWith("/api/")
? url
: `${apiBase}${url.startsWith("/") ? url : `/${url}`}`;
const fullUrl = new URL(url, apiBase).toString();
console.log('fullUrl', fullUrl);
const { allow404, ...rest } = options || {};
const res = await fetch(fullUrl, {
...rest,
@ -188,7 +191,7 @@ function PageBody() {
} catch {
return null;
}
};
}, [apiBase]);
const renderPromptInput = () => (
<PromptInput globalDrop multiple onSubmit={handleSubmit}>
@ -673,7 +676,7 @@ function PageBody() {
return () => {
cancelled = true;
};
}, [selectedResource]);
}, [selectedResource, requestJson]);
useEffect(() => {
const loadAgents = async () => {

10
apps/rowboatx/global.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
export {};
declare global {
interface Window {
config: {
apiBase: string;
};
}
}

View file

@ -2,6 +2,7 @@ import type { NextConfig } from "next";
import path from "path";
const nextConfig: NextConfig = {
output: "export",
turbopack: {
// Keep Turbopack scoped to this app instead of inferring a parent workspace root.
root: __dirname || path.join(process.cwd()),

View file

@ -21,7 +21,6 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tiptap/extension-bubble-menu": "^3.13.0",
"@tiptap/extension-code-block-lowlight": "^3.13.0",
"@tiptap/extension-placeholder": "^3.13.0",
"@tiptap/pm": "^3.13.0",
@ -2732,6 +2731,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.13.0.tgz",
"integrity": "sha512-qZ3j2DBsqP9DjG2UlExQ+tHMRhAnWlCKNreKddKocb/nAFrPdBCtvkqIEu+68zPlbLD4ukpoyjUklRJg+NipFg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},

12
build-electron.sh Normal file
View file

@ -0,0 +1,12 @@
#!/bin/bash
set -e
# build rowboatx next.js app
(cd apps/rowboatx && \
npm install && \
npm run build)
# build rowboat server
(cd apps/cli && \
npm install && \
npm run build)