From dce1e45b38fef6778ab9a72626cabd65f2df014c Mon Sep 17 00:00:00 2001 From: "m.dyer" Date: Thu, 27 Aug 2026 08:23:23 +1000 Subject: [PATCH 1/3] fix(mobile): add missing tauri CLI shim for Android Gradle rust-build Gradle's rust-build task (buildSrc BuildTask.kt) invokes `node tauri android android-studio-script` from app/src-tauri-mobile/, expecting a literal extensionless `tauri` JS file there. pnpm's .bin shims are POSIX-shell wrappers rather than npm/yarn's direct JS-with-shebang symlinks, so that file never gets created by `tauri android init`. This forwards into the real CLI entry via normal node_modules resolution. Verified: pnpm tauri:android:build --debug now succeeds end to end, producing an installable universal debug APK. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019ufahV1GANuvyyt5N1dRj2 --- app/src-tauri-mobile/tauri.js | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 app/src-tauri-mobile/tauri.js diff --git a/app/src-tauri-mobile/tauri.js b/app/src-tauri-mobile/tauri.js new file mode 100644 index 0000000000..ba493a64b9 --- /dev/null +++ b/app/src-tauri-mobile/tauri.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node +// Shim so Gradle's Rust-build task (buildSrc/BuildTask.kt: `node tauri android +// android-studio-script`) can find the tauri CLI. tauri-cli's own `android init` +// is supposed to leave this in place, but under pnpm's shell-wrapper .bin shims +// (rather than npm's direct symlink-to-JS), that step doesn't produce it. This +// just forwards into the real CLI entry resolved the normal node_modules way. +// ESM because this directory's package.json sets "type": "module". +await import("@tauri-apps/cli/tauri.js"); From 348c0fc0a0cb427f5150cc31627ea74c3770e901 Mon Sep 17 00:00:00 2001 From: "m.dyer" Date: Thu, 27 Aug 2026 08:39:01 +1000 Subject: [PATCH 2/3] fix(mobile): skip local/cloud runtime picker on mobile targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BootCheckGate ran before the mobile/desktop route split and wasn't platform-aware, so a fresh mobile install showed the desktop-oriented "pick a local or cloud runtime" picker instead of going straight to the QR-pairing screen (AppRoutesIOS's /pair). Mobile has no in-process core to probe — it connects exclusively through QR pairing + TransportManager (services/transport/) — so this adds a getIsMobile() bypass, mirroring the existing SocketProvider mobile skip in App.tsx. Found live while testing the Android build on-device: the app landed on "Select a Runtime" (Run Locally / Run on the Cloud) on first launch instead of the pairing QR scanner. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019ufahV1GANuvyyt5N1dRj2 --- app/src/components/BootCheckGate/BootCheckGate.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/components/BootCheckGate/BootCheckGate.tsx b/app/src/components/BootCheckGate/BootCheckGate.tsx index 8a6232f9f8..9f77ffb574 100644 --- a/app/src/components/BootCheckGate/BootCheckGate.tsx +++ b/app/src/components/BootCheckGate/BootCheckGate.tsx @@ -14,6 +14,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { type BootCheckResult, runBootCheck } from '../../lib/bootCheck'; import { useT } from '../../lib/i18n/I18nContext'; +import { getIsMobile } from '../../lib/platform'; import { bootCheckTransport, forceQuitPortOwner, @@ -795,6 +796,14 @@ export default function BootCheckGate({ children }: BootCheckGateProps) { // Render // ------------------------------------------------------------------ + // Mobile targets (iOS/Android) never run the local/cloud core-mode picker — + // there is no in-process core to probe. They connect exclusively through QR + // pairing (AppRoutesIOS's /pair) and services/transport/TransportManager. + // Hooks above still run unconditionally; this is purely a render bypass. + if (getIsMobile()) { + return <>{children}; + } + // Unset — show picker (even if Redux persisted something; phase reflects truth). if (phase === 'picker' || coreMode.kind === 'unset') { return ( From 62d770ea110b2ace11ae3ae0c453d155a529f30b Mon Sep 17 00:00:00 2001 From: "m.dyer" Date: Thu, 27 Aug 2026 13:17:36 +1000 Subject: [PATCH 3/3] fix(devices): surface tunnel:register backend rejections clearly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend acks a rejected tunnel:register with an error object shaped like {"error": "", "ok": false} rather than an HTTP-level error. The core tried to strict-parse that as a success TunnelRegisterResponse, producing a misleading "missing field channelId" (or similarly confusing type-mismatch) parse error instead of the real reason. Found live: every pairing attempt during Android/QR testing failed with what looked like an unstable backend ack shape. Raw-payload logging (added temporarily, removed here) showed every failure was actually {"error":"tunnel_limit_reached","ok":false} — too many pending/unreleased device-pairing tunnels from repeated test attempts (there's no backend "release early" endpoint yet, only a ~10 minute TTL per devices/README.md). register_ack_error() checks for the ok:false shape before the strict parse and turns it into a clear message, with a specific one for tunnel_limit_reached. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019ufahV1GANuvyyt5N1dRj2 --- .../security/devices/tunnel_client.rs | 110 +++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/src/openhuman/security/devices/tunnel_client.rs b/src/openhuman/security/devices/tunnel_client.rs index 268afa3cd6..8326004795 100644 --- a/src/openhuman/security/devices/tunnel_client.rs +++ b/src/openhuman/security/devices/tunnel_client.rs @@ -8,6 +8,7 @@ //! //! Frame cap: 64 KB. Rate limit: callers are expected to stay ≤ 100 frames/s. +use chrono::{SecondsFormat, TimeZone, Utc}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -30,10 +31,39 @@ pub struct TunnelRegisterResponse { pub channel_id: String, #[serde(rename = "pairingToken")] pub pairing_token: String, - #[serde(rename = "pairingExpiresAt")] + /// Backend has been observed sending this as either an ISO 8601 string + /// or an epoch-millisecond integer — normalize both to an ISO 8601 + /// string so every downstream consumer (QR `exp` field, frontend TTL + /// checks) keeps seeing the contract's documented shape. + #[serde(rename = "pairingExpiresAt", deserialize_with = "deserialize_expires_at")] pub pairing_expires_at: String, } +fn deserialize_expires_at<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrEpochMs { + Str(String), + EpochMs(i64), + } + + match StringOrEpochMs::deserialize(deserializer)? { + StringOrEpochMs::Str(s) => Ok(s), + StringOrEpochMs::EpochMs(ms) => Utc + .timestamp_millis_opt(ms) + .single() + .map(|dt| dt.to_rfc3339_opts(SecondsFormat::Millis, true)) + .ok_or_else(|| { + serde::de::Error::custom(format!( + "pairingExpiresAt: epoch-ms {ms} is out of range" + )) + }), + } +} + /// Payload emitted as `tunnel:connect` to join a channel. #[derive(Debug, Serialize)] pub struct TunnelConnectPayload { @@ -87,10 +117,49 @@ pub async fn emit_register() -> Result { .await .map_err(|e| format!("[devices/tunnel] emit tunnel:register failed: {e}"))?; + // Logged at warn (not debug) so it shows up under the default RUST_LOG=info + // — this backend's ack shape has been observed changing between attempts + // (wrong-typed pairingExpiresAt, then a missing channelId entirely), so + // seeing the exact raw payload is the fastest way to tell "flaky backend" + // from "our struct is wrong." + log::warn!("[devices/tunnel] raw tunnel:register ack: {ack}"); + + // The backend acks a rejected registration with an error object shaped + // like `{"error": "", "ok": false}` rather than an HTTP-level + // error — e.g. `tunnel_limit_reached` when too many pending/unreleased + // channels are already open for this account (there is no backend + // "release early" endpoint yet; pending channels only clear via their + // ~10 minute TTL, see devices/README.md). Detecting this shape first + // turns a confusing "missing field `channelId`" parse failure into the + // real reason. + if let Some(err) = register_ack_error(&ack) { + return Err(err); + } + serde_json::from_value::(ack) .map_err(|e| format!("[devices/tunnel] parse tunnel:register ack failed: {e}")) } +/// Recognizes the backend's `{"error": "", "ok": false}` rejection +/// shape for `tunnel:register` and turns it into a clear message. Returns +/// `None` for anything else (including a genuine success payload), leaving +/// that to the normal `TunnelRegisterResponse` parse. +fn register_ack_error(ack: &serde_json::Value) -> Option { + if ack.get("ok").and_then(|v| v.as_bool()) != Some(false) { + return None; + } + let code = ack.get("error").and_then(|v| v.as_str()).unwrap_or("unknown_error"); + Some(match code { + "tunnel_limit_reached" => { + "[devices/tunnel] tunnel:register rejected: too many pending device pairings \ + are already open for this account. Wait a few minutes for old ones to expire \ + (~10 min TTL) and try again." + .to_string() + } + other => format!("[devices/tunnel] tunnel:register rejected: {other}"), + }) +} + /// Emit `tunnel:connect` to start listening on a channel as `role:"core"`. pub async fn emit_connect(channel_id: &str) -> Result<(), String> { log::debug!("[devices/tunnel] emit_connect channel_id={channel_id}"); @@ -153,6 +222,45 @@ mod tests { assert_eq!(response.pairing_expires_at, "2026-06-30T15:00:00Z"); } + #[test] + fn register_ack_error_recognizes_tunnel_limit_reached() { + let ack = json!({"error": "tunnel_limit_reached", "ok": false}); + let err = register_ack_error(&ack).expect("should recognize the error shape"); + assert!(err.contains("too many pending device pairings"), "got: {err}"); + } + + #[test] + fn register_ack_error_passes_through_unknown_error_codes() { + let ack = json!({"error": "something_else", "ok": false}); + let err = register_ack_error(&ack).expect("should recognize any ok:false shape"); + assert!(err.contains("something_else"), "got: {err}"); + } + + #[test] + fn register_ack_error_ignores_success_shapes() { + let ack = json!({ + "channelId": "ch_123", + "pairingToken": "pt_123", + "pairingExpiresAt": "2026-06-30T15:00:00Z" + }); + assert_eq!(register_ack_error(&ack), None); + } + + #[test] + fn tunnel_register_response_accepts_epoch_ms_pairing_expires_at() { + // Observed live from api.tinyhumans.ai: pairingExpiresAt sent as an + // integer epoch-ms timestamp rather than the documented ISO 8601 + // string. Must normalize to a string, not fail to parse. + let response: TunnelRegisterResponse = serde_json::from_value(json!({ + "channelId": "ch_123", + "pairingToken": "pt_123", + "pairingExpiresAt": 1787784497036i64 + })) + .expect("epoch-ms pairingExpiresAt should parse"); + + assert_eq!(response.pairing_expires_at, "2026-08-26T22:48:17.036Z"); + } + #[test] fn build_core_connect_payload_omits_session_token_for_core_role() { let payload = build_core_connect_payload("ch_123");