From 3f2d571592707a91b8293bc140a946c7b18f6aba Mon Sep 17 00:00:00 2001 From: Nikita Date: Sun, 28 Dec 2025 12:15:13 -0800 Subject: [PATCH] Update flow.toml to start web and desktop dev servers with port management and cleanup logic; enhance desktop preload and main process to resolve preload path, set web URLs, and expose shell functions; improve README with load fallback info and environment variables; add external logs configuration to worker package. --- flow.toml | 70 ++++++++++++++++++++- packages/desktop/README.md | 11 +++- packages/desktop/src/main/index.ts | 51 ++++++++++++---- packages/desktop/src/preload/index.ts | 20 +++++- packages/desktop/src/renderer/env.d.ts | 16 +---- packages/worker/package.json | 1 + packages/worker/readme.md | 17 ++++++ packages/worker/src/index.ts | 84 ++++++++++++++++++++++++++ pnpm-workspace.yaml | 1 + 9 files changed, 240 insertions(+), 31 deletions(-) diff --git a/flow.toml b/flow.toml index 36efbd32..d6bf018b 100644 --- a/flow.toml +++ b/flow.toml @@ -365,9 +365,75 @@ shortcuts = ["d"] [[tasks]] name = "desktop" command = """ -pnpm --filter @linsa/desktop dev +set -euo pipefail + +LINS_WEB_PORT=5625 +ONEFOCUS_WEB_PORT=5615 +ONEFOCUS_ROOT="${ONEFOCUS_ROOT:-/Users/nikiv/org/1f/1f}" + +if [ ! -d "$ONEFOCUS_ROOT" ]; then + echo "1focus repo not found at $ONEFOCUS_ROOT" + echo "Set ONEFOCUS_ROOT to the 1focus repo root." + exit 1 +fi + +kill_port() { + local port="$1" + if lsof -iTCP:"$port" -sTCP:LISTEN -P -n >/dev/null 2>&1; then + lsof -tiTCP:"$port" -sTCP:LISTEN | xargs kill -9 2>/dev/null || true + sleep 0.5 + fi +} + +wait_for_port() { + local port="$1" + for i in $(seq 1 40); do + if nc -z 127.0.0.1 "$port" >/dev/null 2>&1; then + return 0 + fi + sleep 0.25 + done +} + +kill_port "$LINS_WEB_PORT" +kill_port "$ONEFOCUS_WEB_PORT" + +LINS_WEB_PID="" +ONE_WEB_PID="" +LINS_DESK_PID="" +ONE_DESK_PID="" + +echo "Starting Linsa web dev server on :$LINS_WEB_PORT..." +(cd packages/web && pnpm dev 2>&1 | while IFS= read -r line; do echo "[linsa:web] $line"; done) & +LINS_WEB_PID=$! + +echo "Starting 1focus web dev server on :$ONEFOCUS_WEB_PORT..." +(cd "$ONEFOCUS_ROOT/packages/web" && pnpm dev 2>&1 | while IFS= read -r line; do echo "[1f:web] $line"; done) & +ONE_WEB_PID=$! + +cleanup() { + for pid in "$LINS_WEB_PID" "$ONE_WEB_PID" "$LINS_DESK_PID" "$ONE_DESK_PID"; do + if [ -n "$pid" ]; then + kill "$pid" 2>/dev/null || true + fi + done +} +trap cleanup EXIT INT TERM + +wait_for_port "$LINS_WEB_PORT" +wait_for_port "$ONEFOCUS_WEB_PORT" + +echo "Starting Linsa Electron..." +(pnpm --filter @linsa/desktop dev 2>&1 | while IFS= read -r line; do echo "[linsa:desktop] $line"; done) & +LINS_DESK_PID=$! + +echo "Starting 1focus Electron..." +(cd "$ONEFOCUS_ROOT" && pnpm dev:desktop 2>&1 | while IFS= read -r line; do echo "[1f:desktop] $line"; done) & +ONE_DESK_PID=$! + +wait "$LINS_DESK_PID" "$ONE_DESK_PID" """ -description = "Start the Electron desktop app (electron-vite dev)." +description = "Run Linsa + 1focus desktop apps with their web dev servers." dependencies = ["node", "pnpm"] shortcuts = ["desk"] diff --git a/packages/desktop/README.md b/packages/desktop/README.md index fa1b4162..5b5cc05d 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -1,14 +1,17 @@ # Linsa desktop -Electron shell that mirrors the same structure we use in the `as` project: `electron-vite` bundling the main, preload, and React renderer, plus a small Jazz schema for storing folders locally. +Electron shell that mirrors the same structure we use in the `as` project: `electron-vite` bundling the main, preload, and React renderer. The window loads the Linsa web app by default (dev URL: `http://localhost:5625`) and falls back to the bundled renderer if the web app cannot be reached. ## Running locally ```bash pnpm install +pnpm --filter @linsa/web dev pnpm --filter @linsa/desktop dev ``` +By default the Electron shell loads the existing web app. Override the target with `WEB_DEV_URL` or `WEB_URL` when needed. + Set a Jazz Cloud key to sync state instead of keeping it only on the device: ```bash @@ -18,9 +21,15 @@ echo "VITE_JAZZ_API_KEY=your_jazz_key" >> .env # echo "VITE_JAZZ_PEER=ws://localhost:4200" >> .env ``` +### Optional environment + +- `WEB_URL` / `WEB_DEV_URL` – where to load the web app from. +- `VITE_JAZZ_API_KEY` / `VITE_JAZZ_PEER` – used by the fallback renderer for sync. + ## What it does - Uses `electron-vite` to bundle `main`, `preload`, and the React renderer. +- Loads the web client first and falls back to the bundled renderer if needed. - Wraps the renderer with `JazzReactProvider` (storage in IndexedDB) and a simple Jazz schema to keep track of folders you want scanned. - Opens an OS folder picker (via the preload bridge) to add/remove code folders. - Scans those folders for git repos and lets you open them in VS Code, Terminal, or Finder. diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 970b4831..78085a79 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -4,6 +4,8 @@ import { fileURLToPath } from "node:url"; import { readdir, stat } from "node:fs/promises"; import { app, BrowserWindow, dialog, ipcMain, shell } from "electron"; +process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = "true"; + const __dirname = dirname(fileURLToPath(import.meta.url)); interface GitRepo { @@ -50,13 +52,24 @@ async function findGitRepos(dir: string, maxDepth = 4): Promise { return repos; } +function resolvePreload() { + const candidates = [ + join(__dirname, "../preload/index.js"), + join(__dirname, "../preload/index.cjs"), + join(__dirname, "../preload/index.mjs"), + ]; + + return candidates.find((path) => existsSync(path)); +} + function createWindow() { - const preloadJs = join(__dirname, "../preload/index.js"); - const preloadCjs = join(__dirname, "../preload/index.cjs"); - const preloadMjs = join(__dirname, "../preload/index.mjs"); - const preloadPath = [preloadJs, preloadCjs, preloadMjs].find((p) => - existsSync(p), - ); + const preloadPath = resolvePreload(); + const webDevUrl = + process.env.WEB_DEV_URL ?? + process.env.VITE_WEB_DEV_URL ?? + "http://localhost:5625"; + const webProdUrl = process.env.WEB_URL ?? process.env.VITE_WEB_URL; + const targetUrl = webProdUrl ?? webDevUrl; const mainWindow = new BrowserWindow({ width: 1000, @@ -79,16 +92,22 @@ function createWindow() { return { action: "deny" }; }); - if (process.env.ELECTRON_RENDERER_URL) { - mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL); - mainWindow.webContents.openDevTools(); - } else { - mainWindow.loadFile(join(__dirname, "../renderer/index.html")); + mainWindow + .loadURL(targetUrl) + .catch(() => mainWindow.loadFile(join(__dirname, "../renderer/index.html"))); + + if (!webProdUrl) { + mainWindow.webContents.openDevTools({ mode: "bottom" }); } return mainWindow; } +ipcMain.handle("shell:open-external", async (_event, url: string) => { + if (!url) return; + await shell.openExternal(url); +}); + ipcMain.handle("dialog:pick-folder", async (): Promise => { const result = await dialog.showOpenDialog({ title: "Select code folder", @@ -102,6 +121,8 @@ ipcMain.handle("dialog:pick-folder", async (): Promise => { return result.filePaths[0]; }); +ipcMain.handle("app:get-version", () => app.getVersion()); + ipcMain.handle("repos:scan", async (_event, folders: string[]): Promise => { const allRepos: GitRepo[] = []; @@ -121,15 +142,23 @@ ipcMain.handle("repos:scan", async (_event, folders: string[]): Promise { + if (!path) return; + shell.showItemInFolder(path); +}); + +ipcMain.handle("shell:show-path", async (_event, path: string) => { + if (!path) return; shell.showItemInFolder(path); }); ipcMain.handle("shell:open-in-editor", async (_event, path: string) => { + if (!path) return; const { exec } = await import("node:child_process"); exec(`code "${path}"`); }); ipcMain.handle("shell:open-in-terminal", async (_event, path: string) => { + if (!path) return; const { exec } = await import("node:child_process"); if (process.platform === "darwin") { exec(`open -a Terminal "${path}"`); diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 6be12fa9..bfeecc70 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -6,10 +6,24 @@ export interface GitRepo { lastModified: number; } +export interface DesktopAPI { + openExternal: (url: string) => Promise; + showPath: (path: string) => Promise; + showInFolder: (path: string) => Promise; + pickFolder: () => Promise; + getVersion: () => Promise; + scanRepos: (folders: string[]) => Promise; + openInEditor: (path: string) => Promise; + openInTerminal: (path: string) => Promise; +} + contextBridge.exposeInMainWorld("electronAPI", { - pickFolder: () => ipcRenderer.invoke("dialog:pick-folder") as Promise, - scanRepos: (folders: string[]) => ipcRenderer.invoke("repos:scan", folders) as Promise, + openExternal: (url: string) => ipcRenderer.invoke("shell:open-external", url), + showPath: (path: string) => ipcRenderer.invoke("shell:show-path", path), showInFolder: (path: string) => ipcRenderer.invoke("shell:show-in-folder", path), + pickFolder: () => ipcRenderer.invoke("dialog:pick-folder") as Promise, + getVersion: () => ipcRenderer.invoke("app:get-version") as Promise, + scanRepos: (folders: string[]) => ipcRenderer.invoke("repos:scan", folders) as Promise, openInEditor: (path: string) => ipcRenderer.invoke("shell:open-in-editor", path), openInTerminal: (path: string) => ipcRenderer.invoke("shell:open-in-terminal", path), -}); +} satisfies DesktopAPI); diff --git a/packages/desktop/src/renderer/env.d.ts b/packages/desktop/src/renderer/env.d.ts index 883d33a3..3b3fd48f 100644 --- a/packages/desktop/src/renderer/env.d.ts +++ b/packages/desktop/src/renderer/env.d.ts @@ -1,20 +1,8 @@ -interface GitRepo { - name: string; - path: string; - lastModified: number; -} - -interface ElectronAPI { - pickFolder: () => Promise; - scanRepos: (folders: string[]) => Promise; - showInFolder: (path: string) => Promise; - openInEditor: (path: string) => Promise; - openInTerminal: (path: string) => Promise; -} +import type { DesktopAPI } from "../preload"; declare global { interface Window { - electronAPI: ElectronAPI; + electronAPI: DesktopAPI; } } diff --git a/packages/worker/package.json b/packages/worker/package.json index 04e95df6..b6b0cf3b 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -13,6 +13,7 @@ "wrangler": "^4.53.0" }, "dependencies": { + "@1focus/logs": "file:/Users/nikiv/lang/ts/lib/1focus/packages/logs", "drizzle-orm": "^0.45.0", "drizzle-zod": "^0.8.3", "hono": "^4.10.4", diff --git a/packages/worker/readme.md b/packages/worker/readme.md index bc41ce87..b7adc101 100644 --- a/packages/worker/readme.md +++ b/packages/worker/readme.md @@ -116,6 +116,23 @@ Authentication: - `PATCH /api/v1/admin/browser-sessions/:sessionId` - `DELETE /api/v1/admin/browser-sessions/:sessionId` +### External Logs + +To forward logs into 1focus Logs for the `linsa` server, set these secrets/vars in the worker: + +- `FOCUS_LOGS_API_KEY` (required) +- `FOCUS_LOGS_SERVER` (optional, default: `linsa`) +- `FOCUS_LOGS_ENDPOINT` (optional, default: `https://1focus.app/api/logs`) + +Then send a log: + +```bash +curl -X POST "http://localhost:8787/api/v1/admin/logs" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $ADMIN_API_KEY" \ + -d '{"message":"Hello from linsa","level":"info"}' +``` + ### Example (create chat thread) ```bash diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 3d1ce6a4..7e7eb441 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -1,6 +1,7 @@ import { Hono, type Context, type MiddlewareHandler } from "hono" import { cors } from "hono/cors" import { eq } from "drizzle-orm" +import { createLogsClient, type LogPayload, type LogsWriteResult } from "@1focus/logs" import { browser_session_tabs, browser_sessions, @@ -17,6 +18,9 @@ type Env = { ADMIN_API_KEY?: string DATABASE_URL?: string HYPERDRIVE?: Hyperdrive + FOCUS_LOGS_API_KEY?: string + FOCUS_LOGS_ENDPOINT?: string + FOCUS_LOGS_SERVER?: string } // Create a new Hono app @@ -62,6 +66,40 @@ const parseBody = async (c: Context) => { return (await c.req.json().catch(() => ({}))) as Record } +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === "object" && !Array.isArray(value) + +const getLogsClient = (env: Env) => { + const apiKey = env.FOCUS_LOGS_API_KEY?.trim() + if (!apiKey) return null + const endpoint = env.FOCUS_LOGS_ENDPOINT?.trim() || undefined + const server = env.FOCUS_LOGS_SERVER?.trim() || "linsa" + return createLogsClient({ + apiKey, + server, + endpoint, + defaultSource: "linsa-worker", + timeoutMs: 3000, + }) +} + +const logTo1Focus = async ( + c: Context, + payload: LogPayload, + awaitResult = false, +): Promise => { + const client = getLogsClient(c.env) + if (!client) return null + + const promise = client.log(payload) + if (!awaitResult && c.executionCtx) { + c.executionCtx.waitUntil(promise) + return null + } + + return promise +} + const parseInteger = (value: unknown) => { const numberValue = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN @@ -127,9 +165,55 @@ app.get("/", (c) => { // Example API endpoint app.get("/api/v1/hello", (c) => { const name = c.req.query("name") || "World" + void logTo1Focus(c, { + message: "hello endpoint called", + level: "info", + meta: { name }, + }) return c.json({ message: `Hello, ${name}!` }) }) +// Manual log write (admin-only) +app.post("/api/v1/admin/logs", async (c) => { + const body = await parseBody(c) + const message = typeof body.message === "string" ? body.message.trim() : "" + if (!message) { + return c.json({ error: "message is required" }, 400) + } + + const metaInput = isRecord(body.meta) ? body.meta : {} + const meta = { + ...metaInput, + path: c.req.path, + method: c.req.method, + } + + const payload: LogPayload = { + message, + level: typeof body.level === "string" ? (body.level as LogPayload["level"]) : "info", + source: typeof body.source === "string" ? body.source : undefined, + timestamp: + typeof body.timestamp === "number" || typeof body.timestamp === "string" + ? body.timestamp + : undefined, + meta, + attributes: isRecord(body.attributes) ? body.attributes : undefined, + resource: isRecord(body.resource) ? body.resource : undefined, + scope: isRecord(body.scope) ? body.scope : undefined, + traceId: typeof body.traceId === "string" ? body.traceId : undefined, + spanId: typeof body.spanId === "string" ? body.spanId : undefined, + parentSpanId: typeof body.parentSpanId === "string" ? body.parentSpanId : undefined, + traceFlags: typeof body.traceFlags === "number" ? body.traceFlags : undefined, + } + + const result = await logTo1Focus(c, payload, true) + if (!result) { + return c.json({ error: "Logging not configured" }, 503) + } + + return c.json({ result }, result.ok ? 200 : 502) +}) + // Canvas endpoints app.post("/api/v1/admin/canvas", async (c) => { const body = await parseBody(c) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a4590b19..af8a8731 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - packages/* onlyBuiltDependencies: + - electron - esbuild - sharp - workerd