Skip to content

Add FaceTime command flow and auto-approval improvements#236

Open
cameronaaron wants to merge 2103 commits intomautrix:masterfrom
cameronaaron:refactor
Open

Add FaceTime command flow and auto-approval improvements#236
cameronaaron wants to merge 2103 commits intomautrix:masterfrom
cameronaaron:refactor

Conversation

@cameronaaron
Copy link
Copy Markdown
Contributor

Summary

  • add and wire FaceTime command handlers, including facetime-send behavior updates
  • improve FaceTime incoming call/link handling in connector flow
  • add rustpush FaceTime Let Me In auto-approval path for bridge-owned links
  • include related connector/login wiring updates

Notes

  • source branch: cameronaaron/refactor
  • requested upstream refactor base does not exist; using master

mackid1993 and others added 30 commits March 27, 2026 02:15
fix(backfill): canonicalize DM senders consistently
fix(connector): stabilize SMS and RCS portal handling
…ility

fix(groups): preserve authoritative names and stable gid aliases
…geting

fix(connector): target replies and tapbacks to the correct part
…chments

When a Matrix client sets body == filename on an m.image/m.file event
(meaning "no caption"), treat it as no caption instead of forwarding
the filename through to rustpush as a caption. Rustpush places the
caption in the iMessage subject field, which is invisible in the bridge
but renders as a bold subject line above the photo for iPhone recipients
and triggers a double-puppet edit on echo.

Real captions (body != filename per MSC2530) still flow through.

Regression from 4150feb (native iMessage captions for outbound photos).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…dKit sharing

Brings in Cameron's rustpushgo wrapper surface (StatusKit presence/focus
notifications, profile sharing, stickers, transcript backgrounds, typing
app metadata, FindMy client, Shared Passwords, live photos, video
remuxing), CI/CD infrastructure (GitHub Actions, Intel macOS builds,
unit tests, security scanning), reliability fixes (ghost read receipts,
CloudKit group photo panic, PurgeRecoverableZones after delete), and
deeper rustpush integration with guarded fallbacks.

Preserved from our master:
  - All 40+ forward fixes in pkg/connector/ (SMS/RCS portal handling,
    group name stability, tapback part targeting, HEIC metadata,
    backfill DM sender parity, vCard group prefix, delivery receipt
    panic guard, etc.)
  - CreateConfigFromHardwareKey translator at lib.rs:2127
    (pkg/rustpushgo/src/lib.rs) — the Linux hardware key compat layer
  - rustpush/open-absinthe (vendored Linux NAC emulator)

Single conflict resolved: .gitignore union (kept both our /build/ line
and Cameron's /third_party/rustpush-upstream/ + related entries).

Post-merge state compiles against cameronaaron/rustpush:imessage-bridge-compat
fork by default (Makefile RUSTPUSH_SOURCE=fork). Phase B will swap to
pinned OpenBubbles upstream with zero rustpush patches.

Plan: .claude/plans/hashed-bouncing-popcorn.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tpush patches

Switches the Rust FFI wrapper from Cameron's vendored/fork rustpush to a pinned
clone of OpenBubbles upstream at third_party/rustpush-upstream/ (HEAD
937dc64664303a7ad64b5ff2e2777ee9db56230a). Enables the `macos-validation-data`
feature on the rustpush dependency so `rustpush::macos::MacOSConfig` (public
OSConfig impl) is available.

Reduces 47 wrapper compile errors to 0 by refactoring, not patching upstream.

## Refactor highlights

**WrappedTokenProvider** now stores `account`, `os_config`, and
`mme_delegate_bytes` locally instead of relying on `TokenProvider::get_*()`
helpers that OpenBubbles upstream doesn't expose (those were Cameron's
unreleased WIP). Getters read directly from stored state:

- `get_dsid()` / `get_adsid()` read `account.spd["DsPrsId"|"adsid"]`
- `get_icloud_auth_headers()` builds headers from `TokenProvider::get_mme_token`
  (public) + anisette + OSConfig UDID/client-info
- `get_contacts_url()` parses the stored delegate via untyped `plist::Value`
  dictionary traversal
- `get_mme_delegate_json()` / `seed_mme_delegate_json()` round-trip opaque
  plist XML bytes
- New `parse_mme_delegate::<T>()` helper uses type inference from the receiving
  function signature (e.g. `KeychainClientState::new(_, _, &T)`) to reconstitute
  a `MobileMeDelegateResponse` without naming it — the type is not reachable
  from outside `rustpush::auth` (`mod auth` is private in upstream)
- `create_keychain_clients(wp: &WrappedTokenProvider)` takes the wrapper
  directly and reads from its fields

Both construction sites (restore_token_provider + IDS login flow) populate
the new fields. The IDS login flow manually serializes the MobileMe delegate
to plist bytes field-by-field because `MobileMeDelegateResponse` does not
implement `Serialize` in upstream.

**local_config.rs** no longer implements `OSConfig` directly — external impls
are architecturally blocked because `OSConfig::build_activation_info` returns
`rustpush::activation::ActivationInfo` from a private module. Instead, it
exposes `LocalMacOSConfig::into_macos_config()` which constructs a
`rustpush::macos::MacOSConfig` populated from IOKit hardware data. The `_enc`
fields (platform_serial_number_enc, platform_uuid_enc, etc.) are left empty;
upstream's ValidationCtx skips absent ones. Our `nac-validation` crate
(AAAbsintheContext native) stays in-tree as a standalone utility but is no
longer wired into OSConfig.

**Known follow-up**: macOS validation data now goes through upstream's
open-absinthe NAC path instead of our native `nac-validation`. If Apple's
servers reject registrations without the `_enc` hardware fields, we'll need
to either read them from IOKit in hardware_info.m or find a different path.

**Wrapper utilities**: replaced `rustpush::{base64_decode, encode_hex,
base64_encode, decode_hex}` imports (not re-exported in upstream) with local
helpers over the `base64` and `hex` crates (hex added to Cargo.toml).

**StatusKit channel management**: `update_channels()` and `request_channels()`
stubbed to no-ops because they require constructing
`rustpush::aps::APSChannelIdentifier`, which lives in a private module.
Receive-side StatusKit presence (the "contact has notifications silenced"
feature) goes through `ensure_channel`, `request_handles`, and the APS
message handler — none of which depend on the stubbed methods. Verified the
Go bridge never calls either stubbed method.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rve uniffi exports

When I split out the internal `pub(crate)` helpers into a non-uniffi `impl
WrappedTokenProvider` block, I accidentally left `get_escrow_devices`,
`join_keychain_clique`, and `join_keychain_clique_for_device` inside the
non-uniffi block — those are the macOS login flow's entry points and they
MUST be exported to Go.

Fix: close the internal-helpers block after `get_os_config()`, then reopen
a SECOND `#[uniffi::export(async_runtime = "tokio")] impl WrappedTokenProvider`
block for the three clique methods.

Regenerated Go FFI bindings via `make bindings` (uniffi-bindgen-go). Full
`make build` now green:

  Built mautrix-imessage-v2.app + bbctl (0.1.0-03f0b04)
  $ mautrix-imessage-v2 --version
  mautrix-imessage v0.1.0 (built at Sat, 11 Apr 2026 03:54:01 UTC with go1.26.0)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…nthe

Preserves the bridge's "Local NAC on macOS via AAAbsintheContext" behavior
without patching upstream rustpush. The path:

  rustpush::macos::MacOSConfig::generate_validation_data
    → open_absinthe::nac::ValidationCtx::new()
        → nac_validation::generate_validation_data()
            → AAAbsintheContext (AppleAccount.framework)

