1// SophaxChatApp.swift
2// SophaxChat
3//
4// App entry point.
5
6import SwiftUI
7import BackgroundTasks
8import SophaxChatCore
9import UIKit
10
11extension Notification.Name {
12 static let sophaxShowSettings = Notification.Name("com.sophax.showSettings")
13 static let sophaxOpenChat = Notification.Name("com.sophax.openChat")
14}
15
16@main
17struct SophaxChatApp: App {
18
19 @StateObject private var appState = AppState()
20
21 // MARK: - Background task identifier
22
23 private static let meshRefreshID = "com.sophax.mesh-refresh"
24
25 @Environment(\.scenePhase) private var scenePhase
26
27 var body: some Scene {
28 WindowGroup {
29 RootView()
30 .environmentObject(appState)
31 .onOpenURL { url in
32 appState.handleIncomingLink(url)
33 }
34 .onAppear {
35 // Allow free window resizing on macOS (Designed for iPad).
36 // Deferred one run-loop tick so the UIWindowScene is fully
37 // initialised before we touch sizeRestrictions.
38 DispatchQueue.main.async {
39 if let windowScene = UIApplication.shared.connectedScenes
40 .first as? UIWindowScene {
41 windowScene.sizeRestrictions?.minimumSize = CGSize(width: 380, height: 600)
42 windowScene.sizeRestrictions?.maximumSize = CGSize(width: 9999, height: 9999)
43 }
44 }
45 }
46 // Prevent the app from appearing in the app switcher screenshot
47 // (reduces the risk of sensitive content being captured by iOS)
48 .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in
49 appState.isBlurred = true
50 appState.lockApp()
51 scheduleBackgroundMeshRefresh()
52 // Clear clipboard on background to prevent sensitive message content leaking
53 UIPasteboard.general.items = []
54 }
55 .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
56 appState.isBlurred = false
57 // AppLockView.onAppear handles unlock attempt automatically
58 // Re-establish TCP connections that dropped while backgrounded
59 appState.reconnectTCPPeers()
60 }
61 .onReceive(NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification)) { _ in
62 appState.handleScreenshot()
63 }
64 }
65 .commands {
66 // ⌘, opens Settings — standard Mac convention
67 CommandGroup(replacing: .appSettings) {
68 Button("Settings…") {
69 NotificationCenter.default.post(name: .sophaxShowSettings, object: nil)
70 }
71 .keyboardShortcut(",", modifiers: .command)
72 }
73 }
74 // Background processing task — re-wakes the mesh briefly after iOS suspends the app.
75 // The bluetooth-central/peripheral background modes in Info.plist allow MPC to stay
76 // alive for several minutes after backgrounding; this BGTask extends coverage when
77 // iOS has fully suspended the process.
78 .backgroundTask(.appRefresh(Self.meshRefreshID)) {
79 await appState.handleBackgroundMeshRefresh()
80 }
81 }
82
83 // MARK: - Background scheduling
84
85 private func scheduleBackgroundMeshRefresh() {
86 let request = BGAppRefreshTaskRequest(identifier: Self.meshRefreshID)
87 // iOS will call this after ~15 minutes at the earliest; actual timing is system-driven.
88 request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
89 try? BGTaskScheduler.shared.submit(request)
90 }
91}
92
93// MARK: - Screenshot Prevention
94
95/// Wraps content in a UITextField(isSecureTextEntry: true) layer. iOS excludes the secure
96/// text field's CALayer subtree from screenshots and screen recordings, making the content
97/// appear as a blank frame in any screen capture.
98///
99/// NOTE: This relies on an undocumented CALayer flag inside UITextField. It works on current
100/// iOS versions but could theoretically change in a future OS release. If content ever appears
101/// unexpectedly blank, toggle "Block Screenshots" off in Settings > Security.
102#if !targetEnvironment(macCatalyst)
103private struct SecureContainerView<Content: View>: UIViewRepresentable {
104 let content: Content
105
106 func makeCoordinator() -> Coordinator { Coordinator() }
107
108 func makeUIView(context: Context) -> UITextField {
109 let field = UITextField()
110 field.isSecureTextEntry = true
111 field.backgroundColor = .clear
112
113 let host = UIHostingController(rootView: content)
114 host.view.backgroundColor = .clear
115 host.view.translatesAutoresizingMaskIntoConstraints = false
116 field.addSubview(host.view)
117 NSLayoutConstraint.activate([
118 host.view.topAnchor.constraint(equalTo: field.topAnchor),
119 host.view.bottomAnchor.constraint(equalTo: field.bottomAnchor),
120 host.view.leadingAnchor.constraint(equalTo: field.leadingAnchor),
121 host.view.trailingAnchor.constraint(equalTo: field.trailingAnchor),
122 ])
123 context.coordinator.host = host
124 return field
125 }
126
127 func updateUIView(_ uiView: UITextField, context: Context) {
128 context.coordinator.host?.rootView = content
129 }
130
131 class Coordinator {
132 var host: UIHostingController<Content>?
133 }
134}
135#endif
136
137// MARK: - Root View
138
139struct RootView: View {
140 @EnvironmentObject var appState: AppState
141
142 var body: some View {
143 #if !targetEnvironment(macCatalyst)
144 if appState.screenshotPreventionEnabled {
145 SecureContainerView(content: rootZStack.environmentObject(appState))
146 .ignoresSafeArea()
147 } else {
148 rootZStack
149 }
150 #else
151 rootZStack
152 #endif
153 }
154
155 @ViewBuilder private var rootZStack: some View {
156 ZStack {
157 if appState.isSetupComplete {
158 ChatListView()
159 } else {
160 OnboardingView()
161 }
162
163 // Security overlay: blurs content when app goes to background
164 // Prevents sensitive content appearing in the app switcher
165 if appState.isBlurred {
166 Rectangle()
167 .fill(.ultraThinMaterial)
168 .ignoresSafeArea()
169 .transition(.opacity)
170 }
171
172 if appState.isAppLocked {
173 AppLockView()
174 .ignoresSafeArea()
175 .transition(.opacity)
176 }
177
178 // Duress overlay: replaces real content with an empty decoy state.
179 // Shown when the user entered the duress PIN on the lock screen.
180 if appState.isDuressActive {
181 DuressEmptyView()
182 .ignoresSafeArea()
183 .transition(.opacity)
184 }
185
186 if appState.isScreenBeingRecorded {
187 VStack {
188 HStack(spacing: 6) {
189 Image(systemName: "record.circle.fill")
190 .foregroundStyle(.red)
191 .symbolEffect(.pulse)
192 Text("Screen recording active")
193 .font(.caption.weight(.medium))
194 Spacer()
195 }
196 .padding(.horizontal, 14)
197 .padding(.vertical, 8)
198 .background(.red.opacity(0.12))
199 .overlay(Rectangle().frame(height: 1).foregroundStyle(.red.opacity(0.25)), alignment: .bottom)
200 Spacer()
201 }
202 .ignoresSafeArea(edges: .top)
203 .transition(.move(edge: .top).combined(with: .opacity))
204 }
205
206 if appState.didTakeScreenshot {
207 VStack {
208 Spacer()
209 HStack(spacing: 8) {
210 Image(systemName: "camera.fill")
211 .foregroundStyle(.orange)
212 Text("Screenshot taken — messages may be exposed")
213 .font(.caption)
214 .foregroundStyle(.primary)
215 }
216 .padding(.horizontal, 16)
217 .padding(.vertical, 10)
218 .background(.regularMaterial)
219 .clipShape(RoundedRectangle(cornerRadius: 12))
220 .shadow(color: .black.opacity(0.12), radius: 8, y: 4)
221 .padding(.horizontal, 20)
222 .padding(.bottom, 32)
223 }
224 .transition(.move(edge: .bottom).combined(with: .opacity))
225 }
226
227 // Contact card received toast — must be inside ZStack to overlay content
228 if let addr = appState.lastAddedContactAddress {
229 VStack {
230 Spacer()
231 HStack(spacing: 8) {
232 Image(systemName: "person.badge.plus")
233 .foregroundStyle(.green)
234 Text("Contact added — \(addr.prefix(16))…")
235 .font(.caption)
236 .foregroundStyle(.primary)
237 }
238 .padding(.horizontal, 16)
239 .padding(.vertical, 10)
240 .background(.regularMaterial)
241 .clipShape(RoundedRectangle(cornerRadius: 12))
242 .shadow(color: .black.opacity(0.12), radius: 8, y: 4)
243 .padding(.horizontal, 20)
244 .padding(.bottom, 32)
245 }
246 .transition(.move(edge: .bottom).combined(with: .opacity))
247 }
248 }
249 .animation(.easeInOut(duration: 0.15), value: appState.isBlurred)
250 .animation(.easeInOut(duration: 0.2), value: appState.isAppLocked)
251 .animation(.easeInOut(duration: 0.3), value: appState.isScreenBeingRecorded)
252 .animation(.spring(duration: 0.4), value: appState.didTakeScreenshot)
253 .animation(.spring(duration: 0.4), value: appState.lastAddedContactAddress)
254 .alert(
255 "Add contact?",
256 isPresented: Binding(
257 get: { appState.pendingDeepLink != nil },
258 set: { if !$0 { appState.pendingDeepLink = nil } }
259 ),
260 presenting: appState.pendingDeepLink
261 ) { pending in
262 Button("Add & Connect") { appState.confirmDeepLink() }
263 Button("Cancel", role: .cancel) { appState.pendingDeepLink = nil }
264 } message: { pending in
265 Text("Connect to \(pending.onionHost)?")
266 }
267 }
268}