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.
371 lines
12 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|