Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
push:
branches: [main]

permissions:
contents: read

jobs:
gates:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -56,3 +59,12 @@ jobs:
- name: Production quality gate
working-directory: ./frontend
run: npm run check:quality

release:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [gates, controller, frontend]
permissions:
contents: write
issues: write
pull-requests: write
uses: ./.github/workflows/release.yml
32 changes: 20 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,39 +1,47 @@
name: Release

on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
workflow_call:

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
queue: max

permissions:
contents: write
issues: write
pull-requests: write
contents: read

jobs:
release:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}

- name: Select tested main revision
run: git checkout -B main "${{ github.event.workflow_run.head_sha }}"
ref: ${{ github.sha }}

- uses: actions/setup-node@v4
with:
node-version: 22.19.0

- name: Verify tested main revision
id: revision
env:
TESTED_SHA: ${{ github.sha }}
run: node scripts/release-revision.mjs

- name: Configure git author
if: steps.revision.outputs.current == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

- name: Release
if: steps.revision.outputs.current == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
Expand Down
3 changes: 2 additions & 1 deletion frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,12 @@ next-env.d.ts

# desktop artifacts
/desktop/dist/
/dist-desktop/
/dist-desktop
/dist-installers/

# generated desktop Pi runtime
/.desktop-pi-runtime/
/.desktop-runtime/

# local replay-parity goldens over real pi sessions (private content, never commit)
/scripts/.parity-goldens/
Expand Down
11 changes: 11 additions & 0 deletions frontend/desktop/configs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { app } from "electron";
import path from "node:path";
import { verifiedPackagedRuntime } from "./runtime-identity";

const DEFAULT_DEV_SERVER_URL = "http://127.0.0.1:3000";

Expand Down Expand Up @@ -40,3 +41,13 @@ export function resolveStaticAssetsSource(): { staticDir: string; publicDir: str
publicDir: path.resolve(__dirname, "..", "..", "public"),
};
}

export function resolvePackagedRuntimeEnvironment(): NodeJS.ProcessEnv {
if (!app.isPackaged) return {};
const runtimeDir = path.join(process.resourcesPath, "app", "runtime");
const runtime = verifiedPackagedRuntime(runtimeDir);
return {
LOCAL_STUDIO_NODE_RUNTIME: runtime.nodeRuntime,
LOCAL_STUDIO_NODE_RUNTIME_MANIFEST: runtime.manifestPath,
};
}
8 changes: 8 additions & 0 deletions frontend/desktop/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ productName: Local Studio
asar: true

afterPack: ./scripts/electron-builder-after-pack.mjs
afterSign: ./scripts/electron-builder-after-sign.mjs
beforePack: ./scripts/stage-desktop-runtime.mjs

asarUnpack:
- "node_modules/@lydell/**/*.node"
Expand All @@ -11,10 +13,16 @@ asarUnpack:
files:
- desktop/dist/**
- package.json
- node_modules/effect/dist/**
- node_modules/effect/package.json
- node_modules/@lydell/node-pty/**
- node_modules/@lydell/node-pty-${platform}-${arch}/**

extraResources:
- from: .desktop-runtime/${os}-${arch}
to: app/runtime
filter:
- "**/*"
- from: ../services/agent-runtime/dist/standalone.mjs
to: app/agent-runtime/server.mjs
- from: .next/standalone
Expand Down
1 change: 1 addition & 0 deletions frontend/desktop/executable-identity.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = module.require("../../services/agent-runtime/src/executable-identity.cjs");
1 change: 1 addition & 0 deletions frontend/desktop/executable-identity.d.cts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "../../services/agent-runtime/src/executable-identity.cjs";
38 changes: 38 additions & 0 deletions frontend/desktop/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,45 @@ export interface ControllerDeployBridge {
onLog(listener: (line: string) => void): () => void;
}

export interface ConnectorApprovalsBridge {
list(): Promise<unknown>;
decide(requestId: string, decision: "approve" | "deny"): Promise<void>;
}

export interface ConnectorManagementBridge {
list(): Promise<unknown>;
save(payload: string): Promise<unknown>;
remove(id: string): Promise<unknown>;
probe(id: string): Promise<string>;
}

export interface PluginManagementBridge {
list(): Promise<string>;
setEnabled(id: string, enabled: boolean): Promise<string>;
}

export interface GitHubArtifactManagementBridge {
status(): Promise<string>;
install(): Promise<string>;
}

export type GoogleAccountId = "gmail" | "google-calendar";

export interface GoogleAccountManagementBridge {
get(): Promise<string>;
saveClient(payload: string): Promise<string>;
disconnect(account: GoogleAccountId): Promise<string>;
beginAuthorization(account: GoogleAccountId): Promise<string>;
cancelAuthorization(account: GoogleAccountId): Promise<string>;
}

