mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(mobile): render pairing QR at scanner-safe scale (#15058)
This commit is contained in:
@@ -15,6 +15,7 @@ vi.mock('electron', () => ({
|
||||
|
||||
vi.mock('qrcode', () => ({
|
||||
default: {
|
||||
create: vi.fn().mockReturnValue({ modules: { size: 21 } }),
|
||||
toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,qr')
|
||||
}
|
||||
}))
|
||||
@@ -344,6 +345,7 @@ describe('registerMobileHandlers', () => {
|
||||
|
||||
await expect(handlers.get('mobile:getPairingQR')?.(null, {})).resolves.toMatchObject({
|
||||
available: true,
|
||||
qrSize: 58,
|
||||
pairingUrl: 'orca://pair#mobile',
|
||||
endpoint: 'ws://100.102.47.57:6768',
|
||||
deviceId: 'mobile-1',
|
||||
@@ -422,6 +424,7 @@ describe('registerMobileHandlers', () => {
|
||||
).resolves.toEqual({
|
||||
available: true,
|
||||
qrDataUrl: null,
|
||||
qrSize: null,
|
||||
qrError: 'encoding_failed',
|
||||
pairingUrl: 'orca://pair?code=copy-me',
|
||||
endpoint: 'wss://pair.example/oversized',
|
||||
|
||||
@@ -132,6 +132,7 @@ export function registerMobileHandlers(
|
||||
return {
|
||||
available: true as const,
|
||||
qrDataUrl: qr.ok ? qr.qrDataUrl : null,
|
||||
qrSize: qr.ok ? qr.qrSize : null,
|
||||
...(!qr.ok ? { qrError: qr.reason } : {}),
|
||||
pairingUrl: offer.pairingUrl,
|
||||
// Why: with nothing advertised the offer's endpoint is the loopback fallback, which points at
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PNG } from 'pngjs'
|
||||
import { encodePairingOffer } from '../../shared/pairing'
|
||||
import type { PairingOffer } from '../../shared/mobile-relay-pairing-offer'
|
||||
import { encodeMobilePairingQr } from './mobile-pairing-qr'
|
||||
@@ -49,6 +50,30 @@ async function discoverEndpointBoundary(relay: boolean): Promise<number> {
|
||||
}
|
||||
|
||||
describe('encodeMobilePairingQr', () => {
|
||||
it('renders the current Relay-sized symbol at two pixels per module with a four-module quiet zone', async () => {
|
||||
const { default: QRCode } = await import('qrcode')
|
||||
const url = pairingUrl(100, true)
|
||||
const moduleCount = QRCode.create(url, { errorCorrectionLevel: 'M' }).modules.size
|
||||
expect(moduleCount).toBe(101)
|
||||
|
||||
const result = await encodeMobilePairingQr(url)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const image = PNG.sync.read(Buffer.from(result.qrDataUrl.split(',')[1]!, 'base64'))
|
||||
expect(result.qrSize).toBe(218)
|
||||
expect(image.width).toBe(result.qrSize)
|
||||
expect(image.height).toBe(result.qrSize)
|
||||
expect(image.data.subarray((8 * image.width + 7) * 4, (8 * image.width + 8) * 4)).toEqual(
|
||||
Buffer.from([255, 255, 255, 255])
|
||||
)
|
||||
expect(image.data.subarray((8 * image.width + 8) * 4, (8 * image.width + 9) * 4)).toEqual(
|
||||
Buffer.from([0, 0, 0, 255])
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['direct', false],
|
||||
['relay', true]
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
export type MobilePairingQrResult =
|
||||
| { ok: true; qrDataUrl: string }
|
||||
| { ok: true; qrDataUrl: string; qrSize: number }
|
||||
| { ok: false; reason: 'encoding_failed' }
|
||||
|
||||
const QUIET_ZONE_MODULES = 4
|
||||
const MODULE_PITCH_PX = 2
|
||||
|
||||
export async function encodeMobilePairingQr(pairingUrl: string): Promise<MobilePairingQrResult> {
|
||||
try {
|
||||
const { default: QRCode } = await import('qrcode')
|
||||
const qrCode = QRCode.create(pairingUrl, { errorCorrectionLevel: 'M' })
|
||||
const qrSize = (qrCode.modules.size + QUIET_ZONE_MODULES * 2) * MODULE_PITCH_PX
|
||||
const qrDataUrl = await QRCode.toDataURL(pairingUrl, {
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 2,
|
||||
width: 256
|
||||
margin: QUIET_ZONE_MODULES,
|
||||
scale: MODULE_PITCH_PX
|
||||
})
|
||||
return { ok: true, qrDataUrl }
|
||||
return { ok: true, qrDataUrl, qrSize }
|
||||
} catch {
|
||||
return { ok: false, reason: 'encoding_failed' }
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ export type MobileApi = {
|
||||
| {
|
||||
available: true
|
||||
qrDataUrl: string | null
|
||||
/** Natural bitmap width and height in pixels. */
|
||||
qrSize: number | null
|
||||
qrError?: 'encoding_failed'
|
||||
pairingUrl: string
|
||||
/** Null when no direct address was advertised — the QR pairs over Relay alone. */
|
||||
|
||||
@@ -4884,6 +4884,8 @@ const api = {
|
||||
| {
|
||||
available: true
|
||||
qrDataUrl: string | null
|
||||
/** Natural bitmap width and height in pixels. */
|
||||
qrSize: number | null
|
||||
qrError?: 'encoding_failed'
|
||||
pairingUrl: string
|
||||
/** Null when no direct address was advertised — the QR pairs over Relay alone. */
|
||||
|
||||
@@ -4,8 +4,11 @@ import { describe, expect, it } from 'vitest'
|
||||
const mobilePageCss = fs.readFileSync(new URL('./mobile-page.css', import.meta.url), 'utf8')
|
||||
|
||||
describe('mobile page QR grid layout (#9700)', () => {
|
||||
it('defines a shared large-QR size token used by the box and grid tracks', () => {
|
||||
it('defines separate install and adaptive pairing QR tracks', () => {
|
||||
expect(mobilePageCss).toMatch(/--mp-qr-large-size:\s*184px/)
|
||||
expect(mobilePageCss).toMatch(
|
||||
/--mp-pairing-qr-frame-size:\s*calc\(var\(--mp-pairing-qr-image-size\) \+ 20px\)/
|
||||
)
|
||||
expect(mobilePageCss).toMatch(
|
||||
/\.mobile-page-root \.mp-qr-large\s*{[^}]*width:\s*var\(--mp-qr-large-size\)/s
|
||||
)
|
||||
@@ -18,7 +21,7 @@ describe('mobile page QR grid layout (#9700)', () => {
|
||||
/\.mobile-page-root \.mp-step2-layout\s*{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+var\(--mp-qr-large-size\)/s
|
||||
)
|
||||
expect(mobilePageCss).toMatch(
|
||||
/\.mobile-page-root \.mp-pairing-layout\s*{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+var\(--mp-qr-large-size\)/s
|
||||
/\.mobile-page-root \.mp-pairing-layout\s*{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+var\(--mp-pairing-qr-frame-size\)/s
|
||||
)
|
||||
expect(mobilePageCss).not.toMatch(
|
||||
/\.mobile-page-root \.mp-(?:step2|pairing)-layout\s*{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+auto/s
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
--mp-flow-card-inset: calc(var(--mp-flow-card-padding) + var(--mp-flow-card-border-width));
|
||||
--mp-flow-shell-height: 400px;
|
||||
--mp-qr-large-size: 184px;
|
||||
--mp-pairing-qr-image-size: 164px;
|
||||
--mp-pairing-qr-frame-size: calc(var(--mp-pairing-qr-image-size) + 20px);
|
||||
--m-bg-base: #111111;
|
||||
--m-bg-panel: #1a1a1a;
|
||||
--m-bg-raised: #242424;
|
||||
@@ -596,6 +598,17 @@
|
||||
height: calc(var(--mp-qr-large-size) - 20px);
|
||||
}
|
||||
|
||||
.mobile-page-root .mp-pairing-qr .mp-qr-large {
|
||||
width: var(--mp-pairing-qr-frame-size);
|
||||
height: var(--mp-pairing-qr-frame-size);
|
||||
}
|
||||
|
||||
.mobile-page-root .mp-pairing-qr .mp-qr-large img {
|
||||
width: var(--mp-pairing-qr-image-size);
|
||||
height: var(--mp-pairing-qr-image-size);
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.mobile-page-root .mp-qr-refreshing {
|
||||
filter: blur(5px);
|
||||
opacity: 0.32;
|
||||
@@ -686,7 +699,7 @@
|
||||
|
||||
.mobile-page-root .mp-pairing-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--mp-qr-large-size);
|
||||
grid-template-columns: minmax(0, 1fr) var(--mp-pairing-qr-frame-size);
|
||||
grid-template-areas:
|
||||
'copy qr'
|
||||
'relay qr'
|
||||
|
||||
@@ -218,6 +218,15 @@ describe('HeroFlow height', () => {
|
||||
expect(screen.queryByTestId('relay-mint-failure-notice')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a pairing QR at its natural integer-scaled bitmap size', () => {
|
||||
renderFlow(1, { pairQrDataUrl: 'data:image/png;base64,qr', pairQrSize: 218 })
|
||||
|
||||
const image = screen.getByRole('img', { name: 'Pairing QR' })
|
||||
const layout = image.closest('.mp-pairing-layout') as HTMLElement
|
||||
expect(layout.style.getPropertyValue('--mp-pairing-qr-image-size')).toBe('218px')
|
||||
expect(layout.style.getPropertyValue('--mp-pairing-qr-frame-size')).toBe('238px')
|
||||
})
|
||||
|
||||
it('shows an encoder error while keeping the copy fallback enabled', () => {
|
||||
renderFlow(1, {
|
||||
pairingQrError: true,
|
||||
|
||||
@@ -25,6 +25,7 @@ type HeroFlowProps = {
|
||||
onOpenInstallUrl: () => void
|
||||
onCopyInstallUrl: () => void
|
||||
pairQrDataUrl: string | null
|
||||
pairQrSize?: number | null
|
||||
pairingUrl: string | null
|
||||
pairingQrError: boolean
|
||||
relayMintFailure: MobileRelayMintFailure | null
|
||||
@@ -63,6 +64,7 @@ export function HeroFlow({
|
||||
onOpenInstallUrl,
|
||||
onCopyInstallUrl,
|
||||
pairQrDataUrl,
|
||||
pairQrSize = null,
|
||||
pairingUrl,
|
||||
pairingQrError,
|
||||
relayMintFailure,
|
||||
@@ -222,6 +224,7 @@ export function HeroFlow({
|
||||
>
|
||||
<MobileHeroPairingStep
|
||||
pairQrDataUrl={pairQrDataUrl}
|
||||
pairQrSize={pairQrSize}
|
||||
pairingUrl={pairingUrl}
|
||||
pairingQrError={pairingQrError}
|
||||
relayMintFailure={relayMintFailure}
|
||||
|
||||
@@ -58,6 +58,7 @@ function emptyPairingQrMessage(args: {
|
||||
|
||||
export function MobileHeroPairingStep({
|
||||
pairQrDataUrl,
|
||||
pairQrSize = null,
|
||||
pairingUrl,
|
||||
pairingQrError,
|
||||
relayMintFailure,
|
||||
@@ -82,6 +83,7 @@ export function MobileHeroPairingStep({
|
||||
refreshingNetworkInterfaces
|
||||
}: {
|
||||
pairQrDataUrl: string | null
|
||||
pairQrSize?: number | null
|
||||
pairingUrl: string | null
|
||||
pairingQrError: boolean
|
||||
relayMintFailure: MobileRelayMintFailure | null
|
||||
@@ -105,6 +107,13 @@ export function MobileHeroPairingStep({
|
||||
onRefreshNetworkInterfaces: () => void
|
||||
refreshingNetworkInterfaces: boolean
|
||||
}): React.JSX.Element {
|
||||
const pairingLayoutStyle =
|
||||
pairQrSize == null
|
||||
? undefined
|
||||
: ({
|
||||
'--mp-pairing-qr-image-size': `${pairQrSize}px`,
|
||||
'--mp-pairing-qr-frame-size': `${pairQrSize + 20}px`
|
||||
} as React.CSSProperties)
|
||||
const copyPairingCodeRef = useRef<HTMLButtonElement | null>(null)
|
||||
const pairingWasReadyRef = useRef(pairingUrl != null && !pairLoading)
|
||||
const usingRelay = connectionMode === 'automatic'
|
||||
@@ -169,7 +178,10 @@ export function MobileHeroPairingStep({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('mp-pairing-layout', relayMintFailure != null && 'has-failure')}>
|
||||
<div
|
||||
className={cn('mp-pairing-layout', relayMintFailure != null && 'has-failure')}
|
||||
style={pairingLayoutStyle}
|
||||
>
|
||||
<div className="mp-step2-copy mp-pairing-copy">
|
||||
<div className="mp-eyebrow-row">
|
||||
<div className="mp-step-num">2</div>
|
||||
|
||||
@@ -56,6 +56,7 @@ vi.mock('./MobilePageContent', () => ({
|
||||
beforeCustomAddressChange: (address: string) => Promise<boolean>
|
||||
handleContinue: () => void
|
||||
pairQrDataUrl: string | null
|
||||
pairQrSize: number | null
|
||||
pairingUrl: string | null
|
||||
pairingQrError: boolean
|
||||
relayMintFailure: MobileRelayMintFailure | null
|
||||
@@ -72,6 +73,7 @@ vi.mock('./MobilePageContent', () => ({
|
||||
<span data-testid="mode">{props.connectionMode}</span>
|
||||
<span data-testid="can-generate">{String(props.canGeneratePairing)}</span>
|
||||
<span data-testid="pairing-qr">{props.pairQrDataUrl ?? 'none'}</span>
|
||||
<span data-testid="pairing-qr-size">{props.pairQrSize ?? 'none'}</span>
|
||||
<span data-testid="pairing-url">{props.pairingUrl ?? 'none'}</span>
|
||||
<span data-testid="pairing-qr-error">{String(props.pairingQrError)}</span>
|
||||
<span data-testid="relay-failure">{props.relayMintFailure?.stage ?? 'none'}</span>
|
||||
@@ -132,6 +134,7 @@ describe('MobilePage pairing connection mode', () => {
|
||||
getPairingQR.mockReset().mockResolvedValue({
|
||||
available: true,
|
||||
qrDataUrl: 'data:image/png;base64,qr',
|
||||
qrSize: 218,
|
||||
pairingUrl: 'orca://pair#automatic'
|
||||
})
|
||||
listNetworkInterfaces.mockReset().mockResolvedValue({ interfaces: [] })
|
||||
@@ -171,6 +174,7 @@ describe('MobilePage pairing connection mode', () => {
|
||||
|
||||
await waitFor(() => expect(getPairingQR).toHaveBeenCalledWith({ connectionMode: 'automatic' }))
|
||||
await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,qr'))
|
||||
expect(screen.getByTestId('pairing-qr-size')).toHaveTextContent('218')
|
||||
expect(screen.getByTestId('mode')).toHaveTextContent('automatic')
|
||||
|
||||
let resolveRotatedLocalQr: ((value: Record<string, unknown>) => void) | undefined
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function MobilePage(): React.JSX.Element {
|
||||
const [iosChannel, setIosChannel] = useState<IosChannel>('preview')
|
||||
|
||||
const [pairQrDataUrl, setPairQrDataUrl] = useState<string | null>(null)
|
||||
const [pairQrSize, setPairQrSize] = useState<number | null>(null)
|
||||
const [pairingUrl, setPairingUrl] = useState<string | null>(null)
|
||||
const [pairingQrError, setPairingQrError] = useState(false)
|
||||
const [relayMintFailure, setRelayMintFailure] = useState<MobileRelayMintFailure | null>(null)
|
||||
@@ -85,6 +86,7 @@ export default function MobilePage(): React.JSX.Element {
|
||||
hasGeneratedRef,
|
||||
pairingRequestIdRef,
|
||||
setPairQrDataUrl,
|
||||
setPairQrSize,
|
||||
setPairingUrl,
|
||||
setPairingQrError,
|
||||
setPairLoading,
|
||||
@@ -109,6 +111,7 @@ export default function MobilePage(): React.JSX.Element {
|
||||
pairingRequestIdRef.current += 1
|
||||
hasGeneratedRef.current = false
|
||||
setPairQrDataUrl(null)
|
||||
setPairQrSize(null)
|
||||
setPairingUrl(null)
|
||||
setPairingQrError(false)
|
||||
setRelayMintFailure(null)
|
||||
@@ -171,6 +174,7 @@ export default function MobilePage(): React.JSX.Element {
|
||||
hasGeneratedRef,
|
||||
pairingRequestIdRef,
|
||||
setPairQrDataUrl,
|
||||
setPairQrSize,
|
||||
setPairingUrl,
|
||||
setPairingQrError,
|
||||
setPairLoading,
|
||||
@@ -262,6 +266,7 @@ export default function MobilePage(): React.JSX.Element {
|
||||
const enterFlow = (): void => {
|
||||
hasGeneratedRef.current = false
|
||||
setPairQrDataUrl(null)
|
||||
setPairQrSize(null)
|
||||
setPairingUrl(null)
|
||||
setPairingQrError(false)
|
||||
setRelayMintFailure(null)
|
||||
@@ -273,6 +278,7 @@ export default function MobilePage(): React.JSX.Element {
|
||||
const pairAnotherDevice = (): void => {
|
||||
hasGeneratedRef.current = false
|
||||
setPairQrDataUrl(null)
|
||||
setPairQrSize(null)
|
||||
setPairingUrl(null)
|
||||
setPairingQrError(false)
|
||||
setRelayMintFailure(null)
|
||||
@@ -328,6 +334,7 @@ export default function MobilePage(): React.JSX.Element {
|
||||
connectionMode={connectionMode}
|
||||
handleConnectionModeChange={handleConnectionModeChange}
|
||||
pairQrDataUrl={pairQrDataUrl}
|
||||
pairQrSize={pairQrSize}
|
||||
pairingUrl={pairingUrl}
|
||||
pairingQrError={pairingQrError}
|
||||
relayMintFailure={
|
||||
|
||||
@@ -42,6 +42,7 @@ type MobilePageContentProps = {
|
||||
connectionMode: MobilePairingConnectionMode
|
||||
handleConnectionModeChange: (mode: MobilePairingConnectionMode) => void
|
||||
pairQrDataUrl: string | null
|
||||
pairQrSize: number | null
|
||||
pairingUrl: string | null
|
||||
pairingQrError: boolean
|
||||
relayMintFailure: MobileRelayMintFailure | null
|
||||
@@ -88,6 +89,7 @@ export function MobilePageContent({
|
||||
connectionMode,
|
||||
handleConnectionModeChange,
|
||||
pairQrDataUrl,
|
||||
pairQrSize,
|
||||
pairingUrl,
|
||||
pairingQrError,
|
||||
relayMintFailure,
|
||||
@@ -136,6 +138,7 @@ export function MobilePageContent({
|
||||
onOpenInstallUrl={openInstallUrl}
|
||||
onCopyInstallUrl={copyInstallUrl}
|
||||
pairQrDataUrl={pairQrDataUrl}
|
||||
pairQrSize={pairQrSize}
|
||||
pairingUrl={pairingUrl}
|
||||
pairingQrError={pairingQrError}
|
||||
relayMintFailure={relayMintFailure}
|
||||
|
||||
@@ -23,6 +23,7 @@ export function useMobilePairingGeneration(params: {
|
||||
hasGeneratedRef: MutableRef<boolean>
|
||||
pairingRequestIdRef: MutableRef<number>
|
||||
setPairQrDataUrl: (value: string | null) => void
|
||||
setPairQrSize: (value: number | null) => void
|
||||
setPairingUrl: (value: string | null) => void
|
||||
setPairingQrError: (value: boolean) => void
|
||||
setPairLoading: (value: boolean) => void
|
||||
@@ -42,6 +43,7 @@ export function useMobilePairingGeneration(params: {
|
||||
hasGeneratedRef,
|
||||
pairingRequestIdRef,
|
||||
setPairQrDataUrl,
|
||||
setPairQrSize,
|
||||
setPairingUrl,
|
||||
setPairingQrError,
|
||||
setPairLoading,
|
||||
@@ -76,6 +78,7 @@ export function useMobilePairingGeneration(params: {
|
||||
if (result.available) {
|
||||
if (mountedRef.current) {
|
||||
setPairQrDataUrl(result.qrDataUrl)
|
||||
setPairQrSize(result.qrSize)
|
||||
setPairingUrl(result.pairingUrl)
|
||||
setPairingQrError(result.qrDataUrl === null)
|
||||
setRelayMintFailure(null)
|
||||
@@ -84,6 +87,7 @@ export function useMobilePairingGeneration(params: {
|
||||
// Why: keep hasGenerated so step-2 auto-mint does not loop on failure.
|
||||
if (mountedRef.current) {
|
||||
setPairQrDataUrl(null)
|
||||
setPairQrSize(null)
|
||||
setPairingUrl(null)
|
||||
setPairingQrError(false)
|
||||
if (result.reason === 'relay_mint_failed' && result.relayFailure) {
|
||||
@@ -106,6 +110,7 @@ export function useMobilePairingGeneration(params: {
|
||||
if (mountedRef.current && requestId === pairingRequestIdRef.current) {
|
||||
hasGeneratedRef.current = false
|
||||
setPairQrDataUrl(null)
|
||||
setPairQrSize(null)
|
||||
setPairingUrl(null)
|
||||
setPairingQrError(false)
|
||||
setRelayMintFailure(null)
|
||||
@@ -130,6 +135,7 @@ export function useMobilePairingGeneration(params: {
|
||||
selectedAddress,
|
||||
setPairLoading,
|
||||
setPairQrDataUrl,
|
||||
setPairQrSize,
|
||||
setPairingUrl,
|
||||
setPairingQrError,
|
||||
setRelayMintFailure,
|
||||
|
||||
@@ -20,6 +20,7 @@ export function useMobilePairingQrInvalidation(params: {
|
||||
hasGeneratedRef: MutableRef<boolean>
|
||||
pairingRequestIdRef: MutableRef<number>
|
||||
setPairQrDataUrl: (value: string | null) => void
|
||||
setPairQrSize: (value: number | null) => void
|
||||
setPairingUrl: (value: string | null) => void
|
||||
setPairingQrError: (value: boolean) => void
|
||||
setPairLoading: (value: boolean) => void
|
||||
@@ -33,6 +34,7 @@ export function useMobilePairingQrInvalidation(params: {
|
||||
hasGeneratedRef,
|
||||
pairingRequestIdRef,
|
||||
setPairQrDataUrl,
|
||||
setPairQrSize,
|
||||
setPairingUrl,
|
||||
setPairingQrError,
|
||||
setPairLoading,
|
||||
@@ -59,6 +61,7 @@ export function useMobilePairingQrInvalidation(params: {
|
||||
setPairingUrl(null)
|
||||
setPairingQrError(false)
|
||||
setPairQrDataUrl(null)
|
||||
setPairQrSize(null)
|
||||
setRelayMintFailure?.(null)
|
||||
if (signedIn && canMintMobilePairingOffer({ connectionMode, signedIn })) {
|
||||
// Why: rotate on the sign-in edge — the token behind the QR cleared at
|
||||
@@ -73,6 +76,7 @@ export function useMobilePairingQrInvalidation(params: {
|
||||
hasGeneratedRef,
|
||||
pairingRequestIdRef,
|
||||
setPairQrDataUrl,
|
||||
setPairQrSize,
|
||||
setPairingUrl,
|
||||
setPairingQrError,
|
||||
setPairLoading,
|
||||
@@ -97,6 +101,7 @@ export function useMobilePairingQrInvalidation(params: {
|
||||
setPairingUrl(null)
|
||||
setPairingQrError(false)
|
||||
setPairQrDataUrl(null)
|
||||
setPairQrSize(null)
|
||||
setRelayMintFailure?.(null)
|
||||
if (shouldRegenerate && canMintMobilePairingOffer({ connectionMode, signedIn })) {
|
||||
// Why: no rotate here — the main process rotates exactly once when the
|
||||
@@ -115,6 +120,7 @@ export function useMobilePairingQrInvalidation(params: {
|
||||
hasGeneratedRef,
|
||||
pairingRequestIdRef,
|
||||
setPairQrDataUrl,
|
||||
setPairQrSize,
|
||||
setPairingUrl,
|
||||
setPairingQrError,
|
||||
setPairLoading,
|
||||
|
||||
@@ -98,4 +98,28 @@ describe('MobilePairingQrSection', () => {
|
||||
|
||||
expect(persistentAction).toHaveFocus()
|
||||
})
|
||||
|
||||
it('keeps the QR on integer CSS scaling at normal and enlarged sizes', () => {
|
||||
render(
|
||||
<MobilePairingQrSection
|
||||
qrDataUrl="data:image/png;base64,qr"
|
||||
qrSize={218}
|
||||
qrError={false}
|
||||
pairingUrl="orca://pair#ready"
|
||||
endpoint={null}
|
||||
qrEnlarged
|
||||
codeCopied={false}
|
||||
onQrEnlargedChange={vi.fn()}
|
||||
onCodeCopiedChange={vi.fn()}
|
||||
onClearCodeCopiedTimer={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
const images = Array.from(
|
||||
document.querySelectorAll<HTMLImageElement>('img[alt="QR Code for mobile pairing"]')
|
||||
)
|
||||
expect(images.map((image) => image.style.width).sort()).toEqual(['218px', '436px'])
|
||||
expect(images.map((image) => image.style.height).sort()).toEqual(['218px', '436px'])
|
||||
expect(images.every((image) => image.style.imageRendering === 'pixelated')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { translate } from '@/i18n/i18n'
|
||||
|
||||
type MobilePairingQrSectionProps = {
|
||||
qrDataUrl: string | null
|
||||
qrSize?: number | null
|
||||
qrError: boolean
|
||||
pairingUrl: string | null
|
||||
endpoint: string | null
|
||||
@@ -19,6 +20,7 @@ type MobilePairingQrSectionProps = {
|
||||
|
||||
export function MobilePairingQrSection({
|
||||
qrDataUrl,
|
||||
qrSize = null,
|
||||
qrError,
|
||||
pairingUrl,
|
||||
endpoint,
|
||||
@@ -28,6 +30,8 @@ export function MobilePairingQrSection({
|
||||
onCodeCopiedChange,
|
||||
onClearCodeCopiedTimer
|
||||
}: MobilePairingQrSectionProps): React.JSX.Element | null {
|
||||
const renderedQrSize = qrSize ?? 192
|
||||
const enlargedQrSize = qrSize == null ? 288 : qrSize * 2
|
||||
const pairingCodeButtonMountedRef = useRef(false)
|
||||
const pairingCodeButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||
const hadPairingUrlRef = useRef(pairingUrl != null)
|
||||
@@ -102,7 +106,12 @@ export function MobilePairingQrSection({
|
||||
'auto.components.settings.MobilePane.6436e56546',
|
||||
'QR Code for mobile pairing'
|
||||
)}
|
||||
className="size-48"
|
||||
className="block"
|
||||
style={{
|
||||
width: renderedQrSize,
|
||||
height: renderedQrSize,
|
||||
imageRendering: 'pixelated'
|
||||
}}
|
||||
/>
|
||||
<Maximize2 className="absolute top-1.5 right-1.5 size-3 text-black/30 can-hover:opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
@@ -158,7 +167,7 @@ export function MobilePairingQrSection({
|
||||
|
||||
{qrDataUrl ? (
|
||||
<Dialog open={qrEnlarged} onOpenChange={onQrEnlargedChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{translate(
|
||||
@@ -175,7 +184,12 @@ export function MobilePairingQrSection({
|
||||
'auto.components.settings.MobilePane.6436e56546',
|
||||
'QR Code for mobile pairing'
|
||||
)}
|
||||
className="size-72"
|
||||
className="block"
|
||||
style={{
|
||||
width: enlargedQrSize,
|
||||
height: enlargedQrSize,
|
||||
imageRendering: 'pixelated'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{endpoint && (
|
||||
|
||||
@@ -31,6 +31,7 @@ export function MobilePane(): React.JSX.Element {
|
||||
const autoRestoreFitMs = useAppStore((s) => s.settings?.mobileAutoRestoreFitMs ?? null)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
|
||||
const [qrSize, setQrSize] = useState<number | null>(null)
|
||||
const [pairingUrl, setPairingUrl] = useState<string | null>(null)
|
||||
const [qrError, setQrError] = useState(false)
|
||||
const [relayMintFailure, setRelayMintFailure] = useState<MobileRelayMintFailure | null>(null)
|
||||
@@ -81,6 +82,7 @@ export function MobilePane(): React.JSX.Element {
|
||||
pairingRequestIdRef.current += 1
|
||||
const hadPending = qrDisplayedRef.current || loadingRef.current
|
||||
setQrDataUrl(null)
|
||||
setQrSize(null)
|
||||
setPairingUrl(null)
|
||||
setQrError(false)
|
||||
setRelayMintFailure(null)
|
||||
@@ -197,6 +199,7 @@ export function MobilePane(): React.JSX.Element {
|
||||
useAppStore.getState().recordFeatureInteraction('mobile-pairing')
|
||||
if (mountedRef.current) {
|
||||
setQrDataUrl(result.qrDataUrl)
|
||||
setQrSize(result.qrSize)
|
||||
setPairingUrl(result.pairingUrl)
|
||||
setQrError(result.qrDataUrl === null)
|
||||
setRelayMintFailure(null)
|
||||
@@ -209,6 +212,7 @@ export function MobilePane(): React.JSX.Element {
|
||||
}
|
||||
} else if (mountedRef.current) {
|
||||
setQrDataUrl(null)
|
||||
setQrSize(null)
|
||||
setPairingUrl(null)
|
||||
setQrError(false)
|
||||
setEndpoint(null)
|
||||
@@ -432,6 +436,7 @@ export function MobilePane(): React.JSX.Element {
|
||||
|
||||
<MobilePairingQrSection
|
||||
qrDataUrl={qrDataUrl}
|
||||
qrSize={qrSize}
|
||||
qrError={qrError}
|
||||
pairingUrl={pairingUrl}
|
||||
endpoint={endpoint}
|
||||
|
||||
Reference in New Issue
Block a user