Skip to content

Commit e797393

Browse files
authored
fix: auto-update arch fallback + CI install smoke tests (#1133)
* fix: improve updater arch fallback and add CI install smoke tests * fix: stabilize windows update and directory path handling * fix: improve updater arch fallback and add CI install smoke tests * fix: stabilize windows update and directory path handling * chore(i18n): regenerate i18n key types * chore(i18n): regenerate key types after upstream merge * chore(format): fix prettier issues in settings bridge and i18n types
1 parent 7083241 commit e797393

14 files changed

Lines changed: 358 additions & 51 deletions

File tree

.github/workflows/pr-checks.yml

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,122 @@ jobs:
250250
- name: Verify packaged i18n assets
251251
run: bun run test:packaged:i18n
252252

253+
- name: Verify build artifacts exist
254+
shell: bash
255+
run: |
256+
echo "=========================================="
257+
echo "VERIFY ARTIFACTS: ${{ matrix.platform }}"
258+
echo "=========================================="
259+
ls -lah out || true
260+
261+
case "${{ matrix.platform }}" in
262+
windows-*)
263+
ls out/*.exe out/*latest*.yml >/dev/null
264+
;;
265+
macos-*)
266+
ls out/*.dmg out/*.zip out/*latest*.yml >/dev/null
267+
;;
268+
linux)
269+
ls out/*.AppImage out/*.deb out/*latest*.yml >/dev/null
270+
;;
271+
esac
272+
273+
- name: Silent install smoke test (Windows x64)
274+
if: matrix.platform == 'windows-x64'
275+
shell: pwsh
276+
run: |
277+
Write-Host "=========================================="
278+
Write-Host "SMOKE INSTALL: windows-x64"
279+
Write-Host "=========================================="
280+
281+
$installer = Get-ChildItem -Path out -Filter "AionUi-*-win-*.exe" | Sort-Object LastWriteTime -Descending | Select-Object -First 1
282+
if (-not $installer) {
283+
throw "No Windows installer found in out/"
284+
}
285+
286+
Write-Host "Using installer: $($installer.FullName)"
287+
Start-Process -FilePath $installer.FullName -ArgumentList '/S' -Wait -NoNewWindow
288+
289+
$candidates = @(
290+
"$env:LOCALAPPDATA\Programs\AionUi\AionUi.exe",
291+
"$env:ProgramFiles\AionUi\AionUi.exe",
292+
"$env:ProgramFiles(x86)\AionUi\AionUi.exe"
293+
)
294+
295+
$installedExe = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1
296+
if (-not $installedExe) {
297+
throw "Silent install finished but app executable not found in expected locations"
298+
}
299+
300+
Write-Host "Installed executable: $installedExe"
301+
302+
- name: Skip executable smoke for Windows arm64 cross build
303+
if: matrix.platform == 'windows-arm64'
304+
shell: pwsh
305+
run: |
306+
Write-Host "Skipping runtime install smoke for windows-arm64: runner is windows-x64, cannot reliably execute arm64 installer."
307+
Get-ChildItem -Path out -Filter "AionUi-*-win-*.exe" | Format-Table Name, Length
308+
309+
- name: Install smoke test (macOS arm64)
310+
if: matrix.platform == 'macos-arm64'
311+
shell: bash
312+
run: |
313+
set -euo pipefail
314+
echo "=========================================="
315+
echo "SMOKE INSTALL: macos-arm64"
316+
echo "=========================================="
317+
318+
DMG_FILE=$(ls out/*.dmg | head -n 1)
319+
MOUNT_POINT="/tmp/aionui-smoke-mount"
320+
APP_DIR="/tmp/aionui-smoke-app"
321+
322+
rm -rf "$MOUNT_POINT" "$APP_DIR"
323+
mkdir -p "$MOUNT_POINT" "$APP_DIR"
324+
325+
hdiutil attach "$DMG_FILE" -nobrowse -mountpoint "$MOUNT_POINT"
326+
cp -R "$MOUNT_POINT"/*.app "$APP_DIR"/
327+
hdiutil detach "$MOUNT_POINT"
328+
329+
APP_PATH=$(ls -d "$APP_DIR"/*.app | head -n 1)
330+
APP_BIN="$APP_PATH/Contents/MacOS/AionUi"
331+
332+
test -x "$APP_BIN"
333+
"$APP_BIN" --version || true
334+
335+
- name: Skip executable smoke for macOS x64 cross build
336+
if: matrix.platform == 'macos-x64'
337+
shell: bash
338+
run: |
339+
echo "Skipping runtime launch smoke for macos-x64 cross build on arm64 runner."
340+
ls -lah out/*.dmg out/*.zip
341+
342+
- name: Install smoke test (Linux)
343+
if: matrix.platform == 'linux'
344+
shell: bash
345+
run: |
346+
set -euo pipefail
347+
echo "=========================================="
348+
echo "SMOKE INSTALL: linux"
349+
echo "=========================================="
350+
351+
APPIMAGE_FILE=$(ls out/*.AppImage | head -n 1)
352+
chmod +x "$APPIMAGE_FILE"
353+
"$APPIMAGE_FILE" --appimage-extract-and-run --version || true
354+
355+
DEB_FILE=$(ls out/*.deb | head -n 1)
356+
PKG_NAME=$(dpkg-deb -f "$DEB_FILE" Package)
357+
sudo dpkg -i "$DEB_FILE" || sudo apt-get install -f -y
358+
359+
INSTALLED_BIN=$(dpkg -L "$PKG_NAME" | grep -Ei '/(bin|opt)/.*(aionui)$' | head -n 1 || true)
360+
if [ -z "$INSTALLED_BIN" ]; then
361+
echo "Package files:"
362+
dpkg -L "$PKG_NAME" | head -n 50
363+
echo "No installed executable path matched expected pattern"
364+
exit 1
365+
fi
366+
367+
test -x "$INSTALLED_BIN"
368+
253369
# Job 4: Trigger GPT AI review (only on first PR submission)
254370
review:
255371
name: AI Review

src/process/bridge/systemSettingsBridge.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,4 @@ export function initSystemSettingsBridge(): void {
4141
// 然后通知主进程更新托盘状态
4242
_changeListener?.(enabled);
4343
});
44-
4544
}

src/process/bridge/updateBridge.ts

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,30 +63,62 @@ const mapAsset = (asset: GitHubReleaseApiAsset): GitHubReleaseAsset => ({
6363
contentType: asset.content_type,
6464
});
6565

66-
const getPlatformHints = () => {
67-
const platform = process.platform;
68-
const arch = process.arch;
66+
type RuntimePlatformInfo = {
67+
platform: NodeJS.Platform;
68+
arch: string;
69+
};
70+
71+
type CanonicalArch = 'x64' | 'arm64' | 'ia32';
72+
73+
const normalizeArch = (arch: string): CanonicalArch => {
74+
if (arch === 'arm64') return 'arm64';
75+
if (arch === 'ia32' || arch === 'x32') return 'ia32';
76+
return 'x64';
77+
};
78+
79+
const detectAssetArchs = (nameLower: string): Set<CanonicalArch> => {
80+
const detected = new Set<CanonicalArch>();
81+
82+
if (/\b(arm64|aarch64)\b/.test(nameLower)) detected.add('arm64');
83+
if (/\b(x64|x86_64|amd64)\b/.test(nameLower)) detected.add('x64');
84+
85+
const hasX86Token = /\bx86\b/.test(nameLower) && !/\bx86[_-]?64\b/.test(nameLower);
86+
if (/\b(ia32|x32|32bit)\b/.test(nameLower) || hasX86Token) detected.add('ia32');
6987

70-
const archHints = arch === 'arm64' ? ['arm64', 'aarch64'] : ['x64', 'x86_64', 'amd64'];
88+
return detected;
89+
};
90+
91+
const getPlatformHints = (runtime: RuntimePlatformInfo = { platform: process.platform, arch: process.arch }) => {
92+
const platform = runtime.platform;
93+
const arch = runtime.arch;
94+
const normalizedArch = normalizeArch(arch);
95+
96+
const archHints = normalizedArch === 'arm64' ? ['arm64', 'aarch64'] : normalizedArch === 'ia32' ? ['ia32', 'x86', 'x32', '32bit'] : ['x64', 'x86_64', 'amd64'];
7197

7298
// electron-builder artifact names often include one of these
7399
const platformHints = platform === 'win32' ? ['win', 'win32', 'windows'] : platform === 'darwin' ? ['mac', 'darwin', 'osx'] : ['linux'];
74100

75-
return { platform, arch, archHints, platformHints };
101+
return { platform, arch, normalizedArch, archHints, platformHints };
76102
};
77103

78-
const scoreAsset = (asset: GitHubReleaseAsset): number => {
79-
const { platform, archHints, platformHints } = getPlatformHints();
104+
const scoreAsset = (asset: GitHubReleaseAsset, runtime?: RuntimePlatformInfo): number => {
105+
const { platform, normalizedArch, archHints, platformHints } = getPlatformHints(runtime);
80106
const nameLower = asset.name.toLowerCase();
81107
const ext = path.extname(asset.name);
82108

109+
const detectedArchs = detectAssetArchs(nameLower);
110+
if (detectedArchs.size > 0 && !detectedArchs.has(normalizedArch)) {
111+
return -1;
112+
}
113+
83114
let score = 0;
84115

85116
// Platform match
86117
if (platformHints.some((hint) => nameLower.includes(hint))) score += 20;
87118

88119
// Arch match
89120
if (archHints.some((hint) => nameLower.includes(hint))) score += 10;
121+
if (detectedArchs.has(normalizedArch)) score += 15;
90122

91123
// Prefer installer formats per platform
92124
if (platform === 'win32') {
@@ -106,10 +138,15 @@ const scoreAsset = (asset: GitHubReleaseAsset): number => {
106138
return score;
107139
};
108140

109-
const pickRecommendedAsset = (assets: GitHubReleaseAsset[]): GitHubReleaseAsset | undefined => {
141+
export const pickRecommendedAsset = (assets: GitHubReleaseAsset[], runtime?: RuntimePlatformInfo): GitHubReleaseAsset | undefined => {
110142
if (!assets.length) return undefined;
111-
const scored = [...assets].sort((a, b) => scoreAsset(b) - scoreAsset(a));
112-
return scored[0];
143+
144+
const scored = assets
145+
.map((asset) => ({ asset, score: scoreAsset(asset, runtime) }))
146+
.filter((item) => item.score >= 0)
147+
.sort((a, b) => b.score - a.score);
148+
149+
return scored[0]?.asset;
113150
};
114151

115152
const resolveRepo = (requestRepo?: string): string => {

src/renderer/components/UpdateModal/index.tsx

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const UpdateModal: React.FC = () => {
2727
const [progress, setProgress] = useState({ percent: 0, speed: '', total: 0, transferred: 0 });
2828
const [errorMsg, setErrorMsg] = useState('');
2929
const [downloadPath, setDownloadPath] = useState('');
30+
const [releasePageUrl, setReleasePageUrl] = useState('');
3031
const [useAutoUpdate, setUseAutoUpdate] = useState(true); // 默认使用自动更新
3132
const [autoUpdateInfo, setAutoUpdateInfo] = useState<{ version: string; releaseNotes?: string } | null>(null);
3233

@@ -38,10 +39,19 @@ const UpdateModal: React.FC = () => {
3839
setProgress({ percent: 0, speed: '', total: 0, transferred: 0 });
3940
setErrorMsg('');
4041
setDownloadPath('');
42+
setReleasePageUrl('');
4143
setAutoUpdateInfo(null);
4244
};
4345

4446
const includePrerelease = useMemo(() => localStorage.getItem('update.includePrerelease') === 'true', [visible]);
47+
const hasCompatibleManualAsset = Boolean(updateInfo?.recommendedAsset);
48+
49+
const openReleasePage = () => {
50+
if (!releasePageUrl) return;
51+
void ipcBridge.shell.openExternal.invoke(releasePageUrl).catch((error) => {
52+
console.error('Failed to open release page:', error);
53+
});
54+
};
4555

4656
const checkForUpdates = async () => {
4757
setStatus('checking');
@@ -60,6 +70,11 @@ const UpdateModal: React.FC = () => {
6070
setCurrentVersion(manualRes.data?.currentVersion || '');
6171
if (manualRes.data?.latest) {
6272
setUpdateInfo(manualRes.data.latest);
73+
setReleasePageUrl(manualRes.data.latest.htmlUrl || '');
74+
if (!manualRes.data.latest.recommendedAsset) {
75+
setUseAutoUpdate(false);
76+
setErrorMsg(t('update.noCompatibleAssetManual'));
77+
}
6378
}
6479
}
6580
setStatus('available');
@@ -79,11 +94,16 @@ const UpdateModal: React.FC = () => {
7994

8095
if (res.data?.updateAvailable && res.data.latest) {
8196
setUpdateInfo(res.data.latest);
97+
setReleasePageUrl(res.data.latest.htmlUrl || '');
98+
if (!res.data.latest.recommendedAsset) {
99+
setErrorMsg(t('update.noCompatibleAssetManual'));
100+
}
82101
setStatus('available');
83102
return;
84103
}
85104

86105
setUpdateInfo(res.data?.latest || null);
106+
setReleasePageUrl(res.data?.latest?.htmlUrl || '');
87107
setStatus('upToDate');
88108
} catch (err: unknown) {
89109
const msg = err instanceof Error ? err.message : String(err);
@@ -99,6 +119,10 @@ const UpdateModal: React.FC = () => {
99119
try {
100120
// 使用自动更新模式
101121
if (useAutoUpdate) {
122+
if (updateInfo && !updateInfo.recommendedAsset) {
123+
setUseAutoUpdate(false);
124+
throw new Error(t('update.noCompatibleAssetManual'));
125+
}
102126
const res = await ipcBridge.autoUpdate.download.invoke();
103127
if (!res?.success) {
104128
throw new Error(res?.msg || t('update.downloadStartFailed'));
@@ -110,7 +134,7 @@ const UpdateModal: React.FC = () => {
110134
if (!updateInfo) return;
111135
const asset = updateInfo.recommendedAsset;
112136
if (!asset) {
113-
throw new Error(t('update.noCompatibleAsset'));
137+
throw new Error(t('update.noCompatibleAssetManual'));
114138
}
115139

116140
const res = await ipcBridge.update.download.invoke({
@@ -302,12 +326,15 @@ const UpdateModal: React.FC = () => {
302326
</div>
303327
</div>
304328
<div className='flex items-center gap-12px'>
305-
{!useAutoUpdate && (
329+
{!hasCompatibleManualAsset && releasePageUrl ? (
330+
<Button type='primary' size='small' onClick={openReleasePage} className='!px-16px'>
331+
{t('update.goToRelease')}
332+
</Button>
333+
) : !useAutoUpdate ? (
306334
<Button type='primary' size='small' onClick={startDownload} className='!px-16px'>
307335
{t('update.downloadButton')}
308336
</Button>
309-
)}
310-
{useAutoUpdate && (
337+
) : (
311338
<Button type='primary' size='small' onClick={startDownload} className='!px-16px'>
312339
{t('update.downloadAndInstall')}
313340
</Button>
@@ -318,9 +345,11 @@ const UpdateModal: React.FC = () => {
318345
{/* 自动更新开关 / Auto update toggle */}
319346
<div className='flex items-center justify-between px-24px py-12px bg-fill-1 border-b border-border-2'>
320347
<div className='text-13px text-t-secondary'>{t('update.autoUpdateMode')}</div>
321-
<Switch checked={useAutoUpdate} onChange={setUseAutoUpdate} size='small' />
348+
<Switch checked={useAutoUpdate} onChange={setUseAutoUpdate} size='small' disabled={!hasCompatibleManualAsset} />
322349
</div>
323350

