feat: add travel album OTG import flow

This commit is contained in:
2026-07-08 09:24:51 +08:00
parent 00bda390e8
commit 92fcad7ac9
42 changed files with 6826 additions and 89 deletions

View File

@ -0,0 +1,16 @@
//
// CameraDeviceInfo.swift
// otg_swift
//
import Foundation
/// ImageCaptureCore PlatformDetector Driver 使
struct CameraDeviceInfo {
let name: String
let manufacturer: String?
let model: String?
let serialNumber: String?
let usbVendorID: Int?
let usbProductID: Int?
}

View File

@ -0,0 +1,32 @@
//
// CameraDriver.swift
// otg_swift
//
// Created by hanqiu on 2026/7/2.
//
import Foundation
/// Driver
/// ViewModel
protocol CameraDriver: AnyObject {
var platform: CameraPlatform { get }
var deviceInfo: CameraDeviceInfo { get }
/// Session ConnectionManager Driver
func connect() async throws
func disconnect()
/// MTP/PTP
func listObjects() async throws -> [CameraObject]
func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data?
/// URL
func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL
}
/// ICCameraFile
struct CameraObject: Equatable {
let id: String
let filename: String
let fileSize: Int64
let capturedAt: Date
}

View File

@ -0,0 +1,29 @@
//
// CameraError.swift
// otg_swift
//
// Created by hanqiu on 2026/7/2.
//
import Foundation
///
enum CameraError: LocalizedError {
/// Driver downloadObject
case notImplemented
/// PlatformDetector unknown Driver
case unsupportedPlatform
/// listObjects / download ICCameraFile
case deviceNotFound
/// Session PTP `String`
case connectionFailed(String)
var errorDescription: String? {
switch self {
case .notImplemented: return "功能尚未实现"
case .unsupportedPlatform: return "不支持的相机品牌"
case .deviceNotFound: return "未找到相机"
case .connectionFailed(let msg): return "连接失败:\(msg)"
}
}
}

View File

@ -0,0 +1,39 @@
//
// CameraFactory.swift
// otg_swift
//
// Created by hanqiu on 2026/7/2.
//
import Foundation
@preconcurrency import ImageCaptureCore
/// PlatformDetector CameraDriver
enum CameraFactory {
/// Driver`unknown` nil
static func makeDriver(
platform: CameraPlatform,
device: ICCameraDevice,
deviceInfo: CameraDeviceInfo
) -> CameraDriver? {
OTGLog.info(.factory, "creating driver for platform=\(platform.rawValue), device=\(deviceInfo.name)")
switch platform {
case .nikon:
let driver = NikonCameraDriver(device: device, deviceInfo: deviceInfo)
OTGLog.info(.factory, "created NikonCameraDriver")
return driver
case .canon:
let driver = CanonCameraDriver(device: device, deviceInfo: deviceInfo)
OTGLog.info(.factory, "created CanonCameraDriver")
return driver
case .sony:
let driver = SonyCameraDriver(device: device, deviceInfo: deviceInfo)
OTGLog.info(.factory, "created SonyCameraDriver")
return driver
case .unknown:
OTGLog.error(.factory, "refused to create driver for unknown platform")
return nil
}
}
}

View File

@ -0,0 +1,20 @@
//
// CameraPlatform.swift
// otg_swift
//
// Created by hanqiu on 2026/7/2.
//
import Foundation
/// PlatformDetector CameraFactory
enum CameraPlatform: String, CaseIterable {
/// Driver
case nikon
/// MTP
case canon
/// MTP PC Remote
case sony
/// VID Driver
case unknown
}

View File

