Skip to content

Commit 6496544

Browse files
committed
Fix intermittent mobile connect QR rendering
1 parent 24678c7 commit 6496544

2 files changed

Lines changed: 139 additions & 34 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { availableNetworkModeFor, connectionUrlForMode } from './ConnectPhoneModal';
4+
5+
const remoteAccess = {
6+
enabled: true,
7+
url: 'http://192.168.1.20:7777?token=abc',
8+
wifiUrl: 'http://192.168.1.20:7777?token=abc',
9+
tailscaleUrl: 'http://100.64.1.2:7777?token=abc',
10+
};
11+
12+
describe('connectionUrlForMode', () => {
13+
it('returns null while remote access is disabled', () => {
14+
expect(connectionUrlForMode({ ...remoteAccess, enabled: false }, 'wifi')).toBeNull();
15+
});
16+
17+
it('uses the selected network URL when available', () => {
18+
expect(connectionUrlForMode(remoteAccess, 'wifi')).toBe(remoteAccess.wifiUrl);
19+
expect(connectionUrlForMode(remoteAccess, 'tailscale')).toBe(remoteAccess.tailscaleUrl);
20+
});
21+
22+
it('falls back to the server URL when the selected network URL is missing', () => {
23+
expect(connectionUrlForMode({ ...remoteAccess, wifiUrl: null }, 'wifi')).toBe(remoteAccess.url);
24+
expect(connectionUrlForMode({ ...remoteAccess, tailscaleUrl: null }, 'tailscale')).toBe(
25+
remoteAccess.url,
26+
);
27+
});
28+
});
29+
30+
describe('availableNetworkModeFor', () => {
31+
it('keeps the current network mode while it is available', () => {
32+
expect(availableNetworkModeFor(remoteAccess, 'wifi')).toBe('wifi');
33+
expect(availableNetworkModeFor(remoteAccess, 'tailscale')).toBe('tailscale');
34+
});
35+
36+
it('switches to an available mode when the current mode is unavailable', () => {
37+
expect(availableNetworkModeFor({ ...remoteAccess, wifiUrl: null }, 'wifi')).toBe('tailscale');
38+
expect(availableNetworkModeFor({ ...remoteAccess, tailscaleUrl: null }, 'tailscale')).toBe(
39+
'wifi',
40+
);
41+
});
42+
43+
it('keeps the current mode when only the fallback server URL is known', () => {
44+
expect(
45+
availableNetworkModeFor({ ...remoteAccess, wifiUrl: null, tailscaleUrl: null }, 'wifi'),
46+
).toBe('wifi');
47+
});
48+
});

src/components/ConnectPhoneModal.tsx

Lines changed: 91 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,54 @@ import { Dialog } from './Dialog';
55
import { store } from '../store/core';
66
import { startRemoteAccess, stopRemoteAccess, refreshRemoteStatus } from '../store/remote';
77
import { theme } from '../lib/theme';
8+
import type { RemoteAccess } from '../store/types';
89

910
type NetworkMode = 'wifi' | 'tailscale';
11+
type RemoteAccessUrls = Pick<RemoteAccess, 'enabled' | 'url' | 'wifiUrl' | 'tailscaleUrl'>;
1012

1113
interface ConnectPhoneModalProps {
1214
open: boolean;
1315
onClose: () => void;
1416
}
1517

