diff --git a/docs/breadcrumbs.md b/docs/breadcrumbs.md index 26ef95c..fbd3522 100644 --- a/docs/breadcrumbs.md +++ b/docs/breadcrumbs.md @@ -65,3 +65,229 @@ - Export WebP: added to_webp_or_png() + load_screenshot_optimized() helpers; HTML export uses dynamic MIME; Markdown export converts + writes correct extension. - Export WebP: removed unused load_screenshot_base64; all 142 Rust + 75 frontend tests pass; clippy clean. - PDF compression research: WKWebView.createPDF() has zero compression controls; WebP not in PDF spec (re-encoded to lossless = bloat); PDFKit macOS 13+ has saveAllImagesAsJPEG + optimizeImagesForScreen write options; Quartz filters work but need raw FFI; best approach = JPEG data URIs for PDF export + optional PDFKit post-processing. Spec at docs/specs/pdf-compression.md. + +2026-02-09 +- Apple Intelligence feasibility research: Foundation Models framework = on-device LLM (3B params, 2-bit), optimized for summarization/extraction/classification; not for deep reasoning/world knowledge. +- API surface (Swift-first): guided generation (@Generable/@Guide), snapshot streaming, tool calling, stateful sessions; availability gating by Apple-Intelligence device + supported region/language; errors include guardrail violation, unsupported language, context/context-window exceeded. +- Requirements: Apple Intelligence-capable hardware (Mac M1+), sufficient storage for model download, user must enable Apple Intelligence; Foundation Models framework availability appears tied to macOS 26+ (per Apple Newsroom + third-party Rust binding docs). +- Mapped StepCast Step fields + AX click metadata (currently only logged) to text-only step description generation; noted that storing AX label/role in Step improves quality without Vision/OCR. +- Drafted decision matrix: keep Tauri and integrate via small native Swift wrapper (plugin/XPC/CLI) vs hybrid host vs full Swift rewrite; recommendation: keep Tauri, add optional AI path. +- User decision: ship Apple Intelligence descriptions only on macOS 26+ Apple Silicon; all other cases use deterministic fallback. +- Research: Apple code-along shows availability cases `.deviceNotEligible`, `.appleIntelligenceNotEnabled`, `.modelNotReady` (+ unknown) via `SystemLanguageModel.default.availability`. +- Research: adapter deployment requires a separate “FoundationModels Framework Adapter Entitlement”; irrelevant for v1 (default system model only). +- Design: recommend global in-app toggle (default off) + tutorial note; system toggle lives in macOS Settings “Apple Intelligence & Siri”; no extra TCC permission for text-only descriptions. +- Implemented: Settings -> Apple Intelligence section with global toggle + privacy note; added “Open Apple Intelligence & Siri Settings” button (deep link + fallback) and a new backend command `get_apple_intelligence_eligibility` (OS/arch check for macOS 26+ + Apple Silicon). Added Quick Start welcome banner bullet. +- Research: scanned existing FoundationModels OSS usage; found bridge patterns (Swift sidecar JSON stdio, C ABI wrapper) + note that CLI tools may be more strictly rate limited than GUI apps. +- UI: replaced plain checkbox with an Apple-style native switch using WebKit’s `` (fallback: checkbox when unsupported). +- Fix: ensure the switch is visible/clickable in StepCast (exclude checkbox/switch inputs from text-input CSS; wrap row in a label to make the whole row clickable). Increased panel height to 640 to avoid Settings view scrolling. +- Implemented Apple Intelligence step descriptions end-to-end: + - Added Swift sidecar helper `src-tauri/swift/stepcast_ai_helper.swift` (FoundationModels) compiled in `src-tauri/build.rs` and executed via JSON stdin/stdout. + - Added Tauri commands `update_step_description` + `generate_step_descriptions`; generation updates steps via `step-updated` events. + - Recorder: auto-generate missing descriptions after Stop when toggle enabled. + - Step Editor: toolbar `Enhance Steps`, per-step sparkle regenerate, inline manual description editing. + - Exports: prefer enhanced `step.description` via `effective_description` (HTML/Markdown/PDF). + - Settings: switch upgraded to custom Apple-style toggle; tray window auto-resizes for Settings (no scroll). +- AI quality: added richer AX metadata (subrole/role_description/identifier/container_role) and improved Swift prompt with "UI kind" hints + deterministic baseline + quality gate (prevents vague "Click Finder." and non-tab "tab" hallucinations). +- AI quality: added on-device Vision OCR on step screenshots (ROI around click) when labels are missing / right-clicks, so descriptions can include sidebar item names + filenames. +- Fix: AI helper extraction now compares embedded bytes vs existing file (not only length) so updates reliably ship to `~/Library/Caches/com.w0nk1.stepcast/bin/stepcast_ai_helper`. +- AI quality: AX click metadata is now kept even when the hit-test element has no label; label recovery scans AX children (depth-limited) to pull visible text from list rows (Finder sidebar/file list). +- AI quality: right-click OCR grounding is filename-only (ignore context menu OCR) and Vision OCR switched to `.accurate` + smaller min text height. +- AI quality: AX label extraction now prefers `AXTitleUIElement` + `AXPlaceholderValue` and avoids reading `AXValue` for text fields to prevent leaking user-entered content. +- AI quality: Vision OCR click selection now strongly prefers candidates whose bounding box contains the click (and auto-detects bbox coordinate space vs ROI). +- AI copy: text-field clicks are described as `Click ... field` (no implicit typing) since StepCast doesn’t record keystrokes. +- UX: Settings window now auto-resizes to full content using `scrollHeight` + `ResizeObserver` (no Settings scroll). +- UX: Apple Intelligence toggle now propagates between panel/editor via `ai-toggle-changed` Tauri event (storage event remains as fallback). +- Verify: `npm test` + `cargo test` pass. + +2026-02-10 +- AI quality: AX click grounding now strongly prefers pressable elements (`AXPress`/`AXConfirm`) and attempts to recover labels from child `AXStaticText` on pressable groups; prevents “RustDesk in RustDesk” and improves window control (close) detection. +- AI quality: store clicked element bounds as `step.ax.element_bounds` (percent) and use it to drive OCR ROI (fallback only); improves label accuracy for buttons/text fields without app-specific heuristics. +- AI quality: ignore GUID/hex-like AX labels; prefer humanized `AXIdentifier` for grounding when available (e.g. emoji/sticker controls). +- Capture: treat titleless overlay windows as `Popup` unless near menu bar; avoid top-of-screen region capture for in-app popovers; fixes cropped sticker picker screenshots. +- Window controls: classify minimize/zoom via AX subrole/roleDescription and emit deterministic baseline (“Minimize the … window.”). +- Verify: `npm test`, `cargo test`. +- Packaging: Swift AI helper is now built into `src-tauri/bin/stepcast_ai_helper`, included as a Tauri bundle resource, and executed from the app bundle (no cache extraction). +- Verify: `cargo build`, `cargo clippy -D warnings`, `cargo test`, `npm test`. +- Verify: `npm test` + `cargo test` pass. +- AI quality: for list/table/outline clicks, attempt to replace structural/metadata-heavy AX labels with the currently selected row/item label (AXSelectedRows/AXSelectedChildren); improves Finder file selection and chat list targets without per-app logic. +- AI quality: list interactions can now be classified using container identifiers (table/list/outline/collection) even when the clicked element role is `AXButton`; improves verbs ("Select …") and reduces "sad" button descriptions. +- AI quality: common structural labels ("editor area", "scroll area", "split view") are treated as generic and no longer used as grounding labels; reduces cryptic outputs in complex apps like Xcode. +- Cleanup: fixed new clippy warnings in `src-tauri/src/export/pdf.rs` and `src-tauri/src/recorder/window_info/auth.rs`; ran `cargo fmt` and `cargo clippy -D warnings`. + +2026-02-11 +- Investigated capture regression with tiny snippets in `/Users/markus/Library/Caches/com.w0nk1.stepcast/sessions/8fd64097-bfaa-43b6-aefc-d691cdd0d2a9`; found menu-item steps captured via `window_id_capture` despite AX menu roles. +- Fix: updated titleless overlay classification to allow overlay detection even when frontmost and capture window IDs match; keeps display-relative menu detection. +- Fix: menu-role AX interactions now force region capture in top menu branch; avoids tiny menu-only screenshots. +- Fix: popup overlays now union with the largest app window for clicked PID when available, then clamp to clicked display; improves context for picker/popover steps. +- Verify: `cd src-tauri && cargo check -q` and targeted helper test `classify_titleless_overlay_window_dropdown_vs_popup` pass. +- Follow-up hardening: restored conservative `same window_id => NotOverlay` classification to avoid false popup classification on titleless utility windows. +- Follow-up hardening: extracted `should_use_menu_region_capture(...)` + tests; menu-role forcing is now explicitly top-region-gated. +- Follow-up hardening: added `get_window_for_pid_at_click(...)` and use it for popup union context (fallback remains largest-PID window). +- Follow-up hardening: added `exclude_window_id` to `get_window_for_pid_at_click(...)` and exclude the active popup window when resolving popup context. +- Follow-up hardening: popup overlay branch now always captures via region (union bounds) instead of optional window-id capture. +- Follow-up hardening: `get_window_for_pid_at_click(...)` now chooses the largest click-containing window to avoid selecting another small overlay in front. +- Follow-up hardening: clamped Dock/menu/top-region capture bounds to clicked display; fixed small-display edge cases where fixed region width/height could exceed display and produce off-screen captures. +- Verify: full suite green (`cargo test` 165 tests, `npm test` 126 tests). +- 2026-02-11: audited latest 24-step session (`33edc9e4-d393-4952-bebf-3a8f22c12cf4`) with `scripts/session-ai-report.js` + manual screenshot review; confirmed recurring errors from generic/metadata AX labels (`Seitenleiste`, `Listendarstellung`, `2,4 MB`) and chat metadata labels. +- 2026-02-11: patched Swift grounding heuristics (`stepcast_ai_helper_descriptions.swift`) to reject structural+metadata labels, improve comma-metadata segment extraction, and avoid wrong right-click labels when OCR has no filename target. +- 2026-02-11: patched Vision OCR scoring (`stepcast_ai_helper_vision.swift`) to penalize metadata values and prefer filename OCR on list-item double-clicks. +- 2026-02-11: patched Rust AX list heuristics (`src-tauri/src/recorder/ax_helpers.rs`) to treat sidebar/size/time labels as suspicious and reject those as selected-item targets; added regression tests. +- 2026-02-11: auth placeholder UX copy updated across recorder/export/editor/timeline to `Authenticate with Touch ID or enter your password to continue.`. +- 2026-02-11: verification passed (`swiftc -typecheck` for AI helper files, `npm test` 126/126, `cargo test` 167/167 + integration tests). +- 2026-02-11: after replaying AI generation against latest request JSON, found identifier leak (`NS:8`) and fixed it by filtering implementation-style identifiers from Swift grounding. +- 2026-02-11: improved OCR filename detection for truncated Finder names (`...` in middle) to recover the actual selected file row on double-click. +- 2026-02-11: improved `ax_copy_selected_label` to prefer only semantic selected labels and continue searching descendants instead of returning first (often metadata) label. +- 2026-02-11: deep-dive on latest regression session (`5f8c10ea-7bc0-468d-92fa-296fc7c5e11b`) + prior session (`33edc9e4-d393-4952-bebf-3a8f22c12cf4`) via `recording.log` and `session-ai-report.js`; found repeated false `attached_dialog_window` detections from unrelated apps. +- 2026-02-11: hardened `src-tauri/src/recorder/window_info/topmost.rs` attached-dialog detection with owner validation + area-ratio constraints; added normalization tests for app-name matching. +- 2026-02-11: hardened `src-tauri/src/recorder/pipeline/mod.rs` capture selection: normalized same-app checks + safe clicked-window reconciliation by click-point window lookup only. +- 2026-02-11: verification complete: `cargo fmt`, `cargo test -p stepcast recorder::window_info::topmost -- --nocapture`, `cargo test -p stepcast recorder::pipeline -- --nocapture` all green. +- 2026-02-11: analyzed newest 28-step session (`0a3241eb-3ccc-476d-a402-5f6f4d0b8309`); identified three regressions: menu-bar opener click ignored, open/save panel click captured as host window, and dialog captures too tightly cropped. +- 2026-02-11: pipeline fix: stop dropping `AXMenuBarItem` clicks (keep ignoring `AXMenuButton` menu-open noise only). +- 2026-02-11: pipeline fix: add foreign topmost dialog acceptance (overlap/containment-gated) to support helper-process dialogs (e.g., appkit open/save panels) without reintroducing unrelated behind-window captures. +- 2026-02-11: pipeline fix: `is_sheet_dialog` now captures parent+dialog union; sheet fast-path now unions AX dialog area with frontmost parent window (display-clamped). +- 2026-02-11: Swift AI fix: classify `AXMenu` as `menu item` so menu actions are phrased as "Choose ... from the menu". +- 2026-02-11: verification: `cargo fmt`, `cargo test -p stepcast recorder::pipeline -- --nocapture` (40 pass), `swiftc -typecheck ...` (pass with existing non-blocking warning in vision file). +- 2026-02-11: audited latest session `d60d83a7-b7c6-4648-9b6e-6e7b07b8e209` (`session-ai-report.js` + `recording.log`) and confirmed regressions at steps 16/21/26 (wrong list target + missing overlay context + low-confidence Arc label). +- 2026-02-11: patched capture policy in `src-tauri/src/recorder/pipeline/mod.rs` + `helpers.rs` to introduce transient region-capture preference for volatile roles (`AXMenuItem`/`AXMenu`/`AXGroup`) and keep menu-region logic separate/tested. +- 2026-02-11: added Rust regression tests in `src-tauri/src/recorder/pipeline/helpers.rs` for transient capture policy. +- 2026-02-11: patched Swift OCR grounding in `stepcast_ai_helper_vision.swift` (no filename-forcing on generic list double-click) and `stepcast_ai_helper_descriptions.swift` (timestamp/date noise filtering + strict baseline fallback without trusted labels). +- 2026-02-11: replay-validated AI generation locally against session request JSON (`ai-trace-1770817901597-request.json`): step-016 improved to `var 9`; step-026 now deterministic generic fallback (no hallucinated label). +- 2026-02-11: verification passed: `cargo fmt --all`, `cargo test -p stepcast recorder::pipeline -- --nocapture` (42 pass), `swiftc -typecheck ...` (pass; existing non-blocking Vision warning unchanged). +- 2026-02-11: fixed pipeline ordering bug in `src-tauri/src/recorder/pipeline/mod.rs`: `AXMenuButton` ignore now runs before `session.next_step_id()`/screenshot path allocation, preventing overwritten `step-XXX.png` files when menu-open is ignored. +- 2026-02-11: re-verified after ordering fix: `cargo fmt --all`, `cargo test -p stepcast recorder::pipeline -- --nocapture` (45 pass), `swiftc -typecheck ...` (pass; existing non-blocking Vision warning unchanged). +- 2026-02-11: analyzed regression session `82a47171-cc80-48c5-ac2c-736cc817b591`; found menu-bar clicks misclassified as window-controls (`window_control_fast_path`) and follow-up menu item clicks resolving to underlying `AXGroup` labels (`Today`) from Arc. +- 2026-02-11: patched `src-tauri/src/recorder/pipeline/mod.rs` + `helpers.rs` with short-lived menu-followup state (`last_menu_bar_click_ms`), menu-role guard for window-control inference, forced `window_title=Menu` on menu-region captures, and tighter transient capture policy (`AXGroup` only for popup overlays). +- 2026-02-11: patched `src-tauri/swift/stepcast_ai_helper_descriptions.swift` to classify `windowTitle=Menu` top clicks as menu items, improving verb selection (`Choose ... from the menu`). +- 2026-02-11: patched `src-tauri/swift/stepcast_ai_helper_vision.swift` ROI selection to ignore weak `AXGroup` element bounds on day-like labels (`Today/Heute/...`) and use click-centered ROI. +- 2026-02-11: verification after this patchset: `cargo fmt --all`, `cargo test -p stepcast recorder::pipeline -- --nocapture` (45 pass), `swiftc -typecheck ...` (pass; existing Vision warning unchanged). + +2026-02-12 +- Patched `src-tauri/swift/stepcast_ai_helper_descriptions.swift`: fixed checkbox toggle verb semantics (`checked=true` -> Disable), added file-kind label filtering, and improved OCR cleanup for slash artifacts in filenames. +- Patched `src-tauri/swift/stepcast_ai_helper.swift`: deterministic baseline for menu/menu-bar/checkbox/window-control kinds; model-error fallback now returns baseline text instead of marking step as failed. +- Patched `src-tauri/swift/stepcast_ai_helper_vision.swift`: improved list-row OCR selection (row-aligned semantic priority, metadata/file-kind penalties, clipped-vs-full variant preference). +- Patched `src-tauri/src/recorder/ax_helpers.rs`: for list/outline clicks, try selected-row label more aggressively and retry once after a short delay to catch first-click selection updates. +- Verification: `swiftc -typecheck ...` pass; helper replay on session `c9f4d328-c2c1-48b3-a119-63a97b510511` now has `FAILURES=0`; `cargo test -p stepcast recorder::ax_helpers -- --nocapture` pass (22/22). +- Analyzed latest session `c39c68aa-de4f-4489-a5a5-4978b139e131` (`recording.log` + replay): confirmed foreign-overlay capture hijacks (`Grammarly Desktop`) and weak list labels (`Filme`, `hreibtisch`, clipped sidebar strings). +- Patched `src-tauri/src/recorder/pipeline/mod.rs`: foreign topmost dialog capture now requires explicit AX dialog hints; prevents unrelated helper overlays from replacing the real click target. +- Patched `src-tauri/swift/stepcast_ai_helper_descriptions.swift`: removed `Ohne Titel`/`Untitled` context suffix, filtered picker-category labels (`Emoji/GIF/Stickers/Today`) as non-targets for item clicks, tightened list-item label acceptance. +- Patched `src-tauri/swift/stepcast_ai_helper_vision.swift`: added anti-sidebar-leak scoring for list OCR and expanded file-kind detection (plural/localized variants) to avoid column-kind hallucinations. +- Replay check (`ai-trace-1770891651602-request.json`): improved outputs now include `Enable "WLAN" ...`, removed duplicate app suffix, downgraded unsafe guesses to generic (`Select the item ...`) for weak targets. +- Verification: `swiftc -typecheck src-tauri/swift/stepcast_ai_helper.swift src-tauri/swift/stepcast_ai_helper_descriptions.swift src-tauri/swift/stepcast_ai_helper_vision.swift`; `cargo test -p stepcast recorder::pipeline -- --nocapture`; `cargo test -p stepcast recorder::ax_helpers -- --nocapture` all pass. +- Added `docs/specs/2026-02-12-preclick-screencapturekit-buffer.md` and implemented macOS pre-click ScreenCaptureKit ring buffer in `src-tauri/src/recorder/pre_click_buffer.rs`. +- Integrated pre-click fast-path into `src-tauri/src/recorder/pipeline/mod.rs` for volatile interactions (`prefer_transient_region_capture`) with unchanged fallback to existing capture paths. +- Wired lifecycle in `src-tauri/src/lib.rs`: start buffer on recording start; stop/release on stop and discard; pass buffer handle into `pipeline::process_click(...)`. +- Added `src-tauri/build.rs` linker rpath for `/usr/lib/swift` so test/dev binaries resolve Swift runtime dylibs without env overrides. +- Verification: `cd src-tauri && cargo check -q` pass; `cd src-tauri && cargo test pre_click_buffer::tests::pick_frame -- --nocapture` pass; `cd src-tauri && cargo test -q recorder::pipeline -- --nocapture` pass (45 tests). +- Updated `src-tauri/src/recorder/pipeline/mod.rs` to prefer pre-click full-display capture for standard clicks (`preclick_fullframe_capture`) before branch-specific window/menu logic. +- Added stale-frame guard (`PRECLICK_MAX_AGE_MS=500`) and explicit debug logging for `ok/stale/unavailable/failed` pre-click fullframe outcomes. +- Kept existing capture branches as fallback plus unchanged right-click/auth exceptions. +- Verification: `cargo fmt --manifest-path src-tauri/Cargo.toml`, `cargo check --manifest-path src-tauri/Cargo.toml`, `cargo test --manifest-path src-tauri/Cargo.toml recorder::pipeline::tests::calculate_click_percent_in_middle -- --nocapture` all pass. +- Patched `src-tauri/src/recorder/pre_click_buffer.rs`: `pick_frame_index(...)` now returns `None` when all buffered frames are newer than the click timestamp (no fallback to latest/future frame). +- Patched `src-tauri/src/recorder/pipeline/mod.rs`: pre-click fullframe acceptance now requires `age_ms` in `0..=500` (negative/future frames rejected). +- Verification: `cargo fmt --manifest-path src-tauri/Cargo.toml`, `cargo check --manifest-path src-tauri/Cargo.toml`, `cargo test --manifest-path src-tauri/Cargo.toml pre_click_buffer::tests::pick_frame -- --nocapture`, `cargo test --manifest-path src-tauri/Cargo.toml recorder::pipeline::tests::calculate_click_percent_in_middle -- --nocapture` all pass. +- Audited session `d83599f5-b6fb-4b77-8ebe-66de91eb3eb6` via `scripts/session-ai-report.js` + `ai-trace-1770903867743-request/response.json`; confirmed capture path fixed (Poll menu present) but AI-label issues remained (`vat`, `Schreptisch`, `mixuino_errors @ öffnen`, icon glyph ``). +- Patched `src-tauri/swift/stepcast_ai_helper_descriptions.swift`: + - keep `label == window_title` for list/sidebar contexts when container signals list semantics, + - strip OCR context-menu suffixes (`@ open/öffnen/...`), + - broaden context-menu phrase detection (`offnen`/`oeffnen` variants), + - canonicalize `Schreptisch` -> `Schreibtisch`, + - strip Private Use Area glyphs from labels, + - adjust `preferredVerb(...)` to prioritize actual user action (`Right-click`/`Double-click`/`Press`), + - refactor quality gate to return structured decision reason. +- Patched `src-tauri/swift/stepcast_ai_helper.swift`: response now includes per-step debug payload (`kind`, `location`, `groundingLabel`, `groundingOcr`, `baseline`, `candidate`, `qualityGateReason`). +- Patched `src-tauri/src/apple_intelligence/mod.rs`: Rust `GenerateResultItem` now accepts optional `debug` field. +- Patched `src-tauri/src/lib.rs`: persist `ai_desc_debug` lines in `recording.log` for each generated result when debug payload is present. +- Patched `scripts/session-ai-report.js`: render AI debug summary (`gate`, `label`, `ocr`, `baseline`, `candidate`) when available. +- Verification: + - `cargo fmt --manifest-path src-tauri/Cargo.toml` + - `cargo check --manifest-path src-tauri/Cargo.toml` + - replayed helper on latest request JSON (`src-tauri/bin/stepcast_ai_helper generate < .../ai-trace-1770903867743-request.json`) and validated: + - `step-005`: `Select "Pepper"...` (no `vat`) + - `step-030`: `Select "Schreibtisch"...` (typo fixed) + - `step-031`: `Right-click "mixuino_errors"...` (no menu contamination) + - `step-008/009`: icon glyph removed (`Anonymous Voting` / `Multiple Answers`) +- Follow-up (user preference): removed language-specific typo canonicalization from `src-tauri/swift/stepcast_ai_helper_descriptions.swift` (no correction list). +- Follow-up (user preference): strengthened conservative gate to prefer baseline for `list item` / `item` / `button` / `text field` / `tab` whenever candidate differs (`qualityGateReason=prefer_baseline_for_kind`). +- Re-verified replay on `ai-trace-1770903867743-request.json`: baseline-first behavior active; `step-005` remains stable (`Pepper`), `step-031` remains stable (`mixuino_errors`), and menu steps remain deterministic. +- Round-2 hardening after reviewing newest session `859e5952-0510-4320-9645-5770a797502e`: + - Patched `classifyKind(...)` in `src-tauri/swift/stepcast_ai_helper_descriptions.swift` so `window_title=Menu` only forces `menu item` when AX role is menu-like; fixed false menu wording on `step-002`. + - Patched `isGenericUiLabel(...)` to treat `Popover Dismiss Region`/dismiss-region labels as generic; fixed technical identifier output on `step-016`. + - Patched list-item OCR gating for sidebar-side clicks (`x < 30%`): reject OCR-only non-filename labels; this intentionally prefers generic baseline over wrong specifics for Finder sidebar-like targets. + - Verification: `cargo check --manifest-path src-tauri/Cargo.toml` and replay against `ai-trace-1770979987150-request.json`: + - `step-002`: now `Click "Growbox – Home Assistant" in Arc.` (no false menu phrase) + - `step-016`: now `Click the item in WhatsApp.` (no `Popover Dismiss Region`) + - `step-021/022/023`: Poll flow still correct and stable + - `step-030/031/032`: now conservative generic list-item wording instead of wrong OCR names +- Round-3 hardening (same session): added picker-interaction filter (`sticker`/`emoji`/`gif`/`picker` signals in AX metadata) to suppress OCR/identifier grounding for list items in picker grids. +- Replay verification (`ai-trace-1770979987150-request.json`): + - `step-017` changed from `Select "BIRTH"...` to `Select the item in WhatsApp (Window).` + - Poll/menu steps unchanged and still deterministic. + +2026-02-13 +- Added spec `docs/specs/2026-02-13-step-crop-focus-system.md` describing non-destructive crop architecture and verification plan. +- Added `crop_region` to step model (TS + Rust), plus session mutation `update_step_crop` and Tauri command wiring (`update_step_crop`) with permissions/capabilities updates. +- Implemented recorder auto-focus crop heuristic in `src-tauri/src/recorder/pipeline/helpers.rs` and applied it in main pipeline only for display-sized captures. +- Implemented editor crop UI using `react-image-crop` in `src/components/EditorStepCard.tsx` with Apply/Reset flow and optimistic state sync via `EditorWindow`. +- Implemented preview marker remap + cropped rendering in `src/components/EditorStepCard.tsx`, `src/components/StepItem.tsx`, and `src/utils/stepCrop.ts`. +- Implemented export crop application in `src-tauri/src/export/helpers.rs` and wired into HTML/Markdown paths (PDF inherits HTML path). +- Added/updated tests: `src/utils/stepCrop.test.ts`, `src/components/StepItem.test.tsx`, `src/components/EditorStepCard.test.tsx`, and Rust crop/marker tests in export/pipeline/session modules. +- Verification run completed: `npm test`, `npm run build`, `cd src-tauri && cargo test --quiet`, `cd src-tauri && cargo fmt`. +- Follow-up after reviewing latest sessions/screenshots (`859e...`, `eb821...`): poll/menu captures are now present; biggest remaining readability gap was oversized full-screen-like sheet captures. +- Patched `src-tauri/src/recorder/pipeline/helpers.rs` with `should_apply_focus_crop(...)` (display-coverage heuristic) and added regression test `should_apply_focus_crop_for_near_fullscreen_or_large_area`. +- Patched `src-tauri/src/recorder/pipeline/mod.rs` to use focus-crop heuristic in: + - `sheet_fast_path` step creation + - `window_control_fast_path` step creation + - main step finalization (replacing exact-display-size gate) +- Patched `src-tauri/src/recorder/pre_click_buffer.rs`: `TARGET_FPS` from `12` to `16`. +- Patched `src-tauri/src/recorder/pipeline/mod.rs`: `PRECLICK_MAX_AGE_MS` from `500` to `250`. +- Verification follow-up completed: + - `cd src-tauri && cargo fmt` + - `cd src-tauri && cargo test --quiet recorder::pipeline::helpers::tests::should_apply_focus_crop_for_near_fullscreen_or_large_area` + - `npm run -s test` + - `npm run -s build` + - `cd src-tauri && cargo test --quiet` +2026-02-13 +- Investigated user-reported missing screenshots for latest recordings ("step 1+2, 4-7"). +- Audited sessions `eb82136b-81f3-44d5-846d-11b3086c434c` and `1fb5073d-0b71-41b4-8e47-61f191cedc28`: screenshot files exist on disk, valid PNGs, and recorder logs show captured paths for the affected step IDs. +- Implemented frontend hardening: new `mergeUpdatedStep` in `src/utils/stepEvents.ts` and wired into `RecorderPanel` + `EditorWindow` so `step-updated` cannot accidentally drop previously-known screenshot paths. +- Implemented UI image load hardening in `src/components/StepItem.tsx` and `src/components/EditorStepCard.tsx`: up to 2 delayed retries with cache-busting query on image load failure. +- Added regression tests `src/utils/stepEvents.test.ts` for omitted/null screenshot update handling. +- Verification run: `npm run -s test`, `npm run -s build`, `cd src-tauri && cargo test --quiet` all pass. +2026-02-13 +- Investigated latest user repro: session `3c03f1c9-6d5c-4bcc-89cd-e613446eca47` has all `step-001..015.png` files present and valid; `recording.log` confirms `screenshot_path` emitted for every step. +- Confirmed issue is editor crop rendering stability, not capture pipeline loss. +- Patched `src/components/EditorStepCard.tsx`: crop rendering now independent of natural-size readiness, with stable frame fallback ratio and modal image onLoad size hydration. +- Patched `src/editor.css`: wrapper/frame sizing adjusted to prevent collapse in cropped (absolute image) mode. +- Added regression test in `src/components/EditorStepCard.test.tsx` for cropped frame visibility. +- Added spec `docs/specs/2026-02-13-editor-crop-visibility-fix.md`. +2026-02-13 +- Implemented direct crop reposition in editor overview (`src/components/EditorStepCard.tsx`): pointer-drag on cropped frame updates transient crop preview and persists once on pointer release. +- Added drag-state guards (`moved` flag + latest crop tracking) so simple clicks do not trigger writes. +- Added cursor/touch-action styling for draggable cropped frames in `src/editor.css`. +- Added regression test in `src/components/EditorStepCard.test.tsx` verifying drag updates crop position and preserves crop size. +- Verification: `npm run -s test -- src/components/EditorStepCard.test.tsx`, `npm run -s test`, `npm run -s build` all pass. +2026-02-13 +- User-reported crop drag bug reproduced conceptually: native image drag ghost (`+` cursor) appeared while trying to reposition crop. +- Patched `src/components/EditorStepCard.tsx` to suppress native drag, use pointer-driven crop drag only, and batch pointermove preview updates with `requestAnimationFrame`. +- Added drag-commit epsilon to avoid writing crop updates on micro-jitter/no-op pointer interactions. +- Patched `src/editor.css` with `-webkit-user-drag: none` + cropped-image pointer-event policy. +- Patched `src/components/StepItem.tsx` and `src/components/EditorStepCard.tsx` image tags with lazy/async decode attributes for lower initial load cost. +- Added spec `docs/specs/2026-02-13-editor-crop-drag-performance.md`. +- Verification: targeted tests (`EditorStepCard`, `StepItem`), full frontend tests, and build all pass. +2026-02-14 +- Investigated pre-push blocker: `npm test:coverage` failed on 5 files due per-file thresholds. +- Added/expanded frontend tests: + - `src/components/EditorWindow.test.tsx` + - `src/components/EditorStepCard.test.tsx` + - `src/components/SettingsSheet.test.tsx` + - `src/components/StepItem.test.tsx` + - `src/utils/stepCrop.test.ts` +- Added spec: `docs/specs/2026-02-14-pre-push-coverage-gate.md`. +- Verification completed with hook-equivalent command: + - `sh .githooks/pre-push.sh` (passes: clippy + test:coverage + tsc). + +2026-02-14 +- Fixed CI Swift compile blocker (`no such module FoundationModels`) in `src-tauri/swift/stepcast_ai_helper.swift` via conditional import + guarded generation path. +- Added unsupported availability fallback so `checkAvailability` reports unsupported instead of failing to compile on non-AI runners. +- Added guarded generation fallback to baseline text with reason `model_unavailable_fallback`. +- Re-ran hook-equivalent verification: `sh .githooks/pre-push.sh` passes locally. diff --git a/docs/choices.md b/docs/choices.md index 1fa5d8c..2d639e4 100644 --- a/docs/choices.md +++ b/docs/choices.md @@ -60,3 +60,173 @@ - Default: skip structured logging (log/tracing crates); reason: custom debug_log() + conditional eprintln! is sufficient for current scale. - Default: add cargo-audit + npm audit to CI; reason: catch known vulnerabilities early. - Default: keep stale worktrees (codex/fast-capture, codex/permission-gate-settings); reason: branches have unmerged work. + +2026-02-09 (apple-intel-feasibility) +- Default: treat Apple Intelligence step auto-description as optional feature gated by model availability (device/region/language/setting); reason: framework only works on Apple Intelligence-capable devices, must not block recording/export. +- Default: prefer native Swift wrapper (Tauri plugin/XPC/embedded CLI) over new Rust bindings for first integration; reason: Apple API is Swift-first; third-party Rust bindings are very new/low-adoption and add linkage/rpath risk. +- Default: avoid “CLI-only” bridges if possible; prefer in-process or GUI-host integration; reason: Apple DTS notes command-line tools can be more strictly rate limited than GUI apps. +- Default: do not ship Apple Intelligence trademark logos in-app unless Apple provides explicit UI glyph/badge licensing; use generic sparkle icon instead; reason: avoid trademark risk / implied endorsement. +- Default: use WebKit’s built-in OS-native HTML switch (``) for the toggle UI; reason: matches macOS System Settings look and degrades gracefully to checkbox. +- Default: increase panel height to `640` to avoid Settings view scrolling; reason: Settings now includes Apple Intelligence section + longer copy. +- Default: v1 uses text-only inputs (Step fields + captured AX label/role metadata) and does NOT depend on screenshot understanding; reason: avoids OCR/Vision complexity, aligns with device-scale model strengths (summarize/extract/classify). +- Default: generate AI descriptions post-recording / at export time (not in the click pipeline); reason: avoid adding latency/jitter to recording; simpler error handling; user can export without AI if unavailable. +- Decision: support envelope for Apple Intelligence descriptions = macOS 26+ + Apple Silicon + Apple Intelligence enabled + supported region/language; otherwise deterministic fallback descriptions; reason: Foundation Models availability/hardware gating. +- Default: no new macOS TCC permissions needed for basic step description generation; reason: Foundation Models uses system on-device model; only tool-calling into Contacts/Calendar/etc would trigger those standard permissions. +- Default: do not request/depend on Foundation Models adapter entitlement; reason: adapters are out-of-scope for v1; entitlement needed only for deploying custom adapters. +- Default: StepCast toggle = global setting (default OFF) with live availability status + explanation; reason: consistent exports, avoids surprises, and feature frequently unavailable on older Macs/regions. +- Default: tutorial includes a short "Optional: Apple Intelligence descriptions" section (requirements, how to enable in macOS + StepCast, privacy note); reason: reduces support burden and sets correct expectations. +- Default: open Apple Intelligence settings via `x-apple.systempreferences:com.apple.Siri-Settings.extension` with fallback `x-apple.systempreferences:com.apple.preference.siri`; reason: best-effort deep link to the correct System Settings pane. +- Default: persist toggle in `localStorage.appleIntelligenceDescriptions`; reason: simple, cross-version, no backend migration needed. +- Decision: implement Foundation Models integration via embedded Swift sidecar helper (build.rs `swiftc` -> include_bytes -> extract to cache -> JSON stdio); reason: FoundationModels is Swift-first; lowest-risk path for v1 without Rust bindings. +- Change: toggle UI uses a custom Apple-style switch (button + CSS), not WebKit ``; reason: consistent look/feel in Tauri WKWebView + matches requested UX. +- Decision: keep tray panel default height at 640 but auto-resize to 720 when Settings is open; reason: avoid scrolling in Settings without adding permanent empty space in the recorder UI. +- Default: AI description constraints = 1 sentence, max 110 chars, greedy sampling; reason: still short but allows location grounding ("from the Dock", "in Finder"). +- Default: AI descriptions use a deterministic baseline + quality gate (verb/kind/app/label checks) to prevent vague outputs and "tab" hallucinations; reason: user trust > model creativity, stable exports. +- Default: store additional AX grounding metadata (subrole, role_description, identifier, container_role/subrole) on each step; reason: clicked element is often a child (static text) inside a list row; container hints improve specificity. +- Default: when AX label missing (or ActionType::RightClick), run on-device Vision OCR on the step screenshot (ROI near click) and use the best near-click text as grounding label; reason: reliably captures sidebar items and filenames (e.g. "Downloads", "coreauth.png") without cloud/LLM vision. +- Default: never generate AI descriptions for auth placeholder steps or ActionType::Note; reason: avoid sensitive/system dialogs; note steps already user-authored. +- Default: missing-only/all generation never overwrites manual descriptions; per-step sparkle can overwrite; reason: protect user edits by default. +- Change: keep AX grounding metadata even when the clicked element has no label; attempt to recover label by scanning the AX child tree (depth-limited); reason: Finder/sidebar/file-list rows often store the visible text on child static text elements. +- Change: make OCR ROI selection prefer semantic AX sidebar hints; geometry fallback only when AX metadata missing; reason: avoid "left side = sidebar" hacks when AX already provides a clean signal. +- Change: Vision OCR uses `.accurate` + smaller min text height (0.010); reason: improve label extraction for small UI text in screenshots. +- Change: Right-click OCR grounding uses filename-like strings only (ignore context menu OCR); reason: prevent wrong targets like "Right-click Open". +- Change: propagate Apple Intelligence toggle across Tauri webviews via `ai-toggle-changed` event (plus storage fallback); reason: storage events are unreliable in some Tauri window setups. +- Change: Settings panel auto-resizes to content using `documentElement.scrollHeight` + `ResizeObserver`; reason: no scrolling for the last "eighth" when Settings content grows (eligibility copy, etc.). +- Change: AX label extraction prefers `AXTitleUIElement` + `AXPlaceholderValue` and avoids `AXValue` for text fields; reason: semantically correct labels without leaking user-entered text. +- Change: text-field steps baseline uses `Click ... field` (not `Enter ...`); reason: StepCast does not capture global typing events (would require Input Monitoring permission), so claiming typing is inaccurate. + +2026-02-10 (ax-pressable-scoring) +- Change: AX element selection prefers pressable elements (`AXPress`/`AXConfirm`) with a strong minimum score (>=220) and tries to pull the visible label from child `AXStaticText` when pressable; reason: many cross-platform apps expose custom buttons as pressable `AXGroup`s; prevents vague “App in App” steps and improves close/button grounding without OCR or per-app rules. +- Change: store clicked element bounds (percent within captured screenshot) as `step.ax.element_bounds` and use it to focus OCR ROI when needed; reason: if the click lands on a button background (not on the label text), click-distance OCR can pick the wrong nearby label. Element-bounds ROI keeps OCR “clean” and generic while staying on-device. +- Change: treat AX labels that look like GUID/hex IDs as non-human and ignore them for grounding; prefer `AXIdentifier`-derived labels over OCR; reason: some apps expose implementation IDs as AX labels (unhelpful for tutorials), and `AXIdentifier` often encodes the real control purpose (e.g. emoji button). +- Change: window controls (close/minimize/zoom) always use deterministic baseline text (no OCR, no model); reason: OCR picks random chrome text (“Ch”), and the model cannot improve accuracy over the AX subrole signal. +- Change: titleless overlay windows default to window_title `Popup` (not `Menu`) unless they are near the menu bar; reason: avoids misclassifying in-app popovers/pickers as menus and improves step kind classification downstream. + +2026-02-10 (ai-helper-packaging) +- Change: ship the Swift FoundationModels helper as a bundled resource (`src-tauri/bin/stepcast_ai_helper`) and execute it from the app bundle; reason: avoids running a cache-extracted unsigned binary which can break Gatekeeper/notarization expectations in release builds. + +2026-02-10 (list-selection-grounding) +- Default: for clicks inside list/table/outline containers, prefer the *selected row/item* label (via AXSelectedRows/AXSelectedChildren/AXSelectedItems) when the hit-test label looks structural/metadata-heavy; reason: AppKit lists often return container/view-mode labels at the click point ("List view"), and many apps embed timestamps/status metadata in AX labels. Selected-item label is usually the actual user target (file/chat). +- Change: classify list interactions using `AX container identifier` / `AX identifier` hints (table/list/outline/collection) even when the clicked element role is `AXButton`/`AXGroup`; reason: many apps implement list rows as pressable buttons, and the verb "Select …" is clearer than "Click … button" for tutorials. +- Change: treat common structural labels like "editor area"/"scroll area"/"split view" as generic (not grounding labels); reason: avoids "sad" descriptions and keeps output novice-friendly when the UI target isn't a named control. + +2026-02-11 (capture-overlay-stability) +- Change: classify titleless overlays by display-relative Y even when `window_id == main_window_id`; reason: prevents false non-overlay classification on multi-display/menu-overlay cases where frontmost window resolves to the overlay itself. +- Change: force menu-region capture when AX role is menu-related (`AXMenuItem`/`AXMenuBarItem`/`AXMenuButton`/`AXMenu`); reason: avoids tiny window-ID snippets for status/menu-bar interactions and restores consistent top-region capture. +- Change: for popup overlays, compute context union using the largest app window for the clicked PID when available; reason: prevents clipped popup-only screenshots (e.g. picker panels) without app-specific hacks. + +2026-02-11 (capture-overlay-stability-followup) +- Change: keep titleless `same window_id` as `NotOverlay`; reason: prevents false popup classification for legitimate titleless utility windows. Menu-bar interactions are now handled via explicit AX menu-role + top-region gating. +- Change: menu-role forced region capture is limited to top 500px of the clicked display; reason: avoids misclassifying mid-screen context menus as menu-bar captures. +- Change: popup union now prefers the clicked app’s window at the click point (`get_window_for_pid_at_click`) before falling back to largest PID window; reason: safer in multi-window apps than area-only selection. +- Change: `get_window_for_pid_at_click` now supports excluding the current popup window id; reason: prevents selecting the popup itself as "context window" and restores intended union-with-base-window behavior. +- Change: popup overlay path now always uses region capture with computed union bounds; reason: deterministic behavior and fewer tiny overlay-only window-ID captures. +- Change: `get_window_for_pid_at_click` returns the largest matching window containing the click (not first/front-most); reason: avoids selecting another small overlay and improves base-window context in stacked overlay scenarios. +- Change: clamp Dock/menu/top-region capture rectangles to clicked display bounds and adapt fixed widths/heights when display is smaller than defaults; reason: avoids off-screen/negative region math and prevents accidental clipped/empty captures on small or offset displays. +- Change: generic label filtering now rejects structural list labels (`sidebar`/`Seitenleiste`/`source list`/`list`) and metadata value labels (sizes/timestamps) as grounding targets; reason: prevents wrong outputs like "Select Seitenleiste" / "Double-click 2,4 MB". +- Change: metadata-label simplification now scores comma-separated segments and prefers recipient/entity segments (e.g. `Gesendet an ...`) over preview/status tokens; reason: improves list/chat target naming without app-specific rules. +- Change: right-click grounding now drops generic labels when OCR cannot recover a filename-like target; reason: safer generic baseline is better than wrong specific text like "Right-click List". +- Change: auth placeholder steps now carry deterministic description `Authenticate with Touch ID or enter your password to continue.`; reason: clearer guidance for end users and consistent export/editor fallback. +- Change: ignore implementation-style identifiers like `NS:8` / `AX123` as grounding labels; reason: prevents technical AX internals from leaking into user-facing step text. +- Change: filename detection now accepts truncated filenames with internal ellipsis (`foo...bar.jpeg`) but still rejects trailing-ellipsis menu items; reason: Finder list OCR often truncates long names. +- Change: selected-row AX extraction now validates candidates via `selected_label_is_probably_ui_target` before returning and keeps only semantic fallbacks; reason: avoids returning size/status cells when a file/chat target label is available in selected row descendants. + +2026-02-11 (capture-owner-hardening) +- Change: `find_attached_dialog_window` now enforces owner matching (clicked PID/main PID/app-name normalization) and rejects system-UI/fremde app windows; reason: prevent foreign dialogs (Finder/1Password/CleanMyMac/etc.) from hijacking capture target selection. +- Change: attached-dialog candidates now require sensible size ratio vs main window (4%-95%); reason: reject tiny floating overlays/noise while still allowing real sheets/dialogs. +- Change: app-name comparisons in capture selection use normalized matching (strip non-alnum + lowercase); reason: avoid false mismatches from hidden Unicode/control chars (e.g. `‎WhatsApp`). +- Change: clicked-app reconciliation now switches capture window only when a concrete window for clicked PID exists at click coordinates (`get_window_for_pid_at_click`), no fallback to largest PID window; reason: prevent capturing unrelated windows that were never clicked. +- Change: do not ignore `AXMenuBarItem` clicks anymore (still ignore `AXMenuButton` menu-open noise); reason: menu-bar opener clicks are meaningful user actions and should be recorded/described. +- Change: accept foreign topmost windows as dialog candidates when they are non-system, smaller than main window, and strongly overlap/are contained in main window; reason: system-hosted sheets/open-save panels can belong to helper processes and otherwise were lost. +- Change: for `is_sheet_dialog` captures, force region union of parent window + dialog; reason: better user comprehension than dialog-only crops. +- Change: sheet fast-path now unions AX dialog region with frontmost parent window (overlap-gated) and clamps to display bounds; reason: preserve dialog context while keeping race-condition robustness. +- Change: classify `AXMenu` role as `menu item` in Swift description heuristics; reason: actions like attachment menus should use "Choose ... from the menu" instead of generic click text. +- Change: for volatile AX roles (`AXMenuItem`/`AXMenu`/`AXGroup`), prefer region capture over window-id capture in the normal window path; reason: transient overlays/pickers can disappear before slower window-id capture completes. +- Change: keep menu-bar region behavior explicit (`should_use_menu_region_capture`) and add separate transient-capture policy (`should_prefer_transient_region_capture`); reason: deterministic, app-agnostic capture selection with clearer testable rules. +- Change: OCR filename preference is now right-click-only (not all double-click list actions); reason: folders/list items without file extensions (e.g. `var 9`) are valid targets and were previously mis-grounded. +- Change: relative-day labels (`Today/Heute/Yesterday/Gestern`) and timestamp tokens are treated as non-target metadata in AI grounding; reason: avoid wrong labels like chat date separators. +- Change: when no trustworthy grounding label exists, force deterministic baseline output (no free model rewrite); reason: prevent hallucinated targets under low-confidence conditions. +- Change: filter `AXMenuButton` menu-open noise before `next_step_id()` allocation in pipeline; reason: prevents step-id reuse and screenshot overwrite when the following click is the actual menu item. +- Change: keep a short-lived `last_menu_bar_click_ms` signal and treat near-top follow-up clicks as menu interactions; reason: AX hit-testing on transient menu rows can fall back to underlying app roles (`AXGroup`), causing wrong capture branch selection. +- Change: restrict transient region capture for `AXGroup` to popup overlays only; reason: generic in-window `AXGroup` clicks should not force volatile capture behavior and can pollute screenshot/context alignment. +- Change: skip traffic-light geometry fallback for menu roles (`AXMenuBarItem`/`AXMenuItem`/`AXMenu`/`AXMenuButton`); reason: prevents false close/minimize/zoom inference on menu-bar interactions. +- Change: when menu-region capture is chosen, force step `window_title` to `Menu`; reason: downstream description classification should consistently treat those steps as menu actions. +- Change: ignore `ax.element_bounds` ROI for menu-like/group hits with weak day-like labels (`Today/Heute/...`) and fall back to click-centered OCR ROI; reason: prevents OCR grounding from drifting into unrelated background text. + +2026-02-12 (ai-description-hardening) +- Change: checkbox verbs now use toggle semantics from current AX state (`checked=true` => `Disable`, `checked=false` => `Enable`); reason: previously steps could be described inverted. +- Change: menu/menu-bar/checkbox/window-control kinds now use deterministic baseline text (no model call), and model errors fall back to baseline; reason: removes red AI failures for otherwise well-grounded steps. +- Change: OCR list grounding now penalizes file-kind/status/date metadata labels and prefers row-aligned primary labels; reason: improves filename/folder target selection in Finder-like lists. +- Change: AX list clicks now attempt selected-row label replacement more aggressively (including one short retry for delayed selection updates); reason: reduce OCR dependence and improve first-click row accuracy. + +2026-02-12 (ai-description-hardening-followup) +- Default: hide generic window titles like `Ohne Titel`/`Untitled` from description context; reason: reduces noise and avoids confusing suffixes. +- Default: for list-item grounding, reject weak labels that look like file-kind/status/date/sidebar location when click is in content area; reason: better generic fallback than wrong specific target (`Filme`, `hreibtisch`). +- Default: for popup/group clicks, treat picker category labels (`Emoji/GIF/Stickers/Today`) as non-target context unless role is explicit tab/menu; reason: avoids mislabeling sticker-item clicks as category clicks. +- Default: if list OCR confidence is low or semantically weak, prefer deterministic generic phrasing (`Select the item...`) over speculative names; reason: stability > false precision. + +2026-02-12 (preclick-screencapturekit-buffer) +- Default: use ScreenCaptureKit pre-click ring buffer only for volatile click paths (`prefer_transient_region_capture`); reason: highest benefit where menus/popovers close fastest, minimal pipeline risk elsewhere. +- Default: keep existing CG/window/region capture as hard fallback when pre-click frame is unavailable or fails; reason: no regression in current capture baseline. +- Default: ring buffer depth `4` at `12` FPS with BGRA stream; reason: enough temporal history for menu clicks while keeping CPU/memory bounded. +- Default: maintain a single active ScreenCaptureKit display stream and switch on display change (first click after switch falls back); reason: avoid multi-stream handler ambiguity in current crate integration. +- Default: add linker rpath `/usr/lib/swift` in `src-tauri/build.rs`; reason: ensure test/dev binaries can resolve Swift runtime dylibs required by ScreenCaptureKit bridge. + +2026-02-12 (pixel-first-capture-default) +- Default: prefer `preclick_fullframe_capture` for all non-right-click, non-auth interactions; reason: pixel-level full-display frame is the most robust cross-app/web source of truth for transient overlays/menus. +- Default: keep right-click on existing context-menu path; reason: right-click menus appear after click and would be missed by strict pre-click capture. +- Default: keep auth dialogs on dedicated secure capture/placeholder path; reason: secure UI may not be capturable via regular full-frame semantics. +- Default: reject stale pre-click frames older than `500ms`; reason: avoid mismatched screenshots when stream/frame timing drifts. +- Default: reject pre-click frames captured after the click (`age_ms < 0`) and return no pre-click frame if all ring frames are newer than click timestamp; reason: prevent time-inverted frame selection under queue lag. + +2026-02-12 (ai-grounding-debug-telemetry) +- Default: keep list-item labels equal to `window_title` when AX container hints indicate real list/sidebar targets; reason: avoids false "generic label" rejection that pushed OCR drift (`Pepper` -> `vat`). +- Default: strip context-menu artifacts from OCR labels (`@ open/öffnen/...`) and broaden context-menu phrase detection; reason: prevent contamination like `"mixuino_errors @ öffnen"` becoming the target label. +- Default: remove Private Use Area glyphs from labels (e.g. ``); reason: icon-font artifacts are not user-facing text and pollute tutorial steps. +- Default: quality gate now returns explicit decision reasons (`accepted`, `quoted_label_lost`, etc.); reason: deterministic traceability for why baseline/candidate was chosen. +- Default: include per-step AI debug payload (`kind`, `groundingLabel`, `groundingOcr`, `baseline`, `candidate`, `qualityGateReason`) in generation response; reason: richer post-mortem data for fragile edge cases. + +2026-02-12 (baseline-first-no-typo-list) +- Default: prefer deterministic baseline text for fragile kinds (`list item`, `item`, `button`, `text field`, `tab`) when model rewrite differs; reason: stability over stylistic rewrites. +- Default: no language-specific typo correction list in grounding pipeline; reason: keep behavior multilingual and avoid locale-bound rules. + +2026-02-12 (result-hardening-round-2) +- Default: do not classify by `window_title=Menu` alone when AX role is not menu-like; reason: prevents false "Choose ... from the menu" wording on normal web/content clicks. +- Default: treat infrastructure labels like `Popover Dismiss Region` as generic UI labels; reason: avoid technical/internal identifiers in end-user instructions. +- Default: for sidebar-side list clicks (`x < 30%`), reject OCR-only labels that are not strong filename tokens; reason: wrong specific names are worse than stable generic steps. +- Default: for picker-like interactions (emoji/gif/sticker/picker signals in AX role/identifier), reject OCR/identifier labels for list items; reason: grid content OCR is volatile and produces non-actionable fragments. + +2026-02-13 (step-focus-crop-system) +- Default: persist non-destructive per-step crop metadata as `crop_region` percentages (`x/y/width/height`) instead of rewriting screenshots; reason: stable across resolutions, editable, and safe for rollback. +- Default: auto-focus crop only when capture is display-sized (>= ~screen bounds tolerance); reason: avoid over-cropping already tight window/dialog captures. +- Default: auto-focus center uses AX element bounds when available, else click position; reason: better first-pass focus without app-specific rules. +- Default: crop exports by transforming image bytes during export (HTML/Markdown/PDF) while keeping source files untouched; reason: output readability + consistent behavior across formats. + +2026-02-13 (step-focus-crop-followup) +- Default: apply auto-focus crop for very large captures based on display coverage (`area_ratio` / width+height ratios), not only exact fullscreen matches; reason: large sheet/dialog union captures were still too zoomed out for tutorial readability. +- Default: apply auto-focus crop in fast-path step creation (`sheet_fast_path`, `window_control_fast_path`) as well; reason: these early-return branches bypassed the normal crop decision and produced oversized screenshots. +- Default: tighten pre-click frame freshness window from `500ms` to `250ms`; reason: reduce stale-frame risk for transient overlays while keeping enough tolerance at current capture FPS. +- Default: increase pre-click buffer stream rate from `12` to `16` FPS; reason: improve probability of capturing transient UI state close to click-time with acceptable overhead. +2026-02-13 (screenshot-event-stability) +- Default: on frontend `step-updated` merges, preserve an existing `screenshot_path` when incoming payload omits it or clears it unexpectedly without `capture_status=Failed`; reason: prevents transient/partial update events from blanking already-captured thumbnails. +- Default: retry local screenshot image loads up to 2 times with short delay + cache-busting query; reason: reduce timing-related blank thumbnails when filesystem write and webview image decode race. +2026-02-13 (editor-crop-visibility-fix) +- Default: cropped editor rendering is activated whenever `crop_region` is valid (not gated on loaded natural image size); reason: avoids post-apply "image disappeared" state caused by sizing race. +- Default: cropped frame uses a deterministic fallback `aspect-ratio` (`16/9`) until natural image dimensions are known; reason: prevents zero-size layout collapse in absolute-position crop mode. +- Default: editor screenshot wrapper is block-level and width-constrained (`width: min(100%, 760px)`); reason: stable layout for both full and cropped frames. +2026-02-13 (editor-crop-drag-reposition) +- Default: existing crops can be repositioned directly in step preview via pointer drag (left mouse), commit on pointer release only; reason: fast UX without command spam during drag. +- Default: crop drag uses direct manipulation semantics (content follows cursor), implemented by translating crop origin against pointer delta scaled by crop size; reason: intuitive repositioning of visible area. +- Default: no-op click (down/up without movement) does not persist crop updates; reason: avoid unnecessary writes/events. +2026-02-13 (editor-crop-drag-runtime-hardening) +- Default: disable native browser image drag in editor screenshot surfaces (`draggable=false`, prevent default dragstart, `-webkit-user-drag: none`); reason: avoids ghost-image/plus-cursor interference during crop reposition. +- Default: crop drag preview updates are batched via `requestAnimationFrame`; reason: smoother drag with lower React render pressure under frequent pointermove events. +- Default: commit crop changes only when movement exceeds a minimal epsilon (`0.02` percent units); reason: ignore jitter/no-op clicks and reduce unnecessary state writes/events. +- Default: editor and timeline images use `loading="lazy"` + `decoding="async"`; reason: lower initial decode cost for long recordings. +2026-02-14 (pre-push-coverage-gate) +- Default: keep strict per-file thresholds (`80/80/80/80`) and fix via targeted tests instead of lowering config; reason: preserves quality gate and prevents regressions on critical editor/crop flows. +- Default: cover branch gaps through UI-level tests (RTL/Vitest) before considering instrumentation ignores; reason: executable behavior checks are more trustworthy than coverage-only annotations. + +2026-02-14 (swift-foundationmodels-ci-compat) +- Default: compile AI helper with conditional `FoundationModels` import and feature guards (`#if canImport(FoundationModels)`); reason: keep CI/macOS runners without Apple Intelligence SDK buildable. +- Default: when `FoundationModels` is unavailable, return deterministic baseline output with explicit reason (`model_unavailable_fallback`) instead of throwing/aborting; reason: preserve functional captures and editor flow without hard failure. diff --git a/docs/specs/2026-02-09-apple-intelligence-descriptions-toggle.md b/docs/specs/2026-02-09-apple-intelligence-descriptions-toggle.md new file mode 100644 index 0000000..05978cf --- /dev/null +++ b/docs/specs/2026-02-09-apple-intelligence-descriptions-toggle.md @@ -0,0 +1,43 @@ +# Apple Intelligence Descriptions (Toggle + Generation + Editor UX) + +Date: 2026-02-09 + +## Goal + +- Provide a global opt-in toggle for Apple Intelligence-powered step descriptions. +- Generate concise per-step descriptions on-device using the Foundation Models framework. +- Communicate requirements + privacy clearly (on-device; no step upload). + +## Non-Goals + +- Screenshot understanding (OCR/Vision / image model prompts). +- Tool-calling to external data sources (Contacts/Calendar/etc). +- Multi-language UI for descriptions (v1 uses concise English prompts). + +## UX + +- Settings: `Apple Intelligence -> Use for step descriptions` (global toggle, default OFF). +- Toggle UI: custom Apple-style toggle (button + CSS) to match macOS System Settings look reliably inside Tauri/WKWebView. +- Eligibility hint (runtime via Foundation Models availability): + - Device must support Apple Intelligence. + - Apple Intelligence must be enabled in macOS System Settings. + - Model may be temporarily not ready (download/initialization). +- Provide a button to open macOS settings (deep link + fallback): + - Primary deep link: `x-apple.systempreferences:com.apple.Siri-Settings.extension` + - Fallback: `x-apple.systempreferences:com.apple.preference.siri` +- Recorder: after stopping a recording, if toggle is ON, auto-generate descriptions for steps that are missing one. +- Step Editor: + - Toolbar button `Enhance Steps` (uses missing-only when missing exists, otherwise regenerates non-manual steps). + - Per-step sparkle button to regenerate that step. + - Clicking the description text allows inline manual editing (stored as manual source). +- Quick Start (welcome banner): one bullet mentioning the optional toggle. + +## Storage + +- `localStorage.appleIntelligenceDescriptions` = `"true" | "false"` + +## Privacy Copy (must be precise) + +- For Apple Intelligence descriptions: runs on-device and works offline. +- StepCast does not upload recorded steps or screenshots for this feature (no third-party LLMs). +- (Do not make broader claims about analytics/update checks.) diff --git a/docs/specs/2026-02-12-ai-grounding-debug-telemetry.md b/docs/specs/2026-02-12-ai-grounding-debug-telemetry.md new file mode 100644 index 0000000..aaf8ef2 --- /dev/null +++ b/docs/specs/2026-02-12-ai-grounding-debug-telemetry.md @@ -0,0 +1,35 @@ +# Spec: AI Grounding + Debug Telemetry Hardening + +Date: 2026-02-12 +Status: implemented + +## Problem +Recent sessions show capture correctness but unstable AI labels: +- wrong target label from OCR drift (`vat` instead of `Pepper`) +- context-menu contamination in right-click labels (`@ öffnen`) +- OCR typo in Finder sidebar (`Schreptisch`) +- icon-font glyph leakage in labels (``) +- low observability for why baseline/candidate was chosen + +## Goals +1. Keep high-specificity labels when reliable. +2. Fall back deterministically when uncertain. +3. Expose per-step decision data for audits. + +## Non-Goals +- app-specific rule engines +- complex ML confidence calibration + +## Changes +1. Strengthen label normalization/cleanup (context-menu suffixes, icon glyph filtering, typo canonicalization). +2. Keep `window_title` label for list/sidebar contexts with list container signals. +3. Make quality gate return decision reason, not only text. +4. Include per-step debug payload in AI helper response and recorder logs. + +## Acceptance +- replay latest problematic trace yields: + - `step-005` no `vat` + - `step-030` no `Schreptisch` + - `step-031` no `@ öffnen` + - `step-008/009` no icon glyph +- `cargo check` passes. diff --git a/docs/specs/2026-02-12-preclick-screencapturekit-buffer.md b/docs/specs/2026-02-12-preclick-screencapturekit-buffer.md new file mode 100644 index 0000000..a0b5e27 --- /dev/null +++ b/docs/specs/2026-02-12-preclick-screencapturekit-buffer.md @@ -0,0 +1,40 @@ +# Pre-Click ScreenCaptureKit Buffer + +Date: 2026-02-12 + +## Problem + +Volatile UI (browser/Telegram menus, popovers) closes before current capture path finishes. +Result: screenshot often misses the clicked menu state; AI label can still be correct from AX, but image context is wrong. + +## Goal + +Use a short ScreenCaptureKit frame ringbuffer and prefer a pre-click frame for volatile interactions (`AXMenu`, `AXMenuItem`, transient popup/group roles). + +## Scope + +- Add macOS-only `pre_click_buffer` recorder module. +- Start buffer when recording starts; stop on stop/discard. +- In pipeline transient capture branch, attempt pre-click frame first. +- Keep existing CG/window/region capture as fallback. + +## Approach + +1. ScreenCaptureKit stream for active display, low-latency settings (BGRA, limited queue depth, capped fps). +2. Maintain small in-memory ringbuffer with recent frames + capture timestamps. +3. On volatile click: + - Select latest frame at or before click timestamp (fallback: latest frame). + - Write frame to current `screenshot_path`. + - Use display bounds as capture bounds for click percentages. +4. If unavailable/failed, continue existing capture logic unchanged. + +## Non-Goals + +- No OCR/AI prompt changes in this patch. +- No replacement of existing capture pipeline; only an additional robust fast-path. + +## Verification + +- Unit tests for frame selection policy (before-click preference + fallback). +- `cargo test` targeted recorder tests. +- `cargo check` for full crate compile. diff --git a/docs/specs/2026-02-13-editor-crop-drag-performance.md b/docs/specs/2026-02-13-editor-crop-drag-performance.md new file mode 100644 index 0000000..6d4f396 --- /dev/null +++ b/docs/specs/2026-02-13-editor-crop-drag-performance.md @@ -0,0 +1,28 @@ +# Spec: Editor Crop Drag Performance & UX Hardening + +Date: 2026-02-13 +Owner: codex +Status: Implemented + +## Goal +- Make drag-based crop repositioning feel immediate and stable. +- Eliminate native image drag artifacts (ghost image + plus cursor). +- Keep render cost low for larger recordings. + +## Problems Observed +- Browser native image drag could trigger while user drags crop preview. +- Crop updates on every pointer event can cause unnecessary re-render churn. +- Full-size screenshots in long lists increase initial rendering and decode cost. + +## Decisions +- Disable native image drag (`draggable=false`, `onDragStart preventDefault`, `-webkit-user-drag: none`). +- Keep pointer events on crop frame, not image (`pointer-events: none` on cropped image). +- Batch pointer-move crop preview with `requestAnimationFrame`. +- Persist crop only on pointer release and only when movement is meaningful. +- Use `loading="lazy"` and `decoding="async"` for preview and thumbnail images. + +## Validation +- Unit tests for editor crop interactions remain green. +- Full frontend test suite remains green. +- Production build passes. + diff --git a/docs/specs/2026-02-13-editor-crop-visibility-fix.md b/docs/specs/2026-02-13-editor-crop-visibility-fix.md new file mode 100644 index 0000000..00f9e39 --- /dev/null +++ b/docs/specs/2026-02-13-editor-crop-visibility-fix.md @@ -0,0 +1,40 @@ +# Spec: Editor Crop Apply Keeps Screenshot Visible + +Date: 2026-02-13 +Owner: codex +Status: Implemented + +## Problem +- After applying a crop in the step editor, the screenshot can disappear. +- User impact: recorded instructions become unusable because the key visual context is gone. + +## Root Cause +- Cropped mode uses absolute image positioning. +- Wrapper/frame sizing could collapse (0-size layout path) after crop apply. +- Crop rendering also depended on loaded natural image size; that introduced a fragile state boundary. + +## Requirements +- Applying a crop must never hide the screenshot. +- Crop view must render immediately, even if natural image size is not yet known. +- No destructive image rewrite; UI-only crop state stays in `crop_region`. + +## Implementation +- `EditorStepCard`: + - Enable cropped rendering whenever a valid non-full crop exists (independent of natural-size availability). + - Add stable frame style with fallback `aspect-ratio` (`16/9`) + `width: 100%`. + - Keep natural-size update on regular image load and on crop-modal image load. +- `editor.css`: + - Make editor image wrapper block-level with deterministic width. + - Ensure crop frame is `position: relative` and width-constrained. + - Prevent collapse when cropped image is absolutely positioned. +- Regression test: + - Added test ensuring cropped frame is rendered with a non-empty aspect ratio style when `crop_region` exists. + +## Validation +- Run frontend tests for `EditorStepCard`. +- Run full test suite + build. +- Manual replay: + - Open editor. + - Apply crop on a step with screenshot. + - Verify image remains visible and marker position remains coherent. + diff --git a/docs/specs/2026-02-13-step-crop-focus-system.md b/docs/specs/2026-02-13-step-crop-focus-system.md new file mode 100644 index 0000000..ee73591 --- /dev/null +++ b/docs/specs/2026-02-13-step-crop-focus-system.md @@ -0,0 +1,48 @@ +# Spec: Step Focus Crop System + +Date: 2026-02-13 +Status: Done + +## Goal +Provide reliable, non-destructive focus crops for recorded step screenshots so guides remain readable and actionable, especially when source captures are full-screen. + +## Requirements +1. Non-destructive: always keep original screenshot file untouched. +2. Persisted crop metadata per step as percentages (`x/y/width/height`) so crops are resolution-independent. +3. Recorder should set a sensible default focus crop for very large captures (not only exact full-screen) to keep dialogs/overlays readable. +4. Editor must allow manual crop adjust + reset-to-full. +5. Click marker must remain accurate after crop in panel/editor/export. +6. Export (HTML/Markdown/PDF) must apply crop to image output. +7. Backward compatibility: steps without crop render/export as full image. + +## Data Model +- Add optional `crop_region: BoundsPercent` to step model (frontend + backend). +- Validation constraints: clamp values to [0..100], enforce min width/height. + +## Recorder Default Focus Heuristic +Apply when capture bounds are large display-like frames (coverage heuristic against clicked display). +- Base around click location. +- If AX element bounds exist, expand around element with margin. +- Clamp to display bounds in percent. +- Leave `None` for auth placeholders and already-tight captures. + +## Editor UX +- Add "Crop" action on step card. +- Open modal cropper with live preview and click marker context. +- Save => persist `crop_region`. +- Reset => clear `crop_region` (full image). + +## Export Rules +- If `crop_region` exists, crop image bytes during export pipeline before encoding. +- Marker coordinates remapped to cropped coordinate space for HTML/PDF overlays. +- Markdown image output uses cropped bitmap. + +## Edge Cases +- Invalid crop metadata -> ignore crop (fallback full image). +- Marker outside crop -> hide marker (avoid misleading point). +- Missing screenshot -> no image/crop rendering. + +## Verification +- Rust unit tests for crop normalization + exported marker remap. +- Frontend tests for marker remap and crop UI command calls. +- Existing tests must remain green. diff --git a/docs/specs/2026-02-14-pre-push-coverage-gate.md b/docs/specs/2026-02-14-pre-push-coverage-gate.md new file mode 100644 index 0000000..ee2626a --- /dev/null +++ b/docs/specs/2026-02-14-pre-push-coverage-gate.md @@ -0,0 +1,29 @@ +# 2026-02-14 pre-push coverage gate hardening + +## Context +Pre-push hook blocks branch push due per-file 80% coverage thresholds on: +- `src/components/EditorWindow.tsx` +- `src/components/EditorStepCard.tsx` +- `src/components/SettingsSheet.tsx` +- `src/components/StepItem.tsx` +- `src/utils/stepCrop.ts` + +## Goal +Raise coverage via behavior tests (no threshold relaxation, no ignore hints), keep runtime logic unchanged. + +## Scope +- Add targeted tests for unexecuted branches: + - AI toggle sync/fallback and generation branching + - crop modal controls + retry branches + - settings fallback/openUrl/toggle branches + - thumbnail retry and sortable branches + - crop helper utility branches +- Validate with exact pre-push chain: + - `cargo clippy -- -D warnings` + - `npm run test:coverage` + - `npx tsc --noEmit` + +## Non-goals +- No feature/UI behavior change +- No runtime code refactor +- No threshold config changes diff --git a/docs/step-editor-design-spec.md b/docs/step-editor-design-spec.md new file mode 100644 index 0000000..b9b6f21 --- /dev/null +++ b/docs/step-editor-design-spec.md @@ -0,0 +1,412 @@ +# Step Editor Design Spec + +## Research Date: 2026-02-09 + +## Current State + +The editor (`editor.css` + `EditorStepCard.tsx` + `EditorWindow.tsx`) already has a solid foundation: +- CSS custom properties for light/dark theming +- 12px border-radius cards with `var(--border)` borders +- SF Pro system font stack +- 900px max-width centered layout +- Correct accent color (`#7C5CFC` / `#A78BFA`) + +What follows are concrete, high-impact upgrades derived from analyzing CleanShot X, Scribe, Tango, Linear, Notion, and Apple HIG patterns. + +--- + +## 1. Layout Pattern: Timeline + Cards (Hybrid) + +**What the best tools do:** +- Scribe/Tango: Vertical card stack, each card = one step (screenshot + number + description + note) +- Linear: Single-column list of dense, aligned cards with 8px spacing scale +- CleanShot X: Floating annotation overlay with numbered step counters + +**Recommendation — "Timeline Cards":** + +Add a thin vertical timeline connector on the left side of the step list. Each step card gets a small numbered badge that sits on the timeline line. This creates visual flow and progression without the complexity of a full alternating timeline. + +``` + [1]---[ Step Card: header / note / screenshot ] + | + [2]---[ Step Card: header / note / screenshot ] + | + [3]---[ Step Card: header / note / screenshot ] +``` + +### CSS Implementation + +```css +/* Timeline container */ +.editor-steps { + display: flex; + flex-direction: column; + gap: 0; /* remove gap, use padding instead */ + position: relative; + padding-left: 48px; /* space for timeline + badge */ +} + +/* Vertical connector line */ +.editor-steps::before { + content: ''; + position: absolute; + left: 19px; /* center of 40px badge area */ + top: 0; + bottom: 0; + width: 2px; + background: var(--border); + border-radius: 1px; +} + +/* Each step card wrapper */ +.editor-step { + position: relative; + margin-bottom: 20px; +} + +/* Step number badge on the timeline */ +.editor-step-badge { + position: absolute; + left: -48px; + top: 14px; /* align with header center */ + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--accent); + color: #fff; + font-size: 12px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + box-shadow: 0 0 0 4px var(--bg-primary); /* "cut through" the line */ +} +``` + +**Why this works:** Creates visual progression (users can scan step numbers instantly), feels like a professional documentation tool (Scribe/Tango pattern), and the connecting line gives a sense of flow. The `box-shadow` trick on the badge creates a clean "punch through" effect on the timeline line without extra markup. + +--- + +## 2. Screenshot Display + +**What the best tools do:** +- CleanShot X: `border-radius: 10px`, `box-shadow: 0 10px 30px rgba(0,0,0,0.1)`, screenshots sit on a subtle background +- Tango: Screenshots have a thin border + slight shadow, displayed at full card width +- Scribe: Screenshots are full-bleed within the card, annotation highlights drawn on top +- Linear: Not screenshot-heavy, but media always has consistent rounding + +**Recommendation — "Elevated Screenshot":** + +```css +.editor-step-image { + padding: 16px; + background: var(--bg-secondary); + border-radius: 0 0 12px 12px; /* match card bottom corners */ +} + +.editor-image-wrapper img { + display: block; + max-width: 100%; + height: auto; + border-radius: 8px; /* rounded screenshot corners */ + border: 1px solid var(--border); /* thin frame */ + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.06), + 0 8px 24px rgba(0, 0, 0, 0.04); /* layered soft shadow */ +} + +/* Dark mode: deeper shadow */ +@media (prefers-color-scheme: dark) { + .editor-image-wrapper img { + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.2), + 0 8px 24px rgba(0, 0, 0, 0.15); + border-color: rgba(255, 255, 255, 0.06); + } +} +``` + +**Key details:** +- 8px border-radius on screenshots (not too rounded, not sharp) +- Layered box-shadow (two layers = more depth than a single shadow) +- 1px border gives definition, especially in light mode where shadows are subtle +- The `bg-secondary` padding area creates a "stage" for the screenshot (CleanShot X pattern) + +--- + +## 3. Step Number / Header Design + +**What the best tools do:** +- Scribe: Numbered circular badges (purple/blue accent) with bold step text beside them +- Tango: Step numbers in colored pills, description in regular weight below +- CleanShot X Counter tool: Numbered circles with white text on colored background +- Linear: No step numbers, but uses pill-shaped status badges with subtle backgrounds + +**Recommendation — Two options (pick one):** + +### Option A: Accent Badge (Scribe-style) — Recommended + +Already described in the timeline section above. The numbered circle sits on the timeline. Inside the header, remove the redundant "Step N" text and instead show just the action description. + +```css +.editor-step-header { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: var(--bg-primary); /* same as card, not secondary */ + border-bottom: 1px solid var(--border); +} + +/* Remove old step-number styling; number is now in the badge */ +.editor-step-desc { + font-size: 14px; /* bump up slightly */ + font-weight: 600; + color: var(--text-primary); + line-height: 1.3; +} +``` + +### Option B: Inline Pill Badge (Linear-style) + +If you prefer no timeline, keep the step number inline but style it as a colored pill: + +```css +.editor-step-number { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 22px; + padding: 0 8px; + border-radius: 6px; + background: rgba(124, 92, 252, 0.12); /* accent at 12% opacity */ + color: var(--accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; +} + +/* Dark mode */ +@media (prefers-color-scheme: dark) { + .editor-step-number { + background: rgba(167, 139, 250, 0.15); + } +} +``` + +**Option A is recommended** because it matches the timeline pattern and creates a more distinctive, premium feel. Option B is simpler to implement if you skip the timeline. + +--- + +## 4. Notes / Annotations Area + +**What the best tools do:** +- Scribe: Inline text below screenshot, editable on click, with a subtle dashed border placeholder +- Tango: Description text sits above the screenshot, editable inline +- Notion: Inline editing with a subtle placeholder that says "Empty. Click to edit." +- Linear: Descriptions use a muted secondary color, become editable with a single click + +**Recommendation — "Refined Inline Note":** + +Your current implementation is already close. Key refinements: + +```css +.editor-step-note-row { + padding: 10px 16px 12px; + border-bottom: 1px solid var(--border); +} + +.editor-step-note-btn { + display: block; + width: 100%; + text-align: left; + padding: 8px 12px; + border: 1px dashed transparent; /* invisible border by default */ + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + font-size: 13px; + font-family: inherit; + line-height: 1.5; + cursor: pointer; + transition: all 0.15s ease; + -webkit-app-region: no-drag; +} + +/* Placeholder state */ +.editor-step-note-btn:not(.has-note) { + font-style: italic; + opacity: 0.6; +} + +/* Has a note — show it as normal text */ +.editor-step-note-btn.has-note { + color: var(--text-primary); + opacity: 1; +} + +/* Hover reveals the edit affordance */ +.editor-step-note-btn:hover { + border-color: var(--border); + background: var(--bg-secondary); +} + +.editor-step-note-input { + display: block; + width: 100%; + padding: 8px 12px; + border: 1px solid var(--accent); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + line-height: 1.5; + resize: vertical; + outline: none; + box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.1); /* focus ring */ +} +``` + +**What makes this feel polished:** +- No visible border until hover (Notion pattern — clean by default) +- Subtle background shift on hover signals "this is editable" +- Focus ring using box-shadow (not outline) for consistent cross-browser appearance +- Italic + reduced opacity for empty placeholder state (visual hierarchy) + +--- + +## 5. Card Container Refinements + +**What the best tools do:** +- CleanShot X: `border-radius: 10px`, light shadows +- Linear: 8px spacing scale, minimal borders, increased contrast via subtle elevation +- Modern cards (2025): 12-16px border-radius, layered shadows, 1px borders at 10% opacity + +**Recommendation:** + +```css +.editor-step { + border: 1px solid var(--border); + border-radius: 14px; /* slightly larger for premium feel */ + overflow: hidden; + background: var(--bg-primary); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); /* minimal resting elevation */ + transition: box-shadow 0.2s ease, border-color 0.2s ease; +} + +.editor-step:hover { + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.06), + 0 8px 24px rgba(0, 0, 0, 0.03); + border-color: rgba(0, 0, 0, 0.15); /* slightly stronger border */ +} + +/* Dark mode hover */ +@media (prefers-color-scheme: dark) { + .editor-step:hover { + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.15), + 0 8px 24px rgba(0, 0, 0, 0.1); + border-color: rgba(255, 255, 255, 0.12); + } +} +``` + +**Why:** Hover elevation change gives cards a feeling of interactivity and depth. The transition duration (0.2s) is fast enough to feel responsive but smooth enough to look intentional. + +--- + +## 6. What Makes It Feel "Modern and Polished" + +Synthesized from all sources: + +### Typography +- **Use Inter Display or SF Pro Display for the page title** (22px, weight 700). You already use SF Pro via `-apple-system` — good. +- **Body text at 13-14px** with 1.4-1.5 line-height. Your current 13px is correct. +- **Step descriptions at 14px weight 600** — slightly larger than body to create hierarchy. +- **Secondary text at reduced opacity** rather than a completely different color (Linear pattern: use 60-75% opacity of primary text). + +### Spacing +- **8px spacing scale** (Linear's approach): 8, 12, 16, 24, 32, 48. You're already mostly aligned. +- **24px gap between cards** (upgrade from current 20px for slightly more breathing room). +- **16px internal padding** on all card sections — consistent horizontal rhythm. + +### Color +- **Neutral-first palette** — you already have this with `--text-secondary` and `--bg-secondary`. +- **Accent color used sparingly** — only for badges, focus states, and primary actions. +- **Avoid pure black text on white** — your `#1d1d1f` is perfect (Apple's approach). +- **Dark mode: increase shadow intensity** 2-3x, reduce border opacity. You're doing this. + +### Micro-interactions +- All interactive elements should have `transition: all 0.15s ease` (you already do this). +- Consider adding a very subtle `transform: translateY(-1px)` on card hover for "lift" effect. +- Focus states should use `box-shadow` focus rings, not browser outlines (already in place). + +### Overall "Premium" Signals +1. **Consistent border-radius**: Pick 8, 12, or 14px and use it EVERYWHERE. Mixed radii feel cheap. +2. **Layered shadows** (two `box-shadow` values): Creates more realistic depth than a single shadow. +3. **Subtle background differentiation**: `--bg-secondary` for screenshot areas, `--bg-primary` for text areas. +4. **Generous whitespace**: Better to have too much padding than too little. +5. **No hard borders between sections** where possible — use background color changes instead. + +--- + +## 7. Dark Mode Considerations + +Based on Apple HIG and Linear's approach: + +| Element | Light | Dark | +|---------|-------|------| +| Card background | `#ffffff` | `#1c1c1e` (current) | +| Screenshot stage | `#f5f5f7` | `#2c2c2e` (current) | +| Border | `rgba(0,0,0,0.1)` | `rgba(255,255,255,0.1)` (current) | +| Shadow intensity | 4-6% opacity | 15-20% opacity | +| Badge text on accent | `#fff` | `#fff` (stays) | +| Accent color | `#7C5CFC` | `#A78BFA` (lighter — current) | + +Your current dark mode is well-considered. The one upgrade: + +```css +/* Dark mode: add a very subtle inner glow to cards for definition */ +@media (prefers-color-scheme: dark) { + .editor-step { + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), /* top edge highlight */ + 0 1px 3px rgba(0, 0, 0, 0.2); + } +} +``` + +This `inset` top-edge highlight is a technique Apple uses in macOS system controls and Linear uses in its cards. It gives a subtle "glass edge" effect in dark mode that prevents cards from looking flat. + +--- + +## 8. Priority Implementation Order (Biggest Impact First) + +1. **Screenshot shadow + border** (5 min) — Instant premium feel +2. **Step number pill/badge styling** (10 min) — Visual identity for steps +3. **Card hover elevation** (5 min) — Interactivity signal +4. **Note area refinement** (10 min) — Clean placeholder + focus ring +5. **Timeline connector** (20 min) — Requires small React changes + CSS +6. **Dark mode inset highlights** (5 min) — Polish +7. **Typography fine-tuning** (5 min) — Step description weight/size bump + +--- + +## Sources + +- [CleanShot X Features](https://cleanshot.com/features) +- [How We Redesigned Linear UI](https://linear.app/now/how-we-redesigned-the-linear-ui) +- [10 Card UI Design Examples 2025](https://bricxlabs.com/blogs/card-ui-design-examples) +- [Linear Design: The SaaS Design Trend](https://blog.logrocket.com/ux-design/linear-design/) +- [Accessible Linear Design Dark/Light Modes](https://blog.logrocket.com/how-do-you-implement-accessible-linear-design-across-light-and-dark-modes) +- [Scribe Step-by-Step Guides](https://scribe.com) +- [Tango Guide Editor](https://www.tango.ai/) +- [Apple Dark Mode HIG](https://developer.apple.com/design/human-interface-guidelines/dark-mode) +- [Flowbite Timeline Component](https://flowbite.com/docs/components/timeline/) +- [Vertical Timelines with Tailwind CSS](https://cruip.com/3-examples-of-brilliant-vertical-timelines-with-tailwind-css/) +- [20 Modern UI Design Trends 2025](https://medium.com/@baheer224/20-modern-ui-design-trends-for-developers-in-2025-efdefa5d69e0) +- [Notion Gallery View Guide](https://super.so/blog/notion-gallery-view-a-comprehensive-guide) diff --git a/editor.html b/editor.html new file mode 100644 index 0000000..93368a9 --- /dev/null +++ b/editor.html @@ -0,0 +1,14 @@ + + + + + + + Step Editor + + + +
+ + + diff --git a/package-lock.json b/package-lock.json index 158e897..b9b975c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,25 @@ { "name": "stepcast", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stepcast", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@aptabase/tauri": "^0.4.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.0", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "react-image-crop": "^11.0.10" }, "devDependencies": { "@tauri-apps/cli": "^2", @@ -521,6 +525,59 @@ "node": ">=20.19.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -2773,6 +2830,15 @@ "react": "^19.2.4" } }, + "node_modules/react-image-crop": { + "version": "11.0.10", + "resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.0.10.tgz", + "integrity": "sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==", + "license": "ISC", + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -3042,6 +3108,12 @@ "node": ">=20" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.8.3", "dev": true, diff --git a/package.json b/package.json index 730529c..4672073 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri", + "session:ai-report": "node scripts/session-ai-report.js", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", @@ -20,13 +21,17 @@ }, "dependencies": { "@aptabase/tauri": "^0.4.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.0", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "react-image-crop": "^11.0.10" }, "devDependencies": { "@tauri-apps/cli": "^2", diff --git a/scripts/session-ai-report.js b/scripts/session-ai-report.js new file mode 100644 index 0000000..b04dc02 --- /dev/null +++ b/scripts/session-ai-report.js @@ -0,0 +1,239 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +function parseArgs(argv) { + const args = { session: null, dir: null, trace: null }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === "--session") args.session = argv[++i] ?? null; + else if (a === "--dir") args.dir = argv[++i] ?? null; + else if (a === "--trace") args.trace = argv[++i] ?? null; + else if (a === "-h" || a === "--help") args.help = true; + } + return args; +} + +function sessionsRoot() { + return path.join(os.homedir(), "Library", "Caches", "com.w0nk1.stepcast", "sessions"); +} + +function listDirsSortedByMtime(root) { + if (!fs.existsSync(root)) return []; + const entries = fs.readdirSync(root, { withFileTypes: true }); + const dirs = entries + .filter((e) => e.isDirectory()) + .map((e) => path.join(root, e.name)) + .map((p) => ({ p, mtimeMs: fs.statSync(p).mtimeMs })) + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .map((x) => x.p); + return dirs; +} + +function pickSessionDir(args) { + if (args.dir) return args.dir; + const root = sessionsRoot(); + if (args.session) return path.join(root, args.session); + const dirs = listDirsSortedByMtime(root); + return dirs[0] ?? null; +} + +function tryReadJson(filePath) { + try { + const s = fs.readFileSync(filePath, "utf8"); + return JSON.parse(s); + } catch { + return null; + } +} + +function listAiTraceFiles(sessionDir) { + const files = fs.readdirSync(sessionDir); + return files + .filter((f) => /^ai-trace-\d+-(request|response)\.json$/.test(f)) + .map((f) => path.join(sessionDir, f)) + .map((p) => ({ p, mtimeMs: fs.statSync(p).mtimeMs })) + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .map((x) => x.p); +} + +function pickTraceTs(sessionDir, args) { + if (args.trace) return String(args.trace); + const files = listAiTraceFiles(sessionDir); + const first = files[0]; + if (!first) return null; + const m = path.basename(first).match(/^ai-trace-(\d+)-/); + return m?.[1] ?? null; +} + +function readAiTrace(sessionDir, traceTs) { + if (!traceTs) return { request: null, response: null }; + const req = tryReadJson(path.join(sessionDir, `ai-trace-${traceTs}-request.json`)); + const res = tryReadJson(path.join(sessionDir, `ai-trace-${traceTs}-response.json`)); + return { request: req, response: res }; +} + +function parseRecordingLog(logPath) { + if (!fs.existsSync(logPath)) return { byStepId: new Map(), aiDesc: new Map(), lines: [] }; + const lines = fs.readFileSync(logPath, "utf8").split("\n").filter(Boolean); + const byStepId = new Map(); + const aiDesc = new Map(); + + // Heuristic: events are sequential. We attach the nearest preceding `ax_click` + `click` + // to the next `screenshot_path=.../step-XYZ.png` we see. + let lastClick = null; + let lastAx = null; + + for (const line of lines) { + if (line.includes(" click: ")) lastClick = line; + if (line.includes(" ax_click: ")) lastAx = line; + + const sp = line.match(/screenshot_path=([^ ]+\/(step-\d+)\.png)/); + if (sp) { + const stepId = sp[2]; + const entry = byStepId.get(stepId) ?? { stepId, screenshotPath: sp[1], click: null, ax: null, extra: [] }; + entry.screenshotPath = sp[1]; + entry.click = lastClick; + entry.ax = lastAx; + entry.extra.push(line); + byStepId.set(stepId, entry); + continue; + } + + const ai = line.match(/ai_desc\s+trace=\d+\s+id=([^\s]+)\s+text=(.*)$/); + if (ai) { + aiDesc.set(ai[1], ai[2]); + continue; + } + const fail = line.match(/ai_desc_failed\s+trace=\d+\s+id=([^\s]+)\s+error=(.*)$/); + if (fail) { + aiDesc.set(fail[1], `<> ${fail[2]}`); + continue; + } + } + + return { byStepId, aiDesc, lines }; +} + +function extractAxSummary(axLine) { + if (!axLine) return null; + const role = axLine.match(/role=([^ ]+)/)?.[1] ?? ""; + const label = axLine.match(/label='([^']*)'/)?.[1] ?? ""; + const parentRole = axLine.match(/parent_role=Some\\(\"([^\"]+)\"\\)/)?.[1] ?? null; + const topRole = axLine.match(/top_role=Some\\(\"([^\"]+)\"\\)/)?.[1] ?? null; + return { role, label, parentRole, topRole }; +} + +function renderReport({ sessionDir, traceTs, aiTrace, log }) { + const steps = []; + + // Prefer request.steps order when available (this is the exact set sent to the model). + if (aiTrace?.request?.steps && Array.isArray(aiTrace.request.steps)) { + for (const s of aiTrace.request.steps) { + const stepId = s?.id; + if (!stepId) continue; + steps.push(stepId); + } + } else { + // Fallback: use screenshot files. + const files = fs.existsSync(sessionDir) ? fs.readdirSync(sessionDir) : []; + for (const f of files.filter((x) => /^step-\d+\.png$/.test(x)).sort()) { + steps.push(f.replace(/\.png$/, "")); + } + } + + const lines = []; + lines.push(`# Session AI Report`); + lines.push(""); + lines.push(`- session_dir: ${sessionDir}`); + lines.push(`- trace: ${traceTs ?? "(none found)"}`); + lines.push(""); + + for (const id of steps) { + const entry = log.byStepId.get(id) ?? { stepId: id, screenshotPath: null, click: null, ax: null, extra: [] }; + if (!entry.screenshotPath) { + const fallback = path.join(sessionDir, `${id}.png`); + if (fs.existsSync(fallback)) entry.screenshotPath = fallback; + } + const traceResult = aiTrace?.response?.results?.find?.((r) => r?.id === id) ?? null; + const aiText = + log.aiDesc.get(id) ?? + traceResult?.text ?? + aiTrace?.response?.failures?.find?.((f) => f?.id === id)?.error ?? + "(no ai output found)"; + + const ax = extractAxSummary(entry.ax); + + lines.push(`## ${id}`); + lines.push(""); + lines.push(`- screenshot: ${entry.screenshotPath ?? "(missing)"}${entry.screenshotPath && fs.existsSync(entry.screenshotPath) ? "" : " (not found)"}`); + lines.push(`- click_log: ${entry.click ?? "(missing)"}`); + lines.push(`- ax_log: ${entry.ax ?? "(missing)"}`); + if (ax) { + lines.push(`- ax_summary: role=${ax.role} label=${JSON.stringify(ax.label)} parent=${ax.parentRole ?? ""} top=${ax.topRole ?? ""}`); + } + lines.push(`- ai_text: ${aiText}`); + if (traceResult?.debug) { + const dbg = traceResult.debug; + lines.push(`- ai_debug: kind=${dbg.kind ?? ""} gate=${dbg.qualityGateReason ?? ""} label=${JSON.stringify(dbg.groundingLabel ?? "")} ocr=${JSON.stringify(dbg.groundingOcr ?? "")}`); + lines.push(`- ai_baseline: ${dbg.baseline ?? ""}`); + if (dbg.candidate != null) lines.push(`- ai_candidate: ${dbg.candidate}`); + } + lines.push(""); + } + + // Also include any screenshots that exist but were NOT sent to the model + // (e.g. auth placeholder steps), so audits see the full timeline. + try { + const files = fs.readdirSync(sessionDir).filter((x) => /^step-\d+\.png$/.test(x)).sort(); + const idsInRequest = new Set(steps); + for (const f of files) { + const id = f.replace(/\.png$/, ""); + if (idsInRequest.has(id)) continue; + lines.push(`## ${id}`); + lines.push(""); + lines.push(`- screenshot: ${path.join(sessionDir, f)}`); + lines.push(`- click_log: (not sent to model)`); + lines.push(`- ax_log: (not sent to model)`); + lines.push(`- ai_text: (not generated; excluded from AI input)`); + lines.push(""); + } + } catch { + // ignore + } + + return lines.join("\n"); +} + +function main() { + const args = parseArgs(process.argv); + if (args.help) { + process.stdout.write( + [ + "Usage:", + " node scripts/session-ai-report.js [--dir ] [--session ] [--trace ]", + "", + "Defaults:", + " picks newest session under ~/Library/Caches/com.w0nk1.stepcast/sessions", + " picks newest ai-trace-* within that session", + "", + ].join("\n"), + ); + process.exit(0); + } + + const sessionDir = pickSessionDir(args); + if (!sessionDir || !fs.existsSync(sessionDir)) { + process.stderr.write(`No session dir found.\n`); + process.exit(2); + } + + const traceTs = pickTraceTs(sessionDir, args); + const aiTrace = readAiTrace(sessionDir, traceTs); + const log = parseRecordingLog(path.join(sessionDir, "recording.log")); + + process.stdout.write(renderReport({ sessionDir, traceTs, aiTrace, log })); + process.stdout.write("\n"); +} + +main(); diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index b21bd68..83d9aab 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -5,3 +5,6 @@ # Generated by Tauri # will have schema files for capabilities auto-completion /gen/schemas + +# Generated by build.rs (Swift helper; bundled as resource) +/bin/stepcast_ai_helper diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 321fda5..6fcd1fb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4048,6 +4048,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "screencapturekit" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ccf069cb109cf8e01ebdca0d55dfce45dbbf669e8c56ed5c62150b056d3ec9f" + [[package]] name = "security-framework" version = "2.11.1" @@ -4441,6 +4447,7 @@ dependencies = [ "objc2-foundation", "objc2-pdf-kit", "objc2-web-kit", + "screencapturekit", "serde", "serde_json", "slug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 403ac2b..8c97a1d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -52,6 +52,9 @@ tauri-plugin-global-shortcut = "2" tokio = { version = "1", features = ["rt-multi-thread"] } libc = "0.2" +[target.'cfg(target_os = "macos")'.dependencies] +screencapturekit = "1.5.0" + [dev-dependencies] tempfile = "3" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 02618b2..647d764 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,17 +1,128 @@ fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { + // Ensure Swift runtime dylibs required by ScreenCaptureKit bridge can be resolved + // when running test binaries outside an app bundle. + println!("cargo:rustc-link-arg=-Wl,-rpath,/usr/lib/swift"); + } + + build_swift_ai_helper(); + let manifest = tauri_build::AppManifest::new().commands(&[ "greet", "check_permissions", - "request_permissions", + "get_apple_intelligence_eligibility", + "request_screen_recording", + "request_accessibility", "start_recording", "pause_recording", "resume_recording", "stop_recording", "get_steps", + "update_step_note", + "update_step_description", + "update_step_crop", "export_guide", + "delete_step", + "reorder_steps", + "open_editor_window", "discard_recording", + "generate_step_descriptions", + "get_startup_state", + "mark_startup_seen", + "dismiss_whats_new", ]); let attrs = tauri_build::Attributes::new().app_manifest(manifest); tauri_build::try_build(attrs).expect("failed to run build script"); } + +fn build_swift_ai_helper() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") { + return; + } + + let manifest_dir = + std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR")); + + let swift_dir = manifest_dir.join("swift"); + let mut sources: Vec = Vec::new(); + let read_dir = std::fs::read_dir(&swift_dir) + .unwrap_or_else(|e| panic!("read swift dir {}: {e}", swift_dir.display())); + for entry in read_dir.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("swift") { + sources.push(path); + } + } + sources.sort(); + if sources.is_empty() { + return; + } + for src in &sources { + println!("cargo:rerun-if-changed={}", src.display()); + } + + let dst = out_dir.join("stepcast_ai_helper"); + let resource_dst = manifest_dir.join("bin").join("stepcast_ai_helper"); + + // Incremental: skip compiling if the output is newer than the source. + let latest_src_mtime = sources + .iter() + .filter_map(|p| std::fs::metadata(p).ok()?.modified().ok()) + .max(); + let mut needs_compile = true; + if let (Some(src_mtime), Ok(dst_meta)) = (latest_src_mtime, std::fs::metadata(&dst)) { + if let Ok(dst_mtime) = dst_meta.modified() { + if dst_mtime >= src_mtime { + needs_compile = false; + } + } + } + + if needs_compile { + let mut cmd = std::process::Command::new("swiftc"); + cmd.arg("-parse-as-library").arg("-O"); + for src in &sources { + cmd.arg(src); + } + let status = cmd + .arg("-o") + .arg(&dst) + .status() + .expect("failed to run swiftc"); + + if !status.success() { + panic!("swiftc failed: {status}"); + } + } + + // Also emit the helper as a bundle resource for notarization/codesigning correctness. + // We execute this helper directly from the app bundle in release builds. + let resource_dir = resource_dst.parent().expect("resource dst parent"); + std::fs::create_dir_all(resource_dir) + .unwrap_or_else(|e| panic!("create resource dir {}: {e}", resource_dir.display())); + + let should_copy = match (std::fs::read(&dst), std::fs::read(&resource_dst)) { + (Ok(a), Ok(b)) => a != b, + (Ok(_), Err(_)) => true, + (Err(e), _) => panic!("read swift helper output {}: {e}", dst.display()), + }; + + if should_copy { + std::fs::copy(&dst, &resource_dst).unwrap_or_else(|e| { + panic!( + "copy swift helper {} -> {}: {e}", + dst.display(), + resource_dst.display() + ) + }); + } + + #[cfg(target_os = "macos")] + { + use std::os::unix::fs::PermissionsExt; + let perm = std::fs::Permissions::from_mode(0o755); + let _ = std::fs::set_permissions(&resource_dst, perm); + } +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 099191c..22d5947 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ "core:default", "macos-permissions:default", "allow-check-permissions", + "allow-get-apple-intelligence-eligibility", "allow-request-screen-recording", "allow-request-accessibility", "allow-start-recording", @@ -14,8 +15,15 @@ "allow-resume-recording", "allow-stop-recording", "allow-get-steps", + "allow-update-step-note", + "allow-update-step-description", + "allow-update-step-crop", + "allow-delete-step", + "allow-reorder-steps", + "allow-open-editor-window", "allow-export-guide", "allow-discard-recording", + "allow-generate-step-descriptions", "allow-get-startup-state", "allow-mark-startup-seen", "allow-dismiss-whats-new", diff --git a/src-tauri/capabilities/editor.json b/src-tauri/capabilities/editor.json new file mode 100644 index 0000000..b25b356 --- /dev/null +++ b/src-tauri/capabilities/editor.json @@ -0,0 +1,16 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "editor", + "description": "Capability for the step editor window", + "windows": ["step-editor"], + "permissions": [ + "core:default", + "allow-get-steps", + "allow-update-step-note", + "allow-update-step-description", + "allow-update-step-crop", + "allow-delete-step", + "allow-generate-step-descriptions", + "allow-open-editor-window" + ] +} diff --git a/src-tauri/permissions/autogenerated/delete_step.toml b/src-tauri/permissions/autogenerated/delete_step.toml new file mode 100644 index 0000000..05a8491 --- /dev/null +++ b/src-tauri/permissions/autogenerated/delete_step.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-delete-step" +description = "Enables the delete_step command without any pre-configured scope." +commands.allow = ["delete_step"] + +[[permission]] +identifier = "deny-delete-step" +description = "Denies the delete_step command without any pre-configured scope." +commands.deny = ["delete_step"] diff --git a/src-tauri/permissions/autogenerated/generate_step_descriptions.toml b/src-tauri/permissions/autogenerated/generate_step_descriptions.toml new file mode 100644 index 0000000..89c2aa1 --- /dev/null +++ b/src-tauri/permissions/autogenerated/generate_step_descriptions.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-generate-step-descriptions" +description = "Enables the generate_step_descriptions command without any pre-configured scope." +commands.allow = ["generate_step_descriptions"] + +[[permission]] +identifier = "deny-generate-step-descriptions" +description = "Denies the generate_step_descriptions command without any pre-configured scope." +commands.deny = ["generate_step_descriptions"] diff --git a/src-tauri/permissions/autogenerated/get_apple_intelligence_eligibility.toml b/src-tauri/permissions/autogenerated/get_apple_intelligence_eligibility.toml new file mode 100644 index 0000000..a53be03 --- /dev/null +++ b/src-tauri/permissions/autogenerated/get_apple_intelligence_eligibility.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-get-apple-intelligence-eligibility" +description = "Enables the get_apple_intelligence_eligibility command without any pre-configured scope." +commands.allow = ["get_apple_intelligence_eligibility"] + +[[permission]] +identifier = "deny-get-apple-intelligence-eligibility" +description = "Denies the get_apple_intelligence_eligibility command without any pre-configured scope." +commands.deny = ["get_apple_intelligence_eligibility"] diff --git a/src-tauri/permissions/autogenerated/open_editor_window.toml b/src-tauri/permissions/autogenerated/open_editor_window.toml new file mode 100644 index 0000000..52ce670 --- /dev/null +++ b/src-tauri/permissions/autogenerated/open_editor_window.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-open-editor-window" +description = "Enables the open_editor_window command without any pre-configured scope." +commands.allow = ["open_editor_window"] + +[[permission]] +identifier = "deny-open-editor-window" +description = "Denies the open_editor_window command without any pre-configured scope." +commands.deny = ["open_editor_window"] diff --git a/src-tauri/permissions/autogenerated/reorder_steps.toml b/src-tauri/permissions/autogenerated/reorder_steps.toml new file mode 100644 index 0000000..855cf36 --- /dev/null +++ b/src-tauri/permissions/autogenerated/reorder_steps.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-reorder-steps" +description = "Enables the reorder_steps command without any pre-configured scope." +commands.allow = ["reorder_steps"] + +[[permission]] +identifier = "deny-reorder-steps" +description = "Denies the reorder_steps command without any pre-configured scope." +commands.deny = ["reorder_steps"] diff --git a/src-tauri/permissions/autogenerated/update_step_crop.toml b/src-tauri/permissions/autogenerated/update_step_crop.toml new file mode 100644 index 0000000..aac363e --- /dev/null +++ b/src-tauri/permissions/autogenerated/update_step_crop.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-update-step-crop" +description = "Enables the update_step_crop command without any pre-configured scope." +commands.allow = ["update_step_crop"] + +[[permission]] +identifier = "deny-update-step-crop" +description = "Denies the update_step_crop command without any pre-configured scope." +commands.deny = ["update_step_crop"] diff --git a/src-tauri/permissions/autogenerated/update_step_description.toml b/src-tauri/permissions/autogenerated/update_step_description.toml new file mode 100644 index 0000000..b761dbc --- /dev/null +++ b/src-tauri/permissions/autogenerated/update_step_description.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-update-step-description" +description = "Enables the update_step_description command without any pre-configured scope." +commands.allow = ["update_step_description"] + +[[permission]] +identifier = "deny-update-step-description" +description = "Denies the update_step_description command without any pre-configured scope." +commands.deny = ["update_step_description"] diff --git a/src-tauri/permissions/autogenerated/update_step_note.toml b/src-tauri/permissions/autogenerated/update_step_note.toml new file mode 100644 index 0000000..e3c424e --- /dev/null +++ b/src-tauri/permissions/autogenerated/update_step_note.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-update-step-note" +description = "Enables the update_step_note command without any pre-configured scope." +commands.allow = ["update_step_note"] + +[[permission]] +identifier = "deny-update-step-note" +description = "Denies the update_step_note command without any pre-configured scope." +commands.deny = ["update_step_note"] diff --git a/src-tauri/src/apple_intelligence/mod.rs b/src-tauri/src/apple_intelligence/mod.rs new file mode 100644 index 0000000..73b518b --- /dev/null +++ b/src-tauri/src/apple_intelligence/mod.rs @@ -0,0 +1,156 @@ +use crate::recorder::types::Step; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::OnceLock; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AvailabilityResponse { + pub available: bool, + #[serde(default)] + pub reason: Option, + #[serde(default)] + pub details: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateRequest { + pub steps: Vec, + #[serde(default)] + pub max_chars: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateResultItem { + pub id: String, + pub text: String, + #[serde(default)] + pub debug: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateFailureItem { + pub id: String, + pub error: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateResponse { + pub results: Vec, + pub failures: Vec, +} + +#[cfg(target_os = "macos")] +static AI_HELPER_PATH: OnceLock = OnceLock::new(); + +#[cfg(target_os = "macos")] +pub fn init(app: &tauri::AppHandle) -> Result<(), String> { + use tauri::path::BaseDirectory; + use tauri::Manager; + + let path = app + .path() + .resolve("bin/stepcast_ai_helper", BaseDirectory::Resource) + .map_err(|e| format!("resolve ai helper resource: {e}"))?; + if !path.exists() { + return Err(format!("missing ai helper resource at {}", path.display())); + } + AI_HELPER_PATH + .set(path) + .map_err(|_| "ai helper path already initialized".to_string())?; + Ok(()) +} + +#[cfg(target_os = "macos")] +fn helper_path() -> Result<&'static PathBuf, String> { + if let Some(p) = AI_HELPER_PATH.get() { + return Ok(p); + } + + // Best-effort fallback (dev/test): derive from the current executable location. + let exe = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?; + // StepCast.app/Contents/MacOS/StepCast -> StepCast.app/Contents/Resources/bin/stepcast_ai_helper + let candidate = exe + .parent() + .and_then(|p| p.parent()) + .map(|p| p.join("Resources").join("bin").join("stepcast_ai_helper")) + .ok_or("could not derive helper path".to_string())?; + + if !candidate.exists() { + return Err(format!( + "ai helper not initialized and fallback path missing: {}", + candidate.display() + )); + } + let _ = AI_HELPER_PATH.set(candidate); + Ok(AI_HELPER_PATH.get().expect("just set")) +} + +#[cfg(target_os = "macos")] +fn run_helper(args: &[&str], stdin: Option<&[u8]>) -> Result, String> { + use std::io::Write; + use std::process::{Command, Stdio}; + + let helper = helper_path()?; + let mut cmd = Command::new(helper); + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); + + if stdin.is_some() { + cmd.stdin(Stdio::piped()); + } else { + cmd.stdin(Stdio::null()); + } + + let mut child = cmd.spawn().map_err(|e| format!("spawn ai helper: {e}"))?; + if let Some(input) = stdin { + if let Some(mut w) = child.stdin.take() { + w.write_all(input) + .map_err(|e| format!("write helper stdin: {e}"))?; + } + } + + let out = child + .wait_with_output() + .map_err(|e| format!("wait ai helper: {e}"))?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + return Err(format!( + "ai helper failed ({}): {}", + out.status, + stderr.trim() + )); + } + Ok(out.stdout) +} + +#[cfg(not(target_os = "macos"))] +fn run_helper(_args: &[&str], _stdin: Option<&[u8]>) -> Result, String> { + Err("not supported on this platform".into()) +} + +pub fn availability() -> Result { + let out = run_helper(&["availability"], None)?; + serde_json::from_slice(&out).map_err(|e| format!("parse availability json: {e}")) +} + +pub fn generate_descriptions( + steps: Vec, + max_chars: usize, +) -> Result { + // Keep the Swift helper API stable: snake_case JSON. + let req = GenerateRequest { + steps, + max_chars: Some(max_chars), + }; + let input = serde_json::to_vec(&req).map_err(|e| format!("encode generate json: {e}"))?; + let out = run_helper(&["generate"], Some(&input))?; + serde_json::from_slice(&out).map_err(|e| format!("parse generate json: {e}")) +} + +pub fn is_auth_placeholder(step: &Step) -> bool { + step.window_title == "Authentication dialog (secure)" + || step.app.to_lowercase() == "authentication" +} + +pub fn is_blank_description(desc: Option<&str>) -> bool { + desc.unwrap_or("").trim().is_empty() +} diff --git a/src-tauri/src/export/helpers.rs b/src-tauri/src/export/helpers.rs index 49c0454..dac9f43 100644 --- a/src-tauri/src/export/helpers.rs +++ b/src-tauri/src/export/helpers.rs @@ -1,4 +1,4 @@ -use crate::recorder::types::{ActionType, Step}; +use crate::recorder::types::{ActionType, BoundsPercent, Step}; use base64::Engine; use std::fs; @@ -8,10 +8,104 @@ pub fn is_auth_placeholder(step: &Step) -> bool { || step.app.to_lowercase() == "authentication" } +fn normalize_crop_region(crop_region: Option<&BoundsPercent>) -> Option { + let crop = crop_region?; + let values = [ + crop.x_percent, + crop.y_percent, + crop.width_percent, + crop.height_percent, + ]; + if values.iter().any(|v| !v.is_finite()) { + return None; + } + + let x = crop.x_percent.clamp(0.0, 100.0); + let y = crop.y_percent.clamp(0.0, 100.0); + let mut w = crop.width_percent.clamp(0.0, 100.0); + let mut h = crop.height_percent.clamp(0.0, 100.0); + if x + w > 100.0 { + w = (100.0 - x).max(0.0); + } + if y + h > 100.0 { + h = (100.0 - y).max(0.0); + } + if w < 2.0 || h < 2.0 { + return None; + } + + Some(BoundsPercent { + x_percent: x, + y_percent: y, + width_percent: w, + height_percent: h, + }) +} + +fn crop_rect_px( + img_w: u32, + img_h: u32, + crop_region: Option<&BoundsPercent>, +) -> Option<(u32, u32, u32, u32)> { + let crop = normalize_crop_region(crop_region)?; + if crop.x_percent <= 0.0 + && crop.y_percent <= 0.0 + && crop.width_percent >= 99.99 + && crop.height_percent >= 99.99 + { + return None; + } + + let w_f = img_w as f64; + let h_f = img_h as f64; + let x = ((crop.x_percent as f64 / 100.0) * w_f).floor() as u32; + let y = ((crop.y_percent as f64 / 100.0) * h_f).floor() as u32; + let mut width = ((crop.width_percent as f64 / 100.0) * w_f).round() as u32; + let mut height = ((crop.height_percent as f64 / 100.0) * h_f).round() as u32; + + if x >= img_w || y >= img_h { + return None; + } + width = width.clamp(1, img_w.saturating_sub(x).max(1)); + height = height.clamp(1, img_h.saturating_sub(y).max(1)); + Some((x, y, width, height)) +} + +fn maybe_crop_image(raw: &[u8], crop_region: Option<&BoundsPercent>) -> Option> { + let img = image::load_from_memory(raw).ok()?; + let (x, y, width, height) = crop_rect_px(img.width(), img.height(), crop_region)?; + let cropped = img.crop_imm(x, y, width, height); + let mut out = std::io::Cursor::new(Vec::new()); + if cropped.write_to(&mut out, image::ImageFormat::Png).is_err() { + return None; + } + Some(out.into_inner()) +} + +/// Map click marker into cropped image coordinate space. +/// Returns `None` when marker is outside the crop. +pub fn marker_position_percent(step: &Step) -> Option<(f32, f32)> { + if step.screenshot_path.is_none() || is_auth_placeholder(step) { + return None; + } + let click_x = step.click_x_percent.clamp(0.0, 100.0); + let click_y = step.click_y_percent.clamp(0.0, 100.0); + let Some(crop) = normalize_crop_region(step.crop_region.as_ref()) else { + return Some((click_x, click_y)); + }; + + let x = ((click_x - crop.x_percent) / crop.width_percent) * 100.0; + let y = ((click_y - crop.y_percent) / crop.height_percent) * 100.0; + if !(0.0..=100.0).contains(&x) || !(0.0..=100.0).contains(&y) { + return None; + } + Some((x.clamp(0.0, 100.0), y.clamp(0.0, 100.0))) +} + /// Human-readable description of what happened in a step pub fn action_description(step: &Step) -> String { if is_auth_placeholder(step) { - return "Authentication required (secure dialog)".to_string(); + return "Authenticate with Touch ID or enter your password to continue.".to_string(); } match step.action { @@ -28,6 +122,15 @@ pub fn action_description(step: &Step) -> String { } } +/// Prefer enhanced description if present, otherwise fall back to `action_description`. +pub fn effective_description(step: &Step) -> String { + let desc = step.description.as_deref().unwrap_or("").trim(); + if !desc.is_empty() { + return desc.to_string(); + } + action_description(step) +} + /// Image data with format metadata for export. pub struct OptimizedImage { pub bytes: Vec, @@ -67,13 +170,29 @@ pub enum ImageTarget { Pdf, } -/// Load a screenshot, convert to optimized format, return base64 + MIME. -pub fn load_screenshot_optimized(path: &str, target: ImageTarget) -> Option<(String, &'static str)> { +/// Load a screenshot and return optimized bytes + MIME/ext. +pub fn load_screenshot_optimized_image( + path: &str, + target: ImageTarget, + crop_region: Option<&BoundsPercent>, +) -> Option { let raw = fs::read(path).ok()?; + let cropped = maybe_crop_image(&raw, crop_region); + let source = cropped.as_deref().unwrap_or(&raw); let img = match target { - ImageTarget::Web => to_webp_or_png(&raw), - ImageTarget::Pdf => to_jpeg(&raw), + ImageTarget::Web => to_webp_or_png(source), + ImageTarget::Pdf => to_jpeg(source), }; + Some(img) +} + +/// Load a screenshot, convert to optimized format, return base64 + MIME. +pub fn load_screenshot_optimized( + path: &str, + target: ImageTarget, + crop_region: Option<&BoundsPercent>, +) -> Option<(String, &'static str)> { + let img = load_screenshot_optimized_image(path, target, crop_region)?; Some(( base64::engine::general_purpose::STANDARD.encode(&img.bytes), img.mime, @@ -88,12 +207,15 @@ pub fn to_jpeg(png_bytes: &[u8]) -> OptimizedImage { let rgb = img.to_rgb8(); let mut buf = std::io::Cursor::new(Vec::new()); let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 85); - if encoder.write_image( - rgb.as_raw(), - rgb.width(), - rgb.height(), - image::ExtendedColorType::Rgb8, - ).is_ok() { + if encoder + .write_image( + rgb.as_raw(), + rgb.width(), + rgb.height(), + image::ExtendedColorType::Rgb8, + ) + .is_ok() + { return OptimizedImage { bytes: buf.into_inner(), mime: "image/jpeg", @@ -140,36 +262,54 @@ mod tests { window_title: "Downloads".into(), screenshot_path: None, note: None, + description: None, + description_source: None, + description_status: None, + description_error: None, + ax: None, capture_status: None, capture_error: None, + crop_region: None, } } #[test] fn action_description_click() { let s = sample_step(); - assert_eq!(action_description(&s), "Clicked in Finder \u{2014} \"Downloads\""); + assert_eq!( + action_description(&s), + "Clicked in Finder \u{2014} \"Downloads\"" + ); } #[test] fn action_description_double_click() { let mut s = sample_step(); s.action = ActionType::DoubleClick; - assert_eq!(action_description(&s), "Double-clicked in Finder \u{2014} \"Downloads\""); + assert_eq!( + action_description(&s), + "Double-clicked in Finder \u{2014} \"Downloads\"" + ); } #[test] fn action_description_right_click() { let mut s = sample_step(); s.action = ActionType::RightClick; - assert_eq!(action_description(&s), "Right-clicked in Finder \u{2014} \"Downloads\""); + assert_eq!( + action_description(&s), + "Right-clicked in Finder \u{2014} \"Downloads\"" + ); } #[test] fn action_description_shortcut() { let mut s = sample_step(); s.action = ActionType::Shortcut; - assert_eq!(action_description(&s), "Used keyboard shortcut in Finder \u{2014} \"Downloads\""); + assert_eq!( + action_description(&s), + "Used keyboard shortcut in Finder \u{2014} \"Downloads\"" + ); } #[test] @@ -184,14 +324,34 @@ mod tests { fn action_description_auth_placeholder() { let mut s = sample_step(); s.window_title = "Authentication dialog (secure)".into(); - assert_eq!(action_description(&s), "Authentication required (secure dialog)"); + assert_eq!( + action_description(&s), + "Authenticate with Touch ID or enter your password to continue." + ); } #[test] fn action_description_auth_by_app() { let mut s = sample_step(); s.app = "Authentication".into(); - assert_eq!(action_description(&s), "Authentication required (secure dialog)"); + assert_eq!( + action_description(&s), + "Authenticate with Touch ID or enter your password to continue." + ); + } + + #[test] + fn effective_description_prefers_step_description() { + let mut s = sample_step(); + s.description = Some("Click \"Share\"".into()); + assert_eq!(effective_description(&s), "Click \"Share\""); + } + + #[test] + fn effective_description_falls_back_when_empty() { + let mut s = sample_step(); + s.description = Some(" ".into()); + assert!(effective_description(&s).contains("Clicked in Finder")); } #[test] @@ -214,7 +374,9 @@ mod tests { let result = slugify_title("\u{00c4}rger mit \u{00d6}lf\u{00f6}rderung"); assert!(!result.contains('\u{00e4}')); assert!(!result.contains('\u{00f6}')); - assert!(result.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')); + assert!(result + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-')); } #[test] @@ -261,7 +423,59 @@ mod tests { #[test] fn load_screenshot_optimized_missing_file() { - assert!(load_screenshot_optimized("/nonexistent/file.png", ImageTarget::Web).is_none()); + assert!( + load_screenshot_optimized("/nonexistent/file.png", ImageTarget::Web, None).is_none() + ); + } + + #[test] + fn marker_position_percent_without_crop() { + let mut s = sample_step(); + s.screenshot_path = Some("/tmp/x.png".into()); + s.click_x_percent = 70.0; + s.click_y_percent = 30.0; + assert_eq!(marker_position_percent(&s), Some((70.0, 30.0))); + } + + #[test] + fn marker_position_percent_with_crop() { + let mut s = sample_step(); + s.screenshot_path = Some("/tmp/x.png".into()); + s.click_x_percent = 50.0; + s.click_y_percent = 50.0; + s.crop_region = Some(BoundsPercent { + x_percent: 25.0, + y_percent: 25.0, + width_percent: 50.0, + height_percent: 50.0, + }); + assert_eq!(marker_position_percent(&s), Some((50.0, 50.0))); + } + + #[test] + fn load_screenshot_optimized_image_applies_crop() { + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let img = image::RgbaImage::from_pixel(200, 100, image::Rgba([255, 0, 0, 255])); + let img_path = tmp.path().join("shot.png"); + img.save(&img_path).unwrap(); + + let out = load_screenshot_optimized_image( + img_path.to_str().unwrap(), + ImageTarget::Web, + Some(&BoundsPercent { + x_percent: 25.0, + y_percent: 20.0, + width_percent: 50.0, + height_percent: 50.0, + }), + ) + .expect("optimized image"); + + let decoded = image::load_from_memory(&out.bytes).expect("decode optimized image"); + assert_eq!(decoded.width(), 100); + assert_eq!(decoded.height(), 50); } #[test] @@ -289,7 +503,7 @@ mod tests { #[test] fn jpeg_conversion_realistic_screenshot() { - use image::{ImageFormat, RgbaImage, Rgba}; + use image::{ImageFormat, Rgba, RgbaImage}; let (w, h) = (1440u32, 900u32); let mut img = RgbaImage::new(w, h); for y in 0..h { @@ -308,18 +522,26 @@ mod tests { let result = to_jpeg(&png_bytes); let png_kb = png_bytes.len() / 1024; let jpg_kb = result.bytes.len() / 1024; - eprintln!("Screenshot 1440x900: PNG={png_kb}KB → JPEG={jpg_kb}KB ({}% savings)", - if png_kb > 0 { 100 - (jpg_kb * 100 / png_kb) } else { 0 } + eprintln!( + "Screenshot 1440x900: PNG={png_kb}KB → JPEG={jpg_kb}KB ({}% savings)", + if png_kb > 0 { + 100 - (jpg_kb * 100 / png_kb) + } else { + 0 + } ); assert_eq!(result.mime, "image/jpeg"); - assert!(result.bytes.len() < png_bytes.len(), "JPEG should be smaller than PNG"); + assert!( + result.bytes.len() < png_bytes.len(), + "JPEG should be smaller than PNG" + ); } #[test] fn webp_conversion_realistic_screenshot() { // Simulate a 1440x900 screenshot with varied content (gradient + noise) - use image::{ImageFormat, RgbaImage, Rgba}; + use image::{ImageFormat, Rgba, RgbaImage}; let (w, h) = (1440u32, 900u32); let mut img = RgbaImage::new(w, h); for y in 0..h { @@ -339,12 +561,20 @@ mod tests { let png_kb = png_bytes.len() / 1024; let out_kb = result.bytes.len() / 1024; - eprintln!("Screenshot 1440x900: PNG={png_kb}KB → {}={out_kb}KB ({}% savings)", + eprintln!( + "Screenshot 1440x900: PNG={png_kb}KB → {}={out_kb}KB ({}% savings)", result.ext, - if png_kb > 0 { 100 - (out_kb * 100 / png_kb) } else { 0 } + if png_kb > 0 { + 100 - (out_kb * 100 / png_kb) + } else { + 0 + } ); - assert_eq!(result.ext, "webp", "WebP should be smaller for a realistic screenshot"); + assert_eq!( + result.ext, "webp", + "WebP should be smaller for a realistic screenshot" + ); assert_eq!(result.mime, "image/webp"); // WebP should be meaningfully smaller (at least 20%) assert!( diff --git a/src-tauri/src/export/html.rs b/src-tauri/src/export/html.rs index 130642c..8ca298f 100644 --- a/src-tauri/src/export/html.rs +++ b/src-tauri/src/export/html.rs @@ -1,5 +1,8 @@ +use super::helpers::{ + effective_description, html_escape, load_screenshot_optimized, marker_position_percent, + ImageTarget, +}; use crate::recorder::types::{ActionType, Step}; -use super::helpers::{action_description, html_escape, is_auth_placeholder, load_screenshot_optimized, ImageTarget}; /// Generate a self-contained HTML document from steps. pub fn generate(title: &str, steps: &[Step]) -> String { @@ -26,8 +29,13 @@ pub fn generate_for(title: &str, steps: &[Step], target: ImageTarget) -> String -

{title_esc} — {count} step{plural}

+
+

{title_esc}

+

{count} step{plural}

+
{steps_html} +
+
"#, title_esc = html_escape(title), @@ -39,10 +47,12 @@ pub fn generate_for(title: &str, steps: &[Step], target: ImageTarget) -> String } fn render_step(num: usize, step: &Step, target: ImageTarget) -> String { - let desc = html_escape(&action_description(step)); + let desc = html_escape(&effective_description(step)); - let image_html = step.screenshot_path.as_ref() - .and_then(|p| load_screenshot_optimized(p, target)) + let image_html = step + .screenshot_path + .as_ref() + .and_then(|p| load_screenshot_optimized(p, target, step.crop_region.as_ref())) .map(|(b64, mime)| format!(r#"Step {num}"#)) .unwrap_or_default(); @@ -52,34 +62,35 @@ fn render_step(num: usize, step: &Step, target: ImageTarget) -> String { _ => "click-marker", }; - let click_marker = if step.screenshot_path.is_some() && !is_auth_placeholder(step) { - format!( - r#"
"#, - marker_class, step.click_x_percent, step.click_y_percent - ) - } else { - String::new() - }; + let click_marker = marker_position_percent(step) + .map(|(x, y)| { + format!(r#"
"#) + }) + .unwrap_or_default(); - let note_html = step.note.as_ref() + let note_html = step + .note + .as_ref() .map(|n| format!(r#"

{}

"#, escape_text(n))) .unwrap_or_default(); format!( r#" -
-
- Step {num} - {desc} -
-
-
- {image_html} - {click_marker} +
+
{num}
+
+
+ {desc} +
+
+
+ {image_html} + {click_marker} +
-
- {note_html} -
"# + {note_html} + + "# ) } @@ -88,38 +99,44 @@ fn escape_text(s: &str) -> String { html_escape(s).replace('\'', "'") } -const CSS: &str = r#"* { box-sizing: border-box; } -body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 800px; margin: 0 auto; padding: 40px 20px; color: #1d1d1f; background: #fff; line-height: 1.5; } -h1 { margin-bottom: 8px; } -.meta { color: #86868b; font-size: 14px; margin-bottom: 32px; } -.step { margin-bottom: 32px; border: 1px solid #e8e8ed; border-radius: 12px; overflow: hidden; } -.step-header { padding: 12px 16px; background: #f5f5f7; border-bottom: 1px solid #e8e8ed; display: flex; gap: 12px; align-items: center; } -.step-number { font-weight: 600; font-size: 12px; text-transform: uppercase; color: #86868b; white-space: nowrap; } -.step-app { color: #1d1d1f; } -.step-image { background: #f5f5f7; display: flex; align-items: center; justify-content: center; padding: 16px; } -.step-image img { display: block; max-width: 100%; height: auto; } -.image-wrapper { position: relative; display: inline-block; max-width: 100%; } -.step-note { margin: 0; padding: 10px 16px; font-size: 14px; color: #1d1d1f; background: #fef9e7; border-top: 1px solid #e8e8ed; } +const CSS: &str = r#"* { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif; background: #f5f5f7; color: #1d1d1f; line-height: 1.5; -webkit-font-smoothing: antialiased; -webkit-print-color-adjust: exact; print-color-adjust: exact; } +.container { max-width: 860px; margin: 0 auto; padding: 40px 32px 64px; } +h1 { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 4px; } +.subtitle { font-size: 14px; color: #86868b; margin-bottom: 32px; } +.timeline { display: flex; flex-direction: column; position: relative; } +.timeline::before { content: ''; position: absolute; left: 15px; top: 16px; bottom: 16px; width: 2px; background: #d1d1d6; border-radius: 1px; } +.timeline-item { display: grid; grid-template-columns: 32px 1fr; gap: 16px; padding-bottom: 24px; position: relative; } +.timeline-item:last-child { padding-bottom: 0; } +.timeline-badge { width: 32px; height: 32px; border-radius: 50%; background: #7c5cfc; color: #fff; font-size: 13px; font-weight: 700; display: flex; align-items: center; justify-content: center; position: relative; z-index: 1; box-shadow: 0 0 0 4px #f5f5f7; flex-shrink: 0; } +.step { border: 1px solid #d1d1d6; border-radius: 14px; overflow: hidden; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.03); } +.step-header { display: flex; align-items: center; gap: 12px; padding: 14px 20px; } +.step-desc { font-size: 14px; font-weight: 600; color: #1d1d1f; } +.step-image { padding: 0 20px 16px; display: flex; align-items: center; justify-content: center; } +.image-wrapper { position: relative; display: inline-block; max-width: 100%; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.08); border: 1px solid #d1d1d6; } +.image-wrapper img { display: block; max-width: 100%; height: auto; } +.step-note { margin: 0; padding: 12px 20px 16px; font-size: 13px; color: #1d1d1f; background: rgba(124,92,252,0.05); border-top: none; } .click-marker { position: absolute; width: 24px; height: 24px; border-radius: 50%; background: transparent; border: 2.5px solid #ff3b30; box-shadow: 0 0 0 1.5px rgba(255,255,255,0.9), 0 2px 6px rgba(0,0,0,0.25); transform: translate(-50%, -50%); pointer-events: none; } .click-marker.double-click { width: 18px; height: 18px; border-width: 2px; } .click-marker.double-click::after { content: ''; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 30px; height: 30px; border-radius: 50%; border: 2px solid #ff3b30; box-shadow: 0 0 0 1.5px rgba(255,255,255,0.9); pointer-events: none; } .click-marker.right-click { border-style: dashed; } @media print { - .step { break-inside: avoid; } - body { background: #fff !important; color: #1d1d1f !important; } - .step { border-color: #e8e8ed !important; } - .step-header { background: #f5f5f7 !important; border-color: #e8e8ed !important; } - .step-app { color: #1d1d1f !important; } - .step-image { background: #f5f5f7 !important; } - .step-note { background: #fef9e7 !important; color: #1d1d1f !important; border-color: #e8e8ed !important; } + body { background: #fff !important; } + .container { padding: 20px !important; } + .timeline::before { background: #d1d1d6 !important; } + .timeline-badge { box-shadow: 0 0 0 4px #fff !important; } + .timeline-item { break-inside: avoid; } + .step { box-shadow: none !important; border-color: #d1d1d6 !important; } } @media (prefers-color-scheme: dark) { - body { background: #1d1d1f; color: #f5f5f7; } - .step { border-color: #38383d; } - .step-header { background: #2c2c2e; border-color: #38383d; } - .step-app { color: #f5f5f7; } - .step-image { background: #2c2c2e; } - .step-note { background: #3a3520; color: #f5f5f7; border-color: #38383d; } + body { background: #1c1c1e; color: #f5f5f7; } + .subtitle { color: #98989d; } + .timeline::before { background: #38383a; } + .timeline-badge { box-shadow: 0 0 0 4px #1c1c1e; } + .step { background: #2c2c2e; border-color: #38383a; box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 1px 3px rgba(0,0,0,0.2), 0 4px 12px rgba(0,0,0,0.15); } + .step-desc { color: #f5f5f7; } + .image-wrapper { border-color: #38383a; } + .step-note { color: #f5f5f7; background: rgba(167,139,250,0.08); } }"#; #[cfg(test)] @@ -132,15 +149,22 @@ mod tests { id: "s1".into(), ts: 0, action: ActionType::Click, - x: 10, y: 20, + x: 10, + y: 20, click_x_percent: 50.0, click_y_percent: 50.0, app: "Finder".into(), window_title: "Downloads".into(), screenshot_path: None, note: None, + description: None, + description_source: None, + description_status: None, + description_error: None, + ax: None, capture_status: None, capture_error: None, + crop_region: None, } } @@ -148,7 +172,8 @@ mod tests { fn generate_contains_title() { let html = generate("Test Guide", &[sample_step()]); assert!(html.contains("Test Guide")); - assert!(html.contains("

Test Guide — 1 step

")); + assert!(html.contains("

Test Guide

")); + assert!(html.contains("1 step")); } #[test] @@ -164,9 +189,10 @@ mod tests { } #[test] - fn generate_contains_step_article() { + fn generate_contains_timeline_structure() { let html = generate("G", &[sample_step()]); - assert!(html.contains("Step 1")); + assert!(html.contains("timeline-badge")); + assert!(html.contains("timeline-item")); assert!(html.contains("Clicked in Finder")); } @@ -251,11 +277,7 @@ mod tests { // Create a 1440x900 gradient screenshot let mut img = image::RgbaImage::new(1440, 900); for (x, y, pixel) in img.enumerate_pixels_mut() { - *pixel = image::Rgba([ - ((x * 255) / 1440) as u8, - ((y * 255) / 900) as u8, - 128, 255, - ]); + *pixel = image::Rgba([((x * 255) / 1440) as u8, ((y * 255) / 900) as u8, 128, 255]); } let img_path = tmp.path().join("screenshot.png"); img.save(&img_path).unwrap(); diff --git a/src-tauri/src/export/markdown.rs b/src-tauri/src/export/markdown.rs index 8e9f087..096fa8f 100644 --- a/src-tauri/src/export/markdown.rs +++ b/src-tauri/src/export/markdown.rs @@ -1,10 +1,10 @@ +use super::helpers::{effective_description, load_screenshot_optimized_image, ImageTarget}; use crate::recorder::types::Step; -use super::helpers::{action_description, to_webp_or_png}; use std::fs; use std::io::{Cursor, Write as _}; use std::path::Path; -use zip::ZipWriter; use zip::write::SimpleFileOptions; +use zip::ZipWriter; /// Derive the images directory name from a stem. /// "My Guide" → "My Guide-images" @@ -18,7 +18,12 @@ pub fn images_dir_name(output_path: &Path) -> String { /// Generate markdown content. `images_dir` is the relative folder name for images. /// `image_exts` maps step index (0-based) to file extension ("webp" or "png"). -pub fn generate_content(title: &str, steps: &[Step], images_dir: &str, image_exts: &[&str]) -> String { +pub fn generate_content( + title: &str, + steps: &[Step], + images_dir: &str, + image_exts: &[&str], +) -> String { let mut md = format!( "# {title} — {count} step{plural}\n\n", count = steps.len(), @@ -27,7 +32,7 @@ pub fn generate_content(title: &str, steps: &[Step], images_dir: &str, image_ext for (i, step) in steps.iter().enumerate() { let num = i + 1; - let desc = action_description(step); + let desc = effective_description(step); md.push_str(&format!("## Step {num}\n\n")); @@ -36,13 +41,14 @@ pub fn generate_content(title: &str, steps: &[Step], images_dir: &str, image_ext // Image reference (relative path into images dir) if step.screenshot_path.is_some() { let ext = image_exts.get(i).unwrap_or(&"png"); - md.push_str(&format!("![Step {num}](<./{images_dir}/step-{num}.{ext}>)\n\n")); + md.push_str(&format!( + "![Step {num}](<./{images_dir}/step-{num}.{ext}>)\n\n" + )); } if let Some(note) = &step.note { md.push_str(&format!("> {note}\n\n")); } - } md @@ -60,16 +66,15 @@ pub fn write(title: &str, steps: &[Step], output_path: &str) -> Result<(), Strin let md_filename = format!("{stem}.md"); let images_dir = images_dir_name(path); - let opts = SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated); + let opts = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); // Convert images and collect (bytes, extension) per step let mut converted: Vec, &str)>> = Vec::with_capacity(steps.len()); for (i, step) in steps.iter().enumerate() { if let Some(src) = &step.screenshot_path { - let raw = fs::read(src) - .map_err(|e| format!("Failed to read screenshot {}: {e}", i + 1))?; - let img = to_webp_or_png(&raw); + let img = + load_screenshot_optimized_image(src, ImageTarget::Web, step.crop_region.as_ref()) + .ok_or_else(|| format!("Failed to read screenshot {}: {src}", i + 1))?; converted.push(Some((img.bytes, img.ext))); } else { converted.push(None); @@ -108,8 +113,7 @@ pub fn write(title: &str, steps: &[Step], output_path: &str) -> Result<(), Strin .into_inner() }; - fs::write(output_path, buf) - .map_err(|e| format!("Failed to write zip file: {e}"))?; + fs::write(output_path, buf).map_err(|e| super::friendly_write_error(&e, output_path))?; Ok(()) } @@ -125,27 +129,44 @@ mod tests { id: "s1".into(), ts: 0, action: ActionType::Click, - x: 10, y: 20, + x: 10, + y: 20, click_x_percent: 50.0, click_y_percent: 50.0, app: "Finder".into(), window_title: "Downloads".into(), screenshot_path: None, note: None, + description: None, + description_source: None, + description_status: None, + description_error: None, + ax: None, capture_status: None, capture_error: None, + crop_region: None, } } #[test] fn generate_contains_title() { - let md = generate_content("Test Guide", &[sample_step()], "test-guide-images", &["png"]); + let md = generate_content( + "Test Guide", + &[sample_step()], + "test-guide-images", + &["png"], + ); assert!(md.starts_with("# Test Guide — ")); } #[test] fn generate_contains_step_count() { - let md = generate_content("G", &[sample_step(), sample_step()], "g-images", &["png", "png"]); + let md = generate_content( + "G", + &[sample_step(), sample_step()], + "g-images", + &["png", "png"], + ); assert!(md.contains("2 steps")); } @@ -208,8 +229,8 @@ mod tests { #[test] fn write_creates_valid_zip() { - use tempfile::TempDir; use std::io::Cursor; + use tempfile::TempDir; use zip::ZipArchive; let tmp = TempDir::new().unwrap(); @@ -244,9 +265,10 @@ mod tests { assert!(names.contains(&"My Guide.md".to_string())); // Image could be webp or png depending on which is smaller - let has_image = names.iter().any(|n| - n.starts_with("My Guide-images/step-1.") && (n.ends_with(".webp") || n.ends_with(".png")) - ); + let has_image = names.iter().any(|n| { + n.starts_with("My Guide-images/step-1.") + && (n.ends_with(".webp") || n.ends_with(".png")) + }); assert!(has_image, "Expected image file in zip, got: {names:?}"); assert_eq!(names.len(), 2); @@ -257,7 +279,10 @@ mod tests { assert!(md_content.contains("# My Guide")); assert!(md_content.contains("2 steps")); // The image reference should match the actual file in the zip - let img_name = names.iter().find(|n| n.starts_with("My Guide-images/step-1.")).unwrap(); + let img_name = names + .iter() + .find(|n| n.starts_with("My Guide-images/step-1.")) + .unwrap(); let ext = img_name.rsplit('.').next().unwrap(); assert!(md_content.contains(&format!("step-1.{ext}"))); } @@ -265,8 +290,8 @@ mod tests { /// End-to-end: realistic 1440x900 screenshot → zip with WebP image + correct md reference #[test] fn write_zip_uses_webp_for_large_screenshot() { - use tempfile::TempDir; use std::io::Cursor; + use tempfile::TempDir; use zip::ZipArchive; let tmp = TempDir::new().unwrap(); @@ -274,12 +299,7 @@ mod tests { // Create a realistic 1440x900 gradient image let mut img = image::RgbaImage::new(1440, 900); for (x, y, pixel) in img.enumerate_pixels_mut() { - *pixel = image::Rgba([ - ((x * 255) / 1440) as u8, - ((y * 255) / 900) as u8, - 128, - 255, - ]); + *pixel = image::Rgba([((x * 255) / 1440) as u8, ((y * 255) / 900) as u8, 128, 255]); } let img_path = tmp.path().join("screenshot.png"); img.save(&img_path).unwrap(); @@ -312,7 +332,11 @@ mod tests { std::io::Read::read_to_end(&mut entry, &mut buf).unwrap(); buf }; - assert_eq!(&webp_bytes[..4], b"RIFF", "WebP must start with RIFF header"); + assert_eq!( + &webp_bytes[..4], + b"RIFF", + "WebP must start with RIFF header" + ); assert_eq!(&webp_bytes[8..12], b"WEBP", "WebP must contain WEBP marker"); // Verify markdown references .webp @@ -325,7 +349,9 @@ mod tests { assert!(md_content.contains("step-1.webp")); let webp_size = webp_bytes.len() as u64; - eprintln!("E2E: PNG={png_size}B → WebP={webp_size}B ({}% smaller)", - 100 - (webp_size * 100 / png_size)); + eprintln!( + "E2E: PNG={png_size}B → WebP={webp_size}B ({}% smaller)", + 100 - (webp_size * 100 / png_size) + ); } } diff --git a/src-tauri/src/export/mod.rs b/src-tauri/src/export/mod.rs index f3d060a..6ce8e5d 100644 --- a/src-tauri/src/export/mod.rs +++ b/src-tauri/src/export/mod.rs @@ -4,6 +4,7 @@ pub mod markdown; pub mod pdf; use crate::recorder::types::Step; +use std::path::Path; #[derive(Debug, Clone, Copy)] pub enum ExportFormat { @@ -23,20 +24,120 @@ impl ExportFormat { } } +/// Turn an IO error into a user-friendly message. +fn friendly_write_error(e: &std::io::Error, path: &str) -> String { + match e.kind() { + std::io::ErrorKind::PermissionDenied => { + format!("Cannot save to \"{path}\" — permission denied. Is the file open in another app or the folder read-only?") + } + std::io::ErrorKind::NotFound => { + format!("The folder for \"{path}\" does not exist.") + } + _ if e.raw_os_error() == Some(28) /* ENOSPC */ => { + "Not enough disk space to save the file.".to_string() + } + _ => format!("Could not save file: {e}"), + } +} + +/// Pre-validate that we can write to `output_path` before doing expensive work. +/// +/// Checks: parent dir writable (tempfile probe), existing file writable, +/// sufficient disk space. Total cost: ~3 syscalls, <1ms. +fn validate_write_access(output_path: &str, estimated_bytes: u64) -> Result<(), String> { + let path = Path::new(output_path); + + let parent = path + .parent() + .ok_or_else(|| format!("Invalid output path: \"{output_path}\""))?; + + if !parent.exists() { + return Err(format!( + "The folder \"{}\" does not exist.", + parent.display() + )); + } + + // Probe writability: create a temp file in the same directory + let probe_path = parent.join(format!(".stepcast_probe_{}", std::process::id())); + match std::fs::File::create(&probe_path) { + Ok(_) => { + let _ = std::fs::remove_file(&probe_path); + } + Err(e) => { + let _ = std::fs::remove_file(&probe_path); + return Err(match e.kind() { + std::io::ErrorKind::PermissionDenied => format!( + "Cannot write to folder \"{}\" — permission denied.", + parent.display() + ), + _ => format!("Cannot write to folder \"{}\": {e}", parent.display()), + }); + } + } + + // If target file exists, verify it is writable (opens without truncating) + if path.exists() { + if let Err(e) = std::fs::OpenOptions::new().write(true).open(path) { + return Err(match e.kind() { + std::io::ErrorKind::PermissionDenied => format!( + "Cannot overwrite \"{}\" — the file is read-only or locked.", + path.display() + ), + _ => format!("Cannot write to \"{}\": {e}", path.display()), + }); + } + } + + // Check available disk space via statvfs + if let Some(dir_str) = parent.to_str() { + if let Ok(avail) = available_disk_space(dir_str) { + const MIN_BUFFER: u64 = 10 * 1024 * 1024; // 10 MB safety margin + let needed = estimated_bytes + MIN_BUFFER; + if avail < needed { + let need_mb = needed / (1024 * 1024); + let have_mb = avail / (1024 * 1024); + return Err(format!( + "Not enough disk space. Need ~{need_mb} MB, but only {have_mb} MB available." + )); + } + } + } + + Ok(()) +} + +/// Returns available disk space in bytes for the filesystem containing `path`. +fn available_disk_space(path: &str) -> std::io::Result { + let c_path = std::ffi::CString::new(path) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + let mut stat: libc::statvfs = unsafe { std::mem::zeroed() }; + let ret = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) }; + if ret != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(stat.f_bavail as u64 * stat.f_frsize) +} + /// Unified export: writes the given steps to output_path in the requested format. -pub fn export(title: &str, steps: &[Step], format: ExportFormat, output_path: &str, app: &tauri::AppHandle) -> Result<(), String> { +pub fn export( + title: &str, + steps: &[Step], + format: ExportFormat, + output_path: &str, + app: &tauri::AppHandle, +) -> Result<(), String> { + // Pre-validate before expensive work (~500KB per step estimate) + let estimated_bytes = (steps.len() as u64) * 500_000 + 100_000; + validate_write_access(output_path, estimated_bytes)?; + match format { ExportFormat::Html => { let content = html::generate(title, steps); - std::fs::write(output_path, content) - .map_err(|e| format!("Failed to write HTML: {e}")) - } - ExportFormat::Markdown => { - markdown::write(title, steps, output_path) - } - ExportFormat::Pdf => { - pdf::write(title, steps, output_path, app) + std::fs::write(output_path, content).map_err(|e| friendly_write_error(&e, output_path)) } + ExportFormat::Markdown => markdown::write(title, steps, output_path), + ExportFormat::Pdf => pdf::write(title, steps, output_path, app), } } @@ -46,13 +147,42 @@ mod tests { #[test] fn format_from_str_valid() { - assert!(matches!(ExportFormat::from_str("html"), Ok(ExportFormat::Html))); - assert!(matches!(ExportFormat::from_str("md"), Ok(ExportFormat::Markdown))); - assert!(matches!(ExportFormat::from_str("pdf"), Ok(ExportFormat::Pdf))); + assert!(matches!( + ExportFormat::from_str("html"), + Ok(ExportFormat::Html) + )); + assert!(matches!( + ExportFormat::from_str("md"), + Ok(ExportFormat::Markdown) + )); + assert!(matches!( + ExportFormat::from_str("pdf"), + Ok(ExportFormat::Pdf) + )); } #[test] fn format_from_str_invalid() { assert!(ExportFormat::from_str("docx").is_err()); } + + #[test] + fn validate_write_access_writable_dir() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("test.html").to_str().unwrap().to_string(); + assert!(validate_write_access(&path, 1000).is_ok()); + } + + #[test] + fn validate_write_access_nonexistent_parent() { + let result = validate_write_access("/nonexistent/dir/file.html", 1000); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("does not exist")); + } + + #[test] + fn available_disk_space_current_dir() { + let space = available_disk_space(".").unwrap(); + assert!(space > 0); + } } diff --git a/src-tauri/src/export/pdf.rs b/src-tauri/src/export/pdf.rs index 94fbb2a..41b79b3 100644 --- a/src-tauri/src/export/pdf.rs +++ b/src-tauri/src/export/pdf.rs @@ -11,8 +11,7 @@ fn optimize_pdf_bytes(pdf_bytes: &[u8]) -> Vec { use objc2::AnyThread; use objc2_foundation::{NSData, NSDictionary, NSNumber, NSString}; use objc2_pdf_kit::{ - PDFDocument, PDFDocumentOptimizeImagesForScreenOption, - PDFDocumentSaveImagesAsJPEGOption, + PDFDocument, PDFDocumentOptimizeImagesForScreenOption, PDFDocumentSaveImagesAsJPEGOption, }; // Try to load as PDFDocument @@ -78,8 +77,7 @@ fn render_pdf_on_main_thread(html: &str, output_path: &str, tx: mpsc::Sender>\nstartxref\n190\n%%EOF"; let result = optimize_pdf_bytes(minimal_pdf); // Should return *some* valid bytes (possibly re-encoded, possibly original) - assert!(!result.is_empty(), "valid PDF should produce non-empty output"); + assert!( + !result.is_empty(), + "valid PDF should produce non-empty output" + ); } /// Creates a PDFDocument with a single image page for size testing. @@ -257,9 +260,8 @@ mod tests { let page = unsafe { PDFPage::initWithImage(PDFPage::alloc(), &ns_image) } .expect("PDFPage from image"); - let doc: objc2::rc::Retained = unsafe { - objc2::msg_send![PDFDocument::alloc(), init] - }; + let doc: objc2::rc::Retained = + unsafe { objc2::msg_send![PDFDocument::alloc(), init] }; unsafe { doc.insertPage_atIndex(&page, 0) }; let raw = unsafe { doc.dataRepresentation() }.expect("PDF bytes"); @@ -307,9 +309,8 @@ mod tests { let obj5_offset = pdf.len(); let content = format!("{w} 0 0 {h} 0 0 cm /Im0 Do"); let content_len = content.len(); - let obj5 = format!( - "5 0 obj\n<< /Length {content_len} >>\nstream\n{content}\nendstream\nendobj\n" - ); + let obj5 = + format!("5 0 obj\n<< /Length {content_len} >>\nstream\n{content}\nendstream\nendobj\n"); pdf.extend_from_slice(obj5.as_bytes()); // Cross-reference table @@ -317,18 +318,16 @@ mod tests { let xref = format!( "xref\n0 6\n\ 0000000000 65535 f \n\ - {:010} 00000 n \n\ - {:010} 00000 n \n\ - {:010} 00000 n \n\ - {:010} 00000 n \n\ - {:010} 00000 n \n", - obj1_offset, obj2_offset, obj3_offset, obj4_offset, obj5_offset + {obj1_offset:010} 00000 n \n\ + {obj2_offset:010} 00000 n \n\ + {obj3_offset:010} 00000 n \n\ + {obj4_offset:010} 00000 n \n\ + {obj5_offset:010} 00000 n \n" ); pdf.extend_from_slice(xref.as_bytes()); - let trailer = format!( - "trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n" - ); + let trailer = + format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n"); pdf.extend_from_slice(trailer.as_bytes()); pdf @@ -369,7 +368,9 @@ mod tests { let n_kb = opt.len() / 1024; let pct = if !orig.is_empty() { 100.0 - (opt.len() as f64 / orig.len() as f64 * 100.0) - } else { 0.0 }; + } else { + 0.0 + }; let orig_str = String::from_utf8_lossy(orig); let dct = orig_str.contains("DCTDecode"); let flate = orig_str.contains("FlateDecode"); @@ -410,8 +411,14 @@ mod tests { window_title: "Downloads".into(), screenshot_path: None, note: None, + description: None, + description_source: None, + description_status: None, + description_error: None, + ax: None, capture_status: None, capture_error: None, + crop_region: None, }; let result = super::super::html::generate("Test", &[step]); assert!(result.contains("")); @@ -419,8 +426,8 @@ mod tests { #[test] fn pdf_html_uses_jpeg_not_webp() { - use crate::recorder::types::{ActionType, Step}; use super::super::helpers::ImageTarget; + use crate::recorder::types::{ActionType, Step}; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); @@ -432,19 +439,32 @@ mod tests { id: "s1".into(), ts: 0, action: ActionType::Click, - x: 10, y: 20, + x: 10, + y: 20, click_x_percent: 50.0, click_y_percent: 50.0, app: "Finder".into(), window_title: "Downloads".into(), screenshot_path: Some(img_path.to_str().unwrap().to_string()), note: None, + description: None, + description_source: None, + description_status: None, + description_error: None, + ax: None, capture_status: None, capture_error: None, + crop_region: None, }; let html = super::super::html::generate_for("Test", &[step], ImageTarget::Pdf); - assert!(html.contains("data:image/jpeg;base64,"), "PDF path should use JPEG"); - assert!(!html.contains("data:image/webp;base64,"), "PDF path should not use WebP"); + assert!( + html.contains("data:image/jpeg;base64,"), + "PDF path should use JPEG" + ); + assert!( + !html.contains("data:image/webp;base64,"), + "PDF path should not use WebP" + ); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 238a244..f2b5189 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,18 +1,19 @@ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +mod apple_intelligence; +mod export; mod panel; mod recorder; mod startup_state; mod tray; -mod export; use recorder::click_listener::ClickListener; use recorder::pipeline; use recorder::session::Session; use recorder::state::{RecorderState, SessionState}; -use recorder::types::Step; +use recorder::types::{ActionType, BoundsPercent, DescriptionSource, DescriptionStatus, Step}; use serde::Serialize; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use tauri::{Emitter, Manager}; +use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; #[cfg(not(debug_assertions))] use tauri_plugin_aptabase::EventTracker; @@ -28,18 +29,59 @@ fn permission_debug_log(message: &str) { let path = dir.join("permissions.log"); let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f%:z"); - if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) { + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { let _ = writeln!(f, "[{ts}] {message}"); } } +#[cfg(debug_assertions)] +fn session_debug_log(session_dir: &std::path::Path, message: &str) { + use std::io::Write; + + let log_path = session_dir.join("recording.log"); + let is_new = !log_path.exists(); + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log_path) + { + if is_new { + let _ = writeln!(file, "session_dir={}", session_dir.to_string_lossy()); + } + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let _ = writeln!(file, "[{ts}] {message}"); + } +} + +#[cfg(debug_assertions)] +fn write_session_json(session_dir: &std::path::Path, filename: &str, value: &serde_json::Value) { + let path = session_dir.join(filename); + if let Ok(s) = serde_json::to_string_pretty(value) { + let _ = std::fs::write(path, s); + } +} + +#[cfg(debug_assertions)] +fn json_escape_one_line(s: &str) -> String { + // Keep `recording.log` one-result-per-line for easy grep. + s.replace(['\n', '\r', '\t'], " ") +} struct RecorderAppState { recorder_state: Mutex, session: Mutex>, click_listener: Mutex>, + pre_click_buffer: Mutex>, processing_running: Arc, pipeline_state: Mutex, + ai_descriptions_running: Arc, } #[derive(Debug, Clone, Copy, Serialize, Default)] @@ -48,9 +90,141 @@ struct PermissionStatus { accessibility: bool, } +#[derive(Debug, Clone, Serialize)] +struct AppleIntelligenceEligibility { + eligible: bool, + reason: String, + /// Best-effort details for debugging (platform/version/arch). + #[serde(skip_serializing_if = "Option::is_none")] + details: Option, +} + const SCREEN_RECORDING_SETTINGS_URL: &str = "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"; +#[cfg(target_os = "macos")] +fn macos_product_version() -> Option { + let out = std::process::Command::new("sw_vers") + .arg("-productVersion") + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8(out.stdout).ok()?; + let s = s.trim().to_string(); + if s.is_empty() { + None + } else { + Some(s) + } +} + +#[tauri::command] +fn get_apple_intelligence_eligibility() -> AppleIntelligenceEligibility { + let arch = std::env::consts::ARCH; + + #[cfg(not(target_os = "macos"))] + { + return AppleIntelligenceEligibility { + eligible: false, + reason: "Not supported on this platform.".to_string(), + details: Some(format!("{} ({arch})", std::env::consts::OS)), + }; + } + + #[cfg(target_os = "macos")] + { + let version = macos_product_version(); + let platform_details = version + .as_ref() + .map(|v| format!("macos {v} ({arch})")) + .or_else(|| Some(format!("macos (unknown version) ({arch})"))); + + if arch != "aarch64" { + return AppleIntelligenceEligibility { + eligible: false, + reason: "Requires Apple Silicon (M1+).".to_string(), + details: platform_details, + }; + } + + let major = version + .as_ref() + .and_then(|v| v.split('.').next()) + .and_then(|m| m.parse::().ok()); + if major.is_none() { + return AppleIntelligenceEligibility { + eligible: false, + reason: "Could not detect macOS version.".to_string(), + details: platform_details, + }; + } + + if major.unwrap_or(0) < 26 { + return AppleIntelligenceEligibility { + eligible: false, + reason: "Requires macOS 26+.".to_string(), + details: platform_details, + }; + } + + let availability = match crate::apple_intelligence::availability() { + Ok(a) => a, + Err(err) => { + return AppleIntelligenceEligibility { + eligible: false, + reason: "Could not check Apple Intelligence availability.".to_string(), + details: Some(format!( + "{}; {}", + platform_details.unwrap_or_else(|| format!("macos (unknown) ({arch})")), + err + )), + }; + } + }; + + if availability.available { + return AppleIntelligenceEligibility { + eligible: true, + reason: "Available.".to_string(), + details: platform_details, + }; + } + + let reason = match availability.reason.as_deref() { + Some("appleIntelligenceNotEnabled") => { + "Apple Intelligence is disabled in System Settings.".to_string() + } + Some("deviceNotEligible") => { + "This device is not eligible for Apple Intelligence.".to_string() + } + Some("modelNotReady") => { + "Apple Intelligence model is not ready yet (downloading/initializing).".to_string() + } + _ => availability + .details + .clone() + .unwrap_or_else(|| "Apple Intelligence unavailable.".to_string()), + }; + + let mut details = platform_details; + if let Some(extra) = availability.details.as_deref() { + if let Some(ref mut d) = details { + d.push_str("; "); + d.push_str(extra); + } else { + details = Some(extra.to_string()); + } + } + + AppleIntelligenceEligibility { + eligible: false, + reason, + details, + } + } +} #[tauri::command] async fn check_permissions() -> PermissionStatus { @@ -63,7 +237,9 @@ async fn check_permissions() -> PermissionStatus { #[cfg(not(target_os = "macos"))] let accessibility = false; if cfg!(debug_assertions) { - eprintln!("check_permissions: screen_recording={screen_recording} accessibility={accessibility}"); + eprintln!( + "check_permissions: screen_recording={screen_recording} accessibility={accessibility}" + ); } PermissionStatus { screen_recording, @@ -116,8 +292,7 @@ fn check_screen_recording_via_window_names() -> bool { for i in 0..count { let window_dict = unsafe { - core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) - as CFDictionaryRef + core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) as CFDictionaryRef }; let dict = unsafe { core_foundation::dictionary::CFDictionary::::wrap_under_get_rule( @@ -130,8 +305,7 @@ fn check_screen_recording_via_window_names() -> bool { let owner_pid = dict .find(pid_key) .and_then(|v| { - let num: CFNumber = - unsafe { CFNumber::wrap_under_get_rule(v.as_CFTypeRef() as _) }; + let num: CFNumber = unsafe { CFNumber::wrap_under_get_rule(v.as_CFTypeRef() as _) }; num.to_i32() }) .unwrap_or(0); @@ -196,8 +370,7 @@ fn first_foreign_window_id() -> Option { let count = unsafe { core_foundation::array::CFArrayGetCount(window_list as _) }; for i in 0..count { let window_dict = unsafe { - core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) - as CFDictionaryRef + core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) as CFDictionaryRef }; let dict = unsafe { core_foundation::dictionary::CFDictionary::::wrap_under_get_rule( @@ -209,8 +382,7 @@ fn first_foreign_window_id() -> Option { let owner_pid = dict .find(pid_key) .and_then(|v| { - let num: CFNumber = - unsafe { CFNumber::wrap_under_get_rule(v.as_CFTypeRef() as _) }; + let num: CFNumber = unsafe { CFNumber::wrap_under_get_rule(v.as_CFTypeRef() as _) }; num.to_i32() }) .unwrap_or(0); @@ -231,8 +403,7 @@ fn first_foreign_window_id() -> Option { let window_id = dict .find(id_key) .and_then(|v| { - let num: CFNumber = - unsafe { CFNumber::wrap_under_get_rule(v.as_CFTypeRef() as _) }; + let num: CFNumber = unsafe { CFNumber::wrap_under_get_rule(v.as_CFTypeRef() as _) }; num.to_i32() }) .unwrap_or(0); @@ -269,7 +440,9 @@ async fn request_screen_recording(app: tauri::AppHandle) -> PermissionStatus { { let (tx, rx) = std::sync::mpsc::channel(); let _ = app.run_on_main_thread(move || { - permission_debug_log("request_screen_recording(main): calling CGRequestScreenCaptureAccess"); + permission_debug_log( + "request_screen_recording(main): calling CGRequestScreenCaptureAccess", + ); #[link(name = "CoreGraphics", kind = "framework")] extern "C" { @@ -285,7 +458,9 @@ async fn request_screen_recording(app: tauri::AppHandle) -> PermissionStatus { // Only open System Settings if still not granted (avoid double dialog) if !check_screen_recording() { - if let Err(err) = tauri_plugin_opener::open_url(SCREEN_RECORDING_SETTINGS_URL, None::<&str>) { + if let Err(err) = + tauri_plugin_opener::open_url(SCREEN_RECORDING_SETTINGS_URL, None::<&str>) + { eprintln!("Failed to open Screen Recording settings: {err}"); } } @@ -303,7 +478,9 @@ async fn request_accessibility(app: tauri::AppHandle) -> PermissionStatus { // open System Settings ourselves. let (tx, rx) = std::sync::mpsc::channel(); let _ = app.run_on_main_thread(move || { - permission_debug_log("request_accessibility(main): calling AXIsProcessTrustedWithOptions"); + permission_debug_log( + "request_accessibility(main): calling AXIsProcessTrustedWithOptions", + ); let result = ax_is_process_trusted_with_prompt(); permission_debug_log(&format!( "request_accessibility(main): AXIsProcessTrustedWithOptions -> {result}" @@ -356,11 +533,22 @@ fn process_clicks_loop(app: tauri::AppHandle, processing_running: Arc) -> Result<(), Stri .recorder_state .lock() .map_err(|_| "recorder state lock poisoned".to_string())?; - recorder_state - .pause() - .map_err(|error| format!("{error:?}")) + recorder_state.pause().map_err(|error| format!("{error:?}")) } #[tauri::command] @@ -583,13 +776,28 @@ fn stop_recording( listener.stop(); } } + { + let mut pre_click_lock = state + .pre_click_buffer + .lock() + .map_err(|_| "pre-click buffer lock poisoned")?; + if let Some(buffer) = pre_click_lock.take() { + buffer.stop(); + } + } + { + let mut pre_click_lock = state + .pre_click_buffer + .lock() + .map_err(|_| "pre-click buffer lock poisoned")?; + if let Some(buffer) = pre_click_lock.take() { + buffer.stop(); + } + } // Write diagnostics and get steps from session let steps = { - let session_lock = state - .session - .lock() - .map_err(|_| "session lock poisoned")?; + let session_lock = state.session.lock().map_err(|_| "session lock poisoned")?; if let Some(s) = session_lock.as_ref() { s.write_diagnostics(); } @@ -643,14 +851,12 @@ fn stop_recording( #[tauri::command] fn get_steps(state: tauri::State<'_, RecorderAppState>) -> Result, String> { - let session_lock = state - .session - .lock() - .map_err(|_| "session lock poisoned")?; - Ok(session_lock + let session_lock = state.session.lock().map_err(|_| "session lock poisoned")?; + let steps = session_lock .as_ref() .map(|s| s.get_steps().to_vec()) - .unwrap_or_default()) + .unwrap_or_default(); + Ok(steps) } #[tauri::command] @@ -677,10 +883,7 @@ fn discard_recording( // Write diagnostics, then clean up session temp dir and clear session { - let mut session_lock = state - .session - .lock() - .map_err(|_| "session lock poisoned")?; + let mut session_lock = state.session.lock().map_err(|_| "session lock poisoned")?; if let Some(session) = session_lock.as_ref() { session.write_diagnostics(); session.cleanup(); @@ -700,10 +903,16 @@ fn discard_recording( // Reset pipeline state { - let mut ps = state.pipeline_state.lock().map_err(|_| "pipeline state lock poisoned")?; + let mut ps = state + .pipeline_state + .lock() + .map_err(|_| "pipeline state lock poisoned")?; ps.reset(); } + // Notify editor window (if open) that steps were discarded + let _ = app.emit("steps-discarded", ()); + // Show panel and reset icon on main thread after discard let app_clone = app.clone(); let _ = app.run_on_main_thread(move || { @@ -733,6 +942,437 @@ fn discard_recording( Ok(()) } +#[tauri::command] +fn update_step_note( + app: tauri::AppHandle, + state: tauri::State<'_, RecorderAppState>, + step_id: String, + note: Option, +) -> Result<(), String> { + let mut session_lock = state.session.lock().map_err(|_| "session lock poisoned")?; + let session = session_lock.as_mut().ok_or("no active session")?; + let updated = session + .update_step_note(&step_id, note) + .ok_or("step not found")? + .clone(); + let _ = app.emit("step-updated", &updated); + Ok(()) +} + +#[tauri::command] +fn update_step_description( + app: tauri::AppHandle, + state: tauri::State<'_, RecorderAppState>, + step_id: String, + description: Option, +) -> Result<(), String> { + let mut session_lock = state.session.lock().map_err(|_| "session lock poisoned")?; + let session = session_lock.as_mut().ok_or("no active session")?; + let updated = session + .set_step_description_manual(&step_id, description) + .ok_or("step not found")? + .clone(); + let _ = app.emit("step-updated", &updated); + Ok(()) +} + +fn normalize_crop_region_input(crop_region: Option) -> Option { + let input = crop_region?; + let values = [ + input.x_percent, + input.y_percent, + input.width_percent, + input.height_percent, + ]; + if values.iter().any(|v| !v.is_finite()) { + return None; + } + + let x = input.x_percent.clamp(0.0, 100.0); + let y = input.y_percent.clamp(0.0, 100.0); + let mut w = input.width_percent.clamp(0.0, 100.0); + let mut h = input.height_percent.clamp(0.0, 100.0); + if x + w > 100.0 { + w = (100.0 - x).max(0.0); + } + if y + h > 100.0 { + h = (100.0 - y).max(0.0); + } + + const MIN_SIZE_PERCENT: f32 = 2.0; + if w < MIN_SIZE_PERCENT || h < MIN_SIZE_PERCENT { + return None; + } + + Some(BoundsPercent { + x_percent: x, + y_percent: y, + width_percent: w, + height_percent: h, + }) +} + +#[tauri::command] +fn update_step_crop( + app: tauri::AppHandle, + state: tauri::State<'_, RecorderAppState>, + step_id: String, + crop_region: Option, +) -> Result<(), String> { + let mut session_lock = state.session.lock().map_err(|_| "session lock poisoned")?; + let session = session_lock.as_mut().ok_or("no active session")?; + let updated = session + .update_step_crop(&step_id, normalize_crop_region_input(crop_region)) + .ok_or("step not found")? + .clone(); + let _ = app.emit("step-updated", &updated); + Ok(()) +} + +#[tauri::command] +fn generate_step_descriptions( + app: tauri::AppHandle, + state: tauri::State<'_, RecorderAppState>, + mode: Option, + step_ids: Option>, +) -> Result<(), String> { + // Serialize description generation to avoid racing step updates. + if state.ai_descriptions_running.swap(true, Ordering::SeqCst) { + return Err("AI description generation already running.".into()); + } + + struct ResetOnDrop(std::sync::Arc); + impl Drop for ResetOnDrop { + fn drop(&mut self) { + self.0.store(false, Ordering::SeqCst); + } + } + let running_guard = ResetOnDrop(state.ai_descriptions_running.clone()); + + #[derive(Debug, Clone, Copy)] + enum Mode { + MissingOnly, + All, + Ids, + } + + let parsed_mode = match (mode.as_deref(), step_ids.as_ref()) { + (_, Some(ids)) if !ids.is_empty() => Mode::Ids, + (Some("all"), _) => Mode::All, + _ => Mode::MissingOnly, + }; + + // Slightly longer than a one-liner, still "no novels" — enables useful context like "from the Dock". + let max_chars = 110usize; + let mut ids_to_generate: Vec = Vec::new(); + let (steps_to_generate, session_dir): (Vec, std::path::PathBuf) = { + let mut session_lock = state.session.lock().map_err(|_| "session lock poisoned")?; + let Some(session) = session_lock.as_mut() else { + return Err("no active session".into()); + }; + + let session_dir = session.temp_dir.clone(); + let mut steps: Vec = Vec::new(); + let id_set: std::collections::HashSet = + step_ids.clone().unwrap_or_default().into_iter().collect(); + + for step in session.steps.iter_mut() { + if crate::apple_intelligence::is_auth_placeholder(step) { + continue; + } + if step.action == ActionType::Note { + continue; + } + + let should_generate = match parsed_mode { + Mode::Ids => id_set.contains(&step.id), + Mode::All => !matches!(step.description_source, Some(DescriptionSource::Manual)), + Mode::MissingOnly => { + crate::apple_intelligence::is_blank_description(step.description.as_deref()) + && !matches!(step.description_source, Some(DescriptionSource::Manual)) + } + }; + + if !should_generate { + continue; + } + + step.description_status = Some(DescriptionStatus::Generating); + step.description_error = None; + + let updated = step.clone(); + ids_to_generate.push(step.id.clone()); + steps.push(updated.clone()); + let _ = app.emit("step-updated", &updated); + } + + (steps, session_dir) + }; + + if steps_to_generate.is_empty() { + return Ok(()); + } + + #[cfg(debug_assertions)] + let trace_ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + + #[cfg(debug_assertions)] + { + session_debug_log( + &session_dir, + &format!( + "ai_generate_start trace={} mode={:?} count={} max_chars={}", + trace_ts, + mode.as_deref().unwrap_or("missing_only"), + steps_to_generate.len(), + max_chars + ), + ); + let req_json = serde_json::json!({ + "trace": trace_ts, + "mode": mode.as_deref().unwrap_or("missing_only"), + "max_chars": max_chars, + "step_ids": ids_to_generate, + "steps": steps_to_generate, + }); + write_session_json( + &session_dir, + &format!("ai-trace-{trace_ts}-request.json"), + &req_json, + ); + } + + let running = state.ai_descriptions_running.clone(); + let app_handle = app.clone(); + let session_dir_for_logs = session_dir.clone(); + + tauri::async_runtime::spawn(async move { + let generate_steps = steps_to_generate; + + let resp = tauri::async_runtime::spawn_blocking(move || { + crate::apple_intelligence::generate_descriptions(generate_steps, max_chars) + }) + .await; + + let apply_error_to_all = |app_handle: &tauri::AppHandle, ids: &[String], err: String| { + let state = app_handle.state::(); + let mut session_lock = match state.session.lock() { + Ok(l) => l, + Err(e) => e.into_inner(), + }; + let Some(session) = session_lock.as_mut() else { + return; + }; + for id in ids { + if let Some(step) = session.mark_step_description_failed(id, err.clone()) { + let _ = app_handle.emit("step-updated", step); + } + } + }; + + match resp { + Ok(Ok(gen)) => { + #[cfg(debug_assertions)] + { + let resp_json = serde_json::json!({ + "trace": trace_ts, + "results": gen.results, + "failures": gen.failures, + }); + write_session_json( + &session_dir_for_logs, + &format!("ai-trace-{trace_ts}-response.json"), + &resp_json, + ); + } + + let state = app_handle.state::(); + let mut session_lock = match state.session.lock() { + Ok(l) => l, + Err(e) => e.into_inner(), + }; + let Some(session) = session_lock.as_mut() else { + running.store(false, Ordering::SeqCst); + return; + }; + + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + for r in gen.results { + seen.insert(r.id.clone()); + #[cfg(debug_assertions)] + { + session_debug_log( + &session_dir_for_logs, + &format!( + "ai_desc trace={} id={} text={}", + trace_ts, + r.id, + json_escape_one_line(&r.text) + ), + ); + if let Some(debug) = &r.debug { + if let Ok(debug_json) = serde_json::to_string(debug) { + session_debug_log( + &session_dir_for_logs, + &format!( + "ai_desc_debug trace={} id={} data={}", + trace_ts, + r.id, + json_escape_one_line(&debug_json) + ), + ); + } + } + } + if let Some(step) = session.apply_step_description_ai(&r.id, r.text) { + let _ = app_handle.emit("step-updated", step); + } + } + for f in gen.failures { + seen.insert(f.id.clone()); + if f.id == "*" { + continue; + } + #[cfg(debug_assertions)] + { + session_debug_log( + &session_dir_for_logs, + &format!( + "ai_desc_failed trace={} id={} error={}", + trace_ts, + f.id, + json_escape_one_line(&f.error) + ), + ); + } + if let Some(step) = session.mark_step_description_failed(&f.id, f.error) { + let _ = app_handle.emit("step-updated", step); + } + } + + // Any step that was marked generating but has no result should be failed. + for id in &ids_to_generate { + if seen.contains(id) { + continue; + } + #[cfg(debug_assertions)] + { + session_debug_log( + &session_dir_for_logs, + &format!( + "ai_desc_failed trace={} id={} error={}", + trace_ts, id, "No model output." + ), + ); + } + if let Some(step) = + session.mark_step_description_failed(id, "No model output.".into()) + { + let _ = app_handle.emit("step-updated", step); + } + } + + #[cfg(debug_assertions)] + session_debug_log( + &session_dir_for_logs, + &format!("ai_generate_done trace={trace_ts}"), + ); + } + Ok(Err(err)) => { + #[cfg(debug_assertions)] + session_debug_log( + &session_dir_for_logs, + &format!( + "ai_generate_failed trace={} error={}", + trace_ts, + json_escape_one_line(&err) + ), + ); + apply_error_to_all(&app_handle, &ids_to_generate, err) + } + Err(err) => { + #[cfg(debug_assertions)] + session_debug_log( + &session_dir_for_logs, + &format!( + "ai_generate_failed trace={} error={}", + trace_ts, + json_escape_one_line(&err.to_string()) + ), + ); + apply_error_to_all( + &app_handle, + &ids_to_generate, + format!("AI generation task failed: {err}"), + ) + } + } + + running.store(false, Ordering::SeqCst); + }); + + // Background task owns resetting the running flag. + std::mem::forget(running_guard); + Ok(()) +} + +#[tauri::command] +fn delete_step( + app: tauri::AppHandle, + state: tauri::State<'_, RecorderAppState>, + step_id: String, +) -> Result<(), String> { + let mut session_lock = state.session.lock().map_err(|_| "session lock poisoned")?; + let session = session_lock.as_mut().ok_or("no active session")?; + if !session.delete_step(&step_id) { + return Err("step not found".into()); + } + let _ = app.emit("step-deleted", &step_id); + Ok(()) +} + +#[tauri::command] +fn reorder_steps( + app: tauri::AppHandle, + state: tauri::State<'_, RecorderAppState>, + step_ids: Vec, +) -> Result, String> { + let mut session_lock = state.session.lock().map_err(|_| "session lock poisoned")?; + let session = session_lock.as_mut().ok_or("no active session")?; + session.reorder_steps(&step_ids); + let steps = session.get_steps().to_vec(); + let _ = app.emit("steps-reordered", &steps); + Ok(steps) +} + +#[tauri::command] +fn open_editor_window(app: tauri::AppHandle) -> Result<(), String> { + // Hide the tray panel so it doesn't overlap the editor + if let Some(panel_window) = app.get_webview_window(panel::panel_label()) { + let _ = panel_window.hide(); + } + + // If editor already exists, focus it + if let Some(window) = app.get_webview_window("step-editor") { + let _ = window.set_focus(); + return Ok(()); + } + + WebviewWindowBuilder::new(&app, "step-editor", WebviewUrl::App("/editor.html".into())) + .title("Step Editor") + .inner_size(900.0, 700.0) + .resizable(true) + .decorations(true) + .build() + .map_err(|e| format!("Failed to create editor window: {e}"))?; + + Ok(()) +} + #[tauri::command] async fn export_guide( app: tauri::AppHandle, @@ -744,7 +1384,10 @@ async fn export_guide( let fmt = export::ExportFormat::from_str(&format)?; let steps = { let session_lock = state.session.lock().map_err(|_| "session lock poisoned")?; - session_lock.as_ref().map(|s| s.get_steps().to_vec()).unwrap_or_default() + session_lock + .as_ref() + .map(|s| s.get_steps().to_vec()) + .unwrap_or_default() }; export::export(&title, &steps, fmt, &output_path, &app) } @@ -780,7 +1423,10 @@ pub fn run() { let _recorder = recorder::Recorder::new(); // Clean up leftover session directories from previous runs - Session::cleanup_all_sessions(); + // In dev, keep session dirs so we can audit recorder + AI behavior. + if !cfg!(debug_assertions) { + Session::cleanup_all_sessions(); + } // Tokio runtime required by tauri-plugin-aptabase let _rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime"); @@ -811,15 +1457,30 @@ pub fn run() { panel::init(app.handle())?; tray::create(app.handle())?; + // Resolve Apple Intelligence helper path early. This is required for release builds + // because we execute the Swift helper directly from the app bundle resources + // (codesigned/notarized), not from a cache-extracted copy. + #[cfg(target_os = "macos")] + { + if let Err(err) = crate::apple_intelligence::init(app.handle()) { + if cfg!(debug_assertions) { + eprintln!("Apple Intelligence helper init failed: {err}"); + } + } + } + // Register global shortcut Cmd+Shift+S to toggle panel { use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut}; let shortcut = Shortcut::new(Some(Modifiers::META | Modifiers::SHIFT), Code::KeyS); - if let Err(err) = app.global_shortcut().on_shortcut(shortcut, |app, _shortcut, event| { - if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed { - tray::toggle_panel(app); - } - }) { + if let Err(err) = + app.global_shortcut() + .on_shortcut(shortcut, |app, _shortcut, event| { + if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed { + tray::toggle_panel(app); + } + }) + { eprintln!("Failed to register global shortcut: {err}"); } } @@ -833,11 +1494,14 @@ pub fn run() { recorder_state: Mutex::new(RecorderState::new()), session: Mutex::new(None), click_listener: Mutex::new(None), + pre_click_buffer: Mutex::new(None), processing_running: Arc::new(AtomicBool::new(false)), pipeline_state: Mutex::new(pipeline::PipelineState::new()), + ai_descriptions_running: Arc::new(AtomicBool::new(false)), }) .invoke_handler(tauri::generate_handler![ check_permissions, + get_apple_intelligence_eligibility, request_screen_recording, request_accessibility, start_recording, @@ -845,8 +1509,15 @@ pub fn run() { resume_recording, stop_recording, get_steps, + update_step_note, + update_step_description, + update_step_crop, + delete_step, + reorder_steps, + open_editor_window, export_guide, discard_recording, + generate_step_descriptions, get_startup_state, mark_startup_seen, dismiss_whats_new, diff --git a/src-tauri/src/panel.rs b/src-tauri/src/panel.rs index 49feece..9a2a1af 100644 --- a/src-tauri/src/panel.rs +++ b/src-tauri/src/panel.rs @@ -171,7 +171,9 @@ pub fn tray_icon_metrics( Err("no monitor found containing tray icon position".to_string()) } -pub fn panel_bounds(app_handle: &AppHandle) -> Result { +pub fn panel_bounds( + app_handle: &AppHandle, +) -> Result { let window = app_handle .get_webview_window(PANEL_LABEL) .ok_or_else(|| "panel window missing".to_string())?; @@ -290,7 +292,7 @@ mod tests { .as_f64() .expect("height is number"); let expected_width = 340.0; - let expected_height = 554.0; + let expected_height = 640.0; assert_eq!(width, expected_width); assert_eq!(height, expected_height); diff --git a/src-tauri/src/recorder/ax_helpers.rs b/src-tauri/src/recorder/ax_helpers.rs index cb23e08..b4cfc36 100644 --- a/src-tauri/src/recorder/ax_helpers.rs +++ b/src-tauri/src/recorder/ax_helpers.rs @@ -48,8 +48,7 @@ pub(super) fn get_pid_at_position(x: f32, y: f32) -> Option { let system_wide = CfRef::wrap(AXUIElementCreateSystemWide() as *mut _)?; let mut element: accessibility_sys::AXUIElementRef = std::ptr::null_mut(); - let result = - AXUIElementCopyElementAtPosition(system_wide.as_type(), x, y, &mut element); + let result = AXUIElementCopyElementAtPosition(system_wide.as_type(), x, y, &mut element); if result != 0 { return None; } @@ -69,7 +68,19 @@ pub(super) fn get_pid_at_position(x: f32, y: f32) -> Option { #[derive(Debug, Clone)] pub(super) struct AxElementLabel { pub role: String, + pub subrole: Option, + /// Human readable role label from Accessibility (often localized). + pub role_description: Option, + /// Best-effort identifier (when apps set AXIdentifier). + pub identifier: Option, pub label: String, + /// Bounds of the selected element in screen coordinates (may be missing). + pub element_bounds: Option, + /// First meaningful container role found in the parent chain (helps when the + /// clicked element is a static text inside a list row, etc.). + pub container_role: Option, + pub container_subrole: Option, + pub container_identifier: Option, pub window_role: Option, pub window_subrole: Option, pub window_bounds: Option, @@ -79,6 +90,7 @@ pub(super) struct AxElementLabel { pub parent_dialog_role: Option, pub parent_dialog_subrole: Option, pub parent_dialog_bounds: Option, + pub is_checked: Option, pub is_cancel_button: bool, pub is_default_button: bool, } @@ -114,6 +126,352 @@ fn ax_copy_string_attr( } } +fn ax_copy_bool_attr(element: accessibility_sys::AXUIElementRef, attr_name: &str) -> Option { + use accessibility_sys::AXUIElementCopyAttributeValue; + use core_foundation::base::{CFGetTypeID, CFTypeRef, TCFType}; + use core_foundation::boolean::{CFBoolean, CFBooleanGetTypeID}; + use core_foundation::number::CFNumber; + use core_foundation::string::CFString; + + unsafe { + let attr = CFString::new(attr_name); + let mut value: CFTypeRef = std::ptr::null_mut(); + let result = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef(), &mut value); + if result != 0 { + return None; + } + let guard = CfRef::wrap(value as *mut _)?; + let raw = guard.as_ptr() as *const _; + let ty = CFGetTypeID(raw); + + if ty == CFBooleanGetTypeID() { + let b: CFBoolean = CFBoolean::wrap_under_get_rule(raw as _); + return Some(bool::from(b)); + } + + if ty == CFNumber::type_id() { + let num: CFNumber = CFNumber::wrap_under_get_rule(raw as _); + if let Some(v) = num.to_i32() { + return Some(v != 0); + } + } + + if ty == CFString::type_id() { + let s = CFString::wrap_under_get_rule(raw as _).to_string(); + let t = s.trim().to_lowercase(); + return match t.as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + }; + } + + None + } +} + +fn ax_best_label_for_element(element: accessibility_sys::AXUIElementRef) -> Option { + use accessibility_sys::{ + kAXDescriptionAttribute, kAXRoleAttribute, kAXTitleAttribute, kAXValueAttribute, + kAXValueDescriptionAttribute, + }; + + let role = ax_copy_string_attr(element, kAXRoleAttribute) + .unwrap_or_default() + .to_lowercase(); + + // Role-specific label extraction: + // - For text fields: never use AXValue (can contain user-entered text). + // - For static text: AXValue is often the visible string. + // - Otherwise: prefer Title/ValueDescription/Description. + if role.contains("textfield") || role.contains("text field") || role.contains("textarea") { + return ax_copy_string_attr(element, kAXTitleAttribute) + .or_else(|| ax_copy_string_attr(element, kAXValueDescriptionAttribute)) + .or_else(|| ax_copy_string_attr(element, kAXDescriptionAttribute)); + } + + if role.contains("statictext") || role.contains("static text") { + return ax_copy_string_attr(element, kAXValueAttribute) + .or_else(|| ax_copy_string_attr(element, kAXTitleAttribute)) + .or_else(|| ax_copy_string_attr(element, kAXDescriptionAttribute)) + .or_else(|| ax_copy_string_attr(element, kAXValueDescriptionAttribute)); + } + + ax_copy_string_attr(element, kAXTitleAttribute) + .or_else(|| ax_copy_string_attr(element, kAXValueDescriptionAttribute)) + .or_else(|| ax_copy_string_attr(element, kAXDescriptionAttribute)) +} + +fn ax_copy_children(element: accessibility_sys::AXUIElementRef) -> Vec { + use accessibility_sys::{kAXChildrenAttribute, AXUIElementCopyAttributeValue}; + use core_foundation::array::{ + CFArrayGetCount, CFArrayGetTypeID, CFArrayGetValueAtIndex, CFArrayRef, + }; + use core_foundation::base::{CFGetTypeID, CFRetain, CFTypeRef, TCFType}; + use core_foundation::string::CFString; + + unsafe { + let attr = CFString::new(kAXChildrenAttribute); + let mut value: CFTypeRef = std::ptr::null_mut(); + let result = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef(), &mut value); + if result != 0 { + return Vec::new(); + } + + let Some(guard) = CfRef::wrap(value as *mut _) else { + return Vec::new(); + }; + + if CFGetTypeID(guard.as_ptr() as _) != CFArrayGetTypeID() { + return Vec::new(); + } + + let arr: CFArrayRef = guard.as_ptr() as _; + let count = CFArrayGetCount(arr); + if count <= 0 { + return Vec::new(); + } + + // Keep this bounded: some elements have huge child lists (tables, etc.). + let limit = count.min(32); + let mut out: Vec = Vec::with_capacity(limit as usize); + + for i in 0..limit { + let ptr = CFArrayGetValueAtIndex(arr, i); + if ptr.is_null() { + continue; + } + let retained = CFRetain(ptr); + if retained.is_null() { + continue; + } + if let Some(child) = CfRef::wrap(retained as *mut _) { + out.push(child); + } + } + + out + } +} + +fn ax_copy_action_names(element: accessibility_sys::AXUIElementRef) -> Vec { + use accessibility_sys::AXUIElementCopyActionNames; + use core_foundation::array::{CFArrayGetCount, CFArrayGetValueAtIndex, CFArrayRef}; + use core_foundation::base::TCFType; + use core_foundation::string::CFString; + + unsafe { + let mut names: CFArrayRef = std::ptr::null_mut(); + let result = AXUIElementCopyActionNames(element, &mut names); + if result != 0 || names.is_null() { + return Vec::new(); + } + let Some(guard) = CfRef::wrap(names as *mut _) else { + return Vec::new(); + }; + let arr = guard.as_ptr() as CFArrayRef; + let count = CFArrayGetCount(arr as _); + if count <= 0 { + return Vec::new(); + } + + let limit = count.min(32); + let mut out: Vec = Vec::with_capacity(limit as usize); + + for i in 0..limit { + let value = CFArrayGetValueAtIndex(arr as _, i); + if value.is_null() { + continue; + } + let s = CFString::wrap_under_get_rule(value as _).to_string(); + let trimmed = s.trim().to_string(); + if trimmed.is_empty() { + continue; + } + out.push(trimmed); + } + + out + } +} + +fn ax_actions_has_press(actions: &[String]) -> bool { + actions.iter().any(|a| a == "AXPress" || a == "AXConfirm") +} + +fn ax_actions_has_menu(actions: &[String]) -> bool { + actions.iter().any(|a| a == "AXShowMenu") +} + +fn ax_role_score(role: &str, subrole: &str) -> i32 { + let r = role.to_lowercase(); + let s = subrole.to_lowercase(); + if r.contains("button") && s.contains("close") { + return 260; + } + if r.contains("button") { + return 240; + } + if r.contains("textfield") + || r.contains("text field") + || r.contains("textarea") + || s.contains("searchfield") + { + return 230; + } + if r.contains("menuitem") { + return 220; + } + if r.contains("menubar") || r.contains("menubaritem") { + return 210; + } + if r.contains("checkbox") || r.contains("radiobutton") { + return 200; + } + if r.contains("tab") { + return 190; + } + if r.contains("row") + || r.contains("cell") + || r.contains("outline") + || r.contains("table") + || r.contains("list") + { + return 170; + } + if r.contains("statictext") || r.contains("static text") { + return 120; + } + 0 +} + +fn ax_candidate_score( + role: &str, + subrole: &str, + actions: &[String], + label: &str, + depth: usize, +) -> i32 { + let mut score = ax_role_score(role, subrole); + if ax_actions_has_press(actions) { + // Some apps implement "buttons" as pressable groups; prefer pressable elements + // over nearby static text labels to avoid "App in App" descriptions. + score = score.max(220); + } + if ax_actions_has_menu(actions) { + score = score.max(200); + } + if !label.trim().is_empty() { + score += 50; + } + // Prefer the closest element in the chain when scores are similar. + score -= (depth as i32) * 4; + score +} + +fn ax_find_label_in_tree( + element: accessibility_sys::AXUIElementRef, + max_depth: usize, + nodes_left: &mut usize, +) -> Option { + if max_depth == 0 || *nodes_left == 0 { + return None; + } + + let children = ax_copy_children(element); + if children.is_empty() { + return None; + } + + for child in &children { + if *nodes_left == 0 { + return None; + } + *nodes_left -= 1; + if let Some(label) = ax_best_label_for_element(child.as_type()) { + return Some(label); + } + } + + if max_depth <= 1 { + return None; + } + + for child in children { + if *nodes_left == 0 { + return None; + } + if let Some(label) = + ax_find_label_in_tree(child.as_type(), max_depth.saturating_sub(1), nodes_left) + { + return Some(label); + } + } + + None +} + +fn ax_find_static_text_label_in_tree( + element: accessibility_sys::AXUIElementRef, + max_depth: usize, + nodes_left: &mut usize, +) -> Option { + use accessibility_sys::kAXRoleAttribute; + + if max_depth == 0 || *nodes_left == 0 { + return None; + } + + let children = ax_copy_children(element); + if children.is_empty() { + return None; + } + + for child in &children { + if *nodes_left == 0 { + return None; + } + *nodes_left -= 1; + let role = ax_copy_string_attr(child.as_type(), kAXRoleAttribute) + .unwrap_or_default() + .to_lowercase(); + if !(role.contains("statictext") || role.contains("static text")) { + continue; + } + if let Some(label) = ax_best_label_for_element(child.as_type()) { + return Some(label); + } + } + + if max_depth <= 1 { + return None; + } + + for child in children { + if *nodes_left == 0 { + return None; + } + if let Some(label) = ax_find_static_text_label_in_tree( + child.as_type(), + max_depth.saturating_sub(1), + nodes_left, + ) { + return Some(label); + } + } + + None +} + +fn ax_copy_title_ui_label(element: accessibility_sys::AXUIElementRef) -> Option { + let title_el = ax_copy_element_attr(element, "AXTitleUIElement")?; + ax_best_label_for_element(title_el.as_type()) +} + +fn ax_copy_placeholder_value(element: accessibility_sys::AXUIElementRef) -> Option { + ax_copy_string_attr(element, "AXPlaceholderValue") +} + fn ax_copy_element_attr( element: accessibility_sys::AXUIElementRef, attr_name: &str, @@ -156,8 +514,8 @@ fn ax_copy_window_bounds( window_element: accessibility_sys::AXUIElementRef, ) -> Option { use accessibility_sys::{ - AXValueGetType, AXValueGetValue, kAXPositionAttribute, kAXSizeAttribute, - kAXValueTypeCGPoint, kAXValueTypeCGSize, + kAXPositionAttribute, kAXSizeAttribute, kAXValueTypeCGPoint, kAXValueTypeCGSize, + AXValueGetType, AXValueGetValue, }; use core_graphics::geometry::{CGPoint, CGSize}; use std::ffi::c_void; @@ -222,11 +580,7 @@ fn ax_element_matches_attr_element( fn ax_find_dialog_parent( element: accessibility_sys::AXUIElementRef, -) -> ( - Option, - Option, - Option, -) { +) -> (Option, Option, Option) { use accessibility_sys::{kAXParentAttribute, kAXRoleAttribute, kAXSubroleAttribute}; let mut current_raw = element; @@ -256,15 +610,331 @@ fn ax_find_dialog_parent( (None, None, None) } +fn is_container_role(role: &str) -> bool { + matches!( + role, + "AXRow" + | "AXCell" + | "AXTable" + | "AXOutline" + | "AXList" + | "AXScrollArea" + | "AXGroup" + | "AXToolbar" + | "AXSplitGroup" + ) +} + +fn ax_find_container_parent( + element: accessibility_sys::AXUIElementRef, +) -> (Option, Option, Option) { + use accessibility_sys::{kAXParentAttribute, kAXRoleAttribute, kAXSubroleAttribute}; + + let mut current_raw = element; + let mut current_guard: Option = None; + + fn container_score(role: &str, subrole: Option<&str>, identifier: Option<&str>) -> i32 { + let r = role; + let s = subrole.unwrap_or("").to_lowercase(); + let i = identifier.unwrap_or("").to_lowercase(); + + let mut score = match r { + "AXOutline" | "AXTable" | "AXList" => 100, + "AXScrollArea" | "AXSplitGroup" => 80, + "AXGroup" => 60, + "AXRow" | "AXCell" => 20, + "AXToolbar" => 10, + _ => 0, + }; + + if s.contains("sourcelist") || s.contains("sidebar") || i.contains("sidebar") { + score += 80; + } + + score + } + + let mut best: (i32, String, Option, Option) = (0, String::new(), None, None); + + for _ in 0..12 { + let Some(parent) = ax_copy_element_attr(current_raw, kAXParentAttribute) else { + break; + }; + current_raw = parent.as_type(); + current_guard = Some(parent); + + let role = ax_copy_string_attr(current_raw, kAXRoleAttribute); + if let Some(ref r) = role { + if is_container_role(r) { + let subrole = ax_copy_string_attr(current_raw, kAXSubroleAttribute); + let ident = ax_copy_string_attr(current_raw, "AXIdentifier"); + let score = container_score(r, subrole.as_deref(), ident.as_deref()); + + if score > best.0 { + best = (score, r.clone(), subrole.clone(), ident.clone()); + } + + // If we find a strong semantic signal (source list), stop early. + if score >= 180 { + drop(current_guard); + return (Some(best.1), best.2, best.3); + } + } + } + } + drop(current_guard); + + if best.0 > 0 { + (Some(best.1), best.2, best.3) + } else { + (None, None, None) + } +} + +fn ax_copy_element_array_attr( + element: accessibility_sys::AXUIElementRef, + attr_name: &str, +) -> Vec { + use accessibility_sys::AXUIElementCopyAttributeValue; + use core_foundation::array::{ + CFArrayGetCount, CFArrayGetTypeID, CFArrayGetValueAtIndex, CFArrayRef, + }; + use core_foundation::base::{CFGetTypeID, CFRetain, CFTypeRef, TCFType}; + use core_foundation::string::CFString; + + unsafe { + let attr = CFString::new(attr_name); + let mut value: CFTypeRef = std::ptr::null_mut(); + let result = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef(), &mut value); + if result != 0 { + return Vec::new(); + } + + let Some(guard) = CfRef::wrap(value as *mut _) else { + return Vec::new(); + }; + + if CFGetTypeID(guard.as_ptr() as _) != CFArrayGetTypeID() { + return Vec::new(); + } + + let arr: CFArrayRef = guard.as_ptr() as _; + let count = CFArrayGetCount(arr); + if count <= 0 { + return Vec::new(); + } + + // Keep this bounded: some apps expose huge selection arrays. + let limit = count.min(8); + let mut out: Vec = Vec::with_capacity(limit as usize); + for i in 0..limit { + let ptr = CFArrayGetValueAtIndex(arr, i); + if ptr.is_null() { + continue; + } + let retained = CFRetain(ptr); + if retained.is_null() { + continue; + } + if let Some(child) = CfRef::wrap(retained as *mut _) { + out.push(child); + } + } + out + } +} + +fn is_listish_role(role: &str) -> bool { + let r = role.to_lowercase(); + r.contains("outline") || r.contains("table") || r.contains("list") +} + +fn label_is_suspicious_for_list(label: &str) -> bool { + let t = label.trim(); + if t.is_empty() { + return true; + } + if t.len() > 56 { + return true; + } + let mut digits = 0usize; + let mut commas = 0usize; + for ch in t.chars() { + if ch.is_ascii_digit() { + digits += 1; + } + if ch == ',' { + commas += 1; + } + } + if digits >= 10 && t.len() >= 28 { + return true; + } + if commas >= 2 && t.len() >= 34 { + return true; + } + + let lower = t.to_lowercase(); + // Common "view mode" / structural labels. Keep small; this is cross-app enough for AppKit UI. + if lower.contains("darstellung") + || lower.contains("ansicht") + || lower.ends_with(" view") + || lower == "sidebar" + || lower == "seitenleiste" + || lower == "source list" + || lower == "list" + { + return true; + } + + if label_looks_like_size_or_time(&lower) { + return true; + } + + false +} + +fn looks_like_filename_label(label: &str) -> bool { + let t = label.trim(); + if t.len() < 3 || t.len() > 96 { + return false; + } + let Some((base, ext)) = t.rsplit_once('.') else { + return false; + }; + if base.trim().is_empty() { + return false; + } + if ext.is_empty() || ext.len() > 8 { + return false; + } + ext.chars().all(|c| c.is_ascii_alphanumeric()) +} + +fn selected_label_is_probably_ui_target(label: &str) -> bool { + let t = label.trim(); + if t.is_empty() { + return false; + } + if t.contains('\n') || t.contains('\r') || t.contains('\t') { + return false; + } + if looks_like_filename_label(t) { + return true; + } + // Avoid accidentally surfacing long free-form text (e.g. chat message previews). + if t.len() > 40 { + return false; + } + let lower = t.to_lowercase(); + if label_looks_like_size_or_time(&lower) { + return false; + } + if lower.contains("http://") || lower.contains("https://") || lower.contains("www.") { + return false; + } + + // Allow simple punctuation, but reject labels that look like sentences. + let mut punct = 0usize; + for ch in t.chars() { + if ch.is_ascii_alphanumeric() || ch == ' ' || ch == '_' || ch == '-' { + continue; + } + if ch == '.' { + // Non-filename: allow a single dot (e.g. "v1.0"), but count it. + punct += 1; + continue; + } + punct += 1; + } + punct <= 2 +} + +fn label_looks_like_size_or_time(lower_trimmed: &str) -> bool { + let t = lower_trimmed.trim(); + if t.is_empty() { + return false; + } + + let looks_like_time = { + let bytes = t.as_bytes(); + bytes.len() == 5 + && bytes[0].is_ascii_digit() + && bytes[1].is_ascii_digit() + && bytes[2] == b':' + && bytes[3].is_ascii_digit() + && bytes[4].is_ascii_digit() + }; + if looks_like_time { + return true; + } + + let has_digit = t.chars().any(|c| c.is_ascii_digit()); + let size_suffix = t.ends_with(" kb") + || t.ends_with(" mb") + || t.ends_with(" gb") + || t.ends_with(" tb") + || t.ends_with(" bytes") + || t.ends_with(" byte") + || t.ends_with(" b"); + if has_digit && size_suffix { + return true; + } + + false +} + +fn ax_copy_selected_label(element: accessibility_sys::AXUIElementRef) -> Option { + // Try common AX selection attributes across AppKit and custom UIs. + // These are best-effort; we keep it small and bounded. + const ATTRS: [&str; 4] = [ + "AXSelectedRows", + "AXSelectedChildren", + "AXSelectedItems", + "AXSelectedCells", + ]; + + let mut fallback: Option = None; + + for attr in ATTRS { + let items = ax_copy_element_array_attr(element, attr); + if items.is_empty() { + continue; + } + for item in items { + let el: accessibility_sys::AXUIElementRef = item.as_type(); + // Prefer a direct label. + if let Some(label) = ax_best_label_for_element(el) { + let trimmed = label.trim(); + if selected_label_is_probably_ui_target(trimmed) { + return Some(trimmed.to_string()); + } + if fallback.is_none() && !trimmed.is_empty() { + fallback = Some(trimmed.to_string()); + } + } + // Otherwise, try to find a nearby static text label in the subtree. + let mut nodes_left = 64usize; + if let Some(label) = ax_find_label_in_tree(el, 2, &mut nodes_left) { + let trimmed = label.trim(); + if selected_label_is_probably_ui_target(trimmed) { + return Some(trimmed.to_string()); + } + if fallback.is_none() && !trimmed.is_empty() { + fallback = Some(trimmed.to_string()); + } + } + } + } + fallback +} + /// Get role + label of the UI element at the given screen position using Accessibility API. pub(super) fn get_clicked_element_label(x: f32, y: f32) -> Option { use accessibility_sys::{ - AXUIElementCopyElementAtPosition, AXUIElementCreateSystemWide, kAXDescriptionAttribute, - kAXCancelButtonAttribute, kAXDefaultButtonAttribute, - kAXTopLevelUIElementAttribute, - kAXRoleAttribute, kAXSubroleAttribute, kAXTitleAttribute, kAXValueAttribute, - kAXValueDescriptionAttribute, - kAXWindowAttribute, + kAXCancelButtonAttribute, kAXDefaultButtonAttribute, kAXParentAttribute, kAXRoleAttribute, + kAXSubroleAttribute, kAXTopLevelUIElementAttribute, kAXWindowAttribute, + AXUIElementCopyElementAtPosition, AXUIElementCreateSystemWide, }; unsafe { @@ -276,14 +946,168 @@ pub(super) fn get_clicked_element_label(x: f32, y: f32) -> Option bool { + let r = role.to_lowercase(); + let s = subrole.to_lowercase(); + r.contains("textfield") + || r.contains("text field") + || r.contains("textarea") + || s.contains("searchfield") + } + + let root = CfRef::wrap(raw_element as *mut _)?; + let mut chain: Vec = Vec::with_capacity(8); + chain.push(root); + + let mut current_raw: accessibility_sys::AXUIElementRef = chain[0].as_type(); + for _ in 0..7 { + let Some(parent) = ax_copy_element_attr(current_raw, kAXParentAttribute) else { + break; + }; + current_raw = parent.as_type(); + chain.push(parent); + } + + // Find the best list/table/outline container in the parent chain. + // Prefer containers that actually contain the click point and are closest + // to the clicked element in the chain. + let mut list_container: Option = None; + let mut list_container_score = i32::MIN; + for (idx, guard) in chain.iter().enumerate() { + let cand: accessibility_sys::AXUIElementRef = guard.as_type(); + let Some(role) = ax_copy_string_attr(cand, kAXRoleAttribute) else { + continue; + }; + if !is_listish_role(&role) { + continue; + } + + let mut score = 0i32; + let depth_bonus = (90i32 - (idx as i32 * 8)).max(0); + score += depth_bonus; + + if let Some(bounds) = ax_copy_window_bounds(cand) { + let inside = (x as i32) >= bounds.x + && (x as i32) < bounds.x + bounds.width as i32 + && (y as i32) >= bounds.y + && (y as i32) < bounds.y + bounds.height as i32; + if inside { + score += 300; + // Prefer the tighter matching container when multiple contain the click. + let area = (bounds.width as i64).saturating_mul(bounds.height as i64); + let area_penalty = (area / 20_000) as i32; + score = score.saturating_sub(area_penalty.min(120)); + } + } + + if score > list_container_score { + list_container_score = score; + list_container = Some(cand); + } + } + + let mut best_idx: usize = 0; + let mut best_score: i32 = i32::MIN; + let mut best_label: String = String::new(); + + for (idx, guard) in chain.iter().enumerate() { + let cand: accessibility_sys::AXUIElementRef = guard.as_type(); + let Some(role) = ax_copy_string_attr(cand, kAXRoleAttribute) else { + continue; + }; + let sub = ax_copy_string_attr(cand, kAXSubroleAttribute).unwrap_or_default(); + let actions = ax_copy_action_names(cand); + let has_press = ax_actions_has_press(&actions); + + // Base label, role-aware. + let mut label = ax_best_label_for_element(cand); + + let r_lower = role.to_lowercase(); + if label.is_none() && has_press { + let mut nodes_left = 64usize; + label = ax_find_static_text_label_in_tree(cand, 2, &mut nodes_left); + } + if label.is_none() + && (r_lower.contains("row") + || r_lower.contains("cell") + || r_lower.contains("group")) + { + let mut nodes_left = 64usize; + label = ax_find_label_in_tree(cand, 2, &mut nodes_left); + } + if label.is_none() && r_lower.contains("button") { + let mut nodes_left = 64usize; + label = ax_find_static_text_label_in_tree(cand, 2, &mut nodes_left); + } + if label.is_none() && is_text_field_role(&role, &sub) { + label = ax_copy_title_ui_label(cand).or_else(|| ax_copy_placeholder_value(cand)); + } + if label.is_none() { + label = ax_copy_title_ui_label(cand); + } + let label = label.unwrap_or_default(); + let score = ax_candidate_score(&role, &sub, &actions, &label, idx); + + if score > best_score { + best_score = score; + best_idx = idx; + best_label = label; + } + } + + let el: accessibility_sys::AXUIElementRef = chain[best_idx].as_type(); let role = ax_copy_string_attr(el, kAXRoleAttribute); - let label = ax_copy_string_attr(el, kAXTitleAttribute) - .or_else(|| ax_copy_string_attr(el, kAXValueDescriptionAttribute)) - .or_else(|| ax_copy_string_attr(el, kAXValueAttribute)) - .or_else(|| ax_copy_string_attr(el, kAXDescriptionAttribute)); + let subrole = ax_copy_string_attr(el, kAXSubroleAttribute); + let role_description = ax_copy_string_attr(el, "AXRoleDescription"); + let identifier = ax_copy_string_attr(el, "AXIdentifier"); + let label = { + let mut l = best_label; + let best_role_is_listish = role.as_ref().map(|r| is_listish_role(r)).unwrap_or(false); + // Improve list clicks by using the selected row/item label when the hit-test label is + // structural ("List view") or metadata-heavy (timestamps, message status, etc.). + // + // We intentionally key this on *presence of a list container in the chain* (not just + // the best element role), because many apps model list rows as pressable groups/buttons. + if let Some(container) = list_container { + let should_try_selected = label_is_suspicious_for_list(&l) || best_role_is_listish; + if should_try_selected { + let mut selected = ax_copy_selected_label(container); + + // Finder/AppKit list selection can lag by a few ms on first click. + // Retry once when selection still equals the old container-like label. + if let Some(ref current_selected) = selected { + if best_role_is_listish + && current_selected.trim().eq_ignore_ascii_case(l.trim()) + { + std::thread::sleep(std::time::Duration::from_millis(14)); + if let Some(retried) = ax_copy_selected_label(container) { + if !retried.trim().eq_ignore_ascii_case(current_selected.trim()) { + selected = Some(retried); + } + } + } + } + + if let Some(selected) = selected { + let selected_trim = selected.trim(); + if selected_trim.len() <= 64 + && selected_label_is_probably_ui_target(selected_trim) + && !selected_trim.eq_ignore_ascii_case(l.trim()) + { + l = selected_trim.to_string(); + } + } + } + } + l + }; + let element_bounds = ax_copy_window_bounds(el); + + let (container_role, container_subrole, container_identifier) = + ax_find_container_parent(el); let (window_role, window_subrole, window_bounds, is_cancel_button, is_default_button) = ax_copy_element_attr(el, kAXWindowAttribute) @@ -300,42 +1124,52 @@ pub(super) fn get_clicked_element_label(x: f32, y: f32) -> Option Some(AxElementLabel { - role, - label, - window_role, - window_subrole, - window_bounds, - top_level_role, - top_level_subrole, - top_level_bounds, - parent_dialog_role, - parent_dialog_subrole, - parent_dialog_bounds, - is_cancel_button: is_cancel_button || top_level_cancel, - is_default_button: is_default_button || top_level_default, - }), - _ => None, - } + let is_checked = ax_copy_bool_attr(el, "AXValue"); + + // Return best-effort metadata even when the label is missing. + role.map(|role| AxElementLabel { + role, + subrole, + role_description, + identifier, + label, + element_bounds, + container_role, + container_subrole, + container_identifier, + window_role, + window_subrole, + window_bounds, + top_level_role, + top_level_subrole, + top_level_bounds, + parent_dialog_role, + parent_dialog_subrole, + parent_dialog_bounds, + is_checked, + is_cancel_button: is_cancel_button || top_level_cancel, + is_default_button: is_default_button || top_level_default, + }) } } @@ -362,7 +1196,11 @@ pub(super) fn get_friendly_app_name(proc_path: &str) -> String { return app_part.trim_end_matches(".app").to_string(); } // Fallback: just use the last component - proc_path.split('/').next_back().unwrap_or(proc_path).to_string() + proc_path + .split('/') + .next_back() + .unwrap_or(proc_path) + .to_string() } /// Check if a process name belongs to a system authentication agent (Touch ID, password dialogs) @@ -409,17 +1247,14 @@ mod tests { #[test] fn friendly_name_from_app_bundle_path() { - let name = get_friendly_app_name( - "/System/Library/CoreServices/Dock.app/Contents/MacOS/Dock", - ); + let name = + get_friendly_app_name("/System/Library/CoreServices/Dock.app/Contents/MacOS/Dock"); assert_eq!(name, "Dock"); } #[test] fn friendly_name_from_applications_path() { - let name = get_friendly_app_name( - "/Applications/Safari.app/Contents/MacOS/Safari", - ); + let name = get_friendly_app_name("/Applications/Safari.app/Contents/MacOS/Safari"); assert_eq!(name, "Safari"); } @@ -522,4 +1357,41 @@ mod tests { assert!(!is_system_ui_process("Terminal")); assert!(!is_system_ui_process("Finder")); } + + // --- ax_candidate_score --- + + #[test] + fn pressable_group_scores_higher_than_static_text() { + let static_text = ax_candidate_score("AXStaticText", "", &[], "RustDesk", 0); + let pressable_group = ax_candidate_score("AXGroup", "", &[String::from("AXPress")], "", 1); + assert!(pressable_group > static_text); + } + + #[test] + fn close_button_scores_higher_than_pressable_group() { + let close_btn = ax_candidate_score( + "AXButton", + "AXCloseButton", + &[String::from("AXPress")], + "", + 0, + ); + let pressable_group = ax_candidate_score("AXGroup", "", &[String::from("AXPress")], "", 0); + assert!(close_btn > pressable_group); + } + + #[test] + fn suspicious_list_labels_detect_sidebar_and_sizes() { + assert!(label_is_suspicious_for_list("Seitenleiste")); + assert!(label_is_suspicious_for_list("sidebar")); + assert!(label_is_suspicious_for_list("2,4 MB")); + assert!(label_is_suspicious_for_list("12:34")); + } + + #[test] + fn selected_label_rejects_size_values_but_accepts_filenames() { + assert!(!selected_label_is_probably_ui_target("2,4 MB")); + assert!(!selected_label_is_probably_ui_target("12:34")); + assert!(selected_label_is_probably_ui_target("test.pdf")); + } } diff --git a/src-tauri/src/recorder/cg_capture.rs b/src-tauri/src/recorder/cg_capture.rs index 7744c96..e7c1041 100644 --- a/src-tauri/src/recorder/cg_capture.rs +++ b/src-tauri/src/recorder/cg_capture.rs @@ -44,14 +44,18 @@ pub fn capture_region_fast( let bytes_per_row = image_ref.bytes_per_row() as usize; let bytes_per_pixel = (image_ref.bits_per_pixel() / 8) as usize; if bytes_per_pixel < 4 { - return Err(CaptureError::CgImage("unsupported pixel format".to_string())); + return Err(CaptureError::CgImage( + "unsupported pixel format".to_string(), + )); } let data = image_ref.data(); let bytes = data.bytes(); let needed = bytes_per_row.saturating_mul(h); if bytes.len() < needed { - return Err(CaptureError::CgImage("CGImage buffer too small".to_string())); + return Err(CaptureError::CgImage( + "CGImage buffer too small".to_string(), + )); } // CGWindowListCreateImage typically returns BGRA (premultiplied). Convert to RGBA. @@ -86,10 +90,7 @@ pub fn capture_region_fast( /// Capture a specific window by its CGWindow ID using CoreGraphics. /// This captures the window content even if it's partially obscured or closing, /// avoiding race conditions where the window disappears before a region capture. -pub fn capture_window_cg( - window_id: u32, - output_path: &Path, -) -> Result<(), CaptureError> { +pub fn capture_window_cg(window_id: u32, output_path: &Path) -> Result<(), CaptureError> { use core_graphics::geometry::{CGPoint, CGRect, CGSize}; use core_graphics::window::{ create_image, kCGWindowImageBestResolution, kCGWindowImageBoundsIgnoreFraming, @@ -111,26 +112,34 @@ pub fn capture_window_cg( window_id, kCGWindowImageBestResolution | kCGWindowImageBoundsIgnoreFraming, ) - .ok_or_else(|| CaptureError::CgImage("CGWindowListCreateImage returned null for window ID".to_string()))?; + .ok_or_else(|| { + CaptureError::CgImage("CGWindowListCreateImage returned null for window ID".to_string()) + })?; let image_ref = image.as_ref(); let w = image_ref.width() as usize; let h = image_ref.height() as usize; if w == 0 || h == 0 { - return Err(CaptureError::CgImage("empty CGImage for window capture".to_string())); + return Err(CaptureError::CgImage( + "empty CGImage for window capture".to_string(), + )); } let bytes_per_row = image_ref.bytes_per_row() as usize; let bytes_per_pixel = (image_ref.bits_per_pixel() / 8) as usize; if bytes_per_pixel < 4 { - return Err(CaptureError::CgImage("unsupported pixel format".to_string())); + return Err(CaptureError::CgImage( + "unsupported pixel format".to_string(), + )); } let data = image_ref.data(); let bytes = data.bytes(); let needed = bytes_per_row.saturating_mul(h); if bytes.len() < needed { - return Err(CaptureError::CgImage("CGImage buffer too small".to_string())); + return Err(CaptureError::CgImage( + "CGImage buffer too small".to_string(), + )); } let mut out = vec![0u8; w * h * 4]; diff --git a/src-tauri/src/recorder/click_listener.rs b/src-tauri/src/recorder/click_listener.rs index 9998228..2217fde 100644 --- a/src-tauri/src/recorder/click_listener.rs +++ b/src-tauri/src/recorder/click_listener.rs @@ -11,8 +11,7 @@ use std::time::Duration; use core_foundation::runloop::{kCFRunLoopCommonModes, kCFRunLoopDefaultMode, CFRunLoop}; use core_graphics::event::{ - CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, CGEventType, - EventField, + CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, CGEventType, EventField, }; use super::click_event::{ClickEvent, MouseButton}; @@ -85,7 +84,8 @@ impl ClickListener { }; // Get click count (1 = single, 2 = double, 3 = triple) - let click_count = event.get_integer_value_field(EventField::MOUSE_EVENT_CLICK_STATE); + let click_count = + event.get_integer_value_field(EventField::MOUSE_EVENT_CLICK_STATE); let click_event = ClickEvent::new(location.x as i32, location.y as i32, button, click_count); @@ -102,7 +102,7 @@ impl ClickListener { Ok(tap) => tap, Err(()) => { let _ = setup_tx.send(Err( - "Failed to create event tap. Check accessibility permissions.".to_string() + "Failed to create event tap. Check accessibility permissions.".to_string(), )); return; } @@ -226,7 +226,11 @@ mod tests { Err(e) => { // This is expected if accessibility permissions are not granted println!("Click listener could not start (expected without permissions): {e}"); - assert!(e.contains("accessibility") || e.contains("permission") || e.contains("event tap")); + assert!( + e.contains("accessibility") + || e.contains("permission") + || e.contains("event tap") + ); } } } diff --git a/src-tauri/src/recorder/mod.rs b/src-tauri/src/recorder/mod.rs index ee6e0b3..a801eb2 100644 --- a/src-tauri/src/recorder/mod.rs +++ b/src-tauri/src/recorder/mod.rs @@ -5,6 +5,7 @@ pub mod click_event; pub mod click_listener; pub mod macos_screencapture; pub mod pipeline; +pub mod pre_click_buffer; pub mod session; pub mod state; pub mod storage; diff --git a/src-tauri/src/recorder/pipeline/helpers.rs b/src-tauri/src/recorder/pipeline/helpers.rs index b5735be..adeb263 100644 --- a/src-tauri/src/recorder/pipeline/helpers.rs +++ b/src-tauri/src/recorder/pipeline/helpers.rs @@ -1,19 +1,118 @@ //! Pipeline helper functions: capture, filtering, debouncing, context menu detection. +use super::super::ax_helpers::{get_clicked_element_info, is_security_agent_process}; use super::super::capture::CaptureError; use super::super::cg_capture::{capture_region_cg, capture_region_fast}; use super::super::click_event::ClickEvent; use super::super::session::Session; -use super::super::types::{ActionType, Step}; +use super::super::types::{ActionType, BoundsPercent, Step}; use super::super::window_info::find_auth_dialog_window; -use super::super::ax_helpers::{ - get_clicked_element_info, is_security_agent_process, -}; +use super::super::window_info::WindowBounds; use super::types::*; use std::path::Path; use std::sync::Mutex; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TitlelessOverlayKind { + NotOverlay, + DropdownMenu, + Popup, +} + +pub fn is_ax_menu_role(role: &str) -> bool { + role == accessibility_sys::kAXMenuItemRole + || role == accessibility_sys::kAXMenuBarItemRole + || role == accessibility_sys::kAXMenuButtonRole + || role == accessibility_sys::kAXMenuRole +} + +pub fn should_use_menu_region_capture( + overlay_kind: TitlelessOverlayKind, + ax_role: Option<&str>, + click_y_in_display: i32, + recent_menu_open: bool, +) -> bool { + const MENUBAR_HEIGHT: i32 = 30; + const TOP_MENU_INTERACTION_HEIGHT: i32 = 180; + const MENU_FOLLOWUP_HEIGHT: i32 = 320; + let is_menu_bar_click = (0..MENUBAR_HEIGHT).contains(&click_y_in_display); + let is_dropdown_menu = overlay_kind == TitlelessOverlayKind::DropdownMenu; + let is_top_menu_interaction = ax_role.is_some_and(is_ax_menu_role) + && (0..TOP_MENU_INTERACTION_HEIGHT).contains(&click_y_in_display); + let is_recent_menu_followup = + recent_menu_open && (0..MENU_FOLLOWUP_HEIGHT).contains(&click_y_in_display); + is_menu_bar_click || is_dropdown_menu || is_top_menu_interaction || is_recent_menu_followup +} + +/// Prefer region capture for volatile interactions that commonly close/hide +/// overlays during the click handling path (menu rows, picker rows, etc.). +/// +/// We keep this role-based and app-agnostic: +/// - `AXMenuItem` / `AXMenu` rows are transient by nature. +/// - `AXGroup` is often used by web/native pickers for clickable rows. +/// +/// Right-click keeps the existing context-menu path. +pub fn should_prefer_transient_region_capture( + ax_role: Option<&str>, + overlay_kind: TitlelessOverlayKind, + is_right_click: bool, +) -> bool { + if is_right_click { + return false; + } + + let Some(role) = ax_role else { + return false; + }; + + // Menu-bar/dropdown interactions are already handled by menu-region rules. + if overlay_kind == TitlelessOverlayKind::DropdownMenu { + return false; + } + + role == accessibility_sys::kAXMenuItemRole + || role == accessibility_sys::kAXMenuRole + || (role == accessibility_sys::kAXGroupRole && overlay_kind == TitlelessOverlayKind::Popup) +} + +/// Classify titleless overlay windows (menus vs popovers) for capture decisions. +/// +/// We only treat a titleless window as a dropdown menu when it is near the menu bar; +/// otherwise it's more likely an in-app popover (sticker picker, emoji panel, etc.) +/// and should be captured by window bounds to avoid cropping. +pub fn classify_titleless_overlay_window( + window_title: &str, + window_id: u32, + main_window_id: u32, + bounds: &WindowBounds, + display_top_y: i32, +) -> TitlelessOverlayKind { + const MENUBAR_REGION_HEIGHT: i32 = 60; + + let is_titleless = window_title.trim().is_empty(); + if !is_titleless { + return TitlelessOverlayKind::NotOverlay; + } + if window_id == 0 || window_id == main_window_id { + return TitlelessOverlayKind::NotOverlay; + } + // Popovers/menus are typically narrow; avoid matching full-size overlays. + if bounds.width >= 800 { + return TitlelessOverlayKind::NotOverlay; + } + + // IMPORTANT: use display-relative Y, not global Y. + // On secondary displays, global Y may be far from 0 even when the window is near that + // display's menu bar. + let y_in_display = bounds.y - display_top_y; + if (0..=MENUBAR_REGION_HEIGHT).contains(&y_in_display) { + TitlelessOverlayKind::DropdownMenu + } else { + TitlelessOverlayKind::Popup + } +} + pub fn debug_log(session: &Session, msg: &str) { if !cfg!(debug_assertions) { return; @@ -21,14 +120,14 @@ pub fn debug_log(session: &Session, msg: &str) { let log_path = session.temp_dir.join("recording.log"); let is_new = !log_path.exists(); - if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(log_path) { + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log_path) + { use std::io::Write; if is_new { - let _ = writeln!( - file, - "session_dir={}", - session.temp_dir.to_string_lossy() - ); + let _ = writeln!(file, "session_dir={}", session.temp_dir.to_string_lossy()); } let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -45,9 +144,8 @@ pub fn write_auth_placeholder(path: &Path, width: u32, height: u32) -> Result<() let h = height.max(80); let bytes = include_bytes!("../assets/coreauth.png"); - let img = image::load_from_memory(bytes).map_err(|e| { - CaptureError::CgImage(format!("placeholder decode failed: {e}")) - })?; + let img = image::load_from_memory(bytes) + .map_err(|e| CaptureError::CgImage(format!("placeholder decode failed: {e}")))?; let resized = img.resize(w, h, imageops::FilterType::Lanczos3).to_rgba8(); @@ -80,18 +178,14 @@ pub fn capture_region_best( Ok(()) => { debug_log( session, - &format!( - "fast_region_capture ok: x={x} y={y} w={width} h={height}", - ), + &format!("fast_region_capture ok: x={x} y={y} w={width} h={height}",), ); Ok(()) } Err(err) => { debug_log( session, - &format!( - "fast_region_capture failed: {err} (x={x} y={y} w={width} h={height})", - ), + &format!("fast_region_capture failed: {err} (x={x} y={y} w={width} h={height})",), ); capture_region_cg(x, y, width, height, output_path) } @@ -108,7 +202,11 @@ pub fn validate_screenshot(path: &Path) -> bool { pub fn should_emit_auth_prompt(ps: &mut PipelineState, window_id: u32, timestamp_ms: i64) -> bool { match ps.last_auth_prompt { - Some((prev_id, prev_ts)) if prev_id == window_id && timestamp_ms - prev_ts < AUTH_PROMPT_DEDUP_MS => false, + Some((prev_id, prev_ts)) + if prev_id == window_id && timestamp_ms - prev_ts < AUTH_PROMPT_DEDUP_MS => + { + false + } _ => { ps.last_auth_prompt = Some((window_id, timestamp_ms)); true @@ -137,7 +235,10 @@ pub fn handle_auth_prompt( click: &ClickEvent, session: &mut Session, pipeline_state: &Mutex, -)-> (Option, bool) { +) -> (Option, bool) { + const AUTH_PLACEHOLDER_DESCRIPTION: &str = + "Authenticate with Touch ID or enter your password to continue."; + let clicked_info = get_clicked_element_info(click.x, click.y); let auth_window = match find_security_auth_window(click.x, click.y, clicked_info.is_none()) { Some(window) => window, @@ -194,8 +295,14 @@ pub fn handle_auth_prompt( window_title: "Authentication dialog (secure)".to_string(), screenshot_path: Some(screenshot_path.to_string_lossy().to_string()), note: None, + description: Some(AUTH_PLACEHOLDER_DESCRIPTION.to_string()), + description_source: None, + description_status: None, + description_error: None, + ax: None, capture_status: None, capture_error: None, + crop_region: None, }; debug_log( @@ -253,6 +360,7 @@ pub fn should_filter_panel_click(ps: &PipelineState, click: &ClickEvent) -> bool } /// Get main screen dimensions in logical points (not pixels) +#[allow(dead_code)] pub fn get_main_screen_size() -> (i32, i32) { use core_graphics::display::CGDisplay; let main = CGDisplay::main(); @@ -292,7 +400,11 @@ pub fn get_display_bounds_for_click(click_x: i32, click_y: i32) -> (i32, i32, i3 /// Find a context menu window near the click position. /// Context menus are typically: empty title, small layer, appear near the click. -pub fn find_context_menu_near_click(click_x: i32, click_y: i32, app_name: &str) -> Option { +pub fn find_context_menu_near_click( + click_x: i32, + click_y: i32, + app_name: &str, +) -> Option { use core_foundation::base::{CFType, TCFType}; use core_foundation::dictionary::CFDictionaryRef; use core_foundation::number::CFNumber; @@ -313,18 +425,24 @@ pub fn find_context_menu_near_click(click_x: i32, click_y: i32, app_name: &str) let windows: Vec = unsafe { let count = core_foundation::array::CFArrayGetCount(window_list as _); (0..count) - .map(|i| core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) as CFDictionaryRef) + .map(|i| { + core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) + as CFDictionaryRef + }) .collect() }; for window_dict in windows { let dict = unsafe { - core_foundation::dictionary::CFDictionary::::wrap_under_get_rule(window_dict) + core_foundation::dictionary::CFDictionary::::wrap_under_get_rule( + window_dict, + ) }; // Get window title - context menus typically have empty titles let title_key = CFString::new("kCGWindowName"); - let window_title = dict.find(&title_key) + let window_title = dict + .find(&title_key) .map(|v| { let s: CFString = unsafe { CFString::wrap_under_get_rule(v.as_CFTypeRef() as _) }; s.to_string() @@ -337,7 +455,8 @@ pub fn find_context_menu_near_click(click_x: i32, click_y: i32, app_name: &str) // Get owner name let owner_name_key = CFString::new("kCGWindowOwnerName"); - let owner_name = dict.find(&owner_name_key) + let owner_name = dict + .find(&owner_name_key) .map(|v| { let s: CFString = unsafe { CFString::wrap_under_get_rule(v.as_CFTypeRef() as _) }; s.to_string() @@ -358,15 +477,35 @@ pub fn find_context_menu_near_click(click_x: i32, click_y: i32, app_name: &str) let bounds_key = CFString::new("kCGWindowBounds"); let bounds = match dict.find(&bounds_key) { Some(v) => { - let bounds_dict: core_foundation::dictionary::CFDictionary = - unsafe { core_foundation::dictionary::CFDictionary::wrap_under_get_rule(v.as_CFTypeRef() as _) }; - - let x = bounds_dict.find(CFString::new("X")).and_then(|n| n.to_i32()).unwrap_or(0); - let y = bounds_dict.find(CFString::new("Y")).and_then(|n| n.to_i32()).unwrap_or(0); - let width = bounds_dict.find(CFString::new("Width")).and_then(|n| n.to_i32()).unwrap_or(0) as u32; - let height = bounds_dict.find(CFString::new("Height")).and_then(|n| n.to_i32()).unwrap_or(0) as u32; - - super::super::window_info::WindowBounds { x, y, width, height } + let bounds_dict: core_foundation::dictionary::CFDictionary = unsafe { + core_foundation::dictionary::CFDictionary::wrap_under_get_rule( + v.as_CFTypeRef() as _ + ) + }; + + let x = bounds_dict + .find(CFString::new("X")) + .and_then(|n| n.to_i32()) + .unwrap_or(0); + let y = bounds_dict + .find(CFString::new("Y")) + .and_then(|n| n.to_i32()) + .unwrap_or(0); + let width = bounds_dict + .find(CFString::new("Width")) + .and_then(|n| n.to_i32()) + .unwrap_or(0) as u32; + let height = bounds_dict + .find(CFString::new("Height")) + .and_then(|n| n.to_i32()) + .unwrap_or(0) as u32; + + super::super::window_info::WindowBounds { + x, + y, + width, + height, + } } None => continue, }; @@ -401,7 +540,13 @@ pub fn find_context_menu_near_click(click_x: i32, click_y: i32, app_name: &str) /// Check if click should be debounced (too close in time/position to previous) /// Returns (should_debounce, should_upgrade_previous) - upgrade means replace last Click with DoubleClick -pub fn is_debounced(ps: &mut PipelineState, timestamp_ms: i64, x: i32, y: i32, click_count: i64) -> (bool, bool) { +pub fn is_debounced( + ps: &mut PipelineState, + timestamp_ms: i64, + x: i32, + y: i32, + click_count: i64, +) -> (bool, bool) { if let Some((last_ts, last_x, last_y, last_count)) = ps.last_click { let time_diff = timestamp_ms - last_ts; let same_position = (x - last_x).abs() < 5 && (y - last_y).abs() < 5; @@ -444,3 +589,359 @@ pub fn calculate_click_percent(click_coord: i32, window_offset: i32, window_size let percent = (relative as f64 / window_size as f64) * 100.0; percent.clamp(0.0, 100.0) } + +fn clamp_percent(v: f64) -> f64 { + v.clamp(0.0, 100.0) +} + +/// Compute a default focus crop for large display-like captures. +/// +/// This is intentionally conservative: +/// - only for large captures (typically full-screen snapshots), +/// - based on click location (and optional AX element bounds), +/// - leaves smaller/tighter captures untouched. +pub fn suggested_focus_crop_for_capture( + capture_bounds: &WindowBounds, + click_x_percent: f64, + click_y_percent: f64, + element_bounds_in_capture: Option<&BoundsPercent>, +) -> Option { + const LARGE_CAPTURE_MIN_W: u32 = 1400; + const LARGE_CAPTURE_MIN_H: u32 = 800; + if capture_bounds.width < LARGE_CAPTURE_MIN_W || capture_bounds.height < LARGE_CAPTURE_MIN_H { + return None; + } + + let mut center_x = clamp_percent(click_x_percent); + let mut center_y = clamp_percent(click_y_percent); + let mut crop_w = 46.0_f64; + let mut crop_h = 46.0_f64; + + if let Some(bounds) = element_bounds_in_capture { + let x = clamp_percent(bounds.x_percent as f64); + let y = clamp_percent(bounds.y_percent as f64); + let w = clamp_percent(bounds.width_percent as f64); + let h = clamp_percent(bounds.height_percent as f64); + if w > 0.0 && h > 0.0 { + center_x = x + (w / 2.0); + center_y = y + (h / 2.0); + crop_w = (w * 3.2).clamp(24.0, 70.0); + crop_h = (h * 4.0).clamp(22.0, 70.0); + } + } + + // Menus/toolbars are often near the top and need wider context. + if center_y < 18.0 { + crop_h = crop_h.max(36.0); + center_y = center_y.max(12.0); + } + + let mut x = center_x - crop_w / 2.0; + let mut y = center_y - crop_h / 2.0; + x = x.clamp(0.0, 100.0 - crop_w); + y = y.clamp(0.0, 100.0 - crop_h); + + let x = clamp_percent(x); + let y = clamp_percent(y); + let crop_w = crop_w.clamp(2.0, 100.0 - x); + let crop_h = crop_h.clamp(2.0, 100.0 - y); + if crop_w < 2.0 || crop_h < 2.0 { + return None; + } + + Some(BoundsPercent { + x_percent: x as f32, + y_percent: y as f32, + width_percent: crop_w as f32, + height_percent: crop_h as f32, + }) +} + +/// Decide whether an auto focus-crop should be applied for a capture. +/// +/// We keep this conservative: only for large captures where full-frame output +/// tends to make UI details too small in tutorial exports. +pub fn should_apply_focus_crop( + capture_bounds: &WindowBounds, + display_width: i32, + display_height: i32, +) -> bool { + const MIN_CAPTURE_W: u32 = 1100; + const MIN_CAPTURE_H: u32 = 620; + const MIN_AREA_RATIO: f64 = 0.55; + const MIN_WIDTH_RATIO: f64 = 0.75; + const MIN_HEIGHT_RATIO: f64 = 0.70; + + if capture_bounds.width < MIN_CAPTURE_W || capture_bounds.height < MIN_CAPTURE_H { + return false; + } + + let dw = display_width.max(1) as f64; + let dh = display_height.max(1) as f64; + let cw = capture_bounds.width as f64; + let ch = capture_bounds.height as f64; + + let area_ratio = (cw * ch) / (dw * dh); + let width_ratio = cw / dw; + let height_ratio = ch / dh; + + area_ratio >= MIN_AREA_RATIO + || (width_ratio >= MIN_WIDTH_RATIO && height_ratio >= MIN_HEIGHT_RATIO) +} + +pub fn bounds_percent_in_capture( + element: &WindowBounds, + capture: &WindowBounds, +) -> Option { + let cw = capture.width as f64; + let ch = capture.height as f64; + if cw <= 1.0 || ch <= 1.0 { + return None; + } + + let x_pct = (((element.x - capture.x) as f64 / cw) * 100.0).clamp(0.0, 100.0); + let y_pct = (((element.y - capture.y) as f64 / ch) * 100.0).clamp(0.0, 100.0); + let w_pct = ((element.width as f64 / cw) * 100.0).clamp(0.0, 100.0); + let h_pct = ((element.height as f64 / ch) * 100.0).clamp(0.0, 100.0); + + if w_pct <= 0.0 || h_pct <= 0.0 { + return None; + } + + Some(BoundsPercent { + x_percent: x_pct as f32, + y_percent: y_pct as f32, + width_percent: w_pct as f32, + height_percent: h_pct as f32, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bounds_percent_in_capture_basic() { + let capture = WindowBounds { + x: 100, + y: 200, + width: 1000, + height: 500, + }; + let element = WindowBounds { + x: 200, + y: 250, + width: 100, + height: 50, + }; + let pct = bounds_percent_in_capture(&element, &capture).expect("bounds percent"); + assert!((pct.x_percent - 10.0).abs() < 0.01); + assert!((pct.y_percent - 10.0).abs() < 0.01); + assert!((pct.width_percent - 10.0).abs() < 0.01); + assert!((pct.height_percent - 10.0).abs() < 0.01); + } + + #[test] + fn suggested_focus_crop_only_for_large_captures() { + let small = WindowBounds { + x: 0, + y: 0, + width: 1200, + height: 700, + }; + assert!(suggested_focus_crop_for_capture(&small, 50.0, 50.0, None).is_none()); + + let large = WindowBounds { + x: 0, + y: 0, + width: 2560, + height: 1080, + }; + let crop = suggested_focus_crop_for_capture(&large, 50.0, 50.0, None) + .expect("focus crop for large capture"); + assert!(crop.width_percent < 100.0); + assert!(crop.height_percent < 100.0); + } + + #[test] + fn should_apply_focus_crop_for_near_fullscreen_or_large_area() { + let display_w = 2560; + let display_h = 1080; + + let near_full = WindowBounds { + x: 0, + y: 30, + width: 2560, + height: 987, + }; + assert!(should_apply_focus_crop(&near_full, display_w, display_h)); + + let large_dialog_union = WindowBounds { + x: 600, + y: 80, + width: 1585, + height: 974, + }; + assert!(should_apply_focus_crop( + &large_dialog_union, + display_w, + display_h + )); + + let small = WindowBounds { + x: 900, + y: 220, + width: 900, + height: 560, + }; + assert!(!should_apply_focus_crop(&small, display_w, display_h)); + } + + #[test] + fn classify_titleless_overlay_window_dropdown_vs_popup() { + let bounds_top = WindowBounds { + x: 100, + y: 0, + width: 600, + height: 500, + }; + assert_eq!( + classify_titleless_overlay_window("", 2, 1, &bounds_top, 0), + TitlelessOverlayKind::DropdownMenu + ); + + let bounds_mid = WindowBounds { + x: 100, + y: 200, + width: 600, + height: 500, + }; + assert_eq!( + classify_titleless_overlay_window("", 2, 1, &bounds_mid, 0), + TitlelessOverlayKind::Popup + ); + + let bounds_wide = WindowBounds { + x: 100, + y: 0, + width: 1200, + height: 500, + }; + assert_eq!( + classify_titleless_overlay_window("", 2, 1, &bounds_wide, 0), + TitlelessOverlayKind::NotOverlay + ); + + let bounds_same_id = WindowBounds { + x: 100, + y: 0, + width: 600, + height: 500, + }; + assert_eq!( + classify_titleless_overlay_window("", 1, 1, &bounds_same_id, 0), + TitlelessOverlayKind::NotOverlay + ); + + let bounds_zero_id = WindowBounds { + x: 100, + y: 0, + width: 600, + height: 500, + }; + assert_eq!( + classify_titleless_overlay_window("", 0, 1, &bounds_zero_id, 0), + TitlelessOverlayKind::NotOverlay + ); + + // Secondary display offset: still near menu bar on that display. + let bounds_offset_display = WindowBounds { + x: 3300, + y: 460, + width: 432, + height: 422, + }; + assert_eq!( + classify_titleless_overlay_window("", 2, 1, &bounds_offset_display, 420), + TitlelessOverlayKind::DropdownMenu + ); + } + + #[test] + fn should_use_menu_region_capture_rules() { + assert!(should_use_menu_region_capture( + TitlelessOverlayKind::NotOverlay, + None, + 12, + false + )); + assert!(should_use_menu_region_capture( + TitlelessOverlayKind::DropdownMenu, + None, + 220, + false + )); + assert!(should_use_menu_region_capture( + TitlelessOverlayKind::NotOverlay, + Some(accessibility_sys::kAXMenuItemRole), + 120, + false + )); + assert!(!should_use_menu_region_capture( + TitlelessOverlayKind::NotOverlay, + Some(accessibility_sys::kAXMenuItemRole), + 720, + false + )); + assert!(!should_use_menu_region_capture( + TitlelessOverlayKind::NotOverlay, + Some(accessibility_sys::kAXButtonRole), + 120, + false + )); + assert!(should_use_menu_region_capture( + TitlelessOverlayKind::NotOverlay, + Some(accessibility_sys::kAXGroupRole), + 140, + true + )); + } + + #[test] + fn prefer_transient_region_capture_for_menu_and_group_roles() { + assert!(should_prefer_transient_region_capture( + Some(accessibility_sys::kAXMenuItemRole), + TitlelessOverlayKind::NotOverlay, + false + )); + assert!(should_prefer_transient_region_capture( + Some(accessibility_sys::kAXMenuRole), + TitlelessOverlayKind::Popup, + false + )); + assert!(should_prefer_transient_region_capture( + Some(accessibility_sys::kAXGroupRole), + TitlelessOverlayKind::Popup, + false + )); + assert!(!should_prefer_transient_region_capture( + Some(accessibility_sys::kAXGroupRole), + TitlelessOverlayKind::NotOverlay, + false + )); + } + + #[test] + fn no_transient_region_capture_for_right_click_or_dropdown_menu() { + assert!(!should_prefer_transient_region_capture( + Some(accessibility_sys::kAXMenuItemRole), + TitlelessOverlayKind::NotOverlay, + true + )); + assert!(!should_prefer_transient_region_capture( + Some(accessibility_sys::kAXGroupRole), + TitlelessOverlayKind::DropdownMenu, + false + )); + } +} diff --git a/src-tauri/src/recorder/pipeline/mod.rs b/src-tauri/src/recorder/pipeline/mod.rs index 6d30d47..f9f9e97 100644 --- a/src-tauri/src/recorder/pipeline/mod.rs +++ b/src-tauri/src/recorder/pipeline/mod.rs @@ -6,27 +6,24 @@ //! - Captures a screenshot of that window //! - Creates a Step with the click position as percentages within the window -mod types; mod helpers; +mod types; +pub use helpers::{handle_auth_prompt, record_panel_bounds, record_tray_click, set_panel_visible}; pub use types::*; -pub use helpers::{ - handle_auth_prompt, record_panel_bounds, record_tray_click, set_panel_visible, -}; -use helpers::*; -use super::click_event::ClickEvent; use super::cg_capture::capture_window_cg; +use super::click_event::ClickEvent; use super::macos_screencapture::capture_window as capture_window_by_id; +use super::pre_click_buffer::PreClickFrameBuffer; use super::session::Session; -use super::types::{ActionType, CaptureStatus, Step}; +use super::types::{ActionType, AxClickInfo, CaptureStatus, Step}; use super::window_info::{ - find_attached_dialog_window, - get_frontmost_window, - get_main_window_for_pid, - get_security_agent_window, - get_topmost_window_at_point, + find_attached_dialog_window, get_frontmost_window, get_main_window_for_pid, + get_security_agent_window, get_topmost_window_at_point, get_window_for_pid_at_click, + WindowBounds, }; +use helpers::*; use super::ax_helpers::{ get_clicked_element_info, get_clicked_element_label, is_security_agent_process, @@ -35,6 +32,158 @@ use super::ax_helpers::{ use std::sync::Mutex; +fn normalize_app_name(name: &str) -> String { + name.chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(|c| c.to_lowercase()) + .collect() +} + +fn app_names_match(left: &str, right: &str) -> bool { + let left_norm = normalize_app_name(left); + let right_norm = normalize_app_name(right); + !left_norm.is_empty() && left_norm == right_norm +} + +fn bounds_overlap_ratio(a: &WindowBounds, b: &WindowBounds) -> f32 { + let a_left = a.x; + let a_top = a.y; + let a_right = a.x + a.width as i32; + let a_bottom = a.y + a.height as i32; + + let b_left = b.x; + let b_top = b.y; + let b_right = b.x + b.width as i32; + let b_bottom = b.y + b.height as i32; + + let inter_left = a_left.max(b_left); + let inter_top = a_top.max(b_top); + let inter_right = a_right.min(b_right); + let inter_bottom = a_bottom.min(b_bottom); + let inter_w = (inter_right - inter_left).max(0) as i64; + let inter_h = (inter_bottom - inter_top).max(0) as i64; + let inter_area = inter_w * inter_h; + let a_area = (a.width as i64) * (a.height as i64); + if a_area <= 0 { + return 0.0; + } + inter_area as f32 / a_area as f32 +} + +fn bounds_contained_with_margin(inner: &WindowBounds, outer: &WindowBounds, margin: i32) -> bool { + let inner_right = inner.x + inner.width as i32; + let inner_bottom = inner.y + inner.height as i32; + let outer_right = outer.x + outer.width as i32; + let outer_bottom = outer.y + outer.height as i32; + inner.x >= outer.x - margin + && inner.y >= outer.y - margin + && inner_right <= outer_right + margin + && inner_bottom <= outer_bottom + margin +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WindowControlKind { + Close, + Minimize, + Zoom, +} + +impl WindowControlKind { + fn label(self) -> &'static str { + match self { + Self::Close => "Close", + Self::Minimize => "Minimize", + Self::Zoom => "Zoom", + } + } + + fn subrole(self) -> &'static str { + match self { + Self::Close => "AXCloseButton", + Self::Minimize => "AXMinimizeButton", + Self::Zoom => "AXZoomButton", + } + } + + fn role_description(self) -> &'static str { + match self { + Self::Close => "Close button", + Self::Minimize => "Minimize button", + Self::Zoom => "Zoom button", + } + } +} + +fn infer_window_control_kind( + click_x: i32, + click_y: i32, + bounds: &WindowBounds, + ax_role: Option<&str>, + ax_subrole: Option<&str>, + ax_role_description: Option<&str>, +) -> Option { + let role = ax_role.unwrap_or(""); + let role_lower = role.to_lowercase(); + if role == accessibility_sys::kAXMenuBarItemRole + || role == accessibility_sys::kAXMenuItemRole + || role == accessibility_sys::kAXMenuRole + || role == accessibility_sys::kAXMenuButtonRole + || role_lower.contains("menubar") + || role_lower.contains("menuitem") + { + return None; + } + + let sub = ax_subrole.unwrap_or("").to_lowercase(); + if sub.contains("close") { + return Some(WindowControlKind::Close); + } + if sub.contains("minimize") { + return Some(WindowControlKind::Minimize); + } + if sub.contains("zoom") { + return Some(WindowControlKind::Zoom); + } + + let desc = ax_role_description.unwrap_or("").to_lowercase(); + if desc.contains("schlie") || desc.contains("close") { + return Some(WindowControlKind::Close); + } + if desc.contains("minim") { + return Some(WindowControlKind::Minimize); + } + if desc.contains("zoom") { + return Some(WindowControlKind::Zoom); + } + + // Geometry fallback for cases where hit-testing returns root/application instead + // of the traffic-light button (common during close animations). + let role_allows_geometry = role == accessibility_sys::kAXButtonRole + || role == accessibility_sys::kAXPopUpButtonRole + || role_lower.contains("application") + || role_lower.contains("window") + || role_lower.contains("group") + || role.is_empty(); + + if !role_allows_geometry { + return None; + } + + let rel_x = click_x - bounds.x; + let rel_y = click_y - bounds.y; + if !(0..=92).contains(&rel_x) || !(0..=42).contains(&rel_y) { + return None; + } + + if rel_x <= 30 { + Some(WindowControlKind::Close) + } else if rel_x <= 60 { + Some(WindowControlKind::Minimize) + } else { + Some(WindowControlKind::Zoom) + } +} + /// Process a click event and create a step with screenshot. /// /// This function orchestrates the full capture pipeline: @@ -51,7 +200,12 @@ use std::sync::Mutex; /// # Returns /// /// Returns the created Step on success, or a PipelineError if any step fails. -pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: &Mutex) -> Result { +pub fn process_click( + click: &ClickEvent, + session: &mut Session, + pipeline_state: &Mutex, + pre_click_buffer: Option<&PreClickFrameBuffer>, +) -> Result { debug_log( session, &format!( @@ -81,10 +235,14 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: let clicked_info = get_clicked_element_info(click.x, click.y); let clicked_ax = get_clicked_element_label(click.x as f32, click.y as f32); if let Some(ax) = clicked_ax.as_ref() { + if ax.role == accessibility_sys::kAXMenuBarItemRole { + let mut ps = pipeline_state.lock().unwrap_or_else(|e| e.into_inner()); + ps.last_menu_bar_click_ms = Some(click.timestamp_ms); + } debug_log( session, &format!( - "ax_click: role={} label='{}' win_role={:?} win_subrole={:?} top_role={:?} top_subrole={:?} parent_role={:?} parent_subrole={:?} cancel={} default={}", + "ax_click: role={} label='{}' win_role={:?} win_subrole={:?} top_role={:?} top_subrole={:?} parent_role={:?} parent_subrole={:?} checked={:?} cancel={} default={}", ax.role, ax.label, ax.window_role, @@ -93,12 +251,34 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: ax.top_level_subrole, ax.parent_dialog_role, ax.parent_dialog_subrole, + ax.is_checked, ax.is_cancel_button, ax.is_default_button ), ); } + let mut ax_info: Option = clicked_ax.as_ref().map(|ax| AxClickInfo { + role: ax.role.clone(), + subrole: ax.subrole.clone(), + role_description: ax.role_description.clone(), + identifier: ax.identifier.clone(), + label: ax.label.clone(), + element_bounds: None, + container_role: ax.container_role.clone(), + container_subrole: ax.container_subrole.clone(), + container_identifier: ax.container_identifier.clone(), + window_role: ax.window_role.clone(), + window_subrole: ax.window_subrole.clone(), + top_level_role: ax.top_level_role.clone(), + top_level_subrole: ax.top_level_subrole.clone(), + parent_dialog_role: ax.parent_dialog_role.clone(), + parent_dialog_subrole: ax.parent_dialog_subrole.clone(), + is_checked: ax.is_checked, + is_cancel_button: ax.is_cancel_button, + is_default_button: ax.is_default_button, + }); + // 0b. Filter clicks on our own app using Accessibility API if let Some((clicked_pid, ref clicked_app)) = clicked_info { let our_pid = std::process::id() as i32; @@ -110,7 +290,10 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: &format!("filtered: own app click {clicked_app} (PID {clicked_pid})"), ); if cfg!(debug_assertions) { - eprintln!("Filtered own app click at ({}, {}): {clicked_app} (PID {clicked_pid})", click.x, click.y); + eprintln!( + "Filtered own app click at ({}, {}): {clicked_app} (PID {clicked_pid})", + click.x, click.y + ); } session.diagnostics.clicks_filtered += 1; return Err(PipelineError::OwnAppClick); @@ -120,7 +303,13 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: // 0c. Debounce rapid duplicate clicks (but allow double-click upgrades) let (should_debounce, should_upgrade) = { let mut ps = pipeline_state.lock().unwrap_or_else(|e| e.into_inner()); - is_debounced(&mut ps, click.timestamp_ms, click.x, click.y, click.click_count) + is_debounced( + &mut ps, + click.timestamp_ms, + click.x, + click.y, + click.click_count, + ) }; if should_upgrade { @@ -130,7 +319,10 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: last_step.action = ActionType::DoubleClick; debug_log(session, "upgraded previous step to DoubleClick"); if cfg!(debug_assertions) { - eprintln!("Upgraded previous step to DoubleClick at ({}, {})", click.x, click.y); + eprintln!( + "Upgraded previous step to DoubleClick at ({}, {})", + click.x, click.y + ); } return Err(PipelineError::UpgradedToDblClick); } @@ -203,7 +395,11 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: &format!( "auth_detected={} clicked_info={} auth_window_id={}", is_auth_dialog, - if clicked_info.is_some() { "some" } else { "none" }, + if clicked_info.is_some() { + "some" + } else { + "none" + }, auth_window.as_ref().map(|w| w.window_id).unwrap_or(0) ), ); @@ -224,10 +420,12 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: || ax.window_subrole.as_deref() == Some(accessibility_sys::kAXSystemDialogSubrole) || ax.top_level_role.as_deref() == Some(accessibility_sys::kAXSheetRole) || ax.top_level_subrole.as_deref() == Some(accessibility_sys::kAXDialogSubrole) - || ax.top_level_subrole.as_deref() == Some(accessibility_sys::kAXSystemDialogSubrole) + || ax.top_level_subrole.as_deref() + == Some(accessibility_sys::kAXSystemDialogSubrole) || ax.parent_dialog_role.as_deref() == Some(accessibility_sys::kAXSheetRole) || ax.parent_dialog_subrole.as_deref() == Some(accessibility_sys::kAXDialogSubrole) - || ax.parent_dialog_subrole.as_deref() == Some(accessibility_sys::kAXSystemDialogSubrole) + || ax.parent_dialog_subrole.as_deref() + == Some(accessibility_sys::kAXSystemDialogSubrole) || ax.is_cancel_button || ax.is_default_button; let is_button = ax.role == accessibility_sys::kAXButtonRole @@ -239,7 +437,8 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: if is_sheet_button_click && !is_auth_dialog { let step_id = session.next_step_id(); let screenshot_path = session.screenshot_path(&step_id); - let (display_x, display_y, display_w, display_h) = get_display_bounds_for_click(click.x, click.y); + let (display_x, display_y, display_w, display_h) = + get_display_bounds_for_click(click.x, click.y); let preferred_dialog_bounds = clicked_ax.as_ref().and_then(|ax| { ax.parent_dialog_bounds .clone() @@ -248,7 +447,7 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: }); // Prefer AX-derived dialog bounds with tight margins for cleaner output. - let (mut region_x, mut region_y, region_width, region_height, bounds_source) = + let (mut region_x, mut region_y, mut region_width, mut region_height, mut bounds_source) = if let Some(bounds) = preferred_dialog_bounds { let mut x = bounds.x - 40; let mut y = bounds.y - 52; @@ -276,6 +475,33 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: (x, y, w, h, "fallback") }; + if let Ok(parent_window) = get_frontmost_window() { + let parent = parent_window.bounds; + let region_bounds = WindowBounds { + x: region_x, + y: region_y, + width: region_width as u32, + height: region_height as u32, + }; + let region_area = (region_width as i64) * (region_height as i64); + let parent_area = (parent.width as i64) * (parent.height as i64); + let overlap = bounds_overlap_ratio(®ion_bounds, &parent); + if parent_area > region_area && overlap >= 0.45 { + let union_x = region_x.min(parent.x); + let union_y = region_y.min(parent.y); + let union_right = (region_x + region_width).max(parent.x + parent.width as i32); + let union_bottom = (region_y + region_height).max(parent.y + parent.height as i32); + region_x = union_x; + region_y = union_y; + region_width = (union_right - union_x).max(region_width); + region_height = (union_bottom - union_y).max(region_height); + bounds_source = "ax_bounds+parent_union"; + } + } + + region_width = region_width.min(display_w.max(1)); + region_height = region_height.min(display_h.max(1)); + let max_x = display_x + display_w - region_width; let max_y = display_y + display_h - region_height; region_x = region_x.clamp(display_x, max_x.max(display_x)); @@ -303,6 +529,30 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: ), ); + let app_name_for_log = clicked_info + .as_ref() + .map(|(_, app)| app.as_str()) + .unwrap_or("Application"); + let title_for_log = clicked_ax + .as_ref() + .and_then(|ax| { + if ax.label.is_empty() { + None + } else { + Some(ax.label.as_str()) + } + }) + .unwrap_or("Dialog"); + debug_log( + session, + &format!( + "screenshot_path={} window_id=0 title='{}' app='{}' (sheet_fast_path)", + screenshot_path.to_string_lossy(), + title_for_log, + app_name_for_log + ), + ); + capture_region_best( session, region_x, @@ -317,6 +567,12 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: ((click.x - region_x) as f64 / region_width as f64 * 100.0).clamp(0.0, 100.0); let click_y_percent = ((click.y - region_y) as f64 / region_height as f64 * 100.0).clamp(0.0, 100.0); + let capture_bounds = super::window_info::WindowBounds { + x: region_x, + y: region_y, + width: region_width as u32, + height: region_height as u32, + }; use super::click_event::MouseButton; let action = match (click.button, click.click_count) { @@ -337,6 +593,28 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: } } + let mut ax_info_for_step = ax_info.clone(); + if let (Some(ref mut info), Some(ax_label)) = + (ax_info_for_step.as_mut(), clicked_ax.as_ref()) + { + info.element_bounds = ax_label + .element_bounds + .as_ref() + .and_then(|b| bounds_percent_in_capture(b, &capture_bounds)); + } + let auto_crop_region = if should_apply_focus_crop(&capture_bounds, display_w, display_h) { + suggested_focus_crop_for_capture( + &capture_bounds, + click_x_percent, + click_y_percent, + ax_info_for_step + .as_ref() + .and_then(|info| info.element_bounds.as_ref()), + ) + } else { + None + }; + let step = Step { id: step_id, ts: click.timestamp_ms, @@ -349,8 +627,14 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: window_title, screenshot_path: Some(screenshot_path.to_string_lossy().to_string()), note: None, + description: None, + description_source: None, + description_status: None, + description_error: None, + ax: ax_info_for_step, capture_status: Some(CaptureStatus::Ok), capture_error: None, + crop_region: auto_crop_region, }; session.add_step(step.clone()); @@ -361,6 +645,166 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: let window_info = get_frontmost_window().map_err(|e| PipelineError::WindowInfoFailed(format!("{e}")))?; + // Detect traffic-light window controls early and capture immediately. + // This avoids dark "closing animation" frames for close/minimize/zoom clicks. + let inferred_window_control = if !is_auth_dialog { + let control_bounds = clicked_ax + .as_ref() + .and_then(|ax| { + ax.top_level_bounds + .as_ref() + .or(ax.window_bounds.as_ref()) + .or(ax.parent_dialog_bounds.as_ref()) + }) + .unwrap_or(&window_info.bounds); + infer_window_control_kind( + click.x, + click.y, + control_bounds, + clicked_ax.as_ref().map(|a| a.role.as_str()), + clicked_ax.as_ref().and_then(|a| a.subrole.as_deref()), + clicked_ax + .as_ref() + .and_then(|a| a.role_description.as_deref()), + ) + } else { + None + }; + + if let Some(control) = inferred_window_control { + if let Some(ref mut info) = ax_info { + info.role = accessibility_sys::kAXButtonRole.to_string(); + info.subrole = Some(control.subrole().to_string()); + info.role_description = Some(control.role_description().to_string()); + if info.label.trim().is_empty() { + info.label = control.label().to_string(); + } + } + + let step_id = session.next_step_id(); + let screenshot_path = session.screenshot_path(&step_id); + let (display_x, display_y, display_w, display_h) = + get_display_bounds_for_click(click.x, click.y); + + let mut capture_bounds = clicked_ax + .as_ref() + .and_then(|ax| { + ax.top_level_bounds + .clone() + .or_else(|| ax.window_bounds.clone()) + .or_else(|| ax.parent_dialog_bounds.clone()) + }) + .unwrap_or_else(|| window_info.bounds.clone()); + + // Clamp to clicked display bounds. + let left = capture_bounds.x.max(display_x); + let top = capture_bounds.y.max(display_y); + let right = (capture_bounds.x + capture_bounds.width as i32).min(display_x + display_w); + let bottom = (capture_bounds.y + capture_bounds.height as i32).min(display_y + display_h); + if right > left && bottom > top { + capture_bounds.x = left; + capture_bounds.y = top; + capture_bounds.width = (right - left) as u32; + capture_bounds.height = (bottom - top) as u32; + } + + debug_log( + session, + &format!( + "window_control_fast_path: control={:?} bounds=({}, {}, {}x{})", + control, + capture_bounds.x, + capture_bounds.y, + capture_bounds.width, + capture_bounds.height + ), + ); + debug_log( + session, + &format!( + "screenshot_path={} window_id={} title='{}' app='{}' (window_control_fast_path)", + screenshot_path.to_string_lossy(), + window_info.window_id, + window_info.window_title, + window_info.app_name + ), + ); + + capture_region_best( + session, + capture_bounds.x, + capture_bounds.y, + capture_bounds.width as i32, + capture_bounds.height as i32, + &screenshot_path, + ) + .map_err(|e| PipelineError::ScreenshotFailed(format!("{e}")))?; + + if let (Some(ref mut info), Some(ax_label)) = (ax_info.as_mut(), clicked_ax.as_ref()) { + info.element_bounds = ax_label + .element_bounds + .as_ref() + .and_then(|b| bounds_percent_in_capture(b, &capture_bounds)); + } + + use super::click_event::MouseButton; + let action = match (click.button, click.click_count) { + (MouseButton::Right, _) => ActionType::RightClick, + (MouseButton::Left, 2) => ActionType::DoubleClick, + (MouseButton::Left, n) if n >= 3 => ActionType::DoubleClick, + _ => ActionType::Click, + }; + + let click_x_percent = + calculate_click_percent(click.x, capture_bounds.x, capture_bounds.width as i32); + let click_y_percent = + calculate_click_percent(click.y, capture_bounds.y, capture_bounds.height as i32); + let auto_crop_region = if should_apply_focus_crop(&capture_bounds, display_w, display_h) { + suggested_focus_crop_for_capture( + &capture_bounds, + click_x_percent, + click_y_percent, + ax_info + .as_ref() + .and_then(|info| info.element_bounds.as_ref()), + ) + } else { + None + }; + + let step = Step { + id: step_id, + ts: click.timestamp_ms, + action, + x: click.x, + y: click.y, + click_x_percent: click_x_percent as f32, + click_y_percent: click_y_percent as f32, + app: clicked_info + .as_ref() + .map(|(_, app)| app.clone()) + .unwrap_or_else(|| window_info.app_name.clone()), + window_title: if window_info.window_title.trim().is_empty() { + "Window".to_string() + } else { + window_info.window_title.clone() + }, + screenshot_path: Some(screenshot_path.to_string_lossy().to_string()), + note: None, + description: None, + description_source: None, + description_status: None, + description_error: None, + ax: ax_info, + capture_status: Some(CaptureStatus::Ok), + capture_error: None, + crop_region: auto_crop_region, + }; + + session.add_step(step.clone()); + return Ok(step); + } + // 2. Check if click is on a popup/menu window (only for frontmost app's windows) // We look for smaller overlay windows that belong to the same app let topmost_at_click = get_topmost_window_at_point(click.x, click.y); @@ -371,15 +815,16 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: // - For popup/menus (empty title, smaller), use the popup window // - For system UI (Dock, Spotlight), use the main window let mut is_sheet_dialog = false; + let attached_dialog_owner = clicked_info.as_ref().map(|(pid, app)| (*pid, app.as_str())); let attached_dialog = if !is_auth_dialog { if let Some(ref topmost) = topmost_at_click { if topmost.window_id == window_info.window_id { - find_attached_dialog_window(click.x, click.y, &window_info) + find_attached_dialog_window(click.x, click.y, &window_info, attached_dialog_owner) } else { None } } else { - find_attached_dialog_window(click.x, click.y, &window_info) + find_attached_dialog_window(click.x, click.y, &window_info, attached_dialog_owner) } } else { None @@ -423,9 +868,12 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: if cfg!(debug_assertions) { eprintln!( "Using heuristic auth window for capture: '{}' id={} bounds=({}, {}, {}x{})", - auth_window.app_name, auth_window.window_id, - auth_window.bounds.x, auth_window.bounds.y, - auth_window.bounds.width, auth_window.bounds.height + auth_window.app_name, + auth_window.window_id, + auth_window.bounds.x, + auth_window.bounds.y, + auth_window.bounds.width, + auth_window.bounds.height ); } auth_window @@ -444,9 +892,12 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: if cfg!(debug_assertions) { eprintln!( "Using security agent window for capture: '{}' id={} bounds=({}, {}, {}x{})", - auth_window.app_name, auth_window.window_id, - auth_window.bounds.x, auth_window.bounds.y, - auth_window.bounds.width, auth_window.bounds.height + auth_window.app_name, + auth_window.window_id, + auth_window.bounds.x, + auth_window.bounds.y, + auth_window.bounds.width, + auth_window.bounds.height ); } auth_window @@ -468,24 +919,76 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: // Consider "same app" if the topmost window matches either the frontmost window // OR the clicked element's app (from AX API). This handles menu-bar apps where the // frontmost window belongs to a different app than the one being clicked. - let same_app = topmost.app_name.to_lowercase() == window_info.app_name.to_lowercase() - || clicked_info.as_ref().is_some_and(|(_, clicked_app)| { - topmost.app_name.to_lowercase() == clicked_app.to_lowercase() - }); + let same_app = app_names_match(&topmost.app_name, &window_info.app_name) + || clicked_info + .as_ref() + .is_some_and(|(_, clicked_app)| app_names_match(&topmost.app_name, clicked_app)); let is_reasonable_size = topmost.bounds.width >= 50 && topmost.bounds.height >= 20; + let overlaps_main = bounds_overlap_ratio(&topmost.bounds, &window_info.bounds); + let contained_in_main = + bounds_contained_with_margin(&topmost.bounds, &window_info.bounds, 12); + let has_ax_dialog_hint = clicked_ax + .as_ref() + .map(|ax| { + ax.window_role.as_deref() == Some(accessibility_sys::kAXSheetRole) + || ax.window_subrole.as_deref() == Some(accessibility_sys::kAXDialogSubrole) + || ax.window_subrole.as_deref() + == Some(accessibility_sys::kAXSystemDialogSubrole) + || ax.top_level_role.as_deref() == Some(accessibility_sys::kAXSheetRole) + || ax.top_level_subrole.as_deref() == Some(accessibility_sys::kAXDialogSubrole) + || ax.top_level_subrole.as_deref() + == Some(accessibility_sys::kAXSystemDialogSubrole) + || ax.parent_dialog_role.as_deref() == Some(accessibility_sys::kAXSheetRole) + || ax.parent_dialog_subrole.as_deref() + == Some(accessibility_sys::kAXDialogSubrole) + || ax.parent_dialog_subrole.as_deref() + == Some(accessibility_sys::kAXSystemDialogSubrole) + || ax.is_cancel_button + || ax.is_default_button + }) + .unwrap_or(false); + let is_foreign_dialog_like = !same_app + && !is_system_ui + && topmost.window_id != window_info.window_id + && main_area > 0 + && topmost_area < main_area + && has_ax_dialog_hint + && (overlaps_main >= 0.55 || contained_in_main); // Regular same-app window WITH a title: use it (handles multiple windows of same app) let is_regular_same_app_window = same_app && !topmost.window_title.is_empty(); // Menu/popup: empty title, smaller than main window, and same app let is_menu_popup = same_app && topmost.window_title.is_empty() && topmost_area < main_area; - if !is_system_ui && is_reasonable_size && (is_regular_same_app_window || is_menu_popup) { + if !is_system_ui + && is_reasonable_size + && (is_regular_same_app_window || is_menu_popup || is_foreign_dialog_like) + { capture_from_topmost = true; + if is_foreign_dialog_like { + is_sheet_dialog = true; + debug_log( + session, + &format!( + "foreign_dialog_topmost: id={} owner='{}' bounds=({}, {}, {}x{}) overlap={:.2}", + topmost.window_id, + topmost.app_name, + topmost.bounds.x, + topmost.bounds.y, + topmost.bounds.width, + topmost.bounds.height, + overlaps_main + ), + ); + } if cfg!(debug_assertions) { eprintln!( "Using clicked window for capture: '{}' - '{}' (id={}, {}x{})", - topmost.app_name, topmost.window_title, topmost.window_id, - topmost.bounds.width, topmost.bounds.height + topmost.app_name, + topmost.window_title, + topmost.window_id, + topmost.bounds.width, + topmost.bounds.height ); } topmost.clone() @@ -509,18 +1012,31 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: // EXCEPTION: when capture_window came from topmost overlay detection (e.g. GIF picker, // popup panel), keep it — the overlay is the correct capture target. let mut capture_window = capture_window; - let (actual_app_name, mut actual_window_title) = if let Some((clicked_pid, ref clicked_app)) = clicked_info { - if clicked_app.to_lowercase() != capture_window.app_name.to_lowercase() && !capture_from_topmost { - // Try to find the clicked app's main window for correct capture bounds - if let Some(clicked_window) = get_main_window_for_pid(clicked_pid, clicked_app) { + let (actual_app_name, mut actual_window_title) = if let Some((clicked_pid, ref clicked_app)) = + clicked_info + { + if !app_names_match(clicked_app, &capture_window.app_name) && !capture_from_topmost { + // Only switch capture windows when we can resolve a concrete window + // under the click for the clicked PID. Falling back to the "largest" + // window can jump to unrelated apps/windows. + if let Some(clicked_window) = get_window_for_pid_at_click( + clicked_pid, + clicked_app, + click.x, + click.y, + Some(capture_window.window_id), + ) { if cfg!(debug_assertions) { eprintln!( - "Resolved clicked app window: '{}' - '{}' id={} bounds=({}, {}, {}x{})", - clicked_window.app_name, clicked_window.window_title, - clicked_window.window_id, - clicked_window.bounds.x, clicked_window.bounds.y, - clicked_window.bounds.width, clicked_window.bounds.height - ); + "Resolved clicked app window at click: '{}' - '{}' id={} bounds=({}, {}, {}x{})", + clicked_window.app_name, + clicked_window.window_title, + clicked_window.window_id, + clicked_window.bounds.x, + clicked_window.bounds.y, + clicked_window.bounds.width, + clicked_window.bounds.height + ); } let title = if clicked_window.window_title.is_empty() { format!("Click on {clicked_app}") @@ -530,9 +1046,14 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: capture_window = clicked_window; (clicked_app.clone(), title) } else { - (clicked_app.clone(), format!("Click on {clicked_app}")) + let title = if capture_window.window_title.is_empty() { + format!("Click on {clicked_app}") + } else { + capture_window.window_title.clone() + }; + (clicked_app.clone(), title) } - } else if capture_from_topmost && clicked_app.to_lowercase() != capture_window.app_name.to_lowercase() { + } else if capture_from_topmost && !app_names_match(clicked_app, &capture_window.app_name) { // Topmost overlay from different app: keep capture window, use clicked_app for label if cfg!(debug_assertions) { eprintln!( @@ -547,10 +1068,16 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: }; (clicked_app.clone(), title) } else { - (capture_window.app_name.clone(), capture_window.window_title.clone()) + ( + capture_window.app_name.clone(), + capture_window.window_title.clone(), + ) } } else { - (capture_window.app_name.clone(), capture_window.window_title.clone()) + ( + capture_window.app_name.clone(), + capture_window.window_title.clone(), + ) }; let mut resolved_window_title = actual_window_title.clone(); @@ -559,6 +1086,21 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: eprintln!("Recording click on: {actual_app_name} - {actual_window_title}"); } + // Ignore pure menu-open clicks early (before allocating step IDs/screenshot paths). + // This prevents reusing IDs and overwriting screenshots when the next click is a menu item. + if !is_auth_dialog { + if let Some(ax_label) = clicked_ax.as_ref() { + let role = ax_label.role.as_str(); + if role == accessibility_sys::kAXMenuButtonRole { + debug_log( + session, + &format!("ignored menu open: role={role} label='{}'", ax_label.label), + ); + return Err(PipelineError::IgnoredMenuOpen); + } + } + } + // 2. Generate step ID and screenshot path let step_id = session.next_step_id(); let screenshot_path = session.screenshot_path(&step_id); @@ -599,9 +1141,71 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: // Track capture outcome across all branches let mut final_capture_status = CaptureStatus::Ok; let mut final_capture_error: Option = None; + let (click_display_x, click_display_y, click_display_w, click_display_h) = + get_display_bounds_for_click(click.x, click.y); + + // 3. Capture screenshot. + // Pixel-first strategy: for regular clicks, prefer the pre-click full-display frame. + // This preserves transient UI (web overlays/menus/popups) at click-time across apps/sites. + // + // Keep right-click and auth dialogs on dedicated paths: + // - right-click wants post-click context menu capture + // - auth dialogs may require secure placeholders/window-ID capture semantics + let is_right_click = matches!(click.button, super::click_event::MouseButton::Right); + const PRECLICK_MAX_AGE_MS: i64 = 250; + let pre_click_fullframe_capture = if !is_right_click && !is_auth_dialog { + if let Some(buffer) = pre_click_buffer { + match buffer.capture_for_click(click.x, click.y, click.timestamp_ms, &screenshot_path) { + Ok(Some(pre)) if (0..=PRECLICK_MAX_AGE_MS).contains(&pre.frame_age_ms) => { + debug_log( + session, + &format!( + "preclick_fullframe_capture ok: age_ms={} bounds=({}, {}, {}x{})", + pre.frame_age_ms, + pre.bounds.x, + pre.bounds.y, + pre.bounds.width, + pre.bounds.height + ), + ); + Some(pre) + } + Ok(Some(pre)) => { + debug_log( + session, + &format!( + "preclick_fullframe_capture stale: age_ms={} (max={})", + pre.frame_age_ms, PRECLICK_MAX_AGE_MS + ), + ); + None + } + Ok(None) => { + debug_log(session, "preclick_fullframe_capture unavailable"); + None + } + Err(err) => { + debug_log( + session, + &format!("preclick_fullframe_capture failed: {err}"), + ); + None + } + } + } else { + None + } + } else { + None + }; - // 3. Capture screenshot - special handling for Dock, auth dialogs, then windows - let (click_x_percent, click_y_percent) = if is_dock_click { + let (click_x_percent, click_y_percent, capture_bounds_for_step) = if let Some(pre) = + pre_click_fullframe_capture + { + let x_pct = calculate_click_percent(click.x, pre.bounds.x, pre.bounds.width as i32); + let y_pct = calculate_click_percent(click.y, pre.bounds.y, pre.bounds.height as i32); + (x_pct, y_pct, pre.bounds) + } else if is_dock_click { // Dock click - capture zoomed region, centered on the clicked icon use core_graphics::display::CGDisplay; @@ -627,16 +1231,23 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: let display_width = display_bounds.size.width as i32; let display_height = display_bounds.size.height as i32; - let region_width = 800; - let region_height = 150; + let region_width = 800.min(display_width.max(1)); + let region_height = 150.min(display_height.max(1)); // Calculate click position relative to the display let click_rel_x = click.x - display_x; // Center the region on the click (but clamp to display bounds) - let region_rel_x = (click_rel_x - region_width / 2).max(0).min(display_width - region_width); + let max_rel_x = (display_width - region_width).max(0); + let region_rel_x = (click_rel_x - region_width / 2).max(0).min(max_rel_x); let region_x = display_x + region_rel_x; let region_y = display_y + display_height - region_height; + let capture_bounds = super::window_info::WindowBounds { + x: region_x, + y: region_y, + width: region_width as u32, + height: region_height as u32, + }; capture_region_best( session, @@ -646,16 +1257,18 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: region_height, &screenshot_path, ) - .map_err(|e| PipelineError::ScreenshotFailed(format!("{e}")))?; + .map_err(|e| PipelineError::ScreenshotFailed(format!("{e}")))?; // Calculate click position within the captured region - let x_pct = ((click_rel_x - region_rel_x) as f64 / region_width as f64 * 100.0).clamp(0.0, 100.0); + let x_pct = + ((click_rel_x - region_rel_x) as f64 / region_width as f64 * 100.0).clamp(0.0, 100.0); let y_pct = ((click.y - region_y) as f64 / region_height as f64 * 100.0).clamp(0.0, 100.0); - (x_pct, y_pct) + (x_pct, y_pct, capture_bounds) } else if is_auth_dialog && capture_window.window_id > 0 { // Auth dialogs (Touch ID, password, WireGuard picker) - capture the SPECIFIC window by ID // Using screencapture -l ensures we capture only the dialog, not background windows let bounds = &capture_window.bounds; + let capture_bounds = bounds.clone(); if cfg!(debug_assertions) { eprintln!( @@ -666,10 +1279,7 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: let capture_result = capture_window_by_id(capture_window.window_id, &screenshot_path); if let Err(err) = capture_result { - debug_log( - session, - &format!("auth_window_capture_failed: {err}"), - ); + debug_log(session, &format!("auth_window_capture_failed: {err}")); if cfg!(debug_assertions) { eprintln!("Auth window capture failed ({err}), falling back to region capture"); } @@ -687,32 +1297,61 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: let x_pct = calculate_click_percent(click.x, bounds.x, bounds.width as i32); let y_pct = calculate_click_percent(click.y, bounds.y, bounds.height as i32); - (x_pct, y_pct) + (x_pct, y_pct, capture_bounds.clone()) } else { debug_log(session, "auth_window_capture: ok"); // Calculate click position relative to window bounds let x_pct = calculate_click_percent(click.x, bounds.x, bounds.width as i32); let y_pct = calculate_click_percent(click.y, bounds.y, bounds.height as i32); - (x_pct, y_pct) + (x_pct, y_pct, capture_bounds.clone()) } } else if capture_window.window_id > 0 { - // Check if click is in menubar region (top ~30 pixels) OR on a dropdown menu - const MENUBAR_HEIGHT: i32 = 30; - const DROPDOWN_REGION_HEIGHT: i32 = 500; // Typical max height for dropdown menus + // Check if click is in menubar region or in a menu interaction. + + let overlay_kind = classify_titleless_overlay_window( + &capture_window.window_title, + capture_window.window_id, + window_info.window_id, + &capture_window.bounds, + click_display_y, + ); - // Detect if this is a popup/dropdown menu (empty title, different from main window) - let is_popup_menu = capture_window.window_title.is_empty() - && capture_window.window_id != window_info.window_id - && (capture_window.bounds.width as i32) < 800; // Popups are typically narrow + let click_y_in_display = click.y - click_display_y; + let ax_role = clicked_ax.as_ref().map(|ax| ax.role.as_str()); + let is_right_click = matches!(click.button, super::click_event::MouseButton::Right); + let is_dropdown_menu = overlay_kind == helpers::TitlelessOverlayKind::DropdownMenu; + let recent_menu_open = { + let mut ps = pipeline_state.lock().unwrap_or_else(|e| e.into_inner()); + let is_recent = ps + .last_menu_bar_click_ms + .map(|ts| { + let dt = click.timestamp_ms - ts; + (0..=2_500).contains(&dt) + }) + .unwrap_or(false); + if !is_recent { + ps.last_menu_bar_click_ms = None; + } + is_recent + }; - // Use region capture for: menubar clicks OR dropdown menu clicks in upper screen - let use_region_capture = click.y < MENUBAR_HEIGHT - || (is_popup_menu && click.y < DROPDOWN_REGION_HEIGHT); + // Use menu-region capture only for true menu-bar/dropdown interactions near top. + // Transient overlays (pickers/menus away from menu bar) are handled below with + // window/popup bounds so we don't crop to the top strip. + let use_menu_region_capture = should_use_menu_region_capture( + overlay_kind, + ax_role, + click_y_in_display, + recent_menu_open, + ); + let use_region_capture = use_menu_region_capture; if resolved_window_title.is_empty() { if is_sheet_dialog { resolved_window_title = "Dialog".to_string(); - } else if is_popup_menu || use_region_capture { + } else if overlay_kind == helpers::TitlelessOverlayKind::Popup { + resolved_window_title = "Popup".to_string(); + } else if use_menu_region_capture || is_dropdown_menu { resolved_window_title = "Menu".to_string(); } else { resolved_window_title = "Window".to_string(); @@ -720,14 +1359,18 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: } if use_region_capture { + resolved_window_title = "Menu".to_string(); // Menubar/dropdown click - capture a region around the click - let region_height = 500; // Include dropdown content - let region_width = 600; + let region_height = 500.min(click_display_h.max(1)); // Include dropdown content + let region_width = 600.min(click_display_w.max(1)); - // Center horizontally on click (in global coordinates, can be negative) - let region_x = click.x - region_width / 2; - // For dropdown clicks, start capture from top of screen to include menubar - let region_y = 0; + // Center horizontally on click, clamped to clicked display bounds. + let min_region_x = click_display_x; + let max_region_x = (click_display_x + click_display_w - region_width).max(min_region_x); + let region_x = (click.x - region_width / 2).clamp(min_region_x, max_region_x); + // For dropdown clicks, start capture from top of the clicked display + // (not global y=0) so secondary-display menubars are captured correctly. + let region_y = click_display_y; // Capture the region capture_region_best( @@ -738,11 +1381,29 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: region_height, &screenshot_path, ) - .map_err(|e| PipelineError::ScreenshotFailed(format!("{e}")))?; + .map_err(|e| PipelineError::ScreenshotFailed(format!("{e}")))?; // Calculate click position as percentage within captured region - let x_pct = ((click.x - region_x) as f64 / region_width as f64 * 100.0).clamp(0.0, 100.0); - let y_pct = ((click.y - region_y) as f64 / region_height as f64 * 100.0).clamp(0.0, 100.0); + let x_pct = + ((click.x - region_x) as f64 / region_width as f64 * 100.0).clamp(0.0, 100.0); + let y_pct = + ((click.y - region_y) as f64 / region_height as f64 * 100.0).clamp(0.0, 100.0); + + let mut ax_info_for_step = ax_info.clone(); + if let (Some(ref mut info), Some(ax_label)) = + (ax_info_for_step.as_mut(), clicked_ax.as_ref()) + { + let capture_bounds = super::window_info::WindowBounds { + x: region_x, + y: region_y, + width: region_width as u32, + height: region_height as u32, + }; + info.element_bounds = ax_label + .element_bounds + .as_ref() + .and_then(|b| bounds_percent_in_capture(b, &capture_bounds)); + } let step = Step { id: step_id, @@ -761,30 +1422,51 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: window_title: resolved_window_title, screenshot_path: Some(screenshot_path.to_string_lossy().to_string()), note: None, + description: None, + description_source: None, + description_status: None, + description_error: None, + ax: ax_info_for_step, capture_status: Some(CaptureStatus::Ok), capture_error: None, + crop_region: None, }; session.add_step(step.clone()); return Ok(step); } - // Check if this is a popup/menu (empty title, smaller than main window) - let is_popup_menu = capture_window.window_title.is_empty() - && capture_window.window_id != window_info.window_id; + let overlay_kind = classify_titleless_overlay_window( + &capture_window.window_title, + capture_window.window_id, + window_info.window_id, + &capture_window.bounds, + click_display_y, + ); + // Only treat titleless overlays as "popup menus" here when they're dropdown menus. + // Other titleless overlays are typically in-app popovers and should be captured as windows. + let is_popup_menu = overlay_kind == helpers::TitlelessOverlayKind::DropdownMenu; + let prefer_transient_region_capture = + should_prefer_transient_region_capture(ax_role, overlay_kind, is_right_click); // For right-clicks, poll for the context menu to appear. // macOS renders context menus asynchronously; a single fixed delay // sometimes captures before the menu is visible. We poll a few // times with short sleeps (total max ~250ms) so the screenshot // reliably includes the menu. - let is_right_click = matches!(click.button, super::click_event::MouseButton::Right); let context_menu_bounds = if is_right_click && !is_popup_menu { if cfg!(debug_assertions) { - eprintln!("Looking for context menu near click ({}, {}) for app '{}'", click.x, click.y, &capture_window.app_name); + eprintln!( + "Looking for context menu near click ({}, {}) for app '{}'", + click.x, click.y, &capture_window.app_name + ); } let mut found = None; for attempt in 0..5 { - std::thread::sleep(std::time::Duration::from_millis(if attempt == 0 { 80 } else { 40 })); + std::thread::sleep(std::time::Duration::from_millis(if attempt == 0 { + 80 + } else { + 40 + })); found = find_context_menu_near_click(click.x, click.y, &capture_window.app_name); if found.is_some() { debug_log( @@ -795,7 +1477,9 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: // Finder menus can be slow to populate (Quick Actions, extensions …). std::thread::sleep(std::time::Duration::from_millis(150)); // Re-measure — the menu may have grown during its animation - if let Some(refreshed) = find_context_menu_near_click(click.x, click.y, &capture_window.app_name) { + if let Some(refreshed) = + find_context_menu_near_click(click.x, click.y, &capture_window.app_name) + { found = Some(refreshed); } break; @@ -809,6 +1493,8 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: if resolved_window_title.is_empty() { if is_sheet_dialog { resolved_window_title = "Dialog".to_string(); + } else if overlay_kind == helpers::TitlelessOverlayKind::Popup { + resolved_window_title = "Popup".to_string(); } else if is_popup_menu || context_menu_bounds.is_some() { resolved_window_title = "Menu".to_string(); } else { @@ -816,8 +1502,46 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: } } - // For popup menus or right-clicks with context menu: use region capture that includes both - let (use_region_capture, mut actual_bounds) = if is_popup_menu { + // For popup menus, popovers, or right-click context menus: use region capture that includes + // both base window and overlay/menu when available. + let (use_region_capture, mut actual_bounds) = if is_sheet_dialog { + // For sheets/dialogs, capture parent+dialog context (easier for users to follow) + // instead of a cropped dialog-only image. + let main = &window_info.bounds; + let dialog = &capture_window.bounds; + let union_x = main.x.min(dialog.x); + let union_y = main.y.min(dialog.y); + let union_right = (main.x + main.width as i32).max(dialog.x + dialog.width as i32); + let union_bottom = (main.y + main.height as i32).max(dialog.y + dialog.height as i32); + + let mut union_bounds = super::window_info::WindowBounds { + x: union_x, + y: union_y, + width: (union_right - union_x) as u32, + height: (union_bottom - union_y) as u32, + }; + + let left = union_bounds.x.max(click_display_x); + let top = union_bounds.y.max(click_display_y); + let right = + (union_bounds.x + union_bounds.width as i32).min(click_display_x + click_display_w); + let bottom = (union_bounds.y + union_bounds.height as i32) + .min(click_display_y + click_display_h); + if right > left && bottom > top { + union_bounds.x = left; + union_bounds.y = top; + union_bounds.width = (right - left) as u32; + union_bounds.height = (bottom - top) as u32; + } + + if cfg!(debug_assertions) { + eprintln!( + "Dialog/sheet detected - using window+dialog union: ({}, {}, {}x{})", + union_bounds.x, union_bounds.y, union_bounds.width, union_bounds.height + ); + } + (true, union_bounds) + } else if is_popup_menu { // Calculate union of main window and menu bounds let main = &window_info.bounds; let menu = &capture_window.bounds; @@ -842,6 +1566,63 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: ); } (true, union_bounds) + } else if overlay_kind == helpers::TitlelessOverlayKind::Popup { + // In-app popovers (sticker picker, emoji picker, etc.): avoid tiny "overlay-only" + // screenshots by capturing the union with the main window for context. + let mut main_bounds = window_info.bounds.clone(); + if let Some((clicked_pid, clicked_app)) = clicked_info.as_ref() { + if let Some(candidate) = get_window_for_pid_at_click( + *clicked_pid, + clicked_app, + click.x, + click.y, + Some(capture_window.window_id), + ) { + main_bounds = candidate.bounds; + } else if let Some(candidate) = get_main_window_for_pid(*clicked_pid, clicked_app) { + main_bounds = candidate.bounds; + } + } + let main = &main_bounds; + let popup = &capture_window.bounds; + + let union_x = main.x.min(popup.x); + let union_y = main.y.min(popup.y); + let union_right = (main.x + main.width as i32).max(popup.x + popup.width as i32); + let union_bottom = (main.y + main.height as i32).max(popup.y + popup.height as i32); + + let mut union_bounds = super::window_info::WindowBounds { + x: union_x, + y: union_y, + width: (union_right - union_x) as u32, + height: (union_bottom - union_y) as u32, + }; + + // Clamp to clicked display bounds to avoid oversized multi-display captures. + let left = union_bounds.x.max(click_display_x); + let top = union_bounds.y.max(click_display_y); + let right = + (union_bounds.x + union_bounds.width as i32).min(click_display_x + click_display_w); + let bottom = (union_bounds.y + union_bounds.height as i32) + .min(click_display_y + click_display_h); + if right > left && bottom > top { + union_bounds.x = left; + union_bounds.y = top; + union_bounds.width = (right - left) as u32; + union_bounds.height = (bottom - top) as u32; + } + + if cfg!(debug_assertions) { + eprintln!( + "Popup overlay detected - using union capture for context: ({}, {}, {}x{}), capture_from_topmost={}", + union_bounds.x, + union_bounds.y, + union_bounds.width, + union_bounds.height, + capture_from_topmost + ); + } + (true, union_bounds) } else if let Some(ref menu_bounds) = context_menu_bounds { // Right-click with context menu found - include both window and menu. // Add generous padding around the menu to account for macOS drop shadows @@ -851,10 +1632,10 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: let union_x = main.x.min(menu_bounds.x - MENU_PAD); let union_y = main.y.min(menu_bounds.y - MENU_PAD); - let union_right = - (main.x + main.width as i32).max(menu_bounds.x + menu_bounds.width as i32 + MENU_PAD); - let union_bottom = - (main.y + main.height as i32).max(menu_bounds.y + menu_bounds.height as i32 + MENU_PAD); + let union_right = (main.x + main.width as i32) + .max(menu_bounds.x + menu_bounds.width as i32 + MENU_PAD); + let union_bottom = (main.y + main.height as i32) + .max(menu_bounds.y + menu_bounds.height as i32 + MENU_PAD); let union_bounds = super::window_info::WindowBounds { x: union_x, @@ -866,11 +1647,39 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: if cfg!(debug_assertions) { eprintln!( "Right-click with context menu - using union: ({}, {}, {}x{})", - union_bounds.x, union_bounds.y, - union_bounds.width, union_bounds.height + union_bounds.x, union_bounds.y, union_bounds.width, union_bounds.height ); } (true, union_bounds) + } else if prefer_transient_region_capture { + // Volatile UI actions (menu rows, picker rows) can disappear during click handling. + // Prefer immediate region capture over slower window-ID capture to preserve what the + // user actually saw at click-time. + debug_log( + session, + &format!("transient_region_capture: role={ax_role:?} overlay_kind={overlay_kind:?}"), + ); + let bounds = if overlay_kind == helpers::TitlelessOverlayKind::NotOverlay { + // Keep OCR/context focused around click for weak AX roles (e.g. AXGroup), + // instead of capturing the whole app window. + let region_w = 1400.min(click_display_w.max(1)); + let region_h = 900.min(click_display_h.max(1)); + let min_x = click_display_x; + let max_x = (click_display_x + click_display_w - region_w).max(min_x); + let min_y = click_display_y; + let max_y = (click_display_y + click_display_h - region_h).max(min_y); + let x = (click.x - region_w / 2).clamp(min_x, max_x); + let y = (click.y - region_h / 2).clamp(min_y, max_y); + super::window_info::WindowBounds { + x, + y, + width: region_w as u32, + height: region_h as u32, + } + } else { + capture_window.bounds.clone() + }; + (true, bounds) } else { (false, capture_window.bounds.clone()) }; @@ -889,13 +1698,17 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: .map(|ax| { ax.window_role.as_deref() == Some(accessibility_sys::kAXSheetRole) || ax.window_subrole.as_deref() == Some(accessibility_sys::kAXDialogSubrole) - || ax.window_subrole.as_deref() == Some(accessibility_sys::kAXSystemDialogSubrole) + || ax.window_subrole.as_deref() + == Some(accessibility_sys::kAXSystemDialogSubrole) || ax.top_level_role.as_deref() == Some(accessibility_sys::kAXSheetRole) || ax.top_level_subrole.as_deref() == Some(accessibility_sys::kAXDialogSubrole) - || ax.top_level_subrole.as_deref() == Some(accessibility_sys::kAXSystemDialogSubrole) + || ax.top_level_subrole.as_deref() + == Some(accessibility_sys::kAXSystemDialogSubrole) || ax.parent_dialog_role.as_deref() == Some(accessibility_sys::kAXSheetRole) - || ax.parent_dialog_subrole.as_deref() == Some(accessibility_sys::kAXDialogSubrole) - || ax.parent_dialog_subrole.as_deref() == Some(accessibility_sys::kAXSystemDialogSubrole) + || ax.parent_dialog_subrole.as_deref() + == Some(accessibility_sys::kAXDialogSubrole) + || ax.parent_dialog_subrole.as_deref() + == Some(accessibility_sys::kAXSystemDialogSubrole) || ax.is_cancel_button || ax.is_default_button }) @@ -939,9 +1752,8 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: } else { 0.0 }; - let larger_than_main = - ax_bounds.width > window_info.bounds.width + 24 - || ax_bounds.height > window_info.bounds.height + 24; + let larger_than_main = ax_bounds.width > window_info.bounds.width + 24 + || ax_bounds.height > window_info.bounds.height + 24; let should_use_ax_bounds = click_in_ax && overlap_ratio >= 0.25 && (is_dialog_marker || larger_than_main); @@ -985,8 +1797,8 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: get_display_bounds_for_click(click.x, click.y); let left = (actual_bounds.x - 180).max(display_x); let top = (actual_bounds.y - 80).max(display_y); - let right = (actual_bounds.x + actual_bounds.width as i32 + 180) - .min(display_x + display_w); + let right = + (actual_bounds.x + actual_bounds.width as i32 + 180).min(display_x + display_w); let bottom = (actual_bounds.y + actual_bounds.height as i32 + 140) .min(display_y + display_h); if right > left && bottom > top { @@ -1022,13 +1834,48 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: let mut used_fallback = false; let mut last_capture_err: Option = None; + if prefer_transient_region_capture && !is_right_click { + if let Some(buffer) = pre_click_buffer { + match buffer.capture_for_click( + click.x, + click.y, + click.timestamp_ms, + &screenshot_path, + ) { + Ok(Some(pre)) => { + actual_bounds = pre.bounds; + capture_ok = true; + debug_log( + session, + &format!( + "preclick_buffer_capture ok: age_ms={} bounds=({}, {}, {}x{})", + pre.frame_age_ms, + actual_bounds.x, + actual_bounds.y, + actual_bounds.width, + actual_bounds.height + ), + ); + } + Ok(None) => { + debug_log(session, "preclick_buffer_capture unavailable"); + } + Err(err) => { + debug_log(session, &format!("preclick_buffer_capture failed: {err}")); + } + } + } + } + if !use_region_capture && context_menu_bounds.is_none() && capture_window.window_id > 0 { if cfg!(debug_assertions) { eprintln!( "Trying window-ID capture: id={} bounds=({}, {}, {}x{})", capture_window.window_id, - actual_bounds.x, actual_bounds.y, - actual_bounds.width, actual_bounds.height, + actual_bounds.x, + actual_bounds.y, + actual_bounds.width, + actual_bounds.height, ); } match capture_window_cg(capture_window.window_id, &screenshot_path) { @@ -1063,8 +1910,10 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: if cfg!(debug_assertions) { eprintln!( "Region capture: bounds=({}, {}, {}x{}) popup={}", - actual_bounds.x, actual_bounds.y, - actual_bounds.width, actual_bounds.height, + actual_bounds.x, + actual_bounds.y, + actual_bounds.width, + actual_bounds.height, use_region_capture ); } @@ -1084,8 +1933,10 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: } Ok(()) => { debug_log(session, "region_capture produced empty file"); - last_capture_err = Some(last_capture_err.unwrap_or_default() - + "; region capture produced empty file"); + last_capture_err = Some( + last_capture_err.unwrap_or_default() + + "; region capture produced empty file", + ); } Err(e) => { debug_log(session, &format!("region_capture failed: {e}")); @@ -1119,62 +1970,70 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: if cfg!(debug_assertions) { eprintln!( "Click calc: click=({}, {}), capture_bounds=(x={}, y={}, w={}, h={})", - click.x, click.y, - actual_bounds.x, actual_bounds.y, - actual_bounds.width, actual_bounds.height + click.x, + click.y, + actual_bounds.x, + actual_bounds.y, + actual_bounds.width, + actual_bounds.height ); } // Calculate click position relative to the CAPTURED window bounds - let x_pct = calculate_click_percent( - click.x, - actual_bounds.x, - actual_bounds.width as i32, - ); - let y_pct = calculate_click_percent( - click.y, - actual_bounds.y, - actual_bounds.height as i32, - ); + let x_pct = calculate_click_percent(click.x, actual_bounds.x, actual_bounds.width as i32); + let y_pct = calculate_click_percent(click.y, actual_bounds.y, actual_bounds.height as i32); if cfg!(debug_assertions) { eprintln!("Click percent: x={x_pct}%, y={y_pct}%"); } - (x_pct, y_pct) + (x_pct, y_pct, actual_bounds) } else { - // No valid window - check if click is in menubar/dropdown region or use fullscreen - let (screen_width, screen_height) = get_main_screen_size(); + // No valid window - check if click is in menubar/dropdown region on the clicked display, + // otherwise capture the full clicked display. + let screen_width = click_display_w; + let screen_height = click_display_h; + let screen_x = click_display_x; + let screen_y = click_display_y; + let click_y_in_display = click.y - screen_y; // Menubar and dropdown region (top portion of screen) // This covers: menubar clicks, dropdown menus, status menu popups const MENUBAR_REGION_HEIGHT: i32 = 500; - if click.y < MENUBAR_REGION_HEIGHT { + if (0..MENUBAR_REGION_HEIGHT).contains(&click_y_in_display) { // Use fixed region size and center on click (in global coordinates) // Global coordinates can be negative for displays left of primary - let region_width = 800; + let region_width = 800.min(screen_width.max(1)); let region_height = MENUBAR_REGION_HEIGHT.min(screen_height); - // Center horizontally on click (can be negative for multi-monitor) - let region_x = click.x - region_width / 2; + // Center horizontally on click, clamped to clicked display bounds. + let min_region_x = screen_x; + let max_region_x = (screen_x + screen_width - region_width).max(min_region_x); + let region_x = (click.x - region_width / 2).clamp(min_region_x, max_region_x); + let capture_bounds = super::window_info::WindowBounds { + x: region_x, + y: screen_y, + width: region_width as u32, + height: region_height as u32, + }; if cfg!(debug_assertions) { eprintln!( - "Top-region click at ({}, {}), using focused capture ({}, 0, {}x{})", - click.x, click.y, region_x, region_width, region_height + "Top-region click at ({}, {}), using focused capture ({}, {}, {}x{})", + click.x, click.y, region_x, screen_y, region_width, region_height ); } capture_region_best( session, region_x, - 0, + screen_y, region_width, region_height, &screenshot_path, ) - .map_err(|e| PipelineError::ScreenshotFailed(format!("{e}")))?; + .map_err(|e| PipelineError::ScreenshotFailed(format!("{e}")))?; let x_pct = if region_width > 0 { ((click.x - region_x) as f64 / region_width as f64 * 100.0).clamp(0.0, 100.0) @@ -1182,33 +2041,53 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: 50.0 }; let y_pct = if region_height > 0 { - (click.y as f64 / region_height as f64 * 100.0).clamp(0.0, 100.0) + ((click.y - screen_y) as f64 / region_height as f64 * 100.0).clamp(0.0, 100.0) } else { 50.0 }; - (x_pct, y_pct) + (x_pct, y_pct, capture_bounds) } else { // Fullscreen capture for clicks in lower screen area without window if cfg!(debug_assertions) { eprintln!("No valid window_id, using fullscreen capture"); } - capture_region_best(session, 0, 0, screen_width, screen_height, &screenshot_path) - .map_err(|e| PipelineError::ScreenshotFailed(format!("{e}")))?; + let capture_bounds = super::window_info::WindowBounds { + x: screen_x, + y: screen_y, + width: screen_width as u32, + height: screen_height as u32, + }; + capture_region_best( + session, + screen_x, + screen_y, + screen_width, + screen_height, + &screenshot_path, + ) + .map_err(|e| PipelineError::ScreenshotFailed(format!("{e}")))?; let x_pct = if screen_width > 0 { - (click.x as f64 / screen_width as f64 * 100.0).clamp(0.0, 100.0) + ((click.x - screen_x) as f64 / screen_width as f64 * 100.0).clamp(0.0, 100.0) } else { 50.0 }; let y_pct = if screen_height > 0 { - (click.y as f64 / screen_height as f64 * 100.0).clamp(0.0, 100.0) + ((click.y - screen_y) as f64 / screen_height as f64 * 100.0).clamp(0.0, 100.0) } else { 50.0 }; - (x_pct, y_pct) + (x_pct, y_pct, capture_bounds) } }; + if let (Some(ref mut ax_info), Some(ax_label)) = (ax_info.as_mut(), clicked_ax.as_ref()) { + ax_info.element_bounds = ax_label + .element_bounds + .as_ref() + .and_then(|b| bounds_percent_in_capture(b, &capture_bounds_for_step)); + } + if resolved_window_title.is_empty() { if is_sheet_dialog { resolved_window_title = "Dialog".to_string(); @@ -1222,19 +2101,9 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: let role = ax_label.role.as_str(); let label = ax_label.label; let is_menu_item = role == accessibility_sys::kAXMenuItemRole; - let is_menu_container = role == accessibility_sys::kAXMenuBarItemRole - || role == accessibility_sys::kAXMenuButtonRole; let is_button = role == accessibility_sys::kAXButtonRole || role == accessibility_sys::kAXPopUpButtonRole; - if is_menu_container && !is_menu_item { - debug_log( - session, - &format!("ignored menu open: role={role} label='{label}'"), - ); - return Err(PipelineError::IgnoredMenuOpen); - } - if is_menu_item { resolved_window_title = format!("Menu - {label}"); } else if is_button { @@ -1256,6 +2125,22 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: _ => ActionType::Click, }; + let auto_crop_region = if final_capture_status != CaptureStatus::Failed + && !is_auth_dialog + && should_apply_focus_crop(&capture_bounds_for_step, click_display_w, click_display_h) + { + suggested_focus_crop_for_capture( + &capture_bounds_for_step, + click_x_percent, + click_y_percent, + ax_info + .as_ref() + .and_then(|info| info.element_bounds.as_ref()), + ) + } else { + None + }; + // 7. Create step let screenshot = if final_capture_status == CaptureStatus::Failed { None @@ -1274,8 +2159,14 @@ pub fn process_click(click: &ClickEvent, session: &mut Session, pipeline_state: window_title: resolved_window_title, screenshot_path: screenshot, note: None, + description: None, + description_source: None, + description_status: None, + description_error: None, + ax: ax_info, capture_status: Some(final_capture_status), capture_error: final_capture_error, + crop_region: auto_crop_region, }; // 8. Add to session @@ -1350,6 +2241,17 @@ mod tests { assert!(err.to_string().contains("capture failed")); } + #[test] + fn app_name_match_normalizes_hidden_chars() { + assert!(app_names_match("‎WhatsApp", "WhatsApp")); + assert!(app_names_match("WireGuard", "wireguard")); + } + + #[test] + fn app_name_match_rejects_different_names() { + assert!(!app_names_match("Finder", "Preview")); + } + // Note: is_click_on_own_app uses the Accessibility API and requires // actual UI elements to test, so we can't easily unit test it. // It's tested manually by running the app. @@ -1358,26 +2260,46 @@ mod tests { #[test] fn tray_rect_contains_point_inside() { - let rect = TrayRect { x: 100, y: 0, width: 30, height: 22 }; + let rect = TrayRect { + x: 100, + y: 0, + width: 30, + height: 22, + }; assert!(rect.contains(115, 11)); } #[test] fn tray_rect_contains_top_left_corner() { - let rect = TrayRect { x: 100, y: 0, width: 30, height: 22 }; + let rect = TrayRect { + x: 100, + y: 0, + width: 30, + height: 22, + }; assert!(rect.contains(100, 0)); } #[test] fn tray_rect_excludes_bottom_right_edge() { - let rect = TrayRect { x: 100, y: 0, width: 30, height: 22 }; + let rect = TrayRect { + x: 100, + y: 0, + width: 30, + height: 22, + }; // x < x + width, y < y + height (exclusive) assert!(!rect.contains(130, 22)); } #[test] fn tray_rect_excludes_point_outside() { - let rect = TrayRect { x: 100, y: 0, width: 30, height: 22 }; + let rect = TrayRect { + x: 100, + y: 0, + width: 30, + height: 22, + }; assert!(!rect.contains(50, 10)); assert!(!rect.contains(200, 10)); assert!(!rect.contains(115, 30)); @@ -1387,16 +2309,26 @@ mod tests { #[test] fn panel_rect_contains_point_inside() { - let rect = PanelRect { x: 50, y: 30, width: 340, height: 554 }; + let rect = PanelRect { + x: 50, + y: 30, + width: 340, + height: 640, + }; assert!(rect.contains(200, 300)); } #[test] fn panel_rect_excludes_point_outside() { - let rect = PanelRect { x: 50, y: 30, width: 340, height: 554 }; + let rect = PanelRect { + x: 50, + y: 30, + width: 340, + height: 640, + }; assert!(!rect.contains(0, 300)); assert!(!rect.contains(400, 300)); - assert!(!rect.contains(200, 600)); + assert!(!rect.contains(200, 700)); } // --- is_debounced --- @@ -1471,11 +2403,21 @@ mod tests { ps.last_click = Some((1000, 100, 200, 1)); ps.last_auth_click_ms = Some(500); ps.last_tray_click = Some(TrayClick { - rect: TrayRect { x: 0, y: 0, width: 30, height: 22 }, + rect: TrayRect { + x: 0, + y: 0, + width: 30, + height: 22, + }, timestamp_ms: 999, }); ps.panel_state.visible = true; - ps.panel_state.rect = Some(PanelRect { x: 50, y: 30, width: 340, height: 554 }); + ps.panel_state.rect = Some(PanelRect { + x: 50, + y: 30, + width: 340, + height: 640, + }); ps.last_auth_prompt = Some((42, 1000)); ps.reset(); @@ -1514,7 +2456,12 @@ mod tests { fn filter_tray_click_within_window() { let mut ps = PipelineState::new(); ps.last_tray_click = Some(TrayClick { - rect: TrayRect { x: 100, y: 0, width: 30, height: 22 }, + rect: TrayRect { + x: 100, + y: 0, + width: 30, + height: 22, + }, timestamp_ms: 1000, }); let click = ClickEvent { @@ -1531,7 +2478,12 @@ mod tests { fn filter_tray_click_expired() { let mut ps = PipelineState::new(); ps.last_tray_click = Some(TrayClick { - rect: TrayRect { x: 100, y: 0, width: 30, height: 22 }, + rect: TrayRect { + x: 100, + y: 0, + width: 30, + height: 22, + }, timestamp_ms: 1000, }); let click = ClickEvent { @@ -1550,7 +2502,12 @@ mod tests { fn filter_panel_click_visible() { let mut ps = PipelineState::new(); ps.panel_state.visible = true; - ps.panel_state.rect = Some(PanelRect { x: 50, y: 30, width: 340, height: 554 }); + ps.panel_state.rect = Some(PanelRect { + x: 50, + y: 30, + width: 340, + height: 640, + }); let click = ClickEvent { x: 200, y: 300, @@ -1565,7 +2522,12 @@ mod tests { fn filter_panel_click_hidden() { let mut ps = PipelineState::new(); ps.panel_state.visible = false; - ps.panel_state.rect = Some(PanelRect { x: 50, y: 30, width: 340, height: 554 }); + ps.panel_state.rect = Some(PanelRect { + x: 50, + y: 30, + width: 340, + height: 640, + }); let click = ClickEvent { x: 200, y: 300, @@ -1609,11 +2571,70 @@ mod tests { assert!(should_emit_auth_prompt(&mut ps, 99, 2000)); } + #[test] + fn infer_window_control_from_subrole() { + let bounds = WindowBounds { + x: 100, + y: 100, + width: 800, + height: 600, + }; + let kind = infer_window_control_kind( + 120, + 116, + &bounds, + Some(accessibility_sys::kAXButtonRole), + Some("AXCloseButton"), + None, + ); + assert_eq!(kind, Some(WindowControlKind::Close)); + } + + #[test] + fn infer_window_control_from_geometry_fallback() { + let bounds = WindowBounds { + x: 200, + y: 80, + width: 1200, + height: 900, + }; + let kind = infer_window_control_kind( + 214, // near top-left traffic light + 96, + &bounds, + Some("AXApplication"), + None, + None, + ); + assert_eq!(kind, Some(WindowControlKind::Close)); + } + + #[test] + fn infer_window_control_rejects_non_control_area() { + let bounds = WindowBounds { + x: 200, + y: 80, + width: 1200, + height: 900, + }; + let kind = infer_window_control_kind( + 500, + 400, + &bounds, + Some(accessibility_sys::kAXButtonRole), + None, + None, + ); + assert_eq!(kind, None); + } + // --- validate_screenshot --- #[test] fn validate_screenshot_nonexistent() { - assert!(!validate_screenshot(Path::new("/tmp/definitely_not_a_real_file_xyz.png"))); + assert!(!validate_screenshot(Path::new( + "/tmp/definitely_not_a_real_file_xyz.png" + ))); } #[test] diff --git a/src-tauri/src/recorder/pipeline/types.rs b/src-tauri/src/recorder/pipeline/types.rs index 8c241ec..7cf21dc 100644 --- a/src-tauri/src/recorder/pipeline/types.rs +++ b/src-tauri/src/recorder/pipeline/types.rs @@ -29,6 +29,7 @@ pub struct PipelineState { pub last_tray_click: Option, pub panel_state: PanelState, pub last_auth_prompt: Option<(u32, i64)>, + pub last_menu_bar_click_ms: Option, } impl PipelineState { @@ -39,6 +40,7 @@ impl PipelineState { last_tray_click: None, panel_state: PanelState::new(), last_auth_prompt: None, + last_menu_bar_click_ms: None, } } @@ -87,19 +89,13 @@ impl PanelState { impl TrayRect { pub fn contains(&self, x: i32, y: i32) -> bool { - x >= self.x - && x < self.x + self.width - && y >= self.y - && y < self.y + self.height + x >= self.x && x < self.x + self.width && y >= self.y && y < self.y + self.height } } impl PanelRect { pub fn contains(&self, x: i32, y: i32) -> bool { - x >= self.x - && x < self.x + self.width - && y >= self.y - && y < self.y + self.height + x >= self.x && x < self.x + self.width && y >= self.y && y < self.y + self.height } } @@ -127,7 +123,9 @@ impl fmt::Display for PipelineError { PipelineError::ScreenshotFailed(msg) => write!(f, "screenshot failed: {msg}"), PipelineError::OwnAppClick => write!(f, "click on own app"), PipelineError::DebouncedClick => write!(f, "click debounced (too fast)"), - PipelineError::UpgradedToDblClick => write!(f, "upgraded previous step to double-click"), + PipelineError::UpgradedToDblClick => { + write!(f, "upgraded previous step to double-click") + } PipelineError::IgnoredMenuOpen => write!(f, "ignored menu open click"), } } diff --git a/src-tauri/src/recorder/pre_click_buffer.rs b/src-tauri/src/recorder/pre_click_buffer.rs new file mode 100644 index 0000000..8a2dab6 --- /dev/null +++ b/src-tauri/src/recorder/pre_click_buffer.rs @@ -0,0 +1,423 @@ +use std::collections::VecDeque; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct BufferedFrameMeta { + pub captured_at_ms: i64, +} + +pub(crate) fn pick_frame_index( + frames: &VecDeque, + click_ts_ms: i64, +) -> Option { + if frames.is_empty() { + return None; + } + + let mut latest_before_click: Option = None; + for (idx, frame) in frames.iter().enumerate() { + if frame.captured_at_ms <= click_ts_ms { + latest_before_click = Some(idx); + } + } + + latest_before_click +} + +#[cfg(target_os = "macos")] +mod imp { + use std::collections::{HashMap, VecDeque}; + use std::path::Path; + use std::sync::{Arc, Mutex}; + + use image::RgbaImage; + use screencapturekit::prelude::{ + PixelFormat, SCContentFilter, SCDisplay, SCShareableContent, SCStream, + SCStreamConfiguration, SCStreamOutputType, + }; + + use super::{pick_frame_index, BufferedFrameMeta}; + use crate::recorder::window_info::WindowBounds; + + const MAX_RING_FRAMES: usize = 4; + const TARGET_FPS: u32 = 16; + + #[derive(Debug, Clone)] + pub struct PreClickCaptureResult { + pub bounds: WindowBounds, + pub frame_age_ms: i64, + } + + #[derive(Clone)] + pub struct PreClickFrameBuffer { + inner: Arc, + } + + #[derive(Debug, Clone)] + struct DisplayTarget { + display: SCDisplay, + bounds: WindowBounds, + } + + #[derive(Debug, Clone)] + struct BufferedFrame { + meta: BufferedFrameMeta, + width: u32, + height: u32, + rgba: Vec, + } + + #[derive(Debug)] + struct StreamState { + active_display_id: u32, + stream: Option, + } + + struct PreClickFrameBufferInner { + displays: Vec, + frames_by_display: Arc>>>, + stream_state: Mutex, + } + + impl Drop for PreClickFrameBufferInner { + fn drop(&mut self) { + if let Ok(mut state) = self.stream_state.lock() { + if let Some(stream) = state.stream.take() { + let _ = stream.stop_capture(); + } + } + } + } + + impl PreClickFrameBuffer { + pub fn start() -> Result { + let content = SCShareableContent::get() + .map_err(|e| format!("ScreenCaptureKit shareable content failed: {e}"))?; + let displays_raw = content.displays(); + if displays_raw.is_empty() { + return Err("ScreenCaptureKit returned no displays".to_string()); + } + + let displays: Vec = + displays_raw.into_iter().map(display_to_target).collect(); + let initial_display = displays[0].clone(); + + let frames_by_display = + Arc::new(Mutex::new(HashMap::>::new())); + let stream = start_stream_for_display( + &initial_display.display, + initial_display.display_id(), + Arc::clone(&frames_by_display), + )?; + + Ok(Self { + inner: Arc::new(PreClickFrameBufferInner { + displays, + frames_by_display, + stream_state: Mutex::new(StreamState { + active_display_id: initial_display.display_id(), + stream: Some(stream), + }), + }), + }) + } + + pub fn stop(&self) { + if let Ok(mut state) = self.inner.stream_state.lock() { + if let Some(stream) = state.stream.take() { + let _ = stream.stop_capture(); + } + } + } + + pub fn capture_for_click( + &self, + click_x: i32, + click_y: i32, + click_ts_ms: i64, + output_path: &Path, + ) -> Result, String> { + let Some(target) = self.find_display_for_click(click_x, click_y).cloned() else { + return Ok(None); + }; + + let switched = self.ensure_stream_for_display(target.display_id())?; + if switched { + return Ok(None); + } + + let frame = { + let frame_map = self + .inner + .frames_by_display + .lock() + .map_err(|_| "pre-click frame map lock poisoned".to_string())?; + let Some(ring) = frame_map.get(&target.display_id()) else { + return Ok(None); + }; + let metas: VecDeque = ring.iter().map(|f| f.meta).collect(); + let Some(idx) = pick_frame_index(&metas, click_ts_ms) else { + return Ok(None); + }; + ring.get(idx).cloned() + }; + + let Some(frame) = frame else { + return Ok(None); + }; + + let image = RgbaImage::from_raw(frame.width, frame.height, frame.rgba) + .ok_or_else(|| "pre-click frame conversion failed".to_string())?; + image + .save(output_path) + .map_err(|e| format!("pre-click frame save failed: {e}"))?; + + let frame_age_ms = click_ts_ms.saturating_sub(frame.meta.captured_at_ms); + Ok(Some(PreClickCaptureResult { + bounds: target.bounds, + frame_age_ms, + })) + } + + fn find_display_for_click(&self, x: i32, y: i32) -> Option<&DisplayTarget> { + self.inner + .displays + .iter() + .find(|d| d.bounds.contains(x, y)) + .or_else(|| self.inner.displays.first()) + } + + fn ensure_stream_for_display(&self, display_id: u32) -> Result { + let mut state = self + .inner + .stream_state + .lock() + .map_err(|_| "pre-click stream state lock poisoned".to_string())?; + + if state.stream.is_some() && state.active_display_id == display_id { + return Ok(false); + } + + if let Some(stream) = state.stream.take() { + let _ = stream.stop_capture(); + } + + let Some(target) = self + .inner + .displays + .iter() + .find(|d| d.display_id() == display_id) + else { + return Err(format!("pre-click display not found: {display_id}")); + }; + + let stream = start_stream_for_display( + &target.display, + target.display_id(), + Arc::clone(&self.inner.frames_by_display), + )?; + state.active_display_id = display_id; + state.stream = Some(stream); + Ok(true) + } + } + + fn start_stream_for_display( + display: &SCDisplay, + display_id: u32, + frames_by_display: Arc>>>, + ) -> Result { + let filter = SCContentFilter::create() + .with_display(display) + .with_excluding_windows(&[]) + .build(); + + let config = SCStreamConfiguration::new() + .with_width(display.width()) + .with_height(display.height()) + .with_pixel_format(PixelFormat::BGRA) + .with_queue_depth(MAX_RING_FRAMES as u32) + .with_fps(TARGET_FPS) + .with_shows_cursor(true) + .with_captures_audio(false); + + let mut stream = SCStream::new(&filter, &config); + stream.add_output_handler( + move |sample: screencapturekit::cm::CMSampleBuffer, output_type| { + if output_type != SCStreamOutputType::Screen { + return; + } + if sample + .frame_status() + .map(|status| !status.has_content()) + .unwrap_or(false) + { + return; + } + + let Some(pixel_buffer) = sample.image_buffer() else { + return; + }; + let Ok(guard) = pixel_buffer.lock_read_only() else { + return; + }; + + let width = guard.width(); + let height = guard.height(); + let bytes_per_row = guard.bytes_per_row(); + if width == 0 || height == 0 || bytes_per_row < width.saturating_mul(4) { + return; + } + + let raw = guard.as_slice(); + if raw.len() < bytes_per_row.saturating_mul(height) { + return; + } + + let mut rgba = vec![0_u8; width.saturating_mul(height).saturating_mul(4)]; + for y in 0..height { + let src_row = &raw[y * bytes_per_row..y * bytes_per_row + width * 4]; + let dst_row = &mut rgba[y * width * 4..(y + 1) * width * 4]; + for x in 0..width { + let si = x * 4; + let di = si; + // ScreenCaptureKit delivers BGRA for PixelFormat::BGRA. + dst_row[di] = src_row[si + 2]; + dst_row[di + 1] = src_row[si + 1]; + dst_row[di + 2] = src_row[si]; + dst_row[di + 3] = src_row[si + 3]; + } + } + + let frame = BufferedFrame { + meta: BufferedFrameMeta { + captured_at_ms: now_ms(), + }, + width: width as u32, + height: height as u32, + rgba, + }; + + if let Ok(mut map) = frames_by_display.lock() { + let ring = map.entry(display_id).or_default(); + push_frame(ring, frame, MAX_RING_FRAMES); + } + }, + SCStreamOutputType::Screen, + ); + + stream + .start_capture() + .map_err(|e| format!("ScreenCaptureKit start_capture failed: {e}"))?; + + Ok(stream) + } + + fn push_frame(ring: &mut VecDeque, frame: BufferedFrame, max_frames: usize) { + ring.push_back(frame); + while ring.len() > max_frames { + let _ = ring.pop_front(); + } + } + + fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 + } + + fn display_to_target(display: SCDisplay) -> DisplayTarget { + let frame = display.frame(); + DisplayTarget { + bounds: WindowBounds { + x: frame.x.round() as i32, + y: frame.y.round() as i32, + width: display.width(), + height: display.height(), + }, + display, + } + } + + impl DisplayTarget { + fn display_id(&self) -> u32 { + self.display.display_id() + } + } + + impl WindowBounds { + fn contains(&self, x: i32, y: i32) -> bool { + x >= self.x + && x < self.x + self.width as i32 + && y >= self.y + && y < self.y + self.height as i32 + } + } +} + +#[cfg(not(target_os = "macos"))] +mod imp { + use std::path::Path; + + use crate::recorder::window_info::WindowBounds; + + #[derive(Debug, Clone)] + pub struct PreClickCaptureResult { + pub bounds: WindowBounds, + pub frame_age_ms: i64, + } + + #[derive(Clone)] + pub struct PreClickFrameBuffer; + + impl PreClickFrameBuffer { + pub fn start() -> Result { + Err("pre-click buffer is only available on macOS".to_string()) + } + + pub fn stop(&self) {} + + pub fn capture_for_click( + &self, + _click_x: i32, + _click_y: i32, + _click_ts_ms: i64, + _output_path: &Path, + ) -> Result, String> { + Ok(None) + } + } +} + +pub use imp::PreClickFrameBuffer; + +#[cfg(test)] +mod tests { + use super::*; + + fn frames(ts: &[i64]) -> VecDeque { + ts.iter() + .map(|t| BufferedFrameMeta { captured_at_ms: *t }) + .collect() + } + + #[test] + fn pick_frame_prefers_latest_before_click() { + let ring = frames(&[1_000, 1_020, 1_050, 1_080]); + let idx = pick_frame_index(&ring, 1_055).expect("frame index"); + assert_eq!(idx, 2); + } + + #[test] + fn pick_frame_returns_none_if_all_frames_are_after_click() { + let ring = frames(&[2_000, 2_050, 2_100]); + let idx = pick_frame_index(&ring, 1_900); + assert_eq!(idx, None); + } + + #[test] + fn pick_frame_returns_none_for_empty_ring() { + let ring = VecDeque::::new(); + assert_eq!(pick_frame_index(&ring, 42), None); + } +} diff --git a/src-tauri/src/recorder/session.rs b/src-tauri/src/recorder/session.rs index a21e17f..2691de4 100644 --- a/src-tauri/src/recorder/session.rs +++ b/src-tauri/src/recorder/session.rs @@ -1,4 +1,4 @@ -use super::types::Step; +use super::types::{BoundsPercent, DescriptionSource, DescriptionStatus, Step}; use serde::Serialize; use std::path::PathBuf; use uuid::Uuid; @@ -85,6 +85,86 @@ impl Session { self.steps.last_mut() } + /// Update a step's note by ID. Returns the updated step or None if not found. + pub fn update_step_note(&mut self, step_id: &str, note: Option) -> Option<&Step> { + let step = self.steps.iter_mut().find(|s| s.id == step_id)?; + step.note = note; + Some(step) + } + + /// Update a step's crop region by ID. `None` resets to full image. + pub fn update_step_crop( + &mut self, + step_id: &str, + crop_region: Option, + ) -> Option<&Step> { + let step = self.steps.iter_mut().find(|s| s.id == step_id)?; + step.crop_region = crop_region; + Some(step) + } + + /// Set a step's manual description. Passing `None` clears the description and related metadata. + pub fn set_step_description_manual( + &mut self, + step_id: &str, + description: Option, + ) -> Option<&Step> { + let step = self.steps.iter_mut().find(|s| s.id == step_id)?; + let desc = description + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + step.description = desc; + step.description_source = step.description.as_ref().map(|_| DescriptionSource::Manual); + step.description_status = None; + step.description_error = None; + Some(step) + } + + /// Apply an AI-generated description to a step. + pub fn apply_step_description_ai( + &mut self, + step_id: &str, + description: String, + ) -> Option<&Step> { + let step = self.steps.iter_mut().find(|s| s.id == step_id)?; + let desc = description.trim().to_string(); + if desc.is_empty() { + return None; + } + step.description = Some(desc); + step.description_source = Some(DescriptionSource::Ai); + step.description_status = Some(DescriptionStatus::Idle); + step.description_error = None; + Some(step) + } + + /// Mark a step description generation as failed. + pub fn mark_step_description_failed(&mut self, step_id: &str, error: String) -> Option<&Step> { + let step = self.steps.iter_mut().find(|s| s.id == step_id)?; + step.description_status = Some(DescriptionStatus::Failed); + step.description_error = Some(error); + Some(step) + } + + /// Remove a step by ID. Returns true if found and removed. + pub fn delete_step(&mut self, step_id: &str) -> bool { + let before = self.steps.len(); + self.steps.retain(|s| s.id != step_id); + self.steps.len() < before + } + + /// Reorder steps to match the given ID sequence. + /// IDs not in the list are dropped; unknown IDs are ignored. + pub fn reorder_steps(&mut self, step_ids: &[String]) { + let mut reordered = Vec::with_capacity(step_ids.len()); + for id in step_ids { + if let Some(pos) = self.steps.iter().position(|s| s.id == *id) { + reordered.push(self.steps.swap_remove(pos)); + } + } + self.steps = reordered; + } + pub fn next_step_id(&self) -> String { format!("step-{:03}", self.steps.len() + 1) } @@ -133,6 +213,51 @@ mod tests { std::fs::remove_dir_all(&session.temp_dir).ok(); } + #[test] + fn update_step_note_sets_note() { + let mut session = Session::new().expect("create session"); + session.add_step(Step::sample()); + + let updated = session.update_step_note("step-1", Some("Hello".into())); + assert!(updated.is_some()); + assert_eq!(updated.unwrap().note, Some("Hello".into())); + + // Clear note + let updated = session.update_step_note("step-1", None); + assert!(updated.is_some()); + assert_eq!(updated.unwrap().note, None); + + // Nonexistent step + assert!(session + .update_step_note("nonexistent", Some("x".into())) + .is_none()); + + std::fs::remove_dir_all(&session.temp_dir).ok(); + } + + #[test] + fn update_step_crop_sets_crop_region() { + let mut session = Session::new().expect("create session"); + session.add_step(Step::sample()); + + let crop = BoundsPercent { + x_percent: 10.0, + y_percent: 20.0, + width_percent: 60.0, + height_percent: 50.0, + }; + let updated = session.update_step_crop("step-1", Some(crop.clone())); + assert!(updated.is_some()); + assert_eq!(updated.unwrap().crop_region, Some(crop)); + + let updated = session.update_step_crop("step-1", None); + assert!(updated.is_some()); + assert_eq!(updated.unwrap().crop_region, None); + + assert!(session.update_step_crop("missing", None).is_none()); + std::fs::remove_dir_all(&session.temp_dir).ok(); + } + #[test] fn write_diagnostics_creates_json() { let mut session = Session::new().expect("create session"); @@ -140,7 +265,10 @@ mod tests { session.diagnostics.clicks_filtered = 3; session.diagnostics.captures_fallback = 1; session.diagnostics.captures_failed = 0; - session.diagnostics.failure_reasons.push("window capture produced empty file".into()); + session + .diagnostics + .failure_reasons + .push("window capture produced empty file".into()); session.write_diagnostics(); @@ -152,7 +280,10 @@ mod tests { assert_eq!(parsed["clicks_filtered"], 3); assert_eq!(parsed["captures_fallback"], 1); assert_eq!(parsed["captures_failed"], 0); - assert_eq!(parsed["failure_reasons"][0], "window capture produced empty file"); + assert_eq!( + parsed["failure_reasons"][0], + "window capture produced empty file" + ); // Cleanup std::fs::remove_dir_all(&session.temp_dir).ok(); diff --git a/src-tauri/src/recorder/types.rs b/src-tauri/src/recorder/types.rs index 744e853..6d90a39 100644 --- a/src-tauri/src/recorder/types.rs +++ b/src-tauri/src/recorder/types.rs @@ -20,6 +20,66 @@ pub enum CaptureStatus { Failed, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DescriptionSource { + Ai, + Manual, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DescriptionStatus { + Idle, + Generating, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AxClickInfo { + pub role: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub subrole: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub role_description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub identifier: Option, + pub label: String, + /// Bounds of the clicked element within the captured screenshot (percent, origin top-left). + #[serde(skip_serializing_if = "Option::is_none")] + pub element_bounds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub container_role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub container_subrole: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub container_identifier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub window_role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub window_subrole: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_level_role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_level_subrole: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_dialog_role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_dialog_subrole: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_checked: Option, + pub is_cancel_button: bool, + pub is_default_button: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BoundsPercent { + pub x_percent: f32, + pub y_percent: f32, + pub width_percent: f32, + pub height_percent: f32, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Step { pub id: String, @@ -33,12 +93,27 @@ pub struct Step { pub window_title: String, pub screenshot_path: Option, pub note: Option, + /// Optional enhanced description (e.g. Apple Intelligence). When absent, exporters fall back to templates. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description_source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description_error: Option, + /// Best-effort Accessibility metadata for grounding descriptions. + #[serde(skip_serializing_if = "Option::is_none")] + pub ax: Option, /// How the screenshot capture resolved. `None` for legacy steps. #[serde(skip_serializing_if = "Option::is_none")] pub capture_status: Option, /// Human-readable reason when capture_status is Fallback or Failed. #[serde(skip_serializing_if = "Option::is_none")] pub capture_error: Option, + /// Optional non-destructive crop region within the screenshot (percent, origin top-left). + #[serde(skip_serializing_if = "Option::is_none")] + pub crop_region: Option, } #[cfg(test)] @@ -56,8 +131,14 @@ impl Step { window_title: "Downloads".to_string(), screenshot_path: Some("screenshots/step-001.png".to_string()), note: None, + description: None, + description_source: None, + description_status: None, + description_error: None, + ax: None, capture_status: None, capture_error: None, + crop_region: None, } } } diff --git a/src-tauri/src/recorder/window_info/auth.rs b/src-tauri/src/recorder/window_info/auth.rs index 8cdd751..f0c5f1f 100644 --- a/src-tauri/src/recorder/window_info/auth.rs +++ b/src-tauri/src/recorder/window_info/auth.rs @@ -87,7 +87,12 @@ fn now_ms() -> i64 { .as_millis() as i64 } -fn window_is_recent(cache: &WindowRecencyCache, window_id: u32, now_ms: i64, cfg: &AuthHeuristicConfig) -> bool { +fn window_is_recent( + cache: &WindowRecencyCache, + window_id: u32, + now_ms: i64, + cfg: &AuthHeuristicConfig, +) -> bool { if !cache.initialized { return false; } @@ -97,7 +102,11 @@ fn window_is_recent(cache: &WindowRecencyCache, window_id: u32, now_ms: i64, cfg } } -fn score_auth_candidate(candidate: &mut AuthWindowCandidate, cfg: &AuthHeuristicConfig, clicked_info_missing: bool) -> i32 { +fn score_auth_candidate( + candidate: &mut AuthWindowCandidate, + cfg: &AuthHeuristicConfig, + clicked_info_missing: bool, +) -> i32 { let mut score = 0; if candidate.layer >= cfg.layer_modal { @@ -171,7 +180,10 @@ pub fn get_security_agent_window() -> Result, WindowError> { let windows: Vec = unsafe { let count = core_foundation::array::CFArrayGetCount(window_list as _); (0..count) - .map(|i| core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) as CFDictionaryRef) + .map(|i| { + core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) + as CFDictionaryRef + }) .collect() }; @@ -188,8 +200,7 @@ pub fn get_security_agent_window() -> Result, WindowError> { let window_id = dict .find(window_id_key) .and_then(|v| { - let num: CFNumber = - unsafe { CFNumber::wrap_under_get_rule(v.as_CFTypeRef() as _) }; + let num: CFNumber = unsafe { CFNumber::wrap_under_get_rule(v.as_CFTypeRef() as _) }; num.to_i32().map(|n| n as u32) }) .unwrap_or(0); @@ -260,8 +271,7 @@ pub fn get_security_agent_window() -> Result, WindowError> { let layer = dict .find(layer_key) .and_then(|v| { - let num: CFNumber = - unsafe { CFNumber::wrap_under_get_rule(v.as_CFTypeRef() as _) }; + let num: CFNumber = unsafe { CFNumber::wrap_under_get_rule(v.as_CFTypeRef() as _) }; num.to_i32() }) .unwrap_or(0); @@ -301,9 +311,12 @@ pub fn get_security_agent_window() -> Result, WindowError> { if cfg!(debug_assertions) { eprintln!( "Found security agent window: '{}' id={} bounds=({}, {}, {}x{})", - c.info.app_name, c.info.window_id, - c.info.bounds.x, c.info.bounds.y, - c.info.bounds.width, c.info.bounds.height + c.info.app_name, + c.info.window_id, + c.info.bounds.x, + c.info.bounds.y, + c.info.bounds.width, + c.info.bounds.height ); } } @@ -369,7 +382,10 @@ pub fn find_auth_dialog_window( let windows: Vec = unsafe { let count = core_foundation::array::CFArrayGetCount(window_list as _); (0..count) - .map(|i| core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) as CFDictionaryRef) + .map(|i| { + core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) + as CFDictionaryRef + }) .collect() }; @@ -405,15 +421,35 @@ pub fn find_auth_dialog_window( let bounds_key = CFString::new("kCGWindowBounds"); let bounds = match dict.find(bounds_key) { Some(v) => { - let bounds_dict: core_foundation::dictionary::CFDictionary = - unsafe { core_foundation::dictionary::CFDictionary::wrap_under_get_rule(v.as_CFTypeRef() as _) }; + let bounds_dict: core_foundation::dictionary::CFDictionary = unsafe { + core_foundation::dictionary::CFDictionary::wrap_under_get_rule( + v.as_CFTypeRef() as _ + ) + }; - let x = bounds_dict.find(CFString::new("X")).and_then(|n| n.to_i32()).unwrap_or(0); - let y = bounds_dict.find(CFString::new("Y")).and_then(|n| n.to_i32()).unwrap_or(0); - let width = bounds_dict.find(CFString::new("Width")).and_then(|n| n.to_i32()).unwrap_or(0) as u32; - let height = bounds_dict.find(CFString::new("Height")).and_then(|n| n.to_i32()).unwrap_or(0) as u32; + let x = bounds_dict + .find(CFString::new("X")) + .and_then(|n| n.to_i32()) + .unwrap_or(0); + let y = bounds_dict + .find(CFString::new("Y")) + .and_then(|n| n.to_i32()) + .unwrap_or(0); + let width = bounds_dict + .find(CFString::new("Width")) + .and_then(|n| n.to_i32()) + .unwrap_or(0) as u32; + let height = bounds_dict + .find(CFString::new("Height")) + .and_then(|n| n.to_i32()) + .unwrap_or(0) as u32; - WindowBounds { x, y, width, height } + WindowBounds { + x, + y, + width, + height, + } } None => continue, }; @@ -543,8 +579,10 @@ pub fn find_auth_dialog_window( if candidate.layer > current.layer { true } else if candidate.layer == current.layer { - let cand_area = candidate.info.bounds.width as u64 * candidate.info.bounds.height as u64; - let cur_area = current.info.bounds.width as u64 * current.info.bounds.height as u64; + let cand_area = candidate.info.bounds.width as u64 + * candidate.info.bounds.height as u64; + let cur_area = + current.info.bounds.width as u64 * current.info.bounds.height as u64; cand_area > cur_area } else { false @@ -597,10 +635,12 @@ mod tests { #[test] fn auth_candidate_scoring_balanced() { - let mut cfg = AuthHeuristicConfig::default(); - cfg.layer_modal = 10; - cfg.layer_status = 5; - cfg.score_threshold = 6; + let cfg = AuthHeuristicConfig { + layer_modal: 10, + layer_status: 5, + score_threshold: 6, + ..Default::default() + }; let mut candidate = AuthWindowCandidate { info: WindowInfo::sample(), @@ -621,8 +661,10 @@ mod tests { #[test] fn window_recency_logic() { - let mut cfg = AuthHeuristicConfig::default(); - cfg.recent_window_ms = 500; + let cfg = AuthHeuristicConfig { + recent_window_ms: 500, + ..Default::default() + }; let cache = WindowRecencyCache { initialized: true, diff --git a/src-tauri/src/recorder/window_info/mod.rs b/src-tauri/src/recorder/window_info/mod.rs index cc39777..a4298c0 100644 --- a/src-tauri/src/recorder/window_info/mod.rs +++ b/src-tauri/src/recorder/window_info/mod.rs @@ -1,15 +1,17 @@ // Some functions are kept for potential future use #![allow(dead_code, unused_imports)] -mod types; mod auth; mod query; mod topmost; +mod types; -pub use types::{WindowBounds, WindowError, WindowInfo}; pub use auth::{find_auth_dialog_window, get_security_agent_window}; -pub use query::{get_frontmost_window, get_main_window_for_pid, get_window_at_click}; +pub use query::{ + get_frontmost_window, get_main_window_for_pid, get_window_at_click, get_window_for_pid_at_click, +}; pub use topmost::{find_attached_dialog_window, get_topmost_window_at_point}; +pub use types::{WindowBounds, WindowError, WindowInfo}; #[cfg(test)] mod tests { @@ -31,9 +33,15 @@ mod tests { fn system_ui_filter_catches_dock() { assert!(super::super::ax_helpers::is_system_ui_process("Dock")); assert!(super::super::ax_helpers::is_system_ui_process("dock")); - assert!(super::super::ax_helpers::is_system_ui_process("WindowServer")); - assert!(super::super::ax_helpers::is_system_ui_process("SystemUIServer")); - assert!(super::super::ax_helpers::is_system_ui_process("ControlCenter")); + assert!(super::super::ax_helpers::is_system_ui_process( + "WindowServer" + )); + assert!(super::super::ax_helpers::is_system_ui_process( + "SystemUIServer" + )); + assert!(super::super::ax_helpers::is_system_ui_process( + "ControlCenter" + )); } #[test] diff --git a/src-tauri/src/recorder/window_info/query.rs b/src-tauri/src/recorder/window_info/query.rs index 4e8c9c1..745fe33 100644 --- a/src-tauri/src/recorder/window_info/query.rs +++ b/src-tauri/src/recorder/window_info/query.rs @@ -16,10 +16,12 @@ pub fn get_window_at_click(click_x: i32, click_y: i32) -> Result Result = unsafe { let count = core_foundation::array::CFArrayGetCount(window_list as _); (0..count) - .map(|i| core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) as CFDictionaryRef) + .map(|i| { + core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) + as CFDictionaryRef + }) .collect() }; // Find the topmost window of this app that contains the click point for window_dict in windows { - let dict = unsafe { core_foundation::dictionary::CFDictionary::::wrap_under_get_rule(window_dict) }; + let dict = unsafe { + core_foundation::dictionary::CFDictionary::::wrap_under_get_rule( + window_dict, + ) + }; // Check if window belongs to frontmost app let owner_pid_key = CFString::new("kCGWindowOwnerPID"); if let Some(owner_pid) = dict.find(owner_pid_key) { - let owner_pid: CFNumber = unsafe { CFNumber::wrap_under_get_rule(owner_pid.as_CFTypeRef() as _) }; + let owner_pid: CFNumber = + unsafe { CFNumber::wrap_under_get_rule(owner_pid.as_CFTypeRef() as _) }; if let Some(owner_pid_val) = owner_pid.to_i32() { if owner_pid_val != pid { continue; @@ -97,7 +107,8 @@ pub fn get_window_at_click(click_x: i32, click_y: i32) -> Result Result Result Result { // Get frontmost app let workspace = NSWorkspace::sharedWorkspace(); - let frontmost = workspace.frontmostApplication() + let frontmost = workspace + .frontmostApplication() .ok_or(WindowError::NoFrontmostApp)?; - let app_name = frontmost.localizedName() + let app_name = frontmost + .localizedName() .map(|n| n.to_string()) .unwrap_or_else(|| "Unknown".to_string()); @@ -176,7 +195,10 @@ pub fn get_frontmost_window() -> Result { let windows: Vec = unsafe { let count = core_foundation::array::CFArrayGetCount(window_list as _); (0..count) - .map(|i| core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) as CFDictionaryRef) + .map(|i| { + core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) + as CFDictionaryRef + }) .collect() }; @@ -185,12 +207,17 @@ pub fn get_frontmost_window() -> Result { let mut best_area: u64 = 0; for window_dict in windows { - let dict = unsafe { core_foundation::dictionary::CFDictionary::::wrap_under_get_rule(window_dict) }; + let dict = unsafe { + core_foundation::dictionary::CFDictionary::::wrap_under_get_rule( + window_dict, + ) + }; // Check if window belongs to frontmost app let owner_pid_key = CFString::new("kCGWindowOwnerPID"); if let Some(owner_pid) = dict.find(owner_pid_key) { - let owner_pid: CFNumber = unsafe { CFNumber::wrap_under_get_rule(owner_pid.as_CFTypeRef() as _) }; + let owner_pid: CFNumber = + unsafe { CFNumber::wrap_under_get_rule(owner_pid.as_CFTypeRef() as _) }; if let Some(owner_pid_val) = owner_pid.to_i32() { if owner_pid_val != pid { continue; @@ -200,7 +227,8 @@ pub fn get_frontmost_window() -> Result { // Get window ID let window_id_key = CFString::new("kCGWindowNumber"); - let window_id = dict.find(window_id_key) + let window_id = dict + .find(window_id_key) .and_then(|v| { let num: CFNumber = unsafe { CFNumber::wrap_under_get_rule(v.as_CFTypeRef() as _) }; num.to_i32().map(|n| n as u32) @@ -213,7 +241,8 @@ pub fn get_frontmost_window() -> Result { // Get window title let title_key = CFString::new("kCGWindowName"); - let window_title = dict.find(title_key) + let window_title = dict + .find(title_key) .map(|v| { let s: CFString = unsafe { CFString::wrap_under_get_rule(v.as_CFTypeRef() as _) }; s.to_string() @@ -262,9 +291,12 @@ pub fn get_frontmost_window() -> Result { if cfg!(debug_assertions) { eprintln!( "Main window: '{}' id={} bounds=({}, {}, {}x{})", - window.window_title, window.window_id, - window.bounds.x, window.bounds.y, - window.bounds.width, window.bounds.height + window.window_title, + window.window_id, + window.bounds.x, + window.bounds.y, + window.bounds.width, + window.bounds.height ); } return Ok(window); @@ -275,7 +307,12 @@ pub fn get_frontmost_window() -> Result { app_name, window_title: String::new(), window_id: 0, - bounds: WindowBounds { x: 0, y: 0, width: 800, height: 600 }, + bounds: WindowBounds { + x: 0, + y: 0, + width: 800, + height: 600, + }, }) } @@ -302,7 +339,10 @@ pub fn get_main_window_for_pid(pid: i32, app_name: &str) -> Option { let windows: Vec = unsafe { let count = core_foundation::array::CFArrayGetCount(window_list as _); (0..count) - .map(|i| core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) as CFDictionaryRef) + .map(|i| { + core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) + as CFDictionaryRef + }) .collect() }; @@ -310,11 +350,16 @@ pub fn get_main_window_for_pid(pid: i32, app_name: &str) -> Option { let mut best_area: u64 = 0; for window_dict in windows { - let dict = unsafe { core_foundation::dictionary::CFDictionary::::wrap_under_get_rule(window_dict) }; + let dict = unsafe { + core_foundation::dictionary::CFDictionary::::wrap_under_get_rule( + window_dict, + ) + }; let owner_pid_key = CFString::new("kCGWindowOwnerPID"); if let Some(owner_pid) = dict.find(owner_pid_key) { - let owner_pid: CFNumber = unsafe { CFNumber::wrap_under_get_rule(owner_pid.as_CFTypeRef() as _) }; + let owner_pid: CFNumber = + unsafe { CFNumber::wrap_under_get_rule(owner_pid.as_CFTypeRef() as _) }; if let Some(owner_pid_val) = owner_pid.to_i32() { if owner_pid_val != pid { continue; @@ -323,7 +368,8 @@ pub fn get_main_window_for_pid(pid: i32, app_name: &str) -> Option { } let window_id_key = CFString::new("kCGWindowNumber"); - let window_id = dict.find(window_id_key) + let window_id = dict + .find(window_id_key) .and_then(|v| { let num: CFNumber = unsafe { CFNumber::wrap_under_get_rule(v.as_CFTypeRef() as _) }; num.to_i32().map(|n| n as u32) @@ -335,7 +381,8 @@ pub fn get_main_window_for_pid(pid: i32, app_name: &str) -> Option { } let title_key = CFString::new("kCGWindowName"); - let window_title = dict.find(title_key) + let window_title = dict + .find(title_key) .map(|v| { let s: CFString = unsafe { CFString::wrap_under_get_rule(v.as_CFTypeRef() as _) }; s.to_string() @@ -380,3 +427,144 @@ pub fn get_main_window_for_pid(pid: i32, app_name: &str) -> Option { best_window } + +/// Find the topmost visible window for a given PID that contains the click point. +/// This is useful when an app has multiple windows and we need the local context +/// for popup/overlay captures. +#[cfg(target_os = "macos")] +pub fn get_window_for_pid_at_click( + pid: i32, + app_name: &str, + click_x: i32, + click_y: i32, + exclude_window_id: Option, +) -> Option { + use core_foundation::base::{CFType, TCFType}; + use core_foundation::dictionary::CFDictionaryRef; + use core_foundation::number::CFNumber; + use core_foundation::string::CFString; + use core_graphics::display::*; + + let window_list = unsafe { + CGWindowListCopyWindowInfo( + kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements, + kCGNullWindowID, + ) + }; + + if window_list.is_null() { + return None; + } + + let windows: Vec = unsafe { + let count = core_foundation::array::CFArrayGetCount(window_list as _); + (0..count) + .map(|i| { + core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) + as CFDictionaryRef + }) + .collect() + }; + + let mut best_window: Option = None; + let mut best_area: u64 = 0; + + for window_dict in windows { + let dict = unsafe { + core_foundation::dictionary::CFDictionary::::wrap_under_get_rule( + window_dict, + ) + }; + + let owner_pid_key = CFString::new("kCGWindowOwnerPID"); + let owner_matches = dict.find(owner_pid_key).and_then(|owner_pid| { + let owner_pid: CFNumber = + unsafe { CFNumber::wrap_under_get_rule(owner_pid.as_CFTypeRef() as _) }; + owner_pid.to_i32() + }) == Some(pid); + if !owner_matches { + continue; + } + + let bounds_key = CFString::new("kCGWindowBounds"); + let bounds = dict.find(bounds_key).map(|v| { + let bounds_dict: core_foundation::dictionary::CFDictionary = + unsafe { + core_foundation::dictionary::CFDictionary::wrap_under_get_rule( + v.as_CFTypeRef() as _, + ) + }; + + let x = bounds_dict + .find(CFString::new("X")) + .and_then(|n| n.to_i32()) + .unwrap_or(0); + let y = bounds_dict + .find(CFString::new("Y")) + .and_then(|n| n.to_i32()) + .unwrap_or(0); + let width = bounds_dict + .find(CFString::new("Width")) + .and_then(|n| n.to_i32()) + .unwrap_or(0) as u32; + let height = bounds_dict + .find(CFString::new("Height")) + .and_then(|n| n.to_i32()) + .unwrap_or(0) as u32; + WindowBounds { + x, + y, + width, + height, + } + })?; + + if bounds.width < 10 || bounds.height < 10 { + continue; + } + + let inside_x = click_x >= bounds.x && click_x < bounds.x + bounds.width as i32; + let inside_y = click_y >= bounds.y && click_y < bounds.y + bounds.height as i32; + if !inside_x || !inside_y { + continue; + } + + let window_id_key = CFString::new("kCGWindowNumber"); + let window_id = dict + .find(window_id_key) + .and_then(|v| { + let num: CFNumber = unsafe { CFNumber::wrap_under_get_rule(v.as_CFTypeRef() as _) }; + num.to_i32().map(|n| n as u32) + }) + .unwrap_or(0); + if window_id == 0 { + continue; + } + if exclude_window_id.is_some_and(|id| id == window_id) { + continue; + } + + let title_key = CFString::new("kCGWindowName"); + let window_title = dict + .find(title_key) + .map(|v| { + let s: CFString = unsafe { CFString::wrap_under_get_rule(v.as_CFTypeRef() as _) }; + s.to_string() + }) + .unwrap_or_default(); + + let candidate = WindowInfo { + app_name: app_name.to_string(), + window_title, + window_id, + bounds, + }; + let area = candidate.bounds.width as u64 * candidate.bounds.height as u64; + if area > best_area { + best_area = area; + best_window = Some(candidate); + } + } + + best_window +} diff --git a/src-tauri/src/recorder/window_info/topmost.rs b/src-tauri/src/recorder/window_info/topmost.rs index a607d7c..483aa03 100644 --- a/src-tauri/src/recorder/window_info/topmost.rs +++ b/src-tauri/src/recorder/window_info/topmost.rs @@ -21,6 +21,19 @@ fn get_process_name_by_pid(pid: i32) -> Option { None } +fn normalize_app_name(name: &str) -> String { + name.chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(|c| c.to_lowercase()) + .collect() +} + +fn app_names_match(left: &str, right: &str) -> bool { + let left_norm = normalize_app_name(left); + let right_norm = normalize_app_name(right); + !left_norm.is_empty() && left_norm == right_norm +} + /// Get the topmost on-screen window at the given click point. /// This checks ALL windows (not just the frontmost app) to properly capture /// popup menus, context menus, and other overlay windows. @@ -46,7 +59,10 @@ pub fn get_topmost_window_at_point(click_x: i32, click_y: i32) -> Option = unsafe { let count = core_foundation::array::CFArrayGetCount(window_list as _); (0..count) - .map(|i| core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) as CFDictionaryRef) + .map(|i| { + core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) + as CFDictionaryRef + }) .collect() }; @@ -54,7 +70,9 @@ pub fn get_topmost_window_at_point(click_x: i32, click_y: i32) -> Option::wrap_under_get_rule(window_dict) + core_foundation::dictionary::CFDictionary::::wrap_under_get_rule( + window_dict, + ) }; // Get window layer - skip desktop/wallpaper level windows @@ -76,15 +94,35 @@ pub fn get_topmost_window_at_point(click_x: i32, click_y: i32) -> Option { - let bounds_dict: core_foundation::dictionary::CFDictionary = - unsafe { core_foundation::dictionary::CFDictionary::wrap_under_get_rule(v.as_CFTypeRef() as _) }; - - let x = bounds_dict.find(CFString::new("X")).and_then(|n| n.to_i32()).unwrap_or(0); - let y = bounds_dict.find(CFString::new("Y")).and_then(|n| n.to_i32()).unwrap_or(0); - let width = bounds_dict.find(CFString::new("Width")).and_then(|n| n.to_i32()).unwrap_or(0) as u32; - let height = bounds_dict.find(CFString::new("Height")).and_then(|n| n.to_i32()).unwrap_or(0) as u32; - - WindowBounds { x, y, width, height } + let bounds_dict: core_foundation::dictionary::CFDictionary = unsafe { + core_foundation::dictionary::CFDictionary::wrap_under_get_rule( + v.as_CFTypeRef() as _ + ) + }; + + let x = bounds_dict + .find(CFString::new("X")) + .and_then(|n| n.to_i32()) + .unwrap_or(0); + let y = bounds_dict + .find(CFString::new("Y")) + .and_then(|n| n.to_i32()) + .unwrap_or(0); + let width = bounds_dict + .find(CFString::new("Width")) + .and_then(|n| n.to_i32()) + .unwrap_or(0) as u32; + let height = bounds_dict + .find(CFString::new("Height")) + .and_then(|n| n.to_i32()) + .unwrap_or(0) as u32; + + WindowBounds { + x, + y, + width, + height, + } } None => continue, }; @@ -118,12 +156,10 @@ pub fn get_topmost_window_at_point(click_x: i32, click_y: i32) -> Option Option Option, ) -> Option { use core_foundation::base::{CFType, TCFType}; use core_foundation::dictionary::CFDictionaryRef; @@ -215,7 +254,10 @@ pub fn find_attached_dialog_window( let windows: Vec = unsafe { let count = core_foundation::array::CFArrayGetCount(window_list as _); (0..count) - .map(|i| core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) as CFDictionaryRef) + .map(|i| { + core_foundation::array::CFArrayGetValueAtIndex(window_list as _, i) + as CFDictionaryRef + }) .collect() }; @@ -225,24 +267,74 @@ pub fn find_attached_dialog_window( let main_top = main.y; let main_right = main.x + main.width as i32; let main_bottom = main.y + main.height as i32; + let expected_owner_pid = expected_owner.map(|(pid, _)| pid); + let expected_owner_name = expected_owner.map(|(_, name)| name); + + let mut main_owner_pid: Option = None; + for window_dict in &windows { + let dict = unsafe { + core_foundation::dictionary::CFDictionary::::wrap_under_get_rule( + *window_dict, + ) + }; + let window_id_key = CFString::new("kCGWindowNumber"); + let window_id = dict + .find(window_id_key) + .and_then(|v| { + let num: CFNumber = unsafe { CFNumber::wrap_under_get_rule(v.as_CFTypeRef() as _) }; + num.to_i32().map(|n| n as u32) + }) + .unwrap_or(0); + if window_id != main_window.window_id { + continue; + } + let owner_pid_key = CFString::new("kCGWindowOwnerPID"); + main_owner_pid = dict.find(owner_pid_key).and_then(|v| { + let num: CFNumber = unsafe { CFNumber::wrap_under_get_rule(v.as_CFTypeRef() as _) }; + num.to_i32() + }); + break; + } for window_dict in windows { let dict = unsafe { - core_foundation::dictionary::CFDictionary::::wrap_under_get_rule(window_dict) + core_foundation::dictionary::CFDictionary::::wrap_under_get_rule( + window_dict, + ) }; let bounds_key = CFString::new("kCGWindowBounds"); let bounds = match dict.find(bounds_key) { Some(v) => { - let bounds_dict: core_foundation::dictionary::CFDictionary = - unsafe { core_foundation::dictionary::CFDictionary::wrap_under_get_rule(v.as_CFTypeRef() as _) }; - - let x = bounds_dict.find(CFString::new("X")).and_then(|n| n.to_i32()).unwrap_or(0); - let y = bounds_dict.find(CFString::new("Y")).and_then(|n| n.to_i32()).unwrap_or(0); - let width = bounds_dict.find(CFString::new("Width")).and_then(|n| n.to_i32()).unwrap_or(0) as u32; - let height = bounds_dict.find(CFString::new("Height")).and_then(|n| n.to_i32()).unwrap_or(0) as u32; - - WindowBounds { x, y, width, height } + let bounds_dict: core_foundation::dictionary::CFDictionary = unsafe { + core_foundation::dictionary::CFDictionary::wrap_under_get_rule( + v.as_CFTypeRef() as _ + ) + }; + + let x = bounds_dict + .find(CFString::new("X")) + .and_then(|n| n.to_i32()) + .unwrap_or(0); + let y = bounds_dict + .find(CFString::new("Y")) + .and_then(|n| n.to_i32()) + .unwrap_or(0); + let width = bounds_dict + .find(CFString::new("Width")) + .and_then(|n| n.to_i32()) + .unwrap_or(0) as u32; + let height = bounds_dict + .find(CFString::new("Height")) + .and_then(|n| n.to_i32()) + .unwrap_or(0) as u32; + + WindowBounds { + x, + y, + width, + height, + } } None => continue, }; @@ -274,6 +366,14 @@ pub fn find_attached_dialog_window( if area >= main_area { continue; } + let area_ratio = if main_area > 0 { + area as f32 / main_area as f32 + } else { + 0.0 + }; + if !(0.04..0.95).contains(&area_ratio) { + continue; + } let right = bounds.x + bounds.width as i32; let bottom = bounds.y + bounds.height as i32; @@ -301,19 +401,18 @@ pub fn find_attached_dialog_window( } let owner_pid_key = CFString::new("kCGWindowOwnerPID"); - let owner_pid = dict - .find(owner_pid_key) - .and_then(|v| { - let num: CFNumber = unsafe { CFNumber::wrap_under_get_rule(v.as_CFTypeRef() as _) }; - num.to_i32() - }); + let owner_pid = dict.find(owner_pid_key).and_then(|v| { + let num: CFNumber = unsafe { CFNumber::wrap_under_get_rule(v.as_CFTypeRef() as _) }; + num.to_i32() + }); let app_name = if let Some(pid) = owner_pid { get_process_name_by_pid(pid).unwrap_or_else(|| { let owner_name_key = CFString::new("kCGWindowOwnerName"); dict.find(owner_name_key) .map(|v| { - let s: CFString = unsafe { CFString::wrap_under_get_rule(v.as_CFTypeRef() as _) }; + let s: CFString = + unsafe { CFString::wrap_under_get_rule(v.as_CFTypeRef() as _) }; s.to_string() }) .unwrap_or_else(|| "Unknown".to_string()) @@ -322,12 +421,30 @@ pub fn find_attached_dialog_window( let owner_name_key = CFString::new("kCGWindowOwnerName"); dict.find(owner_name_key) .map(|v| { - let s: CFString = unsafe { CFString::wrap_under_get_rule(v.as_CFTypeRef() as _) }; + let s: CFString = + unsafe { CFString::wrap_under_get_rule(v.as_CFTypeRef() as _) }; s.to_string() }) .unwrap_or_else(|| "Unknown".to_string()) }; + if super::super::ax_helpers::is_system_ui_process(&app_name) { + continue; + } + + let expected_pid = expected_owner_pid.or(main_owner_pid); + if let Some(pid) = expected_pid { + if owner_pid != Some(pid) { + continue; + } + } else if let Some(name) = expected_owner_name { + if !app_names_match(&app_name, name) { + continue; + } + } else if !app_names_match(&app_name, &main_window.app_name) { + continue; + } + let title_key = CFString::new("kCGWindowName"); let window_title = dict .find(title_key) @@ -353,6 +470,23 @@ pub fn find_attached_dialog_window( _click_x: i32, _click_y: i32, _main_window: &WindowInfo, + _expected_owner: Option<(i32, &str)>, ) -> Option { None } + +#[cfg(test)] +mod tests { + use super::app_names_match; + + #[test] + fn app_name_match_normalizes_hidden_chars() { + assert!(app_names_match("‎WhatsApp", "WhatsApp")); + assert!(app_names_match("WireGuard", "wireguard")); + } + + #[test] + fn app_name_match_rejects_different_names() { + assert!(!app_names_match("Finder", "Preview")); + } +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 7029bd2..7c0c30d 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,14 +1,14 @@ use std::path::PathBuf; use std::time::Duration; use tauri::image::Image; -use tauri::path::BaseDirectory; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; +use tauri::path::BaseDirectory; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent, TrayIconId}; use tauri::{AppHandle, Emitter, Manager}; use tauri_nspanel::ManagerExt; -use crate::panel::{panel_label, position_panel_at_tray_icon}; use crate::panel::TrayIconMetrics; +use crate::panel::{panel_label, position_panel_at_tray_icon}; use crate::recorder::pipeline::PanelRect; const TRAY_ID: &str = "tray"; @@ -153,14 +153,19 @@ fn should_hide_panel( /// Set tray to recording state with red recording icon pub fn set_recording_icon(app_handle: &AppHandle) -> tauri::Result<()> { - let tray = app_handle.tray_by_id(&TrayIconId::new(TRAY_ID)) - .ok_or_else(|| tauri::Error::Io(std::io::Error::new( - std::io::ErrorKind::NotFound, - "tray icon not found" - )))?; + let tray = app_handle + .tray_by_id(&TrayIconId::new(TRAY_ID)) + .ok_or_else(|| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "tray icon not found", + )) + })?; // Load recording icon - let icon_path = app_handle.path().resolve("icons/recording.png", BaseDirectory::Resource)?; + let icon_path = app_handle + .path() + .resolve("icons/recording.png", BaseDirectory::Resource)?; let icon = Image::from_path(icon_path)?; tray.set_icon(Some(icon))?; @@ -171,11 +176,14 @@ pub fn set_recording_icon(app_handle: &AppHandle) -> tauri::Result<()> { /// Reset tray to default state pub fn set_default_icon(app_handle: &AppHandle) -> tauri::Result<()> { - let tray = app_handle.tray_by_id(&TrayIconId::new(TRAY_ID)) - .ok_or_else(|| tauri::Error::Io(std::io::Error::new( - std::io::ErrorKind::NotFound, - "tray icon not found" - )))?; + let tray = app_handle + .tray_by_id(&TrayIconId::new(TRAY_ID)) + .ok_or_else(|| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "tray icon not found", + )) + })?; // Load default icon let icon_path = resolve_tray_icon_path(app_handle)?; @@ -225,7 +233,8 @@ pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { let icon = Image::from_path(tray_icon_path)?; let open = MenuItem::with_id(app_handle, "open", "Open StepCast", true, None::<&str>)?; - let quick_start = MenuItem::with_id(app_handle, "quick_start", "Quick Start", true, None::<&str>)?; + let quick_start = + MenuItem::with_id(app_handle, "quick_start", "Quick Start", true, None::<&str>)?; let sep = PredefinedMenuItem::separator(app_handle)?; let quit = MenuItem::with_id(app_handle, "quit", "Quit StepCast", true, None::<&str>)?; let menu = Menu::with_items(app_handle, &[&open, &quick_start, &sep, &quit])?; @@ -462,7 +471,10 @@ mod tests { size: Size::Logical((16.0, 12.0).into()), }; - assert_eq!(rect_debug(&rect), "logical pos=(10.00,20.00) size=(16.00x12.00)"); + assert_eq!( + rect_debug(&rect), + "logical pos=(10.00,20.00) size=(16.00x12.00)" + ); } #[test] diff --git a/src-tauri/swift/stepcast_ai_helper.swift b/src-tauri/swift/stepcast_ai_helper.swift new file mode 100644 index 0000000..71c3f39 --- /dev/null +++ b/src-tauri/swift/stepcast_ai_helper.swift @@ -0,0 +1,302 @@ +import Foundation +#if canImport(FoundationModels) +import FoundationModels +#endif + +struct AvailabilityResponse: Codable { + let available: Bool + let reason: String? + let details: String? +} + +struct BoundsPercent: Codable { + let xPercent: Double + let yPercent: Double + let widthPercent: Double + let heightPercent: Double +} + +struct AxInfo: Codable { + let role: String + let subrole: String? + let roleDescription: String? + let identifier: String? + let label: String + let elementBounds: BoundsPercent? + let containerRole: String? + let containerSubrole: String? + let containerIdentifier: String? + let windowRole: String? + let windowSubrole: String? + let topLevelRole: String? + let topLevelSubrole: String? + let parentDialogRole: String? + let parentDialogSubrole: String? + let isChecked: Bool? + let isCancelButton: Bool + let isDefaultButton: Bool +} + +struct StepInput: Codable { + let id: String + let action: String + let app: String + let windowTitle: String + let clickXPercent: Double + let clickYPercent: Double + let screenshotPath: String? + let note: String? + let ax: AxInfo? +} + +struct GenerateRequest: Codable { + let steps: [StepInput] + let maxChars: Int? +} + +struct GenerateResultItem: Codable { + let id: String + let text: String + let debug: GenerateResultDebug? +} + +struct GenerateResultDebug: Codable { + let kind: String + let location: String? + let groundingLabel: String + let groundingOcr: String? + let baseline: String + let candidate: String? + let qualityGateReason: String +} + +struct GenerateFailureItem: Codable { + let id: String + let error: String +} + +struct GenerateResponse: Codable { + let results: [GenerateResultItem] + let failures: [GenerateFailureItem] +} + +func encodeJSON(_ value: T) -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return (try? encoder.encode(value)) ?? Data("{}".utf8) +} + +func readStdin() -> Data { + FileHandle.standardInput.readDataToEndOfFile() +} + +func writeStdout(_ data: Data) { + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data("\n".utf8)) +} + +@inline(__always) +func unsupportedModelAvailability() -> AvailabilityResponse { + AvailabilityResponse( + available: false, + reason: "unsupported", + details: "Apple Intelligence framework is not available on this system." + ) +} + +#if canImport(FoundationModels) +func availabilityReasonCode(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> String { + switch reason { + case .deviceNotEligible: + return "deviceNotEligible" + case .appleIntelligenceNotEnabled: + return "appleIntelligenceNotEnabled" + case .modelNotReady: + return "modelNotReady" + @unknown default: + return "unavailable" + } +} + +func availabilityReasonDetails(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> String { + switch reason { + case .deviceNotEligible: + return "This device is not eligible for Apple Intelligence." + case .appleIntelligenceNotEnabled: + return "Apple Intelligence is disabled. Enable it in System Settings." + case .modelNotReady: + return "The model is not ready yet. Try again in a moment." + @unknown default: + return "Apple Intelligence is unavailable." + } +} + +func checkAvailability() -> AvailabilityResponse { + switch SystemLanguageModel.default.availability { + case .available: + return AvailabilityResponse(available: true, reason: nil, details: nil) + case .unavailable(let reason): + return AvailabilityResponse( + available: false, + reason: availabilityReasonCode(reason), + details: availabilityReasonDetails(reason), + ) + } +} +#else +func checkAvailability() -> AvailabilityResponse { + unsupportedModelAvailability() +} +#endif + +func generateDescriptions(_ req: GenerateRequest) async -> GenerateResponse { + let maxChars = max(20, min(req.maxChars ?? 110, 140)) + let availability = checkAvailability() + if !availability.available { + let failures = req.steps.map { + GenerateFailureItem(id: $0.id, error: availability.details ?? "Apple Intelligence unavailable.") + } + return GenerateResponse(results: [], failures: failures) + } + + let instructions = + "You generate concise UI tutorial step descriptions. " + + "Keep output short and specific. Never invent UI labels; use only provided context." + + var results: [GenerateResultItem] = [] + let failures: [GenerateFailureItem] = [] + results.reserveCapacity(req.steps.count) + + for step in req.steps { + do { + let kind = classifyKind(step) + let location = locationHint(step, kind: kind) + let grounding = chooseGroundingLabel(step, kind: kind) + let baseline = baselineDescription( + step, + kind: kind, + label: grounding.label, + location: location, + maxChars: maxChars + ) + + // Deterministic kinds are best handled without model calls. + if kind == "close button" || kind == "minimize button" || kind == "zoom button" + || kind == "menu item" || kind == "menu bar item" || kind == "checkbox" + { + results.append(GenerateResultItem( + id: step.id, + text: baseline, + debug: GenerateResultDebug( + kind: kind, + location: location, + groundingLabel: grounding.label, + groundingOcr: grounding.ocr, + baseline: baseline, + candidate: nil, + qualityGateReason: "deterministic_baseline" + ) + )) + continue + } + + #if canImport(FoundationModels) + let prompt = promptForStep( + step, + kind: kind, + baseline: baseline, + label: grounding.label, + ocr: grounding.ocr, + location: location, + maxChars: maxChars + ) + let session = LanguageModelSession(instructions: instructions) + let options = GenerationOptions(sampling: .greedy) + let response = try await session.respond(to: prompt, options: options) + let candidate = sanitizeDescription(response.content, maxChars: maxChars) + let decision = applyQualityGate( + step: step, + kind: kind, + baseline: baseline, + candidate: candidate, + label: grounding.label + ) + let finalText = decision.text.isEmpty ? baseline : decision.text + #else + let candidate: String? = nil + let decision = (text: baseline, reason: "model_unavailable_fallback") + let finalText = baseline + #endif + results.append(GenerateResultItem( + id: step.id, + text: finalText, + debug: GenerateResultDebug( + kind: kind, + location: location, + groundingLabel: grounding.label, + groundingOcr: grounding.ocr, + baseline: baseline, + candidate: candidate, + qualityGateReason: decision.reason + ) + )) + } catch { + // Keep UI stable even when model refuses/errs (safety, transient availability). + let kind = classifyKind(step) + let location = locationHint(step, kind: kind) + let grounding = chooseGroundingLabel(step, kind: kind) + let baseline = baselineDescription( + step, + kind: kind, + label: grounding.label, + location: location, + maxChars: maxChars + ) + results.append(GenerateResultItem( + id: step.id, + text: baseline, + debug: GenerateResultDebug( + kind: kind, + location: location, + groundingLabel: grounding.label, + groundingOcr: grounding.ocr, + baseline: baseline, + candidate: nil, + qualityGateReason: "model_error_fallback" + ) + )) + } + } + + return GenerateResponse(results: results, failures: failures) +} + +@main +struct StepCastAIHelper { + static func main() async { + let args = Array(CommandLine.arguments.dropFirst()) + guard let cmd = args.first else { + writeStdout(Data("{}".utf8)) + exit(2) + } + + switch cmd { + case "availability": + writeStdout(encodeJSON(checkAvailability())) + case "generate": + let input = readStdin() + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + guard let req = try? decoder.decode(GenerateRequest.self, from: input) else { + let resp = GenerateResponse(results: [], failures: [GenerateFailureItem(id: "*", error: "Invalid JSON input.")]) + writeStdout(encodeJSON(resp)) + exit(2) + } + let resp = await generateDescriptions(req) + writeStdout(encodeJSON(resp)) + default: + writeStdout(Data("{}".utf8)) + exit(2) + } + } +} diff --git a/src-tauri/swift/stepcast_ai_helper_descriptions.swift b/src-tauri/swift/stepcast_ai_helper_descriptions.swift new file mode 100644 index 0000000..f03325a --- /dev/null +++ b/src-tauri/swift/stepcast_ai_helper_descriptions.swift @@ -0,0 +1,1256 @@ +import Foundation + +func actionVerb(_ action: String) -> String { + switch action { + case "DoubleClick": + return "Double-click" + case "RightClick": + return "Right-click" + case "Shortcut": + return "Press" + case "Note": + return "Add a note" + default: + return "Click" + } +} + +func normalizeForMatch(_ s: String) -> String { + s + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "…", with: "...") + .replacingOccurrences(of: "\"", with: "") + .replacingOccurrences(of: "“", with: "") + .replacingOccurrences(of: "”", with: "") +} + +func stripFormatMarks(_ s: String) -> String { + s.filter { ch in + switch ch.unicodeScalars.first?.value ?? 0 { + case 0x200E, 0x200F, 0x202A, 0x202B, 0x202C, 0x2066, 0x2067, 0x2068, 0x2069: + return false + default: + return true + } + } +} + +func normalizedNameKey(_ s: String) -> String { + stripFormatMarks(s) + .lowercased() + .unicodeScalars + .filter { CharacterSet.alphanumerics.contains($0) } + .map(String.init) + .joined() +} + +func safeWindowTitleContext(_ step: StepInput) -> String? { + let w = stripFormatMarks(step.windowTitle).trimmingCharacters(in: .whitespacesAndNewlines) + if w.isEmpty { return nil } + + let lower = w.lowercased() + if lower == "menu" || lower == "dock" { return nil } + if lower == "popup" { return nil } + if lower == "ohne titel" || lower == "untitled" { return nil } + if lower.hasPrefix("menu - ") || lower.hasPrefix("dialog") || lower.hasPrefix("button - ") { return nil } + if lower.hasPrefix("click on ") { return nil } + if lower.contains("authentication dialog") { return nil } + + // Avoid bundle-like titles / noisy long titles. + if w.contains("::") { return nil } + if w.count > 32 { return nil } + + return w +} + +func cleanupOcrLabel(_ s: String) -> String { + var t = s.trimmingCharacters(in: .whitespacesAndNewlines) + if t.isEmpty { return "" } + + // Normalize slash-like glyphs and spacing artifacts first. + t = t.replacingOccurrences(of: "/", with: "/") + t = t.replacingOccurrences(of: "∕", with: "/") + t = t.replacingOccurrences(of: #"\s*/\s*"#, with: "/", options: .regularExpression) + t = t.replacingOccurrences(of: #"\s*\.\s*"#, with: ".", options: .regularExpression) + + // Drop common OCR noise prefixes like "cz " before a filename token. + let parts = t.split(separator: " ", omittingEmptySubsequences: true) + if parts.count == 2 { + let p0 = String(parts[0]) + let p1 = String(parts[1]) + if p0.count <= 2 && p0.allSatisfy({ $0.isLetter }) && looksLikeFileName(p1) { + t = p1 + } + } + + // OCR sometimes reads "l" as "/" in file names (e.g. "s-/1600.webp"). + if t.contains("-/") { + let alt = t.replacingOccurrences(of: "-/", with: "-l") + if looksLikeFileName(alt) { + t = alt + } + } + if t.contains("/") { + let alt = t.replacingOccurrences(of: "/", with: "") + if looksLikeFileName(alt) { + t = alt + } + } + + // OCR can append context-menu artifacts to file names (e.g. "foo @ öffnen"). + t = t.replacingOccurrences( + of: #"(?i)\s*@\s*(open|offnen|oeffnen|öffnen)\s*$"#, + with: "", + options: .regularExpression + ) + + return t.trimmingCharacters(in: .whitespacesAndNewlines) +} + +func isDockStep(_ step: StepInput) -> Bool { + step.app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "dock" +} + +func bestLabel(_ step: StepInput) -> String { + if let ax = step.ax { + let l = ax.label.trimmingCharacters(in: .whitespacesAndNewlines) + if !l.isEmpty { return l } + } + let title = step.windowTitle.trimmingCharacters(in: .whitespacesAndNewlines) + if title.hasPrefix("Menu - ") { return String(title.dropFirst("Menu - ".count)) } + if title.hasPrefix("Button - ") { return String(title.dropFirst("Button - ".count)) } + return "" +} + +func isGenericUiLabel(_ step: StepInput, kind: String, label: String) -> Bool { + let l = normalizeForMatch(label) + if l.isEmpty { return true } + + if let ax = step.ax { + let role = ax.role.lowercased() + if role.contains("group") + && (l == "create" + || l.hasPrefix("create ") + || l.hasPrefix("new ") + || l.hasPrefix("add ") + || l.contains(" erstellen") + || l.hasPrefix("erstelle") + || l.hasPrefix("hinzuf")) + { + return true + } + } + + // Common structural labels that are rarely meaningful UI targets. + // Keep this small + cross-app (avoid per-app rules). + if l == "editor area" + || l == "content area" + || l == "scroll area" + || l == "split group" + || l == "split view" + || l == "workspace" + || l == "popover dismiss region" + { + return true + } + if l.contains("dismiss region") || (l.contains("popover") && l.contains("dismiss")) { + return true + } + if (l.hasSuffix(" area") || l.hasSuffix(" group")) && l.split(separator: " ").count <= 2 { + return true + } + if l == "sidebar" + || l == "seitenleiste" + || l == "source list" + || l == "list" + || l == "list view" + || l == "outline view" + || l == "column view" + { + return true + } + + let app = normalizeForMatch(step.app) + if !app.isEmpty && l == app { + // When the clicked thing is literally the app menu in the menu bar, + // the label equals the app name and is meaningful. + if kind == "menu bar item" || kind == "menu item" { + return false + } + return true + } + + let w = normalizeForMatch(step.windowTitle) + if !w.isEmpty && l == w { + // In many chat/file sidebars the selected row equals the window title. + // Treating this as generic causes OCR drift ("vat" instead of "Pepper"). + if kind == "list item" { + let containerRole = (step.ax?.containerRole ?? "").lowercased() + let containerIdent = (step.ax?.containerIdentifier ?? "").lowercased() + if containerRole.contains("list") + || containerRole.contains("outline") + || containerRole.contains("table") + || containerRole.contains("collection") + || containerIdent.contains("sidebar") + || containerIdent.contains("list") + || containerIdent.contains("outline") + || containerIdent.contains("table") + || containerIdent.contains("collection") + { + return false + } + } + return true + } + + if l == "window" || l == "menu" || l == "dialog" || l == "application" { + return true + } + + return false +} + +func isLikelyViewModeLabel(_ step: StepInput, kind: String, label: String) -> Bool { + if kind != "list item" { return false } + guard let ax = step.ax else { return false } + + let role = ax.role.lowercased() + if !(role.contains("outline") || role.contains("table")) { return false } + + let l = normalizeForMatch(label) + if l.isEmpty { return false } + + // Common view-mode labels (Finder and many AppKit tables). Keep small; avoid per-app rules. + if l == "list view" || l == "outline view" || l == "column view" || l == "icon view" { return true } + if l.contains("listendarstellung") || l.contains("spaltendarstellung") || l.contains("symbolansicht") { return true } + + // Finder list container often exposes identifier like "ListView". + if let ident = ax.identifier?.lowercased(), ident.contains("listview") { + // Single-word labels here are very likely view-mode labels, not item names. + if !l.contains(" ") { return true } + } + + return false +} + +func safeQuoted(_ label: String) -> String { + let cleaned = label + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\"", with: "'") + if cleaned.isEmpty { return "" } + return "\"\(cleaned)\"" +} + +func sanitizeUiLabel(_ label: String) -> String { + var s = label.trimmingCharacters(in: .whitespacesAndNewlines) + if s.isEmpty { return "" } + + // Remove common invisible Unicode "format" marks that often appear in app AX labels. + // (e.g. LRM/RLM and bidi isolate marks). Keep it small and explicit. + s = s.filter { ch in + let v = ch.unicodeScalars.first?.value ?? 0 + switch v { + case 0x200E, 0x200F, 0x202A, 0x202B, 0x202C, 0x2066, 0x2067, 0x2068, 0x2069: + return false + case 0xE000...0xF8FF: + // Private-use icon glyphs (common in web UIs) are not user-facing labels. + return false + default: + return true + } + } + + // Drop common OCR bullet / list prefix noise: "* ", "• ", "- ". + if s.hasPrefix("* ") || s.hasPrefix("• ") || s.hasPrefix("- ") { + s = String(s.dropFirst(2)).trimmingCharacters(in: .whitespacesAndNewlines) + } + + // Collapse repeated whitespace. + s = s.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + + // Remove spaces around dots in filenames ("a .jpeg" -> "a.jpeg"). + s = s.replacingOccurrences(of: " .", with: ".") + + return s.trimmingCharacters(in: .whitespacesAndNewlines) +} + +func looksLikeGuidOrHexLabel(_ label: String) -> Bool { + let t = label + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "-", with: "") + .uppercased() + if t.count < 12 { return false } + var hex = 0 + var other = 0 + for ch in t.unicodeScalars { + if ("0"..."9").contains(ch) || ("A"..."F").contains(ch) { + hex += 1 + } else { + other += 1 + } + } + // Treat labels that are almost entirely hex as non-human UI labels (GUID/hash style). + return other == 0 || (Double(hex) / Double(max(1, hex + other))) > 0.92 +} + +func looksLikeImplementationIdentifier(_ label: String) -> Bool { + let t = label.trimmingCharacters(in: .whitespacesAndNewlines) + if t.isEmpty { return false } + if t.range(of: #"^\d+$"#, options: .regularExpression) != nil { return true } + if t.range(of: #"^[A-Za-z]{1,4}[:._-]?\d+$"#, options: .regularExpression) != nil { return true } + if t.range(of: #"^(?:ns|ax|ui)[:._-]?\d+$"#, options: [.regularExpression, .caseInsensitive]) != nil { + return true + } + return false +} + +func isNoisyLabel(_ label: String) -> Bool { + let t = label.trimmingCharacters(in: .whitespacesAndNewlines) + if t.isEmpty { return true } + if looksLikeFileName(t) { return false } + if isLikelyTimestampOrDateLabel(t) { return true } + if isLikelySizeOrCountLabel(t) { return true } + if isLikelyStatusOnlyLabel(t) { return true } + if t.count > 56 { return true } + + var digits = 0 + var commas = 0 + for ch in t.unicodeScalars { + if CharacterSet.decimalDigits.contains(ch) { digits += 1 } + if ch == "," { commas += 1 } + } + if digits >= 10 && t.count >= 28 { return true } + if commas >= 2 && t.count >= 34 { return true } + return false +} + +func isLikelyTimestampOrDateLabel(_ label: String) -> Bool { + let t = label.trimmingCharacters(in: .whitespacesAndNewlines) + if t.isEmpty { return false } + if t.range(of: #"^\d{1,2}:\d{2}$"#, options: .regularExpression) != nil { return true } + if t.range(of: #"^\d{1,2}[./-]\d{1,2}[./-]\d{2,4}$"#, options: .regularExpression) != nil { return true } + if t.range(of: #"^\d{1,2}\s*[:.]\s*\d{2}$"#, options: .regularExpression) != nil { return true } + // Relative day separators in chat/list UIs. + let rel = normalizeForMatch(t) + if rel == "today" || rel == "heute" || rel == "yesterday" || rel == "gestern" { + return true + } + return false +} + +func containsLikelyTimestampToken(_ label: String) -> Bool { + label.range(of: #"\d{1,2}[:.]\d{2}"#, options: .regularExpression) != nil +} + +func isLikelySizeOrCountLabel(_ label: String) -> Bool { + let t = label.trimmingCharacters(in: .whitespacesAndNewlines) + if t.isEmpty { return false } + + // Common size/value patterns in list columns (cross-app, language-agnostic enough). + if t.range(of: #"^\d+(?:[.,]\d+)?\s*(?:b|kb|mb|gb|tb|bytes?)$"#, options: [.regularExpression, .caseInsensitive]) != nil { + return true + } + if t.range(of: #"^\d+[.,]?\d*\s*(?:items?|elemente?|einträge?)$"#, options: [.regularExpression, .caseInsensitive]) != nil { + return true + } + return false +} + +func isLikelyStatusOnlyLabel(_ label: String) -> Bool { + let l = normalizeForMatch(label) + if l.isEmpty { return false } + let statuses: Set = [ + "sent", "gesendet", + "read", "gelesen", + "delivered", "zugestellt", + "status", "inaktiv", "inactive", + ] + return statuses.contains(l) +} + +func isLikelyFileKindLabel(_ label: String) -> Bool { + let l = normalizeForMatch(label) + if l.isEmpty { return false } + + // Common Finder/file-list "Kind" column values (cross-language minimal set). + if l == "folder" || l == "folders" + || l == "ordner" + || l == "image" || l == "images" + || l == "bild" || l == "bilder" + || l == "video" || l == "videos" + || l == "film" || l == "filme" + { + return true + } + if l.hasSuffix("-bild") || l.hasSuffix("-film") || l.hasSuffix("-document") || l.hasSuffix("-dokument") { + return true + } + if l.hasSuffix(" dokument") || l.hasSuffix(" image") || l.hasSuffix(" video") { + return true + } + if l.contains("jpeg-bild") || l.contains("png-bild") || l.contains("webp-bild") || l.contains("mpeg-4-film") { + return true + } + return false +} + +func isLikelySidebarLocationLabel(_ label: String) -> Bool { + let l = normalizeForMatch(label) + if l.isEmpty { return false } + + let exact: Set = [ + "desktop", "schreibtisch", + "downloads", "download", + "documents", "dokumente", + "movies", "filme", + "icloud drive", "onedrive", "dropbox", "airdrop", + "applications", "programme", + "macintosh hd", + "network", "netzwerk", + "home", + ] + if exact.contains(l) { return true } + + return l.hasPrefix("macintosh hd") + || l.hasSuffix("reibtisch") + || l.hasPrefix("leben-mac-") + || l.hasPrefix("eben-mac-") + || l.hasPrefix("mac-") +} + +func isLikelyPickerCategoryLabel(_ label: String) -> Bool { + let l = normalizeForMatch(label) + if l.isEmpty { return false } + return l == "emoji" + || l == "emojis" + || l == "gif" + || l == "gifs" + || l == "sticker" + || l == "stickers" + || l == "today" + || l == "heute" +} + +func isLikelyPickerInteraction(_ step: StepInput) -> Bool { + let role = (step.ax?.role ?? "").lowercased() + let ident = (step.ax?.identifier ?? "").lowercased() + let containerIdent = (step.ax?.containerIdentifier ?? "").lowercased() + let roleDesc = (step.ax?.roleDescription ?? "").lowercased() + + let haystack = [role, ident, containerIdent, roleDesc].joined(separator: " ") + return haystack.contains("sticker") + || haystack.contains("emoji") + || haystack.contains("gif") + || haystack.contains("picker") +} + +func isLikelyContextMenuPhrase(_ label: String) -> Bool { + let l = normalizeForMatch(label) + if l.isEmpty { return false } + + if l == "open" || l == "öffnen" || l == "offnen" || l == "oeffnen" + || l == "open with" || l == "öffnen mit" || l == "offnen mit" || l == "oeffnen mit" + || l == "information" || l == "informationen" + || l == "rename" || l == "umbenennen" + || l == "copy" || l == "kopieren" + || l == "share" || l == "teilen" + || l == "delete" || l == "in den papierkorb legen" + { + return true + } + + if l.hasSuffix(" @ öffnen") || l.hasSuffix(" @ offnen") || l.hasSuffix(" @ oeffnen") || l.hasSuffix(" @ open") { return true } + if l.contains(" öffnen mit") || l.contains(" offnen mit") || l.contains(" oeffnen mit") || l.contains(" open with") { return true } + if l.contains("@") && (l.contains("öffnen") || l.contains("offnen") || l.contains("oeffnen") || l.contains("open")) { + return true + } + return false +} + +func extractRecipientTargetSegment(_ segment: String) -> (value: String, boosted: Bool) { + let trimmed = segment.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return ("", false) } + + let lowered = normalizeForMatch(trimmed) + let prefixMap: [(String, String)] = [ + ("gesendet an ", "gesendet an "), + ("sent to ", "sent to "), + ] + + for (key, rawPrefix) in prefixMap { + if lowered.hasPrefix(key) { + let idx = trimmed.index(trimmed.startIndex, offsetBy: rawPrefix.count, limitedBy: trimmed.endIndex) ?? trimmed.endIndex + let tail = String(trimmed[idx...]).trimmingCharacters(in: .whitespacesAndNewlines) + if !tail.isEmpty { return (tail, true) } + } + } + return (trimmed, false) +} + +func simplifyMetadataLabel(_ label: String) -> String { + let t = label.trimmingCharacters(in: .whitespacesAndNewlines) + if t.isEmpty { return "" } + if looksLikeFileName(t) { return t } + + // Many apps encode list-row metadata in a single comma-separated AX label. + // Pick the most "entity-like" segment instead of always taking the first one. + if t.contains(",") { + let rawSegments = t + .split(separator: ",", omittingEmptySubsequences: false) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + var best = "" + var bestScore = Int.min + + for raw in rawSegments { + let extracted = extractRecipientTargetSegment(raw) + let candidate = sanitizeUiLabel(extracted.value) + if candidate.isEmpty { continue } + if isLikelyTimestampOrDateLabel(candidate) { continue } + if isLikelySizeOrCountLabel(candidate) { continue } + if isLikelyStatusOnlyLabel(candidate) { continue } + if isNoisyLabel(candidate) && !looksLikeFileName(candidate) { continue } + + var score = 0 + if extracted.boosted { score += 10 } + if looksLikeFileName(candidate) { score += 8 } + if candidate.count >= 4 && candidate.count <= 36 { score += 4 } + if candidate.split(separator: " ").count >= 2 { score += 2 } + + if score > bestScore { + bestScore = score + best = candidate + } + } + + if !best.isEmpty { return best } + } + + return t +} + +func humanizeIdentifier(_ identifier: String) -> String { + var s = identifier.trimmingCharacters(in: .whitespacesAndNewlines) + if s.isEmpty { return "" } + + // Normalize separators to spaces. + s = s.replacingOccurrences(of: "_", with: " ") + s = s.replacingOccurrences(of: "-", with: " ") + + // Insert spaces for simple camelCase / PascalCase transitions. + var out = "" + out.reserveCapacity(s.count + 8) + var prevWasLower = false + for ch in s { + let isUpper = ch.isUppercase + if isUpper && prevWasLower { + out.append(" ") + } + out.append(ch) + prevWasLower = ch.isLowercase + } + + let rawTokens = out + .split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" }) + .map { String($0) } + + // Drop common implementation-detail tokens. Keep list small + generic (not app-specific). + let drop: Set = [ + "button", "btn", + "view", "collectionview", "collection", + "cell", "row", "item", + "group", "container", + "icon", "image", + ] + + var tokens: [String] = [] + tokens.reserveCapacity(rawTokens.count) + for tok in rawTokens { + let lower = tok.lowercased() + if drop.contains(lower) { continue } + if lower.count <= 1 { continue } + tokens.append(tok) + } + + if tokens.isEmpty { return "" } + + // Prefer "semantic" tokens (works across apps without per-app rules). + let semantic: Set = [ + "emoji", "emojis", + "sticker", "stickers", + "gif", "gifs", + "search", + "send", + "connect", "connection", + "download", "downloads", + "import", "export", + "delete", "remove", + "settings", + "close", "minimize", "zoom", + ] + let semanticTokens = tokens.filter { semantic.contains($0.lowercased()) } + if !semanticTokens.isEmpty { + let s = semanticTokens.prefix(2).joined(separator: " ") + return sanitizeUiLabel(s) + } + + if tokens.count > 4 { tokens = Array(tokens.prefix(4)) } + + var label = tokens.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + if label.count > 32 { + label = String(label.prefix(32)).trimmingCharacters(in: .whitespacesAndNewlines) + } + return label +} + +func requiresQuotedLabel(_ label: String) -> Bool { + // Enforce quotes for filenames / non-ASCII / punctuation labels. This improves readability. + if label.isEmpty { return false } + for u in label.unicodeScalars { + if u.value > 0x7F { return true } + if CharacterSet.letters.contains(u) || CharacterSet.decimalDigits.contains(u) || u == " " { continue } + return true + } + return false +} + +func locationHint(_ step: StepInput, kind: String) -> String? { + // Prefer semantic AX signals; avoid geometry guesses when possible. + if let ax = step.ax { + let sub = (ax.containerSubrole ?? "").lowercased() + let ident = (ax.containerIdentifier ?? "").lowercased() + let selfIdent = (ax.identifier ?? "").lowercased() + + if sub.contains("sourcelist") || sub.contains("sidebar") || ident.contains("sidebar") || selfIdent.contains("sidebar") { + return "sidebar" + } + } + + if kind == "menu bar item" { + return "menu bar" + } + + let app = step.app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if app == "finder" { + // If we can’t prove “sidebar”, still add a non-guessy hint for list interactions. + if kind == "list item" { return "file list" } + } + return nil +} + +func appContextSuffix(_ step: StepInput, location: String?) -> String { + let app = step.app.trimmingCharacters(in: .whitespacesAndNewlines) + if app.isEmpty { return "" } + let lower = app.lowercased() + if lower == "application" || lower == "dock" { return "" } + + let winRaw = safeWindowTitleContext(step) + let appKey = normalizedNameKey(app) + let win = winRaw.flatMap { w -> String? in + let winKey = normalizedNameKey(w) + if !appKey.isEmpty && !winKey.isEmpty && winKey == appKey { + return nil + } + return w + } + if let location = location, !location.isEmpty { + if lower == "finder" { + if let win = win { return " in the Finder \(location) (\(win))" } + return " in the Finder \(location)" + } + if let win = win { return " in \(app) \(location) (\(win))" } + return " in \(app) \(location)" + } + if let win = win { + if lower == "finder" { + return " in Finder (\(win))" + } + return " in \(app) (\(win))" + } + return " in \(app)" +} + +func classifyKind(_ step: StepInput) -> String { + if isDockStep(step) { return "dock item" } + let windowTitleNorm = normalizeForMatch(step.windowTitle) + let hasMenuWindowHint = windowTitleNorm == "menu" && step.clickYPercent <= 70.0 + guard let ax = step.ax else { + // Only treat as menu when the recorder explicitly tagged a menu selection. + let w = step.windowTitle.trimmingCharacters(in: .whitespacesAndNewlines) + if w.lowercased().hasPrefix("menu - ") { return "menu item" } + if hasMenuWindowHint { return "menu item" } + return "item" + } + + let role = ax.role.lowercased() + let subrole = (ax.subrole ?? "").lowercased() + let container = (ax.containerRole ?? "").lowercased() + let containerIdent = (ax.containerIdentifier ?? "").lowercased() + let selfIdent = (ax.identifier ?? "").lowercased() + let roleDesc = (ax.roleDescription ?? "").lowercased() + + let roleLooksMenuLike = + role.contains("menuitem") + || role == "axmenu" + || role.contains(" menu") + || role.hasSuffix("menu") + || role.contains("menubar") + || role.contains("menubutton") + if hasMenuWindowHint && roleLooksMenuLike { + return "menu item" + } + + if subrole.contains("close") && role.contains("button") { return "close button" } + if (subrole.contains("minimize") || roleDesc.contains("minim")) && role.contains("button") { + return "minimize button" + } + if (subrole.contains("zoom") || roleDesc.contains("zoom")) && role.contains("button") { + return "zoom button" + } + if role.contains("textfield") || role.contains("text field") || subrole.contains("searchfield") { + return "text field" + } + if role.contains("menubar") || role.contains("menubutton") { return "menu bar item" } + if role.contains("menuitem") || role == "axmenu" || role.contains(" menu") || role.hasSuffix("menu") { return "menu item" } + if role.contains("application") { + // Some apps report AXApplication when clicking traffic-light window controls. + // Use a conservative geometry fallback near the top-left corner. + if step.clickXPercent <= 8.0 && step.clickYPercent <= 8.0 { + return "close button" + } + } + if role.contains("checkbox") { return "checkbox" } + if role.contains("tab") || subrole.contains("tab") { return "tab" } + + // Many apps model list rows as pressable groups/buttons, so the clicked role may be "button". + // Use container hints (role/identifier) to classify those as list interactions. + if container.contains("row") + || container.contains("outline") + || container.contains("table") + || container.contains("list") + || containerIdent.contains("table") + || containerIdent.contains("outline") + || containerIdent.contains("list") + || containerIdent.contains("collection") + || selfIdent.contains("table") + || selfIdent.contains("outline") + || selfIdent.contains("list") + || selfIdent.contains("collection") + { + return "list item" + } + if role.contains("row") || role.contains("cell") || role.contains("outline") || role.contains("table") || role.contains("list") { return "list item" } + + if role.contains("popupbutton") || role.contains("button") { return "button" } + if role.contains("radiobutton") { return "option" } + return "item" +} + +func checkboxVerb(_ step: StepInput) -> String { + if let checked = step.ax?.isChecked { + // AXValue reflects current state at click-time; action semantics are state toggle. + return checked ? "Disable" : "Enable" + } + return "Enable" +} + +func preferredVerb(_ step: StepInput, kind: String) -> String { + let action = actionVerb(step.action) + if isDockStep(step) { return "Open" } + if action == "Right-click" || action == "Double-click" || action == "Press" { + return action + } + switch kind { + case "menu item": + return "Choose" + case "menu bar item": + return "Click" + case "close button": + return "Close" + case "text field": + return "Click" + case "checkbox": + return checkboxVerb(step) + case "list item": + return "Select" + default: + return action + } +} + +func chooseGroundingLabel(_ step: StepInput, kind: String) -> (label: String, ocr: String?) { + var axLabel = simplifyMetadataLabel(sanitizeUiLabel(bestLabel(step))) + if looksLikeGuidOrHexLabel(axLabel) { + axLabel = "" + } + if !axLabel.isEmpty && isNoisyLabel(axLabel) { + axLabel = "" + } + if kind == "list item" && isLikelyFileKindLabel(axLabel) { + axLabel = "" + } + if isLikelyTimestampOrDateLabel(axLabel) || isLikelySizeOrCountLabel(axLabel) || isLikelyStatusOnlyLabel(axLabel) { + axLabel = "" + } + if kind == "list item" && step.clickXPercent > 30.0 && isLikelySidebarLocationLabel(axLabel) { + axLabel = "" + } + if kind == "list item" && isLikelyPickerInteraction(step) { + axLabel = "" + } + if (kind == "item" || kind == "button") && isLikelyPickerCategoryLabel(axLabel) { + axLabel = "" + } + + let identLabel: String = { + guard let ident = step.ax?.identifier else { return "" } + let identLower = ident.lowercased() + let role = (step.ax?.role ?? "").lowercased() + if identLower.contains("collectionview") + || identLower.contains("listview") + || identLower.contains("tableview") + { + if role.contains("group") || role.contains("collection") || role.contains("list") { + return "" + } + } + let h = sanitizeUiLabel(humanizeIdentifier(ident)) + if h.isEmpty { return "" } + if looksLikeGuidOrHexLabel(h) { return "" } + if looksLikeImplementationIdentifier(h) { return "" } + if isNoisyLabel(h) { return "" } + if isLikelyTimestampOrDateLabel(h) || isLikelySizeOrCountLabel(h) || isLikelyStatusOnlyLabel(h) { + return "" + } + if isGenericUiLabel(step, kind: kind, label: h) || isLikelyViewModeLabel(step, kind: kind, label: h) { + return "" + } + return h + }() + + if kind == "menu bar item" { + // Menu bar icons often have no AX label; OCR may pick unrelated menu text like "Status". + // Use the app name as the grounding label and skip OCR for this kind. + if axLabel.isEmpty { + let app = step.app.trimmingCharacters(in: .whitespacesAndNewlines) + if !app.isEmpty { + return (label: app, ocr: nil) + } + } + return (label: axLabel, ocr: nil) + } + + // For window controls: avoid OCR and any unrelated labels; baseline handles it. + if kind == "close button" || kind == "minimize button" || kind == "zoom button" { + return (label: "", ocr: nil) + } + + let axGeneric = + isGenericUiLabel(step, kind: kind, label: axLabel) + || isLikelyViewModeLabel(step, kind: kind, label: axLabel) + + // OCR is expensive; run it only when it can materially improve results. + // Prefer AXIdentifier-derived labels over OCR when AXTitle is missing/cryptic. + if (axLabel.isEmpty || axGeneric), !identLabel.isEmpty { + return (label: identLabel, ocr: nil) + } + + let shouldOcr = axLabel.isEmpty || step.action == "RightClick" || axGeneric + let rawOcr = shouldOcr ? bestOcrLabelNearClick(step) : nil + let cleanedOcr = rawOcr + .map(cleanupOcrLabel) + .map(sanitizeUiLabel) + let ocr: String? = { + guard let candidate = cleanedOcr else { return nil } + if isGenericUiLabel(step, kind: kind, label: candidate) { return nil } + if isLikelyPickerCategoryLabel(candidate) && (kind == "item" || kind == "button") { return nil } + if kind == "list item" && isLikelyContextMenuPhrase(candidate) { return nil } + if isLikelyTimestampOrDateLabel(candidate) { return nil } + if containsLikelyTimestampToken(candidate) { return nil } + if isLikelySizeOrCountLabel(candidate) { return nil } + if isLikelyStatusOnlyLabel(candidate) { return nil } + if kind == "list item" && isLikelyFileKindLabel(candidate) { return nil } + if kind == "list item" && step.clickXPercent > 30.0 && isLikelySidebarLocationLabel(candidate) { + return nil + } + if kind == "list item" && isLikelyPickerInteraction(step) { return nil } + if kind == "list item" && step.clickXPercent < 30.0 && !looksLikeFileName(candidate) { + // Sidebar OCR without a strong token is error-prone across apps/locales. + return nil + } + if isNoisyLabel(candidate) && !looksLikeFileName(candidate) { return nil } + return candidate + }() + + if step.action == "RightClick" { + // Right-click: prefer OCR near click for row targets, including folder names without file extensions. + if let ocr = ocr, !ocr.isEmpty { + if axLabel.isEmpty || axGeneric { + return (label: ocr, ocr: ocr) + } + // List rows frequently expose generic AX labels; OCR is often closer to user intent. + if kind == "list item" + && normalizeForMatch(ocr) != normalizeForMatch(axLabel) + && !isLikelyContextMenuPhrase(ocr) + { + return (label: ocr, ocr: ocr) + } + } + if axLabel.isEmpty || axGeneric { + return (label: "", ocr: nil) + } + return (label: axLabel, ocr: ocr) + } + + if axLabel.isEmpty || axGeneric { + if let ocr = ocr { return (label: ocr, ocr: ocr) } + // Prefer a blank label over a generic "RustDesk in RustDesk" label. + if axGeneric { return (label: "", ocr: nil) } + } + + if axLabel.isEmpty { + return (label: ocr ?? "", ocr: ocr) + } + + return (label: axLabel, ocr: ocr) +} + +func sanitizeDescription(_ raw: String, maxChars: Int) -> String { + var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if s.hasPrefix("\"") && s.hasSuffix("\"") && s.count >= 2 { + s = String(s.dropFirst().dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) + } + + // Drop common prefixes ("Step 1:", "1.", etc.) + s = s.replacingOccurrences( + of: #"^\s*(Step\s*\d+[:.)-]\s*)"#, + with: "", + options: .regularExpression + ) + s = s.replacingOccurrences( + of: #"^\s*\d+[:.)-]\s*"#, + with: "", + options: .regularExpression + ) + + // Collapse whitespace / newlines. + s = s.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + + // Remove bullet noise, including cases like "Double-click * Foo". + s = s.replacingOccurrences( + of: #"^(click|double-click|right-click|choose|select)\s+[*•-]\s+"#, + with: "$1 ", + options: [.regularExpression, .caseInsensitive] + ) + s = s.replacingOccurrences( + of: #"^\s*[*•-]\s+"#, + with: "", + options: .regularExpression + ) + + // Keep only the first sentence if multiple are present. + if let idx = s.indices.first(where: { i in + let ch = s[i] + guard ch == "." || ch == "!" || ch == "?" else { return false } + let next = s.index(after: i) + guard next < s.endIndex, s[next] == " " else { return false } + let after = s.index(after: next) + return after < s.endIndex + }) { + s = String(s[...idx]) + } + + s = s.trimmingCharacters(in: .whitespacesAndNewlines) + if s.isEmpty { return "" } + + if s.count > maxChars { + let prefix = String(s.prefix(maxChars)) + if let lastSpace = prefix.lastIndex(of: " ") { + s = String(prefix[.. String { + let cleanLabel = sanitizeUiLabel(label) + let q = safeQuoted(cleanLabel) + let suffix = appContextSuffix(step, location: location) + let verb = actionVerb(step.action) + + var s: String + if kind == "close button" { + let app = step.app.trimmingCharacters(in: .whitespacesAndNewlines) + if !app.isEmpty && app.lowercased() != "application" && app.lowercased() != "dock" { + s = "Close the \(app) window." + } else { + s = "Close the window." + } + } else if kind == "minimize button" { + let app = step.app.trimmingCharacters(in: .whitespacesAndNewlines) + if !app.isEmpty && app.lowercased() != "application" && app.lowercased() != "dock" { + s = "Minimize the \(app) window." + } else { + s = "Minimize the window." + } + } else if kind == "zoom button" { + let app = step.app.trimmingCharacters(in: .whitespacesAndNewlines) + if !app.isEmpty && app.lowercased() != "application" && app.lowercased() != "dock" { + s = "Zoom the \(app) window." + } else { + s = "Zoom the window." + } + } else if kind == "menu bar item" { + let app = step.app.trimmingCharacters(in: .whitespacesAndNewlines) + if !app.isEmpty && app.lowercased() != "application" && app.lowercased() != "dock" { + s = "Click the \(app) icon in the menu bar to open its menu." + } else if !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + s = "Click \(q) in the menu bar to open its menu." + } else { + s = "Click the menu bar icon to open its menu." + } + } else if kind == "text field" { + // We only record clicks (no global key capture); avoid pretending we saw typing. + if label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + s = "Click the text field\(suffix)." + } else if label.allSatisfy({ $0.isLetter || $0.isNumber }) && !label.contains(" ") { + s = "Click the \(label) field\(suffix)." + } else { + s = "Click \(q) field\(suffix)." + } + } else if isDockStep(step) { + s = label.isEmpty ? "Open the app from the Dock." : "Open \(label) from the Dock." + } else if kind == "menu item" { + s = q.isEmpty ? "Choose the menu item\(suffix)." : "Choose \(q) from the menu\(suffix)." + } else if kind == "checkbox" { + let checkboxAction = checkboxVerb(step) + s = q.isEmpty ? "\(checkboxAction) the checkbox\(suffix)." : "\(checkboxAction) \(q)\(suffix)." + } else if verb == "Right-click" { + s = q.isEmpty ? "Right-click the \(kind)\(suffix)." : "Right-click \(q)\(suffix)." + } else if verb == "Double-click" { + s = q.isEmpty ? "Double-click the \(kind)\(suffix)." : "Double-click \(q)\(suffix)." + } else if verb == "Press" { + let note = step.note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !note.isEmpty { + s = "Press \(note)\(suffix)." + } else { + s = "Press the keyboard shortcut\(suffix)." + } + } else { + switch kind { + case "list item": + s = q.isEmpty ? "Select the item\(suffix)." : "Select \(q)\(suffix)." + case "tab": + s = q.isEmpty ? "Click the tab\(suffix)." : "Click the \(q) tab\(suffix)." + case "button": + if q.isEmpty { + s = "Click the button\(suffix)." + } else if cleanLabel.allSatisfy({ $0.isLetter || $0.isNumber || $0 == " " }) && !cleanLabel.contains(" ") { + s = "Click the \(cleanLabel) button\(suffix)." + } else { + s = "Click \(q)\(suffix)." + } + default: + s = q.isEmpty ? "Click the item\(suffix)." : "Click \(q)\(suffix)." + } + } + + return sanitizeDescription(s, maxChars: maxChars) +} + +func startsWithVerb(_ s: String) -> Bool { + let t = s.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return t.hasPrefix("click") + || t.hasPrefix("double-click") + || t.hasPrefix("right-click") + || t.hasPrefix("close") + || t.hasPrefix("open") + || t.hasPrefix("choose") + || t.hasPrefix("select") + || t.hasPrefix("enable") + || t.hasPrefix("disable") + || t.hasPrefix("press") + || t.hasPrefix("type") + || t.hasPrefix("drag") +} + +struct QualityGateDecision { + let text: String + let reason: String +} + +func firstQuotedSegment(_ s: String) -> String? { + let pattern = #""([^"]+)"|'([^']+)'"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(s.startIndex..= 2, let r = Range(m.range(at: 1), in: s) { + return String(s[r]).trimmingCharacters(in: .whitespacesAndNewlines) + } + if m.numberOfRanges >= 3, let r = Range(m.range(at: 2), in: s) { + return String(s[r]).trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil +} + +func applyQualityGate(step: StepInput, kind: String, baseline: String, candidate: String, label: String) -> QualityGateDecision { + let cand = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + if cand.isEmpty { return QualityGateDecision(text: baseline, reason: "candidate_empty") } + if !startsWithVerb(cand) { return QualityGateDecision(text: baseline, reason: "candidate_missing_verb") } + + let candNorm = normalizeForMatch(cand) + let baseNorm = normalizeForMatch(baseline) + + // Conservative-by-default: for fragile UI targets, keep deterministic baseline wording. + // This avoids cross-app/cross-language drift in generated rewrites. + let baselinePreferredKinds: Set = ["list item", "item", "button", "text field", "tab"] + if baselinePreferredKinds.contains(kind), candNorm != baseNorm { + return QualityGateDecision(text: baseline, reason: "prefer_baseline_for_kind") + } + + let pref = preferredVerb(step, kind: kind).lowercased() + if !cand.lowercased().hasPrefix(pref) { + // Keep the style consistent and avoid generic verbs ("Click") when we have a stronger signal. + return QualityGateDecision(text: baseline, reason: "candidate_wrong_verb") + } + + if isDockStep(step) && !candNorm.contains("dock") { + return QualityGateDecision(text: baseline, reason: "dock_context_missing") + } + if kind == "menu item" && !candNorm.contains("menu") { + return QualityGateDecision(text: baseline, reason: "menu_context_missing") + } + if kind == "menu bar item" && !candNorm.contains("menu bar") { + return QualityGateDecision(text: baseline, reason: "menu_bar_context_missing") + } + if kind != "tab" && candNorm.contains(" tab") { + // Avoid "tab" hallucinations when we don't have a tab signal. + return QualityGateDecision(text: baseline, reason: "tab_hallucination") + } + + if !label.isEmpty { + let cleanLabel = sanitizeUiLabel(label) + let labelNorm = normalizeForMatch(cleanLabel) + if !candNorm.contains(labelNorm) { + return QualityGateDecision(text: baseline, reason: "label_not_present") + } + + let quoted = "\"\(cleanLabel)\"" + let quotedAlt = "'\(cleanLabel)'" + let baselineHasQuotedTarget = baseline.contains(quoted) || baseline.contains(quotedAlt) + if baselineHasQuotedTarget { + let candHasQuotedTarget = cand.contains(quoted) || cand.contains(quotedAlt) + if !candHasQuotedTarget { + return QualityGateDecision(text: baseline, reason: "quoted_label_lost") + } + + // For target-bearing kinds, a mismatched first quoted object is almost always wrong. + if kind == "list item" || kind == "button" || kind == "tab" || kind == "item" || kind == "text field" { + if let firstQuoted = firstQuotedSegment(cand) { + if normalizeForMatch(firstQuoted) != labelNorm { + return QualityGateDecision(text: baseline, reason: "quoted_target_mismatch") + } + } + } + } + + if requiresQuotedLabel(cleanLabel) { + // Keep labels readable and unambiguous for novices. + let quoted = "\"\(cleanLabel)\"" + let quotedAlt = "'\(cleanLabel)'" + if !cand.contains(quoted) && !cand.contains(quotedAlt) { + return QualityGateDecision(text: baseline, reason: "quoted_label_required") + } + } + + if let win = safeWindowTitleContext(step) { + let winNorm = normalizeForMatch(win) + if !winNorm.isEmpty && baseNorm.contains(winNorm) && !candNorm.contains(winNorm) { + // If baseline includes useful window context (e.g. "Downloads"), keep it. + return QualityGateDecision(text: baseline, reason: "window_context_lost") + } + } + + let app = step.app.trimmingCharacters(in: .whitespacesAndNewlines) + if !appContextSuffix(step, location: nil).isEmpty { + let appNorm = normalizeForMatch(app) + if !appNorm.isEmpty && !candNorm.contains(appNorm) { + // If we know the target app, keep that grounding (prevents "Select front."). + return QualityGateDecision(text: baseline, reason: "app_context_lost") + } + } + } else { + // No trustworthy grounding label: keep deterministic baseline, avoid hallucinations. + return QualityGateDecision(text: baseline, reason: "no_grounding_label") + } + + return QualityGateDecision(text: cand, reason: "accepted") +} + +func promptForStep( + _ step: StepInput, + kind: String, + baseline: String, + label: String, + ocr: String?, + location: String?, + maxChars: Int +) -> String { + var lines: [String] = [] + lines.append("Write ONE short UI tutorial step description.") + lines.append("Rules:") + lines.append("- ONE sentence, max \(maxChars) characters.") + lines.append("- Start with a verb (e.g. Click, Double-click, Right-click, Close, Open, Choose, Select).") + lines.append("- No numbering, no markdown, no quotes unless quoting a UI label.") + lines.append("- Do NOT invent UI labels. Use the provided label; if missing, stay generic.") + lines.append("- Avoid vague output like \"Click Finder.\"; include location when known (Dock/menu/dialog).") + lines.append("- If unsure, return the Baseline exactly.") + lines.append("- Return ONLY the description text.") + lines.append("") + lines.append("Detected UI element kind: \(kind)") + lines.append("Click position: x=\(Int(step.clickXPercent))% y=\(Int(step.clickYPercent))% (from top-left)") + lines.append("Preferred verb: \(preferredVerb(step, kind: kind))") + if let location = location { lines.append("Location hint: \(location)") } + if !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + lines.append("Grounding label: \(label)") + } + if let ocr = ocr, !ocr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + lines.append("OCR near click: \(ocr)") + } + lines.append("Baseline (safe): \(baseline)") + lines.append("") + lines.append("Action: \(actionVerb(step.action))") + lines.append("App: \(step.app)") + if !step.windowTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + lines.append("Window title: \(step.windowTitle)") + } + if let ax = step.ax { + lines.append("AX role: \(ax.role)") + if let sub = ax.subrole { lines.append("AX subrole: \(sub)") } + if let desc = ax.roleDescription { lines.append("AX role description: \(desc)") } + if let ident = ax.identifier { lines.append("AX identifier: \(ident)") } + lines.append("AX label: \(ax.label)") + if let containerRole = ax.containerRole { lines.append("AX container role: \(containerRole)") } + if let containerSub = ax.containerSubrole { lines.append("AX container subrole: \(containerSub)") } + if let containerIdent = ax.containerIdentifier { lines.append("AX container identifier: \(containerIdent)") } + if let windowRole = ax.windowRole { lines.append("AX window role: \(windowRole)") } + if let windowSubrole = ax.windowSubrole { lines.append("AX window subrole: \(windowSubrole)") } + if let topRole = ax.topLevelRole { lines.append("AX top role: \(topRole)") } + if let topSubrole = ax.topLevelSubrole { lines.append("AX top subrole: \(topSubrole)") } + if let dialogRole = ax.parentDialogRole { lines.append("AX dialog role: \(dialogRole)") } + if let dialogSub = ax.parentDialogSubrole { lines.append("AX dialog subrole: \(dialogSub)") } + if let checked = ax.isChecked { lines.append("AX checked: \(checked ? "true" : "false")") } + if ax.isDefaultButton { lines.append("AX hint: default button") } + if ax.isCancelButton { lines.append("AX hint: cancel button") } + } + if let note = step.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { + lines.append("User note: \(note)") + } + return lines.joined(separator: "\n") +} diff --git a/src-tauri/swift/stepcast_ai_helper_vision.swift b/src-tauri/swift/stepcast_ai_helper_vision.swift new file mode 100644 index 0000000..e27834a --- /dev/null +++ b/src-tauri/swift/stepcast_ai_helper_vision.swift @@ -0,0 +1,369 @@ +import Foundation +import Vision +import ImageIO + +struct OcrCandidate { + let text: String + let confidence: Float + /// Bounding box in ROI-normalized coordinates (origin bottom-left). + let bbox: CGRect +} + +func loadCGImage(atPath path: String) -> CGImage? { + let url = URL(fileURLWithPath: path) + guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } + return CGImageSourceCreateImageAtIndex(src, 0, nil) +} + +func clamp01(_ v: CGFloat) -> CGFloat { max(0.0, min(1.0, v)) } + +/// Convert click percent (CSS-style, origin top-left) to Vision normalized coordinates (origin bottom-left). +func clickNormPointVision(_ step: StepInput) -> CGPoint { + let x = clamp01(CGFloat(step.clickXPercent / 100.0)) + let yTop = clamp01(CGFloat(step.clickYPercent / 100.0)) + return CGPoint(x: x, y: 1.0 - yTop) +} + +func hasSidebarSemanticHint(_ step: StepInput) -> Bool { + guard let ax = step.ax else { return false } + let sub = (ax.containerSubrole ?? "").lowercased() + let ident = (ax.containerIdentifier ?? "").lowercased() + let selfIdent = (ax.identifier ?? "").lowercased() + return sub.contains("sourcelist") + || sub.contains("sidebar") + || ident.contains("sidebar") + || selfIdent.contains("sidebar") +} + +func roiForClick(_ step: StepInput) -> CGRect { + let click = clickNormPointVision(step) + + if let b = step.ax?.elementBounds { + let ex = clamp01(CGFloat(b.xPercent / 100.0)) + let eyTop = clamp01(CGFloat(b.yPercent / 100.0)) + let ew = clamp01(CGFloat(b.widthPercent / 100.0)) + let eh = clamp01(CGFloat(b.heightPercent / 100.0)) + + // Some AX elements (e.g. list containers) report bounds for the entire + // scroll area. Using that as OCR ROI makes results worse. Fall back to a + // click-centered ROI when bounds look like a container. + let area = ew * eh + let looksLikeContainer = (ew > 0.85 && eh > 0.40) || area > 0.65 + let role = (step.ax?.role ?? "").lowercased() + let sub = (step.ax?.subrole ?? "").lowercased() + let labelNorm = normalizeForMatch(step.ax?.label ?? "") + let hasVolatileDayLikeLabel = + labelNorm == "today" || labelNorm == "heute" || labelNorm == "yesterday" || labelNorm == "gestern" + let menuLikeRole = role.contains("menu") + let groupWithWeakLabel = role.contains("group") && (labelNorm.isEmpty || hasVolatileDayLikeLabel) + let avoidElementBounds = menuLikeRole || groupWithWeakLabel + + if !looksLikeContainer && !avoidElementBounds { + let isTextField = role.contains("textfield") || role.contains("text field") || sub.contains("searchfield") + + // Element bounds are in screenshot percent (origin top-left). Convert to Vision coords (origin bottom-left). + let ey = clamp01(1.0 - eyTop - eh) + + let padX: CGFloat = 0.04 + let padY: CGFloat = 0.05 + let extraLeft: CGFloat = isTextField ? 0.06 : 0.0 + + let x = clamp01(ex - padX - extraLeft) + let y = clamp01(ey - padY) + let w = clamp01(ew + padX * 2.0 + extraLeft) + let h = clamp01(eh + padY * 2.0) + + if w > 0.02 && h > 0.02 { + // Clamp to image bounds. + let wClamped = min(w, 1.0 - x) + let hClamped = min(h, 1.0 - y) + if wClamped > 0.02 && hClamped > 0.02 { + return CGRect(x: x, y: y, width: wClamped, height: hClamped) + } + } + } + } + + // Prefer semantic AX hints; fall back to geometry only if we have no AX metadata. + let isLeftPane = hasSidebarSemanticHint(step) || (step.ax == nil && click.x < 0.30) + let w: CGFloat = isLeftPane ? 0.42 : 0.72 + let h: CGFloat = isLeftPane ? 0.50 : 0.28 + + let x = isLeftPane ? 0.0 : clamp01(click.x - w / 2.0) + let y = clamp01(click.y - h / 2.0) + + return CGRect( + x: min(x, 1.0 - w), + y: min(y, 1.0 - h), + width: w, + height: h + ) +} + +func ocrCandidatesNearClick(_ step: StepInput) -> (roi: CGRect, clickImage: CGPoint, clickInRoi: CGPoint, bboxesInImage: Bool, candidates: [OcrCandidate]) { + guard let path = step.screenshotPath, !path.isEmpty else { + return (roi: .zero, clickImage: .zero, clickInRoi: .zero, bboxesInImage: false, candidates: []) + } + guard FileManager.default.fileExists(atPath: path) else { + return (roi: .zero, clickImage: .zero, clickInRoi: .zero, bboxesInImage: false, candidates: []) + } + guard let cgImage = loadCGImage(atPath: path) else { + return (roi: .zero, clickImage: .zero, clickInRoi: .zero, bboxesInImage: false, candidates: []) + } + + let click = clickNormPointVision(step) // image-normalized (origin bottom-left) + let roi = roiForClick(step) + let clickInRoi = CGPoint( + x: roi.width > 0 ? clamp01((click.x - roi.minX) / roi.width) : 0, + y: roi.height > 0 ? clamp01((click.y - roi.minY) / roi.height) : 0 + ) + + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.usesLanguageCorrection = false + request.regionOfInterest = roi + request.minimumTextHeight = 0.008 + + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) + do { + try handler.perform([request]) + } catch { + return (roi: roi, clickImage: click, clickInRoi: clickInRoi, bboxesInImage: false, candidates: []) + } + + let observations = request.results ?? [] + if observations.isEmpty { + return (roi: roi, clickImage: click, clickInRoi: clickInRoi, bboxesInImage: false, candidates: []) + } + + var out: [OcrCandidate] = [] + out.reserveCapacity(min(observations.count, 30)) + + for obs in observations { + guard let cand = obs.topCandidates(1).first else { continue } + let text = cand.string.trimmingCharacters(in: .whitespacesAndNewlines) + if text.isEmpty { continue } + out.append(OcrCandidate(text: text, confidence: cand.confidence, bbox: obs.boundingBox)) + } + + // Vision docs say boundingBox is image-normalized, but we have observed ROI-normalized boxes + // in some configurations. Detect by checking whether many boxes "escape" the ROI bounds. + let eps: CGFloat = 0.02 + let needsDetection = roi.width > 0.0 && roi.height > 0.0 + && (abs(roi.minX - 0.0) > 0.001 || abs(roi.minY - 0.0) > 0.001 || abs(roi.maxX - 1.0) > 0.001 || abs(roi.maxY - 1.0) > 0.001) + var bboxesInImage = true + if needsDetection { + let escapes = out.filter { c in + let b = c.bbox + return b.minX < roi.minX - eps + || b.minY < roi.minY - eps + || b.maxX > roi.maxX + eps + || b.maxY > roi.maxY + eps + }.count + // If many escape, treat as ROI-normalized. + let escapeThreshold = max(2, Int(Double(out.count) * 0.25)) + bboxesInImage = escapes < escapeThreshold + } + + return (roi: roi, clickImage: click, clickInRoi: clickInRoi, bboxesInImage: bboxesInImage, candidates: out) +} + +func looksLikeFileName(_ s: String) -> Bool { + let t = s.trimmingCharacters(in: .whitespacesAndNewlines) + if t.count < 3 || t.count > 80 { return false } + let parts = t.split(separator: ".", omittingEmptySubsequences: false) + if parts.count < 2 { return false } + let ext = parts.last ?? "" + if ext.count < 1 || ext.count > 8 { return false } + if !ext.allSatisfy({ $0.isLetter || $0.isNumber }) { return false } + // Reject common context-menu ellipsis items like "Open With…". + // Keep truncated filenames like "foo...bar.jpeg" valid. + let lower = t.lowercased() + if lower.hasSuffix("…") || lower.hasSuffix("...") { return false } + return true +} + +func isMostlyNonLetters(_ s: String) -> Bool { + let t = s.trimmingCharacters(in: .whitespacesAndNewlines) + if t.isEmpty { return true } + var letters = 0 + var digits = 0 + var other = 0 + for ch in t.unicodeScalars { + if CharacterSet.letters.contains(ch) { letters += 1 } + else if CharacterSet.decimalDigits.contains(ch) { digits += 1 } + else if CharacterSet.whitespacesAndNewlines.contains(ch) { continue } + else { other += 1 } + } + if letters == 0 { return true } + // "Mostly non-letters": more digits+symbols than letters. + return (digits + other) > letters +} + +func isLikelyMetadataValue(_ s: String) -> Bool { + let t = s.trimmingCharacters(in: .whitespacesAndNewlines) + if t.isEmpty { return false } + + if t.range(of: #"^\d{1,2}:\d{2}$"#, options: .regularExpression) != nil { return true } + if t.range(of: #"^\d{1,2}[./-]\d{1,2}[./-]\d{2,4}$"#, options: .regularExpression) != nil { + return true + } + if t.range(of: #"^\d+(?:[.,]\d+)?\s*(?:b|kb|mb|gb|tb|bytes?)$"#, options: [.regularExpression, .caseInsensitive]) != nil { + return true + } + return false +} + +func isLikelyFileKindText(_ s: String) -> Bool { + let t = normalizeForMatch(s) + if t.isEmpty { return false } + + if t == "folder" || t == "folders" + || t == "ordner" + || t == "image" || t == "images" + || t == "bild" || t == "bilder" + || t == "video" || t == "videos" + || t == "film" || t == "filme" + { + return true + } + if t.hasSuffix("-bild") || t.hasSuffix("-film") || t.hasSuffix("-document") || t.hasSuffix("-dokument") { + return true + } + if t.contains("jpeg-bild") || t.contains("png-bild") || t.contains("webp-bild") || t.contains("mpeg-4-film") { + return true + } + return false +} + +func bestOcrLabelNearClick(_ step: StepInput) -> String? { + let res = ocrCandidatesNearClick(step) + let candidates = res.candidates + if candidates.isEmpty { return nil } + + let click = res.bboxesInImage ? res.clickImage : res.clickInRoi + + let axRole = (step.ax?.role ?? "").lowercased() + let containerRole = (step.ax?.containerRole ?? "").lowercased() + let isListInteraction = axRole.contains("outline") + || axRole.contains("table") + || axRole.contains("list") + || containerRole.contains("outline") + || containerRole.contains("table") + || containerRole.contains("list") + let avoidSidebarLeak = isListInteraction && step.clickXPercent > 30.0 && !hasSidebarSemanticHint(step) + let preferRowAligned = isListInteraction + let pool = candidates + + if preferRowAligned { + // For list/table rows, the target is usually the row's primary label (often left-most). + // Prefer row-aligned semantic text over metadata columns (kind/date/size). + let rowSlack: CGFloat = 0.015 + let rowCandidates = pool.filter { c in + let b = c.bbox + return click.y >= b.minY - rowSlack && click.y <= b.maxY + rowSlack + } + + let semanticRowCandidates = rowCandidates.filter { c in + let trimmed = c.text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return false } + if isLikelyMetadataValue(trimmed) { return false } + if isLikelyFileKindText(trimmed) { return false } + if isLikelyTimestampOrDateLabel(trimmed) { return false } + if containsLikelyTimestampToken(trimmed) { return false } + if isLikelyStatusOnlyLabel(trimmed) { return false } + if trimmed.count <= 2 { return false } + return true + } + + if !semanticRowCandidates.isEmpty { + let noSidebarLeak = semanticRowCandidates.filter { c in + if !avoidSidebarLeak { return true } + let trimmed = c.text.trimmingCharacters(in: .whitespacesAndNewlines) + if !isLikelySidebarLocationLabel(trimmed) { return true } + return c.bbox.midX >= 0.18 + } + + let effectiveCandidates = noSidebarLeak.isEmpty ? semanticRowCandidates : noSidebarLeak + + // If OCR produced both a clipped and a full variant of the same row label, + // prefer the longer variant. + let strengthened = effectiveCandidates.enumerated().map { idx, cand -> (cand: OcrCandidate, strength: Int) in + let trimmed = cand.text.trimmingCharacters(in: .whitespacesAndNewlines) + let norm = normalizeForMatch(trimmed) + var strength = 0 + for (otherIdx, other) in effectiveCandidates.enumerated() { + if otherIdx == idx { continue } + let otherNorm = normalizeForMatch(other.text) + if otherNorm.isEmpty || norm.isEmpty { continue } + if norm.hasSuffix(otherNorm) && norm.count > otherNorm.count { + strength += 1 + } + } + return (cand: cand, strength: strength) + } + + let chosen = strengthened.sorted { a, b in + let ax = abs(a.cand.bbox.midX - click.x) + let bx = abs(b.cand.bbox.midX - click.x) + if abs(ax - bx) > 0.08 { return ax < bx } + if a.strength != b.strength { return a.strength > b.strength } + let xDelta = abs(a.cand.bbox.minX - b.cand.bbox.minX) + if xDelta > 0.03 { return a.cand.bbox.minX < b.cand.bbox.minX } + let wDelta = abs(a.cand.bbox.width - b.cand.bbox.width) + if wDelta > 0.01 { return a.cand.bbox.width > b.cand.bbox.width } + if a.cand.text.count != b.cand.text.count { return a.cand.text.count > b.cand.text.count } + return a.cand.confidence > b.cand.confidence + }.first + + if let chosen { + return chosen.cand.text.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + } + + var best: (score: Double, text: String)? = nil + + for c in pool { + let bbox = c.bbox + let contains = bbox.contains(click) + let center = CGPoint(x: bbox.midX, y: bbox.midY) + let dx = Double(center.x - click.x) + let dy = Double(center.y - click.y) + let rowWeight = preferRowAligned ? 6.0 : 1.0 + var dist = dx * dx + (dy * dy * rowWeight) + if preferRowAligned { + // Prefer text on the same row band even if the click is on whitespace. + if click.y >= Double(bbox.minY) && click.y <= Double(bbox.maxY) { + dist *= 0.35 + } + } + if contains { dist *= 0.02 } // strongly prefer the text actually under the click + let confPenalty = Double(1.0 - c.confidence) * 0.03 + var score = dist + confPenalty + + // Avoid column headers / dates / sizes when we want an item label. + let trimmed = c.text.trimmingCharacters(in: .whitespacesAndNewlines) + if isMostlyNonLetters(trimmed) { + score += 0.06 + } + if isLikelyMetadataValue(trimmed) { + score += 0.20 + } + if isLikelyFileKindText(trimmed) { + score += 0.30 + } + if avoidSidebarLeak && isLikelySidebarLocationLabel(trimmed) { + score += 0.40 + } + if trimmed.count <= 2 { + score += 0.05 + } + + if best == nil || score < best!.score { + best = (score: score, text: c.text) + } + } + + return best?.text.trimmingCharacters(in: .whitespacesAndNewlines) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index acd1960..891aeb7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -14,7 +14,7 @@ { "title": "StepCast", "width": 340, - "height": 554, + "height": 640, "resizable": false, "decorations": false, "transparent": true, @@ -38,7 +38,7 @@ "style-src": "'self' 'unsafe-inline' http://localhost:1420", "script-src": "'self' 'unsafe-eval' http://localhost:1420" }, - "capabilities": ["default"], + "capabilities": ["default", "editor"], "assetProtocol": { "enable": true, "scope": ["$HOME/Library/Caches/**", "$TEMP/**"] @@ -74,7 +74,8 @@ "icons/recording.png", "icons/recording@2x.png", "icons/stop.png", - "icons/stop@2x.png" + "icons/stop@2x.png", + "bin/stepcast_ai_helper" ] } } diff --git a/src-tauri/tests/security_config.rs b/src-tauri/tests/security_config.rs index c60243f..b155bfa 100644 --- a/src-tauri/tests/security_config.rs +++ b/src-tauri/tests/security_config.rs @@ -6,8 +6,12 @@ fn tauri_conf_has_restrictive_csp_and_capabilities() { let json: Value = serde_json::from_str(raw).expect("parse tauri.conf.json"); let security = &json["app"]["security"]; - let csp = security["csp"].as_object().expect("csp should be an object"); - let dev_csp = security["devCsp"].as_object().expect("devCsp should be an object"); + let csp = security["csp"] + .as_object() + .expect("csp should be an object"); + let dev_csp = security["devCsp"] + .as_object() + .expect("devCsp should be an object"); let img_src = csp["img-src"].as_str().unwrap_or(""); assert!(img_src.contains("asset:"), "csp img-src must allow asset:"); @@ -17,7 +21,10 @@ fn tauri_conf_has_restrictive_csp_and_capabilities() { ); let connect_src = csp["connect-src"].as_str().unwrap_or(""); - assert!(connect_src.contains("ipc:"), "csp connect-src must allow ipc:"); + assert!( + connect_src.contains("ipc:"), + "csp connect-src must allow ipc:" + ); assert!( connect_src.contains("http://ipc.localhost"), "csp connect-src must allow ipc.localhost" diff --git a/src/App.css b/src/App.css index baa6d8f..79602ab 100644 --- a/src/App.css +++ b/src/App.css @@ -255,7 +255,7 @@ html, body { color: var(--text-secondary); } -input { +input:not([type="checkbox"]) { padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border); @@ -266,14 +266,62 @@ input { transition: border-color 0.15s ease; } -input:focus { +input:not([type="checkbox"]):focus { border-color: var(--accent); } -input::placeholder { +input:not([type="checkbox"])::placeholder { color: var(--text-secondary); } +.apple-toggle { + flex-shrink: 0; + width: 42px; + height: 26px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg-tertiary); + padding: 2px; + cursor: pointer; + position: relative; + display: inline-flex; + align-items: center; + transition: background 0.18s ease, border-color 0.18s ease; +} + +.apple-toggle.on { + background: var(--success); + border-color: rgba(0, 0, 0, 0.05); +} + +.apple-toggle:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.apple-toggle:focus-visible { + outline: 2px solid rgba(124, 92, 252, 0.55); + outline-offset: 2px; +} + +.apple-toggle-thumb { + width: 22px; + height: 22px; + border-radius: 999px; + background: #ffffff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); + transform: translateX(0); + transition: transform 0.18s ease; +} + +.apple-toggle.on .apple-toggle-thumb { + transform: translateX(16px); +} + +.settings-row-switch { + user-select: none; +} + /* Permissions */ .permissions { display: flex; @@ -487,6 +535,30 @@ button:focus, button:focus-visible { border: 1px solid var(--border); } +.drag-handle { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + flex-shrink: 0; + cursor: grab; + color: var(--text-secondary); + opacity: 0.5; + border: none; + background: none; + padding: 0; + -webkit-app-region: no-drag; + touch-action: none; +} + +.drag-handle:hover { + opacity: 1; +} + +.drag-handle:active { + cursor: grabbing; +} + .step-thumb { width: 40px; height: 30px; @@ -496,12 +568,24 @@ button:focus, button:focus-visible { background: var(--bg-tertiary); } -.step-thumb img { +.step-thumb .step-image-frame { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +} + +.step-thumb .step-image-frame img { + display: block; width: 100%; height: 100%; object-fit: cover; } +.step-thumb .step-image-frame-cropped img { + object-fit: fill; +} + /* Click Indicator */ .step-thumb { position: relative; @@ -604,6 +688,75 @@ button:focus, button:focus-visible { flex: 1; } +/* Dropdown menu (New button) */ +.action-dropdown { + position: relative; + flex: 1; +} + +.action-dropdown .button { + width: 100%; +} + +.action-dropdown-backdrop { + position: fixed; + inset: 0; + z-index: 19; +} + +.action-dropdown-menu { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + right: 0; + z-index: 20; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 1px 4px rgba(0, 0, 0, 0.08); + padding: 4px; + animation: overlay-fade-in 0.1s ease; +} + +.action-dropdown-menu button { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + cursor: pointer; + -webkit-app-region: no-drag; +} + +.action-dropdown-menu button:hover { + background: var(--bg-secondary); +} + +.action-dropdown-menu button.danger { + color: var(--danger); +} + +.action-dropdown-menu button.danger:hover { + background: rgba(255, 59, 48, 0.1); +} + +.action-dropdown-menu button svg { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.action-dropdown-menu button:disabled { + opacity: 0.4; + cursor: default; +} + /* Export Sheet Overlay */ .export-overlay { position: absolute; @@ -775,6 +928,16 @@ button:focus, button:focus-visible { letter-spacing: 0.04em; } +.ai-settings-label { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.ai-icon { + display: block; +} + .settings-row { display: flex; align-items: center; diff --git a/src/components/EditorStepCard.test.tsx b/src/components/EditorStepCard.test.tsx new file mode 100644 index 0000000..2b20737 --- /dev/null +++ b/src/components/EditorStepCard.test.tsx @@ -0,0 +1,705 @@ +import { describe, it, expect, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import EditorStepCard from "./EditorStepCard"; +import type { Step } from "../types/step"; + +const mockConvertFileSrc = vi.mocked(convertFileSrc); + +function makeStep(overrides: Partial = {}): Step { + return { + id: "step-1", + ts: Date.now(), + action: "Click", + x: 100, + y: 200, + click_x_percent: 50, + click_y_percent: 50, + app: "Finder", + window_title: "Documents", + screenshot_path: "/tmp/screenshot.png", + note: null, + ...overrides, + }; +} + +describe("EditorStepCard", () => { + it("renders timeline badge and description", () => { + const { container } = render( + , + ); + const badge = container.querySelector(".editor-timeline-badge"); + expect(badge).toHaveTextContent("1"); + expect(screen.getByText("Clicked in Finder")).toBeInTheDocument(); + }); + + it("renders screenshot with convertFileSrc", () => { + mockConvertFileSrc.mockReturnValue("asset://localhost//tmp/screenshot.png"); + render( + , + ); + expect(mockConvertFileSrc).toHaveBeenCalledWith("/tmp/screenshot.png"); + const img = screen.getByAltText("Step 1"); + expect(img).toHaveAttribute("src", "asset://localhost//tmp/screenshot.png"); + }); + + it("shows 'Add a note...' placeholder when no note", () => { + render( + , + ); + expect(screen.getByText("Add a note...")).toBeInTheDocument(); + }); + + it("shows existing note text", () => { + render( + , + ); + expect(screen.getByText("My note")).toBeInTheDocument(); + }); + + it("enters editing mode on click and saves on blur", async () => { + const user = userEvent.setup(); + const onUpdateNote = vi.fn(); + render( + , + ); + + await user.click(screen.getByText("Add a note...")); + const textarea = screen.getByPlaceholderText("Add a note..."); + await user.type(textarea, "New note"); + await user.tab(); // blur + expect(onUpdateNote).toHaveBeenCalledWith("step-1", "New note"); + }); + + it("saves on Enter", async () => { + const user = userEvent.setup(); + const onUpdateNote = vi.fn(); + render( + , + ); + + await user.click(screen.getByText("Add a note...")); + const textarea = screen.getByPlaceholderText("Add a note..."); + await user.type(textarea, "Note text{Enter}"); + expect(onUpdateNote).toHaveBeenCalledWith("step-1", "Note text"); + }); + + it("cancels editing on Escape", async () => { + const user = userEvent.setup(); + const onUpdateNote = vi.fn(); + render( + , + ); + + await user.click(screen.getByText("Add a note...")); + const textarea = screen.getByPlaceholderText("Add a note..."); + await user.type(textarea, "Temp"); + await user.keyboard("{Escape}"); + expect(onUpdateNote).not.toHaveBeenCalled(); + // Button should be back + expect(screen.getByText("Add a note...")).toBeInTheDocument(); + }); + + it("shows click marker at correct position", () => { + const { container } = render( + , + ); + const marker = container.querySelector(".click-indicator") as HTMLElement; + expect(marker.style.left).toBe("75%"); + expect(marker.style.top).toBe("25%"); + }); + + it("hides marker for auth placeholder", () => { + const { container } = render( + , + ); + expect(container.querySelector(".click-indicator")).not.toBeInTheDocument(); + }); + + it("shows double-click description and marker", () => { + const { container } = render( + , + ); + expect(screen.getByText("Double-clicked in Finder")).toBeInTheDocument(); + expect(container.querySelector(".click-indicator.double-click")).toBeInTheDocument(); + }); + + it("shows right-click description and marker", () => { + const { container } = render( + , + ); + expect(screen.getByText("Right-clicked in Finder")).toBeInTheDocument(); + expect(container.querySelector(".click-indicator.right-click")).toBeInTheDocument(); + }); + + it("shows shortcut description", () => { + render( + , + ); + expect(screen.getByText("Used keyboard shortcut in Finder")).toBeInTheDocument(); + }); + + it("calls onDelete when delete button clicked", async () => { + const user = userEvent.setup(); + const onDelete = vi.fn(); + render( + , + ); + + await user.click(screen.getByTitle("Remove step")); + expect(onDelete).toHaveBeenCalledWith("step-1"); + }); + + it("opens crop editor and applies crop", async () => { + const user = userEvent.setup(); + const onUpdateCrop = vi.fn(); + render( + , + ); + + await user.click(screen.getByTitle("Adjust visible screenshot area")); + expect(screen.getByText("Adjust Focus Crop")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Apply" })); + expect(onUpdateCrop).toHaveBeenCalledWith("step-1", null); + }); + + it("supports description editing save and escape cancel", async () => { + const user = userEvent.setup(); + const onUpdateDescription = vi.fn(); + render( + , + ); + + await user.click(screen.getByText("Clicked in Finder")); + const input = screen.getByPlaceholderText("Step description..."); + await user.clear(input); + await user.type(input, "Do it now{Enter}"); + expect(onUpdateDescription).toHaveBeenCalledWith("step-1", "Do it now"); + + await user.click(screen.getByText("Clicked in Finder")); + const input2 = screen.getByPlaceholderText("Step description..."); + await user.type(input2, "temp"); + await user.keyboard("{Escape}"); + expect(onUpdateDescription).toHaveBeenCalledTimes(1); + }); + + it("disables description editing and crop for auth placeholder", async () => { + const user = userEvent.setup(); + render( + , + ); + + const descButton = screen.getByRole("button", { + name: "Authenticate with Touch ID or enter your password to continue.", + }); + expect(descButton).toBeDisabled(); + expect(screen.queryByTitle("Adjust visible screenshot area")).not.toBeInTheDocument(); + expect(screen.getByTitle("Generate with Apple Intelligence")).toBeDisabled(); + await user.click(descButton); + expect(screen.queryByPlaceholderText("Step description...")).not.toBeInTheDocument(); + }); + + it("shows AI status pills for generating, ai and failure states", () => { + const { rerender } = render( + , + ); + expect(screen.getByText("AI…")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText("AI")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText("AI!")).toBeInTheDocument(); + }); + + it("uses default failure tooltip when no description_error exists", () => { + render( + , + ); + expect(screen.getByText("AI!")).toHaveAttribute( + "title", + "Apple Intelligence generation failed", + ); + }); + + it("calls generate handler from AI button when enabled", async () => { + const user = userEvent.setup(); + const onGenerateDescription = vi.fn(); + render( + , + ); + await user.click(screen.getByTitle("Generate with Apple Intelligence")); + expect(onGenerateDescription).toHaveBeenCalledWith("step-1"); + }); + + it("supports crop modal close/reset/cancel actions", async () => { + const user = userEvent.setup(); + const onUpdateCrop = vi.fn(); + render( + , + ); + + await user.click(screen.getByTitle("Adjust visible screenshot area")); + await user.click(screen.getByRole("button", { name: "Reset" })); + expect(onUpdateCrop).toHaveBeenCalledWith("step-1", null); + + await user.click(screen.getByTitle("Adjust visible screenshot area")); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByText("Adjust Focus Crop")).not.toBeInTheDocument(); + + await user.click(screen.getByTitle("Adjust visible screenshot area")); + await user.click(screen.getByTitle("Close crop editor")); + expect(screen.queryByText("Adjust Focus Crop")).not.toBeInTheDocument(); + + await user.click(screen.getByTitle("Adjust visible screenshot area")); + fireEvent.click(screen.getByText("Adjust Focus Crop").closest(".editor-crop-overlay") as Element); + expect(screen.queryByText("Adjust Focus Crop")).not.toBeInTheDocument(); + }); + + it("renders a stable cropped frame when crop region is present", () => { + const { container } = render( + , + ); + + const frame = container.querySelector(".step-image-frame-cropped") as HTMLElement | null; + expect(frame).toBeInTheDocument(); + expect(frame?.style.aspectRatio).not.toBe(""); + }); + + it("repositions an existing crop by dragging in the step preview", () => { + const onUpdateCrop = vi.fn(); + const { container } = render( + , + ); + + const frame = container.querySelector(".step-image-frame-cropped") as HTMLDivElement | null; + expect(frame).toBeInTheDocument(); + if (!frame) return; + + Object.defineProperty(frame, "getBoundingClientRect", { + value: () => + ({ + x: 0, + y: 0, + width: 500, + height: 250, + top: 0, + left: 0, + right: 500, + bottom: 250, + toJSON: () => ({}), + }) as DOMRect, + }); + + fireEvent.pointerDown(frame, { pointerId: 1, button: 0, clientX: 100, clientY: 100 }); + fireEvent.pointerMove(frame, { pointerId: 1, clientX: 150, clientY: 120 }); + fireEvent.pointerUp(frame, { pointerId: 1, clientX: 150, clientY: 120 }); + + expect(onUpdateCrop).toHaveBeenCalledTimes(1); + const [, cropArg] = onUpdateCrop.mock.calls[0]; + expect(cropArg).toMatchObject({ + width_percent: 50, + height_percent: 50, + }); + expect(cropArg.x_percent).toBeLessThan(20); + expect(cropArg.y_percent).toBeLessThan(20); + }); + + it("does not persist crop when pointer is pressed/released without movement", () => { + const onUpdateCrop = vi.fn(); + const { container } = render( + , + ); + + const frame = container.querySelector(".step-image-frame-cropped") as HTMLDivElement | null; + expect(frame).toBeInTheDocument(); + if (!frame) return; + + Object.defineProperty(frame, "getBoundingClientRect", { + value: () => + ({ + x: 0, + y: 0, + width: 500, + height: 250, + top: 0, + left: 0, + right: 500, + bottom: 250, + toJSON: () => ({}), + }) as DOMRect, + }); + + fireEvent.pointerDown(frame, { pointerId: 1, button: 0, clientX: 100, clientY: 100 }); + fireEvent.pointerUp(frame, { pointerId: 1, clientX: 100, clientY: 100 }); + + expect(onUpdateCrop).not.toHaveBeenCalled(); + }); + + it("ignores non-left-button and invalid frame sizes for crop drag start", () => { + const onUpdateCrop = vi.fn(); + const { container } = render( + , + ); + const frame = container.querySelector(".step-image-frame-cropped") as HTMLDivElement; + Object.defineProperty(frame, "getBoundingClientRect", { + value: () => + ({ + x: 0, + y: 0, + width: 0.5, + height: 0.5, + top: 0, + left: 0, + right: 0.5, + bottom: 0.5, + toJSON: () => ({}), + }) as DOMRect, + }); + + fireEvent.pointerDown(frame, { pointerId: 1, button: 1, clientX: 100, clientY: 100 }); + fireEvent.pointerDown(frame, { pointerId: 1, button: 0, clientX: 100, clientY: 100 }); + fireEvent.pointerMove(frame, { pointerId: 1, clientX: 150, clientY: 150 }); + fireEvent.pointerUp(frame, { pointerId: 1, clientX: 150, clientY: 150 }); + expect(onUpdateCrop).not.toHaveBeenCalled(); + }); + + it("retries image loading on error up to max retries", async () => { + mockConvertFileSrc.mockReturnValue("asset://localhost//tmp/screenshot.png"); + const { container } = render( + , + ); + const img = screen.getByAltText("Step 1"); + expect(img).toHaveAttribute("src", "asset://localhost//tmp/screenshot.png"); + + fireEvent.error(img); + await waitFor(() => + expect(screen.getByAltText("Step 1")).toHaveAttribute( + "src", + "asset://localhost//tmp/screenshot.png?retry=1", + ), + ); + + fireEvent.error(screen.getByAltText("Step 1")); + await waitFor(() => + expect(screen.getByAltText("Step 1")).toHaveAttribute( + "src", + "asset://localhost//tmp/screenshot.png?retry=2", + ), + ); + + fireEvent.error(screen.getByAltText("Step 1")); + await new Promise((resolve) => window.setTimeout(resolve, 150)); + expect(screen.getByAltText("Step 1")).toHaveAttribute( + "src", + "asset://localhost//tmp/screenshot.png?retry=2", + ); + + const frame = container.querySelector(".step-image-frame") as HTMLElement; + Object.defineProperty(frame, "getBoundingClientRect", { + value: () => + ({ + x: 0, + y: 0, + width: 640, + height: 360, + top: 0, + left: 0, + right: 640, + bottom: 360, + toJSON: () => ({}), + }) as DOMRect, + }); + const loaded = screen.getByAltText("Step 1") as HTMLImageElement; + Object.defineProperty(loaded, "naturalWidth", { value: 1920, configurable: true }); + Object.defineProperty(loaded, "naturalHeight", { value: 1080, configurable: true }); + fireEvent.load(loaded); + }); +}); diff --git a/src/components/EditorStepCard.tsx b/src/components/EditorStepCard.tsx new file mode 100644 index 0000000..c9d42f9 --- /dev/null +++ b/src/components/EditorStepCard.tsx @@ -0,0 +1,545 @@ +import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import ReactCrop, { type PercentCrop } from "react-image-crop"; +import type { BoundsPercent, Step } from "../types/step"; +import { + getCropAspectRatio, + getCroppedImageStyles, + isFullCrop, + markerPositionForStep, + normalizeCropRegion, +} from "../utils/stepCrop"; + +type EditorStepCardProps = { + step: Step; + index: number; + onUpdateNote: (stepId: string, note: string | null) => void; + onUpdateDescription: (stepId: string, description: string | null) => void; + onGenerateDescription: (stepId: string) => void; + onUpdateCrop: (stepId: string, cropRegion: BoundsPercent | null) => void; + aiEnabled: boolean; + onDelete: (stepId: string) => void; +}; + +const FULL_PERCENT_CROP: PercentCrop = { + unit: "%", + x: 0, + y: 0, + width: 100, + height: 100, +}; + +function toPercentCrop(cropRegion?: BoundsPercent | null): PercentCrop { + const crop = normalizeCropRegion(cropRegion); + if (!crop) return { ...FULL_PERCENT_CROP }; + return { + unit: "%", + x: crop.x_percent, + y: crop.y_percent, + width: crop.width_percent, + height: crop.height_percent, + }; +} + +function toBoundsPercent(crop: PercentCrop): BoundsPercent | null { + const normalized = normalizeCropRegion({ + x_percent: crop.x ?? 0, + y_percent: crop.y ?? 0, + width_percent: crop.width ?? 0, + height_percent: crop.height ?? 0, + }); + if (!normalized) return null; + return isFullCrop(normalized) ? null : normalized; +} + +export default memo(function EditorStepCard({ + step, + index, + onUpdateNote, + onUpdateDescription, + onGenerateDescription, + onUpdateCrop, + aiEnabled, + onDelete, +}: EditorStepCardProps) { + const [noteEditing, setNoteEditing] = useState(false); + const [noteDraft, setNoteDraft] = useState(step.note ?? ""); + const noteTextareaRef = useRef(null); + + const [descEditing, setDescEditing] = useState(false); + const [descDraft, setDescDraft] = useState(""); + const descInputRef = useRef(null); + + const [cropOpen, setCropOpen] = useState(false); + const [cropDraft, setCropDraft] = useState(() => toPercentCrop(step.crop_region)); + const [dragCrop, setDragCrop] = useState(null); + const [isCropDragging, setIsCropDragging] = useState(false); + const [imageRetry, setImageRetry] = useState(0); + const [imageNaturalSize, setImageNaturalSize] = useState<{ width: number; height: number }>({ + width: 0, + height: 0, + }); + const dragRafRef = useRef(null); + const dragPendingCropRef = useRef(null); + const cropDragRef = useRef<{ + pointerId: number; + startClientX: number; + startClientY: number; + frameWidth: number; + frameHeight: number; + startCrop: BoundsPercent; + moved: boolean; + latestCrop: BoundsPercent; + } | null>(null); + + useEffect(() => { + setImageRetry(0); + }, [step.screenshot_path]); + + useEffect(() => { + return () => { + if (dragRafRef.current != null) { + cancelAnimationFrame(dragRafRef.current); + dragRafRef.current = null; + } + }; + }, []); + + const screenshotBaseSrc = useMemo( + () => (step.screenshot_path ? convertFileSrc(step.screenshot_path) : null), + [step.screenshot_path], + ); + const screenshotSrc = useMemo(() => { + if (!screenshotBaseSrc) return null; + if (imageRetry === 0) return screenshotBaseSrc; + const sep = screenshotBaseSrc.includes("?") ? "&" : "?"; + return `${screenshotBaseSrc}${sep}retry=${imageRetry}`; + }, [imageRetry, screenshotBaseSrc]); + + const isAuthPlaceholder = + step.window_title === "Authentication dialog (secure)" || + step.app.toLowerCase() === "authentication"; + + const actionDesc = + step.action === "DoubleClick" + ? "Double-clicked in" + : step.action === "RightClick" + ? "Right-clicked in" + : step.action === "Shortcut" + ? "Used keyboard shortcut in" + : "Clicked in"; + + const authDescription = + step.description && step.description.trim().length > 0 + ? step.description.trim() + : "Authenticate with Touch ID or enter your password to continue."; + + const effectiveDescription = isAuthPlaceholder + ? authDescription + : (step.description && step.description.trim().length > 0 + ? step.description.trim() + : `${actionDesc} ${step.app}`); + + const markerClass = + step.action === "DoubleClick" + ? "click-indicator double-click" + : step.action === "RightClick" + ? "click-indicator right-click" + : "click-indicator"; + + const isGenerating = step.description_status === "generating"; + const isFailed = step.description_status === "failed"; + const isAi = step.description_source === "ai"; + + const stepCrop = normalizeCropRegion(step.crop_region); + const normalizedCrop = dragCrop ?? stepCrop; + + const frameAspect = useMemo( + () => getCropAspectRatio(imageNaturalSize.width, imageNaturalSize.height, normalizedCrop), + [imageNaturalSize.height, imageNaturalSize.width, normalizedCrop], + ); + + const shouldApplyCrop = Boolean(normalizedCrop && !isFullCrop(normalizedCrop)); + const cropStyles = getCroppedImageStyles(shouldApplyCrop ? normalizedCrop : null); + const marker = markerPositionForStep( + shouldApplyCrop ? step : { ...step, crop_region: null }, + ); + const frameStyle = shouldApplyCrop + ? { width: "100%", aspectRatio: `${frameAspect ?? 16 / 9}` } + : undefined; + const frameClasses = [cropStyles.frameClassName]; + if (shouldApplyCrop) frameClasses.push("is-crop-draggable"); + if (isCropDragging) frameClasses.push("is-crop-dragging"); + + const clampCropPosition = (crop: BoundsPercent): BoundsPercent => { + const width = Math.max(2, Math.min(100, crop.width_percent)); + const height = Math.max(2, Math.min(100, crop.height_percent)); + const maxX = Math.max(0, 100 - width); + const maxY = Math.max(0, 100 - height); + return { + x_percent: Math.max(0, Math.min(maxX, crop.x_percent)), + y_percent: Math.max(0, Math.min(maxY, crop.y_percent)), + width_percent: width, + height_percent: height, + }; + }; + const cropDistance = (a: BoundsPercent, b: BoundsPercent) => + Math.max( + Math.abs(a.x_percent - b.x_percent), + Math.abs(a.y_percent - b.y_percent), + Math.abs(a.width_percent - b.width_percent), + Math.abs(a.height_percent - b.height_percent), + ); + + const handleCropDragStart = (e: React.PointerEvent) => { + if (!shouldApplyCrop || !normalizedCrop) return; + if (e.button !== 0) return; + if (e.cancelable) e.preventDefault(); + + const rect = e.currentTarget.getBoundingClientRect(); + if (rect.width <= 1 || rect.height <= 1) return; + + cropDragRef.current = { + pointerId: e.pointerId, + startClientX: e.clientX, + startClientY: e.clientY, + frameWidth: rect.width, + frameHeight: rect.height, + startCrop: normalizedCrop, + moved: false, + latestCrop: normalizedCrop, + }; + dragPendingCropRef.current = normalizedCrop; + setIsCropDragging(true); + setDragCrop(normalizedCrop); + if (typeof e.currentTarget.setPointerCapture === "function") { + e.currentTarget.setPointerCapture(e.pointerId); + } + }; + + const handleCropDragMove = (e: React.PointerEvent) => { + const drag = cropDragRef.current; + if (!drag || drag.pointerId !== e.pointerId) return; + + const dxPx = e.clientX - drag.startClientX; + const dyPx = e.clientY - drag.startClientY; + + // Keep cursor/content movement direct: dragging right moves visible content right. + const dxCrop = -(dxPx / drag.frameWidth) * drag.startCrop.width_percent; + const dyCrop = -(dyPx / drag.frameHeight) * drag.startCrop.height_percent; + + const nextCrop = clampCropPosition({ + ...drag.startCrop, + x_percent: drag.startCrop.x_percent + dxCrop, + y_percent: drag.startCrop.y_percent + dyCrop, + }); + drag.moved = cropDistance(nextCrop, drag.startCrop) > 0.02; + drag.latestCrop = nextCrop; + dragPendingCropRef.current = nextCrop; + if (dragRafRef.current == null) { + dragRafRef.current = requestAnimationFrame(() => { + dragRafRef.current = null; + if (dragPendingCropRef.current) { + setDragCrop(dragPendingCropRef.current); + } + }); + } + }; + + const finishCropDrag = () => { + const drag = cropDragRef.current; + const finalCrop = drag?.latestCrop ?? null; + if (dragRafRef.current != null) { + cancelAnimationFrame(dragRafRef.current); + dragRafRef.current = null; + } + dragPendingCropRef.current = null; + cropDragRef.current = null; + setIsCropDragging(false); + if (finalCrop && drag?.moved) { + onUpdateCrop(step.id, finalCrop); + } + setDragCrop(null); + }; + + const handleCropDragEnd = (e: React.PointerEvent) => { + const drag = cropDragRef.current; + if (!drag || drag.pointerId !== e.pointerId) return; + finishCropDrag(); + }; + + const handleStartNoteEdit = () => { + setNoteDraft(step.note ?? ""); + setNoteEditing(true); + requestAnimationFrame(() => noteTextareaRef.current?.focus()); + }; + + const handleSaveNote = () => { + const trimmed = noteDraft.trim(); + onUpdateNote(step.id, trimmed || null); + setNoteEditing(false); + }; + + const handleNoteKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSaveNote(); + } + if (e.key === "Escape") { + setNoteEditing(false); + } + }; + + const handleStartDescEdit = () => { + if (isAuthPlaceholder) return; + if (isGenerating) return; + setDescDraft(effectiveDescription); + setDescEditing(true); + requestAnimationFrame(() => descInputRef.current?.focus()); + }; + + const handleSaveDesc = () => { + const trimmed = descDraft.trim(); + onUpdateDescription(step.id, trimmed || null); + setDescEditing(false); + }; + + const handleDescKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSaveDesc(); + } + if (e.key === "Escape") { + setDescEditing(false); + } + }; + + const handleOpenCropEditor = () => { + if (!screenshotSrc || isAuthPlaceholder) return; + setCropDraft(toPercentCrop(step.crop_region)); + setCropOpen(true); + }; + + const handleSaveCrop = () => { + onUpdateCrop(step.id, toBoundsPercent(cropDraft)); + setCropOpen(false); + }; + + const handleResetCrop = () => { + onUpdateCrop(step.id, null); + setCropDraft({ ...FULL_PERCENT_CROP }); + setCropOpen(false); + }; + const handleImageError = () => { + if (imageRetry >= 2) return; + window.setTimeout(() => { + setImageRetry((prev) => (prev < 2 ? prev + 1 : prev)); + }, 120); + }; + + return ( +
+
{index + 1}
+ +
+
+ {descEditing ? ( + setDescDraft(e.target.value)} + onBlur={handleSaveDesc} + onKeyDown={handleDescKeyDown} + placeholder="Step description..." + /> + ) : ( + + )} + +
+ {!isAuthPlaceholder && screenshotSrc && ( + + )} + {isGenerating && ( + + AI… + + )} + {!isGenerating && isAi && ( + + AI + + )} + {isFailed && ( + + AI! + + )} + + +
+
+ + {screenshotSrc && ( +
+
+
e.preventDefault()} + > + {`Step e.preventDefault()} + onLoad={(e) => { + const img = e.currentTarget; + if (img.naturalWidth > 0 && img.naturalHeight > 0) { + setImageNaturalSize({ width: img.naturalWidth, height: img.naturalHeight }); + } + }} + /> + {!isAuthPlaceholder && marker.visible && ( +
+ )} +
+
+
+ )} + +
+ {noteEditing ? ( +