poke-gate 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.github/workflows/release.yml +53 -3
  2. package/README.md +48 -14
  3. package/bin/poke-gate.js +17 -0
  4. package/clients/Poke macOS Gate/Poke macOS Gate/AboutView.swift +7 -1
  5. package/clients/Poke macOS Gate/Poke macOS Gate/AccessibilityPermissionView.swift +58 -0
  6. package/clients/Poke macOS Gate/Poke macOS Gate/GateService.swift +354 -23
  7. package/clients/Poke macOS Gate/Poke macOS Gate/Info.plist +2 -0
  8. package/clients/Poke macOS Gate/Poke macOS Gate/LogsView.swift +1 -1
  9. package/clients/Poke macOS Gate/Poke macOS Gate/MacVisualStyle.swift +89 -0
  10. package/clients/Poke macOS Gate/Poke macOS Gate/PermissionRowView.swift +55 -0
  11. package/clients/Poke macOS Gate/Poke macOS Gate/Poke_macOS_GateApp.swift +241 -91
  12. package/clients/Poke macOS Gate/Poke macOS Gate/SettingsView.swift +131 -65
  13. package/clients/Poke macOS Gate/Poke macOS Gate/SetupView.swift +157 -0
  14. package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/project.pbxproj +28 -8
  15. package/docs/cli.md +19 -0
  16. package/docs/getting-started.md +9 -6
  17. package/docs/index.md +23 -18
  18. package/docs/macos-app.md +39 -4
  19. package/docs/security.md +62 -18
  20. package/examples/agents/battery.30m.js +1 -1
  21. package/examples/agents/screentime.24h.js +5 -6
  22. package/package.json +3 -1
  23. package/src/agents.js +5 -8
  24. package/src/app.js +3 -3
  25. package/src/mcp-server.js +502 -27
  26. package/src/permission-service.js +128 -0
  27. package/test/mcp-server-access-policy.test.js +40 -0
  28. package/test/mcp-server-loop-guard.test.js +57 -0
  29. package/test/mcp-server-sandbox-command.test.js +18 -0
  30. package/test/permission-service.test.js +97 -0
@@ -1,4 +1,5 @@
1
1
  import SwiftUI
2
+ import ServiceManagement
2
3
 
