Skip to content

Commit ca540b5

Browse files
committed
Add proxy URL settings and config handling
1 parent 2158432 commit ca540b5

File tree

3 files changed

+168
-27
lines changed

3 files changed

+168
-27
lines changed

src/Sources/AppDelegate.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, UNUserNoti
195195
settingsWindow = window
196196
}
197197

198-
func windowDidClose(_ notification: Notification) {
198+
func windowWillClose(_ notification: Notification) {
199199
if notification.object as? NSWindow === settingsWindow {
200200
settingsWindow = nil
201201
}

src/Sources/ServerManager.swift

Lines changed: 105 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ class ServerManager: ObservableObject {
5555
UserDefaults.standard.set(enabledProviders, forKey: "enabledProviders")
5656
}
5757
}
58+
@Published var proxyURL: String = "" {
59+
didSet {
60+
UserDefaults.standard.set(proxyURL, forKey: "proxyURL")
61+
}
62+
}
63+
private var activeConfigPath: String = ""
5864

5965
/// Vercel AI Gateway configuration for Claude requests
6066
@Published var vercelGatewayEnabled: Bool = false {
@@ -104,21 +110,51 @@ class ServerManager: ObservableObject {
104110
}
105111
vercelGatewayEnabled = UserDefaults.standard.bool(forKey: "vercelGatewayEnabled")
106112
vercelApiKey = UserDefaults.standard.string(forKey: "vercelApiKey") ?? ""
113+
if let savedProxyURL = UserDefaults.standard.string(forKey: "proxyURL") {
114+
proxyURL = savedProxyURL
115+
}
107116
}
108117

109118
/// Check if a provider is enabled (defaults to true if not set)
110119
func isProviderEnabled(_ providerKey: String) -> Bool {
111120
return enabledProviders[providerKey] ?? true
112121
}
113122

114-
/// Set provider enabled state and regenerate config (hot reload - no restart needed)
123+
/// Set provider enabled state and regenerate config (hot reload where possible)
115124
func setProviderEnabled(_ providerKey: String, enabled: Bool) {
116125
enabledProviders[providerKey] = enabled
117126
addLog(enabled ? "✓ Enabled provider: \(providerKey)" : "⚠️ Disabled provider: \(providerKey)")
118127

119-
// Regenerate config - CLIProxyAPI hot reloads config.yaml automatically
120-
_ = getConfigPath()
121-
addLog("Config updated (hot reload)")
128+
applyConfigUpdate()
129+
}
130+
131+
func setProxyURL(_ url: String) {
132+
let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
133+
guard trimmed != proxyURL else { return }
134+
proxyURL = trimmed
135+
addLog(trimmed.isEmpty ? "Proxy URL cleared" : "Proxy URL updated")
136+
137+
applyConfigUpdate()
138+
}
139+
140+
private func applyConfigUpdate() {
141+
let newConfigPath = getConfigPath()
142+
guard !newConfigPath.isEmpty else { return }
143+
144+
if isRunning {
145+
let currentPath = activeConfigPath.isEmpty ? newConfigPath : activeConfigPath
146+
if currentPath != newConfigPath {
147+
activeConfigPath = newConfigPath
148+
addLog("Config path changed; restarting server to apply update")
149+
stop { [weak self] in
150+
self?.start { _ in }
151+
}
152+
return
153+
}
154+
addLog("Config updated (hot reload)")
155+
}
156+
157+
activeConfigPath = newConfigPath
122158
}
123159

124160
deinit {
@@ -198,6 +234,7 @@ class ServerManager: ObservableObject {
198234

199235
do {
200236
try process?.run()
237+
activeConfigPath = configPath
201238
DispatchQueue.main.async {
202239
self.isRunning = true
203240
}
@@ -255,6 +292,7 @@ class ServerManager: ObservableObject {
255292
DispatchQueue.main.async {
256293
self.process = nil
257294
self.isRunning = false
295+
self.activeConfigPath = ""
258296
self.addLog("✓ Server stopped")
259297
NotificationCenter.default.post(name: .serverStatusChanged, object: nil)
260298
completion?()
@@ -278,8 +316,11 @@ class ServerManager: ObservableObject {
278316
let authProcess = Process()
279317
authProcess.executableURL = URL(fileURLWithPath: bundledPath)
280318

281-
// Get the config path
282-
let configPath = (resourcePath as NSString).appendingPathComponent("config.yaml")
319+
let configPath = getConfigPath()
320+
guard !configPath.isEmpty && FileManager.default.fileExists(atPath: configPath) else {
321+
completion(false, "Config not found")
322+
return
323+
}
283324

284325
var qwenEmail: String?
285326

@@ -429,12 +470,12 @@ class ServerManager: ObservableObject {
429470
completion(true, "🌐 Browser opened for authentication.\n\nPlease complete the login in your browser.\n\nThe app will automatically detect when you're authenticated.")
430471
} else {
431472
// Process died quickly - check for error
432-
let outputData = try? outputPipe.fileHandleForReading.readDataToEndOfFile()
433-
let errorData = try? errorPipe.fileHandleForReading.readDataToEndOfFile()
473+
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
474+
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
434475

435-
var output = String(data: outputData ?? Data(), encoding: .utf8) ?? ""
476+
var output = String(data: outputData, encoding: .utf8) ?? ""
436477
if output.isEmpty { output = capture.text }
437-
let error = String(data: errorData ?? Data(), encoding: .utf8) ?? ""
478+
let error = String(data: errorData, encoding: .utf8) ?? ""
438479

439480
NSLog("[Auth] Process died quickly - output: %@", output.isEmpty ? "(empty)" : String(output.prefix(200)))
440481

@@ -517,14 +558,20 @@ class ServerManager: ObservableObject {
517558
}
518559
}
519560

520-
/// Returns the config path to use, merging bundled config with Z.AI provider and provider exclusions
561+
/// Returns the config path to use, merging bundled config with proxy settings, Z.AI provider, and provider exclusions
521562
func getConfigPath() -> String {
522563
guard let resourcePath = Bundle.main.resourcePath else {
523564
return ""
524565
}
525566

526567
let bundledConfigPath = (resourcePath as NSString).appendingPathComponent("config.yaml")
527568
let authDir = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".cli-proxy-api")
569+
let mergedConfigPath = authDir.appendingPathComponent("merged-config.yaml")
570+
do {
571+
try FileManager.default.createDirectory(at: authDir, withIntermediateDirectories: true)
572+
} catch {
573+
NSLog("[ServerManager] Failed to create auth directory: %@", error.localizedDescription)
574+
}
528575

529576
// Check for Z.AI auth files
530577
var zaiApiKeys: [String] = []
@@ -546,20 +593,31 @@ class ServerManager: ObservableObject {
546593
}
547594
}
548595

549-
// If no Z.AI keys and no disabled providers, use bundled config
550-
guard !zaiApiKeys.isEmpty || !disabledProviders.isEmpty else {
596+
let trimmedProxyURL = proxyURL.trimmingCharacters(in: .whitespacesAndNewlines)
597+
let shouldAddProxy = !trimmedProxyURL.isEmpty
598+
let shouldAddProviders = !disabledProviders.isEmpty
599+
let shouldAddZai = !zaiApiKeys.isEmpty && isProviderEnabled("zai")
600+
601+
if !shouldAddProxy && !shouldAddProviders && !shouldAddZai {
602+
if FileManager.default.fileExists(atPath: mergedConfigPath.path) {
603+
try? FileManager.default.removeItem(at: mergedConfigPath)
604+
}
551605
return bundledConfigPath
552606
}
553607

554608
// Generate merged config
555-
guard let bundledContent = try? String(contentsOfFile: bundledConfigPath, encoding: .utf8) else {
609+
guard var bundledContent = try? String(contentsOfFile: bundledConfigPath, encoding: .utf8) else {
556610
return bundledConfigPath
557611
}
558-
612+
613+
if shouldAddProxy {
614+
bundledContent = applyProxyURLOverride(to: bundledContent, proxyURL: trimmedProxyURL)
615+
}
616+
559617
var additionalConfig = ""
560618

561619
// Build oauth-excluded-models section for disabled providers
562-
if !disabledProviders.isEmpty {
620+
if shouldAddProviders {
563621
additionalConfig += """
564622
565623
# Provider exclusions (auto-added by VibeProxy)
@@ -574,7 +632,7 @@ oauth-excluded-models:
574632
}
575633

576634
// Build Z.AI openai-compatibility section (only if Z.AI is enabled)
577-
if !zaiApiKeys.isEmpty && isProviderEnabled("zai") {
635+
if shouldAddZai {
578636
additionalConfig += """
579637
580638
# Z.AI GLM Provider (auto-added by VibeProxy)
@@ -585,12 +643,7 @@ openai-compatibility:
585643
586644
"""
587645
for key in zaiApiKeys {
588-
// Escape special YAML characters in double-quoted strings
589-
let escapedKey = key
590-
.replacingOccurrences(of: "\\", with: "\\\\")
591-
.replacingOccurrences(of: "\"", with: "\\\"")
592-
.replacingOccurrences(of: "\n", with: "\\n")
593-
.replacingOccurrences(of: "\t", with: "\\t")
646+
let escapedKey = escapeYAMLDoubleQuoted(key)
594647
additionalConfig += " - api-key: \"\(escapedKey)\"\n"
595648
}
596649
additionalConfig += """
@@ -607,7 +660,6 @@ openai-compatibility:
607660
}
608661

609662
let mergedContent = bundledContent + additionalConfig
610-
let mergedConfigPath = authDir.appendingPathComponent("merged-config.yaml")
611663

612664
do {
613665
try mergedContent.write(to: mergedConfigPath, atomically: true, encoding: .utf8)
@@ -619,6 +671,35 @@ openai-compatibility:
619671
return bundledConfigPath
620672
}
621673
}
674+
675+
private func applyProxyURLOverride(to content: String, proxyURL: String) -> String {
676+
let escapedURL = escapeYAMLDoubleQuoted(proxyURL)
677+
let newLine = "proxy-url: \"\(escapedURL)\""
678+
var didReplace = false
679+
let lines = content.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline)
680+
let updated = lines.map { line -> String in
681+
let trimmed = String(line).trimmingCharacters(in: .whitespaces)
682+
if trimmed.hasPrefix("proxy-url:") {
683+
didReplace = true
684+
let indent = line.prefix { $0 == " " || $0 == "\t" }
685+
return "\(indent)\(newLine)"
686+
}
687+
return String(line)
688+
}
689+
var result = updated.joined(separator: "\n")
690+
if !didReplace {
691+
result += "\n\n# Network proxy (auto-added by VibeProxy)\n\(newLine)\n"
692+
}
693+
return result
694+
}
695+
696+
private func escapeYAMLDoubleQuoted(_ value: String) -> String {
697+
value
698+
.replacingOccurrences(of: "\\", with: "\\\\")
699+
.replacingOccurrences(of: "\"", with: "\\\"")
700+
.replacingOccurrences(of: "\n", with: "\\n")
701+
.replacingOccurrences(of: "\t", with: "\\t")
702+
}
622703

623704
func getLogs() -> [String] {
624705
return logBuffer.elements()

src/Sources/SettingsView.swift

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@ struct SettingsView: View {
244244
@State private var zaiApiKey = ""
245245
@State private var pendingRefresh: DispatchWorkItem?
246246
@State private var expandedRowCount = 0
247+
@State private var proxyURLInput = ""
248+
@State private var isNetworkExpanded = false
247249

248250
private enum Timing {
249251
static let serverRestartDelay: TimeInterval = 0.3
@@ -257,6 +259,10 @@ struct SettingsView: View {
257259
return ""
258260
}
259261

262+
private var trimmedProxyURLInput: String {
263+
proxyURLInput.trimmingCharacters(in: .whitespacesAndNewlines)
264+
}
265+
260266
var body: some View {
261267
VStack(spacing: 0) {
262268
Form {
@@ -288,6 +294,49 @@ struct SettingsView: View {
288294
toggleLaunchAtLogin(newValue)
289295
}
290296

297+
DisclosureGroup(isExpanded: $isNetworkExpanded) {
298+
VStack(alignment: .leading, spacing: 8) {
299+
HStack(alignment: .center, spacing: 8) {
300+
Text("Proxy URL")
301+
.frame(width: 80, alignment: .leading)
302+
TextField("", text: $proxyURLInput)
303+
.textFieldStyle(.roundedBorder)
304+
.frame(maxWidth: .infinity)
305+
.multilineTextAlignment(.leading)
306+
}
307+
HStack(spacing: 8) {
308+
Button("Apply") {
309+
applyProxyURL()
310+
}
311+
.disabled(trimmedProxyURLInput == serverManager.proxyURL)
312+
Button("Clear") {
313+
clearProxyURL()
314+
}
315+
.disabled(proxyURLInput.isEmpty && serverManager.proxyURL.isEmpty)
316+
Spacer()
317+
}
318+
Text("Leave empty to disable. Example: http://proxy.local:8080 or socks5://127.0.0.1:1080")
319+
.font(.caption)
320+
.foregroundColor(.secondary)
321+
}
322+
.frame(maxWidth: .infinity, alignment: .leading)
323+
.padding(.top, 6)
324+
} label: {
325+
HStack {
326+
Text("Network")
327+
Spacer()
328+
if !serverManager.proxyURL.isEmpty {
329+
Text("Configured")
330+
.font(.caption)
331+
.foregroundColor(.secondary)
332+
}
333+
}
334+
.contentShape(Rectangle())
335+
.onTapGesture {
336+
isNetworkExpanded.toggle()
337+
}
338+
}
339+
291340
HStack {
292341
Text("Auth files")
293342
Spacer()
@@ -400,7 +449,6 @@ struct SettingsView: View {
400449
}
401450
}
402451
.formStyle(.grouped)
403-
.scrollDisabled(expandedRowCount == 0)
404452

405453
Spacer()
406454
.frame(height: 6)
@@ -451,7 +499,7 @@ struct SettingsView: View {
451499
}
452500
.padding(.bottom, 12)
453501
}
454-
.frame(width: 480, height: 740)
502+
.frame(width: 480, height: 800)
455503
.sheet(isPresented: $showingQwenEmailPrompt) {
456504
VStack(spacing: 16) {
457505
Text("Qwen Account Email")
@@ -507,6 +555,8 @@ struct SettingsView: View {
507555
.onAppear {
508556
authManager.checkAuthStatus()
509557
checkLaunchAtLogin()
558+
proxyURLInput = serverManager.proxyURL
559+
isNetworkExpanded = false
510560
startMonitoringAuthDirectory()
511561
}
512562
.onDisappear {
@@ -526,6 +576,16 @@ struct SettingsView: View {
526576
NSWorkspace.shared.open(authDir)
527577
}
528578

579+
private func applyProxyURL() {
580+
serverManager.setProxyURL(proxyURLInput)
581+
proxyURLInput = serverManager.proxyURL
582+
}
583+
584+
private func clearProxyURL() {
585+
proxyURLInput = ""
586+
applyProxyURL()
587+
}
588+
529589
private func toggleLaunchAtLogin(_ enabled: Bool) {
530590
if #available(macOS 13.0, *) {
531591
do {

0 commit comments

Comments
 (0)