351+
{!hasCompatibleManualAsset && <div className='mx-24px mt-12px px-12px py-10px text-12px rounded-8px bg-[rgb(var(--warning-6))]/10 text-[rgb(var(--warning-6))]'>{t('update.noCompatibleAssetManual')}</div>}
352+
324353
{/* 更新日志内容 / Release notes content */}
325354
<div className='flex-1 min-h-0 overflow-y-auto px-24px py-16px custom-scrollbar'>
326355
{updateInfo?.name && <div className='text-14px font-500 text-t-primary mb-12px'>{updateInfo.name}</div>}
@@ -395,9 +424,16 @@ const UpdateModal: React.FC = () => {
395424
</div>
396425
<div className='text-16px text-t-primary font-600 mb-8px'>{t('update.errorTitle')}</div>
397426
<div className='text-13px text-t-tertiary mb-24px text-center max-w-360px'>{errorMsg}</div>
398-
<Button size='small' onClick={checkForUpdates} icon={<Refresh size='14' />} className='!px-16px'>
399-
{t('common.retry')}
400-
</Button>
427+
<div className='flex gap-12px'>
428+
<Button size='small' onClick={checkForUpdates} icon={<Refresh size='14' />} className='!px-16px'>
429+
{t('common.retry')}
430+
</Button>
431+
{releasePageUrl && (
432+
<Button type='primary' size='small' onClick={openReleasePage} className='!px-16px'>
433+
{t('update.goToRelease')}
434+
</Button>
435+
)}
436+
</div>
401437
</div>
402438
);
403439
}