export interface DesktopBridge {
getRuntime(): Promise<{
platform: NodeJS.Platform;
appVersion: string;
chromeVersion: string;
electronVersion: string;
mode?: "dev-server" | "embedded-standalone";
}>;
openExternal(url: string): Promise<boolean>;
getUpdateStatus(): Promise<DesktopUpdateSnapshot>;
Expand All @@ -103,4 +136,9 @@ export interface DesktopBridge {
terminal: PtyBridge;
quickPanel: QuickPanelBridge;
controllerDeploy: ControllerDeployBridge;
connectorApprovals: ConnectorApprovalsBridge;
connectors: ConnectorManagementBridge;
plugins: PluginManagementBridge;
githubArtifact: GitHubArtifactManagementBridge;
googleAccount: GoogleAccountManagementBridge;
}
3 changes: 2 additions & 1 deletion frontend/desktop/logic/agent-runtime-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { app } from "electron";
import { existsSync } from "node:fs";
import path from "node:path";
import { fork, type ChildProcess } from "node:child_process";
import { DESKTOP_CONFIG } from "../configs";
import { DESKTOP_CONFIG, resolvePackagedRuntimeEnvironment } from "../configs";
import { log } from "../helpers/logger";
import { resolveStablePort } from "../helpers/ports";
import { resolveAugmentedPath } from "../helpers/resolve-path";
Expand Down Expand Up @@ -113,6 +113,7 @@ export async function startAgentRuntime(
PORT: String(port),
LOCAL_STUDIO_DATA_DIR: DESKTOP_CONFIG.userDataDir,
LOCAL_STUDIO_RESOURCES_PATH: process.resourcesPath,
...resolvePackagedRuntimeEnvironment(),
LOCAL_STUDIO_AGENT_CWD: process.env.LOCAL_STUDIO_AGENT_CWD || app.getPath("home"),
LOCAL_STUDIO_FRONTEND_BASE: options.frontendUrl,
},
Expand Down
27 changes: 26 additions & 1 deletion frontend/desktop/logic/app-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { app } from "electron";
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fork, type ChildProcess } from "node:child_process";
import { DESKTOP_CONFIG, resolveStandaloneBaseDir, resolveStaticAssetsSource } from "../configs";
import {
DESKTOP_CONFIG,
resolvePackagedRuntimeEnvironment,
resolveStandaloneBaseDir,
resolveStaticAssetsSource,
} from "../configs";
import type { DesktopServerRuntime } from "../types";
import { log } from "../helpers/logger";
import { registerOAuthVault } from "./oauth-vault";
Expand All @@ -13,6 +18,11 @@ import {
stopAgentRuntime,
type AgentRuntimeHandle,
} from "./agent-runtime-server";
import {
childProcessConnectorApprovalTransport,
createConnectorApprovalProcessClient,
type ConnectorApprovalProcessClient,
} from "./connector-approval-ipc-client";

// The most recently forked embedded server. A single process-exit hook kills
// whichever child is current — registering a fresh once("exit") per (re)start
Expand All @@ -26,6 +36,7 @@ process.once("exit", () => {

interface ServerHandle {
agentRuntime: AgentRuntimeHandle;
connectorApprovals?: ConnectorApprovalProcessClient;
runtime: DesktopServerRuntime;
process?: ChildProcess;
}
Expand Down Expand Up @@ -222,13 +233,24 @@ export async function startFrontendServer(
LOCAL_STUDIO_DESKTOP: "1",
LOCAL_STUDIO_DATA_DIR: DESKTOP_CONFIG.userDataDir,
LOCAL_STUDIO_RESOURCES_PATH: process.resourcesPath,
...resolvePackagedRuntimeEnvironment(),
LOCAL_STUDIO_SSH_REMOTE_MCP_PATH: path.join(
process.resourcesPath,
"desktop",
"resources",
"mcp",
"ssh-remote.mjs",
),
LOCAL_STUDIO_AGENT_CWD: process.env.LOCAL_STUDIO_AGENT_CWD || app.getPath("home"),
LOCAL_STUDIO_AGENT_RUNTIME_URL: agentRuntime.url,
LOCAL_STUDIO_FRONTEND_BASE: url,
},
});

registerOAuthVault(child, DESKTOP_CONFIG.userDataDir);
const connectorApprovals = createConnectorApprovalProcessClient(
childProcessConnectorApprovalTransport(child),
);

child.stdout?.on("data", (chunk: Buffer | string) => {
log.info(`frontend: ${String(chunk).trim()}`);
Expand Down Expand Up @@ -263,6 +285,7 @@ export async function startFrontendServer(
} catch (error) {
await stopFrontendServer({
agentRuntime,
connectorApprovals,
process: child,
runtime: { mode: "embedded-standalone", port, url },
});
Expand All @@ -271,6 +294,7 @@ export async function startFrontendServer(

return {
agentRuntime,
connectorApprovals,
runtime: {
mode: "embedded-standalone",
port,
Expand All @@ -282,6 +306,7 @@ export async function startFrontendServer(

export async function stopFrontendServer(handle?: ServerHandle): Promise<void> {
if (!handle) return;
handle.connectorApprovals?.close();
if (handle.process) {
const child = handle.process;
const pid = child.pid;
Expand Down
Loading