Private
Public Access
1
0

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:
2026-04-04 03:28:31 +01:00
parent b43261cff4
commit 531a534c44
10 changed files with 789 additions and 0 deletions

View 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
}
}

View 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()
}
}
}

View 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 }

View 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)
}
}

View 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),
]
}

View 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)
}

View 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) }

View 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) {}
}

View 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)
}
}
}

View 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
}
}