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
}