// // SettingsView.swift // Zyquo Router // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Native settings tabs (720×540): Server, Logging, Usage & Pricing, // Appearance, Shortcuts, Advanced. Server + Appearance are fully live in // Phase 4; the rest fill in with Phase 6 features. // import ServiceManagement import SwiftUI import UniformTypeIdentifiers struct SettingsView: View { var body: some View { TabView { ServerSettings() .tabItem { Label("Server", systemImage: "server.rack") } LoggingSettings() .tabItem { Label("Logging", systemImage: "doc.text") } UsageSettings() .tabItem { Label("Usage & Pricing", systemImage: "dollarsign.circle") } AppearanceSettings() .tabItem { Label("Appearance", systemImage: "paintpalette") } ShortcutsSettings() .tabItem { Label("Shortcuts", systemImage: "keyboard") } AdvancedSettings() .tabItem { Label("Advanced", systemImage: "gearshape.2") } } .frame(width: ZyquoMetrics.settingsWidth, height: ZyquoMetrics.settingsHeight) } } private struct ServerSettings: View { @EnvironmentObject private var server: ServerController @AppStorage("autoStartServer") private var autoStart = false @AppStorage("keepServingWhenClosed") private var keepServing = true @AppStorage("menuBarExtraEnabled") private var menuBarExtra = true @State private var launchAtLogin = SMAppService.mainApp.status == .enabled var body: some View { Form { TextField("Default port:", value: $server.port, format: .number.grouping(.never)) .frame(width: 200) Picker("Bind:", selection: $server.bindLAN) { Text("Localhost only").tag(false) Text("LAN (requires a local API key)").tag(true) } .frame(width: 340) Toggle("Start the server when the app launches", isOn: $autoStart) Toggle("Keep serving when the window is closed", isOn: $keepServing) Toggle("Launch Zyquo Router at login", isOn: $launchAtLogin) .onChange(of: launchAtLogin) { enabled in do { if enabled { try SMAppService.mainApp.register() } else { try SMAppService.mainApp.unregister() } } catch { launchAtLogin = SMAppService.mainApp.status == .enabled } } Toggle("Show the menu bar extra", isOn: $menuBarExtra) Text("Request body limit: 32 MB · streaming timeout: 15 min") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) } .padding(ZyquoSpacing.xl) } } private struct LoggingSettings: View { @AppStorage("logBodiesRevealed") private var reveal = false var body: some View { Form { Toggle("Reveal request/response bodies in the inspector (this session)", isOn: $reveal) Text("Bodies are redacted by default. Metadata (model, status, latency, tokens, cost) is always recorded; provider keys are never logged.") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) } .padding(ZyquoSpacing.xl) } } private struct UsageSettings: View { @EnvironmentObject private var catalog: ModelCatalog @EnvironmentObject private var server: ServerController @State private var confirmingReset = false var body: some View { Form { Text("Costs are estimated from the catalog's per-model pricing (\(catalog.all.count) models). Usage counters reset daily at midnight.") .font(ZyquoFont.body()) .foregroundStyle(ZyquoColor.textSecondary) Button("Reset usage counters…") { confirmingReset = true } .confirmationDialog("Clear all recorded usage and the request log?", isPresented: $confirmingReset) { Button("Reset", role: .destructive) { let log = server.requestLog Task { await log.clear() } } } } .padding(ZyquoSpacing.xl) } } private struct AppearanceSettings: View { @EnvironmentObject private var appearance: AppearanceStore var body: some View { Form { Picker("Theme:", selection: $appearance.themeMode) { ForEach(ThemeMode.allCases) { mode in Text(mode.displayName).tag(mode) } } .pickerStyle(.segmented) .frame(width: 320) Picker("Accent:", selection: $appearance.accent) { ForEach(AccentChoice.allCases) { accent in Text(accent.displayName).tag(accent) } } .frame(width: 320) Slider(value: $appearance.fontSize, in: 12...16, step: 0.5) { Text("Font size: \(appearance.fontSize, format: .number.precision(.fractionLength(1)))pt") } .frame(width: 380) } .padding(ZyquoSpacing.xl) } } private struct ShortcutsSettings: View { var body: some View { Form { shortcut("⌘R", "Start / stop the server") shortcut("⌘1–6", "Switch sections") shortcut("⌘⇧C", "Copy endpoint URL") shortcut("⌘K", "Command palette") shortcut("⌘F", "Filter requests") shortcut("⌘⏎", "Send in Playground") } .padding(ZyquoSpacing.xl) } private func shortcut(_ keys: String, _ label: String) -> some View { HStack { Text(keys) .font(ZyquoFont.mono(size: 12, weight: .medium)) .foregroundStyle(ZyquoColor.textPrimary) .frame(width: 70, alignment: .leading) Text(label) .font(ZyquoFont.body()) .foregroundStyle(ZyquoColor.textSecondary) } } } private struct AdvancedSettings: View { @EnvironmentObject private var routerConfig: RouterConfigStore @State private var importError: String? var body: some View { Form { Button("Reveal data folder in Finder") { NSWorkspace.shared.activateFileViewerSelecting([PersistenceService.shared.rootDirectory]) } HStack { Button("Export config…") { exportConfig() } Button("Import config…") { importConfig() } } if let importError { Text(importError) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.danger) } Text("Config export covers aliases, fallback chains, disabled models, and favorites — provider keys stay in the encrypted vault (vault.zq) and are never exported in plaintext.") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) } .padding(ZyquoSpacing.xl) } private func exportConfig() { guard let data = try? routerConfig.exportData() else { return } let panel = NSSavePanel() panel.allowedContentTypes = [.json] panel.nameFieldStringValue = "zyquo-router-config.json" if panel.runModal() == .OK, let url = panel.url { try? data.write(to: url) } } private func importConfig() { let panel = NSOpenPanel() panel.allowedContentTypes = [.json] panel.allowsMultipleSelection = false if panel.runModal() == .OK, let url = panel.url { do { try routerConfig.importData(try Data(contentsOf: url)) importError = nil } catch { importError = "Not a valid Zyquo Router config file." } } } }