diff --git a/PHP Monitor.xcodeproj/project.pbxproj b/PHP Monitor.xcodeproj/project.pbxproj index 9ce15429..59b13b16 100644 --- a/PHP Monitor.xcodeproj/project.pbxproj +++ b/PHP Monitor.xcodeproj/project.pbxproj @@ -84,6 +84,11 @@ 039E1D7A2E5F0F300072D13D /* ValetUpgrader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039E1D782E5F0F2C0072D13D /* ValetUpgrader.swift */; }; 039E1D7B2E5F0F300072D13D /* ValetUpgrader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039E1D782E5F0F2C0072D13D /* ValetUpgrader.swift */; }; 039E1D7C2E5F0F300072D13D /* ValetUpgrader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039E1D782E5F0F2C0072D13D /* ValetUpgrader.swift */; }; + 03ACC6452ECCAB190070D4CD /* RealWebApiTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03ACC6442ECCAB140070D4CD /* RealWebApiTest.swift */; }; + 03ACC6472ECCBA130070D4CD /* CaskFile+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03ACC6462ECCBA100070D4CD /* CaskFile+API.swift */; }; + 03ACC6482ECCBA130070D4CD /* CaskFile+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03ACC6462ECCBA100070D4CD /* CaskFile+API.swift */; }; + 03ACC6492ECCBA130070D4CD /* CaskFile+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03ACC6462ECCBA100070D4CD /* CaskFile+API.swift */; }; + 03ACC64A2ECCBA130070D4CD /* CaskFile+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03ACC6462ECCBA100070D4CD /* CaskFile+API.swift */; }; 03B675E92EBA30D800EE04A9 /* NSImageExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B675E82EBA30D200EE04A9 /* NSImageExtension.swift */; }; 03B675EA2EBA30D800EE04A9 /* NSImageExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B675E82EBA30D200EE04A9 /* NSImageExtension.swift */; }; 03B675EB2EBA30D800EE04A9 /* NSImageExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B675E82EBA30D200EE04A9 /* NSImageExtension.swift */; }; @@ -1038,6 +1043,8 @@ 039C29172E8AA311007F5FAB /* TestableWebApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestableWebApi.swift; sourceTree = ""; }; 039C291C2E8AA399007F5FAB /* TestableWebApiTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestableWebApiTest.swift; sourceTree = ""; }; 039E1D782E5F0F2C0072D13D /* ValetUpgrader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValetUpgrader.swift; sourceTree = ""; }; + 03ACC6442ECCAB140070D4CD /* RealWebApiTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealWebApiTest.swift; sourceTree = ""; }; + 03ACC6462ECCBA100070D4CD /* CaskFile+API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CaskFile+API.swift"; sourceTree = ""; }; 03B675E82EBA30D200EE04A9 /* NSImageExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSImageExtension.swift; sourceTree = ""; }; 03BFF5262E312C39007F96FA /* Startup+Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Startup+Timers.swift"; sourceTree = ""; }; 03BFF52B2E313240007F96FA /* StatusMenu+Driver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusMenu+Driver.swift"; sourceTree = ""; }; @@ -1455,11 +1462,19 @@ 039C291E2E8AA39B007F5FAB /* Api */ = { isa = PBXGroup; children = ( - 039C291C2E8AA399007F5FAB /* TestableWebApiTest.swift */, ); path = Api; sourceTree = ""; }; + 03ACC6432ECCAAF70070D4CD /* WebApi */ = { + isa = PBXGroup; + children = ( + 039C291C2E8AA399007F5FAB /* TestableWebApiTest.swift */, + 03ACC6442ECCAB140070D4CD /* RealWebApiTest.swift */, + ); + path = WebApi; + sourceTree = ""; + }; 03BFF1D12E3CF4F2004C56A9 /* Provision */ = { isa = PBXGroup; children = ( @@ -1485,6 +1500,7 @@ C4E2E84628FC1D8C003B070C /* TestableConfigurationTest.swift */, C413E43328DA3E8F00AE33C7 /* Shell */, C471E6DA28F9AFCB0021E251 /* Filesystem */, + 03ACC6432ECCAAF70070D4CD /* WebApi */, ); path = Testables; sourceTree = ""; @@ -2145,6 +2161,7 @@ C4B79EB529CA387F00A483EE /* BrewPhpFormulaeHandler.swift */, C4F2E4362752F0870020E974 /* BrewDiagnostics.swift */, C40934A1298EEB2C00D25014 /* CaskFile.swift */, + 03ACC6462ECCBA100070D4CD /* CaskFile+API.swift */, C4E684082AF26B830023ED25 /* BrewTapFormulae.swift */, 031E2B682B1525A7007C29E1 /* BrewPhpExtension.swift */, ); @@ -2914,6 +2931,7 @@ C45D654C29F52F74004C28F9 /* BrewPermissionFixer.swift in Sources */, C4D3660B29113F20006BD146 /* System.swift in Sources */, C4D36601291132B7006BD146 /* ValetScanners.swift in Sources */, + 03ACC6472ECCBA130070D4CD /* CaskFile+API.swift in Sources */, C4EED88927A48778006D7272 /* InterAppHandler.swift in Sources */, C40C7F1E2772136000DDDCDC /* PhpEnvironments.swift in Sources */, C4B79EB629CA387F00A483EE /* BrewPhpFormulaeHandler.swift in Sources */, @@ -3143,6 +3161,7 @@ C441CC582AE8249400DDFACD /* ConfigFSNotifier.swift in Sources */, C471E80828F9BAD40021E251 /* PhpExtension.swift in Sources */, C471E7F928F9BACB0021E251 /* PhpSwitcher.swift in Sources */, + 03ACC6482ECCBA130070D4CD /* CaskFile+API.swift in Sources */, C471E82A28F9BB330021E251 /* ValetListable.swift in Sources */, 031E2B6B2B1525A7007C29E1 /* BrewPhpExtension.swift in Sources */, C471E82728F9BB310021E251 /* BrewDiagnostics.swift in Sources */, @@ -3251,6 +3270,7 @@ C471E8B428F9BB8F0021E251 /* MainMenu+Switcher.swift in Sources */, C471E8B528F9BB8F0021E251 /* MainMenu+FixMyValet.swift in Sources */, C471E8B628F9BB8F0021E251 /* MainMenu+Actions.swift in Sources */, + 03ACC64A2ECCBA130070D4CD /* CaskFile+API.swift in Sources */, C471E8B728F9BB8F0021E251 /* StatusMenu.swift in Sources */, 032DAC302E8BEB6B0018E01C /* WebApiProtocol.swift in Sources */, C471E8B828F9BB8F0021E251 /* StatusMenu+Items.swift in Sources */, @@ -3547,6 +3567,7 @@ C40C7F3127722E8D00DDDCDC /* Logger.swift in Sources */, C4068CAB27B0890D00544CD5 /* MenuBarIcons.swift in Sources */, C4AD38B328ECD9D300FA8D83 /* TestableFileSystem.swift in Sources */, + 03ACC6492ECCBA130070D4CD /* CaskFile+API.swift in Sources */, C40C5C9D2846A40600E28255 /* Preset.swift in Sources */, C4CE7F9729683B43000102CF /* PhpVersionNumberCollection.swift in Sources */, C4F30B09278E1A0E00755FCE /* CustomPrefs.swift in Sources */, @@ -3557,6 +3578,7 @@ C4E49DEE28F764A00026AC4E /* TestableCommand.swift in Sources */, C4611E612AEAD3110010BE24 /* ByteLimitView.swift in Sources */, C40175B92903108900763A68 /* ValetInteractor.swift in Sources */, + 03ACC6452ECCAB190070D4CD /* RealWebApiTest.swift in Sources */, 039E1D792E5F0F300072D13D /* ValetUpgrader.swift in Sources */, C4CE3BBC27B324250086CA49 /* ComposerWindow.swift in Sources */, C40B24F427A310830018C7D2 /* StatusMenu.swift in Sources */, diff --git a/phpmon/Common/Http/RealWebApi.swift b/phpmon/Common/Http/RealWebApi.swift index b586af55..0353a9bc 100644 --- a/phpmon/Common/Http/RealWebApi.swift +++ b/phpmon/Common/Http/RealWebApi.swift @@ -11,15 +11,81 @@ import Foundation class RealWebApi: WebApiProtocol { var container: Container + var defaultHeaders: HttpHeaders { + return [ + "User-Agent": "phpmon-nur/2.0", + "X-phpmon-version": "\(App.shortVersion) (\(App.bundleVersion))", + "X-phpmon-os-version": "\(App.macVersion)", + "X-phpmon-bundle-id": "\(App.identifier)" + ] + } + init(container: Container) { self.container = container } - func get(_ url: URL, withHeaders: HttpHeaders, withTimeout: TimeInterval) async throws -> WebApiResponse { - return WebApiResponse(statusCode: 200, headers: [:], data: Data()) + private func request( + url: URL, + method: String, + data: Data?, + headers: HttpHeaders, + timeout: TimeInterval + ) async throws -> WebApiResponse { + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = timeout + for header in headers { + request.setValue(header.value, forHTTPHeaderField: header.key) + } + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let response = response as? HTTPURLResponse else { + throw WebApiError.networkError + } + + return WebApiResponse( + statusCode: response.statusCode, + headers: response.allHeaderFields as! HttpHeaders, + data: data + ) + } catch { + if let urlError = error as? URLError { + if urlError.code == .timedOut { + throw WebApiError.timedOut + } + } + throw WebApiError.networkError + } } - func post(_ url: URL, withHeaders: HttpHeaders, withData: String, withTimeout: TimeInterval) async throws -> WebApiResponse { - return WebApiResponse(statusCode: 200, headers: [:], data: Data()) + func get( + _ url: URL, + withHeaders headers: HttpHeaders = [:], + withTimeout timeout: TimeInterval = URLSession.shared.configuration.timeoutIntervalForRequest + ) async throws -> WebApiResponse { + try await self.request( + url: url, + method: "GET", + data: nil, + headers: headers, + timeout: timeout + ) + } + + func post( + _ url: URL, + withHeaders headers: HttpHeaders, + withData data: String, + withTimeout timeout: TimeInterval + ) async throws -> WebApiResponse { + try await self.request( + url: url, + method: "POST", + data: data.data(using: .utf8), + headers: headers, + timeout: timeout + ) } } diff --git a/phpmon/Common/Http/TestableWebApi.swift b/phpmon/Common/Http/TestableWebApi.swift index 67afc347..cf774924 100644 --- a/phpmon/Common/Http/TestableWebApi.swift +++ b/phpmon/Common/Http/TestableWebApi.swift @@ -9,14 +9,33 @@ import Foundation class TestableWebApi: WebApiProtocol { + + // MARK: - Internal Fake Responses + private var fakeGetResponses: [URL: FakeWebApiResponse] = [:] private var fakePostResponses: [URL: FakeWebApiResponse] = [:] + + // MARK: - Slow Mode + private var slow: Bool = false public func setSlowMode(_ slow: Bool) { self.slow = slow } + // MARK: - Default Headers + + var defaultHeaders: HttpHeaders { + return [ + "User-Agent": "phpmon-nur/2.0", + "X-phpmon-version": "\(App.shortVersion) (\(App.bundleVersion))", + "X-phpmon-os-version": "\(App.macVersion)", + "X-phpmon-bundle-id": "\(App.identifier)" + ] + } + + // MARK: - Constructor + init( getResponses: [URL: FakeWebApiResponse], postResponses: [URL: FakeWebApiResponse] @@ -25,6 +44,8 @@ class TestableWebApi: WebApiProtocol { self.fakePostResponses = postResponses } + // MARK: - Public API + public func hasGetResponse(for url: URL) -> Bool { return fakeGetResponses.keys.contains(url) } diff --git a/phpmon/Common/Http/WebApiProtocol.swift b/phpmon/Common/Http/WebApiProtocol.swift index 32184717..a6128f98 100644 --- a/phpmon/Common/Http/WebApiProtocol.swift +++ b/phpmon/Common/Http/WebApiProtocol.swift @@ -14,6 +14,7 @@ enum WebApiError: Error { case invalidURL case networkError case timedOut + case other } struct WebApiResponse { @@ -37,6 +38,8 @@ struct WebApiResponse { } protocol WebApiProtocol { + var defaultHeaders: HttpHeaders { get } + func get( _ url: URL, withHeaders headers: HttpHeaders, diff --git a/phpmon/Domain/App/AppUpdater.swift b/phpmon/Domain/App/AppUpdater.swift index 6a128d97..e792fe80 100644 --- a/phpmon/Domain/App/AppUpdater.swift +++ b/phpmon/Domain/App/AppUpdater.swift @@ -28,8 +28,7 @@ class AppUpdater { let caskUrl = Constants.Urls.UpdateCheckEndpoint - guard let caskFile = await CaskFile.from(App.shared.container, url: caskUrl) else { - Log.err("The contents of the CaskFile at '\(caskUrl.absoluteString)' could not be retrieved.") + guard let caskFile = try? await CaskFile.fromUrl(App.shared.container, caskUrl) else { presentCouldNotRetrieveUpdateIfInteractive() return .networkError } diff --git a/phpmon/Domain/App/CrashReporter.swift b/phpmon/Domain/App/CrashReporter.swift index 0ca2b72a..28954dc4 100644 --- a/phpmon/Domain/App/CrashReporter.swift +++ b/phpmon/Domain/App/CrashReporter.swift @@ -101,15 +101,11 @@ class CrashReporter { request.setValue("text/crash", forHTTPHeaderField: "Content-Type") request.setValue("phpmon-crashrep/1.0", forHTTPHeaderField: "User-Agent") request.httpBody = text.data(using: .utf8) + request.timeoutInterval = timeout - // Send the request synchronously, we want the report to be sent before anything else - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = timeout - - let session = URLSession(configuration: config) let semaphore = DispatchSemaphore(value: 0) - let task = session.dataTask(with: request) { _, response, error in + let task = URLSession.shared.dataTask(with: request) { _, response, error in defer { semaphore.signal() } if let error = error { diff --git a/phpmon/Domain/Integrations/Homebrew/CaskFile+API.swift b/phpmon/Domain/Integrations/Homebrew/CaskFile+API.swift new file mode 100644 index 00000000..da86a981 --- /dev/null +++ b/phpmon/Domain/Integrations/Homebrew/CaskFile+API.swift @@ -0,0 +1,52 @@ +// +// CaskFile+API.swift +// PHP Monitor +// +// Created by Nico Verbruggen on 18/11/2025. +// Copyright © 2025 Nico Verbruggen. All rights reserved. +// + +import Foundation + +enum CaskFileError: Error { + case requestFailed + case invalidData + case invalidFile +} + +extension CaskFile { + public static func fromUrl( + _ container: Container, + _ url: URL + ) async throws -> CaskFile? { + // First, determine if we're loading a local URL or not + if url.scheme == "file" { + if let string = try? container.filesystem.getStringFromFile(url.relativePath) { + return CaskFile.from(string) + } else { + throw CaskFileError.invalidFile + } + } + + // If it's a URL via the network we need to know if to use the complex API or not + if isRunningTests || App.hasLoadedTestableConfiguration || url.absoluteString.contains("https://raw.githubusercontent.com") { + // For testing simplicity, we will use curl, since we need no complex rules or headers + return CaskFile.from(await container.shell.pipe("curl -s --max-time 10 '\(url.absoluteString)'").out) + } else { + // However, for the real deal, we will use the Web API + guard let response = try? await container.webApi.get( + url, + withHeaders: container.webApi.defaultHeaders, + withTimeout: .seconds(10) + ) else { + throw CaskFileError.requestFailed + } + + guard let text = response.plainText else { + throw CaskFileError.invalidData + } + + return CaskFile.from(text) + } + } +} diff --git a/phpmon/Domain/Integrations/Homebrew/CaskFile.swift b/phpmon/Domain/Integrations/Homebrew/CaskFile.swift index 8218b6d4..04a7fb9c 100644 --- a/phpmon/Domain/Integrations/Homebrew/CaskFile.swift +++ b/phpmon/Domain/Integrations/Homebrew/CaskFile.swift @@ -24,35 +24,7 @@ struct CaskFile { return self.properties["version"]! } - private static func loadFromApi(_ container: Container, _ url: URL) async -> String { - if isRunningTests || App.hasLoadedTestableConfiguration || url.absoluteString.contains("https://raw.githubusercontent.com") { - return await container.shell.pipe("curl -s --max-time 10 '\(url.absoluteString)'").out - } else { - return await container.shell.pipe(""" - curl -s --max-time 10 \ - -H "User-Agent: phpmon-curl/1.0" \ - -H "X-phpmon-version: \(App.shortVersion) (\(App.bundleVersion))" \ - -H "X-phpmon-os-version: \(App.macVersion)" \ - -H "X-phpmon-bundle-id: \(App.identifier)" \ - '\(url.absoluteString)' - """).out - } - } - - public static func from(_ container: Container, url: URL) async -> CaskFile? { - var string: String? - - if url.scheme == "file" { - string = try? String(contentsOf: url) - } else { - string = await CaskFile.loadFromApi(container, url) - } - - guard let string else { - Log.err("The content of the URL for the CaskFile could not be retrieved") - return nil - } - + public static func from(_ string: String) -> CaskFile? { let lines = string.split(separator: "\n") .map { line in return line.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/tests/unit/Parsers/CaskFileParserTest.swift b/tests/unit/Parsers/CaskFileParserTest.swift index 29d16f01..48029564 100644 --- a/tests/unit/Parsers/CaskFileParserTest.swift +++ b/tests/unit/Parsers/CaskFileParserTest.swift @@ -27,7 +27,7 @@ struct CaskFileParserTest { } @Test func can_extract_fields_from_cask_file() async throws { - guard let caskFile = await CaskFile.from(container, url: CaskFileParserTest.exampleFilePath) else { + guard let caskFile = try? await CaskFile.fromUrl(container, CaskFileParserTest.exampleFilePath) else { Issue.record("The CaskFile could not be parsed, check the log for more info") return } @@ -53,7 +53,7 @@ struct CaskFileParserTest { @Test func can_extract_fields_from_remote_cask_file() async throws { let url = URL(string: "https://raw.githubusercontent.com/nicoverbruggen/homebrew-cask/master/Casks/phpmon.rb")! - guard let caskFile = await CaskFile.from(container, url: url) else { + guard let caskFile = try? await CaskFile.fromUrl(container, url) else { Issue.record("The remote CaskFile could not be parsed, check the log for more info") return } diff --git a/tests/unit/Testables/WebApi/RealWebApiTest.swift b/tests/unit/Testables/WebApi/RealWebApiTest.swift new file mode 100644 index 00000000..0a0995a3 --- /dev/null +++ b/tests/unit/Testables/WebApi/RealWebApiTest.swift @@ -0,0 +1,41 @@ +// +// RealWebApiTest.swift +// PHP Monitor +// +// Created by Nico Verbruggen on 18/11/2025. +// Copyright © 2025 Nico Verbruggen. All rights reserved. +// + +import Testing +import Foundation + +struct RealWebApiTest { + private var container: Container + + init() throws { + self.container = Container() + container.bind() + } + + var WebApi: RealWebApi { + return container.webApi as! RealWebApi + } + + @Test func requestSucceeds() async { + let response = try! await WebApi.get( + url("https://api.phpmon.test/up") + ) + + #expect(response.statusCode == 200) + #expect(response.plainText!.contains("Response rendered in")) + } + + @Test func requestTimesOut() async { + await #expect(throws: WebApiError.timedOut) { + try await WebApi.get( + url("https://api.phpmon.test/up"), + withTimeout: .seconds(0.01) + ) + } + } +} diff --git a/tests/unit/Api/TestableWebApiTest.swift b/tests/unit/Testables/WebApi/TestableWebApiTest.swift similarity index 100% rename from tests/unit/Api/TestableWebApiTest.swift rename to tests/unit/Testables/WebApi/TestableWebApiTest.swift