diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 06d41bad344..ebf4d275678 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -90,7 +90,19 @@ const bundledPluginResources = { // from package directories where pnpm's symlink farm is absent. Copy the exact // runtime dependency closure to Resources/node_modules so bare require() calls // do not fall through to a developer checkout's node_modules. -const commonExtraResources = [relayExtraResource, bundledPluginResources, skillFreshnessResources] +// Why the single file rather than the package root: app.asar carries no node_modules, so main's +// lazy require in deferred-emoji-shortcode-dataset.ts resolves only out of Resources/node_modules, +// but emojibase-data is 49 MB of locale datasets and worktree naming reads exactly this 166 KB file. +const emojiShortcodeDatasetResource = { + from: 'node_modules/emojibase-data/en/shortcodes/emojibase.json', + to: 'node_modules/emojibase-data/en/shortcodes/emojibase.json' +} +const commonExtraResources = [ + relayExtraResource, + bundledPluginResources, + skillFreshnessResources, + emojiShortcodeDatasetResource +] // Why: native speech addons must be real files outside app.asar; copy only the // package matching the artifact target instead of every optional variant. const macSpeechNativeResource = { diff --git a/config/scripts/electron-builder-runtime-resources.test.mjs b/config/scripts/electron-builder-runtime-resources.test.mjs index d2407776fa7..453d5702cb0 100644 --- a/config/scripts/electron-builder-runtime-resources.test.mjs +++ b/config/scripts/electron-builder-runtime-resources.test.mjs @@ -1,14 +1,18 @@ +import { readFileSync, readdirSync } from 'node:fs' import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' import { describe, expect, it } from 'vitest' const require = createRequire(import.meta.url) +const projectRoot = resolve(import.meta.dirname, '..', '..') const electronBuilderConfig = require('../electron-builder.config.cjs') const { createPackagedRuntimeNodeModuleResources, findAsarEntry, + isPackagedExternalSpecifier, + packageNameFromSpecifier, prunePackagedNodePty, prunePackagedParcelWatcher, prunePackagedSherpaOnnx, @@ -306,3 +310,91 @@ describe('packaged runtime resources', () => { } ) }) + +// Why source-anchored: the bundler renames a createRequire()'d require, so +// verifyPackagedMainRuntimeDeps' `require("x")` scan cannot see these specifiers — packaging +// stays green while the packaged app throws MODULE_NOT_FOUND the first time the path runs. +function collectLazyRequireSpecifiers(directory, found = new Map()) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const entryPath = join(directory, entry.name) + if (entry.isDirectory()) { + collectLazyRequireSpecifiers(entryPath, found) + continue + } + if (!entry.isFile() || !entry.name.endsWith('.ts') || entry.name.includes('.test.')) { + continue + } + const source = readFileSync(entryPath, 'utf8') + if (!source.includes('createRequire(')) { + continue + } + for (const match of source.matchAll(/\brequire[A-Za-z0-9_]*\(\s*'([^']+)'\s*\)/g)) { + if (isPackagedExternalSpecifier(match[1])) { + found.set(match[1], relative(projectRoot, entryPath).replaceAll('\\', '/')) + } + } + } + return found +} + +function packagedResourceDestinations(platform) { + return new Set( + (electronBuilderConfig[platform].extraResources ?? []).map((resource) => + String(resource.to).replaceAll('\\', '/') + ) + ) +} + +describe('lazily required packages reach Resources/node_modules', () => { + it('copies every createRequire specifier main uses into the packaged resource plan', () => { + const specifiers = collectLazyRequireSpecifiers(join(projectRoot, 'src', 'main')) + expect(specifiers.size).toBeGreaterThan(0) + + const destinations = { + win: packagedResourceDestinations('win'), + mac: packagedResourceDestinations('mac'), + linux: packagedResourceDestinations('linux') + } + for (const [specifier, source] of specifiers) { + const packageName = packageNameFromSpecifier(specifier) + const covered = (platform) => + destinations[platform].has(`node_modules/${packageName}`) || + destinations[platform].has(`node_modules/${specifier}`) + // Windows carries the full closure, so an uncovered specifier is uncovered everywhere. + expect( + covered('win'), + `${source} lazily requires '${specifier}', but nothing copies it to Resources/node_modules` + ).toBe(true) + if (covered('mac') && covered('linux')) { + continue + } + // Only the Windows-native loaders may be absent from the mac/linux plans. + expect(source, `'${specifier}' is packaged for Windows only`).toContain('windows') + } + }) + + it('resolves the copied emoji dataset the way the packaged main bundle does', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-lazy-require-')) + try { + const datasetPath = 'node_modules/emojibase-data/en/shortcodes/emojibase.json' + const entry = electronBuilderConfig.mac.extraResources.find( + (resource) => String(resource.to) === datasetPath + ) + expect(entry).toBeDefined() + const destination = join(resourcesDir, ...datasetPath.split('/')) + await mkdir(dirname(destination), { recursive: true }) + await cp(join(projectRoot, ...String(entry.from).split('/')), destination) + + // app.asar's parent is Resources, so main's bare require walks into Resources/node_modules. + const packagedMainDir = join(resourcesDir, 'app.asar', 'out', 'main') + await mkdir(packagedMainDir, { recursive: true }) + const probe = join(packagedMainDir, 'probe.cjs') + await writeFile(probe, 'module.exports = require', 'utf8') + + const dataset = require(probe)('emojibase-data/en/shortcodes/emojibase.json') + expect(Object.keys(dataset).length).toBeGreaterThan(1000) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) +}) diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 56253527c69..2caf2149f73 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -19,6 +19,7 @@ "../src/preload/usage-provider-api.ts", "../src/shared/**/*", "../src/main/gitlab/mappers.ts", + "../src/main/ipc/deferred-emoji-shortcode-dataset.ts", "../src/main/ipc/worktree-branch-name.ts", "../src/main/ipc/worktree-logic.ts", "../src/main/ipc/worktree-display-name.ts", diff --git a/src/main/ipc/deferred-emoji-shortcode-dataset.test.ts b/src/main/ipc/deferred-emoji-shortcode-dataset.test.ts new file mode 100644 index 00000000000..a4d510650d6 --- /dev/null +++ b/src/main/ipc/deferred-emoji-shortcode-dataset.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' +import emojiShortcodes from 'emojibase-data/en/shortcodes/emojibase.json' +import { requireEmojiShortcodeDataset } from './deferred-emoji-shortcode-dataset' + +// Lives under src/main (not next to the shared catalog) so the shared tsconfig projects stay +// free of a src/main import — the boundary emoji-shortcode-catalog.lazy.test.ts asserts on. +describe('deferred emoji shortcode dataset', () => { + it('loads the main-side dataset synchronously into an identical catalog', async () => { + vi.resetModules() + const eager = await import('../../shared/emoji-shortcode-catalog.js') + eager.setEmojiShortcodeDatasetLoader(() => emojiShortcodes) + const eagerEntries = eager.getStandardEmojiShortcodeEntries() + const eagerTransform = eager.replaceKnownEmojiWithShortcodes('ship \u{1F389} \u{1F44D}') + + vi.resetModules() + const deferred = await import('../../shared/emoji-shortcode-catalog.js') + deferred.setEmojiShortcodeDatasetLoader(requireEmojiShortcodeDataset) + + // No await between registration and first use: the require path keeps the sync contract. + expect(deferred.getStandardEmojiShortcodeEntries()).toEqual(eagerEntries) + expect(deferred.replaceKnownEmojiWithShortcodes('ship \u{1F389} \u{1F44D}')).toBe( + eagerTransform + ) + }) +}) diff --git a/src/main/ipc/deferred-emoji-shortcode-dataset.ts b/src/main/ipc/deferred-emoji-shortcode-dataset.ts new file mode 100644 index 00000000000..0d499d25750 --- /dev/null +++ b/src/main/ipc/deferred-emoji-shortcode-dataset.ts @@ -0,0 +1,13 @@ +import { createRequire } from 'node:module' +import type { EmojiShortcodeDataset } from '../../shared/emoji-shortcode-catalog' + +// Why createRequire (same reason as linear-sdk.ts): a static import inlines the 166 KB +// shortcode dataset into out/main/index.js and JSON.parses it on every launch, while only +// worktree-name sanitization ever reads it. app.asar ships no node_modules, so this bare require +// resolves out of Resources/node_modules — electron-builder.config.cjs copies exactly this file +// there (the package root is 49 MB of locale data). +const requireFromMain = createRequire(__filename) + +export function requireEmojiShortcodeDataset(): EmojiShortcodeDataset { + return requireFromMain('emojibase-data/en/shortcodes/emojibase.json') as EmojiShortcodeDataset +} diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 17ad0c49e46..7a8fe175c89 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -4,9 +4,15 @@ import type { Repo } from '../../shared/repo-types' import { isWindowsAbsolutePathLike, resolveRuntimePath } from '../../shared/cross-platform-path' import { isWslUncPath, resolveWslRepoWorktreeBasePath } from '../../shared/wsl-paths' import { splitWorktreeId } from '../../shared/worktree/id' -import { replaceKnownEmojiWithShortcodes } from '../../shared/emoji-shortcode-catalog' +import { + replaceKnownEmojiWithShortcodes, + setEmojiShortcodeDatasetLoader +} from '../../shared/emoji-shortcode-catalog' +import { requireEmojiShortcodeDataset } from './deferred-emoji-shortcode-dataset' import { getWslHome, getWslHomeAsync, parseWslPath } from '../wsl' +setEmojiShortcodeDatasetLoader(requireEmojiShortcodeDataset) + type WorktreePathSettings = Pick & { /** Distro to mirror the workspace root into when the repo itself sits on a * Windows drive but this project's git runs in WSL. Omitted = today's diff --git a/src/main/proxy-guarded-fetch-call-site-audit.test.ts b/src/main/proxy-guarded-fetch-call-site-audit.test.ts new file mode 100644 index 00000000000..cf9e1f60609 --- /dev/null +++ b/src/main/proxy-guarded-fetch-call-site-audit.test.ts @@ -0,0 +1,140 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Startup applies the persisted proxy to `session.defaultSession` only, and +// `installElectronProxyRequestGuard(session.defaultSession)` is what actually holds requests +// until that apply (and every later proxy transition) settles. Two ways a main-process fetcher +// can escape that fence, both audited here: +// 1. a `net.fetch` / `net.request` that names another `session` or `partition` +// 2. a `.fetch(` on a `session.fromPartition(...)` session +// Known pre-existing gap outside this repo's reach: electron-updater runs on its own partition. +// +// Rule 2 entries map a file to its expected number of non-`net` `.fetch(` calls. A count change +// means a call site was added, removed, or moved: re-audit the file and update the count. +const AUDITED_NON_NET_FETCH_CALLS = new Map([ + // Isolated cookie-jar session, proxied by createOpenCodeRequestSession before any request. + ['main/rate-limits/opencode-go-usage-fetcher.ts', 2], + // Isolated cookie-jar session that does NOT apply the proxy — a pre-existing gap, not a + // regression: no proxy has ever reached this partition. Keep it listed so it stays visible. + ['main/rate-limits/minimax-request-context.ts', 2], + // Injected HttpClient, not a session: resolves to net.fetch on defaultSession + // (main/host/electron-http-client.ts) or to the global-fetch-audited Node fallback. + ['main/jira/authenticated-request.ts', 1] +]) + +// `globalThis.fetch` / `global.fetch` belong to global-fetch-call-site-audit.test.ts. +// `\s*` before `(`: the formatter never emits `net.fetch (url)`, but an unformatted call must not +// be a hole in a guard whose whole job is to fail on the call nobody reviewed. +const FETCH_CALL = /\.fetch\s*\(/g +const RECEIVER_IDENTIFIER = /(?:^|[^.\w$])([A-Za-z_$][\w$]*)\s*$/ +const DEFAULT_SESSION_RECEIVERS = new Set(['net', 'globalThis', 'global']) +const NET_REQUEST_CALL = /(? { + const sources = auditedSourceFiles(__dirname) + + it('keeps every net.fetch/net.request on the guarded default session', () => { + const offenders: string[] = [] + for (const { file, content } of sources) { + for (const match of content.matchAll(NET_REQUEST_CALL)) { + const args = callArgumentText(content, match.index + match[0].length) + if (SESSION_SCOPED_OPTION.test(args)) { + offenders.push(`${file}:${content.slice(0, match.index).split('\n').length}`) + } + } + } + expect( + offenders.sort(), + 'This request names its own session/partition, so it is not covered by ' + + 'installElectronProxyRequestGuard(session.defaultSession) and startup never applies the ' + + 'persisted proxy to it. Either drop the option, or apply the proxy to that session ' + + 'yourself (see main/rate-limits/opencode-go-request-session.ts) and allowlist it here.' + ).toEqual([]) + }) + + it('keeps every non-default-session fetcher audited with its expected count', () => { + const found = new Map() + for (const { file, content } of sources) { + const hits = [...content.matchAll(FETCH_CALL)].filter((match) => { + const receiver = RECEIVER_IDENTIFIER.exec(content.slice(0, match.index))?.[1] + // A chained (`session.fromPartition(...).fetch(`) or member (`ctx.session.fetch(`) + // receiver has no bare trailing identifier, and is never the default session. + return receiver === undefined || !DEFAULT_SESSION_RECEIVERS.has(receiver) + }).length + if (hits > 0) { + found.set(file, hits) + } + } + + const drifted = [...found] + .filter(([file, count]) => AUDITED_NON_NET_FETCH_CALLS.get(file) !== count) + .map(([file, count]) => `${file}: found ${count} call(s)`) + .sort() + expect( + drifted, + 'A session.fromPartition(...) session is not covered by ' + + 'installElectronProxyRequestGuard(session.defaultSession), so nothing holds its requests ' + + 'until the proxy lands and startup never applies the proxy to it. Apply the proxy to that ' + + 'session yourself (see main/rate-limits/opencode-go-request-session.ts), then update ' + + 'AUDITED_NON_NET_FETCH_CALLS.' + ).toEqual([]) + + const stale = [...AUDITED_NON_NET_FETCH_CALLS.keys()].filter((file) => !found.has(file)).sort() + expect(stale, 'Remove audited entries whose .fetch( calls are gone.').toEqual([]) + }) +}) diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index fc381f15714..5e4d4cfe428 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -66,7 +66,12 @@ describe('startup ordering', () => { ) expect(desktopStartup).toContain('recordRuntimeRpcStartFailure(') // Why: `void`, not `await` — awaiting the dialog would park the rest of startup behind a modal. - expect(desktopStartup).toMatch(/void showRuntimeRpcStartupFailureDialog\(\s*win,/) + // It chains off the i18n barrier (published before this phase starts) so the translated strings + // it reads are loaded, which is a wait on i18n only, never on the dialog itself. + expect(desktopStartup).toMatch( + /void state\.mainProcessI18nReady\.then\(\(\) =>\s*showRuntimeRpcStartupFailureDialog\(\s*win,/ + ) + expect(desktopStartup).not.toMatch(/await[^\n]*showRuntimeRpcStartupFailureDialog\(/) // Why (#11025): a bare console.error here is exactly what left the CLI dead but the app healthy. expect(desktopStartup).not.toContain( "console.error('[runtime] Failed to start local RPC transport:'" diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index e122961b0c9..171aaf50421 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -139,7 +139,22 @@ export async function initializeReadyFoundation(): Promise { }) state.store = store // Why: create pending readiness before the guard can observe the default session. - const initialProxyApplication = applyElectronProxySettings(store.getSettings()) + // Why parked on state instead of awaited here: Dock/Launchpad launches don't inherit shell + // proxy env vars, so the persisted proxy must land before any app-owned network fetcher runs — + // but the guard below already holds every default-session request until this settles, so + // awaiting it inline only delayed window creation. Runtime launch awaits it before the first + // fetcher (the desktop relay / headless serve). + state.initialProxyApplicationReady = applyElectronProxySettings(store.getSettings()).then( + (result) => { + if (result.source === 'invalid-settings') { + // Why (STA-3442): a silent DIRECT fallback made a dead configured proxy undiagnosable. + console.warn('[proxy] persisted proxy settings are invalid; using direct networking') + } + }, + () => { + console.warn('[proxy] Failed to apply network proxy settings') + } + ) installElectronProxyRequestGuard(session.defaultSession) // Why armed here and not at install time: the report remembers what it last said, and // that state lives beside the profile data file, which does not exist until now. @@ -235,16 +250,6 @@ export async function initializeReadyFoundation(): Promise { if (shouldSuppressDevEducation({ isDev: is.dev })) { suppressDevEducationForStore(store) } - try { - // Why: Dock/Launchpad launches don't inherit shell proxy env vars, so apply the persisted proxy before any app-owned network fetchers run. - const proxyApplyResult = await initialProxyApplication - if (proxyApplyResult.source === 'invalid-settings') { - // Why (STA-3442): a silent DIRECT fallback made a dead configured proxy undiagnosable. - console.warn('[proxy] persisted proxy settings are invalid; using direct networking') - } - } catch { - console.warn('[proxy] Failed to apply network proxy settings') - } // Why: the partition installer reads the proxy through this resolver, so register it before sessions materialize. setBrowserNetworkProxySettingsResolver(() => state.store!.getSettings()) // Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches. diff --git a/src/main/startup/main-process-ready-phase-ordering.test.ts b/src/main/startup/main-process-ready-phase-ordering.test.ts new file mode 100644 index 00000000000..749eb56cb0f --- /dev/null +++ b/src/main/startup/main-process-ready-phase-ordering.test.ts @@ -0,0 +1,153 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const phaseEvents: string[] = [] +let releaseI18n: (() => void) | null = null + +vi.mock('./main-process-ready-foundation', () => ({ + initializeReadyFoundation: vi.fn(async () => { + phaseEvents.push('foundation') + }) +})) +vi.mock('./main-process-ready-runtime', () => ({ + initializeReadyRuntimeServices: vi.fn(async () => { + phaseEvents.push('runtime-services') + }) +})) +vi.mock('./main-process-i18n-menu', () => ({ + initializeMainProcessI18nAndMenu: vi.fn( + () => + new Promise((resolve) => { + phaseEvents.push('i18n-start') + releaseI18n = () => { + phaseEvents.push('i18n-done') + resolve() + } + }) + ) +})) +vi.mock('./main-process-runtime-launch', () => ({ + initializeMainProcessRuntimeLaunch: vi.fn(async () => { + phaseEvents.push('launch-start') + await Promise.resolve() + phaseEvents.push('window-created') + }) +})) + +const { initializeMainProcessReady } = await import('./main-process-ready') + +describe('ready-phase concurrency', () => { + beforeEach(() => { + phaseEvents.length = 0 + releaseI18n = null + }) + + it('creates the window without waiting for i18n and the native menu', async () => { + const options = { + openMainWindow: vi.fn(), + handleMacAppActivation: vi.fn() + } as unknown as Parameters[0] + + const ready = initializeMainProcessReady(options) + // Drain the launch phase's microtasks while i18n is still pending. + for (let tick = 0; tick < 8; tick += 1) { + await Promise.resolve() + } + + expect(phaseEvents).toEqual([ + 'foundation', + 'runtime-services', + 'i18n-start', + 'launch-start', + 'window-created' + ]) + + releaseI18n?.() + await ready + expect(phaseEvents.at(-1)).toBe('i18n-done') + }) + + it('still resolves only once i18n and the menu have settled', async () => { + const options = { + openMainWindow: vi.fn(), + handleMacAppActivation: vi.fn() + } as unknown as Parameters[0] + + const ready = initializeMainProcessReady(options) + let settled = false + void ready.then(() => { + settled = true + }) + for (let tick = 0; tick < 8; tick += 1) { + await Promise.resolve() + } + + expect(settled).toBe(false) + releaseI18n?.() + await ready + expect(settled).toBe(true) + }) +}) + +describe('initial proxy application ordering', () => { + const readStartupSource = (file: string): string => + readFileSync(join(process.cwd(), 'src/main/startup', file), 'utf8') + + it('parks the default-session proxy apply instead of blocking window creation on it', () => { + const foundation = readStartupSource('main-process-ready-foundation.ts') + + expect(foundation).toContain('state.initialProxyApplicationReady = applyElectronProxySettings(') + // The request guard, not this phase, is what fences fetchers on the proxy; awaiting it here + // only queued openMainWindow behind a ~24 ms setProxy round trip. + expect(foundation).not.toMatch(/await\s+(?:state\.)?initialProxyApplication/) + }) + + it('awaits the proxy after the window opens and before the desktop relay starts', () => { + const launch = readStartupSource('main-process-runtime-launch.ts') + const desktopStart = launch.indexOf('async function launchDesktopMode(') + const desktopEnd = launch.indexOf('\nexport async function initializeMainProcessRuntimeLaunch') + expect(desktopStart).toBeGreaterThanOrEqual(0) + expect(desktopEnd).toBeGreaterThan(desktopStart) + const desktop = launch.slice(desktopStart, desktopEnd) + + const windowIndex = desktop.indexOf('openMainWindow()') + const proxyIndex = desktop.indexOf('await state.initialProxyApplicationReady') + const relayIndex = desktop.indexOf('new DesktopRelayService(') + + expect(windowIndex).toBeGreaterThanOrEqual(0) + expect(proxyIndex).toBeGreaterThan(windowIndex) + expect(relayIndex).toBeGreaterThan(proxyIndex) + }) + + it('waits for i18n before the only launch-phase dialog that reads a translated string', () => { + const ready = readStartupSource('main-process-ready.ts') + const launch = readStartupSource('main-process-runtime-launch.ts') + + // Published before the launch phase starts, or the barrier the dialog awaits is still the + // default resolved promise. + const publishIndex = ready.indexOf('state.mainProcessI18nReady = ') + expect(publishIndex).toBeGreaterThanOrEqual(0) + expect(ready.indexOf('initializeMainProcessRuntimeLaunch(options)')).toBeGreaterThan( + publishIndex + ) + expect(launch).toMatch( + /state\.mainProcessI18nReady\.then\(\(\) =>\s*\n?\s*showRuntimeRpcStartupFailureDialog\(/ + ) + }) + + it('keeps headless serve strictly ordered behind the proxy apply', () => { + const launch = readStartupSource('main-process-runtime-launch.ts') + const serveStart = launch.indexOf('async function launchServeMode(') + const serveEnd = launch.indexOf('\nasync function launchDesktopMode(', serveStart) + expect(serveStart).toBeGreaterThanOrEqual(0) + expect(serveEnd).toBeGreaterThan(serveStart) + const serve = launch.slice(serveStart, serveEnd) + + const proxyIndex = serve.indexOf('await state.initialProxyApplicationReady') + const rpcIndex = serve.indexOf('runtimeRpc.start()') + + expect(proxyIndex).toBeGreaterThanOrEqual(0) + expect(rpcIndex).toBeGreaterThan(proxyIndex) + }) +}) diff --git a/src/main/startup/main-process-ready.ts b/src/main/startup/main-process-ready.ts index e6d8d782e6e..835c1f1dd13 100644 --- a/src/main/startup/main-process-ready.ts +++ b/src/main/startup/main-process-ready.ts @@ -1,4 +1,5 @@ import { initializeMainProcessI18nAndMenu } from './main-process-i18n-menu' +import { mainProcessState as state } from './main-process-state' import { initializeReadyFoundation } from './main-process-ready-foundation' import { initializeReadyRuntimeServices } from './main-process-ready-runtime' import { @@ -12,6 +13,10 @@ export async function initializeMainProcessReady( ): Promise { await initializeReadyFoundation() await initializeReadyRuntimeServices() - await initializeMainProcessI18nAndMenu() - await initializeMainProcessRuntimeLaunch(options) + // Why concurrent: window creation reads no translated string and no menu item, and both the + // native menu and the tray only become reachable once the window shows — so serializing them + // ahead of openMainWindow only delayed the renderer (8 ms in English, more for a lazy locale). + const i18nAndMenuReady = initializeMainProcessI18nAndMenu() + state.mainProcessI18nReady = i18nAndMenuReady.catch(() => {}) + await Promise.all([i18nAndMenuReady, initializeMainProcessRuntimeLaunch(options)]) } diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 9df28ecea8c..9390fd23497 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -120,6 +120,9 @@ async function launchServeMode( runtimeRpc: OrcaRuntimeRpcServer, serveOptions: NonNullable> ): Promise { + // Why here: headless serve has no window to unblock, so keep the persisted proxy strictly + // ahead of every fetcher this phase can reach (relay, CLI install, RPC clients). + await state.initialProxyApplicationReady // Why: give managed WSL launchers a brief chance to migrate before headless PTYs go live, without slow repairs withholding all RPC readiness. logStartupMilestone('wsl-cli-barrier-start') await state.managedWslCliStartupBarrierReady @@ -226,8 +229,17 @@ async function launchDesktopMode( ) ]) if (!runtimeRpcStartResult.ok) { - void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) + // Why gated: this dialog is the only launch-phase text read through translateMain, and i18n + // now settles alongside this phase — without the wait a non-English user could get the + // English defaultValue fallback. Still off the renderer's path (it is failure-only). + void state.mainProcessI18nReady.then(() => + showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) + ) } + // Why after the window and not before it: the default-session request guard already holds every + // fetcher until the persisted proxy lands, so this only has to keep the launch phase itself + // ordered ahead of the relay — it must not gate the renderer. + await state.initialProxyApplicationReady const cloudAuth = getOrcaCloudAuthConfig() if (cloudAuth.configured) { try { diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index d48219b461e..c88d5a66c48 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -100,6 +100,13 @@ export const mainProcessState = { // Electron with no error. Only the renderer's own pull proves the listener is live. markdownFileOpenListenerReady: false, firstWindowStartupServicesReady: Promise.resolve(), + // Why published: the default-session proxy must be applied before the first app-owned fetcher, + // but window creation has no reason to queue behind it (the request guard already fences it). + initialProxyApplicationReady: Promise.resolve(), + // Why published: i18n/menu init no longer precedes the launch phase, so the one launch-phase + // path that reads a translated string (the runtime-RPC startup failure dialog) waits on this. + // Never rejects: the phase's own failure is surfaced by initializeMainProcessReady. + mainProcessI18nReady: Promise.resolve(), managedWslCliReconciliationReady: Promise.resolve(), managedWslCliStartupBarrierReady: Promise.resolve(), // Why: the serve barrier fails open, so this state tells headless clients a WSL PTY launch may still race an un-migrated registration ('settled' = off-Windows no-op). diff --git a/src/renderer/src/lib/workspace-emoji-shortcodes.ts b/src/renderer/src/lib/workspace-emoji-shortcodes.ts index a3d65c18907..184207ffbd8 100644 --- a/src/renderer/src/lib/workspace-emoji-shortcodes.ts +++ b/src/renderer/src/lib/workspace-emoji-shortcodes.ts @@ -1,8 +1,14 @@ +import emojiShortcodes from 'emojibase-data/en/shortcodes/emojibase.json' import { getStandardEmojiShortcodeEntries, + setEmojiShortcodeDatasetLoader, type StandardEmojiShortcodeEntry } from '../../../shared/emoji-shortcode-catalog' +// Why eager here and lazy in main: a dynamic import made the shortcode transform return an +// empty catalog until it settled, so a `:wink:` submitted in that window persisted literally. +setEmojiShortcodeDatasetLoader(() => emojiShortcodes) + export type WorkspaceEmojiSuggestion = StandardEmojiShortcodeEntry export type ActiveWorkspaceEmojiShortcode = { diff --git a/src/shared/emoji-shortcode-catalog.lazy.test.ts b/src/shared/emoji-shortcode-catalog.lazy.test.ts index e838eef765e..dc05160696b 100644 --- a/src/shared/emoji-shortcode-catalog.lazy.test.ts +++ b/src/shared/emoji-shortcode-catalog.lazy.test.ts @@ -1,6 +1,13 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' +import emojiShortcodes from 'emojibase-data/en/shortcodes/emojibase.json' + +async function importConfiguredCatalog() { + const catalog = await import('./emoji-shortcode-catalog.js') + catalog.setEmojiShortcodeDatasetLoader(() => emojiShortcodes) + return catalog +} describe('emoji shortcode catalog laziness', () => { beforeEach(() => { @@ -8,7 +15,7 @@ describe('emoji shortcode catalog laziness', () => { }) it('does not build the catalog when the shared module is imported', async () => { - const catalog = await import('./emoji-shortcode-catalog.js') + const catalog = await importConfiguredCatalog() expect(catalog.isEmojiShortcodeCatalogBuiltForTest()).toBe(false) @@ -17,20 +24,23 @@ describe('emoji shortcode catalog laziness', () => { }) it('builds on first use and keeps the main process off the eager path', async () => { - const catalog = await import('./emoji-shortcode-catalog.js') + const catalog = await importConfiguredCatalog() expect(catalog.replaceKnownEmojiWithShortcodes('ship \u{1F389}')).toBe('ship party ') expect(catalog.isEmojiShortcodeCatalogBuiltForTest()).toBe(true) }) - it('leaves the main-process worktree namer importing only the deferred entry point', () => { + it('leaves the main-process worktree namer importing only the deferred entry points', () => { // A cross-project import would drag src/main into the shared tsconfig, so assert on source. const worktreeLogic = readFileSync(join(__dirname, '../main/ipc/worktree-logic.ts'), 'utf8') const catalogImport = worktreeLogic.match( /import \{([^}]*)\} from '[^']*emoji-shortcode-catalog'/ ) - expect(catalogImport?.[1].trim()).toBe('replaceKnownEmojiWithShortcodes') + expect(catalogImport?.[1].split(',').map((name) => name.trim())).toEqual([ + 'replaceKnownEmojiWithShortcodes', + 'setEmojiShortcodeDatasetLoader' + ]) }) it('keeps the catalog build out of module scope', () => { @@ -41,4 +51,15 @@ describe('emoji shortcode catalog laziness', () => { expect(sharedSource).not.toMatch(/^const \w+ = new (?:Map|Intl\.Segmenter)\(/m) expect(sharedSource).toContain('function loadCatalog()') }) + + it('keeps the 166 KB dataset off every module main statically imports', () => { + const sharedSource = readFileSync(join(__dirname, 'emoji-shortcode-catalog.ts'), 'utf8') + const worktreeLogic = readFileSync(join(__dirname, '../main/ipc/worktree-logic.ts'), 'utf8') + + // A static `emojibase-data` import anywhere main reaches inlines the dataset into + // out/main/index.js and JSON.parses it on every launch. + const staticDatasetImport = /\bfrom '[^']*emojibase-data[^']*'/ + expect(sharedSource).not.toMatch(staticDatasetImport) + expect(worktreeLogic).not.toMatch(staticDatasetImport) + }) }) diff --git a/src/shared/emoji-shortcode-catalog.ts b/src/shared/emoji-shortcode-catalog.ts index b583a0c617a..fca9922d3fe 100644 --- a/src/shared/emoji-shortcode-catalog.ts +++ b/src/shared/emoji-shortcode-catalog.ts @@ -1,10 +1,23 @@ -import emojiShortcodes from 'emojibase-data/en/shortcodes/emojibase.json' - export type StandardEmojiShortcodeEntry = { emoji: string shortcode: string } +/** Shape of `emojibase-data/en/shortcodes/emojibase.json`: hexcode -> shortcode or aliases. */ +export type EmojiShortcodeDataset = Readonly> + +let loadDataset: (() => EmojiShortcodeDataset) | null = null + +/** + * Why injected instead of statically imported: the renderer must keep its eager copy (a + * dynamic import there returned an empty catalog mid-load and persisted `:wink:` literally), + * but a static import here also inlines the same 166 KB into out/main/index.js and JSON.parses + * it on every launch. Main supplies a lazy require instead; both stay synchronous. + */ +export function setEmojiShortcodeDatasetLoader(load: () => EmojiShortcodeDataset): void { + loadDataset = load +} + // Skin-tone aliases (`wave_tone3`) are ~40% of the dataset and would drown the suggestion list. const SKIN_TONE_SHORTCODE = /_tone\d(?:-\d)?$/ @@ -23,7 +36,10 @@ function loadCatalog(): EmojiShortcodeCatalog { if (catalog) { return catalog } - const grouped = Object.entries(emojiShortcodes).flatMap(([hexcode, value]) => { + if (!loadDataset) { + throw new Error('Emoji shortcode dataset loader was never registered') + } + const grouped = Object.entries(loadDataset()).flatMap(([hexcode, value]) => { const shortcodes = (typeof value === 'string' ? [value] : value).filter( (shortcode) => !SKIN_TONE_SHORTCODE.test(shortcode) )