forked from akai/readest
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02d005dae7 |
@@ -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 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Share an idea or suggestion
|
||||
title: 'FR: describing your feature request'
|
||||
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 [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
|
||||
> 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.
|
||||
|
||||
**Additional context**
|
||||
|
||||
> 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
|
||||
```
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Keep GitHub Actions up to date with GitHub's Dependabot...
|
||||
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
|
||||
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- '*' # 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- 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@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
# 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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,512 +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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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
|
||||
permissions:
|
||||
contents: read
|
||||
# Required by actions/attest-build-provenance: id-token mints the Sigstore
|
||||
# OIDC identity, attestations writes the provenance to the repo's store.
|
||||
id-token: write
|
||||
attestations: write
|
||||
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 }}
|
||||
# Backstop only — must stay ABOVE setup time + the per-step timeouts below,
|
||||
# because a job-level timeout reports `cancelled` and skips assemble-manifest.
|
||||
timeout-minutes: 75
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # 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
|
||||
echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .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
|
||||
timeout-minutes: 55
|
||||
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
|
||||
|
||||
# The truly-portable AppImage bundler downloads quick-sharun.sh from
|
||||
# Anylinux-AppImages@main ONLY when it is not already in the tauri tools
|
||||
# cache. An upstream strace-mode change (2026-06-29) made bundling launch
|
||||
# the app under Xvfb and hang forever, timing out the Linux legs (#4906).
|
||||
# Seed the cache with the last known-good revision so the bundler never
|
||||
# fetches the moving main-branch script.
|
||||
- name: pin quick-sharun.sh for AppImage bundling (Linux)
|
||||
if: matrix.config.release == 'linux'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/tauri"
|
||||
mkdir -p "$cache_dir"
|
||||
curl -fsSL --retry 3 -o "$cache_dir/quick-sharun.sh" \
|
||||
"https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/b3a9e985cdedf7efa81d172f182cd13983743147/useful-tools/quick-sharun.sh"
|
||||
chmod +x "$cache_dir/quick-sharun.sh"
|
||||
|
||||
- name: build desktop bundles
|
||||
if: matrix.config.release != 'android'
|
||||
shell: bash
|
||||
# A hung build must fail the STEP (step timeout -> job failure), not hit
|
||||
# the job-level timeout: job timeouts report `cancelled`, which the
|
||||
# assemble-manifest guard treats as run cancellation and skips promoting
|
||||
# latest.json for ALL platforms (#4906).
|
||||
timeout-minutes: 45
|
||||
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
|
||||
timeout-minutes: 30
|
||||
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
|
||||
|
||||
# Attest the distributable binaries staged in nightly-out (apks, AppImage,
|
||||
# app.tar.gz, setup/portable exe). gh attestation verify is digest-based,
|
||||
# so it verifies these against readest/readest even though they ship via
|
||||
# download.readest.com rather than a GitHub release. The .sig updater
|
||||
# signatures are excluded — they are not binaries users run.
|
||||
- name: attest nightly binaries
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
with:
|
||||
subject-path: |
|
||||
nightly-out/Readest*
|
||||
!nightly-out/*.sig
|
||||
|
||||
- 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 +0,0 @@
|
||||
name: PR checks
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
rust_lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTFLAGS: '-C target-cpu=skylake'
|
||||
SCCACHE_GHA_ENABLED: 'true'
|
||||
RUSTC_WRAPPER: sccache
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
- name: Cache apt packages
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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
|
||||
|
||||
- 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
|
||||
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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
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@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
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
|
||||
+58
-436
@@ -5,371 +5,101 @@ on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
get-release:
|
||||
create-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 }}
|
||||
release_id: ${{ steps.create-release.outputs.result }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@v3
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
uses: actions/setup-node@v3
|
||||
- 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
|
||||
- name: create release
|
||||
id: create-release
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.getLatestRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
})
|
||||
core.setOutput('release_id', data.id);
|
||||
core.setOutput('release_tag', data.tag_name);
|
||||
- name: get release notes
|
||||
id: get-release-notes
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const version = require('./apps/readest-app/package.json').version;
|
||||
const releaseNotesFileContent = fs.readFileSync('./apps/readest-app/release-notes.json', 'utf8');
|
||||
const releaseNotes = JSON.parse(releaseNotesFileContent).releases[version] || {};
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- 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-calibre-plugin:
|
||||
needs: get-release
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: create calibre plugin zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
|
||||
# Stamp PLUGIN_VERSION in __init__.py with the release version
|
||||
# (from apps/readest-app/package.json, mirroring the koplugin's
|
||||
# _meta.lua stamp above); the committed value is a dev placeholder.
|
||||
version_tuple=$(echo "${version}" | awk -F. '{printf "(%d, %d, %d)", $1, $2, $3}')
|
||||
perl -i -pe "s/^PLUGIN_VERSION = \(\d+, \d+, \d+\)/PLUGIN_VERSION = ${version_tuple}/" \
|
||||
apps/readest-calibre-plugin/__init__.py
|
||||
|
||||
make -C apps/readest-calibre-plugin zip
|
||||
plugin_zip="apps/readest-calibre-plugin/dist/Readest-${version}.calibre-plugin.zip"
|
||||
|
||||
echo "Uploading ${plugin_zip} to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} ${plugin_zip} --clobber
|
||||
return data.id
|
||||
|
||||
build-tauri:
|
||||
needs: get-release
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
# Required by actions/attest-build-provenance: id-token mints the Sigstore
|
||||
# OIDC identity, attestations writes the provenance to the repo's store.
|
||||
id-token: write
|
||||
attestations: write
|
||||
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
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
- 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
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
node-version: 24
|
||||
version: 9.14.4
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: setup Java (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # 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
|
||||
key: ${{ matrix.config.os }}-cargo-${{ hashFiles('apps/readest-app/src-tauri/Cargo.lock') }}
|
||||
workspaces: apps/readest-app/src-tauri -> target
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android' && matrix.config.arch != 'armhf'
|
||||
if: matrix.config.os == 'ubuntu-latest'
|
||||
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
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: install dependencies (ubuntu only - armhf specific)
|
||||
if: contains(matrix.config.os, 'ubuntu') && matrix.config.arch == 'armhf'
|
||||
- name: Create .env.local file for Next.js
|
||||
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
|
||||
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ vars.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_POSTHOG_HOST=${{ vars.NEXT_PUBLIC_POSTHOG_HOST }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_DEEPL_API_KEY=${{ vars.NEXT_PUBLIC_DEEPL_API_KEY }}" >> .env.local
|
||||
|
||||
- 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
|
||||
echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env.local
|
||||
cp .env.local apps/readest-app/.env.local
|
||||
- name: Copy .env.local to apps/readest-app
|
||||
run: cp .env.local apps/readest-app
|
||||
|
||||
- name: build and upload Android apks
|
||||
if: matrix.config.release == 'android'
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
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: |
|
||||
cd apps/readest-app/
|
||||
rm -rf src-tauri/gen/android
|
||||
pnpm tauri android init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
git checkout .
|
||||
|
||||
pushd src-tauri/gen/android
|
||||
echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" > keystore.properties
|
||||
echo "password=${{ secrets.ANDROID_KEY_PASSWORD }}" >> keystore.properties
|
||||
base64 -d <<< "${{ secrets.ANDROID_KEY_BASE64 }}" > $RUNNER_TEMP/keystore.jks
|
||||
echo "storeFile=$RUNNER_TEMP/keystore.jks" >> keystore.properties
|
||||
|
||||
popd
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
apk_path=src-tauri/gen/android/app/build/outputs/apk/universal/release
|
||||
universial_apk=Readest_${version}_universal.apk
|
||||
arm64_apk=Readest_${version}_arm64.apk
|
||||
pnpm tauri android build
|
||||
cp ${apk_path}/app-universal-release.apk $universial_apk
|
||||
pnpm tauri android build -t aarch64
|
||||
cp ${apk_path}/app-universal-release.apk $arm64_apk
|
||||
|
||||
echo "Uploading $universial_apk to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk --clobber
|
||||
echo "Uploading $arm64_apk to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk --clobber
|
||||
echo "Uploading signatures to GitHub release"
|
||||
pnpm tauri signer sign $universial_apk
|
||||
pnpm tauri signer sign $arm64_apk
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk.sig --clobber
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk.sig --clobber
|
||||
|
||||
- name: attest Android apks
|
||||
if: matrix.config.release == 'android'
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
with:
|
||||
subject-path: |
|
||||
apps/readest-app/Readest_${{ needs.get-release.outputs.release_version }}_universal.apk
|
||||
apps/readest-app/Readest_${{ needs.get-release.outputs.release_version }}_arm64.apk
|
||||
|
||||
- name: download and update latest.json for Android release
|
||||
if: matrix.config.release == 'android'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cd apps/readest-app/
|
||||
# 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
|
||||
|
||||
# The truly-portable AppImage bundler downloads quick-sharun.sh from
|
||||
# Anylinux-AppImages@main ONLY when it is not already in the tauri tools
|
||||
# cache. An upstream strace-mode change (2026-06-29) made bundling launch
|
||||
# the app under Xvfb and hang forever (#4906). Seed the cache with the
|
||||
# last known-good revision so the bundler never fetches the moving
|
||||
# main-branch script. Keep in sync with nightly.yml.
|
||||
- name: pin quick-sharun.sh for AppImage bundling (Linux)
|
||||
if: matrix.config.release == 'linux'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/tauri"
|
||||
mkdir -p "$cache_dir"
|
||||
curl -fsSL --retry 3 -o "$cache_dir/quick-sharun.sh" \
|
||||
"https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/b3a9e985cdedf7efa81d172f182cd13983743147/useful-tools/quick-sharun.sh"
|
||||
chmod +x "$cache_dir/quick-sharun.sh"
|
||||
|
||||
- uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
|
||||
id: tauri
|
||||
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 }}
|
||||
@@ -378,137 +108,29 @@ 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 || '' }}
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.config.os == 'macos-latest' && '--target universal-apple-darwin' || '' }}
|
||||
|
||||
# Attest the freshly built desktop bundles (installers, AppImage, dmg,
|
||||
# updater archives + their .sig). tauri-action reports their on-disk paths
|
||||
# as a JSON array; fromJSON('"\n"') yields a real newline to join them into
|
||||
# the newline-delimited list subject-path expects.
|
||||
- name: attest desktop bundles
|
||||
if: matrix.config.release != 'android' && steps.tauri.outputs.artifactPaths != ''
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
with:
|
||||
subject-path: ${{ join(fromJSON(steps.tauri.outputs.artifactPaths), fromJSON('"\n"')) }}
|
||||
|
||||
- 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)
|
||||
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 }}
|
||||
|
||||
if [ "$arch" = "x86_64" ]; then
|
||||
bin_file="Readest_${version}_x64-portable.exe"
|
||||
elif [ "$arch" = "aarch64" ]; then
|
||||
bin_file="Readest_${version}_arm64-portable.exe"
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# The portable rebuild above is not produced by tauri-action, so it is not
|
||||
# covered by the "attest desktop bundles" step; attest it here. Exactly one
|
||||
# portable exe is staged at the workspace root per Windows leg.
|
||||
- name: attest Windows portable binary
|
||||
if: matrix.config.os == 'windows-latest'
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
with:
|
||||
subject-path: Readest_*-portable.exe
|
||||
|
||||
- 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]
|
||||
publish-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: [create-release, build-tauri]
|
||||
|
||||
steps:
|
||||
- name: publish release
|
||||
id: publish-release
|
||||
uses: actions/github-script@v6
|
||||
env:
|
||||
release_id: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: process.env.release_id,
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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"
|
||||
@@ -1,23 +0,0 @@
|
||||
name: Deploy to vercel on merge
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
submodules: 'true'
|
||||
- uses: amondnet/vercel-action@de09aeac2ace6599ec9b11ef87558759a496bac4 # v42
|
||||
with:
|
||||
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||
github-comment: false
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
# Contribution Guidelines
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
Basically you need to install or update the following development tools:
|
||||
|
||||
- **Node.js** and **pnpm** for Next.js development
|
||||
- **Rust** and **Cargo** for Tauri development
|
||||
|
||||
```bash
|
||||
nvm install v24
|
||||
nvm use v24
|
||||
npm install -g pnpm
|
||||
rustup update
|
||||
```
|
||||
|
||||
### Getting Started
|
||||
|
||||
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
|
||||
cd readest
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
#### 2. Install Dependencies
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
#### 3. Verify Dependencies Installation
|
||||
|
||||
To confirm that all dependencies are correctly installed, run the following command:
|
||||
|
||||
```bash
|
||||
pnpm tauri info
|
||||
```
|
||||
|
||||
This command will display information about the installed Tauri dependencies and configuration on your platform. Note that the output may vary depending on the operating system and environment setup. Please review the output specific to your platform for any potential issues.
|
||||
|
||||
For Windows targets, “Build Tools for Visual Studio 2022” (or a higher edition of Visual Studio) and the “Desktop development with C++” workflow must be installed. For Windows ARM64 targets, the “VS 2022 C++ ARM64 build tools” and "C++ Clang Compiler for Windows" components must be installed. And make sure `clang` can be found in the path by adding `C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\Llvm\x64\bin` for example in the environment variable `Path`.
|
||||
|
||||
#### 4. Build for Development
|
||||
|
||||
```bash
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
#### 5. Build for Production
|
||||
|
||||
```bash
|
||||
pnpm tauri build
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
| Command | Description |
|
||||
| ---------------- | -------------------------------------------------- |
|
||||
| `pnpm dev-web` | Starts the development server for the web app only |
|
||||
| `pnpm build-web` | Builds the web app |
|
||||
|
||||
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)
|
||||
- rust-analyzer (rust-lang.rust-analyzer) (for Tauri development only)
|
||||
|
||||
### When you're done
|
||||
|
||||
Check that your code follows the project's style guidelines by running:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Please also make a manual, functional test of your changes. When all that's done, it's time to file a pull request to upstream and fill out the title and body appropriately.
|
||||
|
||||
## Credits
|
||||
|
||||
This documented was inspired by the contributing guidelines for [cloudflare/wrangler2](https://github.com/cloudflare/wrangler2/blob/main/CONTRIBUTING.md).
|
||||
Generated
-11460
File diff suppressed because it is too large
Load Diff
-46
@@ -1,46 +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" }
|
||||
# Xcode 26.2 (Swift 6.2) broke upstream swift-rs 1.0.7's per-swiftc target
|
||||
# override; the vendored copy cross-compiles via `--triple`/`--sdk` instead.
|
||||
# Upstream is unmaintained (last release 2024). See packages/swift-rs.
|
||||
swift-rs = { path = "packages/swift-rs" }
|
||||
-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"]
|
||||
@@ -1,137 +1,27 @@
|
||||
<div align="center">
|
||||
<a href="https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme" target="_blank">
|
||||
<img src="https://github.com/readest/readest/blob/main/apps/readest-app/src-tauri/icons/icon.png?raw=true" alt="Readest Logo" width="20%" />
|
||||
</a>
|
||||
<h1>Readest</h1>
|
||||
<br>
|
||||
# Readest
|
||||
|
||||
[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.
|
||||
|
||||
[![Website][badge-website]][link-website]
|
||||
[![Web App][badge-web-app]][link-web-readest]
|
||||
[![OS][badge-platforms]][link-website]
|
||||
<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>
|
||||
|
||||
<p align="center">
|
||||
<a href="#features">Features</a> •
|
||||
<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="#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%" />
|
||||
</a>
|
||||
</div>
|
||||
Readest is an open-source ebook reading software designed for immersive and deep reading experiences. It supports EPUB and PDF document formats, and with Tauri v2, Readest is cross-platform, running seamlessly on macOS, Windows, and Linux.
|
||||
|
||||
## Features
|
||||
|
||||
<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. | ✅ |
|
||||
|
||||
## 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. | 🔄 |
|
||||
|
||||
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
|
||||
- **EPUB and PDF Support**: Enjoy both EPUB and PDF formats, making Readest versatile for all your reading needs.
|
||||
- **Cross-Platform Compatibility**: Runs on macOS, Windows, and Linux with Tauri v2.
|
||||
- **Immersive Reading Experience**: Supports advanced reading features like note-taking, highlighting, and progress syncing.
|
||||
- **Optimized Performance**: Readest is lightweight, ensuring smooth performance even with large files.
|
||||
- **Customizable Interface**: Built with daisyUI for a modern and user-friendly UI.
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Downloads
|
||||
|
||||
### Mobile Apps
|
||||
|
||||
<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]**
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js** and **pnpm** for Next.js development
|
||||
- **Rust** and **Cargo** for Tauri development
|
||||
- **Rust and Cargo** for Tauri development
|
||||
|
||||
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.
|
||||
For the best experience, 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
|
||||
npm install -g pnpm
|
||||
nvm install v22
|
||||
nvm use v22
|
||||
rustup update
|
||||
```
|
||||
|
||||
@@ -142,18 +32,18 @@ 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
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
# might need to rerun this when code is updated
|
||||
git submodule update --init --recursive
|
||||
npm install -g pnpm
|
||||
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
|
||||
@@ -168,197 +58,26 @@ This command will display information about the installed Tauri dependencies and
|
||||
|
||||
For Windows targets, “Build Tools for Visual Studio 2022” (or a higher edition of Visual Studio) and the “Desktop development with C++” workflow must be installed. For Windows ARM64 targets, the “VS 2022 C++ ARM64 build tools” and "C++ Clang Compiler for Windows" components must be installed. And make sure `clang` can be found in the path by adding `C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\Llvm\x64\bin` for example in the environment variable `Path`.
|
||||
|
||||
### 4. Build for Development
|
||||
### 4. Build the 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
|
||||
## Contributing
|
||||
|
||||
### 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
|
||||
|
||||
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" />
|
||||
</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.
|
||||
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please review our contributing guidelines before you start.
|
||||
|
||||
## 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:
|
||||
|
||||
- [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.
|
||||
This project is licensed under the AGPL V3 License. See the LICENSE file for details.
|
||||
|
||||
---
|
||||
|
||||
<div align="center" style="color: gray;">Happy reading with Readest!</div>
|
||||
|
||||
[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-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-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
|
||||
[link-gh-wiki]: https://github.com/readest/readest/wiki
|
||||
[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/
|
||||
Happy reading with 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,139 +0,0 @@
|
||||
# Readest Project Memory
|
||||
|
||||
## Key Reference Documents (aggregators)
|
||||
- [Bug Patterns](bug-patterns.md) · [CSS & Style](css-style-fixes.md) EPUB CSS + style.ts · [TTS](tts-fixes.md)
|
||||
- [Layout & UI](layout-ui-fixes.md) insets/z-index · [Platform Compat](platform-compat-fixes.md) · [Annotator & Reader](annotator-reader-fixes.md)
|
||||
## Safety & Security
|
||||
- [In-place delete wiped originals](in-place-delete-wiped-originals.md) never `fs.removeFile` `external` · [Backup zip Windows paths #4703](backup-windows-zip-paths-4703.md) normalize `\` · [download_file scope Android #4639](download-file-scope-android-regression.md)
|
||||
- [Security advisories 2026-06](security-advisories-web-2026-06.md) 4 GHSA #4638; SSRF guard broke dev-LAN OPDS, dev-only exemption 2026-07 PR#5002
|
||||
## Paginator & Scroll
|
||||
- Reading ruler: [line-aware](reading-ruler-line-aware.md) frame-offset map; [vertical-rl backwards #4865](reading-ruler-vertical-rtl-4865.md)
|
||||
- [Vertical-rl horizontal pagination (#624)](vertical-rl-horizontal-pagination-624.md) — horizontal inputs + two-phase slide; rtl gated `!vertical`
|
||||
- [Slide/curl turn styles via VT (#555)](page-turn-styles-viewtransitions-555.md) — VT turns gated on nested-VT-groups support (iOS 18 WebKit crashes despite having the API); Tauri fallback = `CapturedPageTurn` capture pipeline (WebGL mesh curl + flat canvas slide, full-gridcell capture, instant nav = drop `animated`); MERGED #4940 (2026-07-05), verified live mac/iOS/Android (mobile = JPEG capped 2x, PNG was 1.5s/turn); Win/Linux capture still open
|
||||
- [Captured turn ignored instant-highlight hold](captured-turn-instant-highlight-scrolllock.md) — captured slide/curl swipe path (app interceptor, `no-swipe` set) didn't honor `renderer.scrollLocked`; fix = foliate `get scrollLocked()` + gate `useCapturedTurn` move on it (push/VT-slide already gated); PR#5000 + foliate#51
|
||||
- TOC: [expand + auto-scroll](toc-expand-and-autoscroll.md); [current-position row](toc-current-position-row.md); [table heading clip #4439](toc-table-heading-clip-4439.md); [BooknoteView auto-scroll #4352](booknote-view-autoscroll-4352.md)
|
||||
- Paginated bg: [swipe flash](paginator-swipe-bg-flash.md); [texture occlusion #4399](paginated-texture-occlusion-4399.md); [gutter bleed #4394](paginator-gutter-bleed-asymmetry-4394.md); [bg-replace reflow #4785](pageturn-bg-replace-reflow-4785.md)
|
||||
- [Inline-block column overflow](inline-block-column-overflow.md) `#demoteUnfragmentableBoxes`
|
||||
- FXL/PDF: [fit-width scroll reset #4683](fixed-layout-paginated-scroll-reset-4683.md); [PDF spread seam #4587](pdf-spread-canvas-seam-4587.md); [spine seam #4857](fxl-spread-spine-seam-4857.md); [portrait auto-spread off-center #4984](fxl-portrait-autospread-offcenter-4984.md) MERGED PR#4992+foliate#50 lone page kept one-sided auto margin -> stranded + taps turned page; `computeSpreadInlineMargins`
|
||||
- Scrolled: [PDF wheel double #4727](pdf-scroll-mode-wheel-double-4727.md); [header title center #4436](scrolled-header-title-center-4436.md); [Duokan fullscreen cover](duokan-fullscreen-cover-scroll.md)
|
||||
## Critical Files (Most Bug-Prone)
|
||||
- `src/utils/style.ts` EPUB CSS hub · `packages/foliate-js/paginator.js` · `src/services/tts/TTSController.ts`
|
||||
- `src/hooks/useSafeAreaInsets.ts` · `src/app/reader/components/FoliateViewer.tsx` · `.../annotator/Annotator.tsx`
|
||||
## Sync Notes
|
||||
- [Cloud Sync provider selection #4959/#4380](cloud-sync-provider-selection-plan.md) MERGED #4971+#4973+#4975+#4976: derived provider, exclusive routing, syncBooks auto-enable, fleet probe, chooser; i18n pass + live verify pending
|
||||
- [Grimmory native sync](grimmory-native-sync.md) Booklore-fork REVERTED
|
||||
- KOSync: [CFI spine resolution](kosync-cfi-spine-resolution.md); [connect() false-positive #4692](kosync-connect-false-positive-4692.md)
|
||||
- [Empty-start CFI sync](empty-start-cfi-sync.md) · [Custom fonts vanish #4410](custom-fonts-reincarnation-4410.md) CRDT remove-wins
|
||||
- koplugin: [note deletion](koplugin-note-deletion-sync.md) tombstone; [stats #4666](koplugin-stats-sync.md); [bulk download #4751](koplugin-bulk-download-4751.md); [dup book rows #4861](koplugin-stats-duplicate-book-rows-4861.md)
|
||||
- [Statusless re-pin #4677](sync-statusless-book-rebump-4677.md) · [pull cursor synced_at #4678](sync-synced-at-cursor-4678.md)
|
||||
- [koplugin library stale #4934](koplugin-library-stale-synced-cursor-4934.md) pull cursor updated_at→synced_at + split push watermark + v2→v3 heal migration
|
||||
- WebDAV: [metadata #4756](webdav-metadata-sync-4756.md) LWW; [group membership #4942](webdav-group-membership-sync-4942.md) mergeBookMetadata dropped groupId/groupName; [credentials #4810](webdav-credential-sync-4810.md); [connect nullified #4780](webdav-connect-nullified-4780.md) stale closure
|
||||
- [WebDAV deletion + upload-after-enable (#4860/#4856)](webdav-deletion-and-upload-after-enable-4860-4856.md) edit-wins LWW + tombstone union
|
||||
- File sync: [refactor #4784](webdav-filesync-refactor-plan.md) `FileSyncEngine`; [third-party auto-sync #4835](third-party-library-autosync-4835.md)
|
||||
- [Transfer Queue clear not persisted](transfer-queue-clear-persistence.md) hook mutated store directly, skipped `persistQueue()`; route clears through `transferManager`
|
||||
- [Multi-window settings clobber (#4580)](multiwindow-settings-clobber-4580.md)
|
||||
- Google Drive: [research](gdrive-sync-provider-research.md); [multi-PR status](gdrive-provider-multipr-status.md); [full walk every sync](gdrive-fullwalk-every-sync-no-source-cursor.md) no-source books never recorded in uploadedHashes + focus refires pullLibrary
|
||||
- [S3/R2 provider](s3-r2-sync-provider.md) third backend, aws4fetch SigV4 path-style, full slice on dev uncommitted; live R2 verify pending
|
||||
- [Hardcover edition_id (#4792)](hardcover-progress-edition-id-4792.md)
|
||||
## Build, Testing & CI
|
||||
- [Nightly quick-sharun hang #4906](nightly-quick-sharun-hang-4906.md) pin via cache pre-seed + step timeouts
|
||||
- [format:check separate gate](verify-format-check-gate.md) · [Worktree rebase submodule drift](worktree-rebase-submodule-drift.md)
|
||||
- Android CDP: [e2e lane](android-cdp-e2e-lane.md) `pnpm test:android`; [WebView profiling](cdp-android-webview-profiling.md); [double-tap gesture](android-e2e-doubletap-cdp-gesture.md)
|
||||
- [Tauri Rust↔JS parser parity](tauri-parser-parity-tests.md)
|
||||
- TTS tests: [browser e2e harness](tts-browser-e2e-harness.md); [paragraph+RSVP sync #3235](tts-sync-paragraph-rsvp-3235.md) TTS-is-clock
|
||||
- [fastlane App Store](fastlane-apple-appstore-submission.md) `APPLE_API_KEY_PATH` out of build env
|
||||
- [Turbopack cache OOM (#4619)](turbopack-build-cache-oom-docker-standalone.md)
|
||||
- [Deps override workflow](deps-security-overrides-workflow.md) `pnpm-workspace.yaml`
|
||||
- [Xcode 26.2 broke iOS builds (swift-rs)](xcode26-swiftrs-ios-build-broken.md) — phantom `Bundle.main`/`privacy:` errors; vendored `packages/swift-rs` `--triple`/`--sdk`; Package.swift platforms floor now enforced
|
||||
- [pdfjs vendor wasm](pdfjs-vendor-wasm-decoders.md) copy `wasm/*`
|
||||
- [CI/PR delivery + push keepalive](ci-pr-delivery-and-push.md)
|
||||
## Platform Compat
|
||||
- Android: [hyphen selection #1553](android-hyphen-selection-bounds-1553.md); [NativeFile vs RemoteFile I/O](android-nativefile-remotefile-io.md)
|
||||
- [Window-state sanitizer #4398](window-state-sanitize-4398.md) · [Android themed icon #4733](android-themed-icon-4733.md)
|
||||
- [Open-with intent #4521](android-open-with-intent-flow.md) · [dict lookup hijack #4559](dict-lookup-browser-hijack-4559.md)
|
||||
- [Large-PDF OOM range flood (#3470)](pdf-oom-range-flood-3470.md) MAX_CONCURRENT_RANGES=6
|
||||
- [Black screen external cache (#4853)](android-black-screen-external-cache-4853.md) FIXED PR#4889; drop `$CACHE` grants
|
||||
- [macOS 26 Tahoe close→black window (#4875)](macos26-tahoe-close-black-screen-4875.md) `minimize()` not `hide()`
|
||||
- [Linux app invisible after backup (#3682)](linux-transparent-window-invisible-3682.md) FIXED PR#4904 opaque window
|
||||
- [Apple OAuth expired + deeplink swallowed (#4881)](oauth-deeplink-error-swallowed-4881.md)
|
||||
- [Annotation link ignored when reader open #4887](deeplink-drop-running-macos-4887.md) `open-book-in-reader` event
|
||||
- [iOS auto-brightness locks (#4885)](ios-brightness-lock-background-4885.md) brightness GLOBAL; release on bg
|
||||
- [iOS share .txt stuck #4917](ios-share-txt-stuck-supportstext.md) drop SupportsText in `project.yml` (xcodegen src)
|
||||
- [Updater disable non-AppImage (#4874)](updater-disable-nonappimage-linux-4874.md)
|
||||
- [Fullscreen no-op Phosh (#4034)](fullscreen-maximized-phosh-4034.md) drop `isMaximized` branch
|
||||
## Reader Features & UI
|
||||
- [Android Auto TTS #3919/PR#4907](android-auto-tts-3919.md) MERGED; CarPlay blocked on entitlement
|
||||
- Widgets: [mobile reading #1602/PR#4842](mobile-reading-widgets.md); [iOS App Group stripped PR#4891](ios-widget-appgroup-stripped-appstore.md); [cover bright right-edge line](ios-widget-cover-bright-edge-line.md) fractional resize → round target to whole px
|
||||
- PDF: [scrolled lag #4795](pdf-scroll-lag-preload-4795.md); [scrolled pinch-zoom #4817](scrolled-pdf-pinch-zoom-4817.md); [pinch vs two-finger scroll #4858](pinch-vs-twofinger-scroll-4858.md); [text selection misplaced w/ OS font scale #4480](pdf-text-selection-fontscale-4480.md) OS font-scale inflates text-layer glyph size not positions; divide `--text-scale-factor` (font-size lever) by detected scale, NOT `--total-scale-factor`
|
||||
- [Search modes #4560](search-modes-4560-and-spoiler-bound-bug.md)
|
||||
- [OPDS groups carousel #4750](opds-groups-carousel-4750.md) · [WebDAV browser sort+search #4724](webdav-browse-sort-search-4724.md)
|
||||
- [Image zoom trackpad flicker (#4742)](image-zoom-trackpad-flicker-4742.md) macOS pinch=`ctrl+wheel`
|
||||
- Instant highlight: [ate tap/swipe](instant-highlight-tap-paginate.md); [delete orphan #4773](instant-highlight-delete-orphan-4773.md); [empty leak #4791](empty-highlight-leak-on-annotate-cancel-4791.md)
|
||||
- Selection: [keyboard adjust #4728](keyboard-selection-adjust-4728.md); [cross-page auto-turn #4741](cross-page-selection-autoturn-4741.md)
|
||||
- Click/tap: [double-click word select](iframe-double-click-word-select.md); [dblclick-drag #4524](dblclick-drag-pageturn-4524.md); [tap open image/table #4600](tap-to-open-image-table-4600.md)
|
||||
- [Annotator onLoad listener leak (#4735)](annotator-onload-listener-leak-paragraph-mode.md)
|
||||
- Paragraph mode: [toggle/resume #4717](paragraph-mode-toggle-resume-4717.md); [accidental exit #4474](paragraph-mode-accidental-exit-4474.md)
|
||||
- [#4584 tap-death](issue-4584-tap-death-investigation.md) UNFIXED; likely WebView-148
|
||||
- [PDF/CBZ Contrast view-menu](pdf-cbz-contrast-view-menu.md) ONE `filter:`
|
||||
- [Header/footer over light PDF in dark (#4901)](pdf-header-footer-contrast-blend-4901.md) `mix-blend-difference`, FIXED light anchor
|
||||
- [iOS instant-dict double popup](ios-instant-dict-double-popup.md) once-per-gesture latch
|
||||
- Dict: [popup font size #4443](dict-popup-font-size-4443.md); [lemmatization #4574](dict-lemmatization-4574.md); [popup speak button #4876](dict-popup-tts-speak-4876.md) standalone wordPronouncer, Edge-direct + dedicated WebAudio ctx
|
||||
- Word Lens: [inline gloss](wordlens-feature.md) CFI-safe ruby; [en-en](wordlens-en-en.md)
|
||||
- [Stripe highest-active plan (#4694)](stripe-plan-highest-active-4694.md)
|
||||
- [Save image to gallery (#4680)](save-image-to-gallery-android.md) MediaStore
|
||||
- [Webtoon Mode (#3647)](webtoon-mode-3647.md)
|
||||
- [Middle-click autoscroll #4951](middle-click-autoscroll-4951.md) Autoscroller RAF core; `containerPosition +=`; armed-books preventDefault
|
||||
- [Auto Scroll teleprompter #4998](auto-scroll-teleprompter-4998.md) MERGED PR#4999 PacedScroller + useAutoScroll + gridcell-centered pill; scrolled-only; tap=pause via iframe-single-click consume
|
||||
- [Biometric app-lock #4645](biometric-app-lock-4645.md) · [Reference Pages #4542](reference-pages-672-4542.md) · [e-ink refresh page-turner #4687](eink-screen-refresh-pageturner-4687.md)
|
||||
- [Share intent + toolbar (#4014)](annotation-share-toolbar-4014.md)
|
||||
- Customize Toolbar: [global serializeConfig #4760](customize-toolbar-global-serializeconfig.md); [e-ink black bar #4839](customize-toolbar-eink-black-bar-4839.md)
|
||||
- [Edge TTS Web Audio engine (#3851)](edge-tts-webaudio-engine.md) gapless WebAudioPlayer + WSOLA
|
||||
- [Background TTS sessions PR#4941](tts-background-session-decoupling.md) — hash-keyed session manager, detach/attach, NowPlayingBar; header X routes `onCloseBook` NOT `onGoToLibrary`
|
||||
- [TTS player redesign](tts-player-redesign.md) mini-player + Dialog sheet replaces icon/popup/TTSBar; MERGED #4996; open: isPlaying glyph desync at section transitions
|
||||
- [Android bg TTS media session fix](android-bg-tts-media-session-fix.md) — #4941/#4931 regression: `startService()` dies backgrounded → in-process instance calls; always request POST_NOTIFICATIONS; + lock-screen duration scrubber + `onSeekTo` (Edge-only)
|
||||
- Native TTS: [iOS #4676](native-ios-tts-4676.md) pause==stop; [offline halt #4613](native-tts-offline-autoadvance-4613.md)
|
||||
- Edge TTS: [word highlight #4017](edge-tts-word-highlighting-4017.md); [drift](tts-word-highlight-singletextnode-drift.md)
|
||||
- TTS UX: [highlight granularity](tts-highlight-granularity-setting.md); [start-from-selection](tts-start-from-selection.md); [reuse session](tts-reuse-session-mode-entry.md)
|
||||
- RSVP: [control-bar REVERT](rsvp-control-bar-overlap-revert.md); [font #4519](rsvp-font-settings-4519.md); [RTL word #4630](rsvp-rtl-word-display-4630.md)
|
||||
- [Overlay z-index scale](zindex-overlay-scale.md) RSVP 100 → app-lock
|
||||
- [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md)
|
||||
- [Overlayer splitRange text nodes](overlayer-splitrange-textnodes.md)
|
||||
- [Android image callout freeze](android-image-callout-freeze.md) `.no-context-menu` ANCESTOR
|
||||
- [Inline-img vertical-align (#4866)](inline-img-vertical-align-4866.md) gated on computed valign
|
||||
- [Table dark-mode tint #4419](table-dark-mode-tint-4419.md) · [footnote aside border #4438](footnote-aside-namespace-order-4438.md)
|
||||
- Proofread: [enhancements #4700](proofread-enhancements-4700.md); [per-book CRDT #4781](proofread-per-book-crdt-sync.md); [edit Find + toggle #4859](proofread-edit-toggle-4859.md)
|
||||
- [Russian NBSP (#4769)](russian-hanging-prepositions-nbsp-4769.md)
|
||||
- OPDS: [Firefox strict-XML #4479](opds-firefox-strict-xml-4479.md); [JSON search #4502](opds2-json-search-4502.md); [HTML desc #4503](opds-html-description-4503.md); [self-link #4749](opds-self-link-metadata-4749.md); [popular dedup #4782](opds-popular-catalog-dedup-4782.md); [auto-download subdir crawl #4272](opds-autodownload-subdir-crawl-4272.md) bounded BFS, never crawl newest-feed catalogs; [preemptive Basic 400s digest Calibre](opds-preemptive-basic-digest-400.md) bare-retry on 400 PR#5002; [auto-download TLS #4988](opds-autodownload-tls-skipssl-4988.md) skipSslVerification parity PR#5002
|
||||
- [D-pad Navigation](dpad-navigation.md)
|
||||
- [koplugin cover upload (#4374)](koplugin-cover-upload.md)
|
||||
- [koplugin Library slow open #4954](koplugin-library-open-mosaic-cache-4954.md) group mosaics recomposed every paint; PR#4974 availability-keyed cache + async compose + cache nil result
|
||||
- [Calibre plugin push #4863](calibre-plugin-push-4863.md) OAuth localhost relay
|
||||
- [Calibre custom columns #4811](calibre-custom-columns-4811.md) `metadata.calibreColumns`
|
||||
## Library Fixes
|
||||
- [Book action platform surfaces](book-actions-platform-surfaces.md) · [menu append race #4389](tauri-menu-append-race-4389.md)
|
||||
- TXT: [author recognition #4390](txt-author-recognition-4390.md); [chapter measure-word FP #4658](txt-chapter-measure-word-4658.md)
|
||||
- [Cover stale (in-place mutation)](cover-stale-inplace-mutation-memo.md)
|
||||
- [Series/author back no-op (#4437)](series-folder-back-noop-4437.md)
|
||||
- [Library/reader separate texture #4743](library-reader-separate-texture-4743.md) · [list view series overflow #4796](list-view-series-overflow-4796.md)
|
||||
- [Recently-read shelf (#3797)](recent-read-shelf-3797.md)
|
||||
- [Auto-import watched folders (#3889)](auto-import-watched-folders-3889.md) per-folder opt-in
|
||||
## Architecture & Patterns
|
||||
- foliate-js submodule at `packages/foliate-js/`; multiview paginator preloads adjacent sections
|
||||
- [Turso "concurrent use forbidden"](turso-concurrent-use-forbidden.md) `op_lock` async mutex
|
||||
- Markdown: [.md support #774](markdown-md-support-774.md); [resume position #4862](markdown-resume-position-4862.md)
|
||||
- Style: `getLayoutStyles()` always, `getColorStyles()` when overriding; `transformStylesheet()` rewrites EPUB CSS
|
||||
- TTS `#ttsSectionIndex`; insets: native plugin → useSafeAreaInsets → styles; Dropdowns `DropdownContext`
|
||||
- Stale settings closure: persist `useSettingsStore.getState().settings` ([#4780](webdav-connect-nullified-4780.md))
|
||||
- [Page margins not live #4898](page-margin-live-update-4898.md) in-place mutation froze memo
|
||||
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md)
|
||||
- [iframe cross-realm instanceof](iframe-cross-realm-instanceof.md) duck-type `'closest'`
|
||||
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md)
|
||||
- [Design system → DESIGN.md](feedback_design_system_doc.md) never `pl/pr/ml/mr` (RTL)
|
||||
## Workflow & Feedback
|
||||
- [Commit messages English-only](feedback-commit-message-english-only.md) no CJK, no em/en dashes
|
||||
- PR flow: [rebase onto origin/main](feedback_pr_rebase.md); [fresh branch per PR](feedback_pr_new_branch.md); [always `pnpm worktree:new`](feedback_use_worktree.md); [commit locally, don't push until confirmed](feedback_dont_push_every_change.md)
|
||||
- [Test file filter](feedback_test_file_filter.md) `pnpm test <path>` no `--`
|
||||
- [No test seams in prod](feedback_no_test_seams_in_prod.md) · [no lookbehind regex](feedback_no_lookbehind_regex.md)
|
||||
- i18n: [en plurals manual](feedback_en_plurals_manual.md); [i18n:extract prunes keys](i18n-extract-prunes-keys.md)
|
||||
- [Dependabot transitive fixes](dependabot-pnpm-overrides.md) `pnpm-workspace.yaml` `overrides:`
|
||||
- [Upgrade gstack locally](feedback_gstack_upgrade.md)
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
name: android-bg-tts-media-session-fix
|
||||
description: Android background TTS regression - startService() dies backgrounded; use in-process service calls; + lock-screen scrubber/seek
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 052bb3f3-27fe-4eb0-95c3-699a3122083a
|
||||
---
|
||||
|
||||
Branch `fix/android-bg-tts-media-session` (worktree `/Users/chrox/dev/readest-fix-android-bg-tts-media-session`), 2026-07-07. **PR readest/readest#4994 MERGED** (2026-07-07; worktree + local branch cleaned up. rebased onto origin/main; foliate-js submodule re-synced to f6dced2 after rebase per [[worktree-rebase-submodule-drift]]). 5 commits: 15817fc4b in-process IPC, 04e4b4fe6 duration scrubber+seek, 67c22b72b FGS hardening+diagnostic logs, 27e224bcc keepAppInForeground removal (the real fix), a8643ec12 Edge edge-fade click fix. Verified on-device (Xiaomi/MIUI/Android 15). Diagnostic Log.d traces left in 67c22b72b (offered to strip).
|
||||
|
||||
**Regression (commit 1, `fix(android): keep background TTS media controls alive when backgrounded`):** after #4941 (session decoupling) + #4931 (Edge WebAudio engine), Android background TTS lost the lock-screen control and audio died when backgrounded. Logs: `Not allowed to start service Intent { act=UPDATE_PLAYBACK_STATE ... MediaPlaybackService }: app is in background`.
|
||||
|
||||
Root cause: `NativeTTSPlugin.update_media_session_state`/`update_media_session_metadata` pushed updates to the already-running foreground service via `activity.startService(intent)`. Android 8+ (BSSR) rejects `Context.startService()` from the background unless an active foreground service exempts the app; each per-sentence update threw, so the FGS notification stopped refreshing (lock-screen control went stale) and the audio route was lost. **Fix pattern: never `startService()` to talk to a running service - call the live instance in-process.** `MediaPlaybackService` already had the pattern: static `@Volatile instance` + `requestDeactivation()` posting to it on the main thread. Added companion `pushMetadata`/`pushPlaybackState` (update statics, post to `instance` via `Handler(Looper.getMainLooper())`) + private instance `applyMetadata`/`applyPlaybackState`; removed the dead `UPDATE_METADATA`/`UPDATE_PLAYBACK_STATE` intent branches + the now-unused `serviceScope`/`kotlinx.coroutines.*`. `startForeground()` to *update* an already-foregrounded service is allowed from background (unlike `Context.startForegroundService()` to *start* one).
|
||||
|
||||
Secondary: #4941 dropped the `keepAppInForeground`/notification titles from `TTSMediaBridge.bind()`'s `setActive({active:true})`. `keepAppInForeground` gated `requestPostNotificationPermission()` in `mediaSession.ts`, and it defaults false (`alwaysInForeground` in constants.ts), so POST_NOTIFICATIONS was never requested. Fix: `setActive` requests it on EVERY activation (no-op once decided), not gated on the setting - else the FGS media notification (= the lock-screen control) is silently suppressed on Android 13+.
|
||||
|
||||
**Feature (commit 2, `feat(android): show section duration and enable seek on the TTS media session`):** user asked to show estimated section duration + seek from the media session. JS half was already there - `ttsMediaBridge.#updatePositionState` already sends `{playing, position, duration}` (ms) every mark, and `mediaSession.ts` already listens for a `media-session-seek` event -> `handlers['seekto']` -> `controller.seekToTime(pos/1000)`. Native side never used them. Added: `METADATA_KEY_DURATION` in the session metadata (Android reads scrubber length from METADATA, thumb from PlaybackState), `ACTION_SEEK_TO` in `setActions`, and `SessionCallback.onSeekTo(pos)` -> `pluginEventTrigger("media-session-seek", {position})` + optimistic thumb move. Bare play/pause updates omit position/duration, so `pushPlaybackState(playing, position: Long?, duration: Long?)` preserves last-known statics (else scrubber snaps to 0 on pause). **Section timeline is Edge/WebAudio ONLY** (`TTSController` comment "position/duration/seek (Edge client only)"; `getPlaybackInfo()` returns null for native TextToSpeech) - native TTS leaves duration 0 so no scrubber appears, which is correct.
|
||||
|
||||
**On-device (Xiaomi/MIUI, targetSdk 36) round 1 FAILED - two findings:** (1) tested APK was STALE - logcat still showed `startService(act=UPDATE_METADATA/UPDATE_PLAYBACK_STATE)` which the fix removes, so the fix wasn't built in (likely built from main tree, not the worktree). (2) Deeper root cause the IPC fix does NOT touch: `W/ActivityManager: Stopping service due to app idle: ...MediaPlaybackService` = the service was NEVER promoted to a foreground service (FGS services aren't idle-stopped; readest uid never appears in FGS-type logs). Also MIUI hostile: uid 10186 (SecurityCenter) repeatedly sets readest `post_notification` appop to `ignore`; `Force stopping service`. Audio plays via WebView (`org.chromium.content.browser.AudioFocusDelegate` holds focus), and the service ExoPlayer also requests AUDIOFOCUS_GAIN - possible focus-steal conflict (unconfirmed).
|
||||
|
||||
**Commit 3 (`fix(android): harden TTS foreground-service promotion + add diagnostics`):** `showNotification` now uses `ServiceCompat.startForeground(this, id, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)` (explicit type, targetSdk 34+) wrapped in try/catch+Log. `setActive` (mediaSession.ts) decoupled: POST_NOTIFICATIONS request in its own try/catch so a throw/hang can't abort `set_media_session_active` (the FGS start). Trace logs added: `set_media_session_active: startForegroundService` (plugin), `activateSession (wasActive=)`, `startForeground ok`/`failed`. Next device run: build FROM the worktree + `adb uninstall com.bilingify.readest` first; set MIUI Autostart ON + battery No-restrictions + lock in recents; grep logcat for those tags to see where the FGS path breaks. If `startForeground ok` but audio still dies backgrounded -> WebAudio AudioContext suspension (test native voice: survives = confirms WebView issue).
|
||||
|
||||
**ACTUAL ROOT CAUSE found round 3 (WebView console `[INFO:CONSOLE]` via `adb logcat` chromium tag):** `Failed to set media session active state: invalid args payload for command set_media_session_active: missing field keepAppInForeground`. The Rust `SetMediaSessionActiveRequest` (models.rs) had `keep_app_in_foreground: bool` as a REQUIRED serde field (all other fields `Option`); #4941's `ttsMediaBridge.bind()` sends `setActive({active:true})` without it, so **Tauri rejected the invoke at the serde layer before the command ran** -> `set_media_session_active` never executed -> FGS never started -> no notification + Android 15 `AS.AudioService: AudioHardening background playback would be muted` killed background audio. Every earlier fix (in-process IPC, FGS hardening, POST_NOTIFICATIONS decouple) was downstream of this and couldn't help because the command never ran. Diagnostic trap: native tags (MediaPlaybackService/NativeTTSPlugin) were absent from logcat because the service was never touched; the answer was only in the WebView JS console (grep logcat for `CONSOLE`). Commit 4 (`fix(android): drop required keepAppInForeground so the TTS service starts`, 27e224bcc): removed `keepAppInForeground` ENTIRELY (dead everywhere - no platform read it; FGS always starts, POST_NOTIFICATIONS now unconditional) from Rust/Kotlin/iOS/TS payloads per user request ("default true"). Follow-up commit 0b8843012 also removed the now-dead `alwaysInForeground` setting + its Android "Background Read Aloud" library-menu toggle (settings.ts/constants.ts/SettingsMenu.tsx + tests) and pruned the i18n key across 33 locales via `pnpm i18n:extract`. **Lesson: on a failed Tauri mobile command, capture the WebView console (logcat `CONSOLE` tag) FIRST - serde arg-rejection surfaces only there, not in native logs.**
|
||||
|
||||
Verified: `pnpm test` (7022 pass), `pnpm lint` clean, `cargo check/fmt/clippy -p tauri-plugin-native-tts` clean. **Kotlin NOT compiled/device-verified** - worktree `src-tauri/gen/android` lacks `tauri.settings.gradle` so the plugin's `app.tauri.plugin.*` deps don't resolve standalone; needs `pnpm tauri android` on a real Android 13+/14 device (logcat: foreground -> background -> lock screen). Related: [[tts-background-session-decoupling]], [[edge-tts-webaudio-engine]], [[native-ios-tts-4676]].
|
||||
@@ -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,56 +0,0 @@
|
||||
---
|
||||
name: android-e2e-doubletap-cdp-gesture
|
||||
description: Nightly Android E2E double-tap test failed since
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: eafe11ed-faac-4406-957d-1674353f081c
|
||||
---
|
||||
|
||||
Nightly `Android E2E (CDP)` failed every night from 2026-06-28 (first night after
|
||||
PR #4846 merged) with `timed out waiting for selection of "..." (last: null)` in
|
||||
`double-click.android.test.ts`. The test never passed on CI. Fixed 2026-07-04 —
|
||||
harness-only, no app code changed.
|
||||
|
||||
**Two stacked root causes:**
|
||||
|
||||
1. **PRIMARY — feature is opt-in on mobile.** `DEFAULT_MOBILE_VIEW_SETTINGS`
|
||||
ships `disableDoubleClick: true` (double-click detection delays single-tap
|
||||
page turns by the 250ms disambiguation window, so mobile opts out).
|
||||
`handleClick` (iframeEventHandlers.ts) then posts `iframe-single-click`
|
||||
IMMEDIATELY and never arms the double-click window — the #4846 double-tap
|
||||
word selection is deliberately "gated by the user's double-click setting"
|
||||
(Annotator comment). The e2e assumed default config → could never pass on a
|
||||
fresh device. Diagnostic signature: `iframe-single-click` ~20ms after click
|
||||
(window disabled) vs ~250ms after (window armed, no second click).
|
||||
2. **SECONDARY — adb double-tap can't hit the window.** The old helper ran
|
||||
`input tap x y && input tap x y`; each `input` invocation spawns a fresh
|
||||
app_process JVM (measured 0.9–1.05s each, 28s cold). Warm fast host ≈130ms
|
||||
click gap (passes), loaded CI emulator >250ms (always fails).
|
||||
|
||||
**Fix (all in `src/__tests__/android/`):**
|
||||
- `reader.ts patchGlobalViewSettings(patch)` — force-stop app, read/patch
|
||||
`settings.json` `globalViewSettings` via `adb shell run-as` (debug builds
|
||||
only; file lives at the app data dir ROOT, not files/), write back via
|
||||
base64 pipe, return previous values for restore in afterAll. Missing
|
||||
settings.json is fine: `loadSettings` deep-merges partial file over defaults.
|
||||
- `cdp.ts CdpPage.doubleTap(cssX, cssY)` — ONE
|
||||
`Input.synthesizeTapGesture {tapCount: 2, duration: 20, gestureSourceType: 'touch'}`;
|
||||
renderer-internal timing gives ~200ms click gap on a busy emulator. TWO
|
||||
sequential synthesizeTapGesture commands are too slow (~535ms gap — each
|
||||
resolves long after its gesture). Raw `Input.dispatchTouchEvent` delivers
|
||||
touch events but does NOT reliably synthesize clicks on Android WebView.
|
||||
- Word finder requires `range.getClientRects().length === 1`: a
|
||||
hyphenated/wrapped word's bounding rect spans two lines, so its center taps
|
||||
between lines and selects the neighboring word (saw `'party' !== 'sensation'`).
|
||||
- `dismissSelection` picks a mid-column tap spot (0.78H or 0.25H) that the
|
||||
`.selection-popup` doesn't cover — a blind 0.78H tap can press a toolbar
|
||||
button when the selection sits low.
|
||||
|
||||
**Other gotchas:** headless emulator display sleeps → adb `input` no-ops while
|
||||
CDP input still works (`input keyevent KEYCODE_WAKEUP`); a leaked single-click
|
||||
(broken double-tap) toggles header or opens the media viewer, contaminating the
|
||||
session; local repro = `pnpm tauri android build --debug --target aarch64` +
|
||||
`adb install -r` + `pnpm test:android`.
|
||||
|
||||
Related: [[android-cdp-e2e-lane]], [[iframe-double-click-word-select]]
|
||||
@@ -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 routing is now gated by `autoImportBooksOnOpen` (PR #4747, issue #4746): `shouldOpenTransient(action, autoImportBooksOnOpen)` in `helpers/openWith.ts` → only `VIEW` with the setting OFF goes `openTransient` (ephemeral book, `deletedAt` set, `filePath` = content:// URI, no library write/upload); `VIEW` with it ON falls through to the SEND path. SEND (and VIEW-with-import-on) → `window.OPEN_WITH_FILES` → `library/page.tsx::processOpenWithFiles` (full ingest + force cloud upload on mobile). The setting defaults TRUE on mobile (`DEFAULT_MOBILE_SYSTEM_SETTINGS`, desktop default still false) and its "Auto Import on File Open" toggle is now shown on mobile too. `useOpenWithBooks.handle` reads it via `appService.loadSettings()` (disk), NOT the settings store — the store is unhydrated during the cold-start intent replay and would wrongly fall back to transient. So the Telegram default is now import-to-library (persists past the dying URI grant); transient is opt-out.
|
||||
- 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,52 +0,0 @@
|
||||
---
|
||||
name: android-themed-icon-4733
|
||||
description: "Android Material-You themed (monochrome) launcher icon — restoring it (#4733), the gen/android force-commit pipeline, and emulator verification"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 6bc82dac-a705-4ef2-ab28-c13b43f48a46
|
||||
---
|
||||
|
||||
Issue #4733 = add Android themed (Material You / monochrome) launcher icon. It had
|
||||
existed (#2122/#2153 added `ic_launcher_monochrome.png`) but PR #2353 ("fixed
|
||||
launcher icon size") rewrote the committed adaptive icon to inset the foreground
|
||||
22% and **silently dropped the `<monochrome>` layer**, so themed icons stopped
|
||||
working. Fix = re-add `<monochrome><inset android:drawable="@mipmap/ic_launcher_monochrome" android:inset="22%"/></monochrome>`
|
||||
to `ic_launcher.xml` + ship the monochrome mipmaps.
|
||||
|
||||
**Android icon pipeline (non-obvious).** `src-tauri/gen` is gitignored, BUT specific
|
||||
customized res files are **force-added** (tracked): `mipmap-anydpi-v26/ic_launcher.xml`,
|
||||
`drawable/ic_launcher_background.xml`, `values/themes.xml`, `splash_icon.png`. CI
|
||||
(release/nightly/android-e2e) does `rm -rf gen/android` → `tauri android init` →
|
||||
`tauri icon ../../data/icons/readest-book.png` → **`git checkout .`** (restores the
|
||||
tracked customizations) → build. So **the committed gen files are the build's source
|
||||
of truth.** `tauri icon` (CLI 2.10.1) writes gen mipmaps + a DEFAULT `ic_launcher.xml`
|
||||
(foreground+background only) and does NOT emit a monochrome layer — so the monochrome
|
||||
PNGs (or a vector drawable) MUST be force-committed into `gen/.../res/` to survive
|
||||
`git checkout .`. `git add -f apps/.../gen/.../mipmap-*/ic_launcher_monochrome.png`.
|
||||
`src-tauri/icons/android/*` is the historical master but is NOT what the build reads.
|
||||
|
||||
**Themed tint = SRC_IN (alpha only).** The launcher replaces the monochrome layer's
|
||||
RGB with the wallpaper tint, keeping only alpha → any fully-opaque artwork flattens
|
||||
to a solid blob (the original desaturated-logo monochrome lost all detail). Convey
|
||||
character via negative space. For Readest we kept the existing artwork and carved a
|
||||
**narrow vertical center-gap (spine)** via an alpha-multiply mask (ImageMagick:
|
||||
`magick src -alpha extract a.png; magick -size WxH xc:white -fill black -draw "roundrectangle ..." g.png; magick a.png g.png -compose multiply -composite na.png; magick src na.png -alpha off -compose CopyOpacity -composite out.png`),
|
||||
gap ≈ centered, width ~4% of content, from ~3%→84% of content height (pages stay
|
||||
joined at the binding). Preview-as-themed = tint `-colorize`, inset to central 56%
|
||||
(=22% inset), composite over dark bg, circular mask.
|
||||
|
||||
**Emulator verify (Pixel_9_Pro AVD, Google Play image, NexusLauncher).** Themed icons
|
||||
toggle: Wallpaper & style (`am start -n com.google.android.apps.wallpaper/com.android.customization.picker.CustomizationPickerActivity`)
|
||||
→ "Home screen" tab → "Themed icons" switch. Only the **home screen/dock** is themed;
|
||||
the **app drawer keeps full color** (expected, not a bug). `uiautomator dump` returns
|
||||
"null root node" on the wallpaper picker (SurfaceView) → navigate by screenshot
|
||||
coords. Build for emulator = `pnpm tauri android build --debug --target aarch64 --apk`
|
||||
(NDK_HOME must be set). Gradle-standalone (`./gradlew :app:assembleUniversalDebug`)
|
||||
fails: the `rustBuild*` task shells `pnpm tauri ...` which panics at
|
||||
`tauri-cli/src/mobile/mod.rs:403` unless driven by `tauri android build`. Confirm the
|
||||
APK packaged it: `aapt2 dump xmltree --file res/mipmap-anydpi-v26/ic_launcher.xml app.apk`
|
||||
should show an `E: monochrome` node. Regression guard:
|
||||
`src/__tests__/android/themed-icon.test.ts` (asserts `<monochrome>` in the XML + a
|
||||
tracked monochrome mipmap per density). Related: [[dict-lookup-browser-hijack-4559]]
|
||||
(Android resource/manifest gotchas), [[android-cdp-e2e-lane]].
|
||||
@@ -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,25 +0,0 @@
|
||||
---
|
||||
name: annotator-onload-listener-leak-paragraph-mode
|
||||
description: "Paragraph mode degrades over chapters on Android (#4735) — Annotator onLoad leaked renderer-scroll + native-touch listeners on long-lived objects; per-view fix + the reusable per-section-leak pattern"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 980f8d79-9360-4402-bd49-8dd389200c1e
|
||||
---
|
||||
|
||||
PR #4735 (`fix/annotator-input-listener-leak`). Reported: reading a 3000-chapter web novel in **paragraph reading mode** on Android (Z Fold 7), paragraph transitions get sluggish after a few chapters and keep degrading until app restart. Classic per-section-transition resource leak.
|
||||
|
||||
**Root cause — Annotator `onLoad` attaches listeners to objects that OUTLIVE the section.** `Annotator.tsx`'s `onLoad` (wired via `useFoliateEvents(view, { onLoad })` to the foliate `load` event, which fires once per section document load) did:
|
||||
- `view.renderer.addEventListener('scroll', handleScroll)` — never removed
|
||||
- `view.renderer.addEventListener('scroll', () => repositionPopups())` — anonymous, unremovable
|
||||
- Android: `eventDispatcher.on('native-touch', handleNativeTouch)` — never `off`'d
|
||||
|
||||
`view.renderer` is created ONCE per book (`createElement('foliate-view')` + `view.open()` in `FoliateViewer.tsx`), lives the whole session; the global `eventDispatcher` too. So every `load` permanently adds listeners. **foliate fires `load` for PRELOADED neighbour sections too** (`paginator.js#loadAdjacentSection` → `dispatchEvent(new CustomEvent('load',…))`, `#preloadNext` loads up to 8), so the accrual is several-per-chapter, not one. The doc-scoped `detail.doc.addEventListener(...)` listeners do NOT leak (the section iframe is destroyed by foliate's `#destroyView`, taking them with it). Only the renderer-/dispatcher-scoped ones leak.
|
||||
|
||||
**Why paragraph mode + Android specifically.** Every paragraph advance calls `renderer.goTo({index, anchor})` (`focusCurrentParagraph`), which scrolls the renderer container → `paginator.js:~1161` `this.#container.addEventListener('scroll', () => { if(!#isAnimating) dispatchEvent(new Event('scroll')) })` → runs ALL accumulated scroll listeners. Normal paginated reading scrolls only on occasional page turns, so the same leak is far less felt. Cost is REAL on Android: `handleScroll` (useTextSelector.ts, the `#873` selection-pin workaround) early-returns unless `osPlatform==='android'`, then calls `getViewSettings`; `native-touch` is Android-only. Restart recreates the view/renderer → cleared (the reporter's workaround).
|
||||
|
||||
**Fix = `useRendererInputListeners(view, {...})` hook** (`src/app/reader/hooks/`): registers the renderer `scroll` + (Android) `native-touch` listeners ONCE per view in an effect keyed `[view, enableNativeTouch]`, with cleanup; handlers routed through refs so re-renders never re-subscribe. The native-touch handler now resolves the CURRENT primary section's doc/index at fire time (`view.renderer.getContents().find(c=>c.index===primaryIndex)`) instead of capturing a load's doc/index (a load may be an off-screen preload — and the old code fan-fired EVERY loaded section's handler per touch, calling handleTouchEnd/handlePointerUp N times; new code fires once = strictly more correct). Dropped the redundant `scroll→repositionPopups` (a dedicated effect already repositions popups on scroll). `listenToNativeTouchEvents()` just sets one global `window.onNativeTouch` that re-dispatches `native-touch` via eventDispatcher — idempotent, fine to call once/view.
|
||||
|
||||
**Reusable pattern (the lesson).** Listeners attached inside a per-section / per-event handler (`onLoad`, `load`, relocate, create-overlay) to an object that outlives that event (`view.renderer`, global `eventDispatcher`, `window`, `document`) LEAK one set per event. Audit `addEventListener`/`eventDispatcher.on` inside `onLoad`-style handlers: if the target isn't the per-section `detail.doc` (which dies with the iframe), it must move to a per-view `useEffect` with cleanup. `eventDispatcher` (`utils/event.ts`) stores async listeners in a per-event `Set` keyed by callback reference; fresh closures each call never dedupe → unbounded.
|
||||
|
||||
**Verify gotchas.** Hook unit-tested with `renderHook` + a `MockRenderer extends EventTarget` tracking scroll listeners + a mocked `eventDispatcher` Set; assert size stays 1 across 20 re-renders, latest-handler routing, unmount→0, Android gate. NOTE: creating the mock `view` INSIDE the renderHook callback churns view identity → the `[view]`-keyed effect re-runs each render (still no leak — cleanup keeps Set at 1 — but `listenToNativeTouchEvents` call-count grows); hoist `view` outside to mirror the real stable `getView(bookKey)`. Needs on-device Android verification (Android-gated paths). Related: [[paragraph-mode-toggle-resume-4717]], [[tts-sync-paragraph-rsvp-3235]], [[android-nativefile-remotefile-io]].
|
||||
@@ -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,51 +0,0 @@
|
||||
---
|
||||
name: auto-scroll-teleprompter-4998
|
||||
description: "Auto Scroll teleprompter mode (#4998, PR#4999): PacedScroller core, useAutoScroll hook, control pill centered on gridcell, scrolled-mode-only View menu toggle"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 129a72a3-6d52-4f4c-a499-972c0055b4e3
|
||||
---
|
||||
|
||||
Auto Scroll reading mode (#4998), PR #4999 MERGED 2026-07-08 (merge
|
||||
f8ad47a41); worktree and local branch cleaned up.
|
||||
Teleprompter scrolling for scrolled mode only, toggled from the View menu
|
||||
(Shift+A, `onToggleAutoScroll`), dispatches `autoscroll-toggle` events.
|
||||
|
||||
Key structure:
|
||||
- `PacedScroller` added to `src/app/reader/utils/autoscroller.ts` alongside the
|
||||
middle-click `Autoscroller` ([[middle-click-autoscroll-4951]]): constant
|
||||
velocity, whole-pixel emission + fractional carry, injected raf/now for
|
||||
tests, `PACED_SCROLL_MAX_FRAME_MS` dt clamp (background tab resume). A
|
||||
scrollBy callback may stop() the scroller mid-tick; #tick re-checks active
|
||||
before re-arming (test covers it).
|
||||
- `useAutoScroll(bookKey, viewRef)` in reader/hooks, mounted in FoliateViewer:
|
||||
scrolls `renderer.containerPosition += sign * delta`; sign = -1 when
|
||||
`renderer.scrollProp === 'scrollLeft'` (scrolled+vertical), matching foliate
|
||||
paginator.js `offset = -offset` for scrolled vertical (vertical-lr is a known
|
||||
upstream FIXME). Manual wheel/drag composes with the paced steps by design
|
||||
(no pause-on-wheel). Tap pause/resume consumes `iframe-single-click` via
|
||||
eventDispatcher.onSync (same swallow mechanism as middle-click). Stall
|
||||
detection: containerPosition unchanged ≥800ms → `view.next()` (hops sections
|
||||
under noContinuousScroll) or stop + 'End of book' toast when
|
||||
`renderer.atEnd`. Session state mirrored to readerStore
|
||||
`viewState.autoScrollEnabled` (new setter) for the ViewMenu checkmark;
|
||||
session never persisted, speed IS: `autoScrollSpeed` percent in BookLayout
|
||||
(default 100 = 20 px/s base, 25-500 step 25, constants in
|
||||
services/constants.ts).
|
||||
- `AutoScrollControl` pill reuses the ParagraphBar chassis but positioned
|
||||
`absolute` (NOT `fixed`): maintainer explicitly wants it centered on the
|
||||
book's gridcell, not the viewport — pinned sidebar pushes the reading column
|
||||
off window center. (ParagraphBar's #4474 comment argues the opposite for
|
||||
paragraph mode; the two are intentionally different.) Fades after 2.5s while
|
||||
scrolling, wakes on mousemove/pause, hidden while hoveredBookKey shows bars.
|
||||
- Adding a field to readerStore ViewState breaks two test fixtures that build
|
||||
ViewState literals (reader-store.test.ts, tts-auto-advance.browser.test.tsx).
|
||||
- i18n: 6 new keys (Auto Scroll, Toggle Auto Scroll, Slower, Faster, Exit Auto
|
||||
Scroll, End of book) hand-translated across all 33 locales following each
|
||||
locale's existing Scrolled Mode / RSVP Slower-Faster terminology; scanner
|
||||
extraction only touched trailing commas (no pruning this time).
|
||||
|
||||
Verified live in dev-web with claude-in-chrome (localhost:3001): 20 px/s at
|
||||
100%, menu gating, pill geometry (pillCenterX == gridcell center != viewport
|
||||
center).
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
name: backup-windows-zip-paths-4703
|
||||
description: Backup zip exported on Windows failed to restore anywhere — backslash separators in zip entry names
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: dd015419-996e-466b-8039-f2d98312d9d6
|
||||
---
|
||||
|
||||
#4703: Backup `.zip` exported on Windows wouldn't restore on any platform (Web/Android/Windows) — books restored with metadata but missing files/covers.
|
||||
|
||||
**Root cause:** `appService.readDirectory` returns paths with the host separator. On Windows `nativeAppService.readDir` → `getRelativePath` strips the base prefix but leaves backslashes, so `file.path` is `hash\cover.png`. `addBackupEntriesToZip` used `file.path` verbatim as the zip entry name. Restore (`restoreFromBackupZip`) matches a book's files by `e.filename.startsWith(`${hash}/`)` (forward slash) → backslash names never match → all files silently skipped. (The "garbled Unicode" reported in zip viewers was just the `\` rendered oddly.)
|
||||
|
||||
**Fix (export side only):** normalize the zip entry name to forward slashes — `file.path.replace(/\\/g, '/')` — in `addBackupEntriesToZip` (`src/services/backupService.ts`). Keep `file.path` (host separators) for `readFile`. Test: `backup-windows-paths.test.ts` drives the now-exported `addBackupEntriesToZip` with a capturing ZipWriter stub + Windows-style backslash listing (no zip.js workers needed; the forced `useWebWorkers`/`useCompressionStream` config makes a real round-trip impractical under jsdom).
|
||||
|
||||
**General lesson:** `readDirectory`/`readDir` paths carry host separators; normalize to `/` at any cross-platform boundary (zip entries, sync keys, anything serialized for another device). Already-broken Windows backups still need re-export — restore was left unchanged (minimal fix; matches the issue's expected behavior). See [[platform-compat-fixes]].
|
||||
@@ -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,24 +0,0 @@
|
||||
---
|
||||
name: calibre-custom-columns-4811
|
||||
description: "Surface Calibre custom columns from OPF user metadata (#4811) - parse formats, calibreColumns field, details UI, library search"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 7f74fc26-9614-4fe3-987b-66c8ce412523
|
||||
---
|
||||
|
||||
Feature #4811 SHIPPED (app PR #4939 merged 2026-07-05 as `ec45a08`; foliate-js#47 merged as `8485e93`): surface Calibre custom columns embedded in EPUB OPFs. Worktree and branches cleaned up.
|
||||
|
||||
**Calibre OPF encodings (verified against calibre source opf2.py/opf3.py):**
|
||||
- OPF2: one `<meta name="calibre:user_metadata:#label" content="{json}"/>` per column; label must start with `#`
|
||||
- OPF3: a single `<meta property="calibre:user_metadata">{"#label": {...}}</meta>` (raw property attr always literally `calibre:user_metadata`; the `calibre:` prefix maps to `https://calibre-ebook.com` but foliate's URL-resolution concatenates without `:` so match the RAW attr, not the resolved one). Calibre prefers OPF3 over OPF2 when both present (`read_user_metadata3 || read_user_metadata2`).
|
||||
- Value in `#value#` (array for multi-value), series index in `#extra#`; datetimes wrapped `{"__class__": "datetime.datetime", "__value__": "<ISO>"}`, unset date = `0101-01-01`; embedded files carry EVERY library column so empty values (null/''/[]/rating 0/undefined-date) must be dropped at parse time.
|
||||
|
||||
**Where things live:**
|
||||
- Parser: `getCalibreUserMetadata` in foliate-js `epub.js`, attached AFTER `tidy()` (tidy would collapse single-element value arrays) as `metadata.calibreColumns` `[{label, name, datatype, value, extra?}]`
|
||||
- Type: `CalibreCustomColumn` in `src/libs/document.ts`; `BookMetadata.calibreColumns`
|
||||
- Formatter: `formatCalibreColumnValue` in `src/utils/book.ts` (rating → ★ half-stars /2, series → `Name [idx]`, bool → ✓/✗, comments → strip tags, datetime → formatDate)
|
||||
- UI: extra grid cells in `BookDetailView.tsx` Metadata section after Identifier (column names are user content, NOT i18n keys)
|
||||
- Search: `getCalibreColumnsText` in `src/app/library/utils/libraryUtils.ts` `createBookFilter` (both regex and substring branches)
|
||||
|
||||
**Why safe:** metaHash dedupe uses only title/authors/identifiers; metadata editor spreads `{...metadata}` so the field survives edits; import assigns `loadedBook.metadata` as-is. Calibre plugin pushes already embed user metadata via calibre `set_metadata`, so plugin-pushed books get columns through the same OPF parse (the plugin's flat `customColumns` wire field is a DIFFERENT shape and stays unused). E2E-verified on the real sample (Elena Sabe, OPF3, 11 columns → 7 shown, search "CT1" filters). Related: [[calibre-plugin-push-4863]].
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: calibre-plugin-push-4863
|
||||
description: "readest-calibre-plugin (#4863) pushes calibre books+metadata to Readest cloud; key protocol facts (OAuth localhost relay, /sync explicit-null carry-over)"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 5d4d83a0-0aee-4200-852f-555df5243bed
|
||||
---
|
||||
|
||||
`apps/readest-calibre-plugin/` implements #4863: calibre GUI plugin pushing selected books + metadata into the Readest cloud, modeled on BookFusion's plugin. MERGED to main via PR #4918 (2026-07-04, merge 6b403d019); packaged in releases as `Readest-<version>.calibre-plugin.zip` by release.yml's `build-calibre-plugin` job.
|
||||
|
||||
Design decisions and hard-won protocol facts:
|
||||
- **Identity**: `Book.hash` = partial MD5 (KOReader algorithm; JS `1024 << -2` wraps to 0, so offsets are 0, 1024, 4096, ... 1024<<20). metaHash = `md5(NFC("title|authors,|ids,"))`, preferred id scheme uuid > calibre > isbn; Python impl verified byte-identical to `js-md5` output.
|
||||
- **OPF embedding + uuid dedup** (v2, per maintainer request): metadata IS embedded into a temp copy at upload (`calibre.ebooks.metadata.meta.set_metadata` — deterministic for EPUB, writes custom columns as `calibre:user_metadata`). Dedup keys: calibre uuid in row `metadata.identifier` (survives byte changes) + `metadata.calibreSourceHash` = raw library-file partialMD5 (change detection, no local state; v1 rows fall back to `book_hash` which equals the raw hash). File changed → replace flow: upload new blob, push new row (carry-over) + tombstone old in one /sync POST, best-effort delete old cloud files. Metadata-only edit → row update, no re-upload (embedded OPF goes stale until next file upload).
|
||||
- **POST /sync explicit-nulls absent fields** (transformBookToDB) — updates must carry over `uploadedAt`, `groupId/Name`, `progress`, `readingStatus*`, `coverHash` from the pulled server row (`wire.py::merge_for_push`); same lesson as koplugin syncbooks.lua.
|
||||
- **Upload key** `Readest/Books/{hash}/{hash}.{ext}`; app's `{title}.{ext}` downloads resolve via download.ts hash+extension fallback. cover.png stores *original* bytes (app never converts formats, bookService.ts:568), so calibre's cover.jpg bytes upload as-is; coverHash = partialMD5 of those bytes.
|
||||
- **OAuth from a non-app client works**: `{supabase}/auth/v1/authorize?provider=X&redirect_to=http://localhost:PORT` is whitelisted (readest-app's Flatpak/custom-OAuth production path uses it). Tokens arrive in the URL *fragment*; serve a page whose JS relays `location.hash` to `/callback?...` (tauri-plugin-oauth trick). Implemented in `oauth.py`.
|
||||
- Pure modules (`api.py`, `wire.py`, `oauth.py`) are calibre-free; `make test` runs 56 unittests; `make zip` builds; smoke-test inside calibre with `calibre-debug -c` after `from calibre.customize.ui import find_plugin` (initializes the `calibre_plugins` namespace).
|
||||
|
||||
- **Release packaging** (PR #4918): `build-calibre-plugin` job in release.yml mirrors the koplugin job; perl-stamps `PLUGIN_VERSION` from readest-app package.json, `make zip` → `Readest-<version>.calibre-plugin.zip` release asset. Committed version stays the (0, 1, 0) dev placeholder.
|
||||
- **Pushing workflow files**: gh's OAuth token lacks `workflow` scope (HTTPS push of .github/workflows/* rejected); SSH push works (transient hangs — retry with ConnectTimeout/ServerAliveInterval).
|
||||
|
||||
Related: [[koplugin-cover-upload]], [[grimmory-native-sync]], [[ci-pr-delivery-and-push]]
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
name: captured-turn-instant-highlight-scrolllock
|
||||
description: Captured slide/curl page turns ignored the instant-highlight still-hold gate; fixed by honoring renderer.scrollLocked like the push paginator
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 871c7b42-61c0-44e7-a1d6-8edb35d80300
|
||||
---
|
||||
|
||||
Instant Highlight's 300ms still-hold gate ([[instant-highlight-tap-paginate]])
|
||||
worked in **push** mode but NOT in **slide/curl** — a swipe after the hold turned
|
||||
the page (with the slide/curl effect) instead of extending the highlight.
|
||||
|
||||
**Root cause: two independent swipe paths.** foliate's native `#onTouchMove`
|
||||
(paginator.js) bows out at `if (this.hasAttribute('no-swipe')) return` (~2149),
|
||||
THEN checks `if (this.#scrollLocked) return` (~2162), THEN the `#layeredTurn` VT
|
||||
drag (~2179). So:
|
||||
- **push** (no turn-style, no no-swipe) → native swipe, honors `#scrollLocked`. ✅
|
||||
- **VT-layered slide** (`turn-style='slide'`, no no-swipe; engines with nested VT
|
||||
groups) → native swipe → layered turn, still AFTER the scrollLocked check. ✅
|
||||
- **captured curl (always) / captured slide (Tauri w/o full VT support)** →
|
||||
`applyPageTurnAttributes` sets `no-swipe`, so native swipe returns early and the
|
||||
APP-side captured-turn touch interceptor in `useCapturedTurn.ts` (priority 5,
|
||||
driven by `iframe-touchmove` → `dispatchTouchInterceptors`) is the swipe handler.
|
||||
It began a drag on any >15px horizontal move WITHOUT checking scrollLocked. ❌
|
||||
|
||||
`useTextSelector.startInstantAnnotating` sets `view.renderer.scrollLocked = true`
|
||||
when the hold engages. The captured interceptor is a parallel reimplementation of
|
||||
swipe-to-turn and must honor the same lock independently.
|
||||
|
||||
**Fix (app PR readest#5000 + foliate readest/foliate-js#51, tests:
|
||||
`useCapturedTurn-scrollLock.test.ts`):**
|
||||
1. foliate `paginator.js`: add `get scrollLocked()` — it was setter-only, so JS
|
||||
couldn't read it back (app `src/types/view.ts` already declared it a readable
|
||||
boolean). foliate PR #51 MERGED (squash → `ba57ec8` on foliate main); app
|
||||
#5000 bumps the submodule pointer to `ba57ec8` (mergeable, awaiting merge).
|
||||
2. `useCapturedTurn.ts` touch interceptor, `move` phase, before starting a drag
|
||||
(`!state` branch): `if (currentView.renderer.scrollLocked) return false;`.
|
||||
|
||||
**Why the `!state` branch is sufficient:** a captured drag needs >15px horizontal
|
||||
travel, but `maybeCancelInstantHoldOnMove` cancels the hold at >10px — so a drag
|
||||
can never already be in progress when instant annotation engages; no need to gate
|
||||
an in-flight drag. See [[page-turn-styles-viewtransitions-555]].
|
||||
@@ -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,32 +0,0 @@
|
||||
---
|
||||
name: cloud-sync-provider-selection-plan
|
||||
description: "APPROVED /autoplan-reviewed plan making third-party sync (WebDAV/Drive) a first-class selectable provider; quota scoped to Readest Cloud (#4959/#4380)"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: c0549d91-7f40-46a8-b110-628964be195b
|
||||
---
|
||||
|
||||
Plan APPROVED 2026-07-06 after full /autoplan review (CEO+Design+Eng dual voices, 43 logged decisions). Plan file: `~/.claude/plans/research-on-https-github-com-readest-rea-velvet-meteor.md` (contains registries, UI state matrix, eng hardening, coverage diagram, 26 tasks). CEO doc: `~/.gstack/projects/unknown/ceo-plans/2026-07-06-cloud-sync-provider-selection.md`.
|
||||
|
||||
**Architecture:** policy layer over TWO engines (native DB-sync + FileSyncEngine) — Readest Cloud is NOT wrapped in FileSyncProvider (would regress server merges #4634/#4544/#4678). New `src/services/sync/cloudSyncProvider.ts`: pure `getCloudSyncProvider(settings)` derived from `webdav/googleDrive.enabled` (device-local) + separate `resolveCloudSyncGate(settings, plan)` w/ cached plan accessor (isCloudSyncAllowed needs async JWT — can't be settings-pure). Guard trips → PAUSED state + prompt, never silent readest fallback. Native gating = one branch in `syncCategories.isSyncCategoryEnabled` (book/progress/note); binary gating = `transferManager.queueUpload` returns null. Account channels (settings/stats/replicas/translations/Send) always native.
|
||||
|
||||
**Sequence (user-ruled at gate):** PR1 quota decouple (#4959 hotfix: gate + quota-403 no-retry + BATCH toast dedupe — spam is N-books×1-toast after retries, verified transferManager.ts:376/395) → PR1.5 file-engine parity (tags+readingStatus in mergeBookConfig/mergeBookMetadata+wire, BEFORE gating) → PR2 exclusive gating + mixed-fleet detection → PR3 chooser UI.
|
||||
|
||||
**Gate rulings:** UC3 = `syncBooks` AUTO-ENABLES on third-party selection (closes books-backed-up-nowhere hole; opt-out shows warning). UC1 = derived/device-local kept + read-only `/api/sync?since=providerSelectedAt&limit=1` probe → one-time "another device still syncs" banner + Sentry provider tag. UC2 = parity before gating. Switch-back = new-imports-only auto-upload (NO 675-book burst); metadata rows DO re-push (intended).
|
||||
|
||||
**PR1 IMPLEMENTED (2026-07-06):** commit `f6e5d7740` on branch `fix/cloud-sync-quota-decouple` (worktree `/Users/chrox/dev/readest-fix-cloud-sync-quota-decouple`), 22 files, LOCAL ONLY (not pushed, per confirm-before-push). Full suite 6900 pass + lint clean; new suites: `cloudSyncProvider.test.ts` (18), `transfer-manager-gating.test.ts` (19). i18n extraction deliberately SKIPPED in this PR (scanner pruned ~1350 live translations, e.g. "Read Aloud" — run the dedicated /i18n pass later; new strings fall back to English keys). Deviations from plan, all sound: paused toast centralized in `handleBookUpload` (both manual surfaces route through it); useTransferQueue default-param hazard fixed by the manager-level settings barrier instead of signature churn; migration passes the settings snapshot into `runMigrations(lastVersion, settings)` and mutates in place because `Settings.loadSettings` re-reads disk (subclass post-save would clobber an independent save). **SERIES FULLY MERGED (2026-07-07): #4971 (PR1 quota) + #4973 (PR1.5 parity) + #4975 (PR2 exclusive routing, closes #4380) + #4976 (PR3 chooser UI).** Worktrees removed, local branches deleted. **LIVE-VERIFY BUG FOUND+FIXED = #4981 OPEN** (`3a0af54dd`, fix/file-sync-auth-abort): expired Drive web token → engine swallowed AUTH_FAILED on index pull → remoteIndex=null read as FIRST SYNC → attempted 682-book re-upload march; latent hazard: null index skips the peers-tombstone union in the final re-push (#4860 class — transient pull failure could resurrect deletions). Fix: unreadable index (throw) aborts (404→null stays first-sync); terminal AUTH_FAILED latch stops runPool + skips index push + rethrows; web auto-sync preflights hasValidWebDriveToken. KEY ENGINE INVARIANT going forward: FileSyncError AUTH_FAILED is terminal — rethrow, never aggregate.
|
||||
|
||||
**i18n PASS = #4980 OPEN** (`237953cc2`, fix/cloud-sync-i18n, worktree `readest-fix-cloud-sync-i18n`): 22 strings x 33 locales + CLDR plural forms + en `_one`/`_other`, appended WITHOUT the scanner (removeUnusedKeys would prune live keys), additions-only diff. REMAINING: live verification checklist (real WebDAV 192.168.2.3:6065: exclusive e2e, syncBooks auto-enable on connect, fleet banner, switch-back no-burst, two-window switch), TODOS.md follow-ups (Sentry Rust tag, server quota error code, download-all-before-switch, library sync indicator, account chip, stats/viewSettings parity, Manage-Sync binary-gating mismatch). Note: GitHub reports 5 dependabot vulns on default branch (1 high) — pre-existing.
|
||||
|
||||
**PR3 contents:** activation moved to `src/services/sync/cloudSyncActivation.ts` (accepts 'readest'; component cloudSync.ts is a re-export shim); pure status matrix `cloudSyncStatus.ts` (getReadestCloudRowStatus/getThirdPartyRowStatus, fully tested — paused renders on the THIRD-PARTY row, not Readest row as plan sketch had it); Cloud Sync section (Readest-first radio rows, scope subtitle, role=radiogroup); Readest Cloud inline sub-page (Quota + NavigationRow to Account, never navigateToProfile from the row); premium branch keeps Readest row; capability Tips both directions in webdav/gdrive sub-pages; FileSyncForm Upload Book Files relabel; SyncCategoriesSection 'Managed by {{provider}}' description swap (toggles stay live).
|
||||
|
||||
**REMAINING (user/ops):** push 2 branch stacks + open PRs (PR1.5 independent; PR2/3 stacked on PR1); dedicated /i18n pass for ~20 new strings (extraction pruning hazard — run /i18n which handles it); live verification per plan (real WebDAV 192.168.2.3:6065: exclusive mode e2e, syncBooks auto-enable, fleet banner, switch-back no-burst); TODOS.md follow-ups (Sentry Rust tag, quota error code, etc.). Discard uncommitted TODOS.md duplicate in main checkout.
|
||||
|
||||
**PR2 IMPLEMENTED (2026-07-06):** commit `95fd33f0a` on `feat/cloud-sync-exclusive-gating`, STACKED on PR1 in the same worktree (`/Users/chrox/dev/readest-fix-cloud-sync-quota-decouple`), 27 files +869/-99, LOCAL ONLY. Full suite 6923 pass + lint clean. Contents: syncCategories provider gate (book/progress/note, runtime override, user toggles persist); `persistActiveCloudProvider` single write path (chooser + both connect/disconnect flows + gdrive OAuth callback which had bypassed broadcast); minimal switch-only broadcast (`{enabled, providerSelectedAt}` — never credentials/cursors); **found+fixed PR1 integration bug: buildWebDAVConnectSettings pre-set `enabled:true` so fresh-connect never triggered the syncBooks auto-flip — builder is now activation-agnostic**; fileSyncStore `lastError` + `fleetNoticeShown`; `runActiveFileLibrarySync()` shared runner (menu tap + pull-to-refresh + BackupWindow all route via pullLibrary's provider branch — fixes "undefined book(s) synced"); SettingsMenu "Synced via {{provider}}" + quota caption + Auto-Upload hidden (also command palette `action.autoUpload` filtered, BookItem badge, TransferQueuePanel Upload All); mixed-fleet read-only probe (`pullChanges(providerSelectedAt,'books',...,1)` in useBooksSync's throttled interval, once-per-session toast); `providerSelectedAt` in both provider types + backup blacklist. Sentry cloudSyncProvider tag DEFERRED to TODOS (tagging is Rust-mediated via set_webview_info pattern — needs src-tauri command).
|
||||
|
||||
**PR1.5 IMPLEMENTED (2026-07-06):** commit `f19fc6fa1` on `feat/file-sync-metadata-parity` (worktree `/Users/chrox/dev/readest-feat-file-sync-metadata-parity`, branched off origin/main independent of PR1), 4 files +253/-26, LOCAL ONLY. Full suite 6869 pass + lint clean. KEY FINDING: library.json already serializes FULL Book objects — tags/readingStatus were on the wire all along; the drop was `mergeBookMetadata`'s overlay (same gap as #4942 groups) + the reconcile predicate not firing on status-only changes. Fix: tags join the metadata LWW subset (raw assignment, removals propagate); readingStatus merges on its own `readingStatusUpdatedAt` clock (client mirror of #4634); new `shouldApplyRemoteBookMetadata` predicate (either clock) replaces `isRemoteBookMetadataNewer` in the engine reconcile filter (the old predicate stays exported). NO wire changes needed. PR2 stacks on PR1 (needs cloudSyncProvider.ts) — merge PR1 first or stack branches.
|
||||
|
||||
**Key traps found in review:** `BACKUP_SETTINGS_BLACKLIST` does NOT exclude enabled flags/webdav.deviceId (plan text was wrong; PR1 adds deviceId/lastSyncedAt to blacklist); settings broadcast must carry ONLY `{enabled}` (password would leak; routine lastSyncedAt writes could revert a switch via slice LWW); Drive OAuth callback writes via appService.saveSettings bypassing broadcast → centralize `activateCloudProvider()`; `useTransferQueue()` DEFAULT params (`libraryLoaded=true`) in SettingsMenu/TransferQueuePanel are the real unguarded init path (barrier = `settings.version`); cancelled needs structured `cancelReason` + queue schemaVersion (`retryAllFailed` resurrects cancelled rows today; failed-includes-cancelled copy-pasted in 5 places); fileSyncStore is process-local — durable lastSyncedAt lives in provider settings.
|
||||
|
||||
See [[webdav-filesync-refactor-plan]] · [[gdrive-provider-multipr-status]].
|
||||
@@ -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,57 +0,0 @@
|
||||
---
|
||||
name: cross-page-selection-autoturn-4741
|
||||
description: Cross-page selection/highlight in paginated mode via extracted useAutoPageTurn; all four selection gestures drive the corner-dwell turn
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 33b70e98-fb55-467a-b03f-e4065491bc7e
|
||||
---
|
||||
|
||||
#4741: in paginated (non-scrolling) mode, extend a selection/highlight past the
|
||||
page edge by turning the page mid-gesture. Branch `feat/cross-page-highlight-autoturn`.
|
||||
|
||||
**Extracted `src/app/reader/hooks/useAutoPageTurn.ts`** from `useTextSelector` —
|
||||
the corner-dwell auto page-turn (#1354), now **decoupled from the DOM selection**
|
||||
so selection-less gestures can drive it. API: `notePoint`/`noteAutoTurnPoint`
|
||||
(window-coord engagement point), `cancel`, `isAutoTurning`, `onAfterTurn(cb)`
|
||||
(Set of subs), `cornerAtPoint`, `readingAreaRect`. Liveness at dwell fire-time is
|
||||
an injected predicate, not `doc.getSelection()`: `noteCorner(corner, isInCorner)`.
|
||||
`useTextSelector` keeps the dual-signal native liveness (`pointerCornerNow ||
|
||||
caretCornerNow`); point-only callers use `noteAutoTurnPoint` (last-point liveness).
|
||||
Pure exports `getReadingAreaRect`, `turnForFocusBeyondPage`, `keyboardTurnDirection`.
|
||||
|
||||
**Key trap:** the old `armDwell` required a valid DOM selection to turn. Instant
|
||||
Highlight (`user-select:none` + CFI overlay) and AnnotationRangeEditor (CFI
|
||||
overlay) have **no** DOM selection, so the machine refused to turn for them. The
|
||||
decoupling is what makes them work at all.
|
||||
|
||||
**Four gestures, all feeding the one machine** (`useTextSelector` re-exposes
|
||||
`noteAutoTurnPoint`/`cancelAutoTurn`/`onAutoTurn` to the editors via `Annotator`):
|
||||
1. Instant Highlight drag — `handlePointerMove`/`handleNativeTouchMove` feed the
|
||||
finger corner. `useInstantAnnotation` now **DOM-anchors the start** (`startPosRef`
|
||||
= `{node,offset}` at pointer-down; `buildRangeFromAnchor` builds anchor->end each
|
||||
move) so it survives the scroll; relaxed the pointer-up `distance<10` cancel with
|
||||
`&& !previewAnnotationRef.current`. See [[instant-highlight-tap-paginate]].
|
||||
2. `SelectionRangeEditor` handle drag — already DOM-anchored the fixed end; just
|
||||
feed `noteAutoTurnPoint(point)` + cancel + re-emit.
|
||||
3. `AnnotationRangeEditor` handle drag — `useAnnotationEditor` changed from
|
||||
`handleAnnotationRangeChange(startPt,endPt)` (`buildRangeFromPoints` resolved BOTH
|
||||
ends from window coords -> lost previous page) to `applyAnnotationRange(range,...)`;
|
||||
component anchors the non-dragged end (`fixedAnchorRef`) + builds via
|
||||
`rangeFromAnchorToPoint` like SelectionRangeEditor.
|
||||
4. `Shift+Arrow` keyboard adjust (#4728) — `useBookShortcuts.adjustTextSelection`,
|
||||
after `extendSelectionFromContents`, **immediate turn-on-cross** (no dwell):
|
||||
`keyboardTurnDirection(contents, getReadingAreaRect(...))` -> `view.next()/prev()`
|
||||
when the extended focus leaves the page. Desktop-only; gated `!scrolled`.
|
||||
|
||||
**After-turn re-emit:** active gesture subscribes `onAfterTurn` to rebuild its range
|
||||
from the held point onto the new page immediately (instant: `reapplyInstantAnnotation`;
|
||||
editors: `subscribeAutoTurnReemit` -> `updateFromDraggedPoint(lastPoint)`). Native
|
||||
selection does NOT subscribe (browser extends its own). The Android #873 scroll-pin
|
||||
(`selectionPosition`) is re-anchored after every turn via `onAfterTurn` in useTextSelector.
|
||||
|
||||
`focusCaretWindowPos` promoted `useTextSelector` -> `src/utils/sel.ts` (keyboard reuse).
|
||||
Scope: within-section column turns only (a Range can't span two iframe docs).
|
||||
Tests: `useAutoPageTurn.test.ts` (21), `useTextSelector-instantTurn.test.ts`,
|
||||
`useInstantAnnotation.test.ts`, `useAnnotationEditor.test.ts`; existing autoTurn/
|
||||
instantHold suites stay green (regression net for the extraction).
|
||||
@@ -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,14 +0,0 @@
|
||||
---
|
||||
name: customize-toolbar-eink-black-bar-4839
|
||||
description: Customize Toolbar preview rendered as a solid black bar in e-ink; preview surfaces copying bg-gray-600 need eink-bordered
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
#4839: the Customize Toolbar sub-page (`AnnotationToolbarCustomizer.tsx`) toolbar **preview** Zone copied the live popup's `selection-popup bg-gray-600 text-white` but rendered as an unreadable solid black bar under `[data-eink='true']`.
|
||||
|
||||
**Why:** the real reader popup earns its e-ink chrome from `.popup-container` (globals.css `[data-eink] .popup-container` → `bg base-100` + 1px `base-content` border). The preview Zone is a plain `<div>` with NO `popup-container`, so the dark `bg-gray-600` survived in e-ink; the base-content (inverted via `[data-eink] button`) chip icons then sat black-on-black.
|
||||
|
||||
**How to apply:** any e-ink "preview" surface that mimics the live popup must scope the dark fill to non-e-ink (`not-eink:bg-gray-600 not-eink:text-white`) and add `eink-bordered` so e-ink renders it as `bg-base-100` + 1px `base-content` border (don't just rely on `eink-bordered`'s `!important` to override the gray — drop the gray in e-ink outright). Also fix copied white hint text (`text-white/70` → `not-eink:text-white/70 eink:text-base-content`) since the surface turns base-100. Chip icons need no change — they are `<button>`s, already inverted to base-content by the global `[data-eink] button` rule. Guard: render test asserts `.selection-popup` element carries `eink-bordered`. Verify rendered colors via `getComputedStyle` under `[data-eink]` (set `data-theme='default-light'` first or theme vars are unresolved → transparent); note daisyUI returns **oklch** not rgb — e-ink correct = bg `oklch(1 0 0)`, border/icon `oklch(0.2 0 0)`. PR #4841.
|
||||
|
||||
Same feature as [[customize-toolbar-global-serializeconfig]]; e-ink conventions in [[feedback_design_system_doc]].
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
name: customize-toolbar-global-serializeconfig
|
||||
description: Customize Toolbar applied per-book not global; root cause = serializeConfig compared viewSettings by reference (!==) so array values were always stored as stale per-book overrides
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: c6601464-9463-4ac3-99c0-e7527e4051b5
|
||||
---
|
||||
|
||||
Customize Toolbar (annotation bar, #4014, shipped v0.11.12) changes only applied
|
||||
to the book where edited, not globally. Fixed in PR #4760 (MERGED, squashed onto
|
||||
main as 7da5f8321).
|
||||
|
||||
**Root cause:** `serializeConfig` (`src/utils/serializer.ts`) decides which per-book
|
||||
viewSettings to persist as overrides via `globalViewSettings[key] !== value` — a
|
||||
*reference* compare. It deep-clones the config first (`JSON.parse(JSON.stringify)`),
|
||||
so any **array/object** viewSettings value (`annotationToolbarItems`, and latently
|
||||
`paragraphMode`, `proofreadRules`, `ttsHighlightOptions`, `noteExportConfig`) is a
|
||||
fresh reference ≠ global → stored as a per-book override on **every** save (progress
|
||||
autosave serializes with settings each relocate). On reopen the merge
|
||||
`{ ...globalViewSettings, ...perBookOverrides }` lets the stale override shadow
|
||||
global → a global toolbar change never reaches already-saved books.
|
||||
|
||||
**Fix (final — minimal, general, no special-casing):** compare viewSettings values
|
||||
by content, not reference. Added `isSameViewSettingValue(a,b) = a===b ||
|
||||
JSON.stringify(a)===JSON.stringify(b)`, used in the viewSettings reduce ONLY
|
||||
(searchConfig left on `!==` — it holds functions / large `results`). The field
|
||||
stays `annotationToolbarItems` in `AnnotatorConfig` (normal per-book viewSettings,
|
||||
honors the isGlobal "Apply to This Book" toggle). PR diff is just serializer.ts +
|
||||
serializer.test.ts.
|
||||
|
||||
**Iteration history (user steered):** (1) a `GLOBAL_ONLY_VIEW_SETTINGS` exception
|
||||
forcing global save + strip/ignore per-book — rejected "don't make it an exception";
|
||||
(2) move field to `SystemSettings.globalReadSettings` — rejected "too much";
|
||||
(3) rename `annotationToolbarItems`→`annotationToolbar` for a clean slate — rejected,
|
||||
keep the original name (it's synced in globalViewSettings). Landing point: keep the
|
||||
name, fix only the serializer reference-compare bug.
|
||||
|
||||
**Known limitation (no rename clean-slate):** existing books may carry a per-book
|
||||
`annotationToolbarItems` override from the buggy v0.11.12 build. The value compare
|
||||
stops new ones and drops an existing one on next save when it matches global, but
|
||||
does NOT retroactively clear an override whose content differs from current global —
|
||||
those books keep the stale toolbar until re-saved while equal to global. A follow-up
|
||||
one-time migration (clear persisted per-book toolbar overrides) would close this if
|
||||
needed.
|
||||
|
||||
Tests: `src/__tests__/utils/serializer.test.ts` — array setting equal to global is
|
||||
not persisted; differing array still persisted.
|
||||
@@ -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,45 +0,0 @@
|
||||
---
|
||||
name: dict-popup-font-size-4443
|
||||
description: Adjustable dictionary popup font size via ::part() + em-rebasing; the only cross-shadow font hook for MDict
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: b105ba93-61b7-4d28-a269-1201a7be89bd
|
||||
---
|
||||
|
||||
#4443 — adjustable dictionary popup font size (independent of the reading view).
|
||||
SHIPPED: merged to main via PR #4734.
|
||||
|
||||
**The lever** = `DictionarySettings.fontScale` (number, default 1), set in
|
||||
Settings → Language → Dictionaries (`SettingsSelect`, 85–175%). Stored in the
|
||||
dictionary settings; SYNCED by adding `dictionarySettings.fontScale` to
|
||||
`SETTINGS_WHITELIST` (whole-field LWW, like providerOrder). `setFontScale` in
|
||||
`customDictionaryStore` + default-merge in `loadCustomDictionaries`
|
||||
(`?? DEFAULT_DICTIONARY_SETTINGS.fontScale`).
|
||||
|
||||
**Plumbing**: `useDictionaryResults` returns `fontScale`; `DictionaryResultsBody`
|
||||
puts `data-dict-content` + inline `--dict-font-scale` on each per-tab container
|
||||
(the `setContainerRef` div). All CSS lives in `globals.css`.
|
||||
|
||||
**Two non-obvious CSS facts that drove the design:**
|
||||
1. **MDict renders into a Shadow DOM** (`shadowHost.attachShadow`, the only
|
||||
provider that does) → its body is unreachable by ordinary popup CSS.
|
||||
`::part(dict-content)` is the ONLY hook. So `mdictProvider` sets
|
||||
`body.setAttribute('part','dict-content')` AND adds a stable host class
|
||||
`dict-shadow-host` (the `::part()` rule needs a host selector subject).
|
||||
`--dict-font-scale` inherits across the shadow boundary, so the outer rule
|
||||
`…::part(dict-content){font-size: calc(var(--dict-font-scale,1) * 0.875rem)}`
|
||||
resolves it. The dict's own shadow CSS never targets our wrapper `<div>`, so
|
||||
no cascade fight — em-based dict content scales from it, px-based stays fixed
|
||||
(expected for a font-size lever).
|
||||
2. **Light-DOM providers size text with Tailwind `text-*` = root-relative `rem`**,
|
||||
which a container `font-size` can't move. Fix = re-base the utilities to `em`
|
||||
WITHIN `[data-dict-content]` only: `[data-dict-content] .text-sm{font-size:.875em}`
|
||||
etc. Higher specificity than the bare utility + declared after `@tailwind
|
||||
utilities` → wins, no `!important`. Container itself = `calc(scale * 1em)`.
|
||||
|
||||
**Verify**: the CSS contract (em-rebasing + `::part` + var inheritance) needs a
|
||||
real browser — jsdom has no layout. Covered by
|
||||
`dict-popup-font-size.browser.test.ts` (scale 1 → 18/14/14px, scale 1.5 →
|
||||
27/21/21px, incl. the shadow body). Provider/store/whitelist sides have jsdom
|
||||
unit tests. See [[css-style-fixes]].
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
name: dict-popup-tts-speak-4876
|
||||
description: "Dictionary popup speaker button pronounces the headword via Edge TTS (#4876), with a standalone pronouncer that bypasses TTSController"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 98d0ef1c-84c2-4a16-85a0-0abad0010923
|
||||
---
|
||||
|
||||
Issue #4876: add a "speak" button to the dictionary popup so a looked-up word
|
||||
can be pronounced. Implemented on branch `feat/dict-popup-tts` (commit
|
||||
f2acafb4b, 2026-07-06). Button-only (no auto-speak); speaker icon sits inline
|
||||
left of the headword in the shared `DictionaryResultsHeader`, so it covers both
|
||||
the desktop `DictionaryPopup` and mobile `DictionarySheet`.
|
||||
|
||||
Key file: `src/services/tts/wordPronouncer.ts` — a standalone single-word
|
||||
pronouncer, deliberately independent of the reader's `TTSController`:
|
||||
- **Speak ASAP**: never calls `EdgeTTSClient.init()` (which wastes a round trip
|
||||
synthesizing "test"). Calls `EdgeSpeechTTS.createAudioData()` directly; its
|
||||
static LRU MP3 cache makes repeat words instant.
|
||||
- **Dedicated Web Audio context** (`new WebAudioPlayer(() => new AudioContext())`,
|
||||
NOT the module-shared context the reader uses) so pronouncing a word can never
|
||||
resume/suspend or overlap an active read-aloud session. One extra AudioContext,
|
||||
fine under WebKit's ~4 cap.
|
||||
- **Gesture warmup**: `warmWordAudio()` must be called synchronously in the click
|
||||
handler (the hook's `speakWord` does this) because `pronounceWord` resumes the
|
||||
context only after a network await, outside WebKit's autoplay gesture window.
|
||||
- **Engine order**: Edge wss -> Edge https proxy (`fetchWithAuth`, throws "Not
|
||||
authenticated" when logged out) -> platform fallback. Fallback reuses the
|
||||
existing `WebSpeechClient` (desktop/web) / `NativeTTSClient` (mobile app)
|
||||
standalone via `genSSMLRaw(word)` + `setPrimaryLang(lang)`; the SSML default
|
||||
`xml:lang="en"` is overridden by `parseSSMLMarks(ssml, primaryLang)`.
|
||||
- `requestToken` guards staleness so a superseded in-flight synth bails.
|
||||
|
||||
Hook: `useDictionaryResults` gained `isSpeaking` + `speakWord`; cancels on word
|
||||
change / unmount. Voice pick = `TTSUtils.getPreferredVoice('edge-tts', lang)`
|
||||
then first `isSameLang` match then `en-US-AriaNeural`.
|
||||
|
||||
Tests: `src/__tests__/services/tts/wordPronouncer.test.ts` (Edge-first / fallback
|
||||
contract; jsdom has no AudioContext so `getPlayer()` returns null unless
|
||||
`globalThis.AudioContext` is stubbed + `WebAudioPlayer` mocked). Speak-button
|
||||
wiring test added to `DictionarySheet.test.tsx` (mocks the pronouncer module).
|
||||
|
||||
NOT verified live: real audio playback + iOS gesture warmup (not unit-testable).
|
||||
Related: [[edge-tts-webaudio-engine]] (the WebAudio refactor that replaced the old
|
||||
blob-URL `createAudio` with `createAudioData`), [[ios-instant-dict-double-popup]].
|
||||
@@ -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,26 +0,0 @@
|
||||
---
|
||||
name: edge-tts-webaudio-engine
|
||||
description: "Edge TTS Web Audio refactor (#3851/#2033) — gapless engine, WSOLA rate, section timeline + scrubber; branch feat/edge-tts-webaudio; release gates and design invariants"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 97e57af9-5961-4c92-a63e-4582178bf798
|
||||
---
|
||||
|
||||
Branch `feat/edge-tts-webaudio` (worktree `/Users/chrox/dev/readest-feat-edge-tts-webaudio`, built 2026-07-04, NOT pushed) replaces Edge TTS per-sentence `<audio>` playback with a Web Audio pipeline: fetch MP3 at rate 1.0 (unchanged LRU + new in-flight dedup in `edgeTTS.ts`) → decode → `pcm.ts` silence trim → `timeStretch.ts` in-house WSOLA (pitch-preserved client rate, cache never refetches on rate change) → `WebAudioPlayer.ts` gapless scheduling. `SectionTimeline.ts` (measured > per-voice cps EMA in localStorage `readest-tts-voice-cps` > script defaults) powers a TTSPanel scrubber + media-session position/seekto. foliate-js fork branch `feat/tts-get-sentences` adds `getSentences` — fork PR must merge BEFORE the readest PR (submodule pin).
|
||||
|
||||
**Why:** #3851 first-word clipping cause is a HYPOTHESIS (Android reporter reproduced with BT off); treat as falsifiable experiment. #2033 gaps = element restarts + ~300ms Edge trailing silence.
|
||||
|
||||
**Load-bearing invariants (don't regress):**
|
||||
- AudioContext is a module-level singleton, never closed — a fresh TTSController per tts-speak calls `stop()` not `shutdown()`, and WebKit caps ~4 live contexts (leak = permanent silence).
|
||||
- Marks dispatch at AUDIBLE time (player chunk-start via onended, background-safe), never at fetch — else foliate's `#lastMark` runs ahead and prev/next/resume break.
|
||||
- `endSession` fires session-end synchronously when nothing is unfinished — zero-chunk sessions (Edge outage) must not wedge controls in "playing".
|
||||
- `ensureSharedAudioContext()` is called in the tts-speak gesture path BEFORE any await (WebKit autoplay window); `unblockAudio` silent element runs on ALL platforms (desktop Chromium media keys need a playing HTMLMediaElement).
|
||||
- `abortSession` never suspends the context (warm output stream IS the #3851 fix); only user pause suspends.
|
||||
- Word boundaries stay in original untrimmed media time; `getChunkPosition()` returns trim-relative clamped seconds; timeline sums TRIMMED durations.
|
||||
- Inter-sentence CLICKS/POPS = the silence trim (`findSpeechBounds`) cuts at an amplitude threshold (0.005), NOT a zero crossing, so each buffer edge is a non-zero sample; the source steps to/from silence → click. NOT WSOLA (no-op at rate 1.0, cross-fades internal splices). Fix (commit a8643ec12, branch fix/android-bg-tts-media-session): `applyEdgeFade` in `pcm.ts` ramps ~3ms at both ends of the buffer's OWN copy (`buffer.getChannelData(0)` after `createMonoBuffer`) — never the `trimmed` subarray view (rate 1.0 aliases the decoded buffer). Trim + gap kept. Removing the trim instead would work (WSOLA DOES scale silence, so gaps stay rate-scaled) but doubles each gap to Edge's tail+lead silence.
|
||||
- `POPUP_HEIGHT` in TTSControl.tsx is fixed and non-scrolling — grows to 200 only when a timeline-capable client is active.
|
||||
|
||||
**Follow-up decided (2026-07-04, not yet planned): background TTS decoupling.** App-level TTSSessionManager owns the controller; reader hook becomes attach/detach. Matrix chrox chose: close book = keep playing (headless via `section.createDocument()`); reopen SAME book = seamless reattach (adopt session + `redispatchPosition()` + CFI re-anchoring — the highlighter already re-anchors ranges through CFIs, so cross-doc ranges are safe; swap text supply to rendered doc lazily at next section boundary); open a DIFFERENT book = TTS STOPS (not "keeps playing while browsing"); explicit stop / sleep timer = stops. Fiddly bit: `getCFI` without a rendered view. Recorded in branch TODOS.md.
|
||||
|
||||
**How to apply:** Release gates before closing the issues (in plan Verification): WSOLA A/B listening test 0.2x-3x EN+CJK, Linux WebKitGTK decode (GStreamer), reporter-hardware beta (Soundcore Q20i iOS / Galaxy S22U screen-off), iOS lock-screen + interruption QA, e-ink `[data-eink] .range` fill check (NO eink range rule exists in globals.css), RTL slider direction. Plan + 35-decision audit trail: worktree `.agents/plans/2026-07-03-edge-tts-webaudio.md` (gitignored, local). i18n keys added ('This chapter', 'Chapter progress', 'Failed to seek', '{{elapsed}} of {{total}}') need the /i18n pass. Deferred follow-ups in TODOS.md incl. provider-agnostic local-TTS hedge ([[grimmory-native-sync]] unrelated).
|
||||
@@ -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,30 +0,0 @@
|
||||
---
|
||||
name: eink-screen-refresh-pageturner-4687
|
||||
description: "Page-turner \"Refresh Page\" action that deep-refreshes the e-ink panel (clear ghosting) on Android, via generic reflection across BOOX/Tolino/Rockchip"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 742b1517-392b-4735-8355-32b57fbfa400
|
||||
---
|
||||
|
||||
Issue #4687 — added a bindable **"Refresh Page"** page-turner action that triggers a deep e-ink full refresh (GC16) to clear ghosting. Shipped as **PR #4822 (MERGED)** (`feat/eink-screen-refresh-pageturner` → main, 55 files +470/-41), built in an isolated worktree off origin/main (worktree + branch since removed). Rebase note: origin/main's Drive-sync PR #4821 added `secure_item` native-bridge commands at the exact anchors I used (end of COMMANDS / handler list / structs / impls), so all 7 plugin files (build.rs, default.toml, commands.rs, desktop.rs, lib.rs, mobile.rs, models.rs) conflicted on apply — resolved "keep both" by re-adding `refresh_eink_screen` after the secure_item code; locales re-derived via script on main's current files; autogenerated permission files regenerated via `cargo check -p tauri-plugin-native-bridge`.
|
||||
|
||||
**Frontend** (reuses the existing hardware page-turner binding machinery — see [[keyboard-selection-adjust-4728]] / `src/utils/keybinding.ts`):
|
||||
- `keybinding.ts`: `'refresh'` added to `PageTurnAction` + `PAGE_TURN_ACTIONS` (so `resolvePageTurn` matches it). `matchesBinding` now accepts `undefined`.
|
||||
- `types/settings.ts`: `HardwarePageTurnerSettings.bindings.refresh?: KeyBinding | null` — OPTIONAL (older persisted settings lack it; never migrate, optional-chaining handles absence). Default `refresh: null` in `constants.ts`.
|
||||
- `PageTurnerSettings.tsx`: refresh slot rendered ONLY when `appService?.isAndroidApp && viewSettings.isEink` (the user-facing Eink-mode view setting, not just hardware detection).
|
||||
- `usePagination.ts` `handleHardwarePageTurn`: branch `if (action === 'refresh') { if (appService?.isAndroidApp) refreshEinkScreen().catch(()=>{}); return true; }` BEFORE the page/section side/mode logic. Also added `bindings.refresh?.source === 'native'` to `hasNativeBinding` + the effect dep array so a media key bound to refresh still acquires page-turner key interception.
|
||||
- `bridge.ts`: `refreshEinkScreen()` → `invoke('plugin:native-bridge|refresh_eink_screen')`.
|
||||
|
||||
**Native generic refresh** (`EinkRefreshController.kt`, new) — the answer to "compatible with most e-ink devices, generic interface not brand SDK". Android has NO public e-ink API; each vendor patches `android.view.View`. Probe via reflection, stop at first success (patterns from KOReader android-luajit-launcher EPD controllers):
|
||||
1. **Onyx BOOX (Qualcomm)**: `View.refreshScreen(0,0,w,h, 34)` instance method. `34 = FULL(32)+GC16(2)`.
|
||||
2. **Tolino/Nook (NTX/Freescale)**: `View.postInvalidateDelayed(0L,0,0,w,h, 34)`.
|
||||
3. **Rockchip (Boyue clones)**: `View.requestEpdMode(View$EINK_MODE.EPD_FULL, true)`.
|
||||
|
||||
Deliberately do NOT bundle the Onyx SDK (`com.onyx.android.sdk.*` classes aren't on-device unless bundled — reflection would always fail) and do NOT call Onyx `setWaveformAndScheme`/None (KOReader does, but it owns the update loop; Readest leaves system auto-update in place, so switching to manual mode could FREEZE later updates). Run on UI thread against `activity.window.decorView`; `success:false` (no controller) is a soft no-op, not an error. iOS Swift stub resolves `{success:false}`.
|
||||
|
||||
**Plugin wiring** added across `models.rs`/`commands.rs`/`mobile.rs`/`desktop.rs`/`lib.rs` + `build.rs` COMMANDS + `permissions/default.toml` `allow-refresh-eink-screen` (build regenerates `reference.md`/`schema.json`/`commands/refresh_eink_screen.toml`). App uses `native-bridge:default` so no capability edit needed.
|
||||
|
||||
**Verified on real hardware**: ONYX BOOX Leaf5 (`ro.product.manufacturer=ONYX`). `pnpm dev-android` build+install; via adb+CDP invoked `plugin:native-bridge|refresh_eink_screen` directly in the WebView → `{success:true}`, logcat `EinkRefresh: onyx full refresh requested` (the Onyx/Qualcomm `View.refreshScreen` path, decor view), and the user visually confirmed 5/5 full GC16 screen flashes in the reader. So the onyx path works on modern BOOX without SDK bundling or `setWaveformAndScheme` priming. (CDP socket is pid-bound `webview_devtools_remote_<pid>`; re-forward when the WebView process recycles — see [[cdp-android-webview-profiling]].)
|
||||
|
||||
**i18n**: ran into [[i18n-extract-prunes-keys]] (scanner `removeUnusedKeys:true` deleted ~314 dynamic keys / huge churn). REVERTED the scanner output and added the single `"Refresh Page"` key MANUALLY to all 33 non-en locales (en is key-as-content, needs no entry), aligning each translation with the locale's existing `"Reload Page"`/`"Next Page"` terminology. `check:translations` green.
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
name: empty-highlight-leak-on-annotate-cancel-4791
|
||||
description: Annotate eagerly creates a highlight placeholder; cancelling the note must tear it down
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 1c75c865-8e1b-4641-ac20-81692d3ff20b
|
||||
---
|
||||
|
||||
#4791 — clicking **Annotate** on a selection eagerly creates a highlight (`note:''`)
|
||||
as the note anchor (`handleAnnotate` → `handleHighlight(true)` in `Annotator.tsx`),
|
||||
so the selection stays visible while the NoteEditor is open. Cancelling the note
|
||||
(Cancel button, overlay, Escape, switching books, closing the notebook) left that
|
||||
empty highlight leaked into config → showed as a stale card in the left-sidebar
|
||||
Booknotes list + a phantom yellow highlight.
|
||||
|
||||
**Fix:**
|
||||
- `handleHighlight` now returns the created `BookNote` only when it pushes a NEW
|
||||
record (returns `null` when it restyles/toggles an EXISTING one — that record
|
||||
predates the flow and must survive a cancel).
|
||||
- `handleAnnotate` stores `created?.id` via `setNotebookNewHighlightId` (new
|
||||
`notebookStore` field). This tracked id is what distinguishes a removable
|
||||
placeholder from a pre-existing highlight; do NOT identify it by cfi (a fresh
|
||||
selection can collide with an existing highlight's cfi).
|
||||
- `removeEmptyAnnotationPlaceholder(booknotes, id, now)` in `annotatorUtil.ts`
|
||||
tombstones (`deletedAt`) the live annotation with that id ONLY if it still has
|
||||
no note text, and returns it so the caller tears the overlay down with
|
||||
`removeBookNoteOverlays` across ALL views (`getViewsById`, symmetric with how
|
||||
`handleHighlight` drew it).
|
||||
- Cleanup is **presentation-driven**, not threaded through every cancel path:
|
||||
`Notebook.tsx` runs `handleCancelNewAnnotation` from an effect whenever the
|
||||
creation editor stops being presented (`!(isNotebookVisible && notebookNewAnnotation)`)
|
||||
— catches Cancel/Escape/overlay/close/swipe/navigate — plus a second effect's
|
||||
cleanup on `sideBarBookKey` change / unmount for book-switch (pinned) and
|
||||
reader-close.
|
||||
- Save survives the guard (placeholder gains note text) and also clears the
|
||||
tracked id. `handleCancelNewAnnotation` has stable identity (empty deps) so the
|
||||
effects don't re-fire mid-edit; it reads settings fresh via
|
||||
`useSettingsStore.getState().settings` (stale-closure guard, see [[webdav-connect-nullified-4780]]).
|
||||
|
||||
**Why id-set-LAST in handleAnnotate matters:** `setNotebookNewHighlightId` is
|
||||
called after `setNotebookVisible(true)` + `setNotebookNewAnnotation`, so no
|
||||
intermediate render has (editing=false AND a fresh placeholder id) — prevents the
|
||||
presentation effect from deleting the placeholder it just created.
|
||||
|
||||
Related: [[instant-highlight-delete-orphan-4773]], [[customize-toolbar-global-serializeconfig]].
|
||||
@@ -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,18 +0,0 @@
|
||||
---
|
||||
name: fastlane-apple-appstore-submission
|
||||
description: "fastlane lanes for iOS/macOS App Store + TestFlight submission, and two gotchas (Tauri notarization trigger, fastlane cwd)"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 6604c57a-dee4-4a6e-8624-540162f41a80
|
||||
---
|
||||
|
||||
Readest's Apple App Store + TestFlight submission via fastlane (root `fastlane/Fastfile`, alongside the existing Android `upload_to_play_store` lanes). Builds are unchanged (`pnpm run release-ios-appstore` / `release-macos-universial-appstore` → `tauri build` + `xcrun altool --upload-app`); fastlane only does the post-upload App Store version + review submission and TestFlight distribution on the already-uploaded build.
|
||||
|
||||
Lanes (per-platform, each does App Store review submit AND TestFlight, sharing a `submit_apple_build` helper): `release_ios`, `release_macos`. App Store via `upload_to_app_store(skip_binary_upload: true, ipa:/pkg:, platform: "ios"/"osx", submit_for_review: true, automatic_release: true, force: true, skip_screenshots: true, skip_metadata: false, release_notes:{"en-US"=>...}, promotional_text:{"en-US"=>...})`; TestFlight via `upload_to_testflight(distribute_only: true, app_platform: "ios"/"osx", distribute_external: true, groups:["Beta Testers"])`. App Store submit runs FIRST (it waits for build processing, which the TestFlight distribute then needs). `release_notes_text` parses `apps/readest-app/release-notes.json` (latest version by `Gem::Version`, drops notes matching `/\b(?:Android|Windows|Linux)\b/i`, prefixes each `– `). Auth: `app_store_connect_api_key`. Commands: `pnpm run submit-appstore-ios` / `submit-appstore-macos`.
|
||||
|
||||
GOTCHA 1 (Tauri notarization): `tauri build` auto-notarizes the macOS App Store bundle whenever the FULL App Store Connect API key trio (`APPLE_API_KEY` + `APPLE_API_ISSUER` + `APPLE_API_KEY_PATH`) is in the build env. Notarization REJECTS App Store builds ("not signed with a valid Developer ID certificate" / "no secure timestamp") because they use an Apple Distribution cert — App Store apps are NOT notarized. So `APPLE_API_KEY_PATH` must stay OUT of `.env.apple-appstore.local` (the macOS build env). `asc_api_key` instead DERIVES the `.p8` path from the key id: `repo_path("apps/readest-app/private_keys/AuthKey_#{key_id}.p8")` (the keys are named `AuthKey_<KEYID>.p8`, same convention altool uses; honors an explicit `APPLE_API_KEY_PATH` when set, e.g. the iOS build env which DOES need it and iOS doesn't notarize).
|
||||
|
||||
GOTCHA 2 (fastlane cwd): fastlane changes cwd to the `./fastlane` folder when EXECUTING a lane (`__dir__` is just "."), so raw `File.read("./apps/...")` breaks with "No such file". `fastlane lanes` only PARSES (doesn't run lane bodies) so it won't catch this — verify path-dependent lanes by actually RUNNING one. Fix = `repo_path(rel) = File.expand_path(rel, File.expand_path("..", __dir__))`, route every path (release-notes.json, .p8, ipa, pkg) through it.
|
||||
|
||||
GOTCHA 3 (dotenv shadowing): bare `dotenv` on PATH is the Ruby gem (`-f` syntax); package.json scripts use the npm `dotenv-cli` (`-e` syntax) resolved from `apps/readest-app/node_modules/.bin`. The submit scripts run `dotenv -e .env.apple-appstore.local -- bash -c 'cd ../.. && fastlane release_*'` — the `cd ../..` is required because fastlane does NOT search upward for the `fastlane/` dir (pnpm runs scripts from `apps/readest-app`).
|
||||
@@ -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,40 +0,0 @@
|
||||
---
|
||||
name: fixed-layout-paginated-scroll-reset-4683
|
||||
description: "Fit-width tall fixed-layout page opens scrolled-to-end on WebKit page turn (#4683); Blink unaffected; fix = explicit scrollTop=0 on page-turn render"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 780a4235-5498-42c8-8286-7021c6fcf1ed
|
||||
---
|
||||
|
||||
#4683: in paginated fixed-layout (PDF / fixed-layout EPUB) **fit-width** mode, when a
|
||||
page is scaled taller than the viewport (`isOverflowY` true, host gets a vertical
|
||||
scrollbar), turning to the next page opened the new page **scrolled to the bottom**
|
||||
instead of the top. Root cause: `FixedLayout` host (`:host{overflow:auto;align-items:center}`
|
||||
in `packages/foliate-js/fixed-layout.js`) scrolls vertically; `#render`'s `transform`
|
||||
re-centered `container.scrollLeft` on every render but **never reset `container.scrollTop`**.
|
||||
On a page turn the freshly-shown page inherited the previous page's offset (≈ bottom, since
|
||||
the reader scrolled down to finish, and same-size pages share maxScrollTop).
|
||||
|
||||
**Engine-specific — WebKit only.** WebKit (Linux WebKitGTK, iOS, macOS WKWebView)
|
||||
*preserves* a scroll container's offset when `#showSpread` swaps the flow content
|
||||
(old frame → `position:absolute;visibility:hidden`, new frame appended). **Blink**
|
||||
(Android WebView, Chrome, WebView2) *resets* scrollTop to 0 on that swap, so the bug
|
||||
never manifests there. Reporter was on Ubuntu/WebKitGTK `WebView 605.1.15`.
|
||||
|
||||
**Fix:** new exported pure helper `computePaginatedScroll({elementWidth,containerWidth,scrollTop,pageTurn})`
|
||||
→ `{scrollLeft:(elementWidth-containerWidth)/2, scrollTop: pageTurn?0:scrollTop}`.
|
||||
Thread a `pageTurn` flag into `#render(side, pageTurn=false)`; set `true` ONLY at the
|
||||
3 navigation entry points (`#showSpread`, `#goLeft`, `#goRight`). Plain re-renders
|
||||
(ResizeObserver, zoom/scale-factor attr, pageColors, goToSpread same-index re-render)
|
||||
keep `pageTurn=false` so resize/pinch-zoom of a tall page does NOT jar to the top.
|
||||
Test: `src/__tests__/document/fixed-layout-paginated-scroll.test.ts` (pure-helper pattern,
|
||||
like [[booknote-view-autoscroll-4352]] sibling fixed-layout helper tests — the custom
|
||||
element can't be instantiated in jsdom: no ResizeObserver + getBoundingClientRect=0).
|
||||
|
||||
**Verification recipe (the bug is NOT Android-reproducible):** CDP on Xiaomi showed
|
||||
`view.next()` already yields scrollTop 0 on Blink → can't distinguish fix on Android.
|
||||
Proved on REAL WebKit instead: auto-running HTML mirroring host CSS + `#showSpread` swap,
|
||||
opened via `open -a Safari file://…`, screenshot. Safari `AppleWebKit/605.1.15` (== reporter)
|
||||
showed scrollTop 420/440 (bug) without reset, 0 with reset. readest fixed-layout page turn
|
||||
goes through `view.next()`/`view.prev()` (`usePagination.ts`), the same path.
|
||||
@@ -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,37 +0,0 @@
|
||||
---
|
||||
name: fxl-portrait-autospread-offcenter-4984
|
||||
description: PDF/FXL auto-spread in portrait rendered the lone page off-center and made taps turn pages
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: f24a5890-de13-4767-bb33-97621f332e44
|
||||
---
|
||||
|
||||
Issue #4984: in fixed-layout (PDF) `spread='auto'` + portrait viewport, the page
|
||||
was shoved into one half of the screen ("weirdly separate") and almost every tap
|
||||
turned the page instead of opening the menu.
|
||||
|
||||
Root cause (verified in Chrome): `FixedLayout.#render` in
|
||||
`packages/foliate-js/fixed-layout.js` already hides the non-target page in
|
||||
portrait (`if (portrait && frame !== target) display:none`) and scales the shown
|
||||
page as a single page, BUT it kept the spread-centering one-sided inline margin —
|
||||
left page `marginInlineStart:auto`, right page `marginInlineEnd:auto`. With no
|
||||
partner page to meet at the spine, that auto margin stranded the lone page in one
|
||||
half of the viewport whenever it was narrower than the viewport (any zoom < 100%,
|
||||
e.g. the issue's 50% zoom; or a page whose fit-scaled width < viewport width).
|
||||
The off-center page then sat over a page-turn tap zone (tap zones are
|
||||
view-relative: center 0.375-0.625 = menu, else turn — see `usePagination.ts`), so
|
||||
taps turned the page. Symptom 2 was a consequence of symptom 1.
|
||||
|
||||
Fix MERGED (readest PR#4992 + foliate-js PR#50 squash -> foliate main f6dced2, readest submodule bumped to it): added pure `computeSpreadInlineMargins(portrait)`; in portrait
|
||||
both inline margins are `auto` (centered), in landscape one-sided (pages meet at
|
||||
spine). It sets BOTH margins explicitly (opposite side cleared to '') because
|
||||
frames are re-styled in place on rotation (ResizeObserver -> `#render`, no
|
||||
`#respread`), so a stale `auto` would otherwise linger. NOT fixed by forcing
|
||||
`spread='none'` in portrait — that duplicates the existing portrait-single-page
|
||||
path, needs app-layer orientation swapping + `#respread` (cache clear + re-nav),
|
||||
and overrides the user's chosen setting.
|
||||
|
||||
Test: `src/__tests__/document/fixed-layout-portrait-single-page.test.ts`. Related:
|
||||
[[fxl-spread-spine-seam-4857]] shares this render branch;
|
||||
[[pdf-text-selection-fontscale-4480]].
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user