@noego/wood 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/client/index.cjs +12 -0
  2. package/dist/client/index.cjs.map +1 -1
  3. package/dist/client/index.d.cts +14 -1
  4. package/dist/client/index.d.ts +14 -1
  5. package/dist/client/index.js +11 -0
  6. package/dist/client/index.js.map +1 -1
  7. package/dist/codegen/preload_generator.cjs +44 -5
  8. package/dist/codegen/preload_generator.cjs.map +1 -1
  9. package/dist/codegen/preload_generator.js +44 -5
  10. package/dist/codegen/preload_generator.js.map +1 -1
  11. package/dist/frontend/frontend_surface.d.cts +1 -0
  12. package/dist/frontend/frontend_surface.d.ts +1 -0
  13. package/dist/frontend/index.d.cts +1 -0
  14. package/dist/frontend/index.d.ts +1 -0
  15. package/dist/index.d.cts +2 -1
  16. package/dist/index.d.ts +2 -1
  17. package/dist/main/index.cjs +43 -58
  18. package/dist/main/index.cjs.map +1 -1
  19. package/dist/main/index.d.cts +2 -2
  20. package/dist/main/index.d.ts +2 -2
  21. package/dist/main/index.js +48 -58
  22. package/dist/main/index.js.map +1 -1
  23. package/dist/main/shutdown_coordinator.cjs +153 -0
  24. package/dist/main/shutdown_coordinator.cjs.map +1 -0
  25. package/dist/main/shutdown_coordinator.d.cts +81 -0
  26. package/dist/main/shutdown_coordinator.d.ts +81 -0
  27. package/dist/main/shutdown_coordinator.js +127 -0
  28. package/dist/main/shutdown_coordinator.js.map +1 -0
  29. package/dist/navigation/index.cjs.map +1 -1
  30. package/dist/navigation/index.d.cts +4 -28
  31. package/dist/navigation/index.d.ts +4 -28
  32. package/dist/navigation/index.js.map +1 -1
  33. package/dist/navigation/layout_preservation.cjs.map +1 -1
  34. package/dist/navigation/layout_preservation.d.cts +27 -2
  35. package/dist/navigation/layout_preservation.d.ts +27 -2
  36. package/dist/navigation/layout_preservation.js.map +1 -1
  37. package/dist/testing/create_controller_harness.d.cts +1 -0
  38. package/dist/testing/create_controller_harness.d.ts +1 -0
  39. package/dist/testing/index.d.cts +1 -0
  40. package/dist/testing/index.d.ts +1 -0
  41. package/dist/testing/test_wood_config.d.cts +1 -0
  42. package/dist/testing/test_wood_config.d.ts +1 -0
  43. package/dist/testing/wood_env_renderer.d.cts +1 -0
  44. package/dist/testing/wood_env_renderer.d.ts +1 -0
  45. package/package.json +1 -1
  46. package/src/navigation/layout_preservation.ts +3 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/codegen/preload_generator.ts"],"sourcesContent":["import type { OperationDef } from '../types/index.cjs';\nimport type { RendererTraceBatchingConfig } from '../types/config.cjs';\nimport { RENDERER_TRACE_BATCHING_DEFAULTS } from '../config/config_parser.cjs';\nimport { generatedFileNotice } from './generated_file_notice.cjs';\n\nexport type GeneratePreloadOptions = {\n /**\n * Renderer→main IPC trace batching policy resolved from\n * wood.config.yml `observability.trace.rendererToMain.batching`.\n * The generated preload embeds these values but does not own them.\n */\n traceBatching?: RendererTraceBatchingConfig;\n};\n\nconst PRELOAD_SOURCE_INSTRUCTIONS = [\n 'To change IPC channels or preload bridge methods, edit the operations YAML configured by',\n 'wood.config.yml main.openapi and regenerate with `wood dev` or `wood build`.',\n] as const;\n\nfunction operationHasInput(op: OperationDef): boolean {\n return Boolean(\n op.inputSchema &&\n (op.inputSchema as Record<string, unknown>).type === 'object' &&\n Object.keys((op.inputSchema as Record<string, Record<string, unknown>>).properties ?? {}).length > 0,\n );\n}\n\n/**\n * Build a nested object tree from dot-path operation channels.\n * \"chat.message.send\" -> { chat: { message: { send: ... } } }\n */\nfunction buildNestedTree(operations: Map<string, OperationDef>): Record<string, unknown> {\n const tree: Record<string, unknown> = {};\n\n for (const [channel, op] of operations) {\n const parts = channel.split('.');\n let current: Record<string, unknown> = tree;\n\n for (let i = 0; i < parts.length - 1; i++) {\n if (!current[parts[i]]) current[parts[i]] = {};\n current = current[parts[i]] as Record<string, unknown>;\n }\n\n const leaf = parts[parts.length - 1];\n current[leaf] = { __operation: op };\n }\n\n return tree;\n}\n\n/**\n * Generate TypeScript code for a nested tree node.\n */\nfunction generateTreeCode(node: Record<string, unknown>, indent: string = ' '): string {\n const lines: string[] = [];\n\n for (const [key, value] of Object.entries(node)) {\n const entry = value as Record<string, unknown>;\n if (entry.__operation) {\n const op = entry.__operation as OperationDef;\n const hasInput = operationHasInput(op);\n\n if (hasInput) {\n lines.push(`${indent}${key}: (data: unknown) =>`);\n lines.push(`${indent} ipcRenderer.invoke('${op.channel}', data),`);\n } else {\n lines.push(`${indent}${key}: () =>`);\n lines.push(`${indent} ipcRenderer.invoke('${op.channel}'),`);\n }\n } else {\n lines.push(`${indent}${key}: {`);\n lines.push(generateTreeCode(entry, indent + ' '));\n lines.push(`${indent}},`);\n }\n }\n\n return lines.join('\\n');\n}\n\nfunction generateRpcManifestCode(operations: Map<string, OperationDef>): string {\n const entries = Array.from(operations.values())\n .sort((a, b) => a.channel.localeCompare(b.channel))\n .map((op) => {\n const payload = {\n channel: op.channel,\n controller: op.controller,\n action: op.action,\n path: op.channel.split('.'),\n hasInput: operationHasInput(op),\n };\n return ` ${JSON.stringify(payload)},`;\n });\n\n return `const __WOOD_RPC_MANIFEST = [\n${entries.join('\\n')}\n];`;\n}\n\nconst INTERNAL_BRIDGE_GLOBAL_KEY = '__NOEGO_WOOD_BRIDGE__';\n\n/**\n * Generate a complete preload.ts file from operations.\n */\nexport function generatePreload(\n operations: Map<string, OperationDef>,\n options: GeneratePreloadOptions = {},\n): string {\n const tree = buildNestedTree(operations);\n const operationsCode = generateTreeCode(tree);\n const rpcManifestCode = generateRpcManifestCode(operations);\n const traceBatching = options.traceBatching ?? RENDERER_TRACE_BATCHING_DEFAULTS;\n\n return `${generatedFileNotice(PRELOAD_SOURCE_INSTRUCTIONS)}\nimport { contextBridge, ipcRenderer } from 'electron';\nimport {\n RendererTraceBatchTransport,\n type WoodRendererTraceInput,\n type WoodTraceBatch,\n type WoodTraceBatchAcknowledgement,\n type WoodTraceBatchPort,\n} from '@noego/wood/trace-transport';\n\n${rpcManifestCode}\n\nconst __WOOD_INTERNAL_BRIDGE_KEY = '${INTERNAL_BRIDGE_GLOBAL_KEY}';\n\ncontextBridge.exposeInMainWorld(__WOOD_INTERNAL_BRIDGE_KEY, {\n${operationsCode}\n\n // Internal: backend controller/action RPC map for @Inject(App)\n __rpcManifest: __WOOD_RPC_MANIFEST,\n\n // Internal: loader data requests\n __load: (route: string, params: Record<string, string>) =>\n ipcRenderer.invoke('__load', { route, params }),\n\n // Internal: cross-window navigation\n __navigate: (windowId: string, route: string) =>\n ipcRenderer.invoke('__navigate', { windowId, route }),\n\n // Internal: window management\n __window: {\n current: () =>\n ipcRenderer.invoke('__window', { action: 'current' }),\n open: (windowId: string, options?: Record<string, unknown>) =>\n ipcRenderer.invoke('__window', { action: 'open', windowId, options }),\n close: (windowId?: string) =>\n ipcRenderer.invoke('__window', { action: 'close', windowId }),\n focus: (windowId: string) =>\n ipcRenderer.invoke('__window', { action: 'focus', windowId }),\n minimize: (windowId?: string) =>\n ipcRenderer.invoke('__window', { action: 'minimize', windowId }),\n maximize: (windowId?: string) =>\n ipcRenderer.invoke('__window', { action: 'maximize', windowId }),\n },\n\n // Internal: framework config (trace min level, etc)\n __woodConfig: () =>\n ipcRenderer.invoke('__woodConfig'),\n\n // Internal: debug metadata\n __debug: {\n current: () =>\n ipcRenderer.invoke('__debug', { action: 'current' }),\n },\n\n // Internal: context menu\n __contextMenu: {\n show: (data: unknown) =>\n ipcRenderer.invoke('__contextMenu', { action: 'show', ...data as Record<string, unknown> }),\n },\n\n // Push events from main process\n on: (channel: string, callback: (...args: unknown[]) => void) => {\n const subscription = (_event: unknown, ...args: unknown[]) => callback(...args);\n ipcRenderer.on(\\`__event:\\${channel}\\`, subscription);\n return () => ipcRenderer.removeListener(\\`__event:\\${channel}\\`, subscription);\n },\n __trace: (event: unknown) => {\n ipcRenderer.send('__trace', event);\n },\n __log: (level: string, logger: string, message: string, context?: unknown) => {\n ipcRenderer.send('__log', { level, logger, message, context });\n },\n __logBatch: (batch: unknown) => {\n ipcRenderer.send('__log_batch', batch);\n },\n});\n\n// Trace transport via MessagePort (activated when main process sends a port)\nlet __tracePort: MessagePort | null = null;\nconst __traceBuffer: unknown[] = [];\nconst __TRACE_BUFFER_CAPACITY = 256;\nlet __traceDroppedCapacity = 0;\n\nfunction __traceFlush(): void {\n while (__traceBuffer.length > 0 && __tracePort) {\n __tracePort.postMessage(__traceBuffer.shift());\n }\n}\n\nipcRenderer.on('__trace-port', (event) => {\n __tracePort = event.ports[0];\n __tracePort.start();\n __traceFlush();\n});\n\ncontextBridge.exposeInMainWorld('__NOEGO_TRACE__', {\n send: (data: unknown) => {\n if (__tracePort) {\n __tracePort.postMessage(data);\n } else if (__traceBuffer.length >= __TRACE_BUFFER_CAPACITY) {\n __traceDroppedCapacity += 1;\n } else {\n __traceBuffer.push(data);\n }\n },\n status: () => ({\n queuedEvents: __traceBuffer.length,\n droppedCapacity: __traceDroppedCapacity,\n }),\n});\n\nconst __traceBatchProducerId = typeof globalThis.crypto?.randomUUID === 'function'\n ? globalThis.crypto.randomUUID()\n : \\`wood-renderer-\\${Date.now()}-\\${Math.random().toString(16).slice(2)}\\`;\nlet __traceBatchListener: ((message: unknown) => void) | null = null;\n\nconst __traceBatchPort: WoodTraceBatchPort = {\n postMessage: (batch: WoodTraceBatch) => {\n void ipcRenderer.invoke('__traceBatch', batch)\n .then((acknowledgement: WoodTraceBatchAcknowledgement) => {\n __traceBatchListener?.(acknowledgement);\n })\n .catch((error: unknown) => {\n __traceBatchListener?.({\n kind: 'retryable',\n batchId: batch.batchId,\n reason: error instanceof Error ? error.message : String(error),\n retryAfterMs: 100,\n });\n });\n },\n subscribe: (listener: (message: unknown) => void) => {\n __traceBatchListener = listener;\n return () => {\n if (__traceBatchListener === listener) {\n __traceBatchListener = null;\n }\n };\n },\n close: () => {\n __traceBatchListener = null;\n },\n};\n\n// Batching policy resolved from wood.config.yml observability configuration\n// at generation time; this file embeds but does not own it.\nconst __traceBatchTransport = new RendererTraceBatchTransport({\n producerId: __traceBatchProducerId,\n maxQueuedEvents: ${traceBatching.maxQueuedEvents},\n maxBatchEvents: ${traceBatching.maxBatchEvents},\n acknowledgementTimeoutMs: ${traceBatching.acknowledgementTimeoutMs},\n retryLimit: ${traceBatching.retryLimit},\n retryBaseDelayMs: ${traceBatching.retryBaseDelayMs},\n now: () => Date.now(),\n schedule: (work: () => void) => queueMicrotask(work),\n delay: (durationMs: number) => new Promise((resolve) => setTimeout(resolve, durationMs)),\n});\n__traceBatchTransport.attachPort(__traceBatchPort);\n\ncontextBridge.exposeInMainWorld('__NOEGO_WOOD_TRACE_BATCH__', {\n send: (event: WoodRendererTraceInput) => __traceBatchTransport.admit(event),\n status: () => __traceBatchTransport.status(),\n flush: (deadlineMs: number) => __traceBatchTransport.flush(deadlineMs),\n close: (deadlineMs: number) => __traceBatchTransport.close(deadlineMs),\n});\n\nipcRenderer.on('__woodTraceDrainRequest', (_event, payload: unknown) => {\n if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {\n return;\n }\n const requestId = Reflect.get(payload, 'requestId');\n const deadlineMs = Reflect.get(payload, 'deadlineMs');\n if (typeof requestId !== 'string'\n || typeof deadlineMs !== 'number'\n || !Number.isFinite(deadlineMs)) {\n return;\n }\n void __traceBatchTransport.close(deadlineMs).finally(() => {\n ipcRenderer.send('__woodTraceDrainAck', {\n requestId,\n status: __traceBatchTransport.status(),\n });\n });\n});\n\nglobalThis.addEventListener?.('pagehide', () => {\n void __traceBatchTransport.flush(Date.now() + 100);\n});\n`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAEA,2BAAiD;AACjD,mCAAoC;AAWpC,MAAM,8BAA8B;AAAA,EAClC;AAAA,EACA;AACF;AAEA,SAAS,kBAAkB,IAA2B;AACpD,SAAO;AAAA,IACL,GAAG,eACA,GAAG,YAAwC,SAAS,YACrD,OAAO,KAAM,GAAG,YAAwD,cAAc,CAAC,CAAC,EAAE,SAAS;AAAA,EACvG;AACF;AAMA,SAAS,gBAAgB,YAAgE;AACvF,QAAM,OAAgC,CAAC;AAEvC,aAAW,CAAC,SAAS,EAAE,KAAK,YAAY;AACtC,UAAM,QAAQ,QAAQ,MAAM,GAAG;AAC/B,QAAI,UAAmC;AAEvC,aAAS,IAAI,GAAG,IAAI,MAAM,SAAS,GAAG,KAAK;AACzC,UAAI,CAAC,QAAQ,MAAM,CAAC,CAAC,EAAG,SAAQ,MAAM,CAAC,CAAC,IAAI,CAAC;AAC7C,gBAAU,QAAQ,MAAM,CAAC,CAAC;AAAA,IAC5B;AAEA,UAAM,OAAO,MAAM,MAAM,SAAS,CAAC;AACnC,YAAQ,IAAI,IAAI,EAAE,aAAa,GAAG;AAAA,EACpC;AAEA,SAAO;AACT;AAKA,SAAS,iBAAiB,MAA+B,SAAiB,MAAc;AACtF,QAAM,QAAkB,CAAC;AAEzB,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,IAAI,GAAG;AAC/C,UAAM,QAAQ;AACd,QAAI,MAAM,aAAa;AACrB,YAAM,KAAK,MAAM;AACjB,YAAM,WAAW,kBAAkB,EAAE;AAErC,UAAI,UAAU;AACZ,cAAM,KAAK,GAAG,MAAM,GAAG,GAAG,sBAAsB;AAChD,cAAM,KAAK,GAAG,MAAM,yBAAyB,GAAG,OAAO,WAAW;AAAA,MACpE,OAAO;AACL,cAAM,KAAK,GAAG,MAAM,GAAG,GAAG,SAAS;AACnC,cAAM,KAAK,GAAG,MAAM,yBAAyB,GAAG,OAAO,KAAK;AAAA,MAC9D;AAAA,IACF,OAAO;AACL,YAAM,KAAK,GAAG,MAAM,GAAG,GAAG,KAAK;AAC/B,YAAM,KAAK,iBAAiB,OAAO,SAAS,IAAI,CAAC;AACjD,YAAM,KAAK,GAAG,MAAM,IAAI;AAAA,IAC1B;AAAA,EACF;AAEA,SAAO,MAAM,KAAK,IAAI;AACxB;AAEA,SAAS,wBAAwB,YAA+C;AAC9E,QAAM,UAAU,MAAM,KAAK,WAAW,OAAO,CAAC,EAC3C,KAAK,CAAC,GAAG,MAAM,EAAE,QAAQ,cAAc,EAAE,OAAO,CAAC,EACjD,IAAI,CAAC,OAAO;AACX,UAAM,UAAU;AAAA,MACd,SAAS,GAAG;AAAA,MACZ,YAAY,GAAG;AAAA,MACf,QAAQ,GAAG;AAAA,MACX,MAAM,GAAG,QAAQ,MAAM,GAAG;AAAA,MAC1B,UAAU,kBAAkB,EAAE;AAAA,IAChC;AACA,WAAO,KAAK,KAAK,UAAU,OAAO,CAAC;AAAA,EACrC,CAAC;AAEH,SAAO;AAAA,EACP,QAAQ,KAAK,IAAI,CAAC;AAAA;AAEpB;AAEA,MAAM,6BAA6B;AAK5B,SAAS,gBACd,YACA,UAAkC,CAAC,GAC3B;AACR,QAAM,OAAO,gBAAgB,UAAU;AACvC,QAAM,iBAAiB,iBAAiB,IAAI;AAC5C,QAAM,kBAAkB,wBAAwB,UAAU;AAC1D,QAAM,gBAAgB,QAAQ,iBAAiB;AAE/C,SAAO,OAAG,kDAAoB,2BAA2B,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAU1D,eAAe;AAAA;AAAA,sCAEqB,0BAA0B;AAAA;AAAA;AAAA,EAG9D,cAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,qBAqIK,cAAc,eAAe;AAAA,oBAC9B,cAAc,cAAc;AAAA,8BAClB,cAAc,wBAAwB;AAAA,gBACpD,cAAc,UAAU;AAAA,sBAClB,cAAc,gBAAgB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAqCpD;","names":[]}
1
+ {"version":3,"sources":["../../src/codegen/preload_generator.ts"],"sourcesContent":["import type { OperationDef } from '../types/index.cjs';\nimport type { RendererTraceBatchingConfig } from '../types/config.cjs';\nimport { RENDERER_TRACE_BATCHING_DEFAULTS } from '../config/config_parser.cjs';\nimport { generatedFileNotice } from './generated_file_notice.cjs';\n\nexport type GeneratePreloadOptions = {\n /**\n * Renderer→main IPC trace batching policy resolved from\n * wood.config.yml `observability.trace.rendererToMain.batching`.\n * The generated preload embeds these values but does not own them.\n */\n traceBatching?: RendererTraceBatchingConfig;\n};\n\nconst PRELOAD_SOURCE_INSTRUCTIONS = [\n 'To change IPC channels or preload bridge methods, edit the operations YAML configured by',\n 'wood.config.yml main.openapi and regenerate with `wood dev` or `wood build`.',\n] as const;\n\nfunction operationHasInput(op: OperationDef): boolean {\n return Boolean(\n op.inputSchema &&\n (op.inputSchema as Record<string, unknown>).type === 'object' &&\n Object.keys((op.inputSchema as Record<string, Record<string, unknown>>).properties ?? {}).length > 0,\n );\n}\n\n/**\n * Build a nested object tree from dot-path operation channels.\n * \"chat.message.send\" -> { chat: { message: { send: ... } } }\n */\nfunction buildNestedTree(operations: Map<string, OperationDef>): Record<string, unknown> {\n const tree: Record<string, unknown> = {};\n\n for (const [channel, op] of operations) {\n const parts = channel.split('.');\n let current: Record<string, unknown> = tree;\n\n for (let i = 0; i < parts.length - 1; i++) {\n if (!current[parts[i]]) current[parts[i]] = {};\n current = current[parts[i]] as Record<string, unknown>;\n }\n\n const leaf = parts[parts.length - 1];\n current[leaf] = { __operation: op };\n }\n\n return tree;\n}\n\n/**\n * Generate TypeScript code for a nested tree node.\n */\nfunction generateTreeCode(node: Record<string, unknown>, indent: string = ' '): string {\n const lines: string[] = [];\n\n for (const [key, value] of Object.entries(node)) {\n const entry = value as Record<string, unknown>;\n if (entry.__operation) {\n const op = entry.__operation as OperationDef;\n const hasInput = operationHasInput(op);\n\n if (hasInput) {\n lines.push(`${indent}${key}: (data: unknown) =>`);\n lines.push(`${indent} ipcRenderer.invoke('${op.channel}', data),`);\n } else {\n lines.push(`${indent}${key}: () =>`);\n lines.push(`${indent} ipcRenderer.invoke('${op.channel}'),`);\n }\n } else {\n lines.push(`${indent}${key}: {`);\n lines.push(generateTreeCode(entry, indent + ' '));\n lines.push(`${indent}},`);\n }\n }\n\n return lines.join('\\n');\n}\n\nfunction generateRpcManifestCode(operations: Map<string, OperationDef>): string {\n const entries = Array.from(operations.values())\n .sort((a, b) => a.channel.localeCompare(b.channel))\n .map((op) => {\n const payload = {\n channel: op.channel,\n controller: op.controller,\n action: op.action,\n path: op.channel.split('.'),\n hasInput: operationHasInput(op),\n };\n return ` ${JSON.stringify(payload)},`;\n });\n\n return `const __WOOD_RPC_MANIFEST = [\n${entries.join('\\n')}\n];`;\n}\n\nconst INTERNAL_BRIDGE_GLOBAL_KEY = '__NOEGO_WOOD_BRIDGE__';\n\n/**\n * Generate a complete preload.ts file from operations.\n */\nexport function generatePreload(\n operations: Map<string, OperationDef>,\n options: GeneratePreloadOptions = {},\n): string {\n const tree = buildNestedTree(operations);\n const operationsCode = generateTreeCode(tree);\n const rpcManifestCode = generateRpcManifestCode(operations);\n const traceBatching = options.traceBatching ?? RENDERER_TRACE_BATCHING_DEFAULTS;\n\n return `${generatedFileNotice(PRELOAD_SOURCE_INSTRUCTIONS)}\nimport { contextBridge, ipcRenderer } from 'electron';\nimport {\n RendererTraceBatchTransport,\n type WoodRendererTraceInput,\n type WoodTraceBatch,\n type WoodTraceBatchAcknowledgement,\n type WoodTraceBatchPort,\n} from '@noego/wood/trace-transport';\n\n${rpcManifestCode}\n\nconst __WOOD_INTERNAL_BRIDGE_KEY = '${INTERNAL_BRIDGE_GLOBAL_KEY}';\n\ncontextBridge.exposeInMainWorld(__WOOD_INTERNAL_BRIDGE_KEY, {\n${operationsCode}\n\n // Internal: backend controller/action RPC map for @Inject(App)\n __rpcManifest: __WOOD_RPC_MANIFEST,\n\n // Internal: loader data requests\n __load: (route: string, params: Record<string, string>) =>\n ipcRenderer.invoke('__load', { route, params }),\n\n // Internal: cross-window navigation\n __navigate: (windowId: string, route: string) =>\n ipcRenderer.invoke('__navigate', { windowId, route }),\n\n // Internal: window management\n __window: {\n current: () =>\n ipcRenderer.invoke('__window', { action: 'current' }),\n open: (windowId: string, options?: Record<string, unknown>) =>\n ipcRenderer.invoke('__window', { action: 'open', windowId, options }),\n close: (windowId?: string) =>\n ipcRenderer.invoke('__window', { action: 'close', windowId }),\n focus: (windowId: string) =>\n ipcRenderer.invoke('__window', { action: 'focus', windowId }),\n minimize: (windowId?: string) =>\n ipcRenderer.invoke('__window', { action: 'minimize', windowId }),\n maximize: (windowId?: string) =>\n ipcRenderer.invoke('__window', { action: 'maximize', windowId }),\n },\n\n // Internal: framework config (trace min level, etc)\n __woodConfig: () =>\n ipcRenderer.invoke('__woodConfig'),\n\n // Internal: debug metadata\n __debug: {\n current: () =>\n ipcRenderer.invoke('__debug', { action: 'current' }),\n },\n\n // Internal: context menu\n __contextMenu: {\n show: (data: unknown) =>\n ipcRenderer.invoke('__contextMenu', { action: 'show', ...data as Record<string, unknown> }),\n },\n\n // Push events from main process\n on: (channel: string, callback: (...args: unknown[]) => void) => {\n const subscription = (_event: unknown, ...args: unknown[]) => callback(...args);\n ipcRenderer.on(\\`__event:\\${channel}\\`, subscription);\n return () => ipcRenderer.removeListener(\\`__event:\\${channel}\\`, subscription);\n },\n __trace: (event: unknown) => {\n ipcRenderer.send('__trace', event);\n },\n __log: (level: string, logger: string, message: string, context?: unknown) => {\n ipcRenderer.send('__log', { level, logger, message, context });\n },\n __logBatch: (batch: unknown) => {\n ipcRenderer.send('__log_batch', batch);\n },\n});\n\n// Trace transport via MessagePort (activated when main process sends a port)\nlet __tracePort: MessagePort | null = null;\nconst __traceBuffer: unknown[] = [];\nconst __TRACE_BUFFER_CAPACITY = 256;\nlet __traceDroppedCapacity = 0;\n\nfunction __traceFlush(): void {\n while (__traceBuffer.length > 0 && __tracePort) {\n __tracePort.postMessage(__traceBuffer.shift());\n }\n}\n\nipcRenderer.on('__trace-port', (event) => {\n __tracePort = event.ports[0];\n __tracePort.start();\n __traceFlush();\n});\n\ncontextBridge.exposeInMainWorld('__NOEGO_TRACE__', {\n send: (data: unknown) => {\n if (__tracePort) {\n __tracePort.postMessage(data);\n } else if (__traceBuffer.length >= __TRACE_BUFFER_CAPACITY) {\n __traceDroppedCapacity += 1;\n } else {\n __traceBuffer.push(data);\n }\n },\n status: () => ({\n queuedEvents: __traceBuffer.length,\n droppedCapacity: __traceDroppedCapacity,\n }),\n});\n\nconst __traceBatchProducerId = typeof globalThis.crypto?.randomUUID === 'function'\n ? globalThis.crypto.randomUUID()\n : \\`wood-renderer-\\${Date.now()}-\\${Math.random().toString(16).slice(2)}\\`;\nlet __traceBatchListener: ((message: unknown) => void) | null = null;\n\nconst __traceBatchPort: WoodTraceBatchPort = {\n postMessage: (batch: WoodTraceBatch) => {\n void ipcRenderer.invoke('__traceBatch', batch)\n .then((acknowledgement: WoodTraceBatchAcknowledgement) => {\n __traceBatchListener?.(acknowledgement);\n })\n .catch((error: unknown) => {\n __traceBatchListener?.({\n kind: 'retryable',\n batchId: batch.batchId,\n reason: error instanceof Error ? error.message : String(error),\n retryAfterMs: 100,\n });\n });\n },\n subscribe: (listener: (message: unknown) => void) => {\n __traceBatchListener = listener;\n return () => {\n if (__traceBatchListener === listener) {\n __traceBatchListener = null;\n }\n };\n },\n close: () => {\n __traceBatchListener = null;\n },\n};\n\n// Batching policy resolved from wood.config.yml observability configuration\n// at generation time; this file embeds but does not own it.\nconst __traceBatchTransport = new RendererTraceBatchTransport({\n producerId: __traceBatchProducerId,\n maxQueuedEvents: ${traceBatching.maxQueuedEvents},\n maxBatchEvents: ${traceBatching.maxBatchEvents},\n acknowledgementTimeoutMs: ${traceBatching.acknowledgementTimeoutMs},\n retryLimit: ${traceBatching.retryLimit},\n retryBaseDelayMs: ${traceBatching.retryBaseDelayMs},\n now: () => Date.now(),\n schedule: (work: () => void) => queueMicrotask(work),\n delay: (durationMs: number) => new Promise((resolve) => setTimeout(resolve, durationMs)),\n});\n__traceBatchTransport.attachPort(__traceBatchPort);\n\ncontextBridge.exposeInMainWorld('__NOEGO_WOOD_TRACE_BATCH__', {\n send: (event: WoodRendererTraceInput) => __traceBatchTransport.admit(event),\n status: () => __traceBatchTransport.status(),\n flush: (deadlineMs: number) => __traceBatchTransport.flush(deadlineMs),\n close: (deadlineMs: number) => __traceBatchTransport.close(deadlineMs),\n});\n\n// App-registered shutdown drain hooks (essential user-state persistence).\n// Main waits for the drain acknowledgement, bounded by deadlineMs, before it\n// destroys this window, so hooks must flush quickly and never block forever.\ntype __WoodShutdownDrainHook = (deadlineMs: number) => unknown;\nconst __shutdownDrainHooks = new Set<__WoodShutdownDrainHook>();\n\nasync function __runShutdownDrainHooks(deadlineMs: number): Promise<{ hooks: number; settled: number; failed: number }> {\n const hooks = [...__shutdownDrainHooks];\n if (hooks.length === 0) {\n return { hooks: 0, settled: 0, failed: 0 };\n }\n const remainingMs = Math.max(0, deadlineMs - Date.now());\n const outcomes = await Promise.race([\n Promise.allSettled(hooks.map(async (hook) => hook(deadlineMs))),\n new Promise<null>((resolve) => setTimeout(() => resolve(null), remainingMs)),\n ]);\n if (outcomes === null) {\n return { hooks: hooks.length, settled: 0, failed: 0 };\n }\n const failed = outcomes.filter((outcome) => outcome.status === 'rejected').length;\n return { hooks: hooks.length, settled: outcomes.length, failed };\n}\n\ncontextBridge.exposeInMainWorld('__NOEGO_WOOD_SHUTDOWN__', {\n onDrain: (hook: __WoodShutdownDrainHook) => {\n if (typeof hook !== 'function') {\n return () => {};\n }\n __shutdownDrainHooks.add(hook);\n return () => { __shutdownDrainHooks.delete(hook); };\n },\n});\n\nipcRenderer.on('__woodTraceDrainRequest', (_event, payload: unknown) => {\n if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {\n return;\n }\n const requestId = Reflect.get(payload, 'requestId');\n const deadlineMs = Reflect.get(payload, 'deadlineMs');\n if (typeof requestId !== 'string'\n || typeof deadlineMs !== 'number'\n || !Number.isFinite(deadlineMs)) {\n return;\n }\n let persistence: { hooks: number; settled: number; failed: number } = { hooks: 0, settled: 0, failed: 0 };\n void __runShutdownDrainHooks(deadlineMs)\n .then((result) => { persistence = result; })\n .catch(() => {})\n .then(() => __traceBatchTransport.close(deadlineMs))\n .finally(() => {\n ipcRenderer.send('__woodTraceDrainAck', {\n requestId,\n status: __traceBatchTransport.status(),\n persistence,\n });\n });\n});\n\nglobalThis.addEventListener?.('pagehide', () => {\n void __traceBatchTransport.flush(Date.now() + 100);\n});\n`;\n}\n"],"mappings":"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","names":[]}
@@ -232,6 +232,39 @@ contextBridge.exposeInMainWorld('__NOEGO_WOOD_TRACE_BATCH__', {
232
232
  close: (deadlineMs: number) => __traceBatchTransport.close(deadlineMs),
233
233
  });
234
234
 
235
+ // App-registered shutdown drain hooks (essential user-state persistence).
236
+ // Main waits for the drain acknowledgement, bounded by deadlineMs, before it
237
+ // destroys this window, so hooks must flush quickly and never block forever.
238
+ type __WoodShutdownDrainHook = (deadlineMs: number) => unknown;
239
+ const __shutdownDrainHooks = new Set<__WoodShutdownDrainHook>();
240
+
241
+ async function __runShutdownDrainHooks(deadlineMs: number): Promise<{ hooks: number; settled: number; failed: number }> {
242
+ const hooks = [...__shutdownDrainHooks];
243
+ if (hooks.length === 0) {
244
+ return { hooks: 0, settled: 0, failed: 0 };
245
+ }
246
+ const remainingMs = Math.max(0, deadlineMs - Date.now());
247
+ const outcomes = await Promise.race([
248
+ Promise.allSettled(hooks.map(async (hook) => hook(deadlineMs))),
249
+ new Promise<null>((resolve) => setTimeout(() => resolve(null), remainingMs)),
250
+ ]);
251
+ if (outcomes === null) {
252
+ return { hooks: hooks.length, settled: 0, failed: 0 };
253
+ }
254
+ const failed = outcomes.filter((outcome) => outcome.status === 'rejected').length;
255
+ return { hooks: hooks.length, settled: outcomes.length, failed };
256
+ }
257
+
258
+ contextBridge.exposeInMainWorld('__NOEGO_WOOD_SHUTDOWN__', {
259
+ onDrain: (hook: __WoodShutdownDrainHook) => {
260
+ if (typeof hook !== 'function') {
261
+ return () => {};
262
+ }
263
+ __shutdownDrainHooks.add(hook);
264
+ return () => { __shutdownDrainHooks.delete(hook); };
265
+ },
266
+ });
267
+
235
268
  ipcRenderer.on('__woodTraceDrainRequest', (_event, payload: unknown) => {
236
269
  if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
237
270
  return;
@@ -243,12 +276,18 @@ ipcRenderer.on('__woodTraceDrainRequest', (_event, payload: unknown) => {
243
276
  || !Number.isFinite(deadlineMs)) {
244
277
  return;
245
278
  }
246
- void __traceBatchTransport.close(deadlineMs).finally(() => {
247
- ipcRenderer.send('__woodTraceDrainAck', {
248
- requestId,
249
- status: __traceBatchTransport.status(),
279
+ let persistence: { hooks: number; settled: number; failed: number } = { hooks: 0, settled: 0, failed: 0 };
280
+ void __runShutdownDrainHooks(deadlineMs)
281
+ .then((result) => { persistence = result; })
282
+ .catch(() => {})
283
+ .then(() => __traceBatchTransport.close(deadlineMs))
284
+ .finally(() => {
285
+ ipcRenderer.send('__woodTraceDrainAck', {
286
+ requestId,
287
+ status: __traceBatchTransport.status(),
288
+ persistence,
289
+ });
250
290
  });
251
- });
252
291
  });
253
292
 
254
293
  globalThis.addEventListener?.('pagehide', () => {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/codegen/preload_generator.ts"],"sourcesContent":["import type { OperationDef } from '../types/index.js';\nimport type { RendererTraceBatchingConfig } from '../types/config.js';\nimport { RENDERER_TRACE_BATCHING_DEFAULTS } from '../config/config_parser.js';\nimport { generatedFileNotice } from './generated_file_notice.js';\n\nexport type GeneratePreloadOptions = {\n /**\n * Renderer→main IPC trace batching policy resolved from\n * wood.config.yml `observability.trace.rendererToMain.batching`.\n * The generated preload embeds these values but does not own them.\n */\n traceBatching?: RendererTraceBatchingConfig;\n};\n\nconst PRELOAD_SOURCE_INSTRUCTIONS = [\n 'To change IPC channels or preload bridge methods, edit the operations YAML configured by',\n 'wood.config.yml main.openapi and regenerate with `wood dev` or `wood build`.',\n] as const;\n\nfunction operationHasInput(op: OperationDef): boolean {\n return Boolean(\n op.inputSchema &&\n (op.inputSchema as Record<string, unknown>).type === 'object' &&\n Object.keys((op.inputSchema as Record<string, Record<string, unknown>>).properties ?? {}).length > 0,\n );\n}\n\n/**\n * Build a nested object tree from dot-path operation channels.\n * \"chat.message.send\" -> { chat: { message: { send: ... } } }\n */\nfunction buildNestedTree(operations: Map<string, OperationDef>): Record<string, unknown> {\n const tree: Record<string, unknown> = {};\n\n for (const [channel, op] of operations) {\n const parts = channel.split('.');\n let current: Record<string, unknown> = tree;\n\n for (let i = 0; i < parts.length - 1; i++) {\n if (!current[parts[i]]) current[parts[i]] = {};\n current = current[parts[i]] as Record<string, unknown>;\n }\n\n const leaf = parts[parts.length - 1];\n current[leaf] = { __operation: op };\n }\n\n return tree;\n}\n\n/**\n * Generate TypeScript code for a nested tree node.\n */\nfunction generateTreeCode(node: Record<string, unknown>, indent: string = ' '): string {\n const lines: string[] = [];\n\n for (const [key, value] of Object.entries(node)) {\n const entry = value as Record<string, unknown>;\n if (entry.__operation) {\n const op = entry.__operation as OperationDef;\n const hasInput = operationHasInput(op);\n\n if (hasInput) {\n lines.push(`${indent}${key}: (data: unknown) =>`);\n lines.push(`${indent} ipcRenderer.invoke('${op.channel}', data),`);\n } else {\n lines.push(`${indent}${key}: () =>`);\n lines.push(`${indent} ipcRenderer.invoke('${op.channel}'),`);\n }\n } else {\n lines.push(`${indent}${key}: {`);\n lines.push(generateTreeCode(entry, indent + ' '));\n lines.push(`${indent}},`);\n }\n }\n\n return lines.join('\\n');\n}\n\nfunction generateRpcManifestCode(operations: Map<string, OperationDef>): string {\n const entries = Array.from(operations.values())\n .sort((a, b) => a.channel.localeCompare(b.channel))\n .map((op) => {\n const payload = {\n channel: op.channel,\n controller: op.controller,\n action: op.action,\n path: op.channel.split('.'),\n hasInput: operationHasInput(op),\n };\n return ` ${JSON.stringify(payload)},`;\n });\n\n return `const __WOOD_RPC_MANIFEST = [\n${entries.join('\\n')}\n];`;\n}\n\nconst INTERNAL_BRIDGE_GLOBAL_KEY = '__NOEGO_WOOD_BRIDGE__';\n\n/**\n * Generate a complete preload.ts file from operations.\n */\nexport function generatePreload(\n operations: Map<string, OperationDef>,\n options: GeneratePreloadOptions = {},\n): string {\n const tree = buildNestedTree(operations);\n const operationsCode = generateTreeCode(tree);\n const rpcManifestCode = generateRpcManifestCode(operations);\n const traceBatching = options.traceBatching ?? RENDERER_TRACE_BATCHING_DEFAULTS;\n\n return `${generatedFileNotice(PRELOAD_SOURCE_INSTRUCTIONS)}\nimport { contextBridge, ipcRenderer } from 'electron';\nimport {\n RendererTraceBatchTransport,\n type WoodRendererTraceInput,\n type WoodTraceBatch,\n type WoodTraceBatchAcknowledgement,\n type WoodTraceBatchPort,\n} from '@noego/wood/trace-transport';\n\n${rpcManifestCode}\n\nconst __WOOD_INTERNAL_BRIDGE_KEY = '${INTERNAL_BRIDGE_GLOBAL_KEY}';\n\ncontextBridge.exposeInMainWorld(__WOOD_INTERNAL_BRIDGE_KEY, {\n${operationsCode}\n\n // Internal: backend controller/action RPC map for @Inject(App)\n __rpcManifest: __WOOD_RPC_MANIFEST,\n\n // Internal: loader data requests\n __load: (route: string, params: Record<string, string>) =>\n ipcRenderer.invoke('__load', { route, params }),\n\n // Internal: cross-window navigation\n __navigate: (windowId: string, route: string) =>\n ipcRenderer.invoke('__navigate', { windowId, route }),\n\n // Internal: window management\n __window: {\n current: () =>\n ipcRenderer.invoke('__window', { action: 'current' }),\n open: (windowId: string, options?: Record<string, unknown>) =>\n ipcRenderer.invoke('__window', { action: 'open', windowId, options }),\n close: (windowId?: string) =>\n ipcRenderer.invoke('__window', { action: 'close', windowId }),\n focus: (windowId: string) =>\n ipcRenderer.invoke('__window', { action: 'focus', windowId }),\n minimize: (windowId?: string) =>\n ipcRenderer.invoke('__window', { action: 'minimize', windowId }),\n maximize: (windowId?: string) =>\n ipcRenderer.invoke('__window', { action: 'maximize', windowId }),\n },\n\n // Internal: framework config (trace min level, etc)\n __woodConfig: () =>\n ipcRenderer.invoke('__woodConfig'),\n\n // Internal: debug metadata\n __debug: {\n current: () =>\n ipcRenderer.invoke('__debug', { action: 'current' }),\n },\n\n // Internal: context menu\n __contextMenu: {\n show: (data: unknown) =>\n ipcRenderer.invoke('__contextMenu', { action: 'show', ...data as Record<string, unknown> }),\n },\n\n // Push events from main process\n on: (channel: string, callback: (...args: unknown[]) => void) => {\n const subscription = (_event: unknown, ...args: unknown[]) => callback(...args);\n ipcRenderer.on(\\`__event:\\${channel}\\`, subscription);\n return () => ipcRenderer.removeListener(\\`__event:\\${channel}\\`, subscription);\n },\n __trace: (event: unknown) => {\n ipcRenderer.send('__trace', event);\n },\n __log: (level: string, logger: string, message: string, context?: unknown) => {\n ipcRenderer.send('__log', { level, logger, message, context });\n },\n __logBatch: (batch: unknown) => {\n ipcRenderer.send('__log_batch', batch);\n },\n});\n\n// Trace transport via MessagePort (activated when main process sends a port)\nlet __tracePort: MessagePort | null = null;\nconst __traceBuffer: unknown[] = [];\nconst __TRACE_BUFFER_CAPACITY = 256;\nlet __traceDroppedCapacity = 0;\n\nfunction __traceFlush(): void {\n while (__traceBuffer.length > 0 && __tracePort) {\n __tracePort.postMessage(__traceBuffer.shift());\n }\n}\n\nipcRenderer.on('__trace-port', (event) => {\n __tracePort = event.ports[0];\n __tracePort.start();\n __traceFlush();\n});\n\ncontextBridge.exposeInMainWorld('__NOEGO_TRACE__', {\n send: (data: unknown) => {\n if (__tracePort) {\n __tracePort.postMessage(data);\n } else if (__traceBuffer.length >= __TRACE_BUFFER_CAPACITY) {\n __traceDroppedCapacity += 1;\n } else {\n __traceBuffer.push(data);\n }\n },\n status: () => ({\n queuedEvents: __traceBuffer.length,\n droppedCapacity: __traceDroppedCapacity,\n }),\n});\n\nconst __traceBatchProducerId = typeof globalThis.crypto?.randomUUID === 'function'\n ? globalThis.crypto.randomUUID()\n : \\`wood-renderer-\\${Date.now()}-\\${Math.random().toString(16).slice(2)}\\`;\nlet __traceBatchListener: ((message: unknown) => void) | null = null;\n\nconst __traceBatchPort: WoodTraceBatchPort = {\n postMessage: (batch: WoodTraceBatch) => {\n void ipcRenderer.invoke('__traceBatch', batch)\n .then((acknowledgement: WoodTraceBatchAcknowledgement) => {\n __traceBatchListener?.(acknowledgement);\n })\n .catch((error: unknown) => {\n __traceBatchListener?.({\n kind: 'retryable',\n batchId: batch.batchId,\n reason: error instanceof Error ? error.message : String(error),\n retryAfterMs: 100,\n });\n });\n },\n subscribe: (listener: (message: unknown) => void) => {\n __traceBatchListener = listener;\n return () => {\n if (__traceBatchListener === listener) {\n __traceBatchListener = null;\n }\n };\n },\n close: () => {\n __traceBatchListener = null;\n },\n};\n\n// Batching policy resolved from wood.config.yml observability configuration\n// at generation time; this file embeds but does not own it.\nconst __traceBatchTransport = new RendererTraceBatchTransport({\n producerId: __traceBatchProducerId,\n maxQueuedEvents: ${traceBatching.maxQueuedEvents},\n maxBatchEvents: ${traceBatching.maxBatchEvents},\n acknowledgementTimeoutMs: ${traceBatching.acknowledgementTimeoutMs},\n retryLimit: ${traceBatching.retryLimit},\n retryBaseDelayMs: ${traceBatching.retryBaseDelayMs},\n now: () => Date.now(),\n schedule: (work: () => void) => queueMicrotask(work),\n delay: (durationMs: number) => new Promise((resolve) => setTimeout(resolve, durationMs)),\n});\n__traceBatchTransport.attachPort(__traceBatchPort);\n\ncontextBridge.exposeInMainWorld('__NOEGO_WOOD_TRACE_BATCH__', {\n send: (event: WoodRendererTraceInput) => __traceBatchTransport.admit(event),\n status: () => __traceBatchTransport.status(),\n flush: (deadlineMs: number) => __traceBatchTransport.flush(deadlineMs),\n close: (deadlineMs: number) => __traceBatchTransport.close(deadlineMs),\n});\n\nipcRenderer.on('__woodTraceDrainRequest', (_event, payload: unknown) => {\n if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {\n return;\n }\n const requestId = Reflect.get(payload, 'requestId');\n const deadlineMs = Reflect.get(payload, 'deadlineMs');\n if (typeof requestId !== 'string'\n || typeof deadlineMs !== 'number'\n || !Number.isFinite(deadlineMs)) {\n return;\n }\n void __traceBatchTransport.close(deadlineMs).finally(() => {\n ipcRenderer.send('__woodTraceDrainAck', {\n requestId,\n status: __traceBatchTransport.status(),\n });\n });\n});\n\nglobalThis.addEventListener?.('pagehide', () => {\n void __traceBatchTransport.flush(Date.now() + 100);\n});\n`;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/codegen/preload_generator.ts"],"sourcesContent":["import type { OperationDef } from '../types/index.js';\nimport type { RendererTraceBatchingConfig } from '../types/config.js';\nimport { RENDERER_TRACE_BATCHING_DEFAULTS } from '../config/config_parser.js';\nimport { generatedFileNotice } from './generated_file_notice.js';\n\nexport type GeneratePreloadOptions = {\n /**\n * Renderer→main IPC trace batching policy resolved from\n * wood.config.yml `observability.trace.rendererToMain.batching`.\n * The generated preload embeds these values but does not own them.\n */\n traceBatching?: RendererTraceBatchingConfig;\n};\n\nconst PRELOAD_SOURCE_INSTRUCTIONS = [\n 'To change IPC channels or preload bridge methods, edit the operations YAML configured by',\n 'wood.config.yml main.openapi and regenerate with `wood dev` or `wood build`.',\n] as const;\n\nfunction operationHasInput(op: OperationDef): boolean {\n return Boolean(\n op.inputSchema &&\n (op.inputSchema as Record<string, unknown>).type === 'object' &&\n Object.keys((op.inputSchema as Record<string, Record<string, unknown>>).properties ?? {}).length > 0,\n );\n}\n\n/**\n * Build a nested object tree from dot-path operation channels.\n * \"chat.message.send\" -> { chat: { message: { send: ... } } }\n */\nfunction buildNestedTree(operations: Map<string, OperationDef>): Record<string, unknown> {\n const tree: Record<string, unknown> = {};\n\n for (const [channel, op] of operations) {\n const parts = channel.split('.');\n let current: Record<string, unknown> = tree;\n\n for (let i = 0; i < parts.length - 1; i++) {\n if (!current[parts[i]]) current[parts[i]] = {};\n current = current[parts[i]] as Record<string, unknown>;\n }\n\n const leaf = parts[parts.length - 1];\n current[leaf] = { __operation: op };\n }\n\n return tree;\n}\n\n/**\n * Generate TypeScript code for a nested tree node.\n */\nfunction generateTreeCode(node: Record<string, unknown>, indent: string = ' '): string {\n const lines: string[] = [];\n\n for (const [key, value] of Object.entries(node)) {\n const entry = value as Record<string, unknown>;\n if (entry.__operation) {\n const op = entry.__operation as OperationDef;\n const hasInput = operationHasInput(op);\n\n if (hasInput) {\n lines.push(`${indent}${key}: (data: unknown) =>`);\n lines.push(`${indent} ipcRenderer.invoke('${op.channel}', data),`);\n } else {\n lines.push(`${indent}${key}: () =>`);\n lines.push(`${indent} ipcRenderer.invoke('${op.channel}'),`);\n }\n } else {\n lines.push(`${indent}${key}: {`);\n lines.push(generateTreeCode(entry, indent + ' '));\n lines.push(`${indent}},`);\n }\n }\n\n return lines.join('\\n');\n}\n\nfunction generateRpcManifestCode(operations: Map<string, OperationDef>): string {\n const entries = Array.from(operations.values())\n .sort((a, b) => a.channel.localeCompare(b.channel))\n .map((op) => {\n const payload = {\n channel: op.channel,\n controller: op.controller,\n action: op.action,\n path: op.channel.split('.'),\n hasInput: operationHasInput(op),\n };\n return ` ${JSON.stringify(payload)},`;\n });\n\n return `const __WOOD_RPC_MANIFEST = [\n${entries.join('\\n')}\n];`;\n}\n\nconst INTERNAL_BRIDGE_GLOBAL_KEY = '__NOEGO_WOOD_BRIDGE__';\n\n/**\n * Generate a complete preload.ts file from operations.\n */\nexport function generatePreload(\n operations: Map<string, OperationDef>,\n options: GeneratePreloadOptions = {},\n): string {\n const tree = buildNestedTree(operations);\n const operationsCode = generateTreeCode(tree);\n const rpcManifestCode = generateRpcManifestCode(operations);\n const traceBatching = options.traceBatching ?? RENDERER_TRACE_BATCHING_DEFAULTS;\n\n return `${generatedFileNotice(PRELOAD_SOURCE_INSTRUCTIONS)}\nimport { contextBridge, ipcRenderer } from 'electron';\nimport {\n RendererTraceBatchTransport,\n type WoodRendererTraceInput,\n type WoodTraceBatch,\n type WoodTraceBatchAcknowledgement,\n type WoodTraceBatchPort,\n} from '@noego/wood/trace-transport';\n\n${rpcManifestCode}\n\nconst __WOOD_INTERNAL_BRIDGE_KEY = '${INTERNAL_BRIDGE_GLOBAL_KEY}';\n\ncontextBridge.exposeInMainWorld(__WOOD_INTERNAL_BRIDGE_KEY, {\n${operationsCode}\n\n // Internal: backend controller/action RPC map for @Inject(App)\n __rpcManifest: __WOOD_RPC_MANIFEST,\n\n // Internal: loader data requests\n __load: (route: string, params: Record<string, string>) =>\n ipcRenderer.invoke('__load', { route, params }),\n\n // Internal: cross-window navigation\n __navigate: (windowId: string, route: string) =>\n ipcRenderer.invoke('__navigate', { windowId, route }),\n\n // Internal: window management\n __window: {\n current: () =>\n ipcRenderer.invoke('__window', { action: 'current' }),\n open: (windowId: string, options?: Record<string, unknown>) =>\n ipcRenderer.invoke('__window', { action: 'open', windowId, options }),\n close: (windowId?: string) =>\n ipcRenderer.invoke('__window', { action: 'close', windowId }),\n focus: (windowId: string) =>\n ipcRenderer.invoke('__window', { action: 'focus', windowId }),\n minimize: (windowId?: string) =>\n ipcRenderer.invoke('__window', { action: 'minimize', windowId }),\n maximize: (windowId?: string) =>\n ipcRenderer.invoke('__window', { action: 'maximize', windowId }),\n },\n\n // Internal: framework config (trace min level, etc)\n __woodConfig: () =>\n ipcRenderer.invoke('__woodConfig'),\n\n // Internal: debug metadata\n __debug: {\n current: () =>\n ipcRenderer.invoke('__debug', { action: 'current' }),\n },\n\n // Internal: context menu\n __contextMenu: {\n show: (data: unknown) =>\n ipcRenderer.invoke('__contextMenu', { action: 'show', ...data as Record<string, unknown> }),\n },\n\n // Push events from main process\n on: (channel: string, callback: (...args: unknown[]) => void) => {\n const subscription = (_event: unknown, ...args: unknown[]) => callback(...args);\n ipcRenderer.on(\\`__event:\\${channel}\\`, subscription);\n return () => ipcRenderer.removeListener(\\`__event:\\${channel}\\`, subscription);\n },\n __trace: (event: unknown) => {\n ipcRenderer.send('__trace', event);\n },\n __log: (level: string, logger: string, message: string, context?: unknown) => {\n ipcRenderer.send('__log', { level, logger, message, context });\n },\n __logBatch: (batch: unknown) => {\n ipcRenderer.send('__log_batch', batch);\n },\n});\n\n// Trace transport via MessagePort (activated when main process sends a port)\nlet __tracePort: MessagePort | null = null;\nconst __traceBuffer: unknown[] = [];\nconst __TRACE_BUFFER_CAPACITY = 256;\nlet __traceDroppedCapacity = 0;\n\nfunction __traceFlush(): void {\n while (__traceBuffer.length > 0 && __tracePort) {\n __tracePort.postMessage(__traceBuffer.shift());\n }\n}\n\nipcRenderer.on('__trace-port', (event) => {\n __tracePort = event.ports[0];\n __tracePort.start();\n __traceFlush();\n});\n\ncontextBridge.exposeInMainWorld('__NOEGO_TRACE__', {\n send: (data: unknown) => {\n if (__tracePort) {\n __tracePort.postMessage(data);\n } else if (__traceBuffer.length >= __TRACE_BUFFER_CAPACITY) {\n __traceDroppedCapacity += 1;\n } else {\n __traceBuffer.push(data);\n }\n },\n status: () => ({\n queuedEvents: __traceBuffer.length,\n droppedCapacity: __traceDroppedCapacity,\n }),\n});\n\nconst __traceBatchProducerId = typeof globalThis.crypto?.randomUUID === 'function'\n ? globalThis.crypto.randomUUID()\n : \\`wood-renderer-\\${Date.now()}-\\${Math.random().toString(16).slice(2)}\\`;\nlet __traceBatchListener: ((message: unknown) => void) | null = null;\n\nconst __traceBatchPort: WoodTraceBatchPort = {\n postMessage: (batch: WoodTraceBatch) => {\n void ipcRenderer.invoke('__traceBatch', batch)\n .then((acknowledgement: WoodTraceBatchAcknowledgement) => {\n __traceBatchListener?.(acknowledgement);\n })\n .catch((error: unknown) => {\n __traceBatchListener?.({\n kind: 'retryable',\n batchId: batch.batchId,\n reason: error instanceof Error ? error.message : String(error),\n retryAfterMs: 100,\n });\n });\n },\n subscribe: (listener: (message: unknown) => void) => {\n __traceBatchListener = listener;\n return () => {\n if (__traceBatchListener === listener) {\n __traceBatchListener = null;\n }\n };\n },\n close: () => {\n __traceBatchListener = null;\n },\n};\n\n// Batching policy resolved from wood.config.yml observability configuration\n// at generation time; this file embeds but does not own it.\nconst __traceBatchTransport = new RendererTraceBatchTransport({\n producerId: __traceBatchProducerId,\n maxQueuedEvents: ${traceBatching.maxQueuedEvents},\n maxBatchEvents: ${traceBatching.maxBatchEvents},\n acknowledgementTimeoutMs: ${traceBatching.acknowledgementTimeoutMs},\n retryLimit: ${traceBatching.retryLimit},\n retryBaseDelayMs: ${traceBatching.retryBaseDelayMs},\n now: () => Date.now(),\n schedule: (work: () => void) => queueMicrotask(work),\n delay: (durationMs: number) => new Promise((resolve) => setTimeout(resolve, durationMs)),\n});\n__traceBatchTransport.attachPort(__traceBatchPort);\n\ncontextBridge.exposeInMainWorld('__NOEGO_WOOD_TRACE_BATCH__', {\n send: (event: WoodRendererTraceInput) => __traceBatchTransport.admit(event),\n status: () => __traceBatchTransport.status(),\n flush: (deadlineMs: number) => __traceBatchTransport.flush(deadlineMs),\n close: (deadlineMs: number) => __traceBatchTransport.close(deadlineMs),\n});\n\n// App-registered shutdown drain hooks (essential user-state persistence).\n// Main waits for the drain acknowledgement, bounded by deadlineMs, before it\n// destroys this window, so hooks must flush quickly and never block forever.\ntype __WoodShutdownDrainHook = (deadlineMs: number) => unknown;\nconst __shutdownDrainHooks = new Set<__WoodShutdownDrainHook>();\n\nasync function __runShutdownDrainHooks(deadlineMs: number): Promise<{ hooks: number; settled: number; failed: number }> {\n const hooks = [...__shutdownDrainHooks];\n if (hooks.length === 0) {\n return { hooks: 0, settled: 0, failed: 0 };\n }\n const remainingMs = Math.max(0, deadlineMs - Date.now());\n const outcomes = await Promise.race([\n Promise.allSettled(hooks.map(async (hook) => hook(deadlineMs))),\n new Promise<null>((resolve) => setTimeout(() => resolve(null), remainingMs)),\n ]);\n if (outcomes === null) {\n return { hooks: hooks.length, settled: 0, failed: 0 };\n }\n const failed = outcomes.filter((outcome) => outcome.status === 'rejected').length;\n return { hooks: hooks.length, settled: outcomes.length, failed };\n}\n\ncontextBridge.exposeInMainWorld('__NOEGO_WOOD_SHUTDOWN__', {\n onDrain: (hook: __WoodShutdownDrainHook) => {\n if (typeof hook !== 'function') {\n return () => {};\n }\n __shutdownDrainHooks.add(hook);\n return () => { __shutdownDrainHooks.delete(hook); };\n },\n});\n\nipcRenderer.on('__woodTraceDrainRequest', (_event, payload: unknown) => {\n if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {\n return;\n }\n const requestId = Reflect.get(payload, 'requestId');\n const deadlineMs = Reflect.get(payload, 'deadlineMs');\n if (typeof requestId !== 'string'\n || typeof deadlineMs !== 'number'\n || !Number.isFinite(deadlineMs)) {\n return;\n }\n let persistence: { hooks: number; settled: number; failed: number } = { hooks: 0, settled: 0, failed: 0 };\n void __runShutdownDrainHooks(deadlineMs)\n .then((result) => { persistence = result; })\n .catch(() => {})\n .then(() => __traceBatchTransport.close(deadlineMs))\n .finally(() => {\n ipcRenderer.send('__woodTraceDrainAck', {\n requestId,\n status: __traceBatchTransport.status(),\n persistence,\n });\n });\n});\n\nglobalThis.addEventListener?.('pagehide', () => {\n void __traceBatchTransport.flush(Date.now() + 100);\n});\n`;\n}\n"],"mappings":"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","names":[]}
@@ -5,6 +5,7 @@ import { WorkRegistry, WorkFailure } from './work_registry.cjs';
5
5
  export { FrontendWorkError } from './work_registry.cjs';
6
6
  export { FrontendLifecycle } from './frontend_lifecycle.cjs';
7
7
  import { FrontendPage } from './frontend_page.cjs';
8
+ import '../navigation/layout_preservation.cjs';
8
9
  import '../navigation/automation_introspection.cjs';
9
10
 
10
11
  interface FrontendOpenOptions {
@@ -5,6 +5,7 @@ import { WorkRegistry, WorkFailure } from './work_registry.js';
5
5
  export { FrontendWorkError } from './work_registry.js';
6
6
  export { FrontendLifecycle } from './frontend_lifecycle.js';
7
7
  import { FrontendPage } from './frontend_page.js';
8
+ import '../navigation/layout_preservation.js';
8
9
  import '../navigation/automation_introspection.js';
9
10
 
10
11
  interface FrontendOpenOptions {
@@ -7,4 +7,5 @@ export { CreateFrontendSurfaceOptions, FrontendApplicationSurface, FrontendOpenO
7
7
  import '../types/views.cjs';
8
8
  import '../transport/wood_rpc_transport.cjs';
9
9
  import '../navigation/index.cjs';
10
+ import '../navigation/layout_preservation.cjs';
10
11
  import '../navigation/automation_introspection.cjs';
@@ -7,4 +7,5 @@ export { CreateFrontendSurfaceOptions, FrontendApplicationSurface, FrontendOpenO
7
7
  import '../types/views.js';
8
8
  import '../transport/wood_rpc_transport.js';
9
9
  import '../navigation/index.js';
10
+ import '../navigation/layout_preservation.js';
10
11
  import '../navigation/automation_introspection.js';
package/dist/index.d.cts CHANGED
@@ -38,9 +38,10 @@ export { WoodRendererTraceEvent, WoodRendererTraceInput, WoodTraceBatch, WoodTra
38
38
  export { WoodTraceBatchContract } from './tracing/wood_trace_batch_contract.cjs';
39
39
  export { TraceCommitRow, TraceCommitWriter, TraceCommitWriterOptions, TraceFlushOptions, TraceFlushResult, WoodTracePersistencePort } from './tracing/trace_commit_writer.cjs';
40
40
  export { FrameworkTracer, MainTracer, RendererTracer, TestTracer, TraceProvider, TraceProviderConfiguration, TraceProviderMode, TraceRuntime } from './tracing/trace_provider.cjs';
41
- export { Navigation, NavigationEntry, NavigationOptions, getNavigation, go, goback, goto } from './navigation/index.cjs';
41
+ export { Navigation, NavigationEntry, getNavigation, go, goback, goto } from './navigation/index.cjs';
42
42
  export { Ipc, getIpc } from './ipc/index.cjs';
43
43
  export { IpcPushOptions } from './ipc/shared.cjs';
44
+ export { NavigationOptions } from './navigation/layout_preservation.cjs';
44
45
  import '@noego/ioc';
45
46
  import './runtime/renderer_log_dispatcher.cjs';
46
47
  import './runtime/renderer_config_extension.cjs';
package/dist/index.d.ts CHANGED
@@ -38,9 +38,10 @@ export { WoodRendererTraceEvent, WoodRendererTraceInput, WoodTraceBatch, WoodTra
38
38
  export { WoodTraceBatchContract } from './tracing/wood_trace_batch_contract.js';
39
39
  export { TraceCommitRow, TraceCommitWriter, TraceCommitWriterOptions, TraceFlushOptions, TraceFlushResult, WoodTracePersistencePort } from './tracing/trace_commit_writer.js';
40
40
  export { FrameworkTracer, MainTracer, RendererTracer, TestTracer, TraceProvider, TraceProviderConfiguration, TraceProviderMode, TraceRuntime } from './tracing/trace_provider.js';
41
- export { Navigation, NavigationEntry, NavigationOptions, getNavigation, go, goback, goto } from './navigation/index.js';
41
+ export { Navigation, NavigationEntry, getNavigation, go, goback, goto } from './navigation/index.js';
42
42
  export { Ipc, getIpc } from './ipc/index.js';
43
43
  export { IpcPushOptions } from './ipc/shared.js';
44
+ export { NavigationOptions } from './navigation/layout_preservation.js';
44
45
  import '@noego/ioc';
45
46
  import './runtime/renderer_log_dispatcher.js';
46
47
  import './runtime/renderer_config_extension.js';
@@ -29,10 +29,12 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
29
29
  var main_exports = {};
30
30
  __export(main_exports, {
31
31
  BootstrapActivationGate: () => BootstrapActivationGate,
32
+ RENDERER_SHUTDOWN_DRAIN_TIMEOUT_MS: () => import_shutdown_coordinator2.RENDERER_SHUTDOWN_DRAIN_TIMEOUT_MS,
33
+ ShutdownCoordinator: () => import_shutdown_coordinator2.ShutdownCoordinator,
32
34
  resolveWoodDebugMetadataConfiguration: () => resolveWoodDebugMetadataConfiguration,
33
35
  setRendererConfigExtension: () => import_renderer_config_extension2.setRendererConfigExtension,
34
36
  setRendererLogRecordFilter: () => import_renderer_log_batch_receiver2.setRendererLogRecordFilter,
35
- shouldDeferLastWindowCloseForCleanup: () => shouldDeferLastWindowCloseForCleanup,
37
+ shouldDeferLastWindowCloseForCleanup: () => import_shutdown_coordinator2.shouldDeferLastWindowCloseForCleanup,
36
38
  startWoodMain: () => startWoodMain
37
39
  });
38
40
  module.exports = __toCommonJS(main_exports);
@@ -54,6 +56,8 @@ var import_renderer_config_extension2 = require("../runtime/renderer_config_exte
54
56
  var import_ipc = require("../ipc/index.js");
55
57
  var import_shared = require("../ipc/shared.js");
56
58
  var import_renderer_trace_drain_coordinator = require("./renderer_trace_drain_coordinator.js");
59
+ var import_shutdown_coordinator = require("./shutdown_coordinator.js");
60
+ var import_shutdown_coordinator2 = require("./shutdown_coordinator.js");
57
61
  class BootstrapActivationGate {
58
62
  constructor(hasWindow, createMainWindow) {
59
63
  this.hasWindow = hasWindow;
@@ -81,9 +85,6 @@ class BootstrapActivationGate {
81
85
  }
82
86
  }
83
87
  }
84
- function shouldDeferLastWindowCloseForCleanup(trackedWindowCount, cleanupComplete) {
85
- return trackedWindowCount === 1 && !cleanupComplete;
86
- }
87
88
  const DEFAULT_WOOD_REMOTE_DEBUGGING_PORT = 9333;
88
89
  function resolveWoodDebugMetadataConfiguration(env = process.env) {
89
90
  const isDevelopment = env.WOOD_MODE === "development";
@@ -133,8 +134,7 @@ function startWoodMain(options) {
133
134
  const windows = /* @__PURE__ */ new Map();
134
135
  const rendererHealth = /* @__PURE__ */ new Map();
135
136
  let mainWindow = null;
136
- let beforeQuitCleanupComplete = false;
137
- let beforeQuitCleanupPromise = null;
137
+ let shutdownCoordinator = null;
138
138
  let runtimeContext = null;
139
139
  let mainBootCleanup;
140
140
  let runtimeCleanup;
@@ -525,7 +525,11 @@ function startWoodMain(options) {
525
525
  mainWindow = null;
526
526
  }
527
527
  if (windows.size === 0) {
528
- import_electron.app.quit();
528
+ if (shutdownCoordinator) {
529
+ shutdownCoordinator.handleAllWindowsClosed();
530
+ } else {
531
+ import_electron.app.quit();
532
+ }
529
533
  }
530
534
  });
531
535
  if (options.onWindowCreated) {
@@ -727,73 +731,52 @@ function startWoodMain(options) {
727
731
  };
728
732
  void import_electron.app.whenReady().then(bootstrap).catch(handleBootstrapFailure);
729
733
  const registerApplicationLifecycle = () => {
734
+ const coordinator = new import_shutdown_coordinator.ShutdownCoordinator({
735
+ trackedWindows: () => windows.entries(),
736
+ clearTrackedWindows: () => {
737
+ windows.clear();
738
+ },
739
+ drainRenderers: () => (0, import_renderer_trace_drain_coordinator.drainRendererTracesBeforeQuit)(
740
+ rendererTraceDrain,
741
+ windows.values(),
742
+ logger,
743
+ Date.now() + import_shutdown_coordinator.RENDERER_SHUTDOWN_DRAIN_TIMEOUT_MS
744
+ ),
745
+ cleanupBackend: () => (0, import_cleanup.cleanupAll)([
746
+ () => runtimeCleanup?.(),
747
+ () => {
748
+ if (options.legacyGlobals) (0, import_shared.clearIpcPushSender)();
749
+ },
750
+ () => mainBootCleanup?.(),
751
+ () => options.onBeforeQuit?.(runtimeContext),
752
+ () => import_ioc.ExecutionContext.run(container, () => container.dispose())
753
+ ], "Wood main shutdown failed"),
754
+ requestQuit: () => {
755
+ import_electron.app.quit();
756
+ },
757
+ logger
758
+ });
759
+ shutdownCoordinator = coordinator;
730
760
  import_electron.app.on(
731
761
  "browser-window-created",
732
762
  (_event, window) => {
733
763
  window.on("close", (event) => {
734
764
  const isTrackedWindow = [...windows.values()].includes(window);
735
- if (!isTrackedWindow || !shouldDeferLastWindowCloseForCleanup(
736
- windows.size,
737
- beforeQuitCleanupComplete
738
- )) {
765
+ if (!isTrackedWindow) {
739
766
  return;
740
767
  }
741
- event.preventDefault();
742
- if (!beforeQuitCleanupPromise) {
743
- logger.debug("deferring last window close for renderer trace drain");
744
- import_electron.app.quit();
745
- }
768
+ coordinator.handleTrackedWindowClose(event, windows.size);
746
769
  });
747
770
  }
748
771
  );
749
- const destroyWindowsForQuit = () => {
750
- for (const [id, win] of windows) {
751
- if (!win.isDestroyed()) {
752
- win.destroy();
753
- logger.debug("destroyed window on quit", { windowId: id });
754
- }
755
- }
756
- windows.clear();
757
- };
758
- const runBeforeQuitCleanup = async () => {
759
- await (0, import_cleanup.cleanupAll)([
760
- () => (0, import_renderer_trace_drain_coordinator.drainRendererTracesBeforeQuit)(rendererTraceDrain, windows.values(), logger, Date.now() + 1e3),
761
- () => runtimeCleanup?.(),
762
- () => {
763
- if (options.legacyGlobals) (0, import_shared.clearIpcPushSender)();
764
- },
765
- () => mainBootCleanup?.(),
766
- () => options.onBeforeQuit?.(runtimeContext),
767
- () => import_ioc.ExecutionContext.run(container, () => container.dispose())
768
- ], "Wood main shutdown failed");
769
- };
770
772
  import_electron.app.on("before-quit", (event) => {
771
773
  if (!runtimeContext) {
772
774
  return;
773
775
  }
774
- if (beforeQuitCleanupComplete) {
775
- destroyWindowsForQuit();
776
- return;
777
- }
778
- event.preventDefault();
779
- if (beforeQuitCleanupPromise) {
780
- return;
781
- }
782
- beforeQuitCleanupPromise = (async () => {
783
- try {
784
- await runBeforeQuitCleanup();
785
- } catch (error) {
786
- logger.warn("before-quit handler failed", { error: toErrorMessage(error) });
787
- } finally {
788
- beforeQuitCleanupComplete = true;
789
- beforeQuitCleanupPromise = null;
790
- }
791
- destroyWindowsForQuit();
792
- import_electron.app.quit();
793
- })();
776
+ coordinator.handleBeforeQuit(event);
794
777
  });
795
778
  import_electron.app.on("window-all-closed", () => {
796
- import_electron.app.quit();
779
+ coordinator.handleAllWindowsClosed();
797
780
  });
798
781
  import_electron.app.on("activate", () => {
799
782
  bootstrapActivationGate.activate();
@@ -812,6 +795,8 @@ function startWoodMain(options) {
812
795
  // Annotate the CommonJS export names for ESM import in node:
813
796
  0 && (module.exports = {
814
797
  BootstrapActivationGate,
798
+ RENDERER_SHUTDOWN_DRAIN_TIMEOUT_MS,
799
+ ShutdownCoordinator,
815
800
  resolveWoodDebugMetadataConfiguration,
816
801
  setRendererConfigExtension,
817
802
  setRendererLogRecordFilter,