Skip to content

Commit 19a4c3d

Browse files
authored
add remote server launcher flow (#277)
## Summary - add a remote CodeNomad server launcher flow in the home screen, including saved server profiles, probe-before-connect behavior, and desktop bridge APIs for opening remote windows - add Electron support for remote server windows with per-window origin handling and self-signed certificate bypass, plus Tauri support for remote windows with clearer self-signed guidance - fix Tauri dev server resolution and window shutdown behavior so dev mode prefers the source server entry and the app only exits after the last window closes
2 parents 893d5f9 + 1050692 commit 19a4c3d

22 files changed

Lines changed: 1411 additions & 199 deletions

File tree

packages/electron-app/electron/main/ipc.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,28 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
117117
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
118118
)
119119

120+
ipcMain.handle(
121+
"remote:openWindow",
122+
async (
123+
_event,
124+
payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean },
125+
): Promise<{ ok: boolean }> => {
126+
const opener = (mainWindow as BrowserWindow & {
127+
__codenomadOpenRemoteWindow?: (payload: {
128+
id: string
129+
name: string
130+
baseUrl: string
131+
skipTlsVerify: boolean
132+
}) => Promise<void>
133+
}).__codenomadOpenRemoteWindow
134+
if (!opener) {
135+
throw new Error("Remote window opening is not available")
136+
}
137+
await opener(payload)
138+
return { ok: true }
139+
},
140+
)
141+
120142
ipcMain.handle(
121143
"notifications:show",
122144
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {

packages/electron-app/electron/main/main.ts

Lines changed: 133 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ let pendingCliUrl: string | null = null
2121
let pendingBootstrapToken: string | null = null
2222
let showingLoadingScreen = false
2323
let preloadingView: BrowserView | null = null
24+
const remoteWindowOrigins = new Map<number, Set<string>>()
25+
const insecureWindowOrigins = new Map<number, Set<string>>()
2426

2527
if (isMac) {
2628
app.commandLine.appendSwitch("disable-spell-checking")
@@ -93,8 +95,13 @@ function loadLoadingScreen(window: BrowserWindow) {
9395
})
9496
}
9597

96-
function getAllowedRendererOrigins(): string[] {
98+
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
9799
const origins = new Set<string>()
100+
if (window) {
101+
for (const origin of remoteWindowOrigins.get(window.id) ?? []) {
102+
origins.add(origin)
103+
}
104+
}
98105
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
99106
for (const candidate of rendererCandidates) {
100107
if (!candidate) {
@@ -109,13 +116,13 @@ function getAllowedRendererOrigins(): string[] {
109116
return Array.from(origins)
110117
}
111118

112-
function shouldOpenExternally(url: string): boolean {
119+
function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean {
113120
try {
114121
const parsed = new URL(url)
115122
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
116123
return true
117124
}
118-
const allowedOrigins = getAllowedRendererOrigins()
125+
const allowedOrigins = getAllowedRendererOrigins(window)
119126
return !allowedOrigins.includes(parsed.origin)
120127
} catch {
121128
return false
@@ -128,21 +135,62 @@ function setupNavigationGuards(window: BrowserWindow) {
128135
}
129136

130137
window.webContents.setWindowOpenHandler(({ url }) => {
131-
if (shouldOpenExternally(url)) {
138+
if (shouldOpenExternally(url, window)) {
132139
handleExternal(url)
133140
return { action: "deny" }
134141
}
135142
return { action: "allow" }
136143
})
137144

138145
window.webContents.on("will-navigate", (event, url) => {
139-
if (shouldOpenExternally(url)) {
146+
if (shouldOpenExternally(url, window)) {
140147
event.preventDefault()
141148
handleExternal(url)
142149
}
143150
})
144151
}
145152

153+
function setWindowAllowedOrigin(window: BrowserWindow, url: string) {
154+
try {
155+
const origin = new URL(url).origin
156+
remoteWindowOrigins.set(window.id, new Set([origin]))
157+
} catch (error) {
158+
console.warn("[cli] failed to store allowed origin", url, error)
159+
}
160+
}
161+
162+
function clearWindowAllowedOrigin(window: BrowserWindow) {
163+
remoteWindowOrigins.delete(window.id)
164+
}
165+
166+
function addWindowInsecureOrigin(window: BrowserWindow, url: string) {
167+
try {
168+
const origin = new URL(url).origin
169+
insecureWindowOrigins.set(window.id, new Set([origin]))
170+
} catch (error) {
171+
console.warn("[cli] failed to store insecure origin", url, error)
172+
}
173+
}
174+
175+
function clearWindowInsecureOrigin(window: BrowserWindow) {
176+
insecureWindowOrigins.delete(window.id)
177+
}
178+
179+
function isInsecureOriginAllowed(url: string) {
180+
try {
181+
const targetOrigin = new URL(url).origin
182+
for (const origins of insecureWindowOrigins.values()) {
183+
if (origins.has(targetOrigin)) {
184+
return true
185+
}
186+
}
187+
} catch {
188+
return false
189+
}
190+
191+
return false
192+
}
193+
146194
let cachedPreloadPath: string | null = null
147195
function getPreloadPath() {
148196
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
@@ -207,25 +255,30 @@ function createWindow() {
207255
},
208256
})
209257

210-
setupNavigationGuards(mainWindow)
258+
const window = mainWindow
259+
260+
setupNavigationGuards(window)
211261

212262
if (isMac) {
213-
mainWindow.webContents.session.setSpellCheckerEnabled(false)
263+
window.webContents.session.setSpellCheckerEnabled(false)
214264
}
215265

216266
showingLoadingScreen = true
217267
currentCliUrl = null
218-
loadLoadingScreen(mainWindow)
268+
clearWindowAllowedOrigin(window)
269+
loadLoadingScreen(window)
219270

220271
if (process.env.NODE_ENV === "development") {
221-
mainWindow.webContents.openDevTools({ mode: "detach" })
272+
window.webContents.openDevTools({ mode: "detach" })
222273
}
223274

224-
createApplicationMenu(mainWindow)
225-
setupCliIPC(mainWindow, cliManager)
275+
createApplicationMenu(window)
276+
setupCliIPC(window, cliManager)
226277

227-
mainWindow.on("closed", () => {
278+
window.on("closed", () => {
228279
destroyPreloadingView()
280+
clearWindowAllowedOrigin(window)
281+
clearWindowInsecureOrigin(window)
229282
mainWindow = null
230283
currentCliUrl = null
231284
pendingCliUrl = null
@@ -322,10 +375,66 @@ function finalizeCliSwap(url: string) {
322375
return
323376
}
324377

378+
const window = mainWindow
325379
showingLoadingScreen = false
326380
currentCliUrl = url
381+
setWindowAllowedOrigin(window, url)
327382
pendingCliUrl = null
328-
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
383+
window.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
384+
}
385+
386+
function buildRemoteWindowTitle(name: string, baseUrl: string) {
387+
try {
388+
const parsed = new URL(baseUrl)
389+
return `${name} - ${parsed.host}`
390+
} catch {
391+
return `${name} - ${baseUrl}`
392+
}
393+
}
394+
395+
function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) {
396+
const escapedName = name.replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
397+
const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
398+
const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
399+
return `<!doctype html><html><head><meta charset="utf-8" /><title>${escapedName}</title><style>body{margin:0;background:#111827;color:#f9fafb;font-family:Inter,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}main{max-width:560px;width:100%;background:rgba(17,24,39,.88);border:1px solid rgba(255,255,255,.08);border-radius:20px;padding:28px;box-shadow:0 25px 60px rgba(0,0,0,.45)}h1{margin:0 0 10px;font-size:1.5rem}p{margin:0 0 10px;color:#cbd5e1;line-height:1.5}code{display:block;margin-top:16px;padding:12px 14px;border-radius:12px;background:#0f172a;color:#bfdbfe;overflow:auto}</style></head><body><main><h1>${escapedName}</h1><p>Could not connect to the remote server.</p><p>${escapedMessage}</p><code>${escapedUrl}</code></main></body></html>`
400+
}
401+
402+
async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }) {
403+
const targetUrl = new URL(payload.baseUrl)
404+
const title = buildRemoteWindowTitle(payload.name, payload.baseUrl)
405+
const window = new BrowserWindow({
406+
width: 1400,
407+
height: 900,
408+
minWidth: 800,
409+
minHeight: 600,
410+
backgroundColor: "#1a1a1a",
411+
icon: getIconPath(),
412+
title,
413+
webPreferences: {
414+
preload: getPreloadPath(),
415+
contextIsolation: true,
416+
nodeIntegration: false,
417+
spellcheck: !isMac,
418+
},
419+
})
420+
421+
setWindowAllowedOrigin(window, targetUrl.toString())
422+
if (payload.skipTlsVerify) {
423+
addWindowInsecureOrigin(window, targetUrl.toString())
424+
}
425+
426+
setupNavigationGuards(window)
427+
window.on("closed", () => {
428+
clearWindowAllowedOrigin(window)
429+
clearWindowInsecureOrigin(window)
430+
})
431+
432+
try {
433+
await window.loadURL(targetUrl.toString())
434+
} catch (error) {
435+
const message = error instanceof Error ? error.message : String(error)
436+
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildRemoteErrorHtml(payload.name, payload.baseUrl, message))}`)
437+
}
329438
}
330439

331440
let bootstrapExchangeInFlight = false
@@ -504,6 +613,17 @@ app.whenReady().then(() => {
504613
}
505614

506615
createWindow()
616+
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
617+
618+
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
619+
if (isInsecureOriginAllowed(url)) {
620+
event.preventDefault()
621+
console.warn("[cli] allowing insecure remote certificate for", url, error)
622+
callback(true)
623+
return
624+
}
625+
callback(false)
626+
})
507627

508628
app.on("activate", () => {
509629
if (BrowserWindow.getAllWindows().length === 0) {

packages/electron-app/electron/preload/index.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const electronAPI = {
2323
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
2424
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
2525
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
26+
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
2627
}
2728

2829
contextBridge.exposeInMainWorld("electronAPI", electronAPI)

packages/server/src/api-types.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,32 @@ export interface VoiceModeStateResponse {
244244
enabled: boolean
245245
}
246246

247+
export interface RemoteServerProfile {
248+
id: string
249+
name: string
250+
baseUrl: string
251+
skipTlsVerify: boolean
252+
createdAt: string
253+
updatedAt: string
254+
lastConnectedAt?: string
255+
}
256+
257+
export interface RemoteServerProbeRequest {
258+
baseUrl: string
259+
skipTlsVerify?: boolean
260+
}
261+
262+
export interface RemoteServerProbeResponse {
263+
ok: boolean
264+
reachable: boolean
265+
normalizedUrl: string
266+
skipTlsVerify: boolean
267+
requiresAuth: boolean
268+
authenticated: boolean
269+
error?: string
270+
errorCode?: string
271+
}
272+
247273
export type WorkspaceEventType =
248274
| "workspace.created"
249275
| "workspace.started"

packages/server/src/server/http-server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { registerPluginRoutes } from "./routes/plugin"
2222
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
2323
import { registerWorktreeRoutes } from "./routes/worktrees"
2424
import { registerSpeechRoutes } from "./routes/speech"
25+
import { registerRemoteServerRoutes } from "./routes/remote-servers"
2526
import { ServerMeta } from "../api-types"
2627
import { InstanceStore } from "../storage/instance-store"
2728
import { BackgroundProcessManager } from "../background-processes/manager"
@@ -270,6 +271,7 @@ export function createHttpServer(deps: HttpServerDeps) {
270271
eventBus: deps.eventBus,
271272
workspaceManager: deps.workspaceManager,
272273
})
274+
registerRemoteServerRoutes(app, { logger: apiLogger })
273275
registerSpeechRoutes(app, { speechService: deps.speechService })
274276
registerPluginRoutes(app, {
275277
workspaceManager: deps.workspaceManager,

0 commit comments

Comments
 (0)