diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Log.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Log.swift index 70116833d..c919b66f9 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Log.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/Log.swift @@ -52,6 +52,48 @@ public final class Log { self.logger.error("\(message, privacy: .public)") logWriter?.write(severity: .error, message: message) } + + // Returns the size in bytes of the provided directory, calculated by summing + // the size of its contents recursively. + public static func size(of directory: URL) async -> Int64 { + let fileManager = FileManager.default + var totalSize: Int64 = 0 + + func sizeOfFile(at url: URL, with resourceValues: URLResourceValues) -> Int64 { + guard resourceValues.isRegularFile == true else { return 0 } + return Int64(resourceValues.totalFileAllocatedSize ?? resourceValues.totalFileSize ?? 0) + } + + // Tally size of each log file in parallel + await withTaskGroup(of: Int64.self) { taskGroup in + fileManager.forEachFileUnder( + directory, + including: [ + .totalFileAllocatedSizeKey, + .totalFileSizeKey, + .isRegularFileKey, + ] + ) { url, resourceValues in + taskGroup.addTask { + return sizeOfFile(at: url, with: resourceValues) + } + } + + for await size in taskGroup { + totalSize += size + } + } + + return totalSize + } + + // Clears the contents of the provided directory. + public static func clear(in directory: URL?) throws { + guard let directory = directory + else { return } + + try FileManager.default.removeItem(at: directory) + } } private final class LogWriter { diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift index a19f88eaf..4419fc1f3 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/TunnelManager.swift @@ -13,6 +13,7 @@ import NetworkExtension enum TunnelManagerError: Error { case cannotSaveIfMissing + case decodeIPCDataFailed } public enum TunnelManagerKeys { @@ -27,6 +28,8 @@ public enum TunnelMessage: Codable { case getResourceList(Data) case signOut case internetResourceEnabled(Bool) + case clearLogs + case getLogFolderSize enum CodingKeys: String, CodingKey { case type @@ -37,6 +40,8 @@ public enum TunnelMessage: Codable { case getResourceList case signOut case internetResourceEnabled + case clearLogs + case getLogFolderSize } public init(from decoder: Decoder) throws { @@ -51,6 +56,10 @@ public enum TunnelMessage: Codable { self = .getResourceList(value) case .signOut: self = .signOut + case .clearLogs: + self = .clearLogs + case .getLogFolderSize: + self = .getLogFolderSize } } public func encode(to encoder: Encoder) throws { @@ -64,6 +73,10 @@ public enum TunnelMessage: Codable { try container.encode(value, forKey: .value) case .signOut: try container.encode(MessageType.signOut, forKey: .type) + case .clearLogs: + try container.encode(MessageType.clearLogs, forKey: .type) + case .getLogFolderSize: + try container.encode(MessageType.getLogFolderSize, forKey: .type) } } } @@ -277,6 +290,43 @@ public class TunnelManager { } } + func clearLogs() async throws { + return try await withCheckedThrowingContinuation { continuation in + do { + try session().sendProviderMessage( + encoder.encode(TunnelMessage.clearLogs) + ) { _ in continuation.resume() } + } catch { + continuation.resume(throwing: error) + } + } + } + + func getLogFolderSize() async throws -> Int64 { + return try await withCheckedThrowingContinuation { continuation in + do { + try session().sendProviderMessage( + encoder.encode(TunnelMessage.getLogFolderSize) + ) { data in + + guard let data = data + else { + continuation + .resume(throwing: TunnelManagerError.decodeIPCDataFailed) + + return + } + + data.withUnsafeBytes { rawBuffer in + continuation.resume(returning: rawBuffer.load(as: Int64.self)) + } + } + } catch { + continuation.resume(throwing: error) + } + } + } + private func session() -> NETunnelProviderSession { guard let manager = manager, let session = manager.connection as? NETunnelProviderSession diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift index afeb20bf2..99d157b41 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift @@ -28,7 +28,7 @@ public final class Store: ObservableObject { // we could periodically update it if we need to. @Published private(set) var decision: UNAuthorizationStatus - private let tunnelManager: TunnelManager + public let tunnelManager: TunnelManager private var sessionNotification: SessionNotification private var cancellables: Set = [] private var resourcesTimer: Timer? diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift index a573853e2..8ec5db8ee 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/SettingsView.swift @@ -52,70 +52,61 @@ public final class SettingsViewModel: ObservableObject { } } - func calculateLogDirSize() -> String? { + // Calculates the total size of our logs by summing the size of the + // app, tunnel, and connlib log directories. + // + // On iOS, SharedAccess.logFolderURL is a single folder that contains all + // three directories, but on macOS, the app log directory lives in a different + // Group Container than tunnel and connlib directories, so we use IPC to make + // a call to sum both the tunnel and connlib directories. + // + // Unfortunately the IPC method doesn't work on iOS because the tunnel process + // is not started on demand, so the IPC calls hang. Thus, we use separate code + // paths for iOS and macOS. + func calculateLogDirSize() async -> String { Log.app.log("\(#function)") guard let logFilesFolderURL = SharedAccess.logFolderURL else { Log.app.error("\(#function): Log folder is unavailable") - return nil + + return "Unknown" } - let fileManager = FileManager.default + let logFolderSize = await Log.size(of: logFilesFolderURL) - var totalSize = 0 - fileManager.forEachFileUnder( - logFilesFolderURL, - including: [ - .totalFileAllocatedSizeKey, - .totalFileSizeKey, - .isRegularFileKey, - ] - ) { url, resourceValues in - if resourceValues.isRegularFile ?? false { - totalSize += (resourceValues.totalFileAllocatedSize ?? resourceValues.totalFileSize ?? 0) - } + do { +#if os(macOS) + let providerLogFolderSize = try await store.tunnelManager.getLogFolderSize() + let totalSize = logFolderSize + providerLogFolderSize +#else + let totalSize = logFolderSize +#endif + + let byteCountFormatter = ByteCountFormatter() + byteCountFormatter.countStyle = .file + byteCountFormatter.allowsNonnumericFormatting = false + byteCountFormatter.allowedUnits = [.useKB, .useMB, .useGB, .useTB, .usePB] + + return byteCountFormatter.string(fromByteCount: Int64(totalSize)) + + } catch { + Log.app.error("\(#function): \(error)") + + return "Unknown" } - - if Task.isCancelled { - return nil - } - - let byteCountFormatter = ByteCountFormatter() - byteCountFormatter.countStyle = .file - byteCountFormatter.allowsNonnumericFormatting = false - byteCountFormatter.allowedUnits = [.useKB, .useMB, .useGB, .useTB, .usePB] - return byteCountFormatter.string(fromByteCount: Int64(totalSize)) } - func clearAllLogs() throws { + // On iOS, all the logs are stored in one directory. + // On macOS, we need to clear logs from the app process, then call over IPC + // to clear the provider's log directory. + func clearAllLogs() async throws { Log.app.log("\(#function)") - guard let logFilesFolderURL = SharedAccess.logFolderURL else { - Log.app.error("\(#function): Log folder is unavailable") - return - } + try Log.clear(in: SharedAccess.logFolderURL) - let fileManager = FileManager.default - var unremovedFilesCount = 0 - fileManager.forEachFileUnder( - logFilesFolderURL, - including: [ - .isRegularFileKey - ] - ) { url, resourceValues in - if resourceValues.isRegularFile ?? false { - do { - try fileManager.removeItem(at: url) - } catch { - unremovedFilesCount += 1 - Log.app.error("Unable to remove '\(url)': \(error)") - } - } - } - - if unremovedFilesCount > 0 { - Log.app.log("\(#function): Unable to remove \(unremovedFilesCount) files") - } +#if os(macOS) + try await store.tunnelManager.clearLogs() +#endif } } @@ -623,7 +614,7 @@ public struct SettingsView: View { self.calculateLogSizeTask = Task.detached(priority: .userInitiated) { let calculatedLogsSize = await model.calculateLogDirSize() await MainActor.run { - self.calculatedLogsSize = calculatedLogsSize ?? "Unknown" + self.calculatedLogsSize = calculatedLogsSize self.isCalculatingLogsSize = false self.calculateLogSizeTask = nil } diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift index 6ca33e236..7b67aa90c 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -124,6 +124,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { completionHandler() } + // TODO: It would be helpful to be able to encapsulate Errors here. To do that + // we need to update TunnelMessage to encode/decode Result to and from Data. override func handleAppMessage(_ message: Data, completionHandler: ((Data?) -> Void)? = nil) { guard let tunnelMessage = try? PropertyListDecoder().decode(TunnelMessage.self, from: message) else { return } @@ -139,6 +141,38 @@ class PacketTunnelProvider: NEPacketTunnelProvider { resourceListJSON in completionHandler?(resourceListJSON?.data(using: .utf8)) } + case .clearLogs: + clearLogs(completionHandler) + case .getLogFolderSize: + getLogFolderSize(completionHandler) + } + } + + func clearLogs(_ completionHandler: ((Data?) -> Void)? = nil) { + Task { + do { + try Log.clear(in: SharedAccess.logFolderURL) + } catch { + Log.tunnel.error("Error clearing logs: \(error)") + } + + completionHandler?(nil) + } + } + + func getLogFolderSize(_ completionHandler: ((Data?) -> Void)? = nil) { + guard let logFolderURL = SharedAccess.logFolderURL + else { + completionHandler?(nil) + + return + } + + Task { + let size = await Log.size(of: logFolderURL) + let data = withUnsafeBytes(of: size) { Data($0) } + + completionHandler?(data) } } }