open-absinthe is OUR crate (lives at rustpush/open-absinthe/, re-exported to
third_party/rustpush-upstream/open-absinthe/ by the Makefile for the build
graph). We modify it freely — this is refactoring our own code, not patching
rustpush upstream.

## What changed

**rustpush/open-absinthe/Cargo.toml**
- New optional dependency `nac-validation` (cfg target_os = "macos")
- New feature `native-nac = ["dep:nac-validation"]`

**rustpush/open-absinthe/src/nac.rs — ValidationCtx refactor**
- Struct rewired to hold `inner: ValidationCtxInner`, an enum with two
  variants: `Emulator { uc, state, validation_ctx_addr }` (the existing
  unicorn-based XNU emulator path) and `Native { bytes }` (new macOS native
  path, cfg-gated).
- `new()`: on `#[cfg(all(target_os = "macos", feature = "native-nac"))]`,
  calls `nac_validation::generate_validation_data()` first. On success,
  returns the Native variant with the bytes stashed and writes an empty
  `out_request_bytes`. On failure, logs a warning and falls through to the
  emulator path. Emulator path preserved unchanged for Linux and macOS
  without the feature.
- `key_establishment()`: dispatches on variant — Native is a no-op (the
  AAAbsintheContext already consumed Apple's response internally inside
  nac-validation), Emulator retains existing unicorn logic.
- `sign()`: dispatches on variant — Native returns a clone of the cached
  bytes, Emulator runs the existing unicorn NAC_SIGN path.

**pkg/rustpushgo/Cargo.toml**
- Direct dep on open-absinthe (macOS target) with `native-nac` feature
  enabled. Cargo unifies this with rustpush's internal path dep on
  open-absinthe (same source tree); features are additive, so the flag
  propagates.

**pkg/rustpushgo/src/local_config.rs**
- Removed the unused `encode_hex` helper (warning cleanup after my earlier
  deletion of the LocalMacOSConfig OSConfig impl).

**Makefile (ensure-rustpush-source)**
- After cloning rustpush into third_party/rustpush-upstream, now always
  overlays our modified `rustpush/open-absinthe/` into
  `third_party/rustpush-upstream/open-absinthe/`. This ensures the native-nac
  feature code is present in the build graph on every clone — without it,
  the upstream submodule would be either the SSH OpenBubbles/OpenAbsinthe-Stub
  URL (unfetchable) or the stale cameronaaron/rustpush copy.
- Also adds a fallback overlay of `rustpush/apple-private-apis/` when the
  submodule clone fails (SSH URL issue).

## Architectural note (why this works without patching upstream)

Upstream `rustpush::macos::MacOSConfig::generate_validation_data` calls
`open_absinthe::nac::ValidationCtx::new/key_establishment/sign`. Those
methods are ours (we OWN open-absinthe). By adding a native-nac code path
INSIDE ValidationCtx, we transparently change what MacOSConfig does without
touching a single line of rustpush upstream. The `impl OSConfig` stays in
upstream's macos.rs; we just swap the implementation of a lower-level
building block it depends on.

The `_enc` HardwareConfig fields are now irrelevant on macOS because the
native path never reaches the unicorn emulator that consumes them.

## Known runtime caveat

MacOSConfig's flow still does two HTTP round-trips to Apple's
id-initialize-validation endpoint *after* our native path has already
returned the final bytes. The empty `out_request_bytes` we write may cause
Apple to return an error response; we discard it in the Native variant's
no-op key_establishment and return native bytes from sign() anyway. If Apple
hard-rejects the POST with a non-plist body, MacOSConfig's plist parse fails
before reaching sign() — in which case we'd need to write a minimal valid
plist dummy to out_request_bytes or intercept the HTTP call. Will verify at
runtime and iterate.

macOS build: GREEN
  mautrix-imessage v0.1.0 (built at 2026-04-11T04:19:10Z with go1.26.0)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Preserves the exact pre-refactor macOS Local NAC behavior on OpenBubbles
upstream with zero rustpush changes. The old master LocalMacOSConfig
impl'd OSConfig directly so it could replace generate_validation_data
entirely with nac_validation::generate_validation_data(); that's
impossible against upstream because OSConfig::build_activation_info
returns activation::ActivationInfo from a private module.

Solution: split nac-validation's NAC protocol into three callable steps
(NACInit / NACKeyEstablishment / NACSign) and have open-absinthe's
ValidationCtx Native variant delegate each step to AAAbsintheContext.
Upstream MacOSConfig::generate_validation_data drives its normal cert
fetch → id-initialize-validation POST → key-est → sign flow end-to-end,
just with Apple's own framework producing the cryptographic output
instead of the unicorn emulator.

- nac-validation: add nac_ctx_init / nac_ctx_key_establishment /
  nac_ctx_sign / nac_ctx_free C entry points plus a safe Rust
  NacContext wrapper with Drop-based cleanup.
- open-absinthe (ours): ValidationCtx::Native now holds a NacContext
  behind UnsafeCell so sign(&self) can call its &mut methods; new()
  returns real request bytes from NACInit (replacing the empty-body
  shortcut that would have caused Apple to reject the POST).
- Apple sees exactly one POST to id-initialize-validation per
  validation, byte-identical to vanilla upstream rustpush — no flag
  risk, no double POST, no stub request bytes.

No build-dependency changes (bindgen deliberately avoided — the
existing hand-written extern "C" pattern in nac-validation is the
established shape for this crate; AGENTS.md's "always regenerate"
rule is specific to the uniffi Go bindings).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…h edits)

CloudKit video attachments hit MMCS cross-batch deduplication: when the
same video is uploaded twice, MMCS serves ONE blob encrypted with the
original uploader's Ford key, but each CloudAttachment record carries
its OWN Ford key in lqa.protection_info. Upstream rustpush's get_mmcs
panics on the SIV decrypt .unwrap() in that case — no cache, no
fallback, no retry. The 94f7b8e fix added a process-wide Ford key
cache + fallback retry inside mmcs.rs; this refactor preserves those
semantics entirely outside the rustpush crate.

Architecture:

* pkg/connector/ford_cache.go — Go-side FordKeyCache keyed by
  fordChecksum (0x01 || SHA1(key)), exercised by sync_controller and
  unit-tested independently of CloudKit.
* pkg/rustpushgo/src/lib.rs — wrapper-level static Ford cache with
  `register_ford_key` / `ford_key_cache_size` exported via uniffi so
  Go can populate it during sync. The wrapper's cloud_download_attachment
  now wraps the normal download_attachment call in catch_unwind, and
  on SIV panic falls through to cloud_download_attachment_ford_recovery:
  fetches the CloudAttachment record directly via public
  FetchRecordOperation / FetchedRecords APIs, then iterates cached
  Ford keys, mutating lqa.protection_info.protection_info per attempt
  and retrying container.get_assets (also in catch_unwind). The first
  candidate key that SIV-decrypts cleanly returns bytes; all-failed
  surfaces as a typed error, not a process abort.
* pkg/connector/sync_controller.go — during CloudKit attachment sync,
  every record's lqa.protection_info and avid.protection_info Ford
  keys are registered into BOTH the Go cache and the wrapper cache so
  the recovery path has cross-batch visibility from the first minute.
* pkg/rustpushgo/src/lib.rs WrappedCloudAttachmentInfo grows `ford_key`
  and `avid_ford_key` fields (Option<Vec<u8>>), populated in
  cloud_sync_attachments from the CloudAttachment's PCS-decrypted
  protection_info bytes.

