// AppView shell. NavigationSplitView backed by the `AppSection` enum, one // sidebar row per screen, grouped into Server / Models / Benchmark / General // sections. Sized close to the minimum comfortable settings window so the // first open does not feel oversized, while still surviving a resize. // // The shell is the entry point for the menubar's `Admin Panel` item and is // hosted in the SwiftUI `Window` scene declared in `oMLXApp.swift`. import AppKit import MarkdownUI import SwiftUI struct AppView: View { @State private var selection: AppSection? = .status @State private var presentedUpdate: AvailableUpdate? @Environment(\.colorScheme) private var scheme @Environment(AppServices.self) private var services var body: some View { let theme = scheme == .dark ? OMLXTheme.dark : OMLXTheme.light let section = selectedSection NavigationSplitView { SettingsSidebar(selection: bindingForSelection()) } detail: { ContentScaffold(section: section, detailTitle: detailTitle) { screen(for: section) } } .navigationSplitViewStyle(.balanced) .frame(minWidth: 880, idealWidth: 880, minHeight: 600, idealHeight: 600) // The theme resolves this through the dynamic macOS window color so // the shell tracks System Settings instead of a fixed canvas color. .background(theme.windowBg) .environment(\.omlxTheme, theme) .onChange(of: services.requestedSection) { _, requested in // A screen asked us to navigate elsewhere (e.g. "Edit on // Server →" from the per-model Profiles tab). Clear the // request after applying so the same section can be requested // twice in a row. if let requested { if requested != .models { services.modelDetailID = nil } selection = requested services.requestedSection = nil } } .onChange(of: services.updates.confirmationUpdate, initial: true) { _, update in presentedUpdate = update } .sheet(item: $presentedUpdate, onDismiss: { services.updates.dismissUpdateConfirmation() }) { update in UpdateConfirmationSheet( update: update, updates: services.updates, onLater: { services.updates.deferUpdate(update) presentedUpdate = nil }, onConfirm: { services.updates.confirmUpdate(update) presentedUpdate = nil } ) .environment(\.omlxTheme, theme) } } /// Drilling out of ModelSettingsScreen via the sidebar (changing section) /// must clear the per-model detail id so we don't accidentally re-enter /// the detail when the user returns to Models. private func bindingForSelection() -> Binding { Binding( get: { selection }, set: { newValue in guard let newValue else { return } if newValue != .models { services.modelDetailID = nil } selection = newValue } ) } private var selectedSection: AppSection { selection ?? .status } private var detailTitle: String? { if selectedSection == .models, let id = services.modelDetailID, !id.isEmpty { return id } return nil } @ViewBuilder private func screen(for section: AppSection) -> some View { switch section { case .server: ServerScreen() case .network: NetworkScreen() case .performance: PerformanceScreen() case .status: StatusScreen() case .logs: LogsScreen() case .models: if let id = services.modelDetailID { ModelSettingsScreen(modelID: id) } else { ModelsScreen() } case .downloads: DownloadsScreen() case .integrations: IntegrationsScreen() case .quantization: QuantizationScreen() case .throughputBench: ThroughputBenchScreen(vm: services.throughputBench) case .accuracyBench: AccuracyBenchScreen(vm: services.accuracyBench) case .security: SecurityScreen() case .about: AboutScreen() } } } // MARK: - Update confirmation @MainActor private struct UpdateConfirmationSheet: View { let update: AvailableUpdate let updates: UpdateController let onLater: () -> Void let onConfirm: () -> Void @Environment(\.omlxTheme) private var theme private var trimmedNotes: String { update.notes.trimmingCharacters(in: .whitespacesAndNewlines) } private var noteBlocks: [ReleaseNotesBlock] { ReleaseNotesHTML.blocks(from: trimmedNotes) } private var isStaged: Bool { if case .ready(let ready) = updates.state { return ready.version == update.version } return false } var body: some View { VStack(spacing: 0) { header Divider() notesBody Divider() footer } .frame(width: 680, height: 560) .background(theme.windowBg) } private var header: some View { HStack(spacing: 12) { Squircle(systemSymbol: "arrow.down.circle.fill", size: 34, gradient: SquircleGradient.update) VStack(alignment: .leading, spacing: 4) { Text(String(localized: "update.confirm.title", defaultValue: "oMLX \(update.version) is available", comment: "Update confirmation sheet title; placeholder is the version")) .font(.omlxText(17, weight: .semibold)) .foregroundStyle(theme.text) Text(String(localized: "update.confirm.subtitle", defaultValue: "Review the release notes before downloading and relaunching.", comment: "Subtitle for the update confirmation sheet")) .font(.omlxText(12)) .foregroundStyle(theme.textSecondary) } Spacer() Button { NSWorkspace.shared.open(update.htmlURL) } label: { Image(systemName: "arrow.up.right.square") .font(.system(size: 13, weight: .semibold)) } .buttonStyle(.omlx(.plain, size: .small)) .help(String(localized: "update.confirm.view_release", defaultValue: "View release on GitHub", comment: "Tooltip for the release link button in the update confirmation sheet")) } .padding(.horizontal, 20) .padding(.vertical, 16) } @ViewBuilder private var notesBody: some View { if trimmedNotes.isEmpty { VStack(spacing: 10) { Image(systemName: "doc.text.magnifyingglass") .font(.system(size: 30, weight: .light)) .foregroundStyle(theme.textTertiary) Text(String(localized: "update.confirm.empty_notes", defaultValue: "This release does not include detailed notes.", comment: "Empty state when a GitHub release has no release notes")) .font(.omlxText(13)) .foregroundStyle(theme.textSecondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(24) } else { ScrollView { LazyVStack(alignment: .leading, spacing: 16) { ForEach(noteBlocks) { block in switch block { case .markdown(let text): Markdown(text) .markdownTheme(.docC) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) case .imageGroup(let images): ReleaseNotesImageGroup(images: images) } } } .padding(20) } } } private var footer: some View { HStack(spacing: 10) { VStack(alignment: .leading, spacing: 3) { Text(String(localized: "update.confirm.size", defaultValue: "Download size: \(update.sizeText ?? "Unknown")", comment: "Update confirmation download size line; placeholder is a formatted byte size or Unknown")) .font(.omlxText(11)) .foregroundStyle(theme.textSecondary) Text(String(localized: "update.confirm.restart_notice", defaultValue: "oMLX will quit, install the update, and relaunch.", comment: "Notice explaining what happens after confirming an update")) .font(.omlxText(11)) .foregroundStyle(theme.textTertiary) } Spacer() Button(String(localized: "update.confirm.later", defaultValue: "Later", comment: "Dismiss button in the update confirmation sheet")) { onLater() } .buttonStyle(.omlx(.normal)) Button(primaryButtonTitle) { onConfirm() } .buttonStyle(.omlx(.primary)) } .padding(.horizontal, 20) .padding(.vertical, 14) } private var primaryButtonTitle: String { if isStaged { return String(localized: "update.confirm.install_ready", defaultValue: "Install & Relaunch", comment: "Primary button when the update is already staged") } return String(localized: "update.confirm.install", defaultValue: "Download, Install & Relaunch", comment: "Primary button to download, install, and relaunch") } } private enum ReleaseNotesBlock: Identifiable { case markdown(String) case imageGroup([ReleaseNotesImage]) var id: String { switch self { case .markdown(let text): return "markdown:\(text.hashValue)" case .imageGroup(let images): return "images:\(images.map(\.id).joined(separator: ","))" } } } private struct ReleaseNotesImage: Identifiable, Equatable { let url: URL let alt: String var id: String { "\(url.absoluteString):\(alt)" } } private enum ReleaseNotesHTML { private static let imageParagraphPattern = #"(?is)]*>\s*((?:]*>\s*)+)

