Refactor Dashboard
This commit is contained in:
@@ -5,49 +5,91 @@ 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
|
||||
}
|
||||
Task {
|
||||
await viewModel.reload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ApplicationLibrary.inPreview {
|
||||
body1
|
||||
} else {
|
||||
body1
|
||||
if coordinator.isLoading {
|
||||
ProgressView()
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.reloadSystemProxy()
|
||||
}
|
||||
}
|
||||
.onChangeCompat(of: profile.status) { newStatus in
|
||||
if newStatus == .connected {
|
||||
Task {
|
||||
await viewModel.reloadSystemProxy()
|
||||
}
|
||||
coordinator.onEmptyProfilesChange = { environments.emptyProfiles = $0 }
|
||||
Task { await coordinator.reload() }
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
pageSelector
|
||||
pageContent
|
||||
} else {
|
||||
overviewPage
|
||||
}
|
||||
#else
|
||||
overviewPage
|
||||
#endif
|
||||
}
|
||||
#if os(iOS) || os(tvOS)
|
||||
.toolbar { toolbar }
|
||||
#endif
|
||||
.onAppear {
|
||||
if ApplicationLibrary.inPreview {
|
||||
environments.commandClient.connect()
|
||||
} else {
|
||||
environments.connect()
|
||||
}
|
||||
}
|
||||
.onChangeCompat(of: scenePhase) { phase in
|
||||
guard phase == .active else { return }
|
||||
environments.connect()
|
||||
}
|
||||
.onChangeCompat(of: profile.status) { status in
|
||||
guard status.isConnected else { return }
|
||||
environments.connect()
|
||||
}
|
||||
.onReceive(environments.profileUpdate) { _ in
|
||||
Task { await coordinator.reload() }
|
||||
}
|
||||
.onReceive(environments.selectedProfileUpdate) { _ in
|
||||
Task {
|
||||
await coordinator.updateSelectedProfile()
|
||||
if profile.status.isConnected {
|
||||
await coordinator.reloadSystemProxy()
|
||||
}
|
||||
}
|
||||
}
|
||||
.alertBinding($coordinator.alert)
|
||||
}
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
@ViewBuilder
|
||||
private var pageSelector: some View {
|
||||
Picker("Page", selection: $coordinator.selection) {
|
||||
ForEach(DashboardPage.enabledCases()) { page in
|
||||
page.label
|
||||
}
|
||||
@@ -57,9 +99,19 @@ public struct ActiveDashboardView: View {
|
||||
.padding([.leading, .trailing])
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
TabView(selection: $viewModel.selection) {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var pageContent: some View {
|
||||
TabView(selection: $coordinator.selection) {
|
||||
ForEach(DashboardPage.enabledCases()) { page in
|
||||
page.contentView($viewModel.profileList, $viewModel.selectedProfileID, $viewModel.systemProxyAvailable, $viewModel.systemProxyEnabled)
|
||||
page.contentView(
|
||||
$coordinator.profileList,
|
||||
$coordinator.selectedProfileID,
|
||||
$coordinator.systemProxyAvailable,
|
||||
$coordinator.systemProxyEnabled,
|
||||
externalCardConfigurationVersion ?? cardConfigurationVersion
|
||||
)
|
||||
.tag(page)
|
||||
}
|
||||
}
|
||||
@@ -67,71 +119,53 @@ public struct ActiveDashboardView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
} else {
|
||||
OverviewView($viewModel.profileList, $viewModel.selectedProfileID, $viewModel.systemProxyAvailable, $viewModel.systemProxyEnabled)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
OverviewView($viewModel.profileList, $viewModel.selectedProfileID, $viewModel.systemProxyAvailable, $viewModel.systemProxyEnabled)
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
private var overviewPage: some View {
|
||||
OverviewView(
|
||||
$coordinator.profileList,
|
||||
$coordinator.selectedProfileID,
|
||||
$coordinator.systemProxyAvailable,
|
||||
$coordinator.systemProxyEnabled,
|
||||
cardConfigurationVersion: externalCardConfigurationVersion ?? cardConfigurationVersion
|
||||
)
|
||||
}
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
.toolbar {
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if coordinator.selection == .overview {
|
||||
if #available(iOS 16.0, tvOS 17.0, *) {
|
||||
cardManagementButton
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
StartStopButton()
|
||||
}
|
||||
}
|
||||
.modifier(DashboardMenuToolbarModifier(selection: viewModel.selection))
|
||||
#endif
|
||||
.onAppear {
|
||||
if ApplicationLibrary.inPreview {
|
||||
environments.commandClient.connect()
|
||||
} else {
|
||||
environments.connect()
|
||||
|
||||
#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])
|
||||
}
|
||||
}
|
||||
.onChangeCompat(of: scenePhase) { newPhase in
|
||||
if newPhase == .active {
|
||||
environments.connect()
|
||||
}
|
||||
}
|
||||
.onChangeCompat(of: profile.status) { newStatus in
|
||||
if newStatus.isConnected {
|
||||
environments.connect()
|
||||
}
|
||||
}
|
||||
.onReceive(environments.profileUpdate) { _ in
|
||||
Task {
|
||||
await viewModel.reload()
|
||||
}
|
||||
}
|
||||
.onReceive(environments.selectedProfileUpdate) { _ in
|
||||
Task {
|
||||
await viewModel.updateSelectedProfile()
|
||||
if profile.status.isConnected {
|
||||
await viewModel.reloadSystemProxy()
|
||||
}
|
||||
}
|
||||
}
|
||||
.alertBinding($viewModel.alert)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
private struct DashboardMenuToolbarModifier: ViewModifier {
|
||||
let selection: DashboardPage
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 16.0, tvOS 17.0, *) {
|
||||
content.toolbar {
|
||||
if selection == .overview {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
DashboardMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<Int>) {
|
||||
_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<Bool> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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?
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -51,11 +51,11 @@ public extension DashboardPage {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func contentView(_ profileList: Binding<[ProfilePreview]>, _ selectedProfileID: Binding<Int64>, _ systemProxyAvailable: Binding<Bool>, _ systemProxyEnabled: Binding<Bool>) -> some View {
|
||||
func contentView(_ profileList: Binding<[ProfilePreview]>, _ selectedProfileID: Binding<Int64>, _ systemProxyAvailable: Binding<Bool>, _ systemProxyEnabled: Binding<Bool>, _ 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:
|
||||
|
||||
@@ -4,63 +4,63 @@ 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 {
|
||||
content
|
||||
.onAppear {
|
||||
coordinator.setOpenURL { openURL($0) }
|
||||
#if os(macOS)
|
||||
if Variant.useSystemExtension {
|
||||
viewBuilder {
|
||||
if !viewModel.systemExtensionInstalled {
|
||||
Task { await coordinator.reload() }
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.onChangeCompat(of: controlActiveState) { state in
|
||||
guard state != .inactive, Variant.useSystemExtension, !coordinator.isLoading else { return }
|
||||
Task { await coordinator.reload() }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
#if os(macOS)
|
||||
if Variant.useSystemExtension, !coordinator.systemExtensionInstalled {
|
||||
FormView {
|
||||
InstallSystemExtensionButton {
|
||||
await viewModel.reload()
|
||||
await coordinator.reload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DashboardView0()
|
||||
}
|
||||
}.onAppear {
|
||||
Task {
|
||||
await viewModel.reload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DashboardView0()
|
||||
mainContent
|
||||
}
|
||||
#else
|
||||
DashboardView0()
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.onChangeCompat(of: controlActiveState) { newValue in
|
||||
if newValue != .inactive {
|
||||
if Variant.useSystemExtension {
|
||||
if !viewModel.isLoading {
|
||||
Task {
|
||||
await viewModel.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mainContent
|
||||
#endif
|
||||
}
|
||||
|
||||
struct DashboardView0: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
|
||||
var body: some View {
|
||||
@ViewBuilder
|
||||
private var mainContent: some View {
|
||||
if ApplicationLibrary.inPreview {
|
||||
ActiveDashboardView()
|
||||
ActiveDashboardView(externalCardConfigurationVersion: cardConfigurationVersion)
|
||||
} else if environments.extensionProfileLoading {
|
||||
ProgressView()
|
||||
} else if let profile = environments.extensionProfile {
|
||||
DashboardView1().environmentObject(profile)
|
||||
ActiveDashboardView(externalCardConfigurationVersion: cardConfigurationVersion)
|
||||
.environmentObject(profile)
|
||||
.alertBinding($coordinator.alert)
|
||||
.onChangeCompat(of: profile.status) { status in
|
||||
coordinator.handleStatusChange(status, profile: profile)
|
||||
}
|
||||
} else {
|
||||
FormView {
|
||||
InstallProfileButton {
|
||||
@@ -69,27 +69,4 @@ public struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
.onChangeCompat(of: profile.status) { newValue in
|
||||
viewModel.handleStatusChange(newValue, profile: profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+15
-20
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-32
@@ -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<Int64> {
|
||||
$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<Int64>, _ systemProxyAvailable: Binding<Bool>, _ systemProxyEnabled: Binding<Bool>) {
|
||||
public init(
|
||||
_ profileList: Binding<[ProfilePreview]>,
|
||||
_ selectedProfileID: Binding<Int64>,
|
||||
_ systemProxyAvailable: Binding<Bool>,
|
||||
_ systemProxyEnabled: Binding<Bool>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+68
-10
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user