1// BackupView.swift
2// SophaxChat
3//
4// Export and import encrypted local backups.
5// No cloud. No server. You own your data.
6
7import SwiftUI
8import SophaxChatCore
9import UniformTypeIdentifiers
10
11struct BackupView: View {
12 @EnvironmentObject var appState: AppState
13 @Environment(\.dismiss) private var dismiss
14
15 @State private var passphrase = ""
16 @State private var confirm = ""
17 @State private var isExporting = false
18 @State private var isImporting = false
19 @State private var exportItem: BackupTransferable?
20 @State private var errorText: String?
21 @State private var successText: String?
22
23 var body: some View {
24 NavigationStack {
25 Form {
26 Section {
27 Text("Your backup is encrypted with your passphrase. Nobody — not us, not Apple — can open it without it.")
28 .font(.subheadline)
29 .foregroundStyle(.secondary)
30 }
31
32 Section("Export Backup") {
33 SecureField("Passphrase", text: $passphrase)
34 .textContentType(.newPassword)
35 SecureField("Confirm passphrase", text: $confirm)
36 .textContentType(.newPassword)
37
38 Button {
39 export()
40 } label: {
41 if isExporting {
42 ProgressView().frame(maxWidth: .infinity)
43 } else {
44 Label("Export Encrypted Backup", systemImage: "square.and.arrow.up")
45 .frame(maxWidth: .infinity)
46 }
47 }
48 .disabled(passphrase.count < 16 || passphrase != confirm || isExporting)
49 .fileExporter(
50 isPresented: Binding(get: { exportItem != nil }, set: { if !$0 { exportItem = nil } }),
51 document: exportItem,
52 contentType: .sophaxBackup,
53 defaultFilename: BackupManager.suggestedFilename()
54 ) { result in
55 switch result {
56 case .success: successText = "Backup saved."
57 case .failure(let e): errorText = e.localizedDescription
58 }
59 exportItem = nil
60 }
61 }
62
63 Section("Restore Backup") {
64 SecureField("Passphrase used when exporting", text: $passphrase)
65 .textContentType(.password)
66
67 Button {
68 isImporting = true
69 } label: {
70 Label("Choose Backup File…", systemImage: "square.and.arrow.down")
71 .frame(maxWidth: .infinity)
72 }
73 .disabled(passphrase.isEmpty)
74 .fileImporter(
75 isPresented: $isImporting,
76 allowedContentTypes: [.sophaxBackup, .data]
77 ) { result in
78 restore(result: result)
79 }
80 }
81
82 if let error = errorText {
83 Section {
84 Text(error).foregroundStyle(.red)
85 }
86 }
87 if let success = successText {
88 Section {
89 Text(success).foregroundStyle(.green)
90 }
91 }
92
93 Section {
94 VStack(alignment: .leading, spacing: 6) {
95 Label("Messages and contacts are backed up", systemImage: "checkmark.circle")
96 Label("Identity keys are NOT exported (by design)", systemImage: "lock.shield")
97 Label("Contacts will ask to re-verify safety numbers", systemImage: "info.circle")
98 }
99 .font(.caption)
100 .foregroundStyle(.secondary)
101 }
102 }
103 .navigationTitle("Backup & Restore")
104 .navigationBarTitleDisplayMode(.inline)
105 .toolbar {
106 ToolbarItem(placement: .confirmationAction) {
107 Button("Done") { dismiss() }
108 }
109 }
110 }
111 }
112
113 // MARK: - Actions
114
115 private func export() {
116 guard passphrase == confirm, passphrase.count >= 16 else {
117 errorText = "Passphrases do not match or are too short (min 16 characters)."
118 return
119 }
120 isExporting = true
121 errorText = nil
122 successText = nil
123 Task.detached(priority: .userInitiated) {
124 do {
125 let data = try await MainActor.run { try appState.exportBackup(passphrase: passphrase) }
126 await MainActor.run {
127 exportItem = BackupTransferable(data: data)
128 isExporting = false
129 }
130 } catch {
131 await MainActor.run {
132 errorText = error.localizedDescription
133 isExporting = false
134 }
135 }
136 }
137 }
138
139 private func restore(result: Result<URL, Error>) {
140 errorText = nil
141 successText = nil
142 switch result {
143 case .failure(let e):
144 errorText = e.localizedDescription
145 case .success(let url):
146 guard url.startAccessingSecurityScopedResource() else {
147 errorText = "Could not access the selected file."
148 return
149 }
150 defer { url.stopAccessingSecurityScopedResource() }
151 do {
152 let data = try Data(contentsOf: url)
153 try appState.importBackup(data: data, passphrase: passphrase)
154 successText = "Backup restored. Restart the app to see all messages."
155 passphrase = ""
156 } catch {
157 errorText = error.localizedDescription
158 }
159 }
160 }
161}
162
163// MARK: - FileDocument wrapper
164
165struct BackupTransferable: FileDocument {
166 static var readableContentTypes: [UTType] { [.sophaxBackup, .data] }
167 let data: Data
168 init(data: Data) { self.data = data }
169 init(configuration: ReadConfiguration) throws {
170 guard let d = configuration.file.regularFileContents else { throw CocoaError(.fileReadCorruptFile) }
171 self.data = d
172 }
173 func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
174 FileWrapper(regularFileWithContents: data)
175 }
176}
177
178extension UTType {
179 static let sophaxBackup = UTType(exportedAs: "com.sophax.sophaxbackup")
180}