Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions Sources/CodexBarCore/Config/CodexBarConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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] {
Expand Down
29 changes: 29 additions & 0 deletions Sources/CodexBarCore/Config/CodexBarConfigValidation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ public enum CodexBarConfigValidator {
self.validateProvider(entry, issues: &issues)
}

if let proxy = config.networkProxy {
self.validateNetworkProxy(proxy, issues: &issues)
}

return issues
}

Expand Down Expand Up @@ -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."))
}
}
}
51 changes: 51 additions & 0 deletions Sources/CodexBarCore/Config/NetworkProxyConfiguration.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
43 changes: 31 additions & 12 deletions Sources/CodexBarCore/ProviderHTTPClient.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)) {
Expand Down
63 changes: 63 additions & 0 deletions Tests/CodexBarTests/ConfigValidationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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" }))
}
}