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
@@ -1,45 +1,4 @@
import { useEffect } from 'react';
import packageJson from '../../../package.json';
import { fetchLatestStableVersion, isElectron, refreshServiceWorkerRegistration } from '../../lib/app-update';
import useAppUpdateStore from '../../stores/use-app-update-store';
const UPDATE_CHECK_INTERVAL_MS = 60 * 1000;
const AppUpdateRegistration = () => {
useEffect(() => {
if (isElectron) {
return undefined;
}
let isDisposed = false;
const syncUpdateAvailability = async () => {
await refreshServiceWorkerRegistration().catch((error) => {
console.error('Failed to refresh service worker registration', error);
});
try {
const latestStableVersion = await fetchLatestStableVersion();
if (!isDisposed) {
useAppUpdateStore.getState().setNeedRefresh(packageJson.version !== latestStableVersion);
}
} catch (error) {
console.error('Failed to check app update availability', error);
}
};
void syncUpdateAvailability();
const intervalId = window.setInterval(() => {
void syncUpdateAvailability();
}, UPDATE_CHECK_INTERVAL_MS);
return () => {
isDisposed = true;
window.clearInterval(intervalId);
useAppUpdateStore.getState().setNeedRefresh(false);
};
}, []);
return null;
};
@@ -3,7 +3,6 @@ import { createElement } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import packageJson from '../../../../../package.json';
import InterfaceSettings from '../interface-settings';
import useFeedViewSettingsStore from '../../../../stores/use-feed-view-settings-store';
import { INTERFACE_LANGUAGE_STORAGE_KEY } from '../../../../lib/constants';
@@ -16,8 +15,8 @@ const testState = vi.hoisted(() => ({
alertMock: vi.fn(),
applyAppUpdateMock: vi.fn(),
changeLanguageMock: vi.fn(),
fetchMock: vi.fn(),
fitExpandedImagesToScreen: false,
refreshAvailableUpdateMock: vi.fn(),
setFitExpandedImagesToScreenMock: vi.fn(),
setUnmuteExpandedVideoSoundMock: vi.fn(),
unmuteExpandedVideoSound: false,
@@ -47,7 +46,6 @@ vi.mock('../../../style-selector/style-selector', () => ({
default: () => null,
}));
/** Minimal component that subscribes to feed view settings (like Board) to verify re-renders. */
const BoardModeIndicator = () => {
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
return <span data-testid='board-mode'>{enableInfiniteScroll ? 'infinite' : 'pagination'}</span>;
@@ -58,26 +56,14 @@ const STORAGE_KEY = 'feed-view-settings-store';
let root: Root;
let container: HTMLDivElement;
const createFetchResponse = (body: unknown) => ({
json: vi.fn().mockResolvedValue(body),
});
const createDeferred = <T,>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((innerResolve, innerReject) => {
resolve = innerResolve;
reject = innerReject;
});
return { promise, resolve, reject };
};
const render = (children: React.ReactNode) => {
act(() => {
root.render(createElement(MemoryRouter, {}, children));
});
};
const findButtonByText = (text: string) => Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text);
describe('InterfaceSettings', () => {
let setItemSpy: ReturnType<typeof vi.spyOn>;
@@ -87,18 +73,20 @@ describe('InterfaceSettings', () => {
testState.alertMock.mockReset();
testState.applyAppUpdateMock.mockReset();
testState.changeLanguageMock.mockReset();
testState.fetchMock.mockReset();
testState.fitExpandedImagesToScreen = false;
testState.refreshAvailableUpdateMock.mockReset();
testState.setFitExpandedImagesToScreenMock.mockReset();
testState.setUnmuteExpandedVideoSoundMock.mockReset();
testState.unmuteExpandedVideoSound = false;
useFeedViewSettingsStore.getState().setEnableInfiniteScroll(false);
useAppUpdateStore.setState({
needRefresh: false,
availableUpdate: null,
isApplyingUpdate: false,
isCheckingForUpdate: false,
applyAppUpdate: testState.applyAppUpdateMock,
refreshAvailableUpdate: testState.refreshAvailableUpdateMock,
});
vi.stubGlobal('alert', testState.alertMock);
vi.stubGlobal('fetch', testState.fetchMock);
setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
container = document.createElement('div');
document.body.appendChild(container);
@@ -119,7 +107,7 @@ describe('InterfaceSettings', () => {
it('renders enable_infinite_scroll checkbox unchecked by default', () => {
render(createElement(InterfaceSettings));
const label = Array.from(container.querySelectorAll('label')).find((l) => l.textContent?.toLowerCase().includes('enable_infinite_scroll'));
const label = Array.from(container.querySelectorAll('label')).find((candidate) => candidate.textContent?.toLowerCase().includes('enable_infinite_scroll'));
expect(label).toBeTruthy();
const checkbox = label?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox).toBeTruthy();
@@ -129,7 +117,7 @@ describe('InterfaceSettings', () => {
it('toggling checkbox updates persisted state and re-renders board mode', async () => {
render(createElement(React.Fragment, {}, createElement(InterfaceSettings), createElement(BoardModeIndicator)));
const label = Array.from(container.querySelectorAll('label')).find((l) => l.textContent?.toLowerCase().includes('enable_infinite_scroll'));
const label = Array.from(container.querySelectorAll('label')).find((candidate) => candidate.textContent?.toLowerCase().includes('enable_infinite_scroll'));
const checkbox = label?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox).toBeTruthy();
@@ -192,37 +180,70 @@ describe('InterfaceSettings', () => {
expect(localStorage.getItem(INTERFACE_LANGUAGE_STORAGE_KEY)).toBe('fr');
});
it('disables the update button while fetching and restores it afterward', async () => {
const pendingFetch = createDeferred<ReturnType<typeof createFetchResponse>>();
testState.fetchMock.mockReturnValueOnce(pendingFetch.promise);
it('renders a check button when no app update is available', () => {
render(createElement(InterfaceSettings));
expect(container.textContent).toContain('Update:');
expect(findButtonByText('Check')).toBeTruthy();
});
it('shows the checking status while an update check is in progress', () => {
useAppUpdateStore.setState({
isCheckingForUpdate: true,
});
render(createElement(InterfaceSettings));
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'check');
expect(findButtonByText('Check')?.disabled).toBe(true);
expect(container.textContent).toContain('checking_for_updates');
});
it('renders a download button and release link when an app update is available', () => {
useAppUpdateStore.setState({
availableUpdate: {
runtime: 'web',
targetVersion: '9.9.9',
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
},
});
render(createElement(InterfaceSettings));
expect(findButtonByText('Download')).toBeTruthy();
const releaseLink = container.querySelector<HTMLAnchorElement>('a[href="https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9"]');
expect(releaseLink?.textContent).toBe('v9.9.9');
expect(container.textContent).toContain('new_version_found');
});
it('checks for app updates when the check button is pressed', async () => {
render(createElement(InterfaceSettings));
const button = findButtonByText('Check');
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(button?.disabled).toBe(true);
pendingFetch.resolve(createFetchResponse({ version: packageJson.version }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(button?.disabled).toBe(false);
expect(testState.alertMock).toHaveBeenCalledWith(expect.stringContaining('latest_stable_version'));
expect(testState.refreshAvailableUpdateMock).toHaveBeenCalledTimes(1);
expect(testState.applyAppUpdateMock).not.toHaveBeenCalled();
});
it('applies the app update when a newer stable version is available', async () => {
testState.fetchMock.mockResolvedValueOnce(createFetchResponse({ version: '9.9.9' }));
it('applies the available app update when the button is pressed', async () => {
useAppUpdateStore.setState({
availableUpdate: {
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',
},
});
render(createElement(InterfaceSettings));
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'check');
const button = findButtonByText('Download');
expect(button).toBeTruthy();
await act(async () => {
@@ -234,12 +255,36 @@ describe('InterfaceSettings', () => {
expect(testState.alertMock).not.toHaveBeenCalled();
});
it('alerts when already on the latest stable version', async () => {
testState.fetchMock.mockResolvedValueOnce(createFetchResponse({ version: packageJson.version }));
it('disables the update button while an app update is already being applied', () => {
useAppUpdateStore.setState({
availableUpdate: {
runtime: 'web',
targetVersion: '9.9.9',
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
},
isApplyingUpdate: true,
});
render(createElement(InterfaceSettings));
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'check');
expect(findButtonByText('Download')?.disabled).toBe(true);
});
it('alerts when applying the update fails', async () => {
testState.applyAppUpdateMock.mockRejectedValueOnce(new Error('installer failed'));
useAppUpdateStore.setState({
availableUpdate: {
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',
},
});
render(createElement(InterfaceSettings));
const button = findButtonByText('Download');
expect(button).toBeTruthy();
await act(async () => {
@@ -247,48 +292,6 @@ describe('InterfaceSettings', () => {
await Promise.resolve();
});
expect(testState.alertMock).toHaveBeenCalledWith(expect.stringContaining('latest_stable_version'));
});
it('renders an update button when a service worker refresh is ready', () => {
useAppUpdateStore.setState({ needRefresh: true });
render(createElement(InterfaceSettings));
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'update');
expect(button).toBeTruthy();
});
it('applies the waiting service worker when the update button is pressed', async () => {
useAppUpdateStore.setState({ needRefresh: true });
render(createElement(InterfaceSettings));
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'update');
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(testState.applyAppUpdateMock).toHaveBeenCalledTimes(1);
expect(testState.fetchMock).not.toHaveBeenCalled();
});
it('alerts when fetching the latest version info fails', async () => {
testState.fetchMock.mockRejectedValueOnce(new Error('network down'));
render(createElement(InterfaceSettings));
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'check');
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(testState.alertMock).toHaveBeenCalledWith('Failed to fetch latest version info: Error: network down');
expect(testState.alertMock).toHaveBeenCalledWith('Error: installer failed');
});
});
@@ -51,6 +51,10 @@
padding-left: 19px;
}
.updateStatus {
margin-left: 8px;
}
.webUploadWarning {
font-size: 0.8em;
padding: 2px 0;
@@ -1,6 +1,5 @@
import { memo, useState } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import packageJson from '../../../../package.json';
import styles from './interface-settings.module.css';
import capitalize from 'lodash/capitalize';
import useExpandedMediaStore from '../../../stores/use-expanded-media-store';
@@ -8,78 +7,50 @@ import useFeedViewSettingsStore from '../../../stores/use-feed-view-settings-sto
import Version from '../../version';
import StyleSelector from '../../style-selector/style-selector';
import { INTERFACE_LANGUAGE_STORAGE_KEY, SUPPORTED_INTERFACE_LANGUAGES } from '../../../lib/constants';
import { fetchLatestStableVersion, isElectron } from '../../../lib/app-update';
import useAppUpdateStore from '../../../stores/use-app-update-store';
const commitRef = process.env.VITE_COMMIT_REF;
const fetchLatestVersionInfo = async (t: (key: string, opts?: Record<string, unknown>) => string, applyAppUpdate: () => Promise<void>): Promise<void> => {
try {
const latestStableVersion = await fetchLatestStableVersion();
let updateAvailable = false;
if (packageJson.version !== latestStableVersion) {
if (isElectron) {
const newVersionText = t('new_stable_version', { newVersion: latestStableVersion, oldVersion: packageJson.version });
const updateActionText = t('download_latest_desktop', { link: 'https://github.com/bitsocialnet/5chan/releases/latest', interpolation: { escapeValue: false } });
alert(newVersionText + ' ' + updateActionText);
} else {
await applyAppUpdate();
return;
}
updateAvailable = true;
}
if (commitRef && commitRef.length > 0) {
const commitRes = await fetch('https://api.github.com/repos/bitsocialnet/5chan/commits?per_page=1&sha=development', { cache: 'no-cache' });
const commitData = await commitRes.json();
const latestCommitHash = commitData[0].sha;
if (latestCommitHash.trim() !== commitRef.trim()) {
const newVersionText = t('new_development_version', { newCommit: latestCommitHash.slice(0, 7), oldCommit: commitRef.slice(0, 7) }) + ' ' + t('refresh_to_update');
alert(newVersionText);
updateAvailable = true;
}
}
if (!updateAvailable) {
alert(
commitRef
? `${t('latest_development_version', { commit: commitRef.slice(0, 7), link: `${window.location.origin}/#/`, interpolation: { escapeValue: false } })}`
: `${t('latest_stable_version', { version: packageJson.version })}`,
);
}
} catch (error) {
alert('Failed to fetch latest version info: ' + error);
}
};
const CheckForUpdates = () => {
const UpdateButton = () => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const needRefresh = useAppUpdateStore((state) => state.needRefresh);
const availableUpdate = useAppUpdateStore((state) => state.availableUpdate);
const isApplyingUpdate = useAppUpdateStore((state) => state.isApplyingUpdate);
const isCheckingForUpdate = useAppUpdateStore((state) => state.isCheckingForUpdate);
const applyAppUpdate = useAppUpdateStore((state) => state.applyAppUpdate);
const refreshAvailableUpdate = useAppUpdateStore((state) => state.refreshAvailableUpdate);
const checkForUpdates = async () => {
setLoading(true);
const handleUpdateAction = async () => {
try {
if (needRefresh) {
if (availableUpdate) {
await applyAppUpdate();
return;
}
await fetchLatestVersionInfo(t, applyAppUpdate);
} finally {
setLoading(false);
await refreshAvailableUpdate();
} catch (error) {
alert(String(error));
}
};
const buttonLabel = availableUpdate ? t('download') : t('check');
const isBusy = isApplyingUpdate || isCheckingForUpdate;
return (
<button className={styles.checkForUpdatesButton} onClick={checkForUpdates} disabled={loading}>
{needRefresh ? t('update') : t('check')}
</button>
<>
<button type='button' onClick={handleUpdateAction} disabled={isBusy}>
{capitalize(buttonLabel)}
</button>
{isCheckingForUpdate && (
<span className={styles.updateStatus} aria-live='polite'>
{t('checking_for_updates')}
</span>
)}
{!isCheckingForUpdate && availableUpdate && (
<span className={styles.updateStatus} aria-live='polite'>
{t('new_version_found')}:&nbsp;
<a href={availableUpdate.releaseUrl} target='_blank' rel='noopener noreferrer'>
v{availableUpdate.targetVersion}
</a>
</span>
)}
</>
);
};
@@ -117,7 +88,7 @@ const InterfaceSettings = () => {
{capitalize(t('version'))}: <Version />
</div>
<div className={styles.setting}>
{capitalize(t('update'))}: <CheckForUpdates />
{capitalize(t('update'))}: <UpdateButton />
</div>
<div className={styles.setting}>
{capitalize(t('interface_language'))}: <InterfaceLanguage />
+3 -4
View File
@@ -1,7 +1,6 @@
import { useTranslation } from 'react-i18next';
import packageJson from '../../../package.json';
import { currentAppVersion } from '../../lib/app-version';
const { version } = packageJson;
const commitRef = import.meta.env.VITE_COMMIT_REF;
const isElectron = window.electronApi?.isElectron === true;
@@ -10,11 +9,11 @@ const Version = () => {
return (
<>
<a
href={commitRef ? `https://github.com/bitsocialnet/5chan/commit/${commitRef}` : `https://github.com/bitsocialnet/5chan/releases/tag/v${version}`}
href={commitRef ? `https://github.com/bitsocialnet/5chan/commit/${commitRef}` : `https://github.com/bitsocialnet/5chan/releases/tag/v${currentAppVersion}`}
target='_blank'
rel='noopener noreferrer'
>
v{commitRef ? `${version}#${commitRef.slice(0, 7)}` : version}
v{commitRef ? `${currentAppVersion}#${commitRef.slice(0, 7)}` : currentAppVersion}
</a>
{isElectron && (
<>
+1
View File
@@ -13,6 +13,7 @@ declare global {
copyToClipboard: (text: string) => Promise<{ success: boolean; error?: string }>;
getPlatform: () => Promise<{ platform: NodeJS.Platform; arch: string; version: string }>;
automateUploadMedia: (options: { provider: ProviderId; filePath: string }) => Promise<{ url: string; provider: ProviderId }>;
downloadAndInstallUpdate?: (options: { url: string; fileName: string }) => Promise<void>;
getPathForFile?: (file: File) => string | null;
};
}
+5
View File
@@ -15,6 +15,11 @@ import { Analytics } from '@vercel/analytics/react';
// Exclude Electron (file:// or localhost), Capacitor/APK (capacitor:// or localhost), and IPFS (ipfs:// or different domain)
const isVercelDeployment =
typeof window !== 'undefined' && (window.location.hostname === '5chan.app' || window.location.hostname === 'www.5chan.app') && !window.isElectron;
const e2eStartHash = import.meta.env.VITE_E2E_START_HASH?.trim();
if (typeof window !== 'undefined' && e2eStartHash && window.location.hash.length === 0) {
window.location.hash = e2eStartHash.startsWith('#') ? e2eStartHash : `#${e2eStartHash}`;
}
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
+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 };
+14
View File
@@ -0,0 +1,14 @@
import { registerPlugin } from '@capacitor/core';
interface DownloadAndInstallUpdateOptions {
url: string;
fileName: string;
}
interface AppUpdaterPlugin {
downloadAndInstallUpdate(options: DownloadAndInstallUpdateOptions): Promise<void>;
}
const AppUpdater = registerPlugin<AppUpdaterPlugin>('AppUpdater');
export default AppUpdater;
+30 -14
View File
@@ -1,24 +1,40 @@
import { create } from 'zustand';
import { refreshServiceWorkerRegistration } from '../lib/app-update';
import { applyAvailableAppUpdate, resolveAvailableAppUpdate, type AvailableAppUpdate } from '../lib/app-update';
interface AppUpdateState {
needRefresh: boolean;
setNeedRefresh: (needRefresh: boolean) => void;
availableUpdate: AvailableAppUpdate | null;
isApplyingUpdate: boolean;
isCheckingForUpdate: boolean;
refreshAvailableUpdate: () => Promise<AvailableAppUpdate | null>;
applyAppUpdate: () => Promise<void>;
}
const reloadCurrentPage = () => {
window.location.reload();
};
const useAppUpdateStore = create<AppUpdateState>((set) => ({
needRefresh: false,
setNeedRefresh: (needRefresh) => set({ needRefresh }),
const useAppUpdateStore = create<AppUpdateState>((set, get) => ({
availableUpdate: null,
isApplyingUpdate: false,
isCheckingForUpdate: false,
refreshAvailableUpdate: async () => {
set({ isCheckingForUpdate: true });
try {
const availableUpdate = await resolveAvailableAppUpdate();
set({ availableUpdate });
return availableUpdate;
} finally {
set({ isCheckingForUpdate: false });
}
},
applyAppUpdate: async () => {
await refreshServiceWorkerRegistration().catch((error) => {
console.error('Failed to refresh service worker registration', error);
});
reloadCurrentPage();
const availableUpdate = get().availableUpdate;
if (!availableUpdate) {
return;
}
set({ isApplyingUpdate: true });
try {
await applyAvailableAppUpdate(availableUpdate);
} finally {
set({ isApplyingUpdate: false });
}
},
}));