1// SettingsView.swift
2// SophaxChat
3
4import SwiftUI
5import SophaxChatCore
6
7struct SettingsView: View {
8 @EnvironmentObject var appState: AppState
9 @Environment(\.dismiss) private var dismiss
10 #if !targetEnvironment(macCatalyst)
11 @ObservedObject private var torManager = TorManager.shared
12 #endif
13
14 @State private var tcpConnectAddress: String = ""
15 @State private var showTCPConnectAlert: Bool = false
16 @State private var tcpConnectError: String? = nil
17 @State private var showingContactCard: Bool = false
18 @State private var showingBackup: Bool = false
19 @State private var showingIdentity: Bool = false
20 @State private var showingWipeConfirm: Bool = false
21 @State private var showingWipeConfirm2: Bool = false
22 @State private var showingFullExport: Bool = false
23 @State private var showingFullImport: Bool = false
24 @State private var pendingFullRestoreData: FullRestoreWrapper? = nil
25 @State private var showingPINSetup: Bool = false
26 @State private var showingDuressSetup: Bool = false
27 @State private var showingCrashLog: Bool = false
28 @State private var showAddTrustedWipePeer: Bool = false
29
30 private var trimmedTCPAddress: String {
31 tcpConnectAddress.trimmingCharacters(in: .whitespaces)
32 }
33
34 var blockedList: [(id: String, name: String)] {
35 appState.blockedPeers.sorted().map { id in
36 let name = appState.blockedPeerNames[id] ?? String(id.prefix(12)) + "…"
37 return (id: id, name: name)
38 }
39 }
40
41 var body: some View {
42 NavigationStack {
43 List {
44 identitySection
45 securitySection
46 globalSection
47 backupSection
48 helpSection
49 blockedSection
50 remoteWipeSection
51 dangerSection
52 }
53 .navigationTitle("Settings")
54 .navigationBarTitleDisplayMode(.inline)
55 .toolbar {
56 ToolbarItem(placement: .confirmationAction) {
57 Button("Done") { dismiss() }
58 }
59 }
60 .alert("Connection Failed", isPresented: $showTCPConnectAlert) {
61 Button("OK", role: .cancel) { tcpConnectError = nil }
62 } message: {
63 Text(tcpConnectError ?? "")
64 }
65 .sheet(isPresented: $showingContactCard) {
66 ContactCardView().environmentObject(appState)
67 }
68 .sheet(isPresented: $showingBackup) {
69 BackupView().environmentObject(appState)
70 }
71 .sheet(isPresented: $showingIdentity) {
72 IdentityView().environmentObject(appState)
73 }
74 .sheet(isPresented: $showingFullExport) {
75 FullExportView().environmentObject(appState)
76 }
77 .sheet(isPresented: $showingPINSetup) {
78 LockPINSetupView().environmentObject(appState)
79 }
80 .sheet(isPresented: $showingDuressSetup) {
81 DuressPINSetupView().environmentObject(appState)
82 }
83 .sheet(isPresented: $showingCrashLog) {
84 CrashLogView()
85 }
86 .sheet(item: $pendingFullRestoreData) { wrapper in
87 FullRestoreView(data: wrapper.data).environmentObject(appState)
88 }
89 .fileImporter(
90 isPresented: $showingFullImport,
91 allowedContentTypes: [.data],
92 allowsMultipleSelection: false
93 ) { result in
94 guard case .success(let urls) = result, let url = urls.first else { return }
95 guard url.startAccessingSecurityScopedResource() else { return }
96 defer { url.stopAccessingSecurityScopedResource() }
97 guard let data = try? Data(contentsOf: url) else { return }
98 pendingFullRestoreData = FullRestoreWrapper(data: data)
99 }
100 .confirmationDialog(
101 "Delete account and all data?",
102 isPresented: $showingWipeConfirm,
103 titleVisibility: .visible
104 ) {
105 Button("Yes, delete everything", role: .destructive) {
106 showingWipeConfirm2 = true
107 }
108 Button("Cancel", role: .cancel) {}
109 } message: {
110 Text("This will permanently erase your identity, all messages, and all attachments. There is no undo.")
111 }
112 .confirmationDialog(
113 "Are you absolutely sure?",
114 isPresented: $showingWipeConfirm2,
115 titleVisibility: .visible
116 ) {
117 Button("Delete everything now", role: .destructive) {
118 dismiss()
119 appState.wipeAccount()
120 }
121 Button("Cancel", role: .cancel) {}
122 } message: {
123 Text("Your identity keys will be destroyed. You cannot recover your account after this.")
124 }
125 }
126 }
127
128 // MARK: - Sections
129
130 @ViewBuilder
131 private var identitySection: some View {
132 Section {
133 Button { showingIdentity = true } label: {
134 HStack(spacing: 14) {
135 ZStack {
136 Circle().fill(Color.accentColor.opacity(0.12)).frame(width: 40, height: 40)
137 Text(String(appState.myUsername?.prefix(1) ?? "?").uppercased())
138 .font(.headline.bold())
139 .foregroundStyle(Color.accentColor)
140 }
141 VStack(alignment: .leading, spacing: 2) {
142 Text(appState.myUsername ?? "My Profile")
143 .font(.subheadline.weight(.semibold))
144 .foregroundStyle(.primary)
145 Text("Identity, safety number, backup")
146 .font(.caption)
147 .foregroundStyle(.secondary)
148 }
149 Spacer()
150 Image(systemName: "chevron.right")
151 .font(.caption.weight(.semibold))
152 .foregroundStyle(.tertiary)
153 }
154 .padding(.vertical, 4)
155 }
156 .buttonStyle(.plain)
157 } header: {
158 Text("Profile")
159 }
160 }
161
162 @ViewBuilder
163 private var blockedSection: some View {
164 Section {
165 if blockedList.isEmpty {
166 Text("No blocked users").foregroundStyle(.secondary)
167 } else {
168 ForEach(blockedList, id: \.id) { entry in
169 HStack {
170 VStack(alignment: .leading, spacing: 2) {
171 Text(entry.name)
172 .font(.subheadline.weight(.medium))
173 Text(String(entry.id.prefix(16)) + "…")
174 .font(.system(.caption2, design: .monospaced))
175 .foregroundStyle(.tertiary)
176 }
177 Spacer()
178 Button("Unblock") { appState.unblockPeer(peerID: entry.id) }
179 .font(.subheadline)
180 .buttonStyle(.bordered)
181 .tint(.accentColor)
182 }
183 }
184 }
185 } header: {
186 Text("Blocked Users")
187 } footer: {
188 Text("Blocked users cannot send you messages. Unblocking allows future messages if they are nearby.")
189 }
190 }
191
192 @ViewBuilder
193 private var securitySection: some View {
194 Section {
195 Toggle("App Lock", isOn: Binding(
196 get: { appState.appLockEnabled },
197 set: { enabled in
198 appState.appLockEnabled = enabled
199 if !enabled {
200 appState.isAppLocked = false
201 appState.clearRealLockPIN()
202 }
203 }
204 ))
205 Toggle(isOn: $appState.notifShowSender) {
206 VStack(alignment: .leading, spacing: 2) {
207 Text("Show sender in notifications")
208 Text("Displays name and message preview on the lock screen")
209 .font(.caption)
210 .foregroundStyle(.secondary)
211 }
212 }
213 #if !targetEnvironment(macCatalyst)
214 Toggle(isOn: $appState.screenshotPreventionEnabled) {
215 VStack(alignment: .leading, spacing: 2) {
216 Text("Block screenshots")
217 Text("Screenshots and screen recordings appear blank")
218 .font(.caption)
219 .foregroundStyle(.secondary)
220 }
221 }
222 #endif
223 } header: {
224 Text("Security")
225 } footer: {
226 Text("When "Show sender" is off, notifications only say "New message" — no name or content visible on the lock screen.")
227 }
228
229 if appState.appLockEnabled {
230 Section {
231 Button {
232 showingPINSetup = true
233 } label: {
234 HStack {
235 Label(
236 appState.hasRealLockPIN ? "Change Lock PIN" : "Set Lock PIN",
237 systemImage: "lock.rotation"
238 )
239 Spacer()
240 if appState.hasRealLockPIN {
241 Image(systemName: "checkmark.circle.fill")
242 .foregroundStyle(.green)
243 }
244 }
245 }
246 if appState.hasRealLockPIN {
247 Button {
248 showingDuressSetup = true
249 } label: {
250 HStack {
251 Label(
252 appState.hasDuressPIN ? "Change Duress PIN" : "Set Duress PIN",
253 systemImage: "exclamationmark.shield"
254 )
255 Spacer()
256 if appState.hasDuressPIN {
257 Image(systemName: "checkmark.circle.fill")
258 .foregroundStyle(.orange)
259 }
260 }
261 }
262 if appState.hasDuressPIN {
263 Button(role: .destructive) {
264 appState.clearDuressPIN()
265 } label: {
266 Label("Remove Duress PIN", systemImage: "trash")
267 }
268 }
269 }
270 } header: {
271 Text("PIN Lock")
272 } footer: {
273 Text(appState.hasRealLockPIN
274 ? "Entering the duress PIN shows an empty app without unlocking it."
275 : "Set a numeric PIN to enable an alternative unlock path. Required before setting a duress PIN.")
276 }
277 }
278 }
279
280 @ViewBuilder
281 private var globalSection: some View {
282 Section {
283 // Main toggle
284 Toggle(isOn: Binding(
285 get: { appState.tcpEnabled },
286 set: { appState.tcpEnabled = $0 }
287 )) {
288 VStack(alignment: .leading, spacing: 3) {
289 Text("Chat with anyone in the world")
290 .font(.body)
291 Text("Not just people nearby — reach anyone, anywhere")
292 .font(.caption)
293 .foregroundStyle(.secondary)
294 }
295 }
296
297 if appState.tcpEnabled {
298 #if !targetEnvironment(macCatalyst)
299 Toggle(isOn: $appState.torEnabled) {
300 Label("Route through Tor", systemImage: "network.badge.shield.half.filled")
301 }
302 if appState.torEnabled {
303 torStatusCard
304 Toggle(isOn: $appState.torEnforcedMode) {
305 VStack(alignment: .leading, spacing: 2) {
306 Text("Enforce Tor (refuse clearnet)")
307 Text("TCP will not start until Tor is ready. If Tor stops, TCP stops.")
308 .font(.caption)
309 .foregroundStyle(.secondary)
310 }
311 }
312 }
313 #endif
314 connectCard
315 }
316 } header: {
317 Text("Global Reach")
318 } footer: {
319 Text(appState.tcpEnabled
320 ? "Messages are always end-to-end encrypted. No server, no account — only you and the person you're chatting with."
321 : "Turn this on to chat with people anywhere in the world.")
322 }
323 }
324
325 #if !targetEnvironment(macCatalyst)
326 @ViewBuilder
327 private var torStatusCard: some View {
328 switch torManager.state {
329 case .ready:
330 Button { showingContactCard = true } label: {
331 HStack(spacing: 14) {
332 ZStack {
333 Circle().fill(Color.green.opacity(0.12)).frame(width: 44, height: 44)
334 Image(systemName: "checkmark.shield.fill")
335 .font(.system(size: 20))
336 .foregroundStyle(.green)
337 }
338 VStack(alignment: .leading, spacing: 3) {
339 Text("Anonymous & ready")
340 .font(.subheadline.weight(.semibold))
341 .foregroundStyle(.primary)
342 Text("Tor is running — share your address to receive messages")
343 .font(.caption)
344 .foregroundStyle(.secondary)
345 }
346 Spacer()
347 Image(systemName: "qrcode")
348 .foregroundStyle(.secondary)
349 }
350 .padding(.vertical, 4)
351 }
352
353 case .starting:
354 HStack(spacing: 14) {
355 ZStack {
356 Circle().fill(Color.accentColor.opacity(0.10)).frame(width: 44, height: 44)
357 Image(systemName: "network")
358 .font(.system(size: 20))
359 .foregroundStyle(Color.accentColor)
360 }
361 VStack(alignment: .leading, spacing: 6) {
362 HStack {
363 Text("Connecting to Tor…")
364 .font(.subheadline.weight(.semibold))
365 Spacer()
366 Text("\(torManager.bootstrapProgress)%")
367 .font(.caption.monospacedDigit())
368 .foregroundStyle(.secondary)
369 }
370 ProgressView(value: Double(torManager.bootstrapProgress), total: 100)
371 .tint(Color.accentColor)
372 }
373 }
374 .padding(.vertical, 4)
375
376 case .failed(let reason):
377 HStack(spacing: 14) {
378 ZStack {
379 Circle().fill(Color.red.opacity(0.10)).frame(width: 44, height: 44)
380 Image(systemName: "exclamationmark.triangle.fill")
381 .font(.system(size: 20))
382 .foregroundStyle(.red)
383 }
384 VStack(alignment: .leading, spacing: 3) {
385 Text("Tor unavailable")
386 .font(.subheadline.weight(.semibold))
387 Text(reason)
388 .font(.caption)
389 .foregroundStyle(.secondary)
390 }
391 }
392 .padding(.vertical, 4)
393
394 case .stopped:
395 EmptyView()
396 }
397 }
398 #endif // !targetEnvironment(macCatalyst)
399
400 @ViewBuilder
401 private var connectCard: some View {
402 VStack(alignment: .leading, spacing: 10) {
403 HStack(spacing: 14) {
404 ZStack {
405 Circle().fill(Color.accentColor.opacity(0.10)).frame(width: 44, height: 44)
406 Image(systemName: "person.crop.circle.badge.plus")
407 .font(.system(size: 20))
408 .foregroundStyle(Color.accentColor)
409 }
410 VStack(alignment: .leading, spacing: 3) {
411 Text("Connect to someone")
412 .font(.subheadline.weight(.semibold))
413 Text("Enter the address they shared with you")
414 .font(.caption)
415 .foregroundStyle(.secondary)
416 }
417 }
418 HStack(spacing: 10) {
419 TextField("e.g. abc123.onion:25519", text: $tcpConnectAddress)
420 .keyboardType(.asciiCapable)
421 .autocorrectionDisabled()
422 .textInputAutocapitalization(.never)
423 .padding(10)
424 .background(Color(.systemGray6))
425 .clipShape(RoundedRectangle(cornerRadius: 10))
426 Button {
427 if let err = appState.connectViaTCP(address: trimmedTCPAddress) {
428 tcpConnectError = err
429 showTCPConnectAlert = true
430 } else {
431 tcpConnectAddress = ""
432 }
433 } label: {
434 Text("Connect")
435 .padding(.horizontal, 16)
436 .padding(.vertical, 10)
437 .background(trimmedTCPAddress.isEmpty ? Color(.systemGray4) : Color.accentColor)
438 .foregroundStyle(.white)
439 .clipShape(RoundedRectangle(cornerRadius: 10))
440 }
441 .disabled(trimmedTCPAddress.isEmpty)
442 }
443 .padding(.leading, 58)
444
445 // Expert options — collapsed by default
446 DisclosureGroup("Expert settings") {
447 VStack(alignment: .leading, spacing: 6) {
448 Text("Warning: changing the proxy overrides Tor and exposes your IP. Only modify if you know exactly what you're doing.")
449 .font(.caption)
450 .foregroundStyle(.orange)
451 .padding(.vertical, 4)
452 }
453 VStack(spacing: 0) {
454 HStack {
455 Text("Proxy")
456 .foregroundStyle(.secondary)
457 Spacer()
458 TextField("127.0.0.1:9050", text: $appState.tcpSocksProxy)
459 .keyboardType(.asciiCapable)
460 .autocorrectionDisabled()
461 .textInputAutocapitalization(.never)
462 .multilineTextAlignment(.trailing)
463 .foregroundStyle(.secondary)
464 }
465 .padding(.vertical, 6)
466 Divider()
467 HStack {
468 Text("Port")
469 .foregroundStyle(.secondary)
470 Spacer()
471 TextField("25519", text: $appState.tcpPort)
472 .keyboardType(.numberPad)
473 .multilineTextAlignment(.trailing)
474 .frame(width: 70)
475 .foregroundStyle(.secondary)
476 }
477 .padding(.vertical, 6)
478 Divider()
479 HStack {
480 Text("My address")
481 .foregroundStyle(.secondary)
482 Spacer()
483 TextField("host:port", text: $appState.myTCPAddress)
484 .keyboardType(.asciiCapable)
485 .autocorrectionDisabled()
486 .textInputAutocapitalization(.never)
487 .multilineTextAlignment(.trailing)
488 .foregroundStyle(.secondary)
489 }
490 .padding(.vertical, 6)
491 }
492 }
493 .font(.caption)
494 .foregroundStyle(.tertiary)
495 .padding(.leading, 58)
496 }
497 .padding(.vertical, 6)
498 }
499
500 @ViewBuilder
501 private var backupSection: some View {
502 Section {
503 NavigationLink {
504 LinkedDevicesView()
505 } label: {
506 Label("Linked Devices", systemImage: "iphone.and.iphone")
507 }
508 Button {
509 showingBackup = true
510 } label: {
511 Label("Backup & Restore", systemImage: "externaldrive.badge.checkmark")
512 }
513 Button {
514 showingFullExport = true
515 } label: {
516 Label("Export Everything", systemImage: "tray.and.arrow.up.fill")
517 }
518 Button {
519 showingFullImport = true
520 } label: {
521 Label("Restore from Full Export", systemImage: "tray.and.arrow.down.fill")
522 }
523 } header: {
524 Text("Data")
525 } footer: {
526 Text("Linked devices share your messages. Encrypted local backup — no cloud, no server. \"Export Everything\" saves identity + all messages in one encrypted .sxfe file for device migration.")
527 }
528 }
529
530 @ViewBuilder
531 private var helpSection: some View {
532 Section {
533 Link(destination: URL(string: "https://github.com/SophaxTechnologies/SophaxChat")!) {
534 HStack {
535 Label("Source Code", systemImage: "chevron.left.forwardslash.chevron.right")
536 .foregroundStyle(.primary)
537 Spacer()
538 Text("GitHub")
539 .foregroundStyle(.secondary)
540 Image(systemName: "arrow.up.right").font(.caption).foregroundStyle(.secondary)
541 }
542 }
543 HStack {
544 Text("Version")
545 Spacer()
546 Text(appVersion).foregroundStyle(.secondary)
547 }
548 HStack {
549 Text("Protocol")
550 Spacer()
551 Text("X3DH + Double Ratchet")
552 .foregroundStyle(.secondary)
553 .font(.caption)
554 }
555 HStack {
556 Text("Transport")
557 Spacer()
558 Text(appState.tcpEnabled ? "BLE / WiFi + TCP" : "Bluetooth LE / WiFi Direct")
559 .foregroundStyle(.secondary)
560 .font(.caption)
561 }
562 Button {
563 showingCrashLog = true
564 } label: {
565 Label("Diagnostic Log", systemImage: "doc.text.magnifyingglass")
566 .foregroundStyle(.primary)
567 }
568 } header: {
569 Text("Help & About")
570 }
571 }
572
573 @ViewBuilder
574 private var remoteWipeSection: some View {
575 Section {
576 ForEach(appState.trustedWipePeers, id: \.id) { peer in
577 Label(peer.username, systemImage: "person.badge.shield.checkmark.fill")
578 .swipeActions {
579 Button("Remove", role: .destructive) {
580 appState.removeTrustedWipePeer(peer.id)
581 }
582 }
583 }
584 Button {
585 showAddTrustedWipePeer = true
586 } label: {
587 Label("Add trusted peer…", systemImage: "plus.circle")
588 }
589 } header: {
590 Text("Remote Wipe")
591 } footer: {
592 Text("Peers listed here can trigger a remote wipe of your account. Only add people you fully trust.")
593 }
594 .sheet(isPresented: $showAddTrustedWipePeer) {
595 // Peer picker: list knownPeers not already trusted
596 NavigationStack {
597 List {
598 ForEach(appState.knownPeers.filter { p in
599 !appState.trustedWipePeers.contains(where: { $0.id == p.id })
600 }) { peer in
601 Button(peer.username) {
602 appState.addTrustedWipePeer(peer)
603 showAddTrustedWipePeer = false
604 }
605 }
606 }
607 .navigationTitle("Add Trusted Peer")
608 .toolbar {
609 ToolbarItem(placement: .cancellationAction) {
610 Button("Cancel") { showAddTrustedWipePeer = false }
611 }
612 }
613 }
614 }
615 }
616
617 @ViewBuilder
618 private var dangerSection: some View {
619 Section {
620 Button(role: .destructive) {
621 showingWipeConfirm = true
622 } label: {
623 Label("Delete Account & All Data", systemImage: "trash.fill")
624 }
625 } header: {
626 Text("Danger Zone")
627 } footer: {
628 Text("Permanently deletes your identity keys, all messages, and attachments. This cannot be undone.")
629 }
630 }
631
632 private var appVersion: String {
633 Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—"
634 }
635}
636
637#Preview {
638 SettingsView().environmentObject(AppState())
639}