Same observable behavior as old master:
* Non-dedup downloads work identically (catch_unwind happy path).
* Dedup downloads that used to panic now enter recovery and return
  the real file bytes on first successful cached-key match.
* Sync logs show "register_ford_key: cached Ford key for dedup fallback"
  per record and Ford recovery logs mirror the Rust fix's "Ford SIV
  succeeded with cached key (dedup resolved, attempt X/N)".
* Matches 94f7b8e's all-nighter fix in algorithm, log wording, and
  final download semantics.

Makefile cleanup:

* Replaced the `RUSTPUSH_SOURCE=fork` / Cameron's-repo scaffolding with
  a single pinned-SHA flow reading third_party/rustpush-upstream.sha
  (currently 937dc64664303a7ad64b5ff2e2777ee9db56230a, OpenBubbles HEAD
  at refactor start). Manual bump: edit the .sha file, test locally,
  commit. No auto-bump, no branch drift, no fork URLs.

Ford cache tests (pkg/connector/ford_cache_test.go) cover: checksum
format (0x01||SHA1), round-trip register/lookup, cross-batch
simulation, empty-key ignore, idempotent re-register, caller-slice
isolation. All pass under CGO_CFLAGS/CGO_LDFLAGS for libolm.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Upstream OpenBubbles CloudAttachment has only `cm` and `lqa` fields;
our vendored fork added `pub avid: Asset` to expose the Live Photo MOV
companion. Instead of overlaying the struct, this commit defines a
local `CloudAttachmentWithAvid` in pkg/rustpushgo/src/lib.rs that
derives `CloudKitRecord` from the same `attachment` record type and
declares all three fields. Same on-the-wire schema, same PCS decryption
path — only the Rust type we decode into changes.

Effects:

* cloud_sync_attachments is hand-rolled (same structure as upstream's
  CloudMessagesClient::sync_records) to decode records as
  CloudAttachmentWithAvid, register BOTH `lqa.protection_info` and
  `avid.protection_info` Ford keys into the wrapper cache, and return
  real `has_avid` values (`att.avid.size.unwrap_or(0) > 0`) instead of
  the previous feature-gated `false` fallback.
* cloud_download_attachment_avid fetches the record as
  CloudAttachmentWithAvid and calls container.get_assets directly with
  `&record.avid`, wrapped in catch_unwind for SIV panic safety. On
  Ford dedup panic it falls through to cloud_download_avid_ford_recovery
  which iterates cached Ford keys mutating `avid.protection_info`
  per attempt — same recovery algorithm as the lqa path.
* cloud_supports_avid_download always returns true; the
  rustpush-avid-download cargo feature is deprecated and no longer
  gates behavior.
* Added a direct `cloudkit-proto` path dep (alongside the transitive
  dep via rustpush) so the CloudKitRecord derive macro's generated
  code can resolve `cloudkit_proto::*` references from our scope. The
  `sha1 = "0.10"` dep was also added for the Ford checksum in
  register_ford_key.

PCS decode of each record is wrapped in catch_unwind, matching the
existing pattern at 5+ other PCS-reaching wrapper call sites (PCS
decode can panic on malformed fields; we catch and skip-the-record
instead of crashing the bridge).

Makefile cleanup:

* Dropped the RUSTPUSH_SOURCE=fork scaffolding and cameronaaron's fork
  URLs entirely. Single pinned-SHA flow: reads third_party/rustpush-
  upstream.sha, checks out that exact commit from OpenBubbles/rustpush,
  aborts if the file is missing. Manual bump: edit the .sha file,
  rebuild, test, commit. No auto-bump, no fork refs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Adds `refactor` to the push/PR branch list so every commit on the
  refactor branch gets the full macOS + Linux build + tests gate.
* Adds `workflow_dispatch: {}` trigger for manual re-verification
  without pushing a commit (useful after editing
  third_party/rustpush-upstream.sha locally).
* New "Verify pinned rustpush SHA file" step in both build jobs that
  fails loudly with a GitHub annotation if third_party/rustpush-
  upstream.sha is missing or empty. The Makefile already fails hard
  in the same case; this surfaces it earlier with a clearer message.
* Cache key includes third_party/rustpush-upstream.sha so bumping the
  pin invalidates the Cargo cache on the next run (prevents stale
  compiled artifacts from surviving a rustpush source swap).

No auto-bump job, no scheduled pipeline, no fork URLs. The SHA bump
flow is fully manual: edit the .sha file, test locally, commit, push.
CI re-verifies build + tests on push/PR as the final gate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds `if: github.event_name == 'workflow_dispatch'` to the build-macos
job so it only runs when explicitly triggered via Actions → Run
workflow. Push and PR events still run lint, test-imessage, and
build-linux automatically — Linux catches the vast majority of
breakage and macOS parity is a nice-to-have that's cheap to trigger
on demand (e.g. after bumping third_party/rustpush-upstream.sha or
touching platform-gated code in pkg/rustpushgo or local_config).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The CI exists for one purpose: tell us whether the refactor branch
still builds and tests pass against the currently pinned rustpush
SHA. It's the gate for "can we bump rustpush." No one's downloading
the .app bundle or coverage profile — those were holdover noise from
Cameron's release pipeline.

Removed:
* `Upload coverage artifact` in test-imessage, build-macos, build-linux
* `Upload macOS app bundle` in build-macos
* `Upload Linux binary` in build-linux
* `-coverprofile` / `-v` flags on go test calls — coverage isn't
  needed if we're not uploading, and quieter test output is easier
  to scan in the Actions UI

Kept: everything that actually verifies correctness — lint, imessage
unit tests, Linux build, macOS build (manual dispatch), pkg/connector
tests on both platforms, the pinned-SHA verify step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
OpenBubbles/rustpush's .gitmodules declares submodules via
`git@github.com:...` (SSH), which fails on CI runners and any
workstation without SSH keys configured for github.com. The
previous failure mode was subtle: submodule clone would fail, the
Makefile's fallback would overlay our stale vendored copy of
`rustpush/apple-private-apis/`, and the build would then fail with
cryptic "no GenerateVerificationTokenRequest in the root" errors
because the vendored copy is older than what upstream's pinned
SHA expects.

Fix: after clone and before `submodule update --init --recursive`,
run `git config url."https://github.com/".insteadOf "git@github.com:"`
on the rustpush-upstream clone. This rewrites the SSH URLs in-place
for this repo only (not global), so HTTPS is used for all submodule
fetches. Works on CI, works on a fresh workstation, and is a no-op
if SSH keys happen to be available.

Linux CI now correctly pulls the up-to-date apple-private-apis
submodule (commit e1c2b0b) which contains
GenerateVerificationTokenRequest.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tance

Two bugs surfaced on a Linux box with stale local state:

1. Stale local modifications to files inside
   third_party/rustpush-upstream/src/ (from earlier runs or manual
   tampering) blocked `git checkout <pinned_sha>` with "would be
   overwritten" and the Makefile silently continued, leaving the
   tree at the wrong commit. Now we `git reset --hard HEAD` and
   `git clean -fd` before checkout to discard any local mods, and
   every git step uses `|| exit 1` so a failure aborts the build
   with a clear error instead of marching on with a broken tree.

