Files
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

404 lines
16 KiB
Swift

// AppConfig — single source of truth: `<basePath>/settings.json`.
//
// basePath resolution (first match wins):
// 1. OMLX_BASE_PATH env var. macOS Finder/Dock launches inherit only the
// launchd environment — not the user's shell env — so this is mostly
// relevant when the app was launched from a terminal or the user has
// `launchctl setenv` configured.
// 2. ~/Library/Application Support/oMLX/base-path — a one-line
// bootstrap file Swift owns. Written when the user changes the base
// path through the in-app Storage row, so Finder relaunches still
// land on the right data root. This is the *only* Swift-side config
// we keep — its sole job is to tell us where settings.json lives.
// 3. Default `~/.omlx`.
//
// Every other field (host, port, api_key, model_dirs, hf_endpoint) lives
// in `<basePath>/settings.json` — owned by the running Python server,
// written by AppConfig.save() when the server is offline. Unknown keys
// in settings.json (cache, integrations, ui, …) are preserved verbatim.
// While the server is running, prefer the HTTP PATCH path
// (/admin/api/global-settings) — the server is the owner.
//
// Env overrides for host/port/api_key are still honored last so dev
// escape-hatches always win.
import Foundation
struct AppConfig: Sendable, Equatable, Codable {
/// The raw bind address the user configured (e.g. `0.0.0.0`, `127.0.0.1`, `localhost`).
var bindAddress: String
/// The connectable host — normalises wildcard/local binds to loopback
/// because they are bind addresses, not always connectable URL hosts.
var host: String {
Self.connectableHost(for: bindAddress)
}
var port: Int
/// Whether the macOS app should start the managed server automatically
/// when the app launches.
var autoStartOnLaunch: Bool
var apiKey: String?
/// Always `OMLX_BASE_PATH` if set, else `~/.omlx`. Set at load() time
/// from the current process env so the running app sees a consistent
/// view; `AppServices.changeBasePath` updates the env in place when
/// the user moves their data root.
var basePath: String
/// Always a literal path. Defaults to `<basePath>/models` and can be
/// pointed at any folder; never empty in memory or on disk. Kept as
/// the primary path for older call sites and the deprecated server key.
var modelDir: String
/// Ordered model roots. The first path mirrors `modelDir`; additional
/// entries are scanned for local models but are not download targets.
var modelDirs: [String] = []
/// HuggingFace endpoint override. Empty = default `huggingface.co`.
var hfEndpoint: String
init(
bindAddress: String,
port: Int,
autoStartOnLaunch: Bool = true,
apiKey: String?,
basePath: String,
modelDir: String,
modelDirs: [String]? = nil,
hfEndpoint: String
) {
self.bindAddress = bindAddress
self.port = port
self.autoStartOnLaunch = autoStartOnLaunch
self.apiKey = apiKey
self.basePath = basePath
self.modelDir = modelDir
let cleanedModelDirs = (modelDirs ?? [modelDir]).filter {
!$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
self.modelDirs = cleanedModelDirs.isEmpty ? [modelDir] : cleanedModelDirs
if let primary = self.modelDirs.first {
self.modelDir = primary
}
self.hfEndpoint = hfEndpoint
}
static var `default`: AppConfig {
let base = currentBasePath()
let modelDir = defaultModelDir(forBasePath: base)
return AppConfig(
bindAddress: "127.0.0.1",
port: 8000,
autoStartOnLaunch: true,
apiKey: nil,
basePath: base,
modelDir: modelDir,
modelDirs: [modelDir],
hfEndpoint: ""
)
}
var effectiveModelDirs: [String] {
let dirs = modelDirs.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
return dirs.isEmpty ? [modelDir] : dirs
}
mutating func setModelDirs(_ dirs: [String]) {
let cleaned = dirs.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
guard let primary = cleaned.first else { return }
modelDirs = cleaned
modelDir = primary
}
/// `<basePath>/models` — the literal the server falls back to when the
/// user hasn't pointed model storage somewhere else.
static func defaultModelDir(forBasePath base: String) -> String {
URL(fileURLWithPath: base, isDirectory: true)
.appendingPathComponent("models", isDirectory: true)
.path
}
static func connectableHost(for bindAddress: String) -> String {
var host = primaryBindHost(for: bindAddress)
if host.hasPrefix("[") && host.hasSuffix("]") {
host = String(host.dropFirst().dropLast())
}
switch host.lowercased() {
case "", "0.0.0.0", "::", "localhost", "127.0.0.1":
return "127.0.0.1"
default:
return host
}
}
static func httpURL(host: String, port: Int, path: String = "") -> URL? {
let urlHost = normalizedURLHost(host)
let urlPath = path.isEmpty ? "" : (path.hasPrefix("/") ? path : "/" + path)
if urlHost.contains(":") {
let escapedHost = urlHost.replacingOccurrences(of: "%", with: "%25")
return URL(string: "http://[\(escapedHost)]:\(port)\(urlPath)")
}
var comps = URLComponents()
comps.scheme = "http"
comps.host = urlHost
comps.port = port
if !urlPath.isEmpty {
comps.path = urlPath
}
return comps.url
}
var baseURL: URL? {
Self.httpURL(host: host, port: port)
}
private static func primaryBindHost(for bindAddress: String) -> String {
bindAddress
.split(separator: ",", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.first { !$0.isEmpty } ?? ""
}
private static func normalizedURLHost(_ host: String) -> String {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") {
return String(trimmed.dropFirst().dropLast())
}
return trimmed
}
// MARK: - Path resolution
/// Resolve the user-effective base path. `OMLX_BASE_PATH` env wins,
/// then the bootstrap file under Library/Application Support, then the
/// `~/.omlx` default.
static func currentBasePath() -> String {
let env = ProcessInfo.processInfo.environment
if let custom = env["OMLX_BASE_PATH"], !custom.isEmpty {
return normalize(path: custom)
}
if let stored = readBootstrapBasePath() {
return stored
}
return defaultBasePath()
}
private static func normalize(path: String) -> String {
((path as NSString).expandingTildeInPath as NSString).standardizingPath
}
// MARK: - Bootstrap file (basePath only)
/// One-line file that survives Finder relaunches when the launchd env
/// has no `OMLX_BASE_PATH`. The single field it carries is the path
/// itself; nothing else lives here.
static func bootstrapFileURL() -> URL {
appSupportURL().appendingPathComponent("base-path")
}
/// Returns the basePath stored in the bootstrap file, normalized.
/// `nil` when the file is missing or empty.
static func readBootstrapBasePath() -> String? {
let url = bootstrapFileURL()
guard FileManager.default.fileExists(atPath: url.path),
let raw = try? String(contentsOf: url, encoding: .utf8)
else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : normalize(path: trimmed)
}
/// Persist the basePath to the bootstrap file so future Finder launches
/// see it. Pass `nil` to delete the file (for the "reset to default"
/// flow).
static func writeBootstrapBasePath(_ value: String?) throws {
let url = bootstrapFileURL()
if let value, !value.isEmpty {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try (value + "\n").write(to: url, atomically: true, encoding: .utf8)
} else if FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.removeItem(at: url)
}
}
/// Apply a basePath choice across every layer that contributes to
/// `currentBasePath()` resolution on the next launch:
/// • process env via `setenv`/`unsetenv` (so the spawned child
/// server inherits the choice immediately)
/// • bootstrap file (so Finder relaunches and the app-managed CLI shim
/// see it without editing shell rc files)
/// Pass `nil` (or an empty string) to clear every override — the
/// "reset to ~/.omlx default" flow. Callers should compare against
/// `defaultBasePath()` first and pass `nil` when the user chose the
/// default so a default install isn't left with stale state.
static func persistBasePath(_ path: String?) {
let value = (path?.isEmpty ?? true) ? nil : path
if let value {
setenv(ShellEnvWriter.variableName, value, 1)
} else {
unsetenv(ShellEnvWriter.variableName)
}
try? writeBootstrapBasePath(value)
}
static func defaultBasePath() -> String {
let home = FileManager.default.homeDirectoryForCurrentUser
return home.appendingPathComponent(".omlx", isDirectory: true).path
}
static func settingsURL(basePath: String) -> URL {
URL(fileURLWithPath: basePath, isDirectory: true)
.appendingPathComponent("settings.json")
}
static func settingsURL() -> URL {
settingsURL(basePath: currentBasePath())
}
/// Library/Application Support directory — still used for the Swift
/// parent's stdout/stderr capture of the Python child (logs, not
/// configuration). The base directory is created on demand.
static func appSupportURL() -> URL {
let base = FileManager.default.urls(
for: .applicationSupportDirectory, in: .userDomainMask
).first ?? FileManager.default.temporaryDirectory
let dir = base.appendingPathComponent("oMLX", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
/// True when settings.json exists at the current basePath. Used to
/// gate the Welcome wizard.
static var hasExistingConfig: Bool {
FileManager.default.fileExists(atPath: settingsURL().path)
}
// MARK: - Load
static func load() -> AppConfig {
var c = Self.default
if let slice = try? readSettings(basePath: c.basePath) {
if let h = slice.bindAddress { c.bindAddress = h }
if let p = slice.port { c.port = p }
if let autoStart = slice.autoStartOnLaunch {
c.autoStartOnLaunch = autoStart
}
if let k = slice.apiKey, !k.isEmpty { c.apiKey = k }
// settings.json may not have model_dirs on a brand-new install;
// in that case `c.modelDirs` keeps the `<basePath>/models`
// default that `Self.default` already populated.
let dirs = (slice.modelDirs ?? [])
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
if let first = dirs.first {
c.modelDirs = dirs
c.modelDir = first
} else if let m = slice.modelDir, !m.isEmpty {
c.modelDir = m
c.modelDirs = [m]
}
if let hf = slice.hfEndpoint { c.hfEndpoint = hf }
}
// Env overrides for non-path fields. basePath is already env-driven
// via currentBasePath().
let env = ProcessInfo.processInfo.environment
if let h = env["OMLX_HOST"], !h.isEmpty { c.bindAddress = h }
if let pStr = env["OMLX_PORT"], let p = Int(pStr) { c.port = p }
if let k = env["OMLX_API_KEY"], !k.isEmpty { c.apiKey = k }
return c
}
// MARK: - Save
/// Write our slice into `<basePath>/settings.json`, preserving every
/// other key in the file (cache, claude_code, integrations, …).
func save() throws {
let url = AppConfig.settingsURL(basePath: basePath)
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
var json: [String: Any] = [:]
if FileManager.default.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url),
let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
json = existing
}
var server = (json["server"] as? [String: Any]) ?? [:]
server["host"] = bindAddress
server.removeValue(forKey: "bind_address")
server["port"] = port
server["auto_start_on_launch"] = autoStartOnLaunch
json["server"] = server
var auth = (json["auth"] as? [String: Any]) ?? [:]
auth["api_key"] = apiKey ?? ""
json["auth"] = auth
var model = (json["model"] as? [String: Any]) ?? [:]
// `modelDirs` is always persisted as the canonical ordered list, with
// `model_dir` kept in sync for older server/app builds.
let dirs = effectiveModelDirs
model["model_dirs"] = dirs
model["model_dir"] = dirs[0]
json["model"] = model
var hf = (json["huggingface"] as? [String: Any]) ?? [:]
hf["endpoint"] = hfEndpoint
json["huggingface"] = hf
if json["version"] == nil { json["version"] = "1.0" }
let out = try JSONSerialization.data(
withJSONObject: json,
options: [.prettyPrinted]
)
try out.write(to: url, options: [.atomic])
}
// MARK: - Internal
/// Subset of `<basePath>/settings.json` we project into AppConfig.
struct ServerSettingsSlice {
var bindAddress: String?
var port: Int?
var autoStartOnLaunch: Bool?
var apiKey: String?
var modelDirs: [String]?
var modelDir: String?
var hfEndpoint: String?
}
static func readSettingsForTests(basePath: String) throws -> ServerSettingsSlice {
try readSettings(basePath: basePath)
}
private static func readSettings(basePath: String) throws -> ServerSettingsSlice {
let url = settingsURL(basePath: basePath)
guard FileManager.default.fileExists(atPath: url.path) else {
return ServerSettingsSlice()
}
let data = try Data(contentsOf: url)
let json = (try JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
let server = json["server"] as? [String: Any]
let auth = json["auth"] as? [String: Any]
let model = json["model"] as? [String: Any]
let hf = json["huggingface"] as? [String: Any]
// `host` remains the Python/admin settings key. `bind_address` is a
// read-only compatibility fallback for builds that briefly wrote it.
let bindAddr = server?["host"] as? String
?? server?["bind_address"] as? String
return ServerSettingsSlice(
bindAddress: bindAddr,
port: server?["port"] as? Int,
autoStartOnLaunch: server?["auto_start_on_launch"] as? Bool,
apiKey: auth?["api_key"] as? String,
modelDirs: model?["model_dirs"] as? [String],
modelDir: model?["model_dir"] as? String,
hfEndpoint: hf?["endpoint"] as? String
)
}
}