chore: import upstream snapshot with attribution
container project - merge build / Invoke build (push) Successful in 1s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:06:18 +08:00
commit e84fb4a79e
474 changed files with 68321 additions and 0 deletions
@@ -0,0 +1,48 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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 SystemPackage
/// Provides the application data root path.
public struct ApplicationRoot {
/// The environment variable that if set, determines the root directory for the application data store.
/// Otherwise, the system uses the default "~/Library/Application Support/com.apple.container".
public static let environmentName = "CONTAINER_APP_ROOT"
/// The default root directory used when ``environmentName`` is not set:
/// `~/Library/Application Support/com.apple.container`.
public static let defaultPath = FilePath(
FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first!.path(percentEncoded: false)
)
.appending(FilePath.Component("com.apple.container"))
/// The resolved root directory path, always lexically normalized.
///
/// If the environment variable is set to an absolute path, that path is used directly.
/// If it is set to a relative path, the path is resolved against the working directory.
/// Otherwise, ``defaultPath`` is used.
public static let path = FilePath(FileManager.default.currentDirectoryPath).resolve(
ProcessInfo.processInfo.environment[environmentName],
defaultPath: defaultPath
)
/// The pathname to the root directory
public static let pathname = path.string
}
@@ -0,0 +1,48 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container 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 SystemPackage
extension FilePath {
/// Resolves a pathname string relative to this path.
///
/// The result is lexically normalized `.` components are removed and `..` components
/// collapse the preceding component. Absolute pathnames are returned normalized as-is;
/// relative pathnames are appended to `self` before normalizing.
///
/// - Parameter pathname: The pathname to resolve.
/// - Returns: The resolved ``FilePath``, or `nil` if `pathname` is `nil` or empty.
package func resolve(_ pathname: String?) -> FilePath? {
guard let pathname, !pathname.isEmpty else { return nil }
let path = FilePath(pathname)
guard !path.isAbsolute else { return path.lexicallyNormalized() }
return self.appending(path.components).lexicallyNormalized()
}
/// Resolves a pathname string relative to this path, falling back to a default.
///
/// The result is lexically normalized `.` components are removed and `..` components
/// collapse the preceding component. Absolute pathnames are returned normalized as-is;
/// relative pathnames are appended to `self` before normalizing.
///
/// - Parameters:
/// - pathname: The pathname to resolve.
/// - defaultPath: The path returned when `pathname` is `nil` or empty.
/// - Returns: The resolved ``FilePath``, or `defaultPath` lexically normalized if `pathname` is `nil` or empty.
package func resolve(_ pathname: String?, defaultPath: FilePath) -> FilePath {
resolve(pathname) ?? defaultPath.lexicallyNormalized()
}
}
+49
View File
@@ -0,0 +1,49 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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 ContainerVersion
import Foundation
import SystemPackage
/// Provides the application installation root path.
public struct InstallRoot {
/// The environment variable that if set, determines the root directory for installed application.
/// Otherwise, the system computes the install path as the parent of the directory containing the
/// application binary (for example, "/usr/local/bin/container" -> "/usr/local").
public static let environmentName = "CONTAINER_INSTALL_ROOT"
/// The default root directory used when the environment variable is not set.
///
/// Computed as the grandparent of ``CommandLine/executablePath``
/// (for example, `/usr/local/bin/container` `/usr/local`).
/// Lexically normalized but not canonical, as symlinks in the executable path are not resolved.
public static let defaultPath = CommandLine.executablePath
.removingLastComponent()
.removingLastComponent()
/// The resolved root directory path, always lexically normalized.
///
/// If the environment variable is set to an absolute path, that path is used directly.
/// If it is set to a relative path, the path is resolved against the working directory.
/// Otherwise, ``defaultPath`` is used.
public static let path = FilePath(FileManager.default.currentDirectoryPath).resolve(
ProcessInfo.processInfo.environment[environmentName],
defaultPath: defaultPath
)
/// The pathname to the root directory
public static let pathname = path.string
}
+133
View File
@@ -0,0 +1,133 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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.
//===----------------------------------------------------------------------===//
#if os(macOS)
import Foundation
public struct LaunchPlist: Encodable {
static let debugTarget = "CONTAINER_DEBUG_LAUNCHD_LABEL"
public enum Domain: String, Codable {
case Aqua
case Background
case System
}
public let label: String
public let arguments: [String]
public let environment: [String: String]?
public let cwd: String?
public let username: String?
public let groupname: String?
public let limitLoadToSessionType: [Domain]?
public let runAtLoad: Bool?
public let stdin: String?
public let stdout: String?
public let stderr: String?
public let disabled: Bool?
public let program: String?
public let keepAlive: Bool?
public let machServices: [String: Bool]?
public let waitForDebugger: Bool?
enum CodingKeys: String, CodingKey {
case label = "Label"
case arguments = "ProgramArguments"
case environment = "EnvironmentVariables"
case cwd = "WorkingDirectory"
case username = "UserName"
case groupname = "GroupName"
case limitLoadToSessionType = "LimitLoadToSessionType"
case runAtLoad = "RunAtLoad"
case stdin = "StandardInPath"
case stdout = "StandardOutPath"
case stderr = "StandardErrorPath"
case disabled = "Disabled"
case program = "Program"
case keepAlive = "KeepAlive"
case machServices = "MachServices"
case waitForDebugger = "WaitForDebugger"
}
static private func getWaitForDebugger(label: String, fromArg: Bool?) -> Bool? {
if let fromArg {
return fromArg
}
let env = ProcessInfo.processInfo.environment
if let debugTarget = env[Self.debugTarget],
label == debugTarget || label.starts(with: debugTarget + ".")
{
return true
}
return nil
}
public init(
label: String,
arguments: [String],
environment: [String: String]? = nil,
cwd: String? = nil,
username: String? = nil,
groupname: String? = nil,
limitLoadToSessionType: [Domain]? = nil,
runAtLoad: Bool? = nil,
stdin: String? = nil,
stdout: String? = nil,
stderr: String? = nil,
disabled: Bool? = nil,
program: String? = nil,
keepAlive: Bool? = nil,
machServices: [String]? = nil,
waitForDebugger: Bool? = nil
) {
self.label = label
self.arguments = arguments
self.environment = environment
self.cwd = cwd
self.username = username
self.groupname = groupname
self.limitLoadToSessionType = limitLoadToSessionType
self.runAtLoad = runAtLoad
self.stdin = stdin
self.stdout = stdout
self.stderr = stderr
self.disabled = disabled
self.program = program
self.keepAlive = keepAlive
self.waitForDebugger = Self.getWaitForDebugger(label: label, fromArg: waitForDebugger)
if let services = machServices {
var machServices: [String: Bool] = [:]
for service in services {
machServices[service] = true
}
self.machServices = machServices
} else {
self.machServices = nil
}
}
}
extension LaunchPlist {
public func encode() throws -> Data {
let enc = PropertyListEncoder()
enc.outputFormat = .xml
return try enc.encode(self)
}
}
#endif
+37
View File
@@ -0,0 +1,37 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container 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 SystemPackage
/// Provides the application data root path.
public struct LogRoot {
/// The environment variable that if set, determines the root directory for log files.
/// Otherwise, the application uses the macOS log facility.
public static let environmentName = "CONTAINER_LOG_ROOT"
/// The resolved root directory for log files, or `nil` if the environment variable is not set.
///
/// When non-nil, the path is always lexically normalized.
/// If the environment variable is set to an absolute path, that path is used directly.
/// If it is set to a relative path, the path is resolved against the working directory.
public static let path = FilePath(FileManager.default.currentDirectoryPath).resolve(
ProcessInfo.processInfo.environment[environmentName]
)
/// The pathname to the root directory
public static let pathname = path?.string
}
+120
View File
@@ -0,0 +1,120 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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
/// Value type that contains the plugin configuration, the parsed name of the
/// plugin and whether a CLI surface for the plugin was found.
public struct Plugin: Sendable, Codable {
private static let machServicePrefix = "com.apple.container."
/// Pathname to installation directory for plugins.
public let binaryURL: URL
/// Configuration for the plugin.
public let config: PluginConfig
/// Pathname to resources directory for plugins.
public let resourceURL: URL?
public init(binaryURL: URL, config: PluginConfig, resourceURL: URL? = nil) {
self.binaryURL = binaryURL
self.config = config
self.resourceURL = resourceURL
}
}
extension Plugin {
public var name: String { binaryURL.lastPathComponent }
public var shouldBoot: Bool {
guard let config = self.config.servicesConfig else {
return false
}
return config.loadAtBoot
}
public func getLaunchdLabel(instanceId: String? = nil) -> String {
// Use the plugin name for the launchd label.
guard let instanceId else {
return "\(Self.machServicePrefix)\(self.name)"
}
return "\(Self.machServicePrefix)\(self.name).\(instanceId)"
}
public func getMachServices(instanceId: String? = nil) -> [String] {
// Use the service type for the mach service.
guard let config = self.config.servicesConfig else {
return []
}
var services = [String]()
for service in config.services {
let serviceName: String
if let instanceId {
serviceName = "\(Self.machServicePrefix)\(service.type.rawValue).\(name).\(instanceId)"
} else {
serviceName = "\(Self.machServicePrefix)\(service.type.rawValue).\(name)"
}
services.append(serviceName)
}
return services
}
public func getMachService(instanceId: String? = nil, type: PluginConfig.DaemonPluginType) -> String? {
guard hasType(type) else {
return nil
}
guard let instanceId else {
return "\(Self.machServicePrefix)\(type.rawValue).\(name)"
}
return "\(Self.machServicePrefix)\(type.rawValue).\(name).\(instanceId)"
}
public func hasType(_ type: PluginConfig.DaemonPluginType) -> Bool {
guard let config = self.config.servicesConfig else {
return false
}
guard !(config.services.filter { $0.type == type }.isEmpty) else {
return false
}
return true
}
}
extension Plugin {
public func exec(args: [String]) throws {
var args = args
let executable = self.binaryURL.path
args[0] = executable
let argv = args.map { strdup($0) } + [nil]
guard execvp(executable, argv) != -1 else {
throw POSIXError.fromErrno()
}
fatalError("unreachable")
}
func helpText(padding: Int) -> String {
guard !self.name.isEmpty else {
return ""
}
let namePadded = name.padding(toLength: padding, withPad: " ", startingAt: 0)
return " " + namePadded + self.config.abstract
}
}
+134
View File
@@ -0,0 +1,134 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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 TOML
/// PluginConfig details all of the fields to describe and register a plugin.
/// A plugin is registered by creating a subdirectory `<application-root>/user-plugins`,
/// where the name of the subdirectory is the name of the plugin, and then placing a
/// file named `config.toml` (or fall back to the legacy `config.json` file) inside with
/// the schema below. If `services` is filled in then there MUST be a binary named
/// matching the plugin name in a `bin` subdirectory inside the same directory as
/// the `config.toml`. An example of a valid plugin directory structure would be
/// $ tree foobar
/// foobar
/// bin
/// foobar
/// config.toml (`config.json`)
public struct PluginConfig: Sendable, Codable {
/// Categories of services that can be offered through plugins.
public enum DaemonPluginType: String, Sendable, Codable {
/// A runtime plugin provides an XPC API through which the lifecycle
/// of a **single** container can be managed.
/// A runtime daemon plugin would typically also have a counterpart
/// CLI plugin which knows how to talk to the API exposed by the runtime plugin.
/// The API server ensures that a single instance of the plugin is configured
/// for a given container such that the client can communicate with it given an instance id.
case runtime
/// A network plugin provides an XPC API through which IP address allocations on a given
/// network can be managed. The API server ensures that a single instance
/// of this plugin is configured for a given network. Similar to the runtime plugin, it typically
/// would be accompanied by a CLI plugin that knows how to communicate with the XPC API
/// given an instance id.
case network
/// A core plugin provides an XPC API to manage a given type of resource.
/// The API server ensures that there exist only a single running instance
/// of this plugin type. A core plugin can be thought of a singleton whose lifecycle
/// is tied to that of the API server. Core plugins can be used to expand the base functionality
/// provided by the API server. As with the other plugin types, it maybe associated with a client
/// side plugin that communicates with the XPC service exposed by the daemon plugin.
case core
/// Reserved for future use. Currently there is no difference between a core and auxiliary daemon plugin.
case auxiliary
}
// An XPC service that the plugin publishes.
public struct Service: Sendable, Codable {
/// The type of the service the daemon is exposing.
/// One plugin can expose multiple services of different types.
///
/// The plugin MUST expose a MachService at
/// `com.apple.container.{type}.{name}.[{id}]` for
/// each service that it exposes.
public let type: DaemonPluginType
/// Optional description of this service.
public let description: String?
}
/// Descriptor for the services that the plugin offers.
public struct ServicesConfig: Sendable, Codable {
/// Load the plugin into launchd when the API server starts.
public let loadAtBoot: Bool
/// Launch the plugin binary as soon as it loads into launchd.
public let runAtLoad: Bool
/// The service types that the plugin provides.
public let services: [Service]
/// An optional parameter that include any command line arguments
/// that must be passed to the plugin binary when it is loaded.
public let defaultArguments: [String]
}
/// Short description of the plugin surface. This will be displayed as the
/// help-text for CLI plugins, and will be returned in API calls to view loaded
/// plugins from the daemon.
public let abstract: String
/// Author of the plugin. This is solely metadata.
public let author: String?
/// Services configuration. Specify nil for a CLI plugin, and an empty array for
/// that does not publish any XPC services.
public let servicesConfig: ServicesConfig?
public init(abstract: String, author: String?, servicesConfig: ServicesConfig?) {
self.abstract = abstract
self.author = author
self.servicesConfig = servicesConfig
}
}
extension PluginConfig {
public var isCLI: Bool { self.servicesConfig == nil }
}
extension PluginConfig {
/// Initialize from a config file, selecting the decoder based on file extension.
/// Supports `.toml` (via TOMLDecoder) and `.json` (via JSONDecoder).
public init?(configURL: URL) throws {
let fm = FileManager.default
if !fm.fileExists(atPath: configURL.path) {
return nil
}
guard let data = fm.contents(atPath: configURL.path) else {
return nil
}
switch configURL.pathExtension {
case "toml":
guard let content = String(data: data, encoding: .utf8) else {
return nil
}
self = try TOMLDecoder().decode(PluginConfig.self, from: content)
case "json":
self = try JSONDecoder().decode(PluginConfig.self, from: data)
default:
return nil
}
}
}
+135
View File
@@ -0,0 +1,135 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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 Logging
/// Describes the configuration and binary file locations for a plugin.
public protocol PluginFactory: Sendable {
/// Create a plugin from the plugin path, if it conforms to the layout.
func create(installURL: URL) throws -> Plugin?
/// Create a plugin from the plugin parent path and name, if it conforms to the layout.
func create(parentURL: URL, name: String) throws -> Plugin?
}
/// Default layout which uses a Unix-like structure.
public struct DefaultPluginFactory: PluginFactory {
// Order matters: earlier entries take priority during config file discovery.
private static let configFilenames: [String] = ["config.toml", "config.json"]
private let logger: Logger
public init(logger: Logger) {
self.logger = logger
}
/// Returns the URL of the first config file found in `directory`, preferring TOML over JSON.
static func findConfigURL(in directory: URL, logger: Logger) -> URL? {
let fm = FileManager.default
for filename in configFilenames {
let url = directory.appending(path: filename)
if fm.fileExists(atPath: url.path) {
if url.pathExtension == "json" {
logger.warning(
"Plugin using legacy config.json; please migrate to config.toml",
metadata: ["path": "\(url.path)"]
)
}
return url
}
}
return nil
}
public func create(installURL: URL) throws -> Plugin? {
let fm = FileManager.default
guard let configURL = Self.findConfigURL(in: installURL, logger: logger) else {
return nil
}
guard let config = try PluginConfig(configURL: configURL) else {
return nil
}
let name = installURL.lastPathComponent
let binaryURL = installURL.appending(path: "bin").appending(path: name)
guard fm.fileExists(atPath: binaryURL.path) else {
return nil
}
var resourceURL: URL? = nil
if case let url = installURL.appending(path: "resources"), fm.fileExists(atPath: url.path) {
resourceURL = url
}
return Plugin(binaryURL: binaryURL, config: config, resourceURL: resourceURL)
}
public func create(parentURL: URL, name: String) throws -> Plugin? {
try create(installURL: parentURL.appendingPathComponent(name))
}
}
/// Layout which uses a macOS application bundle structure.
public struct AppBundlePluginFactory: PluginFactory {
private static let appSuffix = ".app"
private let logger: Logger
public init(logger: Logger) {
self.logger = logger
}
public func create(installURL: URL) throws -> Plugin? {
let fm = FileManager.default
let contentResources =
installURL
.appending(path: "Contents")
.appending(path: "Resources")
guard let configURL = DefaultPluginFactory.findConfigURL(in: contentResources, logger: logger) else {
return nil
}
guard let config = try PluginConfig(configURL: configURL) else {
return nil
}
let appName = installURL.lastPathComponent
guard appName.hasSuffix(Self.appSuffix) else {
return nil
}
let name = String(appName.dropLast(Self.appSuffix.count))
let binaryURL =
installURL
.appending(path: "Contents")
.appending(path: "MacOS")
.appending(path: name)
guard fm.fileExists(atPath: binaryURL.path) else {
return nil
}
var resourceURL: URL? = nil
if case let url = contentResources.appending(path: "resources"), fm.fileExists(atPath: url.path) {
resourceURL = url
}
return Plugin(binaryURL: binaryURL, config: config, resourceURL: resourceURL)
}
public func create(parentURL: URL, name: String) throws -> Plugin? {
try create(installURL: parentURL.appendingPathComponent("\(name)\(Self.appSuffix)"))
}
}
+284
View File
@@ -0,0 +1,284 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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
import Logging
import SystemPackage
public struct PluginLoader: Sendable {
private let appRoot: URL
private let installRoot: URL
private let logRoot: FilePath?
private let pluginDirectories: [URL]
private let pluginFactories: [PluginFactory]
private let log: Logger?
public typealias PluginQualifier = ((Plugin) -> Bool)
// A path on disk managed by the PluginLoader, where it stores
// runtime data for loaded plugins. This includes the launchd plists
// and logs files.
private let pluginResourceRoot: URL
public init(
appRoot: URL,
installRoot: URL,
logRoot: FilePath?,
pluginDirectories: [URL],
pluginFactories: [PluginFactory],
log: Logger? = nil
) throws {
let pluginResourceRoot = appRoot.appendingPathComponent("plugin-state")
try FileManager.default.createDirectory(at: pluginResourceRoot, withIntermediateDirectories: true)
self.pluginResourceRoot = pluginResourceRoot
self.appRoot = appRoot
self.installRoot = installRoot
self.logRoot = logRoot
self.pluginDirectories = pluginDirectories
self.pluginFactories = pluginFactories
self.log = log
}
static public func userPluginsDir(installRoot: URL) -> URL {
installRoot
.appending(path: "libexec")
.appending(path: "container-plugins")
.resolvingSymlinksInPath()
}
}
extension PluginLoader {
public func alterCLIHelpText(original: String) -> String {
var plugins = findPlugins()
plugins = plugins.filter { $0.config.isCLI }
guard !plugins.isEmpty else {
return original
}
var lines = original.split(separator: "\n").map { String($0) }
let sectionHeader = "PLUGINS:"
lines.append(sectionHeader)
for plugin in plugins {
let helpText = plugin.helpText(padding: 24)
lines.append(helpText)
}
return lines.joined(separator: "\n")
}
/// Scan all plugin directories and detect plugins.
public func findPlugins() -> [Plugin] {
let fm = FileManager.default
// Maintain a set for tracking shadowed plugins
var pluginNames = Set<String>()
var plugins: [Plugin] = []
for pluginDir in pluginDirectories {
// Skip nonexistent plugin parent directories
if !fm.fileExists(atPath: pluginDir.path) {
continue
}
// Get all entries under the parent directory
let resolvedPluginDir = pluginDir.resolvingSymlinksInPath()
guard
let urls = try? fm.contentsOfDirectory(
at: resolvedPluginDir,
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
options: .skipsHiddenFiles
)
else {
continue
}
// Filter out all but plugin installation directories
let installURLs = urls.filter { url in
if url.isDirectory {
return true
}
if url.isSymlink {
var isDirectory: ObjCBool = false
_ = fm.fileExists(atPath: url.resolvingSymlinksInPath().path(percentEncoded: false), isDirectory: &isDirectory)
return isDirectory.boolValue
}
return false
}
for installURL in installURLs {
do {
// Create a plugin with the first factory that can grok the layout under the install URL
guard
let plugin = try
(pluginFactories.compactMap {
try $0.create(installURL: installURL)
}.first)
else {
log?.warning(
"not installing plugin with missing configuration",
metadata: [
"path": "\(installURL.path)"
]
)
continue
}
// Warn and skip if this plugin name has been encountered already
guard !pluginNames.contains(plugin.name) else {
log?.warning(
"not installing shadowed plugin",
metadata: [
"path": "\(installURL.path)",
"name": "\(plugin.name)",
])
continue
}
// Add the plugin to the list
plugins.append(plugin)
pluginNames.insert(plugin.name)
} catch {
log?.warning(
"not installing plugin with invalid configuration",
metadata: [
"path": "\(installURL.path)",
"error": "\(error)",
]
)
}
}
}
return plugins
}
/// Locate a plugin with a specific name.
public func findPlugin(name: String, log: Logger? = nil) -> Plugin? {
do {
for pluginDirectory in pluginDirectories {
for PluginFactory in pluginFactories {
// throw means that the factory is correct but the plugin is broken
if let plugin = try PluginFactory.create(parentURL: pluginDirectory, name: name) {
return plugin
}
}
}
} catch {
log?.warning(
"not installing plugin with invalid configuration",
metadata: [
"name": "\(name)",
"error": "\(error)",
]
)
}
return nil
}
}
extension PluginLoader {
public static let proxyKeys: Set<String> = {
var keys: Set<String> = [
"http_proxy", "HTTP_PROXY",
"https_proxy", "HTTPS_PROXY",
"no_proxy", "NO_PROXY",
]
#if CONTAINER_COVERAGE
// Allows LLVM coverage profiling data to be written by launchd-managed
// helper processes. Compiled in only for coverage enabled builds.
keys.insert("LLVM_PROFILE_FILE")
#endif
return keys
}()
public func registerWithLaunchd(
plugin: Plugin,
pluginStateRoot: URL? = nil,
args: [String]? = nil,
instanceId: String? = nil,
debug: Bool = false,
) throws {
// We only care about loading plugins that have a service
// to expose; otherwise, they may just be CLI commands.
guard let serviceConfig = plugin.config.servicesConfig else {
return
}
let id = plugin.getLaunchdLabel(instanceId: instanceId)
log?.info("Registering plugin", metadata: ["id": "\(id)"])
let rootURL = pluginStateRoot ?? self.pluginResourceRoot.appending(path: plugin.name)
let resourceURL = plugin.resourceURL
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
var env = Self.filterEnvironment()
env[ApplicationRoot.environmentName] = appRoot.path(percentEncoded: false)
env[InstallRoot.environmentName] = installRoot.path(percentEncoded: false)
if let logRoot {
env[LogRoot.environmentName] =
logRoot.isAbsolute
? logRoot.string
: FilePath(FileManager.default.currentDirectoryPath).appending(logRoot.components).string
}
let processedArgs = (args ?? ["start"]) + (resourceURL.map { ["--resources", $0.path] } ?? []) + (debug ? ["--debug"] : [])
let plist = LaunchPlist(
label: id,
arguments: [plugin.binaryURL.path] + processedArgs + serviceConfig.defaultArguments,
environment: env,
limitLoadToSessionType: [.Aqua, .Background, .System],
runAtLoad: serviceConfig.runAtLoad,
machServices: plugin.getMachServices(instanceId: instanceId)
)
let plistUrl = rootURL.appendingPathComponent("service.plist")
let data = try plist.encode()
try data.write(to: plistUrl)
try ServiceManager.register(plistPath: plistUrl.path)
}
public func deregisterWithLaunchd(plugin: Plugin, instanceId: String? = nil) throws {
// We only care about loading plugins that have a service
// to expose; otherwise, they may just be CLI commands.
guard plugin.config.servicesConfig != nil else {
return
}
let domain = try ServiceManager.getDomainString()
let label = "\(domain)/\(plugin.getLaunchdLabel(instanceId: instanceId))"
log?.info("Deregistering plugin", metadata: ["id": "\(plugin.getLaunchdLabel())"])
try ServiceManager.deregister(fullServiceLabel: label)
}
public static func filterEnvironment(
env: [String: String] = ProcessInfo.processInfo.environment,
additionalAllowKeys: Set<String> = Self.proxyKeys
) -> [String: String] {
env.filter { key, _ in
key.hasPrefix("CONTAINER_") || additionalAllowKeys.contains(key)
}
}
}
@@ -0,0 +1,36 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container 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 ContainerizationError
import Foundation
import SystemPackage
public struct PluginStateRoot {
private let plugin: FilePath.Component
public init(plugin: String) throws {
guard let plugin = FilePath.Component(plugin) else {
throw ContainerizationError(.invalidArgument, message: "invalid plugin name \(plugin)")
}
self.plugin = plugin
}
public var path: FilePath {
ApplicationRoot.path
.appending(FilePath.Component("plugin-state"))
.appending(plugin)
}
}
@@ -0,0 +1,137 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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 ContainerizationError
import Foundation
public struct ServiceManager {
private static func runLaunchctlCommand(args: [String]) throws -> Int32 {
let launchctl = Foundation.Process()
launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl")
launchctl.arguments = args
let null = FileHandle.nullDevice
launchctl.standardOutput = null
launchctl.standardError = null
try launchctl.run()
launchctl.waitUntilExit()
return launchctl.terminationStatus
}
/// Register a service by providing the path to a plist.
public static func register(plistPath: String) throws {
let domain = try Self.getDomainString()
_ = try runLaunchctlCommand(args: ["bootstrap", domain, plistPath])
}
/// Deregister a service by a launchd label.
public static func deregister(fullServiceLabel label: String) throws {
_ = try runLaunchctlCommand(args: ["bootout", label])
}
/// Deregister a service and pass return status
public static func deregister(fullServiceLabel label: String, status: inout Int32) throws {
status = try runLaunchctlCommand(args: ["bootout", label])
}
/// Restart a service by a launchd label.
public static func kickstart(fullServiceLabel label: String) throws {
_ = try runLaunchctlCommand(args: ["kickstart", "-k", label])
}
/// Send a signal to a service by a launchd label.
public static func kill(fullServiceLabel label: String, signal: Int32 = 15) throws {
_ = try runLaunchctlCommand(args: ["kill", "\(signal)", label])
}
/// Retrieve labels for all loaded launch units.
public static func enumerate() throws -> [String] {
let launchctl = Foundation.Process()
launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl")
launchctl.arguments = ["list"]
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
launchctl.standardOutput = stdoutPipe
launchctl.standardError = stderrPipe
try launchctl.run()
let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
launchctl.waitUntilExit()
let status = launchctl.terminationStatus
guard status == 0 else {
throw ContainerizationError(
.internalError, message: "command `launchctl list` failed with status \(status), message: \(String(data: stderrData, encoding: .utf8) ?? "no error message")")
}
guard let outputText = String(data: outputData, encoding: .utf8) else {
throw ContainerizationError(
.internalError, message: "could not decode output of command `launchctl list`, message: \(String(data: stderrData, encoding: .utf8) ?? "no error message")")
}
// The third field of each line of launchctl list output is the label
return outputText.split { $0.isNewline }
.map { String($0).split { $0.isWhitespace } }
.filter { $0.count >= 3 }
.map { String($0[2]) }
}
/// Check if a service has been registered or not.
public static func isRegistered(fullServiceLabel label: String) throws -> Bool {
let exitStatus = try runLaunchctlCommand(args: ["list", label])
return exitStatus == 0
}
private static func getLaunchdSessionType() throws -> String {
let launchctl = Foundation.Process()
launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl")
launchctl.arguments = ["managername"]
let null = FileHandle.nullDevice
let stdoutPipe = Pipe()
launchctl.standardOutput = stdoutPipe
launchctl.standardError = null
try launchctl.run()
let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
launchctl.waitUntilExit()
let status = launchctl.terminationStatus
guard status == 0 else {
throw ContainerizationError(.internalError, message: "command `launchctl managername` failed with status \(status)")
}
guard let outputText = String(data: outputData, encoding: .utf8) else {
throw ContainerizationError(.internalError, message: "could not decode output of command `launchctl managername`")
}
return outputText.trimmingCharacters(in: .whitespacesAndNewlines)
}
public static func getDomainString() throws -> String {
let currentSessionType = try getLaunchdSessionType()
switch currentSessionType {
case LaunchPlist.Domain.System.rawValue:
return LaunchPlist.Domain.System.rawValue.lowercased()
case LaunchPlist.Domain.Background.rawValue:
return "user/\(getuid())"
case LaunchPlist.Domain.Aqua.rawValue:
return "gui/\(getuid())"
default:
throw ContainerizationError(.internalError, message: "unsupported session type \(currentSessionType)")
}
}
}