2. Nested submodule clones (e.g. `apple-private-apis/clearadi`,
   which is a sub-submodule of apple-private-apis) don't inherit
   the URL rewrite set via `git config url.insteadOf` on the
   top-level rustpush-upstream clone — each nested submodule
   spawns a fresh git subprocess that reads its own local config
   and the parent's system/global config, NOT the parent repo's
   local config. So clearadi kept falling back to SSH and failing.

   Fix: use GIT_CONFIG_COUNT / GIT_CONFIG_KEY_0 / GIT_CONFIG_VALUE_0
   environment variables instead of local repo config. These
   propagate through subprocess boundaries (nested submodule inits
   inherit them) and get the URL rewrite applied at every level.
   No global git config mutation — the env vars are exported only
   for the Makefile's ensure-rustpush-source recipe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Upstream OpenBubbles MacOSConfig doesn't have nac_relay_url/relay_token/
relay_cert_fp, so our previous wrapper code wouldn't compile against
upstream. This commit preserves the Apple Silicon → Linux relay path
end-to-end without modifying upstream rustpush, using the same pattern
we used for macOS Local NAC (local state + open-absinthe ValidationCtx
variant + 3-step protocol via existing nac_ctx_* C API).

* pkg/rustpushgo/src/lib.rs — JSON parsing no longer uses upstream
  MacOSConfig directly (would drop the relay fields). Uses a local
  FullHardwareKey struct that declares both the upstream MacOSConfig
  fields AND the relay fields, splits them, stashes the relay config
  via register_nac_relay() → open_absinthe::nac::set_relay_config(),
  and builds upstream's MacOSConfig with only the fields it has.
  Existing hardware-key JSON blobs (including those with relay info
  embedded in the base64 string) are read without any migration.

* rustpush/open-absinthe/src/nac.rs — adds static RELAY_CONFIG +
  set_relay_config(url, token, cert_fp) setter. ValidationCtxInner
  gets a new Relay variant holding the config and a session_id.
  ValidationCtx::new() checks relay config FIRST (before the macOS
  native-nac and Linux emulator paths) and, if set, POSTs to
  {url}/nac/init with the cert, parses out session_id + real Apple-
  accepted request_bytes, writes those into out_request_bytes so
  upstream MacOSConfig's Apple id-initialize-validation POST
  succeeds, and returns Relay variant. key_establishment POSTs
  session_info to /nac/key_establishment, sign() POSTs to /nac/sign
  and returns the final validation bytes. Uses ureq + native-tls
  with danger_accept_invalid_certs(true) to match the master-branch
  reqwest client's behavior for self-signed relay TLS certs.

* tools/nac-relay/main.go — adds three new endpoints alongside the
  existing /validation-data single-shot path:
    POST /nac/init              → {session_id, request_bytes}
    POST /nac/key_establishment → {ok}
    POST /nac/sign              → {validation_data}
  All three use the nac_ctx_init/key_establishment/sign C functions
  that already exist in nac-validation/src/validation_data.m (added
  earlier for macOS Local NAC). In-memory session store keyed by
  128-bit random hex IDs with a 2-minute TTL. Opportunistic GC of
  expired contexts on every saveSession call. Both the single-shot
  and 3-step endpoints share the same bearer-token auth middleware.

  Users running the master-branch nac-relay need to redeploy the
  binary once to get the 3-step endpoints. Existing single-shot
  clients still work via the preserved /validation-data path.

Same observable behavior as master's relay flow — same bearer token,
same TLS handshake (self-signed accept), same final NACSign bytes
returned from AAAbsintheContext. Only the client/server wire
protocol changes from single-shot to 3-step so that upstream
MacOSConfig's unmodified HTTP flow (fetch cert → POST request_bytes
to Apple → feed session_info back to the ctx → sign) composes with
a remote relay the same way it composes with native AAAbsintheContext
on macOS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…relay_config

The wrapper's `register_nac_relay` calls `open_absinthe::nac::set_relay_config`
on every platform, but `open-absinthe` was only declared in the
`[target.'cfg(target_os = "macos")'.dependencies]` block — so the Linux
build couldn't resolve the crate. Moved it to the base [dependencies]
without features. The macOS-only block still enables the `native-nac`
feature via Cargo feature unification, so Local NAC on macOS continues
to work. Linux just gets the plain crate, which is what it needs to
call set_relay_config for the 3-step relay path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Upstream rustpush's `DebugMutex`/`DebugRwLock` logs every lock/unlock
at `info!` level. On the refactor branch this floods stdout with
hundreds of `Locking/Locked/Reading/Writing mutex at .../aps.rs:...`
lines per registration attempt, which stomps on interactive prompts
like the Apple ID login flow.

Master never had this problem because master's vendored rustpush
didn't include the mutex instrumentation. Now that we use upstream
OpenBubbles (which carries it), the default log filter needs to
silence it to restore master's clean UX.

Fix: extend the default RUST_LOG from "info" to
"info,rustpush::util=warn" in init_logger. Users who actually want
the mutex contention trace can still set RUST_LOG=debug or
RUST_LOG=info,rustpush::util=info manually to re-enable it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
$(RUST_LIB) listed `ensure-rustpush-source` as a normal prerequisite.
Because that target is .PHONY, make treats it as "always newer than
any file" and forces $(RUST_LIB)'s recipe to run every single
`make rust` / `make build`, which re-invokes `cargo build --release`
even when nothing has changed (cargo then has to stat every source
file and decide it has nothing to do — still several seconds of
overhead per build).

Fix: demote `ensure-rustpush-source` to an order-only prerequisite
via the `|` separator. Order-only phony prereqs still run before
the recipe (so the pinned-SHA checkout + open-absinthe overlay
still happens), but their always-dirty status doesn't dirty the
parent target. $(RUST_LIB) now rebuilds only when $(RUST_SRC),
$(RUSTPUSH_SRC), or $(CARGO_FILES) are newer than librustpushgo.a
— the expected behavior.

Warm `make rust` after this change: a couple of shell conditionals
and exit 0, no cargo invocation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
rustpush::aps logs its APNs send loop at info! level — every few
seconds during steady state it prints "Sending", "Attempting to
send", "Updating topics", "Updated". With the util-mutex spam
silenced, the aps output is still enough to stomp on interactive
prompts (Apple ID login) and make typing credentials impossible.

Extend the default RUST_LOG filter from
  "info,rustpush::util=warn"
to
  "info,rustpush::util=warn,rustpush::aps=warn"

so steady-state APNs loops stay quiet. Connection errors and
warnings still surface at WARN, and users debugging APNs can
opt back in with
  RUST_LOG=info,rustpush::util=info,rustpush::aps=info
or RUST_LOG=debug.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ensure-rustpush-source was unconditionally running `rm -rf` + `cp -R`
on the open-absinthe overlay every invocation. That touched the
mtimes on every file under third_party/rustpush-upstream/open-absinthe/.
Those files are captured in $(RUSTPUSH_SRC), which is a normal
prereq of $(RUST_LIB) — so make kept seeing "freshly touched"
source files, kicked cargo, produced a new librustpushgo.a, and
that in turn dirtied $(BINARY) and triggered a full go build
every `make` invocation.

Fix is two-pronged:

1. Guard the overlay with `diff -rq` so it only runs when the
   source and destination trees actually differ. On warm runs the
   overlay is a no-op and mtimes on the dest tree stay put.
2. Use `cp -Rp` instead of `cp -R` when the overlay does run, so
   even the first-after-change overlay preserves the source file
   mtimes on the destination tree — make's staleness check then
   compares against the stable source mtimes instead of "now".

Net effect: warm `make build` with no source changes does zero
cargo work and zero go work — the Rust static lib and Go binary
are both considered up-to-date against their actual source files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tore

