mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-02 02:34:29 +00:00
The library the consolidated CLI is built on: - bundle/library model (VPhoneBundle, VPhoneLibrary, VPhoneVirtualMachineManifest), bundle ops + reporting, restore helpers (VPhoneRestoreOps) - host process primitives: VPhoneProcessRunner, VPhoneManagedProcess (spawn + stdout pattern-match + SIGKILL-escalating terminate), VPhoneLaunchLayout, VPhoneBootPatterns - VPhoneResources: bundled-.app vs dev asset resolution, and Python resolution that provisions a per-user venv (~/.vphone/venv) on demand so the app is portable — never depends on the repo's .venv - VPhoneVerbosity (quiet/info/debug/trace) and VPhoneVMPicker Manifest moves out of the executable target into VPhoneCore. Full unit-test suite under tests/VPhoneCoreTests. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01Y4VDqWf5pVakcFLqB23CKe
83 lines
2.9 KiB
Swift
83 lines
2.9 KiB
Swift
import Foundation
|
|
|
|
public enum VPhoneLibraryError: Error, Equatable {
|
|
case notFound(name: String)
|
|
case alreadyExists(name: String)
|
|
case invalidName(String)
|
|
}
|
|
|
|
extension VPhoneLibraryError: CustomStringConvertible, LocalizedError {
|
|
public var description: String {
|
|
switch self {
|
|
case let .notFound(name): "VM '\(name)' not found"
|
|
case let .alreadyExists(name): "VM '\(name)' already exists"
|
|
case let .invalidName(name): "Invalid VM name '\(name)' (must be non-empty, contain no '/', and not start with '.')"
|
|
}
|
|
}
|
|
public var errorDescription: String? { description }
|
|
}
|
|
|
|
public struct VPhoneLibrarySkip: Sendable {
|
|
public let name: String
|
|
public let reason: String
|
|
|
|
public init(name: String, reason: String) {
|
|
self.name = name
|
|
self.reason = reason
|
|
}
|
|
}
|
|
|
|
// MARK: - Library
|
|
|
|
public struct VPhoneLibrary: Sendable {
|
|
public let root: URL
|
|
|
|
public init(root: URL) { self.root = root }
|
|
|
|
public static func defaultRoot() -> URL {
|
|
if let override = ProcessInfo.processInfo.environment["VPHONE_LIBRARY_ROOT"] {
|
|
return URL(fileURLWithPath: override, isDirectory: true)
|
|
}
|
|
// `~/.vphone/VMs` — deliberately space-free: bundle paths flow into the
|
|
// shell/make firmware pipeline, and "Application Support" (a space) breaks
|
|
// any unquoted expansion there. Keep the default path shell-safe.
|
|
return FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent(".vphone/VMs", isDirectory: true)
|
|
}
|
|
|
|
public func url(forName name: String) -> URL {
|
|
root.appendingPathComponent(name, isDirectory: true)
|
|
}
|
|
|
|
public func scan() throws -> (bundles: [VPhoneBundle], skipped: [VPhoneLibrarySkip]) {
|
|
let fm = FileManager.default
|
|
guard fm.fileExists(atPath: root.path) else { return ([], []) }
|
|
let entries = try fm.contentsOfDirectory(
|
|
at: root, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
|
|
var bundles: [VPhoneBundle] = []
|
|
var skipped: [VPhoneLibrarySkip] = []
|
|
for url in entries
|
|
where fm.fileExists(atPath: url.appendingPathComponent("config.plist").path) {
|
|
do {
|
|
bundles.append(try VPhoneBundle.load(at: url))
|
|
} catch {
|
|
skipped.append(VPhoneLibrarySkip(name: url.lastPathComponent, reason: "\(error)"))
|
|
}
|
|
}
|
|
return (bundles.sorted { $0.name < $1.name }, skipped.sorted { $0.name < $1.name })
|
|
}
|
|
|
|
public func bundles() throws -> [VPhoneBundle] {
|
|
try scan().bundles
|
|
}
|
|
|
|
public func bundle(named name: String) throws -> VPhoneBundle {
|
|
let url = url(forName: name)
|
|
guard FileManager.default.fileExists(
|
|
atPath: url.appendingPathComponent("config.plist").path) else {
|
|
throw VPhoneLibraryError.notFound(name: name)
|
|
}
|
|
return try VPhoneBundle.load(at: url)
|
|
}
|
|
}
|