diff --git a/packages/ui/src/apps/MobileConnectionDebugPanel.tsx b/packages/ui/src/apps/MobileConnectionDebugPanel.tsx new file mode 100644 index 00000000..b989df7a --- /dev/null +++ b/packages/ui/src/apps/MobileConnectionDebugPanel.tsx @@ -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 ( +
+
+

{t('mobile.connectionDebug.title')}

+
+ + +
+
+
+ {entries.length === 0 ? ( +

{t('mobile.connectionDebug.empty')}

+ ) : ( +
+            {entries.map(formatMobileConnectDebugEntry).join('\n')}
+          
+ )} +
+
+ ); +}; diff --git a/packages/ui/src/apps/MobileConnectionWelcome.tsx b/packages/ui/src/apps/MobileConnectionWelcome.tsx index 4722c900..2a105cdd 100644 --- a/packages/ui/src/apps/MobileConnectionWelcome.tsx +++ b/packages/ui/src/apps/MobileConnectionWelcome.tsx @@ -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(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 ? scanAbortRef.current?.abort()} /> : null} {isCompletingScan ? : null} + {debugOpen ? setDebugOpen(false)} /> : null}
- + + +

{t('mobile.connect.welcome.title')}

diff --git a/packages/ui/src/apps/MobileInstancesSurface.tsx b/packages/ui/src/apps/MobileInstancesSurface.tsx index 21aa230d..5eb03410 100644 --- a/packages/ui/src/apps/MobileInstancesSurface.tsx +++ b/packages/ui/src/apps/MobileInstancesSurface.tsx @@ -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(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 ? scanAbortRef.current?.abort()} /> : null} {isCompletingScan ? : null} + {debugOpen ? setDebugOpen(false)} /> : null}
{connections.length > 0 ? ( -
+
{connections.map((connection) => { const confirming = confirmingDeleteId === connection.id; const isActive = isActiveRuntimeConnection(connection); @@ -287,7 +294,7 @@ export const MobileInstancesSurface: React.FC<{ })}
) : ( -

+

{t('mobile.connect.saved.empty')}

)} diff --git a/packages/ui/src/apps/mobileConnectionDebug.ts b/packages/ui/src/apps/mobileConnectionDebug.ts new file mode 100644 index 00000000..062f68b3 --- /dev/null +++ b/packages/ui/src/apps/mobileConnectionDebug.ts @@ -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(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, + }; +}; diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index ad703ed7..9c1e41ab 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index e8b8f66c..cd1d0e9f 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index c0fec928..50778a36 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -86,6 +86,11 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 575ee8f2..d7f5dabb 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index d5625a91..2b65cc04 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -86,6 +86,11 @@ export const dict: Record = { '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': '削除', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 1befdd79..e92a1b52 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -89,6 +89,11 @@ export const dict: Record = { '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': '닫기', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index b86aed49..3548e484 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -90,6 +90,11 @@ export const dict: Record = { '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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 7eae7ba6..144d79d1 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -86,6 +86,11 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index da83c9df..1fee6316 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -86,6 +86,11 @@ export const dict: Record = { "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": "Зберегти зміни", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 6af405eb..977665c4 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -89,6 +89,11 @@ export const dict: Record = { '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': '关闭', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 8a739ba4..9e0982f0 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -89,6 +89,11 @@ export const dict: Record = { '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': '關閉',