mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 20:00:23 +01:00
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:
70
flow.toml
70
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"]
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<GitRepo[]> {
|
||||
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<string | null> => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: "Select code folder",
|
||||
@@ -102,6 +121,8 @@ ipcMain.handle("dialog:pick-folder", async (): Promise<string | null> => {
|
||||
return result.filePaths[0];
|
||||
});
|
||||
|
||||
ipcMain.handle("app:get-version", () => app.getVersion());
|
||||
|
||||
ipcMain.handle("repos:scan", async (_event, folders: string[]): Promise<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) => {
|
||||
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}"`);
|
||||
|
||||
@@ -6,10 +6,24 @@ export interface GitRepo {
|
||||
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", {
|
||||
pickFolder: () => ipcRenderer.invoke("dialog:pick-folder") as Promise<string | null>,
|
||||
scanRepos: (folders: string[]) => ipcRenderer.invoke("repos:scan", folders) as Promise<GitRepo[]>,
|
||||
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<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),
|
||||
openInTerminal: (path: string) => ipcRenderer.invoke("shell:open-in-terminal", path),
|
||||
});
|
||||
} satisfies DesktopAPI);
|
||||
|
||||
16
packages/desktop/src/renderer/env.d.ts
vendored
16
packages/desktop/src/renderer/env.d.ts
vendored
@@ -1,20 +1,8 @@
|
||||
interface GitRepo {
|
||||
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>;
|
||||
}
|
||||
import type { DesktopAPI } from "../preload";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
electronAPI: DesktopAPI;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<AppEnv>) => {
|
||||
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 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)
|
||||
|
||||
@@ -2,6 +2,7 @@ packages:
|
||||
- packages/*
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- electron
|
||||
- esbuild
|
||||
- sharp
|
||||
- workerd
|
||||
|
||||
Reference in New Issue
Block a user