From 6e0448298112c69e86e66548fee040831f2beb66 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 5 Mar 2026 14:07:29 +0200 Subject: [PATCH] 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 --- desktopApp/build.gradle.kts | 3 +- .../amethyst/desktop/ui/auth/LoginCard.kt | 29 +---- .../amethyst/desktop/ui/auth/QrCodeDecoder.kt | 107 ++++++++++++++---- gradle/libs.versions.toml | 2 + 4 files changed, 90 insertions(+), 51 deletions(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index e9effcff2..4f851d3f7 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -46,9 +46,10 @@ dependencies { // JSON implementation(libs.jackson.module.kotlin) - // QR code decoding from images + // QR code scanning (webcam + image decoding) implementation(libs.zxing) implementation(libs.zxing.javase) + implementation(libs.webcam.capture) // Collections implementation(libs.kotlinx.collections.immutable) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt index 0b4eb4a34..c64b71f58 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt @@ -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" } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt index 6d7a6f457..d677a6a47 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt @@ -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 } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4e75f8df0..336d9f929 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -57,6 +57,7 @@ unifiedpush = "3.0.10" vico-charts = "2.4.3" zelory = "3.0.1" zoomable = "2.11.1" +webcamCapture = "0.3.12" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" 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" } zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" } 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-javase = { group = "com.google.zxing", name = "javase", version.ref = "zxing" } zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" }