Skill update round trip / roundtrip (false, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, latest) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
256 lines
11 KiB
TypeScript
256 lines
11 KiB
TypeScript
import { ipcMain, Menu, type BrowserWindow } from 'electron'
|
|
import { isCrashReportReason } from '../../shared/crash-reporting'
|
|
import {
|
|
richMarkdownContextMenuTargetChannel,
|
|
type RichMarkdownContextMenuTableTarget
|
|
} from '../../shared/rich-markdown-context-menu'
|
|
import {
|
|
DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES,
|
|
DEFAULT_RENDERER_RECOVERY_WINDOW_MS,
|
|
RendererRecoveryCircuitBreaker
|
|
} from '../crash-reporting/renderer-recovery-circuit-breaker'
|
|
import {
|
|
buildEditableContextMenuTemplate,
|
|
matchingRichMarkdownContextMenuTableTarget,
|
|
parseRichMarkdownContextMenuTableTarget
|
|
} from './editable-context-menu'
|
|
import type { CreateMainWindowOptions } from './main-window-contracts'
|
|
import { browserRouteWebContentsRegistry } from '../browser/browser-route-session-runtime'
|
|
import {
|
|
attachBrowserClientPageRenderer,
|
|
retireBrowserClientPageRenderer
|
|
} from '../browser/browser-client-page-renderer-runtime'
|
|
import { registerRendererDocumentNavigation } from './renderer-document-navigation'
|
|
|
|
export type MainWindowFocusLifecycle = {
|
|
dispose: () => void
|
|
isFloatingPanelFocused: () => boolean
|
|
isFloatingTerminalInputFocused: () => boolean
|
|
isMarkdownEditorFocused: () => boolean
|
|
isRendererProcessGone: () => boolean
|
|
isShortcutRecorderFocused: () => boolean
|
|
isTerminalInputFocused: () => boolean
|
|
}
|
|
|
|
export function installMainWindowFocusLifecycle(args: {
|
|
isWindowClosing: () => boolean
|
|
mainWindow: BrowserWindow
|
|
opts?: CreateMainWindowOptions
|
|
reloadMainWindow: () => void
|
|
rendererWebContentsId: number
|
|
}): MainWindowFocusLifecycle {
|
|
const { isWindowClosing, mainWindow, opts, reloadMainWindow, rendererWebContentsId } = args
|
|
// Why: mirror markdown-editor focus so before-input-event skips Cmd/Ctrl+B while TipTap owns focus (docs/markdown-cmd-b-bold-design.md).
|
|
let markdownEditorFocused = false
|
|
let terminalInputFocused = false
|
|
// floatingTerminalInputFocused: textarea-only (terminal keybinding context). floatingPanelFocused: superset for routing ownership.
|
|
let floatingTerminalInputFocused = false
|
|
let floatingPanelFocused = false
|
|
let shortcutRecorderFocused = false
|
|
|
|
const markdownFocusChannel = 'ui:setMarkdownEditorFocused'
|
|
// Why: strict-bool + sender check so a guest/webview or malformed IPC payload can't disable the Cmd+B sidebar carve-out.
|
|
const onMarkdownEditorFocused = (event: Electron.IpcMainEvent, focused: unknown): void => {
|
|
if (event.sender !== mainWindow.webContents) {
|
|
return
|
|
}
|
|
markdownEditorFocused = focused === true
|
|
}
|
|
ipcMain.on(markdownFocusChannel, onMarkdownEditorFocused)
|
|
const terminalInputFocusChannel = 'ui:setTerminalInputFocused'
|
|
// Why: before-input-event resolves shortcuts before renderer keydown; mirror xterm focus so Terminal-first lets shells own app chords.
|
|
const onTerminalInputFocused = (event: Electron.IpcMainEvent, focused: unknown): void => {
|
|
if (event.sender !== mainWindow.webContents) {
|
|
return
|
|
}
|
|
terminalInputFocused = focused === true
|
|
}
|
|
ipcMain.on(terminalInputFocusChannel, onTerminalInputFocused)
|
|
const floatingFocusChannel = 'ui:setFloatingFocus'
|
|
// Why: one atomic payload for both bits so before-input-event never reads a torn terminal=true/panel=false state.
|
|
// terminalFocused drives the Ctrl+B/L terminal-context carve-out; panelFocused is the routing-ownership superset (panel ⊇ terminal).
|
|
const onFloatingFocus = (event: Electron.IpcMainEvent, state: unknown): void => {
|
|
if (event.sender !== mainWindow.webContents) {
|
|
return
|
|
}
|
|
const payload = (state ?? {}) as { panelFocused?: unknown; terminalFocused?: unknown }
|
|
const terminal = payload.terminalFocused === true
|
|
floatingTerminalInputFocused = terminal
|
|
// Re-assert the invariant defensively in case a sender ever emits panel=false with terminal=true.
|
|
floatingPanelFocused = payload.panelFocused === true || terminal
|
|
}
|
|
ipcMain.on(floatingFocusChannel, onFloatingFocus)
|
|
const shortcutRecorderFocusChannel = 'ui:setShortcutRecorderFocused'
|
|
// Why: the Settings recorder must receive app shortcuts to rebind them; before-input-event would otherwise consume the key first.
|
|
const onShortcutRecorderFocused = (event: Electron.IpcMainEvent, focused: unknown): void => {
|
|
if (event.sender !== mainWindow.webContents) {
|
|
return
|
|
}
|
|
shortcutRecorderFocused = focused === true
|
|
}
|
|
ipcMain.on(shortcutRecorderFocusChannel, onShortcutRecorderFocused)
|
|
|
|
let pendingRichMarkdownContextMenuTableTarget: RichMarkdownContextMenuTableTarget | null = null
|
|
const onRichMarkdownContextMenuTarget = (event: Electron.IpcMainEvent, value: unknown): void => {
|
|
if (event.sender !== mainWindow.webContents) {
|
|
return
|
|
}
|
|
pendingRichMarkdownContextMenuTableTarget = parseRichMarkdownContextMenuTableTarget(value)
|
|
}
|
|
ipcMain.on(richMarkdownContextMenuTargetChannel, onRichMarkdownContextMenuTarget)
|
|
const onMainContextMenu = (_event: Electron.Event, params: Electron.ContextMenuParams): void => {
|
|
const tableTarget = matchingRichMarkdownContextMenuTableTarget(
|
|
params,
|
|
pendingRichMarkdownContextMenuTableTarget
|
|
)
|
|
pendingRichMarkdownContextMenuTableTarget = null
|
|
const template = buildEditableContextMenuTemplate(params, mainWindow.webContents, {
|
|
tableTarget
|
|
})
|
|
if (template.length === 0 || mainWindow.isDestroyed()) {
|
|
return
|
|
}
|
|
// Why: the context-menu event can precede our focus-mirror update; trust Electron's editable params, not markdownEditorFocused.
|
|
Menu.buildFromTemplate(template).popup({ window: mainWindow, x: params.x, y: params.y })
|
|
}
|
|
mainWindow.webContents.on('context-menu', onMainContextMenu)
|
|
|
|
// Why: a dead renderer can't clear its focus mirror; default-deny carve-outs so it can't disable app shortcuts in a later lifecycle.
|
|
const resetMarkdownEditorFocus = (): void => {
|
|
markdownEditorFocused = false
|
|
pendingRichMarkdownContextMenuTableTarget = null
|
|
}
|
|
const resetTerminalInputFocus = (): void => {
|
|
terminalInputFocused = false
|
|
}
|
|
const resetFloatingTerminalInputFocus = (): void => {
|
|
floatingTerminalInputFocused = false
|
|
floatingPanelFocused = false
|
|
}
|
|
const resetShortcutRecorderFocus = (): void => {
|
|
shortcutRecorderFocused = false
|
|
}
|
|
let rendererProcessGone = false
|
|
const rendererWebContents = mainWindow.webContents
|
|
registerRendererDocumentNavigation(rendererWebContents, () => {
|
|
const frame = rendererWebContents.mainFrame
|
|
retireBrowserClientPageRenderer(rendererWebContents)
|
|
resetMarkdownEditorFocus()
|
|
resetTerminalInputFocus()
|
|
resetFloatingTerminalInputFocus()
|
|
resetShortcutRecorderFocus()
|
|
return () => {
|
|
if (
|
|
rendererProcessGone ||
|
|
rendererWebContents.isDestroyed() ||
|
|
rendererWebContents.mainFrame !== frame
|
|
) {
|
|
return
|
|
}
|
|
attachBrowserClientPageRenderer(rendererWebContents)
|
|
}
|
|
})
|
|
let rendererRecoveryTimer: ReturnType<typeof setTimeout> | null = null
|
|
// Why: stop a deterministic per-load renderer fault from auto-reloading forever; breaker opens after too many recoveries in a rolling window.
|
|
const rendererRecoveryCircuitBreaker = new RendererRecoveryCircuitBreaker({
|
|
windowMs: DEFAULT_RENDERER_RECOVERY_WINDOW_MS,
|
|
maxRecoveries: DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES
|
|
})
|
|
const clearRendererRecoveryTimer = (): void => {
|
|
if (rendererRecoveryTimer) {
|
|
clearTimeout(rendererRecoveryTimer)
|
|
rendererRecoveryTimer = null
|
|
}
|
|
}
|
|
const scheduleRendererRecovery = (details: Electron.RenderProcessGoneDetails): void => {
|
|
if (
|
|
rendererRecoveryTimer ||
|
|
!details ||
|
|
!isCrashReportReason(details.reason) ||
|
|
isWindowClosing() ||
|
|
opts?.getIsQuitting?.() ||
|
|
opts?.shouldRecoverRenderer?.(details, rendererWebContentsId) === false ||
|
|
mainWindow.isDestroyed()
|
|
) {
|
|
return
|
|
}
|
|
rendererRecoveryTimer = setTimeout(() => {
|
|
rendererRecoveryTimer = null
|
|
if (
|
|
isWindowClosing() ||
|
|
opts?.getIsQuitting?.() ||
|
|
opts?.shouldRecoverRenderer?.(details, rendererWebContentsId) === false ||
|
|
mainWindow.isDestroyed()
|
|
) {
|
|
return
|
|
}
|
|
const recovery = rendererRecoveryCircuitBreaker.registerRecoveryAttempt(Date.now())
|
|
if (!recovery.allowed) {
|
|
// Why: too many reloads means it will just crash again; stop and let the host surface a recovery prompt.
|
|
opts?.onRendererRecoveryExhausted?.({
|
|
details,
|
|
webContentsId: rendererWebContentsId,
|
|
recentRecoveryCount: recovery.recentRecoveryCount
|
|
})
|
|
return
|
|
}
|
|
// Why: a transient renderer/Network Service loss can blank Chromium; reload the app document once to recover.
|
|
// Why: mark this in-place reload so the did-finish-load orphan sweep spares live PTYs until session restore (#5787).
|
|
opts?.onBeforeRecoveryReload?.(mainWindow.webContents.id)
|
|
reloadMainWindow()
|
|
}, 250)
|
|
}
|
|
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
|
rendererProcessGone = true
|
|
retireBrowserClientPageRenderer(rendererWebContents)
|
|
browserRouteWebContentsRegistry.retireRenderer(rendererWebContentsId)
|
|
resetMarkdownEditorFocus()
|
|
resetTerminalInputFocus()
|
|
resetFloatingTerminalInputFocus()
|
|
resetShortcutRecorderFocus()
|
|
// Why: macOS reports BrowserWindow teardown as renderer killed/SIGKILL after close — window noise, not a crash.
|
|
if (!isWindowClosing()) {
|
|
// Why: the recorder owns crash classification; filtering here made expected-teardown evidence unreachable.
|
|
opts?.onRendererProcessGone?.(details, rendererWebContentsId)
|
|
}
|
|
if (!isWindowClosing()) {
|
|
console.error('[window] Renderer process gone; close confirmation will be bypassed', details)
|
|
}
|
|
scheduleRendererRecovery(details)
|
|
})
|
|
mainWindow.webContents.on('destroyed', () => {
|
|
retireBrowserClientPageRenderer(rendererWebContents)
|
|
resetMarkdownEditorFocus()
|
|
resetTerminalInputFocus()
|
|
resetFloatingTerminalInputFocus()
|
|
resetShortcutRecorderFocus()
|
|
})
|
|
mainWindow.webContents.on('did-finish-load', () => {
|
|
rendererProcessGone = false
|
|
attachBrowserClientPageRenderer(rendererWebContents)
|
|
clearRendererRecoveryTimer()
|
|
})
|
|
|
|
const dispose = (): void => {
|
|
resetMarkdownEditorFocus()
|
|
resetTerminalInputFocus()
|
|
resetFloatingTerminalInputFocus()
|
|
resetShortcutRecorderFocus()
|
|
clearRendererRecoveryTimer()
|
|
ipcMain.removeListener(markdownFocusChannel, onMarkdownEditorFocused)
|
|
ipcMain.removeListener(terminalInputFocusChannel, onTerminalInputFocused)
|
|
ipcMain.removeListener(floatingFocusChannel, onFloatingFocus)
|
|
ipcMain.removeListener(shortcutRecorderFocusChannel, onShortcutRecorderFocused)
|
|
ipcMain.removeListener(richMarkdownContextMenuTargetChannel, onRichMarkdownContextMenuTarget)
|
|
}
|
|
return {
|
|
dispose,
|
|
isFloatingPanelFocused: () => floatingPanelFocused,
|
|
isFloatingTerminalInputFocused: () => floatingTerminalInputFocused,
|
|
isMarkdownEditorFocused: () => markdownEditorFocused,
|
|
isRendererProcessGone: () => rendererProcessGone,
|
|
isShortcutRecorderFocused: () => shortcutRecorderFocused,
|
|
isTerminalInputFocused: () => terminalInputFocused
|
|
}
|
|
}
|