feat(apple): permit resources to be disabled (#6215)

Work for #6074 equivalent to #6166 for MacOS

MacOs view:

<img width="547" alt="image"
src="https://github.com/user-attachments/assets/f465183e-247b-49b5-a916-3ecc5f0a02f4">


iOS(ipad) view:


![image](https://github.com/user-attachments/assets/e64da75a-c69f-4e6a-aeeb-739958c3b046)

Other than implementing the resource disabling, this PR also refactor
the IPC between the network extension and the app so that it's some form
of structured IPC instead of relying on it being deserializable to
string to match the message.

One big difference with Android is that we don't introduce the concept
of a `ResourceView` for swift, the main reason for this is that on iOS
the resources are bound to the view instead of just being a parameter
for creating the view. So if we modify the `disabled` property it'd
update the UI unnecessarily, also it'd update the `Store` value for the
resource and then we need to copy that over again to the view. Making it
easier to go out of sync.
This commit is contained in:
Gabi
2024-08-10 01:20:14 -03:00
committed by GitHub
parent a52f459da6
commit db655dd171
9 changed files with 246 additions and 47 deletions

View File

@@ -60,6 +60,9 @@ mod ffi {
// <https://github.com/firezone/firezone/issues/4350> // <https://github.com/firezone/firezone/issues/4350>
#[swift_bridge(swift_name = "setDns")] #[swift_bridge(swift_name = "setDns")]
fn set_dns(&mut self, dns_servers: String); fn set_dns(&mut self, dns_servers: String);
#[swift_bridge(swift_name = "setDisabledResources")]
fn set_disabled_resources(&mut self, disabled_resources: String);
fn disconnect(self); fn disconnect(self);
} }
@@ -234,6 +237,11 @@ impl WrappedSession {
.set_dns(serde_json::from_str(&dns_servers).unwrap()) .set_dns(serde_json::from_str(&dns_servers).unwrap())
} }
fn set_disabled_resources(&mut self, disabled_resources: String) {
self.inner
.set_disabled_resources(serde_json::from_str(&disabled_resources).unwrap())
}
fn disconnect(self) { fn disconnect(self) {
self.inner.disconnect() self.inner.disconnect()
} }

View File

