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

949 lines
38 KiB
Swift

// First-run welcome wizard. Three-step flow: product intro, setup, and
// success. The setup step persists config and spawns the server.
//
// Architecture
// • `WelcomeWindowController` is the AppKit owner of the NSWindow + the
// SwiftUI `WelcomeView`. AppDelegate creates one on first run only —
// returning users never see this window.
// • `WelcomeViewModel` is a @MainActor ObservableObject holding the wizard
// state across pages, the validation, and the "Start Server" action.
//
// First-run trigger lives in `AppDelegate` (PR 10 addition). When settings.json
// already exists (re-entry), the Welcome page is skipped via app boot flow.
import AppKit
import SwiftUI
// MARK: - Window controller
@MainActor
final class WelcomeWindowController: NSObject, NSWindowDelegate {
static let willCloseNotification = Notification.Name("OMLXWelcomeWillClose")
private var window: NSWindow?
private var vm: WelcomeViewModel?
private weak var services: AppServices?
private weak var server: ServerProcess?
private let didFinish: (AppConfig, ServerProcess?) -> Void
init(
services: AppServices,
server: ServerProcess?,
didFinish: @escaping (AppConfig, ServerProcess?) -> Void
) {
self.services = services
self.server = server
self.didFinish = didFinish
super.init()
}
func show() {
if let window {
window.makeKeyAndOrderFront(self)
NSApp.activate(ignoringOtherApps: true)
return
}
guard let services else { return }
let vm = WelcomeViewModel(services: services, server: server)
vm.onFinish = { [weak self] config, server in
guard let self else { return }
self.didFinish(config, server)
}
vm.onOpenDashboard = { [weak self] in
self?.close()
}
vm.onClose = { [weak self] in
self?.close()
}
self.vm = vm
let root = WelcomeView(vm: vm)
.environment(services)
let hosting = NSHostingController(rootView: root)
hosting.view.frame = NSRect(x: 0, y: 0, width: 680, height: 620)
let win = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 680, height: 620),
styleMask: [.titled, .closable, .fullSizeContentView],
backing: .buffered,
defer: false
)
win.title = String(localized: "welcome.window.title",
defaultValue: "Welcome to oMLX",
comment: "Window title bar text for the Welcome wizard")
win.titleVisibility = .hidden
win.titlebarAppearsTransparent = true
win.titlebarSeparatorStyle = .none
win.backgroundColor = .windowBackgroundColor
win.isMovableByWindowBackground = true
win.contentViewController = hosting
win.center()
win.delegate = self
win.isReleasedWhenClosed = false
self.window = win
win.makeKeyAndOrderFront(self)
NSApp.activate(ignoringOtherApps: true)
}
func close() {
window?.close()
}
// NSWindowDelegate
nonisolated func windowWillClose(_ notification: Notification) {
DispatchQueue.main.async {
MainActor.assumeIsolated {
self.handleWillClose()
}
}
}
/// Closing before Start Server is cancellation, not partial setup. Do not
/// write settings.json or create the base path; AppDelegate will terminate
/// so the next launch starts from the Welcome intro again.
private func handleWillClose() {
NotificationCenter.default.post(
name: WelcomeWindowController.willCloseNotification,
object: nil
)
}
}
// MARK: - View model
enum WelcomeStep: Equatable, Sendable {
case intro
case setup
case complete
}
@MainActor
final class WelcomeViewModel: ObservableObject {
@Published var step: WelcomeStep = .intro
@Published var basePath: String
@Published var modelDir: String
@Published var portText: String
@Published var apiKey: String = ""
@Published var lastError: String?
@Published var isStarting: Bool = false
@Published var startCompleted: Bool = false
var onFinish: ((AppConfig, ServerProcess?) -> Void)?
var onOpenDashboard: (() -> Void)?
var onClose: (() -> Void)?
private weak var services: AppServices?
private weak var server: ServerProcess?
init(services: AppServices, server: ServerProcess?) {
self.services = services
self.server = server
let cfg = services.config
self.basePath = cfg.basePath.isEmpty ? AppConfig.defaultBasePath() : cfg.basePath
self.modelDir = cfg.modelDir
self.portText = String(cfg.port)
self.apiKey = cfg.apiKey ?? ""
}
/// Single-page validation gate — runs Storage + API-key checks in
/// sequence and surfaces the first failure into `lastError`.
func validateSetup() -> Bool {
validateStorage() && validateApiKey()
}
func beginSetup() {
step = .setup
lastError = nil
}
func backToIntro() {
guard !isStarting else { return }
step = .intro
lastError = nil
}
func requestClose() {
onClose?()
}
// MARK: Validation
func validateStorage() -> Bool {
let trimmedBase = basePath.trimmingCharacters(in: .whitespaces)
guard !trimmedBase.isEmpty else {
lastError = String(localized: "welcome.error.base_dir_required",
defaultValue: "Base directory is required.",
comment: "Welcome wizard validation: empty base path")
return false
}
guard let port = Int(portText.trimmingCharacters(in: .whitespaces)),
(1...65535).contains(port) else {
lastError = String(localized: "welcome.error.port_out_of_range",
defaultValue: "Port must be a number between 1 and 65535.",
comment: "Welcome wizard validation: port not in valid range")
return false
}
_ = port
lastError = nil
return true
}
func validateApiKey() -> Bool {
let key = apiKey.trimmingCharacters(in: .whitespaces)
guard key.count >= 4 else {
lastError = String(localized: "welcome.error.key_too_short",
defaultValue: "API key must be at least 4 characters.",
comment: "Welcome wizard validation: api key below min length")
return false
}
guard !key.contains(where: { $0.isWhitespace }) else {
lastError = String(localized: "welcome.error.key_whitespace",
defaultValue: "API key must not contain whitespace.",
comment: "Welcome wizard validation: api key contains spaces")
return false
}
guard key.unicodeScalars.allSatisfy({ $0.value >= 0x20 && $0.value < 0x7F }) else {
lastError = String(localized: "welcome.error.key_non_ascii",
defaultValue: "API key must contain only printable ASCII.",
comment: "Welcome wizard validation: api key has non-printable or non-ASCII chars")
return false
}
lastError = nil
return true
}
// MARK: Folder picker
func browseBaseDirectory() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.canCreateDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = String(localized: "welcome.browse.prompt",
defaultValue: "Select",
comment: "NSOpenPanel button label for the Welcome wizard's folder pickers")
panel.message = String(localized: "welcome.browse.base_message",
defaultValue: "Choose a parent folder. An .omlx directory will be created inside it.",
comment: "NSOpenPanel message when picking the Base Directory in Welcome wizard")
if panel.runModal() == .OK, let url = panel.url {
basePath = url.appendingPathComponent(".omlx", isDirectory: true).path
}
}
func browseModelDirectory() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.canCreateDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = String(localized: "welcome.browse.prompt",
defaultValue: "Select",
comment: "NSOpenPanel button label for the Welcome wizard's folder pickers")
panel.message = String(localized: "welcome.browse.model_message",
defaultValue: "Choose the directory containing your model files.",
comment: "NSOpenPanel message when picking the Model Directory in Welcome wizard")
if panel.runModal() == .OK, let url = panel.url {
modelDir = url.path
}
}
// MARK: Finish
func startServer() async -> Bool {
guard let services else { return false }
isStarting = true
defer { isStarting = false }
// 1. Persist AppConfig.
guard let port = Int(portText.trimmingCharacters(in: .whitespaces)) else {
lastError = String(localized: "welcome.error.invalid_port",
defaultValue: "Invalid port.",
comment: "Welcome wizard: port field couldn't be parsed as an integer")
return false
}
let trimmedKey = apiKey.trimmingCharacters(in: .whitespaces)
let resolvedBase = ((basePath.trimmingCharacters(in: .whitespaces)
as NSString).expandingTildeInPath as NSString)
.standardizingPath
var config = services.config
config.bindAddress = "127.0.0.1"
config.basePath = resolvedBase
config.port = port
// modelDir is always a literal path. The wizard's "Reset" button
// clears the field — interpret that as "use the default for the
// basePath I just picked" rather than persisting an empty string.
let trimmedDir = modelDir.trimmingCharacters(in: .whitespaces)
let resolvedModelDir = trimmedDir.isEmpty
? AppConfig.defaultModelDir(forBasePath: resolvedBase)
: trimmedDir
config.setModelDirs([resolvedModelDir])
// hf_endpoint is set later from Downloads → "HF Mirror" — we don't
// touch the existing value here so a returning user's mirror choice
// survives a re-entry into the wizard.
config.apiKey = trimmedKey
// Ensure the base directory exists before spawning the server. The
// Python child creates `<base>/settings.json` on first start; if the
// directory is missing, it bails with "Cannot create directory".
do {
try FileManager.default.createDirectory(
at: URL(fileURLWithPath: resolvedBase),
withIntermediateDirectories: true
)
} catch {
lastError = String(localized: "welcome.error.mkdir_failed",
defaultValue: "Cannot create base directory: \(error.localizedDescription)",
comment: "Welcome wizard: mkdir on the base path failed; placeholder is the system error message")
return false
}
// When the user kept the default ~/.omlx, clear every override.
let isDefault = (resolvedBase == AppConfig.defaultBasePath())
AppConfig.persistBasePath(isDefault ? nil : resolvedBase)
do {
try config.save()
} catch {
lastError = String(localized: "welcome.error.save_config_failed",
defaultValue: "Failed to save config: \(error.localizedDescription)",
comment: "Welcome wizard: writing settings.json failed; placeholder is the system error message")
return false
}
services.updateConfig(config)
// 2. Build a ServerProcess if AppDelegate didn't already pre-stage one
// (first-run path defers spawning until the wizard finishes).
let proc: ServerProcess
if let existing = server {
proc = existing
} else {
do {
let runtime = try PythonRuntime.resolve()
proc = ServerProcess(
runtime: runtime,
bindAddress: config.bindAddress,
port: config.port,
basePath: URL(fileURLWithPath: config.basePath, isDirectory: true)
)
} catch {
lastError = String(localized: "welcome.error.python_runtime_failed",
defaultValue: "Failed to locate Python runtime: \(error.localizedDescription)",
comment: "Welcome wizard: PythonRuntime.resolve() threw; placeholder is the system error message")
return false
}
}
services.bind(server: proc)
// 3. Start the server (port-conflict surfaces inline; user can edit
// the port and tap again).
do {
switch try proc.start() {
case .started, .alreadyRunning:
break
case .portConflict(let conflict):
lastError = conflict.isOMLX
? String(localized: "welcome.error.port_in_use_omlx",
defaultValue: "Port \(String(config.port)) is already in use (oMLX server already running).",
comment: "Welcome wizard: bind() failed because another oMLX instance owns the port")
: String(localized: "welcome.error.port_in_use",
defaultValue: "Port \(String(config.port)) is already in use.",
comment: "Welcome wizard: bind() failed because some other process owns the port")
return false
}
} catch {
lastError = String(localized: "welcome.error.start_server_failed",
defaultValue: "Failed to start server: \(error.localizedDescription)",
comment: "Welcome wizard: ServerProcess.start() threw; placeholder is the system error message")
return false
}
// 4. Best-effort post-start fix-ups: setup-api-key (or login if the
// server already had one) + hf_endpoint patch. None of these are
// fatal on first run — the user can re-do them in Security /
// Server screens.
// Give the server a beat to bind, then wait until the health-check
// loop has confirmed /health 200 (cap 8s so a hung server doesn't
// freeze the wizard).
try? await Task.sleep(for: .milliseconds(500))
await waitUntilHealthyOrTimeout(proc: proc, timeout: 8)
_ = await setupServerApiKey(client: services.client, key: trimmedKey)
startCompleted = true
step = .complete
onFinish?(config, proc)
return true
}
/// Opens the admin dashboard after the setup step has started the server.
@discardableResult
func openWebDashboard() -> Bool {
guard let services else { return false }
guard let url = AppConfig.httpURL(
host: services.config.host,
port: services.config.port,
path: "/admin/dashboard"
) else {
return false
}
NSWorkspace.shared.open(url)
onOpenDashboard?()
return true
}
private func setupServerApiKey(client: OMLXClient, key: String) async -> Bool {
// Try setup-api-key (fresh install). When the server already has a
// key set, the endpoint returns 400 — we swallow that and let
// `OMLXClient`'s 401 auto-login handle the next authenticated call.
// The server is local-only on first run, so we don't need an
// explicit login round-trip here.
do {
_ = try await client.setupApiKey(key, confirm: key)
return true
} catch {
return false
}
}
private func waitUntilHealthyOrTimeout(proc: ServerProcess, timeout: TimeInterval) async {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if case .running = proc.state { return }
try? await Task.sleep(for: .milliseconds(200))
}
}
}
// MARK: - View
struct WelcomeView: View {
@ObservedObject var vm: WelcomeViewModel
@Environment(\.colorScheme) private var scheme
var body: some View {
let theme = scheme == .dark ? OMLXTheme.dark : OMLXTheme.light
ZStack {
WelcomeBackdrop()
.ignoresSafeArea()
VStack(spacing: 0) {
Group {
switch vm.step {
case .intro:
WelcomeIntroBody()
case .setup:
WelcomeSetupBody(vm: vm)
case .complete:
WelcomeCompleteBody(vm: vm)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
WelcomeFooter(vm: vm)
}
}
.environment(\.omlxTheme, theme)
.frame(width: 680, height: 620)
}
}
// MARK: - Welcome redesign
private enum WelcomeStyle {
static let bg = Color(nsColor: .windowBackgroundColor)
static let panel = Color(nsColor: .controlBackgroundColor)
static let panelBorder = Color(nsColor: .separatorColor)
static let text = Color(nsColor: .labelColor)
static let muted = Color(nsColor: .secondaryLabelColor)
static let faint = Color(nsColor: .tertiaryLabelColor)
static let fill = Color(nsColor: .quaternaryLabelColor).opacity(0.16)
static let accent = Color.accentColor
}
private struct WelcomeBackdrop: View {
var body: some View {
WelcomeStyle.bg
}
}
private struct WelcomeIntroBody: View {
var body: some View {
VStack(spacing: 24) {
Spacer(minLength: 18)
WelcomeIcon(size: 88)
VStack(spacing: 14) {
Text(String(localized: "welcome.header.title",
defaultValue: "oMLX",
comment: "Main heading shown on the Welcome wizard"))
.font(.omlxDisplay(48, weight: .semibold))
.foregroundStyle(WelcomeStyle.text)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Text(String(localized: "welcome.header.subtitle",
defaultValue: "Local AI, no more waiting.",
comment: "Short tagline under the Welcome wizard's main heading"))
.font(.omlxDisplay(25, weight: .semibold))
.foregroundStyle(WelcomeStyle.text)
.multilineTextAlignment(.center)
Text(String(localized: "welcome.header.tagline",
defaultValue: "macOS-native MLX server with smart caching.\nClaude Code, OpenClaw, and Cursor respond in 5 seconds, not 90.",
comment: "Sub-tagline under the Welcome wizard's main heading"))
.font(.omlxText(15))
.foregroundStyle(WelcomeStyle.muted)
.multilineTextAlignment(.center)
.lineSpacing(5)
.frame(maxWidth: 540)
.padding(.top, 6)
}
HStack(spacing: 10) {
FeaturePill(icon: "lock.fill", title: "Localhost")
FeaturePill(icon: "memorychip", title: "MLX")
FeaturePill(icon: "bolt.fill", title: "Smart caching")
}
.padding(.top, 8)
Text(String(localized: "welcome.header.meta",
defaultValue: "Apple Silicon · macOS-native · Apache 2.0",
comment: "Short metadata line on the Welcome intro page"))
.font(.omlxText(12))
.foregroundStyle(WelcomeStyle.faint)
Spacer(minLength: 28)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 54)
}
}
private struct WelcomeSetupBody: View {
@ObservedObject var vm: WelcomeViewModel
@State private var keyVisible: Bool = false
var body: some View {
VStack(spacing: 22) {
WelcomeIcon(size: 70)
VStack(spacing: 6) {
Text(String(localized: "welcome.setup.title",
defaultValue: "Set up your local server",
comment: "Heading on the Welcome setup page"))
.font(.omlxDisplay(24, weight: .semibold))
.foregroundStyle(WelcomeStyle.text)
Text(String(localized: "welcome.intro",
defaultValue: "Choose where models live, pick a port, and create the API key you'll use from apps and the web dashboard.",
comment: "Intro paragraph at the top of the Welcome wizard's setup body"))
.font(.omlxText(13))
.foregroundStyle(WelcomeStyle.muted)
.multilineTextAlignment(.center)
.lineSpacing(2)
.frame(maxWidth: 480)
}
VStack(alignment: .leading, spacing: 18) {
WelcomeNotice(
icon: "memorychip.fill",
title: String(localized: "welcome.setup.local.title",
defaultValue: "Local server",
comment: "Title for the local server setup card"),
text: String(localized: "welcome.setup.local.body",
defaultValue: "oMLX binds to 127.0.0.1 on first run, so clients on this Mac can use it without exposing the server to your network.",
comment: "Body for the local server setup card")
)
VStack(spacing: 0) {
SettingRow(
icon: "number",
title: String(localized: "welcome.storage.port.label",
defaultValue: "Port",
comment: "Row label for the server port field in Welcome wizard"),
subtitle: String(localized: "welcome.storage.port.sub",
defaultValue: "Default 8000. Change this only if the port is already in use.",
comment: "Sublabel for the port field with the recommended default")
) {
TextInput(text: $vm.portText, mono: true, width: 96)
}
WelcomeDivider()
SettingRow(
icon: "folder",
title: String(localized: "welcome.storage.model_dir.label",
defaultValue: "Model Directory",
comment: "Row label for the Model Directory picker in Welcome wizard"),
subtitle: String(localized: "welcome.storage.model_dir.sub",
defaultValue: "Where downloaded models are stored.",
comment: "Sublabel explaining the model directory")
) {
HStack(spacing: 8) {
Text(vm.modelDir.isEmpty
? AppConfig.defaultModelDir(forBasePath: vm.basePath)
: vm.modelDir)
.font(.omlxMono(11))
.foregroundStyle(WelcomeStyle.muted)
.lineLimit(1)
.truncationMode(.middle)
.frame(width: 232, alignment: .trailing)
Button {
vm.browseModelDirectory()
} label: {
Image(systemName: "folder")
.font(.system(size: 13, weight: .semibold))
}
.buttonStyle(.omlx(.normal, size: .small))
.disabled(vm.isStarting)
.help(String(localized: "welcome.button.browse",
defaultValue: "Browse...",
comment: "Folder picker trigger button in Welcome wizard"))
}
}
WelcomeDivider()
SettingRow(
icon: "key",
title: String(localized: "welcome.api_key.label",
defaultValue: "API Key",
comment: "Row label for the primary API key field in Welcome wizard"),
subtitle: String(localized: "welcome.api_key.sub",
defaultValue: "This key is also your Web Dashboard login password.",
comment: "Sublabel explaining API key usage")
) {
HStack(spacing: 0) {
TextInput("welcome.api_key.placeholder", text: $vm.apiKey, placeholder: "sk-omlx-…", isSecure: !keyVisible, mono: true, width: 210)
Button {
keyVisible.toggle()
} label: {
Image(systemName: keyVisible ? "eye.slash" : "eye")
.font(.system(size: 13, weight: .semibold))
}
.disabled(vm.isStarting)
.help(keyVisible
? String(localized: "welcome.api_key.hide",
defaultValue: "Hide key",
comment: "Tooltip on the eye-slash button that masks the API key field")
: String(localized: "welcome.api_key.show",
defaultValue: "Show key",
comment: "Tooltip on the eye button that unmasks the API key field"))
Button {
vm.apiKey = APIKeyGenerator.random()
keyVisible = true
} label: {
Image(systemName: "arrow.triangle.2.circlepath")
}
.help(String(localized: "security.api_key.generate",
defaultValue: "Generate a random key",
comment: "Tooltip on the API key regenerate button"))
}
.buttonStyle(.omlx(.plain, size: .small))
}
}
.background(WelcomeStyle.panel)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(WelcomeStyle.panelBorder.opacity(0.45), lineWidth: 0.5)
)
Text(String(localized: "welcome.hint.settings_path",
defaultValue: "Settings are stored in ~/.omlx/settings.json.",
comment: "Hint line under the API key section pointing to settings.json"))
.font(.omlxText(11))
.foregroundStyle(WelcomeStyle.faint)
.frame(maxWidth: .infinity, alignment: .center)
}
.frame(width: 560)
}
.padding(.horizontal, 48)
.padding(.top, 18)
.padding(.bottom, 6)
.disabled(vm.isStarting)
}
}
private struct WelcomeCompleteBody: View {
@ObservedObject var vm: WelcomeViewModel
var body: some View {
VStack(spacing: 24) {
Spacer(minLength: 34)
WelcomeIcon(size: 84)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 42, weight: .semibold))
.foregroundStyle(WelcomeStyle.accent)
.accessibilityHidden(true)
VStack(spacing: 10) {
Text(String(localized: "welcome.complete.title",
defaultValue: "All set!",
comment: "Heading on the Welcome completion page"))
.font(.omlxDisplay(30, weight: .semibold))
.foregroundStyle(WelcomeStyle.text)
Text(String(localized: "welcome.complete.description",
defaultValue: "oMLX is running locally at http://127.0.0.1:\(vm.portText). Open the web dashboard to download your first model, manage settings, and connect your coding tools.",
comment: "Description on the Welcome completion page"))
.font(.omlxText(14))
.foregroundStyle(WelcomeStyle.muted)
.multilineTextAlignment(.center)
.lineSpacing(3)
.frame(maxWidth: 500)
Text(String(localized: "welcome.complete.status",
defaultValue: "Server started. Dashboard is ready.",
comment: "Short success status on the Welcome completion page"))
.font(.omlxText(12, weight: .medium))
.foregroundStyle(WelcomeStyle.faint)
.padding(.top, 4)
}
Spacer(minLength: 40)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 54)
}
}
private struct WelcomeFooter: View {
@ObservedObject var vm: WelcomeViewModel
var body: some View {
HStack(alignment: .center, spacing: 18) {
if vm.step == .setup {
Button {
vm.backToIntro()
} label: {
Label(String(localized: "common.back",
defaultValue: "Back",
comment: "Back button label"),
systemImage: "chevron.left")
}
.buttonStyle(.omlx(.plain))
.disabled(vm.isStarting)
} else {
Spacer()
.frame(width: 90)
}
Spacer()
if let error = vm.lastError {
Text(error)
.font(.omlxText(11.5))
.foregroundStyle(Color(nsColor: .systemRed))
.lineLimit(2)
.multilineTextAlignment(.trailing)
.frame(maxWidth: 320, alignment: .trailing)
}
primaryButton
}
.padding(.horizontal, 28)
.frame(height: 72)
.background(WelcomeStyle.panel.opacity(0.68))
.overlay(alignment: .top) {
Rectangle()
.fill(WelcomeStyle.panelBorder.opacity(0.5))
.frame(height: 0.5)
}
}
@ViewBuilder
private var primaryButton: some View {
switch vm.step {
case .intro:
WelcomeCTA(
title: String(localized: "welcome.button.get_started",
defaultValue: "Get Started",
comment: "Primary footer button that advances from the intro to setup"),
systemImage: "arrow.right",
width: 142
) {
vm.beginSetup()
}
case .setup:
WelcomeCTA(
title: vm.isStarting
? String(localized: "welcome.button.starting",
defaultValue: "Starting Server...",
comment: "Footer button label shown while the server is being spawned")
: String(localized: "welcome.button.start_server",
defaultValue: "Start Server",
comment: "Primary footer button that spawns the server"),
systemImage: vm.isStarting ? nil : "arrow.right",
isBusy: vm.isStarting,
width: 160
) {
Task {
guard vm.validateSetup() else { return }
_ = await vm.startServer()
}
}
.disabled(vm.isStarting)
case .complete:
WelcomeCTA(
title: String(localized: "welcome.button.open_dashboard",
defaultValue: "Open Web Dashboard",
comment: "Footer button that opens the local web dashboard"),
systemImage: "arrow.up.right",
width: 210
) {
_ = vm.openWebDashboard()
}
}
}
}
private struct WelcomeCTA: View {
let title: String
var systemImage: String?
var isBusy: Bool = false
var width: CGFloat
let action: () -> Void
@Environment(\.isEnabled) private var isEnabled
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
if isBusy {
ProgressView()
.controlSize(.small)
}
Text(title)
.font(.omlxText(13, weight: .medium))
.lineLimit(1)
.minimumScaleFactor(0.86)
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 12, weight: .semibold))
}
}
.foregroundStyle(Color.white)
.frame(width: width, height: 32)
.background(WelcomeStyle.accent)
.clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
.opacity(isEnabled ? 1.0 : 0.55)
}
.buttonStyle(.plain)
}
}
private struct WelcomeIcon: View {
let size: CGFloat
var body: some View {
Image("AppLogo")
.resizable()
.interpolation(.high)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: size * 0.22, style: .continuous))
.shadow(color: Color.black.opacity(0.10), radius: 12, y: 6)
.accessibilityLabel("oMLX")
}
}
private struct FeaturePill: View {
let icon: String
let title: String
var body: some View {
HStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: 11, weight: .semibold))
Text(title)
.font(.omlxText(12, weight: .medium))
}
.foregroundStyle(WelcomeStyle.muted)
.padding(.horizontal, 11)
.padding(.vertical, 7)
.background(WelcomeStyle.fill)
.clipShape(Capsule())
}
}
private struct WelcomeNotice: View {
let icon: String
let title: String
let text: String
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(WelcomeStyle.accent)
.frame(width: 24, height: 24)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.omlxText(13, weight: .semibold))
.foregroundStyle(WelcomeStyle.text)
Text(text)
.font(.omlxText(12))
.foregroundStyle(WelcomeStyle.muted)
.lineSpacing(2)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(14)
.background(WelcomeStyle.fill)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
private struct SettingRow<Content: View>: View {
let icon: String
let title: String
let subtitle: String
let content: () -> Content
init(
icon: String,
title: String,
subtitle: String,
@ViewBuilder content: @escaping () -> Content
) {
self.icon = icon
self.title = title
self.subtitle = subtitle
self.content = content
}
var body: some View {
HStack(alignment: .center, spacing: 14) {
Image(systemName: icon)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(WelcomeStyle.muted)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.omlxText(13, weight: .medium))
.foregroundStyle(WelcomeStyle.text)
Text(subtitle)
.font(.omlxText(11.5))
.foregroundStyle(WelcomeStyle.faint)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 16)
content()
}
.padding(.horizontal, 16)
.padding(.vertical, 13)
}
}
private struct WelcomeDivider: View {
var body: some View {
Rectangle()
.fill(WelcomeStyle.panelBorder.opacity(0.42))
.frame(height: 0.5)
.padding(.leading, 52)
}
}