Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions apps/desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ const { execFileSync, spawn } = require('node:child_process')
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { runBootstrap } = require('./bootstrap-runner.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const {
getWindowButtonPosition: computeWindowButtonPosition,
getWindowState: computeWindowState
} = require('./window-state.cjs')
const {
DATA_URL_READ_MAX_BYTES,
DEFAULT_FETCH_TIMEOUT_MS,
Expand Down Expand Up @@ -2660,8 +2664,10 @@ async function waitForHermes(baseUrl, token) {
}

function getWindowButtonPosition() {
if (!IS_MAC) return null
return mainWindow?.getWindowButtonPosition?.() || WINDOW_BUTTON_POSITION
return computeWindowButtonPosition(mainWindow, {
isMac: IS_MAC,
fallbackButtonPosition: WINDOW_BUTTON_POSITION
})
}

function getNativeOverlayWidth() {
Expand All @@ -2672,12 +2678,17 @@ function getNativeOverlayWidth() {
return IS_MAC ? 0 : NATIVE_OVERLAY_BUTTON_WIDTH
}

// Reads geometry without ever throwing when mainWindow has been destroyed.
// startHermes() spreads this into its result AFTER the backend is ready; if the
// window was torn down mid-boot (updater relaunch / reconnect), a naive call
// here would throw "Object has been destroyed" and fail the whole boot. See
// window-state.cjs and #38468.
function getWindowState() {
return {
isFullscreen: Boolean(mainWindow?.isFullScreen?.()),
return computeWindowState(mainWindow, {
isMac: IS_MAC,
nativeOverlayWidth: getNativeOverlayWidth(),
windowButtonPosition: getWindowButtonPosition()
}
fallbackButtonPosition: WINDOW_BUTTON_POSITION
})
}

function sendBackendExit(payload) {
Expand Down
55 changes: 55 additions & 0 deletions apps/desktop/electron/window-state.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
'use strict'

// Helpers for reading BrowserWindow geometry that NEVER throw when the window
// has gone away.
//
// A destroyed Electron BrowserWindow is still a non-null JS object, so optional
// chaining (`win?.isFullScreen?.()`) does NOT protect against it — calling any
// native method on a destroyed window throws "Object has been destroyed". This
// bit the boot flow: startHermes() resolves with `...getWindowState()` once the
// backend is ready, and if the window was torn down in the meantime (updater
// relaunch, gateway reconnect), querying it rejected the whole boot with
// "Desktop boot failed: Object has been destroyed". See #38468.
//
// These functions live in their own module (no `electron` import) so they can
// be unit-tested with plain fake window objects.

function isWindowLive(win) {
if (!win) return false
try {
return typeof win.isDestroyed === 'function' ? !win.isDestroyed() : true
} catch {
// A window so far gone that even isDestroyed() throws is, definitionally,
// not safe to query.
return false
}
}

function getWindowButtonPosition(win, { isMac, fallbackButtonPosition } = {}) {
if (!isMac) return null
if (!isWindowLive(win)) return fallbackButtonPosition
try {
return win.getWindowButtonPosition?.() || fallbackButtonPosition
} catch {
return fallbackButtonPosition
}
}

function getWindowState(win, { isMac, nativeOverlayWidth, fallbackButtonPosition } = {}) {
let isFullscreen = false
if (isWindowLive(win)) {
try {
isFullscreen = Boolean(win.isFullScreen?.())
} catch {
isFullscreen = false
}
}

return {
isFullscreen,
nativeOverlayWidth,
windowButtonPosition: getWindowButtonPosition(win, { isMac, fallbackButtonPosition })
}
}

module.exports = { getWindowButtonPosition, getWindowState, isWindowLive }
95 changes: 95 additions & 0 deletions apps/desktop/electron/window-state.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
const assert = require('node:assert/strict')
const test = require('node:test')

const { getWindowButtonPosition, getWindowState, isWindowLive } = require('./window-state.cjs')

const FALLBACK = { x: 24, y: 6 }
const LIVE_BUTTON = { x: 99, y: 7 }

function liveWindow(overrides = {}) {
return {
isDestroyed: () => false,
isFullScreen: () => false,
getWindowButtonPosition: () => LIVE_BUTTON,
...overrides
}
}

function destroyedWindow() {
// A destroyed BrowserWindow keeps isDestroyed() working but throws on every
// other native accessor — exactly the shape that broke #38468.
return {
isDestroyed: () => true,
isFullScreen: () => {
throw new Error('Object has been destroyed')
},
getWindowButtonPosition: () => {
throw new Error('Object has been destroyed')
}
}
}

test('isWindowLive distinguishes null, destroyed, and live windows', () => {
assert.equal(isWindowLive(null), false)
assert.equal(isWindowLive(undefined), false)
assert.equal(isWindowLive(destroyedWindow()), false)
assert.equal(isWindowLive(liveWindow()), true)
// A window so far gone even isDestroyed() throws must read as not-live.
assert.equal(
isWindowLive({
isDestroyed: () => {
throw new Error('Object has been destroyed')
}
}),
false
)
})

test('getWindowState does not throw for a destroyed window (regression #38468)', () => {
const state = getWindowState(destroyedWindow(), {
isMac: true,
nativeOverlayWidth: 0,
fallbackButtonPosition: FALLBACK
})

assert.deepEqual(state, {
isFullscreen: false,
nativeOverlayWidth: 0,
windowButtonPosition: FALLBACK
})
})

test('getWindowState does not throw for a missing window', () => {
const state = getWindowState(null, { isMac: false, nativeOverlayWidth: 144, fallbackButtonPosition: FALLBACK })

assert.deepEqual(state, {
isFullscreen: false,
nativeOverlayWidth: 144,
windowButtonPosition: null
})
})

test('getWindowState reflects a live window', () => {
const state = getWindowState(liveWindow({ isFullScreen: () => true }), {
isMac: true,
nativeOverlayWidth: 0,
fallbackButtonPosition: FALLBACK
})

assert.deepEqual(state, {
isFullscreen: true,
nativeOverlayWidth: 0,
windowButtonPosition: LIVE_BUTTON
})
})

test('getWindowButtonPosition returns null off macOS and falls back when not live', () => {
assert.equal(getWindowButtonPosition(liveWindow(), { isMac: false, fallbackButtonPosition: FALLBACK }), null)
assert.equal(getWindowButtonPosition(destroyedWindow(), { isMac: true, fallbackButtonPosition: FALLBACK }), FALLBACK)
assert.equal(getWindowButtonPosition(liveWindow(), { isMac: true, fallbackButtonPosition: FALLBACK }), LIVE_BUTTON)
})

test('getWindowButtonPosition falls back when the live window returns nothing', () => {
const win = liveWindow({ getWindowButtonPosition: () => null })
assert.equal(getWindowButtonPosition(win, { isMac: true, fallbackButtonPosition: FALLBACK }), FALLBACK)
})
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/window-state.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
Expand Down
Loading