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

432 lines
18 KiB
Swift

// PR 7 — wires AppDelegate-owned runtime objects (ServerProcess, AppConfig)
// to the SwiftUI side. AppView mounts a single instance via `.environment`
// so screens can pull whatever they need without prop drilling. The screens
// keep their own data + polling state in their own view models.
//
// `serverState` republishes ServerProcess.State on every state change so a
// view can `@Environment(AppServices.self)` AppServices and use it as a SwiftUI source
// of truth. ServerProcess itself stays NSNotification-driven (no Combine
// retrofit).
import Foundation
import SwiftUI
@MainActor
@Observable
final class AppServices: NSObject {
var config: AppConfig
var serverState: ServerProcess.State = .stopped
/// PR 8 — when non-nil, the AppView swaps the Models screen for the
/// per-model ModelSettingsScreen drilled to this id.
var modelDetailID: String?
/// When set, AppView pulls the sidebar selection to this section on
/// the next runloop tick and clears the request. Lets a screen
/// imperatively navigate the user (e.g. the Profiles tab's
/// "Edit on Server →" link) without prop-drilling a `Binding<AppSection>`.
var requestedSection: AppSection?
/// Pair with `requestedSection` to scroll the Server screen to a
/// specific section after the deep-link lands. ContentScaffold's
/// `ScrollViewReader` observes this, scrolls, then nils it. Only the
/// Default Profile anchor is wired today — extend the enum as more
/// deep links land.
var requestedServerAnchor: ServerAnchor?
let client: OMLXClient
let updates: UpdateController
/// Read-only preset bundle (sourced from the shipped JSON + remote
/// refresh). The per-model settings preset chip strip subscribes via
/// Observation to react to refreshes.
let presetBundle = PresetBundleStore()
/// Long-lived view models for the Bench screens. Owned here (not by
/// screen-local state) so a running benchmark survives
/// leaving the screen — the server keeps producing results while
/// we're off-screen and the poll task continues updating these VMs,
/// so coming back shows the in-flight state instead of an empty
/// form. The HTML admin panel got this for free via Alpine's
/// app-scoped store; SwiftUI needs the lifetime promoted manually.
let throughputBench = ThroughputBenchScreenVM()
let accuracyBench = AccuracyBenchScreenVM()
@ObservationIgnored
private weak var server: ServerProcess?
init(config: AppConfig = .default, server: ServerProcess? = nil) {
self.config = config
self.client = OMLXClient(host: config.host, port: config.port, apiKey: config.apiKey)
self.updates = UpdateController()
super.init()
self.bind(server: server)
// Wire Sparkle (or its stub) on the next runloop so any user prefs
// saved on disk are applied before the first background check.
DispatchQueue.main.async { [weak self] in
self?.updates.bootstrap()
}
}
func bind(server: ServerProcess?) {
// Detach from the previous server (if any) before re-attaching.
if self.server != nil {
NotificationCenter.default.removeObserver(
self,
name: ServerProcess.stateDidChangeNotification,
object: nil
)
}
self.server = server
if let server {
self.serverState = server.state
NotificationCenter.default.addObserver(
self,
selector: #selector(serverStateDidChange(_:)),
name: ServerProcess.stateDidChangeNotification,
object: server
)
}
}
@objc private func serverStateDidChange(_ note: Notification) {
guard let proc = note.object as? ServerProcess, proc === server else { return }
// ServerProcess posts on the main queue (via DispatchQueue.main.async
// in terminationHandler / @MainActor health-check Task), so we're
// already on the main thread here.
serverState = proc.state
}
func updateConfig(_ next: AppConfig) {
self.config = next
client.configure(host: next.host, port: next.port, apiKey: next.apiKey)
}
func setAutoStartOnLaunch(_ enabled: Bool, persist: Bool = true) throws {
var updated = config
updated.autoStartOnLaunch = enabled
if persist {
try updated.save()
}
self.config = updated
}
// MARK: - Server lifecycle (proxied to ServerProcess)
var hasServer: Bool { server != nil }
@discardableResult
func startServer() throws -> ServerProcess.StartResult? {
try server?.start()
}
func stopServer() async {
await server?.stop()
}
func restartServer() async throws {
await server?.stop()
_ = try server?.start()
}
func forceRestartServer() async throws {
_ = try await server?.forceRestart()
}
enum BasePathChangeError: LocalizedError {
case sameAsCurrent
case destinationNotEmpty(String)
case destinationNotWritable(String)
case moveFailed(String)
var errorDescription: String? {
switch self {
case .sameAsCurrent:
return "Base path is unchanged."
case .destinationNotEmpty(let p):
return "\(p) already exists and isn't empty. Pick an unused folder."
case .destinationNotWritable(let p):
return "Can't write to \(p)."
case .moveFailed(let m):
return "Move failed: \(m)"
}
}
}
/// Apply pending edits to the storage layout. Both arguments are
/// optional so the caller (Server screen → Apply) can submit only
/// what actually changed:
/// • `basePath`: relocates every file under the current root, sets
/// `OMLX_BASE_PATH` (env + bootstrap file), and
/// reconfigures the spawn args.
/// • `modelDir` / `modelDirs`: writes the explicit model root list into
/// `<basePath>/settings.json`; the first entry is the primary
/// download target and backward-compatible `model_dir` value.
/// • `port`: a port change bundled into the same Apply. The spawn
/// uses cached `--port` args, so the restart below must carry the
/// new port or the server silently comes back on the old one. The
/// caller already PATCHed it to settings.json before us.
/// The server is stopped once before any mutation and restarted once
/// at the end — the user-stated rule: restart only fires when at
/// least one of the inputs actually differs from the current config.
func applyStorageChanges(
basePath: String? = nil,
modelDir: String? = nil,
modelDirs: [String]? = nil,
port: Int? = nil
) async throws {
let normalizedBase = basePath.map(Self.normalize)
let trimmedDir = modelDir?.trimmingCharacters(in: .whitespacesAndNewlines)
let requestedModelDirs: [String]? = {
if let modelDirs {
return Self.cleanedModelDirs(modelDirs)
}
if let trimmedDir, !trimmedDir.isEmpty {
return [Self.normalize(trimmedDir)]
}
return nil
}()
let basePathChanging: Bool = {
guard let normalizedBase else { return false }
return normalizedBase != Self.normalize(config.basePath)
}()
let modelDirChanging: Bool = {
guard let requestedModelDirs else { return false }
return requestedModelDirs != Self.cleanedModelDirs(config.effectiveModelDirs)
}()
guard basePathChanging || modelDirChanging else {
throw BasePathChangeError.sameAsCurrent
}
// Stop the server BEFORE any filesystem mutation so an open log
// file or SSD cache doesn't corrupt the move.
if let server { await server.stop() }
if basePathChanging, let newPath = normalizedBase {
try migrateBasePath(to: newPath)
}
if modelDirChanging, let requestedModelDirs {
var updated = config
updated.setModelDirs(requestedModelDirs)
try updated.save()
self.config = updated
}
// Fold a bundled port change into the same single restart. Mirrors
// applyServerEndpoint's persistence rule: the running Python server
// owns settings.json, so we only write AppConfig to disk when the
// server is offline. The HTTP client always needs the new endpoint.
if let port {
var updated = config
updated.port = port
if server == nil { try updated.save() }
self.config = updated
client.configure(host: updated.host, port: port, apiKey: updated.apiKey)
}
if let server {
let baseURL = URL(fileURLWithPath: config.basePath, isDirectory: true)
try server.reconfigure(port: port, basePath: baseURL)
_ = try server.start()
}
}
/// Move every file under the current basePath to `newPath` and persist
/// the choice. Caller must have already stopped the server.
private func migrateBasePath(to newPath: String) throws {
let fm = FileManager.default
let oldPath = Self.normalize(config.basePath)
let oldURL = URL(fileURLWithPath: oldPath, isDirectory: true)
let newURL = URL(fileURLWithPath: newPath, isDirectory: true)
// Ensure the destination's parent exists. If the destination itself
// already exists we require it to be empty so we don't accidentally
// overwrite an unrelated folder.
try fm.createDirectory(
at: newURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
if fm.fileExists(atPath: newURL.path) {
let entries = (try? fm.contentsOfDirectory(atPath: newURL.path)) ?? []
if !entries.isEmpty {
let preview = entries.prefix(4).joined(separator: ", ")
let suffix = entries.count > 4 ? ", …" : ""
throw BasePathChangeError.destinationNotEmpty(
"\(newPath) (\(entries.count) item\(entries.count == 1 ? "" : "s"): \(preview)\(suffix))"
)
}
try? fm.removeItem(at: newURL)
}
do {
if fm.fileExists(atPath: oldURL.path) {
try fm.moveItem(at: oldURL, to: newURL)
} else {
try fm.createDirectory(at: newURL, withIntermediateDirectories: true)
}
} catch {
throw BasePathChangeError.moveFailed(error.localizedDescription)
}
// When the user resets to the `~/.omlx` default, clear every
// override so a default install isn't left with stale state.
let isDefault = (newPath == AppConfig.defaultBasePath())
AppConfig.persistBasePath(isDefault ? nil : newPath)
var updated = config
updated.basePath = newPath
// If the explicit modelDir lived under the OLD basePath, rewrite
// its prefix so it tags along — files were physically moved by the
// moveItem() above, so the new path is where they actually live.
// A modelDir outside the old basePath (e.g. /Volumes/SSD/models)
// stays put untouched.
updated.setModelDirs(config.effectiveModelDirs.map {
Self.relocate(path: $0, oldBase: oldPath, newBase: newPath)
})
// Persist any unknown server keys at the new location — settings.json
// moved with the directory, so this is mostly a refresh of our slice
// for first installs that didn't have one yet.
try? updated.save()
self.config = updated
// settings.json also carries path-bearing fields outside AppConfig's
// normal slice (cache.ssd_cache_dir, logging.log_dir). When those were persisted as
// absolute paths under the old basePath, the server reads them after
// the move and recreates dirs at the stale path. Rewrite them here.
// The model list is included too for older settings files and as a
// second pass after AppConfig.save().
// Errors are surfaced via NSLog so a silent failure is debuggable in
// Console.app — but we don't fail the migration (move already worked).
do {
try Self.relocateOrphanPaths(in: AppConfig.settingsURL(basePath: newPath),
oldBase: oldPath, newBase: newPath)
} catch {
NSLog("oMLX: relocateOrphanPaths failed: %@", String(describing: error))
}
}
/// If `path` is inside `oldBase`, swap the prefix to `newBase`.
/// Returns the input unchanged when it's empty or sits outside the
/// migrated tree. Internal so unit tests can drive it directly. Pure —
/// `nonisolated` so it's callable without bouncing onto MainActor.
nonisolated static func relocate(path: String, oldBase: String, newBase: String) -> String {
guard !path.isEmpty else { return path }
let normalized = normalize(path)
let oldRoot = oldBase
if normalized == oldRoot {
return newBase
}
let oldPrefix = oldRoot.hasSuffix("/") ? oldRoot : oldRoot + "/"
if normalized.hasPrefix(oldPrefix) {
let suffix = String(normalized.dropFirst(oldPrefix.count))
return URL(fileURLWithPath: newBase, isDirectory: true)
.appendingPathComponent(suffix).path
}
return path
}
/// Rewrite path-bearing fields in `<basePath>/settings.json` that may
/// contain old-base absolute paths. Paths outside the migrated tree are
/// left alone.
nonisolated static func relocateOrphanPaths(in url: URL, oldBase: String, newBase: String) throws {
NSLog("oMLX: relocateOrphanPaths in=%@ old=%@ new=%@",
url.path, oldBase, newBase)
guard FileManager.default.fileExists(atPath: url.path) else {
NSLog("oMLX: relocateOrphanPaths skipped — file does not exist")
return
}
let data = try Data(contentsOf: url)
guard var json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
NSLog("oMLX: relocateOrphanPaths skipped — root is not an object")
return
}
if var model = json["model"] as? [String: Any] {
if let dirs = model["model_dirs"] as? [String] {
model["model_dirs"] = dirs.map {
Self.relocate(path: $0, oldBase: oldBase, newBase: newBase)
}
}
if let dir = model["model_dir"] as? String, !dir.isEmpty {
model["model_dir"] = Self.relocate(path: dir, oldBase: oldBase, newBase: newBase)
}
json["model"] = model
}
if var cache = json["cache"] as? [String: Any] {
if let dir = cache["ssd_cache_dir"] as? String, !dir.isEmpty {
cache["ssd_cache_dir"] = Self.relocate(path: dir, oldBase: oldBase, newBase: newBase)
}
json["cache"] = cache
}
if var logging = json["logging"] as? [String: Any] {
if let dir = logging["log_dir"] as? String, !dir.isEmpty {
logging["log_dir"] = Self.relocate(path: dir, oldBase: oldBase, newBase: newBase)
}
json["logging"] = logging
}
let out = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted])
try out.write(to: url, options: [.atomic])
NSLog("oMLX: relocateOrphanPaths wrote %d bytes", out.count)
}
nonisolated private static func normalize(_ path: String) -> String {
((path as NSString).expandingTildeInPath as NSString).standardizingPath
}
nonisolated private static func cleanedModelDirs(_ dirs: [String]) -> [String] {
var seen = Set<String>()
return dirs.compactMap { raw in
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let normalized = normalize(trimmed)
return seen.insert(normalized).inserted ? normalized : nil
}
}
/// Persist a new host/port to AppConfig, reconfigure the running server
/// process, and bounce it. Without this, ServerScreenVM's port path in
/// `applyServerSettings` (and `saveHost` for Listen Address) would only
/// update the server's `settings.json`, but the next spawn still uses
/// the cached --host / --port arguments captured at app launch.
///
/// The Python server is the canonical writer of `settings.json` while
/// it's running — the caller already PATCHed it before us, so we don't
/// double-write here. When the server is offline (wizard dropouts,
/// dev), we DO write so the next spawn reads the right values.
func applyServerEndpoint(host: String? = nil, port: Int? = nil) async throws {
let resolvedBindAddress = host ?? config.bindAddress
let resolvedPort = port ?? config.port
var updated = config
updated.bindAddress = resolvedBindAddress
updated.port = resolvedPort
if server == nil {
try updated.save()
}
self.config = updated
// The HTTP client uses the connectable host (normalises 0.0.0.0 → 127.0.0.1).
client.configure(host: updated.host, port: resolvedPort, apiKey: updated.apiKey)
if let server {
await server.stop()
try server.reconfigure(bindAddress: resolvedBindAddress, port: resolvedPort)
_ = try server.start()
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
/// Scroll anchors inside the Server screen that other screens can deep
/// link to via `AppServices.requestedServerAnchor`. The raw value is the
/// `.id(_:)` attached to the corresponding `SectionHeader`.
enum ServerAnchor: String, Sendable {
case defaultProfile = "server.defaultProfile"
}