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
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.javase)
implementation(libs.webcam.capture)
// Collections
implementation(libs.kotlinx.collections.immutable)
@@ -20,8 +20,6 @@
*/
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
@@ -29,14 +27,21 @@ import com.google.zxing.client.j2se.BufferedImageLuminanceSource
import com.google.zxing.common.HybridBinarizer
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Graphics
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.JFrame
import javax.swing.JLabel
import javax.swing.JPanel
import javax.swing.SwingConstants
import javax.swing.SwingUtilities
fun decodeQrFromImage(image: BufferedImage): String? =
private fun decodeQrFromImage(image: BufferedImage): String? =
try {
val source = BufferedImageLuminanceSource(image)
val bitmap = BinaryBitmap(HybridBinarizer(source))
@@ -45,76 +50,132 @@ fun decodeQrFromImage(image: BufferedImage): String? =
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.
* 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).
* Opens a webcam scanner dialog using ffmpeg to capture frames.
* Continuously decodes frames looking for QR codes.
* Returns decoded text when found, or null if cancelled / no webcam.
* Must be called off the EDT (e.g. Dispatchers.IO).
*/
fun scanQrFromWebcam(): String? {
val webcam =
try {
Webcam.getDefault()
} catch (_: Exception) {
null
} ?: return null
val ffmpeg = findFfmpeg() ?: return null
webcam.viewSize =
webcam.viewSizes
.filter { it.width <= 1280 }
.maxByOrNull { it.width * it.height }
?: webcam.viewSizes.first()
val result = AtomicReference<String?>(null)
val running = AtomicBoolean(true)
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)
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(imagePanel, BorderLayout.CENTER)
dialog.add(statusLabel, BorderLayout.SOUTH)
dialog.preferredSize = Dimension(640, 520)
dialog.pack()
dialog.setLocationRelativeTo(null)
// Scanning thread — reads frames and decodes
val scanThread =
dialog.addWindowListener(
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 {
panel.start()
while (result == null && webcam.isOpen && dialog.isVisible) {
while (running.get()) {
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)
if (decoded != null) {
result = decoded
result.set(decoded)
running.set(false)
SwingUtilities.invokeLater { dialog.dispose() }
break
}
} catch (_: Exception) {
// webcam may throw during shutdown
if (!running.get()) break
Thread.sleep(500)
}
Thread.sleep(150)
}
}
scanThread.isDaemon = true
scanThread.start()
captureThread.isDaemon = true
captureThread.start()
// Blocks until dialog is closed (modal)
dialog.isVisible = true
running.set(false)
// Cleanup
try {
panel.stop()
webcam.close()
} catch (_: Exception) {
}
return result
return result.get()
}
-2
View File
@@ -57,7 +57,6 @@ 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"
@@ -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" }
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" }