poke-gate 0.1.9 → 0.2.1

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 (32) hide show
  1. package/.github/workflows/release.yml +53 -3
  2. package/Gate.app +0 -0
  3. package/README.md +48 -14
  4. package/bin/poke-gate.js +17 -0
  5. package/clients/Poke macOS Gate/Poke macOS Gate/AboutView.swift +7 -1
  6. package/clients/Poke macOS Gate/Poke macOS Gate/AccessibilityPermissionView.swift +58 -0
  7. package/clients/Poke macOS Gate/Poke macOS Gate/GateService.swift +389 -23
  8. package/clients/Poke macOS Gate/Poke macOS Gate/Info.plist +2 -0
  9. package/clients/Poke macOS Gate/Poke macOS Gate/LogsView.swift +1 -1
  10. package/clients/Poke macOS Gate/Poke macOS Gate/MacVisualStyle.swift +89 -0
  11. package/clients/Poke macOS Gate/Poke macOS Gate/PermissionRowView.swift +55 -0
  12. package/clients/Poke macOS Gate/Poke macOS Gate/Poke_macOS_GateApp.swift +234 -91
  13. package/clients/Poke macOS Gate/Poke macOS Gate/SettingsView.swift +125 -81
  14. package/clients/Poke macOS Gate/Poke macOS Gate/SetupView.swift +157 -0
  15. package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/project.pbxproj +31 -11
  16. package/docs/cli.md +19 -0
  17. package/docs/getting-started.md +9 -6
  18. package/docs/index.md +23 -18
  19. package/docs/macos-app.md +39 -4
  20. package/docs/security.md +62 -18
  21. package/examples/agents/battery.30m.js +1 -1
  22. package/examples/agents/screentime.24h.js +5 -6
  23. package/macOS +0 -0
  24. package/package.json +3 -1
  25. package/src/agents.js +5 -8
  26. package/src/app.js +29 -5
  27. package/src/mcp-server.js +502 -27
  28. package/src/permission-service.js +128 -0
  29. package/test/mcp-server-access-policy.test.js +40 -0
  30. package/test/mcp-server-loop-guard.test.js +57 -0
  31. package/test/mcp-server-sandbox-command.test.js +18 -0
  32. package/test/permission-service.test.js +97 -0
@@ -1,6 +1,9 @@
1
1
  import Foundation
2
2
  import Combine
3
3
  import ScreenCaptureKit
4
+ import AppKit
5
+ import CoreGraphics
6
+ import ApplicationServices
4
7
 
5
8
  @MainActor
