Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/Core/Capture/AgentCaptureSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
5 changes: 4 additions & 1 deletion Sources/Core/Devices/IOSDeviceProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
39 changes: 33 additions & 6 deletions Sources/Core/Devices/InstalledApps.swift
Original file line number Diff line number Diff line change
@@ -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] {
Expand Down Expand Up @@ -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
}
}

Expand Down
3 changes: 2 additions & 1 deletion Sources/Core/Logs/IOSDeviceConsoleLogSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
18 changes: 17 additions & 1 deletion Sources/Core/Process/ProcessRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion Sources/Model/LogSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down