diff --git a/@types/frame/state.d.ts b/@types/frame/state.d.ts index 67f18dcb60..7d931cc56f 100644 --- a/@types/frame/state.d.ts +++ b/@types/frame/state.d.ts @@ -26,7 +26,7 @@ interface Frame { views: Record } -type SignerType = 'ring' | 'seed' | 'trezor' | 'ledger' | 'lattice' +type SignerType = 'ring' | 'seed' | 'trezor' | 'ledger' | 'lattice' | 'qr' type AccountStatus = 'ok' interface Signer { diff --git a/app/dash/Accounts/Add/AddHardwareQR/QRScanner.js b/app/dash/Accounts/Add/AddHardwareQR/QRScanner.js new file mode 100644 index 0000000000..25fc648e9d --- /dev/null +++ b/app/dash/Accounts/Add/AddHardwareQR/QRScanner.js @@ -0,0 +1,186 @@ +import { useEffect, useRef, useState } from 'react' +import { BrowserMultiFormatReader } from '@zxing/browser' +import { URDecoder, UREncoder } from '@ngraveio/bc-ur' +import link from '../../../../../resources/link' + +function QRScanner({ + onScan, + onError, + onCancel, + title = 'Scan device sync QR code', + instructions = 'Open your hardware wallet and display the account sync QR code', + externalError = null +}) { + const videoRef = useRef(null) + const [error, setError] = useState(null) + const [progress, setProgress] = useState(0) + const [isAnimated, setIsAnimated] = useState(false) + + const readerRef = useRef(null) + const urDecoderRef = useRef(null) + const lastCompletedScanRef = useRef({ payload: '', timestamp: 0 }) + + useEffect(() => { + let mounted = true + let controls = null + + // Reset duplicate detection on mount to prevent stale state from previous scans + lastCompletedScanRef.current = { payload: '', timestamp: 0 } + + const startScanning = async () => { + try { + // Request camera permission first (required on macOS) + const permissionResult = await new Promise((resolve) => { + link.rpc('requestCameraAccess', (err, result) => { + if (err) { + resolve({ granted: false, error: err }) + } else { + resolve(result) + } + }) + }) + + if (!permissionResult.granted) { + throw new Error( + 'Camera access denied. Please enable camera access in System Preferences > Privacy & Security > Camera.' + ) + } + + // Initialize the ZXing reader + readerRef.current = new BrowserMultiFormatReader() + + // Initialize UR decoder for animated QR codes + urDecoderRef.current = new URDecoder() + + // Get available cameras + const devices = await BrowserMultiFormatReader.listVideoInputDevices() + + if (devices.length === 0) { + throw new Error('No camera found') + } + + // Start scanning with the first camera + controls = await readerRef.current.decodeFromVideoDevice( + devices[0].deviceId, + videoRef.current, + (result, _err) => { + if (!mounted) return + + if (result) { + handleQRResult(result.getText()) + } + } + ) + } catch (err) { + if (mounted) { + setError(err.message || 'Failed to access camera') + onError(err.message || 'Failed to access camera') + } + } + } + + const handleQRResult = (text) => { + const now = Date.now() + const lastScan = lastCompletedScanRef.current + const isDuplicateScan = lastScan.payload === text && now - lastScan.timestamp < 1500 + if (isDuplicateScan) return + + // Check if this is a UR-encoded QR code + if (text.toLowerCase().startsWith('ur:')) { + handleURPart(text) + } else { + // Non-UR data, pass through directly + lastCompletedScanRef.current = { payload: text, timestamp: now } + onScan(text) + stopScanning() + } + } + + const handleURPart = (urPart) => { + try { + urDecoderRef.current.receivePart(urPart) + + const progressValue = urDecoderRef.current.getProgress() + setProgress(Math.round(progressValue * 100)) + + // Check if this is an animated QR + if (urDecoderRef.current.expectedPartCount() > 1) { + setIsAnimated(true) + } + + if (urDecoderRef.current.isComplete()) { + const ur = urDecoderRef.current.resultUR() + // Re-encode the complete UR to a single-part string (handles multi-frame QRs) + const encoder = new UREncoder(ur, Infinity) + const completeURString = encoder.nextPart() + + const now = Date.now() + const lastScan = lastCompletedScanRef.current + const isDuplicateScan = lastScan.payload === completeURString && now - lastScan.timestamp < 1500 + if (isDuplicateScan) return + + lastCompletedScanRef.current = { payload: completeURString, timestamp: now } + onScan(completeURString) + stopScanning() + } + } catch (err) { + // If this part fails, it might be from a different QR code + // Reset the decoder and try again + urDecoderRef.current = new URDecoder() + setProgress(0) + setIsAnimated(false) + } + } + + const stopScanning = () => { + if (controls) { + controls.stop() + } + if (readerRef.current) { + readerRef.current = null + } + } + + startScanning() + + return () => { + mounted = false + // Reset duplicate detection on unmount + lastCompletedScanRef.current = { payload: '', timestamp: 0 } + stopScanning() + } + }, [onScan, onError]) + + const handleCancel = () => { + if (readerRef.current) { + readerRef.current = null + } + onCancel() + } + + return ( +
+
{isAnimated ? `Scanning animated QR... ${progress}%` : title}
+ +
+
+ + {isAnimated && ( +
+
+
+ )} + + {(error || externalError) &&
{error || externalError}
} + +
{instructions}
+ +
+ Cancel +
+
+ ) +} + +export default QRScanner diff --git a/app/dash/Accounts/Add/AddHardwareQR/index.js b/app/dash/Accounts/Add/AddHardwareQR/index.js new file mode 100644 index 0000000000..2dab6a1778 --- /dev/null +++ b/app/dash/Accounts/Add/AddHardwareQR/index.js @@ -0,0 +1,214 @@ +import React from 'react' +import Restore from 'react-restore' + +import Signer from '../../../Signer' +import link from '../../../../../resources/link' +import RingIcon from '../../../../../resources/Components/RingIcon' +import QRScanner from './QRScanner' + +class AddHardwareQR extends React.Component { + constructor(...args) { + super(...args) + this.state = { + adding: false, + index: 0, + status: '', + error: false, + deviceName: 'QR', + scanning: false, + scannedData: '', + signerId: null + } + this.forms = [React.createRef()] + } + + onChange(key, e) { + e.preventDefault() + const value = e.target.value.substring(0, 20) + this.setState({ [key]: value || '' }) + } + + onBlur(key, e) { + e.preventDefault() + this.setState({ [key]: this.state[key] || '' }) + } + + onFocus(key, e) { + e.preventDefault() + if (this.state[key] === '') { + this.setState({ [key]: '' }) + } + } + + currentForm() { + return this.forms[this.state.index] + } + + blurActive() { + const formInput = this.currentForm() + if (formInput && formInput.current) formInput.current.blur() + } + + focusActive() { + setTimeout(() => { + const formInput = this.currentForm() + if (formInput && formInput.current) formInput.current.focus() + }, 500) + } + + next() { + this.blurActive() + this.setState({ index: this.state.index + 1 }) + this.focusActive() + } + + startScanning() { + this.setState({ scanning: true, index: 1 }) + } + + onQRScanned(data) { + this.setState({ scannedData: data, scanning: false, status: 'Importing device...' }) + this.importDevice(data) + } + + onScanError(error) { + this.setState({ scanning: false, status: error, error: true }) + } + + onScanCancel() { + this.setState({ scanning: false, index: 0 }) + } + + importDevice(urData) { + link.rpc('importQRDevice', urData, this.state.deviceName, (err, result) => { + if (err) { + this.setState({ status: err, error: true }) + } else { + this.setState({ + signerId: result.id, + status: 'Successful', + index: 2 + }) + // Navigate to the new signer + link.send('tray:action', 'backDash', 2) + const crumb = { + view: 'expandedSigner', + data: { signer: result.id } + } + link.send('tray:action', 'navDash', crumb) + } + }) + } + + restart() { + this.setState({ + adding: false, + index: 0, + scannedData: '', + scanning: false, + signerId: null + }) + setTimeout(() => { + this.setState({ status: '', error: false }) + }, 500) + this.focusActive() + } + + render() { + let itemClass = 'addAccountItem addAccountItemSmart addAccountItemAdding' + + let signer + if (this.state.signerId && this.state.status === 'Successful') { + signer = this.store('main.signers', this.state.signerId) + } + + return ( +
+
+
+
+
+
+
+ +
+
+
QR Hardware
+
+
Keystone, Keycard Shell
+
+
+
+
+ {/* Frame 0: Device Name */} +
+
Device Name
+
+ this.onChange('deviceName', e)} + onFocus={(e) => this.onFocus('deviceName', e)} + onBlur={(e) => this.onBlur('deviceName', e)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + this.startScanning() + } + }} + /> +
+
this.startScanning()}> + Scan QR Code +
+
+ + {/* Frame 1: QR Scanner */} +
+ {this.state.scanning ? ( + this.onQRScanned(data)} + onError={(err) => this.onScanError(err)} + onCancel={() => this.onScanCancel()} + /> + ) : ( + <> +
{this.state.status || 'Scanning...'}
+ {this.state.error ? ( +
this.restart()}> + Try Again +
+ ) : null} + + )} +
+ + {/* Frame 2: Success */} +
+ {signer && this.state.status === 'Successful' ? ( + + ) : ( + <> +
{this.state.status}
+ {this.state.error ? ( +
this.restart()}> + Try Again +
+ ) : null} + + )} +
+
+
+
+
+
+
+ ) + } +} + +export default Restore.connect(AddHardwareQR) diff --git a/app/dash/Accounts/Add/index.js b/app/dash/Accounts/Add/index.js index 39463604c1..6fbe9e12f4 100644 --- a/app/dash/Accounts/Add/index.js +++ b/app/dash/Accounts/Add/index.js @@ -5,6 +5,7 @@ import svg from '../../../../resources/svg' import AddHardware from './AddHardware' import AddHardwareLattice from './AddHardwareLattice' +import AddHardwareQR from './AddHardwareQR' import AddPhrase from './AddPhrase' import AddRing from './AddRing' import AddAddress from './AddAddress' @@ -71,17 +72,18 @@ class Add extends React.Component { +
{svg.flame(18)}
Hot Accounts
- - + +
{svg.handshake(23)}
Nonsigning Accounts
- +
{svg.logo(32)}
diff --git a/app/dash/Accounts/Add/style/index.styl b/app/dash/Accounts/Add/style/index.styl index 4b16c195ab..93b174c067 100644 --- a/app/dash/Accounts/Add/style/index.styl +++ b/app/dash/Accounts/Add/style/index.styl @@ -506,3 +506,58 @@ opacity 1 transform translateY(0px) scale(1) rotateY(0deg) transition-delay 0.00s + +// QR Scanner styles +.qrScannerContainer + display flex + flex-direction column + align-items center + justify-content center + width 100% + height 100% + padding 10px + box-sizing border-box + +.qrScannerTitle + font-size 14px + font-weight 400 + margin-bottom 12px + text-align center + +.qrScannerVideo + width 100% + max-width 200px + display flex + justify-content center + align-items center + border-radius 8px + overflow hidden + background var(--ghostA) + +.qrScannerProgress + width 100% + max-width 200px + height 4px + background var(--ghostA) + border-radius 2px + margin-top 10px + overflow hidden + +.qrScannerProgressBar + height 100% + background var(--good) + transition width 0.2s ease + +.qrScannerError + color var(--bad) + font-size 12px + text-align center + margin-top 10px + padding 0 20px + +.qrScannerInstructions + font-size 11px + text-align center + color var(--outerspace08) + margin-top 12px + padding 0 20px diff --git a/app/dash/Accounts/index.js b/app/dash/Accounts/index.js index 5b1d56f546..77d46d422a 100644 --- a/app/dash/Accounts/index.js +++ b/app/dash/Accounts/index.js @@ -8,6 +8,7 @@ import Signer from '../Signer' import AddHardware from './Add/AddHardware' import AddHardwareLattice from './Add/AddHardwareLattice' +import AddHardwareQR from './Add/AddHardwareQR' import AddPhrase from './Add/AddPhrase' import AddRing from './Add/AddRing' import AddKeystore from './Add/AddKeystore' @@ -69,6 +70,13 @@ class AddAccounts extends React.Component {
) } + renderAddQR() { + return ( +
+ +
+ ) + } renderAddGnosis() { return
{'Add Gnosis'}
} @@ -97,6 +105,10 @@ class AddAccounts extends React.Component {
{svg.trezor(20)}
{'Trezor Device'}
+
this.createNewAccount('qr')}> +
{svg.qr(20)}
+
{'QR Device'}
+
this.createNewAccount('seed')}>
{svg.seedling(25)}
{'Seed Phrase'}
@@ -125,6 +137,8 @@ class AddAccounts extends React.Component { return this.renderAddTrezor() } else if (newAccountType === 'lattice') { return this.renderAddLattice() + } else if (newAccountType === 'qr') { + return this.renderAddQR() } else if (newAccountType === 'seed') { return this.renderAddSeed({ accountData }) } else if (newAccountType === 'keyring') { @@ -151,7 +165,12 @@ class Dash extends React.Component { const hardwareSigners = Object.keys(this.store('main.signers')) .map((s) => { const signer = this.store('main.signers', s) - if (signer.type === 'ledger' || signer.type === 'trezor' || signer.type === 'lattice') { + if ( + signer.type === 'ledger' || + signer.type === 'trezor' || + signer.type === 'lattice' || + signer.type === 'qr' + ) { return signer } else { return false diff --git a/app/dash/Command/index.js b/app/dash/Command/index.js index defa205380..1a1bd1bd83 100644 --- a/app/dash/Command/index.js +++ b/app/dash/Command/index.js @@ -13,6 +13,8 @@ class Command extends React.Component { return
{svg.flame(23)}
} else if (type === 'lattice') { return
{svg.lattice(22)}
+ } else if (type === 'qr') { + return
{svg.qr(20)}
} else { return
{svg.logo(20)}
} diff --git a/app/dash/Signer/index.js b/app/dash/Signer/index.js index 8d19d2cb9a..c65315c017 100644 --- a/app/dash/Signer/index.js +++ b/app/dash/Signer/index.js @@ -172,7 +172,8 @@ class Signer extends React.Component { } getStatus() { - return (this.props.status || '').toLowerCase() + const signer = this.store('main.signers', this.props.id) + return (signer?.status || this.props.status || '').toLowerCase() } status() { @@ -279,6 +280,8 @@ class Signer extends React.Component { return
{svg.flame(23)}
if (type === 'lattice') return
{svg.lattice(22)}
+ if (type === 'qr') + return
{svg.qr(20)}
return
{svg.logo(20)}
})()}
diff --git a/app/tray/Account/Requests/QRSignRequest/index.js b/app/tray/Account/Requests/QRSignRequest/index.js new file mode 100644 index 0000000000..fcbbe4e86b --- /dev/null +++ b/app/tray/Account/Requests/QRSignRequest/index.js @@ -0,0 +1,338 @@ +import React from 'react' +import Restore from 'react-restore' +import QRCode from 'qrcode' + +import link from '../../../../../resources/link' +import QRScanner from '../../../../dash/Accounts/Add/AddHardwareQR/QRScanner' + +class QRSignRequest extends React.Component { + constructor(...args) { + super(...args) + this.isSubmittingSignature = false + this.state = { + mode: 'display', // 'display' | 'scan' + qrReady: false, + error: null, + scanError: null, + scanAttempts: 0, + submittingSignature: false, + urFrames: [], + currentFrame: 0, + animationInterval: null, + currentRequestId: null + } + this.canvasRef = React.createRef() + + // Bind callbacks for stable references + this.handleScan = this.onSignatureScanned.bind(this) + this.handleError = this.onScanError.bind(this) + this.handleCancel = this.onScanCancel.bind(this) + } + + componentDidMount() { + const signRequest = this.store('main.qr.signRequest') + + // Validate sign request has required data + if (signRequest && (!signRequest.requestId || !signRequest.urData)) { + console.warn('QRSignRequest: Invalid sign request, clearing') + link.rpc('cancelQRSignRequest', signRequest.signerId, 'Invalid sign request', () => {}) + return + } + + this.generateQR() + } + + componentDidUpdate() { + const signRequest = this.store('main.qr.signRequest') + + if (!signRequest) return + + // If this is a new request, reset state and generate new QR + if (signRequest.requestId !== this.state.currentRequestId) { + if (this.state.animationInterval) { + clearInterval(this.state.animationInterval) + } + this.setState( + { + mode: 'display', + qrReady: false, + error: null, + scanError: null, + scanAttempts: 0, + submittingSignature: false, + urFrames: [], + currentFrame: 0, + animationInterval: null, + currentRequestId: signRequest.requestId + }, + () => { + this.isSubmittingSignature = false + this.generateQR() + } + ) + } + } + + componentWillUnmount() { + if (this.state.animationInterval) { + clearInterval(this.state.animationInterval) + } + this.isSubmittingSignature = false + + // Cancel any pending sign request to clear ALL stale state + const signRequest = this.store('main.qr.signRequest') + if (signRequest && signRequest.signerId) { + link.rpc('cancelQRSignRequest', signRequest.signerId, 'Component unmounted', () => {}) + } + } + + async generateQR() { + const signRequest = this.store('main.qr.signRequest') + if (!signRequest) return + + try { + // Use pre-encoded UR data from the main process + const qrData = signRequest.urData + if (!qrData) { + throw new Error('No UR data in sign request') + } + + // Handle animated QR codes (multiple frames) + if (signRequest.animated && signRequest.frames && signRequest.frames.length > 1) { + // Use callback to ensure state is set before starting animation + this.setState({ urFrames: signRequest.frames, currentFrame: 0 }, () => { + this.startAnimation() + }) + } else { + // Single QR code + const canvas = this.canvasRef.current + if (canvas) { + await QRCode.toCanvas(canvas, qrData, { + width: 340, + margin: 3, + color: { + dark: '#000000', + light: '#ffffff' + }, + errorCorrectionLevel: 'M' + }) + this.setState({ qrReady: true }) + } + } + } catch (err) { + this.setState({ error: err.message || 'Failed to generate QR' }) + } + } + + startAnimation() { + const { urFrames } = this.state + if (!urFrames || urFrames.length === 0) return + + // Animate through frames at 15 FPS (~67ms interval) + const interval = setInterval(async () => { + const { currentFrame, urFrames } = this.state + const nextFrame = (currentFrame + 1) % urFrames.length + + const canvas = this.canvasRef.current + if (canvas) { + await QRCode.toCanvas(canvas, urFrames[nextFrame], { + width: 340, + margin: 3, + color: { + dark: '#000000', + light: '#ffffff' + }, + errorCorrectionLevel: 'M' + }) + } + + this.setState({ currentFrame: nextFrame, qrReady: true }) + }, 67) + + this.setState({ animationInterval: interval, qrReady: true }) + + // Draw first frame immediately + const canvas = this.canvasRef.current + if (canvas && urFrames[0]) { + QRCode.toCanvas(canvas, urFrames[0], { + width: 340, + margin: 3, + color: { + dark: '#000000', + light: '#ffffff' + }, + errorCorrectionLevel: 'M' + }) + } + } + + startScanning() { + if (this.state.animationInterval) { + clearInterval(this.state.animationInterval) + } + this.setState((prevState) => ({ + mode: 'scan', + animationInterval: null, + scanError: null, + submittingSignature: false, + scanAttempts: prevState.scanAttempts + 1 + })) + } + + returnToDisplay() { + this.setState({ mode: 'display', qrReady: false, scanError: null, submittingSignature: false }, () => { + this.isSubmittingSignature = false + this.generateQR() + }) + } + + formatRpcError(err) { + if (!err) return 'Unknown signature scan error' + + const rawMessage = typeof err === 'string' ? err : err.message || '' + if (!rawMessage) { + return 'Failed to submit scanned signature' + } + + if (rawMessage.includes('Signature verification failed')) { + return 'Signature does not match this request. Re-scan the latest request QR and sign again.' + } + + if (rawMessage.includes('attempts=')) { + return 'Signature does not match this request. Re-scan the latest request QR and sign again.' + } + + if (rawMessage.includes('requestId mismatch')) { + return 'Scanned signature is for a different request. Scan the latest signature QR from your device.' + } + + if (rawMessage.includes('missing requestId')) { + return 'Signature QR is missing request metadata. Please sign from the latest request QR.' + } + + if (rawMessage.includes('Invalid signature')) { + return 'Signature QR data is invalid. Re-sign on your device and scan again.' + } + + if (rawMessage.includes('No pending QR sign request')) { + return 'No active QR signing request. Restart signing from the beginning.' + } + + if (rawMessage.length > 220) { + return `${rawMessage.slice(0, 217)}...` + } + + return rawMessage + } + + onSignatureScanned(urData) { + const signRequest = this.store('main.qr.signRequest') + if (!signRequest || this.isSubmittingSignature || this.state.submittingSignature) return + + this.isSubmittingSignature = true + this.setState({ submittingSignature: true, scanError: null }) + link.rpc('submitQRSignature', signRequest.signerId, urData, (err) => { + if (err) { + this.isSubmittingSignature = false + this.setState((prevState) => ({ + submittingSignature: false, + scanError: this.formatRpcError(err), + scanAttempts: prevState.scanAttempts + 1 + })) + return + } + + this.isSubmittingSignature = false + this.setState({ submittingSignature: false, scanError: null }) + }) + } + + onScanError(_error) { + this.setState({ scanError: _error || 'Scanner error' }) + } + + onScanCancel() { + this.isSubmittingSignature = false + this.returnToDisplay() + } + + cancel() { + const signRequest = this.store('main.qr.signRequest') + if (!signRequest) return + + link.rpc('cancelQRSignRequest', signRequest.signerId, 'User cancelled', () => {}) + } + + render() { + const signRequest = this.store('main.qr.signRequest') + + if (!signRequest) return null + + const signerName = this.store('main.signers', signRequest.signerId, 'name') || 'QR Device' + return ( +
+
+
+
Sign with {signerName}
+
+ {signRequest.type === 'transaction' && 'Transaction'} + {signRequest.type === 'message' && 'Message'} + {signRequest.type === 'typedData' && 'Typed Data'} +
+
+ +
+ {this.state.mode === 'display' ? ( + <> +
+ + {!this.state.qrReady && !this.state.error && ( +
Generating QR...
+ )} + {this.state.error &&
Error: {this.state.error}
} +
+ +
+
1. Scan this QR code with your {signerName}
+
+ 2. Review and approve the transaction on your device +
+
+ 3. Click "Scan Signature" and scan the signed QR +
+
+ +
+
this.startScanning()} + > + Scan Signature +
+
this.cancel()} + > + Cancel +
+
+ + ) : ( + + )} +
+
+
+ ) + } +} + +export default Restore.connect(QRSignRequest) diff --git a/app/tray/Account/Requests/style/index.styl b/app/tray/Account/Requests/style/index.styl index ee74bdfbef..0c16af8803 100644 --- a/app/tray/Account/Requests/style/index.styl +++ b/app/tray/Account/Requests/style/index.styl @@ -2222,6 +2222,188 @@ span max-width fit-content position relative +// QR Sign Request Overlay +.qrSignRequestOverlay + position fixed + top 0 + left 0 + right 0 + bottom 0 + background rgba(0, 0, 0, 0.85) + z-index 99999999999 + display flex + justify-content center + align-items center + animation fadeIn 0.2s ease-out + +.qrSignRequestModal + background var(--ghostA) + border-radius 24px + padding 24px + width 320px + box-shadow 0px 20px 40px rgba(0, 0, 0, 0.4) + +.qrSignRequestHeader + text-align center + margin-bottom 20px + +.qrSignRequestTitle + font-size 18px + font-weight 400 + margin-bottom 4px + +.qrSignRequestType + font-size 12px + text-transform uppercase + letter-spacing 2px + color var(--outerspace08) + +.qrSignRequestContent + display flex + flex-direction column + align-items center + +.qrSignRequestQR + background white + padding 16px + border-radius 12px + margin-bottom 20px + + img + display block + width 280px + height 280px + +.qrSignRequestLoading + width 280px + height 280px + display flex + justify-content center + align-items center + color var(--outerspace08) + +.qrSignRequestInstructions + text-align center + margin-bottom 24px + +.qrSignRequestStep + font-size 12px + color var(--outerspace08) + margin-bottom 8px + line-height 1.4 + +.qrSignRequestActions + display flex + flex-direction column + gap 10px + width 100% + +.qrSignRequestButton + height 44px + border-radius 22px + display flex + justify-content center + align-items center + cursor pointer + font-size 14px + font-weight 400 + text-transform uppercase + letter-spacing 1px + transition var(--standard) + +.qrSignRequestButtonPrimary + background var(--good) + color var(--spacewhite) + +.qrSignRequestButtonPrimary:hover + background var(--goodOver) + +.qrSignRequestButtonSecondary + background var(--ghostB) + color var(--outerspace) + +.qrSignRequestButtonSecondary:hover + background var(--ghostC) + +// QR Scanner styles (for signature scanning mode) +.qrScannerContainer + display flex + flex-direction column + align-items center + justify-content flex-start + width 100% + padding 10px + box-sizing border-box + +.qrScannerTitle + font-size 14px + font-weight 400 + margin-bottom 12px + text-align center + +.qrScannerVideo + width 100% + max-width 280px + display flex + justify-content center + align-items center + border-radius 12px + overflow hidden + background var(--ghostZ) + + video + width 100% + border-radius 12px + +.qrScannerProgress + width 100% + max-width 280px + height 4px + background var(--ghostZ) + border-radius 2px + margin-top 10px + overflow hidden + +.qrScannerProgressBar + height 100% + background var(--good) + transition width 0.2s ease + +.qrScannerError + color var(--bad) + font-size 12px + text-align center + margin-top 10px + padding 0 20px + +.qrScannerInstructions + font-size 11px + text-align center + color var(--outerspace08) + margin-top 12px + padding 0 20px + +// Cancel button for QR scanner in tray context +.qrScannerContainer .addAccountItemOptionSubmit + height 44px + border-radius 22px + padding 0px 40px + margin-top 20px + display flex + text-align center + justify-content center + align-items center + cursor pointer + background var(--ghostB) + font-size 14px + font-weight 400 + text-transform uppercase + letter-spacing 1px + transition var(--standard) + +.qrScannerContainer .addAccountItemOptionSubmit:hover + background var(--ghostC) + @import './next' @import '../TransactionRequest/style' @import '../ChainRequest/style' diff --git a/app/tray/Account/Signer/SignerPreview/index.js b/app/tray/Account/Signer/SignerPreview/index.js index b8dd966ac8..7b208bf390 100644 --- a/app/tray/Account/Signer/SignerPreview/index.js +++ b/app/tray/Account/Signer/SignerPreview/index.js @@ -85,6 +85,13 @@ class Signer extends React.Component {
{'Hot'}
) + } else if (type === 'qr') { + return ( +
+
{svg.qr(16)}
+
{'QR Hardware'}
+
+ ) } else { return (
diff --git a/app/tray/AccountSelector/AccountController/index.js b/app/tray/AccountSelector/AccountController/index.js index b587f92211..889c46d951 100644 --- a/app/tray/AccountSelector/AccountController/index.js +++ b/app/tray/AccountSelector/AccountController/index.js @@ -155,6 +155,8 @@ class Account extends React.Component { return
{svg.flame(25)}
if (type === 'lattice') return
{svg.lattice(26)}
+ if (type === 'qr') + return
{svg.qr(24)}
return
{svg.logo(22)}
})()}
diff --git a/app/tray/App.js b/app/tray/App.js index cb7e63348b..bf8cb32d7c 100644 --- a/app/tray/App.js +++ b/app/tray/App.js @@ -6,6 +6,7 @@ import Account from './Account' import Notify from './Notify' import Menu from './Menu' import Badge from './Badge' +import QRSignRequest from './Account/Requests/QRSignRequest' import Backdrop from './Backdrop' import AccountSelector from './AccountSelector' @@ -79,6 +80,7 @@ class Panel extends React.Component { +