feat(ios): initial SilverApple Swift project scaffold
- App entry point, AppEnvironment dependency container - PassportAuthService: PKCE OAuth 2.0 against SilverSHELL Passport - TokenStore: Keychain-backed token storage - SilverAPIClient: MDM enrollment + privacy score API calls - MDMEnrollmentService: enrollment URL fetching + device listing - Onboarding coordinator with MDM, account setup, and hardening steps - Dashboard with privacy score ring and category breakdown - WelcomeView with Passport sign-in flow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
43
SilverApple/App/AppEnvironment.swift
Normal file
43
SilverApple/App/AppEnvironment.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
38
SilverApple/App/SilverAppleApp.swift
Normal file
38
SilverApple/App/SilverAppleApp.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
119
SilverApple/Auth/PassportAuthService.swift
Normal file
119
SilverApple/Auth/PassportAuthService.swift
Normal file
@@ -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 }
|
||||||
50
SilverApple/Auth/TokenStore.swift
Normal file
50
SilverApple/Auth/TokenStore.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
130
SilverApple/Dashboard/Views/DashboardView.swift
Normal file
130
SilverApple/Dashboard/Views/DashboardView.swift
Normal file
@@ -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),
|
||||||
|
]
|
||||||
|
}
|
||||||
38
SilverApple/MDM/MDMEnrollmentService.swift
Normal file
38
SilverApple/MDM/MDMEnrollmentService.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
84
SilverApple/Networking/SilverAPIClient.swift
Normal file
84
SilverApple/Networking/SilverAPIClient.swift
Normal file
@@ -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<T: Decodable>(_ path: String) async throws -> T {
|
||||||
|
let req = try buildRequest(path, method: "GET", body: nil as EmptyBody?)
|
||||||
|
return try await perform(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func post<B: Encodable, T: Decodable>(_ path: String, body: B) async throws -> T {
|
||||||
|
let req = try buildRequest(path, method: "POST", body: body)
|
||||||
|
return try await perform(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildRequest<B: Encodable>(_ 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<T: Decodable>(_ 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) }
|
||||||
115
SilverApple/Onboarding/Views/MDMEnrollmentView.swift
Normal file
115
SilverApple/Onboarding/Views/MDMEnrollmentView.swift
Normal file
@@ -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) {}
|
||||||
|
}
|
||||||
105
SilverApple/Onboarding/Views/OnboardingCoordinatorView.swift
Normal file
105
SilverApple/Onboarding/Views/OnboardingCoordinatorView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
SilverApple/Onboarding/Views/WelcomeView.swift
Normal file
67
SilverApple/Onboarding/Views/WelcomeView.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user