chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Defines an AI coding agent that can be run inside a sandbox container.
|
||||
///
|
||||
/// Agent definitions can be built-in or loaded from JSON files in the
|
||||
/// `agents/` subdirectory of the sandboxy config directory.
|
||||
///
|
||||
/// Location: `~/.config/sandboxy/agents/<name>.json`
|
||||
///
|
||||
/// Example (`foo.json`):
|
||||
/// ```json
|
||||
/// {
|
||||
/// "displayName": "Foo",
|
||||
/// "baseImage": "docker.io/library/python:3.12-slim",
|
||||
/// "installCommands": [
|
||||
/// "pip install foo"
|
||||
/// ],
|
||||
/// "launchCommand": ["foo"],
|
||||
/// "environmentVariables": [],
|
||||
/// "mounts": [
|
||||
/// {"hostPath": "~/.foo", "containerPath": "/root/.foo", "readOnly": true}
|
||||
/// ],
|
||||
/// "allowedHosts": ["api.example.com", "*.cdn.example.com"]
|
||||
/// }
|
||||
/// ```
|
||||
struct AgentDefinition: Codable, Sendable {
|
||||
/// Human-readable name used in output messages.
|
||||
let displayName: String
|
||||
|
||||
/// The base container image reference (e.g., "docker.io/library/node:22").
|
||||
let baseImage: String
|
||||
|
||||
/// Shell commands run sequentially inside the container to install the agent
|
||||
/// and its dependencies. Each string is passed as an argument to `sh -c`.
|
||||
let installCommands: [String]
|
||||
|
||||
/// The command and arguments to launch the agent interactively.
|
||||
let launchCommand: [String]
|
||||
|
||||
/// Environment variables required by the agent (key=value format).
|
||||
let environmentVariables: [String]
|
||||
|
||||
/// Host paths to mount into the container. Each entry specifies a host path
|
||||
/// and a container path. Paths starting with `~` are expanded to the user's
|
||||
/// home directory. Only mounted if the host path exists.
|
||||
let mounts: [AgentMount]
|
||||
|
||||
/// Default hostnames to allow through the network filtering proxy.
|
||||
/// Supports exact matches and `*.suffix` wildcard patterns.
|
||||
/// Merged with CLI `--allow-hosts` values.
|
||||
/// An empty list means all traffic is denied. Use `--no-network-filter` to disable filtering.
|
||||
let allowedHosts: [String]
|
||||
}
|
||||
|
||||
/// A host-to-container mount for an agent definition.
|
||||
struct AgentMount: Codable, Sendable {
|
||||
/// Path on the host. Supports `~` for the user's home directory.
|
||||
let hostPath: String
|
||||
|
||||
/// Path inside the container where the host path is mounted.
|
||||
let containerPath: String
|
||||
|
||||
/// Whether the mount is read-only. Defaults to `false`.
|
||||
let readOnly: Bool
|
||||
|
||||
init(hostPath: String, containerPath: String, readOnly: Bool = false) {
|
||||
self.hostPath = hostPath
|
||||
self.containerPath = containerPath
|
||||
self.readOnly = readOnly
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
hostPath = try container.decode(String.self, forKey: .hostPath)
|
||||
containerPath = try container.decode(String.self, forKey: .containerPath)
|
||||
readOnly = try container.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false
|
||||
}
|
||||
|
||||
/// Returns the resolved absolute host path, expanding `~`.
|
||||
var resolvedHostPath: String {
|
||||
if hostPath.hasPrefix("~/") {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path(percentEncoded: false)
|
||||
return home + String(hostPath.dropFirst(1))
|
||||
}
|
||||
if hostPath == "~" {
|
||||
return FileManager.default.homeDirectoryForCurrentUser.path(percentEncoded: false)
|
||||
}
|
||||
return hostPath
|
||||
}
|
||||
}
|
||||
|
||||
extension AgentDefinition {
|
||||
/// Built-in agent definitions, keyed by their CLI name.
|
||||
static let builtIn: [String: AgentDefinition] = [
|
||||
"claude": .claude
|
||||
]
|
||||
|
||||
/// Returns all available agents: built-in definitions merged with any
|
||||
/// user-defined agents from `<configRoot>/agents/`. If a user file matches
|
||||
/// a built-in agent name, non-nil fields from the user file override the
|
||||
/// built-in values, allowing partial overrides.
|
||||
static func allAgents(configRoot: URL) -> [String: AgentDefinition] {
|
||||
var agents = builtIn
|
||||
|
||||
let agentsDir = configRoot.appendingPathComponent("agents")
|
||||
let agentsDirPath = agentsDir.path(percentEncoded: false)
|
||||
guard FileManager.default.fileExists(atPath: agentsDirPath) else {
|
||||
return agents
|
||||
}
|
||||
|
||||
do {
|
||||
let files = try FileManager.default.contentsOfDirectory(
|
||||
at: agentsDir,
|
||||
includingPropertiesForKeys: nil
|
||||
).filter { $0.pathExtension == "json" }
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
for file in files {
|
||||
let name = file.deletingPathExtension().lastPathComponent
|
||||
do {
|
||||
let data = try Data(contentsOf: file)
|
||||
let override = try decoder.decode(AgentOverride.self, from: data)
|
||||
if let base = agents[name] {
|
||||
agents[name] = override.merged(onto: base)
|
||||
} else {
|
||||
agents[name] = try override.asFullDefinition()
|
||||
}
|
||||
} catch {
|
||||
ProgressUI.printWarning(
|
||||
"Failed to load agent definition from \(file.lastPathComponent): \(error)")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
ProgressUI.printWarning("Failed to read agents directory: \(error)")
|
||||
}
|
||||
|
||||
return agents
|
||||
}
|
||||
|
||||
/// Returns the sorted list of available agent names for display in help text.
|
||||
static func knownAgentNames(configRoot: URL) -> [String] {
|
||||
allAgents(configRoot: configRoot).keys.sorted()
|
||||
}
|
||||
|
||||
static let claude = AgentDefinition(
|
||||
displayName: "Claude Code",
|
||||
baseImage: "docker.io/library/node:22",
|
||||
installCommands: [
|
||||
"apt-get update && apt-get install -y --no-install-recommends less git procps sudo fzf zsh man-db unzip gnupg2 gh ipset iproute2 dnsutils aggregate jq nano vim ripgrep ca-certificates && apt-get clean && rm -rf /var/lib/apt/lists/*",
|
||||
"npm install -g @anthropic-ai/claude-code",
|
||||
"npm install -g global-agent",
|
||||
],
|
||||
launchCommand: ["claude", "--dangerously-skip-permissions"],
|
||||
environmentVariables: [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"NODE_OPTIONS=--max-old-space-size=4096",
|
||||
"IS_SANDBOX=1",
|
||||
],
|
||||
mounts: [
|
||||
AgentMount(hostPath: "~/.claude", containerPath: "/root/.claude")
|
||||
],
|
||||
allowedHosts: [
|
||||
"*.anthropic.com",
|
||||
"*.claude.com",
|
||||
"npm.org",
|
||||
"*.npmjs.org",
|
||||
"*.github.com",
|
||||
"*.githubusercontent.com",
|
||||
"*.pypi.org",
|
||||
"*.pythonhosted.org",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// All-optional mirror of `AgentDefinition` used when loading user override files.
|
||||
/// For agents that match a built-in name, only the non-nil fields override the defaults.
|
||||
/// For entirely new agents, all required fields must be provided.
|
||||
struct AgentOverride: Codable, Sendable {
|
||||
var displayName: String?
|
||||
var baseImage: String?
|
||||
var installCommands: [String]?
|
||||
var launchCommand: [String]?
|
||||
var environmentVariables: [String]?
|
||||
var mounts: [AgentMount]?
|
||||
var allowedHosts: [String]?
|
||||
|
||||
/// Merges this override onto a base definition, replacing only the fields
|
||||
/// that are non-nil in the override.
|
||||
func merged(onto base: AgentDefinition) -> AgentDefinition {
|
||||
AgentDefinition(
|
||||
displayName: displayName ?? base.displayName,
|
||||
baseImage: baseImage ?? base.baseImage,
|
||||
installCommands: installCommands ?? base.installCommands,
|
||||
launchCommand: launchCommand ?? base.launchCommand,
|
||||
environmentVariables: environmentVariables ?? base.environmentVariables,
|
||||
mounts: mounts ?? base.mounts,
|
||||
allowedHosts: allowedHosts ?? base.allowedHosts
|
||||
)
|
||||
}
|
||||
|
||||
/// Converts this override into a full definition, throwing if any required
|
||||
/// fields are missing. Used for entirely new (non-built-in) agents.
|
||||
func asFullDefinition() throws -> AgentDefinition {
|
||||
guard let displayName, let baseImage, let installCommands,
|
||||
let launchCommand
|
||||
else {
|
||||
throw SandboxyError.incompleteAgentDefinition(
|
||||
missing: [
|
||||
displayName == nil ? "displayName" : nil,
|
||||
baseImage == nil ? "baseImage" : nil,
|
||||
installCommands == nil ? "installCommands" : nil,
|
||||
launchCommand == nil ? "launchCommand" : nil,
|
||||
].compactMap { $0 }
|
||||
)
|
||||
}
|
||||
|
||||
return AgentDefinition(
|
||||
displayName: displayName,
|
||||
baseImage: baseImage,
|
||||
installCommands: installCommands,
|
||||
launchCommand: launchCommand,
|
||||
environmentVariables: environmentVariables ?? [],
|
||||
mounts: mounts ?? [],
|
||||
allowedHosts: allowedHosts ?? []
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
extension Sandboxy {
|
||||
struct Cache: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "cache",
|
||||
abstract: "Manage cached rootfs images",
|
||||
subcommands: [
|
||||
CacheList.self,
|
||||
CacheRemove.self,
|
||||
CacheClean.self,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
struct CacheList: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "list",
|
||||
abstract: "List cached rootfs images and named instance state"
|
||||
)
|
||||
|
||||
func run() async throws {
|
||||
_ = try Sandboxy.loadConfig()
|
||||
|
||||
let cacheDir = Sandboxy.appRoot.appendingPathComponent("cache")
|
||||
let namedDir = InstanceState.namedRootfsDir(appRoot: Sandboxy.appRoot)
|
||||
|
||||
let agentCaches = listRootfsFiles(in: cacheDir, suffix: "-rootfs.ext4")
|
||||
let namedCaches = listRootfsFiles(in: namedDir, suffix: "-rootfs.ext4")
|
||||
|
||||
if agentCaches.isEmpty && namedCaches.isEmpty {
|
||||
print("No cached images found.")
|
||||
return
|
||||
}
|
||||
|
||||
if !agentCaches.isEmpty {
|
||||
print("Agent caches:")
|
||||
print(pad(" NAME", to: 20) + pad("SIZE", to: 12) + "MODIFIED")
|
||||
for entry in agentCaches {
|
||||
print(
|
||||
pad(" \(entry.name)", to: 20)
|
||||
+ pad(formatBytes(entry.diskSize), to: 12)
|
||||
+ entry.modified
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if !namedCaches.isEmpty {
|
||||
if !agentCaches.isEmpty { print() }
|
||||
print("Named instances:")
|
||||
print(pad(" NAME", to: 20) + pad("SIZE", to: 12) + "MODIFIED")
|
||||
for entry in namedCaches {
|
||||
print(
|
||||
pad(" \(entry.name)", to: 20)
|
||||
+ pad(formatBytes(entry.diskSize), to: 12)
|
||||
+ entry.modified
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CacheEntry {
|
||||
let name: String
|
||||
let diskSize: UInt64
|
||||
let modified: String
|
||||
}
|
||||
|
||||
private func listRootfsFiles(in dir: URL, suffix: String) -> [CacheEntry] {
|
||||
let dirPath = dir.path(percentEncoded: false)
|
||||
guard FileManager.default.fileExists(atPath: dirPath) else {
|
||||
return []
|
||||
}
|
||||
|
||||
do {
|
||||
let files = try FileManager.default.contentsOfDirectory(
|
||||
at: dir,
|
||||
includingPropertiesForKeys: [
|
||||
.totalFileAllocatedSizeKey,
|
||||
.contentModificationDateKey,
|
||||
]
|
||||
).filter { $0.lastPathComponent.hasSuffix(suffix) }
|
||||
|
||||
return files.compactMap { file -> CacheEntry? in
|
||||
let cleanName = file.lastPathComponent
|
||||
.replacingOccurrences(of: suffix, with: "")
|
||||
|
||||
do {
|
||||
let values = try file.resourceValues(forKeys: [
|
||||
.totalFileAllocatedSizeKey,
|
||||
.contentModificationDateKey,
|
||||
])
|
||||
let diskSize = UInt64(values.totalFileAllocatedSize ?? 0)
|
||||
let date = values.contentModificationDate ?? Date()
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .short
|
||||
return CacheEntry(
|
||||
name: cleanName,
|
||||
diskSize: diskSize,
|
||||
modified: formatter.string(from: date)
|
||||
)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}.sorted { $0.name < $1.name }
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func pad(_ s: String, to width: Int) -> String {
|
||||
if s.count >= width { return s }
|
||||
return s + String(repeating: " ", count: width - s.count)
|
||||
}
|
||||
|
||||
private func formatBytes(_ bytes: UInt64) -> String {
|
||||
if bytes < 1024 { return "\(bytes) B" }
|
||||
let kb = Double(bytes) / 1024
|
||||
if kb < 1024 { return String(format: "%.1f KB", kb) }
|
||||
let mb = kb / 1024
|
||||
if mb < 1024 { return String(format: "%.1f MB", mb) }
|
||||
let gb = mb / 1024
|
||||
return String(format: "%.1f GB", gb)
|
||||
}
|
||||
}
|
||||
|
||||
struct CacheRemove: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "rm",
|
||||
abstract: "Remove a specific agent cache"
|
||||
)
|
||||
|
||||
@Argument(help: "Name of the agent cache to remove")
|
||||
var name: String
|
||||
|
||||
func run() async throws {
|
||||
_ = try Sandboxy.loadConfig()
|
||||
|
||||
let cacheDir = Sandboxy.appRoot.appendingPathComponent("cache")
|
||||
let cachePath = cacheDir.appendingPathComponent("\(name)-rootfs.ext4")
|
||||
|
||||
guard FileManager.default.fileExists(atPath: cachePath.path(percentEncoded: false)) else {
|
||||
print("No cache found for agent '\(name)'.")
|
||||
throw ExitCode.failure
|
||||
}
|
||||
|
||||
try FileManager.default.removeItem(at: cachePath)
|
||||
print("Removed cache for '\(name)'.")
|
||||
}
|
||||
}
|
||||
|
||||
struct CacheClean: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "clean",
|
||||
abstract: "Remove all cached rootfs images (use --all to also remove kernel, init image, and content store)"
|
||||
)
|
||||
|
||||
@Flag(name: .long, help: "Also remove kernel, init image, and content store, forcing a full re-download on next run")
|
||||
var all = false
|
||||
|
||||
@Flag(name: .long, help: "Skip confirmation prompts")
|
||||
var yes = false
|
||||
|
||||
func run() async throws {
|
||||
_ = try Sandboxy.loadConfig()
|
||||
|
||||
let fm = FileManager.default
|
||||
|
||||
// Check for named instances and warn the user before deleting them.
|
||||
let namedDir = InstanceState.namedRootfsDir(appRoot: Sandboxy.appRoot)
|
||||
let namedInstances = listNamedInstances(in: namedDir)
|
||||
if !namedInstances.isEmpty && !yes {
|
||||
print("This will also delete the following named instances:")
|
||||
for name in namedInstances {
|
||||
print(" - \(name)")
|
||||
}
|
||||
print()
|
||||
print("Are you sure? [y/N] ", terminator: "")
|
||||
guard let response = readLine()?.lowercased(), response == "y" || response == "yes" else {
|
||||
print("Aborted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let cacheDir = Sandboxy.appRoot.appendingPathComponent("cache")
|
||||
if fm.fileExists(atPath: cacheDir.path(percentEncoded: false)) {
|
||||
try fm.removeItem(at: cacheDir)
|
||||
try fm.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
if fm.fileExists(atPath: namedDir.path(percentEncoded: false)) {
|
||||
try fm.removeItem(at: namedDir)
|
||||
try fm.createDirectory(at: namedDir, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
if all {
|
||||
let kernelDir = Sandboxy.appRoot.appendingPathComponent("kernel")
|
||||
if fm.fileExists(atPath: kernelDir.path(percentEncoded: false)) {
|
||||
try fm.removeItem(at: kernelDir)
|
||||
print("Removed kernel.")
|
||||
}
|
||||
|
||||
let initfs = Sandboxy.appRoot.appendingPathComponent("initfs.ext4")
|
||||
if fm.fileExists(atPath: initfs.path(percentEncoded: false)) {
|
||||
try fm.removeItem(at: initfs)
|
||||
print("Removed init image.")
|
||||
}
|
||||
|
||||
let contentDir = Sandboxy.appRoot.appendingPathComponent("content")
|
||||
if fm.fileExists(atPath: contentDir.path(percentEncoded: false)) {
|
||||
try fm.removeItem(at: contentDir)
|
||||
print("Removed content store.")
|
||||
}
|
||||
|
||||
// Remove the image store reference database so stale references
|
||||
// don't point to missing content.
|
||||
let stateFile = Sandboxy.appRoot.appendingPathComponent("state.json")
|
||||
if fm.fileExists(atPath: stateFile.path(percentEncoded: false)) {
|
||||
try fm.removeItem(at: stateFile)
|
||||
}
|
||||
|
||||
print("All caches and downloaded artifacts removed.")
|
||||
} else {
|
||||
print("All caches removed. Use --all to also remove kernel, init image, and content store.")
|
||||
}
|
||||
}
|
||||
|
||||
private func listNamedInstances(in dir: URL) -> [String] {
|
||||
let path = dir.path(percentEncoded: false)
|
||||
guard FileManager.default.fileExists(atPath: path) else {
|
||||
return []
|
||||
}
|
||||
do {
|
||||
return try FileManager.default.contentsOfDirectory(
|
||||
at: dir,
|
||||
includingPropertiesForKeys: nil
|
||||
)
|
||||
.filter { $0.lastPathComponent.hasSuffix("-rootfs.ext4") }
|
||||
.map { $0.lastPathComponent.replacingOccurrences(of: "-rootfs.ext4", with: "") }
|
||||
.sorted()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
extension Sandboxy {
|
||||
struct Config: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "config",
|
||||
abstract: "View and create configuration files",
|
||||
subcommands: [
|
||||
ConfigList.self,
|
||||
ConfigCreate.self,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
struct ConfigList: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "list",
|
||||
abstract: "Print current configuration and agent definitions"
|
||||
)
|
||||
|
||||
@Option(name: .long, help: "Print the definition for a specific agent")
|
||||
var agent: String?
|
||||
|
||||
@Flag(name: .long, help: "Print built-in defaults instead of the resolved configuration")
|
||||
var defaults = false
|
||||
|
||||
@Flag(name: .long, help: "Print configuration file paths")
|
||||
var paths = false
|
||||
|
||||
@Flag(name: .long, help: "List available agents")
|
||||
var agents = false
|
||||
|
||||
func run() async throws {
|
||||
if paths {
|
||||
let configPath = Sandboxy.configRoot.appendingPathComponent("config.json")
|
||||
let agentsDir = Sandboxy.configRoot.appendingPathComponent("agents")
|
||||
print("Config: \(configPath.path(percentEncoded: false))")
|
||||
print("Agents: \(agentsDir.path(percentEncoded: false))")
|
||||
print("Data: \(Sandboxy.appRoot.path(percentEncoded: false))")
|
||||
return
|
||||
}
|
||||
|
||||
if agents {
|
||||
let allAgents = AgentDefinition.allAgents(configRoot: Sandboxy.configRoot)
|
||||
let builtInNames = Set(AgentDefinition.builtIn.keys)
|
||||
for name in allAgents.keys.sorted() {
|
||||
let definition = allAgents[name]!
|
||||
let source = builtInNames.contains(name) ? "built-in" : "custom"
|
||||
print(" \(name) - \(definition.displayName) (\(source))")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
|
||||
if let agentName = agent {
|
||||
let allAgents = AgentDefinition.allAgents(configRoot: Sandboxy.configRoot)
|
||||
guard let definition = allAgents[agentName] else {
|
||||
let available = allAgents.keys.sorted().joined(separator: ", ")
|
||||
throw ValidationError(
|
||||
"Unknown agent '\(agentName)'. Available agents: \(available)"
|
||||
)
|
||||
}
|
||||
let data = try encoder.encode(definition)
|
||||
print(String(data: data, encoding: .utf8)!)
|
||||
} else {
|
||||
let config = defaults ? SandboxyConfig.defaults : try Sandboxy.loadConfig()
|
||||
let data = try encoder.encode(config)
|
||||
print(String(data: data, encoding: .utf8)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigCreate: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "create",
|
||||
abstract: "Create a default configuration or agent definition file"
|
||||
)
|
||||
|
||||
@Option(name: .long, help: "Create a definition file for a new agent with this name")
|
||||
var agent: String?
|
||||
|
||||
@Flag(name: .long, help: "Overwrite existing files without prompting")
|
||||
var force = false
|
||||
|
||||
func run() async throws {
|
||||
let fm = FileManager.default
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
|
||||
if let agentName = agent {
|
||||
let agentsDir = Sandboxy.configRoot.appendingPathComponent("agents")
|
||||
try fm.createDirectory(at: agentsDir, withIntermediateDirectories: true)
|
||||
|
||||
let filePath = agentsDir.appendingPathComponent("\(agentName).json")
|
||||
if fm.fileExists(atPath: filePath.path(percentEncoded: false)) && !force {
|
||||
guard isatty(STDIN_FILENO) != 0 else {
|
||||
print("Agent definition already exists at \(filePath.path(percentEncoded: false)). Use --force to overwrite.")
|
||||
throw ExitCode.failure
|
||||
}
|
||||
print("Agent definition already exists at \(filePath.path(percentEncoded: false))")
|
||||
print("Overwrite? [y/N] ", terminator: "")
|
||||
guard let response = readLine()?.lowercased(), response == "y" || response == "yes" else {
|
||||
print("Aborted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let definition: AgentDefinition
|
||||
if let builtIn = AgentDefinition.builtIn[agentName] {
|
||||
definition = builtIn
|
||||
} else {
|
||||
definition = AgentDefinition(
|
||||
displayName: agentName.capitalized,
|
||||
baseImage: "docker.io/library/node:22",
|
||||
installCommands: [],
|
||||
launchCommand: [agentName],
|
||||
environmentVariables: [],
|
||||
mounts: [],
|
||||
allowedHosts: []
|
||||
)
|
||||
}
|
||||
|
||||
let data = try encoder.encode(definition)
|
||||
try data.write(to: filePath, options: .atomic)
|
||||
print("Created agent definition at \(filePath.path(percentEncoded: false))")
|
||||
print()
|
||||
print(String(data: data, encoding: .utf8)!)
|
||||
} else {
|
||||
let configPath = Sandboxy.configRoot.appendingPathComponent("config.json")
|
||||
if fm.fileExists(atPath: configPath.path(percentEncoded: false)) && !force {
|
||||
guard isatty(STDIN_FILENO) != 0 else {
|
||||
print("Configuration file already exists at \(configPath.path(percentEncoded: false)). Use --force to overwrite.")
|
||||
throw ExitCode.failure
|
||||
}
|
||||
print("Configuration file already exists at \(configPath.path(percentEncoded: false))")
|
||||
print("Overwrite? [y/N] ", terminator: "")
|
||||
guard let response = readLine()?.lowercased(), response == "y" || response == "yes" else {
|
||||
print("Aborted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try fm.createDirectory(at: Sandboxy.configRoot, withIntermediateDirectories: true)
|
||||
|
||||
let data = try encoder.encode(SandboxyConfig.defaults)
|
||||
try data.write(to: configPath, options: .atomic)
|
||||
print("Created configuration file at \(configPath.path(percentEncoded: false))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import Containerization
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
|
||||
extension Sandboxy {
|
||||
struct Edit: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "edit",
|
||||
abstract: "Open an interactive shell in an agent's cached environment",
|
||||
discussion: """
|
||||
Boots the cached rootfs for the given agent and drops you into a shell.
|
||||
Any changes you make (installing packages, editing configs, etc.) are
|
||||
saved back to the cache when you exit. If no cache exists, the agent
|
||||
is installed from scratch first.
|
||||
"""
|
||||
)
|
||||
|
||||
@Argument(help: "Agent whose environment to edit (e.g. claude)")
|
||||
var agent: String
|
||||
|
||||
@Option(
|
||||
name: [.customLong("kernel"), .customShort("k")],
|
||||
help: "Path to Linux kernel binary (auto-downloads if omitted)",
|
||||
completion: .file(),
|
||||
transform: { str in
|
||||
URL(fileURLWithPath: str, relativeTo: .currentDirectory())
|
||||
.absoluteURL.path(percentEncoded: false)
|
||||
})
|
||||
var kernel: String?
|
||||
|
||||
func run() async throws {
|
||||
let config = try Sandboxy.loadConfig()
|
||||
|
||||
let agents = AgentDefinition.allAgents(configRoot: Sandboxy.configRoot)
|
||||
guard let definition = agents[agent] else {
|
||||
let available = agents.keys.sorted().joined(separator: ", ")
|
||||
throw ValidationError(
|
||||
"Unknown agent '\(agent)'. Available agents: \(available)"
|
||||
)
|
||||
}
|
||||
|
||||
ProgressUI.printStatus("Opening \(definition.displayName) environment for editing...")
|
||||
|
||||
let kernelPath = try await KernelManager.ensureKernel(
|
||||
explicitPath: kernel,
|
||||
appRoot: Sandboxy.appRoot,
|
||||
config: config
|
||||
)
|
||||
let vmKernel = Kernel(path: kernelPath, platform: .linuxArm)
|
||||
|
||||
let enableNetworking: Bool
|
||||
var sharedNetwork: VmnetNetwork?
|
||||
if #available(macOS 26, *) {
|
||||
sharedNetwork = try VmnetNetwork()
|
||||
enableNetworking = true
|
||||
} else {
|
||||
sharedNetwork = nil
|
||||
enableNetworking = false
|
||||
}
|
||||
|
||||
let vmnetMTU: UInt32 = 1400
|
||||
|
||||
let initfsReference = config.initfsReference ?? SandboxyConfig.defaults.initfsReference!
|
||||
var manager = try await ContainerManager(
|
||||
kernel: vmKernel,
|
||||
initfsReference: initfsReference,
|
||||
root: Sandboxy.appRoot
|
||||
)
|
||||
|
||||
let containerId = "\(agent)-edit-\(ProcessInfo.processInfo.processIdentifier)"
|
||||
|
||||
let cacheDir = Sandboxy.appRoot.appendingPathComponent("cache")
|
||||
try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
||||
let agentCachePath = cacheDir.appendingPathComponent("\(agent)-rootfs.ext4")
|
||||
let containerRootfsPath = Sandboxy.appRoot
|
||||
.appendingPathComponent("containers")
|
||||
.appendingPathComponent(containerId)
|
||||
.appendingPathComponent("rootfs.ext4")
|
||||
|
||||
let hasCachedRootfs = FileManager.default.fileExists(
|
||||
atPath: agentCachePath.path(percentEncoded: false))
|
||||
|
||||
let container: LinuxContainer
|
||||
|
||||
if hasCachedRootfs {
|
||||
ProgressUI.printDetail("Using cached environment...")
|
||||
let containerDir = Sandboxy.appRoot
|
||||
.appendingPathComponent("containers")
|
||||
.appendingPathComponent(containerId)
|
||||
try FileManager.default.createDirectory(
|
||||
at: containerDir, withIntermediateDirectories: true)
|
||||
|
||||
let result = Darwin.clonefile(
|
||||
agentCachePath.path(percentEncoded: false),
|
||||
containerRootfsPath.path(percentEncoded: false),
|
||||
0
|
||||
)
|
||||
if result != 0 {
|
||||
try FileManager.default.copyItem(at: agentCachePath, to: containerRootfsPath)
|
||||
}
|
||||
|
||||
let rootfsMount = Mount.block(
|
||||
format: "ext4",
|
||||
source: containerRootfsPath.path(percentEncoded: false),
|
||||
destination: "/"
|
||||
)
|
||||
|
||||
let image = try await Sandboxy.imageStore.get(
|
||||
reference: definition.baseImage, pull: true)
|
||||
|
||||
container = try await manager.create(
|
||||
containerId,
|
||||
image: image,
|
||||
rootfs: rootfsMount,
|
||||
networking: false
|
||||
) { config in
|
||||
if enableNetworking, let iface = try sharedNetwork?.createInterface(containerId, mtu: vmnetMTU) {
|
||||
config.interfaces = [iface]
|
||||
config.dns = .init(nameservers: [sharedNetwork!.ipv4Gateway.description])
|
||||
}
|
||||
config.cpus = 4
|
||||
config.memoryInBytes = 4096 * 1024 * 1024
|
||||
config.process.arguments = ["/bin/sleep", "infinity"]
|
||||
config.process.workingDirectory = "/"
|
||||
config.process.capabilities = .allCapabilities
|
||||
config.useInit = true
|
||||
}
|
||||
} else {
|
||||
ProgressUI.printDetail("No cached environment, setting up from scratch...")
|
||||
container = try await manager.create(
|
||||
containerId,
|
||||
reference: definition.baseImage,
|
||||
rootfsSizeInBytes: 512.gib(),
|
||||
networking: false
|
||||
) { config in
|
||||
if enableNetworking, let iface = try sharedNetwork?.createInterface(containerId, mtu: vmnetMTU) {
|
||||
config.interfaces = [iface]
|
||||
config.dns = .init(nameservers: [sharedNetwork!.ipv4Gateway.description])
|
||||
}
|
||||
config.cpus = 4
|
||||
config.memoryInBytes = 4096 * 1024 * 1024
|
||||
config.process.arguments = ["/bin/sleep", "infinity"]
|
||||
config.process.workingDirectory = "/"
|
||||
config.process.capabilities = .allCapabilities
|
||||
config.useInit = true
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try await container.create()
|
||||
try await container.start()
|
||||
|
||||
// If no cache existed, run the agent's install commands first.
|
||||
if !hasCachedRootfs {
|
||||
ProgressUI.printStatus("Installing \(definition.displayName) toolchain...")
|
||||
do {
|
||||
try await installAgent(in: container, definition: definition)
|
||||
ProgressUI.printStatus("Installation complete.")
|
||||
} catch {
|
||||
ProgressUI.printError("Installation failed: \(error)")
|
||||
ProgressUI.printStatus("Dropping into shell...")
|
||||
}
|
||||
}
|
||||
|
||||
// Drop into an interactive shell.
|
||||
ProgressUI.printStatus("Launching shell (exit to save changes)...\n")
|
||||
|
||||
let sigwinchStream = AsyncSignalHandler.create(notify: [SIGWINCH])
|
||||
let current = try Terminal.current
|
||||
try current.setraw()
|
||||
defer { current.tryReset() }
|
||||
|
||||
let shellProcess = try await container.exec("edit-shell") { config in
|
||||
config.arguments = ["/bin/bash"]
|
||||
config.environmentVariables = [
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"TERM=xterm",
|
||||
"HOME=/root",
|
||||
]
|
||||
config.workingDirectory = "/root"
|
||||
config.setTerminalIO(terminal: current)
|
||||
config.capabilities = .allCapabilities
|
||||
}
|
||||
|
||||
try await shellProcess.start()
|
||||
try? await shellProcess.resize(to: try current.size)
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
for await _ in sigwinchStream.signals {
|
||||
try await shellProcess.resize(to: try current.size)
|
||||
}
|
||||
}
|
||||
|
||||
_ = try await shellProcess.wait()
|
||||
group.cancelAll()
|
||||
try await shellProcess.delete()
|
||||
}
|
||||
|
||||
// Stop container so rootfs is cleanly unmounted before caching.
|
||||
try await container.stop()
|
||||
|
||||
// Save the modified rootfs back to the cache.
|
||||
ProgressUI.printStatus("Saving changes to cache...")
|
||||
removeIfExists(at: agentCachePath)
|
||||
try FileManager.default.copyItem(at: containerRootfsPath, to: agentCachePath)
|
||||
ProgressUI.printStatus("Done.")
|
||||
|
||||
try manager.delete(containerId)
|
||||
try? sharedNetwork?.releaseInterface(containerId)
|
||||
} catch {
|
||||
do {
|
||||
try await container.stop()
|
||||
} catch {
|
||||
log.warning("Failed to stop container \(containerId): \(error)")
|
||||
}
|
||||
do {
|
||||
try manager.delete(containerId)
|
||||
} catch {
|
||||
log.warning("Failed to delete container \(containerId): \(error)")
|
||||
}
|
||||
try? sharedNetwork?.releaseInterface(containerId)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
// Adapted from https://github.com/apple/swift-nio-examples/tree/main/connect-proxy
|
||||
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOHTTP1
|
||||
import NIOPosix
|
||||
|
||||
/// A lightweight HTTP proxy that runs on the host and filters by hostname.
|
||||
///
|
||||
/// Binds to the host's gateway IP on a vmnet host-only network. Workload containers
|
||||
/// that have no direct internet route use this proxy (via HTTP_PROXY/HTTPS_PROXY env vars)
|
||||
/// to reach the outside world. Only hostnames matching the allowlist are permitted.
|
||||
///
|
||||
/// Handles both HTTPS (via CONNECT tunneling) and plain HTTP (via request forwarding).
|
||||
/// For HTTPS, the client sends a CONNECT request with the target hostname in plaintext
|
||||
/// before TLS begins, so we can filter without any certificate interception.
|
||||
final class HostProxy: @unchecked Sendable {
|
||||
private let group: MultiThreadedEventLoopGroup
|
||||
private let channel: any Channel
|
||||
|
||||
/// The port the proxy is listening on.
|
||||
let port: Int
|
||||
|
||||
/// The host address the proxy is bound to.
|
||||
let host: String
|
||||
|
||||
/// Start a proxy bound to the given address.
|
||||
/// - Parameters:
|
||||
/// - host: IP address to bind to (e.g. the vmnet gateway IP).
|
||||
/// - port: Port to bind to. Use 0 for an OS-assigned port.
|
||||
/// - allowedHosts: Hostname patterns to allow. Supports `*.example.com` wildcards.
|
||||
init(host: String, port: Int = 0, allowedHosts: [String]) async throws {
|
||||
self.host = host
|
||||
let group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
|
||||
self.group = group
|
||||
|
||||
let bootstrap = ServerBootstrap(group: group)
|
||||
.serverChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1)
|
||||
.childChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1)
|
||||
.childChannelInitializer { channel in
|
||||
channel.eventLoop.makeCompletedFuture {
|
||||
try channel.pipeline.syncOperations.addHandler(
|
||||
ByteToMessageHandler(HTTPRequestDecoder(leftOverBytesStrategy: .forwardBytes))
|
||||
)
|
||||
try channel.pipeline.syncOperations.addHandler(HTTPResponseEncoder())
|
||||
try channel.pipeline.syncOperations.addHandler(
|
||||
ConnectHandler(allowedHosts: allowedHosts)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let channel = try await bootstrap.bind(
|
||||
to: SocketAddress(ipAddress: host, port: port)
|
||||
).get()
|
||||
|
||||
guard let localAddress = channel.localAddress, let assignedPort = localAddress.port else {
|
||||
throw SandboxyError.proxyFailed(reason: "could not determine proxy listen port")
|
||||
}
|
||||
|
||||
self.port = assignedPort
|
||||
self.channel = channel
|
||||
}
|
||||
|
||||
/// Stop the proxy and release resources.
|
||||
func stop() async throws {
|
||||
try await channel.close()
|
||||
try await group.shutdownGracefully()
|
||||
}
|
||||
|
||||
/// Check if a hostname matches any pattern in the allowlist.
|
||||
static func isAllowed(host: String, allowedHosts: [String]) -> Bool {
|
||||
let host = host.lowercased()
|
||||
for pattern in allowedHosts {
|
||||
let pattern = pattern.lowercased()
|
||||
if pattern.hasPrefix("*.") {
|
||||
let suffix = String(pattern.dropFirst(1)) // e.g. ".example.com"
|
||||
if host == String(pattern.dropFirst(2)) || host.hasSuffix(suffix) {
|
||||
return true
|
||||
}
|
||||
} else if host == pattern {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel handler that processes HTTP CONNECT and plain HTTP proxy requests.
|
||||
/// Checks the target hostname against an allowlist before connecting.
|
||||
private final class ConnectHandler {
|
||||
private var upgradeState: State
|
||||
private let allowedHosts: [String]
|
||||
|
||||
/// Buffered request for plain HTTP forwarding (nil for CONNECT).
|
||||
private var pendingHTTPHead: HTTPRequestHead?
|
||||
private var pendingHTTPBody: [ByteBuffer] = []
|
||||
|
||||
init(allowedHosts: [String]) {
|
||||
self.upgradeState = .idle
|
||||
self.allowedHosts = allowedHosts
|
||||
}
|
||||
}
|
||||
|
||||
extension ConnectHandler {
|
||||
fileprivate enum State {
|
||||
case idle
|
||||
case beganConnecting
|
||||
case awaitingEnd(connectResult: Channel)
|
||||
case awaitingConnection(pendingBytes: [NIOAny])
|
||||
case upgradeComplete(pendingBytes: [NIOAny])
|
||||
case upgradeFailed
|
||||
}
|
||||
}
|
||||
|
||||
extension ConnectHandler: ChannelInboundHandler {
|
||||
typealias InboundIn = HTTPServerRequestPart
|
||||
typealias OutboundOut = HTTPServerResponsePart
|
||||
|
||||
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
||||
switch self.upgradeState {
|
||||
case .idle:
|
||||
self.handleInitialMessage(context: context, data: self.unwrapInboundIn(data))
|
||||
|
||||
case .beganConnecting:
|
||||
switch self.unwrapInboundIn(data) {
|
||||
case .body(let body):
|
||||
self.pendingHTTPBody.append(body)
|
||||
case .end:
|
||||
self.upgradeState = .awaitingConnection(pendingBytes: [])
|
||||
self.removeDecoder(context: context)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
case .awaitingEnd(let peerChannel):
|
||||
switch self.unwrapInboundIn(data) {
|
||||
case .body(let body):
|
||||
self.pendingHTTPBody.append(body)
|
||||
case .end:
|
||||
self.upgradeState = .upgradeComplete(pendingBytes: [])
|
||||
self.removeDecoder(context: context)
|
||||
self.glue(peerChannel, context: context)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
case .awaitingConnection(var pendingBytes):
|
||||
self.upgradeState = .awaitingConnection(pendingBytes: [])
|
||||
pendingBytes.append(data)
|
||||
self.upgradeState = .awaitingConnection(pendingBytes: pendingBytes)
|
||||
|
||||
case .upgradeComplete(var pendingBytes):
|
||||
self.upgradeState = .upgradeComplete(pendingBytes: [])
|
||||
pendingBytes.append(data)
|
||||
self.upgradeState = .upgradeComplete(pendingBytes: pendingBytes)
|
||||
|
||||
case .upgradeFailed:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConnectHandler: RemovableChannelHandler {
|
||||
func removeHandler(context: ChannelHandlerContext, removalToken: ChannelHandlerContext.RemovalToken) {
|
||||
var didRead = false
|
||||
|
||||
while case .upgradeComplete(var pendingBytes) = self.upgradeState, pendingBytes.count > 0 {
|
||||
self.upgradeState = .upgradeComplete(pendingBytes: [])
|
||||
let nextRead = pendingBytes.removeFirst()
|
||||
self.upgradeState = .upgradeComplete(pendingBytes: pendingBytes)
|
||||
|
||||
context.fireChannelRead(nextRead)
|
||||
didRead = true
|
||||
}
|
||||
|
||||
if didRead {
|
||||
context.fireChannelReadComplete()
|
||||
}
|
||||
|
||||
context.leavePipeline(removalToken: removalToken)
|
||||
}
|
||||
}
|
||||
|
||||
extension ConnectHandler {
|
||||
private func handleInitialMessage(context: ChannelHandlerContext, data: InboundIn) {
|
||||
guard case .head(let head) = data else {
|
||||
self.httpErrorAndClose(context: context, status: .badRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if head.method == .CONNECT {
|
||||
// HTTPS: CONNECT host:port
|
||||
let components = head.uri.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
let host = String(components.first!)
|
||||
let port = components.last.flatMap { Int($0, radix: 10) } ?? 443
|
||||
|
||||
guard HostProxy.isAllowed(host: host, allowedHosts: self.allowedHosts) else {
|
||||
self.httpErrorAndClose(context: context, status: .forbidden)
|
||||
return
|
||||
}
|
||||
|
||||
self.upgradeState = .beganConnecting
|
||||
self.connectTo(host: host, port: port, context: context)
|
||||
} else {
|
||||
// Plain HTTP: GET http://host/path, POST http://host/path, etc.
|
||||
guard let url = URLComponents(string: head.uri),
|
||||
let hostname = url.host, !hostname.isEmpty
|
||||
else {
|
||||
self.httpErrorAndClose(context: context, status: .badRequest)
|
||||
return
|
||||
}
|
||||
|
||||
guard HostProxy.isAllowed(host: hostname, allowedHosts: self.allowedHosts) else {
|
||||
self.httpErrorAndClose(context: context, status: .forbidden)
|
||||
return
|
||||
}
|
||||
|
||||
let port = url.port ?? 80
|
||||
|
||||
// Rewrite URI from absolute (http://host/path) to relative (/path).
|
||||
var relativePath = url.path
|
||||
if relativePath.isEmpty { relativePath = "/" }
|
||||
if let query = url.query {
|
||||
relativePath += "?\(query)"
|
||||
}
|
||||
|
||||
var rewritten = head
|
||||
rewritten.uri = relativePath
|
||||
self.pendingHTTPHead = rewritten
|
||||
|
||||
self.upgradeState = .beganConnecting
|
||||
self.connectTo(host: hostname, port: port, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
private func connectTo(host: String, port: Int, context: ChannelHandlerContext) {
|
||||
ClientBootstrap(group: context.eventLoop)
|
||||
.connect(host: host, port: port).assumeIsolatedUnsafeUnchecked().whenComplete { result in
|
||||
switch result {
|
||||
case .success(let channel):
|
||||
self.connectSucceeded(channel: channel, context: context)
|
||||
case .failure(let error):
|
||||
self.connectFailed(error: error, context: context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func connectSucceeded(channel: Channel, context: ChannelHandlerContext) {
|
||||
switch self.upgradeState {
|
||||
case .beganConnecting:
|
||||
self.upgradeState = .awaitingEnd(connectResult: channel)
|
||||
|
||||
case .awaitingConnection(let pendingBytes):
|
||||
self.upgradeState = .upgradeComplete(pendingBytes: pendingBytes)
|
||||
self.glue(channel, context: context)
|
||||
|
||||
case .awaitingEnd(let peerChannel):
|
||||
peerChannel.close(mode: .all, promise: nil)
|
||||
context.close(promise: nil)
|
||||
|
||||
case .idle, .upgradeFailed, .upgradeComplete:
|
||||
context.close(promise: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func connectFailed(error: Error, context: ChannelHandlerContext) {
|
||||
switch self.upgradeState {
|
||||
case .beganConnecting, .awaitingConnection:
|
||||
self.httpErrorAndClose(context: context, status: .badGateway)
|
||||
|
||||
case .awaitingEnd(let peerChannel):
|
||||
peerChannel.close(mode: .all, promise: nil)
|
||||
context.close(promise: nil)
|
||||
|
||||
case .idle, .upgradeFailed, .upgradeComplete:
|
||||
context.close(promise: nil)
|
||||
}
|
||||
|
||||
context.fireErrorCaught(error)
|
||||
}
|
||||
|
||||
private func glue(_ peerChannel: Channel, context: ChannelHandlerContext) {
|
||||
if let httpHead = self.pendingHTTPHead {
|
||||
// Plain HTTP: forward the buffered request to the peer, then glue.
|
||||
var buffer = context.channel.allocator.buffer(capacity: 256)
|
||||
buffer.writeString("\(httpHead.method) \(httpHead.uri) HTTP/\(httpHead.version.major).\(httpHead.version.minor)\r\n")
|
||||
for (name, value) in httpHead.headers {
|
||||
buffer.writeString("\(name): \(value)\r\n")
|
||||
}
|
||||
buffer.writeString("\r\n")
|
||||
for var body in self.pendingHTTPBody {
|
||||
buffer.writeBuffer(&body)
|
||||
}
|
||||
peerChannel.writeAndFlush(buffer, promise: nil)
|
||||
self.pendingHTTPHead = nil
|
||||
self.pendingHTTPBody = []
|
||||
} else {
|
||||
// CONNECT: send 200 OK to the client.
|
||||
// Content-Length: 0 prevents the encoder from adding chunked transfer encoding,
|
||||
// which would inject a chunked terminator into the raw tunnel and break TLS.
|
||||
let headers = HTTPHeaders([("Content-Length", "0")])
|
||||
let head = HTTPResponseHead(version: .init(major: 1, minor: 1), status: .ok, headers: headers)
|
||||
context.write(self.wrapOutboundOut(.head(head)), promise: nil)
|
||||
context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
|
||||
}
|
||||
|
||||
self.removeEncoder(context: context)
|
||||
|
||||
let (localGlue, peerGlue) = GlueHandler.matchedPair()
|
||||
do {
|
||||
try context.channel.pipeline.syncOperations.addHandler(localGlue)
|
||||
try peerChannel.pipeline.syncOperations.addHandler(peerGlue)
|
||||
context.pipeline.syncOperations.removeHandler(self, promise: nil)
|
||||
} catch {
|
||||
peerChannel.close(mode: .all, promise: nil)
|
||||
context.close(promise: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func httpErrorAndClose(context: ChannelHandlerContext, status: HTTPResponseStatus) {
|
||||
self.upgradeState = .upgradeFailed
|
||||
|
||||
let headers = HTTPHeaders([("Content-Length", "0"), ("Connection", "close")])
|
||||
let head = HTTPResponseHead(version: .init(major: 1, minor: 1), status: status, headers: headers)
|
||||
context.write(self.wrapOutboundOut(.head(head)), promise: nil)
|
||||
context.writeAndFlush(self.wrapOutboundOut(.end(nil))).assumeIsolatedUnsafeUnchecked().whenComplete {
|
||||
(_: Result<Void, Error>) in
|
||||
context.close(mode: .output, promise: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeDecoder(context: ChannelHandlerContext) {
|
||||
if let ctx = try? context.pipeline.syncOperations.context(
|
||||
handlerType: ByteToMessageHandler<HTTPRequestDecoder>.self
|
||||
) {
|
||||
context.pipeline.syncOperations.removeHandler(context: ctx, promise: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeEncoder(context: ChannelHandlerContext) {
|
||||
if let ctx = try? context.pipeline.syncOperations.context(
|
||||
handlerType: HTTPResponseEncoder.self
|
||||
) {
|
||||
context.pipeline.syncOperations.removeHandler(context: ctx, promise: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bidirectional relay handler that glues two channels together.
|
||||
private final class GlueHandler {
|
||||
private var partner: GlueHandler?
|
||||
private var context: ChannelHandlerContext?
|
||||
private var pendingRead: Bool = false
|
||||
|
||||
private init() {}
|
||||
|
||||
static func matchedPair() -> (GlueHandler, GlueHandler) {
|
||||
let first = GlueHandler()
|
||||
let second = GlueHandler()
|
||||
first.partner = second
|
||||
second.partner = first
|
||||
return (first, second)
|
||||
}
|
||||
}
|
||||
|
||||
extension GlueHandler {
|
||||
fileprivate func partnerWrite(_ data: NIOAny) {
|
||||
self.context?.write(data, promise: nil)
|
||||
}
|
||||
|
||||
fileprivate func partnerFlush() {
|
||||
self.context?.flush()
|
||||
}
|
||||
|
||||
fileprivate func partnerWriteEOF() {
|
||||
self.context?.close(mode: .output, promise: nil)
|
||||
}
|
||||
|
||||
fileprivate func partnerCloseFull() {
|
||||
self.context?.close(promise: nil)
|
||||
}
|
||||
|
||||
fileprivate func partnerBecameWritable() {
|
||||
if self.pendingRead {
|
||||
self.pendingRead = false
|
||||
self.context?.read()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate var partnerWritable: Bool {
|
||||
self.context?.channel.isWritable ?? false
|
||||
}
|
||||
}
|
||||
|
||||
extension GlueHandler: ChannelDuplexHandler {
|
||||
typealias InboundIn = NIOAny
|
||||
typealias OutboundIn = NIOAny
|
||||
typealias OutboundOut = NIOAny
|
||||
|
||||
func handlerAdded(context: ChannelHandlerContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func handlerRemoved(context: ChannelHandlerContext) {
|
||||
self.context = nil
|
||||
self.partner = nil
|
||||
}
|
||||
|
||||
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
||||
self.partner?.partnerWrite(data)
|
||||
}
|
||||
|
||||
func channelReadComplete(context: ChannelHandlerContext) {
|
||||
self.partner?.partnerFlush()
|
||||
}
|
||||
|
||||
func channelInactive(context: ChannelHandlerContext) {
|
||||
self.partner?.partnerCloseFull()
|
||||
}
|
||||
|
||||
func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) {
|
||||
if let event = event as? ChannelEvent, case .inputClosed = event {
|
||||
self.partner?.partnerWriteEOF()
|
||||
}
|
||||
}
|
||||
|
||||
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
||||
self.partner?.partnerCloseFull()
|
||||
}
|
||||
|
||||
func channelWritabilityChanged(context: ChannelHandlerContext) {
|
||||
if context.channel.isWritable {
|
||||
self.partner?.partnerBecameWritable()
|
||||
}
|
||||
}
|
||||
|
||||
func read(context: ChannelHandlerContext) {
|
||||
if let partner = self.partner, partner.partnerWritable {
|
||||
context.read()
|
||||
} else {
|
||||
self.pendingRead = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Persistent metadata about a sandbox instance.
|
||||
struct InstanceState: Codable, Sendable {
|
||||
let id: String
|
||||
/// User-provided name for persistent instances. Nil for ephemeral runs.
|
||||
let name: String?
|
||||
let agent: String
|
||||
let workspace: String
|
||||
let status: Status
|
||||
let createdAt: Date
|
||||
var stoppedAt: Date?
|
||||
let cpus: Int
|
||||
let memoryMB: UInt64
|
||||
|
||||
enum Status: String, Codable, Sendable {
|
||||
case running
|
||||
case stopped
|
||||
}
|
||||
|
||||
/// Whether this is a named (persistent) instance.
|
||||
var isNamed: Bool { name != nil }
|
||||
|
||||
/// Directory where instance state files are stored.
|
||||
static func instancesDir(appRoot: URL) -> URL {
|
||||
appRoot.appendingPathComponent("instances")
|
||||
}
|
||||
|
||||
/// Directory where named instance rootfs files are preserved.
|
||||
static func namedRootfsDir(appRoot: URL) -> URL {
|
||||
appRoot.appendingPathComponent("named")
|
||||
}
|
||||
|
||||
/// Path to the preserved rootfs for a named instance.
|
||||
static func namedRootfsPath(appRoot: URL, name: String) -> URL {
|
||||
namedRootfsDir(appRoot: appRoot).appendingPathComponent("\(name)-rootfs.ext4")
|
||||
}
|
||||
|
||||
/// Path to this instance's state file.
|
||||
func statePath(appRoot: URL) -> URL {
|
||||
Self.instancesDir(appRoot: appRoot).appendingPathComponent("\(id).json")
|
||||
}
|
||||
|
||||
/// Saves this instance state to disk.
|
||||
func save(appRoot: URL) throws {
|
||||
let dir = Self.instancesDir(appRoot: appRoot)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = .prettyPrinted
|
||||
let data = try encoder.encode(self)
|
||||
try data.write(to: statePath(appRoot: appRoot))
|
||||
}
|
||||
|
||||
/// Loads all instance states from disk.
|
||||
static func loadAll(appRoot: URL) throws -> [InstanceState] {
|
||||
let dir = instancesDir(appRoot: appRoot)
|
||||
let path = dir.path(percentEncoded: false)
|
||||
guard FileManager.default.fileExists(atPath: path) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
|
||||
let files = try FileManager.default.contentsOfDirectory(
|
||||
at: dir,
|
||||
includingPropertiesForKeys: nil
|
||||
).filter { $0.pathExtension == "json" }
|
||||
|
||||
return files.compactMap { file -> InstanceState? in
|
||||
do {
|
||||
let data = try Data(contentsOf: file)
|
||||
return try decoder.decode(InstanceState.self, from: data)
|
||||
} catch {
|
||||
log.warning("Failed to load instance state from \(file.lastPathComponent): \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds a named instance by name.
|
||||
static func find(name: String, appRoot: URL) throws -> InstanceState? {
|
||||
try loadAll(appRoot: appRoot).first { $0.name == name }
|
||||
}
|
||||
|
||||
/// Removes this instance's state file from disk.
|
||||
func remove(appRoot: URL) throws {
|
||||
try FileManager.default.removeItem(at: statePath(appRoot: appRoot))
|
||||
}
|
||||
|
||||
/// Removes this instance's state file and preserved rootfs (for named instances).
|
||||
func removeAll(appRoot: URL) throws {
|
||||
try remove(appRoot: appRoot)
|
||||
if let name {
|
||||
let rootfs = Self.namedRootfsPath(appRoot: appRoot, name: name)
|
||||
let path = rootfs.path(percentEncoded: false)
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
try FileManager.default.removeItem(at: rootfs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import AsyncHTTPClient
|
||||
import ContainerizationArchive
|
||||
import ContainerizationExtras
|
||||
import Foundation
|
||||
|
||||
enum KernelManager {
|
||||
// Hardcoded default kernel source (Kata Containers arm64 static release).
|
||||
private static let defaultKernelURL =
|
||||
"https://github.com/kata-containers/kata-containers/releases/download/3.26.0/kata-static-3.26.0-arm64.tar.zst"
|
||||
private static let defaultKernelPathInTarball = "opt/kata/share/kata-containers/vmlinux.container"
|
||||
|
||||
/// Ensures a kernel binary is available, returning its path.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. CLI flag (`-k` / `--kernel`)
|
||||
/// 2. Config file (`"kernel"` field)
|
||||
/// 3. Cached kernel at `appRoot/kernel/vmlinux`
|
||||
/// 4. Auto-download from Kata Containers
|
||||
static func ensureKernel(explicitPath: String?, appRoot: URL, config: SandboxyConfig) async throws -> URL {
|
||||
// 1. CLI flag takes priority.
|
||||
if let explicitPath {
|
||||
let url = URL(fileURLWithPath: explicitPath)
|
||||
guard FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) else {
|
||||
throw SandboxyError.kernelNotFound(path: explicitPath)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// 2. Config file path.
|
||||
if let configKernel = config.kernel {
|
||||
let url = URL(fileURLWithPath: configKernel)
|
||||
guard FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) else {
|
||||
throw SandboxyError.kernelNotFound(path: configKernel)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// 3. Cached kernel.
|
||||
let kernelDir = appRoot.appendingPathComponent("kernel")
|
||||
let kernelPath = kernelDir.appendingPathComponent("vmlinux")
|
||||
|
||||
if FileManager.default.fileExists(atPath: kernelPath.path(percentEncoded: false)) {
|
||||
return kernelPath
|
||||
}
|
||||
|
||||
// 4. Auto-download.
|
||||
try FileManager.default.createDirectory(at: kernelDir, withIntermediateDirectories: true)
|
||||
|
||||
let progressConfig = try ProgressConfig(
|
||||
description: "Downloading kernel",
|
||||
showTasks: true,
|
||||
totalTasks: 2
|
||||
)
|
||||
let progress = ProgressBar(config: progressConfig)
|
||||
defer { progress.finish() }
|
||||
progress.start()
|
||||
|
||||
let tarballPath = kernelDir.appendingPathComponent("kata.tar.zst")
|
||||
try await downloadFile(from: defaultKernelURL, to: tarballPath, progress: progress)
|
||||
|
||||
progress.set(description: "Extracting kernel")
|
||||
try extractKernel(from: tarballPath, kernelPathInTarball: defaultKernelPathInTarball, to: kernelPath)
|
||||
|
||||
try FileManager.default.removeItem(at: tarballPath)
|
||||
|
||||
return kernelPath
|
||||
}
|
||||
|
||||
private static func downloadFile(from urlString: String, to destination: URL, progress: ProgressBar) async throws {
|
||||
guard let url = URL(string: urlString) else {
|
||||
throw SandboxyError.kernelDownloadFailed(reason: "invalid URL: \(urlString)")
|
||||
}
|
||||
|
||||
let delegate = try FileDownloadDelegate(
|
||||
path: destination.path(percentEncoded: false),
|
||||
reportHead: { head in
|
||||
if let contentLength = head.headers["Content-Length"].first, let totalBytes = Int64(contentLength) {
|
||||
progress.add(totalSize: totalBytes)
|
||||
}
|
||||
},
|
||||
reportProgress: { progressUpdate in
|
||||
progress.set(size: Int64(progressUpdate.receivedBytes))
|
||||
}
|
||||
)
|
||||
|
||||
let request = try HTTPClient.Request(url: url)
|
||||
let client = createClient(url: url)
|
||||
do {
|
||||
_ = try await client.execute(request: request, delegate: delegate).get()
|
||||
} catch {
|
||||
try? await client.shutdown()
|
||||
throw error
|
||||
}
|
||||
try await client.shutdown()
|
||||
}
|
||||
|
||||
private static func createClient(url: URL) -> HTTPClient {
|
||||
var httpConfiguration = HTTPClient.Configuration()
|
||||
httpConfiguration.timeout = HTTPClient.Configuration.Timeout(
|
||||
connect: .seconds(30),
|
||||
read: .none
|
||||
)
|
||||
if let host = url.host {
|
||||
let proxyURL = ProxyUtils.proxyFromEnvironment(scheme: url.scheme, host: host)
|
||||
if let proxyURL, let proxyHost = proxyURL.host {
|
||||
httpConfiguration.proxy = HTTPClient.Configuration.Proxy.server(host: proxyHost, port: proxyURL.port ?? 8080)
|
||||
}
|
||||
}
|
||||
|
||||
return HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration)
|
||||
}
|
||||
|
||||
private static func extractKernel(from tarball: URL, kernelPathInTarball: String, to destination: URL) throws {
|
||||
var target = kernelPathInTarball
|
||||
var reader = try ArchiveReader(file: tarball)
|
||||
var (entry, data) = try reader.extractFile(path: target)
|
||||
|
||||
// If the target file is a symlink, get the data for the actual file.
|
||||
if entry.fileType == .symbolicLink, let symlinkRelative = entry.symlinkTarget {
|
||||
reader = try ArchiveReader(file: tarball)
|
||||
let symlinkTarget = URL(filePath: target).deletingLastPathComponent().appending(path: symlinkRelative)
|
||||
|
||||
// Standardize so that we remove any and all ../ and ./ in the path since symlink targets
|
||||
// are relative paths to the target file from the symlink's parent dir itself.
|
||||
target = symlinkTarget.standardized.relativePath
|
||||
let (_, targetData) = try reader.extractFile(path: target)
|
||||
data = targetData
|
||||
}
|
||||
|
||||
try data.write(to: destination, options: .atomic)
|
||||
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755],
|
||||
ofItemAtPath: destination.path(percentEncoded: false)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
extension Sandboxy {
|
||||
struct List: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "list",
|
||||
abstract: "List sandbox instances",
|
||||
aliases: ["ls"]
|
||||
)
|
||||
|
||||
func run() async throws {
|
||||
_ = try Sandboxy.loadConfig()
|
||||
|
||||
let instances = try InstanceState.loadAll(appRoot: Sandboxy.appRoot)
|
||||
|
||||
if instances.isEmpty {
|
||||
print("No sandbox instances found.")
|
||||
return
|
||||
}
|
||||
|
||||
// Deduplicate named instances, keeping only the most recent entry per name.
|
||||
var seen = Set<String>()
|
||||
var deduplicated: [InstanceState] = []
|
||||
let sorted = instances.sorted { $0.createdAt > $1.createdAt }
|
||||
for instance in sorted {
|
||||
if let name = instance.name {
|
||||
if seen.contains(name) { continue }
|
||||
seen.insert(name)
|
||||
}
|
||||
deduplicated.append(instance)
|
||||
}
|
||||
|
||||
print(
|
||||
pad("NAME", to: 30)
|
||||
+ pad("AGENT", to: 12)
|
||||
+ pad("STATUS", to: 12)
|
||||
+ pad("CREATED", to: 12)
|
||||
+ "WORKSPACE"
|
||||
)
|
||||
|
||||
for instance in deduplicated {
|
||||
let age = relativeTime(from: instance.createdAt)
|
||||
let displayName = instance.name ?? "-"
|
||||
print(
|
||||
pad(truncate(displayName, to: 28), to: 30)
|
||||
+ pad(instance.agent, to: 12)
|
||||
+ pad(instance.status.rawValue, to: 12)
|
||||
+ pad(age, to: 12)
|
||||
+ instance.workspace
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func pad(_ s: String, to width: Int) -> String {
|
||||
if s.count >= width {
|
||||
return s
|
||||
}
|
||||
return s + String(repeating: " ", count: width - s.count)
|
||||
}
|
||||
|
||||
private func truncate(_ s: String, to maxLen: Int) -> String {
|
||||
if s.count <= maxLen { return s }
|
||||
return "..." + String(s.suffix(maxLen - 3))
|
||||
}
|
||||
|
||||
private func relativeTime(from date: Date) -> String {
|
||||
let seconds = Int(Date().timeIntervalSince(date))
|
||||
if seconds < 60 { return "\(seconds)s ago" }
|
||||
let minutes = seconds / 60
|
||||
if minutes < 60 { return "\(minutes)m ago" }
|
||||
let hours = minutes / 60
|
||||
if hours < 24 { return "\(hours)h ago" }
|
||||
let days = hours / 24
|
||||
return "\(days)d ago"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Containerization
|
||||
import Foundation
|
||||
import Synchronization
|
||||
|
||||
/// A Writer that captures output into a Data buffer and optionally streams it.
|
||||
final class OutputCapture: Writer, Sendable {
|
||||
private let storage = Mutex(Data())
|
||||
private let streamTo: FileHandle?
|
||||
|
||||
var data: Data {
|
||||
storage.withLock { $0 }
|
||||
}
|
||||
|
||||
init(streamToStdout: Bool = false, streamToStderr: Bool = false) {
|
||||
if streamToStdout {
|
||||
self.streamTo = .standardOutput
|
||||
} else if streamToStderr {
|
||||
self.streamTo = .standardError
|
||||
} else {
|
||||
self.streamTo = nil
|
||||
}
|
||||
}
|
||||
|
||||
func write(_ data: Data) throws {
|
||||
guard data.count > 0 else { return }
|
||||
storage.withLock { $0.append(data) }
|
||||
streamTo?.write(data)
|
||||
}
|
||||
|
||||
func close() throws {}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Status output helpers that write to stderr to keep stdout clean for the agent's terminal IO.
|
||||
enum ProgressUI {
|
||||
private static let boxLines = [
|
||||
"┌──────────────┐",
|
||||
"│ ░░░░░░░░░░░░ │",
|
||||
"│ ░░░░░░░░░░░░ │",
|
||||
"│ ░░░░░░░░░░░░ │",
|
||||
"│ ░░░░░░░░░░░░ │",
|
||||
"│ ░░░░░░░░░░░░ │",
|
||||
"│ ░░░░░░░░░░░░ │",
|
||||
"└──────────────┘",
|
||||
]
|
||||
|
||||
/// Prints the logo with info lines displayed to the right of the box.
|
||||
static func printLogo(info: [String] = []) {
|
||||
let yellow = "\u{1b}[33m"
|
||||
let reset = "\u{1b}[0m"
|
||||
let gap = " "
|
||||
|
||||
for (i, boxLine) in boxLines.enumerated() {
|
||||
let coloredBox = "\(yellow)\(boxLine)\(reset)"
|
||||
if i < info.count {
|
||||
FileHandle.standardError.write(Data(" \(coloredBox)\(gap)\(info[i])\n".utf8))
|
||||
} else {
|
||||
FileHandle.standardError.write(Data(" \(coloredBox)\n".utf8))
|
||||
}
|
||||
}
|
||||
// Print any remaining info lines that don't fit beside the box.
|
||||
if info.count > boxLines.count {
|
||||
for j in boxLines.count..<info.count {
|
||||
let indent = String(repeating: " ", count: 2 + boxLines[0].count + gap.count)
|
||||
FileHandle.standardError.write(Data("\(indent)\(info[j])\n".utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func printStatus(_ message: String) {
|
||||
FileHandle.standardError.write(Data("\u{1b}[32m==> \(message)\u{1b}[0m\n".utf8))
|
||||
}
|
||||
|
||||
static func printDetail(_ message: String) {
|
||||
FileHandle.standardError.write(Data("\u{1b}[32m \(message)\u{1b}[0m\n".utf8))
|
||||
}
|
||||
|
||||
static func printWarning(_ message: String) {
|
||||
FileHandle.standardError.write(Data("warning: \(message)\n".utf8))
|
||||
}
|
||||
|
||||
static func printError(_ message: String) {
|
||||
FileHandle.standardError.write(Data("\u{1b}[31merror: \(message)\u{1b}[0m\n".utf8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
extension Sandboxy {
|
||||
struct Remove: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "rm",
|
||||
abstract: "Remove sandbox instances and their preserved state"
|
||||
)
|
||||
|
||||
@Argument(help: "Name of the instance to remove")
|
||||
var names: [String] = []
|
||||
|
||||
@Flag(name: [.customShort("a"), .long], help: "Remove all instances")
|
||||
var all: Bool = false
|
||||
|
||||
func run() async throws {
|
||||
_ = try Sandboxy.loadConfig()
|
||||
|
||||
if all {
|
||||
let instances = try InstanceState.loadAll(appRoot: Sandboxy.appRoot)
|
||||
if instances.isEmpty {
|
||||
print("No instances to remove.")
|
||||
return
|
||||
}
|
||||
for instance in instances {
|
||||
let displayName = instance.name ?? instance.id
|
||||
do {
|
||||
try instance.removeAll(appRoot: Sandboxy.appRoot)
|
||||
print("Removed instance '\(displayName)'.")
|
||||
} catch {
|
||||
print("Failed to remove instance '\(displayName)': \(error)")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard !names.isEmpty else {
|
||||
print("Specify instance name(s) to remove, or use --all (-a).")
|
||||
throw ExitCode.failure
|
||||
}
|
||||
|
||||
for name in names {
|
||||
guard let instance = try InstanceState.find(name: name, appRoot: Sandboxy.appRoot) else {
|
||||
print("No instance named '\(name)' found.")
|
||||
continue
|
||||
}
|
||||
try instance.removeAll(appRoot: Sandboxy.appRoot)
|
||||
print("Removed instance '\(name)'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import Containerization
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
import Logging
|
||||
|
||||
let log = {
|
||||
LoggingSystem.bootstrap(StreamLogHandler.standardError)
|
||||
var log = Logger(label: "com.apple.containerization.sandboxy")
|
||||
log.logLevel = .info
|
||||
return log
|
||||
}()
|
||||
|
||||
@main
|
||||
struct Sandboxy: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "sandboxy",
|
||||
abstract: "Run sandboxed AI coding agents in a lightweight virtual machine",
|
||||
version: "0.1.0",
|
||||
subcommands: [
|
||||
Run.self,
|
||||
Edit.self,
|
||||
List.self,
|
||||
Remove.self,
|
||||
Cache.self,
|
||||
Config.self,
|
||||
]
|
||||
)
|
||||
|
||||
static let defaultAppRoot: URL = {
|
||||
FileManager.default.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first!
|
||||
.appendingPathComponent("com.apple.containerization.sandboxy")
|
||||
}()
|
||||
|
||||
/// Runtime data directory. Defaults to Application Support, but can be
|
||||
/// overridden via `dataDir` in `~/.config/sandboxy/config.json`.
|
||||
/// Set once during `loadConfig()` before any concurrent access.
|
||||
nonisolated(unsafe) static var appRoot: URL = defaultAppRoot
|
||||
|
||||
/// Loads the config and applies `dataDir` to `appRoot` if set.
|
||||
/// Must be called before accessing `imageStore` or `contentStore`.
|
||||
static func loadConfig() throws -> SandboxyConfig {
|
||||
let fm = FileManager.default
|
||||
let agentsDir = configRoot.appendingPathComponent("agents")
|
||||
try fm.createDirectory(at: agentsDir, withIntermediateDirectories: true)
|
||||
|
||||
let config = try SandboxyConfig.load(configRoot: configRoot)
|
||||
if let dataDir = config.dataDir {
|
||||
appRoot = URL(fileURLWithPath: dataDir)
|
||||
}
|
||||
|
||||
try fm.createDirectory(at: appRoot, withIntermediateDirectories: true)
|
||||
return config
|
||||
}
|
||||
|
||||
/// User configuration directory (`~/.config/sandboxy/`).
|
||||
/// Holds `config.json` and `agents/` definition files.
|
||||
static let configRoot: URL = {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".config")
|
||||
.appendingPathComponent("sandboxy")
|
||||
}()
|
||||
|
||||
private static let _contentStore: ContentStore = {
|
||||
try! LocalContentStore(path: appRoot.appendingPathComponent("content"))
|
||||
}()
|
||||
|
||||
private static let _imageStore: ImageStore = {
|
||||
try! ImageStore(
|
||||
path: appRoot,
|
||||
contentStore: contentStore
|
||||
)
|
||||
}()
|
||||
|
||||
static var imageStore: ImageStore {
|
||||
_imageStore
|
||||
}
|
||||
|
||||
static var contentStore: ContentStore {
|
||||
_contentStore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Optional configuration file for overriding sandboxy defaults.
|
||||
///
|
||||
/// If `config.json` exists in the sandboxy config directory, it is
|
||||
/// loaded at startup and its values override the built-in defaults. Any field
|
||||
/// can be omitted to keep the default.
|
||||
///
|
||||
/// Location: `~/.config/sandboxy/config.json`
|
||||
///
|
||||
/// Example:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "dataDir": "/Volumes/fast/sandboxy",
|
||||
/// "kernel": "/path/to/vmlinux",
|
||||
/// "initfsReference": "ghcr.io/apple/containerization/vminit:latest",
|
||||
/// "defaultCPUs": 4,
|
||||
/// "defaultMemory": "4g"
|
||||
/// }
|
||||
/// ```
|
||||
struct SandboxyConfig: Codable, Sendable {
|
||||
/// Directory for runtime data (caches, content store, containers).
|
||||
/// Defaults to `~/Library/Application Support/com.apple.containerization.sandboxy`.
|
||||
var dataDir: String?
|
||||
/// Path to a Linux kernel binary on disk. When set, the auto-download is skipped.
|
||||
var kernel: String?
|
||||
/// OCI reference for the VM init image.
|
||||
var initfsReference: String?
|
||||
/// Default number of CPUs for new containers.
|
||||
var defaultCPUs: Int?
|
||||
/// Default memory for new containers (e.g. "4g", "512m", "4096" for MB).
|
||||
var defaultMemory: String?
|
||||
|
||||
/// Built-in defaults used when no config file is present.
|
||||
static let defaults = SandboxyConfig(
|
||||
initfsReference: "ghcr.io/apple/containerization/vminit:0.30.0",
|
||||
defaultCPUs: 4,
|
||||
defaultMemory: "4g"
|
||||
)
|
||||
|
||||
/// Loads the config from `<configRoot>/config.json`, falling back to defaults
|
||||
/// for any missing fields.
|
||||
static func load(configRoot: URL) throws -> SandboxyConfig {
|
||||
let configPath = configRoot.appendingPathComponent("config.json")
|
||||
|
||||
guard FileManager.default.fileExists(atPath: configPath.path(percentEncoded: false)) else {
|
||||
return .defaults
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: configPath)
|
||||
let userConfig = try JSONDecoder().decode(SandboxyConfig.self, from: data)
|
||||
|
||||
return SandboxyConfig(
|
||||
dataDir: userConfig.dataDir,
|
||||
kernel: userConfig.kernel,
|
||||
initfsReference: userConfig.initfsReference ?? defaults.initfsReference,
|
||||
defaultCPUs: userConfig.defaultCPUs ?? defaults.defaultCPUs,
|
||||
defaultMemory: userConfig.defaultMemory ?? defaults.defaultMemory
|
||||
)
|
||||
} catch {
|
||||
throw SandboxyError.configFailedToLoad(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum SandboxyError: Error, CustomStringConvertible {
|
||||
case configFailedToLoad(error: Swift.Error)
|
||||
case installFailed(step: Int, command: String, exitCode: Int32)
|
||||
case kernelDownloadFailed(reason: String)
|
||||
case proxyFailed(reason: String)
|
||||
case kernelNotFound(path: String)
|
||||
case incompleteAgentDefinition(missing: [String])
|
||||
case invalidMountSpec(spec: String)
|
||||
case envVarNotSet(name: String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .configFailedToLoad(let error):
|
||||
return "Failed to load sandbox config: \(error)"
|
||||
case .installFailed(let step, let command, let exitCode):
|
||||
return """
|
||||
Installation step \(step) failed (exit code \(exitCode)).
|
||||
Command: \(command)
|
||||
"""
|
||||
case .kernelDownloadFailed(let reason):
|
||||
return "Failed to download kernel: \(reason)"
|
||||
case .proxyFailed(let reason):
|
||||
return "Proxy failed: \(reason)"
|
||||
case .kernelNotFound(let path):
|
||||
return "Kernel not found at \(path). Provide a valid path with -k or omit to auto-download."
|
||||
case .incompleteAgentDefinition(let missing):
|
||||
return "Agent definition is missing required fields: \(missing.joined(separator: ", ")). Use 'sandboxy config --agent claude' to see a complete example."
|
||||
case .invalidMountSpec(let spec):
|
||||
return "Invalid mount specification: '\(spec)'. Expected format: hostpath:containerpath[:ro|rw]"
|
||||
case .envVarNotSet(let name):
|
||||
return "Environment variable '\(name)' is not set on the host."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Int {
|
||||
func formattedTime() -> String {
|
||||
let secondsInMinute = 60
|
||||
let secondsInHour = secondsInMinute * 60
|
||||
let secondsInDay = secondsInHour * 24
|
||||
|
||||
let days = self / secondsInDay
|
||||
let hours = (self % secondsInDay) / secondsInHour
|
||||
let minutes = (self % secondsInHour) / secondsInMinute
|
||||
let seconds = self % secondsInMinute
|
||||
|
||||
var components = [String]()
|
||||
if days > 0 {
|
||||
components.append("\(days)d")
|
||||
}
|
||||
if hours > 0 || days > 0 {
|
||||
components.append("\(hours)h")
|
||||
}
|
||||
if minutes > 0 || hours > 0 || days > 0 {
|
||||
components.append("\(minutes)m")
|
||||
}
|
||||
components.append("\(seconds)s")
|
||||
return components.joined(separator: " ")
|
||||
}
|
||||
|
||||
func formattedNumber() -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
guard let formattedNumber = formatter.string(from: NSNumber(value: self)) else {
|
||||
return ""
|
||||
}
|
||||
return formattedNumber
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Int64 {
|
||||
func formattedSize() -> String {
|
||||
let formattedSize = ByteCountFormatter.string(fromByteCount: self, countStyle: .binary)
|
||||
return formattedSize
|
||||
}
|
||||
|
||||
func formattedSizeSpeed(from startTime: DispatchTime) -> String {
|
||||
let elapsedTimeNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds
|
||||
let elapsedTimeSeconds = Double(elapsedTimeNanoseconds) / 1_000_000_000
|
||||
guard elapsedTimeSeconds > 0 else {
|
||||
return "0 B/s"
|
||||
}
|
||||
|
||||
let speed = Double(self) / elapsedTimeSeconds
|
||||
let formattedSpeed = ByteCountFormatter.string(fromByteCount: Int64(speed), countStyle: .binary)
|
||||
return "\(formattedSpeed)/s"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension ProgressBar {
|
||||
/// A handler function to update the progress bar.
|
||||
/// - Parameter events: The events to handle.
|
||||
public func handler(_ events: [ProgressUpdateEvent]) {
|
||||
for event in events {
|
||||
switch event {
|
||||
case .setDescription(let description):
|
||||
set(description: description)
|
||||
case .setSubDescription(let subDescription):
|
||||
set(subDescription: subDescription)
|
||||
case .setItemsName(let itemsName):
|
||||
set(itemsName: itemsName)
|
||||
case .addTasks(let tasks):
|
||||
add(tasks: tasks)
|
||||
case .setTasks(let tasks):
|
||||
set(tasks: tasks)
|
||||
case .addTotalTasks(let totalTasks):
|
||||
add(totalTasks: totalTasks)
|
||||
case .setTotalTasks(let totalTasks):
|
||||
set(totalTasks: totalTasks)
|
||||
case .addSize(let size):
|
||||
add(size: size)
|
||||
case .setSize(let size):
|
||||
set(size: size)
|
||||
case .addTotalSize(let totalSize):
|
||||
add(totalSize: totalSize)
|
||||
case .setTotalSize(let totalSize):
|
||||
set(totalSize: totalSize)
|
||||
case .addItems(let items):
|
||||
add(items: items)
|
||||
case .setItems(let items):
|
||||
set(items: items)
|
||||
case .addTotalItems(let totalItems):
|
||||
add(totalItems: totalItems)
|
||||
case .setTotalItems(let totalItems):
|
||||
set(totalItems: totalItems)
|
||||
case .custom:
|
||||
// Custom events are handled by the client.
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a check to see if the progress bar should be finished.
|
||||
public func checkIfFinished() {
|
||||
let state = self.state.withLock { $0 }
|
||||
|
||||
var finished = true
|
||||
var defined = false
|
||||
if let totalTasks = state.totalTasks, totalTasks > 0 {
|
||||
// For tasks, we're showing the current task rather than the number of completed tasks.
|
||||
finished = finished && state.tasks == totalTasks
|
||||
defined = true
|
||||
}
|
||||
if let totalItems = state.totalItems, totalItems > 0 {
|
||||
finished = finished && state.items == totalItems
|
||||
defined = true
|
||||
}
|
||||
if let totalSize = state.totalSize, totalSize > 0 {
|
||||
finished = finished && state.size == totalSize
|
||||
defined = true
|
||||
}
|
||||
if defined && finished {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the current tasks.
|
||||
/// - Parameter newTasks: The current tasks to set.
|
||||
/// - Parameter render: The flag indicating whether the progress bar has to render after the update.
|
||||
public func set(tasks newTasks: Int, render: Bool = true) {
|
||||
state.withLock { $0.tasks = newTasks }
|
||||
if render {
|
||||
self.render()
|
||||
}
|
||||
checkIfFinished()
|
||||
}
|
||||
|
||||
/// Performs an addition to the current tasks.
|
||||
/// - Parameter delta: The tasks to add to the current tasks.
|
||||
/// - Parameter render: The flag indicating whether the progress bar has to render after the update.
|
||||
public func add(tasks delta: Int, render: Bool = true) {
|
||||
state.withLock {
|
||||
let newTasks = $0.tasks + delta
|
||||
$0.tasks = newTasks
|
||||
}
|
||||
if render {
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the total tasks.
|
||||
/// - Parameter newTotalTasks: The total tasks to set.
|
||||
/// - Parameter render: The flag indicating whether the progress bar has to render after the update.
|
||||
public func set(totalTasks newTotalTasks: Int, render: Bool = true) {
|
||||
state.withLock { $0.totalTasks = newTotalTasks }
|
||||
if render {
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs an addition to the total tasks.
|
||||
/// - Parameter delta: The tasks to add to the total tasks.
|
||||
/// - Parameter render: The flag indicating whether the progress bar has to render after the update.
|
||||
public func add(totalTasks delta: Int, render: Bool = true) {
|
||||
state.withLock {
|
||||
let totalTasks = $0.totalTasks ?? 0
|
||||
let newTotalTasks = totalTasks + delta
|
||||
$0.totalTasks = newTotalTasks
|
||||
}
|
||||
if render {
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the items name.
|
||||
/// - Parameter newItemsName: The current items to set.
|
||||
/// - Parameter render: The flag indicating whether the progress bar has to render after the update.
|
||||
public func set(itemsName newItemsName: String, render: Bool = true) {
|
||||
state.withLock { $0.itemsName = newItemsName }
|
||||
if render {
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the current items.
|
||||
/// - Parameter newItems: The current items to set.
|
||||
public func set(items newItems: Int, render: Bool = true) {
|
||||
state.withLock { $0.items = newItems }
|
||||
if render {
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs an addition to the current items.
|
||||
/// - Parameter delta: The items to add to the current items.
|
||||
/// - Parameter render: The flag indicating whether the progress bar has to render after the update.
|
||||
public func add(items delta: Int, render: Bool = true) {
|
||||
state.withLock {
|
||||
let newItems = $0.items + delta
|
||||
$0.items = newItems
|
||||
}
|
||||
if render {
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the total items.
|
||||
/// - Parameter newTotalItems: The total items to set.
|
||||
/// - Parameter render: The flag indicating whether the progress bar has to render after the update.
|
||||
public func set(totalItems newTotalItems: Int, render: Bool = true) {
|
||||
state.withLock { $0.totalItems = newTotalItems }
|
||||
if render {
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs an addition to the total items.
|
||||
/// - Parameter delta: The items to add to the total items.
|
||||
/// - Parameter render: The flag indicating whether the progress bar has to render after the update.
|
||||
public func add(totalItems delta: Int, render: Bool = true) {
|
||||
state.withLock {
|
||||
let totalItems = $0.totalItems ?? 0
|
||||
let newTotalItems = totalItems + delta
|
||||
$0.totalItems = newTotalItems
|
||||
}
|
||||
if render {
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the current size.
|
||||
/// - Parameter newSize: The current size to set.
|
||||
/// - Parameter render: The flag indicating whether the progress bar has to render after the update.
|
||||
public func set(size newSize: Int64, render: Bool = true) {
|
||||
state.withLock { $0.size = newSize }
|
||||
if render {
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs an addition to the current size.
|
||||
/// - Parameter delta: The size to add to the current size.
|
||||
/// - Parameter render: The flag indicating whether the progress bar has to render after the update.
|
||||
public func add(size delta: Int64, render: Bool = true) {
|
||||
state.withLock {
|
||||
let newSize = $0.size + delta
|
||||
$0.size = newSize
|
||||
}
|
||||
if render {
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the total size.
|
||||
/// - Parameter newTotalSize: The total size to set.
|
||||
/// - Parameter render: The flag indicating whether the progress bar has to render after the update.
|
||||
public func set(totalSize newTotalSize: Int64, render: Bool = true) {
|
||||
state.withLock { $0.totalSize = newTotalSize }
|
||||
if render {
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs an addition to the total size.
|
||||
/// - Parameter delta: The size to add to the total size.
|
||||
/// - Parameter render: The flag indicating whether the progress bar has to render after the update.
|
||||
public func add(totalSize delta: Int64, render: Bool = true) {
|
||||
state.withLock {
|
||||
let totalSize = $0.totalSize ?? 0
|
||||
let newTotalSize = totalSize + delta
|
||||
$0.totalSize = newTotalSize
|
||||
}
|
||||
if render {
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension ProgressBar {
|
||||
/// State for the progress bar.
|
||||
struct State {
|
||||
/// A flag indicating whether the progress bar is finished.
|
||||
var finished = false
|
||||
var iteration = 0
|
||||
private let speedInterval: DispatchTimeInterval = .seconds(1)
|
||||
|
||||
var description: String
|
||||
var subDescription: String
|
||||
var itemsName: String
|
||||
|
||||
var tasks: Int
|
||||
var totalTasks: Int?
|
||||
|
||||
var items: Int
|
||||
var totalItems: Int?
|
||||
|
||||
private var sizeUpdateTime: DispatchTime?
|
||||
private var sizeUpdateValue: Int64 = 0
|
||||
var size: Int64 {
|
||||
didSet {
|
||||
calculateSizeSpeed()
|
||||
}
|
||||
}
|
||||
|
||||
var totalSize: Int64?
|
||||
private var sizeUpdateSpeed: String?
|
||||
var sizeSpeed: String? {
|
||||
guard sizeUpdateTime == nil || sizeUpdateTime! > .now() - speedInterval - speedInterval else {
|
||||
return Int64(0).formattedSizeSpeed(from: startTime)
|
||||
}
|
||||
return sizeUpdateSpeed
|
||||
}
|
||||
var averageSizeSpeed: String {
|
||||
size.formattedSizeSpeed(from: startTime)
|
||||
}
|
||||
|
||||
var percent: String {
|
||||
var value = 0
|
||||
if let totalSize, totalSize > 0 {
|
||||
value = Int(size * 100 / totalSize)
|
||||
} else if let totalItems, totalItems > 0 {
|
||||
value = Int(items * 100 / totalItems)
|
||||
}
|
||||
value = min(value, 100)
|
||||
return "\(value)%"
|
||||
}
|
||||
|
||||
var startTime: DispatchTime
|
||||
var output = ""
|
||||
var renderTask: Task<Void, Never>?
|
||||
|
||||
init(
|
||||
description: String = "", subDescription: String = "", itemsName: String = "", tasks: Int = 0, totalTasks: Int? = nil, items: Int = 0, totalItems: Int? = nil,
|
||||
size: Int64 = 0, totalSize: Int64? = nil, startTime: DispatchTime = .now()
|
||||
) {
|
||||
self.description = description
|
||||
self.subDescription = subDescription
|
||||
self.itemsName = itemsName
|
||||
self.tasks = tasks
|
||||
self.totalTasks = totalTasks
|
||||
self.items = items
|
||||
self.totalItems = totalItems
|
||||
self.size = size
|
||||
self.totalSize = totalSize
|
||||
self.startTime = startTime
|
||||
}
|
||||
|
||||
private mutating func calculateSizeSpeed() {
|
||||
if sizeUpdateTime == nil || sizeUpdateTime! < .now() - speedInterval {
|
||||
let partSize = size - sizeUpdateValue
|
||||
let partStartTime = sizeUpdateTime ?? startTime
|
||||
let partSizeSpeed = partSize.formattedSizeSpeed(from: partStartTime)
|
||||
self.sizeUpdateSpeed = partSizeSpeed
|
||||
|
||||
sizeUpdateTime = .now()
|
||||
sizeUpdateValue = size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
|
||||
enum EscapeSequence {
|
||||
static let hideCursor = "\u{001B}[?25l"
|
||||
static let showCursor = "\u{001B}[?25h"
|
||||
static let moveUp = "\u{001B}[1A"
|
||||
static let clearToEndOfLine = "\u{001B}[K"
|
||||
}
|
||||
|
||||
extension ProgressBar {
|
||||
var termWidth: Int {
|
||||
guard
|
||||
let terminalHandle = term,
|
||||
let terminal = try? Terminal(descriptor: terminalHandle.fileDescriptor)
|
||||
else {
|
||||
return 0
|
||||
}
|
||||
|
||||
return (try? Int(terminal.size.width)) ?? 0
|
||||
}
|
||||
|
||||
/// Clears the progress bar and resets the cursor.
|
||||
public func clearAndResetCursor() {
|
||||
state.withLock { s in
|
||||
clear(state: &s)
|
||||
resetCursor()
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the progress bar.
|
||||
public func clear() {
|
||||
state.withLock { s in
|
||||
clear(state: &s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the progress bar (caller must hold state lock).
|
||||
func clear(state: inout State) {
|
||||
displayText("", state: &state)
|
||||
}
|
||||
|
||||
/// Resets the cursor.
|
||||
public func resetCursor() {
|
||||
display(EscapeSequence.showCursor)
|
||||
}
|
||||
|
||||
func display(_ text: String) {
|
||||
guard let term else {
|
||||
return
|
||||
}
|
||||
termQueue.sync {
|
||||
try? term.write(contentsOf: Data(text.utf8))
|
||||
try? term.synchronize()
|
||||
}
|
||||
}
|
||||
|
||||
func displayText(_ text: String, terminating: String = "\r") {
|
||||
state.withLock { s in
|
||||
displayText(text, state: &s, terminating: terminating)
|
||||
}
|
||||
}
|
||||
|
||||
func displayText(_ text: String, state: inout State, terminating: String = "\r") {
|
||||
state.output = text
|
||||
|
||||
// Clears previously printed lines.
|
||||
var lines = ""
|
||||
if terminating.hasSuffix("\r") && termWidth > 0 {
|
||||
let lineCount = (text.count - 1) / termWidth
|
||||
for _ in 0..<lineCount {
|
||||
lines += EscapeSequence.moveUp
|
||||
}
|
||||
}
|
||||
|
||||
let output = "\(text)\(EscapeSequence.clearToEndOfLine)\(terminating)\(lines)"
|
||||
display(output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
import Synchronization
|
||||
|
||||
/// A progress bar that updates itself as tasks are completed.
|
||||
public final class ProgressBar: Sendable {
|
||||
let config: ProgressConfig
|
||||
let state: Mutex<State>
|
||||
let term: FileHandle?
|
||||
let termQueue = DispatchQueue(label: "com.apple.container.ProgressBar")
|
||||
|
||||
/// Returns `true` if the progress bar has finished.
|
||||
public var isFinished: Bool {
|
||||
state.withLock { $0.finished }
|
||||
}
|
||||
|
||||
/// Creates a new progress bar.
|
||||
/// - Parameter config: The configuration for the progress bar.
|
||||
public init(config: ProgressConfig) {
|
||||
self.config = config
|
||||
term = isatty(config.terminal.fileDescriptor) == 1 ? config.terminal : nil
|
||||
let state = State(
|
||||
description: config.initialDescription, itemsName: config.initialItemsName, totalTasks: config.initialTotalTasks,
|
||||
totalItems: config.initialTotalItems,
|
||||
totalSize: config.initialTotalSize)
|
||||
self.state = Mutex(state)
|
||||
display(EscapeSequence.hideCursor)
|
||||
}
|
||||
|
||||
/// Allows resetting the progress state.
|
||||
public func reset() {
|
||||
state.withLock {
|
||||
$0 = State(description: config.initialDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Allows resetting the progress state of the current task.
|
||||
public func resetCurrentTask() {
|
||||
state.withLock {
|
||||
$0 = State(description: $0.description, itemsName: $0.itemsName, tasks: $0.tasks, totalTasks: $0.totalTasks, startTime: $0.startTime)
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the description of the progress bar and increments the tasks by one.
|
||||
/// - Parameter description: The description of the action being performed.
|
||||
public func set(description: String) {
|
||||
resetCurrentTask()
|
||||
|
||||
state.withLock {
|
||||
$0.description = description
|
||||
$0.subDescription = ""
|
||||
$0.tasks += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the additional description of the progress bar.
|
||||
/// - Parameter subDescription: The additional description of the action being performed.
|
||||
public func set(subDescription: String) {
|
||||
resetCurrentTask()
|
||||
|
||||
state.withLock { $0.subDescription = subDescription }
|
||||
}
|
||||
|
||||
private func start(intervalSeconds: TimeInterval) async {
|
||||
while true {
|
||||
let done = state.withLock { s -> Bool in
|
||||
guard !s.finished else {
|
||||
return true
|
||||
}
|
||||
render(state: &s)
|
||||
s.iteration += 1
|
||||
return false
|
||||
}
|
||||
|
||||
if done {
|
||||
return
|
||||
}
|
||||
|
||||
let intervalNanoseconds = UInt64(intervalSeconds * 1_000_000_000)
|
||||
guard (try? await Task.sleep(nanoseconds: intervalNanoseconds)) != nil else {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts an animation of the progress bar.
|
||||
/// - Parameter intervalSeconds: The time interval between updates in seconds.
|
||||
public func start(intervalSeconds: TimeInterval = 0.04) {
|
||||
state.withLock {
|
||||
if $0.renderTask != nil {
|
||||
return
|
||||
}
|
||||
$0.renderTask = Task(priority: .utility) {
|
||||
await start(intervalSeconds: intervalSeconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finishes the progress bar.
|
||||
/// - Parameter clearScreen: If true, clears the progress bar from the screen.
|
||||
public func finish(clearScreen: Bool = false) {
|
||||
state.withLock { s in
|
||||
guard !s.finished else { return }
|
||||
|
||||
s.finished = true
|
||||
s.renderTask?.cancel()
|
||||
|
||||
let shouldClear = clearScreen || config.clearOnFinish
|
||||
if !config.disableProgressUpdates && !shouldClear {
|
||||
let output = draw(state: s)
|
||||
displayText(output, state: &s, terminating: "\n")
|
||||
}
|
||||
|
||||
if shouldClear {
|
||||
clear(state: &s)
|
||||
}
|
||||
resetCursor()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProgressBar {
|
||||
private func secondsSinceStart(from startTime: DispatchTime) -> Int {
|
||||
let timeDifferenceNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds
|
||||
let timeDifferenceSeconds = Int(floor(Double(timeDifferenceNanoseconds) / 1_000_000_000))
|
||||
return timeDifferenceSeconds
|
||||
}
|
||||
|
||||
func render(force: Bool = false) {
|
||||
guard term != nil && !config.disableProgressUpdates else {
|
||||
return
|
||||
}
|
||||
state.withLock { s in
|
||||
render(state: &s, force: force)
|
||||
}
|
||||
}
|
||||
|
||||
func render(state: inout State, force: Bool = false) {
|
||||
guard term != nil && !config.disableProgressUpdates else {
|
||||
return
|
||||
}
|
||||
guard force || !state.finished else {
|
||||
return
|
||||
}
|
||||
let output = draw(state: state)
|
||||
displayText(output, state: &state)
|
||||
}
|
||||
|
||||
/// Detail levels for progressive truncation.
|
||||
enum DetailLevel: Int, CaseIterable {
|
||||
case full = 0 // Everything shown
|
||||
case noSpeed // Drop speed from parens
|
||||
case noSize // Drop size from parens
|
||||
case noParens // Drop parens entirely (items, size, speed)
|
||||
case noTime // Drop time
|
||||
case noDescription // Drop description/subdescription
|
||||
case minimal // Just spinner, tasks, percent
|
||||
}
|
||||
|
||||
func draw(state: State) -> String {
|
||||
let width = termWidth
|
||||
// If no terminal or width unknown, use full detail
|
||||
guard width > 0 else {
|
||||
return draw(state: state, detail: .full)
|
||||
}
|
||||
|
||||
// Add a small buffer to prevent wrapping issues during resize
|
||||
let bufferChars = 4
|
||||
let targetWidth = max(1, width - bufferChars)
|
||||
|
||||
for detail in DetailLevel.allCases {
|
||||
let output = draw(state: state, detail: detail)
|
||||
if output.count <= targetWidth {
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
return draw(state: state, detail: .minimal)
|
||||
}
|
||||
|
||||
func draw(state: State, detail: DetailLevel) -> String {
|
||||
var components = [String]()
|
||||
|
||||
// Spinner - always shown if configured (unless using progress bar)
|
||||
if config.showSpinner && !config.showProgressBar {
|
||||
if !state.finished {
|
||||
let spinnerIcon = config.theme.getSpinnerIcon(state.iteration)
|
||||
components.append("\(spinnerIcon)")
|
||||
} else {
|
||||
components.append("\(config.theme.done)")
|
||||
}
|
||||
}
|
||||
|
||||
// Tasks [x/y] - always shown if configured
|
||||
if config.showTasks, let totalTasks = state.totalTasks {
|
||||
let tasks = min(state.tasks, totalTasks)
|
||||
components.append("[\(tasks)/\(totalTasks)]")
|
||||
}
|
||||
|
||||
// Description - dropped at noDescription level
|
||||
if detail.rawValue < DetailLevel.noDescription.rawValue {
|
||||
if config.showDescription && !state.description.isEmpty {
|
||||
components.append("\(state.description)")
|
||||
if !state.subDescription.isEmpty {
|
||||
components.append("\(state.subDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let allowProgress = !config.ignoreSmallSize || state.totalSize == nil || state.totalSize! > Int64(1024 * 1024)
|
||||
let value = state.totalSize != nil ? state.size : Int64(state.items)
|
||||
let total = state.totalSize ?? Int64(state.totalItems ?? 0)
|
||||
|
||||
// Percent - always shown if configured
|
||||
if config.showPercent && total > 0 && allowProgress {
|
||||
components.append("\(state.finished ? "100%" : state.percent)")
|
||||
}
|
||||
|
||||
// Progress bar - always shown if configured
|
||||
if config.showProgressBar, total > 0, allowProgress {
|
||||
let usedWidth = components.joined(separator: " ").count + 45
|
||||
let remainingWidth = max(config.width - usedWidth, 1)
|
||||
let barLength = state.finished ? remainingWidth : Int(Int64(remainingWidth) * value / total)
|
||||
let barPaddingLength = remainingWidth - barLength
|
||||
let bar = "\(String(repeating: config.theme.bar, count: barLength))\(String(repeating: " ", count: barPaddingLength))"
|
||||
components.append("|\(bar)|")
|
||||
}
|
||||
|
||||
// Additional components in parens - progressively dropped
|
||||
if detail.rawValue < DetailLevel.noParens.rawValue {
|
||||
var additionalComponents = [String]()
|
||||
|
||||
// Items - dropped at noParens level
|
||||
if config.showItems, state.items > 0 {
|
||||
var itemsName = ""
|
||||
if !state.itemsName.isEmpty {
|
||||
itemsName = " \(state.itemsName)"
|
||||
}
|
||||
if state.finished {
|
||||
if let totalItems = state.totalItems {
|
||||
additionalComponents.append("\(totalItems.formattedNumber())\(itemsName)")
|
||||
}
|
||||
} else {
|
||||
if let totalItems = state.totalItems {
|
||||
additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)")
|
||||
} else {
|
||||
additionalComponents.append("\(state.items.formattedNumber())\(itemsName)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Size and speed - progressively dropped
|
||||
if state.size > 0 && allowProgress {
|
||||
if state.finished {
|
||||
// Size - dropped at noSize level
|
||||
if detail.rawValue < DetailLevel.noSize.rawValue {
|
||||
if config.showSize {
|
||||
if let totalSize = state.totalSize {
|
||||
var formattedTotalSize = totalSize.formattedSize()
|
||||
formattedTotalSize = adjustFormattedSize(formattedTotalSize)
|
||||
additionalComponents.append(formattedTotalSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Size - dropped at noSize level
|
||||
var formattedCombinedSize = ""
|
||||
if detail.rawValue < DetailLevel.noSize.rawValue && config.showSize {
|
||||
var formattedSize = state.size.formattedSize()
|
||||
formattedSize = adjustFormattedSize(formattedSize)
|
||||
if let totalSize = state.totalSize {
|
||||
var formattedTotalSize = totalSize.formattedSize()
|
||||
formattedTotalSize = adjustFormattedSize(formattedTotalSize)
|
||||
formattedCombinedSize = combineSize(size: formattedSize, totalSize: formattedTotalSize)
|
||||
} else {
|
||||
formattedCombinedSize = formattedSize
|
||||
}
|
||||
}
|
||||
|
||||
// Speed - dropped at noSpeed level
|
||||
var formattedSpeed = ""
|
||||
if detail.rawValue < DetailLevel.noSpeed.rawValue && config.showSpeed {
|
||||
formattedSpeed = "\(state.sizeSpeed ?? state.averageSizeSpeed)"
|
||||
formattedSpeed = adjustFormattedSize(formattedSpeed)
|
||||
}
|
||||
|
||||
if !formattedCombinedSize.isEmpty && !formattedSpeed.isEmpty {
|
||||
additionalComponents.append(formattedCombinedSize)
|
||||
additionalComponents.append(formattedSpeed)
|
||||
} else if !formattedCombinedSize.isEmpty {
|
||||
additionalComponents.append(formattedCombinedSize)
|
||||
} else if !formattedSpeed.isEmpty {
|
||||
additionalComponents.append(formattedSpeed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if additionalComponents.count > 0 {
|
||||
let joinedAdditionalComponents = additionalComponents.joined(separator: ", ")
|
||||
components.append("(\(joinedAdditionalComponents))")
|
||||
}
|
||||
}
|
||||
|
||||
// Time - dropped at noTime level
|
||||
if detail.rawValue < DetailLevel.noTime.rawValue && config.showTime {
|
||||
let timeDifferenceSeconds = secondsSinceStart(from: state.startTime)
|
||||
let formattedTime = timeDifferenceSeconds.formattedTime()
|
||||
components.append("[\(formattedTime)]")
|
||||
}
|
||||
|
||||
return components.joined(separator: " ")
|
||||
}
|
||||
|
||||
private func adjustFormattedSize(_ size: String) -> String {
|
||||
// Ensure we always have one digit after the decimal point to prevent flickering.
|
||||
let zero = Int64(0).formattedSize()
|
||||
let decimalSep = Locale.current.decimalSeparator ?? "."
|
||||
guard !size.contains(decimalSep), let first = size.first, first.isNumber || !size.contains(zero) else {
|
||||
return size
|
||||
}
|
||||
var size = size
|
||||
for unit in ["MB", "GB", "TB"] {
|
||||
size = size.replacingOccurrences(of: " \(unit)", with: "\(decimalSep)0 \(unit)")
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
private func combineSize(size: String, totalSize: String) -> String {
|
||||
let sizeComponents = size.split(separator: " ", maxSplits: 1)
|
||||
let totalSizeComponents = totalSize.split(separator: " ", maxSplits: 1)
|
||||
guard sizeComponents.count == 2, totalSizeComponents.count == 2 else {
|
||||
return "\(size)/\(totalSize)"
|
||||
}
|
||||
let sizeNumber = sizeComponents[0]
|
||||
let sizeUnit = sizeComponents[1]
|
||||
let totalSizeNumber = totalSizeComponents[0]
|
||||
let totalSizeUnit = totalSizeComponents[1]
|
||||
guard sizeUnit == totalSizeUnit else {
|
||||
return "\(size)/\(totalSize)"
|
||||
}
|
||||
return "\(sizeNumber)/\(totalSizeNumber) \(totalSizeUnit)"
|
||||
}
|
||||
|
||||
func draw() -> String {
|
||||
state.withLock { draw(state: $0) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A configuration for displaying a progress bar.
|
||||
public struct ProgressConfig: Sendable {
|
||||
/// The file handle for progress updates.
|
||||
let terminal: FileHandle
|
||||
/// The initial description of the progress bar.
|
||||
let initialDescription: String
|
||||
/// The initial additional description of the progress bar.
|
||||
let initialSubDescription: String
|
||||
/// The initial items name (e.g., "files").
|
||||
let initialItemsName: String
|
||||
/// A flag indicating whether to show a spinner (e.g., "⠋").
|
||||
/// The spinner is hidden when a progress bar is shown.
|
||||
public let showSpinner: Bool
|
||||
/// A flag indicating whether to show tasks and total tasks (e.g., "[1]" or "[1/3]").
|
||||
public let showTasks: Bool
|
||||
/// A flag indicating whether to show the description (e.g., "Downloading...").
|
||||
public let showDescription: Bool
|
||||
/// A flag indicating whether to show a percentage (e.g., "100%").
|
||||
/// The percentage is hidden when no total size and total items are set.
|
||||
public let showPercent: Bool
|
||||
/// A flag indicating whether to show a progress bar (e.g., "|███ |").
|
||||
/// The progress bar is hidden when no total size and total items are set.
|
||||
public let showProgressBar: Bool
|
||||
/// A flag indicating whether to show items and total items (e.g., "(22 it)" or "(22/22 it)").
|
||||
public let showItems: Bool
|
||||
/// A flag indicating whether to show a size and a total size (e.g., "(22 MB)" or "(22/22 MB)").
|
||||
public let showSize: Bool
|
||||
/// A flag indicating whether to show a speed (e.g., "(4.834 MB/s)").
|
||||
/// The speed is combined with the size and total size (e.g., "(22/22 MB, 4.834 MB/s)").
|
||||
/// The speed is hidden when no total size is set.
|
||||
public let showSpeed: Bool
|
||||
/// A flag indicating whether to show the elapsed time (e.g., "[4s]").
|
||||
public let showTime: Bool
|
||||
/// The flag indicating whether to ignore small size values (less than 1 MB). For example, this may help to avoid reaching 100% after downloading metadata before downloading content.
|
||||
public let ignoreSmallSize: Bool
|
||||
/// The initial total tasks of the progress bar.
|
||||
let initialTotalTasks: Int?
|
||||
/// The initial total size of the progress bar.
|
||||
let initialTotalSize: Int64?
|
||||
/// The initial total items of the progress bar.
|
||||
let initialTotalItems: Int?
|
||||
/// The width of the progress bar in characters.
|
||||
public let width: Int
|
||||
/// The theme of the progress bar.
|
||||
public let theme: ProgressTheme
|
||||
/// The flag indicating whether to clear the progress bar before resetting the cursor.
|
||||
public let clearOnFinish: Bool
|
||||
/// The flag indicating whether to update the progress bar.
|
||||
public let disableProgressUpdates: Bool
|
||||
/// Creates a new instance of `ProgressConfig`.
|
||||
/// - Parameters:
|
||||
/// - terminal: The file handle for progress updates. The default value is `FileHandle.standardError`.
|
||||
/// - description: The initial description of the progress bar. The default value is `""`.
|
||||
/// - subDescription: The initial additional description of the progress bar. The default value is `""`.
|
||||
/// - itemsName: The initial items name. The default value is `"it"`.
|
||||
/// - showSpinner: A flag indicating whether to show a spinner. The default value is `true`.
|
||||
/// - showTasks: A flag indicating whether to show tasks and total tasks. The default value is `false`.
|
||||
/// - showDescription: A flag indicating whether to show the description. The default value is `true`.
|
||||
/// - showPercent: A flag indicating whether to show a percentage. The default value is `true`.
|
||||
/// - showProgressBar: A flag indicating whether to show a progress bar. The default value is `false`.
|
||||
/// - showItems: A flag indicating whether to show items and a total items. The default value is `false`.
|
||||
/// - showSize: A flag indicating whether to show a size and a total size. The default value is `true`.
|
||||
/// - showSpeed: A flag indicating whether to show a speed. The default value is `true`.
|
||||
/// - showTime: A flag indicating whether to show the elapsed time. The default value is `true`.
|
||||
/// - ignoreSmallSize: A flag indicating whether to ignore small size values. The default value is `false`.
|
||||
/// - totalTasks: The initial total tasks of the progress bar. The default value is `nil`.
|
||||
/// - totalItems: The initial total items of the progress bar. The default value is `nil`.
|
||||
/// - totalSize: The initial total size of the progress bar. The default value is `nil`.
|
||||
/// - width: The width of the progress bar in characters. The default value is `120`.
|
||||
/// - theme: The theme of the progress bar. The default value is `nil`.
|
||||
/// - clearOnFinish: The flag indicating whether to clear the progress bar before resetting the cursor. The default is `true`.
|
||||
/// - disableProgressUpdates: The flag indicating whether to update the progress bar. The default is `false`.
|
||||
public init(
|
||||
terminal: FileHandle = .standardError,
|
||||
description: String = "",
|
||||
subDescription: String = "",
|
||||
itemsName: String = "it",
|
||||
showSpinner: Bool = true,
|
||||
showTasks: Bool = false,
|
||||
showDescription: Bool = true,
|
||||
showPercent: Bool = true,
|
||||
showProgressBar: Bool = false,
|
||||
showItems: Bool = false,
|
||||
showSize: Bool = true,
|
||||
showSpeed: Bool = true,
|
||||
showTime: Bool = true,
|
||||
ignoreSmallSize: Bool = false,
|
||||
totalTasks: Int? = nil,
|
||||
totalItems: Int? = nil,
|
||||
totalSize: Int64? = nil,
|
||||
width: Int = 120,
|
||||
theme: ProgressTheme? = nil,
|
||||
clearOnFinish: Bool = true,
|
||||
disableProgressUpdates: Bool = false
|
||||
) throws {
|
||||
if let totalTasks {
|
||||
guard totalTasks > 0 else {
|
||||
throw Error.invalid("totalTasks must be greater than zero")
|
||||
}
|
||||
}
|
||||
if let totalItems {
|
||||
guard totalItems > 0 else {
|
||||
throw Error.invalid("totalItems must be greater than zero")
|
||||
}
|
||||
}
|
||||
if let totalSize {
|
||||
guard totalSize > 0 else {
|
||||
throw Error.invalid("totalSize must be greater than zero")
|
||||
}
|
||||
}
|
||||
|
||||
self.terminal = terminal
|
||||
self.initialDescription = description
|
||||
self.initialSubDescription = subDescription
|
||||
self.initialItemsName = itemsName
|
||||
|
||||
self.showSpinner = showSpinner
|
||||
self.showTasks = showTasks
|
||||
self.showDescription = showDescription
|
||||
self.showPercent = showPercent
|
||||
self.showProgressBar = showProgressBar
|
||||
self.showItems = showItems
|
||||
self.showSize = showSize
|
||||
self.showSpeed = showSpeed
|
||||
self.showTime = showTime
|
||||
|
||||
self.ignoreSmallSize = ignoreSmallSize
|
||||
self.initialTotalTasks = totalTasks
|
||||
self.initialTotalItems = totalItems
|
||||
self.initialTotalSize = totalSize
|
||||
|
||||
self.width = width
|
||||
self.theme = theme ?? DefaultProgressTheme()
|
||||
self.clearOnFinish = clearOnFinish
|
||||
self.disableProgressUpdates = disableProgressUpdates
|
||||
}
|
||||
}
|
||||
|
||||
extension ProgressConfig {
|
||||
/// An enumeration of errors that can occur when creating a `ProgressConfig`.
|
||||
public enum Error: Swift.Error, CustomStringConvertible {
|
||||
case invalid(String)
|
||||
|
||||
/// The description of the error.
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .invalid(let reason):
|
||||
return "failed to validate config (\(reason))"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A type that represents a task whose progress is being monitored.
|
||||
public struct ProgressTask: Sendable, Equatable {
|
||||
private var id = UUID()
|
||||
private var coordinator: ProgressTaskCoordinator
|
||||
|
||||
init(manager: ProgressTaskCoordinator) {
|
||||
self.coordinator = manager
|
||||
}
|
||||
|
||||
static public func == (lhs: ProgressTask, rhs: ProgressTask) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
/// Returns `true` if this task is the currently active task, `false` otherwise.
|
||||
public func isCurrent() async -> Bool {
|
||||
guard let currentTask = await coordinator.currentTask else {
|
||||
return false
|
||||
}
|
||||
return currentTask == self
|
||||
}
|
||||
}
|
||||
|
||||
/// A type that coordinates progress tasks to ignore updates from completed tasks.
|
||||
public actor ProgressTaskCoordinator {
|
||||
var currentTask: ProgressTask?
|
||||
|
||||
/// Creates an instance of `ProgressTaskCoordinator`.
|
||||
public init() {}
|
||||
|
||||
/// Returns a new task that should be monitored for progress updates.
|
||||
public func startTask() -> ProgressTask {
|
||||
let newTask = ProgressTask(manager: self)
|
||||
currentTask = newTask
|
||||
return newTask
|
||||
}
|
||||
|
||||
/// Performs cleanup when the monitored tasks complete.
|
||||
public func finish() {
|
||||
currentTask = nil
|
||||
}
|
||||
|
||||
/// Returns a handler that updates the progress of a given task.
|
||||
/// - Parameters:
|
||||
/// - task: The task whose progress is being updated.
|
||||
/// - progressUpdate: The handler to invoke when progress updates are received.
|
||||
public static func handler(for task: ProgressTask, from progressUpdate: @escaping ProgressUpdateHandler) -> ProgressUpdateHandler {
|
||||
{ events in
|
||||
// Ignore updates from completed tasks.
|
||||
if await task.isCurrent() {
|
||||
await progressUpdate(events)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
/// A theme for progress bar.
|
||||
public protocol ProgressTheme: Sendable {
|
||||
/// The icons used to represent a spinner.
|
||||
var spinner: [String] { get }
|
||||
/// The icon used to represent a progress bar.
|
||||
var bar: String { get }
|
||||
/// The icon used to indicate that a progress bar finished.
|
||||
var done: String { get }
|
||||
}
|
||||
|
||||
public struct DefaultProgressTheme: ProgressTheme {
|
||||
public let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
public let bar = "█"
|
||||
public let done = "✔"
|
||||
}
|
||||
|
||||
extension ProgressTheme {
|
||||
func getSpinnerIcon(_ iteration: Int) -> String {
|
||||
spinner[iteration % spinner.count]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
public enum ProgressUpdateEvent: Sendable {
|
||||
case setDescription(String)
|
||||
case setSubDescription(String)
|
||||
case setItemsName(String)
|
||||
case addTasks(Int)
|
||||
case setTasks(Int)
|
||||
case addTotalTasks(Int)
|
||||
case setTotalTasks(Int)
|
||||
case addItems(Int)
|
||||
case setItems(Int)
|
||||
case addTotalItems(Int)
|
||||
case setTotalItems(Int)
|
||||
case addSize(Int64)
|
||||
case setSize(Int64)
|
||||
case addTotalSize(Int64)
|
||||
case setTotalSize(Int64)
|
||||
case custom(String)
|
||||
}
|
||||
|
||||
public typealias ProgressUpdateHandler = @Sendable (_ events: [ProgressUpdateEvent]) async -> Void
|
||||
|
||||
public protocol ProgressAdapter {
|
||||
associatedtype T
|
||||
static func handler(from progressUpdate: ProgressUpdateHandler?) -> (@Sendable ([T]) async -> Void)?
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct StandardError {
|
||||
func write(_ string: String) {
|
||||
if let data = string.data(using: .utf8) {
|
||||
FileHandle.standardError.write(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user