diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift index f5c5e43734b..9c823bbeea3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift @@ -23,22 +23,62 @@ public enum LoggingPreset: String { public enum LoggingPreferences { private static let defaultsKey = "SwiftSDKLogLevel" + /// Sessions beyond this count are deleted at startup, oldest + /// first. File logging runs on devices too (beta diagnostics), + /// so growth must stay bounded without anyone thinking about it. + private static let maxRetainedSessions = 20 + + /// Total-bytes quota across retained sessions. The Rust side + /// appends without rotation, so individual sessions can be + /// arbitrarily large — a count cap alone doesn't bound disk + /// use. Sessions are kept newest-first until the quota is hit; + /// the current session is always kept (its in-flight growth + /// can only be bounded by rotation on the Rust side). + private static let maxRetainedBytes: UInt64 = 100 * 1024 * 1024 + + /// Root under which each launch creates one timestamped session + /// directory of per-crate `run.log` files. Exposed so diagnostics + /// features (log export) can enumerate sessions without + /// re-deriving the layout. + public static var logsRootDirectory: URL? { + FileManager.default + .urls(for: .libraryDirectory, in: .userDomainMask).first? + .appendingPathComponent("Logs", isDirectory: true) + .appendingPathComponent("SwiftDashSDK", isDirectory: true) + } + + /// Session directory of the current launch, set once `configure()` + /// installs the file subscriber. `nil` when file logging is off + /// (subscriber already installed, or the path wasn't writable). + @MainActor + public private(set) static var currentSessionDirectory: URL? + + /// The tracing subscriber is process-global and first-init-wins, + /// so the install must run at most once per process. Without + /// this guard, every `configure()` call after the first (e.g. + /// a bootstrap retry) would make the Rust initializer lay out a + /// fresh session directory of empty log files before `try_init` + /// discovers the existing subscriber and bails — leaving decoy + /// "newest" sessions that logging never writes to. + @MainActor + private static var didInstallLogging = false + @discardableResult @MainActor public static func configure() -> LoggingPreset { let preset = loadPreset() let enableSwiftVerbose: Bool - // File logging is gated to the iOS Simulator - #if targetEnvironment(simulator) - if let sessionRoot = launchLogPaths(), - SDK.enableFileLogging(level: .info, sessionRoot: sessionRoot) { - } else { - SDK.enableLogging(level: .info) + if !didInstallLogging { + didInstallLogging = true + if let sessionRoot = launchLogPaths(), + SDK.enableFileLogging(level: .info, sessionRoot: sessionRoot.path) { + currentSessionDirectory = sessionRoot + pruneOldSessions(keeping: sessionRoot) + } else { + SDK.enableLogging(level: .info) + } } - #else - SDK.enableLogging(level: .info) - #endif switch preset { case .high: @@ -54,24 +94,79 @@ public enum LoggingPreferences { return preset } - private static func launchLogPaths() -> String? { - guard - let libraryURL = FileManager.default - .urls(for: .libraryDirectory, in: .userDomainMask).first - else { - return nil - } + private static func launchLogPaths() -> URL? { + guard let root = logsRootDirectory else { return nil } let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone(secondsFromGMT: 0) formatter.dateFormat = "yyyy-MM-dd'T'HH-mm-ss'Z'" - return libraryURL - .appendingPathComponent("Logs", isDirectory: true) - .appendingPathComponent("SwiftDashSDK", isDirectory: true) + return root .appendingPathComponent(formatter.string(from: Date()), isDirectory: true) - .path + } + + /// Delete session directories past either retention bound — + /// `maxRetainedSessions` count or `maxRetainedBytes` total — + /// walking newest-first so what survives is always the most + /// recent history. The current session is always kept + /// regardless of where its stamp sorts or how big it is. + /// Runs detached — deleting multi-megabyte directories has no + /// business on the main actor during launch. + private static func pruneOldSessions(keeping current: URL) { + guard let root = logsRootDirectory else { return } + Task.detached(priority: .utility) { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return } + + // Session stamps are fixed-width UTC ISO timestamps, so + // lexicographic order is chronological order. + let sessions = entries + .filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true } + .sorted { $0.lastPathComponent > $1.lastPathComponent } + + let currentName = current.lastPathComponent + var keptCount = 0 + var keptBytes: UInt64 = 0 + for session in sessions { + if session.lastPathComponent == currentName { + // Counted against the byte quota so a huge + // just-finished-syncing session pushes old + // history out, but never deleted itself. + keptCount += 1 + keptBytes += directoryBytes(of: session) + continue + } + let bytes = directoryBytes(of: session) + if keptCount >= maxRetainedSessions + || keptBytes + bytes > maxRetainedBytes { + try? fm.removeItem(at: session) + } else { + keptCount += 1 + keptBytes += bytes + } + } + } + } + + private static func directoryBytes(of directory: URL) -> UInt64 { + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .fileSizeKey] + ) else { return 0 } + + var total: UInt64 = 0 + for case let file as URL in enumerator { + let values = try? file.resourceValues( + forKeys: [.totalFileAllocatedSizeKey, .fileSizeKey] + ) + total += UInt64(values?.totalFileAllocatedSize ?? values?.fileSize ?? 0) + } + return total } public static var preset: LoggingPreset { loadPreset() } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/LogExporter.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/LogExporter.swift new file mode 100644 index 00000000000..ba645c0a5d0 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/LogExporter.swift @@ -0,0 +1,268 @@ +import Foundation +import SwiftDashSDK + +enum LogExportError: LocalizedError { + case noLogsFound + case zipFailed(String) + + var errorDescription: String? { + switch self { + case .noLogsFound: + return "No SDK log sessions found on this device. " + + "Logs are written from the next launch after installing " + + "a build with file logging enabled." + case .zipFailed(let reason): + return "Could not create the log archive: \(reason)" + } + } +} + +/// Bundles recent SwiftDashSDK log sessions into a shareable zip. +/// +/// Each app launch writes one session directory of per-crate +/// `run.log` files under `Library/Logs/SwiftDashSDK//` +/// (see `LoggingPreferences`). This exporter stages the launch's own +/// session (passed in by the caller — never inferred from timestamp +/// order, which a clock rollback or stale future-dated directory +/// could subvert) plus the newest few before it, adds a +/// `summary.txt` of build/device context, then zips the staging +/// directory. Nothing is uploaded — the caller hands the returned +/// file to a share sheet and the user decides where it goes. +struct LogExporter { + /// Current run's session + up to two before it. After a crash + /// the run that crashed is usually the session immediately + /// before the current one, so this window covers "it just + /// crashed, send logs" without any .ips parsing. + static let maxSessions = 3 + + /// Older sessions stop being added once the archive's raw input + /// would pass this. The first selected session (the current one + /// when known) is always included even if it alone is bigger. + static let maxTotalBytes: UInt64 = 15 * 1024 * 1024 + + struct SessionCandidate: Equatable { + let url: URL + let bytes: UInt64 + } + + /// Blocking (file I/O + compression) — call off the main actor. + /// + /// - Parameters: + /// - network: display string for `summary.txt`, captured by + /// the caller on the main actor. + /// - appVersion: ditto. + /// - currentSession: `LoggingPreferences.currentSessionDirectory`, + /// captured by the caller on the main actor. `nil` when file + /// logging didn't install this launch — the export then falls + /// back to newest-on-disk ordering and says so in the summary. + static func export( + network: String, + appVersion: String, + currentSession: URL? + ) throws -> URL { + let fm = FileManager.default + + guard let root = LoggingPreferences.logsRootDirectory, + let entries = try? fm.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + else { + throw LogExportError.noLogsFound + } + + let onDisk = entries.filter { + (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true + } + + let currentOnDisk = currentSession.flatMap { session in + onDisk.first { $0.lastPathComponent == session.lastPathComponent } + } + let current = currentOnDisk.map { + SessionCandidate(url: $0, bytes: directorySize(of: $0)) + } + let others = onDisk + .filter { $0.lastPathComponent != currentOnDisk?.lastPathComponent } + .map { SessionCandidate(url: $0, bytes: directorySize(of: $0)) } + + let selected = selectSessions(current: current, others: others) + guard !selected.isEmpty else { + throw LogExportError.noLogsFound + } + + let stamp = selected[0].url.lastPathComponent + let archiveName = "SwiftDashSDK-logs-\(stamp)" + let scratch = fm.temporaryDirectory + .appendingPathComponent("LogExport-\(UUID().uuidString)", isDirectory: true) + let staging = scratch.appendingPathComponent(archiveName, isDirectory: true) + defer { try? fm.removeItem(at: scratch) } + + try fm.createDirectory(at: staging, withIntermediateDirectories: true) + for session in selected { + try fm.copyItem( + at: session.url, + to: staging.appendingPathComponent(session.url.lastPathComponent, isDirectory: true) + ) + } + + let summary = summaryText( + network: network, + appVersion: appVersion, + selected: selected, + currentIsKnown: current != nil, + totalSessionsOnDevice: onDisk.count + ) + try summary.write( + to: staging.appendingPathComponent("summary.txt"), + atomically: true, + encoding: .utf8 + ) + + let zipURL = fm.temporaryDirectory.appendingPathComponent("\(archiveName).zip") + if fm.fileExists(atPath: zipURL.path) { + try? fm.removeItem(at: zipURL) + } + try zipDirectory(at: staging, to: zipURL) + return zipURL + } + + /// Pure selection policy, split out for unit testing. + /// + /// The current session (when known) is always first and always + /// included, no matter how its timestamp sorts against the rest + /// — it's the authoritative record of this run, not a candidate. + /// Older sessions then fill the remaining slots newest-first + /// until either cap is hit. The walk stops at the first session + /// that would breach the byte cap rather than skipping past it: + /// a gap in the middle of "the last three runs" would be more + /// confusing than a shorter archive. + static func selectSessions( + current: SessionCandidate?, + others: [SessionCandidate], + maxSessions: Int = LogExporter.maxSessions, + maxTotalBytes: UInt64 = LogExporter.maxTotalBytes + ) -> [SessionCandidate] { + var selected: [SessionCandidate] = [] + var totalBytes: UInt64 = 0 + + if let current { + selected.append(current) + totalBytes = current.bytes + } + + // Fixed-width UTC stamps: lexicographic == chronological. + let newestFirst = others.sorted { + $0.url.lastPathComponent > $1.url.lastPathComponent + } + for candidate in newestFirst { + guard selected.count < maxSessions else { break } + if !selected.isEmpty && totalBytes + candidate.bytes > maxTotalBytes { break } + selected.append(candidate) + totalBytes += candidate.bytes + } + return selected + } + + /// Zip without third-party dependencies: a coordinated read with + /// `.forUploading` makes the system produce a zip of a directory + /// in a temporary location that is only valid inside the + /// accessor, so the copy to `destination` happens in the block. + private static func zipDirectory(at source: URL, to destination: URL) throws { + var coordinatorError: NSError? + var copyError: Error? + NSFileCoordinator().coordinate( + readingItemAt: source, + options: .forUploading, + error: &coordinatorError + ) { zippedURL in + do { + try FileManager.default.copyItem(at: zippedURL, to: destination) + } catch { + copyError = error + } + } + if let coordinatorError { + throw LogExportError.zipFailed(coordinatorError.localizedDescription) + } + if let copyError { + throw LogExportError.zipFailed(copyError.localizedDescription) + } + } + + private static func directorySize(of directory: URL) -> UInt64 { + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .fileSizeKey] + ) else { return 0 } + + var total: UInt64 = 0 + for case let file as URL in enumerator { + let values = try? file.resourceValues( + forKeys: [.totalFileAllocatedSizeKey, .fileSizeKey] + ) + total += UInt64(values?.totalFileAllocatedSize ?? values?.fileSize ?? 0) + } + return total + } + + private static func summaryText( + network: String, + appVersion: String, + selected: [SessionCandidate], + currentIsKnown: Bool, + totalSessionsOnDevice: Int + ) -> String { + let iso = ISO8601DateFormatter() + let sizeFormatter = ByteCountFormatter() + sizeFormatter.countStyle = .file + + var machine = utsname() + uname(&machine) + let model = withUnsafeBytes(of: &machine.machine) { raw in + String(decoding: raw.prefix(while: { $0 != 0 }), as: UTF8.self) + } + #if targetEnvironment(simulator) + let environment = "simulator" + #else + let environment = "device" + #endif + + var lines: [String] = [ + "SwiftDashSDK diagnostic log export", + "Generated: \(iso.string(from: Date()))", + "", + "App version: \(appVersion)", + "OS: \(ProcessInfo.processInfo.operatingSystemVersionString)", + "Hardware: \(model) (\(environment))", + "Network: \(network)", + "", + "Each session directory's build_info.txt records the exact", + "platform-wallet git commit that run was built from.", + "", + "Sessions included (one directory per app launch):", + ] + for (index, session) in selected.enumerated() { + let role: String + switch (index, currentIsKnown) { + case (0, true): role = "current session" + case (0, false): + role = "newest session on disk (current session unknown — " + + "file logging was not active this launch)" + case (1, _): role = "previous session — after a crash, the crashed run is usually this one" + default: role = "older session" + } + lines.append( + " \(session.url.lastPathComponent) " + + "[\(sizeFormatter.string(fromByteCount: Int64(session.bytes)))] — \(role)" + ) + } + lines.append("") + lines.append( + "Included \(selected.count) of \(totalSessionsOnDevice) session(s) on this " + + "\(environment) (limit: \(maxSessions) sessions / " + + "\(sizeFormatter.string(fromByteCount: Int64(maxTotalBytes))) raw)." + ) + return lines.joined(separator: "\n") + "\n" + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index ca850be1b15..fcbe452aaa3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -10,6 +10,9 @@ struct OptionsView: View { @State private var showingDataManagement = false @State private var showingAbout = false @State private var isSwitchingNetwork = false + @State private var isExportingLogs = false + @State private var exportedLogs: ExportedLogsArchive? + @State private var logExportError: String? @State private var sdkStatus: SDKStatus? @State private var isLoadingStatus = false @@ -445,6 +448,24 @@ struct OptionsView: View { } } + Section("Diagnostics") { + Button(action: exportLogs) { + HStack { + Label("Export Logs", systemImage: "square.and.arrow.up") + Spacer() + if isExportingLogs { + ProgressView() + .scaleEffect(0.8) + } + } + } + .disabled(isExportingLogs) + + Text("Bundles the SDK logs from this launch and the two before it (a crashed run is usually the previous session) into a zip you can AirDrop, mail, or save to Files.") + .font(.caption) + .foregroundColor(.secondary) + } + Section(header: Text("Platform")) { // Contracts moved here from its own root tab. Pushed // without its own NavigationStack so it attaches to @@ -555,6 +576,56 @@ struct OptionsView: View { .sheet(isPresented: $showingAbout) { AboutView() } + .sheet(item: $exportedLogs) { archive in + ShareSheet(items: [archive.url]) + } + .alert( + "Log Export Failed", + isPresented: Binding( + get: { logExportError != nil }, + set: { if !$0 { logExportError = nil } } + ) + ) { + Button("OK", role: .cancel) { logExportError = nil } + } message: { + Text(logExportError ?? "") + } + } + } + + /// Zip the most recent SDK log sessions and hand the archive to a + /// share sheet. The staging + compression is file I/O, so it runs + /// detached; only the resulting URL (or error) comes back to the + /// main actor. + private func exportLogs() { + isExportingLogs = true + let network = appState.currentNetwork.displayName + let bundle = Bundle.main + let short = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" + let build = bundle.infoDictionary?["CFBundleVersion"] as? String ?? "?" + let appVersion = "\(short) (\(build))" + // Authoritative record of which directory this run is + // writing to — timestamp sorting alone can be fooled by a + // clock rollback or a stale future-dated directory. + let currentSession = LoggingPreferences.currentSessionDirectory + + Task.detached(priority: .userInitiated) { + let result: Result = Result { + try LogExporter.export( + network: network, + appVersion: appVersion, + currentSession: currentSession + ) + } + await MainActor.run { + isExportingLogs = false + switch result { + case .success(let url): + exportedLogs = ExportedLogsArchive(url: url) + case .failure(let error): + logExportError = error.localizedDescription + } + } } } @@ -672,6 +743,14 @@ struct OptionsView: View { } } +/// `Identifiable` wrapper so the exported zip can drive +/// `.sheet(item:)` — presenting keyed on the value (not a separate +/// bool) means the sheet can never race ahead of the URL being set. +private struct ExportedLogsArchive: Identifiable { + let url: URL + var id: URL { url } +} + struct DataManagementView: View { @EnvironmentObject var appState: AppState @Environment(\.dismiss) var dismiss diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/LogExporterSelectionTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/LogExporterSelectionTests.swift new file mode 100644 index 00000000000..bf01a439289 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/LogExporterSelectionTests.swift @@ -0,0 +1,125 @@ +import XCTest +@testable import SwiftExampleApp + +/// Behavioral tests for `LogExporter.selectSessions` — the pure +/// policy deciding which log-session directories go into a +/// diagnostic export. +/// +/// The invariants under test: +/// - the caller-provided current session is authoritative: always +/// first and always included, regardless of how its timestamp +/// sorts against other directories (clock rollbacks, stale +/// future-dated dirs) and regardless of the byte cap; +/// - older sessions fill remaining slots newest-first, subject to +/// the session-count cap and the total-bytes cap; +/// - the walk stops at the first over-budget session instead of +/// skipping past it, so the archive is always a contiguous run +/// of the most recent history; +/// - with no known current session (file logging didn't install), +/// the newest directory on disk still gets exported. +final class LogExporterSelectionTests: XCTestCase { + private func candidate(_ stamp: String, bytes: UInt64) -> LogExporter.SessionCandidate { + LogExporter.SessionCandidate( + url: URL(fileURLWithPath: "/logs/\(stamp)", isDirectory: true), + bytes: bytes + ) + } + + func testCurrentSessionIsFirstEvenWhenOlderStampedThanOthers() { + // A stale future-dated directory sorts lexicographically + // after the real current session. It must not displace it. + let current = candidate("2026-07-15T10-00-00Z", bytes: 100) + let futureStale = candidate("2027-01-01T00-00-00Z", bytes: 100) + let previous = candidate("2026-07-14T09-00-00Z", bytes: 100) + + let selected = LogExporter.selectSessions( + current: current, + others: [previous, futureStale] + ) + + XCTAssertEqual(selected.first, current) + XCTAssertEqual(selected.count, 3) + // Remaining slots are newest-first among the others. + XCTAssertEqual(selected[1], futureStale) + XCTAssertEqual(selected[2], previous) + } + + func testSessionCountCap() { + let current = candidate("2026-07-15T10-00-00Z", bytes: 1) + let others = (1...5).map { + candidate("2026-07-10T0\($0)-00-00Z", bytes: 1) + } + + let selected = LogExporter.selectSessions(current: current, others: others) + + XCTAssertEqual(selected.count, LogExporter.maxSessions) + XCTAssertEqual(selected.first, current) + // Newest two of the others. + XCTAssertEqual( + selected.dropFirst().map { $0.url.lastPathComponent }, + ["2026-07-10T05-00-00Z", "2026-07-10T04-00-00Z"] + ) + } + + func testOversizedCurrentSessionIsStillIncluded() { + let huge = candidate("2026-07-15T10-00-00Z", bytes: 500) + let previous = candidate("2026-07-14T09-00-00Z", bytes: 10) + + let selected = LogExporter.selectSessions( + current: huge, + others: [previous], + maxTotalBytes: 100 + ) + + // The current session is the point of the export; the cap + // only stops *additional* sessions from being added. + XCTAssertEqual(selected, [huge]) + } + + func testByteCapStopsAtFirstOversizedOlderSession() { + let current = candidate("2026-07-15T10-00-00Z", bytes: 40) + let bigPrevious = candidate("2026-07-14T09-00-00Z", bytes: 90) + let smallOlder = candidate("2026-07-13T08-00-00Z", bytes: 5) + + let selected = LogExporter.selectSessions( + current: current, + others: [smallOlder, bigPrevious], + maxTotalBytes: 100 + ) + + // bigPrevious breaches the cap; smallOlder must NOT be + // pulled in past it — a gap in the middle of the history + // would be more confusing than a shorter archive. + XCTAssertEqual(selected, [current]) + } + + func testUnknownCurrentFallsBackToNewestOnDisk() { + let newest = candidate("2026-07-15T10-00-00Z", bytes: 500) + let older = candidate("2026-07-14T09-00-00Z", bytes: 10) + + let selected = LogExporter.selectSessions( + current: nil, + others: [older, newest], + maxTotalBytes: 100 + ) + + // Even over-budget, the newest session is always included + // so an export is never empty when logs exist. + XCTAssertEqual(selected, [newest]) + } + + func testUnknownCurrentSelectsNewestFirstUnderCaps() { + let a = candidate("2026-07-15T10-00-00Z", bytes: 10) + let b = candidate("2026-07-14T09-00-00Z", bytes: 10) + let c = candidate("2026-07-13T08-00-00Z", bytes: 10) + let d = candidate("2026-07-12T07-00-00Z", bytes: 10) + + let selected = LogExporter.selectSessions(current: nil, others: [d, b, a, c]) + + XCTAssertEqual(selected, [a, b, c]) + } + + func testNoSessionsSelectsNothing() { + XCTAssertEqual(LogExporter.selectSessions(current: nil, others: []), []) + } +}