The cascade of "MobileMe delegate not seeded — call seed_mme_delegate_json
first" CloudKit FFI errors on bridge restart was caused by Go
skipping the seed step after RestoreTokenProvider and relying on
an auto-refresh chain (get_mme_token → refresh_mme →
login_apple_delegates) that existed on master but was removed from
WrappedTokenProvider during the upstream merge.

The wrapper's restore_token_provider intentionally returns a
WrappedTokenProvider with empty mme_delegate_bytes — the comment
in the Rust source is explicit that callers must
seed_mme_delegate_json() from persisted state before using keychain
or contacts features. Go was not doing that; on every restart the
token provider came up empty and every CloudKit attachment/chat/
message sync and every recycle-bin query hit the FFI error path.

Fix: after RestoreTokenProvider, call tp.SeedMmeDelegateJson with
meta.MmeDelegateJSON if it's present (captured during the most
recent successful login and already persisted in UserLoginMetadata).
If the persisted delegate is expired, the eventual CloudKit call
will surface an auth error the bridge can handle by prompting
re-login. If there's no persisted delegate at all, log a clear
warning so the user knows CloudKit won't work until they re-login.

This restores master's behavior one layer up from the wrapper
without needing the removed auto-refresh chain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…d keys

Matches master's sync_attachments behavior. With NO_ASSETS the
CloudKit record fields came back with `protection_info.protection_info`
either stripped or truncated in the serialization path that
upstream's RetrieveChanges response produces, so every Ford key
we decrypted and registered into the cache was either zero-length
or garbage. The cache reached 911 entries but none of them matched
the `fordChecksum` on any dedup'd chunk, so every
cloud_download_attachment_ford_recovery brute-force iteration
failed SIV decrypt at upstream mmcs.rs:1113 — the exact symptom
the user was seeing.

