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.
107 lines
4.0 KiB
TypeScript
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,
|
|
};
|
|
};
|