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.
This commit is contained in:
Bohdan Triapitsyn
2026-08-13 00:06:51 +03:00
parent 4074986112
commit dc9c5f8e3e
15 changed files with 235 additions and 3 deletions
@@ -0,0 +1,55 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { copyTextToClipboard } from '@/lib/clipboard';
import { useI18n } from '@/lib/i18n';
import { formatMobileConnectDebugEntry, getMobileConnectDebugEntries, getMobileConnectDebugText } from './mobileConnectionDebug';
// Hidden diagnostics surface for device-only connection bugs: renders the
// in-memory connection event trail with one-tap copy, so a user on a release
// build (no tethered debugger, no Web Inspector) can paste the exact probe
// sequence into a bug report. Opened via long-press easter eggs on the connect
// screen logo and the instances list — invisible unless you know it's there.
export const MobileConnectionDebugPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const { t } = useI18n();
const [copied, setCopied] = React.useState(false);
// Snapshot on open; a live-updating log under the user's finger would fight
// the copy button. Reopen to refresh.
const entries = React.useMemo(() => getMobileConnectDebugEntries(), []);
const handleCopy = React.useCallback(() => {
void copyTextToClipboard(getMobileConnectDebugText()).then((result) => {
if (!result.ok) return;
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
});
}, []);
return (
<div className="fixed inset-0 z-[70] flex flex-col bg-background pb-[var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))] pt-[var(--safe-area-inset-top,env(safe-area-inset-top,0px))] text-foreground">
<div className="flex items-center justify-between gap-2 border-b border-border/70 px-4 py-2.5">
<h2 className="min-w-0 truncate typography-ui-label text-foreground">{t('mobile.connectionDebug.title')}</h2>
<div className="flex shrink-0 items-center gap-1.5">
<Button type="button" variant="outline" size="sm" onClick={handleCopy} disabled={entries.length === 0}>
<Icon name={copied ? 'check' : 'file-copy'} className="size-4" />
{copied ? t('mobile.connectionDebug.copied') : t('mobile.connectionDebug.copy')}
</Button>
<Button type="button" variant="ghost" size="icon" aria-label={t('mobile.connectionDebug.close')} onClick={onClose}>
<Icon name="close" className="size-[18px]" />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-4 py-3">
{entries.length === 0 ? (
<p className="typography-small text-muted-foreground">{t('mobile.connectionDebug.empty')}</p>
) : (
<pre className="whitespace-pre-wrap break-words typography-code text-muted-foreground">
{entries.map(formatMobileConnectDebugEntry).join('\n')}
</pre>
)}
</div>
</div>
);
};
@@ -7,6 +7,8 @@ import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { connectionDisplayUrl, useMobileConnection } from './mobileConnections';
import { useDebugPanelLongPress } from './mobileConnectionDebug';
import { MobileConnectionDebugPanel } from './MobileConnectionDebugPanel';
import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
import { mobileConnectionInputClass, mobileInputKeyboardProps } from './mobileConnectionUi';
import { MobileQrConnectionLoading, MobileQrScannerOverlay } from './MobileQrScannerOverlay';
@@ -37,6 +39,10 @@ export const MobileConnectionWelcome: React.FC<{
// Which saved connection is being connected to, for the per-row spinner.
const [connectingId, setConnectingId] = React.useState<string | null>(null);
const [password, setPassword] = React.useState('');
// Hidden diagnostics: long-press the logo to open the connection event log —
// reachable even when a user has been bounced back to this screen.
const [debugOpen, setDebugOpen] = React.useState(false);
const debugLongPress = useDebugPanelLongPress(React.useCallback(() => setDebugOpen(true), []));
const handleSubmit = React.useCallback((event: React.FormEvent) => {
event.preventDefault();
@@ -127,10 +133,13 @@ export const MobileConnectionWelcome: React.FC<{
<>
{isScanning ? <MobileQrScannerOverlay onCancel={() => scanAbortRef.current?.abort()} /> : null}
{isCompletingScan ? <MobileQrConnectionLoading /> : null}
{debugOpen ? <MobileConnectionDebugPanel onClose={() => setDebugOpen(false)} /> : null}
<main className="oc-keyboard-fill-screen flex min-h-dvh flex-col overflow-y-auto bg-background px-6 pb-[calc(var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))+28px)] pt-[calc(var(--safe-area-inset-top,env(safe-area-inset-top,0px))+28px)] text-foreground">
<div className="m-auto flex w-full max-w-[360px] shrink-0 flex-col items-center gap-9 py-8">
<div className="flex flex-col items-center gap-5 text-center">
<OpenChamberLogo width={72} height={72} className="size-[72px]" />
<span {...debugLongPress} className="select-none" style={{ touchAction: 'manipulation' }}>
<OpenChamberLogo width={72} height={72} className="size-[72px]" />
</span>
<h1 className="typography-h2 text-foreground">{t('mobile.connect.welcome.title')}</h1>
</div>
@@ -7,6 +7,8 @@ import { isRelayModeActive } from '@/lib/relay/runtime-tunnel';
import { cn } from '@/lib/utils';
import { connectionDisplayUrl, isActiveRuntimeConnection, useMobileConnection } from './mobileConnections';
import { useDebugPanelLongPress } from './mobileConnectionDebug';
import { MobileConnectionDebugPanel } from './MobileConnectionDebugPanel';
import { isQrScanSupported, scanConnectionQr } from './mobileQrScan';
import { mobileConnectionInputClass, mobileInputKeyboardProps } from './mobileConnectionUi';
import { MobileQrConnectionLoading, MobileQrScannerOverlay } from './MobileQrScannerOverlay';
@@ -37,6 +39,10 @@ export const MobileInstancesSurface: React.FC<{
const [formOpen, setFormOpen] = React.useState(false);
// Which row is being connected to, for the per-row spinner.
const [connectingId, setConnectingId] = React.useState<string | null>(null);
// Hidden diagnostics: long-press a connection row to open the connection
// event log (the long-press swallows the row's normal connect tap).
const [debugOpen, setDebugOpen] = React.useState(false);
const debugLongPress = useDebugPanelLongPress(React.useCallback(() => setDebugOpen(true), []));
// Populate/clear the form imperatively (on edit tap / cancel / save) rather than via
// an effect keyed on the derived connection object. With an effect, any churn of the
@@ -189,11 +195,12 @@ export const MobileInstancesSurface: React.FC<{
<>
{isScanning ? <MobileQrScannerOverlay onCancel={() => scanAbortRef.current?.abort()} /> : null}
{isCompletingScan ? <MobileQrConnectionLoading /> : null}
{debugOpen ? <MobileConnectionDebugPanel onClose={() => setDebugOpen(false)} /> : null}
<div className="flex h-full flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto px-5 py-4">
<div className="space-y-6">
{connections.length > 0 ? (
<div className="overflow-hidden rounded-[18px] border border-border/70 bg-surface-elevated">
<div {...debugLongPress} className="overflow-hidden rounded-[18px] border border-border/70 bg-surface-elevated">
{connections.map((connection) => {
const confirming = confirmingDeleteId === connection.id;
const isActive = isActiveRuntimeConnection(connection);
@@ -287,7 +294,7 @@ export const MobileInstancesSurface: React.FC<{
})}
</div>
) : (
<p className="rounded-[18px] border border-dashed border-border/70 px-4 py-6 text-center typography-small text-muted-foreground">
<p {...debugLongPress} className="rounded-[18px] border border-dashed border-border/70 px-4 py-6 text-center typography-small text-muted-foreground">
{t('mobile.connect.saved.empty')}
</p>
)}
@@ -0,0 +1,106 @@
// 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,
};
};
+5
View File
@@ -76,6 +76,11 @@ export const dict = {
'mobile.instances.status.connectedDirect': 'Verbunden · Lokales Netzwerk',
'mobile.instances.status.connectedRelay': 'Verbunden · Privater Relay',
'mobile.instances.saveEdit': 'Änderungen speichern',
'mobile.connectionDebug.title': 'Verbindungsprotokoll',
'mobile.connectionDebug.copy': 'Kopieren',
'mobile.connectionDebug.copied': 'Kopiert',
'mobile.connectionDebug.close': 'Schließen',
'mobile.connectionDebug.empty': 'Noch keine Verbindungsereignisse.',
'mobile.nav.changes': 'Änderungen',
'mobile.nav.settings': 'Einstellungen',
'mobile.surface.closeAria': 'Schließen',
+5
View File
@@ -88,6 +88,11 @@ export const dict = {
'mobile.instances.status.connectedDirect': 'Connected · Local network',
'mobile.instances.status.connectedRelay': 'Connected · Private relay',
'mobile.instances.saveEdit': 'Save changes',
'mobile.connectionDebug.title': 'Connection log',
'mobile.connectionDebug.copy': 'Copy',
'mobile.connectionDebug.copied': 'Copied',
'mobile.connectionDebug.close': 'Close',
'mobile.connectionDebug.empty': 'No connection events yet.',
'mobile.nav.changes': 'Changes',
'mobile.nav.settings': 'Settings',
'mobile.surface.closeAria': 'Close',
+5
View File
@@ -86,6 +86,11 @@ export const dict: Record<I18nKey, string> = {
"mobile.instances.label.label": "Nombre",
"mobile.instances.label.placeholder": "Nombre para mostrar (opcional)",
"mobile.instances.saveNew": "Guardar instancia",
"mobile.connectionDebug.title": "Registro de conexión",
"mobile.connectionDebug.copy": "Copiar",
"mobile.connectionDebug.copied": "Copiado",
"mobile.connectionDebug.close": "Cerrar",
"mobile.connectionDebug.empty": "Aún no hay eventos de conexión.",
"mobile.instances.status.connectedDirect": "Conectado · Red local",
"mobile.instances.status.connectedRelay": "Conectado · Relay privado",
"mobile.instances.saveEdit": "Guardar cambios",
+5
View File
@@ -2738,6 +2738,11 @@ export const dict = {
'mobile.instances.status.connectedDirect': 'Connecté · Réseau local',
'mobile.instances.status.connectedRelay': 'Connecté · Relais privé',
'mobile.instances.saveEdit': 'Enregistrer les modifications',
'mobile.connectionDebug.title': 'Journal de connexion',
'mobile.connectionDebug.copy': 'Copier',
'mobile.connectionDebug.copied': 'Copié',
'mobile.connectionDebug.close': 'Fermer',
'mobile.connectionDebug.empty': 'Aucun événement de connexion pour le moment.',
'mobile.nav.changes': 'Modifications',
'mobile.nav.settings': 'Paramètres',
'mobile.surface.closeAria': 'Fermer',
+5
View File
@@ -86,6 +86,11 @@ export const dict: Record<I18nKey, string> = {
'mobile.instances.status.connectedDirect': '接続中 · ローカルネットワーク',
'mobile.instances.status.connectedRelay': '接続中 · プライベートリレー',
'mobile.instances.saveEdit': '変更を保存',
'mobile.connectionDebug.title': '接続ログ',
'mobile.connectionDebug.copy': 'コピー',
'mobile.connectionDebug.copied': 'コピーしました',
'mobile.connectionDebug.close': '閉じる',
'mobile.connectionDebug.empty': '接続イベントはまだありません。',
'mobile.instances.cancelEdit': 'キャンセル',
'mobile.instances.edit': '編集',
'mobile.instances.delete': '削除',
+5
View File
@@ -89,6 +89,11 @@ export const dict: Record<I18nKey, string> = {
'mobile.instances.status.connectedDirect': '연결됨 · 로컬 네트워크',
'mobile.instances.status.connectedRelay': '연결됨 · 비공개 릴레이',
'mobile.instances.saveEdit': '변경 사항 저장',
'mobile.connectionDebug.title': '연결 로그',
'mobile.connectionDebug.copy': '복사',
'mobile.connectionDebug.copied': '복사됨',
'mobile.connectionDebug.close': '닫기',
'mobile.connectionDebug.empty': '아직 연결 이벤트가 없습니다.',
'mobile.nav.changes': '변경사항',
'mobile.nav.settings': '설정',
'mobile.surface.closeAria': '닫기',
+5
View File
@@ -90,6 +90,11 @@ export const dict: Record<I18nKey, string> = {
'mobile.instances.status.connectedDirect': 'Połączono · Sieć lokalna',
'mobile.instances.status.connectedRelay': 'Połączono · Prywatny relay',
'mobile.instances.saveEdit': 'Zapisz zmiany',
'mobile.connectionDebug.title': 'Dziennik połączenia',
'mobile.connectionDebug.copy': 'Kopiuj',
'mobile.connectionDebug.copied': 'Skopiowano',
'mobile.connectionDebug.close': 'Zamknij',
'mobile.connectionDebug.empty': 'Brak zdarzeń połączenia.',
'mobile.nav.changes': 'Zmiany',
'mobile.nav.settings': 'Ustawienia',
'mobile.surface.closeAria': 'Zamknij',
@@ -86,6 +86,11 @@ export const dict: Record<I18nKey, string> = {
"mobile.instances.label.label": "Nome",
"mobile.instances.label.placeholder": "Nome de exibição opcional",
"mobile.instances.saveNew": "Salvar instância",
"mobile.connectionDebug.title": "Registro de conexão",
"mobile.connectionDebug.copy": "Copiar",
"mobile.connectionDebug.copied": "Copiado",
"mobile.connectionDebug.close": "Fechar",
"mobile.connectionDebug.empty": "Ainda não há eventos de conexão.",
"mobile.instances.status.connectedDirect": "Conectado · Rede local",
"mobile.instances.status.connectedRelay": "Conectado · Relay privado",
"mobile.instances.saveEdit": "Salvar alterações",
+5
View File
@@ -86,6 +86,11 @@ export const dict: Record<I18nKey, string> = {
"mobile.instances.label.label": "Назва",
"mobile.instances.label.placeholder": "Необовʼязкова назва",
"mobile.instances.saveNew": "Зберегти інстанс",
"mobile.connectionDebug.title": "Журнал з'єднання",
"mobile.connectionDebug.copy": "Копіювати",
"mobile.connectionDebug.copied": "Скопійовано",
"mobile.connectionDebug.close": "Закрити",
"mobile.connectionDebug.empty": "Поки немає подій з'єднання.",
"mobile.instances.status.connectedDirect": "Підключено · Локальна мережа",
"mobile.instances.status.connectedRelay": "Підключено · Приватний relay",
"mobile.instances.saveEdit": "Зберегти зміни",
@@ -89,6 +89,11 @@ export const dict: Record<I18nKey, string> = {
'mobile.instances.status.connectedDirect': '已连接 · 局域网',
'mobile.instances.status.connectedRelay': '已连接 · 私有中继',
'mobile.instances.saveEdit': '保存更改',
'mobile.connectionDebug.title': '连接日志',
'mobile.connectionDebug.copy': '复制',
'mobile.connectionDebug.copied': '已复制',
'mobile.connectionDebug.close': '关闭',
'mobile.connectionDebug.empty': '暂无连接事件。',
'mobile.nav.changes': '更改',
'mobile.nav.settings': '设置',
'mobile.surface.closeAria': '关闭',
@@ -89,6 +89,11 @@ export const dict: Record<I18nKey, string> = {
'mobile.instances.status.connectedDirect': '已連線 · 區域網路',
'mobile.instances.status.connectedRelay': '已連線 · 私人中繼',
'mobile.instances.saveEdit': '儲存變更',
'mobile.connectionDebug.title': '連線日誌',
'mobile.connectionDebug.copy': '複製',
'mobile.connectionDebug.copied': '已複製',
'mobile.connectionDebug.close': '關閉',
'mobile.connectionDebug.empty': '尚無連線事件。',
'mobile.nav.changes': '變更',
'mobile.nav.settings': '設定',
'mobile.surface.closeAria': '關閉',