diff --git a/Sources/Core/Capture/AgentCaptureSource.swift b/Sources/Core/Capture/AgentCaptureSource.swift index 9f6c5d5..ccc521a 100644 --- a/Sources/Core/Capture/AgentCaptureSource.swift +++ b/Sources/Core/Capture/AgentCaptureSource.swift @@ -58,7 +58,7 @@ final class AgentCaptureSource: CaptureSource { let r = try? await CommandRunner.run(AppleToolchain.xcrun, ["simctl", "list", "devices", "booted"]) return r?.stdout.contains(device.id) ?? false case .iosDevice: - let r = try? await CommandRunner.run(AppleToolchain.xcrun, ["devicectl", "list", "devices"]) + let r = try? await CommandRunner.run(AppleToolchain.xcrun, ["devicectl", "list", "devices"], timeout: 12) return r?.stdout.contains(device.id) ?? false } } diff --git a/Sources/Core/Devices/IOSDeviceProvider.swift b/Sources/Core/Devices/IOSDeviceProvider.swift index 35221aa..8a898da 100644 --- a/Sources/Core/Devices/IOSDeviceProvider.swift +++ b/Sources/Core/Devices/IOSDeviceProvider.swift @@ -69,10 +69,13 @@ final class IOSDeviceProvider: DeviceProvider { let tmp = FileManager.default.temporaryDirectory .appendingPathComponent("squeeze-devicectl-\(UUID().uuidString).json") defer { try? FileManager.default.removeItem(at: tmp) } + // Bounded: this runs on a discovery poll loop, so a hung devicectl must self-kill + // rather than accumulate (a pile of stuck `list devices` wedges CoreDevice itself). guard (try? await CommandRunner.run( AppleToolchain.xcrun, ["devicectl", "list", "devices", "--json-output", tmp.path], - environment: AppleToolchain.environment() + environment: AppleToolchain.environment(), + timeout: 12 )) != nil, let data = try? Data(contentsOf: tmp) else { return [] } return IOSDeviceParser.parse(data) } diff --git a/Sources/Core/Devices/InstalledApps.swift b/Sources/Core/Devices/InstalledApps.swift index f7d53d8..6261f3d 100644 --- a/Sources/Core/Devices/InstalledApps.swift +++ b/Sources/Core/Devices/InstalledApps.swift @@ -1,13 +1,32 @@ import Foundation /// An installed app/package available to filter by. -struct AppEntry: Identifiable, Hashable, Sendable { +struct AppEntry: Identifiable, Hashable, Sendable, Codable { let id: String // package id (Android) / bundle id (iOS) let name: String? // human name (iOS); nil on Android let isUserApp: Bool // user-installed vs system var display: String { name ?? id } } +/// Disk cache of the last good iOS-device app list, so a slow/wedged `devicectl` +/// (CoreDevice stalls) shows the last-known apps instead of an empty "No apps found". +private enum IOSAppCache { + private static func file(_ udid: String) -> URL { + let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Jaca", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("ios-apps-\(udid).json") + } + static func load(_ udid: String) -> [AppEntry] { + guard let data = try? Data(contentsOf: file(udid)) else { return [] } + return (try? JSONDecoder().decode([AppEntry].self, from: data)) ?? [] + } + static func save(_ apps: [AppEntry], udid: String) { + guard let data = try? JSONEncoder().encode(apps) else { return } + try? data.write(to: file(udid)) + } +} + /// Enumerates installed apps for the package/app-id filter dropdown. enum InstalledApps { static func list(for device: Device, adbURL: URL?) async -> [AppEntry] { @@ -43,16 +62,24 @@ enum InstalledApps { // MARK: iOS Device private static func iosDevice(udid: String) async -> [AppEntry] { - guard AppleToolchain.hasFullXcode else { return [] } + guard AppleToolchain.hasFullXcode else { return IOSAppCache.load(udid) } let tmp = FileManager.default.temporaryDirectory .appendingPathComponent("jaca-devicectl-apps-\(UUID().uuidString).json") defer { try? FileManager.default.removeItem(at: tmp) } - guard (try? await CommandRunner.run( + var fresh: [AppEntry] = [] + if (try? await CommandRunner.run( AppleToolchain.xcrun, ["devicectl", "device", "info", "apps", "--device", udid, "--json-output", tmp.path], - environment: AppleToolchain.environment() - )) != nil, let data = try? Data(contentsOf: tmp) else { return [] } - return IOSAppsParser.parse(data) + environment: AppleToolchain.environment(), + timeout: 25 + )) != nil, let data = try? Data(contentsOf: tmp) { + fresh = IOSAppsParser.parse(data) + } + // devicectl is flaky (CoreDevice stalls): on a slow/empty/failed query, fall back to + // the last good list so the picker never blanks to "No apps found". + guard !fresh.isEmpty else { return IOSAppCache.load(udid) } + IOSAppCache.save(fresh, udid: udid) + return fresh } } diff --git a/Sources/Core/Logs/IOSDeviceConsoleLogSource.swift b/Sources/Core/Logs/IOSDeviceConsoleLogSource.swift index 6abe21a..7bcdbc4 100644 --- a/Sources/Core/Logs/IOSDeviceConsoleLogSource.swift +++ b/Sources/Core/Logs/IOSDeviceConsoleLogSource.swift @@ -122,7 +122,8 @@ enum IOSDeviceLock { guard (try? await CommandRunner.run( AppleToolchain.xcrun, ["devicectl", "device", "info", "lockState", "--device", udid, "--json-output", tmp.path], - environment: AppleToolchain.environment() + environment: AppleToolchain.environment(), + timeout: 8 // polled in a loop — never let a wedged devicectl stack up )) != nil, let data = try? Data(contentsOf: tmp), let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] diff --git a/Sources/Core/Process/ProcessRunner.swift b/Sources/Core/Process/ProcessRunner.swift index e34be7f..9c3169f 100644 --- a/Sources/Core/Process/ProcessRunner.swift +++ b/Sources/Core/Process/ProcessRunner.swift @@ -25,10 +25,15 @@ enum CommandRunner { /// Runs `executable args…` to completion off the calling actor. /// Throws `ProcessError` only on launch failure; a non-zero exit is returned /// in `Result.exitCode` (callers decide whether that's fatal). + /// `timeout` (seconds) SIGKILLs a child that runs too long, so a wedged tool — + /// notably `devicectl` when CoreDevice stalls — can't block this call forever or + /// let callers (device polling, app listing) pile up zombie processes that wedge + /// CoreDevice further. A killed child returns its signal exit code + partial output. static func run( _ executable: URL, _ arguments: [String], - environment: [String: String]? = nil + environment: [String: String]? = nil, + timeout: TimeInterval? = nil ) async throws -> Result { guard FileManager.default.isExecutableFile(atPath: executable.path) else { throw ProcessError.executableNotFound(executable.path) @@ -49,10 +54,21 @@ enum CommandRunner { continuation.resume(throwing: ProcessError.launchFailed(error.localizedDescription)) return } + var timer: DispatchSourceTimer? + if let timeout { + let pid = process.processIdentifier + let t = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility)) + t.schedule(deadline: .now() + timeout) + t.setEventHandler { if process.isRunning { kill(pid, SIGKILL) } } + t.resume() + timer = t + } // Read fully before waitUntilExit to avoid deadlock on large output. + // A SIGKILL on timeout closes the pipes, so these reads unblock. let outData = outPipe.fileHandleForReading.readDataToEndOfFile() let errData = errPipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() + timer?.cancel() continuation.resume(returning: Result( exitCode: process.terminationStatus, stdout: String(decoding: outData, as: UTF8.self), diff --git a/Sources/Model/LogSession.swift b/Sources/Model/LogSession.swift index f7e3bba..d3b9190 100644 --- a/Sources/Model/LogSession.swift +++ b/Sources/Model/LogSession.swift @@ -327,7 +327,7 @@ final class LogSession: WorkspaceTab { let r = try? await CommandRunner.run(AppleToolchain.xcrun, ["simctl", "list", "devices", "booted"]) return r?.stdout.contains(device.id) ?? false case .iosDevice: - let r = try? await CommandRunner.run(AppleToolchain.xcrun, ["devicectl", "list", "devices"]) + let r = try? await CommandRunner.run(AppleToolchain.xcrun, ["devicectl", "list", "devices"], timeout: 12) return r?.stdout.contains(device.id) ?? false } }