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 safeexpectedRevisionandreceivedRevisionhashes. 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_mismatchas 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_changeddiagnostic with stableprotocol_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 fromTuttiAppLifecycle. 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
ProcessLifecycleOwnerstate, so switching between Activities in the same process remains foreground; iOS publishesUIApplicationforeground/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 -> MainActivitysequence. - 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:assembleReleasereaches Metro bundling and failsapp:createBundleReleaseJsAndAssetswithCannot find module '@babel/plugin-transform-react-jsx', while TypeScript, Jest, and debug development may still pass. - Quick checks: Resolve
@babel/plugin-transform-react-jsxfrom theapps/mobilepackage, 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-interopasks 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-jsxas an explicitapps/mobiledevelopment 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 falseAndroid Metro bundle, and run theAndroid Internal Buildworkflow 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 downloadDownloadManagerowns 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 targetversionCode: 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, andUPDATE_INSTALLER_LAUNCH_FAILED. If Android's per-app unknown-source permission is missing,UPDATE_INSTALL_PERMISSION_REQUIREDmeans the user returned without granting it rather than an APK failure;UPDATE_PERMISSION_SETTINGS_FAILEDmeans the system settings page itself could not be opened. TheEXTRA_RETURN_RESULTflow reports the package manager result throughandroid.intent.extra.INSTALL_RESULT; project that legacy result to the publicPackageInstallerstatus 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 updatelatest.jsonlast. Treatlatest.jsonsizeBytesandsha256as 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 installedversionCodereaches 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:compileDebugKotlinfromapps/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 beforelatest.jsonmoves. -
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_httpresponse forGET /v1/agent-quick-prompts. A 403 response withroute_not_allowedidentifies 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 DeviceLinkagent_httpresponse for the matchingPOST /v1/agent-providers/{provider}/composer-optionsrequest. A 403 response withroute_not_allowedidentifies 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(enorzh-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-optionsshape to the DeviceLinkagent_httpallowlist, 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
mobileremotetests, 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::handlePortalRenderentries, followed by a maximum-update-depth error, identify the legacy overlay path. Without that error, inspect the native window hierarchy: two simultaneous composerModalwindows 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-sheetdelegatesBottomSheetModalrendering 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
NativeSheeton React Native's window-levelModal, 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-sheetfor 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
ASWebAuthenticationSessionon 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_inforequest. A successful redemption followed bySESSION_ID_MISSINGidentifies this case. - Root cause: Mobile API requests already authenticate from the encrypted
session using one explicit
Cookieheader. Leaving React Native's native cookie jar enabled creates a second credential source; a stale nativesession_idcan 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 validatedsession_idheader 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 nativesession_idcookie 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
Android DeviceLink opens a session and then repeatedly restarts
- 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_SELFwith status2; logcat containsfatal error: bulkBarrierPreWrite: unaligned argumentsinstead of a Java or React Native exception. - Quick checks: Run
adb shell dumpsys activity exit-info sh.tutti.mobileand 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'sSubscriber.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 withio.EOFalso 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.EOFremain 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 everylibgojni.soreports 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
Mobile direct DeviceLink consistently takes about ten seconds
- 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.stageevents fordirect_credentials_ready,direct_attempt_created,direct_first_candidate_published,direct_attempt_ready,direct_remote_candidate_received, anddirect_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.Startand publish valid ICE credentials immediately, allowing an empty candidate list. RunPublishLocalandFeedRemotebesideParticipant.Connect; keep signed attempt reads/writes, push hints, account authorization, and pairing state in the product adapter. Mobile must drain the gomobileActionPumpnext/resolve protocol rather than use the legacy blockingLocalDescriptionbridge 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 forConnect, 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
TuttiDeviceLinkAgent 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_changedanddevice_connection.phase_changeddiagnostics with NativeTuttiDeviceLinklogs. 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 atobjc_retainorobjc_storeStrongbelowObjCTurboModule::performMethodInvocationon the JavaScript thread. - Quick checks: Capture the App console and the latest
.ipscrash 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
Boolreturn can leave the invocation result buffer shaped like an object pointer; retaining that value then causes an invalid-address crash. - Fix: Return
NSNumberfrom the Swift export. React Native converts it to a JavaScript boolean, so the TypeScript contract remainsboolean. - 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:podsdownloads and installs every Pod, then fails duringGenerating Pods projectwithArgumentError: pathname contains null bytefromPod::Project#group_for_path_in_group. Re-running may succeed. - Quick checks: Confirm the stack ends in
Pathname#realdirpathand the workspace uses pnpm-linked React Native packages. CocoaPods version changes or deletingPods/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.rbloaded from the Podfile. It retains CocoaPods' group-building behavior but normalizes the local Pod base withcleanpathinstead of resolving the pnpm symlink throughrealdirpath. Do not depend on retries or a CocoaPods downgrade. - Validation: Run
ios:podsfrom a clean checkout, confirm the Pods project is generated, then archive theTuttiMobileworkspace 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 teststarts hundreds of Hermes or third-party suites underapps/mobile/ios/Podsafter 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/Podsidentify 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 JesttestPathIgnorePatterns, alongside the Android generated tree andnode_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. PlainViewrows 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 dumpfor the direct children of thePressable; 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
Pressablehost 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-backedViewthat ownsflexDirection: "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