feat(desktop): add webcam QR code scanning for bunker login

Uses webcam-capture + ZXing to scan QR codes from the camera.
Click the QR icon on the login screen to open a webcam scanner
dialog that auto-detects bunker:// URIs from Amber's QR display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-05 14:07:29 +02:00
parent fd1b435020
commit 6e04482981
4 changed files with 90 additions and 51 deletions
+2 -1
View File
@@ -46,9 +46,10 @@ dependencies {
// JSON // JSON
implementation(libs.jackson.module.kotlin) implementation(libs.jackson.module.kotlin)
// QR code decoding from images // QR code scanning (webcam + image decoding)
implementation(libs.zxing) implementation(libs.zxing)
implementation(libs.zxing.javase) implementation(libs.zxing.javase)
implementation(libs.webcam.capture)
// Collections // Collections
implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.collections.immutable)
@@ -60,8 +60,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
private val HEX_64_REGEX = Regex("^[0-9a-fA-F]{64}$") private val HEX_64_REGEX = Regex("^[0-9a-fA-F]{64}$")
@@ -136,33 +134,14 @@ fun LoginCard(
IconButton( IconButton(
onClick = { onClick = {
// Try clipboard first, then file picker scope.launch(Dispatchers.IO) {
val clipboardResult = decodeQrFromClipboard() val decoded = scanQrFromWebcam()
if (clipboardResult != null) { withContext(Dispatchers.Main) {
keyInput = clipboardResult
errorMessage = null
} else {
val chooser =
JFileChooser().apply {
fileFilter =
FileNameExtensionFilter(
"Images",
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp",
)
dialogTitle = "Select QR code image"
}
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
val decoded = decodeQrFromFile(chooser.selectedFile)
if (decoded != null) { if (decoded != null) {
keyInput = decoded keyInput = decoded
errorMessage = null errorMessage = null
} else { } else {
errorMessage = "No QR code found in image" errorMessage = "No QR code found"
} }
} }
} }
@@ -20,31 +20,23 @@
*/ */
package com.vitorpamplona.amethyst.desktop.ui.auth package com.vitorpamplona.amethyst.desktop.ui.auth
import com.github.sarxos.webcam.Webcam
import com.github.sarxos.webcam.WebcamPanel
import com.google.zxing.BinaryBitmap import com.google.zxing.BinaryBitmap
import com.google.zxing.MultiFormatReader import com.google.zxing.MultiFormatReader
import com.google.zxing.NotFoundException import com.google.zxing.NotFoundException
import com.google.zxing.client.j2se.BufferedImageLuminanceSource import com.google.zxing.client.j2se.BufferedImageLuminanceSource
import com.google.zxing.common.HybridBinarizer import com.google.zxing.common.HybridBinarizer
import java.awt.Image import java.awt.BorderLayout
import java.awt.Toolkit import java.awt.Dimension
import java.awt.datatransfer.DataFlavor
import java.awt.image.BufferedImage import java.awt.image.BufferedImage
import java.io.File import javax.swing.JDialog
import javax.imageio.ImageIO import javax.swing.JFrame
import javax.swing.JLabel
import javax.swing.SwingConstants
import javax.swing.SwingUtilities
fun decodeQrFromFile(file: File): String? { fun decodeQrFromImage(image: BufferedImage): String? =
val image = ImageIO.read(file) ?: return null
return decodeQrFromImage(image)
}
fun decodeQrFromClipboard(): String? {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
if (!clipboard.isDataFlavorAvailable(DataFlavor.imageFlavor)) return null
val image = clipboard.getData(DataFlavor.imageFlavor) as? Image ?: return null
return decodeQrFromImage(image.toBufferedImage())
}
private fun decodeQrFromImage(image: BufferedImage): String? =
try { try {
val source = BufferedImageLuminanceSource(image) val source = BufferedImageLuminanceSource(image)
val bitmap = BinaryBitmap(HybridBinarizer(source)) val bitmap = BinaryBitmap(HybridBinarizer(source))
@@ -53,11 +45,76 @@ private fun decodeQrFromImage(image: BufferedImage): String? =
null null
} }
private fun Image.toBufferedImage(): BufferedImage { /**
if (this is BufferedImage) return this * Opens a modal webcam scanner dialog. Scans frames for QR codes continuously.
val buffered = BufferedImage(getWidth(null), getHeight(null), BufferedImage.TYPE_INT_ARGB) * Returns decoded text when found, or null if cancelled / no webcam available.
val g = buffered.createGraphics() * Must be called off the EDT (e.g. from a coroutine on Dispatchers.IO).
g.drawImage(this, 0, 0, null) */
g.dispose() fun scanQrFromWebcam(): String? {
return buffered val webcam =
try {
Webcam.getDefault()
} catch (_: Exception) {
null
} ?: return null
webcam.viewSize =
webcam.viewSizes
.filter { it.width <= 1280 }
.maxByOrNull { it.width * it.height }
?: webcam.viewSizes.first()
webcam.open()
var result: String? = null
val dialog = JDialog(null as JFrame?, "Scan QR Code", true)
dialog.defaultCloseOperation = JDialog.DISPOSE_ON_CLOSE
dialog.layout = BorderLayout()
val panel = WebcamPanel(webcam, false)
panel.isFPSDisplayed = false
panel.isMirrored = true
dialog.add(panel, BorderLayout.CENTER)
val statusLabel = JLabel("Point camera at QR code...", SwingConstants.CENTER)
dialog.add(statusLabel, BorderLayout.SOUTH)
dialog.preferredSize = Dimension(640, 520)
dialog.pack()
dialog.setLocationRelativeTo(null)
// Scanning thread — reads frames and decodes
val scanThread =
Thread {
panel.start()
while (result == null && webcam.isOpen && dialog.isVisible) {
try {
val image = webcam.image ?: continue
val decoded = decodeQrFromImage(image)
if (decoded != null) {
result = decoded
SwingUtilities.invokeLater { dialog.dispose() }
break
}
} catch (_: Exception) {
// webcam may throw during shutdown
}
Thread.sleep(150)
}
}
scanThread.isDaemon = true
scanThread.start()
// Blocks until dialog is closed (modal)
dialog.isVisible = true
// Cleanup
try {
panel.stop()
webcam.close()
} catch (_: Exception) {
}
return result
} }
+2
View File
@@ -57,6 +57,7 @@ unifiedpush = "3.0.10"
vico-charts = "2.4.3" vico-charts = "2.4.3"
zelory = "3.0.1" zelory = "3.0.1"
zoomable = "2.11.1" zoomable = "2.11.1"
webcamCapture = "0.3.12"
zxing = "3.5.4" zxing = "3.5.4"
zxingAndroidEmbedded = "4.3.0" zxingAndroidEmbedded = "4.3.0"
windowCoreAndroid = "1.5.1" windowCoreAndroid = "1.5.1"
@@ -159,6 +160,7 @@ vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", ver
vico-charts-views = { group = "com.patrykandpatrick.vico", name = "views", version.ref = "vico-charts" } vico-charts-views = { group = "com.patrykandpatrick.vico", name = "views", version.ref = "vico-charts" }
zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" } zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" }
zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" }
webcam-capture = { group = "com.github.sarxos", name = "webcam-capture", version.ref = "webcamCapture" }
zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
zxing-javase = { group = "com.google.zxing", name = "javase", version.ref = "zxing" } zxing-javase = { group = "com.google.zxing", name = "javase", version.ref = "zxing" }
zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" }