Files
FTStore/admin/FTStoreAdmin/App/AdminServerSettingsView.swift
Felitendo 3e62b98577 refactor(admin): FTStoreAdmin/Console is FTStoreAdmin/App
The last place still calling the operator app a console. The Xcode project uses
a synchronized root group pointed at FTStoreAdmin itself, so it never named the
subfolder and the rename is a git mv and nothing else.

App/ rather than Screens/, because the folder holds the transport, the session
and the wire types as well as the views, and because the distinction that
actually matters here is against Support/: this app's own code, versus helpers
inlined from elsewhere with a note saying where they came from.

Both AGENTS.md files follow, including the note recording that the history runs
back through manager/FTStore/Admin/ — it now says the middle stretch was
Console/, since that is the part a git log --follow has to cross.
2026-08-10 21:16:57 +02:00

371 lines
12 KiB
Swift

//
// 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<AdminSettingsResponse>()
@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<String> {
Binding(
get: { _text[key] ?? "" },
set: { _text[key] = $0 }
)
}
private func _flag(_ key: String) -> Binding<Bool> {
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()
}
}
}