Files
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

165 lines
4.4 KiB
Swift

//
// AdminBackupView.swift
// FTStore Admin
//
// Settings → General → Backup & Restore, on the phone.
//
// The file is a document, not a model: it is fetched and posted back as raw
// bytes so a backup written by a newer FTStore Admin round-trips unchanged
// instead of being silently narrowed to the fields this app happens to know.
//
import SwiftUI
import UIKit
import UniformTypeIdentifiers
struct AdminBackupView: View {
@EnvironmentObject private var session: AdminSession
@State private var _includeSecrets = false
@State private var _exporting = false
@State private var _importing = false
@State private var _picking = false
@State private var _banner: String?
@State private var _failed = false
var body: some View {
Form {
if let banner = _banner {
Section {
Label(banner, systemImage: _failed ? "exclamationmark.triangle" : "checkmark.circle")
.font(.footnote)
.foregroundStyle(_failed ? Color.red : Color.secondary)
}
}
Section {
Toggle("Include secrets", isOn: $_includeSecrets)
Button {
_export()
} label: {
HStack {
Label("Export Settings", systemImage: "square.and.arrow.up")
if _exporting {
Spacer()
ProgressView()
}
}
}
.disabled(_exporting)
} header: {
Text("Export")
} footer: {
Text(_includeSecrets
? "The file will contain the Telegram API hash and both GitHub tokens. Treat it as a credential."
: "Secrets are left out, so the file is safe to hand around. The export reads the saved settings, so save before exporting.")
}
Section {
Button {
_picking = true
} label: {
HStack {
Label("Import Settings", systemImage: "square.and.arrow.down")
if _importing {
Spacer()
ProgressView()
}
}
}
.disabled(_importing)
} header: {
Text("Restore")
} footer: {
Text("Importing merges into this install: what the file names is overwritten, anything it leaves out is kept, including credentials the file doesn't carry. Stored IPAs, the queue, scan progress and users are never touched.")
}
}
.navigationTitle("Backup & Restore")
.navigationBarTitleDisplayMode(.inline)
.fileImporter(
isPresented: $_picking,
allowedContentTypes: [.json],
allowsMultipleSelection: false
) { result in
_import(result)
}
}
// MARK: Export
private func _export() {
_exporting = true
_banner = nil
_failed = false
Task { @MainActor in
do {
let data = try await session.rawData(
.get,
"/api/settings/backup",
query: ["secrets": _includeSecrets ? "1" : "0"]
)
// Written to the temp directory rather than Documents: the file
// only has to survive long enough for the share sheet, and a
// secrets-bearing export should not linger in a browsable
// directory afterwards.
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(Self._fileName())
try data.write(to: url, options: .atomic)
UIActivityViewController.show(activityItems: [url])
_banner = .localized("Exported %@.", AdminFormat.bytes(Int64(data.count)))
} catch {
_failed = true
_banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
_exporting = false
}
}
private static func _fileName() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
return "ftstore-settings-\(formatter.string(from: Date())).json"
}
// MARK: Import
private func _import(_ result: Result<[URL], Error>) {
_banner = nil
_failed = false
guard case .success(let urls) = result, let url = urls.first else {
if case .failure(let error) = result {
_failed = true
_banner = error.localizedDescription
}
return
}
_importing = true
Task { @MainActor in
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
do {
let data = try Data(contentsOf: url)
let response = try await session.rawData(
.post,
"/api/settings/backup",
body: data,
contentType: "application/json"
)
let ack = try? AdminJSON.decoder.decode(AdminAck.self, from: response)
_banner = ack?.summary ?? "Settings imported."
} catch {
_failed = true
_banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
_importing = false
}
}
}