Skip to content

Commit afe6972

Browse files
feat: allow custom cloudflared binary path (#96)
Follows the existing pattern used for kubectl and socat custom paths. Users can now specify a custom cloudflared path in Settings when it's installed via MacPorts or other non-Homebrew package managers. Fixes #90 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3066aee commit afe6972

3 files changed

Lines changed: 122 additions & 15 deletions

File tree

platforms/macos/Sources/Managers/PortForwardManager.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ extension Defaults.Keys {
99
static let portForwardShowNotifications = Key<Bool>("portForwardShowNotifications", default: true)
1010
static let customKubectlPath = Key<String?>("customKubectlPath", default: nil)
1111
static let customSocatPath = Key<String?>("customSocatPath", default: nil)
12+
static let customCloudflaredPath = Key<String?>("customCloudflaredPath", default: nil)
1213
}
1314

1415
// MARK: - Port Forward Manager

platforms/macos/Sources/Services/CloudflaredService.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ actor CloudflaredService {
1414
// MARK: - Dependency Check
1515

1616
nonisolated var cloudflaredPath: String? {
17+
// Check custom path first
18+
if let custom = Defaults[.customCloudflaredPath],
19+
!custom.isEmpty,
20+
FileManager.default.fileExists(atPath: custom) {
21+
return custom
22+
}
1723
let paths = [
1824
"/opt/homebrew/bin/cloudflared",
1925
"/usr/local/bin/cloudflared"
@@ -25,6 +31,23 @@ actor CloudflaredService {
2531
cloudflaredPath != nil
2632
}
2733

34+
nonisolated var isUsingCustomPath: Bool {
35+
if let custom = Defaults[.customCloudflaredPath],
36+
!custom.isEmpty,
37+
FileManager.default.fileExists(atPath: custom) {
38+
return true
39+
}
40+
return false
41+
}
42+
43+
nonisolated var autoDetectedPath: String? {
44+
let paths = [
45+
"/opt/homebrew/bin/cloudflared",
46+
"/usr/local/bin/cloudflared"
47+
]
48+
return paths.first { FileManager.default.fileExists(atPath: $0) }
49+
}
50+
2851
// MARK: - Handler Management
2952

3053
func setURLHandler(for id: UUID, handler: @escaping @Sendable (String) -> Void) {

platforms/macos/Sources/Views/Settings/CloudflaredSettingsSection.swift

Lines changed: 98 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,112 @@ import Defaults
88

99
struct CloudflaredSettingsSection: View {
1010
@Default(.cloudflaredProtocol) private var protocolSelection
11+
@Default(.customCloudflaredPath) private var customPath
12+
@State private var pathInput = ""
13+
14+
private let service = CloudflaredService()
15+
16+
private var effectivePath: String? {
17+
if let custom = customPath, !custom.isEmpty, FileManager.default.fileExists(atPath: custom) {
18+
return custom
19+
}
20+
return service.autoDetectedPath
21+
}
1122

1223
var body: some View {
1324
SettingsGroup("Cloudflare Tunnels", icon: "cloud.fill") {
14-
SettingsRowContainer {
15-
HStack {
16-
VStack(alignment: .leading, spacing: 2) {
17-
Text("Tunnel protocol")
18-
.fontWeight(.medium)
19-
Text("Choose how cloudflared connects to Cloudflare (applies to new tunnels)")
20-
.font(.caption)
21-
.foregroundStyle(.secondary)
25+
VStack(spacing: 0) {
26+
SettingsRowContainer {
27+
HStack {
28+
VStack(alignment: .leading, spacing: 2) {
29+
Text("Tunnel protocol")
30+
.fontWeight(.medium)
31+
Text("Choose how cloudflared connects to Cloudflare (applies to new tunnels)")
32+
.font(.caption)
33+
.foregroundStyle(.secondary)
34+
}
35+
36+
Spacer()
37+
38+
Picker("", selection: $protocolSelection) {
39+
ForEach(CloudflaredProtocol.allCases, id: \.self) { option in
40+
Text(option.displayName).tag(option)
41+
}
42+
}
43+
.labelsHidden()
44+
.pickerStyle(.segmented)
45+
.frame(width: 160)
2246
}
47+
}
48+
49+
SettingsDivider()
50+
51+
SettingsRowContainer {
52+
VStack(alignment: .leading, spacing: 10) {
53+
HStack {
54+
Text("cloudflared path")
55+
.fontWeight(.medium)
56+
57+
Spacer()
2358

24-
Spacer()
59+
if effectivePath != nil {
60+
HStack(spacing: 4) {
61+
Image(systemName: "checkmark.circle.fill")
62+
.foregroundStyle(.green)
63+
Text("Installed")
64+
.font(.caption)
65+
.foregroundStyle(.secondary)
66+
}
67+
} else {
68+
HStack(spacing: 4) {
69+
Image(systemName: "xmark.circle.fill")
70+
.foregroundStyle(.red)
71+
Text("Not found")
72+
.font(.caption)
73+
.foregroundStyle(.secondary)
74+
}
75+
}
76+
}
77+
78+
HStack(spacing: 8) {
79+
TextField("Custom path (leave empty for auto)", text: $pathInput)
80+
.textFieldStyle(.roundedBorder)
81+
.font(.system(.caption, design: .monospaced))
82+
.onAppear {
83+
pathInput = customPath ?? ""
84+
}
85+
.onChange(of: pathInput) { _, newValue in
86+
if newValue.isEmpty {
87+
Defaults[.customCloudflaredPath] = nil
88+
} else {
89+
Defaults[.customCloudflaredPath] = newValue
90+
}
91+
}
92+
93+
if !pathInput.isEmpty {
94+
Button("Clear") {
95+
pathInput = ""
96+
Defaults[.customCloudflaredPath] = nil
97+
}
98+
.buttonStyle(.bordered)
99+
.controlSize(.small)
100+
}
101+
}
25102

26-
Picker("", selection: $protocolSelection) {
27-
ForEach(CloudflaredProtocol.allCases, id: \.self) { option in
28-
Text(option.displayName).tag(option)
103+
if let path = effectivePath {
104+
HStack(spacing: 4) {
105+
Text("Using:")
106+
.font(.caption)
107+
.foregroundStyle(.tertiary)
108+
Text(path)
109+
.font(.system(.caption, design: .monospaced))
110+
.foregroundStyle(.secondary)
111+
Text(service.isUsingCustomPath ? "(custom)" : "(auto)")
112+
.font(.caption2)
113+
.foregroundStyle(service.isUsingCustomPath ? Color.orange : Color.gray)
114+
}
29115
}
30116
}
31-
.labelsHidden()
32-
.pickerStyle(.segmented)
33-
.frame(width: 160)
34117
}
35118
}
36119
}

0 commit comments

Comments
 (0)