// // AdminServerSettingsView.swift // FTStore Admin // // FTStore Admin's own settings, edited from the phone. `/api/settings` is a // flat key/value store, so this screen is driven by a descriptor table rather // than a hand-written form — a new server setting only needs a row here. // // Credentials are deliberately read-only: the server already replaces // `github_token` and `ftstore_app_github_token` with a mask before serialising, // and Telegram's API hash is only ever entered in the web dashboard. This app // can see *that* a secret is configured, never what it is. // // There is no Save button. A switch writes the moment it moves, and a text // field writes when it stops being edited — which is the point at which the // value is finished rather than half-typed. Every write carries the one key // that changed, so leaving a field alone can no longer send a stale copy of it // back over somebody else's change, which the old whole-form PUT did on every // press. // import SwiftUI struct AdminServerSettingsView: View { @EnvironmentObject private var session: AdminSession @StateObject private var _settings = AdminLoader() @State private var _text: [String: String] = [:] @State private var _flags: [String: Bool] = [:] @State private var _saving = false @State private var _banner: String? /// What the server last told us each field holds. /// /// Kept beside the editable copy so a field can be compared against it and /// skipped when nothing actually changed — tapping through a form should not /// write every value it passed. @State private var _savedText: [String: String] = [:] @State private var _savedFlags: [String: Bool] = [:] /// Which text field is being typed in. A field commits when this moves off /// it, which is the moment the value is finished rather than half-typed: /// saving per keystroke would send "ftstore.a" on the way to "ftstore.app". @FocusState private var _focused: String? // MARK: Descriptors private struct Field: Identifiable { enum Kind { case text, number, toggle } let key: String let title: String let kind: Kind var placeholder: String = "" var id: String { key } } private struct FieldGroup: Identifiable { let title: String let footer: String? let fields: [Field] var id: String { title } } // Two groups that used to be here are gone, and neither is coming back: // // • "Source Metadata" (the `source_*` branding). FTStore is the only thing // this server publishes, so its name, subtitle, description and tint are // constants. Site domain stays below: it is deployment plumbing. // • "Feed & Storage", whose one switch let an operator stop requiring the // FTStore User-Agent on IPA downloads. Downloads are restricted to // FTStore full stop now, so the switch is gone and its neighbours moved // into the groups they belong in. private static let _groups: [FieldGroup] = [ FieldGroup( title: "Server", footer: "The site domain is used for download links when the reverse proxy doesn't forward the original host. Leave it blank to derive it per request.", fields: [ Field(key: "site_domain", title: "Site domain", kind: .text, placeholder: "ftstore.app"), Field(key: "appstore_country", title: "App Store country", kind: .text, placeholder: "us"), ] ), FieldGroup( title: "Intervals", footer: nil, fields: [ Field(key: "scan_interval_minutes", title: "Scan every (min)", kind: .number), Field(key: "json_regen_interval_minutes", title: "Publish every (min)", kind: .number), Field(key: "cleanup_interval_hours", title: "Cleanup every (h)", kind: .number), ] ), FieldGroup( title: "Limits", footer: nil, fields: [ Field(key: "max_versions_per_app", title: "Versions per app", kind: .number), Field(key: "log_retention_days", title: "Keep logs (days)", kind: .number), Field(key: "scan_message_limit", title: "Messages per scan", kind: .number), Field(key: "github_release_scan_limit", title: "GitHub releases per scan", kind: .number), Field(key: "previous_ipa_scan_amount", title: "Scan-previous depth", kind: .number), ] ), FieldGroup( title: "Storage", footer: "Max storage is in GiB; 0 means unlimited. When the cap is reached the scraper stops downloading, and manual uploads keep working.", fields: [ Field(key: "storage_dir", title: "Storage directory", kind: .text), Field(key: "temp_dir", title: "Temp directory", kind: .text), Field(key: "max_storage_gb", title: "Max storage (GB)", kind: .number), ] ), ] /// Shown but never editable from here. The GitHub owner/repo/branch rows are /// gone with the settings behind them: the app repo is hardcoded and every /// scanned repository is configured per source under Channels & Sources, so /// those three had been read-only views of three values nothing read. private static let _readOnly: [(key: String, title: String)] = [ ("telegram_api_id", "Telegram API ID"), ("telegram_api_hash", "Telegram API hash"), ("telegram_phone", "Telegram phone"), ("github_token", "GitHub token"), ("ftstore_app_github_token", "Manager proxy token"), ] // MARK: Body var body: some View { Form { if let banner = _banner { Section { Text(banner).font(.footnote).foregroundStyle(.secondary) } } if _settings.isLoading { AdminLoadingRow() } else if let error = _settings.errorMessage, !_settings.hasLoaded { AdminErrorRow(message: error) { Task { await _reload() } } } else if _settings.hasLoaded { // The descriptor tables above hold `String`, not // `LocalizedStringKey`, so every title and footer is looked up // here rather than by SwiftUI. ForEach(Self._groups) { group in Section { ForEach(group.fields) { field in _row(field) } } header: { Text(String.localized(group.title)) } footer: { if let footer = group.footer { Text(String.localized(footer)) } } } Section { ForEach(Self._readOnly, id: \.key) { entry in LabeledContent(String.localized(entry.title)) { Text(_readOnlyValue(entry.key)) .foregroundStyle(.secondary) .lineLimit(1) } } } header: { Text("Credentials") } footer: { Text("Read-only on purpose. Secrets arrive masked from the server and are edited in the web dashboard.") } Section { NavigationLink { AdminBackupView() } label: { Label("Backup & Restore", systemImage: "arrow.up.arrow.down.square") } } footer: { Text("Export every setting, channel and GitHub source as one JSON file, or restore one.") } } } .navigationTitle("Server Settings") .navigationBarTitleDisplayMode(.inline) .refreshable { await _reload() } .task { await _reload() } // Single-argument onChange: the two-argument form is iOS 17 and this // deploys lower. .onChange(of: _focused) { _ in _commitEverythingUnfocused() } // Leaving the screen ends editing without the focus ever moving to // another field, so without this the last thing typed would be lost. .onDisappear { _commitEverythingUnfocused() } .toolbar { ToolbarItem(placement: .confirmationAction) { if _saving { ProgressView() } } } } // MARK: Rows @ViewBuilder private func _row(_ field: Field) -> some View { switch field.kind { case .toggle: // A switch has no half-flipped state, so it goes the moment it moves. Toggle(String.localized(field.title), isOn: _flag(field.key)) case .number: LabeledContent(String.localized(field.title)) { // The placeholder is an example value ("ftstore.app", "us"), not // prose, so it stays as written. TextField(field.placeholder, text: _binding(field.key)) .keyboardType(.numberPad) .multilineTextAlignment(.trailing) .focused($_focused, equals: field.key) .onSubmit { _commit(field.key) } } case .text: LabeledContent(String.localized(field.title)) { TextField(field.placeholder, text: _binding(field.key)) .textInputAutocapitalization(.never) .autocorrectionDisabled() .multilineTextAlignment(.trailing) .focused($_focused, equals: field.key) .onSubmit { _commit(field.key) } } } } private func _readOnlyValue(_ key: String) -> String { let value = _settings.value?.data.string(key) ?? "" return value.isEmpty ? .localized("not set") : value } // MARK: Bindings private func _binding(_ key: String) -> Binding { Binding( get: { _text[key] ?? "" }, set: { _text[key] = $0 } ) } private func _flag(_ key: String) -> Binding { Binding( get: { _flags[key] ?? false }, // Moved first so the switch does not sit under a thumb waiting for a // round trip; the reload afterwards is what makes it honest again. set: { newValue in _flags[key] = newValue _commit(key) } ) } // MARK: Loading & saving private func _reload() async { await _settings.load { try await session.get("/api/settings", as: AdminSettingsResponse.self) } guard let data = _settings.value?.data else { return } for group in Self._groups { for field in group.fields { // The field being typed in is left alone: a reload triggered by // saving its neighbour must not overwrite what is under the // cursor right now. guard field.key != _focused else { continue } switch field.kind { case .toggle: let flag = data.bool(field.key) ?? false _flags[field.key] = flag _savedFlags[field.key] = flag case .number: let text = data.int(field.key).map(String.init) ?? "" _text[field.key] = text _savedText[field.key] = text case .text: let text = data.string(field.key) ?? "" _text[field.key] = text _savedText[field.key] = text } } } } /// Write every text field that has been edited and is no longer focused. /// /// Driven off the focus moving rather than off each keystroke, so a value is /// sent once it is finished. Called for *all* unfocused fields rather than /// only the one just left, because a keyboard dismissed by tapping the /// background moves focus to nil without ever naming what it came from. private func _commitEverythingUnfocused() { for group in Self._groups { for field in group.fields where field.kind != .toggle { guard field.key != _focused else { continue } _commit(field.key) } } } /// Send one setting, if it actually differs from what the server has. /// /// One key per request rather than the whole form. That is what makes saving /// on the way past a field safe: an operator editing the scan interval can no /// longer write back a stale copy of a domain somebody else changed a minute /// ago, which a wholesale PUT did every time it fired. private func _commit(_ key: String) { guard _settings.hasLoaded else { return } guard let field = Self._groups .flatMap({ $0.fields }) .first(where: { $0.key == key }) else { return } let value: AnyCodable switch field.kind { case .toggle: let flag = _flags[key] ?? false guard flag != _savedFlags[key] else { return } value = AnyCodable(flag) case .number: // A blanked-out field is left alone rather than written as 0 over a // working value. guard let number = Int(_text[key] ?? "") else { return } guard String(number) != _savedText[key] else { return } value = AnyCodable(number) case .text: let text = _text[key] ?? "" guard text != _savedText[key] else { return } value = AnyCodable(text) } // Recorded before the round trip, so a second commit for the same field // (focus moving and the view disappearing together) does not send twice. switch field.kind { case .toggle: _savedFlags[key] = _flags[key] ?? false case .number, .text: _savedText[key] = _text[key] ?? "" } _saving = true Task { @MainActor in do { let ack = try await session.act( .put, "/api/settings", body: [key: value] ) _banner = ack.summary } catch { _banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription // Put the snapshot back so the next attempt is not skipped as // "unchanged" — a failed write must stay pending, not vanish. switch field.kind { case .toggle: _savedFlags[key] = nil case .number, .text: _savedText[key] = nil } } _saving = false await _reload() } } }