88
99import spawn from 'cross-spawn'
1010import log from 'electron-log'
11+ import type { ClientInstallProgressPayload } from '@shared/channels'
1112import type {
1213 ClientId ,
1314 ClientInstallAttempt ,
@@ -38,6 +39,8 @@ interface InstallExecutionResult {
3839 readonly error ?: string
3940}
4041
42+ type InstallProgressReporter = ( payload : ClientInstallProgressPayload ) => void
43+
4144const INSTALL_DEFINITIONS : Readonly < Record < ClientId , ClientInstallDefinition > > = {
4245 cursor : {
4346 docsUrl : 'https://www.cursor.com/downloads' ,
@@ -335,6 +338,46 @@ const requiresElevation = (text: string): boolean =>
335338const 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+
338381const 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
390433export 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