Skip to content

Commit 46090a2

Browse files
committed
feat: add update detection and one-click lightweight update
1 parent 2ff669d commit 46090a2

7 files changed

Lines changed: 641 additions & 5 deletions

File tree

.github/workflows/release-gui.yml

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,41 @@ jobs:
5757
- name: Package with electron-builder
5858
run: npx electron-builder --win --config electron-builder.yml
5959

60+
- name: Generate update manifest
61+
run: |
62+
node -e "
63+
const fs = require('fs');
64+
const path = require('path');
65+
const crypto = require('crypto');
66+
const sha256 = f => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');
67+
const asarPath = path.join('release', 'win-unpacked', 'resources', 'app.asar');
68+
const bundlePath = path.join('build', 'copilot-proxy-bundle.mjs');
69+
if (!fs.existsSync(asarPath) || !fs.existsSync(bundlePath)) { console.error('Missing assets'); process.exit(1); }
70+
const pkg = require('./package.json');
71+
const ePkg = require('./node_modules/electron/package.json');
72+
const manifest = {
73+
version: pkg.version,
74+
minElectronVersion: ePkg.version.split('.')[0] + '.0.0',
75+
assets: {
76+
'app.asar': { size: fs.statSync(asarPath).size, sha256: sha256(asarPath) },
77+
'copilot-proxy-bundle.mjs': { size: fs.statSync(bundlePath).size, sha256: sha256(bundlePath) },
78+
},
79+
};
80+
fs.writeFileSync('release/update-manifest.json', JSON.stringify(manifest, null, 2));
81+
fs.copyFileSync(asarPath, 'release/app.asar');
82+
fs.copyFileSync(bundlePath, 'release/copilot-proxy-bundle.mjs');
83+
console.log(JSON.stringify(manifest, null, 2));
84+
"
85+
6086
- name: Upload to GitHub Release
6187
if: startsWith(github.ref, 'refs/tags/')
6288
uses: softprops/action-gh-release@v2
6389
with:
64-
files: release/*.exe
90+
files: |
91+
release/*.exe
92+
release/update-manifest.json
93+
release/app.asar
94+
release/copilot-proxy-bundle.mjs
6595
env:
6696
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
6797

electron/main.cjs

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const os = require('node:os')
33
const path = require('node:path')
44
const https = require('node:https')
55
const { spawn, spawnSync } = require('node:child_process')
6+
const crypto = require('node:crypto')
67
const { 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
}
180189
function mt(key) { return mainI18n[currentLang]?.[key] ?? mainI18n.zh[key] ?? key }
@@ -189,6 +198,19 @@ let serviceLogs = []
189198
let lastServicePayload = null // remembered for tray "Start" action
190199
let 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

521747
async 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()

electron/preload.cjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,9 @@ contextBridge.exposeInMainWorld('copilotProxyDesktop', {
3232
ipcRenderer.on('copilot-proxy:trigger-start', listener)
3333
return () => ipcRenderer.removeListener('copilot-proxy:trigger-start', listener)
3434
},
35+
onUpdateState(callback) {
36+
const listener = (_event, state) => callback(state)
37+
ipcRenderer.on('copilot-proxy:update-state', listener)
38+
return () => ipcRenderer.removeListener('copilot-proxy:update-state', listener)
39+
},
3540
})

0 commit comments

Comments
 (0)