完善 OTG 相机有线传图:支持 Sony/Canon 自动识别,页面 detach 保持会话以便再次进入复连。
引入进程级 CameraTetheringSession 与共享 USB Browser;离开传图页仅取消 UI 回调,App 终止时再完整 shutdown 释放 PTP 与 Browser 资源。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,37 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// ImageCaptureCore 文件下载回调代理。
|
||||
final class CameraDownloadDelegate: NSObject, ICCameraDeviceDownloadDelegate {
|
||||
var onComplete: ((ICCameraFile, URL?, Error?) -> Void)?
|
||||
var onProgress: ((String, Double) -> Void)?
|
||||
|
||||
@objc func didDownloadFile(_ file: ICCameraFile, error: Error?, options: [String: Any], contextInfo: UnsafeMutableRawPointer?) {
|
||||
if let error {
|
||||
CameraTetheringLogger.log("下载失败 \(file.name ?? "?"): \(error.localizedDescription)", logger: CameraTetheringLogger.transfer)
|
||||
onComplete?(file, nil, error)
|
||||
return
|
||||
}
|
||||
|
||||
let downloadOptions = options as? [ICDownloadOption: Any] ?? [:]
|
||||
if let directory = downloadOptions[.downloadsDirectoryURL] as? URL,
|
||||
let filename = downloadOptions[.savedFilename] as? String {
|
||||
let url = directory.appendingPathComponent(filename)
|
||||
CameraTetheringLogger.log("下载完成 \(filename) -> \(url.path)", logger: CameraTetheringLogger.transfer)
|
||||
onComplete?(file, url, nil)
|
||||
} else if let urlString = file.value(forKey: "url") as? String {
|
||||
let url = URL(fileURLWithPath: urlString)
|
||||
CameraTetheringLogger.log("下载完成 \(file.name ?? "?") -> \(url.path)", logger: CameraTetheringLogger.transfer)
|
||||
onComplete?(file, url, nil)
|
||||
} else {
|
||||
CameraTetheringLogger.log("下载完成但无法定位路径: \(file.name ?? "?")", logger: CameraTetheringLogger.transfer)
|
||||
onComplete?(file, nil, CameraServiceError.downloadFailed("无法定位下载文件路径"))
|
||||
}
|
||||
}
|
||||
|
||||
@objc func didReceiveDownloadProgress(for file: ICCameraFile, downloadedBytes: off_t, maxBytes: off_t) {
|
||||
guard maxBytes > 0, let name = file.name else { return }
|
||||
onProgress?(name, Double(downloadedBytes) / Double(maxBytes))
|
||||
}
|
||||
}
|
||||
@ -10,14 +10,26 @@ import os
|
||||
|
||||
/// 相机有线传输模块日志工具,按 Camera / Transfer / PTP 分类输出。
|
||||
enum CameraTetheringLogger {
|
||||
/// 控制台统一前缀,便于 Xcode / Console.app 过滤:`[OTG]`
|
||||
static let logPrefix = "[OTG]"
|
||||
|
||||
private static let subsystem = Bundle.main.bundleIdentifier ?? "com.suixinkan"
|
||||
|
||||
static let camera = Logger(subsystem: subsystem, category: "Camera")
|
||||
static let transfer = Logger(subsystem: subsystem, category: "Transfer")
|
||||
static let ptp = Logger(subsystem: subsystem, category: "PTP")
|
||||
|
||||
/// 写入指定分类的 info 日志。
|
||||
/// 写入指定分类的 info 日志,消息前自动附加 `[OTG]` 前缀。
|
||||
static func log(_ message: String, logger: Logger = camera) {
|
||||
logger.info("\(message, privacy: .public)")
|
||||
let formatted = prefixed(message)
|
||||
logger.info("\(formatted, privacy: .public)")
|
||||
}
|
||||
|
||||
/// 为消息附加 OTG 统一前缀;若已含前缀则不再重复添加。
|
||||
static func prefixed(_ message: String) -> String {
|
||||
if message.hasPrefix(logPrefix) {
|
||||
return message
|
||||
}
|
||||
return "\(logPrefix) \(message)"
|
||||
}
|
||||
}
|
||||
|
||||
231
suixinkan/Core/CameraTethering/Utilities/PTPCommon.swift
Normal file
231
suixinkan/Core/CameraTethering/Utilities/PTPCommon.swift
Normal file
@ -0,0 +1,231 @@
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// PTP 容器类型。
|
||||
enum PTPContainerType: UInt16 {
|
||||
case command = 0x0001
|
||||
case data = 0x0002
|
||||
case response = 0x0003
|
||||
case event = 0x0004
|
||||
}
|
||||
|
||||
/// PTP 命令/响应/事件容器。
|
||||
struct PTPContainer {
|
||||
let length: UInt32
|
||||
let type: UInt16
|
||||
let code: UInt16
|
||||
let transactionID: UInt32
|
||||
let params: [UInt32]
|
||||
}
|
||||
|
||||
enum PTPParseError: Error {
|
||||
case tooShort
|
||||
case invalidLength
|
||||
}
|
||||
|
||||
/// 标准 PTP 响应码。
|
||||
enum PTPResponseCode: UInt16 {
|
||||
case ok = 0x2001
|
||||
case generalError = 0x2002
|
||||
case sessionNotOpen = 0x2003
|
||||
case invalidTransaction = 0x2004
|
||||
case operationNotSupported = 0x2005
|
||||
}
|
||||
|
||||
/// PTP 命令发送结果。
|
||||
struct PTPCommandResult {
|
||||
let success: Bool
|
||||
let response: Data
|
||||
let inData: Data
|
||||
}
|
||||
|
||||
/// 各品牌共用的 PTP 编解码与命令发送工具。
|
||||
enum PTPHelper {
|
||||
static func buildCommand(
|
||||
code: UInt16,
|
||||
transactionID: UInt32,
|
||||
parameters: [UInt32] = []
|
||||
) -> Data {
|
||||
let length = UInt32(12 + parameters.count * 4)
|
||||
var data = Data()
|
||||
appendUInt32LE(length, to: &data)
|
||||
appendUInt16LE(PTPContainerType.command.rawValue, to: &data)
|
||||
appendUInt16LE(code, to: &data)
|
||||
appendUInt32LE(transactionID, to: &data)
|
||||
for parameter in parameters {
|
||||
appendUInt32LE(parameter, to: &data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
static func parseContainer(_ data: Data) throws -> PTPContainer {
|
||||
guard data.count >= 12 else { throw PTPParseError.tooShort }
|
||||
|
||||
let length = readUInt32LE(data, offset: 0)
|
||||
guard length >= 12, data.count >= Int(length) else { throw PTPParseError.invalidLength }
|
||||
|
||||
let type = readUInt16LE(data, offset: 4)
|
||||
let code = readUInt16LE(data, offset: 6)
|
||||
let transactionID = readUInt32LE(data, offset: 8)
|
||||
|
||||
var params: [UInt32] = []
|
||||
var offset = 12
|
||||
while offset + 4 <= Int(length) {
|
||||
params.append(readUInt32LE(data, offset: offset))
|
||||
offset += 4
|
||||
}
|
||||
|
||||
return PTPContainer(
|
||||
length: length,
|
||||
type: type,
|
||||
code: code,
|
||||
transactionID: transactionID,
|
||||
params: params
|
||||
)
|
||||
}
|
||||
|
||||
static func parseEventCode(from eventData: Data) -> UInt16? {
|
||||
guard let container = try? parseContainer(eventData) else { return nil }
|
||||
return container.code
|
||||
}
|
||||
|
||||
static func parseObjectCompressedSize(_ data: Data) -> UInt32? {
|
||||
guard data.count >= 12 else { return nil }
|
||||
return readUInt32LE(data, offset: 8)
|
||||
}
|
||||
|
||||
static func parseObjectInfoFilename(_ data: Data) -> String? {
|
||||
guard data.count >= 53 else { return nil }
|
||||
|
||||
if let filename = parsePTPString(data, offset: 52)?.string, !filename.isEmpty {
|
||||
return CameraDownloadStorage.sanitizeFilename(filename)
|
||||
}
|
||||
|
||||
var offset = 52
|
||||
var codeUnits: [UInt16] = []
|
||||
while offset + 1 < data.count {
|
||||
let unit = readUInt16LE(data, offset: offset)
|
||||
if unit == 0 { break }
|
||||
codeUnits.append(unit)
|
||||
offset += 2
|
||||
}
|
||||
|
||||
guard !codeUnits.isEmpty else { return nil }
|
||||
let raw = String(utf16CodeUnits: codeUnits, count: codeUnits.count)
|
||||
return CameraDownloadStorage.sanitizeFilename(raw)
|
||||
}
|
||||
|
||||
static func parsePTPString(_ data: Data, offset start: Int) -> (string: String, nextOffset: Int)? {
|
||||
guard start < data.count else { return nil }
|
||||
let charCount = Int(data[start])
|
||||
var offset = start + 1
|
||||
guard charCount > 0, offset + charCount * 2 <= data.count else { return nil }
|
||||
|
||||
var codeUnits: [UInt16] = []
|
||||
codeUnits.reserveCapacity(charCount)
|
||||
for index in 0..<charCount {
|
||||
codeUnits.append(readUInt16LE(data, offset: offset + index * 2))
|
||||
}
|
||||
offset += charCount * 2
|
||||
|
||||
let string = String(utf16CodeUnits: codeUnits, count: codeUnits.count)
|
||||
return (string, offset)
|
||||
}
|
||||
|
||||
static func isLikelyImageData(_ data: Data) -> Bool {
|
||||
guard data.count >= 3 else { return false }
|
||||
if data[0] == 0xFF, data[1] == 0xD8, data[2] == 0xFF { return true }
|
||||
if data.count >= 4, data[0] == 0x89, data[1] == 0x50, data[2] == 0x4E, data[3] == 0x47 { return true }
|
||||
return data.count > 1024
|
||||
}
|
||||
|
||||
static func isResponseOK(_ response: Data) -> Bool {
|
||||
guard !response.isEmpty else { return true }
|
||||
guard let parsed = try? parseContainer(response) else { return false }
|
||||
return parsed.code == PTPResponseCode.ok.rawValue
|
||||
}
|
||||
|
||||
/// 通过 ImageCaptureCore 发送 PTP 命令。
|
||||
static func sendCommand(
|
||||
on camera: ICCameraDevice,
|
||||
code: UInt16,
|
||||
transactionID: inout UInt32,
|
||||
parameters: [UInt32],
|
||||
outData: Data?,
|
||||
label: String
|
||||
) async -> PTPCommandResult {
|
||||
let currentID = transactionID
|
||||
transactionID += 1
|
||||
|
||||
let commandData = buildCommand(
|
||||
code: code,
|
||||
transactionID: currentID,
|
||||
parameters: parameters
|
||||
)
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
camera.requestSendPTPCommand(
|
||||
commandData,
|
||||
outData: outData,
|
||||
completion: { inData, response, error in
|
||||
if let error {
|
||||
CameraTetheringLogger.log("PTP \(label) 失败: \(error.localizedDescription)", logger: CameraTetheringLogger.ptp)
|
||||
continuation.resume(returning: PTPCommandResult(success: false, response: response, inData: inData))
|
||||
return
|
||||
}
|
||||
|
||||
var commandSuccess = true
|
||||
if !response.isEmpty {
|
||||
do {
|
||||
let parsed = try parseContainer(response)
|
||||
if parsed.code != PTPResponseCode.ok.rawValue {
|
||||
CameraTetheringLogger.log(
|
||||
"PTP \(label) 响应码: 0x\(String(format: "%04X", parsed.code)) (非 OK)",
|
||||
logger: CameraTetheringLogger.ptp
|
||||
)
|
||||
commandSuccess = false
|
||||
}
|
||||
} catch {
|
||||
CameraTetheringLogger.log("PTP \(label) 响应解析失败", logger: CameraTetheringLogger.ptp)
|
||||
commandSuccess = false
|
||||
}
|
||||
}
|
||||
|
||||
if commandSuccess {
|
||||
CameraTetheringLogger.log(
|
||||
"PTP \(label) 成功, inData=\(inData.count) bytes",
|
||||
logger: CameraTetheringLogger.ptp
|
||||
)
|
||||
}
|
||||
continuation.resume(returning: PTPCommandResult(success: commandSuccess, response: response, inData: inData))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func appendUInt16LE(_ value: UInt16, to data: inout Data) {
|
||||
var littleEndian = value.littleEndian
|
||||
withUnsafeBytes(of: &littleEndian) { raw in
|
||||
data.append(contentsOf: raw)
|
||||
}
|
||||
}
|
||||
|
||||
static func appendUInt32LE(_ value: UInt32, to data: inout Data) {
|
||||
var littleEndian = value.littleEndian
|
||||
withUnsafeBytes(of: &littleEndian) { raw in
|
||||
data.append(contentsOf: raw)
|
||||
}
|
||||
}
|
||||
|
||||
static func readUInt16LE(_ data: Data, offset: Int) -> UInt16 {
|
||||
UInt16(data[offset])
|
||||
| (UInt16(data[offset + 1]) << 8)
|
||||
}
|
||||
|
||||
static func readUInt32LE(_ data: Data, offset: Int) -> UInt32 {
|
||||
UInt32(data[offset])
|
||||
| (UInt32(data[offset + 1]) << 8)
|
||||
| (UInt32(data[offset + 2]) << 16)
|
||||
| (UInt32(data[offset + 3]) << 24)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// 进程内共享 USB Browser,避免页面离开后 stop Browser 导致 iOS 无法再次枚举仍连接的相机。
|
||||
enum SharedUSBCameraBrowserHub {
|
||||
static let controller = USBDeviceBrowserController()
|
||||
}
|
||||
@ -0,0 +1,393 @@
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// 记录最近一次 USB 相机会话断开,用于再次进入页面时触发 Browser 热重启重新枚举。
|
||||
enum USBCameraReconnectHint {
|
||||
private(set) static var lastDisconnectedAt: Date?
|
||||
|
||||
/// 标记 USB 相机已断开,供下次 start 判断是否需热重启。
|
||||
static func markDisconnected() {
|
||||
lastDisconnectedAt = Date()
|
||||
}
|
||||
|
||||
/// 近期是否刚断开(60s 内),需要主动 re-enumerate。
|
||||
static var shouldReEnumerate: Bool {
|
||||
guard let lastDisconnectedAt else { return false }
|
||||
return Date().timeIntervalSince(lastDisconnectedAt) < 60
|
||||
}
|
||||
}
|
||||
|
||||
/// 非 MainActor 的 USB 设备浏览器。
|
||||
/// ICDeviceBrowserDelegate 不应挂在 @MainActor 类型上,否则部分系统版本上 didAdd 可能永不触发。
|
||||
final class USBDeviceBrowserController: NSObject {
|
||||
var onCameraDiscovered: ((ICCameraDevice, String) -> Void)?
|
||||
var onCameraRemoved: ((ICDevice) -> Void)?
|
||||
var onAuthorizationDenied: (() -> Void)?
|
||||
var onScanTimeout: (() -> Void)?
|
||||
|
||||
private let browser = ICDeviceBrowser()
|
||||
private var pollTimer: Timer?
|
||||
private var scanTimeoutTask: Task<Void, Never>?
|
||||
private var notifiedUUIDs: Set<String> = []
|
||||
private var pollTick = 0
|
||||
private var isAuthorized = false
|
||||
private var emptyPollStreak = 0
|
||||
private var isRestarting = false
|
||||
private var isStopped = false
|
||||
private var prefersCombinedBrowseMask = true
|
||||
private var isReEnumerating = false
|
||||
|
||||
/// Browser 是否仍在运行(未 stop)。
|
||||
var isSessionBrowsing: Bool {
|
||||
browser.isBrowsing && !isStopped
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
browser.delegate = self
|
||||
configureBrowseMask()
|
||||
}
|
||||
|
||||
/// 请求授权并启动设备搜索;若 Browser 已在运行则直接扫描已连接设备。
|
||||
func start() async -> Bool {
|
||||
isStopped = false
|
||||
browser.delegate = self
|
||||
|
||||
if browser.isBrowsing {
|
||||
log("Browser 已在运行,扫描已连接 USB 相机…")
|
||||
prepareForNewSession()
|
||||
startScanTimeout()
|
||||
let found = await waitForDiscovery(maxAttempts: 8, intervalNanoseconds: 300_000_000)
|
||||
if !found, USBCameraReconnectHint.shouldReEnumerate {
|
||||
await reEnumerateConnectedDevices(reason: "再次进入")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
stopPolling()
|
||||
scanTimeoutTask?.cancel()
|
||||
pollTick = 0
|
||||
emptyPollStreak = 0
|
||||
notifiedUUIDs.removeAll()
|
||||
|
||||
if !isAuthorized {
|
||||
let granted = await requestAuthorizations(on: browser)
|
||||
guard granted else {
|
||||
onAuthorizationDenied?()
|
||||
return false
|
||||
}
|
||||
isAuthorized = true
|
||||
}
|
||||
|
||||
await beginBrowsing(label: "首次启动")
|
||||
startPolling()
|
||||
startScanTimeout()
|
||||
|
||||
let foundImmediately = await waitForDiscovery(maxAttempts: 4, intervalNanoseconds: 300_000_000)
|
||||
if !foundImmediately {
|
||||
await reEnumerateConnectedDevices(reason: "首次空结果")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// 页面离开:保持 Browser 运行,仅重置发现状态供下次 attach。
|
||||
func pauseForSessionEnd() {
|
||||
guard !isStopped else { return }
|
||||
scanTimeoutTask?.cancel()
|
||||
scanTimeoutTask = nil
|
||||
notifiedUUIDs.removeAll()
|
||||
emptyPollStreak = 0
|
||||
pollTick = 0
|
||||
isReEnumerating = false
|
||||
isRestarting = false
|
||||
log("会话结束,Browser 保持运行等待再次 attach")
|
||||
}
|
||||
|
||||
/// 立即扫描 browser.devices,用于 Browser 已运行时的再次进入。
|
||||
func discoverAlreadyConnectedDevices() {
|
||||
guard !isStopped, browser.isBrowsing else { return }
|
||||
pollDiscoveredDevices()
|
||||
}
|
||||
|
||||
private func prepareForNewSession() {
|
||||
notifiedUUIDs.removeAll()
|
||||
emptyPollStreak = 0
|
||||
pollTick = 0
|
||||
if pollTimer == nil {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
/// 在未发现设备时重启 Browser(热插拔 / 用户手动触发)。
|
||||
func restart() async {
|
||||
guard !isRestarting, !isReEnumerating else { return }
|
||||
isRestarting = true
|
||||
defer { isRestarting = false }
|
||||
|
||||
guard isAuthorized else {
|
||||
_ = await start()
|
||||
return
|
||||
}
|
||||
|
||||
log("手动/自动重启 Browser 搜索…")
|
||||
if browser.isBrowsing {
|
||||
browser.stop()
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
notifiedUUIDs.removeAll()
|
||||
pollTick = 0
|
||||
emptyPollStreak = 0
|
||||
scanTimeoutTask?.cancel()
|
||||
toggleBrowseMask()
|
||||
await beginBrowsing(label: "重启")
|
||||
startScanTimeout()
|
||||
_ = await waitForDiscovery(maxAttempts: 6, intervalNanoseconds: 300_000_000)
|
||||
}
|
||||
|
||||
/// 停止搜索。
|
||||
func stop() {
|
||||
isStopped = true
|
||||
stopPolling()
|
||||
scanTimeoutTask?.cancel()
|
||||
scanTimeoutTask = nil
|
||||
pollTick = 0
|
||||
emptyPollStreak = 0
|
||||
if browser.isBrowsing {
|
||||
browser.stop()
|
||||
}
|
||||
browser.delegate = nil
|
||||
notifiedUUIDs.removeAll()
|
||||
isAuthorized = false
|
||||
isRestarting = false
|
||||
}
|
||||
|
||||
/// 测试用:当前是否已缓存 USB 授权结果。
|
||||
var isAuthorizationGrantedForTesting: Bool { isAuthorized }
|
||||
/// 测试用:Browser 是否已停止。
|
||||
var isStoppedForTesting: Bool { isStopped }
|
||||
|
||||
func resetNotifiedDevices() {
|
||||
notifiedUUIDs.removeAll()
|
||||
}
|
||||
|
||||
private func configureBrowseMask() {
|
||||
applyBrowseMask(combined: prefersCombinedBrowseMask)
|
||||
}
|
||||
|
||||
/// 切换 Camera|LocalUSB 与仅 Camera 掩码,提高 re-enumerate 成功率。
|
||||
private func toggleBrowseMask() {
|
||||
prefersCombinedBrowseMask.toggle()
|
||||
applyBrowseMask(combined: prefersCombinedBrowseMask)
|
||||
}
|
||||
|
||||
private func applyBrowseMask(combined: Bool) {
|
||||
guard #available(iOS 15.2, *) else { return }
|
||||
let mask: ICDeviceTypeMask
|
||||
if combined {
|
||||
mask = ICDeviceTypeMask(
|
||||
rawValue: ICDeviceTypeMask.camera.rawValue | ICDeviceLocationTypeMask.local.rawValue
|
||||
) ?? .camera
|
||||
log("browsedDeviceTypeMask=0x\(String(mask.rawValue, radix: 16)) (Camera|LocalUSB)")
|
||||
} else {
|
||||
mask = .camera
|
||||
log("browsedDeviceTypeMask=0x\(String(mask.rawValue, radix: 16)) (CameraOnly)")
|
||||
}
|
||||
browser.browsedDeviceTypeMask = mask
|
||||
}
|
||||
|
||||
/// 轮询等待 didAdd / devices 列表出现相机。
|
||||
private func waitForDiscovery(maxAttempts: Int, intervalNanoseconds: UInt64) async -> Bool {
|
||||
for _ in 0 ..< maxAttempts {
|
||||
guard !isStopped else { return !notifiedUUIDs.isEmpty }
|
||||
try? await Task.sleep(nanoseconds: intervalNanoseconds)
|
||||
pollDiscoveredDevices()
|
||||
if !notifiedUUIDs.isEmpty { return true }
|
||||
}
|
||||
return !notifiedUUIDs.isEmpty
|
||||
}
|
||||
|
||||
/// 会话关闭后 iOS 常不触发 didAdd;stop → wait → start 强制重新枚举仍连接的 USB 相机。
|
||||
private func reEnumerateConnectedDevices(reason: String) async {
|
||||
guard !isStopped, notifiedUUIDs.isEmpty, !isReEnumerating else { return }
|
||||
isReEnumerating = true
|
||||
defer { isReEnumerating = false }
|
||||
|
||||
for attempt in 1 ... 2 {
|
||||
guard !isStopped, notifiedUUIDs.isEmpty else { return }
|
||||
log("\(reason):第 \(attempt) 次热重启 Browser 以重新枚举 USB 相机…")
|
||||
|
||||
if browser.isBrowsing {
|
||||
browser.stop()
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: UInt64(attempt) * 500_000_000)
|
||||
toggleBrowseMask()
|
||||
notifiedUUIDs.removeAll()
|
||||
pollTick = 0
|
||||
emptyPollStreak = 0
|
||||
|
||||
await beginBrowsing(label: "\(reason)-热重启\(attempt)")
|
||||
if await waitForDiscovery(maxAttempts: 6, intervalNanoseconds: 300_000_000) {
|
||||
log("\(reason):热重启第 \(attempt) 次成功发现相机")
|
||||
return
|
||||
}
|
||||
}
|
||||
log("\(reason):热重启后仍未发现 USB 相机")
|
||||
}
|
||||
|
||||
private func beginBrowsing(label: String) async {
|
||||
log("\(label) ICDeviceBrowser.start() suspended=\(browser.isSuspended)")
|
||||
browser.start()
|
||||
log("isBrowsing=\(browser.isBrowsing) devices.count=\(browser.devices?.count ?? 0)")
|
||||
}
|
||||
|
||||
private func stopPolling() {
|
||||
pollTimer?.invalidate()
|
||||
pollTimer = nil
|
||||
}
|
||||
|
||||
private func startPolling() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.stopPolling()
|
||||
self.pollTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||
self?.pollDiscoveredDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startScanTimeout() {
|
||||
scanTimeoutTask?.cancel()
|
||||
scanTimeoutTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 45_000_000_000)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
guard self.browser.isBrowsing else { return }
|
||||
guard self.notifiedUUIDs.isEmpty else { return }
|
||||
self.log("45s 内未发现 USB 相机")
|
||||
self.onScanTimeout?()
|
||||
}
|
||||
}
|
||||
|
||||
private func pollDiscoveredDevices() {
|
||||
guard !isStopped else { return }
|
||||
pollTick += 1
|
||||
|
||||
let devices = browser.devices ?? []
|
||||
if devices.isEmpty {
|
||||
emptyPollStreak += 1
|
||||
} else {
|
||||
emptyPollStreak = 0
|
||||
}
|
||||
|
||||
if pollTick == 1 || pollTick % 10 == 0 {
|
||||
log(
|
||||
"轮询 tick=\(pollTick) devices.count=\(devices.count) " +
|
||||
"isBrowsing=\(browser.isBrowsing) suspended=\(browser.isSuspended)"
|
||||
)
|
||||
for device in devices {
|
||||
logDeviceSummary(device, prefix: " devices[]")
|
||||
}
|
||||
}
|
||||
|
||||
// 连续 6 次(约 3s)无设备时自动重启 Browser;不与 reEnumerate 并发。
|
||||
if emptyPollStreak > 0, emptyPollStreak % 6 == 0, notifiedUUIDs.isEmpty, !isRestarting, !isReEnumerating {
|
||||
Task { await self.restart() }
|
||||
}
|
||||
|
||||
for device in devices {
|
||||
handleDiscoveredDevice(device, source: "poll")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDiscoveredDevice(_ device: ICDevice, source: String) {
|
||||
guard !isStopped else { return }
|
||||
guard let camera = device as? ICCameraDevice else {
|
||||
log(
|
||||
"[\(source)] 非 ICCameraDevice: \(device.name ?? "?") " +
|
||||
"type=\(device.type) transport=\(device.transportType ?? "?")"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let uuid = camera.uuidString ?? "\(camera.usbVendorID)-\(camera.usbLocationID)"
|
||||
guard notifiedUUIDs.insert(uuid).inserted else { return }
|
||||
|
||||
let vendorID = Int(camera.usbVendorID)
|
||||
log(
|
||||
"[\(source)] 发现相机: \(camera.name ?? "?") vid=0x\(String(vendorID, radix: 16)) " +
|
||||
"transport=\(camera.transportType ?? "?")"
|
||||
)
|
||||
scanTimeoutTask?.cancel()
|
||||
scanTimeoutTask = nil
|
||||
onCameraDiscovered?(camera, source)
|
||||
}
|
||||
|
||||
private func requestAuthorizations(on browser: ICDeviceBrowser) async -> Bool {
|
||||
log(
|
||||
"contentsAuthorizationStatus=\(browser.contentsAuthorizationStatus), " +
|
||||
"controlAuthorizationStatus=\(browser.controlAuthorizationStatus)"
|
||||
)
|
||||
|
||||
let contentsGranted = await withCheckedContinuation { continuation in
|
||||
browser.requestContentsAuthorization { status in
|
||||
CameraTetheringLogger.log("AutoDetect 内容授权: \(String(describing: status))")
|
||||
continuation.resume(returning: status == .authorized)
|
||||
}
|
||||
}
|
||||
guard contentsGranted else { return false }
|
||||
|
||||
let controlGranted = await withCheckedContinuation { continuation in
|
||||
browser.requestControlAuthorization { status in
|
||||
CameraTetheringLogger.log("AutoDetect 控制授权: \(String(describing: status))")
|
||||
continuation.resume(returning: status == .authorized)
|
||||
}
|
||||
}
|
||||
if !controlGranted {
|
||||
log("控制授权未通过,仍尝试连接")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func logDeviceSummary(_ device: ICDevice, prefix: String) {
|
||||
if let camera = device as? ICCameraDevice {
|
||||
log(
|
||||
"\(prefix): \(camera.name ?? "?") vid=0x\(String(Int(camera.usbVendorID), radix: 16)) " +
|
||||
"transport=\(camera.transportType ?? "?")"
|
||||
)
|
||||
} else {
|
||||
log("\(prefix): \(device.name ?? "?") type=\(device.type) transport=\(device.transportType ?? "?")")
|
||||
}
|
||||
}
|
||||
|
||||
private func log(_ message: String) {
|
||||
CameraTetheringLogger.log("AutoDetect \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
extension USBDeviceBrowserController: ICDeviceBrowserDelegate {
|
||||
@objc(deviceBrowser:didAddDevice:moreComing:)
|
||||
func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) {
|
||||
guard !isStopped else { return }
|
||||
log("didAdd 回调 name=\(device.name ?? "?") moreComing=\(moreComing)")
|
||||
handleDiscoveredDevice(device, source: "didAdd")
|
||||
}
|
||||
|
||||
@objc(deviceBrowser:didRemoveDevice:moreGoing:)
|
||||
func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) {
|
||||
guard !isStopped else { return }
|
||||
log("didRemove 回调 name=\(device.name ?? "?") moreGoing=\(moreGoing)")
|
||||
if let uuid = device.uuidString {
|
||||
notifiedUUIDs.remove(uuid)
|
||||
}
|
||||
onCameraRemoved?(device)
|
||||
}
|
||||
|
||||
func deviceBrowserDidSuspendOperations(_ browser: ICDeviceBrowser) {
|
||||
log("Browser 操作已挂起(App 进入后台)")
|
||||
}
|
||||
|
||||
func deviceBrowserDidResumeOperations(_ browser: ICDeviceBrowser) {
|
||||
guard !isStopped else { return }
|
||||
log("Browser 操作已恢复(App 回到前台)")
|
||||
Task { await restart() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user