完善 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

@ -64,11 +64,15 @@
};
A00000022FE9000000000002 /* suixinkanTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = suixinkanTests;
sourceTree = "<group>";
};
B00000022FEF000000000002 /* suixinkanUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = suixinkanUITests;
sourceTree = "<group>";
};
@ -324,14 +328,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";

View File

@ -38,4 +38,13 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
completionHandler(.newData)
}
}
func applicationWillTerminate(_ application: UIApplication) {
let semaphore = DispatchSemaphore(value: 0)
Task { @MainActor in
await CameraTetheringSession.shutdown()
semaphore.signal()
}
_ = semaphore.wait(timeout: .now() + 2)
}
}

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()
}
}
}

View File

@ -2,26 +2,73 @@
## 职责
提供 USB OTG 相机有线连接与 Sony PTP 边拍边传能力,不包含业务 API 或 OSS 上传。
提供 USB OTG 相机有线连接与 Sony/Canon PTP 边拍边传能力,不包含业务 API 或 OSS 上传。
## 核心组件
| 组件 | 说明 |
| --- | --- |
| `CameraServiceProtocol` | 相机服务抽象,上层只依赖此协议 |
| `SonyCameraService` | Sony 相机实现,封装 PTP 下载与连接状态 |
| `ImageCaptureDeviceManager` | ImageCaptureCore 设备浏览、会话与 PTP 事件 |
| `SonyPTPCommands` | Sony SDIO/PTP 命令封装 |
| `CameraTetheringSession` | 进程级单例,持有共享 `AutoDetectCameraService` |
| `AutoDetectCameraService` | 按 USB Vendor ID 自动识别品牌并委托 |
| `SonyCameraService` | Sony 相机实现,封装 SDIO/PTP 下载 |
| `CanonCameraService` | Canon EOS 相机实现,封装 GetEvent/PTP 下载 |
| `ImageCaptureDeviceManager` | Sony ICC 设备管理 |
| `CanonImageCaptureDeviceManager` | Canon ICC 设备管理 |
| `PTPHelper` | 共享 PTP 编解码与命令发送 |
| `CameraDownloadStorage` | 本地下载作用域、目录与文件名消毒 |
## 品牌识别
插入 USB 相机后,`AutoDetectCameraService` 读取 `ICCameraDevice.usbVendorID`
- `0x054C` → Sony
- `0x04A9` → Canon
- 其他 → 提示不支持的相机品牌
## 业务流程
1. `SonyCameraService.connect()` 启动 USB 设备搜索。
2. 授权通过后打开 ICC 会话并执行 Sony SDIO 握手
3. 拍摄事件触发 PTP 下载,底层先写入临时目录,再由传输管道按账号与相册移入业务目录
4. 通过 `onNewAsset` 回调通知上层有新照片
1. `AutoDetectCameraService.connect()` 启动 USB 设备搜索(或复用已有连接)
2. 发现相机并完成系统授权后,按 Vendor ID 创建品牌 Service 并 attach
3. 品牌 Service 打开 ICC 会话并执行品牌专有 PTP 握手Sony SDIO / Canon SetRemoteMode
4. 拍摄事件触发 PTP 下载或 catalog 回退,底层先写入临时目录
5. 通过 `onNewAsset` 回调通知上层有新照片。
## 相机端前置条件
- **Sony**设为电脑遥控PC Remote模式
- **Canon**:设为电脑遥控 / PC Remote / 照片传输PTP模式
- 均需关闭「仅充电」模式
## 依赖
- ImageCaptureCore系统框架
- 无第三方库依赖
## 页面生命周期与资源释放
OTG 相机连接由 `CameraTetheringSession.sharedService` 进程级持有;`CameraServiceFactory.makeAutoDetect()` 始终返回该单例。
**进入 OTG 页:**
1. `.task` 调用 `WiredCameraTransferViewModel.start()`
2. `pipeline.attachToUI(...)` 绑定 UI 回调。
3. `connect()` 启动或复用 USB 搜索;若已连接则跳过 attach同步状态并补发 detach 期间缓冲的新片。
4. 发现相机后按 Vendor ID attach 品牌服务,打开 ICC 会话并执行 PTP 握手。
**离开 OTG 页detach**
1. `.task` 取消或 `onDisappear` 触发 `detachFromUI()`(幂等)。
2. 仅清空 `onNewAsset` / `onConnectionStateChange` 等 UI 回调,取消 Pipeline 上传/通知任务。
3. **不** PTP CloseSession、**不** stop Browser、**不** nil `activeService`
4. 底层 D215 轮询与 PTP 下载可继续,新片写入 `pendingAssetsWhileDetached`,再次进入时 flush。
**App 即将终止shutdown**
1. `AppDelegate.applicationWillTerminate` 调用 `CameraTetheringSession.shutdown()`
2. 品牌 Service 完整断开Sony 先 `Position_Key_Setting=Local` 再 PTP CloseSession
3. `usbBrowser.stop()` 停止 USB Browser 并释放授权状态。
进入后台**不**主动 shutdown系统挂起 USB 时由 `deviceBrowserDidResumeOperations` 处理恢复。
若返回后再进入无法连接,优先检查日志中是否出现「会话关闭超时」或在非 terminate 场景误调用了 `shutdown()`

View File

@ -0,0 +1,17 @@
import Foundation
/// OTG detach UI App shutdown
@MainActor
enum CameraTetheringSession {
static let sharedService = AutoDetectCameraService()
/// UI ICC/PTP Browser
static func detachFromUI() {
sharedService.detachFromUI()
}
/// App USB Browser
static func shutdown() async {
await sharedService.shutdown()
}
}

View File

@ -0,0 +1,39 @@
# Canon CameraTethering
## 职责
提供 Canon EOS 相机 USB OTG 有线连接与边拍边传PC Remote能力。
## 核心组件
| 组件 | 说明 |
| --- | --- |
| `CanonCameraService` | 实现 `CameraServiceProtocol`,管理基线与去重 |
| `CanonImageCaptureDeviceManager` | ImageCaptureCore 会话、GetEvent 轮询、PTP/Catalog 下载 |
| `CanonPTPCommands` | Canon EOS PTP 厂商扩展命令 |
| `CanonEosEventParser` | GetEvent blob 解析ObjectAddedEx / ObjectAddedEx2 |
## PTP 会话时序
1. ImageCaptureCore 打开 ICC 会话(不发 PTP OpenSession
2. `SetRemoteMode` (0x9114) — 进入 PC 遥控模式。
3. `SetEventMode` (0x9115) — 订阅扩展事件。
4. `KeepDeviceOn` (0x911D) — 重置自动关机(部分机身不支持,非致命)。
5. 每 500ms 轮询 `GetEvent` (0x9116)。
6. 收到 `ObjectAddedEx` / `ObjectAddedEx2` 后通过 `EOS_GetObject` (0x9104) 下载。
7. 发送 `TransferComplete` (0x9117) 释放相机缓冲。
## Catalog 回退
若 PTP 实时路径不可用,仍可通过 `deviceDidBecomeReady` + `didAddItems` + 1.5s 轮询检测 catalog 新文件,并使用 `requestDownloadFile` 下载。
## 相机端设置
- USB 模式设为 **电脑遥控 / PC Remote / 照片传输PTP**
- 关闭「仅充电」模式
- 边拍边传时勿调用 `SetUILock` (0x911B)
## 依赖
- ImageCaptureCore系统框架
- 共享 `PTPHelper` / `CameraDownloadStorage`

View File

@ -0,0 +1,181 @@
import Foundation
import ImageCaptureCore
/// Canon EOS 线退
@MainActor
final class CanonCameraService: CameraServiceProtocol {
let brand: CameraBrand = .canon
private(set) var connectionState: CameraConnectionState = .disconnected
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
var onNewAsset: ((CameraAsset) -> Void)?
private let deviceManager = CanonImageCaptureDeviceManager()
private var knownAssetIDs: Set<String> = []
private var baselineEstablished = false
private var pendingAssetsWhileDetached: [CameraAsset] = []
init() {
deviceManager.onConnectionStateChange = { [weak self] state in
Task { @MainActor in
self?.connectionState = state
self?.onConnectionStateChange?(state)
}
}
deviceManager.onNewItems = { [weak self] items in
Task { @MainActor in
self?.handleNewItems(items, source: "didAddItems")
}
}
deviceManager.onBaselineCatalogReady = { [weak self] in
Task { @MainActor in
self?.establishBaseline()
}
}
deviceManager.onPollTick = { [weak self] in
Task { @MainActor in
self?.scanForNewAssets(source: "poll")
}
}
deviceManager.onPTPAssetReady = { [weak self] asset, _ in
Task { @MainActor in
self?.handlePTPAsset(asset)
}
}
deviceManager.onError = { [weak self] message in
Task { @MainActor in
CameraTetheringLogger.log("Canon 相机错误: \(message)")
self?.connectionState = .error(message)
self?.onConnectionStateChange?(.error(message))
}
}
}
func connect() async {
CameraTetheringLogger.log("CanonCameraService.connect()")
let shouldResetBaseline = !deviceManager.isBrowsing
deviceManager.startBrowsing()
if shouldResetBaseline {
knownAssetIDs.removeAll()
baselineEstablished = false
}
}
/// AutoDetect
func attach(to camera: ICCameraDevice) async {
CameraTetheringLogger.log("CanonCameraService.attach()")
knownAssetIDs.removeAll()
baselineEstablished = false
pendingAssetsWhileDetached.removeAll()
deviceManager.attach(to: camera)
}
/// UI PTP
func detachFromUI() {
CameraTetheringLogger.log("CanonCameraService.detachFromUI()")
onConnectionStateChange = nil
onNewAsset = nil
}
/// detach
func reattachToUI() {
guard !pendingAssetsWhileDetached.isEmpty else { return }
let pending = pendingAssetsWhileDetached
pendingAssetsWhileDetached.removeAll()
CameraTetheringLogger.log("CanonCameraService.reattachToUI: 补发 \(pending.count) 张离线新片")
for asset in pending {
onNewAsset?(asset)
}
}
func shutdown() async {
CameraTetheringLogger.log("CanonCameraService.shutdown()")
await deviceManager.stopBrowsing()
knownAssetIDs.removeAll()
baselineEstablished = false
pendingAssetsWhileDetached.removeAll()
connectionState = .disconnected
onConnectionStateChange?(.disconnected)
}
func listAssets() async throws -> [CameraAsset] {
guard connectionState.isConnected else {
throw CameraServiceError.notConnected
}
return deviceManager.allFiles().map { deviceManager.makeAsset(from: $0) }
}
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
guard connectionState.isConnected else {
throw CameraServiceError.notConnected
}
if let cachedURL = deviceManager.localURL(forAssetID: asset.id) {
return cachedURL
}
guard let file = deviceManager.file(forAssetID: asset.id) else {
throw CameraServiceError.assetNotFound
}
let directory = CameraDownloadStorage.temporaryDownloadsDirectory
return try await deviceManager.download(file: file, to: directory)
}
private func handlePTPAsset(_ asset: CameraAsset) {
deliverNewAsset(asset, source: "Canon PTP")
}
private func establishBaseline() {
guard !baselineEstablished else { return }
baselineEstablished = true
let files = deviceManager.allFiles()
var skipped = 0
var newCount = 0
for file in files {
if file.wasAddedAfterContentCatalogCompleted {
emitAssetIfNew(file: file, source: "baseline-new")
newCount += 1
} else {
knownAssetIDs.insert(deviceManager.assetIdentifier(for: file))
skipped += 1
}
}
CameraTetheringLogger.log("Canon 基线已建立: 跳过已有 \(skipped) 张, 立即传输新拍 \(newCount)")
}
private func handleNewItems(_ items: [ICCameraItem], source: String) {
guard baselineEstablished else { return }
for item in items {
guard let file = item as? ICCameraFile else { continue }
emitAssetIfNew(file: file, source: source)
}
}
private func scanForNewAssets(source: String) {
guard baselineEstablished else { return }
for file in deviceManager.allFiles() {
emitAssetIfNew(file: file, source: source)
}
}
private func emitAssetIfNew(file: ICCameraFile, source: String) {
let asset = deviceManager.makeAsset(from: file)
deliverNewAsset(asset, source: source)
}
private func deliverNewAsset(_ asset: CameraAsset, source: String) {
guard !knownAssetIDs.contains(asset.id) else { return }
knownAssetIDs.insert(asset.id)
CameraTetheringLogger.log("[Canon \(source)] 发现新照片: \(asset.filename)", logger: CameraTetheringLogger.transfer)
if let onNewAsset {
onNewAsset(asset)
} else {
pendingAssetsWhileDetached.append(asset)
}
}
}

View File

@ -0,0 +1,188 @@
import Foundation
/// Canon EOS GetEvent blob
enum CanonEosEventParser {
private static let recordHeaderSize = 8
private static let objectAddedExMinPayload = 33
private static let objectAddedEx2MinPayload = 41
/// Canon
enum ParsedEvent {
case objectAdded(ObjectAdded)
case requestObjectTransfer(handle: UInt32)
case cameraStatus(status: Int32)
}
/// ObjectAddedEx / ObjectAddedEx2
struct ObjectAdded {
let objectHandle: UInt32
let storageId: UInt32
let formatCode: UInt16
let parentHandle: UInt32
let size: Int64
let filename: String
}
/// GetEvent blob
static func parse(_ blob: Data) -> [ParsedEvent] {
guard !blob.isEmpty else { return [] }
var events: [ParsedEvent] = []
var offset = 0
while offset + recordHeaderSize <= blob.count {
let recordStart = offset
let recordSizeRaw = Int64(PTPHelper.readUInt32LE(blob, offset: offset))
offset += 4
if recordSizeRaw == 0 { break }
if recordSizeRaw < Int64(recordHeaderSize) { break }
let recordSize = Int(recordSizeRaw)
let type = Int(PTPHelper.readUInt32LE(blob, offset: offset) & 0xFFFF)
offset += 4
let payloadSize = recordSize - recordHeaderSize
if type == 0, payloadSize == 0 { break }
if offset + payloadSize > blob.count { break }
let payloadEnd = offset + payloadSize
switch UInt16(type) {
case CanonPTPCommand.eosObjectAddedEx:
if let added = parseObjectAdded(
blob,
offset: offset,
payloadSize: payloadSize,
filenameOffset: 32,
minPayload: objectAddedExMinPayload
) {
events.append(.objectAdded(added))
}
case CanonPTPCommand.eosObjectAddedEx2:
if let added = parseObjectAdded(
blob,
offset: offset,
payloadSize: payloadSize,
filenameOffset: 40,
minPayload: objectAddedEx2MinPayload
) {
events.append(.objectAdded(added))
}
case CanonPTPCommand.eosRequestObjectTransfer:
let handle: UInt32 = payloadSize >= 4 ? PTPHelper.readUInt32LE(blob, offset: offset) : 0
events.append(.requestObjectTransfer(handle: handle))
case CanonPTPCommand.eosCameraStatusChanged:
let status: Int32 = payloadSize >= 4
? Int32(bitPattern: PTPHelper.readUInt32LE(blob, offset: offset))
: 0
events.append(.cameraStatus(status: status))
case CanonPTPCommand.eosObjectInfoChangedEx,
CanonPTPCommand.eosStorageInfoChanged:
break
default:
break
}
offset = payloadEnd
if offset == recordStart { break }
}
return events
}
/// ObjectAdded CameraAsset
static func makeAsset(from event: ObjectAdded) -> CameraAsset {
let filename = event.filename.isEmpty
? "IMG_\(event.objectHandle).JPG"
: CameraDownloadStorage.sanitizeFilename(event.filename)
let isMovie = isVideoFormat(event.formatCode) || isVideoExtension(filename)
return CameraAsset(
id: CanonPTPHelper.assetID(handle: event.objectHandle, filename: filename, size: event.size),
filename: filename,
fileSize: event.size,
creationDate: Date(),
isMovie: isMovie
)
}
private static func parseObjectAdded(
_ blob: Data,
offset payloadStart: Int,
payloadSize: Int,
filenameOffset: Int,
minPayload: Int
) -> ObjectAdded? {
guard payloadSize >= minPayload else { return nil }
var offset = payloadStart
let objectHandle = PTPHelper.readUInt32LE(blob, offset: offset)
offset += 4
let storageId = PTPHelper.readUInt32LE(blob, offset: offset)
offset += 4
let ofc = UInt16(PTPHelper.readUInt32LE(blob, offset: offset) & 0xFFFF)
offset += 4
let size: Int64
let parentHandle: UInt32
if filenameOffset == 32 {
offset += 4 // flags
parentHandle = PTPHelper.readUInt32LE(blob, offset: offset)
offset += 4
offset += 4 // reserved
size = Int64(PTPHelper.readUInt32LE(blob, offset: offset))
offset += 4
offset += 4 // reserved
} else {
offset += 4 // reserved
parentHandle = PTPHelper.readUInt32LE(blob, offset: offset)
offset += 4
let sizeLow = Int64(PTPHelper.readUInt32LE(blob, offset: offset))
offset += 4
let sizeHigh = Int64(PTPHelper.readUInt32LE(blob, offset: offset))
offset += 4
size = sizeLow | (sizeHigh << 32)
offset += 4 // reserved
offset += 4 // relatedHandle
}
let expected = payloadStart + filenameOffset
if offset != expected { offset = expected }
let filenameBytesAvailable = payloadSize - filenameOffset
guard filenameBytesAvailable > 0, offset + filenameBytesAvailable <= blob.count else { return nil }
let filenameData = blob.subdata(in: offset..<(offset + filenameBytesAvailable))
let filename = extractCString(filenameData)
return ObjectAdded(
objectHandle: objectHandle,
storageId: storageId,
formatCode: ofc,
parentHandle: parentHandle,
size: size,
filename: filename
)
}
private static func extractCString(_ bytes: Data) -> String {
if let nulIndex = bytes.firstIndex(of: 0) {
return String(bytes: bytes.prefix(upTo: nulIndex), encoding: .ascii)?.trimmingCharacters(in: .whitespaces) ?? ""
}
return String(bytes: bytes, encoding: .ascii)?.trimmingCharacters(in: .whitespaces) ?? ""
}
private static func isVideoFormat(_ ofc: UInt16) -> Bool {
switch ofc {
case 0x300D, 0x3000, 0xB982, 0xB983, 0x300F:
return true
default:
return false
}
}
private static func isVideoExtension(_ filename: String) -> Bool {
let lower = filename.lowercased()
return [".mov", ".mp4", ".m4v", ".avi", ".mpg", ".mpeg"].contains { lower.hasSuffix($0) }
}
}

View File

@ -0,0 +1,597 @@
import CoreGraphics
import Foundation
import ImageCaptureCore
/// Canon ImageCaptureCore GetEvent PTP/Catalog
final class CanonImageCaptureDeviceManager: NSObject {
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
var onNewItems: (([ICCameraItem]) -> Void)?
var onBaselineCatalogReady: (() -> Void)?
var onPollTick: (() -> Void)?
var onPTPAssetReady: ((CameraAsset, URL) -> Void)?
var onError: ((String) -> Void)?
private(set) var cameraDevice: ICCameraDevice?
private(set) var isCatalogReady = false
private var deviceBrowser: ICDeviceBrowser?
private let downloadDelegate = CameraDownloadDelegate()
private var transactionID: UInt32 = 1
private var pendingDownloads: [String: CheckedContinuation<URL, Error>] = [:]
private var ptpAssetURLs: [String: URL] = [:]
private var pollTimer: Timer?
private var baselineNotified = false
private var isRemoteSessionReady = false
private var isExternalAttach = false
private var eventPollTask: Task<Void, Never>?
private var keepDeviceOnSupported = false
private var eventPollTick = 0
private var seenHandles: Set<UInt32> = []
private var isDownloadingPTPObject = false
private var sessionSetupTask: Task<Void, Never>?
private var sessionCloseContinuation: CheckedContinuation<Void, Never>?
private var sessionCloseTimeoutTask: Task<Void, Never>?
private var closingDeviceUUID: String?
private var isDisconnecting = false
var isBrowsing: Bool { deviceBrowser != nil || isExternalAttach }
/// Browser AutoDetect
func attach(to camera: ICCameraDevice) {
guard cameraDevice == nil else {
CameraTetheringLogger.log("Canon attach: 已有连接中的相机,忽略")
return
}
isExternalAttach = true
CameraTetheringLogger.log("Canon attach: \(camera.name ?? "Unknown")")
openSession(for: camera)
}
func startBrowsing() {
guard deviceBrowser == nil else {
CameraTetheringLogger.log("Canon: 已在监听 USB 相机")
if cameraDevice == nil {
updateState(.searching)
}
return
}
CameraTetheringLogger.log("Canon: 开始搜索 USB 相机…")
updateState(.searching)
let browser = ICDeviceBrowser()
browser.delegate = self
if #available(iOS 15.2, *) {
let mask = ICDeviceTypeMask(
rawValue: ICDeviceTypeMask.camera.rawValue | ICDeviceLocationTypeMask.local.rawValue
) ?? .camera
browser.browsedDeviceTypeMask = mask
}
deviceBrowser = browser
browser.start()
}
func stopBrowsing() async {
CameraTetheringLogger.log("Canon: 停止搜索并断开")
stopEventPolling()
stopPolling()
await disconnect()
if !isExternalAttach {
deviceBrowser?.stop()
}
deviceBrowser = nil
isExternalAttach = false
isCatalogReady = false
baselineNotified = false
updateState(.disconnected)
}
func disconnect() async {
guard !isDisconnecting else { return }
isDisconnecting = true
defer { isDisconnecting = false }
sessionSetupTask?.cancel()
sessionSetupTask = nil
let shouldClosePTP = isRemoteSessionReady
isRemoteSessionReady = false
isDownloadingPTPObject = false
stopEventPolling()
stopPolling()
cancelPendingDownloads()
guard let camera = cameraDevice else {
resetDisconnectLocalState()
return
}
let deviceUUID = camera.uuidString ?? ""
camera.ptpEventHandler = { _ in }
if shouldClosePTP {
var localTransactionID = transactionID
await CanonPTPHelper.closeRemoteSession(on: camera, transactionID: &localTransactionID)
}
await waitForSessionClose(on: camera, deviceUUID: deviceUUID)
camera.delegate = nil
resetDisconnectLocalState()
}
private func cancelPendingDownloads() {
let disconnected = CameraServiceError.notConnected
for (_, continuation) in pendingDownloads {
continuation.resume(throwing: disconnected)
}
pendingDownloads.removeAll()
}
private func waitForSessionClose(on camera: ICCameraDevice, deviceUUID: String) async {
sessionCloseTimeoutTask?.cancel()
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
sessionCloseContinuation = continuation
closingDeviceUUID = deviceUUID
camera.requestCloseSession()
sessionCloseTimeoutTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 3_000_000_000)
guard let self, self.sessionCloseContinuation != nil else { return }
CameraTetheringLogger.log("Canon 会话关闭超时,继续 teardown")
self.finishSessionClose()
}
}
sessionCloseTimeoutTask?.cancel()
sessionCloseTimeoutTask = nil
}
private func finishSessionClose() {
sessionCloseContinuation?.resume()
sessionCloseContinuation = nil
closingDeviceUUID = nil
}
private func resetDisconnectLocalState() {
cameraDevice = nil
transactionID = 1
isCatalogReady = false
baselineNotified = false
isRemoteSessionReady = false
keepDeviceOnSupported = false
eventPollTick = 0
seenHandles.removeAll()
ptpAssetURLs.removeAll()
isExternalAttach = false
}
func localURL(forAssetID assetID: String) -> URL? {
ptpAssetURLs[assetID]
}
func allFiles() -> [ICCameraFile] {
guard let camera = cameraDevice else { return [] }
if let mediaFiles = camera.mediaFiles, !mediaFiles.isEmpty {
return mediaFiles.compactMap { $0 as? ICCameraFile }
}
return collectFiles(from: camera.contents ?? [])
}
func file(forAssetID assetID: String) -> ICCameraFile? {
allFiles().first { assetIdentifier(for: $0) == assetID }
}
func download(file: ICCameraFile, to directory: URL) async throws -> URL {
guard let camera = cameraDevice else {
throw CameraServiceError.notConnected
}
let filename = file.name ?? "photo_\(UUID().uuidString).jpg"
let assetID = assetIdentifier(for: file)
CameraTetheringLogger.log("Canon 开始下载 \(filename), handle=\(file.ptpObjectHandle)", logger: CameraTetheringLogger.transfer)
return try await withCheckedThrowingContinuation { continuation in
pendingDownloads[assetID] = continuation
downloadDelegate.onComplete = { [weak self] completedFile, url, error in
guard let self else { return }
let completedID = self.assetIdentifier(for: completedFile)
guard let pending = self.pendingDownloads.removeValue(forKey: completedID) else { return }
if let error {
pending.resume(throwing: error)
} else if let url {
pending.resume(returning: url)
} else {
pending.resume(throwing: CameraServiceError.downloadFailed("未知错误"))
}
}
let options: [ICDownloadOption: Any] = [
.downloadsDirectoryURL: directory,
.savedFilename: filename,
.overwrite: true
]
camera.requestDownloadFile(
file,
options: options,
downloadDelegate: downloadDelegate,
didDownloadSelector: #selector(CameraDownloadDelegate.didDownloadFile(_:error:options:contextInfo:)),
contextInfo: nil
)
}
}
func assetIdentifier(for file: ICCameraFile) -> String {
if file.ptpObjectHandle != 0 {
return "ptp_\(file.ptpObjectHandle)"
}
if let persistentID = file.value(forKey: "persistentID") as? String, !persistentID.isEmpty {
return persistentID
}
let name = file.name ?? "unknown"
let size = file.fileSize
let date = file.creationDate?.timeIntervalSince1970 ?? 0
return "\(name)_\(size)_\(Int(date))"
}
func makeAsset(from file: ICCameraFile) -> CameraAsset {
let filename = file.name ?? "unknown"
let isMovie = file.duration > 0 || Self.movieExtensions.contains(where: { filename.lowercased().hasSuffix($0) })
return CameraAsset(
id: assetIdentifier(for: file),
filename: filename,
fileSize: file.fileSize,
creationDate: file.creationDate,
isMovie: isMovie
)
}
private static let movieExtensions = [".mp4", ".mov", ".m4v", ".avi"]
private func collectFiles(from items: [ICCameraItem]) -> [ICCameraFile] {
var files: [ICCameraFile] = []
for item in items {
if let file = item as? ICCameraFile {
files.append(file)
} else if let folder = item as? ICCameraFolder {
files.append(contentsOf: collectFiles(from: folder.contents ?? []))
}
}
return files
}
private func updateState(_ state: CameraConnectionState) {
CameraTetheringLogger.log("Canon 连接状态: \(state.displayText)")
onConnectionStateChange?(state)
}
private func requestAuthorizations(completion: @escaping (Bool, Bool) -> Void) {
guard let browser = deviceBrowser else {
completion(false, false)
return
}
var contentsGranted = false
var controlGranted = false
let group = DispatchGroup()
group.enter()
browser.requestContentsAuthorization { status in
contentsGranted = status == .authorized
group.leave()
}
group.enter()
browser.requestControlAuthorization { status in
controlGranted = status == .authorized
group.leave()
}
group.notify(queue: .main) {
completion(contentsGranted, controlGranted)
}
}
private func openSession(for camera: ICCameraDevice) {
updateState(.connecting)
camera.delegate = self
cameraDevice = camera
CameraTetheringLogger.log("Canon 打开相机会务: \(camera.name ?? "Unknown")")
camera.requestOpenSession(options: nil) { [weak self] error in
guard let self else { return }
if let error {
CameraTetheringLogger.log("Canon 会话打开失败: \(error.localizedDescription)")
self.onError?(error.localizedDescription)
self.updateState(.error(error.localizedDescription))
return
}
let name = camera.name ?? "Canon Camera"
CameraTetheringLogger.log("Canon 会话已打开: \(name)")
self.updateState(.connected(deviceName: name))
self.configureCameraAfterSession(camera)
}
}
private func configureCameraAfterSession(_ camera: ICCameraDevice) {
let capabilities = camera.capabilities.joined(separator: ", ")
CameraTetheringLogger.log("Canon 相机能力: [\(capabilities)]")
camera.ptpEventHandler = { [weak self] eventData in
self?.handlePTPEvent(eventData)
}
sessionSetupTask = Task { [weak self] in
guard let self else { return }
let session = await CanonPTPHelper.initializeRemoteSession(
on: camera,
startingTransactionID: transactionID
)
guard !Task.isCancelled, !self.isDisconnecting else { return }
transactionID = session.transactionID
keepDeviceOnSupported = session.keepDeviceOnSupported
isRemoteSessionReady = true
startEventPolling()
}
}
private func handlePTPEvent(_ eventData: Data) {
guard !isDisconnecting else { return }
let hex = eventData.prefix(16).map { String(format: "%02X", $0) }.joined(separator: " ")
CameraTetheringLogger.log("Canon 收到 PTP 事件: \(hex)", logger: CameraTetheringLogger.ptp)
}
private func startEventPolling() {
stopEventPolling()
CameraTetheringLogger.log("Canon: 启动 GetEvent 轮询 (每 \(CanonPTPCommand.getEventInterval)s)", logger: CameraTetheringLogger.ptp)
eventPollTask = Task { [weak self] in
while !Task.isCancelled {
guard let self, !self.isDisconnecting else { return }
await self.pollEventsOnce()
try? await Task.sleep(nanoseconds: UInt64(CanonPTPCommand.getEventInterval * 1_000_000_000))
}
}
}
private func stopEventPolling() {
eventPollTask?.cancel()
eventPollTask = nil
}
private func pollEventsOnce() async {
guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { return }
eventPollTick += 1
if keepDeviceOnSupported, eventPollTick % CanonPTPCommand.keepOnIntervalTicks == 0 {
var localID = transactionID
_ = await PTPHelper.sendCommand(
on: camera,
code: CanonPTPCommand.eosKeepDeviceOn,
transactionID: &localID,
parameters: [],
outData: nil,
label: "KeepDeviceOn(poll)"
)
transactionID = localID
}
var localID = transactionID
let blob = await CanonPTPHelper.pollEvents(on: camera, transactionID: &localID)
transactionID = localID
guard !blob.isEmpty else { return }
let parsed = CanonEosEventParser.parse(blob)
let addedCount = parsed.filter {
if case .objectAdded = $0 { return true }
return false
}.count
if addedCount > 0 {
CameraTetheringLogger.log("Canon GetEvent: \(parsed.count) 事件, 新增 \(addedCount)", logger: CameraTetheringLogger.ptp)
}
for event in parsed {
switch event {
case .objectAdded(let added):
await handleObjectAdded(added)
case .requestObjectTransfer(let handle):
CameraTetheringLogger.log("Canon RequestObjectTransfer handle=\(handle)", logger: CameraTetheringLogger.ptp)
case .cameraStatus:
break
}
}
}
private func handleObjectAdded(_ added: CanonEosEventParser.ObjectAdded) async {
guard !isDisconnecting else { return }
guard seenHandles.insert(added.objectHandle).inserted else { return }
let asset = CanonEosEventParser.makeAsset(from: added)
guard CanonPTPHelper.isSupportedFilename(asset.filename) else {
CameraTetheringLogger.log("Canon: 跳过不支持的文件 \(asset.filename)", logger: CameraTetheringLogger.transfer)
return
}
guard !isDownloadingPTPObject else {
CameraTetheringLogger.log("Canon: 下载进行中,稍后重试 \(asset.filename)", logger: CameraTetheringLogger.transfer)
seenHandles.remove(added.objectHandle)
return
}
isDownloadingPTPObject = true
defer { isDownloadingPTPObject = false }
guard let camera = cameraDevice else { return }
var localID = transactionID
guard let data = await CanonPTPHelper.downloadObject(
handle: added.objectHandle,
on: camera,
transactionID: &localID
) else {
seenHandles.remove(added.objectHandle)
CameraTetheringLogger.log("Canon: PTP 下载失败 \(asset.filename)", logger: CameraTetheringLogger.transfer)
return
}
transactionID = localID
await CanonPTPHelper.transferComplete(
handle: added.objectHandle,
on: camera,
transactionID: &localID
)
transactionID = localID
let destination = CameraDownloadStorage.uniqueTemporaryURL(for: asset.filename)
do {
try data.write(to: destination, options: .atomic)
} catch {
seenHandles.remove(added.objectHandle)
CameraTetheringLogger.log("Canon: 文件写入失败 \(error.localizedDescription)", logger: CameraTetheringLogger.transfer)
return
}
ptpAssetURLs[asset.id] = destination
CameraTetheringLogger.log(
"Canon PTP 下载完成: \(asset.filename) -> \(destination.path)",
logger: CameraTetheringLogger.transfer
)
onPTPAssetReady?(asset, destination)
}
private func notifyBaselineIfNeeded() {
guard !baselineNotified else { return }
baselineNotified = true
isCatalogReady = true
let fileCount = allFiles().count
CameraTetheringLogger.log("Canon 目录就绪,当前 \(fileCount) 个文件,建立基线")
onBaselineCatalogReady?()
startPolling()
}
private func startPolling() {
stopPolling()
CameraTetheringLogger.log("Canon: 启动轮询检测新照片 (每 1.5s)")
pollTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in
guard let self, self.isCatalogReady else { return }
self.onPollTick?()
}
}
private func stopPolling() {
pollTimer?.invalidate()
pollTimer = nil
}
}
extension CanonImageCaptureDeviceManager: ICDeviceBrowserDelegate {
@objc(deviceBrowser:didAddDevice:moreComing:)
func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) {
CameraTetheringLogger.log("Canon 发现设备: \(device.name ?? "?")")
guard let camera = device as? ICCameraDevice else { return }
guard cameraDevice == nil else { return }
requestAuthorizations { [weak self] contentsGranted, controlGranted in
guard let self else { return }
guard contentsGranted else {
let message = "未获得相机内容访问权限,请在系统设置中允许"
self.onError?(message)
self.updateState(.error("权限被拒绝"))
return
}
if !controlGranted {
CameraTetheringLogger.log("Canon: 控制授权未通过,仍尝试连接")
}
self.openSession(for: camera)
}
}
@objc(deviceBrowser:didRemoveDevice:moreGoing:)
func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) {
guard !isDisconnecting else { return }
if device.uuidString == cameraDevice?.uuidString {
Task { await disconnect() }
updateState(.disconnected)
}
}
}
extension CanonImageCaptureDeviceManager: ICDeviceDelegate {
func didRemove(_ device: ICDevice) {
guard !isDisconnecting else { return }
if device.uuidString == cameraDevice?.uuidString {
Task { await disconnect() }
updateState(.disconnected)
}
}
func device(_ device: ICDevice, didOpenSessionWithError error: Error?) {
if let error {
CameraTetheringLogger.log("Canon delegate didOpenSession 失败: \(error.localizedDescription)")
}
}
func device(_ device: ICDevice, didCloseSessionWithError error: Error?) {
if device.uuidString == closingDeviceUUID {
finishSessionClose()
}
guard !isDisconnecting else { return }
if device.uuidString == cameraDevice?.uuidString {
cameraDevice = nil
isCatalogReady = false
baselineNotified = false
isRemoteSessionReady = false
stopPolling()
stopEventPolling()
updateState(.disconnected)
}
}
}
extension CanonImageCaptureDeviceManager: ICCameraDeviceDelegate {
@objc(cameraDevice:didAddItems:)
func cameraDevice(_ camera: ICCameraDevice, didAdd items: [ICCameraItem]) {
let names = items.compactMap { ($0 as? ICCameraFile)?.name ?? $0.name }
CameraTetheringLogger.log("Canon didAddItems: \(names.joined(separator: ", "))")
onNewItems?(items)
}
@objc(cameraDevice:didRemoveItems:)
func cameraDevice(_ camera: ICCameraDevice, didRemove items: [ICCameraItem]) {
}
@objc(cameraDevice:didRenameItems:)
func cameraDevice(_ camera: ICCameraDevice, didRenameItems items: [ICCameraItem]) {
}
func cameraDevice(_ camera: ICCameraDevice, didReceiveThumbnail thumbnail: CGImage?, for item: ICCameraItem, error: Error?) {
}
func cameraDevice(_ camera: ICCameraDevice, didReceiveMetadata metadata: [AnyHashable: Any]?, for item: ICCameraItem, error: Error?) {
}
func deviceDidBecomeReady(withCompleteContentCatalog device: ICCameraDevice) {
CameraTetheringLogger.log("Canon deviceDidBecomeReady, 文件数=\(allFiles().count)")
notifyBaselineIfNeeded()
}
func cameraDeviceDidChangeCapability(_ camera: ICCameraDevice) {
}
func cameraDeviceDidRemoveAccessRestriction(_ device: ICDevice) {
}
func cameraDeviceDidEnableAccessRestriction(_ device: ICDevice) {
onError?("设备已锁定,解锁后才能读取媒体文件")
}
func cameraDevice(_ camera: ICCameraDevice, didReceivePTPEvent eventData: Data) {
handlePTPEvent(eventData)
}
}

View File

@ -0,0 +1,177 @@
import Foundation
import ImageCaptureCore
/// Canon EOS PTP
enum CanonPTPCommand {
static let eosGetObject: UInt16 = 0x9104
static let eosGetStorageIDs: UInt16 = 0x9101
static let eosGetObjectInfoEx: UInt16 = 0x9109
static let eosGetPartialObject: UInt16 = 0x9107
static let eosGetThumbEx: UInt16 = 0x910A
static let eosSetRemoteMode: UInt16 = 0x9114
static let eosSetEventMode: UInt16 = 0x9115
static let eosGetEvent: UInt16 = 0x9116
static let eosTransferComplete: UInt16 = 0x9117
static let eosCancelTransfer: UInt16 = 0x9118
static let eosKeepDeviceOn: UInt16 = 0x911D
static let eosObjectAddedEx: UInt16 = 0xC181
static let eosObjectRemoved: UInt16 = 0xC182
static let eosRequestGetObjectInfoEx: UInt16 = 0xC183
static let eosStorageStatusChanged: UInt16 = 0xC184
static let eosStorageInfoChanged: UInt16 = 0xC185
static let eosObjectInfoChangedEx: UInt16 = 0xC189
static let eosCameraStatusChanged: UInt16 = 0xC18A
static let eosRequestObjectTransfer: UInt16 = 0xC18B
static let eosObjectAddedEx2: UInt16 = 0xC1A7
static let getObjectInfo: UInt16 = 0x1008
static let getObject: UInt16 = 0x1009
static let getThumb: UInt16 = 0x100A
static let closeSession: UInt16 = 0x1003
static let getEventInterval: TimeInterval = 0.5
static let keepOnIntervalTicks: Int = 60
static let supportedExtensions: Set<String> = [
"jpg", "jpeg", "png", "cr2", "cr3", "mp4", "mov"
]
}
/// Canon EOS PTP
enum CanonPTPHelper {
/// ImageCaptureCore Canon PC Remote
static func initializeRemoteSession(on camera: ICCameraDevice, startingTransactionID: UInt32) async -> (transactionID: UInt32, keepDeviceOnSupported: Bool) {
var transactionID = startingTransactionID
guard camera.capabilities.contains("ICCameraDeviceCanAcceptPTPCommands") else {
CameraTetheringLogger.log("Canon: 相机不支持 PTP 指令", logger: CameraTetheringLogger.ptp)
return (transactionID, false)
}
CameraTetheringLogger.log("开始 Canon EOS 远程会话初始化", logger: CameraTetheringLogger.ptp)
let remoteMode = await sendCommand(
on: camera,
code: CanonPTPCommand.eosSetRemoteMode,
transactionID: &transactionID,
parameters: [1],
outData: nil,
label: "SetRemoteMode"
)
CameraTetheringLogger.log("SetRemoteMode: \(remoteMode.success ? "OK" : "failed")", logger: CameraTetheringLogger.ptp)
let eventMode = await sendCommand(
on: camera,
code: CanonPTPCommand.eosSetEventMode,
transactionID: &transactionID,
parameters: [1],
outData: nil,
label: "SetEventMode"
)
CameraTetheringLogger.log("SetEventMode: \(eventMode.success ? "OK" : "failed")", logger: CameraTetheringLogger.ptp)
let keepOn = await sendCommand(
on: camera,
code: CanonPTPCommand.eosKeepDeviceOn,
transactionID: &transactionID,
parameters: [],
outData: nil,
label: "KeepDeviceOn"
)
let keepOnSupported = keepOn.success
CameraTetheringLogger.log("KeepDeviceOn: \(keepOnSupported ? "OK" : "skipped")", logger: CameraTetheringLogger.ptp)
CameraTetheringLogger.log("Canon EOS 远程会话初始化完成", logger: CameraTetheringLogger.ptp)
return (transactionID, keepOnSupported)
}
/// PTP PC Remote
static func closeRemoteSession(on camera: ICCameraDevice, transactionID: inout UInt32) async {
_ = await sendCommand(
on: camera,
code: CanonPTPCommand.closeSession,
transactionID: &transactionID,
parameters: [],
outData: nil,
label: "CloseSession"
)
}
/// Canon
static func pollEvents(
on camera: ICCameraDevice,
transactionID: inout UInt32
) async -> Data {
let result = await sendCommand(
on: camera,
code: CanonPTPCommand.eosGetEvent,
transactionID: &transactionID,
parameters: [],
outData: nil,
label: "GetEvent"
)
return result.success ? result.inData : Data()
}
/// Canon GetObject
static func downloadObject(
handle: UInt32,
on camera: ICCameraDevice,
transactionID: inout UInt32
) async -> Data? {
let result = await sendCommand(
on: camera,
code: CanonPTPCommand.eosGetObject,
transactionID: &transactionID,
parameters: [handle],
outData: nil,
label: "EOS_GetObject(0x\(String(format: "%08X", handle)))"
)
guard result.success, !result.inData.isEmpty else { return nil }
return result.inData
}
///
static func transferComplete(
handle: UInt32,
on camera: ICCameraDevice,
transactionID: inout UInt32
) async {
_ = await sendCommand(
on: camera,
code: CanonPTPCommand.eosTransferComplete,
transactionID: &transactionID,
parameters: [handle],
outData: nil,
label: "TransferComplete"
)
}
static func isSupportedFilename(_ filename: String) -> Bool {
let ext = (filename as NSString).pathExtension.lowercased()
return CanonPTPCommand.supportedExtensions.contains(ext)
}
static func assetID(handle: UInt32, filename: String, size: Int64) -> String {
"canon_\(handle)_\(filename)_\(size)"
}
private static func sendCommand(
on camera: ICCameraDevice,
code: UInt16,
transactionID: inout UInt32,
parameters: [UInt32],
outData: Data?,
label: String
) async -> PTPCommandResult {
await PTPHelper.sendCommand(
on: camera,
code: code,
transactionID: &transactionID,
parameters: parameters,
outData: outData,
label: label
)
}
}

View File

@ -5,8 +5,15 @@ enum CameraServiceFactory {
switch brand {
case .sony:
SonyCameraService()
case .canon, .nikon:
case .canon:
CanonCameraService()
case .nikon:
preconditionFailure("\(brand.displayName) 相机尚未实现")
}
}
/// USB detach /
static func makeAutoDetect() -> any CameraServiceProtocol {
CameraTetheringSession.sharedService
}
}

View File

@ -0,0 +1,25 @@
import Foundation
import ImageCaptureCore
/// USB Vendor ID
enum CameraUSBVendorID {
static let sony: Int = 0x054C
static let canon: Int = 0x04A9
static let nikon: Int = 0x04B0
}
extension CameraBrand {
/// ICCameraDevice USB Vendor ID
static func detect(from camera: ICCameraDevice) -> CameraBrand? {
switch Int(camera.usbVendorID) {
case CameraUSBVendorID.sony:
return .sony
case CameraUSBVendorID.canon:
return .canon
case CameraUSBVendorID.nikon:
return .nikon
default:
return nil
}
}
}

View File

@ -27,7 +27,17 @@ protocol CameraServiceProtocol: AnyObject {
var onNewAsset: ((CameraAsset) -> Void)? { get set }
func connect() async
func disconnect() async
/// UI
func detachFromUI()
/// App
func shutdown() async
func listAssets() async throws -> [CameraAsset]
func downloadAsset(_ asset: CameraAsset) async throws -> URL
}
extension CameraServiceProtocol {
/// `shutdown()`
func disconnect() async {
await shutdown()
}
}

View File

@ -2,39 +2,6 @@ import CoreGraphics
import Foundation
import 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))
}
}
final class ImageCaptureDeviceManager: NSObject {
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
var onNewItems: (([ICCameraItem]) -> Void)?
@ -62,8 +29,27 @@ final class ImageCaptureDeviceManager: NSObject {
private var isDrainingShotQueue = false
private var isEvaluatingShotBuffer = false
private var propertyCheckCoalesceScheduled = false
private var isExternalAttach = false
private var sessionSetupTask: Task<Void, Never>?
private var sessionCloseContinuation: CheckedContinuation<Void, Never>?
private var sessionCloseTimeoutTask: Task<Void, Never>?
private var closingDeviceUUID: String?
private var shotDownloadDrainTask: Task<Void, Never>?
private var coalescedCheckTask: Task<Void, Never>?
private var isDisconnecting = false
var isBrowsing: Bool { deviceBrowser != nil }
var isBrowsing: Bool { deviceBrowser != nil || isExternalAttach }
/// Browser AutoDetect
func attach(to camera: ICCameraDevice) {
guard cameraDevice == nil else {
CameraTetheringLogger.log("Sony attach: 已有连接中的相机,忽略")
return
}
isExternalAttach = true
CameraTetheringLogger.log("Sony attach: \(camera.name ?? "Unknown")")
openSession(for: camera)
}
func startBrowsing() {
guard deviceBrowser == nil else {
@ -90,7 +76,7 @@ final class ImageCaptureDeviceManager: NSObject {
browser.start()
}
func stopBrowsing() {
func stopBrowsing() async {
CameraTetheringLogger.log("停止搜索并断开")
propertyCheckTask?.cancel()
shotDownloadQueue.removeAll()
@ -100,28 +86,94 @@ final class ImageCaptureDeviceManager: NSObject {
propertyCheckTask = nil
stopShotBufferPolling()
stopPolling()
disconnect()
await disconnect()
if !isExternalAttach {
deviceBrowser?.stop()
}
deviceBrowser = nil
isExternalAttach = false
isCatalogReady = false
baselineNotified = false
updateState(.disconnected)
}
func disconnect() {
func disconnect() async {
guard !isDisconnecting else { return }
isDisconnecting = true
defer { isDisconnecting = false }
sessionSetupTask?.cancel()
sessionSetupTask = nil
shotDownloadDrainTask?.cancel()
shotDownloadDrainTask = nil
coalescedCheckTask?.cancel()
coalescedCheckTask = nil
propertyCheckTask?.cancel()
propertyCheckTask = nil
let shouldClosePTP = isRemoteSessionReady
isRemoteSessionReady = false
isDownloadingPTPObject = false
shotDownloadQueue.removeAll()
isDrainingShotQueue = false
isEvaluatingShotBuffer = false
propertyCheckCoalesceScheduled = false
propertyCheckTask = nil
stopShotBufferPolling()
stopPolling()
if let camera = cameraDevice {
camera.ptpEventHandler = { _ in }
camera.requestCloseSession()
camera.delegate = nil
cancelPendingDownloads()
guard let camera = cameraDevice else {
resetDisconnectLocalState()
return
}
let deviceUUID = camera.uuidString ?? ""
camera.ptpEventHandler = { _ in }
if shouldClosePTP {
var localTransactionID = transactionID
await SonyPTPHelper.teardownRemoteSession(on: camera, transactionID: &localTransactionID)
}
await waitForSessionClose(on: camera, deviceUUID: deviceUUID)
camera.delegate = nil
resetDisconnectLocalState()
}
private func cancelPendingDownloads() {
let disconnected = CameraServiceError.notConnected
for (_, continuation) in pendingDownloads {
continuation.resume(throwing: disconnected)
}
pendingDownloads.removeAll()
}
private func waitForSessionClose(on camera: ICCameraDevice, deviceUUID: String) async {
sessionCloseTimeoutTask?.cancel()
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
sessionCloseContinuation = continuation
closingDeviceUUID = deviceUUID
camera.requestCloseSession()
sessionCloseTimeoutTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 3_000_000_000)
guard let self, self.sessionCloseContinuation != nil else { return }
CameraTetheringLogger.log("会话关闭超时,继续 teardown")
self.finishSessionClose()
}
}
sessionCloseTimeoutTask?.cancel()
sessionCloseTimeoutTask = nil
}
private func finishSessionClose() {
sessionCloseContinuation?.resume()
sessionCloseContinuation = nil
closingDeviceUUID = nil
}
private func resetDisconnectLocalState() {
cameraDevice = nil
transactionID = 1
isCatalogReady = false
@ -134,6 +186,7 @@ final class ImageCaptureDeviceManager: NSObject {
isDrainingShotQueue = false
isEvaluatingShotBuffer = false
propertyCheckCoalesceScheduled = false
isExternalAttach = false
}
func localURL(forAssetID assetID: String) -> URL? {
@ -286,11 +339,13 @@ final class ImageCaptureDeviceManager: NSObject {
self?.handlePTPEvent(eventData)
}
Task {
sessionSetupTask = Task { [weak self] in
guard let self else { return }
let updatedID = await SonyPTPHelper.initializeRemoteSession(
on: camera,
startingTransactionID: transactionID
)
guard !Task.isCancelled, !self.isDisconnecting else { return }
transactionID = updatedID
isRemoteSessionReady = true
startShotBufferPolling()
@ -299,6 +354,7 @@ final class ImageCaptureDeviceManager: NSObject {
}
private func handlePTPEvent(_ eventData: Data) {
guard !isDisconnecting, isRemoteSessionReady else { return }
let hex = eventData.prefix(16).map { String(format: "%02X", $0) }.joined(separator: " ")
CameraTetheringLogger.log("收到 PTP 事件: \(hex)", logger: CameraTetheringLogger.ptp)
@ -331,6 +387,7 @@ final class ImageCaptureDeviceManager: NSObject {
}
private func schedulePropertyBasedShotCheck(reason: String, delay: TimeInterval = 0.12) {
guard !isDisconnecting else { return }
if isEvaluatingShotBuffer {
propertyCheckCoalesceScheduled = true
return
@ -344,6 +401,7 @@ final class ImageCaptureDeviceManager: NSObject {
}
private func runPropertyBasedShotCheck(reason: String) async {
guard !isDisconnecting else { return }
if isEvaluatingShotBuffer {
propertyCheckCoalesceScheduled = true
return
@ -351,19 +409,23 @@ final class ImageCaptureDeviceManager: NSObject {
isEvaluatingShotBuffer = true
defer {
isEvaluatingShotBuffer = false
if propertyCheckCoalesceScheduled {
if propertyCheckCoalesceScheduled, !isDisconnecting {
propertyCheckCoalesceScheduled = false
Task { [weak self] in
coalescedCheckTask?.cancel()
coalescedCheckTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 80_000_000)
guard !Task.isCancelled else { return }
await self?.runPropertyBasedShotCheck(reason: "Coalesced")
}
} else {
propertyCheckCoalesceScheduled = false
}
}
await evaluateShotBufferFromProperties(reason: reason)
}
private func evaluateShotBufferFromProperties(reason: String) async {
guard isRemoteSessionReady, let camera = cameraDevice else { return }
guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { return }
var localTransactionID = transactionID
var properties = await SonyPTPHelper.fetchExtDeviceProperties(
@ -423,7 +485,7 @@ final class ImageCaptureDeviceManager: NSObject {
/// Probe filename+size
@discardableResult
private func attemptFetchPendingShot(reason: String) async -> Bool {
guard isRemoteSessionReady, let camera = cameraDevice else { return false }
guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { return false }
var localTransactionID = transactionID
guard let probe = await SonyPTPHelper.probeShotObject(
@ -452,11 +514,11 @@ final class ImageCaptureDeviceManager: NSObject {
}
private func refreshShotBufferPollInterval() {
guard isRemoteSessionReady else { return }
guard !isDisconnecting, isRemoteSessionReady else { return }
let interval = (isDrainingShotQueue || !shotDownloadQueue.isEmpty) ? 0.25 : 1.0
stopShotBufferPolling()
shotBufferPollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
guard let self, self.isRemoteSessionReady else { return }
guard let self, !self.isDisconnecting, self.isRemoteSessionReady else { return }
Task { await self.evaluateShotBufferFromProperties(reason: "D215Poll") }
}
}
@ -468,6 +530,7 @@ final class ImageCaptureDeviceManager: NSObject {
/// PTP
private func enqueueShotDownload(handle: UInt32, reason: String) {
guard !isDisconnecting else { return }
shotDownloadQueue.append((handle: handle, reason: reason))
CameraTetheringLogger.log(
"\(reason): 入队 PTP 下载 (队列=\(shotDownloadQueue.count))",
@ -478,11 +541,13 @@ final class ImageCaptureDeviceManager: NSObject {
}
private func startShotDownloadDrainIfNeeded() {
guard !isDisconnecting else { return }
guard !isDrainingShotQueue else { return }
guard !shotDownloadQueue.isEmpty else { return }
isDrainingShotQueue = true
Task { [weak self] in
shotDownloadDrainTask?.cancel()
shotDownloadDrainTask = Task { [weak self] in
await self?.drainShotDownloadQueue()
}
}
@ -490,13 +555,16 @@ final class ImageCaptureDeviceManager: NSObject {
private func drainShotDownloadQueue() async {
defer {
isDrainingShotQueue = false
shotDownloadDrainTask = nil
if !isDisconnecting {
refreshShotBufferPollInterval()
if !shotDownloadQueue.isEmpty {
startShotDownloadDrainIfNeeded()
}
}
}
while !shotDownloadQueue.isEmpty {
while !shotDownloadQueue.isEmpty, !isDisconnecting {
let item = shotDownloadQueue.removeFirst()
_ = await downloadPTPObject(
handle: item.handle,
@ -507,13 +575,14 @@ final class ImageCaptureDeviceManager: NSObject {
try? await Task.sleep(nanoseconds: 120_000_000)
}
guard !isDisconnecting else { return }
await drainRemainingBuffer(maxExtraDownloads: 12)
}
/// D215/Probe
private func drainRemainingBuffer(maxExtraDownloads: Int) async {
for round in 1...maxExtraDownloads {
guard isRemoteSessionReady, let camera = cameraDevice else { break }
guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { break }
var localTransactionID = transactionID
guard let probe = await SonyPTPHelper.probeShotObject(
@ -548,7 +617,7 @@ final class ImageCaptureDeviceManager: NSObject {
maxAttempts: Int = 3,
tryDirectGetObject: Bool = false
) async -> Bool {
guard isRemoteSessionReady, let camera = cameraDevice else {
guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else {
CameraTetheringLogger.log("\(reason): Sony 远程会话尚未就绪", logger: CameraTetheringLogger.ptp)
return false
}
@ -564,6 +633,7 @@ final class ImageCaptureDeviceManager: NSObject {
var localTransactionID = transactionID
for attempt in 1...maxAttempts {
guard !isDisconnecting else { return false }
if attempt > 1 {
try? await Task.sleep(nanoseconds: UInt64(attempt) * 350_000_000)
}
@ -676,6 +746,7 @@ final class ImageCaptureDeviceManager: NSObject {
}
extension ImageCaptureDeviceManager: ICDeviceBrowserDelegate {
@objc(deviceBrowser:didAddDevice:moreComing:)
func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) {
CameraTetheringLogger.log("发现设备: \(device.name ?? "?"), type=\(device.type), transport=\(device.transportType ?? "?")")
@ -704,10 +775,12 @@ extension ImageCaptureDeviceManager: ICDeviceBrowserDelegate {
}
}
@objc(deviceBrowser:didRemoveDevice:moreGoing:)
func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) {
guard !isDisconnecting else { return }
CameraTetheringLogger.log("设备移除: \(device.name ?? "?")")
if device.uuidString == cameraDevice?.uuidString {
disconnect()
Task { await disconnect() }
updateState(.disconnected)
}
}
@ -715,9 +788,10 @@ extension ImageCaptureDeviceManager: ICDeviceBrowserDelegate {
extension ImageCaptureDeviceManager: ICDeviceDelegate {
func didRemove(_ device: ICDevice) {
guard !isDisconnecting else { return }
CameraTetheringLogger.log("didRemove: \(device.name ?? "?")")
if device.uuidString == cameraDevice?.uuidString {
disconnect()
Task { await disconnect() }
updateState(.disconnected)
}
}
@ -742,11 +816,17 @@ extension ImageCaptureDeviceManager: ICDeviceDelegate {
if let error {
CameraTetheringLogger.log("会话关闭: \(error.localizedDescription)")
}
if device.uuidString == closingDeviceUUID {
finishSessionClose()
}
guard !isDisconnecting else { return }
if device.uuidString == cameraDevice?.uuidString {
cameraDevice = nil
isCatalogReady = false
baselineNotified = false
isRemoteSessionReady = false
stopPolling()
stopShotBufferPolling()
updateState(.disconnected)
}
}

View File

@ -12,6 +12,7 @@ final class SonyCameraService: CameraServiceProtocol {
private let deviceManager = ImageCaptureDeviceManager()
private var knownAssetIDs: Set<String> = []
private var baselineEstablished = false
private var pendingAssetsWhileDetached: [CameraAsset] = []
init() {
deviceManager.onConnectionStateChange = { [weak self] state in
@ -64,11 +65,39 @@ final class SonyCameraService: CameraServiceProtocol {
}
}
func disconnect() async {
CameraTetheringLogger.log("SonyCameraService.disconnect()")
deviceManager.stopBrowsing()
/// AutoDetect
func attach(to camera: ICCameraDevice) async {
CameraTetheringLogger.log("SonyCameraService.attach()")
knownAssetIDs.removeAll()
baselineEstablished = false
pendingAssetsWhileDetached.removeAll()
deviceManager.attach(to: camera)
}
/// UI PTP
func detachFromUI() {
CameraTetheringLogger.log("SonyCameraService.detachFromUI()")
onConnectionStateChange = nil
onNewAsset = nil
}
/// detach
func reattachToUI() {
guard !pendingAssetsWhileDetached.isEmpty else { return }
let pending = pendingAssetsWhileDetached
pendingAssetsWhileDetached.removeAll()
CameraTetheringLogger.log("SonyCameraService.reattachToUI: 补发 \(pending.count) 张离线新片")
for asset in pending {
onNewAsset?(asset)
}
}
func shutdown() async {
CameraTetheringLogger.log("SonyCameraService.shutdown()")
await deviceManager.stopBrowsing()
knownAssetIDs.removeAll()
baselineEstablished = false
pendingAssetsWhileDetached.removeAll()
connectionState = .disconnected
onConnectionStateChange?(.disconnected)
}
@ -100,13 +129,7 @@ final class SonyCameraService: CameraServiceProtocol {
}
private func handlePTPAsset(_ asset: CameraAsset) {
guard !knownAssetIDs.contains(asset.id) else { return }
knownAssetIDs.insert(asset.id)
CameraTetheringLogger.log(
"[PTP] 发现新照片: \(asset.filename) id=\(asset.id) size=\(asset.fileSize)",
logger: CameraTetheringLogger.transfer
)
onNewAsset?(asset)
deliverNewAsset(asset, source: "PTP")
}
/// 线
@ -153,10 +176,20 @@ final class SonyCameraService: CameraServiceProtocol {
private func emitAssetIfNew(file: ICCameraFile, source: String) {
let asset = deviceManager.makeAsset(from: file)
guard !knownAssetIDs.contains(asset.id) else { return }
deliverNewAsset(asset, source: source)
}
private func deliverNewAsset(_ asset: CameraAsset, source: String) {
guard !knownAssetIDs.contains(asset.id) else { return }
knownAssetIDs.insert(asset.id)
CameraTetheringLogger.log("[\(source)] 发现新照片: \(asset.filename) id=\(asset.id) size=\(asset.fileSize)", logger: CameraTetheringLogger.transfer)
onNewAsset?(asset)
CameraTetheringLogger.log(
"[\(source)] 发现新照片: \(asset.filename) id=\(asset.id) size=\(asset.fileSize)",
logger: CameraTetheringLogger.transfer
)
if let onNewAsset {
onNewAsset(asset)
} else {
pendingAssetsWhileDetached.append(asset)
}
}
}

View File

@ -1,26 +1,6 @@
import Foundation
import ImageCaptureCore
enum PTPContainerType: UInt16 {
case command = 0x0001
case data = 0x0002
case response = 0x0003
case event = 0x0004
}
struct PTPContainer {
let length: UInt32
let type: UInt16
let code: UInt16
let transactionID: UInt32
let params: [UInt32]
}
enum PTPParseError: Error {
case tooShort
case invalidLength
}
enum SonyPTPCommand {
static let getDeviceInfo: UInt16 = 0x1001
static let openSession: UInt16 = 0x1002
@ -42,7 +22,7 @@ enum SonyPTPCommand {
static let stillImageTransSize: UInt16 = 0xD268
static let shootingFileInfo: UInt16 = 0xD215
static let shotObjectHandle: UInt32 = 0xFFFF_C001
static let ptpResponseOk: UInt16 = 0x2001
static let ptpResponseOk: UInt16 = PTPResponseCode.ok.rawValue
static let saveDestinationHostPC: UInt16 = 1
static let saveDestinationHostAndCamera: UInt16 = 2
static let shotBufferReadyThreshold: Int64 = 0x8000
@ -66,90 +46,35 @@ enum SonyPTPHelper {
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
PTPHelper.buildCommand(code: code, transactionID: transactionID, parameters: parameters)
}
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
)
try PTPHelper.parseContainer(data)
}
static func parseEventCode(from eventData: Data) -> UInt16? {
guard let container = try? parseContainer(eventData) else { return nil }
return container.code
PTPHelper.parseEventCode(from: eventData)
}
static func parseObjectCompressedSize(_ data: Data) -> UInt32? {
guard data.count >= 12 else { return nil }
return readUInt32LE(data, offset: 8)
PTPHelper.parseObjectCompressedSize(data)
}
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)
PTPHelper.parseObjectInfoFilename(data)
}
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))
PTPHelper.parsePTPString(data, offset: start)
}
offset += charCount * 2
let string = String(utf16CodeUnits: codeUnits, count: codeUnits.count)
return (string, offset)
static func readUInt16LE(_ data: Data, offset: Int) -> UInt16 {
PTPHelper.readUInt16LE(data, offset: offset)
}
static func readUInt32LE(_ data: Data, offset: Int) -> UInt32 {
PTPHelper.readUInt32LE(data, offset: offset)
}
/// ImageCaptureCore PTP Sony SDIO PC Remote
@ -212,6 +137,30 @@ enum SonyPTPHelper {
return transactionID
}
/// Sony PC Remote PTP
static func teardownRemoteSession(on camera: ICCameraDevice, transactionID: inout UInt32) async {
_ = await setDeviceProperty(
on: camera,
propertyCode: SonyPTPCommand.positionKeySetting,
value: 0,
transactionID: &transactionID,
label: "Position_Key_Setting=Local"
)
await closeRemoteSession(on: camera, transactionID: &transactionID)
}
/// PTP PC Remote
static func closeRemoteSession(on camera: ICCameraDevice, transactionID: inout UInt32) async {
_ = await sendCommand(
on: camera,
code: SonyPTPCommand.closeSession,
transactionID: &transactionID,
parameters: [],
outData: nil,
label: "CloseSession"
)
}
static func probeShotObject(
on camera: ICCameraDevice,
handle: UInt32 = SonyPTPCommand.shotObjectHandle,
@ -266,22 +215,17 @@ enum SonyPTPHelper {
outData: nil,
label: "GetObject(0x\(String(format: "%08X", handle)))"
)
guard objectResult.success, isLikelyImageData(objectResult.inData) else { return nil }
guard objectResult.success, PTPHelper.isLikelyImageData(objectResult.inData) else { return nil }
return (filename, objectResult.inData)
}
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
PTPHelper.isLikelyImageData(data)
}
static func isResponseOK(_ response: Data) -> Bool {
guard !response.isEmpty else { return true }
guard let parsed = try? parseContainer(response) else { return false }
return parsed.code == SonyPTPCommand.ptpResponseOk
PTPHelper.isResponseOK(response)
}
static func fetchExtDeviceProperties(
@ -448,7 +392,7 @@ enum SonyPTPHelper {
label: String
) async -> Bool {
var payload = Data()
appendUInt32LE(value, to: &payload)
PTPHelper.appendUInt32LE(value, to: &payload)
return await sendCommand(
on: camera,
@ -468,7 +412,7 @@ enum SonyPTPHelper {
label: String
) async -> Bool {
var payload = Data()
appendUInt16LE(value, to: &payload)
PTPHelper.appendUInt16LE(value, to: &payload)
return await sendCommand(
on: camera,
@ -480,12 +424,6 @@ enum SonyPTPHelper {
).success
}
private struct PTPCommandResult {
let success: Bool
let response: Data
let inData: Data
}
private static func sendCommand(
on camera: ICCameraDevice,
code: UInt16,
@ -494,78 +432,13 @@ enum SonyPTPHelper {
outData: Data?,
label: String
) async -> PTPCommandResult {
let currentID = transactionID
transactionID += 1
let commandData = buildCommand(
await PTPHelper.sendCommand(
on: camera,
code: code,
transactionID: currentID,
parameters: parameters
)
return await withCheckedContinuation { continuation in
camera.requestSendPTPCommand(
commandData,
transactionID: &transactionID,
parameters: parameters,
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 != SonyPTPCommand.ptpResponseOk {
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))
}
label: label
)
}
}
private static func appendUInt16LE(_ value: UInt16, to data: inout Data) {
var littleEndian = value.littleEndian
withUnsafeBytes(of: &littleEndian) { raw in
data.append(contentsOf: raw)
}
}
private 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)
}
}

View File

@ -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))
}
}

View File

@ -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)"
}
}

View 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)
}
}

View File

@ -0,0 +1,6 @@
import Foundation
/// USB Browser stop Browser iOS
enum SharedUSBCameraBrowserHub {
static let controller = USBDeviceBrowserController()
}

View File

@ -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 didAddstop 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() }
}
}

View File

@ -26,10 +26,21 @@ final class CameraTransferPipeline {
private let maxRetries = 3
private let deferredNotifyInterval: TimeInterval = 0.15
private let maxConcurrentUploads = 3
private var isUIAttached = false
///
init(cameraService: any CameraServiceProtocol) {
self.cameraService = cameraService
}
/// UI OTG attach
func attachToUI(
onConnectionStateChange: @escaping (CameraConnectionState) -> Void,
onTasksUpdated: @escaping ([CameraTransferTask]) -> Void
) {
isUIAttached = true
self.onTasksUpdated = onTasksUpdated
cameraService.onConnectionStateChange = onConnectionStateChange
cameraService.onNewAsset = { [weak self] asset in
Task { @MainActor in
await self?.handleNewAsset(asset, skipIfExists: false)
@ -37,6 +48,18 @@ final class CameraTransferPipeline {
}
}
/// UI
func detachFromUI() {
guard isUIAttached else { return }
isUIAttached = false
pendingDeferredNotifyTask?.cancel()
pendingDeferredNotifyTask = nil
activeUploadAssetIDs.removeAll()
inFlightAssetIDs.removeAll()
onTasksUpdated = nil
cameraService.detachFromUI()
}
/// Sink
func configure(
uploadSink: (any CameraAssetUploadSink)?,
@ -50,14 +73,33 @@ final class CameraTransferPipeline {
}
}
///
///
func connect() async {
await cameraService.connect()
if cameraService.connectionState.isConnected {
await syncExistingPhotos()
}
}
///
/// App teardown
func shutdown() async {
detachFromUI()
await cameraService.shutdown()
}
/// shutdown
func disconnect() async {
await cameraService.disconnect()
await shutdown()
}
/// USB
func restartCameraDiscovery() async {
if let autoDetect = cameraService as? AutoDetectCameraService {
await autoDetect.restartDiscovery()
return
}
await cameraService.shutdown()
await cameraService.connect()
}
///
@ -65,14 +107,6 @@ final class CameraTransferPipeline {
cameraService.connectionState
}
///
var onConnectionStateChange: ((CameraConnectionState) -> Void)? {
get { nil }
set {
cameraService.onConnectionStateChange = newValue
}
}
///
func retryFailedUploads() async {
guard uploadEnabled, uploadSink != nil else { return }

View File

@ -69,7 +69,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
## 业务流程
1. 创建任务:`POST .../travel-album/create`
2. 进入有线传图Sony 相机 PTP 下载`Core/CameraTethering`
2. 进入有线传图Sony/Canon USB 有线传图`Core/CameraTethering`,自动识别品牌
3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile`
4. 素材登记:`POST .../travel-album/upload-material`
5. 本地照片路径以账号 + 相册 scoped 目录持久化(如 `CameraDownloads/<accountKey>/<albumID>/originals/xxx.JPG`),该功能未上线,不兼容旧版全局目录记录

View File

@ -435,6 +435,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
@Published var showCameraDisconnectedAlert = false
@Published var showHelpDoc = false
@Published var isRefreshingCameraFiles = false
@Published var isSearchingCamera = false
@Published var deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot()
@Published var connectionState: CameraConnectionState = .disconnected
@Published var errorMessage: String?
@ -450,6 +451,9 @@ final class WiredCameraTransferViewModel: ObservableObject {
private var lastProgressPersistDates: [String: Date] = [:]
private var wasConnected = false
private var disconnectAlertTask: Task<Void, Never>?
private var isTearingDownCamera = false
/// true
private var isLeavingPage = false
/// 线 ViewModel
init(
@ -458,17 +462,6 @@ final class WiredCameraTransferViewModel: ObservableObject {
) {
self.context = context
self.pipeline = CameraTransferPipeline(cameraService: cameraService)
pipeline.onConnectionStateChange = { [weak self] state in
Task { @MainActor in
self?.handleConnectionStateChange(state)
}
}
pipeline.onTasksUpdated = { [weak self] tasks in
Task { @MainActor in
await self?.applyPipelineTasks(tasks)
}
}
}
/// Sink
@ -478,6 +471,18 @@ final class WiredCameraTransferViewModel: ObservableObject {
scenicID: Int,
accountContext: AccountContext
) async {
isLeavingPage = false
pipeline.attachToUI(
onConnectionStateChange: { [weak self] state in
self?.handleConnectionStateChange(state)
},
onTasksUpdated: { [weak self] tasks in
Task { @MainActor in
await self?.applyPipelineTasks(tasks)
}
}
)
let accountCachePrefix = accountContext.accountCachePrefix ?? "guest_"
let currentDownloadScope = CameraDownloadStorage.Scope(
accountKey: accountCachePrefix,
@ -505,15 +510,38 @@ final class WiredCameraTransferViewModel: ObservableObject {
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: currentDownloadScope)
refreshDeviceStorageInfo()
loadPersistedPhotos()
do {
await pipeline.connect()
connectionState = pipeline.connectionState
wasConnected = connectionState.isConnected
while !Task.isCancelled {
try await Task.sleep(nanoseconds: 1_000_000_000)
}
} catch is CancellationError {
// task
} catch {
errorMessage = error.localizedDescription
}
detachFromUI()
}
///
/// detach UI
func stop() async {
detachFromUI()
}
/// UI
func detachFromUI() {
guard !isTearingDownCamera else { return }
isTearingDownCamera = true
isLeavingPage = true
defer { isTearingDownCamera = false }
disconnectAlertTask?.cancel()
await pipeline.disconnect()
showCameraDisconnectedAlert = false
pipeline.detachFromUI()
}
///
@ -571,12 +599,25 @@ final class WiredCameraTransferViewModel: ObservableObject {
errorMessage = nil
}
///
/// USB
func refreshCameraFiles() async {
guard connectionState.isConnected, !isRefreshingCameraFiles else { return }
if connectionState.isConnected {
guard !isRefreshingCameraFiles else { return }
isRefreshingCameraFiles = true
defer { isRefreshingCameraFiles = false }
await pipeline.syncExistingPhotos()
return
}
await restartCameraSearch()
}
/// USB
func restartCameraSearch() async {
guard !connectionState.isConnected, !isSearchingCamera else { return }
isSearchingCamera = true
defer { isSearchingCamera = false }
await pipeline.restartCameraDiscovery()
connectionState = pipeline.connectionState
}
/// / /
@ -912,7 +953,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
}
private func handleConnectionStateChange(_ state: CameraConnectionState) {
if wasConnected, !state.isConnected {
if wasConnected, !state.isConnected, !isLeavingPage {
triggerCameraDisconnectedAlert()
}
wasConnected = state.isConnected

View File

@ -16,6 +16,7 @@ struct WiredCameraTransferView: View {
@Environment(\.ossUploadService) private var ossUploadService
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase
@StateObject private var viewModel: WiredCameraTransferViewModel
@State private var previewSources: [String]?
@ -26,7 +27,7 @@ struct WiredCameraTransferView: View {
_viewModel = StateObject(
wrappedValue: WiredCameraTransferViewModel(
context: context,
cameraService: CameraServiceFactory.make(brand: .sony)
cameraService: CameraServiceFactory.makeAutoDetect()
)
)
}
@ -50,6 +51,7 @@ struct WiredCameraTransferView: View {
deviceStorageInfo: viewModel.deviceStorageInfo,
isCameraConnected: viewModel.isCameraConnected,
isRefreshingCameraFiles: viewModel.isRefreshingCameraFiles,
isSearchingCamera: viewModel.isSearchingCamera,
cameraDeviceName: viewModel.cameraDeviceName,
transferModeOption: viewModel.transferModeOption,
tabTitles: tabTitles,
@ -103,7 +105,11 @@ struct WiredCameraTransferView: View {
)
}
.onDisappear {
Task { await viewModel.stop() }
viewModel.detachFromUI()
}
.onChange(of: scenePhase) { phase in
guard phase == .active, !viewModel.isCameraConnected else { return }
Task { await viewModel.restartCameraSearch() }
}
.onChange(of: viewModel.errorMessage) { message in
guard let message, !message.isEmpty else { return }

View File

@ -11,6 +11,7 @@ struct WiredTransferControlCard: View {
let deviceStorageInfo: DeviceStorageSnapshot
let isCameraConnected: Bool
let isRefreshingCameraFiles: Bool
let isSearchingCamera: Bool
let cameraDeviceName: String
let transferModeOption: String
let tabTitles: [String]
@ -130,19 +131,20 @@ struct WiredTransferControlCard: View {
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(!isCameraConnected || isRefreshingCameraFiles)
.opacity(isCameraConnected && !isRefreshingCameraFiles ? 1 : 0.85)
.disabled(isRefreshingCameraFiles || isSearchingCamera)
.opacity(isRefreshingCameraFiles || isSearchingCamera ? 0.85 : 1)
}
private var refreshButtonTitle: String {
if isSearchingCamera { return "搜索中" }
if isRefreshingCameraFiles { return "读取中" }
if isCameraConnected { return "刷新" }
return "等待连接"
return "重新搜索"
}
private var refreshButtonColor: Color {
if !isCameraConnected { return WiredTransferDesign.danger }
if isRefreshingCameraFiles { return WiredTransferDesign.text999 }
if isSearchingCamera || isRefreshingCameraFiles { return WiredTransferDesign.text999 }
if !isCameraConnected { return AppDesign.primary }
return AppDesign.primary
}

View File

@ -21,7 +21,7 @@
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>用于扫码核销订单和直播推流预览</string>
<string>用于扫码核销直播推流预览,以及通过 USB 连接外接相机进行有线传图</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>用于景区选择、打卡点选点和位置上报时获取当前位置</string>
<key>NSMicrophoneUsageDescription</key>

View File

@ -0,0 +1,19 @@
//
// CameraBrandDetectionTests.swift
// suixinkanTests
//
import ImageCaptureCore
import XCTest
@testable import suixinkan
/// USB Vendor ID
final class CameraBrandDetectionTests: XCTestCase {
func testSonyVendorID() {
XCTAssertEqual(CameraUSBVendorID.sony, 0x054C)
}
func testCanonVendorID() {
XCTAssertEqual(CameraUSBVendorID.canon, 0x04A9)
}
}

View File

@ -0,0 +1,34 @@
//
// CameraServiceFactoryTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// CameraServiceFactory
@MainActor
final class CameraServiceFactoryTests: XCTestCase {
func testMakeSonyReturnsSonyService() {
let service = CameraServiceFactory.make(brand: .sony)
XCTAssertTrue(service is SonyCameraService)
XCTAssertEqual(service.brand, .sony)
}
func testMakeCanonReturnsCanonService() {
let service = CameraServiceFactory.make(brand: .canon)
XCTAssertTrue(service is CanonCameraService)
XCTAssertEqual(service.brand, .canon)
}
func testMakeAutoDetectReturnsAutoDetectService() {
let service = CameraServiceFactory.makeAutoDetect()
XCTAssertTrue(service is AutoDetectCameraService)
}
func testMakeAutoDetectReturnsSharedInstance() {
let first = CameraServiceFactory.makeAutoDetect()
let second = CameraServiceFactory.makeAutoDetect()
XCTAssertTrue(first === second)
}
}

View File

@ -0,0 +1,24 @@
//
// CameraTetheringSessionTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// CameraTetheringSession detach/shutdown
@MainActor
final class CameraTetheringSessionTests: XCTestCase {
func testSharedServiceIsAutoDetectInstance() {
XCTAssertTrue(CameraTetheringSession.sharedService is AutoDetectCameraService)
}
func testDetachFromUIClearsCallbacksWithoutChangingConnectionState() {
let service = AutoDetectCameraService()
service.onConnectionStateChange = { _ in }
service.onNewAsset = { _ in }
service.detachFromUI()
XCTAssertNil(service.onConnectionStateChange)
XCTAssertNil(service.onNewAsset)
}
}

View File

@ -0,0 +1,85 @@
//
// CanonPTPCommandsTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// Canon PTP
final class CanonPTPCommandsTests: XCTestCase {
/// SetRemoteMode opcode
func testBuildSetRemoteModeCommand() {
let data = PTPHelper.buildCommand(
code: CanonPTPCommand.eosSetRemoteMode,
transactionID: 1,
parameters: [1]
)
XCTAssertEqual(data.count, 16)
XCTAssertEqual(data[6], 0x14)
XCTAssertEqual(data[7], 0x91)
}
/// ObjectAddedEx (0xC181)
func testParseObjectAddedExEvent() {
var blob = Data()
let recordSize: UInt32 = 8 + 40
blob.append(contentsOf: withUnsafeBytes(of: recordSize.littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: CanonPTPCommand.eosObjectAddedEx.littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x1234).littleEndian) { Data($0) }) // handle
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x00010001).littleEndian) { Data($0) }) // storage
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x3801).littleEndian) { Data($0) }) // ofc
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) // flags
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) // parent
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) // reserved
blob.append(contentsOf: withUnsafeBytes(of: UInt32(1024).littleEndian) { Data($0) }) // size
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) // reserved
let filename = "IMG_0001.JPG"
blob.append(filename.data(using: .ascii)!)
blob.append(0)
let events = CanonEosEventParser.parse(blob)
XCTAssertEqual(events.count, 1)
guard case .objectAdded(let added) = events[0] else {
return XCTFail("expected objectAdded")
}
XCTAssertEqual(added.objectHandle, 0x1234)
XCTAssertEqual(added.filename, "IMG_0001.JPG")
XCTAssertEqual(added.size, 1024)
}
/// ObjectAddedEx2 (0xC1A7) size
func testParseObjectAddedEx2Event() {
var blob = Data()
let recordSize: UInt32 = 8 + 48
blob.append(contentsOf: withUnsafeBytes(of: recordSize.littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: CanonPTPCommand.eosObjectAddedEx2.littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x5678).littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x00010001).littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x3801).littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: UInt32(512).littleEndian) { Data($0) }) // sizeLow
blob.append(contentsOf: withUnsafeBytes(of: UInt32(1).littleEndian) { Data($0) }) // sizeHigh
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) })
blob.append("LARGE.JPG".data(using: .ascii)!)
blob.append(0)
let events = CanonEosEventParser.parse(blob)
XCTAssertEqual(events.count, 1)
guard case .objectAdded(let added) = events[0] else {
return XCTFail("expected objectAdded")
}
XCTAssertEqual(added.objectHandle, 0x5678)
XCTAssertEqual(added.size, 512 | (1 << 32))
}
///
func testSupportedFilenameExtensions() {
XCTAssertTrue(CanonPTPHelper.isSupportedFilename("photo.JPG"))
XCTAssertTrue(CanonPTPHelper.isSupportedFilename("photo.cr2"))
XCTAssertFalse(CanonPTPHelper.isSupportedFilename("photo.tif"))
}
}

View File

@ -0,0 +1,25 @@
//
// USBDeviceBrowserControllerTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// USB teardown
final class USBDeviceBrowserControllerTests: XCTestCase {
/// stop stopped 便
func testStopResetsAuthorizationAndStoppedFlag() {
let controller = USBDeviceBrowserController()
controller.stop()
XCTAssertTrue(controller.isStoppedForTesting)
XCTAssertFalse(controller.isAuthorizationGrantedForTesting)
}
/// re-enumerate
func testReconnectHintMarksAndExpires() {
USBCameraReconnectHint.markDisconnected()
XCTAssertTrue(USBCameraReconnectHint.shouldReEnumerate)
}
}

View File

@ -21,7 +21,7 @@ final class CameraTransferPipelineTests: XCTestCase {
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
var latestTasks: [CameraTransferTask] = []
pipeline.onTasksUpdated = { latestTasks = $0 }
attachPipeline(pipeline, onTasksUpdated: { latestTasks = $0 })
let asset = CameraAsset(id: "ptp_1", filename: "DSC_001.JPG", fileSize: 1024)
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_001.JPG")
@ -95,6 +95,7 @@ final class CameraTransferPipelineTests: XCTestCase {
let scope = makeScope("mode_switch")
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
pipeline.configure(uploadSink: sink, uploadEnabled: false, downloadScope: scope)
attachPipeline(pipeline)
camera.onNewAsset?(CameraAsset(id: "post_001", filename: "POST_001.JPG", fileSize: 1024))
try await Task.sleep(nanoseconds: 400_000_000)
@ -138,6 +139,7 @@ final class CameraTransferPipelineTests: XCTestCase {
let scope = makeScope("burst")
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
attachPipeline(pipeline)
for index in 1 ... 10 {
let filename = "DSC_\(index).JPG"
@ -169,7 +171,7 @@ final class CameraTransferPipelineTests: XCTestCase {
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
var updateCount = 0
pipeline.onTasksUpdated = { _ in updateCount += 1 }
attachPipeline(pipeline, onTasksUpdated: { _ in updateCount += 1 })
camera.onNewAsset?(CameraAsset(id: "ptp_chatty", filename: "chatty.JPG", fileSize: 1024))
try await Task.sleep(nanoseconds: 800_000_000)
@ -178,6 +180,35 @@ final class CameraTransferPipelineTests: XCTestCase {
XCTAssertLessThan(updateCount, 18)
}
/// shutdown
func testShutdownCallsCameraService() async {
let camera = DisconnectTrackingMockCameraService()
let pipeline = CameraTransferPipeline(cameraService: camera)
await pipeline.shutdown()
XCTAssertEqual(camera.shutdownCount, 1)
}
/// detachFromUI shutdown
func testDetachFromUIDoesNotShutdownCameraService() async {
let camera = DisconnectTrackingMockCameraService()
let pipeline = CameraTransferPipeline(cameraService: camera)
attachPipeline(pipeline)
pipeline.detachFromUI()
XCTAssertEqual(camera.detachCount, 1)
XCTAssertEqual(camera.shutdownCount, 0)
}
private func attachPipeline(
_ pipeline: CameraTransferPipeline,
onTasksUpdated: @escaping ([CameraTransferTask]) -> Void = { _ in }
) {
pipeline.attachToUI(onConnectionStateChange: { _ in }, onTasksUpdated: onTasksUpdated)
}
private func makeScope(_ albumName: String) -> CameraDownloadStorage.Scope {
let albumID = albumName.unicodeScalars.reduce(0) { partial, scalar in
(partial + Int(scalar.value)) % 10_000
@ -186,6 +217,24 @@ final class CameraTransferPipelineTests: XCTestCase {
}
}
@MainActor
private final class DisconnectTrackingMockCameraService: CameraServiceProtocol {
let brand: CameraBrand = .sony
var connectionState: CameraConnectionState = .disconnected
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
var onNewAsset: ((CameraAsset) -> Void)?
private(set) var detachCount = 0
private(set) var shutdownCount = 0
func connect() async {}
func detachFromUI() { detachCount += 1 }
func shutdown() async { shutdownCount += 1 }
func listAssets() async throws -> [CameraAsset] { [] }
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
}
}
@MainActor
private final class MockCameraService: CameraServiceProtocol {
let brand: CameraBrand = .sony
@ -195,7 +244,8 @@ private final class MockCameraService: CameraServiceProtocol {
var listedAssets: [CameraAsset] = []
func connect() async {}
func disconnect() async {}
func detachFromUI() {}
func shutdown() async {}
func listAssets() async throws -> [CameraAsset] {
listedAssets
@ -219,7 +269,8 @@ private final class MockBurstCameraService: CameraServiceProtocol {
var downloadURLs: [String: URL] = [:]
func connect() async {}
func disconnect() async {}
func detachFromUI() {}
func shutdown() async {}
func listAssets() async throws -> [CameraAsset] { [] }

View File

@ -46,6 +46,40 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.photos.first?.fileName, "DSC_NEW.JPG")
}
/// start detach UI shutdown
func testStartCancellationDetachesWithoutShutdown() async {
let camera = DisconnectTrackingWiredCameraService()
let context = WiredTransferContext(
albumId: 6603,
albumName: "测试相册",
phone: "",
orderNumber: ""
)
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
let startTask = Task {
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
}
for _ in 0 ..< 100 {
if camera.connectCount > 0 { break }
try? await Task.sleep(nanoseconds: 10_000_000)
}
startTask.cancel()
try? await Task.sleep(nanoseconds: 300_000_000)
XCTAssertGreaterThanOrEqual(camera.detachCount, 1)
XCTAssertEqual(camera.shutdownCount, 0)
}
///
func testPhotoBoundToOtherAlbumIsFilteredOut() async throws {
let bindingStore = WiredTransferPhotoStore(
@ -655,6 +689,35 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
}
}
@MainActor
private final class DisconnectTrackingWiredCameraService: CameraServiceProtocol {
let brand: CameraBrand = .sony
var connectionState: CameraConnectionState = .disconnected
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
var onNewAsset: ((CameraAsset) -> Void)?
private(set) var connectCount = 0
private(set) var detachCount = 0
private(set) var shutdownCount = 0
func connect() async {
connectCount += 1
connectionState = .connected(deviceName: "Mock")
onConnectionStateChange?(.connected(deviceName: "Mock"))
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 50_000_000)
}
}
func detachFromUI() { detachCount += 1 }
func shutdown() async { shutdownCount += 1 }
func listAssets() async throws -> [CameraAsset] { [] }
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
}
}
@MainActor
private final class MockWiredCameraService: CameraServiceProtocol {
let brand: CameraBrand = .sony
@ -664,7 +727,8 @@ private final class MockWiredCameraService: CameraServiceProtocol {
var downloadURL: URL?
func connect() async {}
func disconnect() async {}
func detachFromUI() {}
func shutdown() async {}
func listAssets() async throws -> [CameraAsset] {
[]