"# private static let imagePattern = #"(?is)]*)>"# private static let attributePattern = #"([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(['"])(.*?)\2"# static func blocks(from raw: String) -> [ReleaseNotesBlock] { guard let paragraphRegex = try? NSRegularExpression(pattern: imageParagraphPattern) else { return markdownBlocks(raw) } var blocks: [ReleaseNotesBlock] = [] var cursor = raw.startIndex let fullRange = NSRange(raw.startIndex.. 1, let bodyRange = Range(match.range(at: 1), in: raw) { let images = extractImages(from: String(raw[bodyRange])) if images.isEmpty { appendMarkdown(String(raw[matchRange]), to: &blocks) } else { blocks.append(.imageGroup(images)) } } cursor = matchRange.upperBound } appendMarkdown(String(raw[cursor.. [ReleaseNotesBlock] { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? [] : [.markdown(trimmed)] } private static func appendMarkdown(_ raw: String, to blocks: inout [ReleaseNotesBlock]) { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { blocks.append(.markdown(trimmed)) } } private static func extractImages(from html: String) -> [ReleaseNotesImage] { guard let imageRegex = try? NSRegularExpression(pattern: imagePattern) else { return [] } let fullRange = NSRange(html.startIndex.. 1, let attrsRange = Range(match.range(at: 1), in: html) else { return nil } let attrs = attributes(from: String(html[attrsRange])) guard let src = attrs["src"], let url = URL(string: decodeHTMLEntities(src)) else { return nil } return ReleaseNotesImage( url: url, alt: attrs["alt"].map(decodeHTMLEntities) ?? "" ) } } private static func attributes(from raw: String) -> [String: String] { guard let attrRegex = try? NSRegularExpression(pattern: attributePattern) else { return [:] } var attrs: [String: String] = [:] let fullRange = NSRange(raw.startIndex.. 3, let keyRange = Range(match.range(at: 1), in: raw), let valueRange = Range(match.range(at: 3), in: raw) else { continue } attrs[String(raw[keyRange]).lowercased()] = String(raw[valueRange]) } return attrs } private static func decodeHTMLEntities(_ raw: String) -> String { guard let data = raw.data(using: .utf8), let decoded = try? NSAttributedString( data: data, options: [ .documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue, ], documentAttributes: nil ).string else { return raw } return decoded } } private struct ReleaseNotesImageGroup: View { let images: [ReleaseNotesImage] @Environment(\.omlxTheme) private var theme var body: some View { HStack(alignment: .top, spacing: 8) { ForEach(images) { image in AsyncImage(url: image.url) { phase in switch phase { case .empty: ProgressView() .frame(maxWidth: .infinity, minHeight: 120) case .success(let rendered): rendered .resizable() .scaledToFit() .accessibilityLabel(image.alt) case .failure: VStack(spacing: 8) { Image(systemName: "photo") .font(.system(size: 24, weight: .light)) .foregroundStyle(theme.textTertiary) if !image.alt.isEmpty { Text(image.alt) .font(.omlxText(11)) .foregroundStyle(theme.textSecondary) .multilineTextAlignment(.center) } } .frame(maxWidth: .infinity, minHeight: 120) @unknown default: EmptyView() } } .frame(maxWidth: .infinity) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(theme.groupBorder.opacity(0.55), lineWidth: 1) ) } } .frame(maxWidth: .infinity) } } // MARK: - Sidebar private struct SettingsSidebar: View { @Binding var selection: AppSection? var body: some View { List(selection: $selection) { Section { SidebarRow(section: .status) SidebarRow(section: .server) SidebarRow(section: .network) SidebarRow(section: .performance) SidebarRow(section: .logs) } header: { Text(String(localized: "sidebar.group.server", defaultValue: "Server", comment: "Sidebar group heading for server-related screens")) } Section { SidebarRow(section: .models) SidebarRow(section: .downloads) SidebarRow(section: .integrations) SidebarRow(section: .quantization) } header: { Text(String(localized: "sidebar.group.models", defaultValue: "Models", comment: "Sidebar group heading for models/downloads/quant screens")) } Section { SidebarRow(section: .throughputBench) SidebarRow(section: .accuracyBench) } header: { Text(String(localized: "sidebar.group.benchmark", defaultValue: "Benchmark", comment: "Sidebar group heading for accuracy + throughput bench screens")) } Section { SidebarRow(section: .security) SidebarRow(section: .about) } header: { Text(String(localized: "sidebar.group.general", defaultValue: "General", comment: "Sidebar group heading for the about/integrations/logs screens")) } } .listStyle(.sidebar) .navigationSplitViewColumnWidth(min: 180, ideal: 195, max: 215) } } private struct SidebarRow: View { let section: AppSection var body: some View { NavigationLink(value: section) { Label(section.title, systemImage: section.symbol) } } } // MARK: - Detail scaffold /// Wraps the per-section view with the design's toolbar title + scroll body. /// Mirrors `ContentArea` from the design (omlx-components.jsx:250-292): /// 42 pt toolbar, 720 pt max content width, 20/28/36 pt padding. private struct ContentScaffold: View { let section: AppSection let detailTitle: String? @ViewBuilder var content: () -> Content @Environment(\.omlxTheme) private var theme @Environment(AppServices.self) private var services private var titleText: String { detailTitle ?? section.title } var body: some View { Group { if section.fillsContentArea { content() .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: 720, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .padding(.bottom, 18) .background(theme.windowBg) } else { ScrollViewReader { proxy in ScrollView { content() // Wrap title + content together in a single max-width // frame so the section title and the cards share the // same left edge (Settings.app pattern: large title // sits flush with content, not offset). .frame(maxWidth: 720, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .top) .padding(.bottom, 36) } // Deep-link scroll: when another screen (e.g. the // per-model "Edit on Server →" link) requested a // jump to a named anchor *inside the section we just // switched to*, scroll there once the inner view has // had a runloop tick to lay out. The id includes // both section and anchor so re-requesting the same // anchor in the same section still fires. .task(id: ScrollAnchorKey(section: section, anchor: services.requestedServerAnchor)) { guard let anchor = services.requestedServerAnchor, section == .server else { return } // One render cycle to let ServerScreen mount its // SectionHeader with the `.id()` we're targeting. try? await Task.sleep(nanoseconds: 60_000_000) withAnimation(.easeInOut(duration: 0.25)) { proxy.scrollTo(anchor.rawValue, anchor: .top) } services.requestedServerAnchor = nil } .scrollContentBackground(.hidden) .background(theme.windowBg) } } } .navigationTitle(titleText) .background(theme.windowBg) } } /// Composite identity used by `ContentScaffold`'s deep-link scroll /// `.task(id:)` so the scroll fires whenever either the section or the /// anchor changes — and re-fires if the same anchor is requested twice. private struct ScrollAnchorKey: Equatable { let section: AppSection let anchor: ServerAnchor? } #Preview("AppView — light") { AppView() .frame(width: 1140, height: 760) .preferredColorScheme(.light) } #Preview("AppView — dark") { AppView() .frame(width: 1140, height: 760) .preferredColorScheme(.dark) }