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:
+4
-25
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+82
-25
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user