完善 OTG 相机有线传图:支持 Sony/Canon 自动识别,页面 detach 保持会话以便再次进入复连。

引入进程级 CameraTetheringSession 与共享 USB Browser;离开传图页仅取消 UI 回调,App 终止时再完整 shutdown 释放 PTP 与 Browser 资源。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-01 15:45:35 +08:00
parent 5f7ef24683
commit 90d135b5ac
34 changed files with 2870 additions and 310 deletions

View File

@ -0,0 +1,213 @@
import Foundation
import ImageCaptureCore
/// USB
@MainActor
final class AutoDetectCameraService: NSObject, CameraServiceProtocol {
private(set) var brand: CameraBrand = .sony
private(set) var connectionState: CameraConnectionState = .disconnected
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
var onNewAsset: ((CameraAsset) -> Void)?
private var activeService: (any CameraServiceProtocol)?
private var pendingCamera: ICCameraDevice?
private let usbBrowser = SharedUSBCameraBrowserHub.controller
private var isConnectingCamera = false
private var isDisconnecting = false
override init() {
super.init()
bindUSBBrowserCallbacks()
}
func connect() async {
CameraTetheringLogger.log("AutoDetectCameraService.connect()")
if let activeService {
CameraTetheringLogger.log("AutoDetect: 已有活动服务,复用连接")
connectionState = activeService.connectionState
rebindActiveServiceCallbacks()
flushPendingAssetsIfNeeded(on: activeService)
onConnectionStateChange?(connectionState)
return
}
isConnectingCamera = false
connectionState = .searching
onConnectionStateChange?(.searching)
let started = await usbBrowser.start()
guard started else {
connectionState = .error("权限被拒绝")
onConnectionStateChange?(.error("权限被拒绝"))
return
}
}
/// UI ICC/PTP Browser
func detachFromUI() {
CameraTetheringLogger.log("AutoDetectCameraService.detachFromUI()")
onConnectionStateChange = nil
onNewAsset = nil
activeService?.detachFromUI()
}
func shutdown() async {
guard !isDisconnecting else { return }
isDisconnecting = true
defer { isDisconnecting = false }
CameraTetheringLogger.log("AutoDetectCameraService.shutdown()")
isConnectingCamera = false
if let activeService {
await activeService.shutdown()
}
activeService = nil
pendingCamera = nil
usbBrowser.stop()
USBCameraReconnectHint.markDisconnected()
try? await Task.sleep(nanoseconds: 300_000_000)
connectionState = .disconnected
onConnectionStateChange?(.disconnected)
}
/// USB App
func restartDiscovery() async {
CameraTetheringLogger.log("AutoDetectCameraService.restartDiscovery()")
guard activeService == nil else {
CameraTetheringLogger.log("AutoDetect: 已连接,跳过 restartDiscovery")
return
}
isConnectingCamera = false
connectionState = .searching
onConnectionStateChange?(.searching)
await usbBrowser.restart()
}
func listAssets() async throws -> [CameraAsset] {
guard let activeService else {
throw CameraServiceError.notConnected
}
return try await activeService.listAssets()
}
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
guard let activeService else {
throw CameraServiceError.notConnected
}
return try await activeService.downloadAsset(asset)
}
private func bindUSBBrowserCallbacks() {
usbBrowser.onCameraDiscovered = { [weak self] camera, source in
Task { @MainActor in
await self?.handleCameraDiscovered(camera, source: source)
}
}
usbBrowser.onCameraRemoved = { [weak self] device in
Task { @MainActor in
await self?.handleCameraRemoved(device)
}
}
usbBrowser.onAuthorizationDenied = { [weak self] in
Task { @MainActor in
guard let self else { return }
self.connectionState = .error("权限被拒绝")
self.onConnectionStateChange?(.error("权限被拒绝"))
}
}
usbBrowser.onScanTimeout = { [weak self] in
Task { @MainActor in
guard let self, self.activeService == nil, !self.isConnectingCamera else { return }
let message = "未发现 USB 相机。请确认:① 相机 USB 设为「照片传输/电脑遥控」② 存储卡内至少有 1 张照片 ③ 先连手机再连相机 ④ 进入页面后重新插拔 USB 线"
self.connectionState = .error(message)
self.onConnectionStateChange?(.error(message))
}
}
}
private func handleCameraDiscovered(_ camera: ICCameraDevice, source: String) async {
guard activeService == nil, !isConnectingCamera, !isDisconnecting else {
CameraTetheringLogger.log("AutoDetect: 忽略重复发现 (\(source))")
return
}
let vendorID = Int(camera.usbVendorID)
guard let detectedBrand = CameraBrand.detect(from: camera) else {
let message = "不支持的相机品牌 (Vendor ID: 0x\(String(vendorID, radix: 16)))"
CameraTetheringLogger.log(message)
connectionState = .error(message)
onConnectionStateChange?(.error(message))
return
}
isConnectingCamera = true
await activateService(for: camera, brand: detectedBrand)
isConnectingCamera = false
}
private func handleCameraRemoved(_ device: ICDevice) async {
guard !isDisconnecting else { return }
guard activeService != nil else { return }
guard device.uuidString == pendingCamera?.uuidString else { return }
CameraTetheringLogger.log("AutoDetect: 相机已物理移除 \(device.name ?? "?")")
pendingCamera = nil
if let activeService {
await activeService.shutdown()
}
activeService = nil
usbBrowser.resetNotifiedDevices()
connectionState = .disconnected
onConnectionStateChange?(.disconnected)
}
private func activateService(for camera: ICCameraDevice, brand detectedBrand: CameraBrand) async {
brand = detectedBrand
let service = CameraServiceFactory.make(brand: detectedBrand)
activeService = service
pendingCamera = camera
rebindActiveServiceCallbacks()
connectionState = .connecting
onConnectionStateChange?(.connecting)
CameraTetheringLogger.log("AutoDetect: 挂载 \(detectedBrand.displayName) 服务 (source attach)…")
switch detectedBrand {
case .sony:
if let sony = service as? SonyCameraService {
await sony.attach(to: camera)
}
case .canon:
if let canon = service as? CanonCameraService {
await canon.attach(to: camera)
}
case .nikon:
connectionState = .error("Nikon 相机尚未实现")
onConnectionStateChange?(.error("Nikon 相机尚未实现"))
}
}
private func rebindActiveServiceCallbacks() {
guard let activeService else { return }
activeService.onConnectionStateChange = { [weak self] state in
Task { @MainActor in
self?.connectionState = state
self?.onConnectionStateChange?(state)
}
}
activeService.onNewAsset = { [weak self] asset in
self?.onNewAsset?(asset)
}
}
private func flushPendingAssetsIfNeeded(on service: any CameraServiceProtocol) {
if let sony = service as? SonyCameraService {
sony.reattachToUI()
} else if let canon = service as? CanonCameraService {
canon.reattachToUI()
}
}
}