| author | |
| committer | |
| log | 0a91b9a116117dd5df619fb7c1248cba9cf6f43c |
| tree | e26abd2f08686538452004aeb3240267d0e17801 |
| parent | 20a8f1de3668e0ff4da236d75258e2184a68d727 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Code <noreply@anthropic.com>8 files changed, 350 insertions(+), 0 deletions(-)
apps/mobile/src/Stack.tsx+8| ... | @@ -53,6 +53,7 @@ import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; | ... | @@ -53,6 +53,7 @@ import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; |
| 53 | import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; | 53 | import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; |
| 54 | import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; | 54 | import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; |
| 55 | import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; | 55 | import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; |
| 56 | import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen"; | ||
| 56 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; | 57 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; |
| 57 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; | 58 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 58 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; | 59 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; |
| ... | @@ -198,6 +199,13 @@ const SettingsContentStack = createNativeStackNavigator({ | ... | @@ -198,6 +199,13 @@ const SettingsContentStack = createNativeStackNavigator({ |
| 198 | title: "Client Storage", | 199 | title: "Client Storage", |
| 199 | }, | 200 | }, |
| 200 | }), | 201 | }), |
| 202 | SettingsDiagnostics: createNativeStackScreen({ | ||
| 203 | screen: SettingsDiagnosticsRouteScreen, | ||
| 204 | linking: "diagnostics", | ||
| 205 | options: { | ||
| 206 | title: "Diagnostics", | ||
| 207 | }, | ||
| 208 | }), | ||
| 201 | SettingsUsageAccount: createNativeStackScreen({ | 209 | SettingsUsageAccount: createNativeStackScreen({ |
| 202 | screen: UsageLimitAccountScreen, | 210 | screen: UsageLimitAccountScreen, |
| 203 | options: { title: "Account" }, | 211 | options: { title: "Account" }, |
apps/mobile/src/components/AppSymbol.tsx+2| ... | @@ -79,6 +79,7 @@ import IconServer from "@tabler/icons-react-native/IconServer"; | ... | @@ -79,6 +79,7 @@ import IconServer from "@tabler/icons-react-native/IconServer"; |
| 79 | import IconSettings from "@tabler/icons-react-native/IconSettings"; | 79 | import IconSettings from "@tabler/icons-react-native/IconSettings"; |
| 80 | import IconSparkles from "@tabler/icons-react-native/IconSparkles"; | 80 | import IconSparkles from "@tabler/icons-react-native/IconSparkles"; |
| 81 | import IconStack2 from "@tabler/icons-react-native/IconStack2"; | 81 | import IconStack2 from "@tabler/icons-react-native/IconStack2"; |
| 82 | import IconStethoscope from "@tabler/icons-react-native/IconStethoscope"; | ||
| 82 | import IconSun from "@tabler/icons-react-native/IconSun"; | 83 | import IconSun from "@tabler/icons-react-native/IconSun"; |
| 83 | import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2"; | 84 | import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2"; |
| 84 | import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease"; | 85 | import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease"; |
| ... | @@ -164,6 +165,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { | ... | @@ -164,6 +165,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 164 | "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge, | 165 | "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge, |
| 165 | safari: IconExternalLink, | 166 | safari: IconExternalLink, |
| 166 | "server.rack": IconServer, | 167 | "server.rack": IconServer, |
| 168 | stethoscope: IconStethoscope, | ||
| 167 | "sidebar.left": IconLayoutSidebar, | 169 | "sidebar.left": IconLayoutSidebar, |
| 168 | "sidebar.right": IconLayoutSidebarRight, | 170 | "sidebar.right": IconLayoutSidebarRight, |
| 169 | "slider.horizontal.3": IconAdjustmentsHorizontal, | 171 | "slider.horizontal.3": IconAdjustmentsHorizontal, |
apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx created+175| ... | @@ -0,0 +1,175 @@ | ||
| 1 | import Constants from "expo-constants"; | ||
| 2 | import * as Updates from "expo-updates"; | ||
| 3 | import { useEffect, useState } from "react"; | ||
| 4 | import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native"; | ||
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 6 | |||
| 7 | import { SymbolView } from "../../components/AppSymbol"; | ||
| 8 | import { AppText as Text } from "../../components/AppText"; | ||
| 9 | import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | ||
| 10 | import { SettingsSection } from "../settings/components/SettingsSection"; | ||
| 11 | import { | ||
| 12 | formatStartupCrashReport, | ||
| 13 | parseStartupCrashRecords, | ||
| 14 | type StartupCrashRecord, | ||
| 15 | } from "./crash-log-model"; | ||
| 16 | |||
| 17 | // expo-updates keeps its persistent log this long. Reading any further back | ||
| 18 | // returns nothing, so this is the whole available window. | ||
| 19 | const LOG_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; | ||
| 20 | |||
| 21 | type CrashLogState = | ||
| 22 | | { readonly status: "loading" } | ||
| 23 | | { readonly status: "unavailable" } | ||
| 24 | | { readonly status: "ready"; readonly records: ReadonlyArray<StartupCrashRecord> }; | ||
| 25 | |||
| 26 | function appIdentity() { | ||
| 27 | return { | ||
| 28 | version: Constants.expoConfig?.version ?? "0.0.0", | ||
| 29 | build: | ||
| 30 | (Platform.OS === "ios" | ||
| 31 | ? Constants.platform?.ios?.buildNumber | ||
| 32 | : Constants.platform?.android?.versionCode?.toString()) ?? "dev", | ||
| 33 | }; | ||
| 34 | } | ||
| 35 | |||
| 36 | /** | ||
| 37 | * Startup crashes that TestFlight and the stores strip from their reports. | ||
| 38 | * expo-updates' ErrorRecovery writes the JS error and component stack to its | ||
| 39 | * own log before aborting the process, so the next launch can show it here. | ||
| 40 | */ | ||
| 41 | export function SettingsDiagnosticsRouteScreen() { | ||
| 42 | const insets = useSafeAreaInsets(); | ||
| 43 | const [state, setState] = useState<CrashLogState>(() => | ||
| 44 | Updates.isEnabled ? { status: "loading" } : { status: "unavailable" }, | ||
| 45 | ); | ||
| 46 | const [copied, setCopied] = useState(false); | ||
| 47 | |||
| 48 | useEffect(() => { | ||
| 49 | if (!Updates.isEnabled) return; | ||
| 50 | let cancelled = false; | ||
| 51 | Updates.readLogEntriesAsync(LOG_WINDOW_MS) | ||
| 52 | .then((entries) => { | ||
| 53 | if (cancelled) return; | ||
| 54 | setState({ status: "ready", records: parseStartupCrashRecords(entries) }); | ||
| 55 | }) | ||
| 56 | .catch((error: unknown) => { | ||
| 57 | console.warn("[diagnostics] could not read the expo-updates log", error); | ||
| 58 | if (!cancelled) setState({ status: "unavailable" }); | ||
| 59 | }); | ||
| 60 | return () => { | ||
| 61 | cancelled = true; | ||
| 62 | }; | ||
| 63 | }, []); | ||
| 64 | |||
| 65 | const records = state.status === "ready" ? state.records : []; | ||
| 66 | const copyReport = async () => { | ||
| 67 | const ok = await tryCopyTextWithHaptic(formatStartupCrashReport(records, appIdentity()), { | ||
| 68 | target: "crash report", | ||
| 69 | }); | ||
| 70 | if (ok) setCopied(true); | ||
| 71 | }; | ||
| 72 | |||
| 73 | return ( | ||
| 74 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 75 | <ScrollView | ||
| 76 | contentInsetAdjustmentBehavior="automatic" | ||
| 77 | contentInset={{ bottom: Math.max(insets.bottom, 18) }} | ||
| 78 | showsVerticalScrollIndicator={false} | ||
| 79 | className="flex-1" | ||
| 80 | contentContainerClassName="gap-6 px-5 pt-4 pb-[18px]" | ||
| 81 | > | ||
| 82 | <SettingsSection title="Startup crashes"> | ||
| 83 | {state.status === "loading" ? ( | ||
| 84 | <View className="items-center gap-3 px-6 py-8"> | ||
| 85 | <ActivityIndicator /> | ||
| 86 | <Text className="text-center text-sm text-foreground-muted">Reading crash log…</Text> | ||
| 87 | </View> | ||
| 88 | ) : state.status === "unavailable" ? ( | ||
| 89 | <EmptyState | ||
| 90 | icon="exclamationmark.triangle" | ||
| 91 | title="Crash log unavailable" | ||
| 92 | detail="Startup crash records are only kept in store and TestFlight builds." | ||
| 93 | /> | ||
| 94 | ) : records.length === 0 ? ( | ||
| 95 | <EmptyState | ||
| 96 | icon="checkmark.circle" | ||
| 97 | title="No startup crashes" | ||
| 98 | detail="Nothing has taken the app down during launch in the last 7 days." | ||
| 99 | /> | ||
| 100 | ) : ( | ||
| 101 | records.map((record, index) => ( | ||
| 102 | <CrashRow key={record.timestamp} record={record} first={index === 0} /> | ||
| 103 | )) | ||
| 104 | )} | ||
| 105 | </SettingsSection> | ||
| 106 | |||
| 107 | <View className="gap-3"> | ||
| 108 | <SettingsSection title="Actions"> | ||
| 109 | <Pressable | ||
| 110 | accessibilityRole="button" | ||
| 111 | disabled={state.status !== "ready"} | ||
| 112 | onPress={() => void copyReport()} | ||
| 113 | className="flex-row items-center gap-4 p-4 disabled:opacity-40" | ||
| 114 | > | ||
| 115 | <SymbolView | ||
| 116 | name={copied ? "checkmark" : "doc.on.doc"} | ||
| 117 | size={22} | ||
| 118 | tintColorClassName={"accent-icon"} | ||
| 119 | type="monochrome" | ||
| 120 | weight="regular" | ||
| 121 | /> | ||
| 122 | <Text className="flex-1 text-lg text-foreground"> | ||
| 123 | {copied ? "Copied" : "Copy crash report"} | ||
| 124 | </Text> | ||
| 125 | </Pressable> | ||
| 126 | </SettingsSection> | ||
| 127 | <Text className="px-2 text-sm leading-normal text-foreground-muted"> | ||
| 128 | Paste the report into a GitHub issue. It contains the app version, the JavaScript error | ||
| 129 | message, and the component stack. Error messages can quote values from the app, so read | ||
| 130 | it over before sharing. | ||
| 131 | </Text> | ||
| 132 | </View> | ||
| 133 | </ScrollView> | ||
| 134 | </View> | ||
| 135 | ); | ||
| 136 | } | ||
| 137 | |||
| 138 | function EmptyState(props: { | ||
| 139 | readonly icon: "exclamationmark.triangle" | "checkmark.circle"; | ||
| 140 | readonly title: string; | ||
| 141 | readonly detail: string; | ||
| 142 | }) { | ||
| 143 | return ( | ||
| 144 | <View className="items-center gap-2 px-6 py-8"> | ||
| 145 | <SymbolView | ||
| 146 | name={props.icon} | ||
| 147 | size={28} | ||
| 148 | tintColorClassName={"accent-icon"} | ||
| 149 | type="monochrome" | ||
| 150 | weight="regular" | ||
| 151 | /> | ||
| 152 | <Text className="text-center text-base text-foreground">{props.title}</Text> | ||
| 153 | <Text className="text-center text-sm text-foreground-muted">{props.detail}</Text> | ||
| 154 | </View> | ||
| 155 | ); | ||
| 156 | } | ||
| 157 | |||
| 158 | function CrashRow(props: { readonly record: StartupCrashRecord; readonly first: boolean }) { | ||
| 159 | const { record } = props; | ||
| 160 | return ( | ||
| 161 | <View className={props.first ? "gap-1.5 p-4" : "gap-1.5 border-t border-border-subtle p-4"}> | ||
| 162 | <Text className="text-xs text-foreground-muted"> | ||
| 163 | {new Date(record.timestamp).toLocaleString()} | ||
| 164 | </Text> | ||
| 165 | <Text selectable className="text-base leading-snug text-danger-foreground"> | ||
| 166 | {record.description} | ||
| 167 | </Text> | ||
| 168 | {record.frames.length > 0 ? ( | ||
| 169 | <Text selectable className="font-mono text-xs leading-snug text-foreground-muted"> | ||
| 170 | {record.frames.slice(0, 4).join("\n")} | ||
| 171 | </Text> | ||
| 172 | ) : null} | ||
| 173 | </View> | ||
| 174 | ); | ||
| 175 | } | ||
apps/mobile/src/features/diagnostics/crash-log-model.test.ts created+74| ... | @@ -0,0 +1,74 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { formatStartupCrashReport, parseStartupCrashRecords } from "./crash-log-model"; | ||
| 4 | |||
| 5 | // Verbatim shape of the entry expo-updates wrote for the build 56 launch crash. | ||
| 6 | const BUNDLE = | ||
| 7 | "/Users/expo/workingdir/build/apps/mobile/ios/build/Build/Intermediates.noindex/ArchiveIntermediates/T3Code/BuildProductsPath/Release-iphoneos/main.jsbundle"; | ||
| 8 | const FATAL = { | ||
| 9 | timestamp: 1789277752000, | ||
| 10 | level: "error", | ||
| 11 | code: "JSRuntimeError", | ||
| 12 | message: [ | ||
| 13 | "ErrorRecovery fatal exception: Fatal error: Time: 1789277752033.127930", | ||
| 14 | "Domain: RCTErrorDomain", | ||
| 15 | "Code: 0", | ||
| 16 | "Description: Unhandled JS Exception: TypeError: Cannot read property 'defaultModelSelection' of null", | ||
| 17 | "", | ||
| 18 | "This error is located at:", | ||
| 19 | ` at NewTaskFlowProvider (${BUNDLE}:590585:51)`, | ||
| 20 | ` at NavigationProvider (${BUNDLE}:139067:3)`, | ||
| 21 | " at RNSSafeAreaView (<anonymous>)", | ||
| 22 | ].join("\n"), | ||
| 23 | }; | ||
| 24 | |||
| 25 | describe("parseStartupCrashRecords", () => { | ||
| 26 | it("extracts the JS error and a compact component stack from a fatal entry", () => { | ||
| 27 | const [record] = parseStartupCrashRecords([FATAL]); | ||
| 28 | expect(record?.description).toBe( | ||
| 29 | "TypeError: Cannot read property 'defaultModelSelection' of null", | ||
| 30 | ); | ||
| 31 | expect(record?.frames).toEqual([ | ||
| 32 | "NewTaskFlowProvider (main.jsbundle:590585:51)", | ||
| 33 | "NavigationProvider (main.jsbundle:139067:3)", | ||
| 34 | "RNSSafeAreaView", | ||
| 35 | ]); | ||
| 36 | expect(record?.detail).toContain("Domain: RCTErrorDomain"); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("ignores update-check noise and orders crashes newest first", () => { | ||
| 40 | const records = parseStartupCrashRecords([ | ||
| 41 | { timestamp: 1, level: "info", code: "NoUpdatesAvailable", message: "checked" }, | ||
| 42 | { ...FATAL, timestamp: 10 }, | ||
| 43 | { timestamp: 5, level: "error", code: "UpdateFailedToLoad", message: "nope" }, | ||
| 44 | { ...FATAL, timestamp: 20 }, | ||
| 45 | ]); | ||
| 46 | expect(records.map((record) => record.timestamp)).toEqual([20, 10]); | ||
| 47 | }); | ||
| 48 | |||
| 49 | it("skips a runtime error entry without a description line", () => { | ||
| 50 | expect( | ||
| 51 | parseStartupCrashRecords([ | ||
| 52 | { ...FATAL, message: "ErrorRecovery fatal exception: Fatal exception: Name: x" }, | ||
| 53 | ]), | ||
| 54 | ).toEqual([]); | ||
| 55 | }); | ||
| 56 | }); | ||
| 57 | |||
| 58 | describe("formatStartupCrashReport", () => { | ||
| 59 | it("writes the app version and each crash's full detail", () => { | ||
| 60 | const report = formatStartupCrashReport(parseStartupCrashRecords([FATAL]), { | ||
| 61 | version: "1.1.1", | ||
| 62 | build: "56", | ||
| 63 | }); | ||
| 64 | expect(report.startsWith("T3 Code 1.1.1 (56)\n")).toBe(true); | ||
| 65 | expect(report).toContain("2026-09-13T05:35:52.000Z"); | ||
| 66 | expect(report).toContain("at NewTaskFlowProvider"); | ||
| 67 | }); | ||
| 68 | |||
| 69 | it("says so when nothing was recorded", () => { | ||
| 70 | expect(formatStartupCrashReport([], { version: "1.1.1", build: "56" })).toBe( | ||
| 71 | "T3 Code 1.1.1 (56)\nNo startup crashes recorded.", | ||
| 72 | ); | ||
| 73 | }); | ||
| 74 | }); | ||
apps/mobile/src/features/diagnostics/crash-log-model.ts created+83| ... | @@ -0,0 +1,83 @@ | ||
| 1 | /** | ||
| 2 | * The shape of an expo-updates log entry we care about. Mirrors | ||
| 3 | * `UpdatesLogEntry` structurally so the model needs no native module at test | ||
| 4 | * time. | ||
| 5 | */ | ||
| 6 | export interface UpdatesLogEntryLike { | ||
| 7 | readonly timestamp: number; | ||
| 8 | readonly message: string; | ||
| 9 | readonly code: string; | ||
| 10 | readonly level: string; | ||
| 11 | } | ||
| 12 | |||
| 13 | /** One fatal JavaScript error that took the app down at startup. */ | ||
| 14 | export interface StartupCrashRecord { | ||
| 15 | readonly timestamp: number; | ||
| 16 | /** The `Description:` line, minus the "Unhandled JS Exception:" prefix. */ | ||
| 17 | readonly description: string; | ||
| 18 | /** Component stack (`at Name (bundle:line:col)`), one frame per entry. */ | ||
| 19 | readonly frames: ReadonlyArray<string>; | ||
| 20 | /** Everything after the message header, verbatim, for copying. */ | ||
| 21 | readonly detail: string; | ||
| 22 | } | ||
| 23 | |||
| 24 | const FATAL_PREFIX = "ErrorRecovery fatal exception: "; | ||
| 25 | const DESCRIPTION_PREFIX = "Description: "; | ||
| 26 | const UNHANDLED_PREFIX = "Unhandled JS Exception: "; | ||
| 27 | |||
| 28 | /** | ||
| 29 | * Keep only the JS runtime fatals expo-updates' ErrorRecovery wrote while | ||
| 30 | * aborting the process. Everything else in that log (update checks, asset | ||
| 31 | * loads) is noise for a "why did the app crash" question. | ||
| 32 | */ | ||
| 33 | export function parseStartupCrashRecords( | ||
| 34 | entries: ReadonlyArray<UpdatesLogEntryLike>, | ||
| 35 | ): ReadonlyArray<StartupCrashRecord> { | ||
| 36 | const records: StartupCrashRecord[] = []; | ||
| 37 | for (const entry of entries) { | ||
| 38 | if (entry.code !== "JSRuntimeError" || !entry.message.startsWith(FATAL_PREFIX)) continue; | ||
| 39 | const body = entry.message.slice(FATAL_PREFIX.length); | ||
| 40 | const lines = body.split("\n"); | ||
| 41 | const descriptionLine = lines.find((line) => line.startsWith(DESCRIPTION_PREFIX)); | ||
| 42 | if (descriptionLine === undefined) continue; | ||
| 43 | let description = descriptionLine.slice(DESCRIPTION_PREFIX.length).trim(); | ||
| 44 | if (description.startsWith(UNHANDLED_PREFIX)) { | ||
| 45 | description = description.slice(UNHANDLED_PREFIX.length); | ||
| 46 | } | ||
| 47 | const frames = lines | ||
| 48 | .map((line) => line.trim()) | ||
| 49 | .filter((line) => line.startsWith("at ")) | ||
| 50 | .map(compactFrame); | ||
| 51 | records.push({ timestamp: entry.timestamp, description, frames, detail: body.trim() }); | ||
| 52 | } | ||
| 53 | // Newest first; the log appends in time order. | ||
| 54 | return records.sort((left, right) => right.timestamp - left.timestamp); | ||
| 55 | } | ||
| 56 | |||
| 57 | /** | ||
| 58 | * `at NewTaskFlowProvider (/Users/expo/…/main.jsbundle:590585:51)` reads as | ||
| 59 | * `NewTaskFlowProvider (main.jsbundle:590585:51)`. The absolute build path is | ||
| 60 | * the same on every frame and says nothing. | ||
| 61 | */ | ||
| 62 | function compactFrame(line: string): string { | ||
| 63 | const match = /^at (.+?) \((.*)\)$/.exec(line); | ||
| 64 | if (!match) return line.slice("at ".length); | ||
| 65 | const [, name = "", location = ""] = match; | ||
| 66 | const file = location.slice(location.lastIndexOf("/") + 1); | ||
| 67 | return location === "<anonymous>" ? name : `${name} (${file})`; | ||
| 68 | } | ||
| 69 | |||
| 70 | /** The report a user pastes into an issue: every recorded crash, newest first. */ | ||
| 71 | export function formatStartupCrashReport( | ||
| 72 | records: ReadonlyArray<StartupCrashRecord>, | ||
| 73 | app: { readonly version: string; readonly build: string }, | ||
| 74 | ): string { | ||
| 75 | const header = `T3 Code ${app.version} (${app.build})`; | ||
| 76 | if (records.length === 0) return `${header}\nNo startup crashes recorded.`; | ||
| 77 | return [ | ||
| 78 | header, | ||
| 79 | ...records.map( | ||
| 80 | (record) => `\n--- ${new Date(record.timestamp).toISOString()} ---\n${record.detail}`, | ||
| 81 | ), | ||
| 82 | ].join("\n"); | ||
| 83 | } | ||
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+1| ... | @@ -821,6 +821,7 @@ function AppSettingsSection() { | ... | @@ -821,6 +821,7 @@ function AppSettingsSection() { |
| 821 | return ( | 821 | return ( |
| 822 | <SettingsSection title="App"> | 822 | <SettingsSection title="App"> |
| 823 | <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" /> | 823 | <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" /> |
| 824 | <SettingsRow icon="stethoscope" label="Diagnostics" target="SettingsDiagnostics" /> | ||
| 824 | <SettingsRow | 825 | <SettingsRow |
| 825 | icon="doc.on.doc" | 826 | icon="doc.on.doc" |
| 826 | label="Open source licenses" | 827 | label="Open source licenses" |
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+1| ... | @@ -4,6 +4,7 @@ export type SettingsSheetTarget = | ... | @@ -4,6 +4,7 @@ export type SettingsSheetTarget = |
| 4 | | "SettingsAppearance" | 4 | | "SettingsAppearance" |
| 5 | | "SettingsProjectGrouping" | 5 | | "SettingsProjectGrouping" |
| 6 | | "SettingsClientStorage" | 6 | | "SettingsClientStorage" |
| 7 | | "SettingsDiagnostics" | ||
| 7 | | "SettingsOpenSourceLicenses" | 8 | | "SettingsOpenSourceLicenses" |
| 8 | | "SettingsUsage"; | 9 | | "SettingsUsage"; |
| 9 | 10 |
docs/user/install.md+6| ... | @@ -60,6 +60,12 @@ Install T3 Code from the | ... | @@ -60,6 +60,12 @@ Install T3 Code from the |
| 60 | The phone connects to a server on another machine. Follow | 60 | The phone connects to a server on another machine. Follow |
| 61 | [remote access](./remote-access.md) to link it through T3 Connect or a pairing URL. | 61 | [remote access](./remote-access.md) to link it through T3 Connect or a pairing URL. |
| 62 | 62 | ||
| 63 | If the app crashes during launch, open Settings → Diagnostics on the next launch | ||
| 64 | that succeeds. It lists startup crashes from the last 7 days with the error and | ||
| 65 | component stack that store crash reports leave out. Copy the report and paste it | ||
| 66 | into a GitHub issue. Error messages can quote values from the app, so read it over | ||
| 67 | before sharing. | ||
| 68 | |||
| 63 | ## Providers | 69 | ## Providers |
| 64 | 70 | ||
| 65 | Open **Settings → Providers** in the web or desktop app, select the environment, | 71 | Open **Settings → Providers** in the web or desktop app, select the environment, |