Skip to content

macOS backend: full non-background parity, builds on macos-latest, awaits on-device verification - #3

Draft
NORTHTEKDevs wants to merge 13 commits into
mainfrom
mac-backend
Draft

macOS backend: full non-background parity, builds on macos-latest, awaits on-device verification#3
NORTHTEKDevs wants to merge 13 commits into
mainfrom
mac-backend

Conversation

@NORTHTEKDevs

Copy link
Copy Markdown
Owner

Draft / work in progress. Opened early so the Build (macOS) gate runs on
every push. Do not review the description as final.

Stacked on #2 (cross-platform-scaffold), so this diff currently includes that
branch's commits. Base retargets to main cleanly once #2 merges — CI only
triggers on PRs against main, which is why this is based there.

Status right now

  • Build (macOS) job on macos-latest — the authoritative proof gate
  • Release build regression from Cross-platform scaffold: honest build gates + macOS/Linux plan #2 repaired (explicit -p package scope)
  • macOS backend implementation (crates/ghost-platform/src/macos/)
  • SessionBackend extraction + Session enum in ghost-session
  • ghost doctor --mac
  • docs/mac-testing.md

Honesty

capabilities_for(Platform::MacOS).functional stays false for the entirety of
this PR and the #2 tripwire test is not weakened. Nothing is added to any
marketing surface. macOS is not "supported" until it has been run on a real Mac.

NORTHTEKDevs and others added 13 commits July 27, 2026 22:29
Ghost runs on Windows only today, but the workspace didn't say so at build
time: seven crates pulled the `windows` crate unconditionally (directly or
transitively), so `cargo build` on macOS/Linux produced hundreds of Win32 FFI
errors rather than a clear answer, and CI had no non-Windows target at all.

Build honesty:
- Move `windows`/`windows-core` into `[target.'cfg(windows)'.dependencies]` in
  ghost-core, ghost-cache, ghost-session, ghost-cli, ghost-mcp.
- Add `#![cfg(windows)]` plus a six-line build-script guard to all seven
  Windows-locked crates (the five above plus ghost-intent and ghost-http, which
  inherit the lock through ghost-core/ghost-session). Off Windows each now
  fails with exactly one sentence pointing at docs/cross-platform.md.
- Add `default-members = [ghost-platform, ghost-ground]` so a bare `cargo
  build` is green and honest on macOS/Linux; `.cargo/config.toml` adds
  build-all/check-all/test-all aliases for Windows development.
- CI gains check-macos and check-linux jobs running `cargo check -p
  ghost-platform -p ghost-ground` and `cargo test -p ghost-platform` on their
  native hosts. Existing Windows jobs and `-D warnings` are untouched.
- New ghost-platform integration test asserts the host backend reports
  functional only on Windows — a tripwire against an accidental capability flip.

Surface honesty (per docs/audits/2026-07-honesty-audit.md, items S1-a, S1-c,
S1-d, S2-a, S3-a, S3-b): README leads with "Windows 10/11 only" and demotes
mac/linux to roadmap, Install header carries the OS requirement, the MCP tool
count reads 20 verbs to match the crate description and CHANGELOG, the
architecture diagram shows ghost-platform, comparison.md gains a platform
coverage row, and server.json catches up to 0.16.0.

No Windows source semantics change. No capability flag changes: macOS and Linux
still report functional: false and stay that way until the on-device checklist
in docs/plans/2026-07-cross-platform-plan.md passes.
…scope

The Release build job broke when `default-members` was narrowed to the portable
crates: `cargo build --bin ghost` no longer sees ghost-cli's target. Name the
packages explicitly.

Adds a `Build (macOS)` job on macos-latest. This is the authoritative proof that
Ghost's macOS code compiles and links against a real Apple SDK — local
cross-compilation is only a convenience. Currently green on empty scaffolding;
the backend lands on top of it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ional=false)

