chore(epic): v0.7.x hardening — close P2/P3 audit findings + tray-panel UX#37
Merged
Conversation
- PRD with 10-commit plan covering F17/F18/F19/F20/F16/F21/F22/F25/F29/F03+F28 + tray-panel auto-hide - Brainstorm decisions captured: 6-mod stat split / fine-grained UI split / en canonical / 3-tier IPC timeout / CGWindowList macOS fullscreen - Research: macOS fullscreen API survey persisted to research/ - implement.jsonl + check.jsonl curated with relevant specs and audit report Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes audit finding F17. The 2214-line stat.rs is replaced by 6 focused modules under src-tauri/src/services/stat/ (mod / writer / migration / export / trends / health), each owning a single concern: - mod.rs (393): StatService struct, Service trait impl, the writer command channel, and the two row-fetch helpers that orchestrate trends/health queries - writer.rs (384): dedicated writer task loop + persistence helpers + the cfg(test) session_at / record_taken fixtures used by sibling test modules - migration.rs (375): v0->v1 and v1->v2 SQLite migrations with their transactional rollback contracts - export.rs (336): VACUUM INTO path validation + IANA timezone resolution - trends.rs (238): pure aggregate_sessions plus the outcome/reason string parsers - health.rs (395): pure compute for ECI, ribbon, rhythm, today counts, longest-work-segment approximation No behavior changes. Same public surface (statistics_trends, cycle_outcomes, export_to, enqueue_*, record_*_cfg-test). Sibling test fixtures are factored to keep each file <= 400 lines per the PRD AC; ECI special cases are consolidated into one parameterized test which trades 5 named tests for a single behavior table. Verification: - cargo test --lib: 257 passed / 0 failed - cargo clippy --all-targets -D warnings: clean - cargo llvm-cov: 92.68% lines / 89.13% functions (>= 90/85 gate) - Per-file lines after rustfmt: mod=393, health=395, writer=384, migration=375, export=336, trends=238 (all <= 400) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
F18 from v0.7.x hardening audit. Decomposes two monolithic page files (1147 + 1121 lines) into orchestrators (49 + 252 lines) plus 14 cohesive sub-components, each <=300 lines per the AC. Settings (orchestrator + 7 sections + utils): - TimerSection / HotkeySection / BehaviorSection (embeds AutoStartSection) - WhitelistSection / ScheduleSection / DisplaySection / AutoStartSection - settings/utils.ts shared errorMessage(err) helper Statistics (orchestrator + 7 components): - EciDisplay / SuppressedDetails / ActivityRibbon / RhythmCards - TrendChart (chart=$state for reactive tracking after async ECharts init) - RangeSwitcher (TranslationKey-typed labelKey) - ExportControls (callback pattern; banners owned by orchestrator) Verification: 131/131 tests pass, svelte-check clean, coverage 93.99% lines / 91.72% functions (above 90/85 gate). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Older configs (v0.7.0 and earlier) wrote the BCP-47 long form en-US while the runtime tray/i18n code already aliased it to the short en. Two surfaces (validator allow-list + Settings page select) kept the en-US literal alive in the canonical path. This commit drives the canonical form down to en everywhere: - New canonicalize_language() helper rewrites en-US -> en at config load (alongside sanitize_process_whitelist). Legacy TOML files load unchanged; the next save flushes en to disk. - Tighten validate_display_config allow-list to ["zh-CN", "en"] (en-US no longer accepted via update_display_config). - Simplify DisplaySection derived value: the legacy alias check is no longer needed because the backend canonicalizes on load. - Update spec table in ipc-and-state.md to drop en-US from the accepted values column. Defensive aliases preserved: - I18nService::normalize_locale keeps "en" | "en-US" => ENGLISH_LOCALE for tray translation lookups (any caller, not just config). - src/lib/i18n/index.svelte.ts setLocale keeps the en-US case for runtime setLocale() callers outside the config store. Verification: 260 backend + 131 frontend tests pass, svelte-check clean, clippy --all-targets -D warnings clean, frontend coverage 94.00% lines / 91.72% functions (above the 90/85 gate). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 5 IPC event identifiers were referenced by 7 call sites across
the codebase, with the backend already using crate::events
constants for 4 of them. Two surfaces still had naked string
literals: tray.rs (emit + test assertion) and the entire frontend
src/lib/events.ts (5 listen + 1 emit). This commit closes the
gap so adding or renaming an event is a one-touch change.
Backend:
- Add NAVIGATE_TAB constant to src-tauri/src/events/mod.rs
alongside the existing 4 constants.
- Remove the #[cfg(not(test))] gate on the events module so the
tray.rs test that asserts the emit call can reference
events::NAVIGATE_TAB. The module is pure const strings with no
Tauri runtime imports, so widening visibility costs nothing.
- Replace naked "navigate_tab" in tray.rs (emit call + test
assertion) with events::NAVIGATE_TAB.
Frontend:
- Hoist the 5 event identifiers in src/lib/events.ts to top-level
exported consts (EVENT_STATE_CHANGED / EVENT_CONFIG_CHANGED /
EVENT_HOTKEY_STATUS_CHANGED / EVENT_STAT_PERSISTENCE_ERROR /
EVENT_NAVIGATE_TAB) and reuse them from the listen/emit wrappers.
Intentionally left as naked strings:
- Tray menu ids ("pause"/"settings"/"about"/"quit") — per PRD,
scope is IPC events only.
- src/lib/__tests__/events.test.ts — these assertions are the
wire-contract verification, replacing them with the same const
on both sides would be tautological.
- Capability JSONs (main-window.json / tray-panel.json) — JSON
doesn't support comments and the loader needs literal strings.
Verification: 260 backend + 131 frontend tests pass, svelte-check
clean, clippy --all-targets -D warnings clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous 5s timeout fired on every IPC call regardless of expected duration, causing the SQLite-backed exportStatistics (VACUUM INTO + filesystem write) to error in the UI for legitimate large databases. This commit introduces three explicit tiers: - INVOKE_TIMEOUT_DEFAULT_MS = 5_000 fast in-memory reads + state flips - INVOKE_TIMEOUT_IO_MS = 10_000 SQLite + system capability probes - INVOKE_TIMEOUT_EXPORT_MS = 60_000 VACUUM INTO + filesystem export invokeWithTimeout now accepts an optional third argument (defaults to the 5s tier), so callers opt in to a longer budget at the call site: - getStatisticsTrends -> IO - getStatisticsCycleOutcomes -> IO - getDetectorCapabilities -> IO - updateHotkeysConfig -> IO (OS hotkey registration round-trip) - exportStatistics -> EXPORT - everything else -> default 5s (unchanged) Spec ipc-and-state.md updated to document the three tiers and the call-site choice obligation. Verification: 133 frontend tests pass (2 new fake-timer tests cover the 10s IO tier and 60s EXPORT tier), svelte-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tray-panel.json previously granted unscoped core:event:allow-emit,
letting the tray webview emit any event. The only emit call in
src/pages/tray/TrayApp.svelte:161 is emitNavigateTab('Settings'), so
narrow the scope to {event: "navigate_tab"} — any future emit from
the tray panel must now be added explicitly here.
F21 (delete dead shell:default from main-window.json) deliberately
skipped: the audit was outdated. AboutPage.svelte:12 calls
open(RELEASES_URL) from @tauri-apps/plugin-shell to open the GitHub
releases page, and that requires shell:default (the PRD AC itself
allows for "if shell is actually used, keep it").
Verification: cargo build succeeds (capability schema validation
runs at build time).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v0.1.0-era .claude/index.json and the v0.7.0-shipped CLAUDE.md both drifted from current reality during the post-v0.7.0 epic: .claude/index.json: - services list now enumerates all 9 services + stat/ and timer/ sub-modules (was 7 services, stat/hotkey labelled "planned"), plus the tray_tooltip / window_layout pure helpers - pages list includes StatisticsPage + 7 settings/ sub-sections + 7 statistics/ sub-components (was a Phase-1-era list with StatisticsPage marked "planned") - bindings, stores, commands, capabilities, permissions, frontend tests sections updated to actual file inventory (28 ts-rs files, 17 commands, 17 permission tomls, 16 test files, etc.) - gaps + nextSteps rewritten around the in-flight v0.7.x epic and the deferred v1.0.0 / coverage-95% items CLAUDE.md: - "当前阶段" line now reflects v0.7.x hardening epic in progress (was claiming hardening complete, which conflated v0.7.0 with v0.7.x). - "下一步" lists the 10-commit v0.7.x epic with finding IDs (F17- F22 / F25 / F29 / F03+F28 / NEW tray-panel UX) and the post- epic v0.7.1 plus deferred items. memory/MEMORY.md sync (out-of-repo, recorded here for context): - Project-state "current stage" line updated to in-progress epic. - IPC line now reads "3 graded timeout tiers" instead of "5s". - v0.7.x epic + 10-commit breakdown added to release-history bullets. Verification: JSON parses cleanly (14_849 chars); CLAUDE.md edits are descriptive prose only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three platforms have low-probability but real degradation modes when
extracting basenames for the process whitelist, and they were not
captured anywhere. F29 from the v1.0.0 audit asked for these to be
documented as known limitations rather than papered over with
defensive shims.
Added "Known limitations" section to
.trellis/spec/backend/platform-storage.md spelling out:
- Windows: paths longer than MAX_PATH (260) return Ok(None) from
QueryFullProcessImageNameW with the PROCESS_NAME_WIN32 flag, so
the process is treated as not-whitelisted. Source pointer:
src-tauri/src/platform/windows.rs:175-194.
- Linux: /proc/{pid}/exe targets containing non-UTF-8 bytes flow
through OsStr::to_str() which returns None, short-circuiting the
basename chain to None. Source: linux.rs:330-338.
- macOS: kCGWindowOwnerName with invalid UTF-16 surrogates returns
a U+FFFD-substituted (lossy) string via CFString::to_string(),
which is then unlikely to equal any sanitized whitelist entry.
Source: macos.rs:195-202.
The spec explicitly forbids adding caller-side fallback logic to
hide these — they all already manifest as Ok(None), so the
whitelist's "not in list -> show reminder" branch handles them
correctly.
CHANGELOG: added an [Unreleased] section pre-staging this entry
plus the other commits already landed in the v0.7.x hardening epic
(F16-F22 / F25 docs / F17/F18 refactors). The section gets renamed
to [0.7.1] when the release is cut.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…isplay (F03+F28) v0.7.0 PR #29 set supports_fullscreen_detection() to false and made detect_fullscreen_macos() return DegradedFalse. That kept the Settings "Fullscreen Skip" toggle disabled on macOS even though CGWindowListCopyWindowInfo was already being called successfully for the foreground-process probe. F03+F28 from the v1.0.0 audit asked for a real implementation; research/macos-fullscreen-apis.md picked CGWindowList + CGGetActiveDisplayList (Option 1) over the NSWindow / Accessibility / private-CGS alternatives because it needs no entitlements, no TCC prompt, and reuses the existing CF dictionary parsing scaffold. Algorithm (matches the research doc): 1. CGGetActiveDisplayList -> Vec<u32> -> CGDisplayBounds for each -> Vec<DisplayRect>. Zero displays maps to DegradedFalse. 2. CGWindowListCopyWindowInfo with kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements -> CFArray of window dicts. 3. For each dict with kCGWindowLayer == 0, parse kCGWindowBounds via CGRectMakeWithDictionaryRepresentation into a DisplayRect and check it covers any display rect within 1px. Architecture: - DisplayRect + covers_any_display() live in platform/mod.rs as a pure helper so the bounds-comparison logic can be unit-tested on any host (CI mac runner cannot exercise a real fullscreen window). Both are cfg_attr-gated to allow(dead_code) on non-macOS to keep Win/Linux clippy --all-targets clean while still compiling the helper for tests. - macos.rs declares the CG FFI directly (CGGetActiveDisplayList, CGDisplayBounds, CGRectMakeWithDictionaryRepresentation) mirroring the existing CGEventSourceSecondsSinceLastEventType extern block. Tests (6 new cross-platform tests in platform/mod.rs covering covers_any_display): - exact match - 1px tolerance acceptance (Retina sub-pixel scaling) - maximized-with-titlebar rejection (40px gap) - multi-monitor match on secondary display - zero-display vector returns false - off-bounds overlay rect rejection Plus macos.rs replaces its 2 obsolete stub-state tests with: - supports_fullscreen_detection_is_true_after_real_impl - is_fullscreen_app_active_runs_without_panic (the achievable CI contract: FFI binds + no panic; positive match needs human host) Docs: - spec platform-storage.md table updated to spell out the new macOS algorithm in the fullscreen detection row. - CLAUDE.md "项目状态" + epic progress note both updated. - CHANGELOG [Unreleased] adds the fix entry. Verification: 266 backend tests pass (260 prior + 6 new cross- platform helper tests), clippy --all-targets clean. CI cannot exercise real fullscreen; the integration test verifies the FFI binds cleanly on macos-latest. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the always-on-top tray-panel stayed visible after clicking elsewhere, blocking content underneath like an unmovable widget. Extend the global on_window_event handler in lib.rs with a new arm: WindowEvent::Focused(false) on the 'tray-panel' label calls hide(), matching the existing dismiss-on-tray-icon-click behavior so the panel behaves like a native popover. Re-clicking the tray icon re-opens it via the unchanged toggle_tray_panel path. The handler is registered at Tauri Builder level (same place as the main-window CloseRequested intercept), so no port/trait abstraction is needed — the WindowEvent callback already gets a &Window we can hide directly. The existing TrayService tests are unaffected. AC met: - 266 backend tests pass - clippy --all-targets clean Closes the last item of the v0.7.x hardening epic.
README has been stuck at v0.3.0 for four releases (v0.4 process whitelist, v0.5 pomodoro + export, v0.6 health analysis, v0.7 hardening release). Rewrite both English and Chinese READMEs: - Version badge 0.3.0 -> 0.7.0, add coverage badge (93%) - Restructure Features into 5 groups: Core Timer, Smart Skipping, Analytics, Interface, Engineering — surfaces pomodoro, statistics, health analysis, process whitelist, export, hotkeys, tray-panel blur-hide, and the macOS fullscreen real impl - Comparison table adds Statistics + Pomodoro rows - Roadmap marks v0.4-v0.7 done, keeps v1.0 pending - Build section swaps long ad-hoc command list for "npm run ci" one-liner (still preserves the individual checks for transparency) - Statistics + Health screenshots noted as in-app exploration since the existing 4-screenshot set predates v0.6.0 CLAUDE.md + .claude/index.json: flip v0.7.x epic from "in progress" to "complete (single PR pending review)". Drop F03/F28 and tray-panel items from gaps (now implemented in this branch). Add the Wayland limitation as the standing P3 gap so the picture stays honest.
Old 4-screenshot single-row layout cramped each image to ~25% of README width, making them illegible. Reorganize into three thematic groups with 2-column tables so each image gets ~50% width, and put the new Pomodoro settings shot in a centered 720px callout to highlight the v0.5.0 mode addition. New screenshots (manually captured against `npm run tauri dev`): - statistics-overview.png — Statistics page top: rest rhythm card, Eye-Care Index ribbon (41/100 demo state), counts + 24h ribbon - statistics-trend.png — Trend card switched to weekly view with ECharts bar+line composite over W21 vs W22 - settings-pomodoro.png — Settings → Pomodoro section showing focus duration, short/long break duration, long-break interval steppers Both README.md (English) and .github/README.zh-CN.md (Chinese) updated symmetrically.
Remove sections that don't serve a first-time reader of the repo: - ProjectEye comparison table — competitor positioning belongs in release notes / blog posts, not the project README - Download section — superseded by the GitHub Releases link in the status badge at the top, the duplicate platform table was just noise - Roadmap — already covered by CHANGELOG.md + git tags, kept drifting out of sync with reality (saw it stuck at v0.3 for four releases) - Credits — ProjectEye inspiration is honored in CHANGELOG / docs, no need to re-state every time someone opens the README Replace the verbose "Build from Source" + scattered command lists with a focused "Quick Start" — clone, install, dev/build, and one "npm run ci" for everything else. Add a new "Architecture" section after Tech Stack: a 3-layer ASCII diagram (Frontend / Services / Platform) + 4 bullets explaining service composition, platform degrade flags, ts-rs IPC bindings, and the .trellis/spec/ canonical rules. Deliberately high-level — detailed module map already lives in CLAUDE.md. Drop the "added in v0.X.0" tags from the screenshot section headers per follow-up feedback — the README documents current state, not release timeline. Net effect: 169 lines deleted, 79 added; README is now ~40% shorter while covering more of what readers actually need (what it does, how it looks, how to run it, how it's built).
- Replace the verbose "What is the 20-20-20 Rule?" explainer with a single-paragraph Introduction (covers 20-20-20, Pomodoro, smart skipping, and analytics in one breath) - Drop the redundant "Status: v0.7.0 released" line above the rule — the version badge already conveys that - Compress Features from 5 categories / 17 bullets down to a tight 7-bullet Highlights list keyed by an emoji each: ⏱️ timer modes, 🎯 smart skipping, 📊 statistics, 🖥️ multi-monitor, 🌓 theme+i18n, ⌨️ hotkeys+tray, ⚡ lightweight+tested - Add semantic emoji anchors to every section heading (👀 ✨ 📸 🚀 🛠️ 🏗️ ⚙️ 🤝 📄) — makes scanning the README on GitHub easier without leaning into cosmetic clutter Net: 86 lines deleted, 36 added — same coverage of what readers need, faster to scan.
…arkdown Hoist OPTIONS const above active_display_bounds() call site (fixes clippy::items_after_statements) and wrap CFDictionary in backticks in the rect_from_dict doc comment (fixes clippy::doc_markdown). Both lints only fire on the macOS CI runner since the surrounding code is gated behind cfg(target_os = "macos").
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
v0.7.x hardening epic — 14 work commits + 2 trellis chore commits closing every P2/P3 finding from the v0.7.0 audit that was deferred into v0.7.x, plus a tray-panel focus-loss UX bug surfaced during v0.7.0 manual testing.
Single-PR by design (per epic PRD): each commit is independently reviewable but they share the v0.7.x quality-bar theme, so splitting into 10 micro-PRs would just be churn. Final PR will be squash-merged like all hardening work.
Closed findings
F21 was originally on the list but the audit turned out stale — `shell:default` is still live for `AboutPage` external links, so we kept it. Documented inline in the F22 commit message.
README overhaul (4 follow-up commits)
After implementation finished, the README — stuck at v0.3.0 for four releases — got a full pass:
Net README: -86 / +36 (just the last pass); ~40% shorter overall while covering more.
Architectural notes
Test plan
Out of scope (pushed to v1.0.0 or unscheduled)
🤖 Generated with Claude Code