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
36 changes: 25 additions & 11 deletions Sources/Core/Devices/InstalledApps.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ enum InstalledApps {
}
}

/// Last good list for a device, read synchronously, so UI can show it instantly
/// while `list(for:)` refreshes. Empty for non-iOS-device or no cache yet.
static func cached(for device: Device) -> [AppEntry] {
device.platform == .iosDevice ? IOSAppCache.load(device.id) : []
}

// MARK: Android

private static func android(adbURL: URL?, serial: String) async -> [AppEntry] {
Expand All @@ -63,23 +69,31 @@ enum InstalledApps {

private static func iosDevice(udid: String) async -> [AppEntry] {
guard AppleToolchain.hasFullXcode else { return IOSAppCache.load(udid) }
// devicectl's first call after a cold start often stalls while it warms the device
// tunnel, then a retry returns fast — so try twice before giving up. On any
// slow/empty/failed result, fall back to the last good list so the picker never
// blanks to "No apps found".
for attempt in 0..<2 {
if let fresh = await queryIOSApps(udid: udid), !fresh.isEmpty {
IOSAppCache.save(fresh, udid: udid)
return fresh
}
if attempt == 0 { try? await Task.sleep(for: .seconds(1)) }
}
return IOSAppCache.load(udid)
}

private static func queryIOSApps(udid: String) async -> [AppEntry]? {
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("jaca-devicectl-apps-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: tmp) }
var fresh: [AppEntry] = []
if (try? await CommandRunner.run(
guard (try? await CommandRunner.run(
AppleToolchain.xcrun,
["devicectl", "device", "info", "apps", "--device", udid, "--json-output", tmp.path],
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
timeout: 20
)) != nil, let data = try? Data(contentsOf: tmp) else { return nil }
return IOSAppsParser.parse(data)
}
}

Expand Down
27 changes: 18 additions & 9 deletions Sources/Features/LogView/LogSessionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,9 @@ private struct PackagePicker: View {
@State private var query = ""

var body: some View {
Button(action: { show = true; if !loaded { load() } }) {
// Re-fetch when we have nothing to show (first open, or a previous attempt came
// back empty) so a flaky devicectl gets another shot just by reopening.
Button(action: { show = true; if !loaded || apps.isEmpty { load() } }) {
Image(systemName: "chevron.down")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(LemonadeTheme.colors.content.contentSecondary)
Expand Down Expand Up @@ -350,11 +352,16 @@ private struct PackagePicker: View {
ProgressView().padding(LemonadeTheme.spaces.spacing400)
.frame(maxWidth: .infinity)
} else if apps.isEmpty {
LemonadeUi.Text("No apps found on this device.",
textStyle: LemonadeTypography.shared.bodySmallRegular,
color: LemonadeTheme.colors.content.contentTertiary)
.padding(LemonadeTheme.spaces.spacing400)
.frame(maxWidth: .infinity)
VStack(spacing: LemonadeTheme.spaces.spacing200) {
LemonadeUi.Text("No apps found on this device.",
textStyle: LemonadeTypography.shared.bodySmallRegular,
color: LemonadeTheme.colors.content.contentTertiary)
LemonadeUi.Button(label: "Retry", onClick: { load() },
variant: .neutral, type: .subtle, size: .small)
.fixedSize()
}
.padding(LemonadeTheme.spaces.spacing400)
.frame(maxWidth: .infinity)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
Expand Down Expand Up @@ -400,12 +407,14 @@ private struct PackagePicker: View {
}

private func load() {
loading = true
// Show the last-known list instantly (iOS cache); only spin if we have nothing yet.
if apps.isEmpty { apps = session.cachedApps() }
loading = apps.isEmpty
// @MainActor so the @State mutations below never happen off the main thread.
Task { @MainActor in
let result = await session.installedApps()
apps = result
loaded = true
if !result.isEmpty { apps = result } // keep the cache shown if a refresh fails
loaded = !apps.isEmpty // stay un-loaded while empty so reopen retries
loading = false
}
}
Expand Down
6 changes: 6 additions & 0 deletions Sources/Model/LogSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,12 @@ final class LogSession: WorkspaceTab {
await InstalledApps.list(for: device, adbURL: adbURL)
}

/// Last good app list (synchronous), so the picker shows something instantly while
/// `installedApps()` refreshes — never a blank "No apps found" when we have a cache.
func cachedApps() -> [AppEntry] {
InstalledApps.cached(for: device)
}

// MARK: - Export

func exportText() -> String {
Expand Down