@@ -20,9 +20,55 @@ public enum TunnelManagerKeys {
static let authBaseURL = "authBaseURL" static let authBaseURL = "authBaseURL"
static let apiURL = "apiURL" static let apiURL = "apiURL"
public static let logFilter = "logFilter" public static let logFilter = "logFilter"
public static let disabledResources = "disabledResources"
} }
class TunnelManager { public enum TunnelMessage: Codable {
case getResourceList(Data)
case signOut
case setDisabledResources(Set<String>)
enum CodingKeys: String, CodingKey {
case type
case value
}
enum MessageType: String, Codable {
case getResourceList
case signOut
case setDisabledResources
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(MessageType.self, forKey: .type)
switch type {
case .setDisabledResources:
let value = try container.decode(Set<String>.self, forKey: .value)
self = .setDisabledResources(value)
case .getResourceList:
let value = try container.decode(Data.self, forKey: .value)
self = .getResourceList(value)
case .signOut:
self = .signOut
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .setDisabledResources(let value):
try container.encode(MessageType.setDisabledResources, forKey: .type)
try container.encode(value, forKey: .value)
case .getResourceList(let value):
try container.encode(MessageType.getResourceList, forKey: .type)
try container.encode(value, forKey: .value)
case .signOut:
try container.encode(MessageType.signOut, forKey: .type)
}
}
}
public class TunnelManager {
// Expose closures that someone else can use to respond to events // Expose closures that someone else can use to respond to events
// for this manager. // for this manager.
var statusChangeHandler: ((NEVPNStatus) async -> Void)? var statusChangeHandler: ((NEVPNStatus) async -> Void)?
@@ -36,11 +82,17 @@ class TunnelManager {
// Cache resources on this side of the IPC barrier so we can // Cache resources on this side of the IPC barrier so we can
// return them to callers when they haven't changed. // return them to callers when they haven't changed.
private var resourcesListCache = Data() private var resourcesListCache: [Resource] = []
// Persists our tunnel settings // Persists our tunnel settings
private var manager: NETunnelProviderManager? private var manager: NETunnelProviderManager?
// Resources that are currently disabled and will not be used
public var disabledResources: Set<String> = []
// Encoder used to send messages to the tunnel
private let encoder = PropertyListEncoder()
// Use separate bundle IDs for release and debug. // Use separate bundle IDs for release and debug.
// Helps with testing releases and dev builds on the same Mac. // Helps with testing releases and dev builds on the same Mac.
#if DEBUG #if DEBUG
@@ -69,6 +121,7 @@ class TunnelManager {
protocolConfiguration.serverAddress = settings.apiURL protocolConfiguration.serverAddress = settings.apiURL
manager.localizedDescription = bundleDescription manager.localizedDescription = bundleDescription
manager.protocolConfiguration = protocolConfiguration manager.protocolConfiguration = protocolConfiguration
encoder.outputFormat = .binary
// Save the new VPN profile to System Preferences and reload it, // Save the new VPN profile to System Preferences and reload it,
// which should update our status from invalid -> disconnected. // which should update our status from invalid -> disconnected.
@@ -101,6 +154,10 @@ class TunnelManager {
// Found it // Found it
let settings = Settings.fromProviderConfiguration(providerConfiguration) let settings = Settings.fromProviderConfiguration(providerConfiguration)
let actorName = providerConfiguration[TunnelManagerKeys.actorName] let actorName = providerConfiguration[TunnelManagerKeys.actorName]
if let disabledResourcesData = providerConfiguration[TunnelManagerKeys.disabledResources]?.data(using: .utf8) {
self.disabledResources = (try? JSONDecoder().decode(Set<String>.self, from: disabledResourcesData)) ?? Set()
}
let status = manager.connection.status let status = manager.connection.status
// Share what we found with our caller // Share what we found with our caller
@@ -188,7 +245,7 @@ class TunnelManager {
func stop(clearToken: Bool = false) { func stop(clearToken: Bool = false) {
if clearToken { if clearToken {
do { do {
try session().sendProviderMessage("signOut".data(using: .utf8)!) { _ in try session().sendProviderMessage(encoder.encode(TunnelMessage.signOut)) { _ in
self.session().stopTunnel() self.session().stopTunnel()
} }
} catch { } catch {
@@ -199,14 +256,32 @@ class TunnelManager {
} }
} }
func fetchResources(callback: @escaping (Data) -> Void) { func updateDisabledResources() {
guard session().status == .connected else { return }
try? session().sendProviderMessage(encoder.encode(TunnelMessage.setDisabledResources(disabledResources))) { _ in }
}
func toggleResourceDisabled(resource: String, enabled: Bool) {
if enabled {
disabledResources.remove(resource)
} else {
disabledResources.insert(resource)
}
updateDisabledResources()
}
func fetchResources(callback: @escaping ([Resource]) -> Void) {
guard session().status == .connected else { return } guard session().status == .connected else { return }
do { do {
try session().sendProviderMessage(resourceListHash) { data in try session().sendProviderMessage(encoder.encode(TunnelMessage.getResourceList(resourceListHash))) { data in
if let data = data { if let data = data {
self.resourceListHash = Data(SHA256.hash(data: data)) self.resourceListHash = Data(SHA256.hash(data: data))
self.resourcesListCache = data var decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
self.resourcesListCache = (try? decoder.decode([Resource].self, from: data)) ?? []
} }
callback(self.resourcesListCache) callback(self.resourcesListCache)
@@ -248,7 +323,7 @@ class TunnelManager {
if session.status == .disconnected { if session.status == .disconnected {
// Reset resource list on disconnect // Reset resource list on disconnect
resourceListHash = Data() resourceListHash = Data()
resourcesListCache = Data() resourcesListCache = []
} }
await statusChangeHandler?(session.status) await statusChangeHandler?(session.status)

View File

@@ -16,8 +16,9 @@ public struct Resource: Decodable, Identifiable, Equatable {
public var status: ResourceStatus public var status: ResourceStatus
public var sites: [Site] public var sites: [Site]
public var type: ResourceType public var type: ResourceType
public var canToggle: Bool
public init(id: String, name: String, address: String, addressDescription: String?, status: ResourceStatus, sites: [Site], type: ResourceType) { public init(id: String, name: String, address: String, addressDescription: String?, status: ResourceStatus, sites: [Site], type: ResourceType, canToggle: Bool) {
self.id = id self.id = id
self.name = name self.name = name
self.address = address self.address = address
@@ -25,6 +26,7 @@ public struct Resource: Decodable, Identifiable, Equatable {
self.status = status self.status = status
self.sites = sites self.sites = sites
self.type = type self.type = type
self.canToggle = canToggle
} }
} }

View File

@@ -10,6 +10,7 @@ struct Settings: Equatable {
var authBaseURL: String var authBaseURL: String
var apiURL: String var apiURL: String
var logFilter: String var logFilter: String
var disabledResources: Set<String>
var isValid: Bool { var isValid: Bool {
let authBaseURL = URL(string: authBaseURL) let authBaseURL = URL(string: authBaseURL)
@@ -26,6 +27,7 @@ struct Settings: Equatable {
&& !logFilter.isEmpty && !logFilter.isEmpty
} }
// Convert provider configuration (which may have empty fields if it was tampered with) to Settings // Convert provider configuration (which may have empty fields if it was tampered with) to Settings
static func fromProviderConfiguration(_ providerConfiguration: [String: Any]?) -> Settings { static func fromProviderConfiguration(_ providerConfiguration: [String: Any]?) -> Settings {
if let providerConfiguration = providerConfiguration as? [String: String] { if let providerConfiguration = providerConfiguration as? [String: String] {
@@ -35,19 +37,29 @@ struct Settings: Equatable {
apiURL: providerConfiguration[TunnelManagerKeys.apiURL] apiURL: providerConfiguration[TunnelManagerKeys.apiURL]
?? Settings.defaultValue.apiURL, ?? Settings.defaultValue.apiURL,
logFilter: providerConfiguration[TunnelManagerKeys.logFilter] logFilter: providerConfiguration[TunnelManagerKeys.logFilter]
?? Settings.defaultValue.logFilter ?? Settings.defaultValue.logFilter,
disabledResources: getDisabledResources(disabledResources: providerConfiguration[TunnelManagerKeys.disabledResources])
) )
} else { } else {
return Settings.defaultValue return Settings.defaultValue
} }
} }
static private func getDisabledResources(disabledResources: String?) -> Set<String> {
guard let disabledResourcesJSON = disabledResources, let disabledResourcesData = disabledResourcesJSON.data(using: .utf8) else{
return Set()
}
return (try? JSONDecoder().decode(Set<String>.self, from: disabledResourcesData))
?? Settings.defaultValue.disabledResources
}
// Used for initializing a new providerConfiguration from Settings // Used for initializing a new providerConfiguration from Settings
func toProviderConfiguration() -> [String: String] { func toProviderConfiguration() -> [String: String] {
return [ return [
"authBaseURL": authBaseURL, TunnelManagerKeys.authBaseURL: authBaseURL,
"apiURL": apiURL, TunnelManagerKeys.apiURL: apiURL,
"logFilter": logFilter, TunnelManagerKeys.logFilter: logFilter,
TunnelManagerKeys.disabledResources: String(data: try! JSONEncoder().encode(disabledResources), encoding: .utf8) ?? "",
] ]
} }
@@ -59,13 +71,15 @@ struct Settings: Equatable {
authBaseURL: "https://app.firez.one", authBaseURL: "https://app.firez.one",
apiURL: "wss://api.firez.one", apiURL: "wss://api.firez.one",
logFilter: logFilter:
"firezone_tunnel=debug,phoenix_channel=debug,connlib_shared=debug,connlib_client_shared=debug,snownet=debug,str0m=info,warn" "firezone_tunnel=debug,phoenix_channel=debug,connlib_shared=debug,connlib_client_shared=debug,snownet=debug,str0m=info,warn",
disabledResources: Set()
) )
#else #else
Settings( Settings(
authBaseURL: "https://app.firezone.dev", authBaseURL: "https://app.firezone.dev",
apiURL: "wss://api.firezone.dev", apiURL: "wss://api.firezone.dev",
logFilter: "str0m=warn,info" logFilter: "str0m=warn,info",
disabledResources: Set()
) )
#endif #endif
}() }()

View File

@@ -45,6 +45,10 @@ public final class Store: ObservableObject {
initTunnelManager() initTunnelManager()
} }
public func isResourceEnabled(_ id: String) -> Bool {
!tunnelManager.disabledResources.contains(id)
}
private func initNotifications() { private func initNotifications() {
// Finish initializing notification binding // Finish initializing notification binding
sessionNotification.signInHandler = { sessionNotification.signInHandler = {
@@ -138,7 +142,7 @@ public final class Store: ObservableObject {
// Network Extensions don't have a 2-way binding up to the GUI process, // Network Extensions don't have a 2-way binding up to the GUI process,
// so we need to periodically ask the tunnel process for them. // so we need to periodically ask the tunnel process for them.
func beginUpdatingResources(callback: @escaping (Data) -> Void) { func beginUpdatingResources(callback: @escaping ([Resource]) -> Void) {
Log.app.log("\(#function)") Log.app.log("\(#function)")
tunnelManager.fetchResources(callback: callback) tunnelManager.fetchResources(callback: callback)
@@ -167,6 +171,15 @@ public final class Store: ObservableObject {
} }
} }
func toggleResourceDisabled(resource: String, enabled: Bool) {
tunnelManager.toggleResourceDisabled(resource: resource, enabled: enabled)
var newSettings = settings
newSettings.disabledResources = tunnelManager.disabledResources
Task {
try await save(newSettings)
}
}
// Handles the frequent VPN state changes during sign in, sign out, etc. // Handles the frequent VPN state changes during sign in, sign out, etc.
private func handleVPNStatusChange(status: NEVPNStatus) async { private func handleVPNStatusChange(status: NEVPNStatus) async {
self.status = status self.status = status

View File

@@ -59,15 +59,11 @@ public final class MenuBar: NSObject, ObservableObject {
guard let self = self else { return } guard let self = self else { return }
if status == .connected { if status == .connected {
model.store.beginUpdatingResources { data in model.store.beginUpdatingResources { newResources in
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
if let newResources = try? decoder.decode([Resource].self, from: data) {
// Handle resource changes // Handle resource changes
self.populateResourceMenu(newResources) self.populateResourceMenu(newResources)
self.handleTunnelStatusOrResourcesChanged(status: status, resources: newResources) self.handleTunnelStatusOrResourcesChanged(status: status, resources: newResources)
self.resources = newResources self.resources = newResources
}
} }
} else { } else {
model.store.endUpdatingResources() model.store.endUpdatingResources()
@@ -424,6 +420,10 @@ public final class MenuBar: NSObject, ObservableObject {
return item return item
} }
private func resourceTitle(_ id: String) -> String {
model.isResourceEnabled(id) ? "Disable this resource" : "Enable this resource"
}
private func createSubMenu(resource: Resource) -> NSMenu { private func createSubMenu(resource: Resource) -> NSMenu {
let subMenu = NSMenu() let subMenu = NSMenu()
let resourceAddressDescriptionItem = NSMenuItem() let resourceAddressDescriptionItem = NSMenuItem()
@@ -433,6 +433,7 @@ public final class MenuBar: NSObject, ObservableObject {
let siteSectionItem = NSMenuItem() let siteSectionItem = NSMenuItem()
let siteNameItem = NSMenuItem() let siteNameItem = NSMenuItem()
let siteStatusItem = NSMenuItem() let siteStatusItem = NSMenuItem()
let enableToggle = NSMenuItem()
// AddressDescription first -- will be most common action // AddressDescription first -- will be most common action
@@ -484,6 +485,18 @@ public final class MenuBar: NSObject, ObservableObject {
resourceAddressItem.target = self resourceAddressItem.target = self
subMenu.addItem(resourceAddressItem) subMenu.addItem(resourceAddressItem)
// Resource toggle
if resource.canToggle {
subMenu.addItem(NSMenuItem.separator())
enableToggle.action = #selector(resourceToggle(_:))
enableToggle.title = resourceTitle(resource.id)
enableToggle.toolTip = "Enable or disable resource"
enableToggle.isEnabled = true
enableToggle.target = self
enableToggle.representedObject = resource.id
subMenu.addItem(enableToggle)
}
// Site details // Site details
if let site = resource.sites.first { if let site = resource.sites.first {
subMenu.addItem(NSMenuItem.separator()) subMenu.addItem(NSMenuItem.separator())
@@ -526,6 +539,13 @@ public final class MenuBar: NSObject, ObservableObject {
} }
} }
@objc private func resourceToggle(_ sender: NSMenuItem) {
let id = sender.representedObject as! String
self.model.store.toggleResourceDisabled(resource: id, enabled: !model.isResourceEnabled(id))
sender.title = resourceTitle(id)
}
@objc private func resourceURLTapped(_ sender: AnyObject?) { @objc private func resourceURLTapped(_ sender: AnyObject?) {
if let value = (sender as? NSMenuItem)?.title { if let value = (sender as? NSMenuItem)?.title {
// URL has already been validated // URL has already been validated

View File

@@ -15,6 +15,7 @@ public final class SessionViewModel: ObservableObject {
@Published private(set) var resources: [Resource]? = nil @Published private(set) var resources: [Resource]? = nil
@Published private(set) var status: NEVPNStatus? = nil @Published private(set) var status: NEVPNStatus? = nil
let store: Store let store: Store
private var cancellables: Set<AnyCancellable> = [] private var cancellables: Set<AnyCancellable> = []
@@ -40,10 +41,8 @@ public final class SessionViewModel: ObservableObject {
self.status = status self.status = status
if status == .connected { if status == .connected {
store.beginUpdatingResources() { data in store.beginUpdatingResources() { resources in
let decoder = JSONDecoder() self.resources = resources
decoder.keyDecodingStrategy = .convertFromSnakeCase
self.resources = try? decoder.decode([Resource].self, from: data)
} }
} else { } else {
store.endUpdatingResources() store.endUpdatingResources()
@@ -54,6 +53,10 @@ public final class SessionViewModel: ObservableObject {
#endif #endif
} }
public func isResourceEnabled(_ resource: String) -> Bool {
store.isResourceEnabled(resource)
}
} }
#if os(iOS) #if os(iOS)
@@ -69,9 +72,26 @@ struct SessionView: View {
Text("No Resources. Contact your admin to be granted access.") Text("No Resources. Contact your admin to be granted access.")
} else { } else {
List(resources) { resource in List(resources) { resource in
NavigationLink(resource.name, destination: ResourceView(resource: resource)) HStack {
.navigationTitle("All Resources") NavigationLink { ResourceView(resource: resource) }
label: {
HStack {
Text(resource.name)
if resource.canToggle {
Spacer()
Toggle("Enabled", isOn: Binding<Bool>(
get: { model.isResourceEnabled(resource.id) },
set: { newValue in
model.store.toggleResourceDisabled(resource: resource.id, enabled: newValue)
}
)).labelsHidden()
}
}
}
.navigationTitle("All Resources")
}
} }
.listStyle(GroupedListStyle()) .listStyle(GroupedListStyle())
} }
} else { } else {

View File

@@ -59,6 +59,12 @@ class Adapter {
/// This is the primary async primitive used in this class. /// This is the primary async primitive used in this class.
private let workQueue = DispatchQueue(label: "FirezoneAdapterWorkQueue") private let workQueue = DispatchQueue(label: "FirezoneAdapterWorkQueue")
/// Currently disabled resources
private var disabledResources: Set<String> = []
/// Cache of resources that can be disabled
private var canBeDisabled: Set<String> = []
/// Adapter state. /// Adapter state.
private var state: AdapterState { private var state: AdapterState {
didSet { didSet {
@@ -79,6 +85,7 @@ class Adapter {
apiURL: String, apiURL: String,
token: String, token: String,
logFilter: String, logFilter: String,
disabledResources: Set<String>,
packetTunnelProvider: PacketTunnelProvider packetTunnelProvider: PacketTunnelProvider
) { ) {
self.apiURL = apiURL self.apiURL = apiURL
@@ -89,6 +96,7 @@ class Adapter {
self.logFilter = logFilter self.logFilter = logFilter
self.connlibLogFolderPath = SharedAccess.connlibLogFolderURL?.path ?? "" self.connlibLogFolderPath = SharedAccess.connlibLogFolderURL?.path ?? ""
self.networkSettings = nil self.networkSettings = nil
self.disabledResources = disabledResources
} }
// Could happen abruptly if the process is killed. // Could happen abruptly if the process is killed.
@@ -187,6 +195,35 @@ class Adapter {
} }
} }
} }
func resources() -> [Resource] {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
guard let resourceList = resourceListJSON else { return [] }
return (try? decoder.decode([Resource].self, from: resourceList.data(using: .utf8)!)) ?? []
}
public func setDisabledResources(newDisabledResources: Set<String>) {
workQueue.async { [weak self] in
guard let self = self else { return }
self.disabledResources = newDisabledResources
self.resourcesUpdated()
}
}
public func resourcesUpdated() {
guard case .tunnelStarted(let session) = self.state else { return }
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
canBeDisabled = Set(resources().filter({ $0.canToggle }).map({ $0.id }))
let disablingResources = disabledResources.filter({ canBeDisabled.contains($0) })
let currentlyDisabled = try! JSONEncoder().encode(disablingResources)
session.setDisabledResources(String(data: currentlyDisabled, encoding: .utf8)!)
}
} }
// MARK: Responding to path updates // MARK: Responding to path updates
@@ -370,6 +407,8 @@ extension Adapter: CallbackHandlerDelegate {
// Update resource List. We don't care what's inside. // Update resource List. We don't care what's inside.
resourceListJSON = resourceList resourceListJSON = resourceList
self.resourcesUpdated()
} }
} }

View File

@@ -78,10 +78,17 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
return return
} }
let disabledResources: Set<String> = if let disabledResourcesJSON = providerConfiguration[TunnelManagerKeys.disabledResources]?.data(using: .utf8) {
(try? JSONDecoder().decode(Set<String>.self, from: disabledResourcesJSON )) ?? Set()
} else {
Set()
}
let adapter = Adapter( let adapter = Adapter(
apiURL: apiURL, token: token, logFilter: logFilter, packetTunnelProvider: self) apiURL: apiURL, token: token, logFilter: logFilter, disabledResources: disabledResources, packetTunnelProvider: self)
self.adapter = adapter self.adapter = adapter
try adapter.start() try adapter.start()
// Tell the system the tunnel is up, moving the tunnelManager status to // Tell the system the tunnel is up, moving the tunnelManager status to
@@ -128,37 +135,38 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
cancelTunnelWithError(nil) cancelTunnelWithError(nil)
} }
// TODO: Use a message format to allow requesting different types of data.
// This currently assumes we're requesting resources.
override func handleAppMessage(_ message: Data, completionHandler: ((Data?) -> Void)? = nil) { override func handleAppMessage(_ message: Data, completionHandler: ((Data?) -> Void)? = nil) {
let string = String(data: message, encoding: .utf8) guard let tunnelMessage = try? PropertyListDecoder().decode(TunnelMessage.self, from: message) else { return }
switch string { switch tunnelMessage {
case "signOut": case .setDisabledResources(let value):
adapter?.setDisabledResources(newDisabledResources: value)
case .signOut:
Task { Task {
do { await clearToken()
try await clearToken()
} catch {
Log.tunnel.error("\(#function): Error: \(error)")
}
} }
default: case .getResourceList(let value):
adapter?.getResourcesIfVersionDifferentFrom(hash: message) { adapter?.getResourcesIfVersionDifferentFrom(hash: value) {
resourceListJSON in resourceListJSON in
completionHandler?(resourceListJSON?.data(using: .utf8)) completionHandler?(resourceListJSON?.data(using: .utf8))
} }
} }
} }
private func clearToken() async throws { enum TokenError: Error {
let keychain = Keychain() case TokenNotFound
guard let ref = await keychain.search() }
else {
Log.tunnel.error("\(#function): Error: token not found!")
return
}
try await keychain.delete(persistentRef: ref) private func clearToken() async {
do {
let keychain = Keychain()
guard let ref = await keychain.search() else {
throw TokenError.TokenNotFound
}
try await keychain.delete(persistentRef: ref)
} catch {
Log.tunnel.error("\(#function): Error: \(error)")
}
} }
} }