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.
- package/.github/workflows/release.yml +53 -3
- package/README.md +48 -14
- package/bin/poke-gate.js +17 -0
- package/clients/Poke macOS Gate/Poke macOS Gate/AboutView.swift +7 -1
- package/clients/Poke macOS Gate/Poke macOS Gate/AccessibilityPermissionView.swift +58 -0
- package/clients/Poke macOS Gate/Poke macOS Gate/GateService.swift +354 -23
- package/clients/Poke macOS Gate/Poke macOS Gate/Info.plist +2 -0
- package/clients/Poke macOS Gate/Poke macOS Gate/LogsView.swift +1 -1
- package/clients/Poke macOS Gate/Poke macOS Gate/MacVisualStyle.swift +89 -0
- package/clients/Poke macOS Gate/Poke macOS Gate/PermissionRowView.swift +55 -0
- package/clients/Poke macOS Gate/Poke macOS Gate/Poke_macOS_GateApp.swift +241 -91
- package/clients/Poke macOS Gate/Poke macOS Gate/SettingsView.swift +131 -65
- package/clients/Poke macOS Gate/Poke macOS Gate/SetupView.swift +157 -0
- package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/project.pbxproj +28 -8
- package/docs/cli.md +19 -0
- package/docs/getting-started.md +9 -6
- package/docs/index.md +23 -18
- package/docs/macos-app.md +39 -4
- package/docs/security.md +62 -18
- package/examples/agents/battery.30m.js +1 -1
- package/examples/agents/screentime.24h.js +5 -6
- package/package.json +3 -1
- package/src/agents.js +5 -8
- package/src/app.js +3 -3
- package/src/mcp-server.js +502 -27
- package/src/permission-service.js +128 -0
- package/test/mcp-server-access-policy.test.js +40 -0
- package/test/mcp-server-loop-guard.test.js +57 -0
- package/test/mcp-server-sandbox-command.test.js +18 -0
- 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
|
|
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
|
-
|
|
381
|
-
return nil
|
|
600
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
601
|
+
return [:]
|
|
382
602
|
}
|
|
383
|
-
return
|
|
603
|
+
return json
|
|
384
604
|
}
|
|
385
605
|
|
|
386
|
-
private func
|
|
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 = (
|
|
427
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
|
@@ -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
|
+
}
|