18+
export function connectionUrlForMode(
19+
remoteAccess: RemoteAccessUrls,
20+
networkMode: NetworkMode,
21+
): string | null {
22+
if (!remoteAccess.enabled) return null;
23+
const modeUrl = networkMode === 'tailscale' ? remoteAccess.tailscaleUrl : remoteAccess.wifiUrl;
24+
return modeUrl ?? remoteAccess.url;
25+
}
26+
27+
export function availableNetworkModeFor(
28+
remoteAccess: RemoteAccessUrls,
29+
currentMode: NetworkMode,
30+
): NetworkMode {
31+
if (currentMode === 'wifi' && remoteAccess.wifiUrl) return 'wifi';
32+
if (currentMode === 'tailscale' && remoteAccess.tailscaleUrl) return 'tailscale';
33+
if (remoteAccess.wifiUrl) return 'wifi';
34+
if (remoteAccess.tailscaleUrl) return 'tailscale';
35+
return currentMode;
36+
}
37+
1638
export function ConnectPhoneModal(props: ConnectPhoneModalProps) {
1739
const [qrDataUrl, setQrDataUrl] = createSignal<string | null>(null);
40+
const [qrError, setQrError] = createSignal<string | null>(null);
1841
const [starting, setStarting] = createSignal(false);
1942
const [error, setError] = createSignal<string | null>(null);
2043
const [copied, setCopied] = createSignal(false);
2144
const [mode, setMode] = createSignal<NetworkMode>('wifi');
2245
let stopPolling: (() => void) | undefined;
2346
let copiedTimer: ReturnType<typeof setTimeout> | undefined;
47+
let qrRequestId = 0;
2448
onCleanup(() => {
2549
if (copiedTimer !== undefined) clearTimeout(copiedTimer);
50+
qrRequestId++;
2651
});
2752

28-
const activeUrl = createMemo(() => {
29-
if (!store.remoteAccess.enabled) return null;
30-
return mode() === 'tailscale' ? store.remoteAccess.tailscaleUrl : store.remoteAccess.wifiUrl;
31-
});
53+
const activeUrl = createMemo(() => connectionUrlForMode(store.remoteAccess, mode()));
3254

33-
async function generateQr(url: string) {
55+
async function generateQr(url: string, requestId: number) {
3456
try {
3557
const mod = await import('qrcode');
3658
// qrcode is CJS — Vite dev wraps it as .default only, prod adds named re-exports
@@ -40,20 +62,30 @@ export function ConnectPhoneModal(props: ConnectPhoneModalProps) {
4062
margin: 2,
4163
color: { dark: '#000000', light: '#ffffff' },
4264
});
65+
if (requestId !== qrRequestId) return;
4366
setQrDataUrl(dataUrl);
67+
setQrError(null);
4468
} catch (err) {
69+
if (requestId !== qrRequestId) return;
4570
console.error('[ConnectPhoneModal] QR generation failed:', err);
4671
setQrDataUrl(null);
72+
setQrError('QR code unavailable');
4773
}
4874
}
4975

50-
// Regenerate QR when mode changes
76+
// Regenerate QR when the shown connection URL changes.
5177
createEffect(() => {
5278
const url = activeUrl();
53-
if (url) {
54-
setQrDataUrl(null); // clear stale QR immediately
55-
generateQr(url);
79+
if (!props.open || !url) {
80+
qrRequestId++;
81+
setQrDataUrl(null);
82+
setQrError(null);
83+
return;
5684
}
85+
const requestId = ++qrRequestId;
86+
setQrDataUrl(null);
87+
setQrError(null);
88+
generateQr(url, requestId);
5789
});
5890

5991
// Focus the dialog panel when it opens (Dialog doesn't auto-focus)
@@ -75,28 +107,25 @@ export function ConnectPhoneModal(props: ConnectPhoneModalProps) {
75107
startRemoteAccess()
76108
.then((result) => {
77109
setStarting(false);
78-
// Default to wifi if available, otherwise tailscale
79-
setMode(result.wifiUrl ? 'wifi' : 'tailscale');
80-
const url = result.wifiUrl ?? result.tailscaleUrl ?? result.url;
81-
generateQr(url);
110+
setMode(
111+
availableNetworkModeFor(
112+
{
113+
enabled: true,
114+
url: result.url,
115+
wifiUrl: result.wifiUrl,
116+
tailscaleUrl: result.tailscaleUrl,
117+
},
118+
untrack(mode),
119+
),
120+
);
82121
})
83122
.catch((err: unknown) => {
84123
setStarting(false);
85124
setError(err instanceof Error ? err.message : 'Failed to start server');
86125
});
87126
} else {
88127
// Re-derive mode if network changed since last open
89-
if (mode() === 'wifi' && !store.remoteAccess.wifiUrl && store.remoteAccess.tailscaleUrl) {
90-
setMode('tailscale');
91-
} else if (
92-
mode() === 'tailscale' &&
93-
!store.remoteAccess.tailscaleUrl &&
94-
store.remoteAccess.wifiUrl
95-
) {
96-
setMode('wifi');
97-
}
98-
const url = activeUrl();
99-
if (url) generateQr(url);
128+
setMode(availableNetworkModeFor(store.remoteAccess, mode()));
100129
}
101130

102131
// Poll connected clients count while modal is open
@@ -224,15 +253,43 @@ export function ConnectPhoneModal(props: ConnectPhoneModalProps) {
224253
</div>
225254

226255
{/* QR Code */}
227-
<Show when={qrDataUrl()}>
228-
{(url) => (
229-
<img
230-
src={url()}
231-
alt="Connection QR code"
232-
style={{ width: '200px', height: '200px', 'border-radius': '8px' }}
233-
/>
234-
)}
235-
</Show>
256+
<div
257+
style={{
258+
width: '200px',
259+
height: '200px',
260+
'border-radius': '8px',
261+
background: '#ffffff',
262+
display: 'flex',
263+
'align-items': 'center',
264+
'justify-content': 'center',
265+
overflow: 'hidden',
266+
}}
267+
>
268+
<Show
269+
when={qrDataUrl()}
270+
fallback={
271+
<span
272+
aria-live="polite"
273+
style={{
274+
color: '#3f3f46',
275+
'font-size': '12px',
276+
'text-align': 'center',
277+
padding: '16px',
278+
}}
279+
>
280+
{qrError() ?? 'Generating QR code...'}
281+
</span>
282+
}
283+
>
284+
{(url) => (
285+
<img
286+
src={url()}
287+
alt="Connection QR code"
288+
style={{ width: '200px', height: '200px' }}
289+
/>
290+
)}
291+
</Show>
292+
</div>
236293

237294
{/* URL */}
238295
<div
@@ -252,7 +309,7 @@ export function ConnectPhoneModal(props: ConnectPhoneModalProps) {
252309
onClick={handleCopyUrl}
253310
title="Click to copy"
254311
>
255-
{activeUrl() ?? store.remoteAccess.url}
312+
{activeUrl()}
256313
</div>
257314

258315
<Show when={copied()}>

0 commit comments

Comments
 (0)