@@ -6,34 +6,47 @@ enum AppLogLevel: String {
66 case error = " ERROR "
77}
88
9- /// File-based logger for user-facing diagnostics. Writes to
10- /// `~/Library/Logs/VPNMenuBar/vpnmenubar.log` and also mirrors each line to
11- /// `NSLog` so it remains visible in Console.app. Callers are responsible for
12- /// redacting secrets before logging — this class does not inspect payloads.
9+ /// File-based logger for user-facing diagnostics. Writes per-day log files
10+ /// `~/Library/Logs/VPNMenuBar/vpnmenubar-YYYY-MM-DD.log` and mirrors each line
11+ /// to `NSLog` for Console.app. Files older than `retentionDays` are purged at
12+ /// launch. Callers are responsible for redacting secrets — this class does not
13+ /// inspect payloads.
1314final class AppLogger {
1415 static let shared = AppLogger ( )
1516
1617 let logDirectory : URL
17- let logFileURL : URL
1818
1919 private let queue = DispatchQueue ( label: " com.example.vpnmenubar.applogger " , qos: . utility)
20- private let formatter : DateFormatter
21- private let maxBytes : Int = 1_000_000
20+ private let timestampFormatter : DateFormatter
21+ private let dateFormatter : DateFormatter
22+ private let retentionDays : Int = 14
23+ private let filePrefix = " vpnmenubar- "
24+ private let fileSuffix = " .log "
2225
2326 private init ( ) {
2427 let libraryLogs = FileManager . default. urls ( for: . libraryDirectory, in: . userDomainMask)
2528 . first!
2629 . appendingPathComponent ( " Logs/VPNMenuBar " , isDirectory: true )
2730 self . logDirectory = libraryLogs
28- self . logFileURL = libraryLogs. appendingPathComponent ( " vpnmenubar.log " )
2931
30- let f = DateFormatter ( )
31- f. locale = Locale ( identifier: " en_US_POSIX " )
32- f. dateFormat = " yyyy-MM-dd HH:mm:ss.SSS "
33- self . formatter = f
32+ let ts = DateFormatter ( )
33+ ts. locale = Locale ( identifier: " en_US_POSIX " )
34+ ts. dateFormat = " yyyy-MM-dd HH:mm:ss.SSS "
35+ self . timestampFormatter = ts
36+
37+ let day = DateFormatter ( )
38+ day. locale = Locale ( identifier: " en_US_POSIX " )
39+ day. dateFormat = " yyyy-MM-dd "
40+ self . dateFormatter = day
3441
3542 try ? FileManager . default. createDirectory ( at: libraryLogs, withIntermediateDirectories: true )
36- rotateIfNeeded ( )
43+ purgeOldLogs ( )
44+ }
45+
46+ /// URL of today's log file. Re-computed each call so consumers (menu /
47+ /// Settings "Reveal in Finder") always land on the current-day file.
48+ var logFileURL : URL {
49+ logDirectory. appendingPathComponent ( " \( filePrefix) \( dateFormatter. string ( from: Date ( ) ) ) \( fileSuffix) " )
3750 }
3851
3952 func info( _ message: String , file: String = #fileID, line: Int = #line) {
@@ -49,39 +62,51 @@ final class AppLogger {
4962 }
5063
5164 private func log( _ level: AppLogLevel , _ message: String , file: String , line: Int ) {
52- let ts = formatter. string ( from: Date ( ) )
65+ let now = Date ( )
66+ let ts = timestampFormatter. string ( from: now)
5367 let short = file. split ( separator: " / " ) . last. map ( String . init) ?? file
5468 let entry = " \( ts) [ \( level. rawValue) ] \( short) : \( line) \( message) \n "
5569 NSLog ( " VPNMenuBar: [%{public}@] %{public}@ " , level. rawValue, message)
5670 queue. async { [ weak self] in
57- self ? . appendToFile ( entry)
71+ self ? . appendToFile ( entry, at : now )
5872 }
5973 }
6074
61- private func appendToFile( _ entry: String ) {
75+ private func appendToFile( _ entry: String , at date : Date ) {
6276 guard let data = entry. data ( using: . utf8) else { return }
77+ let url = logDirectory. appendingPathComponent ( " \( filePrefix) \( dateFormatter. string ( from: date) ) \( fileSuffix) " )
6378 let fm = FileManager . default
64- if !fm. fileExists ( atPath: logFileURL . path) {
65- try ? data. write ( to: logFileURL )
79+ if !fm. fileExists ( atPath: url . path) {
80+ try ? data. write ( to: url )
6681 return
6782 }
68- guard let handle = try ? FileHandle ( forWritingTo: logFileURL ) else { return }
83+ guard let handle = try ? FileHandle ( forWritingTo: url ) else { return }
6984 defer { try ? handle. close ( ) }
7085 _ = try ? handle. seekToEnd ( )
7186 do { try handle. write ( contentsOf: data) } catch { /* best-effort */ }
7287 }
7388
74- /// Rotate the log when it exceeds `maxBytes `. Keeps a single `.1` archive
75- /// (overwritten each rotation) — this is a personal-use menu-bar app, no
76- /// need for N-generation retention .
77- private func rotateIfNeeded ( ) {
89+ /// Delete per-day log files older than `retentionDays `. Called once at
90+ /// launch — running apps are expected to restart occasionally, and the
91+ /// disk cost of one extra old file in a day is negligible .
92+ private func purgeOldLogs ( ) {
7893 let fm = FileManager . default
79- guard let attrs = try ? fm. attributesOfItem ( atPath: logFileURL. path) ,
80- let size = attrs [ . size] as? Int , size > maxBytes else {
81- return
94+ guard let entries = try ? fm. contentsOfDirectory (
95+ at: logDirectory,
96+ includingPropertiesForKeys: nil ,
97+ options: [ . skipsHiddenFiles]
98+ ) else { return }
99+ let cutoff = Calendar . current. date ( byAdding: . day, value: - retentionDays, to: Date ( ) )
100+ ?? Date ( timeIntervalSinceNow: - Double( retentionDays) * 86_400 )
101+ let cutoffDay = dateFormatter. string ( from: cutoff)
102+ for url in entries {
103+ let name = url. lastPathComponent
104+ guard name. hasPrefix ( filePrefix) , name. hasSuffix ( fileSuffix) else { continue }
105+ let datePart = String ( name. dropFirst ( filePrefix. count) . dropLast ( fileSuffix. count) )
106+ // Lexicographic compare works because dateFormat is yyyy-MM-dd.
107+ if datePart < cutoffDay {
108+ try ? fm. removeItem ( at: url)
109+ }
82110 }
83- let backup = logFileURL. deletingPathExtension ( ) . appendingPathExtension ( " 1.log " )
84- try ? fm. removeItem ( at: backup)
85- try ? fm. moveItem ( at: logFileURL, to: backup)
86111 }
87112}
0 commit comments