Android-native migration: shared Rust client-core/client-runtime through F2b - #8
Open
deymosh wants to merge 155 commits into
Open
Android-native migration: shared Rust client-core/client-runtime through F2b#8deymosh wants to merge 155 commits into
deymosh wants to merge 155 commits into
Conversation
F0 probe-1 from the native-android migration plan. Answers, with running code, whether a UniFFI-generated API for the future Rust client-core is ergonomic from Kotlin/Compose and usable from Tauri. Verdict: GO. Exercised through the real FFI (JNA against the cdylib): - foreign-implemented callback trait (CoreListener) - async fn command -> Kotlin suspend fun (Core.dispatch) - typed errors -> sealed class CoreException subclasses - object lifecycle with a tokio background task, no thread leak - 50 concurrent suspend dispatches during background emission - real NIP-44 v2 (nostr crate) crossing the boundary as String The same Rust API also compiles behind a tauri command (tauri-consumer). Findings, now conventions for the real client-core: - a fielded uniffi error variant must not name a field "message" (collides with Kotlin Throwable.message; 0.28 emits no override) - a tauri command macro must live in a submodule, not a library crate root Data: nostr 0.44 with default-features=false plus nip44 is a tight dep tree; leaning on it for NIP-44 in F1 is fine. Reproduce: ./spike/uniffi-binding-probe/run.sh (Docker only). Delete spike/ once the verdict is recorded in the plan or an ADR. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…dict: GO) The MDK 0.8 / MLS + SQLCipher stack from apps/mobile/src-tauri survives the re-layout: crates/client-core as a workspace member + cdylib, alongside the uniffi proc-macro stack, and still cross-compiles to aarch64-linux-android. - host tests: two SQLCipher stores open (encryption verified), MLS key package minted, 1:1 group created (2 members) - android: cargo ndk -t arm64-v8a build -> libclient_core.so, ELF ARM aarch64, 14.5 MB; SQLCipher + vendored OpenSSL + secp256k1 C all linked - feature unification (rusqlite bundled-sqlcipher-vendored-openssl) holds across a two-member workspace - uniffi macros coexist with openmls/mdk-core in one crate Finding: cargo-ndk + NDK r28c cross-builds the vendored-openssl SQLCipher with zero AR_/RANLIB_ env hacks; the manual exports in apps/mobile/docker/Dockerfile (CDX-012) can likely go once apps/android's Gradle build uses cargo-ndk. Reproduce: ./spike/ndk-marmot-probe/run.sh (Docker only). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ness The networking half of "sockets in a Rust core inside the foreground service survive backgrounding, where WebView sockets do not": - rust/heartbeat-core: a real tokio-tungstenite Nostr client that subscribes to kind-30515, counts deliveries, reconnects with backoff. Host tests (in-process WS relay) cover delivery counting + reconnect-after-drop. Consumed on Android via UniFFI (the probe-1 pattern). - android/: minimal native app — a dataSync foreground service owns the Rust core; MainActivity shows received / last-heartbeat / reconnects. - pulse-relay bin: WS stand-in for a bridge emitting 30515 every 15s. - build.sh: one Docker image (Rust+NDK+cargo-ndk + JDK+Android SDK) does cargo test -> .so (x86_64+arm64) -> uniffi bindgen -> APK. - run-emulator.sh: install + run the background matrix (baseline, HOME, screen-off, forced Doze, airplane blip) and report DELIVERING/STALLED. Notes: probe pins compileSdk 36 (API-37 platform ships SDK XML v4 the Dockerized AGP 8.11.1 sdklib cannot parse — a native Studio build handles 37; the real apps/android still targets 37). A stock emulator is lenient and will NOT reproduce OEM background kills — emulator GREEN is necessary, not sufficient; a real Samsung + Xiaomi pass remains a hard F1 gate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Networking core (Rust relay client: subscribe kind-30515, count deliveries, reconnect with backoff) is GREEN via host tests and packaged into the APK through UniFFI. The on-device matrix (FGS survival through HOME / screen-off / forced Doze / airplane) is built and ready but not run: the only installed AVD is 96% full and is the maintainer's populated test AVD (not safe to wipe). run-emulator.sh + a roomy/clean AVD (or a device) finishes it. MainActivity now starts the service on `am start --ez auto true` so the harness needs no exported service and no screen taps. Emulator caveat recorded: a stock AOSP/Google-APIs image honours forced Doze but does NOT reproduce Samsung/Xiaomi OEM background kills — emulator GREEN is necessary, not sufficient; real-device pass stays a hard F1 gate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
On a factory-reset API-36 emulator, the Rust relay core hosted by a native dataSync foreground service kept delivering kind-30515 through every phase: foreground baseline 2 -> 4 reconnects 0 DELIVERING backgrounded (HOME) 4 -> 7 reconnects 0 DELIVERING screen off 7 -> 10 reconnects 0 DELIVERING forced Doze 10 -> 15 reconnects 0 DELIVERING (socket survived Doze) after airplane blip 15 -> 17 reconnects 4->5 DELIVERING (backoff-reconnected) Full log in artifacts/emulator-matrix.log. Socket lives in the service process (UniFFI-loaded .so), not a WebView. Still required before F1 commits: a real Samsung/OneUI + Xiaomi/MIUI pass — a stock emulator honours forced Doze but does not reproduce OEM background kills. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…itional GO) Paparazzi screenshot testing runs in Docker on the JVM (no emulator) — the right fidelity guardrail for F3. Bespoke rows (diff card, tool group) port to Compose with matching near-black monochrome fidelity (goldens in artifacts/). The Compose-native Markdown renderer (mikepenz 0.35, the leading option) does NOT drop in for parity: - Markdown(content=) parses async; Paparazzi's static snapshot catches the empty pre-parse frame (blank golden) - rememberMarkdownState(immediate=true) hit NoSuchMethodError — the -android /-m3/-code modules drift transitively; must pin one exact version - text colour now comes from markdownTypography per-slot TextStyles, not markdownColor(text=); dark-only theme needs every slot set explicitly - syntax highlighting is a separate module covering ~12 languages with coarser tokens than highlight.js's ~190 — exact per-token parity is the biggest gap, not free Bounded, known integration work — not a blocker. F3 must start with a transcript-screen spike + a reference capture from the running React app. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…(GO, scoped) Got the Compose Markdown renderer actually rendering the corpus (mikepenz 0.27, synchronous parse). Findings sharpen from "conditional" to GO with a scoped F3 renderer decision: RENDERS: headings, paragraphs, ordered/unordered/nested lists, inline code, 5 fenced code blocks (bash/ts/rust/json/diff), on the dark near-black theme. GAPS: GFM tables render as stacked lines (need custom table component even with GFMFlavourDescriptor); task-list checkboxes render as bullets; no syntax highlighting (monochrome); blockquote not visually distinct. Version treadmill to lock in F3: sync Markdown(content) only in <=0.27; 0.39.x needs Kotlin 2.2 (project is on 2.0.21 per Paparazzi 1.3.5/AGP 8.7.3); mikepenz modules must all be pinned to avoid runtime NoSuchMethodError. Not a blocker; widens F3/F4 UI scope but doesn't threaten stopping at F2b. Bespoke rows (diff, tool group) still port with pixel-close fidelity. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Starts the shared Rust client (migration plan F1). Node/TS side untouched;
apps/mobile frozen.
- root Cargo workspace: crates/client-core (pure logic) + crates/client-runtime
(async host skeleton). apps/mobile/src-tauri and tauri-plugin-* are NOT
members yet (excluded); they join when apps/android is built.
- client-core::crypto — faithful port of packages/core/src/nostr/crypto.ts:
generate_keypair / keypair_from_secret{,_hex} / npub_from_hex / hex helpers
with the TS strictness / encrypt_to / decrypt_from (NIP-44 v2 via the nostr
crate, validated against this crate in the F0 uniffi probe). decrypt_from is
total from the caller's side (Err, never panic). 5 vector tests incl.
cross-key round-trip with the NIP-44 spec keys and total-on-garbage.
- CI: the cargo job grows a `core` path filter -> cargo test --workspace +
cargo clippy -D warnings on crates/**, Cargo.toml, Cargo.lock.
- docs/CLIENT-CORE.md: the client-core/client-runtime contract, API-surface
target, in/out split, F0 conventions, anti-drift plan, and a port-tracking
table.
Verify: cargo test --workspace + cargo clippy --workspace --all-targets
-- -D warnings (green), run in rust:1-bookworm via Docker.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three small, self-contained pure ports from packages/protocol, each with the TS test vectors carried over. - wire::kinds — the v10 storage-class kind split (30515 heartbeat / 4515 command / 4516 response / 24515 live) + NIP-40 expiry constants. - wire::capabilities — PROTOCOL_VERSION 10, the 10 capability strings with their three-tier docs (hard gate / presence marker / transport beacon), ALL_BRIDGE_CAPABILITIES / ALL_PHONE_CAPABILITIES, BridgeHostKind with total from_wire. - ranges — normalize / missing / chunk / union / subtract / range_size / covers, faithful to ranges.ts. chunk_ranges takes NonZeroU64 (the TS throws on size < 1; here that's unrepresentable). 13 tests ported 1:1 from ranges.test.ts including the missing+have partition property. 26 tests total, cargo clippy -D warnings clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Port of packages/protocol/src/chunking.ts — oversize-event fragmentation,
the transport layer below the semantic protocol.
- MAX_EVENT_CONTENT_BYTES / NIP44_SAFE_PLAINTEXT_BYTES / CHUNK_ENVELOPE_MARGIN
/ CHUNK_MESSAGE_TYPE / CHUNK_ASSEMBLY_TTL_MS constants.
- ChunkEnvelope (serde, field order = JSON.stringify order so the
{"type":"chunk" prefix pre-filter works).
- frame_encoded_message: <= safe cap -> [json] unchanged; oversize -> N
envelopes sized by a measured binary search, slices snapped to UTF-8 char
boundaries (Rust has no lone surrogates, so the TS surrogate guard becomes
the boundary snap). Concatenating parts in index order == the original.
- parse_chunk_envelope: prefix check + serde + n>=2 + non-empty cid.
- ChunkAssembler: out-of-order / dedup / i>=n invalid / different-n restart /
TTL sweep / maxOpen + maxBytes eviction. now_ms passed per call (Clock port
lives one layer up).
Port bug caught by the ported tests: sweep must compute
`now - first_seen >= ttl` (the TS does `first_seen <= now - ttl` in signed
arithmetic; a saturating_sub on the cutoff wrongly drops everything at now=0).
42 tests total (incl. the real-nip44 padding boundary: one byte past
NIP44_SAFE_PLAINTEXT_BYTES lands on content=65628, the exact bug-report
number — the nostr crate's NIP-44 v2 padding is byte-identical to
nostr-tools). clippy -D warnings clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Port of the reducer half of apps/mobile/src/core/stores/connection.ts (bug-A design). The effect interpreter + zustand store stay for client-runtime. - ConnectionStatus / Presence / ConnectionState / ConnectionEvent (12) / ConnectionEffect (7), initial_connection_state. - ReconnectConfig + DEFAULT / TOR configs; backoff_delay_ms(attempt, random, cfg) = exp(base -> max) + floor(clamp(random) * base * jitter). checked_shl so a 50-attempt storm clamps at the cap instead of overflowing. - connection_reducer: one deterministic match, one arm per TS `case` — visibility flips only debounce (never tear a healthy socket), socket-close while offline waits for `online` instead of burning retries, decrypt failures are diagnostics not disconnects, resume is cheap while connected. - presence_of (3 honest states) + heartbeats_all_stale (CDX-020 dead-subscription detection with the last_connected_at loop guard). 26 vector tests ported 1:1 from connectionReducer.test.ts (lifecycle, backoff ladder [2000,4000,8000,16000,30000,30000,30000,30000], jitter bounds, network transitions, visibility storm, resume, decrypt threshold, presence, CDX-020 grace window). 68 tests total; clippy -D warnings clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Port of packages/protocol/src/{codec,schemas/common,schemas/commands,
schemas/events}.ts — the wire contract, mirrored from the normative zod spec.
- wire::common: the shared enums (PermissionMode / EffortLevel / SessionState
/ OutputEntryType / DiffLineType / DeviceRole / AppUnderTest) and object
types (RemoteSessionInfo, OutputEntry+DiffData, UsageData+UsageWindow,
GsdState+phases/execution/actions, AuthStatus, ProviderModel/ProfileInfo,
DeviceConfig). is_valid_provider_base_url hand-rolled (no url crate — the
protocol package stays dep-light): https anywhere, http only on
localhost/127.0.0.1/[::1].
- wire::tristate: Tristate<T> {Keep,Clear,Set} with custom Serialize/Deserialize
for the wire's absent/null/value distinction (set-credentials,
set-provider-profile.authToken). Use with
#[serde(default, skip_serializing_if = "Tristate::is_keep")].
- wire::commands: 24 phone->bridge structs, VersionFields (v/caps) flattened
into each, PhoneToBridge union #[serde(tag = "type")]. upload-image is an
#[serde(untagged)] Blossom|Chunk sub-union (mirrors zod's z.union, same
`type`, disambiguated by shape).
- wire::events: 24 bridge->phone structs, BridgeToPhone union.
- wire::codec: decode_{phone_to_bridge,bridge_to_phone} (total: Err never
panic, with the TS's `schema mismatch for type "X"` error prefix),
encode_* (encode_phone_to_bridge enforces the CDX-071 https base-url gate
on egress so a cleartext profile fails at the sender).
Faithful to zod: unknown message `type` and unknown enum value are decode
errors (the plan-§3 #[serde(other)] forward-compat leniency is a separate
tested layer). Unknown *fields* are ignored (matches zod's default strip).
91 tests total: every message type round-trips from a representative fixture;
Tristate keep/clear/set; upload-image shape disambiguation; unknown-type
error; extra-field forward-compat; provider base-url egress rejection.
clippy -D warnings clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
packages/protocol/fixtures/corpus.json is the executable half of "one source of truth": ~60 valid + ~14 rejected messages across both directions, plus forward-compatible (extra-field) cases. Consumed by: - packages/protocol/src/__tests__/fixtures.test.ts (vitest) — 86 tests - crates/client-core/tests/codec_conformance.rs (cargo) — 5 tests Both run the identical assertions on the identical bytes: decode every valid entry + semantic round-trip (encode -> decode -> equal), reject every invalid entry, and accept forwardCompatible messages (unknown fields ignored). A zod-schema change mirrored on only one side fails CI there. CI: the cargo job's `core` path filter now also fires on packages/protocol/fixtures/**. Full TS suite still green (protocol 196, core 454, testkit 28, bridge 40, mobile 766). Workspace: 96 Rust tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review/improve pass on the codec conformance corpus:
Generation — kept hand-curated (a random generator produces non-deterministic,
non-representative data and can't express the deliberate edge cases). Instead:
- fixtures.test.ts now introspects phoneToBridgeSchema / bridgeToPhoneSchema
for the full set of `type` literals and asserts the corpus covers every one
(and names no unknown type). Adding a message type to the union fails this
until a fixture exists. The spec drives completeness, not a hand-kept count.
- every `valid`/`rejected` entry was already spec-validated by the decode call.
- packages/protocol/fixtures/README.md documents the contract.
Consumption — crates/client-core/build.rs resolves the corpus (owned by
packages/protocol) to an ABSOLUTE path via CARGO_MANIFEST_DIR + canonicalize
and exports CODEDECK_PROTOCOL_CORPUS + cargo:rerun-if-changed. The Rust test
is now `include_str!(env!("CODEDECK_PROTOCOL_CORPUS"))` — no `../../..`.
Added a distinct-type-count tripwire (23 p2b / 24 b2p) on the Rust side.
TS: fixtures.test.ts 88 tests (protocol 198). Rust: 97 tests. clippy clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Port of apps/mobile/src/core/services/nostrClient.ts + the per-class filter split from platform/poolOptions.ts, behind a Transport port (callback-style subscribe, mirroring the TS TransportSubscriptionParams). Rc<RefCell> mirrors the single-threaded TS `this`; the real tokio-tungstenite + SOCKS5 transport drives it from one task later. - build_phone_filters: the 3 per-traffic-class filters (30515 no since / 4516 since = cursor-60s / 24515 no since) — the bug-A kind split. - NostrClient: generation guard (CDB-037 — epoch++ BEFORE closing subs, so a deliberate teardown never fakes a socket-close), EOSE-on-all => on_socket_open, one real sub death => teardown epoch + exactly one on_socket_close, seenIds dedup (cap 2000, FIFO), stored-cursor tracking (30515+4516 only, never ephemeral, never regresses), vacuous open when unpaired, set_relays. 15 tests ported 1:1 from nostrClient.test.ts (the "reconnect resumes 4516" case is now a direct filter assertion — the Rust FakeTransport records the whole Filter, not just kinds). client-runtime: 12 tests. Workspace: 109 Rust tests, clippy -D warnings clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…line) Ports the pure half of apps/mobile/src/core/services/bridgeApi.ts (plan section 6.2): policy and validation in client-core, socket I/O deferred to client-runtime. - kind_for_message: the one place phone->bridge kind policy is stated (every command is a stored COMMAND_KIND event; bridge->phone is split three ways, that lives in nostr_client). - build_command: encode (egress-validated, so a cleartext provider baseUrl fails at the sender), stamp v + caps (CDX-050), NIP-44 encrypt, schnorr sign, NIP-40 expiration tag. Returns one SignedCommand the caller re-uses verbatim on retry — rebuilding would change created_at, the NIP-44 nonce and the id, defeating the bridge's id dedup and double-injecting an image (CDX-086). - BridgeApi::ingest: decrypt -> ChunkAssembler -> decode_bridge_to_phone, total. A bad payload is a returned variant plus a diagnostics bump, never a panic; a decrypt failure from a known machine is a diagnostic, never a fake disconnect. - classify_publish / combine_publish: the CDX-086 four-way verdict (accepted / unconfirmed / rejected / unreachable), softest-wins across relays. The retry loop that consumes them is runtime. - folder-ack request-id allocation + pending set (the timeout timer and caller promise are runtime). Tests ported 1:1 from bridgeApiChunk.test.ts and bridgeApiUploadImage.test.ts plus native units for the verdict logic and the egress gate. Adds Debug to ChunkAssembler so BridgeApi can derive it. cargo test --workspace: 130 pass (client-core 112, codec_conformance 6, client-runtime 12). cargo clippy --workspace --all-targets -- -D warnings clean. Run in rust:1-bookworm via Docker. No TS touched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ports packages/protocol/src/nip42.ts. The bridge and the phone both answer a relay's ["AUTH", challenge] with a kind-22242 event signed by their own identity keypair — one allowlisted pubkey per side on a private / Haven relay, no separate auth credential. Where the TS signs a template nostr-tools builds, the Rust transport builds the event itself, so build_auth_event is that one call; a relay that never challenges never triggers it. Factors the nostr::Event -> plain-data conversion into client_core::nostr_event::SignedEvent, now shared by build_command and build_auth_event instead of each hand-rolling the same seven fields. cargo test --workspace: 133 pass (+3 nip42). clippy -D warnings clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The pure half of the real transport: the ten client-side relay frame shapes — parse EVENT / EOSE / CLOSED / OK / NOTICE / AUTH, build REQ / CLOSE / EVENT / AUTH, and the relay-JSON form of a subscription Filter. No sockets. An unrecognised inbound verb is RelayMessage::Unknown, not an error, so a relay sending COUNT or a future frame does not drop the read loop; a malformed known frame is an error the caller can log. Decision recorded in docs/CLIENT-CORE.md: the transport is hand-rolled on tokio-tungstenite + tokio-socks, not nostr-sdk / nostr-relay-pool. The port's shape is a thin transport under the connection FSM; a relay pool re-introduces the self-timed idle close (CDX-020) and the pool-fires-its-own-onclose trap (CDB-037) the TS spent effort defeating, and its publish result collapses unconfirmed vs unreachable — the one distinction CDX-086 keeps. The client Nostr wire is ~10 frame shapes; nostr gives event types + signing + NIP-44 + NIP-42, which is all the leverage needed. cargo test --workspace --locked: 141 pass (client-runtime 20, +8 frames). clippy -D warnings clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…gic) The fan-out / aggregation half of the real transport, extracted pure the way nostr_client and connection were. One logical subscription (one sub_id per Transport::subscribe) is REQ'd to every relay; the router turns per-relay frames into the callbacks nostr_client expects: - on_event per received event (cross-relay dedup stays in nostr_client's SeenIds). - on_eose ONCE, when every still-connected REQ'd relay has EOSEd, or when the last relay that had not EOSEd drops — matching nostr-tools subscribeMany. - on_close ONCE, only when the sub is dead on EVERY relay; one relay closing a sub while another still carries it is not a close (the FSM must not back off while a socket is live). - NIP-42: ["AUTH", challenge] -> NeedAuth; a CLOSED with auth-required -> ResubAfterAuth (the sub is not marked dead — it is re-REQ'd after AUTH). - publish: per-relay OK/reject/timeout/drop fed through bridge_api::classify_publish, settled with combine_publish. Settles immediately on the first acceptance (TS raceForAcceptance), otherwise waits for every relay; a budget timeout settles the silent relays as unconfirmed. 34 client-runtime tests (+14). cargo test --workspace --locked: 155 pass. clippy -D warnings clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ort) WsTransport implements nostr_client::Transport over tokio-tungstenite + tokio-socks: one connect + read task per relay, wired through the pure frames codec and router. - dial: direct TcpStream, or through a SOCKS5 proxy (Orbot) when configured — every relay then goes via Tor, the only way a .onion relay is routable. wss:// via rustls + webpki roots (no OpenSSL, so it cross-compiles for Android; cost is the ring transitive). - read loop: parse -> router.route -> run actions after dropping the state borrow (a user callback may re-enter subscribe / close). Callbacks are Rc (was Box) so one can be cloned out from under the RefCell borrow. - ping liveness: a socket with no inbound traffic for 75s is dropped, so a silently-rotted relay is detected — the enablePing intent, without nostr-tools' idle-close bug. - no auto-reconnect: a dead socket is one on_close; the connection FSM owns backoff and calls ensure_connected(). set_relays diffs and re-dials. - NIP-42: an AUTH challenge is answered with client_core::nip42; a CLOSED auth-required re-sends that sub's REQ. - publish_confirmed: fan out the SAME signed event to every up relay, feed per-relay OK/reject/timeout/drop through the router, return the CDX-086 verdict. Retries only on unreachable (transient) within the budget. Single-threaded by construction (SubCallbacks are !Send) -> current-thread runtime + LocalSet. Integration-tested against a loopback mock relay: subscribe/REQ/EVENT/EOSE, NIP-42 AUTH round-trip, publish verdict, socket-drop -> on_close, sub.close() -> CLOSE frame + silence. cargo test --workspace --locked: 160 pass (client-runtime 39, +19). clippy -D warnings clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…elay Composes the connection FSM (client_core::connection), the epoch-guarded subscription client (nostr_client::NostrClient over the real WsTransport), and bridge_api (egress build + total ingest) behind one tokio event loop on the FG service's LocalSet. This is the handle the UniFFI / tauri bindings attach to. - lifecycle: start / stop / pause / resume / set_online / set_machines. Each is an mpsc message; NostrClientHost callbacks and internal timers post the same way, so a callback firing inside the transport task never re-enters the loop. - effect interpreter: OpenSocket -> ws.ensure_connected + nostr.connect; CloseSocket -> nostr.disconnect + ws.shutdown; ScheduleRetry / ScheduleVisibilityCheck -> spawn_local sleep tasks tracked by AbortHandle. The transport never reconnects itself — the FSM's backoff drives every redial. - CDX-020 watchdog: a 30s tick runs heartbeats_all_stale; if the socket claims Connected but every paired-machine heartbeat has aged out, it injects a SocketClose so the normal backoff path reconnects. - ingest: a 30515 from a PAIRED machine feeds HeartbeatReceived (presence + CDX-020) whether or not the payload decodes; every event then goes through bridge_api::ingest -> observer.bridge_message / action_failed. A decrypt failure raises the needs-pairing-check diagnostic, never a disconnect. - egress: Core::send builds the signed command and publishes off the loop, so a 12s confirmation budget never blocks socket-close handling; publish_confirmed awaits the CDX-086 verdict. - CoreObserver is the seed of the F2 CoreEvent stream — semantic, no UI strings. The full View / Intent surface is F2. The loopback mock relay moves to transport::mock, shared by the ws and core integration tests (start -> connected after all EOSE, message delivery, socket-drop -> backoff -> reconnect, stop is terminal). NostrEvent gains `content` so the layer above can decrypt. cargo test --workspace --locked: 165 pass (client-runtime 44, +10). clippy -D warnings clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Core::set_relays(relays) forwards to NostrClient::set_relays (transport re-dials the diff; if connected, the subs re-REQ). - Core::connection_status() -> (ConnectionStatus, bool) — a fresh read for a UI that just attached, since CoreObserver only reports changes. Served by a QueryStatus message so it reads the loop's own state with no shared lock. - WsSub::close now sends the CLOSE frame only to `up` relays. A CLOSE queued on a still-dialling relay sat ahead of the REQs on_relay_up replays and arrived out of order — surfaced by the set_relays test (the new relay's first frame was a stale CLOSE, not a REQ). cargo test --workspace --locked: 167 pass (client-runtime 46, +2). clippy -D warnings clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e, gated)
The Rust side of "run the Nostr sockets in the app process, not the WebView".
Behind the `native-core` Cargo feature — OFF by default, so the crate builds
byte-identical to before and the shipping APK is unchanged. A test APK turns it
on; it flips to default once field-tested.
corebridge.rs:
- CoreBridge managed state; core_init spins a dedicated "codedeck-core" thread
with a current-thread tokio runtime + LocalSet that hosts the !Send Core
loop, and hands the Send Core handle back to Tauri state.
- commands: core_{init,start,stop,pause,resume,set_online,set_machines,
set_relays,send,publish,connection_status}. send/publish take the phone→bridge
command as wire JSON (the same object the TS encoder builds) and run it
through the real decode_phone_to_bridge.
- TauriObserver fans CoreObserver out as core://{connection,message,
action-failed} events — semantic payloads, no UI strings; `message` is the
decoded BridgeToPhone in its wire shape so the WebView feeds it to the same
handlers.
- the identity secret arrives once via core_init, consumed into the keypair,
never logged.
CI builds `cargo build --features native-core` in the src-tauri job so a broken
corebridge fails CI, not a test APK.
Verified in a Tauri-deps container: cargo test --locked (default) 7 pass;
--features native-core builds + 7 pass; clippy --features native-core clean.
The WebView opt-in wiring (listen to the events, disable the TS transport) and
the eventual TS network-path removal are the next F1 sub-steps.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…untime)
The isTauri-guarded wrapper over corebridge: the core_* commands + the
core://{connection,message,action-failed} events, in the house style
(lazy @tauri-apps imports, untyped JSON re-validated at the boundary).
- nativeCoreOver(invoke, listen) — the testable core; maps each method to its
command, normalises the connection snapshot (snake_case + unknown status),
and runs every inbound core://message through the real decodeBridgeToPhone so
a Rust/JS drift fails here, not in a store.
- createNativeCore(log) — the production seam: null when there is no Tauri
runtime OR the APK was built without native-core (the probe core_init
rejects), so callers fall back to the WebView transport.
- identity secret passes through init once, never logged.
createPhoneCore consumes this in place of its own transport + BridgeApi + nostr
client when present — that wiring is the next F1 sub-step.
./codedeck check green (mobile 770 tests, +4).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… / dispatchDecoded) The BridgeApi-side hook for the in-process runtime. When deps.nativeSend / nativePublishConfirmed are set (createPhoneCore passes them when a native core is present): - send() hands the UNSTAMPED phone→bridge command to the native core — Rust stamps v/caps, NIP-44-encrypts, signs and publishes — instead of buildCommand + this.deps.publish. A native failure is contained the same way (false / rejected verdict, logged, never thrown). - sendConfirmed() routes to nativePublishConfirmed (the image path keeps its CDX-086 verdict). - dispatchDecoded(msg, machine) routes a message the native core already decrypted + decoded straight into dispatch, skipping ingest's decrypt→reassemble→decode pipeline. buildCommand / publish / ingest / the ChunkAssembler are simply unused in that mode (Rust owns them). No behaviour change when the hooks are absent. ./codedeck check green (mobile 773 tests, +4). testkit contract Scenario B2 flaked once mid-run (retry-exhaustion timing); passes on re-run, unrelated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
When deps.nativeCore is present the bridge protocol (30515/4515/4516/24515) rides the in-process Rust runtime; deps.transport still serves DM (1059) + Marmot (445) until F2a. - core/nativeCore.ts: NativeCoreControl port (init/start/stop/setMachines/ setRelays/send/publish). Kept out of ports.ts, which stays protocol-free. - on boot: native.init once with relays + hex identity secret + Orbot proxy (deps.nativeCoreProxy) + tor flag. The secret is passed here and nowhere else, never logged. - connection FSM openSocket/closeSocket effects -> native.start()/native.stop() (idempotent; the runtime owns the socket + reconnect). client.connect() is not called. - BridgeApi gets nativeSend/nativePublishConfirmed -> every api.send / sendConfirmed / typed convenience method goes to the runtime unstamped. - authors changes (pairing candidate/paired, removeMachine) -> native.setMachines(currentAuthors()); relay changes -> native.setRelays. - inbound is wired by the boot layer onto api.dispatchDecoded / connection.dispatch (next commit). No behaviour change when nativeCore is absent (every branch is `if (native)`). ./codedeck check green (mobile 779 tests, +6). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Completes the WebView side. createNativeCore(log) probes for the native-core build; null → the WebView transport path is unchanged. When present: - adapt the platform seam to the core's NativeCoreControl port (send → bool, publish → PublishResult), pass it to createPhoneCore alongside `transport` (which still serves DM/Marmot) with the Orbot proxy address. - inbound: nativeSeam.onMessage → core.api.dispatchDecoded; onConnection → mirror the runtime's status onto the WebView FSM (connected → socket-open, waiting-retry/offline → socket-close) so the chip + resync-on-reconnect stay honest; onActionFailed(decrypt-failed) → connection decrypt-failure. - skip the WebView CDX-020 checkHeartbeats tick — the runtime runs its own. Connectivity (visibility/online/offline/resume) needs no change: it flows through the FSM's openSocket/closeSocket effects, which now call native.start()/native.stop(). ./codedeck check green (mobile 779). main.tsx is boot glue (untested); typecheck + the full suite cover the pieces it wires. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
./codedeck apk benchmark --features native-core builds a release-optimized, debug-signed APK whose src-tauri is compiled with the native-core feature — the Nostr sockets + crypto + connection FSM run in the app process. Output is dist/codedeck-benchmark-native-core.apk. No --features → the build is unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
./codedeck apk benchmark --features native-core cross-compiles the client-core / client-runtime / tokio-tungstenite / tokio-socks / tokio-rustls tree for aarch64-linux-android with no errors and no NDK workarounds. libcodedeck_mobile_lib.so = 34.1 MB (whole APK 37.5 MB) — under the 50 MB gate, and essentially unchanged from the pre-F1 baseline because ring / rustls / tokio were already in the tree via reqwest. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`./codedeck apk debug --features native-core --target x86_64` builds for an x86 emulator; default stays --target aarch64 (real devices). Output name gets a non-default ABI suffix so an emulator build does not overwrite the device one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
corebridge.rs's CorePorts used ..CorePorts::default() for both `notifier` and `http`, i.e. NullNotifier and NoHttpFetch — found while wiring the main.tsx cutover and the SessionScreen.tsx native image-send branch, not part of the three gaps this pass set out to close. Left as-is, a native-core boot would have silently delivered zero OS notifications (DMs, turn-finished, permission prompts) and failed every Blossom image upload/download. TauriNotifier (corebridge.rs) calls tauri-plugin-notification's Rust API (AppHandle::notification().builder().title(..).body(..).show()) directly from the core's own thread — the same plugin the WebView path drives over its JS bindings, no new IPC surface needed. `cancel` stays the trait's own default no-op: the plugin's remove-by-id call is exposed to the JS side only (`removeActive`), and duplicating the WebView notifier's per-tag id bookkeeping just to auto-dismiss a resolved permission-request notification isn't worth it yet. ReqwestHttpFetch (native_http.rs) reuses tauri-plugin-http's OWN reqwest (re-exported as tauri_plugin_http::reqwest) rather than adding a second HTTP stack — the WebView path already depends on this exact crate as its CDX-029 CORS escape hatch for Blossom uploads. It is SOCKS5-aware: core_init's InitConfig::proxy (the same host:port the WS transport dials when Tor is on) configures an identical reqwest::Proxy, so a Blossom upload never bypasses Orbot while the relay sockets don't either. The `socks` feature is forwarded onto tauri-plugin-http only through the native-core Cargo feature (tauri-plugin-http/socks), never unconditionally on the dependency itself, so the default build's dependency graph — and the shipped APK — stays byte-identical without native-core. Neither port hot-reconfigures if Tor is toggled while the app is already running (same known limitation as the WS transport; documented in docs/CLIENT-CORE.md, not fixed here). Also fixes an unrelated clippy lint (cloned_ref_to_slice_refs, newly added to stable clippy) in a native_ports.rs test, caught while running the required cargo clippy --all-targets pass for this change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
main.tsx now decides its ENTIRE composition from one capability probe (createNativeCore(), already used for F1): non-null picks bootNative (createPhoneCoreNative — identity/settings load, transport, the Tor WebView proxy override, and the Marmot platform seam are all skipped, since createPhoneCoreNative owns them itself or Rust does); null picks bootLocal, the unchanged pre-F2b WebView-driven path (also always taken in plain-browser dev). This retires F1's old partial nativeCore wiring from the real boot path — createPhoneCore.ts's own nativeCore option still exists and is still covered by its own dedicated test, just never reached from main.tsx now that a native-core build always has the full F2b surface. The stay-connected foreground service and connectivity wiring are interface-only (core.settings / core.connection) and stay shared, unchanged, between both paths. attachTorProxy (a WebView-only PROXY_OVERRIDE toggle) is skipped for native mode, whose transport dials its own SOCKS5 at core.init instead — toggling Tor while a native-core boot is already running does not hot-reconfigure that transport, a known, separate, documented gap. Also closes the two remaining F2b UI gaps this pass set out for: removeMachine now dispatches Intent::RemoveMachine instead of being a logged no-op (the Rust side landed in an earlier commit). PhoneCore gains an optional sendSessionImageNative method — the one method the native composition defines that the local one does not, since Intent::SendSessionImage already does the whole Blossom-upload-then-chunk- fallback as one step in Rust, with no way to plug that into BridgeApiLike's uploadImageBlossom/uploadImageChunk shape without either double-uploading or silently breaking the documented fallback. SessionScreen.tsx's sendWithImage checks for the method's presence and, when native, decodes the staged file's base64 to raw bytes (base64ToBytes, already used elsewhere) and dispatches directly — no BridgeApi calls, no fine-grained upload progress (the spinner covers the one dispatch), and no true cancellation of an in-flight send (the same outer withDeadline backstop applies, but a timeout only stops the spinner, not a send already handed to Rust). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Updates the status summary and port-tracking table for this phase's work: Intent::RemoveMachine, the main.tsx capability check, the sendSessionImageNative native image-send branch, and the Notifier/HttpFetch ports the cutover surfaced were missing. Notes what is left before the F2b stop-point is no longer code — a real end-to-end manual smoke pass on a device — and records the one remaining known gap (Tor toggle does not hot-reconfigure a running native-core transport or HTTP client). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…DX-026c) Intent::SelectSession/SelectDmPeer already returned the correct UiEffect::SessionViewed/DmOpened in IntentResult::ui_effects (proven by the intent module's own tests), but Core::interpret_intent never read that field at all — opening a session or DM the user had been notified about left its OS notification sitting in the tray forever under native-core. interpret_intent now maps each effect to session_notify_tag/dm_notify_tag (client_core::notifications — the same helpers emit_notify already uses for delivery) and calls notifier.cancel(tag), mirroring what notificationsCoordinator.ts already does for the WebView path. Found by re-auditing every "needs a ... seam (F2b)" comment left in core.rs after being asked whether any code gaps remained, rather than assuming the three originally-scoped gaps were the only ones. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CorePorts.marmot was the last field still defaulting to a stub (NoMarmot): the runtime-side wiring (Core's full connect sequence, AcceptMarmotWelcome/ SendMarmotMessage/StartMarmotChat) was already complete and tested, but corebridge.rs never constructed a real engine, so every Marmot chat would have failed outright under native-core. Forwards client-runtime's own marmot feature (already just ["client-core/marmot"]) through native-core -- client-core's marmot feature was already unconditionally on for this crate (the WebView path's own marmot.rs Tauri commands need it), so this adds no new compiled dependencies -- then constructs MarmotEngineImpl::new(marmot_db_path). marmot_db_path resolves to the SAME app_data_dir()/marmot.db the WebView path's marmot_init command already opens: an install switching between the two compositions must keep its MLS group state, since losing it is unrecoverable. TauriNotifier::cancel was the trait's own default no-op (a deliberate scope cut when TauriNotifier was first added). Now mirrors platform/notifier.ts exactly: assigns an id per delivery (tauri-plugin-notification's mobile-only remove_active call removes by id, not tag), remembers ids per cancellation tag (capped, same as the JS side), and removes them on cancel. Desktop has no remove_active equivalent at all -- #[cfg(mobile)]-gated, a no-op on desktop, matching the JS notifier's own graceful fallback. Both found the same way: re-auditing every real stub left in CorePorts after being asked whether any code gaps remained. Verified with a real `./codedeck apk benchmark --features native-core` build (aarch64-linux-android cross-compile + Gradle + signing), not just cargo check/test/clippy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…crates apps/mobile/package.json pinned @tauri-apps/plugin-http and @tauri-apps/plugin-notification to npm minors whose locked versions (2.5.9, 2.3.3) trailed the Rust crates' resolved minors (2.6.0, 2.4.0) -- both caret ranges already allowed the newer versions, but pnpm-lock.yaml had never been asked to pick them up. `cargo tauri build` refuses to package an APK when the JS and Rust halves of a plugin are on different minor releases, which blocked the native-core benchmark APK build outright. Bumped the package.json minimums and regenerated the lockfile. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Updates the status summary and port-tracking table: the CDX-026c notification-cancel bugfix, TauriNotifier::cancel, and MarmotEngineImpl are now real -- CorePorts has no stub fields left under native-core. Records that a real `--features native-core` benchmark APK now builds, installs, and was smoke-tested (pairing confirmed working). Notes the two remaining non-CorePorts gaps: the attention chime (ping) has no platform port yet, and one-QR mesh auto-join is out of scope for F2b by design (its own F6 Kotlin phase in the plan). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…pires Reported from a real device running the native-core benchmark APK: closing a session and never tapping undo left the undo banner on screen forever. Core::on_undo_timer already cleared stores.ui.undo_toast correctly when the delete controller's timer fired (delete_controller::commit's HideUndoToast effect), but the function emitted StateChanged(Cards) at the end instead of StateChanged(Ui) -- commit() never produces a card effect at all, so this notified nothing that could see the toast had just been cleared. A UiView consumer (the native adapter) has no other way to learn to re-fetch, so the toast it had already fetched (with the countdown) just sat there unchanged, permanently, once the window elapsed without a tap on undo. Tapping undo itself was never affected -- that path already routes through apply_delete_effects, which sets ui_changed correctly. Added a regression test that pairs a machine, lists a real session, dispatches DeleteSession, lets the actual ~4s undo window elapse without ever dispatching UndoDelete, and asserts both that the toast is gone from a fresh ui_view() query and that a StateChanged(Ui) event fired for it -- confirmed it fails on the pre-fix code and passes after. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…pass Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
apps/mobile/src/core's pre-F2b implementation is gone: createPhoneCore.ts (the local composition root), the local FSM/persistence half of every store, services/nostrClient.ts, platform/relayTransport.ts, deleteController.ts, notifications.ts, selectionPersistence.ts, defaultSessionMode.ts, and every test that exercised them (~28 files). createPhoneCoreNative.ts (the native-core composition) is now the sole PhoneCore implementation. Each surviving store file (connection, dm, machines, marmot, outbox, pairing, pendingSessions, quickPrompts, settings, transcript, ui) keeps only the shared types and pure helper functions still called directly by a native adapter or by UI code (parsePairingUrl, sessionKeyOf, truncatePeerLabel, parsePeerInput, unifiedConversations, hydrateSettings, loadPersistedSettings, ...); every stateful create*Store() factory implementing local reducer/FSM logic is gone, since client_runtime::Core owns all of that now. bridgeApi.ts keeps only the BridgeApiLike interface; ports.ts drops the PhoneTransport/TransportSubscription types the deleted transport used (PublishVerdict/PublishResult/ PublishConfirmOptions survive — BridgeApiLike's two documented gaps still type against them). The PhoneCore interface itself moves to a new core/phoneCore.ts (client field dropped — nothing read it; sendSessionImageNative made required, the only implementation left). main.tsx loses its bootLocal path entirely: boot() now throws if createNativeCore() comes back null (no Tauri, or a Tauri build without the feature) instead of falling back to anything, and plain-browser dev no longer starts the app at all. The WebView-only Tor proxy override call site is gone from main.tsx too; platform/torProxy.ts and its Tauri plugin are now dead code, flagged as a follow-up cleanup rather than pulled into this pass. SessionScreen.tsx's image-send path collapses to the single native dispatch (the old Blossom/chunk-fallback branch is unreachable now that sendSessionImageNative is required), which let imageFile.ts drop the whole client-side upload orchestration and SessionScreen drop the vestigial upload-progress/unconfirmed-banner state that went with it. Also fixes two pre-existing test bugs surfaced by actually running the suite for the first time since this migration began: foregroundService's tauriListen mock and connectivity's airplane-mode test were asserting an incomplete event sequence. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
~20 UI test files fixtured themselves against createPhoneCore() purely as a convenient in-memory PhoneCore, unrelated to local-vs-native — they just needed something real to render against. With that composition gone, they now build a PhoneCore via createPhoneCoreNative() over a shared, stateful fake NativeCore (core/__tests__/nativeCoreFixture.ts): fakeNativeCore()/buildFakePhoneCore() seed the *View responses a test needs, and an onDispatch hook lets a test script exactly what a dispatched Intent would change on the fake's views — the fixture itself stays dumb rather than re-implementing client_runtime::Core's behavior. The fixture also auto-echoes the optimistic local state every select*-style adapter method sets before dispatching (selectSession, selectDmPeer, selectMarmotGroup), so a test's own onDispatch handler for an unrelated side effect on the same view can't race it back to a stale value once the next refresh lands. A few scenarios changed rather than just moved: session-image send tests collapsed to the single native path (see the previous commit); DM/Marmot's real gift-wrap/MLS round trips are now Rust's job, so those tests seed the resulting view state directly instead of driving two real PhoneCores through actual crypto; a couple of optimistic-UI assertions (provider-profile "Saving...", credentials "Saving...") are dropped as documented, still-open native gaps (see nativeUi.ts) rather than asserted falsely; and the Marmot start-chat failure message changed from a specific "no KeyPackage" string to nativeMarmot.ts's documented generic failure reason, since the native adapter no longer distinguishes the two causes. multiRootPicker.contract.test.tsx drops the real BridgeCore-over-relay round trip it used to drive (that belongs to a future Rust contract-harness test, not a TS one) and instead proves the same CDX-031 regression — NewSessionModal offering every advertised root, not just what's inside them — against a MachinesView built with the shape a real heartbeat produces. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
apps/mobile/src-tauri's native-core feature (the in-process client-runtime hosting the whole bridge protocol, not just F1's transport/crypto) has been field-tested on a real device and is now `default = ["native-core"]` — every build ships it unless `--no-default-features` is passed explicitly, which stays buildable (see [features] in Cargo.toml) but has no remaining production caller now that apps/mobile/src/core's local composition is deleted. Updates the places that talked about it as an opt-in test flag: build-apk.sh's --features passthrough, the codedeck script's apk usage text, and the CI cargo-test job (which built --features native-core as a second, now-redundant step — it builds --no-default-features instead, to keep that configuration honest for CI rather than duplicating what the default build already covers). docs/CLIENT-CORE.md's status section is rewritten to record F2b as complete: the src/core deletion, the shared test fixture, the feature flip, and platform/torProxy.ts's resulting dead-code status (a follow-up cleanup, not pulled into this pass — removing a whole Tauri plugin is a bigger, separate change than a TS module deletion). The F1 status table's "delete the TS network path" gate flips to done, since the deleted local composition was the whole network path, not just its transport half. Verified: ./codedeck check (typecheck + test, every package) and cargo test / cargo clippy --all-targets -- -D warnings for both the root crates/ workspace and apps/mobile/src-tauri (default and --no-default-features) are green. A real benchmark APK (./codedeck apk benchmark) builds, installs, and pairs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pulls client-core's src/wire/ mirror (common/commands/events/capabilities/ kinds/relays/codec/tristate) plus ranges.rs, chunking.rs, nip42.rs, crypto.rs and nostr_event.rs into a new crates/protocol crate — all self-contained wire and wire-crypto primitives with no dependency on client-core's stores or reducers. client-core now depends on protocol; protocol depends on nothing else in this workspace, so a future Rust bridge (bridge-core/bridge-runtime) can consume it too without reorganizing anything here. client-runtime re-exports protocol (client_runtime::protocol::...), matching the existing client_core re-export pattern, so apps/mobile/src-tauri needs no new direct Cargo dependency. Pure move: every internal crate::/super:: reference between the moved modules already resolved relatively and needed no changes; only the handful of crate::wire:: call sites elsewhere in client-core/client-runtime were rewritten to protocol::. packages/protocol's fixture-corpus conformance mechanism (corpus.json consumed by both fixtures.test.ts and codec_conformance.rs) keeps working unchanged, just at its new path. Verified: cargo test --workspace, cargo clippy --workspace --all-targets -D warnings, cargo test -p client-core --features marmot, cargo clippy -p client-runtime --features marmot, and apps/mobile/src-tauri in both the default and --no-default-features configs all green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t yet wired to consumers) Adds #[derive(specta::Type)] alongside the existing serde derives across protocol, client-core's stores, and client-runtime's View/Intent/CoreEvent types, plus a tauri-specta Builder (corebridge::ts_bindings_builder) and an #[ignore]d regeneration test (tests/gen_ts_bindings.rs) that exports the whole graph to apps/mobile/src/core/nativeCoreTypes.ts. Notable fixes needed to get a correct export, kept as inline comments where they land: Tristate<T>'s wire shape isn't a plain enum (Keep is a skip_serializing_if-omitted field, not a variant) so it gets a manual specta::Type forwarding to Option<T> instead of a derive; two genuinely untyped serde_json::Value fields are overridden to specta_typescript::Unknown rather than expanded (Value is structurally recursive and overflows the exporter's stack when inlined); every u64/i64/usize field reachable from the export graph is overridden to specta_typescript::Number (this app's actual values — timestamps, counters, seq numbers — stay well inside JS's safe- integer range, so this matches current behavior rather than exporting bigint); SeqRange (a bare (u64, u64) tuple) is overridden per field to [number, number]. core_send/core_publish's `message` parameter changed from serde_json::Value + a manual decode_phone_to_bridge call to a plain PhoneToBridge parameter — Tauri's own deserialization now does exactly what that decode call did, one layer earlier, with no behavior change. specta/specta-typescript become unconditional (non-optional) dependencies of protocol and client-core — pure derive macros with no runtime footprint, so an apps/mobile/src-tauri --no-default-features build should still dead-code- eliminate them, but this is disclosed in that crate's own feature-flag comment since it's no longer a literally-unchanged compile graph. Deliberately NOT included in this commit: the regenerated nativeCoreTypes.ts itself, or the consumer-side fixes it requires. Regenerating surfaces real, mostly-welcome shape corrections (e.g. Option<u64> fields now correctly type as `T | null` instead of the old hand-written `T | undefined`) alongside tauri-specta's dual-phase Serialize/Deserialize type splitting for any type with asymmetric skip_serializing_if fields anywhere in its dependency graph — both require updating roughly a dozen apps/mobile/src TS files, which is the next step, not this one. apps/mobile stays on its current hand-written nativeCoreTypes.ts and typechecks clean. Verified: cargo test --workspace, cargo clippy --workspace --all-targets -D warnings, cargo test -p client-core --features marmot, cargo clippy -p client-runtime --features marmot, apps/mobile/src-tauri in both the default and --no-default-features configs, and the gen_ts_bindings test itself (run manually — it writes to the working tree, so it stays #[ignore]d in normal `cargo test`) all green. pnpm -r typecheck confirms apps/mobile (and everything else) is unaffected since nativeCoreTypes.ts didn't change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s consumers
nativeCoreTypes.ts is no longer hand-written — it's now a thin facade
(export * plus 7 direction-picking aliases) over nativeCoreTypes.generated.ts
(committed as-produced by tests/gen_ts_bindings.rs, never hand-edited).
Rust's View/Intent/CoreEvent types are the source of truth; regenerating
after any of them changes is now a mechanical `cargo test ... -- --ignored`
away, with CI (next step) failing the build if someone forgets to.
Why a facade, not a straight rename: tauri-specta emits a `X_Serialize`/
`X_Deserialize` pair (plus an `X = X_Serialize | X_Deserialize` union under
the bare name) for any type whose dependency graph has an asymmetric
skip_serializing_if field anywhere in it — several `protocol` leaf types
carry one. Every one of these types crosses this app's wire in exactly ONE
direction (Views/CoreEvent are Rust-serialized and never deserialized back;
Intent is TS-constructed and Rust-deserialized), so the facade picks the
real direction per split type instead of forcing every read site to
redundantly narrow a case that can't occur.
Fixed consumer-side, surfaced by regenerating (all real, most welcome):
- `event.stateChanged?.slice === X` / `intent.someVariant` truthy checks
replace `'x' in y && y.x.field` everywhere (9 native adapters + ~15 test
files) — TS's `in` narrowing doesn't exclude specta's `{ x: T } & { other?:
never }` mutual-exclusion encoding the way it does a plain discriminated
union, so the old pattern always left `y.x` typed as "possibly undefined."
- `Option<u64>` fields (`lastHeartbeatAt`, `protocolVersion`, …) now
correctly type as `T | null` (matching what Rust actually sends — these
fields have no `skip_serializing_if`, so they're never actually absent)
instead of the old hand-written `T | undefined`; ~15 test fixtures gained
the two fields they were previously allowed to omit.
- `skip_serializing_if` fields (`diff`, ack-status maps, `RemoteSessionInfo`'s
optional fields, …) specta types conservatively as `T | null` even though
they're only ever actually omitted — normalized null-to-undefined at the
handful of seams where a native adapter still feeds a pre-migration
domain-store shape that predates the distinction.
- Plain Rust floats (`SettingsData.ui_scale: f64`, no `Option`) type as
`number | null` because `serde_json` serializes NaN/Infinity as `null`;
falls back to `UI_SCALE_DEFAULT` (the value is already clamped Rust-side,
so this is a type-level formality, not a real runtime case).
- `PairingView.phase` crosses as a plain Rust `&'static str`, not a literal
union specta can see — kept the same trust the hand-written type placed
in this value, now as an explicit cast with a comment instead of an
implicit one.
- `core_send`/`core_publish`'s `message: PhoneToBridge_Deserialize` parameter
needed no fixes — that simplification already landed in the prior commit.
Verified: `pnpm -r typecheck` and `pnpm -r test` both green across every
workspace package (1282 tests total; a `packages/core`/`packages/testkit`
run needs `git` on PATH and can show one unrelated flake without it — both
confirmed passing in isolation with git present).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a "TS bindings drift check" step to the cargo job: regenerate via the same command a developer runs (./codedeck gen-protocol-types), then git diff --exit-code the committed file. Runs whenever src-tauri OR crates/** changed, since either can move the exported View/Intent/CoreEvent shapes — broadens the existing Tauri-toolchain install steps' condition to match (a crates/client-runtime-only change still needs to build src-tauri to run the check). Same anti-drift shape as crates/protocol's fixture-corpus check. ./codedeck gen-protocol-types wraps the regeneration in a throwaway Rust container (same pattern as the existing check/typecheck/test commands' Node container), so no local toolchain is needed to keep the generated file current after a Rust type change. Also regenerates nativeCoreTypes.generated.ts once more: the previous commit built it before gen_ts_bindings.rs's header-comment text was finalized, leaving a one-line drift the check above would otherwise have caught immediately on the next CI run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ription, per-relay status Root cause of "connection dot stays orange after close/reopen, a bridge- confirmed session doesn't show up": every native adapter (nativeConnection, nativeMachines, nativeDm, nativeMarmot, nativeOutbox, nativePairing, nativePendingSessions, nativeQuickPrompts, nativeSettings, nativeUi) fired its one-shot initial snapshot fetch and its live-update listener registration unawaited and concurrently, racing with no guaranteed order. Rust only re-announces state on an actual transition (never replays history for a late listener), so on an ordinary reopen against an already-connected, already-populated core (the singleton CoreBridge outlives a WebView reload; core_init is explicitly idempotent), nothing is about to transition again soon — the one-shot fetch was the ONLY chance to observe reality, and a lost race or a silently-swallowed rejection stranded the store at its construction-time default forever. Fixed via a new shared `hydrateFromCore` helper (core/stores/ nativeHydration.ts): await listener registration to actually complete BEFORE firing the snapshot fetch. Once registration resolves, any future transition is guaranteed to arrive over the listener, and the fetch that follows reads whatever is true at that instant — no version/generation counter needed. Every adapter with this pattern now goes through it. Also fixed while tracing the same code: - Sidebar.tsx's per-machine presence dot read `core.connection.getState() .presence(...)` directly (non-reactive) inside MachineGroup, repainting only because a sibling subscription happened to force a re-render. Now subscribes to the two pieces `presence()` actually reads (connection status, this machine's cached heartbeat). - corebridge.rs's core_init logs which branch it took (fresh init vs. idempotent no-op reusing the running Core) — a diagnostic for confirming on a real device whether the singleton actually survives a close/reopen, as the fix above assumes. - nativeDm.ts's and nativeMarmot.ts's `subscribed` field was hardcoded `true` — there's no independent per-traffic-class subscription concept left post-F2b (client-runtime multiplexes everything over one socket), so both now derive it from the shared `connection.status` instead of a constant that could never show a real disconnect. New: a per-relay status dot in Settings (CoreObserver::connection_changed and Core::connection_status both now carry a `connected_relays` snapshot, sourced from Router's already-tracked-internally `connected: HashSet <String>` — the plumbing existed, it just never left the transport layer). Not a fully live push: relay-level connect/disconnect has no observer channel of its own (WsTransport has no CoreObserver reference, and wiring one would be a much larger change than this warrants), so the dot refreshes on every reconnect-class transition rather than on every individual relay flap — documented inline rather than silently overclaiming full liveness. Verified: cargo test/clippy across the full matrix (root workspace, marmot feature, both apps/mobile/src-tauri feature configs) and pnpm -r typecheck + test (apps/mobile: 64 files / 553 tests, including new coverage for subscribed's reactivity and the relay dot) all green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…flake Scenario B2 (and the same disconnected-burst pattern in A and B1) waited only on transcript.seqHigh() before reconnecting the simulated phone and running the sync-drop scenario. seqHigh bumps synchronously at append call time, but the bridge's onOutput (which drives the live 24515 publish) fires only after the durable write flushes (CDX-060) — asynchronously, with no bound on how far behind it can lag under load. Ephemeral Nostr kinds are delivered only to whoever is subscribed at the exact instant publish() runs. Under CPU contention, a straggler live-output publish for a burst the phone was meant to stay dark for could land after world.sim.connect() re-subscribed, delivering some of the deliberately 'dropped' chunk's entries over the live channel instead of the sync channel — no dropChunkIf/dropNextSyncChunks rule ever sees that path. That corrupted the healing resync's computed gap ([[1,50]] observed as [[1,1]]), reproduced reliably (~1 in 5) by running the full suite under load and root-caused with temporary instrumentation showing the resync's own haveRanges already covering [2,61] before it ever ran. emitAndSettle() waits on the relay's own publish count catching up to the expected new-entry count, not just seqHigh, closing the window before the phone reconnects. Verified with 40 repeated full-suite runs under load (0 failures, versus ~4/20 before). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
android.app.RemoteServiceException$ForegroundServiceDidNotStopInTimeException was crashing the whole process, twice roughly a day apart in the field. Android 15+ time-limits the dataSync foreground service type (the existing comment on the startForeground() call already documents the ~6h/24h budget for a NEW start); this is the OTHER half of that same platform behavior — a RUNNING instance whose window runs out gets Service.onTimeout(startId, fgsType) instead of just being killed, and is expected to call stopSelf() back promptly. Without an override, the system's own force-stop didn't complete inside its timeout and tore down the whole process instead of just this service. The service owns no sockets (the WebView's own relay connection recovers on its own — see the class doc), so stopping on the spot is always safe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tore
Real-device dogfooding surfaced two related pushes that never arrive after
the app is backgrounded and reopened, even while the bridge confirms it
received everything the phone sent:
1. corebridge.rs's TauriObserver discards every app.emit(...) result
('let _ = ...'), and Android can suspend a backgrounded WebView's JS
execution for long enough that a state_changed push racing that window
is silently dropped -- not just at boot (the listener-registration race
hydrateFromCore already closed), but at any later point a background/
foreground cycle straddles a real state change. Rust never repeats an
announcement once made, so a push-only design has nothing left to notice
or retry with. hydrateFromCore now also re-pulls the same snapshot on
every Tauri resume/focus event (NativeCore.onResume, backed by the same
injected listen() every other subscription already uses) -- never trust
a push alone to have survived whatever the OS did while backgrounded.
2. Per-relay connect/disconnect (Router::relay_connected/disconnected,
driven straight from each relay's own WS task) never runs through
dispatch's status-transition gate at all: NostrClient's aggregate
on_socket_close only fires once EVERY relay for a subscription is dead,
so one relay of several flapping leaves the overall ConnectionStatus
completely untouched and the observer never learns the connected-relay
set changed. This is why Settings' per-relay dot could stay colorless
(frozen on the empty set an early snapshot happened to catch) and, by
extension, why the DM dot (which now correctly derives from the shared
connection status) could look permanently stuck. The existing 30s stale
watchdog now also compares the connected set on every tick and notifies
the observer when it changed, independent of any status transition.
Every native store adapter's hydrateFromCore call is updated for the new
onResume parameter; a new client-runtime test drives two mock relays and
confirms a lone relay dying is invisible to dispatch's own gate but caught
by the watchdog within one tick.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t for runtime validation
apps/mobile now sources every wire TYPE from nativeCoreTypes.ts (generated
from crates/protocol/client-core/client-runtime) instead of hand-maintained
zod-inferred types from @codedeck/protocol. 39 of 48 files that imported
protocol only for types are migrated; the remaining 9 keep the import
because they need protocol's actual RUNTIME behavior (effortLevelSchema/
permissionModeSchema backing Settings' dropdowns, decodeBridgeToPhone
re-validating the core://message decode boundary, CAPABILITIES and relay
constants) -- there is no generated equivalent for a zod validator, and
dropping the re-validation at an IPC boundary would be a real regression,
not a cleanup.
Two real problems surfaced and got fixed along the way, not papered over:
- BridgeToPhone (the bridge-to-phone wire enum) was never reachable from
the generated bindings at all -- nothing in ts_bindings_builder() ever
registered it, since core_send/core_publish only carry the OTHER
direction (PhoneToBridge) and the core://message event is emitted ad hoc
outside tauri-specta's typed event system. Added .typ::<BridgeToPhone>()
in corebridge.rs and regenerated.
- PhoneToBridge/BridgeToPhone are internally tagged
(#[serde(tag = "type", rename_all = "kebab-case")] -- confirmed against
packages/protocol/fixtures/corpus.json that the real wire JSON is flat,
{"type":"input",...}), but specta's generated TypeScript for that repr
still nests each variant's payload under an extra key matching the
variant name. Confirmed directly: TypeScript accepts only the nested
shape for PhoneToBridge_Deserialize and rejects the real flat one.
nativeCoreTypes.ts's message unions are reconstructed instead from
protocol's own inner message structs (InputMsg, PairAckMsg, ...), which
don't have this problem since they're plain structs, not enum variants,
each intersected with its own literal `type` tag matching the corpus
fixtures.
A third, narrower issue: specta models every Option<T> field as `T | null`
even when #[serde(skip_serializing_if = "Option::is_none")] means the field
is only ever omitted, never actually serialized as null. The old zod
schemas modelled these as `T | undefined` only, which is what every
consumer here is written against -- RemoteSessionInfo/UsageData/
ProviderProfileInfo/ProviderModel get narrowed overrides in the facade,
field by field, checked against crates/protocol/src/common.rs's actual
skip_serializing_if attributes; a genuinely nullable field (title,
subscriptionType) is left alone.
Verified: full Rust workspace test+clippy clean, apps/mobile/src-tauri
clippy clean in both feature configs, ./codedeck check green (all 7
packages, 555 mobile tests).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…contracts BridgeApiLike.ingest, DmStoreState.ingest, and MarmotStoreState's ingestGiftWrap/ingestGroupMessage all typed their parameter as nostr-tools' NostrEvent, even though every one of them is a confirmed no-op under native mode (Rust owns ingest entirely now) and every test that exercises them already bypasses the type with an `as never` cast rather than constructing a real event. The only thing this bought was a nostr-tools import in contract types the UI and its native adapters otherwise have no reason to know are Nostr-shaped at all. Retyped all four as `unknown`. nip19 stays in dm.ts -- npub/hex encoding is a real identity-format concern this app's UI genuinely deals with, not transport leakage, and keypair assignment (core/crypto.ts) is deliberately left alone as the seam a future external signer (Android Intents) would want anyway. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…bscribed `Core::spawn()` always started the subscription author list (`HostBridge. machines`, and the loop's own mirror of it) empty, regardless of what was already in the persisted `machines` store from a prior run. Nothing in the ordinary connect path ever repopulates it for an already-paired machine: `refresh_authors()` — the only thing that writes to it — is called solely as a reactive side effect of a pairing-related route or intent result, never from the plain connect/reconnect/post-connect-reconcile sequence a normal boot goes through. `NostrClient::connect()` treats an empty author list as vacuous and opens none of the three bridge subscriptions, so a phone that reopens without going through a fresh pairing exchange this process lifetime never receives another 30515 heartbeat or session update from a machine it paired in a previous run — even though its own `refresh-sessions` request still reaches the bridge fine (nothing gates outbound publishes on the author list, only the inbound subscription filter). This is the mechanism behind a machine's presence dot staying stuck after the very first pairing session and a bridge-confirmed session never showing up on the phone. Seed the author list from the same persisted `machines` store (plus any live pairing candidate) that `refresh_authors()` itself reads, at construction time, so the very first `connect()` a process makes already has the right subscription instead of relying on a later event to fix it up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d dual-mode design Every native store adapter still carried write methods and lifecycle hooks that made sense when the phone could run either the WebView-driven local composition or the Rust native core, and needed one shared interface both could satisfy. That composition was deleted in F2b (native-core is now the only one), so a whole family of methods became permanent, unreachable no-ops: production never calls them, and each one's own doc comment already said so. Removed, rather than left as documented dead weight, from every store interface and its native adapter implementation, plus the tests that existed solely to pin the no-op behavior in place: - BridgeApiLike: input, ingest, dispatchDecoded (Rust's Router owns all inbound routing and the outbox lifecycle now; nothing decodes a wire message in TS to hand to these). - DmStoreState: start, stop, ingest — client-runtime owns the 1059 subscription itself; there is no separate subscription lifecycle in TS. - MarmotStoreState: start, stop, ingestGiftWrap, ingestGroupMessage — same reasoning as DmStoreState, for the 445 subscription and MDK engine seam. - MachinesStoreState: every write method (registerMachine, removeMachine, applySessionList, applySessionUpsert, applySessionReplaced, updateSessionInfo, noteFirstUserMessage, userRemoveSession, dismissSession, restoreSession, applyUsage, applyGsd, applyModels, applyProviderProfiles) — the Rust Router folds every bridge message into MachinesView directly. - PairingStoreState: handlePairAck — the Router folds a pair-ack into the pairing store directly. - PendingSessionsStoreState: applyPending, resolve, applyFailed, sweep — the Router applies session-pending/ready/failed directly and runs its own periodic sweep. - TranscriptStoreState: applyOutput, applySyncBegin, applySyncChunk, applySyncEnd, onReconnect, removeSession — the Router applies every Output/Sync*/CloseSessionAck message into the real persistent store. ensureSynced/retrySweep stay: production still calls both (the sync-request path and the periodic connectivity retry), even though Rust's own reconciliation makes them no-ops here too. - OutboxStoreState: confirm, fail — the bridge-message ingest path is the Router's job. sweep stays: main.tsx's connectivity tick still calls it. - UiStoreState: selectMachine, markSessionUnread, clearSessionUnread, applyCredentialsAck, applyDeviceConfigAck, applyProviderProfileAck, setUndoToast — each transition already lands via the matching Intent's Rust-side effect. markCardResponded and the three noteXSent methods stay: the first still has real call sites (an optimistic local mark ahead of the refresh), the other three cover a genuinely open gap (no Rust Intent exists yet for sending those three commands, only for receiving their ack). - ConnectionStoreState: checkHeartbeats — client_runtime::Core runs its own copy of the dead-subscription watchdog; the one call site this existed for was itself deleted with the local composition. No behavior changes: every removed method was already a no-op, or (for sweep/ensureSynced/retrySweep/markCardResponded, kept) already redundant with what Rust does on its own. Header comments that explained the no-op in terms of the retired composition are rewritten to state the current architecture plainly instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Its whole job was reconfiguring androidx.webkit.ProxyController — a process-wide override for the WebView's OWN network requests, including the relay WebSockets the pre-F2b WebView-driven composition opened directly. That composition is gone: native-core's client_runtime::Core dials its own SOCKS5 proxy at core_init (CoreConfig.proxy/tor, sourced from the same persisted torProxyEnabled setting), so the WebView no longer originates any Nostr traffic for the plugin to have routed. main.tsx already stopped attaching it during the F2b cutover — this removes the plugin crate, its Android module, its generated permission manifests, the TS wrapper (platform/torProxy.ts, which had zero remaining callers or test coverage), and its capability entry, closing the follow-up docs/CLIENT-CORE.md flagged at the time. The torProxyEnabled setting and its Settings screen toggle are unaffected — they still drive CoreConfig.proxy/tor directly and remain the real, only mechanism controlling Orbot routing now. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Nine files still imported the wire package for real runtime behavior rather than types: effort-level/permission-mode enumeration and validation, the custom-providers capability string, the default relay lists, the provider-base-url rule, and the core://message decode boundary. Each is resolved the same way the layering rule already resolves @codedeck/core: - `platform/nativeCore.ts`'s `send`/`publish`/`onMessage` (and the `decodeBridgeToPhone` call inside `onMessage`) had zero production callers — the phone dispatches Intents and reads *View() snapshots exclusively now, the same F1-era transport-only surface `main.tsx`'s send/publish never grew into. Deleted outright, along with the matching dead stubs in every native adapter test's `NativeCore` fake and the fixture's `emitMessage` helper. - Everything else (EffortLevel/PermissionMode enumeration and validation, the CAPABILITIES.customProviders string, DEFAULT_RELAYS/MARMOT_RELAYS, isValidProviderBaseUrl/PROVIDER_BASE_URL_ERROR) is real, still-needed behavior. New `core/protocolConstants.ts` mirrors each value from its `crates/protocol` source (common.rs, capabilities.rs, relays.rs) WITHOUT importing the TS package — the same reasoning `core/crypto.ts` already applies to `@codedeck/core`'s crypto helpers. The Rust side stays authoritative for anything that's actually applied (a provider profile is re-validated independently inside `Intent::SetProviderProfile`'s handler, so a drift here could only produce a confusing client-side moment, never a bypass of the wire rule), and the codec-conformance fixture corpus already fails loudly if `EffortLevel`/`PermissionMode`'s wire spelling drifts between the zod schema and the Rust enum this file's values are read from. The phone now depends on neither `@codedeck/core` nor `@codedeck/protocol` at runtime. Removed the now-unused `@codedeck/protocol` dependency from `apps/mobile/package.json` and tightened `layering.test.ts`'s guard (it only had a narrow, documented exception before) into a full ban alongside the existing `@codedeck/core`/`@codedeck/testkit` checks. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`codedeck-bridge run --test-mode` serves every session from a new `TestModeSdkFacade` instead of spawning a real Claude Code subprocess — no `claude` executable, no API key, no network to Anthropic required at all. Its session recognizes five input commands and drives the SAME `canUseTool` callback and `SdkMessage` shapes a real session produces, so everything downstream of the SDK boundary (the adapter, the permission broker, the phone UI) runs its real, unmocked code: - /test-message [text] — a plain assistant reply, or a default greeting. - /test-tool — a Read tool_use, arbitrated through the real permission broker (auto-allowed in yolo mode, a real card in plan/acceptEdits). - /test-plan — an ExitPlanMode tool_use with an example plan, producing the same plan-approval card and yolo/edit/deny choice a real plan does. - /test-question / /test-question-multiple — one or two AskUserQuestion groups; once the phone answers, the session replies "received <answer>". An unrecognized command gets the list of the ones that exist rather than silent nothing. `startBridge` skips `resolveClaudeExecutable` entirely under test-mode (there is nothing to resolve), and the startup banner marks the run as TEST MODE so it can't be confused with a real one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1. The `last_stored_seen` cursor advanced in memory on every stored-kind (4516/30515) event, but `note_stored_seen` never told the `Kv` port about it — every restart re-hydrated at 0 and re-fetched a peer's entire stored response history instead of resuming from where it left off. The read side (`LAST_STORED_SEEN_KEY` hydration) and the write helper (`Persister::save_last_stored_seen`) already existed; only the live wire between them was missing. `note_stored_seen` now also fires `Msg::NoteStoredSeen`, persisted from the loop's own async context (the trait method itself is synchronous, called from inside the transport's task, with no `Kv` access of its own). 2. `WsTransport::dial` accepted a cleartext `ws://` relay to any host — the "cleartext ws:// only for .onion" rule had no transport-level enforcement at all, and turned out to have no enforcement anywhere else either (checked: neither the phone nor the bridge ever validated a relay URL's scheme). `dial` now refuses a non-tls, non-.onion, non-loopback URL before attempting to connect. Loopback stays cleartext-allowed for the same reason `is_valid_provider_base_url` already carves it out elsewhere in this codebase: it never leaves the machine — and every mock-relay test in this file depends on exactly that. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… TS mirror
`protocolConstants.ts` (this session's earlier fix for the last
`@codedeck/protocol` runtime import) still hand-copied effort levels,
permission modes, default relay lists, and the provider-base-url error text
as TS literals — exactly the kind of "someone reads the Rust source and
retypes it" duplication the whole native-core migration has been closing
everywhere else. It now has a real Rust-sourced view instead:
- `crates/protocol/src/defaults.rs`: `protocol_defaults()` builds a
`ProtocolDefaults` struct straight from this crate's own constants
(`common::{EffortLevel, PermissionMode}`, `capabilities::CUSTOM_PROVIDERS`,
`relays::{DEFAULT_RELAYS, MARMOT_RELAYS}`, `common::PROVIDER_BASE_URL_ERROR`)
— one place enumerates the enum variants, everything else is a direct read.
- `corebridge.rs`'s `core_defaults` Tauri command exposes it — a pure read
needing no running `Core`, callable before `core_init` (same shape as
`core_available`).
- `main.tsx` calls it once at boot and feeds the result to
`protocolConstants.ts`'s new `applyProtocolDefaults`, which reassigns that
module's own `let` exports. ES module imports are live bindings, so every
file that already imported `EFFORT_LEVELS`/`PERMISSION_MODES`/
`DEFAULT_RELAYS`/`MARMOT_RELAYS`/`CAPABILITIES`/`PROVIDER_BASE_URL_ERROR`
sees the real values with no code changes at any of those call sites — the
hand-written literals survive only as the pre-boot fallback (and what
plain unit tests still see, via each fixture's own `defaults()` stub).
`isValidProviderBaseUrl` stays a local pure function rather than a
Rust round-trip: Rust remains authoritative in the sense that matters (a
profile is re-validated independently when `Intent::SetProviderProfile`
actually applies it), and a synchronous per-keystroke validator has no good
async equivalent.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e codebase
A sweep for every "known gap" / "genuine gap" / "real gap" comment left over
from the F2b native-core migration, each closed at the root:
1. createFolder (Intent::CreateFolder + CoreEvent::FolderAck): the wire
messages (CreateFolderMsg/FolderAckMsg) already existed end to end on the
bridge, but nothing on the phone's native side could reach them. Added
the Intent, its dispatch (builds and sends CreateFolder), the route()
handling of the bridge's folder-ack (surfaced verbatim, no store side
effect — a one-shot RPC-style exchange, not persisted state), and the
CoreEvent that carries it. `createNativeBridgeApi.ts`'s createFolder no
longer rejects: it dispatches with a fresh request id, registers its
CoreEvent listener before the dispatch (so a same-tick reply can't be
lost), and resolves with the matching ack or times out.
2. note*Sent optimistic "saving" state (nativeUi.ts): the doc comment claimed
set-credentials/set-device-config/set-provider-profile sending had "not
been ported" — false; those Intents were already dispatched by
`nativeBridgeApi.ts` and had real call sites (MachineCredentials.tsx,
MachineProviders.tsx, MeshSection.tsx) expecting the optimistic "saving…"
state these three methods were supposed to set. They were still no-ops.
Now they set a local `{ state: 'saving', at }` entry, the same pattern
`setPlanApprovalChoice` already uses, overwritten by the real ack on the
next view refresh either way.
3. The in-app attention chime never actually fired: `NotifyEffect::Ping` was
matched and then silently dropped in `interpret_route`, AND separately
`Router::new`'s conservative `ping_available: false` default was never
overridden anywhere — the pure `decide_ping` logic could never even run.
Added `CoreEvent::Ping` (a bare unit variant) and wired both: the Router
now runs with `ping_available: true`, and the WebView plays
`platform/pingSound.ts`'s tone (previously dead code with zero callers)
through a new `onCoreEvent` listener in `main.tsx`.
4. Toggling Tor live now actually reconfigures the transport instead of
requiring an app restart: `WsTransport::set_proxy` (redials every open
connection through the new setting) and `ReqwestHttpFetch::set_proxy`
(rebuilds the client — a proxy bakes in at construction) are wired to
`Intent::SetTorEnabled` via `Loop::tor_proxy_address`, which remembers the
SOCKS5 address independent of on/off state (the Intent only carries a
bool). This needed `InitConfig.proxy` to actually reach Rust even when
Tor starts off — `createPhoneCoreNative.ts` used to null it out in that
case, leaving nothing to switch back to.
Verified: `cargo test --workspace` + `cargo clippy --workspace --all-targets
-- -D warnings` at the repo root, `apps/mobile/src-tauri` in both feature
configs (test + clippy + `--no-default-features` check), and `pnpm -r
typecheck`/`test` across all 6 TS packages — all green. TS bindings
regenerated for `ProtocolDefaults`... `Intent::CreateFolder`, and
`CoreEvent::{FolderAck,Ping}`.
Two things checked and deliberately left as documented, permanent
architecture rather than gaps: `uploadImageBlossom`/`uploadImageChunk`
(their two-stage shape is already one step inside `Intent::SendSessionImage`
— shimming them individually would double-upload) and mesh auto-join
(`Intent::mesh_join` — expected to wait for the mesh's own F6 Kotlin phase).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
The Android-native migration (
docs/CLIENT-CORE.md), F0 through F2b: a shared Rustclient-core(pure protocol/state logic) +client-runtime(the async host — sockets, crypto, lifecycle, ports) that both a future native Android UI and the current Tauri app can consume, replacing the TSapps/mobile/src/coreduplication this was designed to retire.Nothing about the shipping app changes in this PR.
apps/mobile/src-tauri'snative-coreCargo feature is off by default — the APK stays byte-identical. Everything here is additive: two new Rust crates plus a Node devtool, and an opt-in Rust command surface in the Tauri shell that nothing currently calls in production.Where it stands
native-core) — done, field-tested on a real APK.client-corestate layer — every store, reducer, and the presentation model) — complete.client_runtime::Core: the full bridge protocol, NIP-17 DMs, Marmot/MLS group messaging, session image upload, the plan's View/Intent/CoreEvent API) — complete and proven end-to-end against a real bridge over a real socket (crates/client-runtime/tests/contract_harness.rsdrivingtools/contract-harness, a new devtool: a realBridgeCore+ fake SDK behind a genuinews://relay). That test runs the whole plan Scenario A — pair → session → live output → input ack → bridge restart → phone reconnect → sync gap-refill — and asserts the phone's transcript ends up byte-identical to the bridge's own.apps/mobile/src's stores/UI to actually call the now-complete Rust surface (corebridge.rs'score_dispatch/core_*_view/core://event, plus the TS-sidenativeCore.tswrapper this PR adds) instead of its own socket path, then deletingsrc/core. Deliberately not started here — it's large, touches currently-shipping code, and deserves its own focused pass with a real device/QA cycle.Structure
docs/CLIENT-CORE.mdis the living port-tracking doc — every row there names the exact TS origin, the Rust destination, and what's verified.A regression this branch caught and fixed
Partway through, an earlier commit on this branch removed
rusqlitefromapps/mobile/src-tauri's direct dependencies (its own use of it, unrelated to MDK's, got dropped along with MDK's copy during an engine relocation). That broke the crate's build entirely, in every feature configuration — undetected locally because verifying it needs the Tauri Linux system deps a plain Rust container doesn't have, and because no PR had run this branch through CI yet. Fixed and re-verified against the exact commands.github/workflows/ci.yml'scargojob runs. Flagging it here since it's exactly the kind of thing this PR's CI run should catch on its own from now on.Verification
Every commit was built/tested before landing, in Docker (
rust:1-bookworm, with the Tauri Linux system deps installed for anything touchingapps/mobile/src-tauri, and a Node container fortools/contract-harness+apps/mobile's TS side) — no host toolchain used. Current numbers: ~425crates/workspace tests + 317client-corelib tests with--features marmot, clippy clean both ways;apps/mobile/src-tauricargo test/cargo build --features native-coreclean; the full pnpm workspace (pnpm -r typecheck+pnpm -r test, 6 packages) clean, apps/mobile at 782 tests.This is also the first time this branch runs through GitHub Actions — a good independent check on all of the above.