Skip to content

Commit 4f1a7f5

Browse files
committed
feat: implement client installation progress tracking with detailed event emissions and UI integration
1 parent 3e6c125 commit 4f1a7f5

27 files changed

Lines changed: 1204 additions & 79 deletions

src/main/__tests__/preload-bridge.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ const EXPECTED_KEYS = [
7474
'windowMaximize',
7575
'windowClose',
7676
'onConfigChanged',
77+
'onClientInstallProgress',
7778
'onActivateProfileFromTray',
7879
'onUpdateAvailable',
7980
'onUpdateDownloaded',
@@ -122,6 +123,8 @@ describe('preload bridge composition', () => {
122123
await api.backupsList('cursor')
123124
await api.filesReveal('C:\\tmp\\file.txt')
124125
await api.registryPrepareInstall('smithery', '@anthropic/github-mcp')
126+
const offInstallProgress = api.onClientInstallProgress(() => {})
127+
offInstallProgress()
125128

126129
expect(invoke).toHaveBeenCalledWith('clients:detect-all')
127130
expect(invoke).toHaveBeenCalledWith('clients:install', 'cursor')
@@ -155,5 +158,10 @@ describe('preload bridge composition', () => {
155158
'smithery',
156159
'@anthropic/github-mcp',
157160
)
161+
const installProgressListener = (
162+
on.mock.calls as [string, (...args: unknown[]) => void][]
163+
).find((call) => call[0] === 'clients:install-progress')?.[1]
164+
expect(installProgressListener).toBeTypeOf('function')
165+
expect(removeListener).toHaveBeenCalledWith('clients:install-progress', installProgressListener)
158166
})
159167
})

src/main/clients/__tests__/client-install.service.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import { EventEmitter } from 'events'
99
import { beforeEach, describe, expect, it, vi } from 'vitest'
10+
import type { ClientInstallProgressPayload } from '@shared/channels'
1011

1112
type Platform = typeof process.platform
1213

@@ -100,6 +101,34 @@ describe('ClientInstallService', () => {
100101
expect(spawnMock).toHaveBeenNthCalledWith(2, 'choco', expect.any(Array), expect.any(Object))
101102
})
102103

