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:
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '削除',
|
||||
|
||||
@@ -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': '닫기',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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': '關閉',
|
||||
|
||||
Reference in New Issue
Block a user