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
191 changes: 191 additions & 0 deletions Sources/macosdb/CleanupCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import ArgumentParser
import Foundation

struct CleanupCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "cleanup",
abstract: "Find and remove leftover temp directories and mounted DMGs from aborted scans."
)

@Flag(name: .shortAndLong, help: "Actually unmount and delete (default is dry-run).")
var force = false

func run() async throws {
let mounts = findStaleMounts()
let tempDirs = findStaleTempDirs()

if mounts.isEmpty && tempDirs.isEmpty {
printStatus("Nothing to clean up.")
return
}

if !mounts.isEmpty {
printStatus("Mounted DMGs from scans:")
for mount in mounts {
printStatus(" \(mount.mountPoint) (\(mount.deviceNode))")
printStatus(" source: \(mount.imagePath)")
}
printStatus("")
}

if !tempDirs.isEmpty {
printStatus("Stale temp directories:")
for dir in tempDirs {
printStatus(" \(dir.path)")
}
printStatus("")
}

if !force {
printStatus("Run with --force to clean up.")
return
}

for mount in mounts {
unmount(mount)
}

removeDirectories(tempDirs)
}

// MARK: - Stale mount detection

private struct StaleMount {
let imagePath: String
let mountPoint: String
let deviceNode: String
}

private func findStaleMounts() -> [StaleMount] {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil")
process.arguments = ["info", "-plist"]

let stdout = Pipe()
process.standardOutput = stdout
process.standardError = FileHandle.nullDevice

do {
try process.run()
process.waitUntilExit()
} catch {
return []
}

guard process.terminationStatus == 0 else { return [] }

let data = stdout.fileHandleForReading.readDataToEndOfFile()
guard let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any],
let images = plist["images"] as? [[String: Any]] else {
return []
}

var results: [StaleMount] = []
for image in images {
guard let imagePath = image["image-path"] as? String,
imagePath.contains("macosdb-"),
let entities = image["system-entities"] as? [[String: Any]] else {
continue
}

for entity in entities {
guard let mountPoint = entity["mount-point"] as? String,
let deviceNode = entity["dev-entry"] as? String else {
continue
}
results.append(StaleMount(
imagePath: imagePath,
mountPoint: mountPoint,
deviceNode: deviceNode
))
}
}

return results
}

private func unmount(_ mount: StaleMount) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil")
process.arguments = ["detach", mount.deviceNode, "-force"]
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice

do {
try process.run()
process.waitUntilExit()
if process.terminationStatus == 0 {
printStatus("Unmounted \(mount.mountPoint)")
} else {
printStatus("Failed to unmount \(mount.mountPoint)")
}
} catch {
printStatus("Failed to unmount \(mount.mountPoint): \(error.localizedDescription)")
}
}

// MARK: - Stale temp directory detection

private func findStaleTempDirs() -> [URL] {
let tempDir = FileManager.default.temporaryDirectory
guard let contents = try? FileManager.default.contentsOfDirectory(
at: tempDir,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
) else {
return []
}

return contents.filter { url in
let name = url.lastPathComponent
guard name.hasPrefix("macosdb-") else { return false }
return (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
}.sorted { $0.path < $1.path }
}

// MARK: - Temp directory removal

private func removeDirectories(_ urls: [URL]) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/rm")
process.arguments = ["-rf"] + urls.map(\.path)
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice

let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
var frame = 0

do {
try process.run()
} catch {
printStatus("Failed to remove directories: \(error.localizedDescription)")
return
}

while process.isRunning {
printInline("\(spinner[frame % spinner.count]) Removing \(urls.count) directory(s)...")
frame += 1
Thread.sleep(forTimeInterval: 0.1)
}

printInline("")
if process.terminationStatus == 0 {
for url in urls {
printStatus("Removed \(url.lastPathComponent)")
}
} else {
printStatus("Failed to remove directories")
}
}

// MARK: - Helpers

private func printStatus(_ message: String) {
FileHandle.standardError.write(Data((message + "\n").utf8))
}

