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,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,112 @@ 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 are available, subject to chat approval."
36
+ case .limited: return "Safe tools and curated command families only."
37
+ case .sandbox: return "Broader command support, but strictly limited by macOS sandbox-exec policies."
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
+ init() {
112
+ self.permissionMode = Self.loadPermissionModeStatic()
113
+ self.hasCompletedSetup = Self.loadHasCompletedSetupStatic()
114
+ refreshSystemPermissions()
115
+ }
116
+
117
+ var hasSystemPermissionsGranted: Bool {
118
+ !systemPermissionStatuses.isEmpty && systemPermissionStatuses.allSatisfy { $0.isGranted }
119
+ }
120
+
121
+ var missingSystemPermissions: [SystemPermission] {
122
+ systemPermissionStatuses.filter { !$0.isGranted }.map { $0.permission }
123
+ }
26
124
 
27
125
  var apiKey: String {
28
126
  get { loadAPIKey() ?? "" }
@@ -44,6 +142,76 @@ class GateService: ObservableObject {
44
142
  appendLog("Launched poke login (npx: \(npxBin)) — check your browser.")
45
143
  }
46
144
 
145
+ func setPermissionMode(_ mode: PermissionMode) {
146
+ guard permissionMode != mode else { return }
147
+ permissionMode = mode
148
+ savePermissionMode(mode)
149
+ appendLog("Access mode changed to: \(mode.title)")
150
+
151
+ if process?.isRunning == true {
152
+ appendLog("Restarting gate to apply access mode.")
153
+ restart()
154
+ }
155
+ }
156
+
157
+ func completeFirstRunSetup(selectedMode: PermissionMode, requestPermissions: Bool) {
158
+ setPermissionMode(selectedMode)
159
+ if requestPermissions {
160
+ requestSystemPermissions()
161
+ }
162
+ hasCompletedSetup = true
163
+ saveHasCompletedSetup(true)
164
+ appendLog("Initial setup complete.")
165
+ }
166
+
167
+ func requestSystemPermissions() {
168
+ openMissingSystemPermissions()
169
+ }
170
+
171
+ func openMissingSystemPermissions() {
172
+ refreshSystemPermissions()
173
+
174
+ guard !missingSystemPermissions.isEmpty else {
175
+ appendLog("All required macOS permissions are already granted.")
176
+ return
177
+ }
178
+
179
+ let requested = missingSystemPermissions.map { $0.title }.joined(separator: ", ")
180
+ appendLog("Opening macOS settings for: \(requested).")
181
+
182
+ for permission in missingSystemPermissions {
183
+ guard let url = URL(string: permission.settingsURL) else { continue }
184
+ NSWorkspace.shared.open(url)
185
+ }
186
+
187
+ appendLog("Opened Privacy settings for missing permissions.")
188
+ }
189
+
190
+ func refreshSystemPermissions() {
191
+ systemPermissionStatuses = SystemPermission.allCases.map { permission in
192
+ SystemPermissionStatus(permission: permission, isGranted: isPermissionGranted(permission))
193
+ }
194
+ }
195
+
196
+ func startPermissionPolling() {
197
+ stopPermissionPolling()
198
+ permissionPollingTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
199
+ Task { @MainActor [weak self] in
200
+ self?.refreshSystemPermissions()
201
+ }
202
+ }
203
+ }
204
+
205
+ func stopPermissionPolling() {
206
+ permissionPollingTimer?.invalidate()
207
+ permissionPollingTimer = nil
208
+ }
209
+
210
+ func openSystemPermission(_ permission: SystemPermission) {
211
+ guard let url = URL(string: permission.settingsURL) else { return }
212
+ NSWorkspace.shared.open(url)
213
+ }
214
+
47
215
  func captureAndSend() {
48
216
  appendLog("Screenshot requested via deeplink.")
49
217
 
@@ -77,8 +245,6 @@ class GateService: ObservableObject {
77
245
  try pngData.write(to: tempURL)
78
246
  appendLog("Screenshot saved to \(tempPath) (\(pngData.count) bytes)")
79
247
 
80
- let base64 = pngData.base64EncodedString()
81
-
82
248
  guard let token = loadPokeLoginToken() else {
83
249
  appendLog("Cannot send screenshot: not signed in to Poke.")
84
250
  return
@@ -158,6 +324,10 @@ class GateService: ObservableObject {
158
324
  }
159
325
  }
160
326
 
327
+ func clearLogs() {
328
+ logs = []
329
+ }
330
+
161
331
  func shellPath() -> String {
162
332
  let home = NSHomeDirectory()
163
333
  let fallback = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin"
@@ -241,6 +411,7 @@ class GateService: ObservableObject {
241
411
 
242
412
  status = .starting
243
413
  appendLog("Starting poke-gate…")
414
+ appendLog("Access mode: \(permissionMode.title)")
244
415
 
245
416
  let fullPath = shellPath()
246
417
  let npxBin = findNpx()
@@ -255,6 +426,7 @@ class GateService: ObservableObject {
255
426
  proc.environment = ProcessInfo.processInfo.environment.merging(
256
427
  [
257
428
  "PATH": fullPath,
429
+ "POKE_GATE_PERMISSION_MODE": permissionMode.rawValue,
258
430
  ],
259
431
  uniquingKeysWith: { _, new in new }
260
432
  )
@@ -299,6 +471,7 @@ class GateService: ObservableObject {
299
471
  private func handleOutput(_ raw: String) {
300
472
  for line in raw.components(separatedBy: .newlines) where !line.isEmpty {
301
473
  appendLog(line)
474
+ parseTerminalPreviewLine(line)
302
475
 
303
476
  if line.contains("Tunnel connected") || line.contains("Ready") {
304
477
  status = .connected
@@ -361,6 +534,54 @@ class GateService: ObservableObject {
361
534
 
362
535
  // MARK: - Config
363
536
 
537
+ private static func loadPermissionModeStatic() -> PermissionMode {
538
+ let configDir: URL
539
+ if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] {
540
+ configDir = URL(fileURLWithPath: xdg)
541
+ } else {
542
+ configDir = FileManager.default.homeDirectoryForCurrentUser
543
+ .appendingPathComponent(".config")
544
+ }
545
+
546
+ let configURL = configDir
547
+ .appendingPathComponent("poke-gate")
548
+ .appendingPathComponent("config.json")
549
+
550
+ guard let data = try? Data(contentsOf: configURL),
551
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
552
+ let value = json["permissionMode"] as? String,
553
+ let mode = PermissionMode(rawValue: value) else {
554
+ return .full
555
+ }
556
+
557
+ return mode
558
+ }
559
+
560
+ private static func loadHasCompletedSetupStatic() -> Bool {
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] else {
575
+ return false
576
+ }
577
+
578
+ if let value = json["setupCompleted"] as? Bool {
579
+ return value
580
+ }
581
+
582
+ return false
583
+ }
584
+
364
585
  private var configURL: URL {
365
586
  let configDir: URL
366
587
  if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] {
@@ -374,25 +595,145 @@ class GateService: ObservableObject {
374
595
  .appendingPathComponent("config.json")
375
596
  }
376
597
 
377
- private func loadAPIKey() -> String? {
598
+ private func readConfig() -> [String: Any] {
378
599
  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
600
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
601
+ return [:]
382
602
  }
383
- return key
603
+ return json
384
604
  }
385
605
 
386
- private func saveAPIKey(_ key: String) {
606
+ private func writeConfig(_ json: [String: Any]) {
387
607
  let dir = configURL.deletingLastPathComponent()
388
608
  try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
389
- let json: [String: Any] = ["apiKey": key]
390
609
  if let data = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted]) {
391
610
  try? data.write(to: configURL)
392
611
  }
393
612
  objectWillChange.send()
394
613
  }
395
614
 
615
+ private func loadAPIKey() -> String? {
616
+ readConfig()["apiKey"] as? String
617
+ }
618
+
619
+ private func saveAPIKey(_ key: String) {
620
+ var json = readConfig()
621
+ json["apiKey"] = key
622
+ writeConfig(json)
623
+ }
624
+
625
+ private func savePermissionMode(_ mode: PermissionMode) {
626
+ var json = readConfig()
627
+ json["permissionMode"] = mode.rawValue
628
+ writeConfig(json)
629
+ }
630
+
631
+ private func saveHasCompletedSetup(_ value: Bool) {
632
+ var json = readConfig()
633
+ json["setupCompleted"] = value
634
+ writeConfig(json)
635
+ }
636
+
637
+ private func isPermissionGranted(_ permission: SystemPermission) -> Bool {
638
+ switch permission {
639
+ case .accessibility:
640
+ return AXIsProcessTrusted()
641
+ }
642
+ }
643
+
644
+ private func parseTerminalPreviewLine(_ line: String) {
645
+ let body = stripToolTimestamp(from: line)
646
+
647
+ if body == "terminal preview:" {
648
+ activeTerminalPreviewId = nil
649
+ return
650
+ }
651
+
652
+ if body.hasPrefix("$ ") {
653
+ let (command, cwd) = parseCommandAndCwd(body)
654
+ let preview = TerminalPreview(
655
+ id: UUID(),
656
+ timestamp: Date(),
657
+ command: command,
658
+ cwd: cwd,
659
+ exitCode: nil,
660
+ durationMs: nil,
661
+ timedOut: false,
662
+ sandboxMode: nil,
663
+ stdoutPreview: nil,
664
+ stderrPreview: nil
665
+ )
666
+ terminalPreviews.append(preview)
667
+ if terminalPreviews.count > maxTerminalPreviews {
668
+ terminalPreviews.removeFirst(terminalPreviews.count - maxTerminalPreviews)
669
+ }
670
+ activeTerminalPreviewId = preview.id
671
+ return
672
+ }
673
+
674
+ guard let id = activeTerminalPreviewId,
675
+ let index = terminalPreviews.firstIndex(where: { $0.id == id }) else {
676
+ return
677
+ }
678
+
679
+ if body.hasPrefix("process: ") {
680
+ var updated = terminalPreviews[index]
681
+ updated.exitCode = parseInt(body, key: "exit=")
682
+ updated.durationMs = parseInt(body, key: "duration=")
683
+ updated.timedOut = body.contains(" timeout")
684
+ if body.contains("sandbox=os") {
685
+ updated.sandboxMode = "os"
686
+ } else if body.contains("sandbox=none") {
687
+ updated.sandboxMode = "none"
688
+ }
689
+ terminalPreviews[index] = updated
690
+ return
691
+ }
692
+
693
+ if body.hasPrefix("stdout: ") {
694
+ var updated = terminalPreviews[index]
695
+ updated.stdoutPreview = String(body.dropFirst("stdout: ".count))
696
+ terminalPreviews[index] = updated
697
+ return
698
+ }
699
+
700
+ if body.hasPrefix("stderr: ") {
701
+ var updated = terminalPreviews[index]
702
+ updated.stderrPreview = String(body.dropFirst("stderr: ".count))
703
+ terminalPreviews[index] = updated
704
+ }
705
+ }
706
+
707
+ private func stripToolTimestamp(from line: String) -> String {
708
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
709
+ guard trimmed.hasPrefix("["), let end = trimmed.firstIndex(of: "]") else {
710
+ return trimmed
711
+ }
712
+ let after = trimmed.index(after: end)
713
+ return String(trimmed[after...]).trimmingCharacters(in: .whitespaces)
714
+ }
715
+
716
+ private func parseCommandAndCwd(_ body: String) -> (String, String?) {
717
+ let text = String(body.dropFirst(2))
718
+ let marker = " (in "
719
+ guard let range = text.range(of: marker), text.hasSuffix(")") else {
720
+ return (text, nil)
721
+ }
722
+
723
+ let command = String(text[..<range.lowerBound])
724
+ let cwdStart = range.upperBound
725
+ let cwdEnd = text.index(before: text.endIndex)
726
+ let cwd = String(text[cwdStart..<cwdEnd])
727
+ return (command, cwd)
728
+ }
729
+
730
+ private func parseInt(_ text: String, key: String) -> Int? {
731
+ guard let range = text.range(of: key) else { return nil }
732
+ let suffix = text[range.upperBound...]
733
+ let digits = suffix.prefix { $0.isNumber }
734
+ return Int(digits)
735
+ }
736
+
396
737
  // MARK: - Poke Login Credentials
397
738
 
398
739
  private var pokeCredentialsURL: URL {
@@ -423,9 +764,8 @@ class GateService: ObservableObject {
423
764
 
424
765
  var authSource: AuthSource {
425
766
  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" {
767
+ let config = readConfig()
768
+ if let source = config["authSource"] as? String, source == "pokeLogin" {
429
769
  return .pokeLogin
430
770
  }
431
771
  if loadAPIKey() != nil {
@@ -437,18 +777,9 @@ class GateService: ObservableObject {
437
777
  return .none
438
778
  }
439
779
  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
- }
780
+ var json = readConfig()
447
781
  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()
782
+ writeConfig(json)
452
783
  }
453
784
  }
454
785
 
@@ -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
+ }