Refactor Dashboard
This commit is contained in:
@@ -5,49 +5,91 @@ import SwiftUI
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public struct ActiveDashboardView: View {
|
public struct ActiveDashboardView: View {
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@Environment(\.selection) private var parentSelection
|
|
||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
@EnvironmentObject private var profile: ExtensionProfile
|
@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 {
|
public var body: some View {
|
||||||
if viewModel.isLoading {
|
if coordinator.isLoading {
|
||||||
ProgressView().onAppear {
|
ProgressView()
|
||||||
viewModel.onEmptyProfilesChange = { isEmpty in
|
|
||||||
environments.emptyProfiles = isEmpty
|
|
||||||
}
|
|
||||||
Task {
|
|
||||||
await viewModel.reload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if ApplicationLibrary.inPreview {
|
|
||||||
body1
|
|
||||||
} else {
|
|
||||||
body1
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task {
|
coordinator.onEmptyProfilesChange = { environments.emptyProfiles = $0 }
|
||||||
await viewModel.reloadSystemProxy()
|
Task { await coordinator.reload() }
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChangeCompat(of: profile.status) { newStatus in
|
|
||||||
if newStatus == .connected {
|
|
||||||
Task {
|
|
||||||
await viewModel.reloadSystemProxy()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} 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 {
|
VStack {
|
||||||
#if os(iOS) || os(tvOS)
|
#if os(iOS) || os(tvOS)
|
||||||
if ApplicationLibrary.inPreview || profile.status.isConnectedStrict {
|
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
|
ForEach(DashboardPage.enabledCases()) { page in
|
||||||
page.label
|
page.label
|
||||||
}
|
}
|
||||||
@@ -57,9 +99,19 @@ public struct ActiveDashboardView: View {
|
|||||||
.padding([.leading, .trailing])
|
.padding([.leading, .trailing])
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
TabView(selection: $viewModel.selection) {
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var pageContent: some View {
|
||||||
|
TabView(selection: $coordinator.selection) {
|
||||||
ForEach(DashboardPage.enabledCases()) { page in
|
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)
|
.tag(page)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,71 +119,53 @@ public struct ActiveDashboardView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
.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
|
#endif
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var overviewPage: some View {
|
||||||
|
OverviewView(
|
||||||
|
$coordinator.profileList,
|
||||||
|
$coordinator.selectedProfileID,
|
||||||
|
$coordinator.systemProxyAvailable,
|
||||||
|
$coordinator.systemProxyEnabled,
|
||||||
|
cardConfigurationVersion: externalCardConfigurationVersion ?? cardConfigurationVersion
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS) || os(tvOS)
|
#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) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
StartStopButton()
|
StartStopButton()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modifier(DashboardMenuToolbarModifier(selection: viewModel.selection))
|
|
||||||
#endif
|
#endif
|
||||||
.onAppear {
|
|
||||||
if ApplicationLibrary.inPreview {
|
|
||||||
environments.commandClient.connect()
|
|
||||||
} else {
|
|
||||||
environments.connect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS) || os(tvOS)
|
#if os(iOS) || os(tvOS)
|
||||||
private struct DashboardMenuToolbarModifier: ViewModifier {
|
@available(iOS 16.0, tvOS 17.0, *)
|
||||||
let selection: DashboardPage
|
@ViewBuilder
|
||||||
|
private var cardManagementButton: some View {
|
||||||
func body(content: Content) -> some View {
|
Menu {
|
||||||
if #available(iOS 16.0, tvOS 17.0, *) {
|
Button {
|
||||||
content.toolbar {
|
showCardManagement = true
|
||||||
if selection == .overview {
|
} label: {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
Label("Dashboard Items", systemImage: "square.grid.2x2")
|
||||||
DashboardMenu()
|
|
||||||
}
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Others", systemImage: "ellipsis.circle")
|
||||||
}
|
}
|
||||||
}
|
.sheet(isPresented: $showCardManagement) {
|
||||||
} else {
|
CardManagementSheet(configurationVersion: $cardConfigurationVersion)
|
||||||
content
|
.presentationDetents([.medium, .large])
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#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) {
|
DashboardCardView(title: "Connections", isHalfWidth: true) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if ApplicationLibrary.inPreview {
|
if ApplicationLibrary.inPreview {
|
||||||
CardLine(String(localized: "Inbound"), "34")
|
DashboardCardLine(String(localized: "Inbound"), "34")
|
||||||
CardLine(String(localized: "Outbound"), "28")
|
DashboardCardLine(String(localized: "Outbound"), "28")
|
||||||
} else if let message = commandClient.status {
|
} else if let message = commandClient.status {
|
||||||
CardLine(String(localized: "Inbound"), "\(message.connectionsIn)")
|
DashboardCardLine(String(localized: "Inbound"), "\(message.connectionsIn)")
|
||||||
CardLine(String(localized: "Outbound"), "\(message.connectionsOut)")
|
DashboardCardLine(String(localized: "Outbound"), "\(message.connectionsOut)")
|
||||||
} else {
|
} else {
|
||||||
CardLine(String(localized: "Inbound"), "...")
|
DashboardCardLine(String(localized: "Inbound"), "...")
|
||||||
CardLine(String(localized: "Outbound"), "...")
|
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) {
|
DashboardCardView(title: "Status", isHalfWidth: true) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if ApplicationLibrary.inPreview {
|
if ApplicationLibrary.inPreview {
|
||||||
CardLine(String(localized: "Memory"), "6.4 MB")
|
DashboardCardLine(String(localized: "Memory"), "6.4 MB")
|
||||||
CardLine(String(localized: "Goroutines"), "89")
|
DashboardCardLine(String(localized: "Goroutines"), "89")
|
||||||
} else if let message = commandClient.status {
|
} else if let message = commandClient.status {
|
||||||
CardLine(String(localized: "Memory"), LibboxFormatMemoryBytes(message.memory))
|
DashboardCardLine(String(localized: "Memory"), LibboxFormatMemoryBytes(message.memory))
|
||||||
CardLine(String(localized: "Goroutines"), "\(message.goroutines)")
|
DashboardCardLine(String(localized: "Goroutines"), "\(message.goroutines)")
|
||||||
} else {
|
} else {
|
||||||
CardLine(String(localized: "Memory"), "...")
|
DashboardCardLine(String(localized: "Memory"), "...")
|
||||||
CardLine(String(localized: "Goroutines"), "...")
|
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) {
|
DashboardCardView(title: "Traffic", isHalfWidth: true) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if ApplicationLibrary.inPreview {
|
if ApplicationLibrary.inPreview {
|
||||||
CardLine(String(localized: "Uplink"), "38 B/s")
|
DashboardCardLine(String(localized: "Uplink"), "38 B/s")
|
||||||
CardLine(String(localized: "Downlink"), "249 MB/s")
|
DashboardCardLine(String(localized: "Downlink"), "249 MB/s")
|
||||||
} else if let message = commandClient.status, message.trafficAvailable {
|
} else if let message = commandClient.status, message.trafficAvailable {
|
||||||
CardLine(String(localized: "Uplink"), "\(LibboxFormatBytes(message.uplink))/s")
|
DashboardCardLine(String(localized: "Uplink"), "\(LibboxFormatBytes(message.uplink))/s")
|
||||||
CardLine(String(localized: "Downlink"), "\(LibboxFormatBytes(message.downlink))/s")
|
DashboardCardLine(String(localized: "Downlink"), "\(LibboxFormatBytes(message.downlink))/s")
|
||||||
} else {
|
} else {
|
||||||
CardLine(String(localized: "Uplink"), "...")
|
DashboardCardLine(String(localized: "Uplink"), "...")
|
||||||
CardLine(String(localized: "Downlink"), "...")
|
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) {
|
DashboardCardView(title: "Traffic Total", isHalfWidth: true) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if ApplicationLibrary.inPreview {
|
if ApplicationLibrary.inPreview {
|
||||||
CardLine(String(localized: "Uplink"), "52 MB")
|
DashboardCardLine(String(localized: "Uplink"), "52 MB")
|
||||||
CardLine(String(localized: "Downlink"), "5.6 GB")
|
DashboardCardLine(String(localized: "Downlink"), "5.6 GB")
|
||||||
} else if let message = commandClient.status, message.trafficAvailable {
|
} else if let message = commandClient.status, message.trafficAvailable {
|
||||||
CardLine(String(localized: "Uplink"), LibboxFormatBytes(message.uplinkTotal))
|
DashboardCardLine(String(localized: "Uplink"), LibboxFormatBytes(message.uplinkTotal))
|
||||||
CardLine(String(localized: "Downlink"), LibboxFormatBytes(message.downlinkTotal))
|
DashboardCardLine(String(localized: "Downlink"), LibboxFormatBytes(message.downlinkTotal))
|
||||||
} else {
|
} else {
|
||||||
CardLine(String(localized: "Uplink"), "...")
|
DashboardCardLine(String(localized: "Uplink"), "...")
|
||||||
CardLine(String(localized: "Downlink"), "...")
|
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)
|
.labelStyle(.iconOnly)
|
||||||
} else if let profile = environments.extensionProfile {
|
} else if let profile = environments.extensionProfile {
|
||||||
Button0().environmentObject(profile)
|
ToggleConnectionButton().environmentObject(profile)
|
||||||
} else {
|
} else {
|
||||||
Button {} label: {
|
Button {} label: {
|
||||||
Label("Start", systemImage: "play.fill")
|
Label("Start", systemImage: "play.fill")
|
||||||
@@ -28,7 +28,7 @@ public struct StartStopButton: View {
|
|||||||
.disabled(environments.emptyProfiles)
|
.disabled(environments.emptyProfiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct Button0: View {
|
private struct ToggleConnectionButton: View {
|
||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
@EnvironmentObject private var profile: ExtensionProfile
|
@EnvironmentObject private var profile: ExtensionProfile
|
||||||
@State private var alert: Alert?
|
@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
|
@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 {
|
viewBuilder {
|
||||||
switch self {
|
switch self {
|
||||||
case .overview:
|
case .overview:
|
||||||
OverviewView(profileList, selectedProfileID, systemProxyAvailable, systemProxyEnabled)
|
OverviewView(profileList, selectedProfileID, systemProxyAvailable, systemProxyEnabled, cardConfigurationVersion: cardConfigurationVersion)
|
||||||
case .groups:
|
case .groups:
|
||||||
GroupListView()
|
GroupListView()
|
||||||
case .connections:
|
case .connections:
|
||||||
|
|||||||
@@ -4,63 +4,63 @@ import SwiftUI
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public struct DashboardView: View {
|
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)
|
#if os(macOS)
|
||||||
@Environment(\.controlActiveState) private var controlActiveState
|
@Environment(\.controlActiveState) private var controlActiveState
|
||||||
@StateObject private var viewModel = DashboardViewModel()
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
viewBuilder {
|
content
|
||||||
|
.onAppear {
|
||||||
|
coordinator.setOpenURL { openURL($0) }
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
if Variant.useSystemExtension {
|
Task { await coordinator.reload() }
|
||||||
viewBuilder {
|
#endif
|
||||||
if !viewModel.systemExtensionInstalled {
|
}
|
||||||
|
#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 {
|
FormView {
|
||||||
InstallSystemExtensionButton {
|
InstallSystemExtensionButton {
|
||||||
await viewModel.reload()
|
await coordinator.reload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
DashboardView0()
|
mainContent
|
||||||
}
|
|
||||||
}.onAppear {
|
|
||||||
Task {
|
|
||||||
await viewModel.reload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
DashboardView0()
|
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
DashboardView0()
|
mainContent
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#if os(macOS)
|
|
||||||
.onChangeCompat(of: controlActiveState) { newValue in
|
|
||||||
if newValue != .inactive {
|
|
||||||
if Variant.useSystemExtension {
|
|
||||||
if !viewModel.isLoading {
|
|
||||||
Task {
|
|
||||||
await viewModel.reload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DashboardView0: View {
|
@ViewBuilder
|
||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
private var mainContent: some View {
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if ApplicationLibrary.inPreview {
|
if ApplicationLibrary.inPreview {
|
||||||
ActiveDashboardView()
|
ActiveDashboardView(externalCardConfigurationVersion: cardConfigurationVersion)
|
||||||
} else if environments.extensionProfileLoading {
|
} else if environments.extensionProfileLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
} else if let profile = environments.extensionProfile {
|
} 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 {
|
} else {
|
||||||
FormView {
|
FormView {
|
||||||
InstallProfileButton {
|
InstallProfileButton {
|
||||||
@@ -70,26 +70,3 @@ 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
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class OverviewViewModel: ObservableObject {
|
public final class OverviewCoordinator: ObservableObject {
|
||||||
@Published var alert: Alert?
|
@Published public var alert: Alert?
|
||||||
@Published var reasserting = false
|
@Published public var reasserting = false
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
func switchProfile(_ newProfileID: Int64, profile: ExtensionProfile, environments: ExtensionEnvironments) async {
|
public func switchProfile(_ profileID: Int64, profile: ExtensionProfile, environments: ExtensionEnvironments) async {
|
||||||
await SharedPreferences.selectedProfileID.set(newProfileID)
|
await SharedPreferences.selectedProfileID.set(profileID)
|
||||||
environments.selectedProfileUpdate.send()
|
environments.selectedProfileUpdate.send()
|
||||||
|
|
||||||
if profile.status.isConnected {
|
if profile.status.isConnected {
|
||||||
do {
|
do {
|
||||||
try await serviceReload()
|
try await serviceReload()
|
||||||
@@ -23,21 +24,19 @@ public final class OverviewViewModel: ObservableObject {
|
|||||||
reasserting = false
|
reasserting = false
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func serviceReload() async throws {
|
public nonisolated func serviceReload() async throws {
|
||||||
try LibboxNewStandaloneCommandClient()!.serviceReload()
|
try LibboxNewStandaloneCommandClient()!.serviceReload()
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func setSystemProxyEnabled(_ isEnabled: Bool, profile: ExtensionProfile) async {
|
public nonisolated func setSystemProxyEnabled(_ enabled: Bool, profile: ExtensionProfile) async {
|
||||||
do {
|
do {
|
||||||
await SharedPreferences.systemProxyEnabled.set(isEnabled)
|
await SharedPreferences.systemProxyEnabled.set(enabled)
|
||||||
if isEnabled {
|
if enabled {
|
||||||
try LibboxNewStandaloneCommandClient()!.setSystemProxyEnabled(isEnabled)
|
try LibboxNewStandaloneCommandClient()!.setSystemProxyEnabled(enabled)
|
||||||
} else {
|
} 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()
|
try await profile.stop()
|
||||||
|
|
||||||
var waitSeconds = 0
|
var waitSeconds = 0
|
||||||
while await profile.status != .disconnected {
|
while await profile.status != .disconnected {
|
||||||
try await Task.sleep(nanoseconds: NSEC_PER_SEC)
|
try await Task.sleep(nanoseconds: NSEC_PER_SEC)
|
||||||
@@ -47,14 +46,10 @@ public final class OverviewViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try await profile.start()
|
try await profile.start()
|
||||||
await MainActor.run {
|
await MainActor.run { reasserting = false }
|
||||||
reasserting = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run { alert = Alert(error) }
|
||||||
alert = Alert(error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+29
-32
@@ -5,32 +5,29 @@ import SwiftUI
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public struct OverviewView: View {
|
public struct OverviewView: View {
|
||||||
@Environment(\.selection) private var selection
|
|
||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
@EnvironmentObject private var profile: ExtensionProfile
|
@EnvironmentObject private var profile: ExtensionProfile
|
||||||
|
@StateObject private var coordinator = OverviewCoordinator()
|
||||||
|
@StateObject private var configuration = DashboardCardConfiguration()
|
||||||
|
|
||||||
@Binding private var profileList: [ProfilePreview]
|
@Binding private var profileList: [ProfilePreview]
|
||||||
@Binding private var selectedProfileID: Int64
|
@Binding private var selectedProfileID: Int64
|
||||||
@Binding private var systemProxyAvailable: Bool
|
@Binding private var systemProxyAvailable: Bool
|
||||||
@Binding private var systemProxyEnabled: Bool
|
@Binding private var systemProxyEnabled: Bool
|
||||||
@StateObject private var viewModel = OverviewViewModel()
|
private let cardConfigurationVersion: Int
|
||||||
|
|
||||||
@State private var enabledCards: [DashboardCard] = []
|
public init(
|
||||||
@State private var cardOrder: [DashboardCard] = []
|
_ profileList: Binding<[ProfilePreview]>,
|
||||||
|
_ selectedProfileID: Binding<Int64>,
|
||||||
private var selectedProfileIDLocal: Binding<Int64> {
|
_ systemProxyAvailable: Binding<Bool>,
|
||||||
$selectedProfileID.withSetter { newValue in
|
_ systemProxyEnabled: Binding<Bool>,
|
||||||
viewModel.reasserting = true
|
cardConfigurationVersion: Int
|
||||||
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>) {
|
|
||||||
_profileList = profileList
|
_profileList = profileList
|
||||||
_selectedProfileID = selectedProfileID
|
_selectedProfileID = selectedProfileID
|
||||||
_systemProxyAvailable = systemProxyAvailable
|
_systemProxyAvailable = systemProxyAvailable
|
||||||
_systemProxyEnabled = systemProxyEnabled
|
_systemProxyEnabled = systemProxyEnabled
|
||||||
|
self.cardConfigurationVersion = cardConfigurationVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
@@ -49,20 +46,16 @@ public struct OverviewView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onChangeCompat(of: cardConfigurationVersion) { _ in
|
||||||
Task {
|
Task { await configuration.reload() }
|
||||||
enabledCards = await viewModel.loadEnabledCards()
|
|
||||||
cardOrder = await viewModel.loadCardOrder()
|
|
||||||
}
|
}
|
||||||
}
|
.alertBinding($coordinator.alert)
|
||||||
.alertBinding($viewModel.alert)
|
.disabled(!ApplicationLibrary.inPreview && (!profile.status.isSwitchable || coordinator.reasserting))
|
||||||
.disabled(!ApplicationLibrary.inPreview && (!profile.status.isSwitchable || viewModel.reasserting))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var cardGrid: some View {
|
private var cardGrid: some View {
|
||||||
let orderedCards = viewModel.getOrderedEnabledCards(enabledCards: enabledCards, order: cardOrder)
|
let visibleCards = configuration.orderedEnabledCards.filter(shouldShowCard)
|
||||||
let visibleCards = orderedCards.filter { shouldShowCard($0) }
|
|
||||||
let groupedCards = groupCards(visibleCards)
|
let groupedCards = groupCards(visibleCards)
|
||||||
|
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
@@ -87,7 +80,6 @@ public struct OverviewView: View {
|
|||||||
|
|
||||||
while index < cards.count {
|
while index < cards.count {
|
||||||
let card = cards[index]
|
let card = cards[index]
|
||||||
|
|
||||||
if card.isHalfWidth, index + 1 < cards.count, cards[index + 1].isHalfWidth {
|
if card.isHalfWidth, index + 1 < cards.count, cards[index + 1].isHalfWidth {
|
||||||
result.append([card, cards[index + 1]])
|
result.append([card, cards[index + 1]])
|
||||||
index += 2
|
index += 2
|
||||||
@@ -96,18 +88,15 @@ public struct OverviewView: View {
|
|||||||
index += 1
|
index += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldShowCard(_ card: DashboardCard) -> Bool {
|
private func shouldShowCard(_ card: DashboardCard) -> Bool {
|
||||||
switch card {
|
switch card {
|
||||||
case .status, .connections, .traffic, .trafficTotal:
|
case .status, .connections, .traffic, .trafficTotal, .clashMode:
|
||||||
return ApplicationLibrary.inPreview || profile.status.isConnected
|
return ApplicationLibrary.inPreview || profile.status.isConnected
|
||||||
case .httpProxy:
|
case .httpProxy:
|
||||||
return (ApplicationLibrary.inPreview || profile.status.isConnectedStrict) && systemProxyAvailable
|
return (ApplicationLibrary.inPreview || profile.status.isConnectedStrict) && systemProxyAvailable
|
||||||
case .clashMode:
|
|
||||||
return ApplicationLibrary.inPreview || profile.status.isConnected
|
|
||||||
case .profile:
|
case .profile:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -132,8 +121,8 @@ public struct OverviewView: View {
|
|||||||
HTTPProxyCard(
|
HTTPProxyCard(
|
||||||
systemProxyAvailable: $systemProxyAvailable,
|
systemProxyAvailable: $systemProxyAvailable,
|
||||||
systemProxyEnabled: $systemProxyEnabled
|
systemProxyEnabled: $systemProxyEnabled
|
||||||
) { newValue in
|
) { enabled in
|
||||||
await viewModel.setSystemProxyEnabled(newValue, profile: profile)
|
await coordinator.setSystemProxyEnabled(enabled, profile: profile)
|
||||||
}
|
}
|
||||||
case .clashMode:
|
case .clashMode:
|
||||||
ClashModeCard()
|
ClashModeCard()
|
||||||
@@ -141,7 +130,15 @@ public struct OverviewView: View {
|
|||||||
case .profile:
|
case .profile:
|
||||||
ProfileCard(
|
ProfileCard(
|
||||||
profileList: $profileList,
|
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
|
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)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] logList, defaultLogLevel, selectedLogLevel, searchText in
|
.sink { [weak self] logList, defaultLogLevel, selectedLogLevel, searchText in
|
||||||
guard let self = self else { return }
|
guard let self else { return }
|
||||||
let effectiveLevel = selectedLogLevel ?? defaultLogLevel
|
let effectiveLevel = selectedLogLevel ?? defaultLogLevel
|
||||||
|
|
||||||
// Check if we can do incremental filtering
|
// Check if we can do incremental filtering
|
||||||
|
|||||||
+68
-10
@@ -227,7 +227,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Clash Mode" : {
|
"Clash Mode" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Clash 模式"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Clear Logs" : {
|
"Clear Logs" : {
|
||||||
"comment" : "Clear all logs",
|
"comment" : "Clear all logs",
|
||||||
@@ -381,7 +388,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Dashboard Items" : {
|
"Dashboard Items" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "仪表项"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Data Size" : {
|
"Data Size" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1059,7 +1073,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Mode" : {
|
"Mode" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "模式"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Name" : {
|
"Name" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1161,6 +1182,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Others" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "其他"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Outbound" : {
|
"Outbound" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
@@ -1223,7 +1254,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Pause" : {
|
"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." : {
|
"Please grant the permission for **SFMExtension**, then we can continue." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1347,12 +1386,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Reset to Default" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Resume" : {
|
"Resume" : {
|
||||||
"comment" : "Resume log auto-scroll"
|
"comment" : "Resume log auto-scroll",
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "恢复"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Save" : {
|
"Save" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1365,7 +1409,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Search" : {
|
"Search" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "搜索"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Select Device" : {
|
"Select Device" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1581,7 +1632,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"System HTTP Proxy" : {
|
"System HTTP Proxy" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "系统 HTTP 代理"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"System: " : {
|
"System: " : {
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ public struct MainView: View {
|
|||||||
@Environment(\.controlActiveState) private var controlActiveState
|
@Environment(\.controlActiveState) private var controlActiveState
|
||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
@StateObject private var viewModel = MainViewModel()
|
@StateObject private var viewModel = MainViewModel()
|
||||||
|
@State private var showCardManagement = false
|
||||||
|
@State private var cardConfigurationVersion = 0
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ public struct MainView: View {
|
|||||||
viewModel.selection.contentView
|
viewModel.selection.contentView
|
||||||
.navigationTitle(viewModel.selection.title)
|
.navigationTitle(viewModel.selection.title)
|
||||||
}
|
}
|
||||||
|
.environment(\.cardConfigurationVersion, cardConfigurationVersion)
|
||||||
.navigationSplitViewColumnWidth(650)
|
.navigationSplitViewColumnWidth(650)
|
||||||
}
|
}
|
||||||
.frame(minHeight: 500)
|
.frame(minHeight: 500)
|
||||||
@@ -32,7 +35,11 @@ public struct MainView: View {
|
|||||||
}
|
}
|
||||||
if viewModel.selection == .dashboard {
|
if viewModel.selection == .dashboard {
|
||||||
ToolbarItem(placement: .automatic) {
|
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)
|
.environment(\.importRemoteProfile, $viewModel.importRemoteProfile)
|
||||||
.handlesExternalEvents(preferring: [], allowing: ["*"])
|
.handlesExternalEvents(preferring: [], allowing: ["*"])
|
||||||
.onOpenURL(perform: viewModel.openURL)
|
.onOpenURL(perform: viewModel.openURL)
|
||||||
|
.sheet(isPresented: $showCardManagement) {
|
||||||
|
CardManagementSheet(configurationVersion: $cardConfigurationVersion)
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user