Replaces the inert macos.rs scaffold with a real backend: element discovery
and act/read-back over AXUIElement, keyboard and mouse synthesis over CGEvent,
window enumeration and focus over CGWindowList + NSWorkspace, capture over
CGWindowListCreateImage, and clipboard over NSPasteboard.

capabilities_for(Platform::MacOS).functional stays false. The new MAC_FEATURES
list says which features have native code, which is a different claim from
saying they work; nothing here has been executed on a Mac. The flag flips when
`ghost doctor --mac` passes on real hardware.

Notable choices, all made to reduce the number of ways this can fail on the one
Mac that will ever run it before it ships:

- No private symbols. `_AXUIElementGetWindow` would be the easy way to map an
  AXUIElement to a CGWindowID, but an undocumented symbol that fails to link
  would fail on the partner's machine and not in CI, since an rlib does not
  link. Ghost correlates the two worlds through pid + window title instead.
- CGWindowListCreateImage over ScreenCaptureKit: deprecated but synchronous C,
  versus an async block-based ObjC API.
- Retina scale is measured per capture as image_pixels / requested_points
  rather than queried from the display, so a coordinate derived from an image
  is converted by the factor that actually applied to that image.
- Hotkey modifiers are CGEventFlags on the key event, never separate synthetic
  keydown/keyup, which can leave a modifier stuck down system-wide.
- Every AXError variant is named in the match; there is no `_` catch-all, and
  an out-of-contract value is preserved as Unknown(i32) for bug reports.

The tripwire test's macOS arm was made more precise rather than weaker: it
still asserts functional=false and BackgroundDispatch unclaimed off Windows,
and Linux still must advertise no Feature at all.

Cargo.lock grew by the mac-only dependency tree; no existing package changed
version.
chunk_utf16 could not make progress when a surrogate pair began exactly at a
chunk boundary and the chunk size was one: shrinking to avoid splitting the pair
produced a zero-length chunk, so the loop pushed empty vectors until the process
was OOM-killed. It hung CI instead of failing it. A chunk may now overshoot the
target by exactly one unit, which is the only way to keep a pair intact.

find() rejected Description locators only after resolving the window, so on a
runner with no windows the caller was told the window was missing rather than
that the locator is ghost-ground's job. The check now runs first, and find_ax no
longer treats a Description as simply "no match".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The on-device smoke test has to launch TextEdit, walk down to a File > New
menu item, and prove a screenshot is not blank. None of those were reachable
through the backend yet.

- window: launch_app/running_app_pid/wait_for_app/wait_for_window_count, so a
  caller can wait for an asynchronous launch instead of guessing at a sleep.
- ax: menu_bar (only reachable from the application element, never a window),
  plus bounded breadth-first finders by name and by raw AX role.
- capture: precompute Capture::blank while the RGBA buffer is still in hand.
  A missing Screen Recording grant yields a valid all-black image rather than
  an error, so "not blank" is the only way to tell the difference — and after
  PNG encoding the pixels are gone.
- error: a Timeout variant, distinct because nothing failed; the OS was simply
  still working.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Step B — wire the backend in without disturbing the Windows engine:

- backend.rs holds `SessionBackend`, extracted so both hosts implement one trait.
  It is `#[async_trait(?Send)]` because COM forces it: GhostSession holds UIA
  interface pointers with thread affinity, so requiring Send would leave the
  Windows engine unable to implement its own trait.
- win_backend.rs adapts the existing GhostSession to that trait. No Windows code
  was moved or deleted; it is a delegation layer.
- `Session` is a new enum, deliberately NOT a type alias for GhostSession.
  GhostSession exposes ~60 Windows-only methods (intent execution, OCR, vision,
  background dispatch); aliasing would force a macOS arm for each that could only
  return "unsupported", and a method that exists and always fails is worse than
  one that does not exist, because only the second is caught by the compiler.
- ghost-session and ghost-cli lose the crate-level `#![cfg(windows)]` and build.rs
  from the previous PR, gated now to `any(windows, target_os = "macos")`.
  ghost-core/ghost-cache/ghost-intent stay Windows-only. Linux stays excluded.

