Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/app.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: App

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

jobs:
checks:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Install check dependencies
run: brew install ripgrep
- name: Run standard checks
run: ./scripts/check.sh --no-build
- name: Enforce UI theme coverage
run: ./scripts/check_ui_theme.sh --warnings-as-errors

architecture-build:
name: architecture-build (${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
runner: macos-15
- arch: x86_64
runner: macos-15-intel
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- name: Install Sparkle
run: brew install --cask sparkle
- name: Locate Sparkle
run: |
sparkle_root="$(find "$(brew --prefix)/Caskroom/sparkle" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1)"
test -d "$sparkle_root/Sparkle.framework"
echo "SPARKLE_HOME=$sparkle_root" >> "$GITHUB_ENV"
- name: Build native debug app
run: REQUIRE_BUNDLED_SPEECH_RUNTIMES=0 ./scripts/build_app.sh --debug --archs "${{ matrix.arch }}"

universal-build:
if: always()
needs: architecture-build
runs-on: ubuntu-latest
steps:
- name: Require both architecture builds
env:
ARCHITECTURE_BUILD_RESULT: ${{ needs.architecture-build.result }}
run: test "$ARCHITECTURE_BUILD_RESULT" = "success"
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ These instructions apply to the entire repository.

- All new user-visible text must provide Chinese and English variants through `AppText.localized(_:_:)` or an existing `AppText` property.
- New controls must work in all reader themes: `original`, `eyeCare`, and `dark`.
- Icon-only controls must use an SF Symbol with an accessibility description and theme-aware tinting.
- Icon-only controls must use an SF Symbol with an accessibility description, an explicit control accessibility label, and theme-aware tinting.
- Dynamically created controls must apply the current theme at creation and participate in the owning surface's later theme refresh path.
- Preserve keyboard navigation, menu shortcuts, first-responder behavior, and native PDFKit/WebKit scrolling behavior.
- Keep expensive parsing, database work, network requests, model loading, and process execution off the main UI path.
Expand All @@ -48,12 +48,14 @@ These instructions apply to the entire repository.
- Use prepared statements and bindings for values. Do not interpolate user-controlled values into SQL.
- Treat a multi-statement write as atomic: check `BEGIN`, every statement, and `COMMIT`; roll back and report failure if any step fails. Never return success before a successful commit.
- Add failure-path coverage for destructive replacement writes, including statement and commit failures, and verify that existing records remain intact.
- Encode required structured values before mutating memory or stepping a persistence statement. If encoding fails, report failure and preserve the previous record; do not substitute an empty object or partial value.
- Never delete or reinterpret existing user records without an explicit migration policy and regression coverage.

## Security and Untrusted Input

- Store API keys, tokens, and other secrets in macOS Keychain. Do not derive encryption keys from predictable app, user, or filesystem metadata, and do not keep recoverable secrets in `UserDefaults` or ordinary files.
- Secret migrations must write and verify the Keychain item before deleting legacy data. Never log secrets, authorization headers, complete request payloads, or user document text.
- Do not make startup connectivity probes to unrelated third-party hosts. Use system path status for coarse UI state and the real target request error for fallback decisions.
- Treat EPUB, DOCX, model/runtime archives, manifests, HTML, and linked resources as untrusted input.
- Before extracting an archive, reject absolute paths, parent traversal, escaping symlinks, excessive entry counts, excessive expanded size, and unsafe compression ratios. Verify every resolved extracted path remains inside the owned destination and clean up partial output on failure.
- Runtime/model installation must fail closed unless a trusted manifest provides the expected asset, byte size, and non-empty checksum. Validate the archive before extracting or executing any installed file.
Expand All @@ -70,8 +72,9 @@ These instructions apply to the entire repository.
- Keep code in `mac-app/Resources/reader-web*.js` compatible with the WebKit version available on macOS 12.
- Avoid duplicating reader state between Swift and JavaScript; use the existing bridge and message patterns.
- Shell scripts must use `#!/usr/bin/env bash` and `set -euo pipefail` unless there is a documented compatibility reason not to.
- Quote path and variable expansions, use repository-relative paths derived from the script location, and put temporary output under `mktemp` or `/private/tmp`.
- Quote path and variable expansions, use repository-relative paths derived from the script location, and create owned temporary output with `mktemp`; install an `EXIT` trap when cleanup is required.
- Do not weaken signing, notarization, bundle auditing, checksum, or architecture checks to make a build pass.
- Publish and checksum-verify release assets before pushing an appcast that references them. Keep installers in GitHub Releases rather than Git tracking, and make pre-publication failures remove staged remote release state.

## Tests and Validation

Expand All @@ -94,6 +97,7 @@ Use `./scripts/check.sh` for a full pre-commit verification when the local envir
- Tests use lightweight executable Swift test runners rather than XCTest. Follow the existing `expect`/`expectEqual` and runner patterns.
- When adding a test-only source dependency, update the appropriate source list in `tests/run.sh`.
- JavaScript changes must pass `node --check` and `tests/ReaderWebScriptTests.js`; the standard test script runs these checks.
- Keep GitHub Actions application CI aligned with the standard checks, strict UI-theme validation, and a universal app build.
- Always run `git diff --check` before committing.

## Documentation and Generated Files
Expand Down
4 changes: 4 additions & 0 deletions docs/wiki/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ AppDelegate
- `AppDelegate+UserDataBackup.swift`: backup and restore menu workflow. Pending restores run before reader controllers and database singletons are created.
- `Resources/reader-web*.js`: focused WebKit reader modules for text, marks, search, TTS ranges, selection events, and bridge installation. Web marks share cached normalized text indexes and prefer CSS Custom Highlight ranges, with a DOM-span fallback for older WebKit versions.
- `RecentDocuments*.swift` and `RecentBookCardView.swift`: bookshelf panel and recent document UI.
- `DocumentIdentity.swift`: streaming content hashes and compatibility mappings that keep document state stable across moves while preventing equal-size, equal-timestamp replacements from inheriting old records.
- `NetworkConnectivityMonitor.swift`: coarse `NWPathMonitor` state for UI availability. Request fallback decisions use errors from the actual model endpoint; app startup performs no unrelated HTTP reachability probe.
- `WordRecordSQLiteStore.swift` and related stores: persistent word and conversation data.
- `TextQuoteAnchor.swift` and `ReaderWindowController+VocabularyHighlights.swift`: semantic PDF occurrence identity plus visible-page, bounded-batch annotation materialization. Stored rectangles remain the compatibility fallback for existing records.

Expand All @@ -39,6 +41,8 @@ Large controllers are split by behavior into extensions or focused helper views.

Document opening prioritizes first visible content. PDF cover generation, table-of-contents construction, and persisted mark restoration start only after the reader surface is visible, and every asynchronous result is guarded by the active document generation.

Document content identity is calculated off the main thread before persistent state is attached. The same streaming pass calculates the historical MD5 needed to discover state after a move. A compatibility registry associates an existing namespace with its first observed content hash, while contradictory cached content proof prevents replacement bytes from claiming metadata-based state.

Reader selection and automatic background embedding work do not inspect Keychain credentials. Credentials are read only from explicit AI, diagnostics, connection-test, or settings actions. User-data backups exclude all current and legacy API-key preference fields and never copy or replace Keychain items.

## Related Files
Expand Down
16 changes: 8 additions & 8 deletions docs/wiki/code-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ Generated by `./scripts/generate_code_wiki.sh`.

## Summary

- Code files: 449
- Main code lines: 62023
- Swift app lines: 50643
- Code files: 451
- Main code lines: 62767
- Swift app lines: 50849
- Full Swift type index: [Type Index](type-index.md)

## Largest Files
Expand All @@ -20,18 +20,18 @@ Generated by `./scripts/generate_code_wiki.sh`.
| `mac-app/PersonalVocabularyProfileStore.swift` | 479 |
| `mac-app/KokoroTTSBackend.swift` | 476 |
| `tests/SpeechRuntimeDownloadTests.swift` | 461 |
| `tests/ReadingNoteLogicTests.swift` | 444 |
| `mac-app/ECDICTDictionary.swift` | 440 |
| `mac-app/ReaderChromeViews.swift` | 430 |
| `mac-app/ReaderWindowController+ReadingNotes.swift` | 424 |
| `mac-app/ReaderWindowController+VocabularyHighlights.swift` | 420 |
| `mac-app/ReaderWindowController+ReadingNotes.swift` | 414 |
| `tests/ReadingNoteLogicTests.swift` | 413 |
| `mac-app/ReadingNotePanelController+AskAI.swift` | 413 |
| `tests/AISettingsLogicTests.swift` | 411 |
| `mac-app/Resources/reader-web-marks.js` | 401 |
| `mac-app/ReaderWindowController+Input.swift` | 400 |
| `tests/AISettingsLogicTests.swift` | 399 |
| `mac-app/WordRecordSQLiteStore.swift` | 394 |
| `tests/SpeechRuntimeAvailabilityTests.swift` | 393 |
| `tests/ReadingNoteMarkdownLogicTests.swift` | 390 |
| `mac-app/WordRecordSQLiteStore.swift` | 389 |
| `tests/SpeechRuntimeAvailabilityTests.swift` | 385 |

## Reader Window Modules

Expand Down
5 changes: 5 additions & 0 deletions docs/wiki/development-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ Run:
```sh
./scripts/check.sh --no-build
./scripts/check_ui_theme.sh --warnings-as-errors
./scripts/check_ui_accessibility.sh
./scripts/build_app.sh
```

Expand All @@ -131,6 +132,7 @@ UI rule:

- Every new visible control must define or inherit colors for all reader modes: original, eyeCare, and dark.
- Icon-only buttons must set `contentTintColor` from the active theme, not a fixed system color.
- Icon-only buttons must pass localized text into both the symbol accessibility description and the control accessibility label.
- Controls created after startup must use the current theme at creation time and must also be updated by the surface's theme refresh path.
- If a control is inside a dynamic row, bubble, note, or popup accessory view, theme refresh must walk existing subviews and update it.
- Save panels and other macOS accessory views should hide irrelevant system fields, such as tags, when they are not part of the app workflow.
Expand Down Expand Up @@ -238,6 +240,7 @@ Run:
Watch for:

- Moved files losing stable identity.
- Replacement content inheriting state because identity uses only path, size, or modification time.
- Sorting or import behavior changing without test coverage.
- Shelf actions clearing the wrong document data.

Expand Down Expand Up @@ -292,4 +295,6 @@ Watch for:

- Version references disagreeing between `Info.plist`, `README.md`, website, and appcast.
- Package signing or notarization failures.
- Pushing an appcast before the referenced GitHub Release asset is public and checksum-verified.
- Leaving a draft release or remote tag behind after a pre-publication failure.
- Sparkle update check failing after publishing.
7 changes: 5 additions & 2 deletions docs/wiki/release-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ curl -I -L https://leafreader.space/appcast.xml

Use `--push-wiki` when the release should sync GitHub Wiki as part of the publish flow. Use `--cleanup-releases` to remove old ignored local release artifacts after a successful publish.

The publish script pushes the tag, creates a draft release, downloads and checksum-verifies its assets, publishes the release, verifies the public package, and only then pushes `main` with the appcast. A failure before publication removes the draft release and staged remote tag.

- Confirm the Git tag exists:

```sh
Expand All @@ -97,8 +99,9 @@ curl -I -L https://github.com/dowellhz/LeafReader/releases/download/v<version>/L
./scripts/update_wiki.sh --push
```

## Rollback Notes
## Recovery Notes

- If GitHub Release upload fails, keep the tag and local package until the failure is understood.
- If GitHub Release upload or verification fails before publication, the script removes the draft release and remote tag. Keep the local release commit, tag, and package for diagnosis; rerunning is supported when the local tag still points to that commit.
- If the release becomes public but pushing `main` fails, do not republish. Verify the public package, then run `git push origin main` to expose the already-valid appcast commit.
- If appcast metadata is wrong, fix `docs/appcast.xml`, commit, push, and re-check the update dialog.
- If notarization fails, do not publish the appcast entry until the package is signed and accepted.
8 changes: 5 additions & 3 deletions docs/wiki/release-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,9 @@ For a full maintenance publish, include wiki sync and release cleanup:
Expected:

- Version checks pass.
- Release artifacts are uploaded to GitHub Releases.
- `main` and `v<version>` are pushed.
- `v<version>` is pushed and a draft GitHub Release receives the artifacts.
- Every draft asset is downloaded and checksum-verified before the release becomes public.
- The public package is downloaded and checksum-verified before `main` exposes the appcast.
- `docs/appcast.xml`, `README.md`, and website references are current.
- With `--push-wiki`, GitHub Wiki and `docs/wiki` source are updated after publication.
- With `--cleanup-releases`, old ignored local release artifacts are removed after publication.
Expand Down Expand Up @@ -117,7 +118,8 @@ Skip this manual step when `publish_release.sh` was run with `--push-wiki`.

## Recovery

- If GitHub Release upload fails, inspect the existing release and asset list before retrying.
- If upload or verification fails before publication, the script removes the draft release and remote tag. Inspect the local package and retry; the local tag is accepted when it points to the release commit.
- If the release is public but pushing `main` fails, verify the public asset and recover with `git push origin main`; do not recreate the release.
- If the appcast is wrong, fix `docs/appcast.xml`, push `main`, and re-check the appcast URL.
- If notarization or signing fails, do not publish the appcast entry until the package verifies.
- If the update dialog fails, check [Troubleshooting](troubleshooting.md) before changing Sparkle configuration.
4 changes: 2 additions & 2 deletions docs/wiki/type-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ Generated by `./scripts/generate_code_wiki.sh`.
| `mac-app/RecentDocumentsPanelController+Actions.swift` | 3 | `extension RecentDocumentsPanelController {` |
| `mac-app/RecentDocumentsPanelController+Cards.swift` | 5 | `extension RecentDocumentsPanelController {` |
| `mac-app/RecentDocumentsPanelController.swift` | 6 | `final class RecentDocumentsPanelController` |
| `mac-app/RecentDocumentsStore.swift` | 11 | `enum RecentDocumentsStore {` |
| `mac-app/RecentDocumentsStore.swift` | 12 | `enum RecentDocumentsStore {` |
| `mac-app/RecentDocumentsStore.swift` | 3 | `struct RecentDocumentItem` |
| `mac-app/RequestAvailabilityPolicy.swift` | 3 | `enum RequestAvailabilityPolicy {` |
| `mac-app/SQLiteSchemaMigrator.swift` | 4 | `enum SQLiteSchemaMigrator {` |
Expand Down Expand Up @@ -539,7 +539,7 @@ Generated by `./scripts/generate_code_wiki.sh`.
| `mac-app/WebWordRecordStore.swift` | 3 | `struct StoredWebWordRecord` |
| `mac-app/WordQuestionRequest.swift` | 3 | `struct WordQuestionRequest {` |
| `mac-app/WordQuestionRequest.swift` | 8 | `struct WordQuestionStartResult {` |
| `mac-app/WordRecordSQLiteRowMapper.swift` | 108 | `struct WebWordRecordSQLiteMapper {` |
| `mac-app/WordRecordSQLiteRowMapper.swift` | 110 | `struct WebWordRecordSQLiteMapper {` |
| `mac-app/WordRecordSQLiteRowMapper.swift` | 19 | `enum WordRecordSQLiteBindIndex` |
| `mac-app/WordRecordSQLiteRowMapper.swift` | 34 | `struct PDFWordRecordSQLiteMapper {` |
| `mac-app/WordRecordSQLiteRowMapper.swift` | 4 | `struct WordRecordSQLiteJSONCodec {` |
Expand Down
4 changes: 4 additions & 0 deletions mac-app/AIChatPanel+Bubbles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ extension AIChatPanel {
button.contentTintColor = aiAccentColor
button.imageScaling = .scaleProportionallyDown
button.imagePosition = .imageOnly
button.setAccessibilityLabel(AppText.localized("播放单词发音", "Play word pronunciation"))
button.identifier = NSUserInterfaceItemIdentifier(word)
button.spokenWord = word
button.toolTip = AppText.localized("播放单词发音", "Play word pronunciation")
Expand Down Expand Up @@ -174,6 +175,7 @@ extension AIChatPanel {
button.contentTintColor = secondaryTextColor
button.imageScaling = .scaleProportionallyDown
button.imagePosition = .imageOnly
button.setAccessibilityLabel(AppText.localized("删除这段气泡", "Delete this bubble"))
button.identifier = NSUserInterfaceItemIdentifier(bodyID)
button.toolTip = AppText.localized("删除这段气泡", "Delete this bubble")
button.translatesAutoresizingMaskIntoConstraints = false
Expand All @@ -195,6 +197,7 @@ extension AIChatPanel {
button.contentTintColor = secondaryTextColor
button.imageScaling = .scaleProportionallyDown
button.imagePosition = .imageOnly
button.setAccessibilityLabel(AppText.localized("重新生成这段回答", "Regenerate this answer"))
button.identifier = NSUserInterfaceItemIdentifier(bodyID)
button.toolTip = AppText.localized("重新生成这段回答", "Regenerate this answer")
button.translatesAutoresizingMaskIntoConstraints = false
Expand All @@ -212,6 +215,7 @@ extension AIChatPanel {
button.contentTintColor = secondaryTextColor
button.imageScaling = .scaleProportionallyDown
button.imagePosition = .imageOnly
button.setAccessibilityLabel(AppText.localized("复制这段回答的 Markdown", "Copy this answer as Markdown"))
button.identifier = NSUserInterfaceItemIdentifier(bodyID)
button.toolTip = AppText.localized("复制这段回答的 Markdown", "Copy this answer as Markdown")
button.translatesAutoresizingMaskIntoConstraints = false
Expand Down
1 change: 0 additions & 1 deletion mac-app/AIChatPanel+RequestFailure.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ extension AIChatPanel {
logAIRequestFailure(error, usesDictionaryFallback: shouldUseDictionaryFallback)

if shouldUseDictionaryFallback {
NetworkConnectivityMonitor.shared.markNetworkFailure()
if let fallbackAnswer, let assistantBody {
applyOfflineDictionaryFallback(
fallbackAnswer,
Expand Down
1 change: 0 additions & 1 deletion mac-app/AIChatPanel+Requests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ extension AIChatPanel {
self.setBusy(false, text: "")
switch result {
case .success(let content):
NetworkConnectivityMonitor.shared.markRequestSucceeded()
let finalContent = VocabularyTagFormatter.appendSuffix(
to: AIResponseTextFormatter.trimmed(content),
suffix: answerSuffix
Expand Down
Loading
Loading