Skip to content
Closed
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
1 change: 1 addition & 0 deletions Sources/CodexBar/CodexbarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ struct CodexBarApp: App {
let preferencesSelection = PreferencesSelection()
let settings = SettingsStore()
Self.applyLanguagePreference(from: settings)
ProxyConfigurator.apply(from: settings)
configureUsageFormatterLocalizationProvider()
let managedCodexAccountCoordinator = ManagedCodexAccountCoordinator()
managedCodexAccountCoordinator.onManagedAccountsDidChange = {
Expand Down
42 changes: 42 additions & 0 deletions Sources/CodexBar/PreferencesAdvancedPane.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CodexBarCore
import KeyboardShortcuts
import SwiftUI

Expand All @@ -6,6 +7,7 @@ struct AdvancedPane: View {
@Bindable var settings: SettingsStore
@State private var isInstallingCLI = false
@State private var cliStatus: String?
@FocusState private var proxyURLFocused: Bool

var body: some View {
ScrollView(.vertical, showsIndicators: true) {
Expand All @@ -28,6 +30,38 @@ struct AdvancedPane: View {

Divider()

SettingsSection(contentSpacing: 10) {
PreferenceToggleRow(
title: L("proxy_enabled_title"),
subtitle: L("proxy_enabled_subtitle"),
binding: self.$settings.proxyEnabled)
TextField(L("proxy_url_placeholder"), text: self.$settings.proxyURL)
.textFieldStyle(.roundedBorder)
.disableAutocorrection(true)
.disabled(!self.settings.proxyEnabled)
.focused(self.$proxyURLFocused)
.onSubmit { ProxyConfigurator.apply(from: self.settings) }
Comment on lines +38 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply proxy changes when editing finishes

With the switch already enabled, editing this field only persists settings.proxyURL; the active ProviderHTTPClient is rebuilt only if the user presses Return. In the common flow of enabling the checkbox, typing a URL, then closing preferences or tabbing/clicking away, traffic continues using the previous/direct session even though the UI shows the new enabled proxy URL.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in commit 10c153e

.onChange(of: self.proxyURLFocused) { _, focused in
// Apply when editing finishes (tab/click away), not only on Return.
if !focused { ProxyConfigurator.apply(from: self.settings) }
}
if let message = self.proxyValidationMessage {
Text(message)
.font(.footnote)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
.onChange(of: self.settings.proxyEnabled) {
ProxyConfigurator.apply(from: self.settings)
}
.onDisappear {
// Safety net for closing Preferences without the field losing focus first.
ProxyConfigurator.apply(from: self.settings)
}

Divider()

SettingsSection(contentSpacing: 10) {
HStack(spacing: 12) {
Button {
Expand Down Expand Up @@ -129,6 +163,14 @@ struct OpenMenuShortcutRecorder: NSViewRepresentable {
}

extension AdvancedPane {
var proxyValidationMessage: String? {
guard self.settings.proxyEnabled else { return nil }
let trimmed = self.settings.proxyURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if (try? ProxyConfiguration.parse(from: trimmed)) != nil { return nil }
return L("proxy_invalid_url")
}

private func installCLI() async {
if self.isInstallingCLI { return }
self.isInstallingCLI = true
Expand Down
29 changes: 29 additions & 0 deletions Sources/CodexBar/ProxyConfigurator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import CodexBarCore
import Foundation

/// Bridges the app-level proxy settings into ``ProviderHTTPClient`` so all provider traffic honors them.
@MainActor
enum ProxyConfigurator {
private static var applied: ProxyConfiguration?
private static var hasApplied = false

/// Resolves the configured proxy, or `nil` when disabled / empty / invalid.
static func resolve(from settings: SettingsStore) -> ProxyConfiguration? {
guard settings.proxyEnabled else { return nil }
let trimmed = settings.proxyURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return try? ProxyConfiguration.parse(from: trimmed)
}

/// Applies the current settings to the shared HTTP client.
///
/// Safe to call liberally (focus loss, submit, disappear): the session is only rebuilt when the
/// resolved configuration actually changes.
static func apply(from settings: SettingsStore) {
let config = self.resolve(from: settings)
guard !self.hasApplied || config != self.applied else { return }
self.applied = config
self.hasApplied = true
ProviderHTTPClient.shared.applyProxyConfiguration(config)
}
}
5 changes: 5 additions & 0 deletions Sources/CodexBar/Resources/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,11 @@
"hide_personal_info_subtitle" = "عناوين بريد إلكتروني غامضة في شريط القائمة وواجهة القائمة.";
"show_provider_storage_usage_title" = "عرض استخدام التخزين لمزود الخدمة";
"show_provider_storage_usage_subtitle" = "عرض استخدام القرص المحلي في القوائم. يمسح المسارات المعروفة المملوكة لمزود الخدمة في الخلفية.";
"proxy_enabled_title" = "وكيل الشبكة";
"proxy_enabled_subtitle" = "عند التشغيل، يوجّه CodexBar جميع طلبات المزوّدين عبر الوكيل أدناه. كما يغطي وكيل http:// حركة مرور HTTPS.";
"proxy_url_placeholder" = "http://127.0.0.1:9000";
"proxy_invalid_url" = "أدخل عنوان وكيل صالحًا، مثل http://127.0.0.1:9000 أو socks5://127.0.0.1:1080.";

"section_keychain_access" = "Keychain الوصول";
"keychain_access_caption" = "قم بتعطيل جميع Keychain القراءة والكتابة. استخدم هذا إذا استمر macOS في طلب 'Chrome/Brave/Edge التخزين الآمن' حتى بعد الضغط على 'دائما السماح'. استيراد ملفات تعريف الارتباط من المتصفح غير متاح أثناء تفعيله؛ الصق رؤوس الكوكيز يدويا في المزودين. Claude/Codex OAuth عبر CLI لا يزال يعمل.";
"disable_keychain_access_title" = "تعطيل Keychain الوصول";
Expand Down
5 changes: 5 additions & 0 deletions Sources/CodexBar/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,11 @@
"hide_personal_info_subtitle" = "Obscure email addresses in the menu bar and menu UI.";
"show_provider_storage_usage_title" = "Show provider storage usage";
"show_provider_storage_usage_subtitle" = "Show local disk usage in menus. Scans known provider-owned paths in the background.";
"proxy_enabled_title" = "Network proxy";
"proxy_enabled_subtitle" = "When on, CodexBar routes all provider requests through the proxy below. An http:// proxy also covers HTTPS.";
"proxy_url_placeholder" = "http://127.0.0.1:9000";
"proxy_invalid_url" = "Enter a valid proxy URL, e.g. http://127.0.0.1:9000 or socks5://127.0.0.1:1080.";

"section_keychain_access" = "Keychain access";
"keychain_access_caption" = "Disable all Keychain reads and writes. Use this if macOS keeps prompting for 'Chrome/Brave/Edge Safe Storage' even after clicking Always Allow. Browser cookie import is unavailable while enabled; paste Cookie headers manually in Providers. Claude/Codex OAuth via the CLI still works.";
"disable_keychain_access_title" = "Disable Keychain access";
Expand Down
5 changes: 5 additions & 0 deletions Sources/CodexBar/Resources/fa.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,11 @@
"hide_personal_info_subtitle" = "آدرس های ایمیل مبهم در نوار منو و رابط کاربری منو.";
"show_provider_storage_usage_title" = "استفاده از ذخیره سازی ارائه دهنده را نمایش دهید";
"show_provider_storage_usage_subtitle" = "استفاده محلی از دیسک را در منوها نمایش دهید. مسیرهای شناخته شده متعلق به ارائه دهندگان را در پس زمینه اسکن می کند.";
"proxy_enabled_title" = "پراکسی شبکه";
"proxy_enabled_subtitle" = "وقتی روشن است، CodexBar همهٔ درخواست‌های ارائه‌دهندگان را از طریق پراکسی زیر هدایت می‌کند. پراکسی http:// ترافیک HTTPS را نیز پوشش می‌دهد.";
"proxy_url_placeholder" = "http://127.0.0.1:9000";
"proxy_invalid_url" = "یک نشانی پراکسی معتبر وارد کنید، مثلاً http://127.0.0.1:9000 یا socks5://127.0.0.1:1080.";

"section_keychain_access" = "دسترسی Keychain";
"keychain_access_caption" = "تمام قابلیت های خواندن و نوشتن Keychain را غیرفعال کنید. اگر macOS حتی بعد از کلیک روی همیشه اجازه مدام درخواست «ذخیره سازی امن» Chrome/Brave/Edge کند، از این گزینه استفاده کنید. وارد کردن کوکی مرورگر در حالت فعال بودن در دسترس نیست؛ هدرهای کوکی را به صورت دستی در Providers پیست کنید. Claude/Codex OAuth از طریق CLI هنوز کار می کند.";
"disable_keychain_access_title" = "غیرفعال کردن دسترسی Keychain";
Expand Down
5 changes: 5 additions & 0 deletions Sources/CodexBar/Resources/th.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,11 @@
"hide_personal_info_subtitle" = "ปิดบังที่อยู่อีเมลในแถบเมนูและ UI เมนู";
"show_provider_storage_usage_title" = "แสดงการใช้พื้นที่เก็บข้อมูลของผู้ให้บริการ";
"show_provider_storage_usage_subtitle" = "แสดงการใช้ดิสก์ในเครื่องในเมนู สแกนเส้นทางที่รู้จักโดยผู้ให้บริการในเบื้องหลัง";
"proxy_enabled_title" = "พร็อกซีเครือข่าย";
"proxy_enabled_subtitle" = "เมื่อเปิด CodexBar จะกำหนดเส้นทางคำขอของผู้ให้บริการทั้งหมดผ่านพร็อกซีด้านล่าง พร็อกซี http:// ครอบคลุมทราฟฟิก HTTPS ด้วย";
"proxy_url_placeholder" = "http://127.0.0.1:9000";
"proxy_invalid_url" = "ป้อน URL พร็อกซีที่ถูกต้อง เช่น http://127.0.0.1:9000 หรือ socks5://127.0.0.1:1080";

"section_keychain_access" = "การเข้าถึง Keychain";
"keychain_access_caption" = "ปิดใช้งานการอ่านและเขียน Keychain ทั้งหมด ใช้ตัวเลือกนี้หาก macOS ยังคงแจ้งให้ 'Chrome/Brave/Edge ที่เก็บข้อมูลที่ปลอดภัย' แม้ว่าจะคลิกอนุญาตเสมอแล้วก็ตาม การนําเข้าคุกกี้ของเบราว์เซอร์ไม่พร้อมใช้งานในขณะที่เปิดใช้งาน วางส่วนหัวของคุกกี้ด้วยตนเองในผู้ให้บริการ Claude/Codex OAuth ผ่าน CLI ยังคงใช้งานได้";
"disable_keychain_access_title" = "ปิดใช้งานการเข้าถึง Keychain";
Expand Down
5 changes: 5 additions & 0 deletions Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,11 @@
"hide_personal_info_subtitle" = "在菜单栏和菜单界面中隐藏电子邮件地址。";
"show_provider_storage_usage_title" = "显示提供商存储用量";
"show_provider_storage_usage_subtitle" = "在菜单中显示本地磁盘用量。会在后台扫描已知的提供商自有路径。";
"proxy_enabled_title" = "网络代理";
"proxy_enabled_subtitle" = "开启后,CodexBar 会将所有提供商请求经由下方代理转发。http:// 代理同样适用于 HTTPS 流量。";
"proxy_url_placeholder" = "http://127.0.0.1:9000";
"proxy_invalid_url" = "请输入有效的代理地址,例如 http://127.0.0.1:9000 或 socks5://127.0.0.1:1080。";

"section_keychain_access" = "钥匙串访问";
"keychain_access_caption" = "禁用所有钥匙串读写。浏览器 Cookie 导入不可用;请在“提供商”中手动粘贴 Cookie 标头。";
"disable_keychain_access_title" = "禁用钥匙串访问";
Expand Down
16 changes: 16 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,22 @@ extension SettingsStore {
}
}

var proxyEnabled: Bool {
get { self.defaultsState.proxyEnabled }
set {
self.defaultsState.proxyEnabled = newValue
self.userDefaults.set(newValue, forKey: "proxyEnabled")
}
}

var proxyURL: String {
get { self.defaultsState.proxyURL }
set {
self.defaultsState.proxyURL = newValue
self.userDefaults.set(newValue, forKey: "proxyURL")
}
}

var mergeIcons: Bool {
get { self.defaultsState.mergeIcons }
set {
Expand Down
6 changes: 5 additions & 1 deletion Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,8 @@ extension SettingsStore {
let providersSortedAlphabetically = userDefaults.object(
forKey: "providersSortedAlphabetically") as? Bool ?? false
let appLanguageRaw = userDefaults.string(forKey: "appLanguage")
let proxyEnabled = userDefaults.object(forKey: "proxyEnabled") as? Bool ?? false
let proxyURL = userDefaults.string(forKey: "proxyURL") ?? ""
return SettingsDefaultsState(
refreshFrequency: refreshFrequency,
launchAtLogin: launchAtLogin,
Expand Down Expand Up @@ -471,7 +473,9 @@ extension SettingsStore {
providerDetectionCompleted: providerDetectionCompleted,
providersSortedAlphabetically: providersSortedAlphabetically,
appLanguageRaw: appLanguageRaw,
terminalAppRaw: userDefaults.string(forKey: "terminalApp"))
terminalAppRaw: userDefaults.string(forKey: "terminalApp"),
proxyEnabled: proxyEnabled,
proxyURL: proxyURL)
}

private static func loadMenuBarMetricPreferences(userDefaults: UserDefaults) -> [String: String] {
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,6 @@ struct SettingsDefaultsState {
var providersSortedAlphabetically: Bool
var appLanguageRaw: String?
var terminalAppRaw: String?
var proxyEnabled: Bool
var proxyURL: String
}
23 changes: 20 additions & 3 deletions Sources/CodexBarCore/ProviderHTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,19 +168,35 @@ extension ProviderHTTPTransport {
public final class ProviderHTTPClient: ProviderHTTPTransport, @unchecked Sendable {
public static let shared = ProviderHTTPClient(session: ProviderHTTPClient.sharedSession())

private let session: URLSession
private let lock = NSLock()
private var session: URLSession

public init(session: URLSession? = nil) {
self.session = session ?? Self.redirectGuardedSession()
}

static func defaultConfiguration() -> URLSessionConfiguration {
/// Rebuilds the underlying session so all subsequent requests use the given proxy.
/// Passing `nil` reverts to a direct (system-proxy) session.
public func applyProxyConfiguration(_ config: ProxyConfiguration?) {
let newSession = Self.redirectGuardedSession(configuration: Self.defaultConfiguration(proxy: config))
let previous = self.lock.withLock { () -> URLSession in
let previous = self.session
self.session = newSession
return previous
}
previous.finishTasksAndInvalidate()
}

static func defaultConfiguration(proxy: ProxyConfiguration? = nil) -> URLSessionConfiguration {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 90
#if !os(Linux)
configuration.waitsForConnectivity = false
#endif
if let proxy {
configuration.connectionProxyDictionary = proxy.connectionProxyDictionary()
}
return configuration
}

Expand Down Expand Up @@ -213,7 +229,8 @@ public final class ProviderHTTPClient: ProviderHTTPTransport, @unchecked Sendabl
}

public func data(for request: URLRequest) async throws -> (Data, URLResponse) {
try await self.session.data(for: request)
let session = self.lock.withLock { self.session }
return try await session.data(for: request)
}
}

Expand Down
110 changes: 110 additions & 0 deletions Sources/CodexBarCore/ProxyConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

/// A parsed, validated outbound proxy configuration applied globally to ``ProviderHTTPClient``.
///
/// Authentication is intentionally unsupported: credentials embedded in the URL are ignored.
public struct ProxyConfiguration: Sendable, Equatable {
public enum ProxyType: Sendable, Equatable {
case http
case socks
}

public let type: ProxyType
public let host: String
public let port: Int

public init(type: ProxyType, host: String, port: Int) {
self.type = type
self.host = host
self.port = port
}

/// Parses a proxy URL such as `http://127.0.0.1:8080` or `socks5://127.0.0.1:1080`.
/// Any user-info component is ignored — proxy authentication is not supported.
public static func parse(from urlString: String) throws -> ProxyConfiguration {
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw ProxyConfigurationError.empty }

guard let components = URLComponents(string: trimmed),
let scheme = components.scheme?.lowercased(), !scheme.isEmpty
else {
throw ProxyConfigurationError.badScheme("")
}

let type: ProxyType
let defaultPort: Int
switch scheme {
case "http", "https":
type = .http
defaultPort = 8080
case "socks", "socks5":
type = .socks
defaultPort = 1080
default:
throw ProxyConfigurationError.badScheme(scheme)
}

guard let host = components.host, !host.isEmpty else {
throw ProxyConfigurationError.badHost
}

let port = components.port ?? defaultPort
guard (1...65535).contains(port) else { throw ProxyConfigurationError.badPort }

return ProxyConfiguration(type: type, host: host, port: port)
}

/// The `URLSessionConfiguration.connectionProxyDictionary` representation.
///
/// For an HTTP proxy both the HTTP and HTTPS keys are set to the same host/port, because nearly all
/// provider endpoints are `https://`.
public func connectionProxyDictionary() -> [AnyHashable: Any] {
#if os(Linux)
// Linux/CLI relies on http_proxy/https_proxy environment variables instead.
return [:]
#else
switch self.type {
case .http:
return [
kCFNetworkProxiesHTTPEnable as String: true,
kCFNetworkProxiesHTTPProxy as String: self.host,
kCFNetworkProxiesHTTPPort as String: self.port,
kCFNetworkProxiesHTTPSEnable as String: true,
kCFNetworkProxiesHTTPSProxy as String: self.host,
kCFNetworkProxiesHTTPSPort as String: self.port,
]
case .socks:
return [
kCFNetworkProxiesSOCKSEnable as String: true,
kCFNetworkProxiesSOCKSProxy as String: self.host,
kCFNetworkProxiesSOCKSPort as String: self.port,
]
}
#endif
}
}

public enum ProxyConfigurationError: LocalizedError, Equatable {
case empty
case badScheme(String)
case badHost
case badPort

public var errorDescription: String? {
switch self {
case .empty:
"Proxy URL is empty."
case let .badScheme(scheme):
scheme.isEmpty
? "Proxy URL is missing a scheme. Use http://, https://, or socks5://."
: "Unsupported proxy scheme “\(scheme)”. Use http://, https://, or socks5://."
case .badHost:
"Proxy URL is missing a valid host."
case .badPort:
"Proxy URL has an invalid port."
}
}
}
Loading