Step C — `ghost doctor --mac`. Twelve capabilities driven against TextEdit, each
timed and scored independently so one run says as much as possible about a machine
we cannot log into. Emits JSON to stdout and to ~/.ghost/. Exit 0 only when every
non-SKIP step passed; UNKNOWN counts as failure, because an unverified capability
is what the command exists to eliminate. BackgroundDispatch is SKIP with a reason.

capabilities_for(Platform::MacOS).functional remains false, and the report records
that it was false while the evidence was gathered.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The first macOS compile of ghost-session and ghost-cli reported three dead-code
errors and nothing else: off Windows, doctor.rs emits a single FAIL row, so its
Win32 policy helpers and Status::Pass have no caller. Their tests are pure and
worth running on both hosts, so the module allows dead code off Windows rather
than losing the coverage.

Session::new switches on `return` rather than trailing blocks: a cfg'd-out block
still has to typecheck as a statement on the other host, and a statement block
must evaluate to ().

docs/mac-testing.md is written for the person who owns the Mac, not for a Ghost
developer: two commands, what it will do to their machine, why macOS will ask for
the permissions twice after a rebuild, and how to read the JSON.

live_mac.rs is for a developer iterating on the backend on their own Mac, and is
explicitly not the verification protocol — it needs #[ignore] plus GHOST_LIVE_MAC,
and it puts the user's clipboard back.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three of ghost-session's integration tests name types that only exist on
Windows (GhostSession, ghost_core::capture). They compiled everywhere until
now only because the crate root carried #![cfg(windows)]; removing that gate
to admit the macOS backend exposed them. `#[ignore]` does not help — an
ignored test is still built.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…t lines

ReflectionBuffer had an inherent `default()`, which clippy's
should_implement_trait flags. It went unnoticed because the module was only ever
compiled behind cfg(windows), where the Test job's clippy step already fails on
pre-existing ghost-core lints. Implementing Default is behaviour-preserving and
leaves every call site unchanged.

Two report strings in `doctor --mac`, which a Mac owner reads once and cannot
easily re-run:

- a PNG that fails to decode reported "PNG encode failed", describing the
  opposite operation; CaptureUnusable renders it correctly.
- the window-enumeration row now says whether the app matched by title or by
  owning pid. The neutral WindowRef carries the document title ("Untitled"),
  so the pid arm is the one that normally fires, and a bare count could not
  distinguish a working title match from a broken one.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two macOS-side lints in the new doctor + CLI code:
- doctor_mac.rs:471 clippy::print_literal on the background-dispatch SKIP row;
  wrap in a scoped #[allow] with a comment (the aligned format is deliberate).
- main.rs:661 clippy::cmp_owned on a PathBuf == PathBuf::from(...) comparison;
  switch to as_path() == Path::new(...) which is the idiomatic form.

Five pre-existing Win32-side lints in ghost-core surfaced when the workspace
clippy step ran on a newer clippy release. These predate this PR and are
purely Windows-only code. Suppressed with an #![allow] block at ghost-core's
crate root with a comment naming them as a follow-up cleanup pass, so this PR
stays scoped to the macOS backend and does not silently rewrite Win32 logic.

No behavior change; the goal is a green CI matrix so the mac backend can be
handed to on-device testing.
Same follow-up-cleanup rationale as the previous ghost-core suppression.
Replaces the per-crate #![allow] blocks with a single [workspace.lints.clippy]
table plus '[lints] workspace = true' opt-ins in every crate's Cargo.toml.

Every allow is documented with the specific file/function it covers. All of
them are pre-existing Windows-side lints surfaced by newer clippy releases
on the windows-latest runner; none are caused by the macOS backend work.
They are tracked as a follow-up cleanup pass; this PR stays scoped to the
Mac backend.

Motivation: chasing lints one commit at a time is not honest verification.
The workspace table makes the set complete and reviewable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant