Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

36 KiB

Mobile Troubleshooting

Android stays on “Syncing the latest data” after pairing

  • Symptom: Device pairing and the direct DeviceLink handshake succeed, but the mobile App remains on Syncing the latest data. The native log repeats Agent live stream rejected, often once per retry interval.
  • Quick checks: Correlate the pairing ID and workspace across the phone and computer logs. If DeviceLink reaches its connected stage and the Agent live rejection reports protocol_revision_mismatch, compare the safe expectedRevision and receivedRevision hashes. This is a protocol compatibility failure, not a local-network or pairing failure.
  • Root cause: The computer rejects an Agent live subscription whose protocol revision does not match its own. If the native rejection is collapsed into a generic disconnect, the live lane treats a deterministic incompatibility as transient and retries forever. Repeated disconnect callbacks can also keep replacing the connection-ready deadline, so the App never reaches a visible failed state.
  • Fix: Preserve the typed rejection reason and revision hashes through the native bridge. Classify protocol_revision_mismatch as terminal for the current connection attempt, close the rejected subscription without scheduling another live retry, and keep the original synchronization deadline for retryable transport failures. Present an explicit incompatible version message with a mobile update action; do not automatically restart a terminal attempt after foreground resume.
  • Validation: Verify an ordinary stream close still retries and rebuilds DeviceLink after the recovery grace period. Inject a protocol-revision rejection and assert that the connection enters the failed state once, emits a device_connection.phase_changed diagnostic with stable protocol_revision_mismatch, preserves both revision hashes, schedules no additional subscription, and stays terminal across background and foreground transitions. Confirm the failure UI offers Check for updates instead of Reconnect.
  • References: apps/mobile/src/native/agentLiveNativeBridge.ts, apps/mobile/src/services/workspaceAgentLiveLane.ts, apps/mobile/src/services/mobileApplicationService.ts, apps/mobile/src/components/MobileConnectionRecoveryOverlay.tsx

Android QR scan closes without advancing pairing

  • Symptom: The pairing scanner opens, reads a valid Desktop QR code, and returns to the device page, but the App neither shows an error nor advances to waiting or confirmed state.
  • Quick checks: Confirm Android launches and finishes the ZXing CaptureActivity, then inspect events from TuttiAppLifecycle. A background event during the in-process Activity handoff identifies a lifecycle-adapter regression rather than a camera or QR parsing failure.
  • Root cause: Android presents the scanner as a child Activity. Pausing the React Activity therefore looks like ordinary App backgrounding. If the service invalidates every pairing generation on that transition, the native scan promise can resolve successfully while its QR payload is silently discarded as stale.
  • Fix: Own application visibility at the native platform boundary. Android publishes ProcessLifecycleOwner state, so switching between Activities in the same process remains foreground; iOS publishes UIApplication foreground/background notifications. The TypeScript lifecycle port consumes this one semantic signal, while pairing remains unaware of Activities and scanners. A genuine process background suspends remote pairing operations and starts the DeviceLink grace period. A claim already sent is reconciled from challenge state after foreground recovery instead of being submitted twice.
  • Validation: Verify the lifecycle adapter publishes its initial state, deduplicates transitions, and disposes subscriptions. Verify the application service applies background policy only after a real process background event. Verify scanner cancellation, duplicate taps, permission failures, and claim/poll reconciliation in the device service. Finally scan on Android and confirm no background event is emitted during the MainActivity -> CaptureActivity -> MainActivity sequence.
  • References: apps/mobile/android/app/src/main/java/sh/tutti/mobile/AppLifecycleModule.kt, apps/mobile/ios/TuttiMobile/AppLifecycleModule.swift, apps/mobile/src/native/appLifecyclePort.ts

Android release bundling cannot resolve the JSX transform

  • Symptom: app:assembleRelease reaches Metro bundling and fails app:createBundleReleaseJsAndAssets with Cannot find module '@babel/plugin-transform-react-jsx', while TypeScript, Jest, and debug development may still pass.
  • Quick checks: Resolve @babel/plugin-transform-react-jsx from the apps/mobile package, then run a production Android Metro bundle. If resolution fails from the app but the plugin exists only below the React Native preset in pnpm's store, the app has been relying on transitive layout.
  • Root cause: react-native-css-interop asks Babel to load the JSX transform by package name but does not declare that package. pnpm's strict dependency isolation therefore does not expose the preset's private copy to the app-level Babel configuration.
  • Fix: Keep @babel/plugin-transform-react-jsx as an explicit apps/mobile development dependency aligned with the locked Babel cohort. Do not solve this by changing pnpm hoisting.
  • Validation: Resolve the plugin from apps/mobile, produce a --dev false Android Metro bundle, and run the Android Internal Build workflow through APK assembly.
  • References: apps/mobile/package.json, apps/mobile/babel.config.js

Android update stays on MainActivity without opening the installer

  • Symptom: The Android App remains in the update progress overlay or never opens PackageInstaller / .InstallStart. A successful feed request does not prove that Android downloaded, verified, and handed off the APK.

  • Quick checks: Filter the device logcat by the native update tag:

    adb -s <serial> logcat -v threadtime TuttiMobileSecurity:I '*:S'
    

    The UI phase narrows the boundary: Preparing means validation/storage, Downloading or Paused is Android DownloadManager, Verifying is exact-size plus SHA-256 verification, Allow update installation is the per-app unknown-source permission handoff, and Install the downloaded update is the Android package-installer handoff. Inspect the system-owned job with:

    adb -s <serial> shell dumpsys download
    

    DownloadManager owns network reconnection and background continuation. The App persists its download id and release metadata so reopening the App and choosing the same release reconnects to the job or reuses a verified cached APK. After verification it also records the target versionCode: a confirmed install deletes the APK immediately, while a process replacement during upgrade is handled on the next launch once the installed version reaches the target. Installer cancellation or failure retains the APK for retry. The in-App download cancel action removes the system job and cached artifact.

  • Relevant error codes: UPDATE_URL_INVALID, UPDATE_VERSION_INVALID, UPDATE_SIZE_INVALID, UPDATE_SIZE_MISMATCH, UPDATE_STORAGE_INSUFFICIENT, UPDATE_DOWNLOAD_FILE_FAILED, UPDATE_DOWNLOAD_SERVER_FAILED, UPDATE_DOWNLOAD_MANAGER_FAILED, UPDATE_DOWNLOAD_QUERY_FAILED, UPDATE_CHECKSUM_INVALID, UPDATE_CHECKSUM_FAILED, UPDATE_CACHE_FAILED, UPDATE_CACHE_REPLACE_FAILED, UPDATE_INSTALL_DEFERRED, UPDATE_INSTALL_STORAGE_INSUFFICIENT, UPDATE_INSTALL_INCOMPATIBLE, UPDATE_INSTALL_CONFLICT, UPDATE_INSTALL_BLOCKED, UPDATE_INSTALL_PACKAGE_INVALID, UPDATE_INSTALL_FAILED, UPDATE_URI_FAILED, and UPDATE_INSTALLER_LAUNCH_FAILED. If Android's per-app unknown-source permission is missing, UPDATE_INSTALL_PERMISSION_REQUIRED means the user returned without granting it rather than an APK failure; UPDATE_PERMISSION_SETTINGS_FAILED means the system settings page itself could not be opened. The EXTRA_RETURN_RESULT flow reports the package manager result through android.intent.extra.INSTALL_RESULT; project that legacy result to the public PackageInstaller status before choosing the in-App error. A cancelled activity without an install result is user cancellation; an aborted or rejected result remains a failure.

  • Root cause: The release feed, Android system download, artifact verification, FileProvider, and PackageInstaller are separate trust boundaries. A stale or malformed sizeBytes, a reused version URL with different bytes, insufficient storage, a paused system job, or missing unknown-source permission can stop the chain at different phases.

  • Fix: Keep release URLs credential-free HTTPS and publish immutable APK paths under <tag>/<sha256>/. Preflight both the APK and checksum before uploading either object, then update latest.json last. Treat latest.json sizeBytes and sha256 as mandatory: both must match before PackageInstaller opens. If the permission settings page opens, enable Allow from this source and return to the App; the verified cached APK continues to the installer automatically. Returning without granting permission produces a localized recovery prompt and keeps the package for an explicit retry. Keep the target version with that pending artifact, delete it on confirmed success, and repeat cleanup on the first launch whose installed versionCode reaches the target. For paused downloads, restore an allowed network or cancel and retry rather than deleting app data.

  • Validation: Run pnpm --filter @tutti-os/mobile check, ./gradlew app:testDebugUnitTest, and ./gradlew app:compileDebugKotlin from apps/mobile/android. On a physical device, reproduce from Settings and verify determinate progress, cancel, background/reopen continuation, offline pause/resume, exact-size/SHA failure, and the installer handoff. Confirm that a late progress callback after cancel cannot reopen the overlay. For permission recovery, revoke Tutti's unknown-source permission, finish the download, grant the permission in Android settings, return to the App, and confirm the installer opens without another download action. Also deny the permission once and confirm the App explains how to recover. Exercise an installer rejection and confirm it is reported with the mapped failure rather than cancellation. Cancel once and confirm the verified APK remains reusable; complete an update and confirm the pending APK is gone either from the success callback or after the updated App starts. For release recovery, simulate an APK-only upload, rerun the same version with a different APK digest, and confirm the missing objects are published before latest.json moves.

  • References: apps/mobile/android/app/src/main/java/sh/tutti/mobile/MobileSecurityModule.kt, apps/mobile/android/app/src/main/java/sh/tutti/mobile/MobileUpdateArtifact.kt, apps/mobile/android/app/src/main/java/sh/tutti/mobile/MobileUpdateCoordinator.kt, apps/mobile/android/app/src/main/java/sh/tutti/mobile/MobileUpdateInstallResult.kt, apps/mobile/android/app/src/main/java/sh/tutti/mobile/MobileUpdatePendingInstallStore.kt, apps/mobile/src/services/mobileUpdateService.ts, tools/scripts/build-mobile-release-latest.mjs

Mobile quick prompts are missing from the plus menu

  • Symptom: The connected Mobile composer does not show Quick prompts under +, or the list is empty despite prompts saved on Desktop.
  • Quick checks: Inspect the DeviceLink agent_http response for GET /v1/agent-quick-prompts. A 403 response with route_not_allowed identifies an allowlist gap.
  • Root cause: Mobile loads prompt content only from the canonical device list, so neither the UI nor workspace activity state invents a local fallback.
  • Fix: Keep the exact quick-prompt GET route in the DeviceLink allowlist and keep all preference writes, quick-prompt mutations, and per-prompt routes blocked. Refresh the authenticated-device quick-prompt service when the + menu opens.
  • Validation: Confirm the canonical prompt order is visible, search matches title and content, and selecting a prompt adds text at the current input position without replacing the existing draft or sending it.
  • References: services/tuttid/service/mobileremote/remote_protocol.go, apps/mobile/src/services/mobileQuickPromptLibraryService.ts, apps/mobile/src/components/MobileComposerDock.tsx

Mobile composer model and permission controls are missing

  • Symptom: A remotely connected Android or iOS App can open an Agent conversation and send prompts, but the model and permission controls above the composer are absent.
  • Quick checks: Query the host with tutti --json agent composer-options --agent-id <agent-target-id> and confirm that model and permission options are present. Then inspect the DeviceLink agent_http response for the matching POST /v1/agent-providers/{provider}/composer-options request. A 403 response with route_not_allowed identifies a transport allowlist gap rather than an AgentGUI capability or mobile layout problem.
  • Locale check: Confirm the request body includes the phone's normalized locale (en or zh-CN). If it is absent, tuttid falls back to the desktop preference and the phone may show model, reasoning, or permission labels in the desktop language.
  • Root cause: Mobile loads authoritative composer options through the DeviceLink HTTP bridge. If the exact read-side composer-options route is absent from the bridge allowlist, the request is rejected before it reaches tuttid's handler. The mobile UI fails closed and hides controls for options it cannot verify.
  • Fix: Add only the exact POST /v1/agent-providers/{provider}/composer-options shape to the DeviceLink agent_http allowlist, and include the mobile device locale in the request. Keep unrelated provider routes and other HTTP methods blocked; do not hardcode model or permission catalogs in the App.
  • Validation: Run the focused mobileremote tests, confirm a composer-options request round-trips through DeviceLink while GET and extra-path variants remain forbidden, then use different phone and desktop languages to verify that option labels follow the phone language and that model, reasoning, and permission changes update the chips and persist in the selected Session.
  • References: services/tuttid/service/mobileremote/remote_protocol.go, apps/mobile/src/services/workspaceActivityCommandAdapter.ts, apps/mobile/src/components/MobileComposerSettingsSheet.tsx

Mobile composer option sheets do not respond

  • Symptom: Model, reasoning, speed, and permission chips are visible and accessible above the composer, but tapping them does not show their option sheet; or rapidly alternating between a chip and the + button leaves two overlapping menus, with the lower menu visible but unable to receive taps.
  • Quick checks: Tap a chip on a real Android or iOS renderer and inspect the JavaScript log. Repeated BottomSheetModal::handlePortalRender entries, followed by a maximum-update-depth error, identify the legacy overlay path. Without that error, inspect the native window hierarchy: two simultaneous composer Modal windows identify competing local overlay state rather than a slow or missing press handler. A missing composer-options response is a different problem covered above.
  • Root causes: @gorhom/bottom-sheet delegates BottomSheetModal rendering through @gorhom/portal; its portal update path is incompatible with the current React 19 Native renderer and recursively republishes the modal node instead of presenting it. Separately, sibling composer controls must not own independent window-level overlay state: queued taps can open both windows, and the top window then intercepts input intended for the visible lower one.
  • Fix: Keep the shared compact NativeSheet on React Native's window-level Modal, with UI System scrim and panel tokens. Require a caller-localized close label, expose backdrop dismissal as an accessibility button, handle the iOS accessibility escape gesture, and represent an optional fixed height as one value rather than silently accepting multiple snap points. Reserve @gorhom/bottom-sheet for app-owned complex sheets that genuinely need its gesture, keyboard, or multi-snap-point behavior. At composition boundaries that offer multiple overlay entry points, keep one discriminated overlay state in their nearest common owner and make child controls submit typed menu intents.
  • Validation: Open and dismiss the model, speed, and permission sheets more than once, select the already active option without changing Session state, and rapidly alternate between a settings chip and +; assert that no more than one native modal is visible. Confirm there is no maximum-update-depth error. Also verify touch backdrop, Android system-back, VoiceOver escape, and screen-reader close button dismissal without hiding the sheet's interactive descendants.
  • References: packages/ui/system/src/native/sheet.tsx, apps/mobile/src/components/MobileComposerDock.tsx, apps/mobile/src/components/MobileComposerSettingsSheet.tsx

Browser login completes but leaves the browser in front

  • Symptom: Mobile completes provider login, but the browser stays in front. Switching back to Tutti manually reveals that the account session is already available or allows the login flow to finish.
  • Quick checks: Confirm the localhost callback received a transfer code and that transfer-code redemption succeeds. If the hosted result page's manual “open App” action works, the account flow is healthy and only the browser-to-App return path failed.
  • Root cause: Opening a raw external browser makes the hosted result page responsible for returning to tutti://auth/login. Mobile browsers can reject a custom-scheme navigation started by delayed JavaScript because it no longer carries a direct user gesture, even though the localhost bridge already completed the account transfer.
  • Fix: Keep the shared hosted login page, localhost bridge, and one-time transfer-code redemption. Present that flow with ASWebAuthenticationSession on iOS and AndroidX Auth Tab when the Android browser provider supports it, so the operating system owns callback matching and foreground return. Android falls back to the external system browser when Auth Tab is unavailable; the hosted manual return action remains the recovery path for that fallback.
  • Validation: On physical iOS and Android devices, complete a real provider login and confirm the browser dismisses and Tutti becomes foreground without a manual app switch. Also cancel once, exercise an Android browser without Auth Tab support, and verify the transfer code is redeemed no more than once.
  • References: apps/mobile/ios/TuttiMobile/MobileWebAuthenticationSession.swift, apps/mobile/ios/TuttiMobile/MobileBrowserAuthBridge.swift, apps/mobile/android/app/src/main/java/sh/tutti/mobile/MainActivity.kt, apps/mobile/android/app/src/main/java/sh/tutti/mobile/MobileBrowserAuthBridge.kt

Browser login returns to the App but remains signed out

  • Symptom: Android opens the Tutti Web login page, completes the provider login, and returns to the App, but the login screen shows a generic failure.
  • Quick checks: Confirm the localhost auth bridge has stopped listening after the callback. In read-only account-service access logs, compare the transfer code redemption with the immediately following user/v1/user_info request. A successful redemption followed by SESSION_ID_MISSING identifies this case.
  • Root cause: Mobile API requests already authenticate from the encrypted session using one explicit Cookie header. Leaving React Native's native cookie jar enabled creates a second credential source; a stale native session_id can be appended to or override the explicit session after browser login.
  • Fix: Keep the account session in encrypted native storage, set credentials: "omit" on account and control-plane fetches, and attach exactly one validated session_id header per API request. The system browser login bridge returns only a short-lived transfer code; do not copy provider or browser cookies into the App, and do not make API login depend on a WebView cookie store. During migration, clear the legacy native session_id cookie at startup and sign-out, but keep cookie installation outside the application port contract.
  • Validation: Complete a real browser login, verify the App reaches the device page, restart the App, and verify the same account session still authorizes device-list requests.
  • References: apps/mobile/src/services/accountClient.ts, apps/mobile/android/app/src/main/java/sh/tutti/mobile/MobileSecurityModule.kt
  • Symptom: Pairing and the secure link succeed, and the App may briefly load a workspace or session before returning to the launcher. Android process exit history reports EXIT_SELF with status 2; logcat contains fatal error: bulkBarrierPreWrite: unaligned arguments instead of a Java or React Native exception.
  • Quick checks: Run adb shell dumpsys activity exit-info sh.tutti.mobile and inspect a narrow logcat window for the Go fatal message. This distinguishes a Go runtime abort from an Android lifecycle transition or a React Native development reload.
  • Root cause: A gomobile-exported Go method returned a pointer-bearing value, first ([]byte, error) and later (string, error) from Agent Live's Subscriber.Apply. Generated cgo code moves pointer-bearing parameters and return values through a packed structure whose address is not guaranteed to satisfy the Go runtime write barrier's pointer alignment before Go 1.26. The first successful response-body or Agent Live frame therefore exposes the upstream cgo alignment defect. Returning final stream data together with io.EOF also cannot be represented by the generated Java API, which chooses either a byte array or an exception.
  • Fix: Build every Android AAR and iOS XCFramework gomobile artifact with Go 1.26.0 or newer, which includes the cmd/cgo alignment fix. Keep bulk stream data in a Java-owned byte array passed into Go and return only a scalar byte count so final bytes plus io.EOF remain representable. The Mobile and DeviceLink Makefiles pin the fixed toolchain; do not bypass that pin when rebuilding native artifacts.
  • Validation: Generate the Java binding and confirm the stream method is readInto(byte[]) with a scalar return, build the AAR and confirm every libgojni.so reports the pinned Go toolchain, then install the AAR consumer, connect on an ARM64 Android device, receive Agent Live frames, and observe beyond the previous crash window with no new Go fatal message.
  • References: packages/device-link/mobile/link.go, apps/mobile/android/app/src/main/java/sh/tutti/mobile/DeviceLinkModule.kt
  • Symptom: A paired computer is online and often on the same LAN, but the direct path regularly needs roughly ten seconds before it becomes usable. Relay may appear much faster even though P2P eventually succeeds.
  • Quick checks: Inspect sanitized device_link.stage events for direct_credentials_ready, direct_attempt_created, direct_first_candidate_published, direct_attempt_ready, direct_remote_candidate_received, and direct_connected. If credentials or attempt creation appear only after a five-second boundary, inspect the native prepare bridge for a blocking full ICE description. If ready is early but connected is late, isolate candidate exchange from authenticated ICE/QUIC instead of changing Relay delay.
  • Root cause: The caller previously waited for a complete host-only ICE description, created the attempt to discover server STUN endpoints, replaced the Participant, and then waited for a second complete description. The Desktop owner also waited for its full description before publishing. Two configured five-second gathering windows therefore sat on the critical path, while TSH's lower-level implementation already published credentials and trickled candidates during Connect.
  • Fix: Use candidateexchange.Start and publish valid ICE credentials immediately, allowing an empty candidate list. Run PublishLocal and FeedRemote beside Participant.Connect; keep signed attempt reads/writes, push hints, account authorization, and pairing state in the product adapter. Mobile must drain the gomobile ActionPump next/resolve protocol rather than use the legacy blocking LocalDescription bridge or own a second TypeScript retry/poll scheduler. Resolve a publication successfully only after the returned authoritative attempt contains its candidate snapshot. Android must keep enough native operation workers for Connect, candidate actions, and Relay work to progress concurrently.
  • Validation: Cover an initial zero-candidate snapshot, a later local candidate update, a ready peer with zero candidates, a later authoritative remote snapshot, missed-push polling fallback, exact-snapshot publish retry, and a successful stream probe. Compare stage deltas on LAN and external NAT; do not log candidates, addresses, ICE credentials, fingerprints, tokens, or payloads.
  • References: packages/device-link/candidateexchange, packages/device-link/mobile/link.go, apps/mobile/src/services/deviceLinkCandidateExchange.ts, services/tuttid/service/mobileremote/candidate_exchange.go

Mobile shows output from a completed Session after foreground resume

  • Symptom: After the App enters the background and is reopened, transcript output from the previously selected Session continues streaming even though that Session already ended on the host.
  • Quick checks: Compare App lifecycle events with Native TuttiDeviceLink Agent Live logs. Capture the subscription generation on the last delivery before background and the first delivery after foreground. If a delivery from the closed generation reaches the replacement JavaScript listener, the fault is lifecycle fencing rather than canonical Session state or server replay.
  • Root cause: DeviceLink intentionally stays open for a short background grace period, but Agent Live used to share that lifetime. A suspended JavaScript runtime could therefore miss or delay its stop call while Native continued reading the old stream. React Native could queue those deliveries and publish them to the newly attached listener after foreground resume. The Native envelope carried workspace identity but no local subscription generation, so the replacement listener could not distinguish queued old output from its new stream.
  • Fix: Stop Agent Live immediately at the Android and iOS background boundary while preserving the underlying DeviceLink grace interval. Give every bridge subscription a caller-owned generation, include it in every Native delivery, and reject mismatched generations in both the bridge parser and workspace live lane. Queue canonical workspace and selected-Session reconciliation before opening the replacement stream.
  • Validation: Background and foreground a connected workspace, invoke a captured old listener after the replacement subscription exists, and verify it cannot mark transport connected or change projected activity. Verify matching-generation ready and event deliveries still apply, missing or stale generations fail closed, and Android/iOS Native binding checks pass.
  • References: apps/mobile/src/native/createMobileServicePorts.ts, apps/mobile/src/services/workspaceAgentLiveLane.ts, apps/mobile/android/app/src/main/java/sh/tutti/mobile/DeviceLinkModule.kt, apps/mobile/ios/TuttiMobile/DeviceLinkModule.mm

Mobile stays connected after a long lock-screen interval but sends fail

  • Symptom: After Mobile remains locked or backgrounded for at least the DeviceLink grace interval, the previous conversation is still visible but the next command fails. Returning to the computer list and connecting again restores service.
  • Quick checks: Compare application.lifecycle_changed and device_connection.phase_changed diagnostics with Native TuttiDeviceLink logs. If Native closed the link after its grace deadline while JavaScript never published its own delayed disconnect, verify whether the foreground transition used elapsed background time. Confirm the workspace runtime is blocked until a replacement live lane reports ready.
  • Root cause: Native lifecycle handlers continue running while React Native timers may be suspended. When both layers independently scheduled the same background deadline, Native closed the real link but JavaScript canceled its still pending timer on foreground and resumed a stale connected workspace.
  • Fix: Keep the close deadline Native-owned. Record background entry time in the application service and decide short resume versus full reconnect from elapsed time on foreground. Project one application-scoped connection phase, retain the paused workspace until a hydrated replacement is ready, and let a root overlay render that phase without owning recovery logic.
  • Validation: Cover foreground before and after the grace boundary, an active live-stream loss, failed reconnect plus explicit retry, and generation fencing when the app backgrounds during recovery. On a physical device, lock beyond the deadline, unlock, observe reconnect/synchronize presentation, and send from the original conversation without revisiting the computer list.
  • References: apps/mobile/src/services/mobileApplicationService.ts, apps/mobile/android/app/src/main/java/sh/tutti/mobile/DeviceLinkModule.kt

