mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-22 22:25:28 +01:00
Based on the `ur-registry` upgrade I refactored the `CameraScanner` and `ShowQR` partials: Besides general code changes, the main change is that most of the configuration and result handling now happens on the outer view. Those partials and functions are now generalized and don't know about their purpose (like handling PSBTs): They can be instantiated with simple data (e.g. for displaying a plain QR code) or different modes (like showing a static and the UR version of a QR code) and the result handling is done via callback. The callbacks can now also distinguish between the different results (data as plain string vs. UR-type objects for wallet data or PSBT) and also handle the specific type of data. For instance: Before it wasn't possible to strip the leading derivation path from an xpub when scanning the QR code, because the scanner didn't know about the type of data it was handling. Now that the data is handled in the callback, we can implement that functionality for the scan view only.
228 lines
8.2 KiB
Text
228 lines
8.2 KiB
Text
@inject BTCPayServer.Security.ContentSecurityPolicies csp
|
|
@{
|
|
csp.Add("worker-src", "blob:");
|
|
}
|
|
|
|
<template id="camera-qr-scanner-wrap">
|
|
<div v-if="modalId" :id="modalId" class="modal fade" data-bs-backdrop="static">
|
|
<div class="modal-dialog" role="document">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
{{title}}
|
|
</h5>
|
|
<button type="button" class="btn-close" aria-label="Close" v-on:click="close">
|
|
<vc:icon symbol="close"/>
|
|
</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<slot/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else>
|
|
<slot></slot>
|
|
</div>
|
|
</template>
|
|
|
|
<div id="camera-qr-scanner-modal-app" v-cloak class="only-for-js">
|
|
<scanner-wrap v-bind="$data" v-on:close="close">
|
|
<div class="d-flex justify-content-center align-items-center" :class="{'border border-secondary': !isModal}">
|
|
<div class="spinner-border text-secondary position-absolute" role="status"></div>
|
|
<qrcode-drop-zone v-on:decode="onDecode" v-on:init="logErrors">
|
|
<qrcode-stream v-on:decode="onDecode" v-on:init="onInit" :camera="cameraOff? 'off': cameras[camera]" :device-id="cameras[camera]" :track="paint"/>
|
|
</qrcode-drop-zone>
|
|
<qrcode-capture v-if="noStreamApiSupport" v-on:decode="onDecode" :camera="cameraOff? 'off': cameras[camera]" :device-id="cameras[camera]"/>
|
|
</div>
|
|
<div v-if="isLoaded">
|
|
<div v-if="errorMessage" class="alert alert-danger mt-3" role="alert">
|
|
{{errorMessage}}
|
|
</div>
|
|
<div v-if="successMessage" class="alert alert-success mt-3" role="alert">
|
|
{{successMessage}}
|
|
</div>
|
|
<div v-else-if="qrData" class="alert alert-info font-monospace text-truncate mt-3">
|
|
{{qrData}}
|
|
</div>
|
|
<div v-else-if="decoder">
|
|
<div class="my-3">BC UR: {{decoder.expectedPartCount()}} parts, {{decoder.getProgress() * 100}}% completed</div>
|
|
<div class="progress">
|
|
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" :style="{width: `${decoder.getProgress() * 100}%`}" id="progressbar"></div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-3 text-center">
|
|
<button type="button" class="btn btn-primary me-1" v-if="qrData" v-on:click="submitData">Submit</button>
|
|
<button type="button" class="btn btn-secondary me-1" v-if="qrData" v-on:click="retry">Retry</button>
|
|
<button type="button" class="btn btn-secondary" v-if="requestInput && cameras.length > 2" v-on:click="nextCamera">Switch camera</button>
|
|
</div>
|
|
</div>
|
|
</scanner-wrap>
|
|
</div>
|
|
|
|
<script>
|
|
function initCameraScanningApp(title, onDataSubmit, modalId, submitOnScan = false) {
|
|
const isModal = !!modalId;
|
|
|
|
Vue.component('scanner-wrap', {
|
|
props: ["modalId", "title", "decoder"],
|
|
template: "#camera-qr-scanner-wrap",
|
|
methods: {
|
|
close() {
|
|
this.$emit('close');
|
|
}
|
|
}
|
|
});
|
|
|
|
const app = new Vue({
|
|
el: '#camera-qr-scanner-modal-app',
|
|
data() {
|
|
return {
|
|
isModal,
|
|
isLoaded: !isModal,
|
|
title: title,
|
|
modalId: modalId,
|
|
noStreamApiSupport: false,
|
|
qrData: null,
|
|
decoder: null,
|
|
errorMessage: null,
|
|
successMessage: null,
|
|
camera: 0,
|
|
cameraOff: true,
|
|
cameras: ["auto"],
|
|
submitOnScan
|
|
}
|
|
},
|
|
mounted() {
|
|
if (this.isModal) {
|
|
const modal = document.getElementById(this.modalId);
|
|
modal.addEventListener('shown.bs.modal', () => { this.isLoaded = true; this.cameraOff = false; });
|
|
modal.addEventListener('hide.bs.modal', () => { this.isLoaded = false; this.cameraOff = true; });
|
|
} else {
|
|
this.isLoaded = true;
|
|
this.cameraOff = false;
|
|
}
|
|
},
|
|
computed: {
|
|
requestInput() {
|
|
return !this.cameraOff && !this.errorMessage && !this.successMessage && !this.qrData;
|
|
}
|
|
},
|
|
methods: {
|
|
nextCamera() {
|
|
if (this.camera === 0){
|
|
this.camera++;
|
|
} else if (this.camera === this.cameras.length - 1) {
|
|
this.camera = 0;
|
|
} else {
|
|
this.camera++;
|
|
}
|
|
},
|
|
setQrData(qrData) {
|
|
this.qrData = qrData;
|
|
this.cameraOff = !!qrData;
|
|
|
|
if (this.qrData && this.submitOnScan) {
|
|
this.submitData();
|
|
}
|
|
},
|
|
retry() {
|
|
this.cameraOff = true;
|
|
this.$nextTick(this.reset);
|
|
},
|
|
reset() {
|
|
this.setQrData(null);
|
|
this.successMessage = null;
|
|
this.errorMessage = null;
|
|
this.decoder = null;
|
|
},
|
|
close() {
|
|
if (this.modalId) {
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById(this.modalId));
|
|
modal.hide();
|
|
}
|
|
this.reset();
|
|
},
|
|
onDecode(content) {
|
|
if (this.qrData) return;
|
|
const isUR = content.toLowerCase().startsWith("ur:");
|
|
console.debug(1, content);
|
|
|
|
try {
|
|
if (!isUR) {
|
|
this.setQrData(content);
|
|
} else {
|
|
this.decoder = this.decoder || new window.URlib.URRegistryDecoder();
|
|
if (this.decoder.receivePart(content)) {
|
|
if (this.decoder.isComplete()) {
|
|
if (this.decoder.isSuccess()) {
|
|
const ur = this.decoder.resultUR();
|
|
this.setQrData(ur);
|
|
this.successMessage = `UR ${ur.type} decoded`;
|
|
} else if (this.decoder.isError()) {
|
|
this.errorMessage = this.decoder.resultError();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
this.errorMessage = error.message;
|
|
}
|
|
},
|
|
submitData() {
|
|
if (onDataSubmit) {
|
|
onDataSubmit(this.qrData);
|
|
}
|
|
this.close();
|
|
},
|
|
logErrors(promise) {
|
|
promise.catch(console.error)
|
|
},
|
|
paint(detectedCodes, ctx) {
|
|
for (const detectedCode of detectedCodes) {
|
|
const [ firstPoint, ...otherPoints ] = detectedCode.cornerPoints
|
|
ctx.strokeStyle = "#51b13e";
|
|
ctx.beginPath();
|
|
ctx.moveTo(firstPoint.x, firstPoint.y);
|
|
for (const { x, y } of otherPoints) {
|
|
ctx.lineTo(x, y);
|
|
}
|
|
ctx.lineTo(firstPoint.x, firstPoint.y);
|
|
ctx.closePath();
|
|
ctx.stroke();
|
|
}
|
|
},
|
|
onInit(promise) {
|
|
promise.then(() => {
|
|
if (app.cameras.length === 1) {
|
|
navigator.mediaDevices.enumerateDevices().then(devices => {
|
|
for (const device of devices) {
|
|
if (device.kind === "videoinput"){
|
|
app.cameras.push( device.deviceId)
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}).catch(error => {
|
|
if (error.name === 'StreamApiNotSupportedError') {
|
|
this.noStreamApiSupport = true;
|
|
} else if (error.name === 'NotAllowedError') {
|
|
this.errorMessage = 'A permission to the camera is needed to scan the QR code. Please grant the browser access and then retry.'
|
|
} else if (error.name === 'NotFoundError') {
|
|
this.errorMessage = 'A camera was not detected on your device.'
|
|
} else if (error.name === 'NotSupportedError') {
|
|
this.errorMessage = 'This page is served in non-secure context (HTTPS, localhost or file://)'
|
|
} else if (error.name === 'NotReadableError') {
|
|
this.errorMessage = 'Couldn\'t access your camera. Is it already in use?'
|
|
} else if (error.name === 'OverconstrainedError') {
|
|
this.errorMessage = 'Constraints don\'t match any installed camera.'
|
|
} else {
|
|
this.errorMessage = 'UNKNOWN ERROR: ' + error.message
|
|
}
|
|
})
|
|
}
|
|
}
|
|
});
|
|
}
|
|
</script>
|