diff --git a/ApplicationLibrary/Views/Abstract/TabViewBottomAccessoryCompat.swift b/ApplicationLibrary/Views/Abstract/TabViewBottomAccessoryCompat.swift new file mode 100644 index 0000000..4f94063 --- /dev/null +++ b/ApplicationLibrary/Views/Abstract/TabViewBottomAccessoryCompat.swift @@ -0,0 +1,66 @@ +import SwiftUI + +#if os(iOS) + import UIKit + + public extension View { + @ViewBuilder + func tabViewBottomAccessoryCompat( + isEnabled: Bool = true, + useSystemAccessory: Bool = true, + @ViewBuilder content: @escaping () -> Content + ) -> some View { + if isEnabled { + if #available(iOS 26.0, *), useSystemAccessory { + tabViewBottomAccessory { + content() + } + } else { + safeAreaInset(edge: .bottom, spacing: 0) { + TabViewBottomAccessoryContainer(content: content) + } + } + } else { + self + } + } + + } + + private struct TabViewBottomAccessoryContainer: View { + @ViewBuilder let content: () -> Content + + var body: some View { + content() + .frame(maxWidth: .infinity) + .frame(height: TabViewBottomAccessoryMetrics.height) + .background( + .bar, + in: RoundedRectangle( + cornerRadius: TabViewBottomAccessoryMetrics.cornerRadius, + style: .continuous + ) + ) + .padding(.horizontal, TabViewBottomAccessoryMetrics.horizontalPadding) + .padding(.top, TabViewBottomAccessoryMetrics.topPadding) + .padding(.bottom, TabViewBottomAccessoryMetrics.bottomPadding) + } + } + + private enum TabViewBottomAccessoryMetrics { + static var height: CGFloat { + let toolbar = UIToolbar() + let size = toolbar.sizeThatFits(CGSize(width: UIScreen.main.bounds.width, height: 0)) + return size.height > 0 ? size.height : 44 + } + + static var cornerRadius: CGFloat { + height * 0.5 + } + + static let horizontalPadding: CGFloat = 20 + static let topPadding: CGFloat = 8 + static let bottomPadding: CGFloat = 12 + + } +#endif diff --git a/ApplicationLibrary/Views/Connections/ConnectionListView.swift b/ApplicationLibrary/Views/Connections/ConnectionListView.swift index ed0f865..d1ccf7e 100644 --- a/ApplicationLibrary/Views/Connections/ConnectionListView.swift +++ b/ApplicationLibrary/Views/Connections/ConnectionListView.swift @@ -37,10 +37,6 @@ public struct ConnectionListView: View { connectionSort: $viewModel.connectionSort, closeAllConnections: viewModel.closeAllConnections ) - if #available(iOS 26.0, *), !Variant.debugNoIOS26 { - } else { - StartStopButton() - } } } #elseif os(macOS) @@ -82,16 +78,6 @@ public struct ConnectionListView: View { .background(Color(uiColor: .systemGroupedBackground)) #endif } - - private var backgroundColor: Color { - #if os(iOS) - return Color(uiColor: .secondarySystemGroupedBackground) - #elseif os(macOS) - return Color(nsColor: .textBackgroundColor) - #elseif os(tvOS) - return Color(uiColor: .black) - #endif - } } #if os(iOS) diff --git a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift index 6b852c0..69db020 100644 --- a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift +++ b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift @@ -11,6 +11,8 @@ import SwiftUI @StateObject private var cardConfiguration = DashboardCardConfiguration() #if os(iOS) || os(tvOS) @State private var showCardManagement = false + #endif + #if os(tvOS) @State private var showGroups = false @State private var showConnections = false @State private var buttonState = ButtonVisibilityState() @@ -58,22 +60,7 @@ import SwiftUI @ViewBuilder private var content: some View { Group { - #if os(iOS) - if useLegacyTabView { - if Variant.screenshotMode || profile.status.isConnectedStrict { - VStack { - pageSelector - pageContent - } - } else { - overviewPage - } - } else { - overviewPage - } - #else - overviewPage - #endif + overviewPage } #if os(iOS) || os(tvOS) .toolbar { @@ -110,19 +97,15 @@ import SwiftUI } } #else - .sheet(isPresented: $showGroups) { - groupsSheetContent - }.sheet(isPresented: $showConnections) { - connectionsSheetContent - }.sheet(isPresented: $showCardManagement, onDismiss: { - Task { await cardConfiguration.reload() } - }, content: { - if #available(iOS 16.0, *) { - CardManagementSheet().presentationDetents([.large]).presentationDragIndicator(.visible) - } else { - CardManagementSheet() - } - }) + .sheet(isPresented: $showCardManagement, onDismiss: { + Task { await cardConfiguration.reload() } + }, content: { + if #available(iOS 16.0, *) { + CardManagementSheet().presentationDetents([.large]).presentationDragIndicator(.visible) + } else { + CardManagementSheet() + } + }) #endif #endif .onAppear { @@ -149,24 +132,14 @@ import SwiftUI } } } - #if os(iOS) || os(tvOS) + #if os(tvOS) .onReceive(environments.commandClient.$groups) { _ in Task { @MainActor in updateButtonVisibility() - #if os(iOS) - if useLegacyTabView, coordinator.selection == .groups, !buttonState.showGroupsButton { - coordinator.selection = .overview - } - #endif } }.onReceive(profile.$status) { _ in Task { @MainActor in updateButtonVisibility() - #if os(iOS) - if useLegacyTabView, coordinator.selection == .groups, !buttonState.showGroupsButton { - coordinator.selection = .overview - } - #endif } }.onAppear { updateButtonVisibility() @@ -189,58 +162,13 @@ import SwiftUI ) } - #if os(iOS) || os(tvOS) + #if os(tvOS) private func updateButtonVisibility() { buttonState.update(profile: profile, commandClient: environments.commandClient) } + #endif - #if os(iOS) - private var isTabViewBottomAccessoryAvailable: Bool { - if #available(iOS 26.0, *), !Variant.debugNoIOS26 { - return true - } - return false - } - - private var useLegacyTabView: Bool { - !isTabViewBottomAccessoryAvailable - } - - private var enabledPages: [DashboardPage] { - DashboardPage.enabledCases(hasGroups: buttonState.showGroupsButton) - } - - @ViewBuilder - private var pageSelector: some View { - Picker("Page", selection: $coordinator.selection) { - ForEach(enabledPages) { page in - page.label - } - } - .pickerStyle(.segmented) - .padding([.leading, .trailing]) - .navigationBarTitleDisplayMode(.inline) - } - - @ViewBuilder - private var pageContent: some View { - TabView(selection: $coordinator.selection) { - ForEach(enabledPages) { page in - page.contentView( - $coordinator.profileList, - $coordinator.selectedProfileID, - $coordinator.systemProxyAvailable, - $coordinator.systemProxyEnabled, - cardConfiguration - ) - .tag(page) - } - } - .navigationBarTitleDisplayMode(.inline) - .tabViewStyle(.page(indexDisplayMode: .never)) - } - #endif - + #if os(iOS) || os(tvOS) @ToolbarContentBuilder private var toolbar: some ToolbarContent { #if os(tvOS) ToolbarItemGroup(placement: .topBarLeading) { @@ -249,23 +177,10 @@ import SwiftUI #endif ToolbarItemGroup(placement: .topBarTrailing) { if #available(iOS 16.0, tvOS 17.0, *) { - #if os(iOS) - if !useLegacyTabView || coordinator.selection == .overview { - cardManagementButton - } - #else - cardManagementButton - #endif + cardManagementButton } #if os(tvOS) StartStopButton() - #elseif os(iOS) - if #available(iOS 26.0, *), !Variant.debugNoIOS26 { - } else if !useLegacyTabView || coordinator.selection != .connections { - StartStopButton() - } - #else - StartStopButton() #endif } } @@ -289,14 +204,6 @@ import SwiftUI #endif #if os(iOS) || os(tvOS) - private var groupsSheetContent: some View { - GroupsSheetContent() - } - - private var connectionsSheetContent: some View { - ConnectionsSheetContent() - } - @available(iOS 16.0, *) @ViewBuilder private var cardManagementButton: some View { #if os(iOS) Menu { diff --git a/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift b/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift index 8671e9a..916c5ff 100644 --- a/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift +++ b/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift @@ -5,13 +5,17 @@ import SwiftUI @MainActor public struct StartStopButton: View { @EnvironmentObject private var environments: ExtensionEnvironments + private let showsRuntimeDuration: Bool - public init() {} + public init(showsRuntimeDuration: Bool = false) { + self.showsRuntimeDuration = showsRuntimeDuration + } public var body: some View { Group { if let profile = environments.extensionProfile { - ToggleConnectionButton().environmentObject(profile) + ToggleConnectionButton(showsRuntimeDuration: showsRuntimeDuration) + .environmentObject(profile) } else { Button {} label: { #if os(tvOS) @@ -33,6 +37,7 @@ public struct StartStopButton: View { @State private var alert: AlertState? @State private var currentTime = Date() @State private var isStarting = false + let showsRuntimeDuration: Bool private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @@ -44,7 +49,7 @@ public struct StartStopButton: View { } label: { #if os(iOS) HStack(spacing: 8) { - if showRuntimeDuration, profile.status.isConnectedStrict, let duration = runtimeDuration { + if showsRuntimeDuration, profile.status.isConnectedStrict, let duration = runtimeDuration { Text(duration) .font(.caption) .foregroundStyle(.secondary) @@ -121,15 +126,6 @@ public struct StartStopButton: View { } } - #if os(iOS) - private var showRuntimeDuration: Bool { - if #available(iOS 26.0, *), !Variant.debugNoIOS26 { - return true - } - return false - } - #endif - private var runtimeDuration: String? { guard let connectedDate = profile.connectedDate else { return nil } let interval: TimeInterval diff --git a/ApplicationLibrary/Views/Dashboard/DashboardPage.swift b/ApplicationLibrary/Views/Dashboard/DashboardPage.swift deleted file mode 100644 index 089272f..0000000 --- a/ApplicationLibrary/Views/Dashboard/DashboardPage.swift +++ /dev/null @@ -1,66 +0,0 @@ -import Foundation -import Library -import SwiftUI - -public enum DashboardPage: Int, CaseIterable, Identifiable { - public var id: Self { - self - } - - case overview - case groups - case connections -} - -public extension DashboardPage { - static func enabledCases() -> [DashboardPage] { - [.overview, .groups, .connections] - } - - static func enabledCases(hasGroups: Bool) -> [DashboardPage] { - var cases: [DashboardPage] = [.overview] - if hasGroups { - cases.append(.groups) - } - cases.append(.connections) - return cases - } -} - -public extension DashboardPage { - var title: String { - switch self { - case .overview: - return String(localized: "Overview") - case .groups: - return String(localized: "Groups") - case .connections: - return String(localized: "Connections") - } - } - - var label: some View { - switch self { - case .overview: - return Label(title, systemImage: "text.and.command.macwindow") - case .groups: - return Label(title, systemImage: "rectangle.3.group.fill") - case .connections: - return Label(title, systemImage: "list.bullet.rectangle.portrait.fill") - } - } - - @MainActor - func contentView(_ profileList: Binding<[ProfilePreview]>, _ selectedProfileID: Binding, _ systemProxyAvailable: Binding, _ systemProxyEnabled: Binding, _ cardConfiguration: DashboardCardConfiguration) -> some View { - Group { - switch self { - case .overview: - OverviewView(profileList, selectedProfileID, systemProxyAvailable, systemProxyEnabled, cardConfiguration: cardConfiguration) - case .groups: - GroupListView() - case .connections: - ConnectionListView() - } - } - } -} diff --git a/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift b/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift index 59c79b4..ad31278 100644 --- a/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift +++ b/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift @@ -15,7 +15,6 @@ private let logger = Logger(category: "DashboardViewModel") public final class DashboardViewModel: BaseViewModel { @Published public var profileList: [ProfilePreview] = [] @Published public var selectedProfileID: Int64 = 0 - @Published public var selection = DashboardPage.overview @Published public var systemProxyAvailable = false @Published public var systemProxyEnabled = false diff --git a/Library/Network/ExtensionProfile.swift b/Library/Network/ExtensionProfile.swift index e19a1a0..835cd47 100644 --- a/Library/Network/ExtensionProfile.swift +++ b/Library/Network/ExtensionProfile.swift @@ -88,12 +88,6 @@ public class ExtensionProfile: ObservableObject { } #endif - private func unregister() { - if let observer { - NotificationCenter.default.removeObserver(observer) - } - } - nonisolated deinit { if let observer { NotificationCenter.default.removeObserver(observer) @@ -242,7 +236,9 @@ public class ExtensionProfile: ObservableObject { try await manager.saveToPreferences() } do { - try LibboxNewStandaloneCommandClient()!.serviceClose() + try await Task.detached(priority: .userInitiated) { + try LibboxNewStandaloneCommandClient()!.serviceClose() + }.value } catch { logger.debug("serviceClose error: \(error.localizedDescription)") } diff --git a/SFI/MainView.swift b/SFI/MainView.swift index a49de5f..54adedf 100644 --- a/SFI/MainView.swift +++ b/SFI/MainView.swift @@ -33,30 +33,17 @@ struct MainView: View { return true } - @available(iOS 26.0, *) @ViewBuilder private var tabViewContent: some View { if shouldShowBottomAccessory { - baseTabView - .tabViewBottomAccessory { - 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() + if #available(iOS 26.0, *), !Variant.debugNoIOS26 { + baseTabView + .tabViewBottomAccessory { + bottomAccessoryContent } - .padding(.horizontal) - } + } else { + legacyTabView + } } else { baseTabView } @@ -72,11 +59,19 @@ struct MainView: View { @ViewBuilder private var baseTabView: some View { + tabView(showsBottomAccessory: false) + } + + private var legacyTabView: some View { + tabView(showsBottomAccessory: shouldShowBottomAccessory) + } + + @ViewBuilder + private func tabView(showsBottomAccessory: Bool) -> some View { TabView(selection: $selection) { ForEach(NavigationPage.allCases, id: \.self) { page in NavigationStackCompat { - page.contentView - .navigationTitle(page.title) + tabContent(for: page, showsBottomAccessory: showsBottomAccessory) } .tag(page) .tabItem { page.label } @@ -84,40 +79,78 @@ struct MainView: View { } } + @ViewBuilder + private func tabContent(for page: NavigationPage, showsBottomAccessory: Bool) -> some View { + if showsBottomAccessory { + let content = page.contentView + .navigationTitle(page.title) + .tabViewBottomAccessoryCompat(useSystemAccessory: false) { + bottomAccessoryContent + } + tabBarBackgroundIfAvailable(content) + } else { + let content = page.contentView + .navigationTitle(page.title) + content + } + } + + @ViewBuilder + private func tabBarBackgroundIfAvailable(_ content: Content) -> 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 { - if #available(iOS 26.0, *), !Variant.debugNoIOS26 { - 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() - } - } else { - baseTabView - } + 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()