poke-gate 0.2.2 → 0.3.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/README.md +31 -26
- package/bin/poke-gate.js +6 -0
- package/clients/Poke macOS Gate/Poke macOS Gate/AccessibilityPermissionView.swift +1 -1
- package/clients/Poke macOS Gate/Poke macOS Gate/GateService.swift +116 -58
- package/clients/Poke macOS Gate/Poke macOS Gate/Info.plist +0 -11
- package/clients/Poke macOS Gate/Poke macOS Gate/Poke_macOS_GateApp.swift +74 -13
- package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/project.pbxproj +2 -2
- package/docs/cli.md +16 -0
- package/package.json +1 -1
- package/src/app.js +5 -4
- package/src/download-macos.js +133 -0
- package/src/mcp-server.js +35 -22
- package/src/take-screenshot.js +48 -0
- package/src/tunnel.js +1 -1
- package/Gate.app +0 -0
package/README.md
CHANGED
|
@@ -28,6 +28,25 @@ Run Poke Gate on your Mac, then message Poke from iMessage, Telegram, or SMS to
|
|
|
28
28
|
brew install f/tap/poke-gate
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
**Install via npx**
|
|
32
|
+
|
|
33
|
+
If you have Node.js installed, you can download and install the macOS app with a single command:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx poke-gate download-macos
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
This downloads the latest DMG from GitHub Releases, installs the app to `/Applications`, and clears the quarantine flag automatically.
|
|
40
|
+
|
|
41
|
+
**Don't have Node.js?** Install it first:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Option 1: Homebrew
|
|
45
|
+
brew install node
|
|
46
|
+
|
|
47
|
+
# Option 2: Download from https://nodejs.org
|
|
48
|
+
```
|
|
49
|
+
|
|
31
50
|
**Manual download**
|
|
32
51
|
|
|
33
52
|
Download the latest **Poke.macOS.Gate.dmg** from [Releases](https://github.com/f/poke-gate/releases), open it, and drag to Applications. Since the app is not notarized, you may need to run:
|
|
@@ -36,7 +55,9 @@ Download the latest **Poke.macOS.Gate.dmg** from [Releases](https://github.com/f
|
|
|
36
55
|
xattr -cr /Applications/Poke\ macOS\ Gate.app
|
|
37
56
|
```
|
|
38
57
|
|
|
39
|
-
**CLI** (no macOS app needed)
|
|
58
|
+
**CLI only** (no macOS app needed)
|
|
59
|
+
|
|
60
|
+
If you just want to run poke-gate from the terminal without the menu bar app:
|
|
40
61
|
|
|
41
62
|
```bash
|
|
42
63
|
npx poke-gate
|
|
@@ -123,7 +144,9 @@ Hit **Run** in Xcode, or build from the command line:
|
|
|
123
144
|
|
|
124
145
|
## CLI usage
|
|
125
146
|
|
|
126
|
-
If you
|
|
147
|
+
The CLI requires [Node.js](https://nodejs.org) 18 or later. If you don't have it, install via `brew install node` or download from [nodejs.org](https://nodejs.org).
|
|
148
|
+
|
|
149
|
+
Start the gate:
|
|
127
150
|
|
|
128
151
|
```bash
|
|
129
152
|
npx poke-gate
|
|
@@ -142,6 +165,12 @@ npx poke-gate --mode limited
|
|
|
142
165
|
npx poke-gate --mode sandbox
|
|
143
166
|
```
|
|
144
167
|
|
|
168
|
+
Install or update the macOS app:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
npx poke-gate download-macos
|
|
172
|
+
```
|
|
173
|
+
|
|
145
174
|
Config is stored at `~/.config/poke-gate/config.json`.
|
|
146
175
|
|
|
147
176
|
## Agents
|
|
@@ -264,30 +293,6 @@ POKE_GATE_PERMISSION_MODE=limited npx poke-gate
|
|
|
264
293
|
|
|
265
294
|
Only run Poke Gate on machines and networks you trust. Use `limited` or `sandbox` mode if you want tighter restrictions.
|
|
266
295
|
|
|
267
|
-
## Project structure
|
|
268
|
-
|
|
269
|
-
```
|
|
270
|
-
clients/
|
|
271
|
-
Poke macOS Gate/ macOS menu bar app (SwiftUI)
|
|
272
|
-
bin/
|
|
273
|
-
poke-gate.js CLI entry point with --mode flag
|
|
274
|
-
src/
|
|
275
|
-
app.js Startup: MCP server + tunnel + agent scheduler
|
|
276
|
-
agents.js Agent discovery, scheduling, env loading, download
|
|
277
|
-
mcp-server.js JSON-RPC MCP handler, tools, access policy, sandbox
|
|
278
|
-
permission-service.js HMAC approval tokens, session whitelisting
|
|
279
|
-
tunnel.js PokeTunnel wrapper
|
|
280
|
-
test/
|
|
281
|
-
mcp-server-access-policy.test.js
|
|
282
|
-
mcp-server-loop-guard.test.js
|
|
283
|
-
mcp-server-sandbox-command.test.js
|
|
284
|
-
permission-service.test.js
|
|
285
|
-
examples/
|
|
286
|
-
agents/
|
|
287
|
-
beeper.1h.js Example: Beeper message digest agent
|
|
288
|
-
.env.beeper Example env file for beeper agent
|
|
289
|
-
```
|
|
290
|
-
|
|
291
296
|
## Credits
|
|
292
297
|
|
|
293
298
|
- [Poke](https://poke.com) by [The Interaction Company of California](https://interaction.co)
|
package/bin/poke-gate.js
CHANGED
|
@@ -39,6 +39,12 @@ async function main() {
|
|
|
39
39
|
const prompt = promptIdx !== -1 ? args.slice(promptIdx + 1).join(" ") : args.slice(2).join(" ") || null;
|
|
40
40
|
const { createAgent } = await import("../src/agent-create.js");
|
|
41
41
|
await createAgent(prompt);
|
|
42
|
+
} else if (args[0] === "download-macos") {
|
|
43
|
+
const { downloadMacOSApp } = await import("../src/download-macos.js");
|
|
44
|
+
await downloadMacOSApp();
|
|
45
|
+
} else if (args[0] === "take-screenshot") {
|
|
46
|
+
const { takeScreenshot } = await import("../src/take-screenshot.js");
|
|
47
|
+
await takeScreenshot();
|
|
42
48
|
} else {
|
|
43
49
|
const mode = parseMode();
|
|
44
50
|
if (mode) {
|
|
@@ -4,7 +4,7 @@ struct AccessibilityPermissionView: View {
|
|
|
4
4
|
@ObservedObject var service: GateService
|
|
5
5
|
|
|
6
6
|
var body: some View {
|
|
7
|
-
let granted = service.
|
|
7
|
+
let granted = service.isPermissionGranted(.accessibility)
|
|
8
8
|
|
|
9
9
|
VStack(alignment: .leading, spacing: 10) {
|
|
10
10
|
HStack(spacing: 8) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
import Combine
|
|
3
|
-
import ScreenCaptureKit
|
|
4
3
|
import AppKit
|
|
5
4
|
import CoreGraphics
|
|
6
5
|
import ApplicationServices
|
|
@@ -41,30 +40,35 @@ class GateService: ObservableObject {
|
|
|
41
40
|
|
|
42
41
|
enum SystemPermission: String, CaseIterable, Identifiable {
|
|
43
42
|
case accessibility
|
|
43
|
+
case screenRecording
|
|
44
44
|
|
|
45
45
|
var id: String { rawValue }
|
|
46
46
|
|
|
47
47
|
var title: String {
|
|
48
48
|
switch self {
|
|
49
49
|
case .accessibility: return "Accessibility"
|
|
50
|
+
case .screenRecording: return "Screen Recording"
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
var subtitle: String {
|
|
54
55
|
switch self {
|
|
55
56
|
case .accessibility: return "Needed for keyboard, mouse, and automation-style control."
|
|
57
|
+
case .screenRecording: return "Needed for the take_screenshot tool."
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
var settingsURL: String {
|
|
60
62
|
switch self {
|
|
61
63
|
case .accessibility: return "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
|
|
64
|
+
case .screenRecording: return "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
|
|
62
65
|
}
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
var systemImageName: String {
|
|
66
69
|
switch self {
|
|
67
70
|
case .accessibility: return "figure.wave"
|
|
71
|
+
case .screenRecording: return "camera.viewfinder"
|
|
68
72
|
}
|
|
69
73
|
}
|
|
70
74
|
}
|
|
@@ -97,6 +101,10 @@ class GateService: ObservableObject {
|
|
|
97
101
|
@Published var permissionMode: PermissionMode
|
|
98
102
|
@Published var hasCompletedSetup: Bool
|
|
99
103
|
@Published var systemPermissionStatuses: [SystemPermissionStatus] = []
|
|
104
|
+
@Published var availableUpdate: String? = nil
|
|
105
|
+
@Published var isUpdating = false
|
|
106
|
+
@Published var isCheckingForUpdate = false
|
|
107
|
+
@Published var updateCheckResult: String? = nil
|
|
100
108
|
|
|
101
109
|
private var hasAutoStarted = false
|
|
102
110
|
private var process: Process?
|
|
@@ -121,7 +129,9 @@ class GateService: ObservableObject {
|
|
|
121
129
|
object: nil,
|
|
122
130
|
queue: .main
|
|
123
131
|
) { [weak self] _ in
|
|
124
|
-
|
|
132
|
+
Task { @MainActor in
|
|
133
|
+
self?.refreshSystemPermissions()
|
|
134
|
+
}
|
|
125
135
|
}
|
|
126
136
|
}
|
|
127
137
|
|
|
@@ -233,74 +243,124 @@ class GateService: ObservableObject {
|
|
|
233
243
|
case .accessibility:
|
|
234
244
|
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
|
235
245
|
_ = AXIsProcessTrustedWithOptions(options)
|
|
246
|
+
case .screenRecording:
|
|
247
|
+
guard let url = URL(string: permission.settingsURL) else { return }
|
|
248
|
+
NSWorkspace.shared.open(url)
|
|
236
249
|
}
|
|
237
250
|
}
|
|
238
251
|
|
|
239
|
-
func captureAndSend() {
|
|
240
|
-
appendLog("Screenshot requested via deeplink.")
|
|
241
252
|
|
|
242
|
-
|
|
253
|
+
func autoStartIfNeeded() {
|
|
254
|
+
guard !hasAutoStarted else { return }
|
|
255
|
+
hasAutoStarted = true
|
|
256
|
+
if hasAPIKey {
|
|
257
|
+
start()
|
|
258
|
+
}
|
|
259
|
+
checkForUpdate(silent: true)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
func checkForUpdate(silent: Bool = false) {
|
|
263
|
+
let currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
|
|
264
|
+
isCheckingForUpdate = true
|
|
265
|
+
updateCheckResult = nil
|
|
266
|
+
|
|
267
|
+
Task.detached {
|
|
268
|
+
let start = ContinuousClock.now
|
|
269
|
+
var found = false
|
|
270
|
+
|
|
271
|
+
defer {
|
|
272
|
+
Task { @MainActor in
|
|
273
|
+
self.isCheckingForUpdate = false
|
|
274
|
+
if !found && !silent {
|
|
275
|
+
self.updateCheckResult = "You're on the latest version."
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
guard let url = URL(string: "https://registry.npmjs.org/poke-gate/latest") else { return }
|
|
243
281
|
do {
|
|
244
|
-
let
|
|
245
|
-
guard let
|
|
246
|
-
|
|
247
|
-
|
|
282
|
+
let (data, _) = try await URLSession.shared.data(from: url)
|
|
283
|
+
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
284
|
+
let latestVersion = json["version"] as? String else { return }
|
|
285
|
+
|
|
286
|
+
let elapsed = ContinuousClock.now - start
|
|
287
|
+
if elapsed < .seconds(1) {
|
|
288
|
+
try await Task.sleep(for: .seconds(1) - elapsed)
|
|
248
289
|
}
|
|
249
290
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
let image = try await SCScreenshotManager.captureImage(
|
|
257
|
-
contentFilter: filter,
|
|
258
|
-
configuration: config
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
let rep = NSBitmapImageRep(cgImage: image)
|
|
262
|
-
guard let pngData = rep.representation(using: .png, properties: [:]) else {
|
|
263
|
-
appendLog("Failed to encode screenshot as PNG.")
|
|
264
|
-
return
|
|
291
|
+
if Self.isNewer(latestVersion, than: currentVersion) {
|
|
292
|
+
found = true
|
|
293
|
+
await MainActor.run {
|
|
294
|
+
self.availableUpdate = latestVersion
|
|
295
|
+
self.appendLog("Update available: v\(latestVersion) (current: v\(currentVersion))")
|
|
296
|
+
}
|
|
265
297
|
}
|
|
298
|
+
} catch {}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
266
301
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
302
|
+
private nonisolated static func isNewer(_ remote: String, than local: String) -> Bool {
|
|
303
|
+
let r = remote.split(separator: ".").compactMap { Int($0) }
|
|
304
|
+
let l = local.split(separator: ".").compactMap { Int($0) }
|
|
305
|
+
for i in 0..<max(r.count, l.count) {
|
|
306
|
+
let rv = i < r.count ? r[i] : 0
|
|
307
|
+
let lv = i < l.count ? l[i] : 0
|
|
308
|
+
if rv > lv { return true }
|
|
309
|
+
if rv < lv { return false }
|
|
310
|
+
}
|
|
311
|
+
return false
|
|
312
|
+
}
|
|
271
313
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
314
|
+
func performUpdate() {
|
|
315
|
+
guard let version = availableUpdate else { return }
|
|
316
|
+
isUpdating = true
|
|
317
|
+
appendLog("Updating to v\(version)...")
|
|
318
|
+
|
|
319
|
+
stop()
|
|
320
|
+
|
|
321
|
+
let fullPath = shellPath()
|
|
322
|
+
let npxBin = findNpx()
|
|
276
323
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
324
|
+
let proc = Process()
|
|
325
|
+
let pipe = Pipe()
|
|
326
|
+
proc.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
|
327
|
+
proc.arguments = ["-c", "\(npxBin) -y poke-gate@\(version) download-macos"]
|
|
328
|
+
proc.environment = ProcessInfo.processInfo.environment.merging(
|
|
329
|
+
["PATH": fullPath],
|
|
330
|
+
uniquingKeysWith: { _, new in new }
|
|
331
|
+
)
|
|
332
|
+
proc.standardOutput = pipe
|
|
333
|
+
proc.standardError = pipe
|
|
334
|
+
proc.currentDirectoryURL = FileManager.default.homeDirectoryForCurrentUser
|
|
282
335
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
336
|
+
let handle = pipe.fileHandleForReading
|
|
337
|
+
handle.readabilityHandler = { [weak self] fh in
|
|
338
|
+
let data = fh.availableData
|
|
339
|
+
guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return }
|
|
340
|
+
DispatchQueue.main.async {
|
|
341
|
+
for l in line.components(separatedBy: .newlines) where !l.isEmpty {
|
|
342
|
+
self?.appendLog(l)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
286
346
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
347
|
+
proc.terminationHandler = { [weak self] proc in
|
|
348
|
+
DispatchQueue.main.async {
|
|
349
|
+
self?.isUpdating = false
|
|
350
|
+
if proc.terminationStatus == 0 {
|
|
351
|
+
self?.appendLog("Update complete. The app will relaunch.")
|
|
352
|
+
self?.availableUpdate = nil
|
|
290
353
|
} else {
|
|
291
|
-
appendLog("
|
|
354
|
+
self?.appendLog("Update failed (exit \(proc.terminationStatus)).")
|
|
292
355
|
}
|
|
293
|
-
} catch {
|
|
294
|
-
appendLog("Screenshot error: \(error.localizedDescription)")
|
|
295
356
|
}
|
|
296
357
|
}
|
|
297
|
-
}
|
|
298
358
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
start()
|
|
359
|
+
do {
|
|
360
|
+
try proc.run()
|
|
361
|
+
} catch {
|
|
362
|
+
isUpdating = false
|
|
363
|
+
appendLog("Failed to start update: \(error.localizedDescription)")
|
|
304
364
|
}
|
|
305
365
|
}
|
|
306
366
|
|
|
@@ -496,7 +556,7 @@ class GateService: ObservableObject {
|
|
|
496
556
|
private func killOrphanedProcesses() {
|
|
497
557
|
let task = Process()
|
|
498
558
|
task.executableURL = URL(fileURLWithPath: "/usr/bin/pkill")
|
|
499
|
-
task.arguments = ["-f", "poke-gate"]
|
|
559
|
+
task.arguments = ["-f", "node.*poke-gate.*app\\.js"]
|
|
500
560
|
try? task.run()
|
|
501
561
|
task.waitUntilExit()
|
|
502
562
|
}
|
|
@@ -677,14 +737,10 @@ class GateService: ObservableObject {
|
|
|
677
737
|
writeConfig(json)
|
|
678
738
|
}
|
|
679
739
|
|
|
680
|
-
|
|
740
|
+
func isPermissionGranted(_ permission: SystemPermission) -> Bool {
|
|
681
741
|
switch permission {
|
|
682
742
|
case .accessibility:
|
|
683
743
|
if AXIsProcessTrusted() { return true }
|
|
684
|
-
// AXIsProcessTrusted() can return false despite the toggle being ON
|
|
685
|
-
// in System Settings when the TCC entry's code signature doesn't match
|
|
686
|
-
// the running binary (ad-hoc signing, rebuild from Xcode, etc.).
|
|
687
|
-
// Probe the API directly as a fallback.
|
|
688
744
|
let systemWide = AXUIElementCreateSystemWide()
|
|
689
745
|
var value: AnyObject?
|
|
690
746
|
let result = AXUIElementCopyAttributeValue(
|
|
@@ -693,6 +749,8 @@ class GateService: ObservableObject {
|
|
|
693
749
|
&value
|
|
694
750
|
)
|
|
695
751
|
return result == .success || result == .noValue || result == .attributeUnsupported
|
|
752
|
+
case .screenRecording:
|
|
753
|
+
return CGPreflightScreenCaptureAccess()
|
|
696
754
|
}
|
|
697
755
|
}
|
|
698
756
|
|
|
@@ -6,16 +6,5 @@
|
|
|
6
6
|
<string>dev.fka.Poke-macOS-Gate</string>
|
|
7
7
|
<key>LSUIElement</key>
|
|
8
8
|
<true/>
|
|
9
|
-
<key>CFBundleURLTypes</key>
|
|
10
|
-
<array>
|
|
11
|
-
<dict>
|
|
12
|
-
<key>CFBundleURLName</key>
|
|
13
|
-
<string>dev.fka.Poke-macOS-Gate</string>
|
|
14
|
-
<key>CFBundleURLSchemes</key>
|
|
15
|
-
<array>
|
|
16
|
-
<string>poke-gate</string>
|
|
17
|
-
</array>
|
|
18
|
-
</dict>
|
|
19
|
-
</array>
|
|
20
9
|
</dict>
|
|
21
10
|
</plist>
|
|
@@ -3,15 +3,6 @@ import ServiceManagement
|
|
|
3
3
|
|
|
4
4
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
|
5
5
|
var service: GateService?
|
|
6
|
-
|
|
7
|
-
func application(_ application: NSApplication, open urls: [URL]) {
|
|
8
|
-
for url in urls {
|
|
9
|
-
guard url.scheme == "poke-gate" else { continue }
|
|
10
|
-
if url.host == "screenshot" {
|
|
11
|
-
service?.captureAndSend()
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
6
|
}
|
|
16
7
|
|
|
17
8
|
@main
|
|
@@ -111,6 +102,9 @@ struct PopoverContent: View {
|
|
|
111
102
|
SetupView(service: service)
|
|
112
103
|
} else {
|
|
113
104
|
VStack(spacing: 10) {
|
|
105
|
+
if service.availableUpdate != nil {
|
|
106
|
+
updateBanner
|
|
107
|
+
}
|
|
114
108
|
statusSection
|
|
115
109
|
recentActivitySection
|
|
116
110
|
accessModeSection
|
|
@@ -119,8 +113,8 @@ struct PopoverContent: View {
|
|
|
119
113
|
}
|
|
120
114
|
.frame(width: 320)
|
|
121
115
|
.padding(10)
|
|
122
|
-
.onChange(of: service.
|
|
123
|
-
if
|
|
116
|
+
.onChange(of: service.systemPermissionStatuses) { _, _ in
|
|
117
|
+
if service.isPermissionGranted(.accessibility) && pendingFullMode {
|
|
124
118
|
pendingFullMode = false
|
|
125
119
|
service.setPermissionMode(.full)
|
|
126
120
|
}
|
|
@@ -128,6 +122,37 @@ struct PopoverContent: View {
|
|
|
128
122
|
}
|
|
129
123
|
}
|
|
130
124
|
|
|
125
|
+
private var updateBanner: some View {
|
|
126
|
+
HStack(spacing: 8) {
|
|
127
|
+
Image(systemName: "arrow.down.circle.fill")
|
|
128
|
+
.foregroundStyle(.blue)
|
|
129
|
+
|
|
130
|
+
VStack(alignment: .leading, spacing: 1) {
|
|
131
|
+
Text("Update available: v\(service.availableUpdate ?? "")")
|
|
132
|
+
.font(.caption)
|
|
133
|
+
.fontWeight(.semibold)
|
|
134
|
+
Text("A new version of Poke Gate is ready.")
|
|
135
|
+
.font(.caption2)
|
|
136
|
+
.foregroundStyle(.secondary)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
Spacer()
|
|
140
|
+
|
|
141
|
+
if service.isUpdating {
|
|
142
|
+
ProgressView()
|
|
143
|
+
.controlSize(.small)
|
|
144
|
+
} else {
|
|
145
|
+
Button("Update") {
|
|
146
|
+
service.performUpdate()
|
|
147
|
+
}
|
|
148
|
+
.controlSize(.small)
|
|
149
|
+
.buttonStyle(.borderedProminent)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
.padding(10)
|
|
153
|
+
.macPanelStyle(.neutral, cornerRadius: 10)
|
|
154
|
+
}
|
|
155
|
+
|
|
131
156
|
private var statusSection: some View {
|
|
132
157
|
VStack(alignment: .leading, spacing: 6) {
|
|
133
158
|
HStack(spacing: 8) {
|
|
@@ -303,8 +328,10 @@ struct PopoverContent: View {
|
|
|
303
328
|
.macPanelStyle(.neutral, cornerRadius: 12)
|
|
304
329
|
}
|
|
305
330
|
|
|
331
|
+
@State private var refreshRotation: Double = 0
|
|
332
|
+
|
|
306
333
|
private var footerSection: some View {
|
|
307
|
-
HStack {
|
|
334
|
+
HStack(spacing: 6) {
|
|
308
335
|
Button {
|
|
309
336
|
NSApp.activate(ignoringOtherApps: true)
|
|
310
337
|
openWindow(id: "about")
|
|
@@ -315,6 +342,28 @@ struct PopoverContent: View {
|
|
|
315
342
|
}
|
|
316
343
|
.buttonStyle(.plain)
|
|
317
344
|
|
|
345
|
+
Button {
|
|
346
|
+
service.checkForUpdate()
|
|
347
|
+
} label: {
|
|
348
|
+
Image(systemName: "arrow.triangle.2.circlepath")
|
|
349
|
+
.font(.system(size: 9))
|
|
350
|
+
.foregroundStyle(MacVisualStyle.sectionTitleColor)
|
|
351
|
+
.rotationEffect(.degrees(refreshRotation))
|
|
352
|
+
}
|
|
353
|
+
.buttonStyle(.plain)
|
|
354
|
+
.disabled(service.isCheckingForUpdate)
|
|
355
|
+
.onChange(of: service.isCheckingForUpdate) { _, checking in
|
|
356
|
+
if checking {
|
|
357
|
+
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
|
|
358
|
+
refreshRotation = 360
|
|
359
|
+
}
|
|
360
|
+
} else {
|
|
361
|
+
withAnimation(.default) {
|
|
362
|
+
refreshRotation = 0
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
318
367
|
Spacer()
|
|
319
368
|
|
|
320
369
|
Text("Not affiliated with Poke")
|
|
@@ -322,6 +371,18 @@ struct PopoverContent: View {
|
|
|
322
371
|
.foregroundStyle(MacVisualStyle.sectionTitleColor.opacity(0.7))
|
|
323
372
|
}
|
|
324
373
|
.padding(.horizontal, 6)
|
|
374
|
+
.onChange(of: service.updateCheckResult) { _, result in
|
|
375
|
+
guard let message = result else { return }
|
|
376
|
+
service.updateCheckResult = nil
|
|
377
|
+
DispatchQueue.main.async {
|
|
378
|
+
let alert = NSAlert()
|
|
379
|
+
alert.messageText = "Version Check"
|
|
380
|
+
alert.informativeText = message
|
|
381
|
+
alert.alertStyle = .informational
|
|
382
|
+
alert.addButton(withTitle: "OK")
|
|
383
|
+
alert.runModal()
|
|
384
|
+
}
|
|
385
|
+
}
|
|
325
386
|
}
|
|
326
387
|
|
|
327
388
|
private func sectionTitle(_ text: String) -> some View {
|
|
@@ -366,7 +427,7 @@ struct PopoverContent: View {
|
|
|
366
427
|
private func handleModeSelection(_ mode: GateService.PermissionMode) {
|
|
367
428
|
guard mode != service.permissionMode else { return }
|
|
368
429
|
|
|
369
|
-
if mode == .full && !service.
|
|
430
|
+
if mode == .full && !service.isPermissionGranted(.accessibility) {
|
|
370
431
|
pendingFullMode = true
|
|
371
432
|
service.openSystemPermission(.accessibility)
|
|
372
433
|
} else {
|
|
@@ -286,7 +286,7 @@
|
|
|
286
286
|
"@executable_path/../Frameworks",
|
|
287
287
|
);
|
|
288
288
|
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
|
289
|
-
MARKETING_VERSION = 0.
|
|
289
|
+
MARKETING_VERSION = 0.3.0;
|
|
290
290
|
PRODUCT_BUNDLE_IDENTIFIER = "dev.fka.Poke-macOS-Gate";
|
|
291
291
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
292
292
|
REGISTER_APP_GROUPS = YES;
|
|
@@ -322,7 +322,7 @@
|
|
|
322
322
|
"@executable_path/../Frameworks",
|
|
323
323
|
);
|
|
324
324
|
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
|
325
|
-
MARKETING_VERSION = 0.
|
|
325
|
+
MARKETING_VERSION = 0.3.0;
|
|
326
326
|
PRODUCT_BUNDLE_IDENTIFIER = "dev.fka.Poke-macOS-Gate";
|
|
327
327
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
328
328
|
REGISTER_APP_GROUPS = YES;
|
package/docs/cli.md
CHANGED
|
@@ -82,6 +82,22 @@ npx poke-gate agent create --prompt "send me a daily git commit summary across a
|
|
|
82
82
|
npx poke-gate agent create --prompt "track Spotify listening and log my music taste"
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
## Download the macOS app
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npx poke-gate download-macos
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Downloads and installs the Poke macOS Gate app from GitHub Releases. Matches the version of the npm package being run.
|
|
92
|
+
|
|
93
|
+
This command:
|
|
94
|
+
1. Downloads the DMG from the matching GitHub release
|
|
95
|
+
2. Mounts the DMG, copies the app to `/Applications`
|
|
96
|
+
3. Clears the quarantine flag (`xattr -cr`)
|
|
97
|
+
4. Launches the app
|
|
98
|
+
|
|
99
|
+
The macOS app also checks for updates automatically on startup and shows a banner when a new version is available.
|
|
100
|
+
|
|
85
101
|
## Install an agent
|
|
86
102
|
|
|
87
103
|
```bash
|
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -9,9 +9,10 @@ enableLogging(verbose);
|
|
|
9
9
|
|
|
10
10
|
function killExistingInstances() {
|
|
11
11
|
const myPid = process.pid;
|
|
12
|
+
const ppid = process.ppid;
|
|
12
13
|
try {
|
|
13
|
-
const out = execSync("pgrep -f 'poke-gate'", { encoding: "utf-8" }).trim();
|
|
14
|
-
const pids = out.split("\n").map(Number).filter((p) => p && p !== myPid);
|
|
14
|
+
const out = execSync("pgrep -f 'node.*poke-gate.*app\\.js'", { encoding: "utf-8" }).trim();
|
|
15
|
+
const pids = out.split("\n").map(Number).filter((p) => p && p !== myPid && p !== ppid);
|
|
15
16
|
for (const pid of pids) {
|
|
16
17
|
try { process.kill(pid, "SIGTERM"); } catch {}
|
|
17
18
|
}
|
|
@@ -146,8 +147,8 @@ function buildAccessModeMessage(mode) {
|
|
|
146
147
|
default:
|
|
147
148
|
return (
|
|
148
149
|
"Access mode: Full. " +
|
|
149
|
-
"You can run any shell command, read
|
|
150
|
-
"
|
|
150
|
+
"You can run any shell command, read files, list directories, take screenshots, and check system info — no approval needed. " +
|
|
151
|
+
"Only destructive actions (deleting files, rm, write_file) require a one-time approval; after that, everything is auto-approved for the session."
|
|
151
152
|
);
|
|
152
153
|
}
|
|
153
154
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import https from "node:https";
|
|
2
|
+
import { createWriteStream, unlinkSync, readFileSync } from "node:fs";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, dirname } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const APP_NAME = "Poke macOS Gate";
|
|
9
|
+
const DMG_ASSET = "Poke.macOS.Gate.dmg";
|
|
10
|
+
const INSTALL_PATH = `/Applications/${APP_NAME}.app`;
|
|
11
|
+
const REPO = "f/poke-gate";
|
|
12
|
+
|
|
13
|
+
function getPackageVersion() {
|
|
14
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
15
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
16
|
+
return pkg.version;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function httpGet(url) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
https.get(url, { headers: { "User-Agent": "poke-gate" } }, (res) => {
|
|
22
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
23
|
+
return httpGet(res.headers.location).then(resolve, reject);
|
|
24
|
+
}
|
|
25
|
+
resolve(res);
|
|
26
|
+
}).on("error", reject);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function downloadFile(url, dest, version) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const follow = (followUrl) => {
|
|
33
|
+
https.get(followUrl, { headers: { "User-Agent": "poke-gate" } }, (res) => {
|
|
34
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
35
|
+
return follow(res.headers.location);
|
|
36
|
+
}
|
|
37
|
+
if (res.statusCode !== 200) {
|
|
38
|
+
reject(new Error(`Download failed: HTTP ${res.statusCode}`));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const total = parseInt(res.headers["content-length"], 10) || 0;
|
|
43
|
+
let downloaded = 0;
|
|
44
|
+
const file = createWriteStream(dest);
|
|
45
|
+
|
|
46
|
+
res.on("data", (chunk) => {
|
|
47
|
+
downloaded += chunk.length;
|
|
48
|
+
if (total > 0) {
|
|
49
|
+
const pct = Math.round((downloaded / total) * 100);
|
|
50
|
+
const dlMB = (downloaded / 1024 / 1024).toFixed(1);
|
|
51
|
+
const totalMB = (total / 1024 / 1024).toFixed(1);
|
|
52
|
+
process.stdout.write(`\rDownloading ${APP_NAME} v${version}... ${pct}% (${dlMB}/${totalMB} MB)`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
res.pipe(file);
|
|
57
|
+
|
|
58
|
+
file.on("finish", () => {
|
|
59
|
+
file.close(() => {
|
|
60
|
+
console.log("");
|
|
61
|
+
resolve();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
file.on("error", (err) => {
|
|
66
|
+
file.close();
|
|
67
|
+
reject(err);
|
|
68
|
+
});
|
|
69
|
+
}).on("error", reject);
|
|
70
|
+
};
|
|
71
|
+
follow(url);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function run(cmd) {
|
|
76
|
+
return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function downloadMacOSApp() {
|
|
80
|
+
const version = getPackageVersion();
|
|
81
|
+
const tag = `v${version}`;
|
|
82
|
+
const dmgUrl = `https://github.com/${REPO}/releases/download/${tag}/${DMG_ASSET}`;
|
|
83
|
+
|
|
84
|
+
console.log(`Poke Gate macOS installer (${tag})`);
|
|
85
|
+
console.log("");
|
|
86
|
+
|
|
87
|
+
const res = await httpGet(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`);
|
|
88
|
+
const chunks = [];
|
|
89
|
+
for await (const chunk of res) chunks.push(chunk);
|
|
90
|
+
const release = JSON.parse(Buffer.concat(chunks).toString());
|
|
91
|
+
|
|
92
|
+
if (!release.assets || !release.assets.find((a) => a.name === DMG_ASSET)) {
|
|
93
|
+
console.error(`No DMG found for ${tag}. The release may not have finished building yet.`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const dmgPath = join(tmpdir(), `poke-gate-${version}.dmg`);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await downloadFile(dmgUrl, dmgPath, version);
|
|
101
|
+
|
|
102
|
+
console.log("Mounting DMG...");
|
|
103
|
+
const mountOutput = run(`hdiutil attach "${dmgPath}" -nobrowse`);
|
|
104
|
+
const mountMatch = mountOutput.match(/\/Volumes\/.+/);
|
|
105
|
+
if (!mountMatch) {
|
|
106
|
+
throw new Error("Failed to detect mount point from hdiutil output.");
|
|
107
|
+
}
|
|
108
|
+
const mountPoint = mountMatch[0].trim();
|
|
109
|
+
const appSource = `${mountPoint}/${APP_NAME}.app`;
|
|
110
|
+
|
|
111
|
+
console.log("Stopping running instances...");
|
|
112
|
+
try { run(`pkill -f "${APP_NAME}"`); } catch {}
|
|
113
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
114
|
+
|
|
115
|
+
console.log(`Installing to ${INSTALL_PATH}...`);
|
|
116
|
+
try { run(`rm -rf "${INSTALL_PATH}"`); } catch {}
|
|
117
|
+
run(`cp -R "${appSource}" "${INSTALL_PATH}"`);
|
|
118
|
+
|
|
119
|
+
console.log("Clearing quarantine...");
|
|
120
|
+
run(`xattr -cr "${INSTALL_PATH}"`);
|
|
121
|
+
|
|
122
|
+
console.log("Unmounting DMG...");
|
|
123
|
+
try { run(`hdiutil detach "${mountPoint}" -quiet`); } catch {}
|
|
124
|
+
|
|
125
|
+
console.log("");
|
|
126
|
+
console.log(`Poke macOS Gate v${version} installed successfully.`);
|
|
127
|
+
console.log("");
|
|
128
|
+
console.log("Launching...");
|
|
129
|
+
try { run(`open "${INSTALL_PATH}"`); } catch {}
|
|
130
|
+
} finally {
|
|
131
|
+
try { unlinkSync(dmgPath); } catch {}
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/mcp-server.js
CHANGED
|
@@ -22,6 +22,15 @@ const runCommandLoopState = new Map();
|
|
|
22
22
|
|
|
23
23
|
const SAFE_TOOL_NAMES = new Set(["read_file", "read_image", "list_directory", "system_info", "network_speed"]);
|
|
24
24
|
|
|
25
|
+
const DESTRUCTIVE_COMMAND_PATTERNS = [
|
|
26
|
+
/\brm\b/i,
|
|
27
|
+
/\brmdir\b/i,
|
|
28
|
+
/\bunlink\b/i,
|
|
29
|
+
/\bmkfs\b/i,
|
|
30
|
+
/\bdiskutil\s+erase/i,
|
|
31
|
+
/>\s*\//,
|
|
32
|
+
];
|
|
33
|
+
|
|
25
34
|
const LIMITED_RUN_COMMANDS = new Set([
|
|
26
35
|
"curl", "yt-dlp", "youtube-dl",
|
|
27
36
|
"ls", "pwd", "cat", "grep", "find", "head", "tail", "wc", "sed", "awk",
|
|
@@ -270,6 +279,15 @@ function hasDangerousPattern(commandText) {
|
|
|
270
279
|
return DANGEROUS_COMMAND_PATTERNS.some((pattern) => pattern.test(commandText));
|
|
271
280
|
}
|
|
272
281
|
|
|
282
|
+
function isDestructiveInFullMode(name, cleanArgs) {
|
|
283
|
+
if (name === "write_file") return true;
|
|
284
|
+
if (name === "run_command") {
|
|
285
|
+
const cmd = typeof cleanArgs.command === "string" ? cleanArgs.command : "";
|
|
286
|
+
return DESTRUCTIVE_COMMAND_PATTERNS.some((p) => p.test(cmd));
|
|
287
|
+
}
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
273
291
|
function validateRunCommandAgainstAllowlist(commandText, allowlist) {
|
|
274
292
|
if (typeof commandText !== "string" || commandText.trim().length === 0) {
|
|
275
293
|
return "Command is empty.";
|
|
@@ -565,7 +583,11 @@ function handleToolCall(name, args, context = {}) {
|
|
|
565
583
|
return blocked;
|
|
566
584
|
}
|
|
567
585
|
|
|
568
|
-
|
|
586
|
+
const needsApproval = PERMISSION_MODE === "full"
|
|
587
|
+
? isDestructiveInFullMode(name, cleanArgs)
|
|
588
|
+
: permissionService.isRisky(name);
|
|
589
|
+
|
|
590
|
+
if (needsApproval) {
|
|
569
591
|
const commandText = typeof cleanArgs.command === "string" ? cleanArgs.command : "";
|
|
570
592
|
const alreadyAllowed = sessionAutoApproveAllRisky.has(sessionId) ||
|
|
571
593
|
(commandText && permissionService.isAllowedBySessionPattern(sessionId, commandText));
|
|
@@ -581,12 +603,15 @@ function handleToolCall(name, args, context = {}) {
|
|
|
581
603
|
return buildApprovalResponse(name, cleanArgs, approval);
|
|
582
604
|
}
|
|
583
605
|
|
|
584
|
-
if (
|
|
585
|
-
permissionService.allowPatternForSession(sessionId, commandText);
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
if (args.remember_all_risky === true) {
|
|
606
|
+
if (PERMISSION_MODE === "full") {
|
|
589
607
|
sessionAutoApproveAllRisky.add(sessionId);
|
|
608
|
+
} else {
|
|
609
|
+
if (name === "run_command" && args.remember_in_session === true && commandText) {
|
|
610
|
+
permissionService.allowPatternForSession(sessionId, commandText);
|
|
611
|
+
}
|
|
612
|
+
if (args.remember_all_risky === true) {
|
|
613
|
+
sessionAutoApproveAllRisky.add(sessionId);
|
|
614
|
+
}
|
|
590
615
|
}
|
|
591
616
|
}
|
|
592
617
|
}
|
|
@@ -754,23 +779,11 @@ function handleToolCall(name, args, context = {}) {
|
|
|
754
779
|
case "take_screenshot": {
|
|
755
780
|
logTool(name, cleanArgs);
|
|
756
781
|
|
|
757
|
-
return runCommand(
|
|
758
|
-
if (
|
|
759
|
-
return
|
|
760
|
-
return { content: [{ type: "text", text: "Screenshot captured and sent to Poke via the macOS app." }] };
|
|
761
|
-
});
|
|
782
|
+
return runCommand("npx -y poke-gate@latest take-screenshot", homedir(), { permissionMode: "full" }).then((result) => {
|
|
783
|
+
if (result.exitCode === 0) {
|
|
784
|
+
return { content: [{ type: "text", text: "Screenshot captured and sent to Poke." }] };
|
|
762
785
|
}
|
|
763
|
-
|
|
764
|
-
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
765
|
-
const dest = cleanArgs.path
|
|
766
|
-
? resolve(cleanArgs.path.replace(/^~/, homedir()))
|
|
767
|
-
: join(homedir(), "Desktop", `screenshot-${ts}.png`);
|
|
768
|
-
return runCommand(`/usr/sbin/screencapture -x "${dest}"`, homedir()).then((result) => {
|
|
769
|
-
if (result.exitCode === 0) {
|
|
770
|
-
return { content: [{ type: "text", text: `Screenshot saved to ${dest}` }] };
|
|
771
|
-
}
|
|
772
|
-
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 };
|
|
773
|
-
});
|
|
786
|
+
return { content: [{ type: "text", text: `Screenshot failed: ${result.stderr || result.stdout || "unknown error"}` }], isError: true };
|
|
774
787
|
});
|
|
775
788
|
}
|
|
776
789
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { readFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir, platform } from "node:os";
|
|
5
|
+
import { Poke, getToken, isLoggedIn, login } from "poke";
|
|
6
|
+
|
|
7
|
+
export async function takeScreenshot() {
|
|
8
|
+
if (platform() !== "darwin") {
|
|
9
|
+
console.error("Screenshots are only supported on macOS.");
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (!isLoggedIn()) {
|
|
14
|
+
console.log("Signing in to Poke...");
|
|
15
|
+
await login();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const token = getToken();
|
|
19
|
+
if (!token) {
|
|
20
|
+
console.error("Authentication failed: no token returned by Poke SDK.");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const dest = join(tmpdir(), `poke-gate-screenshot-${Date.now()}.png`);
|
|
25
|
+
|
|
26
|
+
console.log("Capturing screenshot...");
|
|
27
|
+
try {
|
|
28
|
+
execSync(`/usr/sbin/screencapture -x "${dest}"`, { stdio: "pipe" });
|
|
29
|
+
} catch {
|
|
30
|
+
console.error("Screenshot failed. Grant Screen Recording permission in System Settings > Privacy & Security > Screen Recording.");
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const png = readFileSync(dest);
|
|
35
|
+
const base64 = png.toString("base64");
|
|
36
|
+
|
|
37
|
+
console.log(`Screenshot captured (${(png.length / 1024).toFixed(0)} KB). Sending to Poke...`);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const poke = new Poke({ token });
|
|
41
|
+
await poke.sendMessage(
|
|
42
|
+
`Here is a screenshot of my screen right now. Reply me with the image.\n\n\`\`\`\ndata:image/png;base64,${base64}\n\`\`\``
|
|
43
|
+
);
|
|
44
|
+
console.log("Screenshot sent to Poke.");
|
|
45
|
+
} finally {
|
|
46
|
+
try { unlinkSync(dest); } catch {}
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/tunnel.js
CHANGED
package/Gate.app
DELETED
|
File without changes
|