forked from akai/readest
Compare commits
110 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5b2feda48 | |||
| 59f875f590 | |||
| 8efad90932 | |||
| 5c23642ac0 | |||
| ee6500cecb | |||
| f004d7b3ea | |||
| f7d53508a6 | |||
| 53e7f2526c | |||
| 8f0e2d1642 | |||
| c807f0ccc8 | |||
| 6ba15319a8 | |||
| 3ea3a50baa | |||
| ccd467ebcf | |||
| fe25c3e995 | |||
| d4af2d3ef5 | |||
| 6ff1edb67c | |||
| 3d2847e427 | |||
| ff4d8af054 | |||
| 612302f42f | |||
| a26607a154 | |||
| 939c7049ed | |||
| 0ead07e704 | |||
| f4908c4536 | |||
| 37951c2ebf | |||
| 915f222e08 | |||
| 9446df1b9c | |||
| bfcf89b093 | |||
| b0cf087d78 | |||
| 5863586766 | |||
| 99182a1f9e | |||
| 2599f40dfb | |||
| aa66ba54c5 | |||
| 4f0ef01a17 | |||
| 267e1656db | |||
| dcecda2984 | |||
| ce88f0229a | |||
| 316c82ea52 | |||
| 25be60ce2a | |||
| 4703eec045 | |||
| ab22a83332 | |||
| a3d6ebe44d | |||
| f8804b5d3c | |||
| ee3785ad51 | |||
| 5ba40cd2d6 | |||
| 6131180a31 | |||
| 7ccf3d0632 | |||
| 39fb7f759e | |||
| d89834a53d | |||
| 3c4dd2a143 | |||
| 847efb7082 | |||
| 01db8f90fa | |||
| e70c46ff02 | |||
| 7cb1998ebb | |||
| f5fd37f40d | |||
| 2daee62b80 | |||
| ffd179bb06 | |||
| 4acc0d8e12 | |||
| a66786444f | |||
| 01ad18ca57 | |||
| a72c8f2391 | |||
| f90177713a | |||
| 041eb1b921 | |||
| 105302d241 | |||
| bd9c7b459c | |||
| 1c122752bb | |||
| 203ef8b986 | |||
| ceda806f77 | |||
| baac9dfa25 | |||
| 10aace0026 | |||
| 3e38896078 | |||
| 0fa53c5f15 | |||
| 2b6320b880 | |||
| 7ba938ea25 | |||
| 6d4ad34e32 | |||
| 3239ff0a80 | |||
| 8b7e3c6f79 | |||
| ace4b78420 | |||
| 39ac89fead | |||
| 490fe7e70c | |||
| 06a7a8ea82 | |||
| b7f4a3503f | |||
| 835ed443a5 | |||
| 3f3c6983ba | |||
| f22ce1c225 | |||
| 9990de112f | |||
| ede37757db | |||
| 0c65d44bc9 | |||
| 07c9813ec1 | |||
| 03f0ee1f7d | |||
| ebc23c4ce3 | |||
| d098487cd1 | |||
| 14f2db730a | |||
| a79f301165 | |||
| 05d086352f | |||
| ef864a32c2 | |||
| 0d93a13407 | |||
| ab0208b7ab | |||
| c52f3c0430 | |||
| accf6fb5c7 | |||
| 18c2ce1274 | |||
| 9a104b918c | |||
| 948303c43a | |||
| 82f303fcfe | |||
| 359fa8df35 | |||
| 72e6c058f5 | |||
| 7648aa47b2 | |||
| 48074f0f53 | |||
| 66bd8d74f3 | |||
| 37f3c8b829 | |||
| 8775f90bc1 |
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+16
-1
@@ -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" }
|
||||
@@ -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 Won’t 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 doesn’t 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 you’ve 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...": "排序方式..."
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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>
|
||||
+18
@@ -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
|
||||
+2
-2
@@ -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")
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.readest.native_bridge
|
||||
|
||||
import android.util.Log
|
||||
|
||||
class NativeBridge {
|
||||
}
|
||||
+113
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.plugin.safari_auth
|
||||
package com.readest.native_bridge
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
@@ -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
-4
@@ -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
-1
@@ -1,3 +1,3 @@
|
||||
# Tauri Plugin safari-auth
|
||||
# Tauri Plugin native-bridge
|
||||
|
||||
A description of this package.
|
||||
+31
-9
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import XCTest
|
||||
@testable import NativeBridgePlugin
|
||||
|
||||
final class NativeBridgePluginTests: XCTestCase {
|
||||
func testNativeBridge() throws {
|
||||
let plugin = NativeBridgePlugin()
|
||||
}
|
||||
}
|
||||
+13
@@ -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"]
|
||||
+13
@@ -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"]
|
||||
+13
@@ -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"]
|
||||
+13
@@ -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"]
|
||||
+151
@@ -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",
|
||||
]
|
||||
+57
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
+2
@@ -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
|
||||
-10
@@ -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
|
||||
}
|
||||
}
|
||||
-28
@@ -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();
|
||||
}
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
import XCTest
|
||||
@testable import SafariAuthPlugin
|
||||
|
||||
final class SafariAuthPluginTests: XCTestCase {
|
||||
func testExample() throws {
|
||||
let plugin = SafariAuthPlugin()
|
||||
}
|
||||
}
|
||||
-41
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+16
-17
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 sign‑in 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();
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user