@@ -3,6 +3,7 @@ const os = require('node:os')
33const path = require ( 'node:path' )
44const https = require ( 'node:https' )
55const { spawn, spawnSync } = require ( 'node:child_process' )
6+ const crypto = require ( 'node:crypto' )
67const { app, BrowserWindow, ipcMain, shell, dialog, Menu, Tray, nativeImage, safeStorage } = require ( 'electron' )
78
89/**
@@ -140,6 +141,10 @@ const mainI18n = {
140141 'auth.retrying' : '重试中' ,
141142 'app.alreadyRunning' : '程序已在运行中' ,
142143 'app.alreadyRunningDetail' : '另一个 Copilot Proxy GUI 实例已在运行,将切换到该窗口。' ,
144+ 'update.check' : '检查更新' ,
145+ 'update.newVersion' : '🔔 有新版本 v{v}' ,
146+ 'update.latest' : '已是最新版本' ,
147+ 'update.failed' : '更新检查失败' ,
143148 } ,
144149 en : {
145150 'tray.stopped' : 'Stopped' ,
@@ -175,6 +180,10 @@ const mainI18n = {
175180 'auth.retrying' : 'retrying' ,
176181 'app.alreadyRunning' : 'Application Already Running' ,
177182 'app.alreadyRunningDetail' : 'Another instance of Copilot Proxy GUI is already running. Switching to that window.' ,
183+ 'update.check' : 'Check for Updates' ,
184+ 'update.newVersion' : '🔔 Update Available v{v}' ,
185+ 'update.latest' : 'Already up to date' ,
186+ 'update.failed' : 'Update check failed' ,
178187 } ,
179188}
180189function mt ( key ) { return mainI18n [ currentLang ] ?. [ key ] ?? mainI18n . zh [ key ] ?? key }
@@ -189,6 +198,19 @@ let serviceLogs = []
189198let lastServicePayload = null // remembered for tray "Start" action
190199let lastModelName = '' // model name shown in tray tooltip
191200
201+ // ─── Update state ────────────────────────────────────────────────────
202+ let updateState = {
203+ checking : false ,
204+ available : false ,
205+ downloading : false ,
206+ progress : 0 ,
207+ latestVersion : null ,
208+ releaseUrl : null ,
209+ manifestData : null ,
210+ lightweightPossible : false ,
211+ error : null ,
212+ }
213+
192214// Token & config are stored under Electron's userData directory:
193215// Windows: %APPDATA%/copilot-proxy-gui/
194216// macOS: ~/Library/Application Support/copilot-proxy-gui/
@@ -516,6 +538,210 @@ function serviceStop() {
516538 }
517539}
518540
541+ // ─── Self-update ────────────────────────────────────────────────────
542+
543+ const REPO_OWNER = 'kylefu8'
544+ const REPO_NAME = 'copilot-proxy-gui'
545+ const UPDATE_CHECK_URL = `https://api.github.com/repos/${ REPO_OWNER } /${ REPO_NAME } /releases/latest`
546+
547+ function notifyUpdateState ( ) {
548+ if ( mainWin && ! mainWin . isDestroyed ( ) ) {
549+ mainWin . webContents . send ( 'copilot-proxy:update-state' , { ...updateState } )
550+ }
551+ updateTrayMenu ( )
552+ }
553+
554+ function compareVersions ( a , b ) {
555+ const pa = String ( a ) . replace ( / ^ v / , '' ) . replace ( / - .* $ / , '' ) . split ( '.' ) . map ( Number )
556+ const pb = String ( b ) . replace ( / ^ v / , '' ) . replace ( / - .* $ / , '' ) . split ( '.' ) . map ( Number )
557+ for ( let i = 0 ; i < 3 ; i ++ ) {
558+ const va = pa [ i ] || 0
559+ const vb = pb [ i ] || 0
560+ if ( va > vb ) return 1
561+ if ( va < vb ) return - 1
562+ }
563+ return 0
564+ }
565+
566+ /**
567+ * Download a file over HTTPS, following redirects (GitHub → S3).
568+ * Returns a Buffer. Calls onProgress(downloaded, total) during download.
569+ */
570+ function httpsDownload ( url , onProgress ) {
571+ return new Promise ( ( resolve , reject ) => {
572+ const doRequest = ( reqUrl , redirectCount = 0 ) => {
573+ if ( redirectCount > 5 ) return reject ( new Error ( 'Too many redirects' ) )
574+
575+ const u = new URL ( reqUrl )
576+ const proto = u . protocol === 'https:' ? https : require ( 'node:http' )
577+
578+ proto . get ( reqUrl , {
579+ headers : { 'user-agent' : `CopilotProxyGUI/${ require ( '../package.json' ) . version } ` } ,
580+ } , ( res ) => {
581+ if ( res . statusCode >= 300 && res . statusCode < 400 && res . headers . location ) {
582+ res . resume ( )
583+ const next = res . headers . location . startsWith ( 'http' )
584+ ? res . headers . location
585+ : new URL ( res . headers . location , reqUrl ) . href
586+ return doRequest ( next , redirectCount + 1 )
587+ }
588+
589+ if ( res . statusCode !== 200 ) {
590+ res . resume ( )
591+ return reject ( new Error ( `Download failed: HTTP ${ res . statusCode } ` ) )
592+ }
593+
594+ const totalSize = parseInt ( res . headers [ 'content-length' ] , 10 ) || 0
595+ const chunks = [ ]
596+ let downloaded = 0
597+
598+ res . on ( 'data' , ( chunk ) => {
599+ chunks . push ( chunk )
600+ downloaded += chunk . length
601+ if ( onProgress && totalSize ) onProgress ( downloaded , totalSize )
602+ } )
603+ res . on ( 'end' , ( ) => resolve ( Buffer . concat ( chunks ) ) )
604+ res . on ( 'error' , reject )
605+ } ) . on ( 'error' , reject )
606+ }
607+
608+ doRequest ( url )
609+ } )
610+ }
611+
612+ async function checkForUpdates ( ) {
613+ const currentVersion = require ( '../package.json' ) . version
614+
615+ updateState = { ...updateState , checking : true , error : null }
616+ notifyUpdateState ( )
617+
618+ try {
619+ const res = await httpsGet ( UPDATE_CHECK_URL , {
620+ 'user-agent' : `CopilotProxyGUI/${ currentVersion } ` ,
621+ } , 15000 )
622+
623+ if ( ! res . ok ) throw new Error ( `GitHub API error: HTTP ${ res . status } ` )
624+
625+ const release = res . json
626+ const latestVersion = ( release . tag_name || '' ) . replace ( / ^ v / , '' )
627+
628+ if ( compareVersions ( latestVersion , currentVersion ) <= 0 ) {
629+ updateState = { ...updateState , checking : false , available : false , latestVersion }
630+ notifyUpdateState ( )
631+ return { upToDate : true , currentVersion, latestVersion }
632+ }
633+
634+ // New version available — check for lightweight update manifest
635+ const manifestAsset = ( release . assets || [ ] ) . find ( a => a . name === 'update-manifest.json' )
636+ let manifestData = null
637+ let lightweightPossible = false
638+
639+ if ( manifestAsset ) {
640+ try {
641+ const mBuf = await httpsDownload ( manifestAsset . browser_download_url )
642+ manifestData = JSON . parse ( mBuf . toString ( 'utf8' ) )
643+ const electronVersion = process . versions . electron
644+ if ( ! manifestData . minElectronVersion || compareVersions ( electronVersion , manifestData . minElectronVersion ) >= 0 ) {
645+ lightweightPossible = true
646+ }
647+ } catch ( e ) {
648+ console . warn ( 'Failed to fetch update manifest:' , e )
649+ }
650+ }
651+
652+ updateState = {
653+ ...updateState ,
654+ checking : false ,
655+ available : true ,
656+ latestVersion,
657+ releaseUrl : release . html_url ,
658+ manifestData,
659+ lightweightPossible,
660+ }
661+ notifyUpdateState ( )
662+
663+ return { upToDate : false , currentVersion, latestVersion, releaseUrl : release . html_url , lightweightPossible }
664+ } catch ( e ) {
665+ updateState = { ...updateState , checking : false , error : e . message || String ( e ) }
666+ notifyUpdateState ( )
667+ return { error : true , message : e . message || String ( e ) }
668+ }
669+ }
670+
671+ async function applyLightweightUpdate ( ) {
672+ if ( ! updateState . available || ! updateState . lightweightPossible || ! updateState . manifestData ) {
673+ return { error : true , message : 'No lightweight update available' }
674+ }
675+ if ( updateState . downloading ) return { error : true , message : 'Already downloading' }
676+
677+ const manifest = updateState . manifestData
678+ const expectedAssets = manifest . assets || { }
679+
680+ // Fetch release to get asset download URLs
681+ const res = await httpsGet ( UPDATE_CHECK_URL , {
682+ 'user-agent' : `CopilotProxyGUI/${ require ( '../package.json' ) . version } ` ,
683+ } , 15000 )
684+ if ( ! res . ok ) throw new Error ( 'Failed to fetch release info' )
685+
686+ const releaseAssets = res . json . assets || [ ]
687+
688+ updateState = { ...updateState , downloading : true , progress : 0 , error : null }
689+ notifyUpdateState ( )
690+
691+ try {
692+ const resourcesPath = process . resourcesPath
693+ let filesDone = 0
694+ const filesToUpdate = [ ]
695+
696+ // Determine which assets to download
697+ const bundleAsset = releaseAssets . find ( a => a . name === 'copilot-proxy-bundle.mjs' )
698+ const asarAsset = releaseAssets . find ( a => a . name === 'app.asar' )
699+ if ( bundleAsset ) filesToUpdate . push ( { asset : bundleAsset , key : 'copilot-proxy-bundle.mjs' } )
700+ if ( asarAsset ) filesToUpdate . push ( { asset : asarAsset , key : 'app.asar' } )
701+
702+ if ( filesToUpdate . length === 0 ) {
703+ throw new Error ( 'No downloadable assets found in release' )
704+ }
705+
706+ for ( const { asset, key } of filesToUpdate ) {
707+ const progressBase = ( filesDone / filesToUpdate . length ) * 100
708+ const progressRange = 100 / filesToUpdate . length
709+
710+ const data = await httpsDownload ( asset . browser_download_url , ( downloaded , total ) => {
711+ updateState . progress = Math . round ( progressBase + ( downloaded / total ) * progressRange )
712+ notifyUpdateState ( )
713+ } )
714+
715+ // Verify SHA256 if available
716+ if ( expectedAssets [ key ] ?. sha256 ) {
717+ const hash = crypto . createHash ( 'sha256' ) . update ( data ) . digest ( 'hex' )
718+ if ( hash !== expectedAssets [ key ] . sha256 ) {
719+ throw new Error ( `SHA256 mismatch for ${ key } ` )
720+ }
721+ }
722+
723+ const dest = path . join ( resourcesPath , key )
724+ fs . writeFileSync ( dest , data )
725+ filesDone ++
726+ }
727+
728+ updateState = { ...updateState , downloading : false , progress : 100 }
729+ notifyUpdateState ( )
730+
731+ return { ok : true , version : manifest . version , needRestart : ! ! asarAsset }
732+ } catch ( e ) {
733+ updateState = { ...updateState , downloading : false , error : e . message || String ( e ) }
734+ notifyUpdateState ( )
735+ return { error : true , message : e . message || String ( e ) }
736+ }
737+ }
738+
739+ function restartApp ( ) {
740+ app . relaunch ( )
741+ isQuitting = true
742+ app . quit ( )
743+ }
744+
519745// ─── Launch Claude Code ─────────────────────────────────────────────
520746
521747async function launchClaudeCode ( payload ) {
@@ -1046,6 +1272,27 @@ function updateTrayMenu() {
10461272 } )
10471273 }
10481274
1275+ // Update check item
1276+ if ( updateState . available && updateState . latestVersion ) {
1277+ menuItems . push (
1278+ { type : 'separator' } ,
1279+ {
1280+ label : mt ( 'update.newVersion' ) . replace ( '{v}' , updateState . latestVersion ) ,
1281+ click : ( ) => {
1282+ if ( updateState . releaseUrl ) shell . openExternal ( updateState . releaseUrl )
1283+ } ,
1284+ } ,
1285+ )
1286+ } else {
1287+ menuItems . push (
1288+ { type : 'separator' } ,
1289+ {
1290+ label : mt ( 'update.check' ) ,
1291+ click : ( ) => checkForUpdates ( ) ,
1292+ } ,
1293+ )
1294+ }
1295+
10491296 menuItems . push (
10501297 { type : 'separator' } ,
10511298 {
@@ -1282,6 +1529,15 @@ ipcMain.handle('copilot-proxy:invoke', async (_event, request) => {
12821529 return clearClaudeEnv ( )
12831530 case 'check_claude_env' :
12841531 return checkClaudeEnv ( )
1532+ case 'check_update' :
1533+ return checkForUpdates ( )
1534+ case 'apply_update' :
1535+ return applyLightweightUpdate ( )
1536+ case 'restart_app' :
1537+ setTimeout ( ( ) => restartApp ( ) , 200 )
1538+ return { ok : true }
1539+ case 'get_update_state' :
1540+ return { ...updateState }
12851541 case 'check_claude_installed' : {
12861542 // Check if 'claude' CLI is available
12871543 // Strategy: use an interactive login shell to resolve the real PATH
@@ -1381,6 +1637,11 @@ if (gotLock) app.whenReady().then(() => {
13811637 createTray ( )
13821638 createWindow ( )
13831639
1640+ // Auto-check for updates 10s after launch
1641+ setTimeout ( ( ) => {
1642+ checkForUpdates ( ) . catch ( e => console . warn ( 'Auto update check failed:' , e ) )
1643+ } , 10000 )
1644+
13841645 app . on ( 'activate' , ( ) => {
13851646 if ( mainWin ) {
13861647 mainWin . show ( )
0 commit comments