diff --git a/SilverApple/App/AppEnvironment.swift b/SilverApple/App/AppEnvironment.swift new file mode 100644 index 0000000..5571929 --- /dev/null +++ b/SilverApple/App/AppEnvironment.swift @@ -0,0 +1,43 @@ +import Foundation +import Combine + +/// Root dependency container for SilverApple. +@MainActor +final class AppEnvironment: ObservableObject { + @Published var onboardingComplete = false + @Published var needsServerConfig = false + + let tokenStore: TokenStore + let authService: PassportAuthService + let apiClient: SilverAPIClient + let mdmService: MDMEnrollmentService + + private var serverUrl: String { + UserDefaults.standard.string(forKey: "silverapple.serverUrl") ?? "" + } + + init() { + let store = TokenStore() + let url = UserDefaults.standard.string(forKey: "silverapple.serverUrl") ?? "" + self.tokenStore = store + self.authService = PassportAuthService(serverUrl: url, tokenStore: store, clientId: "silverapple-ios") + self.apiClient = SilverAPIClient(baseUrl: url, tokenStore: store) + self.mdmService = MDMEnrollmentService(apiClient: apiClient) + self.needsServerConfig = url.isEmpty + } + + func configure(serverUrl: String) { + UserDefaults.standard.set(serverUrl, forKey: "silverapple.serverUrl") + needsServerConfig = false + } + + func loadOnboardingState() async { + guard authService.isAuthenticated else { return } + onboardingComplete = UserDefaults.standard.bool(forKey: "silverapple.onboardingComplete") + } + + func markOnboardingComplete() { + UserDefaults.standard.set(true, forKey: "silverapple.onboardingComplete") + onboardingComplete = true + } +} diff --git a/SilverApple/App/SilverAppleApp.swift b/SilverApple/App/SilverAppleApp.swift new file mode 100644 index 0000000..92b3f77 --- /dev/null +++ b/SilverApple/App/SilverAppleApp.swift @@ -0,0 +1,38 @@ +import SwiftUI + +@main +struct SilverAppleApp: App { + @StateObject private var env = AppEnvironment() + + var body: some Scene { + WindowGroup { + RootView() + .environmentObject(env) + .onOpenURL { url in + env.authService.handleCallback(url: url) + } + } + } +} + +struct RootView: View { + @EnvironmentObject var env: AppEnvironment + + var body: some View { + Group { + if env.needsServerConfig { + ServerConfigView() + } else if !env.authService.isAuthenticated { + WelcomeView() + } else if !env.onboardingComplete { + OnboardingCoordinatorView() + } else { + DashboardView() + } + } + .task { + await env.authService.restoreSession() + await env.loadOnboardingState() + } + } +} diff --git a/SilverApple/Auth/PassportAuthService.swift b/SilverApple/Auth/PassportAuthService.swift new file mode 100644 index 0000000..11cf8c7 --- /dev/null +++ b/SilverApple/Auth/PassportAuthService.swift @@ -0,0 +1,119 @@ +import AuthenticationServices +import CryptoKit +import Foundation + +/// PKCE OAuth 2.0 auth against SilverSHELL Passport for SilverApple. +/// Identical pattern to SilverVPN.Client.iOS/Auth/PassportAuthService.swift. +/// Scopes: openid profile email caldav carddav mdm silvervpn offline_access +@MainActor +final class PassportAuthService: NSObject, ObservableObject, ASWebAuthenticationPresentationContextProviding { + + @Published private(set) var isAuthenticated = false + + private let serverUrl: String + private let clientId: String + private let tokenStore: TokenStore + private var pendingVerifier: String? + + init(serverUrl: String, tokenStore: TokenStore, clientId: String) { + self.serverUrl = serverUrl + self.clientId = clientId + self.tokenStore = tokenStore + } + + func signIn() async throws { + let verifier = generateCodeVerifier() + let challenge = generateCodeChallenge(from: verifier) + pendingVerifier = verifier + + let authUrl = buildAuthURL(challenge: challenge) + + let callbackUrl = try await withCheckedThrowingContinuation { continuation in + let session = ASWebAuthenticationSession(url: authUrl, callbackURLScheme: "silverapple") { url, error in + if let error { continuation.resume(throwing: error); return } + guard let url else { continuation.resume(throwing: AuthError.noCallback); return } + continuation.resume(returning: url) + } + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = true + session.start() + } + + try await exchangeCode(from: callbackUrl) + } + + func handleCallback(url: URL) { /* handled by ASWebAuthenticationSession */ } + + func signOut() { + tokenStore.clearAll() + isAuthenticated = false + } + + func restoreSession() async { + guard tokenStore.accessToken != nil else { return } + isAuthenticated = true + } + + // MARK: - Private + + private func exchangeCode(from callbackUrl: URL) async throws { + guard let code = URLComponents(url: callbackUrl, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "code" })?.value, + let verifier = pendingVerifier else { throw AuthError.noCode } + pendingVerifier = nil + + let tokenUrl = URL(string: "\(serverUrl)/oauth/token")! + var request = URLRequest(url: tokenUrl) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + let body = [ + "grant_type": "authorization_code", "code": code, + "client_id": clientId, + "redirect_uri": "silverapple://oauth/callback", + "code_verifier": verifier + ].map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" } + .joined(separator: "&") + request.httpBody = body.data(using: .utf8) + + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw AuthError.tokenExchangeFailed } + + let tokens = try JSONDecoder().decode(TokenResponse.self, from: data) + tokenStore.save(accessToken: tokens.access_token, refreshToken: tokens.refresh_token) + isAuthenticated = true + } + + private func generateCodeVerifier() -> String { + var b = [UInt8](repeating: 0, count: 32); _ = SecRandomCopyBytes(kSecRandomDefault, b.count, &b) + return Data(b).base64EncodedString() + .replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private func generateCodeChallenge(from v: String) -> String { + Data(SHA256.hash(data: Data(v.utf8))).base64EncodedString() + .replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private func buildAuthURL(challenge: String) -> URL { + var c = URLComponents(string: "\(serverUrl)/oauth/authorize")! + c.queryItems = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientId), + URLQueryItem(name: "redirect_uri", value: "silverapple://oauth/callback"), + URLQueryItem(name: "scope", value: "openid profile email caldav carddav mdm silvervpn offline_access"), + URLQueryItem(name: "code_challenge", value: challenge), + URLQueryItem(name: "code_challenge_method", value: "S256") + ] + return c.url! + } + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows }.first { $0.isKeyWindow } ?? ASPresentationAnchor() + } +} + +struct TokenResponse: Codable { let access_token: String; let refresh_token: String? } +enum AuthError: Error { case noCallback, noCode, tokenExchangeFailed } diff --git a/SilverApple/Auth/TokenStore.swift b/SilverApple/Auth/TokenStore.swift new file mode 100644 index 0000000..96a42cb --- /dev/null +++ b/SilverApple/Auth/TokenStore.swift @@ -0,0 +1,50 @@ +import Foundation +import Security + +/// Keychain-backed token store. Identical to SilverVPN.Client.iOS/Auth/TokenStore.swift. +/// TODO: Extract to SilverVPNKit shared package to avoid duplication. +final class TokenStore { + private enum Keys { + static let accessToken = "uk.silverlabs.silverapple.access_token" + static let refreshToken = "uk.silverlabs.silverapple.refresh_token" + } + + var accessToken: String? { load(key: Keys.accessToken) } + var refreshToken: String? { load(key: Keys.refreshToken) } + + func save(accessToken: String, refreshToken: String?) { + store(value: accessToken, key: Keys.accessToken) + if let r = refreshToken { store(value: r, key: Keys.refreshToken) } + } + + func clearAll() { delete(key: Keys.accessToken); delete(key: Keys.refreshToken) } + + private func store(value: String, key: String) { + delete(key: key) + let q: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: Data(value.utf8), + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + SecItemAdd(q as CFDictionary, nil) + } + + private func load(key: String) -> String? { + let q: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + guard SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private func delete(key: String) { + let q: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key] + SecItemDelete(q as CFDictionary) + } +} diff --git a/SilverApple/Dashboard/Views/DashboardView.swift b/SilverApple/Dashboard/Views/DashboardView.swift new file mode 100644 index 0000000..aa66086 --- /dev/null +++ b/SilverApple/Dashboard/Views/DashboardView.swift @@ -0,0 +1,130 @@ +import SwiftUI + +struct DashboardView: View { + @EnvironmentObject var env: AppEnvironment + @StateObject private var vm = DashboardViewModel() + + var body: some View { + TabView { + PrivacyScoreView() + .tabItem { Label("Privacy", systemImage: "shield.lefthalf.filled") } + + HardeningListView() + .tabItem { Label("Hardening", systemImage: "list.bullet.clipboard") } + + ServerConfigView() + .tabItem { Label("Settings", systemImage: "gearshape") } + } + .task { await vm.load(client: env.apiClient) } + } +} + +// MARK: - Privacy Score View + +struct PrivacyScoreView: View { + @StateObject private var vm = PrivacyScoreViewModel() + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 24) { + // Score ring + ZStack { + Circle() + .stroke(Color.secondary.opacity(0.15), lineWidth: 16) + .frame(width: 160, height: 160) + + Circle() + .trim(from: 0, to: vm.scorePercent) + .stroke(scoreColor, style: StrokeStyle(lineWidth: 16, lineCap: .round)) + .frame(width: 160, height: 160) + .rotationEffect(.degrees(-90)) + .animation(.easeOut(duration: 1.0), value: vm.scorePercent) + + VStack(spacing: 2) { + Text("\(Int(vm.scorePercent * 100))") + .font(.system(size: 44, weight: .bold, design: .rounded)) + Text("Privacy Score") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.top, 24) + + // Category breakdown + LazyVStack(spacing: 12) { + ForEach(vm.categories) { category in + CategoryScoreRow(category: category) + } + } + .padding(.horizontal) + } + } + .navigationTitle("SilverApple") + .refreshable { await vm.refresh() } + } + } + + private var scoreColor: Color { + switch vm.scorePercent { + case 0.8...: return .green + case 0.5...: return .orange + default: return .red + } + } +} + +struct CategoryScoreRow: View { + let category: PrivacyCategory + var body: some View { + HStack { + Label(category.name, systemImage: category.icon) + .font(.subheadline) + Spacer() + Text("\(category.completedSteps)/\(category.totalSteps)") + .font(.caption) + .foregroundStyle(.secondary) + Image(systemName: category.isComplete ? "checkmark.circle.fill" : "circle") + .foregroundStyle(category.isComplete ? .green : .secondary) + } + .padding(12) + .background(.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + } +} + +// MARK: - ViewModels (stub) + +@MainActor final class DashboardViewModel: ObservableObject { + func load(client: SilverAPIClient) async {} +} + +@MainActor final class PrivacyScoreViewModel: ObservableObject { + @Published var scorePercent: CGFloat = 0.0 + @Published var categories: [PrivacyCategory] = [] + + func refresh() async { + // TODO: call /api/apple/privacy/score + withAnimation { scorePercent = 0.72 } + categories = PrivacyCategory.defaults + } +} + +// MARK: - Models + +struct PrivacyCategory: Identifiable { + let id: String + let name: String + let icon: String + let completedSteps: Int + let totalSteps: Int + + var isComplete: Bool { completedSteps == totalSteps } + + static let defaults: [PrivacyCategory] = [ + .init(id: "location", name: "Location Services", icon: "location", completedSteps: 3, totalSteps: 5), + .init(id: "tracking", name: "Tracking & Analytics", icon: "eye.slash", completedSteps: 4, totalSteps: 4), + .init(id: "network", name: "Network & DNS", icon: "network", completedSteps: 2, totalSteps: 3), + .init(id: "icloud", name: "iCloud & Apple", icon: "icloud", completedSteps: 1, totalSteps: 6), + .init(id: "apps", name: "App Permissions", icon: "app.badge.checkmark", completedSteps: 5, totalSteps: 7), + ] +} diff --git a/SilverApple/MDM/MDMEnrollmentService.swift b/SilverApple/MDM/MDMEnrollmentService.swift new file mode 100644 index 0000000..f58c98b --- /dev/null +++ b/SilverApple/MDM/MDMEnrollmentService.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Handles the MDM enrollment flow — token generation and profile URL delivery. +/// The actual profile installation happens in Safari/SFSafariViewController (iOS requirement). +@MainActor +final class MDMEnrollmentService: ObservableObject { + @Published private(set) var enrolledDevices: [DeviceResponse] = [] + @Published private(set) var isLoading = false + + private let apiClient: SilverAPIClient + + init(apiClient: SilverAPIClient) { + self.apiClient = apiClient + } + + func getEnrollmentUrl() async throws -> URL { + let response = try await apiClient.getEnrollmentToken() + guard let url = URL(string: response.enrollmentUrl) else { + throw MDMError.invalidEnrollmentUrl + } + return url + } + + func refreshDevices() async { + isLoading = true + do { + enrolledDevices = try await apiClient.getMyDevices() + } catch { + print("Failed to load devices: \(error)") + } + isLoading = false + } +} + +enum MDMError: Error { + case invalidEnrollmentUrl + case enrollmentFailed(String) +} diff --git a/SilverApple/Networking/SilverAPIClient.swift b/SilverApple/Networking/SilverAPIClient.swift new file mode 100644 index 0000000..071352f --- /dev/null +++ b/SilverApple/Networking/SilverAPIClient.swift @@ -0,0 +1,84 @@ +import Foundation + +/// HTTP client for all SilverSHELL API calls from SilverApple. +final class SilverAPIClient { + private let baseUrl: String + private let tokenStore: TokenStore + private let session = URLSession.shared + + init(baseUrl: String, tokenStore: TokenStore) { + self.baseUrl = baseUrl + self.tokenStore = tokenStore + } + + // MARK: - MDM + + func getEnrollmentToken() async throws -> EnrollmentTokenResponse { + return try await get("/api/mdm/enrollment/token") + } + + func getMyDevices() async throws -> [DeviceResponse] { + return try await get("/api/mdm/devices") + } + + // MARK: - Privacy / Hardening + + func getPrivacyChecks() async throws -> [HardeningStep] { + return try await get("/api/apple/privacy/checks") + } + + func getPrivacyScore() async throws -> PrivacyScoreResponse { + return try await get("/api/apple/privacy/score") + } + + func reportPrivacyState(completedStepIds: [String]) async throws { + let body = PrivacyReportRequest(completedStepIds: completedStepIds) + let _: EmptyResponse = try await post("/api/apple/privacy/report", body: body) + } + + // MARK: - HTTP helpers + + private func get(_ path: String) async throws -> T { + let req = try buildRequest(path, method: "GET", body: nil as EmptyBody?) + return try await perform(req) + } + + private func post(_ path: String, body: B) async throws -> T { + let req = try buildRequest(path, method: "POST", body: body) + return try await perform(req) + } + + private func buildRequest(_ path: String, method: String, body: B?) throws -> URLRequest { + guard let url = URL(string: baseUrl + path) else { throw APIError.invalidURL } + var req = URLRequest(url: url) + req.httpMethod = method + if let t = tokenStore.accessToken { req.setValue("Bearer \(t)", forHTTPHeaderField: "Authorization") } + if let body { + req.httpBody = try JSONEncoder().encode(body) + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + return req + } + + private func perform(_ request: URLRequest) async throws -> T { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + throw APIError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0) + } + return try JSONDecoder().decode(T.self, from: data) + } +} + +// MARK: - Response Models + +struct EnrollmentTokenResponse: Decodable { let token: String; let enrollmentUrl: String; let expiresAt: String } +struct DeviceResponse: Decodable { let id: String; let deviceName: String?; let enrollState: String } +struct HardeningStep: Decodable, Identifiable { + let id: String; let title: String; let description: String + let category: String; let riskLevel: String; let settingsUrl: String? +} +struct PrivacyScoreResponse: Decodable { let score: Int; let maxScore: Int } +struct PrivacyReportRequest: Encodable { let completedStepIds: [String] } +struct EmptyResponse: Decodable {} +private struct EmptyBody: Encodable {} +enum APIError: Error { case invalidURL, httpError(Int) } diff --git a/SilverApple/Onboarding/Views/MDMEnrollmentView.swift b/SilverApple/Onboarding/Views/MDMEnrollmentView.swift new file mode 100644 index 0000000..dca987d --- /dev/null +++ b/SilverApple/Onboarding/Views/MDMEnrollmentView.swift @@ -0,0 +1,115 @@ +import SwiftUI +import SafariServices + +struct MDMEnrollmentView: View { + var onComplete: () -> Void + @EnvironmentObject var env: AppEnvironment + @State private var isLoading = false + @State private var showSafari = false + @State private var enrollUrl: URL? + @State private var error: String? + @State private var didEnroll = false + + var body: some View { + ScrollView { + VStack(spacing: 32) { + Image(systemName: "iphone.badge.play") + .font(.system(size: 56)) + .foregroundStyle(.accentColor) + + VStack(spacing: 12) { + Text("Device Management") + .font(.title2.weight(.semibold)) + + Text("SilverApple uses Apple MDM to automatically configure your device — installing VPN, DNS, and account profiles without manual setup.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Text("You remain in control: you can remove MDM enrollment at any time in Settings → General → VPN & Device Management.") + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .padding(.top, 4) + } + + VStack(alignment: .leading, spacing: 12) { + FeatureRow(icon: "lock.shield", text: "Privacy hardening profiles") + FeatureRow(icon: "network", text: "SilverVPN configuration") + FeatureRow(icon: "globe", text: "Encrypted DNS via Technitium") + FeatureRow(icon: "envelope", text: "Mailcow email account") + FeatureRow(icon: "calendar", text: "CalDAV calendar sync") + FeatureRow(icon: "person.crop.rectangle", text: "CardDAV contacts sync") + } + .padding() + .background(.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 12)) + + if let error { + Text(error).font(.caption).foregroundStyle(.red).multilineTextAlignment(.center) + } + + VStack(spacing: 12) { + Button { + Task { await startEnrollment() } + } label: { + Group { + if isLoading { ProgressView() } + else { Label("Enroll this Device", systemImage: "checkmark.shield.fill") } + } + .frame(maxWidth: .infinity).padding() + } + .buttonStyle(.borderedProminent) + .disabled(isLoading || didEnroll) + + if didEnroll { + Button("Continue →", action: onComplete) + .buttonStyle(.bordered) + } + + Button("Skip for now") { onComplete() } + .foregroundStyle(.secondary) + .font(.caption) + } + } + .padding(24) + } + .sheet(isPresented: $showSafari) { + if let url = enrollUrl { + SafariView(url: url) + .ignoresSafeArea() + .onDisappear { didEnroll = true } + } + } + } + + private func startEnrollment() async { + isLoading = true; error = nil + do { + let response = try await env.apiClient.getEnrollmentToken() + enrollUrl = URL(string: response.enrollmentUrl) + showSafari = true + } catch { + self.error = error.localizedDescription + } + isLoading = false + } +} + +struct FeatureRow: View { + let icon: String + let text: String + var body: some View { + Label(text, systemImage: icon) + .font(.subheadline) + .foregroundStyle(.primary) + } +} + +/// UIViewControllerRepresentable wrapper for SFSafariViewController +struct SafariView: UIViewControllerRepresentable { + let url: URL + func makeUIViewController(context: Context) -> SFSafariViewController { + SFSafariViewController(url: url) + } + func updateUIViewController(_ vc: SFSafariViewController, context: Context) {} +} diff --git a/SilverApple/Onboarding/Views/OnboardingCoordinatorView.swift b/SilverApple/Onboarding/Views/OnboardingCoordinatorView.swift new file mode 100644 index 0000000..ad824af --- /dev/null +++ b/SilverApple/Onboarding/Views/OnboardingCoordinatorView.swift @@ -0,0 +1,105 @@ +import SwiftUI + +/// State machine coordinator for the onboarding flow. +/// Steps: serverConfig → signIn → mdmEnroll → accounts → hardening → complete +enum OnboardingStep: Int, CaseIterable { + case mdmEnrollment = 0 + case accountSetup = 1 + case hardening = 2 + case complete = 3 + + var title: String { + switch self { + case .mdmEnrollment: return "Device Management" + case .accountSetup: return "Account Setup" + case .hardening: return "Privacy Hardening" + case .complete: return "All Done" + } + } + + var systemImage: String { + switch self { + case .mdmEnrollment: return "iphone.badge.play" + case .accountSetup: return "person.crop.circle.badge.plus" + case .hardening: return "shield.lefthalf.filled" + case .complete: return "checkmark.seal.fill" + } + } +} + +struct OnboardingCoordinatorView: View { + @EnvironmentObject var env: AppEnvironment + @State private var currentStep: OnboardingStep = .mdmEnrollment + + var body: some View { + NavigationStack { + VStack { + // Progress indicator + HStack(spacing: 8) { + ForEach(OnboardingStep.allCases.dropLast(), id: \.self) { step in + Capsule() + .fill(step.rawValue <= currentStep.rawValue ? Color.accentColor : Color.secondary.opacity(0.3)) + .frame(height: 4) + } + } + .padding(.horizontal) + .padding(.top) + + TabView(selection: $currentStep) { + MDMEnrollmentView(onComplete: { advance() }) + .tag(OnboardingStep.mdmEnrollment) + + AccountSetupView(onComplete: { advance() }) + .tag(OnboardingStep.accountSetup) + + PrivacyHardeningView(onComplete: { advance() }) + .tag(OnboardingStep.hardening) + + OnboardingCompleteView() + .tag(OnboardingStep.complete) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .animation(.easeInOut, value: currentStep) + } + .navigationTitle(currentStep.title) + .navigationBarTitleDisplayMode(.inline) + } + } + + private func advance() { + let next = currentStep.rawValue + 1 + if let step = OnboardingStep(rawValue: next) { + withAnimation { currentStep = step } + if step == .complete { env.markOnboardingComplete() } + } + } +} + +// MARK: - Placeholder views (implemented in separate files) + +struct AccountSetupView: View { + var onComplete: () -> Void + var body: some View { + VStack { Text("Account Setup — TODO").onTapGesture { onComplete() } } + } +} + +struct PrivacyHardeningView: View { + var onComplete: () -> Void + var body: some View { + VStack { Text("Privacy Hardening — TODO").onTapGesture { onComplete() } } + } +} + +struct OnboardingCompleteView: View { + var body: some View { + VStack(spacing: 24) { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 72)) + .foregroundStyle(.green) + Text("Your iPhone is now configured\nfor privacy with SilverLABS") + .multilineTextAlignment(.center) + .font(.headline) + } + } +} diff --git a/SilverApple/Onboarding/Views/WelcomeView.swift b/SilverApple/Onboarding/Views/WelcomeView.swift new file mode 100644 index 0000000..3b12cf8 --- /dev/null +++ b/SilverApple/Onboarding/Views/WelcomeView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct WelcomeView: View { + @EnvironmentObject var env: AppEnvironment + @State private var isSigningIn = false + @State private var error: String? + + var body: some View { + VStack(spacing: 40) { + Spacer() + + VStack(spacing: 16) { + Image(systemName: "apple.logo") + .font(.system(size: 64)) + .foregroundStyle(.primary) + + Text("SilverApple") + .font(.largeTitle.weight(.bold)) + + Text("Privacy-first iPhone configuration\nfor the SilverLABS ecosystem") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + Spacer() + + VStack(spacing: 16) { + Button { + Task { await signIn() } + } label: { + Group { + if isSigningIn { + ProgressView() + } else { + Label("Sign in with Passport", systemImage: "person.badge.shield.checkmark") + } + } + .frame(maxWidth: .infinity) + .padding() + } + .buttonStyle(.borderedProminent) + .disabled(isSigningIn) + + if let error { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + } + } + .padding(.horizontal, 24) + .padding(.bottom, 48) + } + } + + private func signIn() async { + isSigningIn = true + error = nil + do { + try await env.authService.signIn() + } catch { + self.error = error.localizedDescription + } + isSigningIn = false + } +}