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.

This commit is contained in:
Nikita
2025-12-28 12:15:13 -08:00
parent c073fe6ee0
commit 3f2d571592
9 changed files with 240 additions and 31 deletions

View File

@@ -365,9 +365,75 @@ shortcuts = ["d"]
[[tasks]] [[tasks]]
name = "desktop" name = "desktop"
command = """ 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"] dependencies = ["node", "pnpm"]
shortcuts = ["desk"] shortcuts = ["desk"]

View File

@@ -1,14 +1,17 @@
# Linsa desktop # 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 ## Running locally
```bash ```bash
pnpm install pnpm install
pnpm --filter @linsa/web dev
pnpm --filter @linsa/desktop 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: Set a Jazz Cloud key to sync state instead of keeping it only on the device:
```bash ```bash
@@ -18,9 +21,15 @@ echo "VITE_JAZZ_API_KEY=your_jazz_key" >> .env
# echo "VITE_JAZZ_PEER=ws://localhost:4200" >> .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 ## What it does
- Uses `electron-vite` to bundle `main`, `preload`, and the React renderer. - 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. - 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. - 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. - Scans those folders for git repos and lets you open them in VS Code, Terminal, or Finder.

View File

@@ -4,6 +4,8 @@ import { fileURLToPath } from "node:url";
import { readdir, stat } from "node:fs/promises"; import { readdir, stat } from "node:fs/promises";
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron"; import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = "true";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
interface GitRepo { interface GitRepo {
@@ -50,13 +52,24 @@ async function findGitRepos(dir: string, maxDepth = 4): Promise<GitRepo[]> {
return repos; 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() { function createWindow() {
const preloadJs = join(__dirname, "../preload/index.js"); const preloadPath = resolvePreload();
const preloadCjs = join(__dirname, "../preload/index.cjs"); const webDevUrl =
const preloadMjs = join(__dirname, "../preload/index.mjs"); process.env.WEB_DEV_URL ??
const preloadPath = [preloadJs, preloadCjs, preloadMjs].find((p) => process.env.VITE_WEB_DEV_URL ??
existsSync(p), "http://localhost:5625";
); const webProdUrl = process.env.WEB_URL ?? process.env.VITE_WEB_URL;
const targetUrl = webProdUrl ?? webDevUrl;
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 1000, width: 1000,
@@ -79,16 +92,22 @@ function createWindow() {
return { action: "deny" }; return { action: "deny" };
}); });
if (process.env.ELECTRON_RENDERER_URL) { mainWindow
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL); .loadURL(targetUrl)
mainWindow.webContents.openDevTools(); .catch(() => mainWindow.loadFile(join(__dirname, "../renderer/index.html")));
} else {
mainWindow.loadFile(join(__dirname, "../renderer/index.html")); if (!webProdUrl) {
mainWindow.webContents.openDevTools({ mode: "bottom" });
} }
return mainWindow; 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<string | null> => { ipcMain.handle("dialog:pick-folder", async (): Promise<string | null> => {
const result = await dialog.showOpenDialog({ const result = await dialog.showOpenDialog({
title: "Select code folder", title: "Select code folder",
@@ -102,6 +121,8 @@ ipcMain.handle("dialog:pick-folder", async (): Promise<string | null> => {
return result.filePaths[0]; return result.filePaths[0];
}); });
ipcMain.handle("app:get-version", () => app.getVersion());
ipcMain.handle("repos:scan", async (_event, folders: string[]): Promise<GitRepo[]> => { ipcMain.handle("repos:scan", async (_event, folders: string[]): Promise<GitRepo[]> => {
const allRepos: GitRepo[] = []; const allRepos: GitRepo[] = [];
@@ -121,15 +142,23 @@ ipcMain.handle("repos:scan", async (_event, folders: string[]): Promise<GitRepo[
}); });
ipcMain.handle("shell:show-in-folder", async (_event, path: string) => { ipcMain.handle("shell:show-in-folder", async (_event, path: string) => {
if (!path) return;
shell.showItemInFolder(path);
});
ipcMain.handle("shell:show-path", async (_event, path: string) => {
if (!path) return;
shell.showItemInFolder(path); shell.showItemInFolder(path);
}); });
ipcMain.handle("shell:open-in-editor", async (_event, path: string) => { ipcMain.handle("shell:open-in-editor", async (_event, path: string) => {
if (!path) return;
const { exec } = await import("node:child_process"); const { exec } = await import("node:child_process");
exec(`code "${path}"`); exec(`code "${path}"`);
}); });
ipcMain.handle("shell:open-in-terminal", async (_event, path: string) => { ipcMain.handle("shell:open-in-terminal", async (_event, path: string) => {
if (!path) return;
const { exec } = await import("node:child_process"); const { exec } = await import("node:child_process");
if (process.platform === "darwin") { if (process.platform === "darwin") {
exec(`open -a Terminal "${path}"`); exec(`open -a Terminal "${path}"`);

View File

@@ -6,10 +6,24 @@ export interface GitRepo {
lastModified: number; lastModified: number;
} }
export interface DesktopAPI {
openExternal: (url: string) => Promise<void>;
showPath: (path: string) => Promise<void>;
showInFolder: (path: string) => Promise<void>;
pickFolder: () => Promise<string | null>;
getVersion: () => Promise<string>;
scanRepos: (folders: string[]) => Promise<GitRepo[]>;
openInEditor: (path: string) => Promise<void>;
openInTerminal: (path: string) => Promise<void>;
}
contextBridge.exposeInMainWorld("electronAPI", { contextBridge.exposeInMainWorld("electronAPI", {
pickFolder: () => ipcRenderer.invoke("dialog:pick-folder") as Promise<string | null>, openExternal: (url: string) => ipcRenderer.invoke("shell:open-external", url),
scanRepos: (folders: string[]) => ipcRenderer.invoke("repos:scan", folders) as Promise<GitRepo[]>, showPath: (path: string) => ipcRenderer.invoke("shell:show-path", path),
showInFolder: (path: string) => ipcRenderer.invoke("shell:show-in-folder", path), showInFolder: (path: string) => ipcRenderer.invoke("shell:show-in-folder", path),
pickFolder: () => ipcRenderer.invoke("dialog:pick-folder") as Promise<string | null>,
getVersion: () => ipcRenderer.invoke("app:get-version") as Promise<string>,
scanRepos: (folders: string[]) => ipcRenderer.invoke("repos:scan", folders) as Promise<GitRepo[]>,
openInEditor: (path: string) => ipcRenderer.invoke("shell:open-in-editor", path), openInEditor: (path: string) => ipcRenderer.invoke("shell:open-in-editor", path),
openInTerminal: (path: string) => ipcRenderer.invoke("shell:open-in-terminal", path), openInTerminal: (path: string) => ipcRenderer.invoke("shell:open-in-terminal", path),
}); } satisfies DesktopAPI);

View File

@@ -1,20 +1,8 @@
interface GitRepo { import type { DesktopAPI } from "../preload";
name: string;
path: string;
lastModified: number;
}
interface ElectronAPI {
pickFolder: () => Promise<string | null>;
scanRepos: (folders: string[]) => Promise<GitRepo[]>;
showInFolder: (path: string) => Promise<void>;
openInEditor: (path: string) => Promise<void>;
openInTerminal: (path: string) => Promise<void>;
}
declare global { declare global {
interface Window { interface Window {
electronAPI: ElectronAPI; electronAPI: DesktopAPI;
} }
} }

View File

@@ -13,6 +13,7 @@
"wrangler": "^4.53.0" "wrangler": "^4.53.0"
}, },
"dependencies": { "dependencies": {
"@1focus/logs": "file:/Users/nikiv/lang/ts/lib/1focus/packages/logs",
"drizzle-orm": "^0.45.0", "drizzle-orm": "^0.45.0",
"drizzle-zod": "^0.8.3", "drizzle-zod": "^0.8.3",
"hono": "^4.10.4", "hono": "^4.10.4",

View File

@@ -116,6 +116,23 @@ Authentication:
- `PATCH /api/v1/admin/browser-sessions/:sessionId` - `PATCH /api/v1/admin/browser-sessions/:sessionId`
- `DELETE /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) ### Example (create chat thread)
```bash ```bash

View File

@@ -1,6 +1,7 @@
import { Hono, type Context, type MiddlewareHandler } from "hono" import { Hono, type Context, type MiddlewareHandler } from "hono"
import { cors } from "hono/cors" import { cors } from "hono/cors"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { createLogsClient, type LogPayload, type LogsWriteResult } from "@1focus/logs"
import { import {
browser_session_tabs, browser_session_tabs,
browser_sessions, browser_sessions,
@@ -17,6 +18,9 @@ type Env = {
ADMIN_API_KEY?: string ADMIN_API_KEY?: string
DATABASE_URL?: string DATABASE_URL?: string
HYPERDRIVE?: Hyperdrive HYPERDRIVE?: Hyperdrive
FOCUS_LOGS_API_KEY?: string
FOCUS_LOGS_ENDPOINT?: string
FOCUS_LOGS_SERVER?: string
} }
// Create a new Hono app // Create a new Hono app
@@ -62,6 +66,40 @@ const parseBody = async (c: Context<AppEnv>) => {
return (await c.req.json().catch(() => ({}))) as Record<string, unknown> return (await c.req.json().catch(() => ({}))) as Record<string, unknown>
} }
const isRecord = (value: unknown): value is Record<string, unknown> =>
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<AppEnv>,
payload: LogPayload,
awaitResult = false,
): Promise<LogsWriteResult | null> => {
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 parseInteger = (value: unknown) => {
const numberValue = const numberValue =
typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN
@@ -127,9 +165,55 @@ app.get("/", (c) => {
// Example API endpoint // Example API endpoint
app.get("/api/v1/hello", (c) => { app.get("/api/v1/hello", (c) => {
const name = c.req.query("name") || "World" const name = c.req.query("name") || "World"
void logTo1Focus(c, {
message: "hello endpoint called",
level: "info",
meta: { name },
})
return c.json({ message: `Hello, ${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 // Canvas endpoints
app.post("/api/v1/admin/canvas", async (c) => { app.post("/api/v1/admin/canvas", async (c) => {
const body = await parseBody(c) const body = await parseBody(c)

View File

@@ -2,6 +2,7 @@ packages:
- packages/* - packages/*
onlyBuiltDependencies: onlyBuiltDependencies:
- electron
- esbuild - esbuild
- sharp - sharp
- workerd - workerd