src/renderer/i18n/i18n-keys.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,7 @@ export type I18nKey =
794794
| 'settings.clearStatus'
795795
| 'settings.clickToValidate'
796796
| 'settings.closeDevTools'
797+
| 'settings.closeToTray'
797798
| 'settings.command'
798799
| 'settings.configGuide'
799800
| 'settings.configGuideSuffix'
@@ -1230,9 +1231,11 @@ export type I18nKey =
12301231
| 'update.downloadStartFailed'
12311232
| 'update.downloadingTitle'
12321233
| 'update.errorTitle'
1234+
| 'update.goToRelease'
12331235
| 'update.installNow'
12341236
| 'update.modalTitle'
12351237
| 'update.noCompatibleAsset'
1238+
| 'update.noCompatibleAssetManual'
12361239
| 'update.noReleaseNotes'
12371240
| 'update.openFile'
12381241
| 'update.readyToInstall'

src/renderer/i18n/locales/en-US/update.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
"downloadStartFailed": "Failed to start download",
1313
"downloadFailed": "Download failed",
1414
"noCompatibleAsset": "No compatible download found for this platform",
15+
"noCompatibleAssetManual": "No package matches your current system architecture. Please download the correct installer from Releases.",
16+
"goToRelease": "Open Releases",
1517
"downloadCompleteTitle": "Download complete",
1618
"readyToInstall": "Ready to install",
1719
"readyToInstallDesc": "Update has been downloaded. Click the button below to restart and install the update.",

