Files
openchamber/packages/ui/src/apps/mobileConnectionDebug.ts
T
Bohdan Triapitsyn dc9c5f8e3e feat(mobile): hidden connection log panel for device-only diagnostics
Connection lifecycle events (probes, transport failures, resume decisions)
are mirrored into an in-memory trail that resets on every launch. A long
press on the connect-screen logo or the instances list opens a panel that
renders the trail with one-tap copy, so release builds can report the exact
probe sequence without a tethered debugger. Details reuse the already-masked
log payloads — no tokens or secrets are captured.
2026-08-13 00:06:51 +03:00

107 lines
4.0 KiB
TypeScript

// In-memory capture of mobile connection lifecycle events, so device-only
// connection failures (Capacitor iOS/Android) can be diagnosed without a
// tethered debugger: the hidden debug panel renders this buffer and offers a
// one-tap copy for bug reports. Console logging stays the primary sink — this
// mirrors it. Never persisted; details are the already-masked logConnect
// payloads (no tokens or secrets reach this module).
import React from 'react';
type MobileConnectDebugEntry = {
at: number;
step: string;
detail: string;
};
const MAX_ENTRIES = 300;
// The trail documents THE CURRENT app run only — it resets on every launch.
// Days of accumulated history would bury the failure the panel exists to
// expose. (An earlier revision persisted the log across launches; the storage
// key is removed here so installs that ran it don't keep a stale blob around.)
const LEGACY_STORAGE_KEY = 'openchamber.mobile.connectLog.v1';
const entries: MobileConnectDebugEntry[] = [];
if (typeof window !== 'undefined') {
try {
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
} catch {
// Storage unavailable — the in-memory trail still works.
}
}
export const recordMobileConnectDebug = (step: string, detail: string): void => {
entries.push({ at: Date.now(), step, detail });
if (entries.length > MAX_ENTRIES) entries.splice(0, entries.length - MAX_ENTRIES);
};
// Launch separator: makes "everything above happened in a previous run of the
// app" readable at a glance in the persisted trail.
if (typeof window !== 'undefined') {
recordMobileConnectDebug('app:launch', '{}');
}
export const getMobileConnectDebugEntries = (): MobileConnectDebugEntry[] => [...entries];
const formatTime = (at: number): string => {
const date = new Date(at);
const pad = (value: number, width = 2) => String(value).padStart(width, '0');
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
};
export const formatMobileConnectDebugEntry = (entry: MobileConnectDebugEntry): string =>
`${formatTime(entry.at)} ${entry.step}${entry.detail && entry.detail !== '{}' ? ` ${entry.detail}` : ''}`;
export const getMobileConnectDebugText = (): string =>
entries.map(formatMobileConnectDebugEntry).join('\n');
// Long-press detector for the hidden debug-panel triggers. Pointer-based with a
// movement threshold so scrolling and normal taps never fire it; the synthetic
// click that follows a long-press release is swallowed in the capture phase so
// the host element's normal tap action does not also run.
export const useDebugPanelLongPress = (onLongPress: () => void, delayMs = 700) => {
const timerRef = React.useRef<number | null>(null);
const originRef = React.useRef<{ x: number; y: number } | null>(null);
const firedRef = React.useRef(false);
const clear = React.useCallback(() => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = null;
originRef.current = null;
}, []);
React.useEffect(() => clear, [clear]);
const onPointerDown = React.useCallback((event: React.PointerEvent) => {
firedRef.current = false;
originRef.current = { x: event.clientX, y: event.clientY };
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
firedRef.current = true;
onLongPress();
}, delayMs);
}, [delayMs, onLongPress]);
const onPointerMove = React.useCallback((event: React.PointerEvent) => {
const origin = originRef.current;
if (!origin) return;
if (Math.abs(event.clientX - origin.x) > 10 || Math.abs(event.clientY - origin.y) > 10) clear();
}, [clear]);
const onClickCapture = React.useCallback((event: React.MouseEvent) => {
if (!firedRef.current) return;
firedRef.current = false;
event.preventDefault();
event.stopPropagation();
}, []);
return {
onPointerDown,
onPointerMove,
onPointerUp: clear,
onPointerCancel: clear,
onClickCapture,
};
};