@ -0,0 +1,658 @@
//
// ConnectionManager.swift
// otg_swift
//
// Created by hanqiu on 2026/7/2.
//
@preconcurrency import ImageCaptureCore
/// ConnectionManager ViewModel
@MainActor
protocol ConnectionManagerDelegate: AnyObject {
func connectionManager(_ manager: ConnectionManager, didUpdate state: ConnectionState)
func connectionManager(_ manager: ConnectionManager, didCreate driver: CameraDriver)
func connectionManager(_ manager: ConnectionManager, didFail error: Error)
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: CameraDriver)
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager)
///
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int)
}
extension ConnectionManagerDelegate {
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: CameraDriver) {}
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) {}
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int) {}
}
/// 线便 ViewModel
@MainActor
protocol WiredCameraConnectionManaging: AnyObject {
var delegate: ConnectionManagerDelegate? { get set }
var state: ConnectionState { get }
var currentDriver: CameraDriver? { get }
var isContentCatalogReady: Bool { get }
func configureLiveTransfer(albumID: Int?)
func start()
func unbindDelegate()
func disconnect()
}
/// ImageCaptureCore SessionDriver
/// `ICDeviceBrowser` `ICCameraDevice`View Delegate
@MainActor
final class ConnectionManager: NSObject, WiredCameraConnectionManaging {
static let shared = ConnectionManager()
weak var delegate: ConnectionManagerDelegate?
private(set) var state: ConnectionState = .idle {
didSet {
if oldValue != state {
OTGLog.info(.connection, "state changed: \(oldValue) -> \(state)")
}
notify { self.delegate?.connectionManager(self, didUpdate: self.state) }
}
}
private(set) var currentDriver: CameraDriver?
private(set) var isContentCatalogReady = false
private var browser: ICDeviceBrowser?
/// First `didAdd` caches the device; kept after session close until USB unplug (`didRemove`).
private var cachedDevice: ICCameraDevice?
private var isClosingSession = false
private var pendingStartAfterClose = false
private var searchRescanWorkItem: DispatchWorkItem?
private var searchReplugHintWorkItem: DispatchWorkItem?
private var sonyRemoteCapture: SonyRemoteCaptureService?
private var canonRemoteCapture: CanonRemoteCaptureService?
private var nikonLiveCapture: NikonCatalogLiveCaptureService?
/// OTGTransferViewModel start
private var liveTransferAlbumID: Int?
private let searchRescanDelay: TimeInterval = 1.5
///
private let searchReplugHintDelay: TimeInterval = 4.5
private override init() {
super.init()
}
// MARK: - Public
/// / nil
func configureLiveTransfer(albumID: Int?) {
liveTransferAlbumID = albumID
OTGLog.debug(.connection, "live transfer album=\(albumID.map(String.init) ?? "nil")")
}
/// Entry point when OTG UI appears: reconnect cached device or start discovery.
func start() {
OTGLog.info(.connection, "start requested, state=\(state)")
if case .connected = state {
return
}
if isClosingSession {
OTGLog.info(.connection, "start deferred, session is closing")
pendingStartAfterClose = true
return
}
if let cached = cachedDevice {
reconnect(to: cached)
return
}
startSearching()
}
func unbindDelegate() {
delegate = nil
cancelSearchTimers()
}
/// Session Driver USB
func disconnect() {
guard let device = activeDevice else {
state = .disconnected
return
}
guard !isClosingSession else {
OTGLog.info(.connection, "disconnect ignored, session already closing")
return
}
OTGLog.info(.connection, "disconnect requested")
isClosingSession = true
isContentCatalogReady = false
cancelSearchTimers()
sonyRemoteCapture?.stop()
sonyRemoteCapture = nil
canonRemoteCapture?.stop()
canonRemoteCapture = nil
nikonLiveCapture?.stop()
nikonLiveCapture = nil
liveTransferAlbumID = nil
currentDriver?.disconnect()
currentDriver = nil
device.delegate = self
device.requestCloseSession()
}
// MARK: - Private
private var activeDevice: ICCameraDevice? {
cachedDevice
}
private func notify(_ block: () -> Void) {
block()
}
private func startSearching() {
OTGLog.info(.connection, "start searching for cameras")
cancelSearchTimers()
state = .searching
if browser == nil {
launchBrowser()
}
scheduleSearchMonitoring()
}
private func launchBrowser() {
let browser = ICDeviceBrowser()
browser.delegate = self
// iOS 15.2+ Camera Local USB Sony/Canon
if #available(iOS 15.2, *) {
let mask = ICDeviceTypeMask(
rawValue: ICDeviceTypeMask.camera.rawValue | ICDeviceLocationTypeMask.local.rawValue
) ?? .camera
browser.browsedDeviceTypeMask = mask
} else {
browser.browsedDeviceTypeMask = .camera
}
browser.start()
self.browser = browser
OTGLog.debug(.connection, "browser launched")
}
private func reconnect(to camera: ICCameraDevice) {
guard !isClosingSession else {
OTGLog.warning(.connection, "reconnect ignored, session is closing")
return
}
if case .connected = state {
return
}
OTGLog.info(.connection, "reconnecting to cached device: \(camera.name ?? "nil")")
cancelSearchTimers()
cachedDevice = camera
state = .connecting(deviceName: camera.name ?? "Unknown Camera")
requestAuthorizations { [weak self] in
self?.openSession(for: camera, context: "cached device")
}
}
/// contents controlPTP control
private func requestAuthorizations(then completion: @escaping () -> Void) {
if browser == nil {
launchBrowser()
}
guard let browser else {
OTGLog.error(.connection, "authorization skipped, browser unavailable")
completion()
return
}
browser.requestContentsAuthorization { [weak self] contentsStatus in
guard let self else { return }
let contentsGranted = contentsStatus == .authorized
OTGLog.info(.connection, "contents authorization: \(contentsStatus.rawValue)")
guard contentsGranted else {
DispatchQueue.main.async {
OTGLog.error(.connection, "camera contents authorization denied")
self.state = .failed(message: "未获得相机内容访问权限")
self.delegate?.connectionManager(
self,
didFail: CameraError.connectionFailed("未获得相机内容访问权限")
)
}
return
}
browser.requestControlAuthorization { [weak self] controlStatus in
guard let self else { return }
let controlGranted = controlStatus == .authorized
OTGLog.info(.connection, "control authorization: \(controlStatus.rawValue)")
DispatchQueue.main.async {
guard controlGranted else {
OTGLog.error(.connection, "camera control authorization denied, PTP requires control access")
self.state = .failed(message: "未获得相机控制权限,无法使用 PTP 传图")
self.delegate?.connectionManager(
self,
didFail: CameraError.connectionFailed(
"请在系统弹窗中允许控制外接相机PTP 传图需要此权限)"
)
)
return
}
completion()
}
}
}
}
private func openSession(for camera: ICCameraDevice, context: String) {
camera.delegate = self
camera.requestOpenSession()
OTGLog.debug(.connection, "requestOpenSession sent (\(context))")
}
private func scheduleSearchMonitoring() {
cancelSearchTimers()
let rescan = DispatchWorkItem { [weak self] in
guard let self, self.state == .searching, self.cachedDevice == nil else { return }
self.rescanBrowser()
}
let replugHint = DispatchWorkItem { [weak self] in
guard let self, self.state == .searching, self.cachedDevice == nil else { return }
OTGLog.warning(.connection, "search timed out, suggesting replug")
self.notify { self.delegate?.connectionManagerDidSuggestReplug(self) }
self.scheduleSearchMonitoring()
}
searchRescanWorkItem = rescan
searchReplugHintWorkItem = replugHint
DispatchQueue.main.asyncAfter(deadline: .now() + searchRescanDelay, execute: rescan)
DispatchQueue.main.asyncAfter(deadline: .now() + searchReplugHintDelay, execute: replugHint)
}
private func rescanBrowser() {
OTGLog.info(.connection, "rescanning: restarting browser")
browser?.stop()
browser?.delegate = nil
browser = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
guard let self, self.state == .searching, self.cachedDevice == nil else { return }
self.launchBrowser()
self.scheduleSearchMonitoring()
}
}
private func cancelSearchTimers() {
searchRescanWorkItem?.cancel()
searchRescanWorkItem = nil
searchReplugHintWorkItem?.cancel()
searchReplugHintWorkItem = nil
}
private func clearDeviceCache() {
sonyRemoteCapture?.stop()
sonyRemoteCapture = nil
canonRemoteCapture?.stop()
canonRemoteCapture = nil
nikonLiveCapture?.stop()
nikonLiveCapture = nil
cachedDevice = nil
currentDriver?.disconnect()
currentDriver = nil
isContentCatalogReady = false
isClosingSession = false
}
}
extension ConnectionManager: ICDeviceBrowserDelegate {
func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) {
OTGLog.info(.connection, "device added: name=\(device.name ?? "nil"), type=\(device.type.rawValue), moreComing=\(moreComing)")
guard let camera = device as? ICCameraDevice else {
OTGLog.warning(.connection, "ignored non-camera device: \(device.name ?? "nil")")
return
}
handleDiscoveredCamera(camera)
}
func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) {
OTGLog.info(.connection, "device removed: name=\(device.name ?? "nil"), uuid=\(device.uuidString ?? "nil"), moreGoing=\(moreGoing)")
guard device.uuidString == cachedDevice?.uuidString else { return }
cancelSearchTimers()
clearDeviceCache()
state = .idle
}
}
private extension ConnectionManager {
func handleDiscoveredCamera(_ camera: ICCameraDevice) {
ICCameraDeviceDebugLogger.logNonEmptyProperties(of: camera, context: "discovered")
guard !isClosingSession else {
OTGLog.warning(.connection, "camera ignored while session is closing")
return
}
if let cached = cachedDevice, cached.uuidString == camera.uuidString {
if case .connected = state { return }
reconnect(to: cached)
return
}
guard cachedDevice == nil else {
OTGLog.warning(.connection, "camera ignored, already have cached device: \(cachedDevice?.name ?? "nil")")
return
}
OTGLog.info(.connection, "discovered camera: name=\(camera.name ?? "nil"), uuid=\(camera.uuidString ?? "nil")")
cancelSearchTimers()
cachedDevice = camera
state = .connecting(deviceName: camera.name ?? "Unknown Camera")
requestAuthorizations { [weak self] in
self?.openSession(for: camera, context: "discovered")
}
}
func buildDeviceInfo(from camera: ICCameraDevice) -> CameraDeviceInfo {
let vendorID = Int(camera.usbVendorID)
let productID = Int(camera.usbProductID)
let info = CameraDeviceInfo(
name: camera.name ?? "Unknown Camera",
manufacturer: camera.productKind,
model: camera.name,
serialNumber: camera.uuidString,
usbVendorID: vendorID != 0 ? vendorID : nil,
usbProductID: productID != 0 ? productID : nil
)
OTGLog.debug(
.connection,
"device info: \(info.name), manufacturer=\(info.manufacturer ?? "nil"), usbVendorID=\(info.usbVendorID.map { String(format: "0x%04X", $0) } ?? "nil"), serial=\(info.serialNumber ?? "nil")"
)
return info
}
/// Driver connected
func createDriver(for camera: ICCameraDevice) {
let info = buildDeviceInfo(from: camera)
let platform = PlatformDetector.detect(from: info)
guard platform != .unknown else {
OTGLog.error(.connection, "unsupported platform for device: \(info.name)")
state = .failed(message: "不支持的相机品牌")
delegate?.connectionManager(self, didFail: CameraError.unsupportedPlatform)
return
}
guard let driver = CameraFactory.makeDriver(
platform: platform,
device: camera,
deviceInfo: info
) else {
OTGLog.error(.connection, "failed to create driver for platform: \(platform.rawValue)")
state = .failed(message: "无法创建相机驱动")
return
}
currentDriver = driver
state = .connected(platform: platform, deviceName: info.name)
OTGLog.info(.connection, "driver ready: \(platform.rawValue), device=\(info.name)")
delegate?.connectionManager(self, didCreate: driver)
}
/// Session PTP SDIO + Probe catalog
private func configurePTPAfterSession(_ camera: ICCameraDevice) {
let platform = PlatformDetector.detect(from: buildDeviceInfo(from: camera))
Task {
// Session PTP
try? await Task.sleep(nanoseconds: 300_000_000)
switch platform {
case .canon:
await runGetDeviceInfoProbe(on: camera)
case .sony:
let transactionID: UInt32 = 1
let result = await SonyPTPHelper.initializeRemoteSession(
on: camera,
startingTransactionID: transactionID
)
if result.success {
let service = SonyRemoteCaptureService()
service.onShotSaved = makeLiveShotSavedHandler()
sonyRemoteCapture = service
service.start(
camera: camera,
startingTransactionID: result.nextTransactionID,
albumID: liveTransferAlbumID
)
OTGLog.info(.sony, "Sony remote session ready")
} else {
OTGLog.error(.sony, "Sony remote session setup failed")
}
default:
await runGetDeviceInfoProbe(on: camera)
}
}
}
private func runGetDeviceInfoProbe(on camera: ICCameraDevice) async {
OTGLog.info(.ptp, "PTP probe starting...")
var transactionID: UInt32 = 1
let result = await PTPHelper.sendCommand(
on: camera,
code: PTPStandardCommand.getDeviceInfo,
transactionID: &transactionID,
label: "GetDeviceInfo(probe)"
)
if result.success {
OTGLog.info(.ptp, "PTP probe passed, deviceInfo=\(result.inData.count) bytes")
} else {
OTGLog.error(.ptp, "PTP probe failed")
}
}
/// catalog Canon SetRemoteMode SD
private func startCanonLiveTransferIfNeeded(for camera: ICCameraDevice) {
guard canonRemoteCapture == nil else { return }
guard PlatformDetector.detect(from: buildDeviceInfo(from: camera)) == .canon else { return }
Task { @MainActor in
try? await Task.sleep(nanoseconds: 500_000_000)
let transactionID: UInt32 = 1
let result = await CanonPTPHelper.initializeRemoteSession(
on: camera,
startingTransactionID: transactionID
)
guard result.success else {
OTGLog.error(.canon, "Canon remote session setup failed")
return
}
let service = CanonRemoteCaptureService()
service.onShotSaved = makeLiveShotSavedHandler()
canonRemoteCapture = service
service.start(
camera: camera,
startingTransactionID: result.nextTransactionID,
albumID: liveTransferAlbumID
)
OTGLog.info(.canon, "Canon remote capture ready")
}
}
/// catalog didAdd + catalog PTP
private func startNikonLiveTransferIfNeeded(for camera: ICCameraDevice) {
guard nikonLiveCapture == nil else { return }
guard PlatformDetector.detect(from: buildDeviceInfo(from: camera)) == .nikon else { return }
let service = NikonCatalogLiveCaptureService()
service.onShotSaved = makeLiveShotSavedHandler()
nikonLiveCapture = service
service.start(camera: camera, albumID: liveTransferAlbumID)
OTGLog.info(.nikon, "Nikon catalog live capture ready")
}
private func makeLiveShotSavedHandler() -> (String) -> Void {
{ [weak self] filename in
guard let self, let albumID = self.liveTransferAlbumID else { return }
self.notify {
self.delegate?.connectionManager(
self,
didSaveLiveShot: filename,
albumID: albumID
)
}
}
}
}
extension ConnectionManager: ICCameraDeviceDelegate {
func device(_ device: ICDevice, didOpenSessionWithError error: (any Error)?) {
guard device.uuidString == cachedDevice?.uuidString else { return }
if let error {
OTGLog.error(.connection, "open session failed: \(error.localizedDescription)")
state = .failed(message: error.localizedDescription)
delegate?.connectionManager(self, didFail: error)
return
}
OTGLog.info(.connection, "session opened: \(device.name ?? "nil")")
guard let camera = device as? ICCameraDevice else { return }
cachedDevice = camera
ICCameraDeviceDebugLogger.logNonEmptyProperties(of: camera, context: "session-opened")
configurePTPAfterSession(camera)
createDriver(for: camera)
}
func device(_ device: ICDevice, didCloseSessionWithError error: (any Error)?) {
guard device.uuidString == cachedDevice?.uuidString else { return }
if let error {
OTGLog.warning(.connection, "session closed with error: \(error.localizedDescription)")
} else {
OTGLog.info(.connection, "session closed: \(device.name ?? "nil")")
}
isClosingSession = false
sonyRemoteCapture?.stop()
sonyRemoteCapture = nil
canonRemoteCapture?.stop()
canonRemoteCapture = nil
nikonLiveCapture?.stop()
nikonLiveCapture = nil
currentDriver = nil
isContentCatalogReady = false
if let camera = device as? ICCameraDevice {
cachedDevice = camera
}
state = .disconnected
if pendingStartAfterClose {
pendingStartAfterClose = false
OTGLog.info(.connection, "session close finished, resuming start")
start()
}
}
func cameraDevice(_ camera: ICCameraDevice, didAdd items: [ICCameraItem]) {
OTGLog.debug(.connection, "camera items added: \(items.count)")
canonRemoteCapture?.handleCatalogItemsAdded(items)
nikonLiveCapture?.handleCatalogItemsAdded(items)
}
func cameraDevice(_ camera: ICCameraDevice, didRemove items: [ICCameraItem]) {
OTGLog.debug(.connection, "camera items removed: \(items.count)")
}
func cameraDevice(
_ camera: ICCameraDevice,
didReceiveThumbnail thumbnail: CGImage?,
for item: ICCameraItem,
error: (any Error)?
) {
if let error {
OTGLog.warning(.connection, "thumbnail failed for \(item.name ?? "nil"): \(error.localizedDescription)")
}
}
func cameraDevice(
_ camera: ICCameraDevice,
didReceiveMetadata metadata: [AnyHashable: Any]?,
for item: ICCameraItem,
error: (any Error)?
) {
if let error {
OTGLog.warning(.connection, "metadata failed for \(item.name ?? "nil"): \(error.localizedDescription)")
}
}
func cameraDevice(_ camera: ICCameraDevice, didRenameItems items: [ICCameraItem]) {
OTGLog.debug(.connection, "camera items renamed: \(items.count)")
}
func cameraDeviceDidChangeCapability(_ camera: ICCameraDevice) {
OTGLog.debug(.connection, "camera capability changed: \(camera.name ?? "nil")")
}
func cameraDevice(_ camera: ICCameraDevice, didReceivePTPEvent event: Data) {
guard cachedDevice?.uuidString == camera.uuidString else { return }
sonyRemoteCapture?.handlePTPEvent(event)
canonRemoteCapture?.handlePTPEvent(event)
}
/// ImageCaptureCore `listObjects`MTP
func deviceDidBecomeReady(withCompleteContentCatalog camera: ICCameraDevice) {
guard camera.uuidString == cachedDevice?.uuidString else {
OTGLog.warning(.connection, "content catalog ready ignored, unknown camera: \(camera.name ?? "nil")")
return
}
OTGLog.info(.connection, "camera ready with content catalog: \(camera.name ?? "nil")")
ICCameraDeviceDebugLogger.logNonEmptyProperties(of: camera, context: "content-catalog-ready")
isContentCatalogReady = true
guard let driver = currentDriver else {
OTGLog.warning(.connection, "content catalog ready but no driver available")
return
}
notify { self.delegate?.connectionManager(self, contentCatalogDidBecomeReady: driver) }
startCanonLiveTransferIfNeeded(for: camera)
startNikonLiveTransferIfNeeded(for: camera)
}
func cameraDeviceDidRemoveAccessRestriction(_ device: ICDevice) {
OTGLog.info(.connection, "access restriction removed: \(device.name ?? "nil")")
}
func cameraDeviceDidEnableAccessRestriction(_ device: ICDevice) {
OTGLog.warning(.connection, "access restriction enabled: \(device.name ?? "nil")")
}
func didRemove(_ device: ICDevice) {
OTGLog.info(.connection, "device removed delegate callback: \(device.name ?? "nil")")
}
}

