// // 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 } } }