6
9
  class GateService: ObservableObject {
@@ -12,17 +15,128 @@ class GateService: ObservableObject {
12
15
  case error = "Error"
13
16
  }
14
17
 
18
+ enum PermissionMode: String, CaseIterable, Identifiable {
19
+ case full
20
+ case limited
21
+ case sandbox
22
+
23
+ var id: String { rawValue }
24
+
25
+ var title: String {
26
+ switch self {
27
+ case .full: return "Full System Access"
28
+ case .limited: return "Limited Permissions"
29
+ case .sandbox: return "Run in Sandbox"
30
+ }
31
+ }
32
+
33
+ var subtitle: String {
34
+ switch self {
35
+ case .full: return "All tools enabled. Risky actions require chat approval."
36
+ case .limited: return "Read-only tools and safe commands (ls, cat, grep, curl…). File writes and screenshots disabled."
37
+ case .sandbox: return "Commands like brew, node, python, ffmpeg allowed. Writes restricted to ~/Downloads and /tmp."
38
+ }
39
+ }
40
+ }
41
+
42
+ enum SystemPermission: String, CaseIterable, Identifiable {
43
+ case accessibility
44
+
45
+ var id: String { rawValue }
46
+
47
+ var title: String {
48
+ switch self {
49
+ case .accessibility: return "Accessibility"
50
+ }
51
+ }
52
+
53
+ var subtitle: String {
54
+ switch self {
55
+ case .accessibility: return "Needed for keyboard, mouse, and automation-style control."
56
+ }
57
+ }
58
+
59
+ var settingsURL: String {
60
+ switch self {
61
+ case .accessibility: return "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
62
+ }
63
+ }
64
+
65
+ var systemImageName: String {
66
+ switch self {
67
+ case .accessibility: return "figure.wave"
68
+ }
69
+ }
70
+ }
71
+
72
+ struct SystemPermissionStatus: Identifiable, Equatable {
73
+ let permission: SystemPermission
74
+ let isGranted: Bool
75
+
76
+ var id: SystemPermission { permission }
77
+ }
78
+
79
+ struct TerminalPreview: Identifiable, Equatable {
80
+ let id: UUID
81
+ let timestamp: Date
82
+ var command: String
83
+ var cwd: String?
84
+ var exitCode: Int?
85
+ var durationMs: Int?
86
+ var timedOut: Bool
87
+ var sandboxMode: String?
88
+ var stdoutPreview: String?
89
+ var stderrPreview: String?
90
+ }
91
+
15
92
  @Published var status: Status = .stopped
16
93
  @Published var logs: [String] = []
94
+ @Published var terminalPreviews: [TerminalPreview] = []
17
95
  @Published var userName: String? = nil
96
+ @Published var permissionMode: PermissionMode
97
+ @Published var hasCompletedSetup: Bool
98
+ @Published var systemPermissionStatuses: [SystemPermissionStatus] = []
18
99
 
19
100
  private var hasAutoStarted = false
20
101
  private var process: Process?
21
102
  private var outputPipe: Pipe?
22
103
  private var shouldRestart = true
23
104
  private let maxLogs = 200
105
+ private let maxTerminalPreviews = 100
24
106
  private var restartAttempts = 0
25
107
  private var healthCheckTimer: Timer?
108
+ private var activeTerminalPreviewId: UUID?
109
+ private var permissionPollingTimer: Timer?
110
+
111
+ private var appActiveObserver: NSObjectProtocol?
112
+
113
+ init() {
114
+ self.permissionMode = Self.loadPermissionModeStatic()
115
+ self.hasCompletedSetup = Self.loadHasCompletedSetupStatic()
116
+ refreshSystemPermissions()
117
+
118
+ appActiveObserver = NotificationCenter.default.addObserver(
119
+ forName: NSApplication.didBecomeActiveNotification,
120
+ object: nil,
121
+ queue: .main
122
+ ) { [weak self] _ in
123
+ self?.refreshSystemPermissions()
124
+ }
125
+ }
126
+
127
+ deinit {
128
+ if let observer = appActiveObserver {
129
+ NotificationCenter.default.removeObserver(observer)
130
+ }
131
+ }
132
+
133
+ var hasSystemPermissionsGranted: Bool {
134
+ !systemPermissionStatuses.isEmpty && systemPermissionStatuses.allSatisfy { $0.isGranted }
135
+ }
136
+
137
+ var missingSystemPermissions: [SystemPermission] {
138
+ systemPermissionStatuses.filter { !$0.isGranted }.map { $0.permission }
139
+ }
26
140
 
27
141
  var apiKey: String {
28
142
  get { loadAPIKey() ?? "" }
@@ -44,6 +158,83 @@ class GateService: ObservableObject {
44
158
  appendLog("Launched poke login (npx: \(npxBin)) — check your browser.")
45
159
  }
46
160
 
161
+ func setPermissionMode(_ mode: PermissionMode) {
162
+ guard permissionMode != mode else { return }
163
+ permissionMode = mode
164
+ savePermissionMode(mode)
165
+ appendLog("Access mode changed to: \(mode.title)")
166
+
167
+ if process?.isRunning == true {
168
+ appendLog("Restarting gate to apply access mode.")
169
+ restart()
170
+ }
171
+ }
172
+
173
+ func completeFirstRunSetup(selectedMode: PermissionMode, requestPermissions: Bool) {
174
+ setPermissionMode(selectedMode)
175
+ if requestPermissions {
176
+ requestSystemPermissions()
177
+ }
178
+ hasCompletedSetup = true
179
+ saveHasCompletedSetup(true)
180
+ appendLog("Initial setup complete.")
181
+ }
182
+
183
+ func requestSystemPermissions() {
184
+ openMissingSystemPermissions()
185
+ }
186
+
187
+ func openMissingSystemPermissions() {
188
+ refreshSystemPermissions()
189
+
190
+ guard !missingSystemPermissions.isEmpty else {
191
+ appendLog("All required macOS permissions are already granted.")
192
+ return
193
+ }
194
+
195
+ let requested = missingSystemPermissions.map { $0.title }.joined(separator: ", ")
196
+ appendLog("Requesting macOS permissions: \(requested).")
197
+
198
+ for permission in missingSystemPermissions {
199
+ openSystemPermission(permission)
200
+ }
201
+ }
202
+
203
+ func refreshSystemPermissions() {
204
+ let previous = systemPermissionStatuses
205
+ systemPermissionStatuses = SystemPermission.allCases.map { permission in
206
+ SystemPermissionStatus(permission: permission, isGranted: isPermissionGranted(permission))
207
+ }
208
+ for status in systemPermissionStatuses {
209
+ let was = previous.first(where: { $0.permission == status.permission })
210
+ if was == nil || was?.isGranted != status.isGranted {
211
+ appendLog("\(status.permission.title): \(status.isGranted ? "granted" : "not granted")")
212
+ }
213
+ }
214
+ }
215
+
216
+ func startPermissionPolling() {
217
+ stopPermissionPolling()
218
+ permissionPollingTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
219
+ Task { @MainActor [weak self] in
220
+ self?.refreshSystemPermissions()
221
+ }
222
+ }
223
+ }
224
+
225
+ func stopPermissionPolling() {
226
+ permissionPollingTimer?.invalidate()
227
+ permissionPollingTimer = nil
228
+ }
229
+
230
+ func openSystemPermission(_ permission: SystemPermission) {
231
+ switch permission {
232
+ case .accessibility:
233
+ let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
234
+ _ = AXIsProcessTrustedWithOptions(options)
235
+ }
236
+ }
237
+
47
238
  func captureAndSend() {
48
239
  appendLog("Screenshot requested via deeplink.")
49
240
 
@@ -77,8 +268,6 @@ class GateService: ObservableObject {
77
268
  try pngData.write(to: tempURL)
78
269
  appendLog("Screenshot saved to \(tempPath) (\(pngData.count) bytes)")
79
270
 
80
- let base64 = pngData.base64EncodedString()
81
-
82
271
  guard let token = loadPokeLoginToken() else {
83
272
  appendLog("Cannot send screenshot: not signed in to Poke.")
84
273
  return
@@ -158,6 +347,10 @@ class GateService: ObservableObject {
158
347
  }
159
348
  }
160
349
 
350
+ func clearLogs() {
351
+ logs = []
352
+ }
353
+
161
354
  func shellPath() -> String {
162
355
  let home = NSHomeDirectory()
163
356
  let fallback = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin"
@@ -241,6 +434,7 @@ class GateService: ObservableObject {
241
434
 
242
435
  status = .starting
243
436
  appendLog("Starting poke-gate…")
437
+ appendLog("Access mode: \(permissionMode.title)")
244
438
 
245
439
  let fullPath = shellPath()
246
440
  let npxBin = findNpx()
@@ -255,6 +449,7 @@ class GateService: ObservableObject {
255
449
  proc.environment = ProcessInfo.processInfo.environment.merging(
256
450
  [
257
451
  "PATH": fullPath,
452
+ "POKE_GATE_PERMISSION_MODE": permissionMode.rawValue,
258
453
  ],
259
454
  uniquingKeysWith: { _, new in new }
260
455
  )
@@ -299,6 +494,7 @@ class GateService: ObservableObject {
299
494
  private func handleOutput(_ raw: String) {
300
495
  for line in raw.components(separatedBy: .newlines) where !line.isEmpty {
301
496
  appendLog(line)
497
+ parseTerminalPreviewLine(line)
302
498
 
303
499
  if line.contains("Tunnel connected") || line.contains("Ready") {
304
500
  status = .connected
@@ -361,6 +557,54 @@ class GateService: ObservableObject {
361
557
 
362
558
  // MARK: - Config
363
559
 
560
+ private static func loadPermissionModeStatic() -> PermissionMode {
561
+ let configDir: URL
562
+ if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] {
563
+ configDir = URL(fileURLWithPath: xdg)
564
+ } else {
565
+ configDir = FileManager.default.homeDirectoryForCurrentUser
566
+ .appendingPathComponent(".config")
567
+ }
568
+
569
+ let configURL = configDir
570
+ .appendingPathComponent("poke-gate")
571
+ .appendingPathComponent("config.json")
572
+
573
+ guard let data = try? Data(contentsOf: configURL),
574
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
575
+ let value = json["permissionMode"] as? String,
576
+ let mode = PermissionMode(rawValue: value) else {
577
+ return .full
578
+ }
579
+
580
+ return mode
581
+ }
582
+
583
+ private static func loadHasCompletedSetupStatic() -> Bool {
584
+ let configDir: URL
585
+ if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] {
586
+ configDir = URL(fileURLWithPath: xdg)
587
+ } else {
588
+ configDir = FileManager.default.homeDirectoryForCurrentUser
589
+ .appendingPathComponent(".config")
590
+ }
591
+
592
+ let configURL = configDir
593
+ .appendingPathComponent("poke-gate")
594
+ .appendingPathComponent("config.json")
595
+
596
+ guard let data = try? Data(contentsOf: configURL),
597
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
598
+ return false
599
+ }
600
+
601
+ if let value = json["setupCompleted"] as? Bool {
602
+ return value
603
+ }
604
+
605
+ return false
606
+ }
607
+
364
608
  private var configURL: URL {
365
609
  let configDir: URL
366
610
  if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] {
@@ -374,25 +618,157 @@ class GateService: ObservableObject {
374
618
  .appendingPathComponent("config.json")
375
619
  }
376
620
 
377
- private func loadAPIKey() -> String? {
621
+ private func readConfig() -> [String: Any] {
378
622
  guard let data = try? Data(contentsOf: configURL),
379
- let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
380
- let key = json["apiKey"] as? String else {
381
- return nil
623
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
624
+ return [:]
382
625
  }
383
- return key
626
+ return json
384
627
  }
385
628
 
386
- private func saveAPIKey(_ key: String) {
629
+ private func writeConfig(_ json: [String: Any]) {
387
630
  let dir = configURL.deletingLastPathComponent()
388
631
  try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
389
- let json: [String: Any] = ["apiKey": key]
390
632
  if let data = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted]) {
391
633
  try? data.write(to: configURL)
392
634
  }
393
635
  objectWillChange.send()
394
636
  }
395
637
 
638
+ private func loadAPIKey() -> String? {
639
+ readConfig()["apiKey"] as? String
640
+ }
641
+
642
+ private func saveAPIKey(_ key: String) {
643
+ var json = readConfig()
644
+ json["apiKey"] = key
645
+ writeConfig(json)
646
+ }
647
+
648
+ private func savePermissionMode(_ mode: PermissionMode) {
649
+ var json = readConfig()
650
+ json["permissionMode"] = mode.rawValue
651
+ writeConfig(json)
652
+ }
653
+
654
+ private func saveHasCompletedSetup(_ value: Bool) {
655
+ var json = readConfig()
656
+ json["setupCompleted"] = value
657
+ writeConfig(json)
658
+ }
659
+
660
+ private func isPermissionGranted(_ permission: SystemPermission) -> Bool {
661
+ switch permission {
662
+ case .accessibility:
663
+ if AXIsProcessTrusted() { return true }
664
+ // AXIsProcessTrusted() can return false despite the toggle being ON
665
+ // in System Settings when the TCC entry's code signature doesn't match
666
+ // the running binary (ad-hoc signing, rebuild from Xcode, etc.).
667
+ // Probe the API directly as a fallback.
668
+ let systemWide = AXUIElementCreateSystemWide()
669
+ var value: AnyObject?
670
+ let result = AXUIElementCopyAttributeValue(
671
+ systemWide,
672
+ kAXFocusedApplicationAttribute as CFString,
673
+ &value
674
+ )
675
+ return result == .success || result == .noValue || result == .attributeUnsupported
676
+ }
677
+ }
678
+
679
+ private func parseTerminalPreviewLine(_ line: String) {
680
+ let body = stripToolTimestamp(from: line)
681
+
682
+ if body == "terminal preview:" {
683
+ activeTerminalPreviewId = nil
684
+ return
685
+ }
686
+
687
+ if body.hasPrefix("$ ") {
688
+ let (command, cwd) = parseCommandAndCwd(body)
689
+ let preview = TerminalPreview(
690
+ id: UUID(),
691
+ timestamp: Date(),
692
+ command: command,
693
+ cwd: cwd,
694
+ exitCode: nil,
695
+ durationMs: nil,
696
+ timedOut: false,
697
+ sandboxMode: nil,
698
+ stdoutPreview: nil,
699
+ stderrPreview: nil
700
+ )
701
+ terminalPreviews.append(preview)
702
+ if terminalPreviews.count > maxTerminalPreviews {
703
+ terminalPreviews.removeFirst(terminalPreviews.count - maxTerminalPreviews)
704
+ }
705
+ activeTerminalPreviewId = preview.id
706
+ return
707
+ }
708
+
709
+ guard let id = activeTerminalPreviewId,
710
+ let index = terminalPreviews.firstIndex(where: { $0.id == id }) else {
711
+ return
712
+ }
713
+
714
+ if body.hasPrefix("process: ") {
715
+ var updated = terminalPreviews[index]
716
+ updated.exitCode = parseInt(body, key: "exit=")
717
+ updated.durationMs = parseInt(body, key: "duration=")
718
+ updated.timedOut = body.contains(" timeout")
719
+ if body.contains("sandbox=os") {
720
+ updated.sandboxMode = "os"
721
+ } else if body.contains("sandbox=none") {
722
+ updated.sandboxMode = "none"
723
+ }
724
+ terminalPreviews[index] = updated
725
+ return
726
+ }
727
+
728
+ if body.hasPrefix("stdout: ") {
729
+ var updated = terminalPreviews[index]
730
+ updated.stdoutPreview = String(body.dropFirst("stdout: ".count))
731
+ terminalPreviews[index] = updated
732
+ return
733
+ }
734
+
735
+ if body.hasPrefix("stderr: ") {
736
+ var updated = terminalPreviews[index]
737
+ updated.stderrPreview = String(body.dropFirst("stderr: ".count))
738
+ terminalPreviews[index] = updated
739
+ }
740
+ }
741
+
742
+ private func stripToolTimestamp(from line: String) -> String {
743
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
744
+ guard trimmed.hasPrefix("["), let end = trimmed.firstIndex(of: "]") else {
745
+ return trimmed
746
+ }
747
+ let after = trimmed.index(after: end)
748
+ return String(trimmed[after...]).trimmingCharacters(in: .whitespaces)
749
+ }
750
+
751
+ private func parseCommandAndCwd(_ body: String) -> (String, String?) {
752
+ let text = String(body.dropFirst(2))
753
+ let marker = " (in "
754
+ guard let range = text.range(of: marker), text.hasSuffix(")") else {
755
+ return (text, nil)
756
+ }
757
+
758
+ let command = String(text[..<range.lowerBound])
759
+ let cwdStart = range.upperBound
760
+ let cwdEnd = text.index(before: text.endIndex)
761
+ let cwd = String(text[cwdStart..<cwdEnd])
762
+ return (command, cwd)
763
+ }
764
+
765
+ private func parseInt(_ text: String, key: String) -> Int? {
766
+ guard let range = text.range(of: key) else { return nil }
767
+ let suffix = text[range.upperBound...]
768
+ let digits = suffix.prefix { $0.isNumber }
769
+ return Int(digits)
770
+ }
771
+
396
772
  // MARK: - Poke Login Credentials
397
773
 
398
774
  private var pokeCredentialsURL: URL {
@@ -423,9 +799,8 @@ class GateService: ObservableObject {
423
799
 
424
800
  var authSource: AuthSource {
425
801
  get {
426
- let config = (try? Data(contentsOf: configURL))
427
- .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }
428
- if let source = config?["authSource"] as? String, source == "pokeLogin" {
802
+ let config = readConfig()
803
+ if let source = config["authSource"] as? String, source == "pokeLogin" {
429
804
  return .pokeLogin
430
805
  }
431
806
  if loadAPIKey() != nil {
@@ -437,18 +812,9 @@ class GateService: ObservableObject {
437
812
  return .none
438
813
  }
439
814
  set {
440
- let dir = configURL.deletingLastPathComponent()
441
- try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
442
- var json: [String: Any] = [:]
443
- if let data = try? Data(contentsOf: configURL),
444
- let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
445
- json = existing
446
- }
815
+ var json = readConfig()
447
816
  json["authSource"] = newValue == .pokeLogin ? "pokeLogin" : "apiKey"
448
- if let data = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted]) {
449
- try? data.write(to: configURL)
450
- }
451
- objectWillChange.send()
817
+ writeConfig(json)
452
818
  }
453
819
  }
454
820
 
@@ -2,6 +2,8 @@
2
2
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
3
  <plist version="1.0">
4
4
  <dict>
5
+ <key>CFBundleIdentifier</key>
6
+ <string>dev.fka.Poke-macOS-Gate</string>
5
7
  <key>LSUIElement</key>
6
8
  <true/>
7
9
  <key>CFBundleURLTypes</key>
@@ -25,7 +25,7 @@ struct LogsView: View {
25
25
  .help("Copy all logs")
26
26
 
27
27
  Button {
28
- service.logs.removeAll()
28
+ service.clearLogs()
29
29
  } label: {
30
30
  Image(systemName: "trash")
31
31
  .font(.caption)
@@ -0,0 +1,89 @@
1
+ import SwiftUI
2
+
3
+ enum MacPanelTone {
4
+ case neutral
5
+ case selected
6
+ case success
7
+ case warning
8
+ }
9
+
10
+ fileprivate struct MacPanelColors {
11
+ let fill: Color
12
+ let stroke: Color
13
+ }
14
+
15
+ enum MacVisualStyle {
16
+ static var usesNativeFirstChrome: Bool {
17
+ if #available(macOS 26, *) {
18
+ return true
19
+ }
20
+
21
+ return false
22
+ }
23
+
24
+ static var sectionTitleColor: Color {
25
+ Color.secondary.opacity(usesNativeFirstChrome ? 0.9 : 0.7)
26
+ }
27
+
28
+ static var progressTrackColor: Color {
29
+ Color.secondary.opacity(usesNativeFirstChrome ? 0.16 : 0.22)
30
+ }
31
+
32
+ static var chipActiveFill: Color {
33
+ usesNativeFirstChrome ? Color.accentColor.opacity(0.9) : Color.accentColor
34
+ }
35
+
36
+ static var chipInactiveFill: Color {
37
+ Color.secondary.opacity(usesNativeFirstChrome ? 0.1 : 0.14)
38
+ }
39
+
40
+ fileprivate static func panelColors(for tone: MacPanelTone) -> MacPanelColors {
41
+ switch tone {
42
+ case .neutral:
43
+ return MacPanelColors(
44
+ fill: Color.primary.opacity(usesNativeFirstChrome ? 0.02 : 0.05),
45
+ stroke: Color.primary.opacity(usesNativeFirstChrome ? 0.05 : 0.08)
46
+ )
47
+ case .selected:
48
+ return MacPanelColors(
49
+ fill: Color.accentColor.opacity(usesNativeFirstChrome ? 0.08 : 0.12),
50
+ stroke: Color.accentColor.opacity(usesNativeFirstChrome ? 0.2 : 0.35)
51
+ )
52
+ case .success:
53
+ return MacPanelColors(
54
+ fill: Color.green.opacity(usesNativeFirstChrome ? 0.05 : 0.06),
55
+ stroke: Color.green.opacity(usesNativeFirstChrome ? 0.18 : 0.3)
56
+ )
57
+ case .warning:
58
+ return MacPanelColors(
59
+ fill: Color.orange.opacity(usesNativeFirstChrome ? 0.05 : 0.06),
60
+ stroke: Color.orange.opacity(usesNativeFirstChrome ? 0.18 : 0.28)
61
+ )
62
+ }
63
+ }
64
+ }
65
+
66
+ private struct MacPanelModifier: ViewModifier {
67
+ let tone: MacPanelTone
68
+ let cornerRadius: CGFloat
69
+
70
+ func body(content: Content) -> some View {
71
+ let colors = MacVisualStyle.panelColors(for: tone)
72
+
73
+ content
74
+ .background(
75
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
76
+ .fill(colors.fill)
77
+ )
78
+ .overlay(
79
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
80
+ .stroke(colors.stroke, lineWidth: 1)
81
+ )
82
+ }
83
+ }
84
+
85
+ extension View {
86
+ func macPanelStyle(_ tone: MacPanelTone, cornerRadius: CGFloat = 10) -> some View {
87
+ modifier(MacPanelModifier(tone: tone, cornerRadius: cornerRadius))
88
+ }
89
+ }
@@ -0,0 +1,55 @@
1
+ import SwiftUI
2
+
3
+ struct PermissionRowView: View {
4
+ let permission: GateService.SystemPermission
5
+ let isGranted: Bool
6
+ let onGrant: () -> Void
7
+
8
+ var body: some View {
9
+ HStack(alignment: .top, spacing: 12) {
10
+ iconView
11
+
12
+ VStack(alignment: .leading, spacing: 3) {
13
+ Text(permission.title)
14
+ .font(.subheadline)
15
+ .fontWeight(.medium)
16
+ Text(permission.subtitle)
17
+ .font(.caption)
18
+ .foregroundStyle(.secondary)
19
+ }
20
+
21
+ Spacer()
22
+
23
+ statusView
24
+ }
25
+ .padding(12)
26
+ .macPanelStyle(isGranted ? .success : .neutral)
27
+ .animation(.easeInOut(duration: 0.2), value: isGranted)
28
+ }
29
+
30
+ private var iconView: some View {
31
+ Image(systemName: permission.systemImageName)
32
+ .font(.system(size: 18))
33
+ .foregroundStyle(isGranted ? .green : .orange)
34
+ .frame(width: 28)
35
+ }
36
+
37
+ @ViewBuilder
38
+ private var statusView: some View {
39
+ if isGranted {
40
+ HStack(spacing: 4) {
41
+ Image(systemName: "checkmark.circle.fill")
42
+ .font(.caption)
43
+ .foregroundStyle(.green)
44
+ Text("Granted")
45
+ .font(.caption)
46
+ .foregroundStyle(.green)
47
+ }
48
+ } else {
49
+ Button("Grant Access", action: onGrant)
50
+ .font(.caption)
51
+ .buttonStyle(.bordered)
52
+ .controlSize(.small)
53
+ }
54
+ }
55
+ }