build(android): add fdroid distribution flavor

This commit is contained in:
Tommaso Casaburi
2026-04-30 18:58:50 +07:00
parent ea03d04cd9
commit fdbb650baa
25 changed files with 259 additions and 75 deletions
+2 -2
View File
@@ -175,8 +175,8 @@ jobs:
- name: Sync Capacitor Android project - name: Sync Capacitor Android project
run: npx cap sync android run: npx cap sync android
- name: Run Android unit tests and assemble debug APK - name: Run Android unit tests and assemble debug APKs
run: cd android && ./gradlew testDebugUnitTest assembleDebug --stacktrace run: cd android && ./gradlew testGithubDebugUnitTest testFdroidDebugUnitTest assembleGithubDebug assembleFdroidDebug --stacktrace
package-linux: package-linux:
name: Package Linux (Ubuntu) name: Package Linux (Ubuntu)
+10 -8
View File
@@ -263,28 +263,30 @@ jobs:
run: CI='' NODE_ENV=production yarn build run: CI='' NODE_ENV=production yarn build
- name: Set Android versionCode and versionName - name: Set Android versionCode and versionName
run: | run: |
sed -i "s/versionCode 1/versionCode $(git tag | wc -l)/" ./android/app/build.gradle VERSION=$(node -e "console.log(require('./package.json').version)")
sed -i "s/versionName \"1.0\"/versionName \"$(node -e "console.log(require('./package.json').version)")\"/" ./android/app/build.gradle VERSION_CODE=$(node -e "const [major = 0, minor = 0, patch = 0] = require('./package.json').version.replace(/^v/, '').split('-')[0].split('.').map(Number); console.log((major * 10000) + (minor * 100) + patch)")
cat ./android/app/build.gradle echo "APP_VERSION_NAME=${VERSION}" >> "$GITHUB_ENV"
echo "APP_VERSION_CODE=${VERSION_CODE}" >> "$GITHUB_ENV"
echo "Android versionName=${VERSION} versionCode=${VERSION_CODE}"
- name: Sync Capacitor - name: Sync Capacitor
run: npx cap sync android run: npx cap sync android
- name: Build APK - name: Build APK
run: | run: |
for i in 1 2 3; do for i in 1 2 3; do
echo "gradlew assembleRelease attempt $i" echo "gradlew assembleGithubRelease attempt $i"
(cd android && ./gradlew assembleRelease --stacktrace) && break (cd android && ./gradlew assembleGithubRelease -PAPP_VERSION_NAME="${APP_VERSION_NAME}" -PAPP_VERSION_CODE="${APP_VERSION_CODE}" --stacktrace) && break
sleep 10 sleep 10
[ "$i" = "3" ] && exit 1 [ "$i" = "3" ] && exit 1
done done
- name: Optimize APK - name: Optimize APK
run: cd android/app/build/outputs/apk/release && zipalign 4 app-release-unsigned.apk app-release-unsigned-zip.apk run: cd android/app/build/outputs/apk/github/release && zipalign 4 app-github-release-unsigned.apk app-github-release-unsigned-zip.apk
- name: Sign APK - name: Sign APK
run: cd android/app/build/outputs/apk/release && apksigner sign --ks ../../../../../plebbit.keystore --ks-pass pass:${{ secrets.PLEBBIT_REACT_KEYSTORE_PASSWORD }} --ks-key-alias release --out app-release-signed.apk app-release-unsigned-zip.apk run: cd android/app/build/outputs/apk/github/release && apksigner sign --ks ../../../../../../plebbit.keystore --ks-pass pass:${{ secrets.PLEBBIT_REACT_KEYSTORE_PASSWORD }} --ks-key-alias release --out app-github-release-signed.apk app-github-release-unsigned-zip.apk
- name: Stage release artifacts - name: Stage release artifacts
run: | run: |
mkdir -p release-assets mkdir -p release-assets
VERSION=$(node -e "console.log(require('./package.json').version)") VERSION=$(node -e "console.log(require('./package.json').version)")
mv android/app/build/outputs/apk/release/app-release-signed.apk "release-assets/5chan-${VERSION}.apk" mv android/app/build/outputs/apk/github/release/app-github-release-signed.apk "release-assets/5chan-${VERSION}.apk"
- name: List release assets - name: List release assets
run: ls -la release-assets run: ls -la release-assets
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
+36 -2
View File
@@ -1,5 +1,30 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
import groovy.json.JsonSlurper
def resolvePackageVersionName() {
def packageJsonFile = file("../../package.json")
if (!packageJsonFile.exists()) {
return "1.0"
}
return new JsonSlurper().parse(packageJsonFile).version.toString()
}
def versionNameToCode(String versionName) {
def stableVersion = versionName.replaceFirst(/^v/, "").split("-")[0]
def parts = stableVersion.tokenize(".").collect { part -> part.isInteger() ? part.toInteger() : 0 }
while (parts.size() < 3) {
parts.add(0)
}
return (parts[0] * 10000) + (parts[1] * 100) + parts[2]
}
def resolvedVersionName = "${project.findProperty("APP_VERSION_NAME") ?: resolvePackageVersionName()}"
def resolvedVersionCode = Integer.parseInt("${project.findProperty("APP_VERSION_CODE") ?: versionNameToCode(resolvedVersionName)}")
android { android {
namespace "fivechan.android" namespace "fivechan.android"
compileSdkVersion rootProject.ext.compileSdkVersion compileSdkVersion rootProject.ext.compileSdkVersion
@@ -7,8 +32,8 @@ android {
applicationId "fivechan.android" applicationId "fivechan.android"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode Integer.parseInt(project.findProperty("APP_VERSION_CODE") ?: "1") versionCode resolvedVersionCode
versionName "${project.findProperty("APP_VERSION_NAME") ?: "1.0"}" versionName resolvedVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -16,6 +41,15 @@ android {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
} }
} }
flavorDimensions "distribution"
productFlavors {
github {
dimension "distribution"
}
fdroid {
dimension "distribution"
}
}
buildTypes { buildTypes {
release { release {
minifyEnabled false minifyEnabled false
@@ -0,0 +1,15 @@
package fivechan.android;
import android.os.Bundle;
import com.capacitorjs.plugins.statusbar.StatusBarPlugin;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
// Custom plugins must be registered before bridge initialization.
registerPlugin(FileUploaderPlugin.class);
registerPlugin(StatusBarPlugin.class);
super.onCreate(savedInstanceState);
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
</manifest>
@@ -1,8 +1,8 @@
package fivechan.android; package fivechan.android;
import android.os.Bundle; import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
import com.capacitorjs.plugins.statusbar.StatusBarPlugin; import com.capacitorjs.plugins.statusbar.StatusBarPlugin;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity { public class MainActivity extends BridgeActivity {
@Override @Override
-1
View File
@@ -2,7 +2,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:allowBackup="true" android:allowBackup="true"
+46
View File
@@ -0,0 +1,46 @@
# Draft metadata for https://gitlab.com/fdroid/fdroiddata.
# Replace the commit field with the full v0.8.2 commit hash before opening the F-Droid merge request.
Categories:
- Internet
License: GPL-3.0-or-later
AuthorName: Bitsocial Forge
WebSite: https://5chan.app
SourceCode: https://github.com/bitsocialnet/5chan
IssueTracker: https://github.com/bitsocialnet/5chan/issues
Changelog: https://github.com/bitsocialnet/5chan/releases
AutoName: 5chan
RepoType: git
Repo: https://github.com/bitsocialnet/5chan
Builds:
- versionName: '0.8.2'
versionCode: 802
commit: TODO_REPLACE_WITH_V0_8_2_COMMIT_HASH
subdir: android
sudo:
- curl -Lo node.tar.xz https://nodejs.org/dist/v22.12.0/node-v22.12.0-linux-x64.tar.xz
- echo "22982235e1b71fa8850f82edd09cdae7e3f32df1764a9ec298c72d25ef2c164f node.tar.xz" | sha256sum -c -
- tar xf node.tar.xz --strip-components=1 -C /usr/local/
- corepack enable
rm:
- android/plebbit.keystore
build:
- cd .. && corepack yarn install --immutable && corepack yarn build:fdroid && corepack yarn cap sync android
scandelete:
- node_modules/
- build/
- android/app/build/
- android/capacitor-cordova-android-plugins/build/
gradle:
- fdroid
gradleprops:
- APP_VERSION_NAME=0.8.2
- APP_VERSION_CODE=802
output: app/build/outputs/apk/fdroid/release/app-fdroid-release-unsigned.apk
AutoUpdateMode: Version
UpdateCheckMode: Tags
CurrentVersion: '0.8.2'
CurrentVersionCode: 802
@@ -0,0 +1 @@
Adds an F-Droid build without in-app APK updating and fixes Android version metadata.
@@ -0,0 +1,5 @@
5chan is a decentralized imageboard client for the Bitsocial protocol.
It keeps the familiar imageboard layout while letting anyone create and operate their own peer-to-peer communities. Boards are owned and moderated by their operators, and users can open any community directly by address.
The Android app supports browsing boards, posting, replying, media upload workflows, account management, themes, and the same directory-based discovery used by the web client.
@@ -0,0 +1 @@
Decentralized imageboard client for Bitsocial communities
@@ -0,0 +1 @@
5chan
+2 -1
View File
@@ -59,6 +59,7 @@
"start": "node scripts/start-dev.js", "start": "node scripts/start-dev.js",
"start:android-usb": "node scripts/start-android-usb.mjs", "start:android-usb": "node scripts/start-android-usb.mjs",
"build": "cross-env PUBLIC_URL=./ GENERATE_SOURCEMAP=false vite build", "build": "cross-env PUBLIC_URL=./ GENERATE_SOURCEMAP=false vite build",
"build:fdroid": "cross-env PUBLIC_URL=./ GENERATE_SOURCEMAP=false VITE_APP_DISTRIBUTION=fdroid vite build",
"build:preload": "vite build --config electron/vite.preload.config.js", "build:preload": "vite build --config electron/vite.preload.config.js",
"build-vercel": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" PUBLIC_URL=./ GENERATE_SOURCEMAP=true VITE_COMMIT_REF=$COMMIT_REF CI='' vite build", "build-vercel": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" PUBLIC_URL=./ GENERATE_SOURCEMAP=true VITE_COMMIT_REF=$COMMIT_REF CI='' vite build",
"test": "vitest", "test": "vitest",
@@ -93,7 +94,7 @@
"type-check": "tsgo --noEmit", "type-check": "tsgo --noEmit",
"prettier": "oxfmt src/**/*.{js,ts,tsx} electron/**/*.{js,mjs}", "prettier": "oxfmt src/**/*.{js,ts,tsx} electron/**/*.{js,mjs}",
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0", "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
"android:build": "corepack yarn build && npx cap sync android && npx cap run android", "android:build": "corepack yarn build && corepack yarn cap sync android && corepack yarn cap run android --flavor github",
"i18n:update": "node scripts/update-translations.js", "i18n:update": "node scripts/update-translations.js",
"i18n:update:dry": "node scripts/update-translations.js --dry", "i18n:update:dry": "node scripts/update-translations.js --dry",
"doctor": "react-doctor . -y", "doctor": "react-doctor . -y",
@@ -62,6 +62,13 @@ const render = (children: React.ReactNode) => {
}); });
}; };
const settleLazyImports = async () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
};
const findButtonByText = (text: string) => Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text); const findButtonByText = (text: string) => Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text);
describe('InterfaceSettings', () => { describe('InterfaceSettings', () => {
@@ -180,25 +187,27 @@ describe('InterfaceSettings', () => {
expect(localStorage.getItem(INTERFACE_LANGUAGE_STORAGE_KEY)).toBe('fr'); expect(localStorage.getItem(INTERFACE_LANGUAGE_STORAGE_KEY)).toBe('fr');
}); });
it('renders a check button when no app update is available', () => { it('renders a check button when no app update is available', async () => {
render(createElement(InterfaceSettings)); render(createElement(InterfaceSettings));
await settleLazyImports();
expect(container.textContent).toContain('Update:'); expect(container.textContent).toContain('Update:');
expect(findButtonByText('Check')).toBeTruthy(); expect(findButtonByText('Check')).toBeTruthy();
}); });
it('shows the checking status while an update check is in progress', () => { it('shows the checking status while an update check is in progress', async () => {
useAppUpdateStore.setState({ useAppUpdateStore.setState({
isCheckingForUpdate: true, isCheckingForUpdate: true,
}); });
render(createElement(InterfaceSettings)); render(createElement(InterfaceSettings));
await settleLazyImports();
expect(findButtonByText('Check')?.disabled).toBe(true); expect(findButtonByText('Check')?.disabled).toBe(true);
expect(container.textContent).toContain('checking_for_updates'); expect(container.textContent).toContain('checking_for_updates');
}); });
it('renders a download button and release link when an app update is available', () => { it('renders a download button and release link when an app update is available', async () => {
useAppUpdateStore.setState({ useAppUpdateStore.setState({
availableUpdate: { availableUpdate: {
runtime: 'web', runtime: 'web',
@@ -208,6 +217,7 @@ describe('InterfaceSettings', () => {
}); });
render(createElement(InterfaceSettings)); render(createElement(InterfaceSettings));
await settleLazyImports();
expect(findButtonByText('Download')).toBeTruthy(); expect(findButtonByText('Download')).toBeTruthy();
const releaseLink = container.querySelector<HTMLAnchorElement>('a[href="https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9"]'); const releaseLink = container.querySelector<HTMLAnchorElement>('a[href="https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9"]');
@@ -217,6 +227,7 @@ describe('InterfaceSettings', () => {
it('checks for app updates when the check button is pressed', async () => { it('checks for app updates when the check button is pressed', async () => {
render(createElement(InterfaceSettings)); render(createElement(InterfaceSettings));
await settleLazyImports();
const button = findButtonByText('Check'); const button = findButtonByText('Check');
expect(button).toBeTruthy(); expect(button).toBeTruthy();
@@ -242,6 +253,7 @@ describe('InterfaceSettings', () => {
}); });
render(createElement(InterfaceSettings)); render(createElement(InterfaceSettings));
await settleLazyImports();
const button = findButtonByText('Download'); const button = findButtonByText('Download');
expect(button).toBeTruthy(); expect(button).toBeTruthy();
@@ -255,7 +267,7 @@ describe('InterfaceSettings', () => {
expect(testState.alertMock).not.toHaveBeenCalled(); expect(testState.alertMock).not.toHaveBeenCalled();
}); });
it('disables the update button while an app update is already being applied', () => { it('disables the update button while an app update is already being applied', async () => {
useAppUpdateStore.setState({ useAppUpdateStore.setState({
availableUpdate: { availableUpdate: {
runtime: 'web', runtime: 'web',
@@ -266,6 +278,7 @@ describe('InterfaceSettings', () => {
}); });
render(createElement(InterfaceSettings)); render(createElement(InterfaceSettings));
await settleLazyImports();
expect(findButtonByText('Download')?.disabled).toBe(true); expect(findButtonByText('Download')?.disabled).toBe(true);
}); });
@@ -283,6 +296,7 @@ describe('InterfaceSettings', () => {
}); });
render(createElement(InterfaceSettings)); render(createElement(InterfaceSettings));
await settleLazyImports();
const button = findButtonByText('Download'); const button = findButtonByText('Download');
expect(button).toBeTruthy(); expect(button).toBeTruthy();
@@ -0,0 +1,61 @@
import { useTranslation } from 'react-i18next';
import capitalize from 'lodash/capitalize';
import styles from './interface-settings.module.css';
import useAppUpdateStore from '../../../stores/use-app-update-store';
const UpdateButton = () => {
const { t } = useTranslation();
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 handleUpdateAction = async () => {
try {
if (availableUpdate) {
await applyAppUpdate();
return;
}
await refreshAvailableUpdate();
} catch (error) {
alert(String(error));
}
};
const buttonLabel = availableUpdate ? t('download') : t('check');
const isBusy = isApplyingUpdate || isCheckingForUpdate;
return (
<>
<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>
)}
</>
);
};
const AppUpdateSetting = () => {
const { t } = useTranslation();
return (
<div className={styles.setting}>
{capitalize(t('update'))}: <UpdateButton />
</div>
);
};
export default AppUpdateSetting;
@@ -1,4 +1,4 @@
import { memo } from 'react'; import { lazy, memo, Suspense } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import styles from './interface-settings.module.css'; import styles from './interface-settings.module.css';
import capitalize from 'lodash/capitalize'; import capitalize from 'lodash/capitalize';
@@ -7,52 +7,9 @@ import useFeedViewSettingsStore from '../../../stores/use-feed-view-settings-sto
import Version from '../../version'; import Version from '../../version';
import StyleSelector from '../../style-selector/style-selector'; import StyleSelector from '../../style-selector/style-selector';
import { INTERFACE_LANGUAGE_STORAGE_KEY, SUPPORTED_INTERFACE_LANGUAGES } from '../../../lib/constants'; import { INTERFACE_LANGUAGE_STORAGE_KEY, SUPPORTED_INTERFACE_LANGUAGES } from '../../../lib/constants';
import useAppUpdateStore from '../../../stores/use-app-update-store';
const UpdateButton = () => { const shouldRenderAppUpdateSetting = import.meta.env.VITE_APP_DISTRIBUTION !== 'fdroid';
const { t } = useTranslation(); const AppUpdateSetting = shouldRenderAppUpdateSetting ? lazy(() => import('./app-update-setting')) : null;
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 handleUpdateAction = async () => {
try {
if (availableUpdate) {
await applyAppUpdate();
return;
}
await refreshAvailableUpdate();
} catch (error) {
alert(String(error));
}
};
const buttonLabel = availableUpdate ? t('download') : t('check');
const isBusy = isApplyingUpdate || isCheckingForUpdate;
return (
<>
<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>
)}
</>
);
};
const InterfaceLanguage = () => { const InterfaceLanguage = () => {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
@@ -87,9 +44,11 @@ const InterfaceSettings = () => {
<div className={styles.version}> <div className={styles.version}>
{capitalize(t('version'))}: <Version /> {capitalize(t('version'))}: <Version />
</div> </div>
<div className={styles.setting}> {AppUpdateSetting && (
{capitalize(t('update'))}: <UpdateButton /> <Suspense fallback={null}>
</div> <AppUpdateSetting />
</Suspense>
)}
<div className={styles.setting}> <div className={styles.setting}>
{capitalize(t('interface_language'))}: <InterfaceLanguage /> {capitalize(t('interface_language'))}: <InterfaceLanguage />
</div> </div>
+2
View File
@@ -3,6 +3,8 @@
declare interface ImportMetaEnv { declare interface ImportMetaEnv {
readonly VITE_COMMIT_REF: string; readonly VITE_COMMIT_REF: string;
readonly VITE_APP_VERSION?: string;
readonly VITE_APP_DISTRIBUTION?: string;
} }
declare interface ImportMeta { declare interface ImportMeta {
+6 -2
View File
@@ -8,13 +8,13 @@ import './index.css';
import './themes.css'; import './themes.css';
import AppUpdateRegistration from './components/app-update-registration'; import AppUpdateRegistration from './components/app-update-registration';
import { App as CapacitorApp } from '@capacitor/app'; import { App as CapacitorApp } from '@capacitor/app';
import { Analytics } from '@vercel/analytics/react';
import { configureP2PBrowserPkcOptions } from './lib/p2p-browser-config'; import { configureP2PBrowserPkcOptions } from './lib/p2p-browser-config';
// Only enable analytics on 5chan.app (Vercel deployment) // Only enable analytics on 5chan.app (Vercel deployment)
// Exclude Electron (file:// or localhost), Capacitor/APK (capacitor:// or localhost), and IPFS (ipfs:// or different domain) // Exclude Electron (file:// or localhost), Capacitor/APK (capacitor:// or localhost), and IPFS (ipfs:// or different domain)
const isVercelDeployment = const isVercelDeployment =
typeof window !== 'undefined' && (window.location.hostname === '5chan.app' || window.location.hostname === 'www.5chan.app') && !window.isElectron; typeof window !== 'undefined' && (window.location.hostname === '5chan.app' || window.location.hostname === 'www.5chan.app') && !window.isElectron;
const shouldLoadAnalytics = import.meta.env.VITE_APP_DISTRIBUTION !== 'fdroid' && isVercelDeployment;
const e2eStartHash = import.meta.env.VITE_E2E_START_HASH?.trim(); const e2eStartHash = import.meta.env.VITE_E2E_START_HASH?.trim();
const requestedE2EHarness = import.meta.env.DEV && typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('e2e') : null; const requestedE2EHarness = import.meta.env.DEV && typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('e2e') : null;
@@ -27,6 +27,7 @@ configureP2PBrowserPkcOptions();
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
const renderRoot = async () => { const renderRoot = async () => {
let e2eHarness: React.ComponentType | null = null; let e2eHarness: React.ComponentType | null = null;
let Analytics: React.ComponentType | null = null;
if (requestedE2EHarness === 'thread-auto-update') { if (requestedE2EHarness === 'thread-auto-update') {
e2eHarness = (await import('./e2e/thread-auto-update-harness')).default; e2eHarness = (await import('./e2e/thread-auto-update-harness')).default;
@@ -40,13 +41,16 @@ const renderRoot = async () => {
} }
const App = (await import('./app')).default; const App = (await import('./app')).default;
if (shouldLoadAnalytics) {
Analytics = (await import('@vercel/analytics/react')).Analytics;
}
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<Router> <Router>
<AppUpdateRegistration /> <AppUpdateRegistration />
<App /> <App />
{isVercelDeployment && <Analytics />} {Analytics && <Analytics />}
</Router> </Router>
</React.StrictMode>, </React.StrictMode>,
); );
+22
View File
@@ -43,6 +43,7 @@ describe('app-update', () => {
testState.electronDownloadAndInstallUpdateMock.mockReset(); testState.electronDownloadAndInstallUpdateMock.mockReset();
testState.electronGetPlatformMock.mockReset(); testState.electronGetPlatformMock.mockReset();
testState.fetchMock.mockReset(); testState.fetchMock.mockReset();
vi.stubEnv('VITE_APP_VERSION', '0.8.1');
vi.stubGlobal('fetch', testState.fetchMock); vi.stubGlobal('fetch', testState.fetchMock);
window.electronApi = undefined; window.electronApi = undefined;
Object.defineProperty(navigator, 'serviceWorker', { Object.defineProperty(navigator, 'serviceWorker', {
@@ -91,6 +92,27 @@ describe('app-update', () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it('disables update checks for F-Droid builds', async () => {
vi.stubEnv('VITE_APP_DISTRIBUTION', 'fdroid');
testState.capacitorPlatform = 'android';
const { applyAvailableAppUpdate, isAppUpdateEnabled, resolveAvailableAppUpdate } = await loadModule();
await expect(resolveAvailableAppUpdate()).resolves.toBeNull();
await expect(
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',
}),
).rejects.toThrow('App updates are disabled for this build');
expect(isAppUpdateEnabled).toBe(false);
expect(testState.fetchMock).not.toHaveBeenCalled();
expect(testState.androidDownloadAndInstallUpdateMock).not.toHaveBeenCalled();
});
it('selects the matching electron release asset for the current desktop platform', async () => { it('selects the matching electron release asset for the current desktop platform', async () => {
window.electronApi = { window.electronApi = {
isElectron: true, isElectron: true,
+4
View File
@@ -0,0 +1,4 @@
const appDistribution = import.meta.env.VITE_APP_DISTRIBUTION?.trim().toLowerCase();
const isAppUpdateEnabled = appDistribution !== 'fdroid';
export { isAppUpdateEnabled };
+11 -2
View File
@@ -1,5 +1,5 @@
import { Capacitor } from '@capacitor/core'; import { Capacitor } from '@capacitor/core';
import AppUpdater from '../plugins/app-updater'; import { isAppUpdateEnabled } from './app-distribution';
import { currentAppVersion } from './app-version'; import { currentAppVersion } from './app-version';
import { getDefaultReleaseUrl, getReleaseApiUrl, isAllowedDownloadUrl } from './app-update-config'; import { getDefaultReleaseUrl, getReleaseApiUrl, isAllowedDownloadUrl } from './app-update-config';
@@ -180,6 +180,10 @@ const fetchLatestReleaseUpdate = async (runtime: Extract<AppRuntime, 'electron'
}; };
const resolveAvailableAppUpdate = async (): Promise<AvailableAppUpdate | null> => { const resolveAvailableAppUpdate = async (): Promise<AvailableAppUpdate | null> => {
if (!isAppUpdateEnabled) {
return null;
}
const runtime = getAppRuntime(); const runtime = getAppRuntime();
if (runtime === 'web') { if (runtime === 'web') {
@@ -201,6 +205,10 @@ const resolveAvailableAppUpdate = async (): Promise<AvailableAppUpdate | null> =
}; };
const applyAvailableAppUpdate = async (update: AvailableAppUpdate): Promise<void> => { const applyAvailableAppUpdate = async (update: AvailableAppUpdate): Promise<void> => {
if (!isAppUpdateEnabled) {
throw new Error('App updates are disabled for this build');
}
if (update.runtime === 'web') { if (update.runtime === 'web') {
await refreshServiceWorkerRegistration().catch((error) => { await refreshServiceWorkerRegistration().catch((error) => {
console.error('Failed to refresh service worker registration', error); console.error('Failed to refresh service worker registration', error);
@@ -221,6 +229,7 @@ const applyAvailableAppUpdate = async (update: AvailableAppUpdate): Promise<void
return; return;
} }
const { default: AppUpdater } = await import('../plugins/app-updater');
await AppUpdater.downloadAndInstallUpdate({ await AppUpdater.downloadAndInstallUpdate({
url: update.downloadUrl, url: update.downloadUrl,
fileName: update.assetName, fileName: update.assetName,
@@ -228,4 +237,4 @@ const applyAvailableAppUpdate = async (update: AvailableAppUpdate): Promise<void
}; };
export type { AppRuntime, AvailableAppUpdate, NativeAppUpdateInfo, WebAppUpdateInfo }; export type { AppRuntime, AvailableAppUpdate, NativeAppUpdateInfo, WebAppUpdateInfo };
export { applyAvailableAppUpdate, fetchLatestStableVersion, getAppRuntime, isElectron, refreshServiceWorkerRegistration, resolveAvailableAppUpdate }; export { applyAvailableAppUpdate, fetchLatestStableVersion, getAppRuntime, isAppUpdateEnabled, isElectron, refreshServiceWorkerRegistration, resolveAvailableAppUpdate };
+1 -3
View File
@@ -1,5 +1,3 @@
import packageJson from '../../package.json';
const resolveCurrentAppVersion = (): string => { const resolveCurrentAppVersion = (): string => {
const configuredVersion = import.meta.env.VITE_APP_VERSION; const configuredVersion = import.meta.env.VITE_APP_VERSION;
@@ -7,7 +5,7 @@ const resolveCurrentAppVersion = (): string => {
return configuredVersion.trim(); return configuredVersion.trim();
} }
return packageJson.version; return '0.0.0';
}; };
const currentAppVersion = resolveCurrentAppVersion(); const currentAppVersion = resolveCurrentAppVersion();
+2
View File
@@ -7,6 +7,7 @@ import { VitePWA } from 'vite-plugin-pwa';
const { version: packageVersion } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')); const { version: packageVersion } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
const appVersion = `${process.env.VITE_APP_VERSION || packageVersion}`.trim() || packageVersion; const appVersion = `${process.env.VITE_APP_VERSION || packageVersion}`.trim() || packageVersion;
process.env.VITE_APP_VERSION = appVersion;
const publicBase = process.env.PUBLIC_URL || '/'; const publicBase = process.env.PUBLIC_URL || '/';
const buildOutDir = 'build'; const buildOutDir = 'build';
const basePathPrefix = (() => { const basePathPrefix = (() => {
@@ -373,6 +374,7 @@ export default defineConfig({
include: ['ethers', 'assert', 'buffer', 'process', 'util', 'stream-browserify', 'isomorphic-fetch', 'workbox-core', 'workbox-precaching'], include: ['ethers', 'assert', 'buffer', 'process', 'util', 'stream-browserify', 'isomorphic-fetch', 'workbox-core', 'workbox-precaching'],
}, },
define: { define: {
'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion),
'process.env.VITE_COMMIT_REF': JSON.stringify(process.env.COMMIT_REF), 'process.env.VITE_COMMIT_REF': JSON.stringify(process.env.COMMIT_REF),
'process.version': JSON.stringify(''), 'process.version': JSON.stringify(''),
global: 'globalThis', global: 'globalThis',