104+
it('emits monotonic progress events across fallback attempts', async () => {
105+
spawnPlanQueue.push(
106+
{ command: 'winget', exitCode: 1, stderr: 'failed' },
107+
{ command: 'choco', exitCode: 0, stdout: 'ok' },
108+
)
109+
const service = new ClientInstallService()
110+
const progressEvents: ClientInstallProgressPayload[] = []
111+
112+
await service.install('cursor', (payload) => {
113+
progressEvents.push(payload)
114+
})
115+
116+
expect(progressEvents.map((event) => event.phase)).toEqual([
117+
'start',
118+
'manager_check',
119+
'manager_running',
120+
'manager_failed',
121+
'manager_check',
122+
'manager_running',
123+
'manager_succeeded',
124+
'completed',
125+
])
126+
expect(progressEvents.map((event) => event.progress)).toEqual(
127+
[...progressEvents.map((event) => event.progress)].sort((a, b) => a - b),
128+
)
129+
expect(progressEvents.at(-1)?.progress).toBe(100)
130+
})
131+
103132
it('returns no_available_manager when none of the managers are present', async () => {
104133
managerAvailability.winget = false
105134
managerAvailability.choco = false
@@ -115,6 +144,26 @@ describe('ClientInstallService', () => {
115144
expect(spawnMock).not.toHaveBeenCalled()
116145
})
117146

147+
it('emits manager_skipped progress events when managers are unavailable', async () => {
148+
managerAvailability.winget = false
149+
managerAvailability.choco = false
150+
managerAvailability.npm = false
151+
const service = new ClientInstallService()
152+
const progressEvents: ClientInstallProgressPayload[] = []
153+
154+
const result = await service.install('codex-cli', (payload) => {
155+
progressEvents.push(payload)
156+
})
157+
158+
expect(result.failureReason).toBe('no_available_manager')
159+
expect(progressEvents.filter((event) => event.phase === 'manager_skipped')).toHaveLength(3)
160+
expect(progressEvents.at(-1)).toMatchObject({
161+
phase: 'completed',
162+
failureReason: 'no_available_manager',
163+
progress: 100,
164+
})
165+
})
166+
118167
it('returns requires_elevation without trying to elevate automatically', async () => {
119168
spawnPlanQueue.push({
120169
command: 'winget',
@@ -136,13 +185,18 @@ describe('ClientInstallService', () => {
136185

137186
it('returns manual_install_required for manual-only clients', async () => {
138187
const service = new ClientInstallService()
188+
const progressEvents: ClientInstallProgressPayload[] = []
139189

140-
const result = await service.install('jetbrains')
190+
const result = await service.install('jetbrains', (payload) => {
191+
progressEvents.push(payload)
192+
})
141193

142194
expect(result.success).toBe(false)
143195
expect(result.failureReason).toBe('manual_install_required')
144196
expect(result.docsUrl).toContain('jetbrains.com')
145197
expect(result.attempts).toHaveLength(0)
198+
expect(progressEvents.map((event) => event.phase)).toEqual(['start', 'completed'])
199+
expect(progressEvents.at(-1)?.failureReason).toBe('manual_install_required')
146200
expect(spawnMock).not.toHaveBeenCalled()
147201
})
148202

src/main/clients/client-install.service.ts

Lines changed: 152 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import spawn from 'cross-spawn'
1010
import log from 'electron-log'
11+
import type { ClientInstallProgressPayload } from '@shared/channels'
1112
import type {
1213
ClientId,
1314
ClientInstallAttempt,
@@ -38,6 +39,8 @@ interface InstallExecutionResult {
3839
readonly error?: string
3940
}
4041

42+
type InstallProgressReporter = (payload: ClientInstallProgressPayload) => void
43+
4144
const INSTALL_DEFINITIONS: Readonly<Record<ClientId, ClientInstallDefinition>> = {
4245
cursor: {
4346
docsUrl: 'https://www.cursor.com/downloads',
@@ -335,6 +338,46 @@ const requiresElevation = (text: string): boolean =>
335338
const managerAvailable = (manager: AutomaticInstallManager): boolean =>
336339
hasWindowsCommandOnPath(MANAGER_COMMANDS[manager])
337340

341+
const clampProgress = (progress: number): number => Math.min(100, Math.max(0, Math.round(progress)))
342+
343+
const progressForAttempt = (
344+
attemptIndex: number,
345+
attemptCount: number,
346+
segment: 'check' | 'running' | 'result',
347+
): number => {
348+
if (attemptCount <= 0) return 95
349+
const start = 5
350+
const total = 90
351+
const span = total / attemptCount
352+
const offset = segment === 'check' ? 0 : segment === 'running' ? 0.5 : 1
353+
return start + (attemptIndex - 1 + offset) * span
354+
}
355+
356+
const createProgressEmitter = (
357+
clientId: ClientId,
358+
reportProgress?: InstallProgressReporter,
359+
): ((payload: Omit<ClientInstallProgressPayload, 'clientId'>) => void) => {
360+
let lastProgress = 0
361+
362+
return (payload) => {
363+
if (!reportProgress) return
364+
365+
const nextProgress = clampProgress(payload.progress)
366+
const monotonicProgress = Math.max(lastProgress, nextProgress)
367+
lastProgress = monotonicProgress
368+
369+
try {
370+
reportProgress({
371+
...payload,
372+
clientId,
373+
progress: monotonicProgress,
374+
})
375+
} catch (err) {
376+
log.warn(`[clients:install] progress reporter failed for ${clientId}: ${String(err)}`)
377+
}
378+
}
379+
}
380+
338381
const execute = async (command: string, args: readonly string[]): Promise<InstallExecutionResult> =>
339382
new Promise((resolve) => {
340383
const child = spawn(command, [...args], {
@@ -388,41 +431,88 @@ const failureResult = (
388431
})
389432

390433
export class ClientInstallService {
391-
async install(clientId: ClientId): Promise<ClientInstallResult> {
434+
async install(
435+
clientId: ClientId,
436+
reportProgress?: InstallProgressReporter,
437+
): Promise<ClientInstallResult> {
438+
const expectedAttemptCount = INSTALL_DEFINITIONS[clientId]?.attempts.length ?? 0
439+
const emitProgress = createProgressEmitter(clientId, reportProgress)
440+
emitProgress({
441+
phase: 'start',
442+
progress: 1,
443+
attemptIndex: 0,
444+
attemptCount: expectedAttemptCount,
445+
})
446+
392447
if (process.platform !== 'win32') {
393-
return failureResult(
448+
const result = failureResult(
394449
clientId,
395450
[],
396451
'unsupported_platform',
397452
'In-app installs are currently supported on Windows only.',
398453
)
454+
emitProgress({
455+
phase: 'completed',
456+
progress: 100,
457+
attemptIndex: 0,
458+
attemptCount: expectedAttemptCount,
459+
...(result.failureReason ? { failureReason: result.failureReason } : {}),
460+
})
461+
return result
399462
}
400463

401464
const definition = INSTALL_DEFINITIONS[clientId]
402465
if (!definition) {
403-
return failureResult(
466+
const result = failureResult(
404467
clientId,
405468
[],
406469
'unsupported_client',
407470
`No install definition is available for client: ${clientId}`,
408471
)
472+
emitProgress({
473+
phase: 'completed',
474+
progress: 100,
475+
attemptIndex: 0,
476+
attemptCount: 0,
477+
...(result.failureReason ? { failureReason: result.failureReason } : {}),
478+
})
479+
return result
409480
}
410481

411482
if (definition.manualOnly || definition.attempts.length === 0) {
412-
return failureResult(
483+
const result = failureResult(
413484
clientId,
414485
[],
415486
'manual_install_required',
416487
'Automatic install is not available for this client. Please install it manually.',
417488
definition.docsUrl,
418489
)
490+
emitProgress({
491+
phase: 'completed',
492+
progress: 100,
493+
attemptIndex: 0,
494+
attemptCount: definition.attempts.length,
495+
...(result.failureReason ? { failureReason: result.failureReason } : {}),
496+
})
497+
return result
419498
}
420499

421500
const attempts: ClientInstallAttempt[] = []
422501
let hadRunnableManager = false
423502
let sawElevationFailure = false
503+
const attemptCount = definition.attempts.length
504+
let attemptIndex = 0
424505

425506
for (const installAttempt of definition.attempts) {
507+
attemptIndex += 1
508+
emitProgress({
509+
phase: 'manager_check',
510+
progress: progressForAttempt(attemptIndex, attemptCount, 'check'),
511+
attemptIndex,
512+
attemptCount,
513+
manager: installAttempt.manager,
514+
})
515+
426516
if (!managerAvailable(installAttempt.manager)) {
427517
attempts.push({
428518
manager: installAttempt.manager,
@@ -432,10 +522,24 @@ export class ClientInstallService {
432522
skipped: true,
433523
error: `${installAttempt.manager} is not available on PATH`,
434524
})
525+
emitProgress({
526+
phase: 'manager_skipped',
527+
progress: progressForAttempt(attemptIndex, attemptCount, 'result'),
528+
attemptIndex,
529+
attemptCount,
530+
manager: installAttempt.manager,
531+
})
435532
continue
436533
}
437534

438535
hadRunnableManager = true
536+
emitProgress({
537+
phase: 'manager_running',
538+
progress: progressForAttempt(attemptIndex, attemptCount, 'running'),
539+
attemptIndex,
540+
attemptCount,
541+
manager: installAttempt.manager,
542+
})
439543
const runResult = await execute(installAttempt.command, installAttempt.args)
440544
const combinedOutput = `${runResult.stdout}\n${runResult.stderr}\n${runResult.error ?? ''}`
441545

@@ -456,45 +560,85 @@ export class ClientInstallService {
456560

457561
attempts.push(result)
458562

563+
emitProgress({
564+
phase: result.success ? 'manager_succeeded' : 'manager_failed',
565+
progress: progressForAttempt(attemptIndex, attemptCount, 'result'),
566+
attemptIndex,
567+
attemptCount,
568+
manager: installAttempt.manager,
569+
})
570+
459571
if (result.success) {
460572
log.info(`[clients:install] ${clientId} installed via ${installAttempt.manager}`)
461-
return {
573+
const successResult: ClientInstallResult = {
462574
clientId,
463575
success: true,
464576
attempts,
465577
installedWith: installAttempt.manager,
466578
docsUrl: definition.docsUrl,
467579
message: `Installed via ${installAttempt.manager}.`,
468580
}
581+
emitProgress({
582+
phase: 'completed',
583+
progress: 100,
584+
attemptIndex,
585+
attemptCount,
586+
manager: installAttempt.manager,
587+
})
588+
return successResult
469589
}
470590
}
471591

472592
if (!hadRunnableManager) {
473-
return failureResult(
593+
const result = failureResult(
474594
clientId,
475595
attempts,
476596
'no_available_manager',
477597
'No supported package manager is available (winget/choco/npm).',
478598
definition.docsUrl,
479599
)
600+
emitProgress({
601+
phase: 'completed',
602+
progress: 100,
603+
attemptIndex,
604+
attemptCount,
605+
...(result.failureReason ? { failureReason: result.failureReason } : {}),
606+
})
607+
return result
480608
}
481609

482610
if (sawElevationFailure) {
483-
return failureResult(
611+
const result = failureResult(
484612
clientId,
485613
attempts,
486614
'requires_elevation',
487615
'Install failed because elevated privileges are required. aidrelay does not auto-elevate.',
488616
definition.docsUrl,
489617
)
618+
emitProgress({
619+
phase: 'completed',
620+
progress: 100,
621+
attemptIndex,
622+
attemptCount,
623+
...(result.failureReason ? { failureReason: result.failureReason } : {}),
624+
})
625+
return result
490626
}
491627

492-
return failureResult(
628+
const result = failureResult(
493629
clientId,
494630
attempts,
495631
'command_failed',
496632
'Install failed for all attempted package managers.',
497633
definition.docsUrl,
498634
)
635+
emitProgress({
636+
phase: 'completed',
637+
progress: 100,
638+
attemptIndex,
639+
attemptCount,
640+
...(result.failureReason ? { failureReason: result.failureReason } : {}),
641+
})
642+
return result
499643
}
500644
}

0 commit comments

Comments
 (0)