Master's equivalent (`sync_records_with_assets::<CloudAttachment>(
"attachmentManateeZone", continuation_token, &ALL_ASSETS)`) used
ALL_ASSETS for this reason. Our hand-rolled reimplementation was
following upstream's internal `sync_records` which uses NO_ASSETS,
not master's attachment-specific override. Switching to ALL_ASSETS
in cloud_sync_attachments only (other sync_records call sites stay
on NO_ASSETS since they don't need asset metadata).

No change to sync_chats or sync_messages. No change to rustpush.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rust's default panic hook runs BEFORE catch_unwind catches, so
every wrong-key attempt in cloud_download_attachment_ford_recovery's
brute-force loop floods stderr with
  thread '<unnamed>' panicked at mmcs.rs:1113:101
  called `Result::unwrap()` on an `Err` value
even though the wrapper is handling each one cleanly and continuing
to the next cached key.

Install a custom panic hook in init_logger that drops panics whose
`Location::file()` ends in `icloud/mmcs.rs` (matching both Unix and
Windows path separators) and falls through to the default hook for
everything else. Real panics from other modules still surface.

The semantic recovery was already working — the Ford dedup cache
was producing successful SIV decrypts and transfers were completing.
This commit is cosmetic: it cleans up the log so operators can see
actual sync progress instead of pages of stderr spam during
attachment download.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Upstream OpenBubbles's rustpush has dramatically more info! log
sites than master's vendored copy — ~80+ across mmcs, cloudkit,
keychain (alone has 39), cloud_messages, pcs, aps, util, statuskit,
and findmy. At the default "info" filter, the bridge's useful
Go-side backfill / CloudKit sync progress gets buried under
Locking/Transferring/Fetching/Decrypting/etc. spam.

New default filter: "warn,rustpush=warn,rustpushgo=info". Keeps:
  * WARN + ERROR from everywhere (real problems surface)
  * INFO from our rustpushgo wrapper (Ford recovery, relay wiring,
    NAC native-path diagnostics, backfill entry/exit)
  * Full Go-side bridge logs through zerolog (unaffected by RUST_LOG)

Silences:
  * All rustpush::* info-level chatter

Override for deep debugging:
  RUST_LOG=info                          # master-style (chatty)
  RUST_LOG=info,rustpush::icloud=debug   # cloudkit/mmcs/pcs internals
  RUST_LOG=debug                         # everything

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Upstream's cloudkit::CloudKitOpenContainer::get_assets uses
`.expect("No bundled asset!")` at cloudkit.rs:2075 on
`asset.bundled_request_id`. That field is populated by the CloudKit
server when a record's asset is fetched with download authorization
(ALL_ASSETS); if the server didn't include auth for a given asset
(asset deleted, tombstoned record, or a transient server issue), the
field is None and upstream unconditionally panics.

This isn't a wrapper bug — it's an upstream design choice. Master's
vendored rustpush had the same .expect() and would panic on the same
records; we just hadn't been exercising the dedup recovery path on
master the way the refactor branch does.

Two changes to handle it gracefully:

1. Pre-flight check in cloud_download_attachment_ford_recovery:
   if base_record.lqa.bundled_request_id is None, skip the entire
   retry loop (iterating 900 cached keys against a record that can't
   be downloaded regardless of which Ford key we use is pointless)
   and return a typed WrappedError with a clear message. A warn!
   log fires per occurrence so operators can see the ratio of
   skipped-vs-recovered records.

2. Extend the panic hook to also silence panics from
   icloud/cloudkit.rs (in addition to icloud/mmcs.rs already covered).
   Both are intentional upstream asserts that our catch_unwind
   wrappers catch — the default Rust panic hook was printing them
   to stderr before catch_unwind got to them, flooding the log.

3. Upgrade the Ford recovery success log from info! to warn! and
   reword it to "Ford dedup recovery SUCCESS: record=X attempt=Y/Z
   bytes=N" so it stands out clearly against the other log output
   regardless of filter level. Same treatment for the avid path
   and the failure path ("Ford dedup recovery FAILED: ...").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ys before get_assets

Matches master's cloud_messages.rs::download_attachment pattern that
registers the current record's Ford keys into the cache BEFORE
calling get_assets. My previous version called upstream's
download_attachment directly, which skips the registration step —
so freshly-downloaded records that hadn't yet been sync'd never
contributed their Ford keys to the cache, leaving brute-force
recovery without the keys it needed.

New flow in cloud_download_attachment:
  1. perform_operations(FetchRecordOperation::many(ALL_ASSETS))
  2. FetchedRecords::new(&invoke)
  3. records.get_record::<CloudAttachmentWithAvid>(...) — parses
     lqa AND avid via my local type
  4. register_ford_key(lqa.protection_info.protection_info)
  5. register_ford_key(avid.protection_info.protection_info)
  6. container.get_assets(&records.assets, &record.lqa, shared)
  7. On SIV panic: cloud_download_attachment_ford_recovery_with_record
     (new variant) — reuses the already-fetched records/base_record
     instead of re-fetching, saving a CloudKit round-trip per retry.

The old cloud_download_attachment_ford_recovery helper is still in
place (marked #[allow(dead_code)]) so the avid path can keep using
it until I pipe the with_record variant through there too.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dropping the hand-rolled sync_attachments loop that parsed records
as CloudAttachmentWithAvid with upstream's CloudKitRecord derive.
That path produced cached bytes that didn't match what MMCS needed
for SIV decrypt, causing brute-force recovery to fail on every
cross-batch deduplicated attachment — the exact symptom master
didn't have.

Switching to calling upstream's own `cloud_messages.sync_attachments()`
directly. This is the battle-tested code path master went through
via its vendored tree: same PCS decryption, same CloudAttachment
parsing, same protection_info extraction. Whatever subtle delta
my hand-rolled version introduced is gone.

avid Ford keys and Live Photo download detection now happen at
download time via the existing cloud_download_attachment which
already parses records as CloudAttachmentWithAvid and registers
BOTH lqa and avid Ford keys before calling get_assets. So Live
Photo support is preserved — it's just driven by the download
path instead of being pre-registered during sync.

has_avid is false at sync time because upstream's CloudAttachment
doesn't expose the field. The Go side can still trigger avid
downloads via cloud_download_attachment_avid when the message
metadata indicates a Live Photo (hide_attachment flag on the
companion record, mime_type == image/heic heuristics, etc.).

Also adds the Ford retry cap + recency-first iteration from the
previous uncommitted edit (retry at most 20 cached keys per
failing download, iterate newest-registered first, override via
RUSTPUSHGO_FORD_RETRY_LIMIT env var). Backfill previously took
4-8 minutes per failing record doing 913 HTTP retries each; now
it caps at ~10 seconds per failing record.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
David and others added 29 commits April 15, 2026 16:25
…kfill

Rich-link bubbles in chat.db are stored as the URL in the message body plus a
sidecar *.pluginPayloadAttachment plist that drives the preview card. CloudKit
preserves both. The live-message path filters Rust's synthesized
x-richlink/{meta,image} sidebands, but the CloudKit path was bridging the raw
plist as a downloadable file, surfacing as a stray attachment next to the
preview card.

Filter on filename suffix (the only signal CloudKit reliably keeps) rather
than HideAttachment, because that flag also marks Live Photo MOV companions
which we deliberately bridge. Mirror the filter into preUploadCloudAttachments
to avoid wasting a CloudKit download on a file we'll immediately drop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Upstream's IdentityResource uses the same services slice for two purposes:
the all-or-nothing register() call inside generate(), and the get_main_service
topic-lookup table. cache_keys panics with "Topic {topic} not found!" for any
topic missing from that slice (identity_manager.rs:806).

The earlier split — narrow bundle plus a best-effort separate
register([FACETIME, VIDEO]) — registered the services in IDSUser but never
updated the resource's services slice, so any !im facetime invocation tripped
the .expect() and panicked out of the FFI on CreateSession.

Reverting that workaround: FT/Video go back in the bundle so cache_keys can
look them up. Trade-off captured in-line: a future Apple 6005 on FT/Video
would now wrap as DoNotRetry and permanently close the shared IdentityResource
used by iMessage send. Accepting that risk because no 6005 has been observed
on this account.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…wering

Upstream's FTClient::create_session chains the caller's own handle into
session.members (facetime.rs:598), so prop_up_conv(ring=true) fans the
wire message out to every device under that handle — Mac included. The
line-759 Invitation-type suppression only strips the Invitation marker;
the wire body still has is_initiator_key=true plus full call context,
which Continuity treats as actionable, and the Mac auto-answers.

Immediately after create_session, call prop_up_conv(ring=false) on the
same session. is_ringing_inaccurate is still true (set in create_session,
never cleared), so the ring=false path emits a RespondedElsewhere scoped
only to the caller's own handle (facetime.rs:708-726) — the same message
Apple uses when one of your devices picks up and the others stop ringing.
Receiving devices set is_ringing_inaccurate=false (handle path at
facetime.rs:1241-1250) and drop the call UI.

Side effect: the rest of prop_up_conv(ring=false) sends a second wire
message (no Invitation type) to all members. The callee already received
the ring=true message a moment earlier and should treat the duplicate as
an idempotent state update.

Also expected to fix the wife-shows-up-in-bot-room symptom — that was
almost certainly the Mac auto-answering and emitting a JoinEvent that
the bot room surfaced as "wife joined".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…s own devices

Upstream FTClient::create_session puts the caller's handle into
session.members (facetime.rs:598) and prop_up_conv derives the wire
fanout from there, so every Apple device under the caller's handle gets
the call wire — Mac picks it up via Continuity and auto-answers.

When the Mac picks up, it sends a RespondedElsewhere back to the bridge,
which clears is_ringing_inaccurate on the session. Then when the user
taps the FaceTime link from their actual device, the link-joiner request
arrives as a temp pseudonym (not the user's real handle); the
auto_approve_bridge_letmein fallback chain — linked → member → ringing —
no longer matches the original session (member fails on the temp
mismatch, ringing fails on the cleared flag), so the overlay spins up a
fresh session for the join. Caller and callee end up in different
sessions, neither can hear the other.

We can't suppress the wire fanout from inside upstream, but FTSession,
ensure_allocations, and prop_up_conv are already pub. Mirror the
upstream sequence here and temporarily strip the caller's handle from
session.members during prop_up_conv so the wire only fans to the target.
ensure_allocations still runs with the full member set so the bridge
gets its own quickrelay participant entry — without that, prop_up_conv
panics on NoParticipantTokenIndex. Restore the caller to members after
the ring so add_members / decline_invite / etc. see a complete set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…s handle

Reapply the &n=<name> slug on outbound !im facetime links — same
mechanism we tried in f168c0d and reverted in 8d9c8f2. The reason it
displayed gibberish before wasn't the param, it was the encoding:
url.QueryEscape percent-encodes + to %2B (it's form-encoding, where +
means space), and Apple's web FT fragment parser appears to display the
encoded form literally rather than decoding it — so callers saw
"%2B18454996730" instead of "+18454996730".

Use a minimal fragment escape that only percent-encodes #, &, % (the
characters that would actually break fragment parsing) and leaves +, @,
digits, letters as-is. Source the slug from client.handle (the
preferred handle from config.yaml) so the prefill matches the account
the caller is calling from.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ty with handleReadReceipt

Beeper stopped showing the "delivered" indicator on outgoing iMessages
while read receipts kept working. The asymmetry traces to commit 8a0c6f4
("use SendMessageStatus for delivery receipts instead of simplevent.Receipt"),
which replaced the framework-driven `bridgev2.RemoteEventDeliveryReceipt`
path with a direct `SendMessageStatus` call. The framework had robust
internal portal resolution; the new direct path implemented only a single
`GetExistingPortalByKey(initialPortalKey)` lookup with silent return on miss.

handleReadReceipt subsequently accumulated five fallback resolution paths —
gid: portal lookup using sender_guid, imGroupGuids cache, findGroupPortalForMember,
last-resort initial-key check, and a UUID-based portal override via
resolvePortalByTargetMessage — plus queueGhostReceiptFallback at every drop
point. handleDeliveryReceipt got none of them, so as makeReceiptPortalKey's
output drifted (sender_guid format churn, group portal restructure) the
delivery path silently dropped while the read path recovered via fallbacks.

Mirror handleReadReceipt's resolution chain in handleDeliveryReceipt and
add Debug logging at every remaining silent-return point so the next
data-shape regression won't leave us blind. Login + clique-join paths are
untouched — this is purely the receive-side handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…n approve

Two issues falling out of yesterday's create_session wrap.

1. Link name prefill rendered as "?_8?[??" gibberish. Apple's web FT page
   base64-decodes the &n= value (matches the &k= and &p= pattern, both of
   which are URL-safe base64 of binary data — see upstream
   facetime.rs:100/557). Sending raw "+18454996730" was being run through
   atob(), producing 9 bytes (0xFB 0x5F 0x38 0xE7 0x9E 0x3D 0xF7 0xAE 0xF7)
   that render as exactly the gibberish observed. Fix: encode the bare
   handle with base64.RawURLEncoding before appending.

2. Tapping the link still rang the caller's Mac. The wrapped create_session
   strips own handle from session.members during the initial ring fanout,
   but auto_approve_bridge_letmein then calls upstream's respond_letmein,
   which internally fires add_members -> update_conversation -> wire fanout
   to (session.members ∪ new_members). By that point the handle is back in
   members, so the AddMember wire goes to every device under it including
   the Mac. Strip it again immediately before respond_letmein and restore
   after (add_members replaces session.members on its way out, so the
   restore re-inserts what add_members would otherwise lose).

Also extracted the strip-and-restore create_session sequence into a free
helper (create_session_skip_own_devices) so both
WrappedFaceTimeClient::create_session and the cold-start path inside
auto_approve_bridge_letmein use the same code instead of one wrapping and
the other not.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Delivery receipts arrive within seconds of a send — almost always inside
the 30s post-reconnect window — so the IsStoredMessage check was silently
dropping nearly every live "delivered" receipt. Read receipts have the
same gate but naturally land later (the user has to actually read), which
is why only delivered visibly broke.

Confirmed in production logs:
  Stored message detected: uuid=... delta=14901ms (window=30000ms)
  Skipping stored delivery receipt

SendMessageStatus is idempotent, so even on a genuine post-reconnect
APNs replay re-emitting an already-shown delivered tick is a no-op.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…'s ring

Stripping the bridge owner's handle from session.members around
respond_letmein was meant to suppress the AddMember wire fanout to the
owner's own devices on link tap, so the Mac wouldn't ring when the
caller joined via web FT. But the strip appeared to also break the
initial ring on subsequent !im facetime invocations — wife stopped
ringing at all.

The mechanism isn't obvious: respond_letmein only fires after a link
tap, well after wife is supposed to have already started ringing from
create_session. So either there's a state race I'm not seeing, or
something about the mutated session.members is persisting in a way
that affects the next outbound call.

Backing out the strip until we can either reproduce the regression
cleanly or expose IDSSendMessage from upstream so we can craft a
self-only RespondedElsewhere instead of mutating members.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BindBridgeLinkToSession sets link.session_link on the persistent
"bridge" link immediately after CreateSession, so the letmein approver's
linked_group branch matches deterministically on the first tap — no
more falling through to member/ringing heuristics that miss under
cold-start / stale-state and fabricate an empty session (the "0 people"
symptom).

Also adds two info logs to diagnose the "wife's phone doesn't ring"
side: create_session now logs ring_targets + is_propped +
is_ringing_inaccurate after prop_up_conv, and auto_approve logs
match_kind (linked | member | ringing | cold-start) so we can tell on
the next run whether the pin took and which branch routed the tap.
…irect

82e002a wrapped upstream's create_session with a strip that pulled the
caller's handle out of session.members around prop_up_conv, so the wire
ring wouldn't fan out to the owner's other Apple devices (Mac, iPad).
The original motivation was that a Mac auto-answer sent
RespondedElsewhere back to the bridge, cleared is_ringing_inaccurate,
and broke auto_approve_bridge_letmein's ringing-group fallback for link
taps.

583369d's bind_bridge_link_to_session pins link.session_link to the
outgoing session id immediately after create, so the approver's
linked_group branch matches deterministically regardless of
is_ringing_inaccurate (confirmed on the last test run —
match_kind=linked). The strip's original justification is moot.

Empirically the strip also correlated with the callee not ringing on
outbound (log showed prop_ok=true, ring_targets=[wife], is_propped=true,
but wife's phone never rang — own was absent from update_context.members
and fanout_groupmembers in the Invitation wire, which Apple's FT routing
appears to reject as malformed). Calling upstream directly sends a
well-formed Invitation.

Side effect: the owner's devices will ring too. Acceptable for now;
future work is a targeted prop_up_conv(false) nudge once the callee
ring is confirmed stable.

Also: inbound-call join link now gets the same &n=<base64-handle>
pre-fill that outbound !im facetime applies (client.go:2870-ish), so
the user lands on the web FT join page with their display name already
populated instead of blank.
Upstream's FTClient::handle() hard-requires decoded_context.message to
be Some on command 207 (someone joined) and command 209 (group updated)
— see facetime.rs:1272 and :1344. Apple has started sending at least
some of these with message=None (server-originated state updates after
link-tap joins, plus the callee's answer ack), and upstream BadMsg's
out. The bridge never records the joiner in session.participants, the
local session state diverges from Apple's authoritative copy, and the
visible symptom on the callee's device is "this call is not available"
when answering.

The fix stays entirely in our wrapper (no upstream source changes — see
feedback_no_patch_rustpush):

- Wrap the receive-loop's ft.handle(msg) call with
  ft_handle_with_join_recovery.
- On any non-BadMsg result (success or other error) return unchanged.
- On BadMsg: re-run identity.receive_message on the cloned msg (it's
  side-effect-free beyond decryption, so a second call is safe); if
  cmd is 207 or 209, deserialize the wire plist into a locally-mirrored
  struct (FTWireMessage's fields are private upstream, but the schema
  is stable — we redeclare the fields we need with the same serde
  rename attrs); insert the joiner into session.participants with
  sensible defaults; emit a synthetic FTMessage::JoinEvent so the
  bridge's downstream pipeline still fires.

Skipped: session.unpack_members (private upstream helper). Member-list
drift is cosmetic — the load-bearing piece for Apple-side state is the
participants map, and that's what we populate.

Pairs with ba96333 (strip removal — wife's phone rings) to close the
outbound call loop end-to-end: she rings, she answers, her answer
no longer trips BadMsg, session state stays consistent.
Old flow: !im facetime → CreateSession (upstream prop_up_conv(ring=true))
→ wife rings immediately → she answers before the caller is in the
session → Apple sees no live participant → "call not available" /
"request declined." Even when the caller tapped the join link, the
race was too tight.

New flow (restored from PR 39's pending-ring design):

1. `!im facetime` calls CreateSessionNoRing — allocates the session and
   propagates to Apple's quickrelay, but prop_up_conv(ring=false) +
   is_ringing_inaccurate=false means no Invitation wire goes out. Nobody
   rings at this point.
2. RegisterPendingRing queues the callee's handle keyed on the session
   guid, filtered so the caller's own implicit self-join doesn't fire.
3. The bridge replies with the join link. The caller taps it. The
   letmein approver adds their web-FT temp pseud as a session member;
   Apple echoes back a JoinEvent.
4. maybe_fire_pending_ring in the receive loop sees the temp-pseud join
   (not the caller's own handle → not filtered), pops the queue, and
   calls ft.ring() against the callee. Her phone rings.
5. She answers. The caller is already a live participant, so Apple's
   side has a real session to connect her to.

Rust changes (pkg/rustpushgo/src/lib.rs):
- New FFI method WrappedFaceTimeClient::create_session_no_ring mirroring
  upstream's create_session skeleton but with is_ringing_inaccurate=false
  and prop_up_conv(ring=false).
- Pending-ring machinery (PendingFTRing, maybe_fire_pending_ring,
  register_pending_ring) was already in place from an earlier PR;
  nothing to add there.

Go changes (pkg/connector/facetime.go):
- fnFaceTimeCallInPortal swaps ft.CreateSession → ft.CreateSessionNoRing,
  then ft.RegisterPendingRing(sessionID, caller_handle, [target], 60s).
- Reply copy updated to match: "Tapping this link will ring <contact>'s
  phone" instead of "their phone is ringing now."

Regenerated uniffi bindings.
Two follow-ups on ee1ee6f (the pending-ring gate for outbound calls):

1. Restore missed-call detection. create_session_no_ring starts the
   session with is_ringing_inaccurate=false so prop_up_conv's
   RespondedElsewhere diversion doesn't fire. That also meant the
   upstream "no participants active + ringing" branch at
   facetime.rs:1411 never tripped, so if the callee declined or timed
   out, the session silently closed instead of marking Missed.

   maybe_fire_pending_ring now flips is_ringing_inaccurate=true at the
   moment the Invitation actually leaves — which is the semantically
   correct point, since that's when the callee's phone starts ringing.
   Upstream's Missed-marker path now trips normally.

2. Missed-call notice uses the bridge flow instead of facetime://.
   The old notice gave the user `facetime://<handle>` and
   `facetime-audio://<handle>` links that only worked on native
   iOS/macOS — tap on Android/web and nothing happened. Now the notice
   posts the same bridge link as `!im facetime`:

   - Mint a no-ring session targeting the caller we missed.
   - Queue a pending ring (1-hour TTL since the user may not see the
     notice immediately).
   - Fetch + pin the persistent bridge link; prefill &n= with the
     owner's handle.
   - On tap, letmein approve adds the owner to the session; their
     JoinEvent fires ft.ring() against the original caller.

   Copy mirrors the outbound command: "Tapping this link will ring X's
   phone … open the link when you're ready to be on camera." Falls
   back to facetime:// only if the bridge-link arm fails, so native
   users don't lose functionality on transient errors.

