forked from akai/readest
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5163d01e08 |
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Readest",
|
||||
// Initialize only the submodules required for Docker builds.
|
||||
// tauri/tauri-plugins are skipped here since they're only needed for desktop builds.
|
||||
"postCreateCommand": "git submodule update --init packages/foliate-js packages/simplecc-wasm"
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
# Rust build artifacts
|
||||
target
|
||||
**/target
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Build outputs
|
||||
.next
|
||||
**/.next
|
||||
.open-next
|
||||
**/.open-next
|
||||
out
|
||||
**/out
|
||||
.vercel
|
||||
**/.vercel
|
||||
|
||||
# Local env files
|
||||
docker/.env
|
||||
.env*.local
|
||||
**/.env*.local
|
||||
|
||||
# Local credentials and tooling state
|
||||
*.pem
|
||||
certs
|
||||
**/certs
|
||||
.claude/settings.local.json
|
||||
**/.claude/settings.local.json
|
||||
.claude/worktrees
|
||||
**/.claude/worktrees
|
||||
.gstack
|
||||
**/.gstack
|
||||
.playwright-mcp
|
||||
**/.playwright-mcp
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -1,3 +0,0 @@
|
||||
[*.{ts,tsx}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
@@ -1,15 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: ['readest']
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -1,23 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Share an idea or suggestion
|
||||
title: 'FR: describing your feature request'
|
||||
title: 'FR: [a handful of words describing the FR]'
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Does your feature request involve difficulty for you to complete a task? Please describe.**
|
||||
|
||||
> A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
|
||||
**Does your feature request involve difficulty completing a task? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
|
||||
> A clear and concise description of what you'd like to happen.
|
||||
A clear and concise description of what you'd like to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
|
||||
> A clear and concise description of any alternative solutions or features you've considered.
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
|
||||
> Add any additional context or screenshots about the feature request here.
|
||||
Add any additional context or screenshots about the feature request here.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: Report a bug
|
||||
about: Report a bug or a functional regression
|
||||
title: 'Example: In DarkMode, a blank square appears in bottom right corner while scrolling'
|
||||
title: 'Ex: In DarkMode, a blank square appears in bottom right corner while scrolling'
|
||||
labels: ['type: bug']
|
||||
assignees: ''
|
||||
---
|
||||
@@ -11,25 +11,28 @@ assignees: ''
|
||||
A clear and concise description of what the current behavior is.
|
||||
Please also add **screenshots** of the existing application.
|
||||
|
||||
> **Example:**
|
||||
> In DarkMode, when scrollbar are displayed (for example on Companies page, with enough companies in the list), we see a blank square in the bottom right corner
|
||||
> [screenshot]
|
||||
**Example:**
|
||||
|
||||
```
|
||||
In DarkMode, when scrollbar are displayed (for example on Companies page, with enough companies in the list), we see a blank square in the bottom right corner
|
||||
[screenshot]
|
||||
```
|
||||
|
||||
## Expected behavior
|
||||
|
||||
A clear and concise description of what the expected behavior is.
|
||||
|
||||
> **Example:**
|
||||
> The blank square should be transparent (invisible)
|
||||
**Example:**
|
||||
|
||||
```
|
||||
The blank square should be transparent (invisible)
|
||||
```
|
||||
|
||||
## Technical inputs
|
||||
|
||||
Operating System:
|
||||
**Example:**
|
||||
|
||||
Readest Version:
|
||||
|
||||
> **Example:**
|
||||
> Operating System: Android 14 (WebView 135.0)
|
||||
> Readest Version: 0.9.0
|
||||
> We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
|
||||
> Probably fixable with CSS
|
||||
```
|
||||
- We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
|
||||
- Probably fixable with CSS
|
||||
```
|
||||
|
||||
@@ -8,6 +8,6 @@ updates:
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- '*' # Group all Actions updates into a single larger pull request
|
||||
- "*" # Group all Actions updates into a single larger pull request
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
name: Android E2E (CDP)
|
||||
|
||||
# On-device end-to-end tests: boots an x86_64 Android emulator (KVM), installs
|
||||
# a debug APK, and runs the CDP-driven selection lane (pnpm test:android).
|
||||
# Not PR-blocking: runs nightly, on demand, or when a PR is labeled
|
||||
# `e2e-android`.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '30 19 * * *'
|
||||
pull_request:
|
||||
types: [labeled, synchronize]
|
||||
|
||||
concurrency:
|
||||
group: android-e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
android-e2e:
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'e2e-android')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: initialize git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: enable KVM for the emulator
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: setup Java
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: setup Android SDK
|
||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4
|
||||
|
||||
- name: install NDK
|
||||
run: sdkmanager "ndk;28.2.13676358"
|
||||
|
||||
- name: install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
|
||||
- name: copy pdfjs-dist and simplecc-dist to public directory
|
||||
run: pnpm --filter @readest/readest-app setup-vendors
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
with:
|
||||
targets: x86_64-linux-android
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
workspaces: apps/readest-app/src-tauri
|
||||
|
||||
- name: create .env.local file for Next.js
|
||||
run: |
|
||||
echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local
|
||||
cp .env.local apps/readest-app/.env.local
|
||||
|
||||
- name: build debug APK (x86_64)
|
||||
env:
|
||||
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/28.2.13676358
|
||||
run: |
|
||||
cd apps/readest-app
|
||||
# Only the customized files of gen/android are tracked — regenerate
|
||||
# the gradle scaffolding, then restore the tracked customizations
|
||||
# (same flow as the release workflow).
|
||||
rm -rf src-tauri/gen/android
|
||||
pnpm tauri android init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
git checkout .
|
||||
# Debug build: signed with the debug keystore, no release secrets
|
||||
# needed (gradle only loads keystore.properties when it exists).
|
||||
pnpm tauri android build --debug --target x86_64
|
||||
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*-debug.apk' | head -n 1)
|
||||
echo "APK=$PWD/$APK" >> "$GITHUB_ENV"
|
||||
test -n "$APK"
|
||||
|
||||
- name: cache AVD snapshot
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
id: avd-cache
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/*
|
||||
~/.android/adb*
|
||||
key: avd-api-34
|
||||
|
||||
- name: create AVD snapshot for caching
|
||||
if: steps.avd-cache.outputs.cache-hit != 'true'
|
||||
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2
|
||||
with:
|
||||
api-level: 34
|
||||
arch: x86_64
|
||||
target: google_apis
|
||||
force-avd-creation: false
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
disable-animations: false
|
||||
script: echo "AVD snapshot created"
|
||||
|
||||
- name: run Android e2e lane
|
||||
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2
|
||||
with:
|
||||
api-level: 34
|
||||
arch: x86_64
|
||||
target: google_apis
|
||||
force-avd-creation: false
|
||||
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
disable-animations: true
|
||||
script: |
|
||||
adb install -r "$APK"
|
||||
cd apps/readest-app && pnpm test:android
|
||||
@@ -1,105 +0,0 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: 'CodeQL Advanced'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
pull_request:
|
||||
branches: ['main']
|
||||
schedule:
|
||||
- cron: '38 20 * * 4'
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||
# - https://gh.io/supported-runners-and-hardware-resources
|
||||
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
# required to fetch internal or private CodeQL packs
|
||||
packages: read
|
||||
|
||||
# only required for workflows in private repositories
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
- language: rust
|
||||
build-mode: none
|
||||
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
|
||||
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
# Add any setup steps before running the `github/codeql-action/init` action.
|
||||
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
||||
# or others). This is typically only required for manual builds.
|
||||
# - name: Setup runtime (example)
|
||||
# uses: actions/setup-example@v1
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# If the analyze step fails for one of the languages you are analyzing with
|
||||
# "We were unable to automatically build your code", modify the matrix above
|
||||
# to set the build mode to "manual" for that language. Then modify this step
|
||||
# to build your code.
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
- name: Run manual build steps
|
||||
if: matrix.build-mode == 'manual'
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||
'languages you are analyzing, replace this with the commands to build' \
|
||||
'your code, for example:'
|
||||
echo ' make bootstrap'
|
||||
echo ' make release'
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
@@ -1,191 +0,0 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
concurrency:
|
||||
group: publish-docker-image-${{ github.event.release.tag_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
env:
|
||||
BUILD_ARGS: |
|
||||
NEXT_PUBLIC_APP_PLATFORM=web
|
||||
steps:
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta (for labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/readest
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: production-stage
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: ${{ env.BUILD_ARGS }}
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/readest:buildcache-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/readest:buildcache-${{ env.PLATFORM_PAIR }},mode=max
|
||||
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/readest,push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
needs: build
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Detect Docker Hub credentials
|
||||
id: dockerhub
|
||||
run: |
|
||||
if [ -n "${{ secrets.DOCKERHUB_USERNAME }}" ] && [ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: steps.dockerhub.outputs.enabled == 'true'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract image metadata (GHCR only)
|
||||
id: meta-ghcr
|
||||
if: steps.dockerhub.outputs.enabled != 'true'
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/readest
|
||||
tags: |
|
||||
type=raw,value=main,enable={{is_default_branch}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'release' }}
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Extract image metadata (GHCR + Docker Hub)
|
||||
id: meta-all
|
||||
if: steps.dockerhub.outputs.enabled == 'true'
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository_owner }}/readest
|
||||
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/readest
|
||||
tags: |
|
||||
type=raw,value=main,enable={{is_default_branch}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'release' }}
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Create manifest list and push (GHCR only)
|
||||
if: steps.dockerhub.outputs.enabled != 'true'
|
||||
working-directory: /tmp/digests
|
||||
env:
|
||||
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta-ghcr.outputs.json }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository_owner }}/readest@sha256:%s ' *)
|
||||
|
||||
- name: Create manifest list and push (GHCR + Docker Hub)
|
||||
if: steps.dockerhub.outputs.enabled == 'true'
|
||||
working-directory: /tmp/digests
|
||||
env:
|
||||
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta-all.outputs.json }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository_owner }}/readest@sha256:%s ' *)
|
||||
|
||||
- name: Published image summary
|
||||
run: |
|
||||
echo "## Published Images" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Tags:" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ "${{ steps.dockerhub.outputs.enabled }}" == "true" ]; then
|
||||
echo "${{ steps.meta-all.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "${{ steps.meta-ghcr.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,468 +0,0 @@
|
||||
# Nightly builds. Mirrors the build matrix and build/signing steps of
|
||||
# release.yml — keep cert/NDK/toolchain bumps, secret names, and the
|
||||
# truly-portable AppImage + portable-Windows steps in sync between the two.
|
||||
#
|
||||
# Differences from release.yml: this workflow (1) stamps a nightly version
|
||||
# `<base>-<YYYYMMDDHH>` (Asia/Shanghai), (2) publishes to Cloudflare R2 only (no
|
||||
# GitHub release), and (3) assembles `nightly/latest.json` race-free from
|
||||
# per-leg manifest fragments so a single failing leg never clobbers the manifest.
|
||||
name: Nightly Readest
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 22 * * *' # 22:00 UTC = 06:00 GMT+8
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Serialize runs so an older run can't publish nightly/latest.json after a newer
|
||||
# one (no cancel — let an in-flight build finish rather than drop artifacts).
|
||||
concurrency:
|
||||
group: nightly-readest
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
compute-version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
nightly_version: ${{ steps.v.outputs.nightly_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
- id: v
|
||||
run: |
|
||||
BASE=$(node -p "require('./apps/readest-app/package.json').version")
|
||||
STAMP=$(TZ=Asia/Shanghai date +%Y%m%d%H)
|
||||
echo "nightly_version=${BASE}-${STAMP}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
needs: compute-version
|
||||
strategy:
|
||||
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 --bundles nsis'
|
||||
- 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: 60
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
- name: initialize git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: setup Java (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: setup Android SDK (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4
|
||||
|
||||
- name: install NDK (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
run: sdkmanager "ndk;28.2.13676358"
|
||||
|
||||
- name: install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
|
||||
- name: copy pdfjs-dist and simplecc-dist to public directory
|
||||
run: pnpm --filter @readest/readest-app setup-vendors
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
with:
|
||||
targets: ${{ matrix.config.rust_target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
key: nightly-${{ 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 libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev gir1.2-javascriptcoregtk-4.1 gir1.2-webkit2-4.1 libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
|
||||
- name: create .env.local file for Next.js
|
||||
run: |
|
||||
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}" >> .env.local
|
||||
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: install rclone
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$RUNNER_OS" = "Linux" ]; then
|
||||
sudo apt-get update && sudo apt-get install -y rclone
|
||||
elif [ "$RUNNER_OS" = "macOS" ]; then
|
||||
brew install rclone
|
||||
else
|
||||
choco install rclone -y
|
||||
fi
|
||||
|
||||
- name: configure rclone
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ~/.config/rclone
|
||||
cat > ~/.config/rclone/rclone.conf <<EOF
|
||||
[r2]
|
||||
type = s3
|
||||
provider = Cloudflare
|
||||
access_key_id = ${{ secrets.RELEASE_R2_ACCESS_KEY_ID }}
|
||||
secret_access_key = ${{ secrets.RELEASE_R2_SECRET_ACCESS_KEY }}
|
||||
endpoint = https://${{ secrets.RELEASE_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
|
||||
EOF
|
||||
|
||||
# ──────────────────────────── ANDROID ────────────────────────────
|
||||
# `pnpm tauri android init` + `git checkout .` reverts tracked files
|
||||
# (including package.json), so the nightly version MUST be patched AFTER
|
||||
# the checkout. Mirrors release.yml's android build/signing steps.
|
||||
- name: build and sign Android apks
|
||||
if: matrix.config.release == 'android'
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/28.2.13676358
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
version="${{ needs.compute-version.outputs.nightly_version }}"
|
||||
cd apps/readest-app/
|
||||
rm -rf src-tauri/gen/android
|
||||
pnpm tauri android init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
git checkout .
|
||||
# Patch the nightly version AFTER checkout so the stamp survives.
|
||||
node -e "const f='package.json';const j=require('./'+f);j.version='${version}';require('fs').writeFileSync(f, JSON.stringify(j,null,2)+'\n')"
|
||||
|
||||
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
|
||||
|
||||
apk_path=src-tauri/gen/android/app/build/outputs/apk/universal/release
|
||||
universal_apk=Readest_${version}_universal.apk
|
||||
arm64_apk=Readest_${version}_arm64.apk
|
||||
pnpm tauri android build
|
||||
cp ${apk_path}/app-universal-release.apk $universal_apk
|
||||
pnpm tauri android build -t aarch64
|
||||
cp ${apk_path}/app-universal-release.apk $arm64_apk
|
||||
pnpm tauri signer sign $universal_apk
|
||||
pnpm tauri signer sign $arm64_apk
|
||||
|
||||
# ──────────────────────────── DESKTOP ────────────────────────────
|
||||
# Linux uses the truly-portable AppImage tauri CLI fork (mirrors
|
||||
# release.yml). The nightly version is patched BEFORE `tauri build` so the
|
||||
# bundle filenames carry the stamp.
|
||||
- name: Override tauri-cli with custom AppImage format (Linux)
|
||||
if: matrix.config.release == 'linux'
|
||||
run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
|
||||
|
||||
- name: build desktop bundles
|
||||
if: matrix.config.release != 'android'
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: 'true'
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
run: |
|
||||
version="${{ needs.compute-version.outputs.nightly_version }}"
|
||||
node -e "const f='apps/readest-app/package.json';const j=require('./'+f);j.version='${version}';require('fs').writeFileSync(f, JSON.stringify(j,null,2)+'\n')"
|
||||
cd apps/readest-app
|
||||
# On Linux use the cargo `tauri` CLI (the truly-portable AppImage fork
|
||||
# installed above); elsewhere the npm @tauri-apps/cli.
|
||||
if [ "${{ matrix.config.release }}" = "linux" ]; then
|
||||
cargo tauri build ${{ matrix.config.args }}
|
||||
else
|
||||
pnpm tauri build ${{ matrix.config.args }}
|
||||
fi
|
||||
|
||||
# Portable Windows build: rebuild with NEXT_PUBLIC_PORTABLE_APP=true and
|
||||
# ship the raw exe (mirrors release.yml). Runs after the NSIS build above.
|
||||
- name: build and sign portable binaries (Windows only)
|
||||
if: matrix.config.os == 'windows-latest'
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${{ needs.compute-version.outputs.nightly_version }}"
|
||||
arch="${{ matrix.config.arch }}"
|
||||
rust_target="${{ matrix.config.rust_target }}"
|
||||
|
||||
# The clean NSIS build above produced bundle/nsis/Readest_<ver>_<x64|
|
||||
# arm64>-setup.exe (+ .sig). The portable rebuild below runs `tauri
|
||||
# build ... --bundles nsis` AGAIN with NEXT_PUBLIC_PORTABLE_APP=true,
|
||||
# which OVERWRITES that installer with a portable-flavored one. Stage
|
||||
# the clean installer + its updater .sig to a safe dir FIRST so the
|
||||
# collect step can read the untouched copy for the windows-* keys.
|
||||
if [ "$arch" = "x86_64" ]; then
|
||||
nsis_name="Readest_${version}_x64-setup.exe"
|
||||
else
|
||||
nsis_name="Readest_${version}_arm64-setup.exe"
|
||||
fi
|
||||
nsis_src="target/${rust_target}/release/bundle/nsis/${nsis_name}"
|
||||
mkdir -p nsis-staged
|
||||
cp "$nsis_src" "nsis-staged/${nsis_name}"
|
||||
cp "${nsis_src}.sig" "nsis-staged/${nsis_name}.sig"
|
||||
|
||||
pushd apps/readest-app/
|
||||
echo "NEXT_PUBLIC_PORTABLE_APP=true" >> .env.local
|
||||
pnpm tauri build ${{ matrix.config.args }}
|
||||
popd
|
||||
|
||||
if [ "$arch" = "x86_64" ]; then
|
||||
bin_file="Readest_${version}_x64-portable.exe"
|
||||
else
|
||||
bin_file="Readest_${version}_arm64-portable.exe"
|
||||
fi
|
||||
exe_file="target/${{ matrix.config.rust_target }}/release/readest.exe"
|
||||
# Browsers on Windows refuse to download zips containing exe files, so
|
||||
# ship the exe directly (matches release.yml).
|
||||
cp "$exe_file" "$bin_file"
|
||||
pushd apps/readest-app/
|
||||
pnpm tauri signer sign "../../$bin_file"
|
||||
popd
|
||||
|
||||
# ───────────────── COLLECT ARTIFACTS + BUILD FRAGMENT ─────────────────
|
||||
# Each leg copies its updater artifacts (+ .sig) into ./nightly-out and
|
||||
# emits a per-leg manifest fragment keyed by the EXACT Tauri platform keys
|
||||
# the client expects (see src/helpers/updater.ts::getNightlyPlatformKey and
|
||||
# src/components/UpdaterWindow.tsx::TAURI_UPDATER_KEYS). The fragment
|
||||
# `signature` is the .sig file CONTENTS; `url` is the download.readest.com
|
||||
# URL of the uploaded artifact under nightly/<version>/.
|
||||
- name: collect artifacts + build manifest fragment
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${{ needs.compute-version.outputs.nightly_version }}"
|
||||
release="${{ matrix.config.release }}"
|
||||
arch="${{ matrix.config.arch }}"
|
||||
rust_target="${{ matrix.config.rust_target }}"
|
||||
base_url="https://download.readest.com/nightly/${version}"
|
||||
out="$PWD/nightly-out"
|
||||
frag_dir="$out/frag"
|
||||
mkdir -p "$out" "$frag_dir"
|
||||
# Cargo WORKSPACE: bundles land in the REPO-ROOT target/ dir, not
|
||||
# apps/readest-app/src-tauri/target/ (matches release.yml line 371).
|
||||
bundle="target"
|
||||
|
||||
# Stage one artifact + its .sig into $out, then append a
|
||||
# platforms[<key>] = {signature, url} entry to the fragment JSON.
|
||||
frag="$frag_dir/${release}-${arch:-all}.json"
|
||||
echo '{"platforms":{}}' > "$frag"
|
||||
add_entry() {
|
||||
local src="$1"; local fname="$2"; local key="$3"
|
||||
if [ ! -f "$src" ] || [ ! -f "${src}.sig" ]; then
|
||||
echo "::error::missing artifact or signature for $key: $src"
|
||||
exit 1
|
||||
fi
|
||||
cp "$src" "$out/$fname"
|
||||
cp "${src}.sig" "$out/${fname}.sig"
|
||||
local sig; sig=$(cat "${src}.sig")
|
||||
local url="${base_url}/${fname}"
|
||||
jq --arg k "$key" --arg sig "$sig" --arg url "$url" \
|
||||
'.platforms[$k] = {signature: $sig, url: $url}' "$frag" > "$frag.tmp" && mv "$frag.tmp" "$frag"
|
||||
echo "fragment += $key -> $fname"
|
||||
}
|
||||
|
||||
case "$release" in
|
||||
android)
|
||||
# Signed in the android step; basenames already version-stamped.
|
||||
add_entry "apps/readest-app/Readest_${version}_universal.apk" "Readest_${version}_universal.apk" "android-universal"
|
||||
add_entry "apps/readest-app/Readest_${version}_arm64.apk" "Readest_${version}_arm64.apk" "android-arm64"
|
||||
;;
|
||||
macos)
|
||||
# Universal updater bundle: bundle/macos/Readest.app.tar.gz (no
|
||||
# version/arch on disk). Upload under the stable convention name
|
||||
# Readest_universal.app.tar.gz; both darwin keys point at it.
|
||||
src="${bundle}/universal-apple-darwin/release/bundle/macos/Readest.app.tar.gz"
|
||||
add_entry "$src" "Readest_universal.app.tar.gz" "darwin-aarch64"
|
||||
# Reuse the already-staged copy for the x86_64 key (same artifact).
|
||||
x86_sig=$(cat "$out/Readest_universal.app.tar.gz.sig")
|
||||
jq --arg sig "$x86_sig" --arg url "${base_url}/Readest_universal.app.tar.gz" \
|
||||
'.platforms["darwin-x86_64"] = {signature: $sig, url: $url}' "$frag" > "$frag.tmp" && mv "$frag.tmp" "$frag"
|
||||
echo "fragment += darwin-x86_64 -> Readest_universal.app.tar.gz"
|
||||
;;
|
||||
windows)
|
||||
if [ "$arch" = "x86_64" ]; then
|
||||
nsis_name="Readest_${version}_x64-setup.exe"
|
||||
portable_name="Readest_${version}_x64-portable.exe"
|
||||
nsis_key="windows-x86_64"; portable_key="windows-x86_64-portable"
|
||||
else
|
||||
nsis_name="Readest_${version}_arm64-setup.exe"
|
||||
portable_name="Readest_${version}_arm64-portable.exe"
|
||||
nsis_key="windows-aarch64"; portable_key="windows-aarch64-portable"
|
||||
fi
|
||||
# Read the CLEAN NSIS installer staged before the portable rebuild
|
||||
# (the rebuild overwrites bundle/nsis/...-setup.exe). See the
|
||||
# "build and sign portable binaries" step.
|
||||
add_entry "nsis-staged/${nsis_name}" "$nsis_name" "$nsis_key"
|
||||
# Portable exe was copied + signed into the repo root above.
|
||||
add_entry "$portable_name" "$portable_name" "$portable_key"
|
||||
;;
|
||||
linux)
|
||||
# Truly-portable AppImage: bundle/appimage/Readest_<ver>_<amd64|aarch64>.AppImage
|
||||
if [ "$arch" = "x86_64" ]; then
|
||||
appimage_name="Readest_${version}_amd64.AppImage"
|
||||
key="linux-x86_64-appimage"
|
||||
else
|
||||
appimage_name="Readest_${version}_aarch64.AppImage"
|
||||
key="linux-aarch64-appimage"
|
||||
fi
|
||||
# The Linux leg builds with `cargo tauri build` WITHOUT `--target`
|
||||
# (no matrix args), so cargo emits bundles under target/release/
|
||||
# (host-target default) — NOT target/<triple>/release/ like the
|
||||
# macOS/Windows legs, which DO pass `--target`. So there is no
|
||||
# ${rust_target} subdir here.
|
||||
add_entry "${bundle}/release/bundle/appimage/${appimage_name}" "$appimage_name" "$key"
|
||||
;;
|
||||
*)
|
||||
echo "::error::unknown release leg: $release"; exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: upload artifacts + fragment to R2
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${{ needs.compute-version.outputs.nightly_version }}"
|
||||
base="r2:readest-releases/nightly/${version}"
|
||||
out="$PWD/nightly-out"
|
||||
# Artifacts (exclude the local frag/ scratch dir).
|
||||
rclone copy "$out" "$base/" --exclude "frag/**"
|
||||
# Per-leg manifest fragment.
|
||||
rclone copy "$out/frag" "$base/manifest-fragments/"
|
||||
|
||||
assemble-manifest:
|
||||
needs: [compute-version, build]
|
||||
if: ${{ always() && needs.build.result != 'cancelled' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: install rclone + jq
|
||||
run: sudo apt-get update && sudo apt-get install -y rclone jq
|
||||
|
||||
- name: configure rclone
|
||||
run: |
|
||||
mkdir -p ~/.config/rclone
|
||||
cat > ~/.config/rclone/rclone.conf <<EOF
|
||||
[r2]
|
||||
type = s3
|
||||
provider = Cloudflare
|
||||
access_key_id = ${{ secrets.RELEASE_R2_ACCESS_KEY_ID }}
|
||||
secret_access_key = ${{ secrets.RELEASE_R2_SECRET_ACCESS_KEY }}
|
||||
endpoint = https://${{ secrets.RELEASE_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
|
||||
EOF
|
||||
|
||||
- name: assemble + atomically promote latest.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${{ needs.compute-version.outputs.nightly_version }}"
|
||||
base="r2:readest-releases/nightly"
|
||||
|
||||
# Pull only the fragments that SUCCEEDED legs uploaded.
|
||||
rclone copy "$base/${version}/manifest-fragments" ./frag || true
|
||||
if [ -z "$(ls -A ./frag 2>/dev/null)" ]; then
|
||||
echo "::error::no manifest fragments found — all build legs failed; manifest NOT promoted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Merge every fragment's .platforms into one manifest.
|
||||
jq -s \
|
||||
--arg version "$version" \
|
||||
'{version: $version, pub_date: (now | todateiso8601), notes: "Nightly build", platforms: (map(.platforms) | add)}' \
|
||||
./frag/*.json > latest.json
|
||||
|
||||
echo "Assembled latest.json:"
|
||||
jq '{version, platforms: (.platforms | keys)}' latest.json
|
||||
|
||||
# Promote the manifest with a directory `rclone copy`, exactly like the
|
||||
# stable release flow writes releases/latest.json (upload-to-r2.yml).
|
||||
# A single-file `rclone copyto`/`moveto` first issues a CreateBucket
|
||||
# probe (PUT /<bucket>) that the object-scoped R2 token can't satisfy
|
||||
# (403 AccessDenied); a directory copy PUTs the object directly. R2
|
||||
# PutObject is atomic, so readers never observe a half-written manifest.
|
||||
mkdir -p promote && cp latest.json promote/latest.json
|
||||
rclone copy promote "$base/"
|
||||
|
||||
- name: prune old nightly folders (keep newest 7)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="r2:readest-releases/nightly"
|
||||
# Version dirs are <base>-<YYYYMMDDHH>. Lexicographic order is NOT
|
||||
# chronological across a base-version bump (e.g. 0.2.0-* sorts after
|
||||
# 0.11.0-*), so sort by the numeric stamp tail (everything after the
|
||||
# last '-') to keep the newest 7 by build time.
|
||||
mapfile -t dirs < <(rclone lsf "$base/" --dirs-only | sed 's:/$::' \
|
||||
| sed -E 's/^(.*)-([0-9]{10})$/\2 \1-\2/' | sort -n -k1,1 | cut -d' ' -f2-)
|
||||
count=${#dirs[@]}
|
||||
if [ "$count" -gt 7 ]; then
|
||||
for d in "${dirs[@]:0:$((count-7))}"; do
|
||||
echo "pruning $d"
|
||||
rclone purge "$base/$d"
|
||||
done
|
||||
fi
|
||||
|
||||
- name: notify on failure
|
||||
if: failure()
|
||||
run: echo "::error::Nightly assemble failed — manifest not promoted."
|
||||
@@ -1,321 +1,35 @@
|
||||
name: PR checks
|
||||
name: Build Web Application on Pull Request
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
rust_lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTFLAGS: '-C target-cpu=skylake'
|
||||
SCCACHE_GHA_ENABLED: 'true'
|
||||
RUSTC_WRAPPER: sccache
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
submodules: 'true'
|
||||
- name: setup sccache
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
- name: Install minimal stable with clippy and rustfmt
|
||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
- name: Cache apt packages
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: apt-rust-lint-${{ runner.os }}
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev
|
||||
- name: Format check
|
||||
working-directory: apps/readest-app/src-tauri
|
||||
run: cargo fmt --check
|
||||
- name: Clippy Check
|
||||
working-directory: apps/readest-app/src-tauri
|
||||
run: cargo clippy -p Readest --no-deps -- -D warnings
|
||||
- name: Unit tests
|
||||
working-directory: apps/readest-app/src-tauri
|
||||
run: cargo test -p Readest --lib
|
||||
|
||||
build_web_app:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.1
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: install Dependencies
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
|
||||
pnpm install && pnpm setup-pdfjs
|
||||
|
||||
- name: run format check
|
||||
run: |
|
||||
pnpm format:check || (pnpm format && git diff && exit 1)
|
||||
|
||||
# pnpm lint here is web-only (tsgo + biome). The koplugin syntax check
|
||||
# (lint:lua) runs in the test_extensions job, which installs LuaJIT only
|
||||
# when the koplugin sources changed.
|
||||
- name: run lint
|
||||
- name: build the web App
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm lint
|
||||
|
||||
- name: build the web app
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm build-web && pnpm check:all
|
||||
|
||||
- name: cache playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: install playwright browsers
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = 'true' ]; then
|
||||
npx playwright install-deps chromium
|
||||
else
|
||||
npx playwright install --with-deps chromium
|
||||
fi
|
||||
|
||||
- name: run web e2e tests
|
||||
id: web_e2e
|
||||
working-directory: apps/readest-app
|
||||
run: pnpm test:e2e:web
|
||||
|
||||
- name: upload e2e report
|
||||
if: ${{ failure() && steps.web_e2e.outcome == 'failure' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report
|
||||
path: apps/readest-app/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
# The jsdom unit suite is the slowest part of the PR checks, so it is split
|
||||
# across two parallel shards (vitest --shard). The browser tests need
|
||||
# Playwright and run only on shard 1; koplugin + browser-extension tests
|
||||
# moved to the test_extensions job.
|
||||
test_web_app:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: install Dependencies
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
|
||||
|
||||
# Playwright is only needed by the browser tests, which run on shard 1.
|
||||
- name: cache playwright browsers
|
||||
if: matrix.shard == 1
|
||||
id: playwright-cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: install playwright browsers
|
||||
if: matrix.shard == 1
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = 'true' ]; then
|
||||
npx playwright install-deps chromium
|
||||
else
|
||||
npx playwright install --with-deps chromium
|
||||
fi
|
||||
|
||||
- name: run web unit tests (shard ${{ matrix.shard }}/2)
|
||||
working-directory: apps/readest-app
|
||||
run: pnpm test:pr:web:unit --shard=${{ matrix.shard }}/2
|
||||
|
||||
- name: run web browser tests
|
||||
if: matrix.shard == 1
|
||||
working-directory: apps/readest-app
|
||||
run: pnpm test:browser
|
||||
|
||||
# Browser-extension tests + build always run. The koplugin lint + Lua tests
|
||||
# (and the ~45s LuaJIT/busted install they need) only run when the koplugin
|
||||
# sources changed, so most PRs skip that cost entirely.
|
||||
test_extensions:
|
||||
runs-on: ubuntu-latest
|
||||
# pull-requests: read lets dorny/paths-filter list a PR's changed files
|
||||
# via the REST API (the top-level grant is contents: read only).
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
- name: detect koplugin changes
|
||||
id: changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4
|
||||
with:
|
||||
filters: |
|
||||
koplugin:
|
||||
- 'apps/readest.koplugin/**'
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: install Dependencies
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
|
||||
|
||||
- name: run extension tests
|
||||
working-directory: apps/readest-app
|
||||
run: pnpm test:extension
|
||||
|
||||
- name: build browser extension
|
||||
working-directory: apps/readest-app
|
||||
run: pnpm build-browser-ext
|
||||
|
||||
- name: cache apt packages
|
||||
if: steps.changes.outputs.koplugin == 'true'
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: apt-test-koplugin-${{ runner.os }}
|
||||
|
||||
- name: install LuaJIT + busted (for koplugin lint + tests)
|
||||
if: steps.changes.outputs.koplugin == 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# luajit — pnpm lint:lua + pnpm test:lua
|
||||
# luarocks/libsqlite3-dev — required to build lsqlite3complete
|
||||
sudo apt-get install -y luajit luarocks libsqlite3-dev
|
||||
# Install busted + the SQLite binding the LibraryStore specs use,
|
||||
# both pinned to Lua 5.1 (LuaJIT-compatible). System-wide install
|
||||
# so `luarocks --lua-version=5.1 path` (sourced by
|
||||
# scripts/test-koplugin.mjs) picks them up.
|
||||
sudo luarocks --lua-version=5.1 install busted
|
||||
sudo luarocks --lua-version=5.1 install lsqlite3complete
|
||||
|
||||
- name: lint koplugin
|
||||
if: steps.changes.outputs.koplugin == 'true'
|
||||
working-directory: apps/readest-app
|
||||
run: pnpm lint:lua
|
||||
|
||||
- name: run koplugin tests
|
||||
if: steps.changes.outputs.koplugin == 'true'
|
||||
working-directory: apps/readest-app
|
||||
run: pnpm test:lua
|
||||
|
||||
build_tauri_app:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: 'true'
|
||||
RUSTC_WRAPPER: sccache
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
# The tauri tests run `next dev`, whose Turbopack cache lives in
|
||||
# `.next/dev/cache` (a different path from the `next build` cache).
|
||||
- name: cache Turbopack dev cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: apps/readest-app/.next/dev/cache
|
||||
key: turbo-dev-tauri-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
turbo-dev-tauri-${{ runner.os }}-
|
||||
|
||||
- name: install Dependencies
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
|
||||
|
||||
- name: setup sccache
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
|
||||
- name: install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
||||
with:
|
||||
toolchain: stable
|
||||
# Disable this action's built-in rust-cache so the explicit
|
||||
# Swatinem/rust-cache below is the single cache (it's the one
|
||||
# configured with cache-workspace-crates for the vendored tauri fork).
|
||||
cache: false
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
# cache-workspace-crates caches the path/workspace crates too: the
|
||||
# vendored tauri fork (packages/tauri, packages/tauri-plugins, wired
|
||||
# via [patch.crates-io]) and the local src-tauri/plugins/*. These are
|
||||
# workspace members that rust-cache prunes by default, so the whole
|
||||
# tauri stack — plus every crates.io plugin that depends on the
|
||||
# patched `tauri` — rebuilt on every run. They change only
|
||||
# sporadically (pinned submodules), so caching them is a big win.
|
||||
# The key is bumped (-ws) so the old workspace-crate-less cache is
|
||||
# invalidated and the next run repopulates it with the workspace
|
||||
# crates included.
|
||||
key: tauri-cargo-ws
|
||||
cache-all-crates: 'true'
|
||||
cache-workspace-crates: 'true'
|
||||
|
||||
- name: Cache apt packages
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: apt-tauri-${{ runner.os }}
|
||||
- name: install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev xvfb
|
||||
|
||||
- name: run tauri tests
|
||||
working-directory: apps/readest-app
|
||||
run: xvfb-run pnpm test:pr:tauri
|
||||
pnpm build-web
|
||||
|
||||
+59
-311
@@ -5,28 +5,25 @@ on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
get-release:
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.get-release.outputs.release_id }}
|
||||
release_tag: ${{ steps.get-release.outputs.release_tag }}
|
||||
release_note: ${{ steps.get-release-notes.outputs.release_note }}
|
||||
release_version: ${{ steps.get-release-notes.outputs.release_version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
uses: actions/setup-node@v4
|
||||
- name: get version
|
||||
run: echo "PACKAGE_VERSION=$(node -p "require('./apps/readest-app/package.json').version")" >> $GITHUB_ENV
|
||||
- name: get release
|
||||
id: get-release
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.getLatestRelease({
|
||||
@@ -37,7 +34,7 @@ jobs:
|
||||
core.setOutput('release_tag', data.tag_name);
|
||||
- name: get release notes
|
||||
id: get-release-notes
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
@@ -47,77 +44,8 @@ jobs:
|
||||
const notes = releaseNotes.notes || [];
|
||||
const releaseNote = notes.map((note, index) => `${index + 1}. ${note}`).join(' ');
|
||||
console.log('Formatted release note:', releaseNote);
|
||||
core.setOutput('release_version', version);
|
||||
core.setOutput('release_note', releaseNote);
|
||||
|
||||
update-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: get-release
|
||||
|
||||
steps:
|
||||
- name: update release
|
||||
id: update-release
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
env:
|
||||
release_id: ${{ needs.get-release.outputs.release_id }}
|
||||
release_tag: ${{ needs.get-release.outputs.release_tag }}
|
||||
release_note: ${{ needs.get-release.outputs.release_note }}
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.generateReleaseNotes({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: process.env.release_tag,
|
||||
})
|
||||
const notes = process.env.release_note.split(/\d+\.\s/).filter(Boolean);
|
||||
const formattedNotes = notes.map(note => `* ${note.trim()}`).join("\n");
|
||||
const body = `## Release Highlight\n${formattedNotes}\n\n${data.body}`;
|
||||
github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: process.env.release_id,
|
||||
body: body,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
})
|
||||
|
||||
build-koreader-plugin:
|
||||
needs: get-release
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: create KOReader plugin zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
plugin_zip="Readest-${version}-1.koplugin.zip"
|
||||
meta_file="apps/readest.koplugin/_meta.lua"
|
||||
perl -i -pe "s/^}/ version = \"${version}\",\n}/" "${meta_file}"
|
||||
|
||||
# Exclude dev-only artifacts from the published plugin zip:
|
||||
# scripts/ — i18n + build helpers
|
||||
# docs/ — design notes
|
||||
# spec/ — busted test suite
|
||||
# .busted — busted runner config
|
||||
# Mirror these in apps/readest.koplugin/scripts/build-koplugin.mjs
|
||||
# for local builds.
|
||||
cd apps
|
||||
zip -r ../${plugin_zip} readest.koplugin \
|
||||
-x 'readest.koplugin/scripts/*' \
|
||||
'readest.koplugin/docs/*' \
|
||||
'readest.koplugin/spec/*' \
|
||||
'readest.koplugin/.busted'
|
||||
cd ..
|
||||
|
||||
echo "Uploading ${plugin_zip} to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} ${plugin_zip} --clobber
|
||||
|
||||
build-tauri:
|
||||
needs: get-release
|
||||
permissions:
|
||||
@@ -126,193 +54,78 @@ 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 --bundles nsis'
|
||||
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: 60
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: initialize git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.14.4
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: setup Java (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: setup Android SDK (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4
|
||||
|
||||
- name: install NDK (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
run: sdkmanager "ndk;28.2.13676358"
|
||||
|
||||
- name: install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
run: pnpm install
|
||||
|
||||
- name: copy pdfjs-dist and simplecc-dist to public directory
|
||||
run: pnpm --filter @readest/readest-app setup-vendors
|
||||
- name: copy pdfjs-dist to public directory
|
||||
run: pnpm --filter @readest/readest-app setup-pdfjs
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.config.rust_target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
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' && matrix.config.arch != 'armhf'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1 libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev gir1.2-javascriptcoregtk-4.1 gir1.2-webkit2-4.1 libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
|
||||
- name: install dependencies (ubuntu only - armhf specific)
|
||||
if: contains(matrix.config.os, 'ubuntu') && matrix.config.arch == 'armhf'
|
||||
run: |
|
||||
sudo dpkg --add-architecture armhf
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev:armhf libgtk-3-dev:armhf libwebkit2gtk-4.1-dev:armhf libappindicator3-dev:armhf librsvg2-dev:armhf gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf
|
||||
echo 'PKG_CONFIG_ALLOW_CROSS=1' >> $GITHUB_ENV
|
||||
echo 'PKG_CONFIG_PATH=/usr/lib/arm-linux-gnueabihf/pkgconfig:/usr/share/pkgconfig' >> $GITHUB_ENV
|
||||
echo 'PKG_CONFIG_SYSROOT_DIR=/usr/arm-linux-gnueabihf' >> $GITHUB_ENV
|
||||
echo 'CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc' >> $GITHUB_ENV
|
||||
echo 'CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_RUSTFLAGS=--cfg=io_uring_skip_arch_check' >> $GITHUB_ENV
|
||||
key: ${{ matrix.config.os }}-cargo-${{ hashFiles('apps/readest-app/src-tauri/Cargo.lock') }}
|
||||
workspaces: apps/readest-app/src-tauri -> target
|
||||
|
||||
- name: create .env.local file for Next.js
|
||||
run: |
|
||||
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_DEEPL_API_KEY=${{ secrets.NEXT_PUBLIC_DEEPL_API_KEY }}" >> .env.local
|
||||
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: build and upload Android apks
|
||||
if: matrix.config.release == 'android'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/28.2.13676358
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
- name: copy .env.local to apps/readest-app
|
||||
run: cp .env.local apps/readest-app/.env.local
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.config.os == 'ubuntu-22.04'
|
||||
run: |
|
||||
cd apps/readest-app/
|
||||
rm -rf src-tauri/gen/android
|
||||
pnpm tauri android init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
git checkout .
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
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'
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cd apps/readest-app/
|
||||
# Use -f so curl fails on HTTP errors instead of writing the 404 body
|
||||
# ("Not Found") into latest.json and clobbering the release asset with
|
||||
# invalid JSON, which then breaks tauri-action's updater merge on every
|
||||
# subsequent build.
|
||||
if ! curl -fsSL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json; then
|
||||
echo "::error::Failed to download existing latest.json; aborting to avoid clobbering the release asset."
|
||||
exit 1
|
||||
fi
|
||||
if ! jq empty latest.json 2>/dev/null; then
|
||||
echo "::error::Existing latest.json is not valid JSON; aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
- name: Override tauri-cli with custom AppImage format (Linux)
|
||||
if: matrix.config.release == 'linux'
|
||||
run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
|
||||
|
||||
- uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0
|
||||
if: matrix.config.release != 'android'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: 'true'
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -321,118 +134,53 @@ 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
|
||||
# On Linux, build with the Rust `cargo tauri` CLI installed in the
|
||||
# step above so the new (truly-portable AppImage) bundler is used.
|
||||
# Without this, tauri-action falls back to the npm @tauri-apps/cli.
|
||||
tauriScript: ${{ matrix.config.release == 'linux' && 'cargo tauri' || '' }}
|
||||
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: build and upload portable binaries (Windows only)
|
||||
- name: upload portable binaries (Windows only)
|
||||
if: matrix.config.os == 'windows-latest'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Building Portable Binaries"
|
||||
pushd apps/readest-app/
|
||||
echo "NEXT_PUBLIC_PORTABLE_APP=true" >> .env.local
|
||||
pnpm tauri build ${{ matrix.config.args }}
|
||||
|
||||
popd
|
||||
echo "Uploading Portable Binaries"
|
||||
arch=${{ matrix.config.arch }}
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
version=$PACKAGE_VERSION
|
||||
|
||||
if [ "$arch" = "x86_64" ]; then
|
||||
bin_file="Readest_${version}_x64-portable.exe"
|
||||
zip_name="Readest_${version}_x64-portable.zip"
|
||||
elif [ "$arch" = "aarch64" ]; then
|
||||
bin_file="Readest_${version}_arm64-portable.exe"
|
||||
zip_name="Readest_${version}_arm64-portable.zip"
|
||||
else
|
||||
echo "Unknown architecture: $arch"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exe_file="target/${{ matrix.config.rust_target }}/release/readest.exe"
|
||||
# Browsers on Windows won't download zip files that contain exe files
|
||||
# so upload the exe files instead. This is totally stupid.
|
||||
# powershell.exe -Command "Compress-Archive -Path $exe_file -DestinationPath $bin_file -Force"
|
||||
cp $exe_file $bin_file
|
||||
zip -j $zip_name apps/readest-app/src-tauri/target/release/readest.exe
|
||||
echo "Uploading $zip_name to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $zip_name --clobber
|
||||
|
||||
echo "Uploading $bin_file to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file --clobber
|
||||
|
||||
echo "Signing portable binary"
|
||||
pushd apps/readest-app/
|
||||
pnpm tauri signer sign "../../$bin_file"
|
||||
popd
|
||||
echo "Uploading signature to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file.sig --clobber
|
||||
|
||||
- name: download and update latest.json for Windows portable release
|
||||
if: matrix.config.os == 'windows-latest'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
# Use -f so curl fails on HTTP errors instead of writing the 404 body
|
||||
# ("Not Found") into latest.json and clobbering the release asset with
|
||||
# invalid JSON, which then breaks tauri-action's updater merge on every
|
||||
# subsequent build.
|
||||
if ! curl -fsSL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json; then
|
||||
echo "::error::Failed to download existing latest.json; aborting to avoid clobbering the release asset."
|
||||
exit 1
|
||||
fi
|
||||
if ! jq empty latest.json 2>/dev/null; then
|
||||
echo "::error::Existing latest.json is not valid JSON; aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
arch=${{ matrix.config.arch }}
|
||||
|
||||
if [ "$arch" = "x86_64" ]; then
|
||||
bin_file="Readest_${version}_x64-portable.exe"
|
||||
platform_key="windows-x86_64-portable"
|
||||
elif [ "$arch" = "aarch64" ]; then
|
||||
bin_file="Readest_${version}_arm64-portable.exe"
|
||||
platform_key="windows-aarch64-portable"
|
||||
else
|
||||
echo "Unknown architecture: $arch"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
portable_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/$bin_file"
|
||||
portable_sig=$(cat $bin_file.sig)
|
||||
|
||||
jq --arg url "$portable_url" \
|
||||
--arg sig "$portable_sig" \
|
||||
--arg key "$platform_key" \
|
||||
'.platforms[$key] = {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
|
||||
|
||||
upload-to-r2:
|
||||
needs: [get-release, build-tauri]
|
||||
update-release:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/upload-to-r2.yml
|
||||
with:
|
||||
tag: ${{ needs.get-release.outputs.release_tag }}
|
||||
secrets: inherit
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-release, build-tauri]
|
||||
|
||||
steps:
|
||||
- name: update release
|
||||
id: update-release
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
release_id: ${{ needs.get-release.outputs.release_id }}
|
||||
release_note: ${{ needs.get-release.outputs.release_note }}
|
||||
with:
|
||||
script: |
|
||||
const body = `## Release Highlight\n${process.env.release_note}`;
|
||||
github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: process.env.release_id,
|
||||
body: body,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
})
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# This workflow uses actions that are not certified by GitHub. They are provided
|
||||
# by a third-party and are governed by separate terms of service, privacy
|
||||
# policy, and support documentation.
|
||||
|
||||
name: Scorecard supply-chain security
|
||||
on:
|
||||
# For Branch-Protection check. Only the default branch is supported. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
|
||||
branch_protection_rule:
|
||||
# To guarantee Maintained check is occasionally updated. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
||||
schedule:
|
||||
- cron: '26 4 * * 3'
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
# `publish_results: true` only works when run from the default branch. conditional can be removed if disabled.
|
||||
if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
|
||||
permissions:
|
||||
# Needed to upload the results to code-scanning dashboard.
|
||||
security-events: write
|
||||
# Needed to publish results and get a badge (see publish_results below).
|
||||
id-token: write
|
||||
# Uncomment the permissions below if installing in a private repository.
|
||||
# contents: read
|
||||
# actions: read
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
|
||||
# - you want to enable the Branch-Protection check on a *public* repository, or
|
||||
# - you are installing Scorecard on a *private* repository
|
||||
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
|
||||
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
|
||||
|
||||
# Public repositories:
|
||||
# - Publish results to OpenSSF REST API for easy access by consumers
|
||||
# - Allows the repository to include the Scorecard badge.
|
||||
# - See https://github.com/ossf/scorecard-action#publishing-results.
|
||||
# For private repositories:
|
||||
# - `publish_results` will always be set to `false`, regardless
|
||||
# of the value entered here.
|
||||
publish_results: true
|
||||
|
||||
# (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore
|
||||
# file_mode: git
|
||||
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard (optional).
|
||||
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
@@ -1,74 +0,0 @@
|
||||
name: Upload Release Assets to R2
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag name (e.g., v1.2.3)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
upload-to-r2:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 3
|
||||
strategy:
|
||||
fail-fast: false
|
||||
env:
|
||||
RELEASE_R2_BUCKET: readest-releases
|
||||
RELEASE_R2_ACCOUNT_ID: ${{ secrets.RELEASE_R2_ACCOUNT_ID }}
|
||||
RELEASE_R2_ACCESS_KEY_ID: ${{ secrets.RELEASE_R2_ACCESS_KEY_ID }}
|
||||
RELEASE_R2_SECRET_ACCESS_KEY: ${{ secrets.RELEASE_R2_SECRET_ACCESS_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Download release assets
|
||||
run: |
|
||||
gh release download "${{ inputs.tag }}" --repo readest/readest --dir ./release-assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install rclone
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y rclone
|
||||
|
||||
- name: Configure rclone
|
||||
run: |
|
||||
mkdir -p ~/.config/rclone
|
||||
cat > ~/.config/rclone/rclone.conf <<EOF
|
||||
[r2]
|
||||
type = s3
|
||||
provider = Cloudflare
|
||||
access_key_id = $RELEASE_R2_ACCESS_KEY_ID
|
||||
secret_access_key = $RELEASE_R2_SECRET_ACCESS_KEY
|
||||
endpoint = https://${RELEASE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com
|
||||
EOF
|
||||
|
||||
- name: Modify latest.json download URLs
|
||||
run: |
|
||||
GITHUB_BASE_URL="https://github.com/readest/readest/releases/download"
|
||||
READEST_BASE_URL="https://download.readest.com/releases"
|
||||
sed -i "s#${GITHUB_BASE_URL}#${READEST_BASE_URL}#g" ./release-assets/latest.json
|
||||
|
||||
- name: Upload to R2
|
||||
run: |
|
||||
mkdir releases
|
||||
mv ./release-assets/latest.json releases
|
||||
mv ./release-assets/release-notes.json releases
|
||||
rclone copy ./release-assets r2:${RELEASE_R2_BUCKET}/releases/${{ inputs.tag }}/
|
||||
rclone copy ./releases r2:${RELEASE_R2_BUCKET}/releases/
|
||||
|
||||
- name: Upload successful
|
||||
if: success()
|
||||
run: echo "Upload completed successfully"
|
||||
@@ -4,20 +4,20 @@ on:
|
||||
branches:
|
||||
- main
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
deployments: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'true'
|
||||
- uses: amondnet/vercel-action@de09aeac2ace6599ec9b11ef87558759a496bac4 # v42
|
||||
- uses: amondnet/vercel-action@v25
|
||||
with:
|
||||
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||
github-comment: false
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
vercel-args: '--prod'
|
||||
vercel-org-id: ${{ secrets.ORG_ID}}
|
||||
vercel-project-id: ${{ secrets.PROJECT_ID}}
|
||||
|
||||
-19
@@ -1,5 +1,4 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
docker/.env
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
@@ -36,21 +35,3 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Rust build
|
||||
target
|
||||
|
||||
fastlane/report.xml
|
||||
fastlane/metadata/android/en-US/changelogs
|
||||
|
||||
*.koplugin.zip
|
||||
|
||||
# nix
|
||||
result*
|
||||
|
||||
.playwright-mcp/
|
||||
.gstack
|
||||
|
||||
.claude/worktrees
|
||||
.claude/settings.local.json
|
||||
|
||||
|
||||
+2
-23
@@ -1,27 +1,6 @@
|
||||
[submodule "packages/foliate-js"]
|
||||
path = packages/foliate-js
|
||||
url = https://github.com/readest/foliate-js.git
|
||||
url = https://github.com/chrox/foliate-js.git
|
||||
[submodule "packages/tauri"]
|
||||
path = packages/tauri
|
||||
url = https://github.com/readest/tauri.git
|
||||
[submodule "packages/tauri-plugins"]
|
||||
path = packages/tauri-plugins
|
||||
url = https://github.com/readest/tauri-plugins-workspace.git
|
||||
[submodule "packages/simplecc-wasm"]
|
||||
path = packages/simplecc-wasm
|
||||
url = https://github.com/readest/simplecc-wasm.git
|
||||
[submodule "apps/readest-app/src-tauri/plugins/tauri-plugin-turso"]
|
||||
path = apps/readest-app/src-tauri/plugins/tauri-plugin-turso
|
||||
url = https://github.com/readest/tauri-plugin-turso.git
|
||||
[submodule "apps/readest-app/.claude/skills/gstack"]
|
||||
path = apps/readest-app/.claude/skills/gstack
|
||||
url = https://github.com/garrytan/gstack.git
|
||||
[submodule "packages/qcms"]
|
||||
path = packages/qcms
|
||||
url = https://github.com/mozilla/pdf.js.qcms.git
|
||||
[submodule "packages/js-mdict"]
|
||||
path = packages/js-mdict
|
||||
url = https://github.com/readest/js-mdict.git
|
||||
[submodule "apps/readest-app/src-tauri/plugins/tauri-plugin-webview-upgrade"]
|
||||
path = apps/readest-app/src-tauri/plugins/tauri-plugin-webview-upgrade
|
||||
url = https://github.com/readest/tauri-plugin-webview-upgrade.git
|
||||
url = https://github.com/chrox/tauri.git
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pnpm exec lint-staged
|
||||
@@ -1,3 +0,0 @@
|
||||
pnpm -C apps/readest-app format:check
|
||||
pnpm -C apps/readest-app lint
|
||||
pnpm -C apps/readest-app test
|
||||
@@ -0,0 +1 @@
|
||||
packages/foliate-js/
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-vscode.vscode-typescript-next",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"biomejs.biome",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
Vendored
+1
-26
@@ -4,29 +4,4 @@
|
||||
"packages/tauri/Cargo.toml",
|
||||
"apps/readest-app/src-tauri/Cargo.toml"
|
||||
],
|
||||
// "editor.formatOnSave": true, // uncomment to add format on save
|
||||
"typescript.inlayHints.parameterNames.enabled": "all",
|
||||
"typescript.inlayHints.variableTypes.enabled": true,
|
||||
"typescript.inlayHints.propertyDeclarationTypes.enabled": true,
|
||||
"typescript.inlayHints.functionLikeReturnTypes.enabled": true,
|
||||
"typescript.inlayHints.enumMemberValues.enabled": true,
|
||||
"javascript.validate.enable": false,
|
||||
"javascript.format.enable": false,
|
||||
"typescript.format.enable": false,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -3,14 +3,14 @@
|
||||
When contributing to `Readest`, whether on GitHub or in other community spaces:
|
||||
|
||||
- Be respectful, civil, and open-minded.
|
||||
- Before opening a new pull request, try searching through the [issue tracker](https://github.com/readest/readest/issues) for known issues or fixes.
|
||||
- Before opening a new pull request, try searching through the [issue tracker](https://github.com/chrox/readest/issues) for known issues or fixes.
|
||||
- If you want to make code changes based on your personal opinion(s), make sure you open an issue first describing the changes you want to make, and open a pull request only when your suggestions get approved by maintainers.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
### Prerequisites
|
||||
|
||||
In order to not waste your time implementing a change that has already been declined, or is generally not needed, start by [opening an issue](https://github.com/readest/readest/issues/new/choose) describing the problem you would like to solve.
|
||||
In order to not waste your time implementing a change that has already been declined, or is generally not needed, start by [opening an issue](https://github.com/chrox/readest/issues/new/choose) describing the problem you would like to solve.
|
||||
|
||||
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
|
||||
|
||||
@@ -33,7 +33,7 @@ To get started with Readest, follow these steps to clone and build the project.
|
||||
#### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/readest/readest.git
|
||||
git clone https://github.com/chrox/readest.git
|
||||
cd readest
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
@@ -43,8 +43,8 @@ git submodule update --init --recursive
|
||||
```bash
|
||||
# might need to rerun this when code is updated
|
||||
pnpm install
|
||||
# copy vendors dist libs to public directory
|
||||
pnpm --filter @readest/readest-app setup-vendors
|
||||
# copy pdfjs-dist to Next.js public directory
|
||||
pnpm --filter @readest/readest-app setup-pdfjs
|
||||
```
|
||||
|
||||
#### 3. Verify Dependencies Installation
|
||||
@@ -75,7 +75,7 @@ Now you're all setup and can start implementing your changes.
|
||||
|
||||
### Implement your changes
|
||||
|
||||
This project is a monorepo. The code for the `readest-app` is in the `apps/readest-app` directory. Here are some useful scripts for developing the frontend only without compiling Tauri:
|
||||
This project is a monorepo. The code for the `readest-app` is in the `app/readest-app` directory. Here are some useful scripts for developing the frontend only without compiling Tauri:
|
||||
|
||||
| Command | Description |
|
||||
| ---------------- | -------------------------------------------------- |
|
||||
@@ -86,7 +86,7 @@ Recommended Visual Studio Code plugins for development:
|
||||
|
||||
- JavaScript and TypeScript Nightly (ms-vscode.vscode-typescript-next)
|
||||
- VS Code ESLint extension (dbaeumer.vscode-eslint)
|
||||
- Biome - Code formatter and linter (biomejs.biome)
|
||||
- Prettier - Code formatter (esbenp.prettier-vscode)
|
||||
- rust-analyzer (rust-lang.rust-analyzer) (for Tauri development only)
|
||||
|
||||
### When you're done
|
||||
|
||||
Generated
-10994
File diff suppressed because it is too large
Load Diff
-42
@@ -1,42 +0,0 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"apps/readest-app/src-tauri",
|
||||
"packages/tauri/crates/tauri",
|
||||
"packages/tauri-plugins/plugins/fs"
|
||||
]
|
||||
exclude = [
|
||||
"packages/qcms"
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tracing = "0.1"
|
||||
log = "0.4"
|
||||
tauri = { version = "2", default-features = false }
|
||||
tauri-build = "2"
|
||||
tauri-plugin = "2"
|
||||
tauri-utils = "2"
|
||||
schemars = "0.8"
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
glob = "0.3"
|
||||
zbus = "5.9"
|
||||
dunce = "1"
|
||||
url = "2"
|
||||
tar = "0.4.45"
|
||||
nix = "0.20.2"
|
||||
glib = "0.20.0"
|
||||
|
||||
[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" }
|
||||
tauri-plugin-fs = { path = "packages/tauri-plugins/plugins/fs" }
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS dependencies
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@11.1.1 --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/readest-app/package.json ./apps/readest-app/
|
||||
COPY patches/ ./patches/
|
||||
COPY packages/ ./packages/
|
||||
RUN --mount=type=cache,id=pnpm,sharing=locked,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN test -f packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css \
|
||||
&& test -d packages/simplecc-wasm/dist/web \
|
||||
|| { printf '\nERROR: Required git submodules are not initialized in the source directory.\nEnsure submodules are initialized before running docker build.\nRun: git submodule update --init packages/foliate-js packages/simplecc-wasm\n\n'; exit 1; }
|
||||
RUN pnpm --filter @readest/readest-app setup-vendors
|
||||
|
||||
FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS development-stage
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@11.1.1 --activate
|
||||
WORKDIR /app
|
||||
COPY --from=dependencies /app /app
|
||||
COPY . .
|
||||
WORKDIR /app/apps/readest-app
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["pnpm", "dev-web", "-H", "0.0.0.0"]
|
||||
|
||||
FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS build
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@11.1.1 --activate
|
||||
WORKDIR /app
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG NEXT_PUBLIC_APP_PLATFORM
|
||||
ARG NEXT_PUBLIC_API_BASE_URL
|
||||
ARG NEXT_PUBLIC_OBJECT_STORAGE_TYPE
|
||||
ARG NEXT_PUBLIC_STORAGE_FIXED_QUOTA
|
||||
ARG NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA
|
||||
COPY --from=dependencies /app/node_modules /app/node_modules
|
||||
COPY --from=dependencies /app/apps/readest-app/node_modules /app/apps/readest-app/node_modules
|
||||
COPY --from=dependencies /app/apps/readest-app/public/vendor /app/apps/readest-app/public/vendor
|
||||
COPY --from=dependencies /app/packages/foliate-js/node_modules /app/packages/foliate-js/node_modules
|
||||
COPY . .
|
||||
WORKDIR /app/apps/readest-app
|
||||
# Opt into the self-contained `.next/standalone` tree for this image only;
|
||||
# next.config.mjs gates `output: 'standalone'` on BUILD_STANDALONE so other
|
||||
# web builds keep their default output.
|
||||
ENV BUILD_STANDALONE=true
|
||||
RUN pnpm build-web
|
||||
|
||||
# Production runtime ships only the standalone server, its traced node_modules,
|
||||
# and the static/public assets — no pnpm, no source tree, no dev dependencies,
|
||||
# no build cache. `output: 'standalone'` (next.config.mjs) emits the self-contained
|
||||
# tree under .next/standalone, so the entrypoint is a plain `node server.js`.
|
||||
FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS production-stage
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
WORKDIR /app
|
||||
# Monorepo-rooted standalone tree: server.js + hoisted, traced node_modules.
|
||||
COPY --from=build --chown=node:node /app/apps/readest-app/.next/standalone ./
|
||||
# Static and public assets are not part of the standalone trace; copy them next
|
||||
# to the server so their default relative paths resolve.
|
||||
COPY --from=build --chown=node:node /app/apps/readest-app/.next/static ./apps/readest-app/.next/static
|
||||
COPY --from=build --chown=node:node /app/apps/readest-app/public ./apps/readest-app/public
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["node", "apps/readest-app/server.js"]
|
||||
@@ -5,22 +5,17 @@
|
||||
<h1>Readest</h1>
|
||||
<br>
|
||||
|
||||
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 16](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to deliver a smooth, cross-platform experience across macOS, Windows, Linux, Android, iOS, and the Web.
|
||||
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 15](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to offer a seamless cross-platform experience on macOS, Windows, Linux and Web, with support for mobile platforms coming soon.
|
||||
|
||||
[![Website][badge-website]][link-website]
|
||||
[![Web App][badge-web-app]][link-web-readest]
|
||||
[![OS][badge-platforms]][link-website]
|
||||
[![][badge-discord]][link-discord]
|
||||
<br>
|
||||
[![Discord][badge-discord]][link-discord]
|
||||
[![Reddit][badge-reddit]][link-reddit]
|
||||
[![AGPL Licence][badge-license]](LICENSE)
|
||||
[![Language Coverage][badge-language-coverage]][link-locales]
|
||||
[![Donate][badge-donate]][link-donate]
|
||||
[![Latest release][badge-release]][link-gh-releases]
|
||||
[![Last commit][badge-last-commit]][link-gh-commits]
|
||||
[![Commits][badge-commit-activity]][link-gh-pulse]
|
||||
[![][badge-hellogithub]][link-hellogithub]
|
||||
[![Ask DeepWiki][badge-deepwiki]][link-deepwiki]
|
||||
|
||||
</div>
|
||||
|
||||
@@ -29,16 +24,14 @@
|
||||
<a href="#planned-features">Planned Features</a> •
|
||||
<a href="#screenshots">Screenshots</a> •
|
||||
<a href="#downloads">Downloads</a> •
|
||||
<a href="#documentation">Documentation</a> •
|
||||
<a href="#getting-started">Getting Started</a> •
|
||||
<a href="#troubleshooting">Troubleshooting</a> •
|
||||
<a href="#support">Support</a> •
|
||||
<a href="#contributors">Contributors</a> •
|
||||
<a href="#license">License</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://readest.com" target="_blank">
|
||||
<img src="./data/screenshots/landing_all_platforms.png" alt="Readest Banner" width="100%" />
|
||||
<img src="./data/screenshots/landing_preview.png" alt="Readest Banner" width="100%" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -46,38 +39,38 @@
|
||||
|
||||
<div align="left">✅ Implemented</div>
|
||||
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, TXT, PDF | ✅ |
|
||||
| **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 and use instant mode for quicker interactions. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
|
||||
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
|
||||
| **OPDS/Calibre Integration** | Integrate OPDS/Calibre to access online libraries and catalogs. | ✅ |
|
||||
| **Translate with DeepL and Yandex** | From a single sentence to the entire book—translate instantly. | ✅ |
|
||||
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
|
||||
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
|
||||
| [**Sync with Koreader**][link-kosync-wiki] | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | ✅ |
|
||||
| **Accessibility** | Provides full keyboard navigation and supports for screen readers such as VoiceOver, TalkBack, NVDA, and Orca. | ✅ |
|
||||
| **Visual & Focus Aids** | Reading ruler, paragraph-by-paragraph reading mode, and speed reading features. | ✅ |
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, 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. | ✅ |
|
||||
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **Translate with DeepL** | Translate selected text instantly using DeepL for accurate translations. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **Sync across Platforms** | Synchronize reading progress, notes, and bookmarks across all supported platforms. | ✅ |
|
||||
|
||||
## Planned Features
|
||||
|
||||
<div align="left">🛠 Building</div>
|
||||
<div align="left">🔄 Planned</div>
|
||||
|
||||
| **Feature** | **Description** | **Priority** |
|
||||
| ------------------------------- | -------------------------------------------------------------------------- | ------------ |
|
||||
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
|
||||
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🛠 |
|
||||
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
|
||||
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
|
||||
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
|
||||
| **Feature** | **Description** | **Priority** |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
|
||||
| **Support iOS and Android** | Expand the APP to work on iOS and Android devices. | 🛠 |
|
||||
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | 🛠 |
|
||||
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
|
||||
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🔄 |
|
||||
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
|
||||
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
|
||||
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
|
||||
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
|
||||
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
|
||||
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🔄 |
|
||||
|
||||
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
|
||||
|
||||
@@ -85,41 +78,24 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## Downloads
|
||||
|
||||
### Mobile Apps
|
||||
The Readest app is available for download! 🥳 🚀
|
||||
|
||||
<div align="center">
|
||||
<a href="https://apps.apple.com/app/id6738622779">
|
||||
<img alt="Download on the App Store" src="https://developer.apple.com/assets/elements/badges/download-on-the-app-store.svg" style="height: 50px;" /></a>
|
||||
<a href="https://play.google.com/store/apps/details?id=com.bilingify.readest">
|
||||
<img alt="Get it on Google Play" src="https://upload.wikimedia.org/wikipedia/commons/7/78/Google_Play_Store_badge_EN.svg" style="height: 50px;" /></a>
|
||||
</div>
|
||||
|
||||
### Platform-Specific Downloads
|
||||
|
||||
- macOS / iOS / iPadOS : Search and install **Readest** on the [App Store][link-appstore], _also_ available on TestFlight for beta test (send your Apple ID to <readestapp@gmail.com> to request access).
|
||||
- Windows / Linux / Android: Visit and download **Readest** at [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
|
||||
- Linux users can also install [Readest on Flathub][link-flathub].
|
||||
- Web: Visit and use **Readest for Web** at [https://web.readest.com][link-web-readest].
|
||||
|
||||
## Documentation
|
||||
|
||||
Guides, tutorials, and FAQs for installing and using Readest live in the official documentation:
|
||||
|
||||
📖 **[https://readest.com/docs][link-docs]**
|
||||
- macOS : Search for "Readest" on the [macOS App Store][link-macos-appstore].
|
||||
- Windows / Linux: Visit [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
|
||||
- Web: Visit [https://web.readest.com][link-web-readest].
|
||||
- iOS / Android: coming soon 👀
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -129,8 +105,8 @@ Guides, tutorials, and FAQs for installing and using Readest live in the officia
|
||||
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
|
||||
|
||||
```bash
|
||||
nvm install v24
|
||||
nvm use v24
|
||||
nvm install v22
|
||||
nvm use v22
|
||||
npm install -g pnpm
|
||||
rustup update
|
||||
```
|
||||
@@ -144,16 +120,16 @@ To get started with Readest, follow these steps to clone and build the project.
|
||||
```bash
|
||||
git clone https://github.com/readest/readest.git
|
||||
cd readest
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
# might need to rerun this when code is updated
|
||||
git submodule update --init --recursive
|
||||
pnpm install
|
||||
# copy vendors dist libs to public directory
|
||||
pnpm --filter @readest/readest-app setup-vendors
|
||||
# copy pdfjs-dist to Next.js public directory
|
||||
pnpm --filter @readest/readest-app setup-pdfjs
|
||||
```
|
||||
|
||||
### 3. Verify Dependencies Installation
|
||||
@@ -171,161 +147,45 @@ For Windows targets, “Build Tools for Visual Studio 2022” (or a higher editi
|
||||
### 4. Build for Development
|
||||
|
||||
```bash
|
||||
# Start development for the Tauri app
|
||||
pnpm tauri dev
|
||||
# or start development for the Web app
|
||||
pnpm dev-web
|
||||
# preview with OpenNext build for the Web app
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
For Android:
|
||||
|
||||
```bash
|
||||
# Initialize the Android environment (run once)
|
||||
rm apps/readest-app/src-tauri/gen/android
|
||||
pnpm tauri android init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
git checkout apps/readest-app/src-tauri/gen/android
|
||||
|
||||
pnpm tauri android dev
|
||||
# or if you want to dev on a real device
|
||||
pnpm tauri android dev --host
|
||||
```
|
||||
|
||||
For iOS:
|
||||
|
||||
```bash
|
||||
# Set up the iOS environment (run once)
|
||||
pnpm tauri ios init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
|
||||
pnpm tauri ios dev
|
||||
# or if you want to dev on a real device
|
||||
pnpm tauri ios dev --host
|
||||
```
|
||||
|
||||
### 5. Build for Production
|
||||
|
||||
```bash
|
||||
pnpm tauri build
|
||||
pnpm tauri android build
|
||||
pnpm tauri ios build
|
||||
```
|
||||
|
||||
Please refer to our release script if you experience any issues:
|
||||
https://github.com/readest/readest/blob/main/.github/workflows/release.yml
|
||||
|
||||
### 6. Setup dev environment with Nix
|
||||
|
||||
If you have Nix installed, you can leverage flake to enter a development shell
|
||||
with all the necessary dependencies:
|
||||
|
||||
```bash
|
||||
nix develop ./ops # enter a dev shell for the web app
|
||||
nix develop ./ops#ios # enter a dev shell for the ios app
|
||||
nix develop ./ops#android # enter a dev shell for the android app
|
||||
```
|
||||
|
||||
### 7. More information
|
||||
### 6. More information
|
||||
|
||||
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.
|
||||
|
||||
### 2. AppImage Launches but Only Shows a Taskbar Icon
|
||||
|
||||
On some Arch Linux systems—especially those using Wayland—the Readest AppImage may briefly show an icon in the taskbar and then exit without opening a window.
|
||||
|
||||
You might see logs such as:
|
||||
|
||||
```
|
||||
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
|
||||
```
|
||||
|
||||
This behavior is usually caused by compatibility issues between the bundled AppImage libraries and the system’s EGL / Wayland environment.
|
||||
|
||||
**Workaround 1: Launch with LD_PRELOAD (recommended)**
|
||||
|
||||
You can preload the system Wayland client library before launching the AppImage:
|
||||
|
||||
```
|
||||
LD_PRELOAD=/usr/lib/libwayland-client.so /path/to/Readest.AppImage
|
||||
```
|
||||
|
||||
This workaround has been confirmed to resolve the issue on affected systems.
|
||||
|
||||
**Workaround 2: Use the Flatpak Version**
|
||||
|
||||
If you prefer a more reliable out-of-the-box experience on Arch Linux, consider using the [Flatpak build on Flathub][link-flathub] instead. The Flatpak runtime helps avoid system library mismatches and tends to behave more consistently across different Wayland and X11 setups.
|
||||
|
||||
## 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.
|
||||
|
||||
<a href="https://github.com/readest/readest/graphs/contributors">
|
||||
<p align="left">
|
||||
<img width="500" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
|
||||
<img width="200" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
|
||||
</p>
|
||||
</a>
|
||||
|
||||
## Support
|
||||
|
||||
If Readest has been useful to you, consider supporting its development at [donate.readest.com](https://donate.readest.com), where you'll find all available donation methods, including GitHub Sponsors, card payments, and crypto. Your contribution helps us fix bugs faster, improve performance, and keep building great features.
|
||||
|
||||
## License
|
||||
|
||||
Readest is free software: you can redistribute it and/or modify it under the terms of the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
The following libraries and frameworks are used in this software:
|
||||
The following JavaScript libraries are bundled in this software:
|
||||
|
||||
- [foliate-js](https://github.com/johnfactotum/foliate-js), which is MIT licensed.
|
||||
- [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.
|
||||
- [marked](https://github.com/markedjs/marked), which is MIT licensed.
|
||||
- [next.js](https://github.com/vercel/next.js), 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:
|
||||
|
||||
[Bitter](https://fonts.google.com/specimen/Bitter), [Fira Code](https://fonts.google.com/specimen/Fira+Code), [Inter](https://fonts.google.com/specimen/Inter), [Literata](https://fonts.google.com/specimen/Literata), [Merriweather](https://fonts.google.com/specimen/Merriweather), [Noto Sans](https://fonts.google.com/specimen/Noto+Sans), [Roboto](https://fonts.google.com/specimen/Roboto), [LXGW WenKai](https://github.com/lxgw/LxgwWenKai), [MiSans](https://hyperos.mi.com/font/en/), [Source Han](https://github.com/adobe-fonts/source-han-sans/), [WenQuanYi Micro Hei](http://wenq.org/wqy2/)
|
||||
|
||||
We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.netlify.app) for offering open-source tools that enable the use of Chinese fonts on the web.
|
||||
|
||||
---
|
||||
|
||||
@@ -333,23 +193,15 @@ We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.ne
|
||||
|
||||
[badge-website]: https://img.shields.io/badge/website-readest.com-orange
|
||||
[badge-web-app]: https://img.shields.io/badge/read%20online-web.readest.com-orange
|
||||
[badge-license]: https://img.shields.io/badge/license-AGPL--3.0-teal
|
||||
[badge-release]: https://img.shields.io/github/v/release/readest/readest?color=green
|
||||
[badge-platforms]: https://img.shields.io/badge/platforms-macOS%2C%20Windows%2C%20Linux%2C%20Android%2C%20iOS%2C%20Web%2C%20PWA-green
|
||||
[badge-last-commit]: https://img.shields.io/github/last-commit/readest/readest?color=blue
|
||||
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest?color=blue
|
||||
[badge-license]: https://img.shields.io/github/license/readest/readest?color=teal
|
||||
[badge-release]: https://img.shields.io/github/release/readest/readest?color=green
|
||||
[badge-platforms]: https://img.shields.io/badge/OS-macOS%2C%20Windows%2C%20Linux%2C%20Web-green
|
||||
[badge-last-commit]: https://img.shields.io/github/last-commit/readest/readest?color=green
|
||||
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest
|
||||
[badge-discord]: https://img.shields.io/discord/1314226120886976544?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square
|
||||
[badge-hellogithub]: https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=8a5b6ade2aee461a8bd94e59200682a7&claim_uid=eRLUbPOy2qZtDgw&theme=small
|
||||
[badge-donate]: https://donate.readest.com/badge.svg
|
||||
[badge-deepwiki]: https://deepwiki.com/badge.svg
|
||||
[badge-reddit]: https://img.shields.io/reddit/subreddit-subscribers/readest?style=flat&logo=reddit&color=F37E41
|
||||
[badge-language-coverage]: https://img.shields.io/badge/coverage-53%25%20population%20🌍-green
|
||||
[link-donate]: https://donate.readest.com/?tickers=btc%2Ceth%2Csol%2Cusdc
|
||||
[link-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
|
||||
[link-macos-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
|
||||
[link-website]: https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme
|
||||
[link-flathub]: https://flathub.org/en/apps/com.bilingify.readest
|
||||
[link-web-readest]: https://web.readest.com
|
||||
[link-docs]: https://readest.com/docs
|
||||
[link-gh-releases]: https://github.com/readest/readest/releases
|
||||
[link-gh-commits]: https://github.com/readest/readest/commits/main
|
||||
[link-gh-pulse]: https://github.com/readest/readest/pulse
|
||||
@@ -357,8 +209,3 @@ We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.ne
|
||||
[link-discord]: https://discord.gg/gntyVNk3BJ
|
||||
[link-parallel-read]: https://readest.com/#parallel-read
|
||||
[link-koreader]: https://github.com/koreader/koreader
|
||||
[link-hellogithub]: https://hellogithub.com/repository/8a5b6ade2aee461a8bd94e59200682a7
|
||||
[link-deepwiki]: https://deepwiki.com/readest/readest
|
||||
[link-locales]: https://github.com/readest/readest/tree/main/apps/readest-app/public/locales
|
||||
[link-kosync-wiki]: https://github.com/readest/readest/wiki/Sync-with-Koreader-devices
|
||||
[link-reddit]: https://reddit.com/r/readest/
|
||||
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Threat Model
|
||||
|
||||
### Overview
|
||||
|
||||
Readest is a cross-platform e-reader (macOS, Windows, Linux, Android, iOS, Web) built on Next.js and Tauri. It processes user-supplied ebook files, syncs data to the cloud, integrates with external services (OPDS catalogs, KOReader, DeepL, Yandex), and handles user authentication.
|
||||
|
||||
### Assets
|
||||
|
||||
| Asset | Description |
|
||||
| ------------------------------ | ------------------------------------------------------------------------------------ |
|
||||
| Ebook files | User-uploaded EPUB, MOBI, PDF, and other formats stored locally and in cloud storage |
|
||||
| Reading progress & annotations | Highlights, bookmarks, and notes synced across devices |
|
||||
| User credentials | Authentication tokens and session data for cloud sync |
|
||||
| User preferences & settings | Reading preferences, custom fonts, theme configurations |
|
||||
| External API keys | Translation service credentials (DeepL, Yandex) configured by users |
|
||||
|
||||
### Threat Actors
|
||||
|
||||
| Actor | Motivation |
|
||||
| ----------------------- | ---------------------------------------------------------- |
|
||||
| Malicious ebook author | Craft a malformed file to exploit the parser or renderer |
|
||||
| Network attacker (MitM) | Intercept sync traffic to steal credentials or inject data |
|
||||
| Malicious OPDS server | Serve crafted catalog responses to exploit the client |
|
||||
| Compromised dependency | Supply chain attack via npm or Cargo ecosystem |
|
||||
| Unauthorized user | Access another user's synced library or annotations |
|
||||
|
||||
### Attack Surfaces & Mitigations
|
||||
|
||||
#### 1. Ebook File Parsing
|
||||
|
||||
- **Risk:** Malformed EPUB/MOBI/PDF files could trigger parser bugs, path traversal, or script injection via embedded HTML/JS.
|
||||
- **Mitigations:** Ebook content is rendered in a sandboxed iframe. External script execution is blocked. File parsing is isolated from the main process.
|
||||
|
||||
#### 2. Cloud Sync & Authentication
|
||||
|
||||
- **Risk:** Credential theft, session hijacking, or unauthorized access to another user's library data.
|
||||
- **Mitigations:** All sync traffic uses HTTPS/TLS. Authentication tokens are stored securely (OS keychain/secure storage). Server-side authorization ensures users can only access their own data.
|
||||
|
||||
#### 3. OPDS / External Catalog Integration
|
||||
|
||||
- **Risk:** A malicious OPDS server could serve crafted XML to exploit the parser, or redirect downloads to malicious files.
|
||||
- **Mitigations:** OPDS responses are parsed defensively. Users explicitly add catalog sources. Downloaded files are treated as untrusted user content.
|
||||
|
||||
#### 4. Rendered HTML/JS in Ebook Content
|
||||
|
||||
- **Risk:** Embedded JavaScript in EPUB files could attempt XSS or data exfiltration.
|
||||
- **Mitigations:** Book content is rendered in a sandboxed iframe with scripting restrictions. Navigation outside the book context is blocked.
|
||||
|
||||
#### 5. Supply Chain
|
||||
|
||||
- **Risk:** Compromised npm or Cargo packages could introduce malicious code.
|
||||
- **Mitigations:** Dependencies are pinned via `pnpm-lock.yaml` and `Cargo.lock`. Dependabot and GitHub's dependency review are enabled for automated vulnerability detection.
|
||||
|
||||
#### 6. Desktop Native Code (Tauri)
|
||||
|
||||
- **Risk:** Tauri IPC commands could be abused by malicious web content to access the filesystem or OS APIs.
|
||||
- **Mitigations:** Tauri's allowlist restricts which IPC commands are exposed. File system access is scoped to the application data directory.
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Vulnerabilities in user's operating system or browser outside of Readest's control
|
||||
- Physical access attacks to a user's device
|
||||
- Issues in third-party services (DeepL, Yandex, Calibre) themselves
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Readest does not currently maintain separate release channels. Security updates are provided only for the latest release series.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.10.x | :white_check_mark: |
|
||||
| < 0.10 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report suspected vulnerabilities privately. Do not open a public GitHub
|
||||
issue or discussion for security-sensitive reports.
|
||||
|
||||
Use GitHub's private vulnerability reporting for this repository:
|
||||
|
||||
<https://github.com/readest/readest/security/advisories/new>
|
||||
|
||||
When submitting a report, include:
|
||||
|
||||
- A clear description of the issue and the affected component
|
||||
- Steps to reproduce, proof of concept, or a minimal test case
|
||||
- The versions, platforms, or environments you tested
|
||||
- Any suggested remediation or mitigating details, if available
|
||||
|
||||
What to expect after you report:
|
||||
|
||||
- We will aim to acknowledge receipt within 3 business days.
|
||||
- We may contact you for additional details, reproduction steps, or validation.
|
||||
- If the report is accepted, we will work on a fix and coordinate disclosure.
|
||||
- If the report is declined, we will explain why, for example if the behavior is
|
||||
expected, unsupported, or not reproducible.
|
||||
|
||||
Please keep vulnerability details private until a fix is available and the
|
||||
maintainers have approved disclosure.
|
||||
|
||||
## Incident Response Plan
|
||||
|
||||
When a security vulnerability is confirmed, we follow this process:
|
||||
|
||||
### 1. Triage (Day 1–2)
|
||||
|
||||
- Assign a severity level (Critical / High / Medium / Low) based on impact and exploitability.
|
||||
- Identify affected versions, components, and users.
|
||||
- Assign an owner responsible for coordinating the response.
|
||||
|
||||
### 2. Containment (Day 1–3)
|
||||
|
||||
- Assess whether an immediate mitigation or workaround can be published.
|
||||
- Limit further exposure where possible (e.g., disable affected features, update dependencies).
|
||||
|
||||
### 3. Remediation (Day 3–14, depending on severity)
|
||||
|
||||
- Develop and internally review a fix.
|
||||
- Validate the fix does not introduce regressions.
|
||||
- Prepare a patched release and update changelog.
|
||||
|
||||
### 4. Disclosure & Release
|
||||
|
||||
- Coordinate disclosure timing with the reporter.
|
||||
- Publish a GitHub Security Advisory with CVE if applicable.
|
||||
- Release the patched version and notify users via release notes.
|
||||
|
||||
### 5. Post-Incident Review
|
||||
|
||||
- Document the root cause, timeline, and resolution.
|
||||
- Update processes or controls to prevent recurrence.
|
||||
|
||||
### Severity Definitions
|
||||
|
||||
| Severity | Description |
|
||||
| -------- | --------------------------------------------------------------------- |
|
||||
| Critical | Remote code execution, full data compromise, or authentication bypass |
|
||||
| High | Significant data exposure, privilege escalation, or denial of service |
|
||||
| Medium | Limited data exposure or functionality disruption |
|
||||
| Low | Minor issues with minimal security impact |
|
||||
@@ -1 +0,0 @@
|
||||
../.claude/memory
|
||||
@@ -1 +0,0 @@
|
||||
../.claude/plans
|
||||
@@ -1 +0,0 @@
|
||||
../.claude/rules
|
||||
@@ -1,150 +0,0 @@
|
||||
# Readest Project Memory
|
||||
|
||||
## Key Reference Documents
|
||||
- [Bug Fixing Patterns](bug-patterns.md) - Common bug categories, root causes, and fix strategies
|
||||
- [CSS & Style Fixes](css-style-fixes.md) - EPUB CSS override patterns and the style.ts pipeline
|
||||
- [TTS Fixes](tts-fixes.md) - Text-to-Speech architecture and bug patterns
|
||||
- [Layout & UI Fixes](layout-ui-fixes.md) - Safe insets, z-index, platform-specific UI issues
|
||||
- [Platform Compat Fixes](platform-compat-fixes.md) - Android, iOS, Linux, macOS platform-specific bugs
|
||||
- [Annotator & Reader Fixes](annotator-reader-fixes.md) - Highlight, selection, accessibility bugs
|
||||
|
||||
## Security
|
||||
- [download_file scope Android regression](download-file-scope-android-regression.md) — #4639 strict `is_allowed` broke ALL Android downloads to app data dir (covers/dicts/books); `app.fs_scope()` lacks command-scoped capability globs; fix = `app.path()` base-dir membership. On-device CDP verify recipe + raw-invoke Channel trick
|
||||
- [Security advisories 2026-06](security-advisories-web-2026-06.md) — all 4 GHSA fixed in PR #4638 (web: A OPDS-proxy SSRF + canonical `isBlockedHost` in network.ts + `isLanAddress` merge, B storage `isSafeObjectKeyName`, D Stripe `metadata.userId` ownership) + PR #4639 (native C: `transfer_file.rs` fs_scope guard). OPDS proxy can't require auth (`<img>` usage); strict `is_allowed` for C; shared-target worktree build-cache pollution gotcha
|
||||
|
||||
## Paginator Scroll Knowledge
|
||||
- [Issue #4112 scroll-anchoring](issue-4112-scroll-anchoring.md) — RESOLVED (PR #4349). Scroll-anchoring suppressed at scrollTop 0 when prepending a section in scrolled mode; fix patterns (prepend compensation, eager backward preload, no-blank nav) + test & dev-server gotchas
|
||||
- [Reading ruler line/column-aware](reading-ruler-line-aware.md) — ruler snaps to real lines; multi-column band spans one column; Range.getClientRects() returns tall block boxes that must be dropped; iframe frame-offset mapping; synthetic-key throttling
|
||||
- [TOC expand + auto-scroll](toc-expand-and-autoscroll.md) — #4059 collapse-by-default policy in `tocTree.ts`; pinned-sidebar mounts before progress → dynamic expansion breaks scroll-to-current via (1) spurious onScroll clearing pending and (2) Virtuoso scrollToIndex landing short after row growth (re-assert on rAF)
|
||||
- [BooknoteView auto-scroll (#4352)](booknote-view-autoscroll-4352.md) — virtualizing the annotation/bookmark list dropped auto-scroll-to-nearest; two paths (reload: OverlayScrollbars resets scrollTop → re-apply in `initialized` via ref; tab-switch: synchronous scrollToIndex on fresh-mounted list wedges Virtuoso → use `initialTopMostItemIndex` + skip-gate). Mirrors TOCView. Includes dev-server/Fast-Refresh/screenshot-vs-DOM verification gotchas
|
||||
- [TOC current-position row](toc-current-position-row.md) — synthetic "Current position" row (open-book icon + live `progress.page`) injected one level deeper under the active TOC item via `buildTOCDisplayItems` in `TOCItem.tsx`. INVARIANT: insert AFTER the active item so its `flatItems` index stays valid for the auto-scroll effects
|
||||
- [Swipe page-turn bg flash](paginator-swipe-bg-flash.md) — white↔black flash on swipe+animation only; `#background` was static screen-space and didn't track content during drag/snap; fix = sliding per-view full-bleed segments (`computeBackgroundSegments`) rebuilt on scroll + per-rAF synced to the view transform during snap
|
||||
- [Duokan fullscreen cover hidden in scroll mode](duokan-fullscreen-cover-scroll.md) — #4379 `data-duokan-page-fullscreen` cover pinned `position:absolute height:100%` collapses against auto-height scroll container; gate fullscreen on `this.#column` + reset stale absolute props on toggle (`setImageSize` in paginator.js)
|
||||
- [Paginated texture occlusion](paginated-texture-occlusion-4399.md) — #4399 host `.foliate-viewer::before` texture absent in paginated (shown in scrolled); opaque `#background` container (`= fallbackBg`) from the swipe-flash fix occludes it; shared `textureAwareBackground` helper + `hasTexture ? '' : fallbackBg` container
|
||||
- [Dark-mode texture occluded by body bg (#4446)](dark-mode-texture-body-bg-4446.md) — RESOLVED (PR #4564): `body.theme-dark{bg !important}` from #4392 (v0.11.4, NOT foliate-js) painted iframe bodies opaque dark → occluded host texture + poisoned `docBackground` capture → opaque segments/view bgs; fix = `transparent !important` UNCONDITIONALLY (texture-gating would go stale: capture is once-per-section-load); CDP gotchas = patch ALL multiview iframes, stale preload views survive navigation ±2, load-listener sees exact capture-time state
|
||||
- [Background overflows column (#4394, PR #4429)](paginator-gutter-bleed-asymmetry-4394.md) — paginated page bg stretched into the outer `--_outer-min` gutter → mixed cover/title 2-up spread shifted off-centre (~250px at 1920px). KEEP the grid (`--_outer-min` keeps margins symmetric); fix = clamp `computeBackgroundSegments` to `[containerStart,containerEnd]` (Math.max/Math.min) so bg stays in its column. 2 wrong tries first (bleed-gating, "page shouldn't be yellow"); foliate submodule needs dev-server RESTART to pick up edits
|
||||
- [Inline-block column overflow](inline-block-column-overflow.md) — chapter skips to "Reference materials", clipping a large middle; EPUB wraps body in `display:inline-block` div → atomic-inline box can't fragment across columns → vertical overflow clipped (scrollHeight≫clientHeight). Fix = paginator `#demoteUnfragmentableBoxes` in `columnize` (col-mode, over-tall atomic-inline→fragmentable block). Renders via goTo/next but pages unreachable; scrolled mode unaffected
|
||||
|
||||
## Critical Files (Most Bug-Prone)
|
||||
- `src/utils/style.ts` - Central EPUB CSS transformation hub (14+ bug fixes)
|
||||
- `packages/foliate-js/paginator.js` - Page layout, image sizing, backgrounds
|
||||
- `src/services/tts/TTSController.ts` - TTS state machine, section tracking
|
||||
- `src/hooks/useSafeAreaInsets.ts` - Safe area inset management
|
||||
- `src/app/reader/components/FoliateViewer.tsx` - Reader view orchestration
|
||||
- `src/app/reader/components/annotator/Annotator.tsx` - Annotation lifecycle
|
||||
|
||||
## Sync Notes
|
||||
- [KOSync CFI spine resolution](kosync-cfi-spine-resolution.md) — convert via the CFI's own spine (`getXPointerFromCFI`/`getCFIFromXPointer`), never `new XCFI(primaryDoc, primaryIndex)`; primaryIndex lags during scroll → spine-mismatch throw
|
||||
- [Empty-start CFI sync bug](empty-start-cfi-sync.md) — `epubcfi(/6/24!/4,,/20/1:58)` (empty-start range) from the cfi-inert skip-link transitional window; jumps to wrong section end; `isMalformedLocationCfi` → discard the synced value in `useProgressSync` (NOT the local open path); foliate fix doesn't repair already-synced values
|
||||
- [Custom fonts disappear on cloud sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins: re-import-after-delete needs a `reincarnation` token or the pull re-applies the tombstone; `addFont`/`addTexture` minted none; fix mirrors dictionary (both cases) + OPDS token style; coverage matrix per kind
|
||||
- [koplugin note deletion sync](koplugin-note-deletion-sync.md) — koplugin push only walked LIVE annotations so deletions never reached the server; fix = `recordDeletion` persists a `deletedAt` tombstone to `doc_settings.readest_sync.deleted_notes`, `push` folds+clears them; deletion signal in `onAnnotationsModified` is `items.index_modified < 0`
|
||||
- [koplugin stats sync (#4666)](koplugin-stats-sync.md) — reading-stats sync (pull on open / push on close, whole statistics.sqlite3 delta, cursor-based); 3-bug chain: plain-table-not-LuaSettings `settings:readSetting` crash; missing required books/notes/configs; statBooks/statPages need `optional_params` (Spore expected=required∪optional, `payload`≠accepted); large-backlog UI-stall + silent-retry risk unfixed
|
||||
- [Statusless books re-pinned to top (#4677)](sync-statusless-book-rebump-4677.md) — never-statused (locally-imported, never-pulled) books send `reading_status:undefined` vs server `null`; server POST `statusChanged` (`undefined!==null`) rewrites `updated_at=now()` every push → batch-identical ts pins them top of date-sort; 1-day re-sync window amplifies; fix = `(a??null)!==(b??null)`. On-device CDP PUSH_SENT-vs-RETURNED proof recipe
|
||||
|
||||
## Testing
|
||||
- [Nightly updater Android E2E](nightly-updater-android-e2e.md) — real Xiaomi/HyperOS test of #4577 self-updater; `pnpm dev-android` (--features devtools) for CDP, raw-socket CDP discovery, nightly>stable comparator, MIUI 单次安装授权 install gates
|
||||
- [Android CDP e2e lane](android-cdp-e2e-lane.md) — `pnpm test:android`: adb+CDP drives the installed app on device/emulator; discover-don't-assume targeting, injected hyphenation, MediaStore VIEW transient open (canonical `_data` path gotcha), per-section frame restore; CI workflow with KVM emulator + debug x86_64 APK (no signing secrets)
|
||||
- [CDP Android WebView profiling](cdp-android-webview-profiling.md) — drive the on-device Readest WebView via adb+CDP to run JS probes/benchmarks inside the live app (no rebuild); gotchas: locked device freezes fetch (not invoke), visible:false throttles setTimeout, `__TAURI_INTERNALS__.convertFileSrc/invoke` always present, books in internal `/data/user/0/...`, fs `read{rid,len}` last-8-bytes=nread, `fs|close` not ACL-allowed, curl mishandles WebView HTTP framing
|
||||
- [Tauri Rust↔JS parser parity tests](tauri-parser-parity-tests.md) — #4369 native Rust EPUB/MOBI parser; how to cross-check vs foliate-js in the `.tauri.test.ts` WebView suite (CWD disk path for Rust, Vite URL for JS, normalizer-based compare, cover presence-only, desc whitespace-collapse); the `dcterms:modified`→`published` divergence fix
|
||||
- [TTS browser e2e harness](tts-browser-e2e-harness.md) — faithful auto-advance test (real `<foliate-view>` + real `useTTSControl` + mock ONLY the 3 client modules; mock `speak()` yields `end` to drive the real `forward()` walk); seed readerStore/bookDataStore + `settings.globalViewSettings` (else `getMergedRules` crash stops TTS); reproduce FoliateViewer relocate→setProgress; sample-alice Ch4=section 6/Ch5=section 7; assert badge `false` BEFORE tts-stop
|
||||
- [TTS sync chrome verification](tts-sync-chrome-verification.md) — Edge TTS WORKS in claude-in-chrome (WebSpeech errors there with `InvalidStateError`); use an Edge voice to verify TTS-driven features live (RSVP followed at ~171 wpm). Synthetic-CFI debug recipe (expose controller, `syncToCfi(view.getCFI(docIndex, word.range))`). Exposed the #3235 cross-realm `instanceof Range` bug (frozen RSVP/paragraph follow) → `isRangeLike()` duck-type fix
|
||||
- [TTS sync paragraph+RSVP (#3235, PR #4576)](tts-sync-paragraph-rsvp-3235.md) — TTS-is-clock follow: canonical `tts-position{cfi,kind:word|sentence,sectionIndex,sequence}`; in-mode 🔊 audio toggle (`build{Paragraph,Rsvp}TtsSpeakDetail`, live-range gate); **current word/sentence highlight painted on the overlay CLONE via CSS Custom Highlight API** (no DOM mutation, spans inline; offsets relative to para-start map 1:1 to clone, `getTextSubRange` reuse, index-tagged vs stale); kind-gating `decideParagraphTtsHighlight` (Edge word wins, skip coarse sentence); `::highlight()` from `ttsHighlightOptions`
|
||||
|
||||
## Build & Vendoring
|
||||
- [fastlane Apple App Store submission](fastlane-apple-appstore-submission.md) — `release_ios`/`release_macos` lanes (App Store review + TestFlight on the altool-uploaded build); gotchas = Tauri auto-notarizes if `APPLE_API_KEY_PATH` is in the macOS build env (keep it OUT, derive from key id), fastlane runs lanes from `./fastlane` so anchor paths via `repo_path`, npm-dotenv-cli vs Ruby-gem shadowing + `cd ../..`
|
||||
- [Turbopack build-cache OOM + gated Docker standalone (#4619)](turbopack-build-cache-oom-docker-standalone.md) — interrupted-build partial `turbopackFileSystemCacheForBuild` cache → 42 workers/18GB-swap freeze (clean build=~6.5GB); disabled the flag; `output:'standalone'` gated on `BUILD_STANDALONE` (Docker-only); tauri CI uses `next dev` (config-independent)
|
||||
- [Deps/security override workflow](deps-security-overrides-workflow.md) — fix transitive npm Dependabot alerts: main monorepo overrides live in `pnpm-workspace.yaml` (NOT root package.json); `packages/tauri-plugins` is a SEPARATE submodule project w/ own lockfile + `minimumReleaseAge` (main workspace has no age gate); bound 0.x overrides like `vite`; verify via test+lint+build-web. PR #4618 (esbuild 0.28.1, vitest 4.1.9)
|
||||
- [R2 rclone CreateBucket 403 (#4588)](r2-rclone-createbucket-403.md) — single-file `rclone copyto`/`moveto` probes CreateBucket → 403 on object-scoped R2 token; use a directory `rclone copy` (or `no_check_bucket=true`); broke nightly assemble, not the release flow
|
||||
- [Deploy workers.dev SNI-block + proxy](deploy-workers-dev-sni-proxy.md) — pnpm deploy crash (CN): workers.dev SNI-blocked (DoH useless), R2 populate WS hangs even via proxy; shipped fix = `dangerous.disableIncrementalCache:true` in open-next.config (stock deploy skips populate; readest has no ISR so runtime no-op)
|
||||
- [pdfjs vendor wasm decoders](pdfjs-vendor-wasm-decoders.md) — scanned PDFs blank in CI build only (0.11.2 regression); pdfjs 5.7.x moved JBIG2 to `jbig2.wasm`, `copy-pdfjs-wasm` allow-list dropped it; `cpx` no-errors on empty glob; local stale `public/vendor` (gitignored, not refreshed by `tauri build`) masked it; fix = copy `wasm/*`
|
||||
|
||||
## Platform Compat
|
||||
- [Android hyphen selection bounds (#1553)](android-hyphen-selection-bounds-1553.md) — Blink paints the start handle on the paragraph's LAST hyphen when a touch selection starts at the first word of a hyphenated paragraph (`ComputePaintingSelectionStateForCursor` lacks the generated-text offset remap, hyphen offsets {0,1}); drag-extend re-anchors base there. Fix = repair jumped anchor + suppress handles (empty-commit needs one painted frame) + `SelectionRangeEditor` custom handles; multicol NOT required; desktop/iOS unaffected
|
||||
- [Android NativeFile vs RemoteFile I/O](android-nativefile-remotefile-io.md) — why NativeFile is slow (4-IPC/chunk + bridge serialization, tauri#9190); RemoteFile CANNOT replace it on Android (asset-protocol Range broken: start>0 → "Failed to fetch", start-0 capped at 1,024,000; plain no-Range fetch returns full file at 281 MB/s); measured 44/100/281 MB/s; speedups = handle-reuse (2.3×), whole-file asset loader (6.3×), or fix wry upstream. Verified live via CDP.
|
||||
- [Window-state sanitizer (#4398)](window-state-sanitize-4398.md) — Windows launch crash (WebView2 0x80070057) from invalid `.window-state.json` (`-32000` minimized sentinel / `0×0`); our plugin already has upstream #253 fix so bad files are stale; defense-in-depth `window-state-sanitizer` plugin registered BEFORE window-state (plugin init = registration order); coord threshold `-16000` (~halfway to the -32000 sentinel; real desktops sit a few thousand px off origin) keeps multi-monitor negatives
|
||||
- [Android Open-with intent flow (#4521)](android-open-with-intent-flow.md) — "Open with"/"Send to" pipeline: `NativeBridgePlugin.kt::handleIntent` → `shared-intent` → `useAppUrlIngress` → `useOpenWithBooks` (VIEW=transient→reader, SEND=library+upload). Telegram fails where file-manager works on TWO axes: cold-start delivery (fixed by #4527, on dev NOT released v0.11.4) + foreign-private-file read (Telegram FileProvider non-persistable grant vs shared-storage FUSE real-path). adb MediaStore VIEW repro tests pipeline but CANNOT reproduce the read axis (MANAGE_EXTERNAL_STORAGE bypasses grant)
|
||||
- [Dict lookup → OEM browser hijack (#4559)](dict-lookup-browser-hijack-4559.md) — VIVO system-dict lookup opened the browser not Eudic. PRIMARY: no `<queries>` for `ACTION_PROCESS_TEXT` under targetSdk36 → dictionary apps invisible, only auto-visible browser returned (fix = add `<queries>` to plugin manifest). SECONDARY: browser registers PROCESS_TEXT + is default → filter browsers in pure `decideLookupDispatch` (explicit/chooser/unavailable). Remember-the-pick via `IntentSender`+`EXTRA_CHOSEN_COMPONENT`→`LookupChoiceReceiver`→SharedPreferences (`ACTION_CHOOSER` has no native Always); reset row in `CustomDictionaries.tsx`
|
||||
- [Android sideload same versionCode](android-sideload-same-versioncode.md) — sideloaded APK reinstall allows EQUAL versionCode (only strictly-lower blocked); Play Store's increment rule does NOT apply to sideload. Nightly APKs share base versionCode and still install. Corrects a plausible-but-wrong review claim
|
||||
- [Large-PDF OOM range flood (#3470)](pdf-oom-range-flood-3470.md) — 50MB+ PDF import/open crash = foliate makePDF firing ALL pdf.js range reads un-awaited (753 concurrent fetch→shouldInterceptRequest→Java byte[] → 512MB heap OOM), NOT whole-file load; official viewer survives via browser ~6-conn/host cap; fix = MAX_CONCURRENT_RANGES=6 queue in makePDF; on-device CDP recipe; Xiaomi13/WV147 won't OOM but flood 753→6 verified
|
||||
|
||||
## Feature Notes
|
||||
- [Save image to gallery (Android, #4680)](save-image-to-gallery-android.md) — image-viewer Save button → MediaStore on Android (share sheet can't save-to-file: ACTION_SEND has no file-manager target); sharekit 0-byte self-copy bug (Temp==cacheDir); tsgo misses abstract-class conformance (real tsc catches); on-device CDP verify recipe
|
||||
- [Webtoon Mode (#3647)](webtoon-mode-3647.md) — seamless no-gap scrolled reading for image books (PRs #4662 + foliate-js#30); fixed-layout scroll mode is fit-width by construction (ignores `zoom`, only `scale-factor`); `scroll-gap` attr→`--scroll-page-gap` var; clear-on-leave in BOTH ViewMenu effect AND Shift+J; worktree submodule has local-path origin (push SHA direct to fork)
|
||||
- [Biometric app-lock (#4645)](biometric-app-lock-4645.md) — fingerprint/Face ID startup unlock layered over PIN (mobile); gate must read flag from `appLockStore` not un-seeded `settingsStore` (race); `tauri-plugin-biometric` is `#![cfg(mobile)]` (desktop clippy skips it; pin in root Cargo.lock); scope i18n manually (en unscanned, full extract churns drift)
|
||||
- [Tap to open image/table (#4600)](tap-to-open-image-table-4600.md) — single-tap opens gallery/table-zoom in **reflowable** EPUBs (long-press unchanged); `iframe-long-press` message renamed to `iframe-open-media`, hook `useLongPressEvent`→`useOpenMediaEvent`; shared `detectMediaTarget`; `handleClick` got `isFixedLayout`
|
||||
- [#4584 tap-death investigation](issue-4584-tap-death-investigation.md) — UNFIXED; `isPopuped` self-heals (RED HERRING, don't "fix" it); likely WebView-148-specific (emulator=133 can't repro); Android emulator/CDP gesture-verification gotchas (swiftshader ANR=artifact, CDP can't native-select, screenX=0)
|
||||
- [Dictionary lemmatization (#4574)](dict-lemmatization-4574.md) — inflected selections (`ran`/`mice`/`analyses`) resolve to base headwords (`run`/`mouse`/`analysis`) in dicts that store only lemmas (ODE). Pluggable `lemmatize/` registry (default English, explicit non-English no-op), English rules+irregulars, appended to tail of `buildLookupCandidates` so exact match wins; over-generate + dict-validates; `-ses→-sis` ordered before `-es`
|
||||
- [Word Lens inline gloss (feat/word-wise)](wordlens-feature.md) — Kindle-style native-language hint above hard words; CFI-safe via `<ruby cfi-skip>…<rt cfi-inert>` (epubcfi hoist+merge, NOT just tree-walk); TTS/search isolation (tags:['rt'] + rangeTextExcludingInert + search attributes:['cfi-inert']); gloss data = curated starters, full asset built by `build-wordlens-data.mjs` (ECDICT/CC-CEDICT+HSK)
|
||||
- [iOS instant-dict double popup](ios-instant-dict-double-popup.md) — iOS emits multiple `selectionchange`/long-press → instant sys-dict fired 2-3×; deferredAction `fired` once-per-gesture latch + `beginGesture`; tap-to-deselect re-fire fixed by `isLongPressHold` 300ms gate (!isAndroid); Word Lens `wantWordLensDict` now routes via `handleDictionary` to honor system dict
|
||||
- [Edge TTS word highlighting (#4017, PR #4566)](edge-tts-word-highlighting-4017.md) — keep sentence marks, add word highlight via `audio.metadata` WordBoundary (verbatim input span, 100-ns ticks) synced to `audio.currentTime` by rAF; readaloud endpoint gates on UA (Edg, non-headless) NOT Origin; fixed browser `new WebSocket(url,{headers})` SyntaxError (wss never worked on web); overlay = `<path>` in FOLIATE-PAGINATOR shadow root; dev-web verify recipe (browse --proxy + UA spoof, never Origin header)
|
||||
- [Reference Pages (#672+#4542, PR #4549)](reference-pages-672-4542.md) — 'reference' progressStyle from foliate `pageItem`/`book.pageList` (numeric-max total rule, roman-tail safe); per-book `referencePageCount` via skipGlobal save; verification EPUBs + dev-web synthetic drag-drop import trick; locale-tail rebase-conflict recipe (checkout --ours → re-extract → re-translate)
|
||||
- [OPDS Firefox strict-XML parse (#4479)](opds-firefox-strict-xml-4479.md) — MEK feed has junk after `</feed>`; Firefox DOMParser → `<parsererror>` (silent back-nav), Chrome lenient; `parseOPDSXML` slices root start→last close tag; jsdom mirrors Firefox; wired into page.tsx + validateOPDSURL + feedChecker (latter also #4181 `looksLikeXMLContent` swap)
|
||||
- [OPDS 2.0 JSON search greyed out (#4502)](opds2-json-search-4502.md) — `isSearchLink` ignored templated `application/opds+json` links → `hasSearch` false → disabled navbar input; add `MIME.OPDS2`+`templated`, `expandOPDSSearchTemplate` (foliate `uri-template.js`), handleSearch OPDS2 branch. Gotcha: `resolveURL` mangles `{?query}` braces — expand template BEFORE resolving
|
||||
- [OPDS HTML description (#4503)](opds-html-description-4503.md) — detail-view descriptions showed raw `<p>`/`"` tags; aggregator double-escapes `type="text"` summary + `PublicationView` dumped it into unsanitized `dangerouslySetInnerHTML`; fix = `getOPDSDescriptionHtml` (decode-one-level-iff-fully-escaped, then `sanitizeHtml`)
|
||||
- [Manage Cache + iOS container layout](manage-cache-ios-layout.md) — `'Cache'` base = `Library/Caches/<bundle>` only (not all of Caches); iOS `Documents/Inbox` cleared too; WebKit cache + tmp out of reach; never touch App Support
|
||||
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / keyboard arrow navigation design, key files, and pitfalls
|
||||
- [Cloudflare Workers WebSocket](cloudflare-workers-websocket.md) — use fetch() Upgrade pattern (not `ws` npm); CF delivers binary frames as Blob (must serialize async decodes)
|
||||
- [Share-a-Book Feature (in progress)](share-feature.md) — locked decisions for the /s/{token} share-link feature; plan at ~/.claude/plans/ok-we-will-learn-cosmic-acorn.md
|
||||
- [readest.koplugin i18n](koplugin-i18n.md) — gettext loader at `apps/readest.koplugin/i18n.lua`, `.po` catalog at `locales/<i18next-code>/translation.po`, extract/apply scripts in `scripts/`
|
||||
- [koplugin cover upload](koplugin-cover-upload.md) — #4374 uploadBook only shipped cached cloud covers; local-origin books uploaded blank. Fix = `extractLocalCover` via `FileManagerBookInfo:getCoverImage(nil, file)` → `writeToFile(path,"png")`. KOReader checkout at `/Users/chrox/dev/koreader`
|
||||
|
||||
## Feedback
|
||||
- [Commit messages English-only](feedback-commit-message-english-only.md) — commit messages + PR titles must be English only (no CJK glyphs, no em/en dashes); keep CJK examples/screenshots in the PR body, code, and tests. From PR #4660
|
||||
|
||||
## Patterns
|
||||
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews
|
||||
- [Design system → DESIGN.md](feedback_design_system_doc.md) — codify recurring UI/UX rules in `apps/readest-app/DESIGN.md`; never `pl/pr/ml/mr/text-left/text-right` (RTL); §5 boxed list anatomy has uniform `min-h-14` rows and chromeless controls
|
||||
|
||||
## Reader UI Fixes
|
||||
- [RSVP control bar overlap = REVERT](rsvp-control-bar-overlap-revert.md) — mobile RSVP TTS+settings overlapping transport was a REGRESSION: #4585 fixed it (in-flow `justify-between md:justify-center`), stale-branch #4589 (Word Wise) squash-reverted the whole fix incl. its guard test; re-fixed + on-device CDP recipe (Shift+V to enter RSVP, live-DOM preview)
|
||||
- [Overlay z-index scale](zindex-overlay-scale.md) — compact global scale (RSVP 100/101, Settings 110, ModalPortal/CmdPalette 120, toast 130, app-lock 200) replacing insane 10000s; Add-Catalog-behind-Settings was MOBILE-ONLY (desktop `.window-border` z-99 traps inline Settings; ModalPortal portals to body→wins); static invariant test `zIndexScale.test.ts`; on-device CDP verify via `pnpm dev-android` devtools build
|
||||
- [Search excerpt no context for styled words (#4594)](search-excerpt-context-4594.md) — RESOLVED (foliate-js#25 + readest#4631). italic/`<i>` word = own `strs[]` text node; `makeExcerpt` read context only WITHIN the node → empty pre/post; fix = `collectBefore/After` walk neighbour nodes (+2 latent multi-node match bugs: string-index `slice`, `start===end`)
|
||||
- [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md) — highlighting recurring names = `global` highlights re-fanned-out (TreeWalker + getCFI/occurrence + SVG churn) EVERY page turn (~25-45ms desktop, ×mobile); fix = `WeakMap<Document,...>` memo in `globalAnnotations.ts` skips already-expanded sections; live-profiled via dev-web foliate-view; GBK-TXT synthetic-drop import recipe
|
||||
- [Overlayer splitRange by text nodes](overlayer-splitrange-textnodes.md) — highlight SVG missed bullet-list text when range also touched a `<p>`: `#splitRangeByParagraph`'s `'p,h1-h4'` selector dropped `li` (3rd whack-a-mole after f087826/920676b); fix = walk text nodes + `img,svg` in overlayer.js, never block-tag selectors; jsdom test stubs `Range.prototype.getClientRects`
|
||||
- [Android image callout freeze](android-image-callout-freeze.md) — long-press `<img>` fires WebView native callout that collides with app touch handlers → whole-app freeze; `-webkit-touch-callout: none` doesn't inherit so put `.no-context-menu` on an ancestor of the image (`.no-context-menu img` rule in globals.css); seen on book covers (#4345) + image preview/zoom (#4420, `ImageViewer.tsx`)
|
||||
- [ProgressBar focus-ring line (#4397)](progressbar-focus-ring-4397.md) — decorative `.progressinfo` footer was `tabIndex={-1}` → Android long-press focused it → stray content-width focus-ring line at the bottom every page; fix = drop tabIndex (role='presentation' must not be focusable); ffmpeg-the-video debugging + live-browser `:focus-visible` confirmation
|
||||
- [Table dark-mode tint regression (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` color-mix tint in `getColorStyles` must stay gated on `overrideColor` (gate added #2377, removed #4055, re-broke → #4419); safe now that #4392 light-bg rewriters handle #4028 zebra legibility; SAME rule paints vertical-TOC `.space`/▉ spacer cells (▉ U+2589 = blank glyph, contours=0) → "spacing changes" symptom; both fixed by the gate
|
||||
- [Double-click-drag turns page (#4524)](dblclick-drag-pageturn-4524.md) — web double-click+drag selection also turned the page; 1st click's deferred single-click (250ms) fires mid-drag while 2nd-click button held; fix = `isMouseDown` flag in `iframeEventHandlers.ts` gates the deferred `postSingleClick`; synthetic-repro gotchas (shadow-DOM iframe walk, chained-repro timing pollution, reload to re-bind listeners)
|
||||
- [RSVP font face/family (#4519)](rsvp-font-settings-4519.md) — RSVP word was hardcoded `font-mono`; now mirrors the reader font via `getBaseFontFamily(viewSettings)` (new export in `style.ts`, shares `buildFontFamilyLists` with `getFontStyles`). Overlay renders in the TOP document (portal to body) where custom + basic Google fonts are mounted; known gap = built-in CJK web fonts only in top doc when `isCJKEnv()`
|
||||
- [RSVP RTL word display (#4630)](rsvp-rtl-word-display-4630.md) — Arabic/RTL word window showed separated, reversed letters: ORP focus-letter split slices words by char index (breaks shaping/order); fix = `isRTLText` → render RTL whole via the CJK `.rsvp-word-whole` branch with `dir=rtl`. Literal-RTL-char Edit pitfall → write regex with `\u` escapes
|
||||
- [Edge TTS word-highlight drift on middle sentences](tts-word-highlight-singletextnode-drift.md) — `rangeTextExcludingInert` TEXT_NODE fast path ignored range offsets → returned whole paragraph → word offsets drift (spoken "Those"→hl "if th"); only middle sentences of single-`<span>` paras (cac=TEXT_NODE); Edge-only; fix=slice [startOffset,endOffset]; added dev-only `[TTS] word-sync` log; select-word→popup-headphone repro
|
||||
- [TTS start-from-selection bugs](tts-start-from-selection.md) — foliate `from()` picked first mark at/after selection → started NEXT sentence for non-first words (fix=last mark at/before); + Annotator now `cloneRange()`+`view.deselect()` on TTS start so the word doesn't stay selected; jsdom needs `CSS.escape` polyfill (vitest.setup) since `from()` uses it
|
||||
- [Reuse TTS session on Paragraph/RSVP entry](tts-reuse-session-mode-entry.md) — modes only engaged following on a fresh `playing` event → entering with TTS already playing didn't sync. Fix = `TTSController.redispatchPosition()` + `useTTSControl` `tts-sync-request` replay (position-before-state) + per-mode engage-on-entry effect (following=true, reset lastSequenceSeen, dispatch request); RSVP paused branch also `setExternallyDriven(true)`. Paragraph live-verified ("Following audio" on entry)
|
||||
- [Footnote aside border line (#4438)](footnote-aside-namespace-order-4438.md) — v0.11.4 regression: stray horizontal line below footnote marker. #4383 inlined custom `@font-face` BEFORE the `@namespace epub` (which lived in `getPageLayoutStyles`), invalidating it per CSS spec → namespaced `aside[epub|type~="footnote"]{display:none}` dropped → book's `aside{border:3px double}` showed. Only with custom fonts loaded. Fix = hoist `@namespace` to front of `getStyles`. Repro needs XHTML (`epub:type` namespaced only in XML); Playwright `setContent` parses HTML and won't reproduce
|
||||
- [Scrolled-mode notch mask vs texture (#4486)](notch-mask-texture-4486.md) — top inset mask occluded the bg texture; full-cell + clip-path paint-box-matching for tile alignment; CDP-inject + MAE seam verification on device; adb taps in status-bar region eaten by SystemUI
|
||||
- [Paragraph-mode accidental exit + off-center bar (#4474)](paragraph-mode-accidental-exit-4474.md) — backdrop/center taps exited focus mode (stray "too high/low" taps); `ParagraphBar` only reshows on mousemove (no touch reshow) so can't just delete tap-exits → new `paragraph-show-controls` event reveals the bar instead. Also bar `absolute`→`fixed`: it centered on the gridcell which a pinned sidebar shifts right, while the paragraph centers on the `fixed inset-0` overlay/viewport
|
||||
- [Share intent + customizable toolbar (#4014)](annotation-share-toolbar-4014.md) — Share tool in the selection toolbar (sharekit gated mobile+macOS only re: #4343 Windows freeze; `canShareText`/`shareSelectedText` in dual-purpose `share.ts`) + drag-and-drop customizer sub-page; `annotationToolbarItems` view setting (Share hidden by default); pure helpers in `annotationToolbar.ts`
|
||||
|
||||
## Library Fixes
|
||||
- [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — un-awaited `Menu.append()` (async IPC) in `BookshelfItem.tsx` → context-menu items shuffle order every open (native only, invisible in jsdom); fix = single `await Menu.new({ items })` of ordered `MenuItemOptions`; order/inclusion extracted to pure `getBookContextMenuItemIds` for unit testing
|
||||
- [TXT author recognition (#4390)](txt-author-recognition-4390.md) — 【】-titled Chinese web-novels show author missing/garbage; they're TXT→EPUB (title==full filename is the tell, check `txt.ts` not foliate-js); `extractTxtFilenameMetadata` only handled 《》 + greedy header capture grabbed metadata blobs; fix = `parseLabeledAuthor` for any filename + `isPlausibleAuthorName` guard
|
||||
- [TXT chapter measure-word false positives (#4658)](txt-chapter-measure-word-4658.md) — `第一封信`/`第四本书…` (量词 prose) detected as chapters; `createChapterRegexps('zh')` unit class split into strong `[章节回讲篇话]` (attached title OK) vs weak/量词 `[卷本册部封]` (title needs a separator or line end, never a bare noun)
|
||||
- [Cover stale until refresh (in-place mutation vs React.memo)](cover-stale-inplace-mutation-memo.md) — editing a book cover in details + Save left the library cover stale until reload; `handleUpdateMetadata` mutated `book` IN PLACE so memoized `<BookCover>`'s prev snapshot pointed at the same object → comparator saw no change → skip; fix = pure `getBookWithUpdatedMetadata` returns a NEW book object. Cloning in `updateBook` wouldn't help (original already mutated). Verified live on emulator via CDP fiber-store extraction (A: mutate→stale, B: new obj→updates)
|
||||
- [Series/author folder back no-op (#4437)](series-folder-back-noop-4437.md) — back arrow dead inside Series/Author folder after cold start; Next.js 16.2 static-export empty-search `router.replace` no-op (same as #3782/#3832); `GroupHeader.handleBack` missed the `group=''` workaround. CDP-verify gotcha: synthetic `el.click()` won't fire React onClick — use trusted `Input.dispatchMouseEvent`
|
||||
|
||||
## Library Architecture
|
||||
- [Book action platform surfaces](book-actions-platform-surfaces.md) — library context menu is **Tauri-desktop-only** (`hasContextMenu` false on web + iOS/Android); cross-platform book actions go in `BookDetailView`'s icon row. #4543 Goodreads search added both surfaces + a built-in web-search provider for highlighted-text lookup
|
||||
|
||||
## Architecture Notes
|
||||
- foliate-js is a git submodule at `packages/foliate-js/`
|
||||
- Multiview paginator: loads adjacent sections in background, multiple View/Overlayer instances per book
|
||||
- Style overrides: `getLayoutStyles()` (always), `getColorStyles()` (when overriding color)
|
||||
- `transformStylesheet()` does regex-based EPUB CSS rewriting at load time
|
||||
- TTS uses independent section tracking (`#ttsSectionIndex`) decoupled from view
|
||||
- Safe area insets flow: Native plugin -> useSafeAreaInsets hook -> component styles
|
||||
- Dropdown menus use `DropdownContext` (not blur-based) for screen reader compat
|
||||
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md) — to suppress reader gestures from the app, use `{capture:true}`; the paginator registers bubble-phase doc listeners first (during `view.open()`)
|
||||
- [iframe cross-realm instanceof](iframe-cross-realm-instanceof.md) — app-bundle code (style.ts, iframeEventHandlers.ts) runs in top realm; `iframeEl instanceof Element` is ALWAYS false → guards silently drop all iframe elements (passes jsdom, dead in app). Duck-type `'closest' in target` instead. Bit PR #4391's touch routing + applyTableStyle dedupe
|
||||
|
||||
## Workflow
|
||||
- [Test file filter](feedback_test_file_filter.md) — use `pnpm test <path>` without `--` to run a single file
|
||||
- [Always rebase before PR](feedback_pr_rebase.md) — rebase onto origin/main before creating PRs
|
||||
- [New branch per PR](feedback_pr_new_branch.md) — always create a fresh branch from main for each new PR/issue
|
||||
- [Upgrade gstack locally](feedback_gstack_upgrade.md) — always upgrade from the project's .claude/skills/gstack, not global
|
||||
- [No lookbehind regex](feedback_no_lookbehind_regex.md) — never use `(?<=)` or `(?<!)` in JS/TS; build check rejects them
|
||||
- [Use worktree](feedback_use_worktree.md) — never `git worktree add` directly; always `pnpm worktree:new` before PR review, issue fix, or feature work
|
||||
- [en/translation.json holds ONLY plural variants + proper nouns](feedback_en_plurals_manual.md) — non-plural strings stay out (defaultValue: key is the en source); plural strings (`_('...', { count })`) need hand-added `_one`/`_other` entries or the singular renders as "1 days"
|
||||
- [Never push on every change](feedback_dont_push_every_change.md) — hold pushes during active bug iteration; commit locally only until user confirms or work hits a clean done-state
|
||||
- [No test seams in production code](feedback_no_test_seams_in_prod.md) — production must never import or call `__reset*ForTests`; cross-module test resets belong in the test file's beforeEach/afterEach
|
||||
- [Dependabot transitive fixes](dependabot-pnpm-overrides.md) — pin patched min-version in `pnpm-workspace.yaml` `overrides:` (NOT package.json `pnpm.overrides`, which pnpm 9+ ignores); watch for existing too-low pins; alert#≠issue# so no `Closes #` (PR #4523)
|
||||
- [CI/PR delivery + push keepalive](ci-pr-delivery-and-push.md) — package small PRs from a dirty dev tree via temp-index plumbing (no worktree); slow pre-push hook (~55s full suite) + SOCKS-proxy SSH → idle "Broken pipe", fixed with `ServerAliveInterval`; `--no-verify` safe once the hook already passed (always `git ls-remote` to confirm a push landed)
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: android-cdp-e2e-lane
|
||||
description: "pnpm test:android — CDP+adb e2e lane driving the installed app on a device/emulator; harness design, gotchas, CI workflow"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 16f94822-04b0-4be3-a47e-8a2e3cab290a
|
||||
---
|
||||
|
||||
New test tier (PR #4545, merged 2026-06-12): `pnpm test:android` → `scripts/test-android.sh` → `vitest.android.config.mts` (node env, serial, retry 1) → `src/__tests__/android/*.android.test.ts`. Helpers in `src/__tests__/android/helpers/`: `adb.ts` (tap/longPress/`motionGesture` = one-shell DOWN/MOVE/UP chain), `cdp.ts` (forward `webview_devtools_remote_<pid>`, node:http discovery with Host header, `CdpPage.evaluate` async-IIFE), `reader.ts` (fixture open + probes). Soft-skips without adb/device/app. Covers the [[android-hyphen-selection-bounds-1553]] cases: prone long-press → app handles, drag repair clamp, tap dismissal, handle-drag extension, mid-paragraph native handles, cross-page corner-dwell auto-turn.
|
||||
|
||||
Design principles (per chrox): discover-don't-assume (find a hyphenated on-screen paragraph at runtime, start in main text via `gotoChapter('chapter\\s*4')`), force hyphenation by injecting `p{hyphens:auto!important;text-align:justify!important}` into section docs (app settings irrelevant), poll-don't-sleep (`waitFor`), fixture `sample-alice.epub` opened TRANSIENTLY via MediaStore VIEW intent.
|
||||
|
||||
Gotchas:
|
||||
- MediaStore `_data` is the canonical `/storage/emulated/0/...` path — query `_data LIKE '%/<basename>'`, NOT the `/sdcard/` symlink you pushed to. `content query --projection` takes ONE column or space-separated (not comma). VIEW with `--grant-read-uri-permission` works on a permissionless fresh install.
|
||||
- Multi-section books: each section is its own iframe — record + restore pagination via the TARGET section's frame x (`c.index === sectionIndex`), not `contents[0]`.
|
||||
- Corner auto-turn (#1354) zone is the reading area INSET by content margins — a drag point in the bottom margin is ignored by `cornerAt`; aim ~4% inside the text area.
|
||||
- adb `input motionevent` 5px moves are under touch slop → no pointermove; make post-turn drag movements large.
|
||||
- Verified green on Xiaomi 13 (physical) AND fresh Pixel_9_Pro AVD (`emulator -avd Pixel_9_Pro`, install the aarch64 dev APK), ~21 s.
|
||||
|
||||
CI: `.github/workflows/android-e2e.yml` — ubuntu-latest + KVM udev rule, debug x86_64 APK (`tauri android build --debug --target x86_64`; gradle skips keystore.properties when absent so NO signing secrets), `reactivecircus/android-emulator-runner@v2` (api 34, AVD snapshot cached), nightly + workflow_dispatch + `e2e-android` PR label; not PR-blocking. NOTE: emulator-runner not SHA-pinned yet (repo convention pins by SHA).
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
name: android-hyphen-selection-bounds-1553
|
||||
description: "#1553 Android selection breaks on first word of hyphenated paragraphs — Blink generated-hyphen bounds bug, full RCA + app-side repair/suppress fix"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 16f94822-04b0-4be3-a47e-8a2e3cab290a
|
||||
---
|
||||
|
||||
Issue #1553 root cause (verified live on Xiaomi 13, WebView 147, via [[cdp-android-webview-profiling]]):
|
||||
|
||||
**Upstream**: filed as crbug **522869957** (2026-06-12, by chrox, with full analysis + repro + screenshot). Pre-existing same-root-cause report: crbug **41496034** (Jan 2024, "­" framing — why searches missed it; P2 Available, on "Rendering Core 2026 Fixit" hotlist; MS triager confirmed soft-hyphen cause in Feb 2024). Cross-link comments posted on both.
|
||||
|
||||
**Blink bug** — `LayoutSelection::ComputePaintingSelectionStateForCursor` (third_party/blink/renderer/core/editing/layout_selection.cc) compares `paint_range_` offsets (paragraph **IFC text-content space**, via `OffsetMapping::GetTextContentOffset`) against `position.TextOffset()` — but auto/soft-hyphen fragments are **layout-generated text with self-relative offsets {0,1}**. So a touch selection starting at IFC offset 0 (first word of a paragraph) makes EVERY hyphen fragment in that paragraph report `kStart` → each records a start bound (`SelectionBoundsRecorder`, last paint wins) → **native start handle is drawn at the paragraph's LAST hyphen**. The highlight itself is correct because the sibling path `ComputeSelectionStatus(InlineCursor&)` HAS the `IsLayoutGeneratedText()` remap; the bounds path lacks it. Still unfixed upstream as of Chromium main (June 2026); seemingly unreported.
|
||||
|
||||
Key facts:
|
||||
- Trigger: touch selection (handles visible — `ShouldRecordSelection` gates on `IsHandleVisible()`, so desktop/mouse unaffected; iOS=WebKit unaffected) + selection start at IFC offset ≤1 + generated hyphens in the same paragraph. **Multicol NOT required** (reproduced in a plain top-document div).
|
||||
- Worse than cosmetic: long-press **drag-extension re-anchors the base by hit-testing the bogus bound** → observed anchor jump 0→325 (offset just before last hyphen), selection became [53,325] instead of [0,53]. Explains "select upward works" workaround (upward drags anchor on the correct end bound).
|
||||
- Auto-hyphens show up as separate ~0.3em rects in `Range.getClientRects()` on hyphenated lines — usable as a generated-hyphen detector.
|
||||
- **JS `removeAllRanges()+addRange()` does NOT hide already-visible touch handles synchronously** — the empty selection must commit through one painted frame (double-rAF in the iframe window) before re-adding; then handles stay hidden for all later JS selection updates.
|
||||
|
||||
Fix (**PR #4545, MERGED 2026-06-12**; worktree cleaned): detection utils in `src/utils/sel.ts` (`isHyphenHandleBugProneRange`, `repairJumpedSelectionRange`, `hasTrailingHyphenRectPattern`); gesture-initial anchor capture + touchend sanitize in `useTextSelector` (repair jumped anchor → suppress handles via empty-commit → `makeSelection(handlesSuppressed)`); `SelectionRangeEditor.tsx` renders custom drag handles (reuses `Handle` + extracted `buildRangeFromPoints`/`getHandlePositionsFromRange` in annotatorUtil) for suppressed selections. Gated to Android app + exact bug condition; flipping to always-custom-handles later = drop the proneness gate.
|
||||
|
||||
Gotcha: `input motionevent DOWN/MOVE/UP` (adb) simulates long-press-drag; `input swipe x y x y 700` simulates plain long-press.
|
||||
|
||||
Two post-fix races found by chrox & fixed (commits c6e9f48, 9a63157):
|
||||
1. **Tap-dismiss resurrection** — an Android tap doesn't clear the selection, it COLLAPSES it to a caret at the tap point, with selectionchange ~10-20ms AFTER touchend; the tap's touchend re-entered sanitize (fallback prone-check on the still-valid old selection), the collapse raced into the double-rAF window (also MUTATING the held Range in place), and makeSelection committed a collapsed range as a suppressed selection → "empty custom handles at the tap spot". Fix = gesture gate (`if (!initial) return`) + post-rAF abort when `sel.rangeCount > 0 || finalRange.collapsed`.
|
||||
2. **Extent overshoot** — the corrupted drag has TWO modes: base re-anchors at the bogus bound (anchor jump, Argentina test) OR the EXTENT lands there while the anchor stays at 0 (range then CONTAINS the initial anchor → jump-repair doesn't fire; observed +1013 chars). Fix = `rangeFromAnchorToPoint`: rebuild [gesture-initial anchor → caret at last native touch position] (`pointerPos`, reset per-gesture in handleTouchStart), fallback to jump-repair.
|
||||
|
||||
Verify-build gotcha: `pnpm dev-android` snapshots the Next bundle at build START — an amend after kickoff ships the PRE-amend frontend (grep of out/ chunks for comment text is a FALSE-POSITIVE check; compare out/ mtime vs commit time instead).
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
name: android-image-callout-freeze
|
||||
description: "Android WebView native long-press image callout collides with app touch handlers and freezes the app; reusable `.no-context-menu` fix"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 50bec34f-7090-4bf4-a194-9bf4029527bf
|
||||
---
|
||||
|
||||
Recurring Android-only freeze: long-pressing an `<img>` triggers the WebView's
|
||||
native image callout (context menu / drag / magnifier) which collides with the
|
||||
app's own touch handlers (long-press multi-select, or pinch/pan) and freezes the
|
||||
whole app until restart.
|
||||
|
||||
**Root cause:** `-webkit-touch-callout: none` does NOT inherit, so a
|
||||
`.no-context-menu` class on a *container* never reaches descendant images.
|
||||
|
||||
**Fix:** the `.no-context-menu img, .no-context-menu a` rule in
|
||||
`src/styles/globals.css` (sets `-webkit-touch-callout: none; -webkit-user-drag:
|
||||
none; user-select: none`). Apply the `no-context-menu` class to an *ancestor* of
|
||||
the image so the descendant rule reaches it. Harmless on desktop
|
||||
(`-webkit-touch-callout` is a no-op there; right-click-save still works).
|
||||
|
||||
Occurrences so far:
|
||||
- Book covers on the bookshelf — PR #4345 (`BookshelfItem.tsx`, gated on
|
||||
`appService?.isMobileApp`; added the `.no-context-menu img` rule).
|
||||
- Image preview / zoom viewer — issue #4420, `ImageViewer.tsx` root container
|
||||
(applied unconditionally — no selectable text there, so no need to gate).
|
||||
|
||||
**How to apply:** when a new "Android freezes on long-press of an image" report
|
||||
comes in, find the `<img>` and put `no-context-menu` on a containing element.
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: android-nativefile-remotefile-io
|
||||
description: "Why NativeFile is slow on Android, why RemoteFile (range fetch) can't replace it (asset-protocol Range is broken), measured CDP numbers, and the viable speedups"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 8057ac9c-2e3e-446d-86aa-29baddfbfe66
|
||||
---
|
||||
|
||||
On-device investigation (Xiaomi 2211133C, Android 16, WebView/Chrome 147, wry 0.54.4) of `src/utils/file.ts` `NativeFile` vs `RemoteFile` Android I/O. Verified live via CDP injection into the running app's WebView.
|
||||
|
||||
**Why NativeFile is slow (root cause):** `NativeFile.readData` → `#readAndCacheChunkSafe` does `open()+seek()+read()+close()` = **4 Tauri IPC round-trips per chunk**, opens a FRESH handle every chunk (never reuses `this.#handle`), and `read()` ships raw bytes across the Android Kotlin↔JS IPC bridge (serialization cost = the unresolved tauri-apps/tauri#9190). Code already notes "~400 ms per IPC round-trip" at `nativeAppService.ts:313`.
|
||||
|
||||
**Can RemoteFile replace NativeFile on Android? NO** — and whole-file load is NOT an alternative (RemoteFile's whole point is random access WITHOUT loading the file into RAM). The Tauri/wry Android **asset protocol mishandles Range requests** (still true on WebView 147), which is exactly `RemoteFile.fetchRange`'s mechanism:
|
||||
- `Range: bytes=START-…` with **START ≥ 1024 → hard `TypeError: Failed to fetch`**; `0 ≤ START < 1024` → body truncated to `1024-START` bytes. Reading the zip central directory (EOF) / OPF / cover = non-zero offsets = all fail. "Known issue" at `nativeAppService.ts:244` — confirmed STILL broken.
|
||||
- **ROOT CAUSE (localized):** Tauri's `crates/tauri/src/protocol/asset.rs` range logic is CORRECT (seek+read [start,end], 206, Content-Range, Content-Length; `MAX_LEN=1000*1024` cap is BY DESIGN — RemoteFile already chunks at the same `MAX_RANGE_LEN`). The bug is in **wry `src/android/binding.rs`**: it STRIPS the `Content-Length` header ("WebResourceResponse will auto-generate") and hands Android a `ByteArrayInputStream` of the already-sliced partial body + a `Content-Range` header. The Android WebView then **double-applies the offset** (skips another `start` bytes) → `1024-start` truncation, empty body for start≥1024. **Unchanged through wry 0.55.1**, so bumping wry won't fix it; needs an upstream wry patch (or local vendor/patch) and fights Android's intercepted-206 quirks.
|
||||
- Plain `fetch(assetUrl)` (no Range) returns the full file fast — but loading the whole file defeats RemoteFile's purpose, so NOT a fix.
|
||||
|
||||
**Measured (10 MB mobi, 1 MB chunks):** native fresh-handle **44 MB/s** (222 ms) · native one-handle **100 MB/s** (98 ms) · asset plain-fetch **281 MB/s** (35 ms, full file correct). Per-call 4 KB scattered read via NativeFile ≈ **16 ms/op** (kills imports doing many small reads). So: plain-fetch is **6.3×** native and **2.8×** one-handle; just reusing the handle is **2.3×**.
|
||||
|
||||
**Per-IPC decomposition (warm):** open 1.33 ms, seek 0.60 ms, read(4 KB) 3.02 ms (read carries ~2.4 ms fixed bridge-serialization beyond the round-trip = the tauri#9190 ceiling), seek+read(1 MB) 8.18 ms.
|
||||
|
||||
**SOLUTION (implemented, branch `feat/android-rangefile-protocol`, verified on-device):** a custom `rangefile` URI scheme (`src-tauri/src/range_file.rs`, registered via `register_asynchronous_uri_scheme_protocol`) that carries the byte range in the URL **query** (`http://rangefile.localhost/?path=&start=&end=`) instead of a `Range` header. With NO `Range` header the WebView does no offset re-application and delivers the 200 body verbatim — while bytes still stream through the WebView network stack (not the IPC bridge). Returns 200 + `X-Total-Size` (no `Content-Range`); scope-gated by `asset_protocol_scope().is_allowed()` (same security as asset protocol). TS side: `RemoteFile.fromNativePath(absPath)` (query-range mode, reads `X-Total-Size` on open, `&start=&end=` per fetch, no Range header); wired into `nativeAppService.openFile` Android branch with NativeFile fallback; CSP += `http://rangefile.localhost`.
|
||||
- **Verified on Xiaomi/Android 16 via CDP:** byte-equal to NativeFile ground truth at ALL offsets (0,1,1024,64K,1M,5M,EOF) — the non-zero starts that failed via asset protocol now work; cache-safe across distinct ranges; real library book opens & renders end-to-end (50 rangefile requests, restored mid-file position). **1.83× faster** small scattered 4KB reads (5.2 vs 9.5 ms); bulk-sequential ≈ par (RemoteFile rarely does whole-file reads; native copyFile fast-path handles those). Why this beat the "200 trick" idea: pre-test showed the WebView re-applies the offset to 200 responses too — it's the *Range request header* that triggers it, so removing the header (range-in-URL) is the actual fix.
|
||||
- Why this isn't the "single-call IPC command": IPC still pays the tauri#9190 bridge serialization; the rangefile path streams via the network stack. The IPC command (`open+seek+read+close` → 1 IPC, ~2× small reads) remains a valid simpler fallback if the custom scheme ever regresses.
|
||||
|
||||
**Side-observation:** installed build's ACL rejects `plugin:fs|close` (allows open/seek/read) → possible `NativeFile` handle-leak / `close()` throw path; verify `fs:default` grants. See [[cdp-android-webview-profiling]].
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
name: android-open-with-intent-flow
|
||||
description: "Android \"Open with/Send to Readest\" intent pipeline + why Telegram/cloud-app opens differ from file-manager opens (issue"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 73ee84b7-27a0-4232-981c-de8235a15f07
|
||||
---
|
||||
|
||||
Android "Open with Readest" / "Send to Readest" file-intent pipeline and the #4521 ("open book from Telegram") diagnosis. Confirmed fixed by #4527.
|
||||
|
||||
**Pipeline (ACTION_VIEW = "Open with", ACTION_SEND = "Share"):**
|
||||
- `NativeBridgePlugin.kt::handleIntent` is the real handler (NOT `MainActivity.kt` — its ACTION_SEND branch is legacy/redundant). → `emitSharedIntent("VIEW"|"SEND", uris)` → JS `useAppUrlIngress` `shared-intent` plugin listener → `app-incoming-url` event → `useOpenWithBooks`.
|
||||
- VIEW → `openTransient` → straight to reader (ephemeral book, `deletedAt` set, `filePath` = the content:// URI, no library write/upload). SEND → `window.OPEN_WITH_FILES` → `library/page.tsx::processOpenWithFiles` (full ingest + force cloud upload on mobile).
|
||||
- content:// read: `nativeAppService.openFile` → if URI contains `com.android.externalstorage` → direct `NativeFile` (real path); else `copyURIToPath` → `contentResolver.openInputStream` → copy to Cache → `NativeFile`. `basename` here is LEXICAL (`@tauri-apps/api/path`), not a ContentResolver `DISPLAY_NAME` query — but EPUB format is sniffed by zip magic (`document.ts isZip()`), so an extension-less content URI still opens.
|
||||
- The Tauri deep-link plugin's `getCurrent()`/`onOpenUrl` only fire for configured deep-link domains (`https://web.readest.com`, `readest:`); `content://`/`file://` VIEW intents are filtered out by `DeepLinkPlugin.isDeepLink()`, so file opens flow ONLY through the native `shared-intent` channel, never the deep-link plugin.
|
||||
|
||||
**#4521 root cause (Telegram open fails, file-manager works) — TWO independent axes:**
|
||||
1. **Cold-start delivery.** On cold launch the ACTION_VIEW intent reaches `handleIntent` before the JS `shared-intent` listener registers; upstream `Plugin.trigger()` drops events with no listener. **#4527** added queue+replay (`emitOrQueue`/`pendingEvents` + `registerListener` override) to fix it. **#4527 is on `dev` but NOT in released v0.11.4** (v0.11.4 has #4407 only) — the reporter's likely cause. Logs show `Queued shared-intent payload (no listener yet)` then `Replaying 1 queued event(s) after registerListener`.
|
||||
2. **Foreign-private-file read.** File-manager/MediaStore opens point at SHARED storage → Readest reads via real path / MediaProvider FUSE (it holds `MANAGE_EXTERNAL_STORAGE`), so the URI grant is irrelevant. Telegram/Gmail/Drive serve an APP-PRIVATE file via their own FileProvider with a TEMPORARY, non-persistable grant (`takePersistableUriPermission` throws → caught) → readable only in-session via `openInputStream`; the transient book's `content://` filePath then breaks on later reopen once the grant dies.
|
||||
|
||||
**Verification gotcha (adb, no Telegram):** an adb MediaStore content-URI VIEW intent
|
||||
`adb shell am start -a android.intent.action.VIEW -d content://media/external/file/<id> -t application/epub+zip --grant-read-uri-permission -n com.bilingify.readest/.MainActivity`
|
||||
tests the pipeline (Axis 1) but CANNOT reproduce Axis 2 — shared-storage reads via FUSE bypass the grant, so it always succeeds. To reproduce the real failure use a foreign FileProvider source (Gmail/Drive/Outlook attachment "Open with Readest") or a tiny helper APK with its own FileProvider. Get the MediaStore `_id` via `adb shell content query --uri content://media/external/file --projection _id:_data | grep <name>` (MIUI `--where` chokes on `/storage/...` path tokens). Watch `adb logcat | grep -iE "NativeBridgePlugin|Open with FUSE|Failed to (open|import)|Queued|Replaying"`.
|
||||
|
||||
Critical files: `src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt`, `src/hooks/useAppUrlIngress.ts`, `src/hooks/useOpenWithBooks.ts`, `src/services/nativeAppService.ts::openFile`, `src/services/bookContent.ts::resolveBookContentSource`.
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
name: android-sideload-same-versioncode
|
||||
description: Android sideloaded APK reinstall allows EQUAL versionCode; only strictly-lower is blocked
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: reference
|
||||
originSessionId: a58a4eba-7a3c-4560-9b52-e3713c6ad211
|
||||
---
|
||||
|
||||
Sideloaded APK installs (Readest's in-app updater path: `installPackage` → `Intent.ACTION_VIEW` with `application/vnd.android.package-archive` → system package installer, NOT Play Store) permit reinstalling an APK whose `versionCode` is **equal** to the currently installed one — it's an in-place reinstall/update as long as the signing certificate matches. Android's `INSTALL_FAILED_VERSION_DOWNGRADE` only triggers for a **strictly-lower** versionCode. (Play Store, by contrast, requires a strictly-incrementing versionCode — that constraint does NOT apply to sideload.)
|
||||
|
||||
Consequence for the nightly update channel ([[android-open-with-intent-flow]] uses the same NativeBridge install path): Tauri derives `versionCode = major*1000000 + minor*1000 + patch`, dropping any prerelease suffix, so all nightlies on base `0.11.4` share `versionCode=11004`. That is FINE — they reinstall over each other and over stable `0.11.4`. Because the base only ever increases (0.11.4 → 0.11.5 → ...), nightly versionCode is monotonic non-decreasing, so there is never a downgrade. No need to derive a per-build versionCode from the date stamp. The app's `versionName` carries the full `0.11.4-2026061406` string, which is what the JS `getAppVersion()` updater comparison uses.
|
||||
|
||||
A plausible-but-wrong review claim ("same versionCode means Android refuses the install as not-an-upgrade") confuses Play Store rules with sideload behavior. Corrected by the project owner 2026-06-14.
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
name: annotation-share-toolbar-4014
|
||||
description: "Share intent in the selection toolbar + drag-and-drop toolbar customizer (#4014)"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 507a0166-cb55-4f33-b633-3230c0c514ff
|
||||
---
|
||||
|
||||
#4014 (PR #4570) — added a native "Share" tool to the in-reader text-selection toolbar
|
||||
plus a drag-and-drop customizer (show/hide + reorder tools). Branch
|
||||
`feat/annotation-share-toolbar-4014`; spec + plan in
|
||||
`docs/superpowers/{specs,plans}/2026-06-13-annotation-share-toolbar*`.
|
||||
|
||||
Key facts / gotchas:
|
||||
- **`src/utils/share.ts` is dual-purpose** — it already held share-LINK helpers
|
||||
(`buildShareUrl`/`parseShareDeepLink` for the `/s/{token}` feature). Text-share was
|
||||
added there: `shareSelectedText(text, position?, appService?)` and
|
||||
`canShareText(appService)`.
|
||||
- **Native share is gated to mobile + macOS only** (`isMobileApp || isMacOSApp`).
|
||||
Windows/Linux desktop are excluded because `@choochmeque/tauri-plugin-sharekit-api`'s
|
||||
share UI can FREEZE the app on Windows (issue #4343) — `nativeAppService.saveFile`
|
||||
gates `shareFile` the same way. Ladder: native → `navigator.share` → clipboard.
|
||||
`canShareText` = that OR web `navigator.share`; used to gate Share's visibility in
|
||||
toolbar + customizer + the quick-action dropdown.
|
||||
- **Toolbar order is a view setting**: `AnnotatorConfig.annotationToolbarItems`
|
||||
(`src/types/book.ts`), default in `DEFAULT_ANNOTATOR_CONFIG` = the original 8 tools,
|
||||
**Share hidden by default** (starts in the "Available" tray). No migration needed:
|
||||
the `{...getDefaultViewSettings(ctx), ...saved}` merge in `settingsService.ts` +
|
||||
`getToolbarToolTypes(undefined,...)` fallback both yield the default.
|
||||
- **Pure helpers** in `src/utils/annotationToolbar.ts` (unit-tested) own all
|
||||
order/visibility logic: `getToolbarToolTypes`/`getAvailableToolTypes` (canShare-gated,
|
||||
dedup, drop-unknown), `add/remove/reorderToolbar`. `ALL_ANNOTATION_TOOL_TYPES` is
|
||||
asserted to match the `annotationToolButtons` registry order by a test.
|
||||
- **Customizer** = `src/components/settings/AnnotationToolbarCustomizer.tsx`, a sub-page
|
||||
off `ControlPanel` (Behavior panel) via `NavigationRow`. Two `@dnd-kit` zones; chips are
|
||||
tap-to-toggle AND drag. Design evolved heavily during live browser testing (see gotchas):
|
||||
- **WYSIWYG**: "In toolbar" renders a faithful preview of the real selection popup —
|
||||
`selection-popup bg-gray-600 text-white`, icon-only 32×32 buttons (mirrors
|
||||
`AnnotationToolButton`), `w-fit max-w-full` (content-width, start-aligned). "Available"
|
||||
tools are labeled icon+text chips. Zone content uses `px-4` to align with `SubPageHeader`.
|
||||
- **dnd-kit multiple-containers pattern** (NOT the simple single-list one): single
|
||||
`{toolbar, available}` state; `onDragOver` live-reparents across zones; custom
|
||||
`collisionDetection` = `pointerWithin` → `rectIntersection` fallback, snapping a zone-id
|
||||
hit to the closest inner chip (plain `closestCorners`/`closestCenter` CANNOT drop into an
|
||||
empty zone). `rectSortingStrategy` (NOT `horizontalListSortingStrategy`, which breaks
|
||||
wrapped layouts).
|
||||
- **NO `DragOverlay`** — the settings modal is a CSS-`transform` container, so a
|
||||
`position:fixed` overlay is offset from the cursor. In-place `useSortable` transform
|
||||
(relative translate) tracks correctly.
|
||||
- **`itemsRef` stale-closure fix**: dnd-kit calls `onDragEnd` with the handler captured at
|
||||
drag START, so the closed-over `items` is stale → a cross-zone drag would bounce back on
|
||||
release. Read live state from `itemsRef.current` in `handleDragEnd`/tap handlers.
|
||||
- **Add all** (rebuilds in canonical `ALL_ANNOTATION_TOOL_TYPES` order, NOT prior order) /
|
||||
**Clear all** header buttons.
|
||||
- Cross-platform guard: when editing on a `!canShare` device, `persist` re-appends a
|
||||
`share` that was synced-in but hidden, so it isn't dropped for share-capable devices.
|
||||
- **Empty toolbar suppresses the popup**: when `getToolbarToolTypes` yields [] (user cleared
|
||||
all), `Annotator.tsx` does NOT render the `AnnotationPopup` on a plain selection (gated on
|
||||
`toolButtons.length > 0 || highlightOptionsVisible || annotationNotes.length > 0`) — no
|
||||
empty bar, but highlight-edit/notes popups still work. (Earlier tried fallback-to-default;
|
||||
user wanted full suppression instead.)
|
||||
- Adding a tool to the union (`AnnotationToolType`) is compile-checked: the
|
||||
`createAnnotationToolButtons` generic in `AnnotationTools.tsx` requires every member.
|
||||
@@ -1,90 +0,0 @@
|
||||
# Annotator & Reader Fixes Reference
|
||||
|
||||
## Annotation System Architecture
|
||||
|
||||
### Key Components
|
||||
- `Annotator.tsx` - Annotation lifecycle, popup display, style/color management
|
||||
- `AnnotationRangeEditor.tsx` - Drag handles for adjusting selection range
|
||||
- `MagnifierLoupe.tsx` - Magnifying glass during handle drag (mobile only)
|
||||
- `useTextSelector.ts` - Text selection detection and processing
|
||||
- `useAnnotationEditor.ts` - Editing existing annotations
|
||||
- `useInstantAnnotation.ts` - Creating new annotations on selection
|
||||
|
||||
### Highlight Rendering
|
||||
- Highlights rendered by foliate-js `Overlayer` (SVG overlayer in paginator shadow DOM, not iframe)
|
||||
- Each view in multiview paginator has its own `Overlayer` instance with unique clipPath ID
|
||||
- `Overlayer.add()` stores range + draw function; `redraw()` recalculates positions from stored ranges
|
||||
- Colors stored as color names mapped to custom hex via `globalReadSettings.customHighlightColors`
|
||||
- Sidebar uses `color-mix()` CSS function with custom colors, not Tailwind utility classes (#3273)
|
||||
- Rounded highlight style supported via `vertical` option passed to overlayer (#3208)
|
||||
|
||||
### Multiview Overlayer Pitfalls
|
||||
- **Duplicate SVG IDs**: Each overlayer creates `<clipPath>` for loupe hole — IDs MUST be unique per instance or `url(#id)` resolves to wrong element, clipping everything
|
||||
- **docLoadHandler scope**: `FoliateViewer.tsx` re-adds annotations on `load` event — MUST filter by `detail.index` (loaded section), not re-add ALL annotations (overwrites drag edits)
|
||||
- **MagnifierLoupe lifecycle**: Don't destroy/recreate loupe on every drag tick — `hideLoupe()` should only run on unmount, `showLoupe()` fast path updates position only
|
||||
- **Stale closures in useTextSelector**: `getProgress()` must be called inside callbacks, not captured at hook top-level (useFoliateEvents deps are `[view]` only)
|
||||
|
||||
## Fix History
|
||||
|
||||
| Issue | Problem | Root Cause | Fix |
|
||||
|-------|---------|------------|-----|
|
||||
| #3286 | Selection stuck on first annotation | `initializedRef` guard blocked re-computation | Remove guard, consolidate style/color effects |
|
||||
| #3273 | Custom colors not in sidebar | Hardcoded Tailwind classes | Use inline `style` with `color-mix()` |
|
||||
| #3234 | Letter-by-letter selection on mobile | No word boundary snapping | Add `snapRangeToWords()` using `Intl.Segmenter` |
|
||||
| #3208 | Hard rectangular highlights | No border radius support | Pass `vertical` option, update foliate-js |
|
||||
| #3002 | Can't see text under finger | No magnification UI | New `MagnifierLoupe` component using `view.renderer.showLoupe()` |
|
||||
| #3082 | No page numbers on annotations | `pageNumber` field missing | Add `pageNumber` to BookNote type, compute on create |
|
||||
| #3225 | Android tools unresponsive | Premature `makeSelection()` call | Remove premature re-selection in Android path |
|
||||
|
||||
## Common Annotation Bugs
|
||||
|
||||
### Selection Issues
|
||||
- **Word snapping**: Uses `Intl.Segmenter` with `granularity: 'word'` to snap selection to word boundaries
|
||||
- **Android re-selection**: Don't call `makeSelection(sel, index, true)` immediately on pointer-up; let the popup flow complete
|
||||
- **Range editor handles**: Remove `initializedRef` guards that prevent re-computation when switching annotations
|
||||
|
||||
### Color/Style Issues
|
||||
- **Custom colors in sidebar**: Use inline `style={{ backgroundColor: 'color-mix(...)' }}` not Tailwind classes
|
||||
- **Style synchronization**: Consolidate `selectedStyle` and `selectedColor` into one `useEffect`
|
||||
- **Switching annotations**: Must call `setShowAnnotPopup(false)` and `setEditingAnnotation(null)` before setting up new annotation
|
||||
|
||||
## Reader/Content Fixes
|
||||
|
||||
### Progress Display
|
||||
- Use physical `view.renderer.page` and `view.renderer.pages` for page counts (#3213, #3200)
|
||||
- Last page shows 100% by fixing boundary condition (#3383)
|
||||
- FB2 subsections need special handling for progress (#3136)
|
||||
|
||||
### Translation View (#3078)
|
||||
- Problem: Page jumps back during full-text translation
|
||||
- Root cause: DOM mutations from sequential translation insertions cause paginator relayout
|
||||
- Fix: Batch DOM updates with 50ms timer, use bounded concurrent queue (max 5), show loading overlay
|
||||
|
||||
### TOC Navigation (#3124)
|
||||
- Problem: Expanding TOC chapter scrolls back to current chapter
|
||||
- Fix: Only scroll-into-view on navigation, not on expand/collapse
|
||||
|
||||
## Accessibility (a11y) Fixes
|
||||
|
||||
### Screen Reader (TalkBack) Support
|
||||
- **Page indicator updates** (#2276): Add focus handlers on `<p>` elements that call `view.goTo(cfi)` to update position
|
||||
- **Navigation buttons** (#3036): Always show prev/next buttons when screen reader active; `PageNavigationButtons.tsx`
|
||||
- **Dropdown menus** (#3035): Use `DropdownContext` with overlay dismiss instead of blur-based closing
|
||||
|
||||
### Dropdown Architecture for a11y
|
||||
- `DropdownContext` (`src/context/DropdownContext.tsx`) manages which dropdown is open globally
|
||||
- Uses `useId()` for unique identification
|
||||
- One dropdown open at a time
|
||||
- `<Overlay>` for dismissal (tap/click outside) instead of `onBlur`
|
||||
- `<details>` element with `open={isOpen}` for semantic structure
|
||||
- No auto-focus-first-item (conflicts with TalkBack)
|
||||
|
||||
## E-ink Readability
|
||||
- Use `not-eink:` Tailwind variant for colors and opacity (#3258)
|
||||
- Don't use `text-primary` (blue) or low opacity on e-ink
|
||||
- Highlights use foreground color in dark mode e-ink (#3299)
|
||||
|
||||
## Key Utility Functions
|
||||
- `snapRangeToWords()` in `src/utils/sel.ts` - Word boundary snapping
|
||||
- `handleAccessibilityEvents()` in `src/utils/a11y.ts` - Screen reader focus handling
|
||||
- `color-mix()` CSS function for custom highlight colors with opacity
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
name: biometric-app-lock-4645
|
||||
description: "Biometric (fingerprint/Face ID) startup unlock layered over the PIN app-lock; gotchas for applock-store seeding, mobile-cfg crate, and scoped i18n"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 7d0f633b-0e69-405e-a4b4-5a1b19723d86
|
||||
---
|
||||
|
||||
Biometric app-lock (#4645, PR #4650, branch `feat/biometric-app-lock`): biometrics unlock at startup on Android/iOS, app PIN as fallback; desktop/web unchanged. Layered over the existing PIN lock — `pinCodeEnabled` stays the master switch, PIN crypto in `libs/crypto/applock.ts` untouched. All plugin access isolated behind `src/services/biometric.ts` (guarded no-op off mobile; `authenticate` uses `allowDeviceCredential:false` so PIN is the only fallback). New setting `biometricUnlockEnabled` defaults true only for NEW mobile setups; existing PIN users (undefined→off) opt in via a mobile-only toggle.
|
||||
|
||||
Non-obvious gotchas (cost real review/rework here):
|
||||
- **`AppLockScreen` must read startup-snapshot settings from `appLockStore`, NOT `settingsStore`.** `Providers` seeds ONLY the app-lock store via `useAppLockStore.initialize()` (from its own `loadSettings()`), before the gate mounts. `settingsStore.settings` starts `{}` and is seeded later by page-level init — reading the flag from `settingsStore` in the gate RACES and silently no-ops. Fix = thread the value through `initialize()` like `pinHash`/`pinSalt`.
|
||||
- **`tauri-plugin-biometric` is `#![cfg(mobile)]`** — empty on desktop, so registration must be `#[cfg(any(target_os="ios",target_os="android"))]`-gated (like `haptics`/`sign-in-with-apple`). Desktop `clippy:check` does NOT compile that line, so the Rust side needs a real device build to verify. The dep pin lands in the **workspace-root `Cargo.lock`** (resolved when cargo runs), NOT `src-tauri/Cargo.lock` (which doesn't exist/track here).
|
||||
- **Scoped i18n without churn:** `public/locales/en` is NOT scanner-managed (key-as-content fallback). Running the full `i18n:extract` reconciles ALL strings and pulls in unrelated drift already on main (e.g. Word Lens keys). For a clean PR, discard the scanner output and add only your new keys to the 33 langs in `i18n-langs.json`. "Face ID"/"Touch ID" are Apple brands — keep verbatim in every locale.
|
||||
|
||||
Related: [[ios-instant-dict-double-popup]] (same applock/gate area), [[custom-fonts-reincarnation-4410]] (settings-sync flag patterns).
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
name: book-actions-platform-surfaces
|
||||
description: Where to add a library book action so it reaches every platform (context menu is desktop-only)
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 4efb7b40-cce1-4742-9730-7e93e643d196
|
||||
---
|
||||
|
||||
The library book **context menu** (`BookshelfItem.tsx::bookContextMenuHandler`, native `Menu.new`) only renders where `appService.hasContextMenu` is true — that is **Tauri desktop only** (`nativeAppService.ts`: `!(ios||android)`). It is **false on web AND on iOS/Android**. So a book action added only to the context menu (+ `getBookContextMenuItemIds` in `libraryUtils.ts`) never reaches phone/web users.
|
||||
|
||||
The cross-platform home for book-level actions is the **`BookDetailView` action-icon row** (`src/components/metadata/BookDetailView.tsx`), shown in `BookDetailModal`, reachable on every platform (BookItem tap → details, `Bookshelf.tsx::handleShowDetailsBook`). That row is `flex-nowrap` inside a fixed `h-32` column and already holds up to ~5 icons (Edit/Delete/Download/Upload/Export) — adding more risks phone overflow; keep additions to one small icon.
|
||||
|
||||
**Rule:** desktop-only fast path → context menu; must reach mobile → BookDetailView (or both). Example: the "Search on Goodreads" feature (#4543) added both — `searchGoodreads` context-menu id + a `FaGoodreads` button in BookDetailView, opening `getGoodreadsSearchUrl` via [[open-external-url-helper]]. In-reader highlighted-text Goodreads search is a built-in [[web-search-provider]] entry instead.
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
name: booknote-view-autoscroll-4352
|
||||
description: "Annotation/bookmark list (BooknoteView) auto-scroll-to-nearest regression after virtualization (#4352) and its TOCView-mirroring fix"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: bda988b9-28ec-450f-874e-ee9c104f7603
|
||||
---
|
||||
|
||||
After #4352 virtualized `BooknoteView` (sidebar annotations/bookmarks list,
|
||||
`src/app/reader/components/sidebar/BooknoteView.tsx`), the list stopped
|
||||
auto-scrolling to the nearest annotation for the current reading position (it
|
||||
stranded at the top showing Chapter 1). #4352 replaced the per-item
|
||||
`useScrollToItem` with a single `virtuosoRef.scrollToIndex`, but missed the
|
||||
machinery `TOCView` already had. Two distinct failure paths:
|
||||
|
||||
1. **Reload (annotations tab active at load; progress arrives AFTER mount):** the
|
||||
OverlayScrollbars `initialized` callback (deferred init) resets the wrapped
|
||||
viewport `scrollTop` to 0, clobbering the scroll; the `lastScrolledCfiRef`
|
||||
guard then blocks any retry. Fix = re-apply `scrollToIndex` to the *current*
|
||||
nearest index inside the `initialized` callback, read via a **ref**
|
||||
(`nearestIndexRef`) because that callback is the mount-time closure. Double
|
||||
rAF (settle the reset, then re-assert once rows are measured).
|
||||
2. **Tab-switch (open panel while reading; progress KNOWN at mount):** firing a
|
||||
`scrollToIndex` synchronously on the freshly mounted, unmeasured list either
|
||||
no-ops (`behavior:'smooth'`) or **wedges Virtuoso into rendering nothing**
|
||||
(`behavior:'auto'`). Fix = mount Virtuoso *natively* centered via
|
||||
`initialTopMostItemIndex` + an `initialScrollHandledRef` gate so the scroll
|
||||
effect SKIPS that first jump. This is exactly TOCView's design.
|
||||
|
||||
The fix mirrors [[toc-expand-and-autoscroll]] (same OverlayScrollbars-resets-
|
||||
scrollTop + Virtuoso-lands-short-on-unmeasured-rows pattern). Test:
|
||||
`src/__tests__/components/BooknoteView.test.tsx` stubs Virtuoso (spy-able
|
||||
`scrollToIndex`, captures `initialTopMostItemIndex`) and captures the mount-time
|
||||
`initialized` callback — forcing a ref-based fix (modeled on `TOCView.test.tsx`).
|
||||
|
||||
**Dev-server verification gotchas (cost hours here):**
|
||||
- The `localhost:3000` dev server was running from a *different worktree*
|
||||
(`/Users/chrox/dev/readest-fix-4394-bg-gutter-bleed`), not the main checkout.
|
||||
Edits weren't compiled until copied into that worktree's path. Check
|
||||
`ps aux | grep next-server` for the serving cwd. Book data is per-origin
|
||||
(OPFS/IndexedDB on localhost:3000) so you can't verify on another port.
|
||||
- After ~10 rapid file syncs, **Fast Refresh corrupts the mounted tab's state**
|
||||
— identical code that worked started rendering 0 items. A *brand-new tab*
|
||||
(close the old one) renders correctly. Always verify in a fresh tab.
|
||||
- Chrome MCP `javascript_tool` querying `.booknote-item` count catches Virtuoso
|
||||
mid-render (returns 0 even when it later paints fine). Trust the *screenshot*
|
||||
(the painted frame), not a synchronous DOM count, for virtualized lists.
|
||||
@@ -1,133 +0,0 @@
|
||||
# Bug Fixing Patterns & Strategies
|
||||
|
||||
## Common Root Cause Categories
|
||||
|
||||
### 1. Overly Broad CSS Selectors
|
||||
**Pattern:** A CSS rule targets too many elements, causing unintended visual side effects.
|
||||
**Examples:**
|
||||
- `hr { mix-blend-mode: multiply }` applied to ALL hr elements instead of only decorative ones (#3086)
|
||||
- `p img { mix-blend-mode }` applied to block images, not just inline (#3112)
|
||||
- `svg, img { height: auto; width: auto }` overrode explicit HTML width/height attributes (#3274)
|
||||
- Background-color override applied unconditionally instead of only when user enabled color override (#3316)
|
||||
|
||||
**Fix Strategy:** Narrow selectors with class qualifiers (`.background-img`, `.has-text-siblings`) or attribute pseudo-selectors (`:where(:not([width]))`). Check if the rule should be conditional on a user setting.
|
||||
|
||||
### 2. Conditional vs Unconditional Style Overrides
|
||||
**Pattern:** CSS rules meant for "Override Book Color/Layout" mode are placed in the always-active stylesheet.
|
||||
**Examples:**
|
||||
- Calibre `.calibre { color: unset }` was in `getLayoutStyles()` instead of `getColorStyles()` (#3448)
|
||||
- Image background-color override applied without checking `overrideColor` flag (#3316, #3377)
|
||||
|
||||
**Fix Strategy:** Move rules to the correct conditional block: `getColorStyles()` for color overrides, `getLayoutStyles()` for layout overrides. Check the `overrideColor`/`overrideLayout` flags.
|
||||
|
||||
### 3. Missing EPUB Stylesheet Transformations
|
||||
**Pattern:** EPUB stylesheets contain CSS that conflicts with app functionality.
|
||||
**Examples:**
|
||||
- `user-select: none` prevents text selection (#3370) -> regex replace in `transformStylesheet()`
|
||||
- `font-family: serif/sans-serif` on body bypasses user font (#3334) -> detect and unset
|
||||
- Hardcoded Calibre backgrounds persist in dark mode (#3448) -> unset in color override
|
||||
|
||||
**Fix Strategy:** Add regex-based transformation passes in `transformStylesheet()` in `style.ts`.
|
||||
|
||||
### 4. Stale State / Refs Not Reset
|
||||
**Pattern:** A `useRef` or state variable is set once and never properly reset, blocking re-entry.
|
||||
**Examples:**
|
||||
- TTS `ttsOnRef` prevented restarting TTS from a new location (#3292)
|
||||
- `initializedRef` in AnnotationRangeEditor prevented handle position updates (#3286)
|
||||
- `view.tts` not nulled on shutdown prevented clean TTS restart (#3400)
|
||||
- TTS safety timeout fired after pause, advancing to next sentence (#3244)
|
||||
|
||||
**Fix Strategy:** Check all refs/guards in the affected flow. Ensure cleanup in shutdown/unmount. Remove overly aggressive guards that prevent re-entry.
|
||||
|
||||
### 5. Platform API Differences
|
||||
**Pattern:** A Web API behaves differently or is unavailable on certain platforms.
|
||||
**Examples:**
|
||||
- `navigator.getGamepads()` returns null on older Android WebView (#3245)
|
||||
- `CompressionStream` unavailable on some Android versions (#3255)
|
||||
- `btoa()` throws on non-ASCII characters (#3436)
|
||||
- View Transitions API unsupported in WebKitGTK/Linux (#3417)
|
||||
- `document.startViewTransition()` crashes on Linux
|
||||
|
||||
**Fix Strategy:** Always check API availability before use. Add fallback paths. Use feature detection, not platform detection when possible.
|
||||
|
||||
### 6. Safe Area Inset Issues
|
||||
**Pattern:** UI elements overlap system bars (status bar, navigation bar, notch) on mobile.
|
||||
**Examples:**
|
||||
- Zoom controls behind status bar (#3426)
|
||||
- Android navigation bar overlap (#3466)
|
||||
- iPad sidebar insets incorrect (#3395)
|
||||
- Reader page layout jump after system UI change (#3469)
|
||||
|
||||
**Fix Strategy:** Use `gridInsets` and `statusBarHeight` from `useSafeAreaInsets`. Use `env(safe-area-inset-*)` CSS functions. Call `onUpdateInsets()` after system UI visibility changes. See `docs/safe-area-insets.md`.
|
||||
|
||||
### 7. Z-Index Layering Issues
|
||||
**Pattern:** Interactive elements rendered behind other layers, becoming unclickable.
|
||||
**Examples:**
|
||||
- Navigation buttons invisible on mobile (#3201) -> added `z-10`
|
||||
- Annotation nav bar too prominent (#3386) -> reduced from `z-30` to `z-10`
|
||||
- Page nav buttons behind TTS control (#3184)
|
||||
|
||||
**Fix Strategy:** Check z-index ordering. Use minimum necessary z-index. Reference the z-index hierarchy in the codebase.
|
||||
|
||||
### 8. Event Handling Race Conditions
|
||||
**Pattern:** Timing issues between pointer events, native menus, and React state updates.
|
||||
**Examples:**
|
||||
- macOS context menu steals pointer event loop (#3324) -> 100ms setTimeout delay
|
||||
- Traffic light buttons flicker due to timeout race (#3488, #3129)
|
||||
- Android tool buttons unresponsive due to premature re-selection (#3225)
|
||||
|
||||
**Fix Strategy:** Add small delays before native menu calls. Check event state machine consistency. Remove premature re-triggers on Android.
|
||||
|
||||
### 9. foliate-js Rendering Issues
|
||||
**Pattern:** Bugs in the lower-level EPUB renderer (paginator.js, epub.js).
|
||||
**Examples:**
|
||||
- Image size not constrained in double-page mode (#3432)
|
||||
- Background not shown in scrolled mode (#3344)
|
||||
- Section content cached incorrectly after mode switch (#3242, #3206)
|
||||
- Swipe sensitivity too low for non-animated paging (#3310)
|
||||
|
||||
**Fix Strategy:** Check both `columnize()` and `scrolled()` code paths in paginator.js. Verify CSS variables (`--available-width`, `--available-height`) are computed correctly. Test in both paginated and scrolled modes.
|
||||
|
||||
### 10. Progress/Navigation Calculation Errors
|
||||
**Pattern:** Page counts, progress percentages, or position tracking are wrong.
|
||||
**Examples:**
|
||||
- Progress shows 99.9% at last page (#3383) -> boundary condition
|
||||
- Pages left shows estimated instead of physical count (#3213, #3200)
|
||||
- FB2 subsection progress wrong (#3136) -> nested structure not handled
|
||||
- TOC auto-scrolls on expand (#3124) -> scroll-into-view triggered too broadly
|
||||
|
||||
**Fix Strategy:** Use physical `view.renderer.page`/`view.renderer.pages` instead of estimated section metadata. Check boundary conditions (0-indexed vs 1-indexed, inclusive vs exclusive).
|
||||
|
||||
### 11. Debounced State Stale on User-Initiated Layout Change
|
||||
**Pattern:** A scroll/resize handler is debounced for performance, but during the debounce window any code path that re-runs layout based on saved state (e.g. `#anchor`, `#primaryIndex`) sees stale values.
|
||||
**Example:**
|
||||
- Scrolled-mode toggle reverted to previous chapter (#3987): the paginator's scroll handler is debounced 250 ms, so toggling `flow=scrolled → flow=paginated` within that window made `render() → scrollToAnchor(#anchor)` restore the anchor from before the user scrolled into the next section. Both `#anchor` and `#primaryIndex` were stale together, sending the position back.
|
||||
|
||||
**Fix Strategy:** When an external trigger forces a re-render (here, `setAttribute('flow', ...)`), flush the debounced state synchronously *before* changing the layout. In paginator.js this means overriding `setAttribute` and calling `#detectPrimaryView()` + `#getVisibleRange()` while `this.scrolled` is still true.
|
||||
|
||||
### 12. Multiview Paginator Side Effects
|
||||
**Pattern:** The multiview paginator (e925e9d+) loads adjacent sections in background. Events from these loads can interfere with user interactions on the primary section.
|
||||
**Examples:**
|
||||
- `load` event from adjacent section triggers `docLoadHandler` which re-adds ALL annotations, overwriting drag edits
|
||||
- Multiple overlayers with duplicate SVG `<clipPath>` IDs cause `url(#id)` to resolve to wrong element
|
||||
- `MagnifierLoupe` destroying/recreating body clone on every drag tick triggers ResizeObserver → expand → redraw
|
||||
|
||||
**Fix Strategy:** Scope event handlers to the loaded section's index. Use unique IDs for SVG elements across overlayer instances. Minimize iframe DOM mutations during drag operations.
|
||||
|
||||
### 13. Whole-Field-Synced Flag Reaches an Unsupported Platform
|
||||
**Pattern:** A setting is whole-field synced across devices (e.g. `dictionarySettings.providerEnabled`), so a flag enabled on one platform arrives `true` on a platform where that feature isn't supported. The lookup/runtime path correctly gates on platform support, but a *secondary consumer* (usually UI gating) reads the raw synced flag and misbehaves.
|
||||
**Example:**
|
||||
- System Dictionary enabled on macOS synced to web → web's `CustomDictionaries.tsx` locked all other dictionary toggles read-only (`lockedBySystem`) even though System Dictionary is hidden + a no-op there. The annotator's lookup path used the platform-gated `isSystemDictionaryEnabled(settings)` (registry.ts, gates on `isSystemDictionarySupported()`), but the settings UI compared the raw `providerEnabled[systemDictionary] === true`.
|
||||
|
||||
**Fix Strategy:** Every consumer of a synced flag for a platform-specific feature must route through the *same* platform-aware gate the runtime uses — not the raw `providerEnabled[...]`/setting value. Here: `lockedBySystem = isSystemDictionaryEnabled(settings) && ...`. Search for other readers of the raw flag when fixing one.
|
||||
|
||||
## Debugging Workflow
|
||||
|
||||
1. **Identify the category** from the issue description
|
||||
2. **Check `style.ts`** first for any CSS-related visual bugs
|
||||
3. **Check foliate-js** for rendering/layout bugs
|
||||
4. **Check platform-specific code** for mobile/desktop differences
|
||||
5. **Write a failing test** before implementing the fix
|
||||
6. **Test in both paginated and scrolled modes** for layout changes
|
||||
7. **Test on multiple platforms** for any UI change
|
||||
8. **Run `pnpm build-check`** before submitting
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: cdp-android-webview-profiling
|
||||
description: "How to drive the Android WebView via CDP (adb) to run JS probes/benchmarks inside the live Readest app, and the gotchas that waste time"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: reference
|
||||
originSessionId: 8057ac9c-2e3e-446d-86aa-29baddfbfe66
|
||||
---
|
||||
|
||||
Driving the on-device Readest WebView via CDP to run JS probes/benchmarks **inside the live app** (no rebuild) — used for the NativeFile/RemoteFile I/O study ([[android-nativefile-remotefile-io]]).
|
||||
|
||||
**Setup:** app must be running → `adb shell cat /proc/net/unix | grep webview_devtools_remote_<pid>` → `adb forward tcp:9222 localabstract:webview_devtools_remote_<PID>`. Discover targets with a Node `http.get` to `/json/list` (set header `Host: localhost`) — **curl mishandles the WebView's HTTP framing and hangs/returns empty**. Connect `ws://127.0.0.1:9222/devtools/page/<id>`, then `Runtime.enable` + `Runtime.evaluate {expression:'(async()=>{...})()', awaitPromise:true, returnByValue:true}`. Helper scripts kept in `/tmp/cdp/` (eval.mjs, disc.mjs).
|
||||
|
||||
**Gotchas that burned time:**
|
||||
- **Locked device freezes `fetch`.** When the screen is locked the page is `visible:false`; Chromium freezes the network task queue so EVERY `fetch()` (same-origin and asset) hangs forever — but Tauri `invoke()` still resolves. Must have the user **unlock + keep Readest foregrounded**. Set `svc power stayon true` + `settings put system screen_off_timeout 1800000` after unlock (revert `stayon false` when done).
|
||||
- **`visible:false` also throttles `setTimeout`** (background timer coalescing → ~60 s). Don't rely on setTimeout guards in probes when the page may be hidden; `invoke`-only probes still work hidden.
|
||||
- `window.__TAURI_INTERNALS__` is ALWAYS injected (independent of `withGlobalTauri`) → use `.convertFileSrc(path)` and `.invoke(cmd,args)` from injected JS. Android asset URL = `http://asset.localhost/<encodeURIComponent(path)>`.
|
||||
- Real book files live in **internal** storage (`/data/user/0/com.bilingify.readest/...`), not the external `Android/data/.../files` dir (that's `forbidden path` to the fs plugin). `$APPCACHE` = `/data/user/0/com.bilingify.readest/cache` holds import temp copies (in asset scope `$APPCACHE/**/*`). `adb run-as` is denied on the release build.
|
||||
- fs plugin invokes: `plugin:fs|open{path,options}→rid`, `seek{rid,offset,whence}` (Start=0), `read{rid,len}`→ArrayBuffer whose **last 8 bytes are bigendian nread**, `close{rid}` (**not ACL-allowed** in the installed build). `read_dir`/`stat` on out-of-scope abs paths return `forbidden path`. Tauri v2 `BaseDirectory`: AppData=14, AppLocalData=15, AppCache=16.
|
||||
- zsh: `$PIPESTATUS[0]` is a bash-ism (empty in zsh; use `$pipestatus[1]`) — don't trust it for exit codes.
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
name: ci-pr-delivery-and-push
|
||||
description: Delivering small PRs from a dirty dev tree without a worktree; the slow pre-push hook + proxy SSH-drop and its keepalive fix
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: c1097233-8b53-422a-98ec-3f0146f32f6b
|
||||
---
|
||||
|
||||
How CI/config PRs get delivered in this repo when `dev` has unrelated uncommitted WIP, and the push gotcha.
|
||||
|
||||
**Packaging a commit onto a fresh PR branch WITHOUT `pnpm worktree:new`** (the worktree script does a full `pnpm install` + `tauri android init` + icon gen — disproportionate for a YAML/package.json-only PR, and you can't `git checkout` a branch in the dev tree because the user's WIP blocks it):
|
||||
1. Edit the target files in the dev tree (only files NOT in the user's WIP set — verify with `git status --short -- <files>`), `git add` just those, commit on `dev` (mirrors how the user wanted the pin committed).
|
||||
2. Re-parent onto `origin/main` (or onto the existing PR-branch tip for a fast-forward add) via a temp index — no checkout, no worktree, dev working tree untouched:
|
||||
```
|
||||
export GIT_INDEX_FILE=$(mktemp); git read-tree <BASE>
|
||||
git update-index --cacheinfo 100644,$(git rev-parse HEAD:<path>),<path> # per changed file
|
||||
TREE=$(git write-tree); unset GIT_INDEX_FILE
|
||||
NEW=$(git log --format=%B -n1 HEAD | git commit-tree $TREE -p <BASE>)
|
||||
git update-ref refs/heads/<branch> $NEW
|
||||
```
|
||||
3. Verify `git diff --stat <BASE>..<branch>` shows ONLY the intended files, then push.
|
||||
|
||||
This is how PR #4547 (pin `android-emulator-runner` + shard `test_web_app`) was built on top of `origin/main` while `dev` carried 49 files of unrelated dictionary/goodreads WIP.
|
||||
|
||||
**Push gotcha (now fixed in `~/.ssh/config`):** `git push` opens the SSH connection BEFORE running the pre-push hook; the husky hook runs the FULL vitest suite (~55s, 5271 tests) + format + lint. The user pushes through a SOCKS proxy (`nc -x 127.0.0.1:8119` → `ssh.github.com:443`), so the idle connection got dropped during the hook → "Broken pipe", ref never transferred (remote stayed at old SHA — always `git ls-remote` to confirm). Fix added: `ServerAliveInterval 15` + `ServerAliveCountMax 60` under `Host github.com`. Also: **`--no-verify` is safe once the hook has already passed** on the same tree — re-running it just re-opens the idle window. See also [[feedback_dont_push_every_change]], [[feedback_use_worktree]].
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
name: Cloudflare Workers WebSocket
|
||||
description: How to open and read WebSockets from Cloudflare Workers (the Node `ws` package does not work) and the Blob binary-frame gotcha
|
||||
type: project
|
||||
originSessionId: ec3d5424-adc2-4fca-836f-df323797489c
|
||||
---
|
||||
# Cloudflare Workers WebSocket on readest-app
|
||||
|
||||
## Why the Node `ws` package fails
|
||||
|
||||
The Node `ws` npm package (used transitively by `isomorphic-ws`) opens WebSockets by calling `http.request({ createConnection })`. The Cloudflare Workers runtime does not implement `options.createConnection`, so any attempt to `new WebSocket(url, { headers })` in a Worker throws:
|
||||
|
||||
```
|
||||
The options.createConnection option is not implemented
|
||||
```
|
||||
|
||||
This applies even with `compatibility_flags = ["nodejs_compat"]`.
|
||||
|
||||
## Correct pattern: fetch-based upgrade
|
||||
|
||||
On Workers you open a WebSocket by calling `fetch()` with an `Upgrade: websocket` header against the **https://** (not `wss://`) form of the URL. The response has `status === 101` and a non-standard `webSocket` property that must be `accept()`ed before use:
|
||||
|
||||
```ts
|
||||
const upgradeUrl = url.replace(/^wss:\/\//i, 'https://');
|
||||
const response = (await fetch(upgradeUrl, {
|
||||
headers: { ...baseHeaders, Upgrade: 'websocket' },
|
||||
})) as Response & { webSocket?: WebSocket & { accept(): void } };
|
||||
|
||||
if (response.status !== 101 || !response.webSocket) {
|
||||
throw new Error(`WebSocket upgrade failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const ws = response.webSocket;
|
||||
ws.addEventListener('message', onMessage);
|
||||
ws.accept();
|
||||
ws.send(payload);
|
||||
```
|
||||
|
||||
Detect the Workers runtime with `typeof globalThis.WebSocketPair !== 'undefined'` — `WebSocketPair` is a Workers-only global.
|
||||
|
||||
## Binary frames arrive as Blob (critical)
|
||||
|
||||
Cloudflare Workers deliver WebSocket binary frames as **`Blob`** — not `ArrayBuffer` (browsers) and not `Uint8Array` (Node `ws`). Blob decoding is async via `blob.arrayBuffer()`, so:
|
||||
|
||||
1. You must serialize decodes through a promise chain to keep frames in receive order — otherwise parallel awaits can merge bytes out of order.
|
||||
2. Any terminal text message (e.g. Edge TTS's `Path: turn.end`) arrives **synchronously** and will finalize the stream before the in-flight Blob decodes have flushed. Always `await pendingBinary` in the turn.end handler and the close handler before checking whether data was received.
|
||||
|
||||
Example skeleton:
|
||||
|
||||
```ts
|
||||
let pending: Promise<void> = Promise.resolve();
|
||||
const enqueue = (getBuf: () => Promise<ArrayBufferLike> | ArrayBufferLike) => {
|
||||
pending = pending.then(async () => {
|
||||
const buf = await getBuf();
|
||||
appendBinary(buf);
|
||||
});
|
||||
};
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
const data = event.data;
|
||||
if (data instanceof Blob) enqueue(() => data.arrayBuffer());
|
||||
else if (data instanceof ArrayBuffer) enqueue(() => data);
|
||||
else if (data instanceof Uint8Array) enqueue(() => data.buffer.slice(
|
||||
data.byteOffset, data.byteOffset + data.byteLength,
|
||||
));
|
||||
// ... handle text path: turn.end
|
||||
// -> await pending, then resolve
|
||||
});
|
||||
```
|
||||
|
||||
## Where this is used
|
||||
|
||||
`src/libs/edgeTTS.ts` `#fetchEdgeSpeechWs` has three branches: Tauri (plugin-websocket), Cloudflare Workers (fetch upgrade + Blob handling), and browser/Node fallback (`isomorphic-ws`). The route that exercises the CF branch is `src/app/api/tts/edge/route.ts`, hit when the web client falls back from direct `wss://` (which browsers can't set headers on) to the `/api/tts/edge` HTTPS endpoint.
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
name: cover-bg-image-texture-suppression
|
||||
description: Cover painted via body background-image vanished under an active bg texture (parchment) because textureAwareBackground misclassified it as transparent
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 9d32520c-53be-4871-9104-d93617736e30
|
||||
---
|
||||
|
||||
EPUB cover pages that paint the cover via a `<body>` CSS `background-image`
|
||||
(EPUB sets `background-color` transparent + `background-size:100% 100%`, no
|
||||
`<img>` — e.g. Sigil/duokan样书《商梯》) showed the **background texture instead
|
||||
of the cover** on the first page. Reported "Xiaomi only" but it's
|
||||
texture-only, not Android-only.
|
||||
|
||||
Root cause (verified on-device via adb+CDP, Xiaomi 13 WV147): foliate
|
||||
`packages/foliate-js/paginator.js` `textureAwareBackground(resolved, hasTexture)`.
|
||||
foliate captures the body bg into `view.docBackground` via
|
||||
`getComputedStyle(body).background` (the SHORTHAND), which always serializes the
|
||||
transparent background-*color* first: `rgba(0, 0, 0, 0) url("blob:…") no-repeat
|
||||
fixed 50% 50% / 100% 100% …`. The old `isTransparent` regex
|
||||
`/^\s*(transparent|rgba\(0,\s*0,\s*0,\s*0\))/` matched that prefix → under an
|
||||
active texture (`--bg-texture-id` != none) it returned `''` → no bg segment in
|
||||
the host `#background` → texture (`.foliate-viewer::before`) showed through. With
|
||||
no texture it worked (returns the cover bg unchanged), which is why desktop/
|
||||
default looked fine.
|
||||
|
||||
Fix: a bg that carries an image is NOT transparent. Add `hasImage =
|
||||
/\burl\(/i.test(resolved)` and gate `isTransparent` on `!hasImage`. A full-page
|
||||
cover should occlude the texture; plain `none` transparent pages still drop so
|
||||
the texture shows through. Helps scrolled (line ~1464) and paginated (~1482)
|
||||
callers alike. Test: `paginator-background-segments.test.ts` (added the
|
||||
url()-keeps case; kept the existing `none`-drops case).
|
||||
|
||||
NOT the bug (ruled out on-device): Rust `parse_epub_metadata` cover EXTRACTION
|
||||
(library thumbnail was correct), shorthand serialization (WV147 emits the url
|
||||
fine), the cover blob URL (loads 1200x1800 fine), `background-attachment:fixed`
|
||||
(Android falls back to scroll but the segment sets `background-attachment:
|
||||
initial` anyway). Related: [[paginated-texture-occlusion-4399]],
|
||||
[[dark-mode-texture-body-bg-4446]], [[paginator-swipe-bg-flash]].
|
||||
|
||||
CDP verify recipe: pid changes per app restart — re-derive socket from
|
||||
`/proc/net/unix` (`webview_devtools_remote_<pid>`), `adb forward tcp:9333
|
||||
localabstract:…`; curl mishandles WV HTTP framing → raw-socket fetch `/json`;
|
||||
pure-python WS client (omit Origin for M111+); paint a 50%-width test segment
|
||||
with the cover blob bg into `#background` + `Page.captureScreenshot` to see
|
||||
cover-vs-texture side by side.
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: cover-stale-inplace-mutation-memo
|
||||
description: Library cover (or any memoized child) not updating until refresh — in-place object mutation defeats React.memo
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: ec78c172-79e7-448c-8671-780dcc115613
|
||||
---
|
||||
|
||||
Symptom: edit a book's cover in Book Details → Save → return to library, the cover shows the OLD image; a full page refresh fixes it. Title/author DO update.
|
||||
|
||||
Root cause: `handleUpdateMetadata` in `src/app/library/page.tsx` mutated the existing `book` object IN PLACE (`book.metadata = …; book.coverImageUrl = …; book.updatedAt = …`) then passed that same reference to `updateBook`. `<BookCover>` (`src/components/BookCover.tsx`) is `React.memo`'d with a custom comparator reading `coverImageUrl`/`metadata.coverImageUrl`/`updatedAt` off the book. Because the previous-render snapshot (`prevProps.book`) is the *same mutated object*, every field compares equal → memo skips → cover never re-renders. Title updates because `BookItem` is NOT memoized. Refresh works because `loadLibraryBooks` (`libraryService.ts`) strips `coverImageUrl` on save and REGENERATES it from `${hash}/cover.png` on load (the file was overwritten by `updateCoverImage`).
|
||||
|
||||
KEY INSIGHT: cloning inside `updateBook` would NOT fix it — once the original object is mutated, `prevProps` already reads the new values. The fix must leave the object React holds as `prevProps` untouched.
|
||||
|
||||
Fix (PR for fix/txt-open-with-conversion): pure helper `getBookWithUpdatedMetadata(book, metadata)` in `src/utils/book.ts` returns a NEW book object (`{...book, metadata, title, author, primaryLanguage, updatedAt, coverImageUrl}`); `handleUpdateMetadata` uses it instead of mutating. Cover URL is set from `metadata.coverImageBlobUrl || metadata.coverImageUrl` (cached/blob URL is a unique path = the new image; `'_blank'` for remove). Unit test asserts immutability of the input + new reference.
|
||||
|
||||
General rule: when a memoized child reads fields off a store object, NEVER mutate that object in place to "update" it — build a new object. Same trap could bite any `React.memo` field comparator in this codebase.
|
||||
|
||||
On-device CDP verification (reusable): the cover SET path uses the native Tauri file picker (`selectFiles` → `openDialog`), NOT automatable via CDP; the installed emulator app is the released bundled build (`http://tauri.localhost/...`), not the dev server. So I verified the MECHANISM directly in the live WebView: extracted the zustand library store from the React fiber tree (the library page calls `useLibraryStore()` with NO selector, so its fiber hook `memoizedState` holds the full state incl. `library`/`setLibrary`/`updateBook`), injected an Alice book, then A) mutated it in place + `setLibrary([sameRef])` → rendered `<img src>` stayed stale (bug), B) `setLibrary([{...book,coverImageUrl:NEW}])` → `<img src>` updated immediately (fix). Restore with `Page.reload` (injected book was in-memory only). See [[cdp-android-webview-profiling]], [[android-cdp-e2e-lane]].
|
||||
@@ -1,74 +0,0 @@
|
||||
# CSS & Style Fixes Reference
|
||||
|
||||
## The `style.ts` Pipeline (`src/utils/style.ts`)
|
||||
|
||||
This is the most bug-prone file in the codebase (14+ fixes). It handles all EPUB CSS transformations.
|
||||
|
||||
### Key Functions
|
||||
|
||||
#### `getLayoutStyles()`
|
||||
- Always-active styles applied to every EPUB section
|
||||
- Controls: line-height, hyphens, image sizing, table display
|
||||
- Rules here should NOT be conditional on user settings
|
||||
- Common mistake: putting color-related rules here instead of `getColorStyles()`
|
||||
|
||||
#### `getColorStyles()`
|
||||
- Conditionally applied when user enables "Override Book Color"
|
||||
- Controls: foreground/background colors, mix-blend modes, image backgrounds
|
||||
- Gate rules on `overrideColor` flag
|
||||
|
||||
#### `transformStylesheet()`
|
||||
- Regex-based rewriting of EPUB CSS at load time
|
||||
- Runs on every stylesheet loaded from the EPUB
|
||||
- Used to neutralize problematic EPUB CSS declarations
|
||||
|
||||
#### `applyTableStyle()`
|
||||
- Post-render function that scales tables to fit available width
|
||||
- Uses `getComputedStyle()` (not inline `style.width`) to read actual width
|
||||
- Has two scaling paths: column-width-based and parent-container-based
|
||||
|
||||
### Fix History by Issue
|
||||
|
||||
| Issue | Problem | Fix in style.ts |
|
||||
|-------|---------|-----------------|
|
||||
| #3494 | Line spacing not on `<li>` | Added `li` CSS rule for `line-height` and `hyphens` |
|
||||
| #3448 | Calibre colors persist | Moved `.calibre` unset to `getColorStyles()`, added `background-color: unset` |
|
||||
| #3441 | Body padding/margin | Added `padding: unset; margin: unset` to body in `getLayoutStyles()` |
|
||||
| #3316 | Image bg unconditional | Made `background-color` rule conditional on `overrideColor` |
|
||||
| #3377 | Image bg override | Same pattern as #3316, only override when `overrideColor` is true |
|
||||
| #3334 | Generic font-family | `transformStylesheet()` replaces `font-family: serif/sans-serif` with `unset` on body |
|
||||
| #3370 | user-select: none | `transformStylesheet()` replaces all `user-select: none` with `unset` |
|
||||
| #3284 | Table scaling | Added fallback when `totalTableWidth` is 0 but parent has width |
|
||||
| #3351 | Table display broken | Added `display: table !important` to table rule |
|
||||
| #3274 | Image dimensions | Changed selectors to `:where(:not([width]))` and `:where(:not([height]))` |
|
||||
| #3205 | Table width reading | Changed from `style.width` to `getComputedStyle().width`, fixed CSS var unit |
|
||||
| #3112 | Mix-blend on all images | Narrowed selector to `.has-text-siblings` class |
|
||||
| #3086 | Mix-blend on hr | Narrowed selector to `hr.background-img` |
|
||||
| #3012 | Vertical alignment | Fixed available dimensions (subtract insets), replaced `100vw/vh` with CSS vars |
|
||||
|
||||
### Common Patterns
|
||||
|
||||
1. **Adding new element rules:** Copy the pattern from `p` rules (e.g., adding `li` for #3494)
|
||||
2. **EPUB CSS neutralization:** Add regex in `transformStylesheet()` to replace problematic declarations
|
||||
3. **Conditional overrides:** Use `overrideColor`/`overrideLayout` flags to gate rules
|
||||
4. **Selector narrowing:** Use class qualifiers or attribute pseudo-selectors to avoid over-matching
|
||||
5. **Table fixes:** Always use `getComputedStyle()`, not inline style. Check both width paths.
|
||||
|
||||
### CSS Variables from foliate-js
|
||||
|
||||
- `--available-width` - Usable content width (set by paginator.js)
|
||||
- `--available-height` - Usable content height
|
||||
- `--full-width` - Full viewport width (numeric, multiply by 1px)
|
||||
- `--full-height` - Full viewport height (numeric, multiply by 1px)
|
||||
- `--overlayer-highlight-opacity` - Highlight transparency (default 0.3)
|
||||
|
||||
### foliate-js Rendering (`packages/foliate-js/paginator.js`)
|
||||
|
||||
Key functions:
|
||||
- `columnize()` - Paginated layout path
|
||||
- `scrolled()` - Scrolled layout path
|
||||
- `setImageSize()` - Constrains image dimensions to available space
|
||||
- `#replaceBackground()` - Transfers EPUB backgrounds to paginator layer
|
||||
- `snap()` - Swipe gesture detection for page turning
|
||||
|
||||
Common issue: A fix applied to `columnize()` but not `scrolled()` (or vice versa). Always check both paths.
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: custom-fonts-reincarnation-4410
|
||||
description: Custom fonts/textures disappear when logged into cloud sync after re-import-after-delete; CRDT remove-wins needs a reincarnation token
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 2b61b392-4d32-4516-84bd-f362bba22378
|
||||
---
|
||||
|
||||
# #4410 — disappearing custom fonts when logged into cloud
|
||||
|
||||
**Symptom:** custom fonts vanish a few seconds after opening a book (or ~1 min idle) ONLY when logged into cloud sync; logging out fixes it; a brand-new never-deleted font is fine; problem starts after deleting a font / "Clear Custom Fonts" then re-uploading the same file.
|
||||
|
||||
**Root cause — CRDT remove-wins.** The replica sync (`src/libs/crdt.ts`, `src/libs/replicaInterpret.ts`) is remove-wins: once a row has a `deleted_at_ts` tombstone, a plain field upsert does NOT revive it. Only a `reincarnation` token whose effective HLC beats the tombstone revives it. `isReplicaRowAlive(row)` = `!deleted_at_ts || (reincarnation && updated_at_ts >= deleted_at_ts)`. In `mergeReplica` the reincarnation candidate's timestamp is the **row's `updated_at_ts`** (fresh on every upsert), NOT the token's mint time — so preserving an old token still revives, as long as the upsert carries it.
|
||||
|
||||
Flow that broke: import→delete writes a server tombstone (`publishReplicaDelete`). Re-upload same file → same `contentId` → `addFont` cleared `deletedAt` locally and called `publishFontUpsert` with `reincarnation = undefined` → server tombstone survives → next pull (boot 5s / periodic / book-open / visibility) sees `isReplicaRowAlive===false` → `softDeleteByContentId` → font disappears.
|
||||
|
||||
**Fix (PR for #4410):** in `addFont` (`src/store/customFontStore.ts`) and `addTexture` (`src/store/customTextureStore.ts`), when re-adding an existing entry, mint a reincarnation token (`Math.random().toString(36).slice(2)`, matching OPDS) when `!!contentId && !existing.reincarnation && (existing.deletedAt || existing.contentId === new.contentId)`; otherwise preserve `existing.reincarnation`. Covers both re-import-after-local-delete AND the stale-local race (local still live but another device tombstoned the row). Token is inert without a tombstone, so live re-imports are safe.
|
||||
|
||||
**Coverage matrix across collection kinds (all share the remove-wins replica):**
|
||||
- Dictionary — handles BOTH cases (gold standard): `dictionaryService.ts importDictionaries` via `findTombstonedDictionaryMatches` + `shouldMintReincarnationForLiveReimport` (helpers in `dictionaries/dictionaryDedup.ts`), mints `uuidv4()`.
|
||||
- OPDS — case 1 only: `customOPDSStore.addCatalog` (`existing?.deletedAt && !input.reincarnation`).
|
||||
- Fonts / Textures — handled NEITHER → this bug. Now fixed to dictionary-parity.
|
||||
|
||||
Whole chain carries the token: returned font → `publishFontUpsert` upsert AND `queueReplicaBinaryUpload` → manifest publish (`replicaBinaryUpload.ts` uses `record.reincarnation`).
|
||||
|
||||
Note: `saveCustomFonts`/`saveCustomTextures` persist tombstoned (deletedAt) entries too, so the soft-deleted entry is still in the store at re-import time → the `existing.deletedAt` branch fires. (OPDS strips deleted at save; fonts/textures keep them.)
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
name: dark-mode-texture-body-bg-4446
|
||||
description: "#4446 dark-mode bg texture occluded by body.theme-dark opaque bg !important (style.ts getDarkModeLightBackgroundOverrides); verified on Xiaomi via CDP; multiview = patch ALL section iframes"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 61f864c0-488e-466c-89e9-86df66b57d42
|
||||
---
|
||||
|
||||
RESOLVED — PR #4564 MERGED 2026-06-12 (`fix/dark-mode-texture-4446`, built on origin/main
|
||||
via temp-index plumbing from the dirty dev tree; branch + local mods cleaned after merge).
|
||||
User-verified on the Xiaomi via `pnpm dev-android` (full aarch64 build + `adb install -r`,
|
||||
~7 min).
|
||||
|
||||
#4446 remaining case (after [[paginated-texture-occlusion-4399]] fixed light mode): in
|
||||
**dark mode** the bg texture is absent in paginated entirely, and absent in the scrolled
|
||||
text area while header/footer still show it.
|
||||
|
||||
**Root cause (verified live on Xiaomi 2211133C via CDP, no rebuild).**
|
||||
`getDarkModeLightBackgroundOverrides` (style.ts ~194) emits
|
||||
`body.theme-dark { background-color: ${bg} !important; }`, applied when
|
||||
`isDarkMode && !overrideColor` (style.ts ~320). That paints every section iframe's body
|
||||
opaque dark (`rgb(34,34,34)`), which occludes the host `.foliate-viewer::before` texture.
|
||||
Downstream it also poisons foliate: `resolveBackground(view.docBackground)` resolves the
|
||||
opaque body color, so `textureAwareBackground` keeps the paginated `#background` segment
|
||||
and the scrolled `view.element` inline bg opaque too. The #4399 fix is intact (container
|
||||
is transparent); this is a second, dark-mode-only occluder one layer deeper.
|
||||
|
||||
- Paginated: segment + iframe body span the full viewer → no texture anywhere.
|
||||
- Scrolled: iframes cover only the text column → header/footer strips keep texture
|
||||
(grid-cell + the #4486 notch overlay, see [[notch-mask-texture-4486]]).
|
||||
|
||||
**Proof:** injecting `body.theme-dark{background-color:transparent !important}` into ALL
|
||||
section iframes + clearing the segment/view inline bgs reveals the leaves texture in both
|
||||
modes instantly.
|
||||
|
||||
**Regression source (git-proven):** commit `176b950c9` = PR #4392 (2026-06-01, shipped
|
||||
v0.11.4) added the rule. NOT foliate-js — the foliate swipe-flash regression (#4399,
|
||||
167757a→142bf11) broke LIGHT-mode textures in the same release window, which is why it
|
||||
looked like one. The transformStylesheet light-bg rewriter (also #4392) was EXONERATED
|
||||
for the repro book: Alice's stylesheets have zero body/html background rules (verified by
|
||||
on-device stylesheet enumeration). **No #4392 revert needed** — its callout attribute
|
||||
selectors + rewriter fix real legibility bugs (#4028; #4419/#4426 build on them).
|
||||
|
||||
**Fix (applied):** make the rule `background-color: transparent !important` —
|
||||
UNCONDITIONALLY, not gated on hasBackgroundTexture, because foliate captures
|
||||
`docBackground` once per section load (paginator.js `load` listener; `setStyles` re-runs
|
||||
`#replaceBackground` but never re-captures), so a texture-gated body bg would go stale on
|
||||
live texture toggling. Visuals without texture are identical: the dark fill comes from the
|
||||
paginator container `fallbackBg` / reader grid cell. Book-forced light page bgs stay
|
||||
neutralized (theme-dark fill shows through); page-level rewriter output is cascade-beaten
|
||||
by our `!important` body rule and later-in-head `html` rule; only book `!important`
|
||||
page rules survive — consistent with the #4399 "book-forced opaque page wins" policy.
|
||||
Test: `style-get-styles.test.ts` "#4446" cases. E2E device-verified: with the fixed CSS
|
||||
present at load, capture is transparent and the paginated segments array comes out EMPTY.
|
||||
|
||||
**Verification gotchas (cost ~30 min):**
|
||||
- **Multiview!** The renderer shadow root holds MULTIPLE section iframes (adjacent preload).
|
||||
Patching `sr.querySelector('iframe')` hits a section possibly 18k px off-viewport →
|
||||
"fix didn't work". Patch every `sr.querySelectorAll('iframe')`.
|
||||
- `elementsFromPoint` reports the iframe ELEMENT's computed bg (transparent) — the
|
||||
occluding paint is its content document's body, invisible to the top-doc/shadow stack walk.
|
||||
- Switching `renderer.setAttribute('flow', ...)` can reload section docs and silently wipe
|
||||
styles injected into them (but not always — re-check after every flow switch).
|
||||
- Pseudo-element paint test: patch the `#background-texture` style text with
|
||||
`background-color: red` — if red doesn't show, the pseudo is occluded, not broken.
|
||||
- **Stale preload views**: after patching styles via `renderer.setStyles`, views loaded
|
||||
PRE-patch keep their opaque `docBackground` and `#clearViewsExcept` keeps `|i−index|≤2`
|
||||
across navigation — an opaque segment can come from a kept old view, not the fresh one.
|
||||
Jump ≥3 sections to guarantee fresh captures.
|
||||
- **Capture-time instrumentation**: the paginator dispatches `load` synchronously right
|
||||
BEFORE `docBackground = getBackground(doc)` — an event listener on the renderer sees
|
||||
exactly what the capture will see (class list, computed bgs, active rules).
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
name: dblclick-drag-pageturn-4524
|
||||
description: Web double-click-and-drag selection turned the page; deferred single-click fired mid-drag while button held
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 5fe20151-9768-4e7c-9cee-2aa25da5318c
|
||||
---
|
||||
|
||||
#4524: on Readest Web, **double-click a word then drag** to extend the native
|
||||
selection also **turned the page** (a plain double-click did not). The user
|
||||
expects browser-native double-click-drag word-by-word selection without a page
|
||||
turn.
|
||||
|
||||
**Root cause** (`src/app/reader/utils/iframeEventHandlers.ts` `handleClick`):
|
||||
the first click of a potential double-click schedules a deferred
|
||||
`postSingleClick()` after `DOUBLE_CLICK_INTERVAL_THRESHOLD_MS` (250ms).
|
||||
- Plain double-click: the 2nd `click` fires fast, updates `lastClickTime`, posts
|
||||
`iframe-double-click`; when the 1st click's timer fires, the
|
||||
`Date.now() - lastClickTime >= 250` check is now false → single-click
|
||||
suppressed → no page turn.
|
||||
- Double-click **+ drag**: the user holds the button down on the 2nd click and
|
||||
drags, so the 2nd `mouseup`/`click` is delayed past 250ms. At first-click+250ms
|
||||
`lastClickTime` is still the 1st click → check passes → `iframe-single-click`
|
||||
posted **while the button is still held** → `usePagination.handlePageFlip`
|
||||
turns the page.
|
||||
|
||||
**Fix**: module-level `isMouseDown` flag (set in `handleMousedown`, cleared in
|
||||
`handleMouseup`); the deferred `postSingleClick()` returns early when
|
||||
`isMouseDown` is true (a drag is in progress). Cannot affect a normal single
|
||||
click — `isMouseDown` is false by the time its deferred timer fires; only a
|
||||
held button (drag) suppresses it.
|
||||
|
||||
**Verification gotcha**: reproduced live by dispatching synthetic
|
||||
mousedown/mouseup/click to the reading iframe doc (found via deep shadow-DOM
|
||||
walk; the foliate iframe sits in nested shadow roots, `document.querySelectorAll('iframe')`
|
||||
returns 0). Watch `iframe-single-click` on `window` 'message' + the
|
||||
`.progress-info-label` "N / M" page text. NOTE: back-to-back synthetic gestures
|
||||
share the module's real `setTimeout` deferrals and `lastClickTime`, so a
|
||||
follow-up "normal single click" repro can spuriously show no single-click — the
|
||||
vitest unit test (fake timers) is the authoritative regression check, not
|
||||
chained browser repros. Iframe listeners are attached once
|
||||
(`detail.doc.isEventListenersAdded`), so a full page reload is required to pick
|
||||
up edits — Fast Refresh won't re-bind them.
|
||||
|
||||
Test: `src/__tests__/reader/utils/iframeEventHandlers.test.ts`. Related:
|
||||
[[foliate-touch-listener-capture-phase]], [[progressbar-focus-ring-4397]].
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: dependabot-pnpm-overrides
|
||||
description: How to fix transitive-dependency Dependabot/CVE alerts in the readest monorepo (pnpm overrides location + style)
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: cdc9c728-a2c7-4a9e-b87a-44046560a4fa
|
||||
---
|
||||
|
||||
Transitive npm security advisories (Dependabot alerts against `pnpm-lock.yaml`) are fixed by pinning a **minimum patched version** in the `overrides:` block of **`pnpm-workspace.yaml`** at the monorepo root — NOT `package.json`'s `pnpm.overrides`.
|
||||
|
||||
**Why:** the repo uses pnpm 9+ (`pnpm@11.x`), which reads `overrides`/`patchedDependencies`/`catalog` from `pnpm-workspace.yaml`. A `pnpm.overrides` block added to root `package.json` is silently ignored — `pnpm install` runs fast and the lockfile doesn't change. There is already a long list of security pins in that `overrides:` block (glob, undici, qs, body-parser, etc.).
|
||||
|
||||
**How to apply:**
|
||||
1. `gh api repos/readest/readest/dependabot/alerts/<N>` → get package + `first_patched_version` + vulnerable range.
|
||||
2. Confirm parent ranges allow the patch (`cat node_modules/.pnpm/<parent>@*/.../package.json | grep '"<pkg>"'`).
|
||||
3. Add/raise the entry in `pnpm-workspace.yaml` `overrides:` in the existing style: `<pkg>: '>=<patched>'` (e.g. `shell-quote: '>=1.8.4'`). **Check for an existing too-low pin** — e.g. `qs: '>=6.14.2'` still allowed the vulnerable 6.15.1; had to raise to `'>=6.15.2'`.
|
||||
4. `pnpm install --lockfile-only` then `pnpm install`. Verify with `pnpm why -r <pkg>` (should show only the patched version). Stale dirs may linger in `node_modules/.pnpm` but are harmless if the lockfile has zero refs to the old version.
|
||||
5. Dependabot **alert numbers are not GitHub issue numbers** — don't use `Closes #N`; alerts auto-dismiss when the vulnerable version leaves the default-branch lockfile. Reference the `/security/dependabot/<N>` URLs in the PR body instead.
|
||||
|
||||
First done in PR #4523 (shell-quote 1.8.4 / GHSA-w7jw-789q-3m8p, qs 6.15.2 / GHSA-q8mj-m7cp-5q26). Diff stays scoped to just the bumped packages; prefer this over `pnpm update <pkg>` which incidentally refreshes unrelated in-range patches (e.g. react-is). See [[feedback_pr_new_branch]] [[feedback_use_worktree]].
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: deploy-workers-dev-sni-proxy
|
||||
description: "pnpm deploy crashes in China — workers.dev SNI-blocked, wrangler ws WebSocket bypasses http_proxy; fix = NODE_OPTIONS preload"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 65342d98-7939-41ed-9e10-2efc466946b1
|
||||
---
|
||||
|
||||
`pnpm deploy` (and `pnpm upload`) crashed for chrox (behind GFW, Privoxy at `http://127.0.0.1:8118`) with an **unhandled `ws` `'error'` event → Node process crash** (ETIMEDOUT to Facebook/Vultr/Twitter IPs).
|
||||
|
||||
**Trigger:** `opennextjs-cloudflare deploy`/`upload` ALWAYS runs `populateCache({target:"remote"})` BEFORE the real deploy (no skip flag; only `cacheChunkSize`/`env` knobs). That step calls wrangler's `unstable_startWorker({remote:true})`, which opens a **WebSocket** to a `*.workers.dev` edge host. (`preview` uses `target:"local"` → unaffected.)
|
||||
|
||||
**Root cause (two layers):**
|
||||
1. `*.workers.dev` is **SNI-blocked** by the GFW, not merely DNS-poisoned. Proof: encrypted DoH gives the REAL Cloudflare IPs (104.18.x), but a *direct* TLS connect to that correct IP with SNI=workers.dev is still `Connection reset by peer` before TLS starts. So **DoH/dnscrypt-proxy does NOT help** — the connection must avoid being made directly at all.
|
||||
2. wrangler's REST calls honor `http_proxy` (undici `ProxyAgent`/`EnvHttpProxyAgent`), but the raw `ws` handshake falls back to `https.globalAgent` and **ignores proxy env**. So it connects directly → SNI reset → crash. The crash fires async (unhandled WS 'error'), so `opennextjs-cloudflare deploy`'s `await` can't catch it.
|
||||
|
||||
**Attempt 1 — proxy preload (tried, then REMOVED):** a zero-dep preload that replaced `https.globalAgent` with a `CONNECT`-tunnel agent (CONNECT hides the SNI = defeats the block + does remote DNS; loopback bypassed so the local populate worker on 127.0.0.1 still works). Verified `https.get('https://workers.dev')` → 301 via proxy. This got PAST the WebSocket crash — the local populate worker started and enumerated all 17 cache assets — **BUT the actual R2 writes through the remote binding then timed out** ("Failed to send request to R2 worker: aborted due to timeout", retrying forever). The proxy establishes the connection but can't reliably carry the sustained cache-write traffic. So the preload alone is NOT sufficient. Deleted it.
|
||||
|
||||
**Attempt 2 — replicate `wrangler deploy` in the npm script (tried, then reverted):** skip populate by bypassing `opennextjs-cloudflare deploy` and running `CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV=false OPEN_NEXT_DEPLOY=true wrangler deploy` directly (traced from `runWrangler`: stock deploy's real step is plain `wrangler deploy` vs `wrangler.toml` which has `main=.open-next/worker.js`+all bindings; no generated config/skew mapping; the env flag stops wrangler 4.x auto-loading `.env`/`.dev.vars` into the worker — OpenNext's adapter handles env). Works, but hacky (replicates internals, drift risk).
|
||||
|
||||
**Fix that SHIPPED — config flag (cleanest).** populateCache is gated by `if (!config.dangerous?.disableIncrementalCache && incrementalCache)`. So in `open-next.config.ts`: `config.dangerous = { ...config.dangerous, disableIncrementalCache: true }`. This makes the STOCK `opennextjs-cloudflare deploy`/`upload` skip populate (no script hack, no env flag, no drift) — reverted package.json to stock. Caveat: it's the SAME flag the runtime reads, so it ALSO disables the runtime incremental cache — **but readest uses ZERO ISR (no `revalidate`/`unstable_cache`/`'use cache'`/`generateStaticParams`), so runtime caching is a no-op anyway → no real loss.** Re-enable = delete the one line (from a network that can reach the CF edge). `defineCloudflareConfig` returns `OpenNextConfig` (broad type; `dangerous.disableIncrementalCache?: boolean` exists), tsgo+biome clean. `preview` was always fine (local populate).
|
||||
|
||||
Related: [[turbopack-build-cache-oom-docker-standalone]], [[r2-rclone-createbucket-403]].
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
name: deps-security-overrides-workflow
|
||||
description: "How to fix transitive npm Dependabot alerts in the readest monorepo (pnpm-workspace overrides, where config lives, tauri-plugins is separate)"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: reference
|
||||
originSessionId: c61e7dd2-4033-4bd1-8f32-22056e4ef322
|
||||
---
|
||||
|
||||
Fixing transitive npm Dependabot security alerts (manifest `pnpm-lock.yaml`).
|
||||
|
||||
**Where pnpm config lives (non-obvious):** the MAIN monorepo's `overrides`,
|
||||
`patchedDependencies`, `onlyBuiltDependencies`, `allowBuilds` are in
|
||||
**`pnpm-workspace.yaml`** (newer pnpm style) — NOT the root `package.json`
|
||||
(root `package.json` has no `pnpm` section). The root `pnpm-lock.yaml` is what
|
||||
Dependabot scans; alerts report manifest `pnpm-lock.yaml` = this root lockfile.
|
||||
|
||||
**`packages/tauri-plugins` is a SEPARATE project**, not part of the main pnpm
|
||||
workspace. It's a git submodule (`tauri-plugins-workspace`) with its OWN
|
||||
`pnpm-lock.yaml` and its own `package.json` `pnpm.overrides` +
|
||||
`minimumReleaseAge: 4320`. The `minimumReleaseAge` (3-day age gate) applies ONLY
|
||||
there — the main monorepo has NO age gate, so `^X` specs resolve to the very
|
||||
latest matching version. Dependabot does not scan the tauri-plugins lockfile.
|
||||
`pnpm-workspace.yaml` `packages:` = `apps/*`, send-email worker, extensions,
|
||||
`packages/foliate-js` (NOT tauri-plugins).
|
||||
|
||||
**Recipe for a transitive advisory:**
|
||||
1. Add `pkg: '>=X.Y.Z'` to the `overrides:` block in `pnpm-workspace.yaml`
|
||||
(forces all transitive instances up). For risky 0.x packages, BOUND it like
|
||||
the existing `vite: '>=7.3.2 <8'` (e.g. `esbuild: '>=0.28.1 <0.29'`).
|
||||
2. For packages that are also DIRECT deps, bump the spec in
|
||||
`apps/readest-app/package.json` too (e.g. the vitest family:
|
||||
`vitest`, `@vitest/browser-playwright`, `@vitest/browser-webdriverio`,
|
||||
`@vitest/coverage-v8` — move in lockstep).
|
||||
3. `pnpm install`, then `grep -oE "pkg@[0-9.]+" pnpm-lock.yaml | sort -u` to
|
||||
confirm no vulnerable versions remain.
|
||||
4. Verify: `pnpm test` + `pnpm lint` + `pnpm build-web` (the last exercises
|
||||
esbuild in the OpenNext/Cloudflare bundle path).
|
||||
|
||||
**Override applicability:** an override forces a transitive version regardless
|
||||
of the parent's declared range ONLY when the package is a regular dep (no peer
|
||||
warning). esbuild is a regular dep of vite; vite 7.3.x pins esbuild `^0.27.0`
|
||||
but esbuild 0.28.x is API-compatible for vite's usage (0.28 changelog = install
|
||||
integrity + minifier/codegen fixes). Verified via PR #4618 (alerts #238/#239
|
||||
esbuild→0.28.1, #240 @vitest/browser→4.1.9).
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
name: dict-import-contenturi-filename-4489
|
||||
description: "Android dict import \"incomplete bundle\" — ext-less content URIs; classify used getFilename (string) not basename (content resolver)"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 92f1011c-89b9-4ae1-b128-f24cfb4462a8
|
||||
---
|
||||
|
||||
#4489 / #4472: importing a StarDict bundle (`.ifo`+`.idx`+`.dict.dz`) on **some** Android
|
||||
devices fails with "Skipped incomplete bundles"; works on Xiaomi/Boox and web.
|
||||
|
||||
**Root cause — two filename resolvers diverged.** Tauri's Android `path.file_name`
|
||||
(used by `basename`) special-cases `content://`/`file://` URIs and calls the native
|
||||
`getFileNameFromUri` plugin → **content resolver DISPLAY_NAME** (real filename WITH ext).
|
||||
See `tauri-2.11.2/src/path/android.rs`. Our `getFilename()` (`src/utils/path.ts`) is pure
|
||||
JS string-parse of the URI. On devices whose SAF URI is an **opaque ext-less document id**
|
||||
(e.g. `content://com.android.providers.downloads.documents/document/msf%3A20`), getFilename
|
||||
→ `msf%3A20` (no ext) while basename → `21cen.dict.dz`. Working devices return
|
||||
`primary%3ADictionaries%3A21cen.dict.dz` (ext in the URI) so getFilename happens to work.
|
||||
|
||||
The OLD `selectFileTauri` extension filter ALREADY used `basename` (so files passed the
|
||||
filter and got imported), but **threw the resolved name away** and returned the raw URI.
|
||||
Then `dictionaryService.classify()` re-derived the name with `getFilename()` → no ext →
|
||||
every file orphaned → "incomplete bundle". The bug is the divergence, not the picker.
|
||||
|
||||
**Fix (PR for #4489):**
|
||||
- `SelectedFile.name?: string` added (`useFileSelector.ts`).
|
||||
- `resolveTauriFileName(path, appService)`: `basename` for `content://` / iOS `file://`,
|
||||
else `getFilename` — resolved ONCE in `selectFileTauri`, reused by the ext filter AND
|
||||
stored on `SelectedFile.name`. Removed `processTauriFiles`.
|
||||
- `classify()` (`dictionaryService.ts`): `source.file?.name ?? source.name ?? getFilename(path)`.
|
||||
- Test: `groupBundlesByStem.test.ts` — ext-less content URIs + `name` form a complete bundle.
|
||||
- Also fixes #4472 issue 3 (uploaded dict files renamed to the SAF dir path).
|
||||
|
||||
**Emulator repro (real granted URI, no rebuild needed):** drive SAF picker → "Downloads"
|
||||
location → returns `content://...downloads.documents/document/msf%3A<id>` (ext-less).
|
||||
basename resolves the real name ONLY for granted URIs (synthetic URIs → "path does not have
|
||||
a basename"). Verified via CDP `invoke('plugin:path|basename',{path,ext:null})`. See
|
||||
[[cdp-android-webview-profiling]] for the CDP harness (`src/__tests__/android/helpers/`).
|
||||
|
||||
**Also fixed same turn:** e-ink "black spot" on the Settings→Dictionaries `+` badges
|
||||
(Import Dictionary / Add Web Search) — add `eink-inverted` to the round badge span, mirroring
|
||||
the font import button #4454 (`globals.css` `[data-eink] .eink-inverted` → base-content bg +
|
||||
base-100 icon).
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
name: dict-lemmatization-4574
|
||||
description: "Dictionary lookup lemmatizes inflected words (ran→run, mice→mouse) before lookup; pluggable per-language registry, English impl, candidate-chain integration"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: d4206b72-47da-4c3d-adab-04df32c1137a
|
||||
---
|
||||
|
||||
#4574 FR: dictionary lookup should normalize inflected forms before lookup. Dicts that store only base headwords (Oxford Dictionary of English, Cambridge, Longman) miss `ran`/`mice`/`children`/`analyses`/`realised` even though `run`/`mouse`/`child`/`analysis`/`realise` exist.
|
||||
|
||||
**Integration point** (single, central): `src/services/dictionaries/lookupCandidates.ts` `buildLookupCandidates(word, lang?)` — appends `getLemmaCandidates(lower, lang)` to the tail of `[trimmed, lower, title, upper]`. Lemmas sit AFTER exact/case so exact match always wins. The pre-existing lookup loop in `DictionaryResultsView.tsx` (`useDictionaryResults`, shared by desktop popup + mobile sheet) tries each candidate and breaks on first non-empty hit; wiring was a one-liner passing `langCode` (already in effect scope) as the 2nd arg. Applies to ALL definition providers (mdict/stardict/dict/slob + online builtins).
|
||||
|
||||
**New module** `src/services/dictionaries/lemmatize/`:
|
||||
- `index.ts` — `getLemmaCandidates(word, lang)` + `Record<string, Lemmatizer>` registry (`Lemmatizer = (word)=>string[]`). Lang normalized via `normalizedLangCode` (utils/lang.ts) to primary subtag. **Missing/empty lang defaults to `'en'`** (`normalizedLangCode(lang) || 'en'`); **explicit non-English with no registered lemmatizer → `[]`** (we never force English onto e.g. `fr`/`zh`). Add a language = register one fn, no caller changes.
|
||||
- `english.ts` — `lemmatizeEnglish(word)`: `IRREGULAR_GROUPS` (base→[forms], flattened to inflected→base at load) for suppletive verbs / irregular plurals / irregular comparatives, + regular suffix rules (plural -s/-es/-ies→y/-ves→f,fe/-ses→sis; past -ed/-d/-ied→y + de-double; -ing + e-restore + de-double + -ying→ie; comparative -er/-est/-ier→y; possessive `'s`; adverb -ly). ASCII-single-token guard `/^[a-z][a-z'’-]*$/` (no-op on phrases/numbers/CJK/accented). Lowercases input; never returns the input itself or single letters.
|
||||
|
||||
**Key design insight: over-generate, let the dictionary validate.** The lemmatizer need not be linguistically precise — a bogus stem just misses and the loop moves on. So rules can be liberal. Cost is bounded: lemmas only fire AFTER exact+case all return empty (genuine "not a headword"), and the English rules produce ~2–5 candidates.
|
||||
|
||||
**Ordering gotcha**: `-ses→-sis` rule must come BEFORE generic `-es`/`-s` so `analyses`→`analysis` (the issue's expected noun) is tried ahead of `analyse` (the verb). Both are linguistically valid for `analyses`; issue wants the noun.
|
||||
|
||||
Tests: `__tests__/services/dictionaries/lemmatize/{english,index}.test.ts` + extended `lookupCandidates.test.ts` (all 8 issue cases asserted). Existing trim test's `spaced` sample swapped to `planet` (non-inflecting) since no-lang path now defaults to English lemmatization. Pure functions, fully deterministic — no live MDX needed. Related: [[dict-lookup-browser-hijack-4559]], [[wordlens-feature]].
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
name: dict-lookup-browser-hijack-4559
|
||||
description: Android system-dictionary lookup landing in the OEM browser instead of Eudic/欧路 — package-visibility + PROCESS_TEXT browser hijack
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 5e397668-b766-439c-873c-00ccb1da715a
|
||||
---
|
||||
|
||||
#4559 (PR #4568): on VIVO/iQOO (OriginOS) the system-dictionary lookup opened `com.vivo.browser/.BrowserActivity` instead of an installed dictionary. TWO root causes, both in the Android half of `show_lookup_popover` (`tauri-plugin-native-bridge/.../NativeBridgePlugin.kt`):
|
||||
|
||||
1. **Package-visibility filtering (primary).** App is `targetSdk 36`, but the native-bridge manifest had NO `<queries>` for `ACTION_PROCESS_TEXT`. Under Android 11+ filtering, `queryIntentActivities(PROCESS_TEXT)` then returns only *auto-visible* apps — web browsers are auto-visible (web-intent exception), arbitrary dictionary apps (Eudic/欧路/GoldenDict) are NOT. So the query returned just the browser. Fix = add `<queries><intent><action PROCESS_TEXT/><data text/plain/></intent></queries>` to the plugin manifest (mirrors the native-tts `TTS_SERVICE` pattern). This alone is likely the whole user-visible fix.
|
||||
2. **Browser hijack.** Even when visible, an OEM browser registering `ACTION_PROCESS_TEXT` can be the system default and swallow a plain `startActivity`. Fix = filter browsers out of the handler set in a pure `decideLookupDispatch(handlers, browserPackages, remembered)` (new `LookupDispatch.kt`, JUnit-tested): no-browser → implicit (unchanged, keeps native "Always"); browser+1 dict → explicit `setClassName` direct launch; browser+≥2 → `createChooser` + `EXTRA_EXCLUDE_COMPONENTS`; browser-only → `unavailable:true`. Browsers detected via `queryIntentActivities(ACTION_VIEW https + BROWSABLE)` (auto-visible, no `<queries>` needed).
|
||||
|
||||
**Remember-the-choice** (the maintainer wanted "smooth once chosen"): `ACTION_CHOOSER` has NO native "Always" button (mutually exclusive with `EXTRA_EXCLUDE_COMPONENTS` — the resolver that *has* Always can't exclude and obeys the browser default). Re-implemented Always: pass an `IntentSender` to `createChooser`; system returns `EXTRA_CHOSEN_COMPONENT` to a manifest `LookupChoiceReceiver` (exported=false; explicit intra-app PendingIntent so non-exported is fine; FLAG_MUTABLE on S+) which persists pkg/class to a plain SharedPreferences (`readest_lookup_dictionary_v1`). Next lookup fast-paths it. Reset UI: `get_lookup_dictionary`/`clear_lookup_dictionary` commands (build.rs COMMANDS + default.toml + autogenerated TOMLs/schema/reference regenerate on `cargo build -p tauri-plugin-native-bridge`) → Android-only conditional reset row in `CustomDictionaries.tsx`, only shown when something is actually remembered (`getRememberedLookupApp` returns null otherwise, so no clutter).
|
||||
|
||||
Maintainer DECLINED a settings picker to pre-pick a specific app ("we won't call the app directly"); browser-exclusion respects that (dynamic, not a user-set hardcode). Verified: gradle JUnit (7 cases) + vitest (12). `test:rust`/`clippy` were blocked by UNRELATED stale shared-`target/` cache (deleted sibling worktree `readest-feat-android-rangefile-protocol` path in `fs` plugin permission scan) — not my change; validated Rust via targeted plugin build. Related: [[android-open-with-intent-flow]], [[android-nativefile-remotefile-io]].
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
name: download-file-scope-android-regression
|
||||
description: "#4639 strict is_allowed broke ALL Android downloads to app data dir (covers/dicts/books); fix = app.path() base-dir membership, not glob scope"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: e78227be-6260-405a-88fb-48ffe4b20615
|
||||
---
|
||||
|
||||
Regression from [[security-advisories-web-2026-06]] PR #4639 (commit 4025c4d7b). On Android every `download_file` into the app's own data dir failed: `permission denied: path not in filesystem scope: /data/user/0/com.bilingify.readest/Readest/{Books/<hash>/cover.png, Dictionaries/<id>/*.mdx, ...}`. The error string IS `transfer_file.rs Error::Forbidden` from `ensure_path_allowed`.
|
||||
|
||||
**Root cause (non-obvious):** `app.fs_scope().is_allowed(p)` returns **false** for the app's own files on Android. `FsExt::fs_scope()` returns the GLOBAL `state::<Scope>().scope`, but the capability scope patterns that cover the data dir (`$APPDATA/Readest/**/*`, `**/Readest/**/*`) are **command-scoped**, NOT in that global scope. The fs plugin's `resolve_path` (tauri-plugin-fs `commands.rs`) passes because it checks `fs_scope.scope.is_allowed(p) || scope.is_allowed(p)` where the 2nd `scope` is rebuilt from `global_scope.allows()+command_scope.allows()` per-command — that's where those patterns live. This is the SAME gap `dir_scanner::read_dir` works around with `|| contains("Readest")`. So #4639's note "Chose STRICT is_allowed (NOT read_dir's contains-Readest hatch)" was the bug — strict `is_allowed` rejects the app's own dir on Android.
|
||||
|
||||
Why fs-plugin writes work but `download_file` didn't: app writes via `baseDir: AppData` + relative path → `webview.path().resolve(rel, AppData)` → canonical form matched by the per-command scope. `download_file`/`upload_file` use raw `tokio::fs` with a JS-supplied ABSOLUTE path, so none of that applies.
|
||||
|
||||
**WRONG first attempt (don't repeat):** canonicalizing the symlink (`/data/user/0/<pkg>` → `/data/data/<pkg>`, since `is_allowed` only canonicalizes EXISTING paths and a download target doesn't exist yet) then re-calling `is_allowed`. Verified on-device it STILL fails — the patterns aren't in the global scope at ALL, so no path form matches. The canonicalize-existing-ancestor helper is still useful, just for the prefix check below, not for `is_allowed`.
|
||||
|
||||
**Shipped interim fix** (PR #4651, commit 75b469931): owner rejected the base-dir-membership version as over-engineered ("bloody hard coded" dir list) and asked to mirror `dir_scanner` instead. `ensure_path_allowed` = reject relative+`..` (`has_disallowed_components`) → `if app.fs_scope().is_allowed(p) || is_within_app_storage(p) Ok`. `is_within_app_storage(file_path, app_identifier) = file_path.contains("Readest") || file_path.contains(app_identifier)` where `app_identifier = &app.config().identifier` (NOT a hardcoded literal; `config()` is inherent on AppHandle — NO `use tauri::Manager`). The bundle id (`com.bilingify.readest`) is in EVERY Android sandbox path incl. the cache dir, so it closes the OPDS-to-`$APPCACHE` gap that `contains("Readest")` alone misses (`Readest` is the `DATA_SUBDIR`, capital-R; cache dir has only the lowercase bundle id). `..` rejection keeps GHSA-55vr-pvq5-6fmg (`~/.ssh/id_rsa` has neither marker). Substring posture = same as `dir_scanner`. **Follow-up (deferred):** replace the substring fallback with `BaseDirectory`+relative resolved via `app.path()` (the app already has `appService.resolvePath()` → `{baseDir, fp}`; callers currently flatten it to an absolute string) so targets are in-scope by construction.
|
||||
|
||||
(Rejected earlier attempt, kept for context: base-dir-membership via `app.path()` dirs + `is_inside_any` canonicalizing both sides for the `/data/user/0`↔`/data/data` symlink. Correct but owner found the dir enumeration ugly.)
|
||||
|
||||
**On-device verify recipe (Xiaomi fuxi 2211133C, real release-signed devtools build):** `pnpm dev-android` = `tauri android build -t aarch64 -- --features devtools && adb install -r .../app-universal-release.apk`. Local keystore (`gen/android/keystore.properties` → `/Users/chrox/dev/Android/keys/upload-readest-keystore.jks`, alias `upload`) matches installed signer → `-r` PRESERVES user data (dicts/books). CDP probe: `adb forward tcp:9333 localabstract:webview_devtools_remote_<pid>` (socket name = app pid; stale sockets linger — pick the one matching `adb shell pidof`); fetch `/json/list` via node http (NOT curl — mishandles WebView framing); Node v24 has global `WebSocket`. Raw `invoke('download_file',...)` needs a Channel for `on_progress`: pass `{ ['__TAURI_TO_IPC_KEY__']: () => '__CHANNEL__:'+I.transformCallback(()=>{}) }`. Result: in-scope `/data/user/0/.../Readest/x.bin` → OK; `/data/local/tmp/evil.bin` → still Forbidden. `appData=/data/user/0/com.bilingify.readest` (no `/files`); appCache/temp=`.../cache`.
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: D-pad Navigation Design
|
||||
description: Android TV / Bluetooth remote D-pad navigation architecture, key files, and pitfalls encountered during implementation
|
||||
type: project
|
||||
---
|
||||
|
||||
## D-pad Navigation Architecture
|
||||
|
||||
D-pad support enables Bluetooth remote controller navigation on Android TV (and keyboard arrow navigation on desktop).
|
||||
|
||||
### Key Files
|
||||
|
||||
- `src/app/reader/hooks/useSpatialNavigation.ts` — Reader toolbar D-pad navigation. Left/Right navigates between buttons, Up/Down moves between header↔footer. Auto-focuses first button on show. Uses focus-probe technique for visibility detection.
|
||||
- `src/app/library/hooks/useSpatialNavigation.ts` — Library grid D-pad navigation. Arrow keys move between BookshelfItem elements. ArrowDown from outside bookshelf (e.g. header) enters the grid via window-level listener.
|
||||
- `src/helpers/shortcuts.ts` — `onToggleToolbar` (Enter key) toggles reader toolbar visibility.
|
||||
- `src/app/reader/hooks/useBookShortcuts.ts` — `toggleToolbar` handler shows/hides header+footer bars. Skips when a `<button>` is focused (lets native click fire).
|
||||
- `src/__tests__/hooks/useSpatialNavigation.test.tsx` — Unit tests for reader toolbar navigation.
|
||||
|
||||
### Design Decisions
|
||||
|
||||
- **No third-party library**: Tried `@noriginmedia/norigin-spatial-navigation` but it failed due to init timing issues (React child effects run before parent effects) and conflicts with the existing `useShortcuts` system. Custom solution is simpler and more reliable.
|
||||
- **Two `useSpatialNavigation` hooks**: Same name in different directories — library version handles grid navigation, reader version handles toolbar button navigation. Different navigation patterns but same concept.
|
||||
- **Platform-agnostic hooks**: Both `useSpatialNavigation` hooks work on all platforms, not just Android.
|
||||
- **Focus-probe for visibility**: `offsetParent` is unreliable for detecting visible buttons (returns null inside `position: fixed` containers on mobile). Instead, try `btn.focus()` and check if `document.activeElement === btn` — this correctly handles all hiding methods (display:none, visibility:hidden, fixed positioning).
|
||||
|
||||
### Pitfalls
|
||||
|
||||
1. **WebView spatial navigation conflict**: Android WebView has built-in spatial navigation that intercepts D-pad arrow keys and moves DOM focus between `tabIndex>=0` elements. Added `tabIndex={-1}` to non-interactive overlay elements (HeaderBar trigger, ProgressBar, FooterBar trigger, SectionInfo) to prevent focus theft.
|
||||
|
||||
2. **`eventDispatcher.dispatchSync` short-circuits**: When multiple handlers are registered for `native-key-down`, the first handler returning `true` stops propagation. The FooterBar's Back handler fires before the Reader's. Both must independently call `blur()` — can't rely on the Reader's handler running.
|
||||
|
||||
3. **Must blur on toolbar dismiss**: When Back/Escape dismisses the toolbar, the focused button must be blurred. Otherwise `document.activeElement` remains a hidden button, and `toggleToolbar` skips Enter when `activeElement.tagName === 'BUTTON'`. Blur is called in FooterBar's handleKeyDown (for Back and Escape) and in Reader's handleKeyDown.
|
||||
|
||||
4. **Arrow key trapping must use `stopPropagation`**: Without it, arrow keys bubble to `window` where `useShortcuts` handles them as page turns. The toolbar keydown handler on the container div calls `e.stopPropagation()` + `e.preventDefault()` to prevent this.
|
||||
|
||||
5. **Library grid needs window-level listener**: The bookshelf container keydown handler only fires when focus is inside it. A separate `window` keydown listener handles ArrowDown from the header into the grid (when focus is outside the container).
|
||||
|
||||
6. **Auto-focus race on toolbar show**: Both header and footer bars auto-focus their first button when `isVisible` becomes true simultaneously. The last effect to run wins. This is acceptable — user can navigate between them with Up/Down.
|
||||
|
||||
7. **`offsetParent` null in fixed containers**: On mobile, `.footer-bar` uses `position: fixed`. All child buttons have `offsetParent === null`, making `offsetParent`-based visibility checks useless. The focus-probe approach (try focus, check activeElement) is the reliable alternative.
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: duokan-fullscreen-cover-scroll
|
||||
description: "Duokan fullscreen cover image invisible in scrolled mode (#4379) — paginator pins it position:absolute height:100% which collapses against auto-height scroll container"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: c45aabf0-e8a3-42b6-a5fd-c04d6eb2345c
|
||||
---
|
||||
|
||||
Issue #4379: an EPUB cover with `data-duokan-page-fullscreen` on `<html>` (Duokan/DangDang convention) shows in paginated mode but is **blank in scrolled mode**; only the first cover image, other images fine in both modes.
|
||||
|
||||
**Root cause** — `View.setImageSize()` in `packages/foliate-js/paginator.js` has a `pageFullscreen` branch that pins each img with `position:absolute; inset:0; width:100%; height:100%` and forces ancestors + `<html>` to `height:100%`/`position:relative`. This fills the page in paginated/columnized mode (html has a fixed pixel height). In scrolled mode `scrolled()` sets `html`/`body` height to `auto`, so the `height:100%` chain resolves to **0** and the absolutely-positioned cover collapses out of view (offsetHeight 0).
|
||||
|
||||
**Fix** — gate the fullscreen treatment on column mode: `const applyFullscreen = pageFullscreen && this.#column`. Use `applyFullscreen` for the max-height/max-width margin term and the `if` block. Add an `else if (pageFullscreen)` that `removeProperty`s the stale `position/inset/width/height/margin` on the img (and `width/height/margin/padding` on ancestors, `position` on html) so toggling paginated→scrolled doesn't leave the cover collapsed (same iframe/img is reused via `view.render(layout)` on `flow` change). In scrolled mode the cover then flows like a normal full-page image bounded by `max-height = availableHeight`.
|
||||
|
||||
**Key facts**
|
||||
- `this.#column = layout.flow !== 'scrolled'` (set in `render()` before `setImageSize`), so it's reliable inside `setImageSize`.
|
||||
- Foliate writes these styles as **inline `!important`** → cannot be overridden from `src/utils/style.ts`; the fix must live in the paginator.
|
||||
- Regression test: `src/__tests__/document/paginator-duokan-cover.browser.test.ts` + fixture `repro-4379.epub` (cover xhtml with the duokan attr + dimensionless `<img>`). Asserts cover `img.offsetHeight > 0` in scrolled mode, paginated sanity, and paginated→scrolled toggle. Browser test (real layout) is required — jsdom can't compute the collapse.
|
||||
|
||||
Related: [[paginator-swipe-bg-flash]], [[css-style-fixes]].
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: edge-tts-word-highlighting-4017
|
||||
description: "Edge TTS word-by-word highlighting (#4017, PR"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: afd9b381-c17d-4988-b287-07263d8bea0b
|
||||
---
|
||||
|
||||
# Edge TTS word-by-word highlighting (#4017, PR #4566)
|
||||
|
||||
**Design: keep sentence granularity, add word highlight on top.** All clients still report `getGranularities() = ['sentence']` — switching foliate to word marks would regress media-session metadata (one word on lock screen), byMark seek (word steps), `getSpokenSentence`, and per-word synthesis. Instead: `EdgeSpeechTTS.createAudio()` returns `{url, boundaries}` (cached per payload-hash next to the blob URL), `EdgeTTSClient` runs a rAF loop syncing `audio.currentTime` (media time → playbackRate/pause-safe) against boundary ticks, and `TTSController.prepareSpeakWords/dispatchSpeakWord` match words sequentially (`indexOf` with a moving cursor; unmatched word = skip WITHOUT advancing cursor) against the sentence range text, then highlight the sub-range via the existing `#getHighlighter`.
|
||||
|
||||
**Edge wire facts** (verified with raw WS probe + live):
|
||||
- `audio.metadata` frames: `{"Metadata":[{"Type":"WordBoundary","Data":{"Offset":1000000,"Duration":4250000,"text":{"Text":"Dr.","Length":3}}}]}` — one word/frame, ticks = 100 ns (1e7/s), offsets relative to this request's audio stream.
|
||||
- `Text` is the **verbatim input span** ("Dr.", "23", "$5.50" keep punctuation; trailing sentence punctuation stripped) → sequential indexOf matching is robust. Works for zh too.
|
||||
- The readaloud endpoint gates on **User-Agent (needs Edg/non-headless), NOT Origin** — a localhost Origin with Edg UA is accepted; default HeadlessChrome UA is rejected (close 1006).
|
||||
|
||||
**Pre-existing bug fixed in the same PR:** browser branch did `new WebSocket(url, {headers})` → native WebSocket parses the object as a subprotocol → `SyntaxError` → on web the wss path could NEVER work (always https-proxy fallback, which strips boundaries). Node-only options now.
|
||||
|
||||
**Probe gotchas:** Overlayer draws the highlight as a `<path>` inside `<g fill="#808080">` (NOT `<rect>` — rect-only DOM probes miss it); the overlayer svg lives in `FOLIATE-PAGINATOR`'s open shadow root (sibling layer of the iframe, not inside it). TTS auto-advance creates new views — re-query svgs per sample, never cache the list.
|
||||
|
||||
**dev-web live-verify recipe:** gstack `browse --proxy http://127.0.0.1:8118` (flag needed on EVERY invocation; this machine's external net needs the local proxy, headless Chromium doesn't inherit it) + `browse useragent '...Edg/143...'` (context-level, doesn't break Next) — do NOT use `browse header Origin:...` (extra headers hit localhost too → Next dev 403s ALL chunks → blank page; headers can't be removed without daemon restart). Import books via synthetic drop: fetch epub from `public/`, `DataTransfer` + `DragEvent('drop')` on `.library-page` (in-memory only — re-import after every reload/restart). Patch `content.overlayer.add/remove` to log the real highlight calls — the ground truth when screenshots race. Related: [[tts-fixes]]
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
name: empty-start-cfi-sync
|
||||
description: "Invalid synced-progress CFIs like epubcfi(/6/24!/4,,/20/1:58) — the empty-start range bug from the cfi-inert skip-link, and the read-side normalizeLocationCfi sanitizer"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: ffa4a291-55fa-4cd5-8e35-0ac2852ff5c9
|
||||
---
|
||||
|
||||
Synced progress CFIs of the form `epubcfi(/6/24!/4,,/20/1:58)` (a range with an
|
||||
**empty start** component — the `,,`) are invalid: the start collapses to the
|
||||
section beginning `(body, 0)` while the end reaches the section's last block, so
|
||||
a receiving device navigates to the **wrong end** of the section.
|
||||
|
||||
**Root cause** — the cfi-inert a11y skip-link (`a11y.ts` prepends a 1×1
|
||||
`position:absolute` `<div cfi-inert>` as body's first child). There was a
|
||||
~2.5-month transitional window (foliate `c558766` 2026-03-11 → `569cc06`
|
||||
2026-05-30) where `epubcfi.js getChildNodes` already skipped `cfi-inert` but
|
||||
`paginator.js getVisibleRange` did **not** yet reject it. The relocate range's
|
||||
START could anchor on the skip-link; `fromRange`→`nodeToParts` asks for its
|
||||
index, `getChildNodes` filters it out, `findIndex` returns -1, the
|
||||
`.filter(x => x.index !== -1)` drops the step, and the start collapses to the
|
||||
body boundary → empty start. (Symmetric empty-END form from the next-section
|
||||
skip-link on a section's last page.)
|
||||
|
||||
**Generation is fixed** by `569cc06` (live on `dev` via `c23c21d37`) — but that
|
||||
does NOT repair CFIs already stored on the sync server. Those keep being served.
|
||||
|
||||
**Fix (this work):** `isMalformedLocationCfi(cfi)` predicate in `src/utils/cfi.ts`
|
||||
— true for a degenerate range (empty `parts.start` or `parts.end` via
|
||||
`CFI.parse`). Chose **discard over repair** (user call): don't derive a position
|
||||
from a corrupt CFI; drop it and let a known-good fallback win.
|
||||
- Applied ONLY at `useProgressSync.ts` `applyRemoteProgress`: a malformed
|
||||
`syncedConfig.location` is set to `undefined` so it can't drive goTo, can't win
|
||||
the `CFI.compare` gate, and is filtered out of the persisted config (local
|
||||
location kept; stops re-propagation). A valid `xpointer` still recovers the
|
||||
real position via `getCFIFromXPointer`.
|
||||
- Applied at `useKOSync.ts` `generateKOProgress` (push side): if local
|
||||
`progress.location` is malformed, skip the CFI→XPointer conversion and reuse
|
||||
the last known-good `config.xpointer`. Critical because once a bad CFI is
|
||||
pushed as an XPointer the "malformed" signal is lost — other devices pull a
|
||||
plain XPointer pointing at the wrong section end and can't discard it. The
|
||||
kosync RECEIVE path needs no guard: `getCFIFromXPointer` builds point CFIs from
|
||||
point XPointers, which can't take the empty-start form.
|
||||
- Deliberately NOT applied to `FoliateViewer.tsx` open path — that uses the
|
||||
user's OWN local `config.location`; discarding it would dump them at book start
|
||||
(`goToFraction(0)`). Left untouched per user preference; a legacy local bad
|
||||
value self-heals on the next page-turn save.
|
||||
|
||||
Tests: predicate in `__tests__/utils/cfi.test.ts`; repro + flag in
|
||||
`__tests__/utils/epubcfi-inert.test.ts`; discard behavior (no goTo, not
|
||||
persisted) in `__tests__/hooks/useProgressSync.test.tsx`.
|
||||
Related: [[kosync-cfi-spine-resolution]].
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
name: feedback-commit-message-english-only
|
||||
description: "Commit messages (and PR titles) must be English-only — no CJK characters, no em/en dashes"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: feedback
|
||||
originSessionId: c0199d69-f314-45ee-bf7c-867b908641cc
|
||||
---
|
||||
|
||||
Git commit messages must be **English only**: no CJK characters (no 中文/量词/example glyphs like 第一封信) and no em/en dashes (— –). Use plain ASCII punctuation (comma, colon, parentheses, `...`). The same applies to PR titles for consistency.
|
||||
|
||||
**Why:** the user (a maintainer of readest/readest) keeps the project's git history English-only and clean.
|
||||
|
||||
**How to apply:** when a fix is about Chinese/CJK text, describe the concept in English in the commit subject/body (e.g. "measure-word prose", "the classifiers for 'letter' and 'book'") instead of pasting the glyphs. Keep the concrete CJK examples and screenshots in the PR *body* / code / tests, where they aid understanding — that is fine. First seen on PR #4660 ([[txt-chapter-measure-word-4658]]), where "量词" in the subject had to be amended to "measure-word".
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
name: Design rules live in DESIGN.md
|
||||
description: Readest has a design system doc — codify recurring UI/UX rules there, don't just apply them ad-hoc. Memory points at the canonical location and the patterns it covers.
|
||||
type: feedback
|
||||
originSessionId: 85757e57-a029-40f8-b098-88039c43514b
|
||||
---
|
||||
The project's design system is documented at `apps/readest-app/DESIGN.md`.
|
||||
**When the user articulates a UI/UX rule** ("X should always Y", "we follow Z
|
||||
convention"), add it to DESIGN.md so it persists for the team and for future
|
||||
sessions — don't just apply it inline and move on.
|
||||
|
||||
**Why:** Readest's UI is Adwaita-aligned, e-ink-first, cross-platform-aware.
|
||||
A doc'd system avoids drift across panels and gives reviewers a reference
|
||||
point. The user explicitly asked to "remember the UI/UX rules somewhere"
|
||||
when refining the OPDS Integration sub-page.
|
||||
|
||||
**How to apply:** When the user surfaces a new rule:
|
||||
1. Add it to the appropriate `DESIGN.md` section (numbered principles in §2,
|
||||
anatomy details in §5, anti-patterns in §10).
|
||||
2. Cross-reference from related sections so it's discoverable from multiple
|
||||
entry points.
|
||||
3. Save the actual code change reference if it captures a canonical example.
|
||||
|
||||
**Rules already codified there (don't re-invent — reference instead):**
|
||||
- §2.1–2.7: surface continuity, color discipline, two-step depth, localized
|
||||
hover, motion=color, eink-first, focus visibility.
|
||||
- §2.8: RTL — always use logical properties (`ps`/`pe`/`ms`/`me`/`text-start`
|
||||
/`text-end`/`border-s`/`border-e`/`start-*`/`end-*`). Never `pl`/`pr`/`ml`
|
||||
/`mr`/`text-left`/`text-right`/`left-*`/`right-*`. The user is strict on
|
||||
this — Readest ships RTL languages.
|
||||
- §2.9: every panel/sub-page must open with title + one-line description.
|
||||
- §3: surface tier hierarchy (window/view/card → base-200/100-tinted/100).
|
||||
- §4: action vocabulary (Accent CTA, Suggested, Flat, Pill, Destructive,
|
||||
ListExtension).
|
||||
- §5: boxed list anatomy + uniform row height (`min-h-14 items-center`,
|
||||
never `py-3`) + chromeless controls inside the box + end-aligned values
|
||||
with custom `<MdArrowDropDown>` icon (don't trust daisyui's bg-image
|
||||
chevron for trailing-edge alignment).
|
||||
- §8: e-ink overlay rules.
|
||||
- §10: anti-pattern catalog with real before/after examples.
|
||||
|
||||
**Common file paths to remember:**
|
||||
- `apps/readest-app/DESIGN.md` — source of truth.
|
||||
- `apps/readest-app/src/components/settings/SubPageHeader.tsx` — title +
|
||||
description sub-page primitive that embodies §2.9.
|
||||
- `apps/readest-app/src/components/settings/integrations/` — the canonical
|
||||
reference implementation of the boxed-list-with-rows pattern.
|
||||
- `apps/readest-app/src/styles/globals.css` — eink overlay rules at
|
||||
`[data-eink='true']`.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: Don't push on every change
|
||||
description: Commit when work is done; don't auto-push every iteration during active debugging
|
||||
type: feedback
|
||||
originSessionId: 49a72b36-8f45-4a57-87e1-e10563bac47a
|
||||
---
|
||||
Don't `git push` after each commit while a bug is being actively iterated on. Commit locally as needed but hold the push.
|
||||
|
||||
**Why:** When a fix doesn't actually solve the user-reported bug, every push is wasted CI cycles + remote churn the user has to look past on the PR. The user is testing live and will tell us when something's actually verified.
|
||||
|
||||
**How to apply:** During debugging or fix iterations on a single user-reported bug, commit locally only. Push when (a) the user confirms the fix works, (b) the user explicitly asks to push, or (c) we hit a clean done-state on a multi-step task. New commits + lint/test green is not enough.
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
name: en/translation.json holds ONLY plural variants and proper-noun overrides
|
||||
description: For non-plural strings, do NOT add to en/translation.json — the source-code key IS the en value via `defaultValue: key`. ONLY plural strings need explicit `_one`/`_other` entries in en, because i18next needs the forms to pick from.
|
||||
type: feedback
|
||||
originSessionId: e4ddc690-b1a9-4557-855f-d4e67055824f
|
||||
---
|
||||
|
||||
**Rule:** `public/locales/en/translation.json` is hand-curated and contains essentially two kinds of entries:
|
||||
|
||||
1. **Plural variants** (`<base>_one`, `<base>_other`, and locale-specific `_few`/`_many`/etc. as needed by CLDR). i18next MUST find these to know which form to render — without them, `count: 1` falls back to the bare key like `{{count}} days` and renders "1 days" instead of "1 day".
|
||||
2. **Proper-noun overrides** (e.g., font names like `LXGW WenKai GB Screen`) where the en value differs from the key.
|
||||
|
||||
Everything else — ordinary translatable strings like `Sign in to share books` — does NOT belong in en/translation.json. The translation hook calls `t(key, { defaultValue: key, ...options })`, so for any string not in en/translation.json, i18next renders the key itself. That's why a 51-key en file works for a codebase with thousands of `_()` callsites.
|
||||
|
||||
**Why en is hand-curated:** the project's `i18next-scanner.config.cjs` lists every locale EXCEPT `en` in its `lngs` array. The scanner generates `__STRING_NOT_TRANSLATED__` placeholders only for the listed locales; `en` is never touched.
|
||||
|
||||
**Workflow when adding `_(...)` calls:**
|
||||
|
||||
- **Non-plural string** (e.g. `_('Sign in to share books')`): add the `_(...)` call, run `pnpm run i18n:extract`, translate the new placeholders in non-en locales. **Do NOT touch `en/translation.json`** — the key itself is the en value.
|
||||
- **Plural string** (e.g. `_('{{count}} days', { count: n })`): same as above, PLUS hand-add `<base>_one` and `<base>_other` to `en/translation.json` (and `_few`/`_many`/etc. only if the source language ever needs them, which English doesn't). Convention from existing entries (e.g., `Are you sure to delete {{count}} selected book(s)?_one` → `Are you sure to delete {{count}} selected book?`): keep `{{count}}` interpolated even in `_one`, and swap any `(s)` placeholder to the proper singular/plural noun.
|
||||
|
||||
**Audit script:** walk `src/`, regex-match `_('...', { ..., count: ... })`, for each base key verify both `<base>_one` and `<base>_other` exist in `en/translation.json`. (See conversation history for an implementation.)
|
||||
|
||||
**Where this bit us:**
|
||||
|
||||
- Initial bug: `_('{{count}} days', { count: n })` rendered "1 days" because en had no `_one` form.
|
||||
- Audit found 16 missing en plural keys from earlier PRs (OPDS / TTS / dictionary import) silently rendering wrong.
|
||||
- Then overcorrected and added non-plural keys like `Sign in to share books` to en/translation.json — wrong, breaks the project's convention. en stays clean for non-plural strings.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: gstack upgrade location
|
||||
description: Always upgrade gstack from the project directory (.claude/skills/gstack), not from a global install
|
||||
type: feedback
|
||||
---
|
||||
|
||||
When upgrading gstack, always run the upgrade from the current project's `.claude/skills/gstack` directory (local-git install), not from a global install path.
|
||||
|
||||
**Why:** The project uses a local-git gstack install at `apps/readest-app/.claude/skills/gstack`. Previous mistakes upgraded a global copy while the project's local copy stayed outdated.
|
||||
|
||||
**How to apply:** When `/gstack-upgrade` is invoked, ensure the `cd` and `git reset --hard origin/main && ./setup` happen inside the project's `.claude/skills/gstack` directory.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: No lookbehind regex
|
||||
description: Never use lookbehind assertions in JS/TS code — the build check rejects them for browser compatibility
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Never use lookbehind regex (`(?<=...)` or `(?<!...)`) in JavaScript/TypeScript source code. Use `(?:^|[^...])` or other alternatives instead.
|
||||
|
||||
**Why:** The project has a `check:lookbehind-regex` build check (`pnpm check:all`) that scans the Next.js output chunks and fails if any lookbehind assertions are found. Older WebViews (especially on some Android devices) don't support lookbehinds.
|
||||
|
||||
**How to apply:** When writing regex that needs to assert what comes before a match, use a non-capturing group with alternation (e.g., `(?:^|[^a-z-])`) instead of a negative lookbehind (`(?<![a-z-])`). This applies to all `.ts`/`.tsx`/`.js` files that end up in the build output.
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: No test seams in production code
|
||||
description: Never import or call `__reset*ForTests` (or any test-only helper) from production modules — keep test orchestration on the test side
|
||||
type: feedback
|
||||
originSessionId: 49a72b36-8f45-4a57-87e1-e10563bac47a
|
||||
---
|
||||
Production code must never import or call functions named `__reset*ForTests`
|
||||
(or any other test-only seam). If a `__resetXForTests` function in module A
|
||||
needs to also clear state owned by module B, the test file is responsible
|
||||
for calling both resets — not module A's reset chaining into module B's.
|
||||
|
||||
**Why:** Importing a test-only helper into a production module pulls the
|
||||
test seam into the prod import graph, blurs the test/prod boundary, and
|
||||
risks the helper being shipped or mistakenly called at runtime. Caught
|
||||
once in `src/services/sync/replicaSync.ts` where
|
||||
`__resetReplicaSyncForTests` had been changed to call
|
||||
`__resetSettledEventsForTests` from `@/utils/event` for "convenience."
|
||||
|
||||
**How to apply:**
|
||||
- A `__resetXForTests` function should clear ONLY its own module's state.
|
||||
- If a test needs a coordinated reset across modules, do it in the test
|
||||
file's `beforeEach` / `afterEach` — call each module's seam directly.
|
||||
- Never `import { __reset...ForTests }` inside `src/` outside of
|
||||
`src/__tests__/`. A grep `grep -rn "__reset.*ForTests" src/ --include="*.ts" --include="*.tsx" | grep -v __tests__ | grep -v "^.*export const __reset"` should return zero hits.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: Always use a new branch for new PRs
|
||||
description: Each new PR/issue should get its own fresh branch from main, never reuse an existing feature branch
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Always create a new branch from main for each new PR or issue. Never reuse an existing feature branch for unrelated work.
|
||||
|
||||
**Why:** The user corrected this when a storage fix was committed on the `feat/full-sync-annotations` branch instead of a dedicated branch. Mixing unrelated changes on the same branch makes PRs harder to review and manage.
|
||||
|
||||
**How to apply:** Before committing fixes, create a new branch like `fix/<topic>` from `origin/main`. Only reuse a branch if the work is directly related to that branch's existing purpose.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: Always rebase before PR
|
||||
description: Rebase to origin/main before creating pull requests
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Always rebase the branch onto origin/main before creating a pull request.
|
||||
|
||||
**Why:** The user wants PRs to be up-to-date with main to avoid merge conflicts and keep a clean history.
|
||||
|
||||
**How to apply:** Before running `gh pr create`, always run `git fetch origin && git rebase origin/main` first. If there are conflicts, resolve them before proceeding.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: test-file-filter
|
||||
description: Use pnpm test/test:browser with path directly (no --) to run a single test file
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Run a specific test file with `pnpm test <path>` or `pnpm test:browser <path>` — no `--` separator.
|
||||
|
||||
**Why:** Adding `--` before the path (e.g. `pnpm test:browser -- <path>`) causes vitest to ignore the file filter and run all test files. Without `--`, pnpm appends the path directly to the vitest command, which correctly filters to that file only.
|
||||
|
||||
**How to apply:** Always use `pnpm test src/__tests__/foo.test.ts` or `pnpm test:browser src/__tests__/foo.browser.test.tsx` when verifying a specific test file.
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: Use worktree for PR/issue/feature work
|
||||
description: Always create a git worktree with pnpm worktree:new before reviewing PRs, fixing issues, or implementing features
|
||||
type: feedback
|
||||
originSessionId: 650f8ff2-980d-459f-ad23-ba0af56e28b5
|
||||
---
|
||||
Always use `pnpm worktree:new <branch-name|pr-number>` to create an isolated worktree before starting work on:
|
||||
- Reviewing a GitHub PR (e.g., `pnpm worktree:new 3809`) → worktree at `~/dev/readest-pr-3809`
|
||||
- Fixing a GitHub issue (e.g., `pnpm worktree:new fix/issue-123`) → worktree at `~/dev/readest-fix-issue-123`
|
||||
- Implementing a feature request (e.g., `pnpm worktree:new feat/my-feature`) → worktree at `~/dev/readest-feat-my-feature`
|
||||
|
||||
Worktree directory convention: `readest-<name>` in the parent of the repo root (`~/dev/`), with slashes replaced by dashes.
|
||||
|
||||
Use `pnpm worktree:rm <branch-name|pr-number>` to clean up when done.
|
||||
|
||||
**Why:** Keeps the current bare repo branch untouched. Each task gets its own isolated workspace with submodules, dependencies, env files, and vendor assets already set up.
|
||||
|
||||
**How to apply:** Before touching any code for a PR review, bug fix, or feature, run `pnpm worktree:new` first. Work inside the new worktree directory (e.g., `~/dev/readest-pr-3809/apps/readest-app/`). Clean up with `pnpm worktree:rm` after merging or finishing.
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
name: foliate-touch-listener-capture-phase
|
||||
description: "To intercept/suppress reader touch gestures from the app, use capture-phase listeners — foliate-js's paginator registers bubble-phase doc listeners first"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: reference
|
||||
originSessionId: 4b0bfcd2-a4ed-4b3c-99c2-b3c37ef7c530
|
||||
---
|
||||
|
||||
There are **three** independent touch-listener registrants on the foliate iframe `doc`:
|
||||
1. `FoliateViewer.tsx` (~line 326) — passive forwarders that only `postMessage`.
|
||||
2. `Annotator.tsx` (~line 332) — non-passive, drive text selection.
|
||||
3. **foliate-js's own paginator** (`packages/foliate-js/paginator.js:1034`) — non-passive, **bubble-phase**, registered during `view.open()` (so *before* any app-level `load` handler). It can `preventDefault`, set `#touchScrolled`, `scrollBy`.
|
||||
|
||||
Consequence: a bubble-phase app listener registered "before the existing FoliateViewer listeners" **cannot** `stopImmediatePropagation` the paginator — the paginator already ran. Registration order only controls listeners within the same phase, and the paginator's are earlier regardless.
|
||||
|
||||
**Fix pattern:** register with `{ capture: true, passive: false }`. Capture-phase listeners on `doc` fire before all bubble-phase listeners when the event target is a descendant, so capture-phase `stopImmediatePropagation()` suppresses paginator + Annotator + FoliateViewer handlers alike. Scrolled mode also needs `preventDefault` from the first armed move (the paginator early-returns on `scrolled`, so native container scroll is what moves content).
|
||||
|
||||
Verified end-to-end for the [[brightness-swipe-gesture]] feature (test asserts a bubble-phase paginator stand-in never fires after a capture-phase `stopImmediatePropagation`). Both Codex and a Claude subagent independently confirmed against `paginator.js` during the /autoplan review.
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: footnote-aside-namespace-order-4438
|
||||
description: Footnote aside border line regression — @font-face inlined before @namespace invalidated the namespaced footnote-hiding selector
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 788943e6-fede-4c8f-828c-695ca873f178
|
||||
---
|
||||
|
||||
#4438 (v0.11.4 regression): a stray horizontal line appeared below the footnote/annotation marker because the footnote `<aside epub:type="footnote">` (with the book CSS's `border:3px #333 double`) stopped being hidden.
|
||||
|
||||
**Root cause:** PR #4383 (`e8675fb7e`, inline custom @font-face) changed `getStyles` assembly in `src/utils/style.ts` from `${pageLayoutStyles}...` to `${customFontFaces}\n${pageLayoutStyles}...`. The `@namespace epub "..."` declaration lived *inside* `getPageLayoutStyles`. Per the CSS spec a `@namespace` rule is honored ONLY if it precedes every style/`@font-face` rule — a misplaced one is silently ignored. The inlined `@font-face` rules pushed `@namespace` down, invalidating it, so the namespaced selector `aside[epub|type~="footnote"] { display:none }` was dropped and the aside border showed. Only hit users **with custom fonts loaded** (otherwise `customFontFaces` is empty and `@namespace` stayed first).
|
||||
|
||||
**Fix:** Hoist `@namespace` to the very front of the assembled stylesheet (`const epubNamespace = '@namespace epub "..."'; return \`${epubNamespace}\n${customFontFaces}\n${pageLayoutStyles}...\``) and remove it from `getPageLayoutStyles`. Custom faces still precede the `--serif`/`--sans-serif` lists, preserving #4383's first-paint intent.
|
||||
|
||||
**Gotchas verified the hard way:**
|
||||
- `epub:type` is a *namespaced* attribute only when the doc is parsed as XHTML/XML (foliate loads EPUB content as XHTML). Playwright `page.setContent` parses as **HTML**, where `epub:type` is a plain attr and `[epub|type~=...]` never matches — repro must use `data:application/xhtml+xml` via `page.goto`.
|
||||
- The existing test `style-get-styles.test.ts` literally asserted the buggy order (`@font-face` before `@namespace`) on the false premise that an @font-face must precede the font-family rules that use it. It must not — @font-face rules are collected regardless of source position; only same-family redefinition cares about order.
|
||||
|
||||
Related: [[css-style-fixes]], [[table-dark-mode-tint-4419]] (both in the bug-prone `style.ts`).
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
name: global-annotation-pageturn-perf-4575
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 4640857e-9c37-4ec6-890a-8aa20ec3a3f3
|
||||
---
|
||||
|
||||
**#4575** — "after highlighting several main-character names, page turning is very laggy" (Chinese web-novel TXT). Root cause = the **global highlight** feature (highlight-all-occurrences, `note.global`), NOT plain highlights. The `progress` effect in `Annotator.tsx` (`for (const a of annotationIndex.globals) expandAllRenderedSections(view, a)`) re-fans-out EVERY global note across EVERY rendered section on EVERY page turn. Each pass: TreeWalker over the section DOM (`findTextRanges` in `globalAnnotations.ts`) + `view.getCFI(index, range)` per occurrence (~0.2ms each, the dominant cost) + `overlayer.add` which removes+recreates an SVG and calls `getRects` (forces layout). Overlays already exist after pass 1 → pure waste.
|
||||
|
||||
**Profiled live** (dev-web + claude-in-chrome, real `<foliate-view>`): 6 names / 226 occurrences across 2 rendered chapters = **~25–45ms synchronous main-thread per page turn** on desktop (×3–5 on mobile = the lag). Leads 姜窈(73×) 驰厉(66×) per 2 chapters.
|
||||
|
||||
**Fix (PR branch `fix/global-annot-pageturn-4575`, commit f1404c6b1):** module-level `WeakMap<Document, Map<noteId, signature>>` `expandedByDoc` in `globalAnnotations.ts`. `expandGlobalAnnotation` skips when `docMemo.get(note.id) === signature`; records after expanding (even 0 matches). `signature = updatedAt:style:color:text`. `removeGlobalAnnotationOverlays` clears the memo entry. Turns 2..N → ~0ms; one-time cost stays at section-render (`onCreateOverlay`). 5 unit tests in `src/__tests__/utils/global-annotations.test.ts`.
|
||||
|
||||
**Correctness invariant:** `doc` & `overlayer` are created/destroyed together per section content-record, and `getContents()` returns STABLE doc/overlayer refs across separate calls (verified). So "same doc ⟺ overlays still present" — a re-rendered section gets a fresh `doc` → memo miss → re-expand; never wrongly skips.
|
||||
|
||||
**Secondary complaints in the issue (NOT fixed here):** slow TXT import (`txt.ts` parse, "2MB should be instant"), slow TOC/notes open. Separate concerns.
|
||||
|
||||
**Repro recipe — import a GBK TXT into dev-web without the native picker:** copy the .txt into `public/`, then in-browser `fetch('/file.txt')` → `arrayBuffer` → `new File([buf], '原名.txt')` → `DataTransfer` → dispatch a synthetic `drop` `DragEvent` (with `Object.defineProperty(ev,'dataTransfer',{value:dt})`) on `.library-page`. Raw bytes preserved → app's encoding detection handles GBK. Books at `/Users/chrox/Documents/books/issues/4575/`. See [[tts-sync-chrome-verification]] for the live-foliate-view profiling pattern.
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
name: iframe-cross-realm-instanceof
|
||||
description: "App-bundle code handling foliate iframe DOM must not use `instanceof Element/HTMLElement` — cross-realm, always false"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 2e8d274e-a4da-4d63-8afb-d7a600d560b2
|
||||
---
|
||||
|
||||
Reader content lives in foliate iframes, each with its **own JS realm**. App-bundle
|
||||
code (e.g. `src/utils/style.ts`, `iframeEventHandlers.ts`) runs in the **top window
|
||||
realm**, so its `Element`/`HTMLElement` constructors differ from the iframe's.
|
||||
|
||||
`someIframeEl instanceof Element` (top-realm `Element`) is **always `false`** — verified:
|
||||
`Element === iframeWin.Element` is false. So any guard like `if (!(target instanceof Element)) return`
|
||||
silently drops every real iframe element. Functions still pass jsdom unit tests (single realm)
|
||||
yet are dead in the running app.
|
||||
|
||||
**Use duck-typing instead**: `if (!target || !('closest' in target)) return;` then
|
||||
`(target as Element).closest(...)`. For node-type checks use `node.nodeType === Node.ELEMENT_NODE`
|
||||
(numeric constant, realm-safe) or check `classList`/`closest` presence.
|
||||
|
||||
Seen in PR #4391 (wide-table horizontal scroll): the touch `findWrapper` and `applyTableStyle`'s
|
||||
re-entrancy guard both used `instanceof`, so the touch routing never fired and the dedupe guard
|
||||
never tripped. Related: [[foliate-touch-listener-capture-phase]].
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: inline-block-column-overflow
|
||||
description: Foliate paginator fix — atomic-inline (inline-block) boxes too tall to fragment clip content in paginated mode
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: f0c35f7b-d4ff-4275-9f13-7019b0e167d9
|
||||
---
|
||||
|
||||
Bug: a chapter's content jumps straight to its "Reference materials", silently skipping a large middle (deep-dive/wrap-up). Repro book: "System Design Interview Vol.2" (System Design EPUB), Chapter 8 `OEBPS/c554.xhtml`, reported "at page 346".
|
||||
|
||||
Root cause: the EPUB's own CSS wraps the whole chapter body in a `<div>` with `display: inline-block` (`.class_s5mz1`). Atomic inline-level boxes (inline-block / inline-flex / inline-grid / inline-table) **cannot fragment across CSS columns**, so in paginated (columnized) mode the 7700px-tall box overflows the page vertically and every column past the first is clipped → "1 page left in chapter" while most of the chapter is unreachable. Direct `goTo({index})` and forward `next()` both still RENDER the section (engine traverses by content), so the symptom only manifests as clipped/unreachable pages + bogus page counts; scrolled mode is unaffected (vertical overflow is normal there).
|
||||
|
||||
Diagnosis tell: in column mode `documentElement.scrollHeight >> clientHeight` (e.g. 7768 vs 632); late headings stack vertically at one far-right column-left offset instead of spreading across columns.
|
||||
|
||||
Fix: `packages/foliate-js/paginator.js` → `#demoteUnfragmentableBoxes(availableHeight)`, called from `columnize()` after `setImageSize` (column-mode only). Guarded fast-path: returns immediately unless `scrollHeight > clientHeight + 1`. When overflowing, scans `body.querySelectorAll('*')`, and for any element whose computed display is atomic-inline AND `getBoundingClientRect().height > availableHeight`, demotes to the fragmentable equivalent (inline-block→block, inline-flex→flex, inline-grid→grid, inline-table→table) via `setStylesImportant`. Idempotent (demoted elements no longer match), regression-free (short legit inline-blocks like side-by-side figures untouched — they're never page-tall). Mirrors the existing `setImageSize` over-tall-image clamp and the `p { display: block }` rule in `style.ts` ("epubs set insane inline-block for p").
|
||||
|
||||
Test: `src/__tests__/document/paginator-inline-block-overflow.browser.test.ts` + fixture `repro-inline-block-overflow.epub` (one chapter wrapped in `.wrap{display:inline-block}`, 50 paras + TAIL_MARKER). Asserts `scrollHeight <= clientHeight+2`, wrap display `block`, tail heading in a later column within page height. Needs real layout → browser test (jsdom has no layout). Dev server (Next/Turbopack) picks up the workspace foliate-js edit on reload. Related: [[paginator-gutter-bleed-asymmetry-4394]].
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: ios-instant-dict-double-popup
|
||||
description: iOS instant system-dictionary fired 2-3× per long-press + tap-to-deselect re-opened it + Word Lens ignored system dict; deferredAction once-per-gesture latch + long-press-hold gate
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 1c09f918-0b1d-4f75-b1c6-8cef5eb73d60
|
||||
---
|
||||
|
||||
Three related instant-dictionary bugs fixed together (dev branch, 2026-06-18). Core: the **instant quick action** (Annotator effect `[selection,bookKey]` → `handleQuickAction`) fired per `selectionchange`, and **iOS emits MULTIPLE `selectionchange` for one long-press** (user log showed 3; Android emits 1). Each fire → `handleDictionary` (system path) → `invokeSystemDictionary` → native `show_lookup_popover` which drills to the top-most presented VC and stacks another `UIReferenceLibraryViewController` → 2-3 sheets. Android never hit it because it **defers the action to `touchend`** (coalesces); iOS fired immediately.
|
||||
|
||||
**Fix 1 (double/triple sheet)** — `src/app/reader/utils/deferredAction.ts`: added a `fired` latch so `runOrDeferAction`/`flushDeferredAction` run the action **at most once per gesture**; `beginGesture(state)` (clears pending + re-arms) called at gesture start — Android **native** `touchstart` (replaced `cancelDeferredAction`) and a NEW **non-Android DOM `pointerdown`** listener in `Annotator.tsx` (gated `!isAndroidApp`; Android keeps its native path).
|
||||
|
||||
**Fix 2 (tap-to-deselect re-opened dict ~1/3)** — after dismissing the sheet iOS leaves the word selected; the reselection is safe (latch still set, no WebView pointerdown from the modal swipe), but tapping outside to deselect IS a pointerdown → `beginGesture` re-armed the latch, then a racy `selectionchange` re-reported the lingering word before collapse → re-fired. Fix = `isLongPressHold(pointerDownTime, now, 300ms)` gate in `handleQuickAction` (gated `!isAndroidApp`): only fire from a long-press hold (iOS selection appears ~500ms after pointerdown; tap-stray fires ~tens of ms). Touch pointerdown time recorded in the non-Android listener; **mouse records 0 → bypasses the gate** (desktop selects on pointerup).
|
||||
|
||||
**Fix 3 (Word Lens ignored system dict)** — Annotator effect `wantWordLensDict` branch hardcoded `setShowDictionaryPopup(true)`; changed to call `handleDictionary()` (which checks `isSystemDictionaryEnabled` → `invokeSystemDictionary`, else in-app popup), same as the toolbar/instant paths. See [[wordlens-feature]].
|
||||
|
||||
Verified on Xiaomi 13 Pro via CDP+adb (`src/__tests__/android/helpers/*`): real system-dict path on Android = `ACTION_PROCESS_TEXT`→Eudic; instant dict fired once/long-press + re-armed gesture 2; gloss tap → `handleDictionary system=true`+`invokeSystemDictionary os=android`. iOS confirmed by user. Gotchas: `longPressWord` waits for a persistent selection → times out in instant-action mode (the action dismisses the selection); count fires via a `console.info` hook read over CDP `evaluate` instead. `openFixtureBook`'s >200-char gate fails when the fixture's saved progress lands on the sparse feedbooks end-page → connect to the already-open reader instead.
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
name: issue-4112-scroll-anchoring
|
||||
description: "Root cause for readest#4112 scrolled-mode backward-nav bugs — scroll-anchoring suppressed at scrollTop 0 when prepending a section"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: e0b11058-53ee-4554-a518-134f788823ee
|
||||
---
|
||||
|
||||
**RESOLVED** — merged in readest/readest#4349 (foliate-js fix + submodule bump + browser tests). Kept as reusable paginator scroll-mode knowledge.
|
||||
|
||||
readest/readest#4112 — two scrolled-mode bugs, ONE root cause.
|
||||
|
||||
**Root cause:** Browser scroll-anchoring (`overflow-anchor: auto`, paginator.js container) is **suppressed when scrollTop === 0**. The multiview paginator preloads the *previous* section by inserting its View element **above** the current one (`#loadAdjacentSection`, sorted insertion in `#createView`). When that prepend happens while `scrollTop === 0`, the inserted section pushes the current content down with no scroll compensation, so the viewport ends up showing the previous section.
|
||||
|
||||
**Bug 1 (TOC backward jump → lands on n-1):** Reproduce by navigating *one section back* (target N-1 is an already-loaded adjacent view → `#goTo` "view already loaded" branch, NOT `#display`). `scrollToAnchor(0)` lands at scrollTop 0 with target as topmost view; ~250ms later the **debounced** backward-preload (paginator.js ~line 977) inserts N-2... wait, inserts the section before the target, at scrollTop 0 → suppression → viewport drifts to target-1. Intermittent (~1/3–2/3) because it races `#fillVisibleArea`'s reanchor. `primaryIndex` stays = target but the *visible* top section = target-1.
|
||||
|
||||
**Bug 2 (can't scroll up / jumps to beginning of prev section):** same suppression — prev section inserted above at scrollTop≈0 shifts viewport to the *beginning* of prev instead of staying put. Backward-preload is debounced-only (forward preload is eager/immediate) → asymmetry adds lag.
|
||||
|
||||
**FIX (landed on foliate-js branch `fix/scrolled-prev-prepend-anchor`, 2 changes in `#loadAdjacentSection` + `#goTo`):**
|
||||
1. Manual scroll compensation at the single prepend choke point `#loadAdjacentSection`: when prepending in scrolled mode (`index < sortedViews[0]`), after `await view.load()`, set `#renderedStart` to `startBefore + addedSize`. `containerPosition += (#vertical ? -1 : 1) * correction`; no-op (correction≈0) when the browser already anchored at scrollTop>0. Fixes drift (Bug 1) + scroll-up-shows-beginning (Bug 2b).
|
||||
2. The already-loaded `#goTo` branch only preloaded prev for *short* sections (`contentPages < columnCount`); changed to `needsPrev || this.scrolled` (+ `#isSameDirection` guard), mirroring `#display`. Fixes can't-scroll-up (Bug 2a) — the debounced backward-preload bails while `#stabilizing` after nav, so nav must preload prev itself.
|
||||
|
||||
**UX follow-ups (same branch, same file):**
|
||||
3. **No blank flash on adjacent nav**: the already-loaded `#goTo` branch faded the container `opacity 0→1`; in continuous scrolled mode that flashed (worse after change 2 put the prev-load inside the blank window). Now `blank = !this.scrolled || this.noContinuousScroll` — continuous scrolled scrolls straight to the (already-rendered) target. `loadPrev` helper: paginated loads prev BEFORE the scroll (fill leading columns), scrolled loads it AFTER (instant transition; compensation keeps position).
|
||||
4. **Eager backward preload**: removed the debounced, one-viewport-gated backward preload; added an eager one in the immediate scroll listener mirroring forward (`pagesBehind < minPages`, scrolled-gated). Fixes "scroll up dead-ends at top until you nudge down". Safe now because change 1 compensation handles position stability (the old "debounced to avoid cascade" reason is obsolete).
|
||||
|
||||
**Verified live + tests.** Scrolled regression tests live in `paginator-scrolled.browser.test.ts` (split out of the old `paginator-multiview.browser.test.ts`, which was renamed to `paginator-paginated.browser.test.ts` for the default/paginated + CFI tests). The 4 #4112 tests: drift / prev-preload-after-nav / no-blank / eager-backward-within-a-few-viewports (+ the moved 'columnCount=1 in scrolled mode' and the #3987 toggle-off test). `pnpm test` (4921) + `pnpm lint` + `pnpm format:check` + 57 paginator browser tests green (4 files: scrolled, paginated, expand, stabilization).
|
||||
|
||||
**GOTCHA for live verification:** programmatic `el.scrollTop = N` does NOT fire 'scroll' events in the claude-in-chrome context (real wheel/touch does). To test scroll-driven preloading via the JS console, set scrollTop AND `el.dispatchEvent(new Event('scroll'))`. Also: the Next.js 16 dev server bundles foliate-js (transpilePackages); editing paginator.js hot-reloads, but verify the served chunk has your edit (fetch `_next/static/chunks/packages_foliate-js_paginator_*.js` and grep) — recompile can lag.
|
||||
|
||||
readest-app test change is uncommitted on `dev`; foliate-js fix on branch `fix/scrolled-prev-prepend-anchor` (uncommitted) → needs a PR to readest/foliate-js then a submodule bump.
|
||||
|
||||
**Verification harness:** localhost:3000/reader/<id> book "凡人修仙传" (2470 sections). Expose `__pg`/`__fv`, tag view elements with section index via `iframe.contentDocument` identity, measure `visibleTopSec()` vs target after settle. jsdom CANNOT reproduce (no real layout); use Chrome.
|
||||
|
||||
Key file: `packages/foliate-js/paginator.js` (submodule, fork readest/foliate-js).
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
name: issue-4584-tap-death-investigation
|
||||
description: "#4584 single-taps-dead-after-picture-zoom: isPopuped self-heals (red herring), likely WebView-148-specific, plus Android emulator/CDP gesture-verification gotchas"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: a41b6cab-c0f3-4740-a4c0-61a10b68fc09
|
||||
---
|
||||
|
||||
#4584 (Android): after using picture zoom (long-press image → ImageViewer → close),
|
||||
single taps stop registering until app restart while long-press keeps working. OP on
|
||||
Android 16 / WebView 148. **NOT root-caused / NOT fixed.** PR #4600 only adds single-tap
|
||||
as a second way into the viewer (see [[tap-to-open-image-table-4600]]); it does not fix
|
||||
the tap-death.
|
||||
|
||||
**Red herring — don't "fix" `isPopuped`.** An adversarial analysis pointed at
|
||||
`useTextSelector.ts` `handleSingleClick`'s `isPopuped` branch consuming a tap without
|
||||
resetting the flag (unlike its `isTextSelected`/`isUpToPopup` siblings). But it
|
||||
**self-heals**: the branch calls `handleDismissPopup()` → `showingPopup` false →
|
||||
`handleShowPopup(false)` → `isPopuped` false after 500ms. And the uncancelled 500ms
|
||||
timer in `handleShowPopup` always converges `isPopuped` to the FINAL `showingPopup`
|
||||
(timers are same-delay FIFO), so it can't strand `isPopuped=true` with `showingPopup=false`.
|
||||
Net effect is at most a one-tap glitch, NOT permanent. Resetting it in the branch is
|
||||
harmless but does NOT explain #4584.
|
||||
|
||||
Likely **WebView-148-specific**: the OP reproduces; the maintainer's Xiaomi and the
|
||||
Pixel_9_Pro emulator (WebView **133**) do NOT. A stronger unverified permanent-death
|
||||
candidate is `isInstantAnnotating`/`scrollLocked` getting stuck when the ImageViewer
|
||||
overlay swallows the `pointerup` (needs instant-annotation enabled).
|
||||
|
||||
**Android emulator gesture-verification gotchas (this machine):**
|
||||
- Host-GPU `Pixel_9_Pro` AVD CRASHES on rapid pinch (Vulkan `bad_function_call`); a
|
||||
re-launch can hang at Vulkan init. `-gpu swiftshader_indirect` boots reliably but is
|
||||
too slow for the WebView reader → repeated "Input dispatching timed out for
|
||||
FocusEvent" ANRs under automation. **That ANR is an EMULATOR ARTIFACT, not app code**
|
||||
(JS stays responsive; CPU runs native arm64 on Apple Silicon, only the GPU is
|
||||
swiftshader; CPU profile of page-turns is light; full-screen backdrop-blur made zero
|
||||
frame-time difference).
|
||||
- CDP `Input.dispatchTouchEvent` CANNOT trigger the WebView's native text-SELECTION
|
||||
gesture, so the annotation popup never appears (blocks reproducing selection-driven
|
||||
bugs). It also leaves `screenX=0`, so `handlePageFlip` treats every synthetic tap as
|
||||
left/`prev()` (no-op at page 1 → navigate mid-book to test page-turns via relocate).
|
||||
- Native `adb shell input tap` does NOT reach the WebView's click pipeline.
|
||||
- Long-press via CDP only works if you HOLD the touch on one open connection and poll
|
||||
(a touchStart→sleep→touchEnd across separate `node` invocations releases too early).
|
||||
- Drive the live on-device WebView: `adb forward tcp:9222 localabstract:webview_devtools_remote_<pid>`
|
||||
+ a `ws` CDP client; the debug APK exposes the socket. VIEW-intent a pushed EPUB via its
|
||||
MediaStore `content://.../<id>` to import it.
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: koplugin-cover-upload
|
||||
description: "koplugin cover system — where local/cloud covers come from, and the"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: reference
|
||||
originSessionId: 95cdff70-ce2c-4077-82a8-922f9578a22f
|
||||
---
|
||||
|
||||
readest.koplugin cover handling (in `apps/readest.koplugin/library/`):
|
||||
|
||||
- **Local book covers** come from coverbrowser.koplugin's `BookInfoManager` (a hard dependency). `coverprovider.get_local_cover(file_path)` returns `BIM:getBookInfo(file_path, true).cover_bb` (a downscaled thumbnail bb) and kicks off background extraction on a miss.
|
||||
- **Native-resolution cover** for a file (no open doc needed): `require("apps/filemanager/filemanagerbookinfo"):getCoverImage(nil, file_path)` — opens+closes the doc itself, honors KOReader custom covers, returns a blitbuffer. Same `(nil, file)` call form `calibre.koplugin` uses. Write it to PNG with `bb:writeToFile(path, "png")` (pcall-wrapped internally) then `bb:free()`.
|
||||
- **Cloud covers** are downloaded to `DataStorage:getSettingsDir()/readest_covers/<hash>.png` (`cloud_covers.covers_dir()`); cloud storage key is `<user_id>/Readest/Books/<hash>/cover.png` (`build_cover_key`), which matches readest's `getCoverFilename` (`<hash>/cover.png`).
|
||||
|
||||
**Issue #4374 (fixed):** `syncbooks.uploadBook` only shipped a `cover.png` if one was *already cached* under `readest_covers/<hash>.png` from a prior cloud download — so books that originated locally in KOReader uploaded with no cover and showed blank in readest. Fix = `syncbooks.extractLocalCover(file_path, dst_png)` (extracts via `getCoverImage` → writeToFile), called from `uploadBook` when `has_cover` is false. Only file-upload path is `librarywidget.lua` "Upload to Cloud" → `uploadBook` (FileManager `addToReadest` only stages a local row).
|
||||
|
||||
Tests: network/live parts of `syncbooks.lua` aren't unit-tested (manual matrix in `docs/library-design.md`); `extractLocalCover` IS tested by injecting a fake `apps/filemanager/filemanagerbookinfo` via `package.loaded` in `spec/library/syncbooks_spec.lua`. A real KOReader checkout lives at `/Users/chrox/dev/koreader` for verifying KOReader APIs. See [[koplugin-i18n]].
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
name: readest.koplugin i18n system
|
||||
description: Custom gettext loader, .po catalog layout, and extract/apply scripts for the KOReader plugin at apps/readest.koplugin/
|
||||
type: reference
|
||||
originSessionId: 08cfd0cd-b710-4674-9c90-d2ae4827d071
|
||||
---
|
||||
The KOReader plugin (`apps/readest.koplugin/`) has its own gettext-based i18n system, parallel to but separate from the readest-app i18next setup.
|
||||
|
||||
## Loader
|
||||
|
||||
- File: `apps/readest.koplugin/i18n.lua` — isolated module, returns a callable table via `setmetatable({}, {__call = ...})`, so `_("msg")` syntax works as a drop-in replacement for `require("gettext")`. Provides `ngettext`/`pgettext`/`npgettext` too. Falls back to KOReader's native `gettext` for missing strings.
|
||||
- All Lua sources do `local _ = require("i18n")` (not `require("gettext")`).
|
||||
- **Never rename `i18n.lua` to `gettext.lua`** — it would shadow KOReader's module via `require("gettext")` and break the fallback chain (recursive require / never loaded).
|
||||
|
||||
## Catalog layout
|
||||
|
||||
- `apps/readest.koplugin/locales/<lang>/translation.po` — mirrors `apps/readest-app/public/locales/<lang>/translation.json` exactly, using **i18next-style codes** (`zh-CN`, not `zh_CN`).
|
||||
- The loader converts KOReader's locale (e.g. `zh_CN.utf8` → `zh-CN`) before lookup.
|
||||
- Fallback chain: full code → base lang (`pt_BR` → `pt`) → `zh-CN` for any unspecified zh variant.
|
||||
- Language list is the single source of truth at `apps/readest-app/i18next-scanner.config.cjs` (`options.lngs`) — currently 31 languages.
|
||||
|
||||
## Scripts (in `apps/readest.koplugin/scripts/`)
|
||||
|
||||
- **`extract-i18n.js`** — primary tool. Run with `node scripts/extract-i18n.js`. Scans `*.lua` for `_("...")`, `_('...')`, and `_([[...]])` (with proper Lua-escape handling), reads each `.po`, **preserves existing translations**, adds new msgids with empty `msgstr`, drops obsolete msgids. Idempotent.
|
||||
- **`apply-translations.js`** — bulk applier. Reads `/tmp/koplugin-translations/<lang>.json` files (key = msgid, value = translation) and fills empty `msgstr ""` lines only — **never overwrites** existing translations.
|
||||
|
||||
## Workflow for adding/changing strings
|
||||
|
||||
1. Edit Lua source(s). Use `_("Foo")` or `T(_("Foo %1"), arg)` (`T` from `require("ffi/util").template`) — **never** `_("Foo ") .. arg`, because RTL/verb-final languages can't reorder the placeholder.
|
||||
2. `node scripts/extract-i18n.js` — adds new empty msgids, drops obsolete.
|
||||
3. To translate: drop `<lang>.json` files into `/tmp/koplugin-translations/`, then `node scripts/apply-translations.js`.
|
||||
4. Verify with `luac -p apps/readest.koplugin/*.lua` and re-run `extract-i18n.js` (should report no changes — idempotency check).
|
||||
|
||||
## Translation conventions
|
||||
|
||||
- Brand names "Readest" and "KOReader" stay untranslated.
|
||||
- Technical terms ("PDF", "API", "URL", "Supabase", "Hash") generally kept as-is, sometimes transliterated in non-Latin scripts.
|
||||
- Dialog title = title case (`Sync Info`); menu item label = sentence case (`Sync info`).
|
||||
- Lower-confidence translations (bo, si, ta, bn, sl, fa) deserve native-speaker review.
|
||||
|
||||
## Storage conventions for plugin state
|
||||
|
||||
- Global plugin state: `G_reader_settings:saveSetting("readest_sync", settings)` — login tokens, auto-sync flag, etc.
|
||||
- Per-book state: `ui.doc_settings:readSetting("readest_sync")` table — keys like `meta_hash_v1`, `last_synced_at_config`, `last_synced_at_notes` (seconds since epoch).
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: koplugin-note-deletion-sync
|
||||
description: "koplugin deleting a note didn't sync to Readest — push only walked live annotations; fixed with persisted tombstones"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: f3f6e25e-1bde-4e08-9fc9-c8184d1ef457
|
||||
---
|
||||
|
||||
readest.koplugin deletions of highlights/bookmarks never reached the server (#4119 push direction; the pull direction was already handled by `removeDeletedAnnotations`).
|
||||
|
||||
**Root cause:** `SyncAnnotations:push` builds its payload from `getAnnotations`, which walks `ui.annotation.annotations` (the *live* list). A deleted note is already removed from that list, so it can never appear in a push — the server never gets a `deleted_at`, and the next pull resurrects it.
|
||||
|
||||
**Fix (all in `apps/readest.koplugin/`):**
|
||||
- `readest_syncannotations.lua`: extracted `buildNoteDescriptor(item, book_hash, meta_hash)` (shared by `getAnnotations` and the new deletion path). Added `recordDeletion(doc_settings, item)` — stamps `deletedAt = os.time()*1000` on the descriptor and persists it under `doc_settings.readest_sync.deleted_notes` (per-book sidecar, survives restart/offline). `push` folds `deleted_notes` into the payload (re-stamping current book/meta hash) and clears them in the success callback only (failed push retries).
|
||||
- `main.lua` `onAnnotationsModified(items)`: detects a removal via `items.index_modified < 0` (additions use positive index_modified; note-text edits / type-changes carry no index_modified — see KOReader `ReaderBookmark:removeItemByIndex`). Records the tombstone when `access_token` is set (independent of `auto_sync`, so a later manual/close push still carries it).
|
||||
|
||||
**Key facts:** note id is deterministic — server-created notes keep their stored `item.id` (pulled in), koplugin-created ones derive `generateNoteId(hash, type, pos0, pos1)`; both match the server row, so the tombstone targets it. Server `sync.ts` POST picks the tombstone via `clientDeletedAt > serverDeletedAt` (deletedAt wins even when updatedAt is stale). Missing `text`/`note` in the payload is fine (`sanitizeString(undefined)` returns undefined; plain highlights already push `note=nil`). Tests in `spec/syncannotations_spec.lua` (recordDeletion + push-folds-tombstones, UI-glued push driven by stub client/doc_settings). Related: [[custom-fonts-reincarnation-4410]] (CRDT remove-wins).
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: koplugin-stats-sync
|
||||
description: KOReader readest.koplugin reading-stats sync (push on close / pull on open); 3-bug chain fixed in PR
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 9b3eaf27-8688-4293-ab42-f635af2e1905
|
||||
---
|
||||
|
||||
`readest.koplugin` reading-statistics sync (KOReader `statistics.sqlite3` page events) — fixed in PR #4666 (`readest_syncstats.lua`, `main.lua`, `readest-sync-api.json`).
|
||||
|
||||
**Trigger model (fully automatic, no menu/gesture):**
|
||||
- **Pull** on book OPEN: `onReaderReady` → `pullBookStats(false)` (via `nextTick`).
|
||||
- **Push** on book CLOSE: `onCloseDocument` → `pushBookStats(false)` (wrapped in `goOnlineToRun`).
|
||||
- Both gated on `self.settings.auto_sync and self.settings.access_token`, `interactive=false` (silent on failure).
|
||||
- NOT per-book: `collectSince` queries the whole `statistics.sqlite3` (`page_stat_data JOIN book`, no book filter). Open/close is just the trigger; it syncs the entire stats delta. Incremental via `stats_push_cursor` (max `start_time`) / `stats_pull_cursor` (max `updated_at_ms`); cursor advances only on full success.
|
||||
|
||||
**3 stacked bugs (each hid the next):**
|
||||
1. `push`/`pull` called `settings:readSetting/saveSetting` on `self.settings`, which is the PLAIN `readest_sync` data table (from `G_reader_settings:readSetting("readest_sync", default)`), NOT a `LuaSettings` object → `attempt to call method 'readSetting' (a nil value)` on every open. Fix: field access + persist via `G_reader_settings:saveSetting("readest_sync", settings)` (mirrors `readest_syncauth.lua`). Field-access pattern is used everywhere else (`self.settings.access_token`, etc.).
|
||||
2. `pushChanges` requires `books/notes/configs` (`required_params`); stats sent only `statBooks/statPages` → Spore `books is required`. Fix: send empty `books={},notes={},configs={}` alongside. Server (`src/pages/api/sync.ts`) defaults each to `[]` and processes `statBooks`/`statPages` independently (gated only on their own `.length`).
|
||||
3. `statBooks/statPages` were in the spec's `payload` but not `optional_params` → Spore `statBooks is not expected`. **Spore's expected-param set = `required_params ∪ optional_params`; `payload` only controls body serialization, NOT acceptance** (`common/Spore/Request.lua` `validate()`). Fix: add `optional_params:["statBooks","statPages"]` to `readest-sync-api.json`. Must be optional not required (library/config/annotation pushes legitimately omit them).
|
||||
|
||||
**Spore client:** `readest_syncclient.lua` `_dispatch` runs the RPC in a coroutine with Turbo `AsyncHTTP` (network is non-blocking, yields to event loop); `Format.JSON` encodes the body synchronously first. `SYNC_TIMEOUTS={5,10}` (block/total).
|
||||
|
||||
**Large-backlog blocking risk (UNFIXED, potential follow-up):** push sends the WHOLE backlog in one request (no client chunking; only pull is paginated). For ~10k `page_stat_data` rows: `collectSince` builds a 10k-entry Lua table + ~1MB JSON encode SYNCHRONOUSLY on the main thread → ~1-2s UI stall on weak e-ink CPUs at book close (network part is async, doesn't freeze). Worse: 10s timeout + no chunking + all-or-nothing cursor → if it can't finish in time it fails silently, cursor stays 0, and every later close re-collects/re-encodes/re-uploads the same backlog (server upserts are idempotent but wasteful). Fix idea: chunk push (~500-1000/req, matching server `BATCH=500`), advance cursor per successful chunk.
|
||||
|
||||
**Testing gap that hid it:** `spec/syncstats_spec.lua` originally tested only `collectSince`/`applyRemote`, never `push`/`pull`. Added tests: cursor advance on success (mock client enforces `required_params`), pull cursor from newest `updated_at_ms`, and a spec-level check parsing `readest-sync-api.json` to assert every stats-push key is an expected param (reproduces bug 3). Busted runs with `cwd=KOPLUGIN_DIR` so `io.open("readest-sync-api.json")` works; `require("json")`→dkjson via spec_helper shim. Debug logging kept (`ReadestStats` prefix). Related: [[koplugin-note-deletion-sync]], [[kosync-cfi-spine-resolution]].
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: kosync-cfi-spine-resolution
|
||||
description: "KOSync CFI↔XPointer conversion must resolve via the CFI's own spine index, not the paginator's primaryIndex (which lags during scroll)"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 41b15d60-969d-4173-9db7-d1515721e527
|
||||
---
|
||||
|
||||
KOSync progress push/pull converts between foliate CFI and KOReader XPointer via `src/utils/xcfi.ts`. `XCFI` is constructed for ONE spine section (`spineItemIndex`) and `cfiToXPointer`/`xPointerToCFI` throw `CFI spine index N does not match converter spine index M` if asked to convert a CFI from a different section.
|
||||
|
||||
**Pitfall:** `view.renderer.primaryIndex` lags behind the viewport during scrolling (see `paginator.js` `#primaryIndex` comment). So `progress.location` can be a CFI in a different spine section than the currently-rendered primary view. Building the converter from the primary view's `doc`/`index` then converting `progress.location` throws on mismatch.
|
||||
|
||||
**Fix (PR branch fix/kosync-spine-index):** always go through the exported helpers `getXPointerFromCFI(cfi, doc?, index?, bookDoc)` / `getCFIFromXPointer(...)` in `xcfi.ts`. They call `XCFI.extractSpineIndex(cfi)` and, when the passed `index` ≠ the CFI's spine, load the correct section's document via `bookDoc.sections[xSpineIndex].createDocument()`. `generateKOProgress` in `src/app/reader/hooks/useKOSync.ts` had hand-rolled `new XCFI(primaryDoc, primaryIndex)` instead of using the helper — that was the bug.
|
||||
|
||||
**Why:** the helper already handles cross-section resolution correctly; never reconstruct the converter inline against `primaryIndex`.
|
||||
|
||||
**How to apply:** for any CFI→XPointer or XPointer→CFI in KOSync code, use the `getXPointerFromCFI`/`getCFIFromXPointer` helpers and pass `bookDoc` so off-screen sections can be loaded. Related: [[issue-4112-scroll-anchoring]] (same primaryIndex/scroll-lag family). The companion foliate `fromRange` crash (`Cannot destructure property 'nodeType'`) during relocate is separate scroll-lag noise, not the sync blocker.
|
||||
|
||||
**Conflict-detection comparison (`promptedSync` in `useKOSync.ts`):** KOReader's reported `remote.percentage` comes from CREngine pagination and is NOT comparable to Readest's progress. For reflowable books, convert the remote XPointer → local CFI → `view.getCFIProgress(cfi).fraction` (helper `getRemoteLocalFraction` in `kosyncProgress.ts`) and compare THAT to the local percentage; fall back to `remote.percentage` only when it can't be resolved locally (non-XPointer/Kavita progress or missing section). The resolved fraction also drives the "Approximately X%" remote preview so the dialog shows what was actually compared. Fixed-layout still compares page-derived `remotePercentage`. Threshold: `0.0001` cross-device, but loosened to `0.01` when `remote.device_id === settings.kosync.deviceId` (same device = our own earlier push; don't prompt on sub-page drift). Percentages render via `formatProgressPercentage` (2 decimals, `kosyncPreview.ts`).
|
||||
@@ -1,70 +0,0 @@
|
||||
# Layout & UI Fixes Reference
|
||||
|
||||
## Safe Area Insets
|
||||
|
||||
### Architecture
|
||||
- Native plugins push inset values: iOS (`NativeBridgePlugin.swift`), Android (`NativeBridgePlugin.kt`)
|
||||
- `useSafeAreaInsets` hook (`src/hooks/useSafeAreaInsets.ts`) reads and caches values
|
||||
- Components use `gridInsets` for positioning relative to safe areas
|
||||
- CSS: `env(safe-area-inset-top/bottom/left/right)` for CSS-level insets
|
||||
|
||||
### Common Issues
|
||||
- **Stale insets after system UI change** (#3469): Call `onUpdateInsets()` after `setSystemUIVisibility()`
|
||||
- **iPad sidebar insets wrong** (#3395): Different inset handling needed for sidebar vs main view
|
||||
- **Android nav bar overlap** (#3466): Use `calc(env(safe-area-inset-bottom) + 16px)` for Android bottom padding
|
||||
- **Zoom controls behind status bar** (#3426): Pass `gridInsets` through component chain, use `Math.max(gridInsets.top, statusBarHeight)`
|
||||
|
||||
### Rules (see also `docs/safe-area-insets.md`)
|
||||
- Always pass `gridInsets` to overlay/floating components near screen edges
|
||||
- On Android, account for system navigation bar with `env(safe-area-inset-bottom)`
|
||||
- After toggling system UI visibility, force-refresh insets via `onUpdateInsets()`
|
||||
|
||||
## Z-Index Hierarchy
|
||||
- Navigation buttons: `z-10` (when visible)
|
||||
- Annotation nav bar: `z-10` (reduced from z-30 in #3386)
|
||||
- TTS control button: ensure above page nav buttons
|
||||
- Floating overlays: check against `gridInsets` positioning
|
||||
|
||||
## macOS Traffic Lights
|
||||
- Managed by `trafficLightStore.ts` and `HeaderBar.tsx`
|
||||
- **Fullscreen check required** (#3129): `await currentWindow.isFullscreen()` before hiding
|
||||
- **Timeout for visibility toggle** (#3488): Use 100ms delay to prevent flickering
|
||||
- **Sidebar interaction** (#3488): Check `getIsSideBarVisible()` before hiding traffic lights
|
||||
- Never hide traffic lights when sidebar is open
|
||||
|
||||
## Touch/Input Issues
|
||||
- **Slider hit area on iOS** (#3382): Use min-h-12, strip browser appearance with CSS
|
||||
- **Context menu on macOS** (#3324): 100ms delay before `onContextMenu()` to let pointer events complete
|
||||
- **Swipe sensitivity** (#3310): Use average velocity (distance/time) instead of instantaneous velocity for non-animated paging
|
||||
- **Touchpad natural scrolling** (#3127): Respect system natural scrolling setting in `usePagination.ts`
|
||||
|
||||
## Dialog/Menu Layout
|
||||
- **Dialog header** (#3352): Use `px-2 sm:pe-3 sm:ps-2` padding to align with border radius
|
||||
- **Settings alignment** (#3151): Use `Menu` component instead of raw `div` for consistent styling
|
||||
- **Dropdown for screen readers** (#3035): Use `DropdownContext` with overlay dismiss, not blur-based closing
|
||||
|
||||
## Component-Specific Fixes
|
||||
|
||||
### HeaderBar.tsx
|
||||
- Traffic light visibility management
|
||||
- Sidebar toggle persistent position (#3193)
|
||||
- Library button placement
|
||||
|
||||
### PageNavigationButtons.tsx
|
||||
- z-10 when visible (#3201)
|
||||
- Always shown for screen readers (#3036)
|
||||
- Toggle via `showPageNavButtons` setting
|
||||
|
||||
### ProgressInfo.tsx
|
||||
- Use physical `page`/`pages` from renderer, not estimated values (#3213, #3200)
|
||||
- CSS classes: `time-left-label`, `pages-left-label`, `progress-info-label` (#3343)
|
||||
|
||||
### ReadingRuler.tsx
|
||||
- Remove `containerStyle` from overlay so dimmed area covers full screen (#3304)
|
||||
|
||||
### NavigationBar.tsx
|
||||
- Handle `gridInsets` internally, not via pre-computed `navPadding` (#3466)
|
||||
|
||||
### ContentNavBar.tsx (annotation search results)
|
||||
- Floating buttons with drop shadow, not full-width bar (#3386)
|
||||
- z-10 z-index
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: manage-cache-ios-layout
|
||||
description: "iOS app container layout and what the Manage Cache feature can/can't clear"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: reference
|
||||
originSessionId: 3512356b-a453-42d3-99f6-1ca43d06dd1e
|
||||
---
|
||||
|
||||
Manage Cache feature: `src/utils/cache.ts` (helpers `getCacheEntries`/`getCacheStats`/`clearCacheEntries` over `CacheSource[]`), `src/app/library/components/CacheManagerWindow.tsx` (singleton dialog, `setCacheManagerDialogVisible`, `getCacheSources()` composes the source list), menu item in `SettingsMenu.tsx` Advanced Settings, mounted in `library/page.tsx`.
|
||||
|
||||
Scope: **native mobile apps only** (gated on `appService?.isMobileApp`; hidden on desktop + web). Sources cleared: iOS → Cache + Temp + `Documents/Inbox`; Android → Cache + Temp.
|
||||
|
||||
On-device analysis (dev build `com.bilingify.readest`, pulled via `xcrun devicectl device copy from --domain-type appDataContainer`):
|
||||
|
||||
- The `'Cache'` base = Tauri `appCacheDir()` = **`Library/Caches/com.bilingify.readest`** only — NOT all of `Library/Caches`. On the test device this held ~272 MB: a 249 MB duplicate dictionary (`concise-enhanced.mdx`, canonical copy lives in `App Support/Readest/Dictionaries/<id>/`), ~23 MB duplicate import-staged epubs (canonical in `App Support/Readest/Books/<hash>/`), the `search/` results cache, plus tiny system scratch. All safe to clear — every large item is an orphaned import/download staging duplicate.
|
||||
- iOS open-in leftovers: **`Documents/Inbox`** (resolved via `documentDir()` + join `Inbox`, scanned/cleared with base `'None'` + absolute paths). Already-imported books linger here. The feature now clears it on iOS. (`tmp/<bundle>-Inbox` also exists but was empty.)
|
||||
- NOT reachable by the `'Cache'` base: `Library/Caches/WebKit` (~173 MB, WKWebView disk cache — needs native `WKWebsiteDataStore.removeData`) and `tmp/` blob scratch (~59 MB, maps to `'Temp'` base, not currently cleared). These are the bigger "free up space" wins if the feature is ever expanded.
|
||||
- Never clear `Library/Application Support/com.bilingify.readest/Readest` — the real Books/Dictionaries/Fonts/DB (~1.7 GB).
|
||||
|
||||
Root-cause follow-up worth doing: the import pipeline leaves staging copies in the cache root and `Documents/Inbox` instead of deleting them after import.
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: nightly-updater-android-e2e
|
||||
description: "How to E2E-test the nightly self-updater on a real Android (Xiaomi/HyperOS) device — devtools build, CDP-over-adb, MIUI install gates"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: reference
|
||||
originSessionId: b0c01e3c-9485-45fe-8ae3-eb5f2762f8fa
|
||||
---
|
||||
|
||||
End-to-end validating the in-app nightly updater (#4577) on the physical Xiaomi 13 (`fuxi`, model 2211133C, HyperOS V816 / Android 16, arm64). Verified the full chain: stable `0.11.4` on nightly channel → fetch `nightly/latest.json` → detect newer nightly → dialog+changelog → download APK → minisign-verify → Android PackageInstaller → app relaunches as `0.11.4-2026061506` with user data intact.
|
||||
|
||||
**Get CDP on a release build:** `pnpm dev-android` = `tauri android build -t aarch64 -- --features devtools` then `adb install -r`. The `devtools` cargo feature enables WebView remote debugging; the **CI nightly has no such flag → no CDP socket**. The local build is release-signed via `src-tauri/gen/android/keystore.properties` (alias `upload`, keystore at `/Users/chrox/dev/Android/keys/upload-readest-keystore.jks`), cert SHA-256 `652d1167…` — SAME as the CI release/nightly cert, so it installs over (and is replaced by) the real nightly. versionCode for `0.11.4[-stamp]` is always `11004` (Tauri ignores the prerelease), and sideload allows equal versionCode. After the updater installs the real nightly, CDP is gone again (no devtools).
|
||||
|
||||
**Comparator (`utils/version.ts` `isUpdateNewer`):** on equal X.Y.Z, a nightly outranks the matching stable (`c.isNightly && !cur.isNightly → true`). So a stable build on the nightly channel IS offered the latest nightly — no need to stamp an older version. Two nightlies compare by the 10-digit stamp.
|
||||
|
||||
**CDP-over-adb gotcha:** `adb forward tcp:9333 localabstract:webview_devtools_remote_<PID>` (use the LIVE pid — stale sockets for dead pids linger in `/proc/net/unix`). The WebView's `/json` HTTP server breaks BOTH curl and Node `http` (framing → "empty reply"/"socket hang up"); fetch the page list over a RAW TCP socket instead, and build the ws URL yourself as `ws://127.0.0.1:9333/devtools/page/<id>` (the returned `webSocketDebuggerUrl` reflects the request Host header and drops the port). Then `Runtime.evaluate` works. Helper pattern lived in `/tmp/nightly-test/cdp.cjs`. Settings store is NOT on `window`; drive the real UI via evaluated `.click()` (Settings Menu → "Nightly Builds (Unstable)" toggle → "About Readest" → "Check Update").
|
||||
|
||||
**MIUI/HyperOS install gates (the hard part; needs adb taps + uiautomator, NOT WebView):** (1) "Readest 正尝试安装应用" → tap 继续. (2) "Couldn't find ICP registration info" (China-region nag) → tap **Install** (left grey), NOT the blue Exit. (3) Enhanced-protection installer shows NO direct install button — the visible "安装" is the AD app's (`installBtn`, `com.aliyun.tongyi` etc. — don't tap it); the real path is top-right **More (⋮) → "单次安装授权"** (one-time auth), then the bottom **OK**. "Security authorization → Authorize unverified apps" uses Face-unlock as the verification method (per-install biometric; single-install-auth covered it here). Use `uiautomator dump` to get exact button bounds — native dialogs are introspectable (unlike the WebView). Pin every adb cmd with `ANDROID_SERIAL` when an emulator is also attached; zsh doesn't word-split `$ADB` vars (use the env var or inline the full path).
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: notch-mask-texture-4486
|
||||
description: "Scrolled-mode top inset mask occluded the bg texture; clip-path full-cell trick aligns the mask's texture tiles with the viewer's; CDP-inject + MAE seam verify"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 47d2276d-0e04-455c-99b5-4fd0a651b579
|
||||
---
|
||||
|
||||
#4486 (PR #4563): in scrolled mode the `notch-area` in `SectionInfo.tsx` masks the top
|
||||
safe-area inset with opaque `bg-base-100` at z-10 (hides content scrolling under the
|
||||
status bar) — and painted over the texture (`.foliate-viewer::before` lives at the z-0
|
||||
paint layer, see [[paginated-texture-occlusion-4399]]). Flat untextured strip across the
|
||||
unsafe header area.
|
||||
|
||||
**Fix pattern (paint-box matching).** A texture `::before` only tile-aligns with the
|
||||
viewer's when `background-size: cover/contain` resolves against the SAME element box.
|
||||
So: make the mask span the grid cell (`inset-0`) and clip the visible+hit area to the
|
||||
strip with `clip-path: inset(0 0 calc(100% - topInsetPx) 0)`; add `.notch-masked::before`
|
||||
to the selector group in `styles/textures.ts`, gated by a conditional class only in
|
||||
scrolled-horizontal mode (paginated/vertical notch is transparent — texturing it would
|
||||
double-texture over the viewer's). `mix-blend-mode: multiply` blends against the mask's
|
||||
own opaque bg inside its z-10 stacking context → identical color math to the viewer area.
|
||||
clip-path clips hit-testing too, so the click target stays the strip (verified with
|
||||
`elementsFromPoint`: notch present in stack inside strip, absent mid-screen).
|
||||
|
||||
**Still texture-unaware** (same flaw, not yet reported): the vertical scrolled-mode side
|
||||
masks in `BooksGrid.tsx` (`bg-base-100 absolute left-0/right-0 h-full` when
|
||||
vertical+scrolled). Same trick applies if reported.
|
||||
|
||||
**Verification technique (no rebuild).** Drive the installed app on the device via
|
||||
adb+CDP ([[cdp-android-webview-profiling]]) and inject the exact CSS artifacts the fix
|
||||
produces (patch the `#background-texture` style text + classList/style edits — React
|
||||
won't wipe manual DOM edits unless its computed className/style prop string changes
|
||||
between renders). Quantify the seam: `magick compare -metric MAE` between adjacent
|
||||
1px rows across the boundary — buggy hard seam was MAE 11913 (16-bit scale), fixed
|
||||
seam 230 ≈ ordinary texture-row variation. Gotchas: adb taps at status-bar y-coords are
|
||||
consumed by SystemUI (never reach the app) — use elementFromPoint or in-page click
|
||||
counters for hit-testing; a mid-screen tap toggles the header/footer chrome which then
|
||||
sits ABOVE the notch and pollutes hit stacks (toggle it off before probing).
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: opds-firefox-strict-xml-4479
|
||||
description: OPDS feeds fail on Firefox but work on Chrome — strict DOMParser parsererror on junk after root; parseOPDSXML recovery
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 9539d003-7df3-4643-99ca-bdee69be1b3f
|
||||
---
|
||||
|
||||
#4479 (MEK catalog `bookserver.mek.oszk.hu`, a PHP backend `teljes.php`): OPDS feeds load on Chrome but on Firefox clicking anything shows loading then silently navigates back. Root cause: the server emits a valid Atom feed followed by **trailing junk after `</feed>`** (a stray PHP warning / extra tag / text). Chrome's `DOMParser` ignores it; **Firefox's strict parser replaces the WHOLE document with a `<parsererror>`** ("junk after document element" / "text data outside of root node", Mozilla namespace `http://www.mozilla.org/newlayout/xml/parsererror.xml`). The code then sees a non-`feed` root → treats response as HTML → finds no OPDS link → `router.back()`.
|
||||
|
||||
**jsdom mirrors Firefox exactly** (same strict behavior + same parsererror namespace), so this reproduces in vitest — no need for a real browser. Detect via `doc.documentElement.localName === 'parsererror' || doc.getElementsByTagName('parsererror').length > 0`. Leading whitespace before the root is VALID XML (not the issue); trailing non-whitespace is the killer.
|
||||
|
||||
**Fix:** `parseOPDSXML(text)` helper in `src/app/opds/utils/opdsUtils.ts` — parse, and on parser error re-parse the slice from the first element start tag (`/<([A-Za-z_][\w.:-]*)/`) to its last matching `</root>` close tag (drops leading prolog + trailing junk). Returns the original error doc if recovery fails (no regression — falls through to existing HTML branch). Wired into all 3 OPDS XML parse sites: `page.tsx` (reader nav), `validateOPDSURL` (opdsUtils, adding catalog), `feedChecker.ts` (subscriptions/auto-download). feedChecker also had the #4181 detection bug — switched `text.startsWith('<')` → `looksLikeXMLContent(text)` (the MEK feed has ~13 leading newlines + no `<?xml?>` decl), else the parse fix is unreachable there.
|
||||
|
||||
This is the same MEK server family as [[empty-start-cfi-sync]]-style "tolerate broken servers" work; related #4181 = leading-whitespace detection (`looksLikeXMLContent`).
|
||||
|
||||
NOT fixed (separate, out of scope unless asked): #4479 also reports Android **download** failures (acquisition links point to `mek.oszk.hu/.../*.epub`; works in plain browser, red "download failed" toast in app) — distinct from the XML parse issue.
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
name: opds-html-description-4503
|
||||
description: "OPDS publication descriptions showed raw HTML tags; double-escaped type=\"text\" summaries + unsanitized innerHTML; fix decodes-if-fully-escaped then sanitizes"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 73fd2a21-ea89-4cdb-bbbd-23256a6ae5a2
|
||||
---
|
||||
|
||||
Issue #4503 (FR, but a real bug): OPDS publication detail descriptions rendered
|
||||
raw HTML — literal `<p>`, `</p>`, `"`, `'` text — while Thorium renders
|
||||
them. Reporter's feed served the same Gutenberg book (51726) via an aggregator.
|
||||
|
||||
**Root cause** (confirmed empirically by running foliate `getPublication` in jsdom):
|
||||
the aggregator serves the description as an Atom `type="text"` `<summary>` whose
|
||||
HTML was escaped *twice*. foliate's `getContent` (`packages/foliate-js/opds.js`)
|
||||
only un-escapes for `type="html"`/`"xhtml"`; for `text` it returns `textContent`
|
||||
verbatim, so the value stays `"<p>...&quot;Wall&quot;..."`.
|
||||
`PublicationView` then dumped `content.value` straight into
|
||||
`dangerouslySetInnerHTML` (also **unsanitized — an XSS sink** for arbitrary
|
||||
remote feeds). The browser decodes one entity level → shows the still-escaped
|
||||
`<p>`/`"` as literal text. Single-escaped `type="text"` renders fine (value
|
||||
already has real tags); only *double*-escaped breaks. type=html/xhtml render fine.
|
||||
|
||||
**Fix:** new helper `src/app/opds/utils/opdsContent.ts` `getOPDSDescriptionHtml()`:
|
||||
decode one extra entity level **only when the value is entirely escaped markup**
|
||||
(`/<\/?[a-z]/i` present AND no real `/<[a-z]/i` tag — so mixed content like
|
||||
`<p>see <code></p>` is left literal), then `sanitizeHtml()` (the shared
|
||||
DOMPurify sanitizer in `@/utils/sanitize` — generic, reused; note its
|
||||
ALLOWED_TAGS has no `div`, so xhtml's `<div xmlns>` wrapper is unwrapped,
|
||||
harmless). Wired into `PublicationView` via `useMemo`. Decode-then-sanitize
|
||||
order matters: scripts hidden behind double-escaping are still stripped.
|
||||
(PR #4510 also moved `sanitizeHtml`/`sanitizeForParsing` out of
|
||||
`services/send/conversion/` into `@/utils/sanitize`, alongside `sanitizeString`.)
|
||||
Exported `OPDSContent` from `types/opds.ts` for the helper's param.
|
||||
|
||||
Scope: only the detail view renders description HTML. `PublicationCard` shows no
|
||||
summary; `NavigationCard` renders `SYMBOL.SUMMARY` as React-escaped plain text
|
||||
(getSummary only returns `type==='text'` values) — both correct, untouched.
|
||||
|
||||
Verifying which render path produces "raw tags": React `{value}` text-escapes
|
||||
(shows `©` literally); `dangerouslySetInnerHTML` decodes one level (shows
|
||||
literal `<p>` only if value is `<p>`). The screenshot showed literal `<p>`
|
||||
AND `"` → double-escaped innerHTML path, not the dead `description` path
|
||||
(foliate's `getPublication` never sets `metadata.description`). Related:
|
||||
[[opds-firefox-strict-xml-4479]].
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: opds2-json-search-4502
|
||||
description: "OPDS 2.0 JSON catalog search bar greyed out; isSearchLink didn't recognize templated application/opds+json links"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 9eb835a8-ce7a-4f80-ae3d-94e330935585
|
||||
---
|
||||
|
||||
#4502 — OPDS 2.0 JSON catalogs (e.g. `type: "application/opds+json"`, `templated: true`, href `/opds/search{?query}`) showed a **greyed-out** navbar search input that rejected queries.
|
||||
|
||||
**Root cause:** `isSearchLink` (`src/app/opds/utils/opdsUtils.ts`) only matched `MIME.OPENSEARCH`/`MIME.ATOM`, so `hasSearch` (page.tsx) was false → `<input disabled={!hasSearch}>`. `handleSearch` also only handled those two types.
|
||||
|
||||
**Fix:**
|
||||
- Add `MIME.OPDS2 = 'application/opds+json'` + `templated?: boolean` on `OPDSBaseLink`; `isSearchLink` now also accepts `type === OPDS2 && !!templated`.
|
||||
- New `expandOPDSSearchTemplate(templateHref, queryTerm)` in opdsUtils expands the RFC 6570 template, placing the term in the primary text var (`query`/`searchTerms`/`q`, else first var). Reuses `foliate-js/uri-template.js` (`replace`, `getVariables`) — do NOT reinvent RFC 6570.
|
||||
- page.tsx `handleSearch` adds an `OPDS2` branch.
|
||||
|
||||
**Gotcha (key):** `resolveURL` mangles `{?query}` template braces (`/opds/search%7B?query}`) — it treats `?` as the query start. ALWAYS `expandOPDSSearchTemplate` FIRST, THEN `resolveURL`. For OPENSEARCH/ATOM the order is reversed (resolve then `.replace('{searchTerms}', ...)`), which is why the new branch can't share the top-level `searchURL`.
|
||||
|
||||
**Foliate has `getSearch(link)`** (async, OPDS 2.0 JSON → OPDSSearch via uri-template) but readest's page.tsx never wired it; the JSON path just `JSON.parse`s the feed, preserving raw `templated`/`type`. OPDS 2.0 is JSON-only (XML `getFeed` links never carry `templated`).
|
||||
|
||||
Related: [[opds-firefox-strict-xml-4479]].
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
name: overlayer-splitrange-textnodes
|
||||
description: "Overlayer highlight rects — split range by TEXT NODES, not a block-tag selector; li/blockquote text was silently dropped from SVG when highlight also touched a <p>"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 58d4b1a1-8823-4474-9966-c96727692e3f
|
||||
---
|
||||
|
||||
Bug class (3rd occurrence): `Overlayer` in `packages/foliate-js/overlayer.js` splits a range into sub-ranges before `getClientRects()` so fully-contained block elements don't contribute border boxes (over-highlighting blank space — fork commit f087826 #10). The split was `querySelectorAll('p, h1, h2, h3, h4')`; 920676b added headings after the same hole; June 2026 the hole reappeared for `<li>`: a highlight spanning paragraphs + a bullet list drew NO rects over the list (li text fell into no sub-range), while a highlight entirely inside the list worked via the `splitRanges.length === 0 → [range]` fallback.
|
||||
|
||||
**Fix:** `#splitRange` walks TEXT NODES (TreeWalker, `FILTER_REJECT` on non-intersecting elements prunes subtrees) plus replaced elements (`img, svg`), clipping first/last text nodes to the range boundaries. Text-node line rects never include block border boxes, cover every block type, and can't double-paint (`li > p` nesting). Shared `#getRects(range)` used by both `add()` and `redraw()`.
|
||||
|
||||
**Why:** any hard-coded block-tag selector is whack-a-mole (li, blockquote, dd, td, div-paragraphs…) and adding container tags double-paints nested matched tags.
|
||||
|
||||
**How to apply:** when highlight/overlay rects miss some content or over-paint blank space, check `#splitRange` in overlayer.js first. Test at `src/__tests__/document/overlayer-highlight-blocks.test.ts` — jsdom has no `Range.getClientRects`; stub the prototype to record `range.toString()` per call and assert covered text. Dev-web picked up the symlinked foliate-js edit on page reload (no server restart needed for next dev, unlike [[paginator-gutter-bleed-asymmetry-4394]]'s note).
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
name: paginated-texture-occlusion-4399
|
||||
description: Background texture absent in paginated mode (shown in scrolled) — opaque
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 1bd8a73e-f279-4ed8-9562-98e96d9723d5
|
||||
---
|
||||
|
||||
RESOLVED — merged. foliate-js `142bf11` (on main) + readest pointer bump/test
|
||||
(`fix(reader): show background texture in paginated mode (#4399)`).
|
||||
|
||||
#4399: a background texture (Settings → Color → texture, e.g. Leaves) shows in
|
||||
**scrolled** mode but is **absent in paginated** mode. The texture is NOT in the
|
||||
iframe — it's mounted on the HOST as `.foliate-viewer::before` (`src/styles/textures.ts`,
|
||||
`mountBackgroundTexture`): `position:absolute; inset:0; z-index:0; mix-blend-mode:multiply;
|
||||
opacity:.6` over the reader container. For it to show, the whole foliate view tree
|
||||
(iframe page bg + the paginator `#background` layer) must be transparent so the host
|
||||
`::before` composites over the white page fill that a **parent** element provides
|
||||
(`oklch(1 0 0)` on the reader grid cell, NOT on `.foliate-viewer`, which is transparent).
|
||||
|
||||
**Root cause.** foliate-js `paginator.js` `#replaceBackground`. Scrolled mode already
|
||||
left things transparent under a texture (`#background.style.background = hasTexture ? '' : fallbackBg`
|
||||
+ blanking transparent view elements). The **paginated** branch hard-set
|
||||
`this.#background.style.background = fallbackBg` (opaque theme color, e.g. white) on the
|
||||
segment **container** — that opaque container, a descendant of `.foliate-viewer`, paints
|
||||
over the host `::before` texture. The per-view *segments* were already transparent for
|
||||
transparent pages (`rgba(0,0,0,0)`); only the container was the occluder. Verified live
|
||||
via Chrome MCP: `#background` inline bg was `rgb(255,255,255)`; setting it `''` revealed
|
||||
the leaves texture instantly.
|
||||
|
||||
**Regression** = commit `167757a` "Fix background flash when swiping between
|
||||
differently-colored pages" (2026-05-31, see [[paginator-swipe-bg-flash]]). The OLD
|
||||
paginated path was a CSS `grid` of per-column divs and never set the `#background`
|
||||
container background (kept the `''` reset), so a transparent page let the texture through.
|
||||
167757a swapped to `computeBackgroundSegments` and added the opaque container line.
|
||||
|
||||
**Fix.** Extract a shared exported pure helper `textureAwareBackground(resolved, hasTexture)`
|
||||
→ returns `''` when `hasTexture && isTransparent(resolved)`, else `resolved`. Use it for
|
||||
BOTH scrolled view elements and paginated segment bgs, and set the paginated container
|
||||
`this.#background.style.background = hasTexture ? '' : fallbackBg` (mirroring scrolled).
|
||||
No-texture path unchanged → swipe-flash fix for colored pages fully preserved; a
|
||||
book-forced opaque page still paints its segment (texture correctly does not show there).
|
||||
|
||||
Test: `src/__tests__/document/paginator-background-segments.test.ts` (new `describe` for
|
||||
the pure helper, alongside `computeBackgroundSegments`). foliate-js is a submodule —
|
||||
commit there + bump the pointer. See [[paginator-swipe-bg-flash]].
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
name: paginator-gutter-bleed-asymmetry-4394
|
||||
description: "Paginated page background overflowed its column into the outer --_outer-min gutter (asymmetric on mixed spreads); fix = clamp computeBackgroundSegments to the content area, keep the grid"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 108a70bd-5af9-4a31-9b93-9d6df7637579
|
||||
---
|
||||
|
||||
#4394 "page viewport offset / content clipping." On a 2-up cover spread the
|
||||
left page (cover `<img>`, transparent page bg) had a white outer gutter while
|
||||
the right page (`<body class="c3">` body-colour yellow) bled its colour past its
|
||||
column into the right outer gutter → spread looked shifted right. On a wide
|
||||
screen (1920px, 720 max-inline) the gutters are ~250px so it reads as a "massive
|
||||
white blank gap." 0.10.6 filled colour edge-to-edge; 0.11.2 regressed.
|
||||
|
||||
**Geometry.** Grid `#top` = `minmax(--_outer-min-left,1fr) margin-left
|
||||
minmax(0,maxw) margin-right minmax(--_outer-min-right,1fr)` (foliate
|
||||
`paginator.js`). `#container` = grid-column 2/5 (inset by the outer gutter
|
||||
tracks); `#background` = grid-column 1/-1 (spans the gutters). `--_outer-min =
|
||||
(col_count-1)*(margin/4 + gap/4)` — non-zero ONLY in multi-column (0 in
|
||||
1-column), added in commit `0a0ceda` so the outer margin matches half the
|
||||
inter-column gap (symmetric spacing). `inset = containerLeft - bgLeft =
|
||||
--_outer-min-left`.
|
||||
|
||||
**Cause.** `computeBackgroundSegments` (the swipe-flash fix, commit `167757a`)
|
||||
positioned each page's colour segment at `inset + offset - scrollPos` and then
|
||||
STRETCHED any segment touching a container edge OUT to the bg edge (`start=0` /
|
||||
`end=bgSize`) — i.e. bled into the outer gutter. A body-coloured page bled into
|
||||
its gutter; a transparent/image page (cover) did not (its `#background` is
|
||||
transparent; its `<img>` is clamped inside the inset `#container`). Mixed spread
|
||||
→ one gutter coloured, one not → asymmetric.
|
||||
|
||||
**Fix (what the maintainer wanted).** Do NOT touch `--_outer-min` (it keeps the
|
||||
left/right margins symmetric with the centre gap). Instead CLAMP each segment to
|
||||
the content area so the background stays inside its column and never overflows
|
||||
into the outer gutter:
|
||||
```js
|
||||
const start = Math.max(segStart, containerStart) // was: if(...) start = 0
|
||||
const end = Math.min(segEnd, containerEnd) // was: if(...) end = bgSize
|
||||
if (end <= start) continue
|
||||
```
|
||||
`containerStart=inset`, `containerEnd=inset+containerSize`. In single-column the
|
||||
gutters are 0 (`--_outer-min`=0 → inset 0) so this still fills the viewport edge
|
||||
to edge (matches 0.10.6); in multi-column each page stays in its column with
|
||||
symmetric gutter margins. Image pages (cover/彩页) were already correct (`<img>`
|
||||
sits in its column); only the body-colour `#background` overflow needed fixing.
|
||||
|
||||
**Dead ends (took 2 wrong tries before the maintainer steered me right).**
|
||||
1. Thought it was the gutter-bleed and "gated" the stretch on both-edges-coloured
|
||||
— maintainer: "nothing to do with the gutter" (meaning don't change the bleed
|
||||
GATING, the real issue is the offset).
|
||||
2. Thought the title page shouldn't be yellow at all — it genuinely is
|
||||
`.c3{background:#ffe43f}` on `<body>`, captured as `docBackground` and painted
|
||||
full-bleed (body is zeroed via `doc.body.style.background='none'`, the old
|
||||
f087826 "dismiss iframe background, paint via root" design). Yellow is correct,
|
||||
it was just overflowing.
|
||||
3. Proposed dropping `--_outer-min` (revert `0a0ceda`) — maintainer rejected:
|
||||
that desymmetrises the centre-vs-side gaps. Keep the grid; fix the background.
|
||||
|
||||
**Verifying live.** foliate-js is a submodule; `next dev` / turbopack did NOT
|
||||
hot-reload an edit to `packages/foliate-js/paginator.js` even across full
|
||||
navigations — had to RESTART the dev server (`pnpm dev-web`, port 3000) to pick
|
||||
it up. Cheap interim check: clamp the live `#background` segment divs in the page
|
||||
and screenshot. Segment colours are render-timing dependent (a probe right after
|
||||
nav can show all-transparent before the body-colour is captured).
|
||||
|
||||
Tests: `apps/readest-app/src/__tests__/document/paginator-background-segments.test.ts`
|
||||
(tests "centered page" + "two-up spread" flipped from full-bleed to confined; the
|
||||
inset=0 swipe-flash tests are unchanged). Related: [[paginator-swipe-bg-flash]]
|
||||
[[paginated-texture-occlusion-4399]].
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user