View File

@ -0,0 +1,58 @@
//
// ConnectionState.swift
// otg_swift
//
// Created by hanqiu on 2026/7/2.
//
import Foundation
/// OTG ViewModel `displayText`
enum ConnectionState: Equatable {
///
case idle
/// ICDeviceBrowser USB
case searching
/// Session
case connecting(deviceName: String)
/// Session Driver `platform` UI
case connected(platform: CameraPlatform, deviceName: String)
/// Session USB
case disconnected
/// Session `message`
case failed(message: String)
}
extension ConnectionState: CustomStringConvertible {
var description: String {
switch self {
case .idle: return "idle"
case .searching: return "searching"
case .connecting(let name): return "connecting(\(name))"
case .connected(let platform, let name):
return "connected(\(platform.rawValue), \(name))"
case .disconnected: return "disconnected"
case .failed(let message): return "failed(\(message))"
}
}
var displayText: String {
switch self {
case .idle: return "等待开始"
case .searching: return "正在搜索相机…"
case .connecting(let name): return "正在连接 \(name)"
case .connected(let platform, let name):
return "已连接 \(platformLabel(platform)) · \(name)"
case .disconnected: return "已断开连接"
case .failed(let msg): return "连接失败:\(msg)"
}
}
private func platformLabel(_ platform: CameraPlatform) -> String {
switch platform {
case .nikon: return "尼康"
case .canon: return "佳能"
case .sony: return "索尼"
case .unknown: return "未知"
}
}
}

