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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- When adding new component styles, place them beside their peers in the scoped subdirectory (e.g., `src/styles/messaging/new-part.css`) and import them from the corresponding aggregator file.
- Prefer smaller, focused style files (≈150 lines or less) over large monoliths. Split by component or feature area if a file grows beyond that size.
- Co-locate reusable UI patterns (buttons, selectors, dropdowns, etc.) under `src/styles/components/` and avoid redefining the same utility classes elsewhere.
- Use the shared `.window-*` primitives from `src/styles/components/window.css` for dialog, popover, and floating-window headers, toolbars, bodies, footers, titles, and actions.
- Never use rounded corners in UI styling; keep corners square unless the user explicitly requests otherwise for a specific change.
- Document any new styling conventions or directory additions in this file so future changes remain consistent.

Expand Down
12 changes: 12 additions & 0 deletions packages/electron-app/electron/main/ipc-security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { BrowserWindow, IpcMainInvokeEvent } from "electron"

export function validateMainFrame(event: IpcMainInvokeEvent, window: BrowserWindow, allowedOrigins: string[]): void {
if (window.isDestroyed() || event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) {
throw new Error("Native IPC requires a registered main frame")
}
const current = new URL(window.webContents.getURL())
const sender = new URL(event.senderFrame.url)
if (current.origin !== sender.origin || !allowedOrigins.includes(sender.origin)) {
throw new Error("Native IPC requires an allowed renderer origin")
}
}
36 changes: 20 additions & 16 deletions packages/electron-app/electron/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { requestMicrophoneAccess } from "./permissions"
import type { DeveloperMode } from "./developer-mode"
import type { CliProcessManager } from "./process-manager"
import { openWorkspaceTarget, type WorkspaceEditor, type WorkspaceOpenTarget } from "./workspace-open"
import { setWorkspaceMenuEnabled } from "./menu"
import { popupTitlebarMenu, setWorkspaceMenuEnabled, type TitlebarMenu } from "./menu"
import { requireHttpUrl } from "./navigation-security"
import { validateMainFrame } from "./ipc-security"

