poke-gate 0.1.4 → 0.1.6
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/clients/Poke macOS Gate/Poke macOS Gate/AgentsView.swift +59 -4
- package/clients/Poke macOS Gate/Poke macOS Gate/GateService.swift +176 -19
- package/clients/Poke macOS Gate/Poke macOS Gate/Info.plist +11 -0
- package/clients/Poke macOS Gate/Poke macOS Gate/Poke_macOS_GateApp.swift +18 -1
- package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/project.pbxproj +8 -2
- package/docs/.vitepress/theme/custom.css +7 -0
- package/docs/.vitepress/theme/index.js +4 -0
- package/docs/agents/index.md +8 -7
- package/docs/how-it-works.md +9 -8
- package/docs/index.md +2 -2
- package/examples/agents/context.30m.js +148 -0
- package/examples/agents/music.10m.js +89 -0
- package/package.json +1 -1
- package/src/app.js +60 -31
- package/src/mcp-server.js +18 -9
- package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/project.xcworkspace/xcuserdata/fka.xcuserdatad/UserInterfaceState.xcuserstate +0 -0
- package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/xcuserdata/fka.xcuserdatad/xcschemes/xcschememanagement.plist +0 -14
|
@@ -25,6 +25,8 @@ class AgentsViewModel: ObservableObject {
|
|
|
25
25
|
@Published var selectedAgent: AgentFile?
|
|
26
26
|
@Published var editorContent: String = ""
|
|
27
27
|
@Published var showingEnv: Bool = false
|
|
28
|
+
@Published var isRunning: Bool = false
|
|
29
|
+
@Published var lastRunOutput: String = ""
|
|
28
30
|
|
|
29
31
|
private var fileWatcher: DispatchSourceFileSystemObject?
|
|
30
32
|
private var dirFD: Int32 = -1
|
|
@@ -108,15 +110,20 @@ class AgentsViewModel: ObservableObject {
|
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
func select(_ agent: AgentFile) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
113
|
+
DispatchQueue.main.async {
|
|
114
|
+
self.selectedAgent = agent
|
|
115
|
+
self.showingEnv = false
|
|
116
|
+
self.loadContent()
|
|
117
|
+
}
|
|
114
118
|
}
|
|
115
119
|
|
|
116
120
|
func loadContent() {
|
|
117
121
|
guard let agent = selectedAgent else { return }
|
|
118
122
|
let url = showingEnv ? agent.envPath : agent.path
|
|
119
|
-
|
|
123
|
+
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? (showingEnv ? "# No .env file yet\n" : "")
|
|
124
|
+
DispatchQueue.main.async {
|
|
125
|
+
self.editorContent = content
|
|
126
|
+
}
|
|
120
127
|
}
|
|
121
128
|
|
|
122
129
|
func save() {
|
|
@@ -169,6 +176,45 @@ class AgentsViewModel: ObservableObject {
|
|
|
169
176
|
}
|
|
170
177
|
}
|
|
171
178
|
|
|
179
|
+
func runAgent(_ agent: AgentFile) {
|
|
180
|
+
guard !isRunning else { return }
|
|
181
|
+
isRunning = true
|
|
182
|
+
lastRunOutput = ""
|
|
183
|
+
|
|
184
|
+
let fullPath = GateService().shellPath()
|
|
185
|
+
let proc = Process()
|
|
186
|
+
let pipe = Pipe()
|
|
187
|
+
|
|
188
|
+
proc.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
|
189
|
+
proc.arguments = ["-c", "npx -y poke-gate run-agent \(agent.agentId)"]
|
|
190
|
+
proc.environment = ["HOME": NSHomeDirectory(), "PATH": fullPath]
|
|
191
|
+
proc.standardOutput = pipe
|
|
192
|
+
proc.standardError = pipe
|
|
193
|
+
proc.currentDirectoryURL = FileManager.default.homeDirectoryForCurrentUser
|
|
194
|
+
|
|
195
|
+
let handle = pipe.fileHandleForReading
|
|
196
|
+
handle.readabilityHandler = { [weak self] fh in
|
|
197
|
+
let data = fh.availableData
|
|
198
|
+
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
|
|
199
|
+
DispatchQueue.main.async {
|
|
200
|
+
self?.lastRunOutput += text
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
proc.terminationHandler = { [weak self] _ in
|
|
205
|
+
DispatchQueue.main.async {
|
|
206
|
+
self?.isRunning = false
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
do {
|
|
211
|
+
try proc.run()
|
|
212
|
+
} catch {
|
|
213
|
+
isRunning = false
|
|
214
|
+
lastRunOutput = "Failed to run: \(error.localizedDescription)"
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
172
218
|
func deleteAgent(_ agent: AgentFile) {
|
|
173
219
|
try? FileManager.default.removeItem(at: agent.path)
|
|
174
220
|
if agent.hasEnv {
|
|
@@ -301,6 +347,15 @@ struct AgentDetailView: View {
|
|
|
301
347
|
}
|
|
302
348
|
}
|
|
303
349
|
}
|
|
350
|
+
|
|
351
|
+
Button {
|
|
352
|
+
viewModel.runAgent(agent)
|
|
353
|
+
} label: {
|
|
354
|
+
Label(viewModel.isRunning ? "Running…" : "Run", systemImage: "play.fill")
|
|
355
|
+
.font(.caption)
|
|
356
|
+
}
|
|
357
|
+
.disabled(viewModel.isRunning)
|
|
358
|
+
.padding(.leading, 4)
|
|
304
359
|
}
|
|
305
360
|
.padding(.horizontal, 12)
|
|
306
361
|
.padding(.vertical, 8)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
import Combine
|
|
3
|
+
import ScreenCaptureKit
|
|
3
4
|
|
|
4
5
|
@MainActor
|
|
5
6
|
class GateService: ObservableObject {
|
|
@@ -20,6 +21,8 @@ class GateService: ObservableObject {
|
|
|
20
21
|
private var outputPipe: Pipe?
|
|
21
22
|
private var shouldRestart = true
|
|
22
23
|
private let maxLogs = 200
|
|
24
|
+
private var restartAttempts = 0
|
|
25
|
+
private var healthCheckTimer: Timer?
|
|
23
26
|
|
|
24
27
|
var apiKey: String {
|
|
25
28
|
get { loadAPIKey() ?? "" }
|
|
@@ -32,12 +35,75 @@ class GateService: ObservableObject {
|
|
|
32
35
|
|
|
33
36
|
func runPokeLogin() {
|
|
34
37
|
let fullPath = shellPath()
|
|
38
|
+
let npxBin = findNpx()
|
|
35
39
|
let proc = Process()
|
|
36
40
|
proc.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
|
37
|
-
proc.arguments = ["-c", "
|
|
41
|
+
proc.arguments = ["-c", "\(npxBin) -y poke@latest login"]
|
|
38
42
|
proc.environment = ["HOME": NSHomeDirectory(), "PATH": fullPath]
|
|
39
43
|
try? proc.run()
|
|
40
|
-
appendLog("Launched poke login — check your browser.")
|
|
44
|
+
appendLog("Launched poke login (npx: \(npxBin)) — check your browser.")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func captureAndSend() {
|
|
48
|
+
appendLog("Screenshot requested via deeplink.")
|
|
49
|
+
|
|
50
|
+
Task {
|
|
51
|
+
do {
|
|
52
|
+
let content = try await SCShareableContent.current
|
|
53
|
+
guard let display = content.displays.first else {
|
|
54
|
+
appendLog("No display found for screenshot.")
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let filter = SCContentFilter(display: display, excludingWindows: [])
|
|
59
|
+
let config = SCStreamConfiguration()
|
|
60
|
+
config.width = display.width * 2
|
|
61
|
+
config.height = display.height * 2
|
|
62
|
+
config.capturesAudio = false
|
|
63
|
+
|
|
64
|
+
let image = try await SCScreenshotManager.captureImage(
|
|
65
|
+
contentFilter: filter,
|
|
66
|
+
configuration: config
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
let rep = NSBitmapImageRep(cgImage: image)
|
|
70
|
+
guard let pngData = rep.representation(using: .png, properties: [:]) else {
|
|
71
|
+
appendLog("Failed to encode screenshot as PNG.")
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let tempPath = NSTemporaryDirectory() + "poke-gate-screenshot.png"
|
|
76
|
+
let tempURL = URL(fileURLWithPath: tempPath)
|
|
77
|
+
try pngData.write(to: tempURL)
|
|
78
|
+
appendLog("Screenshot saved to \(tempPath) (\(pngData.count) bytes)")
|
|
79
|
+
|
|
80
|
+
let base64 = pngData.base64EncodedString()
|
|
81
|
+
|
|
82
|
+
guard let token = loadPokeLoginToken() else {
|
|
83
|
+
appendLog("Cannot send screenshot: not signed in to Poke.")
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let url = URL(string: "https://poke.com/api/v1/inbound/api-message")!
|
|
88
|
+
var request = URLRequest(url: url)
|
|
89
|
+
request.httpMethod = "POST"
|
|
90
|
+
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
91
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
92
|
+
|
|
93
|
+
let message = "Here's a screenshot of my screen right now. [Image attached as base64 PNG, \(pngData.count) bytes, \(display.width)x\(display.height)]"
|
|
94
|
+
let body: [String: Any] = ["message": message]
|
|
95
|
+
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
96
|
+
|
|
97
|
+
let (_, response) = try await URLSession.shared.data(for: request)
|
|
98
|
+
if let httpResp = response as? HTTPURLResponse, httpResp.statusCode == 200 {
|
|
99
|
+
appendLog("Screenshot sent to Poke.")
|
|
100
|
+
} else {
|
|
101
|
+
appendLog("Failed to send screenshot to Poke.")
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
appendLog("Screenshot error: \(error.localizedDescription)")
|
|
105
|
+
}
|
|
106
|
+
}
|
|
41
107
|
}
|
|
42
108
|
|
|
43
109
|
func autoStartIfNeeded() {
|
|
@@ -79,7 +145,9 @@ class GateService: ObservableObject {
|
|
|
79
145
|
|
|
80
146
|
func stop() {
|
|
81
147
|
shouldRestart = false
|
|
148
|
+
stopHealthCheck()
|
|
82
149
|
killProcess()
|
|
150
|
+
restartAttempts = 0
|
|
83
151
|
status = .stopped
|
|
84
152
|
}
|
|
85
153
|
|
|
@@ -90,19 +158,82 @@ class GateService: ObservableObject {
|
|
|
90
158
|
}
|
|
91
159
|
}
|
|
92
160
|
|
|
93
|
-
|
|
94
|
-
let
|
|
95
|
-
let
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
161
|
+
func shellPath() -> String {
|
|
162
|
+
let home = NSHomeDirectory()
|
|
163
|
+
let fallback = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin"
|
|
164
|
+
|
|
165
|
+
// Try multiple shells/strategies to get PATH
|
|
166
|
+
let strategies: [(String, [String])] = [
|
|
167
|
+
("/bin/zsh", ["-ilc", "echo $PATH"]),
|
|
168
|
+
("/bin/zsh", ["-lc", "echo $PATH"]),
|
|
169
|
+
("/bin/bash", ["-lc", "echo $PATH"]),
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
for (shell, args) in strategies {
|
|
173
|
+
let proc = Process()
|
|
174
|
+
let pipe = Pipe()
|
|
175
|
+
proc.executableURL = URL(fileURLWithPath: shell)
|
|
176
|
+
proc.arguments = args
|
|
177
|
+
proc.standardOutput = pipe
|
|
178
|
+
proc.standardError = FileHandle.nullDevice
|
|
179
|
+
proc.environment = ["HOME": home]
|
|
180
|
+
do {
|
|
181
|
+
try proc.run()
|
|
182
|
+
proc.waitUntilExit()
|
|
183
|
+
if proc.terminationStatus == 0 {
|
|
184
|
+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
185
|
+
if let path = String(data: data, encoding: .utf8)?
|
|
186
|
+
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
187
|
+
!path.isEmpty {
|
|
188
|
+
return path
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Fallback: build PATH from common locations
|
|
197
|
+
var paths = fallback.split(separator: ":").map(String.init)
|
|
198
|
+
|
|
199
|
+
let commonDirs = [
|
|
200
|
+
"\(home)/.nvm/versions/node",
|
|
201
|
+
"\(home)/.volta/bin",
|
|
202
|
+
"\(home)/.fnm/aliases/default/bin",
|
|
203
|
+
"\(home)/.local/bin",
|
|
204
|
+
"\(home)/.cargo/bin",
|
|
205
|
+
"/opt/homebrew/bin",
|
|
206
|
+
"/usr/local/bin",
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
for dir in commonDirs {
|
|
210
|
+
if FileManager.default.fileExists(atPath: dir) {
|
|
211
|
+
if dir.contains(".nvm") {
|
|
212
|
+
// Find the latest node version in nvm
|
|
213
|
+
if let versions = try? FileManager.default.contentsOfDirectory(atPath: dir) {
|
|
214
|
+
if let latest = versions.sorted().last {
|
|
215
|
+
let binPath = "\(dir)/\(latest)/bin"
|
|
216
|
+
if !paths.contains(binPath) { paths.insert(binPath, at: 0) }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} else if !paths.contains(dir) {
|
|
220
|
+
paths.insert(dir, at: 0)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return paths.joined(separator: ":")
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private func findNpx() -> String {
|
|
229
|
+
let path = shellPath()
|
|
230
|
+
for dir in path.split(separator: ":") {
|
|
231
|
+
let npxPath = "\(dir)/npx"
|
|
232
|
+
if FileManager.default.isExecutableFile(atPath: npxPath) {
|
|
233
|
+
return npxPath
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return "npx"
|
|
106
237
|
}
|
|
107
238
|
|
|
108
239
|
private func launchProcess() {
|
|
@@ -112,15 +243,17 @@ class GateService: ObservableObject {
|
|
|
112
243
|
appendLog("Starting poke-gate…")
|
|
113
244
|
|
|
114
245
|
let fullPath = shellPath()
|
|
246
|
+
let npxBin = findNpx()
|
|
247
|
+
|
|
248
|
+
appendLog("Using npx at: \(npxBin)")
|
|
115
249
|
|
|
116
250
|
let proc = Process()
|
|
117
251
|
let pipe = Pipe()
|
|
118
252
|
|
|
119
253
|
proc.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
|
120
|
-
proc.arguments = ["-c", "
|
|
254
|
+
proc.arguments = ["-c", "\(npxBin) -y poke-gate --verbose"]
|
|
121
255
|
proc.environment = ProcessInfo.processInfo.environment.merging(
|
|
122
256
|
[
|
|
123
|
-
"POKE_API_KEY": resolveToken() ?? "",
|
|
124
257
|
"PATH": fullPath,
|
|
125
258
|
],
|
|
126
259
|
uniquingKeysWith: { _, new in new }
|
|
@@ -169,6 +302,8 @@ class GateService: ObservableObject {
|
|
|
169
302
|
|
|
170
303
|
if line.contains("Tunnel connected") || line.contains("Ready") {
|
|
171
304
|
status = .connected
|
|
305
|
+
restartAttempts = 0
|
|
306
|
+
startHealthCheck()
|
|
172
307
|
} else if line.contains("Tunnel disconnected") || line.contains("Reconnecting") {
|
|
173
308
|
status = .disconnected
|
|
174
309
|
} else if line.contains("Failed to connect") || line.contains("error") {
|
|
@@ -181,10 +316,14 @@ class GateService: ObservableObject {
|
|
|
181
316
|
|
|
182
317
|
private func handleTermination(exitCode: Int32) {
|
|
183
318
|
appendLog("Process exited with code \(exitCode)")
|
|
319
|
+
stopHealthCheck()
|
|
320
|
+
|
|
184
321
|
if shouldRestart {
|
|
322
|
+
restartAttempts += 1
|
|
323
|
+
let delay = min(Double(2 * (1 << min(restartAttempts - 1, 5))), 60.0)
|
|
185
324
|
status = .disconnected
|
|
186
|
-
appendLog("Restarting in
|
|
187
|
-
DispatchQueue.main.asyncAfter(deadline: .now() +
|
|
325
|
+
appendLog("Restarting in \(Int(delay))s (attempt \(restartAttempts))…")
|
|
326
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
188
327
|
if self.shouldRestart {
|
|
189
328
|
self.launchProcess()
|
|
190
329
|
}
|
|
@@ -194,6 +333,24 @@ class GateService: ObservableObject {
|
|
|
194
333
|
}
|
|
195
334
|
}
|
|
196
335
|
|
|
336
|
+
private func startHealthCheck() {
|
|
337
|
+
stopHealthCheck()
|
|
338
|
+
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
|
|
339
|
+
guard let self else { return }
|
|
340
|
+
Task { @MainActor in
|
|
341
|
+
if let proc = self.process, !proc.isRunning {
|
|
342
|
+
self.appendLog("Health check: process died, restarting.")
|
|
343
|
+
self.handleTermination(exitCode: -1)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private func stopHealthCheck() {
|
|
350
|
+
healthCheckTimer?.invalidate()
|
|
351
|
+
healthCheckTimer = nil
|
|
352
|
+
}
|
|
353
|
+
|
|
197
354
|
private func appendLog(_ line: String) {
|
|
198
355
|
let ts = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
|
|
199
356
|
logs.append("[\(ts)] \(line)")
|
|
@@ -4,5 +4,16 @@
|
|
|
4
4
|
<dict>
|
|
5
5
|
<key>LSUIElement</key>
|
|
6
6
|
<true/>
|
|
7
|
+
<key>CFBundleURLTypes</key>
|
|
8
|
+
<array>
|
|
9
|
+
<dict>
|
|
10
|
+
<key>CFBundleURLName</key>
|
|
11
|
+
<string>dev.fka.Poke-macOS-Gate</string>
|
|
12
|
+
<key>CFBundleURLSchemes</key>
|
|
13
|
+
<array>
|
|
14
|
+
<string>poke-gate</string>
|
|
15
|
+
</array>
|
|
16
|
+
</dict>
|
|
17
|
+
</array>
|
|
7
18
|
</dict>
|
|
8
19
|
</plist>
|
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
2
|
|
|
3
|
+
class AppDelegate: NSObject, NSApplicationDelegate {
|
|
4
|
+
var service: GateService?
|
|
5
|
+
|
|
6
|
+
func application(_ application: NSApplication, open urls: [URL]) {
|
|
7
|
+
for url in urls {
|
|
8
|
+
guard url.scheme == "poke-gate" else { continue }
|
|
9
|
+
if url.host == "screenshot" {
|
|
10
|
+
service?.captureAndSend()
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
3
16
|
@main
|
|
4
17
|
struct Poke_macOS_GateApp: App {
|
|
5
18
|
@StateObject private var service = GateService()
|
|
19
|
+
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
|
6
20
|
|
|
7
21
|
var body: some Scene {
|
|
8
22
|
MenuBarExtra {
|
|
9
23
|
PopoverContent(service: service)
|
|
10
|
-
.onAppear {
|
|
24
|
+
.onAppear {
|
|
25
|
+
service.autoStartIfNeeded()
|
|
26
|
+
appDelegate.service = service
|
|
27
|
+
}
|
|
11
28
|
} label: {
|
|
12
29
|
Image(systemName: menuBarIcon)
|
|
13
30
|
}
|
|
@@ -259,12 +259,15 @@
|
|
|
259
259
|
ENABLE_PREVIEWS = YES;
|
|
260
260
|
GENERATE_INFOPLIST_FILE = YES;
|
|
261
261
|
INFOPLIST_FILE = "Poke macOS Gate/Info.plist";
|
|
262
|
+
INFOPLIST_KEY_CFBundleDisplayName = "Poke macOS Gate";
|
|
263
|
+
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
|
262
264
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
|
263
265
|
LD_RUNPATH_SEARCH_PATHS = (
|
|
264
266
|
"$(inherited)",
|
|
265
267
|
"@executable_path/../Frameworks",
|
|
266
268
|
);
|
|
267
|
-
|
|
269
|
+
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
|
270
|
+
MARKETING_VERSION = 0.1.5;
|
|
268
271
|
PRODUCT_BUNDLE_IDENTIFIER = "dev.fka.Poke-macOS-Gate";
|
|
269
272
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
270
273
|
REGISTER_APP_GROUPS = YES;
|
|
@@ -291,12 +294,15 @@
|
|
|
291
294
|
ENABLE_PREVIEWS = YES;
|
|
292
295
|
GENERATE_INFOPLIST_FILE = YES;
|
|
293
296
|
INFOPLIST_FILE = "Poke macOS Gate/Info.plist";
|
|
297
|
+
INFOPLIST_KEY_CFBundleDisplayName = "Poke macOS Gate";
|
|
298
|
+
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
|
294
299
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
|
295
300
|
LD_RUNPATH_SEARCH_PATHS = (
|
|
296
301
|
"$(inherited)",
|
|
297
302
|
"@executable_path/../Frameworks",
|
|
298
303
|
);
|
|
299
|
-
|
|
304
|
+
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
|
305
|
+
MARKETING_VERSION = 0.1.5;
|
|
300
306
|
PRODUCT_BUNDLE_IDENTIFIER = "dev.fka.Poke-macOS-Gate";
|
|
301
307
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
302
308
|
REGISTER_APP_GROUPS = YES;
|
package/docs/agents/index.md
CHANGED
|
@@ -9,15 +9,16 @@ Agents are **push-only** — they send data to Poke, but Poke cannot reach back
|
|
|
9
9
|
:::
|
|
10
10
|
|
|
11
11
|
```mermaid
|
|
12
|
-
flowchart
|
|
13
|
-
subgraph
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
flowchart TB
|
|
13
|
+
subgraph mac ["Your Mac"]
|
|
14
|
+
direction TB
|
|
15
|
+
Source["Local data source\nBeeper, files, APIs, logs"]
|
|
16
|
+
Script["Agent script\nruns on a schedule"]
|
|
17
|
+
Source -->|reads| Script
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
Poke -->|replies| You["You"]
|
|
20
|
+
Script -->|sendMessage| Poke["Poke Agent"]
|
|
21
|
+
Poke -->|replies via iMessage,\nTelegram, or SMS| You["You"]
|
|
21
22
|
```
|
|
22
23
|
|
|
23
24
|
**Example:** A Beeper agent runs every hour, fetches your unread messages, and sends a digest to Poke. Now Poke knows who messaged you — and can answer "did anyone text me?" without needing your machine in real time.
|
package/docs/how-it-works.md
CHANGED
|
@@ -5,14 +5,15 @@ Poke Gate bridges your machine to Poke's cloud so your AI assistant can execute
|
|
|
5
5
|
## Architecture
|
|
6
6
|
|
|
7
7
|
```mermaid
|
|
8
|
-
flowchart
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
flowchart TB
|
|
9
|
+
You["You"] -->|message via iMessage,\nTelegram, or SMS| Agent["Poke Agent"]
|
|
10
|
+
Agent -->|tool call| Tunnel["MCP Tunnel"]
|
|
11
|
+
Tunnel -->|WebSocket| Gate["Poke Gate"]
|
|
12
|
+
Gate -->|execute| Mac["Your Mac"]
|
|
13
|
+
Mac -->|result| Gate
|
|
14
|
+
Gate -->|response| Tunnel
|
|
15
|
+
Tunnel -->|result| Agent
|
|
16
|
+
Agent -->|reply| You
|
|
16
17
|
```
|
|
17
18
|
|
|
18
19
|
## Step by step
|
package/docs/index.md
CHANGED
|
@@ -3,8 +3,8 @@ layout: home
|
|
|
3
3
|
|
|
4
4
|
hero:
|
|
5
5
|
name: Poke Gate
|
|
6
|
-
text:
|
|
7
|
-
tagline:
|
|
6
|
+
text: A two-way bridge between your Mac and your AI.
|
|
7
|
+
tagline: Poke pulls from your Mac when you ask. Your Mac pushes to Poke when something happens. Run commands, read files, take screenshots — and automate it all with Agents.
|
|
8
8
|
image:
|
|
9
9
|
src: /logo.png
|
|
10
10
|
alt: Poke Gate
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @agent context
|
|
3
|
+
* @name Context Fingerprint
|
|
4
|
+
* @description Sends a tiny snapshot of your Mac's state to Poke — volume, battery, WiFi, displays, camera. Maximum context, zero private data.
|
|
5
|
+
* @interval 30m
|
|
6
|
+
* @author f
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Poke, getToken } from "poke";
|
|
10
|
+
import { execSync } from "node:child_process";
|
|
11
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
|
|
15
|
+
const token = getToken();
|
|
16
|
+
if (!token) {
|
|
17
|
+
console.error("Not signed in. Run: npx poke login");
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STATE_FILE = join(homedir(), ".config", "poke-gate", "agents", ".context-state.json");
|
|
22
|
+
|
|
23
|
+
function loadState() {
|
|
24
|
+
try { return JSON.parse(readFileSync(STATE_FILE, "utf-8")); }
|
|
25
|
+
catch { return {}; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function saveState(state) {
|
|
29
|
+
writeFileSync(STATE_FILE, JSON.stringify(state));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function run(cmd) {
|
|
33
|
+
try { return execSync(cmd, { encoding: "utf-8", timeout: 5000 }).trim(); }
|
|
34
|
+
catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getVolume() {
|
|
38
|
+
const raw = run("osascript -e 'get volume settings'");
|
|
39
|
+
if (!raw) return { volume: null, muted: null };
|
|
40
|
+
const vol = raw.match(/output volume:(\d+)/);
|
|
41
|
+
const muted = raw.includes("output muted:true");
|
|
42
|
+
return { volume: vol ? parseInt(vol[1]) : null, muted };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getBattery() {
|
|
46
|
+
const raw = run("pmset -g batt");
|
|
47
|
+
if (!raw) return { level: null, charging: null };
|
|
48
|
+
const match = raw.match(/(\d+)%/);
|
|
49
|
+
const charging = raw.includes("AC Power") || raw.includes("charging");
|
|
50
|
+
return { level: match ? parseInt(match[1]) : null, charging };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getWifi() {
|
|
54
|
+
const raw = run("networksetup -getairportnetwork en0");
|
|
55
|
+
if (!raw || raw.includes("not associated") || raw.includes("off")) return null;
|
|
56
|
+
return raw.replace("Current Wi-Fi Network: ", "").trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getDisplayCount() {
|
|
60
|
+
const raw = run("system_profiler SPDisplaysDataType");
|
|
61
|
+
if (!raw) return 0;
|
|
62
|
+
return (raw.match(/Resolution:/g) || []).length;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getCameraInUse() {
|
|
66
|
+
const raw = run("lsof 2>/dev/null | grep -c 'AppleCamera\\|VDC\\|iSight'");
|
|
67
|
+
return raw && parseInt(raw) > 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getBluetoothDevices() {
|
|
71
|
+
const raw = run("system_profiler SPBluetoothDataType 2>/dev/null");
|
|
72
|
+
if (!raw) return [];
|
|
73
|
+
const devices = [];
|
|
74
|
+
const lines = raw.split("\n");
|
|
75
|
+
for (let i = 0; i < lines.length; i++) {
|
|
76
|
+
if (lines[i].includes("Connected: Yes")) {
|
|
77
|
+
for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
|
|
78
|
+
const nameMatch = lines[j].match(/^\s{8}(\S.+):$/);
|
|
79
|
+
if (nameMatch) {
|
|
80
|
+
devices.push(nameMatch[1]);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return devices;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getUptime() {
|
|
90
|
+
const raw = run("uptime");
|
|
91
|
+
if (!raw) return null;
|
|
92
|
+
const match = raw.match(/up\s+(.+?),\s+\d+ user/);
|
|
93
|
+
return match ? match[1].trim() : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Gather all signals
|
|
97
|
+
const volume = getVolume();
|
|
98
|
+
const battery = getBattery();
|
|
99
|
+
const wifi = getWifi();
|
|
100
|
+
const displays = getDisplayCount();
|
|
101
|
+
const camera = getCameraInUse();
|
|
102
|
+
const bluetooth = getBluetoothDevices();
|
|
103
|
+
const uptime = getUptime();
|
|
104
|
+
|
|
105
|
+
const now = new Date();
|
|
106
|
+
const hour = now.getHours();
|
|
107
|
+
const timeOfDay = hour < 6 ? "night" : hour < 12 ? "morning" : hour < 18 ? "afternoon" : "evening";
|
|
108
|
+
|
|
109
|
+
const fingerprint = {
|
|
110
|
+
time: now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }),
|
|
111
|
+
timeOfDay,
|
|
112
|
+
volume: volume.muted ? "muted" : `${volume.volume}%`,
|
|
113
|
+
battery: `${battery.level}%${battery.charging ? " (charging)" : " (on battery)"}`,
|
|
114
|
+
wifi: wifi || "not connected",
|
|
115
|
+
displays,
|
|
116
|
+
camera: camera ? "in use" : "off",
|
|
117
|
+
bluetooth: bluetooth.length > 0 ? bluetooth.join(", ") : "none",
|
|
118
|
+
uptime: uptime || "unknown",
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
console.log("Context fingerprint:", JSON.stringify(fingerprint, null, 2));
|
|
122
|
+
|
|
123
|
+
// Check if anything meaningful changed
|
|
124
|
+
const state = loadState();
|
|
125
|
+
const key = `${fingerprint.volume}|${fingerprint.wifi}|${fingerprint.displays}|${fingerprint.camera}|${fingerprint.battery}`;
|
|
126
|
+
|
|
127
|
+
if (key === state.lastKey) {
|
|
128
|
+
console.log("No meaningful change, skipping.");
|
|
129
|
+
process.exit(0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
saveState({ lastKey: key });
|
|
133
|
+
|
|
134
|
+
// Build a natural language summary
|
|
135
|
+
let summary = `Context update (${fingerprint.time}, ${timeOfDay}):\n`;
|
|
136
|
+
summary += `• Volume: ${fingerprint.volume}\n`;
|
|
137
|
+
summary += `• Battery: ${fingerprint.battery}\n`;
|
|
138
|
+
summary += `• WiFi: ${fingerprint.wifi}\n`;
|
|
139
|
+
summary += `• Displays: ${fingerprint.displays}\n`;
|
|
140
|
+
summary += `• Camera: ${fingerprint.camera}\n`;
|
|
141
|
+
summary += `• Bluetooth: ${fingerprint.bluetooth}\n`;
|
|
142
|
+
summary += `• Uptime: ${fingerprint.uptime}\n`;
|
|
143
|
+
summary += `\nUse this to understand my current situation without asking.`;
|
|
144
|
+
|
|
145
|
+
const poke = new Poke({ apiKey: token });
|
|
146
|
+
await poke.sendMessage(summary);
|
|
147
|
+
|
|
148
|
+
console.log("Sent context to Poke.");
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @agent music
|
|
3
|
+
* @name Music Log
|
|
4
|
+
* @description Tracks what you're listening to and sends a log to Poke. Supports Apple Music and Spotify.
|
|
5
|
+
* @interval 10m
|
|
6
|
+
* @author f
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Poke, getToken } from "poke";
|
|
10
|
+
import { execSync } from "node:child_process";
|
|
11
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
|
|
15
|
+
const token = getToken();
|
|
16
|
+
if (!token) {
|
|
17
|
+
console.error("Not signed in. Run: npx poke login");
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STATE_FILE = join(homedir(), ".config", "poke-gate", "agents", ".music-state.json");
|
|
22
|
+
|
|
23
|
+
function loadState() {
|
|
24
|
+
try { return JSON.parse(readFileSync(STATE_FILE, "utf-8")); }
|
|
25
|
+
catch { return { lastTrack: null }; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function saveState(state) {
|
|
29
|
+
writeFileSync(STATE_FILE, JSON.stringify(state));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getNowPlaying() {
|
|
33
|
+
// Try Spotify first
|
|
34
|
+
try {
|
|
35
|
+
const result = execSync(`osascript -e '
|
|
36
|
+
if application "Spotify" is running then
|
|
37
|
+
tell application "Spotify"
|
|
38
|
+
if player state is playing then
|
|
39
|
+
return name of current track & " — " & artist of current track & " (" & album of current track & ")"
|
|
40
|
+
end if
|
|
41
|
+
end tell
|
|
42
|
+
end if
|
|
43
|
+
return "not_playing"
|
|
44
|
+
'`, { encoding: "utf-8", timeout: 5000 }).trim();
|
|
45
|
+
if (result && result !== "not_playing") return { source: "Spotify", track: result };
|
|
46
|
+
} catch {}
|
|
47
|
+
|
|
48
|
+
// Try Apple Music
|
|
49
|
+
try {
|
|
50
|
+
const result = execSync(`osascript -e '
|
|
51
|
+
if application "Music" is running then
|
|
52
|
+
tell application "Music"
|
|
53
|
+
if player state is playing then
|
|
54
|
+
return name of current track & " — " & artist of current track & " (" & album of current track & ")"
|
|
55
|
+
end if
|
|
56
|
+
end tell
|
|
57
|
+
end if
|
|
58
|
+
return "not_playing"
|
|
59
|
+
'`, { encoding: "utf-8", timeout: 5000 }).trim();
|
|
60
|
+
if (result && result !== "not_playing") return { source: "Apple Music", track: result };
|
|
61
|
+
} catch {}
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const playing = getNowPlaying();
|
|
67
|
+
const state = loadState();
|
|
68
|
+
|
|
69
|
+
if (!playing) {
|
|
70
|
+
console.log("Nothing playing right now.");
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(`Now playing (${playing.source}): ${playing.track}`);
|
|
75
|
+
|
|
76
|
+
if (playing.track === state.lastTrack) {
|
|
77
|
+
console.log("Same track as last check, skipping.");
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
saveState({ lastTrack: playing.track });
|
|
82
|
+
|
|
83
|
+
const poke = new Poke({ apiKey: token });
|
|
84
|
+
await poke.sendMessage(
|
|
85
|
+
`I'm currently listening to: ${playing.track} (on ${playing.source}). ` +
|
|
86
|
+
`Remember this for context about my mood and taste.`
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
console.log("Sent to Poke.");
|
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -11,6 +11,10 @@ function log(msg) {
|
|
|
11
11
|
console.log(`[${ts}] ${msg}`);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function sleep(ms) {
|
|
15
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
}
|
|
17
|
+
|
|
14
18
|
async function ensureAuthenticated() {
|
|
15
19
|
if (!isLoggedIn()) {
|
|
16
20
|
log("Signing in to Poke...");
|
|
@@ -25,6 +29,53 @@ async function ensureAuthenticated() {
|
|
|
25
29
|
return token;
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
async function connectTunnel(mcpUrl, token) {
|
|
33
|
+
let attempt = 0;
|
|
34
|
+
const maxDelay = 60_000;
|
|
35
|
+
|
|
36
|
+
while (true) {
|
|
37
|
+
attempt++;
|
|
38
|
+
const delay = Math.min(2000 * Math.pow(2, attempt - 1), maxDelay);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
log(attempt > 1 ? `Reconnecting tunnel (attempt ${attempt})…` : "Connecting tunnel to Poke...");
|
|
42
|
+
|
|
43
|
+
await startTunnel({
|
|
44
|
+
mcpUrl,
|
|
45
|
+
onEvent: (type, data) => {
|
|
46
|
+
switch (type) {
|
|
47
|
+
case "connected":
|
|
48
|
+
attempt = 0;
|
|
49
|
+
log(`Tunnel connected (${data.connectionId})`);
|
|
50
|
+
log("Ready — your Poke agent can now access this machine.");
|
|
51
|
+
notifyPoke(data.connectionId, token);
|
|
52
|
+
startAgentScheduler();
|
|
53
|
+
break;
|
|
54
|
+
case "disconnected":
|
|
55
|
+
log("Tunnel disconnected. PokeTunnel will reconnect automatically.");
|
|
56
|
+
break;
|
|
57
|
+
case "error":
|
|
58
|
+
log(`Tunnel error: ${data}`);
|
|
59
|
+
break;
|
|
60
|
+
case "tools-synced":
|
|
61
|
+
log(`Tools synced: ${data}`);
|
|
62
|
+
break;
|
|
63
|
+
case "oauth-required":
|
|
64
|
+
log(`OAuth required: ${data}`);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
break;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
log(`Tunnel failed: ${err.message}`);
|
|
73
|
+
log(`Retrying in ${Math.round(delay / 1000)}s…`);
|
|
74
|
+
await sleep(delay);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
28
79
|
async function main() {
|
|
29
80
|
log("poke-gate starting...");
|
|
30
81
|
|
|
@@ -35,37 +86,7 @@ async function main() {
|
|
|
35
86
|
|
|
36
87
|
const mcpUrl = `http://localhost:${port}/mcp`;
|
|
37
88
|
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
await startTunnel({
|
|
41
|
-
mcpUrl,
|
|
42
|
-
onEvent: (type, data) => {
|
|
43
|
-
switch (type) {
|
|
44
|
-
case "connected":
|
|
45
|
-
log(`Tunnel connected (${data.connectionId})`);
|
|
46
|
-
log("Ready — your Poke agent can now access this machine.");
|
|
47
|
-
notifyPoke(data.connectionId, token);
|
|
48
|
-
startAgentScheduler();
|
|
49
|
-
break;
|
|
50
|
-
case "disconnected":
|
|
51
|
-
log("Tunnel disconnected. Reconnecting...");
|
|
52
|
-
break;
|
|
53
|
-
case "error":
|
|
54
|
-
log(`Tunnel error: ${data}`);
|
|
55
|
-
break;
|
|
56
|
-
case "tools-synced":
|
|
57
|
-
log(`Tools synced: ${data}`);
|
|
58
|
-
break;
|
|
59
|
-
case "oauth-required":
|
|
60
|
-
log(`OAuth required: ${data}`);
|
|
61
|
-
break;
|
|
62
|
-
}
|
|
63
|
-
},
|
|
64
|
-
});
|
|
65
|
-
} catch (err) {
|
|
66
|
-
log(`Failed to connect: ${err.message}`);
|
|
67
|
-
process.exit(1);
|
|
68
|
-
}
|
|
89
|
+
await connectTunnel(mcpUrl, token);
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
async function notifyPoke(connectionId, token) {
|
|
@@ -93,4 +114,12 @@ process.on("SIGTERM", () => {
|
|
|
93
114
|
process.exit(0);
|
|
94
115
|
});
|
|
95
116
|
|
|
117
|
+
process.on("uncaughtException", (err) => {
|
|
118
|
+
log(`Uncaught exception: ${err.message}`);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
process.on("unhandledRejection", (err) => {
|
|
122
|
+
log(`Unhandled rejection: ${err instanceof Error ? err.message : String(err)}`);
|
|
123
|
+
});
|
|
124
|
+
|
|
96
125
|
main();
|
package/src/mcp-server.js
CHANGED
|
@@ -234,16 +234,25 @@ function handleToolCall(name, args) {
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
case "take_screenshot": {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return { content: [{ type: "text", text: `Screenshot saved to ${dest}` }] };
|
|
237
|
+
logTool(name, args);
|
|
238
|
+
|
|
239
|
+
return runCommand('open -Ra "Poke macOS Gate" 2>/dev/null', homedir()).then((appCheck) => {
|
|
240
|
+
if (appCheck.exitCode === 0) {
|
|
241
|
+
return runCommand('open "poke-gate://screenshot"', homedir()).then(() => {
|
|
242
|
+
return { content: [{ type: "text", text: "Screenshot captured and sent to Poke via the macOS app." }] };
|
|
243
|
+
});
|
|
245
244
|
}
|
|
246
|
-
|
|
245
|
+
|
|
246
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
247
|
+
const dest = args.path
|
|
248
|
+
? resolve(args.path.replace(/^~/, homedir()))
|
|
249
|
+
: join(homedir(), "Desktop", `screenshot-${ts}.png`);
|
|
250
|
+
return runCommand(`/usr/sbin/screencapture -x "${dest}"`, homedir()).then((result) => {
|
|
251
|
+
if (result.exitCode === 0) {
|
|
252
|
+
return { content: [{ type: "text", text: `Screenshot saved to ${dest}` }] };
|
|
253
|
+
}
|
|
254
|
+
return { content: [{ type: "text", text: `Screenshot failed: ${result.stderr || "unknown error"}. Grant Screen Recording permission to Terminal or install the Poke macOS Gate app.` }], isError: true };
|
|
255
|
+
});
|
|
247
256
|
});
|
|
248
257
|
}
|
|
249
258
|
|
|
Binary file
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
-
<plist version="1.0">
|
|
4
|
-
<dict>
|
|
5
|
-
<key>SchemeUserState</key>
|
|
6
|
-
<dict>
|
|
7
|
-
<key>Poke macOS Gate.xcscheme_^#shared#^_</key>
|
|
8
|
-
<dict>
|
|
9
|
-
<key>orderHint</key>
|
|
10
|
-
<integer>0</integer>
|
|
11
|
-
</dict>
|
|
12
|
-
</dict>
|
|
13
|
-
</dict>
|
|
14
|
-
</plist>
|