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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@

- **菜单栏上直接看状态**:已连接、有问题要注意、正在推出,还是可以拔了。
- **找出是谁在用这块盘**:列出正在占用盘上文件的程序,分成「确认后能帮你关」和「需要你自己关」两类。
- **按正确的顺序安全推出**:你确认之后,它会请 Xcode、Android Studio 这类应用正常退出(没保存的内容照样会提醒你保存),停掉 Gradle、Kotlin 后台进程和 adb,然后推出硬盘,确认硬盘真的断开了,才告诉你可以拔线。
- **按正确的顺序安全推出**:你确认之后,它会先正常关闭存储在目标盘上的 Apple 模拟器,再请 Xcode、Android Studio 这类应用正常退出(没保存的内容照样会提醒你保存),停掉 Gradle、Kotlin 后台进程和 adb,然后推出硬盘,确认硬盘真的断开了,才告诉你可以拔线。
- **给盘做个体检**:剩余空间和空间都被什么占了、硬盘健康(剩余寿命、温度、异常断电次数)、有没有加密,以及 Time Machine、Spotlight、磁盘休眠这些设置会不会添乱。
- **提醒一个常见的坑**:盘没插的时候,提醒你别打开 Android Studio——它可能以为 SDK 丢了,把整套 SDK 重新下载到电脑自带的硬盘上。

Expand Down
1 change: 1 addition & 0 deletions Sources/DevDiskKit/Core/EjectFailure.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
120 changes: 110 additions & 10 deletions Sources/DevDiskKit/Core/EjectFlow.swift

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Sources/DevDiskKit/Core/Shell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? = {
Expand Down
90 changes: 90 additions & 0 deletions Sources/DevDiskKit/Core/Simulator.swift
Original file line number Diff line number Diff line change
@@ -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<String>()
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) }
}
}
}
18 changes: 18 additions & 0 deletions Sources/DevDiskKit/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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}";
Expand Down Expand Up @@ -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";
Expand Down
18 changes: 18 additions & 0 deletions Sources/DevDiskKit/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "普通推出关联磁盘映像";
Expand All @@ -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} 个应用";
Expand Down Expand Up @@ -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" = "重新检测";
Expand Down
32 changes: 26 additions & 6 deletions Sources/DevDiskKit/Views/DemoFixture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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) }
}
Expand All @@ -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") {
Expand Down Expand Up @@ -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
Expand Down
Loading