iOS App crashes after loading the JavaScript bundle

  • Symptom: A physical iOS device downloads or evaluates the React Native bundle, then the App exits with SIGSEGV. The crash report points at objc_retain or objc_storeStrong below ObjCTurboModule::performMethodInvocation on the JavaScript thread.
  • Quick checks: Capture the App console and the latest .ips crash report. If the last log is JavaScript bundle evaluation and the faulting stack is a synchronous Objective-C TurboModule invocation, inspect every blocking synchronous Swift export used during startup before investigating Metro or DeviceLink.
  • Root cause: React Native's Objective-C interoperability path expects an object from a blocking synchronous method. Exporting a Swift method with a primitive Bool return can leave the invocation result buffer shaped like an object pointer; retaining that value then causes an invalid-address crash.
  • Fix: Return NSNumber from the Swift export. React Native converts it to a JavaScript boolean, so the TypeScript contract remains boolean.
  • Validation: Build and sign the physical-device target, install it, launch it with the device console attached, and keep the process alive beyond the previous post-bundle crash window. Confirm the initial lifecycle state still arrives as a JavaScript boolean.
  • References: apps/mobile/ios/TuttiMobile/AppLifecycleModule.swift, apps/mobile/ios/TuttiMobile/MobileNativeModules.m, apps/mobile/src/native/appLifecyclePort.ts

iOS pod install intermittently reports pathname contains null byte

  • Symptom: pnpm --filter @tutti-os/mobile ios:pods downloads and installs every Pod, then fails during Generating Pods project with ArgumentError: pathname contains null byte from Pod::Project#group_for_path_in_group. Re-running may succeed.
  • Quick checks: Confirm the stack ends in Pathname#realdirpath and the workspace uses pnpm-linked React Native packages. CocoaPods version changes or deleting Pods/ may change the frequency but do not identify a durable fix.
  • Root cause: CocoaPods preserves the filesystem layout for local Pods and resolves their common base path through realdirpath. That resolution is intermittently unsafe for pnpm-backed local Pod symlinks while the Pods project is generated.
  • Fix: Keep apps/mobile/ios/cocoapods_pathname_workaround.rb loaded from the Podfile. It retains CocoaPods' group-building behavior but normalizes the local Pod base with cleanpath instead of resolving the pnpm symlink through realdirpath. Do not depend on retries or a CocoaPods downgrade.
  • Validation: Run ios:pods from a clean checkout, confirm the Pods project is generated, then archive the TuttiMobile workspace on the GitHub macOS 26 runner to exercise the same pnpm and CocoaPods path.
  • References: apps/mobile/ios/Podfile, apps/mobile/ios/cocoapods_pathname_workaround.rb, CocoaPods #12798

Mobile Jest discovers tests inside iOS Pods

  • Symptom: pnpm --filter @tutti-os/mobile test starts hundreds of Hermes or third-party suites under apps/mobile/ios/Pods after Pods are installed. Project suites may pass, but the command fails on upstream fixtures, Flow syntax, or snapshots.
  • Quick checks: Inspect the failing test paths. Paths below ios/Pods identify test discovery crossing into CocoaPods output rather than a product test failure.
  • Root cause: Jest recursively searches the package root. CocoaPods can vendor JavaScript sources and tests, and generated native dependency directories are not excluded by the default React Native preset.
  • Fix: Keep /ios/Pods/ in the Mobile Jest testPathIgnorePatterns, alongside the Android generated tree and node_modules. Do not adjust Babel transforms to make vendored test suites run.
  • Validation: Install iOS Pods, run the Mobile test command, and confirm Jest discovers only repository-owned suites.
  • References: apps/mobile/jest.config.js

React Native Pressable rows stack their children vertically

  • Symptom: A Native button, list row, or app-owned tappable row declares flexDirection: "row" but its icon, label, count, or trailing action appears vertically stacked on a Fabric Android device. Plain View rows on the same screen remain horizontal.
  • Quick checks: Inspect the real device rather than relying only on TypeScript or snapshots. Compare the bounds from uiautomator dump for the direct children of the Pressable; consecutive vertical bounds identify the failure even when the parent style contains the expected row declaration.
  • Root cause: In the current React Native NativeWind/CSS interop renderer, layout declarations on the Pressable host are not reliable for arranging its direct children. Interaction, surface, size, and pressed-state styles still apply, which makes this easy to mistake for stale Fast Refresh state.
  • Fix: Keep interaction and surface styling on Pressable, but wrap its visual children in a plain token-backed View that owns flexDirection: "row" and alignment. Shared buttons and list rows should fix this once in @tutti-os/ui-system/native; app-specific tappable rows should follow the same inner-layout pattern.
  • Validation: Perform a full JavaScript reload, then verify on Android that button icon/label, list title/trailing action, and section label/count remain horizontal. Confirm touch and accessibility actions still reach the outer Pressable.
  • References: packages/ui/system/src/native/button.tsx, packages/ui/system/src/native/list-row.tsx