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
64 changes: 64 additions & 0 deletions assets/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,22 @@
}
}
},
"An app operation on %@ hasn’t finished. Keep waiting, or shut down this simulator to stop pending operations. Shutting down closes all of its apps.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "An app operation on %@ hasn’t finished. Keep waiting, or shut down this simulator to stop pending operations. Shutting down closes all of its apps."
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "%@ 上的应用操作尚未完成。你可以继续等待,或关闭此模拟器以停止待处理的操作。关机会关闭其中的所有应用。"
}
}
}
},
"App Repository": {
"localizations": {
"en": {
Expand Down Expand Up @@ -817,6 +833,22 @@
}
}
},
"Couldn’t start shutting down %@. Check its current state and try again.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Couldn’t start shutting down %@. Check its current state and try again."
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "无法开始关闭 %@。请检查其当前状态后重试。"
}
}
}
},
"Create an iPhone or iPad simulator in %@.\nThen come back to find your next favorite app.": {
"localizations": {
"en": {
Expand Down Expand Up @@ -1201,6 +1233,22 @@
}
}
},
"Keep Waiting": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Keep Waiting"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "继续等待"
}
}
}
},
"License": {
"localizations": {
"en": {
Expand Down Expand Up @@ -1617,6 +1665,22 @@
}
}
},
"Operation Is Taking Longer Than Expected": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Operation Is Taking Longer Than Expected"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "操作耗时较长"
}
}
}
},
"Preparing App…": {
"localizations": {
"en": {
Expand Down
17 changes: 16 additions & 1 deletion src/AppInstallationStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ final class AppInstallationStore {
private(set) var openedApp: Key?
private(set) var uninstalledApp: Key?
@ObservationIgnored private let installer: AppInstaller
@ObservationIgnored private let operationMonitor: SimulatorOperationMonitor?
@ObservationIgnored private var monitoredOperations: [Key: UUID] = [:]
@ObservationIgnored private let didInstall: @MainActor (SimulatorApplication, String) -> Void
@ObservationIgnored private let didReplace: @MainActor (SimulatorApplication, String) -> Void
@ObservationIgnored private let didUninstall: @MainActor (String, String) -> Void
Expand All @@ -30,14 +32,16 @@ final class AppInstallationStore {
didInstall: @escaping @MainActor (SimulatorApplication, String) -> Void = { _, _ in },
didReplace: @escaping @MainActor (SimulatorApplication, String) -> Void = { _, _ in },
didUninstall: @escaping @MainActor (String, String) -> Void = { _, _ in },
didOpen: @escaping @MainActor (String, String) -> Void = { _, _ in }
didOpen: @escaping @MainActor (String, String) -> Void = { _, _ in },
operationMonitor: SimulatorOperationMonitor? = nil
) {
self.installer = installer
self.didInstall = didInstall
self.didReplace = didReplace
self.didUninstall = didUninstall
self.showDevice = showDevice
self.didOpen = didOpen
self.operationMonitor = operationMonitor
}

func phase(appID: String, deviceID: String) -> AppInstallationPhase? {
Expand Down Expand Up @@ -87,6 +91,7 @@ final class AppInstallationStore {
return
} else {
phases[key] = phase
if phase == .installing { monitor(key, on: device) }
}
}
completion(
Expand All @@ -113,6 +118,7 @@ final class AppInstallationStore {
uninstalledApp = nil
errors[key] = nil
phases[key] = .uninstalling
monitor(key, on: device)
jobs[key] = Task {
defer { finish(key) }
do {
Expand Down Expand Up @@ -150,6 +156,7 @@ final class AppInstallationStore {
openedApp = nil
errors[key] = nil
phases[key] = .opening
monitor(key, on: device)
jobs[key] = Task {
defer { finish(key) }
do {
Expand All @@ -164,11 +171,19 @@ final class AppInstallationStore {
}

private func finish(_ key: Key) {
if let operation = monitoredOperations.removeValue(forKey: key) {
operationMonitor?.end(operation)
}
jobs[key] = nil
requests[key] = nil
phases[key] = nil
}

private func monitor(_ key: Key, on device: SimulatorDevice) {
guard monitoredOperations[key] == nil else { return }
monitoredOperations[key] = operationMonitor?.begin(on: device)
}

func updateBatch(deviceID: String) -> AppUpdateBatch? {
updateBatches[deviceID]
}
Expand Down
2 changes: 2 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ add_executable(SimStore MACOSX_BUNDLE
SimulatorDevicePresentation.swift
SimulatorSelectionViews.swift
SimulatorPowerButton.swift
SimulatorOperationMonitor.swift
SimulatorRecoveryAlert.swift
ContentView.swift
LibrarySection.swift
AppCatalog.swift
Expand Down
21 changes: 18 additions & 3 deletions src/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ struct ContentView: View {
@State private var catalog: CatalogStore
@State private var installedApps: InstalledAppsStore
@State private var installations: AppInstallationStore
@State private var operationMonitor: SimulatorOperationMonitor
@State private var launchError: String?
@State private var librarySection: LibrarySection = .discover

Expand Down Expand Up @@ -54,9 +55,12 @@ struct ContentView: View {
}
#endif
let simulators = SimulatorStore(discover: discover, shutdown: shutdown, boot: boot)
let operationMonitor = SimulatorOperationMonitor()
_operationMonitor = State(initialValue: operationMonitor)
_simulators = State(initialValue: simulators)
_catalog = State(initialValue: CatalogStore(client: catalogClient))
let installedApps = InstalledAppsStore(load: loadInstalledApps)
let installedApps = InstalledAppsStore(
load: loadInstalledApps, operationMonitor: operationMonitor)
_installedApps = State(initialValue: installedApps)
_installations = State(
initialValue: AppInstallationStore(
Expand All @@ -80,7 +84,7 @@ struct ContentView: View {
},
didOpen: { bundleID, deviceID in
installedApps.removeRecentInstallation(bundleID: bundleID, deviceID: deviceID)
}))
}, operationMonitor: operationMonitor))
}

private var needsXcode: Bool {
Expand Down Expand Up @@ -151,6 +155,9 @@ struct ContentView: View {
.frame(minWidth: 900, minHeight: 600)
.task { await simulators.refresh() }
.onChange(of: librarySection) { _, _ in installedApps.clearRecentInstallations() }
.onChange(of: discovery?.devices) { _, devices in
operationMonitor.synchronize(with: devices)
}
.task(id: shouldMonitorState) {
while shouldMonitorState && !Task.isCancelled {
do {
Expand Down Expand Up @@ -197,6 +204,7 @@ struct ContentView: View {
} message: {
Text(simulators.shutdownError ?? "")
}
.modifier(SimulatorRecoveryAlert(monitor: operationMonitor, shutdown: shutdownSimulator))
}

@ViewBuilder
Expand Down Expand Up @@ -249,8 +257,15 @@ struct ContentView: View {
}

private func shutdownSimulator() {
guard let id = simulators.selectedDeviceID, simulators.shutdownDevice(id) != nil else { return }
guard let id = simulators.selectedDeviceID else { return }
_ = shutdownSimulator(id)
}

private func shutdownSimulator(_ id: String) -> Bool {
guard simulators.shutdownDevice(id) != nil else { return false }
operationMonitor.stop(deviceID: id)
installations.cancelOperations(deviceID: id)
return true
}

private var shouldMonitorState: Bool {
Expand Down
7 changes: 6 additions & 1 deletion src/InstalledAppsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ final class InstalledAppsStore {
@ObservationIgnored
private let load: @Sendable (SimulatorDevice) async throws -> [SimulatorApplication]
@ObservationIgnored
private let operationMonitor: SimulatorOperationMonitor?
@ObservationIgnored
private var selectedDevice: SimulatorDevice?
@ObservationIgnored
private var request: Request?
Expand All @@ -26,9 +28,10 @@ final class InstalledAppsStore {
init(
load: @escaping @Sendable (SimulatorDevice) async throws -> [SimulatorApplication] = {
try await SimulatorApplications.installedApps(on: $0)
}
}, operationMonitor: SimulatorOperationMonitor? = nil
) {
self.load = load
self.operationMonitor = operationMonitor
}

func installedApps(in catalog: AppCatalog, for deviceID: String) -> [CatalogApp]? {
Expand Down Expand Up @@ -136,7 +139,9 @@ final class InstalledAppsStore {
applications = nil
error = nil
isLoading = true
let operation = operationMonitor?.begin(on: device)
defer {
if let operation { operationMonitor?.end(operation) }
if request === pending {
request = nil
isLoading = false
Expand Down
71 changes: 71 additions & 0 deletions src/SimulatorOperationMonitor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import Foundation
import Observation

@MainActor
@Observable
final class SimulatorOperationMonitor {
struct Prompt: Identifiable, Equatable {
let id: UUID
let device: SimulatorDevice
}

typealias Wait = @Sendable () async throws -> Void

private var stalled: [Prompt] = []
var prompt: Prompt? { stalled.first }

@ObservationIgnored private let wait: Wait
@ObservationIgnored private var active:
[UUID: (device: SimulatorDevice, task: Task<Void, Never>)] = [:]
@ObservationIgnored private var mutedDevices: Set<String> = []

init(wait: @escaping Wait = { try await Task.sleep(for: .seconds(30)) }) {
self.wait = wait
}

func begin(on device: SimulatorDevice) -> UUID {
let id = UUID()
guard device.state == "Booted" else { return id }
let wait = wait
let task = Task { [weak self] in
do {
try await wait()
try Task.checkCancellation()
} catch { return }
guard let self, active[id] != nil, !mutedDevices.contains(device.id) else { return }
stalled.append(Prompt(id: id, device: device))
}
active[id] = (device, task)
return id
}

func end(_ id: UUID) {
guard let operation = active.removeValue(forKey: id) else { return }
operation.task.cancel()
stalled.removeAll { $0.id == id }
if !active.values.contains(where: { $0.device.id == operation.device.id }) {
mutedDevices.remove(operation.device.id)
}
}

func keepWaiting(_ prompt: Prompt) {
guard stalled.contains(where: { $0.id == prompt.id }) else { return }
mutedDevices.insert(prompt.device.id)
stalled.removeAll { $0.device.id == prompt.device.id }
}

func stop(deviceID: String) {
for id in active.keys.filter({ active[$0]?.device.id == deviceID }) {
end(id)
}
}

func synchronize(with devices: [SimulatorDevice]?) {
// A failed scan does not confirm that any device has stopped or disappeared.
guard let devices else { return }
let booted = Set(devices.filter { $0.state == "Booted" }.map(\.id))
for deviceID in Set(active.values.map { $0.device.id }).subtracting(booted) {
stop(deviceID: deviceID)
}
}
}
Loading