Ver código fonte

Add poll interval menu and log rotation

hsiang 5 meses atrás
pai
commit
7f1e4beb91

+ 6 - 0
README.md

@@ -77,6 +77,11 @@ Default config:
 ```
 
 `poll_interval_ms` controls how often AirAccept checks Notification Center for a matching AirPlay request.
+You can set it from the menu bar under:
+
+```text
+AirAccept -> Poll Interval -> 2/3/4/5 seconds
+```
 
 ## Logs
 
@@ -87,6 +92,7 @@ AirAccept writes newline-delimited JSON events to:
 ```
 
 Each event includes timestamp, host process, prompt summary, parsed device name, action, and reason. It also writes to Apple unified logging with subsystem `com.airaccept.AirAccept`.
+The log file rotates at about 1 MB and keeps one previous file at `airaccept.log.1`.
 
 ## Launch at Login
 

+ 10 - 0
Sources/AirAccept/AirPlayPromptWatcher.swift

@@ -52,6 +52,16 @@ final class AirPlayPromptWatcher {
         logger.info("watcher stopped")
     }
 
+    func reloadConfiguration() {
+        guard enabled else {
+            return
+        }
+
+        let interval = currentPollInterval()
+        scheduleTimer(interval: interval)
+        logger.info("watcher poll interval updated to \(interval)s")
+    }
+
     func scanOnce() {
         guard enabled, updateTrustedState(prompt: false) else {
             return

+ 44 - 0
Sources/AirAccept/AppDelegate.swift

@@ -8,6 +8,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
     private let logger = AirAcceptLogger()
     private lazy var watcher = AirPlayPromptWatcher(configStore: configStore, logger: logger)
     private var statusItem: NSStatusItem?
+    private let pollIntervalOptions = [2_000, 3_000, 4_000, 5_000]
 
     func applicationDidFinishLaunching(_ notification: Notification) {
         NSApp.setActivationPolicy(.accessory)
@@ -73,6 +74,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
         watcherItem.target = self
         menu.addItem(watcherItem)
 
+        let intervalMenuItem = NSMenuItem(title: "Poll Interval", action: nil, keyEquivalent: "")
+        intervalMenuItem.submenu = makePollIntervalMenu()
+        menu.addItem(intervalMenuItem)
+
         let launchItem = NSMenuItem(
             title: "Launch at Login",
             action: #selector(toggleLaunchAtLogin(_:)),
@@ -117,15 +122,54 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
         return menu
     }
 
+    private func makePollIntervalMenu() -> NSMenu {
+        let menu = NSMenu()
+        let currentInterval = currentConfig().pollIntervalMilliseconds
+
+        for interval in pollIntervalOptions {
+            let seconds = interval / 1_000
+            let item = NSMenuItem(
+                title: "\(seconds) seconds",
+                action: #selector(setPollInterval(_:)),
+                keyEquivalent: ""
+            )
+            item.target = self
+            item.representedObject = interval
+            item.state = currentInterval == interval ? .on : .off
+            menu.addItem(item)
+        }
+
+        return menu
+    }
+
     private func rebuildMenu() {
         statusItem?.menu = makeMenu()
     }
 
+    private func currentConfig() -> AirAcceptConfig {
+        (try? configStore.loadOrCreate()) ?? AirAcceptConfig()
+    }
+
     @objc private func toggleWatcher(_ sender: NSMenuItem) {
         watcher.setEnabled(!watcher.isEnabled)
         rebuildMenu()
     }
 
+    @objc private func setPollInterval(_ sender: NSMenuItem) {
+        guard let interval = sender.representedObject as? Int else {
+            return
+        }
+
+        do {
+            try configStore.save(AirAcceptConfig(pollIntervalMilliseconds: interval))
+            watcher.reloadConfiguration()
+            logger.info("poll interval set to \(Double(interval) / 1_000.0)s")
+        } catch {
+            logger.error("failed to set poll interval: \(error.localizedDescription)")
+        }
+        rebuildMenu()
+    }
+
     @objc private func toggleLaunchAtLogin(_ sender: NSMenuItem) {
         do {
             try LoginItemManager.setEnabled(!LoginItemManager.isEnabled)

+ 10 - 0
Sources/AirAcceptCore/AirAcceptConfig.swift

@@ -51,8 +51,18 @@ public final class ConfigStore {
 
         let data = try Data(contentsOf: configURL)
         var config = try JSONDecoder().decode(AirAcceptConfig.self, from: data)
+        var shouldSave = false
         if config.pollIntervalMilliseconds == 1_000 {
             config.pollIntervalMilliseconds = AirAcceptConfig.defaultPollIntervalMilliseconds
+            shouldSave = true
+        }
+        if
+            let rawConfig = String(data: data, encoding: .utf8),
+            rawConfig.contains("dry_run") || rawConfig.contains("fallback_click_visible_airplay_banner")
+        {
+            shouldSave = true
+        }
+        if shouldSave {
             try save(config)
         }
         return config

+ 18 - 0
Sources/AirAcceptCore/AirAcceptLogger.swift

@@ -31,6 +31,7 @@ public final class AirAcceptLogger {
     private let logger = Logger(subsystem: "com.airaccept.AirAccept", category: "AirAccept")
     private let fileManager: FileManager
     private let encoder: JSONEncoder
+    private let maxLogSizeBytes = 1_000_000
 
     public init(
         logURL: URL = AirAcceptLogger.defaultLogURL(),
@@ -83,6 +84,7 @@ public final class AirAcceptLogger {
                 at: logURL.deletingLastPathComponent(),
                 withIntermediateDirectories: true
             )
+            try rotateLogIfNeeded()
             let data = Data((line + "\n").utf8)
             if fileManager.fileExists(atPath: logURL.path) {
                 let handle = try FileHandle(forWritingTo: logURL)
@@ -97,6 +99,22 @@ public final class AirAcceptLogger {
         }
     }
 
+    private func rotateLogIfNeeded() throws {
+        guard
+            fileManager.fileExists(atPath: logURL.path),
+            let size = try fileManager.attributesOfItem(atPath: logURL.path)[.size] as? NSNumber,
+            size.intValue >= maxLogSizeBytes
+        else {
+            return
+        }
+
+        let rotatedLogURL = logURL.deletingPathExtension().appendingPathExtension("log.1")
+        if fileManager.fileExists(atPath: rotatedLogURL.path) {
+            try fileManager.removeItem(at: rotatedLogURL)
+        }
+        try fileManager.moveItem(at: logURL, to: rotatedLogURL)
+    }
+
     private func isoTimestamp() -> String {
         ISO8601DateFormatter().string(from: Date())
     }

+ 23 - 0
Tests/AirAcceptCoreTests/AirAcceptCoreTests.swift

@@ -77,4 +77,27 @@ final class AirAcceptCoreTests: XCTestCase {
         XCTAssertEqual(config, AirAcceptConfig())
         XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
     }
+
+    func testConfigStoreRemovesLegacyKeys() throws {
+        let directory = FileManager.default.temporaryDirectory
+            .appendingPathComponent(UUID().uuidString, isDirectory: true)
+        let url = directory.appendingPathComponent("config.json")
+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+        try Data("""
+        {
+          "dry_run": false,
+          "fallback_click_visible_airplay_banner": true,
+          "poll_interval_ms": 2500
+        }
+        """.utf8).write(to: url)
+
+        let store = ConfigStore(configURL: url)
+        let config = try store.loadOrCreate()
+        let rewrittenConfig = try String(contentsOf: url, encoding: .utf8)
+
+        XCTAssertEqual(config, AirAcceptConfig())
+        XCTAssertFalse(rewrittenConfig.contains("dry_run"))
+        XCTAssertFalse(rewrittenConfig.contains("fallback_click_visible_airplay_banner"))
+        XCTAssertTrue(rewrittenConfig.contains("poll_interval_ms"))
+    }
 }