Refactoring: factored the session+link+pending-ring dance into
armBridgeFaceTimeCall so fnFaceTimeCallInPortal and
handleFaceTimeMissedNotice share one implementation and stay in sync.
facetime:// / facetime-audio:// URL schemes only worked on native
iOS/macOS clients — Android/web saw the raw URL and could do nothing
with it, and the native path bypassed the bridge entirely anyway.

If armBridgeFaceTimeCall fails (session mint, link fetch, etc.), post
the missed-call notice without a callback button instead of degrading
to the native scheme. User can still `!im facetime` in the portal
manually to place the callback, and the notice surfaces the miss
either way.
Switch from GetLinkForUsage (persistent bridge link + letmein indirection)
to GetSessionLink (session-specific). With the persistent link, tapping
routed the caller through auto_approve_bridge_letmein, and the JoinEvent
that drives maybe_fire_pending_ring had to match through the linked_group
fallback chain. With a session-specific link the caller joins the session
directly and the JoinEvent fires cleanly for the pending ring to target
wife's phone.

Matches the pattern from PR39 which worked end-to-end. BindBridgeLinkToSession
is no longer called from Go; the FFI method stays in place as a harmless
unused helper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The !im facetime setup chains CreateSessionNoRing → RegisterPendingRing →
GetSessionLink, and the two APNs-backed calls (create + get_session_link)
both surface transient SendTimedOut when APNs drops mid-flight. Our bridge
had been hitting this window repeatedly — the APNs reconnect grace is 30s
on our side, so a short bounded retry lands on the restored connection
instead of returning an error to the user.