interface LocalSender {
id: string
Expand All @@ -14,6 +15,7 @@ interface LocalSender {

interface CliIPCDependencies {
resolveLocal(sender: IpcMainInvokeEvent["sender"]): LocalSender | undefined
resolvePreferences?(sender: IpcMainInvokeEvent["sender"]): BrowserWindow | undefined
getAllowedOrigins(window: BrowserWindow): string[]
openRemoteWindow(payload: { id: string; name: string; baseUrl: string; entryUrl?: string; proxySessionId?: string; skipTlsVerify: boolean }): Promise<void>
newWindow(): Promise<unknown>
Expand All @@ -35,17 +37,6 @@ interface DialogOpenResult {
paths: string[]
}

function validateMainFrame(event: IpcMainInvokeEvent, window: BrowserWindow, allowedOrigins: string[]): void {
if (window.isDestroyed() || event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) {
throw new Error("Native IPC requires a registered main frame")
}
const current = new URL(window.webContents.getURL())
const sender = new URL(event.senderFrame.url)
if (current.origin !== sender.origin || !allowedOrigins.includes(sender.origin)) {
throw new Error("Native IPC requires an allowed renderer origin")
}
}

async function resolveLocalWorkspaceFolder(window: BrowserWindow, cliManager: CliProcessManager, instanceId: string, worktreeSlug: string): Promise<string> {
const baseUrl = cliManager.getStatus().url
if (!baseUrl) throw new Error("Local CodeNomad server is unavailable")
Expand Down Expand Up @@ -75,6 +66,13 @@ export function setupCliIPC(cliManager: CliProcessManager, dependencies: CliIPCD
validateMainFrame(event, record.window, dependencies.getAllowedOrigins(record.window))
return record
}
const settings = (event: IpcMainInvokeEvent): BrowserWindow => {
const record = dependencies.resolveLocal(event.sender)
const window = record?.window ?? dependencies.resolvePreferences?.(event.sender)
if (!window) throw new Error("Native settings operation requires a local application window")
validateMainFrame(event, window, dependencies.getAllowedOrigins(window))
return window
}
const anyTrusted = (event: IpcMainInvokeEvent): BrowserWindow => {
const window = BrowserWindow.fromWebContents(event.sender)
if (!window) throw new Error("Native operation requires a window")
Expand All @@ -91,8 +89,8 @@ export function setupCliIPC(cliManager: CliProcessManager, dependencies: CliIPCD
return false
}

ipcMain.handle("cli:getStatus", async (event) => { local(event); return cliManager.getStatus() })
ipcMain.handle("cli:restart", async (event) => { local(event); return cliManager.restart({ dev: process.env.NODE_ENV === "development" }) })
ipcMain.handle("cli:getStatus", async (event) => { settings(event); return cliManager.getStatus() })
ipcMain.handle("cli:restart", async (event) => { settings(event); return cliManager.restart({ dev: process.env.NODE_ENV === "development" }) })
ipcMain.handle("window:new", async (event) => { local(event); await dependencies.newWindow(); return { ok: true } })
ipcMain.handle("window:nextFolder", async (event) => dependencies.nextFolder(local(event).id))
ipcMain.handle("window:ackFolder", async (event, folder: unknown, opened: unknown) => {
Expand All @@ -101,6 +99,12 @@ export function setupCliIPC(cliManager: CliProcessManager, dependencies: CliIPCD
dependencies.acknowledgeFolder(id, folder, opened)
return { ok: true }
})
ipcMain.handle("menu:popup", async (event, menu: unknown, x: unknown, y: unknown) => {
const { window } = local(event)
if ((menu !== "file" && menu !== "edit" && menu !== "view" && menu !== "window" && menu !== "help")
|| typeof x !== "number" || typeof y !== "number") throw new Error("Invalid titlebar menu request")
popupTitlebarMenu(window, menu as TitlebarMenu, x, y)
})
ipcMain.handle("developer-mode:get", async (event) => {
local(event)
return dependencies.developerMode.state()
Expand All @@ -112,7 +116,7 @@ export function setupCliIPC(cliManager: CliProcessManager, dependencies: CliIPCD
})

ipcMain.handle("dialog:open", async (event, request: DialogOpenRequest): Promise<DialogOpenResult> => {
const { window } = local(event)
const window = settings(event)
if (!request || (request.mode !== "directory" && request.mode !== "file")) throw new Error("Invalid dialog request")
const properties: OpenDialogOptions["properties"] = request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]
if (request.mode === "file" && request.multiple) properties.push("multiSelections")
Expand Down Expand Up @@ -176,7 +180,7 @@ export function setupCliIPC(cliManager: CliProcessManager, dependencies: CliIPCD
return { granted: await requestMicrophoneAccess() }
})
ipcMain.handle("remote:openWindow", async (event, payload: { id: string; name: string; baseUrl: string; entryUrl?: string; proxySessionId?: string; skipTlsVerify: boolean }) => {
local(event)
settings(event)
if (!payload || typeof payload.id !== "string" || !payload.id.trim() || typeof payload.name !== "string" || typeof payload.baseUrl !== "string"
|| (payload.entryUrl !== undefined && typeof payload.entryUrl !== "string")
|| (payload.proxySessionId !== undefined && typeof payload.proxySessionId !== "string")
Expand Down
127 changes: 124 additions & 3 deletions packages/electron-app/electron/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { resolveFocusedLocalTarget, resolveWindowTarget } from "./menu-target"
import { MultiwindowLifecycle } from "./multiwindow-lifecycle"
import { decideNavigation, requireHttpUrl } from "./navigation-security"
import { configureMediaPermissionHandlers, isAllowedRendererOrigin } from "./permissions"
import { setupPreferencesIPC } from "./preferences-ipc"
import { createPreferencesUrl, PreferencesWindowRegistry, type PreferencesRequest } from "./preferences-window"
import { CliProcessManager } from "./process-manager"
import { navigateRemoteWindow, RemoteWindowRegistry } from "./remote-window-registry"
import { resolveConfiguredRendererOrigins } from "./renderer-origin"
Expand Down Expand Up @@ -117,6 +119,10 @@ function runPrimary(firstIntent: LaunchIntent) {
request.on("error", (error) => console.warn("[electron] failed to clean up remote proxy session", sessionId, error))
request.end()
})
const preferencesWindows = new PreferencesWindowRegistry()
let preferencesNavigation: ClientStateNavigationController | null = null
let preferencesTransition: { id: number; key: string; run: () => void } | undefined
let preferencesTransitionId = 0

const getAllowedOrigins = (window?: BrowserWindow | null): string[] => {
const origins = new Set(remoteOrigins.get(window?.id ?? -1) ?? [])
Expand Down Expand Up @@ -185,6 +191,8 @@ function runPrimary(firstIntent: LaunchIntent) {
(url) => {
backendTargetUrl = url
for (const record of registry.all()) void navigateBackend(record, url)
const preferences = preferencesWindows.current()
if (preferences) void navigatePreferences(preferences, url)
},
(error) => console.error("[cli] bootstrap token exchange failed", error),
)
Expand All @@ -195,13 +203,14 @@ function runPrimary(firstIntent: LaunchIntent) {
const window = new BrowserWindow({
width: bounds?.width ?? DEFAULT_WINDOW_WIDTH, height: bounds?.height ?? DEFAULT_WINDOW_HEIGHT,
...(bounds ? { x: bounds.x, y: bounds.y } : {}), useContentSize: true, minWidth: 800, minHeight: 600,
backgroundColor: "#1a1a1a", icon: getIconPath(),
frame: false, autoHideMenuBar: true, backgroundColor: "#1a1a1a", icon: getIconPath(),
webPreferences: {
preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, spellcheck: !isMac,
...(saved ? { zoomFactor: saved.zoomFactor } : {}),
additionalArguments: ["--codenomad-window-context=local", `--codenomad-window-id=${windowId}`],
},
})
if (!isMac) window.setMenuBarVisibility(false)
const nativeWindowId = window.id
const webContentsId = window.webContents.id
const navigation = new ClientStateNavigationController(window, {
Expand Down Expand Up @@ -259,11 +268,30 @@ function runPrimary(firstIntent: LaunchIntent) {
void firstLaunch.catch(() => {})

setupCliIPC(cli, {
resolveLocal: (sender) => registry.resolve(sender), getAllowedOrigins,
resolveLocal: (sender) => registry.resolve(sender), resolvePreferences: (sender) => preferencesWindows.resolve(sender), getAllowedOrigins,
openRemoteWindow, newWindow: () => intentQueue.enqueue({ newWindow: true, folders: [] }),
nextFolder: (id) => registry.nextFolder(id), acknowledgeFolder: (id, folder, opened) => registry.acknowledgeFolder(id, folder, opened),
developerMode,
})
setupPreferencesIPC(ipcMain, {
resolveLocal: (sender) => registry.resolve(sender),
resolvePreferences: (sender) => preferencesWindows.resolve(sender),
getAllowedOrigins,
openPreferences,
getRequest: (window) => preferencesWindows.request(window),
markReady: (window) => preferencesWindows.markReady(window),
acceptRequest: (window, request) => preferencesWindows.acceptRequest(window, request),
resolveTransition: (window, id, approved) => {
if (preferencesWindows.current() !== window || preferencesTransition?.id !== id) return
const transition = preferencesTransition
preferencesTransition = undefined
if (approved) {
preferencesWindows.prepareNavigation(window)
transition.run()
}
},
approveClose: (window) => preferencesWindows.approveClose(window),
})
lifecycle.registerAppEvents()
app.on("second-instance", (_event, argv, workingDirectory) => {
void intentQueue.enqueue(parseLaunchIntent(argvForLaunch(argv), workingDirectory || process.cwd())).catch(() => {})
Expand All @@ -288,14 +316,21 @@ function runPrimary(firstIntent: LaunchIntent) {
})
cli.on("status", (status) => {
registry.fanout("cli:status", status)
preferencesWindows.current()?.webContents.send("cli:status", status)
if (status.state !== "ready") {
bootstrap.reset()
backendUrl = null
backendTargetUrl = null
for (const record of registry.all()) void loadLoading(record, true)
const preferences = preferencesWindows.current()
if (preferences) void loadPreferencesLoading(preferences)
}
})
cli.on("error", (error) => registry.fanout("cli:error", { message: error.message }))
cli.on("error", (error) => {
const payload = { message: error.message }
registry.fanout("cli:error", payload)
preferencesWindows.current()?.webContents.send("cli:error", payload)
})

app.whenReady().then(async () => {
try { app.setAppUserModelId("ai.neuralnomads.codenomad.client") } catch {}
Expand Down Expand Up @@ -366,6 +401,92 @@ function runPrimary(firstIntent: LaunchIntent) {
}
})
}

async function openPreferences(request: PreferencesRequest): Promise<void> {
if (preferencesWindows.reuse(request)) return
if (!backendTargetUrl) throw new Error("Local CodeNomad server is unavailable")
const window = new BrowserWindow({
width: 1100, height: 760, minWidth: 760, minHeight: 560,
useContentSize: true, frame: false, autoHideMenuBar: true, backgroundColor: "#1a1a1a", icon: getIconPath(), title: "Preferences",
webPreferences: {
preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, spellcheck: !isMac,
additionalArguments: ["--codenomad-window-context=preferences"],
},
})
const nativeWindowId = window.id
const webContentsId = window.webContents.id
if (!isMac) window.setMenuBarVisibility(false)
preferencesWindows.register(window, request)
preferencesNavigation = new ClientStateNavigationController(window, {
clientStateManager: { isPrimary: false },
isTrustedOrigin: (url) => isAllowedRendererOrigin(url, getAllowedOrigins(window)),
reportFlushError: () => {},
lifecycle: navigationLifecycle,
})
setupNavigationGuards(window, preferencesNavigation, getAllowedOrigins, getLoadingUrl)
lifecycle.attachRemote(window)
window.webContents.on("page-title-updated", (event) => { event.preventDefault(); window.setTitle("Preferences") })
window.on("closed", () => {
remoteOrigins.delete(nativeWindowId)
insecureOrigins.delete(webContentsId)
preferencesNavigation = null
preferencesTransition = undefined
})
if (isMac) window.webContents.session.setSpellCheckerEnabled(false)
await navigatePreferences(window, backendTargetUrl)
}

async function navigatePreferences(window: BrowserWindow, url: string): Promise<void> {
if (preferencesWindows.isReady(window)) {
requestPreferencesTransition(window, `backend:${url}`, () => void navigatePreferencesNow(window, url))
return
}
await navigatePreferencesNow(window, url)
}

async function navigatePreferencesNow(window: BrowserWindow, url: string): Promise<void> {
const navigation = preferencesNavigation
const request = preferencesWindows.request(window)
if (!navigation || !request) return
const target = createPreferencesUrl(url, request.section)
await navigation.navigate(async (current, generation) => {
if (!navigation.isCurrent(generation)) return
await navigateRemoteWindow(current, target, new Set([target.origin]), remoteOrigins, insecureOrigins, false)
}).catch(async (error) => {
if (!isIgnorableNavigationError(error)) console.warn("[electron] failed to load Preferences; showing loading screen", error)
await loadPreferencesLoadingNow(window)
})
}

async function loadPreferencesLoading(window: BrowserWindow): Promise<void> {
if (preferencesWindows.isReady(window)) {
requestPreferencesTransition(window, "loading", () => void loadPreferencesLoadingNow(window))
return
}
await loadPreferencesLoadingNow(window)
}

function requestPreferencesTransition(window: BrowserWindow, key: string, run: () => void): void {
if (preferencesTransition?.key === key) return
const id = ++preferencesTransitionId
preferencesTransition = { id, key, run }
window.webContents.send("preferences:transition-requested", { id })
}

async function loadPreferencesLoadingNow(window: BrowserWindow): Promise<void> {
const navigation = preferencesNavigation
if (!navigation || preferencesWindows.current() !== window) return
preferencesWindows.suspendGuard(window)
const target = loadingTarget()
await navigation.navigate(async (current, generation) => {
if (!navigation.isCurrent(generation)) return
await (target.url ? current.loadURL(target.url) : current.loadFile(target.file!))
if (navigation.isCurrent(generation)) remoteOrigins.delete(current.id)
}).catch((error) => {
preferencesWindows.cancelNavigation(window)
if (!isIgnorableNavigationError(error)) console.error("[electron] failed to load Preferences loading screen", error)
})
}
}

function setupNavigationGuards(
Expand Down
Loading
Loading