Files
evgeniyChepelev 0327624b30 Multi-profiles (#80)
* fix layout on ipad

* Create multi profiles

* multi-profile server config, connection cache, and auth fixes

 Split ProfilesView into ProfilesListView, AddProfileSheet, ProfileBadge, AddProfileViewModel
- Add server URL + setup key fields to Add Profile screen
- Store per-profile connection data (ip/fqdn/managementURL) as typed model in ProfileConnectionCache
- Show cached connection info immediately on profile switch; empty if no prior data
- Fix profile deletion persistence via tombstone in profiles.json
- Fix logout to remove both netbird.cfg and state.json (cfg holds auth tokens)
- Preserve managementURL in UI after logout via cache fallback
- Guard polling from overwriting new profile's data during disconnect/reconnect cycle

* fix(multi-profile): reinitialize VPN adapter on profile switch and show current server URL

* Update project.pbxproj

* fix(multi-profile): prevent default server overwrite on logout/re-login

* Update PacketTunnelProvider.swift

* fix(multi-profile): address code review findings

- Normalize only scheme and host to lowercase when saving management
  server URL; previously the full URL was lowercased which could corrupt
  case-sensitive paths on self-hosted servers
- Move switchConnectionInfo(to:) inside the do-block so the connection
  UI is only updated after a successful profile switch
- Add ProfileConnectionCache.remove(for:) and clearConnectionData(for:)
  to prevent stale ip/fqdn/managementURL from persisting after a profile
  is deleted or logged out; call them from removeProfile and logoutProfile
- Use ASWebAuthenticationSession.Callback.customScheme on iOS 17.4+,
  falling back to the deprecated callbackURLScheme initializer on older
  versions; add a comment explaining why "http" is used and that a
  proper fix requires custom-scheme support in the SDK
- Guard presentationAnchor against a missing key window with
  assertionFailure in debug builds instead of silently returning an
  empty UIWindow
2026-04-22 10:06:51 +02:00

260 lines
9.9 KiB
Swift

//
// MainView.swift
// NetBirdiOS
//
// Created by Pascal Fischer on 01.08.23.
//
import SwiftUI
import Lottie
import NetworkExtension
// MARK: - Main Entry Point
/// The root view that switches between iOS and tvOS layouts.
struct MainView: View {
@EnvironmentObject var viewModel: ViewModel
var body: some View {
#if os(tvOS)
// tvOS uses a completely different navigation structure
TVMainView()
#else
// iOS uses tab bar navigation
iOSMainView()
#endif
}
}
#if os(iOS)
enum MainAlertType: String, Identifiable {
case changeServer
case serverChanged
case preSharedKeyChanged
case authenticationRequired
case onDemandConflict
case onDemandDisconnect
var id: String { rawValue }
}
struct iOSMainView: View {
@EnvironmentObject var viewModel: ViewModel
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@State private var selectedTab = 0
@State private var activeAlert: MainAlertType?
init() {
let navAppearance = UINavigationBarAppearance()
navAppearance.configureWithOpaqueBackground()
navAppearance.backgroundColor = UIColor(named: "BgNavigationBar")
navAppearance.titleTextAttributes = [.foregroundColor: UIColor(named: "TextPrimary") ?? .white]
navAppearance.largeTitleTextAttributes = [.foregroundColor: UIColor(named: "TextPrimary") ?? .white]
UINavigationBar.appearance().standardAppearance = navAppearance
UINavigationBar.appearance().compactAppearance = navAppearance
UINavigationBar.appearance().scrollEdgeAppearance = navAppearance
let tabAppearance = UITabBarAppearance()
tabAppearance.configureWithOpaqueBackground()
tabAppearance.backgroundColor = UIColor(named: "BgNavigationBar")
UITabBar.appearance().standardAppearance = tabAppearance
UITabBar.appearance().scrollEdgeAppearance = tabAppearance
}
var body: some View {
ZStack {
if !hasCompletedOnboarding {
FirstLaunchView(
hasCompletedOnboarding: $hasCompletedOnboarding,
onChangeServer: {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
activeAlert = .changeServer
}
}
)
} else {
mainContent
}
}
.alert(item: $activeAlert) { alertType in
switch alertType {
case .changeServer:
return Alert(
title: Text("Change server"),
message: Text("Changing server will erase the local config and disconnect this device from the current NetBird account."),
primaryButton: .destructive(Text("Confirm")) {
viewModel.handleServerChanged()
viewModel.navigateToServerView = true
},
secondaryButton: .cancel()
)
case .serverChanged:
return Alert(
title: Text("Server was changed"),
message: Text("Click on the connect button to continue."),
dismissButton: .default(Text("OK"))
)
case .preSharedKeyChanged:
return Alert(
title: Text("Preshared key was set"),
message: Text("Click on the connect button to continue."),
dismissButton: .default(Text("OK"))
)
case .authenticationRequired:
if viewModel.connectOnDemand {
return Alert(
title: Text("Authentication required"),
message: Text("The server requires a new authentication."),
primaryButton: .default(Text("Connect")) {
viewModel.connect()
},
secondaryButton: .cancel(Text("Later"))
)
}
return Alert(
title: Text("Authentication required"),
message: Text("The server requires a new authentication."),
primaryButton: .default(Text("Login")) {
viewModel.connect()
},
secondaryButton: .cancel(Text("Later"))
)
case .onDemandConflict:
return Alert(
title: Text("VPN On Demand Conflict"),
message: Text("Your current On Demand rules prevent connecting on this network. Would you like to disable VPN On Demand and connect?"),
primaryButton: .default(Text("Disable & Connect")) {
viewModel.connectWithOnDemandDisabled()
},
secondaryButton: .cancel(Text("Edit Rules")) {
selectedTab = 3 // Switch to Settings tab
}
)
case .onDemandDisconnect:
return Alert(
title: Text("VPN On Demand Active"),
message: Text("VPN On Demand is enabled and will automatically reconnect the VPN based on your rules. Do you want to disable On Demand and disconnect?"),
primaryButton: .destructive(Text("Disable & Disconnect")) {
viewModel.closeWithOnDemandDisabled()
},
secondaryButton: .default(Text("Cancel")) {
}
)
}
}
}
private var mainContent: some View {
ZStack {
TabView(selection: $selectedTab) {
NavigationView {
iOSConnectionView()
}
.navigationViewStyle(StackNavigationViewStyle())
.tabItem {
Label("Connection", systemImage: "network")
}
.tag(0)
NavigationView {
iOSPeersView()
}
.navigationViewStyle(StackNavigationViewStyle())
.tabItem {
Label("Peers", systemImage: "person.3.fill")
}
.tag(1)
NavigationView {
iOSNetworksView()
}
.navigationViewStyle(StackNavigationViewStyle())
.tabItem {
Label("Resources", systemImage: "globe")
}
.tag(2)
NavigationView {
iOSSettingsView()
}
.navigationViewStyle(StackNavigationViewStyle())
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag(3)
}
.onChange(of: viewModel.navigateToServerView) { newValue in
if newValue {
selectedTab = 0
}
}
.onChange(of: viewModel.showChangeServerAlert) { show in
if show { activeAlert = .changeServer; viewModel.showChangeServerAlert = false }
}
.onChange(of: viewModel.showServerChangedInfo) { show in
if show { activeAlert = .serverChanged; viewModel.showServerChangedInfo = false }
}
.onChange(of: viewModel.showPreSharedKeyChangedInfo) { show in
if show { activeAlert = .preSharedKeyChanged; viewModel.showPreSharedKeyChangedInfo = false }
}
.onChange(of: viewModel.showAuthenticationRequired) { show in
if show { activeAlert = .authenticationRequired; viewModel.showAuthenticationRequired = false }
}
.onChange(of: viewModel.showOnDemandConflictAlert) { show in
if show { activeAlert = .onDemandConflict; viewModel.showOnDemandConflictAlert = false }
}
.onChange(of: viewModel.showOnDemandDisconnectAlert) { show in
if show { activeAlert = .onDemandDisconnect; viewModel.showOnDemandDisconnectAlert = false }
}
// Toast alerts
VStack {
Spacer()
if viewModel.showFqdnCopiedAlert {
HStack {
Image("logo-onboarding")
.resizable()
.frame(width: 20, height: 15)
Text("Domain name copied!")
.foregroundColor(.white)
.font(.headline)
}
.padding(5)
.background(Color.black.opacity(0.5))
.cornerRadius(8)
.transition(AnyTransition.opacity.combined(with: .move(edge: .top)))
.animation(.default, value: viewModel.showFqdnCopiedAlert)
.zIndex(1)
}
if viewModel.showIpCopiedAlert {
HStack {
Image("logo-onboarding")
.resizable()
.frame(width: 20, height: 15)
Text("IP address copied!")
.foregroundColor(.white)
.font(.headline)
}
.padding(5)
.background(Color.black.opacity(0.5))
.cornerRadius(8)
.transition(AnyTransition.opacity.combined(with: .move(edge: .top)))
.animation(.default, value: viewModel.showIpCopiedAlert)
.zIndex(1)
}
Spacer().frame(height: 80)
}
}
}
}
struct MainView_Previews: PreviewProvider {
static var previews: some View {
MainView()
}
}
#endif // os(iOS)