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.
- package/.github/workflows/release.yml +53 -3
- package/Gate.app +0 -0
- 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 +389 -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 +234 -91
- package/clients/Poke macOS Gate/Poke macOS Gate/SettingsView.swift +125 -81
- package/clients/Poke macOS Gate/Poke macOS Gate/SetupView.swift +157 -0
- package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/project.pbxproj +31 -11
- 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/macOS +0 -0
- package/package.json +3 -1
- package/src/agents.js +5 -8
- package/src/app.js +29 -5
- 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,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
|
|
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
|
-
|
|
381
|
-
return nil
|
|
623
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
624
|
+
return [:]
|
|
382
625
|
}
|
|
383
|
-
return
|
|
626
|
+
return json
|
|
384
627
|
}
|
|
385
628
|
|
|
386
|
-
private func
|
|
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 = (
|
|
427
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
|
@@ -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
|
+
}
|