feat(desktop): webcam QR scanning via ffmpeg for bunker login

Replaces webcam-capture (broken on Apple Silicon) with ffmpeg
avfoundation capture. Opens a live preview dialog that scans
for QR codes from the FaceTime camera. Works natively on arm64.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-05 14:11:38 +02:00
parent 6e04482981
commit d030843522
3 changed files with 107 additions and 49 deletions
+1 -2
View File
@@ -46,10 +46,9 @@ dependencies {
// JSON // JSON
implementation(libs.jackson.module.kotlin) implementation(libs.jackson.module.kotlin)
// QR code scanning (webcam + image decoding) // QR code scanning (webcam via ffmpeg + ZXing 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)
@@ -20,8 +20,6 @@
*/ */
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
@@ -29,14 +27,21 @@ import com.google.zxing.client.j2se.BufferedImageLuminanceSource
import com.google.zxing.common.HybridBinarizer import com.google.zxing.common.HybridBinarizer
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Dimension import java.awt.Dimension
import java.awt.Graphics
import java.awt.image.BufferedImage import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import javax.imageio.ImageIO
import javax.swing.JDialog import javax.swing.JDialog
import javax.swing.JFrame import javax.swing.JFrame
import javax.swing.JLabel import javax.swing.JLabel
import javax.swing.JPanel
import javax.swing.SwingConstants import javax.swing.SwingConstants
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
fun decodeQrFromImage(image: BufferedImage): String? = 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))
@@ -45,76 +50,132 @@ fun decodeQrFromImage(image: BufferedImage): String? =
null null
} }
private fun findFfmpeg(): String? =
listOf("ffmpeg", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg")
.firstOrNull { path ->
try {
ProcessBuilder(path, "-version")
.redirectErrorStream(true)
.start()
.waitFor() == 0
} catch (_: Exception) {
false
}
}
/** /**
* Opens a modal webcam scanner dialog. Scans frames for QR codes continuously. * Opens a webcam scanner dialog using ffmpeg to capture frames.
* Returns decoded text when found, or null if cancelled / no webcam available. * Continuously decodes frames looking for QR codes.
* Must be called off the EDT (e.g. from a coroutine on Dispatchers.IO). * Returns decoded text when found, or null if cancelled / no webcam.
* Must be called off the EDT (e.g. Dispatchers.IO).
*/ */
fun scanQrFromWebcam(): String? { fun scanQrFromWebcam(): String? {
val webcam = val ffmpeg = findFfmpeg() ?: return null
try {
Webcam.getDefault()
} catch (_: Exception) {
null
} ?: return null
webcam.viewSize = val result = AtomicReference<String?>(null)
webcam.viewSizes val running = AtomicBoolean(true)
.filter { it.width <= 1280 }
.maxByOrNull { it.width * it.height }
?: webcam.viewSizes.first()
webcam.open() val imagePanel =
object : JPanel() {
var currentImage: BufferedImage? = null
var result: String? = null override fun paintComponent(g: Graphics) {
super.paintComponent(g)
currentImage?.let { img ->
val scale =
minOf(
width.toDouble() / img.width,
height.toDouble() / img.height,
)
val w = (img.width * scale).toInt()
val h = (img.height * scale).toInt()
val x = (width - w) / 2
val y = (height - h) / 2
g.drawImage(img, x, y, w, h, null)
}
}
}
val statusLabel = JLabel("Point camera at QR code...", SwingConstants.CENTER)
val dialog = JDialog(null as JFrame?, "Scan QR Code", true) val dialog = JDialog(null as JFrame?, "Scan QR Code", true)
dialog.defaultCloseOperation = JDialog.DISPOSE_ON_CLOSE dialog.defaultCloseOperation = JDialog.DISPOSE_ON_CLOSE
dialog.layout = BorderLayout() dialog.layout = BorderLayout()
dialog.add(imagePanel, BorderLayout.CENTER)
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.add(statusLabel, BorderLayout.SOUTH)
dialog.preferredSize = Dimension(640, 520) dialog.preferredSize = Dimension(640, 520)
dialog.pack() dialog.pack()
dialog.setLocationRelativeTo(null) dialog.setLocationRelativeTo(null)
// Scanning thread — reads frames and decodes dialog.addWindowListener(
val scanThread = object : java.awt.event.WindowAdapter() {
override fun windowClosing(e: java.awt.event.WindowEvent?) {
running.set(false)
}
},
)
// Capture thread — runs ffmpeg to grab single frames in a loop
val captureThread =
Thread { Thread {
panel.start() while (running.get()) {
while (result == null && webcam.isOpen && dialog.isVisible) {
try { try {
val image = webcam.image ?: continue val process =
ProcessBuilder(
ffmpeg,
"-f",
"avfoundation",
"-video_size",
"640x480",
"-framerate",
"15",
"-i",
"0",
"-frames:v",
"1",
"-f",
"image2pipe",
"-vcodec",
"mjpeg",
"-q:v",
"5",
"pipe:1",
).redirectError(ProcessBuilder.Redirect.DISCARD)
.start()
val baos = ByteArrayOutputStream()
process.inputStream.use { it.copyTo(baos) }
process.waitFor()
val bytes = baos.toByteArray()
if (bytes.isEmpty()) continue
val image = ImageIO.read(ByteArrayInputStream(bytes)) ?: continue
// Update preview
imagePanel.currentImage = image
SwingUtilities.invokeLater { imagePanel.repaint() }
// Try to decode QR
val decoded = decodeQrFromImage(image) val decoded = decodeQrFromImage(image)
if (decoded != null) { if (decoded != null) {
result = decoded result.set(decoded)
running.set(false)
SwingUtilities.invokeLater { dialog.dispose() } SwingUtilities.invokeLater { dialog.dispose() }
break break
} }
} catch (_: Exception) { } catch (_: Exception) {
// webcam may throw during shutdown if (!running.get()) break
Thread.sleep(500)
} }
Thread.sleep(150)
} }
} }
scanThread.isDaemon = true captureThread.isDaemon = true
scanThread.start() captureThread.start()
// Blocks until dialog is closed (modal) // Blocks until dialog is closed (modal)
dialog.isVisible = true dialog.isVisible = true
running.set(false)
// Cleanup return result.get()
try {
panel.stop()
webcam.close()
} catch (_: Exception) {
}
return result
} }
-2
View File
@@ -57,7 +57,6 @@ 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"
@@ -160,7 +159,6 @@ 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" }