diff --git a/Sources/Core/Devices/InstalledApps.swift b/Sources/Core/Devices/InstalledApps.swift index 6261f3d..cb069a5 100644 --- a/Sources/Core/Devices/InstalledApps.swift +++ b/Sources/Core/Devices/InstalledApps.swift @@ -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] { @@ -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) } } diff --git a/Sources/Features/LogView/LogSessionView.swift b/Sources/Features/LogView/LogSessionView.swift index aa97385..d9595ad 100644 --- a/Sources/Features/LogView/LogSessionView.swift +++ b/Sources/Features/LogView/LogSessionView.swift @@ -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) @@ -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) { @@ -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 } } diff --git a/Sources/Model/LogSession.swift b/Sources/Model/LogSession.swift index d3b9190..94e2599 100644 --- a/Sources/Model/LogSession.swift +++ b/Sources/Model/LogSession.swift @@ -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 {