diff --git a/PHP Monitor.xcodeproj/project.pbxproj b/PHP Monitor.xcodeproj/project.pbxproj index 07ae391f..9565f284 100644 --- a/PHP Monitor.xcodeproj/project.pbxproj +++ b/PHP Monitor.xcodeproj/project.pbxproj @@ -79,6 +79,14 @@ 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 */; }; + 037F441D2EDB9195002EBF75 /* ConfigWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F441C2EDB9195002EBF75 /* ConfigWatchManager.swift */; }; + 037F441E2EDB9195002EBF75 /* ConfigWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F441C2EDB9195002EBF75 /* ConfigWatchManager.swift */; }; + 037F441F2EDB9195002EBF75 /* ConfigWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F441C2EDB9195002EBF75 /* ConfigWatchManager.swift */; }; + 037F44202EDB9195002EBF75 /* ConfigWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F441C2EDB9195002EBF75 /* ConfigWatchManager.swift */; }; + 037F44222EDB92EC002EBF75 /* HomebrewWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F44212EDB92EC002EBF75 /* HomebrewWatchManager.swift */; }; + 037F44232EDB92EC002EBF75 /* HomebrewWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F44212EDB92EC002EBF75 /* HomebrewWatchManager.swift */; }; + 037F44242EDB92EC002EBF75 /* HomebrewWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F44212EDB92EC002EBF75 /* HomebrewWatchManager.swift */; }; + 037F44252EDB92EC002EBF75 /* HomebrewWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F44212EDB92EC002EBF75 /* HomebrewWatchManager.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 */; }; @@ -318,10 +326,6 @@ C4415E8E2B0287E90035F520 /* BrewFormulaeObservable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4415E8C2B0287E90035F520 /* BrewFormulaeObservable.swift */; }; C4415E8F2B0287E90035F520 /* BrewFormulaeObservable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4415E8C2B0287E90035F520 /* BrewFormulaeObservable.swift */; }; C4415E902B0287E90035F520 /* BrewFormulaeObservable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4415E8C2B0287E90035F520 /* BrewFormulaeObservable.swift */; }; - C441CC562AE8249400DDFACD /* ConfigFSNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C441CC552AE8249400DDFACD /* ConfigFSNotifier.swift */; }; - C441CC572AE8249400DDFACD /* ConfigFSNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C441CC552AE8249400DDFACD /* ConfigFSNotifier.swift */; }; - C441CC582AE8249400DDFACD /* ConfigFSNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C441CC552AE8249400DDFACD /* ConfigFSNotifier.swift */; }; - C441CC592AE8249400DDFACD /* ConfigFSNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C441CC552AE8249400DDFACD /* ConfigFSNotifier.swift */; }; C44264BE2850B86C007400F1 /* SwiftUIHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44264BD2850B86C007400F1 /* SwiftUIHelper.swift */; }; C44264C02850BD2A007400F1 /* VersionPopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44264BF2850BD2A007400F1 /* VersionPopoverView.swift */; }; C4463FCC29804BCB007B93D5 /* RCFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4463FCB29804BCB007B93D5 /* RCFile.swift */; }; @@ -604,8 +608,6 @@ C471E87728F9BB650021E251 /* Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4CDA892288F1A71007CE25F /* Keys.swift */; }; C471E87828F9BB650021E251 /* TerminalProgressWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44C198C276E3A1C0072762D /* TerminalProgressWindowController.swift */; }; C471E87928F9BB650021E251 /* ProgressVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44A874728905BB000498BC4 /* ProgressVC.swift */; }; - C471E87B28F9BB650021E251 /* App+ConfigWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C8E817276F54D8003AC782 /* App+ConfigWatch.swift */; }; - C471E87C28F9BB650021E251 /* ConfigWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C8E81A276F54E5003AC782 /* ConfigWatchManager.swift */; }; C471E87D28F9BB650021E251 /* Preset.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40C5C9B2846A40600E28255 /* Preset.swift */; }; C471E87E28F9BB650021E251 /* PresetHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C463E37F284930EE00422731 /* PresetHelper.swift */; }; C471E87F28F9BB650021E251 /* WarningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4297F7928970D59004C4630 /* WarningView.swift */; }; @@ -690,8 +692,6 @@ C471E8DA28F9BB8F0021E251 /* Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4CDA892288F1A71007CE25F /* Keys.swift */; }; C471E8DB28F9BB8F0021E251 /* TerminalProgressWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44C198C276E3A1C0072762D /* TerminalProgressWindowController.swift */; }; C471E8DC28F9BB8F0021E251 /* ProgressVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44A874728905BB000498BC4 /* ProgressVC.swift */; }; - C471E8DE28F9BB8F0021E251 /* App+ConfigWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C8E817276F54D8003AC782 /* App+ConfigWatch.swift */; }; - C471E8DF28F9BB8F0021E251 /* ConfigWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C8E81A276F54E5003AC782 /* ConfigWatchManager.swift */; }; C471E8E028F9BB8F0021E251 /* Preset.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40C5C9B2846A40600E28255 /* Preset.swift */; }; C471E8E128F9BB8F0021E251 /* PresetHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C463E37F284930EE00422731 /* PresetHelper.swift */; }; C471E8E228F9BB8F0021E251 /* WarningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4297F7928970D59004C4630 /* WarningView.swift */; }; @@ -755,10 +755,6 @@ C48DDD0E29C75C9E00D032D9 /* BlockingOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48DDD0C29C75C9E00D032D9 /* BlockingOverlayView.swift */; }; C48DDD0F29C75C9E00D032D9 /* BlockingOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48DDD0C29C75C9E00D032D9 /* BlockingOverlayView.swift */; }; C48DDD1029C75C9E00D032D9 /* BlockingOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48DDD0C29C75C9E00D032D9 /* BlockingOverlayView.swift */; }; - C490E3B629BCA367006D2DE6 /* App+BrewWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EAA5629B1689200AB28FC /* App+BrewWatch.swift */; }; - C490E3B829BCA367006D2DE6 /* App+BrewWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EAA5629B1689200AB28FC /* App+BrewWatch.swift */; }; - C490E3B929BCA368006D2DE6 /* App+BrewWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EAA5629B1689200AB28FC /* App+BrewWatch.swift */; }; - C490E3BA29BCA368006D2DE6 /* App+BrewWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EAA5629B1689200AB28FC /* App+BrewWatch.swift */; }; C490E3BB29BCA375006D2DE6 /* Measurements.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EAA5129B12A5A00AB28FC /* Measurements.swift */; }; C490E3BC29BCA375006D2DE6 /* Measurements.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EAA5129B12A5A00AB28FC /* Measurements.swift */; }; C490E3BD29BCA375006D2DE6 /* Measurements.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EAA5129B12A5A00AB28FC /* Measurements.swift */; }; @@ -845,10 +841,6 @@ C4C3ED4327834C5200AB15D8 /* CustomPrefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C3ED4227834C5200AB15D8 /* CustomPrefs.swift */; }; C4C8900328F0E28800CE5E97 /* FileSystemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C8900228F0E28800CE5E97 /* FileSystemProtocol.swift */; }; C4C8900528F0E3D100CE5E97 /* RealFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C8900428F0E3D100CE5E97 /* RealFileSystem.swift */; }; - C4C8E818276F54D8003AC782 /* App+ConfigWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C8E817276F54D8003AC782 /* App+ConfigWatch.swift */; }; - C4C8E819276F54D8003AC782 /* App+ConfigWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C8E817276F54D8003AC782 /* App+ConfigWatch.swift */; }; - C4C8E81B276F54E5003AC782 /* ConfigWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C8E81A276F54E5003AC782 /* ConfigWatchManager.swift */; }; - C4C8E81C276F54E5003AC782 /* ConfigWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C8E81A276F54E5003AC782 /* ConfigWatchManager.swift */; }; C4CB250529B28BB800CA4492 /* MainMenuTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4CB250429B28BB800CA4492 /* MainMenuTest.swift */; }; C4CB6E65292C362C002E9027 /* Homebrew.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4CB6E64292C362C002E9027 /* Homebrew.swift */; }; C4CB6E66292C362C002E9027 /* Homebrew.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4CB6E64292C362C002E9027 /* Homebrew.swift */; }; @@ -1057,6 +1049,8 @@ 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 = ""; }; + 037F441C2EDB9195002EBF75 /* ConfigWatchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigWatchManager.swift; sourceTree = ""; }; + 037F44212EDB92EC002EBF75 /* HomebrewWatchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomebrewWatchManager.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 = ""; }; @@ -1175,7 +1169,6 @@ C44067F827E2585E0045BD4E /* DomainListTypeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DomainListTypeCell.swift; sourceTree = ""; }; C44067FA27E25FD70045BD4E /* DomainListTLSCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DomainListTLSCell.swift; sourceTree = ""; }; C4415E8C2B0287E90035F520 /* BrewFormulaeObservable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrewFormulaeObservable.swift; sourceTree = ""; }; - C441CC552AE8249400DDFACD /* ConfigFSNotifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigFSNotifier.swift; sourceTree = ""; }; C44264BD2850B86C007400F1 /* SwiftUIHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIHelper.swift; sourceTree = ""; }; C44264BF2850BD2A007400F1 /* VersionPopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionPopoverView.swift; sourceTree = ""; }; C4463FCB29804BCB007B93D5 /* RCFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RCFile.swift; sourceTree = ""; }; @@ -1246,7 +1239,6 @@ C49DA9BC2D67AC49006F9CF4 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; C49DA9BD2D67B298006F9CF4 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; C49EAA5129B12A5A00AB28FC /* Measurements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Measurements.swift; sourceTree = ""; }; - C49EAA5629B1689200AB28FC /* App+BrewWatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App+BrewWatch.swift"; sourceTree = ""; }; C4A81CA328C67101008DD9D1 /* PMTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PMTableView.swift; sourceTree = ""; }; C4AC51FB27E27F47008528CA /* DomainListKindCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DomainListKindCell.swift; sourceTree = ""; }; C4ACA38E25C754C100060C66 /* PhpExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhpExtension.swift; sourceTree = ""; }; @@ -1282,8 +1274,6 @@ C4C3ED4227834C5200AB15D8 /* CustomPrefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPrefs.swift; sourceTree = ""; }; C4C8900228F0E28800CE5E97 /* FileSystemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystemProtocol.swift; sourceTree = ""; }; C4C8900428F0E3D100CE5E97 /* RealFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealFileSystem.swift; sourceTree = ""; }; - C4C8E817276F54D8003AC782 /* App+ConfigWatch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "App+ConfigWatch.swift"; sourceTree = ""; }; - C4C8E81A276F54E5003AC782 /* ConfigWatchManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigWatchManager.swift; sourceTree = ""; }; C4CB250429B28BB800CA4492 /* MainMenuTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenuTest.swift; sourceTree = ""; }; C4CB6E64292C362C002E9027 /* Homebrew.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Homebrew.swift; sourceTree = ""; }; C4CCBA6B275C567B008C7055 /* PMWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PMWindowController.swift; sourceTree = ""; }; @@ -2336,12 +2326,10 @@ C4C8E81D276F5686003AC782 /* Watcher */ = { isa = PBXGroup; children = ( - 037F44172EDB27B7002EBF75 /* Debouncer.swift */, - C4C8E81A276F54E5003AC782 /* ConfigWatchManager.swift */, - C441CC552AE8249400DDFACD /* ConfigFSNotifier.swift */, C41ADCE72970CCC700120423 /* FSNotifier.swift */, - C49EAA5629B1689200AB28FC /* App+BrewWatch.swift */, - C4C8E817276F54D8003AC782 /* App+ConfigWatch.swift */, + 037F44172EDB27B7002EBF75 /* Debouncer.swift */, + 037F441C2EDB9195002EBF75 /* ConfigWatchManager.swift */, + 037F44212EDB92EC002EBF75 /* HomebrewWatchManager.swift */, ); path = Watcher; sourceTree = ""; @@ -2837,7 +2825,7 @@ 0309E6672B0D4B2F002AC007 /* BrewExtensionsObservable.swift in Sources */, C4E0F7ED27BEBDA9007475F2 /* NSWindowExtension.swift in Sources */, C4205A7E27F4D21800191A39 /* ValetProxy.swift in Sources */, - C4C8E818276F54D8003AC782 /* App+ConfigWatch.swift in Sources */, + 037F441F2EDB9195002EBF75 /* ConfigWatchManager.swift in Sources */, C43B8FD52BA9BAD3000C02BE /* UnavailableContentView.swift in Sources */, 032DAC2A2E8BEB5B0018E01C /* RealWebApi.swift in Sources */, 54FCFD30276C8DA4004CE748 /* HotkeyPreferenceView.swift in Sources */, @@ -2850,6 +2838,7 @@ C4D5576429C77CC5001A44CD /* PhpVersionManagerWindowController.swift in Sources */, C4E49DED28F764A00026AC4E /* TestableCommand.swift in Sources */, C41E871A2763D42300161EE0 /* DomainListVC+ContextMenu.swift in Sources */, + 037F44222EDB92EC002EBF75 /* HomebrewWatchManager.swift in Sources */, C40C7F2827721FF600DDDCDC /* Valet+Alerts.swift in Sources */, C463E380284930EE00422731 /* PresetHelper.swift in Sources */, C41C02A927E61A65009F26CB /* FakeValetSite.swift in Sources */, @@ -2938,9 +2927,7 @@ 03DAD3A72EB3B08F003417BD /* DomainListVC+Certs.swift in Sources */, C46EBC4728DB9644007ACC74 /* RealShell.swift in Sources */, C4068CAA27B0890D00544CD5 /* MenuBarIcons.swift in Sources */, - C441CC562AE8249400DDFACD /* ConfigFSNotifier.swift in Sources */, C44264C02850BD2A007400F1 /* VersionPopoverView.swift in Sources */, - C4C8E81B276F54E5003AC782 /* ConfigWatchManager.swift in Sources */, C417DC74277614690015E6EE /* Helpers.swift in Sources */, C415D3E82770F692005EF286 /* AppDelegate+InterApp.swift in Sources */, C41C1B3722B0097F00E7CF16 /* AppDelegate.swift in Sources */, @@ -2948,7 +2935,6 @@ C42759672627662800093CAE /* NSMenuExtension.swift in Sources */, C422DDAA28A2C49900CEAC97 /* PhpDoctorView.swift in Sources */, C469E6FE294CF7B200A82AB2 /* FakeValetProxy.swift in Sources */, - C490E3B629BCA367006D2DE6 /* App+BrewWatch.swift in Sources */, C464ADAF275A7A69003FCD53 /* DomainListVC.swift in Sources */, C44CCD4927AFF3B700CE40E5 /* MainMenu+Async.swift in Sources */, C4C1019B27C65C6F001FACC2 /* Process.swift in Sources */, @@ -3147,8 +3133,6 @@ C471E87728F9BB650021E251 /* Keys.swift in Sources */, C471E87828F9BB650021E251 /* TerminalProgressWindowController.swift in Sources */, C471E87928F9BB650021E251 /* ProgressVC.swift in Sources */, - C471E87B28F9BB650021E251 /* App+ConfigWatch.swift in Sources */, - C471E87C28F9BB650021E251 /* ConfigWatchManager.swift in Sources */, C471E87D28F9BB650021E251 /* Preset.swift in Sources */, C471E87E28F9BB650021E251 /* PresetHelper.swift in Sources */, C471E87F28F9BB650021E251 /* WarningView.swift in Sources */, @@ -3179,6 +3163,7 @@ C4415E8F2B0287E90035F520 /* BrewFormulaeObservable.swift in Sources */, C471E7D828F9BA8F0021E251 /* FileSystemProtocol.swift in Sources */, 03BFF52F2E313244007F96FA /* StatusMenu+Driver.swift in Sources */, + 037F44242EDB92EC002EBF75 /* HomebrewWatchManager.swift in Sources */, C471E7F328F9BAC70021E251 /* PhpHelper.swift in Sources */, C46DC7A62C7B5BC900F19D17 /* Favorites.swift in Sources */, C471E7E728F9BAC20021E251 /* Constants.swift in Sources */, @@ -3201,7 +3186,6 @@ C40D72612A018AE30054A067 /* BrewFormula+UI.swift in Sources */, C471E82528F9BB2E0021E251 /* ComposerWindow.swift in Sources */, 0396160D2E74A61E002DD7F6 /* LoggableEvent.swift in Sources */, - C441CC582AE8249400DDFACD /* ConfigFSNotifier.swift in Sources */, C471E80828F9BAD40021E251 /* PhpExtension.swift in Sources */, C471E7F928F9BACB0021E251 /* PhpSwitcher.swift in Sources */, 03ACC6482ECCBA130070D4CD /* CaskFile+API.swift in Sources */, @@ -3209,8 +3193,8 @@ 031E2B6B2B1525A7007C29E1 /* BrewPhpExtension.swift in Sources */, C471E82728F9BB310021E251 /* BrewDiagnostics.swift in Sources */, C471E7DB28F9BA8F0021E251 /* RealShell.swift in Sources */, - C490E3B929BCA368006D2DE6 /* App+BrewWatch.swift in Sources */, C471E7FF28F9BAD10021E251 /* Xdebug.swift in Sources */, + 037F441D2EDB9195002EBF75 /* ConfigWatchManager.swift in Sources */, C409349F298EE8E900D25014 /* AppUpdater.swift in Sources */, 03BFF5292E312C3D007F96FA /* Startup+Timers.swift in Sources */, C471E7F228F9BAC70021E251 /* PhpEnvironments.swift in Sources */, @@ -3272,7 +3256,6 @@ 036C39022E5C883B008DAEDF /* Packagist.swift in Sources */, 0379C4A72ED720220035D7EA /* App+DetectApps.swift in Sources */, C471E89428F9BB8F0021E251 /* LocalNotification.swift in Sources */, - C441CC592AE8249400DDFACD /* ConfigFSNotifier.swift in Sources */, C40934A5298EEB2C00D25014 /* CaskFile.swift in Sources */, C471E89528F9BB8F0021E251 /* MenuBarImageGenerator.swift in Sources */, C40D725D2A018ACC0054A067 /* BusyStatus.swift in Sources */, @@ -3375,8 +3358,6 @@ C471E8DC28F9BB8F0021E251 /* ProgressVC.swift in Sources */, 03D846262EB6344E006EFE3C /* DomainListVC+Window.swift in Sources */, C490E3BF29BCA376006D2DE6 /* Measurements.swift in Sources */, - C471E8DE28F9BB8F0021E251 /* App+ConfigWatch.swift in Sources */, - C471E8DF28F9BB8F0021E251 /* ConfigWatchManager.swift in Sources */, C4CB250529B28BB800CA4492 /* MainMenuTest.swift in Sources */, C40D72622A018AE30054A067 /* BrewFormula+UI.swift in Sources */, C4B79ECE29CA475900A483EE /* RemovePhpVersionCommand.swift in Sources */, @@ -3398,6 +3379,7 @@ C456A0CE2AA6166F0080144F /* BytePhpPreference.swift in Sources */, C4FD87A829AB9ABD0002D701 /* PhpConfigChecker.swift in Sources */, C45B9151295608E300F4EC78 /* ValetServicesManager.swift in Sources */, + 037F44202EDB9195002EBF75 /* ConfigWatchManager.swift in Sources */, C47015032C46D7F00069AAE7 /* NVAlertExtension.swift in Sources */, C471E8EC28F9BB8F0021E251 /* SwiftUIHelper.swift in Sources */, C471E8EE28F9BB8F0021E251 /* HotKey.swift in Sources */, @@ -3419,7 +3401,6 @@ C4611E5A2AEAD2E20010BE24 /* ConfigManagerWindowController.swift in Sources */, C471E80E28F9BAE80021E251 /* DateExtension.swift in Sources */, 036C390F2E5C8D42008DAEDF /* PackagistError.swift in Sources */, - C490E3BA29BCA368006D2DE6 /* App+BrewWatch.swift in Sources */, 03BFF5272E312C3D007F96FA /* Startup+Timers.swift in Sources */, C471E7D028F9BA630021E251 /* FileSystemProtocol.swift in Sources */, C471E81228F9BAE80021E251 /* TimeIntervalExtension.swift in Sources */, @@ -3435,6 +3416,7 @@ C471E7F828F9BACB0021E251 /* InternalSwitcher.swift in Sources */, C471E82328F9BB2E0021E251 /* ComposerJson.swift in Sources */, C471E82128F9BB2E0021E251 /* ProjectTypeDetection.swift in Sources */, + 037F44232EDB92EC002EBF75 /* HomebrewWatchManager.swift in Sources */, 037F441A2EDB27BA002EBF75 /* Debouncer.swift in Sources */, 032DAC282E8BEB5B0018E01C /* RealWebApi.swift in Sources */, C471E7EF28F9BAC30021E251 /* Actions.swift in Sources */, @@ -3548,7 +3530,6 @@ C43A8A2425D9D20D00591B77 /* HomebrewPackageTest.swift in Sources */, C485707928BF456C00539B36 /* ArrayExtension.swift in Sources */, C4F780CA25D80B75000DBC97 /* HomebrewDecodable.swift in Sources */, - C4C8E81C276F54E5003AC782 /* ConfigWatchManager.swift in Sources */, C4F319C927B034A500AFF46F /* Stats.swift in Sources */, C4F30B04278E16BA00755FCE /* HomebrewService.swift in Sources */, 54D9E0B527E4F51E003B9AD9 /* Key.swift in Sources */, @@ -3567,7 +3548,6 @@ 0392CDE92EB23B8F009176DA /* CertificateValidator.swift in Sources */, C4821C5B2C2DEDE200357A68 /* AppMenu.swift in Sources */, C463E381284930EE00422731 /* PresetHelper.swift in Sources */, - C441CC572AE8249400DDFACD /* ConfigFSNotifier.swift in Sources */, C4F520672AF03791006787F2 /* ExtensionEnumeratorTest.swift in Sources */, C46FA98C2822F08F00D78807 /* PhpConfigurationFileTest.swift in Sources */, C4D5576529C77CC5001A44CD /* PhpVersionManagerWindowController.swift in Sources */, @@ -3582,7 +3562,6 @@ C42106672AFA9FF400DF3732 /* PhpVersionManagerView+Actions.swift in Sources */, C46DC7A52C7B5BC900F19D17 /* Favorites.swift in Sources */, 032DAC2F2E8BEB6B0018E01C /* WebApiProtocol.swift in Sources */, - C4C8E819276F54D8003AC782 /* App+ConfigWatch.swift in Sources */, C4FC21B128391F8E00D368BB /* MainMenu+Actions.swift in Sources */, 54D9E0B927E4F51E003B9AD9 /* KeyCombo.swift in Sources */, C4EED88A27A48778006D7272 /* InterAppHandler.swift in Sources */, @@ -3597,12 +3576,14 @@ C4E2E85D28FC282B003B070C /* TestableConfiguration.swift in Sources */, C485706E28BF451C00539B36 /* OnboardingWindowController.swift in Sources */, C4BB393A2981AFC700F8E797 /* PhpVersionSource.swift in Sources */, + 037F44252EDB92EC002EBF75 /* HomebrewWatchManager.swift in Sources */, C4CB6E66292C362C002E9027 /* Homebrew.swift in Sources */, C43603A1275E67610028EFC6 /* AppDelegate+Notifications.swift in Sources */, C4C3643A28AE4FCE00C0770E /* StatusMenu+Items.swift in Sources */, C42759682627662800093CAE /* NSMenuExtension.swift in Sources */, 03BFF52D2E313244007F96FA /* StatusMenu+Driver.swift in Sources */, C4AFC4B429C4F43300BF4E0D /* HomebrewUpgradableTest.swift in Sources */, + 037F441E2EDB9195002EBF75 /* ConfigWatchManager.swift in Sources */, C4E2E84828FC1D93003B070C /* TestableConfigurationTest.swift in Sources */, C4D936CB27E3EE4A00BD69FE /* DomainListCellProtocol.swift in Sources */, C4513F962B13E30C001AD760 /* BrewExtensionsObservable.swift in Sources */, @@ -3694,7 +3675,6 @@ 033D45992B0D4EC600070080 /* InstallPhpExtensionCommand.swift in Sources */, C4F5FBCD28218CB8001065C5 /* Xdebug.swift in Sources */, C40B24F227A310770018C7D2 /* Events.swift in Sources */, - C490E3B829BCA367006D2DE6 /* App+BrewWatch.swift in Sources */, C44AD3F72912EF7100997FF4 /* RealFileSystemTest.swift in Sources */, C4F30B0A278E1A1A00755FCE /* ComposerJson.swift in Sources */, C4C0E8E027F88AEB002D32A9 /* FakeDomainScanner.swift in Sources */, diff --git a/phpmon/Domain/App/App.swift b/phpmon/Domain/App/App.swift index ad6bfab9..088d35f4 100644 --- a/phpmon/Domain/App/App.swift +++ b/phpmon/Domain/App/App.swift @@ -136,15 +136,16 @@ class App { // MARK: - App Watchers - /** 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. */ - var watchManager: ConfigWatchManager! + var configWatchManager: ConfigWatchManager? + + /** + The `HomebrewWatchManager` is responsible for watching the Homebrew binaries folder. + This allows PHP Monitor to respond to external `brew` changes executed by the user. + */ + var homebrewWatchManager: HomebrewWatchManager? + } diff --git a/phpmon/Domain/App/Startup+Launch.swift b/phpmon/Domain/App/Startup+Launch.swift index f570a423..fafc9659 100644 --- a/phpmon/Domain/App/Startup+Launch.swift +++ b/phpmon/Domain/App/Startup+Launch.swift @@ -68,13 +68,13 @@ extension Startup { await container.phpEnvs.reloadPhpVersions() // Set up the filesystem watcher for the Homebrew binaries - App.shared.prepareHomebrewWatchers() + await HomebrewWatchManager.prepare() // Check for other problems container.warningManager.evaluateWarnings() // Set up the config watchers on launch (updated automatically when switching) - App.shared.handlePhpConfigWatcher() + await ConfigWatchManager.handleWatcher() // Detect built-in and custom applications await App.shared.detectApplications() diff --git a/phpmon/Domain/Menu/MainMenu+Actions.swift b/phpmon/Domain/Menu/MainMenu+Actions.swift index cbed971c..8b48e972 100644 --- a/phpmon/Domain/Menu/MainMenu+Actions.swift +++ b/phpmon/Domain/Menu/MainMenu+Actions.swift @@ -292,7 +292,7 @@ extension MainMenu { await PhpEnvironments.switcher.performSwitch(to: version) container.phpEnvs.currentInstall = ActivePhpInstallation(container) - App.shared.handlePhpConfigWatcher() + await ConfigWatchManager.handleWatcher() container.phpEnvs.delegate?.switcherDidCompleteSwitch(to: version) } @@ -307,7 +307,7 @@ extension MainMenu { await PhpEnvironments.switcher.performSwitch(to: version) container.phpEnvs.currentInstall = ActivePhpInstallation(container) - App.shared.handlePhpConfigWatcher() + await ConfigWatchManager.handleWatcher() container.phpEnvs.delegate?.switcherDidCompleteSwitch(to: version) } } @@ -333,7 +333,7 @@ extension MainMenu { await PhpEnvironments.switcher.performSwitch(to: version) container.phpEnvs.currentInstall = ActivePhpInstallation(container) - App.shared.handlePhpConfigWatcher() + await ConfigWatchManager.handleWatcher() container.phpEnvs.delegate?.switcherDidCompleteSwitch(to: version) } diff --git a/phpmon/Domain/Watcher/App+BrewWatch.swift b/phpmon/Domain/Watcher/App+BrewWatch.swift deleted file mode 100644 index 61a059c9..00000000 --- a/phpmon/Domain/Watcher/App+BrewWatch.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// App+BrewWatch.swift -// PHP Monitor -// -// Created by Nico Verbruggen on 03/03/2023. -// Copyright © 2025 Nico Verbruggen. All rights reserved. -// - -import Foundation - -extension App { - - /** - Performs a particular action while suspending the Homebrew watcher, - until the task is completed. - */ - public func withSuspendedHomebrewWatcher(_ action: () async throws -> T) async rethrows -> T { - await suspendHomebrewWatcher() - defer { resumeHomebrewWatcher() } - return try await action() - } - - /** - Prepares the `homebrew/bin` directory watcher. This allows PHP Monitor to quickly respond to - external `brew` changes executed by the user. - */ - public func prepareHomebrewWatchers() { - let notifier = FSNotifier( - for: URL(fileURLWithPath: container.paths.binPath), - eventMask: .all, - onChange: { Task { await self.onHomebrewPhpModification() } } - ) - - self.watchers["homebrewBinaries"] = notifier - self.debouncers["homebrewBinaries"] = Debouncer() - } - - private func suspendHomebrewWatcher() async { - watchers["homebrewBinaries"]?.suspend() - await debouncers["homebrewBinaries"]?.cancel() - } - - private func resumeHomebrewWatcher() { - watchers["homebrewBinaries"]?.resume() - } - - public func onHomebrewPhpModification() async { - 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.") - - // 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/App+ConfigWatch.swift b/phpmon/Domain/Watcher/App+ConfigWatch.swift deleted file mode 100644 index 7327133f..00000000 --- a/phpmon/Domain/Watcher/App+ConfigWatch.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// App+ConfigWatch.swift -// PHP Monitor -// -// Created by Nico Verbruggen on 30/03/2021. -// Copyright © 2025 Nico Verbruggen. All rights reserved. -// - -import Foundation - -extension App { - - func startWatchManager(_ url: URL) { - Log.perf("Starting config watch manager...") - self.watchManager = ConfigWatchManager(for: url) - - self.watchManager.didChange = { url in - Log.perf("Something has changed in: \(url)") - - // Check if the watcher has last updated the menu less than 0.75s ago - let distance = self.watchManager.lastUpdate?.distance(to: Date().timeIntervalSince1970) - if distance == nil || distance != nil && distance! > 0.75 { - Log.perf("Refreshing menu...") - Task { @MainActor in MainMenu.shared.reloadPhpMonitorMenuInBackground() } - self.watchManager.lastUpdate = Date().timeIntervalSince1970 - } - } - } - - func handlePhpConfigWatcher(forceReload: Bool = false) { - if container.filesystem is TestableFileSystem { - Log.warn("Config watch manager is disabled when using testable filesystem.") - return - } - - guard let install = container.phpEnvs.phpInstall else { - Log.info("It appears as if no PHP installation is currently active.") - Log.info("The config watch manager be disabled until a PHP install is active.") - return - } - - let url = URL(fileURLWithPath: "\(container.paths.etcPath)/php/\(install.version.short)") - - // Check whether the manager exists and schedule on the main thread - // if we don't consistently do this, the app will create duplicate watchers - // due to timing issues, which creates retain cycles - Task { @MainActor in - // Watcher needs to be created - if self.watchManager == nil { - self.startWatchManager(url) - } - - // Watcher needs to be updated - if self.watchManager.url != url || forceReload { - self.watchManager.disable() - self.watchManager = nil - Log.perf("Watcher has stopped watching files. Starting new one...") - self.startWatchManager(url) - } - } - } - -} diff --git a/phpmon/Domain/Watcher/ConfigFSNotifier.swift b/phpmon/Domain/Watcher/ConfigFSNotifier.swift deleted file mode 100644 index 167a6181..00000000 --- a/phpmon/Domain/Watcher/ConfigFSNotifier.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// ConfigFSNotifier.swift -// PHP Monitor -// -// Created by Nico Verbruggen on 24/10/2023. -// Copyright © 2025 Nico Verbruggen. All rights reserved. -// - -import Foundation - -class ConfigFSNotifier { - - enum Behaviour { - case reloadsMenu - case reloadsWatchers - } - - let url: URL - - private var parent: ConfigWatchManager! - private var monitoredFolderFileDescriptor: CInt = -1 - private var folderMonitorSource: DispatchSourceFileSystemObject? - - init( - for url: URL, - eventMask: DispatchSource.FileSystemEvent, - parent: ConfigWatchManager, - behaviour: ConfigFSNotifier.Behaviour = .reloadsMenu - ) { - self.url = url - self.parent = parent - self.startMonitoring(eventMask, behaviour: behaviour) - } - - func startMonitoring( - _ eventMask: DispatchSource.FileSystemEvent, - behaviour: ConfigFSNotifier.Behaviour - ) { - // Ensure our starting state is correct, we may already be monitoring! - guard folderMonitorSource == nil && monitoredFolderFileDescriptor == -1 else { - return - } - - // We'll try to open a file descriptor and validate it - monitoredFolderFileDescriptor = open(url.path, O_EVTONLY) - - // If our file descriptor here is still -1, there may have been an issue and we abort - guard monitoredFolderFileDescriptor >= 0 else { - Log.err("Failed to open file descriptor for \(url.path), not monitoring.") - return - } - - // Set the source (with proper file descriptor, event mask and using the right queue) - folderMonitorSource = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: monitoredFolderFileDescriptor, - eventMask: eventMask, - queue: parent.queue - ) - - // Set the event handler (fires depending on the event mask) - folderMonitorSource?.setEventHandler { [weak self] in - if behaviour == .reloadsWatchers - && !ConfigWatchManager.ignoresModificationsToConfigValues { - // Reload all configuration watchers - return App.shared.handlePhpConfigWatcher(forceReload: true) - } - - if let url = self?.url { - self?.parent.didChange?(url) - } - } - - // Cancellation handler, fired when we stop monitoring files - folderMonitorSource?.setCancelHandler { [weak self] in - guard let self = self else { return } - - close(self.monitoredFolderFileDescriptor) - self.monitoredFolderFileDescriptor = -1 - self.folderMonitorSource = nil - } - - folderMonitorSource?.resume() - } - - func stopMonitoring() { - folderMonitorSource?.cancel() - self.parent = nil - } -} diff --git a/phpmon/Domain/Watcher/ConfigWatchManager.swift b/phpmon/Domain/Watcher/ConfigWatchManager.swift index c4f1b21a..a66939db 100644 --- a/phpmon/Domain/Watcher/ConfigWatchManager.swift +++ b/phpmon/Domain/Watcher/ConfigWatchManager.swift @@ -8,24 +8,71 @@ import Foundation -class ConfigWatchManager { +actor ConfigWatchManager { + + enum Behaviour { + case reloadsMenu + case reloadsWatchers + } // MARK: Global state (applicable to ALL watchers) static var ignoresModificationsToConfigValues: Bool = false - // MARK: Public variables + // MARK: Static methods - private var watchers: [ConfigFSNotifier] = [] + /** + Handles the PHP config watcher lifecycle. Creates a new watcher if needed, + or recreates it if the PHP version has changed. - let queue = DispatchQueue(label: "com.nicoverbruggen.phpmon.config_watch") - let url: URL - var lastUpdate: TimeInterval? - var didChange: ((URL) -> Void)? + Actor isolation ensures no duplicate watchers or retain cycles. + */ + @MainActor + public static func handleWatcher(forceReload: Bool = false) async { + let container = App.shared.container + + if container.filesystem is TestableFileSystem { + Log.warn("Config watch manager is disabled when using testable filesystem.") + return + } + + guard let install = container.phpEnvs.phpInstall else { + Log.info("It appears as if no PHP installation is currently active.") + Log.info("The config watch manager is disabled until a PHP install is active.") + return + } + + let url = URL(fileURLWithPath: "\(container.paths.etcPath)/php/\(install.version.short)") + + // Create watcher if missing + guard let manager = App.shared.configWatchManager else { + let manager = ConfigWatchManager(for: url) + await manager.setupWatchers() + App.shared.configWatchManager = manager + return + } + + // Update existing watcher if needed + if await manager.url != url { + // URL changed - update to different PHP version + await manager.updateUrl(to: url) + } else if forceReload { + // Same URL - just reload watchers (e.g., conf.d files added/removed) + await manager.reloadWatchers() + } + } + + // MARK: Instance variables + + private var watchers: [FSNotifier] = [] + private var debouncer: Debouncer + + private(set) var url: URL + nonisolated private let debounceInterval: TimeInterval // MARK: Methods - init(for url: URL) { + init(for url: URL, debounceInterval: TimeInterval = 0.75) { if App.shared.container.filesystem is TestableFileSystem { fatalError(""" ConfigWatchManager is currently incompatible with a testable filesystem!" @@ -34,6 +81,13 @@ class ConfigWatchManager { } self.url = url + self.debounceInterval = debounceInterval + self.debouncer = Debouncer() + } + + func setupWatchers() { + // Guard against double setup + assert(watchers.isEmpty, "setupWatchers() called when watchers already exist") // Add a watcher for php.ini self.addWatcher(for: self.url.appendingPathComponent("php.ini"), eventMask: .write) @@ -62,25 +116,64 @@ class ConfigWatchManager { })) } - func addWatcher( + private func clearWatchers() { + for watcher in self.watchers { + watcher.terminate() + } + self.watchers.removeAll() + } + + func reloadWatchers() { + Log.perf("Reloading configuration watchers...") + clearWatchers() + setupWatchers() + } + + func updateUrl(to newUrl: URL) { + Log.perf("Updating watcher URL from \(self.url.path) to \(newUrl.path)...") + clearWatchers() + self.url = newUrl + setupWatchers() + } + + private func handleConfigChange(at url: URL) async { + await debouncer.debounce(for: debounceInterval) { + Log.perf("Config file changed at \(url.path), debounce completed. Refreshing menu...") + Task { @MainActor in MainMenu.shared.reloadPhpMonitorMenuInBackground() } + } + } + + private func addWatcher( for url: URL, eventMask: DispatchSource.FileSystemEvent, - behaviour: ConfigFSNotifier.Behaviour = .reloadsMenu + behaviour: Behaviour = .reloadsMenu ) { if !App.shared.container.filesystem.anyExists(url.path) { Log.warn("No watcher was created for \(url.path) because the requested file does not exist.") return } - let watcher = ConfigFSNotifier(for: url, eventMask: eventMask, parent: self, behaviour: behaviour) + let watcher = FSNotifier(for: url, eventMask: eventMask) { [weak self] in + guard let self = self else { return } + + Task { + if behaviour == .reloadsWatchers + && !ConfigWatchManager.ignoresModificationsToConfigValues { + // Reload all configuration watchers on this manager + await self.reloadWatchers() + return + } + + await self.handleConfigChange(at: url) + } + } self.watchers.append(watcher) } - func disable() { + func disable() async { Log.perf("Turning off all individual existing watchers...") - self.watchers.forEach { (watcher) in - watcher.stopMonitoring() - } + await debouncer.cancel() + clearWatchers() } deinit { diff --git a/phpmon/Domain/Watcher/FSNotifier.swift b/phpmon/Domain/Watcher/FSNotifier.swift index 0aea7afa..1c3d3c53 100644 --- a/phpmon/Domain/Watcher/FSNotifier.swift +++ b/phpmon/Domain/Watcher/FSNotifier.swift @@ -8,26 +8,37 @@ import Foundation -class FSNotifier { +actor FSNotifier { - public static var shared: FSNotifier! = nil + // MARK: Variables - // MARK: Public variables + /** The URL of the file or folder that is being observed. */ + nonisolated let url: URL - let queue = DispatchQueue(label: "com.nicoverbruggen.phpmon.fs_notifier") - let url: URL + /** Whether responding to events is currently on hold. */ + private(set) var isSuspended = false - // MARK: Private variables + // MARK: Internal Variables - private var fileDescriptor: CInt = -1 - private var dispatchSource: DispatchSourceFileSystemObject? + /** The queue that is used for the `dispatchSource`. */ + private nonisolated let queue: DispatchQueue - private var isSuspended = false + /** An open file or folder required for observation. */ + private nonisolated(unsafe) var fileDescriptor: CInt = -1 + + /** A dispatch source that monitors events associated with a file or folder. */ + private nonisolated(unsafe) var dispatchSource: DispatchSourceFileSystemObject? // MARK: Methods - init(for url: URL, eventMask: DispatchSource.FileSystemEvent, onChange: @escaping () -> Void) { + init( + for url: URL, + eventMask: DispatchSource.FileSystemEvent, + queue: DispatchQueue? = nil, + onChange: @escaping () -> Void + ) { self.url = url + self.queue = queue ?? DispatchQueue(label: "com.nicoverbruggen.phpmon.fs_notifier") fileDescriptor = open(url.path, O_EVTONLY) @@ -42,19 +53,19 @@ class FSNotifier { queue: self.queue ) - dispatchSource?.setEventHandler(handler: { - self.queue.async { + dispatchSource?.setEventHandler(handler: { [weak self] in + Task { [weak self] in + guard let self = self else { return } + // If our notifier is suspended, don't fire - guard !self.isSuspended else { return } + guard await !self.isSuspended else { return } // If our notifier is not suspended, fire - Task { onChange() } + onChange() } }) - dispatchSource?.setCancelHandler(handler: { [weak self] in - guard let self = self else { return } - + dispatchSource?.setCancelHandler(handler: { close(self.fileDescriptor) self.fileDescriptor = -1 self.dispatchSource = nil @@ -63,26 +74,25 @@ class FSNotifier { dispatchSource?.resume() } - func suspend() { - self.queue.async { - self.isSuspended = true - Log.perf("FSNotifier for \(self.url) has been suspended.") - } + /** Suspends responding to filesystem events. This does not stop events from being observed! */ + func suspend() async { + self.isSuspended = true + Log.perf("FSNotifier for \(self.url.path) has been suspended.") } - func resume() { - self.queue.async { - self.isSuspended = false - Log.perf("FSNotifier for \(self.url) has been resumed.") - } + /** Resumes responding to filesystem events. */ + func resume() async { + self.isSuspended = false + Log.perf("FSNotifier for \(self.url.path) has been resumed.") } - func terminate() { + /** Terminates the file monitor, which will cause `deinit` to fire. */ + nonisolated func terminate() { dispatchSource?.cancel() } - deinit { - Log.perf("FSNotifier for \(self.url) will be deinitialized.") + nonisolated deinit { + Log.perf("deinit: FSNotifier @ \(self.url.path)") } } diff --git a/phpmon/Domain/Watcher/HomebrewWatchManager.swift b/phpmon/Domain/Watcher/HomebrewWatchManager.swift new file mode 100644 index 00000000..632db49b --- /dev/null +++ b/phpmon/Domain/Watcher/HomebrewWatchManager.swift @@ -0,0 +1,173 @@ +// +// HomebrewWatchManager.swift +// PHP Monitor +// +// Created by Nico Verbruggen on 29/11/2025. +// Copyright © 2025 Nico Verbruggen. All rights reserved. +// + +import Foundation + +actor HomebrewWatchManager { + + // MARK: Public API + + /** + Prepares the Homebrew watcher. This allows PHP Monitor to quickly respond to + external `brew` changes executed by the user. + */ + @MainActor + public static func prepare() async { + let binPath = App.shared.container.paths.binPath + let manager = HomebrewWatchManager( + for: URL(fileURLWithPath: binPath), + debounceInterval: 5.0 + ) + await manager.setupWatcher() + App.shared.homebrewWatchManager = manager + } + + /** + Performs a particular action while suspending the Homebrew watcher, + until the task is completed. + + Any operations that cause Homebrew to perform tasks (installing, + updating, removing packages) should be wrapped in this helper method, + to prevent the app from doing duplicate work. + */ + public static func withSuspended(_ action: () async throws -> T) async rethrows -> T { + guard let manager = App.shared.homebrewWatchManager else { + // If there's no manager, run the task as-is + return try await action() + } + + // Suspend, execute the action, and resume + return try await manager.withSuspended(action) + } + + // MARK: - Instance variables + + /** + The underlying `FSNotifier` which will respond to filesystem events. + */ + private var watcher: FSNotifier? + + /** + The debouncer, responsible for ensuring events stop firing before + finally responding to changes in `homebrew/bin`. + */ + private var debouncer: Debouncer + + /** + The URL of the `homebrew/bin` path, that we will be watching, too. + */ + nonisolated let url: URL + + /** + The interval for the debounce. Prevents bulk changes from triggering + too many fired events. + */ + nonisolated let debounceInterval: TimeInterval + + // MARK: - Lifecycle + + init(for url: URL, debounceInterval: TimeInterval = 5.0) { + if App.shared.container.filesystem is TestableFileSystem { + fatalError(""" + HomebrewWatchManager is currently incompatible with a testable filesystem! + You are not allowed to instantiate these while using a testable filesystem. + """) + } + + self.url = url + self.debounceInterval = debounceInterval + self.debouncer = Debouncer() + } + + deinit { + Log.perf("deinit: \(String(describing: self)).\(#function)") + } + + // MARK: - Internal Methods + + /** + Sets up the watcher, assuming one does not exist. + The target directory must exist. + */ + private func setupWatcher() { + // Guard against double setup + assert(watcher == nil, "setupWatcher() called when watcher already exists") + + // Ensure that the target directory exists + if !App.shared.container.filesystem.anyExists(url.path) { + Log.warn("No watcher was created for \(url.path) because the requested directory does not exist.") + return + } + + // Create a new FSNotifier which will respond to all events. + // If files are created, removed, etc. in this `homebrew/bin` folder, the handler will fire. + self.watcher = FSNotifier(for: url, eventMask: .all) { [weak self] in + guard let self = self else { return } + + Task { + await self.onHomebrewPhpModification() + } + } + + Log.perf("A watcher exists for Homebrew binaries at: \(url.relativePath)") + } + + /** + Reloads PHP versions and refreshes the active PHP installation if any changes + are made to Homebrew binaries. Usually external changes to packages will trigger this. + + As such, PHP Monitor will check if anything has changed with PHP. + */ + private func onHomebrewPhpModification() async { + await debouncer.debounce(for: debounceInterval) { [weak self] in + guard let self = self else { return } + Log.info("No changes in `\(self.url.path)` occurred for \(self.debounceInterval) seconds. Reloading now.") + + // We reload the PHP versions in the background + await App.shared.container.phpEnvs.reloadPhpVersions() + + // Finally, refresh the active installation + await MainMenu.shared.refreshActiveInstallation() + } + } + + // MARK: - Suspend and resume + + /** + Suspends the `HomebrewWatchManager`. + This prevents any changes to `/homebrew/bin` from causing events to fire. + */ + private func suspend() async { + await watcher?.suspend() + await debouncer.cancel() + } + + /** + Resumes the `HomebrewWatchManager`. + Any changes to `/homebrew/bin` are picked up again. + */ + private func resume() async { + await watcher?.resume() + } + + /** + Executes an `action` callback after suspending the watcher. + */ + private func withSuspended(_ action: () async throws -> T) async rethrows -> T { + await suspend() + do { + let result = try await action() + await resume() + return result + } catch { + await resume() + throw error + } + } + +} diff --git a/phpmon/Modules/PHP Version Manager/UI/PhpVersionManagerView+Actions.swift b/phpmon/Modules/PHP Version Manager/UI/PhpVersionManagerView+Actions.swift index 8b3540f4..a580b05c 100644 --- a/phpmon/Modules/PHP Version Manager/UI/PhpVersionManagerView+Actions.swift +++ b/phpmon/Modules/PHP Version Manager/UI/PhpVersionManagerView+Actions.swift @@ -33,7 +33,7 @@ extension PhpVersionManagerView { do { self.setBusyStatus(true) - try await App.shared.withSuspendedHomebrewWatcher { + try await HomebrewWatchManager.withSuspended { try await command.execute(shell: container.shell) { progress in Task { @MainActor in self.status.title = progress.title @@ -99,7 +99,7 @@ extension PhpVersionManagerView { do { self.setBusyStatus(true) - try await App.shared.withSuspendedHomebrewWatcher { + try await HomebrewWatchManager.withSuspended { try await command.execute(shell: container.shell) { progress in Task { @MainActor in self.status.title = progress.title diff --git a/tests/unit/Watchers/FSNotifierTest.swift b/tests/unit/Watchers/FSNotifierTest.swift index 33b292c6..26ca1ad2 100644 --- a/tests/unit/Watchers/FSNotifierTest.swift +++ b/tests/unit/Watchers/FSNotifierTest.swift @@ -41,10 +41,6 @@ struct FSNotifierTest { } ) - defer { - notifier.terminate() - } - // Modify the file, twice try "hello".write(to: testFile, atomically: false, encoding: .utf8) try "hello".write(to: testFile, atomically: false, encoding: .utf8) @@ -59,5 +55,8 @@ struct FSNotifierTest { // Verify after another second, our second write is actually noted await delay(seconds: 1.2) #expect(eventFired.value == 2) + + // Clean up notifier + notifier.terminate() } }