feat: in-app updater for Android (#885)

This commit is contained in:
Huang Xin
2025-04-14 20:21:58 +08:00
committed by GitHub
parent 5c23642ac0
commit 8efad90932
48 changed files with 720 additions and 366 deletions
+2 -2
View File
@@ -204,11 +204,11 @@ jobs:
jq --arg url "$universial_apk_url" \
--arg sig "$universial_sig" \
'.platforms["android-universal"] = {url: $url, signature: $sig}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
'.platforms["android-universal"] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
jq --arg url "$arm64_apk_url" \
--arg sig "$arm64_sig" \
'.platforms["android-arm64"] = {url: $url, signature: $sig}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
'.platforms["android-arm64"] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
echo "Uploading updated latest.json to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} latest.json --clobber
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.33",
"version": "0.9.35",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -282,5 +282,6 @@
"Download finished": "اكتمل التنزيل",
"DOWNLOAD & INSTALL": "تنزيل وتثبيت",
"Changelog:": "سجل التغييرات:",
"Redownload": "إعادة التنزيل"
"Redownload": "إعادة التنزيل",
"Software Update": "تحديث البرنامج"
}
@@ -282,5 +282,6 @@
"Download finished": "Download abgeschlossen",
"DOWNLOAD & INSTALL": "HERUNTERLADEN & INSTALLIEREN",
"Changelog:": "Änderungsprotokoll:",
"Redownload": "Neu herunterladen"
"Redownload": "Neu herunterladen",
"Software Update": "Software-Update"
}
@@ -282,5 +282,6 @@
"Download finished": "Η λήψη ολοκληρώθηκε",
"DOWNLOAD & INSTALL": "ΛΗΨΗ & ΕΓΚΑΤΑΣΤΑΣΗ",
"Changelog:": "Αλλαγές:",
"Redownload": "Επαναφόρτωση"
"Redownload": "Επαναφόρτωση",
"Software Update": "Ενημέρωση λογισμικού"
}
@@ -282,5 +282,6 @@
"Download finished": "Descarga finalizada",
"DOWNLOAD & INSTALL": "DESCARGAR E INSTALAR",
"Changelog:": "Notas de la versión:",
"Redownload": "Volver a descargar"
"Redownload": "Volver a descargar",
"Software Update": "Actualización de software"
}
@@ -282,5 +282,6 @@
"Download finished": "Téléchargement terminé",
"DOWNLOAD & INSTALL": "TÉLÉCHARGER ET INSTALLER",
"Changelog:": "Journal des modifications :",
"Redownload": "Télécharger à nouveau"
"Redownload": "Télécharger à nouveau",
"Software Update": "Mise à jour logicielle"
}
@@ -282,5 +282,6 @@
"Download finished": "डाउनलोड पूरा हुआ",
"DOWNLOAD & INSTALL": "डाउनलोड और इंस्टॉल करें",
"Changelog:": "चेंजलॉग:",
"Redownload": "री-डाउनलोड करें"
"Redownload": "री-डाउनलोड करें",
"Software Update": "सॉफ़्टवेयर अपडेट"
}
@@ -282,5 +282,6 @@
"Download finished": "Unduhan selesai",
"DOWNLOAD & INSTALL": "UNDUH & INSTAL",
"Changelog:": "Catatan Perubahan:",
"Redownload": "Unduh Ulang"
"Redownload": "Unduh Ulang",
"Software Update": "Pembaruan Perangkat Lunak"
}
@@ -282,5 +282,6 @@
"Download finished": "Download completato",
"DOWNLOAD & INSTALL": "SCARICA E INSTALLA",
"Changelog:": "Note di rilascio:",
"Redownload": "Scarica di nuovo"
"Redownload": "Scarica di nuovo",
"Software Update": "Aggiornamento software"
}
@@ -282,5 +282,6 @@
"Download finished": "ダウンロード完了",
"DOWNLOAD & INSTALL": "更新",
"Changelog:": "変更ログ:",
"Redownload": "再ダウンロード"
"Redownload": "再ダウンロード",
"Software Update": "ソフトウェアの更新"
}
@@ -282,5 +282,6 @@
"Download finished": "다운로드 완료",
"DOWNLOAD & INSTALL": "다운로드 및 설치",
"Changelog:": "변경 로그:",
"Redownload": "다시 다운로드"
"Redownload": "다시 다운로드",
"Software Update": "소프트웨어 업데이트"
}
@@ -282,5 +282,6 @@
"Download finished": "Pobieranie zakończone",
"DOWNLOAD & INSTALL": "POBIERZ I ZAINSTALUJ",
"Changelog:": "Dziennik zmian:",
"Redownload": "Pobierz ponownie"
"Redownload": "Pobierz ponownie",
"Software Update": "Aktualizacja oprogramowania"
}
@@ -282,5 +282,6 @@
"Download finished": "Download concluído",
"DOWNLOAD & INSTALL": "BAIXAR E INSTALAR",
"Changelog:": "Notas de versão:",
"Redownload": "Baixar novamente"
"Redownload": "Baixar novamente",
"Software Update": "Atualização de Software"
}
@@ -282,5 +282,6 @@
"Download finished": "Загрузка завершена",
"DOWNLOAD & INSTALL": "СКАЧАТЬ И УСТАНОВИТЬ",
"Changelog:": "Изменения:",
"Redownload": "Переустановить"
"Redownload": "Переустановить",
"Software Update": "Обновление программного обеспечения"
}
@@ -282,5 +282,6 @@
"Download finished": "İndirme tamamlandı",
"DOWNLOAD & INSTALL": "İNDİR & YÜKLE",
"Changelog:": "Değişiklik Günlüğü:",
"Redownload": "Yeniden İndir"
"Redownload": "Yeniden İndir",
"Software Update": "Yazılım Güncellemesi"
}
@@ -282,5 +282,6 @@
"Download finished": "Завантаження завершено",
"DOWNLOAD & INSTALL": "ЗАВАНТАЖИТИ І ВСТАНОВИТИ",
"Changelog:": "Журнал змін:",
"Redownload": "Завантажити знову"
"Redownload": "Завантажити знову",
"Software Update": "Оновлення програмного забезпечення"
}
@@ -282,5 +282,6 @@
"Download finished": "Tải xuống hoàn tất",
"DOWNLOAD & INSTALL": "TẢI XUỐNG & CÀI ĐẶT",
"Changelog:": "Thay đổi:",
"Redownload": "Tải lại"
"Redownload": "Tải lại",
"Software Update": "Cập nhật phần mềm"
}
@@ -282,5 +282,6 @@
"Download finished": "下载完成",
"DOWNLOAD & INSTALL": "下载并安装",
"Changelog:": "更新日志:",
"Redownload": "重新下载"
"Redownload": "重新下载",
"Software Update": "软件更新"
}
@@ -282,5 +282,6 @@
"Download finished": "下載完成",
"DOWNLOAD & INSTALL": "下載並安裝",
"Changelog:": "更新日誌:",
"Redownload": "重新下載"
"Redownload": "重新下載",
"Software Update": "軟體更新"
}
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
@@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.core.content.FileProvider
import androidx.browser.customtabs.CustomTabsIntent
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
@@ -24,6 +25,11 @@ class CopyURIRequestArgs {
var dst: String? = null
}
@InvokeArg
class InstallPackageRequestArgs {
var path: String? = null
}
@TauriPlugin
class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
private val implementation = NativeBridge()
@@ -74,4 +80,34 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
invoke.resolve(ret)
}
@Command
fun install_package(invoke: Invoke) {
val args = invoke.parseArgs(InstallPackageRequestArgs::class.java)
val ret = JSObject()
try {
val file = File(args.path ?: "")
if (file.exists()) {
val intent = Intent(Intent.ACTION_VIEW)
val apkUri = FileProvider.getUriForFile(activity, "${activity.packageName}.fileprovider", file)
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
val packageManager = activity.packageManager
val resolveInfos = packageManager.queryIntentActivities(intent, 0)
for (resolveInfo in resolveInfos) {
val packageName = resolveInfo.activityInfo.packageName
activity.grantUriPermission(packageName, apkUri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
activity.startActivity(intent)
ret.put("success", true)
} else {
ret.put("success", false)
ret.put("error", "File does not exist")
}
} catch (e: Exception) {
ret.put("success", false)
ret.put("error", e.message)
}
invoke.resolve(ret)
}
}
@@ -3,6 +3,7 @@ const COMMANDS: &[&str] = &[
"auth_with_custom_tab",
"copy_uri_to_path",
"use_background_audio",
"install_package",
];
fn main() {
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-install-package"
description = "Enables the install_package command without any pre-configured scope."
commands.allow = ["install_package"]
[[permission]]
identifier = "deny-install-package"
description = "Denies the install_package command without any pre-configured scope."
commands.deny = ["install_package"]
@@ -8,6 +8,7 @@ Default permissions for the plugin
- `allow-auth-with-custom-tab`
- `allow-copy-uri-to-path`
- `allow-use-background-audio`
- `allow-install-package`
## Permission Table
@@ -99,6 +100,32 @@ Denies the copy_uri_to_path command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-install-package`
</td>
<td>
Enables the install_package command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-install-package`
</td>
<td>
Denies the install_package command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-use-background-audio`
</td>
@@ -1,3 +1,9 @@
[default]
description = "Default permissions for the plugin"
permissions = ["allow-auth-with-safari", "allow-auth-with-custom-tab", "allow-copy-uri-to-path", "allow-use-background-audio"]
permissions = [
"allow-auth-with-safari",
"allow-auth-with-custom-tab",
"allow-copy-uri-to-path",
"allow-use-background-audio",
"allow-install-package",
]
@@ -330,6 +330,18 @@
"const": "deny-copy-uri-to-path",
"markdownDescription": "Denies the copy_uri_to_path command without any pre-configured scope."
},
{
"description": "Enables the install_package command without any pre-configured scope.",
"type": "string",
"const": "allow-install-package",
"markdownDescription": "Enables the install_package command without any pre-configured scope."
},
{
"description": "Denies the install_package command without any pre-configured scope.",
"type": "string",
"const": "deny-install-package",
"markdownDescription": "Denies the install_package command without any pre-configured scope."
},
{
"description": "Enables the use_background_audio command without any pre-configured scope.",
"type": "string",
@@ -343,10 +355,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`"
}
]
}
@@ -35,3 +35,11 @@ pub(crate) async fn use_background_audio<R: Runtime>(
) -> Result<()> {
app.native_bridge().use_background_audio(payload)
}
#[command]
pub(crate) async fn install_package<R: Runtime>(
app: AppHandle<R>,
payload: InstallPackageRequest,
) -> Result<InstallPackageResponse> {
app.native_bridge().install_package(payload)
}
@@ -29,4 +29,11 @@ impl<R: Runtime> NativeBridge<R> {
pub fn use_background_audio(&self, _payload: UseBackgroundAudioRequest) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn install_package(
&self,
_payload: InstallPackageRequest,
) -> crate::Result<InstallPackageResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -41,6 +41,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::auth_with_custom_tab,
commands::copy_uri_to_path,
commands::use_background_audio,
commands::install_package,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -55,3 +55,14 @@ impl<R: Runtime> NativeBridge<R> {
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn install_package(
&self,
payload: InstallPackageRequest,
) -> crate::Result<InstallPackageResponse> {
self.0
.run_mobile_plugin("install_package", payload)
.map_err(Into::into)
}
}
@@ -31,3 +31,16 @@ pub struct CopyURIResponse {
pub struct UseBackgroundAudioRequest {
pub enabled: bool,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallPackageRequest {
pub path: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallPackageResponse {
pub success: bool,
pub error: Option<String>,
}
+11 -7
View File
@@ -201,13 +201,17 @@ pub fn run() {
let app_handle = app.handle().clone();
app.listen("window-ready", move |_| {
app_handle.get_webview_window("main").unwrap()
.eval("window.__READEST_CLI_ACCESS = true; window.__READEST_UPDATER_ACCESS = true;")
app_handle
.get_webview_window("main")
.unwrap()
.eval("window.__READEST_CLI_ACCESS = true;")
.expect("Failed to set cli access config");
set_rounded_window(&app_handle, true);
#[cfg(target_os = "windows")]
if tauri_plugin_os::version() <= tauri_plugin_os::Version::from_string("10.0.19045") {
if tauri_plugin_os::version()
<= tauri_plugin_os::Version::from_string("10.0.19045")
{
set_rounded_window(&app_handle, false);
}
});
@@ -228,9 +232,7 @@ pub fn run() {
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default());
#[cfg(desktop)]
let win_builder = win_builder
.inner_size(800.0, 600.0)
.resizable(true);
let win_builder = win_builder.inner_size(800.0, 600.0).resizable(true);
#[cfg(target_os = "macos")]
let win_builder = win_builder
@@ -247,7 +249,9 @@ pub fn run() {
.title("Readest");
if cfg!(target_os = "windows") {
if tauri_plugin_os::version() <= tauri_plugin_os::Version::from_string("10.0.19045") {
if tauri_plugin_os::version()
<= tauri_plugin_os::Version::from_string("10.0.19045")
{
win_builder = win_builder.shadow(false);
} else {
win_builder = win_builder.shadow(true);
@@ -6,7 +6,7 @@ import { PiUserCircleCheck } from 'react-icons/pi';
import { MdCheck } from 'react-icons/md';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { hasUpdater, isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { useAuth } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
@@ -177,7 +177,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
onClick={toggleAutoImportBooksOnOpen}
/>
)}
{hasUpdater() && (
{appService?.hasUpdater && (
<MenuItem
label={_('Check Updates on Start')}
icon={isAutoCheckUpdates ? <MdCheck className='text-base-content' /> : undefined}
+4 -2
View File
@@ -13,7 +13,7 @@ import { eventDispatcher } from '@/utils/event';
import { ProgressPayload } from '@/utils/transfer';
import { throttle } from '@/utils/throttle';
import { parseOpenWithFiles } from '@/helpers/cli';
import { isTauriAppPlatform, hasUpdater } from '@/services/environment';
import { isTauriAppPlatform } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
@@ -34,6 +34,7 @@ import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { tauriHandleSetAlwaysOnTop, tauriQuitApp } from '@/utils/window';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { Toast } from '@/components/Toast';
import Spinner from '@/components/Spinner';
import LibraryHeader from './components/LibraryHeader';
@@ -100,7 +101,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useEffect(() => {
const doCheckAppUpdates = async () => {
if (hasUpdater() && settings.autoCheckUpdates) {
if (appService?.hasUpdater && settings.autoCheckUpdates) {
await checkForAppUpdates(_);
}
};
@@ -577,6 +578,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
/>
)}
<AboutWindow />
<UpdaterWindow />
<Toast />
</div>
);
@@ -8,12 +8,13 @@ import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
import { useThemeStore } from '@/store/themeStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { Toast } from '@/components/Toast';
import ReaderContent from './ReaderContent';
import { useSidebarStore } from '@/store/sidebarStore';
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { envConfig, appService } = useEnv();
@@ -53,6 +54,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
<Suspense>
<ReaderContent ids={ids} settings={settings} />
<AboutWindow />
<UpdaterWindow />
<Toast />
</Suspense>
</div>
@@ -42,13 +42,13 @@ const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete
<div className='mb-2 mt-4'>
<label className='mb-1 block text-sm font-medium'>{label}</label>
<div
className='border-base-300 overflow-hidden rounded border p-3'
className='border-base-300 overflow-hidden rounded border p-2'
style={{
backgroundColor: backgroundColor,
color: textColor,
}}
>
<p className='mb-2 whitespace-pre-line text-sm'>
<p className='mb-2 whitespace-pre-line text-xs'>
{_(
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare",
)}
+4 -3
View File
@@ -1,22 +1,23 @@
'use client';
import { useEffect } from 'react';
import { hasUpdater } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { useSettingsStore } from '@/store/settingsStore';
import { checkForAppUpdates } from '@/helpers/updater';
import Reader from './components/Reader';
export default function Page() {
const _ = useTranslation();
const { appService } = useEnv();
const { settings } = useSettingsStore();
useOpenWithBooks();
useEffect(() => {
const doCheckAppUpdates = async () => {
if (hasUpdater() && settings.autoCheckUpdates) {
if (appService?.hasUpdater && settings.autoCheckUpdates) {
await checkForAppUpdates(_);
}
};
+5 -233
View File
@@ -1,238 +1,8 @@
'use client';
import clsx from 'clsx';
import semver from 'semver';
import Image from 'next/image';
import { Suspense, useEffect, useState } from 'react';
import { check, Update } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import { fetch } from '@tauri-apps/plugin-http';
import { useTranslation } from '@/hooks/useTranslation';
import { useSearchParams } from 'next/navigation';
import { useTheme } from '@/hooks/useTheme';
import packageJson from '../../../package.json';
import { Suspense } from 'react';
import Spinner from '@/components/Spinner';
const CHANGELOG_URL =
'https://github.com/readest/readest/releases/latest/download/release-notes.json';
interface ReleaseNotes {
releases: Record<
string,
{
date: string;
notes: string[];
}
>;
}
interface Changelog {
version: string;
date: string;
notes: string[];
}
const UpdaterDialog = () => {
const _ = useTranslation();
const searchParams = useSearchParams();
const currentVersion = packageJson.version;
const version = searchParams?.get('version') || '';
const [update, setUpdate] = useState<Update | null>(null);
const [isMounted, setIsMounted] = useState(false);
const [newVersion, setNewVersion] = useState(version);
const [changelogs, setChangelogs] = useState<Changelog[]>([]);
const [progress, setProgress] = useState<number | null>(null);
const [contentLength, setContentLength] = useState<number | null>(null);
const [downloaded, setDownloaded] = useState<number | null>(null);
useEffect(() => {
const fetchChangelogs = async (
fromVersion: string,
toVersion: string,
): Promise<Changelog[]> => {
try {
const res = await fetch(CHANGELOG_URL);
const data: ReleaseNotes = await res.json();
const releases = data.releases;
const entries = Object.entries(releases)
.filter(([ver]) => semver.gt(ver, fromVersion) && semver.lte(ver, toVersion))
.sort(([a], [b]) => semver.rcompare(a, b))
.map(([version, info]) => ({
version,
date: new Date(info.date).toDateString(),
notes: info.notes,
}));
return entries;
} catch (error) {
console.error('Failed to fetch changelog:', error);
return [];
}
};
const parseNumberedList = (input: string): string[] => {
return input
.split(/(?:^|\s)\d+\.\s/)
.map((item) => item.trim())
.filter((item) => item.length > 0);
};
const checkForUpdates = async () => {
const update = await check();
setUpdate(update);
if (update) {
setNewVersion(update.version);
const changelogs = await fetchChangelogs(currentVersion, update.version);
if (changelogs.length > 0) {
setChangelogs(changelogs);
} else {
setChangelogs([
{
version: update.version,
date: new Date(update.date!).toDateString(),
notes: parseNumberedList(update.body ?? ''),
},
]);
}
}
};
checkForUpdates();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useTheme();
useEffect(() => {
setIsMounted(true);
}, []);
const handleDownloadInstall = async () => {
if (!update) {
return;
}
console.log(`found update ${update.version} from ${update.date} with notes ${update.body}`);
let downloaded = 0;
let contentLength = 0;
let lastLogged = 0;
setProgress(0);
await update.downloadAndInstall((event) => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength!;
setContentLength(contentLength);
console.log(`started downloading ${event.data.contentLength} bytes`);
break;
case 'Progress':
downloaded += event.data.chunkLength;
setDownloaded(downloaded);
const percent = Math.floor((downloaded / contentLength) * 100);
setProgress(percent);
if (downloaded - lastLogged >= 1 * 1024 * 1024) {
console.log(`downloaded ${downloaded} bytes from ${contentLength}`);
lastLogged = downloaded;
}
break;
case 'Finished':
console.log('download finished');
setProgress(100);
break;
}
});
console.log('update installed');
await relaunch();
};
if (!isMounted) {
return null;
}
return (
<div className='bg-base-300 flex min-h-screen items-center justify-center'>
<div className='card bg-base-200 w-full max-w-2xl !rounded-none shadow-xl'>
<div className='card-body'>
<div className='flex items-start gap-8'>
<div className='flex flex-col items-center justify-center gap-4'>
<div className='bg-base-200 flex items-center justify-center rounded-2xl shadow-md'>
<Image src='/icon.png' alt='Logo' className='h-20 w-20' width={64} height={64} />
</div>
</div>
<div className='text-base-content flex-1 text-sm'>
<h2 className='mb-2 font-bold'>{_('A new version of Readest is Available!')}</h2>
<p className='mb-2'>
{_('Readest {{newVersion}} is available (installed version {{currentVersion}}).', {
newVersion,
currentVersion,
})}
</p>
<p className='mb-2'>{_('Download and install now?')}</p>
<div className='flex w-full flex-row items-center justify-end gap-4'>
{progress !== null && (
<div className='flex flex-grow flex-col'>
<progress
className='progress my-1 h-4 w-full'
value={progress}
max='100'
></progress>
<p className='text-base-content/75 flex items-center justify-center text-sm'>
{progress < 100
? _('Downloading {{downloaded}} of {{contentLength}}', {
downloaded: downloaded
? `${Math.floor(downloaded / 1024 / 1024)} MB`
: '0 MB',
contentLength: contentLength
? `${Math.floor(contentLength / 1024 / 1024)} MB`
: '0 MB',
})
: _('Download finished')}
</p>
</div>
)}
<div className='card-actions'>
<button
className={clsx(
'btn btn-warning text-base-100 px-6 font-bold',
!update && 'btn-disabled',
)}
onClick={handleDownloadInstall}
>
{_('DOWNLOAD & INSTALL')}
</button>
</div>
</div>
</div>
</div>
</div>
<div className='text-base-content px-8 text-sm'>
<h3 className='mb-2 font-bold'>{_('Changelog:')}</h3>
<div className='bg-base-300 mb-4 rounded-lg px-4 pb-2 pt-4'>
{changelogs.length > 0 ? (
changelogs.map((entry: Changelog) => (
<div key={entry.version} className='mb-4'>
<h4 className='mb-2 font-bold'>
{entry.version} ({entry.date})
</h4>
<ul className='list-disc space-y-1 pl-6 text-sm'>
{entry.notes.map((note: string, i: number) => (
<li key={i}>{note}</li>
))}
</ul>
</div>
))
) : (
<div className='flex h-56 w-full flex-col gap-4'>
<div className='skeleton h-4 w-28'></div>
<div className='skeleton h-4 w-full'></div>
<div className='skeleton h-4 w-full'></div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
import { UpdaterContent } from '@/components/UpdaterWindow';
const UpdaterPage = () => {
return (
@@ -243,7 +13,9 @@ const UpdaterPage = () => {
</div>
}
>
<UpdaterDialog />
<div className='px-12 py-4'>
<UpdaterContent />
</div>
</Suspense>
);
};
+62 -68
View File
@@ -3,7 +3,6 @@ import Image from 'next/image';
import packageJson from '../../package.json';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { hasUpdater } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import { parseWebViewVersion } from '@/utils/ua';
import Dialog from './Dialog';
@@ -30,82 +29,77 @@ export const AboutWindow = () => {
const handleCheckUpdate = async () => {
const update = await checkForAppUpdates(_, false);
if (!update) {
if (update) {
setAboutDialogVisible(false);
} else {
setIsUpdated(true);
}
};
return (
<>
<Dialog
id='about_window'
isOpen={false}
title={_('About Readest')}
onClose={() => setAboutDialogVisible(false)}
boxClassName='sm:!w-96 sm:h-auto'
>
<div className='about-content flex h-full flex-col items-center justify-center'>
<div className='flex flex-col items-center px-8'>
<div className='mb-3'>
<Image src='/icon.png' alt='App Logo' className='h-24 w-24' width={64} height={64} />
</div>
<div className='flex select-text flex-col items-center'>
<h2 className='text-2xl font-bold'>Readest</h2>
<p className='text-neutral-content text-sm'>
{_('Version {{version}}', { version: packageJson.version })} {`(${browserInfo})`}
</p>
</div>
<div className='h-5'>
{hasUpdater() && !isUpdated && (
<span
className='badge badge-primary mt-2 cursor-pointer'
onClick={handleCheckUpdate}
>
{_('Check Update')}
</span>
)}
{isUpdated && (
<p className='text-neutral-content mt-2 text-xs'>
{_('Already the latest version')}
</p>
)}
</div>
<Dialog
id='about_window'
isOpen={false}
title={_('About Readest')}
onClose={() => setAboutDialogVisible(false)}
boxClassName='sm:!w-96 sm:h-auto'
>
<div className='about-content flex h-full flex-col items-center justify-center'>
<div className='flex flex-col items-center px-8'>
<div className='mb-3'>
<Image src='/icon.png' alt='App Logo' className='h-24 w-24' width={64} height={64} />
</div>
<div className='divider py-12 sm:py-2'></div>
<div className='flex flex-col items-center px-4 text-center' dir='ltr'>
<div className='flex select-text flex-col items-center'>
<h2 className='text-2xl font-bold'>Readest</h2>
<p className='text-neutral-content text-sm'>
© {new Date().getFullYear()} Bilingify LLC. All rights reserved.
</p>
<p className='text-neutral-content mt-2 text-xs'>
This software is licensed under the{' '}
<a
href='https://www.gnu.org/licenses/agpl-3.0.html'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
GNU Affero General Public License v3.0
</a>
. You are free to use, modify, and distribute this software under the terms of the
AGPL v3 license. Please see the license for more details.
</p>
<p className='text-neutral-content my-2 text-xs'>
Source code is available at{' '}
<a
href='https://github.com/readest/readest'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
GitHub
</a>
.
{_('Version {{version}}', { version: packageJson.version })} {`(${browserInfo})`}
</p>
</div>
<div className='h-5'>
{appService?.hasUpdater && !isUpdated && (
<span className='badge badge-primary mt-2 cursor-pointer' onClick={handleCheckUpdate}>
{_('Check Update')}
</span>
)}
{isUpdated && (
<p className='text-neutral-content mt-2 text-xs'>{_('Already the latest version')}</p>
)}
</div>
</div>
</Dialog>
</>
<div className='divider py-12 sm:py-2'></div>
<div className='flex flex-col items-center px-4 text-center' dir='ltr'>
<p className='text-neutral-content text-sm'>
© {new Date().getFullYear()} Bilingify LLC. All rights reserved.
</p>
<p className='text-neutral-content mt-2 text-xs'>
This software is licensed under the{' '}
<a
href='https://www.gnu.org/licenses/agpl-3.0.html'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
GNU Affero General Public License v3.0
</a>
. You are free to use, modify, and distribute this software under the terms of the AGPL
v3 license. Please see the license for more details.
</p>
<p className='text-neutral-content my-2 text-xs'>
Source code is available at{' '}
<a
href='https://github.com/readest/readest'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
GitHub
</a>
.
</p>
</div>
</div>
</Dialog>
);
};
@@ -0,0 +1,372 @@
import clsx from 'clsx';
import semver from 'semver';
import Image from 'next/image';
import { useEnv } from '@/context/EnvContext';
import { useEffect, useState } from 'react';
import { type as osType, arch as osArch } from '@tauri-apps/plugin-os';
import { check, Update } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import { fetch } from '@tauri-apps/plugin-http';
import { useTranslation } from '@/hooks/useTranslation';
import { useSearchParams } from 'next/navigation';
import { useTheme } from '@/hooks/useTheme';
import { tauriDownload } from '@/utils/transfer';
import { READEST_UPDATER_FILE, READEST_CHANGELOG_FILE } from '@/services/constants';
import packageJson from '../../package.json';
import Dialog from '@/components/Dialog';
import { installPackage } from '@/utils/bridge';
interface ReleaseNotes {
releases: Record<
string,
{
date: string;
notes: string[];
}
>;
}
interface Changelog {
version: string;
date: string;
notes: string[];
}
type DownloadEvent =
| {
event: 'Started';
data: {
contentLength?: number;
};
}
| {
event: 'Progress';
data: {
chunkLength: number;
};
}
| {
event: 'Finished';
};
interface GenericUpdate {
currentVersion: string;
version: string;
date?: string;
body?: string;
downloadAndInstall?(onEvent?: (progress: DownloadEvent) => void): Promise<void>;
}
export const UpdaterContent = ({ version }: { version?: string }) => {
const _ = useTranslation();
const { appService } = useEnv();
const searchParams = useSearchParams();
const currentVersion = packageJson.version;
const resolvedVersion = version ?? searchParams?.get('version') ?? '';
const [update, setUpdate] = useState<GenericUpdate | Update | null>(null);
const [isMounted, setIsMounted] = useState(false);
const [newVersion, setNewVersion] = useState(resolvedVersion);
const [changelogs, setChangelogs] = useState<Changelog[]>([]);
const [progress, setProgress] = useState<number | null>(null);
const [contentLength, setContentLength] = useState<number | null>(null);
const [downloaded, setDownloaded] = useState<number | null>(null);
useEffect(() => {
const checkDesktopUpdate = async () => {
const update = await check();
if (update) {
setUpdate(update);
}
};
const checkAndroidUpdate = async () => {
const response = await fetch(READEST_UPDATER_FILE);
const data = await response.json();
if (semver.gt(data.version, packageJson.version)) {
const OS_ARCH = osArch();
const platformKey = OS_ARCH === 'aarch64' ? 'android-arm64' : 'android-universal';
const arch = OS_ARCH === 'aarch64' ? 'arm64' : 'universal';
const downloadUrl = data.platforms[platformKey]?.url as string;
const cachePrefix = appService?.fs.getPrefix('Cache');
const apkFilePath = `${cachePrefix}/Readest_${data.version}_${arch}.apk`;
setUpdate({
currentVersion: packageJson.version,
version: data.version,
date: data.date,
body: data.notes,
downloadAndInstall: async (onEvent) => {
await new Promise<void>(async (resolve, reject) => {
let downloaded = 0;
let total = 0;
await tauriDownload(downloadUrl, apkFilePath, (progress) => {
if (!onEvent) return;
if (!total && progress.total) {
total = progress.total;
onEvent({
event: 'Started',
data: { contentLength: total },
});
} else if (downloaded > 0 && progress.progress === progress.total) {
console.log('APK downloaded to', apkFilePath);
onEvent?.({ event: 'Finished' });
setTimeout(() => {
resolve();
}, 1000);
}
onEvent({
event: 'Progress',
data: { chunkLength: progress.progress - downloaded },
});
downloaded = progress.progress;
}).catch((error) => {
console.error('Download failed:', error);
reject(error);
});
});
const res = await installPackage({
path: apkFilePath,
});
if (res.success) {
console.log('APK installed successfully');
} else {
console.error('Failed to install APK:', res.error);
}
},
} as GenericUpdate);
}
};
const checkForUpdates = async () => {
const OS_TYPE = osType();
if (['macos', 'windows', 'linux'].includes(OS_TYPE)) {
checkDesktopUpdate();
} else if (OS_TYPE === 'android') {
checkAndroidUpdate();
}
};
checkForUpdates();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const fetchChangelogs = async (
fromVersion: string,
toVersion: string,
): Promise<Changelog[]> => {
try {
const res = await fetch(READEST_CHANGELOG_FILE);
const data: ReleaseNotes = await res.json();
const releases = data.releases;
const entries = Object.entries(releases)
.filter(([ver]) => semver.gt(ver, fromVersion) && semver.lte(ver, toVersion))
.sort(([a], [b]) => semver.rcompare(a, b))
.map(([version, info]) => ({
version,
date: new Date(info.date).toDateString(),
notes: info.notes,
}));
return entries;
} catch (error) {
console.error('Failed to fetch changelog:', error);
return [];
}
};
const parseNumberedList = (input: string): string[] => {
return input
.split(/(?:^|\s)\d+\.\s/)
.map((item) => item.trim())
.filter((item) => item.length > 0);
};
const updateChangelogs = async (update: GenericUpdate) => {
setNewVersion(update.version);
const changelogs = await fetchChangelogs(currentVersion, update.version);
if (changelogs.length > 0) {
setChangelogs(changelogs);
} else {
setChangelogs([
{
version: update.version,
date: new Date(update.date!).toDateString(),
notes: parseNumberedList(update.body ?? ''),
},
]);
}
};
if (update) {
updateChangelogs(update);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [update]);
useTheme();
useEffect(() => {
setIsMounted(true);
}, []);
const handleDownloadInstall = async () => {
if (!update) {
return;
}
let downloaded = 0;
let contentLength = 0;
let lastLogged = 0;
setProgress(0);
await update.downloadAndInstall?.((event) => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength!;
setContentLength(contentLength);
break;
case 'Progress':
downloaded += event.data.chunkLength;
setDownloaded(downloaded);
const percent = Math.floor((downloaded / contentLength) * 100);
setProgress(percent);
if (downloaded - lastLogged >= 1 * 1024 * 1024) {
console.log(`downloaded ${downloaded} bytes from ${contentLength}`);
lastLogged = downloaded;
}
break;
case 'Finished':
console.log('download finished');
setProgress(100);
break;
}
});
console.log('package installed');
if (!appService?.isAndroidApp && process.env.NODE_ENV === 'production') {
await relaunch();
}
};
if (!isMounted) {
return null;
}
return (
<div className='bg-base-100 flex min-h-screen justify-center'>
<div className='flex w-full max-w-2xl flex-col gap-4'>
<div className='flex flex-col justify-center gap-4 sm:flex-row sm:items-start'>
<div className='flex items-center justify-center'>
<Image src='/icon.png' alt='Logo' className='h-20 w-20' width={64} height={64} />
</div>
<div className='text-base-content flex-grow text-sm'>
<h2 className='mb-4 text-center font-bold sm:text-start'>
{_('A new version of Readest is Available!')}
</h2>
<p className='mb-2'>
{_('Readest {{newVersion}} is available (installed version {{currentVersion}}).', {
newVersion,
currentVersion,
})}
</p>
<p className='mb-2'>{_('Download and install now?')}</p>
<div className='flex w-full flex-row items-center justify-end gap-4'>
{progress !== null && (
<div className='flex flex-grow flex-col'>
<progress
className='progress my-1 h-4 w-full'
value={progress}
max='100'
></progress>
<p className='text-base-content/75 flex items-center justify-center text-sm'>
{progress < 100
? _('Downloading {{downloaded}} of {{contentLength}}', {
downloaded: downloaded
? `${Math.floor(downloaded / 1024 / 1024)} MB`
: '0 MB',
contentLength: contentLength
? `${Math.floor(contentLength / 1024 / 1024)} MB`
: '0 MB',
})
: _('Download finished')}
</p>
</div>
)}
<div className='card-actions'>
<button
className={clsx(
'btn btn-warning text-base-100 px-6 font-bold',
!update && 'btn-disabled',
)}
onClick={handleDownloadInstall}
>
{_('DOWNLOAD & INSTALL')}
</button>
</div>
</div>
</div>
</div>
<div className='text-base-content text-sm'>
<h3 className='mb-2 font-bold'>{_('Changelog:')}</h3>
<div className='bg-base-300 mb-4 rounded-lg px-4 pb-2 pt-4'>
{changelogs.length > 0 ? (
changelogs.map((entry: Changelog) => (
<div key={entry.version} className='mb-4'>
<h4 className='mb-2 font-bold'>
{entry.version} ({entry.date})
</h4>
<ul className='list-disc space-y-1 ps-6 text-sm'>
{entry.notes.map((note: string, i: number) => (
<li key={i}>{note}</li>
))}
</ul>
</div>
))
) : (
<div className='flex h-56 w-full flex-col gap-4'>
<div className='skeleton h-4 w-28'></div>
<div className='skeleton h-4 w-full'></div>
<div className='skeleton h-4 w-full'></div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export const setUpdaterWindowVisible = (visible: boolean, newVersion?: string) => {
if (newVersion) {
localStorage.setItem('newVersion', newVersion);
window.dispatchEvent(new CustomEvent('new-version-detected'));
}
const dialog = document.getElementById('updater_window');
if (visible) {
(dialog as HTMLDialogElement)?.showModal();
} else {
(dialog as HTMLDialogElement)?.close();
}
};
export const UpdaterWindow = () => {
const _ = useTranslation();
const [newVersion, setNewVersion] = useState(localStorage.getItem('newVersion') || '');
useEffect(() => {
const handler = () => {
setNewVersion(localStorage.getItem('newVersion') || '');
};
window.addEventListener('new-version-detected', handler);
return () => window.removeEventListener('new-version-detected', handler);
}, []);
return (
<Dialog
id='updater_window'
isOpen={false}
title={_('Software Update')}
onClose={() => setUpdaterWindowVisible(false)}
boxClassName='sm:!w-[80%] sm:h-auto'
>
<UpdaterContent version={newVersion ?? undefined} />
</Dialog>
);
};
+50 -21
View File
@@ -1,35 +1,64 @@
import semver from 'semver';
import { check } from '@tauri-apps/plugin-updater';
import { type as osType } from '@tauri-apps/plugin-os';
import { fetch } from '@tauri-apps/plugin-http';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { CHECK_UPDATE_INTERVAL_SEC } from '@/services/constants';
import { TranslationFunc } from '@/hooks/useTranslation';
import { setUpdaterWindowVisible } from '@/components/UpdaterWindow';
import { CHECK_UPDATE_INTERVAL_SEC, READEST_UPDATER_FILE } from '@/services/constants';
import packageJson from '../../package.json';
const LAST_CHECK_KEY = 'lastAppUpdateCheck';
export const checkForAppUpdates = async (_: TranslationFunc, autoCheck = true) => {
const showUpdateWindow = (newVersion: string) => {
const win = new WebviewWindow('updater', {
url: `/updater?version=${newVersion}`,
title: 'Software Update',
width: 626,
height: 406,
center: true,
resizable: true,
});
win.once('tauri://created', () => {
console.log('new window created');
});
win.once('tauri://error', (e) => {
console.error('error creating window', e);
});
};
export const checkForAppUpdates = async (
_: TranslationFunc,
autoCheck = true,
): Promise<boolean> => {
const lastCheck = localStorage.getItem(LAST_CHECK_KEY);
const now = Date.now();
if (autoCheck && lastCheck && now - parseInt(lastCheck, 10) < CHECK_UPDATE_INTERVAL_SEC * 1000)
return;
return false;
localStorage.setItem(LAST_CHECK_KEY, now.toString());
console.log('Checking for updates');
const update = await check();
console.log('Update found', update);
if (update) {
const win = new WebviewWindow('updater', {
url: `/updater?version=${update.version}`,
title: 'Software Update',
width: 626,
height: 406,
center: true,
resizable: true,
});
win.once('tauri://created', () => {
console.log('new window created');
});
win.once('tauri://error', (e) => {
console.error('error creating window', e);
});
const OS_TYPE = osType();
if (['macos', 'windows', 'linux'].includes(OS_TYPE)) {
const update = await check();
if (update) {
showUpdateWindow(update.version);
}
return !!update;
} else if (OS_TYPE === 'android') {
try {
const response = await fetch(READEST_UPDATER_FILE);
const data = await response.json();
const isNewer = semver.gt(data.version, packageJson.version);
if (isNewer && ('android-arm64' in data.platforms || 'android-universal' in data.platforms)) {
setUpdaterWindowVisible(true, data.version);
}
return isNewer;
} catch (err) {
console.warn('Failed to fetch Android update info', err);
return false;
}
}
return update;
return false;
};
@@ -57,6 +57,7 @@ export abstract class BaseAppService implements AppService {
hasSafeAreaInset = false;
hasHaptics = false;
hasSysFontsList = false;
hasUpdater = false;
abstract fs: FileSystem;
@@ -468,6 +468,12 @@ export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web'
export const READEST_WEB_BASE_URL = 'https://web.readest.com';
export const GITHUB_LATEST_DOWNLOAD = 'https://github.com/readest/readest/releases/latest/download';
export const READEST_UPDATER_FILE = `${GITHUB_LATEST_DOWNLOAD}/latest.json`;
export const READEST_CHANGELOG_FILE = `${GITHUB_LATEST_DOWNLOAD}/release-notes.json`;
export const SYNC_PROGRESS_INTERVAL_SEC = 60;
export const SYNC_NOTES_INTERVAL_SEC = 10;
export const SYNC_BOOKS_INTERVAL_SEC = 10;
@@ -4,14 +4,11 @@ import { READEST_WEB_BASE_URL } from './constants';
declare global {
interface Window {
__READEST_CLI_ACCESS?: boolean;
__READEST_UPDATER_ACCESS?: boolean;
}
}
export const isTauriAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'tauri';
export const isWebAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web';
export const hasUpdater = () =>
window.__READEST_UPDATER_ACCESS === true && !process.env['NEXT_PUBLIC_DISABLE_UPDATER'];
export const hasCli = () => window.__READEST_CLI_ACCESS === true;
export const isPWA = () => window.matchMedia('(display-mode: standalone)').matches;
@@ -221,6 +221,7 @@ export class NativeAppService extends BaseAppService {
override hasSafeAreaInset = OS_TYPE === 'ios' || OS_TYPE === 'android';
override hasHaptics = OS_TYPE === 'ios' || OS_TYPE === 'android';
override hasSysFontsList = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
override hasUpdater = OS_TYPE !== 'ios' && !process.env['NEXT_PUBLIC_DISABLE_UPDATER'];
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
return resolvePath(fp, base);
+1
View File
@@ -34,6 +34,7 @@ export interface AppService {
hasSafeAreaInset: boolean;
hasHaptics: boolean;
hasSysFontsList: boolean;
hasUpdater: boolean;
isMobile: boolean;
isAppDataSandbox: boolean;
isAndroidApp: boolean;
+18
View File
@@ -14,6 +14,15 @@ export interface UseBackgroundAudioRequest {
enabled: boolean;
}
export interface InstallPackageRequest {
path: string;
}
export interface InstallPackageResponse {
success: boolean;
error?: string;
}
export async function copyURIToPath(request: CopyURIRequest): Promise<CopyURIResponse> {
const result = await invoke<CopyURIResponse>('plugin:native-bridge|copy_uri_to_path', {
payload: request,
@@ -27,3 +36,12 @@ export async function invokeUseBackgroundAudio(request: UseBackgroundAudioReques
payload: request,
});
}
export async function installPackage(
request: InstallPackageRequest,
): Promise<InstallPackageResponse> {
const result = await invoke<InstallPackageResponse>('plugin:native-bridge|install_package', {
payload: request,
});
return result;
}