diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 06f63b4..a748914 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,6 +20,7 @@ "d3-shape": "^3.2.0", "electrobun": "^1.18.1", "env-paths": "^4.0.0", + "jsqr": "^1.4.0", "mode-watcher": "^1.1.0", "monero-ts": "^0.11.8", "qrcode": "^1.5.4", diff --git a/apps/desktop/src/lib/components/qr-upload.svelte b/apps/desktop/src/lib/components/qr-upload.svelte new file mode 100644 index 0000000..5d0b2e5 --- /dev/null +++ b/apps/desktop/src/lib/components/qr-upload.svelte @@ -0,0 +1,62 @@ + + +
+ + + {#if error} + {error} + {/if} +
\ No newline at end of file diff --git a/apps/desktop/src/lib/qr.ts b/apps/desktop/src/lib/qr.ts new file mode 100644 index 0000000..760bb4b --- /dev/null +++ b/apps/desktop/src/lib/qr.ts @@ -0,0 +1,75 @@ +import jsQR from 'jsqr' + +export const decodeQrFromFile = (file: File): Promise => + new Promise((resolve, reject) => { + const url = URL.createObjectURL(file) + const img = new Image() + img.onload = () => { + try { + const canvas = document.createElement('canvas') + canvas.width = img.naturalWidth + canvas.height = img.naturalHeight + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Canvas is not supported') + ctx.drawImage(img, 0, 0) + const { data, width, height } = ctx.getImageData( + 0, + 0, + canvas.width, + canvas.height, + ) + resolve(jsQR(data, width, height)?.data ?? '') + } catch (e) { + reject(e) + } finally { + URL.revokeObjectURL(url) + } + } + img.onerror = () => { + URL.revokeObjectURL(url) + reject(new Error('Could not load the image')) + } + img.src = url + }) + +export type QrParseResult = { + address: string + amount?: string + destinationTag?: string +} + +// Handles payment URIs (monero:ADDRESS?tx_amount=..., xrp:rADDR?amount=..&dt=..) +// as well as bare addresses. Returns the last path-like segment before any +// query string so prefixes and /pay paths are skipped. +export const parseQrPayload = ( + text: string, + kind: 'xrp' | 'monero', +): QrParseResult => { + let body = text.trim().replace(/\s+/g, '') + body = body.replace(/^(?:[a-z0-9+.-]+):\/?\//i, '') + const [beforeQuery, queryRaw] = body.split('?') + const segments = beforeQuery.split('/') + const address = segments[segments.length - 1] + + const params: Record = {} + if (queryRaw) { + for (const pair of queryRaw.split('&')) { + const eq = pair.indexOf('=') + const key = (eq >= 0 ? pair.slice(0, eq) : pair).toLowerCase() + const value = eq >= 0 ? decodeURIComponent(pair.slice(eq + 1)) : '' + if (key) params[key] = value + } + } + + if (kind === 'xrp') { + return { + address, + amount: params['amount'] || undefined, + destinationTag: params['dt'] || undefined, + } + } + return { + address, + amount: params['tx_amount'] || undefined, + } +} \ No newline at end of file diff --git a/apps/desktop/src/routes/monero/send.svelte b/apps/desktop/src/routes/monero/send.svelte index 89806a3..ee085ed 100644 --- a/apps/desktop/src/routes/monero/send.svelte +++ b/apps/desktop/src/routes/monero/send.svelte @@ -19,6 +19,8 @@ SelectTrigger, } from '$lib/components/ui/select/index.js' import { navigate } from 'sv-router/generated' + import QrUpload from '$lib/components/qr-upload.svelte' + import { parseQrPayload } from '$lib/qr.js' const w = moneroWallet @@ -134,7 +136,15 @@
- +
+ + { + const parsed = parseQrPayload(text, 'monero') + recipient = parsed.address + if (parsed.amount && !sendAll) amountXmr = parsed.amount + }} /> +
diff --git a/apps/desktop/src/routes/xrp/send.svelte b/apps/desktop/src/routes/xrp/send.svelte index 71d8bbf..c74ce97 100644 --- a/apps/desktop/src/routes/xrp/send.svelte +++ b/apps/desktop/src/routes/xrp/send.svelte @@ -10,6 +10,8 @@ CardDescription, } from '$lib/components/ui/card/index.js' import { navigate } from 'sv-router/generated' + import QrUpload from '$lib/components/qr-upload.svelte' + import { parseQrPayload } from '$lib/qr.js' const w = xrpWallet @@ -78,7 +80,16 @@ }} class="space-y-4">
- +
+ + { + const parsed = parseQrPayload(text, 'xrp') + recipient = parsed.address + if (parsed.destinationTag) destinationTag = parsed.destinationTag + if (parsed.amount) amountXrp = parsed.amount + }} /> +
diff --git a/bun.lock b/bun.lock index 9161097..50ef359 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "d3-shape": "^3.2.0", "electrobun": "^1.18.1", "env-paths": "^4.0.0", + "jsqr": "^1.4.0", "mode-watcher": "^1.1.0", "monero-ts": "^0.11.8", "qrcode": "^1.5.4", @@ -640,6 +641,8 @@ "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "jsqr": ["jsqr@1.4.0", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "koins": ["koins@workspace:apps/desktop"], @@ -846,7 +849,7 @@ "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "sv-router": ["sv-router@0.18.0", "", { "dependencies": { "esm-env": "^1.2.2" }, "peerDependencies": { "svelte": "^5" }, "bin": { "sv-router": "./src/cli/index.js" } }, "sha512-gLky7FBGj8P6MAna1wxYniHJxMrXgaeTNnkZ7WH387ddRyUohfDtvEgWQFfB4oLL2XxWlw8Lfmk03TtBdK/JTA=="], + "sv-router": ["sv-router@0.18.1", "", { "dependencies": { "esm-env": "^1.2.2" }, "peerDependencies": { "svelte": "^5" }, "bin": { "sv-router": "./src/cli/index.js" } }, "sha512-xMJxgDwmcWtLDopU6xdEiBICRN40ucTlK63GUZf6cQk8689LUaUD5FlMrdtTdPFRQ2kfJBeJgoaI1k+XOuIQSQ=="], "svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="],