src/renderer/i18n/locales/ja-JP/update.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
"downloadStartFailed": "ダウンロードを開始できませんでした",
1313
"downloadFailed": "ダウンロードに失敗しました",
1414
"noCompatibleAsset": "このプラットフォームに対応するファイルが見つかりません",
15+
"noCompatibleAssetManual": "現在のシステムアーキテクチャに対応するパッケージがありません。Release ページから手動でダウンロードしてください。",
16+
"goToRelease": "Release を開く",
1517
"downloadCompleteTitle": "ダウンロード完了",
1618
"readyToInstall": "インストール準備完了",
1719
"readyToInstallDesc": "更新のダウンロードが完了しました。下のボタンをクリックして再起動し、更新をインストールしてください。",

src/renderer/i18n/locales/ko-KR/update.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
"downloadStartFailed": "다운로드를 시작하지 못했습니다",
1313
"downloadFailed": "다운로드에 실패했습니다",
1414
"noCompatibleAsset": "이 플랫폼에 호환되는 다운로드를 찾을 수 없습니다",
15+
"noCompatibleAssetManual": "현재 시스템 아키텍처에 맞는 패키지가 없습니다. Releases 페이지에서 수동으로 다운로드하세요.",
16+
"goToRelease": "Releases 열기",
1517
"downloadCompleteTitle": "다운로드 완료",
1618
"readyToInstall": "설치 준비 완료",
1719
"readyToInstallDesc": "업데이트가 다운로드되었습니다. 아래 버튼을 클릭하여 재시작하고 업데이트를 설치하세요.",

0 commit comments

Comments
 (0)