mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(app-update): add in-app update flow with native e2e coverage
This commit is contained in:
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "fivechan.android"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionCode Integer.parseInt(project.findProperty("APP_VERSION_CODE") ?: "1")
|
||||
versionName "${project.findProperty("APP_VERSION_NAME") ?: "1.0"}"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application android:usesCleartextTraffic="true" />
|
||||
</manifest>
|
||||
@@ -2,6 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package fivechan.android;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
@CapacitorPlugin(name = "AppUpdater")
|
||||
public class AppUpdaterPlugin extends Plugin {
|
||||
private static final String TAG = "AppUpdaterPlugin";
|
||||
private static final String EMULATOR_HOST = "10.0.2.2";
|
||||
|
||||
@PluginMethod
|
||||
public void downloadAndInstallUpdate(PluginCall call) {
|
||||
final String url = sanitizeUrl(call.getString("url"));
|
||||
final String fileName = sanitizeFileName(call.getString("fileName"));
|
||||
|
||||
if (url == null) {
|
||||
call.reject("Update url is required");
|
||||
return;
|
||||
}
|
||||
|
||||
new Thread(
|
||||
() -> {
|
||||
try {
|
||||
File apkFile = downloadApk(url, fileName);
|
||||
Activity activity = getActivity();
|
||||
if (activity == null) {
|
||||
call.reject("Activity unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
activity.runOnUiThread(() -> openInstaller(call, activity, apkFile));
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to download update", e);
|
||||
call.reject("Failed to download update: " + e.getMessage(), e);
|
||||
}
|
||||
})
|
||||
.start();
|
||||
}
|
||||
|
||||
private boolean isDebugBuild() {
|
||||
return (getContext().getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
|
||||
}
|
||||
|
||||
private String sanitizeUrl(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String sanitized = value.trim();
|
||||
Uri uri = Uri.parse(sanitized);
|
||||
String scheme = uri.getScheme();
|
||||
String host = uri.getHost();
|
||||
|
||||
if (scheme == null || host == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalizedScheme = scheme.toLowerCase();
|
||||
String normalizedHost = host.toLowerCase();
|
||||
|
||||
if ("https".equals(normalizedScheme) && "github.com".equals(normalizedHost)) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
if (isDebugBuild()
|
||||
&& ("http".equals(normalizedScheme) || "https".equals(normalizedScheme))
|
||||
&& (EMULATOR_HOST.equals(normalizedHost)
|
||||
|| "127.0.0.1".equals(normalizedHost)
|
||||
|| "localhost".equals(normalizedHost))) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private String sanitizeFileName(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return "5chan-update.apk";
|
||||
}
|
||||
|
||||
String sanitized = value.trim().replace("\\", "/");
|
||||
int slashIndex = sanitized.lastIndexOf('/');
|
||||
String fileName = slashIndex >= 0 ? sanitized.substring(slashIndex + 1) : sanitized;
|
||||
return fileName.toLowerCase().endsWith(".apk") ? fileName : fileName + ".apk";
|
||||
}
|
||||
|
||||
private File downloadApk(String url, String fileName) throws IOException {
|
||||
File updatesDirectory = new File(getContext().getCacheDir(), "app-updates");
|
||||
if (!updatesDirectory.exists() && !updatesDirectory.mkdirs()) {
|
||||
throw new IOException("Could not create update directory");
|
||||
}
|
||||
|
||||
File apkFile = new File(updatesDirectory, fileName);
|
||||
File tempFile = new File(updatesDirectory, fileName + ".download");
|
||||
if (tempFile.exists() && !tempFile.delete()) {
|
||||
throw new IOException("Could not replace pending update download");
|
||||
}
|
||||
|
||||
OkHttpClient client = new OkHttpClient();
|
||||
Request request = new Request.Builder().url(url).build();
|
||||
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
throw new IOException("Unexpected response " + response.code());
|
||||
}
|
||||
|
||||
try (InputStream inputStream = response.body().byteStream();
|
||||
OutputStream outputStream = new java.io.FileOutputStream(tempFile)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (apkFile.exists() && !apkFile.delete()) {
|
||||
throw new IOException("Could not replace existing update package");
|
||||
}
|
||||
|
||||
if (!tempFile.renameTo(apkFile)) {
|
||||
throw new IOException("Could not finalize update package");
|
||||
}
|
||||
|
||||
return apkFile;
|
||||
}
|
||||
|
||||
private void openInstaller(PluginCall call, Activity activity, File apkFile) {
|
||||
PackageManager packageManager = activity.getPackageManager();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) {
|
||||
Intent settingsIntent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + activity.getPackageName()));
|
||||
settingsIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
activity.startActivity(settingsIntent);
|
||||
call.reject("Allow app installs for 5chan, then tap update again.");
|
||||
return;
|
||||
}
|
||||
|
||||
Uri apkUri = FileProvider.getUriForFile(activity, activity.getPackageName() + ".fileprovider", apkFile);
|
||||
Intent installIntent = new Intent(Intent.ACTION_VIEW);
|
||||
installIntent.setDataAndType(apkUri, "application/vnd.android.package-archive");
|
||||
installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
|
||||
try {
|
||||
activity.startActivity(installIntent);
|
||||
call.resolve();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to open installer", e);
|
||||
call.reject("Failed to open installer: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,9 @@ public class MainActivity extends BridgeActivity {
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
// Custom plugins must be registered before bridge initialization.
|
||||
registerPlugin(AppUpdaterPlugin.class);
|
||||
registerPlugin(FileUploaderPlugin.class);
|
||||
registerPlugin(StatusBarPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { app, shell } from 'electron';
|
||||
import fs, { createWriteStream } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const LOCAL_TEST_HOSTS = new Set(
|
||||
`${process.env.APP_UPDATE_ALLOWED_DOWNLOAD_HOSTS || ''}`
|
||||
.split(',')
|
||||
.map((host) => host.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const sanitizeFileName = (fileName) => {
|
||||
const safeName = `${fileName || ''}`.split(/[\\/]/).pop()?.trim();
|
||||
return safeName || '5chan-update';
|
||||
};
|
||||
|
||||
const runDetachedCommand = (command, args) => {
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
};
|
||||
|
||||
const runCommand = (command, args) =>
|
||||
new Promise((resolve, reject) => {
|
||||
let stderr = '';
|
||||
const child = spawn(command, args, {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(stderr.trim() || `${command} exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
|
||||
const isAllowedDownloadHost = (parsedUrl) => {
|
||||
const hostname = parsedUrl.hostname.toLowerCase();
|
||||
if (parsedUrl.protocol === 'https:' && hostname === 'github.com') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return LOCAL_TEST_HOSTS.has(hostname) && (parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'http:');
|
||||
};
|
||||
|
||||
const validateDownloadUrl = (url) => {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
if (!isAllowedDownloadHost(parsedUrl)) {
|
||||
throw new Error('Only approved release asset hosts are supported');
|
||||
}
|
||||
|
||||
return parsedUrl;
|
||||
};
|
||||
|
||||
const downloadReleaseAsset = async ({ url, fileName }) => {
|
||||
validateDownloadUrl(url);
|
||||
|
||||
const updatesDirectory = path.join(app.getPath('temp'), '5chan-updates');
|
||||
const targetPath = path.join(updatesDirectory, sanitizeFileName(fileName));
|
||||
const tempPath = `${targetPath}.download`;
|
||||
|
||||
await fs.promises.mkdir(updatesDirectory, { recursive: true });
|
||||
await fs.promises.rm(tempPath, { force: true });
|
||||
|
||||
const response = await fetch(url, {
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to download update (${response.status})`);
|
||||
}
|
||||
|
||||
await pipeline(Readable.fromWeb(response.body), createWriteStream(tempPath));
|
||||
await fs.promises.rm(targetPath, { force: true });
|
||||
await fs.promises.rename(tempPath, targetPath);
|
||||
|
||||
return targetPath;
|
||||
};
|
||||
|
||||
const findMacAppBundlePath = () => {
|
||||
let currentPath = process.execPath;
|
||||
|
||||
while (currentPath && currentPath !== path.dirname(currentPath)) {
|
||||
if (currentPath.endsWith('.app')) {
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
currentPath = path.dirname(currentPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const findExtractedAppBundle = async (directoryPath) => {
|
||||
const entries = await fs.promises.readdir(directoryPath, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(directoryPath, entry.name);
|
||||
|
||||
if (entry.isDirectory() && entry.name.endsWith('.app')) {
|
||||
return entryPath;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const nestedMatch = await findExtractedAppBundle(entryPath);
|
||||
if (nestedMatch) {
|
||||
return nestedMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const scheduleMacAppBundleInstall = async (zipPath) => {
|
||||
const currentAppBundlePath = findMacAppBundlePath();
|
||||
if (!currentAppBundlePath) {
|
||||
throw new Error('Could not resolve the current macOS app bundle');
|
||||
}
|
||||
|
||||
const stagingRoot = path.join(app.getPath('temp'), '5chan-updates', `staged-mac-${Date.now()}`);
|
||||
await fs.promises.mkdir(stagingRoot, { recursive: true });
|
||||
await runCommand('/usr/bin/ditto', ['-x', '-k', zipPath, stagingRoot]);
|
||||
|
||||
const stagedAppBundlePath = await findExtractedAppBundle(stagingRoot);
|
||||
if (!stagedAppBundlePath) {
|
||||
throw new Error('Downloaded update does not contain a macOS app bundle');
|
||||
}
|
||||
|
||||
const installerScriptPath = path.join(stagingRoot, 'install-update.sh');
|
||||
const script = `#!/bin/sh
|
||||
set -eu
|
||||
TARGET_APP="$1"
|
||||
SOURCE_APP="$2"
|
||||
CURRENT_PID="$3"
|
||||
|
||||
for _ in $(seq 1 120); do
|
||||
if ! kill -0 "$CURRENT_PID" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
rm -rf "$TARGET_APP"
|
||||
/usr/bin/ditto "$SOURCE_APP" "$TARGET_APP"
|
||||
/usr/bin/open -n "$TARGET_APP"
|
||||
rm -rf "$(dirname "$SOURCE_APP")"
|
||||
`;
|
||||
await fs.promises.writeFile(installerScriptPath, script, 'utf8');
|
||||
await fs.promises.chmod(installerScriptPath, 0o755);
|
||||
|
||||
runDetachedCommand('/bin/sh', [installerScriptPath, currentAppBundlePath, stagedAppBundlePath, `${process.pid}`]);
|
||||
};
|
||||
|
||||
const resolveCurrentLinuxAppImagePath = () => {
|
||||
if (typeof process.env.APPIMAGE === 'string' && process.env.APPIMAGE.trim().length > 0) {
|
||||
return process.env.APPIMAGE.trim();
|
||||
}
|
||||
|
||||
return process.execPath.endsWith('.AppImage') ? process.execPath : null;
|
||||
};
|
||||
|
||||
const scheduleLinuxAppImageInstall = async (installerPath) => {
|
||||
const currentAppImagePath = resolveCurrentLinuxAppImagePath();
|
||||
if (!currentAppImagePath) {
|
||||
throw new Error('Could not resolve the current AppImage path');
|
||||
}
|
||||
|
||||
await fs.promises.chmod(installerPath, 0o755);
|
||||
|
||||
const installerScriptPath = path.join(path.dirname(installerPath), `install-update-${Date.now()}.sh`);
|
||||
const script = `#!/bin/sh
|
||||
set -eu
|
||||
TARGET_APPIMAGE="$1"
|
||||
DOWNLOADED_APPIMAGE="$2"
|
||||
CURRENT_PID="$3"
|
||||
|
||||
for _ in $(seq 1 120); do
|
||||
if ! kill -0 "$CURRENT_PID" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
mv "$DOWNLOADED_APPIMAGE" "$TARGET_APPIMAGE"
|
||||
chmod 755 "$TARGET_APPIMAGE"
|
||||
"$TARGET_APPIMAGE" >/dev/null 2>&1 &
|
||||
rm -f "$0"
|
||||
`;
|
||||
await fs.promises.writeFile(installerScriptPath, script, 'utf8');
|
||||
await fs.promises.chmod(installerScriptPath, 0o755);
|
||||
|
||||
runDetachedCommand('/bin/sh', [installerScriptPath, currentAppImagePath, installerPath, `${process.pid}`]);
|
||||
};
|
||||
|
||||
const openDownloadedUpdate = async (installerPath) => {
|
||||
if (process.platform === 'darwin' && installerPath.toLowerCase().endsWith('.zip')) {
|
||||
await scheduleMacAppBundleInstall(installerPath);
|
||||
return 'quit-and-relaunch';
|
||||
}
|
||||
|
||||
if (installerPath.toLowerCase().endsWith('.appimage')) {
|
||||
await scheduleLinuxAppImageInstall(installerPath);
|
||||
return 'quit-and-relaunch';
|
||||
}
|
||||
|
||||
const shellResult = await shell.openPath(installerPath);
|
||||
if (shellResult) {
|
||||
throw new Error(shellResult);
|
||||
}
|
||||
|
||||
return 'external-installer';
|
||||
};
|
||||
|
||||
const downloadAndInstallUpdate = async ({ url, fileName }) => {
|
||||
if (typeof url !== 'string' || url.trim().length === 0) {
|
||||
throw new Error('Update url is required');
|
||||
}
|
||||
|
||||
const installerPath = await downloadReleaseAsset({
|
||||
url: url.trim(),
|
||||
fileName,
|
||||
});
|
||||
|
||||
const installMode = await openDownloadedUpdate(installerPath);
|
||||
|
||||
if (installMode === 'quit-and-relaunch') {
|
||||
setTimeout(() => {
|
||||
app.exit(0);
|
||||
}, 200);
|
||||
}
|
||||
};
|
||||
|
||||
export { downloadAndInstallUpdate };
|
||||
@@ -1,6 +1,7 @@
|
||||
import './log.js';
|
||||
import { app, BrowserWindow, Menu, MenuItem, Tray, shell, dialog, nativeTheme, nativeImage, ipcMain, clipboard } from 'electron';
|
||||
import { automateUploadMedia } from './media-upload-automation.js';
|
||||
import { downloadAndInstallUpdate } from './app-updater.js';
|
||||
import isDev from 'electron-is-dev';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -390,3 +391,8 @@ ipcMain.handle('automate-upload-media', async (event, options) => {
|
||||
}
|
||||
return automateUploadMedia({ provider, filePath });
|
||||
});
|
||||
|
||||
ipcMain.handle('download-and-install-update', async (event, options) => {
|
||||
const { url, fileName } = options || {};
|
||||
return downloadAndInstallUpdate({ url, fileName });
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ contextBridge.exposeInMainWorld('electronApi', {
|
||||
copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text),
|
||||
getPlatform: () => ipcRenderer.invoke('get-platform'),
|
||||
automateUploadMedia: (options) => ipcRenderer.invoke('automate-upload-media', options),
|
||||
downloadAndInstallUpdate: (options) => ipcRenderer.invoke('download-and-install-update', options),
|
||||
getPathForFile: (file) => {
|
||||
try {
|
||||
return webUtils.getPathForFile(file);
|
||||
|
||||
@@ -77,6 +77,8 @@
|
||||
"electron:build:mac:arm64": "corepack yarn build && corepack yarn build:preload && corepack yarn electron:prepare-package && electron-forge make --platform=darwin --arch=arm64",
|
||||
"electron:before": "corepack yarn electron:before:delete-data",
|
||||
"electron:before:delete-data": "node -e \"fs.rmSync('.plebbit', { recursive: true, force: true })\"",
|
||||
"test:update:e2e:electron": "node scripts/run-electron-app-update-e2e.mjs",
|
||||
"test:update:e2e:android": "node scripts/run-android-app-update-e2e.mjs",
|
||||
"android:build:icons": "cordova-res android --skip-config --copy --resources /tmp/plebbit-react-android-icons --icon-source ./android/icons/icon.png --splash-source ./android/icons/splash.png --icon-foreground-source ./android/icons/icon-foreground.png --icon-background-source '#ffffee'",
|
||||
"knip": "knip --production --include dependencies,unlisted,binaries --no-progress",
|
||||
"knip:full": "knip --no-progress --no-exit-code",
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "حذف هذا المنشور نهائياً؟ لا يمكن التراجع عن هذا.",
|
||||
"app_p2p_info": "يمكنك دائمًا الاتصال بأي لوحة، بشكل كامل P2P، لا أحد يستطيع إيقافك. ما عليك سوى لصق عنوانها في حقل البحث أعلاه.",
|
||||
"unmute_video_sound_tip": "إلغاء كتم الصوت تلقائياً عند تشغيل الفيديو",
|
||||
"unmute_video_sound": "إلغاء كتم الصوت تلقائياً عند تشغيل الفيديو"
|
||||
"unmute_video_sound": "إلغاء كتم الصوت تلقائياً عند تشغيل الفيديو",
|
||||
"download": "تحميل",
|
||||
"checking_for_updates": "جاري التحقق من التحديثات...",
|
||||
"new_version_found": "تم العثور على إصدار جديد"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "এই পোস্ট স্থায়ীভাবে মুছে ফেলবেন? এটি অপ্রত্যাবর্তনীয়।",
|
||||
"app_p2p_info": "আপনি যেকোনো বোর্ডে সর্বদা সংযোগ করতে পারেন, সম্পূর্ণ P2P, কেউ আপনাকে থামাতে পারবে না। উপরের অনুসন্ধান ক্ষেত্রে কেবল তার ঠিকানা পেস্ট করুন।",
|
||||
"unmute_video_sound_tip": "ভিডিও চালানোর সময় স্বয়ংক্রিয়ভাবে সাউন্ড আনমিউট করুন",
|
||||
"unmute_video_sound": "ভিডিও চালানোর সময় স্বয়ংক্রিয়ভাবে সাউন্ড আনমিউট করুন"
|
||||
"unmute_video_sound": "ভিডিও চালানোর সময় স্বয়ংক্রিয়ভাবে সাউন্ড আনমিউট করুন",
|
||||
"download": "ডাউনলোড",
|
||||
"checking_for_updates": "আপডেট পরীক্ষা করা হচ্ছে...",
|
||||
"new_version_found": "নতুন সংস্করণ পাওয়া গেছে"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Nadobro smazat tento příspěvek? Tuto akci nelze vrátit zpět.",
|
||||
"app_p2p_info": "K jakékoli desce se můžete kdykoli připojit, plně P2P, nikdo vás nemůže zastavit. Stačí vložit její adresu do vyhledávacího pole výše.",
|
||||
"unmute_video_sound_tip": "Automaticky zapnout zvuk při přehrávání videa",
|
||||
"unmute_video_sound": "Automaticky zapnout zvuk při přehrávání videa"
|
||||
"unmute_video_sound": "Automaticky zapnout zvuk při přehrávání videa",
|
||||
"download": "stáhnout",
|
||||
"checking_for_updates": "Kontroluji aktualizace...",
|
||||
"new_version_found": "nalezena nová verze"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Slet dette opslag permanent? Dette kan ikke fortrydes.",
|
||||
"app_p2p_info": "Du kan altid forbinde til ethvert board, fuldt P2P, ingen kan stoppe dig. Indsæt blot dens adresse i søgefeltet ovenfor.",
|
||||
"unmute_video_sound_tip": "Slå automatisk lyd til under videoafspilning",
|
||||
"unmute_video_sound": "Slå automatisk lyd til under videoafspilning"
|
||||
"unmute_video_sound": "Slå automatisk lyd til under videoafspilning",
|
||||
"download": "hent",
|
||||
"checking_for_updates": "Tjekker for opdateringer...",
|
||||
"new_version_found": "ny version fundet"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Diesen Beitrag dauerhaft löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"app_p2p_info": "Du kannst dich jederzeit mit jedem Board verbinden, vollständig P2P, niemand kann dich aufhalten. Füge einfach seine Adresse in das Suchfeld oben ein.",
|
||||
"unmute_video_sound_tip": "Ton beim Videowiedergabe automatisch aktivieren",
|
||||
"unmute_video_sound": "Ton beim Videowiedergabe automatisch aktivieren"
|
||||
"unmute_video_sound": "Ton beim Videowiedergabe automatisch aktivieren",
|
||||
"download": "herunterladen",
|
||||
"checking_for_updates": "Suche nach Updates...",
|
||||
"new_version_found": "neue Version gefunden"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Διαγραφή οριστικά αυτής της ανάρτησης; Δεν είναι δυνατή η αναίρεση.",
|
||||
"app_p2p_info": "Μπορείς πάντα να συνδεθείς σε οποιοδήποτε board, πλήρως P2P, κανείς δεν μπορεί να σε σταματήσει. Απλά επικόλλησε τη διεύθυνσή του στο πεδίο αναζήτησης παραπάνω.",
|
||||
"unmute_video_sound_tip": "Αυτόματη ενεργοποίηση ήχου κατά την αναπαραγωγή βίντεο",
|
||||
"unmute_video_sound": "Αυτόματη ενεργοποίηση ήχου κατά την αναπαραγωγή βίντεο"
|
||||
"unmute_video_sound": "Αυτόματη ενεργοποίηση ήχου κατά την αναπαραγωγή βίντεο",
|
||||
"download": "λήψη",
|
||||
"checking_for_updates": "Έλεγχος για ενημερώσεις...",
|
||||
"new_version_found": "βρέθηκε νέα έκδοση"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"archived": "Archived",
|
||||
"loading_archive": "Loading archive...",
|
||||
"thread_archived": "Thread archived.",
|
||||
"view": "View"
|
||||
"view": "View",
|
||||
"download": "download",
|
||||
"checking_for_updates": "checking for updates...",
|
||||
"new_version_found": "new version found"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "¿Eliminar esta publicación permanentemente? Esto no se puede deshacer.",
|
||||
"app_p2p_info": "Siempre puedes conectarte a cualquier tablón, totalmente P2P, nadie puede impedírtelo. Simplemente pega su dirección en el campo de búsqueda de arriba.",
|
||||
"unmute_video_sound_tip": "Activar el sonido automáticamente al reproducir vídeo",
|
||||
"unmute_video_sound": "Activar el sonido automáticamente al reproducir vídeo"
|
||||
"unmute_video_sound": "Activar el sonido automáticamente al reproducir vídeo",
|
||||
"download": "descargar",
|
||||
"checking_for_updates": "Buscando actualizaciones...",
|
||||
"new_version_found": "nueva versión encontrada"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "این پست را برای همیشه حذف کن؟ این کار قابل بازگشت نیست.",
|
||||
"app_p2p_info": "همیشه میتوانید به هر بوردی متصل شوید، کاملاً P2P، هیچکس نمیتواند جلوی شما را بگیرد. کافی است آدرس آن را در فیلد جستجوی بالا بچسبانید.",
|
||||
"unmute_video_sound_tip": "غیرفعال کردن بیصدا بهطور خودکار برای پخش ویدیو",
|
||||
"unmute_video_sound": "غیرفعال کردن بیصدا بهطور خودکار برای پخش ویدیو"
|
||||
"unmute_video_sound": "غیرفعال کردن بیصدا بهطور خودکار برای پخش ویدیو",
|
||||
"download": "بارگیری",
|
||||
"checking_for_updates": "در حال بررسی بهروزرسانیها...",
|
||||
"new_version_found": "نسخه جدید یافت شد"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Poistetaanko tämä julkaisu pysyvästi? Tätä ei voi perua.",
|
||||
"app_p2p_info": "Voit aina yhdistää mihin tahansa boardiin, täysin P2P, kukaan ei voi estää sinua. Liitä vain sen osoite yllä olevaan hakukenttään.",
|
||||
"unmute_video_sound_tip": "Ota ääni automaattisesti päälle videotoiston yhteydessä",
|
||||
"unmute_video_sound": "Ota ääni automaattisesti päälle videotoiston yhteydessä"
|
||||
"unmute_video_sound": "Ota ääni automaattisesti päälle videotoiston yhteydessä",
|
||||
"download": "lataa",
|
||||
"checking_for_updates": "Tarkistetaan päivityksiä...",
|
||||
"new_version_found": "uusi versio löytyi"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Permanenteng tanggalin ang post na ito? Hindi na ito maibabalik.",
|
||||
"app_p2p_info": "Maaari mong palaging kumonekta sa anumang board, ganap na P2P, walang makakapigil sa iyo. I-paste lamang ang address nito sa search field sa itaas.",
|
||||
"unmute_video_sound_tip": "I-unmute ang tunog nang awtomatiko kapag nagpe-play ng video",
|
||||
"unmute_video_sound": "I-unmute ang tunog nang awtomatiko kapag nagpe-play ng video"
|
||||
"unmute_video_sound": "I-unmute ang tunog nang awtomatiko kapag nagpe-play ng video",
|
||||
"download": "i-download",
|
||||
"checking_for_updates": "Sinusuri ang mga update...",
|
||||
"new_version_found": "may bagong bersyon na nahanap"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Supprimer définitivement cette publication ? Cette action est irréversible.",
|
||||
"app_p2p_info": "Tu peux toujours te connecter à n'importe quel board, entièrement P2P, personne ne peut t'en empêcher. Colle simplement son adresse dans le champ de recherche ci-dessus.",
|
||||
"unmute_video_sound_tip": "Activer le son automatiquement lors de la lecture vidéo",
|
||||
"unmute_video_sound": "Activer le son automatiquement lors de la lecture vidéo"
|
||||
"unmute_video_sound": "Activer le son automatiquement lors de la lecture vidéo",
|
||||
"download": "télécharger",
|
||||
"checking_for_updates": "Vérification des mises à jour...",
|
||||
"new_version_found": "nouvelle version trouvée"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "למחוק את הפוסט הזה לצמיתות? לא ניתן לבטל פעולה זו.",
|
||||
"app_p2p_info": "תמיד תוכל להתחבר לכל לוח, במלואו P2P, אף אחד לא יכול לעצור אותך. פשוט הדבק את הכתובת שלו בשדה החיפוש למעלה.",
|
||||
"unmute_video_sound_tip": "הפעל אוטומטית את הצליל בעת השמעת וידאו",
|
||||
"unmute_video_sound": "הפעל אוטומטית את הצליל בעת השמעת וידאו"
|
||||
"unmute_video_sound": "הפעל אוטומטית את הצליל בעת השמעת וידאו",
|
||||
"download": "הורדה",
|
||||
"checking_for_updates": "בודק עדכונים...",
|
||||
"new_version_found": "נמצאה גרסה חדשה"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "क्या इस पोस्ट को स्थायी रूप से हटाया जाए? इसे पूर्ववत नहीं किया जा सकता।",
|
||||
"app_p2p_info": "आप किसी भी बोर्ड से हमेशा कनेक्ट हो सकते हैं, पूरी तरह P2P, कोई आपको रोक नहीं सकता। बस ऊपर दिए गए खोज क्षेत्र में उसका पता पेस्ट करें।",
|
||||
"unmute_video_sound_tip": "वीडियो चलाने पर स्वचालित रूप से साउंड अनम्यूट करें",
|
||||
"unmute_video_sound": "वीडियो चलाने पर स्वचालित रूप से साउंड अनम्यूट करें"
|
||||
"unmute_video_sound": "वीडियो चलाने पर स्वचालित रूप से साउंड अनम्यूट करें",
|
||||
"download": "डाउनलोड",
|
||||
"checking_for_updates": "अपडेट जाँच रहा है...",
|
||||
"new_version_found": "नया संस्करण मिला"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Végleg törölni ezt a bejegyzést? Ez nem vonható vissza.",
|
||||
"app_p2p_info": "Bármikor csatlakozhatsz bármely boardhoz, teljesen P2P, senki sem állíthat meg. Egyszerűen illeszd be a címét a fenti keresőmezőbe.",
|
||||
"unmute_video_sound_tip": "Hang automatikus bekapcsolása videólejátszáskor",
|
||||
"unmute_video_sound": "Hang automatikus bekapcsolása videólejátszáskor"
|
||||
"unmute_video_sound": "Hang automatikus bekapcsolása videólejátszáskor",
|
||||
"download": "letöltés",
|
||||
"checking_for_updates": "Frissítések keresése...",
|
||||
"new_version_found": "új verzió található"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Hapus permanen postingan ini? Tindakan ini tidak dapat dibatalkan.",
|
||||
"app_p2p_info": "Anda selalu dapat terhubung ke board mana pun, sepenuhnya P2P, tidak ada yang bisa menghentikan Anda. Cukup tempel alamatnya di kolom pencarian di atas.",
|
||||
"unmute_video_sound_tip": "Aktifkan suara secara otomatis saat memutar video",
|
||||
"unmute_video_sound": "Aktifkan suara secara otomatis saat memutar video"
|
||||
"unmute_video_sound": "Aktifkan suara secara otomatis saat memutar video",
|
||||
"download": "unduh",
|
||||
"checking_for_updates": "Memeriksa pembaruan...",
|
||||
"new_version_found": "versi baru ditemukan"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Eliminare definitivamente questo post? Non è possibile annullare questa operazione.",
|
||||
"app_p2p_info": "Puoi sempre connetterti a qualsiasi board, completamente P2P, nessuno può fermarti. Basta incollare il suo indirizzo nel campo di ricerca sopra.",
|
||||
"unmute_video_sound_tip": "Attiva l'audio automaticamente durante la riproduzione video",
|
||||
"unmute_video_sound": "Attiva l'audio automaticamente durante la riproduzione video"
|
||||
"unmute_video_sound": "Attiva l'audio automaticamente durante la riproduzione video",
|
||||
"download": "scarica",
|
||||
"checking_for_updates": "Controllo aggiornamenti...",
|
||||
"new_version_found": "nuova versione trovata"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "この投稿を完全に削除しますか?元に戻せません。",
|
||||
"app_p2p_info": "いつでもどのボードにも接続できます。完全にP2Pなので、誰にも止められません。上の検索欄にアドレスを貼り付けるだけです。",
|
||||
"unmute_video_sound_tip": "動画再生時に自動で音声をオンにする",
|
||||
"unmute_video_sound": "動画再生時に自動で音声をオンにする"
|
||||
"unmute_video_sound": "動画再生時に自動で音声をオンにする",
|
||||
"download": "ダウンロード",
|
||||
"checking_for_updates": "更新を確認しています...",
|
||||
"new_version_found": "新しいバージョンが見つかりました"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "이 게시물을 영구적으로 삭제하시겠습니까? 취소할 수 없습니다.",
|
||||
"app_p2p_info": "언제든 어떤 보드에도 연결할 수 있습니다. 완전 P2P라 아무도 당신을 막을 수 없습니다. 위 검색창에 주소를 붙여넣기만 하면 됩니다.",
|
||||
"unmute_video_sound_tip": "동영상 재생 시 자동으로 소리 켜기",
|
||||
"unmute_video_sound": "동영상 재생 시 자동으로 소리 켜기"
|
||||
"unmute_video_sound": "동영상 재생 시 자동으로 소리 켜기",
|
||||
"download": "다운로드",
|
||||
"checking_for_updates": "업데이트 확인 중...",
|
||||
"new_version_found": "새 버전을 찾았습니다"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "हे पोस्ट कायमचे हटवायचे? हे पूर्ववत करता येणार नाही.",
|
||||
"app_p2p_info": "तुम्ही नेहमी कोणत्याही बोर्डशी कनेक्ट होऊ शकता, पूर्णपणे P2P, कोणीही तुम्हाला थांबवू शकत नाही. वरील शोध क्षेत्रात त्याचा पत्ता पेस्ट करा.",
|
||||
"unmute_video_sound_tip": "व्हिडिओ प्ले करताना आवाज स्वयंचलितपणे अनम्यूट करा",
|
||||
"unmute_video_sound": "व्हिडिओ प्ले करताना आवाज स्वयंचलितपणे अनम्यूट करा"
|
||||
"unmute_video_sound": "व्हिडिओ प्ले करताना आवाज स्वयंचलितपणे अनम्यूट करा",
|
||||
"download": "डाउनलोड",
|
||||
"checking_for_updates": "अपडेट तपासत आहे...",
|
||||
"new_version_found": "नवीन आवृत्ती सापडली"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Dit bericht permanent verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||
"app_p2p_info": "Je kunt altijd verbinding maken met elk board, volledig P2P, niemand kan je tegenhouden. Plak gewoon het adres in het zoekveld hierboven.",
|
||||
"unmute_video_sound_tip": "Geluid automatisch aanzetten bij videoweergave",
|
||||
"unmute_video_sound": "Geluid automatisch aanzetten bij videoweergave"
|
||||
"unmute_video_sound": "Geluid automatisch aanzetten bij videoweergave",
|
||||
"download": "downloaden",
|
||||
"checking_for_updates": "Controleren op updates...",
|
||||
"new_version_found": "nieuwe versie gevonden"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Slette dette innlegget permanent? Dette kan ikke angres.",
|
||||
"app_p2p_info": "Du kan alltid koble til hvilket som helst board, fullt P2P, ingen kan stoppe deg. Lim bare inn adressen i søkefeltet ovenfor.",
|
||||
"unmute_video_sound_tip": "Slå på lyd automatisk ved videospilling",
|
||||
"unmute_video_sound": "Slå på lyd automatisk ved videospilling"
|
||||
"unmute_video_sound": "Slå på lyd automatisk ved videospilling",
|
||||
"download": "last ned",
|
||||
"checking_for_updates": "Sjekker etter oppdateringer...",
|
||||
"new_version_found": "ny versjon funnet"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Usunąć ten post na stałe? Tej operacji nie można cofnąć.",
|
||||
"app_p2p_info": "Zawsze możesz połączyć się z dowolnym boardem, w pełni P2P, nikt nie może cię powstrzymać. Wklej po prostu jego adres w pole wyszukiwania powyżej.",
|
||||
"unmute_video_sound_tip": "Automatycznie włączaj dźwięk podczas odtwarzania wideo",
|
||||
"unmute_video_sound": "Automatycznie włączaj dźwięk podczas odtwarzania wideo"
|
||||
"unmute_video_sound": "Automatycznie włączaj dźwięk podczas odtwarzania wideo",
|
||||
"download": "pobierz",
|
||||
"checking_for_updates": "Sprawdzanie aktualizacji...",
|
||||
"new_version_found": "znaleziono nową wersję"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Excluir esta publicação permanentemente? Isso não pode ser desfeito.",
|
||||
"app_p2p_info": "Podes sempre conectar a qualquer board, totalmente P2P, ninguém te pode impedir. Basta colar o endereço no campo de pesquisa acima.",
|
||||
"unmute_video_sound_tip": "Ativar o som automaticamente na reprodução de vídeo",
|
||||
"unmute_video_sound": "Ativar o som automaticamente na reprodução de vídeo"
|
||||
"unmute_video_sound": "Ativar o som automaticamente na reprodução de vídeo",
|
||||
"download": "baixar",
|
||||
"checking_for_updates": "Verificando atualizações...",
|
||||
"new_version_found": "nova versão encontrada"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Șterge definitiv această postare? Această acțiune nu poate fi anulată.",
|
||||
"app_p2p_info": "Poți oricând să te conectezi la orice board, complet P2P, nimeni nu te poate opri. Pur și simplu lipește adresa în câmpul de căutare de mai sus.",
|
||||
"unmute_video_sound_tip": "Activează automat sunetul la redarea videoclipului",
|
||||
"unmute_video_sound": "Activează automat sunetul la redarea videoclipului"
|
||||
"unmute_video_sound": "Activează automat sunetul la redarea videoclipului",
|
||||
"download": "descarcă",
|
||||
"checking_for_updates": "Se verifică actualizările...",
|
||||
"new_version_found": "versiune nouă găsită"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Удалить этот пост навсегда? Это действие нельзя отменить.",
|
||||
"app_p2p_info": "Ты всегда можешь подключиться к любому борду, полностью P2P, никто не сможет тебя остановить. Просто вставь его адрес в поле поиска выше.",
|
||||
"unmute_video_sound_tip": "Автоматически включать звук при воспроизведении видео",
|
||||
"unmute_video_sound": "Автоматически включать звук при воспроизведении видео"
|
||||
"unmute_video_sound": "Автоматически включать звук при воспроизведении видео",
|
||||
"download": "скачать",
|
||||
"checking_for_updates": "Проверка обновлений...",
|
||||
"new_version_found": "найдена новая версия"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Fshini këtë postim përgjithmonë? Kjo nuk mund të zhbëhet.",
|
||||
"app_p2p_info": "Mund të lidhesh gjithmonë me çdo board, plotësisht P2P, askush nuk mund të të ndalojë. Thjesht ngjite adresën e tij në fushën e kërkimit më sipër.",
|
||||
"unmute_video_sound_tip": "Aktivizo zërin automatikisht gjatë riprodhimit të videos",
|
||||
"unmute_video_sound": "Aktivizo zërin automatikisht gjatë riprodhimit të videos"
|
||||
"unmute_video_sound": "Aktivizo zërin automatikisht gjatë riprodhimit të videos",
|
||||
"download": "shkarko",
|
||||
"checking_for_updates": "Duke kontrolluar për përditësime...",
|
||||
"new_version_found": "u gjet version i ri"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Radera detta inlägg permanent? Detta kan inte ångras.",
|
||||
"app_p2p_info": "Du kan alltid ansluta till vilket board som helst, helt P2P, ingen kan stoppa dig. Klistra bara in dess adress i sökfältet ovan.",
|
||||
"unmute_video_sound_tip": "Aktivera ljud automatiskt vid videouppspelning",
|
||||
"unmute_video_sound": "Aktivera ljud automatiskt vid videouppspelning"
|
||||
"unmute_video_sound": "Aktivera ljud automatiskt vid videouppspelning",
|
||||
"download": "ladda ner",
|
||||
"checking_for_updates": "Söker efter uppdateringar...",
|
||||
"new_version_found": "ny version hittades"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "ఈ పోస్ట్ను శాశ్వతంగా తొలగించాలా? దీన్ని రద్దు చేయలేము.",
|
||||
"app_p2p_info": "మీరు ఎప్పుడైనా ఏ బోర్డ్కైనా కనెక్ట్ అవ్వవచ్చు, పూర్తిగా P2P, ఎవరూ మిమ్మల్ని ఆపలేరు. పైన ఉన్న సెర్చ్ ఫీల్డ్లో దాని అడ్రస్ పేస్ట్ చేయండి.",
|
||||
"unmute_video_sound_tip": "వీడియో ప్లే చేసేటప్పుడు స్వయంచాలకంగా సౌండ్ అన్-మ్యూట్ చేయండి",
|
||||
"unmute_video_sound": "వీడియో ప్లే చేసేటప్పుడు స్వయంచాలకంగా సౌండ్ అన్-మ్యూట్ చేయండి"
|
||||
"unmute_video_sound": "వీడియో ప్లే చేసేటప్పుడు స్వయంచాలకంగా సౌండ్ అన్-మ్యూట్ చేయండి",
|
||||
"download": "డౌన్లోడ్",
|
||||
"checking_for_updates": "నవీకరణలను తనిఖీ చేస్తోంది...",
|
||||
"new_version_found": "కొత్త వెర్షన్ కనుగొనబడింది"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "ลบกระทู้นี้อย่างถาวรหรือไม่? การดำเนินการนี้ไม่สามารถยกเลิกได้",
|
||||
"app_p2p_info": "คุณสามารถเชื่อมต่อกับบอร์ดใดก็ได้ตลอดเวลา แบบ P2P เต็มรูปแบบ ไม่มีใครหยุดคุณได้ แค่วางที่อยู่ของมันในช่องค้นหาด้านบน",
|
||||
"unmute_video_sound_tip": "เปิดเสียงอัตโนมัติเมื่อเล่นวิดีโอ",
|
||||
"unmute_video_sound": "เปิดเสียงอัตโนมัติเมื่อเล่นวิดีโอ"
|
||||
"unmute_video_sound": "เปิดเสียงอัตโนมัติเมื่อเล่นวิดีโอ",
|
||||
"download": "ดาวน์โหลด",
|
||||
"checking_for_updates": "กำลังตรวจสอบการอัปเดต...",
|
||||
"new_version_found": "พบเวอร์ชันใหม่"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Bu gönderiyi kalıcı olarak silmek istiyor musunuz? Bu işlem geri alınamaz.",
|
||||
"app_p2p_info": "Her zaman herhangi bir boarda bağlanabilirsin, tamamen P2P, kimse seni durduramaz. Sadece adresini yukarıdaki arama alanına yapıştır.",
|
||||
"unmute_video_sound_tip": "Video oynatırken sesi otomatik aç",
|
||||
"unmute_video_sound": "Video oynatırken sesi otomatik aç"
|
||||
"unmute_video_sound": "Video oynatırken sesi otomatik aç",
|
||||
"download": "indir",
|
||||
"checking_for_updates": "Güncellemeler kontrol ediliyor...",
|
||||
"new_version_found": "yeni sürüm bulundu"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Видалити цей пост назавжди? Цю дію не можна скасувати.",
|
||||
"app_p2p_info": "Ти завжди можеш підключитися до будь-якого борду, повністю P2P, ніхто не зможе тебе зупинити. Просто встав його адресу в поле пошуку вище.",
|
||||
"unmute_video_sound_tip": "Автоматично вмикати звук під час відтворення відео",
|
||||
"unmute_video_sound": "Автоматично вмикати звук під час відтворення відео"
|
||||
"unmute_video_sound": "Автоматично вмикати звук під час відтворення відео",
|
||||
"download": "завантажити",
|
||||
"checking_for_updates": "Перевірка оновлень...",
|
||||
"new_version_found": "знайдено нову версію"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "کیا اس پوسٹ کو مستقل طور پر حذف کریں؟ یہ واپس نہیں لیا جا سکتا۔",
|
||||
"app_p2p_info": "آپ ہمیشہ کسی بھی بورڈ سے جڑ سکتے ہیں، مکمل طور پر P2P، کوئی آپ کو روک نہیں سکتا۔ اوپر والے سرچ فیلڈ میں اس کا پتہ پیسٹ کریں۔",
|
||||
"unmute_video_sound_tip": "ویڈیو پلے ہونے پر آواز خود بخود آن کریں",
|
||||
"unmute_video_sound": "ویڈیو پلے ہونے پر آواز خود بخود آن کریں"
|
||||
"unmute_video_sound": "ویڈیو پلے ہونے پر آواز خود بخود آن کریں",
|
||||
"download": "ڈاؤن لوڈ",
|
||||
"checking_for_updates": "اپڈیٹس چیک کیے جا رہے ہیں...",
|
||||
"new_version_found": "نیا ورژن ملا"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "Xóa bài viết này vĩnh viễn? Hành động này không thể hoàn tác.",
|
||||
"app_p2p_info": "Bạn luôn có thể kết nối với bất kỳ board nào, hoàn toàn P2P, không ai có thể ngăn cản bạn. Chỉ cần dán địa chỉ của nó vào ô tìm kiếm phía trên.",
|
||||
"unmute_video_sound_tip": "Tự động bật tiếng khi phát video",
|
||||
"unmute_video_sound": "Tự động bật tiếng khi phát video"
|
||||
"unmute_video_sound": "Tự động bật tiếng khi phát video",
|
||||
"download": "tải xuống",
|
||||
"checking_for_updates": "Đang kiểm tra cập nhật...",
|
||||
"new_version_found": "đã tìm thấy phiên bản mới"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,8 @@
|
||||
"delete_post_confirm": "永久删除此帖子?此操作无法撤销。",
|
||||
"app_p2p_info": "你随时可以连接到任何看板,完全 P2P,没人能阻止你。只需将其地址粘贴到上方搜索框中即可。",
|
||||
"unmute_video_sound_tip": "视频播放时自动开启声音",
|
||||
"unmute_video_sound": "视频播放时自动开启声音"
|
||||
"unmute_video_sound": "视频播放时自动开启声音",
|
||||
"download": "下载",
|
||||
"checking_for_updates": "正在检查更新...",
|
||||
"new_version_found": "发现新版本"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDirectory, '..');
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const createTempWorkspace = async (prefix) => fs.promises.mkdtemp(path.join(os.tmpdir(), `${prefix}-`));
|
||||
|
||||
const logStep = (message) => {
|
||||
console.log(`[app-update-e2e] ${message}`);
|
||||
};
|
||||
|
||||
const runCommand = (command, args, options = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
logStep(`$ ${command} ${args.join(' ')}`);
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd || repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...options.env,
|
||||
},
|
||||
stdio: options.captureOutput ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
if (options.captureOutput) {
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
}
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(stderr.trim() || `${command} exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
|
||||
const waitFor = async (predicate, { timeoutMs = 120000, intervalMs = 1000, description = 'condition' } = {}) => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const result = await predicate();
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${description}`);
|
||||
};
|
||||
|
||||
const findFirstMatchingPath = async (rootPath, matcher) => {
|
||||
const entries = await fs.promises.readdir(rootPath, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(rootPath, entry.name);
|
||||
if (matcher(entryPath, entry)) {
|
||||
return entryPath;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const nestedMatch = await findFirstMatchingPath(entryPath, matcher);
|
||||
if (nestedMatch) {
|
||||
return nestedMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const copyPath = async (sourcePath, targetPath) => {
|
||||
await fs.promises.rm(targetPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
await fs.promises.mkdir(path.dirname(targetPath), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
if (process.platform === 'darwin' && sourcePath.endsWith('.app')) {
|
||||
await runCommand('/usr/bin/ditto', [sourcePath, targetPath]);
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.promises.cp(sourcePath, targetPath, {
|
||||
recursive: true,
|
||||
});
|
||||
};
|
||||
|
||||
const startFixtureServer = async () => {
|
||||
const state = {
|
||||
version: null,
|
||||
assets: [],
|
||||
};
|
||||
|
||||
const server = http.createServer(async (request, response) => {
|
||||
const requestUrl = new URL(request.url || '/', `http://${request.headers.host || '127.0.0.1'}`);
|
||||
|
||||
response.setHeader('Access-Control-Allow-Origin', '*');
|
||||
response.setHeader('Access-Control-Allow-Headers', '*');
|
||||
response.setHeader('Access-Control-Allow-Methods', 'GET,OPTIONS');
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.pathname === '/releases/latest') {
|
||||
if (!state.version || state.assets.length === 0) {
|
||||
response.writeHead(503, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
});
|
||||
response.end(JSON.stringify({ error: 'fixture release not configured' }));
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
tag_name: `v${state.version}`,
|
||||
html_url: `${requestUrl.origin}/releases/v${state.version}`,
|
||||
assets: state.assets.map((asset) => ({
|
||||
name: asset.name,
|
||||
browser_download_url: `${requestUrl.origin}/assets/${asset.name}`,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.pathname.startsWith('/releases/')) {
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
});
|
||||
response.end('<!doctype html><title>Fixture Release</title><p>Fixture release page</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.pathname.startsWith('/assets/')) {
|
||||
const assetName = decodeURIComponent(requestUrl.pathname.slice('/assets/'.length));
|
||||
const matchedAsset = state.assets.find((asset) => asset.name === assetName);
|
||||
|
||||
if (!matchedAsset) {
|
||||
response.writeHead(404, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
});
|
||||
response.end('asset not found');
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
fs.createReadStream(matchedAsset.filePath).pipe(response);
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(404, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
});
|
||||
response.end('not found');
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
server.on('error', reject);
|
||||
server.listen(0, '0.0.0.0', resolve);
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Could not resolve fixture server port');
|
||||
}
|
||||
|
||||
return {
|
||||
port: address.port,
|
||||
setRelease(version, assets) {
|
||||
state.version = version;
|
||||
state.assets = assets;
|
||||
logStep(`fixture server release set to v${version} with assets: ${assets.map((asset) => asset.name).join(', ')}`);
|
||||
},
|
||||
async close() {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export { copyPath, createTempWorkspace, findFirstMatchingPath, logStep, repoRoot, runCommand, sleep, startFixtureServer, waitFor };
|
||||
@@ -0,0 +1,239 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createTempWorkspace, logStep, repoRoot, runCommand, sleep, startFixtureServer, waitFor } from './app-update-e2e-helpers.mjs';
|
||||
|
||||
const OLD_VERSION = '0.7.1';
|
||||
const NEW_VERSION = '0.7.3';
|
||||
const OLD_VERSION_CODE = '701';
|
||||
const NEW_VERSION_CODE = '703';
|
||||
const PACKAGE_NAME = 'fivechan.android';
|
||||
const ACTIVITY_NAME = `${PACKAGE_NAME}/.MainActivity`;
|
||||
const AVD_NAME = 'fivechan-test-api35';
|
||||
const SETTINGS_HASH = '#/all/settings#interface-settings';
|
||||
|
||||
const getAndroidSdkRoot = () => process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
||||
|
||||
const getSdkToolPath = (relativePath) => {
|
||||
const sdkRoot = getAndroidSdkRoot();
|
||||
if (!sdkRoot) {
|
||||
throw new Error('ANDROID_HOME or ANDROID_SDK_ROOT must be set');
|
||||
}
|
||||
|
||||
return path.join(sdkRoot, relativePath);
|
||||
};
|
||||
|
||||
const adb = async (args, options = {}) => runCommand(getSdkToolPath('platform-tools/adb'), args, options);
|
||||
|
||||
const buildAndroidDebugApk = async ({ version, versionCode, fixturePort }) => {
|
||||
await runCommand('corepack', ['yarn', 'build'], {
|
||||
env: {
|
||||
VITE_APP_VERSION: version,
|
||||
VITE_APP_UPDATE_ALLOWED_DOWNLOAD_HOSTS: '10.0.2.2',
|
||||
VITE_APP_UPDATE_RELEASE_API_URL: `http://10.0.2.2:${fixturePort}/releases/latest`,
|
||||
VITE_E2E_START_HASH: SETTINGS_HASH,
|
||||
},
|
||||
});
|
||||
await runCommand('npx', ['cap', 'sync', 'android']);
|
||||
await runCommand('./gradlew', ['assembleDebug', `-PAPP_VERSION_CODE=${versionCode}`, `-PAPP_VERSION_NAME=${version}`], {
|
||||
cwd: path.join(repoRoot, 'android'),
|
||||
});
|
||||
|
||||
return path.join(repoRoot, 'android', 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk');
|
||||
};
|
||||
|
||||
const ensureEmulatorRunning = async () => {
|
||||
const devicesOutput = await adb(['devices'], { captureOutput: true });
|
||||
const existingEmulator = devicesOutput.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith('emulator-') && line.endsWith('\tdevice'));
|
||||
|
||||
if (existingEmulator) {
|
||||
return existingEmulator.split('\t')[0];
|
||||
}
|
||||
|
||||
const avdManagerPath = getSdkToolPath('cmdline-tools/latest/bin/avdmanager');
|
||||
const emulatorPath = getSdkToolPath('emulator/emulator');
|
||||
const avdList = await runCommand(avdManagerPath, ['list', 'avd'], {
|
||||
captureOutput: true,
|
||||
});
|
||||
|
||||
if (!avdList.stdout.includes(`Name: ${AVD_NAME}`)) {
|
||||
await runCommand('/bin/sh', ['-lc', `echo "no" | "${avdManagerPath}" create avd --name "${AVD_NAME}" --package "system-images;android-35;google_apis;arm64-v8a" --device pixel_6 --force`]);
|
||||
}
|
||||
|
||||
const emulatorLogPath = path.join(os.tmpdir(), `fivechan-emulator-${Date.now()}.log`);
|
||||
logStep(`starting emulator, log: ${emulatorLogPath}`);
|
||||
runCommand('/bin/sh', ['-lc', `"${emulatorPath}" -avd "${AVD_NAME}" -no-boot-anim -no-snapshot-save -netdelay none -netspeed full > "${emulatorLogPath}" 2>&1 &`]);
|
||||
|
||||
await adb(['wait-for-device']);
|
||||
await waitFor(
|
||||
async () => {
|
||||
const result = await adb(['shell', 'getprop', 'sys.boot_completed'], {
|
||||
captureOutput: true,
|
||||
});
|
||||
return result.stdout.replace(/\s+/g, '') === '1';
|
||||
},
|
||||
{
|
||||
timeoutMs: 180000,
|
||||
intervalMs: 2000,
|
||||
description: 'the Android emulator to finish booting',
|
||||
},
|
||||
);
|
||||
|
||||
await adb(['shell', 'settings', 'put', 'global', 'window_animation_scale', '0']);
|
||||
await adb(['shell', 'settings', 'put', 'global', 'transition_animation_scale', '0']);
|
||||
await adb(['shell', 'settings', 'put', 'global', 'animator_duration_scale', '0']);
|
||||
|
||||
const refreshedDevicesOutput = await adb(['devices'], { captureOutput: true });
|
||||
const emulatorLine = refreshedDevicesOutput.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith('emulator-') && line.endsWith('\tdevice'));
|
||||
|
||||
if (!emulatorLine) {
|
||||
throw new Error('Could not determine the running emulator serial');
|
||||
}
|
||||
|
||||
return emulatorLine.split('\t')[0];
|
||||
};
|
||||
|
||||
const readUiDump = async (serial) => {
|
||||
await adb(['-s', serial, 'shell', 'uiautomator', 'dump', '/sdcard/window_dump.xml']);
|
||||
const dumpResult = await adb(['-s', serial, 'shell', 'cat', '/sdcard/window_dump.xml'], {
|
||||
captureOutput: true,
|
||||
});
|
||||
return dumpResult.stdout;
|
||||
};
|
||||
|
||||
const findNodeBounds = (dumpXml, matcher) => {
|
||||
const nodePattern = /<node\b([^>]+?)\/>/g;
|
||||
|
||||
for (const match of dumpXml.matchAll(nodePattern)) {
|
||||
const attributes = match[1];
|
||||
const textMatch = attributes.match(/\btext="([^"]*)"/);
|
||||
const contentDescMatch = attributes.match(/\bcontent-desc="([^"]*)"/);
|
||||
const boundsMatch = attributes.match(/\bbounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/);
|
||||
const clickableMatch = attributes.match(/\bclickable="([^"]*)"/);
|
||||
const text = textMatch?.[1] || contentDescMatch?.[1] || '';
|
||||
|
||||
if (!boundsMatch || !matcher(text, clickableMatch?.[1] === 'true')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, left, top, right, bottom] = boundsMatch.map(Number);
|
||||
return {
|
||||
x: Math.floor((left + right) / 2),
|
||||
y: Math.floor((top + bottom) / 2),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const tapText = async (serial, { exactText, pattern, timeoutMs = 120000 }) => {
|
||||
const bounds = await waitFor(
|
||||
async () => {
|
||||
const dumpXml = await readUiDump(serial);
|
||||
return findNodeBounds(dumpXml, (text, clickable) => clickable && (exactText ? text === exactText : pattern.test(text)));
|
||||
},
|
||||
{
|
||||
timeoutMs,
|
||||
intervalMs: 2000,
|
||||
description: `UI text ${exactText || pattern}`,
|
||||
},
|
||||
);
|
||||
|
||||
await adb(['-s', serial, 'shell', 'input', 'tap', `${bounds.x}`, `${bounds.y}`]);
|
||||
};
|
||||
|
||||
const waitForText = async (serial, matcher, description) =>
|
||||
waitFor(
|
||||
async () => {
|
||||
const dumpXml = await readUiDump(serial);
|
||||
if (typeof matcher === 'string') {
|
||||
return dumpXml.includes(`text="${matcher}"`) || dumpXml.includes(`content-desc="${matcher}"`);
|
||||
}
|
||||
|
||||
return matcher.test(dumpXml);
|
||||
},
|
||||
{
|
||||
timeoutMs: 120000,
|
||||
intervalMs: 2000,
|
||||
description,
|
||||
},
|
||||
);
|
||||
|
||||
const waitForInstalledVersion = async (serial, version) =>
|
||||
waitFor(
|
||||
async () => {
|
||||
const packageInfo = await adb(['-s', serial, 'shell', 'dumpsys', 'package', PACKAGE_NAME], {
|
||||
captureOutput: true,
|
||||
});
|
||||
return packageInfo.stdout.includes(`versionName=${version}`);
|
||||
},
|
||||
{
|
||||
timeoutMs: 180000,
|
||||
intervalMs: 3000,
|
||||
description: `installed package version ${version}`,
|
||||
},
|
||||
);
|
||||
|
||||
const main = async () => {
|
||||
const workspace = await createTempWorkspace('android-update-e2e');
|
||||
const fixtureServer = await startFixtureServer();
|
||||
|
||||
try {
|
||||
const oldApkSourcePath = await buildAndroidDebugApk({
|
||||
version: OLD_VERSION,
|
||||
versionCode: OLD_VERSION_CODE,
|
||||
fixturePort: fixtureServer.port,
|
||||
});
|
||||
const oldApkPath = path.join(workspace, `5chan-v${OLD_VERSION}.apk`);
|
||||
await fs.promises.copyFile(oldApkSourcePath, oldApkPath);
|
||||
|
||||
const newApkSourcePath = await buildAndroidDebugApk({
|
||||
version: NEW_VERSION,
|
||||
versionCode: NEW_VERSION_CODE,
|
||||
fixturePort: fixtureServer.port,
|
||||
});
|
||||
const newApkPath = path.join(workspace, `5chan-v${NEW_VERSION}.apk`);
|
||||
await fs.promises.copyFile(newApkSourcePath, newApkPath);
|
||||
|
||||
fixtureServer.setRelease(NEW_VERSION, [
|
||||
{
|
||||
name: path.basename(newApkPath),
|
||||
filePath: newApkPath,
|
||||
},
|
||||
]);
|
||||
|
||||
const serial = await ensureEmulatorRunning();
|
||||
await adb(['-s', serial, 'uninstall', PACKAGE_NAME]).catch(() => undefined);
|
||||
await adb(['-s', serial, 'install', '-r', oldApkPath]);
|
||||
await adb(['-s', serial, 'shell', 'appops', 'set', PACKAGE_NAME, 'REQUEST_INSTALL_PACKAGES', 'allow']);
|
||||
await adb(['-s', serial, 'shell', 'am', 'start', '-n', ACTIVITY_NAME]);
|
||||
|
||||
await waitForText(serial, 'Check', 'the settings update check button');
|
||||
await tapText(serial, { exactText: 'Check' });
|
||||
await waitForText(serial, 'Download', 'the download button after update detection');
|
||||
await waitForText(serial, `v${NEW_VERSION}`, 'the release version link text');
|
||||
await tapText(serial, { exactText: 'Download' });
|
||||
await tapText(serial, {
|
||||
pattern: /^(Install|Update)$/i,
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
await waitForInstalledVersion(serial, NEW_VERSION);
|
||||
|
||||
await adb(['-s', serial, 'shell', 'am', 'force-stop', PACKAGE_NAME]);
|
||||
await adb(['-s', serial, 'shell', 'am', 'start', '-n', ACTIVITY_NAME]);
|
||||
await waitForText(serial, `v${NEW_VERSION}`, 'the relaunched app version text');
|
||||
|
||||
logStep(`android update e2e passed on ${serial}: package ${PACKAGE_NAME} updated to ${NEW_VERSION}`);
|
||||
} finally {
|
||||
await sleep(1000);
|
||||
await fixtureServer.close();
|
||||
}
|
||||
};
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,136 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { _electron as electron } from 'playwright';
|
||||
import { copyPath, createTempWorkspace, findFirstMatchingPath, logStep, repoRoot, runCommand, sleep, startFixtureServer, waitFor } from './app-update-e2e-helpers.mjs';
|
||||
|
||||
const OLD_VERSION = '0.7.1';
|
||||
const NEW_VERSION = '0.7.3';
|
||||
const SETTINGS_HASH = '#/all/settings#interface-settings';
|
||||
|
||||
const findPackagedMacApp = async () => {
|
||||
const outDirectory = path.join(repoRoot, 'out');
|
||||
return findFirstMatchingPath(outDirectory, (entryPath, entry) => entry.isDirectory() && entryPath.endsWith('.app'));
|
||||
};
|
||||
|
||||
const buildPackagedApp = async ({ version, fixturePort }) => {
|
||||
await runCommand('corepack', ['yarn', 'electron:package'], {
|
||||
env: {
|
||||
VITE_APP_VERSION: version,
|
||||
VITE_APP_UPDATE_ALLOWED_DOWNLOAD_HOSTS: '127.0.0.1',
|
||||
VITE_APP_UPDATE_RELEASE_API_URL: `http://127.0.0.1:${fixturePort}/releases/latest`,
|
||||
VITE_E2E_START_HASH: SETTINGS_HASH,
|
||||
},
|
||||
});
|
||||
|
||||
const packagedAppPath = await findPackagedMacApp();
|
||||
if (!packagedAppPath) {
|
||||
throw new Error('Could not find the packaged macOS app');
|
||||
}
|
||||
|
||||
return packagedAppPath;
|
||||
};
|
||||
|
||||
const zipMacApp = async (appPath, zipPath) => {
|
||||
await fs.promises.rm(zipPath, { force: true });
|
||||
await runCommand('/usr/bin/ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath]);
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('Electron update e2e is only implemented for macOS in this harness');
|
||||
}
|
||||
|
||||
const workspace = await createTempWorkspace('electron-update-e2e');
|
||||
const fixtureServer = await startFixtureServer();
|
||||
|
||||
try {
|
||||
const packagedOldAppPath = await buildPackagedApp({
|
||||
version: OLD_VERSION,
|
||||
fixturePort: fixtureServer.port,
|
||||
});
|
||||
|
||||
const installedAppPath = path.join(workspace, 'installed', '5chan.app');
|
||||
await copyPath(packagedOldAppPath, installedAppPath);
|
||||
|
||||
const packagedNewAppPath = await buildPackagedApp({
|
||||
version: NEW_VERSION,
|
||||
fixturePort: fixtureServer.port,
|
||||
});
|
||||
|
||||
const zippedNewAppPath = path.join(workspace, `5chan-darwin-arm64-v${NEW_VERSION}.zip`);
|
||||
await zipMacApp(packagedNewAppPath, zippedNewAppPath);
|
||||
|
||||
fixtureServer.setRelease(NEW_VERSION, [
|
||||
{
|
||||
name: path.basename(zippedNewAppPath),
|
||||
filePath: zippedNewAppPath,
|
||||
},
|
||||
]);
|
||||
|
||||
const versionMetadataPath = path.join(installedAppPath, 'Contents', 'Resources', 'app', 'build', 'version.json');
|
||||
const sandboxHome = path.join(workspace, 'home');
|
||||
const plebbitDataPath = path.join(sandboxHome, 'Library', 'Application Support', 'plebbit');
|
||||
await fs.promises.mkdir(plebbitDataPath, { recursive: true });
|
||||
await fs.promises.writeFile(path.join(plebbitDataPath, 'auth-key'), 'e2e-auth-key', 'utf8');
|
||||
|
||||
const electronApp = await electron.launch({
|
||||
executablePath: path.join(installedAppPath, 'Contents', 'MacOS', '5chan'),
|
||||
env: {
|
||||
...process.env,
|
||||
APP_UPDATE_ALLOWED_DOWNLOAD_HOSTS: '127.0.0.1',
|
||||
HOME: sandboxHome,
|
||||
},
|
||||
});
|
||||
|
||||
const firstWindow = await electronApp.firstWindow();
|
||||
await firstWindow.waitForLoadState('domcontentloaded');
|
||||
await firstWindow.getByRole('button', { name: 'Check' }).waitFor({
|
||||
timeout: 120000,
|
||||
});
|
||||
await firstWindow.getByRole('button', { name: 'Check' }).click();
|
||||
await firstWindow.getByRole('button', { name: 'Download' }).waitFor({
|
||||
timeout: 120000,
|
||||
});
|
||||
await firstWindow.getByText(`v${NEW_VERSION}`).waitFor({
|
||||
timeout: 120000,
|
||||
});
|
||||
await firstWindow.getByRole('button', { name: 'Download' }).click();
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
try {
|
||||
const payload = JSON.parse(await fs.promises.readFile(versionMetadataPath, 'utf8'));
|
||||
return payload.version === NEW_VERSION;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
timeoutMs: 180000,
|
||||
intervalMs: 2000,
|
||||
description: `the installed app bundle to be replaced with v${NEW_VERSION}`,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
const result = await runCommand('/usr/bin/pgrep', ['-f', path.join(installedAppPath, 'Contents', 'MacOS', '5chan')], {
|
||||
captureOutput: true,
|
||||
}).catch(() => null);
|
||||
return result?.stdout?.trim()?.length ? result.stdout.trim() : false;
|
||||
},
|
||||
{
|
||||
timeoutMs: 120000,
|
||||
intervalMs: 2000,
|
||||
description: 'the updated macOS app to relaunch',
|
||||
},
|
||||
);
|
||||
|
||||
logStep(`electron update e2e passed: ${installedAppPath} now contains version ${NEW_VERSION}`);
|
||||
} finally {
|
||||
await sleep(2000);
|
||||
await fixtureServer.close();
|
||||
}
|
||||
};
|
||||
|
||||
await main();
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
+87
-84
@@ -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')}:
|
||||
<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 />
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
Vendored
+1
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@ import { resolve } from 'path';
|
||||
import { readFileSync } from 'fs';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
const { version: appVersion } = 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;
|
||||
|
||||
function appVersionMetadataPlugin() {
|
||||
const payload = `${JSON.stringify({ version: appVersion })}\n`;
|
||||
|
||||
Reference in New Issue
Block a user