Add QR scanner support for macOS with camera selection
- Refactor QR scanner into separate Scanner module for iOS and macOS - Add camera selection menu for devices with multiple cameras - Use Vision framework for QR detection on macOS - Remove tvOS QR scanner support (continuity camera) - Add camera entitlement and usage description for macOS - Fix minor issues in Intents and ExtensionProvider
This commit is contained in:
@@ -9,14 +9,15 @@ public struct NewProfileMenuView: View {
|
|||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var alert: AlertState?
|
@State private var alert: AlertState?
|
||||||
@State private var showFileImporter = false
|
|
||||||
@State private var importRequest: NewProfileView.ImportRequest?
|
@State private var importRequest: NewProfileView.ImportRequest?
|
||||||
@State private var localImportRequest: NewProfileView.LocalImportRequest?
|
@State private var localImportRequest: NewProfileView.LocalImportRequest?
|
||||||
#if os(iOS)
|
#if os(tvOS)
|
||||||
@State private var showQRScanner = false
|
|
||||||
#elseif os(tvOS)
|
|
||||||
@State private var importCompleted = false
|
@State private var importCompleted = false
|
||||||
#elseif os(macOS)
|
#else
|
||||||
|
@State private var showFileImporter = false
|
||||||
|
@State private var showQRScanner = false
|
||||||
|
#endif
|
||||||
|
#if os(macOS)
|
||||||
@State private var showNewProfile = false
|
@State private var showNewProfile = false
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -76,6 +77,19 @@ public struct NewProfileMenuView: View {
|
|||||||
})
|
})
|
||||||
.environmentObject(environments)
|
.environmentObject(environments)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showQRScanner) {
|
||||||
|
QRScannerView { result in
|
||||||
|
handleQRScanResult(result)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 500, minHeight: 400)
|
||||||
|
}
|
||||||
|
.sheet(item: $importRequest) { request in
|
||||||
|
NewProfileView(request, onSuccess: { profile in
|
||||||
|
await SharedPreferences.selectedProfileID.set(profile.mustID)
|
||||||
|
dismiss()
|
||||||
|
})
|
||||||
|
.environmentObject(environments)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -117,10 +131,10 @@ public struct NewProfileMenuView: View {
|
|||||||
handleFileImport(result)
|
handleFileImport(result)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
#if !os(tvOS)
|
||||||
.sheet(isPresented: $showQRScanner) {
|
.sheet(isPresented: $showQRScanner) {
|
||||||
QRCodeScannerView { remoteProfile in
|
QRScannerView { result in
|
||||||
importRequest = NewProfileView.ImportRequest(name: remoteProfile.name, url: remoteProfile.url)
|
handleQRScanResult(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -148,7 +162,7 @@ public struct NewProfileMenuView: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(iOS)
|
#if !os(tvOS)
|
||||||
FormButton {
|
FormButton {
|
||||||
showQRScanner = true
|
showQRScanner = true
|
||||||
} label: {
|
} label: {
|
||||||
@@ -214,4 +228,26 @@ public struct NewProfileMenuView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
private func handleQRScanResult(_ result: QRScanResult) {
|
||||||
|
var error: NSError?
|
||||||
|
let remoteProfile = LibboxParseRemoteProfileImportLink(result.string, &error)
|
||||||
|
if let error {
|
||||||
|
alert = AlertState(
|
||||||
|
title: String(localized: "Invalid QR Code"),
|
||||||
|
message: error.localizedDescription
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let remoteProfile else {
|
||||||
|
alert = AlertState(
|
||||||
|
title: String(localized: "Invalid QR Code"),
|
||||||
|
message: String(localized: "The QR code does not contain a valid profile import link.")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
importRequest = NewProfileView.ImportRequest(name: remoteProfile.name, url: remoteProfile.url)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,13 +135,11 @@ public struct NewProfileView: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
private var macOSBody: some View {
|
private var macOSBody: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
if !viewModel.isImport {
|
Text(viewModel.isImport ? "Import Profile" : "New Profile")
|
||||||
Text("New Profile")
|
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
}
|
|
||||||
|
|
||||||
formContent
|
formContent
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
#if os(iOS)
|
|
||||||
|
|
||||||
import AVFoundation
|
|
||||||
import CodeScanner
|
|
||||||
import Libbox
|
|
||||||
import Library
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
public struct QRCodeScannerView: View {
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
@State private var alert: AlertState?
|
|
||||||
|
|
||||||
private let onScan: (LibboxImportRemoteProfile) -> Void
|
|
||||||
|
|
||||||
public init(onScan: @escaping (LibboxImportRemoteProfile) -> Void) {
|
|
||||||
self.onScan = onScan
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some View {
|
|
||||||
NavigationStackCompat {
|
|
||||||
CodeScannerView(codeTypes: [.qr], showViewfinder: true) { response in
|
|
||||||
handleScan(response)
|
|
||||||
}
|
|
||||||
.ignoresSafeArea()
|
|
||||||
.navigationTitle("Scan QR Code")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
|
||||||
Button("Cancel") { dismiss() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alert($alert)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleScan(_ result: Result<ScanResult, ScanError>) {
|
|
||||||
switch result {
|
|
||||||
case let .success(scanResult):
|
|
||||||
var error: NSError?
|
|
||||||
let remoteProfile = LibboxParseRemoteProfileImportLink(scanResult.string, &error)
|
|
||||||
if let error {
|
|
||||||
alert = AlertState(title: String(localized: "Invalid QR Code"), message: error.localizedDescription)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let remoteProfile else {
|
|
||||||
alert = AlertState(title: String(localized: "Invalid QR Code"), message: String(localized: "The QR code does not contain a valid profile import link."))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dismiss()
|
|
||||||
onScan(remoteProfile)
|
|
||||||
case let .failure(error):
|
|
||||||
switch error {
|
|
||||||
case .permissionDenied:
|
|
||||||
alert = AlertState(title: String(localized: "Camera Access Denied"), message: String(localized: "Please enable camera access in Settings to scan QR codes."))
|
|
||||||
default:
|
|
||||||
alert = AlertState(title: String(localized: "Scanner Error"), message: String(describing: error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public enum QRScanError: Error, LocalizedError {
|
||||||
|
case cameraUnavailable
|
||||||
|
case permissionDenied
|
||||||
|
case scanFailed(Error)
|
||||||
|
case invalidCode
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .cameraUnavailable:
|
||||||
|
return String(localized: "Camera is not available")
|
||||||
|
case .permissionDenied:
|
||||||
|
return String(localized: "Camera access denied")
|
||||||
|
case let .scanFailed(error):
|
||||||
|
return error.localizedDescription
|
||||||
|
case .invalidCode:
|
||||||
|
return String(localized: "Invalid QR code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct QRScanResult: Sendable {
|
||||||
|
public let string: String
|
||||||
|
public let type: AVMetadataObject.ObjectType
|
||||||
|
|
||||||
|
public init(string: String, type: AVMetadataObject.ObjectType) {
|
||||||
|
self.string = string
|
||||||
|
self.type = type
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
#if os(iOS)
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class QRScannerController: NSObject, ObservableObject {
|
||||||
|
private var captureSession: AVCaptureSession?
|
||||||
|
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
private var metadataOutput: AVCaptureMetadataOutput?
|
||||||
|
private var didFinishScanning = false
|
||||||
|
|
||||||
|
@Published var availableCameras: [AVCaptureDevice] = []
|
||||||
|
@Published var selectedCamera: AVCaptureDevice?
|
||||||
|
|
||||||
|
let previewView = UIView()
|
||||||
|
var onScan: ((Result<QRScanResult, QRScanError>) -> Void)?
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
previewView.backgroundColor = .black
|
||||||
|
refreshCameraList()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshCameraList() {
|
||||||
|
var deviceTypes: [AVCaptureDevice.DeviceType] = [
|
||||||
|
.builtInWideAngleCamera,
|
||||||
|
.builtInUltraWideCamera,
|
||||||
|
.builtInTelephotoCamera,
|
||||||
|
]
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
deviceTypes.append(.external)
|
||||||
|
}
|
||||||
|
let discoverySession = AVCaptureDevice.DiscoverySession(
|
||||||
|
deviceTypes: deviceTypes,
|
||||||
|
mediaType: .video,
|
||||||
|
position: .unspecified
|
||||||
|
)
|
||||||
|
availableCameras = discoverySession.devices
|
||||||
|
if selectedCamera == nil {
|
||||||
|
selectedCamera = availableCameras.first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectCamera(_ camera: AVCaptureDevice) {
|
||||||
|
guard camera.uniqueID != selectedCamera?.uniqueID else { return }
|
||||||
|
selectedCamera = camera
|
||||||
|
didFinishScanning = false
|
||||||
|
|
||||||
|
if captureSession != nil {
|
||||||
|
stopScanning()
|
||||||
|
captureSession = nil
|
||||||
|
previewLayer?.removeFromSuperlayer()
|
||||||
|
previewLayer = nil
|
||||||
|
startScanning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startScanning() {
|
||||||
|
guard captureSession == nil else {
|
||||||
|
if captureSession?.isRunning == false {
|
||||||
|
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||||
|
self?.captureSession?.startRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||||
|
case .authorized:
|
||||||
|
setupCaptureSession()
|
||||||
|
case .notDetermined:
|
||||||
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if granted {
|
||||||
|
self?.setupCaptureSession()
|
||||||
|
} else {
|
||||||
|
self?.onScan?(.failure(.permissionDenied))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .denied, .restricted:
|
||||||
|
onScan?(.failure(.permissionDenied))
|
||||||
|
@unknown default:
|
||||||
|
onScan?(.failure(.cameraUnavailable))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopScanning() {
|
||||||
|
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||||
|
self?.captureSession?.stopRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
didFinishScanning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupCaptureSession() {
|
||||||
|
let session = AVCaptureSession()
|
||||||
|
|
||||||
|
guard let videoCaptureDevice = selectedCamera ?? AVCaptureDevice.default(for: .video) else {
|
||||||
|
onScan?(.failure(.cameraUnavailable))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let videoInput: AVCaptureDeviceInput
|
||||||
|
do {
|
||||||
|
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
|
||||||
|
} catch {
|
||||||
|
onScan?(.failure(.scanFailed(error)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard session.canAddInput(videoInput) else {
|
||||||
|
onScan?(.failure(.cameraUnavailable))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.addInput(videoInput)
|
||||||
|
|
||||||
|
let metadataOutput = AVCaptureMetadataOutput()
|
||||||
|
guard session.canAddOutput(metadataOutput) else {
|
||||||
|
onScan?(.failure(.cameraUnavailable))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.addOutput(metadataOutput)
|
||||||
|
metadataOutput.setMetadataObjectsDelegate(self, queue: .main)
|
||||||
|
metadataOutput.metadataObjectTypes = [.qr]
|
||||||
|
|
||||||
|
self.metadataOutput = metadataOutput
|
||||||
|
self.captureSession = session
|
||||||
|
|
||||||
|
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||||||
|
previewLayer.videoGravity = .resizeAspectFill
|
||||||
|
previewLayer.frame = previewView.bounds
|
||||||
|
previewView.layer.addSublayer(previewLayer)
|
||||||
|
self.previewLayer = previewLayer
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||||
|
self?.captureSession?.startRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePreviewFrame(_ frame: CGRect) {
|
||||||
|
previewLayer?.frame = frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension QRScannerController: AVCaptureMetadataOutputObjectsDelegate {
|
||||||
|
nonisolated func metadataOutput(
|
||||||
|
_ output: AVCaptureMetadataOutput,
|
||||||
|
didOutput metadataObjects: [AVMetadataObject],
|
||||||
|
from connection: AVCaptureConnection
|
||||||
|
) {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard !didFinishScanning,
|
||||||
|
let metadataObject = metadataObjects.first,
|
||||||
|
let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject,
|
||||||
|
let stringValue = readableObject.stringValue
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
didFinishScanning = true
|
||||||
|
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
|
||||||
|
|
||||||
|
let result = QRScanResult(string: stringValue, type: readableObject.type)
|
||||||
|
onScan?(.success(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QRScannerControllerView: UIViewControllerRepresentable {
|
||||||
|
let controller: QRScannerController
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIViewController {
|
||||||
|
let viewController = QRScannerViewController(controller: controller)
|
||||||
|
return viewController
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class QRScannerViewController: UIViewController {
|
||||||
|
let controller: QRScannerController
|
||||||
|
|
||||||
|
init(controller: QRScannerController) {
|
||||||
|
self.controller = controller
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .black
|
||||||
|
view.addSubview(controller.previewView)
|
||||||
|
controller.previewView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
controller.previewView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
controller.previewView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
controller.previewView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
controller.previewView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
controller.updatePreviewFrame(view.bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
controller.startScanning()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
controller.stopScanning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import AVFoundation
|
||||||
|
import SwiftUI
|
||||||
|
import Vision
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class QRScannerController: NSObject, ObservableObject {
|
||||||
|
private var captureSession: AVCaptureSession?
|
||||||
|
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
private var videoOutput: AVCaptureVideoDataOutput?
|
||||||
|
private var didFinishScanning = false
|
||||||
|
|
||||||
|
@Published var availableCameras: [AVCaptureDevice] = []
|
||||||
|
@Published var selectedCamera: AVCaptureDevice?
|
||||||
|
|
||||||
|
let previewView = NSView()
|
||||||
|
var onScan: ((Result<QRScanResult, QRScanError>) -> Void)?
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
previewView.wantsLayer = true
|
||||||
|
previewView.layer?.backgroundColor = NSColor.black.cgColor
|
||||||
|
refreshCameraList()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshCameraList() {
|
||||||
|
let discoverySession = AVCaptureDevice.DiscoverySession(
|
||||||
|
deviceTypes: [.builtInWideAngleCamera, .externalUnknown],
|
||||||
|
mediaType: .video,
|
||||||
|
position: .unspecified
|
||||||
|
)
|
||||||
|
availableCameras = discoverySession.devices
|
||||||
|
if selectedCamera == nil {
|
||||||
|
selectedCamera = availableCameras.first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectCamera(_ camera: AVCaptureDevice) {
|
||||||
|
guard camera.uniqueID != selectedCamera?.uniqueID else { return }
|
||||||
|
selectedCamera = camera
|
||||||
|
didFinishScanning = false
|
||||||
|
|
||||||
|
if captureSession != nil {
|
||||||
|
stopScanning()
|
||||||
|
captureSession = nil
|
||||||
|
previewLayer?.removeFromSuperlayer()
|
||||||
|
previewLayer = nil
|
||||||
|
startScanning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startScanning() {
|
||||||
|
guard captureSession == nil else {
|
||||||
|
if captureSession?.isRunning == false {
|
||||||
|
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||||
|
self?.captureSession?.startRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||||
|
case .authorized:
|
||||||
|
setupCaptureSession()
|
||||||
|
case .notDetermined:
|
||||||
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if granted {
|
||||||
|
self?.setupCaptureSession()
|
||||||
|
} else {
|
||||||
|
self?.onScan?(.failure(.permissionDenied))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .denied, .restricted:
|
||||||
|
onScan?(.failure(.permissionDenied))
|
||||||
|
@unknown default:
|
||||||
|
onScan?(.failure(.cameraUnavailable))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopScanning() {
|
||||||
|
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||||
|
self?.captureSession?.stopRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
didFinishScanning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupCaptureSession() {
|
||||||
|
let session = AVCaptureSession()
|
||||||
|
|
||||||
|
guard let videoCaptureDevice = selectedCamera ?? AVCaptureDevice.default(for: .video) else {
|
||||||
|
onScan?(.failure(.cameraUnavailable))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let videoInput: AVCaptureDeviceInput
|
||||||
|
do {
|
||||||
|
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
|
||||||
|
} catch {
|
||||||
|
onScan?(.failure(.scanFailed(error)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard session.canAddInput(videoInput) else {
|
||||||
|
onScan?(.failure(.cameraUnavailable))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.addInput(videoInput)
|
||||||
|
|
||||||
|
let videoOutput = AVCaptureVideoDataOutput()
|
||||||
|
videoOutput.videoSettings = [
|
||||||
|
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
||||||
|
]
|
||||||
|
videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "QRScannerQueue"))
|
||||||
|
|
||||||
|
guard session.canAddOutput(videoOutput) else {
|
||||||
|
onScan?(.failure(.cameraUnavailable))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.addOutput(videoOutput)
|
||||||
|
|
||||||
|
self.videoOutput = videoOutput
|
||||||
|
self.captureSession = session
|
||||||
|
|
||||||
|
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||||||
|
previewLayer.videoGravity = .resizeAspectFill
|
||||||
|
previewLayer.frame = previewView.bounds
|
||||||
|
previewView.layer?.addSublayer(previewLayer)
|
||||||
|
self.previewLayer = previewLayer
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||||
|
self?.captureSession?.startRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePreviewFrame(_ frame: CGRect) {
|
||||||
|
previewLayer?.frame = frame
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processQRCode(_ payloadString: String) {
|
||||||
|
guard !didFinishScanning else { return }
|
||||||
|
didFinishScanning = true
|
||||||
|
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
NSSound.beep()
|
||||||
|
let result = QRScanResult(string: payloadString, type: .qr)
|
||||||
|
self?.onScan?(.success(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension QRScannerController: AVCaptureVideoDataOutputSampleBufferDelegate {
|
||||||
|
nonisolated func captureOutput(
|
||||||
|
_ output: AVCaptureOutput,
|
||||||
|
didOutput sampleBuffer: CMSampleBuffer,
|
||||||
|
from connection: AVCaptureConnection
|
||||||
|
) {
|
||||||
|
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
||||||
|
|
||||||
|
let request = VNDetectBarcodesRequest { [weak self] request, _ in
|
||||||
|
guard let results = request.results as? [VNBarcodeObservation] else { return }
|
||||||
|
|
||||||
|
for result in results {
|
||||||
|
if result.symbology == .qr, let payload = result.payloadStringValue {
|
||||||
|
self?.processQRCode(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request.symbologies = [.qr]
|
||||||
|
|
||||||
|
let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:])
|
||||||
|
try? handler.perform([request])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QRScannerControllerView: NSViewControllerRepresentable {
|
||||||
|
let controller: QRScannerController
|
||||||
|
|
||||||
|
func makeNSViewController(context: Context) -> NSViewController {
|
||||||
|
let viewController = QRScannerViewController(controller: controller)
|
||||||
|
return viewController
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSViewController(_ nsViewController: NSViewController, context: Context) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class QRScannerViewController: NSViewController {
|
||||||
|
let controller: QRScannerController
|
||||||
|
|
||||||
|
init(controller: QRScannerController) {
|
||||||
|
self.controller = controller
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
view = NSView()
|
||||||
|
view.wantsLayer = true
|
||||||
|
view.layer?.backgroundColor = NSColor.black.cgColor
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.addSubview(controller.previewView)
|
||||||
|
controller.previewView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
controller.previewView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
controller.previewView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
controller.previewView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
controller.previewView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayout() {
|
||||||
|
super.viewDidLayout()
|
||||||
|
controller.updatePreviewFrame(view.bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear() {
|
||||||
|
super.viewWillAppear()
|
||||||
|
controller.startScanning()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear() {
|
||||||
|
super.viewWillDisappear()
|
||||||
|
controller.stopScanning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
#if !os(tvOS)
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import Library
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct QRScannerView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var alert: AlertState?
|
||||||
|
@StateObject private var controller = QRScannerController()
|
||||||
|
|
||||||
|
private let onScan: (QRScanResult) -> Void
|
||||||
|
|
||||||
|
public init(onScan: @escaping (QRScanResult) -> Void) {
|
||||||
|
self.onScan = onScan
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
#if os(iOS)
|
||||||
|
iOSBody
|
||||||
|
#elseif os(macOS)
|
||||||
|
macOSBody
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private var iOSBody: some View {
|
||||||
|
NavigationStackCompat {
|
||||||
|
QRScannerControllerView(controller: controller)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.navigationTitle("Scan QR Code")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Menu {
|
||||||
|
if controller.availableCameras.count > 1 {
|
||||||
|
Menu("Camera") {
|
||||||
|
ForEach(controller.availableCameras, id: \.uniqueID) { camera in
|
||||||
|
Button {
|
||||||
|
controller.selectCamera(camera)
|
||||||
|
} label: {
|
||||||
|
if camera.uniqueID == controller.selectedCamera?.uniqueID {
|
||||||
|
Label(camera.localizedName, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(camera.localizedName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert($alert)
|
||||||
|
.onAppear {
|
||||||
|
controller.onScan = handleScanResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
private var macOSBody: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
QRScannerControllerView(controller: controller)
|
||||||
|
.frame(minWidth: 400, minHeight: 300)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
if controller.availableCameras.count > 1 {
|
||||||
|
Picker("Camera", selection: Binding(
|
||||||
|
get: { controller.selectedCamera },
|
||||||
|
set: { camera in
|
||||||
|
if let camera {
|
||||||
|
controller.selectCamera(camera)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
ForEach(controller.availableCameras, id: \.uniqueID) { camera in
|
||||||
|
Text(camera.localizedName).tag(camera as AVCaptureDevice?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 250)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.alert($alert)
|
||||||
|
.onAppear {
|
||||||
|
controller.onScan = handleScanResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private func handleScanResult(_ result: Result<QRScanResult, QRScanError>) {
|
||||||
|
switch result {
|
||||||
|
case let .success(scanResult):
|
||||||
|
dismiss()
|
||||||
|
onScan(scanResult)
|
||||||
|
case let .failure(error):
|
||||||
|
switch error {
|
||||||
|
case .permissionDenied:
|
||||||
|
alert = AlertState(
|
||||||
|
title: String(localized: "Camera Access Denied"),
|
||||||
|
message: String(localized: "Please enable camera access in Settings to scan QR codes.")
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
alert = AlertState(
|
||||||
|
title: String(localized: "Scanner Error"),
|
||||||
|
message: error.localizedDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -144,7 +144,7 @@ struct GetCurrentProfile: AppIntent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func perform() async throws -> some IntentResult & ReturnsValue<String> {
|
func perform() async throws -> some IntentResult & ReturnsValue<String> {
|
||||||
guard let profile = try await ProfileManager.get(await SharedPreferences.selectedProfileID.get()) else {
|
guard let profile = try await ProfileManager.get(SharedPreferences.selectedProfileID.get()) else {
|
||||||
throw NSError(domain: "No profile selected", code: 0)
|
throw NSError(domain: "No profile selected", code: 0)
|
||||||
}
|
}
|
||||||
return .result(value: profile.name)
|
return .result(value: profile.name)
|
||||||
|
|||||||
@@ -249,7 +249,8 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc
|
|||||||
|
|
||||||
private func onUpdateDefaultInterface(_ listener: LibboxInterfaceUpdateListenerProtocol, _ path: Network.NWPath) {
|
private func onUpdateDefaultInterface(_ listener: LibboxInterfaceUpdateListenerProtocol, _ path: Network.NWPath) {
|
||||||
guard path.status != .unsatisfied,
|
guard path.status != .unsatisfied,
|
||||||
let defaultInterface = path.availableInterfaces.first else {
|
let defaultInterface = path.availableInterfaces.first
|
||||||
|
else {
|
||||||
listener.updateDefaultInterface("", interfaceIndex: -1, isExpensive: false, isConstrained: false)
|
listener.updateDefaultInterface("", interfaceIndex: -1, isExpensive: false, isConstrained: false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ open class ExtensionProvider: NEPacketTunnelProvider {
|
|||||||
private var commandServer: LibboxCommandServer!
|
private var commandServer: LibboxCommandServer!
|
||||||
private var platformInterface: ExtensionPlatformInterface!
|
private var platformInterface: ExtensionPlatformInterface!
|
||||||
|
|
||||||
override open func startTunnel(options startOptions: [String: NSObject]?) async throws {
|
override open func startTunnel(options _: [String: NSObject]?) async throws {
|
||||||
let options = LibboxSetupOptions()
|
let options = LibboxSetupOptions()
|
||||||
options.basePath = FilePath.sharedDirectory.relativePath
|
options.basePath = FilePath.sharedDirectory.relativePath
|
||||||
options.workingPath = FilePath.workingDirectory.relativePath
|
options.workingPath = FilePath.workingDirectory.relativePath
|
||||||
|
|||||||
@@ -30,5 +30,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.camera</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -115,5 +115,7 @@
|
|||||||
<string>sing-box uses the Location permission to provide users with routing based on WIFI SSID and BSSID rules, without reading your location.</string>
|
<string>sing-box uses the Location permission to provide users with routing based on WIFI SSID and BSSID rules, without reading your location.</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>As a universal proxy platform, sing-box configures routing according to your configuration.</string>
|
<string>As a universal proxy platform, sing-box configures routing according to your configuration.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Camera access is required to scan QR codes for importing profiles.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -28,5 +28,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.camera</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
3A017F922A4AB2E4009149FA /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 3A017F912A4AB2E4009149FA /* GRDB */; };
|
3A017F922A4AB2E4009149FA /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 3A017F912A4AB2E4009149FA /* GRDB */; };
|
||||||
3A096F8F2A4ED3DE00D4A2ED /* Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3A096F862A4ED3DE00D4A2ED /* Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
3A096F8F2A4ED3DE00D4A2ED /* Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3A096F862A4ED3DE00D4A2ED /* Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
3A1E8E692EDEC1AF00ADD104 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 3A1E8E682EDEC1AF00ADD104 /* CodeScanner */; };
|
|
||||||
3A2E87F22ED5A91100644195 /* Runestone in Frameworks */ = {isa = PBXBuildFile; productRef = 3A2E87F12ED5A91100644195 /* Runestone */; };
|
3A2E87F22ED5A91100644195 /* Runestone in Frameworks */ = {isa = PBXBuildFile; productRef = 3A2E87F12ED5A91100644195 /* Runestone */; };
|
||||||
3A2E87FB2ED5ABDA00644195 /* TreeSitterJSON5Runestone in Frameworks */ = {isa = PBXBuildFile; productRef = 3A2E87FA2ED5ABDA00644195 /* TreeSitterJSON5Runestone */; };
|
3A2E87FB2ED5ABDA00644195 /* TreeSitterJSON5Runestone in Frameworks */ = {isa = PBXBuildFile; productRef = 3A2E87FA2ED5ABDA00644195 /* TreeSitterJSON5Runestone */; };
|
||||||
3A3AA7FC2A4EFDAE002F78AB /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
|
3A3AA7FC2A4EFDAE002F78AB /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
|
||||||
@@ -564,7 +563,6 @@
|
|||||||
files = (
|
files = (
|
||||||
3A4EAD1B2A4FEB02005435B3 /* Library.framework in Frameworks */,
|
3A4EAD1B2A4FEB02005435B3 /* Library.framework in Frameworks */,
|
||||||
3A4A020D2B53E3DC004EFB87 /* QRCode in Frameworks */,
|
3A4A020D2B53E3DC004EFB87 /* QRCode in Frameworks */,
|
||||||
3A1E8E692EDEC1AF00ADD104 /* CodeScanner in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -803,7 +801,6 @@
|
|||||||
name = ApplicationLibrary;
|
name = ApplicationLibrary;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
3A4A020C2B53E3DC004EFB87 /* QRCode */,
|
3A4A020C2B53E3DC004EFB87 /* QRCode */,
|
||||||
3A1E8E682EDEC1AF00ADD104 /* CodeScanner */,
|
|
||||||
);
|
);
|
||||||
productName = ApplicationLibrary;
|
productName = ApplicationLibrary;
|
||||||
productReference = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */;
|
productReference = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */;
|
||||||
@@ -1140,7 +1137,6 @@
|
|||||||
3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */,
|
3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */,
|
||||||
3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */,
|
3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */,
|
||||||
3ACE5E012EE1A91100644196 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */,
|
3ACE5E012EE1A91100644196 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */,
|
||||||
3A1E8E672EDEC1AF00ADD104 /* XCRemoteSwiftPackageReference "CodeScanner" */,
|
|
||||||
);
|
);
|
||||||
productRefGroup = 3AEC20C72A45991900A63465 /* Products */;
|
productRefGroup = 3AEC20C72A45991900A63465 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -2750,14 +2746,6 @@
|
|||||||
minimumVersion = 6.15.1;
|
minimumVersion = 6.15.1;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
3A1E8E672EDEC1AF00ADD104 /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
|
|
||||||
isa = XCRemoteSwiftPackageReference;
|
|
||||||
repositoryURL = "https://github.com/twostraws/CodeScanner.git";
|
|
||||||
requirement = {
|
|
||||||
kind = upToNextMajorVersion;
|
|
||||||
minimumVersion = 2.5.2;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
3A4A020B2B53E3DC004EFB87 /* XCRemoteSwiftPackageReference "qrcode" */ = {
|
3A4A020B2B53E3DC004EFB87 /* XCRemoteSwiftPackageReference "qrcode" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/dagronf/qrcode.git";
|
repositoryURL = "https://github.com/dagronf/qrcode.git";
|
||||||
@@ -2798,11 +2786,6 @@
|
|||||||
package = 3A017F902A4AB2E4009149FA /* XCRemoteSwiftPackageReference "GRDB" */;
|
package = 3A017F902A4AB2E4009149FA /* XCRemoteSwiftPackageReference "GRDB" */;
|
||||||
productName = GRDB;
|
productName = GRDB;
|
||||||
};
|
};
|
||||||
3A1E8E682EDEC1AF00ADD104 /* CodeScanner */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = 3A1E8E672EDEC1AF00ADD104 /* XCRemoteSwiftPackageReference "CodeScanner" */;
|
|
||||||
productName = CodeScanner;
|
|
||||||
};
|
|
||||||
3A2E87F12ED5A91100644195 /* Runestone */ = {
|
3A2E87F12ED5A91100644195 /* Runestone */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */;
|
package = 3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */;
|
||||||
|
|||||||
@@ -46,15 +46,6 @@
|
|||||||
"version" : "0.12.1"
|
"version" : "0.12.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "codescanner",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/twostraws/CodeScanner.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "5e886430238944c7200fc9e10dbf2d9550dba865",
|
|
||||||
"version" : "2.5.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "grdb.swift",
|
"identity" : "grdb.swift",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
Reference in New Issue
Block a user