Extract connected device runtime orchestration
This commit is contained in:
parent
1347bb15ae
commit
cab38254dd
@ -0,0 +1,300 @@
|
|||||||
|
//
|
||||||
|
// SourceConnectedDeviceRuntime.swift
|
||||||
|
// World Manager for Minecraft
|
||||||
|
//
|
||||||
|
// Created by OpenAI Codex on 2026-05-29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol ConnectedDeviceRuntimeHosting: AnyObject {
|
||||||
|
var sources: [MinecraftSource] { get }
|
||||||
|
var connectedDevices: [ConnectedDeviceSidebarEntry] { get set }
|
||||||
|
var lastMatchedConnectedSourceIDs: Set<URL> { get set }
|
||||||
|
var cachedDeviceDiscoveryByUDID: [String: CachedConnectedDeviceDiscovery] { get set }
|
||||||
|
var isShuttingDown: Bool { get }
|
||||||
|
var hasActiveConnectedDeviceScan: Bool { get }
|
||||||
|
|
||||||
|
func source(withID sourceID: URL) -> MinecraftSource?
|
||||||
|
func updateSource(_ sourceID: URL, mutate: (inout MinecraftSource) -> Void)
|
||||||
|
func updateAvailability(for sourceID: URL, to newAvailability: SourceAvailability) -> (previous: SourceAvailability, becameAvailable: Bool)
|
||||||
|
func queueAutomaticSync(for sourceID: URL, reason: String, debounce: TimeInterval?)
|
||||||
|
func persistSourceIfAvailable(withID sourceID: URL)
|
||||||
|
func connectedDeviceDisplayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String
|
||||||
|
func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor
|
||||||
|
func logConnectedDeviceRefreshStage(
|
||||||
|
_ stage: String,
|
||||||
|
elapsed: TimeInterval,
|
||||||
|
device: ConnectedDevice,
|
||||||
|
containerCount: Int,
|
||||||
|
error: Error?
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConnectedDeviceRuntime {
|
||||||
|
static func runRefreshLoop(
|
||||||
|
on host: ConnectedDeviceRuntimeHosting,
|
||||||
|
refreshInterval: TimeInterval,
|
||||||
|
refreshIntervalWhileScanning: TimeInterval,
|
||||||
|
accessMethod: ConnectedDeviceSourceAccessMethod
|
||||||
|
) async {
|
||||||
|
while !Task.isCancelled && !host.isShuttingDown {
|
||||||
|
if host.hasActiveConnectedDeviceScan {
|
||||||
|
do {
|
||||||
|
try await Task.sleep(for: .seconds(refreshIntervalWhileScanning))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshDevices(on: host, using: accessMethod)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let nextInterval = host.hasActiveConnectedDeviceScan
|
||||||
|
? refreshIntervalWhileScanning
|
||||||
|
: refreshInterval
|
||||||
|
try await Task.sleep(for: .seconds(nextInterval))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func refreshDevices(
|
||||||
|
on host: ConnectedDeviceRuntimeHosting,
|
||||||
|
using accessMethod: ConnectedDeviceSourceAccessMethod
|
||||||
|
) async {
|
||||||
|
guard !host.isShuttingDown else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !host.hasActiveConnectedDeviceScan else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let devices: [ConnectedDevice]
|
||||||
|
do {
|
||||||
|
devices = try await accessMethod.listConnectedDevices()
|
||||||
|
} catch {
|
||||||
|
markAllConnectedDeviceSourcesDisconnected(on: host)
|
||||||
|
host.connectedDevices = []
|
||||||
|
host.lastMatchedConnectedSourceIDs = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries: [ConnectedDeviceSidebarEntry] = []
|
||||||
|
var matchedSourceIDs = Set<URL>()
|
||||||
|
let refreshContext = ConnectedDeviceRefreshContext(
|
||||||
|
existingSources: host.sources,
|
||||||
|
sourceFactory: ConnectedDeviceSourceFactory()
|
||||||
|
)
|
||||||
|
let activeScanningDeviceUDIDs = Set(
|
||||||
|
host.sources.compactMap { source -> String? in
|
||||||
|
guard source.isScanning, case .connectedDevice(let device, _) = source.origin else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return device.udid
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let currentDeviceUDIDs = Set(devices.map(\.udid))
|
||||||
|
host.cachedDeviceDiscoveryByUDID = host.cachedDeviceDiscoveryByUDID.filter { currentDeviceUDIDs.contains($0.key) }
|
||||||
|
|
||||||
|
for device in devices {
|
||||||
|
let knownSourceIDs = refreshContext.knownSourceIDs(for: device)
|
||||||
|
if !knownSourceIDs.isEmpty {
|
||||||
|
matchedSourceIDs.formUnion(knownSourceIDs)
|
||||||
|
let cachedContainers = host.cachedDeviceDiscoveryByUDID[device.udid]?.containers ?? []
|
||||||
|
for sourceID in knownSourceIDs {
|
||||||
|
refreshMatchedSource(
|
||||||
|
sourceID: sourceID,
|
||||||
|
device: device,
|
||||||
|
containers: cachedContainers,
|
||||||
|
on: host
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let containers: [DeviceAppContainer]
|
||||||
|
let discoveryErrorDescription: String?
|
||||||
|
if let cachedDiscovery = ConnectedDeviceDiscoveryCachePolicy.cachedDiscovery(
|
||||||
|
for: device,
|
||||||
|
cache: host.cachedDeviceDiscoveryByUDID,
|
||||||
|
isActivelyScanning: activeScanningDeviceUDIDs.contains(device.udid)
|
||||||
|
) {
|
||||||
|
containers = cachedDiscovery.containers
|
||||||
|
discoveryErrorDescription = cachedDiscovery.discoveryErrorDescription
|
||||||
|
} else {
|
||||||
|
let containerDiscoveryStartTime = Date()
|
||||||
|
|
||||||
|
do {
|
||||||
|
containers = try await accessMethod.listAccessibleContainers(for: device)
|
||||||
|
discoveryErrorDescription = nil
|
||||||
|
cacheDeviceDiscovery(
|
||||||
|
device: device,
|
||||||
|
containers: containers,
|
||||||
|
discoveryErrorDescription: nil,
|
||||||
|
on: host
|
||||||
|
)
|
||||||
|
host.logConnectedDeviceRefreshStage(
|
||||||
|
"Container discovery",
|
||||||
|
elapsed: Date().timeIntervalSince(containerDiscoveryStartTime),
|
||||||
|
device: device,
|
||||||
|
containerCount: containers.count,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
containers = []
|
||||||
|
discoveryErrorDescription = error.localizedDescription
|
||||||
|
cacheDeviceDiscovery(
|
||||||
|
device: device,
|
||||||
|
containers: [],
|
||||||
|
discoveryErrorDescription: error.localizedDescription,
|
||||||
|
on: host
|
||||||
|
)
|
||||||
|
host.logConnectedDeviceRefreshStage(
|
||||||
|
"Container discovery failed",
|
||||||
|
elapsed: Date().timeIntervalSince(containerDiscoveryStartTime),
|
||||||
|
device: device,
|
||||||
|
containerCount: 0,
|
||||||
|
error: error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchedSourceID = refreshContext.matchingSourceID(for: device, containers: containers)
|
||||||
|
|
||||||
|
if let matchedSourceID {
|
||||||
|
matchedSourceIDs.insert(matchedSourceID)
|
||||||
|
refreshMatchedSource(
|
||||||
|
sourceID: matchedSourceID,
|
||||||
|
device: device,
|
||||||
|
containers: containers,
|
||||||
|
on: host
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let shouldDisplayEntry =
|
||||||
|
matchedSourceID == nil
|
||||||
|
&& !refreshContext.hasKnownSource(for: device)
|
||||||
|
&& (!containers.isEmpty || device.trustState != .trusted)
|
||||||
|
|
||||||
|
if shouldDisplayEntry {
|
||||||
|
entries.append(
|
||||||
|
ConnectedDeviceSidebarEntry(
|
||||||
|
device: device,
|
||||||
|
containers: containers,
|
||||||
|
matchedSourceID: matchedSourceID,
|
||||||
|
discoveryErrorDescription: discoveryErrorDescription
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markDisconnectedConnectedDeviceSources(excluding: matchedSourceIDs, on: host)
|
||||||
|
|
||||||
|
host.connectedDevices = entries.sorted {
|
||||||
|
let lhsKnown = $0.matchedSourceID != nil
|
||||||
|
let rhsKnown = $1.matchedSourceID != nil
|
||||||
|
if lhsKnown != rhsKnown {
|
||||||
|
return lhsKnown && !rhsKnown
|
||||||
|
}
|
||||||
|
|
||||||
|
let lhsMinecraft = $0.hasMinecraftContainer
|
||||||
|
let rhsMinecraft = $1.hasMinecraftContainer
|
||||||
|
if lhsMinecraft != rhsMinecraft {
|
||||||
|
return lhsMinecraft && !rhsMinecraft
|
||||||
|
}
|
||||||
|
|
||||||
|
return $0.device.name.localizedStandardCompare($1.device.name) == .orderedAscending
|
||||||
|
}
|
||||||
|
|
||||||
|
host.lastMatchedConnectedSourceIDs = matchedSourceIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func cacheDeviceDiscovery(
|
||||||
|
device: ConnectedDevice,
|
||||||
|
containers: [DeviceAppContainer],
|
||||||
|
discoveryErrorDescription: String?,
|
||||||
|
on host: ConnectedDeviceRuntimeHosting
|
||||||
|
) {
|
||||||
|
host.cachedDeviceDiscoveryByUDID[device.udid] = ConnectedDeviceDiscoveryCachePolicy.cacheDiscovery(
|
||||||
|
for: device,
|
||||||
|
containers: containers,
|
||||||
|
discoveryErrorDescription: discoveryErrorDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func refreshMatchedSource(
|
||||||
|
sourceID: URL,
|
||||||
|
device: ConnectedDevice,
|
||||||
|
containers: [DeviceAppContainer],
|
||||||
|
on host: ConnectedDeviceRuntimeHosting
|
||||||
|
) {
|
||||||
|
let nextAvailability = ConnectedDeviceSourcePolicy.availability(
|
||||||
|
for: device,
|
||||||
|
hasMinecraftContainer: true
|
||||||
|
)
|
||||||
|
host.updateSource(sourceID) { source in
|
||||||
|
guard case .connectedDevice(let previousDevice, let previousContainer) = source.origin else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedContainer = containers.first(where: {
|
||||||
|
$0.appID == previousContainer.appID && $0.accessMode == previousContainer.accessMode
|
||||||
|
}) ?? previousContainer
|
||||||
|
|
||||||
|
var resolvedDevice = device
|
||||||
|
resolvedDevice.name = ConnectedDeviceSourcePolicy.preferredDeviceName(
|
||||||
|
currentName: device.name,
|
||||||
|
fallbackDeviceName: previousDevice.name,
|
||||||
|
fallbackDisplayName: source.displayName
|
||||||
|
)
|
||||||
|
source.origin = .connectedDevice(device: resolvedDevice, container: resolvedContainer)
|
||||||
|
source.displayName = host.connectedDeviceDisplayName(for: resolvedDevice, container: resolvedContainer)
|
||||||
|
source.accessDescriptor = host.accessDescriptor(for: source)
|
||||||
|
}
|
||||||
|
let transition = host.updateAvailability(for: sourceID, to: nextAvailability)
|
||||||
|
host.persistSourceIfAvailable(withID: sourceID)
|
||||||
|
|
||||||
|
guard let source = host.source(withID: sourceID), source.availability == .available else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if transition.becameAvailable {
|
||||||
|
if ConnectedDeviceSourcePolicy.shouldRefreshSourceOnReconnect(source) {
|
||||||
|
host.queueAutomaticSync(
|
||||||
|
for: sourceID,
|
||||||
|
reason: source.hasCachedContent
|
||||||
|
? "Device available. Refreshing cached library..."
|
||||||
|
: "Device available. Scanning Minecraft library...",
|
||||||
|
debounce: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ConnectedDeviceSourcePolicy.shouldRefreshSource(source) {
|
||||||
|
host.queueAutomaticSync(for: sourceID, reason: "Refreshing device library...", debounce: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func markAllConnectedDeviceSourcesDisconnected(on host: ConnectedDeviceRuntimeHosting) {
|
||||||
|
for source in host.sources where source.origin.kind == .connectedDevice {
|
||||||
|
_ = host.updateAvailability(for: source.id, to: .disconnected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func markDisconnectedConnectedDeviceSources(
|
||||||
|
excluding matchedSourceIDs: Set<URL>,
|
||||||
|
on host: ConnectedDeviceRuntimeHosting
|
||||||
|
) {
|
||||||
|
for source in host.sources where source.origin.kind == .connectedDevice && !matchedSourceIDs.contains(source.id) {
|
||||||
|
_ = host.updateAvailability(for: source.id, to: .disconnected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,7 +10,7 @@ import Foundation
|
|||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting {
|
final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting, ConnectedDeviceRuntimeHosting {
|
||||||
private static let enrichmentWorkerCount = 4
|
private static let enrichmentWorkerCount = 4
|
||||||
private static let sizeWorkerCount = 2
|
private static let sizeWorkerCount = 2
|
||||||
private static let minimumVisibleScanDuration: TimeInterval = 0.8
|
private static let minimumVisibleScanDuration: TimeInterval = 0.8
|
||||||
@ -26,7 +26,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Published var sources: [MinecraftSource] = []
|
@Published var sources: [MinecraftSource] = []
|
||||||
@Published private(set) var connectedDevices: [ConnectedDeviceSidebarEntry] = []
|
@Published var connectedDevices: [ConnectedDeviceSidebarEntry] = []
|
||||||
@Published var isRestoringPersistedSources = true
|
@Published var isRestoringPersistedSources = true
|
||||||
|
|
||||||
private var scanTasks: [URL: Task<Void, Never>] = [:]
|
private var scanTasks: [URL: Task<Void, Never>] = [:]
|
||||||
@ -38,9 +38,9 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
private let connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod?
|
private let connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod?
|
||||||
private let notificationService: ScanNotificationServicing
|
private let notificationService: ScanNotificationServicing
|
||||||
private let connectedDeviceSourceFactory = ConnectedDeviceSourceFactory()
|
private let connectedDeviceSourceFactory = ConnectedDeviceSourceFactory()
|
||||||
private var lastMatchedConnectedSourceIDs: Set<URL> = []
|
var lastMatchedConnectedSourceIDs: Set<URL> = []
|
||||||
private var cachedDeviceDiscoveryByUDID: [String: CachedConnectedDeviceDiscovery] = [:]
|
var cachedDeviceDiscoveryByUDID: [String: CachedConnectedDeviceDiscovery] = [:]
|
||||||
private var isShuttingDown = false
|
var isShuttingDown = false
|
||||||
|
|
||||||
init(
|
init(
|
||||||
persistenceStore: SourcePersistenceStore = .shared,
|
persistenceStore: SourcePersistenceStore = .shared,
|
||||||
@ -230,7 +230,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
sources.contains(where: \.isScanning)
|
sources.contains(where: \.isScanning)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var hasActiveConnectedDeviceScan: Bool {
|
var hasActiveConnectedDeviceScan: Bool {
|
||||||
sources.contains { $0.isScanning && $0.origin.kind == .connectedDevice }
|
sources.contains { $0.isScanning && $0.origin.kind == .connectedDevice }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -464,29 +464,16 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func runConnectedDeviceRefreshLoop() async {
|
private func runConnectedDeviceRefreshLoop() async {
|
||||||
while !Task.isCancelled && !isShuttingDown {
|
guard let connectedDeviceAccessMethod else {
|
||||||
if hasActiveConnectedDeviceScan {
|
return
|
||||||
do {
|
|
||||||
try await Task.sleep(for: .seconds(Self.connectedDeviceRefreshIntervalWhileScanning))
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshConnectedDevices()
|
|
||||||
|
|
||||||
do {
|
|
||||||
let refreshInterval = sources.contains {
|
|
||||||
$0.isScanning && $0.origin.kind == .connectedDevice
|
|
||||||
}
|
|
||||||
? Self.connectedDeviceRefreshIntervalWhileScanning
|
|
||||||
: Self.connectedDeviceRefreshInterval
|
|
||||||
try await Task.sleep(for: .seconds(refreshInterval))
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ConnectedDeviceRuntime.runRefreshLoop(
|
||||||
|
on: self,
|
||||||
|
refreshInterval: Self.connectedDeviceRefreshInterval,
|
||||||
|
refreshIntervalWhileScanning: Self.connectedDeviceRefreshIntervalWhileScanning,
|
||||||
|
accessMethod: connectedDeviceAccessMethod
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func runLocalSourceRefreshLoop() async {
|
private func runLocalSourceRefreshLoop() async {
|
||||||
@ -564,232 +551,11 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
}
|
}
|
||||||
|
|
||||||
func refreshConnectedDevices() async {
|
func refreshConnectedDevices() async {
|
||||||
guard !isShuttingDown else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !hasActiveConnectedDeviceScan else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let connectedDeviceAccessMethod else {
|
guard let connectedDeviceAccessMethod else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let devices: [ConnectedDevice]
|
await ConnectedDeviceRuntime.refreshDevices(on: self, using: connectedDeviceAccessMethod)
|
||||||
do {
|
|
||||||
devices = try await connectedDeviceAccessMethod.listConnectedDevices()
|
|
||||||
} catch {
|
|
||||||
markAllConnectedDeviceSourcesDisconnected()
|
|
||||||
connectedDevices = []
|
|
||||||
lastMatchedConnectedSourceIDs = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries: [ConnectedDeviceSidebarEntry] = []
|
|
||||||
var matchedSourceIDs = Set<URL>()
|
|
||||||
let refreshContext = ConnectedDeviceRefreshContext(
|
|
||||||
existingSources: sources,
|
|
||||||
sourceFactory: connectedDeviceSourceFactory
|
|
||||||
)
|
|
||||||
let activeScanningDeviceUDIDs = Set(
|
|
||||||
sources.compactMap { source -> String? in
|
|
||||||
guard source.isScanning, case .connectedDevice(let device, _) = source.origin else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return device.udid
|
|
||||||
}
|
|
||||||
)
|
|
||||||
let currentDeviceUDIDs = Set(devices.map(\.udid))
|
|
||||||
cachedDeviceDiscoveryByUDID = cachedDeviceDiscoveryByUDID.filter { currentDeviceUDIDs.contains($0.key) }
|
|
||||||
|
|
||||||
for device in devices {
|
|
||||||
let knownSourceIDs = refreshContext.knownSourceIDs(for: device)
|
|
||||||
if !knownSourceIDs.isEmpty {
|
|
||||||
matchedSourceIDs.formUnion(knownSourceIDs)
|
|
||||||
let cachedContainers = cachedDeviceDiscoveryByUDID[device.udid]?.containers ?? []
|
|
||||||
for sourceID in knownSourceIDs {
|
|
||||||
refreshMatchedConnectedDeviceSource(
|
|
||||||
sourceID: sourceID,
|
|
||||||
device: device,
|
|
||||||
containers: cachedContainers
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let containers: [DeviceAppContainer]
|
|
||||||
let discoveryErrorDescription: String?
|
|
||||||
if let cachedDiscovery = ConnectedDeviceDiscoveryCachePolicy.cachedDiscovery(
|
|
||||||
for: device,
|
|
||||||
cache: cachedDeviceDiscoveryByUDID,
|
|
||||||
isActivelyScanning: activeScanningDeviceUDIDs.contains(device.udid)
|
|
||||||
) {
|
|
||||||
containers = cachedDiscovery.containers
|
|
||||||
discoveryErrorDescription = cachedDiscovery.discoveryErrorDescription
|
|
||||||
} else {
|
|
||||||
let containerDiscoveryStartTime = Date()
|
|
||||||
|
|
||||||
do {
|
|
||||||
containers = try await connectedDeviceAccessMethod.listAccessibleContainers(for: device)
|
|
||||||
discoveryErrorDescription = nil
|
|
||||||
cacheDeviceDiscovery(
|
|
||||||
device: device,
|
|
||||||
containers: containers,
|
|
||||||
discoveryErrorDescription: nil
|
|
||||||
)
|
|
||||||
logDeviceRefreshStage(
|
|
||||||
"Container discovery",
|
|
||||||
elapsed: Date().timeIntervalSince(containerDiscoveryStartTime),
|
|
||||||
device: device,
|
|
||||||
containerCount: containers.count
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
containers = []
|
|
||||||
discoveryErrorDescription = error.localizedDescription
|
|
||||||
cacheDeviceDiscovery(
|
|
||||||
device: device,
|
|
||||||
containers: [],
|
|
||||||
discoveryErrorDescription: error.localizedDescription
|
|
||||||
)
|
|
||||||
logDeviceRefreshStage(
|
|
||||||
"Container discovery failed",
|
|
||||||
elapsed: Date().timeIntervalSince(containerDiscoveryStartTime),
|
|
||||||
device: device,
|
|
||||||
containerCount: 0,
|
|
||||||
error: error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let matchedSourceID = refreshContext.matchingSourceID(for: device, containers: containers)
|
|
||||||
|
|
||||||
if let matchedSourceID {
|
|
||||||
matchedSourceIDs.insert(matchedSourceID)
|
|
||||||
refreshMatchedConnectedDeviceSource(
|
|
||||||
sourceID: matchedSourceID,
|
|
||||||
device: device,
|
|
||||||
containers: containers
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let shouldDisplayEntry =
|
|
||||||
matchedSourceID == nil
|
|
||||||
&& !refreshContext.hasKnownSource(for: device)
|
|
||||||
&& (!containers.isEmpty || device.trustState != .trusted)
|
|
||||||
|
|
||||||
if shouldDisplayEntry {
|
|
||||||
entries.append(
|
|
||||||
ConnectedDeviceSidebarEntry(
|
|
||||||
device: device,
|
|
||||||
containers: containers,
|
|
||||||
matchedSourceID: matchedSourceID,
|
|
||||||
discoveryErrorDescription: discoveryErrorDescription
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
markDisconnectedConnectedDeviceSources(excluding: matchedSourceIDs)
|
|
||||||
|
|
||||||
connectedDevices = entries.sorted {
|
|
||||||
let lhsKnown = $0.matchedSourceID != nil
|
|
||||||
let rhsKnown = $1.matchedSourceID != nil
|
|
||||||
if lhsKnown != rhsKnown {
|
|
||||||
return lhsKnown && !rhsKnown
|
|
||||||
}
|
|
||||||
|
|
||||||
let lhsMinecraft = $0.hasMinecraftContainer
|
|
||||||
let rhsMinecraft = $1.hasMinecraftContainer
|
|
||||||
if lhsMinecraft != rhsMinecraft {
|
|
||||||
return lhsMinecraft && !rhsMinecraft
|
|
||||||
}
|
|
||||||
|
|
||||||
return $0.device.name.localizedStandardCompare($1.device.name) == .orderedAscending
|
|
||||||
}
|
|
||||||
|
|
||||||
lastMatchedConnectedSourceIDs = matchedSourceIDs
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cacheDeviceDiscovery(
|
|
||||||
device: ConnectedDevice,
|
|
||||||
containers: [DeviceAppContainer],
|
|
||||||
discoveryErrorDescription: String?
|
|
||||||
) {
|
|
||||||
cachedDeviceDiscoveryByUDID[device.udid] = ConnectedDeviceDiscoveryCachePolicy.cacheDiscovery(
|
|
||||||
for: device,
|
|
||||||
containers: containers,
|
|
||||||
discoveryErrorDescription: discoveryErrorDescription
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshMatchedConnectedDeviceSource(
|
|
||||||
sourceID: URL,
|
|
||||||
device: ConnectedDevice,
|
|
||||||
containers: [DeviceAppContainer]
|
|
||||||
) {
|
|
||||||
let nextAvailability = ConnectedDeviceSourcePolicy.availability(
|
|
||||||
for: device,
|
|
||||||
hasMinecraftContainer: true
|
|
||||||
)
|
|
||||||
updateSource(sourceID) { source in
|
|
||||||
guard case .connectedDevice(let previousDevice, let previousContainer) = source.origin else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolvedContainer = containers.first(where: {
|
|
||||||
$0.appID == previousContainer.appID && $0.accessMode == previousContainer.accessMode
|
|
||||||
}) ?? previousContainer
|
|
||||||
|
|
||||||
var resolvedDevice = device
|
|
||||||
resolvedDevice.name = ConnectedDeviceSourcePolicy.preferredDeviceName(
|
|
||||||
currentName: device.name,
|
|
||||||
fallbackDeviceName: previousDevice.name,
|
|
||||||
fallbackDisplayName: source.displayName
|
|
||||||
)
|
|
||||||
source.origin = .connectedDevice(device: resolvedDevice, container: resolvedContainer)
|
|
||||||
source.displayName = connectedDeviceSourceFactory.displayName(
|
|
||||||
for: resolvedDevice,
|
|
||||||
container: resolvedContainer
|
|
||||||
)
|
|
||||||
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
|
|
||||||
}
|
|
||||||
let transition = updateAvailability(for: sourceID, to: nextAvailability)
|
|
||||||
persistSourceIfAvailable(withID: sourceID)
|
|
||||||
|
|
||||||
guard let source = source(withID: sourceID), source.availability == .available else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if transition.becameAvailable {
|
|
||||||
if ConnectedDeviceSourcePolicy.shouldRefreshSourceOnReconnect(source) {
|
|
||||||
queueAutomaticSync(
|
|
||||||
for: sourceID,
|
|
||||||
reason: source.hasCachedContent
|
|
||||||
? "Device available. Refreshing cached library..."
|
|
||||||
: "Device available. Scanning Minecraft library..."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if ConnectedDeviceSourcePolicy.shouldRefreshSource(source) {
|
|
||||||
queueAutomaticSync(for: sourceID, reason: "Refreshing device library...")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func markAllConnectedDeviceSourcesDisconnected() {
|
|
||||||
for source in sources where source.origin.kind == .connectedDevice {
|
|
||||||
_ = updateAvailability(for: source.id, to: .disconnected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func markDisconnectedConnectedDeviceSources(excluding matchedSourceIDs: Set<URL>) {
|
|
||||||
for source in sources where source.origin.kind == .connectedDevice && !matchedSourceIDs.contains(source.id) {
|
|
||||||
_ = updateAvailability(for: source.id, to: .disconnected)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] {
|
func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] {
|
||||||
@ -800,6 +566,26 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
connectedDeviceSourceFactory.displayName(for: device, container: container)
|
connectedDeviceSourceFactory.displayName(for: device, container: container)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func accessDescriptor(for source: MinecraftSource) -> SourceAccessDescriptor {
|
||||||
|
sourceAccessMethod.accessDescriptor(for: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logConnectedDeviceRefreshStage(
|
||||||
|
_ stage: String,
|
||||||
|
elapsed: TimeInterval,
|
||||||
|
device: ConnectedDevice,
|
||||||
|
containerCount: Int,
|
||||||
|
error: Error?
|
||||||
|
) {
|
||||||
|
logDeviceRefreshStage(
|
||||||
|
stage,
|
||||||
|
elapsed: elapsed,
|
||||||
|
device: device,
|
||||||
|
containerCount: containerCount,
|
||||||
|
error: error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func appendRestoredSource(_ source: MinecraftSource) {
|
func appendRestoredSource(_ source: MinecraftSource) {
|
||||||
sources.append(source)
|
sources.append(source)
|
||||||
rebuildNormalizedIndex(for: source.id)
|
rebuildNormalizedIndex(for: source.id)
|
||||||
@ -936,7 +722,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
|||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func updateAvailability(for sourceID: URL, to newAvailability: SourceAvailability) -> (previous: SourceAvailability, becameAvailable: Bool) {
|
func updateAvailability(for sourceID: URL, to newAvailability: SourceAvailability) -> (previous: SourceAvailability, becameAvailable: Bool) {
|
||||||
let previousAvailability = source(withID: sourceID)?.availability ?? .unknown
|
let previousAvailability = source(withID: sourceID)?.availability ?? .unknown
|
||||||
let becameAvailable = previousAvailability != .available && newAvailability == .available
|
let becameAvailable = previousAvailability != .available && newAvailability == .available
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user