private func printInline(_ message: String) {
let line = message.isEmpty ? "\r\u{1B}[K" : "\r\(message)"
FileHandle.standardError.write(Data(line.utf8))
}
}
2 changes: 1 addition & 1 deletion Sources/macosdb/MacOSdb.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ struct MacOSdb: AsyncParsableCommand {
commandName: "macosdb",
abstract: "Browse and compare open source components bundled in macOS and Xcode releases.",
version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev",
subcommands: [ListCommand.self, ShowCommand.self, CompareCommand.self, ScanCommand.self, ValidateCommand.self]
subcommands: [CleanupCommand.self, CompareCommand.self, ListCommand.self, ScanCommand.self, ShowCommand.self, ValidateCommand.self]
)
}
21 changes: 14 additions & 7 deletions Sources/macosdb/ValidateCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,21 @@ struct ValidateCommand: AsyncParsableCommand {
var bytesRead = 0

while true {
let chunk = handle.readData(ofLength: bufSize)
guard !chunk.isEmpty else { break }
hasher.update(data: chunk)
bytesRead += chunk.count
if fileSize > 0 {
let pct = bytesRead * 100 / fileSize
printInline(" Hashing... \(pct)%")
var finished = false
autoreleasepool {
let chunk = handle.readData(ofLength: bufSize)
guard !chunk.isEmpty else {
finished = true
return
}
hasher.update(data: chunk)
bytesRead += chunk.count
if fileSize > 0 {
let pct = bytesRead * 100 / fileSize
printInline(" Hashing... \(pct)%")
}
}
if finished { break }
}

printInline("")
Expand Down
4 changes: 4 additions & 0 deletions macOSdb.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
CDAA000D2F6A0001005A8490 /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = CDAA00112F6A0001005A8490 /* ArgumentParser */; };
CDAA00222F6A0002005A8490 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CDAA00202F6A0002005A8490 /* Assets.xcassets */; };
CDC3BCAD2F8360AC00380644 /* ValidateCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC3BCAC2F8360AC00380644 /* ValidateCommand.swift */; };
CDC4C3AB2F84C68400425D25 /* CleanupCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC4C3AA2F84C68400425D25 /* CleanupCommand.swift */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand All @@ -46,6 +47,7 @@
CDAA00062F6A0001005A8490 /* EntryPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryPoint.swift; sourceTree = "<group>"; };
CDAA00202F6A0002005A8490 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
CDC3BCAC2F8360AC00380644 /* ValidateCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateCommand.swift; sourceTree = "<group>"; };
CDC4C3AA2F84C68400425D25 /* CleanupCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupCommand.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -115,6 +117,7 @@
CDAA000E2F6A0001005A8490 /* CommandLine */ = {
isa = PBXGroup;
children = (
CDC4C3AA2F84C68400425D25 /* CleanupCommand.swift */,
CDAA00022F6A0001005A8490 /* CompareCommand.swift */,
CDAA00012F6A0001005A8490 /* ListCommand.swift */,
CDAA00042F6A0001005A8490 /* ScanCommand.swift */,
Expand Down Expand Up @@ -248,6 +251,7 @@
CD7EEB882F5F8038005A8490 /* CompareView.swift in Sources */,
CD7EEB892F5F8038005A8490 /* ReleaseDetailView.swift in Sources */,
CD7EEB8A2F5F8038005A8490 /* ChipSupportView.swift in Sources */,
CDC4C3AB2F84C68400425D25 /* CleanupCommand.swift in Sources */,
CD7EEB8B2F5F8038005A8490 /* AppState.swift in Sources */,
CDAA000C2F6A0001005A8490 /* EntryPoint.swift in Sources */,
CDAA00072F6A0001005A8490 /* ListCommand.swift in Sources */,
Expand Down
2 changes: 1 addition & 1 deletion macOSdbApp/Bootstrap/EntryPoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ struct MacOSdbCLI: AsyncParsableCommand {
commandName: "macosdb",
abstract: "Browse and compare open source components bundled in macOS releases.",
version: appVersion,
subcommands: [ListCommand.self, ShowCommand.self, CompareCommand.self, ScanCommand.self, ValidateCommand.self]
subcommands: [CleanupCommand.self, CompareCommand.self, ListCommand.self, ScanCommand.self, ShowCommand.self, ValidateCommand.self]
)

/// Falls back to the enclosing `.app` bundle when `Bundle.main` misses (e.g. symlink invocation).
Expand Down
Loading