mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-31 01:44:52 +00:00
## Summary - Follow up #578 by consolidating desktop persistence, restore reconciliation, lifecycle coordination, and regression coverage. - Preserve active drafts and attachments, request-scoped workspace ownership, deletion tombstones, renderer authority, and bounded shutdown behavior. - Fix the reported macOS cleanup failure with targeted BSD process queries and random-token-guarded process-group cleanup, without an unverified PID fallback. ## Platform hardening - Ignore development renderer origins in packaged Electron builds. - Preserve staged Tauri navigation authority and handle confirmed Windows session-end shutdown on the UI thread. - Bound workspace launch preflight, runtime startup, and health readiness. - Retain cleanup ownership after unexpected leaders exit and verify portable POSIX descendants by immutable identity or inherited launch token. - Add real Darwin-only process-group integration tests for macOS CI. ## Scope - 96 files changed. - 6,295 additions and 12,167 deletions, a net reduction of 5,872 lines from the merged implementation. - Consolidated duplicated tests while retaining focused race, durability, cleanup, and platform contracts. ## Validation - pm run typecheck - pm run typecheck --workspace @neuralnomads/codenomad - Electron native suite: 60 passed - Tauri suite: 49 passed - Focused server lifecycle/identity suite: 31 passed, 2 Darwin-only skipped on Windows - Focused UI restore/codec/reconciliation suite: 36 passed - Broader server suite: 59 passed, 3 platform skips - Broader UI suite: 97 passed, 1 skip; 2 Node 25 solid-toast loader failures reproduced on the merged baseline - git diff --check - Final limited gatekeeper: PASS for server/macOS, UI restore, and Electron/Tauri
252 lines
8.3 KiB
TypeScript
252 lines
8.3 KiB
TypeScript
import type { BrowserWindow } from "electron"
|
|
import type { ClientStateManager, NativeWindowState, WindowBounds } from "./client-state"
|
|
|
|
export const DEFAULT_WINDOW_WIDTH = 1400
|
|
export const DEFAULT_WINDOW_HEIGHT = 900
|
|
|
|
const MIN_WINDOW_WIDTH = 800
|
|
const MIN_WINDOW_HEIGHT = 600
|
|
const MIN_ZOOM_FACTOR = 0.25
|
|
const MAX_ZOOM_FACTOR = 5
|
|
const SAVE_DEBOUNCE_MS = 250
|
|
|
|
export interface DisplayWorkArea {
|
|
x: number
|
|
y: number
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
function isFiniteNumber(value: unknown): value is number {
|
|
return typeof value === "number" && Number.isFinite(value)
|
|
}
|
|
|
|
function clamp(value: number, minimum: number, maximum: number): number {
|
|
return Math.min(Math.max(value, minimum), maximum)
|
|
}
|
|
|
|
function intersectionArea(bounds: WindowBounds, area: DisplayWorkArea): number {
|
|
const width = Math.max(0, Math.min(bounds.x + bounds.width, area.x + area.width) - Math.max(bounds.x, area.x))
|
|
const height = Math.max(0, Math.min(bounds.y + bounds.height, area.y + area.height) - Math.max(bounds.y, area.y))
|
|
return width * height
|
|
}
|
|
|
|
function centerDistanceSquared(bounds: WindowBounds, area: DisplayWorkArea): number {
|
|
const x = bounds.x + bounds.width / 2 - (area.x + area.width / 2)
|
|
const y = bounds.y + bounds.height / 2 - (area.y + area.height / 2)
|
|
return x * x + y * y
|
|
}
|
|
|
|
export function normalizeZoomFactor(value: unknown): number {
|
|
if (!isFiniteNumber(value) || value <= 0) {
|
|
return 1
|
|
}
|
|
return clamp(value, MIN_ZOOM_FACTOR, MAX_ZOOM_FACTOR)
|
|
}
|
|
|
|
export function normalizeNativeWindowState(value: unknown): NativeWindowState | undefined {
|
|
if (!value || typeof value !== "object") {
|
|
return undefined
|
|
}
|
|
|
|
const candidate = value as Partial<NativeWindowState>
|
|
const bounds = candidate.bounds
|
|
if (
|
|
!bounds ||
|
|
!isFiniteNumber(bounds.x) ||
|
|
!isFiniteNumber(bounds.y) ||
|
|
!isFiniteNumber(bounds.width) ||
|
|
!isFiniteNumber(bounds.height) ||
|
|
bounds.width <= 0 ||
|
|
bounds.height <= 0
|
|
) {
|
|
return undefined
|
|
}
|
|
|
|
return {
|
|
bounds: {
|
|
x: Math.round(bounds.x),
|
|
y: Math.round(bounds.y),
|
|
width: Math.round(bounds.width),
|
|
height: Math.round(bounds.height),
|
|
},
|
|
maximized: candidate.maximized === true,
|
|
fullscreen: candidate.fullscreen === true,
|
|
zoomFactor: normalizeZoomFactor(candidate.zoomFactor),
|
|
}
|
|
}
|
|
|
|
export function clampWindowBounds(bounds: WindowBounds, displays: DisplayWorkArea[]): WindowBounds | undefined {
|
|
const normalized = normalizeNativeWindowState({ bounds, maximized: false, fullscreen: false, zoomFactor: 1 })?.bounds
|
|
const usableDisplays = displays.filter(
|
|
(area) =>
|
|
isFiniteNumber(area.x) &&
|
|
isFiniteNumber(area.y) &&
|
|
isFiniteNumber(area.width) &&
|
|
isFiniteNumber(area.height) &&
|
|
area.width > 0 &&
|
|
area.height > 0,
|
|
)
|
|
if (!normalized || usableDisplays.length === 0) {
|
|
return undefined
|
|
}
|
|
|
|
const display = usableDisplays.reduce((best, area) => {
|
|
const bestIntersection = intersectionArea(normalized, best)
|
|
const areaIntersection = intersectionArea(normalized, area)
|
|
if (areaIntersection !== bestIntersection) {
|
|
return areaIntersection > bestIntersection ? area : best
|
|
}
|
|
return centerDistanceSquared(normalized, area) < centerDistanceSquared(normalized, best) ? area : best
|
|
})
|
|
|
|
const maximumWidth = Math.max(1, Math.floor(display.width))
|
|
const maximumHeight = Math.max(1, Math.floor(display.height))
|
|
const minimumWidth = Math.min(MIN_WINDOW_WIDTH, maximumWidth)
|
|
const minimumHeight = Math.min(MIN_WINDOW_HEIGHT, maximumHeight)
|
|
const width = clamp(normalized.width, minimumWidth, maximumWidth)
|
|
const height = clamp(normalized.height, minimumHeight, maximumHeight)
|
|
const x = clamp(normalized.x, display.x, display.x + maximumWidth - width)
|
|
const y = clamp(normalized.y, display.y, display.y + maximumHeight - height)
|
|
|
|
return { x, y, width, height }
|
|
}
|
|
|
|
export function restoreWindowState(window: BrowserWindow, state: NativeWindowState | undefined, bounds: WindowBounds | undefined) {
|
|
if (!state) {
|
|
return
|
|
}
|
|
|
|
if (bounds) {
|
|
window.setPosition(bounds.x, bounds.y)
|
|
window.setContentSize(bounds.width, bounds.height)
|
|
}
|
|
window.webContents.setZoomFactor(normalizeZoomFactor(state.zoomFactor))
|
|
if (state.maximized) {
|
|
window.maximize()
|
|
}
|
|
if (state.fullscreen) {
|
|
window.setFullScreen(true)
|
|
}
|
|
}
|
|
|
|
export function installWindowZoomInput(window: BrowserWindow, setZoomLevel: (level: number) => void): void {
|
|
const changeZoom = (delta: number) => setZoomLevel(window.webContents.getZoomLevel() + delta)
|
|
window.webContents.on("before-input-event", (event, input) => {
|
|
if (input.type !== "keyDown" || (!input.control && !input.meta) || input.alt) return
|
|
if (input.key === "+" || input.key === "=") {
|
|
event.preventDefault()
|
|
changeZoom(0.5)
|
|
} else if (input.key === "-") {
|
|
event.preventDefault()
|
|
changeZoom(-0.5)
|
|
} else if (input.key === "0") {
|
|
event.preventDefault()
|
|
setZoomLevel(0)
|
|
}
|
|
})
|
|
window.webContents.on("zoom-changed", (event, direction) => {
|
|
event.preventDefault()
|
|
changeZoom(direction === "in" ? 0.5 : -0.5)
|
|
})
|
|
}
|
|
|
|
export class WindowStateTracker {
|
|
private saveTimer: ReturnType<typeof setTimeout> | undefined
|
|
private desiredZoomFactor: number
|
|
private normalBounds: WindowBounds
|
|
|
|
constructor(
|
|
private readonly window: BrowserWindow,
|
|
private readonly clientState: ClientStateManager,
|
|
initialState?: NativeWindowState,
|
|
) {
|
|
this.desiredZoomFactor = normalizeZoomFactor(initialState?.zoomFactor)
|
|
const [x, y] = typeof window.getPosition === "function" ? window.getPosition() : [0, 0]
|
|
const [width, height] = typeof window.getContentSize === "function"
|
|
? window.getContentSize()
|
|
: [DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT]
|
|
this.normalBounds = initialState?.bounds ?? { x, y, width, height }
|
|
|
|
for (const event of ["move", "resize"]) {
|
|
window.on(event as "move", () => {
|
|
if (!window.isMaximized() && !window.isFullScreen()) this.captureNormalBounds()
|
|
this.scheduleSave()
|
|
})
|
|
}
|
|
for (const event of ["maximize", "unmaximize", "enter-full-screen", "leave-full-screen"]) {
|
|
window.on(event as "maximize", () => this.scheduleSave())
|
|
}
|
|
window.webContents.on("zoom-changed", () => this.scheduleSave())
|
|
window.webContents.on("did-start-navigation", (_event, _url, _isInPlace, isMainFrame) => {
|
|
if (isMainFrame && !window.webContents.isDestroyed()) {
|
|
this.desiredZoomFactor = normalizeZoomFactor(window.webContents.getZoomFactor())
|
|
}
|
|
})
|
|
window.webContents.on("did-finish-load", () => {
|
|
if (!window.webContents.isDestroyed()) {
|
|
window.webContents.setZoomFactor(this.desiredZoomFactor)
|
|
}
|
|
})
|
|
window.on("closed", () => this.clearTimer())
|
|
}
|
|
|
|
async flush(): Promise<void> {
|
|
this.clearTimer()
|
|
if (!this.window.isDestroyed()) {
|
|
await this.captureAndQueue()
|
|
}
|
|
await this.clientState.flush()
|
|
}
|
|
|
|
setZoomLevel(level: number): void {
|
|
if (this.window.isDestroyed() || this.window.webContents.isDestroyed()) return
|
|
this.window.webContents.setZoomLevel(level)
|
|
this.desiredZoomFactor = normalizeZoomFactor(this.window.webContents.getZoomFactor())
|
|
this.scheduleSave()
|
|
}
|
|
|
|
private scheduleSave() {
|
|
this.clearTimer()
|
|
this.saveTimer = setTimeout(() => {
|
|
this.saveTimer = undefined
|
|
void this.saveNow()
|
|
}, SAVE_DEBOUNCE_MS)
|
|
}
|
|
|
|
private clearTimer() {
|
|
if (this.saveTimer) {
|
|
clearTimeout(this.saveTimer)
|
|
this.saveTimer = undefined
|
|
}
|
|
}
|
|
|
|
private async saveNow() {
|
|
try {
|
|
await this.captureAndQueue()
|
|
} catch (error) {
|
|
console.warn("[client-state] failed to save window state", error)
|
|
}
|
|
}
|
|
|
|
private captureAndQueue(): Promise<boolean> {
|
|
if (this.window.isDestroyed() || this.window.webContents.isDestroyed()) {
|
|
return Promise.resolve(false)
|
|
}
|
|
|
|
this.desiredZoomFactor = normalizeZoomFactor(this.window.webContents.getZoomFactor())
|
|
if (!this.window.isMaximized() && !this.window.isFullScreen()) this.captureNormalBounds()
|
|
return this.clientState.saveWindowState({
|
|
bounds: this.normalBounds,
|
|
maximized: this.window.isMaximized(),
|
|
fullscreen: this.window.isFullScreen(),
|
|
zoomFactor: this.desiredZoomFactor,
|
|
})
|
|
}
|
|
|
|
private captureNormalBounds(): void {
|
|
const [x, y] = this.window.getPosition()
|
|
const [width, height] = this.window.getContentSize()
|
|
this.normalBounds = { x, y, width, height }
|
|
}
|
|
}
|