mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
build(android): add fdroid distribution flavor
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
-1
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
+18
-4
@@ -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')}:
|
||||||
|
<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')}:
|
|
||||||
<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>
|
||||||
|
|||||||
Vendored
+2
@@ -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
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
const appDistribution = import.meta.env.VITE_APP_DISTRIBUTION?.trim().toLowerCase();
|
||||||
|
const isAppUpdateEnabled = appDistribution !== 'fdroid';
|
||||||
|
|
||||||
|
export { isAppUpdateEnabled };
|
||||||
+11
-2
@@ -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,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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user