View File

@ -0,0 +1,105 @@
//
// ICCameraDevice+DebugLog.swift
// otg_swift
//
import CoreGraphics
@preconcurrency import ImageCaptureCore
/// ICCameraDevice PlatformDetector catalog
enum ICCameraDeviceDebugLogger {
/// ICCameraDevice iOS 访便 PlatformDetector
static func logNonEmptyProperties(of camera: ICCameraDevice, context: String) {
var lines: [String] = []
lines.append("=== ICCameraDevice [\(context)] ===")
// MARK: ICDevice
append("type", String(describing: camera.type.rawValue), to: &lines)
append("name", camera.name, to: &lines)
append("productKind", camera.productKind, to: &lines)
append("transportType", camera.transportType, to: &lines)
append("uuidString", camera.uuidString, to: &lines)
append("systemSymbolName", camera.systemSymbolName, to: &lines)
appendIfTrue("hasOpenSession", camera.hasOpenSession, to: &lines)
appendCapabilities(camera.capabilities, to: &lines)
appendUserData(camera.userData, to: &lines)
appendIcon(camera.icon, to: &lines)
appendUSBID("usbVendorID", Int(camera.usbVendorID), to: &lines)
appendUSBID("usbProductID", Int(camera.usbProductID), to: &lines)
appendUSBID("usbLocationID", Int(camera.usbLocationID), to: &lines)
// MARK: ICCameraDevice
if camera.contentCatalogPercentCompleted > 0 {
lines.append("contentCatalogPercentCompleted=\(camera.contentCatalogPercentCompleted)")
}
appendItems("contents", camera.contents, to: &lines)
appendItems("mediaFiles", camera.mediaFiles, to: &lines)
appendIfTrue("ejectable", camera.isEjectable, to: &lines)
appendIfTrue("locked", camera.isLocked, to: &lines)
appendIfTrue("accessRestrictedAppleDevice", camera.isAccessRestrictedAppleDevice, to: &lines)
appendIfTrue("iCloudPhotosEnabled", camera.iCloudPhotosEnabled, to: &lines)
appendIfTrue("tetheredCaptureEnabled", camera.tetheredCaptureEnabled, to: &lines)
if #available(iOS 14.0, *) {
lines.append("mediaPresentation=\(camera.mediaPresentation.rawValue)")
}
if camera.batteryLevelAvailable {
lines.append("batteryLevelAvailable=true")
lines.append("batteryLevel=\(camera.batteryLevel)")
}
lines.append("ptpEventHandler=set")
lines.append("=== end ICCameraDevice dump ===")
for line in lines {
OTGLog.debug(.connection, line)
}
}
// MARK: - Helpers
private static func append(_ key: String, _ value: String?, to lines: inout [String]) {
guard let value, !value.isEmpty else { return }
lines.append("\(key)=\(value)")
}
private static func appendIfTrue(_ key: String, _ value: Bool, to lines: inout [String]) {
guard value else { return }
lines.append("\(key)=true")
}
private static func appendUSBID(_ key: String, _ value: Int, to lines: inout [String]) {
guard value != 0 else { return }
lines.append("\(key)=0x\(String(format: "%04X", value)) (\(value))")
}
private static func appendCapabilities(_ capabilities: [String], to lines: inout [String]) {
guard !capabilities.isEmpty else { return }
lines.append("capabilities=[\(capabilities.joined(separator: ", "))]")
}
private static func appendUserData(_ userData: NSMutableDictionary?, to lines: inout [String]) {
guard let userData, !userData.allKeys.isEmpty else { return }
lines.append("userData=\(userData)")
}
private static func appendIcon(_ icon: CGImage?, to lines: inout [String]) {
guard let icon else { return }
lines.append("icon=present(\(icon.width)x\(icon.height))")
}
private static func appendItems(_ key: String, _ items: [ICCameraItem]?, to lines: inout [String]) {
guard let items, !items.isEmpty else { return }
let names = items.prefix(5).map { $0.name ?? "unnamed" }.joined(separator: ", ")
let suffix = items.count > 5 ? ", ..." : ""
lines.append("\(key).count=\(items.count)")
lines.append("\(key).preview=[\(names)\(suffix)]")
}
}

