diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4aedd6..87266f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -239,7 +239,9 @@ jobs: xcrun simctl spawn "$UDID" defaults write com.apple.Preferences KeyboardAutocorrection -bool false xcrun simctl spawn "$UDID" defaults write com.apple.Preferences KeyboardAutocapitalization -bool false xcrun simctl spawn "$UDID" defaults write com.apple.Preferences KeyboardPrediction -bool false - defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool NO + # Enabled so Cmd+A works in clearAndType on iPad fields where iOS 26 + # Liquid Glass reports the text field as not-hittable for triple-tap. + defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool YES xcrun simctl status_bar "$UDID" override \ --time "9:41" --dataNetwork wifi --wifiMode active --wifiBars 3 \ --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100 || true diff --git a/SF50 TOLD/Loaders/TerrainLoader/TerrainDataLoader.swift b/SF50 TOLD/Loaders/TerrainLoader/TerrainDataLoader.swift index 186c43d..5bb7443 100644 --- a/SF50 TOLD/Loaders/TerrainLoader/TerrainDataLoader.swift +++ b/SF50 TOLD/Loaders/TerrainLoader/TerrainDataLoader.swift @@ -62,13 +62,13 @@ final class TerrainDataLoader: ObservableObject { @Published private(set) var corruptedRegions: Set = [] /// Logger for debug output. - private let logger = Logger( + nonisolated private let logger = Logger( subsystem: "codes.tim.SF50-TOLD", category: "TerrainDataLoader" ) /// App group identifier for shared storage. - private let appGroupID = "group.codes.tim.TOLD" + nonisolated private let appGroupID = "group.codes.tim.TOLD" /// Base URL for terrain data downloads. private var baseURL = TerrainManifest.defaultBaseURL.absoluteString @@ -82,7 +82,7 @@ final class TerrainDataLoader: ObservableObject { }() /// Returns the URL to the terrain directory in the app group container. - private var terrainDirectory: URL? { + nonisolated private var terrainDirectory: URL? { guard let containerURL = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupID @@ -177,37 +177,41 @@ final class TerrainDataLoader: ObservableObject { /// Refreshes the list of available regions by scanning the terrain directory. /// /// Also detects compressed `.srtm.lzma` files left by the Background Assets - /// extension and triggers async decompression for them. + /// extension and triggers async decompression for them. The filesystem scan + /// runs on a background task so the main actor is never blocked. func refreshAvailableRegions() { - var available = Set() - var pendingDecompression = [TerrainRegion]() - - for region in TerrainRegion.allCases { - if corruptedRegions.contains(region) { continue } - - if let url = decompressedFileURL(for: region), - FileManager.default.fileExists(atPath: url.path) - { - available.insert(region) - } else if let compressed = compressedFileURL(for: region), - FileManager.default.fileExists(atPath: compressed.path) - { - logger.info( - "Found BA-downloaded compressed file for \(region.rawValue), queuing decompression" - ) - pendingDecompression.append(region) + let corrupted = corruptedRegions + Task.detached(priority: .userInitiated) { [self] in + var available = Set() + var pendingDecompression = [TerrainRegion]() + + for region in TerrainRegion.allCases where !corrupted.contains(region) { + if let url = decompressedFileURL(for: region), + FileManager.default.fileExists(atPath: url.path) + { + available.insert(region) + } else if let compressed = compressedFileURL(for: region), + FileManager.default.fileExists(atPath: compressed.path) + { + logger.info( + "Found BA-downloaded compressed file for \(region.rawValue), queuing decompression" + ) + pendingDecompression.append(region) + } } - } - availableRegions = available - logger.info("Available terrain regions: \(available.map(\.rawValue))") + await MainActor.run { + self.availableRegions = available + self.logger.info("Available terrain regions: \(available.map(\.rawValue))") - loadAvailableRegionsIntoService() + self.loadAvailableRegionsIntoService() - // Decompress any files left by the Background Assets extension - for region in pendingDecompression { - Task { - await decompressPendingDownload(for: region) + // Decompress any files left by the Background Assets extension + for region in pendingDecompression { + Task { + await self.decompressPendingDownload(for: region) + } + } } } } @@ -339,12 +343,12 @@ final class TerrainDataLoader: ObservableObject { // MARK: - Private Methods /// Returns the URL to the decompressed terrain file for a region. - private func decompressedFileURL(for region: TerrainRegion) -> URL? { + nonisolated private func decompressedFileURL(for region: TerrainRegion) -> URL? { terrainDirectory?.appendingPathComponent("\(region.rawValue).srtm") } /// Returns the URL to the compressed terrain file for a region. - private func compressedFileURL(for region: TerrainRegion) -> URL? { + nonisolated private func compressedFileURL(for region: TerrainRegion) -> URL? { terrainDirectory?.appendingPathComponent(region.filename) } diff --git a/SF50 TOLD/SF50_TOLDApp.swift b/SF50 TOLD/SF50_TOLDApp.swift index 4779dac..6de7e18 100644 --- a/SF50 TOLD/SF50_TOLDApp.swift +++ b/SF50 TOLD/SF50_TOLDApp.swift @@ -1,8 +1,6 @@ -import Defaults import SF50_Shared import Sentry import SwiftData -import SwiftNASR import SwiftUI import WidgetKit @@ -89,21 +87,10 @@ struct SF50_TOLDApp: App { } init() { - // Handle UI testing mode - let isGeneratingScreenshots = ProcessInfo.processInfo.arguments.contains("GENERATE-SCREENSHOTS") - let isUITesting = ProcessInfo.processInfo.arguments.contains("UI-TESTING") - - if isUITesting { + if ProcessInfo.processInfo.arguments.contains("UI-TESTING") { UITestingHelper.setupUITestingEnvironment(container: sharedModelContainer) } - // Purge stale navigation data before anything can access it - if !isGeneratingScreenshots && !isUITesting - && Defaults[.schemaVersion] != latestSchemaVersion - { - Self.purgeStaleNavigationData(from: sharedModelContainer) - } - SentrySDK.start { options in options.dsn = "https://18ccb9d2342467fafcaebcc33cc676e5@o4510156629475328.ingest.us.sentry.io/4510161674502144" @@ -141,25 +128,4 @@ struct SF50_TOLDApp: App { } } } - - private static func purgeStaleNavigationData(from container: ModelContainer) { - let context = container.mainContext - do { - try context.delete(model: Airport.self) - try context.delete(model: Runway.self) - try context.delete(model: Procedure.self) - try context.delete(model: ProcedureSegment.self) - try context.delete(model: Leg.self) - try context.delete(model: Navaid.self) - try context.delete(model: Obstacle.self) - try context.delete(model: NOTAM.self) - try context.delete(model: Cycle.self) - try context.save() - } catch { - SentrySDK.capture(error: error) { scope in - scope.setTag(value: "purge", key: "swiftData.operation") - scope.setFingerprint(["swiftData", "purge"]) - } - } - } } diff --git a/SF50 TOLDUITests/Extensions.swift b/SF50 TOLDUITests/Extensions.swift index 42ce7f7..ff1ab62 100644 --- a/SF50 TOLDUITests/Extensions.swift +++ b/SF50 TOLDUITests/Extensions.swift @@ -116,8 +116,15 @@ extension XCUIElement { if app.keyboards.firstMatch.waitForExistence(timeout: 2) { break } } - // Select all existing text and type the replacement. - tap(withNumberOfTaps: 3, numberOfTouches: 1) + // Select all existing text. On iPhone, triple-tap works natively. On + // iPad with iOS 26 Liquid Glass overlays, the element can report "not + // hittable" and the XCUIElement triple-tap fails, so fall back to + // hardware-keyboard-emulated Cmd+A which bypasses hit-testing. + if isHittable { + tap(withNumberOfTaps: 3, numberOfTouches: 1) + } else { + typeKey("a", modifierFlags: .command) + } Thread.sleep(forTimeInterval: 0.3) typeText(text) }