Files
evgeniyChepelev 4c95a6582a Implement VPN On Demand with Wi-Fi & cellular policies (#70)
* fix ui state for airplaine mode

* fix slide bar

* Keep VPN tunnel alive during network unavailability

- Add isNetworkUnavailable flag to NetBirdAdapter to track network state
- Modify ConnectionListener to stay in 'connecting' state when network
  is unavailable instead of transitioning to 'disconnected'
- Update PacketTunnelProvider to set network unavailable flag and
  trigger automatic reconnect when network returns
- Fix CustomLottieView to show grey 'Disconnected' state immediately
  when network is lost, without closing the VPN tunnel
- Ensure UI shows correct state after app foreground/background cycle

This allows the VPN tunnel to survive temporary network outages
(e.g. airplane mode) and automatically reconnect when network returns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix UI state after app foreground/background cycle

Show correct connected/disconnected state immediately when app returns
from background, without replaying animations. Use extensionStatus
(iOS VPN state) as the source of truth for UI state.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* netbird credential

* Update MainView.swift

* Separate button by state

* Add gogoleServiceInfo plist reference

* Remove dead code in animation state machine and fix copy-to-clipboard UX  - Remove unreachable shouldForceReset block in CustomLottieView (already handled by earlier return) - Guard against copying empty fqdn/ip strings when disconnected - Use consistent .smooth animation for both fqdn and ip copy feedback

- Remove unreachable shouldForceReset block in CustomLottieView (already handled by earlier return)
- Guard against copying empty fqdn/ip strings when disconnected
- Use consistent .smooth animation for both fqdn and ip copy feedback

* Tab bar

* Update peer view

* Add offline state handling and network warning banner

- Show "Offline" instead of "Connected" when VPN tunnel is active but device has no internet
- Add NetworkWarningBanner with "Network Issues" warning when connected without internet
- Remove InternetStatusView (online/offline indicator) from connection screen
- Add FirstLaunchView onboarding screen
- Remove unused components (CustomBackButton, Extensions, JustifiedText, SolidButton, TransparentGradientButton)
- Update AboutView, AdvancedView, ServerView, PeerTabView, RouteTabView, iOSNetworksView styling
- Add EmptyTabPlaceholder component

* Add AppButton component with liquid glass support for iOS 26+

* Update FirstLaunchView.swift

* old version

* Update project.pbxproj

* Code refactoring

* Update project.pbxproj

* VPN On Demand

* Update MainViewModel.swift

* Add granular VPN On Demand settings with per-interface rules and conflict detection

* Update project.pbxproj

* code refactoring

* Add Wi-Fi network management for VPN On Demand settings

* Fix ai comments

Prevent On Demand enable when user is not logged in
Add login status check in setOnDemandEnabled() to prevent reconnect
loops when VPN On Demand is enabled without an active session.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-20 15:20:30 +01:00

259 lines
9.6 KiB
Swift

//
// VPNOnDemandView.swift
// NetBird
//
// VPN On Demand settings screen (iOS only).
//
import SwiftUI
import NetworkExtension
#if os(iOS)
struct VPNOnDemandView: View {
@EnvironmentObject var viewModel: ViewModel
@State private var currentSSID: String?
@State private var showAddNetworkField = false
@State private var newNetworkName = ""
private let wifiOptions = WiFiOnDemandPolicy.allCases
private let cellularOptions = CellularOnDemandPolicy.allCases
var body: some View {
Form {
Section {
Text("Use VPN On Demand to automatically connect NetBird on this iPhone.")
.font(.footnote)
.foregroundColor(Color("TextSecondary"))
Toggle("VPN On Demand", isOn: $viewModel.connectOnDemand)
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.onChange(of: viewModel.connectOnDemand) { value in
viewModel.setConnectOnDemand(isEnabled: value)
}
}
if viewModel.connectOnDemand {
Section {
HStack {
Image(systemName: "wifi")
.foregroundColor(.accentColor)
Text("Wi-Fi")
Spacer()
Picker("", selection: $viewModel.onDemandWiFiPolicy) {
ForEach(WiFiOnDemandPolicy.allCases, id: \.self) { policy in
Text(policy.displayName).tag(policy)
}
}
.pickerStyle(.menu)
}
HStack {
Image(systemName: "antenna.radiowaves.left.and.right")
.foregroundColor(.accentColor)
Text("Cellular")
Spacer()
Picker("", selection: $viewModel.onDemandCellularPolicy) {
ForEach(CellularOnDemandPolicy.allCases, id: \.self) { policy in
Text(policy.displayName).tag(policy)
}
}
.pickerStyle(.menu)
}
} header: {
Text("Connect Automatically On")
} footer: {
Text(connectionDescription)
}
.onChange(of: viewModel.onDemandWiFiPolicy) { _ in
viewModel.saveOnDemandSettings()
}
.onChange(of: viewModel.onDemandCellularPolicy) { _ in
viewModel.saveOnDemandSettings()
}
if viewModel.onDemandWiFiPolicy == .onlyOn {
networkListSection(
header: "Connect Only On These Wi-Fi Networks"
)
}
if viewModel.onDemandWiFiPolicy == .exceptOn {
networkListSection(
header: "Except On These Wi-Fi Networks"
)
}
}
}
.navigationTitle("VPN On Demand")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
fetchCurrentSSID()
}
}
/// All non-current SSIDs to display: known + manually added, deduplicated.
private var otherNetworks: [String] {
var result = viewModel.knownSSIDs.filter { $0 != currentSSID }
for ssid in viewModel.onDemandWiFiNetworks where ssid != currentSSID && !result.contains(ssid) {
result.append(ssid)
}
return result
}
private func networkListSection(header: String) -> some View {
Section(header: Text(header)) {
// Current connected network
if let ssid = currentSSID {
Button {
toggleNetwork(ssid)
} label: {
HStack {
Image(systemName: viewModel.onDemandWiFiNetworks.contains(ssid) ? "checkmark.circle.fill" : "circle")
.foregroundColor(viewModel.onDemandWiFiNetworks.contains(ssid) ? .accentColor : Color("TextSecondary"))
Image(systemName: "wifi")
.foregroundColor(.accentColor)
Text(ssid)
.foregroundColor(Color("TextPrimary"))
Spacer()
Text("Connected")
.font(.caption)
.foregroundColor(Color("TextSecondary"))
}
}
}
// Other networks
ForEach(otherNetworks, id: \.self) { ssid in
networkRow(ssid: ssid)
}
// Add network manually
if showAddNetworkField {
HStack {
TextField("Network name", text: $newNetworkName)
.disableAutocorrection(true)
.autocapitalization(.none)
Button("Add") {
let trimmed = newNetworkName.trimmingCharacters(in: .whitespacesAndNewlines)
viewModel.recordKnownSSID(trimmed)
viewModel.addOnDemandWiFiNetwork(trimmed)
newNetworkName = ""
showAddNetworkField = false
}
.disabled(newNetworkName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| viewModel.onDemandWiFiNetworks.contains(newNetworkName.trimmingCharacters(in: .whitespacesAndNewlines)))
}
} else {
Button {
showAddNetworkField = true
} label: {
HStack {
Image(systemName: "plus.circle.fill")
.foregroundColor(.accentColor)
Text("Add other network")
.foregroundColor(Color("TextPrimary"))
}
}
}
}
}
private func networkRow(ssid: String) -> some View {
HStack {
Button {
toggleNetwork(ssid)
} label: {
HStack {
Image(systemName: viewModel.onDemandWiFiNetworks.contains(ssid) ? "checkmark.circle.fill" : "circle")
.foregroundColor(viewModel.onDemandWiFiNetworks.contains(ssid) ? .accentColor : Color("TextSecondary"))
Image(systemName: "wifi")
.foregroundColor(Color("TextSecondary"))
Text(ssid)
.foregroundColor(Color("TextPrimary"))
}
}
.buttonStyle(.borderless)
Spacer()
Button {
removeNetworkEntirely(ssid)
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(Color("TextSecondary"))
}
.buttonStyle(.borderless)
}
}
private func toggleNetwork(_ ssid: String) {
if let idx = viewModel.onDemandWiFiNetworks.firstIndex(of: ssid) {
viewModel.removeOnDemandWiFiNetwork(at: IndexSet(integer: idx))
} else {
viewModel.addOnDemandWiFiNetwork(ssid)
}
}
private func removeNetworkEntirely(_ ssid: String) {
if let idx = viewModel.onDemandWiFiNetworks.firstIndex(of: ssid) {
viewModel.removeOnDemandWiFiNetwork(at: IndexSet(integer: idx))
}
viewModel.removeKnownSSID(ssid)
}
private var connectionDescription: String {
var parts: [String] = []
switch viewModel.onDemandWiFiPolicy {
case .always:
parts.append("NetBird will connect whenever this iPhone joins any Wi-Fi network")
case .onlyOn:
if viewModel.onDemandWiFiNetworks.isEmpty {
parts.append("NetBird will not connect on Wi-Fi until you add networks to the list")
} else {
parts.append("NetBird will connect when this iPhone joins any of the Wi-Fi networks specified below")
}
case .exceptOn:
parts.append("NetBird will connect on Wi-Fi, except on the networks listed below")
case .never:
parts.append("NetBird will disconnect when this iPhone uses Wi-Fi")
case .doNothing:
break
}
switch viewModel.onDemandCellularPolicy {
case .always:
if parts.isEmpty {
parts.append("NetBird will connect whenever this iPhone uses cellular data")
} else {
parts.append("It will also connect whenever this iPhone uses cellular data")
}
case .never:
if parts.isEmpty {
parts.append("NetBird will disconnect when this iPhone uses cellular data")
} else {
parts.append("It will disconnect when using cellular data")
}
case .doNothing:
break
}
if parts.isEmpty {
return "NetBird won't automatically connect or disconnect."
}
return parts.joined(separator: ". ") + "."
}
private func fetchCurrentSSID() {
NEHotspotNetwork.fetchCurrent { network in
DispatchQueue.main.async {
self.currentSSID = network?.ssid
if let ssid = network?.ssid {
viewModel.recordKnownSSID(ssid)
}
}
}
}
}
#endif