View File

@ -0,0 +1,66 @@
//
// ICCameraFileDownloader.swift
// otg_swift
//
import Foundation
@preconcurrency import ImageCaptureCore
/// `ICCameraFile.requestDownload`
enum ICCameraFileDownloader {
/// `directory`
static func download(_ file: ICCameraFile, to directory: URL) async throws -> URL {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let filename = uniqueFilename(file.name ?? UUID().uuidString, in: directory)
let destinationURL = directory.appendingPathComponent(filename)
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
return try await withCheckedThrowingContinuation { continuation in
let options: [ICDownloadOption: Any] = [
.downloadsDirectoryURL: directory,
.savedFilename: filename,
.overwrite: true
]
file.requestDownload(options: options) { savedFilename, error in
if let error {
continuation.resume(throwing: error)
return
}
let resolvedFilename = savedFilename ?? filename
let resolvedURL = directory.appendingPathComponent(resolvedFilename)
guard FileManager.default.fileExists(atPath: resolvedURL.path) else {
continuation.resume(throwing: CameraError.connectionFailed("下载文件未找到"))
return
}
continuation.resume(returning: resolvedURL)
}
}
}
private static func uniqueFilename(_ rawName: String, in directory: URL) -> String {
let sanitized = PTPHelper.sanitizeFilename(rawName)
let baseURL = directory.appendingPathComponent(sanitized)
if !FileManager.default.fileExists(atPath: baseURL.path) {
return sanitized
}
let stem = (sanitized as NSString).deletingPathExtension
let ext = (sanitized as NSString).pathExtension
var counter = 1
while counter < 10_000 {
let candidate = ext.isEmpty ? "\(stem)_\(counter)" : "\(stem)_\(counter).\(ext)"
if !FileManager.default.fileExists(atPath: directory.appendingPathComponent(candidate).path) {
return candidate
}
counter += 1
}
return "\(UUID().uuidString)_\(sanitized)"
}
}

