diff --git a/Sources/macosdb/CleanupCommand.swift b/Sources/macosdb/CleanupCommand.swift new file mode 100644 index 0000000..afd5912 --- /dev/null +++ b/Sources/macosdb/CleanupCommand.swift @@ -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)) + } +} diff --git a/Sources/macosdb/MacOSdb.swift b/Sources/macosdb/MacOSdb.swift index b66dcc0..6cc26ca 100644 --- a/Sources/macosdb/MacOSdb.swift +++ b/Sources/macosdb/MacOSdb.swift @@ -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] ) } diff --git a/Sources/macosdb/ValidateCommand.swift b/Sources/macosdb/ValidateCommand.swift index 62c69d0..2994b00 100644 --- a/Sources/macosdb/ValidateCommand.swift +++ b/Sources/macosdb/ValidateCommand.swift @@ -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("") diff --git a/macOSdb.xcodeproj/project.pbxproj b/macOSdb.xcodeproj/project.pbxproj index b9f8ed0..635ae01 100644 --- a/macOSdb.xcodeproj/project.pbxproj +++ b/macOSdb.xcodeproj/project.pbxproj @@ -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 */ @@ -46,6 +47,7 @@ CDAA00062F6A0001005A8490 /* EntryPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryPoint.swift; sourceTree = ""; }; CDAA00202F6A0002005A8490 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; CDC3BCAC2F8360AC00380644 /* ValidateCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateCommand.swift; sourceTree = ""; }; + CDC4C3AA2F84C68400425D25 /* CleanupCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanupCommand.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -115,6 +117,7 @@ CDAA000E2F6A0001005A8490 /* CommandLine */ = { isa = PBXGroup; children = ( + CDC4C3AA2F84C68400425D25 /* CleanupCommand.swift */, CDAA00022F6A0001005A8490 /* CompareCommand.swift */, CDAA00012F6A0001005A8490 /* ListCommand.swift */, CDAA00042F6A0001005A8490 /* ScanCommand.swift */, @@ -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 */, diff --git a/macOSdbApp/Bootstrap/EntryPoint.swift b/macOSdbApp/Bootstrap/EntryPoint.swift index 60e4053..ecc5fbf 100644 --- a/macOSdbApp/Bootstrap/EntryPoint.swift +++ b/macOSdbApp/Bootstrap/EntryPoint.swift @@ -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).