diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml new file mode 100644 index 00000000..a239d0e8 --- /dev/null +++ b/.github/workflows/app.yml @@ -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" diff --git a/AGENTS.md b/AGENTS.md index 730d0cd0..cfd917b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. @@ -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. @@ -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 @@ -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 diff --git a/docs/wiki/architecture.md b/docs/wiki/architecture.md index 99140aeb..17b76582 100644 --- a/docs/wiki/architecture.md +++ b/docs/wiki/architecture.md @@ -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. @@ -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 diff --git a/docs/wiki/code-map.md b/docs/wiki/code-map.md index a7ce82f2..f2c305b1 100644 --- a/docs/wiki/code-map.md +++ b/docs/wiki/code-map.md @@ -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 @@ -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 diff --git a/docs/wiki/development-tasks.md b/docs/wiki/development-tasks.md index 7513ae55..97fcb0ea 100644 --- a/docs/wiki/development-tasks.md +++ b/docs/wiki/development-tasks.md @@ -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 ``` @@ -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. @@ -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. @@ -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. diff --git a/docs/wiki/release-checklist.md b/docs/wiki/release-checklist.md index 465234cd..a3916aca 100644 --- a/docs/wiki/release-checklist.md +++ b/docs/wiki/release-checklist.md @@ -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 @@ -97,8 +99,9 @@ curl -I -L https://github.com/dowellhz/LeafReader/releases/download/v/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. diff --git a/docs/wiki/release-runbook.md b/docs/wiki/release-runbook.md index 77210659..e90e6d98 100644 --- a/docs/wiki/release-runbook.md +++ b/docs/wiki/release-runbook.md @@ -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` are pushed. +- `v` 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. @@ -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. diff --git a/docs/wiki/type-index.md b/docs/wiki/type-index.md index b3fa1762..90bfb368 100644 --- a/docs/wiki/type-index.md +++ b/docs/wiki/type-index.md @@ -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 {` | @@ -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 {` | diff --git a/mac-app/AIChatPanel+Bubbles.swift b/mac-app/AIChatPanel+Bubbles.swift index 4fbed136..b4c471a1 100644 --- a/mac-app/AIChatPanel+Bubbles.swift +++ b/mac-app/AIChatPanel+Bubbles.swift @@ -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") @@ -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 @@ -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 @@ -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 diff --git a/mac-app/AIChatPanel+RequestFailure.swift b/mac-app/AIChatPanel+RequestFailure.swift index 487ccb94..121200a4 100644 --- a/mac-app/AIChatPanel+RequestFailure.swift +++ b/mac-app/AIChatPanel+RequestFailure.swift @@ -19,7 +19,6 @@ extension AIChatPanel { logAIRequestFailure(error, usesDictionaryFallback: shouldUseDictionaryFallback) if shouldUseDictionaryFallback { - NetworkConnectivityMonitor.shared.markNetworkFailure() if let fallbackAnswer, let assistantBody { applyOfflineDictionaryFallback( fallbackAnswer, diff --git a/mac-app/AIChatPanel+Requests.swift b/mac-app/AIChatPanel+Requests.swift index 630d8924..54e62a73 100644 --- a/mac-app/AIChatPanel+Requests.swift +++ b/mac-app/AIChatPanel+Requests.swift @@ -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 diff --git a/mac-app/AIChatPanel+UI.swift b/mac-app/AIChatPanel+UI.swift index 3021efa3..883e2603 100644 --- a/mac-app/AIChatPanel+UI.swift +++ b/mac-app/AIChatPanel+UI.swift @@ -59,6 +59,7 @@ extension AIChatPanel { loadingDots.accentColor = aiAccentColor loadingDots.translatesAutoresizingMaskIntoConstraints = false cancelRequestButton.image = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: AppText.cancel) + cancelRequestButton.setAccessibilityLabel(AppText.cancel) cancelRequestButton.isBordered = false cancelRequestButton.contentTintColor = secondaryTextColor cancelRequestButton.target = self @@ -88,6 +89,7 @@ extension AIChatPanel { inputBar.focusField = inputField sendButton.image = NSImage(systemSymbolName: "arrow.up.circle.fill", accessibilityDescription: AppText.send) + sendButton.setAccessibilityLabel(AppText.send) sendButton.isBordered = false sendButton.target = self sendButton.action = #selector(sendFollowUp) @@ -167,6 +169,7 @@ extension AIChatPanel { func refreshLanguage() { inputField.placeholderString = AppText.followUpPlaceholder sendButton.image = NSImage(systemSymbolName: "arrow.up.circle.fill", accessibilityDescription: AppText.send) + sendButton.setAccessibilityLabel(AppText.send) summaryButton.title = AppText.localized("总结", "Summarize") translateButton.title = AppText.localized("翻译", "Translate") exportConversationButton.title = AppText.localized("导出", "Export") diff --git a/mac-app/AISettingsPanelController+Build.swift b/mac-app/AISettingsPanelController+Build.swift index 2dc962fa..271ef61b 100644 --- a/mac-app/AISettingsPanelController+Build.swift +++ b/mac-app/AISettingsPanelController+Build.swift @@ -28,6 +28,7 @@ extension AISettingsPanelController { let titleLabel = label(AppText.settings, size: 22, weight: .semibold, color: primaryText) let closeButton = NSButton(title: "", target: self, action: #selector(cancel(_:))) closeButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: AppText.close) + closeButton.setAccessibilityLabel(AppText.close) closeButton.isBordered = false closeButton.contentTintColor = primaryText closeButton.translatesAutoresizingMaskIntoConstraints = false diff --git a/mac-app/AISettingsPanelController+BuildCache.swift b/mac-app/AISettingsPanelController+BuildCache.swift index 238778fa..d51f9350 100644 --- a/mac-app/AISettingsPanelController+BuildCache.swift +++ b/mac-app/AISettingsPanelController+BuildCache.swift @@ -28,7 +28,10 @@ extension AISettingsPanelController { let cacheStatusLabel = label(AppText.localized("正在统计缓存...", "Calculating cache..."), size: settingsFontSize, color: secondaryText) let cacheDisclosureButton = NSButton(title: "", target: self, action: #selector(clearVectorCache(_:))) cacheDisclosureButton.isBordered = false - cacheDisclosureButton.image = NSImage(systemSymbolName: "chevron.right", accessibilityDescription: nil) + let disclosureLabel = AppText.localized("展开缓存操作", "Show cache actions") + cacheDisclosureButton.image = NSImage(systemSymbolName: "chevron.right", accessibilityDescription: disclosureLabel) + cacheDisclosureButton.setAccessibilityLabel(disclosureLabel) + cacheDisclosureButton.toolTip = disclosureLabel cacheDisclosureButton.contentTintColor = primaryText cacheDisclosureButton.isHidden = true cacheDisclosureButton.translatesAutoresizingMaskIntoConstraints = false diff --git a/mac-app/AISettingsPanelController+UIFactory.swift b/mac-app/AISettingsPanelController+UIFactory.swift index 1bd477fd..6880da43 100644 --- a/mac-app/AISettingsPanelController+UIFactory.swift +++ b/mac-app/AISettingsPanelController+UIFactory.swift @@ -25,6 +25,7 @@ extension AISettingsPanelController { let imageView = NSImageView() imageView.image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: nil)? .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: metrics.titleIconSymbolSize, weight: .regular)) + imageView.setAccessibilityElement(false) imageView.identifier = Identifiers.settingsTitleIcon imageView.contentTintColor = settingsTitleIconColor(for: ReaderTheme.selected, fallback: primaryText) imageView.imageScaling = .scaleNone diff --git a/mac-app/DiagnosticsPanelController.swift b/mac-app/DiagnosticsPanelController.swift index 3f8e436c..2036ef66 100644 --- a/mac-app/DiagnosticsPanelController.swift +++ b/mac-app/DiagnosticsPanelController.swift @@ -70,6 +70,7 @@ final class DiagnosticsPanelController: NSWindowController { let titleIcon = NSImageView() titleIcon.image = NSImage(systemSymbolName: "calendar.badge.checkmark", accessibilityDescription: nil)? .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 24, weight: .semibold)) + titleIcon.setAccessibilityElement(false) titleIcon.contentTintColor = theme.primaryText titleIcon.imageScaling = .scaleNone titleIcon.translatesAutoresizingMaskIntoConstraints = false @@ -84,6 +85,7 @@ final class DiagnosticsPanelController: NSWindowController { let closeIconButton = NSButton(title: "", target: self, action: #selector(closePanel(_:))) closeIconButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: AppText.close)? .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)) + closeIconButton.setAccessibilityLabel(AppText.close) closeIconButton.isBordered = false closeIconButton.contentTintColor = theme.secondaryText closeIconButton.translatesAutoresizingMaskIntoConstraints = false diff --git a/mac-app/DocumentIdentity.swift b/mac-app/DocumentIdentity.swift index 068cc071..e5802f10 100644 --- a/mac-app/DocumentIdentity.swift +++ b/mac-app/DocumentIdentity.swift @@ -2,6 +2,79 @@ import CryptoKit import Foundation enum DocumentIdentity { + private static let contentMappingDefaultsKey = "documentIdentity.contentMappings.v1" + private static let legacyOwnerDefaultsKey = "documentIdentity.legacyOwners.v1" + private static let readChunkSize = 1_048_576 + + static func contentIdentifiers( + for url: URL, + isCancelled: () -> Bool = { false } + ) throws -> (contentID: String, legacyMD5: String) { + let handle = try FileHandle(forReadingFrom: url.standardizedFileURL) + defer { try? handle.close() } + var hasher = SHA256() + var legacyHasher = Insecure.MD5() + while true { + if isCancelled() { throw CancellationError() } + let data = handle.readData(ofLength: readChunkSize) + if data.isEmpty { break } + hasher.update(data: data) + legacyHasher.update(data: data) + } + if isCancelled() { throw CancellationError() } + let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined() + let legacyDigest = legacyHasher.finalize().map { String(format: "%02x", $0) }.joined() + return ("content-v1-\(digest)", legacyDigest) + } + + static func contentID(for url: URL, isCancelled: () -> Bool = { false }) throws -> String { + try contentIdentifiers(for: url, isCancelled: isCancelled).contentID + } + + static func storageID( + contentID: String, + metadataID: String?, + legacyID: String?, + defaults: UserDefaults = .standard, + hasStoredData: (String) -> Bool + ) -> String { + var mappings = defaults.dictionary(forKey: contentMappingDefaultsKey) as? [String: String] ?? [:] + if let mappedID = mappings[contentID], !mappedID.isEmpty { + return mappedID + } + if hasStoredData(contentID) { + mappings[contentID] = contentID + defaults.set(mappings, forKey: contentMappingDefaultsKey) + return contentID + } + + var legacyOwners = defaults.dictionary(forKey: legacyOwnerDefaultsKey) as? [String: String] ?? [:] + let candidates = [metadataID, legacyID].compactMap { $0 }.filter { !$0.isEmpty } + for candidate in candidates where hasStoredData(candidate) { + guard legacyOwners[candidate] == nil || legacyOwners[candidate] == contentID else { + continue + } + legacyOwners[candidate] = contentID + mappings[contentID] = candidate + defaults.set(legacyOwners, forKey: legacyOwnerDefaultsKey) + defaults.set(mappings, forKey: contentMappingDefaultsKey) + return candidate + } + + mappings[contentID] = contentID + defaults.set(mappings, forKey: contentMappingDefaultsKey) + return contentID + } + + static func migrationMetadataID( + fastID: String, + cachedLegacyID: String?, + computedLegacyID: String + ) -> String? { + guard let cachedLegacyID else { return fastID } + return cachedLegacyID == computedLegacyID ? fastID : nil + } + static func fastID(for url: URL) -> String { let cacheKey = legacyCacheKey(for: url) let digest = SHA256.hash(data: Data(cacheKey.utf8)) diff --git a/mac-app/NetworkConnectivityMonitor.swift b/mac-app/NetworkConnectivityMonitor.swift index 95097bb1..bdf9d520 100644 --- a/mac-app/NetworkConnectivityMonitor.swift +++ b/mac-app/NetworkConnectivityMonitor.swift @@ -23,10 +23,6 @@ final class NetworkConnectivityMonitor { } private enum Constants { - static let probeTimeout: TimeInterval = 3 - static let retryDelay: TimeInterval = 5 - static let probeURL = URL(string: "https://www.apple.com/library/test/success.html")! - static let reachableStatusCodes = 200..<400 static let networkErrorCodes: Set = [ NSURLErrorNotConnectedToInternet, NSURLErrorNetworkConnectionLost, @@ -41,14 +37,6 @@ final class NetworkConnectivityMonitor { private let queue = DispatchQueue(label: "com.linlu.leafreader.network-connectivity") private let lock = NSLock() private var state: State = .online - private var isProbeRunning = false - private var retryProbeWorkItem: DispatchWorkItem? - private lazy var probeSession: URLSession = { - let configuration = URLSessionConfiguration.ephemeral - configuration.timeoutIntervalForRequest = Constants.probeTimeout - configuration.waitsForConnectivity = false - return URLSession(configuration: configuration) - }() var isOnline: Bool { lock.lock() @@ -59,23 +47,9 @@ final class NetworkConnectivityMonitor { private init() { monitor.pathUpdateHandler = { [weak self] path in guard let self else { return } - if path.status == .satisfied { - self.probeInternet() - } else { - self.setState(.offline) - } + self.setState(path.status == .satisfied ? .online : .offline) } monitor.start(queue: queue) - probeInternet() - } - - func markRequestSucceeded() { - setState(.online) - } - - func markNetworkFailure() { - setState(.offline) - scheduleRetryProbe() } static func isNetworkConnectivityError(_ error: Error) -> Bool { @@ -89,66 +63,13 @@ final class NetworkConnectivityMonitor { let didChange = state.isOnline != newState.isOnline state = newState lock.unlock() - if newState.isOnline { - cancelRetryProbe() - } guard didChange else { return } DispatchQueue.main.async { NotificationCenter.default.post(name: .leafReaderNetworkConnectivityChanged, object: self) } } - private func probeInternet() { - lock.lock() - guard !isProbeRunning else { - lock.unlock() - return - } - isProbeRunning = true - lock.unlock() - - var request = URLRequest(url: Constants.probeURL) - request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - request.timeoutInterval = Constants.probeTimeout - probeSession.dataTask(with: request) { [weak self] _, response, error in - guard let self else { return } - let statusCode = (response as? HTTPURLResponse)?.statusCode - let isReachable = error == nil - && statusCode.map { Constants.reachableStatusCodes.contains($0) } == true - self.lock.lock() - self.isProbeRunning = false - self.lock.unlock() - isReachable ? self.setState(.online) : self.markNetworkFailure() - }.resume() - } - - private func scheduleRetryProbe() { - queue.async { [weak self] in - guard let self else { return } - guard !self.isProbeRunning else { return } - if self.retryProbeWorkItem?.isCancelled == false { - return - } - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - self.retryProbeWorkItem = nil - self.probeInternet() - } - self.retryProbeWorkItem = workItem - self.queue.asyncAfter(deadline: .now() + Constants.retryDelay, execute: workItem) - } - } - - private func cancelRetryProbe() { - queue.async { [weak self] in - self?.retryProbeWorkItem?.cancel() - self?.retryProbeWorkItem = nil - } - } - deinit { - retryProbeWorkItem?.cancel() - probeSession.invalidateAndCancel() monitor.cancel() } } diff --git a/mac-app/ReadAloudFloatingControlView.swift b/mac-app/ReadAloudFloatingControlView.swift index d82bbe74..0badee53 100644 --- a/mac-app/ReadAloudFloatingControlView.swift +++ b/mac-app/ReadAloudFloatingControlView.swift @@ -330,6 +330,7 @@ final class ReadAloudFloatingControlView: NSView { isEnabled: Bool ) { button.image = TemplateSymbolImage.make(symbolName, accessibilityDescription: label) + button.setAccessibilityLabel(label) button.isEnabled = isEnabled if let shortcut { button.toolTip = AppText.localized("\(label)(\(shortcut))", "\(label) (\(shortcut))") diff --git a/mac-app/ReaderDocumentState.swift b/mac-app/ReaderDocumentState.swift index 73e1806c..234b83ac 100644 --- a/mac-app/ReaderDocumentState.swift +++ b/mac-app/ReaderDocumentState.swift @@ -8,7 +8,7 @@ struct ReaderDocumentState { var sessionStore = ReaderSessionStore(fileMD5: nil) var currentDocumentKind: ReaderDocumentKind = .pdf var documentLoadGeneration = 0 - var activeWebDocumentLoadCancellationToken: DocumentLoadCancellationToken? + var activeDocumentLoadCancellationToken: DocumentLoadCancellationToken? var pdfTextSnapshot: PDFDocumentTextSnapshot? var pdfTextSnapshotGeneration = 0 var pdfTextSnapshotCancellationToken: PDFDocumentTextCancellationToken? diff --git a/mac-app/ReaderWindowController+ChromeUI.swift b/mac-app/ReaderWindowController+ChromeUI.swift index 1e6bd5b3..55a512e1 100644 --- a/mac-app/ReaderWindowController+ChromeUI.swift +++ b/mac-app/ReaderWindowController+ChromeUI.swift @@ -22,12 +22,14 @@ extension ReaderWindowController { loadingOverlay.addSubview(loadingLabel) } - func iconButton(symbol: String, action: Selector) -> NSButton { + func iconButton(symbol: String, action: Selector, accessibilityDescription: String) -> NSButton { let button = NSButton(title: "", target: self, action: action) button.isBordered = false - setSystemImage(symbol, on: button) + setSystemImage(symbol, on: button, accessibilityDescription: accessibilityDescription) button.imageScaling = .scaleProportionallyDown button.contentTintColor = ReaderTheme.selected.primaryTextColor + button.setAccessibilityLabel(accessibilityDescription) + button.toolTip = accessibilityDescription return button } diff --git a/mac-app/ReaderWindowController+Document.swift b/mac-app/ReaderWindowController+Document.swift index 3b845a2c..af58b485 100644 --- a/mac-app/ReaderWindowController+Document.swift +++ b/mac-app/ReaderWindowController+Document.swift @@ -17,8 +17,8 @@ extension ReaderWindowController { func loadDocument(_ url: URL) { guard let kind = ReaderDocumentKind.kind(for: url) else { return } - activeWebDocumentLoadCancellationToken?.cancel() - activeWebDocumentLoadCancellationToken = nil + activeDocumentLoadCancellationToken?.cancel() + activeDocumentLoadCancellationToken = nil stopReadAloudImmediately() SpeechPlaybackCoordinator.shared.shutdownRuntime(.kokoro) documentLoadGeneration += 1 @@ -28,14 +28,57 @@ extension ReaderWindowController { flushCurrentBookWordRecordSaves() saveCurrentAIConversationBeforeDocumentChange() resetEmbeddingStateForDocumentChange() - switch kind { - case .pdf: - DispatchQueue.main.async { [weak self] in - guard let self, self.documentLoadGeneration == generation else { return } - self.loadPDF(url, generation: generation) + let cancellationToken = DocumentLoadCancellationToken() + activeDocumentLoadCancellationToken = cancellationToken + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + do { + let contentIdentifiers = try DocumentIdentity.contentIdentifiers( + for: url, + isCancelled: { cancellationToken.isCancelled } + ) + try cancellationToken.checkCancellation() + guard let self else { return } + let metadataID = DocumentIdentity.migrationMetadataID( + fastID: DocumentIdentity.fastID(for: url), + cachedLegacyID: self.cachedLegacyMD5(for: url), + computedLegacyID: contentIdentifiers.legacyMD5 + ) + let documentID = DocumentIdentity.storageID( + contentID: contentIdentifiers.contentID, + metadataID: metadataID, + legacyID: contentIdentifiers.legacyMD5, + hasStoredData: self.hasStoredDocumentData(documentID:) + ) + try cancellationToken.checkCancellation() + DispatchQueue.main.async { [weak self] in + guard let self, + self.documentLoadGeneration == generation, + self.activeDocumentLoadCancellationToken === cancellationToken else { + return + } + switch kind { + case .pdf: + self.activeDocumentLoadCancellationToken = nil + self.loadPDF(url, documentID: documentID, generation: generation) + case .epub, .docx: + self.loadWebDocument( + url, + kind: kind, + documentID: documentID, + generation: generation, + cancellationToken: cancellationToken + ) + } + } + } catch is CancellationError { + return + } catch { + DispatchQueue.main.async { [weak self] in + guard let self, self.documentLoadGeneration == generation else { return } + self.activeDocumentLoadCancellationToken = nil + self.showDocumentLoadingFailure(error, generation: generation) + } } - case .epub, .docx: - loadWebDocument(url, kind: kind, generation: generation) } } diff --git a/mac-app/ReaderWindowController+DocumentLoading.swift b/mac-app/ReaderWindowController+DocumentLoading.swift index 89994846..cd2f812e 100644 --- a/mac-app/ReaderWindowController+DocumentLoading.swift +++ b/mac-app/ReaderWindowController+DocumentLoading.swift @@ -9,7 +9,7 @@ private enum ReaderPDFCoverThumbnailLoader { } extension ReaderWindowController { - func loadPDF(_ url: URL, generation: Int? = nil) { + func loadPDF(_ url: URL, documentID: String, generation: Int? = nil) { guard let document = PDFDocument(url: url) else { if let generation { showDocumentLoadingFailure( @@ -28,7 +28,7 @@ extension ReaderWindowController { pdfView.isHidden = false webView.isHidden = true pdfView.document = document - prepareRuntimeStateForLoadedDocument(url: url) + prepareRuntimeStateForLoadedDocument(url: url, documentID: documentID) preparePDFTextSnapshotAsync(for: url) captureOriginalPDFCropBoxes() applyPDFMarginCropIfNeeded() @@ -67,7 +67,7 @@ extension ReaderWindowController { applyReaderTheme(refreshDocumentDecorations: false) updatePageLabel() updateZoomLabel() - RecentDocumentsStore.record(url: url, kind: .pdf) + RecentDocumentsStore.record(url: url, kind: .pdf, documentID: documentID) saveSession() scheduleDocumentEmbeddingWarmup(priorityPageIndex: currentEmbeddingPriorityIndex()) if let generation { @@ -75,9 +75,13 @@ extension ReaderWindowController { } } - func loadWebDocument(_ url: URL, kind: ReaderDocumentKind, generation: Int) { - let cancellationToken = DocumentLoadCancellationToken() - activeWebDocumentLoadCancellationToken = cancellationToken + func loadWebDocument( + _ url: URL, + kind: ReaderDocumentKind, + documentID: String, + generation: Int, + cancellationToken: DocumentLoadCancellationToken + ) { DispatchQueue.global(qos: .userInitiated).async { [weak self] in do { let document = try WebDocumentLoader.load(url: url, cancellationToken: cancellationToken) @@ -86,22 +90,34 @@ extension ReaderWindowController { document.ownedResource?.release() return } - self.activeWebDocumentLoadCancellationToken = nil - self.applyLoadedWebDocument(document, url: url, kind: kind, generation: generation) + self.activeDocumentLoadCancellationToken = nil + self.applyLoadedWebDocument( + document, + url: url, + kind: kind, + documentID: documentID, + generation: generation + ) } } catch is CancellationError { return } catch { DispatchQueue.main.async { guard let self, self.documentLoadGeneration == generation else { return } - self.activeWebDocumentLoadCancellationToken = nil + self.activeDocumentLoadCancellationToken = nil self.showDocumentLoadingFailure(error, generation: generation) } } } } - func applyLoadedWebDocument(_ document: WebReadableDocument, url: URL, kind: ReaderDocumentKind, generation: Int) { + func applyLoadedWebDocument( + _ document: WebReadableDocument, + url: URL, + kind: ReaderDocumentKind, + documentID: String, + generation: Int + ) { closeReadingNotePanelsForDocumentTransition() webView.stopLoading() releaseCurrentOwnedWebResource() @@ -111,7 +127,7 @@ extension ReaderWindowController { pdfDimOverlay.isHidden = true webView.isHidden = false pdfView.document = nil - prepareRuntimeStateForLoadedDocument(url: url) + prepareRuntimeStateForLoadedDocument(url: url, documentID: documentID) pdfWordRecordStore = nil webWordRecordStore = currentFileMD5.map { WebWordRecordStore(fileMD5: $0) } currentWebPlainText = document.plainText @@ -166,7 +182,7 @@ extension ReaderWindowController { applyReaderTheme() applyWebZoomToPage() restoreWebProgressAfterLoad() - RecentDocumentsStore.record(url: url, kind: kind) + RecentDocumentsStore.record(url: url, kind: kind, documentID: documentID) saveSession() scheduleWebPlainTextLoad(document.plainTextLoader, generation: webPlainTextGeneration) scheduleDocumentEmbeddingWarmup(priorityPageIndex: currentEmbeddingPriorityIndex()) @@ -179,11 +195,11 @@ extension ReaderWindowController { currentOwnedWebResource = nil } - func prepareRuntimeStateForLoadedDocument(url: URL) { + func prepareRuntimeStateForLoadedDocument(url: URL, documentID: String) { resetPDFTextSnapshotState() removeAllVocabularyWordAnnotations() currentFileURL = url - currentFileMD5 = fileMD5(for: url) + currentFileMD5 = documentID pendingPDFTOCBuildRequest = nil pendingPDFCoverThumbnailRequest = nil sessionStore = ReaderSessionStore(fileMD5: currentFileMD5) diff --git a/mac-app/ReaderWindowController+DocumentShelf.swift b/mac-app/ReaderWindowController+DocumentShelf.swift index f679b148..a820b275 100644 --- a/mac-app/ReaderWindowController+DocumentShelf.swift +++ b/mac-app/ReaderWindowController+DocumentShelf.swift @@ -86,7 +86,7 @@ extension ReaderWindowController { } func removeShelfItem(path: String, clearVectorCache: Bool, clearWordRecords: Bool, clearAIData: Bool) { - let documentID = fileMD5(for: URL(fileURLWithPath: path)) + let documentID = documentIDForShelfItem(path: path) if currentFileURL?.path == path { unloadCurrentDocumentForShelfRemoval() } @@ -181,7 +181,7 @@ extension ReaderWindowController { } func clearVectorCacheForShelfItem(path: String) { - guard let documentID = fileMD5(for: URL(fileURLWithPath: path)) else { + guard let documentID = documentIDForShelfItem(path: path) else { NSSound.beep() return } @@ -199,7 +199,7 @@ extension ReaderWindowController { } func clearWordRecordsForShelfItem(path: String) { - guard let documentID = fileMD5(for: URL(fileURLWithPath: path)) else { + guard let documentID = documentIDForShelfItem(path: path) else { NSSound.beep() return } @@ -212,7 +212,7 @@ extension ReaderWindowController { } func clearAIDataForShelfItem(path: String) { - guard let documentID = fileMD5(for: URL(fileURLWithPath: path)) else { + guard let documentID = documentIDForShelfItem(path: path) else { NSSound.beep() return } @@ -231,4 +231,14 @@ extension ReaderWindowController { } AIConversationStore(fileMD5: documentID).clear() } + + private func documentIDForShelfItem(path: String) -> String? { + if currentFileURL?.standardizedFileURL.path == path, let currentFileMD5 { + return currentFileMD5 + } + if let documentID = RecentDocumentsStore.load().first(where: { $0.path == path })?.documentID { + return documentID + } + return legacyDocumentIDForShelfItem(URL(fileURLWithPath: path)) + } } diff --git a/mac-app/ReaderWindowController+DocumentState.swift b/mac-app/ReaderWindowController+DocumentState.swift index 1fc40a2a..dc521a12 100644 --- a/mac-app/ReaderWindowController+DocumentState.swift +++ b/mac-app/ReaderWindowController+DocumentState.swift @@ -57,9 +57,9 @@ extension ReaderWindowController { set { documentState.documentLoadGeneration = newValue } } - var activeWebDocumentLoadCancellationToken: DocumentLoadCancellationToken? { - get { documentState.activeWebDocumentLoadCancellationToken } - set { documentState.activeWebDocumentLoadCancellationToken = newValue } + var activeDocumentLoadCancellationToken: DocumentLoadCancellationToken? { + get { documentState.activeDocumentLoadCancellationToken } + set { documentState.activeDocumentLoadCancellationToken = newValue } } var currentPDFSelectedText: String { diff --git a/mac-app/ReaderWindowController+ReadingNotes.swift b/mac-app/ReaderWindowController+ReadingNotes.swift index a50d7276..6fd81bb0 100644 --- a/mac-app/ReaderWindowController+ReadingNotes.swift +++ b/mac-app/ReaderWindowController+ReadingNotes.swift @@ -183,18 +183,28 @@ extension ReaderWindowController { } func saveReadingNote(_ note: ReadingNote) { + guard ReadingNoteStore.shared.upsert(note) else { + NSSound.beep() + let alert = NSAlert() + alert.messageText = AppText.localized("无法保存阅读笔记", "Unable to Save Reading Note") + alert.informativeText = AppText.localized( + "笔记位置数据无效或本地数据库不可用,原有笔记未被覆盖。", + "The note location is invalid or the local database is unavailable. The previous note was preserved." + ) + alert.applyLeafStyle() + alert.runModal() + return + } if let index = storedReadingNotes.firstIndex(where: { $0.id == note.id }) { storedReadingNotes[index] = note } else { storedReadingNotes.append(note) } - if ReadingNoteStore.shared.upsert(note) { - readingNotesPanelController?.update(notes: storedReadingNotes) - if currentDocumentKind == .pdf { - addReadingNoteAnnotation(note) - } else { - markCurrentWebSelectionAsReadingNote(id: note.id) - } + readingNotesPanelController?.update(notes: storedReadingNotes) + if currentDocumentKind == .pdf { + addReadingNoteAnnotation(note) + } else { + markCurrentWebSelectionAsReadingNote(id: note.id) } } diff --git a/mac-app/ReaderWindowController+Session.swift b/mac-app/ReaderWindowController+Session.swift index 3a0f44ab..a8378242 100644 --- a/mac-app/ReaderWindowController+Session.swift +++ b/mac-app/ReaderWindowController+Session.swift @@ -146,7 +146,7 @@ extension ReaderWindowController { } - func fileMD5(for url: URL) -> String? { + func legacyDocumentIDForShelfItem(_ url: URL) -> String? { let fastID = DocumentIdentity.fastID(for: url) let legacyMD5 = cachedLegacyMD5(for: url) return DocumentIdentity.selectedID( @@ -188,6 +188,9 @@ extension ReaderWindowController { if defaults.object(forKey: "aiConversation.\(documentID)") != nil { return true } + if ReadingNoteStore.shared.containsNotes(documentID: documentID) { + return true + } if !WordRecordSQLiteStore.shared.loadPDFRecords(documentID: documentID).isEmpty { return true } diff --git a/mac-app/ReaderWindowController+ToolbarUI.swift b/mac-app/ReaderWindowController+ToolbarUI.swift index 85193832..54607f12 100644 --- a/mac-app/ReaderWindowController+ToolbarUI.swift +++ b/mac-app/ReaderWindowController+ToolbarUI.swift @@ -40,7 +40,11 @@ extension ReaderWindowController { func configureBottomBarViews() -> ReaderBottomBarSetup { let bottomBar = readerBarView() - let settingsButton = iconButton(symbol: "gearshape", action: #selector(openAISettings)) + let settingsButton = iconButton( + symbol: "gearshape", + action: #selector(openAISettings), + accessibilityDescription: AppText.settings + ) settingsButton.image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: AppText.settings)? .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 19, weight: .regular)) let navigationStack = NSStackView() @@ -187,8 +191,11 @@ extension ReaderWindowController { searchUnderlineButton = SearchUnderlineButton(title: "", target: self, action: #selector(showSearchOverlay)) searchUnderlineButton.toolTip = AppText.localized("搜索文档", "Search document") searchUnderlineButton.theme = ReaderTheme.selected - searchButton = iconButton(symbol: "magnifyingglass", action: #selector(showSearchOverlay)) - searchButton.toolTip = AppText.localized("搜索文档", "Search document") + searchButton = iconButton( + symbol: "magnifyingglass", + action: #selector(showSearchOverlay), + accessibilityDescription: AppText.localized("搜索文档", "Search document") + ) } func configureTopRightControls() { @@ -203,7 +210,8 @@ extension ReaderWindowController { func configureRelatedFormsControl() { relatedFormsButton = iconButton( symbol: "eye", - action: #selector(toggleRelatedWordForms(_:)) + action: #selector(toggleRelatedWordForms(_:)), + accessibilityDescription: AppText.localized("显示相关词形高亮", "Show related-form highlights") ) relatedFormsButton.isHidden = true updateRelatedFormsButton() @@ -219,6 +227,7 @@ extension ReaderWindowController { on: relatedFormsButton, accessibilityDescription: title ) + relatedFormsButton.setAccessibilityLabel(title) relatedFormsButton.toolTip = title relatedFormsButton.state = showsRelatedWordForms ? .on : .off } diff --git a/mac-app/ReaderWindowController+VocabularyCards.swift b/mac-app/ReaderWindowController+VocabularyCards.swift index 67e2e45d..a01f8cac 100644 --- a/mac-app/ReaderWindowController+VocabularyCards.swift +++ b/mac-app/ReaderWindowController+VocabularyCards.swift @@ -37,6 +37,7 @@ extension ReaderWindowController { button.contentTintColor = vocabularyAccentColor(for: theme) button.imageScaling = .scaleProportionallyDown button.imagePosition = .imageOnly + button.setAccessibilityLabel(AppText.localized("播放单词发音", "Play word pronunciation")) button.spokenWord = spokenWord button.toolTip = AppText.localized("播放单词发音", "Play word pronunciation") button.translatesAutoresizingMaskIntoConstraints = false diff --git a/mac-app/ReaderWindowController.swift b/mac-app/ReaderWindowController.swift index ac6a4539..8fbf9940 100644 --- a/mac-app/ReaderWindowController.swift +++ b/mac-app/ReaderWindowController.swift @@ -171,7 +171,7 @@ final class ReaderWindowController: NSWindowController, NSWindowDelegate, PDFVie } deinit { - activeWebDocumentLoadCancellationToken?.cancel() + activeDocumentLoadCancellationToken?.cancel() pdfTextSnapshotCancellationToken?.cancel() releaseCurrentOwnedWebResource() if let localEventMonitor { diff --git a/mac-app/ReadingNotePanelController+Build.swift b/mac-app/ReadingNotePanelController+Build.swift index d847128c..e811461a 100644 --- a/mac-app/ReadingNotePanelController+Build.swift +++ b/mac-app/ReadingNotePanelController+Build.swift @@ -107,6 +107,7 @@ extension ReadingNotePanelController { title.alignment = .center title.translatesAutoresizingMaskIntoConstraints = false titleIconView.image = NSImage(systemSymbolName: "pencil.and.list.clipboard", accessibilityDescription: nil) + titleIconView.setAccessibilityElement(false) titleIconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .semibold) titleIconView.translatesAutoresizingMaskIntoConstraints = false titleIconView.widthAnchor.constraint(equalToConstant: 20).isActive = true @@ -124,11 +125,13 @@ extension ReadingNotePanelController { let listButton = iconButton( symbol: "sidebar.right", action: #selector(showNotesTapped(_:)), + label: AppText.localized("阅读笔记列表", "Reading notes list"), pointSize: Metrics.topIconPointSize ) let moreButton = iconButton( symbol: "ellipsis.curlybraces", action: #selector(moreTapped(_:)), + label: AppText.localized("更多操作", "More actions"), pointSize: Metrics.topIconPointSize ) topIconButtons = [listButton, moreButton] @@ -183,18 +186,44 @@ extension ReadingNotePanelController { } private func buildEditorToolbar() -> NSStackView { - let save = iconButton(symbol: "square.and.arrow.down", action: #selector(saveTapped(_:))) - save.toolTip = AppText.localized("保存当前阅读笔记", "Save this reading note") - let undo = iconButton(symbol: "arrow.uturn.backward", action: #selector(undoTapped(_:))) - let redo = iconButton(symbol: "arrow.uturn.forward", action: #selector(redoTapped(_:))) + let save = iconButton( + symbol: "square.and.arrow.down", + action: #selector(saveTapped(_:)), + label: AppText.localized("保存当前阅读笔记", "Save this reading note") + ) + let undo = iconButton( + symbol: "arrow.uturn.backward", + action: #selector(undoTapped(_:)), + label: AppText.localized("撤销", "Undo") + ) + let redo = iconButton( + symbol: "arrow.uturn.forward", + action: #selector(redoTapped(_:)), + label: AppText.localized("重做", "Redo") + ) let bold = textButton(title: "B", action: #selector(boldTapped(_:))) let italic = textButton(title: "I", action: #selector(italicTapped(_:))) italic.font = NSFontManager.shared.convert(AppFont.semibold(ofSize: 16), toHaveTrait: .italicFontMask) - let list = iconButton(symbol: "list.bullet", action: #selector(listTapped(_:))) - let check = iconButton(symbol: "checklist", action: #selector(checklistTapped(_:))) - let template = iconButton(symbol: "doc.plaintext", action: #selector(templateTapped(_:))) - template.toolTip = AppText.localized("插入阅读笔记模板", "Insert reading note template") - let image = iconButton(symbol: "photo", action: #selector(imageTapped(_:))) + let list = iconButton( + symbol: "list.bullet", + action: #selector(listTapped(_:)), + label: AppText.localized("项目符号列表", "Bulleted list") + ) + let check = iconButton( + symbol: "checklist", + action: #selector(checklistTapped(_:)), + label: AppText.localized("检查清单", "Checklist") + ) + let template = iconButton( + symbol: "doc.plaintext", + action: #selector(templateTapped(_:)), + label: AppText.localized("插入阅读笔记模板", "Insert reading note template") + ) + let image = iconButton( + symbol: "photo", + action: #selector(imageTapped(_:)), + label: AppText.localized("插入图片", "Insert image") + ) let buttons = [save, undo, redo, toolbarSeparator(), bold, italic, list, check, template, image] let stack = NSStackView(views: buttons) stack.orientation = .horizontal @@ -256,6 +285,7 @@ extension ReadingNotePanelController { askInputField.translatesAutoresizingMaskIntoConstraints = false askSendButton.image = NSImage(systemSymbolName: "arrow.up.circle.fill", accessibilityDescription: AppText.send) + askSendButton.setAccessibilityLabel(AppText.send) askSendButton.isBordered = false askSendButton.target = self askSendButton.action = #selector(submitAskQuestion(_:)) @@ -301,8 +331,8 @@ extension ReadingNotePanelController { } } - private func iconButton(symbol: String, action: Selector, pointSize: CGFloat = 15) -> NSButton { - let image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)? + private func iconButton(symbol: String, action: Selector, label: String, pointSize: CGFloat = 15) -> NSButton { + let image = NSImage(systemSymbolName: symbol, accessibilityDescription: label)? .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: pointSize, weight: .semibold)) let button = ReadingNoteIconButton(image: image ?? NSImage(), target: self, action: action) button.isBordered = false @@ -313,6 +343,8 @@ extension ReadingNotePanelController { button.translatesAutoresizingMaskIntoConstraints = false button.widthAnchor.constraint(equalToConstant: 34).isActive = true button.heightAnchor.constraint(equalToConstant: 34).isActive = true + button.setAccessibilityLabel(label) + button.toolTip = label return button } diff --git a/mac-app/ReadingNoteRowView.swift b/mac-app/ReadingNoteRowView.swift index a37503dd..00b99751 100644 --- a/mac-app/ReadingNoteRowView.swift +++ b/mac-app/ReadingNoteRowView.swift @@ -98,6 +98,7 @@ final class ReadingNoteRowView: NSView { let icon = NSImageView() icon.image = NSImage(systemSymbolName: "doc.text", accessibilityDescription: nil)? .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: Metrics.iconSymbolSize, weight: .semibold)) + icon.setAccessibilityElement(false) icon.contentTintColor = ReadingNoteTheme.accent(theme) icon.translatesAutoresizingMaskIntoConstraints = false return icon @@ -122,6 +123,7 @@ final class ReadingNoteRowView: NSView { button.toolTip = rowViewModel.isFavorite ? AppText.localized("取消收藏", "Remove favorite") : AppText.localized("收藏并置顶", "Favorite and pin") + button.setAccessibilityLabel(button.toolTip) return button } diff --git a/mac-app/ReadingNoteStore.swift b/mac-app/ReadingNoteStore.swift index 3f5b5052..4d8a02b0 100644 --- a/mac-app/ReadingNoteStore.swift +++ b/mac-app/ReadingNoteStore.swift @@ -60,8 +60,14 @@ final class ReadingNoteStore { let kind = stringColumn(statement, 3), let quote = stringColumn(statement, 4), let markdown = stringColumn(statement, 5), - let locatorJSON = stringColumn(statement, 6), - let locator = decodeLocator(locatorJSON) else { + let locatorJSON = stringColumn(statement, 6) else { + continue + } + let locator: ReadingNote.Locator + do { + locator = try decodeLocator(locatorJSON) + } catch { + NSLog("LeafReader reading notes: skipped unreadable locator (id=%@, error=%@)", id, error.localizedDescription) continue } notes.append(ReadingNote( @@ -85,6 +91,13 @@ final class ReadingNoteStore { func upsert(_ note: ReadingNote) -> Bool { locked { guard let db else { return false } + let locatorJSON: String + do { + locatorJSON = try encodeLocator(note.locator) + } catch { + NSLog("LeafReader reading notes: encode locator failed (id=%@, error=%@)", note.id, error.localizedDescription) + return false + } let sql = """ INSERT OR REPLACE INTO reading_notes( id, document_id, document_title, document_kind, quote, markdown, locator_json, created_at, updated_at, is_favorite @@ -103,7 +116,7 @@ final class ReadingNoteStore { bind(note.documentKind, at: 4, statement: statement) bind(note.quote, at: 5, statement: statement) bind(note.markdown, at: 6, statement: statement) - bind(encodeLocator(note.locator) ?? "{}", at: 7, statement: statement) + bind(locatorJSON, at: 7, statement: statement) sqlite3_bind_double(statement, 8, note.createdAt.timeIntervalSince1970) sqlite3_bind_double(statement, 9, note.updatedAt.timeIntervalSince1970) sqlite3_bind_int(statement, 10, note.isFavorite ? 1 : 0) @@ -135,6 +148,26 @@ final class ReadingNoteStore { } } + func containsNotes(documentID: String) -> Bool { + locked { + guard let db else { return false } + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT 1 FROM reading_notes WHERE document_id = ? LIMIT 1", + -1, + &statement, + nil + ) == SQLITE_OK else { + logSQLiteFailure("prepare note presence lookup") + return false + } + defer { sqlite3_finalize(statement) } + bind(documentID, at: 1, statement: statement) + return sqlite3_step(statement) == SQLITE_ROW + } + } + private func createTables() { _ = execute(sql: """ PRAGMA journal_mode = WAL; @@ -189,14 +222,19 @@ final class ReadingNoteStore { return String(cString: value) } - private func encodeLocator(_ locator: ReadingNote.Locator) -> String? { - guard let data = try? encoder.encode(locator) else { return nil } - return String(data: data, encoding: .utf8) + private func encodeLocator(_ locator: ReadingNote.Locator) throws -> String { + let data = try encoder.encode(locator) + guard let value = String(data: data, encoding: .utf8) else { + throw CocoaError(.fileWriteInapplicableStringEncoding) + } + return value } - private func decodeLocator(_ value: String) -> ReadingNote.Locator? { - guard let data = value.data(using: .utf8) else { return nil } - return try? decoder.decode(ReadingNote.Locator.self, from: data) + private func decodeLocator(_ value: String) throws -> ReadingNote.Locator { + guard let data = value.data(using: .utf8) else { + throw CocoaError(.fileReadInapplicableStringEncoding) + } + return try decoder.decode(ReadingNote.Locator.self, from: data) } private func logSQLiteFailure(_ operation: String) { diff --git a/mac-app/ReadingNotesPanelController.swift b/mac-app/ReadingNotesPanelController.swift index 6f93041d..65104135 100644 --- a/mac-app/ReadingNotesPanelController.swift +++ b/mac-app/ReadingNotesPanelController.swift @@ -105,6 +105,7 @@ final class ReadingNotesPanelController: NSObject { iconView.image = NSImage(systemSymbolName: "note.text", accessibilityDescription: nil)? .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 26, weight: .semibold)) + iconView.setAccessibilityElement(false) iconView.translatesAutoresizingMaskIntoConstraints = false titleLabel.font = AppFont.semibold(ofSize: 20) diff --git a/mac-app/RecentDocumentsPanelController+Cards.swift b/mac-app/RecentDocumentsPanelController+Cards.swift index 5c858f73..dad5f107 100644 --- a/mac-app/RecentDocumentsPanelController+Cards.swift +++ b/mac-app/RecentDocumentsPanelController+Cards.swift @@ -13,6 +13,7 @@ extension RecentDocumentsPanelController { card.translatesAutoresizingMaskIntoConstraints = false let cover = NSImageView() + cover.setAccessibilityElement(false) let coverKey = coverCacheKey(for: item) if let cachedCover = Self.coverCache[coverKey] { cover.image = cachedCover diff --git a/mac-app/RecentDocumentsPanelController.swift b/mac-app/RecentDocumentsPanelController.swift index 4e4472b5..3272cf16 100644 --- a/mac-app/RecentDocumentsPanelController.swift +++ b/mac-app/RecentDocumentsPanelController.swift @@ -101,6 +101,7 @@ final class RecentDocumentsPanelController: NSObject { let titleIcon = NSImageView() titleIcon.image = NSImage(systemSymbolName: "books.vertical", accessibilityDescription: nil)? .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 26, weight: .semibold)) + titleIcon.setAccessibilityElement(false) titleIcon.contentTintColor = primaryText titleIcon.imageScaling = .scaleNone titleIcon.translatesAutoresizingMaskIntoConstraints = false @@ -112,6 +113,7 @@ final class RecentDocumentsPanelController: NSObject { let closeButton = NSButton(title: "", target: self, action: #selector(closePanel(_:))) closeButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: AppText.close) + closeButton.setAccessibilityLabel(AppText.close) closeButton.isBordered = false closeButton.contentTintColor = primaryText closeButton.translatesAutoresizingMaskIntoConstraints = false diff --git a/mac-app/RecentDocumentsStore.swift b/mac-app/RecentDocumentsStore.swift index 1c32bfb7..8d962a0f 100644 --- a/mac-app/RecentDocumentsStore.swift +++ b/mac-app/RecentDocumentsStore.swift @@ -6,13 +6,14 @@ struct RecentDocumentItem: Codable { let kind: String let openedAt: Date let readingProgress: Double? + let documentID: String? } enum RecentDocumentsStore { private static let defaultsKey = "recentDocuments" private static let limit = 200 - static func record(url: URL, kind: ReaderDocumentKind) { + static func record(url: URL, kind: ReaderDocumentKind, documentID: String? = nil) { var items = load() let fileURL = url.standardizedFileURL let path = fileURL.path @@ -23,7 +24,8 @@ enum RecentDocumentsStore { title: fileURL.deletingPathExtension().lastPathComponent, kind: kind.displayName, openedAt: Date(), - readingProgress: nil + readingProgress: nil, + documentID: documentID ), at: 0 ) @@ -53,7 +55,8 @@ enum RecentDocumentsStore { title: fileURL.deletingPathExtension().lastPathComponent, kind: kind.displayName, openedAt: .distantPast, - readingProgress: nil + readingProgress: nil, + documentID: nil ) frontItems.append(item) items.append(item) @@ -121,7 +124,8 @@ enum RecentDocumentsStore { title: existing.title, kind: existing.kind, openedAt: existing.openedAt, - readingProgress: normalizedProgress + readingProgress: normalizedProgress, + documentID: existing.documentID ) } else { items.insert( @@ -130,7 +134,8 @@ enum RecentDocumentsStore { title: fileURL.deletingPathExtension().lastPathComponent, kind: kind.displayName, openedAt: Date(), - readingProgress: normalizedProgress + readingProgress: normalizedProgress, + documentID: nil ), at: 0 ) diff --git a/mac-app/RequestAvailabilityPolicy.swift b/mac-app/RequestAvailabilityPolicy.swift index d6803bd2..e6bec557 100644 --- a/mac-app/RequestAvailabilityPolicy.swift +++ b/mac-app/RequestAvailabilityPolicy.swift @@ -5,10 +5,7 @@ enum RequestAvailabilityPolicy { hasAPIKey } - static func shouldUseLocalDictionaryFallback( - for error: Error, - isOnline: Bool = NetworkConnectivityMonitor.shared.isOnline - ) -> Bool { - !isOnline && NetworkConnectivityMonitor.isNetworkConnectivityError(error) + static func shouldUseLocalDictionaryFallback(for error: Error) -> Bool { + NetworkConnectivityMonitor.isNetworkConnectivityError(error) } } diff --git a/mac-app/SearchOverlayView.swift b/mac-app/SearchOverlayView.swift index 18a3c28d..c265ae76 100644 --- a/mac-app/SearchOverlayView.swift +++ b/mac-app/SearchOverlayView.swift @@ -71,9 +71,24 @@ final class SearchOverlayView: NSView { separator.wantsLayer = true separator.layer?.backgroundColor = ReaderTheme.selected.searchOverlaySeparatorColor.cgColor - configureIconButton(previousButton, symbol: "chevron.up", action: #selector(previousResult)) - configureIconButton(nextButton, symbol: "chevron.down", action: #selector(nextResult)) - configureIconButton(closeButton, symbol: "xmark", action: #selector(closeSearch)) + configureIconButton( + previousButton, + symbol: "chevron.up", + action: #selector(previousResult), + label: AppText.localized("上一个搜索结果", "Previous search result") + ) + configureIconButton( + nextButton, + symbol: "chevron.down", + action: #selector(nextResult), + label: AppText.localized("下一个搜索结果", "Next search result") + ) + configureIconButton( + closeButton, + symbol: "xmark", + action: #selector(closeSearch), + label: AppText.localized("关闭搜索", "Close search") + ) for view in [searchField, resultLabel, separator, previousButton, nextButton, closeButton] { view.translatesAutoresizingMaskIntoConstraints = false @@ -111,13 +126,15 @@ final class SearchOverlayView: NSView { ]) } - private func configureIconButton(_ button: NSButton, symbol: String, action: Selector) { + private func configureIconButton(_ button: NSButton, symbol: String, action: Selector, label: String) { button.isBordered = false button.target = self button.action = action - button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) + button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) button.imageScaling = .scaleProportionallyDown button.contentTintColor = ReaderTheme.selected.secondaryTextColor + button.setAccessibilityLabel(label) + button.toolTip = label } @objc private func submitSearch() { diff --git a/mac-app/VocabularyLemmaResolver.swift b/mac-app/VocabularyLemmaResolver.swift index d75eab60..ba9bf759 100644 --- a/mac-app/VocabularyLemmaResolver.swift +++ b/mac-app/VocabularyLemmaResolver.swift @@ -206,8 +206,8 @@ enum VocabularyLemmaResolver { } if surface.hasSuffix("ing"), surface.count > 5 { let stem = String(surface.dropLast(3)) - candidates.append(stem) candidates.append(removingDoubledFinalConsonant(from: stem)) + candidates.append(stem) candidates.append(stem + "e") if stem.hasSuffix("y") { candidates.append(String(stem.dropLast()) + "ie") @@ -215,8 +215,8 @@ enum VocabularyLemmaResolver { } if surface.hasSuffix("ed"), surface.count > 4 { let stem = String(surface.dropLast(2)) - candidates.append(stem) candidates.append(removingDoubledFinalConsonant(from: stem)) + candidates.append(stem) candidates.append(stem + "e") } if surface.hasSuffix("es"), surface.count > 4 { diff --git a/mac-app/VocabularyPanelController+Build.swift b/mac-app/VocabularyPanelController+Build.swift index 49fa1588..7d7f1cf7 100644 --- a/mac-app/VocabularyPanelController+Build.swift +++ b/mac-app/VocabularyPanelController+Build.swift @@ -128,6 +128,7 @@ extension VocabularyPanelController { let icon = NSImageView() icon.image = NSImage(systemSymbolName: "text.book.closed", accessibilityDescription: nil)? .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 26, weight: .semibold)) + icon.setAccessibilityElement(false) icon.contentTintColor = owner.vocabularyAccentColor(for: theme) icon.imageScaling = .scaleNone icon.translatesAutoresizingMaskIntoConstraints = false diff --git a/mac-app/VocabularyReviewCardBuilder.swift b/mac-app/VocabularyReviewCardBuilder.swift index 568f6d58..904c669f 100644 --- a/mac-app/VocabularyReviewCardBuilder.swift +++ b/mac-app/VocabularyReviewCardBuilder.swift @@ -93,6 +93,7 @@ final class VocabularyReviewCardBuilder { button.contentTintColor = owner.vocabularyAccentColor(for: theme) button.imageScaling = .scaleProportionallyDown button.imagePosition = .imageOnly + button.setAccessibilityLabel(AppText.localized("播放单词发音", "Play word pronunciation")) button.spokenWord = spokenWord button.toolTip = AppText.localized("播放单词发音", "Play word pronunciation") button.translatesAutoresizingMaskIntoConstraints = false diff --git a/mac-app/WordRecordSQLiteRowMapper.swift b/mac-app/WordRecordSQLiteRowMapper.swift index baa86936..7929ea21 100644 --- a/mac-app/WordRecordSQLiteRowMapper.swift +++ b/mac-app/WordRecordSQLiteRowMapper.swift @@ -88,12 +88,13 @@ struct PDFWordRecordSQLiteMapper { ) } - func bind(documentID: String, record: StoredPDFWordRecord, to statement: OpaquePointer?) { + func bind(documentID: String, record: StoredPDFWordRecord, to statement: OpaquePointer?) -> Bool { + guard let boundsJSON = codec.encode(record.bounds) else { return false } bindText(documentID, at: .documentID, statement: statement) bindText(record.id, at: .id, statement: statement) bindText(record.word, at: .word, statement: statement) sqlite3_bind_int(statement, WordRecordSQLiteBindIndex.firstSourceField.rawValue, Int32(record.pageIndex)) - bindText(codec.encode(record.bounds) ?? "{}", at: .secondSourceField, statement: statement) + bindText(boundsJSON, at: .secondSourceField, statement: statement) bindOptionalText(record.context, at: .thirdSourceField, statement: statement) bindText(record.question, at: .question, statement: statement) bindText(record.answer, at: .answer, statement: statement) @@ -102,6 +103,7 @@ struct PDFWordRecordSQLiteMapper { sqlite3_bind_double(statement, WordRecordSQLiteBindIndex.createdAt.rawValue, record.createdAt.timeIntervalSince1970) bindOptionalText(codec.encode(record.srs), at: .srsJSON, statement: statement) bindOptionalText(codec.encode(record.textAnchor), at: 13, statement: statement) + return true } } @@ -161,7 +163,7 @@ struct WebWordRecordSQLiteMapper { ) } - func bind(documentID: String, record: StoredWebWordRecord, to statement: OpaquePointer?) { + func bind(documentID: String, record: StoredWebWordRecord, to statement: OpaquePointer?) -> Bool { bindText(documentID, at: .documentID, statement: statement) bindText(record.id, at: .id, statement: statement) bindText(record.word, at: .word, statement: statement) @@ -174,6 +176,7 @@ struct WebWordRecordSQLiteMapper { bindOptionalInt(record.dictionaryFrequency, at: .dictionaryFrequency, statement: statement) sqlite3_bind_double(statement, WordRecordSQLiteBindIndex.createdAt.rawValue, record.createdAt.timeIntervalSince1970) bindOptionalText(codec.encode(record.srs), at: .srsJSON, statement: statement) + return true } } diff --git a/mac-app/WordRecordSQLiteStore.swift b/mac-app/WordRecordSQLiteStore.swift index a87c9263..6b0df6be 100644 --- a/mac-app/WordRecordSQLiteStore.swift +++ b/mac-app/WordRecordSQLiteStore.swift @@ -246,6 +246,7 @@ final class WordRecordSQLiteStore { for (offset, value) in bindings.enumerated() { sqlite3_bind_text(statement, Int32(offset + 1), value, -1, WORD_RECORD_SQLITE_TRANSIENT) } + return true } } @@ -275,7 +276,7 @@ final class WordRecordSQLiteStore { sql: String, prepareOperation: String, stepOperation: String, - bind: (OpaquePointer?) -> Void + bind: (OpaquePointer?) -> Bool ) -> Bool { guard !shouldFailOperation(prepareOperation) else { logInjectedFailure(prepareOperation) @@ -287,7 +288,10 @@ final class WordRecordSQLiteStore { return false } defer { sqlite3_finalize(statement) } - bind(statement) + guard bind(statement) else { + NSLog("LeafReader word records: %@ failed because required record data could not be encoded", stepOperation) + return false + } guard !shouldFailOperation(stepOperation) else { logInjectedFailure(stepOperation) return false @@ -341,6 +345,7 @@ final class WordRecordSQLiteStore { ) { statement in sqlite3_bind_text(statement, 1, documentID, -1, WORD_RECORD_SQLITE_TRANSIENT) sqlite3_bind_text(statement, 2, id, -1, WORD_RECORD_SQLITE_TRANSIENT) + return true } } } diff --git a/release/1.0.2/Leaf Reader-1.0.2.dmg b/release/1.0.2/Leaf Reader-1.0.2.dmg deleted file mode 100644 index 17b53a08..00000000 Binary files a/release/1.0.2/Leaf Reader-1.0.2.dmg and /dev/null differ diff --git a/release/1.0.2/Leaf Reader-1.0.2.zip b/release/1.0.2/Leaf Reader-1.0.2.zip deleted file mode 100644 index 7ccf1701..00000000 Binary files a/release/1.0.2/Leaf Reader-1.0.2.zip and /dev/null differ diff --git a/release/1.0.4/Leaf Reader-1.0.4.dmg b/release/1.0.4/Leaf Reader-1.0.4.dmg deleted file mode 100644 index 3961af68..00000000 Binary files a/release/1.0.4/Leaf Reader-1.0.4.dmg and /dev/null differ diff --git a/release/1.0.4/Leaf Reader-1.0.4.zip b/release/1.0.4/Leaf Reader-1.0.4.zip deleted file mode 100644 index e7d7b442..00000000 Binary files a/release/1.0.4/Leaf Reader-1.0.4.zip and /dev/null differ diff --git a/release/1.1.1/LeafReader-1.1.1.pkg b/release/1.1.1/LeafReader-1.1.1.pkg deleted file mode 100644 index e333e178..00000000 Binary files a/release/1.1.1/LeafReader-1.1.1.pkg and /dev/null differ diff --git a/scripts/check.sh b/scripts/check.sh index 87360423..aa9154a3 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -32,6 +32,9 @@ echo "==> Checking wiki" echo "==> Checking UI theme coverage" ./scripts/check_ui_theme.sh +echo "==> Checking UI accessibility" +./scripts/check_ui_accessibility.sh + echo "==> Running tests" ./tests/run.sh diff --git a/scripts/check_ui_accessibility.sh b/scripts/check_ui_accessibility.sh new file mode 100755 index 00000000..61150330 --- /dev/null +++ b/scripts/check_ui_accessibility.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +failures=0 + +require_text() { + local file="$1" + local text="$2" + local message="$3" + if ! grep -Fq "$text" "$ROOT_DIR/$file"; then + echo "FAIL accessibility: $message" >&2 + failures=$((failures + 1)) + fi +} + +reject_text() { + local file="$1" + local text="$2" + local message="$3" + if grep -Fq "$text" "$ROOT_DIR/$file"; then + echo "FAIL accessibility: $message" >&2 + failures=$((failures + 1)) + fi +} + +reject_text \ + "mac-app/SearchOverlayView.swift" \ + "configureIconButton(_ button: NSButton, symbol: String, action: Selector)" \ + "search icon buttons must require an accessibility label" +require_text \ + "mac-app/SearchOverlayView.swift" \ + "button.setAccessibilityLabel(label)" \ + "search icon buttons must expose their label to accessibility clients" + +reject_text \ + "mac-app/ReadingNotePanelController+Build.swift" \ + "iconButton(symbol: String, action: Selector, pointSize:" \ + "reading-note icon buttons must require an accessibility label" +require_text \ + "mac-app/ReadingNotePanelController+Build.swift" \ + "button.setAccessibilityLabel(label)" \ + "reading-note icon buttons must expose their label to accessibility clients" + +reject_text \ + "mac-app/ReaderWindowController+ChromeUI.swift" \ + "iconButton(symbol: String, action: Selector)" \ + "reader chrome icon buttons must require an accessibility description" +require_text \ + "mac-app/ReaderWindowController+ChromeUI.swift" \ + "button.setAccessibilityLabel(accessibilityDescription)" \ + "reader chrome icon buttons must expose their label to accessibility clients" +require_text \ + "mac-app/AISettingsPanelController+BuildCache.swift" \ + "cacheDisclosureButton.setAccessibilityLabel(disclosureLabel)" \ + "the cache disclosure button must have an accessibility label" +require_text \ + "mac-app/ReaderWindowController+ToolbarUI.swift" \ + "relatedFormsButton.setAccessibilityLabel(title)" \ + "stateful reader controls must update their accessibility label" +require_text \ + "mac-app/AIChatPanel+UI.swift" \ + "cancelRequestButton.setAccessibilityLabel(AppText.cancel)" \ + "AI chat cancel must have an accessibility label" +require_text \ + "mac-app/AIChatPanel+UI.swift" \ + "sendButton.setAccessibilityLabel(AppText.send)" \ + "AI chat send must have an accessibility label" +require_text \ + "mac-app/ReadAloudFloatingControlView.swift" \ + "button.setAccessibilityLabel(label)" \ + "stateful floating read-aloud buttons must update their accessibility label" +require_text \ + "mac-app/ReadingNotePanelController+Build.swift" \ + "titleIconView.setAccessibilityElement(false)" \ + "decorative reading-note images must be ignored by accessibility" + +if (( failures > 0 )); then + echo "UI accessibility checks failed: $failures issue(s)." >&2 + exit 1 +fi + +echo "UI accessibility checks passed." diff --git a/scripts/check_wiki.sh b/scripts/check_wiki.sh index 4148444d..6592523d 100755 --- a/scripts/check_wiki.sh +++ b/scripts/check_wiki.sh @@ -74,6 +74,7 @@ check_generated_files() { for generated in code-map.md type-index.md index.md; do if ! diff -q "$temp_dir/$generated" "$WIKI_DIR/$generated" >/dev/null; then + diff -u "$WIKI_DIR/$generated" "$temp_dir/$generated" || true fail "$generated is stale; run ./scripts/update_wiki.sh" fi done diff --git a/scripts/generate_code_wiki.sh b/scripts/generate_code_wiki.sh index b46c635c..f8d5caad 100755 --- a/scripts/generate_code_wiki.sh +++ b/scripts/generate_code_wiki.sh @@ -6,6 +6,8 @@ OUT_DIR="${WIKI_OUT_DIR:-$ROOT_DIR/docs/wiki}" OUT_FILE="$OUT_DIR/code-map.md" TYPE_INDEX_FILE="$OUT_DIR/type-index.md" +export LC_ALL=C + mkdir -p "$OUT_DIR" count_files() { diff --git a/scripts/publish_release.sh b/scripts/publish_release.sh index 59b2cee8..a84581e1 100755 --- a/scripts/publish_release.sh +++ b/scripts/publish_release.sh @@ -1,8 +1,12 @@ #!/usr/bin/env bash set -euo pipefail -if [[ $# -lt 1 ]]; then +usage() { echo "Usage: $0 [release-notes-html-file] [--with-speech-models] [--push-wiki] [--cleanup-releases[=N]]" >&2 +} + +if [[ $# -lt 1 ]]; then + usage exit 1 fi @@ -35,13 +39,13 @@ while [[ $# -gt 0 ]]; do ;; -*) echo "Unknown option: $1" >&2 - echo "Usage: $0 [release-notes-html-file] [--with-speech-models] [--push-wiki] [--cleanup-releases[=N]]" >&2 + usage exit 1 ;; *) if [[ -n "$NOTES_FILE" ]]; then echo "Unexpected extra argument: $1" >&2 - echo "Usage: $0 [release-notes-html-file] [--with-speech-models] [--push-wiki] [--cleanup-releases[=N]]" >&2 + usage exit 1 fi NOTES_FILE="$1" @@ -49,11 +53,18 @@ while [[ $# -gt 0 ]]; do ;; esac done + ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" TAG="v$VERSION" PKG_PATH="$ROOT_DIR/release/$VERSION/LeafReader-$VERSION.pkg" RELEASE_URL="https://github.com/dowellhz/LeafReader/releases/tag/$TAG" +DOWNLOAD_URL="https://github.com/dowellhz/LeafReader/releases/download/$TAG/LeafReader-$VERSION.pkg" CHECK_SCRIPT="$ROOT_DIR/scripts/check.sh" +VERIFY_DIR="$(mktemp -d "${TMPDIR:-/private/tmp}/leafreader-release-verify.XXXXXX")" +REMOTE_TAG_PUSHED=0 +RELEASE_PUBLISHED=0 +PUBLISH_DRY_RUN="${LEAFREADER_PUBLISH_DRY_RUN:-0}" +PUBLISH_TEST_LOG="${LEAFREADER_PUBLISH_TEST_LOG:-}" SPEECH_MODEL_ASSETS=( "$ROOT_DIR/docs/tts/kokoro-coreml-macos-arm64.tar.gz" "$ROOT_DIR/docs/tts/piper-tts-macos-arm64.tar.gz" @@ -61,6 +72,160 @@ SPEECH_MODEL_ASSETS=( "$ROOT_DIR/docs/tts/speech-models-manifest.json" ) +cleanup_publish_attempt() { + local status=$? + trap - EXIT + rm -rf "$VERIFY_DIR" + if [[ "$status" -ne 0 && "$RELEASE_PUBLISHED" -eq 0 ]]; then + echo "Release failed before publication; removing staged GitHub state." >&2 + if [[ "$PUBLISH_DRY_RUN" -eq 1 ]]; then + record_publish_event "cleanup-release" + record_publish_event "cleanup-tag" + elif gh release view "$TAG" >/dev/null 2>&1; then + gh release delete "$TAG" --yes --cleanup-tag || true + elif [[ "$REMOTE_TAG_PUSHED" -eq 1 ]]; then + git push origin ":refs/tags/$TAG" || true + fi + fi + exit "$status" +} +trap cleanup_publish_attempt EXIT + +record_publish_event() { + local event="$1" + if [[ -n "$PUBLISH_TEST_LOG" ]]; then + printf '%s\n' "$event" >> "$PUBLISH_TEST_LOG" + fi +} + +inject_publish_failure_if_requested() { + local stage="$1" + if [[ "${LEAFREADER_PUBLISH_INJECT_FAILURE:-}" == "$stage" ]]; then + record_publish_event "failure:$stage" + return 97 + fi +} + +verify_release_asset() { + local source_path="$1" + local asset_name + local expected_sha + local actual_sha + if [[ "$PUBLISH_DRY_RUN" -eq 1 ]]; then + record_publish_event "asset-verified" + return + fi + asset_name="$(basename "$source_path")" + expected_sha="$(shasum -a 256 "$source_path" | awk '{print $1}')" + rm -f "$VERIFY_DIR/$asset_name" + gh release download "$TAG" --pattern "$asset_name" --dir "$VERIFY_DIR" --clobber + actual_sha="$(shasum -a 256 "$VERIFY_DIR/$asset_name" | awk '{print $1}')" + if [[ "$actual_sha" != "$expected_sha" ]]; then + echo "Release asset checksum mismatch: $asset_name" >&2 + exit 1 + fi +} + +verify_public_package() { + local expected_sha="$1" + local public_path="$VERIFY_DIR/LeafReader-$VERSION-public.pkg" + local actual_sha + if [[ "$PUBLISH_DRY_RUN" -eq 1 ]]; then + record_publish_event "public-package-verified" + return + fi + curl --fail --location --retry 5 --retry-delay 2 --retry-all-errors "$DOWNLOAD_URL" --output "$public_path" + actual_sha="$(shasum -a 256 "$public_path" | awk '{print $1}')" + if [[ "$actual_sha" != "$expected_sha" ]]; then + echo "Public release package checksum mismatch." >&2 + exit 1 + fi +} + +push_release_tag() { + if [[ "$PUBLISH_DRY_RUN" -eq 1 ]]; then + record_publish_event "tag-pushed" + else + git push origin "$TAG" + fi + REMOTE_TAG_PUSHED=1 +} + +create_draft_release() { + local release_notes="$1" + if [[ "$PUBLISH_DRY_RUN" -eq 1 ]]; then + record_publish_event "draft-created" + record_publish_event "package-uploaded" + else + gh release create "$TAG" "$PKG_PATH" --draft --title "Leaf Reader $VERSION" --notes "$release_notes" + fi +} + +upload_optional_release_asset() { + local asset="$1" + if [[ "$PUBLISH_DRY_RUN" -eq 1 ]]; then + record_publish_event "optional-asset-uploaded:$(basename "$asset")" + else + gh release upload "$TAG" "$asset" --clobber + fi +} + +publish_draft_release() { + if [[ "$PUBLISH_DRY_RUN" -eq 1 ]]; then + record_publish_event "release-published" + else + gh release edit "$TAG" --draft=false + fi +} + +push_release_commit() { + if [[ "$PUBLISH_DRY_RUN" -eq 1 ]]; then + record_publish_event "main-pushed" + else + git push origin main + fi +} + +publish_release_transaction() { + local release_notes="Leaf Reader $VERSION release. + +SHA256: $SHA256" + push_release_tag + inject_publish_failure_if_requested "release-creation" + create_draft_release "$release_notes" + inject_publish_failure_if_requested "package-upload" + inject_publish_failure_if_requested "asset-verification" + verify_release_asset "$PKG_PATH" + + if [[ "$UPLOAD_SPEECH_MODELS" -eq 1 ]]; then + for asset in "${SPEECH_MODEL_ASSETS[@]}"; do + if [[ "$PUBLISH_DRY_RUN" -ne 1 && ! -f "$asset" ]]; then + echo "Missing speech model asset: $asset" >&2 + exit 1 + fi + upload_optional_release_asset "$asset" + verify_release_asset "$asset" + done + else + echo "Skipping speech model assets; app code points to $RUNTIME_ASSETS_RELEASE_TAG." + fi + + inject_publish_failure_if_requested "final-publish" + publish_draft_release + inject_publish_failure_if_requested "public-verification" + verify_public_package "$SHA256" + RELEASE_PUBLISHED=1 + inject_publish_failure_if_requested "main-push" + push_release_commit +} + +if [[ "$PUBLISH_DRY_RUN" -eq 1 ]]; then + RUNTIME_ASSETS_RELEASE_TAG="test-assets" + SHA256="dry-run-sha256" + publish_release_transaction + exit 0 +fi + cd "$ROOT_DIR" if [[ ! "$VERSION" =~ ^[0-9]+(\.[0-9]+)+$ ]]; then @@ -71,33 +236,28 @@ if [[ ! "$CLEANUP_KEEP" =~ ^[0-9]+$ || "$CLEANUP_KEEP" -lt 1 ]]; then echo "--cleanup-releases keep count must be a positive integer" >&2 exit 1 fi - if [[ -n "$(git status --porcelain)" ]]; then echo "Working tree is not clean. Commit or stash current changes before publishing $VERSION." >&2 git status --short exit 1 fi - -if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "Tag already exists locally: $TAG" >&2 - exit 1 -fi - if git ls-remote --exit-code --tags origin "$TAG" >/dev/null 2>&1; then echo "Tag already exists on origin: $TAG" >&2 + echo "If the release is already public, recover by pushing main instead of rerunning publication." >&2 exit 1 fi - if ! command -v gh >/dev/null 2>&1; then echo "GitHub CLI is required: gh" >&2 exit 1 fi - if ! gh api user >/dev/null 2>&1; then echo "GitHub CLI cannot access GitHub API. Run: gh auth login -h github.com" >&2 exit 1 fi - +if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release already exists: $TAG" >&2 + exit 1 +fi if ! grep -q "## What's New in $VERSION" README.md; then echo "README.md must include release notes section: ## What's New in $VERSION" >&2 exit 1 @@ -108,16 +268,12 @@ if [[ -z "$RUNTIME_ASSETS_RELEASE_TAG" ]]; then echo "Unable to read SpeechRuntimeModel.runtimeAssetsReleaseTag" >&2 exit 1 fi -# Speech models are not uploaded for normal app releases; their download tag is -# intentionally allowed to lag behind the app version until model archives change. if [[ "$RUNTIME_ASSETS_RELEASE_TAG" == "$TAG" && "$UPLOAD_SPEECH_MODELS" -ne 1 ]]; then echo "SpeechRuntimeModel.runtimeAssetsReleaseTag points at $TAG, but --with-speech-models was not provided." >&2 - echo "Either publish the changed model archives with --with-speech-models, or keep runtimeAssetsReleaseTag pointed at the existing model asset release." >&2 exit 1 fi if [[ "$UPLOAD_SPEECH_MODELS" -eq 1 && "$RUNTIME_ASSETS_RELEASE_TAG" != "$TAG" ]]; then - echo "Refusing to upload speech model assets to $TAG because SpeechRuntimeModel.runtimeAssetsReleaseTag is $RUNTIME_ASSETS_RELEASE_TAG." >&2 - echo "Update runtimeAssetsReleaseTag to $TAG when publishing changed model archives." >&2 + echo "Refusing to upload speech model assets to $TAG because runtimeAssetsReleaseTag is $RUNTIME_ASSETS_RELEASE_TAG." >&2 exit 1 fi @@ -134,45 +290,38 @@ if [[ ! -f "$PKG_PATH" ]]; then echo "Expected release package not found: $PKG_PATH" >&2 exit 1 fi - ./scripts/smoke_release_pkg.sh "$VERSION" ./scripts/release_size_report.sh "$VERSION" - SHA256="$(shasum -a 256 "$PKG_PATH" | awk '{print $1}')" git add README.md docs/appcast.xml docs/index.html mac-app/Info.plist -git commit -m "Release $VERSION" -git tag "$TAG" -git push origin main -git push origin "$TAG" - -RELEASE_NOTES="Leaf Reader $VERSION release. - -SHA256: $SHA256" -gh release create "$TAG" "$PKG_PATH" --title "Leaf Reader $VERSION" --notes "$RELEASE_NOTES" - -curl -I -L "https://github.com/dowellhz/LeafReader/releases/download/$TAG/LeafReader-$VERSION.pkg" >/dev/null +if git diff --cached --quiet; then + if [[ "$(git show -s --format=%s HEAD)" != "Release $VERSION" ]]; then + echo "No release changes to commit and HEAD is not the expected release commit." >&2 + exit 1 + fi +else + git commit -m "Release $VERSION" +fi -if [[ "$UPLOAD_SPEECH_MODELS" -eq 1 ]]; then - for asset in "${SPEECH_MODEL_ASSETS[@]}"; do - if [[ ! -f "$asset" ]]; then - echo "Missing speech model asset: $asset" >&2 - exit 1 - fi - gh release upload "$TAG" "$asset" --clobber - curl -I -L "https://github.com/dowellhz/LeafReader/releases/download/$TAG/$(basename "$asset")" >/dev/null - done +if git rev-parse "$TAG" >/dev/null 2>&1; then + if [[ "$(git rev-parse "$TAG^{commit}")" != "$(git rev-parse HEAD)" ]]; then + echo "Local tag $TAG does not point at the release commit." >&2 + exit 1 + fi else - echo "Skipping speech model assets; app code points to $RUNTIME_ASSETS_RELEASE_TAG." - echo "Use --with-speech-models only when model archives changed and SpeechRuntimeModel.runtimeAssetsReleaseTag was updated." + git tag "$TAG" fi +# The appcast is already in the release commit. The transaction exposes it on +# main only after release assets and the public package have been verified. +publish_release_transaction + if [[ "$PUSH_WIKI" -eq 1 ]]; then ./scripts/update_wiki.sh --push else echo "Skipping GitHub Wiki push. Use --push-wiki to sync docs/wiki after publishing." fi - if [[ "$CLEANUP_RELEASES" -eq 1 ]]; then ./scripts/cleanup_releases.sh --keep "$CLEANUP_KEEP" --apply else diff --git a/scripts/release_pkg.sh b/scripts/release_pkg.sh index ee12b6f3..337d2b19 100755 --- a/scripts/release_pkg.sh +++ b/scripts/release_pkg.sh @@ -10,8 +10,9 @@ VERSION="$1" NOTES_FILE="${2:-}" ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" APP_PATH="$ROOT_DIR/Leaf Reader.app" -PKG_ROOT="/private/tmp/leafreader-pkg-root-$VERSION" -COMPONENT_PLIST="/private/tmp/leafreader-components-$VERSION.plist" +RELEASE_TEMP_DIR="$(mktemp -d "${TMPDIR:-/private/tmp}/leafreader-release-pkg.XXXXXX")" +PKG_ROOT="$RELEASE_TEMP_DIR/pkg-root" +COMPONENT_PLIST="$RELEASE_TEMP_DIR/components.plist" RELEASE_DIR="$ROOT_DIR/release/$VERSION" UNSIGNED_PKG="$RELEASE_DIR/LeafReader-$VERSION-unsigned.pkg" SIGNED_PKG="$RELEASE_DIR/LeafReader-$VERSION.pkg" @@ -29,6 +30,21 @@ CONFIG_SPARKLE_KEY_FILE="${SPARKLE_KEY_CONFIG_FILE:-$HOME/.config/leafreader/spa LOCAL_SPARKLE_KEY_FILE="$ROOT_DIR/sparkle-ed25519-private-key" export COPYFILE_DISABLE=1 +cleanup_release_temp() { + rm -rf "$RELEASE_TEMP_DIR" +} +trap cleanup_release_temp EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +if [[ -n "${LEAFREADER_RELEASE_PKG_TEST_TEMP_LOG:-}" ]]; then + printf '%s\n' "$RELEASE_TEMP_DIR" > "$LEAFREADER_RELEASE_PKG_TEST_TEMP_LOG" +fi +if [[ "${LEAFREADER_RELEASE_PKG_INJECT_FAILURE:-}" == "after-temp-setup" ]]; then + echo "Injected release package failure after temporary workspace setup." >&2 + exit 97 +fi + if [[ ! -x "$SIGN_UPDATE" ]]; then echo "Sparkle sign_update not found at $SIGN_UPDATE" >&2 exit 1 @@ -52,9 +68,8 @@ fi APP_SIGN_IDENTITY="$APP_SIGN_IDENTITY" REQUIRE_BUNDLED_SPEECH_RUNTIMES=1 "$BUILD_SCRIPT" --release --universal -rm -rf "$PKG_ROOT" mkdir -p "$PKG_ROOT/Applications" "$RELEASE_DIR" -rm -f "$UNSIGNED_PKG" "$SIGNED_PKG" "$COMPONENT_PLIST" +rm -f "$UNSIGNED_PKG" "$SIGNED_PKG" cp -R "$APP_PATH" "$PKG_ROOT/Applications/" find "$PKG_ROOT" -name '._*' -type f -delete xattr -cr "$PKG_ROOT" diff --git a/tests/AISettingsLogicTests.swift b/tests/AISettingsLogicTests.swift index 2fdeb67f..544d8a8d 100644 --- a/tests/AISettingsLogicTests.swift +++ b/tests/AISettingsLogicTests.swift @@ -115,7 +115,11 @@ enum AISettingsLogicTests { AISettingsStore.save(modelID: AISettingsStore.ollamaModelID, apiKey: "", customModelName: " qwen2.5:7b ") try expectEqual(AISettingsStore.selectedModel.model, "qwen2.5:7b", "Ollama model name should be editable and trimmed") try expectEqual(AISettingsStore.customModelName, "custom-chat", "Ollama model saving should not overwrite Other model name") - try expectEqual(AISettingsStore.ollamaValidationError(modelName: " "), "请输入 Ollama 模型 ID。", "blank Ollama model names should be rejected") + try expectEqual( + AISettingsStore.ollamaValidationError(modelName: " "), + AppText.localized("请输入 Ollama 模型 ID。", "Enter an Ollama model ID."), + "blank Ollama model names should be rejected" + ) let customIndex = AISettingsStore.models.firstIndex { $0.id == AISettingsStore.customModelID } let ollamaIndex = AISettingsStore.models.firstIndex { $0.id == AISettingsStore.ollamaModelID } @@ -136,8 +140,16 @@ enum AISettingsLogicTests { try expectEqual(AISettingsStore.selectedModel.endpoint.absoluteString, "http://127.0.0.1:8000/v1/chat/completions", "local OpenAI-compatible /v1 endpoint should be expanded to chat completions") try expectEqual(AISettingsStore.selectedModel.model, "local-model", "local OpenAI-compatible model name should be editable and trimmed") try expectEqual(AISettingsStore.apiKey(for: AISettingsStore.selectedModel), "local-key", "local OpenAI-compatible API key should be stored separately") - try expectEqual(AISettingsStore.localOpenAIValidationError(endpoint: " ", modelName: "local-model"), "请输入本地 OpenAI 兼容 URL。", "blank local OpenAI-compatible endpoints should be rejected") - try expectEqual(AISettingsStore.localOpenAIValidationError(endpoint: "http://127.0.0.1:8000/v1", modelName: " "), "请输入模型 ID。", "blank local OpenAI-compatible model names should be rejected") + try expectEqual( + AISettingsStore.localOpenAIValidationError(endpoint: " ", modelName: "local-model"), + AppText.localized("请输入本地 OpenAI 兼容 URL。", "Enter a local OpenAI-compatible URL."), + "blank local OpenAI-compatible endpoints should be rejected" + ) + try expectEqual( + AISettingsStore.localOpenAIValidationError(endpoint: "http://127.0.0.1:8000/v1", modelName: " "), + AppText.localized("请输入模型 ID。", "Enter a model ID."), + "blank local OpenAI-compatible model names should be rejected" + ) } } @@ -241,7 +253,7 @@ enum AISettingsLogicTests { let timeout = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) try expect( - AIRequestErrorText.message(for: timeout).contains("超时"), + AIRequestErrorText.message(for: timeout).contains(AppText.localized("超时", "timed out")), "timeout errors should use a timeout-specific message" ) @@ -249,7 +261,7 @@ enum AISettingsLogicTests { NSLocalizedDescriptionKey: "OpenAI HTTP 429: rate_limit_exceeded" ]) try expect( - AIRequestErrorText.message(for: rateLimit).contains("请求太频繁"), + AIRequestErrorText.message(for: rateLimit).contains(AppText.localized("请求太频繁", "Too many requests")), "rate limits should avoid the generic AI failure message" ) @@ -257,7 +269,7 @@ enum AISettingsLogicTests { NSLocalizedDescriptionKey: "Unexpected response: {}" ]) try expect( - AIRequestErrorText.message(for: localUnexpected).contains("本地服务"), + AIRequestErrorText.message(for: localUnexpected).contains(AppText.localized("本地服务", "local service")), "local model response failures should explain the local service compatibility issue" ) } diff --git a/tests/LogicTests.swift b/tests/LogicTests.swift index c45e80f8..5c56a57d 100644 --- a/tests/LogicTests.swift +++ b/tests/LogicTests.swift @@ -155,6 +155,7 @@ private let tests: [(String, () throws -> Void)] = [ ("AI conversation markdown exporter", testAIConversationMarkdownExporter), ("Embedding action policy", testEmbeddingActionPolicy), ("Selection toolbar configuration", VocabularyLogicTests.testSelectionToolbarConfiguration), + ("Local dictionary fallback request failure", VocabularyLogicTests.testLocalDictionaryFallbackUsesActualRequestFailure), ("Vocabulary review display record loader", VocabularyLogicTests.testVocabularyReviewDisplayRecordLoaderLoadsOnlyCurrentRecord), ("Reading context snapshot", testReadingContextSnapshot), ("Reader focused selection priority", testReaderFocusedSelectionPriority), diff --git a/tests/ReadingNoteLogicTests.swift b/tests/ReadingNoteLogicTests.swift index c695f235..f429cd83 100644 --- a/tests/ReadingNoteLogicTests.swift +++ b/tests/ReadingNoteLogicTests.swift @@ -69,6 +69,30 @@ enum ReadingNoteLogicTests { try expectEqual(loaded.count, 1, "upsert should replace the existing note") try expectEqual(loaded[0].markdown, "Updated\n", "updated note should preserve markdown") try expect(loaded[0].isFavorite, "updated note should preserve favorite state") + try expect(store.containsNotes(documentID: "doc-1"), "note presence lookup should find durable records") + + let invalidReplacement = ReadingNote( + id: note.id, + documentID: note.documentID, + documentTitle: note.documentTitle, + documentKind: "epub", + quote: note.quote, + markdown: "Must not replace", + locator: ReadingNote.Locator( + pdfFragments: nil, + webAnchor: ReadingNote.WebAnchor( + selectedText: "selection", + context: "context", + occurrenceIndex: 0, + scrollProgress: .nan + ) + ), + createdAt: note.createdAt, + updatedAt: note.updatedAt + ) + try expect(!store.upsert(invalidReplacement), "invalid required locator JSON should reject the replacement") + loaded = store.load(documentID: "doc-1") + try expectEqual(loaded.first?.markdown, "Updated\n", "failed locator encoding must preserve the existing note") try expect(store.delete(id: "note-1"), "reading note should delete") try expectEqual(store.load(documentID: "doc-1").count, 0, "deleted note should no longer load") @@ -228,7 +252,10 @@ enum ReadingNoteLogicTests { try expectEqual(titleMatches.map(\.id), ["note-new"], "reading note search should match title text") let quoteMatches = ReadingNoteListPresenter.rows(for: [newer, older], query: "pdf fallback") try expectEqual(quoteMatches.map(\.id), ["note-old"], "reading note search should match quote text") - let locationMatches = ReadingNoteListPresenter.rows(for: [newer, older], query: "第 7") + let locationMatches = ReadingNoteListPresenter.rows( + for: [newer, older], + query: AppText.localized("第 7", "p. 7") + ) try expectEqual(locationMatches.map(\.id), ["note-old"], "reading note search should match location text") } @@ -373,12 +400,16 @@ enum ReadingNoteLogicTests { NSLocalizedDescriptionKey: "OpenAI HTTP 429: rate_limit_exceeded" ]) try expect( - ReadingNoteAITextPolicy.userFacingError(rateLimit).contains("请求太频繁"), + ReadingNoteAITextPolicy.userFacingError(rateLimit).contains( + AppText.localized("请求太频繁", "Too many requests") + ), "reading-note AI errors should use the shared request failure classifier" ) try expect( - ReadingNoteAITextPolicy.emptyOutputMessage().contains("没有返回内容"), + ReadingNoteAITextPolicy.emptyOutputMessage().contains( + AppText.localized("没有返回内容", "returned no content") + ), "empty AI responses should have a specific recovery message" ) } diff --git a/tests/RegressionTests.swift b/tests/RegressionTests.swift index 0c0aef36..a8426c82 100644 --- a/tests/RegressionTests.swift +++ b/tests/RegressionTests.swift @@ -92,6 +92,78 @@ private func testDocumentIdentityFastIDIsStableAndNotMD5Length() throws { try expectEqual(firstID.count, 37, "fast document ID should use the fast- prefix plus a 16-byte hex hash") } +private func testDocumentContentIdentityAndLegacyMapping() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("leafreader-content-id-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let originalURL = directory.appendingPathComponent("original.pdf") + let movedURL = directory.appendingPathComponent("moved.pdf") + let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) + try Data("AAAA".utf8).write(to: originalURL) + try FileManager.default.setAttributes([.modificationDate: fixedDate], ofItemAtPath: originalURL.path) + let firstIdentifiers = try DocumentIdentity.contentIdentifiers(for: originalURL) + let firstContentID = firstIdentifiers.contentID + try expectEqual( + firstIdentifiers.legacyMD5, + "098890dde069e9abad63f19a0d9e1f32", + "content identity should calculate the legacy MD5 in the same streaming pass" + ) + try FileManager.default.copyItem(at: originalURL, to: movedURL) + try expectEqual( + try DocumentIdentity.contentID(for: movedURL), + firstContentID, + "renaming identical bytes should preserve content identity" + ) + + let metadataID = DocumentIdentity.fastID(for: originalURL) + try Data("BBBB".utf8).write(to: originalURL) + try FileManager.default.setAttributes([.modificationDate: fixedDate], ofItemAtPath: originalURL.path) + let replacementContentID = try DocumentIdentity.contentID(for: originalURL) + try expect(firstContentID != replacementContentID, "equal-size replacement bytes should change content identity") + try expectEqual(DocumentIdentity.fastID(for: originalURL), metadataID, "fixture should preserve legacy metadata identity") + try expect( + DocumentIdentity.migrationMetadataID( + fastID: metadataID, + cachedLegacyID: firstIdentifiers.legacyMD5, + computedLegacyID: "f50881ced34c7d9e6bce100bf33dec60" + ) == nil, + "metadata state should not migrate when cached content proof contradicts current bytes" + ) + + let suiteName = "LeafReader.DocumentIdentityTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw TestFailure(description: "document identity test defaults should open") + } + defer { defaults.removePersistentDomain(forName: suiteName) } + let storedIDs = Set([metadataID]) + let migratedID = DocumentIdentity.storageID( + contentID: firstContentID, + metadataID: metadataID, + legacyID: nil, + defaults: defaults, + hasStoredData: storedIDs.contains + ) + try expectEqual(migratedID, metadataID, "first content open should preserve the existing legacy namespace") + let movedID = DocumentIdentity.storageID( + contentID: firstContentID, + metadataID: DocumentIdentity.fastID(for: movedURL), + legacyID: nil, + defaults: defaults, + hasStoredData: { _ in false } + ) + try expectEqual(movedID, metadataID, "moved identical content should reuse the registered legacy namespace") + let replacementID = DocumentIdentity.storageID( + contentID: replacementContentID, + metadataID: metadataID, + legacyID: nil, + defaults: defaults, + hasStoredData: storedIDs.contains + ) + try expectEqual(replacementID, replacementContentID, "replacement content must not inherit another content owner's legacy state") +} + private func testAIConversationMergeKeepsUnloadedHistory() throws { let loaded = SavedAIConversation(bubbles: [ bubble("user", "old question"), @@ -271,6 +343,8 @@ struct RegressionTestRunner { print("PASS Fast document ID legacy compatibility") try testDocumentIdentityFastIDIsStableAndNotMD5Length() print("PASS Fast document ID stability") + try testDocumentContentIdentityAndLegacyMapping() + print("PASS content document identity and legacy mapping") try testAIConversationMergeKeepsUnloadedHistory() print("PASS AI conversation lazy-save merge") try testAIConversationMergeTrimsToLimitAfterPreservingNewest() diff --git a/tests/ReleaseAutomationTests.sh b/tests/ReleaseAutomationTests.sh new file mode 100755 index 00000000..c314b4d1 --- /dev/null +++ b/tests/ReleaseAutomationTests.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PUBLISH_SCRIPT="$ROOT_DIR/scripts/publish_release.sh" +PKG_SCRIPT="$ROOT_DIR/scripts/release_pkg.sh" +WORKFLOW="$ROOT_DIR/.github/workflows/app.yml" +failures=0 + +transaction_line_number() { + local pattern="$1" + awk -v pattern="$pattern" ' + /^publish_release_transaction\(\)/ { in_transaction = 1; next } + in_transaction && index($0, pattern) { print NR; exit } + ' "$PUBLISH_SCRIPT" +} + +require_order() { + local earlier_pattern="$1" + local later_pattern="$2" + local description="$3" + local earlier + local later + earlier="$(transaction_line_number "$earlier_pattern")" + later="$(transaction_line_number "$later_pattern")" + if [[ -z "$earlier" || -z "$later" || "$earlier" -ge "$later" ]]; then + echo "FAIL release automation: $description" >&2 + failures=$((failures + 1)) + fi +} + +require_order 'create_draft_release "$release_notes"' 'verify_release_asset "$PKG_PATH"' \ + "the draft package must be uploaded before checksum verification" +require_order 'verify_release_asset "$PKG_PATH"' 'publish_draft_release' \ + "the uploaded package must be verified before publication" +require_order 'verify_public_package "$SHA256"' 'push_release_commit' \ + "the public package must be verified before the appcast commit reaches main" + +if ! grep -Fq 'mktemp -d' "$PKG_SCRIPT" || ! grep -Fq 'trap cleanup_release_temp EXIT' "$PKG_SCRIPT"; then + echo "FAIL release automation: package staging must use an owned temporary directory with an EXIT trap" >&2 + failures=$((failures + 1)) +fi +if ! grep -Fq "trap 'exit 130' INT" "$PKG_SCRIPT" || ! grep -Fq "trap 'exit 143' TERM" "$PKG_SCRIPT"; then + echo "FAIL release automation: package staging must clean up after interruption and termination" >&2 + failures=$((failures + 1)) +fi +if grep -Eq 'PKG_ROOT="/private/tmp|COMPONENT_PLIST="/private/tmp' "$PKG_SCRIPT"; then + echo "FAIL release automation: package staging paths must not be predictable" >&2 + failures=$((failures + 1)) +fi +if git -C "$ROOT_DIR" ls-files | grep -Eq '\.(dmg|pkg|zip)$'; then + echo "FAIL release automation: binary installers must be published as Release assets, not tracked by Git" >&2 + failures=$((failures + 1)) +fi +if [[ ! -f "$WORKFLOW" ]] || ! grep -Fq './scripts/check.sh --no-build' "$WORKFLOW" \ + || ! grep -Fq 'brew install ripgrep' "$WORKFLOW" \ + || ! grep -Fq 'macos-15-intel' "$WORKFLOW" \ + || ! grep -Fq './scripts/build_app.sh --debug --archs' "$WORKFLOW" \ + || ! grep -Fq 'needs: architecture-build' "$WORKFLOW"; then + echo "FAIL release automation: app CI must run standard checks and native ARM/Intel builds" >&2 + failures=$((failures + 1)) +fi + +failure_test_root="$(mktemp -d "${TMPDIR:-/private/tmp}/leafreader-release-failure-test.XXXXXX")" +failure_temp_log="$failure_test_root/workspace.txt" +unrelated_sentinel="$failure_test_root/unrelated" +touch "$unrelated_sentinel" +set +e +LEAFREADER_RELEASE_PKG_TEST_TEMP_LOG="$failure_temp_log" \ + LEAFREADER_RELEASE_PKG_INJECT_FAILURE=after-temp-setup \ + "$PKG_SCRIPT" 0.0-test >/dev/null 2>&1 +failure_status=$? +set -e +if [[ "$failure_status" -ne 97 || ! -f "$failure_temp_log" ]]; then + echo "FAIL release automation: injected package failure did not run as expected" >&2 + failures=$((failures + 1)) +else + failure_workspace="$(head -1 "$failure_temp_log")" + if [[ -e "$failure_workspace" ]]; then + echo "FAIL release automation: injected failure left its temporary workspace behind" >&2 + failures=$((failures + 1)) + fi +fi +if [[ ! -f "$unrelated_sentinel" ]]; then + echo "FAIL release automation: package cleanup touched an unrelated path" >&2 + failures=$((failures + 1)) +fi +rm -rf "$failure_test_root" + +transaction_test_root="$(mktemp -d "${TMPDIR:-/private/tmp}/leafreader-publish-transaction-test.XXXXXX")" +for stage in release-creation package-upload asset-verification final-publish public-verification; do + transaction_log="$transaction_test_root/$stage.log" + set +e + LEAFREADER_PUBLISH_DRY_RUN=1 \ + LEAFREADER_PUBLISH_TEST_LOG="$transaction_log" \ + LEAFREADER_PUBLISH_INJECT_FAILURE="$stage" \ + "$PUBLISH_SCRIPT" 0.0-test >/dev/null 2>&1 + transaction_status=$? + set -e + if [[ "$transaction_status" -ne 97 ]] \ + || ! grep -Fxq 'cleanup-release' "$transaction_log" \ + || ! grep -Fxq 'cleanup-tag' "$transaction_log" \ + || grep -Fxq 'main-pushed' "$transaction_log"; then + echo "FAIL release automation: $stage failure did not preserve the current public appcast" >&2 + failures=$((failures + 1)) + fi +done + +main_push_log="$transaction_test_root/main-push.log" +set +e +LEAFREADER_PUBLISH_DRY_RUN=1 \ + LEAFREADER_PUBLISH_TEST_LOG="$main_push_log" \ + LEAFREADER_PUBLISH_INJECT_FAILURE=main-push \ + "$PUBLISH_SCRIPT" 0.0-test >/dev/null 2>&1 +main_push_status=$? +set -e +if [[ "$main_push_status" -ne 97 ]] \ + || ! grep -Fxq 'release-published' "$main_push_log" \ + || ! grep -Fxq 'public-package-verified' "$main_push_log" \ + || grep -Fxq 'cleanup-release' "$main_push_log" \ + || grep -Fxq 'main-pushed' "$main_push_log"; then + echo "FAIL release automation: main-push failure did not leave a verified release with the old appcast" >&2 + failures=$((failures + 1)) +fi + +success_log="$transaction_test_root/success.log" +LEAFREADER_PUBLISH_DRY_RUN=1 \ + LEAFREADER_PUBLISH_TEST_LOG="$success_log" \ + "$PUBLISH_SCRIPT" 0.0-test >/dev/null +if ! grep -Fxq 'public-package-verified' "$success_log" \ + || ! grep -Fxq 'main-pushed' "$success_log" \ + || grep -Fxq 'cleanup-release' "$success_log"; then + echo "FAIL release automation: successful dry run did not publish assets before main" >&2 + failures=$((failures + 1)) +fi +rm -rf "$transaction_test_root" + +if (( failures > 0 )); then + echo "Release automation checks failed: $failures issue(s)." >&2 + exit 1 +fi + +echo "Release automation checks passed." diff --git a/tests/SQLiteWordRecordStoreTests.swift b/tests/SQLiteWordRecordStoreTests.swift index a5828f59..fbc9979a 100644 --- a/tests/SQLiteWordRecordStoreTests.swift +++ b/tests/SQLiteWordRecordStoreTests.swift @@ -24,13 +24,14 @@ private func pdfRecord( answer: String, createdAt: TimeInterval, textAnchor: TextQuoteAnchor? = nil, - srs: VocabularySRSState? = nil + srs: VocabularySRSState? = nil, + bounds: CGRect = CGRect(x: 10, y: 20, width: 30, height: 12) ) -> StoredPDFWordRecord { StoredPDFWordRecord( id: id, word: word, pageIndex: 4, - bounds: StoredPDFWordRect(CGRect(x: 10, y: 20, width: 30, height: 12)), + bounds: StoredPDFWordRect(bounds), textAnchor: textAnchor, context: "pdf context", question: "What is \(word)?", @@ -184,6 +185,19 @@ struct SQLiteWordRecordStoreTestRunner { assert(batchCommitFailureStore.loadPDFRecords(documentID: batchDocumentID).first?.answer == "keep", "batch COMMIT failure should roll back the update") } + do { + let invalidEncodingStore = WordRecordSQLiteStore(databaseURL: dbURL) + let invalidReplacement = pdfRecord( + id: "batch-original", + word: "stable", + answer: "must-not-replace", + createdAt: 4, + bounds: CGRect(x: CGFloat.nan, y: 0, width: 10, height: 10) + ) + assert(!invalidEncodingStore.upsertPDFRecord(documentID: batchDocumentID, record: invalidReplacement), "required bounds encoding failure should reject the upsert") + assert(invalidEncodingStore.loadPDFRecords(documentID: batchDocumentID).first?.answer == "keep", "encoding failure should preserve the existing PDF row") + } + let legacyDirectory = FileManager.default.temporaryDirectory .appendingPathComponent("leafreader-word-anchor-migration-\(UUID().uuidString)") let legacyDatabaseURL = legacyDirectory.appendingPathComponent("word-records.sqlite3") diff --git a/tests/SpeechRuntimeAvailabilityTests.swift b/tests/SpeechRuntimeAvailabilityTests.swift index d47324d5..fde28114 100644 --- a/tests/SpeechRuntimeAvailabilityTests.swift +++ b/tests/SpeechRuntimeAvailabilityTests.swift @@ -32,7 +32,7 @@ enum SpeechRuntimeAvailabilityTests { ) try expectEqual( LocalRuntimeStatusPresenter.statusText(downloading), - "下载中 · 约 112 MB", + AppText.localized("下载中 · 约 112 MB", "Downloading · 约 112 MB"), "generic local runtime presenter should format active downloads" ) @@ -47,7 +47,7 @@ enum SpeechRuntimeAvailabilityTests { ) try expectEqual( LocalRuntimeStatusPresenter.statusText(missingRuntime), - "缺少运行时 · 模型已安装 · 约 112 MB", + AppText.localized("缺少运行时 · 模型已安装 · 约 112 MB", "Missing runtime · Model installed · 约 112 MB"), "generic local runtime presenter should distinguish missing runtime from missing model" ) @@ -62,7 +62,7 @@ enum SpeechRuntimeAvailabilityTests { ) try expectEqual( LocalRuntimeStatusPresenter.statusText(missingModel), - "运行时已安装 · 缺少模型 · 约 112 MB", + AppText.localized("运行时已安装 · 缺少模型 · 约 112 MB", "Runtime installed · Missing model · 约 112 MB"), "generic local runtime presenter should explain missing model repair state" ) @@ -77,7 +77,10 @@ enum SpeechRuntimeAvailabilityTests { ) try expectEqual( LocalRuntimeStatusPresenter.statusText(missingRuntimeAndModel), - "缺少运行时和模型 · 模型中等,英语质量好 · 约 112 MB", + AppText.localized( + "缺少运行时和模型 · 模型中等,英语质量好 · 约 112 MB", + "Missing runtime and model · Medium model, good English quality · 约 112 MB" + ), "generic local runtime presenter should explain missing runtime and model state" ) @@ -91,7 +94,9 @@ enum SpeechRuntimeAvailabilityTests { inferenceFailureText: nil ) try expect( - LocalRuntimeStatusPresenter.statusText(unsupportedFailure).contains("上次失败:network failed"), + LocalRuntimeStatusPresenter.statusText(unsupportedFailure).contains( + AppText.localized("上次失败:network failed", "Last failed: network failed") + ), "generic local runtime presenter should include download failure details" ) } @@ -222,17 +227,20 @@ enum SpeechRuntimeAvailabilityTests { try expectEqual( SpeechRuntimeResourceManager.incompleteInstallStatusText(for: .piper, installState: .missingRuntime), - "缺少运行时 · 模型已安装 · 约 112 MB", + AppText.localized("缺少运行时 · 模型已安装 · 约 112 MB", "Missing runtime · Model installed · 约 112 MB"), "missing runtime should surface the new repair-oriented status copy" ) try expectEqual( SpeechRuntimeResourceManager.incompleteInstallStatusText(for: .piper, installState: .missingModel), - "运行时已安装 · 缺少模型 · 约 112 MB", + AppText.localized("运行时已安装 · 缺少模型 · 约 112 MB", "Runtime installed · Missing model · 约 112 MB"), "missing model should surface the repair-oriented status copy" ) try expectEqual( SpeechRuntimeResourceManager.incompleteInstallStatusText(for: .piper, installState: .missingRuntimeAndModel), - "缺少运行时和模型 · 模型中等,英语质量好 · 约 112 MB", + AppText.localized( + "缺少运行时和模型 · 模型中等,英语质量好 · 约 112 MB", + "Missing runtime and model · Medium model, good English quality · 约 112 MB" + ), "missing runtime and model should surface the repair-oriented status copy" ) } diff --git a/tests/VocabularyLogicTests.swift b/tests/VocabularyLogicTests.swift index 348014af..f0ecbabd 100644 --- a/tests/VocabularyLogicTests.swift +++ b/tests/VocabularyLogicTests.swift @@ -444,16 +444,16 @@ enum VocabularyLogicTests { ) try expectEqual(full.displayMode, .full(showsSpeak: true), "configured online state should expose the full toolbar") } - - static func testLocalDictionaryFallbackRequiresOfflineState() throws { + static func testLocalDictionaryFallbackUsesActualRequestFailure() throws { let timeout = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) try expect( - RequestAvailabilityPolicy.shouldUseLocalDictionaryFallback(for: timeout, isOnline: false), - "offline network errors should use local dictionary fallback" + RequestAvailabilityPolicy.shouldUseLocalDictionaryFallback(for: timeout), + "network request failures should use local dictionary fallback" ) + let serverError = NSError(domain: NSURLErrorDomain, code: NSURLErrorBadServerResponse) try expect( - !RequestAvailabilityPolicy.shouldUseLocalDictionaryFallback(for: timeout, isOnline: true), - "online network errors should surface the model error instead of silently using local dictionary" + !RequestAvailabilityPolicy.shouldUseLocalDictionaryFallback(for: serverError), + "non-connectivity errors should surface the model error" ) } diff --git a/tests/run.sh b/tests/run.sh index 3b91c728..3e4ac158 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -210,6 +210,8 @@ node tests/ReaderWebScriptTests.js node tests/ReaderWebSearchTests.js node tests/ReaderWebMarksTests.js +./tests/ReleaseAutomationTests.sh + collect_logic_app_sources run_swift_test /tmp/leafreader-sqlite-word-tests \