From 9be32ca8fbdd625e426d6b0355c46cd3ef9ab936 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Thu, 10 Sep 2026 18:32:16 +0800 Subject: [PATCH 01/83] perf(desktop): defer unused WorkHub and bot SDK initialization Create the WorkHub renderer on first use rather than on enable, while retaining drafts after use. Load platform SDKs only when a configured bot starts or Slack credentials are tested. Generated-by: OpenAI Codex --- .../__tests__/workhub-presentation.test.ts | 18 ++++++--- apps/desktop/src/main/workhub-presentation.ts | 10 ++--- .../src/bots/__tests__/bot-registry.test.ts | 37 +++++++++++++++++++ packages/runtime/src/bots/bot-test.ts | 5 ++- packages/runtime/src/bots/feishu-bridge.ts | 14 +++---- packages/runtime/src/bots/slack-bridge.ts | 9 ++++- packages/runtime/src/bots/wecom-bridge.ts | 6 ++- 7 files changed, 76 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts index a755cf1465..d9efcdda8a 100644 --- a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts @@ -550,21 +550,29 @@ test('all WorkHub entries obey the client enable setting and disabling retains t }); -test('prewarms once and the shortcut shows and hides synchronously', async () => { +test('enabling stays lazy and the first shortcut creates a reusable ready-gated view', async () => { const h = await harness(); await h.controller.refreshSettings(); + await h.controller.refreshSettings(); + assert.equal(h.windows.length, 1, 'settings do not create a floating window'); + assert.equal(h.views.length, 0, 'settings do not preload a second application'); + h.shortcut(); const floating = h.windows[1]!; const view = h.views[0]!; - assert.equal(floating.visible, false); - assert.equal(view.visible, false); + assert.equal(floating.visible, true, 'show happens in the shortcut callback, without an async queue'); + assert.equal(view.visible, true); assert.ok(floating.children.has(view)); + assert.equal(view.webContents.sent.some(([channel]) => channel === 'workhub-presentation:focus-composer'), false); + await h.command(view.webContents, 'ready'); + assert.equal(view.webContents.sent.some(([channel]) => channel === 'workhub-presentation:focus-composer'), true); await h.controller.refreshSettings(); assert.equal(h.windows.length, 2); assert.equal(h.views.length, 1); h.shortcut(); - assert.equal(floating.visible, true, 'show happens in the shortcut callback, without an async queue'); - h.shortcut(); assert.equal(floating.visible, false); + h.shortcut(); + assert.equal(floating.visible, true); + assert.equal(h.views.length, 1, 'hiding and summoning preserve the live draft'); assert.equal(h.mainRequests, 0); assert.equal(h.main.focused, 0); h.controller.dispose(); diff --git a/apps/desktop/src/main/workhub-presentation.ts b/apps/desktop/src/main/workhub-presentation.ts index 982abe97db..c89bb91b5c 100644 --- a/apps/desktop/src/main/workhub-presentation.ts +++ b/apps/desktop/src/main/workhub-presentation.ts @@ -608,13 +608,9 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { const enabled = deps.isEnabled(); if (disposed) return; if (enabled) { - // Prepare the reusable native window and renderer while enabling WorkHub, - // before a shortcut needs them. Never restart a crashed renderer implicitly. - const target = ensureFloating(); - if (!rendererCrashed) { - ensureView(); - if (!parent) { attach(target); fitFloating(); } - } + // Enabling only registers the shortcut. The dock, shortcut or control + // request creates the renderer on first use; settings alone must not + // load a second application in the background. if (!shortcutRegistered) shortcutRegistered = globalShortcut.register(SHORTCUT, () => { void toggle(true).catch(reportError); }); } else { if (shortcutRegistered) globalShortcut.unregister(SHORTCUT); diff --git a/packages/runtime/src/bots/__tests__/bot-registry.test.ts b/packages/runtime/src/bots/__tests__/bot-registry.test.ts index 47cce90318..6bfdcbafe8 100644 --- a/packages/runtime/src/bots/__tests__/bot-registry.test.ts +++ b/packages/runtime/src/bots/__tests__/bot-registry.test.ts @@ -18,6 +18,7 @@ */ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { describe, test } from 'node:test'; import { createDefaultBotChannel } from '@maka/core/settings'; import type { BotChatSettings, BotProvider } from '@maka/core/bot-chat-settings'; @@ -25,6 +26,42 @@ import { BotRegistry } from '../bot-registry.js'; import type { BotStatus } from '../types.js'; describe('BotRegistry', () => { + test('the public entry and unconfigured channels do not load platform SDKs', () => { + // Use a fresh process: other bridge tests deliberately load and mock SDKs. + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '--eval', + ` + import assert from 'node:assert/strict'; + import { createRequire } from 'node:module'; + const require = createRequire(${JSON.stringify(import.meta.url)}); + const { BotRegistry, testBotChannel } = await import(${JSON.stringify(new URL('../index.js', import.meta.url).href)}); + const { BOT_PROVIDERS, createDefaultBotChannel } = await import('@maka/core/settings'); + const registry = new BotRegistry({ onIncomingMessage() {}, onStatusChange() {} }); + for (const enabled of [false, true]) { + const channels = Object.fromEntries(BOT_PROVIDERS.map(provider => [ + provider, { ...createDefaultBotChannel(provider), enabled }, + ])); + await registry.applySettings({ channels }); + assert.equal((await testBotChannel('slack', channels.slack)).errorCode, 'slack_tokens_missing'); + } + await registry.stopAll(); + const sdkModules = Object.keys(require.cache).filter(path => + /[\\\\/]node_modules[\\\\/](@larksuiteoapi|@wecom|@slack)[\\\\/]/.test(path)); + assert.deepEqual(sdkModules, []); + `, + ], + { encoding: 'utf8', timeout: 15_000 }, + ); + assert.equal( + result.status, + 0, + result.stderr || result.error?.message || 'SDK loading probe failed', + ); + }); + test('reports disabled and missing-credential statuses without opening network connections', async () => { const statuses: BotStatus[] = []; const registry = new BotRegistry({ diff --git a/packages/runtime/src/bots/bot-test.ts b/packages/runtime/src/bots/bot-test.ts index 124b28ee8b..82a6ebd144 100644 --- a/packages/runtime/src/bots/bot-test.ts +++ b/packages/runtime/src/bots/bot-test.ts @@ -17,8 +17,8 @@ * under the License. */ +import { createRequire } from 'node:module'; import { type BotChannelSettings, type BotProvider } from '@maka/core/bot-chat-settings'; -import { WebClient } from '@slack/web-api'; import type { BotTestResult } from './types.js'; import { proxiedFetch } from './proxied-fetch.js'; import { botDiagnosticMessage } from './base-adapter.js'; @@ -94,6 +94,9 @@ async function testSlack(channel: BotChannelSettings): Promise { return { ok: false, errorCode: 'slack_tokens_missing' }; } try { + const { WebClient } = createRequire(import.meta.url)( + '@slack/web-api', + ) as typeof import('@slack/web-api'); const identity = await new WebClient(botToken).auth.test(); if (!identity.ok) return { ok: false, error: identity.error ?? 'Slack auth.test failed' }; const socket = await new WebClient(appToken).apps.connections.open(); diff --git a/packages/runtime/src/bots/feishu-bridge.ts b/packages/runtime/src/bots/feishu-bridge.ts index df2e5005bc..fda5c56ebf 100644 --- a/packages/runtime/src/bots/feishu-bridge.ts +++ b/packages/runtime/src/bots/feishu-bridge.ts @@ -17,13 +17,8 @@ * under the License. */ -import { - Domain, - LoggerLevel, - createLarkChannel, - type LarkChannel, - type NormalizedMessage, -} from '@larksuiteoapi/node-sdk'; +import { createRequire } from 'node:module'; +import type { LarkChannel, NormalizedMessage } from '@larksuiteoapi/node-sdk'; import type { BotChannelSettings } from '@maka/core/bot-chat-settings'; import { BaseBotAdapter, botReadinessFromSettings } from './base-adapter.js'; import type { BotSendOptions, BotStatus, SendCapable } from './types.js'; @@ -99,6 +94,11 @@ export class FeishuBotBridge extends BaseBotAdapter implements SendCapable { } this.explicitlyStopped = false; + // Load the SDK only for a configured channel. Keep initialization + // synchronous so stop() cannot race a new module-loading await. + const { Domain, LoggerLevel, createLarkChannel } = createRequire(import.meta.url)( + '@larksuiteoapi/node-sdk', + ) as typeof import('@larksuiteoapi/node-sdk'); const isLark = this.settings.domain?.trim() === 'larksuite.com'; const channel = createLarkChannel({ appId, diff --git a/packages/runtime/src/bots/slack-bridge.ts b/packages/runtime/src/bots/slack-bridge.ts index cefa882a30..3711e74c3d 100644 --- a/packages/runtime/src/bots/slack-bridge.ts +++ b/packages/runtime/src/bots/slack-bridge.ts @@ -17,9 +17,10 @@ * under the License. */ +import { createRequire } from 'node:module'; import type { BotChannelSettings } from '@maka/core/bot-chat-settings'; -import { SocketModeClient } from '@slack/socket-mode'; -import { WebClient } from '@slack/web-api'; +import type { SocketModeClient } from '@slack/socket-mode'; +import type { WebClient } from '@slack/web-api'; import { BaseBotAdapter, botReadinessFromSettings } from './base-adapter.js'; import type { BotSendOptions, SendCapable } from './types.js'; @@ -86,6 +87,10 @@ export class SlackBotBridge extends BaseBotAdapter implements SendCapable { return; } + const require = createRequire(import.meta.url); + const { WebClient } = require('@slack/web-api') as typeof import('@slack/web-api'); + const { SocketModeClient } = + require('@slack/socket-mode') as typeof import('@slack/socket-mode'); this.web = new WebClient(botToken); try { const identity = await this.web.auth.test(); diff --git a/packages/runtime/src/bots/wecom-bridge.ts b/packages/runtime/src/bots/wecom-bridge.ts index 215ac172f7..5b9c2dd155 100644 --- a/packages/runtime/src/bots/wecom-bridge.ts +++ b/packages/runtime/src/bots/wecom-bridge.ts @@ -17,7 +17,8 @@ * under the License. */ -import { WSClient, type TextMessage, type WsFrame } from '@wecom/aibot-node-sdk'; +import { createRequire } from 'node:module'; +import type { WSClient, TextMessage, WsFrame } from '@wecom/aibot-node-sdk'; import type { BotChannelSettings } from '@maka/core/bot-chat-settings'; import { BaseBotAdapter, botReadinessFromSettings } from './base-adapter.js'; import type { BotSendOptions, BotStatus, SendCapable } from './types.js'; @@ -90,6 +91,9 @@ export class WeComBotBridge extends BaseBotAdapter implements SendCapable { } this.explicitlyStopped = false; + const { WSClient } = createRequire(import.meta.url)( + '@wecom/aibot-node-sdk', + ) as typeof import('@wecom/aibot-node-sdk'); const client = new WSClient({ botId, secret, From e9289a60a36c964b0687a5b19b31d32c27b007cd Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Thu, 10 Sep 2026 18:43:43 +0800 Subject: [PATCH 02/83] fix(ui): release attachment previews when composers unmount Track Blob URL ownership before decode and stop asynchronous staging after unmount while preserving hidden drafts and StrictMode replay. Generated-by: OpenAI Codex --- .../use-composer-attachments.test.tsx | 259 ++++++++++++++++++ packages/ui/src/use-composer-attachments.ts | 53 +++- 2 files changed, 301 insertions(+), 11 deletions(-) create mode 100644 packages/ui/src/__tests__/use-composer-attachments.test.tsx diff --git a/packages/ui/src/__tests__/use-composer-attachments.test.tsx b/packages/ui/src/__tests__/use-composer-attachments.test.tsx new file mode 100644 index 0000000000..51b59eb903 --- /dev/null +++ b/packages/ui/src/__tests__/use-composer-attachments.test.tsx @@ -0,0 +1,259 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, test } from 'node:test'; +import { act, StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { + useComposerAttachments, + type ComposerAttachmentService, +} from '../use-composer-attachments.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + Image: globalThis.Image, + IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }).IS_REACT_ACT_ENVIRONMENT, +}; +const createObjectURL = URL.createObjectURL; +const revokeObjectURL = URL.revokeObjectURL; +const roots = new Set>(); +const createdUrls: string[] = []; +const decodedUrls: string[] = []; +let decode: () => Promise; + +beforeEach(() => { + const { document, window } = parseHTML(''); + Object.assign(globalThis, { + document, + window, + IS_REACT_ACT_ENVIRONMENT: true, + Image: class { + src = ''; + decode() { + decodedUrls.push(this.src); + return decode(); + } + }, + }); + decode = async () => {}; + URL.createObjectURL = (blob) => { + const url = createObjectURL(blob); + createdUrls.push(url); + return url; + }; +}); + +afterEach(async () => { + for (const root of roots) await act(() => root.unmount()); + roots.clear(); + // Also clean up when an assertion fails against a leaking implementation. + for (const url of createdUrls.splice(0)) revokeObjectURL(url); + decodedUrls.length = 0; + URL.createObjectURL = createObjectURL; + Object.assign(globalThis, originalGlobals); +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { resolve = settle; }); + return { promise, resolve }; +} + +function imageFile(): File { + const bytes = new Uint8Array(1024 * 1024); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + return new File([bytes], 'preview.png', { type: 'image/png' }); +} + +async function mount(service: Partial = {}) { + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + roots.add(root); + let state!: ReturnType; + const notices: string[] = []; + const errors: string[] = []; + function Probe({ draftKey, hidden }: { draftKey: string; hidden: boolean }) { + state = useComposerAttachments({ + draftKey, + copy: { + attachmentFailedTitle: 'Failed', + tryAgain: 'Try again', + imageAttachmentNotDirectTitle: 'Image', + imageAttachmentNotDirectDescription: 'Image notice', + }, + formatError: String, + toastApi: { error: (title) => errors.push(title) }, + service: { + pickFiles: async () => ({ ok: false, reason: 'cancelled' }), + previewApproval: async () => ({ ok: false, reason: 'unavailable' }), + ...service, + }, + imageNotice: { supportsVision: () => false, notify: (title) => notices.push(title) }, + }); + return