View File

@ -0,0 +1,139 @@
//
// ICCameraItem+CameraObjects.swift
// otg_swift
//
import Foundation
@preconcurrency import ImageCaptureCore
import UniformTypeIdentifiers
/// `ICCameraDevice` `CameraObject`
enum ICCameraItemScanner {
/// `mediaFiles` `contents` `filesOfType(public.image)`
static func collectImageFiles(from device: ICCameraDevice, supplementalItems: [ICCameraItem] = []) -> [ICCameraFile] {
var files = collectImageFilesFromDeviceTree(device)
if files.isEmpty, !supplementalItems.isEmpty {
files = collectImageFiles(from: supplementalItems)
OTGLog.info(.connection, "collectImageFiles: using supplemental items, count=\(files.count)")
}
// contents filesOfType
if files.isEmpty {
files = collectImageFiles(fromFilesOfType: device)
}
return files
}
private static func collectImageFilesFromDeviceTree(_ device: ICCameraDevice) -> [ICCameraFile] {
let mediaFiles = (device.mediaFiles ?? []).compactMap { $0 as? ICCameraFile }
if !mediaFiles.isEmpty {
return mediaFiles.filter { isImageFilename($0.name) }
}
return collectImageFiles(from: device.contents ?? [])
}
private static func collectImageFiles(fromFilesOfType device: ICCameraDevice) -> [ICCameraFile] {
guard let names = device.files(ofType: UTType.image.identifier), !names.isEmpty else {
return []
}
OTGLog.info(.connection, "filesOfType(public.image) count=\(names.count), preview=\(names.prefix(3))")
let treeFiles = collectImageFiles(from: device.contents ?? [])
if !treeFiles.isEmpty {
return treeFiles.filter { file in
guard let name = file.name else { return false }
return names.contains(name) || names.contains(where: { ($0 as NSString).lastPathComponent == name })
}
}
return []
}
///
static func collectImageFiles(from items: [ICCameraItem]) -> [ICCameraFile] {
var results: [ICCameraFile] = []
for item in items {
if let file = item as? ICCameraFile, isImageFilename(file.name) {
results.append(file)
} else if let folder = item as? ICCameraFolder {
results.append(contentsOf: collectImageFiles(from: folder.contents ?? []))
}
}
return results
}
/// `CameraObject.id`+ ICCameraFile
static func findFile(matching object: CameraObject, in device: ICCameraDevice) -> ICCameraFile? {
collectImageFiles(from: device).first { cameraObjectID(for: $0) == object.id }
}
static func cameraObject(from file: ICCameraFile) -> CameraObject? {
guard let filename = file.name, !filename.isEmpty, isImageFilename(filename) else { return nil }
let fileSize = Int64(file.fileSize)
let capturedAt = file.exifCreationDate ?? file.fileCreationDate ?? file.creationDate ?? .distantPast
return CameraObject(
id: cameraObjectID(for: file),
filename: filename,
fileSize: fileSize,
capturedAt: capturedAt
)
}
/// metadata catalog
static func cameraObjects(from files: [ICCameraFile]) async -> [CameraObject] {
await withTaskGroup(of: (Int, CameraObject?).self) { group in
for (index, file) in files.enumerated() {
group.addTask {
(index, await cameraObject(from: file))
}
}
var indexed = Array<CameraObject?>(repeating: nil, count: files.count)
for await (index, object) in group {
indexed[index] = object
}
return indexed.compactMap { $0 }
}
}
static func cameraObject(from file: ICCameraFile) async -> CameraObject? {
guard let filename = file.name, !filename.isEmpty, isImageFilename(filename) else { return nil }
let fileSize = Int64(file.fileSize)
let capturedAt = await ICCameraMetadataLoader.captureDate(from: file)
return CameraObject(
id: cameraObjectID(for: file),
filename: filename,
fileSize: fileSize,
capturedAt: capturedAt
)
}
/// ID +
static func cameraObjectID(for file: ICCameraFile) -> String {
cameraObjectID(filename: file.name ?? "unknown", fileSize: Int64(file.fileSize))
}
static func cameraObjectID(filename: String, fileSize: Int64) -> String {
"\(filename)|\(fileSize)"
}
/// dedup `CameraObject.id`
static func cameraObjectID(localPath: String) -> String? {
guard !localPath.isEmpty,
FileManager.default.fileExists(atPath: localPath) else { return nil }
let filename = (localPath as NSString).lastPathComponent
guard let attrs = try? FileManager.default.attributesOfItem(atPath: localPath),
let size = attrs[.size] as? Int64 else { return nil }
return cameraObjectID(filename: filename, fileSize: size)
}
static func isImageFilename(_ name: String?) -> Bool {
guard let name else { return false }
let ext = (name as NSString).pathExtension.lowercased()
return ["jpg", "jpeg", "heic", "png", "arw", "cr2", "cr3", "nef", "raf"].contains(ext)
}
}

