2026-03-02 15:23:51 +01:00
|
|
|
|
//
|
|
|
|
|
|
// iOSConnectionView.swift
|
|
|
|
|
|
// NetBird
|
|
|
|
|
|
//
|
|
|
|
|
|
// Connection tab: VPN button, FQDN/IP display, status indicator.
|
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
|
|
import SwiftUI
|
|
|
|
|
|
import Lottie
|
|
|
|
|
|
import NetworkExtension
|
|
|
|
|
|
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
|
|
|
|
|
|
struct iOSConnectionView: View {
|
|
|
|
|
|
@EnvironmentObject var viewModel: ViewModel
|
|
|
|
|
|
@State private var animationKey: UUID = UUID()
|
|
|
|
|
|
@State private var fqdnCopied = false
|
|
|
|
|
|
@State private var ipCopied = false
|
|
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
|
GeometryReader { geometry in
|
|
|
|
|
|
let isLandscape = geometry.size.width > geometry.size.height
|
|
|
|
|
|
let imageName = isLandscape ? "bg-bottom-landscape" : "bg-bottom"
|
|
|
|
|
|
|
|
|
|
|
|
ZStack {
|
|
|
|
|
|
|
|
|
|
|
|
if viewModel.statusDetailsValid {
|
|
|
|
|
|
// Background layers
|
|
|
|
|
|
VStack {
|
|
|
|
|
|
Color("BgSecondary")
|
|
|
|
|
|
.frame(height: UIScreen.main.bounds.height * 4/5)
|
|
|
|
|
|
.ignoresSafeArea(.all)
|
|
|
|
|
|
Color("BgPrimary")
|
|
|
|
|
|
.frame(height: UIScreen.main.bounds.height * 1/5)
|
|
|
|
|
|
.ignoresSafeArea(.all)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
VStack {
|
|
|
|
|
|
Image(imageName)
|
|
|
|
|
|
.resizable(resizingMode: .stretch)
|
2026-03-23 18:09:54 +01:00
|
|
|
|
.aspectRatio(contentMode: DeviceType.isPad ? .fill : .fit)
|
2026-04-28 15:28:19 +02:00
|
|
|
|
// Button overlaid directly on the image so both share the same
|
|
|
|
|
|
// coordinate space — mirrors Android where btn_connect is a sibling
|
|
|
|
|
|
// of bg_mask inside bg_mask_container.
|
|
|
|
|
|
// vertical_bias=0.07: button top = 7% of (imageHeight - buttonHeight)
|
|
|
|
|
|
// Portrait image ratio h/w = 488/360 = 1.357
|
|
|
|
|
|
// offset = (Screen.width*1.357 - Screen.width*0.79) * 0.07 ≈ Screen.width * 0.04
|
|
|
|
|
|
.overlay(alignment: .top) {
|
|
|
|
|
|
let btnSize = Screen.width * (isLandscape ? 0.40 : 0.79)
|
|
|
|
|
|
let imgH = isLandscape
|
|
|
|
|
|
? Screen.height * 0.81 // landscape: height-constrained
|
|
|
|
|
|
: Screen.width * 1.357 // portrait: width-constrained
|
|
|
|
|
|
let biasOffset = max(0, (imgH - btnSize) * 0.07)
|
|
|
|
|
|
|
|
|
|
|
|
VStack(spacing: 0) {
|
|
|
|
|
|
Color.clear.frame(height: biasOffset)
|
|
|
|
|
|
|
|
|
|
|
|
Button(action: {
|
|
|
|
|
|
if !viewModel.buttonLock {
|
|
|
|
|
|
switch viewModel.vpnDisplayState {
|
|
|
|
|
|
case .disconnected:
|
|
|
|
|
|
viewModel.connect()
|
|
|
|
|
|
case .connecting, .connected:
|
|
|
|
|
|
viewModel.close()
|
|
|
|
|
|
case .disconnecting:
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}) {
|
|
|
|
|
|
CustomLottieView(vpnState: $viewModel.vpnDisplayState)
|
|
|
|
|
|
.id(animationKey)
|
|
|
|
|
|
.frame(width: btnSize, height: btnSize)
|
|
|
|
|
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
|
|
|
|
|
self.animationKey = UUID()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Text(viewModel.extensionStateText)
|
|
|
|
|
|
.foregroundColor(Color("TextSecondary"))
|
|
|
|
|
|
.font(.system(size: 24, weight: .regular))
|
|
|
|
|
|
.padding(.top, 24)
|
|
|
|
|
|
|
|
|
|
|
|
Spacer()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-02 15:23:51 +01:00
|
|
|
|
.padding(.top, Screen.height * (DeviceType.isPad ? (isLandscape ? -0.15 : 0.36) : 0.19))
|
|
|
|
|
|
.padding(.leading, UIScreen.main.bounds.height * (isLandscape ? 0.04 : 0))
|
|
|
|
|
|
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
|
|
|
|
|
|
.edgesIgnoringSafeArea(.bottom)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-28 15:28:19 +02:00
|
|
|
|
// FQDN + IP
|
2026-03-02 15:23:51 +01:00
|
|
|
|
VStack {
|
2026-04-22 10:06:51 +02:00
|
|
|
|
ProfileBadge(profileName: viewModel.activeProfileName) {
|
|
|
|
|
|
viewModel.navigateToProfilesView = true
|
|
|
|
|
|
}
|
|
|
|
|
|
.padding(.top, 8)
|
|
|
|
|
|
|
2026-03-02 15:23:51 +01:00
|
|
|
|
Text(fqdnCopied ? "Copied" : viewModel.fqdn)
|
|
|
|
|
|
.foregroundColor(Color("TextPrimary"))
|
|
|
|
|
|
.font(.system(size: 20, weight: .regular))
|
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
|
.minimumScaleFactor(0.5)
|
|
|
|
|
|
.opacity(fqdnCopied ? 0.7 : 1.0)
|
|
|
|
|
|
.animation(.easeInOut(duration: 0.2), value: fqdnCopied)
|
|
|
|
|
|
.padding(.horizontal, 16)
|
|
|
|
|
|
.padding(.top, Screen.height * (DeviceType.isPad ? 0.09 : 0.13))
|
|
|
|
|
|
.padding(.bottom, 5)
|
|
|
|
|
|
.onTapGesture {
|
|
|
|
|
|
guard !viewModel.fqdn.isEmpty else { return }
|
|
|
|
|
|
UIPasteboard.general.string = viewModel.fqdn
|
|
|
|
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
|
|
|
|
withAnimation(.smooth) {
|
|
|
|
|
|
fqdnCopied = true
|
|
|
|
|
|
}
|
|
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
|
|
|
|
|
withAnimation(.smooth) {
|
|
|
|
|
|
fqdnCopied = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Text(ipCopied ? "Copied" : viewModel.ip)
|
|
|
|
|
|
.foregroundColor(Color("TextPrimary"))
|
|
|
|
|
|
.font(.system(size: 20, weight: .regular))
|
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
|
.minimumScaleFactor(0.5)
|
|
|
|
|
|
.opacity(ipCopied ? 0.7 : 1.0)
|
|
|
|
|
|
.animation(.easeInOut(duration: 0.2), value: ipCopied)
|
|
|
|
|
|
.onTapGesture {
|
|
|
|
|
|
guard !viewModel.ip.isEmpty else { return }
|
|
|
|
|
|
UIPasteboard.general.string = viewModel.ip
|
|
|
|
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
|
|
|
|
withAnimation(.smooth) {
|
|
|
|
|
|
ipCopied = true
|
|
|
|
|
|
}
|
|
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
|
|
|
|
|
withAnimation(.smooth) {
|
|
|
|
|
|
ipCopied = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Spacer()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Network warning banner – above tab bar
|
|
|
|
|
|
if viewModel.vpnDisplayState == .connected && !viewModel.isInternetConnected {
|
|
|
|
|
|
VStack {
|
|
|
|
|
|
Spacer()
|
|
|
|
|
|
NetworkWarningBanner()
|
|
|
|
|
|
.padding(.bottom, geometry.safeAreaInsets.bottom + 80)
|
|
|
|
|
|
}
|
|
|
|
|
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
|
|
|
|
|
.animation(.easeInOut(duration: 0.3), value: viewModel.isInternetConnected)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 10:06:51 +02:00
|
|
|
|
// Hidden NavigationLink for ProfilesView
|
|
|
|
|
|
NavigationLink("", destination: ProfilesListView(), isActive: $viewModel.navigateToProfilesView)
|
|
|
|
|
|
.hidden()
|
|
|
|
|
|
|
2026-03-02 15:23:51 +01:00
|
|
|
|
// Hidden NavigationLink for ServerView
|
|
|
|
|
|
NavigationLink("", destination: ServerView(), isActive: $viewModel.navigateToServerView)
|
|
|
|
|
|
.hidden()
|
|
|
|
|
|
.onChange(of: viewModel.navigateToServerView) { newValue in
|
|
|
|
|
|
if !newValue {
|
|
|
|
|
|
viewModel.startPollingDetails()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Loading placeholder
|
|
|
|
|
|
ZStack {
|
|
|
|
|
|
Color("BgPrimary")
|
|
|
|
|
|
.ignoresSafeArea()
|
|
|
|
|
|
Image("netbird-logo-menu")
|
|
|
|
|
|
.resizable()
|
|
|
|
|
|
.scaledToFit()
|
|
|
|
|
|
.frame(width: 200)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-14 10:30:08 +02:00
|
|
|
|
|
|
|
|
|
|
// Safari login view — shown regardless of statusDetailsValid
|
|
|
|
|
|
if viewModel.networkExtensionAdapter.showBrowser,
|
|
|
|
|
|
let loginURLString = viewModel.networkExtensionAdapter.loginURL,
|
|
|
|
|
|
let loginURL = URL(string: loginURLString)
|
|
|
|
|
|
{
|
|
|
|
|
|
SafariView(isPresented: $viewModel.networkExtensionAdapter.showBrowser,
|
|
|
|
|
|
url: loginURL,
|
|
|
|
|
|
didFinish: {
|
|
|
|
|
|
print("Finish login")
|
|
|
|
|
|
viewModel.networkExtensionAdapter.startVPNConnection()
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-03-02 15:23:51 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
|
|
|
|
.navigationBarHidden(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#endif
|