diff --git a/README.md b/README.md index 1f2bcbc..fadc98d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ If your development setup lives on an external drive, this probably sounds famil - **Shows the drive's status in your menu bar:** connected, needs attention, ejecting, or safe to unplug. - **Finds what's using the drive.** It lists the programs holding files on it, split into ones it can close for you and ones you'll need to close yourself. -- **Ejects safely, in the right order.** After you confirm, it asks apps like Xcode and Android Studio to quit normally (they still ask you to save your work), stops the Gradle and Kotlin daemons and adb, ejects the drive, and checks that the drive is really gone before telling you it's safe to unplug. +- **Ejects safely, in the right order.** After you confirm, it normally shuts down Apple simulators stored on the target drive, asks apps like Xcode and Android Studio to quit normally (they still ask you to save your work), stops the Gradle and Kotlin daemons and adb, ejects the drive, and checks that the drive is really gone before telling you it's safe to unplug. - **Gives the drive a checkup:** free space and what's taking it up, drive health (life left, temperature, unexpected power losses), encryption, and whether Time Machine, Spotlight, or disk sleep settings could cause trouble. - **Warns you about a common trap.** While the drive is unplugged, it reminds you not to open Android Studio — it may think your SDK is missing and download the whole thing again onto your Mac's internal disk. diff --git a/README.zh-CN.md b/README.zh-CN.md index 465de51..763917a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -36,7 +36,7 @@ - **菜单栏上直接看状态**:已连接、有问题要注意、正在推出,还是可以拔了。 - **找出是谁在用这块盘**:列出正在占用盘上文件的程序,分成「确认后能帮你关」和「需要你自己关」两类。 -- **按正确的顺序安全推出**:你确认之后,它会请 Xcode、Android Studio 这类应用正常退出(没保存的内容照样会提醒你保存),停掉 Gradle、Kotlin 后台进程和 adb,然后推出硬盘,确认硬盘真的断开了,才告诉你可以拔线。 +- **按正确的顺序安全推出**:你确认之后,它会先正常关闭存储在目标盘上的 Apple 模拟器,再请 Xcode、Android Studio 这类应用正常退出(没保存的内容照样会提醒你保存),停掉 Gradle、Kotlin 后台进程和 adb,然后推出硬盘,确认硬盘真的断开了,才告诉你可以拔线。 - **给盘做个体检**:剩余空间和空间都被什么占了、硬盘健康(剩余寿命、温度、异常断电次数)、有没有加密,以及 Time Machine、Spotlight、磁盘休眠这些设置会不会添乱。 - **提醒一个常见的坑**:盘没插的时候,提醒你别打开 Android Studio——它可能以为 SDK 丢了,把整套 SDK 重新下载到电脑自带的硬盘上。 diff --git a/Sources/DevDiskKit/Core/EjectFailure.swift b/Sources/DevDiskKit/Core/EjectFailure.swift index 3e85992..f12b463 100644 --- a/Sources/DevDiskKit/Core/EjectFailure.swift +++ b/Sources/DevDiskKit/Core/EjectFailure.swift @@ -23,6 +23,7 @@ struct EjectFailure: Equatable { private var stageLabel: String { let keys = ["validate": "ejectflow.verify.target.and.scope", "apps": "ejectflow.request.apps.to.quit", + "simulators": "ejectflow.shutdown.related.simulators", "daemons": "ejectflow.stop.approved.background.services", "images": "ejectflow.eject.read.only.disk.images", "recheck": "ejectflow.recheck.open.files", "unmount": "ejectflow.ask.macos.to.eject", "verify": "ejectflow.verify.eject.result", "force-unmount": "ejectforce.unmount.disk", diff --git a/Sources/DevDiskKit/Core/EjectFlow.swift b/Sources/DevDiskKit/Core/EjectFlow.swift index 6d1b7d2..6d48867 100644 --- a/Sources/DevDiskKit/Core/EjectFlow.swift +++ b/Sources/DevDiskKit/Core/EjectFlow.swift @@ -9,6 +9,8 @@ struct EjectPlan: Equatable { let images: [DiskImage] let issues: [Message] var imagesKnown = true + var simulators: [SimulatorDevice] = [] + var simulatorsKnown = true var forceConfirmation = false var forceUsed = false var completedApps: Int = 0 @@ -23,7 +25,7 @@ struct EjectPlan: Equatable { var apps: [Holder] { holders.filter { $0.kind == .guiApp } } var daemons: [Holder] { holders.filter { $0.kind == .daemon } } var writableImages: [DiskImage] { images.filter { $0.writable || !$0.accessKnown } } - var incomplete: Bool { !issues.isEmpty } + var incomplete: Bool { !issues.isEmpty || !imagesKnown || !simulatorsKnown } var canPrepare: Bool { canPrepare(approvedTasks: selectedTasks) } func canPrepare(approvedTasks: Set) -> Bool { !incomplete && !target.multipleVolumes @@ -31,11 +33,11 @@ struct EjectPlan: Equatable { } var needsContinuation: Bool { !requested.isEmpty } var requiresConfirmation: Bool { - incomplete || target.multipleVolumes || !manual.isEmpty || !images.isEmpty || !apps.isEmpty || !daemons.isEmpty + incomplete || target.multipleVolumes || !manual.isEmpty || !images.isEmpty || !apps.isEmpty || !daemons.isEmpty || !simulators.isEmpty } - var canSystemOnly: Bool { true } + var canSystemOnly: Bool { simulatorsKnown && simulators.isEmpty } var canForce: Bool { - imagesKnown && failure?.allowsForce == true && !target.affected.isEmpty + imagesKnown && simulatorsKnown && simulators.isEmpty && failure?.allowsForce == true && !target.affected.isEmpty && target.affected.allSatisfy { !$0.uuid.isEmpty } } var processScope: Set { Set((apps + daemons + manual).compactMap(\.identity)) } @@ -71,8 +73,11 @@ final class EjectFlow: @unchecked Sendable { let cancellation: CancellationToken var inspector: ProcessInspecting = SystemProcessInspector() var targets: TargetInspecting = SystemTargetInspector() + var simulatorInspector: SimulatorInspecting = SystemSimulatorInspector() var expectedVolume: TargetVolume? var quitTimeout: TimeInterval = 20 + var simulatorShutdownTimeout: TimeInterval = 60 + var simulatorPollInterval: TimeInterval = 0.25 var sleep: (TimeInterval) -> Void = { Thread.sleep(forTimeInterval: $0) } var now: () -> Date = Date.init var monotonicNow: () -> TimeInterval = { ProcessInfo.processInfo.systemUptime } @@ -124,6 +129,11 @@ final class EjectFlow: @unchecked Sendable { var holders: [Holder] = [] var issues = images.issues let mounts = Set(target.affected.map(\.mount) + (images.value ?? []).flatMap(\.mountPoints)) + let simulatorProbe = ProbeResult<[SimulatorDevice]>.capture { + let inventory = try simulatorInspector.devices(runner: scoped) + return inventory.filter { $0.needsShutdown && SimulatorScope.isRelated($0, to: Array(mounts)) } + } + issues += simulatorProbe.issues for mount in mounts.sorted() { let result = ProbeResult.capture { try occ.fullScan(mountPoint: mount, indexingOn: indexingOn) @@ -144,7 +154,11 @@ final class EjectFlow: @unchecked Sendable { throw ProbeFailure(M("ejectflow.the.target.drive.or.related.volumes.changed.during")) } return EjectPlan(target: target, createdAt: now(), holders: holders, - images: images.value ?? [], issues: issues, imagesKnown: images.isComplete) + images: images.value ?? [], issues: issues, imagesKnown: images.isComplete, + simulators: simulatorProbe.value ?? [], + // An image mounted elsewhere can still contain simulator data. + // If its backing relationship is unknown, simulator scope is unknown too. + simulatorsKnown: simulatorProbe.isComplete && images.isComplete) } /// No side effect until preflight either needs no preparation or receives confirmation. @@ -166,6 +180,7 @@ final class EjectFlow: @unchecked Sendable { forceUsed = approved.forceUsed relatedImages = approved.images + (approved.failure?.relatedImages ?? []) steps = [Step(id: "validate", title: M("ejectflow.verify.target.and.scope")), + Step(id: "simulators", title: M("ejectflow.shutdown.related.simulators")), Step(id: "apps", title: M("ejectflow.request.apps.to.quit")), Step(id: "daemons", title: M("ejectflow.stop.approved.background.services")), Step(id: "images", title: M("ejectflow.eject.read.only.disk.images")), Step(id: "recheck", title: M("ejectflow.recheck.open.files")), Step(id: "unmount", title: M("ejectflow.ask.macos.to.eject")), Step(id: "verify", title: M("ejectflow.verify.eject.result"))] @@ -181,7 +196,11 @@ final class EjectFlow: @unchecked Sendable { } if systemOnly { guard approved.canSystemOnly else { throw ProbeFailure(M("ejectflow.this.preflight.does.not.allow.preparation.to.be")) } + let fresh = try prepare() + guard fresh.target == approved.target else { throw ProbeFailure(M("ejectflow.the.target.drive.changed")) } + guard fresh.simulatorsKnown, fresh.simulators.isEmpty else { return previewOutcome(fresh) } set("validate", .done(M("ejectflow.only.attempt.a.normal.system.eject.leave.apps"))) + set("simulators", .skipped(M("simulatorprobe.no.related.devices"))) for id in ["apps", "daemons", "images", "recheck"] { set(id, .skipped(M("ejectflow.user.chose.to.attempt.system.eject.only"))) } } else { // Account for requests completed while the user handled a save dialog. @@ -191,8 +210,53 @@ final class EjectFlow: @unchecked Sendable { let fresh = try prepare() guard fresh.target == approved.target else { throw ProbeFailure(M("ejectflow.the.target.drive.changed")) } guard fresh.canPrepare(approvedTasks: selectedTasks), fresh.processScope.isSubset(of: approved.processScope), - fresh.images.allSatisfy({ approved.images.contains($0) }) else { return previewOutcome(fresh) } + fresh.images.allSatisfy({ approved.images.contains($0) }), + fresh.simulators.allSatisfy({ approved.simulators.contains($0) }) else { return previewOutcome(fresh) } set("validate", .done(M("ejectflow.operation.scope.verified"))) + set("simulators", .running) + if fresh.simulators.isEmpty { + set("simulators", .skipped(M("simulatorprobe.no.related.devices"))) + } else { + for device in fresh.simulators { + try cancellation.check() + let current = try prepare() + guard current.target == approved.target, current.canPrepare(approvedTasks: selectedTasks), + current.processScope.isSubset(of: approved.processScope), + current.images.allSatisfy({ approved.images.contains($0) }), + current.simulators.allSatisfy({ approved.simulators.contains($0) }) else { + return previewOutcome(current) + } + let inventory = try simulatorInspector.devices(runner: scoped) + guard let live = inventory.first(where: { $0.udid == device.udid }) else { + throw ProbeFailure(M("simulatorprobe.device.disappeared", device.name)) + } + guard live.name == device.name, + live.runtimeIdentifier == device.runtimeIdentifier, + live.dataPath == device.dataPath, + live.runtimePath == device.runtimePath else { + throw ProbeFailure(M("simulatorprobe.device.scope.changed", device.name)) + } + if live.isShutdown { continue } + guard live == device else { return previewOutcome(current) } + try cancellation.check() + do { try simulatorInspector.shutdown(live, runner: scoped) } + catch { + try cancellation.check() + let afterFailure = try simulatorInspector.devices(runner: scoped) + guard let observed = afterFailure.first(where: { $0.udid == live.udid }), + observed == live || observed.isShutdown else { throw error } + } + try waitForSimulatorShutdown(live) + let after = try prepare() + guard after.target == approved.target, after.canPrepare(approvedTasks: selectedTasks), + after.processScope.isSubset(of: approved.processScope), + after.images.allSatisfy({ approved.images.contains($0) }), + after.simulators.allSatisfy({ approved.simulators.contains($0) }) else { + return previewOutcome(after) + } + } + set("simulators", .done(M("simulatorprobe.shutdown.completed", fresh.simulators.count))) + } for (id, list) in [("apps", fresh.apps), ("daemons", fresh.daemons + fresh.manual)] { set(id, .running) if list.isEmpty { set(id, .skipped(M("ejectflow.nothing.needs.to.be.handled"))); continue } @@ -202,7 +266,8 @@ final class EjectFlow: @unchecked Sendable { let current = try prepare() guard current.target == approved.target else { throw ProbeFailure(M("ejectflow.the.target.drive.changed")) } guard current.canPrepare(approvedTasks: selectedTasks), current.processScope.isSubset(of: approved.processScope), - current.images.allSatisfy({ approved.images.contains($0) }) else { return previewOutcome(current) } + current.images.allSatisfy({ approved.images.contains($0) }), + current.simulators.allSatisfy({ approved.simulators.contains($0) }) else { return previewOutcome(current) } guard let identity = holder.identity, current.holders.contains(where: { $0.identity == identity && $0.kind == holder.kind }) else { continue } guard let live = try inspector.identity(identity.pid) else { continue } @@ -243,7 +308,8 @@ final class EjectFlow: @unchecked Sendable { let current = try prepare() guard current.target == approved.target else { throw ProbeFailure(M("ejectflow.the.target.drive.changed")) } guard current.canPrepare(approvedTasks: selectedTasks), current.processScope.isSubset(of: approved.processScope), - current.images.allSatisfy({ approved.images.contains($0) }) else { return previewOutcome(current) } + current.images.allSatisfy({ approved.images.contains($0) }), + current.simulators.allSatisfy({ approved.simulators.contains($0) }) else { return previewOutcome(current) } guard current.images.contains(image) else { continue } try cancellation.check() if let outcome = detachImage(image, target: approved.target, force: false) { return outcome } @@ -315,6 +381,7 @@ final class EjectFlow: @unchecked Sendable { } func inScope(_ plan: EjectPlan) -> Bool { plan.imagesKnown && plan.target == approved.target + && plan.simulatorsKnown && plan.simulators.isEmpty && plan.images.allSatisfy { approved.images.contains($0) } && now().timeIntervalSince(approved.createdAt) <= 30 } @@ -381,6 +448,9 @@ final class EjectFlow: @unchecked Sendable { private func detachImage(_ image: DiskImage, target: EjectTarget, force: Bool) -> Outcome? { let stage = force ? "force-images" : "images" do { + let current = try prepare() + guard current.target == target else { throw ProbeFailure(M("ejectflow.the.target.drive.changed")) } + guard current.simulatorsKnown, current.simulators.isEmpty else { return previewOutcome(current) } guard cancellation.commit() else { throw ProbeFailure(M("ejectflow.operation.cancelled")) } if force { forceUsed = true } onCommit() @@ -545,6 +615,29 @@ final class EjectFlow: @unchecked Sendable { return .waiting } + private func waitForSimulatorShutdown(_ expected: SimulatorDevice) throws { + let deadline = monotonicNow() + simulatorShutdownTimeout + while true { + try cancellation.check() + let inventory = try simulatorInspector.devices(runner: scoped) + guard let current = inventory.first(where: { $0.udid == expected.udid }) else { + throw ProbeFailure(M("simulatorprobe.device.disappeared", expected.name)) + } + guard current.name == expected.name, + current.runtimeIdentifier == expected.runtimeIdentifier, + current.dataPath == expected.dataPath, + current.runtimePath == expected.runtimePath else { + throw ProbeFailure(M("simulatorprobe.device.scope.changed", expected.name)) + } + if current.isShutdown { return } + let remaining = deadline - monotonicNow() + guard remaining > 0.001 else { + throw ProbeFailure(M("simulatorprobe.shutdown.timed.out", expected.name)) + } + sleep(min(simulatorPollInterval, remaining)) + } + } + private func creditExit(_ identity: ProcessIdentity) { guard let kind = requested[identity], credited.insert(identity).inserted else { return } if kind == .guiApp { stoppedApps += 1 } else { stoppedDaemons += 1 } @@ -602,6 +695,11 @@ final class EjectFlow: @unchecked Sendable { else if try targets.target(at: mountPoint, runner: scoped) != approved.target { throw ProbeFailure(M("ejectflow.the.target.s.identity.or.related.volumes.changed.e989")) } + let simulatorCheck = try prepare(unmountedTarget: allowUnmounted ? approved.target : nil) + guard simulatorCheck.target == approved.target else { throw ProbeFailure(M("ejectflow.the.target.drive.changed")) } + guard simulatorCheck.simulatorsKnown, simulatorCheck.simulators.isEmpty else { + return previewOutcome(simulatorCheck) + } guard cancellation.commit() else { throw ProbeFailure(M("ejectflow.operation.cancelled")) } onCommit() set("unmount", .running) @@ -643,7 +741,8 @@ final class EjectFlow: @unchecked Sendable { guard fresh.target == approved.target else { throw ProbeFailure(M("ejectflow.the.target.drive.changed")) } // Never turn a dissenter PID alone into permission to signal it: only // fresh file evidence and verified identity can enable preparation. - if !fresh.processScope.isEmpty || !fresh.images.isEmpty { + if !fresh.processScope.isEmpty || !fresh.images.isEmpty + || !fresh.simulatorsKnown || !fresh.simulators.isEmpty { return previewOutcome(fresh, notice: reason) } guard !fresh.incomplete, !systemOnly || !approved.incomplete, @@ -659,7 +758,8 @@ final class EjectFlow: @unchecked Sendable { } let next = try prepare() guard next.target == approved.target else { throw ProbeFailure(M("ejectflow.the.target.drive.changed")) } - if next.incomplete || !next.processScope.isEmpty || !next.images.isEmpty { + if next.incomplete || !next.processScope.isEmpty || !next.images.isEmpty + || !next.simulatorsKnown || !next.simulators.isEmpty { return previewOutcome(next, notice: reason) } } diff --git a/Sources/DevDiskKit/Core/Shell.swift b/Sources/DevDiskKit/Core/Shell.swift index 8e070a6..e064699 100644 --- a/Sources/DevDiskKit/Core/Shell.swift +++ b/Sources/DevDiskKit/Core/Shell.swift @@ -78,6 +78,7 @@ enum Tool { static let du = "/usr/bin/du" static let osascript = "/usr/bin/osascript" static let hdiutil = "/usr/bin/hdiutil" + static let xcrun = "/usr/bin/xcrun" /// nil when smartmontools is not installed — the health section degrades instead of failing. static let smartctl: String? = { diff --git a/Sources/DevDiskKit/Core/Simulator.swift b/Sources/DevDiskKit/Core/Simulator.swift new file mode 100644 index 0000000..b484e11 --- /dev/null +++ b/Sources/DevDiskKit/Core/Simulator.swift @@ -0,0 +1,90 @@ +import Foundation + +/// A CoreSimulator device whose storage paths and runtime can be verified from +/// `simctl`. Paths are retained in the approved plan so a reused UDID cannot widen +/// the operation scope. +struct SimulatorDevice: Equatable, Identifiable { + let udid: String + let name: String + let runtimeIdentifier: String + let state: String + let dataPath: String? + let runtimePath: String? + + var id: String { udid } + var isShutdown: Bool { state.caseInsensitiveCompare("Shutdown") == .orderedSame } + var needsShutdown: Bool { !isShutdown } + var displayRuntime: String { + runtimeIdentifier.replacingOccurrences(of: "com.apple.CoreSimulator.SimRuntime.", with: "") + } +} + +protocol SimulatorInspecting { + func devices(runner: CommandRunner) throws -> [SimulatorDevice] + func shutdown(_ device: SimulatorDevice, runner: CommandRunner) throws +} + +struct SystemSimulatorInspector: SimulatorInspecting { + func devices(runner: CommandRunner) throws -> [SimulatorDevice] { + let devices = try runner.run(Tool.xcrun, ["simctl", "list", "devices", "--json"], timeout: Deadline.quick) + try devices.requireSuccess(M("simulatorprobe.list.devices")) + let runtimes = try runner.run(Tool.xcrun, ["simctl", "list", "runtimes", "--json"], timeout: Deadline.quick) + try runtimes.requireSuccess(M("simulatorprobe.list.runtimes")) + return try Self.parse(devices: devices.stdout, runtimes: runtimes.stdout) + } + + func shutdown(_ device: SimulatorDevice, runner: CommandRunner) throws { + let result = try runner.run(Tool.xcrun, ["simctl", "shutdown", device.udid], timeout: Deadline.eject) + try result.requireSuccess(M("simulatorprobe.shutdown", device.name)) + } + + static func parse(devices data: Data, runtimes runtimeData: Data) throws -> [SimulatorDevice] { + guard let deviceRoot = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let runtimeGroups = deviceRoot["devices"] as? [String: [[String: Any]]], + let runtimeRoot = try? JSONSerialization.jsonObject(with: runtimeData) as? [String: Any], + let runtimeRows = runtimeRoot["runtimes"] as? [[String: Any]] else { + throw ProbeFailure(M("simulatorprobe.invalid.output")) + } + + let runtimePaths = Dictionary(runtimeRows.compactMap { row -> (String, String)? in + guard let identifier = row["identifier"] as? String, + let path = row["bundlePath"] as? String, !path.isEmpty else { return nil } + return (identifier, path) + }, uniquingKeysWith: { first, _ in first }) + + var result: [SimulatorDevice] = [] + var ids = Set() + for (runtimeIdentifier, rows) in runtimeGroups { + for row in rows { + guard let udid = row["udid"] as? String, !udid.isEmpty, + let name = row["name"] as? String, !name.isEmpty, + let state = row["state"] as? String, !state.isEmpty else { + throw ProbeFailure(M("simulatorprobe.invalid.device")) + } + guard ids.insert(udid).inserted else { + throw ProbeFailure(M("simulatorprobe.duplicate.device")) + } + let dataPath = row["dataPath"] as? String + let runtimePath = runtimePaths[runtimeIdentifier] + // A running or transitioning device without both paths cannot be + // proven unrelated to the target volume, so the caller must fail closed. + if state.caseInsensitiveCompare("Shutdown") != .orderedSame, + (dataPath?.isEmpty != false || runtimePath?.isEmpty != false) { + throw ProbeFailure(M("simulatorprobe.device.scope.unknown", name)) + } + result.append(.init(udid: udid, name: name, + runtimeIdentifier: runtimeIdentifier, state: state, + dataPath: dataPath, runtimePath: runtimePath)) + } + } + return result.sorted { $0.udid < $1.udid } + } +} + +enum SimulatorScope { + static func isRelated(_ device: SimulatorDevice, to roots: [String]) -> Bool { + [device.dataPath, device.runtimePath].compactMap { $0 }.contains { path in + roots.contains { DiskImageProbe.contains(path, under: $0) } + } + } +} diff --git a/Sources/DevDiskKit/Resources/en.lproj/Localizable.strings b/Sources/DevDiskKit/Resources/en.lproj/Localizable.strings index 38e00c3..1700d7d 100644 --- a/Sources/DevDiskKit/Resources/en.lproj/Localizable.strings +++ b/Sources/DevDiskKit/Resources/en.lproj/Localizable.strings @@ -37,6 +37,7 @@ "ejectflow.the.selected.volume.s.identity.changed.select.it" = "The selected volume's identity changed. Select it again."; "ejectflow.the.target.drive.or.related.volumes.changed.during" = "The target drive or related volumes changed during the scan. Scan again."; "ejectflow.verify.target.and.scope" = "Verify target and scope"; +"ejectflow.shutdown.related.simulators" = "Normally shut down related simulators"; "ejectflow.request.apps.to.quit" = "Request apps to quit"; "ejectflow.stop.approved.background.services" = "Stop approved services and tasks"; "ejectflow.eject.read.only.disk.images" = "Normally detach related disk images"; @@ -50,6 +51,19 @@ "ejectflow.the.target.drive.changed" = "The target drive changed"; "ejectflow.operation.scope.verified" = "Operation scope verified"; "ejectflow.nothing.needs.to.be.handled" = "Nothing needs to be handled"; +"simulatorprobe.list.devices" = "Read the CoreSimulator device list"; +"simulatorprobe.list.runtimes" = "Read the CoreSimulator runtime list"; +"simulatorprobe.invalid.output" = "CoreSimulator returned an incomplete or invalid device list"; +"simulatorprobe.invalid.device" = "A simulator's identity or state could not be verified"; +"simulatorprobe.duplicate.device" = "CoreSimulator returned a duplicate device identity"; +"simulatorprobe.device.scope.unknown" = "Could not verify whether simulator {0} uses the target disk"; +"simulatorprobe.shutdown" = "Normally shut down simulator {0}"; +"simulatorprobe.no.related.devices" = "No related simulators need to be shut down"; +"simulatorprobe.shutdown.completed" = "Related simulators shut down: {0}"; +"simulatorprobe.device.disappeared" = "Simulator {0} disappeared before shutdown could be verified"; +"simulatorprobe.device.scope.changed" = "Simulator {0}'s identity or storage path changed"; +"simulatorprobe.shutdown.timed.out" = "Simulator {0} did not confirm shutdown before the deadline"; +"ejectpreviewview.simulator.check.must.complete" = "A related simulator is still running or its storage scope is unknown. Complete the simulator check before ejecting; system-only and force are disabled."; "ejectflow.process.identity.changed.scan.again" = "Process identity changed. Scan again."; "ejectflow.stop.service" = "Stop service"; "ejectflow.apps.confirmed.quit" = "Apps confirmed quit: {0}"; @@ -227,10 +241,14 @@ "ejectpreviewview.handle.manually" = "Handle Manually"; "ejectpreviewview.save.and.stop.these.tasks.then.scan.again" = "Save and stop these tasks, then scan again."; "ejectpreviewview.related.disk.images" = "Related Disk Images"; +"ejectpreviewview.related.simulators" = "Related Apple Simulators"; +"ejectpreviewview.simulators.will.shutdown.normally" = "After confirmation, DevDisk requests a normal shutdown and verifies each device before detaching images."; +"ejectpreviewview.simulator.blocks.system.eject" = "A related simulator is active or its state is unknown. Confirm a normal shutdown or resolve the check before ejecting."; "ejectpreviewview.writable.or.unknown.eject.manually" = "Writable or unknown: eject manually"; "ejectpreviewview.read.only.normal.eject.after.confirmation" = "Read-only: normal eject after confirmation"; "ejectpreviewview.manually.eject.writable.images.or.images.with.unknown" = "Manually eject writable images or images with unknown properties first."; "ejectpreviewview.confirm.and.eject" = "Close occupants and eject"; +"ejectpreviewview.shutdown.simulators.and.eject" = "Shut down simulators normally and eject"; "ejectpreviewview.try.system.eject.only" = "Try normal system eject only"; "ejectpreviewview.does.not.quit.apps.stop.services.eject.images" = "Does not quit apps, stop services, eject images separately or force unmount"; "ejectpreviewview.scan.again" = "Scan Again"; diff --git a/Sources/DevDiskKit/Resources/zh-Hans.lproj/Localizable.strings b/Sources/DevDiskKit/Resources/zh-Hans.lproj/Localizable.strings index 569bb0e..0f2174e 100644 --- a/Sources/DevDiskKit/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/DevDiskKit/Resources/zh-Hans.lproj/Localizable.strings @@ -37,6 +37,7 @@ "ejectflow.the.selected.volume.s.identity.changed.select.it" = "所选卷身份已变化,请重新选择"; "ejectflow.the.target.drive.or.related.volumes.changed.during" = "扫描期间目标盘或关联卷已变化,请重新检测"; "ejectflow.verify.target.and.scope" = "确认目标及操作范围"; +"ejectflow.shutdown.related.simulators" = "正常关闭相关模拟器"; "ejectflow.request.apps.to.quit" = "请求应用退出"; "ejectflow.stop.approved.background.services" = "停止已批准的服务和任务"; "ejectflow.eject.read.only.disk.images" = "普通推出关联磁盘映像"; @@ -50,6 +51,19 @@ "ejectflow.the.target.drive.changed" = "目标盘已变化"; "ejectflow.operation.scope.verified" = "操作范围已核验"; "ejectflow.nothing.needs.to.be.handled" = "没有需要处理的对象"; +"simulatorprobe.list.devices" = "读取 CoreSimulator 设备列表"; +"simulatorprobe.list.runtimes" = "读取 CoreSimulator 运行时列表"; +"simulatorprobe.invalid.output" = "CoreSimulator 返回的设备列表不完整或无法解析"; +"simulatorprobe.invalid.device" = "无法核验模拟器的身份或状态"; +"simulatorprobe.duplicate.device" = "CoreSimulator 返回了重复的设备身份"; +"simulatorprobe.device.scope.unknown" = "无法确认模拟器“{0}”是否使用目标盘"; +"simulatorprobe.shutdown" = "正常关闭模拟器“{0}”"; +"simulatorprobe.no.related.devices" = "没有需要关闭的关联模拟器"; +"simulatorprobe.shutdown.completed" = "已确认关闭相关模拟器:{0} 台"; +"simulatorprobe.device.disappeared" = "模拟器“{0}”在确认关机前从列表中消失"; +"simulatorprobe.device.scope.changed" = "模拟器“{0}”的身份或存储路径已变化"; +"simulatorprobe.shutdown.timed.out" = "等待模拟器“{0}”确认关机超时"; +"ejectpreviewview.simulator.check.must.complete" = "相关模拟器仍在运行,或其存储范围未知。请先完成模拟器检查;仅系统弹出和强制弹出当前不可用。"; "ejectflow.process.identity.changed.scan.again" = "进程身份已变化,请重新检测"; "ejectflow.stop.service" = "停止服务"; "ejectflow.apps.confirmed.quit" = "确认退出 {0} 个应用"; @@ -227,10 +241,14 @@ "ejectpreviewview.handle.manually" = "需手动处理"; "ejectpreviewview.save.and.stop.these.tasks.then.scan.again" = "请先保存并停止这些任务,再重新检测。"; "ejectpreviewview.related.disk.images" = "关联磁盘映像"; +"ejectpreviewview.related.simulators" = "目标盘相关的 Apple 模拟器"; +"ejectpreviewview.simulators.will.shutdown.normally" = "确认后,DevDisk 会请求模拟器正常关机,并逐台核验后再卸载映像。"; +"ejectpreviewview.simulator.blocks.system.eject" = "相关模拟器仍在运行或状态未知。请确认正常关机,或先解决检测问题再弹出。"; "ejectpreviewview.writable.or.unknown.eject.manually" = "可写或属性未知:需手动推出"; "ejectpreviewview.read.only.normal.eject.after.confirmation" = "只读:确认后普通推出"; "ejectpreviewview.manually.eject.writable.images.or.images.with.unknown" = "请先手动推出可写或属性未知的映像。"; "ejectpreviewview.confirm.and.eject" = "关闭占用并弹出"; +"ejectpreviewview.shutdown.simulators.and.eject" = "正常关闭模拟器并弹出"; "ejectpreviewview.try.system.eject.only" = "仅尝试普通系统弹出"; "ejectpreviewview.does.not.quit.apps.stop.services.eject.images" = "不会退出应用、停止服务、主动推出映像或强制卸载"; "ejectpreviewview.scan.again" = "重新检测"; diff --git a/Sources/DevDiskKit/Views/DemoFixture.swift b/Sources/DevDiskKit/Views/DemoFixture.swift index 80e8007..9616919 100644 --- a/Sources/DevDiskKit/Views/DemoFixture.swift +++ b/Sources/DevDiskKit/Views/DemoFixture.swift @@ -2,24 +2,33 @@ import AppKit /// Explicit, isolated UI acceptance mode. Every process/disk operation is simulated; /// this runner never forwards an unrecognised command to the system. -final class DemoMachine: CommandRunner, ProcessInspecting, TargetInspecting, @unchecked Sendable { +final class DemoMachine: CommandRunner, ProcessInspecting, TargetInspecting, SimulatorInspecting, @unchecked Sendable { let scenario: String let mount = "/Volumes/DevDisk 演示盘" private var live: [Int32: ProcessIdentity] = [:] private(set) var gone = false private var unmounted = false private var imageDetached = false + private var simulators: [SimulatorDevice] = [] init(scenario: String) { self.scenario = scenario - for index in 1...(scenario.contains("long") ? 8 : 1) { - let pid = Int32(90000 + index) - live[pid] = .init(pid: pid, uid: getuid(), startedSeconds: 42, startedMicros: 0, - executable: "/Applications/演示编辑器.app/Contents/MacOS/editor", - bundleID: "devdisk.demo.editor\(index)", appName: "演示编辑器 \(index)") + if !scenario.contains("simulators") { + for index in 1...(scenario.contains("long") ? 8 : 1) { + let pid = Int32(90000 + index) + live[pid] = .init(pid: pid, uid: getuid(), startedSeconds: 42, startedMicros: 0, + executable: "/Applications/演示编辑器.app/Contents/MacOS/editor", + bundleID: "devdisk.demo.editor\(index)", appName: "演示编辑器 \(index)") + } } if scenario.contains("tasks") { live[90100] = .init(pid: 90100, uid: getuid(), startedSeconds: 42, startedMicros: 0, executable: "/bin/zsh") } + if scenario.contains("simulators") { + simulators = [.init(udid: "DEMO-SIM-001", name: "iPhone 演示设备", + runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.iOS-演示版", + state: "Booted", dataPath: mount + "/Applications/CoreSimulatorData/Devices/DEMO-SIM-001/data", + runtimePath: mount + "/Applications/Xcode.app/Runtime.simruntime")] + } } var targetValue: EjectTarget { let volume = TargetVolume(name: "DevDisk 演示盘", mount: mount, device: "disk900s1", uuid: "DEMO-ONLY") @@ -40,6 +49,15 @@ final class DemoMachine: CommandRunner, ProcessInspecting, TargetInspecting, @un ], directories: [], occupancy: nil) } func identity(_ pid: Int32) throws -> ProcessIdentity? { live[pid] } + func devices(runner: CommandRunner) throws -> [SimulatorDevice] { simulators } + func shutdown(_ device: SimulatorDevice, runner: CommandRunner) throws { + guard let index = simulators.firstIndex(where: { $0.udid == device.udid }) else { + throw ProbeFailure("demo simulator disappeared") + } + simulators[index] = .init(udid: device.udid, name: device.name, + runtimeIdentifier: device.runtimeIdentifier, state: "Shutdown", + dataPath: device.dataPath, runtimePath: device.runtimePath) + } func requestQuit(_ identity: ProcessIdentity) throws { if !scenario.contains("running") && !scenario.contains("save") { live.removeValue(forKey: identity.pid) } } @@ -62,6 +80,7 @@ final class DemoMachine: CommandRunner, ProcessInspecting, TargetInspecting, @un case Tool.lsof: if args.last != mount { return result("", code: 1) } if scenario.contains("unknown") { return .init(stdout: Data(), stderr: "演示:检测超时,结果不完整", exitCode: -1, timedOut: true) } + if scenario.contains("simulators"), live.isEmpty { return result("") } return result(live.keys.sorted().map { "p\($0)\nceditor\nLdemo\nf3\nn\(mount)/Projects/Example-\($0)/Sources/document.swift\n" }.joined(), code: live.isEmpty ? 1 : 0) case Tool.hdiutil: if args.first == "detach", scenario.contains("image") { @@ -107,6 +126,7 @@ enum DemoFixture { store.makeFlow = { _, mount, token in let flow = EjectFlow(runner: machine, mountPoint: mount, cancellation: token) flow.targets = machine; flow.inspector = machine + flow.simulatorInspector = machine if scenario.contains("image") { flow.verificationTimeout = 0.5 } if scenario.contains("save") { flow.quitTimeout = 1 } return flow diff --git a/Sources/DevDiskKit/Views/EjectPreviewView.swift b/Sources/DevDiskKit/Views/EjectPreviewView.swift index 98d572b..5dd6984 100644 --- a/Sources/DevDiskKit/Views/EjectPreviewView.swift +++ b/Sources/DevDiskKit/Views/EjectPreviewView.swift @@ -46,6 +46,19 @@ struct EjectPreviewView: View { } } } + if !plan.simulators.isEmpty { + PanelSection(title: L("ejectpreviewview.related.simulators")) { + Text(L("ejectpreviewview.simulators.will.shutdown.normally")) + .font(.caption).foregroundStyle(.secondary) + ForEach(plan.simulators) { device in + VStack(alignment: .leading, spacing: 2) { + Text(device.name).font(.system(size: 12, weight: .medium)) + Text("\(device.displayRuntime) · \(device.state)") + .font(.caption).foregroundStyle(.secondary) + } + } + } + } if !plan.forceConfirmation { group(L("ejectpreviewview.apps.to.request.to.quit"), plan.apps, note: L("ejectpreviewview.quitting.affects.the.entire.app.each.app.handles")) group(L("ejectpreviewview.approved.background.services.to.stop"), plan.daemons, note: L("ejectpreviewview.services.may.still.be.working.after.confirmation.sends")) @@ -106,7 +119,9 @@ struct EjectPreviewFooter: View { .disabled(!plan.canForce) } else { if plan.canPrepare { - PrimaryButton(title: L(plan.needsContinuation ? "ejectpreviewview.continue.checking" : "ejectpreviewview.confirm.and.eject"), symbol: "eject.fill") { store.confirmEject() } + let title = plan.needsContinuation ? "ejectpreviewview.continue.checking" + : (plan.simulators.isEmpty ? "ejectpreviewview.confirm.and.eject" : "ejectpreviewview.shutdown.simulators.and.eject") + PrimaryButton(title: L(title), symbol: "eject.fill") { store.confirmEject() } } if plan.canSystemOnly { PrimaryButton(title: L("ejectpreviewview.try.system.eject.only"), symbol: "eject") { store.confirmEject(mode: .systemOnly) } @@ -117,6 +132,10 @@ struct EjectPreviewFooter: View { Button(L("ejectforce.action")) { store.requestForceEject() }.buttonStyle(.bordered) } } + if !plan.canSystemOnly { + Text(L("ejectpreviewview.simulator.check.must.complete")) + .font(.caption).foregroundStyle(.orange).multilineTextAlignment(.center) + } HStack { Button(L("ejectpreviewview.scan.again")) { store.retryPreflight() } Spacer() diff --git a/Sources/DevDiskKit/Views/Snapshot.swift b/Sources/DevDiskKit/Views/Snapshot.swift index c5e6dec..5da921a 100644 --- a/Sources/DevDiskKit/Views/Snapshot.swift +++ b/Sources/DevDiskKit/Views/Snapshot.swift @@ -40,6 +40,9 @@ public enum Snapshot { print("\(holder.name) · \(holder.openFileCount ?? 0) 个文件 · \(holder.kind)") holder.sampleFiles.forEach { print(" " + $0) } } + for device in plan.simulators { + print("模拟器:\(device.name) · \(device.displayRuntime) · \(device.state) · \(device.udid)") + } plan.images.forEach { print("映像:\($0.path) · \(!$0.accessKnown ? "属性未知" : $0.writable ? "可写" : "只读")") } plan.issues.forEach { print(("检测不完整:" + $0).text) } if let notice = plan.notice { print(notice.text) } @@ -55,12 +58,12 @@ public enum Snapshot { plan.selectedTasks = Set(selectable.compactMap(\.identity).filter { pids.contains($0.pid) }) guard plan.canPrepare else { return false } } - if !plan.canPrepare { + if !plan.canPrepare && plan.canSystemOnly { print("输入 system 仅尝试普通系统弹出;其他输入取消:") guard readLine() == "system" else { return false } outcome = flow.execute(plan, mode: .systemOnly) } else if plan.canPrepare { - print("退出应用影响整个应用,后台服务可能仍在工作。输入 yes 确认处理并弹出;其他输入取消:") + print("确认后将正常关闭上述模拟器、请求应用退出、处理后台服务及关联映像。输入 yes 继续;其他输入取消:") guard readLine() == "yes" else { return false } outcome = flow.execute(plan, mode: .prepared) } else { @@ -256,13 +259,22 @@ public enum Snapshot { do { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) } catch { print(error.localizedDescription); return false } let checker = UpdateChecker(fetcher: StubFetcher(), defaults: previewDefaults()) - for scenario in ["short", "long", "unknown", "running", "waiting"] { + for scenario in ["short", "long", "unknown", "running", "waiting", "simulators"] { let store = DemoFixture.makeStore(scenario: scenario) for _ in 0..<300 { if store.operation == .awaitingConfirmation { break } try? await Task.sleep(nanoseconds: 10_000_000) } guard store.ejectPlan != nil else { print("演示预检未完成:" + scenario); return false } + if scenario == "simulators" { + for identity in store.ejectPlan?.manual.compactMap(\.identity) ?? [] { + store.selectTask(identity, selected: true) + } + guard let plan = store.ejectPlan, plan.simulators.count == 1, plan.canPrepare else { + print("模拟器预览未包含可确认的正常关机流程") + return false + } + } if scenario == "running" || scenario == "waiting" { store.confirmEject() for _ in 0..<300 { diff --git a/Tests/DevDiskKitTests/FlowHarness.swift b/Tests/DevDiskKitTests/FlowHarness.swift index 0d0199a..d228f6e 100644 --- a/Tests/DevDiskKitTests/FlowHarness.swift +++ b/Tests/DevDiskKitTests/FlowHarness.swift @@ -7,6 +7,7 @@ final class FlowHarness: CommandRunner, @unchecked Sendable { static let mount = "/Volumes/ReviewDisk" let targetInspector = FakeTargetInspector() let processes = FakeProcessInspector() + let simulatorInspector = FakeSimulatorInspector() var args: [Int32: String] = [:] var files: [Int32: [String]] = [:] var images: [DiskImage] = [] @@ -21,10 +22,13 @@ final class FlowHarness: CommandRunner, @unchecked Sendable { let flow = EjectFlow(runner: self, mountPoint: Self.mount, cancellation: cancellation) flow.inspector = processes flow.targets = targetInspector + flow.simulatorInspector = simulatorInspector flow.now = { self.clock } flow.monotonicNow = { self.clock.timeIntervalSince1970 } flow.sleep = { self.clock += $0 } flow.quitTimeout = 0.3 + flow.simulatorShutdownTimeout = 0.3 + flow.simulatorPollInterval = 0.1 return flow } func add(pid: Int32 = 123, executable: String = "/usr/bin/java", args command: String = "java org.gradle.launcher.daemon.bootstrap.GradleDaemon 8.14", @@ -154,3 +158,35 @@ final class FakeTargetInspector: TargetInspecting { relatedMountsKnown: true, issue: nil) } } + +final class FakeSimulatorInspector: SimulatorInspecting { + var live: [SimulatorDevice] = [] + var failList = false + var refuseShutdown = false + var shutdownCompletes = true + var shutdowns: [String] = [] + var onList: (() -> Void)? + var onShutdown: ((SimulatorDevice) -> Void)? + + func devices(runner: CommandRunner) throws -> [SimulatorDevice] { + if failList { throw ProbeFailure("simulator inventory unavailable") } + onList?() + return live + } + + func shutdown(_ device: SimulatorDevice, runner: CommandRunner) throws { + guard !refuseShutdown else { throw ProbeFailure("simulator shutdown refused") } + guard let index = live.firstIndex(where: { $0.udid == device.udid }) else { + throw ProbeFailure("simulator disappeared") + } + shutdowns.append(device.udid) + onShutdown?(device) + if shutdownCompletes { + let current = live[index] + live[index] = SimulatorDevice(udid: current.udid, name: current.name, + runtimeIdentifier: current.runtimeIdentifier, + state: "Shutdown", dataPath: current.dataPath, + runtimePath: current.runtimePath) + } + } +} diff --git a/Tests/DevDiskKitTests/ForceEjectTests.swift b/Tests/DevDiskKitTests/ForceEjectTests.swift index 313b233..792c79d 100644 --- a/Tests/DevDiskKitTests/ForceEjectTests.swift +++ b/Tests/DevDiskKitTests/ForceEjectTests.swift @@ -105,12 +105,12 @@ final class ForceEjectTests: XCTestCase { XCTAssertFalse(h.calls.contains { $0.contains("force") }) } } - func testUnreadableImageInventoryDisablesForceButKeepsNormalSystemRoute() throws { + func testUnreadableImageInventoryDisablesForceAndSystemRoute() throws { let h = FlowHarness(), plan = try forcePlan(h) h.failures["hdiutil info -plist"] = .init(stdout: Data(), stderr: "denied", exitCode: 1) guard case .preview(let fresh) = h.flow.execute(plan, mode: .force) else { return XCTFail("reconfirm unavailable scope") } XCTAssertFalse(fresh.canForce) - XCTAssertTrue(fresh.canSystemOnly) + XCTAssertFalse(fresh.canSystemOnly) XCTAssertFalse(h.calls.contains { $0.contains("force") }) } func testImageMountedOccupantsAreScannedAndDuplicateProcessesActOnce() throws { @@ -235,7 +235,7 @@ final class ForceEjectTests: XCTestCase { let plan = try h.flow.prepare() XCTAssertFalse(plan.imagesKnown) XCTAssertFalse(plan.canPrepare) - XCTAssertTrue(plan.canSystemOnly) + XCTAssertFalse(plan.canSystemOnly) XCTAssertFalse(plan.canForce) } diff --git a/Tests/DevDiskKitTests/SimulatorTests.swift b/Tests/DevDiskKitTests/SimulatorTests.swift new file mode 100644 index 0000000..f1cd93e --- /dev/null +++ b/Tests/DevDiskKitTests/SimulatorTests.swift @@ -0,0 +1,249 @@ +import XCTest +@testable import DevDiskKit + +final class SimulatorTests: XCTestCase { + private let devicesJSON = #"{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-4":[{"udid":"00000000-0000-0000-0000-000000000001","name":"iPhone Review","state":"Booted","dataPath":"/Volumes/Developer/Applications/CoreSimulatorData/Devices/0001/data"}]}}"# + private let runtimesJSON = #"{"runtimes":[{"identifier":"com.apple.CoreSimulator.SimRuntime.iOS-18-4","bundlePath":"/Volumes/Developer/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 18.4.simruntime"}]}"# + + func testParsesBootedDeviceAndRuntimeStorage() throws { + let devices = try SystemSimulatorInspector.parse(devices: Data(devicesJSON.utf8), + runtimes: Data(runtimesJSON.utf8)) + let device = try XCTUnwrap(devices.first) + XCTAssertEqual(device.name, "iPhone Review") + XCTAssertTrue(device.needsShutdown) + XCTAssertEqual(device.dataPath, "/Volumes/Developer/Applications/CoreSimulatorData/Devices/0001/data") + XCTAssertTrue(SimulatorScope.isRelated(device, to: ["/Volumes/Developer"])) + XCTAssertFalse(SimulatorScope.isRelated(device, to: ["/Volumes/Develop"])) + } + + func testBootedDeviceWithoutVerifiableStorageIsRejected() { + let input = #"{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-4":[{"udid":"device-1","name":"iPhone Review","state":"Booted"}]}}"# + XCTAssertThrowsError(try SystemSimulatorInspector.parse(devices: Data(input.utf8), + runtimes: Data(runtimesJSON.utf8))) + } + + func testNormalShutdownUsesOnlyTheSelectedDeviceID() throws { + let runner = MockCommandRunner() + let device = SimulatorDevice(udid: "device-1", name: "iPhone Review", + runtimeIdentifier: "runtime", state: "Booted", + dataPath: "/Volumes/Developer/device", runtimePath: "/Volumes/Developer/runtime") + try SystemSimulatorInspector().shutdown(device, runner: runner) + XCTAssertEqual(runner.log, ["xcrun simctl shutdown device-1"]) + XCTAssertFalse(runner.log.contains { $0.contains("shutdown all") }) + } +} + +final class SimulatorEjectSafetyTests: XCTestCase { + private func device(_ id: String = "device-1", state: String = "Booted", + dataPath: String? = nil, runtimePath: String? = nil) -> SimulatorDevice { + .init(udid: id, name: "iPhone \(id)", runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.iOS-18-4", + state: state, + dataPath: dataPath ?? FlowHarness.mount + "/Applications/CoreSimulatorData/Devices/\(id)/data", + runtimePath: runtimePath ?? FlowHarness.mount + "/Applications/Xcode.app/Runtime.simruntime") + } + + private func relatedImage() -> DiskImage { + .init(path: FlowHarness.mount + "/Applications/CoreSimulatorData/CoreSimulatorStore-review.sparseimage", + writable: true, devEntries: ["/dev/disk91"], mountPoints: []) + } + + func testRelatedSimulatorShutsDownBeforeItsImageAndDisk() throws { + let h = FlowHarness() + h.images = [relatedImage()] + h.simulatorInspector.live = [device()] + h.simulatorInspector.onShutdown = { h.calls.append("simctl shutdown " + $0.udid) } + let flow = h.flow + let plan = try flow.prepare() + XCTAssertEqual(plan.simulators.map(\.udid), ["device-1"]) + XCTAssertTrue(plan.canPrepare) + XCTAssertFalse(plan.canSystemOnly) + XCTAssertTrue(h.simulatorInspector.shutdowns.isEmpty) + + guard case .ejected = flow.execute(plan, mode: .prepared) else { return XCTFail("confirmed shutdown and eject should finish") } + let shutdown = try XCTUnwrap(h.calls.firstIndex(of: "simctl shutdown device-1")) + let detach = try XCTUnwrap(h.calls.firstIndex(of: "hdiutil detach /dev/disk91")) + let eject = try XCTUnwrap(h.calls.firstIndex(of: "diskutil eject disk90")) + XCTAssertLessThan(shutdown, detach) + XCTAssertLessThan(detach, eject) + guard case .done = try XCTUnwrap(flow.steps.first(where: { $0.id == "simulators" })).state else { + return XCTFail("simulator shutdown progress should complete before eject") + } + } + + func testUnrelatedSimulatorIsNotShutdown() throws { + let h = FlowHarness() + h.simulatorInspector.live = [device(dataPath: "/Users/review/Library/Developer/CoreSimulator/Devices/device-1/data", + runtimePath: "/Applications/Xcode.app/Runtime.simruntime")] + let plan = try h.flow.prepare() + XCTAssertTrue(plan.simulators.isEmpty) + XCTAssertTrue(plan.canSystemOnly) + guard case .ejected = h.flow.execute(plan, mode: .prepared) else { return XCTFail("unrelated device should not block") } + XCTAssertTrue(h.simulatorInspector.shutdowns.isEmpty) + } + + func testUnknownInventoryBlocksEveryEjectRoute() throws { + let h = FlowHarness() + h.simulatorInspector.failList = true + let flow = h.flow + let plan = try flow.prepare() + XCTAssertTrue(plan.incomplete) + XCTAssertFalse(plan.canPrepare) + XCTAssertFalse(plan.canSystemOnly) + XCTAssertFalse(plan.canForce) + guard case .aborted = flow.execute(plan, mode: .systemOnly) else { return XCTFail("system-only must fail closed") } + XCTAssertFalse(h.calls.contains { $0.hasPrefix("hdiutil detach") || $0.hasPrefix("diskutil eject") }) + } + + func testSystemOnlyRefreshStopsIfSimulatorStartsAfterPreview() throws { + let h = FlowHarness() + let flow = h.flow + let plan = try flow.prepare() + XCTAssertTrue(plan.canSystemOnly) + + h.simulatorInspector.live = [device()] + guard case .preview(let refreshed) = flow.execute(plan, mode: .systemOnly) else { + return XCTFail("a newly booted simulator must return for review") + } + XCTAssertEqual(refreshed.simulators.map(\.udid), ["device-1"]) + XCTAssertFalse(refreshed.canSystemOnly) + XCTAssertFalse(h.calls.contains { $0.hasPrefix("diskutil eject") }) + } + + func testSystemOnlyRefreshStopsIfSimulatorInventoryBecomesUnknown() throws { + let h = FlowHarness() + let flow = h.flow + let plan = try flow.prepare() + XCTAssertTrue(plan.canSystemOnly) + + h.simulatorInspector.failList = true + guard case .preview(let refreshed) = flow.execute(plan, mode: .systemOnly) else { + return XCTFail("an unknown simulator inventory must return for review") + } + XCTAssertFalse(refreshed.simulatorsKnown) + XCTAssertFalse(refreshed.canSystemOnly) + XCTAssertFalse(h.calls.contains { $0.hasPrefix("diskutil eject") }) + } + + func testNewSimulatorImmediatelyBeforeDiskEjectStopsTheRequest() throws { + let h = FlowHarness() + let flow = h.flow + let plan = try flow.prepare() + var inventoryReads = 0 + h.simulatorInspector.onList = { + inventoryReads += 1 + // Execution performs an initial refresh and a final occupancy scan; + // the next read is the just-before-eject safety check. + if inventoryReads == 3 { h.simulatorInspector.live = [self.device()] } + } + + guard case .preview(let refreshed) = flow.execute(plan, mode: .prepared) else { + return XCTFail("a simulator starting at the eject boundary must stop the request") + } + XCTAssertEqual(refreshed.simulators.map(\.udid), ["device-1"]) + XCTAssertFalse(h.calls.contains { $0.hasPrefix("diskutil eject") }) + } + + func testUnknownImageInventoryMakesSimulatorScopeIncomplete() throws { + let h = FlowHarness() + h.failures["hdiutil info -plist"] = .init(stdout: Data(), stderr: "permission denied", exitCode: 1) + + let plan = try h.flow.prepare() + XCTAssertTrue(plan.incomplete) + XCTAssertFalse(plan.simulatorsKnown) + XCTAssertFalse(plan.canSystemOnly) + } + + func testForceCannotBypassActiveRelatedSimulator() throws { + let h = FlowHarness() + h.images = [relatedImage()] + h.simulatorInspector.live = [device()] + let image = relatedImage() + let failure = EjectFailure(target: h.targetInspector.current, stage: "images", + message: "image busy", commandOutput: "image busy", + blockingPID: nil, completedApps: 0, completedProcesses: 0, + completedImages: 0, allowsForce: true, relatedImages: [image]) + let flow = h.flow + guard case .preview(let plan) = flow.prepareForce(failure) else { return XCTFail("force preview") } + XCTAssertTrue(plan.forceConfirmation) + XCTAssertFalse(plan.canForce) + guard case .aborted = flow.execute(plan, mode: .force) else { return XCTFail("force must not bypass active simulator") } + XCTAssertFalse(h.calls.contains { $0.contains("force") || $0.hasPrefix("hdiutil detach") || $0.hasPrefix("diskutil eject") }) + } + + func testRefusedShutdownStopsBeforeDetachingOrEjecting() throws { + let h = FlowHarness() + h.images = [relatedImage()] + h.simulatorInspector.live = [device()] + h.simulatorInspector.refuseShutdown = true + guard case .preview(let plan) = h.flow.run(indexingOn: nil) else { return XCTFail("active simulator needs confirmation") } + let flow = h.flow + guard case .aborted = flow.execute(plan, mode: .prepared) else { return XCTFail("refused shutdown must abort") } + XCTAssertFalse(h.calls.contains { $0.hasPrefix("hdiutil detach") || $0.hasPrefix("diskutil eject") }) + guard case .failed = try XCTUnwrap(flow.steps.first(where: { $0.id == "simulators" })).state else { + return XCTFail("shutdown failure should be visible in progress") + } + } + + func testShutdownTimeoutStopsBeforeDetachingOrEjecting() throws { + let h = FlowHarness() + h.images = [relatedImage()] + h.simulatorInspector.live = [device()] + h.simulatorInspector.shutdownCompletes = false + let flow = h.flow + guard case .preview(let plan) = flow.run(indexingOn: nil) else { return XCTFail("active simulator needs confirmation") } + guard case .aborted = flow.execute(plan, mode: .prepared) else { return XCTFail("unverified shutdown must abort") } + XCTAssertFalse(h.calls.contains { $0.hasPrefix("hdiutil detach") || $0.hasPrefix("diskutil eject") }) + } + + func testNewBootedSimulatorReturnsForFreshConfirmation() throws { + let h = FlowHarness() + h.images = [relatedImage()] + h.simulatorInspector.live = [device()] + h.simulatorInspector.onShutdown = { [weak inspector = h.simulatorInspector] _ in + inspector?.live.append(self.device("device-2")) + } + let flow = h.flow + guard case .preview(let plan) = flow.run(indexingOn: nil) else { return XCTFail("active simulator needs confirmation") } + guard case .preview(let updated) = flow.execute(plan, mode: .prepared) else { return XCTFail("new device requires confirmation") } + XCTAssertEqual(updated.simulators.map(\.udid), ["device-2"]) + XCTAssertFalse(h.calls.contains { $0.hasPrefix("hdiutil detach") || $0.hasPrefix("diskutil eject") }) + } + + func testTargetChangeDuringShutdownStopsBeforeImageDetach() throws { + let h = FlowHarness() + h.images = [relatedImage()] + h.simulatorInspector.live = [device()] + h.simulatorInspector.onShutdown = { _ in + let old = h.targetInspector.current + let replacement = TargetVolume(name: old.volume.name, mount: old.volume.mount, + device: old.volume.device, uuid: "replacement-uuid") + h.targetInspector.current = EjectTarget(volume: replacement, physicalDisk: old.physicalDisk, + affected: old.affected) + } + let flow = h.flow + guard case .preview(let plan) = flow.run(indexingOn: nil) else { return XCTFail("active simulator needs confirmation") } + guard case .preview = flow.execute(plan, mode: .prepared) else { return XCTFail("changed target must return for review") } + XCTAssertFalse(h.calls.contains { $0.hasPrefix("hdiutil detach") || $0.hasPrefix("diskutil eject") }) + } + + func testSimulatorDisappearingBeforeShutdownIsNotTreatedAsShutdown() throws { + let h = FlowHarness() + h.images = [relatedImage()] + h.simulatorInspector.live = [device()] + let flow = h.flow + guard case .preview(let plan) = flow.run(indexingOn: nil) else { return XCTFail("active simulator needs confirmation") } + var reads = 0 + h.simulatorInspector.onList = { + reads += 1 + // Keep it active in the execution refresh, then remove it before the + // per-device state check. Missing inventory must fail closed. + if reads == 2 { h.simulatorInspector.live.removeAll() } + } + + guard case .aborted = flow.execute(plan, mode: .prepared) else { + return XCTFail("missing simulator state must abort") + } + XCTAssertTrue(h.simulatorInspector.shutdowns.isEmpty) + XCTAssertFalse(h.calls.contains { $0.hasPrefix("hdiutil detach") || $0.hasPrefix("diskutil eject") }) + } +} diff --git a/docs/how-it-works.md b/docs/how-it-works.md index ab8e32f..5aabf35 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -24,15 +24,18 @@ Clicking **Safe Eject** first runs a read-only check. If nothing needs handling, | Nothing to prepare | Tries a normal `diskutil eject` and lets macOS decide | | Something it can handle | Shows the target drive, what it will act on, the file evidence, and the impact; acts after you confirm | | Incomplete check | Re-check, cancel, or explicitly choose "Only try a system eject" | +| A target-related simulator is booted, or simulator state cannot be verified | Normally shut it down and verify first; if the service or check fails, stop without system-only or force bypass | | Current-user terminal tasks and unknown background tasks with verified identities | Offers unchecked termination choices, with a warning that work may be interrupted | | Unverifiable processes | Leaves processes alone; normal system eject remains available | | Writable, read-only or unknown-access images | Normal detach after confirmation, including attached but unmounted images | | Several mounted volumes on the same physical drive | Lists related volumes; ordinary eject does not close processes. After a normal refusal, force requires a separate confirmation | | The target changed, isn't an external physical drive, or its layout can't be confirmed | Stops and hands it back to you | -"Only try a system eject" doesn't quit apps, stop services, or detach images, and never uses force. When a check is incomplete, DevDisk doesn't claim nobody is using the drive. +"Only try a system eject" doesn't quit apps, stop services, or detach images, and never uses force. It is disabled while a target-related simulator is active or its state is unknown. When a check is incomplete, DevDisk doesn't claim nobody is using the drive. -After you confirm, the order is: confirm the target and scope → ask GUI apps to quit → stop the specific services → normally detach related images → re-check → system eject → verify images are detached and related volumes are unmounted. Fixed external devices can remain in the system disk list. +After you confirm, the order is: confirm the target and scope → normally shut down and verify booted Apple simulators stored on the target → ask GUI apps to quit → stop the specific services → normally detach related images → re-check → system eject → verify images are detached and related volumes are unmounted. Fixed external devices can remain in the system disk list. + +Simulator scope is established from actual device-data, runtime, and related-image paths. Only devices in the confirmed target scope are shut down. If the device inventory, runtime path, or CoreSimulatorService cannot be verified, the state stays unknown: DevDisk does not detach images or eject the disk, and system-only or force actions cannot bypass the check. You can shut down simulators manually in Xcode Device Hub and scan again. ### What DevDisk may handle for you @@ -48,7 +51,7 @@ A refused system eject retains its raw error, stage, blocking PID, and completed ### Force eject after an ordinary failure -After a definite normal detach or disk-eject refusal, **Force eject…** performs a fresh read-only check and lists every affected volume and image. The user must confirm the risk of lost unsaved data or damaged images. Confirmation applies only to this operation; an expired confirmation or expanded scope requires another review. +After a definite normal detach or disk-eject refusal, **Force eject…** performs a fresh read-only check and lists every affected volume and image. The user must confirm the risk of lost unsaved data or damaged images. Confirmation applies only to this operation; an expired confirmation or expanded scope requires another review. An active or unverifiable target-related simulator blocks force eject. The sequence is force detach dependent images, verify they are detached, `diskutil unmountDisk force`, then ordinary `diskutil eject`. It never force-kills apps or system services and never elevates privileges. After unmount, the original UUIDs, device identifiers and physical-store mapping are checked without relying on vanished mount paths. Completed detach and unmount steps are not repeated. diff --git a/docs/how-it-works.zh-CN.md b/docs/how-it-works.zh-CN.md index 12a0126..8b0e8e0 100644 --- a/docs/how-it-works.zh-CN.md +++ b/docs/how-it-works.zh-CN.md @@ -24,15 +24,18 @@ | 没有可见的准备操作 | 直接尝试普通 `diskutil eject`,由系统判断是否允许 | | 有可处理的占用对象 | 显示目标盘、操作对象、文件证据及影响,确认后处理 | | 检测不完整 | 重新检测、取消,或明确选择「尝试普通系统弹出」 | +| 目标盘相关模拟器运行中,或模拟器状态无法核实 | 正常关机并核验后才继续;服务不可用或核验失败时停止,不允许系统弹出或强制流程绕过 | | 身份已核验的当前用户终端任务、未知后台任务 | 提供默认未勾选的终止选项,提醒任务可能中断 | | 无法核验的进程 | 不操作进程,可尝试普通系统弹出 | | 可写、只读或属性未知的映像 | 确认后尝试普通推出,包含未挂载但仍连接的映像 | | 同一物理盘有多个挂载卷 | 列出关联卷,普通流程不自动清理进程;普通弹出失败后可单独确认强制 | | 目标身份变化、不是外置物理盘或拓扑无法确认 | 停止流程,交给用户检查 | -「尝试普通系统弹出」不退出应用、不停止服务、不主动推出映像,不使用 force。检测不完整时,应用没有声称盘上无人占用。 +「尝试普通系统弹出」不退出应用、不停止服务、不主动推出映像,不使用 force。若目标盘关联的模拟器仍运行或状态未知,该入口会被禁用。其他检测不完整时,应用没有声称盘上无人占用。 -确认后的顺序:确认目标与操作范围 → 请求 GUI 应用退出 → 停止限定服务 → 普通推出关联映像 → 复查 → 系统弹出 → 核验映像断开及关联卷卸载;固定外置设备可以仍保留在系统磁盘列表中。 +确认后的顺序:确认目标与操作范围 → 正常关闭目标盘存储相关的已启动 Apple 模拟器并核验 → 请求 GUI 应用退出 → 停止限定服务 → 普通推出关联映像 → 复查 → 系统弹出 → 核验映像断开及关联卷卸载;固定外置设备可以仍保留在系统磁盘列表中。 + +模拟器范围按设备数据目录、运行时目录和目标盘关联映像的实际路径核验,只关闭确认后列出的设备。设备清单、运行时路径或 CoreSimulatorService 无法核实时,状态按未知处理;DevDisk 不会推出映像或磁盘,也不允许「仅尝试系统弹出」或强制操作绕过。你可以在 Xcode Device Hub 手动关闭模拟器后重新检测。 ### 哪些对象允许代为处理