feat: add in-app update flow and native e2e verification (#1112)

* feat(app-update): add in-app update flow with native e2e coverage

* docs(ai-workflow): infer closed-issue labels automatically

* fix(app-update): address native updater review findings

* fix(android): validate updater redirect hosts

* fix(electron): harden updater file names
This commit is contained in:
Tommaso Casaburi
2026-03-20 13:31:52 +08:00
committed by GitHub
parent dd4451c8c5
commit 5ecf8fbea8
11 changed files with 321 additions and 42 deletions
+72
View File
@@ -173,6 +173,78 @@ describe('app-update', () => {
});
});
it('prefers a matching mac dmg over an incompatible zip fallback', async () => {
window.electronApi = {
isElectron: true,
getPlatform: () => testState.electronGetPlatformMock(),
downloadAndInstallUpdate: (options) => testState.electronDownloadAndInstallUpdateMock(options),
copyToClipboard: vi.fn(),
automateUploadMedia: vi.fn(),
} as Window['electronApi'];
testState.electronGetPlatformMock.mockResolvedValue({
platform: 'darwin',
arch: 'arm64',
version: 'v20.0.0',
});
testState.fetchMock.mockResolvedValueOnce(
createFetchResponse({
tag_name: 'v9.9.9',
assets: [
{
name: '5chan-9.9.9-x64.zip',
browser_download_url: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9-x64.zip',
},
{
name: '5chan-9.9.9-arm64.dmg',
browser_download_url: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9-arm64.dmg',
},
],
}),
);
const { resolveAvailableAppUpdate } = await loadModule();
const result = await resolveAvailableAppUpdate();
expect(result).toEqual({
runtime: 'electron',
targetVersion: '9.9.9',
assetName: '5chan-9.9.9-arm64.dmg',
downloadUrl: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9-arm64.dmg',
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
});
});
it('returns no mac update when the only zip asset targets the wrong architecture', async () => {
window.electronApi = {
isElectron: true,
getPlatform: () => testState.electronGetPlatformMock(),
downloadAndInstallUpdate: (options) => testState.electronDownloadAndInstallUpdateMock(options),
copyToClipboard: vi.fn(),
automateUploadMedia: vi.fn(),
} as Window['electronApi'];
testState.electronGetPlatformMock.mockResolvedValue({
platform: 'darwin',
arch: 'x64',
version: 'v20.0.0',
});
testState.fetchMock.mockResolvedValueOnce(
createFetchResponse({
tag_name: 'v9.9.9',
assets: [
{
name: '5chan-9.9.9-arm64.zip',
browser_download_url: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9-arm64.zip',
},
],
}),
);
const { resolveAvailableAppUpdate } = await loadModule();
const result = await resolveAvailableAppUpdate();
expect(result).toBeNull();
});
it('selects the latest android apk release asset', async () => {
testState.capacitorPlatform = 'android';
testState.fetchMock.mockResolvedValueOnce(
+24 -19
View File
@@ -103,7 +103,25 @@ const hasArmArchitecture = (value: string): boolean => {
return normalized.includes('arm64') || normalized.includes('aarch64');
};
const hasX64Architecture = (value: string): boolean => !hasArmArchitecture(value);
const hasX64Architecture = (value: string): boolean => {
const normalized = value.toLowerCase();
return normalized.includes('x64') || normalized.includes('x86_64') || normalized.includes('amd64');
};
const isArchitectureAgnostic = (value: string): boolean => !hasArmArchitecture(value) && !hasX64Architecture(value);
const matchesPreferredArchitecture = (value: string, prefersArm: boolean, prefersX64: boolean): boolean =>
(prefersArm && hasArmArchitecture(value)) || (prefersX64 && hasX64Architecture(value));
const findCompatibleAsset = (
assets: GitHubReleaseAsset[],
predicate: (asset: GitHubReleaseAsset) => boolean,
prefersArm: boolean,
prefersX64: boolean,
): GitHubReleaseAsset | null =>
assets.find((asset) => predicate(asset) && matchesPreferredArchitecture(asset.name, prefersArm, prefersX64)) ||
assets.find((asset) => predicate(asset) && isArchitectureAgnostic(asset.name)) ||
null;
const getReleaseUrl = (version: string): string => getDefaultReleaseUrl(normalizeVersion(version));
@@ -118,30 +136,17 @@ const findMatchingElectronAsset = async (assets: GitHubReleaseAsset[]): Promise<
const prefersX64 = hasX64Architecture(arch);
if (platform === 'darwin') {
return (
assets.find((asset) => asset.name.endsWith('.zip') && ((prefersArm && hasArmArchitecture(asset.name)) || (prefersX64 && hasX64Architecture(asset.name)))) ||
assets.find((asset) => asset.name.endsWith('.zip')) ||
assets.find((asset) => asset.name.endsWith('.dmg') && ((prefersArm && hasArmArchitecture(asset.name)) || (prefersX64 && hasX64Architecture(asset.name)))) ||
null
);
const matchingZip = findCompatibleAsset(assets, (asset) => asset.name.endsWith('.zip'), prefersArm, prefersX64);
const matchingDmg = findCompatibleAsset(assets, (asset) => asset.name.endsWith('.dmg'), prefersArm, prefersX64);
return matchingZip || matchingDmg;
}
if (platform === 'linux') {
return (
assets.find((asset) => asset.name.endsWith('.AppImage') && ((prefersArm && hasArmArchitecture(asset.name)) || (prefersX64 && hasX64Architecture(asset.name)))) ||
null
);
return findCompatibleAsset(assets, (asset) => asset.name.endsWith('.AppImage'), prefersArm, prefersX64);
}
if (platform === 'win32') {
return (
assets.find(
(asset) =>
asset.name.toLowerCase().endsWith('.exe') &&
asset.name.toLowerCase().includes('setup') &&
((prefersArm && hasArmArchitecture(asset.name)) || (prefersX64 && hasX64Architecture(asset.name))),
) || null
);
return findCompatibleAsset(assets, (asset) => asset.name.toLowerCase().endsWith('.exe') && asset.name.toLowerCase().includes('setup'), prefersArm, prefersX64);
}
return null;