@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/LICENSE +21 -0
- package/assets/hydra.ico +0 -0
- package/assets/hydra.png +0 -0
- package/main.cjs +1627 -0
- package/package.json +36 -0
- package/preload.cjs +90 -0
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() })
|