perf: optimize app startup and background lifecycles
This commit is contained in:
@@ -122,6 +122,20 @@ export async function presentAgentBrowser(input: {
|
||||
return requireSuccess(response).browser;
|
||||
}
|
||||
|
||||
export async function setAgentBrowserDiagnostics(input: {
|
||||
projectPath: string;
|
||||
enabled: boolean;
|
||||
}): Promise<AgentBrowserSnapshot> {
|
||||
const response = await hostApiFetch<BrowserEnvelope>(
|
||||
'/api/agent-browser/diagnostics',
|
||||
jsonBody({
|
||||
project_path: input.projectPath,
|
||||
enabled: input.enabled,
|
||||
}),
|
||||
);
|
||||
return requireSuccess(response).browser;
|
||||
}
|
||||
|
||||
export async function navigateAgentBrowser(input: {
|
||||
projectPath: string;
|
||||
action: AgentBrowserNavigateAction;
|
||||
|
||||
34
src/lib/ai-hardware-navigation.ts
Normal file
34
src/lib/ai-hardware-navigation.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { AiHardwareOverview } from '@/lib/ai-hardware';
|
||||
|
||||
export const AI_HARDWARE_NAVIGATION_EVENT = 'makelore:ai-hardware-navigation';
|
||||
export const AI_HARDWARE_OVERVIEW_EVENT = 'makelore:ai-hardware-overview';
|
||||
|
||||
export type AiHardwareNavigationDetail = {
|
||||
agentId?: string | null;
|
||||
openCreate?: boolean;
|
||||
};
|
||||
|
||||
export function readAiHardwareAgentId(search?: string): string | null {
|
||||
if (typeof window === 'undefined' && search === undefined) return null;
|
||||
const value = new URLSearchParams(search ?? window.location.search).get('agent')?.trim();
|
||||
return value || null;
|
||||
}
|
||||
|
||||
export function syncAiHardwareAgentUrl(agentId: string | null): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const url = new URL(window.location.href);
|
||||
if (agentId) url.searchParams.set('agent', agentId);
|
||||
else url.searchParams.delete('agent');
|
||||
url.searchParams.delete('create');
|
||||
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`);
|
||||
}
|
||||
|
||||
export function announceAiHardwareNavigation(detail: AiHardwareNavigationDetail): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent<AiHardwareNavigationDetail>(AI_HARDWARE_NAVIGATION_EVENT, { detail }));
|
||||
}
|
||||
|
||||
export function announceAiHardwareOverview(overview: AiHardwareOverview): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent<AiHardwareOverview>(AI_HARDWARE_OVERVIEW_EVENT, { detail: overview }));
|
||||
}
|
||||
@@ -28,6 +28,23 @@ export async function ensureHostApiToken(): Promise<string> {
|
||||
return token;
|
||||
}
|
||||
|
||||
export type DesktopActivityModule = 'programming' | 'painting' | 'learning' | 'robot' | 'other';
|
||||
|
||||
export function reportDesktopActivity(input: {
|
||||
visible: boolean;
|
||||
module: DesktopActivityModule | null;
|
||||
}): Promise<unknown> {
|
||||
return invokeIpc('lifecycle:activity', input);
|
||||
}
|
||||
|
||||
export function setDesktopBackgroundLease(input: {
|
||||
id: string;
|
||||
kind: string;
|
||||
active: boolean;
|
||||
}): Promise<unknown> {
|
||||
return invokeIpc('lifecycle:lease', input);
|
||||
}
|
||||
|
||||
async function getHostApiBaseUrl(): Promise<string> {
|
||||
if (hostApiBaseResolved) return cachedHostApiBase;
|
||||
try {
|
||||
@@ -63,6 +80,7 @@ type HostApiProxyData = {
|
||||
ok?: boolean;
|
||||
json?: unknown;
|
||||
text?: string;
|
||||
transport?: 'dispatcher' | 'loopback';
|
||||
};
|
||||
|
||||
function headersToRecord(headers?: HeadersInit): Record<string, string> {
|
||||
@@ -72,6 +90,20 @@ function headersToRecord(headers?: HeadersInit): Record<string, string> {
|
||||
return { ...headers };
|
||||
}
|
||||
|
||||
function telemetryRoute(path: string): string {
|
||||
const pathname = path.split('?', 1)[0] || '/';
|
||||
return pathname
|
||||
.split('/')
|
||||
.map((segment, index) => {
|
||||
if (!segment) return '';
|
||||
// Keep diagnostics aggregate-only: IDs, slugs and arbitrary path
|
||||
// segments must never become local telemetry or console output.
|
||||
if (index <= 2 && /^[a-z0-9._-]{1,32}$/i.test(segment)) return segment;
|
||||
return ':id';
|
||||
})
|
||||
.join('/') || '/';
|
||||
}
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let message = `${response.status} ${response.statusText}`;
|
||||
@@ -133,9 +165,9 @@ function parseUnifiedProxyResponse<T>(
|
||||
}
|
||||
|
||||
trackUiEvent('hostapi.fetch', {
|
||||
path,
|
||||
route: telemetryRoute(path),
|
||||
method,
|
||||
source: 'ipc-proxy',
|
||||
source: data.transport === 'dispatcher' ? 'ipc-dispatcher' : 'ipc-proxy',
|
||||
durationMs: Date.now() - startedAt,
|
||||
status: data.status ?? 200,
|
||||
});
|
||||
@@ -165,7 +197,7 @@ function parseLegacyProxyResponse<T>(
|
||||
}
|
||||
|
||||
trackUiEvent('hostapi.fetch', {
|
||||
path,
|
||||
route: telemetryRoute(path),
|
||||
method,
|
||||
source: 'ipc-proxy-legacy',
|
||||
durationMs: Date.now() - startedAt,
|
||||
@@ -215,7 +247,7 @@ export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise
|
||||
const normalized = normalizeAppError(error, { source: 'ipc-proxy', path, method });
|
||||
const message = normalized.message;
|
||||
trackUiEvent('hostapi.fetch_error', {
|
||||
path,
|
||||
route: telemetryRoute(path),
|
||||
method,
|
||||
source: 'ipc-proxy',
|
||||
durationMs: Date.now() - startedAt,
|
||||
@@ -227,7 +259,7 @@ export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise
|
||||
}
|
||||
if (!allowLocalhostFallback()) {
|
||||
trackUiEvent('hostapi.fetch_error', {
|
||||
path,
|
||||
route: telemetryRoute(path),
|
||||
method,
|
||||
source: 'ipc-proxy',
|
||||
durationMs: Date.now() - startedAt,
|
||||
@@ -252,7 +284,7 @@ export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise
|
||||
},
|
||||
});
|
||||
trackUiEvent('hostapi.fetch', {
|
||||
path,
|
||||
route: telemetryRoute(path),
|
||||
method,
|
||||
source: 'browser-fallback',
|
||||
durationMs: Date.now() - startedAt,
|
||||
|
||||
@@ -11,6 +11,9 @@ const HOST_EVENT_TO_IPC_CHANNEL: Record<string, string> = {
|
||||
'agent-browser:show': 'agent-browser:show',
|
||||
'agent-browser:state': 'agent-browser:state',
|
||||
'auth:session-changed': 'auth:session-changed',
|
||||
'lifecycle:pause': 'lifecycle:pause',
|
||||
'lifecycle:sleep': 'lifecycle:sleep',
|
||||
'release-job:status': 'release-job:status',
|
||||
};
|
||||
|
||||
function getEventSource(): EventSource {
|
||||
|
||||
91
src/lib/performance-diagnostics.ts
Normal file
91
src/lib/performance-diagnostics.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
type LongTaskAggregate = {
|
||||
count: number;
|
||||
totalDurationMs: number;
|
||||
maxDurationMs: number;
|
||||
};
|
||||
|
||||
type MainPerformanceSnapshot = {
|
||||
capturedAt?: number;
|
||||
eventLoopDelayMs?: number;
|
||||
processes?: Array<unknown>;
|
||||
windows?: number;
|
||||
webContents?: number;
|
||||
gpu?: Record<string, string>;
|
||||
background?: {
|
||||
activity?: { visible?: boolean; module?: string | null };
|
||||
leaseCount?: number;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Installs development-only, aggregate renderer diagnostics. No URLs, page
|
||||
* content, prompts or file names are retained or sent to Main. Production
|
||||
* builds return a no-op cleanup so this cannot become a hidden polling task.
|
||||
*/
|
||||
export function installRendererPerformanceDiagnostics(): () => void {
|
||||
if (typeof window === 'undefined' || window.electron?.isDev !== true) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const longTasks: LongTaskAggregate = {
|
||||
count: 0,
|
||||
totalDurationMs: 0,
|
||||
maxDurationMs: 0,
|
||||
};
|
||||
let firstPaintMs: number | null = null;
|
||||
let disposed = false;
|
||||
let paintFrame = 0;
|
||||
|
||||
const observer = typeof PerformanceObserver === 'undefined'
|
||||
? null
|
||||
: new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
const duration = Math.round(entry.duration * 100) / 100;
|
||||
longTasks.count += 1;
|
||||
longTasks.totalDurationMs += duration;
|
||||
longTasks.maxDurationMs = Math.max(longTasks.maxDurationMs, duration);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
observer?.observe({ type: 'longtask', buffered: true } as PerformanceObserverInit);
|
||||
} catch {
|
||||
// Chromium versions without the Long Tasks entry type are supported.
|
||||
}
|
||||
|
||||
paintFrame = window.requestAnimationFrame(() => {
|
||||
firstPaintMs = Math.round(performance.now() * 100) / 100;
|
||||
});
|
||||
|
||||
const report = () => {
|
||||
if (disposed) return;
|
||||
const renderer = {
|
||||
firstPaintMs,
|
||||
longTasks: {
|
||||
count: longTasks.count,
|
||||
totalDurationMs: Math.round(longTasks.totalDurationMs * 100) / 100,
|
||||
maxDurationMs: Math.round(longTasks.maxDurationMs * 100) / 100,
|
||||
},
|
||||
};
|
||||
void invokeIpc<MainPerformanceSnapshot>('app:performance')
|
||||
.then((main) => {
|
||||
if (!disposed) {
|
||||
console.debug('[performance]', { renderer, main });
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
// A low-frequency sample is enough for the development panel and avoids
|
||||
// adding another active request loop to normal renderer operation.
|
||||
const timer = window.setInterval(report, 30_000);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.cancelAnimationFrame(paintFrame);
|
||||
window.clearInterval(timer);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,10 @@ export function trackUiEvent(event: string, payload: TelemetryPayload = {}): voi
|
||||
const normalizedPayload = {
|
||||
...payload,
|
||||
};
|
||||
const isDevelopment = typeof window !== 'undefined' && Boolean(window.electron?.isDev);
|
||||
const isHighFrequencyHostRequest = event === 'hostapi.fetch';
|
||||
const shouldRecordHistory = isDevelopment || !isHighFrequencyHostRequest;
|
||||
if (!shouldRecordHistory) return;
|
||||
const ts = new Date().toISOString();
|
||||
const entry: UiTelemetryEntry = {
|
||||
id: nextEntryId,
|
||||
@@ -51,7 +55,9 @@ export function trackUiEvent(event: string, payload: TelemetryPayload = {}): voi
|
||||
};
|
||||
|
||||
// Local-only telemetry for UX diagnostics.
|
||||
console.info(`[ui-metric] ${event} ${safeStringify(logPayload)}`);
|
||||
if (isDevelopment || event.endsWith('_error')) {
|
||||
console.info(`[ui-metric] ${event} ${safeStringify(logPayload)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getUiCounter(event: string): number {
|
||||
|
||||
@@ -158,6 +158,20 @@ export type WorksProjectSourcePublishResult = {
|
||||
bindingWarning?: WorksSubmissionBindingWarning;
|
||||
};
|
||||
|
||||
export type ReleaseJobStatus = {
|
||||
jobId: string;
|
||||
projectId: string;
|
||||
state: 'queued' | 'running' | 'complete' | 'failed' | 'cancelled';
|
||||
phase: 'queued' | 'packaging' | 'installing' | 'building' | 'archiving' | 'complete';
|
||||
percent: number;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
result?: {
|
||||
package: WorksStaticPackageSummary;
|
||||
contract: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type WorksSpeechTranscription = {
|
||||
text: string;
|
||||
model: string;
|
||||
@@ -464,6 +478,38 @@ export async function publishWorksProjectSource(
|
||||
};
|
||||
}
|
||||
|
||||
export async function startWorksReleaseJob(projectId: string): Promise<ReleaseJobStatus> {
|
||||
const response = await hostApiFetch<{ success: boolean; job?: ReleaseJobStatus; error?: string; code?: string }>(
|
||||
'/api/works/projects/release-jobs',
|
||||
{ method: 'POST', body: JSON.stringify({ projectId }) },
|
||||
);
|
||||
if (!response.success || !response.job) {
|
||||
throw new WorksSquareApiError(response.error || 'Failed to start release build', undefined, response.code);
|
||||
}
|
||||
return response.job;
|
||||
}
|
||||
|
||||
export async function getWorksReleaseJob(jobId: string): Promise<ReleaseJobStatus> {
|
||||
const response = await hostApiFetch<{ success: boolean; job?: ReleaseJobStatus; error?: string; code?: string }>(
|
||||
`/api/works/projects/release-jobs/${encodeURIComponent(jobId)}`,
|
||||
);
|
||||
if (!response.success || !response.job) {
|
||||
throw new WorksSquareApiError(response.error || 'Failed to read release build status', undefined, response.code);
|
||||
}
|
||||
return response.job;
|
||||
}
|
||||
|
||||
export async function cancelWorksReleaseJob(jobId: string): Promise<ReleaseJobStatus> {
|
||||
const response = await hostApiFetch<{ success: boolean; job?: ReleaseJobStatus; error?: string; code?: string }>(
|
||||
`/api/works/projects/release-jobs/${encodeURIComponent(jobId)}/cancel`,
|
||||
{ method: 'POST', body: JSON.stringify({}) },
|
||||
);
|
||||
if (!response.success || !response.job) {
|
||||
throw new WorksSquareApiError(response.error || 'Failed to cancel release build', undefined, response.code);
|
||||
}
|
||||
return response.job;
|
||||
}
|
||||
|
||||
export async function fetchWorksProjectVersions(
|
||||
accessToken: string,
|
||||
appId: string,
|
||||
|
||||
Reference in New Issue
Block a user