GetSessionLink's retry is safe: the session.link is persisted before the
message_session fanout, so the second call returns via the early-return
branch without re-sending.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two bugs preventing both outbound and inbound FaceTime from connecting:

1. Outbound: GetSessionLink creates links with usage=None (upstream
   behavior). auto_approve_bridge_letmein gated on usage=="bridge" so
   session-specific links never got approved — user taps link, LetMeIn
   fires, bridge ignores it, web FT hangs forever, no JoinEvent, no
   pending ring, wife never rings. Fix: widen the gate to also accept
   links where session_link.is_some() (these are bridge-created
   session-specific links, equally safe to auto-approve).

2. Inbound: handleFaceTimeRingNotice fell back to the persistent bridge
   link when the caller didn't embed a URL. That link's stale
   session_link (from a prior auto_approve) routed the user to the
   wrong session, so "answer" connected to a dead call. Fix: extract
   the session guid from the marker text and call GetSessionLink(guid)
   to mint a link that joins the caller's actual session.

Also reorder auto_approve fallback to ringing > linked > member. An
actively-ringing session (inbound call) is always the user's immediate
concern; a stale linked_group from a prior outbound would otherwise win
and route to the wrong session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…otency

Three retry layers for the FT LetMeIn path that was dying on APNs flaps:

1. FT handle level: upstream's handle_letmein sends a delegation
   message_session INSIDE handle() before our auto_approve even runs. If
   APNs flaps there, the entire LetMeIn drops. Now retries once after 2s.

2. respond_letmein level: retries up to 3x with backoff. On retry, strips
   delegation_uuid so respond_letmein doesn't hit the "Already responded"
   early-return (first call removed it from delegated_requests but failed
   at the subsequent send). Duplicate LetMeInResponse is harmless; web
   client decrypts the first. add_members is idempotent (already-present
   member triggers ring instead of re-add).

3. Go-side armBridgeFaceTimeCall: retryOnAPNsFlap already covers
   CreateSessionNoRing and GetSessionLink (prior commit).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SMS relays may normalize UUID case, causing delivery receipts to miss
their target message in the bridge DB. Fall back to upper/lower case
lookup before dropping the receipt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When APNs drops mid-send ("early eof"), the connection reconnects within
seconds but the in-flight send times out. Wrap all outbound send paths
(message, attachment, edit, unsend, tapback, read receipt, typing) with
retrySendOnAPNsFlap — same pattern already used for FaceTime calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the raw UUID argument with an interactive numbered list matching
the contact-search and restore-chat UX patterns. Users now type `off`
and pick from Do Not Disturb, Sleep, Driving, Personal, or Work instead
of memorising Apple mode identifiers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ists

Prevents the bridge from subscribing to or inviting its own handles in
StatusKit operations, which wastes APNs quota and can cause self-loops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Contacts may key back under a different handle form than their ghost ID
(e.g. mailto: vs tel:). request_handles does exact string matching
against the ghost list, so cross-form keys were silently unsubscribed —
no APNs channel, no presence updates, ever.

Augment the ghost handle list with every "from" handle persisted in
statuskit-state.plist so request_handles matches all available channels
regardless of handle form.

Also add missing bridge_id filter to the ghost query in
subscribeToContactPresence (the other two StatusKit ghost queries
already had it).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fill allowed_modes with standard iOS Focus mode IDs (DND, Sleep,
  Driving, Personal, Work) instead of sending an empty list. iOS may
  silently ignore key-sharing invites with no allowed modes.
- Add per-handle target breakdown logging so we can see which contacts
  have IDS delivery targets and which don't.
- Log invite_to_channel completion for end-to-end send confirmation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace opaque UUID-based !shared-albums and !shared-photos commands
with a 3-step numbered picker: browse albums by name, browse assets
by filename/date/dimensions, then download selected assets into a
dedicated deletable Matrix room through the bridge's full media
pipeline (HEIC→JPEG, video transcoding, thumbnails).

Rust FFI additions: list_albums(), get_album_assets(), download_file()
with new SharedAlbumInfo and SharedAssetInfo uniffi records.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…g level

The manual !statuskit-invite-channel command uses
WrappedStatusKitClient.invite_to_channel, not
Client.invite_to_status_sharing. Previous diagnostic logging only
covered the automated path. Add info-level logging to both paths.

Also raise rustpush crate log level from warn to info so upstream
IDS send/receive diagnostics (target counts, delivery confirmations,
key lookups) are visible in the journal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@mackid1993
Copy link
Copy Markdown

I believe this pull request was open accidentally. It is not meant for this repository.
I'm not sure how it keeps getting pushed to because the pull request in our repository is already closed.

Perhaps someone should close this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants