diff --git a/PHP Monitor.xcodeproj/project.pbxproj b/PHP Monitor.xcodeproj/project.pbxproj index 3850ea19..07ae391f 100644 --- a/PHP Monitor.xcodeproj/project.pbxproj +++ b/PHP Monitor.xcodeproj/project.pbxproj @@ -75,6 +75,10 @@ 0379C4A62ED720220035D7EA /* App+DetectApps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0379C4A32ED7201D0035D7EA /* App+DetectApps.swift */; }; 0379C4A72ED720220035D7EA /* App+DetectApps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0379C4A32ED7201D0035D7EA /* App+DetectApps.swift */; }; 037F44162EDB0AAA002EBF75 /* FSNotifierTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F44152EDB0AA8002EBF75 /* FSNotifierTest.swift */; }; + 037F44182EDB27BA002EBF75 /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F44172EDB27B7002EBF75 /* Debouncer.swift */; }; + 037F44192EDB27BA002EBF75 /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F44172EDB27B7002EBF75 /* Debouncer.swift */; }; + 037F441A2EDB27BA002EBF75 /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F44172EDB27B7002EBF75 /* Debouncer.swift */; }; + 037F441B2EDB27BA002EBF75 /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F44172EDB27B7002EBF75 /* Debouncer.swift */; }; 0386B0B42ED36C3D00CA6795 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386B0B32ED36C3D00CA6795 /* Locked.swift */; }; 0386B0B52ED36C3D00CA6795 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386B0B32ED36C3D00CA6795 /* Locked.swift */; }; 0386B0B62ED36C3D00CA6795 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386B0B32ED36C3D00CA6795 /* Locked.swift */; }; @@ -1052,6 +1056,7 @@ 0379C49E2ED71CFC0035D7EA /* Startup+Launch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Startup+Launch.swift"; sourceTree = ""; }; 0379C4A32ED7201D0035D7EA /* App+DetectApps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App+DetectApps.swift"; sourceTree = ""; }; 037F44152EDB0AA8002EBF75 /* FSNotifierTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FSNotifierTest.swift; sourceTree = ""; }; + 037F44172EDB27B7002EBF75 /* Debouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debouncer.swift; sourceTree = ""; }; 0386B0B32ED36C3D00CA6795 /* Locked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Locked.swift; sourceTree = ""; }; 0386B0B82ED36DF800CA6795 /* LockedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockedTests.swift; sourceTree = ""; }; 0392CDE52EB23B8F009176DA /* CertificateValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateValidator.swift; sourceTree = ""; }; @@ -2331,6 +2336,7 @@ C4C8E81D276F5686003AC782 /* Watcher */ = { isa = PBXGroup; children = ( + 037F44172EDB27B7002EBF75 /* Debouncer.swift */, C4C8E81A276F54E5003AC782 /* ConfigWatchManager.swift */, C441CC552AE8249400DDFACD /* ConfigFSNotifier.swift */, C41ADCE72970CCC700120423 /* FSNotifier.swift */, @@ -2850,6 +2856,7 @@ C4E2E85C28FC282B003B070C /* TestableConfiguration.swift in Sources */, 039616102E74A61E002DD7F6 /* LoggableEvent.swift in Sources */, C4C0E8DF27F88AEB002D32A9 /* FakeDomainScanner.swift in Sources */, + 037F44192EDB27BA002EBF75 /* Debouncer.swift in Sources */, C44B3A4628E5C70100718CB1 /* TimeIntervalExtension.swift in Sources */, 03C099452EA15C8E00B76D43 /* Container+Real.swift in Sources */, C4463FCC29804BCB007B93D5 /* RCFile.swift in Sources */, @@ -3106,6 +3113,7 @@ C471E86328F9BB650021E251 /* PMTableView.swift in Sources */, C471E86428F9BB650021E251 /* Warning.swift in Sources */, 03C29A772EC88E3100FBA25E /* ValetServicesDataManager.swift in Sources */, + 037F441B2EDB27BA002EBF75 /* Debouncer.swift in Sources */, C40175BA2903108900763A68 /* ValetInteractor.swift in Sources */, C43931C729C4BD610069165B /* PhpVersionManagerView.swift in Sources */, 0379C4A52ED720220035D7EA /* App+DetectApps.swift in Sources */, @@ -3427,6 +3435,7 @@ C471E7F828F9BACB0021E251 /* InternalSwitcher.swift in Sources */, C471E82328F9BB2E0021E251 /* ComposerJson.swift in Sources */, C471E82128F9BB2E0021E251 /* ProjectTypeDetection.swift in Sources */, + 037F441A2EDB27BA002EBF75 /* Debouncer.swift in Sources */, 032DAC282E8BEB5B0018E01C /* RealWebApi.swift in Sources */, C471E7EF28F9BAC30021E251 /* Actions.swift in Sources */, C471E82228F9BB2E0021E251 /* ComposerWindow.swift in Sources */, @@ -3671,6 +3680,7 @@ C485707828BF456300539B36 /* Warning.swift in Sources */, 033D459F2B0D513900070080 /* RemovePhpExtensionCommand.swift in Sources */, C4513F8F2B13E2E5001AD760 /* PhpExtensionManagerWindowController.swift in Sources */, + 037F44182EDB27BA002EBF75 /* Debouncer.swift in Sources */, C415938027A1B54F00D2E1B7 /* ProjectTypeDetection.swift in Sources */, C40F505628ECA64E004AD45B /* TestableConfigurations.swift in Sources */, C4D9ADC9277611A0007277F4 /* InternalSwitcher.swift in Sources */, diff --git a/phpmon/Domain/App/App.swift b/phpmon/Domain/App/App.swift index bd74c55e..ad6bfab9 100644 --- a/phpmon/Domain/App/App.swift +++ b/phpmon/Domain/App/App.swift @@ -139,6 +139,9 @@ class App { /** Individual filesystem watchers, which are, i.e. responsible for watching the Homebrew folders. */ var watchers: [String: FSNotifier] = [:] + /** Individual debouncers for filesystem watchers. */ + var debouncers: [String: Debouncer] = [:] + /** The `ConfigWatchManager` is responsible for watching the `.ini` files and the `.conf.d` folder. This manager object can immediately start or stop all watchers (or pause them) all at once. diff --git a/phpmon/Domain/Watcher/App+BrewWatch.swift b/phpmon/Domain/Watcher/App+BrewWatch.swift index b949fd05..a23ec0ba 100644 --- a/phpmon/Domain/Watcher/App+BrewWatch.swift +++ b/phpmon/Domain/Watcher/App+BrewWatch.swift @@ -17,27 +17,22 @@ extension App { onChange: { Task { await self.onHomebrewPhpModification() } } ) - App.shared.watchers["homebrewBinaries"] = notifier - } - - public func destroyHomebrewWatchers() { - // Removing requires termination and then removing reference - self.watchers["homebrewBinaries"]?.terminate() - self.watchers["homebrewBinaries"] = nil + self.watchers["homebrewBinaries"] = notifier + self.debouncers["homebrewBinaries"] = Debouncer() } public func onHomebrewPhpModification() async { - // let previous = App.shared.container.phpEnvs.currentInstall?.version.text - Log.info("Something changed in the Homebrew binary directory...") - await container.phpEnvs.reloadPhpVersions() - await MainMenu.shared.refreshActiveInstallation() + if let debouncer = self.debouncers["homebrewBinaries"] { + await debouncer.debounce(for: 5.0) { + Log.info("No changes in `\(self.container.paths.binPath)` occurred for 5 seconds. Reloading now.") - // - // TODO: PHP Guard 2.0 - // Check if the new and previous version of PHP are different - // if so, we can show a notification if needed or alert the user - // - // let new = App.shared.container.phpEnvs.currentInstall?.version.text - // + // We reload the PHP versions in the background + await self.container.phpEnvs.reloadPhpVersions() + + // Finally, refresh the active installation + await MainMenu.shared.refreshActiveInstallation() + } + } } + } diff --git a/phpmon/Domain/Watcher/Debouncer.swift b/phpmon/Domain/Watcher/Debouncer.swift new file mode 100644 index 00000000..b7511529 --- /dev/null +++ b/phpmon/Domain/Watcher/Debouncer.swift @@ -0,0 +1,26 @@ +// +// Debouncer.swift +// PHP Monitor +// +// Created by Nico Verbruggen on 29/11/2025. +// Copyright © 2025 Nico Verbruggen. All rights reserved. +// + +import Foundation + +actor Debouncer { + private var task: Task? + + func debounce(for duration: TimeInterval, action: @escaping () async -> Void) { + task?.cancel() + task = Task { + try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + guard !Task.isCancelled else { return } + await action() + } + } + + func cancel() { + task?.cancel() + } +} diff --git a/phpmon/Domain/Watcher/FSNotifier.swift b/phpmon/Domain/Watcher/FSNotifier.swift index ec2a5a26..06114b2f 100644 --- a/phpmon/Domain/Watcher/FSNotifier.swift +++ b/phpmon/Domain/Watcher/FSNotifier.swift @@ -16,7 +16,6 @@ class FSNotifier { let queue = DispatchQueue(label: "com.nicoverbruggen.phpmon.fs_notifier") let url: URL - var lastUpdate: TimeInterval? // MARK: Private variables @@ -43,23 +42,7 @@ class FSNotifier { dispatchSource?.setEventHandler(handler: { self.queue.async { - // See how long ago our last handled event was - let distance = self.lastUpdate? - .distance(to: Date().timeIntervalSince1970) - - // Add a debounce of 1 second - if let distance, distance <= 1.00 { - Log.perf("FSNotifier debounce active for \(url.path).") - return - } - - // Synchronize the last update property - self.lastUpdate = Date().timeIntervalSince1970 - - // Dispatch the async task we set for when the filesystem event occurs - Task { - onChange() - } + Task { onChange() } } }) diff --git a/tests/unit/Watchers/FSNotifierTest.swift b/tests/unit/Watchers/FSNotifierTest.swift index 98fb7c33..33b292c6 100644 --- a/tests/unit/Watchers/FSNotifierTest.swift +++ b/tests/unit/Watchers/FSNotifierTest.swift @@ -25,13 +25,19 @@ struct FSNotifierTest { } let eventFired = Locked(0) + let debouncer = Debouncer() // Create notifier let notifier = FSNotifier( for: testFile, eventMask: .write, onChange: { - eventFired.value += 1 + Task { + // Debouncer is an actor so this is allowed + await debouncer.debounce(for: 1.0) { + eventFired.value += 1 + } + } } ) @@ -43,16 +49,15 @@ struct FSNotifierTest { try "hello".write(to: testFile, atomically: false, encoding: .utf8) try "hello".write(to: testFile, atomically: false, encoding: .utf8) - // Wait for the event to fire, verify it fired ONCE (not TWICE) - try await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + // Wait for the event to fire, verify it fired ONCE after 1 second debounce + await delay(seconds: 1.2) #expect(eventFired.value == 1) // Try to write again (after debounce timing) - try await Task.sleep(nanoseconds: 2_000_000_000) try "hello".write(to: testFile, atomically: false, encoding: .utf8) - // Verify our event fired AGAIN after 0.2 seconds - try await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + // Verify after another second, our second write is actually noted + await delay(seconds: 1.2) #expect(eventFired.value == 2) } }