+
{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': '關閉',