Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";
import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen";
import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
Expand Down Expand Up @@ -198,6 +199,13 @@ const SettingsContentStack = createNativeStackNavigator({
title: "Client Storage",
},
}),
SettingsDiagnostics: createNativeStackScreen({
screen: SettingsDiagnosticsRouteScreen,
linking: "diagnostics",
options: {
title: "Diagnostics",
},
}),
SettingsUsageAccount: createNativeStackScreen({
screen: UsageLimitAccountScreen,
options: { title: "Account" },
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import IconServer from "@tabler/icons-react-native/IconServer";
import IconSettings from "@tabler/icons-react-native/IconSettings";
import IconSparkles from "@tabler/icons-react-native/IconSparkles";
import IconStack2 from "@tabler/icons-react-native/IconStack2";
import IconStethoscope from "@tabler/icons-react-native/IconStethoscope";
import IconSun from "@tabler/icons-react-native/IconSun";
import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";
import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease";
Expand Down Expand Up @@ -164,6 +165,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
"point.topleft.down.curvedto.point.bottomright.up": IconGitMerge,
safari: IconExternalLink,
"server.rack": IconServer,
stethoscope: IconStethoscope,
"sidebar.left": IconLayoutSidebar,
"sidebar.right": IconLayoutSidebarRight,
"slider.horizontal.3": IconAdjustmentsHorizontal,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import Constants from "expo-constants";
import * as Updates from "expo-updates";
import { useEffect, useState } from "react";
import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { SettingsSection } from "../settings/components/SettingsSection";
import {
formatStartupCrashReport,
parseStartupCrashRecords,
type StartupCrashRecord,
} from "./crash-log-model";

// expo-updates keeps its persistent log this long. Reading any further back
// returns nothing, so this is the whole available window.
const LOG_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;

type CrashLogState =
| { readonly status: "loading" }
| { readonly status: "unavailable" }
| { readonly status: "ready"; readonly records: ReadonlyArray<StartupCrashRecord> };

function appIdentity() {
return {
version: Constants.expoConfig?.version ?? "0.0.0",
build:
(Platform.OS === "ios"
? Constants.platform?.ios?.buildNumber
: Constants.platform?.android?.versionCode?.toString()) ?? "dev",
};
}

/**
* Startup crashes that TestFlight and the stores strip from their reports.
* expo-updates' ErrorRecovery writes the JS error and component stack to its
* own log before aborting the process, so the next launch can show it here.
*/
export function SettingsDiagnosticsRouteScreen() {
const insets = useSafeAreaInsets();
const [state, setState] = useState<CrashLogState>(() =>
Updates.isEnabled ? { status: "loading" } : { status: "unavailable" },
);
const [copied, setCopied] = useState(false);

useEffect(() => {
if (!Updates.isEnabled) return;
let cancelled = false;
Updates.readLogEntriesAsync(LOG_WINDOW_MS)
.then((entries) => {
if (cancelled) return;
setState({ status: "ready", records: parseStartupCrashRecords(entries) });
})
.catch((error: unknown) => {
console.warn("[diagnostics] could not read the expo-updates log", error);
if (!cancelled) setState({ status: "unavailable" });
});
return () => {
cancelled = true;
};
}, []);

const records = state.status === "ready" ? state.records : [];
const copyReport = async () => {
const ok = await tryCopyTextWithHaptic(formatStartupCrashReport(records, appIdentity()), {
target: "crash report",
});
if (ok) setCopied(true);
};

return (
<View collapsable={false} className="flex-1 bg-sheet">
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentInset={{ bottom: Math.max(insets.bottom, 18) }}
showsVerticalScrollIndicator={false}
className="flex-1"
contentContainerClassName="gap-6 px-5 pt-4 pb-[18px]"
>
<SettingsSection title="Startup crashes">
{state.status === "loading" ? (
<View className="items-center gap-3 px-6 py-8">
<ActivityIndicator />
<Text className="text-center text-sm text-foreground-muted">Reading crash log…</Text>
</View>
) : state.status === "unavailable" ? (
<EmptyState
icon="exclamationmark.triangle"
title="Crash log unavailable"
detail="Startup crash records are only kept in store and TestFlight builds."
/>
) : records.length === 0 ? (
<EmptyState
icon="checkmark.circle"
title="No startup crashes"
detail="Nothing has taken the app down during launch in the last 7 days."
/>
) : (
records.map((record, index) => (
<CrashRow key={record.timestamp} record={record} first={index === 0} />
))
)}
</SettingsSection>

<View className="gap-3">
<SettingsSection title="Actions">
<Pressable
accessibilityRole="button"
disabled={state.status !== "ready"}
onPress={() => void copyReport()}
className="flex-row items-center gap-4 p-4 disabled:opacity-40"
>
<SymbolView
name={copied ? "checkmark" : "doc.on.doc"}
size={22}
tintColorClassName={"accent-icon"}
type="monochrome"
weight="regular"
/>
<Text className="flex-1 text-lg text-foreground">
{copied ? "Copied" : "Copy crash report"}
</Text>
</Pressable>
</SettingsSection>
<Text className="px-2 text-sm leading-normal text-foreground-muted">
Paste the report into a GitHub issue. It contains the app version, the JavaScript error,
and the component stack; no thread content or credentials.
</Text>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
</View>
</ScrollView>
</View>
);
}

function EmptyState(props: {
readonly icon: "exclamationmark.triangle" | "checkmark.circle";
readonly title: string;
readonly detail: string;
}) {
return (
<View className="items-center gap-2 px-6 py-8">
<SymbolView
name={props.icon}
size={28}
tintColorClassName={"accent-icon"}
type="monochrome"
weight="regular"
/>
<Text className="text-center text-base text-foreground">{props.title}</Text>
<Text className="text-center text-sm text-foreground-muted">{props.detail}</Text>
</View>
);
}

function CrashRow(props: { readonly record: StartupCrashRecord; readonly first: boolean }) {
const { record } = props;
return (
<View className={props.first ? "gap-1.5 p-4" : "gap-1.5 border-t border-border-subtle p-4"}>
<Text className="text-xs text-foreground-muted">
{new Date(record.timestamp).toLocaleString()}
</Text>
<Text selectable className="text-base leading-snug text-danger-foreground">
{record.description}
</Text>
{record.frames.length > 0 ? (
<Text selectable className="font-mono text-xs leading-snug text-foreground-muted">
{record.frames.slice(0, 4).join("\n")}
</Text>
) : null}
</View>
);
}
74 changes: 74 additions & 0 deletions apps/mobile/src/features/diagnostics/crash-log-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vite-plus/test";

import { formatStartupCrashReport, parseStartupCrashRecords } from "./crash-log-model";

// Verbatim shape of the entry expo-updates wrote for the build 56 launch crash.
const BUNDLE =
"/Users/expo/workingdir/build/apps/mobile/ios/build/Build/Intermediates.noindex/ArchiveIntermediates/T3Code/BuildProductsPath/Release-iphoneos/main.jsbundle";
const FATAL = {
timestamp: 1789277752000,
level: "error",
code: "JSRuntimeError",
message: [
"ErrorRecovery fatal exception: Fatal error: Time: 1789277752033.127930",
"Domain: RCTErrorDomain",
"Code: 0",
"Description: Unhandled JS Exception: TypeError: Cannot read property 'defaultModelSelection' of null",
"",
"This error is located at:",
` at NewTaskFlowProvider (${BUNDLE}:590585:51)`,
` at NavigationProvider (${BUNDLE}:139067:3)`,
" at RNSSafeAreaView (<anonymous>)",
].join("\n"),
};

describe("parseStartupCrashRecords", () => {
it("extracts the JS error and a compact component stack from a fatal entry", () => {
const [record] = parseStartupCrashRecords([FATAL]);
expect(record?.description).toBe(
"TypeError: Cannot read property 'defaultModelSelection' of null",
);
expect(record?.frames).toEqual([
"NewTaskFlowProvider (main.jsbundle:590585:51)",
"NavigationProvider (main.jsbundle:139067:3)",
"RNSSafeAreaView",
]);
expect(record?.detail).toContain("Domain: RCTErrorDomain");
});

it("ignores update-check noise and orders crashes newest first", () => {
const records = parseStartupCrashRecords([
{ timestamp: 1, level: "info", code: "NoUpdatesAvailable", message: "checked" },
{ ...FATAL, timestamp: 10 },
{ timestamp: 5, level: "error", code: "UpdateFailedToLoad", message: "nope" },
{ ...FATAL, timestamp: 20 },
]);
expect(records.map((record) => record.timestamp)).toEqual([20, 10]);
});

it("skips a runtime error entry without a description line", () => {
expect(
parseStartupCrashRecords([
{ ...FATAL, message: "ErrorRecovery fatal exception: Fatal exception: Name: x" },
]),
).toEqual([]);
});
});

describe("formatStartupCrashReport", () => {
it("writes the app version and each crash's full detail", () => {
const report = formatStartupCrashReport(parseStartupCrashRecords([FATAL]), {
version: "1.1.1",
build: "56",
});
expect(report.startsWith("T3 Code 1.1.1 (56)\n")).toBe(true);
expect(report).toContain("2026-09-13T05:35:52.000Z");
expect(report).toContain("at NewTaskFlowProvider");
});

it("says so when nothing was recorded", () => {
expect(formatStartupCrashReport([], { version: "1.1.1", build: "56" })).toBe(
"T3 Code 1.1.1 (56)\nNo startup crashes recorded.",
);
});
});
83 changes: 83 additions & 0 deletions apps/mobile/src/features/diagnostics/crash-log-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* The shape of an expo-updates log entry we care about. Mirrors
* `UpdatesLogEntry` structurally so the model needs no native module at test
* time.
*/
export interface UpdatesLogEntryLike {
readonly timestamp: number;
readonly message: string;
readonly code: string;
readonly level: string;
}

/** One fatal JavaScript error that took the app down at startup. */
export interface StartupCrashRecord {
readonly timestamp: number;
/** The `Description:` line, minus the "Unhandled JS Exception:" prefix. */
readonly description: string;
/** Component stack (`at Name (bundle:line:col)`), one frame per entry. */
readonly frames: ReadonlyArray<string>;
/** Everything after the message header, verbatim, for copying. */
readonly detail: string;
}

const FATAL_PREFIX = "ErrorRecovery fatal exception: ";
const DESCRIPTION_PREFIX = "Description: ";
const UNHANDLED_PREFIX = "Unhandled JS Exception: ";

/**
* Keep only the JS runtime fatals expo-updates' ErrorRecovery wrote while
* aborting the process. Everything else in that log (update checks, asset
* loads) is noise for a "why did the app crash" question.
*/
export function parseStartupCrashRecords(
entries: ReadonlyArray<UpdatesLogEntryLike>,
): ReadonlyArray<StartupCrashRecord> {
const records: StartupCrashRecord[] = [];
for (const entry of entries) {
if (entry.code !== "JSRuntimeError" || !entry.message.startsWith(FATAL_PREFIX)) continue;
const body = entry.message.slice(FATAL_PREFIX.length);
const lines = body.split("\n");
const descriptionLine = lines.find((line) => line.startsWith(DESCRIPTION_PREFIX));
if (descriptionLine === undefined) continue;
let description = descriptionLine.slice(DESCRIPTION_PREFIX.length).trim();
if (description.startsWith(UNHANDLED_PREFIX)) {
description = description.slice(UNHANDLED_PREFIX.length);
}
const frames = lines
.map((line) => line.trim())
.filter((line) => line.startsWith("at "))
.map(compactFrame);
records.push({ timestamp: entry.timestamp, description, frames, detail: body.trim() });
}
// Newest first; the log appends in time order.
return records.sort((left, right) => right.timestamp - left.timestamp);
}

/**
* `at NewTaskFlowProvider (/Users/expo/…/main.jsbundle:590585:51)` reads as
* `NewTaskFlowProvider (main.jsbundle:590585:51)`. The absolute build path is
* the same on every frame and says nothing.
*/
function compactFrame(line: string): string {
const match = /^at (.+?) \((.*)\)$/.exec(line);
if (!match) return line.slice("at ".length);
const [, name = "", location = ""] = match;
const file = location.slice(location.lastIndexOf("/") + 1);
return location === "<anonymous>" ? name : `${name} (${file})`;
}

/** The report a user pastes into an issue: every recorded crash, newest first. */
export function formatStartupCrashReport(
records: ReadonlyArray<StartupCrashRecord>,
app: { readonly version: string; readonly build: string },
): string {
const header = `T3 Code ${app.version} (${app.build})`;
if (records.length === 0) return `${header}\nNo startup crashes recorded.`;
return [
header,
...records.map(
(record) => `\n--- ${new Date(record.timestamp).toISOString()} ---\n${record.detail}`,
),
].join("\n");
}
1 change: 1 addition & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,7 @@ function AppSettingsSection() {
return (
<SettingsSection title="App">
<SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" />
<SettingsRow icon="stethoscope" label="Diagnostics" target="SettingsDiagnostics" />
<SettingsRow
icon="doc.on.doc"
label="Open source licenses"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type SettingsSheetTarget =
| "SettingsAppearance"
| "SettingsProjectGrouping"
| "SettingsClientStorage"
| "SettingsDiagnostics"
| "SettingsOpenSourceLicenses"
| "SettingsUsage";

Expand Down
5 changes: 5 additions & 0 deletions docs/user/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ Install T3 Code from the
The phone connects to a server on another machine. Follow
[remote access](./remote-access.md) to link it through T3 Connect or a pairing URL.

If the app crashes during launch, open Settings → Diagnostics on the next launch
that succeeds. It lists startup crashes from the last 7 days with the error and
component stack that store crash reports leave out. Copy the report and paste it
into a GitHub issue; it carries no thread content or credentials.

## Providers

Open **Settings → Providers** in the web or desktop app, select the environment,
Expand Down
Loading