Files

252 lines
8.4 KiB
Swift

import ApplicationLibrary
import Libbox
import Library
import NetworkExtension
import SwiftUI
struct MainView: View {
@Environment(\.scenePhase) private var scenePhase
@EnvironmentObject private var environments: ExtensionEnvironments
@State private var selection = NavigationPage.dashboard
@State private var importProfile: LibboxProfileContent?
@State private var importRemoteProfile: LibboxImportRemoteProfile?
@State private var alert: AlertState?
@State private var showGroups = false
@State private var showConnections = false
@State private var buttonState = ButtonVisibilityState()
private let profileEditor: (Binding<String>, Bool) -> AnyView = { text, isEditable in
AnyView(ProfileEditorWrapperView(text: text, isEditable: isEditable))
}
private var shouldShowBottomAccessory: Bool {
guard !environments.extensionProfileLoading else {
return false
}
guard !environments.emptyProfiles else {
return false
}
guard environments.extensionProfile != nil else {
return false
}
return true
}
@ViewBuilder
private var tabViewContent: some View {
if shouldShowBottomAccessory {
if #available(iOS 26.0, *), !Variant.debugNoIOS26 {
baseTabView
.tabViewBottomAccessory {
bottomAccessoryContent
}
} else {
legacyTabView
}
} else {
baseTabView
}
}
var body: some View {
if Variant.screenshotMode {
mainBody.preferredColorScheme(.dark)
} else {
mainBody
}
}
private var baseTabView: some View {
tabView(showsBottomAccessory: false)
}
private var legacyTabView: some View {
tabView(showsBottomAccessory: shouldShowBottomAccessory)
}
private func tabView(showsBottomAccessory: Bool) -> some View {
TabView(selection: $selection) {
ForEach(NavigationPage.allCases, id: \.self) { page in
NavigationStackCompat {
tabContent(for: page, showsBottomAccessory: showsBottomAccessory)
}
.tag(page)
.tabItem { page.label }
.badge(page == .tools ? environments.totalUnreadReportCount : 0)
}
}
}
@ViewBuilder
private func tabContent(for page: NavigationPage, showsBottomAccessory: Bool) -> some View {
if showsBottomAccessory {
let content = page.contentView
.navigationTitle(page.title)
.tabViewBottomAccessoryCompat(useSystemAccessory: false) {
bottomAccessoryContent
}
if page == .logs {
tabBarBackgroundIfAvailable(
content
.navigationBarTitleDisplayMode(.inline)
)
} else {
tabBarBackgroundIfAvailable(content)
}
} else {
let content = page.contentView
.navigationTitle(page.title)
if page == .logs {
content
.navigationBarTitleDisplayMode(.inline)
} else {
content
}
}
}
private func tabBarBackgroundIfAvailable(_ content: some View) -> some View {
content
}
private var bottomAccessoryContent: some View {
HStack(spacing: 12) {
if let profile = environments.extensionProfile {
StatusText(profile: profile)
.frame(maxWidth: .infinity, alignment: .leading)
}
NavigationButtonsView(
showGroupsButton: buttonState.showGroupsButton,
showConnectionsButton: buttonState.showConnectionsButton,
groupsCount: buttonState.groupsCount,
connectionsCount: buttonState.connectionsCount,
onGroupsTap: { showGroups = true },
onConnectionsTap: { showConnections = true }
)
Divider()
StartStopButton(showsRuntimeDuration: true)
}
.padding(.horizontal)
.tint(.primary)
}
private var mainBody: some View {
Group {
tabViewContent
.onAppear {
updateButtonVisibility()
}
.onReceive(environments.commandClient.$groups) { _ in
Task { @MainActor in updateButtonVisibility() }
}
.onReceive(environments.commandClient.$connections) { _ in
Task { @MainActor in updateButtonVisibility() }
}
.onReceive(environments.commandClient.$hasAnyConnection) { _ in
Task { @MainActor in updateButtonVisibility() }
}
.onReceive(NotificationCenter.default.publisher(for: .NEVPNStatusDidChange)) { _ in
Task { @MainActor in updateButtonVisibility() }
}
.onReceive(environments.$extensionProfile) { _ in
Task { @MainActor in updateButtonVisibility() }
}
.onReceive(environments.$emptyProfiles) { _ in
Task { @MainActor in updateButtonVisibility() }
}
.sheet(isPresented: $showGroups) {
GroupsSheetContent()
}
.sheet(isPresented: $showConnections) {
ConnectionsSheetContent()
}
}
.onAppear {
environments.postReload()
}
.alert($alert)
.globalChecks()
.onChangeCompat(of: scenePhase) { newValue in
if newValue == .active {
environments.postReload()
}
}
.onChangeCompat(of: selection) { newValue in
if newValue == .logs {
environments.connect()
}
}
.onReceive(NotificationCenter.default.publisher(for: .reportReceived)) { _ in
Task {
await environments.crashReportManager.refresh()
await environments.oomReportManager.refresh()
selection = .tools
}
}
.environment(\.selection, $selection)
.environment(\.importProfile, $importProfile)
.environment(\.importRemoteProfile, $importRemoteProfile)
.environment(\.profileEditor, profileEditor)
.handlesExternalEvents(preferring: [], allowing: ["*"])
.onOpenURL(perform: openURL)
}
private func updateButtonVisibility() {
buttonState.update(
profile: environments.extensionProfile,
commandClient: environments.commandClient
)
}
private struct StatusText: View {
@ObservedObject var profile: ExtensionProfile
var body: some View {
statusText
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.fixedSize()
}
private var statusText: Text {
switch profile.status {
case .disconnected:
return Text("Stopped")
case .connecting:
return Text("Starting")
case .connected:
return Text("Started")
case .reasserting:
return Text("Reasserting")
case .disconnecting:
return Text("Stopping")
default:
return Text("Unknown")
.foregroundColor(.red)
}
}
}
private func openURL(url: URL) {
if url.host == "import-remote-profile" {
var error: NSError?
importRemoteProfile = LibboxParseRemoteProfileImportLink(url.absoluteString, &error)
if let error {
alert = AlertState(action: "parse remote profile import link", error: error)
}
} else if url.pathExtension == "bpf" {
do {
importProfile = try url.withSecurityScopedAccess {
try .from(Data(contentsOf: url))
}
} catch {
alert = AlertState(action: "import profile from URL", error: error)
}
} else {
alert = AlertState(errorMessage: String(localized: "Handled unknown URL \(url.absoluteString)"))
}
}
}