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