View File

@ -0,0 +1,133 @@
//
// ICCameraMetadataLoader.swift
// otg_swift
//
import Foundation
@preconcurrency import ImageCaptureCore
import ImageIO
/// `ICCameraFile` catalog EXIF request metadata
enum ICCameraMetadataLoader {
static func captureDate(from file: ICCameraFile) async -> Date {
if let date = immediateCaptureDate(from: file) {
return date
}
if let metadata = await requestMetadata(from: file) {
if let date = parseCaptureDate(from: metadata) {
return date
}
}
if let date = immediateCaptureDate(from: file) {
return date
}
if let date = parseCaptureDate(fromFilename: file.name ?? "") {
return date
}
return .distantPast
}
// MARK: - Private
private static func immediateCaptureDate(from file: ICCameraFile) -> Date? {
file.exifCreationDate ?? file.fileCreationDate ?? file.creationDate
}
private static func requestMetadata(from file: ICCameraFile) async -> [AnyHashable: Any]? {
await withCheckedContinuation { continuation in
file.requestMetadataDictionary(options: nil) { metadata, error in
if let error {
OTGLog.debug(
.connection,
"metadata failed for \(file.name ?? "nil"): \(error.localizedDescription)"
)
}
continuation.resume(returning: metadata)
}
}
}
private static func parseCaptureDate(from metadata: [AnyHashable: Any]) -> Date? {
if let exif = metadata[kCGImagePropertyExifDictionary as String] as? [String: Any],
let date = parseEXIFDateString(exif[kCGImagePropertyExifDateTimeOriginal as String] as? String) {
return date
}
if let tiff = metadata[kCGImagePropertyTIFFDictionary as String] as? [String: Any],
let date = parseEXIFDateString(tiff[kCGImagePropertyTIFFDateTime as String] as? String) {
return date
}
if let date = parseEXIFDateString(metadata[kCGImagePropertyExifDateTimeOriginal as String] as? String) {
return date
}
return nil
}
private static func parseEXIFDateString(_ value: String?) -> Date? {
guard let value, !value.isEmpty else { return nil }
return exifDateFormatter.date(from: value)
}
/// `YYYYMMDD_HHMMSS` `IMG_YYYYMMDD_HHMMSS`
private static func parseCaptureDate(fromFilename filename: String) -> Date? {
let stem = (filename as NSString).deletingPathExtension
let patterns = [
(#"(\d{8})_(\d{6})"#, ["yyyyMMdd", "HHmmss"]),
(#"IMG_(\d{8})_(\d{6})"#, ["yyyyMMdd", "HHmmss"])
]
for (pattern, _) in patterns {
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: stem, range: NSRange(stem.startIndex..., in: stem)),
match.numberOfRanges == 3,
let dateRange = Range(match.range(at: 1), in: stem),
let timeRange = Range(match.range(at: 2), in: stem) else {
continue
}
let datePart = String(stem[dateRange])
let timePart = String(stem[timeRange])
guard let date = filenameDateFormatter.date(from: datePart),
let time = filenameTimeFormatter.date(from: timePart) else {
continue
}
var components = Calendar.current.dateComponents([.year, .month, .day], from: date)
let timeComponents = Calendar.current.dateComponents([.hour, .minute, .second], from: time)
components.hour = timeComponents.hour
components.minute = timeComponents.minute
components.second = timeComponents.second
return Calendar.current.date(from: components)
}
return nil
}
private static let exifDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy:MM:dd HH:mm:ss"
return formatter
}()
private static let filenameDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyyMMdd"
return formatter
}()
private static let filenameTimeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "HHmmss"
return formatter
}()
}

View File

@ -0,0 +1,40 @@
//
// ICCameraThumbnailLoader.swift
// otg_swift
//
import Foundation
@preconcurrency import ImageCaptureCore
/// ImageCaptureCore Data
enum ICCameraThumbnailLoader {
static func loadThumbnailData(
for object: CameraObject,
from device: ICCameraDevice,
maxPixelSize: Int
) async -> Data? {
guard let file = ICCameraItemScanner.findFile(matching: object, in: device) else {
return nil
}
return await loadThumbnailData(from: file, maxPixelSize: maxPixelSize)
}
/// `maxPixelSize`
static func loadThumbnailData(from file: ICCameraFile, maxPixelSize: Int) async -> Data? {
await withCheckedContinuation { continuation in
let options: [ICCameraItemThumbnailOption: Any] = [
ICCameraItemThumbnailOption.imageSourceThumbnailMaxPixelSize: maxPixelSize
]
file.requestThumbnailData(options: options) { data, error in
if let error {
OTGLog.debug(.connection, "thumbnail failed for \(file.name ?? "nil"): \(error.localizedDescription)")
continuation.resume(returning: nil)
return
}
continuation.resume(returning: data)
}
}
}
}

