diff --git a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift index 2a4c913..5108061 100644 --- a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift +++ b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift @@ -5,82 +5,57 @@ import SwiftUI @MainActor public struct ActiveDashboardView: View { - @Environment(\.scenePhase) var scenePhase - @Environment(\.selection) private var parentSelection + @Environment(\.scenePhase) private var scenePhase @EnvironmentObject private var environments: ExtensionEnvironments @EnvironmentObject private var profile: ExtensionProfile - @StateObject private var viewModel = ActiveDashboardViewModel() + @StateObject private var coordinator = DashboardCoordinator() + @State private var cardConfigurationVersion = 0 + #if os(iOS) || os(tvOS) + @State private var showCardManagement = false + #endif + + private let externalCardConfigurationVersion: Int? + + public init(externalCardConfigurationVersion: Int? = nil) { + self.externalCardConfigurationVersion = externalCardConfigurationVersion + } - public init() {} public var body: some View { - if viewModel.isLoading { - ProgressView().onAppear { - viewModel.onEmptyProfilesChange = { isEmpty in - environments.emptyProfiles = isEmpty + if coordinator.isLoading { + ProgressView() + .onAppear { + coordinator.onEmptyProfilesChange = { environments.emptyProfiles = $0 } + Task { await coordinator.reload() } } - Task { - await viewModel.reload() - } - } } else { - if ApplicationLibrary.inPreview { - body1 - } else { - body1 - .onAppear { - Task { - await viewModel.reloadSystemProxy() - } - } - .onChangeCompat(of: profile.status) { newStatus in - if newStatus == .connected { - Task { - await viewModel.reloadSystemProxy() - } - } - } - } + content + .onAppear { + guard !ApplicationLibrary.inPreview else { return } + Task { await coordinator.reloadSystemProxy() } + } + .onChangeCompat(of: profile.status) { status in + guard !ApplicationLibrary.inPreview, status == .connected else { return } + Task { await coordinator.reloadSystemProxy() } + } } } - private var body1: some View { + @ViewBuilder + private var content: some View { VStack { #if os(iOS) || os(tvOS) if ApplicationLibrary.inPreview || profile.status.isConnectedStrict { - Picker("Page", selection: $viewModel.selection) { - ForEach(DashboardPage.enabledCases()) { page in - page.label - } - } - .pickerStyle(.segmented) - #if os(iOS) - .padding([.leading, .trailing]) - .navigationBarTitleDisplayMode(.inline) - #endif - TabView(selection: $viewModel.selection) { - ForEach(DashboardPage.enabledCases()) { page in - page.contentView($viewModel.profileList, $viewModel.selectedProfileID, $viewModel.systemProxyAvailable, $viewModel.systemProxyEnabled) - .tag(page) - } - } - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .tabViewStyle(.page(indexDisplayMode: .never)) + pageSelector + pageContent } else { - OverviewView($viewModel.profileList, $viewModel.selectedProfileID, $viewModel.systemProxyAvailable, $viewModel.systemProxyEnabled) + overviewPage } - #elseif os(macOS) - OverviewView($viewModel.profileList, $viewModel.selectedProfileID, $viewModel.systemProxyAvailable, $viewModel.systemProxyEnabled) + #else + overviewPage #endif } #if os(iOS) || os(tvOS) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - StartStopButton() - } - } - .modifier(DashboardMenuToolbarModifier(selection: viewModel.selection)) + .toolbar { toolbar } #endif .onAppear { if ApplicationLibrary.inPreview { @@ -89,49 +64,108 @@ public struct ActiveDashboardView: View { environments.connect() } } - .onChangeCompat(of: scenePhase) { newPhase in - if newPhase == .active { - environments.connect() - } + .onChangeCompat(of: scenePhase) { phase in + guard phase == .active else { return } + environments.connect() } - .onChangeCompat(of: profile.status) { newStatus in - if newStatus.isConnected { - environments.connect() - } + .onChangeCompat(of: profile.status) { status in + guard status.isConnected else { return } + environments.connect() } .onReceive(environments.profileUpdate) { _ in - Task { - await viewModel.reload() - } + Task { await coordinator.reload() } } .onReceive(environments.selectedProfileUpdate) { _ in Task { - await viewModel.updateSelectedProfile() + await coordinator.updateSelectedProfile() if profile.status.isConnected { - await viewModel.reloadSystemProxy() + await coordinator.reloadSystemProxy() } } } - .alertBinding($viewModel.alert) + .alertBinding($coordinator.alert) } -} -#if os(iOS) || os(tvOS) - private struct DashboardMenuToolbarModifier: ViewModifier { - let selection: DashboardPage + #if os(iOS) || os(tvOS) + @ViewBuilder + private var pageSelector: some View { + Picker("Page", selection: $coordinator.selection) { + ForEach(DashboardPage.enabledCases()) { page in + page.label + } + } + .pickerStyle(.segmented) + #if os(iOS) + .padding([.leading, .trailing]) + .navigationBarTitleDisplayMode(.inline) + #endif + } - func body(content: Content) -> some View { - if #available(iOS 16.0, tvOS 17.0, *) { - content.toolbar { - if selection == .overview { - ToolbarItem(placement: .topBarTrailing) { - DashboardMenu() - } + @ViewBuilder + private var pageContent: some View { + TabView(selection: $coordinator.selection) { + ForEach(DashboardPage.enabledCases()) { page in + page.contentView( + $coordinator.profileList, + $coordinator.selectedProfileID, + $coordinator.systemProxyAvailable, + $coordinator.systemProxyEnabled, + externalCardConfigurationVersion ?? cardConfigurationVersion + ) + .tag(page) + } + } + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .tabViewStyle(.page(indexDisplayMode: .never)) + } + #endif + + @ViewBuilder + private var overviewPage: some View { + OverviewView( + $coordinator.profileList, + $coordinator.selectedProfileID, + $coordinator.systemProxyAvailable, + $coordinator.systemProxyEnabled, + cardConfigurationVersion: externalCardConfigurationVersion ?? cardConfigurationVersion + ) + } + + #if os(iOS) || os(tvOS) + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + if coordinator.selection == .overview { + if #available(iOS 16.0, tvOS 17.0, *) { + cardManagementButton } } - } else { - content + } + ToolbarItem(placement: .topBarTrailing) { + StartStopButton() } } - } -#endif + #endif + + #if os(iOS) || os(tvOS) + @available(iOS 16.0, tvOS 17.0, *) + @ViewBuilder + private var cardManagementButton: some View { + Menu { + Button { + showCardManagement = true + } label: { + Label("Dashboard Items", systemImage: "square.grid.2x2") + } + } label: { + Label("Others", systemImage: "ellipsis.circle") + } + .sheet(isPresented: $showCardManagement) { + CardManagementSheet(configurationVersion: $cardConfigurationVersion) + .presentationDetents([.medium, .large]) + } + } + #endif +} diff --git a/ApplicationLibrary/Views/Dashboard/ActiveDashboardViewModel.swift b/ApplicationLibrary/Views/Dashboard/ActiveDashboardViewModel.swift deleted file mode 100644 index bfd7cef..0000000 --- a/ApplicationLibrary/Views/Dashboard/ActiveDashboardViewModel.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation -import Libbox -import Library -import SwiftUI - -@MainActor -final class ActiveDashboardViewModel: ObservableObject { - @Published var isLoading = true - @Published var profileList: [ProfilePreview] = [] - @Published var selectedProfileID: Int64 = 0 - @Published var alert: Alert? - @Published var selection = DashboardPage.overview - @Published var systemProxyAvailable = false - @Published var systemProxyEnabled = false - - var onEmptyProfilesChange: ((Bool) -> Void)? - - func reload() async { - defer { - isLoading = false - } - if ApplicationLibrary.inPreview { - profileList = [ - ProfilePreview(Profile(id: 0, name: "profile local", type: .local, path: "")), - ProfilePreview(Profile(id: 1, name: "profile remote", type: .remote, path: "", lastUpdated: Date(timeIntervalSince1970: 0))), - ] - systemProxyAvailable = true - systemProxyEnabled = true - selectedProfileID = 0 - - } else { - do { - profileList = try await ProfileManager.list().map { ProfilePreview($0) } - if profileList.isEmpty { - onEmptyProfilesChange?(true) - return - } - selectedProfileID = await SharedPreferences.selectedProfileID.get() - if profileList.filter({ profile in - profile.id == selectedProfileID - }) - .isEmpty { - selectedProfileID = profileList[0].id - await SharedPreferences.selectedProfileID.set(selectedProfileID) - } - - } catch { - alert = Alert(error) - return - } - } - onEmptyProfilesChange?(profileList.isEmpty) - } - - nonisolated func reloadSystemProxy() async { - do { - let status = try LibboxNewStandaloneCommandClient()!.getSystemProxyStatus() - await MainActor.run { - systemProxyAvailable = status.available - systemProxyEnabled = status.enabled - } - } catch { - await MainActor.run { - alert = Alert(error) - } - } - } - - func updateSelectedProfile() async { - selectedProfileID = await SharedPreferences.selectedProfileID.get() - } -} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift b/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift new file mode 100644 index 0000000..5af4586 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift @@ -0,0 +1,117 @@ +import Library +import SwiftUI + +@MainActor public struct CardManagementSheet: View { + @StateObject private var configuration = DashboardCardConfiguration() + @Binding private var configurationVersion: Int + + public init(configurationVersion: Binding) { + _configurationVersion = configurationVersion + } + + public var body: some View { + NavigationStackCompat { + Group { + if configuration.isLoading { + ProgressView() + } else { + listContent + } + } + .navigationTitle("Dashboard Items") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + #if os(iOS) || os(tvOS) + ToolbarItem(placement: .topBarTrailing) { + Button("Reset", role: .destructive) { + Task { + await configuration.resetToDefault() + configurationVersion += 1 + } + } + } + #else + ToolbarItem(placement: .automatic) { + Button("Reset", role: .destructive) { + Task { + await configuration.resetToDefault() + configurationVersion += 1 + } + } + } + #endif + } + } + } + + private var listContent: some View { + List { + ForEach(configuration.cardOrder) { card in + CardRow( + card: card, + isEnabled: configuration.isEnabled(card), + onToggle: { + configuration.toggleCard(card) + configurationVersion += 1 + } + ) + } + .onMove { source, destination in + configuration.moveCard(from: source, to: destination) + configurationVersion += 1 + } + } + .applyContentMargins() + } +} + +private struct CardRow: View { + let card: DashboardCard + let isEnabled: Bool + let onToggle: () -> Void + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "line.3.horizontal") + .foregroundColor(.secondary) + .imageScale(.small) + + if isProfileCard { + Label(card.title, systemImage: card.systemImage) + Spacer() + Text("Required") + .font(.footnote) + .foregroundColor(.secondary) + } else { + Toggle(isOn: isToggleEnabled) { + Label(card.title, systemImage: card.systemImage) + } + .opacity(isEnabled ? 1.0 : 0.5) + } + } + } + + private var isToggleEnabled: Binding { + Binding( + get: { isProfileCard || isEnabled }, + set: { _ in if !isProfileCard { onToggle() } } + ) + } + + private var isProfileCard: Bool { + card == .profile + } +} + +private extension View { + @ViewBuilder + func applyContentMargins() -> some View { + if #available(iOS 17.0, macOS 14.0, tvOS 17.0, *) { + contentMargins(.top, 0, for: .scrollContent) + } else { + self + } + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ConnectionsCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/ConnectionsCard.swift index 5798492..8eb769b 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/ConnectionsCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/ConnectionsCard.swift @@ -11,37 +11,16 @@ public struct ConnectionsCard: View { DashboardCardView(title: "Connections", isHalfWidth: true) { VStack(alignment: .leading, spacing: 8) { if ApplicationLibrary.inPreview { - CardLine(String(localized: "Inbound"), "34") - CardLine(String(localized: "Outbound"), "28") + DashboardCardLine(String(localized: "Inbound"), "34") + DashboardCardLine(String(localized: "Outbound"), "28") } else if let message = commandClient.status { - CardLine(String(localized: "Inbound"), "\(message.connectionsIn)") - CardLine(String(localized: "Outbound"), "\(message.connectionsOut)") + DashboardCardLine(String(localized: "Inbound"), "\(message.connectionsIn)") + DashboardCardLine(String(localized: "Outbound"), "\(message.connectionsOut)") } else { - CardLine(String(localized: "Inbound"), "...") - CardLine(String(localized: "Outbound"), "...") + DashboardCardLine(String(localized: "Inbound"), "...") + DashboardCardLine(String(localized: "Outbound"), "...") } } } } } - -private struct CardLine: View { - private let name: String - private let value: String - - init(_ name: String, _ value: String) { - self.name = name - self.value = value - } - - var body: some View { - HStack { - Text(name) - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - Text(value) - .font(.subheadline) - } - } -} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/DashboardCardConfiguration.swift b/ApplicationLibrary/Views/Dashboard/Cards/DashboardCardConfiguration.swift new file mode 100644 index 0000000..35d28c0 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/Cards/DashboardCardConfiguration.swift @@ -0,0 +1,107 @@ +import Foundation +import Library + +@MainActor +public final class DashboardCardConfiguration: ObservableObject { + @Published public private(set) var enabledCards: [DashboardCard] = [] + @Published public private(set) var cardOrder: [DashboardCard] = [] + @Published public private(set) var isLoading = true + + public init() { + Task { + await reload() + } + } + + public func reload() async { + isLoading = true + enabledCards = await loadEnabledCards() + cardOrder = await loadCardOrder() + isLoading = false + } + + public func isEnabled(_ card: DashboardCard) -> Bool { + enabledCards.contains(card) + } + + public func toggleCard(_ card: DashboardCard) { + guard card != .profile else { return } + + // Update state synchronously so UI reflects change immediately + if enabledCards.contains(card) { + enabledCards.removeAll { $0 == card } + } else { + enabledCards = insertInOrder(card, into: enabledCards) + } + + // Save asynchronously in background + Task { + await saveEnabledCards() + } + } + + public func moveCard(from source: IndexSet, to destination: Int) { + cardOrder.move(fromOffsets: source, toOffset: destination) + + // Save asynchronously in background + Task { + await saveCardOrder() + } + } + + public func resetToDefault() async { + await SharedPreferences.enabledDashboardCards.set([]) + await SharedPreferences.dashboardCardOrder.set([]) + await reload() + } + + public var orderedEnabledCards: [DashboardCard] { + cardOrder.filter { enabledCards.contains($0) } + } + + private func loadEnabledCards() async -> [DashboardCard] { + let saved = await SharedPreferences.enabledDashboardCards.get() + guard !saved.isEmpty else { return DashboardCard.defaultCards } + + var cards = saved.compactMap { DashboardCard(rawValue: $0) } + if !cards.contains(.profile) { + cards.append(.profile) + await SharedPreferences.enabledDashboardCards.set(cards.map(\.rawValue)) + } + return cards + } + + private func loadCardOrder() async -> [DashboardCard] { + let saved = await SharedPreferences.dashboardCardOrder.get() + guard !saved.isEmpty else { return DashboardCard.defaultOrder } + + var order = saved.compactMap { DashboardCard(rawValue: $0) } + let existingSet = Set(order) + let newCards = DashboardCard.allCases.filter { !existingSet.contains($0) } + order.append(contentsOf: newCards) + return order + } + + private func saveEnabledCards() async { + await SharedPreferences.enabledDashboardCards.set(enabledCards.map(\.rawValue)) + } + + private func saveCardOrder() async { + await SharedPreferences.dashboardCardOrder.set(cardOrder.map(\.rawValue)) + } + + private func insertInOrder(_ card: DashboardCard, into cards: [DashboardCard]) -> [DashboardCard] { + guard let cardIndex = cardOrder.firstIndex(of: card) else { + return cards + [card] + } + + let insertIndex = cards.filter { enabledCard in + guard let enabledIndex = cardOrder.firstIndex(of: enabledCard) else { return false } + return enabledIndex < cardIndex + }.count + + var result = cards + result.insert(card, at: insertIndex) + return result + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/DashboardCardLine.swift b/ApplicationLibrary/Views/Dashboard/Cards/DashboardCardLine.swift new file mode 100644 index 0000000..e627872 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/Cards/DashboardCardLine.swift @@ -0,0 +1,22 @@ +import SwiftUI + +public struct DashboardCardLine: View { + private let label: String + private let value: String + + public init(_ label: String, _ value: String) { + self.label = label + self.value = value + } + + public var body: some View { + HStack { + Text(label) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text(value) + .font(.subheadline) + } + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/StatusCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/StatusCard.swift index 4100ad4..f9e03ca 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/StatusCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/StatusCard.swift @@ -11,37 +11,16 @@ public struct StatusCard: View { DashboardCardView(title: "Status", isHalfWidth: true) { VStack(alignment: .leading, spacing: 8) { if ApplicationLibrary.inPreview { - CardLine(String(localized: "Memory"), "6.4 MB") - CardLine(String(localized: "Goroutines"), "89") + DashboardCardLine(String(localized: "Memory"), "6.4 MB") + DashboardCardLine(String(localized: "Goroutines"), "89") } else if let message = commandClient.status { - CardLine(String(localized: "Memory"), LibboxFormatMemoryBytes(message.memory)) - CardLine(String(localized: "Goroutines"), "\(message.goroutines)") + DashboardCardLine(String(localized: "Memory"), LibboxFormatMemoryBytes(message.memory)) + DashboardCardLine(String(localized: "Goroutines"), "\(message.goroutines)") } else { - CardLine(String(localized: "Memory"), "...") - CardLine(String(localized: "Goroutines"), "...") + DashboardCardLine(String(localized: "Memory"), "...") + DashboardCardLine(String(localized: "Goroutines"), "...") } } } } } - -private struct CardLine: View { - private let name: String - private let value: String - - init(_ name: String, _ value: String) { - self.name = name - self.value = value - } - - var body: some View { - HStack { - Text(name) - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - Text(value) - .font(.subheadline) - } - } -} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/TrafficCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/TrafficCard.swift index 2d86902..1773971 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/TrafficCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/TrafficCard.swift @@ -11,37 +11,16 @@ public struct TrafficCard: View { DashboardCardView(title: "Traffic", isHalfWidth: true) { VStack(alignment: .leading, spacing: 8) { if ApplicationLibrary.inPreview { - CardLine(String(localized: "Uplink"), "38 B/s") - CardLine(String(localized: "Downlink"), "249 MB/s") + DashboardCardLine(String(localized: "Uplink"), "38 B/s") + DashboardCardLine(String(localized: "Downlink"), "249 MB/s") } else if let message = commandClient.status, message.trafficAvailable { - CardLine(String(localized: "Uplink"), "\(LibboxFormatBytes(message.uplink))/s") - CardLine(String(localized: "Downlink"), "\(LibboxFormatBytes(message.downlink))/s") + DashboardCardLine(String(localized: "Uplink"), "\(LibboxFormatBytes(message.uplink))/s") + DashboardCardLine(String(localized: "Downlink"), "\(LibboxFormatBytes(message.downlink))/s") } else { - CardLine(String(localized: "Uplink"), "...") - CardLine(String(localized: "Downlink"), "...") + DashboardCardLine(String(localized: "Uplink"), "...") + DashboardCardLine(String(localized: "Downlink"), "...") } } } } } - -private struct CardLine: View { - private let name: String - private let value: String - - init(_ name: String, _ value: String) { - self.name = name - self.value = value - } - - var body: some View { - HStack { - Text(name) - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - Text(value) - .font(.subheadline) - } - } -} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/TrafficTotalCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/TrafficTotalCard.swift index 7561ae4..09e2e34 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/TrafficTotalCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/TrafficTotalCard.swift @@ -11,37 +11,16 @@ public struct TrafficTotalCard: View { DashboardCardView(title: "Traffic Total", isHalfWidth: true) { VStack(alignment: .leading, spacing: 8) { if ApplicationLibrary.inPreview { - CardLine(String(localized: "Uplink"), "52 MB") - CardLine(String(localized: "Downlink"), "5.6 GB") + DashboardCardLine(String(localized: "Uplink"), "52 MB") + DashboardCardLine(String(localized: "Downlink"), "5.6 GB") } else if let message = commandClient.status, message.trafficAvailable { - CardLine(String(localized: "Uplink"), LibboxFormatBytes(message.uplinkTotal)) - CardLine(String(localized: "Downlink"), LibboxFormatBytes(message.downlinkTotal)) + DashboardCardLine(String(localized: "Uplink"), LibboxFormatBytes(message.uplinkTotal)) + DashboardCardLine(String(localized: "Downlink"), LibboxFormatBytes(message.downlinkTotal)) } else { - CardLine(String(localized: "Uplink"), "...") - CardLine(String(localized: "Downlink"), "...") + DashboardCardLine(String(localized: "Uplink"), "...") + DashboardCardLine(String(localized: "Downlink"), "...") } } } } } - -private struct CardLine: View { - private let name: String - private let value: String - - init(_ name: String, _ value: String) { - self.name = name - self.value = value - } - - var body: some View { - HStack { - Text(name) - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - Text(value) - .font(.subheadline) - } - } -} diff --git a/ApplicationLibrary/Views/Dashboard/ClashModeView.swift b/ApplicationLibrary/Views/Dashboard/ClashModeView.swift deleted file mode 100644 index 6045aa0..0000000 --- a/ApplicationLibrary/Views/Dashboard/ClashModeView.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Libbox -import Library -import SwiftUI - -@MainActor -public struct ClashModeView: View { - @EnvironmentObject private var environments: ExtensionEnvironments - @StateObject private var viewModel = ClashModeViewModel() - - public init() {} - public var body: some View { - VStack { - if viewModel.shouldShowPicker { - Picker("", selection: Binding(get: { - viewModel.clashMode - }, set: { newMode in - viewModel.clashMode = newMode - Task { - await viewModel.setClashMode(newMode) - } - }), content: { - ForEach(viewModel.clashModeList, id: \.self) { mode in - Text(mode) - } - }) - .pickerStyle(.segmented) - .padding([.top], 8) - } - } - .padding([.leading, .trailing]) - .onAppear { - viewModel.setCommandClient(environments.commandClient) - } - .alertBinding($viewModel.alert) - } -} diff --git a/ApplicationLibrary/Views/Dashboard/ClashModeViewModel.swift b/ApplicationLibrary/Views/Dashboard/ClashModeViewModel.swift deleted file mode 100644 index 27e1d49..0000000 --- a/ApplicationLibrary/Views/Dashboard/ClashModeViewModel.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Libbox -import Library -import SwiftUI - -@MainActor -final class ClashModeViewModel: ObservableObject { - @Published var clashMode = "" - @Published var alert: Alert? - - var commandClient: CommandClient? - - var clashModeList: [String] { - commandClient?.clashModeList ?? [] - } - - var shouldShowPicker: Bool { - (commandClient?.clashModeList.count ?? 0) > 1 - } - - func setCommandClient(_ client: CommandClient) { - commandClient = client - client.$clashMode - .assign(to: &$clashMode) - } - - nonisolated func setClashMode(_ newMode: String) async { - do { - try LibboxNewStandaloneCommandClient()!.setClashMode(newMode) - } catch { - await MainActor.run { - alert = Alert(error) - } - } - } -} diff --git a/ApplicationLibrary/Views/Dashboard/ExtensionStatusView.swift b/ApplicationLibrary/Views/Dashboard/Components/ExtensionStatusView.swift similarity index 100% rename from ApplicationLibrary/Views/Dashboard/ExtensionStatusView.swift rename to ApplicationLibrary/Views/Dashboard/Components/ExtensionStatusView.swift diff --git a/ApplicationLibrary/Views/Dashboard/InstallProfileButton.swift b/ApplicationLibrary/Views/Dashboard/Components/InstallProfileButton.swift similarity index 100% rename from ApplicationLibrary/Views/Dashboard/InstallProfileButton.swift rename to ApplicationLibrary/Views/Dashboard/Components/InstallProfileButton.swift diff --git a/ApplicationLibrary/Views/Dashboard/InstallSystemExtensionButton.swift b/ApplicationLibrary/Views/Dashboard/Components/InstallSystemExtensionButton.swift similarity index 100% rename from ApplicationLibrary/Views/Dashboard/InstallSystemExtensionButton.swift rename to ApplicationLibrary/Views/Dashboard/Components/InstallSystemExtensionButton.swift diff --git a/ApplicationLibrary/Views/Dashboard/StartStopButton.swift b/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift similarity index 94% rename from ApplicationLibrary/Views/Dashboard/StartStopButton.swift rename to ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift index 9a51b92..428804f 100644 --- a/ApplicationLibrary/Views/Dashboard/StartStopButton.swift +++ b/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift @@ -16,7 +16,7 @@ public struct StartStopButton: View { } .labelStyle(.iconOnly) } else if let profile = environments.extensionProfile { - Button0().environmentObject(profile) + ToggleConnectionButton().environmentObject(profile) } else { Button {} label: { Label("Start", systemImage: "play.fill") @@ -28,7 +28,7 @@ public struct StartStopButton: View { .disabled(environments.emptyProfiles) } - private struct Button0: View { + private struct ToggleConnectionButton: View { @EnvironmentObject private var environments: ExtensionEnvironments @EnvironmentObject private var profile: ExtensionProfile @State private var alert: Alert? diff --git a/ApplicationLibrary/Views/Dashboard/DashboardCoordinator.swift b/ApplicationLibrary/Views/Dashboard/DashboardCoordinator.swift new file mode 100644 index 0000000..c5113c2 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/DashboardCoordinator.swift @@ -0,0 +1,195 @@ +import Foundation +import Libbox +import Library +import NetworkExtension +import SwiftUI + +@MainActor +public final class DashboardCoordinator: ObservableObject { + @Published public var isLoading = true + @Published public var profileList: [ProfilePreview] = [] + @Published public var selectedProfileID: Int64 = 0 + @Published public var alert: Alert? + @Published public var selection = DashboardPage.overview + @Published public var systemProxyAvailable = false + @Published public var systemProxyEnabled = false + @Published public var notStarted = false + + #if os(macOS) + @Published public var systemExtensionInstalled = true + #endif + + public var onEmptyProfilesChange: ((Bool) -> Void)? + private var openURL: ((URL) -> Void)? + + public init() {} + + public func setOpenURL(_ openURL: @escaping (URL) -> Void) { + self.openURL = openURL + } + + public func reload() async { + #if os(macOS) + if Variant.useSystemExtension { + let installed = await SystemExtension.isInstalled() + systemExtensionInstalled = installed + guard installed else { + isLoading = false + return + } + } + #endif + + defer { isLoading = false } + + if ApplicationLibrary.inPreview { + profileList = [ + ProfilePreview(Profile(id: 0, name: "profile local", type: .local, path: "")), + ProfilePreview(Profile(id: 1, name: "profile remote", type: .remote, path: "", lastUpdated: Date(timeIntervalSince1970: 0))), + ] + systemProxyAvailable = true + systemProxyEnabled = true + selectedProfileID = 0 + } else { + do { + profileList = try await ProfileManager.list().map { ProfilePreview($0) } + guard !profileList.isEmpty else { + onEmptyProfilesChange?(true) + return + } + + selectedProfileID = await SharedPreferences.selectedProfileID.get() + if !profileList.contains(where: { $0.id == selectedProfileID }) { + selectedProfileID = profileList[0].id + await SharedPreferences.selectedProfileID.set(selectedProfileID) + } + } catch { + alert = Alert(error) + return + } + } + onEmptyProfilesChange?(profileList.isEmpty) + } + + public func reloadSystemProxy() async { + do { + let status = try LibboxNewStandaloneCommandClient()!.getSystemProxyStatus() + systemProxyAvailable = status.available + systemProxyEnabled = status.enabled + } catch { + alert = Alert(error) + } + } + + public func updateSelectedProfile() async { + selectedProfileID = await SharedPreferences.selectedProfileID.get() + } + + public func handleStatusChange(_ status: NEVPNStatus, profile: ExtensionProfile) { + if status == .connected { + notStarted = false + Task { await checkDeprecatedNotes() } + } else if status == .connecting { + notStarted = true + } else if status == .disconnected { + if #available(iOS 16.0, macOS 13.0, tvOS 17.0, *) { + if notStarted { + Task { await checkLastDisconnectError(profile: profile) } + } + } + } + } + + nonisolated func checkDeprecatedNotes() async { + let disableWarnings = await SharedPreferences.disableDeprecatedWarnings.get() + guard !disableWarnings else { return } + + do { + let reports = try LibboxNewStandaloneCommandClient()!.getDeprecatedNotes() + if reports.hasNext() { + await MainActor.run { + loopShowDeprecateNotes(reports) + } + } + } catch { + await MainActor.run { + alert = Alert(error) + } + } + } + + private func loopShowDeprecateNotes(_ reports: any LibboxDeprecatedNoteIteratorProtocol) { + guard reports.hasNext() else { return } + + let report = reports.next()! + if report.migrationLink.isEmpty { + alert = Alert( + title: Text("Deprecated Warning"), + message: Text(report.message()), + dismissButton: .cancel(Text("Ok")) { + Task.detached { [weak self] in + try await Task.sleep(nanoseconds: 300 * NSEC_PER_MSEC) + await self?.loopShowDeprecateNotes(reports) + } + } + ) + } else { + alert = Alert( + title: Text("Deprecated Warning"), + message: Text(report.message()), + primaryButton: .default(Text("Documentation")) { + self.openURL?(URL(string: report.migrationLink)!) + Task.detached { [weak self] in + try await Task.sleep(nanoseconds: 300 * NSEC_PER_MSEC) + await self?.loopShowDeprecateNotes(reports) + } + }, + secondaryButton: .cancel(Text("Ok")) { + Task.detached { [weak self] in + try await Task.sleep(nanoseconds: 300 * NSEC_PER_MSEC) + await self?.loopShowDeprecateNotes(reports) + } + } + ) + } + } + + @available(iOS 16.0, macOS 13.0, tvOS 17.0, *) + nonisolated func checkLastDisconnectError(profile: ExtensionProfile) async { + do { + try await profile.fetchLastDisconnectError() + return + } catch { + let myError = error as NSError + #if os(macOS) + if myError.domain == "Library.FullDiskAccessPermissionRequired" { + await MainActor.run { + alert = Alert( + title: Text("Full Disk Access permission is required"), + message: Text("Please grant the permission for **SFMExtension**, then we can continue."), + primaryButton: .default(Text("Authorize"), action: openFDASettings), + secondaryButton: .cancel() + ) + } + return + } + #endif + await MainActor.run { + alert = Alert(title: Text("Service Error"), message: Text(myError.localizedDescription)) + } + } + } + + #if os(macOS) + private func openFDASettings() { + if NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles")!) { + return + } + if #available(macOS 13, *) { + NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Settings.app")) + } else { + NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Preferences.app")) + } + } + #endif +} diff --git a/ApplicationLibrary/Views/Dashboard/DashboardMenu.swift b/ApplicationLibrary/Views/Dashboard/DashboardMenu.swift deleted file mode 100644 index 78aa0b1..0000000 --- a/ApplicationLibrary/Views/Dashboard/DashboardMenu.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Foundation -import Library -import SwiftUI - -@MainActor -public struct DashboardMenu: View { - @StateObject private var viewModel = DashboardMenuViewModel() - - public init() {} - - public var body: some View { - Menu { - ForEach(DashboardCard.allCases) { card in - Toggle(isOn: Binding( - get: { viewModel.enabledCards.contains(card) }, - set: { _ in - Task { - await viewModel.toggleCard(card) - } - } - )) { - Label(card.title, systemImage: card.systemImage) - } - } - - Divider() - - Button("Reset to Default") { - Task { - await viewModel.resetToDefault() - } - } - } label: { - Label("Dashboard Items", systemImage: "square.grid.2x2") - } - .onAppear { - Task { - await viewModel.loadCards() - } - } - } -} - -@MainActor -private final class DashboardMenuViewModel: ObservableObject { - @Published var enabledCards: [DashboardCard] = [] - - func loadCards() async { - let savedCards = await SharedPreferences.enabledDashboardCards.get() - if savedCards.isEmpty { - enabledCards = DashboardCard.defaultCards - } else { - enabledCards = savedCards.compactMap { DashboardCard(rawValue: $0) } - } - } - - func toggleCard(_ card: DashboardCard) async { - if enabledCards.contains(card) { - enabledCards.removeAll { $0 == card } - } else { - enabledCards.append(card) - } - await SharedPreferences.enabledDashboardCards.set(enabledCards.map(\.rawValue)) - } - - func resetToDefault() async { - await SharedPreferences.enabledDashboardCards.set([]) - await SharedPreferences.dashboardCardOrder.set([]) - enabledCards = DashboardCard.defaultCards - } -} diff --git a/ApplicationLibrary/Views/Dashboard/DashboardPage.swift b/ApplicationLibrary/Views/Dashboard/DashboardPage.swift index 5acdd5c..e97cd7f 100644 --- a/ApplicationLibrary/Views/Dashboard/DashboardPage.swift +++ b/ApplicationLibrary/Views/Dashboard/DashboardPage.swift @@ -51,11 +51,11 @@ public extension DashboardPage { } @MainActor - func contentView(_ profileList: Binding<[ProfilePreview]>, _ selectedProfileID: Binding, _ systemProxyAvailable: Binding, _ systemProxyEnabled: Binding) -> some View { + func contentView(_ profileList: Binding<[ProfilePreview]>, _ selectedProfileID: Binding, _ systemProxyAvailable: Binding, _ systemProxyEnabled: Binding, _ cardConfigurationVersion: Int) -> some View { viewBuilder { switch self { case .overview: - OverviewView(profileList, selectedProfileID, systemProxyAvailable, systemProxyEnabled) + OverviewView(profileList, selectedProfileID, systemProxyAvailable, systemProxyEnabled, cardConfigurationVersion: cardConfigurationVersion) case .groups: GroupListView() case .connections: diff --git a/ApplicationLibrary/Views/Dashboard/DashboardView.swift b/ApplicationLibrary/Views/Dashboard/DashboardView.swift index c8bdf17..4f5d01d 100644 --- a/ApplicationLibrary/Views/Dashboard/DashboardView.swift +++ b/ApplicationLibrary/Views/Dashboard/DashboardView.swift @@ -4,91 +4,68 @@ import SwiftUI @MainActor public struct DashboardView: View { + @Environment(\.openURL) private var openURL + @Environment(\.cardConfigurationVersion) private var cardConfigurationVersion + @EnvironmentObject private var environments: ExtensionEnvironments + @StateObject private var coordinator = DashboardCoordinator() + #if os(macOS) @Environment(\.controlActiveState) private var controlActiveState - @StateObject private var viewModel = DashboardViewModel() #endif public init() {} + public var body: some View { - viewBuilder { - #if os(macOS) - if Variant.useSystemExtension { - viewBuilder { - if !viewModel.systemExtensionInstalled { - FormView { - InstallSystemExtensionButton { - await viewModel.reload() - } - } - } else { - DashboardView0() - } - }.onAppear { - Task { - await viewModel.reload() - } - } - } else { - DashboardView0() - } - #else - DashboardView0() - #endif - } + content + .onAppear { + coordinator.setOpenURL { openURL($0) } + #if os(macOS) + Task { await coordinator.reload() } + #endif + } #if os(macOS) - .onChangeCompat(of: controlActiveState) { newValue in - if newValue != .inactive { - if Variant.useSystemExtension { - if !viewModel.isLoading { - Task { - await viewModel.reload() - } - } - } + .onChangeCompat(of: controlActiveState) { state in + guard state != .inactive, Variant.useSystemExtension, !coordinator.isLoading else { return } + Task { await coordinator.reload() } } - } #endif } - struct DashboardView0: View { - @EnvironmentObject private var environments: ExtensionEnvironments - - var body: some View { - if ApplicationLibrary.inPreview { - ActiveDashboardView() - } else if environments.extensionProfileLoading { - ProgressView() - } else if let profile = environments.extensionProfile { - DashboardView1().environmentObject(profile) - } else { + @ViewBuilder + private var content: some View { + #if os(macOS) + if Variant.useSystemExtension, !coordinator.systemExtensionInstalled { FormView { - InstallProfileButton { - await environments.reload() + InstallSystemExtensionButton { + await coordinator.reload() } } + } else { + mainContent } - } + #else + mainContent + #endif } - struct DashboardView1: View { - @Environment(\.openURL) var openURL - @EnvironmentObject private var environments: ExtensionEnvironments - @EnvironmentObject private var profile: ExtensionProfile - @StateObject private var viewModel = DashboardViewModel() - - var body: some View { - VStack { - ActiveDashboardView() - } - .alertBinding($viewModel.alert) - .onAppear { - viewModel.setOpenURL { url in - openURL(url) + @ViewBuilder + private var mainContent: some View { + if ApplicationLibrary.inPreview { + ActiveDashboardView(externalCardConfigurationVersion: cardConfigurationVersion) + } else if environments.extensionProfileLoading { + ProgressView() + } else if let profile = environments.extensionProfile { + ActiveDashboardView(externalCardConfigurationVersion: cardConfigurationVersion) + .environmentObject(profile) + .alertBinding($coordinator.alert) + .onChangeCompat(of: profile.status) { status in + coordinator.handleStatusChange(status, profile: profile) + } + } else { + FormView { + InstallProfileButton { + await environments.reload() } - } - .onChangeCompat(of: profile.status) { newValue in - viewModel.handleStatusChange(newValue, profile: profile) } } } diff --git a/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift b/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift deleted file mode 100644 index fcd688b..0000000 --- a/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift +++ /dev/null @@ -1,145 +0,0 @@ -import Libbox -import Library -import NetworkExtension -import SwiftUI - -@MainActor -class DashboardViewModel: ObservableObject { - #if os(macOS) - @Published var isLoading = true - @Published var systemExtensionInstalled = true - #endif - - @Published var alert: Alert? - @Published var notStarted = false - - private var openURL: ((URL) -> Void)? - - func setOpenURL(_ openURL: @escaping (URL) -> Void) { - self.openURL = openURL - } - - #if os(macOS) - nonisolated func reload() async { - let systemExtensionInstalled = await SystemExtension.isInstalled() - await MainActor.run { - self.systemExtensionInstalled = systemExtensionInstalled - self.isLoading = false - } - } - #endif - - func handleStatusChange(_ status: NEVPNStatus, profile: ExtensionProfile) { - if status == .connected { - notStarted = false - Task { - await checkDeprecatedNotes() - } - } else if status == .connecting { - notStarted = true - } else if status == .disconnected { - if #available(iOS 16.0, macOS 13.0, tvOS 17.0, *) { - if notStarted { - Task { - await checkLastDisconnectError(profile: profile) - } - } - } - } - } - - nonisolated func checkDeprecatedNotes() async { - if await SharedPreferences.disableDeprecatedWarnings.get() { - return - } - do { - let reports = try LibboxNewStandaloneCommandClient()!.getDeprecatedNotes() - if reports.hasNext() { - await MainActor.run { - loopShowDeprecateNotes(reports) - } - } - } catch { - await MainActor.run { - alert = Alert(error) - } - } - } - - private func loopShowDeprecateNotes(_ reports: any LibboxDeprecatedNoteIteratorProtocol) { - if reports.hasNext() { - let report = reports.next()! - if report.migrationLink.isEmpty { - alert = Alert( - title: Text("Deprecated Warning"), - message: Text(report.message()), - dismissButton: .cancel(Text("Ok")) { - Task.detached { [weak self] in - try await Task.sleep(nanoseconds: 300 * NSEC_PER_MSEC) - await self?.loopShowDeprecateNotes(reports) - } - } - ) - } else { - alert = Alert( - title: Text("Deprecated Warning"), - message: Text(report.message()), - primaryButton: .default(Text("Documentation")) { - self.openURL?(URL(string: report.migrationLink)!) - Task.detached { [weak self] in - try await Task.sleep(nanoseconds: 300 * NSEC_PER_MSEC) - await self?.loopShowDeprecateNotes(reports) - } - }, - secondaryButton: .cancel(Text("Ok")) { - Task.detached { [weak self] in - try await Task.sleep(nanoseconds: 300 * NSEC_PER_MSEC) - await self?.loopShowDeprecateNotes(reports) - } - } - ) - } - } - } - - @available(iOS 16.0, macOS 13.0, tvOS 17.0, *) - nonisolated func checkLastDisconnectError(profile: ExtensionProfile) async { - var myError: NSError - do { - try await profile.fetchLastDisconnectError() - return - } catch { - myError = error as NSError - } - #if os(macOS) - if myError.domain == "Library.FullDiskAccessPermissionRequired" { - await MainActor.run { - alert = Alert( - title: Text("Full Disk Access permission is required"), - message: Text("Please grant the permission for **SFMExtension**, then we can continue."), - primaryButton: .default(Text("Authorize"), action: openFDASettings), - secondaryButton: .cancel() - ) - } - return - } - #endif - let message = myError.localizedDescription - await MainActor.run { - alert = Alert(title: Text("Service Error"), message: Text(message)) - } - } - - #if os(macOS) - private func openFDASettings() { - if NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles")!) { - return - } - if #available(macOS 13, *) { - NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Settings.app")) - } else { - NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Preferences.app")) - } - } - #endif -} diff --git a/ApplicationLibrary/Views/Dashboard/OverviewViewModel.swift b/ApplicationLibrary/Views/Dashboard/Overview/OverviewCoordinator.swift similarity index 50% rename from ApplicationLibrary/Views/Dashboard/OverviewViewModel.swift rename to ApplicationLibrary/Views/Dashboard/Overview/OverviewCoordinator.swift index 4b29147..5b23195 100644 --- a/ApplicationLibrary/Views/Dashboard/OverviewViewModel.swift +++ b/ApplicationLibrary/Views/Dashboard/Overview/OverviewCoordinator.swift @@ -4,15 +4,16 @@ import Library import SwiftUI @MainActor -public final class OverviewViewModel: ObservableObject { - @Published var alert: Alert? - @Published var reasserting = false +public final class OverviewCoordinator: ObservableObject { + @Published public var alert: Alert? + @Published public var reasserting = false public init() {} - func switchProfile(_ newProfileID: Int64, profile: ExtensionProfile, environments: ExtensionEnvironments) async { - await SharedPreferences.selectedProfileID.set(newProfileID) + public func switchProfile(_ profileID: Int64, profile: ExtensionProfile, environments: ExtensionEnvironments) async { + await SharedPreferences.selectedProfileID.set(profileID) environments.selectedProfileUpdate.send() + if profile.status.isConnected { do { try await serviceReload() @@ -23,21 +24,19 @@ public final class OverviewViewModel: ObservableObject { reasserting = false } - nonisolated func serviceReload() async throws { + public nonisolated func serviceReload() async throws { try LibboxNewStandaloneCommandClient()!.serviceReload() } - nonisolated func setSystemProxyEnabled(_ isEnabled: Bool, profile: ExtensionProfile) async { + public nonisolated func setSystemProxyEnabled(_ enabled: Bool, profile: ExtensionProfile) async { do { - await SharedPreferences.systemProxyEnabled.set(isEnabled) - if isEnabled { - try LibboxNewStandaloneCommandClient()!.setSystemProxyEnabled(isEnabled) + await SharedPreferences.systemProxyEnabled.set(enabled) + if enabled { + try LibboxNewStandaloneCommandClient()!.setSystemProxyEnabled(enabled) } else { - // Apple BUG: HTTP Proxy cannot be disabled via setTunnelNetworkSettings, so we can only restart the Network Extension - await MainActor.run { - reasserting = true - } + await MainActor.run { reasserting = true } try await profile.stop() + var waitSeconds = 0 while await profile.status != .disconnected { try await Task.sleep(nanoseconds: NSEC_PER_SEC) @@ -47,14 +46,10 @@ public final class OverviewViewModel: ObservableObject { } } try await profile.start() - await MainActor.run { - reasserting = false - } + await MainActor.run { reasserting = false } } } catch { - await MainActor.run { - alert = Alert(error) - } + await MainActor.run { alert = Alert(error) } } } } diff --git a/ApplicationLibrary/Views/Dashboard/OverviewView.swift b/ApplicationLibrary/Views/Dashboard/Overview/OverviewView.swift similarity index 71% rename from ApplicationLibrary/Views/Dashboard/OverviewView.swift rename to ApplicationLibrary/Views/Dashboard/Overview/OverviewView.swift index eed0783..c0d0022 100644 --- a/ApplicationLibrary/Views/Dashboard/OverviewView.swift +++ b/ApplicationLibrary/Views/Dashboard/Overview/OverviewView.swift @@ -5,32 +5,29 @@ import SwiftUI @MainActor public struct OverviewView: View { - @Environment(\.selection) private var selection @EnvironmentObject private var environments: ExtensionEnvironments @EnvironmentObject private var profile: ExtensionProfile + @StateObject private var coordinator = OverviewCoordinator() + @StateObject private var configuration = DashboardCardConfiguration() + @Binding private var profileList: [ProfilePreview] @Binding private var selectedProfileID: Int64 @Binding private var systemProxyAvailable: Bool @Binding private var systemProxyEnabled: Bool - @StateObject private var viewModel = OverviewViewModel() + private let cardConfigurationVersion: Int - @State private var enabledCards: [DashboardCard] = [] - @State private var cardOrder: [DashboardCard] = [] - - private var selectedProfileIDLocal: Binding { - $selectedProfileID.withSetter { newValue in - viewModel.reasserting = true - Task { [self] in - await viewModel.switchProfile(newValue, profile: profile, environments: environments) - } - } - } - - public init(_ profileList: Binding<[ProfilePreview]>, _ selectedProfileID: Binding, _ systemProxyAvailable: Binding, _ systemProxyEnabled: Binding) { + public init( + _ profileList: Binding<[ProfilePreview]>, + _ selectedProfileID: Binding, + _ systemProxyAvailable: Binding, + _ systemProxyEnabled: Binding, + cardConfigurationVersion: Int + ) { _profileList = profileList _selectedProfileID = selectedProfileID _systemProxyAvailable = systemProxyAvailable _systemProxyEnabled = systemProxyEnabled + self.cardConfigurationVersion = cardConfigurationVersion } public var body: some View { @@ -49,20 +46,16 @@ public struct OverviewView: View { } } } - .onAppear { - Task { - enabledCards = await viewModel.loadEnabledCards() - cardOrder = await viewModel.loadCardOrder() - } + .onChangeCompat(of: cardConfigurationVersion) { _ in + Task { await configuration.reload() } } - .alertBinding($viewModel.alert) - .disabled(!ApplicationLibrary.inPreview && (!profile.status.isSwitchable || viewModel.reasserting)) + .alertBinding($coordinator.alert) + .disabled(!ApplicationLibrary.inPreview && (!profile.status.isSwitchable || coordinator.reasserting)) } @ViewBuilder private var cardGrid: some View { - let orderedCards = viewModel.getOrderedEnabledCards(enabledCards: enabledCards, order: cardOrder) - let visibleCards = orderedCards.filter { shouldShowCard($0) } + let visibleCards = configuration.orderedEnabledCards.filter(shouldShowCard) let groupedCards = groupCards(visibleCards) VStack(spacing: 16) { @@ -87,7 +80,6 @@ public struct OverviewView: View { while index < cards.count { let card = cards[index] - if card.isHalfWidth, index + 1 < cards.count, cards[index + 1].isHalfWidth { result.append([card, cards[index + 1]]) index += 2 @@ -96,18 +88,15 @@ public struct OverviewView: View { index += 1 } } - return result } private func shouldShowCard(_ card: DashboardCard) -> Bool { switch card { - case .status, .connections, .traffic, .trafficTotal: + case .status, .connections, .traffic, .trafficTotal, .clashMode: return ApplicationLibrary.inPreview || profile.status.isConnected case .httpProxy: return (ApplicationLibrary.inPreview || profile.status.isConnectedStrict) && systemProxyAvailable - case .clashMode: - return ApplicationLibrary.inPreview || profile.status.isConnected case .profile: return true } @@ -132,8 +121,8 @@ public struct OverviewView: View { HTTPProxyCard( systemProxyAvailable: $systemProxyAvailable, systemProxyEnabled: $systemProxyEnabled - ) { newValue in - await viewModel.setSystemProxyEnabled(newValue, profile: profile) + ) { enabled in + await coordinator.setSystemProxyEnabled(enabled, profile: profile) } case .clashMode: ClashModeCard() @@ -141,7 +130,15 @@ public struct OverviewView: View { case .profile: ProfileCard( profileList: $profileList, - selectedProfileID: selectedProfileIDLocal + selectedProfileID: Binding( + get: { selectedProfileID }, + set: { newID in + coordinator.reasserting = true + Task { + await coordinator.switchProfile(newID, profile: profile, environments: environments) + } + } + ) ) } } diff --git a/ApplicationLibrary/Views/Dashboard/OverviewViewModel+Cards.swift b/ApplicationLibrary/Views/Dashboard/OverviewViewModel+Cards.swift deleted file mode 100644 index 305398d..0000000 --- a/ApplicationLibrary/Views/Dashboard/OverviewViewModel+Cards.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation -import Library - -extension OverviewViewModel { - func loadEnabledCards() async -> [DashboardCard] { - let savedCards = await SharedPreferences.enabledDashboardCards.get() - if savedCards.isEmpty { - return DashboardCard.defaultCards - } - return savedCards.compactMap { DashboardCard(rawValue: $0) } - } - - func loadCardOrder() async -> [DashboardCard] { - let savedOrder = await SharedPreferences.dashboardCardOrder.get() - if savedOrder.isEmpty { - return DashboardCard.defaultOrder - } - return savedOrder.compactMap { DashboardCard(rawValue: $0) } - } - - func saveEnabledCards(_ cards: [DashboardCard]) async { - await SharedPreferences.enabledDashboardCards.set(cards.map(\.rawValue)) - } - - func saveCardOrder(_ cards: [DashboardCard]) async { - await SharedPreferences.dashboardCardOrder.set(cards.map(\.rawValue)) - } - - func isCardEnabled(_ card: DashboardCard, in enabledCards: [DashboardCard]) -> Bool { - enabledCards.contains(card) - } - - func toggleCard(_ card: DashboardCard, enabledCards: [DashboardCard]) async -> [DashboardCard] { - var newEnabledCards = enabledCards - if newEnabledCards.contains(card) { - newEnabledCards.removeAll { $0 == card } - } else { - newEnabledCards.append(card) - } - await saveEnabledCards(newEnabledCards) - return newEnabledCards - } - - func resetCardsToDefault() async { - await SharedPreferences.enabledDashboardCards.set([]) - await SharedPreferences.dashboardCardOrder.set([]) - } - - func getOrderedEnabledCards(enabledCards: [DashboardCard], order: [DashboardCard]) -> [DashboardCard] { - order.filter { enabledCards.contains($0) } - } -} diff --git a/ApplicationLibrary/Views/EnvironmentValues.swift b/ApplicationLibrary/Views/EnvironmentValues.swift index 7daaa43..fbf1829 100644 --- a/ApplicationLibrary/Views/EnvironmentValues.swift +++ b/ApplicationLibrary/Views/EnvironmentValues.swift @@ -55,4 +55,17 @@ public extension EnvironmentValues { self[importProfileKey.self] = newValue } } + + private struct cardConfigurationVersionKey: EnvironmentKey { + static var defaultValue: Int = 0 + } + + var cardConfigurationVersion: Int { + get { + self[cardConfigurationVersionKey.self] + } + set { + self[cardConfigurationVersionKey.self] = newValue + } + } } diff --git a/ApplicationLibrary/Views/Log/LogViewModel.swift b/ApplicationLibrary/Views/Log/LogViewModel.swift index 6599e08..3fa5dff 100644 --- a/ApplicationLibrary/Views/Log/LogViewModel.swift +++ b/ApplicationLibrary/Views/Log/LogViewModel.swift @@ -42,7 +42,7 @@ public class LogViewModel: ObservableObject { ) .receive(on: DispatchQueue.main) .sink { [weak self] logList, defaultLogLevel, selectedLogLevel, searchText in - guard let self = self else { return } + guard let self else { return } let effectiveLevel = selectedLogLevel ?? defaultLogLevel // Check if we can do incremental filtering diff --git a/Localizable.xcstrings b/Localizable.xcstrings index a474f02..9f39564 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -227,7 +227,14 @@ } }, "Clash Mode" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clash 模式" + } + } + } }, "Clear Logs" : { "comment" : "Clear all logs", @@ -381,7 +388,14 @@ } }, "Dashboard Items" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仪表项" + } + } + } }, "Data Size" : { "localizations" : { @@ -1059,7 +1073,14 @@ } }, "Mode" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "模式" + } + } + } }, "Name" : { "localizations" : { @@ -1161,6 +1182,16 @@ } } }, + "Others" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "其他" + } + } + } + }, "Outbound" : { "localizations" : { "zh-Hans" : { @@ -1223,7 +1254,15 @@ } }, "Pause" : { - "comment" : "Pause log auto-scroll" + "comment" : "Pause log auto-scroll", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂停" + } + } + } }, "Please grant the permission for **SFMExtension**, then we can continue." : { "localizations" : { @@ -1347,12 +1386,17 @@ } } } - }, - "Reset to Default" : { - }, "Resume" : { - "comment" : "Resume log auto-scroll" + "comment" : "Resume log auto-scroll", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "恢复" + } + } + } }, "Save" : { "localizations" : { @@ -1365,7 +1409,14 @@ } }, "Search" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索" + } + } + } }, "Select Device" : { "localizations" : { @@ -1581,7 +1632,14 @@ } }, "System HTTP Proxy" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "系统 HTTP 代理" + } + } + } }, "System: " : { "shouldTranslate" : false diff --git a/MacLibrary/MainView.swift b/MacLibrary/MainView.swift index 4dd2cb3..bdece27 100644 --- a/MacLibrary/MainView.swift +++ b/MacLibrary/MainView.swift @@ -7,6 +7,8 @@ public struct MainView: View { @Environment(\.controlActiveState) private var controlActiveState @EnvironmentObject private var environments: ExtensionEnvironments @StateObject private var viewModel = MainViewModel() + @State private var showCardManagement = false + @State private var cardConfigurationVersion = 0 public init() {} @@ -19,6 +21,7 @@ public struct MainView: View { viewModel.selection.contentView .navigationTitle(viewModel.selection.title) } + .environment(\.cardConfigurationVersion, cardConfigurationVersion) .navigationSplitViewColumnWidth(650) } .frame(minHeight: 500) @@ -32,7 +35,11 @@ public struct MainView: View { } if viewModel.selection == .dashboard { ToolbarItem(placement: .automatic) { - DashboardMenu() + Button { + showCardManagement = true + } label: { + Label("Dashboard Items", systemImage: "square.grid.2x2") + } } } } @@ -50,5 +57,9 @@ public struct MainView: View { .environment(\.importRemoteProfile, $viewModel.importRemoteProfile) .handlesExternalEvents(preferring: [], allowing: ["*"]) .onOpenURL(perform: viewModel.openURL) + .sheet(isPresented: $showCardManagement) { + CardManagementSheet(configurationVersion: $cardConfigurationVersion) + .presentationDetents([.medium, .large]) + } } }