diff --git a/mobile/TeleportKit/.swiftpm/xcode/xcshareddata/xcschemes/LogBackends.xcscheme b/mobile/TeleportKit/.swiftpm/xcode/xcshareddata/xcschemes/LogBackends.xcscheme new file mode 100644 index 0000000000000..58eb0cfa4ba21 --- /dev/null +++ b/mobile/TeleportKit/.swiftpm/xcode/xcshareddata/xcschemes/LogBackends.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/TeleportKit/Package.swift b/mobile/TeleportKit/Package.swift index f0026b6537a6b..7c606d2d50790 100644 --- a/mobile/TeleportKit/Package.swift +++ b/mobile/TeleportKit/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "TeleportKit", - platforms: [.iOS(.v26)], + platforms: [.iOS(.v26), .macOS(.v26)], products: [ .library(name: "SystemClients", targets: ["SystemClients"]), .library(name: "LogBackends", targets: ["LogBackends"]), @@ -13,6 +13,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-dependencies", .upToNextMajor(from: "1.14.0")), .package(url: "https://github.com/pointfreeco/swift-sharing", .upToNextMajor(from: "2.9.1")), .package(url: "https://github.com/apple/swift-log", .upToNextMajor(from: "1.14.0")), + .package(url: "https://github.com/apple/swift-collections", .upToNextMajor(from: "1.6.0")), ], targets: [ .target( @@ -26,7 +27,19 @@ let package = Package( .target( name: "LogBackends", dependencies: [ + .collections, .logging, + .dependencies, + "SystemClients", + ], + ), + .testTarget( + name: "LogBackendsTests", + dependencies: [ + .dependencies, + .logging, + "LogBackends", + "SystemClients", ], ), ], @@ -52,6 +65,10 @@ extension Target.Dependency { name: "Logging", package: "swift-log", ) + fileprivate static let collections: Self = .product( + name: "Collections", + package: "swift-collections", + ) } // MARK: - Build Settings diff --git a/mobile/TeleportKit/Sources/LogBackends/ConsoleLogHandler.swift b/mobile/TeleportKit/Sources/LogBackends/ConsoleLogHandler.swift index bae5289b259ac..ceb90062ffbae 100644 --- a/mobile/TeleportKit/Sources/LogBackends/ConsoleLogHandler.swift +++ b/mobile/TeleportKit/Sources/LogBackends/ConsoleLogHandler.swift @@ -14,6 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see http://www.gnu.org/licenses/ +import Dependencies import Foundation public import Logging @@ -21,6 +22,9 @@ public import Logging public struct ConsoleLogHandler: LogHandler { public let label: String + @Dependency(\.date.now) + private var now + private let timestampFormmater = Date.ISO8601FormatStyle( dateSeparator: .dash, dateTimeSeparator: .space, @@ -46,11 +50,7 @@ public struct ConsoleLogHandler: LogHandler { } public func log(event: LogEvent) { - let timestamp = timestampFormmater.format(.now) - let metadataToLog = metadata.merging(event.metadata ?? [:]) { _, new in new } - - let output = "\(timestamp) \(event.level.formatted) \(event.file):\(event.line) | \(event.message) \(metadataToLog.formatted)" - - print(output) + let logMessage = LogFormatter.format(label: label, event: event, handlerMetadata: metadata, timestamp: now) + print(logMessage) } } diff --git a/mobile/TeleportKit/Sources/LogBackends/Level+Formatting.swift b/mobile/TeleportKit/Sources/LogBackends/Formatting/Level+Formatting.swift similarity index 100% rename from mobile/TeleportKit/Sources/LogBackends/Level+Formatting.swift rename to mobile/TeleportKit/Sources/LogBackends/Formatting/Level+Formatting.swift diff --git a/mobile/TeleportKit/Sources/LogBackends/Formatting/LogFormatter.swift b/mobile/TeleportKit/Sources/LogBackends/Formatting/LogFormatter.swift new file mode 100644 index 0000000000000..a3446d0e582f4 --- /dev/null +++ b/mobile/TeleportKit/Sources/LogBackends/Formatting/LogFormatter.swift @@ -0,0 +1,67 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see http://www.gnu.org/licenses/ + +import Foundation +import Logging + +/// LogFormatter doesn't currently maintain any local state, so it's simplest to implement it as an enum with static +/// functions. +enum LogFormatter { + private static let timestampFormatter = Date.ISO8601FormatStyle( + dateSeparator: .dash, + dateTimeSeparator: .space, + timeSeparator: .colon, + timeZoneSeparator: .colon, + includingFractionalSeconds: true, + timeZone: .autoupdatingCurrent, + ) + + /// Formats the log message + /// - Parameters: + /// - label: The label of the logger + /// - event: The event to log + /// - handlerMetadata: The metadata for the `LogHandler` (note: events carry their own metadata and this formatter + /// automatically coalesces them with the `LogHandler` metadata) + /// - timestamp: The time at which the logging event took place + /// - Returns: A formatted log message suitable for output to a debug console or writing to a file + static func format( + label: String, + event: LogEvent, + handlerMetadata: Logger.Metadata, + timestamp: Date, + ) -> String { + let metadata = handlerMetadata.merging(event.metadata ?? [:]) { _, eventValue in eventValue } + var components: [String] = [ + timestampFormatter.format(timestamp), + event.level.formatted, + "[\(label)]", + "\(event.file):\(event.line)", + "|", + "\(event.message)", + ] + + let formattedMetadata = metadata.formatted + if !formattedMetadata.isEmpty { + components.append(formattedMetadata) + } + + if let error = event.error { + components.append("❌ error=\(error)") + } + + return components.joined(separator: " ") + } +} diff --git a/mobile/TeleportKit/Sources/LogBackends/Metadata+Formatting.swift b/mobile/TeleportKit/Sources/LogBackends/Formatting/Metadata+Formatting.swift similarity index 89% rename from mobile/TeleportKit/Sources/LogBackends/Metadata+Formatting.swift rename to mobile/TeleportKit/Sources/LogBackends/Formatting/Metadata+Formatting.swift index 3ef3d2e09653d..0387153146766 100644 --- a/mobile/TeleportKit/Sources/LogBackends/Metadata+Formatting.swift +++ b/mobile/TeleportKit/Sources/LogBackends/Formatting/Metadata+Formatting.swift @@ -25,12 +25,7 @@ extension Logger.Metadata { var formatted: String { guard !isEmpty else { return "" } let metadataAsStrings = map { key, value in - let prefix = if key.lowercased().localizedStandardContains("error") { - "❌" - } else { - "🔸" - } - return "\(prefix) \(key)=\(value)" + "🔸 \(key)=\(value)" } return metadataAsStrings.joined(separator: " ") } diff --git a/mobile/TeleportKit/Sources/LogBackends/Rotating File/RotatingFileLogHandler.swift b/mobile/TeleportKit/Sources/LogBackends/Rotating File/RotatingFileLogHandler.swift new file mode 100644 index 0000000000000..13950f6eeefdf --- /dev/null +++ b/mobile/TeleportKit/Sources/LogBackends/Rotating File/RotatingFileLogHandler.swift @@ -0,0 +1,50 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see http://www.gnu.org/licenses/ + +import Dependencies +import Foundation +import Logging + +/// A log handler that writes to a rotating file on disk. +public struct RotatingFileLogHandler: LogHandler { + public let label: String + + @Dependency(\.date.now) + private var now + + private let writer: RotatingFileWriter + + public init(label: String, writer: RotatingFileWriter) { + self.label = label + self.writer = writer + } + + // MARK: LogHandler Conformance + + public var logLevel: Logger.Level = .info + + public var metadata: Logger.Metadata = [:] + + public subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { metadata[key] } + set { metadata[key] = newValue } + } + + public func log(event: LogEvent) { + let logMessage = LogFormatter.format(label: label, event: event, handlerMetadata: metadata, timestamp: now) + writer.enqueue(logMessage: "\(logMessage)\n") + } +} diff --git a/mobile/TeleportKit/Sources/LogBackends/Rotating File/RotatingFileWriter.swift b/mobile/TeleportKit/Sources/LogBackends/Rotating File/RotatingFileWriter.swift new file mode 100644 index 0000000000000..9e81c5e89a093 --- /dev/null +++ b/mobile/TeleportKit/Sources/LogBackends/Rotating File/RotatingFileWriter.swift @@ -0,0 +1,268 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see http://www.gnu.org/licenses/ + +import Dependencies +import DequeModule +public import Foundation +import Synchronization +import SystemClients + +/// Opens a file for writing which rotates to a new file whenever the original is at capacity. +public final class RotatingFileWriter: Sendable { + // MARK: Mutable State + + /// An inbox that maintains the pending items to be written. + /// + /// This mutext protects pending-item bookkeeping only. Filesystem work must never occur while holding this mutex, + /// keeping enqueue operations independent of slow I/O. + private let inbox = Mutex(Inbox()) + + /// A handle to the currently open/active file. + /// + /// This mutex protects the active file state separately from the inbox mutext because it may be held during + /// synchronous filesystem operations. + private let fileState = Mutex(FileState()) + + // MARK: Immutable State + + private let activeFileURL: URL + private let configuration: Configuration + private let queue = DispatchQueue(label: "com.goteleport.teleportkit.rotating-file-writer") + private let fileSystemClient: FileSystemClient + + public convenience init(fileURL: URL) { + self.init(fileURL: fileURL, configuration: .live) + } + + init(fileURL: URL, configuration: Configuration) { + self.activeFileURL = fileURL + self.configuration = configuration + + // Retrieving the dependency once during init is safe because the file system implementation should not + // change mid-process. + @Dependency(\.fileSystemClient) + var fileSystemClient + + self.fileSystemClient = fileSystemClient + } + + /// Waits for all previously enqueued records to be processed and synchronizes the active file. + public func flush() async throws { + try await withCheckedThrowingContinuation { continuation in + // Enqueuing the continuation allows us to ensure all earlier items are processed before the active file is + // synchronized and the continuation resumes. + enqueue(item: .flush(continuation)) + } + } + + /// Enqueues a log message to be written to disk + func enqueue(logMessage: String) { + enqueue(item: .logMessage(logMessage)) + } +} + +// MARK: - Inbox Processing + +extension RotatingFileWriter { + /// Adds an item to the inbox and schedules processing if it isn't already scheduled or active. + private func enqueue(item: PendingItem) { + let shouldScheduleProcessing = inbox.withLock { inbox in + inbox.append(item) + } + + guard shouldScheduleProcessing else { return } + queue.async { + self.processInbox() + } + } + + /// The top level inbox processing function that iterates through the pending items and performs their corresponding + /// operation. + private func processInbox() { + inbox.withLock { $0.beginProcessing() } + + while let item = inbox.withLock({ $0.nextPendingItem() }) { + switch item { + case let .logMessage(record): + do { + try append(record: record) + } catch { + recordFailure(error) + } + case let .droppedRecordNotice(count): + do { + try append(droppedRecordNoticeFor: count) + } catch { + recordFailure(error) + } + case let .flush(continuation): + processFlushRequest(continuation) + } + } + } +} + +// MARK: - File Operations + +extension RotatingFileWriter { + /// Appends the record to the currently active file. + /// - Parameter record: The record to write to disk. + private func append(record: String) throws { + let record = truncateRecordIfNeeded(Data(record.utf8)) + try openActiveFileIfNeeded() + try rotateActiveFileIfNeeded(forAppendingByteCount: record.count) + + try fileState.withLock { fileState in + guard let fileClient = fileState.fileClient else { return } + _ = try fileClient.seekToEnd() + try fileClient.write(data: record) + } + } + + /// Appends a line to the log file indicating that some number of records were dropped, perhaps due to overflowing + /// the queue. + /// - Parameter count: The number of log records + private func append(droppedRecordNoticeFor count: Int) throws { + // TODO: Implement dropped record logging + } + + /// Truncates a record if its size exceeds the max size of a single log file. + /// - Parameter record: The record to truncate + /// - Returns: The record, truncated only if necessary + private func truncateRecordIfNeeded(_ record: Data) -> Data { + // TODO: Truncate the record + record + } + + /// Opens a file for appending if no active file is yet open. + private func openActiveFileIfNeeded() throws { + try fileState.withLock { fileState in + guard fileState.fileClient == nil else { return } + + try fileSystemClient.createDirectory(url: activeFileURL.deletingLastPathComponent()) + if !fileSystemClient.fileExists(url: activeFileURL) { + try fileSystemClient.createFile(url: activeFileURL, contents: nil) + } + fileState.fileClient = try fileSystemClient.openFileForWriting(url: activeFileURL) + } + } + + /// Rotates the active file if needed. + /// + /// Rotation is "needed" when appending a record of the indicated count would cause the log file to exceed its + /// maximum capacity. + /// - Parameter byteCount: The number of bytes we intend to append to the log file. + private func rotateActiveFileIfNeeded(forAppendingByteCount byteCount: Int) throws { + // TODO: Implement rotation + } + + /// Flushes all pending writes to disk via the file handle, ensuring all records are persisted. + /// - Parameter continuation: The continuation to resume when the synchronize operation is done. + private func processFlushRequest(_ continuation: CheckedContinuation) { + do { + try fileState.withLock { fileState in + try fileState.fileClient?.synchronize() + } + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + + private func recordFailure(_ error: any Error) {} +} + +// MARK: - Supporting Types + +extension RotatingFileWriter { + struct Configuration { + static let live = Configuration( + maximumFileSize: 4 * 1024 * 1024, + maximumArchiveCount: 3, + ) + + /// The maximum size any individual file is allowed to be, in number of bytes + let maximumFileSize: Int + + /// The maximum number of archived files. This does not include the file actively being written to. + /// + /// For example, if the maximum archive count is 3, then at most the rotating file writer will govern 4 total + /// files: the 3 archive files and the one active file. + let maximumArchiveCount: Int + } + + private struct Inbox { + var pendingItems: Deque = [] + var processingState = ProcessingState.idle + + /// Appends an item to the pendingItems queue and marks the inbox as scheduled for processing + /// - Parameter item: The item to queue up + /// - Returns: True if a new processing job should be started. False otherwise. + mutating func append(_ item: PendingItem) -> Bool { + pendingItems.append(item) + + guard processingState == .idle else { return false } + processingState = .scheduled + return true + } + + /// Marks the inbox as being actively processed. + mutating func beginProcessing() { + assert(processingState == .scheduled) + processingState = .processing + } + + /// Retrieves the next item from the queue. If the queue is empty, that means processing is done and we mark the + /// inbox as idle. + /// - Returns: The next item to process from the queue, if the queue is non-empty. Nil otherwise. + mutating func nextPendingItem() -> PendingItem? { + assert(processingState == .processing) + + guard !pendingItems.isEmpty else { + processingState = .idle + return nil + } + + return pendingItems.removeFirst() + } + } + + /// Enumerates the possible states the inbox can be in with respect to processing. + /// + /// Lifecycle: idle → scheduled → processing → idle. + /// + /// Whenever the mutex is released, a nonempty inbox must have processing scheduled or active. + private enum ProcessingState { + case idle + case scheduled + case processing + } + + /// Contains mutable state that the worker needs in order to write to disk. + struct FileState { + var fileClient: WritableFileClient? = nil + } + + /// An enumeration of the various items that can be enqueued in the inbox + private enum PendingItem { + /// A log message to be written to disk + case logMessage(String) + /// An indicator saying a certain number of records were dropped. + case droppedRecordNotice(Int) + /// A request to flush the current file handle to disk + case flush(CheckedContinuation) + } +} diff --git a/mobile/TeleportKit/Sources/SystemClients/File System/FileSystemClient.swift b/mobile/TeleportKit/Sources/SystemClients/File System/FileSystemClient.swift new file mode 100644 index 0000000000000..c197d63a2d2e1 --- /dev/null +++ b/mobile/TeleportKit/Sources/SystemClients/File System/FileSystemClient.swift @@ -0,0 +1,64 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see http://www.gnu.org/licenses/ + +import DependenciesMacros +public import Foundation + +/// An interface to the filesystem operations used by TeleportKit. +/// +/// As TeleportKit needs more filesystem capabilities, it's perfectly natural for this client to grow over time. +@DependencyClient +public struct FileSystemClient: Sendable { + public var createDirectory: @Sendable (_ url: URL) throws -> Void + public var fileExists: @Sendable (_ url: URL) -> Bool = { _ in false } + public var createFile: @Sendable (_ url: URL, _ contents: Data?) throws -> Void + public var openFileForWriting: @Sendable (_ url: URL) throws -> WritableFileClient + public var moveItem: @Sendable (_ sourceURL: URL, _ destinationURL: URL) throws -> Void + public var removeItem: @Sendable (_ url: URL) throws -> Void +} + +public enum FileSystemClientError: Error { + /// File creation failure doesn't surface an error, but rather returns a `Bool`, so we have our own error + case couldNotCreateFile(path: String) +} + +extension FileSystemClient { + public static let liveValue = FileSystemClient( + createDirectory: { url in + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true, + ) + }, + fileExists: { url in + FileManager.default.fileExists(atPath: url.path) + }, + createFile: { url, contents in + guard FileManager.default.createFile(atPath: url.path, contents: contents) else { + throw FileSystemClientError.couldNotCreateFile(path: url.path(percentEncoded: false)) + } + }, + openFileForWriting: { url in + try WritableFileClient.liveValue(FileHandle(forWritingTo: url)) + }, + moveItem: { sourceURL, destinationURL in + try FileManager.default.moveItem(at: sourceURL, to: destinationURL) + }, + removeItem: { url in + try FileManager.default.removeItem(at: url) + }, + ) +} diff --git a/mobile/TeleportKit/Sources/SystemClients/File System/WriteableFileClient.swift b/mobile/TeleportKit/Sources/SystemClients/File System/WriteableFileClient.swift new file mode 100644 index 0000000000000..9842a7e397e50 --- /dev/null +++ b/mobile/TeleportKit/Sources/SystemClients/File System/WriteableFileClient.swift @@ -0,0 +1,49 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see http://www.gnu.org/licenses/ + +import DependenciesMacros +public import Foundation + +/// An interface to an open writable file. +/// +/// This interface exists so that code which uses a writeable FileHandle can be tested. For documentation on what the +/// individual functions do, refer to the correspondingly named functions on `FileHandle`. +@DependencyClient +public struct WritableFileClient: Sendable { + public var seekToEnd: @Sendable () throws -> UInt64 + public var write: @Sendable (_ data: Data) throws -> Void + public var synchronize: @Sendable () throws -> Void + public var close: @Sendable () throws -> Void +} + +extension WritableFileClient { + static func liveValue(_ fileHandle: FileHandle) -> WritableFileClient { + WritableFileClient( + seekToEnd: { + try fileHandle.seekToEnd() + }, + write: { data in + try fileHandle.write(contentsOf: data) + }, + synchronize: { + try fileHandle.synchronize() + }, + close: { + try fileHandle.close() + }, + ) + } +} diff --git a/mobile/TeleportKit/Sources/SystemClients/SystemClients.swift b/mobile/TeleportKit/Sources/SystemClients/SystemClients.swift index ade0788a2e2f4..1714fb5ec7d5c 100644 --- a/mobile/TeleportKit/Sources/SystemClients/SystemClients.swift +++ b/mobile/TeleportKit/Sources/SystemClients/SystemClients.swift @@ -20,4 +20,7 @@ import DependenciesMacros extension DependencyValues { @DependencyEntry(liveValue: SerialNumberClient.liveValue) public nonisolated var serialNumberClient = SerialNumberClient() + + @DependencyEntry(liveValue: FileSystemClient.liveValue) + public nonisolated var fileSystemClient: FileSystemClient } diff --git a/mobile/TeleportKit/Tests/LogBackendsTests/RotatingFileWriterTests.swift b/mobile/TeleportKit/Tests/LogBackendsTests/RotatingFileWriterTests.swift new file mode 100644 index 0000000000000..452e7040bac6b --- /dev/null +++ b/mobile/TeleportKit/Tests/LogBackendsTests/RotatingFileWriterTests.swift @@ -0,0 +1,105 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see http://www.gnu.org/licenses/ + +import Dependencies +import Foundation +import Logging +import SystemClients +import Testing +@testable import LogBackends + +struct RotatingFileWriterTests { + @Test + func `flush appends all previously enqueued records in order`() async throws { + try await withTemporaryDirectory { directoryURL in + let fileURL = directoryURL.appending(path: "events.log") + let writer = makeWriter(fileURL: fileURL) + + writer.enqueue(logMessage: "first\n") + writer.enqueue(logMessage: "second\n") + try await writer.flush() + + let contents = try? String(contentsOf: fileURL, encoding: .utf8) + #expect(contents == "first\nsecond\n") + } + } + + @Test + func `records enqueued after a completed flush are processed`() async throws { + try await withTemporaryDirectory { directoryURL in + let fileURL = directoryURL.appending(path: "events.log") + let writer = makeWriter(fileURL: fileURL) + + writer.enqueue(logMessage: "first\n") + try await writer.flush() + + writer.enqueue(logMessage: "second\n") + try await writer.flush() + + let contents = try? String(contentsOf: fileURL, encoding: .utf8) + #expect(contents == "first\nsecond\n") + } + } + + @Test + func `writing creates missing parent directories`() async throws { + try await withTemporaryDirectory { directoryURL in + let fileURL = directoryURL.appending(path: "nested/logs/events.log") + let writer = makeWriter(fileURL: fileURL) + + writer.enqueue(logMessage: "record\n") + try await writer.flush() + + let contents = try? String(contentsOf: fileURL, encoding: .utf8) + #expect(contents == "record\n") + } + } + + @Test + func `handler sends its formatted record to the shared writer`() async throws { + try await withTemporaryDirectory { directoryURL in + let fileURL = directoryURL.appending(path: "events.log") + let writer = makeWriter(fileURL: fileURL) + let handler = withDependencies { + $0.date.now = Date(timeIntervalSince1970: 0) + } operation: { + RotatingFileLogHandler(label: "test.logger", writer: writer) + } + + handler.log(event: LogEvent( + level: .info, + message: "hello from the handler", + metadata: nil, + source: nil, + file: "Module/File.swift", + function: "run()", + line: 42, + )) + try await writer.flush() + + let contents = try? String(contentsOf: fileURL, encoding: .utf8) + #expect(contents?.contains("hello from the handler") == true) + } + } +} + +private func makeWriter(fileURL: URL) -> RotatingFileWriter { + withDependencies { + $0.fileSystemClient = FileSystemClient.liveValue + } operation: { + RotatingFileWriter(fileURL: fileURL) + } +} diff --git a/mobile/TeleportKit/Tests/LogBackendsTests/TemporaryDirectory.swift b/mobile/TeleportKit/Tests/LogBackendsTests/TemporaryDirectory.swift new file mode 100644 index 0000000000000..e21a5474dcfcb --- /dev/null +++ b/mobile/TeleportKit/Tests/LogBackendsTests/TemporaryDirectory.swift @@ -0,0 +1,36 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see http://www.gnu.org/licenses/ + +import Foundation +import Testing + +func withTemporaryDirectory( + _ operation: (URL) async throws -> Result +) async throws -> Result { + let directoryURL = FileManager.default.temporaryDirectory + .appending(path: "TeleportKitTests-\(UUID())", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + + defer { + do { + try FileManager.default.removeItem(at: directoryURL) + } catch { + Issue.record("Failed to remove temporary test directory at \(directoryURL.path): \(error)") + } + } + + return try await operation(directoryURL) +} diff --git a/mobile/Verify/Verify.xcodeproj/project.pbxproj b/mobile/Verify/Verify.xcodeproj/project.pbxproj index c798732a67e19..1562eb3d9a528 100644 --- a/mobile/Verify/Verify.xcodeproj/project.pbxproj +++ b/mobile/Verify/Verify.xcodeproj/project.pbxproj @@ -9,6 +9,10 @@ /* Begin PBXBuildFile section */ 0C94C8CE2FA35F3D00C13C9A /* libresolv.9.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C94C8CD2FA35ED900C13C9A /* libresolv.9.tbd */; }; CD65C8012FF711CE002FB1CD /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = CD65C8002FF711CE002FB1CD /* IdentifiedCollections */; }; + CD67F9AF302A74B20043D017 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = CD67F9AE302A74B20043D017 /* Logging */; }; + CD67F9B1302A74BD0043D017 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = CD67F9B0302A74BD0043D017 /* Logging */; }; + CD67F9B3302A76000043D017 /* Dependencies in Frameworks */ = {isa = PBXBuildFile; productRef = CD67F9B2302A76000043D017 /* Dependencies */; }; + CD67F9B5302A76000043D017 /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = CD67F9B4302A76000043D017 /* DependenciesMacros */; }; CD7999232FE33AE000133048 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD79991B2FE33AE000133048 /* Core.framework */; }; CD7999242FE33AE000133048 /* Core.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CD79991B2FE33AE000133048 /* Core.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; CD79992C2FE33F0600133048 /* libresolv.9.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = CD79992B2FE33EFF00133048 /* libresolv.9.tbd */; }; @@ -23,6 +27,8 @@ CDAA8BB22FFD55CC00E6CA87 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CDAA8BB12FFD55CC00E6CA87 /* GRDB */; }; CDAA8BB52FFD5A1300E6CA87 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CDAA8BB42FFD5A1300E6CA87 /* SQLiteData */; }; CDAA8BB72FFD5A1300E6CA87 /* SQLiteDataTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CDAA8BB62FFD5A1300E6CA87 /* SQLiteDataTestSupport */; }; + CDEA38EA302CD5630004B164 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = CDEA38E9302CD5630004B164 /* Collections */; }; + CDEA38EC302CD5770004B164 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = CDEA38EB302CD5770004B164 /* Collections */; }; CDFCE7053022A9D4002E6773 /* LogBackends in Frameworks */ = {isa = PBXBuildFile; productRef = CDFCE7043022A9D4002E6773 /* LogBackends */; }; CDFCE7073022A9D4002E6773 /* LogBackends in Frameworks */ = {isa = PBXBuildFile; productRef = CDFCE7063022A9D4002E6773 /* LogBackends */; }; /* End PBXBuildFile section */ @@ -102,10 +108,14 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CD67F9AF302A74B20043D017 /* Logging in Frameworks */, CDFCE7053022A9D4002E6773 /* LogBackends in Frameworks */, CD9AEFAC3022782200E2FC14 /* SystemClients in Frameworks */, CDAA8BB52FFD5A1300E6CA87 /* SQLiteData in Frameworks */, + CD67F9B5302A76000043D017 /* DependenciesMacros in Frameworks */, 0C94C8CE2FA35F3D00C13C9A /* libresolv.9.tbd in Frameworks */, + CDEA38EA302CD5630004B164 /* Collections in Frameworks */, + CD67F9B3302A76000043D017 /* Dependencies in Frameworks */, CDAA8BB22FFD55CC00E6CA87 /* GRDB in Frameworks */, CD65C8012FF711CE002FB1CD /* IdentifiedCollections in Frameworks */, CD7999232FE33AE000133048 /* Core.framework in Frameworks */, @@ -117,6 +127,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CDEA38EC302CD5770004B164 /* Collections in Frameworks */, + CD67F9B1302A74BD0043D017 /* Logging in Frameworks */, CDFCE7073022A9D4002E6773 /* LogBackends in Frameworks */, CD9AEFAE3022782900E2FC14 /* SystemClients in Frameworks */, CD79992C2FE33F0600133048 /* libresolv.9.tbd in Frameworks */, @@ -209,6 +221,10 @@ CDAA8BB42FFD5A1300E6CA87 /* SQLiteData */, CD9AEFAB3022782200E2FC14 /* SystemClients */, CDFCE7043022A9D4002E6773 /* LogBackends */, + CD67F9AE302A74B20043D017 /* Logging */, + CD67F9B2302A76000043D017 /* Dependencies */, + CD67F9B4302A76000043D017 /* DependenciesMacros */, + CDEA38E9302CD5630004B164 /* Collections */, ); productName = Verify; productReference = 0C94C8522FA232E600C13C9A /* Verify.app */; @@ -238,6 +254,8 @@ CD7999342FE346B800133048 /* DependenciesMacros */, CD9AEFAD3022782900E2FC14 /* SystemClients */, CDFCE7063022A9D4002E6773 /* LogBackends */, + CD67F9B0302A74BD0043D017 /* Logging */, + CDEA38EB302CD5770004B164 /* Collections */, ); productName = Core; productReference = CD79991B2FE33AE000133048 /* Core.framework */; @@ -310,6 +328,8 @@ CDAA8BB32FFD5A1200E6CA87 /* XCRemoteSwiftPackageReference "sqlite-data" */, CD920CEE301D13500027C579 /* XCRemoteSwiftPackageReference "swift-sharing" */, CD921947302148B80027C579 /* XCLocalSwiftPackageReference "../TeleportKit" */, + CD67F9AD302A74B20043D017 /* XCRemoteSwiftPackageReference "swift-log" */, + CDEA38E8302CD5630004B164 /* XCRemoteSwiftPackageReference "swift-collections" */, ); preferredProjectObjectVersion = 77; productRefGroup = 0C94C8532FA232E600C13C9A /* Products */; @@ -586,6 +606,8 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Verify/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Teleport Verify"; + INFOPLIST_KEY_NSCameraUsageDescription = "Teleport Verify uses the built-in QR code scanner to receive enrollment data from the Teleport Web UI"; + INFOPLIST_KEY_NSFaceIDUsageDescription = "Teleport Verify uses Face ID to confirm your presence when proving this device's identity"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -634,6 +656,8 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Verify/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Teleport Verify"; + INFOPLIST_KEY_NSCameraUsageDescription = "Teleport Verify uses the built-in QR code scanner to receive enrollment data from the Teleport Web UI"; + INFOPLIST_KEY_NSFaceIDUsageDescription = "Teleport Verify uses Face ID to confirm your presence when proving this device's identity"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -872,6 +896,14 @@ minimumVersion = 1.1.1; }; }; + CD67F9AD302A74B20043D017 /* XCRemoteSwiftPackageReference "swift-log" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-log"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.14.0; + }; + }; CD7999312FE345D400133048 /* XCRemoteSwiftPackageReference "swift-dependencies" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-dependencies"; @@ -924,6 +956,14 @@ minimumVersion = 0.63.3; }; }; + CDEA38E8302CD5630004B164 /* XCRemoteSwiftPackageReference "swift-collections" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-collections.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.6.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -932,6 +972,26 @@ package = CD65C7FF2FF711CE002FB1CD /* XCRemoteSwiftPackageReference "swift-identified-collections" */; productName = IdentifiedCollections; }; + CD67F9AE302A74B20043D017 /* Logging */ = { + isa = XCSwiftPackageProductDependency; + package = CD67F9AD302A74B20043D017 /* XCRemoteSwiftPackageReference "swift-log" */; + productName = Logging; + }; + CD67F9B0302A74BD0043D017 /* Logging */ = { + isa = XCSwiftPackageProductDependency; + package = CD67F9AD302A74B20043D017 /* XCRemoteSwiftPackageReference "swift-log" */; + productName = Logging; + }; + CD67F9B2302A76000043D017 /* Dependencies */ = { + isa = XCSwiftPackageProductDependency; + package = CD7999312FE345D400133048 /* XCRemoteSwiftPackageReference "swift-dependencies" */; + productName = Dependencies; + }; + CD67F9B4302A76000043D017 /* DependenciesMacros */ = { + isa = XCSwiftPackageProductDependency; + package = CD7999312FE345D400133048 /* XCRemoteSwiftPackageReference "swift-dependencies" */; + productName = DependenciesMacros; + }; CD7999282FE33B7E00133048 /* SwiftLintBuildToolPlugin */ = { isa = XCSwiftPackageProductDependency; package = CDDEB8752FE1F5F10052BC27 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */; @@ -997,6 +1057,16 @@ package = CDDEB8752FE1F5F10052BC27 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */; productName = "plugin:SwiftLintBuildToolPlugin"; }; + CDEA38E9302CD5630004B164 /* Collections */ = { + isa = XCSwiftPackageProductDependency; + package = CDEA38E8302CD5630004B164 /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = Collections; + }; + CDEA38EB302CD5770004B164 /* Collections */ = { + isa = XCSwiftPackageProductDependency; + package = CDEA38E8302CD5630004B164 /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = Collections; + }; CDFCE7043022A9D4002E6773 /* LogBackends */ = { isa = XCSwiftPackageProductDependency; package = CD921947302148B80027C579 /* XCLocalSwiftPackageReference "../TeleportKit" */; diff --git a/mobile/Verify/Verify/App Lifecycle/VerifyAppModel.swift b/mobile/Verify/Verify/App Lifecycle/VerifyAppModel.swift index 4b37d00c1ef89..cc548f85b3ad8 100644 --- a/mobile/Verify/Verify/App Lifecycle/VerifyAppModel.swift +++ b/mobile/Verify/Verify/App Lifecycle/VerifyAppModel.swift @@ -42,10 +42,7 @@ extension VerifyAppModel { landingViewModel.navigateToDeviceEnrollment(with: deepLink) } } catch { - logger.warning("Failed to parse deep link", metadata: [ - "scannedURL": "\(url)", - "error": .string(String(describing: error)), - ]) + logger.warning("Failed to parse deep link", error: error, metadata: ["scannedURL": "\(url)"]) landingViewModel.showParserError(errorMessage: error.localizedDescription) } } diff --git a/mobile/Verify/Verify/Device Trust/Credentials/SecureEnclaveCredentialStore.swift b/mobile/Verify/Verify/Device Trust/Credentials/SecureEnclaveCredentialStore.swift index 1e88baff162b0..0c00fbde5ce71 100644 --- a/mobile/Verify/Verify/Device Trust/Credentials/SecureEnclaveCredentialStore.swift +++ b/mobile/Verify/Verify/Device Trust/Credentials/SecureEnclaveCredentialStore.swift @@ -230,9 +230,10 @@ extension SecureEnclaveCredentialStore { ) else { if let error { - logger.error("Could not create the Device Trust access-control policy", metadata: [ - "error": "\(error.takeRetainedValue())", - ]) + logger.error( + "Could not create the Device Trust access-control policy", + error: error.takeRetainedValue(), + ) } throw DeviceTrustCredentialError.accessControlCreationFailed diff --git a/mobile/Verify/Verify/Features/Enrollment/EnrollDeviceViewModel.swift b/mobile/Verify/Verify/Features/Enrollment/EnrollDeviceViewModel.swift index 69273273fa4a3..835fd771c1864 100644 --- a/mobile/Verify/Verify/Features/Enrollment/EnrollDeviceViewModel.swift +++ b/mobile/Verify/Verify/Features/Enrollment/EnrollDeviceViewModel.swift @@ -90,9 +90,7 @@ class EnrollDeviceViewModel { loadingState = .success("fake-token-\(cluster?.id.uuidString ?? "(nil)")") } catch { - logger.error("Failed to request enrollment token", metadata: [ - "error": .string(String(describing: error)), - ]) + logger.error("Failed to request enrollment token", error: error) loadingState = .failure(error) } } diff --git a/mobile/Verify/Verify/Features/Landing/LandingViewModel.swift b/mobile/Verify/Verify/Features/Landing/LandingViewModel.swift index c814893afbdd4..6879324f23b7f 100644 --- a/mobile/Verify/Verify/Features/Landing/LandingViewModel.swift +++ b/mobile/Verify/Verify/Features/Landing/LandingViewModel.swift @@ -181,9 +181,7 @@ extension LandingViewModel { try deleteOperation().execute(db) } } catch { - logger.warning("Failed to forget clusters", metadata: [ - "error": .string(String(describing: error)), - ]) + logger.warning("Failed to forget clusters", error: error) destination = .notice(AlertState( title: { TextState("Could Not Forget Clusters") diff --git a/mobile/Verify/Verify/Info.plist b/mobile/Verify/Verify/Info.plist index 9e0f895cb2876..fa817fb92277d 100644 --- a/mobile/Verify/Verify/Info.plist +++ b/mobile/Verify/Verify/Info.plist @@ -2,10 +2,6 @@ - NSCameraUsageDescription - Teleport Verify uses the built-in QR code scanner to receive enrollment data from the Teleport Web UI - NSFaceIDUsageDescription - Teleport Verify uses Face ID to confirm your presence when proving this device's identity CFBundleURLTypes diff --git a/mobile/Verify/Verify/Persistence/SQLite/AppDatabase.swift b/mobile/Verify/Verify/Persistence/SQLite/AppDatabase.swift index 9f8ebf3ad96f7..e3ebf0c8cd880 100644 --- a/mobile/Verify/Verify/Persistence/SQLite/AppDatabase.swift +++ b/mobile/Verify/Verify/Persistence/SQLite/AppDatabase.swift @@ -87,9 +87,7 @@ extension AppDatabase { #if DEBUG fatalError("Database initialization failed: \(error)") #else - logger.critical("Database initialization failed; falling back to in-memory database", metadata: [ - "error": .string(String(describing: error)), - ]) + logger.critical("Database initialization failed; falling back to in-memory database", error: error) database = makeInMemoryDatabase() #endif }