diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift index ab0526d66..696e9e2c0 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -5,10 +5,16 @@ public struct CodexBarConfig: Codable, Sendable { public var version: Int public var providers: [ProviderConfig] + public var networkProxy: NetworkProxyConfiguration? - public init(version: Int = Self.currentVersion, providers: [ProviderConfig]) { + public init( + version: Int = Self.currentVersion, + providers: [ProviderConfig], + networkProxy: NetworkProxyConfiguration? = nil) + { self.version = version self.providers = providers + self.networkProxy = networkProxy } public static func makeDefault( @@ -43,7 +49,8 @@ public struct CodexBarConfig: Codable, Sendable { return CodexBarConfig( version: Self.currentVersion, - providers: normalized) + providers: normalized, + networkProxy: self.networkProxy) } public func orderedProviders() -> [UsageProvider] { diff --git a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift index a0dd11b83..b37b398cb 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift @@ -44,6 +44,10 @@ public enum CodexBarConfigValidator { self.validateProvider(entry, issues: &issues) } + if let proxy = config.networkProxy { + self.validateNetworkProxy(proxy, issues: &issues) + } + return issues } @@ -254,4 +258,29 @@ public enum CodexBarConfigValidator { code: "invalid_region", message: "Region \(region) is not a valid \(displayName) region.")) } + + private static func validateNetworkProxy( + _ proxy: NetworkProxyConfiguration, + issues: inout [CodexBarConfigIssue]) + { + guard proxy.enabled else { return } + + if proxy.trimmedHost.isEmpty { + issues.append(CodexBarConfigIssue( + severity: .error, + provider: nil, + field: "networkProxy.host", + code: "proxy_host_missing", + message: "Network proxy host is required when proxy is enabled.")) + } + + if proxy.resolvedPort == nil { + issues.append(CodexBarConfigIssue( + severity: .error, + provider: nil, + field: "networkProxy.port", + code: "proxy_port_invalid", + message: "Network proxy port must be a number between 1 and 65535.")) + } + } } diff --git a/Sources/CodexBarCore/Config/NetworkProxyConfiguration.swift b/Sources/CodexBarCore/Config/NetworkProxyConfiguration.swift new file mode 100644 index 000000000..d0bcbb015 --- /dev/null +++ b/Sources/CodexBarCore/Config/NetworkProxyConfiguration.swift @@ -0,0 +1,51 @@ +import Foundation + +public enum NetworkProxyScheme: String, CaseIterable, Codable, Sendable { + case http + case socks5 +} + +public struct NetworkProxyConfiguration: Codable, Sendable, Equatable { + public var enabled: Bool + public var scheme: NetworkProxyScheme + public var host: String + public var port: String + public var username: String + + public init( + enabled: Bool, + scheme: NetworkProxyScheme, + host: String, + port: String, + username: String) + { + self.enabled = enabled + self.scheme = scheme + self.host = host + self.port = port + self.username = username + } + + public var trimmedHost: String { + self.host.trimmingCharacters(in: .whitespacesAndNewlines) + } + + public var trimmedPort: String { + self.port.trimmingCharacters(in: .whitespacesAndNewlines) + } + + public var trimmedUsername: String { + self.username.trimmingCharacters(in: .whitespacesAndNewlines) + } + + public var resolvedPort: Int? { + guard let port = Int(self.trimmedPort), (1...65535).contains(port) else { + return nil + } + return port + } + + public var isActive: Bool { + self.enabled && !self.trimmedHost.isEmpty && self.resolvedPort != nil + } +} diff --git a/Sources/CodexBarCore/ProviderHTTPClient.swift b/Sources/CodexBarCore/ProviderHTTPClient.swift index 1c8a3f85a..08d5c67ef 100644 --- a/Sources/CodexBarCore/ProviderHTTPClient.swift +++ b/Sources/CodexBarCore/ProviderHTTPClient.swift @@ -1,25 +1,32 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking +@preconcurrency import FoundationNetworking #endif -public protocol ProviderHTTPTransport: Sendable { +@preconcurrency public protocol ProviderHTTPTransport: Sendable { func data(for request: URLRequest) async throws -> (Data, URLResponse) } -#if !os(Linux) -extension URLSession: ProviderHTTPTransport {} -#endif - +#if canImport(FoundationNetworking) extension URLSession { - public func response(for request: URLRequest) async throws -> ProviderHTTPResponse { - let (data, response) = try await self.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) + public func data(for request: URLRequest) async throws -> (Data, URLResponse) { + try await withCheckedThrowingContinuation { continuation in + let task = self.dataTask(with: request) { data, response, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let data, let response else { + continuation.resume(throwing: URLError(.badServerResponse)) + return + } + continuation.resume(returning: (data, response)) + } + task.resume() } - return ProviderHTTPResponse(data: data, response: httpResponse) } } +#endif public struct ProviderHTTPResponse: Sendable { public let data: Data @@ -35,7 +42,19 @@ public struct ProviderHTTPResponse: Sendable { } } -public struct ProviderHTTPTransportHandler: ProviderHTTPTransport { +extension URLSession { + public func response(for request: URLRequest) async throws -> ProviderHTTPResponse { + let (data, response) = try await self.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + return ProviderHTTPResponse(data: data, response: httpResponse) + } +} + +extension URLSession: ProviderHTTPTransport {} + +public struct ProviderHTTPTransportHandler: ProviderHTTPTransport, Sendable { private let handler: @Sendable (URLRequest) async throws -> (Data, URLResponse) public init(_ handler: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) { diff --git a/Tests/CodexBarTests/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift index e0c0f449f..d509d393a 100644 --- a/Tests/CodexBarTests/ConfigValidationTests.swift +++ b/Tests/CodexBarTests/ConfigValidationTests.swift @@ -83,4 +83,67 @@ struct ConfigValidationTests { #expect(url.path.hasSuffix("/tmp/codexbar-test-config.json")) } + + @Test + func `network proxy config encodes and decodes`() throws { + let config = CodexBarConfig( + providers: [], + networkProxy: NetworkProxyConfiguration( + enabled: true, + scheme: .http, + host: "proxy.example.com", + port: "8080", + username: "codex")) + + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(CodexBarConfig.self, from: data) + + #expect(decoded.networkProxy?.enabled == true) + #expect(decoded.networkProxy?.scheme == .http) + #expect(decoded.networkProxy?.host == "proxy.example.com") + #expect(decoded.networkProxy?.port == "8080") + #expect(decoded.networkProxy?.username == "codex") + } + + @Test + func `config store round trips network proxy`() throws { + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let store = CodexBarConfigStore(fileURL: tempDirectory.appendingPathComponent("config.json")) + defer { try? FileManager.default.removeItem(at: tempDirectory) } + + let config = CodexBarConfig( + providers: [], + networkProxy: NetworkProxyConfiguration( + enabled: true, + scheme: .socks5, + host: "127.0.0.1", + port: "1080", + username: "codex")) + + try store.save(config) + let loaded = try store.load() + + #expect(loaded?.networkProxy?.enabled == true) + #expect(loaded?.networkProxy?.scheme == .socks5) + #expect(loaded?.networkProxy?.host == "127.0.0.1") + #expect(loaded?.networkProxy?.port == "1080") + #expect(loaded?.networkProxy?.username == "codex") + } + + @Test + func `network proxy validation reports missing host and invalid port`() { + let config = CodexBarConfig( + providers: [], + networkProxy: NetworkProxyConfiguration( + enabled: true, + scheme: .http, + host: " ", + port: "not-a-port", + username: "codex")) + + let issues = CodexBarConfigValidator.validate(config) + #expect(issues.contains(where: { $0.field == "networkProxy.host" && $0.code == "proxy_host_missing" })) + #expect(issues.contains(where: { $0.field == "networkProxy.port" && $0.code == "proxy_port_invalid" })) + } }