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

750 lines
39 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PR 7 — real Server screen. ServerHero shows live ServerProcess.State and
// drives Start / Stop / Restart through AppServices. Network + Logging rows
// read/write `/admin/api/global-settings`.
//
// Scope vs design: rows whose backing field doesn't exist server-side yet
// (CORS, HTTPS, Request Timeout, Telemetry, GPU memory, KV-cache quant) are
// deferred until those settings are added to GlobalSettingsRequest. We keep
// the shipped surface honest: every row in this screen is fully wired.
import SwiftUI
import AppKit
struct ServerScreen: View {
@Environment(AppServices.self) private var services
@State private var vm = ServerScreenVM()
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ServerHeroCard(vm: vm)
SectionHeader(String(localized: "server.section.network",
defaultValue: "Network",
comment: "Section heading for the Network rows in Server screen"))
ListGroup {
Row(label: String(localized: "server.row.listen_address",
defaultValue: "Listen Address",
comment: "Row label for the listen-address picker in Server screen")) {
Popup(
selection: vm.bind($vm.host, save: { vm.saveHost(services: services) }),
width: 220,
options: [
("127.0.0.1", String(localized: "server.host.local_only",
defaultValue: "127.0.0.1 (Local only)",
comment: "Listen-address popup option for loopback only")),
("0.0.0.0", String(localized: "server.host.all_networks",
defaultValue: "0.0.0.0 (All networks)",
comment: "Listen-address popup option for binding to all interfaces")),
("localhost", String(localized: "server.host.localhost",
defaultValue: "localhost",
comment: "Listen-address popup option for localhost")),
]
)
}
Row(
label: String(localized: "server.row.port",
defaultValue: "Port",
comment: "Row label for the server port field"),
sublabel: String(localized: "server.row.port.sub",
defaultValue: "Default 8000. Server restarts on save.",
comment: "Sublabel under the Port field"),
isLast: true
) {
TextInput(text: $vm.portText, mono: true, width: 90)
}
}
SectionHeader(String(localized: "server.section.endpoints",
defaultValue: "API Endpoints",
comment: "Section heading for the API endpoints list"))
APIEndpointsList(host: vm.effectiveHost, port: vm.effectivePort)
SectionHeader(
String(localized: "server.section.default_profile",
defaultValue: "Default Profile",
comment: "Section heading for the default sampling profile editor"),
subtitle: String(localized: "server.section.default_profile.sub",
defaultValue: "Fallback values used when a model has no profile, or when a profile leaves a field empty",
comment: "Subtitle for the Default Profile section")
)
// Deep-link target for the per-model Profiles tab's "Edit on
// Server →" link (see AppServices.ServerAnchor.defaultProfile).
.id(ServerAnchor.defaultProfile.rawValue)
ServerDefaultProfileEditor(vm: vm)
SectionHeader(String(localized: "server.section.startup",
defaultValue: "Server Startup",
comment: "Section heading for server startup behavior settings"))
ListGroup {
Row(
label: String(localized: "server.row.auto_start_on_launch",
defaultValue: "Automatically start server on launch",
comment: "Row label for automatically starting the managed server when the macOS app launches"),
sublabel: String(localized: "server.row.auto_start_on_launch.sub",
defaultValue: "When disabled, the menu bar app opens without starting the server.",
comment: "Sublabel explaining the auto-start on launch setting"),
isLast: true
) {
Toggle("", isOn: vm.bind($vm.autoStartOnLaunch, save: {
vm.saveAutoStartOnLaunch(services: services)
}))
.labelsHidden()
.toggleStyle(.switch)
}
}
SectionHeader(String(localized: "server.section.logging",
defaultValue: "Logging",
comment: "Section heading for the Logging rows"))
ListGroup {
Row(label: String(localized: "server.row.log_level",
defaultValue: "Log Level",
comment: "Row label for the log level picker"),
isLast: true) {
Popup(
selection: vm.bind($vm.logLevel, save: vm.saveLogLevel),
width: 130,
options: [
("error", String(localized: "server.log_level.error",
defaultValue: "Error",
comment: "Log level popup option")),
("warning", String(localized: "server.log_level.warning",
defaultValue: "Warning",
comment: "Log level popup option")),
("info", String(localized: "server.log_level.info",
defaultValue: "Info",
comment: "Log level popup option")),
("debug", String(localized: "server.log_level.debug",
defaultValue: "Debug",
comment: "Log level popup option")),
("trace", String(localized: "server.log_level.trace",
defaultValue: "Trace",
comment: "Log level popup option")),
]
)
}
}
SectionHeader(
String(localized: "server.section.storage",
defaultValue: "Storage",
comment: "Section heading for storage rows in Server screen"),
subtitle: String(localized: "server.section.storage.sub",
defaultValue: "Where models, settings, logs, and the SSD cache live.",
comment: "Subtitle for the Storage section in Server screen")
)
ListGroup {
Row(
label: String(localized: "server.row.base_path",
defaultValue: "Base Path",
comment: "Row label for the Base Path text input"),
sublabel: String(localized: "server.row.base_path.sub",
defaultValue: "OMLX_BASE_PATH. Files move and the server restarts when this changes.",
comment: "Sublabel under the Base Path field")
) {
TextInput(text: $vm.basePathText, mono: true, width: 280)
}
FreeRow {
ModelDirectoriesEditor(vm: vm)
}
Row(
label: String(localized: "server.row.hf_cache",
defaultValue: "Use Hugging Face local cache",
comment: "Row label for enabling standard Hugging Face Hub cache model discovery"),
sublabel: String(localized: "server.row.hf_cache.sub",
defaultValue: "Discover MLX-compatible models from the standard Hugging Face Hub cache.",
comment: "Sublabel for enabling Hugging Face local cache discovery"),
isLast: true
) {
Toggle("", isOn: $vm.hfCacheEnabled)
.labelsHidden()
.toggleStyle(.switch)
}
}
ServerAdvancedSection(vm: vm)
HStack {
Spacer()
Button(String(localized: "server.button.apply",
defaultValue: "Apply",
comment: "Button to apply pending server settings: port, default profile, storage, and aliases")) {
vm.applyServerSettings(services: services)
}
.buttonStyle(.omlx(.primary))
.disabled(!vm.hasPendingServerChanges(services: services)
|| vm.isMovingBasePath)
}
.padding(.horizontal, 18)
.padding(.top, 6)
HintFooter(error: vm.lastError)
}
.task {
// services.config is already populated by AppDelegate before this
// view is mounted, so .onChange never fires for the initial value —
// mirror it explicitly on first appearance.
vm.applyConfig(services.config)
await vm.load(client: services.client)
}
.onChange(of: services.config) { _, _ in
vm.applyConfig(services.config)
}
.onChange(of: services.serverState) { _, _ in
// After a restart triggered by saving host/port, reload to pick
// up the new effective values.
Task { await vm.load(client: services.client) }
}
}
}
private struct ModelDirectoriesEditor: View {
var vm: ServerScreenVM
@Environment(\.omlxTheme) private var theme
var body: some View {
VStack(alignment: .leading, spacing: 9) {
HStack(alignment: .firstTextBaseline, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "server.row.models_directories",
defaultValue: "Model Directories",
comment: "Row label for the model directories editor"))
.font(.omlxText(13, weight: .medium))
.foregroundStyle(theme.text)
Text(String(localized: "server.row.models_directories.sub",
defaultValue: "The first path is the download target. All paths are scanned for local models.",
comment: "Sublabel under the model directories editor"))
.font(.omlxText(11.5))
.foregroundStyle(theme.textSecondary)
}
Spacer(minLength: 12)
Button {
vm.addModelDirectory()
} label: {
Image(systemName: "plus")
}
.buttonStyle(.omlx(.normal, size: .small))
.help(String(localized: "server.model_dirs.add.help",
defaultValue: "Add model directory",
comment: "Tooltip for the add model directory button"))
}
VStack(spacing: 7) {
ForEach(Array(vm.modelDirTexts.indices), id: \.self) { index in
modelDirRow(index: index)
}
}
}
}
private func modelDirRow(index: Int) -> some View {
HStack(spacing: 7) {
Text(index == 0
? String(localized: "server.model_dirs.primary",
defaultValue: "Primary",
comment: "Badge for the first model directory")
: String(format: "#%d", index + 1))
.font(.omlxMono(10, weight: .semibold))
.foregroundStyle(index == 0 ? theme.accent : theme.textSecondary)
.frame(width: 52, alignment: .leading)
TextInput(text: Binding(
get: { vm.modelDirText(at: index) },
set: { vm.setModelDirText($0, at: index) }
), mono: true, width: 340)
Button {
vm.browseModelDirectory(at: index)
} label: {
Image(systemName: "folder")
}
.buttonStyle(.omlx(.plain, size: .small))
.help(String(localized: "server.model_dirs.browse.help",
defaultValue: "Choose folder",
comment: "Tooltip for choosing a model directory"))
Button {
vm.removeModelDirectory(at: index)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.omlx(.plain, size: .small))
.help(String(localized: "server.model_dirs.remove.help",
defaultValue: "Remove model directory",
comment: "Tooltip for removing a model directory"))
}
}
}
// MARK: - Server hero
/// Hero card shared between the Server and Status screens (omlx-screens.jsx
/// uses the same component on both, lines 75 and 833). When a `vm` is wired
/// in (Server screen), the Restart button folds any pending port/host edits
/// into the restart. On the Status screen there is no such VM, so it just
/// asks AppServices to bounce the cached endpoint.
struct ServerHeroCard: View {
var vm: ServerScreenVM? = nil
@Environment(AppServices.self) private var services
@Environment(\.omlxTheme) private var theme
var body: some View {
HStack(spacing: 16) {
// App logo — `AppLogo` asset ships the rounded omlx mark (same
// artwork as the README's hero icon, light/dark variants).
// Replaces the previous gradient squircle + "oM" placeholder
// so the hero card on Server / Status reads as oMLX rather
// than a generic Apple-style server icon.
Image("AppLogo")
.resizable()
.interpolation(.high)
.frame(width: 52, height: 52)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 10) {
Text(title)
.font(.omlxText(18, weight: .semibold))
.foregroundStyle(theme.text)
StatusPill(status: pillStatus)
}
Text(subtitle)
.font(.omlxText(11.5))
.foregroundStyle(theme.textSecondary)
}
Spacer(minLength: 12)
buttons
}
.padding(18)
// Hero card surface follows the grouped Settings style. Status is
// carried by the pill/actions, not by a colored background wash.
.background(heroBackground)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding(.horizontal, 14)
.padding(.bottom, 14)
}
@ViewBuilder
private var buttons: some View {
switch services.serverState {
case .running, .unresponsive:
HStack(spacing: 6) {
Button {
// Pick up any pending edits in Listen Address / Port that
// weren't committed via Enter/blur, so this button is a
// single "apply + restart" affordance rather than only
// restarting on the cached endpoint. When the hero is
// mounted without a VM (Status screen) we simply bounce.
if let vm {
vm.restart(services: services)
} else {
Task { try? await services.restartServer() }
}
} label: {
Label(String(localized: "server.hero.restart",
defaultValue: "Restart",
comment: "Hero card button to restart the server"),
systemImage: "arrow.clockwise")
.labelStyle(.titleAndIcon)
}
.buttonStyle(.omlx(.normal))
Button {
Task { await services.stopServer() }
} label: {
Label(String(localized: "server.hero.stop",
defaultValue: "Stop",
comment: "Hero card button to stop the server"),
systemImage: "stop.fill")
.labelStyle(.titleAndIcon)
}
.buttonStyle(.omlx(.destructive))
}
case .starting, .stopping:
Button(String(localized: "server.hero.working",
defaultValue: "Working…",
comment: "Hero card button label shown while the server is transitioning between states")) { }
.buttonStyle(.omlx(.normal))
.disabled(true)
case .stopped, .failed:
Button {
_ = try? services.startServer()
} label: {
Label(String(localized: "server.hero.start",
defaultValue: "Start Server",
comment: "Hero card button to start the server"),
systemImage: "play.fill")
.labelStyle(.titleAndIcon)
}
.buttonStyle(.omlx(.primary))
.disabled(!services.hasServer)
}
}
private var pillStatus: StatusPill.Status {
switch services.serverState {
case .running: return .running
case .starting: return .starting
case .stopping: return .stopping
case .stopped: return .stopped
case .unresponsive: return .custom(color: theme.amberDot,
label: String(localized: "server.hero.pill.unresponsive",
defaultValue: "Unresponsive",
comment: "Status pill label when the server process is alive but not answering health checks"),
fillBg: true)
case .failed: return .error
}
}
private var subtitle: String {
let host = services.config.host
let port = services.config.port
switch services.serverState {
case .running, .unresponsive:
return String(localized: "server.hero.subtitle.listening",
defaultValue: "Listening on \(host):\(String(port))",
comment: "Hero subtitle while server is running; placeholders are host and port (port is plain integer, no grouping)")
case .starting:
return String(localized: "server.hero.subtitle.starting",
defaultValue: "Starting on \(host):\(String(port))…",
comment: "Hero subtitle while server is starting up; placeholders are host and port (port is plain integer, no grouping)")
case .stopping:
return String(localized: "server.hero.subtitle.stopping",
defaultValue: "Stopping…",
comment: "Hero subtitle while server is shutting down")
case .stopped:
return String(localized: "server.hero.subtitle.not_running",
defaultValue: "Not running",
comment: "Hero subtitle when server is stopped")
case .failed(let m):
return m
}
}
@ViewBuilder
private var heroBackground: some View {
theme.groupBg
}
private var title: String {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
let trimmed = version?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "oMLX" : "oMLX \(trimmed)"
}
}
// MARK: - Default profile editor
/// Editor for `GlobalSettings.sampling` (server-wide defaults).
///
/// The HTML design surfaces 15 fields here. The server's `SamplingSettings`
/// dataclass currently backs 6 (context, max-tokens, temperature, top_p,
/// top_k, repetition_penalty). The other 9 (min_p, presence_penalty, TTL,
/// thinking, force sampling, pin, etc) are per-model only — we render
/// them disabled with a "Per-model only" tag so the user knows where to
/// look. Expander mirrors the design's "Show all fields…" affordance.
private struct ServerDefaultProfileEditor: View {
@Bindable var vm: ServerScreenVM
@State private var expanded: Bool = false
@Environment(\.omlxTheme) private var theme
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ListGroup {
Row(label: String(localized: "server.profile.context_window",
defaultValue: "Context Window",
comment: "Row label for the context window field in Default Profile"),
sublabel: String(localized: "server.profile.context_window.sub",
defaultValue: "Maximum prompt + completion tokens.",
comment: "Sublabel for the context window field")) {
TextInput(text: $vm.samplingContextText, mono: true, suffix: "tk", width: 110)
}
Row(label: String(localized: "server.profile.max_tokens",
defaultValue: "Max Tokens",
comment: "Row label for the max tokens field"),
sublabel: String(localized: "server.profile.max_tokens.sub",
defaultValue: "Server-wide cap on generated tokens.",
comment: "Sublabel for the max tokens field")) {
TextInput(text: $vm.samplingMaxTokensText, mono: true, suffix: "tk", width: 110)
}
Row(label: String(localized: "server.profile.temperature",
defaultValue: "Temperature",
comment: "Row label for the temperature field"),
sublabel: String(localized: "server.profile.temperature.sub",
defaultValue: "Sampling randomness (02).",
comment: "Sublabel for the temperature field")) {
TextInput(text: $vm.samplingTemperatureText, placeholder: "0.7", mono: true, width: 90)
}
Row(label: String(localized: "server.profile.top_p",
defaultValue: "Top P",
comment: "Row label for the Top P field"),
sublabel: String(localized: "server.profile.top_p.sub",
defaultValue: "Nucleus sampling cutoff (01).",
comment: "Sublabel for the Top P field")) {
TextInput(text: $vm.samplingTopPText, mono: true, width: 90)
}
Row(label: String(localized: "server.profile.top_k",
defaultValue: "Top K",
comment: "Row label for the Top K field"),
sublabel: String(localized: "server.profile.top_k.sub",
defaultValue: "Limit candidates to top K. 0 = disabled.",
comment: "Sublabel for the Top K field")) {
TextInput(text: $vm.samplingTopKText, mono: true, width: 90)
}
Row(label: String(localized: "server.profile.repetition_penalty",
defaultValue: "Repetition Penalty",
comment: "Row label for the repetition penalty field"),
sublabel: String(localized: "server.profile.repetition_penalty.sub",
defaultValue: "Penalize repeated tokens.",
comment: "Sublabel for the repetition penalty field"),
isLast: !expanded
) {
TextInput(text: $vm.samplingRepetitionPenaltyText, mono: true, width: 90)
}
if expanded {
// The remaining design rows aren't server-backed yet —
// surfaced disabled with a "Per-model only" pill so the
// user knows to set them in the per-model Advanced tab.
perModelOnlyRow(label: String(localized: "server.profile.min_p",
defaultValue: "Min P",
comment: "Disabled row label for Min P"),
note: String(localized: "server.profile.min_p.note",
defaultValue: "Server defaults don't include min_p; set on a model profile.",
comment: "Note explaining Min P is per-model only"))
perModelOnlyRow(label: String(localized: "server.profile.presence_penalty",
defaultValue: "Presence Penalty",
comment: "Disabled row label for Presence Penalty"),
note: String(localized: "server.profile.presence_penalty.note",
defaultValue: "Per-model only.",
comment: "Note marking Presence Penalty as per-model only"))
perModelOnlyRow(label: String(localized: "server.profile.ttl",
defaultValue: "TTL",
comment: "Disabled row label for TTL"),
note: String(localized: "server.profile.ttl.note",
defaultValue: "Per-model only — see Models → [model] → Basic.",
comment: "Note explaining TTL is per-model only and where to find it"))
perModelOnlyRow(label: String(localized: "server.profile.enable_thinking",
defaultValue: "Enable Thinking",
comment: "Disabled row label for Enable Thinking"),
note: String(localized: "server.profile.enable_thinking.note",
defaultValue: "Per-model only — set on a profile.",
comment: "Note marking Enable Thinking as per-model only"))
perModelOnlyRow(label: String(localized: "server.profile.limit_tool_output",
defaultValue: "Limit Tool Output",
comment: "Disabled row label for Limit Tool Output"),
note: String(localized: "server.profile.limit_tool_output.note",
defaultValue: "Per-model only.",
comment: "Note marking Limit Tool Output as per-model only"))
perModelOnlyRow(label: String(localized: "server.profile.force_sampling",
defaultValue: "Force Sampling",
comment: "Disabled row label for Force Sampling"),
note: String(localized: "server.profile.force_sampling.note",
defaultValue: "Per-model only.",
comment: "Note marking Force Sampling as per-model only"))
perModelOnlyRow(label: String(localized: "server.profile.pin_in_memory",
defaultValue: "Pin in memory",
comment: "Disabled row label for Pin in memory"),
note: String(localized: "server.profile.pin_in_memory.note",
defaultValue: "Per-model only.",
comment: "Note marking Pin in memory as per-model only"))
perModelOnlyRow(label: String(localized: "server.profile.speculative_decoding",
defaultValue: "Speculative decoding",
comment: "Disabled row label for Speculative decoding"),
note: String(localized: "server.profile.speculative_decoding.note",
defaultValue: "Per-model only — see Models → [model] → Advanced.",
comment: "Note explaining Speculative decoding is per-model only and where to find it"),
isLast: true)
}
}
HStack {
Spacer()
Button {
expanded.toggle()
} label: {
Text(expanded
? String(localized: "server.profile.show_fewer",
defaultValue: "Show fewer",
comment: "Toggle label to collapse the advanced sampling fields list")
: String(localized: "server.profile.show_all",
defaultValue: "Show all fields…",
comment: "Toggle label to expand the advanced sampling fields list"))
.font(.omlxText(11.5, weight: .medium))
}
.buttonStyle(.omlx(.plain, size: .small))
.padding(.horizontal, 14)
.padding(.top, 4)
.padding(.bottom, 10)
}
}
}
@ViewBuilder
private func perModelOnlyRow(label: String, note: String, isLast: Bool = false) -> some View {
Row(label: label, sublabel: note, isLast: isLast) {
Text(String(localized: "server.profile.per_model_only",
defaultValue: "Per-model only",
comment: "Pill text marking a sampling field as configurable only on individual model profiles"))
.font(.omlxText(10.5, weight: .heavy))
.kerning(0.6)
.textCase(.uppercase)
.foregroundStyle(theme.textTertiary)
.padding(.horizontal, 8)
.frame(height: 22)
.background(
Capsule().fill(theme.codeBg)
)
.overlay(
Capsule().strokeBorder(theme.inputBorder, lineWidth: 0.5)
)
}
}
}
// MARK: - Endpoints
private struct APIEndpointsList: View {
let host: String
let port: Int
var body: some View {
ListGroup {
Row(label: String(localized: "server.endpoint.openai",
defaultValue: "OpenAI-compatible",
comment: "API endpoint row label for the OpenAI-compatible base URL")) {
CodeChip(value: "http://\(host):\(port)/v1")
}
Row(label: String(localized: "server.endpoint.anthropic",
defaultValue: "Anthropic / Claude Code",
comment: "API endpoint row label for the Anthropic/Claude Code base URL")) {
CodeChip(value: "http://\(host):\(port)")
}
Row(label: String(localized: "server.endpoint.health",
defaultValue: "Health probe",
comment: "API endpoint row label for the health probe URL")) {
CodeChip(value: "http://\(host):\(port)/health")
}
Row(label: String(localized: "server.endpoint.metrics",
defaultValue: "Metrics (Prometheus)",
comment: "API endpoint row label for the Prometheus metrics URL"),
isLast: true) {
CodeChip(value: "http://\(host):\(port)/metrics")
}
}
}
}
// MARK: - Advanced disclosure
/// Phase 4 — Server identity / protocol knobs that the average user never
/// touches: `server_aliases` (extra host names the server identifies as for
/// cookie + host-header purposes) and `sse_keepalive_mode`. Hidden behind a
/// chevron so they don't crowd the main ServerScreen surface, but rendered
/// inline (not in a popover) so power users can scroll-find them.
private struct ServerAdvancedSection: View {
@Bindable var vm: ServerScreenVM
@State private var expanded: Bool = false
@Environment(\.omlxTheme) private var theme
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Custom disclosure header — DisclosureGroup's default styling
// doesn't match the rest of the screen (uses SF Pro vs omlxText,
// adds its own padding). Roll our own to stay consistent with
// SectionHeader.
Button {
expanded.toggle()
} label: {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(theme.textSecondary)
.rotationEffect(.degrees(expanded ? 90 : 0))
.animation(.easeOut(duration: 0.12), value: expanded)
Text(String(localized: "server.section.advanced",
defaultValue: "Advanced",
comment: "Disclosure header for the advanced server settings"))
.font(.omlxText(11, weight: .semibold))
.foregroundStyle(theme.textSecondary)
.textCase(.uppercase)
.kerning(0.6)
Spacer()
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.horizontal, 18)
.padding(.top, 22)
.padding(.bottom, 8)
if expanded {
ListGroup {
Row(
label: String(localized: "server.advanced.sse_keepalive",
defaultValue: "SSE Keep-Alive Mode",
comment: "Advanced row label for the SSE keep-alive mode picker"),
sublabel: String(localized: "server.advanced.sse_keepalive.sub",
defaultValue: "How the server keeps long-lived SSE streams open. \"chunk\" emits an empty data line, \"comment\" emits `: ping`, \"off\" disables.",
comment: "Sublabel describing the SSE keep-alive mode options")
) {
Popup(
selection: vm.bind($vm.sseKeepaliveMode, save: vm.saveSseKeepaliveMode),
width: 130,
options: [
("chunk", String(localized: "server.advanced.sse_keepalive.chunk",
defaultValue: "Chunk",
comment: "SSE keep-alive mode option: empty data chunk")),
("comment", String(localized: "server.advanced.sse_keepalive.comment",
defaultValue: "Comment",
comment: "SSE keep-alive mode option: comment ping")),
("off", String(localized: "server.advanced.sse_keepalive.off",
defaultValue: "Off",
comment: "SSE keep-alive mode option: disabled")),
]
)
}
Row(
label: String(localized: "server.advanced.aliases",
defaultValue: "Server Aliases",
comment: "Advanced row label for the server aliases input"),
sublabel: String(localized: "server.advanced.aliases.sub",
defaultValue: "Extra host names the server identifies as. Comma-separated. Used for cookie / Host header matching.",
comment: "Sublabel describing the server aliases input format"),
isLast: true
) {
TextInput(
text: $vm.serverAliasesText,
placeholder: "omlx.local, oMLX.lan",
mono: true,
width: 320
)
}
}
}
}
}
}
// MARK: - Footer hint
private struct HintFooter: View {
let error: String?
@Environment(\.omlxTheme) private var theme
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HintLine(text: String(localized: "server.footer.hint",
defaultValue: "Listen Address, Log Level, and SSE Keep-Alive Mode apply the moment you change them. The rest (port, default profile, storage, aliases) commits when you click Apply. Port and storage changes take effect after a server restart.",
comment: "Hint footer text under the Server screen explaining which controls apply immediately vs. via the Apply button"))
if let error {
Text(error)
.font(.omlxText(11))
.foregroundStyle(theme.redDot)
}
}
.padding(.horizontal, 18)
.padding(.top, 8)
}
}