Files
vphone-cli/sources/VPhoneCore/VPhoneLibrary.swift
T
zqxwceandClaude Opus 4.8 828be6dbc6 feat: add VPhoneCore library — VM bundle model + host primitives
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
2026-07-27 19:46:27 +03:00

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)
}
}