Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

106 changes: 69 additions & 37 deletions Sources/NextcloudKit/Log/NKLogFileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,10 @@ public final class NKLogFileManager: @unchecked Sendable {

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

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

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

/// Waits until all pending file writes have completed.
///
/// Extensions can call this before returning control to the system to prevent their final
/// messages from being lost if the extension process terminates immediately afterward.
public static func flush() {
shared.flush()
}

/// Returns the file URL of the currently active log file.
public func currentLogFileURL() -> URL {
return logDirectory.appendingPathComponent(logFileName)
Expand All @@ -110,7 +118,7 @@ public final class NKLogFileManager: @unchecked Sendable {
// MARK: - Configuration

private let logFileName = "log.txt"
private let logDirectory: URL
private var logDirectory: URL
public var logLevel: NKLogLevel
private var blacklist: [String] = []
private var whitelist: [String] = []
Expand All @@ -125,7 +133,7 @@ public final class NKLogFileManager: @unchecked Sendable {

// MARK: - Initialization

private init(logLevel: NKLogLevel = .normal, blacklist: [String]? = nil, whitelist: [String]? = nil) {
private init(logLevel: NKLogLevel = .normal, logDirectory: URL? = nil, blacklist: [String]? = nil, whitelist: [String]? = nil) {
self.logLevel = logLevel
if let blacklist {
self.blacklist = blacklist
Expand All @@ -134,31 +142,29 @@ public final class NKLogFileManager: @unchecked Sendable {
self.whitelist = whitelist
}

let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let logsFolder = documents.appendingPathComponent("Logs", isDirectory: true)
let logsFolder = logDirectory ?? Self.defaultLogDirectory()
if !FileManager.default.fileExists(atPath: logsFolder.path) {
try? FileManager.default.createDirectory(at: logsFolder, withIntermediateDirectories: true)
}
self.logDirectory = logsFolder
self.currentLogDate = Self.currentDateString()
}

/// Creates the "Logs" folder inside the user's Documents directory if it does not already exist.
/// Creates the configured log directory if it does not already exist.
///
/// This method performs the following steps:
/// - Retrieves the path to the `.documentDirectory` using `FileManager`.
/// - Appends a "Logs" subdirectory path.
/// - Uses the custom directory supplied during configuration, when available.
/// - Otherwise uses the "Logs" subdirectory of `.documentDirectory`.
/// - Checks if the folder already exists.
/// - If not, it creates the folder, including any intermediate directories.
/// - Finally, it sets the `logDirectory` and initializes the current log date.
/// - Initializes the current log date after ensuring the directory exists.
///
/// If folder creation fails, the method silently ignores the error.
///
/// - Note: The `logDirectory` property will point to the created `Logs` folder.
///
private func createLogsFolder() {
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let logsFolder = documents.appendingPathComponent("Logs", isDirectory: true)
let logsFolder = logDirectory
if !FileManager.default.fileExists(atPath: logsFolder.path) {
try? FileManager.default.createDirectory(at: logsFolder, withIntermediateDirectories: true)
}
Expand All @@ -168,8 +174,16 @@ public final class NKLogFileManager: @unchecked Sendable {
/// Sets configuration parameters for the logger.
/// - Parameters:
/// - logLevel: The NKLogLevel { disabled .. verbose }
private func setConfiguration(logLevel: NKLogLevel) {
private func setConfiguration(logLevel: NKLogLevel, logDirectory: URL?) {
flush()
self.logLevel = logLevel
self.logDirectory = logDirectory ?? Self.defaultLogDirectory()
createLogsFolder()
}

private static func defaultLogDirectory() -> URL {
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
return documents.appendingPathComponent("Logs", isDirectory: true)
}

/// Sets blacklist for the logger.
Expand Down Expand Up @@ -337,17 +351,27 @@ public final class NKLogFileManager: @unchecked Sendable {
private func rotateLog(for date: String) {
let currentPath = logDirectory.appendingPathComponent(logFileName)
let rotatedPath = logDirectory.appendingPathComponent("log-\(date).txt")

do {
if fileManager.fileExists(atPath: currentPath.path) {
try fileManager.moveItem(at: currentPath, to: rotatedPath)
var coordinationError: NSError?
let coordinator = NSFileCoordinator(filePresenter: nil)
coordinator.coordinate(
writingItemAt: currentPath,
options: .forMoving,
writingItemAt: rotatedPath,
options: .forReplacing,
error: &coordinationError
) { coordinatedCurrentPath, coordinatedRotatedPath in
do {
if fileManager.fileExists(atPath: coordinatedCurrentPath.path),
!fileManager.fileExists(atPath: coordinatedRotatedPath.path) {
try fileManager.moveItem(at: coordinatedCurrentPath, to: coordinatedRotatedPath)
}

if !fileManager.fileExists(atPath: coordinatedCurrentPath.path) {
try Data().write(to: coordinatedCurrentPath)
}
} catch {
print("Log rotation failed: \(error)")
}

// Create a new empty log file for today
try Data().write(to: currentPath)

} catch {
print("Log rotation failed: \(error)")
}
}

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

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

do {
if !fileManager.fileExists(atPath: logPath.path) {
fileManager.createFile(atPath: logPath.path, contents: nil)
}
var coordinationError: NSError?
let coordinator = NSFileCoordinator(filePresenter: nil)
coordinator.coordinate(writingItemAt: logPath, options: .forMerging, error: &coordinationError) { coordinatedURL in
do {
if !fileManager.fileExists(atPath: coordinatedURL.path) {
fileManager.createFile(atPath: coordinatedURL.path, contents: nil)
}

let handle = try FileHandle(forWritingTo: logPath)
let handle = try FileHandle(forWritingTo: coordinatedURL)

defer { try? handle.close() }
defer { try? handle.close() }

try handle.seekToEnd()
try handle.write(contentsOf: data)
} catch {
// Ignore log write failures to avoid crashes when the device has no free space.
try handle.seekToEnd()
try handle.write(contentsOf: data)
} catch {
// Ignore log write failures to avoid crashes when the device has no free space.
}
}
}

private func flush() {
logQueue.sync {}
}

// MARK: - Cached DateFormatters

/// Cached formatter for "yyyy-MM-dd". Uses current calendar, locale, and time zone.
Expand Down
13 changes: 10 additions & 3 deletions Sources/NextcloudKit/NextcloudKit+Logging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@
// SPDX-FileCopyrightText: 2025 Marino Faggiana
// SPDX-License-Identifier: GPL-3.0-or-later

import Foundation

public extension NextcloudKit {
/// Shared logger accessible via NextcloudKit.logger
static var logger: NKLogFileManager {
return NKLogFileManager.shared
}

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

/// Configure the shared logger blacklist from NextcloudKit
Expand All @@ -22,4 +24,9 @@ public extension NextcloudKit {
static func configureLoggerWhitelist(whitelist: [String]) {
NKLogFileManager.setCandidate(whitelist: whitelist)
}

/// Waits until all pending log file writes have completed.
static func flushLogger() {
NKLogFileManager.flush()
}
}
Loading