diff --git a/ApplicationLibrary/Views/Connections/ConnectionListView.swift b/ApplicationLibrary/Views/Connections/ConnectionListView.swift index 84f84a2..9c79724 100644 --- a/ApplicationLibrary/Views/Connections/ConnectionListView.swift +++ b/ApplicationLibrary/Views/Connections/ConnectionListView.swift @@ -1,30 +1,23 @@ -import Libbox import Library import SwiftUI @MainActor public struct ConnectionListView: View { @Environment(\.scenePhase) private var scenePhase - @State private var isLoading = true - @StateObject private var commandClient = CommandClient(.connections) - @State private var connections: [Connection] = [] - @State private var searchText = "" - @State private var alert: Alert? + @StateObject private var viewModel = ConnectionListViewModel() public init() {} public var body: some View { VStack { - if isLoading { + if viewModel.isLoading { Text("Loading...") } else { - if connections.isEmpty { + if viewModel.connections.isEmpty { Text("Empty connections") } else { ScrollView { LazyVGrid(columns: [GridItem(.flexible())], alignment: .leading) { - ForEach(connections.filter { it in - searchText == "" || it.performSearch(searchText) - }, id: \.hashValue) { it in + ForEach(viewModel.filteredConnections(), id: \.hashValue) { it in ConnectionView(it) } } @@ -37,24 +30,20 @@ public struct ConnectionListView: View { .toolbar { ToolbarItem { Menu { - Picker("State", selection: $commandClient.connectionStateFilter) { + Picker("State", selection: $viewModel.connectionStateFilter) { ForEach(ConnectionStateFilter.allCases) { state in Text(state.name) } } - Picker("Sort By", selection: $commandClient.connectionSort) { + Picker("Sort By", selection: $viewModel.connectionSort) { ForEach(ConnectionSort.allCases, id: \.self) { sortBy in Text(sortBy.name) } } Button("Close All Connections", role: .destructive) { - do { - try LibboxNewStandaloneCommandClient()!.closeConnections() - } catch { - alert = Alert(error) - } + viewModel.closeAllConnections() } } label: { Label("Filter", systemImage: "line.3.horizontal.circle") @@ -63,40 +52,22 @@ public struct ConnectionListView: View { } #endif #if os(macOS) - .searchable(text: $searchText) + .searchable(text: $viewModel.searchText) #endif - .alertBinding($alert) + .alertBinding($viewModel.alert) .onAppear { - connect() + viewModel.connect() } .onDisappear { - commandClient.disconnect() + viewModel.disconnect() } .onChangeCompat(of: scenePhase) { newValue in if newValue == .active { - commandClient.connect() + viewModel.connect() } else { - commandClient.disconnect() + viewModel.disconnect() } } - .onChangeCompat(of: commandClient.connectionStateFilter) { it in - commandClient.filterConnectionsNow() - Task { - await SharedPreferences.connectionStateFilter.set(it.rawValue) - } - } - .onChangeCompat(of: commandClient.connectionSort) { it in - commandClient.filterConnectionsNow() - Task { - await SharedPreferences.connectionSort.set(it.rawValue) - } - } - .onReceive(commandClient.$connections, perform: { connections in - if let connections { - self.connections = convertConnections(connections) - isLoading = false - } - }) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) #if os(iOS) .background(Color(uiColor: .systemGroupedBackground)) @@ -112,50 +83,4 @@ public struct ConnectionListView: View { return Color(uiColor: .black) #endif } - - private func connect() { - if ApplicationLibrary.inPreview { - isLoading = false - } else { - commandClient.connect() - } - } - - private func convertConnections(_ goConnections: [LibboxConnection]) -> [Connection] { - var connections = [Connection]() - for goConnection in goConnections { - if goConnection.outboundType == "dns" { - continue - } - var closedAt: Date? - if goConnection.closedAt > 0 { - closedAt = Date(timeIntervalSince1970: Double(goConnection.closedAt) / 1000) - } - connections.append(Connection( - id: goConnection.id_, - inbound: goConnection.inbound, - inboundType: goConnection.inboundType, - ipVersion: goConnection.ipVersion, - network: goConnection.network, - source: goConnection.source, - destination: goConnection.destination, - domain: goConnection.domain, - displayDestination: goConnection.displayDestination(), - protocolName: goConnection.protocol, - user: goConnection.user, - fromOutbound: goConnection.fromOutbound, - createdAt: Date(timeIntervalSince1970: Double(goConnection.createdAt) / 1000), - closedAt: closedAt, - upload: goConnection.uplink, - download: goConnection.downlink, - uploadTotal: goConnection.uplinkTotal, - downloadTotal: goConnection.downlinkTotal, - rule: goConnection.rule, - outbound: goConnection.outbound, - outboundType: goConnection.outboundType, - chain: goConnection.chain()!.toArray() - )) - } - return connections - } } diff --git a/ApplicationLibrary/Views/Connections/ConnectionListViewModel.swift b/ApplicationLibrary/Views/Connections/ConnectionListViewModel.swift new file mode 100644 index 0000000..2a9c8e8 --- /dev/null +++ b/ApplicationLibrary/Views/Connections/ConnectionListViewModel.swift @@ -0,0 +1,141 @@ +import Combine +import Libbox +import Library +import SwiftUI + +@MainActor +public class ConnectionListViewModel: ObservableObject { + @Published public var isLoading = true + @Published public var connections: [Connection] = [] + @Published public var searchText = "" + @Published public var alert: Alert? + @Published public var connectionStateFilter: ConnectionStateFilter { + didSet { + commandClient.connectionStateFilter = connectionStateFilter + commandClient.filterConnectionsNow() + saveStateFilterTask?.cancel() + saveStateFilterTask = Task { + await SharedPreferences.connectionStateFilter.set(connectionStateFilter.rawValue) + } + } + } + + @Published public var connectionSort: ConnectionSort { + didSet { + commandClient.connectionSort = connectionSort + commandClient.filterConnectionsNow() + saveSortTask?.cancel() + saveSortTask = Task { + await SharedPreferences.connectionSort.set(connectionSort.rawValue) + } + } + } + + private let commandClient = CommandClient(.connections) + private var cancellables = Set() + private var connectTask: Task? + private var saveStateFilterTask: Task? + private var saveSortTask: Task? + + public init() { + connectionStateFilter = .active + connectionSort = .byDate + + commandClient.$connections + .compactMap { $0 } + .sink { [weak self] goConnections in + self?.setConnections(goConnections) + } + .store(in: &cancellables) + } + + public func connect() { + if ApplicationLibrary.inPreview { + isLoading = false + return + } + + connectTask?.cancel() + connectTask = Task { @MainActor [weak self] in + guard let self else { return } + await self.loadPreferences() + if Task.isCancelled { return } + self.commandClient.connect() + self.connectTask = nil + } + } + + private func loadPreferences() async { + let filter = await ConnectionStateFilter(rawValue: SharedPreferences.connectionStateFilter.get()) ?? .active + let sort = await ConnectionSort(rawValue: SharedPreferences.connectionSort.get()) ?? .byDate + connectionStateFilter = filter + connectionSort = sort + } + + public func disconnect() { + connectTask?.cancel() + connectTask = nil + saveStateFilterTask?.cancel() + saveStateFilterTask = nil + saveSortTask?.cancel() + saveSortTask = nil + commandClient.disconnect() + } + + public func closeAllConnections() { + do { + try LibboxNewStandaloneCommandClient()!.closeConnections() + } catch { + alert = Alert(error) + } + } + + public func filteredConnections() -> [Connection] { + connections.filter { connection in + searchText == "" || connection.performSearch(searchText) + } + } + + private func setConnections(_ goConnections: [LibboxConnection]) { + connections = convertConnections(goConnections) + isLoading = false + } + + private func convertConnections(_ goConnections: [LibboxConnection]) -> [Connection] { + var connections = [Connection]() + for goConnection in goConnections { + if goConnection.outboundType == "dns" { + continue + } + var closedAt: Date? + if goConnection.closedAt > 0 { + closedAt = Date(timeIntervalSince1970: Double(goConnection.closedAt) / 1000) + } + connections.append(Connection( + id: goConnection.id_, + inbound: goConnection.inbound, + inboundType: goConnection.inboundType, + ipVersion: goConnection.ipVersion, + network: goConnection.network, + source: goConnection.source, + destination: goConnection.destination, + domain: goConnection.domain, + displayDestination: goConnection.displayDestination(), + protocolName: goConnection.protocol, + user: goConnection.user, + fromOutbound: goConnection.fromOutbound, + createdAt: Date(timeIntervalSince1970: Double(goConnection.createdAt) / 1000), + closedAt: closedAt, + upload: goConnection.uplink, + download: goConnection.downlink, + uploadTotal: goConnection.uplinkTotal, + downloadTotal: goConnection.downlinkTotal, + rule: goConnection.rule, + outbound: goConnection.outbound, + outboundType: goConnection.outboundType, + chain: goConnection.chain()!.toArray() + )) + } + return connections + } +} diff --git a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift index 913abcf..f63a83c 100644 --- a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift +++ b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift @@ -9,20 +9,17 @@ public struct ActiveDashboardView: View { @Environment(\.selection) private var parentSelection @EnvironmentObject private var environments: ExtensionEnvironments @EnvironmentObject private var profile: ExtensionProfile - @State private var isLoading = true - @State private var profileList: [ProfilePreview] = [] - @State private var selectedProfileID: Int64 = 0 - @State private var alert: Alert? - @State private var selection = DashboardPage.overview - @State private var systemProxyAvailable = false - @State private var systemProxyEnabled = false + @StateObject private var viewModel = ActiveDashboardViewModel() public init() {} public var body: some View { - if isLoading { + if viewModel.isLoading { ProgressView().onAppear { + viewModel.onEmptyProfilesChange = { isEmpty in + environments.emptyProfiles = isEmpty + } Task { - await doReload() + await viewModel.reload() } } } else { @@ -32,13 +29,13 @@ public struct ActiveDashboardView: View { body1 .onAppear { Task { - await doReloadSystemProxy() + await viewModel.reloadSystemProxy() } } .onChangeCompat(of: profile.status) { newStatus in if newStatus == .connected { Task { - await doReloadSystemProxy() + await viewModel.reloadSystemProxy() } } } @@ -50,7 +47,7 @@ public struct ActiveDashboardView: View { VStack { #if os(iOS) || os(tvOS) if ApplicationLibrary.inPreview || profile.status.isConnectedStrict { - Picker("Page", selection: $selection) { + Picker("Page", selection: $viewModel.selection) { ForEach(DashboardPage.enabledCases()) { page in page.label } @@ -60,9 +57,9 @@ public struct ActiveDashboardView: View { .padding([.leading, .trailing]) .navigationBarTitleDisplayMode(.inline) #endif - TabView(selection: $selection) { + TabView(selection: $viewModel.selection) { ForEach(DashboardPage.enabledCases()) { page in - page.contentView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled) + page.contentView($viewModel.profileList, $viewModel.selectedProfileID, $viewModel.systemProxyAvailable, $viewModel.systemProxyEnabled) .tag(page) } } @@ -71,75 +68,25 @@ public struct ActiveDashboardView: View { #endif .tabViewStyle(.page(indexDisplayMode: .never)) } else { - OverviewView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled) + OverviewView($viewModel.profileList, $viewModel.selectedProfileID, $viewModel.systemProxyAvailable, $viewModel.systemProxyEnabled) } #elseif os(macOS) - OverviewView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled) + OverviewView($viewModel.profileList, $viewModel.selectedProfileID, $viewModel.systemProxyAvailable, $viewModel.systemProxyEnabled) #endif } .onReceive(environments.profileUpdate) { _ in Task { - await doReload() + await viewModel.reload() } } .onReceive(environments.selectedProfileUpdate) { _ in Task { - selectedProfileID = await SharedPreferences.selectedProfileID.get() + await viewModel.updateSelectedProfile() if profile.status.isConnected { - await doReloadSystemProxy() + await viewModel.reloadSystemProxy() } } } - .alertBinding($alert) - } - - private func doReload() 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 { - 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 - } - } - environments.emptyProfiles = profileList.isEmpty - } - - private nonisolated func doReloadSystemProxy() async { - do { - let status = try LibboxNewStandaloneCommandClient()!.getSystemProxyStatus() - await MainActor.run { - systemProxyAvailable = status.available - systemProxyEnabled = status.enabled - } - } catch { - await MainActor.run { - alert = Alert(error) - } - } + .alertBinding($viewModel.alert) } } diff --git a/ApplicationLibrary/Views/Dashboard/ActiveDashboardViewModel.swift b/ApplicationLibrary/Views/Dashboard/ActiveDashboardViewModel.swift new file mode 100644 index 0000000..bfd7cef --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/ActiveDashboardViewModel.swift @@ -0,0 +1,72 @@ +import Foundation +import Libbox +import Library +import SwiftUI + +@MainActor +final class ActiveDashboardViewModel: ObservableObject { + @Published var isLoading = true + @Published var profileList: [ProfilePreview] = [] + @Published var selectedProfileID: Int64 = 0 + @Published var alert: Alert? + @Published var selection = DashboardPage.overview + @Published var systemProxyAvailable = false + @Published var systemProxyEnabled = false + + var onEmptyProfilesChange: ((Bool) -> Void)? + + func reload() async { + defer { + isLoading = false + } + if ApplicationLibrary.inPreview { + profileList = [ + ProfilePreview(Profile(id: 0, name: "profile local", type: .local, path: "")), + ProfilePreview(Profile(id: 1, name: "profile remote", type: .remote, path: "", lastUpdated: Date(timeIntervalSince1970: 0))), + ] + systemProxyAvailable = true + systemProxyEnabled = true + selectedProfileID = 0 + + } else { + do { + profileList = try await ProfileManager.list().map { ProfilePreview($0) } + if profileList.isEmpty { + onEmptyProfilesChange?(true) + return + } + selectedProfileID = await SharedPreferences.selectedProfileID.get() + if profileList.filter({ profile in + profile.id == selectedProfileID + }) + .isEmpty { + selectedProfileID = profileList[0].id + await SharedPreferences.selectedProfileID.set(selectedProfileID) + } + + } catch { + alert = Alert(error) + return + } + } + onEmptyProfilesChange?(profileList.isEmpty) + } + + nonisolated func reloadSystemProxy() async { + do { + let status = try LibboxNewStandaloneCommandClient()!.getSystemProxyStatus() + await MainActor.run { + systemProxyAvailable = status.available + systemProxyEnabled = status.enabled + } + } catch { + await MainActor.run { + alert = Alert(error) + } + } + } + + func updateSelectedProfile() async { + selectedProfileID = await SharedPreferences.selectedProfileID.get() + } +} diff --git a/ApplicationLibrary/Views/Dashboard/ClashModeView.swift b/ApplicationLibrary/Views/Dashboard/ClashModeView.swift index 0c69415..fe13718 100644 --- a/ApplicationLibrary/Views/Dashboard/ClashModeView.swift +++ b/ApplicationLibrary/Views/Dashboard/ClashModeView.swift @@ -5,57 +5,38 @@ import SwiftUI @MainActor public struct ClashModeView: View { @Environment(\.scenePhase) private var scenePhase - @StateObject private var commandClient = CommandClient(.clashMode) - @State private var clashMode = "" - @State private var alert: Alert? + @StateObject private var viewModel = ClashModeViewModel() public init() {} public var body: some View { VStack { - if commandClient.clashModeList.count > 1 { + if viewModel.shouldShowPicker { Picker("", selection: Binding(get: { - clashMode + viewModel.clashMode }, set: { newMode in - clashMode = newMode + viewModel.clashMode = newMode Task { - await setClashMode(newMode) + await viewModel.setClashMode(newMode) } }), content: { - ForEach(commandClient.clashModeList, id: \.self) { it in - Text(it) + ForEach(viewModel.clashModeList, id: \.self) { mode in + Text(mode) } }) .pickerStyle(.segmented) .padding([.top], 8) } } - .onReceive(commandClient.$clashMode) { newMode in - clashMode = newMode - } .padding([.leading, .trailing]) .onAppear { - commandClient.connect() + viewModel.connect() } .onDisappear { - commandClient.disconnect() + viewModel.disconnect() } .onChangeCompat(of: scenePhase) { newValue in - if newValue == .active { - commandClient.connect() - } else { - commandClient.disconnect() - } - } - .alertBinding($alert) - } - - private nonisolated func setClashMode(_ newMode: String) async { - do { - try LibboxNewStandaloneCommandClient()!.setClashMode(newMode) - } catch { - await MainActor.run { - alert = Alert(error) - } + viewModel.handleScenePhase(newValue) } + .alertBinding($viewModel.alert) } } diff --git a/ApplicationLibrary/Views/Dashboard/ClashModeViewModel.swift b/ApplicationLibrary/Views/Dashboard/ClashModeViewModel.swift new file mode 100644 index 0000000..942ae94 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/ClashModeViewModel.swift @@ -0,0 +1,50 @@ +import Libbox +import Library +import SwiftUI + +@MainActor +final class ClashModeViewModel: ObservableObject { + @Published var clashMode = "" + @Published var alert: Alert? + + private let commandClient = CommandClient(.clashMode) + + var clashModeList: [String] { + commandClient.clashModeList + } + + var shouldShowPicker: Bool { + commandClient.clashModeList.count > 1 + } + + init() { + commandClient.$clashMode + .assign(to: &$clashMode) + } + + func connect() { + commandClient.connect() + } + + func disconnect() { + commandClient.disconnect() + } + + func handleScenePhase(_ phase: ScenePhase) { + if phase == .active { + connect() + } else { + disconnect() + } + } + + nonisolated func setClashMode(_ newMode: String) async { + do { + try LibboxNewStandaloneCommandClient()!.setClashMode(newMode) + } catch { + await MainActor.run { + alert = Alert(error) + } + } + } +} diff --git a/ApplicationLibrary/Views/Dashboard/DashboardView.swift b/ApplicationLibrary/Views/Dashboard/DashboardView.swift index 9f8a033..c8bdf17 100644 --- a/ApplicationLibrary/Views/Dashboard/DashboardView.swift +++ b/ApplicationLibrary/Views/Dashboard/DashboardView.swift @@ -6,8 +6,7 @@ import SwiftUI public struct DashboardView: View { #if os(macOS) @Environment(\.controlActiveState) private var controlActiveState - @State private var isLoading = true - @State private var systemExtensionInstalled = true + @StateObject private var viewModel = DashboardViewModel() #endif public init() {} @@ -16,10 +15,10 @@ public struct DashboardView: View { #if os(macOS) if Variant.useSystemExtension { viewBuilder { - if !systemExtensionInstalled { + if !viewModel.systemExtensionInstalled { FormView { InstallSystemExtensionButton { - await reload() + await viewModel.reload() } } } else { @@ -27,7 +26,7 @@ public struct DashboardView: View { } }.onAppear { Task { - await reload() + await viewModel.reload() } } } else { @@ -41,9 +40,9 @@ public struct DashboardView: View { .onChangeCompat(of: controlActiveState) { newValue in if newValue != .inactive { if Variant.useSystemExtension { - if !isLoading { + if !viewModel.isLoading { Task { - await reload() + await viewModel.reload() } } } @@ -52,16 +51,6 @@ public struct DashboardView: View { #endif } - #if os(macOS) - private nonisolated func reload() async { - let systemExtensionInstalled = await SystemExtension.isInstalled() - await MainActor.run { - self.systemExtensionInstalled = systemExtensionInstalled - isLoading = false - } - } - #endif - struct DashboardView0: View { @EnvironmentObject private var environments: ExtensionEnvironments @@ -86,146 +75,21 @@ public struct DashboardView: View { @Environment(\.openURL) var openURL @EnvironmentObject private var environments: ExtensionEnvironments @EnvironmentObject private var profile: ExtensionProfile - @State private var alert: Alert? - @State private var notStarted = false + @StateObject private var viewModel = DashboardViewModel() var body: some View { VStack { ActiveDashboardView() } - .alertBinding($alert) + .alertBinding($viewModel.alert) + .onAppear { + viewModel.setOpenURL { url in + openURL(url) + } + } .onChangeCompat(of: profile.status) { newValue in - if newValue == .connected { - notStarted = false - } - if newValue == .disconnecting || newValue == .connected { - Task { - await checkServiceError() - if newValue == .connected { - await checkDeprecatedNotes() - } - } - } else if newValue == .connecting { - notStarted = true - } else if newValue == .disconnected { - if #available(iOS 16.0, macOS 13.0, tvOS 17.0, *) { - if notStarted { - Task { - await checkLastDisconnectError() - } - } - } - } + viewModel.handleStatusChange(newValue, profile: profile) } } - - private 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) - } - } - } - - @MainActor - 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 { - try await Task.sleep(nanoseconds: 300 * NSEC_PER_MSEC) - await loopShowDeprecateNotes(reports) - } - } - ) - } else { - alert = Alert( - title: Text("Deprecated Warning"), - message: Text(report.message()), - primaryButton: .default(Text("Documentation")) { - openURL(URL(string: report.migrationLink)!) - Task.detached { - try await Task.sleep(nanoseconds: 300 * NSEC_PER_MSEC) - await loopShowDeprecateNotes(reports) - } - }, - secondaryButton: .cancel(Text("Ok")) { - Task.detached { - try await Task.sleep(nanoseconds: 300 * NSEC_PER_MSEC) - await loopShowDeprecateNotes(reports) - } - } - ) - } - } - } - - private nonisolated func checkServiceError() async { - var error: NSError? - let message = LibboxReadServiceError(&error) - if error != nil { - return - } - await MainActor.run { - alert = Alert(title: Text("Service Error"), message: Text(message!.value)) - } - } - - @available(iOS 16.0, macOS 13.0, tvOS 17.0, *) - private nonisolated func checkLastDisconnectError() async { - var myError: NSError - do { - try await profile.fetchLastDisconnectError() - return - } catch { - myError = error as NSError - } - #if os(macOS) - if myError.domain == "Library.FullDiskAccessPermissionRequired" { - await MainActor.run { - alert = Alert( - title: Text("Full Disk Access permission is required"), - message: Text("Please grant the permission for **SFMExtension**, then we can continue."), - primaryButton: .default(Text("Authorize"), action: openFDASettings), - secondaryButton: .cancel() - ) - } - return - } - #endif - let message = myError.localizedDescription - await MainActor.run { - alert = Alert(title: Text("Service Error"), message: Text(message)) - } - } - - #if os(macOS) - - private func openFDASettings() { - if NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles")!) { - return - } - if #available(macOS 13, *) { - NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Settings.app")) - } else { - NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Preferences.app")) - } - } - - #endif } } diff --git a/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift b/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift new file mode 100644 index 0000000..7bebd0b --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/DashboardViewModel.swift @@ -0,0 +1,161 @@ +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 + } + if status == .disconnecting || status == .connected { + Task { + await checkServiceError() + if status == .connected { + 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) + } + } + ) + } + } + } + + nonisolated func checkServiceError() async { + var error: NSError? + let message = LibboxReadServiceError(&error) + if error != nil { + return + } + await MainActor.run { + alert = Alert(title: Text("Service Error"), message: Text(message!.value)) + } + } + + @available(iOS 16.0, macOS 13.0, tvOS 17.0, *) + nonisolated func checkLastDisconnectError(profile: ExtensionProfile) async { + var myError: NSError + do { + try await profile.fetchLastDisconnectError() + return + } catch { + myError = error as NSError + } + #if os(macOS) + if myError.domain == "Library.FullDiskAccessPermissionRequired" { + await MainActor.run { + alert = Alert( + title: Text("Full Disk Access permission is required"), + message: Text("Please grant the permission for **SFMExtension**, then we can continue."), + primaryButton: .default(Text("Authorize"), action: openFDASettings), + secondaryButton: .cancel() + ) + } + return + } + #endif + let message = myError.localizedDescription + await MainActor.run { + alert = Alert(title: Text("Service Error"), message: Text(message)) + } + } + + #if os(macOS) + private func openFDASettings() { + if NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles")!) { + return + } + if #available(macOS 13, *) { + NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Settings.app")) + } else { + NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Preferences.app")) + } + } + #endif +} diff --git a/ApplicationLibrary/Views/Dashboard/OverviewView.swift b/ApplicationLibrary/Views/Dashboard/OverviewView.swift index 7511a7c..7c80527 100644 --- a/ApplicationLibrary/Views/Dashboard/OverviewView.swift +++ b/ApplicationLibrary/Views/Dashboard/OverviewView.swift @@ -12,14 +12,13 @@ public struct OverviewView: View { @Binding private var selectedProfileID: Int64 @Binding private var systemProxyAvailable: Bool @Binding private var systemProxyEnabled: Bool - @State private var alert: Alert? - @State private var reasserting = false + @StateObject private var viewModel = OverviewViewModel() private var selectedProfileIDLocal: Binding { $selectedProfileID.withSetter { newValue in - reasserting = true + viewModel.reasserting = true Task { [self] in - await switchProfile(newValue) + await viewModel.switchProfile(newValue, profile: profile, environments: environments) } } } @@ -47,7 +46,7 @@ public struct OverviewView: View { Toggle("HTTP Proxy", isOn: $systemProxyEnabled) .onChangeCompat(of: systemProxyEnabled) { newValue in Task { - await setSystemProxyEnabled(newValue) + await viewModel.setSystemProxyEnabled(newValue, profile: profile) } } } @@ -64,7 +63,7 @@ public struct OverviewView: View { Toggle("HTTP Proxy", isOn: $systemProxyEnabled) .onChangeCompat(of: systemProxyEnabled) { newValue in Task { - await setSystemProxyEnabled(newValue) + await viewModel.setSystemProxyEnabled(newValue, profile: profile) } } } @@ -80,55 +79,7 @@ public struct OverviewView: View { } } } - .alertBinding($alert) - .disabled(!ApplicationLibrary.inPreview && (!profile.status.isSwitchable || reasserting)) - } - - private func switchProfile(_ newProfileID: Int64) async { - await SharedPreferences.selectedProfileID.set(newProfileID) - environments.selectedProfileUpdate.send() - if profile.status.isConnected { - do { - try await serviceReload() - } catch { - alert = Alert(error) - } - } - reasserting = false - } - - private nonisolated func serviceReload() async throws { - try LibboxNewStandaloneCommandClient()!.serviceReload() - } - - private nonisolated func setSystemProxyEnabled(_ isEnabled: Bool) async { - do { - await SharedPreferences.systemProxyEnabled.set(isEnabled) - if isEnabled { - try LibboxNewStandaloneCommandClient()!.setSystemProxyEnabled(isEnabled) - } else { - // Apple BUG: HTTP Proxy cannot be disabled via setTunnelNetworkSettings, so we can only restart the Network Extension - 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) - waitSeconds += 1 - if waitSeconds >= 5 { - throw NSError(domain: "Restart service timeout", code: 0) - } - } - try await profile.start() - await MainActor.run { - reasserting = false - } - } - } catch { - await MainActor.run { - alert = Alert(error) - } - } + .alertBinding($viewModel.alert) + .disabled(!ApplicationLibrary.inPreview && (!profile.status.isSwitchable || viewModel.reasserting)) } } diff --git a/ApplicationLibrary/Views/Dashboard/OverviewViewModel.swift b/ApplicationLibrary/Views/Dashboard/OverviewViewModel.swift new file mode 100644 index 0000000..4b29147 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/OverviewViewModel.swift @@ -0,0 +1,60 @@ +import Foundation +import Libbox +import Library +import SwiftUI + +@MainActor +public final class OverviewViewModel: ObservableObject { + @Published var alert: Alert? + @Published var reasserting = false + + public init() {} + + func switchProfile(_ newProfileID: Int64, profile: ExtensionProfile, environments: ExtensionEnvironments) async { + await SharedPreferences.selectedProfileID.set(newProfileID) + environments.selectedProfileUpdate.send() + if profile.status.isConnected { + do { + try await serviceReload() + } catch { + alert = Alert(error) + } + } + reasserting = false + } + + nonisolated func serviceReload() async throws { + try LibboxNewStandaloneCommandClient()!.serviceReload() + } + + nonisolated func setSystemProxyEnabled(_ isEnabled: Bool, profile: ExtensionProfile) async { + do { + await SharedPreferences.systemProxyEnabled.set(isEnabled) + if isEnabled { + try LibboxNewStandaloneCommandClient()!.setSystemProxyEnabled(isEnabled) + } else { + // Apple BUG: HTTP Proxy cannot be disabled via setTunnelNetworkSettings, so we can only restart the Network Extension + 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) + waitSeconds += 1 + if waitSeconds >= 5 { + throw NSError(domain: "Restart service timeout", code: 0) + } + } + try await profile.start() + await MainActor.run { + reasserting = false + } + } + } catch { + await MainActor.run { + alert = Alert(error) + } + } + } +} diff --git a/ApplicationLibrary/Views/Groups/GroupListView.swift b/ApplicationLibrary/Views/Groups/GroupListView.swift index 6fbb432..4bf0697 100644 --- a/ApplicationLibrary/Views/Groups/GroupListView.swift +++ b/ApplicationLibrary/Views/Groups/GroupListView.swift @@ -1,22 +1,19 @@ -import Libbox import Library import SwiftUI public struct GroupListView: View { @Environment(\.scenePhase) private var scenePhase - @State private var isLoading = true - @StateObject private var commandClient = CommandClient(.groups) - @State private var groups: [OutboundGroup] = [] + @StateObject private var viewModel = GroupListViewModel() public init() {} public var body: some View { VStack { - if isLoading { + if viewModel.isLoading { Text("Loading...") - } else if !groups.isEmpty { + } else if !viewModel.groups.isEmpty { ScrollView { VStack { - ForEach(groups, id: \.hashValue) { it in + ForEach(viewModel.groups, id: \.hashValue) { it in GroupView(it) } }.padding() @@ -26,56 +23,17 @@ public struct GroupListView: View { } } .onAppear { - connect() + viewModel.connect() } .onDisappear { - commandClient.disconnect() + viewModel.disconnect() } .onChangeCompat(of: scenePhase) { newValue in if newValue == .active { - commandClient.connect() + viewModel.connect() } else { - commandClient.disconnect() + viewModel.disconnect() } } - .onReceive(commandClient.$groups, perform: { groups in - if let groups { - setGroups(groups) - } - }) - } - - private func connect() { - if ApplicationLibrary.inPreview { - groups = [ - OutboundGroup(tag: "my_group", type: "selector", selected: "server", selectable: true, isExpand: true, items: [ - OutboundGroupItem(tag: "server", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 12), - OutboundGroupItem(tag: "server2", type: "WireGuard", urlTestTime: .now, urlTestDelay: 34), - OutboundGroupItem(tag: "auto", type: "URLTest", urlTestTime: .now, urlTestDelay: 100), - ]), - OutboundGroup(tag: "group2", type: "urltest", selected: "client", selectable: true, isExpand: false, items: - (0 ..< 234).map { index in - OutboundGroupItem(tag: "client\(index)", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: UInt16(100 + index * 10)) - }), - ] - isLoading = false - } else { - commandClient.connect() - } - } - - private func setGroups(_ goGroups: [LibboxOutboundGroup]) { - var groups = [OutboundGroup]() - for goGroup in goGroups { - var items = [OutboundGroupItem]() - let itemIterator = goGroup.getItems()! - while itemIterator.hasNext() { - let goItem = itemIterator.next()! - items.append(OutboundGroupItem(tag: goItem.tag, type: goItem.type, urlTestTime: Date(timeIntervalSince1970: Double(goItem.urlTestTime)), urlTestDelay: UInt16(goItem.urlTestDelay))) - } - groups.append(OutboundGroup(tag: goGroup.tag, type: goGroup.type, selected: goGroup.selected, selectable: goGroup.selectable, isExpand: goGroup.isExpand, items: items)) - } - self.groups = groups - isLoading = false } } diff --git a/ApplicationLibrary/Views/Groups/GroupListViewModel.swift b/ApplicationLibrary/Views/Groups/GroupListViewModel.swift new file mode 100644 index 0000000..fc4fa6f --- /dev/null +++ b/ApplicationLibrary/Views/Groups/GroupListViewModel.swift @@ -0,0 +1,60 @@ +import Combine +import Libbox +import Library +import SwiftUI + +@MainActor +public class GroupListViewModel: ObservableObject { + @Published public var isLoading = true + @Published public var groups: [OutboundGroup] = [] + + private let commandClient = CommandClient(.groups) + private var cancellables = Set() + + public init() { + commandClient.$groups + .compactMap { $0 } + .sink { [weak self] goGroups in + self?.setGroups(goGroups) + } + .store(in: &cancellables) + } + + public func connect() { + if ApplicationLibrary.inPreview { + groups = [ + OutboundGroup(tag: "my_group", type: "selector", selected: "server", selectable: true, isExpand: true, items: [ + OutboundGroupItem(tag: "server", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 12), + OutboundGroupItem(tag: "server2", type: "WireGuard", urlTestTime: .now, urlTestDelay: 34), + OutboundGroupItem(tag: "auto", type: "URLTest", urlTestTime: .now, urlTestDelay: 100), + ]), + OutboundGroup(tag: "group2", type: "urltest", selected: "client", selectable: true, isExpand: false, items: + (0 ..< 234).map { index in + OutboundGroupItem(tag: "client\(index)", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: UInt16(100 + index * 10)) + }), + ] + isLoading = false + } else { + commandClient.connect() + } + } + + public func disconnect() { + commandClient.disconnect() + } + + private func setGroups(_ goGroups: [LibboxOutboundGroup]) { + var groups = [OutboundGroup]() + for goGroup in goGroups { + var items = [OutboundGroupItem]() + let itemIterator = goGroup.getItems()! + while itemIterator.hasNext() { + let goItem = itemIterator.next()! + items.append(OutboundGroupItem(tag: goItem.tag, type: goItem.type, urlTestTime: Date(timeIntervalSince1970: Double(goItem.urlTestTime)), urlTestDelay: UInt16(goItem.urlTestDelay))) + } + groups.append(OutboundGroup(tag: goGroup.tag, type: goGroup.type, selected: goGroup.selected, selectable: goGroup.selectable, isExpand: goGroup.isExpand, items: items)) + } + self.groups = groups + isLoading = false + } +} diff --git a/ApplicationLibrary/Views/Groups/GroupView.swift b/ApplicationLibrary/Views/Groups/GroupView.swift index 646aabf..aaf2f9b 100644 --- a/ApplicationLibrary/Views/Groups/GroupView.swift +++ b/ApplicationLibrary/Views/Groups/GroupView.swift @@ -1,36 +1,31 @@ -import Libbox import Library import SwiftUI @MainActor public struct GroupView: View { - @State private var group: OutboundGroup + @StateObject private var viewModel: GroupViewModel @State private var geometryWidth: CGFloat = 300 - @State private var alert: Alert? public init(_ group: OutboundGroup) { - _group = State(initialValue: group) + _viewModel = StateObject(wrappedValue: GroupViewModel(group: group)) } private var title: some View { HStack { - Text(group.tag) + Text(viewModel.group.tag) .font(.headline) - Text(group.displayType) + Text(viewModel.group.displayType) .font(.subheadline) .foregroundColor(.secondary) - Text("\(group.items.count)") + Text("\(viewModel.group.items.count)") .font(.subheadline) .padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4)) .background(Color.gray.opacity(0.5)) .cornerRadius(4) Button { - group.isExpand = !group.isExpand - Task { - await setGroupExpand() - } + viewModel.toggleExpand() } label: { - if group.isExpand { + if viewModel.group.isExpand { Image(systemName: "arrow.down.to.line") } else { Image(systemName: "arrow.up.to.line") @@ -40,9 +35,7 @@ public struct GroupView: View { .buttonStyle(.plain) #endif Button { - Task { - await doURLTest() - } + viewModel.performURLTest() } label: { Image(systemName: "bolt.fill") } @@ -50,19 +43,19 @@ public struct GroupView: View { .buttonStyle(.plain) #endif } - .alertBinding($alert) + .alertBinding($viewModel.alert) .padding([.top, .bottom], 8) - .animation(.easeInOut, value: group.isExpand) + .animation(.easeInOut, value: viewModel.group.isExpand) } public var body: some View { Section { - if group.isExpand { + if viewModel.group.isExpand { LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: explandColumnCount())) { - ForEach(group.items, id: \.tag) { it in - GroupItemView($group, it) + ForEach(viewModel.group.items, id: \.tag) { it in + GroupItemView($viewModel.group, it) } } } else { @@ -73,7 +66,7 @@ public struct GroupView: View { ZStack { Rectangle() .fill(it.delayColor) - if it.tag == group.selected { + if it.tag == viewModel.group.selected { Rectangle() .fill(Color.white) #if !os(tvOS) @@ -120,9 +113,9 @@ public struct GroupView: View { count = Int(Int(geometryWidth) / 20) #endif if count == 0 { - return [group.items] + return [viewModel.group.items] } else { - return group.items.chunked( + return viewModel.group.items.chunked( into: count ) } @@ -138,26 +131,6 @@ public struct GroupView: View { return standardCount < 1 ? 1 : standardCount #endif } - - private nonisolated func doURLTest() async { - do { - try await LibboxNewStandaloneCommandClient()!.urlTest(group.tag) - } catch { - await MainActor.run { - alert = Alert(error) - } - } - } - - private nonisolated func setGroupExpand() async { - do { - try await LibboxNewStandaloneCommandClient()!.setGroupExpand(group.tag, isExpand: group.isExpand) - } catch { - await MainActor.run { - alert = Alert(error) - } - } - } } private extension Array { diff --git a/ApplicationLibrary/Views/Groups/GroupViewModel.swift b/ApplicationLibrary/Views/Groups/GroupViewModel.swift new file mode 100644 index 0000000..a6268cd --- /dev/null +++ b/ApplicationLibrary/Views/Groups/GroupViewModel.swift @@ -0,0 +1,46 @@ +import Libbox +import Library +import SwiftUI + +@MainActor +public class GroupViewModel: ObservableObject { + @Published public var group: OutboundGroup + @Published public var alert: Alert? + + public init(group: OutboundGroup) { + self.group = group + } + + public func toggleExpand() { + group.isExpand = !group.isExpand + Task { + await setGroupExpand() + } + } + + public func performURLTest() { + Task { + await doURLTest() + } + } + + private nonisolated func doURLTest() async { + do { + try await LibboxNewStandaloneCommandClient()!.urlTest(group.tag) + } catch { + await MainActor.run { + alert = Alert(error) + } + } + } + + private nonisolated func setGroupExpand() async { + do { + try await LibboxNewStandaloneCommandClient()!.setGroupExpand(group.tag, isExpand: group.isExpand) + } catch { + await MainActor.run { + alert = Alert(error) + } + } + } +} diff --git a/ApplicationLibrary/Views/Profile/EditProfileContentView.swift b/ApplicationLibrary/Views/Profile/EditProfileContentView.swift index 994d34c..2830054 100644 --- a/ApplicationLibrary/Views/Profile/EditProfileContentView.swift +++ b/ApplicationLibrary/Views/Profile/EditProfileContentView.swift @@ -10,42 +10,34 @@ public let readOnly: Bool } - private let profileID: Int64? private let readOnly: Bool + @StateObject private var viewModel: EditProfileContentViewModel public init(_ context: Context?) { - profileID = context?.profileID readOnly = context?.readOnly == true + _viewModel = StateObject(wrappedValue: EditProfileContentViewModel(profileID: context?.profileID)) } @Environment(\.dismiss) private var dismiss - @State private var isLoading = true - @State private var profile: Profile! - @State private var profileContent = "" - @State private var isChanged = false - @State private var alert: Alert? - public var body: some View { viewBuilder { - if isLoading { + if viewModel.isLoading { ProgressView().onAppear { Task { - await loadContent() + await viewModel.loadContent() } } } else { viewBuilder { if readOnly { - TextEditor(text: .constant(profileContent)) + TextEditor(text: .constant(viewModel.profileContent)) } else { - TextEditor(text: $profileContent) + TextEditor(text: $viewModel.profileContent) } } .font(Font.system(.caption2, design: .monospaced)) .autocorrectionDisabled(true) - // https://stackoverflow.com/questions/66721935/swiftui-how-to-disable-the-smart-quotes-in-texteditor - // https://stackoverflow.com/questions/74034171/textfield-with-autocorrectiondisabled-still-shows-predictive-text-bar .textContentType(.init(rawValue: "")) #if os(iOS) .keyboardType(.asciiCapable) @@ -54,12 +46,12 @@ #elseif os(macOS) .padding() #endif - .onChangeCompat(of: profileContent) { - isChanged = true + .onChangeCompat(of: viewModel.profileContent) { + viewModel.markAsChanged() } } } - .alertBinding($alert) + .alertBinding($viewModel.alert) .navigationTitle(navigationTitle) #if os(macOS) .toolbar { @@ -67,15 +59,15 @@ if !readOnly { Button { Task { - await saveContent() + await viewModel.saveContent() } } label: { Label("Save", image: "save") } - .disabled(!isChanged) + .disabled(!viewModel.isChanged) } else { Button { - NSPasteboard.general.setString(profileContent, forType: .fileContents) + NSPasteboard.general.setString(viewModel.profileContent, forType: .fileContents) } label: { Label("Copy", systemImage: "clipboard.fill") } @@ -88,12 +80,12 @@ if !readOnly { Button("Save") { Task { - await saveContent() + await viewModel.saveContent() } - }.disabled(!isChanged) + }.disabled(!viewModel.isChanged) } else { Button("Copy") { - UIPasteboard.general.string = profileContent + UIPasteboard.general.string = viewModel.profileContent } } } @@ -109,46 +101,6 @@ return String(localized: "Edit Content") } } - - private func loadContent() async { - do { - try await loadContentBackground() - } catch { - alert = Alert(error) - } - isLoading = false - } - - private nonisolated func loadContentBackground() async throws { - guard let profileID else { - throw NSError(domain: "Context destroyed", code: 0) - } - guard let profile = try await ProfileManager.get(profileID) else { - throw NSError(domain: "Profile missing", code: 0) - } - let profileContent = try profile.read() - await MainActor.run { - self.profile = profile - self.profileContent = profileContent - } - } - - private func saveContent() async { - guard let profile else { - return - } - do { - try await saveContentBackground(profile) - } catch { - alert = Alert(error) - return - } - isChanged = false - } - - private nonisolated func saveContentBackground(_ profile: Profile) async throws { - try await profile.write(profileContent) - } } #endif diff --git a/ApplicationLibrary/Views/Profile/EditProfileContentViewModel.swift b/ApplicationLibrary/Views/Profile/EditProfileContentViewModel.swift new file mode 100644 index 0000000..b8e9fd5 --- /dev/null +++ b/ApplicationLibrary/Views/Profile/EditProfileContentViewModel.swift @@ -0,0 +1,66 @@ +#if os(iOS) || os(macOS) + import Foundation + import Library + import SwiftUI + + @MainActor + public final class EditProfileContentViewModel: ObservableObject { + @Published public var isLoading = true + @Published public var profile: Profile? + @Published public var profileContent = "" + @Published public var isChanged = false + @Published public var alert: Alert? + + private let profileID: Int64? + + public init(profileID: Int64?) { + self.profileID = profileID + } + + public func markAsChanged() { + isChanged = true + } + + public func loadContent() async { + do { + try await loadContentBackground() + } catch { + alert = Alert(error) + } + isLoading = false + } + + private nonisolated func loadContentBackground() async throws { + guard let profileID else { + throw NSError(domain: "Context destroyed", code: 0) + } + guard let profile = try await ProfileManager.get(profileID) else { + throw NSError(domain: "Profile missing", code: 0) + } + let profileContent = try profile.read() + await MainActor.run { + self.profile = profile + self.profileContent = profileContent + } + } + + public func saveContent() async { + guard let profile else { + return + } + do { + try await saveContentBackground(profile) + } catch { + alert = Alert(error) + return + } + isChanged = false + } + + private nonisolated func saveContentBackground(_ profile: Profile) async throws { + let profileContent = await profileContent + try profile.write(profileContent) + } + } + +#endif diff --git a/ApplicationLibrary/Views/Profile/EditProfileView.swift b/ApplicationLibrary/Views/Profile/EditProfileView.swift index c0295b5..9a99608 100644 --- a/ApplicationLibrary/Views/Profile/EditProfileView.swift +++ b/ApplicationLibrary/Views/Profile/EditProfileView.swift @@ -7,12 +7,7 @@ public struct EditProfileView: View { @EnvironmentObject private var environments: ExtensionEnvironments @Environment(\.dismiss) private var dismiss @EnvironmentObject private var profile: Profile - - @State private var isLoading = false - @State private var isChanged = false - @State private var alert: Alert? - @State private var shareLinkPresented = false - @State private var shareLinkText: String? + @StateObject private var viewModel = EditProfileViewModel() public init() {} public var body: some View { @@ -80,19 +75,19 @@ public struct EditProfileView: View { } #endif FormButton { - isLoading = true + viewModel.isLoading = true Task { - await updateProfile() + await viewModel.updateProfile(profile, environments: environments) } } label: { Label("Update", systemImage: "arrow.clockwise") } .foregroundColor(.accentColor) - .disabled(isLoading) + .disabled(viewModel.isLoading) } FormButton(role: .destructive) { Task { - await deleteProfile() + await viewModel.deleteProfile(profile, environments: environments, dismiss: dismiss) } } label: { Label("Delete", systemImage: "trash.fill") @@ -101,84 +96,42 @@ public struct EditProfileView: View { } } .onChangeCompat(of: profile.name) { - isChanged = true + viewModel.markAsChanged() } .onChangeCompat(of: profile.remoteURL) { - isChanged = true + viewModel.markAsChanged() } .onChangeCompat(of: profile.autoUpdate) { - isChanged = true + viewModel.markAsChanged() } - .disabled(isLoading) + .disabled(viewModel.isLoading) #if os(macOS) .toolbar { ToolbarItemGroup(placement: .navigation) { Button { - isLoading = true + viewModel.isLoading = true Task { - await saveProfile() + await viewModel.saveProfile(profile, environments: environments) } } label: { Image("save", bundle: ApplicationLibrary.bundle, label: Text("Save")) } - .disabled(isLoading || !isChanged) + .disabled(viewModel.isLoading || !viewModel.isChanged) } } #elseif os(iOS) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { - isLoading = true + viewModel.isLoading = true Task { - await saveProfile() + await viewModel.saveProfile(profile, environments: environments) } - }.disabled(!isChanged) + }.disabled(!viewModel.isChanged) } } #endif - .alertBinding($alert) + .alertBinding($viewModel.alert) .navigationTitle("Edit Profile") } - - private func updateProfile() async { - defer { - isLoading = false - } - do { - try await Task.sleep(nanoseconds: UInt64(100 * Double(NSEC_PER_MSEC))) - try await profile.updateRemoteProfile() - environments.profileUpdate.send() - } catch { - alert = Alert(error) - } - } - - private func deleteProfile() async { - do { - try await ProfileManager.delete(profile) - } catch { - alert = Alert(error) - return - } - environments.profileUpdate.send() - dismiss() - } - - private func saveProfile() async { - do { - _ = try await ProfileManager.update(profile) - #if os(iOS) || os(tvOS) - try UIProfileUpdateTask.configure() - #else - try await ProfileUpdateTask.configure() - #endif - try await profile.onProfileUpdated() - } catch { - alert = Alert(error) - return - } - isChanged = false - isLoading = false - environments.profileUpdate.send() - } } diff --git a/ApplicationLibrary/Views/Profile/EditProfileViewModel.swift b/ApplicationLibrary/Views/Profile/EditProfileViewModel.swift new file mode 100644 index 0000000..9ba97a9 --- /dev/null +++ b/ApplicationLibrary/Views/Profile/EditProfileViewModel.swift @@ -0,0 +1,60 @@ +import Libbox +import Library +import SwiftUI + +@MainActor +public final class EditProfileViewModel: ObservableObject { + @Published public var isLoading = false + @Published public var isChanged = false + @Published public var alert: Alert? + @Published public var shareLinkPresented = false + @Published public var shareLinkText: String? + + public init() {} + + public func markAsChanged() { + isChanged = true + } + + public func updateProfile(_ profile: Profile, environments: ExtensionEnvironments) async { + defer { + isLoading = false + } + do { + try await Task.sleep(nanoseconds: UInt64(100 * Double(NSEC_PER_MSEC))) + try await profile.updateRemoteProfile() + environments.profileUpdate.send() + } catch { + alert = Alert(error) + } + } + + public func deleteProfile(_ profile: Profile, environments: ExtensionEnvironments, dismiss: DismissAction) async { + do { + try await ProfileManager.delete(profile) + } catch { + alert = Alert(error) + return + } + environments.profileUpdate.send() + dismiss() + } + + public func saveProfile(_ profile: Profile, environments: ExtensionEnvironments) async { + do { + _ = try await ProfileManager.update(profile) + #if os(iOS) || os(tvOS) + try UIProfileUpdateTask.configure() + #else + try await ProfileUpdateTask.configure() + #endif + try await profile.onProfileUpdated() + } catch { + alert = Alert(error) + return + } + isChanged = false + isLoading = false + environments.profileUpdate.send() + } +} diff --git a/ApplicationLibrary/Views/Profile/ImportProfileView.swift b/ApplicationLibrary/Views/Profile/ImportProfileView.swift index 9755bea..1ec6560 100644 --- a/ApplicationLibrary/Views/Profile/ImportProfileView.swift +++ b/ApplicationLibrary/Views/Profile/ImportProfileView.swift @@ -9,19 +9,12 @@ public struct ImportProfileView: View { @EnvironmentObject private var environments: ExtensionEnvironments @Environment(\.dismiss) private var dismiss - - @State private var isLoading = false - @State private var selected = false - @State private var alert: Alert? - @State private var connection: NWConnection? - @State private var socket: NWSocket? - @State private var profiles: [LibboxProfilePreview]? - @State private var isImporting = false + @StateObject private var viewModel = ImportProfileViewModel() public init() {} public var body: some View { VStack(alignment: .center) { - if !selected { + if !viewModel.selected { Form { Section { EmptyView() @@ -32,9 +25,9 @@ DevicePicker( .applicationService(name: "sing-box:profile")) { endpoint in - selected = true + viewModel.selected = true Task { - await handleEndpoint(endpoint) + await viewModel.handleEndpoint(endpoint, environments: environments, dismiss: dismiss) } } label: { Text("Select Device") @@ -44,7 +37,7 @@ .applicationService } } - } else if let profiles { + } else if let profiles = viewModel.profiles { Form { Section { EmptyView() @@ -53,12 +46,12 @@ } ForEach(profiles, id: \.profileID) { profile in Button(profile.name) { - isLoading = true + viewModel.isLoading = true Task { - selectProfile(profileID: profile.profileID) - isLoading = false + viewModel.selectProfile(profileID: profile.profileID) + viewModel.isLoading = false } - }.disabled(isLoading || isImporting) + }.disabled(viewModel.isLoading || viewModel.isImporting) } } } else { @@ -66,145 +59,9 @@ } } .focusSection() - .alertBinding($alert) + .alertBinding($viewModel.alert) .navigationTitle("Import Profile") } - - private func reset() { - if let connection { - connection.stateUpdateHandler = nil - connection.cancel() - self.connection = nil - } - if let socket { - socket.cancel() - self.socket = nil - } - selected = false - profiles = nil - } - - private func handleEndpoint(_ endpoint: NWEndpoint) async { - let connection = NWConnection(to: endpoint, using: NWParameters.applicationService) - self.connection = connection - socket = NWSocket(connection) - connection.stateUpdateHandler = { state in - switch state { - case let .failed(error): - DispatchQueue.main.async { [self] in - reset() - alert = Alert(error) - } - default: break - } - } - connection.start(queue: .global()) - do { - try await loopMessages() - } catch { - alert = Alert(error) - reset() - } - } - - private nonisolated func loopMessages() async throws { - guard let socket = await socket else { - return - } - var message: Data - while true { - do { - message = try socket.read() - } catch { - throw NSError(domain: "read from connection: \(error.localizedDescription)", code: 0) - } - var error: NSError? - switch Int64(message[0]) { - case LibboxMessageTypeError: - let message = LibboxDecodeErrorMessage(message, &error) - if let error { - throw error - } - if let message { - throw NSError(domain: "remote error: \(message.message)", code: 0) - } - case LibboxMessageTypeProfileList: - let decoder = LibboxProfileDecoder() - try decoder.decode(message) - let iterator = decoder.iterator()! - var profiles = [LibboxProfilePreview]() - while iterator.hasNext() { - let profile = iterator.next()! - if profile.type == LibboxProfileTypeiCloud { - // not supported on tvOS - continue - } - profiles.append(profile) - } - await MainActor.run { [self, profiles] in - self.profiles = profiles - isImporting = false - } - case LibboxMessageTypeProfileContent: - let content = LibboxDecodeProfileContent(message, &error) - if let error { - throw error - } - try await importProfile(content!) - return - default: - throw NSError(domain: "unknown message type \(message[0])", code: 0) - } - } - } - - private func selectProfile(profileID: Int64) { - guard let connection else { - return - } - guard let socket else { - return - } - connection.stateUpdateHandler = nil - let request = LibboxProfileContentRequest() - request.profileID = profileID - do { - try socket.write(request.encode()) - isImporting = true - } catch { - alert = Alert(error) - reset() - } - } - - private nonisolated func importProfile(_ content: LibboxProfileContent) async throws { - var type: ProfileType = .local - switch content.type { - case LibboxProfileTypeLocal: - type = .local - case LibboxProfileTypeiCloud: - type = .icloud - case LibboxProfileTypeRemote: - type = .remote - default: - break - } - let nextProfileID = try await ProfileManager.nextID() - let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true) - try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true) - let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json") - try content.config.write(to: profileConfig, atomically: true, encoding: .utf8) - var lastUpdated: Date? - if content.lastUpdated > 0 { - lastUpdated = Date(timeIntervalSince1970: Double(content.lastUpdated)) - } - try await ProfileManager.create(Profile(name: content.name, type: type, path: profileConfig.relativePath, remoteURL: content.remotePath, autoUpdate: content.autoUpdate, lastUpdated: lastUpdated)) - await reset() - await MainActor.run { - environments.profileUpdate.send() - dismiss() - } - } } #endif diff --git a/ApplicationLibrary/Views/Profile/ImportProfileViewModel.swift b/ApplicationLibrary/Views/Profile/ImportProfileViewModel.swift new file mode 100644 index 0000000..619d61a --- /dev/null +++ b/ApplicationLibrary/Views/Profile/ImportProfileViewModel.swift @@ -0,0 +1,157 @@ +#if os(tvOS) + + import DeviceDiscoveryUI + import Libbox + import Library + import Network + import SwiftUI + + @MainActor + public final class ImportProfileViewModel: ObservableObject { + @Published public var isLoading = false + @Published public var selected = false + @Published public var alert: Alert? + @Published public var connection: NWConnection? + @Published public var socket: NWSocket? + @Published public var profiles: [LibboxProfilePreview]? + @Published public var isImporting = false + + public init() {} + + public func reset() { + if let connection { + connection.stateUpdateHandler = nil + connection.cancel() + self.connection = nil + } + if let socket { + socket.cancel() + self.socket = nil + } + selected = false + profiles = nil + } + + public func handleEndpoint(_ endpoint: NWEndpoint, environments: ExtensionEnvironments, dismiss: DismissAction) async { + let connection = NWConnection(to: endpoint, using: NWParameters.applicationService) + self.connection = connection + socket = NWSocket(connection) + connection.stateUpdateHandler = { state in + switch state { + case let .failed(error): + DispatchQueue.main.async { [self] in + reset() + alert = Alert(error) + } + default: break + } + } + connection.start(queue: .global()) + do { + try await loopMessages(environments: environments, dismiss: dismiss) + } catch { + alert = Alert(error) + reset() + } + } + + private nonisolated func loopMessages(environments: ExtensionEnvironments, dismiss: DismissAction) async throws { + guard let socket = await socket else { + return + } + var message: Data + while true { + do { + message = try socket.read() + } catch { + throw NSError(domain: "read from connection: \(error.localizedDescription)", code: 0) + } + var error: NSError? + switch Int64(message[0]) { + case LibboxMessageTypeError: + let message = LibboxDecodeErrorMessage(message, &error) + if let error { + throw error + } + if let message { + throw NSError(domain: "remote error: \(message.message)", code: 0) + } + case LibboxMessageTypeProfileList: + let decoder = LibboxProfileDecoder() + try decoder.decode(message) + let iterator = decoder.iterator()! + var profiles = [LibboxProfilePreview]() + while iterator.hasNext() { + let profile = iterator.next()! + if profile.type == LibboxProfileTypeiCloud { + continue + } + profiles.append(profile) + } + await MainActor.run { [self, profiles] in + self.profiles = profiles + isImporting = false + } + case LibboxMessageTypeProfileContent: + let content = LibboxDecodeProfileContent(message, &error) + if let error { + throw error + } + try await importProfile(content!, environments: environments, dismiss: dismiss) + return + default: + throw NSError(domain: "unknown message type \(message[0])", code: 0) + } + } + } + + public func selectProfile(profileID: Int64) { + guard let connection else { + return + } + guard let socket else { + return + } + connection.stateUpdateHandler = nil + let request = LibboxProfileContentRequest() + request.profileID = profileID + do { + try socket.write(request.encode()) + isImporting = true + } catch { + alert = Alert(error) + reset() + } + } + + private nonisolated func importProfile(_ content: LibboxProfileContent, environments: ExtensionEnvironments, dismiss: DismissAction) async throws { + var type: ProfileType = .local + switch content.type { + case LibboxProfileTypeLocal: + type = .local + case LibboxProfileTypeiCloud: + type = .icloud + case LibboxProfileTypeRemote: + type = .remote + default: + break + } + let nextProfileID = try await ProfileManager.nextID() + let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true) + try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true) + let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json") + try content.config.write(to: profileConfig, atomically: true, encoding: .utf8) + var lastUpdated: Date? + if content.lastUpdated > 0 { + lastUpdated = Date(timeIntervalSince1970: Double(content.lastUpdated)) + } + try await ProfileManager.create(Profile(name: content.name, type: type, path: profileConfig.relativePath, remoteURL: content.remotePath, autoUpdate: content.autoUpdate, lastUpdated: lastUpdated)) + await reset() + await MainActor.run { + environments.profileUpdate.send() + dismiss() + } + } + } + +#endif diff --git a/ApplicationLibrary/Views/Profile/NewProfileView.swift b/ApplicationLibrary/Views/Profile/NewProfileView.swift index bf95512..2a3cd51 100644 --- a/ApplicationLibrary/Views/Profile/NewProfileView.swift +++ b/ApplicationLibrary/Views/Profile/NewProfileView.swift @@ -7,21 +7,7 @@ import SwiftUI public struct NewProfileView: View { @EnvironmentObject private var environments: ExtensionEnvironments @Environment(\.dismiss) private var dismiss - - @State private var isSaving = false - @State private var profileName = "" - #if !os(tvOS) - @State private var profileType = ProfileType.local - #else - @State private var profileType = ProfileType.remote - #endif - @State private var fileImport = false - @State private var fileURL: URL! - @State private var remotePath = "" - @State private var autoUpdate = true - @State private var autoUpdateInterval: Int32 = 60 - @State private var pickerPresented = false - @State private var alert: Alert? + @StateObject private var viewModel: NewProfileViewModel public struct ImportRequest: Codable, Hashable { public let name: String @@ -29,20 +15,16 @@ public struct NewProfileView: View { } public init(_ importRequest: ImportRequest? = nil) { - if let importRequest { - _profileName = .init(initialValue: importRequest.name) - _profileType = .init(initialValue: .remote) - _remotePath = .init(initialValue: importRequest.url) - } + _viewModel = StateObject(wrappedValue: NewProfileViewModel(importRequest: importRequest)) } public var body: some View { FormView { FormItem(String(localized: "Name")) { - TextField("Name", text: $profileName, prompt: Text("Required")) + TextField("Name", text: $viewModel.profileName, prompt: Text("Required")) .multilineTextAlignment(.trailing) } - Picker(selection: $profileType) { + Picker(selection: $viewModel.profileType) { #if !os(tvOS) Text("Local").tag(ProfileType.local) Text("iCloud").tag(ProfileType.icloud) @@ -51,8 +33,8 @@ public struct NewProfileView: View { } label: { Text("Type") } - if profileType == .local { - Picker(selection: $fileImport) { + if viewModel.profileType == .local { + Picker(selection: $viewModel.fileImport) { Text("Create New").tag(false) Text("Import").tag(true) } label: { @@ -62,42 +44,42 @@ public struct NewProfileView: View { .disabled(true) #endif viewBuilder { - if fileImport { + if viewModel.fileImport { HStack { Text("File Path") Spacer() Spacer() - if let fileURL { + if let fileURL = viewModel.fileURL { Button(fileURL.fileName) { - pickerPresented = true + viewModel.pickerPresented = true } } else { Button("Choose") { - pickerPresented = true + viewModel.pickerPresented = true } } } } } - } else if profileType == .icloud { + } else if viewModel.profileType == .icloud { FormItem(String(localized: "Path")) { - TextField("Path", text: $remotePath, prompt: Text("Required")) + TextField("Path", text: $viewModel.remotePath, prompt: Text("Required")) .multilineTextAlignment(.trailing) #if !os(macOS) .keyboardType(.asciiCapableNumberPad) #endif } - } else if profileType == .remote { + } else if viewModel.profileType == .remote { FormItem(String(localized: "URL")) { - TextField("URL", text: $remotePath, prompt: Text("Required")) + TextField("URL", text: $viewModel.remotePath, prompt: Text("Required")) .multilineTextAlignment(.trailing) #if !os(macOS) .keyboardType(.URL) #endif } - Toggle("Auto Update", isOn: $autoUpdate) + Toggle("Auto Update", isOn: $viewModel.autoUpdate) FormItem(String(localized: "Auto Update Interval")) { - TextField("Auto Update Interval", text: $autoUpdateInterval.stringBinding(defaultValue: 60), prompt: Text("In Minutes")) + TextField("Auto Update Interval", text: $viewModel.autoUpdateInterval.stringBinding(defaultValue: 60), prompt: Text("In Minutes")) .multilineTextAlignment(.trailing) #if !os(macOS) .keyboardType(.numberPad) @@ -105,11 +87,11 @@ public struct NewProfileView: View { } } Section { - if !isSaving { + if !viewModel.isSaving { FormButton { - isSaving = true + viewModel.isSaving = true Task { - await createProfile() + await viewModel.createProfile(environments: environments, dismiss: dismiss) } } label: { Label("Create", systemImage: "doc.fill.badge.plus") @@ -120,143 +102,23 @@ public struct NewProfileView: View { } } .navigationTitle("New Profile") - .alertBinding($alert) + .alertBinding($viewModel.alert) #if os(iOS) || os(macOS) .fileImporter( - isPresented: $pickerPresented, + isPresented: $viewModel.pickerPresented, allowedContentTypes: [.json], allowsMultipleSelection: false ) { result in do { let urls = try result.get() if !urls.isEmpty { - fileURL = urls[0] + viewModel.fileURL = urls[0] } } catch { - alert = Alert(error) + viewModel.alert = Alert(error) return } } #endif } - - private func createProfile() async { - defer { - isSaving = false - } - if profileName.isEmpty { - alert = Alert(errorMessage: String(localized: "Missing profile name")) - return - } - if remotePath.isEmpty { - if profileType == .icloud { - alert = Alert(errorMessage: String(localized: "Missing path")) - return - } else if profileType == .remote { - alert = Alert(errorMessage: String(localized: "Missing URL")) - return - } - } - do { - try await createProfileBackground() - } catch { - alert = Alert(error) - return - } - environments.profileUpdate.send() - dismiss() - #if os(macOS) - resetFields() - #endif - } - - private func resetFields() { - profileName = "" - profileType = .local - fileImport = false - fileURL = nil - remotePath = "" - } - - private nonisolated func createProfileBackground() async throws { - let nextProfileID = try await ProfileManager.nextID() - - var savePath = "" - var remoteURL: String? = nil - var lastUpdated: Date? = nil - - let profileName = await profileName - let profileType = await profileType - let fileImport = await fileImport - let fileURL = await fileURL - let remotePath = await remotePath - let autoUpdate = await autoUpdate - let autoUpdateInterval = await autoUpdateInterval - - if profileType == .local { - let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true) - try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true) - let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json") - if fileImport { - guard let fileURL else { - throw NSError(domain: "Missing file", code: 0) - } - if !fileURL.startAccessingSecurityScopedResource() { - throw NSError(domain: "Missing access to selected file", code: 0) - } - defer { - fileURL.stopAccessingSecurityScopedResource() - } - try String(contentsOf: fileURL).write(to: profileConfig, atomically: true, encoding: .utf8) - } else { - try "{}".write(to: profileConfig, atomically: true, encoding: .utf8) - } - savePath = profileConfig.relativePath - } else if profileType == .icloud { - if !FileManager.default.fileExists(atPath: FilePath.iCloudDirectory.path) { - try FileManager.default.createDirectory(at: FilePath.iCloudDirectory, withIntermediateDirectories: true) - } - let saveURL = FilePath.iCloudDirectory.appendingPathComponent(remotePath, isDirectory: false) - _ = saveURL.startAccessingSecurityScopedResource() - defer { - saveURL.stopAccessingSecurityScopedResource() - } - do { - _ = try String(contentsOf: saveURL) - } catch { - try "{}".write(to: saveURL, atomically: true, encoding: .utf8) - } - savePath = remotePath - } else if profileType == .remote { - let remoteContent = try HTTPClient().getString(remotePath) - var error: NSError? - LibboxCheckConfig(remoteContent, &error) - if let error { - throw error - } - let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true) - try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true) - let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json") - try remoteContent.write(to: profileConfig, atomically: true, encoding: .utf8) - savePath = profileConfig.relativePath - remoteURL = remotePath - lastUpdated = .now - } - try await ProfileManager.create(Profile( - name: profileName, - type: profileType, - path: savePath, - remoteURL: remoteURL, - autoUpdate: autoUpdate, - autoUpdateInterval: autoUpdateInterval, - lastUpdated: lastUpdated - )) - if profileType == .remote { - #if os(iOS) || os(tvOS) - try UIProfileUpdateTask.configure() - #else - try await ProfileUpdateTask.configure() - #endif - } - } } diff --git a/ApplicationLibrary/Views/Profile/NewProfileViewModel.swift b/ApplicationLibrary/Views/Profile/NewProfileViewModel.swift new file mode 100644 index 0000000..cd4204c --- /dev/null +++ b/ApplicationLibrary/Views/Profile/NewProfileViewModel.swift @@ -0,0 +1,150 @@ +import Foundation +import Libbox +import Library +import SwiftUI + +@MainActor +public final class NewProfileViewModel: ObservableObject { + @Published public var isSaving = false + @Published public var profileName = "" + #if !os(tvOS) + @Published public var profileType = ProfileType.local + #else + @Published public var profileType = ProfileType.remote + #endif + @Published public var fileImport = false + @Published public var fileURL: URL? + @Published public var remotePath = "" + @Published public var autoUpdate = true + @Published public var autoUpdateInterval: Int32 = 60 + @Published public var pickerPresented = false + @Published public var alert: Alert? + + public init(importRequest: NewProfileView.ImportRequest? = nil) { + if let importRequest { + profileName = importRequest.name + profileType = .remote + remotePath = importRequest.url + } + } + + public func resetFields() { + profileName = "" + profileType = .local + fileImport = false + fileURL = nil + remotePath = "" + } + + public func createProfile(environments: ExtensionEnvironments, dismiss: DismissAction) async { + defer { + isSaving = false + } + if profileName.isEmpty { + alert = Alert(errorMessage: String(localized: "Missing profile name")) + return + } + if remotePath.isEmpty { + if profileType == .icloud { + alert = Alert(errorMessage: String(localized: "Missing path")) + return + } else if profileType == .remote { + alert = Alert(errorMessage: String(localized: "Missing URL")) + return + } + } + do { + try await createProfileBackground() + } catch { + alert = Alert(error) + return + } + environments.profileUpdate.send() + dismiss() + #if os(macOS) + resetFields() + #endif + } + + private nonisolated func createProfileBackground() async throws { + let nextProfileID = try await ProfileManager.nextID() + + var savePath = "" + var remoteURL: String? + var lastUpdated: Date? + + let profileName = await profileName + let profileType = await profileType + let fileImport = await fileImport + let fileURL = await fileURL + let remotePath = await remotePath + let autoUpdate = await autoUpdate + let autoUpdateInterval = await autoUpdateInterval + + if profileType == .local { + let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true) + try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true) + let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json") + if fileImport { + guard let fileURL else { + throw NSError(domain: "Missing file", code: 0) + } + if !fileURL.startAccessingSecurityScopedResource() { + throw NSError(domain: "Missing access to selected file", code: 0) + } + defer { + fileURL.stopAccessingSecurityScopedResource() + } + try String(contentsOf: fileURL).write(to: profileConfig, atomically: true, encoding: .utf8) + } else { + try "{}".write(to: profileConfig, atomically: true, encoding: .utf8) + } + savePath = profileConfig.relativePath + } else if profileType == .icloud { + if !FileManager.default.fileExists(atPath: FilePath.iCloudDirectory.path) { + try FileManager.default.createDirectory(at: FilePath.iCloudDirectory, withIntermediateDirectories: true) + } + let saveURL = FilePath.iCloudDirectory.appendingPathComponent(remotePath, isDirectory: false) + _ = saveURL.startAccessingSecurityScopedResource() + defer { + saveURL.stopAccessingSecurityScopedResource() + } + do { + _ = try String(contentsOf: saveURL) + } catch { + try "{}".write(to: saveURL, atomically: true, encoding: .utf8) + } + savePath = remotePath + } else if profileType == .remote { + let remoteContent = try HTTPClient().getString(remotePath) + var error: NSError? + LibboxCheckConfig(remoteContent, &error) + if let error { + throw error + } + let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true) + try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true) + let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json") + try remoteContent.write(to: profileConfig, atomically: true, encoding: .utf8) + savePath = profileConfig.relativePath + remoteURL = remotePath + lastUpdated = .now + } + try await ProfileManager.create(Profile( + name: profileName, + type: profileType, + path: savePath, + remoteURL: remoteURL, + autoUpdate: autoUpdate, + autoUpdateInterval: autoUpdateInterval, + lastUpdated: lastUpdated + )) + if profileType == .remote { + #if os(iOS) || os(tvOS) + try UIProfileUpdateTask.configure() + #else + try await ProfileUpdateTask.configure() + #endif + } + } +} diff --git a/ApplicationLibrary/Views/Profile/ProfileView.swift b/ApplicationLibrary/Views/Profile/ProfileView.swift index 236445c..59ba6a8 100644 --- a/ApplicationLibrary/Views/Profile/ProfileView.swift +++ b/ApplicationLibrary/Views/Profile/ProfileView.swift @@ -10,18 +10,7 @@ public struct ProfileView: View { @EnvironmentObject private var environments: ExtensionEnvironments @Environment(\.importProfile) private var importProfile @Environment(\.importRemoteProfile) private var importRemoteProfile - @State private var importRemoteProfileRequest: NewProfileView.ImportRequest? - @State private var importRemoteProfilePresented = false - - @State private var isLoading = true - @State private var isUpdating = false - - @State private var alert: Alert? - @State private var profileList: [ProfilePreview] = [] - - #if os(iOS) || os(tvOS) - @State private var editMode = EditMode.inactive - #endif + @StateObject private var viewModel = ProfileViewModel() #if os(tvOS) @Environment(\.devicePickerSupports) private var devicePickerSupports @@ -30,16 +19,17 @@ public struct ProfileView: View { public init() {} public var body: some View { VStack { - if isLoading { + if viewModel.isLoading { ProgressView().onAppear { + viewModel.setEnvironments(environments) Task { - await doReload() + await viewModel.doReload() } } } else { ZStack { - if let importRemoteProfileRequest { - NavigationDestinationCompat(isPresented: $importRemoteProfilePresented) { + if let importRemoteProfileRequest = viewModel.importRemoteProfileRequest { + NavigationDestinationCompat(isPresented: $viewModel.importRemoteProfilePresented) { NewProfileView(importRemoteProfileRequest) } } @@ -50,7 +40,7 @@ public struct ProfileView: View { } label: { Text("New Profile").foregroundColor(.accentColor) } - .disabled(editMode.isEditing) + .disabled(viewModel.editMode.isEditing) #elseif os(macOS) FormNavigationLink { NewProfileView() @@ -73,20 +63,20 @@ public struct ProfileView: View { } } #endif - if profileList.isEmpty { + if viewModel.profileList.isEmpty { Text("Empty profiles") } else { List { - ForEach(profileList, id: \.id) { profile in + ForEach(viewModel.profileList, id: \.id) { profile in viewBuilder { #if os(iOS) || os(tvOS) - if editMode.isEditing == true { + if viewModel.editMode.isEditing == true { Text(profile.name) } else { - ProfileItem(self, profile) + ProfileItem(viewModel, profile) } #else - ProfileItem(self, profile) + ProfileItem(viewModel, profile) #endif } } @@ -98,55 +88,55 @@ public struct ProfileView: View { } } } - .disabled(isUpdating) - .alertBinding($alert, $isLoading) + .disabled(viewModel.isUpdating) + .alertBinding($viewModel.alert, $viewModel.isLoading) .onAppear { if let profile = importProfile.wrappedValue { importProfile.wrappedValue = nil - createImportProfileDialog(profile) + viewModel.createImportProfileDialog(profile) } if let remoteProfile = importRemoteProfile.wrappedValue { importRemoteProfile.wrappedValue = nil - createImportRemoteProfileDialog(remoteProfile) + viewModel.createImportRemoteProfileDialog(remoteProfile) } } .onChangeCompat(of: importProfile.wrappedValue) { newValue in if let newValue { importProfile.wrappedValue = nil - createImportProfileDialog(newValue) + viewModel.createImportProfileDialog(newValue) } } .onChangeCompat(of: importRemoteProfile.wrappedValue) { newValue in if let newValue { importRemoteProfile.wrappedValue = nil - createImportRemoteProfileDialog(newValue) + viewModel.createImportRemoteProfileDialog(newValue) } } .onReceive(environments.profileUpdate) { _ in Task { - await doReload() + await viewModel.doReload() } } #if os(iOS) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - EditButton().disabled(profileList.isEmpty && !editMode.isEditing) + EditButton().disabled(viewModel.profileList.isEmpty && !viewModel.editMode.isEditing) } } #elseif os(tvOS) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - if editMode == .inactive { + if viewModel.editMode == .inactive { Button(action: { - editMode = .active + viewModel.editMode = .active }) { Image(systemName: "square.and.pencil") } .tint(.accentColor) - .disabled(profileList.isEmpty) + .disabled(viewModel.profileList.isEmpty) } else { Button(action: { - editMode = .inactive + viewModel.editMode = .inactive }) { Image(systemName: "checkmark.square.fill") } @@ -156,126 +146,27 @@ public struct ProfileView: View { } #endif #if os(iOS) || os(tvOS) - .environment(\.editMode, $editMode) + .environment(\.editMode, $viewModel.editMode) #endif } - private func createImportProfileDialog(_ profile: LibboxProfileContent) { - alert = Alert( - title: Text("Import Profile"), - message: Text("Are you sure to import profile \(profile.name)?"), - primaryButton: .default(Text("Import")) { - Task { - do { - try await profile.importProfile() - } catch { - alert = Alert(error) - return - } - await doReload() - } - }, - secondaryButton: .cancel() - ) - } - - private func createImportRemoteProfileDialog(_ newValue: LibboxImportRemoteProfile) { - importRemoteProfileRequest = .init(name: newValue.name, url: newValue.url) - alert = Alert( - title: Text("Import Remote Profile"), - message: Text("Are you sure to import remote profile \(newValue.name)? You will connect to \(newValue.host) to download the configuration."), - primaryButton: .default(Text("Import")) { - importRemoteProfilePresented = true - }, - secondaryButton: .cancel() - ) - } - - private func doReload() 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))), - ] - } else { - do { - profileList = try await ProfileManager.list().map { ProfilePreview($0) } - } catch { - alert = Alert(error) - return - } - } - environments.emptyProfiles = profileList.isEmpty - } - - private func updateProfile(_ profile: Profile) async { - await updateProfileBackground(profile) - isUpdating = false - } - - private nonisolated func updateProfileBackground(_ profile: Profile) async { - do { - _ = try await profile.updateRemoteProfile() - } catch { - await MainActor.run { - alert = Alert(error) - } - } - } - - private func deleteProfile(_ profile: Profile) async { - do { - _ = try await ProfileManager.delete(profile) - } catch { - alert = Alert(error) - return - } - environments.profileUpdate.send() - } - private func moveProfile(from source: IndexSet, to destination: Int) { - profileList.move(fromOffsets: source, toOffset: destination) - for (index, profile) in profileList.enumerated() { - profileList[index].order = UInt32(index) - profile.origin.order = UInt32(index) - } - Task { - do { - try await ProfileManager.update(profileList.map(\.origin)) - } catch { - alert = Alert(error) - } - environments.profileUpdate.send() - } + viewModel.moveProfile(from: source, to: destination) } private func deleteProfile(where profileIndex: IndexSet) { - let profileToDelete = profileIndex.map { index in - profileList[index].origin - } - profileList.remove(atOffsets: profileIndex) - environments.emptyProfiles = profileList.isEmpty - Task { - do { - _ = try await ProfileManager.delete(profileToDelete) - } catch { - alert = Alert(error) - } - environments.profileUpdate.send() - } + viewModel.deleteProfile(where: profileIndex) } @MainActor public struct ProfileItem: View { - private let parent: ProfileView + @EnvironmentObject private var environments: ExtensionEnvironments + @ObservedObject private var viewModel: ProfileViewModel @State private var profile: ProfilePreview @State private var shareLinkPresented = false - public init(_ parent: ProfileView, _ profile: ProfilePreview) { - self.parent = parent + public init(_ viewModel: ProfileViewModel, _ profile: ProfilePreview) { + self.viewModel = viewModel _profile = State(initialValue: profile) } @@ -303,7 +194,7 @@ public struct ProfileView: View { shareLinkView.padding() } .contextMenu { - ProfileShareButton(parent.$alert, profile.origin) { + ProfileShareButton($viewModel.alert, profile.origin) { Label("Share", systemImage: "square.and.arrow.up.fill") } @@ -314,9 +205,9 @@ public struct ProfileView: View { Label("Share URL as QR Code", systemImage: "qrcode") } Button { - parent.isUpdating = true + viewModel.isUpdating = true Task { - await parent.updateProfile(profile.origin) + await viewModel.updateProfile(profile.origin) profile = ProfilePreview(profile.origin) } } label: { @@ -325,7 +216,7 @@ public struct ProfileView: View { } Button(role: .destructive) { Task { - await parent.deleteProfile(profile.origin) + await viewModel.deleteProfile(profile.origin) } } label: { Label("Delete", systemImage: "trash.fill") @@ -346,9 +237,9 @@ public struct ProfileView: View { HStack { if profile.type == .remote { Button { - parent.isUpdating = true + viewModel.isUpdating = true Task { - await parent.updateProfile(profile.origin) + await viewModel.updateProfile(profile.origin) profile = ProfilePreview(profile.origin) } } label: { @@ -366,13 +257,13 @@ public struct ProfileView: View { shareLinkView } } - ProfileShareButton(parent.$alert, profile.origin) { + ProfileShareButton($viewModel.alert, profile.origin) { Image(systemName: "square.and.arrow.up.fill") } .padding(.leading, 4) Button { Task { - await parent.deleteProfile(profile.origin) + await viewModel.deleteProfile(profile.origin) } } label: { Image(systemName: "trash.fill") diff --git a/ApplicationLibrary/Views/Profile/ProfileViewModel.swift b/ApplicationLibrary/Views/Profile/ProfileViewModel.swift new file mode 100644 index 0000000..bebc868 --- /dev/null +++ b/ApplicationLibrary/Views/Profile/ProfileViewModel.swift @@ -0,0 +1,135 @@ +import Foundation +import Libbox +import Library +import SwiftUI + +@MainActor +public class ProfileViewModel: ObservableObject { + @Published public var importRemoteProfileRequest: NewProfileView.ImportRequest? + @Published public var importRemoteProfilePresented = false + @Published public var isLoading = true + @Published public var isUpdating = false + @Published public var alert: Alert? + @Published public var profileList: [ProfilePreview] = [] + + #if os(iOS) || os(tvOS) + @Published public var editMode = EditMode.inactive + #endif + + private weak var environments: ExtensionEnvironments? + + public init() {} + + public func setEnvironments(_ environments: ExtensionEnvironments) { + self.environments = environments + } + + public func createImportProfileDialog(_ profile: LibboxProfileContent) { + alert = Alert( + title: Text("Import Profile"), + message: Text("Are you sure to import profile \(profile.name)?"), + primaryButton: .default(Text("Import")) { + Task { + do { + try await profile.importProfile() + } catch { + self.alert = Alert(error) + return + } + await self.doReload() + self.environments?.emptyProfiles = self.profileList.isEmpty + } + }, + secondaryButton: .cancel() + ) + } + + public func createImportRemoteProfileDialog(_ newValue: LibboxImportRemoteProfile) { + importRemoteProfileRequest = .init(name: newValue.name, url: newValue.url) + alert = Alert( + title: Text("Import Remote Profile"), + message: Text("Are you sure to import remote profile \(newValue.name)? You will connect to \(newValue.host) to download the configuration."), + primaryButton: .default(Text("Import")) { + self.importRemoteProfilePresented = true + }, + secondaryButton: .cancel() + ) + } + + public func doReload() 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))), + ] + } else { + do { + profileList = try await ProfileManager.list().map { ProfilePreview($0) } + } catch { + alert = Alert(error) + return + } + } + environments?.emptyProfiles = profileList.isEmpty + } + + public func updateProfile(_ profile: Profile) async { + await updateProfileBackground(profile) + isUpdating = false + } + + private nonisolated func updateProfileBackground(_ profile: Profile) async { + do { + _ = try await profile.updateRemoteProfile() + } catch { + await MainActor.run { + alert = Alert(error) + } + } + } + + public func deleteProfile(_ profile: Profile) async { + do { + _ = try await ProfileManager.delete(profile) + environments?.profileUpdate.send() + environments?.emptyProfiles = profileList.isEmpty + } catch { + alert = Alert(error) + } + } + + public func moveProfile(from source: IndexSet, to destination: Int) { + profileList.move(fromOffsets: source, toOffset: destination) + for (index, profile) in profileList.enumerated() { + profileList[index].order = UInt32(index) + profile.origin.order = UInt32(index) + } + Task { + do { + try await ProfileManager.update(profileList.map(\.origin)) + environments?.profileUpdate.send() + } catch { + alert = Alert(error) + } + } + } + + public func deleteProfile(where profileIndex: IndexSet) { + let profileToDelete = profileIndex.map { index in + profileList[index].origin + } + profileList.remove(atOffsets: profileIndex) + Task { + do { + _ = try await ProfileManager.delete(profileToDelete) + environments?.emptyProfiles = profileList.isEmpty + environments?.profileUpdate.send() + } catch { + alert = Alert(error) + } + } + } +} diff --git a/ApplicationLibrary/Views/Setting/ServiceLogView.swift b/ApplicationLibrary/Views/Setting/ServiceLogView.swift index 3c07ff3..cee10e4 100644 --- a/ApplicationLibrary/Views/Setting/ServiceLogView.swift +++ b/ApplicationLibrary/Views/Setting/ServiceLogView.swift @@ -5,10 +5,7 @@ import SwiftUI @MainActor public struct ServiceLogView: View { @Environment(\.dismiss) private var dismiss - - @State private var isLoading = true - @State private var content = "" - @State private var alert: Alert? + @StateObject private var viewModel = ServiceLogViewModel() private let logFont = Font.system(.caption, design: .monospaced) @@ -16,18 +13,18 @@ public struct ServiceLogView: View { public var body: some View { viewBuilder { - if isLoading { + if viewModel.isLoading { ProgressView().onAppear { Task { - await loadContent() + await viewModel.loadContent() } } } else { - if content.isEmpty { + if viewModel.isEmpty { Text("Empty content") } else { ScrollView { - Text(content) + Text(viewModel.content) .font(logFont) .frame(maxWidth: .infinity, alignment: .topLeading) } @@ -36,17 +33,17 @@ public struct ServiceLogView: View { } } .toolbar { - if !content.isEmpty { + if !viewModel.isEmpty { #if !os(tvOS) - ShareButtonCompat($alert) { + ShareButtonCompat($viewModel.alert) { Label("Export", systemImage: "square.and.arrow.up.fill") } itemURL: { - try content.generateShareFile(name: "service.log") + try viewModel.generateShareFile() } #endif Button(role: .destructive) { Task { - await deleteContent() + await viewModel.deleteContent(dismiss: dismiss) } } label: { #if !os(tvOS) @@ -58,56 +55,10 @@ public struct ServiceLogView: View { } } } - .alertBinding($alert) + .alertBinding($viewModel.alert) .navigationTitle("Service Log") #if os(tvOS) .focusable() #endif } - - private nonisolated func loadContent() async { - var content = "" - do { - content = try String(contentsOf: FilePath.cacheDirectory.appendingPathComponent("stderr.log")) - } catch {} - if content.isEmpty { - do { - content = try String(contentsOf: FilePath.cacheDirectory.appendingPathComponent("stderr.log.old")) - } catch {} - } - #if DEBUG - if content.isEmpty { - content = "Empty content" - } - #endif - if !content.isEmpty { - var systemInfo = utsname() - uname(&systemInfo) - let machineMirror = Mirror(reflecting: systemInfo.machine) - let machineName = machineMirror.children.reduce("") { identifier, element in - guard let value = element.value as? Int8, value != 0 else { return identifier } - return identifier + String(UnicodeScalar(UInt8(value))) - } - var deviceInfo = String("Machine: ") + machineName + "\n" - #if os(iOS) - await deviceInfo += String("System: ") + (UIDevice.current.systemName) + " " + (UIDevice.current.systemVersion) + "\n" - #elseif os(macOS) - deviceInfo += String("System: ") + "macOS " + ProcessInfo().operatingSystemVersionString + "\n" - #endif - content = deviceInfo + "\n" + content - } - await MainActor.run { [content] in - self.content = content - isLoading = false - } - } - - private nonisolated func deleteContent() async { - try? FileManager.default.removeItem(at: FilePath.cacheDirectory.appendingPathComponent("stderr.log")) - try? FileManager.default.removeItem(at: FilePath.cacheDirectory.appendingPathComponent("stderr.log.old")) - await MainActor.run { - dismiss() - isLoading = true - } - } } diff --git a/ApplicationLibrary/Views/Setting/ServiceLogViewModel.swift b/ApplicationLibrary/Views/Setting/ServiceLogViewModel.swift new file mode 100644 index 0000000..29e1af1 --- /dev/null +++ b/ApplicationLibrary/Views/Setting/ServiceLogViewModel.swift @@ -0,0 +1,64 @@ +import Foundation +import Library +import SwiftUI + +@MainActor +final class ServiceLogViewModel: ObservableObject { + @Published var isLoading = true + @Published var content = "" + @Published var alert: Alert? + + var isEmpty: Bool { + content.isEmpty + } + + nonisolated func loadContent() async { + var content = "" + do { + content = try String(contentsOf: FilePath.cacheDirectory.appendingPathComponent("stderr.log")) + } catch {} + if content.isEmpty { + do { + content = try String(contentsOf: FilePath.cacheDirectory.appendingPathComponent("stderr.log.old")) + } catch {} + } + #if DEBUG + if content.isEmpty { + content = "Empty content" + } + #endif + if !content.isEmpty { + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + let machineName = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + var deviceInfo = String("Machine: ") + machineName + "\n" + #if os(iOS) + await deviceInfo += String("System: ") + (UIDevice.current.systemName) + " " + (UIDevice.current.systemVersion) + "\n" + #elseif os(macOS) + deviceInfo += String("System: ") + "macOS " + ProcessInfo().operatingSystemVersionString + "\n" + #endif + content = deviceInfo + "\n" + content + } + await MainActor.run { [content] in + self.content = content + isLoading = false + } + } + + nonisolated func deleteContent(dismiss: DismissAction) async { + try? FileManager.default.removeItem(at: FilePath.cacheDirectory.appendingPathComponent("stderr.log")) + try? FileManager.default.removeItem(at: FilePath.cacheDirectory.appendingPathComponent("stderr.log.old")) + await MainActor.run { + dismiss() + isLoading = true + } + } + + func generateShareFile() throws -> URL { + try content.generateShareFile(name: "service.log") + } +} diff --git a/ApplicationLibrary/Views/Setting/SettingView.swift b/ApplicationLibrary/Views/Setting/SettingView.swift index 8cc2172..f5726eb 100644 --- a/ApplicationLibrary/Views/Setting/SettingView.swift +++ b/ApplicationLibrary/Views/Setting/SettingView.swift @@ -92,8 +92,7 @@ public struct SettingView: View { } } - @State private var isLoading = true - @State private var taiwanFlagAvailable = false + @StateObject private var viewModel = SettingViewModel() public init() {} public var body: some View { @@ -146,24 +145,15 @@ public struct SettingView: View { Label("Service Log", systemImage: "doc.on.clipboard") } FormTextItem("Taiwan Flag Available", "touchid") { - if isLoading { + if viewModel.isLoading { Text("Loading...") .onAppear { Task.detached { - let available: Bool - if ApplicationLibrary.inPreview { - available = true - } else { - available = !DeviceCensorship.isChinaDevice() - } - await MainActor.run { - taiwanFlagAvailable = available - isLoading = false - } + await viewModel.checkTaiwanFlagAvailability() } } } else { - Text(taiwanFlagAvailable.toString()) + Text(viewModel.taiwanFlagAvailable.toString()) } } } diff --git a/ApplicationLibrary/Views/Setting/SettingViewModel.swift b/ApplicationLibrary/Views/Setting/SettingViewModel.swift new file mode 100644 index 0000000..9ff5898 --- /dev/null +++ b/ApplicationLibrary/Views/Setting/SettingViewModel.swift @@ -0,0 +1,21 @@ +import Library +import SwiftUI + +@MainActor +final class SettingViewModel: ObservableObject { + @Published var isLoading = true + @Published var taiwanFlagAvailable = false + + nonisolated func checkTaiwanFlagAvailability() async { + let available: Bool + if ApplicationLibrary.inPreview { + available = true + } else { + available = !DeviceCensorship.isChinaDevice() + } + await MainActor.run { + taiwanFlagAvailable = available + isLoading = false + } + } +} diff --git a/Library/Network/ExtensionErrors.swift b/Library/Network/ExtensionErrors.swift index 54f08e1..3b62707 100644 --- a/Library/Network/ExtensionErrors.swift +++ b/Library/Network/ExtensionErrors.swift @@ -7,7 +7,7 @@ public enum FullDiskAccessPermissionRequired: Error { public class ExtensionStartupError: Error { let message: String - init(_ message: String) { + public init(_ message: String) { self.message = message } } diff --git a/MacLibrary/MainView.swift b/MacLibrary/MainView.swift index 7e5d914..885ac7b 100644 --- a/MacLibrary/MainView.swift +++ b/MacLibrary/MainView.swift @@ -1,5 +1,4 @@ import ApplicationLibrary -import Libbox import Library import SwiftUI @@ -7,11 +6,7 @@ import SwiftUI public struct MainView: View { @Environment(\.controlActiveState) private var controlActiveState @EnvironmentObject private var environments: ExtensionEnvironments - - @State private var selection = NavigationPage.dashboard - @State private var importProfile: LibboxProfileContent? - @State private var importRemoteProfile: LibboxImportRemoteProfile? - @State private var alert: Alert? + @StateObject private var viewModel = MainViewModel() public init() {} @@ -21,96 +16,34 @@ public struct MainView: View { .navigationSplitViewColumnWidth(150) } detail: { NavigationStack { - selection.contentView - .navigationTitle(selection.title) + viewModel.selection.contentView + .navigationTitle(viewModel.selection.title) } .navigationSplitViewColumnWidth(650) } .frame(minHeight: 500) .onAppear { - environments.postReload() - #if !DEBUG - if Variant.useSystemExtension { - Task { - checkApplicationPath() - } - } - #endif + viewModel.onAppear(environments: environments) } - .alertBinding($alert) + .alertBinding($viewModel.alert) .toolbar { ToolbarItem(placement: .navigation) { StartStopButton() } } .onChangeCompat(of: controlActiveState) { newValue in - if newValue != .inactive { - environments.postReload() - } + viewModel.onControlActiveStateChange(newValue, environments: environments) } - .onChangeCompat(of: selection) { value in - if value == .logs { - environments.connectLog() - } + .onChangeCompat(of: viewModel.selection) { value in + viewModel.onSelectionChange(value, environments: environments) } .onReceive(environments.openSettings) { - selection = .settings + viewModel.openSettings() } - .environment(\.selection, $selection) - .environment(\.importProfile, $importProfile) - .environment(\.importRemoteProfile, $importRemoteProfile) + .environment(\.selection, $viewModel.selection) + .environment(\.importProfile, $viewModel.importProfile) + .environment(\.importRemoteProfile, $viewModel.importRemoteProfile) .handlesExternalEvents(preferring: [], allowing: ["*"]) - .onOpenURL(perform: openURL) - } - - private func openURL(url: URL) { - if url.host == "import-remote-profile" { - var error: NSError? - importRemoteProfile = LibboxParseRemoteProfileImportLink(url.absoluteString, &error) - if error != nil { - return - } - if selection != .profiles { - selection = .profiles - } - } else if url.pathExtension == "bpf" { - Task { - await importURLProfile(url) - } - } else { - alert = Alert(errorMessage: String(localized: "Handled unknown URL \(url.absoluteString)")) - } - } - - private func importURLProfile(_ url: URL) async { - do { - _ = url.startAccessingSecurityScopedResource() - importProfile = try await .from(readURL(url)) - url.stopAccessingSecurityScopedResource() - } catch { - alert = Alert(error) - return - } - if selection != .profiles { - selection = .profiles - } - } - - private nonisolated func readURL(_ url: URL) async throws -> Data { - try Data(contentsOf: url) - } - - private func checkApplicationPath() { - let directoryName = URL(filePath: Bundle.main.bundlePath).deletingLastPathComponent().pathComponents.last - if directoryName != "Applications" { - alert = Alert( - title: Text("Wrong application location"), - message: Text("This app needs to be placed under the Applications folder to work."), - dismissButton: .default(Text("Ok")) { - NSWorkspace.shared.selectFile(Bundle.main.bundlePath, inFileViewerRootedAtPath: "") - NSApp.terminate(nil) - } - ) - } + .onOpenURL(perform: viewModel.openURL) } } diff --git a/MacLibrary/MainViewModel.swift b/MacLibrary/MainViewModel.swift new file mode 100644 index 0000000..0ec6044 --- /dev/null +++ b/MacLibrary/MainViewModel.swift @@ -0,0 +1,91 @@ +import AppKit +import ApplicationLibrary +import Libbox +import Library +import SwiftUI + +@MainActor +public class MainViewModel: ObservableObject { + @Published public var selection = NavigationPage.dashboard + @Published public var importProfile: LibboxProfileContent? + @Published public var importRemoteProfile: LibboxImportRemoteProfile? + @Published public var alert: Alert? + + public init() {} + + public func onAppear(environments: ExtensionEnvironments) { + environments.postReload() + #if !DEBUG + if Variant.useSystemExtension { + checkApplicationPath() + } + #endif + } + + public func onControlActiveStateChange(_ newValue: ControlActiveState, environments: ExtensionEnvironments) { + if newValue != .inactive { + environments.postReload() + } + } + + public func onSelectionChange(_ newValue: NavigationPage, environments: ExtensionEnvironments) { + if newValue == .logs { + environments.connectLog() + } + } + + public func openSettings() { + selection = .settings + } + + public func openURL(_ url: URL) { + if url.host == "import-remote-profile" { + var error: NSError? + importRemoteProfile = LibboxParseRemoteProfileImportLink(url.absoluteString, &error) + if error != nil { + return + } + if selection != .profiles { + selection = .profiles + } + } else if url.pathExtension == "bpf" { + Task { + await importURLProfile(url) + } + } else { + alert = Alert(errorMessage: String(localized: "Handled unknown URL \(url.absoluteString)")) + } + } + + private func importURLProfile(_ url: URL) async { + do { + _ = url.startAccessingSecurityScopedResource() + importProfile = try await .from(readURL(url)) + url.stopAccessingSecurityScopedResource() + } catch { + alert = Alert(error) + return + } + if selection != .profiles { + selection = .profiles + } + } + + private nonisolated func readURL(_ url: URL) async throws -> Data { + try Data(contentsOf: url) + } + + private func checkApplicationPath() { + let directoryName = URL(filePath: Bundle.main.bundlePath).deletingLastPathComponent().pathComponents.last + if directoryName != "Applications" { + alert = Alert( + title: Text("Wrong application location"), + message: Text("This app needs to be placed under the Applications folder to work."), + dismissButton: .default(Text("Ok")) { + NSWorkspace.shared.selectFile(Bundle.main.bundlePath, inFileViewerRootedAtPath: "") + NSApp.terminate(nil) + } + ) + } + } +}