Skip to content

Commit 97f4639

Browse files
feat(logging): support custom directories and flushing
Coordinate log file writes and rotation across processes. Downgrade Alamofire resolution to 5.12.0. Signed-off-by: Marino Faggiana <marino.faggiana@nextcloud.com>
1 parent 2ecd4ed commit 97f4639

3 files changed

Lines changed: 81 additions & 42 deletions

File tree

‎Package.resolved‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Sources/NextcloudKit/Log/NKLogFileManager.swift‎

Lines changed: 69 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,10 @@ public final class NKLogFileManager: @unchecked Sendable {
6464

6565
/// Configures the shared logger instance.
6666
/// - Parameters:
67-
/// - minLevel: The minimum log level to be recorded.
68-
public static func configure(logLevel: NKLogLevel = .normal) {
69-
shared.setConfiguration(logLevel: logLevel)
67+
/// - logLevel: The minimum log level to be recorded.
68+
/// - logDirectory: The directory that contains the log files. Defaults to `Documents/Logs`.
69+
public static func configure(logLevel: NKLogLevel = .normal, logDirectory: URL? = nil) {
70+
shared.setConfiguration(logLevel: logLevel, logDirectory: logDirectory)
7071
}
7172

7273
/// Configures filter.
@@ -83,7 +84,7 @@ public final class NKLogFileManager: @unchecked Sendable {
8384
shared.setWhitelist(whitelist: whitelist)
8485
}
8586

86-
/// Creates the "Logs" folder inside the user's Documents directory if it does not already exist.
87+
/// Creates the configured log directory if it does not already exist.
8788
///
8889
/// This static method delegates to the singleton instance (`shared`) and ensures
8990
/// that the log folder structure is created or re-created when needed.
@@ -92,8 +93,7 @@ public final class NKLogFileManager: @unchecked Sendable {
9293
/// (e.g., by iTunes File Sharing, iCloud Drive sync conflicts, or cleanup tools),
9394
/// and must be re-initialized manually.
9495
///
95-
/// The folder path is:
96-
/// `~/Documents/Logs`
96+
/// Unless a custom directory was configured, the folder path is `~/Documents/Logs`.
9797
///
9898
/// If the folder already exists, the method does nothing. If creation fails, the error is silently ignored.
9999
///
@@ -102,6 +102,14 @@ public final class NKLogFileManager: @unchecked Sendable {
102102
shared.createLogsFolder()
103103
}
104104

105+
/// Waits until all pending file writes have completed.
106+
///
107+
/// Extensions can call this before returning control to the system to prevent their final
108+
/// messages from being lost if the extension process terminates immediately afterward.
109+
public static func flush() {
110+
shared.flush()
111+
}
112+
105113
/// Returns the file URL of the currently active log file.
106114
public func currentLogFileURL() -> URL {
107115
return logDirectory.appendingPathComponent(logFileName)
@@ -110,7 +118,7 @@ public final class NKLogFileManager: @unchecked Sendable {
110118
// MARK: - Configuration
111119

112120
private let logFileName = "log.txt"
113-
private let logDirectory: URL
121+
private var logDirectory: URL
114122
public var logLevel: NKLogLevel
115123
private var blacklist: [String] = []
116124
private var whitelist: [String] = []
@@ -125,7 +133,7 @@ public final class NKLogFileManager: @unchecked Sendable {
125133

126134
// MARK: - Initialization
127135

128-
private init(logLevel: NKLogLevel = .normal, blacklist: [String]? = nil, whitelist: [String]? = nil) {
136+
private init(logLevel: NKLogLevel = .normal, logDirectory: URL? = nil, blacklist: [String]? = nil, whitelist: [String]? = nil) {
129137
self.logLevel = logLevel
130138
if let blacklist {
131139
self.blacklist = blacklist
@@ -134,31 +142,29 @@ public final class NKLogFileManager: @unchecked Sendable {
134142
self.whitelist = whitelist
135143
}
136144

137-
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
138-
let logsFolder = documents.appendingPathComponent("Logs", isDirectory: true)
145+
let logsFolder = logDirectory ?? Self.defaultLogDirectory()
139146
if !FileManager.default.fileExists(atPath: logsFolder.path) {
140147
try? FileManager.default.createDirectory(at: logsFolder, withIntermediateDirectories: true)
141148
}
142149
self.logDirectory = logsFolder
143150
self.currentLogDate = Self.currentDateString()
144151
}
145152

146-
/// Creates the "Logs" folder inside the user's Documents directory if it does not already exist.
153+
/// Creates the configured log directory if it does not already exist.
147154
///
148155
/// This method performs the following steps:
149-
/// - Retrieves the path to the `.documentDirectory` using `FileManager`.
150-
/// - Appends a "Logs" subdirectory path.
156+
/// - Uses the custom directory supplied during configuration, when available.
157+
/// - Otherwise uses the "Logs" subdirectory of `.documentDirectory`.
151158
/// - Checks if the folder already exists.
152159
/// - If not, it creates the folder, including any intermediate directories.
153-
/// - Finally, it sets the `logDirectory` and initializes the current log date.
160+
/// - Initializes the current log date after ensuring the directory exists.
154161
///
155162
/// If folder creation fails, the method silently ignores the error.
156163
///
157164
/// - Note: The `logDirectory` property will point to the created `Logs` folder.
158165
///
159166
private func createLogsFolder() {
160-
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
161-
let logsFolder = documents.appendingPathComponent("Logs", isDirectory: true)
167+
let logsFolder = logDirectory
162168
if !FileManager.default.fileExists(atPath: logsFolder.path) {
163169
try? FileManager.default.createDirectory(at: logsFolder, withIntermediateDirectories: true)
164170
}
@@ -168,8 +174,16 @@ public final class NKLogFileManager: @unchecked Sendable {
168174
/// Sets configuration parameters for the logger.
169175
/// - Parameters:
170176
/// - logLevel: The NKLogLevel { disabled .. verbose }
171-
private func setConfiguration(logLevel: NKLogLevel) {
177+
private func setConfiguration(logLevel: NKLogLevel, logDirectory: URL?) {
178+
flush()
172179
self.logLevel = logLevel
180+
self.logDirectory = logDirectory ?? Self.defaultLogDirectory()
181+
createLogsFolder()
182+
}
183+
184+
private static func defaultLogDirectory() -> URL {
185+
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
186+
return documents.appendingPathComponent("Logs", isDirectory: true)
173187
}
174188

175189
/// Sets blacklist for the logger.
@@ -337,17 +351,27 @@ public final class NKLogFileManager: @unchecked Sendable {
337351
private func rotateLog(for date: String) {
338352
let currentPath = logDirectory.appendingPathComponent(logFileName)
339353
let rotatedPath = logDirectory.appendingPathComponent("log-\(date).txt")
340-
341-
do {
342-
if fileManager.fileExists(atPath: currentPath.path) {
343-
try fileManager.moveItem(at: currentPath, to: rotatedPath)
354+
var coordinationError: NSError?
355+
let coordinator = NSFileCoordinator(filePresenter: nil)
356+
coordinator.coordinate(
357+
writingItemAt: currentPath,
358+
options: .forMoving,
359+
writingItemAt: rotatedPath,
360+
options: .forReplacing,
361+
error: &coordinationError
362+
) { coordinatedCurrentPath, coordinatedRotatedPath in
363+
do {
364+
if fileManager.fileExists(atPath: coordinatedCurrentPath.path),
365+
!fileManager.fileExists(atPath: coordinatedRotatedPath.path) {
366+
try fileManager.moveItem(at: coordinatedCurrentPath, to: coordinatedRotatedPath)
367+
}
368+
369+
if !fileManager.fileExists(atPath: coordinatedCurrentPath.path) {
370+
try Data().write(to: coordinatedCurrentPath)
371+
}
372+
} catch {
373+
print("Log rotation failed: \(error)")
344374
}
345-
346-
// Create a new empty log file for today
347-
try Data().write(to: currentPath)
348-
349-
} catch {
350-
print("Log rotation failed: \(error)")
351375
}
352376
}
353377

@@ -363,22 +387,30 @@ public final class NKLogFileManager: @unchecked Sendable {
363387

364388
guard let data = message.data(using: .utf8) else { return }
365389

366-
do {
367-
if !fileManager.fileExists(atPath: logPath.path) {
368-
fileManager.createFile(atPath: logPath.path, contents: nil)
369-
}
390+
var coordinationError: NSError?
391+
let coordinator = NSFileCoordinator(filePresenter: nil)
392+
coordinator.coordinate(writingItemAt: logPath, options: .forMerging, error: &coordinationError) { coordinatedURL in
393+
do {
394+
if !fileManager.fileExists(atPath: coordinatedURL.path) {
395+
fileManager.createFile(atPath: coordinatedURL.path, contents: nil)
396+
}
370397

371-
let handle = try FileHandle(forWritingTo: logPath)
398+
let handle = try FileHandle(forWritingTo: coordinatedURL)
372399

373-
defer { try? handle.close() }
400+
defer { try? handle.close() }
374401

375-
try handle.seekToEnd()
376-
try handle.write(contentsOf: data)
377-
} catch {
378-
// Ignore log write failures to avoid crashes when the device has no free space.
402+
try handle.seekToEnd()
403+
try handle.write(contentsOf: data)
404+
} catch {
405+
// Ignore log write failures to avoid crashes when the device has no free space.
406+
}
379407
}
380408
}
381409

410+
private func flush() {
411+
logQueue.sync {}
412+
}
413+
382414
// MARK: - Cached DateFormatters
383415

384416
/// Cached formatter for "yyyy-MM-dd". Uses current calendar, locale, and time zone.

‎Sources/NextcloudKit/NextcloudKit+Logging.swift‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@
22
// SPDX-FileCopyrightText: 2025 Marino Faggiana
33
// SPDX-License-Identifier: GPL-3.0-or-later
44

5+
import Foundation
6+
57
public extension NextcloudKit {
68
/// Shared logger accessible via NextcloudKit.logger
79
static var logger: NKLogFileManager {
810
return NKLogFileManager.shared
911
}
1012

11-
/// Configure the shared logger from NextcloudKit
12-
static func configureLogger(logLevel: NKLogLevel = .normal) {
13-
NKLogFileManager.configure(logLevel: logLevel)
13+
/// Configures the shared logger and optionally stores logs in a custom directory.
14+
static func configureLogger(logLevel: NKLogLevel = .normal, logDirectory: URL? = nil) {
15+
NKLogFileManager.configure(logLevel: logLevel, logDirectory: logDirectory)
1416
}
1517

1618
/// Configure the shared logger blacklist from NextcloudKit
@@ -22,4 +24,9 @@ public extension NextcloudKit {
2224
static func configureLoggerWhitelist(whitelist: [String]) {
2325
NKLogFileManager.setCandidate(whitelist: whitelist)
2426
}
27+
28+
/// Waits until all pending log file writes have completed.
29+
static func flushLogger() {
30+
NKLogFileManager.flush()
31+
}
2532
}

0 commit comments

Comments
 (0)