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
@@ -60,8 +60,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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}$")
@@ -136,33 +134,14 @@ fun LoginCard(
IconButton(
onClick = {
// Try clipboard first, then file picker
val clipboardResult = decodeQrFromClipboard()
if (clipboardResult != null) {
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)
scope.launch(Dispatchers.IO) {
val decoded = scanQrFromWebcam()
withContext(Dispatchers.Main) {
if (decoded != null) {
keyInput = decoded
errorMessage = null
} else {
errorMessage = "No QR code found in image"
errorMessage = "No QR code found"
}
}
}
@@ -20,31 +20,23 @@
*/
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.MultiFormatReader
import com.google.zxing.NotFoundException
import com.google.zxing.client.j2se.BufferedImageLuminanceSource
import com.google.zxing.common.HybridBinarizer
import java.awt.Image
import java.awt.Toolkit
import java.awt.datatransfer.DataFlavor
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
import javax.swing.JDialog
import javax.swing.JFrame
import javax.swing.JLabel
import javax.swing.SwingConstants
import javax.swing.SwingUtilities
fun decodeQrFromFile(file: File): 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? =
fun decodeQrFromImage(image: BufferedImage): String? =
try {
val source = BufferedImageLuminanceSource(image)
val bitmap = BinaryBitmap(HybridBinarizer(source))
@@ -53,11 +45,76 @@ private fun decodeQrFromImage(image: BufferedImage): String? =
null
}
private fun Image.toBufferedImage(): BufferedImage {
if (this is BufferedImage) return this
val buffered = BufferedImage(getWidth(null), getHeight(null), BufferedImage.TYPE_INT_ARGB)
val g = buffered.createGraphics()
g.drawImage(this, 0, 0, null)
g.dispose()
return buffered
/**
* Opens a modal webcam scanner dialog. Scans frames for QR codes continuously.
* Returns decoded text when found, or null if cancelled / no webcam available.
* Must be called off the EDT (e.g. from a coroutine on Dispatchers.IO).
*/
fun scanQrFromWebcam(): String? {
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
}