feat(diagnostics): active connect-failure triage + Sentry + in-app share (#2)
* feat(diagnostics): active connect-failure triage + Sentry + in-app share Replaces the opaque "Connection Failed" / "Network request failed" dead-end with on-device diagnostics that classify *why* a connect attempt failed. On failure (quick connect and edit-connection test), the app now: - runs parallel probes: target /global/health, target root, and a public 204 endpoint (internet reachability check) - classifies the cause: malformed-url, no-internet, server-unreachable, health-failed, tls-error, timeout - shows a plain-English summary + a "Share report" button that copies a full report (target URL, per-probe results w/ error.cause, device/app info, recent log ring-buffer) to the clipboard and opens the share sheet - captures the same structured context to Sentry (auto-upload), gated on EXPO_PUBLIC_SENTRY_DSN so dev/CI builds work without secrets New: src/lib/logbuffer.ts (ring buffer + logger), src/lib/diagnostics.ts (regex URL parse — Hermes URL is incomplete — probe + report + share), src/lib/sentry.ts (no-op-without-DSN wrapper, scrubs basic-auth from URLs). Wired Sentry.wrap around RootLayout and initSentry() at module load. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(sentry): wire Sentry DSN + source-map upload env into build; bump to 0.2.2 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.github/workflows/build.yml
vendored
5
.github/workflows/build.yml
vendored
@@ -10,6 +10,11 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }}
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
16
HANDOFF.md
16
HANDOFF.md
@@ -61,6 +61,22 @@
|
|||||||
3. **Add accessibility labels** — add `content-desc="Send"` to the send button in the app for better CUA reliability
|
3. **Add accessibility labels** — add `content-desc="Send"` to the send button in the app for better CUA reliability
|
||||||
4. **More scenarios** — settings toggle, reconnect after server restart, slash commands
|
4. **More scenarios** — settings toggle, reconnect after server restart, slash commands
|
||||||
|
|
||||||
|
## Connection Diagnostics (added in feat/connection-diagnostics-sentry)
|
||||||
|
|
||||||
|
When a connect attempt fails, the app now runs an **active triage probe** instead of
|
||||||
|
showing a generic error. It classifies the cause: `malformed-url`, `no-internet`,
|
||||||
|
`server-unreachable`, `health-failed`, `tls-error`, `timeout`. The dialog shows a
|
||||||
|
plain-English summary plus a **Share report** button (copies a full report —
|
||||||
|
target URL, per-probe results, device/app info, recent logs — to clipboard + share sheet).
|
||||||
|
|
||||||
|
- Code: `src/lib/diagnostics.ts` (probe + report), `src/lib/logbuffer.ts` (ring buffer),
|
||||||
|
`src/lib/sentry.ts` (auto-upload wrapper).
|
||||||
|
- **Sentry auto-upload** is opt-in via env var: set `EXPO_PUBLIC_SENTRY_DSN` at build time.
|
||||||
|
Without it, Sentry is a no-op and only the in-app Share report works (fully offline).
|
||||||
|
For source-map upload at build, also set `SENTRY_AUTH_TOKEN` / `SENTRY_ORG` / `SENTRY_PROJECT`.
|
||||||
|
- The probe runs on-device, so it reports the *phone's* real network reality —
|
||||||
|
unlike the co-located emulator, it can distinguish a true remote-peer tailnet failure.
|
||||||
|
|
||||||
## Repo & Auth
|
## Repo & Auth
|
||||||
- Repo: `dzianisv/opencode-mobile`
|
- Repo: `dzianisv/opencode-mobile`
|
||||||
- GitHub auth: `source ~/.env.d/github-dzianisv.env`
|
- GitHub auth: `source ~/.env.d/github-dzianisv.env`
|
||||||
|
|||||||
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "OpenCode",
|
"name": "OpenCode",
|
||||||
"slug": "opencode-mobile",
|
"slug": "opencode-mobile",
|
||||||
"version": "1.0.0",
|
"version": "0.2.2",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"scheme": "opencode",
|
"scheme": "opencode",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"bundler": "metro",
|
"bundler": "metro",
|
||||||
"output": "static"
|
"output": "static"
|
||||||
},
|
},
|
||||||
"plugins": ["expo-router", "expo-secure-store", "expo-local-authentication"],
|
"plugins": ["expo-router", "expo-secure-store", "expo-local-authentication", "@sentry/react-native/expo"],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true,
|
"typedRoutes": true,
|
||||||
"reactCompiler": true
|
"reactCompiler": true
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ import { useCatalog } from "../src/stores/catalog"
|
|||||||
import { useSettings } from "../src/stores/settings"
|
import { useSettings } from "../src/stores/settings"
|
||||||
import { AuthGate } from "../src/components/AuthGate"
|
import { AuthGate } from "../src/components/AuthGate"
|
||||||
import * as notifications from "../src/lib/notifications"
|
import * as notifications from "../src/lib/notifications"
|
||||||
|
import { initSentry, wrap } from "../src/lib/sentry"
|
||||||
|
|
||||||
|
initSentry()
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
export default function RootLayout() {
|
function RootLayout() {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
const isDark = colorScheme === "dark"
|
const isDark = colorScheme === "dark"
|
||||||
|
|
||||||
@@ -118,3 +121,5 @@ export default function RootLayout() {
|
|||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default wrap(RootLayout)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { router, useLocalSearchParams } from "expo-router"
|
|||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
import { useConnections } from "../../src/stores/connections"
|
import { useConnections } from "../../src/stores/connections"
|
||||||
import type { ConnectionType } from "../../src/lib/types"
|
import type { ConnectionType } from "../../src/lib/types"
|
||||||
|
import { probeConnection, shareReport } from "../../src/lib/diagnostics"
|
||||||
|
import { captureDiagnostic } from "../../src/lib/sentry"
|
||||||
|
|
||||||
const CONNECTION_TYPES: Array<{
|
const CONNECTION_TYPES: Array<{
|
||||||
type: ConnectionType
|
type: ConnectionType
|
||||||
@@ -79,14 +81,25 @@ export default function EditConnectionScreen() {
|
|||||||
},
|
},
|
||||||
password || undefined,
|
password || undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
setIsTesting(false)
|
||||||
|
Alert.alert("Success", "Connection successful!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed: run active diagnostics, capture to Sentry, offer a shareable report.
|
||||||
|
const report = await probeConnection(
|
||||||
|
url.trim(),
|
||||||
|
username.trim() && password ? { username: username.trim(), password } : undefined,
|
||||||
|
)
|
||||||
|
captureDiagnostic(report, result.error ? new Error(result.error) : undefined)
|
||||||
setIsTesting(false)
|
setIsTesting(false)
|
||||||
|
|
||||||
Alert.alert(
|
Alert.alert("Connection Failed", `${report.summary}\n\n(${result.error || "no detail"})`, [
|
||||||
result.ok ? "Success" : "Failed",
|
{ text: "OK", style: "cancel" },
|
||||||
result.ok
|
{ text: "Share report", onPress: () => shareReport(report) },
|
||||||
? "Connection successful!"
|
])
|
||||||
: `Could not connect to ${url.trim()}\n\n${result.error || "Check the URL and credentials."}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { router } from "expo-router"
|
|||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
import { useConnections } from "../../src/stores/connections"
|
import { useConnections } from "../../src/stores/connections"
|
||||||
import type { ConnectionType } from "../../src/lib/types"
|
import type { ConnectionType } from "../../src/lib/types"
|
||||||
|
import { probeConnection, shareReport } from "../../src/lib/diagnostics"
|
||||||
|
import { captureDiagnostic } from "../../src/lib/sentry"
|
||||||
|
|
||||||
export default function AddConnectionScreen() {
|
export default function AddConnectionScreen() {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
@@ -90,11 +92,20 @@ export default function AddConnectionScreen() {
|
|||||||
setIsConnecting(false)
|
setIsConnecting(false)
|
||||||
router.back()
|
router.back()
|
||||||
} else {
|
} else {
|
||||||
|
// Failed: run active diagnostics, capture to Sentry, offer a shareable report.
|
||||||
|
const report = await probeConnection(
|
||||||
|
serverUrl,
|
||||||
|
username.trim() && password ? { username: username.trim(), password } : undefined,
|
||||||
|
)
|
||||||
|
captureDiagnostic(report, result.error ? new Error(result.error) : undefined)
|
||||||
setIsConnecting(false)
|
setIsConnecting(false)
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Connection Failed",
|
"Connection Failed",
|
||||||
`Could not connect to ${serverUrl}\n\n${result.error || "Unknown error"}\n\nMake sure:\n1. OpenCode is running: opencode serve --hostname 0.0.0.0\n2. You're on the same network (or Tailscale is connected)\n3. The address is correct (use the IP for tailnet if MagicDNS isn't enabled)`,
|
`${report.summary}\n\nTarget: ${serverUrl}\nError: ${result.error || "Unknown error"}`,
|
||||||
[{ text: "OK" }],
|
[
|
||||||
|
{ text: "OK", style: "cancel" },
|
||||||
|
{ text: "Share report", onPress: () => shareReport(report) },
|
||||||
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
388
package-lock.json
generated
388
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.8",
|
"@gorhom/bottom-sheet": "5.2.8",
|
||||||
"@react-navigation/native": "^7.0.14",
|
"@react-navigation/native": "^7.0.14",
|
||||||
|
"@sentry/react-native": "~6.14.0",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"expo": "^54.0.0",
|
"expo": "^54.0.0",
|
||||||
"expo-clipboard": "8.0.8",
|
"expo-clipboard": "8.0.8",
|
||||||
@@ -2783,6 +2784,345 @@
|
|||||||
"nanoid": "^3.3.11"
|
"nanoid": "^3.3.11"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@sentry-internal/browser-utils": {
|
||||||
|
"version": "8.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.54.0.tgz",
|
||||||
|
"integrity": "sha512-DKWCqb4YQosKn6aD45fhKyzhkdG7N6goGFDeyTaJFREJDFVDXiNDsYZu30nJ6BxMM7uQIaARhPAC5BXfoED3pQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/core": "8.54.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry-internal/feedback": {
|
||||||
|
"version": "8.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.54.0.tgz",
|
||||||
|
"integrity": "sha512-nQqRacOXoElpE0L0ADxUUII0I3A94niqG9Z4Fmsw6057QvyrV/LvTiMQBop6r5qLjwMqK+T33iR4/NQI5RhsXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/core": "8.54.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry-internal/replay": {
|
||||||
|
"version": "8.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.54.0.tgz",
|
||||||
|
"integrity": "sha512-8xuBe06IaYIGJec53wUC12tY2q4z2Z0RPS2s1sLtbA00EvK1YDGuXp96IDD+HB9mnDMrQ/jW5f97g9TvPsPQUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry-internal/browser-utils": "8.54.0",
|
||||||
|
"@sentry/core": "8.54.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry-internal/replay-canvas": {
|
||||||
|
"version": "8.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.54.0.tgz",
|
||||||
|
"integrity": "sha512-K/On3OAUBeq/TV2n+1EvObKC+WMV9npVXpVyJqCCyn8HYMm8FUGzuxeajzm0mlW4wDTPCQor6mK9/IgOquUzCw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry-internal/replay": "8.54.0",
|
||||||
|
"@sentry/core": "8.54.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/babel-plugin-component-annotate": {
|
||||||
|
"version": "3.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.4.0.tgz",
|
||||||
|
"integrity": "sha512-tSzfc3aE7m0PM0Aj7HBDet5llH9AB9oc+tBQ8AvOqUSnWodLrNCuWeQszJ7mIBovD3figgCU3h0cvI6U5cDtsg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/browser": {
|
||||||
|
"version": "8.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.54.0.tgz",
|
||||||
|
"integrity": "sha512-BgUtvxFHin0fS0CmJVKTLXXZcke0Av729IVfi+2fJ4COX8HO7/HAP02RKaSQGmL2HmvWYTfNZ7529AnUtrM4Rg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry-internal/browser-utils": "8.54.0",
|
||||||
|
"@sentry-internal/feedback": "8.54.0",
|
||||||
|
"@sentry-internal/replay": "8.54.0",
|
||||||
|
"@sentry-internal/replay-canvas": "8.54.0",
|
||||||
|
"@sentry/core": "8.54.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/cli": {
|
||||||
|
"version": "2.45.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.45.0.tgz",
|
||||||
|
"integrity": "sha512-4sWu7zgzgHAjIxIjXUA/66qgeEf5ZOlloO+/JaGD5qXNSW0G7KMTR6iYjReNKMgdBCTH6bUUt9qiuA+Ex9Masw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"https-proxy-agent": "^5.0.0",
|
||||||
|
"node-fetch": "^2.6.7",
|
||||||
|
"progress": "^2.0.3",
|
||||||
|
"proxy-from-env": "^1.1.0",
|
||||||
|
"which": "^2.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"sentry-cli": "bin/sentry-cli"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@sentry/cli-darwin": "2.45.0",
|
||||||
|
"@sentry/cli-linux-arm": "2.45.0",
|
||||||
|
"@sentry/cli-linux-arm64": "2.45.0",
|
||||||
|
"@sentry/cli-linux-i686": "2.45.0",
|
||||||
|
"@sentry/cli-linux-x64": "2.45.0",
|
||||||
|
"@sentry/cli-win32-arm64": "2.45.0",
|
||||||
|
"@sentry/cli-win32-i686": "2.45.0",
|
||||||
|
"@sentry/cli-win32-x64": "2.45.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/cli-darwin": {
|
||||||
|
"version": "2.45.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.45.0.tgz",
|
||||||
|
"integrity": "sha512-p4Uxfv/L2fQdP3/wYnKVVz9gzZJf/1Xp9D+6raax/3Bu5y87yHYUqcdt98y/VAXQD4ofp2QgmhGUVPofvQNZmg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/cli-linux-arm": {
|
||||||
|
"version": "2.45.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.45.0.tgz",
|
||||||
|
"integrity": "sha512-6sEskFLlFKJ+e0MOYgIclBTUX5jYMyYhHIxXahEkI/4vx6JO0uvpyRAkUJRpJkRh/lPog0FM+tbP3so+VxB2qQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux",
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/cli-linux-arm64": {
|
||||||
|
"version": "2.45.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.45.0.tgz",
|
||||||
|
"integrity": "sha512-gUcLoEjzg7AIc4QQGEZwRHri+EHf3Gcms9zAR1VHiNF3/C/jL4WeDPJF2YiWAQt6EtH84tHiyhw1Ab/R8XFClg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux",
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/cli-linux-i686": {
|
||||||
|
"version": "2.45.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.45.0.tgz",
|
||||||
|
"integrity": "sha512-VmmOaEAzSW23YdGNdy/+oQjCNAMY+HmOGA77A25/ep/9AV7PQB6FI7xO5Y1PVvlkxZFJ23e373njSsEeg4uDZw==",
|
||||||
|
"cpu": [
|
||||||
|
"x86",
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux",
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/cli-linux-x64": {
|
||||||
|
"version": "2.45.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.45.0.tgz",
|
||||||
|
"integrity": "sha512-a0Oj68mrb25a0WjX/ShZ6AAd4PPiuLcgyzQr7bl2+DvYxIOajwkGbR+CZFEhOVZcfhTnixKy/qIXEzApEPHPQg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux",
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/cli-win32-arm64": {
|
||||||
|
"version": "2.45.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.45.0.tgz",
|
||||||
|
"integrity": "sha512-vn+CwS4p+52pQSLNPoi20ZOrQmv01ZgAmuMnjkh1oUZfTyBAwWLrAh6Cy4cztcN8DfL5dOWKQBo8DBKURE4ttg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/cli-win32-i686": {
|
||||||
|
"version": "2.45.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.45.0.tgz",
|
||||||
|
"integrity": "sha512-8mMoDdlwxtcdNIMtteMK7dbi7054jak8wKSHJ5yzMw8UmWxC5thc/gXBc1uPduiaI56VjoJV+phWHBKCD+6I4w==",
|
||||||
|
"cpu": [
|
||||||
|
"x86",
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/cli-win32-x64": {
|
||||||
|
"version": "2.45.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.45.0.tgz",
|
||||||
|
"integrity": "sha512-ZvK9cIqFaq7vZ0jkHJ/xh5au6902Dr+AUxSk6L6vCL7JCe2p93KGL/4d8VFB5PD/P7Y9b+105G/e0QIFKzpeOw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/cli/node_modules/agent-base": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/cli/node_modules/https-proxy-agent": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "6",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/core": {
|
||||||
|
"version": "8.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.54.0.tgz",
|
||||||
|
"integrity": "sha512-03bWf+D1j28unOocY/5FDB6bUHtYlm6m6ollVejhg45ZmK9iPjdtxNWbrLsjT1WRym0Tjzowu+A3p+eebYEv0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/react": {
|
||||||
|
"version": "8.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-8.54.0.tgz",
|
||||||
|
"integrity": "sha512-42T/fp8snYN19Fy/2P0Mwotu4gcdy+1Lx+uYCNcYP1o7wNGigJ7qb27sW7W34GyCCHjoCCfQgeOqDQsyY8LC9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/browser": "8.54.0",
|
||||||
|
"@sentry/core": "8.54.0",
|
||||||
|
"hoist-non-react-statics": "^3.3.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/react-native": {
|
||||||
|
"version": "6.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-6.14.0.tgz",
|
||||||
|
"integrity": "sha512-BBqixN6oV6tCNp1ABXfzvD531zxj1fUAH0HDPvOR/jX0h9f9pYfxCyI64B+DoQbVZKFsg8nte0QIHkZDhRAW9A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/babel-plugin-component-annotate": "3.4.0",
|
||||||
|
"@sentry/browser": "8.54.0",
|
||||||
|
"@sentry/cli": "2.45.0",
|
||||||
|
"@sentry/core": "8.54.0",
|
||||||
|
"@sentry/react": "8.54.0",
|
||||||
|
"@sentry/types": "8.54.0",
|
||||||
|
"@sentry/utils": "8.54.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": ">=49.0.0",
|
||||||
|
"react": ">=17.0.0",
|
||||||
|
"react-native": ">=0.65.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"expo": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/types": {
|
||||||
|
"version": "8.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.54.0.tgz",
|
||||||
|
"integrity": "sha512-wztdtr7dOXQKi0iRvKc8XJhJ7HaAfOv8lGu0yqFOFwBZucO/SHnu87GOPi8mvrTiy1bentQO5l+zXWAaMvG4uw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/core": "8.54.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/utils": {
|
||||||
|
"version": "8.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.54.0.tgz",
|
||||||
|
"integrity": "sha512-JL8UDjrsKxKclTdLXfuHfE7B3KbrAPEYP7tMyN/xiO2vsF6D84fjwYyalO0ZMtuFZE6vpSze8ZOLEh6hLnPYsw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/core": "8.54.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@sinclair/typebox": {
|
"node_modules/@sinclair/typebox": {
|
||||||
"version": "0.27.10",
|
"version": "0.27.10",
|
||||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
|
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
|
||||||
@@ -7114,6 +7454,26 @@
|
|||||||
"integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==",
|
"integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-forge": {
|
"node_modules/node-forge": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||||
@@ -7732,6 +8092,12 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -9213,6 +9579,12 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-interface-checker": {
|
"node_modules/ts-interface-checker": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||||
@@ -9723,6 +10095,16 @@
|
|||||||
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
|
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/whatwg-url-without-unicode": {
|
"node_modules/whatwg-url-without-unicode": {
|
||||||
"version": "8.0.0-3",
|
"version": "8.0.0-3",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz",
|
||||||
@@ -9737,6 +10119,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-url/node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.8",
|
"@gorhom/bottom-sheet": "5.2.8",
|
||||||
"@react-navigation/native": "^7.0.14",
|
"@react-navigation/native": "^7.0.14",
|
||||||
|
"@sentry/react-native": "~6.14.0",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"expo": "^54.0.0",
|
"expo": "^54.0.0",
|
||||||
"expo-clipboard": "8.0.8",
|
"expo-clipboard": "8.0.8",
|
||||||
|
|||||||
222
src/lib/diagnostics.ts
Normal file
222
src/lib/diagnostics.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
// Active connection diagnostics: when a connect attempt fails, run a set of
|
||||||
|
// parallel probes that classify *why* it failed, instead of swallowing the
|
||||||
|
// opaque RN "Network request failed" string.
|
||||||
|
import { Platform, Share } from "react-native"
|
||||||
|
import * as Clipboard from "expo-clipboard"
|
||||||
|
import * as Device from "expo-device"
|
||||||
|
import appJson from "../../app.json"
|
||||||
|
import { log, formatLogLines } from "./logbuffer"
|
||||||
|
|
||||||
|
const PROBE_TIMEOUT_MS = 8_000
|
||||||
|
// Public 204 endpoints used purely as an "is the internet reachable at all" check.
|
||||||
|
const INTERNET_CHECK_URL = "https://www.gstatic.com/generate_204"
|
||||||
|
|
||||||
|
export type Classification =
|
||||||
|
| "ok"
|
||||||
|
| "malformed-url"
|
||||||
|
| "no-internet"
|
||||||
|
| "server-unreachable"
|
||||||
|
| "health-failed"
|
||||||
|
| "tls-error"
|
||||||
|
| "timeout"
|
||||||
|
| "unknown"
|
||||||
|
|
||||||
|
export interface ProbeAttempt {
|
||||||
|
name: string
|
||||||
|
target: string
|
||||||
|
ok: boolean
|
||||||
|
status?: number
|
||||||
|
durationMs: number
|
||||||
|
error?: string
|
||||||
|
errorCause?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagnosticReport {
|
||||||
|
classification: Classification
|
||||||
|
summary: string
|
||||||
|
url: string
|
||||||
|
scheme?: string
|
||||||
|
host?: string
|
||||||
|
port?: string
|
||||||
|
isHostname: boolean
|
||||||
|
attempts: ProbeAttempt[]
|
||||||
|
device: {
|
||||||
|
platform: string
|
||||||
|
osVersion: string
|
||||||
|
model: string
|
||||||
|
appVersion: string
|
||||||
|
}
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedUrl {
|
||||||
|
valid: boolean
|
||||||
|
scheme?: string
|
||||||
|
host?: string
|
||||||
|
port?: string
|
||||||
|
isHostname: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hermes' built-in URL is incomplete (hostname/port often unreliable), so parse
|
||||||
|
// with a regex instead of `new URL`.
|
||||||
|
function parseUrl(url: string): ParsedUrl {
|
||||||
|
const m = url.trim().match(/^(https?):\/\/([^/:?#]+)(?::(\d+))?/i)
|
||||||
|
if (!m) return { valid: false, isHostname: false }
|
||||||
|
const scheme = m[1].toLowerCase()
|
||||||
|
const host = m[2]
|
||||||
|
const port = m[3] || (scheme === "https" ? "443" : "80")
|
||||||
|
const isHostname = !/^\d{1,3}(\.\d{1,3}){3}$/.test(host)
|
||||||
|
return { valid: true, scheme, host, port, isHostname }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function timedFetch(name: string, target: string, init?: RequestInit): Promise<ProbeAttempt> {
|
||||||
|
const start = Date.now()
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
||||||
|
try {
|
||||||
|
const res = await fetch(target, { ...init, signal: controller.signal })
|
||||||
|
return { name, target, ok: true, status: res.status, durationMs: Date.now() - start }
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as { name?: string; message?: string; cause?: unknown }
|
||||||
|
const aborted = err?.name === "AbortError"
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
target,
|
||||||
|
ok: false,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
error: aborted ? `timeout after ${PROBE_TIMEOUT_MS}ms` : err?.message || String(error),
|
||||||
|
errorCause: err?.cause != null ? String((err.cause as { message?: string })?.message ?? err.cause) : undefined,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function classify(parsed: ParsedUrl, health: ProbeAttempt, internet: ProbeAttempt, root: ProbeAttempt): {
|
||||||
|
classification: Classification
|
||||||
|
summary: string
|
||||||
|
} {
|
||||||
|
if (!parsed.valid) {
|
||||||
|
return { classification: "malformed-url", summary: "The server URL could not be parsed. Check for typos or extra characters." }
|
||||||
|
}
|
||||||
|
if (health.ok) {
|
||||||
|
return { classification: "ok", summary: "Health endpoint responded — connection actually works now." }
|
||||||
|
}
|
||||||
|
|
||||||
|
const txt = `${health.error ?? ""} ${health.errorCause ?? ""}`.toLowerCase()
|
||||||
|
const isTls = /ssl|tls|certificate|trust|handshake/.test(txt)
|
||||||
|
const isTimeout = /timeout|timed out/.test(txt)
|
||||||
|
|
||||||
|
if (isTls) {
|
||||||
|
return { classification: "tls-error", summary: "TLS/certificate problem. Try http:// instead of https://, or fix the server certificate." }
|
||||||
|
}
|
||||||
|
if (!internet.ok) {
|
||||||
|
return { classification: "no-internet", summary: "The device has no working internet/network at all (public check also failed). Check Wi-Fi/data and Tailscale (VPN) status." }
|
||||||
|
}
|
||||||
|
// Internet works, server does not.
|
||||||
|
if (root.ok) {
|
||||||
|
return { classification: "health-failed", summary: `Server is reachable but /global/health failed (HTTP ${health.status ?? "error"}). Likely wrong path, auth, or an old server version.` }
|
||||||
|
}
|
||||||
|
if (isTimeout) {
|
||||||
|
return { classification: "timeout", summary: "Connection to the server timed out (dropped, not refused). Likely a firewall, wrong port, or Tailscale ACL blocking the device." }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
classification: "server-unreachable",
|
||||||
|
summary:
|
||||||
|
`Internet works, but the server at ${parsed.host}:${parsed.port} is unreachable. ` +
|
||||||
|
(parsed.isHostname
|
||||||
|
? "Hostname may not resolve from this device (MagicDNS off?). Try the raw Tailscale IP. "
|
||||||
|
: "") +
|
||||||
|
"Confirm the opencode server is running, the device is on the same tailnet, and the port is correct.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeConnection(url: string, auth?: { username: string; password: string }): Promise<DiagnosticReport> {
|
||||||
|
const parsed = parseUrl(url)
|
||||||
|
log.info("diag", "probe start", url, "parsed", JSON.stringify(parsed))
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (auth) headers["Authorization"] = `Basic ${btoa(`${auth.username}:${auth.password}`)}`
|
||||||
|
|
||||||
|
let health: ProbeAttempt
|
||||||
|
let root: ProbeAttempt
|
||||||
|
let internet: ProbeAttempt
|
||||||
|
|
||||||
|
if (parsed.valid) {
|
||||||
|
const base = `${parsed.scheme}://${parsed.host}:${parsed.port}`
|
||||||
|
;[health, root, internet] = await Promise.all([
|
||||||
|
timedFetch("health", `${base}/global/health`, { headers }),
|
||||||
|
timedFetch("server-root", `${base}/`, { headers }),
|
||||||
|
timedFetch("internet", INTERNET_CHECK_URL),
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
const skipped: ProbeAttempt = { name: "health", target: url, ok: false, durationMs: 0, error: "skipped: malformed url" }
|
||||||
|
health = skipped
|
||||||
|
root = { ...skipped, name: "server-root" }
|
||||||
|
internet = await timedFetch("internet", INTERNET_CHECK_URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { classification, summary } = classify(parsed, health, internet, root)
|
||||||
|
|
||||||
|
const report: DiagnosticReport = {
|
||||||
|
classification,
|
||||||
|
summary,
|
||||||
|
url,
|
||||||
|
scheme: parsed.scheme,
|
||||||
|
host: parsed.host,
|
||||||
|
port: parsed.port,
|
||||||
|
isHostname: parsed.isHostname,
|
||||||
|
attempts: [health, root, internet],
|
||||||
|
device: {
|
||||||
|
platform: Platform.OS,
|
||||||
|
osVersion: String(Platform.Version),
|
||||||
|
model: Device.modelName || "unknown",
|
||||||
|
appVersion: (appJson as { expo?: { version?: string } }).expo?.version || "unknown",
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("diag", "probe result", classification, "-", summary)
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatReport(report: DiagnosticReport): string {
|
||||||
|
const lines: string[] = []
|
||||||
|
lines.push("=== OpenCode Mobile — Connection Diagnostic ===")
|
||||||
|
lines.push(`Time: ${report.timestamp}`)
|
||||||
|
lines.push(`Result: ${report.classification.toUpperCase()}`)
|
||||||
|
lines.push(`Summary: ${report.summary}`)
|
||||||
|
lines.push("")
|
||||||
|
lines.push(`Target URL: ${report.url}`)
|
||||||
|
lines.push(` scheme=${report.scheme} host=${report.host} port=${report.port} hostname=${report.isHostname}`)
|
||||||
|
lines.push("")
|
||||||
|
lines.push("Probes:")
|
||||||
|
for (const a of report.attempts) {
|
||||||
|
const status = a.ok ? `OK ${a.status ?? ""}`.trim() : `FAIL ${a.error ?? ""}`.trim()
|
||||||
|
lines.push(` - ${a.name.padEnd(12)} ${a.target}`)
|
||||||
|
lines.push(` ${status} (${a.durationMs}ms)${a.errorCause ? ` cause=${a.errorCause}` : ""}`)
|
||||||
|
}
|
||||||
|
lines.push("")
|
||||||
|
lines.push("Device:")
|
||||||
|
lines.push(` ${report.device.platform} ${report.device.osVersion} | ${report.device.model} | app ${report.device.appVersion}`)
|
||||||
|
lines.push("")
|
||||||
|
lines.push("Recent logs:")
|
||||||
|
lines.push(formatLogLines())
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the report to the clipboard and open the native share sheet.
|
||||||
|
// Works fully offline (unlike the Sentry auto-upload).
|
||||||
|
export async function shareReport(report: DiagnosticReport): Promise<void> {
|
||||||
|
const text = formatReport(report)
|
||||||
|
try {
|
||||||
|
await Clipboard.setStringAsync(text)
|
||||||
|
} catch {
|
||||||
|
// clipboard optional
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Share.share({ title: "OpenCode connection diagnostic", message: text })
|
||||||
|
} catch (e) {
|
||||||
|
log.warn("diag", "share failed", String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/lib/logbuffer.ts
Normal file
58
src/lib/logbuffer.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// In-memory ring buffer of recent log lines, for attaching to diagnostic reports.
|
||||||
|
// Also mirrors to console so logs still show in Metro / logcat.
|
||||||
|
|
||||||
|
export type LogLevel = "debug" | "info" | "warn" | "error"
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
ts: number
|
||||||
|
level: LogLevel
|
||||||
|
tag: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ENTRIES = 200
|
||||||
|
const buffer: LogEntry[] = []
|
||||||
|
|
||||||
|
function push(level: LogLevel, tag: string, parts: unknown[]) {
|
||||||
|
const message = parts
|
||||||
|
.map((p) => {
|
||||||
|
if (typeof p === "string") return p
|
||||||
|
try {
|
||||||
|
return JSON.stringify(p)
|
||||||
|
} catch {
|
||||||
|
return String(p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join(" ")
|
||||||
|
buffer.push({ ts: Date.now(), level, tag, message })
|
||||||
|
if (buffer.length > MAX_ENTRIES) buffer.shift()
|
||||||
|
|
||||||
|
const line = `[${tag}] ${message}`
|
||||||
|
if (level === "error") console.error(line)
|
||||||
|
else if (level === "warn") console.warn(line)
|
||||||
|
else console.log(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const log = {
|
||||||
|
debug: (tag: string, ...parts: unknown[]) => push("debug", tag, parts),
|
||||||
|
info: (tag: string, ...parts: unknown[]) => push("info", tag, parts),
|
||||||
|
warn: (tag: string, ...parts: unknown[]) => push("warn", tag, parts),
|
||||||
|
error: (tag: string, ...parts: unknown[]) => push("error", tag, parts),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLogEntries(): LogEntry[] {
|
||||||
|
return [...buffer]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLogLines(entries: LogEntry[] = buffer): string {
|
||||||
|
return entries
|
||||||
|
.map((e) => {
|
||||||
|
const t = new Date(e.ts).toISOString().slice(11, 23)
|
||||||
|
return `${t} ${e.level.toUpperCase().padEnd(5)} [${e.tag}] ${e.message}`
|
||||||
|
})
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearLog() {
|
||||||
|
buffer.length = 0
|
||||||
|
}
|
||||||
66
src/lib/sentry.ts
Normal file
66
src/lib/sentry.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// Thin Sentry wrapper. No-ops cleanly when no DSN is configured so dev/CI
|
||||||
|
// builds work without secrets. DSN comes from EXPO_PUBLIC_SENTRY_DSN
|
||||||
|
// (Expo inlines EXPO_PUBLIC_* at build time).
|
||||||
|
import * as Sentry from "@sentry/react-native"
|
||||||
|
import { log } from "./logbuffer"
|
||||||
|
import type { DiagnosticReport } from "./diagnostics"
|
||||||
|
|
||||||
|
const DSN = process.env.EXPO_PUBLIC_SENTRY_DSN
|
||||||
|
let enabled = false
|
||||||
|
|
||||||
|
export function initSentry() {
|
||||||
|
if (!DSN) {
|
||||||
|
log.info("sentry", "no DSN configured — telemetry disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: DSN,
|
||||||
|
// Capture breadcrumbs but keep performance tracing off by default.
|
||||||
|
tracesSampleRate: 0,
|
||||||
|
enableAutoSessionTracking: true,
|
||||||
|
// Don't send PII; connection URLs are attached explicitly + scrubbed below.
|
||||||
|
sendDefaultPii: false,
|
||||||
|
})
|
||||||
|
enabled = true
|
||||||
|
log.info("sentry", "initialized")
|
||||||
|
} catch (e) {
|
||||||
|
log.warn("sentry", "init failed", String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip basic-auth credentials from a URL before it leaves the device.
|
||||||
|
function scrubUrl(url: string): string {
|
||||||
|
return url.replace(/\/\/[^@/]+@/, "//<redacted>@")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureDiagnostic(report: DiagnosticReport, rawError?: unknown) {
|
||||||
|
log.info("sentry", "capture", report.classification, enabled ? "(uploading)" : "(local only)")
|
||||||
|
if (!enabled) return
|
||||||
|
Sentry.withScope((scope) => {
|
||||||
|
scope.setTag("connect.classification", report.classification)
|
||||||
|
scope.setTag("connect.scheme", report.scheme ?? "n/a")
|
||||||
|
scope.setContext("connection", {
|
||||||
|
url: scrubUrl(report.url),
|
||||||
|
host: report.host,
|
||||||
|
port: report.port,
|
||||||
|
isHostname: report.isHostname,
|
||||||
|
summary: report.summary,
|
||||||
|
})
|
||||||
|
scope.setContext("probes", {
|
||||||
|
attempts: report.attempts.map((a) => ({
|
||||||
|
name: a.name,
|
||||||
|
ok: a.ok,
|
||||||
|
status: a.status,
|
||||||
|
durationMs: a.durationMs,
|
||||||
|
error: a.error,
|
||||||
|
cause: a.errorCause,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
scope.setContext("device", report.device)
|
||||||
|
const err = rawError instanceof Error ? rawError : new Error(`connect ${report.classification}: ${report.summary}`)
|
||||||
|
Sentry.captureException(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wrap = Sentry.wrap
|
||||||
Reference in New Issue
Block a user