@hydraharness/harness-desktop 0.1.1-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/main.cjs ADDED
@@ -0,0 +1,1627 @@
1
+ 'use strict'
2
+
3
+ const { once } = require('node:events')
4
+ const { spawn } = require('node:child_process')
5
+ const { randomUUID } = require('node:crypto')
6
+ const { existsSync } = require('node:fs')
7
+ const { glob, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, symlink, writeFile } = require('node:fs/promises')
8
+ const { tmpdir } = require('node:os')
9
+ const { basename, dirname, isAbsolute, join, relative, resolve, sep } = require('node:path')
10
+ const { createInterface } = require('node:readline')
11
+ const { setTimeout: delay } = require('node:timers/promises')
12
+ const { stripVTControlCharacters } = require('node:util')
13
+ const { app, BrowserWindow, dialog, ipcMain, nativeImage, nativeTheme, shell, utilityProcess } = require('electron')
14
+ const nodePty = require('node-pty')
15
+
16
+ const CLI_ENTRY = join(require.resolve('@hydraharness/harness/package.json'), '..', 'lib', 'bin.js')
17
+ const BROWSER_ENTRY = join(require.resolve('@hydraharness/harness-browser-electron/package.json'), '..', 'electron-app', 'main.cjs')
18
+ const DESKTOP_ICON = join(__dirname, 'assets', process.platform === 'win32' ? 'hydra.ico' : 'hydra.png')
19
+ const SMOKE_ZOOM = process.argv.includes('--smoke-zoom')
20
+ const SMOKE = process.argv.includes('--smoke') || SMOKE_ZOOM
21
+ const HOST_READY_TIMEOUT_MS = 90_000
22
+ const HOST_REQUEST_TIMEOUT_MS = 15_000
23
+ const HOST_SHUTDOWN_TIMEOUT_MS = 7_000
24
+ const MAX_EDITABLE_FILE_BYTES = 1_000_000
25
+ const HOST_URL = /^hydra web: (http:\/\/127\.0\.0\.1:\d+)$/u
26
+ const CHROME_EXECUTE_ACTIONS = new Set([
27
+ 'undo', 'redo', 'cut', 'copy', 'paste', 'delete', 'selectAll',
28
+ 'zoomIn', 'zoomOut', 'resetZoom', 'fullscreen', 'close', 'quit',
29
+ ])
30
+ const CHROME_DISPATCH_ACTIONS = new Set([
31
+ 'new-chat', 'open-folder', 'settings', 'sidebar', 'bottom-panel', 'terminal', 'browser',
32
+ 'back', 'forward', 'shortcuts',
33
+ ])
34
+ const TITLEBAR_OVERLAY_SUPPORTED = process.platform === 'win32' || process.platform === 'linux'
35
+ const TITLEBAR_HEIGHT = 36
36
+ const TITLEBAR_OVERLAY_TRANSPARENT = 'rgba(0,0,0,0)'
37
+
38
+ app.setName('Hydra harness')
39
+ app.setPath('userData', join(process.env.HYDRA_HOME || join(app.getPath('home'), '.hydra'), 'desktop-electron'))
40
+
41
+ let mainWindow
42
+ let host
43
+ let hostBaseUrl
44
+ let smokeWorkspace
45
+ let browser
46
+ let browserConnection
47
+ let browserBounds = { x: 0, y: 0, width: 0, height: 0, visible: false, present: true }
48
+ const terminals = new Map()
49
+ const terminalTails = new Map()
50
+ const fileSaveTails = new Map()
51
+
52
+ function panelShortcut(input) {
53
+ if (input.type !== 'keyDown' || !input.control || input.meta) return undefined
54
+ if (input.shift) return !input.alt && input.key.toLowerCase() === 'b' ? 'browser' : undefined
55
+ if (input.alt) return input.key.toLowerCase() === 's' ? 'side-chat' : undefined
56
+ if (input.key.toLowerCase() === 'p') return 'files'
57
+ if (input.key.toLowerCase() === 't') return 'browser'
58
+ if (input.key === '`' || input.code === 'Backquote') return 'terminal'
59
+ return undefined
60
+ }
61
+
62
+ function zoomShortcut(input) {
63
+ if (input.type !== 'keyDown' || !input.control || input.alt || input.meta) return undefined
64
+ if (input.key === '+' || input.key === '=') return 1
65
+ if (!input.shift && input.key === '-') return -1
66
+ if (!input.shift && input.key === '0') return 0
67
+ return undefined
68
+ }
69
+
70
+ /** Applies one zoom step; zero restores the application's actual size. */
71
+ function applyZoom(contents, step) {
72
+ contents.setZoomLevel(step === 0 ? 0 : contents.getZoomLevel() + step)
73
+ }
74
+
75
+ app.on('web-contents-created', (_event, contents) => {
76
+ contents.on('before-input-event', (event, input) => {
77
+ if (mainWindow === undefined || mainWindow.isDestroyed() || contents !== mainWindow.webContents) return
78
+ const zoom = zoomShortcut(input)
79
+ if (zoom !== undefined) {
80
+ event.preventDefault()
81
+ applyZoom(contents, zoom)
82
+ return
83
+ }
84
+ const shortcut = panelShortcut(input)
85
+ if (shortcut === undefined) return
86
+ event.preventDefault()
87
+ mainWindow.webContents.send('hydra-desktop:panel-shortcut', shortcut)
88
+ })
89
+ })
90
+ let shuttingDown
91
+ let hostExited = false
92
+ let hostExitCode
93
+
94
+ function postBrowser(connectionId, type, payload = {}) {
95
+ if (host === undefined || hostExited) return
96
+ host.postMessage({ type, connectionId, ...payload })
97
+ }
98
+
99
+ function parseBrowserLine(line) {
100
+ try {
101
+ return JSON.parse(line)
102
+ } catch {
103
+ return undefined
104
+ }
105
+ }
106
+
107
+ async function handleBrowserMessage(message) {
108
+ if (typeof message !== 'object' || message === null || !('type' in message)) return
109
+ if (message.type === 'hydra-browser-connect') {
110
+ if (typeof message.connectionId !== 'string') return
111
+ const previous = browserConnection
112
+ browserConnection = message.connectionId
113
+ await browser.cancelRequests()
114
+ if (previous !== undefined && previous !== message.connectionId) postBrowser(previous, 'hydra-browser-close')
115
+ if (browserConnection !== message.connectionId) return
116
+ const settings = typeof message.settings === 'object' && message.settings !== null ? message.settings : {}
117
+ try {
118
+ await browser.command('configure_browser', settings)
119
+ } catch (error) {
120
+ postBrowser(message.connectionId, 'hydra-browser-error', { error: String(error) })
121
+ postBrowser(message.connectionId, 'hydra-browser-close')
122
+ browserConnection = undefined
123
+ return
124
+ }
125
+ const homeUrl = typeof message.settings === 'object' && message.settings !== null
126
+ && 'homeUrl' in message.settings && typeof message.settings.homeUrl === 'string'
127
+ ? message.settings.homeUrl
128
+ : undefined
129
+ if (homeUrl !== undefined) {
130
+ try { await browser.command('navigate', { url: homeUrl }) } catch {}
131
+ }
132
+ postBrowser(message.connectionId, 'hydra-browser-line', { line: JSON.stringify({ event: 'ready' }) })
133
+ return
134
+ }
135
+ if (message.type === 'hydra-browser-disconnect') {
136
+ if (message.connectionId === browserConnection) {
137
+ browserConnection = undefined
138
+ await browser.cancelRequests()
139
+ }
140
+ return
141
+ }
142
+ if (message.type !== 'hydra-browser-line' || message.connectionId !== browserConnection || typeof message.line !== 'string') return
143
+ const request = parseBrowserLine(message.line)
144
+ if (request === undefined) return
145
+ const reply = await browser.request(request)
146
+ if (reply !== undefined) {
147
+ postBrowser(message.connectionId, 'hydra-browser-line', { line: JSON.stringify(reply) })
148
+ }
149
+ }
150
+
151
+ function createWindow() {
152
+ const window = new BrowserWindow({
153
+ width: 1440,
154
+ height: 900,
155
+ minWidth: 1000,
156
+ minHeight: 700,
157
+ show: false,
158
+ autoHideMenuBar: true,
159
+ ...(TITLEBAR_OVERLAY_SUPPORTED ? {
160
+ titleBarStyle: 'hidden',
161
+ titleBarOverlay: { color: TITLEBAR_OVERLAY_TRANSPARENT, symbolColor: '#c7ccd4', height: TITLEBAR_HEIGHT },
162
+ } : {}),
163
+ backgroundColor: '#111315',
164
+ title: 'Hydra harness',
165
+ icon: DESKTOP_ICON,
166
+ webPreferences: {
167
+ preload: join(__dirname, 'preload.cjs'),
168
+ contextIsolation: true,
169
+ sandbox: true,
170
+ nodeIntegration: false,
171
+ },
172
+ })
173
+ window.webContents.setWindowOpenHandler(({ url }) => {
174
+ void routeAppUrl(url)
175
+ return { action: 'deny' }
176
+ })
177
+ window.webContents.on('will-navigate', (event, url) => {
178
+ const current = window.webContents.getURL()
179
+ if (current === '' || current === 'about:blank') return
180
+ try {
181
+ if (new URL(current).origin === new URL(url).origin) return
182
+ } catch {}
183
+ event.preventDefault()
184
+ void routeAppUrl(url)
185
+ })
186
+ window.on('page-title-updated', event => { event.preventDefault() })
187
+ return window
188
+ }
189
+
190
+ async function routeAppUrl(url) {
191
+ if (shuttingDown !== undefined || browser === undefined) return
192
+ try {
193
+ await browser.command('route_user_url', { url })
194
+ } catch (error) {
195
+ process.stderr.write(`desktop: URL routing failed: ${String(error)}\n`)
196
+ }
197
+ }
198
+
199
+ function installBrowserController(window) {
200
+ const registered = Promise.withResolvers()
201
+ globalThis.__HYDRA_BROWSER_EMBED__ = {
202
+ window,
203
+ config: {
204
+ persistSessionCookies: true,
205
+ readinessTimeoutMs: 45_000,
206
+ experimentalScriptExecution: true,
207
+ },
208
+ send(message) {
209
+ if (browserConnection !== undefined) {
210
+ postBrowser(browserConnection, 'hydra-browser-line', { line: JSON.stringify(message) })
211
+ } else if (message.event === 'browser:permission') {
212
+ void browser.request({ method: 'browser_permission_response', args: { id: message.id } })
213
+ }
214
+ },
215
+ onState(state) {
216
+ void state
217
+ },
218
+ onAnnotation(annotation) {
219
+ if (!window.isDestroyed()) window.webContents.send('hydra-desktop:browser-annotation', annotation)
220
+ },
221
+ openBrowser() {
222
+ if (!window.isDestroyed()) window.webContents.send('hydra-desktop:panel-shortcut', 'browser')
223
+ },
224
+ closeBrowser() {
225
+ if (!window.isDestroyed()) window.webContents.send('hydra-desktop:panel-close', 'browser')
226
+ },
227
+ register(controller) { registered.resolve(controller) },
228
+ }
229
+ require(BROWSER_ENTRY)
230
+ return registered.promise
231
+ }
232
+
233
+ function startHost() {
234
+ if (!existsSync(CLI_ENTRY)) {
235
+ throw new Error('Desktop Host is not built. Run `pnpm run build` first.')
236
+ }
237
+ const hostEnv = { ...process.env, HYDRA_DESKTOP_BROWSER_BRIDGE: 'parent-port' }
238
+ delete hostEnv.ELECTRON_RUN_AS_NODE
239
+ const child = utilityProcess.fork(CLI_ENTRY, [
240
+ 'web', '--no-open', '--host', '127.0.0.1', '--port', '0',
241
+ ], {
242
+ cwd: process.cwd(),
243
+ env: hostEnv,
244
+ // Electron's Node ABI cannot use the system-Node native helper that exposes
245
+ // the Loader internals. The built-in flag gives profile-relative plugin
246
+ // resolution the same hook without loading that addon.
247
+ execArgv: ['--expose-internals'],
248
+ serviceName: 'Hydra harness Host',
249
+ stdio: 'pipe',
250
+ })
251
+ host = child
252
+ child.on('message', message => { void handleBrowserMessage(message) })
253
+ child.on('exit', (code) => {
254
+ hostExited = true
255
+ hostExitCode = code
256
+ })
257
+ child.stderr?.on('data', chunk => { process.stderr.write(chunk) })
258
+
259
+ const ready = Promise.withResolvers()
260
+ const lines = createInterface({ input: child.stdout })
261
+ lines.on('line', line => {
262
+ process.stdout.write(`${line}\n`)
263
+ const match = HOST_URL.exec(line.trim())
264
+ if (match?.[1] !== undefined) ready.resolve(match[1])
265
+ })
266
+ child.once('exit', (code) => {
267
+ ready.reject(new Error(`Hydra harness Host exited before readiness (code ${String(code)})`))
268
+ })
269
+ const timeout = setTimeout(() => {
270
+ ready.reject(new Error(`Hydra harness Host did not become ready within ${HOST_READY_TIMEOUT_MS}ms`))
271
+ }, HOST_READY_TIMEOUT_MS)
272
+ return ready.promise.finally(() => { clearTimeout(timeout) })
273
+ }
274
+
275
+ function validSender(event) {
276
+ return mainWindow !== undefined && event.sender === mainWindow.webContents
277
+ }
278
+
279
+ function overlayColor(value) {
280
+ if (typeof value !== 'string' || value.length > 128) return undefined
281
+ if (/^#[\da-f]{3,8}$/iu.test(value) || /^rgba?\([^\r\n()]+\)$/u.test(value)) return value
282
+ return undefined
283
+ }
284
+
285
+ function terminalSize(value) {
286
+ if (typeof value !== 'object' || value === null) return undefined
287
+ const cols = Number(value.cols)
288
+ const rows = Number(value.rows)
289
+ if (!Number.isInteger(cols) || cols < 2 || cols > 500) return undefined
290
+ if (!Number.isInteger(rows) || rows < 1 || rows > 200) return undefined
291
+ return { cols, rows }
292
+ }
293
+
294
+ function sendTerminalEvent(terminalId, value) {
295
+ if (mainWindow === undefined || mainWindow.isDestroyed()) return
296
+ mainWindow.webContents.send('hydra-desktop:terminal-event', { terminalId, event: value })
297
+ }
298
+
299
+ function terminalId(value) {
300
+ if (value === 'bottom' || value === 'right') return value
301
+ if (typeof value !== 'string') return undefined
302
+ const match = /^(?:right|bottom|term)(?:-[1-9]\d{0,15})+$/.exec(value)
303
+ if (match === null) return undefined
304
+ const parts = value.split('-').slice(1)
305
+ return parts.every(part => Number.isSafeInteger(Number(part))) ? value : undefined
306
+ }
307
+
308
+ /** Serialize each PTY's start and stop so closing during startup cannot orphan its process. */
309
+ async function queueTerminal(id, operation) {
310
+ const previous = terminalTails.get(id) ?? Promise.resolve()
311
+ const current = previous.catch(() => {}).then(operation)
312
+ terminalTails.set(id, current)
313
+ try {
314
+ return await current
315
+ } finally {
316
+ if (terminalTails.get(id) === current) terminalTails.delete(id)
317
+ }
318
+ }
319
+
320
+ function releaseTerminal(id, instance) {
321
+ const record = terminals.get(id)
322
+ if (record?.instance !== instance) return
323
+ terminals.delete(id)
324
+ for (const subscription of record.subscriptions) subscription.dispose()
325
+ }
326
+
327
+ async function startTerminal(id, size, workspaceId) {
328
+ const current = terminals.get(id)
329
+ if (current !== undefined) {
330
+ if (current.workspaceId !== workspaceId) throw new Error('terminal belongs to another workspace')
331
+ try { current.instance.resize(size.cols, size.rows) } catch {}
332
+ return { running: true }
333
+ }
334
+ const cwd = workspaceId === undefined ? process.cwd() : await registeredWorkspaceRoot(workspaceId)
335
+ const { scrubbedParentEnv } = await import('@hydraharness/harness-subprocess')
336
+ const env = scrubbedParentEnv()
337
+ let shell = env.SHELL ?? '/bin/sh'
338
+ let shellName = shell.split('/').pop() || 'sh'
339
+ if (process.platform === 'win32') {
340
+ const pwsh7 = join(process.env.ProgramFiles ?? 'C:\\Program Files', 'PowerShell', '7', 'pwsh.exe')
341
+ if (existsSync(pwsh7)) {
342
+ shell = pwsh7
343
+ shellName = 'pwsh.exe'
344
+ } else {
345
+ shell = 'powershell.exe'
346
+ shellName = 'powershell.exe'
347
+ }
348
+ }
349
+ const args = process.platform === 'win32' ? ['-NoLogo', '-NoProfile', '-NoExit'] : ['-i']
350
+ const instance = nodePty.spawn(shell, args, {
351
+ name: 'xterm-256color',
352
+ cols: size.cols,
353
+ rows: size.rows,
354
+ cwd,
355
+ env,
356
+ })
357
+ const record = { instance, subscriptions: [], output: '', shellName, workspaceId }
358
+ terminals.set(id, record)
359
+ const data = instance.onData((value) => {
360
+ record.output = (record.output + value).slice(-200_000)
361
+ sendTerminalEvent(id, { type: 'data', data: value })
362
+ })
363
+ const exited = instance.onExit(({ exitCode }) => {
364
+ releaseTerminal(id, instance)
365
+ sendTerminalEvent(id, { type: 'exit', code: exitCode })
366
+ })
367
+ record.subscriptions = [data, exited]
368
+ return { running: true }
369
+ }
370
+
371
+ async function stopTerminal(id) {
372
+ const record = terminals.get(id)
373
+ if (record === undefined) return
374
+ const { instance } = record
375
+ const exited = Promise.withResolvers()
376
+ const wait = instance.onExit(() => { exited.resolve() })
377
+ if (process.platform === 'win32') {
378
+ await new Promise((resolve) => {
379
+ const killer = spawn('taskkill.exe', ['/pid', String(instance.pid), '/t', '/f'], {
380
+ stdio: 'ignore',
381
+ windowsHide: true,
382
+ })
383
+ killer.once('error', resolve)
384
+ killer.once('exit', resolve)
385
+ })
386
+ } else {
387
+ try { instance.kill() } catch {}
388
+ }
389
+ try {
390
+ await Promise.race([exited.promise, delay(2_000)])
391
+ if (terminals.get(id)?.instance === instance) {
392
+ instance.kill()
393
+ await Promise.race([exited.promise, delay(2_000)])
394
+ if (terminals.get(id)?.instance === instance) throw new Error(`Terminal ${id} did not exit`)
395
+ }
396
+ } finally {
397
+ wait.dispose()
398
+ }
399
+ }
400
+
401
+ async function hostRequest(method, payload = {}) {
402
+ if (hostBaseUrl === undefined) throw new Error('Host is unavailable')
403
+ const rpcId = randomUUID()
404
+ const response = await fetch(new URL(`/api/${method}`, hostBaseUrl), {
405
+ method: 'POST',
406
+ headers: { 'content-type': 'application/json' },
407
+ body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
408
+ signal: AbortSignal.timeout(HOST_REQUEST_TIMEOUT_MS),
409
+ })
410
+ if (!response.ok) throw new Error(`${method} failed: HTTP ${response.status}`)
411
+ const message = await response.json()
412
+ const result = message?.result
413
+ if (message?.rpcId !== rpcId || typeof result?.ok !== 'boolean') {
414
+ throw new Error(`${method} returned an invalid response`)
415
+ }
416
+ if (!result.ok) throw new Error(`${method} failed: ${result.error?.message ?? 'unknown error'}`)
417
+ return result.value
418
+ }
419
+
420
+ async function registeredWorkspaceRoot(workspaceId) {
421
+ if (typeof workspaceId !== 'string' || workspaceId === '') throw new Error('workspace is unavailable')
422
+ const value = await hostRequest('workspace.list')
423
+ if (!Array.isArray(value?.items)) throw new Error('workspace.list returned an invalid response')
424
+ const workspace = value.items.find(item => item?.workspaceId === workspaceId)
425
+ if (typeof workspace?.path !== 'string') throw new Error('workspace is not registered')
426
+ const root = await realpath(workspace.path)
427
+ if (!(await stat(root)).isDirectory()) throw new Error('workspace path is invalid')
428
+ return root
429
+ }
430
+
431
+ async function confinedPath(root, target = root) {
432
+ if (typeof root !== 'string' || !isAbsolute(root)
433
+ || typeof target !== 'string' || !isAbsolute(target)) {
434
+ throw new Error('workspace path is invalid')
435
+ }
436
+ const base = await realpath(root)
437
+ if (!(await stat(base)).isDirectory()) throw new Error('workspace path is invalid')
438
+ const candidate = await realpath(target)
439
+ const inside = relative(base, candidate)
440
+ if (inside === '..' || inside.startsWith(`..${sep}`) || isAbsolute(inside)) {
441
+ throw new Error('path is outside the workspace')
442
+ }
443
+ return candidate
444
+ }
445
+
446
+ async function listFiles(root, target = root) {
447
+ const path = await confinedPath(root, target)
448
+ const entries = await readdir(path, { withFileTypes: true })
449
+ return entries
450
+ .filter(entry => !entry.isSymbolicLink())
451
+ .map(entry => ({ name: entry.name, path: join(path, entry.name), directory: entry.isDirectory() }))
452
+ .sort((left, right) => Number(right.directory) - Number(left.directory) || left.name.localeCompare(right.name))
453
+ }
454
+
455
+ async function searchFiles(root, query) {
456
+ if (typeof query !== 'string' || query.length > 256) throw new Error('file search is invalid')
457
+ const needle = query.trim().toLocaleLowerCase().replaceAll('\\', '/')
458
+ if (needle === '') return []
459
+ const base = await confinedPath(root)
460
+ const matches = []
461
+ // Substring search stops at 200 hits; add an index only if large workspaces need ranked results.
462
+ for await (const entry of glob('**/*', {
463
+ cwd: base,
464
+ exclude: entry => entry.isSymbolicLink() || entry.name === '.git' || entry.name === 'node_modules',
465
+ withFileTypes: true,
466
+ })) {
467
+ if (!entry.isFile()) continue
468
+ const candidate = join(entry.parentPath, entry.name)
469
+ const name = relative(base, candidate).split(sep).join('/')
470
+ if (!name.toLocaleLowerCase().includes(needle)) continue
471
+ let path
472
+ try { path = await confinedPath(base, candidate) } catch { continue }
473
+ matches.push({ name, path, directory: false })
474
+ if (matches.length === 200) break
475
+ }
476
+ return matches.sort((left, right) => left.name.localeCompare(right.name))
477
+ }
478
+
479
+ function fileVersion(metadata) {
480
+ return `${metadata.dev}:${metadata.ino}:${metadata.size}:${metadata.mtimeNs}:${metadata.ctimeNs}`
481
+ }
482
+
483
+ function normalizeNewlines(content) {
484
+ return content.replaceAll('\r\n', '\n').replaceAll('\r', '\n')
485
+ }
486
+
487
+ function lineEndingOf(content) {
488
+ let crlf = 0
489
+ let lf = 0
490
+ for (let index = 0; index < content.length; index += 1) {
491
+ if (content[index] !== '\n') continue
492
+ if (content[index - 1] === '\r') crlf += 1
493
+ else lf += 1
494
+ }
495
+ return crlf > lf ? 'CRLF' : 'LF'
496
+ }
497
+
498
+ async function readWorkspaceText(root, target) {
499
+ const path = await confinedPath(root, target)
500
+ const handle = await open(path, 'r')
501
+ try {
502
+ const before = await handle.stat({ bigint: true })
503
+ if (!before.isFile()) throw new Error('path is not a file')
504
+ if (before.size > BigInt(MAX_EDITABLE_FILE_BYTES)) throw new Error('file is larger than 1 MB')
505
+ const capacity = Math.max(1, Number(before.size) + 1)
506
+ const buffer = Buffer.allocUnsafe(capacity)
507
+ let length = 0
508
+ while (length < capacity) {
509
+ const chunk = await handle.read(buffer, length, capacity - length, null)
510
+ if (chunk.bytesRead === 0) break
511
+ length += chunk.bytesRead
512
+ }
513
+ const after = await handle.stat({ bigint: true })
514
+ if (fileVersion(before) !== fileVersion(after)) throw new Error('file changed while it was being read')
515
+ if (length > MAX_EDITABLE_FILE_BYTES) throw new Error('file is larger than 1 MB')
516
+ const bytes = buffer.subarray(0, length)
517
+ if (bytes.includes(0)) throw new Error('binary files are not editable')
518
+ const bom = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
519
+ const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bom ? bytes.subarray(3) : bytes)
520
+ return {
521
+ path,
522
+ content: normalizeNewlines(decoded),
523
+ version: fileVersion(after),
524
+ mode: Number(after.mode & 0o777n),
525
+ lineEnding: lineEndingOf(decoded),
526
+ bom,
527
+ }
528
+ } finally {
529
+ await handle.close()
530
+ }
531
+ }
532
+
533
+ async function readWorkspaceFile(root, target) {
534
+ const { path, content, version } = await readWorkspaceText(root, target)
535
+ return { path, content, version }
536
+ }
537
+
538
+ function validateEntryName(name) {
539
+ if (typeof name !== 'string' || name === '' || name !== name.trim()
540
+ || name === '.' || name === '..' || /[\\/:*?"<>|\0]/u.test(name)) {
541
+ throw new Error('file name is invalid')
542
+ }
543
+ }
544
+
545
+ async function createWorkspaceEntry(root, parentTarget, name, kind) {
546
+ validateEntryName(name)
547
+ if (kind !== 'file' && kind !== 'directory') throw new Error('file kind is invalid')
548
+ const parent = await confinedPath(root, parentTarget)
549
+ if (!(await stat(parent)).isDirectory()) throw new Error('parent path is not a directory')
550
+ const target = join(parent, name)
551
+ if (kind === 'directory') {
552
+ await mkdir(target)
553
+ } else {
554
+ const handle = await open(target, 'wx')
555
+ await handle.close()
556
+ }
557
+ const path = await confinedPath(root, target)
558
+ return { name, path, directory: kind === 'directory' }
559
+ }
560
+
561
+ async function renameWorkspaceEntry(root, target, newName) {
562
+ validateEntryName(newName)
563
+ const currentPath = await confinedPath(root, target)
564
+ const rootCanonical = await realpath(root)
565
+ if (currentPath === rootCanonical) throw new Error('cannot rename workspace root')
566
+ const parent = dirname(currentPath)
567
+ const destination = join(parent, newName)
568
+ const targetStat = await stat(currentPath)
569
+ if (existsSync(destination)) throw new Error('a file or folder with this name already exists')
570
+ await rename(currentPath, destination)
571
+ const newConfined = await confinedPath(root, destination)
572
+ return { oldPath: currentPath, path: newConfined, name: newName, directory: targetStat.isDirectory() }
573
+ }
574
+
575
+ async function moveWorkspaceEntry(root, target, destinationTarget) {
576
+ const currentPath = await confinedPath(root, target)
577
+ const destinationDirectory = await confinedPath(root, destinationTarget)
578
+ const rootCanonical = await realpath(root)
579
+ if (currentPath === rootCanonical) throw new Error('cannot move workspace root')
580
+ if (!(await stat(destinationDirectory)).isDirectory()) throw new Error('destination path is not a directory')
581
+ const targetStat = await stat(currentPath)
582
+ if (targetStat.isDirectory() && (destinationDirectory === currentPath
583
+ || destinationDirectory.startsWith(`${currentPath}${sep}`))) {
584
+ throw new Error('cannot move a folder into itself')
585
+ }
586
+ const destination = join(destinationDirectory, basename(currentPath))
587
+ if (existsSync(destination)) throw new Error('a file or folder with this name already exists')
588
+ await rename(currentPath, destination)
589
+ const newConfined = await confinedPath(root, destination)
590
+ return { oldPath: currentPath, path: newConfined, name: basename(newConfined), directory: targetStat.isDirectory() }
591
+ }
592
+
593
+ async function deleteWorkspaceEntry(root, target) {
594
+ const currentPath = await confinedPath(root, target)
595
+ const rootCanonical = await realpath(root)
596
+ if (currentPath === rootCanonical) throw new Error('cannot delete workspace root')
597
+ await rm(currentPath, { recursive: true, force: true })
598
+ return { path: currentPath }
599
+ }
600
+
601
+ async function revealWorkspaceEntry(root, target) {
602
+ const currentPath = await confinedPath(root, target)
603
+ shell.showItemInFolder(currentPath)
604
+ return true
605
+ }
606
+
607
+ async function serializeFileSave(path, operation) {
608
+ const previous = fileSaveTails.get(path) ?? Promise.resolve()
609
+ const current = previous.catch(() => {}).then(operation)
610
+ fileSaveTails.set(path, current)
611
+ try {
612
+ return await current
613
+ } finally {
614
+ if (fileSaveTails.get(path) === current) fileSaveTails.delete(path)
615
+ }
616
+ }
617
+
618
+ async function saveWorkspaceFile(root, target, content, expectedVersion) {
619
+ if (typeof content !== 'string' || content.includes('\0')
620
+ || Buffer.byteLength(content, 'utf8') > MAX_EDITABLE_FILE_BYTES
621
+ || typeof expectedVersion !== 'string' || expectedVersion === '' || expectedVersion.length > 256) {
622
+ throw new Error('file save is invalid')
623
+ }
624
+ const path = await confinedPath(root, target)
625
+ return await serializeFileSave(path, async () => {
626
+ const current = await readWorkspaceText(root, path)
627
+ if (current.version !== expectedVersion) throw new Error('file changed since it was opened')
628
+ const normalized = normalizeNewlines(content)
629
+ const body = current.lineEnding === 'CRLF' ? normalized.replaceAll('\n', '\r\n') : normalized
630
+ const output = current.bom ? `\ufeff${body}` : body
631
+ if (Buffer.byteLength(output, 'utf8') > MAX_EDITABLE_FILE_BYTES) throw new Error('file is larger than 1 MB')
632
+ const { writeFileAtomic } = await import('@hydraharness/harness-atomic-write')
633
+ const publicationPath = await confinedPath(root, current.path)
634
+ await writeFileAtomic(publicationPath, output, { mode: current.mode })
635
+ const saved = await readWorkspaceText(root, publicationPath)
636
+ if (saved.content !== normalized) throw new Error('file changed immediately after it was saved')
637
+ return { path: saved.path, version: saved.version }
638
+ })
639
+ }
640
+
641
+ async function formatWorkspaceFile(root, target, content) {
642
+ if (typeof content !== 'string' || content.includes('\0')
643
+ || Buffer.byteLength(content, 'utf8') > MAX_EDITABLE_FILE_BYTES) {
644
+ throw new Error('file format is invalid')
645
+ }
646
+ const { path } = await readWorkspaceText(root, target)
647
+ const { format } = await import('prettier')
648
+ const formatted = normalizeNewlines(await format(content, { filepath: path }))
649
+ if (formatted.includes('\0') || Buffer.byteLength(formatted, 'utf8') > MAX_EDITABLE_FILE_BYTES) {
650
+ throw new Error('formatted file is larger than 1 MB')
651
+ }
652
+ return formatted
653
+ }
654
+
655
+ function installRendererIpc() {
656
+ ipcMain.handle('hydra-desktop:open-external', async (event, value) => {
657
+ if (shuttingDown !== undefined || !validSender(event)) throw new Error('desktop is unavailable')
658
+ if (typeof value?.url !== 'string' || value.url.length > 2_048) throw new Error('external URL is invalid')
659
+ let url
660
+ try { url = new URL(value.url) } catch { throw new Error('external URL is invalid') }
661
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('external URL is invalid')
662
+ url.username = ''
663
+ url.password = ''
664
+ await shell.openExternal(url.href)
665
+ })
666
+
667
+ ipcMain.handle('hydra-desktop:open-session-window', async (event, value) => {
668
+ if (shuttingDown !== undefined || !validSender(event) || typeof value?.sessionId !== 'string'
669
+ || value.sessionId.length === 0 || value.sessionId.length > 256 || value.sessionId.includes('\0')) {
670
+ throw new Error('invalid session window request')
671
+ }
672
+ const url = new URL(hostBaseUrl)
673
+ url.searchParams.set('session', value.sessionId)
674
+ const child = new BrowserWindow({
675
+ width: 1200, height: 800, minWidth: 800, minHeight: 600,
676
+ show: !SMOKE,
677
+ title: 'Hydra harness', icon: DESKTOP_ICON,
678
+ webPreferences: { contextIsolation: true, sandbox: true, nodeIntegration: false },
679
+ })
680
+ child.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
681
+ child.webContents.on('will-navigate', (navigation, target) => {
682
+ if (new URL(target).origin !== url.origin) navigation.preventDefault()
683
+ })
684
+ try { await child.loadURL(url.href) } catch (error) { child.destroy(); throw error }
685
+ })
686
+ ipcMain.handle('hydra-desktop:chrome-action', async (event, value) => {
687
+ if (shuttingDown !== undefined || !validSender(event)) throw new Error('desktop is unavailable')
688
+ if (typeof value !== 'string') throw new Error('desktop action is invalid')
689
+ const nativeAction = {
690
+ undo: 'undo',
691
+ redo: 'redo',
692
+ cut: 'cut',
693
+ copy: 'copy',
694
+ paste: 'paste',
695
+ delete: 'delete',
696
+ 'select-all': 'selectAll',
697
+ 'zoom-in': 'zoomIn',
698
+ 'zoom-out': 'zoomOut',
699
+ 'reset-zoom': 'resetZoom',
700
+ 'toggle-fullscreen': 'fullscreen',
701
+ close: 'close',
702
+ quit: 'quit',
703
+ }[value]
704
+ if (nativeAction !== undefined && CHROME_EXECUTE_ACTIONS.has(nativeAction)) {
705
+ if (nativeAction === 'undo' || nativeAction === 'redo' || nativeAction === 'cut' || nativeAction === 'copy'
706
+ || nativeAction === 'paste' || nativeAction === 'delete' || nativeAction === 'selectAll') {
707
+ mainWindow.webContents[nativeAction]()
708
+ }
709
+ else if (nativeAction === 'zoomIn') applyZoom(mainWindow.webContents, 1)
710
+ else if (nativeAction === 'zoomOut') applyZoom(mainWindow.webContents, -1)
711
+ else if (nativeAction === 'resetZoom') applyZoom(mainWindow.webContents, 0)
712
+ else if (nativeAction === 'fullscreen') mainWindow.setFullScreen(!mainWindow.isFullScreen())
713
+ else if (nativeAction === 'close') mainWindow.close()
714
+ else if (nativeAction === 'quit') app.quit()
715
+ return
716
+ }
717
+ const dispatchAction = {
718
+ 'new-chat': 'new-chat',
719
+ 'open-folder': 'open-folder',
720
+ settings: 'settings',
721
+ 'toggle-sidebar': 'sidebar',
722
+ 'toggle-bottom-panel': 'bottom-panel',
723
+ 'open-terminal': 'terminal',
724
+ 'open-browser': 'browser',
725
+ back: 'back',
726
+ forward: 'forward',
727
+ 'keyboard-shortcuts': 'shortcuts',
728
+ }[value]
729
+ if (dispatchAction !== undefined && CHROME_DISPATCH_ACTIONS.has(dispatchAction)) {
730
+ mainWindow.webContents.send('hydra-desktop:chrome-dispatch', dispatchAction)
731
+ return
732
+ }
733
+ throw new Error('desktop action is unavailable')
734
+ })
735
+ ipcMain.on('hydra-desktop:chrome-theme', (event, value) => {
736
+ if (shuttingDown !== undefined || !validSender(event) || !TITLEBAR_OVERLAY_SUPPORTED) return
737
+ if (value === null) {
738
+ mainWindow.setTitleBarOverlay({ color: TITLEBAR_OVERLAY_TRANSPARENT, symbolColor: '#c7ccd4', height: TITLEBAR_HEIGHT })
739
+ return
740
+ }
741
+ if (typeof value !== 'object' || value === null) return
742
+ const color = overlayColor(value.color)
743
+ const symbolColor = overlayColor(value.symbolColor)
744
+ if (color === undefined || symbolColor === undefined) return
745
+ mainWindow.setTitleBarOverlay({ color, symbolColor, height: TITLEBAR_HEIGHT })
746
+ })
747
+ const browserOperation = async (event, method, args = {}) => {
748
+ if (shuttingDown !== undefined || !validSender(event)) throw new Error('browser management is unavailable')
749
+ return await browser.command(method, args)
750
+ }
751
+ ipcMain.on('hydra-desktop:browser-bounds', (event, value) => {
752
+ if (shuttingDown !== undefined || !validSender(event) || typeof value !== 'object' || value === null) return
753
+ const [windowWidth, windowHeight] = mainWindow.getContentSize()
754
+ const zoom = mainWindow.webContents.getZoomFactor()
755
+ const dip = input => Math.round((Number(input) || 0) * zoom)
756
+ const x = Math.min(windowWidth, Math.max(0, dip(value.x)))
757
+ const y = Math.min(windowHeight, Math.max(0, dip(value.y)))
758
+ browserBounds = {
759
+ x,
760
+ y,
761
+ width: Math.min(windowWidth - x, Math.max(0, dip(value.width))),
762
+ height: Math.min(windowHeight - y, Math.max(0, dip(value.height))),
763
+ visible: value.visible === true,
764
+ present: value.present !== false,
765
+ }
766
+ browser.setBounds(browserBounds)
767
+ })
768
+ ipcMain.handle('hydra-desktop:browser-configure', (event, value) =>
769
+ browserOperation(event, 'configure_browser', value))
770
+ ipcMain.on('hydra-desktop:browser-theme', (event, value) => {
771
+ if (shuttingDown !== undefined || !validSender(event)) return
772
+ try { browser.setTheme(value) } catch (error) {
773
+ process.stderr.write(`desktop: Browser theme rejected: ${String(error)}\n`)
774
+ }
775
+ })
776
+ ipcMain.handle('hydra-desktop:browser-confirm-full-cdp', async (event) => {
777
+ if (shuttingDown !== undefined || !validSender(event)) return false
778
+ const choice = await dialog.showMessageBox(mainWindow, {
779
+ type: 'warning',
780
+ title: 'Enable full CDP access?',
781
+ message: 'Full Chrome DevTools Protocol access has elevated risk.',
782
+ detail: 'It can inspect and control sensitive browser internals. Every CDP command will still require separate approval.',
783
+ buttons: ['Enable', 'Cancel'],
784
+ defaultId: 1,
785
+ cancelId: 1,
786
+ noLink: true,
787
+ })
788
+ return choice.response === 0
789
+ })
790
+ ipcMain.handle('hydra-desktop:browser-clear-data', (event, value) =>
791
+ browserOperation(event, 'clear_browser_data', value))
792
+ ipcMain.handle('hydra-desktop:browser-open-url', (event, value) =>
793
+ browserOperation(event, 'route_user_url', value))
794
+ ipcMain.handle('hydra-desktop:browser-history', event =>
795
+ browserOperation(event, 'browser_history'))
796
+ ipcMain.handle('hydra-desktop:browser-remove-history', (event, value) =>
797
+ browserOperation(event, 'remove_browser_history', value))
798
+ ipcMain.handle('hydra-desktop:browser-downloads', event =>
799
+ browserOperation(event, 'browser_downloads'))
800
+ ipcMain.handle('hydra-desktop:browser-remove-download', (event, value) =>
801
+ browserOperation(event, 'remove_browser_download', value))
802
+ ipcMain.handle('hydra-desktop:browser-open-download', (event, value) =>
803
+ browserOperation(event, 'open_browser_download', value))
804
+ ipcMain.handle('hydra-desktop:browser-sites', event =>
805
+ browserOperation(event, 'browser_sites'))
806
+ ipcMain.handle('hydra-desktop:browser-set-site', (event, value) =>
807
+ browserOperation(event, 'set_browser_site', value))
808
+ ipcMain.handle('hydra-desktop:browser-remove-site', (event, value) =>
809
+ browserOperation(event, 'remove_browser_site', value))
810
+ ipcMain.handle('hydra-desktop:browser-autofill-status', event =>
811
+ browserOperation(event, 'autofill_status'))
812
+ ipcMain.handle('hydra-desktop:browser-autofill-list-logins', event =>
813
+ browserOperation(event, 'autofill_list_logins'))
814
+ ipcMain.handle('hydra-desktop:browser-autofill-save-login', (event, value) =>
815
+ browserOperation(event, 'autofill_save_login', value))
816
+ ipcMain.handle('hydra-desktop:browser-autofill-remove-login', (event, value) =>
817
+ browserOperation(event, 'autofill_remove_login', value))
818
+ ipcMain.handle('hydra-desktop:browser-autofill-list-contacts', event =>
819
+ browserOperation(event, 'autofill_list_contacts'))
820
+ ipcMain.handle('hydra-desktop:browser-autofill-get-contact', (event, value) =>
821
+ browserOperation(event, 'autofill_get_contact', value))
822
+ ipcMain.handle('hydra-desktop:browser-autofill-save-contact', (event, value) =>
823
+ browserOperation(event, 'autofill_save_contact', value))
824
+ ipcMain.handle('hydra-desktop:browser-autofill-remove-contact', (event, value) =>
825
+ browserOperation(event, 'autofill_remove_contact', value))
826
+ ipcMain.handle('hydra-desktop:terminal-start', async (event, value) => {
827
+ if (shuttingDown !== undefined || !validSender(event)) throw new Error('terminal is unavailable')
828
+ const id = terminalId(value?.terminalId)
829
+ const size = terminalSize(value?.size)
830
+ if (id === undefined || size === undefined) throw new Error('terminal request is invalid')
831
+ return await queueTerminal(id, () => startTerminal(id, size, value?.workspaceId))
832
+ })
833
+ ipcMain.handle('hydra-desktop:terminal-stop', async (event, value) => {
834
+ if (shuttingDown !== undefined || !validSender(event)) throw new Error('terminal is unavailable')
835
+ const id = terminalId(value?.terminalId)
836
+ if (id === undefined) throw new Error('terminal request is invalid')
837
+ await queueTerminal(id, () => stopTerminal(id))
838
+ })
839
+ ipcMain.on('hydra-desktop:terminal-write', (event, value) => {
840
+ const id = terminalId(value?.terminalId)
841
+ if (shuttingDown !== undefined || !validSender(event) || id === undefined
842
+ || typeof value?.data !== 'string' || value.data.length > 65_536) return
843
+ try { terminals.get(id)?.instance.write(value.data) } catch {}
844
+ })
845
+ ipcMain.on('hydra-desktop:terminal-resize', (event, value) => {
846
+ if (shuttingDown !== undefined || !validSender(event)) return
847
+ const id = terminalId(value?.terminalId)
848
+ const size = terminalSize(value?.size)
849
+ if (id === undefined || size === undefined) return
850
+ try { terminals.get(id)?.instance.resize(size.cols, size.rows) } catch {}
851
+ })
852
+ ipcMain.handle('hydra-desktop:terminal-list', (event) => {
853
+ if (shuttingDown !== undefined || !validSender(event)) return []
854
+ const list = []
855
+ for (const [id, record] of terminals) {
856
+ list.push({
857
+ id,
858
+ pid: record.instance.pid,
859
+ shell: record.shellName || (process.platform === 'win32' ? 'powershell.exe' : 'bash'),
860
+ })
861
+ }
862
+ return list
863
+ })
864
+ ipcMain.on('hydra-desktop:emit-annotation', (event, annotation) => {
865
+ if (shuttingDown !== undefined || !validSender(event)) return
866
+ if (mainWindow !== undefined && !mainWindow.isDestroyed()) {
867
+ mainWindow.webContents.send('hydra-desktop:browser-annotation', annotation)
868
+ }
869
+ })
870
+ ipcMain.handle('hydra-desktop:files-root', async (event, value) => {
871
+ if (shuttingDown !== undefined || !validSender(event)) throw new Error('files are unavailable')
872
+ return await registeredWorkspaceRoot(value?.workspaceId)
873
+ })
874
+ ipcMain.handle('hydra-desktop:files-list', async (event, value) => {
875
+ if (shuttingDown !== undefined || !validSender(event)) throw new Error('files are unavailable')
876
+ return await listFiles(await registeredWorkspaceRoot(value?.workspaceId), value?.path)
877
+ })
878
+ ipcMain.handle('hydra-desktop:files-search', async (event, value) => {
879
+ if (shuttingDown !== undefined || !validSender(event)) throw new Error('files are unavailable')
880
+ return await searchFiles(await registeredWorkspaceRoot(value?.workspaceId), value?.query)
881
+ })
882
+ ipcMain.handle('hydra-desktop:files-read', async (event, value) => {
883
+ if (shuttingDown !== undefined || !validSender(event)) throw new Error('files are unavailable')
884
+ return await readWorkspaceFile(await registeredWorkspaceRoot(value?.workspaceId), value?.path)
885
+ })
886
+ ipcMain.handle('hydra-desktop:files-create', async (event, value) => {
887
+ if (shuttingDown !== undefined || !validSender(event)) throw new Error('files are unavailable')
888
+ return await createWorkspaceEntry(
889
+ await registeredWorkspaceRoot(value?.workspaceId),
890
+ value?.parentPath,
891
+ value?.name,
892
+ value?.kind,
893
+ )
894
+ })
895
+ ipcMain.handle('hydra-desktop:files-rename', async (event, value) => {
896
+ if (shuttingDown !== undefined || !validSender(event)) throw new Error('files are unavailable')
897
+ return await renameWorkspaceEntry(
898
+ await registeredWorkspaceRoot(value?.workspaceId),
899
+ value?.path,
900
+ value?.newName,
901
+ )
902
+ })
903
+ ipcMain.handle('hydra-desktop:files-move', async (event, value) => {
904
+ if (shuttingDown !== undefined || !validSender(event)) throw new Error('files are unavailable')
905
+ return await moveWorkspaceEntry(
906
+ await registeredWorkspaceRoot(value?.workspaceId),
907
+ value?.path,
908
+ value?.destinationDirectory,
909
+ )
910
+ })
911
+ ipcMain.handle('hydra-desktop:files-delete', async (event, value) => {
912
+ if (shuttingDown !== undefined || !validSender(event)) throw new Error('files are unavailable')
913
+ return await deleteWorkspaceEntry(
914
+ await registeredWorkspaceRoot(value?.workspaceId),
915
+ value?.path,
916
+ )
917
+ })
918
+ ipcMain.handle('hydra-desktop:files-reveal', async (event, value) => {
919
+ if (shuttingDown !== undefined || !validSender(event)) throw new Error('files are unavailable')
920
+ return await revealWorkspaceEntry(
921
+ await registeredWorkspaceRoot(value?.workspaceId),
922
+ value?.path,
923
+ )
924
+ })
925
+ ipcMain.handle('hydra-desktop:files-save', async (event, value) => {
926
+ if (shuttingDown !== undefined || !validSender(event)) throw new Error('files are unavailable')
927
+ return await saveWorkspaceFile(
928
+ await registeredWorkspaceRoot(value?.workspaceId),
929
+ value?.path,
930
+ value?.content,
931
+ value?.expectedVersion,
932
+ )
933
+ })
934
+ ipcMain.handle('hydra-desktop:files-format', async (event, value) => {
935
+ if (shuttingDown !== undefined || !validSender(event)) throw new Error('files are unavailable')
936
+ return await formatWorkspaceFile(
937
+ await registeredWorkspaceRoot(value?.workspaceId),
938
+ value?.path,
939
+ value?.content,
940
+ )
941
+ })
942
+ }
943
+
944
+ function assertNativeViews(panel) {
945
+ const visible = mainWindow.contentView.children
946
+ .filter(view => view.getVisible())
947
+ .map(view => view.getBounds())
948
+ .sort((left, right) => left.y - right.y)
949
+ const [chrome, page] = visible
950
+ const valid = visible.length === 2
951
+ && chrome.x === panel.x && chrome.y === panel.y && chrome.width === panel.width
952
+ && page.x === panel.x && page.y === chrome.y + chrome.height && page.width === panel.width
953
+ && page.y + page.height === panel.y + panel.height
954
+ if (!valid) {
955
+ throw new Error(`native browser views do not fill the panel: ${JSON.stringify({ panel, visible })}`)
956
+ }
957
+ }
958
+
959
+ async function waitForBounds(label, predicate) {
960
+ const deadline = Date.now() + 15_000
961
+ while (Date.now() < deadline) {
962
+ if (browserBounds.visible && browserBounds.width > 100 && browserBounds.height > 100 && predicate(browserBounds)) {
963
+ return { ...browserBounds }
964
+ }
965
+ await delay(100)
966
+ }
967
+ throw new Error(`${label} browser view has unusable bounds: ${JSON.stringify(browserBounds)}`)
968
+ }
969
+
970
+ async function waitForHiddenBrowser(label) {
971
+ const deadline = Date.now() + 15_000
972
+ while (Date.now() < deadline) {
973
+ const visibleViews = mainWindow.contentView.children.filter(view => view.getVisible())
974
+ if (!browserBounds.visible && visibleViews.length === 0) return
975
+ await delay(100)
976
+ }
977
+ throw new Error(`${label} did not hide native browser views: ${JSON.stringify(browserBounds)}`)
978
+ }
979
+
980
+ async function selectControl(label, scope) {
981
+ const clicked = await mainWindow.webContents.executeJavaScript(`(() => {
982
+ const parent = ${scope === undefined ? 'document' : `document.querySelector(${JSON.stringify(scope)})`}
983
+ const button = parent?.querySelector('[aria-label=${JSON.stringify(label)}]')
984
+ if (!(button instanceof HTMLButtonElement)) return false
985
+ button.click()
986
+ return true
987
+ })()`)
988
+ if (!clicked) throw new Error(`desktop panel control is missing: ${label}`)
989
+ }
990
+
991
+ async function waitForTerminal(id, label, predicate) {
992
+ const deadline = Date.now() + 15_000
993
+ while (Date.now() < deadline) {
994
+ const terminal = terminals.get(id)
995
+ if (terminal !== undefined && predicate(terminal)) return
996
+ await delay(100)
997
+ }
998
+ throw new Error(`${label} terminal check failed`)
999
+ }
1000
+
1001
+ async function waitForRenderer(label, expression) {
1002
+ const deadline = Date.now() + 15_000
1003
+ while (Date.now() < deadline) {
1004
+ if (await mainWindow.webContents.executeJavaScript(`(async () => Boolean(await (${expression})))()`)) return
1005
+ await delay(100)
1006
+ }
1007
+ throw new Error(`${label} renderer check failed`)
1008
+ }
1009
+
1010
+ async function prepareSmokeWorkspace() {
1011
+ const root = await mkdtemp(join(tmpdir(), 'hydra-desktop-smoke-'))
1012
+ smokeWorkspace = { root }
1013
+ const outside = await mkdtemp(join(tmpdir(), 'hydra-desktop-smoke-outside-'))
1014
+ smokeWorkspace = { root, outside }
1015
+ await writeFile(join(root, 'package.json'), '{"name":"hydra-desktop-smoke"}\n')
1016
+ await writeFile(join(root, 'bom-crlf.txt'), '\ufeffone\r\ntwo\r\n')
1017
+ await writeFile(join(outside, 'outside-only.txt'), 'must not be searchable\n')
1018
+ await symlink(outside, join(root, 'linked-outside'), process.platform === 'win32' ? 'junction' : 'dir')
1019
+ const terminalWorkspaces = []
1020
+ smokeWorkspace.terminalWorkspaces = terminalWorkspaces
1021
+ for (const name of ['workspace B', 'workspace C']) {
1022
+ const path = join(root, name)
1023
+ await mkdir(path)
1024
+ const created = await hostRequest('workspace.create', { path })
1025
+ if (typeof created?.workspace?.workspaceId !== 'string') throw new Error('terminal smoke workspace is unavailable')
1026
+ terminalWorkspaces.push({ workspaceId: created.workspace.workspaceId, path: await realpath(path) })
1027
+ }
1028
+ const value = await hostRequest('workspace.create', { path: root })
1029
+ const workspaceId = value?.workspace?.workspaceId
1030
+ if (typeof workspaceId !== 'string') throw new Error('desktop smoke Workspace is unavailable')
1031
+ if (value?.created !== true) throw new Error('desktop smoke Workspace already exists')
1032
+ smokeWorkspace = { root: await realpath(root), outside: await realpath(outside), workspaceId, terminalWorkspaces }
1033
+ }
1034
+
1035
+ async function cleanupSmokeWorkspace() {
1036
+ const workspace = smokeWorkspace
1037
+ smokeWorkspace = undefined
1038
+ if (workspace === undefined) return
1039
+ let cleanupError
1040
+ for (const workspaceId of [workspace.workspaceId, ...(workspace.terminalWorkspaces ?? []).map(item => item.workspaceId)]) {
1041
+ if (workspaceId === undefined) continue
1042
+ try { await hostRequest('workspace.delete', { workspaceId }) } catch (error) { cleanupError = error }
1043
+ }
1044
+ for (const directory of [workspace.root, workspace.outside]) {
1045
+ if (directory === undefined) continue
1046
+ const parent = resolve(tmpdir())
1047
+ const target = resolve(directory)
1048
+ const inside = relative(parent, target)
1049
+ if (inside.includes(sep) || !inside.startsWith('hydra-desktop-smoke-')) {
1050
+ throw new Error(`refusing to remove unexpected smoke path: ${target}`)
1051
+ }
1052
+ await rm(target, { recursive: true, force: true })
1053
+ }
1054
+ if (cleanupError !== undefined) throw cleanupError
1055
+ }
1056
+
1057
+ async function smokeZoomShortcuts() {
1058
+ const contents = mainWindow.webContents
1059
+ contents.setZoomLevel(0)
1060
+ const actual = []
1061
+ for (const [shortcut, keyCode, modifiers] of [
1062
+ ['Ctrl+=', '=', ['control']],
1063
+ ['Ctrl+-', '-', ['control']],
1064
+ ['Ctrl+Shift+=', '=', ['control', 'shift']],
1065
+ ['Ctrl+0', '0', ['control']],
1066
+ ['Ctrl+-', '-', ['control']],
1067
+ ['Ctrl+0', '0', ['control']],
1068
+ ['Ctrl+Alt+=', '=', ['control', 'alt']],
1069
+ ['Ctrl+Shift+-', '-', ['control', 'shift']],
1070
+ ]) {
1071
+ contents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
1072
+ contents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
1073
+ await delay(100)
1074
+ actual.push(`${shortcut}: ${Math.round(contents.getZoomFactor() * 100)}%`)
1075
+ }
1076
+ await selectControl('Toggle right panel')
1077
+ await waitForRenderer('Review option', 'document.querySelector(\'[aria-label="Open panel"] [aria-label="Review"]\')')
1078
+ await selectControl('Review', '[aria-label="Open panel"]')
1079
+ for (const [width, height] of [[1000, 700], [1366, 768], [1440, 900], [1920, 1080]]) {
1080
+ mainWindow.setSize(width, height)
1081
+ for (const zoom of [0.67, 0.8, 1, 1.25, 1.5, 2]) {
1082
+ contents.setZoomFactor(zoom)
1083
+ for (const expanded of [false, true]) {
1084
+ if (expanded) await selectControl('Expand right panel')
1085
+ await waitForRenderer(`titlebar layout at ${width}x${height}, zoom ${zoom}, expanded ${expanded}`, `(() => {
1086
+ const bar = document.querySelector('[data-desktop-titlebar]')
1087
+ const panel = document.querySelector('[aria-label="Right panel"]')
1088
+ const review = panel?.querySelector('[aria-label="Open panel"] [aria-label="Review"]')
1089
+ if (!bar || !panel || !review || panel.hidden) return false
1090
+ const bounds = bar.getBoundingClientRect()
1091
+ const native = navigator.windowControlsOverlay?.getTitlebarAreaRect()
1092
+ const left = native?.width > 0 ? native.left : 0
1093
+ const right = native?.width > 0 ? native.right : bounds.right
1094
+ const bottom = native?.width > 0 ? native.bottom : 0
1095
+ if (bounds.bottom + 0.5 < bottom) return false
1096
+ const buttons = [...bar.querySelectorAll('button')]
1097
+ if (!buttons.every(button => {
1098
+ const rect = button.getBoundingClientRect()
1099
+ return rect.left >= left && rect.right <= right
1100
+ && rect.top >= bounds.top && rect.bottom <= bounds.bottom
1101
+ })) return false
1102
+ const option = review.getBoundingClientRect()
1103
+ const pane = panel.getBoundingClientRect()
1104
+ return option.width >= 24 && option.top >= bounds.bottom
1105
+ && option.left >= pane.left && option.right <= pane.right
1106
+ })()`)
1107
+ if (expanded) await selectControl('Expand right panel')
1108
+ }
1109
+ }
1110
+ }
1111
+ contents.setZoomLevel(0)
1112
+ actual.push('Titlebar and Review: 4 window sizes, 6 zoom levels, normal and expanded panels')
1113
+ const transcript = `${actual.join('\n')}\n`
1114
+ const expected = await readFile(join(__dirname, 'tests', 'snapshots', 'zoom-shortcuts.expected.md'), 'utf8')
1115
+ if (transcript !== expected.replaceAll('\r\n', '\n')) {
1116
+ throw new Error(`desktop zoom shortcuts differ from the snapshot:\n${transcript}`)
1117
+ }
1118
+ }
1119
+
1120
+ async function smoke() {
1121
+ if (nativeImage.createFromPath(DESKTOP_ICON).isEmpty()) throw new Error('desktop Hydra icon is unavailable')
1122
+ if (typeof smokeWorkspace?.workspaceId !== 'string') throw new Error('desktop smoke Workspace is unavailable')
1123
+ const panelStartsClosed = await mainWindow.webContents.executeJavaScript(`(() => {
1124
+ const panel = document.querySelector('[aria-label="Right panel"]')
1125
+ return panel instanceof HTMLElement && panel.hidden
1126
+ })()`)
1127
+ if (!panelStartsClosed) throw new Error('desktop browser panel opens by default')
1128
+ const titleBarReady = await mainWindow.webContents.executeJavaScript(`(() => {
1129
+ const bar = document.querySelector('[data-desktop-titlebar]')
1130
+ return bar instanceof HTMLElement
1131
+ && bar.querySelector('[aria-label="Back"]') instanceof HTMLButtonElement
1132
+ && bar.querySelector('[aria-label="Forward"]') instanceof HTMLButtonElement
1133
+ && [...bar.querySelectorAll('button[aria-haspopup="menu"]')].map(button => button.textContent).join('|') === 'File|Edit|View|Help'
1134
+ })()`)
1135
+ if (!titleBarReady) throw new Error('desktop title bar controls are unavailable')
1136
+ if (mainWindow.getTitle() !== 'Hydra harness') throw new Error(`unexpected desktop title: ${mainWindow.getTitle()}`)
1137
+ const externalUrls = []
1138
+ const openExternal = shell.openExternal
1139
+ shell.openExternal = async url => { externalUrls.push(url) }
1140
+ try {
1141
+ await mainWindow.webContents.executeJavaScript('window.hydraDesktop.openExternal("https://gemini.google.com/app")')
1142
+ if (externalUrls.length !== 1 || externalUrls[0] !== 'https://gemini.google.com/app') {
1143
+ throw new Error('Desktop Gemini sign-in did not delegate to the system browser')
1144
+ }
1145
+ const invalidExternal = await mainWindow.webContents.executeJavaScript(`window.hydraDesktop.openExternal('file:///private')
1146
+ .then(() => false, error => error.message.includes('external URL is invalid'))`)
1147
+ if (!invalidExternal || externalUrls.length !== 1) throw new Error('Desktop external browser accepted an unsupported URL')
1148
+ } finally { shell.openExternal = openExternal }
1149
+ const windowSession = await hostRequest('session.create', { workspaceId: smokeWorkspace.workspaceId })
1150
+ await mainWindow.webContents.executeJavaScript(`window.hydraDesktop.openSessionWindow(${JSON.stringify(windowSession.sessionId)})`)
1151
+ const sessionWindow = BrowserWindow.getAllWindows().find(window => window !== mainWindow)
1152
+ if (sessionWindow === undefined) throw new Error('session window did not open')
1153
+ const sessionWindowReady = await sessionWindow.webContents.executeJavaScript(`typeof window.hydraDesktop === 'undefined'
1154
+ && new URL(location.href).searchParams.get('session') === ${JSON.stringify(windowSession.sessionId)}`)
1155
+ if (!sessionWindowReady) throw new Error('session window URL or native isolation is invalid')
1156
+ sessionWindow.destroy()
1157
+ await hostRequest('session.delete', { sessionId: windowSession.sessionId })
1158
+ if (panelShortcut({ type: 'keyDown', control: true, alt: false, meta: false, shift: false, key: 'p' }) !== 'files'
1159
+ || panelShortcut({ type: 'keyUp', control: true, alt: false, meta: false, shift: false, key: 'p' }) !== undefined
1160
+ || panelShortcut({ type: 'keyDown', control: true, alt: true, meta: false, shift: false, key: 's' }) !== 'side-chat'
1161
+ || panelShortcut({ type: 'keyDown', control: true, alt: false, meta: false, shift: true, key: 'b' }) !== 'browser'
1162
+ || panelShortcut({ type: 'keyDown', control: true, alt: false, meta: false, shift: false, key: 't' }) !== 'browser'
1163
+ || panelShortcut({ type: 'keyDown', control: true, alt: false, meta: false, shift: false, key: '`' }) !== 'terminal') {
1164
+ throw new Error('desktop panel shortcut mapping is invalid')
1165
+ }
1166
+ await selectControl('Toggle right panel')
1167
+ const onboardingDeadline = Date.now() + 15_000
1168
+ while (!browserBounds.visible && Date.now() < onboardingDeadline) {
1169
+ await mainWindow.webContents.executeJavaScript(`(() => {
1170
+ const dialog = document.querySelector('[role="dialog"][aria-modal="true"]')
1171
+ if (!dialog) return
1172
+ const title = dialog.getAttribute('aria-label') ?? document.getElementById(dialog.getAttribute('aria-labelledby'))?.textContent
1173
+ const label = title === 'Internal Testing Notice' ? 'Continue' : title === 'Settings' ? 'Close' : undefined
1174
+ if (label) [...dialog.querySelectorAll('button')].find(button => button.textContent.trim() === label)?.click()
1175
+ })()`)
1176
+ await delay(100)
1177
+ }
1178
+ const right = await waitForBounds('right', bounds => bounds.x > 0 && bounds.width < mainWindow.getContentBounds().width)
1179
+ assertNativeViews(right)
1180
+ const panelOptions = await mainWindow.webContents.executeJavaScript(`(() =>
1181
+ [...document.querySelectorAll('[aria-label="Open panel"] > button')].map(button => ({
1182
+ label: button.getAttribute('aria-label'),
1183
+ shortcut: button.getAttribute('aria-keyshortcuts'),
1184
+ }))
1185
+ )()`)
1186
+ if (JSON.stringify(panelOptions) !== JSON.stringify([
1187
+ { label: 'Files', shortcut: 'Control+P' },
1188
+ { label: 'Side chat', shortcut: 'Control+Alt+S' },
1189
+ { label: 'Browser', shortcut: 'Control+Shift+B' },
1190
+ { label: 'Terminal', shortcut: 'Control+Backquote' },
1191
+ { label: 'Review', shortcut: null },
1192
+ ])) throw new Error(`panel options contract is invalid: ${JSON.stringify(panelOptions)}`)
1193
+ mainWindow.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'P', modifiers: ['control'] })
1194
+ mainWindow.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'P', modifiers: ['control'] })
1195
+ await waitForHiddenBrowser('Files panel')
1196
+ await waitForRenderer('workspace Files panel', `document.querySelector('[data-panel-kind="files"]:not([hidden]) [aria-label="Workspace files"] [role="treeitem"]')`)
1197
+ await waitForRenderer('workspace Files filter focus', `document.activeElement?.getAttribute('aria-label') === 'Filter workspace files'`)
1198
+ const files = await mainWindow.webContents.executeJavaScript(`(async () => {
1199
+ const workspaceId = ${JSON.stringify(smokeWorkspace.workspaceId)}
1200
+ const api = window.hydraDesktop?.files
1201
+ if (api === undefined) return undefined
1202
+ const rejected = promise => promise.then(() => false, () => true)
1203
+ const root = document.querySelector('[data-panel-kind="files"]:not([hidden]) [data-files-root]')?.getAttribute('data-files-root')
1204
+ const results = await api.search('package.json', workspaceId)
1205
+ const match = results?.find(entry => entry.name === 'package.json')
1206
+ const preview = match === undefined ? undefined : await api?.read(match.path, workspaceId)
1207
+ const linked = await api.search('outside-only', workspaceId)
1208
+ const unscopedRejected = await rejected(api.root())
1209
+ const escaped = await api.read(${JSON.stringify(resolve(smokeWorkspace.root, '..'))}, workspaceId)
1210
+ .then(() => false, error => String(error).includes('outside the workspace'))
1211
+ const directory = await api.create(root, 'src', 'directory', workspaceId)
1212
+ const created = await api.create(directory.path, 'sample.json', 'file', workspaceId)
1213
+ const opened = await api.read(created.path, workspaceId)
1214
+ const formatted = await api.format(created.path, '{"b":2,"a":1}', workspaceId)
1215
+ await api.save(created.path, formatted, opened.version, workspaceId)
1216
+ const formattedRead = await api.read(created.path, workspaceId)
1217
+ const duplicateRejected = await rejected(api.create(directory.path, 'sample.json', 'file', workspaceId))
1218
+ const invalidNameRejected = await rejected(api.create(root, '../escaped.txt', 'file', workspaceId))
1219
+ const outsideParentRejected = await rejected(api.create(
1220
+ ${JSON.stringify(smokeWorkspace.outside)}, 'escaped.txt', 'file', workspaceId,
1221
+ ))
1222
+ const symlinkParentRejected = await rejected(api.create(
1223
+ ${JSON.stringify(join(smokeWorkspace.root, 'linked-outside'))}, 'escaped.txt', 'file', workspaceId,
1224
+ ))
1225
+ const races = await Promise.allSettled([
1226
+ api.save(created.path, 'first\\n', formattedRead.version, workspaceId),
1227
+ api.save(created.path, 'second\\n', formattedRead.version, workspaceId),
1228
+ ])
1229
+ const afterRace = await api.read(created.path, workspaceId)
1230
+ const staleRejected = await rejected(api.save(created.path, 'stale\\n', formattedRead.version, workspaceId))
1231
+ const nulRejected = await rejected(api.save(created.path, 'bad\\0content', afterRace.version, workspaceId))
1232
+ const oversizedRejected = await rejected(api.save(created.path, 'é'.repeat(500_001), afterRace.version, workspaceId))
1233
+ const afterFailure = await api.read(created.path, workspaceId)
1234
+ const bomPath = ${JSON.stringify(join(smokeWorkspace.root, 'bom-crlf.txt'))}
1235
+ const bom = await api.read(bomPath, workspaceId)
1236
+ await api.save(bomPath, bom.content + 'three\\n', bom.version, workspaceId)
1237
+ return {
1238
+ root,
1239
+ preview: preview?.content,
1240
+ previewVersion: preview?.version,
1241
+ linked: linked.length,
1242
+ unscopedRejected,
1243
+ escaped,
1244
+ created: created.path,
1245
+ formatted: formattedRead.content,
1246
+ duplicateRejected,
1247
+ invalidNameRejected,
1248
+ outsideParentRejected,
1249
+ symlinkParentRejected,
1250
+ raceWinners: races.filter(result => result.status === 'fulfilled').length,
1251
+ raceContent: afterRace.content,
1252
+ staleRejected,
1253
+ nulRejected,
1254
+ oversizedRejected,
1255
+ afterFailure: afterFailure.content,
1256
+ }
1257
+ })()`)
1258
+ if (files?.root !== smokeWorkspace.root
1259
+ || !String(files?.preview).includes('hydra-desktop-smoke')
1260
+ || typeof files?.previewVersion !== 'string'
1261
+ || files?.linked !== 0
1262
+ || files?.unscopedRejected !== true
1263
+ || files?.escaped !== true
1264
+ || files?.created !== join(smokeWorkspace.root, 'src', 'sample.json')
1265
+ || !String(files?.formatted).includes('"b": 2')
1266
+ || files?.duplicateRejected !== true
1267
+ || files?.invalidNameRejected !== true
1268
+ || files?.outsideParentRejected !== true
1269
+ || files?.symlinkParentRejected !== true
1270
+ || files?.raceWinners !== 1
1271
+ || !['first\n', 'second\n'].includes(files?.raceContent)
1272
+ || files?.staleRejected !== true
1273
+ || files?.nulRejected !== true
1274
+ || files?.oversizedRejected !== true
1275
+ || files?.afterFailure !== files?.raceContent) {
1276
+ throw new Error(`desktop Files Workspace contract is invalid: ${JSON.stringify(files)}`)
1277
+ }
1278
+ const bomCrlf = await readFile(join(smokeWorkspace.root, 'bom-crlf.txt'))
1279
+ if (!bomCrlf.equals(Buffer.from('\ufeffone\r\ntwo\r\nthree\r\n'))) {
1280
+ throw new Error(`desktop Files changed BOM or CRLF bytes: ${JSON.stringify([...bomCrlf])}`)
1281
+ }
1282
+ await selectControl('Terminal')
1283
+ await waitForHiddenBrowser('right Terminal')
1284
+ await waitForRenderer('right Terminal panel', `document.querySelector('[aria-label="Right terminal"]:not([hidden])')`)
1285
+ await waitForTerminal('right', 'right startup', () => true)
1286
+ await selectControl('Toggle bottom terminal')
1287
+ const terminalsVisible = await mainWindow.webContents.executeJavaScript(`(() => {
1288
+ const bottom = document.querySelector('section[aria-label="Terminal"]')
1289
+ const right = document.querySelector('[aria-label="Right terminal"]')
1290
+ return bottom instanceof HTMLElement && !bottom.hidden && right instanceof HTMLElement && !right.hidden
1291
+ })()`)
1292
+ if (!terminalsVisible) throw new Error('right and bottom Terminal panels did not open together')
1293
+ await waitForTerminal('bottom-1-1', 'bottom startup', () => true)
1294
+ const workspaceTranscript = []
1295
+ const assertTerminalCwd = async (id, expected) => {
1296
+ const command = process.platform === 'win32'
1297
+ ? "Write-Output ('HYDRA_CWD:' + (Get-Location).Path)\r"
1298
+ : "printf 'HYDRA_CWD:%s\\n' \"$PWD\"\r"
1299
+ await mainWindow.webContents.executeJavaScript(`window.hydraDesktop.terminal.write(${JSON.stringify(id)}, ${JSON.stringify(command)})`)
1300
+ await waitForTerminal(id, 'workspace cwd', terminal => terminal.output.includes(`HYDRA_CWD:${expected}`))
1301
+ const output = stripVTControlCharacters(terminals.get(id).output)
1302
+ const cwd = [...output.matchAll(/HYDRA_CWD:([^\r\n]+)/g)].at(-1)?.[1]
1303
+ if (cwd !== expected) throw new Error(`unexpected terminal cwd: ${JSON.stringify(cwd)}`)
1304
+ workspaceTranscript.push({ terminal: id, cwd: relative(smokeWorkspace.root, cwd).split(sep).join('/') || '.' })
1305
+ }
1306
+ await assertTerminalCwd('right', smokeWorkspace.root)
1307
+ await assertTerminalCwd('bottom-1-1', smokeWorkspace.root)
1308
+ const marker = `HYDRA_TERMINAL_SMOKE_${Date.now()}`
1309
+ const rightMarker = `${marker}_RIGHT`
1310
+ const bottomMarker = `${marker}_BOTTOM`
1311
+ // Shell history predictions must not contain the complete output marker.
1312
+ const markerCommand = value => process.platform === 'win32'
1313
+ ? `Write-Output ('HYDRA_' + '${value.slice(6)}')\r`
1314
+ : `printf 'HYDRA_%s\\n' '${value.slice(6)}'\r`
1315
+ await mainWindow.webContents.executeJavaScript(`(() => {
1316
+ window.hydraDesktop?.terminal?.write('right', ${JSON.stringify(markerCommand(rightMarker))})
1317
+ window.hydraDesktop?.terminal?.write('bottom-1-1', ${JSON.stringify(markerCommand(bottomMarker))})
1318
+ })()`)
1319
+ await waitForTerminal('right', 'right command output', terminal => terminal.output.includes(rightMarker))
1320
+ await waitForTerminal('bottom-1-1', 'bottom command output', terminal => terminal.output.includes(bottomMarker))
1321
+ if (terminals.get('right').output.includes(bottomMarker) || terminals.get('bottom-1-1').output.includes(rightMarker)) {
1322
+ throw new Error('right and bottom Terminal output crossed PTY boundaries')
1323
+ }
1324
+ const firstTerminal = terminals.get('right').instance
1325
+ await selectControl('Terminal')
1326
+ await waitForRenderer('second Terminal tab', `document.querySelector('[aria-label="Right terminal 2"]:not([hidden])')`)
1327
+ await waitForTerminal('right-2', 'second right startup', () => true)
1328
+ await assertTerminalCwd('right-2', smokeWorkspace.root)
1329
+ const secondMarker = `${marker}_SECOND`
1330
+ await mainWindow.webContents.executeJavaScript(`window.hydraDesktop.terminal.write('right-2', ${JSON.stringify(markerCommand(secondMarker))})`)
1331
+ await waitForTerminal('right-2', 'second right output', terminal => terminal.output.includes(secondMarker))
1332
+ if (terminals.get('right').instance !== firstTerminal || terminals.get('right').output.includes(secondMarker)
1333
+ || terminals.get('right-2').output.includes(rightMarker) || terminals.get('bottom-1-1').output.includes(secondMarker)) {
1334
+ throw new Error(`Terminal tabs did not preserve independent PTYs: ${JSON.stringify({
1335
+ firstPreserved: terminals.get('right')?.instance === firstTerminal,
1336
+ firstHasSecond: terminals.get('right')?.output.includes(secondMarker),
1337
+ secondHasFirst: terminals.get('right-2')?.output.includes(rightMarker),
1338
+ bottomHasSecond: terminals.get('bottom-1-1')?.output.includes(secondMarker),
1339
+ })}`)
1340
+ }
1341
+ if (process.platform === 'win32') {
1342
+ await mainWindow.webContents.executeJavaScript(`window.hydraDesktop.terminal.write('right-2', "Write-Output ('HYDRA_' + 'POWERSHELL:' + $PSVersionTable.PSVersion.Major)\\r")`)
1343
+ await waitForTerminal('right-2', 'PowerShell version', terminal => /HYDRA_POWERSHELL:\d+/.test(terminal.output))
1344
+ }
1345
+ await selectControl('Close Terminal 2')
1346
+ await waitForRenderer('second Terminal closed', `!document.querySelector('[aria-label="Close Terminal 2"]')`)
1347
+ if (terminals.has('right-2') || terminals.get('right')?.instance !== firstTerminal || !terminals.has('bottom-1-1')) {
1348
+ throw new Error('closing the second Terminal affected another PTY')
1349
+ }
1350
+ const bottomPanel = 'section[aria-label="Terminal"]'
1351
+ const firstBottom = terminals.get('bottom-1-1').instance
1352
+ await selectControl('New Terminal', bottomPanel)
1353
+ await waitForTerminal('bottom-1-2', 'second bottom tab startup', () => true)
1354
+ await assertTerminalCwd('bottom-1-2', smokeWorkspace.root)
1355
+ await waitForRenderer('bottom plus creates a tab without a split', `(() => {
1356
+ const panel = document.querySelector(${JSON.stringify(bottomPanel)})
1357
+ return panel.querySelectorAll('[role="tab"]').length === 2
1358
+ && panel.querySelectorAll('[role="tabpanel"]:not([hidden]) [aria-label="Terminal output"]').length === 1
1359
+ })()`)
1360
+ await selectControl('Split Terminal', `${bottomPanel} [role="tabpanel"]:not([hidden])`)
1361
+ await waitForTerminal('bottom-1-2-2', 'bottom split startup', () => true)
1362
+ await assertTerminalCwd('bottom-1-2-2', smokeWorkspace.root)
1363
+ const bottomTabs = await mainWindow.webContents.executeJavaScript(`(() => {
1364
+ const tabs = [...document.querySelectorAll(${JSON.stringify(`${bottomPanel} [role="tab"]`)})]
1365
+ return tabs.map(tab => ({ label: tab.textContent.trim(), selected: tab.getAttribute('aria-selected') === 'true' }))
1366
+ })()`)
1367
+ if (JSON.stringify(bottomTabs) !== JSON.stringify([{ label: 'Terminal', selected: false }, { label: 'Terminal 2', selected: true }])) {
1368
+ throw new Error(`bottom terminal tabs are invalid: ${JSON.stringify(bottomTabs)}`)
1369
+ }
1370
+ await selectControl('Close Terminal 2', bottomPanel)
1371
+ await waitForRenderer('bottom tab processes stopped', `window.hydraDesktop.terminal.list().then(items =>
1372
+ !items.some(item => item.id === 'bottom-1-2' || item.id === 'bottom-1-2-2'))`)
1373
+ if (terminals.get('bottom-1-1')?.instance !== firstBottom || terminals.get('right')?.instance !== firstTerminal) {
1374
+ throw new Error('closing the bottom tab affected another terminal')
1375
+ }
1376
+ await selectControl('New Terminal', bottomPanel)
1377
+ await waitForTerminal('bottom-1-3', 'replacement bottom tab startup', () => true)
1378
+ await waitForRenderer('bottom tab reuses number 2', `document.querySelector(${JSON.stringify(bottomPanel)})
1379
+ .querySelector('[role="tab"][aria-selected="true"]')?.textContent.trim() === 'Terminal 2'`)
1380
+ workspaceTranscript.push({ terminal: 'bottom-1-3', label: 'Terminal 2' })
1381
+ await mainWindow.webContents.executeJavaScript(`document.querySelector(${JSON.stringify(bottomPanel)})
1382
+ .querySelector('[role="tabpanel"]:not([hidden]) button[aria-label^="Kill "]').click()`)
1383
+ await waitForRenderer('trash closes the terminal tab and process', `!document.querySelector(${JSON.stringify(bottomPanel)})
1384
+ .querySelector('[aria-label="Close Terminal 2"]') && window.hydraDesktop.terminal.list().then(items =>
1385
+ !items.some(item => item.id === 'bottom-1-3'))`)
1386
+ if (terminals.get('bottom-1-1')?.instance !== firstBottom || terminals.get('right')?.instance !== firstTerminal) {
1387
+ throw new Error('trash affected another terminal')
1388
+ }
1389
+ workspaceTranscript.push({ action: 'trash-close', tabs: await mainWindow.webContents.executeJavaScript(`
1390
+ [...document.querySelectorAll(${JSON.stringify(`${bottomPanel} [role="tab"]`)})].map(tab => tab.textContent.trim())`) })
1391
+ const rejectedTerminalId = await mainWindow.webContents.executeJavaScript(`window.hydraDesktop.terminal.start('right-0', { cols: 80, rows: 24 }).then(() => false, () => true)`)
1392
+ if (!rejectedTerminalId) throw new Error('invalid Terminal id was accepted')
1393
+ const rejectedSplitTerminalId = await mainWindow.webContents.executeJavaScript(`window.hydraDesktop.terminal.start('right-4-0', { cols: 80, rows: 24 }).then(() => false, () => true)`)
1394
+ if (!rejectedSplitTerminalId) throw new Error('invalid split Terminal id right-4-0 was accepted')
1395
+ const splitTerminalAccepted = await mainWindow.webContents.executeJavaScript(`(async () => {
1396
+ const res = await window.hydraDesktop.terminal.start('right-4-2', { cols: 80, rows: 24 }, ${JSON.stringify(smokeWorkspace.workspaceId)})
1397
+ return res.running
1398
+ })()`)
1399
+ if (!splitTerminalAccepted) throw new Error('split terminal id right-4-2 was not accepted')
1400
+ await assertTerminalCwd('right-4-2', smokeWorkspace.root)
1401
+ await mainWindow.webContents.executeJavaScript("window.hydraDesktop.terminal.stop('right-4-2')")
1402
+ const workspaceRejections = await mainWindow.webContents.executeJavaScript(`(async () => {
1403
+ const api = window.hydraDesktop.terminal
1404
+ const rejected = promise => promise.then(() => false, () => true)
1405
+ return await Promise.all([
1406
+ rejected(api.start('right-9001', { cols: 80, rows: 24 }, 'missing-workspace')),
1407
+ rejected(api.start('right', { cols: 80, rows: 24 }, ${JSON.stringify(smokeWorkspace.terminalWorkspaces[0].workspaceId)})),
1408
+ ])
1409
+ })()`)
1410
+ if (!workspaceRejections.every(Boolean) || terminals.has('right-9001')) throw new Error('invalid terminal workspace was accepted')
1411
+ for (const workspace of smokeWorkspace.terminalWorkspaces) {
1412
+ await mainWindow.webContents.executeJavaScript(`window.hydraDesktop.terminal.start('right-9001', { cols: 80, rows: 24 }, ${JSON.stringify(workspace.workspaceId)})`)
1413
+ await assertTerminalCwd('right-9001', workspace.path)
1414
+ await mainWindow.webContents.executeJavaScript("window.hydraDesktop.terminal.stop('right-9001')")
1415
+ }
1416
+ const workspaceGolden = join(__dirname, 'tests', 'snapshots', 'terminal-workspaces.expected.json')
1417
+ const transcript = `${JSON.stringify(workspaceTranscript, null, 2)}\n`
1418
+ if (process.env.HYDRA_SNAPSHOT === 'refresh') {
1419
+ await mkdir(dirname(workspaceGolden), { recursive: true })
1420
+ await writeFile(workspaceGolden, transcript)
1421
+ } else if (await readFile(workspaceGolden, 'utf8') !== transcript) {
1422
+ throw new Error(`terminal workspace snapshot mismatch:\n${transcript}`)
1423
+ }
1424
+ await mainWindow.webContents.executeJavaScript(`(async () => {
1425
+ const api = window.hydraDesktop.terminal
1426
+ await Promise.all([
1427
+ api.start('right-9000', { cols: 80, rows: 24 }),
1428
+ api.start('right-9000', { cols: 80, rows: 24 }),
1429
+ api.stop('right-9000'),
1430
+ ])
1431
+ })()`)
1432
+ if (terminals.has('right-9000')) throw new Error('closing during Terminal startup left a PTY alive')
1433
+ const panels = await mainWindow.webContents.executeJavaScript(`(() => {
1434
+ const right = document.querySelector('[aria-label="Right panel"]')?.getBoundingClientRect()
1435
+ const bottom = document.querySelector('section[aria-label="Terminal"]')?.getBoundingClientRect()
1436
+ return right && bottom ? {
1437
+ right: { left: right.left, top: right.top, bottom: right.bottom },
1438
+ bottom: { left: bottom.left, top: bottom.top, right: bottom.right },
1439
+ } : undefined
1440
+ })()`)
1441
+ if (panels === undefined || panels.bottom.top <= panels.right.top || panels.bottom.right > panels.right.left + 1) {
1442
+ throw new Error(`right and bottom panels overlap: ${JSON.stringify(panels)}`)
1443
+ }
1444
+ mainWindow.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'T', modifiers: ['control'] })
1445
+ mainWindow.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'T', modifiers: ['control'] })
1446
+ const simultaneous = await waitForBounds('right with bottom Terminal', bounds => bounds.x > 0 && bounds.width < mainWindow.getContentBounds().width)
1447
+ assertNativeViews(simultaneous)
1448
+ await mainWindow.webContents.executeJavaScript(`(() => {
1449
+ const dialog = document.createElement('div')
1450
+ dialog.id = 'desktop-smoke-modal'
1451
+ dialog.setAttribute('role', 'dialog')
1452
+ dialog.setAttribute('aria-modal', 'true')
1453
+ document.body.append(dialog)
1454
+ })()`)
1455
+ await waitForHiddenBrowser('renderer modal')
1456
+ await mainWindow.webContents.executeJavaScript("document.querySelector('#desktop-smoke-modal')?.remove()")
1457
+ assertNativeViews(await waitForBounds('right after modal', bounds => bounds.x > 0 && bounds.width < mainWindow.getContentBounds().width))
1458
+ await selectControl('Expand right panel')
1459
+ const expanded = await waitForBounds(
1460
+ 'expanded',
1461
+ bounds => bounds.width > simultaneous.width,
1462
+ )
1463
+ assertNativeViews(expanded)
1464
+ await selectControl('Expand right panel')
1465
+ const restored = await waitForBounds('restored right', bounds => bounds.width < expanded.width)
1466
+ assertNativeViews(restored)
1467
+ const terminalRestored = await mainWindow.webContents.executeJavaScript(`(() => {
1468
+ const panel = document.querySelector('section[aria-label="Terminal"]')
1469
+ const button = document.querySelector('[aria-label="Toggle bottom terminal"]')
1470
+ return panel instanceof HTMLElement && !panel.hidden && button?.getAttribute('aria-pressed') === 'true'
1471
+ })()`)
1472
+ if (!terminalRestored) throw new Error('Terminal did not restore after Browser expand')
1473
+ await selectControl('Toggle right panel')
1474
+ await waitForHiddenBrowser('right panel close')
1475
+ mainWindow.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'T', modifiers: ['control'] })
1476
+ mainWindow.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'T', modifiers: ['control'] })
1477
+ assertNativeViews(await waitForBounds('right panel reopen', bounds => bounds.width < expanded.width))
1478
+ if (BrowserWindow.getAllWindows().length !== 1) {
1479
+ throw new Error(`desktop opened ${BrowserWindow.getAllWindows().length} BrowserWindows instead of one`)
1480
+ }
1481
+ const beforeClose = browser.getState()
1482
+ if (beforeClose?.tabs.length !== 1 || beforeClose.activeTabId !== beforeClose.tabs[0]?.id) {
1483
+ throw new Error(`desktop browser tab state is invalid: ${JSON.stringify(beforeClose)}`)
1484
+ }
1485
+ const nativeChrome = mainWindow.contentView.children.find(view => view.webContents.getURL().endsWith('/chrome.html'))
1486
+ nativeChrome.webContents.focus()
1487
+ nativeChrome.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'T', modifiers: ['control'] })
1488
+ nativeChrome.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'T', modifiers: ['control'] })
1489
+ const newTabDeadline = Date.now() + 5_000
1490
+ while (browser.getState().tabs.length !== 2 && Date.now() < newTabDeadline) await delay(50)
1491
+ if (browser.getState().tabs.length !== 2 || !browserBounds.visible) throw new Error('native Ctrl+T toggled a desktop panel instead of opening a browser tab')
1492
+ await browser.command('close_tab', { tabId: browser.getState().activeTabId })
1493
+ assertNativeViews(await waitForBounds('native tab shortcut', bounds => bounds.width < expanded.width))
1494
+ const themeSettings = (await hostRequest('settings.describe')).namespaces.find(item => item.ns === 'ui-theme')
1495
+ let themeRevision = themeSettings.revision
1496
+ const systemTheme = nativeTheme.themeSource
1497
+ let previousPanelColor
1498
+ try {
1499
+ for (const colorScheme of ['dark', 'light']) {
1500
+ nativeTheme.themeSource = colorScheme === 'dark' ? 'light' : 'dark'
1501
+ const updated = await hostRequest('settings.mutate', {
1502
+ ns: 'ui-theme', expectedRevision: themeRevision,
1503
+ ops: [{ op: 'set', path: ['preference'], value: colorScheme }],
1504
+ })
1505
+ themeRevision = updated.revision
1506
+ await waitForRenderer('app theme', `document.documentElement.style.colorScheme === ${JSON.stringify(colorScheme)} && document.body.hasAttribute('data-ds-dark-theme') === ${JSON.stringify(colorScheme === 'dark')}`)
1507
+ const deadline = Date.now() + 15_000
1508
+ let matched = false
1509
+ while (Date.now() < deadline) {
1510
+ const panelColor = await mainWindow.webContents.executeJavaScript(`getComputedStyle(document.querySelector('[data-desktop-panel="browser"] header')).backgroundColor`)
1511
+ const colors = await nativeChrome.webContents.executeJavaScript(`({
1512
+ scheme: document.documentElement.style.colorScheme,
1513
+ shell: getComputedStyle(document.body).backgroundColor,
1514
+ tabs: getComputedStyle(document.querySelector('.tabs')).backgroundColor,
1515
+ })`)
1516
+ if (colors.scheme === colorScheme && colors.shell === panelColor && colors.tabs === panelColor
1517
+ && (previousPanelColor === undefined || panelColor !== previousPanelColor)) {
1518
+ matched = true
1519
+ previousPanelColor = panelColor
1520
+ break
1521
+ }
1522
+ await delay(50)
1523
+ }
1524
+ if (!matched) throw new Error(`Browser chrome does not match the ${colorScheme} app theme`)
1525
+ await waitForRenderer('terminal theme', `(() => {
1526
+ const viewports = [...document.querySelectorAll('[data-desktop-panel] .xterm-viewport')]
1527
+ return viewports.length === 2 && viewports.every(viewport =>
1528
+ getComputedStyle(viewport).backgroundColor === getComputedStyle(viewport.closest('[data-desktop-panel]')).backgroundColor)
1529
+ })()`)
1530
+ const artifacts = join(__dirname, '..', '..', '.artifacts')
1531
+ await mkdir(artifacts, { recursive: true })
1532
+ await writeFile(join(artifacts, `browser-theme-${colorScheme}.png`), (await mainWindow.capturePage()).toPNG())
1533
+ }
1534
+ } finally {
1535
+ nativeTheme.themeSource = systemTheme
1536
+ await hostRequest('settings.mutate', {
1537
+ ns: 'ui-theme', expectedRevision: themeRevision,
1538
+ ops: [themeSettings.user?.preference === undefined
1539
+ ? { op: 'unset', path: ['preference'] }
1540
+ : { op: 'set', path: ['preference'], value: themeSettings.user.preference }],
1541
+ })
1542
+ }
1543
+ await nativeChrome.webContents.executeJavaScript("document.querySelector('.tab-close').click()")
1544
+ await waitForRenderer('Browser panel dismissed after last tab', `!document.querySelector('[role="tab"][aria-controls="hydra-right-panel-surface-browser"]')`)
1545
+ await waitForHiddenBrowser('last browser tab closed')
1546
+ const afterClose = browser.getState()
1547
+ if (BrowserWindow.getAllWindows().length !== 1 || afterClose?.tabs.length !== 0) {
1548
+ throw new Error(`last-tab close did not leave the desktop with an empty browser: ${JSON.stringify(afterClose)}`)
1549
+ }
1550
+ const reopened = await browser.command('get_browser_state')
1551
+ if (reopened.tabs.length !== 1 || reopened.url !== 'about:blank') {
1552
+ throw new Error(`agent could not reopen the browser: ${JSON.stringify(reopened)}`)
1553
+ }
1554
+ assertNativeViews(await waitForBounds('agent reopens browser', bounds => bounds.width < expanded.width))
1555
+ await selectControl('Close Browser')
1556
+ await waitForHiddenBrowser('Browser panel tab closed')
1557
+ await browser.command('get_browser_state')
1558
+ assertNativeViews(await waitForBounds('agent restores Browser panel tab', bounds => bounds.width < expanded.width))
1559
+ process.stdout.write(`${JSON.stringify({
1560
+ event: 'desktop-smoke',
1561
+ ok: true,
1562
+ windows: 1,
1563
+ files: { root: files.root, shortcut: 'Ctrl+P', search: true, preview: true, confined: true },
1564
+ terminal: { rightMarker, bottomMarker, secondMarker, bottomTabs, workspaceCwd: true, simultaneous: true, isolated: true, appTheme: true, multipleTabs: true, powershell: process.platform === 'win32' },
1565
+ browser: { lastTabClosesPanel: true, agentReopens: true, appTheme: true },
1566
+ bounds: { right, simultaneous, expanded, restored },
1567
+ })}\n`)
1568
+ }
1569
+
1570
+ async function shutdown() {
1571
+ if (shuttingDown !== undefined) return await shuttingDown
1572
+ shuttingDown = (async () => {
1573
+ const terminalIds = new Set([...terminals.keys(), ...terminalTails.keys()])
1574
+ try { await Promise.all([...terminalIds].map(id => queueTerminal(id, () => stopTerminal(id)))) } catch (error) { process.stderr.write(`${String(error)}\n`) }
1575
+ try { await browser?.dispose() } catch (error) { process.stderr.write(`${String(error)}\n`) }
1576
+ try { await cleanupSmokeWorkspace() } catch (error) {
1577
+ process.stderr.write(`desktop: smoke Workspace cleanup failed: ${String(error)}\n`)
1578
+ if (SMOKE) process.exitCode = 1
1579
+ }
1580
+ if (host !== undefined && !hostExited) {
1581
+ const exited = once(host, 'exit')
1582
+ host.postMessage({ type: 'shutdown' })
1583
+ const stopped = await Promise.race([exited.then(() => true), delay(HOST_SHUTDOWN_TIMEOUT_MS).then(() => false)])
1584
+ if (!stopped) host.kill()
1585
+ if (SMOKE && (!stopped || hostExitCode !== 0)) {
1586
+ process.stderr.write(`desktop: Host shutdown was not clean (code ${String(hostExitCode)})\n`)
1587
+ process.exitCode = 1
1588
+ }
1589
+ }
1590
+ mainWindow?.destroy()
1591
+ app.exit(process.exitCode ?? 0)
1592
+ })()
1593
+ return await shuttingDown
1594
+ }
1595
+
1596
+ app.whenReady().then(async () => {
1597
+ try {
1598
+ mainWindow = createWindow()
1599
+ browser = await installBrowserController(mainWindow)
1600
+ installRendererIpc()
1601
+ hostBaseUrl = await startHost()
1602
+ if (SMOKE) await prepareSmokeWorkspace()
1603
+ await mainWindow.loadURL(hostBaseUrl)
1604
+ if (!SMOKE) mainWindow.show()
1605
+ mainWindow.on('close', event => {
1606
+ if (shuttingDown === undefined) {
1607
+ event.preventDefault()
1608
+ void shutdown()
1609
+ }
1610
+ })
1611
+ if (SMOKE) {
1612
+ if (SMOKE_ZOOM) {
1613
+ await smokeZoomShortcuts()
1614
+ process.stdout.write(`${JSON.stringify({ event: 'desktop-zoom-smoke', ok: true })}\n`)
1615
+ } else {
1616
+ await smoke()
1617
+ }
1618
+ await shutdown()
1619
+ }
1620
+ } catch (error) {
1621
+ process.stderr.write(`desktop: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`)
1622
+ process.exitCode = 1
1623
+ await shutdown()
1624
+ }
1625
+ })
1626
+
1627
+ app.on('window-all-closed', () => { if (shuttingDown === undefined) void shutdown() })