3
4
  class AppDelegate: NSObject, NSApplicationDelegate {
4
5
  var service: GateService?
@@ -17,13 +18,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
17
18
  struct Poke_macOS_GateApp: App {
18
19
  @StateObject private var service = GateService()
19
20
  @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
20
-
21
21
  var body: some Scene {
22
22
  MenuBarExtra {
23
23
  PopoverContent(service: service)
24
24
  .onAppear {
25
25
  service.autoStartIfNeeded()
26
26
  appDelegate.service = service
27
+ service.startPermissionPolling()
28
+ }
29
+ .onDisappear {
30
+ service.stopPermissionPolling()
27
31
  }
28
32
  } label: {
29
33
  Image(systemName: menuBarIcon)
@@ -35,6 +39,11 @@ struct Poke_macOS_GateApp: App {
35
39
  }
36
40
  .defaultSize(width: 560, height: 400)
37
41
 
42
+ Window("Setup", id: "setup") {
43
+ SetupView(service: service)
44
+ }
45
+ .windowResizability(.contentSize)
46
+
38
47
  Window("Settings", id: "settings") {
39
48
  SettingsView(service: service)
40
49
  }
@@ -51,6 +60,37 @@ struct Poke_macOS_GateApp: App {
51
60
  .windowResizability(.contentSize)
52
61
  }
53
62
 
63
+ private func checkLoginItemPrompt() {
64
+ let dismissed = UserDefaults.standard.bool(forKey: "loginItemPromptDismissed")
65
+ let alreadyEnabled = SMAppService.mainApp.status == .enabled
66
+ if dismissed || alreadyEnabled { return }
67
+
68
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
69
+ let alert = NSAlert()
70
+ alert.messageText = "Start on login?"
71
+ alert.informativeText = "Would you like Poke Gate to start automatically when you log in?"
72
+ alert.addButton(withTitle: "Enable")
73
+ alert.addButton(withTitle: "Not now")
74
+ alert.addButton(withTitle: "Don't ask again")
75
+ alert.alertStyle = .informational
76
+
77
+ NSApp.activate(ignoringOtherApps: true)
78
+ let response = alert.runModal()
79
+
80
+ switch response {
81
+ case .alertFirstButtonReturn:
82
+ try? SMAppService.mainApp.register()
83
+ UserDefaults.standard.set(true, forKey: "loginItemPromptDismissed")
84
+ case .alertSecondButtonReturn:
85
+ break
86
+ case .alertThirdButtonReturn:
87
+ UserDefaults.standard.set(true, forKey: "loginItemPromptDismissed")
88
+ default:
89
+ break
90
+ }
91
+ }
92
+ }
93
+
54
94
  private var menuBarIcon: String {
55
95
  switch service.status {
56
96
  case .connected: "door.left.hand.open"
@@ -64,56 +104,89 @@ struct Poke_macOS_GateApp: App {
64
104
  struct PopoverContent: View {
65
105
  @ObservedObject var service: GateService
66
106
  @Environment(\.openWindow) private var openWindow
107
+ @State private var pendingFullMode = false
67
108
 
68
109
  var body: some View {
69
- VStack(spacing: 0) {
70
- VStack(spacing: 6) {
71
- HStack(spacing: 8) {
72
- Circle()
73
- .fill(statusColor)
74
- .frame(width: 10, height: 10)
110
+ if !service.hasCompletedSetup {
111
+ SetupView(service: service)
112
+ } else {
113
+ VStack(spacing: 10) {
114
+ statusSection
115
+ recentActivitySection
116
+ accessModeSection
117
+ actionsSection
118
+ footerSection
119
+ }
120
+ .frame(width: 320)
121
+ .padding(10)
122
+ .onChange(of: service.hasSystemPermissionsGranted) { _, granted in
123
+ if granted && pendingFullMode {
124
+ pendingFullMode = false
125
+ service.setPermissionMode(.full)
126
+ }
127
+ }
128
+ }
129
+ }
75
130
 
76
- Text(statusText)
77
- .font(.system(.body, weight: .medium))
131
+ private var statusSection: some View {
132
+ VStack(alignment: .leading, spacing: 6) {
133
+ HStack(spacing: 8) {
134
+ Circle()
135
+ .fill(statusColor)
136
+ .frame(width: 10, height: 10)
78
137
 
79
- Spacer()
80
- }
138
+ Text(statusText)
139
+ .font(.system(.body, weight: .medium))
81
140
 
82
- if service.status == .connected {
83
- Text("This machine is accessible via Poke. Ask your Poke to run commands or read files.")
84
- .font(.caption)
85
- .foregroundStyle(.secondary)
86
- .frame(maxWidth: .infinity, alignment: .leading)
87
- .fixedSize(horizontal: false, vertical: true)
88
- } else if service.status == .starting {
89
- Text("Establishing connection…")
90
- .font(.caption)
91
- .foregroundStyle(.secondary)
92
- .frame(maxWidth: .infinity, alignment: .leading)
93
- } else if service.status == .error {
94
- Text("Check Logs for details.")
95
- .font(.caption)
96
- .foregroundStyle(.red.opacity(0.8))
97
- .frame(maxWidth: .infinity, alignment: .leading)
98
- }
141
+ Spacer()
99
142
  }
100
- .padding(12)
101
143
 
102
- Divider()
144
+ statusMessage
145
+ }
146
+ .frame(maxWidth: .infinity, alignment: .leading)
147
+ .padding(12)
148
+ .macPanelStyle(.neutral, cornerRadius: 12)
149
+ }
103
150
 
104
- VStack(alignment: .leading, spacing: 4) {
105
- Text("Recent activity")
106
- .font(.caption2)
107
- .foregroundStyle(.tertiary)
108
- .textCase(.uppercase)
109
-
110
- if service.logs.isEmpty {
111
- Text("No activity yet")
112
- .font(.caption)
113
- .foregroundStyle(.secondary)
114
- } else {
115
- ForEach(Array(service.logs.suffix(4).enumerated()), id: \.offset) { _, line in
116
- Text(line)
151
+ @ViewBuilder
152
+ private var statusMessage: some View {
153
+ switch service.status {
154
+ case .connected:
155
+ Text("This machine is accessible via Poke. Ask your Poke to run commands or read files.")
156
+ .font(.caption)
157
+ .foregroundStyle(.secondary)
158
+ .frame(maxWidth: .infinity, alignment: .leading)
159
+ .fixedSize(horizontal: false, vertical: true)
160
+ case .starting:
161
+ Text("Establishing connection…")
162
+ .font(.caption)
163
+ .foregroundStyle(.secondary)
164
+ .frame(maxWidth: .infinity, alignment: .leading)
165
+ case .error:
166
+ Text("Check Logs for details.")
167
+ .font(.caption)
168
+ .foregroundStyle(.red.opacity(0.8))
169
+ .frame(maxWidth: .infinity, alignment: .leading)
170
+ case .disconnected, .stopped:
171
+ EmptyView()
172
+ }
173
+ }
174
+
175
+ private var recentActivitySection: some View {
176
+ VStack(alignment: .leading, spacing: 6) {
177
+ sectionTitle("Recent activity")
178
+
179
+ if service.terminalPreviews.isEmpty {
180
+ Text("No activity yet")
181
+ .font(.caption)
182
+ .foregroundStyle(.secondary)
183
+ } else {
184
+ ForEach(Array(service.terminalPreviews.suffix(4).enumerated()), id: \.element.id) { _, entry in
185
+ HStack(spacing: 6) {
186
+ Circle()
187
+ .fill(entry.exitCode == 0 ? Color.green : (entry.exitCode == nil ? Color.gray : Color.red))
188
+ .frame(width: 5, height: 5)
189
+ Text("$ \(entry.command)")
117
190
  .font(.system(size: 9, design: .monospaced))
118
191
  .foregroundStyle(.tertiary)
119
192
  .lineLimit(1)
@@ -121,71 +194,117 @@ struct PopoverContent: View {
121
194
  }
122
195
  }
123
196
  }
124
- .frame(maxWidth: .infinity, alignment: .leading)
125
- .padding(12)
197
+ }
198
+ .frame(maxWidth: .infinity, alignment: .leading)
199
+ .padding(12)
200
+ .macPanelStyle(.neutral, cornerRadius: 12)
201
+ }
126
202
 
127
- Divider()
203
+ private var accessModeSection: some View {
204
+ VStack(alignment: .leading, spacing: 8) {
205
+ HStack {
206
+ sectionTitle("Access mode")
207
+ Spacer()
208
+ Text(service.permissionMode.title)
209
+ .font(.caption2)
210
+ .foregroundStyle(.secondary)
211
+ }
128
212
 
129
- HStack(spacing: 12) {
130
- ActionButton(icon: "text.alignleft", label: "Logs") {
131
- NSApp.activate(ignoringOtherApps: true)
132
- openWindow(id: "logs")
213
+ HStack(spacing: 6) {
214
+ ForEach(GateService.PermissionMode.allCases) { mode in
215
+ let isActive = service.permissionMode == mode || (mode == .full && pendingFullMode)
216
+ Button {
217
+ handleModeSelection(mode)
218
+ } label: {
219
+ Text(modeChipTitle(mode))
220
+ .font(.system(size: 10, weight: .semibold))
221
+ .foregroundStyle(isActive ? .white : .secondary)
222
+ .padding(.vertical, 5)
223
+ .padding(.horizontal, 8)
224
+ .background(isActive ? MacVisualStyle.chipActiveFill : MacVisualStyle.chipInactiveFill)
225
+ .clipShape(Capsule())
226
+ }
227
+ .buttonStyle(.plain)
133
228
  }
229
+ }
134
230
 
135
- ActionButton(icon: "bolt.fill", label: "Agents") {
136
- NSApp.activate(ignoringOtherApps: true)
137
- openWindow(id: "agents")
138
- }
231
+ if service.permissionMode == .full || pendingFullMode {
232
+ AccessibilityPermissionView(service: service)
233
+ }
234
+ }
235
+ .frame(maxWidth: .infinity, alignment: .leading)
236
+ .padding(12)
237
+ .macPanelStyle(.neutral, cornerRadius: 12)
238
+ }
139
239
 
140
- ActionButton(icon: "gearshape", label: "Settings") {
141
- NSApp.activate(ignoringOtherApps: true)
142
- openWindow(id: "settings")
143
- }
240
+ private var actionsSection: some View {
241
+ HStack(spacing: 12) {
242
+ ActionButton(icon: "text.alignleft", label: "Logs") {
243
+ NSApp.activate(ignoringOtherApps: true)
244
+ openWindow(id: "logs")
245
+ }
144
246
 
145
- if service.status == .connected || service.status == .starting || service.status == .disconnected {
146
- ActionButton(icon: "arrow.counterclockwise", label: "Restart") {
147
- service.restart()
148
- }
149
- } else {
150
- ActionButton(icon: "play.fill", label: "Start") {
151
- service.start()
152
- }
153
- }
247
+ ActionButton(icon: "bolt.fill", label: "Agents") {
248
+ NSApp.activate(ignoringOtherApps: true)
249
+ openWindow(id: "agents")
250
+ }
154
251
 
155
- Spacer()
252
+ ActionButton(icon: "gearshape", label: "Settings") {
253
+ NSApp.activate(ignoringOtherApps: true)
254
+ openWindow(id: "settings")
255
+ }
156
256
 
157
- ActionButton(icon: "xmark.circle", label: "Quit", tint: .secondary) {
158
- service.stop()
159
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
160
- NSApp.terminate(nil)
161
- }
257
+ if service.status == .connected || service.status == .starting || service.status == .disconnected {
258
+ ActionButton(icon: "arrow.counterclockwise", label: "Restart") {
259
+ service.restart()
260
+ }
261
+ } else {
262
+ ActionButton(icon: "play.fill", label: "Start") {
263
+ service.start()
162
264
  }
163
265
  }
164
- .padding(12)
165
266
 
166
- Divider()
267
+ Spacer()
167
268
 
168
- HStack {
169
- Button {
170
- NSApp.activate(ignoringOtherApps: true)
171
- openWindow(id: "about")
172
- } label: {
173
- Text("Poke Gate v0.0.8")
174
- .font(.caption2)
175
- .foregroundStyle(.tertiary)
269
+ ActionButton(icon: "xmark.circle", label: "Quit", tint: .secondary) {
270
+ service.stop()
271
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
272
+ NSApp.terminate(nil)
176
273
  }
177
- .buttonStyle(.plain)
178
-
179
- Spacer()
274
+ }
275
+ }
276
+ .padding(12)
277
+ .frame(maxWidth: .infinity, alignment: .leading)
278
+ .macPanelStyle(.neutral, cornerRadius: 12)
279
+ }
180
280
 
181
- Text("Not affiliated with Poke")
281
+ private var footerSection: some View {
282
+ HStack {
283
+ Button {
284
+ NSApp.activate(ignoringOtherApps: true)
285
+ openWindow(id: "about")
286
+ } label: {
287
+ Text(appVersionText)
182
288
  .font(.caption2)
183
- .foregroundStyle(.quaternary)
289
+ .foregroundStyle(MacVisualStyle.sectionTitleColor)
184
290
  }
185
- .padding(.horizontal, 12)
186
- .padding(.vertical, 8)
291
+ .buttonStyle(.plain)
292
+
293
+ Spacer()
294
+
295
+ Text("Not affiliated with Poke")
296
+ .font(.caption2)
297
+ .foregroundStyle(MacVisualStyle.sectionTitleColor.opacity(0.7))
187
298
  }
188
- .frame(width: 320)
299
+ .padding(.horizontal, 6)
300
+ }
301
+
302
+ private func sectionTitle(_ text: String) -> some View {
303
+ Text(text)
304
+ .font(.caption2)
305
+ .foregroundStyle(MacVisualStyle.sectionTitleColor)
306
+ .textCase(.uppercase)
307
+ .tracking(0.5)
189
308
  }
190
309
 
191
310
  private var statusText: String {
@@ -210,6 +329,37 @@ struct PopoverContent: View {
210
329
  case .stopped: .gray.opacity(0.5)
211
330
  }
212
331
  }
332
+
333
+ private func modeChipTitle(_ mode: GateService.PermissionMode) -> String {
334
+ switch mode {
335
+ case .full: return "Full"
336
+ case .limited: return "Limited"
337
+ case .sandbox: return "Sandbox"
338
+ }
339
+ }
340
+
341
+ private func handleModeSelection(_ mode: GateService.PermissionMode) {
342
+ guard mode != service.permissionMode else { return }
343
+
344
+ if mode == .full && !service.hasSystemPermissionsGranted {
345
+ pendingFullMode = true
346
+ service.openSystemPermission(.accessibility)
347
+ } else {
348
+ pendingFullMode = false
349
+ service.setPermissionMode(mode)
350
+ }
351
+ }
352
+
353
+ private var appVersionText: String {
354
+ let short = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
355
+ let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
356
+
357
+ if let build, !build.isEmpty, build != short {
358
+ return "Poke Gate v\(short) (\(build))"
359
+ }
360
+
361
+ return "Poke Gate v\(short)"
362
+ }
213
363
  }
214
364
 
215
365
  struct ActionButton: View {
@@ -1,101 +1,167 @@
1
1
  import SwiftUI
2
+ import ServiceManagement
2
3
 
3
4
  struct SettingsView: View {
4
5
  @ObservedObject var service: GateService
5
6
  @Environment(\.dismiss) private var dismiss
7
+ @State private var launchAtLogin = SMAppService.mainApp.status == .enabled
6
8
 
7
9
  var body: some View {
8
10
  VStack(alignment: .leading, spacing: 16) {
11
+ authenticationSection
12
+ accessModeSection
13
+ connectionSection
14
+
9
15
  VStack(alignment: .leading, spacing: 8) {
10
- Text("AUTHENTICATION")
16
+ Text("GENERAL")
11
17
  .font(.caption2)
12
- .foregroundStyle(.tertiary)
18
+ .foregroundStyle(MacVisualStyle.sectionTitleColor)
13
19
  .textCase(.uppercase)
14
20
  .tracking(0.5)
15
21
 
16
- HStack(spacing: 8) {
17
- Image(systemName: service.hasPokeLoginCredentials
18
- ? "checkmark.shield.fill" : "shield.slash")
19
- .foregroundStyle(service.hasPokeLoginCredentials ? .green : .orange)
20
- .font(.title3)
21
-
22
- VStack(alignment: .leading, spacing: 2) {
23
- Text(service.hasPokeLoginCredentials
24
- ? "Signed in via Poke"
25
- : "Not signed in")
26
- .font(.subheadline)
27
- .fontWeight(.medium)
28
-
29
- if service.hasPokeLoginCredentials {
30
- Text("Your Poke session is active.")
31
- .font(.caption)
32
- .foregroundStyle(.secondary)
33
- } else {
34
- Text("Run this command in Terminal to sign in:")
35
- .font(.caption)
36
- .foregroundStyle(.secondary)
22
+ Toggle("Start Poke Gate on login", isOn: $launchAtLogin)
23
+ .font(.subheadline)
24
+ .onChange(of: launchAtLogin) { _, newValue in
25
+ do {
26
+ if newValue {
27
+ try SMAppService.mainApp.register()
28
+ } else {
29
+ try SMAppService.mainApp.unregister()
30
+ }
31
+ } catch {
32
+ launchAtLogin = !newValue
37
33
  }
38
34
  }
35
+ }
36
+
37
+ HStack {
38
+ Spacer()
39
+ Button("Close") {
40
+ dismiss()
39
41
  }
40
- .padding(10)
41
- .frame(maxWidth: .infinity, alignment: .leading)
42
- .background(.quaternary.opacity(0.5))
43
- .cornerRadius(8)
44
-
45
- if !service.hasPokeLoginCredentials {
46
- Button {
47
- service.runPokeLogin()
48
- } label: {
49
- Label("Sign in with Poke", systemImage: "person.crop.circle.badge.plus")
50
- }
51
- .controlSize(.large)
42
+ .keyboardShortcut(.cancelAction)
43
+ }
44
+ }
45
+ .padding(20)
46
+ .frame(width: 430)
47
+ }
48
+
49
+ private var authenticationSection: some View {
50
+ VStack(alignment: .leading, spacing: 8) {
51
+ sectionTitle("AUTHENTICATION")
52
52
 
53
- Text("Opens a browser window to sign in.")
53
+ HStack(spacing: 8) {
54
+ Image(systemName: service.hasPokeLoginCredentials ? "checkmark.shield.fill" : "shield.slash")
55
+ .foregroundStyle(service.hasPokeLoginCredentials ? .green : .orange)
56
+ .font(.title3)
57
+
58
+ VStack(alignment: .leading, spacing: 2) {
59
+ Text(service.hasPokeLoginCredentials ? "Signed in via Poke" : "Not signed in")
60
+ .font(.subheadline)
61
+ .fontWeight(.medium)
62
+
63
+ Text(service.hasPokeLoginCredentials ? "Your Poke session is active." : "Run this command in Terminal to sign in:")
54
64
  .font(.caption)
55
65
  .foregroundStyle(.secondary)
56
66
  }
57
67
  }
68
+ .padding(10)
69
+ .frame(maxWidth: .infinity, alignment: .leading)
70
+ .macPanelStyle(.neutral, cornerRadius: 8)
58
71
 
59
- VStack(alignment: .leading, spacing: 8) {
60
- Text("CONNECTION")
61
- .font(.caption2)
62
- .foregroundStyle(.tertiary)
63
- .textCase(.uppercase)
64
- .tracking(0.5)
72
+ if !service.hasPokeLoginCredentials {
73
+ Button {
74
+ service.runPokeLogin()
75
+ } label: {
76
+ Label("Sign in with Poke", systemImage: "person.crop.circle.badge.plus")
77
+ }
78
+ .controlSize(.large)
79
+
80
+ Text("Opens a browser window to sign in.")
81
+ .font(.caption)
82
+ .foregroundStyle(.secondary)
83
+ }
84
+ }
85
+ }
65
86
 
66
- HStack(spacing: 8) {
67
- Circle()
68
- .fill(connectionColor)
69
- .frame(width: 8, height: 8)
87
+ private var accessModeSection: some View {
88
+ VStack(alignment: .leading, spacing: 10) {
89
+ sectionTitle("ACCESS MODE")
70
90
 
71
- Text(service.status.rawValue)
72
- .font(.subheadline)
91
+ VStack(spacing: 8) {
92
+ ForEach(GateService.PermissionMode.allCases) { mode in
93
+ permissionModeRow(mode)
94
+ }
95
+ }
73
96
 
74
- Spacer()
97
+ if service.permissionMode == .full {
98
+ AccessibilityPermissionView(service: service)
99
+ }
100
+ }
101
+ }
75
102
 
76
- Button {
77
- service.restart()
78
- } label: {
79
- Label("Reconnect", systemImage: "arrow.counterclockwise")
80
- .font(.caption)
81
- }
103
+ private func permissionModeRow(_ mode: GateService.PermissionMode) -> some View {
104
+ Button {
105
+ service.setPermissionMode(mode)
106
+ } label: {
107
+ HStack(alignment: .top, spacing: 10) {
108
+ Image(systemName: service.permissionMode == mode ? "checkmark.circle.fill" : "circle")
109
+ .font(.headline)
110
+ .foregroundStyle(service.permissionMode == mode ? .green : .secondary)
111
+
112
+ VStack(alignment: .leading, spacing: 2) {
113
+ Text(mode.title)
114
+ .font(.subheadline)
115
+ .fontWeight(.medium)
116
+ .foregroundStyle(.primary)
117
+
118
+ Text(mode.subtitle)
119
+ .font(.caption)
120
+ .foregroundStyle(.secondary)
82
121
  }
83
- .padding(10)
84
- .frame(maxWidth: .infinity, alignment: .leading)
85
- .background(.quaternary.opacity(0.5))
86
- .cornerRadius(8)
122
+
123
+ Spacer()
87
124
  }
125
+ .padding(10)
126
+ .frame(maxWidth: .infinity, alignment: .leading)
127
+ .macPanelStyle(service.permissionMode == mode ? .selected : .neutral)
128
+ }
129
+ .buttonStyle(.plain)
130
+ }
131
+
132
+ private var connectionSection: some View {
133
+ VStack(alignment: .leading, spacing: 8) {
134
+ sectionTitle("CONNECTION")
135
+
136
+ HStack(spacing: 8) {
137
+ Circle()
138
+ .fill(connectionColor)
139
+ .frame(width: 8, height: 8)
140
+
141
+ Text(service.status.rawValue)
142
+ .font(.subheadline)
88
143
 
89
- HStack {
90
144
  Spacer()
91
- Button("Close") {
92
- dismiss()
145
+
146
+ Button {
147
+ service.restart()
148
+ } label: {
149
+ Label("Reconnect", systemImage: "arrow.counterclockwise")
150
+ .font(.caption)
93
151
  }
94
- .keyboardShortcut(.cancelAction)
95
152
  }
153
+ .padding(10)
154
+ .frame(maxWidth: .infinity, alignment: .leading)
155
+ .macPanelStyle(.neutral, cornerRadius: 8)
96
156
  }
97
- .padding(20)
98
- .frame(width: 380)
157
+ }
158
+
159
+ private func sectionTitle(_ text: String) -> some View {
160
+ Text(text)
161
+ .font(.caption2)
162
+ .foregroundStyle(MacVisualStyle.sectionTitleColor)
163
+ .textCase(.uppercase)
164
+ .tracking(0.5)
99
165
  }
100
166
 
101
167
  private var connectionColor: Color {