View File

@ -0,0 +1,112 @@
//
// OTGLog.swift
// otg_swift
//
import Foundation
import os
/// OTG Xcode Console `[OTG]`
enum OTGLog {
/// Console
static let filterPrefix = "[OTG]"
/// os.Logger
enum Level: String {
/// DEBUG Xcode Console
case debug = "DEBUG"
/// PTP
case info = "INFO"
///
case warning = "WARN"
///
case error = "ERROR"
}
/// Console `[Category]`
enum Category: String {
/// ConnectionManagerICCamera Session
case connection = "Connection"
/// PTP
case ptp = "PTP"
/// PlatformDetector
case detector = "Detector"
/// CameraFactory Driver
case factory = "Factory"
/// NikonCameraDriver
case nikon = "Nikon"
/// CanonCameraDriver
case canon = "Canon"
/// Sony DriverSDIO
case sony = "Sony"
}
private static let osLog = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "otg_swift",
category: "OTG"
)
static func debug(
_ category: Category,
_ message: @autoclosure () -> String,
file: String = #file,
line: Int = #line
) {
log(.debug, category: category, message: message(), file: file, line: line)
}
static func info(
_ category: Category,
_ message: @autoclosure () -> String,
file: String = #file,
line: Int = #line
) {
log(.info, category: category, message: message(), file: file, line: line)
}
static func warning(
_ category: Category,
_ message: @autoclosure () -> String,
file: String = #file,
line: Int = #line
) {
log(.warning, category: category, message: message(), file: file, line: line)
}
static func error(
_ category: Category,
_ message: @autoclosure () -> String,
file: String = #file,
line: Int = #line
) {
log(.error, category: category, message: message(), file: file, line: line)
}
private static func log(
_ level: Level,
category: Category,
message: String,
file: String,
line: Int
) {
let fileName = (file as NSString).lastPathComponent
let formatted = "\(filterPrefix)[\(level.rawValue)][\(category.rawValue)] \(message) (\(fileName):\(line))"
#if DEBUG
// DEBUG print osLog Xcode Console
print(formatted)
#else
switch level {
case .debug:
osLog.debug("\(formatted)")
case .info:
osLog.info("\(formatted)")
case .warning:
osLog.warning("\(formatted)")
case .error:
osLog.error("\(formatted)")
}
#endif
}
}

View File

@ -0,0 +1,111 @@
//
// PlatformDetector.swift
// otg_swift
//
import Foundation
/// USB Vendor ID
enum PlatformDetector {
private struct Rule {
let platform: CameraPlatform
let keywords: [String]
}
/// USB Vendor ID
private static let vendorIDMap: [Int: CameraPlatform] = [
0x054C: .sony, // Sony
0x04A9: .canon, // Canon
0x04B0: .nikon // Nikon
]
/// + USB VID
private static let rules: [Rule] = [
Rule(platform: .nikon, keywords: [
"nikon",
"dsc-n",
"coolpix",
"z fc",
"z 6", "z 7", "z 8", "z 9",
"z6", "z7", "z8", "z9",
"d3", "d4", "d5", "d6", "d7", "d8", "d850", "d750"
]),
Rule(platform: .canon, keywords: [
"canon",
"eos ",
"eos-",
"powershot",
"ixus",
"eos m", "eos r", "eos 5d", "eos 6d", "eos 7d", "eos 90d"
]),
Rule(platform: .sony, keywords: [
"sony",
"alpha",
"zv-",
"zv e",
"ilce-",
"ilca-",
"dsc-",
"nex-",
"fx3",
"fx30",
"hdr-",
"a7", "a9", "a1"
])
]
static func detect(from info: CameraDeviceInfo) -> CameraPlatform {
// VID ID USB
if let vendorID = info.usbVendorID, vendorID != 0,
let platform = vendorIDMap[vendorID] {
OTGLog.info(
.detector,
"detected platform=\(platform.rawValue), matched=usbVendorID:0x\(String(format: "%04X", vendorID)), device=\"\(info.name)\""
)
return platform
}
let text = normalizedText(from: info)
for rule in rules {
if let keyword = rule.keywords.first(where: { matches(text, keyword: $0) }) {
OTGLog.info(
.detector,
"detected platform=\(rule.platform.rawValue), matched=\"\(keyword)\", text=\"\(text)\""
)
return rule.platform
}
}
let vidHint = info.usbVendorID.map { "usbVendorID=0x\(String(format: "%04X", $0))" } ?? "usbVendorID=nil"
OTGLog.warning(.detector, "unknown platform, \(vidHint), text=\"\(text)\"")
return .unknown
}
private static func normalizedText(from info: CameraDeviceInfo) -> String {
[info.manufacturer, info.model, info.name]
.compactMap { $0?.lowercased() }
.joined(separator: " ")
.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: " ", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func matches(_ text: String, keyword: String) -> Bool {
let key = keyword.lowercased()
guard !key.isEmpty else { return false }
if text.contains(key) {
return true
}
let compactText = text.replacingOccurrences(of: " ", with: "")
.replacingOccurrences(of: "-", with: "")
let compactKey = key.replacingOccurrences(of: " ", with: "")
.replacingOccurrences(of: "-", with: "")
// "ZV-E10" vs "ZVE10" /
return !compactKey.isEmpty && compactText.contains(compactKey)
}
}