diff --git a/NodePass/Server/ServerCardView.swift b/NodePass/Server/ServerCardView.swift index cf167b7..75db501 100644 --- a/NodePass/Server/ServerCardView.swift +++ b/NodePass/Server/ServerCardView.swift @@ -27,6 +27,9 @@ struct ServerCardView: View { VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading) { HStack(spacing: 10) { + Circle() + .fill(server.isEnabled ? Color.green : Color.gray) + .frame(width: 8, height: 8) Text(server.name) if let uptime = metadata?.uptime { HStack(spacing: 5) { @@ -115,7 +118,13 @@ struct ServerCardView: View { .foregroundStyle(.secondary) } else { - if let metadataResult { + if !server.isEnabled { + Text("Disabled") + .bold() + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + else if let metadataResult { switch metadataResult { case .success: Text("Metadata Unavailable") diff --git a/NodePass/Server/ServerListView.swift b/NodePass/Server/ServerListView.swift index 5e9a375..bba36cb 100644 --- a/NodePass/Server/ServerListView.swift +++ b/NodePass/Server/ServerListView.swift @@ -197,6 +197,12 @@ struct ServerListView: View { } .contextMenu { ControlGroup { + Button { + server.isEnabled.toggle() + try? context.save() + } label: { + Label(server.isEnabled ? "Disable" : "Enable", systemImage: server.isEnabled ? "pause.circle" : "play.circle") + } Button { state.editServerSheetMode = .editing state.editServerSheetServer = server diff --git a/NodePass/Service/Add Service/AddDirectForwardServiceView.swift b/NodePass/Service/Add Service/AddDirectForwardServiceView.swift index 11bf5f7..609268e 100644 --- a/NodePass/Service/Add Service/AddDirectForwardServiceView.swift +++ b/NodePass/Service/Add Service/AddDirectForwardServiceView.swift @@ -13,6 +13,10 @@ struct AddDirectForwardServiceView: View { @Environment(\.modelContext) private var context @Query(sort: \Server.timestamp) private var servers: [Server] + private var enabledServers: [Server] { + servers.filter { $0.isEnabled } + } + private var isAdvancedModeEnabled: Bool = NPCore.isAdvancedModeEnabled @State private var name: String = "" @@ -42,7 +46,7 @@ struct AddDirectForwardServiceView: View { Picker("Server", selection: $client) { Text("Select") .tag(nil as Server?) - ForEach(servers) { server in + ForEach(enabledServers) { server in Text(server.name) .tag(server) } diff --git a/NodePass/Service/Add Service/AddNATPassthroughServiceView.swift b/NodePass/Service/Add Service/AddNATPassthroughServiceView.swift index 8938e94..ef078b9 100644 --- a/NodePass/Service/Add Service/AddNATPassthroughServiceView.swift +++ b/NodePass/Service/Add Service/AddNATPassthroughServiceView.swift @@ -13,6 +13,10 @@ struct AddNATPassthroughServiceView: View { @Environment(\.modelContext) private var context @Query(sort: \Server.timestamp) private var servers: [Server] + private var enabledServers: [Server] { + servers.filter { $0.isEnabled } + } + private var isAdvancedModeEnabled: Bool = NPCore.isAdvancedModeEnabled @State private var name: String = "" @@ -54,7 +58,7 @@ struct AddNATPassthroughServiceView: View { Picker("Server", selection: $remoteServer) { Text("Select") .tag(nil as Server?) - ForEach(servers) { server in + ForEach(enabledServers) { server in Text(server.name) .tag(server) } @@ -94,7 +98,7 @@ struct AddNATPassthroughServiceView: View { Picker("Server", selection: $localServer) { Text("Select") .tag(nil as Server?) - ForEach(servers) { server in + ForEach(enabledServers) { server in Text(server.name) .tag(server) } diff --git a/NodePass/Service/Add Service/AddTunnelForwardServiceView.swift b/NodePass/Service/Add Service/AddTunnelForwardServiceView.swift index 034df1c..bc77139 100644 --- a/NodePass/Service/Add Service/AddTunnelForwardServiceView.swift +++ b/NodePass/Service/Add Service/AddTunnelForwardServiceView.swift @@ -13,6 +13,10 @@ struct AddTunnelForwardServiceView: View { @Environment(\.modelContext) private var context @Query(sort: \Server.timestamp) private var servers: [Server] + private var enabledServers: [Server] { + servers.filter { $0.isEnabled } + } + @State private var isAdvancedModeEnabled: Bool = NPCore.isAdvancedModeEnabled @State private var name: String = "" @@ -74,7 +78,7 @@ struct AddTunnelForwardServiceView: View { Picker("Server", selection: $relayServer) { Text("Select") .tag(nil as Server?) - ForEach(servers) { server in + ForEach(enabledServers) { server in Text(server.name) .tag(server) } @@ -112,7 +116,7 @@ struct AddTunnelForwardServiceView: View { Picker("Server", selection: $destinationServer) { Text("Select") .tag(nil as Server?) - ForEach(servers) { server in + ForEach(enabledServers) { server in Text(server.name) .tag(server) } diff --git a/NodePass/Service/Detail/DirectForwardDetailView.swift b/NodePass/Service/Detail/DirectForwardDetailView.swift index 4edfc83..8dac9bb 100644 --- a/NodePass/Service/Detail/DirectForwardDetailView.swift +++ b/NodePass/Service/Detail/DirectForwardDetailView.swift @@ -23,11 +23,18 @@ struct DirectForwardDetailView: View { @Query private var servers: [Server] @State private var instance: Instance? + @State private var instanceLoadingState: LoadingState = .loading @State private var isShowEditInstanceSheet: Bool = false @State private var isShowErrorAlert: Bool = false @State private var errorMessage: String = "" + enum LoadingState { + case loading + case loaded + case notFound + } + var body: some View { if service.type == .directForward { Form { @@ -42,17 +49,26 @@ struct DirectForwardDetailView: View { } .frame(maxWidth: .infinity) - if let instance { - VStack { - InstanceCardView(instance: instance) - .onTapGesture { - isShowEditInstanceSheet = true - } - } - .frame(maxWidth: .infinity) - } - else { + switch instanceLoadingState { + case .loading: ProgressView() + .listRowSeparator(.hidden) + .frame(maxWidth: .infinity) + case .loaded: + if let instance { + VStack { + InstanceCardView(instance: instance) + .onTapGesture { + isShowEditInstanceSheet = true + } + } + .listRowSeparator(.hidden) + .frame(maxWidth: .infinity) + } + case .notFound: + Label("Instance Not Found", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .listRowSeparator(.hidden) .frame(maxWidth: .infinity) } } @@ -89,16 +105,30 @@ struct DirectForwardDetailView: View { private func fetchInstance() { guard let server else { return } + instanceLoadingState = .loading Task { let instanceService = InstanceService() do { let instances = try await instanceService.listInstances(baseURLString: server.url, apiKey: server.key) - self.instance = instances.first(where: { $0.id == implementation.instanceID }) + if let foundInstance = instances.first(where: { $0.id == implementation.instanceID }) { + await MainActor.run { + self.instance = foundInstance + self.instanceLoadingState = .loaded + } + } else { + await MainActor.run { + self.instance = nil + self.instanceLoadingState = .notFound + } + } } catch { #if DEBUG print("Error Fetching Instance: \(error.localizedDescription)") #endif + await MainActor.run { + self.instanceLoadingState = .notFound + } } } } diff --git a/NodePass/Service/Detail/NATPassthroughDetailView.swift b/NodePass/Service/Detail/NATPassthroughDetailView.swift index 87eb015..d9da581 100644 --- a/NodePass/Service/Detail/NATPassthroughDetailView.swift +++ b/NodePass/Service/Detail/NATPassthroughDetailView.swift @@ -30,12 +30,20 @@ struct NATPassthroughDetailView: View { @State private var instance0: Instance? @State private var instance1: Instance? + @State private var instance0LoadingState: LoadingState = .loading + @State private var instance1LoadingState: LoadingState = .loading @State private var isShowEditInstance0Sheet: Bool = false @State private var isShowEditInstance1Sheet: Bool = false @State private var isShowErrorAlert: Bool = false @State private var errorMessage: String = "" + enum LoadingState { + case loading + case loaded + case notFound + } + var body: some View { if service.type == .natPassthrough { Form { @@ -52,17 +60,26 @@ struct NATPassthroughDetailView: View { } .frame(maxWidth: .infinity) - if let instance0 { - VStack { - InstanceCardView(instance: instance0) - .onTapGesture { - isShowEditInstance0Sheet = true - } - } - .frame(maxWidth: .infinity) - } - else { + switch instance0LoadingState { + case .loading: ProgressView() + .listRowSeparator(.hidden) + .frame(maxWidth: .infinity) + case .loaded: + if let instance0 { + VStack { + InstanceCardView(instance: instance0) + .onTapGesture { + isShowEditInstance0Sheet = true + } + } + .listRowSeparator(.hidden) + .frame(maxWidth: .infinity) + } + case .notFound: + Label("Instance Not Found", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .listRowSeparator(.hidden) .frame(maxWidth: .infinity) } } @@ -86,17 +103,26 @@ struct NATPassthroughDetailView: View { } .frame(maxWidth: .infinity) - if let instance1 { - VStack { - InstanceCardView(instance: instance1) - .onTapGesture { - isShowEditInstance1Sheet = true - } - } - .frame(maxWidth: .infinity) - } - else { + switch instance1LoadingState { + case .loading: ProgressView() + .listRowSeparator(.hidden) + .frame(maxWidth: .infinity) + case .loaded: + if let instance1 { + VStack { + InstanceCardView(instance: instance1) + .onTapGesture { + isShowEditInstance1Sheet = true + } + } + .listRowSeparator(.hidden) + .frame(maxWidth: .infinity) + } + case .notFound: + Label("Instance Not Found", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .listRowSeparator(.hidden) .frame(maxWidth: .infinity) } } @@ -140,30 +166,58 @@ struct NATPassthroughDetailView: View { private func fetchInstances() { if let server0 { + instance0LoadingState = .loading Task { let instanceService = InstanceService() do { let instances = try await instanceService.listInstances(baseURLString: server0.url, apiKey: server0.key) - self.instance0 = instances.first(where: { $0.id == implementation0.instanceID }) + if let foundInstance = instances.first(where: { $0.id == implementation0.instanceID }) { + await MainActor.run { + self.instance0 = foundInstance + self.instance0LoadingState = .loaded + } + } else { + await MainActor.run { + self.instance0 = nil + self.instance0LoadingState = .notFound + } + } } catch { #if DEBUG print("Error Fetching Instance 0: \(error.localizedDescription)") #endif + await MainActor.run { + self.instance0LoadingState = .notFound + } } } } if let server1 { + instance1LoadingState = .loading Task { let instanceService = InstanceService() do { let instances = try await instanceService.listInstances(baseURLString: server1.url, apiKey: server1.key) - self.instance1 = instances.first(where: { $0.id == implementation1.instanceID }) + if let foundInstance = instances.first(where: { $0.id == implementation1.instanceID }) { + await MainActor.run { + self.instance1 = foundInstance + self.instance1LoadingState = .loaded + } + } else { + await MainActor.run { + self.instance1 = nil + self.instance1LoadingState = .notFound + } + } } catch { #if DEBUG print("Error Fetching Instance 1: \(error.localizedDescription)") #endif + await MainActor.run { + self.instance1LoadingState = .notFound + } } } } diff --git a/NodePass/Service/Detail/TunnelForwardDetailView.swift b/NodePass/Service/Detail/TunnelForwardDetailView.swift index 5576f71..d41f73a 100644 --- a/NodePass/Service/Detail/TunnelForwardDetailView.swift +++ b/NodePass/Service/Detail/TunnelForwardDetailView.swift @@ -30,12 +30,20 @@ struct TunnelForwardDetailView: View { @State private var instance0: Instance? @State private var instance1: Instance? + @State private var instance0LoadingState: LoadingState = .loading + @State private var instance1LoadingState: LoadingState = .loading @State private var isShowEditInstance0Sheet: Bool = false @State private var isShowEditInstance1Sheet: Bool = false @State private var isShowErrorAlert: Bool = false @State private var errorMessage: String = "" + enum LoadingState { + case loading + case loaded + case notFound + } + var body: some View { if service.type == .tunnelForward { Form { @@ -52,17 +60,26 @@ struct TunnelForwardDetailView: View { } .frame(maxWidth: .infinity) - if let instance0 { - VStack { - InstanceCardView(instance: instance0) - .onTapGesture { - isShowEditInstance0Sheet = true - } - } - .frame(maxWidth: .infinity) - } - else { + switch instance0LoadingState { + case .loading: ProgressView() + .listRowSeparator(.hidden) + .frame(maxWidth: .infinity) + case .loaded: + if let instance0 { + VStack { + InstanceCardView(instance: instance0) + .onTapGesture { + isShowEditInstance0Sheet = true + } + } + .listRowSeparator(.hidden) + .frame(maxWidth: .infinity) + } + case .notFound: + Label("Instance Not Found", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .listRowSeparator(.hidden) .frame(maxWidth: .infinity) } } @@ -86,17 +103,26 @@ struct TunnelForwardDetailView: View { } .frame(maxWidth: .infinity) - if let instance1 { - VStack { - InstanceCardView(instance: instance1) - .onTapGesture { - isShowEditInstance1Sheet = true - } - } - .frame(maxWidth: .infinity) - } - else { + switch instance1LoadingState { + case .loading: ProgressView() + .listRowSeparator(.hidden) + .frame(maxWidth: .infinity) + case .loaded: + if let instance1 { + VStack { + InstanceCardView(instance: instance1) + .onTapGesture { + isShowEditInstance1Sheet = true + } + } + .listRowSeparator(.hidden) + .frame(maxWidth: .infinity) + } + case .notFound: + Label("Instance Not Found", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .listRowSeparator(.hidden) .frame(maxWidth: .infinity) } } @@ -140,30 +166,58 @@ struct TunnelForwardDetailView: View { private func fetchInstances() { if let server0 { + instance0LoadingState = .loading Task { let instanceService = InstanceService() do { let instances = try await instanceService.listInstances(baseURLString: server0.url, apiKey: server0.key) - self.instance0 = instances.first(where: { $0.id == implementation0.instanceID }) + if let foundInstance = instances.first(where: { $0.id == implementation0.instanceID }) { + await MainActor.run { + self.instance0 = foundInstance + self.instance0LoadingState = .loaded + } + } else { + await MainActor.run { + self.instance0 = nil + self.instance0LoadingState = .notFound + } + } } catch { #if DEBUG print("Error Fetching Instance 0: \(error.localizedDescription)") #endif + await MainActor.run { + self.instance0LoadingState = .notFound + } } } } if let server1 { + instance1LoadingState = .loading Task { let instanceService = InstanceService() do { let instances = try await instanceService.listInstances(baseURLString: server1.url, apiKey: server1.key) - self.instance1 = instances.first(where: { $0.id == implementation1.instanceID }) + if let foundInstance = instances.first(where: { $0.id == implementation1.instanceID }) { + await MainActor.run { + self.instance1 = foundInstance + self.instance1LoadingState = .loaded + } + } else { + await MainActor.run { + self.instance1 = nil + self.instance1LoadingState = .notFound + } + } } catch { #if DEBUG print("Error Fetching Instance 1: \(error.localizedDescription)") #endif + await MainActor.run { + self.instance1LoadingState = .notFound + } } } } diff --git a/NodePass/Service/ServiceListView.swift b/NodePass/Service/ServiceListView.swift index ac1e995..2388f27 100644 --- a/NodePass/Service/ServiceListView.swift +++ b/NodePass/Service/ServiceListView.swift @@ -479,12 +479,14 @@ struct ServiceListView: View { var store: [String: [Instance]] = .init() // Server.id: [Instance] var errorStore: [String: String] = .init() // (Server.name || Server.id): Error.localizedDescription var examinedServiceIds: [String] = .init() - syncProgress = (0, servers.count) + // Only include enabled servers + let enabledServers = servers.filter { $0.isEnabled } + syncProgress = (0, enabledServers.count) withAnimation { isShowSyncProgressView = true } try await withThrowingTaskGroup(of: (server: Server, result: Result<[Instance], Error>).self) { group in - for server in servers { + for server in enabledServers { group.addTask { do { let instances = try await instanceService.listInstances( @@ -513,8 +515,13 @@ struct ServiceListView: View { if let serviceId = instance.metadata?.peer.serviceId, serviceId != "", !examinedServiceIds.contains(serviceId) { let serverId0 = serverId let instance0 = instance - if ["0", "5"].contains(instance0.metadata!.peer.serviceType) { + if ["0", "5"].contains(instance0.metadata?.peer.serviceType ?? "") { // Direct Forward - must be client + guard let clientInstanceMetadata = instance0.metadata else { + examinedServiceIds.append(serviceId) + continue + } + let clientId = serverId0 let clientInstance = instance0 let clientScheme = NPCore.parseScheme(urlString: clientInstance.url) @@ -525,7 +532,7 @@ struct ServiceListView: View { existingService.name = instance.metadata?.peer.alias ?? "Untitled" existingService.isConfigurationInvalid = !isValidConfiguration if let implementation = existingService.implementations?.first { - implementation.name = clientInstance.metadata!.peer.alias + implementation.name = clientInstanceMetadata.peer.alias implementation.command = clientInstance.url implementation.fullCommand = clientInstance.config ?? clientInstance.url } @@ -538,7 +545,7 @@ struct ServiceListView: View { type: .directForward, implementations: [ Implementation( - name: clientInstance.metadata!.peer.alias, + name: clientInstanceMetadata.peer.alias, type: .directForwardClient, position: 0, serverID: clientId, @@ -553,16 +560,28 @@ struct ServiceListView: View { try? context.save() } - examinedServiceIds.append(serverId) + examinedServiceIds.append(serviceId) continue } + + // Try to find the second instance for dual-instance services + var foundPairInstance = false for serverId in store.keys { for instance in store[serverId]! { if instance.metadata?.peer.serviceId == serviceId && instance.id != instance0.id { let serverId1 = serverId let instance1 = instance - switch(instance.metadata!.peer.serviceType) { + foundPairInstance = true + + // Validate metadata exists for both instances + guard let metadata0 = instance0.metadata, + let metadata1 = instance1.metadata else { + examinedServiceIds.append(serviceId) + continue + } + + switch(metadata1.peer.serviceType) { case "1", "3", "6": // NAT Passthrough - must be one server and one client let schemeOfInstance0 = NPCore.parseScheme(urlString: instance0.url) @@ -576,12 +595,12 @@ struct ServiceListView: View { if let implementations = existingService.implementations { // Update by instanceID if let impl0 = implementations.first(where: { $0.instanceID == instance0.id }) { - impl0.name = instance0.metadata!.peer.alias + impl0.name = metadata0.peer.alias impl0.command = instance0.url impl0.fullCommand = instance0.config ?? instance0.url } if let impl1 = implementations.first(where: { $0.instanceID == instance1.id }) { - impl1.name = instance1.metadata!.peer.alias + impl1.name = metadata1.peer.alias impl1.command = instance1.url impl1.fullCommand = instance1.config ?? instance1.url } @@ -591,6 +610,7 @@ struct ServiceListView: View { // Create new service - determine positions based on scheme let (serverInstance, clientInstance) = schemeOfInstance0 == .server ? (instance0, instance1) : (instance1, instance0) let (serverId, clientId) = schemeOfInstance0 == .server ? (serverId0, serverId1) : (serverId1, serverId0) + let (serverMetadata, clientMetadata) = schemeOfInstance0 == .server ? (metadata0, metadata1) : (metadata1, metadata0) let service = Service( id: UUID(uuidString: serviceId) ?? UUID(), @@ -598,7 +618,7 @@ struct ServiceListView: View { type: .natPassthrough, implementations: [ Implementation( - name: serverInstance.metadata!.peer.alias, + name: serverMetadata.peer.alias, type: .natPassthroughServer, position: 0, serverID: serverId, @@ -607,7 +627,7 @@ struct ServiceListView: View { fullCommand: serverInstance.config ?? serverInstance.url ), Implementation( - name: clientInstance.metadata!.peer.alias, + name: clientMetadata.peer.alias, type: .natPassthroughClient, position: 1, serverID: clientId, @@ -622,7 +642,7 @@ struct ServiceListView: View { try? context.save() } - examinedServiceIds.append(serverId) + examinedServiceIds.append(serviceId) continue case "2", "4", "7": // Tunnel Forward - must be one server and one client @@ -637,12 +657,12 @@ struct ServiceListView: View { if let implementations = existingService.implementations { // Update by instanceID if let impl0 = implementations.first(where: { $0.instanceID == instance0.id }) { - impl0.name = instance0.metadata!.peer.alias + impl0.name = metadata0.peer.alias impl0.command = instance0.url impl0.fullCommand = instance0.config ?? instance0.url } if let impl1 = implementations.first(where: { $0.instanceID == instance1.id }) { - impl1.name = instance1.metadata!.peer.alias + impl1.name = metadata1.peer.alias impl1.command = instance1.url impl1.fullCommand = instance1.config ?? instance1.url } @@ -653,6 +673,7 @@ struct ServiceListView: View { let modeOfInstance0 = NPCore.parseQueryParameters(urlString: instance0.url)["mode"] let (relayInstance, destInstance) = modeOfInstance0 == "1" ? (instance0, instance1) : (instance1, instance0) let (relayServerId, destServerId) = modeOfInstance0 == "1" ? (serverId0, serverId1) : (serverId1, serverId0) + let (relayMetadata, destMetadata) = modeOfInstance0 == "1" ? (metadata0, metadata1) : (metadata1, metadata0) let service = Service( id: UUID(uuidString: serviceId) ?? UUID(), @@ -660,7 +681,7 @@ struct ServiceListView: View { type: .tunnelForward, implementations: [ Implementation( - name: relayInstance.metadata!.peer.alias, + name: relayMetadata.peer.alias, type: .tunnelForwardRelay, position: 0, serverID: relayServerId, @@ -669,7 +690,7 @@ struct ServiceListView: View { fullCommand: relayInstance.config ?? relayInstance.url ), Implementation( - name: destInstance.metadata!.peer.alias, + name: destMetadata.peer.alias, type: .tunnelForwardDestination, position: 1, serverID: destServerId, @@ -684,7 +705,7 @@ struct ServiceListView: View { try? context.save() } - examinedServiceIds.append(serverId) + examinedServiceIds.append(serviceId) continue default: continue @@ -692,10 +713,25 @@ struct ServiceListView: View { } } } + + // If no pair instance was found for a dual-instance service, mark it as invalid + if !foundPairInstance { + if let existingService = services.first(where: { $0.id.uuidString == serviceId }) { + // Mark the existing service as having invalid configuration + existingService.isConfigurationInvalid = true + try? context.save() + } + examinedServiceIds.append(serviceId) + } } } } + // Save all updates before proceeding to delete + await MainActor.run { + try? context.save() + } + // Delete invalid local services let allRemoteServiceIds = Set( store.values @@ -704,26 +740,48 @@ struct ServiceListView: View { .filter { !$0.isEmpty } ) - let localServicesToDelete = services.filter { service in - let serviceIdString = service.id.uuidString - return !allRemoteServiceIds.contains(serviceIdString) - } + // Wait a moment to ensure UI has updated with the latest changes + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 second - for service in localServicesToDelete { - context.delete(service) + await MainActor.run { + // Build a safe mapping of service IDs to persistent IDs + var serviceIdToPersistentId: [String: PersistentIdentifier] = [:] + + for service in services { + // Access service.id - use the persistent ID as backup identifier + serviceIdToPersistentId[service.id.uuidString] = service.persistentModelID + } + + // Identify services to delete + var persistentIdsToDelete: [PersistentIdentifier] = [] + for (serviceIdString, persistentId) in serviceIdToPersistentId { + if !allRemoteServiceIds.contains(serviceIdString) { + persistentIdsToDelete.append(persistentId) + } + } + + // Delete services by persistent ID + for persistentId in persistentIdsToDelete { + if let serviceToDelete = context.model(for: persistentId) as? Service { + context.delete(serviceToDelete) + } + } + + if !persistentIdsToDelete.isEmpty { + try? context.save() + } } - if !localServicesToDelete.isEmpty { - try? context.save() + await MainActor.run { + if errorStore.count == 0 { + isSensoryFeedbackTriggered.toggle() + } + else { + syncErrorStore = errorStore + isShowSyncErrorSheet = true + } } - if errorStore.count == 0 { - isSensoryFeedbackTriggered.toggle() - } - else { - syncErrorStore = errorStore - isShowSyncErrorSheet = true - } DispatchQueue.main.asyncAfter(deadline: .now() + 3) { withAnimation { isShowSyncProgressView = false diff --git a/NodePass/Service/SyncErrorReportView.swift b/NodePass/Service/SyncErrorReportView.swift index ed213b4..ad01400 100644 --- a/NodePass/Service/SyncErrorReportView.swift +++ b/NodePass/Service/SyncErrorReportView.swift @@ -10,22 +10,89 @@ import SwiftData struct SyncErrorReportView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var context @Query private var servers: [Server] @Binding var syncErrorStore: Dictionary + private var enabledServers: [Server] { + servers.filter { $0.isEnabled } + } + + private var errorServers: [Server] { + syncErrorStore.keys.compactMap { serverId in + servers.first(where: { $0.id == serverId }) + } + } + + private var hasEnabledErrorServers: Bool { + errorServers.contains { $0.isEnabled } + } + var body: some View { NavigationStack { List { - Text("\(syncErrorStore.count) error(s)") - .bold() - ForEach(Array(syncErrorStore), id: \.key) { entry in - let server = servers.first(where: { $0.id == entry.key }) - Text("\(server?.name ?? entry.key): \(entry.value)") + Section { + Text("\(syncErrorStore.count) \(syncErrorStore.count == 1 ? "error" : "errors")") + .bold() + + if !errorServers.isEmpty { + Button { + disableAllErrorServers() + } label: { + Label("Disable Error \(syncErrorStore.count == 1 ? "Server" : "Servers")", systemImage: "pause.circle.fill") + } + .disabled(!hasEnabledErrorServers) + } + } + + Section { + ForEach(Array(syncErrorStore), id: \.key) { entry in + if let server = servers.first(where: { $0.id == entry.key }) { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Circle() + .fill(server.isEnabled ? Color.green : Color.gray) + .frame(width: 8, height: 8) + Text(server.name) + .font(.headline) + } + + Text(entry.value) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Button { + server.isEnabled.toggle() + try? context.save() + } label: { + Image(systemName: server.isEnabled ? "pause.circle.fill" : "play.circle.fill") + .font(.title3) + } + .buttonStyle(.borderless) + } + .padding(.vertical, 4) + } else { + VStack(alignment: .leading, spacing: 4) { + Text(entry.key) + .font(.headline) + Text(entry.value) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } + } header: { + Text("Error Details") } } - .listStyle(.plain) + .listStyle(.insetGrouped) .navigationTitle("Sync Error Report") .toolbar { ToolbarItem(placement: .confirmationAction) { @@ -45,4 +112,11 @@ struct SyncErrorReportView: View { } } } + + private func disableAllErrorServers() { + for server in errorServers where server.isEnabled { + server.isEnabled = false + } + try? context.save() + } } diff --git a/Shared/Models/Server.swift b/Shared/Models/Server.swift index d809dfd..69cf417 100644 --- a/Shared/Models/Server.swift +++ b/Shared/Models/Server.swift @@ -15,13 +15,15 @@ class Server { var name: String = "" var url: String = "" var key: String = "" + var isEnabled: Bool = true - init(name: String, url: String, key: String) { + init(name: String, url: String, key: String, isEnabled: Bool = true) { self.id = UUID().uuidString self.timestamp = Date() self.name = name self.url = url self.key = key + self.isEnabled = isEnabled } func getHost() -> String { diff --git a/Shared/NPState.swift b/Shared/NPState.swift index d74e79f..0d5994a 100644 --- a/Shared/NPState.swift +++ b/Shared/NPState.swift @@ -46,7 +46,9 @@ class NPState { let data = NPData.shared let dataHandler = await data.createDataHandler()() let servers = await dataHandler.getAllServers() - await getServerMetadatas(servers: servers) + // Only update metadata for enabled servers + let enabledServers = servers.filter { $0.isEnabled } + await getServerMetadatas(servers: enabledServers) } }