perf(ssh): visibility-gate and back off the remote port scanner (#7610)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
a09a00f066
commit
c0091c07c3
|
|
@ -111,6 +111,7 @@ import {
|
|||
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
|
||||
import { createSystemTray, destroySystemTray } from './tray/system-tray'
|
||||
import { focusExistingMainWindow } from './window/focus-existing-window'
|
||||
import { notifyMainWindowBecameVisible } from './window/main-window-visibility'
|
||||
import { CodexAccountService } from './codex-accounts/service'
|
||||
import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service'
|
||||
import {
|
||||
|
|
@ -986,6 +987,11 @@ function openMainWindow(): BrowserWindow {
|
|||
window.on('restore', resumeSyntheticTitleSpinnerTimer)
|
||||
window.on('hide', stopSyntheticTitleSpinnerTimer)
|
||||
window.on('minimize', stopSyntheticTitleSpinnerTimer)
|
||||
// Why: visibility-gated main-process pollers (SSH port scanner) park while
|
||||
// hidden and rely on this signal to resume; re-wired per window because
|
||||
// macOS dock re-activation recreates the BrowserWindow.
|
||||
window.on('show', notifyMainWindowBecameVisible)
|
||||
window.on('restore', notifyMainWindowBecameVisible)
|
||||
agentHookServer.setListener(
|
||||
({
|
||||
paneKey,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
PortScanner,
|
||||
SSH_PORT_SCAN_BASE_INTERVAL_MS,
|
||||
SSH_PORT_SCAN_MAX_INTERVAL_MS,
|
||||
type PortScannerWindowVisibility
|
||||
} from './ssh-port-scanner'
|
||||
import type { SshChannelMultiplexer } from './ssh-channel-multiplexer'
|
||||
import type { DetectedPort } from '../../shared/ssh-types'
|
||||
|
||||
type VisibilityHarness = {
|
||||
visibility: PortScannerWindowVisibility
|
||||
setVisible: (visible: boolean) => void
|
||||
listenerCount: () => number
|
||||
}
|
||||
|
||||
function createVisibilityHarness(initiallyVisible: boolean): VisibilityHarness {
|
||||
let visible = initiallyVisible
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
visibility: {
|
||||
isWindowVisible: () => visible,
|
||||
onWindowBecameVisible: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
},
|
||||
setVisible: (next) => {
|
||||
const wasVisible = visible
|
||||
visible = next
|
||||
if (!wasVisible && next) {
|
||||
for (const listener of Array.from(listeners)) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
},
|
||||
listenerCount: () => listeners.size
|
||||
}
|
||||
}
|
||||
|
||||
function port(portNumber: number): DetectedPort {
|
||||
return { port: portNumber, host: '127.0.0.1', pid: 100 + portNumber, processName: 'node' }
|
||||
}
|
||||
|
||||
function createMux(ports: () => DetectedPort[]): {
|
||||
mux: SshChannelMultiplexer
|
||||
request: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const request = vi.fn(async () => ({ ports: ports(), platform: 'linux' }))
|
||||
return { mux: { request } as unknown as SshChannelMultiplexer, request }
|
||||
}
|
||||
|
||||
const BASE = SSH_PORT_SCAN_BASE_INTERVAL_MS
|
||||
const MAX = SSH_PORT_SCAN_MAX_INTERVAL_MS
|
||||
|
||||
describe('PortScanner', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sends zero ports.detect requests while the window is hidden, then scans immediately on show', async () => {
|
||||
const harness = createVisibilityHarness(false)
|
||||
const { mux, request } = createMux(() => [port(3000)])
|
||||
const scanner = new PortScanner(harness.visibility)
|
||||
scanner.startScanning('t1', mux, vi.fn())
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000)
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
|
||||
harness.setVisible(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
|
||||
scanner.dispose()
|
||||
})
|
||||
|
||||
it('polls at the base cadence while visible when results keep changing', async () => {
|
||||
const harness = createVisibilityHarness(true)
|
||||
let next = 3000
|
||||
const { mux, request } = createMux(() => [port(next++)])
|
||||
const scanner = new PortScanner(harness.visibility)
|
||||
scanner.startScanning('t1', mux, vi.fn())
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
|
||||
// 5 simulated base intervals -> 5 more scans (no backoff while changing).
|
||||
await vi.advanceTimersByTimeAsync(5 * BASE)
|
||||
expect(request).toHaveBeenCalledTimes(6)
|
||||
|
||||
scanner.dispose()
|
||||
})
|
||||
|
||||
it('doubles the interval up to the cap while unchanged and resets to base on a change', async () => {
|
||||
const harness = createVisibilityHarness(true)
|
||||
let ports = [port(3000)]
|
||||
const { mux, request } = createMux(() => ports)
|
||||
const onChanged = vi.fn()
|
||||
const scanner = new PortScanner(harness.visibility)
|
||||
scanner.startScanning('t1', mux, onChanged)
|
||||
|
||||
// t=0: first scan is a change (empty -> {3000}) and stays at base.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(onChanged).toHaveBeenCalledTimes(1)
|
||||
|
||||
// t=12s: unchanged -> next wait doubles to 24s.
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
|
||||
// t=24s: nothing (waiting until t=36s).
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
|
||||
// t=36s: unchanged -> next wait caps at 30s (not 48s).
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(request).toHaveBeenCalledTimes(3)
|
||||
|
||||
// t=65.999s: still waiting.
|
||||
await vi.advanceTimersByTimeAsync(MAX - 1)
|
||||
expect(request).toHaveBeenCalledTimes(3)
|
||||
|
||||
// t=66s: unchanged -> next wait remains capped at 30s.
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(request).toHaveBeenCalledTimes(4)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(MAX - 1)
|
||||
expect(request).toHaveBeenCalledTimes(4)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(request).toHaveBeenCalledTimes(5)
|
||||
expect(onChanged).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A changed result resets the cadence back to base.
|
||||
ports = [port(3000), port(4000)]
|
||||
await vi.advanceTimersByTimeAsync(MAX)
|
||||
expect(request).toHaveBeenCalledTimes(6)
|
||||
expect(onChanged).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(request).toHaveBeenCalledTimes(7)
|
||||
expect(scanner.getDetectedPorts('t1').map((p) => p.port)).toEqual([3000, 4000])
|
||||
|
||||
scanner.dispose()
|
||||
})
|
||||
|
||||
it('resets backoff and scans immediately when scanning restarts (reconnect/session-ready)', async () => {
|
||||
const harness = createVisibilityHarness(true)
|
||||
const { mux, request } = createMux(() => [port(3000)])
|
||||
const scanner = new PortScanner(harness.visibility)
|
||||
const onChanged = vi.fn()
|
||||
scanner.startScanning('t1', mux, onChanged)
|
||||
|
||||
// Back off: scans at t=0, t=12s, t=36s -> interval now 48s.
|
||||
await vi.advanceTimersByTimeAsync(3 * BASE)
|
||||
expect(request).toHaveBeenCalledTimes(3)
|
||||
|
||||
// Reconnect paths call startScanning again: immediate scan, base cadence.
|
||||
scanner.startScanning('t1', mux, onChanged)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(request).toHaveBeenCalledTimes(4)
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(request).toHaveBeenCalledTimes(5)
|
||||
|
||||
scanner.dispose()
|
||||
})
|
||||
|
||||
it('parks when the window hides mid-run and resumes with an immediate scan on show', async () => {
|
||||
const harness = createVisibilityHarness(true)
|
||||
let next = 3000
|
||||
const { mux, request } = createMux(() => [port(next++)])
|
||||
const scanner = new PortScanner(harness.visibility)
|
||||
scanner.startScanning('t1', mux, vi.fn())
|
||||
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
|
||||
harness.setVisible(false)
|
||||
await vi.advanceTimersByTimeAsync(30 * 60_000)
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
|
||||
harness.setVisible(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(request).toHaveBeenCalledTimes(3)
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(request).toHaveBeenCalledTimes(4)
|
||||
|
||||
scanner.dispose()
|
||||
})
|
||||
|
||||
it('never overlaps a slow in-flight request and resumes the chain after it settles', async () => {
|
||||
const harness = createVisibilityHarness(true)
|
||||
let resolveFirst: ((value: { ports: DetectedPort[]; platform: string }) => void) | null = null
|
||||
const request = vi.fn(
|
||||
() =>
|
||||
new Promise<{ ports: DetectedPort[]; platform: string }>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
const scanner = new PortScanner(harness.visibility)
|
||||
scanner.startScanning('t1', { request } as unknown as SshChannelMultiplexer, vi.fn())
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The chain waits for the in-flight request instead of stacking more.
|
||||
await vi.advanceTimersByTimeAsync(10 * BASE)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveFirst!({ ports: [port(3000)], platform: 'linux' })
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
|
||||
scanner.dispose()
|
||||
})
|
||||
|
||||
it('stopScanning halts polling and detaches the visibility listener', async () => {
|
||||
const harness = createVisibilityHarness(true)
|
||||
const { mux, request } = createMux(() => [port(3000)])
|
||||
const scanner = new PortScanner(harness.visibility)
|
||||
scanner.startScanning('t1', mux, vi.fn())
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(harness.listenerCount()).toBe(1)
|
||||
|
||||
scanner.stopScanning('t1')
|
||||
expect(harness.listenerCount()).toBe(0)
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000)
|
||||
harness.setVisible(false)
|
||||
harness.setVisible(true)
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(scanner.getDetectedPorts('t1')).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps targets independent: stopping one host leaves the other scanning', async () => {
|
||||
const harness = createVisibilityHarness(true)
|
||||
let nextA = 3000
|
||||
let nextB = 4000
|
||||
const a = createMux(() => [port(nextA++)])
|
||||
const b = createMux(() => [port(nextB++)])
|
||||
const scanner = new PortScanner(harness.visibility)
|
||||
scanner.startScanning('host-a', a.mux, vi.fn())
|
||||
scanner.startScanning('host-b', b.mux, vi.fn())
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(a.request).toHaveBeenCalledTimes(1)
|
||||
expect(b.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
scanner.stopScanning('host-a')
|
||||
await vi.advanceTimersByTimeAsync(BASE)
|
||||
expect(a.request).toHaveBeenCalledTimes(1)
|
||||
expect(b.request).toHaveBeenCalledTimes(2)
|
||||
|
||||
scanner.dispose()
|
||||
await vi.advanceTimersByTimeAsync(10 * BASE)
|
||||
expect(b.request).toHaveBeenCalledTimes(2)
|
||||
expect(harness.listenerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,10 +1,30 @@
|
|||
import type { SshChannelMultiplexer } from './ssh-channel-multiplexer'
|
||||
import type { DetectedPort } from '../../shared/ssh-types'
|
||||
|
||||
const POLL_INTERVAL_MS = 3_000
|
||||
// Why: every tick walks /proc/*/fd on the remote relay, so cadence is remote
|
||||
// CPU, not just a local timer. 12s cuts steady-state request volume 4x vs the
|
||||
// old 3s while keeping new-port detection well under the 30s workspace-scanner
|
||||
// cadence users already accept.
|
||||
export const SSH_PORT_SCAN_BASE_INTERVAL_MS = 12_000
|
||||
// Why: idle backoff cap — an unchanged port set doubles the interval up to
|
||||
// this bound, so a quiet remote costs two scans per minute instead of twenty
|
||||
// without exceeding the existing workspace-port scanner's visible cadence.
|
||||
export const SSH_PORT_SCAN_MAX_INTERVAL_MS = 30_000
|
||||
|
||||
export type PortScannerWindowVisibility = {
|
||||
isWindowVisible: () => boolean
|
||||
// Fires when a hidden/minimized window is shown again; returns unsubscribe.
|
||||
onWindowBecameVisible: (listener: () => void) => () => void
|
||||
}
|
||||
|
||||
type ScanHandle = {
|
||||
timer: ReturnType<typeof setInterval>
|
||||
timer: ReturnType<typeof setTimeout> | null
|
||||
intervalMs: number
|
||||
// Why: while the window is hidden the scan chain is parked outright — no
|
||||
// timer wakeups, no remote requests — and the visibility listener resumes
|
||||
// it with an immediate scan so ports opened while hidden surface at once.
|
||||
parkedWhileHidden: boolean
|
||||
unsubscribeVisibility: () => void
|
||||
// Why: keyed by "host:port" (not just port) so that host-distinct listeners
|
||||
// on the same port (e.g. 127.0.0.1:3000 + 0.0.0.0:3000) are tracked separately.
|
||||
previousPorts: Map<string, DetectedPort>
|
||||
|
|
@ -17,6 +37,8 @@ type ScanHandle = {
|
|||
export class PortScanner {
|
||||
private handles = new Map<string, ScanHandle>()
|
||||
|
||||
constructor(private visibility: PortScannerWindowVisibility) {}
|
||||
|
||||
startScanning(
|
||||
targetId: string,
|
||||
mux: SshChannelMultiplexer,
|
||||
|
|
@ -25,14 +47,18 @@ export class PortScanner {
|
|||
this.stopScanning(targetId)
|
||||
|
||||
const handle: ScanHandle = {
|
||||
timer: null!,
|
||||
timer: null,
|
||||
intervalMs: SSH_PORT_SCAN_BASE_INTERVAL_MS,
|
||||
parkedWhileHidden: false,
|
||||
unsubscribeVisibility: () => {},
|
||||
previousPorts: new Map(),
|
||||
initialPorts: null
|
||||
}
|
||||
const isCurrent = (): boolean => this.handles.get(targetId) === handle
|
||||
|
||||
// Why: guard against overlapping scans. On slow remotes, /proc/*/fd walks
|
||||
// can take longer than POLL_INTERVAL_MS. Without this guard, setInterval
|
||||
// would stack up concurrent requests on the shared SSH multiplexer.
|
||||
// Why: guard against overlapping scans. The timer chain only reschedules
|
||||
// after a poll completes, but the visibility-resume path can race a poll
|
||||
// still in flight on a slow remote.
|
||||
let polling = false
|
||||
const poll = async (): Promise<void> => {
|
||||
if (polling) {
|
||||
|
|
@ -45,7 +71,7 @@ export class PortScanner {
|
|||
platform: string
|
||||
}
|
||||
|
||||
if (!this.handles.has(targetId)) {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +86,10 @@ export class PortScanner {
|
|||
|
||||
if (!portsEqual(handle.previousPorts, currentPorts)) {
|
||||
handle.previousPorts = currentPorts
|
||||
handle.intervalMs = SSH_PORT_SCAN_BASE_INTERVAL_MS
|
||||
onChanged(targetId, result.ports, result.platform)
|
||||
} else {
|
||||
handle.intervalMs = Math.min(handle.intervalMs * 2, SSH_PORT_SCAN_MAX_INTERVAL_MS)
|
||||
}
|
||||
} catch {
|
||||
// Relay disconnected or request timed out — retry on next interval
|
||||
|
|
@ -69,10 +98,32 @@ export class PortScanner {
|
|||
}
|
||||
}
|
||||
|
||||
handle.timer = setInterval(() => void poll(), POLL_INTERVAL_MS)
|
||||
this.handles.set(targetId, handle)
|
||||
const tick = async (): Promise<void> => {
|
||||
handle.timer = null
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
if (!this.visibility.isWindowVisible()) {
|
||||
handle.parkedWhileHidden = true
|
||||
return
|
||||
}
|
||||
await poll()
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
handle.timer = setTimeout(() => void tick(), handle.intervalMs)
|
||||
}
|
||||
|
||||
void poll()
|
||||
handle.unsubscribeVisibility = this.visibility.onWindowBecameVisible(() => {
|
||||
if (!isCurrent() || !handle.parkedWhileHidden) {
|
||||
return
|
||||
}
|
||||
handle.parkedWhileHidden = false
|
||||
void tick()
|
||||
})
|
||||
|
||||
this.handles.set(targetId, handle)
|
||||
void tick()
|
||||
}
|
||||
|
||||
getDetectedPorts(targetId: string): DetectedPort[] {
|
||||
|
|
@ -88,7 +139,10 @@ export class PortScanner {
|
|||
if (!handle) {
|
||||
return
|
||||
}
|
||||
clearInterval(handle.timer)
|
||||
if (handle.timer) {
|
||||
clearTimeout(handle.timer)
|
||||
}
|
||||
handle.unsubscribeVisibility()
|
||||
this.handles.delete(targetId)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,10 @@ function createMockDeps() {
|
|||
} as unknown as SshPortForwardManager
|
||||
const mockWindow = {
|
||||
isDestroyed: () => false,
|
||||
// Why: the port scanner visibility-gates its ticks; a visible mock window
|
||||
// keeps establish-path tests exercising the scan-on-ready behavior.
|
||||
isVisible: () => true,
|
||||
isMinimized: () => false,
|
||||
webContents: { send: vi.fn() }
|
||||
}
|
||||
const getMainWindow = vi.fn().mockReturnValue(mockWindow)
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import {
|
|||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { notifyRemoteWorkspaceHandlers } from '../ipc/remote-workspace-events'
|
||||
import { PortScanner } from './ssh-port-scanner'
|
||||
import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility'
|
||||
import type { SshPortForwardManager } from './ssh-port-forward'
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
import { joinRemotePath, isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
|
|
@ -982,7 +983,13 @@ export class SshRelaySession {
|
|||
if (!this.mux || this.isDisposed()) {
|
||||
return
|
||||
}
|
||||
const scanner = new PortScanner()
|
||||
// Why: each scan walks /proc/*/fd on the remote host, so the scanner skips
|
||||
// ticks entirely while no window can show the results (hidden to tray or
|
||||
// minimized overnight) and rescans immediately when the window returns.
|
||||
const scanner = new PortScanner({
|
||||
isWindowVisible: () => isMainWindowVisible(this.getMainWindow()),
|
||||
onWindowBecameVisible: onMainWindowBecameVisible
|
||||
})
|
||||
this.portScanner = scanner
|
||||
// Why: capture the scanner instance so that a late ports.detect callback
|
||||
// from a previous relay session (before reconnect replaced it) is silently
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isMainWindowVisible } from './main-window-visibility'
|
||||
|
||||
describe('main window visibility helpers', () => {
|
||||
it('treats a minimal alive window double as visible', () => {
|
||||
expect(isMainWindowVisible({ isDestroyed: () => false })).toBe(true)
|
||||
})
|
||||
|
||||
it('parks work when the real window is hidden, minimized, destroyed, or missing', () => {
|
||||
expect(
|
||||
isMainWindowVisible({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => false,
|
||||
isMinimized: () => false
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isMainWindowVisible({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
isMinimized: () => true
|
||||
})
|
||||
).toBe(false)
|
||||
expect(isMainWindowVisible({ isDestroyed: () => true })).toBe(false)
|
||||
expect(isMainWindowVisible(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// Why: BrowserWindow instances are recreated on macOS dock re-activation, so
|
||||
// long-lived main-process services (e.g. the SSH port scanner) subscribe to
|
||||
// this process-global signal instead of a specific window instance; index.ts
|
||||
// re-wires each new window's show/restore events into notifyMainWindowBecameVisible.
|
||||
type MainWindowBecameVisibleListener = () => void
|
||||
|
||||
type MainWindowVisibilityState = {
|
||||
isDestroyed: () => boolean
|
||||
isVisible?: () => boolean
|
||||
isMinimized?: () => boolean
|
||||
}
|
||||
|
||||
const listeners = new Set<MainWindowBecameVisibleListener>()
|
||||
|
||||
export function notifyMainWindowBecameVisible(): void {
|
||||
for (const listener of Array.from(listeners)) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
export function onMainWindowBecameVisible(listener: MainWindowBecameVisibleListener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function isMainWindowVisible(window: MainWindowVisibilityState | null): boolean {
|
||||
if (window === null || window.isDestroyed()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Why: production BrowserWindow exposes both APIs, but older main-process
|
||||
// tests use minimal window doubles that should stay visible by default.
|
||||
const isVisible = window.isVisible?.() ?? true
|
||||
const isMinimized = window.isMinimized?.() ?? false
|
||||
return isVisible && !isMinimized
|
||||
}
|
||||
Loading…
Reference in New Issue