fix(mobile): support QR scanning without Play Services
This commit is contained in:
@@ -81,9 +81,10 @@ iOS Simulator helpers: `mobile:sim:{boot,install,launch,run,serve,list,kill}` (s
|
|||||||
- **Connection onboarding** — server URL entry, password unlock for locked servers, client-token
|
- **Connection onboarding** — server URL entry, password unlock for locked servers, client-token
|
||||||
issuance, saved connections, `Instances` management sheet, auto-connect to the last instance on
|
issuance, saved connections, `Instances` management sheet, auto-connect to the last instance on
|
||||||
launch. Deleting the active instance resets the runtime to the connect screen.
|
launch. Deleting the active instance resets the runtime to the connect screen.
|
||||||
- **QR pairing** — `@capacitor-mlkit/barcode-scanning`. Android's Google code scanner module is
|
- **QR pairing** — `@capacitor-mlkit/barcode-scanning`. Android uses the CameraX-backed
|
||||||
downloaded on first scan (needs Play Services + network); `mobileQrScan.ts` installs/awaits it
|
`startScan()` flow with the barcode model bundled in the app, so scanning works offline and
|
||||||
and retries. CAMERA permission + `NSCameraUsageDescription` declared.
|
without Google Play Services. iOS uses the plugin's native scanner. CAMERA permission +
|
||||||
|
`NSCameraUsageDescription` declared.
|
||||||
- **Secure storage** — `@aparajita/capacitor-secure-storage` for connection tokens.
|
- **Secure storage** — `@aparajita/capacitor-secure-storage` for connection tokens.
|
||||||
- **Deep links** — `openchamber://` URL scheme; a reusable intent vocabulary (`apps/deepLinks.ts`)
|
- **Deep links** — `openchamber://` URL scheme; a reusable intent vocabulary (`apps/deepLinks.ts`)
|
||||||
used by notification taps, widgets, and Control Center. Cold-launch intents are stashed.
|
used by notification taps, widgets, and Control Center. Cold-launch intents are stashed.
|
||||||
@@ -128,8 +129,7 @@ iOS Simulator helpers: `mobile:sim:{boot,install,launch,run,serve,list,kill}` (s
|
|||||||
brings `firebase-messaging`.
|
brings `firebase-messaging`.
|
||||||
- Manifest: permissions `INTERNET`, `CAMERA` (+ optional camera feature), `POST_NOTIFICATIONS`
|
- Manifest: permissions `INTERNET`, `CAMERA` (+ optional camera feature), `POST_NOTIFICATIONS`
|
||||||
(Android 13+; older versions allow notifications by default). `windowSoftInputMode=adjustResize`.
|
(Android 13+; older versions allow notifications by default). `windowSoftInputMode=adjustResize`.
|
||||||
ML Kit `com.google.mlkit.vision.DEPENDENCIES=barcode_ui` meta (preloads the code scanner). FCM
|
FCM `default_notification_icon=@drawable/ic_stat_notify`.
|
||||||
`default_notification_icon=@drawable/ic_stat_notify`.
|
|
||||||
- Adaptive launcher icon: full-bleed color background + `ic_launcher_foreground` (sources under
|
- Adaptive launcher icon: full-bleed color background + `ic_launcher_foreground` (sources under
|
||||||
`packages/mobile/assets/`, regenerable with `@capacitor/assets`).
|
`packages/mobile/assets/`, regenerable with `@capacitor/assets`).
|
||||||
|
|
||||||
|
|||||||
@@ -35,13 +35,6 @@
|
|||||||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
<!-- Tell Play Services to download the ML Kit barcode "code scanner" module (used by
|
|
||||||
@capacitor-mlkit/barcode-scanning's scan()) at install time, so the QR pairing scan
|
|
||||||
works without a per-scan module download. -->
|
|
||||||
<meta-data
|
|
||||||
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
|
||||||
android:value="barcode_ui" />
|
|
||||||
|
|
||||||
<!-- Small icon shown in the status bar / notification shade for FCM notifications the
|
<!-- Small icon shown in the status bar / notification shade for FCM notifications the
|
||||||
system displays while the app is backgrounded. Must be a monochrome silhouette. -->
|
system displays while the app is backgrounded. Must be a monochrome silhouette. -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { connectionDisplayUrl, useMobileConnection } from './mobileConnections';
|
import { connectionDisplayUrl, useMobileConnection } from './mobileConnections';
|
||||||
import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
|
import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
|
||||||
import { mobileConnectionInputClass, mobileInputKeyboardProps } from './mobileConnectionUi';
|
import { mobileConnectionInputClass, mobileInputKeyboardProps } from './mobileConnectionUi';
|
||||||
|
import { MobileQrConnectionLoading, MobileQrScannerOverlay } from './MobileQrScannerOverlay';
|
||||||
|
|
||||||
export type MobileConnectionNotice = {
|
export type MobileConnectionNotice = {
|
||||||
kind: 'unreachable' | 'auth-expired';
|
kind: 'unreachable' | 'auth-expired';
|
||||||
@@ -27,6 +28,8 @@ export const MobileConnectionWelcome: React.FC<{
|
|||||||
const [connectionName, setConnectionName] = React.useState('');
|
const [connectionName, setConnectionName] = React.useState('');
|
||||||
const [clientToken, setClientToken] = React.useState('');
|
const [clientToken, setClientToken] = React.useState('');
|
||||||
const [isScanning, setIsScanning] = React.useState(false);
|
const [isScanning, setIsScanning] = React.useState(false);
|
||||||
|
const [isCompletingScan, setIsCompletingScan] = React.useState(false);
|
||||||
|
const scanAbortRef = React.useRef<AbortController | null>(null);
|
||||||
const qrScanSupported = React.useMemo(() => isQrScanSupported(), []);
|
const qrScanSupported = React.useMemo(() => isQrScanSupported(), []);
|
||||||
// QR pairing is the primary flow; the manual URL form stays collapsed unless
|
// QR pairing is the primary flow; the manual URL form stays collapsed unless
|
||||||
// scanning is unavailable (web build) or the user asks for it.
|
// scanning is unavailable (web build) or the user asks for it.
|
||||||
@@ -60,19 +63,27 @@ export const MobileConnectionWelcome: React.FC<{
|
|||||||
}, [conn]);
|
}, [conn]);
|
||||||
|
|
||||||
const handleScanQr = React.useCallback(async () => {
|
const handleScanQr = React.useCallback(async () => {
|
||||||
if (isScanning || isBusy) return;
|
if (scanAbortRef.current || isBusy) return;
|
||||||
conn.setError(null);
|
conn.setError(null);
|
||||||
setIsScanning(true);
|
setIsScanning(true);
|
||||||
|
const controller = new AbortController();
|
||||||
|
scanAbortRef.current = controller;
|
||||||
try {
|
try {
|
||||||
const result = await scanConnectionQr();
|
const result = await scanConnectionQr({ signal: controller.signal });
|
||||||
|
if (scanAbortRef.current === controller) {
|
||||||
|
scanAbortRef.current = null;
|
||||||
|
setIsScanning(false);
|
||||||
|
}
|
||||||
switch (result.status) {
|
switch (result.status) {
|
||||||
case 'ok':
|
case 'ok':
|
||||||
|
setIsCompletingScan(true);
|
||||||
setServerUrl(result.url);
|
setServerUrl(result.url);
|
||||||
if (result.label) setConnectionName(result.label);
|
if (result.label) setConnectionName(result.label);
|
||||||
if (result.clientToken) setClientToken(result.clientToken);
|
if (result.clientToken) setClientToken(result.clientToken);
|
||||||
await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label });
|
await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label });
|
||||||
break;
|
break;
|
||||||
case 'pairing':
|
case 'pairing':
|
||||||
|
setIsCompletingScan(true);
|
||||||
await conn.redeemPairingConnection(result.pairing);
|
await conn.redeemPairingConnection(result.pairing);
|
||||||
break;
|
break;
|
||||||
case 'permission-denied':
|
case 'permission-denied':
|
||||||
@@ -92,9 +103,15 @@ export const MobileConnectionWelcome: React.FC<{
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsScanning(false);
|
setIsCompletingScan(false);
|
||||||
|
if (scanAbortRef.current === controller) {
|
||||||
|
scanAbortRef.current = null;
|
||||||
|
setIsScanning(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [conn, isBusy, isScanning, t]);
|
}, [conn, isBusy, t]);
|
||||||
|
|
||||||
|
React.useEffect(() => () => scanAbortRef.current?.abort(), []);
|
||||||
|
|
||||||
const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => {
|
const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -107,6 +124,9 @@ export const MobileConnectionWelcome: React.FC<{
|
|||||||
}, [conn]);
|
}, [conn]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{isScanning ? <MobileQrScannerOverlay onCancel={() => scanAbortRef.current?.abort()} /> : null}
|
||||||
|
{isCompletingScan ? <MobileQrConnectionLoading /> : 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">
|
<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="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">
|
<div className="flex flex-col items-center gap-5 text-center">
|
||||||
@@ -297,5 +317,6 @@ export const MobileConnectionWelcome: React.FC<{
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { connectionDisplayUrl, isActiveRuntimeConnection, useMobileConnection } from './mobileConnections';
|
import { connectionDisplayUrl, isActiveRuntimeConnection, useMobileConnection } from './mobileConnections';
|
||||||
import { isQrScanSupported, scanConnectionQr } from './mobileQrScan';
|
import { isQrScanSupported, scanConnectionQr } from './mobileQrScan';
|
||||||
import { mobileConnectionInputClass, mobileInputKeyboardProps } from './mobileConnectionUi';
|
import { mobileConnectionInputClass, mobileInputKeyboardProps } from './mobileConnectionUi';
|
||||||
|
import { MobileQrConnectionLoading, MobileQrScannerOverlay } from './MobileQrScannerOverlay';
|
||||||
|
|
||||||
export const MobileInstancesSurface: React.FC<{
|
export const MobileInstancesSurface: React.FC<{
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
@@ -28,6 +29,8 @@ export const MobileInstancesSurface: React.FC<{
|
|||||||
const [clientToken, setClientToken] = React.useState('');
|
const [clientToken, setClientToken] = React.useState('');
|
||||||
const [password, setPassword] = React.useState('');
|
const [password, setPassword] = React.useState('');
|
||||||
const [isScanning, setIsScanning] = React.useState(false);
|
const [isScanning, setIsScanning] = React.useState(false);
|
||||||
|
const [isCompletingScan, setIsCompletingScan] = React.useState(false);
|
||||||
|
const scanAbortRef = React.useRef<AbortController | null>(null);
|
||||||
const qrScanSupported = React.useMemo(() => isQrScanSupported(), []);
|
const qrScanSupported = React.useMemo(() => isQrScanSupported(), []);
|
||||||
// The manual add/edit form is hidden until asked for — the sheet leads with
|
// The manual add/edit form is hidden until asked for — the sheet leads with
|
||||||
// the list of instances (with live status), not a wall of inputs.
|
// the list of instances (with live status), not a wall of inputs.
|
||||||
@@ -61,11 +64,17 @@ export const MobileInstancesSurface: React.FC<{
|
|||||||
// Scan a pairing QR into the add/edit form fields (does not change edit mode, so
|
// Scan a pairing QR into the add/edit form fields (does not change edit mode, so
|
||||||
// the form-reset effect doesn't wipe the scanned values). The user reviews + saves.
|
// the form-reset effect doesn't wipe the scanned values). The user reviews + saves.
|
||||||
const handleScanInstance = React.useCallback(async () => {
|
const handleScanInstance = React.useCallback(async () => {
|
||||||
if (isScanning) return;
|
if (scanAbortRef.current) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsScanning(true);
|
setIsScanning(true);
|
||||||
|
const controller = new AbortController();
|
||||||
|
scanAbortRef.current = controller;
|
||||||
try {
|
try {
|
||||||
const result = await scanConnectionQr();
|
const result = await scanConnectionQr({ signal: controller.signal });
|
||||||
|
if (scanAbortRef.current === controller) {
|
||||||
|
scanAbortRef.current = null;
|
||||||
|
setIsScanning(false);
|
||||||
|
}
|
||||||
switch (result.status) {
|
switch (result.status) {
|
||||||
case 'ok':
|
case 'ok':
|
||||||
// Legacy token QR: prefill the manual form for review before saving.
|
// Legacy token QR: prefill the manual form for review before saving.
|
||||||
@@ -75,6 +84,7 @@ export const MobileInstancesSurface: React.FC<{
|
|||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
break;
|
break;
|
||||||
case 'pairing':
|
case 'pairing':
|
||||||
|
setIsCompletingScan(true);
|
||||||
await conn.redeemPairingConnection(result.pairing);
|
await conn.redeemPairingConnection(result.pairing);
|
||||||
break;
|
break;
|
||||||
case 'permission-denied':
|
case 'permission-denied':
|
||||||
@@ -94,9 +104,15 @@ export const MobileInstancesSurface: React.FC<{
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsScanning(false);
|
setIsCompletingScan(false);
|
||||||
|
if (scanAbortRef.current === controller) {
|
||||||
|
scanAbortRef.current = null;
|
||||||
|
setIsScanning(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [conn, isScanning, setError, t]);
|
}, [conn, setError, t]);
|
||||||
|
|
||||||
|
React.useEffect(() => () => scanAbortRef.current?.abort(), []);
|
||||||
|
|
||||||
const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => {
|
const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -170,6 +186,9 @@ export const MobileInstancesSurface: React.FC<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{isScanning ? <MobileQrScannerOverlay onCancel={() => scanAbortRef.current?.abort()} /> : null}
|
||||||
|
{isCompletingScan ? <MobileQrConnectionLoading /> : null}
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -358,5 +377,6 @@ export const MobileInstancesSurface: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { Icon } from '@/components/icon/Icon';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export const MobileQrScannerOverlay: React.FC<{ onCancel: () => void }> = ({ onCancel }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const overlayRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const htmlBackground = {
|
||||||
|
value: document.documentElement.style.getPropertyValue('background-color'),
|
||||||
|
priority: document.documentElement.style.getPropertyPriority('background-color'),
|
||||||
|
};
|
||||||
|
const bodyBackground = {
|
||||||
|
value: document.body.style.getPropertyValue('background-color'),
|
||||||
|
priority: document.body.style.getPropertyPriority('background-color'),
|
||||||
|
};
|
||||||
|
// The app's reduced-transparency theme deliberately uses an !important
|
||||||
|
// background. Use an inline important color while CameraX is behind the
|
||||||
|
// WebView; the CSS minifier collapses `background: transparent` in a way
|
||||||
|
// that does not reset that important background color on Android WebView.
|
||||||
|
document.documentElement.style.setProperty('background-color', 'rgba(0, 0, 0, 0)', 'important');
|
||||||
|
document.body.style.setProperty('background-color', 'rgba(0, 0, 0, 0)', 'important');
|
||||||
|
|
||||||
|
// startScan() places CameraX behind the WebView. OpenChamber has several
|
||||||
|
// independent portal roots, so hiding only #root (or relying on inherited
|
||||||
|
// visibility) can leave a sheet/sidebar painted over the preview. Opacity on
|
||||||
|
// each top-level sibling is composited for its whole subtree and cannot be
|
||||||
|
// overridden by descendants.
|
||||||
|
const hidden = new Map<HTMLElement, { opacity: string; pointerEvents: string }>();
|
||||||
|
const hideBodySibling = (node: Node) => {
|
||||||
|
if (!(node instanceof HTMLElement) || node === overlayRef.current || hidden.has(node)) return;
|
||||||
|
hidden.set(node, { opacity: node.style.opacity, pointerEvents: node.style.pointerEvents });
|
||||||
|
node.style.setProperty('opacity', '0');
|
||||||
|
node.style.setProperty('pointer-events', 'none');
|
||||||
|
};
|
||||||
|
Array.from(document.body.children).forEach(hideBodySibling);
|
||||||
|
const observer = new MutationObserver((records) => {
|
||||||
|
records.forEach((record) => record.addedNodes.forEach(hideBodySibling));
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
hidden.forEach((previous, element) => {
|
||||||
|
element.style.opacity = previous.opacity;
|
||||||
|
element.style.pointerEvents = previous.pointerEvents;
|
||||||
|
});
|
||||||
|
if (htmlBackground.value) {
|
||||||
|
document.documentElement.style.setProperty('background-color', htmlBackground.value, htmlBackground.priority);
|
||||||
|
} else {
|
||||||
|
document.documentElement.style.removeProperty('background-color');
|
||||||
|
}
|
||||||
|
if (bodyBackground.value) {
|
||||||
|
document.body.style.setProperty('background-color', bodyBackground.value, bodyBackground.priority);
|
||||||
|
} else {
|
||||||
|
document.body.style.removeProperty('background-color');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'hidden') onCancel();
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('mobile.connect.scanQr')}
|
||||||
|
className="fixed inset-0 z-[1000] flex flex-col bg-transparent px-6 pb-[calc(var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))+24px)] pt-[calc(var(--safe-area-inset-top,env(safe-area-inset-top,0px))+24px)] text-foreground"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-6">
|
||||||
|
<div className="aspect-square w-full max-w-72 rounded-[28px] border-2 border-foreground/90 shadow-[0_0_0_9999px_color-mix(in_srgb,var(--surface-background)_18%,transparent)]" aria-hidden />
|
||||||
|
<p className="max-w-sm rounded-[16px] border border-border/60 bg-background px-4 py-3 text-center typography-body text-foreground shadow-sm">
|
||||||
|
{t('mobile.connect.welcome.scanHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="lg" className="mx-auto min-h-12 w-full max-w-sm bg-background" onClick={onCancel}>
|
||||||
|
<Icon name="close" className="size-[18px]" />
|
||||||
|
{t('mobile.instances.cancelEdit')}
|
||||||
|
</Button>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MobileQrConnectionLoading: React.FC = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return createPortal(
|
||||||
|
<div role="status" className="fixed inset-0 z-[1000] flex flex-col items-center justify-center gap-5 bg-background px-6 text-foreground">
|
||||||
|
<OpenChamberLogo width={96} height={96} isAnimated />
|
||||||
|
<div className="flex items-center gap-2 typography-ui-label text-muted-foreground">
|
||||||
|
<Icon name="loader-4" className="size-[18px] animate-spin" />
|
||||||
|
<span>{t('mobile.connect.connecting')}</span>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
import { encodePairingConnectionPayload, buildPairingConnectionPayload } from '@/lib/connectionPayload';
|
import { encodePairingConnectionPayload, buildPairingConnectionPayload } from '@/lib/connectionPayload';
|
||||||
|
|
||||||
import { parseConnectionPayload } from './mobileQrScan';
|
import { parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
|
||||||
|
|
||||||
const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const;
|
const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const;
|
||||||
|
|
||||||
@@ -40,3 +40,126 @@ describe('parseConnectionPayload', () => {
|
|||||||
expect(parseConnectionPayload('openchamber://connect?v=1&mode=relay#offer=eyJ2IjoxfQ')).toBeNull();
|
expect(parseConnectionPayload('openchamber://connect?v=1&mode=relay#offer=eyJ2IjoxfQ')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('scanConnectionQr on Android', () => {
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the bundled startScan flow and cleans up after a result', async () => {
|
||||||
|
const listeners = new Map<string, (event: { barcodes?: Array<{ rawValue?: string }> }) => void>();
|
||||||
|
let removeCalls = 0;
|
||||||
|
let stopCalls = 0;
|
||||||
|
let scanCalls = 0;
|
||||||
|
let startOptions: unknown;
|
||||||
|
const remove = () => { removeCalls += 1; };
|
||||||
|
const stopScan = async () => { stopCalls += 1; };
|
||||||
|
const scan = async () => { scanCalls += 1; return { barcodes: [] }; };
|
||||||
|
const startScan = async (options?: unknown) => {
|
||||||
|
startOptions = options;
|
||||||
|
listeners.get('barcodesScanned')?.({ barcodes: [{ rawValue: 'https://oc.example' }] });
|
||||||
|
};
|
||||||
|
const plugin = {
|
||||||
|
requestPermissions: mock(async () => ({ camera: 'granted' })),
|
||||||
|
scan,
|
||||||
|
startScan,
|
||||||
|
stopScan,
|
||||||
|
addListener: mock((event: string, callback: (info: { barcodes?: Array<{ rawValue?: string }> }) => void) => {
|
||||||
|
listeners.set(event, callback);
|
||||||
|
return Promise.resolve({ remove });
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await scanConnectionQr()).toEqual({ status: 'ok', url: 'https://oc.example' });
|
||||||
|
expect(startOptions).toEqual({ formats: ['QR_CODE'] });
|
||||||
|
expect(scanCalls).toBe(0);
|
||||||
|
expect(stopCalls).toBe(1);
|
||||||
|
expect(removeCalls).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stops scanning when the caller aborts', async () => {
|
||||||
|
let stopCalls = 0;
|
||||||
|
const stopScan = async () => { stopCalls += 1; };
|
||||||
|
const plugin = {
|
||||||
|
requestPermissions: mock(async () => ({ camera: 'granted' })),
|
||||||
|
startScan: mock(async () => undefined),
|
||||||
|
stopScan,
|
||||||
|
addListener: mock(async () => ({ remove: mock(() => undefined) })),
|
||||||
|
};
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
|
||||||
|
});
|
||||||
|
const controller = new AbortController();
|
||||||
|
const result = scanConnectionQr({ signal: controller.signal });
|
||||||
|
await Promise.resolve();
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
expect(await result).toEqual({ status: 'cancelled' });
|
||||||
|
expect(stopCalls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('waits for listener setup to finish before cleaning up an aborted scan', async () => {
|
||||||
|
let finishListenerSetup: (() => void) | undefined;
|
||||||
|
let removeCalls = 0;
|
||||||
|
let startCalls = 0;
|
||||||
|
let stopCalls = 0;
|
||||||
|
const listenerSetup = new Promise<void>((resolve) => { finishListenerSetup = resolve; });
|
||||||
|
const remove = () => { removeCalls += 1; };
|
||||||
|
const startScan = async () => { startCalls += 1; };
|
||||||
|
const plugin = {
|
||||||
|
requestPermissions: mock(async () => ({ camera: 'granted' })),
|
||||||
|
startScan,
|
||||||
|
stopScan: async () => { stopCalls += 1; },
|
||||||
|
addListener: mock(async () => {
|
||||||
|
await listenerSetup;
|
||||||
|
return { remove };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
|
||||||
|
});
|
||||||
|
const controller = new AbortController();
|
||||||
|
const result = scanConnectionQr({ signal: controller.signal });
|
||||||
|
await Promise.resolve();
|
||||||
|
controller.abort();
|
||||||
|
finishListenerSetup?.();
|
||||||
|
|
||||||
|
expect(await result).toEqual({ status: 'cancelled' });
|
||||||
|
expect(startCalls).toBe(0);
|
||||||
|
expect(removeCalls).toBe(2);
|
||||||
|
expect(stopCalls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cleans up successful listener registration when the other listener fails', async () => {
|
||||||
|
let removeCalls = 0;
|
||||||
|
let startCalls = 0;
|
||||||
|
let stopCalls = 0;
|
||||||
|
const remove = () => { removeCalls += 1; };
|
||||||
|
const plugin = {
|
||||||
|
requestPermissions: mock(async () => ({ camera: 'granted' })),
|
||||||
|
startScan: async () => { startCalls += 1; },
|
||||||
|
stopScan: async () => { stopCalls += 1; },
|
||||||
|
addListener: mock(async (event: string) => {
|
||||||
|
if (event === 'scanError') throw new Error('listener setup failed');
|
||||||
|
return { remove };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await scanConnectionQr()).toEqual({ status: 'failed' });
|
||||||
|
expect(startCalls).toBe(0);
|
||||||
|
expect(removeCalls).toBe(1);
|
||||||
|
expect(stopCalls).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
// Connection payload parsing + native QR scanning for the dedicated mobile app.
|
// Connection payload parsing + native QR scanning for the dedicated mobile app.
|
||||||
//
|
//
|
||||||
// Pairing v2 links (openchamber://connect?v=2&p=<base64url>) carry a one-time
|
// Android uses the plugin's CameraX-backed startScan() flow. Unlike its ready-made
|
||||||
// secret and a list of transport candidates (lan / tunnel / relay); they are
|
// scan() activity, this path bundles the barcode model in the app and does not need
|
||||||
// redeemed server-side over whichever candidate connects first. We also accept a
|
// Google Play Services. iOS keeps the native ready-made scanner.
|
||||||
// bare http(s) URL so a QR encoding only the server address works.
|
|
||||||
//
|
|
||||||
// QR scanning is delegated to a Capacitor barcode-scanner plugin if the native
|
|
||||||
// shell registered one (`window.Capacitor.Plugins.BarcodeScanner`). We resolve it
|
|
||||||
// at runtime instead of importing the package so the web build stays dependency-free
|
|
||||||
// and the browser-hosted mobile UI degrades to `unsupported` cleanly.
|
|
||||||
|
|
||||||
import { parsePairingConnectionPayload, type PairingConnectionPayload } from '@/lib/connectionPayload';
|
import { parsePairingConnectionPayload, type PairingConnectionPayload } from '@/lib/connectionPayload';
|
||||||
|
|
||||||
@@ -32,74 +26,16 @@ export type QrScanResult =
|
|||||||
| { status: 'failed' };
|
| { status: 'failed' };
|
||||||
|
|
||||||
type ScannedBarcode = { rawValue?: string; displayValue?: string };
|
type ScannedBarcode = { rawValue?: string; displayValue?: string };
|
||||||
|
type ListenerHandle = { remove: () => void | Promise<void> };
|
||||||
type ModuleInstallProgress = { state?: number };
|
|
||||||
type ListenerHandle = { remove: () => void };
|
|
||||||
|
|
||||||
type BarcodeScannerPlugin = {
|
type BarcodeScannerPlugin = {
|
||||||
requestPermissions?: () => Promise<{ camera?: string } | undefined>;
|
requestPermissions?: () => Promise<{ camera?: string } | undefined>;
|
||||||
scan?: (options?: { formats?: string[] }) => Promise<{ barcodes?: ScannedBarcode[] } | undefined>;
|
scan?: (options?: { formats?: string[] }) => Promise<{ barcodes?: ScannedBarcode[] } | undefined>;
|
||||||
// Android-only: the Google code scanner used by scan() needs the ML Kit barcode module,
|
startScan?: (options?: { formats?: string[] }) => Promise<void>;
|
||||||
// which Play Services must download once before the first scan. Absent on iOS.
|
stopScan?: () => Promise<void>;
|
||||||
isGoogleBarcodeScannerModuleAvailable?: () => Promise<{ available?: boolean } | undefined>;
|
|
||||||
installGoogleBarcodeScannerModule?: () => Promise<void>;
|
|
||||||
addListener?: (
|
addListener?: (
|
||||||
event: 'googleBarcodeScannerModuleInstallProgress',
|
event: 'barcodesScanned' | 'scanError',
|
||||||
cb: (info: ModuleInstallProgress) => void,
|
cb: (info: { barcodes?: ScannedBarcode[]; message?: string }) => void,
|
||||||
) => Promise<ListenerHandle>;
|
) => Promise<ListenerHandle> | ListenerHandle;
|
||||||
};
|
|
||||||
|
|
||||||
// Google's ModuleInstallProgress states: 4 = COMPLETED, 3 = CANCELED, 5 = FAILED.
|
|
||||||
const MODULE_STATE_COMPLETED = 4;
|
|
||||||
const MODULE_STATE_CANCELED = 3;
|
|
||||||
const MODULE_STATE_FAILED = 5;
|
|
||||||
const MODULE_INSTALL_TIMEOUT_MS = 90_000;
|
|
||||||
|
|
||||||
// Ensure the Android Google barcode module is downloaded before scanning. No-op on platforms
|
|
||||||
// where these methods don't exist (iOS) or when it's already available. Resolves once the module
|
|
||||||
// is usable; rejects if the install is canceled, fails, or times out.
|
|
||||||
const ensureScannerModule = async (plugin: BarcodeScannerPlugin): Promise<void> => {
|
|
||||||
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
|
|
||||||
if (
|
|
||||||
capacitor?.getPlatform?.() !== 'android' ||
|
|
||||||
!plugin.isGoogleBarcodeScannerModuleAvailable ||
|
|
||||||
!plugin.installGoogleBarcodeScannerModule
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const status = await plugin.isGoogleBarcodeScannerModuleAvailable().catch(() => undefined);
|
|
||||||
if (status?.available) return;
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
let handle: ListenerHandle | undefined;
|
|
||||||
const finish = (fn: () => void) => {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
handle?.remove();
|
|
||||||
fn();
|
|
||||||
};
|
|
||||||
const timer = window.setTimeout(
|
|
||||||
() => finish(() => reject(new Error('module install timed out'))),
|
|
||||||
MODULE_INSTALL_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
// addListener may return a handle synchronously OR a Promise<handle> depending on the
|
|
||||||
// Capacitor proxy — normalize with Promise.resolve so a non-thenable handle doesn't throw
|
|
||||||
// and abort the install call below.
|
|
||||||
Promise.resolve(
|
|
||||||
plugin.addListener?.('googleBarcodeScannerModuleInstallProgress', (info) => {
|
|
||||||
if (info?.state === MODULE_STATE_COMPLETED) finish(resolve);
|
|
||||||
else if (info?.state === MODULE_STATE_CANCELED || info?.state === MODULE_STATE_FAILED) {
|
|
||||||
finish(() => reject(new Error('module install failed')));
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.then((h) => {
|
|
||||||
handle = h as ListenerHandle | undefined;
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
Promise.resolve(plugin.installGoogleBarcodeScannerModule?.()).catch((error) =>
|
|
||||||
finish(() => reject(error instanceof Error ? error : new Error('module install failed'))),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getScannerPlugin = (): BarcodeScannerPlugin | null => {
|
const getScannerPlugin = (): BarcodeScannerPlugin | null => {
|
||||||
@@ -108,7 +44,12 @@ const getScannerPlugin = (): BarcodeScannerPlugin | null => {
|
|||||||
Capacitor?: { Plugins?: Record<string, unknown> };
|
Capacitor?: { Plugins?: Record<string, unknown> };
|
||||||
}).Capacitor;
|
}).Capacitor;
|
||||||
const plugin = capacitor?.Plugins?.BarcodeScanner as BarcodeScannerPlugin | undefined;
|
const plugin = capacitor?.Plugins?.BarcodeScanner as BarcodeScannerPlugin | undefined;
|
||||||
return plugin && typeof plugin.scan === 'function' ? plugin : null;
|
return plugin && (typeof plugin.scan === 'function' || typeof plugin.startScan === 'function') ? plugin : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAndroid = (): boolean => {
|
||||||
|
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
|
||||||
|
return capacitor?.getPlatform?.() === 'android';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const parseConnectionPayload = (raw: string): MobileConnectionPayload | MobilePairingPayload | null => {
|
export const parseConnectionPayload = (raw: string): MobileConnectionPayload | MobilePairingPayload | null => {
|
||||||
@@ -124,54 +65,85 @@ export const parseConnectionPayload = (raw: string): MobileConnectionPayload | M
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The Google code scanner can briefly still throw "module not available" in the moments right
|
const resultFromRawValue = (raw: string): QrScanResult => {
|
||||||
// after its install completes. Detect that specific error so we can re-ensure + retry rather
|
const payload = parseConnectionPayload(raw);
|
||||||
// than surfacing a failure the user would have to manually tap through.
|
if (!payload) return { status: 'invalid' };
|
||||||
const isModuleUnavailableError = (error: unknown): boolean => {
|
if ('pairing' in payload) return { status: 'pairing', ...payload };
|
||||||
const message =
|
return { status: 'ok', ...payload };
|
||||||
typeof error === 'object' && error && 'message' in error
|
};
|
||||||
? String((error as { message?: unknown }).message ?? '')
|
|
||||||
: String(error ?? '');
|
const scanWithBundledAndroidScanner = async (
|
||||||
return /module/i.test(message) && /not\s*available|unavailable/i.test(message);
|
plugin: BarcodeScannerPlugin,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<QrScanResult> => {
|
||||||
|
if (!plugin.startScan || !plugin.stopScan || !plugin.addListener) return { status: 'unsupported' };
|
||||||
|
if (signal?.aborted) return { status: 'cancelled' };
|
||||||
|
|
||||||
|
let barcodeListener: ListenerHandle | undefined;
|
||||||
|
let errorListener: ListenerHandle | undefined;
|
||||||
|
let settled = false;
|
||||||
|
let resolveResult: (result: QrScanResult) => void = () => undefined;
|
||||||
|
|
||||||
|
const result = new Promise<QrScanResult>((resolve) => {
|
||||||
|
resolveResult = resolve;
|
||||||
|
});
|
||||||
|
const finish = (scanResult: QrScanResult) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
resolveResult(scanResult);
|
||||||
|
};
|
||||||
|
const abort = () => finish({ status: 'cancelled' });
|
||||||
|
signal?.addEventListener('abort', abort, { once: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const listenerResults = await Promise.allSettled([
|
||||||
|
Promise.resolve(plugin.addListener('barcodesScanned', ({ barcodes }) => {
|
||||||
|
const barcode = barcodes?.[0];
|
||||||
|
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
|
||||||
|
if (raw) finish(resultFromRawValue(raw));
|
||||||
|
})).then((handle) => { barcodeListener = handle; }),
|
||||||
|
Promise.resolve(plugin.addListener('scanError', () => finish({ status: 'failed' })))
|
||||||
|
.then((handle) => { errorListener = handle; }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (listenerResults.some(({ status }) => status === 'rejected')) {
|
||||||
|
finish({ status: 'failed' });
|
||||||
|
} else if (!settled) {
|
||||||
|
void plugin.startScan({ formats: ['QR_CODE'] }).catch(() => finish({ status: 'failed' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await result;
|
||||||
|
} finally {
|
||||||
|
signal?.removeEventListener('abort', abort);
|
||||||
|
await Promise.allSettled([
|
||||||
|
Promise.resolve(barcodeListener?.remove()),
|
||||||
|
Promise.resolve(errorListener?.remove()),
|
||||||
|
plugin.stopScan(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isQrScanSupported = (): boolean => getScannerPlugin() !== null;
|
export const isQrScanSupported = (): boolean => getScannerPlugin() !== null;
|
||||||
|
|
||||||
export const scanConnectionQr = async (): Promise<QrScanResult> => {
|
export const scanConnectionQr = async (options?: { signal?: AbortSignal }): Promise<QrScanResult> => {
|
||||||
const plugin = getScannerPlugin();
|
const plugin = getScannerPlugin();
|
||||||
if (!plugin?.scan) return { status: 'unsupported' };
|
if (!plugin) return { status: 'unsupported' };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (plugin.requestPermissions) {
|
if (plugin.requestPermissions) {
|
||||||
const permission = await plugin.requestPermissions();
|
const permission = await plugin.requestPermissions();
|
||||||
const camera = permission?.camera;
|
const camera = permission?.camera;
|
||||||
if (camera && camera !== 'granted' && camera !== 'limited') {
|
if (camera && camera !== 'granted' && camera !== 'limited') return { status: 'permission-denied' };
|
||||||
return { status: 'permission-denied' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// First scan on Android downloads the Google barcode module (the button stays in its
|
if (options?.signal?.aborted) return { status: 'cancelled' };
|
||||||
// scanning state for the whole wait). The module can still report "not available" for a
|
if (isAndroid()) return scanWithBundledAndroidScanner(plugin, options?.signal);
|
||||||
// moment right after install, so re-ensure + retry within this same call instead of erroring
|
if (!plugin.scan) return { status: 'unsupported' };
|
||||||
// out — the user shouldn't have to guess to tap again.
|
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
|
||||||
try {
|
|
||||||
await ensureScannerModule(plugin);
|
|
||||||
const result = await plugin.scan({ formats: ['QR_CODE'] });
|
|
||||||
const barcode = result?.barcodes?.[0];
|
|
||||||
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
|
|
||||||
if (!raw) return { status: 'cancelled' };
|
|
||||||
|
|
||||||
const payload = parseConnectionPayload(raw);
|
const result = await plugin.scan({ formats: ['QR_CODE'] });
|
||||||
if (!payload) return { status: 'invalid' };
|
const barcode = result?.barcodes?.[0];
|
||||||
if ('pairing' in payload) return { status: 'pairing', ...payload };
|
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
|
||||||
return { status: 'ok', ...payload };
|
return raw ? resultFromRawValue(raw) : { status: 'cancelled' };
|
||||||
} catch (error) {
|
|
||||||
if (!isModuleUnavailableError(error) || attempt === 2) return { status: 'failed' };
|
|
||||||
await new Promise((resolve) => window.setTimeout(resolve, 600));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { status: 'failed' };
|
|
||||||
} catch {
|
} catch {
|
||||||
return { status: 'failed' };
|
return { status: 'failed' };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user