Refactor QR functionality

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.
This commit is contained in:
Dennis Reimann
2022-08-31 12:27:06 +02:00
committed by Andrew Camilleri
parent 6b8f4ee1d5
commit c97b859963
10 changed files with 232 additions and 213 deletions

View File

@@ -10,8 +10,6 @@
<div class="modal-header">
<h5 class="modal-title">
{{title}}
<span v-if="workload.length > 0">Animated QR detected: {{workload.length}} / {{workload[0].total}} scanned</span>
<span v-if="decoder">Animated QR detected: {{decoder.processedPartsCount()}} / {{decoder.expectedPartCount()}} scanned</span>
</h5>
<button type="button" class="btn-close" aria-label="Close" v-on:click="close">
<vc:icon symbol="close"/>
@@ -25,34 +23,38 @@
</div>
<div v-else>
<slot></slot>
<div v-if="workload.length > 0">Animated QR detected: {{workload.length}} / {{workload[0].total}} scanned</div>
</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="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 && requestInput && cameras.length > 2" class="d-flex justify-content-center align-items-center mt-3">
<button class="btn btn-secondary text-center" v-on:click="nextCamera">Switch camera</button>
</div>
<div v-else-if="qrData || errorMessage">
<div v-if="errorMessage" class="alert alert-danger" role="alert">
<div v-if="isLoaded">
<div v-if="errorMessage" class="alert alert-danger mt-3" role="alert">
{{errorMessage}}
</div>
<div v-if="qrData" class="font-monospace mt-3" style="overflow:hidden;text-overflow:ellipsis;">
<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 class="mt-3">
<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-on:click="retry">Retry</button>
<button type="button" class="btn btn-outline-secondary" v-if="isModal" v-on:click="close">Cancel</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>
@@ -63,7 +65,7 @@ function initCameraScanningApp(title, onDataSubmit, modalId, submitOnScan = fals
const isModal = !!modalId;
Vue.component('scanner-wrap', {
props: ["modalId", "title", "workload", "decoder"],
props: ["modalId", "title", "decoder"],
template: "#camera-qr-scanner-wrap",
methods: {
close() {
@@ -82,12 +84,12 @@ function initCameraScanningApp(title, onDataSubmit, modalId, submitOnScan = fals
modalId: modalId,
noStreamApiSupport: false,
qrData: null,
decoder: null,
errorMessage: null,
workload: [],
successMessage: null,
camera: 0,
cameraOff: true,
cameras: ["auto"],
decoder: null,
submitOnScan
}
},
@@ -103,22 +105,23 @@ function initCameraScanningApp(title, onDataSubmit, modalId, submitOnScan = fals
},
computed: {
requestInput() {
return !this.cameraOff && this.errorMessage === null;
return !this.cameraOff && !this.errorMessage && !this.successMessage && !this.qrData;
}
},
methods: {
nextCamera: function (){
nextCamera() {
if (this.camera === 0){
this.camera++;
} else if (this.camera == this.cameras.length -1) {
} else if (this.camera === this.cameras.length - 1) {
this.camera = 0;
} else {
this.camera++;
}
},
setQrData (qrData) {
setQrData(qrData) {
this.qrData = qrData;
this.cameraOff = !!qrData;
if (this.qrData && this.submitOnScan) {
this.submitData();
}
@@ -129,8 +132,8 @@ function initCameraScanningApp(title, onDataSubmit, modalId, submitOnScan = fals
},
reset() {
this.setQrData(null);
this.successMessage = null;
this.errorMessage = null;
this.workload = [];
this.decoder = null;
},
close() {
@@ -142,53 +145,37 @@ function initCameraScanningApp(title, onDataSubmit, modalId, submitOnScan = fals
},
onDecode(content) {
if (this.qrData) return;
if (!content.toLowerCase().startsWith("ur:")) {
this.setQrData(content);
this.workload = [];
} else {
if (this.workload.length == 0){
const d = this.decoder || new window.URlib.URRegistryDecoder();
if (d.receivePart(content)){
this.decoder = d;
if (d.isComplete()){
if (!this.decoder.isSuccess()){
this.errorMessage = this.decoder.resultError();
}else{
this.setQrData(this.decoder.resultRegistryType().toString());
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();
}
}
}
if (this.decoder != null){
return;
}
}
const [index, total] = window.bcur.extractSingleWorkload(content);
if (this.workload.length > 0) {
const currentTotal = this.workload[0].total;
if (total !== currentTotal) {
this.workload = [];
}
}
if (!this.workload.find(i => i.index === index)) {
this.workload.push({
index,
total,
data: content,
});
if (this.workload.length === total) {
const decoded = window.bcur.decodeUR(this.workload.map(i => i.data));
this.setQrData(decoded);
}
}
}
}
} catch (error) {
console.error(error);
this.errorMessage = error.message;
}
},
submitData() {
if (onDataSubmit) {
onDataSubmit(this.qrData);
}
this.close();
},
if (onDataSubmit) {
onDataSubmit(this.qrData);
}
this.close();
},
logErrors(promise) {
promise.catch(console.error)
},
@@ -208,12 +195,10 @@ function initCameraScanningApp(title, onDataSubmit, modalId, submitOnScan = fals
},
onInit(promise) {
promise.then(() => {
this.errorMessage = null;
if (app.cameras.length === 1)
{
navigator.mediaDevices.enumerateDevices().then(function (devices) {
if (app.cameras.length === 1) {
navigator.mediaDevices.enumerateDevices().then(devices => {
for (const device of devices) {
if (device.kind == "videoinput"){
if (device.kind === "videoinput"){
app.cameras.push( device.deviceId)
}
}

View File

@@ -3,134 +3,112 @@
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{title}} <template v-if="fragments.length > 1">({{index+1}}/{{fragments.length}})</template></h5>
<h5 class="modal-title">{{title}} <template v-if="fragments && fragments.length > 1">({{index+1}}/{{fragments.length}})</template></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<vc:icon symbol="close"/>
</button>
</div>
<div class="modal-body text-center ">
<component :is="clickable? 'a': 'div'" class="qr-container text-center mt-3" :href="data" style="min-height: 256px;">
<qrcode v-bind:value="currentFragment" :options="{ width: 256,height:256, margin: 1, color: {dark:'#000', light:'#f5f5f7'} }">
</qrcode>
</component>
<ul class="nav justify-content-center mt-4 mb-3" v-if="allowedModes.length > 1">
<li class="nav-item" v-for="allowedMode in allowedModes">
<a class="btcpay-pill"
v-bind:class="{ 'active': allowedMode == currentMode}" href="#" v-on:click="currentMode = allowedMode">
{{allowedMode}}
</a>
<div class="modal-body text-center">
<div class="text-center mt-3" :style="{height: `${qrOptions.height}px`}">
<component v-if="currentFragment" :is="currentMode.href ? 'a': 'div'" class="qr-container d-inline-block" :href="currentMode.href">
<qrcode :value="currentFragment" :options="qrOptions"></qrcode>
</component>
</div>
<ul class="nav justify-content-center mt-4 mb-3" v-if="modes && Object.keys(modes).length > 1">
<li class="nav-item" v-for="(item, key) in modes">
<a class="btcpay-pill" :class="{ 'active': key === mode }" href="#" v-on:click="mode = key">{{item.title}}</a>
</li>
</ul>
<div v-if="showData && data">
<div class="input-group input-group-sm" :data-clipboard="data">
<input type="text" class="form-control" style="cursor:copy" readonly="readonly" :value="data" id="qr-code-data-input">
<div v-if="currentFragment && currentMode.showData">
<div class="input-group input-group-sm" :data-clipboard="currentFragment">
<input type="text" class="form-control" style="cursor:copy" readonly="readonly" :value="currentFragment" id="qr-code-data-input">
<button type="button" class="btn btn-outline-secondary" data-clipboard-confirm="">Copy</button>
</div>
</div>
<p v-if="note" v-html="note" class="text-muted mt-3"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<div class="mb-4 text-center" v-if="continueCallback">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" v-on:click="continueCallback()">{{ continueTitle || 'Continue' }}</button>
</div>
</div>
</div>
</div>
</div>
<script>
function initQRShow(title, data, modalId)
{
return new Vue(
{
function initQRShow(data) {
return new Vue({
el: '#scan-qr-modal-app',
components:
{
components: {
qrcode: VueQrcode
},
data:
{
index: -1,
title: title,
speed: 500,
data: data,
dataPerMode: {},
clickable: false,
fragments: [],
active: false,
modalId: modalId,
note: "",
currentMode: "bcur",
showData: false,
allowedModes: ["static","bcur"]
data() {
const res = Object.assign({}, {
title: "Scan QR",
modalId: "scan-qr-modal",
modes: {},
index: -1,
speed: 500,
active: false,
note: null,
continueTitle: null,
continueCallback: null,
qrOptions: {
width: 256,
height: 256,
margin: 1,
color: {
dark: '#000',
light: '#f5f5f7'
}
}
}, data || {});
if (!Object.values(res.modes || {}).length) {
res.modes = { default: { title: 'Default', fragments: [res.data] } };
}
if (!res.mode) {
res.mode = Object.keys(res.modes)[0];
}
return res;
},
computed:
{
currentFragment: function ()
{
return this.fragments[this.index];
}
computed: {
fragments() {
return this.currentMode && this.currentMode.fragments;
},
currentMode() {
return this.modes[this.mode];
},
currentFragment() {
return this.fragments && this.fragments[this.index];
}
},
mounted: function ()
{
var self = this;
$("#" + this.modalId)
.on("shown.bs.modal", function ()
{
self.start();
})
.on("hide.bs.modal", function ()
{
self.active = false;
});
self.setFragments();
mounted() {
$(`#${this.modalId}`)
.on("shown.bs.modal", () => { this.start(); })
.on("hide.bs.modal", () => { this.active = false; });
},
watch:
{
currentMode: function(){
if (this.dataPerMode && this.currentMode in this.dataPerMode){
this.data = this.dataPerMode[this.currentMode];
}
this.setFragments();
methods: {
start() {
this.active = true;
this.index = -1;
this.playNext();
},
playNext() {
if (!this.active) return;
},
data: function ()
{
this.setFragments();
}
},
methods:
{
setFragments: function ()
{
if (!this.data)
{
this.fragments = [];
return;
}
if (this.currentMode == "bcur"){
this.fragments = window.bcur.encodeUR(this.data.toString(), 200);
}else{
this.fragments = [this.data.toString()];
}
this.index++;
if (this.index > (this.fragments.length - 1)) {
this.index = 0;
}
setTimeout(this.playNext, this.speed);
},
start: function ()
{
this.active = true;
this.index = -1;
this.playNext();
},
playNext: function ()
{
if (!this.active)
{
return;
}
this.index++;
if (this.index > (this.fragments.length - 1))
{
this.index = 0;
}
setTimeout(this.playNext, this.speed)
}
showData(data) {
this.modes = { default: { title: 'Default', fragments: [data] } };
$(`#${this.modalId}`).modal("show");
}
}
});
}

View File

@@ -109,18 +109,17 @@
})();
});
var apiKeys = @Safe.Json(Model.ApiKeyDatas.Select(data => new
const apiKeys = @Safe.Json(Model.ApiKeyDatas.Select(data => new
{
ApiKey = data.Id,
Host = Context.Request.GetAbsoluteRoot()
}));
var qrApp = initQRShow("API Key QR", "", "scan-qr-modal");
$("button[data-qr]").on("click", function (){
var data = apiKeys[parseInt($(this).data("qr"))];
qrApp.data = JSON.stringify(data);
qrApp.currentMode = "static";
qrApp.allowedModes = ["static"];
$("#scan-qr-modal").modal("show");
const qrApp = initQRShow({ title: "API Key QR" });
delegate("click", "button[data-qr]", e => {
e.preventDefault();
const { qr } = e.target.dataset;
const data = apiKeys[qr];
qrApp.showData(JSON.stringify(data));
});
});
</script>

View File

@@ -244,21 +244,13 @@
<script src="~/vendor/vue-qrcode/vue-qrcode.min.js" asp-append-version="true"></script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<partial name="ShowQR"/>
<script>
<script>
document.addEventListener("DOMContentLoaded", () => {
const qrApp = initQRShow("LNURL Withdraw", "", "scan-qr-modal")
qrApp.data = null;
qrApp.showData = true;
qrApp.dataPerMode = {
Bech32: @Safe.Json(lnurl),
URI: @Safe.Json(lnurlUri)
const modes = {
uri: { title: "URI", fragments: [@Safe.Json(lnurlUri)], showData: true, href: @Safe.Json(lnurlUri) },
bech32: { title: "Bech32", fragments: [@Safe.Json(lnurl)], showData: true, href: @Safe.Json(lnurl) }
};
qrApp.clickable = true;
qrApp.allowedModes = ["Bech32", "URI"];
qrApp.currentMode = "URI";
qrApp.note = @Safe.Json(note);
initQRShow({ title: "LNURL Withdraw", note: @Safe.Json(note), modes })
});
</script>
}

View File

@@ -22,7 +22,7 @@
<template id="modal-template">
<div class="modal-dialog" role="document">
<form class="modal-content" method="post" enctype="multipart/form-data">
<form class="modal-content" method="post" enctype="multipart/form-data">
<div class="modal-header">
<h5 class="modal-title" id="addressVerification">Address verification</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
@@ -52,8 +52,8 @@
<input asp-for="RootFingerprint" type="hidden" />
<input asp-for="KeyPath" type="hidden" />
<div class="form-group">
<table class="table table-hover table-responsive-md">
<div class="mb-3 table-responsive-sm">
<table class="table table-hover w-auto mx-auto">
<thead>
<tr>
<th>Key path</th>

View File

@@ -68,13 +68,28 @@
}
@section PageFootContent {
<partial name="_ValidationScriptsPartial" />
<partial name="_ValidationScriptsPartial"/>
<bundle name="wwwroot/bundles/camera-bundle.min.js"></bundle>
<script>
window.coinName = "@Model.Network.DisplayName.ToLowerInvariant()";
document.addEventListener("DOMContentLoaded", function () {
initCameraScanningApp("Scan wallet QR", data => {
document.getElementById("WalletFileContent").value = data;
let xpub = "";
if (typeof(data) === "object") {
if (data.type === "crypto-account") {
const account = window.URlib.CryptoAccount.fromCBOR(data.cbor);
const [descriptor] = account.getOutputDescriptors();
xpub = descriptor.getHDKey().getBip32Key();
} else {
console.error('Unexpected UR type', data.type)
}
} else if (typeof(data) === 'string') {
xpub = data;
}
// remove potentially leading derivation path
xpub = xpub.replace(/^\[.*?\]/, '');
// submit
document.getElementById("WalletFileContent").value = xpub;
document.getElementById("qr-import-form").submit();
});
});

View File

@@ -198,14 +198,13 @@
@section PageFootContent {
<script>
const wallets = @Safe.Json(Model.AccountKeys.Select(model => Encoders.Hex.EncodeData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(model, Formatting.None)))))
const qrApp = initQRShow("Wallet QR", "", "scan-qr-modal")
const qrApp = initQRShow({ title: "Wallet QR" })
delegate('click', '#Delete', event => { event.preventDefault() })
delegate('click', 'button[data-account-key]', event => {
const { accountKey } = event.target.dataset
qrApp.data = wallets[parseInt(accountKey)]
$("#scan-qr-modal").modal("show")
const { accountKey } = event.target.dataset;
qrApp.showData(wallets[parseInt(accountKey)]);
})
if (navigator.registerProtocolHandler) {

View File

@@ -28,10 +28,31 @@
<bundle name="wwwroot/bundles/camera-bundle.min.js"></bundle>
<script>
document.addEventListener("DOMContentLoaded", function () {
initQRShow("Scan PSBT", @Json.Serialize(Model.PSBTHex), "scan-qr-modal");
initCameraScanningApp("Scan PSBT", function (data){
$("textarea[name=PSBT]").val(data);
$("#Decode").click();
const psbtHex = @Json.Serialize(Model.PSBTHex);
if (psbtHex) {
const buffer = new window.URlib.Buffer.from(psbtHex, "hex");
const cryptoPSBT = new window.URlib.CryptoPSBT(buffer);
const encoder = cryptoPSBT.toUREncoder();
const modes = {
ur: { title: "UR", fragments: encoder.encodeWhole() },
static: { title: "Static", fragments: [psbtHex] }
};
initQRShow({ title: "Scan the PSBT", modes });
}
initCameraScanningApp("Scan PSBT", data => {
let hex = data;
if (typeof(data) === "object") {
if (data.type === "crypto-psbt") {
const psbt = window.URlib.CryptoPSBT.fromCBOR(data.cbor);
hex = psbt.getPSBT().toString('hex');
} else {
console.error('Unexpected UR type', data.type)
}
} else if (typeof(data) === 'string') {
hex = data;
}
document.getElementById("PSBT").value = hex;
document.getElementById("Decode").click();
}, "scanModal");
});
</script>

View File

@@ -28,11 +28,41 @@
<script>
hljs.initHighlightingOnLoad();
document.addEventListener("DOMContentLoaded", function () {
initQRShow("Scan the PSBT with your wallet", @Json.Serialize(Model.PSBTHex), "scan-qr-modal");
initCameraScanningApp("Scan the PSBT from your wallet", function (data){
$("textarea[name=PSBT]").val(data);
$("#Decode").click();
document.addEventListener("DOMContentLoaded", () => {
const psbtHex = @Json.Serialize(Model.PSBTHex);
const buffer = new window.URlib.Buffer.from(psbtHex, "hex");
const cryptoPSBT = new window.URlib.CryptoPSBT(buffer);
const encoder = cryptoPSBT.toUREncoder();
const modes = {
ur: { title: "UR", fragments: encoder.encodeWhole() },
static: { title: "Static", fragments: [psbtHex] }
};
const continueCallback = () => {
document.querySelector("#PSBTOptionsImportHeader button").click()
document.getElementById("scanqrcode").click()
};
initQRShow({
title: "Scan the PSBT with your wallet",
modes,
continueTitle: "Continue with signed PSBT",
continueCallback
});
initCameraScanningApp("Scan the PSBT from your wallet", data => {
let hex = data;
if (typeof(data) === "object") {
if (data.type === "crypto-psbt") {
const psbt = window.URlib.CryptoPSBT.fromCBOR(data.cbor);
hex = psbt.getPSBT().toString('hex');
} else {
console.error('Unexpected UR type', data.type)
}
} else if (typeof(data) === 'string') {
hex = data;
}
document.getElementById("ImportedPSBT").value = hex;
document.getElementById("Decode").click();
}, "scanModal");
});
</script>

View File

@@ -1,6 +1,6 @@
$(function () {
initCameraScanningApp("Scan address/ payment link", function(data) {
initCameraScanningApp("Scan address/ payment link", data => {
$("#BIP21").val(data);
$("form").submit();
},"scanModal");
}, "scanModal");
});