← back to Exo
app/EXO/EXO/ExoProcessController.swift
449 lines
import AppKit
import Combine
import Foundation
private let customNamespaceKey = "EXOCustomNamespace"
private let hfTokenKey = "EXOHFToken"
private let hfEndpointKey = "EXOHFEndpoint"
private let enableImageModelsKey = "EXOEnableImageModels"
private let offlineModeKey = "EXOOfflineMode"
private let fastSynchEnabledKey = "EXOFastSynchEnabled"
private let onboardingCompletedKey = "EXOOnboardingCompleted"
private let defaultModelsDirKey = "EXODefaultModelsDir"
private let additionalModelsDirsKey = "EXOAdditionalModelsDirs"
private let readOnlyModelsDirsKey = "EXOReadOnlyModelsDirs"
private let customEnvironmentVariablesKey = "EXOCustomEnvironmentVariables"
/// A user-defined environment variable that is injected into the exo child
/// process at launch. Used as an escape hatch for env vars that don't have
/// first-class typed UI in Settings.
struct CustomEnvironmentVariable: Codable, Identifiable, Equatable {
var id: UUID
var key: String
var value: String
init(id: UUID = UUID(), key: String = "", value: String = "") {
self.id = id
self.key = key
self.value = value
}
}
@MainActor
final class ExoProcessController: ObservableObject {
enum Status: Equatable {
case stopped
case starting
case running
case failed(message: String)
var displayText: String {
switch self {
case .stopped:
return "Stopped"
case .starting:
return "Starting…"
case .running:
return "Running"
case .failed:
return "Failed"
}
}
}
static let exoDirectoryURL: URL = {
URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".exo")
}()
@Published private(set) var status: Status = .stopped
@Published private(set) var lastError: String?
@Published private(set) var launchCountdownSeconds: Int?
@Published var customNamespace: String = {
return UserDefaults.standard.string(forKey: customNamespaceKey) ?? ""
}()
{
didSet {
UserDefaults.standard.set(customNamespace, forKey: customNamespaceKey)
}
}
@Published var hfToken: String = {
return UserDefaults.standard.string(forKey: hfTokenKey) ?? ""
}()
{
didSet {
UserDefaults.standard.set(hfToken, forKey: hfTokenKey)
}
}
@Published var hfEndpoint: String = {
return UserDefaults.standard.string(forKey: hfEndpointKey) ?? ""
}()
{
didSet {
UserDefaults.standard.set(hfEndpoint, forKey: hfEndpointKey)
}
}
@Published var enableImageModels: Bool = {
return UserDefaults.standard.bool(forKey: enableImageModelsKey)
}()
{
didSet {
UserDefaults.standard.set(enableImageModels, forKey: enableImageModelsKey)
}
}
@Published var offlineMode: Bool = {
return UserDefaults.standard.bool(forKey: offlineModeKey)
}()
{
didSet {
UserDefaults.standard.set(offlineMode, forKey: offlineModeKey)
}
}
@Published var fastSynchEnabled: Bool = {
if UserDefaults.standard.object(forKey: fastSynchEnabledKey) == nil {
return true
}
return UserDefaults.standard.bool(forKey: fastSynchEnabledKey)
}()
{
didSet {
UserDefaults.standard.set(fastSynchEnabled, forKey: fastSynchEnabledKey)
}
}
@Published var defaultModelsDir: String = {
return UserDefaults.standard.string(forKey: defaultModelsDirKey) ?? ""
}()
{
didSet {
UserDefaults.standard.set(defaultModelsDir, forKey: defaultModelsDirKey)
}
}
@Published var additionalModelsDirs: String = {
return UserDefaults.standard.string(forKey: additionalModelsDirsKey) ?? ""
}()
{
didSet {
UserDefaults.standard.set(additionalModelsDirs, forKey: additionalModelsDirsKey)
}
}
@Published var readOnlyModelsDirs: String = {
return UserDefaults.standard.string(forKey: readOnlyModelsDirsKey) ?? ""
}()
{
didSet {
UserDefaults.standard.set(readOnlyModelsDirs, forKey: readOnlyModelsDirsKey)
}
}
@Published var customEnvironmentVariables: [CustomEnvironmentVariable] = {
guard
let data = UserDefaults.standard.data(forKey: customEnvironmentVariablesKey),
let decoded = try? JSONDecoder().decode(
[CustomEnvironmentVariable].self, from: data
)
else {
return []
}
return decoded
}()
{
didSet {
guard let data = try? JSONEncoder().encode(customEnvironmentVariables) else {
return
}
UserDefaults.standard.set(data, forKey: customEnvironmentVariablesKey)
}
}
/// Fires once when EXO transitions to `.running` for the very first time (fresh install).
@Published private(set) var isFirstLaunchReady = false
private var process: Process?
private var runtimeDirectoryURL: URL?
private var pendingLaunchTask: Task<Void, Never>?
func launchIfNeeded() {
guard process?.isRunning != true else { return }
launch()
}
func launch() {
do {
guard process?.isRunning != true else { return }
cancelPendingLaunch()
status = .starting
lastError = nil
let runtimeURL = try resolveRuntimeDirectory()
runtimeDirectoryURL = runtimeURL
let executableURL = runtimeURL.appendingPathComponent("exo")
let child = Process()
child.executableURL = executableURL
let exoHomeURL = Self.exoDirectoryURL
try? FileManager.default.createDirectory(
at: exoHomeURL, withIntermediateDirectories: true
)
child.currentDirectoryURL = exoHomeURL
child.environment = makeEnvironment(for: runtimeURL)
child.standardOutput = FileHandle.nullDevice
child.standardError = FileHandle.nullDevice
child.terminationHandler = { [weak self] proc in
Task { @MainActor in
guard let self else { return }
self.process = nil
switch self.status {
case .stopped:
break
case .failed:
break
default:
self.status = .failed(
message: "Exited with code \(proc.terminationStatus)"
)
self.lastError = "Process exited with code \(proc.terminationStatus)"
}
}
}
try child.run()
process = child
status = .running
// Show welcome popout on every launch
isFirstLaunchReady = true
} catch {
process = nil
status = .failed(message: "Launch error")
lastError = error.localizedDescription
}
}
func stop() {
guard let process else {
status = .stopped
return
}
process.terminationHandler = nil
status = .stopped
guard process.isRunning else {
self.process = nil
return
}
let proc = process
self.process = nil
Task.detached {
proc.interrupt()
for _ in 0..<50 {
if !proc.isRunning { return }
try? await Task.sleep(nanoseconds: 100_000_000)
}
if proc.isRunning {
proc.terminate()
}
for _ in 0..<30 {
if !proc.isRunning { return }
try? await Task.sleep(nanoseconds: 100_000_000)
}
if proc.isRunning {
kill(proc.processIdentifier, SIGKILL)
}
}
}
func restart() {
stop()
launch()
}
/// Mark onboarding as completed (user interacted with the welcome popout).
func markOnboardingCompleted() {
UserDefaults.standard.set(true, forKey: onboardingCompletedKey)
}
/// Reset onboarding so the welcome popout appears on next launch.
func resetOnboarding() {
UserDefaults.standard.removeObject(forKey: onboardingCompletedKey)
isFirstLaunchReady = false
}
func scheduleLaunch(after seconds: TimeInterval) {
cancelPendingLaunch()
let start = max(1, Int(ceil(seconds)))
pendingLaunchTask = Task { [weak self] in
guard let self else { return }
await MainActor.run {
self.launchCountdownSeconds = start
}
var remaining = start
while remaining > 0 {
try? await Task.sleep(nanoseconds: 1_000_000_000)
remaining -= 1
if Task.isCancelled { return }
await MainActor.run {
if remaining > 0 {
self.launchCountdownSeconds = remaining
} else {
self.launchCountdownSeconds = nil
self.launchIfNeeded()
}
}
}
}
}
func cancelPendingLaunch() {
pendingLaunchTask?.cancel()
pendingLaunchTask = nil
launchCountdownSeconds = nil
}
func revealRuntimeDirectory() {
guard let runtimeDirectoryURL else { return }
NSWorkspace.shared.activateFileViewerSelecting([runtimeDirectoryURL])
}
func statusTintColor() -> NSColor {
switch status {
case .running:
return .systemGreen
case .starting:
return .systemYellow
case .failed:
return .systemRed
case .stopped:
return .systemGray
}
}
private func resolveRuntimeDirectory() throws -> URL {
let fileManager = FileManager.default
if let override = ProcessInfo.processInfo.environment["EXO_RUNTIME_DIR"] {
let url = URL(fileURLWithPath: override).standardizedFileURL
if fileManager.fileExists(atPath: url.path) {
return url
}
}
if let resourceRoot = Bundle.main.resourceURL {
let bundled = resourceRoot.appendingPathComponent("exo", isDirectory: true)
if fileManager.fileExists(atPath: bundled.path) {
return bundled
}
}
let repoCandidate = URL(fileURLWithPath: fileManager.currentDirectoryPath)
.appendingPathComponent("dist/exo", isDirectory: true)
if fileManager.fileExists(atPath: repoCandidate.path) {
return repoCandidate
}
throw RuntimeError("Unable to locate the packaged EXO runtime.")
}
private func makeEnvironment(for runtimeURL: URL) -> [String: String] {
var environment = ProcessInfo.processInfo.environment
environment["EXO_RUNTIME_DIR"] = runtimeURL.path
environment["EXO_ZENOH_NAMESPACE"] = computeNamespace()
if !hfToken.isEmpty {
environment["HF_TOKEN"] = hfToken
}
if !hfEndpoint.isEmpty {
environment["HF_ENDPOINT"] = hfEndpoint
}
if enableImageModels {
environment["EXO_ENABLE_IMAGE_MODELS"] = "true"
}
if offlineMode {
environment["EXO_OFFLINE"] = "true"
}
environment["EXO_FAST_SYNCH"] = fastSynchEnabled ? "true" : "false"
var paths: [String] = []
if let existing = environment["PATH"], !existing.isEmpty {
paths = existing.split(separator: ":").map(String.init)
}
let required = [
runtimeURL.path,
runtimeURL.appendingPathComponent("_internal").path,
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin",
]
for entry in required.reversed() {
if !paths.contains(entry) {
paths.insert(entry, at: 0)
}
}
environment["PATH"] = paths.joined(separator: ":")
let trimmedDefaultModelsDir = defaultModelsDir.trimmingCharacters(in: .whitespaces)
if !trimmedDefaultModelsDir.isEmpty {
environment["EXO_DEFAULT_MODELS_DIR"] = trimmedDefaultModelsDir
}
let trimmedAdditionalModelsDirs = additionalModelsDirs.trimmingCharacters(in: .whitespaces)
if !trimmedAdditionalModelsDirs.isEmpty {
environment["EXO_MODELS_DIRS"] = trimmedAdditionalModelsDirs
}
let trimmedReadOnlyModelsDirs = readOnlyModelsDirs.trimmingCharacters(in: .whitespaces)
if !trimmedReadOnlyModelsDirs.isEmpty {
environment["EXO_MODELS_READ_ONLY_DIRS"] = trimmedReadOnlyModelsDirs
}
// Apply user-defined arbitrary environment variables last so that
// power users can override any of the typed fields above when
// necessary. Empty keys are ignored.
for variable in customEnvironmentVariables {
let trimmedKey = variable.key.trimmingCharacters(in: .whitespaces)
guard !trimmedKey.isEmpty else { continue }
environment[trimmedKey] = variable.value
}
return environment
}
private func buildTag() -> String {
if let tag = Bundle.main.infoDictionary?["EXOBuildTag"] as? String, !tag.isEmpty {
return tag
}
if let short = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String,
!short.isEmpty
{
return short
}
return "dev"
}
private func computeNamespace() -> String {
let base = buildTag()
let custom = customNamespace.trimmingCharacters(in: .whitespaces)
return custom.isEmpty ? base : custom
}
}
struct RuntimeError: LocalizedError {
let message: String
init(_ message: String) {
self.message = message
}
var errorDescription: String? {
message
}
}