mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-07-24 17:20:51 +02:00
feat: add barcode scanner plugin (#536)
This commit is contained in:
committed by
GitHub
parent
eccd6f977a
commit
454428cd50
@@ -0,0 +1,10 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
Package.resolved
|
||||
@@ -0,0 +1,35 @@
|
||||
// swift-tools-version:5.3
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "tauri-plugin-barcode-scanner",
|
||||
platforms: [
|
||||
.iOS(.v13)
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
.library(
|
||||
name: "tauri-plugin-barcode-scanner",
|
||||
type: .static,
|
||||
targets: ["tauri-plugin-barcode-scanner"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "Tauri", path: "../.tauri/tauri-api")
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "tauri-plugin-barcode-scanner",
|
||||
dependencies: [
|
||||
.byName(name: "Tauri")
|
||||
],
|
||||
path: "Sources")
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
# Tauri Plugin Barcode Scanner
|
||||
|
||||
A description of this package.
|
||||
@@ -0,0 +1,300 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import AVFoundation
|
||||
import Tauri
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
enum SupportedFormat: String, CaseIterable {
|
||||
// UPC_A not supported
|
||||
case UPC_E
|
||||
case EAN_8
|
||||
case EAN_13
|
||||
case CODE_39
|
||||
case CODE_93
|
||||
case CODE_128
|
||||
// CODABAR not supported
|
||||
case ITF
|
||||
case AZTEC
|
||||
case DATA_MATRIX
|
||||
case PDF_417
|
||||
case QR_CODE
|
||||
|
||||
var value: AVMetadataObject.ObjectType {
|
||||
switch self {
|
||||
case .UPC_E: return AVMetadataObject.ObjectType.upce
|
||||
case .EAN_8: return AVMetadataObject.ObjectType.ean8
|
||||
case .EAN_13: return AVMetadataObject.ObjectType.ean13
|
||||
case .CODE_39: return AVMetadataObject.ObjectType.code39
|
||||
case .CODE_93: return AVMetadataObject.ObjectType.code93
|
||||
case .CODE_128: return AVMetadataObject.ObjectType.code128
|
||||
case .ITF: return AVMetadataObject.ObjectType.interleaved2of5
|
||||
case .AZTEC: return AVMetadataObject.ObjectType.aztec
|
||||
case .DATA_MATRIX: return AVMetadataObject.ObjectType.dataMatrix
|
||||
case .PDF_417: return AVMetadataObject.ObjectType.pdf417
|
||||
case .QR_CODE: return AVMetadataObject.ObjectType.qr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CaptureError: Error {
|
||||
case backCameraUnavailable
|
||||
case frontCameraUnavailable
|
||||
case couldNotCaptureInput(error: NSError)
|
||||
}
|
||||
|
||||
class BarcodeScannerPlugin: Plugin, AVCaptureMetadataOutputObjectsDelegate {
|
||||
var webView: WKWebView!
|
||||
var cameraView: CameraView!
|
||||
var captureSession: AVCaptureSession?
|
||||
var captureVideoPreviewLayer: AVCaptureVideoPreviewLayer?
|
||||
var metaOutput: AVCaptureMetadataOutput?
|
||||
|
||||
var currentCamera = 0
|
||||
var frontCamera: AVCaptureDevice?
|
||||
var backCamera: AVCaptureDevice?
|
||||
|
||||
var isScanning = false
|
||||
|
||||
var windowed = false
|
||||
var previousBackgroundColor: UIColor? = UIColor.white
|
||||
|
||||
var invoke: Invoke? = nil
|
||||
|
||||
var scanFormats = [AVMetadataObject.ObjectType]()
|
||||
|
||||
public override func load(webview: WKWebView) {
|
||||
self.webView = webview
|
||||
loadCamera()
|
||||
}
|
||||
|
||||
private func loadCamera() {
|
||||
cameraView = CameraView(
|
||||
frame: CGRect(
|
||||
x: 0, y: 0, width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height))
|
||||
cameraView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
}
|
||||
|
||||
public func metadataOutput(
|
||||
_ captureOutput: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection
|
||||
) {
|
||||
if metadataObjects.count == 0 || !self.isScanning {
|
||||
// while nothing is detected, or if scanning is false, do nothing.
|
||||
return
|
||||
}
|
||||
|
||||
let found = metadataObjects[0] as! AVMetadataMachineReadableCodeObject
|
||||
if scanFormats.contains(found.type) {
|
||||
var jsObject: JsonObject = [:]
|
||||
|
||||
jsObject["format"] = formatStringFromMetadata(found.type)
|
||||
if found.stringValue != nil {
|
||||
jsObject["content"] = found.stringValue
|
||||
}
|
||||
|
||||
invoke?.resolve(jsObject)
|
||||
destroy()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private func setupCamera(direction: String, windowed: Bool) {
|
||||
do {
|
||||
var cameraDirection = direction
|
||||
cameraView.backgroundColor = UIColor.clear
|
||||
if windowed {
|
||||
webView.superview?.insertSubview(cameraView, belowSubview: webView)
|
||||
} else {
|
||||
webView.superview?.insertSubview(cameraView, aboveSubview: webView)
|
||||
}
|
||||
|
||||
let availableVideoDevices = discoverCaptureDevices()
|
||||
for device in availableVideoDevices {
|
||||
if device.position == AVCaptureDevice.Position.back {
|
||||
backCamera = device
|
||||
} else if device.position == AVCaptureDevice.Position.front {
|
||||
frontCamera = device
|
||||
}
|
||||
}
|
||||
|
||||
// older iPods have no back camera
|
||||
if cameraDirection == "back" {
|
||||
if backCamera == nil {
|
||||
cameraDirection = "front"
|
||||
}
|
||||
} else {
|
||||
if frontCamera == nil {
|
||||
cameraDirection = "back"
|
||||
}
|
||||
}
|
||||
|
||||
let input: AVCaptureDeviceInput
|
||||
input = try createCaptureDeviceInput(
|
||||
cameraDirection: cameraDirection, backCamera: backCamera, frontCamera: frontCamera)
|
||||
captureSession = AVCaptureSession()
|
||||
captureSession!.addInput(input)
|
||||
metaOutput = AVCaptureMetadataOutput()
|
||||
captureSession!.addOutput(metaOutput!)
|
||||
metaOutput!.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||
captureVideoPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession!)
|
||||
cameraView.addPreviewLayer(captureVideoPreviewLayer)
|
||||
|
||||
self.windowed = windowed
|
||||
if windowed {
|
||||
self.previousBackgroundColor = self.webView.backgroundColor
|
||||
self.webView.isOpaque = false
|
||||
self.webView.backgroundColor = UIColor.clear
|
||||
self.webView.scrollView.backgroundColor = UIColor.clear
|
||||
}
|
||||
} catch CaptureError.backCameraUnavailable {
|
||||
//
|
||||
} catch CaptureError.frontCameraUnavailable {
|
||||
//
|
||||
} catch CaptureError.couldNotCaptureInput {
|
||||
//
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
private func dismantleCamera() {
|
||||
if self.captureSession != nil {
|
||||
self.captureSession!.stopRunning()
|
||||
self.cameraView.removePreviewLayer()
|
||||
self.captureVideoPreviewLayer = nil
|
||||
self.metaOutput = nil
|
||||
self.captureSession = nil
|
||||
self.frontCamera = nil
|
||||
self.backCamera = nil
|
||||
}
|
||||
|
||||
self.isScanning = false
|
||||
}
|
||||
|
||||
private func destroy() {
|
||||
dismantleCamera()
|
||||
invoke = nil
|
||||
if windowed {
|
||||
let backgroundColor = previousBackgroundColor ?? UIColor.white
|
||||
webView.isOpaque = true
|
||||
webView.backgroundColor = backgroundColor
|
||||
webView.scrollView.backgroundColor = backgroundColor
|
||||
}
|
||||
}
|
||||
|
||||
private func getPermissionState() -> String {
|
||||
var permissionState: String
|
||||
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
permissionState = "granted"
|
||||
case .denied:
|
||||
permissionState = "denied"
|
||||
default:
|
||||
permissionState = "prompt"
|
||||
}
|
||||
|
||||
return permissionState
|
||||
}
|
||||
|
||||
@objc override func checkPermissions(_ invoke: Invoke) {
|
||||
let permissionState = getPermissionState()
|
||||
invoke.resolve(["camera": permissionState])
|
||||
}
|
||||
|
||||
@objc override func requestPermissions(_ invoke: Invoke) {
|
||||
let state = getPermissionState()
|
||||
if state == "prompt" {
|
||||
AVCaptureDevice.requestAccess(for: .video) { (authorized) in
|
||||
invoke.resolve(["camera": authorized ? "granted" : "denied"])
|
||||
}
|
||||
} else {
|
||||
invoke.resolve(["camera": state])
|
||||
}
|
||||
}
|
||||
|
||||
@objc func openAppSettings(_ invoke: Invoke) {
|
||||
guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if UIApplication.shared.canOpenURL(settingsUrl) {
|
||||
UIApplication.shared.open(
|
||||
settingsUrl,
|
||||
completionHandler: { (success) in
|
||||
invoke.resolve()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runScanner(_ invoke: Invoke) {
|
||||
scanFormats = [AVMetadataObject.ObjectType]()
|
||||
|
||||
if (invoke.data["formats"]) != nil {
|
||||
let _scanFormats = invoke.getArray("formats", String.self)
|
||||
|
||||
if _scanFormats != nil && _scanFormats?.count ?? 0 > 0 {
|
||||
_scanFormats?.forEach { targetedFormat in
|
||||
if let value = SupportedFormat(rawValue: targetedFormat)?.value {
|
||||
scanFormats.append(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if scanFormats.count == 0 {
|
||||
for supportedFormat in SupportedFormat.allCases {
|
||||
scanFormats.append(supportedFormat.value)
|
||||
}
|
||||
}
|
||||
|
||||
self.metaOutput!.metadataObjectTypes = self.scanFormats
|
||||
self.captureSession!.startRunning()
|
||||
|
||||
self.isScanning = true
|
||||
}
|
||||
|
||||
@objc private func scan(_ invoke: Invoke) {
|
||||
self.invoke = invoke
|
||||
|
||||
var iOS14min: Bool = false
|
||||
if #available(iOS 14.0, *) { iOS14min = true }
|
||||
if !iOS14min && self.getPermissionState() != "granted" {
|
||||
var authorized = false
|
||||
AVCaptureDevice.requestAccess(for: .video) { (isAuthorized) in
|
||||
authorized = isAuthorized
|
||||
}
|
||||
if !authorized {
|
||||
invoke.reject("denied by the user")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [self] in
|
||||
self.loadCamera()
|
||||
self.dismantleCamera()
|
||||
self.setupCamera(
|
||||
direction: invoke.getString("cameraDirection") ?? "back",
|
||||
windowed: invoke.getBool("windowed") ?? false
|
||||
)
|
||||
self.runScanner(invoke)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func cancel(_ invoke: Invoke) {
|
||||
self.invoke?.reject("cancelled")
|
||||
|
||||
destroy()
|
||||
invoke.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@_cdecl("init_plugin_barcode_scanner")
|
||||
func initPlugin() -> Plugin {
|
||||
return BarcodeScannerPlugin()
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
class CameraView: UIView {
|
||||
var videoPreviewLayer: AVCaptureVideoPreviewLayer?
|
||||
|
||||
func interfaceOrientationToVideoOrientation(_ orientation: UIInterfaceOrientation)
|
||||
-> AVCaptureVideoOrientation
|
||||
{
|
||||
switch orientation {
|
||||
case UIInterfaceOrientation.portrait:
|
||||
return AVCaptureVideoOrientation.portrait
|
||||
case UIInterfaceOrientation.portraitUpsideDown:
|
||||
return AVCaptureVideoOrientation.portraitUpsideDown
|
||||
case UIInterfaceOrientation.landscapeLeft:
|
||||
return AVCaptureVideoOrientation.landscapeLeft
|
||||
case UIInterfaceOrientation.landscapeRight:
|
||||
return AVCaptureVideoOrientation.landscapeRight
|
||||
default:
|
||||
return AVCaptureVideoOrientation.portraitUpsideDown
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
if let sublayers = self.layer.sublayers {
|
||||
for layer in sublayers {
|
||||
layer.frame = self.bounds
|
||||
}
|
||||
}
|
||||
|
||||
if let interfaceOrientation = UIApplication.shared.windows.first(where: { $0.isKeyWindow })?
|
||||
.windowScene?.interfaceOrientation
|
||||
{
|
||||
self.videoPreviewLayer?.connection?.videoOrientation = interfaceOrientationToVideoOrientation(
|
||||
interfaceOrientation)
|
||||
}
|
||||
}
|
||||
|
||||
func addPreviewLayer(_ previewLayer: AVCaptureVideoPreviewLayer?) {
|
||||
previewLayer!.videoGravity = AVLayerVideoGravity.resizeAspectFill
|
||||
previewLayer!.frame = self.bounds
|
||||
self.layer.addSublayer(previewLayer!)
|
||||
self.videoPreviewLayer = previewLayer
|
||||
}
|
||||
|
||||
func removePreviewLayer() {
|
||||
if self.videoPreviewLayer != nil {
|
||||
self.videoPreviewLayer!.removeFromSuperlayer()
|
||||
self.videoPreviewLayer = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import AVFoundation
|
||||
|
||||
func createCaptureDeviceInput(
|
||||
cameraDirection: String, backCamera: AVCaptureDevice?, frontCamera: AVCaptureDevice?
|
||||
) throws
|
||||
-> AVCaptureDeviceInput
|
||||
{
|
||||
var captureDevice: AVCaptureDevice
|
||||
if cameraDirection == "back" {
|
||||
if backCamera != nil {
|
||||
captureDevice = backCamera!
|
||||
} else {
|
||||
throw CaptureError.backCameraUnavailable
|
||||
}
|
||||
} else {
|
||||
if frontCamera != nil {
|
||||
captureDevice = frontCamera!
|
||||
} else {
|
||||
throw CaptureError.frontCameraUnavailable
|
||||
}
|
||||
}
|
||||
let captureDeviceInput: AVCaptureDeviceInput
|
||||
do {
|
||||
captureDeviceInput = try AVCaptureDeviceInput(device: captureDevice)
|
||||
} catch let error as NSError {
|
||||
throw CaptureError.couldNotCaptureInput(error: error)
|
||||
}
|
||||
return captureDeviceInput
|
||||
}
|
||||
|
||||
func discoverCaptureDevices() -> [AVCaptureDevice] {
|
||||
if #available(iOS 13.0, *) {
|
||||
return AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [
|
||||
.builtInTripleCamera, .builtInDualCamera, .builtInTelephotoCamera,
|
||||
.builtInTrueDepthCamera,
|
||||
.builtInUltraWideCamera, .builtInDualWideCamera, .builtInWideAngleCamera,
|
||||
], mediaType: .video, position: .unspecified
|
||||
).devices
|
||||
} else {
|
||||
return AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [
|
||||
.builtInDualCamera, .builtInWideAngleCamera, .builtInTelephotoCamera,
|
||||
.builtInTrueDepthCamera,
|
||||
], mediaType: .video, position: .unspecified
|
||||
).devices
|
||||
}
|
||||
}
|
||||
|
||||
func formatStringFromMetadata(_ type: AVMetadataObject.ObjectType) -> String {
|
||||
switch type {
|
||||
case AVMetadataObject.ObjectType.upce:
|
||||
return "UPC_E"
|
||||
case AVMetadataObject.ObjectType.ean8:
|
||||
return "EAN_8"
|
||||
case AVMetadataObject.ObjectType.ean13:
|
||||
return "EAN_13"
|
||||
case AVMetadataObject.ObjectType.code39:
|
||||
return "CODE_39"
|
||||
case AVMetadataObject.ObjectType.code93:
|
||||
return "CODE_93"
|
||||
case AVMetadataObject.ObjectType.code128:
|
||||
return "CODE_128"
|
||||
case AVMetadataObject.ObjectType.interleaved2of5:
|
||||
return "ITF"
|
||||
case AVMetadataObject.ObjectType.aztec:
|
||||
return "AZTEC"
|
||||
case AVMetadataObject.ObjectType.dataMatrix:
|
||||
return "DATA_MATRIX"
|
||||
case AVMetadataObject.ObjectType.pdf417:
|
||||
return "PDF_417"
|
||||
case AVMetadataObject.ObjectType.qr:
|
||||
return "QR_CODE"
|
||||
default:
|
||||
return type.rawValue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import ExamplePlugin
|
||||
|
||||
final class ExamplePluginTests: XCTestCase {
|
||||
func testExample() throws {
|
||||
let plugin = ExamplePlugin()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user