diff --git a/apps/desktop/electron/backend-env.ts b/apps/desktop/electron/backend-env.ts index 506575618..a225e2381 100644 --- a/apps/desktop/electron/backend-env.ts +++ b/apps/desktop/electron/backend-env.ts @@ -44,12 +44,14 @@ function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) { if (!entry) { continue } + const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter) for (const part of parts) { if (!part || seen.has(part)) { continue } + seen.add(part) ordered.push(part) } @@ -77,6 +79,7 @@ function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatfor if (!hermesHome) { return hermesHome } + const resolved = pathModule.resolve(String(hermesHome)) const parent = pathModule.dirname(resolved) diff --git a/apps/desktop/electron/backend-ready.ts b/apps/desktop/electron/backend-ready.ts index 78f395c59..05b9b6735 100644 --- a/apps/desktop/electron/backend-ready.ts +++ b/apps/desktop/electron/backend-ready.ts @@ -60,6 +60,7 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs()) if (done) { return } + done = true clearTimeout(timer) child.stdout.off('data', onData) @@ -130,12 +131,14 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno if (done) { return } + done = true clearTimeout(timer) if (interval) { clearInterval(interval) } + child.off('exit', onExit) child.off('error', onError) } @@ -171,6 +174,7 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno if (typeof interval.unref === 'function') { interval.unref() } + check() }) } diff --git a/apps/desktop/electron/bootstrap-runner.ts b/apps/desktop/electron/bootstrap-runner.ts index b50798f6a..f6fec0931 100644 --- a/apps/desktop/electron/bootstrap-runner.ts +++ b/apps/desktop/electron/bootstrap-runner.ts @@ -42,7 +42,6 @@ import { hiddenWindowsChildOptions } from './windows-child-options' const IS_WINDOWS = process.platform === 'win32' - const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i // Stages flagged needs_user_input=true in the manifest are skipped by the @@ -68,6 +67,7 @@ function resolveLocalInstallScript(sourceRepoRoot) { if (!sourceRepoRoot) { return null } + const candidate = path.join(sourceRepoRoot, 'scripts', installScriptName()) try { @@ -91,6 +91,7 @@ function installedAgentInstallScript(hermesHome) { if (!hermesHome) { return null } + const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName()) try { @@ -420,6 +421,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme if (abortSignal) { abortSignal.removeEventListener('abort', onAbort) } + reject(err) }) @@ -436,6 +438,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme if (stderrBuf) { emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' } as any) } + resolve({ stdout, stderr, code, signal, killed } as any) }) }) @@ -512,6 +515,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome if (abortSignal) { abortSignal.removeEventListener('abort', onAbort) } + reject(err) }) @@ -527,6 +531,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome if (stderrBuf) { emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' }) } + resolve({ stdout, stderr, code, signal, killed }) }) }) diff --git a/apps/desktop/electron/desktop-uninstall.ts b/apps/desktop/electron/desktop-uninstall.ts index feebe6595..3d6448653 100644 --- a/apps/desktop/electron/desktop-uninstall.ts +++ b/apps/desktop/electron/desktop-uninstall.ts @@ -106,6 +106,7 @@ function resolveRemovableAppPath(execPath, platform, env: any = {}) { if (env.APPIMAGE) { return env.APPIMAGE } + // Unpacked electron-builder tree: …/linux-unpacked/hermes const dir = p.dirname(exe) diff --git a/apps/desktop/electron/gateway-ws-probe.ts b/apps/desktop/electron/gateway-ws-probe.ts index 9ed146714..152e20b4d 100644 --- a/apps/desktop/electron/gateway-ws-probe.ts +++ b/apps/desktop/electron/gateway-ws-probe.ts @@ -80,6 +80,7 @@ function probeGatewayWebSocket( if (settled) { return } + settled = true clearTimers() @@ -107,6 +108,7 @@ function probeGatewayWebSocket( if (settled) { return } + opened = true // Upgrade accepted. Give the server a brief window to reject the // credential post-handshake (early close) before declaring success. @@ -189,6 +191,7 @@ function extractErrorReason(event) { if (event instanceof Error) { return event.message } + const err = event.error || event.message if (err instanceof Error) { diff --git a/apps/desktop/electron/hardening.ts b/apps/desktop/electron/hardening.ts index 5fb113229..2d6b53310 100644 --- a/apps/desktop/electron/hardening.ts +++ b/apps/desktop/electron/hardening.ts @@ -112,6 +112,7 @@ function sensitiveFileBlockReason(filePath) { function ipcPathError(code: any, message: string): Error & { code: any } { const error = new Error(message) as Error & { code: any } + ;(error as any).code = code return error diff --git a/apps/desktop/electron/link-title-window.ts b/apps/desktop/electron/link-title-window.ts index f636c4b07..0920976a5 100644 --- a/apps/desktop/electron/link-title-window.ts +++ b/apps/desktop/electron/link-title-window.ts @@ -55,6 +55,7 @@ export function readLinkTitleWindowTitle(window) { if (!window || window.isDestroyed()) { return '' } + const contents = window.webContents if (!contents || contents.isDestroyed()) { diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index a870e8bb3..e7921748d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -28,7 +28,7 @@ import { systemPreferences } from 'electron' import nodePty from 'node-pty' -import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' + import { stopBackendChild as stopBackendChildImpl } from './backend-child' import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env' @@ -96,6 +96,7 @@ import { } from './hardening' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' +import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' import { buildSessionWindowUrl, chatWindowWebPreferences, @@ -126,10 +127,10 @@ import { MIN_WIDTH as WINDOW_MIN_WIDTH } from './window-state' import { hiddenWindowsChildOptions } from './windows-child-options' +import { buildPathExtCandidates, chooseUpdaterArgs, resolveVenvHermesCommand } from './windows-hermes-path' import { readWindowsUserEnvVar } from './windows-user-env' import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd' import { readWslWindowsClipboardImage } from './wsl-clipboard-image' -import { buildPathExtCandidates, chooseUpdaterArgs, resolveVenvHermesCommand } from './windows-hermes-path' const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR @@ -154,7 +155,6 @@ const APP_ROOT = app.getAppPath() // Dev (`npm run dev`) and prod both load the esbuild output from dist/. const PRELOAD_PATH = path.join(APP_ROOT, 'dist', 'electron-preload.js') - // Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU // compositor flicker — accelerated layers can't be presented cleanly over the // wire, so the window flashes during scroll/streaming/animation. Local @@ -865,6 +865,7 @@ function planDesktopLogRotation(size) { if (size < DESKTOP_LOG_MAX_BYTES) { return [] } + const backups = n => Array.from({ length: n }, (_, i) => desktopLogBackupPath(i + 1)) // Pathological boot-loop log: reclaim live + every backup outright. @@ -932,6 +933,7 @@ function flushDesktopLogBufferSync() { if (!desktopLogBuffer) { return } + const chunk = desktopLogBuffer desktopLogBuffer = '' @@ -948,6 +950,7 @@ function flushDesktopLogBufferAsync() { if (!desktopLogBuffer) { return desktopLogFlushPromise } + const chunk = desktopLogBuffer desktopLogBuffer = '' @@ -968,6 +971,7 @@ function scheduleDesktopLogFlush() { if (desktopLogFlushTimer) { return } + desktopLogFlushTimer = setTimeout(() => { desktopLogFlushTimer = null void flushDesktopLogBufferAsync() @@ -980,6 +984,7 @@ function rememberLog(chunk) { if (!text) { return } + const lines = text.split(/\r?\n/).map(line => `[hermes] ${line}`) hermesLog.push(...lines) @@ -1178,11 +1183,13 @@ function broadcastBootProgress() { if (!mainWindow || mainWindow.isDestroyed()) { return } + const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) { return } + webContents.send('hermes:boot-progress', bootProgressState) } @@ -1262,11 +1269,13 @@ function broadcastBootstrapEvent(ev) { if (!mainWindow || mainWindow.isDestroyed()) { return } + const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) { return } + webContents.send('hermes:bootstrap:event', ev) } @@ -1469,6 +1478,7 @@ function backendSupportsServe(backend) { if (!backend || !backend.command) { return true } + const key = `${backend.command}::${backend.root || ''}` if (_serveSupportCache.has(key)) { @@ -2269,6 +2279,7 @@ function isShimLocked(shimPath) { if (!IS_WINDOWS) { return false } + let fd try { @@ -2406,6 +2417,7 @@ async function releaseBackendLock(updateRoot, tag) { for (const pid of stragglers) { forceKillProcessTree(pid) } + await new Promise(r => setTimeout(r, 300)) } @@ -2700,6 +2712,7 @@ function runningAppBundle() { if (!IS_MAC) { return null } + let dir = path.dirname(app.getPath('exe')) // .../Contents/MacOS for (let i = 0; i < 2; i++) { @@ -3126,6 +3139,7 @@ function resolveRendererIndex() { if (found) { return found } + // Nothing on disk. A packaged build with no renderer bundle blank-pages with // a bare ERR_FILE_NOT_FOUND and no clue why (see #39484). Surface the cause // and the fix before Electron loads the missing file. @@ -3174,6 +3188,7 @@ function resolveHermesCwd() { if (!candidate) { continue } + const resolved = path.resolve(String(candidate)) if (isPackagedInstallPath(resolved)) { @@ -3698,6 +3713,7 @@ function fetchJson(url, token, options: any = {}) { if (body) { req.write(body) } + req.end() }) } @@ -3787,6 +3803,7 @@ function fetchPublicJson(url, options: any = {}) { if (body) { req.write(body) } + req.end() }) } @@ -3904,6 +3921,7 @@ function cacheTitle(key, title) { if (titleCache.size >= TITLE_CACHE_LIMIT) { titleCache.delete(titleCache.keys().next().value) } + titleCache.set(key, title) } @@ -3958,6 +3976,7 @@ function fetchHtmlTitleWithCurl(rawUrl: string): Promise { if (bytes >= TITLE_BYTE_BUDGET) { return } + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) const remaining = TITLE_BYTE_BUDGET - bytes const next = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer @@ -3970,6 +3989,7 @@ function fetchHtmlTitleWithCurl(rawUrl: string): Promise { if (!chunks.length) { return resolve('') } + resolve(parseHtmlTitle(Buffer.concat(chunks).toString('utf8'))) }) }) @@ -3979,6 +3999,7 @@ function getLinkTitleSession() { if (linkTitleSession || !app.isReady()) { return linkTitleSession } + linkTitleSession = session.fromPartition('hermes:link-titles', { cache: false }) linkTitleSession.webRequest.onBeforeRequest((details, callback) => { callback({ cancel: RENDER_TITLE_BLOCKED_RESOURCES.has(details.resourceType) }) @@ -4021,6 +4042,7 @@ function runRenderTitleJob(rawUrl) { if (settled) { return } + settled = true if (hardTimer) { @@ -4030,6 +4052,7 @@ function runRenderTitleJob(rawUrl) { if (graceTimer) { clearTimeout(graceTimer) } + const value = (title || '').replace(/\s+/g, ' ').trim() try { @@ -4055,6 +4078,7 @@ function runRenderTitleJob(rawUrl) { if (graceTimer) { clearTimeout(graceTimer) } + graceTimer = setTimeout(finishWithTitle, RENDER_TITLE_GRACE_MS) } @@ -4136,6 +4160,7 @@ async function resourceBufferFromUrl(rawUrl) { if (!match) { throw new Error('Invalid data URL') } + const mimeType = match[1] || 'application/octet-stream' const encoded = match[3] || '' const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8') @@ -4184,6 +4209,7 @@ async function copyImageFromUrl(rawUrl) { if (image.isEmpty()) { throw new Error('Could not read image') } + clipboard.writeImage(image) } @@ -4199,6 +4225,7 @@ async function saveImageFromUrl(rawUrl) { if (result.canceled || !result.filePath) { return false } + await fs.promises.writeFile(result.filePath, buffer) return true @@ -4333,11 +4360,13 @@ function sendPreviewFileChanged(payload) { if (!mainWindow || mainWindow.isDestroyed()) { return } + const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) { return } + webContents.send('hermes:preview-file-changed', payload) } @@ -4358,12 +4387,14 @@ async function watchPreviewFile(rawUrl) { if (timer) { clearTimeout(timer) } + timer = setTimeout(() => { timer = null if (!fileExists(filePath)) { return } + sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() }) }, PREVIEW_WATCH_DEBOUNCE_MS) }) @@ -4373,6 +4404,7 @@ async function watchPreviewFile(rawUrl) { if (timer) { clearTimeout(timer) } + watcher.close() } }) @@ -4447,11 +4479,13 @@ function sendBackendExit(payload) { if (!mainWindow || mainWindow.isDestroyed()) { return } + const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) { return } + webContents.send('hermes:backend-exit', payload) } @@ -4459,11 +4493,13 @@ function sendClosePreviewRequested() { if (!mainWindow || mainWindow.isDestroyed()) { return } + const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) { return } + webContents.send('hermes:close-preview-requested') } @@ -4474,11 +4510,13 @@ function sendPowerResume() { if (!mainWindow || mainWindow.isDestroyed()) { return } + const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) { return } + webContents.send('hermes:power-resume') } @@ -4488,6 +4526,7 @@ function registerPowerResumeListeners() { if (powerResumeRegistered) { return } + powerResumeRegistered = true try { @@ -4509,16 +4548,19 @@ function sendOpenUpdatesRequested() { if (!mainWindow || mainWindow.isDestroyed()) { return } + const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) { return } + webContents.send('hermes:open-updates') if (!mainWindow.isVisible()) { mainWindow.show() } + mainWindow.focus() } @@ -4526,11 +4568,13 @@ function sendWindowStateChanged(nextIsFullscreen?: boolean) { if (!mainWindow || mainWindow.isDestroyed()) { return } + const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) { return } + const state = getWindowState() if (typeof nextIsFullscreen === 'boolean') { @@ -4675,6 +4719,7 @@ function installDevToolsShortcut(window) { if (!isInspectShortcut) { return } + event.preventDefault() toggleDevTools(window) }) @@ -4710,6 +4755,7 @@ function setAndPersistZoomLevel(window, zoomLevel) { if (!window || window.isDestroyed()) { return } + const next = clampZoomLevel(zoomLevel) window.webContents.setZoomLevel(next) // Keep any open settings UI in sync, including changes made via the @@ -4726,6 +4772,7 @@ function restorePersistedZoomLevel(window) { if (!window || window.isDestroyed()) { return } + window.webContents .executeJavaScript( `(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()` @@ -4734,6 +4781,7 @@ function restorePersistedZoomLevel(window) { if (stored == null || !window || window.isDestroyed()) { return } + const level = clampZoomLevel(Number(stored)) window.webContents.setZoomLevel(level) }) @@ -4810,6 +4858,7 @@ function installContextMenu(window) { if (template.length) { template.push({ type: 'separator' }) } + template.push( { label: 'Open Link', @@ -4963,6 +5012,7 @@ function getOauthSession() { if (oauthSession || !app.isReady()) { return oauthSession } + oauthSession = session.fromPartition(OAUTH_SESSION_PARTITION) return oauthSession @@ -4978,6 +5028,7 @@ async function hasOauthSessionCookie(baseUrl) { if (!sess) { return false } + const parsed = new URL(baseUrl) try { @@ -5010,6 +5061,7 @@ async function hasLiveOauthSession(baseUrl) { if (!sess) { return false } + const parsed = new URL(baseUrl) try { @@ -5092,6 +5144,7 @@ function openOauthLoginWindow(baseUrl, { silent = false } = {}) { if (settled) { return } + settled = true if (pollTimer) { @@ -5261,6 +5314,7 @@ function fetchJsonViaOauthSession(url, options: any = {}) { if (timedOut) { return } + clearTimeout(timer) const text = Buffer.concat(chunks).toString('utf8') const statusCode = res.statusCode || 500 @@ -5299,6 +5353,7 @@ function fetchJsonViaOauthSession(url, options: any = {}) { if (timedOut) { return } + clearTimeout(timer) reject(error) }) @@ -5306,6 +5361,7 @@ function fetchJsonViaOauthSession(url, options: any = {}) { if (body) { request.write(body) } + request.end() }) } @@ -5711,6 +5767,7 @@ function sanitizeConnectionProfiles(raw: Record) { } = { mode: modeIsRemoteLike(entry.mode) ? entry.mode : 'local' } + const url = String(entry.url || '').trim() if (url) { @@ -6400,6 +6457,7 @@ function touchPoolBackend(profile) { if (!key) { return } + const entry = backendPool.get(key) if (entry) { @@ -6415,6 +6473,7 @@ function evictLruPoolBackends(keep) { if (backendPool.size <= keep) { return } + const now = Date.now() const evictable = [...backendPool.entries()] @@ -6427,6 +6486,7 @@ function evictLruPoolBackends(keep) { if (removable <= 0) { break } + rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${POOL_MAX_BACKENDS})`) stopPoolBackend(profile) removable -= 1 @@ -6437,6 +6497,7 @@ function startPoolIdleReaper() { if (poolIdleReaper) { return } + poolIdleReaper = setInterval(() => { const now = Date.now() @@ -6589,6 +6650,7 @@ function stopPoolBackend(profile) { if (!entry) { return } + backendPool.delete(profile) stopBackendChild(entry.process) } @@ -6599,6 +6661,7 @@ async function teardownPoolBackendAndWait(profile) { if (!entry) { return } + backendPool.delete(profile) stopBackendChild(entry.process) @@ -6623,6 +6686,7 @@ function stopAllPoolBackends() { // that decision calls for. async function prepareProfileDeleteRequest(request) { const profile = profileNameFromDeleteRequest(request) + const decision = decideProfileDeleteAction(profile, { isDefaultProfile: p => p === 'default', isValidProfileName: p => PROFILE_NAME_RE.test(p), @@ -6923,6 +6987,7 @@ function focusWindow(win) { if (!win.isVisible()) { win.show() } + win.focus() } @@ -7360,6 +7425,7 @@ ipcMain.on('hermes:zoom:set-percent', (event, percent) => { if (!window || window.isDestroyed()) { return } + setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent))) }) @@ -7861,6 +7927,7 @@ ipcMain.handle('hermes:notify', (_event, payload) => { if (!Notification.isSupported()) { return false } + // Action buttons render only on signed macOS builds; elsewhere they're dropped // and the body click still works. const actions = Array.isArray(payload?.actions) ? payload.actions : [] @@ -7876,6 +7943,7 @@ ipcMain.handle('hermes:notify', (_event, payload) => { if (!mainWindow || mainWindow.isDestroyed()) { return } + focusWindow(mainWindow) if (payload?.sessionId) { @@ -7886,6 +7954,7 @@ ipcMain.handle('hermes:notify', (_event, payload) => { if (!mainWindow || mainWindow.isDestroyed()) { return } + const action = actions[index] if (action?.id) { @@ -8687,6 +8756,7 @@ async function getUninstallSummary() { if (settled) { return } + settled = true resolve(value) } @@ -8881,6 +8951,7 @@ function handleDeepLink(url) { if (!url || typeof url !== 'string') { return } + let parsed try { @@ -8910,6 +8981,7 @@ function handleDeepLink(url) { if (mainWindow.isMinimized()) { mainWindow.restore() } + mainWindow.focus() mainWindow.webContents.send('hermes:deep-link', payload) rememberLog(`[deeplink] delivered ${kind}/${name}`) @@ -8966,6 +9038,7 @@ if (!_gotSingleInstanceLock) { if (mainWindow.isMinimized()) { mainWindow.restore() } + mainWindow.focus() } }) diff --git a/apps/desktop/electron/update-marker.test.ts b/apps/desktop/electron/update-marker.test.ts index 2910366f3..e6a1da96c 100644 --- a/apps/desktop/electron/update-marker.test.ts +++ b/apps/desktop/electron/update-marker.test.ts @@ -40,6 +40,7 @@ const ALIVE: typeof process.kill = () => true // injected kill that "succeeds" = const DEAD: typeof process.kill = () => { const err = new Error('no such process') + ;(err as any).code = 'ESRCH' throw err } @@ -93,6 +94,7 @@ test('isPidAlive: own pid is alive, impossible pid is dead', () => { test('isPidAlive: EPERM counts as alive (process owned by another user)', () => { const eperm = () => { const err = new Error('operation not permitted') + ;(err as any).code = 'EPERM' throw err } diff --git a/apps/desktop/electron/update-relaunch.ts b/apps/desktop/electron/update-relaunch.ts index cb1c09557..46ea789bb 100644 --- a/apps/desktop/electron/update-relaunch.ts +++ b/apps/desktop/electron/update-relaunch.ts @@ -63,6 +63,7 @@ function resolveUnpackedRelease(execPath, updateRoot, platform) { if (!execPath || !updateRoot) { return null } + const releaseDir = path.join(updateRoot, 'apps', 'desktop', 'release') const unpacked = path.join(releaseDir, unpackedDirName(platform)) const normalizedExec = path.resolve(String(execPath)) @@ -116,6 +117,7 @@ function sandboxPreflight(unpackedDir, statSync) { if (!unpackedDir) { return { ok: false, reason: 'no-unpacked-dir', path: null } } + const sandboxPath = path.join(unpackedDir, 'chrome-sandbox') let st diff --git a/apps/desktop/electron/update-remote.ts b/apps/desktop/electron/update-remote.ts index c1f4b8274..1c5a6d57c 100644 --- a/apps/desktop/electron/update-remote.ts +++ b/apps/desktop/electron/update-remote.ts @@ -22,6 +22,7 @@ function canonicalGitHubRemote(url) { if (!url) { return '' } + let value = String(url).trim() if (value.startsWith('git@github.com:')) { diff --git a/apps/desktop/electron/window-state.ts b/apps/desktop/electron/window-state.ts index d443c6aaf..56510e882 100644 --- a/apps/desktop/electron/window-state.ts +++ b/apps/desktop/electron/window-state.ts @@ -61,6 +61,7 @@ function onScreen(bounds, displays) { if (!a) { return false } + const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x) const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y) diff --git a/apps/desktop/electron/windows-user-env.ts b/apps/desktop/electron/windows-user-env.ts index fa91590be..890cd6f4e 100644 --- a/apps/desktop/electron/windows-user-env.ts +++ b/apps/desktop/electron/windows-user-env.ts @@ -23,6 +23,7 @@ function parseRegQueryValue(stdout, name) { if (!stdout || !name) { return null } + const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/ for (const rawLine of String(stdout).split(/\r?\n/)) { @@ -70,6 +71,7 @@ function readWindowsUserEnvVar( if (platform !== 'win32' || !name) { return null } + let stdout try { @@ -88,6 +90,7 @@ function readWindowsUserEnvVar( if (raw == null) { return null } + const expanded = expandWindowsEnvRefs(raw, env).trim() return expanded || null diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index 135e9e268..aac86af93 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/apps/desktop/src/app/shell/model-menu-panel.test.tsx b/apps/desktop/src/app/shell/model-menu-panel.test.tsx index 301a5d2c5..7ca78274f 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.test.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { cleanup, findByText, fireEvent, render } from '@testing-library/react' +import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { DropdownMenu, DropdownMenuContent } from '@/components/ui/dropdown-menu' @@ -39,7 +39,8 @@ afterEach(() => { function renderPanel(onSelectModel = vi.fn()) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - render( + + const content = render( @@ -49,15 +50,15 @@ function renderPanel(onSelectModel = vi.fn()) { ) - return onSelectModel + return { onSelectModel, content } } describe('ModelMenuPanel MoA presets', () => { it('selecting a MoA preset switches PERSISTENTLY via onSelectModel (not the one-shot dispatch)', async () => { - const onSelectModel = renderPanel() + const { content, onSelectModel } = renderPanel() // moaOptions is async (useQuery) — wait for the preset row to mount. - const row = await findByText(document.body, 'MoA: BeastMode') + const row = await content.findByText('MoA: BeastMode') fireEvent.click(row) // #54670: must route through the persistent model-switch path @@ -69,30 +70,35 @@ describe('ModelMenuPanel MoA presets', () => { it('shows the check on the preset that matches the current moa selection', async () => { $currentProvider.set('moa') $currentModel.set('BeastMode') - renderPanel() + const { content } = renderPanel() - const row = await findByText(document.body, 'MoA: BeastMode') + const row = await content.findByText('MoA: BeastMode') // The check codicon renders as a sibling within the same row item. const item = row.closest('[role="menuitem"]') ?? row.parentElement expect(item?.querySelector('.codicon-check')).not.toBeNull() }) it('keeps the virtual moa provider out of the main model groups (presets section only)', async () => { - renderPanel() + const { content } = renderPanel() - await findByText(document.body, 'MoA: BeastMode') + await content.findByText('MoA: BeastMode') // The provider group header would read "Mixture of Agents"; the presets // section header reads "MoA presets". Only the latter should exist. + // Radix DropdownMenu portals its content to document.body, so assert + // against the body (not content.container) to see the rendered items. + + // eslint-disable-next-line no-restricted-globals expect(document.body.textContent).toContain('MoA presets') + // eslint-disable-next-line no-restricted-globals expect(document.body.textContent).not.toContain('Mixture of Agents') }) it('renders presets from the catalog even before a session exists', async () => { $activeSessionId.set('') - const onSelectModel = renderPanel() + const { onSelectModel, content } = renderPanel() - const row = await findByText(document.body, 'MoA: BeastMode') + const row = await content.findByText('MoA: BeastMode') fireEvent.click(row) // Pre-session picks are UI state shipped on the next session.create — the