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.
165 lines
4.4 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|