Compare commits

...

110 Commits

Author SHA1 Message Date
Huang Xin c5b2feda48 release: version 0.9.35 (#888) 2025-04-14 17:50:39 +02:00
Huang Xin 59f875f590 feat: add books sorting by title and author in the library page, closes #540 (#887) 2025-04-14 17:38:41 +02:00
Huang Xin 8efad90932 feat: in-app updater for Android (#885) 2025-04-14 14:21:58 +02:00
Huang Xin 5c23642ac0 chore: generate Android package signature and download url in update file 2025-04-13 23:59:56 +08:00
Huang Xin ee6500cecb fix: books without cover images now can be synced across devices (#878) 2025-04-13 10:47:50 +02:00
Huang Xin f004d7b3ea release: version 0.9.33 (#875) 2025-04-13 02:31:13 +08:00
Huang Xin f7d53508a6 refactor: new updater dialog (#874) 2025-04-13 02:06:14 +08:00
Huang Xin 53e7f2526c fix: window maximized and fullscreen now don't conflict with each other (#872) 2025-04-12 13:42:36 +02:00
Huang Xin 8f0e2d1642 chore: show webview version info in the about window (#869) 2025-04-12 16:35:54 +08:00
Huang Xin c807f0ccc8 refactor: OAuth flow is now handled via ASWebAuthenticationSession on macOS (#866) 2025-04-12 12:49:41 +08:00
Huang Xin 6ba15319a8 fix: reverse punctuation transform when highlighting (#862) 2025-04-11 06:30:08 +02:00
Huang Xin 3ea3a50baa fix: handle file basename from content provider uri (#861) 2025-04-11 05:18:12 +02:00
Huang Xin ccd467ebcf feat: support separate header/footer visibility for paginated and scrolled modes (#859) 2025-04-11 03:40:26 +02:00
Huang Xin fe25c3e995 feat: support native sign-in-with-apple on macOS (#856) 2025-04-10 14:49:11 +02:00
Huang Xin d4af2d3ef5 feat: also add sans-serif CJK fonts (#853) 2025-04-09 16:03:24 +02:00
Huang Xin 6ff1edb67c fix: no padding top for bookmark ribbon (#851) 2025-04-09 13:03:23 +02:00
Huang Xin 3d2847e427 fix: RTL layout for the bottom configuration panel, closes #849 (#850) 2025-04-09 10:47:15 +02:00
Huang Xin ff4d8af054 release: version 0.9.32 (#848) 2025-04-09 06:01:55 +02:00
Huang Xin 612302f42f feat: add LXGW WenKai TC in CJK fonts list (#847) 2025-04-09 05:57:06 +02:00
Huang Xin a26607a154 fix: don't inline the font menu if more options are available (#846) 2025-04-09 05:32:24 +02:00
Huang Xin 939c7049ed fix: position the underline and squiggly highlight decoration at the middle between text lines (#845) 2025-04-09 05:26:22 +02:00
Huang Xin 0ead07e704 fix: don't reset all font-size and font color when override book style (#842) 2025-04-09 01:55:36 +02:00
Huang Xin f4908c4536 release: version 0.9.31 (#839) 2025-04-08 15:54:34 +02:00
Huang Xin 37951c2ebf feat: add book metadata search in the bookshelf, closes #100 (#838) 2025-04-08 15:37:18 +02:00
Huang Xin 915f222e08 doc: add a troubleshooting section in README (#837) 2025-04-08 18:05:22 +08:00
Huang Xin 9446df1b9c feat: add an option to configure default CJK fonts in CJK environments, closes #780 (#836) 2025-04-08 11:27:59 +02:00
Huang Xin bfcf89b093 fix: toggle the menus on the whole screen when Click-To-Flip is disabled, closes #831 (#834) 2025-04-08 08:09:46 +02:00
Huang Xin b0cf087d78 fix: support opening content URIs by copying to cache when direct access fails, closes #829 (#833) 2025-04-08 07:45:33 +02:00
Huang Xin 5863586766 feat: vertical layout for highlighting tools, closes #787 (#830) 2025-04-07 17:24:14 +02:00
Huang Xin 99182a1f9e feat: add configurable timeout to stop TTS playback automatically, closes #747 (#826) 2025-04-07 12:40:14 +02:00
Huang Xin 2599f40dfb feat: add option to keep window on top, closes #820 (#825) 2025-04-07 08:23:46 +02:00
Huang Xin aa66ba54c5 fix: set proper default theme mode, closes #823 (#824) 2025-04-07 07:16:31 +02:00
Huang Xin 4f0ef01a17 fix: tts now works in background in iOS, closes #547 (#822)
To enable background playback in Android, go to Settings > Apps & Notifications > Readest > Battery > Battery Optimization, and disable battery optimization for Readest.
2025-04-06 18:42:58 +02:00
Huang Xin 267e1656db release: version 0.9.30 (#817) 2025-04-05 11:39:37 +02:00
Huang Xin dcecda2984 fix: cache NativeFile with LRU, closes #797 (#816) 2025-04-05 11:27:07 +02:00
Huang Xin ce88f0229a fix: rounded reader widget only when sidebar is invisible , closes #809 (#815) 2025-04-05 10:08:39 +02:00
Huang Xin 316c82ea52 fix: override link color only when overriding font, closes #812 (#814) 2025-04-05 09:34:29 +02:00
Huang Xin 25be60ce2a chore: bump tauri to 2.4.1 (#813) 2025-04-05 07:40:33 +02:00
Huang Xin 4703eec045 fix: set correct filename in openFile API, closes #803 (#810) 2025-04-05 12:41:26 +08:00
Huang Xin ab22a83332 fix: open default file chooser on Android, closes #798 and closes #799 (#807) 2025-04-04 05:01:12 +02:00
Huang Xin a3d6ebe44d feat: add fixed storage quota for self-hosted instance, closes #805 (#806) 2025-04-04 03:48:43 +02:00
Huang Xin f8804b5d3c fix: revert font family, size and color when overrideFont is set, closes #792 (#804) 2025-04-03 17:15:19 +02:00
Huang Xin ee3785ad51 fix: hack to decode incorrectly encoded url in href (#796) 2025-04-02 07:35:00 +02:00
Huang Xin 5ba40cd2d6 release: version 0.9.29 (#790) 2025-04-01 18:27:15 +02:00
Huang Xin 6131180a31 fix: initiate OAuth using Custom Tabs on Android, closes #361 (#788) 2025-04-01 17:04:17 +02:00
Huang Xin 7ccf3d0632 refactor: read local file with fs plugin for Tauri App, closes #553 and closes #489 (#785) 2025-04-01 14:26:26 +02:00
Huang Xin 39fb7f759e fix: handle preflight on tauri apps (#776) 2025-03-31 03:38:47 +02:00
Huang Xin d89834a53d chore: balance translator load between auth and guest users (#771) 2025-03-30 16:35:13 +02:00
Huang Xin 3c4dd2a143 fix: fix translation api with retry (#770) 2025-03-30 15:59:57 +02:00
Huang Xin 847efb7082 fix: head bar may hide login buttons in auth page (#769) 2025-03-30 11:32:57 +02:00
Huang Xin 01db8f90fa fix: store the filepath for transient import, closes #760 (#768) 2025-03-30 10:27:45 +02:00
Huang Xin e70c46ff02 fix: only replace quote punctuations for vertical layout, closes #761 (#767) 2025-03-30 09:49:22 +02:00
Huang Xin 7cb1998ebb fix: soft keyboard in custom css textarea, closes #762 and closes #763 (#766) 2025-03-30 09:33:27 +02:00
Huang Xin f5fd37f40d release: version 0.9.28 (hotfix) (#759) 2025-03-29 19:46:09 +01:00
Huang Xin 2daee62b80 fix: dismiss highlighting tools for annotation taking (#758) 2025-03-29 19:40:37 +01:00
Huang Xin ffd179bb06 fix: TXT file correctly read and imported on desktop apps, closes #756 (#757) 2025-03-29 19:11:41 +01:00
Huang Xin 4acc0d8e12 feat: replace punctuation marks in vertical reading mode (#754) 2025-03-29 12:42:23 +01:00
Huang Xin a66786444f release: version 0.9.27 (#751) 2025-03-29 08:03:38 +01:00
Huang Xin 01ad18ca57 feat: global fulltext search shortcut with ctrl/cmd+f (#750) 2025-03-29 07:45:41 +01:00
Huang Xin a72c8f2391 refactor: rename plugin safari-auth to native-bridge (#749)
* fix: layout tweaks for mobile

* refactor: rename plugin safari-auth to native-bridge
2025-03-29 07:27:18 +01:00
Huang Xin f90177713a tts: avoid pauses after abbreviations, faster warming up, preferred voice ids (#742) 2025-03-28 13:25:08 +01:00
Huang Xin 041eb1b921 feat: support more languages in deepl translator (#740) 2025-03-28 07:31:34 +01:00
Huang Xin 105302d241 fix: don't crash with some malformed toc data for PDF, closes #518 (#739) 2025-03-28 06:54:32 +01:00
Huang Xin bd9c7b459c fix: avoid stale values in relocate handler, closes #736 (#738) 2025-03-28 06:33:51 +01:00
Huang Xin 1c122752bb fix: update downloadedAt field properly (#737) 2025-03-28 06:03:18 +01:00
Huang Xin 203ef8b986 feat: add reset password page, closes #700 (#731) 2025-03-28 03:52:04 +08:00
Huang Xin ceda806f77 fix: override book writing-mode (#730) 2025-03-27 15:53:35 +01:00
Huang Xin baac9dfa25 fix: unhardcode background color in non-default themes (#729) 2025-03-27 13:05:03 +01:00
Huang Xin 10aace0026 fix: avoid view glitch when initialize view settings, closes #713 (#728) 2025-03-27 12:40:41 +01:00
Huang Xin 3e38896078 feat: add option to swap click-to-flip area, closes #719 (#727) 2025-03-27 11:35:57 +01:00
Huang Xin 0fa53c5f15 fix: should listen single-instance event on Windows to open-with files, closes #724 (#726) 2025-03-27 09:11:03 +01:00
Xi Gou 2b6320b880 refactor: support both r2 and s3 storage (#718) 2025-03-27 07:00:27 +01:00
Huang Xin 7ba938ea25 fix: header layout for auth and user pages (#725) 2025-03-27 05:14:38 +01:00
Huang Xin 6d4ad34e32 release: version 0.9.26 (#723) 2025-03-26 17:44:26 +01:00
Huang Xin 3239ff0a80 feat: add LXGW WenKai to serif fonts list (#722) 2025-03-26 17:28:14 +01:00
Huang Xin 8b7e3c6f79 fix: textarea hidden behind keyboard in fullscreen Android app. closes #653 (#720) 2025-03-26 15:57:35 +01:00
Huang Xin ace4b78420 fix: uncrash scroll prev at start and scroll next at end (#712) 2025-03-26 12:42:39 +01:00
Huang Xin 39ac89fead fix: don't override bg color for img, closes #656 (#711) 2025-03-26 12:23:46 +01:00
Huang Xin 490fe7e70c ui: fix library header padding (#710) 2025-03-26 09:07:38 +01:00
Huang Xin 06a7a8ea82 feat: add option for transient import from file managers, closes #456 (#709) 2025-03-26 07:11:47 +01:00
Huang Xin b7f4a3503f doc: add TXT to supported formats list (#708)
* fix: set max width for annotation toolbar

* doc: add TXT to supported formats list
2025-03-26 11:45:56 +08:00
Huang Xin 835ed443a5 fix: style tweaks for Windows (#707) 2025-03-25 18:58:23 +01:00
Huang Xin 3f3c6983ba fix: also export newly created annotations (#704) 2025-03-25 06:26:11 +01:00
Huang Xin f22ce1c225 fix: access runtime variables in function invoke (#703) 2025-03-25 05:29:22 +01:00
Huang Xin 9990de112f chore: bump updates of next.js and tauri (#702) 2025-03-25 04:46:07 +01:00
Huang Xin ede37757db chore: support deploy in cloudflare with opennext and fix android build 2025-03-25 03:52:04 +08:00
Huang Xin 0c65d44bc9 chore: add android release in github actions (#695) 2025-03-25 01:57:05 +08:00
Huang Xin 07c9813ec1 fix: no background color for section and page info in vertical scroll mode (#694) 2025-03-24 07:13:38 +01:00
Huang Xin 03f0ee1f7d release: version 0.9.25 (#690) 2025-03-23 19:39:39 +01:00
Huang Xin ebc23c4ce3 feat: export annotations in markdown format, closes #152 (#689) 2025-03-23 19:33:05 +01:00
Huang Xin d098487cd1 fix: add metadata parsed from cbz zip comment, closes #639 (#688) 2025-03-23 18:18:08 +01:00
Huang Xin 14f2db730a fix: add option to override justify style in books, closes #671 (#687) 2025-03-23 16:59:45 +01:00
Huang Xin a79f301165 feat: add language preference options in settings (#686) 2025-03-23 16:39:58 +01:00
Huang Xin 05d086352f chore: add f-droid metadata (#682) 2025-03-23 08:59:34 +01:00
Huang Xin ef864a32c2 feat: action bar at the bottom on mobile devices (#681) 2025-03-23 08:56:57 +01:00
Huang Xin 0d93a13407 fix: format footer in popup window (#665) 2025-03-21 05:52:56 +01:00
Huang Xin ab0208b7ab fix: now img in paragraph won't get indented and default to override book layout (#657) 2025-03-20 17:10:26 +01:00
Huang Xin c52f3c0430 feat: support importing TXT files, closes #308 (#655) 2025-03-20 15:00:30 +01:00
Huang Xin accf6fb5c7 feat: snap dialog to preview effects of book settings adjustments (#646) 2025-03-19 14:23:52 +01:00
Huang Xin 18c2ce1274 fix: various fixes on layout settings and styles, closes #637 (#644) 2025-03-19 10:14:02 +01:00
Huang Xin 9a104b918c fix: dismiss popup footnotes on window resize, also closes #634 and closes #635 (#638) 2025-03-19 04:22:15 +01:00
Huang Xin 948303c43a release: version 0.9.23 (#633) 2025-03-18 17:13:43 +01:00
Huang Xin 82f303fcfe feat: responsive window size for popup footnotes, closes #538 (#632) 2025-03-18 17:00:07 +01:00
Huang Xin 359fa8df35 refactor: always save when changes are made with settings, also closes #626 (#630) 2025-03-18 12:58:01 +01:00
Huang Xin 72e6c058f5 fix: completed books now have 100% progress (#625) 2025-03-18 06:00:33 +01:00
Huang Xin 7648aa47b2 fix: don't reinit footnote handler when theme is changed, closes #619 (#621) 2025-03-17 17:47:31 +01:00
Huang Xin 48074f0f53 feat: add options to show/hide header and footer widgets in paginated and scrolled mode, also closes #602 (#620) 2025-03-17 15:30:12 +01:00
Huang Xin 66bd8d74f3 fix: also set color-scheme in iframe for Safari (#616) 2025-03-17 05:53:42 +01:00
Huang Xin 37f3c8b829 chore: fix node out of memory when building 2025-03-16 03:43:32 +08:00
Huang Xin 8775f90bc1 fix: refresh page when vertical writing mode is set (#615) 2025-03-15 20:07:54 +01:00
217 changed files with 11887 additions and 3117 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
run: cargo fmt --check
- name: Clippy Check
working-directory: apps/readest-app/src-tauri
run: cargo clippy -- -D warnings
run: cargo clippy -p Readest --no-deps -- -D warnings
build_web_app:
runs-on: ubuntu-latest
+105 -9
View File
@@ -56,27 +56,35 @@ jobs:
fail-fast: false
matrix:
config:
- os: ubuntu-latest
release: android
rust_target: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
- os: ubuntu-22.04
release: linux
arch: x86_64
rust_target: x86_64-unknown-linux-gnu
- os: ubuntu-22.04-arm
release: linux
arch: aarch64
rust_target: aarch64-unknown-linux-gnu
- os: macos-latest
release: macos
arch: aarch64
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
args: '--target universal-apple-darwin'
- os: windows-latest
release: windows
arch: x86_64
rust_target: x86_64-pc-windows-msvc
args: '--target x86_64-pc-windows-msvc'
- os: windows-latest
release: windows
arch: aarch64
rust_target: aarch64-pc-windows-msvc
args: '--target aarch64-pc-windows-msvc --bundles nsis'
runs-on: ${{ matrix.config.os }}
timeout-minutes: 30
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
@@ -94,6 +102,21 @@ jobs:
node-version: 22
cache: pnpm
- name: setup Java (for Android build only)
if: matrix.config.release == 'android'
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: setup Android SDK (for Android build only)
if: matrix.config.release == 'android'
uses: android-actions/setup-android@v3
- name: install NDK (for Android build only)
if: matrix.config.release == 'android'
run: sdkmanager "ndk;27.0.11902837"
- name: install dependencies
run: pnpm install
@@ -107,7 +130,13 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.config.os }}-cargo-${{ hashFiles('Cargo.lock') }}
key: ${{ matrix.config.os }}-${{ matrix.config.release }}-${{ matrix.config.arch }}-cargo
- name: install dependencies (ubuntu only)
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android'
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: create .env.local file for Next.js
run: |
@@ -116,17 +145,76 @@ jobs:
echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env.local
echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local
echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local
cp .env.local apps/readest-app/.env.local
- name: copy .env.local to apps/readest-app
run: cp .env.local apps/readest-app/.env.local
- name: install dependencies (ubuntu only)
if: contains(matrix.config.os, 'ubuntu')
- name: build and upload Android apks
if: matrix.config.release == 'android'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/27.0.11902837
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
cd apps/readest-app/
rm -rf src-tauri/gen/android
pnpm tauri android init
pnpm tauri icon ../../data/icons/readest-book.png
git checkout .
pushd src-tauri/gen/android
echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" > keystore.properties
echo "password=${{ secrets.ANDROID_KEY_PASSWORD }}" >> keystore.properties
base64 -d <<< "${{ secrets.ANDROID_KEY_BASE64 }}" > $RUNNER_TEMP/keystore.jks
echo "storeFile=$RUNNER_TEMP/keystore.jks" >> keystore.properties
popd
version=${{ needs.get-release.outputs.release_version }}
apk_path=src-tauri/gen/android/app/build/outputs/apk/universal/release
universial_apk=Readest_${version}_universal.apk
arm64_apk=Readest_${version}_arm64.apk
pnpm tauri android build
cp ${apk_path}/app-universal-release.apk $universial_apk
pnpm tauri android build -t aarch64
cp ${apk_path}/app-universal-release.apk $arm64_apk
echo "Uploading $universial_apk to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk --clobber
echo "Uploading $arm64_apk to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk --clobber
echo "Uploading signatures to GitHub release"
pnpm tauri signer sign $universial_apk
pnpm tauri signer sign $arm64_apk
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk.sig --clobber
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk.sig --clobber
- name: download and update latest.json for Android release
if: matrix.config.release == 'android'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd apps/readest-app/
curl -sL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json
version=${{ needs.get-release.outputs.release_version }}
universial_apk_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/Readest_${version}_universal.apk"
arm64_apk_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/Readest_${version}_arm64.apk"
universial_sig=$(cat Readest_${version}_universal.apk.sig)
arm64_sig=$(cat Readest_${version}_arm64.apk.sig)
jq --arg url "$universial_apk_url" \
--arg sig "$universial_sig" \
'.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"] = {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
- uses: tauri-apps/tauri-action@v0
if: matrix.config.release != 'android'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -137,12 +225,20 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
NODE_OPTIONS: '--max-old-space-size=8192'
with:
projectPath: apps/readest-app
releaseId: ${{ needs.get-release.outputs.release_id }}
releaseBody: ${{ needs.get-release.outputs.release_note }}
args: ${{ matrix.config.args || '' }}
- name: upload release notes to GitHub release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Uploading release notes to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} apps/readest-app/release-notes.json --clobber
- name: upload portable binaries (Windows only)
if: matrix.config.os == 'windows-latest'
env:
Generated
+1146 -1112
View File
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -1,3 +1,18 @@
[workspace]
members = ["apps/readest-app/src-tauri"]
members = [
"apps/readest-app/src-tauri",
"packages/tauri/crates/tauri",
]
resolver = "2"
[workspace.package]
authors = ["Bilingify LLC"]
homepage = "https://readest.com"
license = "AGPL-3.0"
repository = "https://github.com/readest/readest"
categories = []
edition = "2021"
rust-version = "1.77.2"
[patch.crates-io]
tauri = { path = "packages/tauri/crates/tauri" }
+38 -4
View File
@@ -26,7 +26,7 @@
<a href="#screenshots">Screenshots</a> •
<a href="#downloads">Downloads</a> •
<a href="#getting-started">Getting Started</a> •
<a href="#contributors">Contributors</a> •
<a href="#troubleshooting">Troubleshooting</a> •
<a href="#license">License</a>
</p>
@@ -42,7 +42,7 @@
| **Feature** | **Description** | **Status** |
| --------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------- |
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, PDF (experimental) | ✅ |
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, TXT, PDF (experimental) | ✅ |
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
@@ -54,6 +54,7 @@
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | ✅ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
## Planned Features
@@ -62,7 +63,6 @@
| **Feature** | **Description** | **Priority** |
| ------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🛠 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
@@ -199,6 +199,39 @@ nix develop ./ops#android # enter a dev shell for the android app
Please check the [wiki][link-gh-wiki] of this project for more information on development.
## Troubleshooting
### 1. Readest Wont Launch on Windows (Missing Edge WebView2 Runtime)
**Symptom**
- When you double-click readest.exe, nothing happens. No window appears, and Task Manager does not show the process.
- This can affect both the standard installer and the portable version.
**Cause**
- Microsoft Edge WebView2 Runtime is either missing, outdated, or improperly installed on your system. Readest depends on WebView2 to render the interface on Windows.
**How to Fix**
1. Check if WebView2 is installed
- Open “Add or Remove Programs” (a.k.a. Apps & features) on Windows. Look for “Microsoft Edge WebView2 Runtime.”
2. Install or Update WebView2
- Download the WebView2 Runtime directly from Microsoft: [link](https://developer.microsoft.com/en-us/microsoft-edge/webview2?form=MA13LH).
- If you prefer an offline installer, download the offline package and run it as an Administrator.
3. Re-run Readest
- After installing/updating WebView2, launch readest.exe again.
- If you still encounter problems, reboot your PC and try again.
**Additional Tips**
- If reinstalling once doesnt work, uninstall Edge WebView2 completely, then reinstall it with Administrator privileges.
- Verify your Windows installation has the latest updates from Microsoft.
**Still Stuck?**
- See Issue [readest/readest#358](https://github.com/readest/readest/issues/358) for further details, or head over to our [Discord][link-discord] server and open a support discussion with detailed logs of your environment and the steps youve taken.
## Contributors
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please **review our [contributing guidelines](CONTRIBUTING.md) before you start**. We also welcome you to join our [Discord][link-discord] community for either support or contributing guidance.
@@ -219,9 +252,10 @@ The following libraries and frameworks are used in this software:
- [zip.js](https://github.com/gildas-lormeau/zip.js), which is licensed under the BSD-3-Clause license.
- [fflate](https://github.com/101arrowz/fflate), which is MIT licensed.
- [PDF.js](https://github.com/mozilla/pdf.js), which is licensed under Apache License 2.0.
- [daisyUI](https://github.com/saadeghi/daisyui), which is MIT licensed.
- [next.js](https://github.com/vercel/next.js), which is MIT licensed.
- [react](https://github.com/facebook/react), which is MIT licensed.
- [react-icons](https://github.com/react-icons/react-icons), which has various open-source licenses.
- [react](https://github.com/facebook/react), which is MIT licensed.
- [tauri](https://github.com/tauri-apps/tauri), which is MIT licensed.
The following fonts are utilized in this software, either bundled within the application or provided through web fonts:
+11
View File
@@ -4,14 +4,25 @@ NEXT_PUBLIC_POSTHOG_HOST=YOUR_POSTHOG_HOST
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
NEXT_PUBLIC_STORAGE_FIXED_QUOTA=1073741824
SUPABASE_ADMIN_KEY=YOUR_SUPABASE_ADMIN_KEY
DEEPL_PRO_API_KEYS=YOUR_DEEPL_PRO_API_KEYS
DEEPL_FREE_API_KEYS=YOUR_DEEPL_FREE_API_KEYS
# r2, s3
NEXT_PUBLIC_OBJECT_STORAGE_TYPE=r2
R2_TOKEN_VALUE=YOUR_R2_TOKEN_VALUE
R2_ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY=YOUR_R2_SECRET_ACCESS_KEY
R2_BUCKET_NAME=YOUR_R2_BUCKET_NAME
R2_ACCOUNT_ID=YOUR_R2_ACCOUNT_ID
R2_REGION=YOUR_R2_REGION
S3_ENDPOINT=PLACE_HOLDER
S3_ACCESS_KEY_ID=PLACE_HOLDER
S3_SECRET_ACCESS_KEY=PLACE_HOLDER
S3_BUCKET_NAME=PLACE_HOLDER
S3_REGION=PLACE_HOLDER
+20 -15
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.22",
"version": "0.9.35",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -42,20 +42,21 @@
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.47.7",
"@tauri-apps/api": "2.3.0",
"@tauri-apps/api": "2.4.1",
"@tauri-apps/plugin-cli": "^2.2.0",
"@tauri-apps/plugin-deep-link": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-haptics": "^2.2.3",
"@tauri-apps/plugin-http": "^2.3.0",
"@tauri-apps/plugin-log": "^2.2.3",
"@tauri-apps/plugin-deep-link": "^2.2.1",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-fs": "^2.2.1",
"@tauri-apps/plugin-haptics": "^2.2.4",
"@tauri-apps/plugin-http": "^2.4.3",
"@tauri-apps/plugin-log": "^2.3.1",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-process": "^2.2.0",
"@tauri-apps/plugin-shell": "~2.2.0",
"@tauri-apps/plugin-updater": "^2.5.1",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "~2.2.1",
"@tauri-apps/plugin-updater": "^2.7.0",
"@zip.js/zip.js": "^2.7.53",
"aws4fetch": "^1.0.20",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"cssbeautify": "^0.3.1",
@@ -65,7 +66,7 @@
"i18next-http-backend": "^3.0.1",
"js-md5": "^0.8.3",
"jwt-decode": "^4.0.0",
"next": "15.2.2",
"next": "15.2.4",
"posthog-js": "^1.205.0",
"react": "19.0.0",
"react-color": "^2.19.3",
@@ -73,17 +74,20 @@
"react-i18next": "^15.2.0",
"react-icons": "^5.4.0",
"react-responsive": "^10.0.0",
"semver": "^7.7.1",
"tinycolor2": "^1.6.0",
"zustand": "5.0.1"
},
"devDependencies": {
"@tauri-apps/cli": "2.3.1",
"@opennextjs/cloudflare": "^0.5.12",
"@tauri-apps/cli": "2.4.1",
"@types/cors": "^2.8.17",
"@types/cssbeautify": "^0.3.5",
"@types/node": "^22.10.1",
"@types/react": "18.3.12",
"@types/react-color": "^3.0.13",
"@types/react-dom": "18.3.1",
"@types/semver": "^7.7.0",
"@types/tinycolor2": "^1.4.6",
"autoprefixer": "^10.4.20",
"cpx2": "^8.0.0",
@@ -99,6 +103,7 @@
"postcss-nested": "^7.0.2",
"raw-loader": "^4.0.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
"typescript": "^5.7.2",
"wrangler": "^4.4.0"
}
}
@@ -126,8 +126,6 @@
"Apply to All Books": "تطبيق على جميع الكتب",
"Apply to This Book": "تطبيق على هذا الكتاب",
"Unable to fetch the translation. Try again later.": "تعذر جلب الترجمة. حاول مرة أخرى لاحقًا.",
"Update Now!": "قم بالتحديث الآن!",
"Update": "تحديث",
"Check Update": "التحقق من التحديث",
"Already the latest version": "الإصدار الحالي هو الإصدار الأحدث بالفعل",
"Book Details": "تفاصيل الكتاب",
@@ -152,7 +150,6 @@
"Text Indent": "مسافة بادئة للنص",
"Paragraph Margin": "هامش الفقرة",
"Override Book Layout": "تجاوز تخطيط الكتاب",
"Add to Group": "إضافة إلى مجموعة",
"Untitled Group": "مجموعة بدون عنوان",
"Group Books": "تجميع الكتب",
"Remove From Group": "إزالة من المجموعة",
@@ -244,5 +241,54 @@
"Contrast": "التباين",
"Sunset": "غروب الشمس",
"Double Border": "حدود مزدوجة",
"Border Color": "لون الحدود"
"Border Color": "لون الحدود",
"Border Frame": "إطار الحدود",
"Show Header": "عرض الرأس",
"Show Footer": "عرض التذييل",
"Small": "صغير",
"Large": "كبير",
"Auto": "تلقائي",
"Language": "اللغة",
"No annotations to export": "لا توجد تعليقات للتصدير",
"Author": "المؤلف",
"Exported from Readest": "تم التصدير من ريديست",
"Highlights & Annotations": "التمييزات والتعليقات",
"Note": "ملاحظة",
"Copied to clipboard": "تم النسخ إلى الحافظة",
"Export Annotations": "تصدير التعليقات",
"Auto Import on File Open": "استيراد تلقائي عند فتح الملف",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "تبديل منطقة التقليب بالنقر",
"No chapters detected.": "لم يتم التعرف على فصول.",
"Failed to parse the EPUB file.": "فشل في تحليل ملف EPUB.",
"This book format is not supported.": "هذا تنسيق الكتاب غير مدعوم.",
"Unable to fetch the translation. Please log in first and try again.": "تعذر جلب الترجمة. يرجى تسجيل الدخول أولاً ثم المحاولة مرة أخرى.",
"Group": "تجميع",
"Always on Top": "دائمًا في المقدمة",
"No Timeout": "لا مهلة",
"{{value}} minute": "{{value}} دقيقة",
"{{value}} minutes": "{{value}} دقائق",
"{{value}} hour": "{{value}} ساعة",
"{{value}} hours": "{{value}} ساعات",
"CJK Font": "خط CJK",
"Clear Search": "مسح البحث",
"Header & Footer": "الرأس والتذييل",
"Apply also in Scrolled Mode": "تطبيق أيضًا في وضع التمرير",
"A new version of Readest is Available!": "إصدار جديد من ريديست متاح!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "إصدار ريديست {{newVersion}} متاح (الإصدار المثبت {{currentVersion}}).",
"Download and install now?": "هل ترغب في تنزيل وتثبيت الآن؟",
"Downloading {{downloaded}} of {{contentLength}}": "جارٍ تنزيل {{downloaded}} من {{contentLength}}",
"Download finished": "اكتمل التنزيل",
"DOWNLOAD & INSTALL": "تنزيل وتثبيت",
"Changelog:": "سجل التغييرات:",
"Redownload": "إعادة التنزيل",
"Software Update": "تحديث البرنامج",
"Title": "العنوان",
"Date Read": "تاريخ القراءة",
"Date Added": "تاريخ الإضافة",
"Format": "التنسيق",
"Ascending": "تصاعدي",
"Descending": "تنازلي",
"Sort by...": "ترتيب حسب..."
}
@@ -126,8 +126,6 @@
"Apply to All Books": "Auf alle Bücher anwenden",
"Apply to This Book": "Auf dieses Buch anwenden",
"Unable to fetch the translation. Try again later.": "Übersetzung konnte nicht abgerufen werden. Versuchen Sie es später erneut.",
"Update Now!": "Jetzt aktualisieren!",
"Update": "Aktualisieren",
"Check Update": "Aktualisierung prüfen",
"Already the latest version": "Bereits die neueste Version",
"Book Details": "Buchdetails",
@@ -152,7 +150,6 @@
"Text Indent": "Texteinzug",
"Paragraph Margin": "Absatzrand",
"Override Book Layout": "Buch-Layout überschreiben",
"Add to Group": "Zur Gruppe hinzufügen",
"Untitled Group": "Unbenannte Gruppe",
"Group Books": "Bücher gruppieren",
"Remove From Group": "Aus Gruppe entfernen",
@@ -244,5 +241,54 @@
"Contrast": "Kontrast",
"Sunset": "Sonnenuntergang",
"Double Border": "Doppelter Rand",
"Border Color": "Randfarbe"
"Border Color": "Randfarbe",
"Border Frame": "Rahmenrand",
"Show Header": "Kopfzeile anzeigen",
"Show Footer": "Fußzeile anzeigen",
"Small": "Klein",
"Large": "Groß",
"Auto": "Automatisch",
"Language": "Sprache",
"No annotations to export": "Keine Anmerkungen zum Exportieren",
"Author": "Autor",
"Exported from Readest": "Exportiert von Readest",
"Highlights & Annotations": "Hervorhebungen & Anmerkungen",
"Note": "Notiz",
"Copied to clipboard": "In die Zwischenablage kopiert",
"Export Annotations": "Anmerkungen exportieren",
"Auto Import on File Open": "Automatischer Import beim Öffnen einer Datei",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "Klick-zu-Blättern-Bereich tauschen",
"No chapters detected.": "Keine Kapitel erkannt.",
"Failed to parse the EPUB file.": "Fehler beim Parsen der EPUB-Datei.",
"This book format is not supported.": "Dieses Buchformat wird nicht unterstützt.",
"Unable to fetch the translation. Please log in first and try again.": "Übersetzung konnte nicht abgerufen werden. Bitte zuerst anmelden und erneut versuchen.",
"Group": "Gruppieren",
"Always on Top": "Immer im Vordergrund",
"No Timeout": "Kein Timeout",
"{{value}} minute": "{{value}} Minute",
"{{value}} minutes": "{{value}} Minuten",
"{{value}} hour": "{{value}} Stunde",
"{{value}} hours": "{{value}} Stunden",
"CJK Font": "CJK-Schriftart",
"Clear Search": "Suche löschen",
"Header & Footer": "Kopf- & Fußzeile",
"Apply also in Scrolled Mode": "Auch im Scroll-Modus anwenden",
"A new version of Readest is Available!": "Eine neue Version von Readest ist verfügbar!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} ist verfügbar (installierte Version {{currentVersion}}).",
"Download and install now?": "Jetzt herunterladen und installieren?",
"Downloading {{downloaded}} of {{contentLength}}": "Herunterladen {{downloaded}} von {{contentLength}}",
"Download finished": "Download abgeschlossen",
"DOWNLOAD & INSTALL": "HERUNTERLADEN & INSTALLIEREN",
"Changelog:": "Änderungsprotokoll:",
"Redownload": "Neu herunterladen",
"Software Update": "Software-Update",
"Title": "Titel",
"Date Read": "Datum gelesen",
"Date Added": "Datum hinzugefügt",
"Format": "Format",
"Ascending": "Aufsteigend",
"Descending": "Absteigend",
"Sort by...": "Sortieren nach..."
}
@@ -126,8 +126,6 @@
"Apply to All Books": "Εφαρμογή σε όλα τα βιβλία",
"Apply to This Book": "Εφαρμογή σε αυτό το βιβλίο",
"Unable to fetch the translation. Try again later.": "Αδυναμία λήψης της μετάφρασης. Δοκιμάστε ξανά αργότερα.",
"Update Now!": "Ενημέρωση τώρα!",
"Update": "Ενημέρωση",
"Check Update": "Έλεγχος ενημέρωσης",
"Already the latest version": "Ήδη η τελευταία έκδοση",
"Book Details": "Λεπτομέρειες βιβλίου",
@@ -152,7 +150,6 @@
"Text Indent": "Εσοχή κειμένου",
"Paragraph Margin": "Περιθώριο παραγράφου",
"Override Book Layout": "Παράκαμψη διάταξης βιβλίου",
"Add to Group": "Προσθήκη στην ομάδα",
"Untitled Group": "Ομάδα χωρίς τίτλο",
"Group Books": "Ομαδοποίηση βιβλίων",
"Remove From Group": "Αφαίρεση από την ομάδα",
@@ -244,5 +241,54 @@
"Contrast": "Αντίθεση",
"Sunset": "Ηλιοβασίλεμα",
"Double Border": "Διπλό περιθώριο",
"Border Color": "Χρώμα περιγράμματος"
"Border Color": "Χρώμα περιγράμματος",
"Border Frame": "Πλαίσιο περιγράμματος",
"Show Header": "Εμφάνιση κεφαλίδας",
"Show Footer": "Εμφάνιση υποσέλιδου",
"Small": "Μικρό",
"Large": "Μεγάλο",
"Auto": "Αυτόματο",
"Language": "Γλώσσα",
"No annotations to export": "Δεν υπάρχουν σχολιασμοί για εξαγωγή",
"Author": "Συγγραφέας",
"Exported from Readest": "Εξήχθη από το Readest",
"Highlights & Annotations": "Επισημάνσεις & Σχολιασμοί",
"Note": "Σημείωση",
"Copied to clipboard": "Αντιγράφηκε στο πρόχειρο",
"Export Annotations": "Εξαγωγή σχολιασμών",
"Auto Import on File Open": "Αυτόματη εισαγωγή κατά το άνοιγμα αρχείου",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "Ανταλλαγή περιοχής κλικ για γύρισμα σελίδας",
"No chapters detected.": "Δεν εντοπίστηκαν κεφάλαια.",
"Failed to parse the EPUB file.": "Αποτυχία ανάλυσης του αρχείου EPUB.",
"This book format is not supported.": "Αυτή η μορφή βιβλίου δεν υποστηρίζεται.",
"Unable to fetch the translation. Please log in first and try again.": "Αδυναμία λήψης της μετάφρασης. Παρακαλώ συνδεθείτε πρώτα και δοκιμάστε ξανά.",
"Group": "Ομάδα",
"Always on Top": "Πάντα στην κορυφή",
"No Timeout": "Χωρίς χρονικό όριο",
"{{value}} minute": "{{value}} λεπτό",
"{{value}} minutes": "{{value}} λεπτά",
"{{value}} hour": "{{value}} ώρα",
"{{value}} hours": "{{value}} ώρες",
"CJK Font": "Γραμματοσειρά CJK",
"Clear Search": "Καθαρισμός αναζήτησης",
"Header & Footer": "Κεφαλίδα & Υποσέλιδο",
"Apply also in Scrolled Mode": "Εφαρμογή και σε λειτουργία κύλισης",
"A new version of Readest is Available!": "Μια νέα έκδοση του Readest είναι διαθέσιμη!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Η έκδοση Readest {{newVersion}} είναι διαθέσιμη (έκδοση που είναι εγκατεστημένη {{currentVersion}}).",
"Download and install now?": "Θέλετε να την κατεβάσετε και να την εγκαταστήσετε τώρα;",
"Downloading {{downloaded}} of {{contentLength}}": "Κατεβάζω {{downloaded}} από {{contentLength}}",
"Download finished": "Η λήψη ολοκληρώθηκε",
"DOWNLOAD & INSTALL": "ΛΗΨΗ & ΕΓΚΑΤΑΣΤΑΣΗ",
"Changelog:": "Αλλαγές:",
"Redownload": "Επαναφόρτωση",
"Software Update": "Ενημέρωση λογισμικού",
"Title": "Τίτλος",
"Date Read": "Ημερομηνία ανάγνωσης",
"Date Added": "Ημερομηνία προσθήκης",
"Format": "Μορφή",
"Ascending": "Αύξουσα",
"Descending": "Φθίνουσα",
"Sort by...": "Ταξινόμηση κατά..."
}
@@ -1 +1,4 @@
{}
{
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC"
}
@@ -126,8 +126,6 @@
"Apply to All Books": "Aplicar a todos los libros",
"Apply to This Book": "Aplicar a este libro",
"Unable to fetch the translation. Try again later.": "No se puede obtener la traducción. Inténtalo de nuevo más tarde.",
"Update Now!": "¡Actualizar ahora!",
"Update": "Actualizar",
"Check Update": "Comprobar actualización",
"Already the latest version": "Ya es la última versión",
"Book Details": "Detalles del libro",
@@ -152,7 +150,6 @@
"Text Indent": "Sangría de texto",
"Paragraph Margin": "Margen de párrafo",
"Override Book Layout": "Sobrescribir diseño del libro",
"Add to Group": "Añadir a grupo",
"Untitled Group": "Grupo sin título",
"Group Books": "Agrupar libros",
"Remove From Group": "Eliminar del grupo",
@@ -244,5 +241,54 @@
"Contrast": "Contraste",
"Sunset": "Atardecer",
"Double Border": "Doble borde",
"Border Color": "Color del borde"
"Border Color": "Color del borde",
"Border Frame": "Marco de borde",
"Show Header": "Mostrar encabezado",
"Show Footer": "Mostrar pie de página",
"Small": "Pequeño",
"Large": "Grande",
"Auto": "Automático",
"Language": "Idioma",
"No annotations to export": "No hay anotaciones para exportar",
"Author": "Autor",
"Exported from Readest": "Exportado desde Readest",
"Highlights & Annotations": "Resaltados y anotaciones",
"Note": "Nota",
"Copied to clipboard": "Copiado al portapapeles",
"Export Annotations": "Exportar anotaciones",
"Auto Import on File Open": "Importación automática al abrir archivo",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "Intercambiar área de clic para voltear",
"No chapters detected.": "No se detectaron capítulos.",
"Failed to parse the EPUB file.": "Error al analizar el archivo EPUB.",
"This book format is not supported.": "Este formato de libro no es compatible.",
"Unable to fetch the translation. Please log in first and try again.": "No se puede obtener la traducción. Por favor, inicia sesión primero e inténtalo de nuevo.",
"Group": "Grupo",
"Always on Top": "Siempre en la parte superior",
"No Timeout": "Sin tiempo de espera",
"{{value}} minute": "{{value}} minuto",
"{{value}} minutes": "{{value}} minutos",
"{{value}} hour": "{{value}} hora",
"{{value}} hours": "{{value}} horas",
"CJK Font": "Fuente CJK",
"Clear Search": "Limpiar búsqueda",
"Header & Footer": "Encabezado y pie de página",
"Apply also in Scrolled Mode": "Aplicar también en modo desplazamiento",
"A new version of Readest is Available!": "¡Una nueva versión de Readest está disponible!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} está disponible (versión instalada {{currentVersion}}).",
"Download and install now?": "Descargar e instalar ahora?",
"Downloading {{downloaded}} of {{contentLength}}": "Descargando {{downloaded}} de {{contentLength}}",
"Download finished": "Descarga finalizada",
"DOWNLOAD & INSTALL": "DESCARGAR E INSTALAR",
"Changelog:": "Notas de la versión:",
"Redownload": "Volver a descargar",
"Software Update": "Actualización de software",
"Title": "Título",
"Date Read": "Fecha de lectura",
"Date Added": "Fecha añadida",
"Format": "Formato",
"Ascending": "Ascendente",
"Descending": "Descendente",
"Sort by...": "Ordenar por..."
}
@@ -126,8 +126,6 @@
"Apply to All Books": "Appliquer à tous les livres",
"Apply to This Book": "Appliquer à ce livre",
"Unable to fetch the translation. Try again later.": "Impossible de récupérer la traduction. Réessayez plus tard.",
"Update Now!": "Mettre à jour maintenant !",
"Update": "Mettre à jour",
"Check Update": "Vérifier la mise à jour",
"Already the latest version": "Déjà la dernière version",
"Book Details": "Détails du livre",
@@ -152,7 +150,6 @@
"Text Indent": "Retrait du texte",
"Paragraph Margin": "Marge de paragraphe",
"Override Book Layout": "Remplacer la mise en page du livre",
"Add to Group": "Ajouter au groupe",
"Untitled Group": "Groupe sans titre",
"Group Books": "Regrouper les livres",
"Remove From Group": "Retirer du groupe",
@@ -244,5 +241,54 @@
"Contrast": "Contraste",
"Sunset": "Coucher de soleil",
"Double Border": "Double bordure",
"Border Color": "Couleur de la bordure"
"Border Color": "Couleur de la bordure",
"Border Frame": "Cadre de bordure",
"Show Header": "Afficher l'en-tête",
"Show Footer": "Afficher le pied de page",
"Small": "Petit",
"Large": "Grand",
"Auto": "Automatique",
"Language": "Langue",
"No annotations to export": "Aucune annotation à exporter",
"Author": "Auteur",
"Exported from Readest": "Exporté depuis Readest",
"Highlights & Annotations": "Surlignages et annotations",
"Note": "Note",
"Copied to clipboard": "Copié dans le presse-papiers",
"Export Annotations": "Exporter les annotations",
"Auto Import on File Open": "Importation automatique à l'ouverture du fichier",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "Échanger la zone de clic pour tourner",
"No chapters detected.": "Aucun chapitre détecté.",
"Failed to parse the EPUB file.": "Échec de l'analyse du fichier EPUB.",
"This book format is not supported.": "Ce format de livre n'est pas pris en charge.",
"Unable to fetch the translation. Please log in first and try again.": "Impossible de récupérer la traduction. Veuillez d'abord vous connecter et réessayer.",
"Group": "Groupe",
"Always on Top": "Toujours au-dessus",
"No Timeout": "Aucun délai d'attente",
"{{value}} minute": "{{value}} minute",
"{{value}} minutes": "{{value}} minutes",
"{{value}} hour": "{{value}} heure",
"{{value}} hours": "{{value}} heures",
"CJK Font": "Police CJK",
"Clear Search": "Effacer la recherche",
"Header & Footer": "En-tête et pied de page",
"Apply also in Scrolled Mode": "Appliquer également en mode défilement",
"A new version of Readest is Available!": "Avis de mise à jour",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} est disponible (version installée {{currentVersion}}).",
"Download and install now?": "Télécharger et installer maintenant ?",
"Downloading {{downloaded}} of {{contentLength}}": "Téléchargement {{downloaded}} sur {{contentLength}}",
"Download finished": "Téléchargement terminé",
"DOWNLOAD & INSTALL": "TÉLÉCHARGER ET INSTALLER",
"Changelog:": "Journal des modifications :",
"Redownload": "Télécharger à nouveau",
"Software Update": "Mise à jour logicielle",
"Title": "Titre",
"Date Read": "Date de lecture",
"Date Added": "Date ajoutée",
"Format": "Format",
"Ascending": "Ascendant",
"Descending": "Descendant",
"Sort by...": "Trier par..."
}
@@ -126,8 +126,6 @@
"Apply to All Books": "सभी पुस्तकों पर लागू करें",
"Apply to This Book": "इस पुस्तक पर लागू करें",
"Unable to fetch the translation. Try again later.": "अनुवाद प्राप्त करने में असमर्थ। बाद में पुनः प्रयास करें।",
"Update Now!": "अभी अपडेट करें!",
"Update": "अपडेट करें",
"Check Update": "अपडेट जांचें",
"Already the latest version": "पहले से ही नवीनतम संस्करण",
"Book Details": "पुस्तक विवरण",
@@ -152,7 +150,6 @@
"Text Indent": "टेक्स्ट इंडेंट",
"Paragraph Margin": "पैराग्राफ मार्जिन",
"Override Book Layout": "पुस्तक लेआउट ओवरराइड करें",
"Add to Group": "समूह में जोड़ें",
"Untitled Group": "अभिनामित समूह",
"Group Books": "समूह पुस्तकें",
"Remove From Group": "समूह से हटाएं",
@@ -244,5 +241,54 @@
"Contrast": "विरोधाभास",
"Sunset": "सनसेट",
"Double Border": "डबल बॉर्डर",
"Border Color": "बॉर्डर रंग"
"Border Color": "बॉर्डर रंग",
"Border Frame": "बॉर्डर फ्रेम",
"Show Header": "हेडर दिखाएं",
"Show Footer": "फ़ुटर दिखाएं",
"Small": "छोटा",
"Large": "बड़ा",
"Auto": "स्वचालित",
"Language": "भाषा",
"No annotations to export": "निर्यात करने के लिए कोई टिप्पणियाँ नहीं हैं",
"Author": "लेखक",
"Exported from Readest": "Readest से निर्यात किया गया",
"Highlights & Annotations": "हाइलाइट और टिप्पणियाँ",
"Note": "टिप्पणी",
"Copied to clipboard": "क्लिपबोर्ड पर कॉपी किया गया",
"Export Annotations": "टिप्पणियाँ निर्यात करें",
"Auto Import on File Open": "फ़ाइल खोलने पर स्वचालित आयात",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "क्लिक-टू-फ्लिप क्षेत्र स्वैप करें",
"No chapters detected.": "कोई अध्याय नहीं मिला।",
"Failed to parse the EPUB file.": "EPUB फ़ाइल को पार्स करने में विफल।",
"This book format is not supported.": "यह पुस्तक प्रारूप समर्थित नहीं है।",
"Unable to fetch the translation. Please log in first and try again.": "अनुवाद प्राप्त करने में असमर्थ। कृपया पहले लॉग इन करें और पुनः प्रयास करें।",
"Group": "समूह",
"Always on Top": "सर्वोच्च पर हमेशा",
"No Timeout": "कोई समय समाप्त नहीं",
"{{value}} minute": "{{value}} मिनट",
"{{value}} minutes": "{{value}} मिनट",
"{{value}} hour": "{{value}} घंटा",
"{{value}} hours": "{{value}} घंटे",
"CJK Font": "CJK फ़ॉन्ट",
"Clear Search": "खोज साफ करें",
"Header & Footer": "हेडर और फ़ुटर",
"Apply also in Scrolled Mode": "स्क्रॉल मोड में भी लागू करें",
"A new version of Readest is Available!": "Readest का एक नया संस्करण उपलब्ध है!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} उपलब्ध है (स्थापित संस्करण {{currentVersion}})।",
"Download and install now?": "अब डाउनलोड और इंस्टॉल करें?",
"Downloading {{downloaded}} of {{contentLength}}": "डाउनलोड कर रहा है {{downloaded}} का {{contentLength}}",
"Download finished": "डाउनलोड पूरा हुआ",
"DOWNLOAD & INSTALL": "डाउनलोड और इंस्टॉल करें",
"Changelog:": "चेंजलॉग:",
"Redownload": "री-डाउनलोड करें",
"Software Update": "सॉफ़्टवेयर अपडेट",
"Title": "शीर्षक",
"Date Read": "पढ़ने की तारीख",
"Date Added": "जोड़ा गया दिनांक",
"Format": "फॉर्मेट",
"Ascending": "आरोही",
"Descending": "अवरोही",
"Sort by...": "द्वारा क्रमबद्ध करें..."
}
@@ -126,8 +126,6 @@
"Apply to All Books": "Terapkan ke semua buku",
"Apply to This Book": "Terapkan ke buku ini",
"Unable to fetch the translation. Try again later.": "Tidak dapat mengambil terjemahan. Coba lagi nanti.",
"Update Now!": "Perbarui Sekarang!",
"Update": "Perbarui",
"Check Update": "Periksa pembaruan",
"Already the latest version": "Sudah versi terbaru",
"Book Details": "Detail Buku",
@@ -152,7 +150,6 @@
"Text Indent": "Indentasi Teks",
"Paragraph Margin": "Margin Paragraf",
"Override Book Layout": "Ganti Tata Letak Buku",
"Add to Group": "Tambahkan ke Grup",
"Untitled Group": "Grup Tanpa Judul",
"Group Books": "Grupkan Buku",
"Remove From Group": "Hapus dari Grup",
@@ -244,5 +241,54 @@
"Contrast": "Kontras",
"Sunset": "Senja",
"Double Border": "Garis Ganda",
"Border Color": "Warna Garis"
"Border Color": "Warna Garis",
"Border Frame": "Bingkai Garis",
"Show Header": "Tampilkan Header",
"Show Footer": "Tampilkan Footer",
"Small": "Kecil",
"Large": "Besar",
"Auto": "Otomatis",
"Language": "Bahasa",
"No annotations to export": "Tidak ada anotasi yang diekspor",
"Author": "Penulis",
"Exported from Readest": "Diekspor dari Readest",
"Highlights & Annotations": "Sorotan & Anotasi",
"Note": "Catatan",
"Copied to clipboard": "Disalin ke papan klip",
"Export Annotations": "Ekspor Anotasi",
"Auto Import on File Open": "Impor Otomatis saat File Dibuka",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "Tukar Area Klik untuk Membalik",
"No chapters detected.": "Tidak ada bab yang terdeteksi.",
"Failed to parse the EPUB file.": "Gagal mengurai file EPUB.",
"This book format is not supported.": "Format buku ini tidak didukung.",
"Unable to fetch the translation. Please log in first and try again.": "Tidak dapat mengambil terjemahan. Silakan masuk terlebih dahulu dan coba lagi.",
"Group": "Grup",
"Always on Top": "Selalu di Atas",
"No Timeout": "Tidak Ada Waktu Habis",
"{{value}} minute": "{{value}} menit",
"{{value}} minutes": "{{value}} menit",
"{{value}} hour": "{{value}} jam",
"{{value}} hours": "{{value}} jam",
"CJK Font": "Font CJK",
"Clear Search": "Hapus Pencarian",
"Header & Footer": "Header & Footer",
"Apply also in Scrolled Mode": "Terapkan juga di Mode Gulir",
"A new version of Readest is Available!": "Versi baru Readest Tersedia!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} tersedia (versi terinstal {{currentVersion}}).",
"Download and install now?": "Unduh dan instal sekarang?",
"Downloading {{downloaded}} of {{contentLength}}": "Mengunduh {{downloaded}} dari {{contentLength}}",
"Download finished": "Unduhan selesai",
"DOWNLOAD & INSTALL": "UNDUH & INSTAL",
"Changelog:": "Catatan Perubahan:",
"Redownload": "Unduh Ulang",
"Software Update": "Pembaruan Perangkat Lunak",
"Title": "Judul",
"Date Read": "Tanggal Dibaca",
"Date Added": "Tanggal Ditambahkan",
"Format": "Format",
"Ascending": "Naik",
"Descending": "Turun",
"Sort by...": "Urutkan berdasarkan..."
}
@@ -126,8 +126,6 @@
"Apply to All Books": "Applica a tutti i libri",
"Apply to This Book": "Applica a questo libro",
"Unable to fetch the translation. Try again later.": "Impossibile recuperare la traduzione. Riprova più tardi.",
"Update Now!": "Aggiorna ora!",
"Update": "Aggiorna",
"Check Update": "Controlla aggiornamento",
"Already the latest version": "Già l'ultima versione",
"Book Details": "Dettagli libro",
@@ -152,7 +150,6 @@
"Text Indent": "Rientro testo",
"Paragraph Margin": "Margine paragrafo",
"Override Book Layout": "Sovrascrivi layout libro",
"Add to Group": "Aggiungi al gruppo",
"Untitled Group": "Gruppo senza titolo",
"Group Books": "Raggruppa libri",
"Remove From Group": "Rimuovi dal",
@@ -244,5 +241,54 @@
"Contrast": "Contrasto",
"Sunset": "Tramonto",
"Double Border": "Doppio bordo",
"Border Color": "Colore bordo"
"Border Color": "Colore bordo",
"Border Frame": "Cornice bordo",
"Show Header": "Mostra intestazione",
"Show Footer": "Mostra piè di pagina",
"Small": "Piccolo",
"Large": "Grande",
"Auto": "Automatico",
"Language": "Lingua",
"No annotations to export": "Nessuna annotazione da esportare",
"Author": "Autore",
"Exported from Readest": "Esportato da Readest",
"Highlights & Annotations": "Evidenziazioni e annotazioni",
"Note": "Nota",
"Copied to clipboard": "Copiato negli appunti",
"Export Annotations": "Esporta annotazioni",
"Auto Import on File Open": "Importazione automatica all'apertura del file",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "Scambia area click per voltare pagina",
"No chapters detected.": "Nessun capitolo rilevato.",
"Failed to parse the EPUB file.": "Impossibile analizzare il file EPUB.",
"This book format is not supported.": "Questo formato di libro non è supportato.",
"Unable to fetch the translation. Please log in first and try again.": "Impossibile recuperare la traduzione. Accedi prima e riprova.",
"Group": "Gruppo",
"Always on Top": "Sempre in primo piano",
"No Timeout": "Nessun timeout",
"{{value}} minute": "{{value}} minuto",
"{{value}} minutes": "{{value}} minuti",
"{{value}} hour": "{{value}} ora",
"{{value}} hours": "{{value}} ore",
"CJK Font": "Font CJK",
"Clear Search": "Cancella ricerca",
"Header & Footer": "Intestazione e piè di pagina",
"Apply also in Scrolled Mode": "Applica anche in modalità scorrimento",
"A new version of Readest is Available!": "È disponibile una nuova versione di Readest!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} è disponibile (versione installata {{currentVersion}}).",
"Download and install now?": "Scarica e installa ora?",
"Downloading {{downloaded}} of {{contentLength}}": "Scaricando {{downloaded}} di {{contentLength}}",
"Download finished": "Download completato",
"DOWNLOAD & INSTALL": "SCARICA E INSTALLA",
"Changelog:": "Note di rilascio:",
"Redownload": "Scarica di nuovo",
"Software Update": "Aggiornamento software",
"Title": "Titolo",
"Date Read": "Data di lettura",
"Date Added": "Data aggiunta",
"Format": "Formato",
"Ascending": "Crescente",
"Descending": "Decrescente",
"Sort by...": "Ordina per..."
}
@@ -126,8 +126,6 @@
"Apply to All Books": "すべての書籍に適用",
"Apply to This Book": "この書籍に適用",
"Unable to fetch the translation. Try again later.": "翻訳を取得できません。後で再試行してください。",
"Update Now!": "今すぐ更新!",
"Update": "更新",
"Check Update": "更新を確認",
"Already the latest version": "すでに最新バージョン",
"Book Details": "書籍の詳細",
@@ -152,7 +150,6 @@
"Text Indent": "テキストインデント",
"Paragraph Margin": "段落マージン",
"Override Book Layout": "書籍のレイアウトを上書き",
"Add to Group": "グループに追加",
"Untitled Group": "無題のグループ",
"Group Books": "書籍をグループ化",
"Remove From Group": "グループから削除",
@@ -244,5 +241,54 @@
"Contrast": "コントラスト",
"Sunset": "サンセット",
"Double Border": "二重枠",
"Border Color": "ボーダーカラー"
"Border Color": "ボーダーカラー",
"Border Frame": "ボーダーフレーム",
"Show Header": "ヘッダーを表示",
"Show Footer": "フッターを表示",
"Small": "小",
"Large": "大",
"Auto": "自動",
"Language": "言語",
"No annotations to export": "エクスポートする注釈はありません",
"Author": "著者",
"Exported from Readest": "Readestからエクスポート",
"Highlights & Annotations": "ハイライトと注釈",
"Note": "メモ",
"Copied to clipboard": "クリップボードにコピー",
"Export Annotations": "注釈をエクスポート",
"Auto Import on File Open": "ファイルを開くと自動インポート",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "クリックめくりエリアを入れ替え",
"No chapters detected.": "章が検出されません。",
"Failed to parse the EPUB file.": "EPUBファイルの解析に失敗しました。",
"This book format is not supported.": "この書籍形式はサポートされていません。",
"Unable to fetch the translation. Please log in first and try again.": "翻訳を取得できません。まずログインして再試行してください。",
"Group": "グループ",
"Always on Top": "常に最前面",
"No Timeout": "タイムアウトなし",
"{{value}} minute": "{{value}}分",
"{{value}} minutes": "{{value}}分",
"{{value}} hour": "{{value}}時間",
"{{value}} hours": "{{value}}時間",
"CJK Font": "CJKフォント",
"Clear Search": "検索をクリア",
"Header & Footer": "ヘッダーとフッター",
"Apply also in Scrolled Mode": "スクロールモードでも適用",
"A new version of Readest is Available!": "新しいバージョンのReadestが利用可能です!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}}が利用可能です(インストールされたバージョン{{currentVersion}})。",
"Download and install now?": "今すぐダウンロードしてインストールしますか?",
"Downloading {{downloaded}} of {{contentLength}}": "ダウンロード中 {{downloaded}} / {{contentLength}}",
"Download finished": "ダウンロード完了",
"DOWNLOAD & INSTALL": "更新",
"Changelog:": "変更ログ:",
"Redownload": "再ダウンロード",
"Software Update": "ソフトウェアの更新",
"Title": "タイトル",
"Date Read": "読書日",
"Date Added": "追加日",
"Format": "形式",
"Ascending": "昇順",
"Descending": "降順",
"Sort by...": "並べ替え..."
}
@@ -126,8 +126,6 @@
"Apply to All Books": "모든 책에 적용",
"Apply to This Book": "이 책에 적용",
"Unable to fetch the translation. Try again later.": "번역을 가져올 수 없습니다. 나중에 다시 시도하세요.",
"Update Now!": "지금 업데이트!",
"Update": "업데이트",
"Check Update": "업데이트 확인",
"Already the latest version": "이미 최신 버전",
"Book Details": "책 세부 정보",
@@ -152,7 +150,6 @@
"Text Indent": "들여쓰기",
"Paragraph Margin": "단락 여백",
"Override Book Layout": "책 레이아웃 덮어쓰기",
"Add to Group": "그룹에 추가",
"Untitled Group": "제목 없는 그룹",
"Group Books": "책 그룹화",
"Remove From Group": "그룹에서 제거",
@@ -244,5 +241,54 @@
"Contrast": "대비",
"Sunset": "일몰",
"Double Border": "이중 테두리",
"Border Color": "테두리 색상"
"Border Color": "테두리 색상",
"Border Frame": "테두리 프레임",
"Show Header": "헤더 표시",
"Show Footer": "푸터 표시",
"Small": "작음",
"Large": "큼",
"Auto": "자동",
"Language": "언어",
"No annotations to export": "내보낼 주석이 없습니다",
"Author": "저자",
"Exported from Readest": "Readest에서 내보냄",
"Highlights & Annotations": "하이라이트 및 주석",
"Note": "노트",
"Copied to clipboard": "클립보드에 복사",
"Export Annotations": "주석 내보내기",
"Auto Import on File Open": "파일 열 때 자동 가져오기",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "클릭-뒤집기 영역 교환",
"No chapters detected.": "장 발견되지 않음.",
"Failed to parse the EPUB file.": "EPUB 파일 구문 분석 실패.",
"This book format is not supported.": "이 책 형식은 지원되지 않습니다.",
"Unable to fetch the translation. Please log in first and try again.": "번역을 가져올 수 없습니다. 먼저 로그인하고 다시 시도하세요.",
"Group": "그룹",
"Always on Top": "항상 위에",
"No Timeout": "타임아웃 없음",
"{{value}} minute": "{{value}} 분",
"{{value}} minutes": "{{value}} 분",
"{{value}} hour": "{{value}} 시간",
"{{value}} hours": "{{value}} 시간",
"CJK Font": "CJK 글꼴",
"Clear Search": "검색 지우기",
"Header & Footer": "헤더 및 푸터",
"Apply also in Scrolled Mode": "스크롤 모드에서도 적용",
"A new version of Readest is Available!": "Readest의 새 버전이 있습니다!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}}가 사용 가능 (설치된 버전 {{currentVersion}}).",
"Download and install now?": "지금 다운로드하고 설치하시겠습니까?",
"Downloading {{downloaded}} of {{contentLength}}": "다운로드 중 {{downloaded}} / {{contentLength}}",
"Download finished": "다운로드 완료",
"DOWNLOAD & INSTALL": "다운로드 및 설치",
"Changelog:": "변경 로그:",
"Redownload": "다시 다운로드",
"Software Update": "소프트웨어 업데이트",
"Title": "제목",
"Date Read": "읽은 날짜",
"Date Added": "추가된 날짜",
"Format": "형식",
"Ascending": "오름차순",
"Descending": "내림차순",
"Sort by...": "정렬 기준..."
}
@@ -126,8 +126,6 @@
"Apply to All Books": "Zastosuj do wszystkich książek",
"Apply to This Book": "Zastosuj do tej książki",
"Unable to fetch the translation. Try again later.": "Nie udało się pobrać tłumaczenia. Spróbuj ponownie później.",
"Update Now!": "Zaktualizuj teraz!",
"Update": "Zaktualizuj",
"Check Update": "Sprawdź aktualizację",
"Already the latest version": "Już najnowsza wersja",
"Book Details": "Szczegóły książki",
@@ -152,7 +150,6 @@
"Text Indent": "Wcięcie tekstu",
"Paragraph Margin": "Margines akapitu",
"Override Book Layout": "Nadpisz układ książki",
"Add to Group": "Dodaj do grupy",
"Untitled Group": "Grupa bez tytułu",
"Group Books": "Grupuj książki",
"Remove From Group": "Usuń z grupy",
@@ -244,5 +241,54 @@
"Contrast": "Kontrast",
"Sunset": "Zachód słońca",
"Double Border": "Podwójna ramka",
"Border Color": "Kolor ramki"
"Border Color": "Kolor ramki",
"Border Frame": "Ramka",
"Show Header": "Pokaż nagłówek",
"Show Footer": "Pokaż stopkę",
"Small": "Mały",
"Large": "Duży",
"Auto": "Automatyczny",
"Language": "Język",
"No annotations to export": "Brak adnotacji do eksportu",
"Author": "Autor",
"Exported from Readest": "Eksportowane z Readest",
"Highlights & Annotations": "Zaznaczenia i adnotacje",
"Note": "Notatka",
"Copied to clipboard": "Skopiowano do schowka",
"Export Annotations": "Eksportuj adnotacje",
"Auto Import on File Open": "Automatyczne importowanie przy otwieraniu pliku",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "Zamień obszar kliknięcia na przewracanie",
"No chapters detected.": "Nie wykryto rozdziałów.",
"Failed to parse the EPUB file.": "Nie udało się przeanalizować pliku EPUB.",
"This book format is not supported.": "Ten format książki nie jest obsługiwany.",
"Unable to fetch the translation. Please log in first and try again.": "Nie można pobrać tłumaczenia. Proszę najpierw się zalogować i spróbować ponownie.",
"Group": "Grupa",
"Always on Top": "Zawsze na wierzchu",
"No Timeout": "Brak limitu czasu",
"{{value}} minute": "{{value}} minuta",
"{{value}} minutes": "{{value}} minut",
"{{value}} hour": "{{value}} godzina",
"{{value}} hours": "{{value}} godziny",
"CJK Font": "Czcionka CJK",
"Clear Search": "Wyczyść wyszukiwanie",
"Header & Footer": "Nagłówek i stopka",
"Apply also in Scrolled Mode": "Zastosuj również w trybie przewijania",
"A new version of Readest is Available!": "Nowa wersja Readest jest dostępna!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} jest dostępny (zainstalowana wersja {{currentVersion}}).",
"Download and install now?": "Pobierz i zainstaluj teraz?",
"Downloading {{downloaded}} of {{contentLength}}": "Pobieranie {{downloaded}} z {{contentLength}}",
"Download finished": "Pobieranie zakończone",
"DOWNLOAD & INSTALL": "POBIERZ I ZAINSTALUJ",
"Changelog:": "Dziennik zmian:",
"Redownload": "Pobierz ponownie",
"Software Update": "Aktualizacja oprogramowania",
"Title": "Tytuł",
"Date Read": "Data przeczytania",
"Date Added": "Data dodania",
"Format": "Format",
"Ascending": "Rosnąco",
"Descending": "Malejąco",
"Sort by...": "Sortuj według..."
}
@@ -126,8 +126,6 @@
"Apply to All Books": "Aplicar a todos os livros",
"Apply to This Book": "Aplicar a este livro",
"Unable to fetch the translation. Try again later.": "Não foi possível buscar a tradução. Tente novamente mais tarde.",
"Update Now!": "Atualizar Agora!",
"Update": "Atualizar",
"Check Update": "Verificar atualização",
"Already the latest version": "Já é a versão mais recente",
"Book Details": "Detalhes do Livro",
@@ -152,7 +150,6 @@
"Text Indent": "Recuo de Texto",
"Paragraph Margin": "Margem de Parágrafo",
"Override Book Layout": "Sobrescrever Layout do Livro",
"Add to Group": "Adicionar ao Grupo",
"Untitled Group": "Grupo Sem Título",
"Group Books": "Agrupar Livros",
"Remove From Group": "Remover do Grupo",
@@ -244,5 +241,54 @@
"Contrast": "Contraste",
"Sunset": "Pôr do Sol",
"Double Border": "Borda Dupla",
"Border Color": "Cor da Borda"
"Border Color": "Cor da Borda",
"Border Frame": "Moldura da Borda",
"Show Header": "Mostrar Cabeçalho",
"Show Footer": "Mostrar Rodapé",
"Small": "Pequeno",
"Large": "Grande",
"Auto": "Automático",
"Language": "Idioma",
"No annotations to export": "Nenhuma anotação para exportar",
"Author": "Autor",
"Exported from Readest": "Exportado do Readest",
"Highlights & Annotations": "Destaques e Anotações",
"Note": "Nota",
"Copied to clipboard": "Copiado para a área de transferência",
"Export Annotations": "Exportar Anotações",
"Auto Import on File Open": "Importação Automática ao Abrir Arquivo",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "Trocar Área de Clique para Virar",
"No chapters detected.": "Nenhum capítulo detectado.",
"Failed to parse the EPUB file.": "Falha ao analisar o arquivo EPUB.",
"This book format is not supported.": "Este formato de livro não é suportado.",
"Unable to fetch the translation. Please log in first and try again.": "Não foi possível buscar a tradução. Faça login primeiro e tente novamente.",
"Group": "Grupo",
"Always on Top": "Sempre no Topo",
"No Timeout": "Sem Tempo Limite",
"{{value}} minute": "{{value}} minuto",
"{{value}} minutes": "{{value}} minutos",
"{{value}} hour": "{{value}} hora",
"{{value}} hours": "{{value}} horas",
"CJK Font": "Fonte CJK",
"Clear Search": "Limpar Pesquisa",
"Header & Footer": "Cabeçalho e Rodapé",
"Apply also in Scrolled Mode": "Aplicar também no Modo Rolado",
"A new version of Readest is Available!": "Uma nova versão do Readest está disponível!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} está disponível (versão instalada {{currentVersion}}).",
"Download and install now?": "Baixar e instalar agora?",
"Downloading {{downloaded}} of {{contentLength}}": "Baixando {{downloaded}} de {{contentLength}}",
"Download finished": "Download concluído",
"DOWNLOAD & INSTALL": "BAIXAR E INSTALAR",
"Changelog:": "Notas de versão:",
"Redownload": "Baixar novamente",
"Software Update": "Atualização de Software",
"Title": "Título",
"Date Read": "Data de leitura",
"Date Added": "Data de adição",
"Format": "Formato",
"Ascending": "Crescente",
"Descending": "Decrescente",
"Sort by...": "Ordenar por..."
}
@@ -126,8 +126,6 @@
"Apply to All Books": "Применить ко всем книгам",
"Apply to This Book": "Применить к этой книге",
"Unable to fetch the translation. Try again later.": "Не удалось получить перевод. Попробуйте позже.",
"Update Now!": "Обновить сейчас!",
"Update": "Обновить",
"Check Update": "Проверить обновление",
"Already the latest version": "Уже последняя версия",
"Book Details": "Детали книги",
@@ -152,7 +150,6 @@
"Text Indent": "Отступ текста",
"Paragraph Margin": "Отступ абзаца",
"Override Book Layout": "Переопределить макет книги",
"Add to Group": "Добавить в группу",
"Untitled Group": "Группа без названия",
"Group Books": "Группировать книги",
"Remove From Group": "Удалить из группы",
@@ -244,5 +241,54 @@
"Contrast": "Контраст",
"Sunset": "Закат",
"Double Border": "Двойная граница",
"Border Color": "Цвет границы"
"Border Color": "Цвет границы",
"Border Frame": "Граница рамки",
"Show Header": "Показать заголовок",
"Show Footer": "Показать нижний колонтитул",
"Small": "Маленький",
"Large": "Большой",
"Auto": "Авто",
"Language": "Язык",
"No annotations to export": "Нет аннотаций для экспорта",
"Author": "Автор",
"Exported from Readest": "Экспортировано из Readest",
"Highlights & Annotations": "Выделения и аннотации",
"Note": "Заметка",
"Copied to clipboard": "Скопировано в буфер обмена",
"Export Annotations": "Экспортировать аннотации",
"Auto Import on File Open": "Автоматический импорт при открытии файла",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "Переключить область клика для переворота",
"No chapters detected.": "Не обнаружено глав.",
"Failed to parse the EPUB file.": "Не удалось разобрать файл EPUB.",
"This book format is not supported.": "Этот формат книги не поддерживается.",
"Unable to fetch the translation. Please log in first and try again.": "Не удалось получить перевод. Пожалуйста, войдите в систему и попробуйте снова.",
"Group": "Группа",
"Always on Top": "Всегда сверху",
"No Timeout": "Нет таймаута",
"{{value}} minute": "{{value}} минута",
"{{value}} minutes": "{{value}} минуты",
"{{value}} hour": "{{value}} час",
"{{value}} hours": "{{value}} часа",
"CJK Font": "CJK Шрифт",
"Clear Search": "Очистить поиск",
"Header & Footer": "Заголовок и нижний колонтитул",
"Apply also in Scrolled Mode": "Применить также в прокручиваемом режиме",
"A new version of Readest is Available!": "Доступна новая версия Readest!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} доступен (установленная версия {{currentVersion}}).",
"Download and install now?": "Скачать и установить сейчас?",
"Downloading {{downloaded}} of {{contentLength}}": "Скачано {{downloaded}} из {{contentLength}}",
"Download finished": "Загрузка завершена",
"DOWNLOAD & INSTALL": "СКАЧАТЬ И УСТАНОВИТЬ",
"Changelog:": "Изменения:",
"Redownload": "Переустановить",
"Software Update": "Обновление программного обеспечения",
"Title": "Название",
"Date Read": "Дата прочтения",
"Date Added": "Дата добавления",
"Format": "Формат",
"Ascending": "По возрастанию",
"Descending": "По убыванию",
"Sort by...": "Сортировать по..."
}
@@ -126,8 +126,6 @@
"Apply to All Books": "Tüm kitaplara uygula",
"Apply to This Book": "Bu kitaba uygula",
"Unable to fetch the translation. Try again later.": "Çeviri alınamıyor. Daha sonra tekrar deneyin.",
"Update Now!": "Şimdi Güncelle!",
"Update": "Güncelle",
"Check Update": "Güncellemeyi kontrol et",
"Already the latest version": "Zaten en son sürüm",
"Book Details": "Kitap Detayları",
@@ -152,7 +150,6 @@
"Text Indent": "Metin Girintisi",
"Paragraph Margin": "Paragraf Kenarı",
"Override Book Layout": "Kitap Düzenini Geçersiz Kıl",
"Add to Group": "Gruba Ekle",
"Untitled Group": "Başlıksız Grup",
"Group Books": "Kitapları Grupla",
"Remove From Group": "Grubu Kaldır",
@@ -244,5 +241,54 @@
"Contrast": "Kontrast",
"Sunset": "Gün Batımı",
"Double Border": "Çift Sınır",
"Border Color": "Sınır Rengi"
"Border Color": "Sınır Rengi",
"Border Frame": "Sınır Çerçevesi",
"Show Header": "Başlık Göster",
"Show Footer": "Altbilgi Göster",
"Small": "Küçük",
"Large": "Büyük",
"Auto": "Otomatik",
"Language": "Dil",
"No annotations to export": "Dışa aktarılacak not yok",
"Author": "Yazar",
"Exported from Readest": "Readest'ten dışa aktarıldı",
"Highlights & Annotations": "Vurgular ve Notlar",
"Note": "Not",
"Copied to clipboard": "Panoya kopyalandı",
"Export Annotations": "Notları Dışa Aktar",
"Auto Import on File Open": "Dosya Açıldığında Otomatik İçe Aktar",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "Tıkla-Çevir Alanını Değiştir",
"No chapters detected.": "Bölüm bulunamadı.",
"Failed to parse the EPUB file.": "EPUB dosyası ayrıştırılamadı.",
"This book format is not supported.": "Bu kitap formatı desteklenmiyor.",
"Unable to fetch the translation. Please log in first and try again.": "Çeviri alınamıyor. Lütfen önce giriş yapın ve tekrar deneyin.",
"Group": "Grup",
"Always on Top": "Her Zaman Üstte",
"No Timeout": "Zaman Aşımı Yok",
"{{value}} minute": "{{value}} dakika",
"{{value}} minutes": "{{value}} dakika",
"{{value}} hour": "{{value}} saat",
"{{value}} hours": "{{value}} saat",
"CJK Font": "CJK Yazı Tipi",
"Clear Search": "Aramayı Temizle",
"Header & Footer": "Başlık ve Altbilgi",
"Apply also in Scrolled Mode": "Kaydırılmış Modda da Uygula",
"A new version of Readest is Available!": "Yeni bir Readest sürümü mevcut!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} mevcut (yüklü sürüm {{currentVersion}}).",
"Download and install now?": "Şimdi indirip yükleyelim mi?",
"Downloading {{downloaded}} of {{contentLength}}": "İndiriliyor {{downloaded}}/{{contentLength}}",
"Download finished": "İndirme tamamlandı",
"DOWNLOAD & INSTALL": "İNDİR & YÜKLE",
"Changelog:": "Değişiklik Günlüğü:",
"Redownload": "Yeniden İndir",
"Software Update": "Yazılım Güncellemesi",
"Title": "Başlık",
"Date Read": "Okunma Tarihi",
"Date Added": "Eklenme Tarihi",
"Format": "Biçim",
"Ascending": "Artan",
"Descending": "Azalan",
"Sort by...": "Şuna göre sırala..."
}
@@ -126,8 +126,6 @@
"Apply to All Books": "Застосувати до всіх книг",
"Apply to This Book": "Застосувати до цієї книги",
"Unable to fetch the translation. Try again later.": "Не вдалося отримати переклад. Спробуйте пізніше.",
"Update Now!": "Оновити зараз!",
"Update": "Оновити",
"Check Update": "Перевірити оновлення",
"Already the latest version": "Вже остання версія",
"Book Details": "Деталі книги",
@@ -152,7 +150,6 @@
"Text Indent": "Відступ тексту",
"Paragraph Margin": "Відступ абзацу",
"Override Book Layout": "Перевизначити макет книги",
"Add to Group": "Додати до групи",
"Untitled Group": "Група без назви",
"Group Books": "Групувати книги",
"Remove From Group": "Видалити з групи",
@@ -244,5 +241,54 @@
"Contrast": "Контраст",
"Sunset": "Захід сонця",
"Double Border": "Подвійна рамка",
"Border Color": "Колір рамки"
"Border Color": "Колір рамки",
"Border Frame": "Рамка рамки",
"Show Header": "Показати заголовок",
"Show Footer": "Показати нижню частину",
"Small": "Малий",
"Large": "Великий",
"Auto": "Автоматичний",
"Language": "Мова",
"No annotations to export": "Немає анотацій для експорту",
"Author": "Автор",
"Exported from Readest": "Експортовано з Readest",
"Highlights & Annotations": "Виділення та анотації",
"Note": "Примітка",
"Copied to clipboard": "Скопійовано в буфер обміну",
"Export Annotations": "Експортувати анотації",
"Auto Import on File Open": "Автоматичний імпорт при відкритті файлу",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "Поміняти місцями область кліку для перевороту",
"No chapters detected.": "Не виявлено жодного розділу.",
"Failed to parse the EPUB file.": "Не вдалося розібрати файл EPUB.",
"This book format is not supported.": "Цей формат книги не підтримується.",
"Unable to fetch the translation. Please log in first and try again.": "Не вдалося отримати переклад. Будь ласка, спочатку увійдіть у систему та спробуйте ще раз.",
"Group": "Група",
"Always on Top": "Завжди зверху",
"No Timeout": "Без тайм-ауту",
"{{value}} minute": "{{value}} хвилина",
"{{value}} minutes": "{{value}} хвилин",
"{{value}} hour": "{{value}} година",
"{{value}} hours": "{{value}} години",
"CJK Font": "CJK шрифт",
"Clear Search": "Очистити пошук",
"Header & Footer": "Заголовок і нижній колонтитул",
"Apply also in Scrolled Mode": "Застосувати також у прокрученому режимі",
"A new version of Readest is Available!": "Доступна нова версія Readest!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} доступна (встановлена версія {{currentVersion}}).",
"Download and install now?": "Завантажити та встановити зараз?",
"Downloading {{downloaded}} of {{contentLength}}": "Завантаження {{downloaded}} з {{contentLength}}",
"Download finished": "Завантаження завершено",
"DOWNLOAD & INSTALL": "ЗАВАНТАЖИТИ І ВСТАНОВИТИ",
"Changelog:": "Журнал змін:",
"Redownload": "Завантажити знову",
"Software Update": "Оновлення програмного забезпечення",
"Title": "Назва",
"Date Read": "Дата прочитання",
"Date Added": "Дата додавання",
"Format": "Формат",
"Ascending": "За зростанням",
"Descending": "За спаданням",
"Sort by...": "Сортувати за..."
}
@@ -126,8 +126,6 @@
"Apply to All Books": "Áp dụng cho tất cả sách",
"Apply to This Book": "Áp dụng cho sách này",
"Unable to fetch the translation. Try again later.": "Không thể tải dịch vụ. Vui lòng thử lại sau.",
"Update Now!": "Cập nhật ngay!",
"Update": "Cập nhật",
"Check Update": "Kiểm tra cập nhật",
"Already the latest version": "Đã là phiên bản mới nhất",
"Book Details": "Chi tiết sách",
@@ -152,7 +150,6 @@
"Text Indent": "Thụt lề",
"Paragraph Margin": "Lề đoạn",
"Override Book Layout": "Ghi đè bố cục sách",
"Add to Group": "Thêm vào nhóm",
"Untitled Group": "Nhóm không có tiêu đề",
"Group Books": "Nhóm sách",
"Remove From Group": "Xóa khỏi nhóm",
@@ -244,5 +241,54 @@
"Contrast": "Tương phản",
"Sunset": "Hoàng hôn",
"Double Border": "Đôi viền",
"Border Color": "Màu viền"
"Border Color": "Màu viền",
"Border Frame": "Khung viền",
"Show Header": "Hiển thị tiêu đề",
"Show Footer": "Hiển thị chân trang",
"Small": "Nhỏ",
"Large": "Lớn",
"Auto": "Tự động",
"Language": "Ngôn ngữ",
"No annotations to export": "Không có chú thích để xuất",
"Author": "Tác giả",
"Exported from Readest": "Đã xuất từ Readest",
"Highlights & Annotations": "Điểm nổi bật & Chú thích",
"Note": "Ghi chú",
"Copied to clipboard": "Đã sao chép vào bảng ghi",
"Export Annotations": "Xuất chú thích",
"Auto Import on File Open": "Tự động nhập khi mở tệp",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "Hoán đổi khu vực nhấp để lật trang",
"No chapters detected.": "Không phát hiện thấy chương nào.",
"Failed to parse the EPUB file.": "Không thể phân tích tệp EPUB.",
"This book format is not supported.": "Định dạng sách này không được hỗ trợ.",
"Unable to fetch the translation. Please log in first and try again.": "Không thể tải dịch vụ. Vui lòng đăng nhập trước và thử lại.",
"Group": "Nhóm",
"Always on Top": "Luôn ở trên cùng",
"No Timeout": "Không có thời gian chờ",
"{{value}} minute": "{{value}} phút",
"{{value}} minutes": "{{value}} phút",
"{{value}} hour": "{{value}} giờ",
"{{value}} hours": "{{value}} giờ",
"CJK Font": "Phông chữ CJK",
"Clear Search": "Xóa tìm kiếm",
"Header & Footer": "Đầu trang & Chân trang",
"Apply also in Scrolled Mode": "Áp dụng cũng trong chế độ cuộn",
"A new version of Readest is Available!": "Đã có phiên bản mới của Readest!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} đã có sẵn (phiên bản đã cài đặt {{currentVersion}}).",
"Download and install now?": "Tải xuống và cài đặt ngay bây giờ?",
"Downloading {{downloaded}} of {{contentLength}}": "Đang tải {{downloaded}} trong {{contentLength}}",
"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",
"Software Update": "Cập nhật phần mềm",
"Title": "Tiêu đề",
"Date Read": "Ngày đã đọc",
"Date Added": "Ngày thêm",
"Format": "Định dạng",
"Ascending": "Tăng dần",
"Descending": "Giảm dần",
"Sort by...": "Sắp xếp theo..."
}
@@ -126,8 +126,6 @@
"Apply to All Books": "应用于所有书籍",
"Apply to This Book": "应用于此书籍",
"Unable to fetch the translation. Try again later.": "无法获取翻译,请稍后重试。",
"Update Now!": "立即更新!",
"Update": "更新",
"Check Update": "检查更新",
"Already the latest version": "已经是最新版本",
"Book Details": "书籍详情",
@@ -152,7 +150,6 @@
"Text Indent": "首行缩进",
"Paragraph Margin": "段间距",
"Override Book Layout": "覆盖版面布局",
"Add to Group": "添加到分组",
"Untitled Group": "无标题分组",
"Group Books": "分组书籍",
"Remove From Group": "从分组中移除",
@@ -231,7 +228,7 @@
"Maximum Column Height": "最大列高",
"Maximum Column Width": "最大列宽",
"Continuous Scroll": "连续滚动",
"Fullscreen": "全屏",
"Fullscreen": "全屏显示",
"No supported files found. Supported formats: {{formats}}": "未找到支持的文件。支持的格式:{{formats}}",
"Drop to Import Books": "拖放导入书籍",
"Custom": "自定义",
@@ -243,6 +240,55 @@
"Preview": "预览",
"Contrast": "对比",
"Sunset": "日落",
"Double Border": "页面版框",
"Border Color": "版框颜色"
"Double Border": "显示版框",
"Border Color": "版框颜色",
"Border Frame": "页面版框",
"Show Header": "显示页眉",
"Show Footer": "显示页脚",
"Small": "小",
"Large": "大",
"Auto": "自动",
"Language": "语言",
"No annotations to export": "没有要导出的笔记",
"Author": "作者",
"Exported from Readest": "从 Readest 导出",
"Highlights & Annotations": "划线和笔记",
"Note": "笔记",
"Copied to clipboard": "已复制到剪贴板",
"Export Annotations": "导出笔记",
"Auto Import on File Open": "打开文件时自动导入",
"LXGW WenKai GB Screen": "霞鹜文楷",
"LXGW WenKai TC": "霞鶩文楷",
"Swap Click-to-Flip Area": "交换点击翻页区域",
"No chapters detected.": "未检测到章节。",
"Failed to parse the EPUB file.": "无法解析 EPUB 文件。",
"This book format is not supported.": "不支持此书籍格式。",
"Unable to fetch the translation. Please log in first and try again.": "无法获取翻译。请登录后重试。",
"Group": "分组",
"Always on Top": "窗口置顶",
"No Timeout": "关闭定时",
"{{value}} minute": "{{value}}分钟",
"{{value}} minutes": "{{value}}分钟",
"{{value}} hour": "{{value}}小时",
"{{value}} hours": "{{value}}小时",
"CJK Font": "中文字体",
"Clear Search": "清除搜索",
"Header & Footer": "页眉页脚",
"Apply also in Scrolled Mode": "应用到滚动模式",
"A new version of Readest is Available!": "有新版本的 Readest 可更新!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} 可更新(已安装版本 {{currentVersion}})。",
"Download and install now?": "立即下载并安装?",
"Downloading {{downloaded}} of {{contentLength}}": "正在下载 {{downloaded}} / {{contentLength}}",
"Download finished": "下载完成",
"DOWNLOAD & INSTALL": "下载并安装",
"Changelog:": "更新日志:",
"Redownload": "重新下载",
"Software Update": "软件更新",
"Title": "书名",
"Date Read": "阅读日期",
"Date Added": "添加日期",
"Format": "格式",
"Ascending": "升序",
"Descending": "降序",
"Sort by...": "排序方式..."
}
@@ -126,8 +126,6 @@
"Apply to All Books": "應用於所有書籍",
"Apply to This Book": "應用於此書籍",
"Unable to fetch the translation. Try again later.": "無法獲取翻譯,請稍後再試。",
"Update Now!": "立即更新!",
"Update": "更新",
"Check Update": "檢查更新",
"Already the latest version": "已經是最新版本",
"Book Details": "書籍詳情",
@@ -152,7 +150,6 @@
"Text Indent": "首行縮進",
"Paragraph Margin": "段間距",
"Override Book Layout": "覆蓋版面佈局",
"Add to Group": "添加到組",
"Untitled Group": "無標題組",
"Group Books": "分組書籍",
"Remove From Group": "從組中移除",
@@ -243,6 +240,55 @@
"Preview": "預覽",
"Contrast": "對比",
"Sunset": "日落",
"Double Border": "頁面邊框",
"Border Color": "邊框顏色"
"Double Border": "顯示邊框",
"Border Color": "邊框顏色",
"Border Frame": "頁面邊框",
"Show Header": "顯示頁眉",
"Show Footer": "顯示頁腳",
"Small": "小",
"Large": "大",
"Auto": "自動",
"Language": "語言",
"No annotations to export": "沒有要匯出的筆記",
"Author": "作者",
"Exported from Readest": "從 Readest 匯出",
"Highlights & Annotations": "高亮和筆記",
"Note": "筆記",
"Copied to clipboard": "已複製到剪貼板",
"Export Annotations": "匯出筆記",
"Auto Import on File Open": "打開文件時自動導入",
"LXGW WenKai GB Screen": "霞鶩文楷 SC",
"LXGW WenKai TC": "霞鶩文楷 TC",
"Swap Click-to-Flip Area": "交換點擊翻頁區域",
"No chapters detected.": "未檢測到章節。",
"Failed to parse the EPUB file.": "無法解析 EPUB 文件。",
"This book format is not supported.": "不支援此書籍格式。",
"Unable to fetch the translation. Please log in first and try again.": "無法獲取翻譯。請先登入然後重試。",
"Group": "分組",
"Always on Top": "視窗置頂",
"No Timeout": "關閉定時",
"{{value}} minute": "{{value}}分鐘",
"{{value}} minutes": "{{value}}分鐘",
"{{value}} hour": "{{value}}小時",
"{{value}} hours": "{{value}}小時",
"CJK Font": "中文字體",
"Clear Search": "清除搜索",
"Header & Footer": "頁眉頁腳",
"Apply also in Scrolled Mode": "滾動模式同樣適用",
"A new version of Readest is Available!": "有新版本的 Readest 可更新!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} 可更新(已安裝版本 {{currentVersion}})。",
"Download and install now?": "立即下載並安裝?",
"Downloading {{downloaded}} of {{contentLength}}": "正在下載 {{downloaded}} / {{contentLength}}",
"Download finished": "下載完成",
"DOWNLOAD & INSTALL": "下載並安裝",
"Changelog:": "更新日誌:",
"Redownload": "重新下載",
"Software Update": "軟體更新",
"Title": "書名",
"Date Read": "閱讀日期",
"Date Added": "添加日期",
"Format": "格式",
"Ascending": "升序",
"Descending": "降序",
"Sort by...": "排序方式..."
}
+101
View File
@@ -1,5 +1,106 @@
{
"releases": {
"0.9.35": {
"date": "2025-04-15",
"notes": [
"🛠️ Fixed an issue where books without cover images can not be synced across devices",
"📱 Added in-app update support for Android",
"📚 You can now sort books by title or author in your library"
]
},
"0.9.33": {
"date": "2025-04-13",
"notes": [
"New: Redesigned update dialog with a progress bar during downloads for a smoother update experience.",
"New: You can now set header and footer visibility separately for paginated and scroll modes.",
"Fixed layout issues in the bottom configuration panel when using RTL (right-to-left) languages.",
"Fixed TXT file import issues on Android by improving support for content provider file paths.",
"Fixed transformed punctuations when highlighting text with vertical layout.",
"Improved login flow on macOS by handling OAuth with the native ASWebAuthenticationSession."
]
},
"0.9.32": {
"date": "2025-04-09",
"notes": [
"No more resetting of font size and color when you apply custom book styles",
"The underline and squiggly highlight decorations have been adjusted to sit neatly between text lines",
"With more font options available, the font menu is no longer forced in-line",
"LXGW WenKai TC is now part of our CJK font list"
]
},
"0.9.31": {
"date": "2025-04-08",
"notes": [
"iOS: Text-to-Speech (TTS) now works in the background",
"Android: Import files from native file manager",
"Desktop: Add option to keep window on top",
"Vertical layout for highlighting tools",
"Added options to auto-stop TTS after timeout",
"Added book search functionality in bookshelf",
"Improved font support for CJK users"
]
},
"0.9.30": {
"date": "2025-04-05",
"notes": [
"Added fixed storage quata when running the app in self-hosted mode",
"Link colors will only change when you override fonts",
"Added cache for native files for better performance"
]
},
"0.9.29": {
"date": "2025-04-02",
"notes": [
"Improved Login on Android without needing to rely on external Chrome browser",
"Local large book files now load more efficiently on Android",
"Balanced the load of online service between different types of users to keep things running smoothly for everyone"
]
},
"0.9.28": {
"date": "2025-03-30",
"notes": [
"Improved vertical reading mode with better support for Chinese punctuations",
"Fixed TXT file importing issue on desktop apps — TXT books now load reliably and display as expected",
"Improved annotation experience — highlighting tools are now properly dismissed when adding notes to avoid UI conflicts"
]
},
"0.9.27": {
"date": "2025-03-29",
"notes": [
"Added option to swap click-to-flip area and reset password",
"Added global shortcuts for full-text search",
"Added support for both R2 and S3 storage in cloud sync",
"Improved TTS with fewer unintended pauses, faster startup, and support for preferred voices",
"Various fixes for rendering styles and UI layout"
]
},
"0.9.26": {
"date": "2025-03-27",
"notes": [
"Support open book from file manager without importing",
"Add LXGW WenKai font in Serif fonts list",
"Various fixes on layout and styles on Windows and Android"
]
},
"0.9.25": {
"date": "2025-03-24",
"notes": [
"Support exporting annotations in markdown format",
"Support importing TXT files",
"Support setting reader language in the UI",
"Add bottom action bar on mobile platforms",
"Various fixes on layout settings and styles"
]
},
"0.9.23": {
"date": "2025-03-19",
"notes": [
"Add options to show/hide header and footer",
"Add responsive window size for popup footnotes",
"Fix theme color not applied on Safari browsers",
"Fix progress not updated to 100% when finished reading"
]
},
"0.9.22": {
"date": "2025-03-16",
"notes": [
+25 -22
View File
@@ -1,6 +1,6 @@
[package]
name = "Readest"
version = "0.2.1"
version = "0.2.2"
description = "Your online library"
authors = ["Bilingify LLC"]
license = ""
@@ -15,7 +15,7 @@ name = "readestlib"
crate-type = ["staticlib", "cdylib", "lib"]
[build-dependencies]
tauri-build = { version = "2.0.3", features = [] }
tauri-build = { version = "2.1.1", features = [] }
[dependencies]
serde_json = "1.0"
@@ -31,33 +31,36 @@ reqwest = { version = "0.12", default-features = false, features = [
"stream",
] }
# FIXME: remove the devtools feature in production
tauri = { version = "2.1.1", features = [ "protocol-asset", "devtools"] }
tauri-build = "2.0.3"
tauri-plugin-log = "2.0.2"
tauri-plugin-fs = "2.0.3"
tauri-plugin-dialog = "2.0.3"
tauri-plugin-os = "2.0.1"
tauri-plugin-http = "2.0.3"
tauri-plugin-devtools = "2.0.0"
tauri-plugin-shell = "2"
tauri-plugin-process = "2"
tauri = { version = "2.4.1", features = [ "protocol-asset", "devtools"] }
tauri-build = "2.1.1"
tauri-plugin-log = "2.3.1"
tauri-plugin-fs = "2.2.1"
tauri-plugin-dialog = "2.2.1"
tauri-plugin-os = "2.2.1"
tauri-plugin-http = "2.4.3"
tauri-plugin-shell = "2.2.1"
tauri-plugin-process = "2.2.1"
tauri-plugin-oauth = "2"
tauri-plugin-opener = "2.2.2"
tauri-plugin-deep-link = "2"
tauri-plugin-opener = "2.2.6"
tauri-plugin-deep-link = "2.2.1"
tauri-plugin-sign-in-with-apple = "1.0.2"
tauri-plugin-haptics = "2.2.3"
tauri-plugin-safari-auth = { path = "./plugins/tauri-plugin-safari-auth" }
[patch.crates-io]
tauri = { path = "../../../packages/tauri/crates/tauri" }
tauri-plugin-haptics = "2.2.4"
tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
[target."cfg(target_os = \"macos\")".dependencies]
rand = "0.8"
cocoa = "0.25"
objc = "0.2.7"
rand = "0.8"
objc-foundation = "0.1.1"
objc_id = "0.1.1"
block = "0.1.6"
objc2 = "0.6"
objc2-authentication-services = "0.3"
objc2-foundation = { version = "0.3", features = ["NSError", "NSArray"] }
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-cli = "2"
tauri-plugin-single-instance = "2.2.0"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2.2.0"
tauri-plugin-single-instance = "2.2.3"
tauri-plugin-updater = "2.7.0"
tauri-plugin-window-state = "2.2.2"
font-enumeration = "0.9.0"
@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": ["main"],
"windows": ["main", "updater"],
"permissions": [
"core:default",
"fs:default",
@@ -45,6 +45,9 @@
{
"url": "https://*.deepl.com"
},
{
"url": "https://github.com/readest/*"
},
{
"url": "https://*.cloudflarestorage.com"
}
@@ -57,12 +60,17 @@
"core:window:allow-center",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-set-size",
"core:window:allow-set-focus",
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"core:window:allow-set-fullscreen",
"core:window:allow-set-always-on-top",
"core:webview:allow-create-webview-window",
"core:path:allow-resolve-directory",
"log:default",
"shell:default",
"process:default",
"process:allow-exit",
@@ -75,7 +83,7 @@
"haptics:allow-impact-feedback",
"haptics:allow-notification-feedback",
"haptics:allow-selection-feedback",
"safari-auth:default",
"native-bridge:default",
"deep-link:default"
]
}
@@ -1,10 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop-capability",
"windows": ["main"],
"windows": ["main", "updater"],
"platforms": ["linux", "macOS", "windows"],
"permissions": [
"updater:default",
"cli:default"
]
"permissions": ["updater:default", "cli:default"]
}
@@ -0,0 +1,58 @@
<?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"/>
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:largeHeap="true"
android:label="@string/app_name"
android:theme="@style/Theme.readest"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
<intent-filter android:label="oauth">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="readest" android:host="auth-callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="web.readest.com" />
</intent-filter>
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -2,10 +2,14 @@ package com.bilingify.readest
import android.os.Build
import android.os.Bundle
import android.content.Intent
import android.net.Uri
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import androidx.activity.enableEdgeToEdge
import app.tauri.plugin.JSObject
import com.readest.native_bridge.NativeBridgePlugin
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -15,6 +19,20 @@ class MainActivity : TauriActivity() {
hideSystemUI()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
val uri = intent.data ?: return
if (uri.scheme == "readest" && uri.host == "auth-callback") {
val result = JSObject().apply {
put("redirectUrl", uri.toString())
}
NativeBridgePlugin.pendingInvoke?.resolve(result)
NativeBridgePlugin.pendingInvoke = null
}
}
private fun hideSystemUI() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.decorView.windowInsetsController?.let { controller ->
@@ -0,0 +1,19 @@
[package]
name = "tauri-plugin-native-bridge"
version = "0.1.0"
authors = [ "You" ]
description = "a bridge between tauri app and native OS functionality"
edition = "2021"
rust-version = "1.77.2"
exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
links = "tauri-plugin-native-bridge"
[dependencies]
tauri = { version = "2.3.1" }
serde = "1.0"
thiserror = "2"
schemars = "0.8"
[build-dependencies]
tauri-plugin = { version = "2.0.4", features = ["build"] }
schemars = "0.8"
@@ -0,0 +1 @@
# Tauri Plugin native-bridge
@@ -4,7 +4,7 @@ plugins {
}
android {
namespace = "com.plugin.safari_auth"
namespace = "com.readest.native_bridge"
compileSdk = 34
defaultConfig {
@@ -33,9 +33,9 @@ android {
}
dependencies {
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("androidx.browser:browser:1.8.0")
implementation("com.google.android.material:material:1.7.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
@@ -1,4 +1,4 @@
package com.plugin.safari_auth
package com.readest.native_bridge
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.plugin.safari_auth", appContext.packageName)
assertEquals("com.readest.native_bridge", appContext.packageName)
}
}
@@ -0,0 +1,6 @@
package com.readest.native_bridge
import android.util.Log
class NativeBridge {
}
@@ -0,0 +1,113 @@
package com.readest.native_bridge
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
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import app.tauri.plugin.Invoke
import java.io.*
@InvokeArg
class AuthRequestArgs {
var authUrl: String? = null
}
@InvokeArg
class CopyURIRequestArgs {
var uri: String? = null
var dst: String? = null
}
@InvokeArg
class InstallPackageRequestArgs {
var path: String? = null
}
@TauriPlugin
class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
private val implementation = NativeBridge()
private var redirectScheme = "readest"
private var redirectHost = "auth-callback"
companion object {
var pendingInvoke: Invoke? = null
}
@Command
fun auth_with_custom_tab(invoke: Invoke) {
val args = invoke.parseArgs(AuthRequestArgs::class.java)
val uri = Uri.parse(args.authUrl)
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.intent.flags = Intent.FLAG_ACTIVITY_NO_HISTORY
Log.d("NativeBridgePlugin", "Launching OAuth URL: ${args.authUrl}")
customTabsIntent.launchUrl(activity, uri)
pendingInvoke = invoke
}
@Command
fun copy_uri_to_path(invoke: Invoke) {
val args = invoke.parseArgs(CopyURIRequestArgs::class.java)
val ret = JSObject()
try {
val uri = Uri.parse(args.uri ?: "")
val dst = File(args.dst ?: "")
val inputStream = activity.contentResolver.openInputStream(uri)
if (inputStream != null) {
dst.outputStream().use { output ->
inputStream.use { input ->
input.copyTo(output)
}
}
ret.put("success", true)
} else {
ret.put("success", false)
ret.put("error", "Failed to open input stream from URI")
}
} catch (e: Exception) {
ret.put("success", false)
ret.put("error", e.message)
}
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)
}
}
@@ -0,0 +1,14 @@
const COMMANDS: &[&str] = &[
"auth_with_safari",
"auth_with_custom_tab",
"copy_uri_to_path",
"use_background_audio",
"install_package",
];
fn main() {
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.ios_path("ios")
.build();
}
@@ -4,7 +4,7 @@
import PackageDescription
let package = Package(
name: "tauri-plugin-safari-auth",
name: "tauri-plugin-native-bridge",
platforms: [
.macOS(.v10_13),
.iOS(.v13),
@@ -12,9 +12,9 @@ let package = Package(
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "tauri-plugin-safari-auth",
name: "tauri-plugin-native-bridge",
type: .static,
targets: ["tauri-plugin-safari-auth"]),
targets: ["tauri-plugin-native-bridge"]),
],
dependencies: [
.package(name: "Tauri", path: "../.tauri/tauri-api")
@@ -23,7 +23,7 @@ let package = Package(
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "tauri-plugin-safari-auth",
name: "tauri-plugin-native-bridge",
dependencies: [
.byName(name: "Tauri")
],
@@ -1,3 +1,3 @@
# Tauri Plugin safari-auth
# Tauri Plugin native-bridge
A description of this package.
@@ -1,22 +1,44 @@
import AuthenticationServices
import AVFoundation
import MediaPlayer
import SwiftRs
import Tauri
import UIKit
import WebKit
class RequestArgs: Decodable {
class SafariAuthRequestArgs: Decodable {
let authUrl: String
}
struct Response: Encodable {
let redirectUrl: String
class UseBackgroundAudioRequestArgs: Decodable {
let enabled: Bool
}
class SafariAuthPlugin: Plugin {
class NativeBridgePlugin: Plugin {
private var authSession: ASWebAuthenticationSession?
@objc public func use_background_audio(_ invoke: Invoke) {
do {
let args = try invoke.parseArgs(UseBackgroundAudioRequestArgs.self)
let enabled = args.enabled
let session = AVAudioSession.sharedInstance()
if enabled {
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
try session.setActive(true)
print("AVAudioSession activated")
} else {
try session.setActive(false)
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
print("AVAudioSession deactivated")
}
invoke.resolve()
} catch {
print("Failed to set up audio session:", error)
}
}
@objc public func auth_with_safari(_ invoke: Invoke) throws {
let args = try invoke.parseArgs(RequestArgs.self)
let args = try invoke.parseArgs(SafariAuthRequestArgs.self)
let authUrl = URL(string: args.authUrl)!
authSession = ASWebAuthenticationSession(url: authUrl, callbackURLScheme: "readest") {
@@ -46,14 +68,14 @@ class SafariAuthPlugin: Plugin {
}
}
@_cdecl("init_plugin_safari_auth")
@_cdecl("init_plugin_native_bridge")
func initPlugin() -> Plugin {
return SafariAuthPlugin()
return NativeBridgePlugin()
}
@available(iOS 13.0, *)
extension SafariAuthPlugin: ASWebAuthenticationPresentationContextProviding {
extension NativeBridgePlugin: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return UIApplication.shared.windows.first ?? UIWindow()
}
}
}
@@ -0,0 +1,8 @@
import XCTest
@testable import NativeBridgePlugin
final class NativeBridgePluginTests: XCTestCase {
func testNativeBridge() throws {
let plugin = NativeBridgePlugin()
}
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-auth-with-custom-tab"
description = "Enables the auth_with_custom_tab command without any pre-configured scope."
commands.allow = ["auth_with_custom_tab"]
[[permission]]
identifier = "deny-auth-with-custom-tab"
description = "Denies the auth_with_custom_tab command without any pre-configured scope."
commands.deny = ["auth_with_custom_tab"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-copy-uri-to-path"
description = "Enables the copy_uri_to_path command without any pre-configured scope."
commands.allow = ["copy_uri_to_path"]
[[permission]]
identifier = "deny-copy-uri-to-path"
description = "Denies the copy_uri_to_path command without any pre-configured scope."
commands.deny = ["copy_uri_to_path"]
@@ -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"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-use-background-audio"
description = "Enables the use_background_audio command without any pre-configured scope."
commands.allow = ["use_background_audio"]
[[permission]]
identifier = "deny-use-background-audio"
description = "Denies the use_background_audio command without any pre-configured scope."
commands.deny = ["use_background_audio"]
@@ -0,0 +1,151 @@
## Default Permission
Default permissions for the plugin
#### This default permission set includes the following:
- `allow-auth-with-safari`
- `allow-auth-with-custom-tab`
- `allow-copy-uri-to-path`
- `allow-use-background-audio`
- `allow-install-package`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`native-bridge:allow-auth-with-custom-tab`
</td>
<td>
Enables the auth_with_custom_tab command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-auth-with-custom-tab`
</td>
<td>
Denies the auth_with_custom_tab command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-auth-with-safari`
</td>
<td>
Enables the auth_with_safari command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-auth-with-safari`
</td>
<td>
Denies the auth_with_safari command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-copy-uri-to-path`
</td>
<td>
Enables the copy_uri_to_path command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-copy-uri-to-path`
</td>
<td>
Denies the copy_uri_to_path command without any pre-configured scope.
</td>
</tr>
<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>
<td>
Enables the use_background_audio command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-use-background-audio`
</td>
<td>
Denies the use_background_audio command without any pre-configured scope.
</td>
</tr>
</table>
@@ -0,0 +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",
"allow-install-package",
]
@@ -49,7 +49,7 @@
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use <h4> headings in markdown content for Tauri documentation generation purposes.",
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
@@ -111,7 +111,7 @@
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use <h4> headings in markdown content for Tauri documentation generation purposes.",
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
@@ -294,20 +294,71 @@
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the auth_with_custom_tab command without any pre-configured scope.",
"type": "string",
"const": "allow-auth-with-custom-tab",
"markdownDescription": "Enables the auth_with_custom_tab command without any pre-configured scope."
},
{
"description": "Denies the auth_with_custom_tab command without any pre-configured scope.",
"type": "string",
"const": "deny-auth-with-custom-tab",
"markdownDescription": "Denies the auth_with_custom_tab command without any pre-configured scope."
},
{
"description": "Enables the auth_with_safari command without any pre-configured scope.",
"type": "string",
"const": "allow-auth-with-safari"
"const": "allow-auth-with-safari",
"markdownDescription": "Enables the auth_with_safari command without any pre-configured scope."
},
{
"description": "Denies the auth_with_safari command without any pre-configured scope.",
"type": "string",
"const": "deny-auth-with-safari"
"const": "deny-auth-with-safari",
"markdownDescription": "Denies the auth_with_safari command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin",
"description": "Enables the copy_uri_to_path command without any pre-configured scope.",
"type": "string",
"const": "default"
"const": "allow-copy-uri-to-path",
"markdownDescription": "Enables the copy_uri_to_path command without any pre-configured scope."
},
{
"description": "Denies the copy_uri_to_path command without any pre-configured scope.",
"type": "string",
"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",
"const": "allow-use-background-audio",
"markdownDescription": "Enables the use_background_audio command without any pre-configured scope."
},
{
"description": "Denies the use_background_audio command without any pre-configured scope.",
"type": "string",
"const": "deny-use-background-audio",
"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`\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`\n- `allow-install-package`"
}
]
}
@@ -0,0 +1,45 @@
use tauri::{command, AppHandle, Runtime};
use crate::models::*;
use crate::NativeBridgeExt;
use crate::Result;
#[command]
pub(crate) async fn auth_with_safari<R: Runtime>(
app: AppHandle<R>,
payload: AuthRequest,
) -> Result<AuthResponse> {
app.native_bridge().auth_with_safari(payload)
}
#[command]
pub(crate) async fn auth_with_custom_tab<R: Runtime>(
app: AppHandle<R>,
payload: AuthRequest,
) -> Result<AuthResponse> {
app.native_bridge().auth_with_custom_tab(payload)
}
#[command]
pub(crate) async fn copy_uri_to_path<R: Runtime>(
app: AppHandle<R>,
payload: CopyURIRequest,
) -> Result<CopyURIResponse> {
app.native_bridge().copy_uri_to_path(payload)
}
#[command]
pub(crate) async fn use_background_audio<R: Runtime>(
app: AppHandle<R>,
payload: UseBackgroundAudioRequest,
) -> 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)
}
@@ -0,0 +1,39 @@
use serde::de::DeserializeOwned;
use tauri::{plugin::PluginApi, AppHandle, Runtime};
use crate::models::*;
pub fn init<R: Runtime, C: DeserializeOwned>(
app: &AppHandle<R>,
_api: PluginApi<R, C>,
) -> crate::Result<NativeBridge<R>> {
Ok(NativeBridge(app.clone()))
}
/// Access to the native-bridge APIs.
pub struct NativeBridge<R: Runtime>(AppHandle<R>);
impl<R: Runtime> NativeBridge<R> {
pub fn auth_with_safari(&self, _payload: AuthRequest) -> crate::Result<AuthResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn auth_with_custom_tab(&self, _payload: AuthRequest) -> crate::Result<AuthResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn copy_uri_to_path(&self, _payload: CopyURIRequest) -> crate::Result<CopyURIResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
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)
}
}
@@ -6,6 +6,8 @@ pub type Result<T> = std::result::Result<T, Error>;
pub enum Error {
#[error("Unsupported platform for this plugin")]
UnsupportedPlatformError,
#[error("Native bridge error: {0}")]
NativeBridgeError(String),
#[error(transparent)]
Io(#[from] std::io::Error),
#[cfg(mobile)]
@@ -0,0 +1,55 @@
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime,
};
pub use models::*;
#[cfg(desktop)]
mod desktop;
#[cfg(mobile)]
mod mobile;
mod commands;
mod error;
mod models;
mod platform;
pub use error::{Error, Result};
#[cfg(desktop)]
use desktop::NativeBridge;
#[cfg(mobile)]
use mobile::NativeBridge;
/// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the native-bridge APIs.
pub trait NativeBridgeExt<R: Runtime> {
fn native_bridge(&self) -> &NativeBridge<R>;
}
impl<R: Runtime, T: Manager<R>> crate::NativeBridgeExt<R> for T {
fn native_bridge(&self) -> &NativeBridge<R> {
self.state::<NativeBridge<R>>().inner()
}
}
/// Initializes the plugin.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("native-bridge")
.invoke_handler(tauri::generate_handler![
commands::auth_with_safari,
commands::auth_with_custom_tab,
commands::copy_uri_to_path,
commands::use_background_audio,
commands::install_package,
])
.setup(|app, api| {
#[cfg(mobile)]
let native_bridge = mobile::init(app, api)?;
#[cfg(desktop)]
let native_bridge = desktop::init(app, api)?;
app.manage(native_bridge);
Ok(())
})
.build()
}
@@ -0,0 +1,68 @@
use serde::de::DeserializeOwned;
use tauri::{
plugin::{PluginApi, PluginHandle},
AppHandle, Runtime,
};
use crate::models::*;
#[cfg(target_os = "ios")]
tauri::ios_plugin_binding!(init_plugin_native_bridge);
// initializes the Kotlin or Swift plugin classes
pub fn init<R: Runtime, C: DeserializeOwned>(
_app: &AppHandle<R>,
api: PluginApi<R, C>,
) -> crate::Result<NativeBridge<R>> {
#[cfg(target_os = "android")]
let handle = api.register_android_plugin("com.readest.native_bridge", "NativeBridgePlugin")?;
#[cfg(target_os = "ios")]
let handle = api.register_ios_plugin(init_plugin_native_bridge)?;
Ok(NativeBridge(handle))
}
/// Access to the native-bridge APIs.
pub struct NativeBridge<R: Runtime>(PluginHandle<R>);
impl<R: Runtime> NativeBridge<R> {
pub fn auth_with_safari(&self, payload: AuthRequest) -> crate::Result<AuthResponse> {
self.0
.run_mobile_plugin("auth_with_safari", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn auth_with_custom_tab(&self, payload: AuthRequest) -> crate::Result<AuthResponse> {
self.0
.run_mobile_plugin("auth_with_custom_tab", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn copy_uri_to_path(&self, payload: CopyURIRequest) -> crate::Result<CopyURIResponse> {
self.0
.run_mobile_plugin("copy_uri_to_path", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn use_background_audio(&self, payload: UseBackgroundAudioRequest) -> crate::Result<()> {
self.0
.run_mobile_plugin("use_background_audio", payload)
.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)
}
}
@@ -0,0 +1,46 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthRequest {
pub auth_url: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthResponse {
pub redirect_url: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CopyURIRequest {
pub uri: String,
pub dst: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CopyURIResponse {
pub success: bool,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
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>,
}
@@ -0,0 +1,2 @@
#[cfg(target_os = "macos")]
pub mod macos;
@@ -1,17 +0,0 @@
[package]
name = "tauri-plugin-safari-auth"
version = "0.1.0"
authors = [ "chrox" ]
description = "OAuth with ASWebAuthenticationSession on iOS"
edition = "2021"
rust-version = "1.77.2"
exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
links = "tauri-plugin-safari-auth"
[dependencies]
tauri = { version = "2.2.4" }
serde = "1.0"
thiserror = "2"
[build-dependencies]
tauri-plugin = { version = "2.0.3", features = ["build"] }
@@ -1 +0,0 @@
# Tauri Plugin safari-auth
@@ -1,10 +0,0 @@
package com.plugin.safari_auth
import android.util.Log
class Example {
fun pong(value: String): String {
Log.i("Pong", value)
return value
}
}
@@ -1,28 +0,0 @@
package com.plugin.safari_auth
import android.app.Activity
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import app.tauri.plugin.Invoke
@InvokeArg
class PingArgs {
var value: String? = null
}
@TauriPlugin
class ExamplePlugin(private val activity: Activity): Plugin(activity) {
private val implementation = Example()
@Command
fun ping(invoke: Invoke) {
val args = invoke.parseArgs(PingArgs::class.java)
val ret = JSObject()
ret.put("value", implementation.pong(args.value ?: "default value :("))
invoke.resolve(ret)
}
}
@@ -1,8 +0,0 @@
const COMMANDS: &[&str] = &["auth_with_safari"];
fn main() {
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.ios_path("ios")
.build();
}
@@ -1,8 +0,0 @@
import XCTest
@testable import SafariAuthPlugin
final class SafariAuthPluginTests: XCTestCase {
func testExample() throws {
let plugin = SafariAuthPlugin()
}
}
@@ -1,41 +0,0 @@
## Default Permission
Default permissions for the plugin
- `allow-auth-with-safari`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`safari-auth:allow-auth-with-safari`
</td>
<td>
Enables the auth_with_safari command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`safari-auth:deny-auth-with-safari`
</td>
<td>
Denies the auth_with_safari command without any pre-configured scope.
</td>
</tr>
</table>
@@ -1,3 +0,0 @@
[default]
description = "Default permissions for the plugin"
permissions = ["allow-auth-with-safari"]
@@ -1,13 +0,0 @@
use tauri::{command, AppHandle, Runtime};
use crate::models::*;
use crate::Result;
use crate::SafariAuthExt;
#[command]
pub(crate) async fn auth_with_safari<R: Runtime>(
app: AppHandle<R>,
payload: SafariAuthRequest,
) -> Result<SafariAuthResponse> {
app.safari_auth().auth_with_safari(payload)
}
@@ -1,23 +0,0 @@
use serde::de::DeserializeOwned;
use tauri::{plugin::PluginApi, AppHandle, Runtime};
use crate::models::*;
pub fn init<R: Runtime, C: DeserializeOwned>(
app: &AppHandle<R>,
_api: PluginApi<R, C>,
) -> crate::Result<SafariAuth<R>> {
Ok(SafariAuth(app.clone()))
}
/// Access to the safari-auth APIs.
pub struct SafariAuth<R: Runtime>(AppHandle<R>);
impl<R: Runtime> SafariAuth<R> {
pub fn auth_with_safari(
&self,
_payload: SafariAuthRequest,
) -> crate::Result<SafariAuthResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -1,48 +0,0 @@
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime,
};
pub use models::*;
#[cfg(desktop)]
mod desktop;
#[cfg(mobile)]
mod mobile;
mod commands;
mod error;
mod models;
pub use error::{Error, Result};
#[cfg(desktop)]
use desktop::SafariAuth;
#[cfg(mobile)]
use mobile::SafariAuth;
/// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the safari-auth APIs.
pub trait SafariAuthExt<R: Runtime> {
fn safari_auth(&self) -> &SafariAuth<R>;
}
impl<R: Runtime, T: Manager<R>> crate::SafariAuthExt<R> for T {
fn safari_auth(&self) -> &SafariAuth<R> {
self.state::<SafariAuth<R>>().inner()
}
}
/// Initializes the plugin.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("safari-auth")
.invoke_handler(tauri::generate_handler![commands::auth_with_safari])
.setup(|app, api| {
#[cfg(mobile)]
let safari_auth = mobile::init(app, api)?;
#[cfg(desktop)]
let safari_auth = desktop::init(app, api)?;
app.manage(safari_auth);
Ok(())
})
.build()
}
@@ -1,37 +0,0 @@
use serde::de::DeserializeOwned;
use tauri::{
plugin::{PluginApi, PluginHandle},
AppHandle, Runtime,
};
use crate::models::*;
#[cfg(target_os = "ios")]
tauri::ios_plugin_binding!(init_plugin_safari_auth);
// initializes the Kotlin or Swift plugin classes
pub fn init<R: Runtime, C: DeserializeOwned>(
_app: &AppHandle<R>,
api: PluginApi<R, C>,
) -> crate::Result<SafariAuth<R>> {
#[cfg(target_os = "android")]
let handle = api.register_android_plugin("com.bilingify.safari_auth", "ExamplePlugin")?;
#[cfg(target_os = "ios")]
let handle = api.register_ios_plugin(init_plugin_safari_auth)?;
Ok(SafariAuth(handle))
}
/// Access to the safari-auth APIs.
pub struct SafariAuth<R: Runtime>(PluginHandle<R>);
impl<R: Runtime> SafariAuth<R> {
pub fn auth_with_safari(
&self,
payload: SafariAuthRequest,
) -> crate::Result<SafariAuthResponse> {
self.0
.run_mobile_plugin("auth_with_safari", payload)
.map_err(Into::into)
}
}
@@ -1,13 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SafariAuthRequest {
pub auth_url: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SafariAuthResponse {
pub redirect_url: String,
}
+73 -48
View File
@@ -6,13 +6,6 @@ extern crate cocoa;
#[macro_use]
extern crate objc;
#[cfg(target_os = "macos")]
mod menu;
#[cfg(target_os = "macos")]
mod traffic_light;
#[cfg(target_os = "macos")]
use traffic_light::set_traffic_lights;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
@@ -23,6 +16,8 @@ use tauri::{AppHandle, Listener, Manager, Url};
#[cfg(desktop)]
use tauri_plugin_fs::FsExt;
#[cfg(target_os = "macos")]
mod macos;
mod transfer_file;
use tauri::{command, Emitter, WebviewUrl, WebviewWindowBuilder, Window};
use tauri_plugin_oauth::start;
@@ -46,6 +41,31 @@ fn allow_file_in_scopes(app: &AppHandle, files: Vec<PathBuf>) {
}
}
#[cfg(desktop)]
fn get_files_from_argv(argv: Vec<String>) -> Vec<PathBuf> {
let mut files = Vec::new();
// NOTICE: `args` may include URL protocol (`your-app-protocol://`)
// or arguments (`--`) if your app supports them.
// files may also be passed as `file://path/to/file`
for (_, maybe_file) in argv.iter().enumerate().skip(1) {
// skip flags like -f or --flag
if maybe_file.starts_with("-") {
continue;
}
// handle `file://` path urls and skip other urls
if let Ok(url) = Url::parse(maybe_file) {
if let Ok(path) = url.to_file_path() {
files.push(path);
} else {
files.push(PathBuf::from(maybe_file))
}
} else {
files.push(PathBuf::from(maybe_file))
}
}
files
}
#[cfg(desktop)]
fn set_window_open_with_files(app: &AppHandle, files: Vec<PathBuf>) {
let files = files
@@ -63,6 +83,15 @@ fn set_window_open_with_files(app: &AppHandle, files: Vec<PathBuf>) {
}
}
#[cfg(desktop)]
fn set_rounded_window(app: &AppHandle, rounded: bool) {
let window = app.get_webview_window("main").unwrap();
let script = format!("window.IS_ROUNDED = {};", rounded);
if let Err(e) = window.eval(&script) {
eprintln!("Failed to set IS_ROUNDED variable: {}", e);
}
}
#[command]
async fn start_server(window: Window) -> Result<u16, String> {
start(move |url| {
@@ -101,7 +130,11 @@ pub fn run() {
download_file,
upload_file,
#[cfg(target_os = "macos")]
set_traffic_lights,
macos::safari_auth::auth_with_safari,
#[cfg(target_os = "macos")]
macos::apple_auth::start_apple_sign_in,
#[cfg(target_os = "macos")]
macos::traffic_light::set_traffic_lights,
#[cfg(desktop)]
list_fonts
])
@@ -110,6 +143,7 @@ pub fn run() {
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_native_bridge::init())
.plugin(tauri_plugin_fs::init());
#[cfg(desktop)]
@@ -118,6 +152,10 @@ pub fn run() {
.get_webview_window("main")
.expect("no main window")
.set_focus();
let files = get_files_from_argv(argv.clone());
if !files.is_empty() {
allow_file_in_scopes(app, files.clone());
}
app.emit("single-instance", Payload { args: argv, cwd })
.unwrap();
}));
@@ -131,14 +169,14 @@ pub fn run() {
let builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
#[cfg(target_os = "macos")]
let builder = builder.plugin(traffic_light::init());
let builder = builder.plugin(macos::traffic_light::init());
#[cfg(target_os = "macos")]
let builder = builder.plugin(macos::safari_auth::init());
#[cfg(target_os = "ios")]
let builder = builder.plugin(tauri_plugin_sign_in_with_apple::init());
#[cfg(target_os = "ios")]
let builder = builder.plugin(tauri_plugin_safari_auth::init());
#[cfg(any(target_os = "ios", target_os = "android"))]
let builder = builder.plugin(tauri_plugin_haptics::init());
@@ -146,26 +184,7 @@ pub fn run() {
.setup(|#[allow(unused_variables)] app| {
#[cfg(desktop)]
{
let mut files = Vec::new();
// NOTICE: `args` may include URL protocol (`your-app-protocol://`)
// or arguments (`--`) if your app supports them.
// files may also be passed as `file://path/to/file`
for maybe_file in std::env::args().skip(1) {
// skip flags like -f or --flag
if maybe_file.starts_with("-") {
continue;
}
// handle `file://` path urls and skip other urls
if let Ok(url) = Url::parse(&maybe_file) {
if let Ok(path) = url.to_file_path() {
files.push(path);
} else {
files.push(PathBuf::from(maybe_file))
}
} else {
files.push(PathBuf::from(maybe_file))
}
}
let files = get_files_from_argv(std::env::args().collect());
if !files.is_empty() {
let app_handle = app.handle().clone();
allow_file_in_scopes(&app_handle, files.clone());
@@ -182,9 +201,19 @@ 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")
{
set_rounded_window(&app_handle, false);
}
});
}
@@ -194,21 +223,16 @@ pub fn run() {
app.deep_link().register_all()?;
}
#[cfg(desktop)]
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
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
@@ -225,8 +249,9 @@ pub fn run() {
.title("Readest");
if cfg!(target_os = "windows") {
let version = tauri_plugin_os::version();
if 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);
@@ -243,7 +268,7 @@ pub fn run() {
// win.open_devtools();
#[cfg(target_os = "macos")]
menu::setup_macos_menu(app.handle())?;
macos::menu::setup_macos_menu(app.handle())?;
app.handle().emit("window-ready", ()).unwrap();
@@ -0,0 +1,203 @@
use std::cell::RefCell;
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2::{
define_class, msg_send, AllocAnyThread, DefinedClass, MainThreadMarker, MainThreadOnly,
};
use objc2_authentication_services::{
ASAuthorization, ASAuthorizationAppleIDCredential, ASAuthorizationAppleIDProvider,
ASAuthorizationController, ASAuthorizationControllerDelegate, ASAuthorizationRequest,
ASAuthorizationScopeEmail, ASAuthorizationScopeFullName,
};
use objc2_foundation::{NSArray, NSError, NSObject, NSObjectProtocol, NSString};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tauri::{command, AppHandle, Emitter};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppleIDAuthorizationRequest {
pub scope: Vec<String>,
pub nonce: Option<String>,
pub state: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppleIDAuthorizationResponse {
pub user_identifier: Option<String>,
pub given_name: Option<String>,
pub family_name: Option<String>,
pub email: Option<String>,
pub authorization_code: Option<String>,
pub identity_token: Option<String>,
pub state: Option<String>,
}
thread_local! {
static APPLE_SIGN_IN_DELEGATE: RefCell<Option<Retained<ASAuthorizationControllerDelegateImpl>>> = RefCell::new(None);
static AUTHORIZATION_CONTROLLER: RefCell<Option<Retained<ASAuthorizationController>>> = RefCell::new(None);
}
#[derive(Clone)]
pub struct Ivars {
app: AppHandle,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "ASAuthorizationControllerDelegateImpl"]
#[ivars = Ivars]
pub struct ASAuthorizationControllerDelegateImpl;
unsafe impl NSObjectProtocol for ASAuthorizationControllerDelegateImpl {}
unsafe impl ASAuthorizationControllerDelegate for ASAuthorizationControllerDelegateImpl {
#[unsafe(method(authorizationController:didCompleteWithAuthorization:))]
#[allow(non_snake_case)]
unsafe fn authorizationController_didCompleteWithAuthorization(
&self,
_controller: &ASAuthorizationController,
authorization: &ASAuthorization,
) {
if let Ok(credential) = authorization
.credential()
.downcast::<ASAuthorizationAppleIDCredential>()
{
let user_identifier = {
let user = credential.user();
if user.len() > 0 {
Some(user.to_string())
} else {
None
}
};
let given_name = credential
.fullName()
.and_then(|name| name.givenName())
.map(|nsstr| nsstr.to_string());
let family_name = credential
.fullName()
.and_then(|name| name.familyName())
.map(|nsstr| nsstr.to_string());
let email = credential.email().map(|nsstr| nsstr.to_string());
let authorization_code = credential
.authorizationCode()
.and_then(|code_data| String::from_utf8(code_data.to_vec()).ok());
let identity_token = credential
.identityToken()
.and_then(|token_data| String::from_utf8(token_data.to_vec()).ok());
let state = credential.state().map(|nsstr| nsstr.to_string());
let resp = AppleIDAuthorizationResponse {
user_identifier,
given_name,
family_name,
email,
authorization_code,
identity_token,
state,
};
let _ = self.ivars().app.emit("apple-sign-in-complete", json!(resp));
log::info!("Apple authorization complete");
} else {
let _ = self
.ivars()
.app
.emit("apple-sign-in-error", "Invalid credential type");
log::error!("Invalid credential type received");
}
}
#[unsafe(method(authorizationController:didCompleteWithError:))]
#[allow(non_snake_case)]
unsafe fn authorizationController_didCompleteWithError(
&self,
_controller: &ASAuthorizationController,
error: &NSError,
) {
let code = error.code();
let description = error.localizedDescription().to_string();
let _ = self.ivars().app.emit(
"apple-sign-in-error",
json!({
"code": code,
"description": description,
}),
);
log::error!(
"Authorization error: code={}, description={}",
code,
description
);
}
}
);
impl ASAuthorizationControllerDelegateImpl {
fn new(app: AppHandle) -> Retained<Self> {
let mtm = MainThreadMarker::new().expect("must run on main thread");
let this = Self::alloc(mtm).set_ivars(Ivars { app });
unsafe { msg_send![super(this), init] }
}
}
#[command]
pub fn start_apple_sign_in(app: AppHandle, payload: AppleIDAuthorizationRequest) {
unsafe {
let provider = ASAuthorizationAppleIDProvider::new();
let request = provider.createRequest();
if let Some(ref nonce) = payload.nonce {
let ns_nonce = NSString::from_str(nonce);
request.setNonce(Some(&ns_nonce));
}
if let Some(ref state) = payload.state {
let ns_state = NSString::from_str(state);
request.setState(Some(&ns_state));
}
let mut scopes = Vec::new();
for scope in payload.scope.iter() {
match scope.as_str() {
"email" => scopes.push(ASAuthorizationScopeEmail),
"fullName" => scopes.push(ASAuthorizationScopeFullName),
_ => {
log::warn!("[Apple Sign-In] Unsupported scope: {}", scope);
}
}
}
if !scopes.is_empty() {
request.setRequestedScopes(Some(&*NSArray::from_slice(&scopes)));
}
let auth_request = &request as &ASAuthorizationRequest;
let controller = ASAuthorizationController::initWithAuthorizationRequests(
ASAuthorizationController::alloc(),
&*NSArray::from_slice(&[auth_request]),
);
let delegate = ASAuthorizationControllerDelegateImpl::new(app.clone());
APPLE_SIGN_IN_DELEGATE.with(|cell| {
*cell.borrow_mut() = Some(delegate.clone());
});
AUTHORIZATION_CONTROLLER.with(|cell| {
*cell.borrow_mut() = Some(controller.clone());
});
controller.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
log::info!("Starting Apple Sign-In authorization request");
controller.performRequests();
}
}
@@ -0,0 +1,4 @@
pub mod apple_auth;
pub mod menu;
pub mod safari_auth;
pub mod traffic_light;
@@ -0,0 +1,255 @@
use std::ffi::CStr;
use std::marker::PhantomData;
use std::sync::{Arc, Mutex};
use block::ConcreteBlock;
use objc::declare::ClassDecl;
use objc::runtime::{Class, Object, Sel, BOOL, YES};
use objc::{class, msg_send, sel, sel_impl};
use objc_foundation::{INSString, NSString};
use objc_id::Id;
use serde::{Deserialize, Serialize};
use tauri::{
command,
plugin::{Builder, TauriPlugin},
Emitter, Manager, Runtime, State, Window,
};
pub struct ThreadSafeObjcPointer(*mut Object, PhantomData<*mut Object>);
unsafe impl Send for ThreadSafeObjcPointer {}
unsafe impl Sync for ThreadSafeObjcPointer {}
impl ThreadSafeObjcPointer {
pub fn new(ptr: *mut Object) -> Self {
Self(ptr, PhantomData)
}
pub fn as_ptr(&self) -> *mut Object {
self.0
}
pub fn is_null(&self) -> bool {
self.0.is_null()
}
}
pub struct AuthSession {
auth_session: Option<ThreadSafeObjcPointer>,
}
impl Default for AuthSession {
fn default() -> Self {
Self { auth_session: None }
}
}
impl Drop for AuthSession {
fn drop(&mut self) {
if let Some(session) = &self.auth_session {
if !session.is_null() {
unsafe {
let _: () = msg_send![session.as_ptr(), cancel];
}
}
}
}
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SafariAuthRequestArgs {
auth_url: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthResult {
redirect_url: String,
}
fn setup_presentation_context_provider() -> &'static Class {
let class_name = "TauriPresentationContextProvider";
let mut decl = match Class::get(class_name) {
Some(class) => return class,
None => ClassDecl::new(class_name, class!(NSObject)).unwrap(),
};
extern "C" fn presentation_anchor_for_web_auth_session(
this: &Object,
_sel: Sel,
_session: *mut Object,
) -> *mut Object {
unsafe {
let window: *mut Object = *this.get_ivar("window");
window
}
}
unsafe {
decl.add_ivar::<*mut Object>("window");
let sel = sel!(presentationAnchorForWebAuthenticationSession:);
decl.add_method(
sel,
presentation_anchor_for_web_auth_session
as extern "C" fn(&Object, Sel, *mut Object) -> *mut Object,
);
}
decl.register()
}
unsafe fn create_provider(window: *mut Object) -> *mut Object {
let class = setup_presentation_context_provider();
let provider: *mut Object = msg_send![class, alloc];
let provider: *mut Object = msg_send![provider, init];
(*provider).set_ivar("window", window);
provider
}
unsafe fn nsstring_to_string(ns_string: *mut Object) -> String {
if ns_string.is_null() {
return String::new();
}
let utf8_ptr: *const i8 = msg_send![ns_string, UTF8String];
if utf8_ptr.is_null() {
return String::new();
}
CStr::from_ptr(utf8_ptr).to_string_lossy().into_owned()
}
#[command]
pub async fn auth_with_safari<R: Runtime>(
window: Window<R>,
state: State<'_, Arc<Mutex<AuthSession>>>,
payload: SafariAuthRequestArgs,
) -> Result<AuthResult, String> {
let auth_url_str = NSString::from_str(&payload.auth_url);
let url_class = Class::get("NSURL").unwrap();
let auth_url: Id<Object> = unsafe {
let url: *mut Object = msg_send![url_class, URLWithString:auth_url_str];
if url.is_null() {
return Err("Failed to create URL".to_string());
}
Id::from_ptr(url)
};
let auth_session_class = match Class::get("ASWebAuthenticationSession") {
Some(class) => class,
None => {
return Err(
"ASWebAuthenticationSession class not found. This requires macOS 10.15+"
.to_string(),
)
}
};
let window_clone = window.clone();
let state_clone = Arc::clone(&*state);
let completion_block =
ConcreteBlock::new(move |callback_url: *mut Object, error: *mut Object| {
let mut auth_session = match state_clone.lock() {
Ok(guard) => guard,
Err(_) => return,
};
if !error.is_null() {
unsafe {
let description: *mut Object = msg_send![error, localizedDescription];
let error_description = nsstring_to_string(description);
log::error!("Auth session error: {}", error_description);
}
return;
}
if !callback_url.is_null() {
let url_string_value = unsafe {
let abs_str: *mut Object = msg_send![callback_url, absoluteString];
nsstring_to_string(abs_str)
};
log::info!("Auth session callback URL: {}", url_string_value);
if let Some(session) = &auth_session.auth_session {
unsafe {
let _: () = msg_send![session.as_ptr(), cancel];
}
}
auth_session.auth_session = None;
let result = AuthResult {
redirect_url: url_string_value,
};
let _ = window_clone.emit("safari-auth-complete", result);
}
});
let completion_block = completion_block.copy();
let callback_scheme = NSString::from_str("readest");
let auth_session_ptr: *mut Object = unsafe {
let alloc: *mut Object = msg_send![auth_session_class, alloc];
if alloc.is_null() {
return Err("Failed to allocate ASWebAuthenticationSession".to_string());
}
let init: *mut Object = msg_send![
alloc,
initWithURL:auth_url
callbackURLScheme:callback_scheme
completionHandler:&*completion_block
];
if init.is_null() {
return Err("Failed to initialize ASWebAuthenticationSession".to_string());
}
init
};
unsafe {
match window.ns_window() {
Ok(ns_window) => {
let ns_window = ns_window as *mut Object;
let provider = create_provider(ns_window);
let _: () = msg_send![auth_session_ptr, setPresentationContextProvider:provider];
}
Err(err) => {
log::warn!("Failed to get NSWindow for presentation context: {}", err);
}
}
}
let started: BOOL = unsafe { msg_send![auth_session_ptr, start] };
log::info!("Auth session start result: {}", started == YES);
if started != YES {
return Err("Failed to start authentication session".to_string());
}
let mut auth_session_guard = state.lock().unwrap();
auth_session_guard.auth_session = Some(ThreadSafeObjcPointer::new(auth_session_ptr));
Ok(AuthResult {
redirect_url: "pending".to_string(),
})
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("safari_auth")
.setup(|app, _| {
app.manage(Arc::new(Mutex::new(AuthSession::default())));
Ok(())
})
.invoke_handler(tauri::generate_handler![auth_with_safari])
.build()
}
@@ -30,7 +30,6 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.build()
}
#[cfg(target_os = "macos")]
#[command]
pub fn set_traffic_lights(window: Window, visible: bool, x: f64, y: f64) {
unsafe {
@@ -47,7 +46,6 @@ pub fn set_traffic_lights(window: Window, visible: bool, x: f64, y: f64) {
}
}
#[cfg(target_os = "macos")]
fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, visible: bool, x: f64, y: f64) {
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
use cocoa::foundation::NSRect;
@@ -83,13 +81,11 @@ fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, visible: bool,
}
}
#[cfg(target_os = "macos")]
#[derive(Debug)]
struct WindowState<R: Runtime> {
window: Window<R>,
}
#[cfg(target_os = "macos")]
pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
use cocoa::appkit::NSWindow;
use cocoa::base::{id, BOOL};
@@ -148,13 +144,14 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
.expect("NS window should exist on state to handle resize")
as id;
#[cfg(target_os = "macos")]
position_traffic_lights(
UnsafeWindowHandle(id as *mut std::ffi::c_void),
TRAFFIC_LIGHTS_VISIBLE,
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
if state.window.label() == "main" {
position_traffic_lights(
UnsafeWindowHandle(id as *mut std::ffi::c_void),
TRAFFIC_LIGHTS_VISIBLE,
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
}
});
let super_del: id = *this.get_ivar("super_delegate");
@@ -281,12 +278,14 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
.expect("Failed to emit event");
let id = state.window.ns_window().expect("Failed to emit event") as id;
position_traffic_lights(
UnsafeWindowHandle(id as *mut std::ffi::c_void),
TRAFFIC_LIGHTS_VISIBLE,
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
if state.window.label() == "main" {
position_traffic_lights(
UnsafeWindowHandle(id as *mut std::ffi::c_void),
TRAFFIC_LIGHTS_VISIBLE,
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
}
});
let super_del: id = *this.get_ivar("super_delegate");
@@ -16,9 +16,27 @@ export default function AuthCallback() {
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
const type = params.get('type');
const next = params.get('next') ?? '/';
const error = params.get('error');
const errorDescription = params.get('error_description');
const errorCode = params.get('error_code');
handleAuthCallback({ accessToken, refreshToken, next, login, navigate: router.push });
handleAuthCallback({
accessToken,
refreshToken,
type,
next,
error,
errorCode,
errorDescription,
login,
navigate: router.push,
});
return null;
return (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<span className='loading loading-infinity loading-xl w-20'></span>
</div>
);
}
+72 -41
View File
@@ -16,13 +16,16 @@ import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { isTauriAppPlatform } from '@/services/environment';
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { start, cancel, onUrl, onInvalidUrl } from '@fabianlars/tauri-plugin-oauth';
import { openUrl } from '@tauri-apps/plugin-opener';
import { handleAuthCallback } from '@/helpers/auth';
import { getAppleIdAuth, Scope } from './utils/appleIdAuth';
import { authWithSafari } from './utils/safariAuth';
import { authWithCustomTab, authWithSafari } from './utils/nativeAuth';
import { READEST_WEB_BASE_URL } from '@/services/constants';
import WindowButtons from '@/components/WindowButtons';
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
@@ -38,8 +41,9 @@ interface ProviderLoginProp {
label: string;
}
const WEB_AUTH_CALLBACK = 'https://web.readest.com/auth/callback';
const DEEPLINK_CALLBACK = 'readest://auth/callback';
const WEB_AUTH_CALLBACK = `${READEST_WEB_BASE_URL}/auth/callback`;
const DEEPLINK_CALLBACK = 'readest://auth-callback';
const USE_APPLE_SIGN_IN = process.env['NEXT_PUBLIC_USE_APPLE_SIGN_IN'] === 'true';
const ProviderLogin: React.FC<ProviderLoginProp> = ({ provider, handleSignIn, Icon, label }) => {
return (
@@ -47,11 +51,11 @@ const ProviderLogin: React.FC<ProviderLoginProp> = ({ provider, handleSignIn, Ic
onClick={() => handleSignIn(provider)}
className={clsx(
'mb-2 flex w-64 items-center justify-center rounded border p-2.5',
'bg-base-100 border-gray-300 shadow-sm transition hover:bg-gray-50',
'bg-base-100 border-base-300 hover:bg-base-200 shadow-sm transition',
)}
>
<Icon />
<span className='text-neutral-content/70 px-2 text-sm'>{label}</span>
<span className='text-base-content/75 px-2 text-sm'>{label}</span>
</button>
);
};
@@ -62,21 +66,31 @@ export default function AuthPage() {
const { login } = useAuth();
const { envConfig, appService } = useEnv();
const { isDarkMode } = useThemeStore();
const { isTrafficLightVisible } = useTrafficLightStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [port, setPort] = useState<number | null>(null);
const isOAuthServerRunning = useRef(false);
const [isMounted, setIsMounted] = useState(false);
const headerRef = useRef<HTMLDivElement>(null);
const getTauriRedirectTo = (isOAuth: boolean) => {
if (process.env.NODE_ENV === 'production' || appService?.isMobile) {
if (appService?.isIOSApp) {
if (process.env.NODE_ENV === 'production' || appService?.isMobile || USE_APPLE_SIGN_IN) {
if (appService?.isMobile) {
return isOAuth ? DEEPLINK_CALLBACK : WEB_AUTH_CALLBACK;
} else if (appService?.isAndroidApp) {
return WEB_AUTH_CALLBACK;
}
return DEEPLINK_CALLBACK;
}
return `http://localhost:${port}`; // only for development env on Desktop
// For development env on Desktop, use a custom OAuth callback server
// it's possible to register a custom URL scheme for the app
// but this is not supported by macOS, so we use a local server instead
return `http://localhost:${port}`;
};
const getWebRedirectTo = () => {
return process.env.NODE_ENV === 'production'
? WEB_AUTH_CALLBACK
: `${window.location.origin}/auth/callback`;
};
const tauriSignInApple = async () => {
@@ -87,15 +101,19 @@ export default function AuthPage() {
const request = {
scope: ['fullName', 'email'] as Scope[],
};
const appleAuthResponse = await getAppleIdAuth(request);
if (appleAuthResponse.identityToken) {
const { error } = await supabase.auth.signInWithIdToken({
provider: 'apple',
token: appleAuthResponse.identityToken,
});
if (error) {
console.error('Authentication error:', error);
if (appService?.isIOSApp || USE_APPLE_SIGN_IN) {
const appleAuthResponse = await getAppleIdAuth(request);
if (appleAuthResponse.identityToken) {
const { error } = await supabase.auth.signInWithIdToken({
provider: 'apple',
token: appleAuthResponse.identityToken,
});
if (error) {
console.error('Authentication error:', error);
}
}
} else {
console.log('Sign in with Apple on this platform is not supported yet');
}
};
@@ -118,11 +136,16 @@ export default function AuthPage() {
}
// Open the OAuth URL in a ASWebAuthenticationSession on iOS to comply with Apple's guidelines
// for other platforms, open the OAuth URL in the default browser
if (appService?.isIOSApp) {
if (appService?.isIOSApp || appService?.isMacOSApp) {
const res = await authWithSafari({ authUrl: data.url });
if (res) {
handleOAuthUrl(res.redirectUrl);
}
} else if (appService?.isAndroidApp) {
const res = await authWithCustomTab({ authUrl: data.url });
if (res) {
handleOAuthUrl(res.redirectUrl);
}
} else {
await openUrl(data.url);
}
@@ -136,16 +159,17 @@ export default function AuthPage() {
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
const type = params.get('type');
const next = params.get('next') ?? '/';
if (accessToken) {
handleAuthCallback({ accessToken, refreshToken, next, login, navigate: router.push });
handleAuthCallback({ accessToken, refreshToken, type, next, login, navigate: router.push });
}
}
};
const startTauriOAuth = async () => {
try {
if (process.env.NODE_ENV === 'production' || appService?.isMobile) {
if (process.env.NODE_ENV === 'production' || appService?.isMobile || USE_APPLE_SIGN_IN) {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const currentWindow = getCurrentWindow();
currentWindow.listen('single-instance', ({ event, payload }) => {
@@ -235,13 +259,6 @@ export default function AuthPage() {
link_text: _('Forgot your password?'),
confirmation_text: _('Check your email for the password reset link'),
},
update_password: {
password_label: _('New Password'),
password_input_placeholder: _('Your new password'),
button_label: _('Update password'),
loading_button_label: _('Updating password ...'),
confirmation_text: _('Your password has been updated'),
},
verify_otp: {
email_input_label: _('Email address'),
email_input_placeholder: _('Your email address'),
@@ -298,22 +315,36 @@ export default function AuthPage() {
return isTauriAppPlatform() ? (
<div
className={clsx(
'mt-6 flex',
'fixed inset-0 z-0 flex select-none flex-col items-center overflow-y-auto',
'bg-base-100 border-base-200 border',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
appService?.hasTrafficLight && 'pt-11',
)}
>
<button
onClick={handleGoBack}
<div
ref={headerRef}
className={clsx(
'btn btn-ghost fixed left-4 h-8 min-h-8 w-8 p-0',
appService?.hasSafeAreaInset && 'top-[calc(env(safe-area-inset-top)+16px)]',
appService?.hasTrafficLight && 'top-11',
'fixed z-10 flex w-full items-center justify-between py-2 pe-6 ps-4',
appService?.hasTrafficLight && 'pt-11',
)}
>
<IoArrowBack className='text-base-content' />
</button>
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem' }}>
<button onClick={handleGoBack} className={clsx('btn btn-ghost h-8 min-h-8 w-8 p-0')}>
<IoArrowBack className='text-base-content' />
</button>
{appService?.hasWindowBar && (
<WindowButtons
headerRef={headerRef}
showMinimize={!isTrafficLightVisible}
showMaximize={!isTrafficLightVisible}
showClose={!isTrafficLightVisible}
onClose={handleGoBack}
/>
)}
</div>
<div
className={clsx('z-20 pb-8', appService?.hasTrafficLight ? 'mt-24' : 'mt-12')}
style={{ maxWidth: '420px' }}
>
<ProviderLogin
provider='google'
handleSignIn={tauriSignIn}
@@ -322,7 +353,7 @@ export default function AuthPage() {
/>
<ProviderLogin
provider='apple'
handleSignIn={appService?.isIOSApp ? tauriSignInApple : tauriSignIn}
handleSignIn={appService?.isIOSApp || USE_APPLE_SIGN_IN ? tauriSignInApple : tauriSignIn}
Icon={FaApple}
label={_('Sign in with Apple')}
/>
@@ -332,7 +363,7 @@ export default function AuthPage() {
Icon={FaGithub}
label={_('Sign in with GitHub')}
/>
<hr className='my-3 mt-6 w-64 border-t border-gray-200' />
<hr className='border-base-300 my-3 mt-6 w-64 border-t' />
<Auth
supabaseClient={supabase}
appearance={{ theme: ThemeSupa }}
@@ -358,7 +389,7 @@ export default function AuthPage() {
theme={isDarkMode ? 'dark' : 'light'}
magicLink={true}
providers={['google', 'apple', 'github']}
redirectTo='/auth/callback'
redirectTo={getWebRedirectTo()}
localization={getAuthLocalization()}
/>
</div>
@@ -0,0 +1,62 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { ThemeSupa } from '@supabase/auth-ui-shared';
import { Auth } from '@supabase/auth-ui-react';
import { supabase } from '@/utils/supabase';
export default function ResetPasswordPage() {
const _ = useTranslation();
const router = useRouter();
const { login } = useAuth();
const { isDarkMode } = useThemeStore();
const getAuthLocalization = () => {
return {
variables: {
update_password: {
password_label: _('New Password'),
password_input_placeholder: _('Your new password'),
button_label: _('Update password'),
loading_button_label: _('Updating password ...'),
confirmation_text: _('Your password has been updated'),
},
},
};
};
useEffect(() => {
const { data: subscription } = supabase.auth.onAuthStateChange((event, session) => {
if (session?.access_token && session.user && event === 'USER_UPDATED') {
login(session.access_token, session.user);
const redirectTo = new URLSearchParams(window.location.search).get('redirect');
router.push(redirectTo ?? '/library');
}
});
return () => {
subscription?.subscription.unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router]);
return (
<div className='flex min-h-screen items-center justify-center'>
<div className='w-full max-w-md'>
<Auth
supabaseClient={supabase}
view='update_password'
appearance={{ theme: ThemeSupa }}
theme={isDarkMode ? 'dark' : 'light'}
magicLink={false}
providers={[]}
localization={getAuthLocalization()}
/>
</div>
</div>
);
}
@@ -1,4 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { type as osType } from '@tauri-apps/plugin-os';
export type Scope = 'fullName' | 'email';
export interface AppleIDAuthorizationRequest {
@@ -23,12 +25,46 @@ export interface AppleIDAuthorizationResponse {
export async function getAppleIdAuth(
request: AppleIDAuthorizationRequest,
): Promise<AppleIDAuthorizationResponse> {
const result = await invoke<AppleIDAuthorizationResponse>(
'plugin:sign-in-with-apple|get_apple_id_credential',
{
payload: request,
},
);
const OS_TYPE = osType();
if (OS_TYPE === 'ios') {
const result = await invoke<AppleIDAuthorizationResponse>(
'plugin:sign-in-with-apple|get_apple_id_credential',
{
payload: request,
},
);
return result;
return result;
} else if (OS_TYPE === 'macos') {
return new Promise<AppleIDAuthorizationResponse>(async (resolve, reject) => {
const unlistenComplete = await listen<AppleIDAuthorizationResponse>(
'apple-sign-in-complete',
({ payload }) => {
cleanup();
resolve(payload);
},
);
const unlistenError = await listen<string>('apple-sign-in-error', ({ payload }) => {
cleanup();
reject(
typeof payload === 'string' ? new Error(payload) : new Error('Apple signin failed'),
);
});
function cleanup() {
unlistenComplete();
unlistenError();
}
try {
await invoke('start_apple_sign_in', { payload: request });
} catch (err) {
cleanup();
reject(err);
}
});
} else {
throw new Error('Unsupported platform');
}
}
@@ -0,0 +1,49 @@
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { type as osType } from '@tauri-apps/plugin-os';
export interface AuthRequest {
authUrl: string;
}
export interface AuthResponse {
redirectUrl: string;
}
export async function authWithSafari(request: AuthRequest): Promise<AuthResponse> {
const OS_TYPE = osType();
if (OS_TYPE === 'ios') {
const result = await invoke<AuthResponse>('plugin:native-bridge|auth_with_safari', {
payload: request,
});
return result;
} else if (OS_TYPE === 'macos') {
return new Promise<AuthResponse>(async (resolve, reject) => {
const unlistenComplete = await listen<AuthResponse>('safari-auth-complete', ({ payload }) => {
cleanup();
resolve(payload);
});
function cleanup() {
unlistenComplete();
}
try {
await invoke<AuthResponse>('auth_with_safari', { payload: request });
} catch (err) {
cleanup();
reject(err);
}
});
} else {
throw new Error('Unsupported OS type');
}
}
export async function authWithCustomTab(request: AuthRequest): Promise<AuthResponse> {
const result = await invoke<AuthResponse>('plugin:native-bridge|auth_with_custom_tab', {
payload: request,
});
return result;
}
@@ -1,17 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
export interface SafariAuthRequest {
authUrl: string;
}
export interface SafariAuthResponse {
redirectUrl: string;
}
export async function authWithSafari(request: SafariAuthRequest): Promise<SafariAuthResponse> {
const result = await invoke<SafariAuthResponse>('plugin:safari-auth|auth_with_safari', {
payload: request,
});
return result;
}
@@ -7,9 +7,11 @@ import { LuFolderPlus } from 'react-icons/lu';
import { PiPlus } from 'react-icons/pi';
import { Book, BooksGroup } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useTranslation } from '@/hooks/useTranslation';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import { formatAuthors, formatTitle } from '@/utils/book';
import { isMd5 } from '@/utils/md5';
import Alert from '@/components/Alert';
@@ -44,13 +46,17 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const router = useRouter();
const searchParams = useSearchParams();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const [loading, setLoading] = useState(false);
const [selectedBooks, setSelectedBooks] = useState<string[]>([]);
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [showGroupingModal, setShowGroupingModal] = useState(false);
const [queryTerm, setQueryTerm] = useState<string | null>(null);
const [navBooksGroup, setNavBooksGroup] = useState<BooksGroup | null>(null);
const [importBookUrl] = useState(searchParams?.get('url') || '');
const [sortBy, setSortBy] = useState(searchParams?.get('sort') || 'updated');
const [sortOrder, setSortOrder] = useState(searchParams?.get('order') || 'desc');
const isImportingBook = useRef(false);
const { setLibrary } = useLibraryStore();
@@ -86,18 +92,48 @@ const Bookshelf: React.FC<BookshelfProps> = ({
useEffect(() => {
const group = searchParams?.get('group') || '';
const query = searchParams?.get('q') || '';
const sort = searchParams?.get('sort') || settings.librarySortBy;
const order = searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc');
const params = new URLSearchParams(searchParams?.toString());
if (query) {
params.set('q', query);
setQueryTerm(query);
} else {
params.delete('q');
setQueryTerm(null);
}
if (sort) {
params.set('sort', sort);
setSortBy(sort);
} else {
params.delete('sort');
}
if (order) {
params.set('order', order);
setSortOrder(order);
} else {
params.delete('order');
}
if (sort === 'updated' && order === 'desc') {
params.delete('sort');
params.delete('order');
}
if (group) {
const booksGroup = allBookshelfItems.find(
(item) => 'name' in item && item.id === group,
) as BooksGroup;
if (booksGroup) {
setNavBooksGroup(booksGroup);
params.set('group', group);
} else {
navigateToLibrary(router);
params.delete('group');
navigateToLibrary(router, `${params.toString()}`);
}
} else {
setNavBooksGroup(null);
navigateToLibrary(router);
params.delete('group');
navigateToLibrary(router, `${params.toString()}`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, libraryBooks, showGroupingModal]);
@@ -137,6 +173,57 @@ const Bookshelf: React.FC<BookshelfProps> = ({
};
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
const bookFilter = (item: Book, queryTerm: string) => {
if (item.deletedAt) return false;
const searchTerm = new RegExp(queryTerm, 'i');
const title = formatTitle(item.title);
const authors = formatAuthors(item.author);
return searchTerm.test(title) || searchTerm.test(authors);
};
const bookSorter = (a: Book, b: Book) => {
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
switch (sortBy) {
case 'title':
const aTitle = formatTitle(a.title);
const bTitle = formatTitle(b.title);
return aTitle.localeCompare(bTitle, uiLanguage || navigator.language);
case 'author':
const aAuthors = formatAuthors(a.author);
const bAuthors = formatAuthors(b.author);
return aAuthors.localeCompare(bAuthors, uiLanguage || navigator.language);
case 'updated':
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
case 'created':
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
case 'format':
return a.format.localeCompare(b.format, uiLanguage || navigator.language);
default:
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
}
};
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
const filteredBookshelfItems = currentBookshelfItems
.filter((item) => {
if ('name' in item) return item.books.some((book) => bookFilter(book, queryTerm || ''));
else if (queryTerm) return bookFilter(item, queryTerm);
return true;
})
.sort((a, b) => {
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
if (sortBy === 'updated') {
return (
(new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()) * sortOrderMultiplier
);
} else if ('name' in a || 'name' in b) {
const aName = 'name' in a ? a.name : formatTitle(a.title);
const bName = 'name' in b ? b.name : formatTitle(b.title);
return aName.localeCompare(bName, uiLanguage || navigator.language) * sortOrderMultiplier;
} else if (!('name' in a || 'name' in b)) {
return bookSorter(a, b) * sortOrderMultiplier;
} else {
return 0;
}
});
return (
<div className='bookshelf'>
@@ -146,7 +233,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
)}
>
{currentBookshelfItems.map((item, index) => (
{filteredBookshelfItems.map((item, index) => (
<BookshelfItem
key={`library-item-${index}`}
item={item}
@@ -210,7 +297,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
)}
>
<LuFolderPlus />
<div>{_('Add to Group')}</div>
<div>{_('Group')}</div>
</button>
<button
onClick={deleteSelectedBooks}
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
@@ -9,7 +9,7 @@ import { useLongPress } from '@/hooks/useLongPress';
import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { getOSPlatform } from '@/utils/misc';
import { getFilename } from '@/utils/book';
import { getLocalBookFilename } from '@/utils/book';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { Book, BookGroupType, BooksGroup } from '@/types/book';
@@ -94,6 +94,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
}) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { updateBook } = useLibraryStore();
@@ -133,7 +134,9 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
if (isSelectMode) {
toggleSelection(group.id);
} else {
navigateToLibrary(router, `group=${group.id}`);
const params = new URLSearchParams(searchParams?.toString());
params.set('group', group.id);
navigateToLibrary(router, `${params.toString()}`);
}
};
@@ -152,7 +155,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const showBookInFinderMenuItem = await MenuItem.new({
text: _(fileRevealLabel),
action: async () => {
const folder = `${settings.localBooksDir}/${getFilename(book)}`;
const folder = `${settings.localBooksDir}/${getLocalBookFilename(book)}`;
revealItemInDir(folder);
},
});
@@ -1,22 +1,25 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { FaSearch } from 'react-icons/fa';
import { PiPlus } from 'react-icons/pi';
import { PiSelectionAllDuotone } from 'react-icons/pi';
import { MdOutlineMenu } from 'react-icons/md';
import { MdArrowBackIosNew } from 'react-icons/md';
import { PiDotsThreeCircle } from 'react-icons/pi';
import { MdOutlineMenu, MdArrowBackIosNew } from 'react-icons/md';
import { IoMdCloseCircle } from 'react-icons/io';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { navigateToLibrary } from '@/utils/nav';
import { throttle } from '@/utils/throttle';
import useShortcuts from '@/hooks/useShortcuts';
import WindowButtons from '@/components/WindowButtons';
import Dropdown from '@/components/Dropdown';
import SettingsMenu from './SettingsMenu';
import ImportMenu from './ImportMenu';
import useShortcuts from '@/hooks/useShortcuts';
import SortMenu from './SortMenu';
interface LibraryHeaderProps {
isSelectMode: boolean;
@@ -40,15 +43,36 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
setTrafficLightVisibility,
cleanupTrafficLightListeners,
} = useTrafficLightStore();
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
const headerRef = useRef<HTMLDivElement>(null);
const iconSize16 = useResponsiveSize(16);
const iconSize18 = useResponsiveSize(18);
const iconSize20 = useResponsiveSize(20);
useShortcuts({
onToggleSelectMode,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
const throttledUpdateQueryParam = useCallback(
throttle((value: string) => {
const params = new URLSearchParams(searchParams?.toString());
if (value) {
params.set('q', value);
} else {
params.delete('q');
}
router.push(`?${params.toString()}`);
}, 1000),
[searchParams],
);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newQuery = e.target.value;
setSearchQuery(newQuery);
throttledUpdateQueryParam(newQuery);
};
useEffect(() => {
if (!appService?.hasTrafficLight) return;
@@ -68,7 +92,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<div
ref={headerRef}
className={clsx(
'titlebar h-13 z-10 flex w-full items-center py-2 pr-4 sm:pr-6',
'titlebar z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px] sm:pr-6',
appService?.hasSafeAreaInset && 'mt-[env(safe-area-inset-top)]',
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
)}
@@ -93,7 +117,9 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
</span>
<input
type='text'
value={searchQuery}
placeholder={_('Search Books...')}
onChange={handleSearchChange}
spellCheck='false'
className={clsx(
'input rounded-badge bg-base-300/50 h-9 w-full pl-10 pr-10 sm:h-7',
@@ -103,6 +129,19 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
/>
</div>
<div className='absolute right-4 flex items-center space-x-2 text-gray-500 sm:space-x-4'>
{searchQuery && (
<button
type='button'
onClick={() => {
setSearchQuery('');
throttledUpdateQueryParam('');
}}
className='text-gray-400 hover:text-gray-600'
aria-label={_('Clear Search')}
>
<IoMdCloseCircle className='h-4 w-4' />
</button>
)}
<span className='bg-base-content/50 mx-2 h-4 w-[0.5px]'></span>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center'
@@ -136,10 +175,17 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<MdOutlineMenu size={iconSize16} />}
toggleButton={<MdOutlineMenu size={iconSize18} />}
>
<SettingsMenu />
</Dropdown>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiDotsThreeCircle size={iconSize18} />}
>
<SortMenu />
</Dropdown>
{appService?.hasWindowBar && (
<WindowButtons
headerRef={headerRef}
@@ -13,7 +13,8 @@ const getProgressPercentage = (book: Book) => {
if (book.progress && book.progress[1] === 1) {
return 100;
}
return Math.round((book.progress[0] / book.progress[1]) * 100);
const percentage = Math.round((book.progress[0] / book.progress[1]) * 100);
return Math.max(0, Math.min(100, percentage));
};
const ReadingProgress: React.FC<ReadingProgressProps> = memo(
@@ -6,7 +6,7 @@ import { PiUserCircleCheck } from 'react-icons/pi';
import { MdCheck } from 'react-icons/md';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { hasUpdater, 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';
@@ -14,16 +14,16 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStoragePlanData } from '@/utils/access';
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
import { tauriHandleToggleFullScreen } from '@/utils/window';
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
import { QuotaType } from '@/types/user';
import MenuItem from '@/components/MenuItem';
import Quota from '@/components/Quota';
interface BookMenuProps {
interface SettingsMenuProps {
setIsDropdownOpen?: (isOpen: boolean) => void;
}
const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
const { envConfig, appService } = useEnv();
@@ -32,7 +32,11 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(settings.alwaysOnTop);
const [isScreenWakeLock, setIsScreenWakeLock] = useState(settings.screenWakeLock);
const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState(
settings.autoImportBooksOnOpen,
);
const showAboutReadest = () => {
setAboutDialogVisible(true);
@@ -63,6 +67,15 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
setIsDropdownOpen?.(false);
};
const toggleAlwaysOnTop = () => {
settings.alwaysOnTop = !settings.alwaysOnTop;
setSettings(settings);
saveSettings(envConfig, settings);
setIsAlwaysOnTop(settings.alwaysOnTop);
tauriHandleSetAlwaysOnTop(settings.alwaysOnTop);
setIsDropdownOpen?.(false);
};
const toggleAutoUploadBooks = () => {
settings.autoUpload = !settings.autoUpload;
setSettings(settings);
@@ -74,6 +87,13 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
}
};
const toggleAutoImportBooksOnOpen = () => {
settings.autoImportBooksOnOpen = !settings.autoImportBooksOnOpen;
setSettings(settings);
saveSettings(envConfig, settings);
setIsAutoImportBooksOnOpen(settings.autoImportBooksOnOpen);
};
const toggleAutoCheckUpdates = () => {
settings.autoCheckUpdates = !settings.autoCheckUpdates;
setSettings(settings);
@@ -112,7 +132,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
return (
<div
tabIndex={0}
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-3 w-72 shadow-2xl'
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-2 w-72 shadow-2xl'
>
{user ? (
<MenuItem
@@ -150,22 +170,34 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
icon={isAutoUpload ? <MdCheck className='text-base-content' /> : undefined}
onClick={toggleAutoUploadBooks}
/>
{hasUpdater() && (
{isTauriAppPlatform() && !appService?.isMobile && (
<MenuItem
label={_('Auto Import on File Open')}
icon={isAutoImportBooksOnOpen ? <MdCheck className='text-base-content' /> : undefined}
onClick={toggleAutoImportBooksOnOpen}
/>
)}
{appService?.hasUpdater && (
<MenuItem
label={_('Check Updates on Start')}
icon={isAutoCheckUpdates ? <MdCheck className='text-base-content' /> : undefined}
onClick={toggleAutoCheckUpdates}
/>
)}
<hr className='border-base-200 my-1' />
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
{appService?.hasWindow && (
<MenuItem
label={_('Always on Top')}
icon={isAlwaysOnTop ? <MdCheck className='text-base-content' /> : undefined}
onClick={toggleAlwaysOnTop}
/>
)}
<MenuItem
label={_('Keep Screen Awake')}
icon={isScreenWakeLock ? <MdCheck className='text-base-content' /> : undefined}
onClick={toggleScreenWakeLock}
/>
<hr className='border-base-200 my-1' />
{appService?.hasRoundedWindow && (
<MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />
)}
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
<hr className='border-base-200 my-1' />
{isWebApp && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
@@ -0,0 +1,96 @@
import React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { MdCheck } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { LibrarySortByType } from '@/types/settings';
import { navigateToLibrary } from '@/utils/nav';
import MenuItem from '@/components/MenuItem';
interface SortMenuProps {
setIsDropdownOpen?: (isOpen: boolean) => void;
}
const SortMenu: React.FC<SortMenuProps> = ({ setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig } = useEnv();
const { settings, setSettings, saveSettings } = useSettingsStore();
const sortBy = settings.librarySortBy;
const isAscending = settings.librarySortAscending;
const sortByOptions = [
{ label: _('Title'), value: 'title' },
{ label: _('Author'), value: 'author' },
{ label: _('Format'), value: 'format' },
{ label: _('Date Read'), value: 'updated' },
{ label: _('Date Added'), value: 'created' },
];
const sortingOptions = [
{ label: _('Ascending'), value: true },
{ label: _('Descending'), value: false },
];
const handleSetSortBy = (value: LibrarySortByType) => {
settings.librarySortBy = value;
setSettings(settings);
saveSettings(envConfig, settings);
setIsDropdownOpen?.(false);
const params = new URLSearchParams(searchParams?.toString());
params.set('sort', value);
navigateToLibrary(router, `${params.toString()}`);
};
const handleSetSortAscending = (value: boolean) => {
settings.librarySortAscending = value;
setSettings(settings);
saveSettings(envConfig, settings);
setIsDropdownOpen?.(false);
const params = new URLSearchParams(searchParams?.toString());
params.set('order', value ? 'asc' : 'desc');
navigateToLibrary(router, `${params.toString()}`);
};
return (
<div
tabIndex={0}
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
>
<MenuItem
label={_('Sort by...')}
buttonClass='h-8'
labelClass='text-sm sm:text-xs'
disabled
/>
{sortByOptions.map((option) => (
<MenuItem
key={option.value}
label={option.label}
buttonClass='h-8'
icon={sortBy === option.value ? <MdCheck className='text-base-content' /> : undefined}
onClick={() => handleSetSortBy(option.value as LibrarySortByType)}
/>
))}
<hr className='border-base-200 my-1' />
{sortingOptions.map((option) => (
<MenuItem
key={option.value.toString()}
label={option.label}
buttonClass='h-8'
icon={
isAscending === option.value ? <MdCheck className='text-base-content' /> : undefined
}
onClick={() => handleSetSortAscending(option.value)}
/>
))}
</div>
);
};
export default SortMenu;
@@ -85,9 +85,8 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
}
const mergedBook =
matchingBook.updatedAt > oldBook.updatedAt
? { ...oldBook, ...matchingBook, updatedAt: oldBook.updatedAt }
: { ...matchingBook, ...oldBook, updatedAt: oldBook.updatedAt };
mergedBook.progress = matchingBook.progress ?? oldBook.progress;
? { ...oldBook, ...matchingBook }
: { ...matchingBook, ...oldBook };
return mergedBook;
}
return oldBook;
@@ -99,11 +98,12 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
if (newBook.uploadedAt && !newBook.deletedAt) {
try {
await appService?.downloadBook(newBook, true);
} catch {
console.error('Failed to download book:', newBook);
} finally {
newBook.coverImageUrl = await appService?.generateCoverImageUrl(newBook);
updatedLibrary.push(newBook);
setLibrary(updatedLibrary);
} catch {
console.error('Failed to download book:', newBook);
}
}
}
@@ -19,7 +19,6 @@ interface DemoBooks {
export const useDemoBooks = () => {
const { envConfig } = useEnv();
const userLang = getUserLang() as keyof typeof libraries;
const [books, setBooks] = useState<Book[]>([]);
const isLoading = useRef(false);
@@ -27,6 +26,7 @@ export const useDemoBooks = () => {
if (isLoading.current) return;
isLoading.current = true;
const userLang = getUserLang() as keyof typeof libraries;
const fetchDemoBooks = async () => {
try {
const appService = await envConfig.getAppService();
+45 -19
View File
@@ -8,12 +8,12 @@ import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/naviga
import { Book } from '@/types/book';
import { AppService } from '@/types/system';
import { navigateToLogin, navigateToReader } from '@/utils/nav';
import { getBaseFilename, listFormater } from '@/utils/book';
import { getFilename, listFormater } from '@/utils/book';
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';
@@ -31,9 +31,10 @@ import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { tauriQuitApp } from '@/utils/window';
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,10 +101,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useEffect(() => {
const doCheckAppUpdates = async () => {
if (hasUpdater() && settings.autoCheckUpdates) {
if (appService?.hasUpdater && settings.autoCheckUpdates) {
await checkForAppUpdates(_);
}
};
if (settings.alwaysOnTop) {
tauriHandleSetAlwaysOnTop(settings.alwaysOnTop);
}
doCheckAppUpdates();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings]);
@@ -161,9 +165,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useEffect(() => {
const libraryPage = document.querySelector('.library-page');
libraryPage?.addEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.addEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.addEventListener('drop', handleDrop as unknown as EventListener);
if (!appService?.isMobile) {
libraryPage?.addEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.addEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.addEventListener('drop', handleDrop as unknown as EventListener);
}
if (isTauriAppPlatform()) {
const unlisten = getCurrentWebview().onDragDropEvent((event) => {
@@ -182,20 +188,24 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}
return () => {
libraryPage?.removeEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.removeEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.removeEventListener('drop', handleDrop as unknown as EventListener);
if (!appService?.isMobile) {
libraryPage?.removeEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.removeEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.removeEventListener('drop', handleDrop as unknown as EventListener);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageRef.current]);
const processOpenWithFiles = React.useCallback(
async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => {
const settings = await appService.loadSettings();
const bookIds: string[] = [];
for (const file of openWithFiles) {
console.log('Open with book:', file);
try {
const book = await appService.importBook(file, libraryBooks);
const temp = !settings.autoImportBooksOnOpen;
const book = await appService.importBook(file, libraryBooks, true, true, false, temp);
if (book) {
bookIds.push(book.hash);
}
@@ -294,6 +304,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const importBooks = async (files: (string | File)[]) => {
setLoading(true);
const failedFiles = [];
const errorMap: [string, string][] = [
['No chapters detected.', _('No chapters detected.')],
['Failed to parse EPUB.', _('Failed to parse the EPUB file.')],
['Unsupported format.', _('This book format is not supported.')],
];
for (const file of files) {
try {
const book = await appService?.importBook(file, libraryBooks);
@@ -304,12 +319,17 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}
} catch (error) {
const filename = typeof file === 'string' ? file : file.name;
const baseFilename = getBaseFilename(filename);
const baseFilename = getFilename(filename);
failedFiles.push(baseFilename);
const errorMessage =
error instanceof Error
? errorMap.find(([substring]) => error.message.includes(substring))?.[1] || ''
: '';
eventDispatcher.dispatch('toast', {
message: _('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(failedFiles),
}),
message:
_('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(failedFiles),
}) + (errorMessage ? `\n${errorMessage}` : ''),
type: 'error',
});
console.error('Failed to import book:', filename, error);
@@ -320,7 +340,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
};
const selectFilesTauri = async () => {
return appService?.selectFiles('Select Books', SUPPORTED_FILE_EXTS);
const exts = appService?.isAndroidApp ? [] : SUPPORTED_FILE_EXTS;
const files = (await appService?.selectFiles(_('Select Books'), exts)) || [];
// Cannot filter out files on Android since some content providers may not return the file name
return files;
};
const selectFilesWeb = () => {
@@ -364,6 +387,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('Not authenticated') && settings.keepLogin) {
settings.keepLogin = false;
setSettings(settings);
navigateToLogin(router);
return false;
} else if (err.message.includes('Insufficient storage quota')) {
@@ -439,7 +464,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
let files;
if (isTauriAppPlatform()) {
if (appService?.isMobile) {
if (appService?.isIOSApp) {
files = (await selectFilesWeb()) as [File];
} else {
files = (await selectFilesTauri()) as [string];
@@ -508,8 +533,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<div
ref={containerRef}
className={clsx(
'scroll-container drop-zone mt-12 flex-grow overflow-auto px-4 sm:px-2',
appService?.hasSafeAreaInset && 'mt-[calc(48px+env(safe-area-inset-top))]',
'scroll-container drop-zone mt-[48px] flex-grow overflow-y-auto px-4 sm:px-2',
appService?.hasSafeAreaInset && 'mt-[calc(52px+env(safe-area-inset-top))]',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom))]',
isDragging && 'drag-over',
)}
@@ -553,6 +578,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
/>
)}
<AboutWindow />
{appService?.isAndroidApp && <UpdaterWindow />}
<Toast />
</div>
);

Some files were not shown because too many files have changed in this diff Show More