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

This commit is contained in:
Tommaso Casaburi
2026-03-19 20:19:48 +08:00
parent bc870ae438
commit a2286aa68e
61 changed files with 1900 additions and 249 deletions
+284
View File
@@ -0,0 +1,284 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const testState = vi.hoisted(() => ({
androidDownloadAndInstallUpdateMock: vi.fn(),
capacitorPlatform: 'web',
electronDownloadAndInstallUpdateMock: vi.fn(),
electronGetPlatformMock: vi.fn(),
fetchMock: vi.fn(),
}));
vi.mock('@capacitor/core', () => ({
Capacitor: {
getPlatform: () => testState.capacitorPlatform,
},
}));
vi.mock('../../plugins/app-updater', () => ({
default: {
downloadAndInstallUpdate: (options: unknown) => testState.androidDownloadAndInstallUpdateMock(options),
},
}));
const createFetchResponse = (body: unknown, ok = true, status = 200) => ({
ok,
status,
json: vi.fn().mockResolvedValue(body),
});
const originalLocation = window.location;
const originalElectronApi = window.electronApi;
const originalServiceWorker = navigator.serviceWorker;
const loadModule = async () => {
vi.resetModules();
return import('../app-update');
};
describe('app-update', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.androidDownloadAndInstallUpdateMock.mockReset();
testState.capacitorPlatform = 'web';
testState.electronDownloadAndInstallUpdateMock.mockReset();
testState.electronGetPlatformMock.mockReset();
testState.fetchMock.mockReset();
vi.stubGlobal('fetch', testState.fetchMock);
window.electronApi = undefined;
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
getRegistration: vi.fn().mockResolvedValue({
update: vi.fn().mockResolvedValue(undefined),
}),
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
window.electronApi = originalElectronApi;
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
});
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: originalServiceWorker,
});
});
it('resolves a web update when version metadata is newer', async () => {
testState.fetchMock.mockResolvedValueOnce(createFetchResponse({ version: '9.9.9' }));
const { resolveAvailableAppUpdate } = await loadModule();
const result = await resolveAvailableAppUpdate();
expect(result).toEqual({
runtime: 'web',
targetVersion: '9.9.9',
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
});
});
it('returns no web update when the installed version is already current', async () => {
testState.fetchMock.mockResolvedValueOnce(createFetchResponse({ version: '0.7.2' }));
const { resolveAvailableAppUpdate } = await loadModule();
const result = await resolveAvailableAppUpdate();
expect(result).toBeNull();
});
it('selects the matching electron release asset for the current desktop platform', 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: 'linux',
arch: 'x64',
version: 'v20.0.0',
});
testState.fetchMock.mockResolvedValueOnce(
createFetchResponse({
tag_name: 'v9.9.9',
assets: [
{
name: '5chan-9.9.9-arm64.AppImage',
browser_download_url: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9-arm64.AppImage',
},
{
name: '5chan-9.9.9-x64.AppImage',
browser_download_url: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9-x64.AppImage',
},
],
}),
);
const { resolveAvailableAppUpdate } = await loadModule();
const result = await resolveAvailableAppUpdate();
expect(result).toEqual({
runtime: 'electron',
targetVersion: '9.9.9',
assetName: '5chan-9.9.9-x64.AppImage',
downloadUrl: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9-x64.AppImage',
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
});
});
it('prefers the matching mac zip asset for packaged electron updates', 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-arm64.zip',
browser_download_url: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9-arm64.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.zip',
downloadUrl: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9-arm64.zip',
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
});
});
it('selects the latest android apk release asset', async () => {
testState.capacitorPlatform = 'android';
testState.fetchMock.mockResolvedValueOnce(
createFetchResponse({
tag_name: 'v9.9.9',
assets: [
{
name: '5chan-9.9.9.apk',
browser_download_url: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9.apk',
},
],
}),
);
const { resolveAvailableAppUpdate } = await loadModule();
const result = await resolveAvailableAppUpdate();
expect(result).toEqual({
runtime: 'android',
targetVersion: '9.9.9',
assetName: '5chan-9.9.9.apk',
downloadUrl: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9.apk',
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
});
});
it('accepts configured local test asset hosts for native e2e builds', async () => {
vi.stubEnv('VITE_APP_UPDATE_ALLOWED_DOWNLOAD_HOSTS', '10.0.2.2');
testState.capacitorPlatform = 'android';
testState.fetchMock.mockResolvedValueOnce(
createFetchResponse({
tag_name: 'v9.9.9',
html_url: 'http://10.0.2.2:4010/releases/v9.9.9',
assets: [
{
name: '5chan-9.9.9.apk',
browser_download_url: 'http://10.0.2.2:4010/assets/5chan-9.9.9.apk',
},
],
}),
);
const { resolveAvailableAppUpdate } = await loadModule();
const result = await resolveAvailableAppUpdate();
expect(result).toEqual({
runtime: 'android',
targetVersion: '9.9.9',
assetName: '5chan-9.9.9.apk',
downloadUrl: 'http://10.0.2.2:4010/assets/5chan-9.9.9.apk',
releaseUrl: 'http://10.0.2.2:4010/releases/v9.9.9',
});
});
it('reloads the page when applying a web update', async () => {
const reloadMock = vi.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: {
...originalLocation,
reload: reloadMock,
},
});
const { applyAvailableAppUpdate } = await loadModule();
await applyAvailableAppUpdate({
runtime: 'web',
targetVersion: '9.9.9',
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
});
expect(reloadMock).toHaveBeenCalledTimes(1);
});
it('routes native update installs through the matching platform bridge', async () => {
window.electronApi = {
isElectron: true,
getPlatform: () => testState.electronGetPlatformMock(),
downloadAndInstallUpdate: (options) => testState.electronDownloadAndInstallUpdateMock(options),
copyToClipboard: vi.fn(),
automateUploadMedia: vi.fn(),
} as Window['electronApi'];
const { applyAvailableAppUpdate } = await loadModule();
await applyAvailableAppUpdate({
runtime: 'electron',
targetVersion: '9.9.9',
assetName: '5chan-9.9.9.Setup.exe',
downloadUrl: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9.Setup.exe',
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
});
await applyAvailableAppUpdate({
runtime: 'android',
targetVersion: '9.9.9',
assetName: '5chan-9.9.9.apk',
downloadUrl: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9.apk',
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
});
expect(testState.electronDownloadAndInstallUpdateMock).toHaveBeenCalledWith({
url: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9.Setup.exe',
fileName: '5chan-9.9.9.Setup.exe',
});
expect(testState.androidDownloadAndInstallUpdateMock).toHaveBeenCalledWith({
url: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9.apk',
fileName: '5chan-9.9.9.apk',
});
});
});
+36
View File
@@ -0,0 +1,36 @@
const DEFAULT_RELEASE_API_URL = 'https://api.github.com/repos/bitsocialnet/5chan/releases/latest';
const DEFAULT_RELEASES_BASE_URL = 'https://github.com/bitsocialnet/5chan/releases/tag/';
const parseConfiguredHosts = (value: string | undefined): Set<string> =>
new Set(
`${value || ''}`
.split(',')
.map((host) => host.trim().toLowerCase())
.filter(Boolean),
);
const configuredDownloadHosts = parseConfiguredHosts(import.meta.env.VITE_APP_UPDATE_ALLOWED_DOWNLOAD_HOSTS);
const getReleaseApiUrl = (): string => {
const configuredUrl = import.meta.env.VITE_APP_UPDATE_RELEASE_API_URL;
return typeof configuredUrl === 'string' && configuredUrl.trim().length > 0 ? configuredUrl.trim() : DEFAULT_RELEASE_API_URL;
};
const getDefaultReleaseUrl = (version: string): string => `${DEFAULT_RELEASES_BASE_URL}v${version.trim().replace(/^v/i, '').split('-')[0]}`;
const isAllowedDownloadUrl = (url: string): boolean => {
try {
const parsedUrl = new URL(url);
const hostname = parsedUrl.hostname.toLowerCase();
if (parsedUrl.protocol === 'https:' && hostname === 'github.com') {
return true;
}
return configuredDownloadHosts.has(hostname) && (parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'http:');
} catch {
return false;
}
};
export { getDefaultReleaseUrl, getReleaseApiUrl, isAllowedDownloadUrl };
+206 -6
View File
@@ -1,11 +1,86 @@
import { Capacitor } from '@capacitor/core';
import AppUpdater from '../plugins/app-updater';
import { currentAppVersion } from './app-version';
import { getDefaultReleaseUrl, getReleaseApiUrl, isAllowedDownloadUrl } from './app-update-config';
const isElectron = window.electronApi?.isElectron === true;
type AppRuntime = 'web' | 'electron' | 'android';
interface GitHubReleaseAsset {
name: string;
browser_download_url: string;
}
interface GitHubLatestReleaseResponse {
tag_name?: string;
html_url?: string;
assets?: GitHubReleaseAsset[];
}
interface WebAppUpdateInfo {
runtime: 'web';
targetVersion: string;
releaseUrl: string;
}
interface NativeAppUpdateInfo {
runtime: 'electron' | 'android';
targetVersion: string;
assetName: string;
downloadUrl: string;
releaseUrl: string;
}
type AvailableAppUpdate = WebAppUpdateInfo | NativeAppUpdateInfo;
const getAppRuntime = (): AppRuntime => {
if (isElectron) {
return 'electron';
}
return Capacitor.getPlatform() === 'android' ? 'android' : 'web';
};
const normalizeVersion = (version: string): string => version.trim().replace(/^v/i, '').split('-')[0];
const compareVersions = (left: string, right: string): number => {
const leftParts = normalizeVersion(left)
.split('.')
.map((part) => Number.parseInt(part, 10) || 0);
const rightParts = normalizeVersion(right)
.split('.')
.map((part) => Number.parseInt(part, 10) || 0);
const maxLength = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < maxLength; index += 1) {
const diff = (leftParts[index] || 0) - (rightParts[index] || 0);
if (diff !== 0) {
return diff > 0 ? 1 : -1;
}
}
return 0;
};
const fetchJson = async <T>(url: string): Promise<T> => {
const response = await fetch(url, {
cache: 'no-store',
headers: {
Accept: 'application/vnd.github+json',
},
});
if (!response.ok) {
throw new Error(`request failed with status ${response.status}`);
}
return response.json() as Promise<T>;
};
const fetchLatestStableVersion = async (): Promise<string> => {
const versionUrl = isElectron
? 'https://raw.githubusercontent.com/bitsocialnet/5chan/master/package.json'
: new URL(`/version.json?t=${Date.now()}`, window.location.origin).toString();
const packageRes = await fetch(versionUrl, { cache: 'no-store' });
const packageData = await packageRes.json();
const versionUrl = new URL(`/version.json?t=${Date.now()}`, window.location.origin).toString();
const packageData = await fetchJson<{ version?: string }>(versionUrl);
if (typeof packageData?.version !== 'string') {
throw new Error('invalid version payload');
@@ -23,4 +98,129 @@ const refreshServiceWorkerRegistration = async (): Promise<void> => {
await registration?.update();
};
export { fetchLatestStableVersion, isElectron, refreshServiceWorkerRegistration };
const hasArmArchitecture = (value: string): boolean => {
const normalized = value.toLowerCase();
return normalized.includes('arm64') || normalized.includes('aarch64');
};
const hasX64Architecture = (value: string): boolean => !hasArmArchitecture(value);
const getReleaseUrl = (version: string): string => getDefaultReleaseUrl(normalizeVersion(version));
const findMatchingElectronAsset = async (assets: GitHubReleaseAsset[]): Promise<GitHubReleaseAsset | null> => {
const platformInfo = await window.electronApi?.getPlatform();
if (!platformInfo) {
return null;
}
const { platform, arch } = platformInfo;
const prefersArm = hasArmArchitecture(arch);
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
);
}
if (platform === 'linux') {
return (
assets.find((asset) => asset.name.endsWith('.AppImage') && ((prefersArm && hasArmArchitecture(asset.name)) || (prefersX64 && hasX64Architecture(asset.name)))) ||
null
);
}
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 null;
};
const fetchLatestReleaseUpdate = async (runtime: Extract<AppRuntime, 'electron' | 'android'>): Promise<NativeAppUpdateInfo | null> => {
const releaseData = await fetchJson<GitHubLatestReleaseResponse>(getReleaseApiUrl());
const targetVersion = typeof releaseData.tag_name === 'string' ? normalizeVersion(releaseData.tag_name) : '';
if (!targetVersion || compareVersions(targetVersion, currentAppVersion) <= 0) {
return null;
}
const assets = Array.isArray(releaseData.assets)
? releaseData.assets.filter((asset) => typeof asset?.name === 'string' && typeof asset?.browser_download_url === 'string')
: [];
const matchedAsset = runtime === 'android' ? assets.find((asset) => asset.name.toLowerCase().endsWith('.apk')) || null : await findMatchingElectronAsset(assets);
if (!matchedAsset || !isAllowedDownloadUrl(matchedAsset.browser_download_url)) {
return null;
}
return {
runtime,
targetVersion,
assetName: matchedAsset.name,
downloadUrl: matchedAsset.browser_download_url,
releaseUrl:
typeof releaseData.html_url === 'string' && releaseData.html_url.trim().length > 0 ? releaseData.html_url : getReleaseUrl(releaseData.tag_name || targetVersion),
};
};
const resolveAvailableAppUpdate = async (): Promise<AvailableAppUpdate | null> => {
const runtime = getAppRuntime();
if (runtime === 'web') {
await refreshServiceWorkerRegistration();
const latestStableVersion = await fetchLatestStableVersion();
if (compareVersions(latestStableVersion, currentAppVersion) > 0) {
return {
runtime: 'web',
targetVersion: latestStableVersion,
releaseUrl: getReleaseUrl(latestStableVersion),
};
}
return null;
}
return fetchLatestReleaseUpdate(runtime);
};
const applyAvailableAppUpdate = async (update: AvailableAppUpdate): Promise<void> => {
if (update.runtime === 'web') {
await refreshServiceWorkerRegistration().catch((error) => {
console.error('Failed to refresh service worker registration', error);
});
window.location.reload();
return;
}
if (update.runtime === 'electron') {
if (!window.electronApi?.downloadAndInstallUpdate) {
throw new Error('Electron updater is unavailable');
}
await window.electronApi.downloadAndInstallUpdate({
url: update.downloadUrl,
fileName: update.assetName,
});
return;
}
await AppUpdater.downloadAndInstallUpdate({
url: update.downloadUrl,
fileName: update.assetName,
});
};
export type { AppRuntime, AvailableAppUpdate, NativeAppUpdateInfo, WebAppUpdateInfo };
export { applyAvailableAppUpdate, fetchLatestStableVersion, getAppRuntime, isElectron, refreshServiceWorkerRegistration, resolveAvailableAppUpdate };
+15
View File
@@ -0,0 +1,15 @@
import packageJson from '../../package.json';
const resolveCurrentAppVersion = (): string => {
const configuredVersion = import.meta.env.VITE_APP_VERSION;
if (typeof configuredVersion === 'string' && configuredVersion.trim().length > 0) {
return configuredVersion.trim();
}
return packageJson.version;
};
const currentAppVersion = resolveCurrentAppVersion();
export { currentAppVersion };