1// FullExportView.swift
2// SophaxChat
3//
4// Exports identity keys + all messages into a single encrypted .sxfe file.
5// Use "Restore from Full Export" (FullRestoreView) to import.
6
7import SwiftUI
8import SophaxChatCore
9import UniformTypeIdentifiers
10
11// MARK: - Export view
12
13struct FullExportView: View {
14 @EnvironmentObject var appState: AppState
15 @Environment(\.dismiss) private var dismiss
16
17 @State private var passphrase = ""
18 @State private var confirm = ""
19 @State private var isExporting = false
20 @State private var exportItem: FullExportTransferable?
21 @State private var errorText: String?
22 @State private var successText: String?
23
24 var body: some View {
25 NavigationStack {
26 Form {
27 Section {
28 Text("Exports your identity keys and all messages in one encrypted file. Use to migrate to a new device.")
29 .font(.subheadline)
30 .foregroundStyle(.secondary)
31 }
32
33 Section("Passphrase") {
34 SecureField("Passphrase (min 16 chars)", text: $passphrase)
35 .textContentType(.newPassword)
36 SecureField("Confirm passphrase", text: $confirm)
37 .textContentType(.newPassword)
38 }
39
40 Section {
41 Button {
42 runExport()
43 } label: {
44 if isExporting {
45 ProgressView().frame(maxWidth: .infinity)
46 } else {
47 Label("Export & Save to Files", systemImage: "square.and.arrow.up")
48 .frame(maxWidth: .infinity)
49 }
50 }
51 .disabled(passphrase.count < 16 || passphrase != confirm || isExporting)
52 .fileExporter(
53 isPresented: Binding(
54 get: { exportItem != nil },
55 set: { if !$0 { exportItem = nil } }
56 ),
57 document: exportItem,
58 contentType: .sxfe,
59 defaultFilename: FullExportManager.suggestedFilename()
60 ) { result in
61 switch result {
62 case .success: successText = "Full export saved."
63 case .failure(let e): errorText = e.localizedDescription
64 }
65 exportItem = nil
66 }
67 }
68
69 if let error = errorText {
70 Section { Text(error).foregroundStyle(.red) }
71 }
72 if let success = successText {
73 Section { Text(success).foregroundStyle(.green) }
74 }
75
76 Section {
77 VStack(alignment: .leading, spacing: 6) {
78 Label("Identity keys are included", systemImage: "key.fill")
79 Label("All messages and contacts are included", systemImage: "checkmark.circle")
80 Label("Double Ratchet sessions are NOT included (forward secrecy)", systemImage: "lock.rotation")
81 Label("Keep this file safe — it contains your private keys", systemImage: "exclamationmark.triangle")
82 }
83 .font(.caption)
84 .foregroundStyle(.secondary)
85 }
86 }
87 .navigationTitle("Export Everything")
88 .navigationBarTitleDisplayMode(.inline)
89 .toolbar {
90 ToolbarItem(placement: .confirmationAction) {
91 Button("Done") { dismiss() }
92 }
93 }
94 }
95 }
96
97 private func runExport() {
98 guard passphrase == confirm, passphrase.count >= 16 else {
99 errorText = "Passphrases do not match or are too short (min 16 characters)."
100 return
101 }
102 isExporting = true
103 errorText = nil
104 successText = nil
105 Task.detached(priority: .userInitiated) {
106 do {
107 let data = try await MainActor.run { try appState.exportFullBackup(passphrase: passphrase) }
108 await MainActor.run {
109 exportItem = FullExportTransferable(data: data)
110 isExporting = false
111 }
112 } catch {
113 await MainActor.run {
114 errorText = error.localizedDescription
115 isExporting = false
116 }
117 }
118 }
119 }
120}
121
122// MARK: - Restore view
123
124struct FullRestoreWrapper: Identifiable {
125 let id = UUID()
126 let data: Data
127}
128
129struct FullRestoreView: View {
130 @EnvironmentObject var appState: AppState
131 @Environment(\.dismiss) private var dismiss
132
133 let data: Data
134
135 @State private var passphrase = ""
136 @State private var isRestoring = false
137 @State private var errorText: String?
138 @State private var successText: String?
139
140 var body: some View {
141 NavigationStack {
142 Form {
143 Section {
144 Text("Enter the passphrase you used when exporting.")
145 .font(.subheadline)
146 .foregroundStyle(.secondary)
147 }
148
149 Section("Passphrase") {
150 SecureField("Passphrase", text: $passphrase)
151 .textContentType(.password)
152 }
153
154 Section {
155 Button {
156 runRestore()
157 } label: {
158 if isRestoring {
159 ProgressView().frame(maxWidth: .infinity)
160 } else {
161 Label("Restore", systemImage: "tray.and.arrow.down.fill")
162 .frame(maxWidth: .infinity)
163 }
164 }
165 .disabled(passphrase.isEmpty || isRestoring)
166 }
167
168 if let error = errorText {
169 Section { Text(error).foregroundStyle(.red) }
170 }
171 if let success = successText {
172 Section { Text(success).foregroundStyle(.green) }
173 }
174
175 Section {
176 Text("This replaces your current identity keys and messages. Cannot be undone.")
177 .font(.caption)
178 .foregroundStyle(.secondary)
179 }
180 }
181 .navigationTitle("Restore Full Export")
182 .navigationBarTitleDisplayMode(.inline)
183 .toolbar {
184 ToolbarItem(placement: .confirmationAction) {
185 Button("Done") { dismiss() }
186 }
187 }
188 }
189 }
190
191 private func runRestore() {
192 isRestoring = true
193 errorText = nil
194 successText = nil
195 Task.detached(priority: .userInitiated) {
196 do {
197 try await MainActor.run { try appState.importFullBackup(data: data, passphrase: passphrase) }
198 await MainActor.run {
199 successText = "Restored successfully. Restart the app."
200 isRestoring = false
201 passphrase = ""
202 }
203 } catch {
204 await MainActor.run {
205 errorText = error.localizedDescription
206 isRestoring = false
207 }
208 }
209 }
210 }
211}
212
213// MARK: - FileDocument wrapper
214
215struct FullExportTransferable: FileDocument {
216 static var readableContentTypes: [UTType] { [.sxfe, .data] }
217 let data: Data
218 init(data: Data) { self.data = data }
219 init(configuration: ReadConfiguration) throws {
220 guard let d = configuration.file.regularFileContents else {
221 throw CocoaError(.fileReadCorruptFile)
222 }
223 data = d
224 }
225 func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
226 FileWrapper(regularFileWithContents: data)
227 }
228}
229
230extension UTType {
231 static let sxfe = UTType(exportedAs: "com.sophax.sxfe")
232}