Extract connected device runtime orchestration

This commit is contained in:
John Burwell 2026-05-29 07:11:07 -05:00
parent 1347bb15ae
commit cab38254dd
2 changed files with 337 additions and 251 deletions

View File

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

View File

@ -10,7 +10,7 @@ import Foundation
import OSLog
@MainActor
final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting {
final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePersistenceHosting, ConnectedDeviceRuntimeHosting {
private static let enrichmentWorkerCount = 4
private static let sizeWorkerCount = 2
private static let minimumVisibleScanDuration: TimeInterval = 0.8
@ -26,7 +26,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
)
@Published var sources: [MinecraftSource] = []
@Published private(set) var connectedDevices: [ConnectedDeviceSidebarEntry] = []
@Published var connectedDevices: [ConnectedDeviceSidebarEntry] = []
@Published var isRestoringPersistedSources = true
private var scanTasks: [URL: Task<Void, Never>] = [:]
@ -38,9 +38,9 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
private let connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod?
private let notificationService: ScanNotificationServicing
private let connectedDeviceSourceFactory = ConnectedDeviceSourceFactory()
private var lastMatchedConnectedSourceIDs: Set<URL> = []
private var cachedDeviceDiscoveryByUDID: [String: CachedConnectedDeviceDiscovery] = [:]
private var isShuttingDown = false
var lastMatchedConnectedSourceIDs: Set<URL> = []
var cachedDeviceDiscoveryByUDID: [String: CachedConnectedDeviceDiscovery] = [:]
var isShuttingDown = false
init(
persistenceStore: SourcePersistenceStore = .shared,
@ -230,7 +230,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
sources.contains(where: \.isScanning)
}
private var hasActiveConnectedDeviceScan: Bool {
var hasActiveConnectedDeviceScan: Bool {
sources.contains { $0.isScanning && $0.origin.kind == .connectedDevice }
}
@ -464,29 +464,16 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
}
private func runConnectedDeviceRefreshLoop() async {
while !Task.isCancelled && !isShuttingDown {
if hasActiveConnectedDeviceScan {
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
}
guard let connectedDeviceAccessMethod else {
return
}
await ConnectedDeviceRuntime.runRefreshLoop(
on: self,
refreshInterval: Self.connectedDeviceRefreshInterval,
refreshIntervalWhileScanning: Self.connectedDeviceRefreshIntervalWhileScanning,
accessMethod: connectedDeviceAccessMethod
)
}
private func runLocalSourceRefreshLoop() async {
@ -564,232 +551,11 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
}
func refreshConnectedDevices() async {
guard !isShuttingDown else {
return
}
guard !hasActiveConnectedDeviceScan else {
return
}
guard let connectedDeviceAccessMethod else {
return
}
let devices: [ConnectedDevice]
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)
}
await ConnectedDeviceRuntime.refreshDevices(on: self, using: connectedDeviceAccessMethod)
}
func currentCollectionSnapshots(for sourceURL: URL) -> [CollectionSnapshot] {
@ -800,6 +566,26 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
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) {
sources.append(source)
rebuildNormalizedIndex(for: source.id)
@ -936,7 +722,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
}
@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 becameAvailable = previousAvailability != .available && newAvailability == .available