diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index a51d8c1235..7c7015477b 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -2026,6 +2026,48 @@ pub async fn initialize_workspace_startup_state( startup_trace: State<'_, DesktopStartupTrace>, ) -> Result { let command_started = Instant::now(); + let result = + initialize_workspace_startup_state_impl(&state, &app, &startup_trace, command_started) + .await; + startup_trace.record_tauri_command_elapsed( + "initialize_workspace_startup_state", + None, + command_started, + ); + result +} + +pub async fn prepare_workspace_startup_bootstrap_snapshot( + state: &State<'_, AppState>, + app: &tauri::AppHandle, + startup_trace: &State<'_, DesktopStartupTrace>, +) -> Option { + let started = Instant::now(); + let snapshot = + initialize_workspace_startup_state_impl(state, app, startup_trace, started).await; + startup_trace.record_elapsed_step( + "native_setup", + "prepare_workspace_startup_bootstrap_snapshot", + started, + ); + match snapshot { + Ok(snapshot) => Some(snapshot), + Err(error) => { + warn!( + "Failed to prepare workspace startup bootstrap snapshot, frontend will fall back to startup command: {}", + error + ); + None + } + } +} + +async fn initialize_workspace_startup_state_impl( + state: &State<'_, AppState>, + app: &tauri::AppHandle, + startup_trace: &State<'_, DesktopStartupTrace>, + command_started: Instant, +) -> Result { let trace = startup_trace.inner(); initialize_global_state_impl(&state, &app, trace).await; @@ -2042,11 +2084,6 @@ pub async fn initialize_workspace_startup_state( { Ok(removed_count) => removed_count, Err(error) => { - startup_trace.record_tauri_command_elapsed( - "initialize_workspace_startup_state", - None, - command_started, - ); return Err(error); } }; @@ -2058,11 +2095,6 @@ pub async fn initialize_workspace_startup_state( "initialize_workspace_startup_state.collect_workspace_state_snapshot", snapshot_started, ); - startup_trace.record_tauri_command_elapsed( - "initialize_workspace_startup_state", - None, - command_started, - ); Ok(WorkspaceStartupStateSnapshotDto { cleanup_removed_count, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 942246075b..438c2ba0d3 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -547,9 +547,39 @@ pub async fn run() { } let app_handle = app.handle().clone(); + let workspace_startup_bootstrap_snapshot = { + let app_state: tauri::State<'_, api::app_state::AppState> = app.state(); + let startup_trace_state: tauri::State<'_, startup_trace::DesktopStartupTrace> = + app.state(); + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on( + prepare_workspace_startup_bootstrap_snapshot( + &app_state, + &app_handle, + &startup_trace_state, + ), + ) + }) + .and_then(|snapshot| { + serde_json::to_value(snapshot) + .map_err(|error| { + log::warn!( + "Failed to serialize workspace startup bootstrap snapshot, frontend will fall back to startup command: {}", + error + ); + error + }) + .ok() + }) + }; let window_started = Instant::now(); startup_trace.record_phase("main_window_create_start", "native_window"); - theme::create_main_window(&app_handle, &startup_trace_id, &startup_trace); + theme::create_main_window( + &app_handle, + &startup_trace_id, + &startup_trace, + workspace_startup_bootstrap_snapshot, + ); let window_duration_ms = elapsed_ms(window_started); startup_trace.record_step( "native_step_end", diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index 1c221e4969..4d36f1778f 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -217,6 +217,7 @@ struct StartupBootstrapConfig { } const MAX_BOOTSTRAP_KEYBINDINGS_JSON_BYTES: usize = 64 * 1024; +const MAX_BOOTSTRAP_WORKSPACE_STATE_JSON_BYTES: usize = 64 * 1024; impl Default for ThemeConfig { fn default() -> Self { @@ -455,6 +456,7 @@ impl ThemeConfig { &self, startup_trace_id: &str, bootstrap_config: &StartupBootstrapConfig, + workspace_startup_state: Option<&serde_json::Value>, ) -> String { let theme_type = if self.is_light { "light" } else { "dark" }; let startup_locale = &bootstrap_config.locale; @@ -483,6 +485,11 @@ impl ThemeConfig { .filter(|json| json.len() <= MAX_BOOTSTRAP_KEYBINDINGS_JSON_BYTES) .map(|json| format!("window.__BITFUN_BOOTSTRAP_KEYBINDINGS__ = {json};")) .unwrap_or_default(); + let bootstrap_workspace_startup_state_assignment = workspace_startup_state + .and_then(|state| serde_json::to_string(state).ok()) + .filter(|json| json.len() <= MAX_BOOTSTRAP_WORKSPACE_STATE_JSON_BYTES) + .map(|json| format!("window.__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__ = {json};")) + .unwrap_or_default(); format!( r#" @@ -496,6 +503,7 @@ impl ThemeConfig { window.__BITFUN_BOOTSTRAP_THEME_ID__ = {bootstrap_theme_id_json}; window.__BITFUN_BOOTSTRAP_THEME_SELECTION__ = {bootstrap_theme_selection_json}; {bootstrap_keybindings_assignment} + {bootstrap_workspace_startup_state_assignment} function applyTheme() {{ var root = document.documentElement; if (!root) return false; @@ -545,6 +553,8 @@ impl ThemeConfig { startup_messages_json = startup_messages_json, show_startup_window_controls = show_startup_window_controls, bootstrap_keybindings_assignment = bootstrap_keybindings_assignment, + bootstrap_workspace_startup_state_assignment = + bootstrap_workspace_startup_state_assignment, ) } @@ -561,12 +571,17 @@ pub fn create_main_window( app_handle: &tauri::AppHandle, startup_trace_id: &str, startup_trace: &DesktopStartupTrace, + workspace_startup_state: Option, ) { let total_started_at = Instant::now(); let bootstrap_config = ThemeConfig::load_startup_bootstrap_config(); let theme = bootstrap_config.theme.clone(); let bg_color = theme.to_tauri_color(); - let init_script = theme.generate_init_script(startup_trace_id, &bootstrap_config); + let init_script = theme.generate_init_script( + startup_trace_id, + &bootstrap_config, + workspace_startup_state.as_ref(), + ); startup_trace.record_step( "native_step_end", "native_window", diff --git a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts index 67f406a14f..b9d9539302 100644 --- a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts +++ b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts @@ -111,6 +111,41 @@ describe('startup performance contract', () => { expect(configManagerSource).toContain('delete globalThis.__BITFUN_BOOTSTRAP_KEYBINDINGS__'); }); + it('keeps workspace startup state on the bootstrap path with command fallback', () => { + const globalStateSource = readSource('../../shared/types/global-state.ts'); + const desktopThemeSource = readSource('../../../../apps/desktop/src/theme.rs'); + const desktopLibSource = readSource('../../../../apps/desktop/src/lib.rs'); + const desktopCommandsSource = readSource('../../../../apps/desktop/src/api/commands.rs'); + + expect(desktopThemeSource).toContain('__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__'); + expect(desktopThemeSource).toContain('MAX_BOOTSTRAP_WORKSPACE_STATE_JSON_BYTES'); + expect(desktopLibSource).toContain('prepare_workspace_startup_bootstrap_snapshot'); + expect(desktopLibSource).toContain('tokio::task::block_in_place'); + expect(desktopLibSource).not.toContain('tauri::async_runtime::block_on(prepare_workspace_startup_bootstrap_snapshot'); + expect(desktopCommandsSource).toContain('initialize_workspace_startup_state_impl'); + expect(globalStateSource).toContain('consumeBootstrapWorkspaceStartupStateSnapshot'); + expect(globalStateSource).toContain('__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__'); + expect(globalStateSource).toContain( + 'delete globalThis.__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__' + ); + }); + + it('keeps startup resource timing as bounded E2E-only report data', () => { + const perfSpecSource = readSource( + '../../../../../tests/e2e/specs/performance/startup-session-perf.spec.ts' + ); + const resourceTimingSource = readSource( + '../../../../../tests/e2e/helpers/performance-resource-timing.ts' + ); + + expect(perfSpecSource).toContain('readStartupResourceTimingSummary'); + expect(perfSpecSource).toContain('resourceTiming'); + expect(resourceTimingSource).toContain('sanitizeResourceTimingName'); + expect(resourceTimingSource).toContain('MAX_RESOURCE_TIMING_ENTRIES'); + expect(resourceTimingSource).not.toContain('console.log'); + expect(resourceTimingSource).not.toContain('createLogger'); + }); + it('keeps built-in theme startup on the bootstrap path without pre-render config writes', () => { const mainSource = readSource('../../main.tsx'); const themeServiceSource = readSource('../../infrastructure/theme/core/ThemeService.ts'); diff --git a/src/web-ui/src/shared/types/global-state.test.ts b/src/web-ui/src/shared/types/global-state.test.ts new file mode 100644 index 0000000000..d607adaaa5 --- /dev/null +++ b/src/web-ui/src/shared/types/global-state.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { WorkspaceStartupStateSnapshot } from '@/infrastructure/api/service-api/GlobalAPI'; +import { createGlobalStateAPI, WorkspaceKind, WorkspaceType } from './global-state'; + +const globalApiMocks = vi.hoisted(() => ({ + initializeWorkspaceStartupState: vi.fn(), +})); + +vi.mock('@/infrastructure/api', () => ({ + globalAPI: globalApiMocks, + workspaceAPI: {}, +})); + +vi.mock('../utils/logger', () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +type BootstrapGlobals = typeof globalThis & { + __BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__?: unknown; +}; + +const bootstrapGlobals = globalThis as BootstrapGlobals; + +function createWorkspaceSnapshot(): WorkspaceStartupStateSnapshot { + const workspace = { + id: 'workspace-1', + name: 'Workspace 1', + rootPath: 'D:/workspace/project', + workspaceType: 'singleProject', + workspaceKind: 'normal', + languages: ['TypeScript'], + openedAt: '2026-06-18T00:00:00.000Z', + lastAccessed: '2026-06-18T00:00:00.000Z', + tags: [], + relatedPaths: [{ path: 'D:/workspace/project/docs', description: null }], + }; + + return { + cleanupRemovedCount: 1, + currentWorkspace: workspace, + recentWorkspaces: [workspace], + openedWorkspaces: [workspace], + legacyRemoteWorkspace: { + connectionId: 'conn-1', + connectionName: 'Remote', + remotePath: '/repo', + sshHost: 'devbox', + }, + }; +} + +describe('createGlobalStateAPI workspace startup bootstrap', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete bootstrapGlobals.__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__; + }); + + it('uses the injected startup workspace snapshot once without a startup IPC', async () => { + bootstrapGlobals.__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__ = createWorkspaceSnapshot(); + globalApiMocks.initializeWorkspaceStartupState.mockResolvedValue({ + cleanupRemovedCount: 0, + currentWorkspace: null, + recentWorkspaces: [], + openedWorkspaces: [], + legacyRemoteWorkspace: null, + }); + + const api = createGlobalStateAPI(); + const state = await api.initializeWorkspaceStartupState(); + + expect(globalApiMocks.initializeWorkspaceStartupState).not.toHaveBeenCalled(); + expect(state.cleanupRemovedCount).toBe(1); + expect(state.currentWorkspace?.workspaceType).toBe(WorkspaceType.SingleProject); + expect(state.currentWorkspace?.workspaceKind).toBe(WorkspaceKind.Normal); + expect(state.currentWorkspace?.sshHost).toBe('localhost'); + expect(state.currentWorkspace?.relatedPaths).toEqual([ + { path: 'D:/workspace/project/docs', description: undefined }, + ]); + expect(state.legacyRemoteWorkspace).toEqual({ + connectionId: 'conn-1', + connectionName: 'Remote', + remotePath: '/repo', + sshHost: 'devbox', + }); + expect( + Object.prototype.hasOwnProperty.call( + bootstrapGlobals, + '__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__' + ) + ).toBe(false); + + await api.initializeWorkspaceStartupState(); + expect(globalApiMocks.initializeWorkspaceStartupState).toHaveBeenCalledTimes(1); + }); + + it('falls back to the startup command when the bootstrap snapshot is invalid', async () => { + bootstrapGlobals.__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__ = { recentWorkspaces: [] }; + globalApiMocks.initializeWorkspaceStartupState.mockResolvedValue(createWorkspaceSnapshot()); + + const api = createGlobalStateAPI(); + const state = await api.initializeWorkspaceStartupState(); + + expect(globalApiMocks.initializeWorkspaceStartupState).toHaveBeenCalledTimes(1); + expect(state.currentWorkspace?.id).toBe('workspace-1'); + expect( + Object.prototype.hasOwnProperty.call( + bootstrapGlobals, + '__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__' + ) + ).toBe(false); + }); +}); diff --git a/src/web-ui/src/shared/types/global-state.ts b/src/web-ui/src/shared/types/global-state.ts index 3514610a34..8beac280e3 100644 --- a/src/web-ui/src/shared/types/global-state.ts +++ b/src/web-ui/src/shared/types/global-state.ts @@ -14,6 +14,13 @@ import { createLogger } from '../utils/logger'; const logger = createLogger('GlobalStateAPI'); +declare global { + // Native startup may inject this once to avoid a first-window IPC waterfall. + var __BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__: + | APIWorkspaceStartupStateSnapshot + | undefined; +} + export enum AppStatus { Initializing = 'initializing', @@ -402,11 +409,72 @@ function mapWorkspaceStartupStateSnapshot( }; } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isWorkspaceStartupStateSnapshot( + value: unknown +): value is APIWorkspaceStartupStateSnapshot { + if (!isRecord(value)) { + return false; + } + + return ( + typeof value.cleanupRemovedCount === 'number' && + (value.currentWorkspace === null || isRecord(value.currentWorkspace)) && + Array.isArray(value.recentWorkspaces) && + Array.isArray(value.openedWorkspaces) && + ( + value.legacyRemoteWorkspace === undefined || + value.legacyRemoteWorkspace === null || + isRecord(value.legacyRemoteWorkspace) + ) + ); +} + +function consumeBootstrapWorkspaceStartupStateSnapshot(): + | APIWorkspaceStartupStateSnapshot + | undefined { + if ( + !Object.prototype.hasOwnProperty.call( + globalThis, + '__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__' + ) + ) { + return undefined; + } + + const snapshot = globalThis.__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__; + delete globalThis.__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__; + + if (!isWorkspaceStartupStateSnapshot(snapshot)) { + logger.warn('Ignored invalid bootstrap workspace startup state snapshot'); + return undefined; + } + + return snapshot; +} + export function createGlobalStateAPI(): GlobalStateAPI { return { async initializeWorkspaceStartupState(): Promise { + const bootstrapSnapshot = consumeBootstrapWorkspaceStartupStateSnapshot(); + if (bootstrapSnapshot) { + try { + const mappedSnapshot = mapWorkspaceStartupStateSnapshot(bootstrapSnapshot); + logger.debug( + 'initializeWorkspaceStartupState returned from bootstrap', + summarizeWorkspacesForLog(mappedSnapshot.recentWorkspaces) + ); + return mappedSnapshot; + } catch (error) { + logger.warn('Failed to map bootstrap workspace startup state snapshot', { error }); + } + } + const snapshot = await globalAPI.initializeWorkspaceStartupState(); const mappedSnapshot = mapWorkspaceStartupStateSnapshot(snapshot); logger.debug( diff --git a/tests/e2e/helpers/performance-resource-timing.ts b/tests/e2e/helpers/performance-resource-timing.ts new file mode 100644 index 0000000000..9c436340d1 --- /dev/null +++ b/tests/e2e/helpers/performance-resource-timing.ts @@ -0,0 +1,133 @@ +import { browser } from '@wdio/globals'; + +export const MAX_RESOURCE_TIMING_ENTRIES = 40; + +type BrowserResourceTimingEntry = { + name: string; + initiatorType: string; + startTime: number; + duration: number; + transferSize?: number; + encodedBodySize?: number; + decodedBodySize?: number; + renderBlockingStatus?: string; +}; + +export type StartupResourceTimingEntry = { + name: string; + initiatorType: string; + startTimeMs: number; + durationMs: number; + transferSize?: number; + encodedBodySize?: number; + decodedBodySize?: number; + renderBlockingStatus?: string; +}; + +export type StartupResourceTimingSummary = { + totalCount: number; + sampledCount: number; + cutoffMs?: number; + byInitiatorType: Array<{ + initiatorType: string; + count: number; + totalDurationMs: number; + maxDurationMs: number; + totalDecodedBodySize: number; + }>; + topDuration: StartupResourceTimingEntry[]; + topDecodedBodySize: StartupResourceTimingEntry[]; +}; + +function round(value: number): number { + return Math.round(value * 10) / 10; +} + +export function sanitizeResourceTimingName(name: string): string { + try { + const url = new URL(name); + const path = url.pathname || url.hostname || name; + return path.slice(0, 180); + } catch { + return name.replace(/\\/g, '/').split('/').slice(-2).join('/').slice(0, 180); + } +} + +function normalizeEntry(entry: BrowserResourceTimingEntry): StartupResourceTimingEntry { + return { + name: sanitizeResourceTimingName(entry.name), + initiatorType: entry.initiatorType || 'unknown', + startTimeMs: round(entry.startTime), + durationMs: round(entry.duration), + transferSize: typeof entry.transferSize === 'number' ? entry.transferSize : undefined, + encodedBodySize: typeof entry.encodedBodySize === 'number' ? entry.encodedBodySize : undefined, + decodedBodySize: typeof entry.decodedBodySize === 'number' ? entry.decodedBodySize : undefined, + renderBlockingStatus: + typeof entry.renderBlockingStatus === 'string' ? entry.renderBlockingStatus : undefined, + }; +} + +export function summarizeStartupResourceTiming( + entries: BrowserResourceTimingEntry[], + cutoffMs?: number, +): StartupResourceTimingSummary { + const filtered = entries + .filter(entry => Number.isFinite(entry.startTime) && Number.isFinite(entry.duration)) + .filter(entry => cutoffMs === undefined || entry.startTime <= cutoffMs); + const normalized = filtered.map(normalizeEntry); + const byInitiator = new Map(); + + for (const entry of normalized) { + const existing = byInitiator.get(entry.initiatorType) ?? { + initiatorType: entry.initiatorType, + count: 0, + totalDurationMs: 0, + maxDurationMs: 0, + totalDecodedBodySize: 0, + }; + existing.count += 1; + existing.totalDurationMs = round(existing.totalDurationMs + entry.durationMs); + existing.maxDurationMs = Math.max(existing.maxDurationMs, entry.durationMs); + existing.totalDecodedBodySize += entry.decodedBodySize ?? 0; + byInitiator.set(entry.initiatorType, existing); + } + + return { + totalCount: filtered.length, + sampledCount: Math.min(normalized.length, MAX_RESOURCE_TIMING_ENTRIES), + cutoffMs: cutoffMs === undefined ? undefined : round(cutoffMs), + byInitiatorType: Array.from(byInitiator.values()).sort( + (left, right) => right.totalDurationMs - left.totalDurationMs + ), + topDuration: [...normalized] + .sort((left, right) => right.durationMs - left.durationMs) + .slice(0, MAX_RESOURCE_TIMING_ENTRIES), + topDecodedBodySize: [...normalized] + .sort((left, right) => (right.decodedBodySize ?? 0) - (left.decodedBodySize ?? 0)) + .slice(0, MAX_RESOURCE_TIMING_ENTRIES), + }; +} + +export async function readStartupResourceTimingSummary( + cutoffMs?: number, +): Promise { + const entries = await browser.execute(() => + performance.getEntriesByType('resource').map(entry => { + const resource = entry as PerformanceResourceTiming; + return { + name: resource.name, + initiatorType: resource.initiatorType, + startTime: resource.startTime, + duration: resource.duration, + transferSize: resource.transferSize, + encodedBodySize: resource.encodedBodySize, + decodedBodySize: resource.decodedBodySize, + renderBlockingStatus: (resource as PerformanceResourceTiming & { + renderBlockingStatus?: string; + }).renderBlockingStatus, + }; + }) + ); + + return summarizeStartupResourceTiming(entries, cutoffMs); +} diff --git a/tests/e2e/specs/performance/startup-session-perf.spec.ts b/tests/e2e/specs/performance/startup-session-perf.spec.ts index 1f6393964f..be7009492a 100644 --- a/tests/e2e/specs/performance/startup-session-perf.spec.ts +++ b/tests/e2e/specs/performance/startup-session-perf.spec.ts @@ -16,6 +16,7 @@ import { waitForTracePhaseCount, type StartupTraceSnapshot, } from '../../helpers/performance-trace'; +import { readStartupResourceTimingSummary } from '../../helpers/performance-resource-timing'; import { StartupPage } from '../../page-objects/StartupPage'; import { ensureWorkspaceOpen } from '../../helpers/workspace-utils'; import { openWorkspace } from '../../helpers/workspace-helper'; @@ -4582,6 +4583,7 @@ describe('Performance telemetry', () => { const startup = summarizeStartup(snapshot); const breakdown = summarizeStartupBreakdown(snapshot); const apiSegments = summarizeApiCommandSegments(snapshot); + const resourceTiming = await readStartupResourceTimingSummary(startup.interactiveShellReadyMs); const maxInteractiveMs = numericEnv('BITFUN_E2E_PERF_MAX_INTERACTIVE_MS'); console.log('[Perf] startup', JSON.stringify({ @@ -4599,6 +4601,7 @@ describe('Performance telemetry', () => { traceId: snapshot.traceId, startup, breakdown, + resourceTiming, apiSegments, api: snapshot.api, native: snapshot.native,