301 lines
12 KiB
Swift
301 lines
12 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|
|
}
|