mirror of
https://github.com/MobAI-App/simslim.git
synced 2026-09-27 17:02:00 +02:00
795 lines
25 KiB
Swift
795 lines
25 KiB
Swift
import Foundation
|
|
|
|
@MainActor
|
|
final class AppModel: ObservableObject {
|
|
@Published private(set) var devices: [SimulatorDevice] = []
|
|
@Published private(set) var categories: [SlimCategory] = []
|
|
@Published private(set) var diskCleanupCategories: [DiskCleanupCategory] = []
|
|
@Published private(set) var diskCleanupPlans: [String: SimulatorDiskCleanupPlan] = [:]
|
|
@Published private(set) var measurements: [String: SimulatorMeasurement] = [:]
|
|
@Published private(set) var diskSizes: [String: SimulatorDiskMeasurement] = [:]
|
|
@Published private(set) var diskSizeLoadingUDIDs: Set<String> = []
|
|
@Published private(set) var activeOperations: [String: String] = [:]
|
|
@Published private(set) var activity: [ActivityEntry] = []
|
|
@Published private(set) var isRefreshing = false
|
|
@Published private(set) var batchProgress: BatchProgress?
|
|
@Published private(set) var lastUpdated: Date?
|
|
@Published var selectedUDIDs: Set<String> = []
|
|
@Published var keptCategoryIDs: Set<String> = []
|
|
@Published var keptServiceLabels: Set<String> = []
|
|
@Published var selectedDiskCleanupCategoryIDs: Set<String> = []
|
|
@Published var preserveBootState = true
|
|
@Published var presentedError: PresentedError?
|
|
|
|
private let backend: SimSlimBackend?
|
|
private var hasLoaded = false
|
|
private var lastKnownDisabled: [String: Int]
|
|
private var diskSizeTask: Task<Void, Never>?
|
|
private var diskReloadRequested = false
|
|
private static let stateCacheKey = "lastKnownManagedDisabled"
|
|
|
|
init() {
|
|
lastKnownDisabled =
|
|
UserDefaults.standard
|
|
.dictionary(forKey: Self.stateCacheKey)?
|
|
.compactMapValues { ($0 as? NSNumber)?.intValue } ?? [:]
|
|
|
|
do {
|
|
backend = try SimSlimBackend()
|
|
} catch {
|
|
backend = nil
|
|
presentedError = PresentedError(message: error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
var isBusy: Bool {
|
|
isRefreshing || batchProgress != nil || !activeOperations.isEmpty
|
|
}
|
|
|
|
var disabledDaemonCount: Int {
|
|
categories.reduce(0) { count, category in
|
|
guard !categoryIsKept(category) else { return count }
|
|
return count + category.labels.filter { !keptServiceLabels.contains($0) }.count
|
|
}
|
|
}
|
|
|
|
var bootedCount: Int { devices.filter(\.isBooted).count }
|
|
var selectionCount: Int { selectedUDIDs.count }
|
|
|
|
var selectedDevices: [SimulatorDevice] {
|
|
devices.filter { selectedUDIDs.contains($0.udid) }
|
|
}
|
|
|
|
var diskAnalysisCoversSelection: Bool {
|
|
diskAnalysisCovers(selectedDevices)
|
|
}
|
|
|
|
var isAnalyzingDisk: Bool {
|
|
batchProgress?.action == "Analyzing"
|
|
}
|
|
|
|
var selectedDiskCleanupBytes: Int64 {
|
|
diskCleanupBytes(for: selectedDevices, categoryIDs: selectedDiskCleanupCategoryIDs)
|
|
}
|
|
|
|
var diskStorageRows: [SimulatorDiskStorageMeasurement] {
|
|
guard let device = selectedDevices.first else { return [] }
|
|
return diskCleanupPlans[device.udid]?.storage ?? []
|
|
}
|
|
|
|
var canCleanDiskSelection: Bool {
|
|
canCleanDisk(selectedDevices)
|
|
}
|
|
|
|
func diskAnalysisCovers(_ devices: [SimulatorDevice]) -> Bool {
|
|
!devices.isEmpty && devices.allSatisfy { diskCleanupPlans[$0.udid] != nil }
|
|
}
|
|
|
|
func canCleanDisk(_ devices: [SimulatorDevice]) -> Bool {
|
|
let cleanableIDs = Set(diskCleanupCategories.filter(\.canClean).map(\.id))
|
|
let selectedIDs = selectedDiskCleanupCategoryIDs.intersection(cleanableIDs)
|
|
return diskAnalysisCovers(devices)
|
|
&& !selectedIDs.isEmpty
|
|
&& diskCleanupBytes(for: devices, categoryIDs: selectedIDs) > 0
|
|
}
|
|
|
|
func load() async {
|
|
guard !hasLoaded else { return }
|
|
hasLoaded = true
|
|
await refresh(includeCategories: true)
|
|
}
|
|
|
|
func refresh(includeCategories: Bool = false) async {
|
|
guard let backend, !isRefreshing else { return }
|
|
isRefreshing = true
|
|
defer { isRefreshing = false }
|
|
|
|
do {
|
|
if includeCategories || categories.isEmpty || diskCleanupCategories.isEmpty {
|
|
async let newDevices = backend.devices()
|
|
async let newCategories = backend.categories()
|
|
async let newDiskCleanupCategories = backend.diskCleanupCategories()
|
|
let loaded = try await (newDevices, newCategories, newDiskCleanupCategories)
|
|
devices = loaded.0
|
|
categories = loaded.1
|
|
let shouldSelectDefaults = diskCleanupCategories.isEmpty
|
|
diskCleanupCategories = loaded.2
|
|
if shouldSelectDefaults {
|
|
selectedDiskCleanupCategoryIDs = Set(
|
|
diskCleanupCategories
|
|
.filter { $0.defaultSelected && $0.canClean }
|
|
.map(\.id)
|
|
)
|
|
}
|
|
} else {
|
|
devices = try await backend.devices()
|
|
}
|
|
|
|
let available = Set(devices.map(\.udid))
|
|
selectedUDIDs.formIntersection(available)
|
|
diskCleanupPlans = diskCleanupPlans.filter { available.contains($0.key) }
|
|
updateMeasurementsFromDevices()
|
|
updateStateCacheFromLiveDevices()
|
|
lastUpdated = Date()
|
|
scheduleDiskSizeRefresh(for: devices)
|
|
} catch {
|
|
recordFailure("Refresh failed: \(error.localizedDescription)", present: true)
|
|
}
|
|
}
|
|
|
|
func slimState(for device: SimulatorDevice) -> ServiceSlimState {
|
|
let cachedOrLiveDisabled = device.managedDisabled ?? lastKnownDisabled[device.udid]
|
|
guard let cachedOrLiveDisabled else { return .unknown }
|
|
// Profile updates can remove a daemon from the slimmable set. Clamp an
|
|
// older shutdown-state cache to the current total until the device boots
|
|
// and supplies a fresh live count.
|
|
let disabled = min(max(cachedOrLiveDisabled, 0), device.managedTotal)
|
|
switch disabled {
|
|
case 0:
|
|
return .stock
|
|
case device.managedTotal:
|
|
return .full(total: device.managedTotal)
|
|
default:
|
|
return .partial(disabled: disabled, total: device.managedTotal)
|
|
}
|
|
}
|
|
|
|
func stateIsCached(for device: SimulatorDevice) -> Bool {
|
|
device.managedDisabled == nil && lastKnownDisabled[device.udid] != nil
|
|
}
|
|
|
|
func diskSizeText(for device: SimulatorDevice, loadingText: String = "…") -> String {
|
|
if let size = diskSizes[device.udid] {
|
|
return size.sizeText
|
|
}
|
|
return diskSizeLoadingUDIDs.contains(device.udid) ? loadingText : "—"
|
|
}
|
|
|
|
func toggleSelection(_ udid: String) {
|
|
if selectedUDIDs.contains(udid) {
|
|
selectedUDIDs.remove(udid)
|
|
} else {
|
|
selectedUDIDs.insert(udid)
|
|
}
|
|
}
|
|
|
|
func select(_ udids: Set<String>) {
|
|
selectedUDIDs = udids
|
|
}
|
|
|
|
func clearSelection() {
|
|
selectedUDIDs.removeAll()
|
|
}
|
|
|
|
func setCategory(_ category: SlimCategory, keptEnabled: Bool) {
|
|
if keptEnabled {
|
|
keptCategoryIDs.insert(category.id)
|
|
keptServiceLabels.subtract(category.labels)
|
|
} else {
|
|
keptCategoryIDs.remove(category.id)
|
|
keptServiceLabels.subtract(category.labels)
|
|
}
|
|
}
|
|
|
|
func categoryIsKept(_ category: SlimCategory) -> Bool {
|
|
keptCategoryIDs.contains(category.id)
|
|
|| category.labels.allSatisfy { keptServiceLabels.contains($0) }
|
|
}
|
|
|
|
func serviceIsKept(_ label: String, in category: SlimCategory) -> Bool {
|
|
categoryIsKept(category) || keptServiceLabels.contains(label)
|
|
}
|
|
|
|
func setService(_ label: String, in category: SlimCategory, keptEnabled: Bool) {
|
|
guard category.labels.contains(label) else { return }
|
|
|
|
if keptEnabled {
|
|
keptServiceLabels.insert(label)
|
|
if category.labels.allSatisfy({ keptServiceLabels.contains($0) }) {
|
|
keptCategoryIDs.insert(category.id)
|
|
keptServiceLabels.subtract(category.labels)
|
|
}
|
|
return
|
|
}
|
|
|
|
if keptCategoryIDs.remove(category.id) != nil {
|
|
keptServiceLabels.formUnion(category.labels)
|
|
}
|
|
keptServiceLabels.remove(label)
|
|
}
|
|
|
|
func resetProfile() {
|
|
keptCategoryIDs.removeAll()
|
|
keptServiceLabels.removeAll()
|
|
}
|
|
|
|
func setDiskCleanupCategory(_ category: DiskCleanupCategory, selected: Bool) {
|
|
guard category.canClean else { return }
|
|
if selected {
|
|
selectedDiskCleanupCategoryIDs.insert(category.id)
|
|
} else {
|
|
selectedDiskCleanupCategoryIDs.remove(category.id)
|
|
}
|
|
}
|
|
|
|
func diskCleanupBytes(for categoryID: String) -> Int64? {
|
|
guard diskAnalysisCoversSelection else { return nil }
|
|
return selectedDevices.reduce(0) {
|
|
$0 + (diskCleanupPlans[$1.udid]?.bytes(for: categoryID) ?? 0)
|
|
}
|
|
}
|
|
|
|
func diskCleanupSizeText(for categoryID: String) -> String {
|
|
guard !selectedDevices.isEmpty else { return "—" }
|
|
guard !isAnalyzingDisk else { return "Analyzing…" }
|
|
guard let bytes = diskCleanupBytes(for: categoryID) else {
|
|
return "Pending"
|
|
}
|
|
return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
|
|
}
|
|
|
|
func diskStorageSizeText(for storageID: String) -> String {
|
|
guard diskAnalysisCoversSelection else { return isAnalyzingDisk ? "Analyzing…" : "Pending" }
|
|
let bytes = selectedDevices.reduce(0) {
|
|
$0 + (diskCleanupPlans[$1.udid]?.storageBytes(for: storageID) ?? 0)
|
|
}
|
|
return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
|
|
}
|
|
|
|
func selectedDiskCleanupSizeText() -> String {
|
|
guard !selectedDevices.isEmpty else { return "No selection" }
|
|
guard !isAnalyzingDisk else { return "Analyzing…" }
|
|
guard diskAnalysisCoversSelection else { return "Pending" }
|
|
return ByteCountFormatter.string(fromByteCount: selectedDiskCleanupBytes, countStyle: .file)
|
|
}
|
|
|
|
func analyzeDiskSelectionIfNeeded() async {
|
|
let missing = selectedDevices.filter { diskCleanupPlans[$0.udid] == nil }
|
|
guard !missing.isEmpty else { return }
|
|
await analyzeDisk(devices: missing)
|
|
}
|
|
|
|
func analyzeDiskSelection() async {
|
|
await analyzeDisk(devices: selectedDevices)
|
|
}
|
|
|
|
func cleanDisk(_ devices: [SimulatorDevice], categoryIDs: Set<String>) async {
|
|
guard let backend, batchProgress == nil, activeOperations.isEmpty, !devices.isEmpty else {
|
|
return
|
|
}
|
|
let cleanableIDs = Set(diskCleanupCategories.filter(\.canClean).map(\.id))
|
|
let selectedIDs = categoryIDs.intersection(cleanableIDs)
|
|
guard !selectedIDs.isEmpty else { return }
|
|
var failures = 0
|
|
|
|
for (index, device) in devices.enumerated() {
|
|
batchProgress = BatchProgress(
|
|
completed: index,
|
|
total: devices.count,
|
|
currentName: device.name,
|
|
action: "Cleaning"
|
|
)
|
|
setOperation("Cleaning disk data…", for: device.udid)
|
|
record(.info, "Cleaning disk data from \(device.name)")
|
|
|
|
do {
|
|
let result = try await backend.cleanDisk(
|
|
udid: device.udid,
|
|
categoryIDs: selectedIDs,
|
|
preserveBootState: preserveBootState
|
|
)
|
|
diskCleanupPlans.removeValue(forKey: device.udid)
|
|
record(.success, "Cleaned \(device.name): reclaimed \(result.reclaimedText)")
|
|
} catch {
|
|
failures += 1
|
|
recordFailure(
|
|
"Could not clean \(device.name): \(error.localizedDescription)", present: false)
|
|
}
|
|
|
|
clearOperation(for: device.udid)
|
|
batchProgress = BatchProgress(
|
|
completed: index + 1,
|
|
total: devices.count,
|
|
currentName: device.name,
|
|
action: "Cleaning"
|
|
)
|
|
}
|
|
|
|
batchProgress = nil
|
|
await refresh()
|
|
finishBatch(action: "Disk cleanup", total: devices.count, failures: failures)
|
|
}
|
|
|
|
func measure(_ device: SimulatorDevice) async {
|
|
guard let backend, device.isBooted, activeOperations[device.udid] == nil else { return }
|
|
setOperation("Measuring memory…", for: device.udid)
|
|
defer { clearOperation(for: device.udid) }
|
|
|
|
do {
|
|
let measurement = try await backend.measure(udid: device.udid)
|
|
measurements[device.udid] = measurement
|
|
record(
|
|
.success,
|
|
"Measured \(device.name): \(measurement.processes) processes, \(measurement.memoryText)")
|
|
} catch {
|
|
recordFailure(
|
|
"Could not measure \(device.name): \(error.localizedDescription)", present: true)
|
|
}
|
|
}
|
|
|
|
func applyProfileToSelection() async {
|
|
await applyProfile(to: selectedDevices)
|
|
}
|
|
|
|
func applyProfile(to devices: [SimulatorDevice]) async {
|
|
await runBatch(action: "Applying", devices: devices, restoreToStock: false)
|
|
}
|
|
|
|
func applyProfile(to device: SimulatorDevice) async {
|
|
await applyProfile(to: [device])
|
|
}
|
|
|
|
func restoreSelection() async {
|
|
await runBatch(action: "Restoring", devices: selectedDevices, restoreToStock: true)
|
|
}
|
|
|
|
func restoreOriginalServices(for device: SimulatorDevice) async {
|
|
await runBatch(action: "Restoring", devices: [device], restoreToStock: true)
|
|
}
|
|
|
|
func cloneSimulator(_ device: SimulatorDevice, named rawName: String) async {
|
|
guard let backend, batchProgress == nil, activeOperations.isEmpty else { return }
|
|
let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
setOperation("Cloning simulator…", for: device.udid)
|
|
record(.info, "Cloning \(device.name) as \(name)")
|
|
defer { clearOperation(for: device.udid) }
|
|
|
|
do {
|
|
let result = try await backend.clone(udid: device.udid, name: name)
|
|
if let disabled = device.managedDisabled ?? lastKnownDisabled[device.udid] {
|
|
setCachedDisabled(disabled, for: result.udid)
|
|
}
|
|
measurements.removeValue(forKey: result.udid)
|
|
record(.success, "Cloned \(device.name) as \(result.name ?? name)")
|
|
selectedUDIDs = [result.udid]
|
|
await refresh()
|
|
} catch {
|
|
await refresh()
|
|
recordFailure("Could not clone \(device.name): \(error.localizedDescription)", present: true)
|
|
}
|
|
}
|
|
|
|
func renameSimulator(_ device: SimulatorDevice, to rawName: String) async {
|
|
guard let backend, batchProgress == nil, activeOperations.isEmpty else { return }
|
|
let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
setOperation("Renaming simulator…", for: device.udid)
|
|
record(.info, "Renaming \(device.name) to \(name)")
|
|
defer { clearOperation(for: device.udid) }
|
|
|
|
do {
|
|
_ = try await backend.rename(udid: device.udid, name: name)
|
|
record(.success, "Renamed \(device.name) to \(name)")
|
|
await refresh()
|
|
selectedUDIDs = [device.udid]
|
|
} catch {
|
|
recordFailure("Could not rename \(device.name): \(error.localizedDescription)", present: true)
|
|
}
|
|
}
|
|
|
|
func eraseSimulators(_ devices: [SimulatorDevice]) async {
|
|
await runManagementBatch(.erase, devices: devices)
|
|
}
|
|
|
|
func deleteSimulators(_ devices: [SimulatorDevice]) async {
|
|
await runManagementBatch(.delete, devices: devices)
|
|
}
|
|
|
|
func bootSimulator(_ device: SimulatorDevice) async {
|
|
guard let backend, batchProgress == nil, activeOperations.isEmpty else { return }
|
|
setOperation("Booting simulator…", for: device.udid)
|
|
record(.info, "Booting \(device.name)")
|
|
defer { clearOperation(for: device.udid) }
|
|
|
|
do {
|
|
_ = try await backend.boot(udid: device.udid)
|
|
record(.success, "Booted \(device.name)")
|
|
await refresh()
|
|
} catch {
|
|
recordFailure("Could not boot \(device.name): \(error.localizedDescription)", present: true)
|
|
}
|
|
}
|
|
|
|
func shutdownSimulator(_ device: SimulatorDevice) async {
|
|
guard let backend, batchProgress == nil, activeOperations.isEmpty else { return }
|
|
setOperation("Shutting down simulator…", for: device.udid)
|
|
record(.info, "Shutting down \(device.name)")
|
|
defer { clearOperation(for: device.udid) }
|
|
|
|
do {
|
|
_ = try await backend.shutdown(udid: device.udid)
|
|
record(.success, "Shut down \(device.name)")
|
|
await refresh()
|
|
} catch {
|
|
recordFailure(
|
|
"Could not shut down \(device.name): \(error.localizedDescription)", present: true)
|
|
}
|
|
}
|
|
|
|
func clearActivity() {
|
|
activity.removeAll()
|
|
}
|
|
|
|
private func runBatch(action: String, devices snapshot: [SimulatorDevice], restoreToStock: Bool)
|
|
async
|
|
{
|
|
guard batchProgress == nil, activeOperations.isEmpty, !snapshot.isEmpty else { return }
|
|
var failures = 0
|
|
|
|
for (index, device) in snapshot.enumerated() {
|
|
batchProgress = BatchProgress(
|
|
completed: index,
|
|
total: snapshot.count,
|
|
currentName: device.name,
|
|
action: action
|
|
)
|
|
|
|
let succeeded: Bool
|
|
if restoreToStock {
|
|
succeeded = await restore(device, presentErrors: false)
|
|
} else {
|
|
succeeded = await slim(device, presentErrors: false)
|
|
}
|
|
if !succeeded { failures += 1 }
|
|
|
|
batchProgress = BatchProgress(
|
|
completed: index + 1,
|
|
total: snapshot.count,
|
|
currentName: device.name,
|
|
action: action
|
|
)
|
|
}
|
|
|
|
batchProgress = nil
|
|
await refresh()
|
|
finishBatch(
|
|
action: restoreToStock ? "Restore" : "Profile update", total: snapshot.count,
|
|
failures: failures)
|
|
}
|
|
|
|
private func diskCleanupBytes(
|
|
for devices: [SimulatorDevice],
|
|
categoryIDs: Set<String>
|
|
) -> Int64 {
|
|
devices.reduce(0) { total, device in
|
|
guard let plan = diskCleanupPlans[device.udid] else { return total }
|
|
return total
|
|
+ categoryIDs.reduce(0) {
|
|
$0 + plan.bytes(for: $1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func analyzeDisk(devices snapshot: [SimulatorDevice]) async {
|
|
guard let backend, batchProgress == nil, activeOperations.isEmpty, !snapshot.isEmpty else {
|
|
return
|
|
}
|
|
var failures = 0
|
|
|
|
for (index, device) in snapshot.enumerated() {
|
|
batchProgress = BatchProgress(
|
|
completed: index,
|
|
total: snapshot.count,
|
|
currentName: device.name,
|
|
action: "Analyzing"
|
|
)
|
|
|
|
do {
|
|
diskCleanupPlans[device.udid] = try await backend.diskCleanupPlan(udid: device.udid)
|
|
} catch {
|
|
failures += 1
|
|
recordFailure(
|
|
"Could not analyze \(device.name): \(error.localizedDescription)", present: false)
|
|
}
|
|
|
|
batchProgress = BatchProgress(
|
|
completed: index + 1,
|
|
total: snapshot.count,
|
|
currentName: device.name,
|
|
action: "Analyzing"
|
|
)
|
|
}
|
|
|
|
batchProgress = nil
|
|
if failures == 0 {
|
|
record(.success, "Disk analysis finished for all \(snapshot.count) selected simulators")
|
|
} else {
|
|
finishBatch(action: "Disk analysis", total: snapshot.count, failures: failures)
|
|
}
|
|
}
|
|
|
|
private func runManagementBatch(
|
|
_ action: SimulatorManagementBatch, devices snapshot: [SimulatorDevice]
|
|
) async {
|
|
guard let backend, batchProgress == nil, activeOperations.isEmpty, !snapshot.isEmpty else {
|
|
return
|
|
}
|
|
var failures = 0
|
|
|
|
for (index, device) in snapshot.enumerated() {
|
|
batchProgress = BatchProgress(
|
|
completed: index,
|
|
total: snapshot.count,
|
|
currentName: device.name,
|
|
action: action.progressTitle
|
|
)
|
|
setOperation(action.operationTitle, for: device.udid)
|
|
record(.info, "\(action.progressTitle) \(device.name)")
|
|
|
|
do {
|
|
switch action {
|
|
case .erase:
|
|
_ = try await backend.erase(udid: device.udid)
|
|
measurements.removeValue(forKey: device.udid)
|
|
setCachedDisabled(0, for: device.udid)
|
|
case .delete:
|
|
_ = try await backend.delete(udid: device.udid)
|
|
measurements.removeValue(forKey: device.udid)
|
|
diskSizes.removeValue(forKey: device.udid)
|
|
diskSizeLoadingUDIDs.remove(device.udid)
|
|
removeCachedDisabled(for: device.udid)
|
|
selectedUDIDs.remove(device.udid)
|
|
}
|
|
record(.success, "\(action.pastTenseTitle) \(device.name)")
|
|
} catch {
|
|
failures += 1
|
|
recordFailure(
|
|
"Could not \(action.verb) \(device.name): \(error.localizedDescription)", present: false)
|
|
}
|
|
|
|
clearOperation(for: device.udid)
|
|
batchProgress = BatchProgress(
|
|
completed: index + 1,
|
|
total: snapshot.count,
|
|
currentName: device.name,
|
|
action: action.progressTitle
|
|
)
|
|
}
|
|
|
|
batchProgress = nil
|
|
await refresh()
|
|
finishBatch(action: action.completionTitle, total: snapshot.count, failures: failures)
|
|
}
|
|
|
|
private func slim(_ device: SimulatorDevice, presentErrors: Bool) async -> Bool {
|
|
guard let backend else { return false }
|
|
setOperation("Applying service profile…", for: device.udid)
|
|
record(.info, "Applying service profile to \(device.name)")
|
|
defer { clearOperation(for: device.udid) }
|
|
|
|
do {
|
|
let output = try await backend.slim(
|
|
udid: device.udid,
|
|
exceptCategories: keptCategoryIDs,
|
|
keepLabels: keptServiceLabels,
|
|
preserveBootState: preserveBootState
|
|
)
|
|
setCachedDisabled(disabledDaemonCount, for: device.udid)
|
|
record(.success, "Updated \(device.name): \(summaryLine(output))")
|
|
return true
|
|
} catch {
|
|
recordFailure(
|
|
"Could not slim \(device.name): \(error.localizedDescription)", present: presentErrors)
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func restore(_ device: SimulatorDevice, presentErrors: Bool) async -> Bool {
|
|
guard let backend else { return false }
|
|
setOperation("Restoring stock services…", for: device.udid)
|
|
record(.info, "Restoring \(device.name) to stock")
|
|
defer { clearOperation(for: device.udid) }
|
|
|
|
do {
|
|
let output = try await backend.restore(
|
|
udid: device.udid, preserveBootState: preserveBootState)
|
|
setCachedDisabled(0, for: device.udid)
|
|
record(.success, "Restored \(device.name): \(summaryLine(output))")
|
|
return true
|
|
} catch {
|
|
recordFailure(
|
|
"Could not restore \(device.name): \(error.localizedDescription)", present: presentErrors)
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func finishBatch(action: String, total: Int, failures: Int) {
|
|
if failures == 0 {
|
|
record(.success, "\(action) finished for all \(total) selected simulators")
|
|
} else {
|
|
let message =
|
|
"\(action) finished with \(failures) failure\(failures == 1 ? "" : "s") out of \(total). Check Activity for details."
|
|
recordFailure(message, present: true)
|
|
}
|
|
}
|
|
|
|
private func updateStateCacheFromLiveDevices() {
|
|
var changed = false
|
|
for device in devices {
|
|
guard let managedDisabled = device.managedDisabled else { continue }
|
|
if lastKnownDisabled[device.udid] != managedDisabled {
|
|
lastKnownDisabled[device.udid] = managedDisabled
|
|
changed = true
|
|
}
|
|
}
|
|
if changed { persistStateCache() }
|
|
}
|
|
|
|
private func updateMeasurementsFromDevices() {
|
|
measurements = Dictionary(
|
|
uniqueKeysWithValues: devices.compactMap { device in
|
|
guard device.isBooted, let memory = device.memory else { return nil }
|
|
return (device.udid, memory)
|
|
})
|
|
}
|
|
|
|
private func scheduleDiskSizeRefresh(for snapshot: [SimulatorDevice]) {
|
|
guard let backend else { return }
|
|
guard diskSizeTask == nil else {
|
|
diskReloadRequested = true
|
|
return
|
|
}
|
|
|
|
let available = Set(snapshot.map(\.udid))
|
|
diskSizes = diskSizes.filter { available.contains($0.key) }
|
|
diskSizeLoadingUDIDs = available
|
|
diskSizeTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
await self.loadDiskSizes(for: snapshot, backend: backend)
|
|
self.diskSizeTask = nil
|
|
if self.diskReloadRequested {
|
|
self.diskReloadRequested = false
|
|
self.scheduleDiskSizeRefresh(for: self.devices)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadDiskSizes(for snapshot: [SimulatorDevice], backend: SimSlimBackend) async {
|
|
await withTaskGroup(of: (String, SimulatorDiskMeasurement?).self) { group in
|
|
var nextIndex = 0
|
|
let concurrentMeasurements = min(3, snapshot.count)
|
|
|
|
for _ in 0..<concurrentMeasurements {
|
|
let device = snapshot[nextIndex]
|
|
nextIndex += 1
|
|
group.addTask {
|
|
let measurement = try? await backend.diskSize(udid: device.udid)
|
|
return (device.udid, measurement)
|
|
}
|
|
}
|
|
|
|
while let (udid, measurement) = await group.next() {
|
|
if devices.contains(where: { $0.udid == udid }), let measurement {
|
|
diskSizes[udid] = measurement
|
|
}
|
|
diskSizeLoadingUDIDs.remove(udid)
|
|
|
|
if nextIndex < snapshot.count {
|
|
let device = snapshot[nextIndex]
|
|
nextIndex += 1
|
|
group.addTask {
|
|
let measurement = try? await backend.diskSize(udid: device.udid)
|
|
return (device.udid, measurement)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let available = Set(devices.map(\.udid))
|
|
diskSizes = diskSizes.filter { available.contains($0.key) }
|
|
diskSizeLoadingUDIDs.formIntersection(available)
|
|
}
|
|
|
|
private func setCachedDisabled(_ count: Int, for udid: String) {
|
|
lastKnownDisabled[udid] = count
|
|
persistStateCache()
|
|
}
|
|
|
|
private func removeCachedDisabled(for udid: String) {
|
|
lastKnownDisabled.removeValue(forKey: udid)
|
|
persistStateCache()
|
|
}
|
|
|
|
private func persistStateCache() {
|
|
UserDefaults.standard.set(lastKnownDisabled, forKey: Self.stateCacheKey)
|
|
}
|
|
|
|
private func setOperation(_ message: String, for udid: String) {
|
|
activeOperations[udid] = message
|
|
}
|
|
|
|
private func clearOperation(for udid: String) {
|
|
activeOperations.removeValue(forKey: udid)
|
|
}
|
|
|
|
private func record(_ level: ActivityEntry.Level, _ message: String) {
|
|
activity.insert(ActivityEntry(level: level, message: message), at: 0)
|
|
if activity.count > 100 {
|
|
activity.removeLast(activity.count - 100)
|
|
}
|
|
}
|
|
|
|
private func recordFailure(_ message: String, present: Bool) {
|
|
record(.failure, message)
|
|
if present {
|
|
presentedError = PresentedError(message: message)
|
|
}
|
|
}
|
|
|
|
private func summaryLine(_ output: String) -> String {
|
|
output
|
|
.split(separator: "\n")
|
|
.last
|
|
.map(String.init) ?? "Done"
|
|
}
|
|
}
|
|
|
|
private enum SimulatorManagementBatch {
|
|
case erase
|
|
case delete
|
|
|
|
var verb: String {
|
|
switch self {
|
|
case .erase: return "erase"
|
|
case .delete: return "delete"
|
|
}
|
|
}
|
|
|
|
var progressTitle: String {
|
|
switch self {
|
|
case .erase: return "Erasing"
|
|
case .delete: return "Deleting"
|
|
}
|
|
}
|
|
|
|
var operationTitle: String {
|
|
switch self {
|
|
case .erase: return "Erasing simulator…"
|
|
case .delete: return "Deleting simulator…"
|
|
}
|
|
}
|
|
|
|
var pastTenseTitle: String {
|
|
switch self {
|
|
case .erase: return "Erased"
|
|
case .delete: return "Deleted"
|
|
}
|
|
}
|
|
|
|
var completionTitle: String {
|
|
switch self {
|
|
case .erase: return "Erase"
|
|
case .delete: return "Deletion"
|
|
}
|
|
}
|
|
}
|