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