Skip to content

Commit a0f3c44

Browse files
committed
v0.1.6 — screenshot deeplink, resilient connection, health checks
- take_screenshot uses poke-gate:// deeplink if macOS app installed, falls back to native - macOS app registers poke-gate:// URL scheme, captures via ScreenCaptureKit - Node tunnel: infinite retry with exponential backoff (no more process.exit on failure) - macOS app: exponential backoff on process restarts, 30s health check timer - Catches uncaughtException/unhandledRejection to prevent surprise crashes - Recovers from: WiFi loss, process crashes, zombies, sleep wake, API downtime Made-with: Cursor
1 parent 5825970 commit a0f3c44

6 files changed

Lines changed: 201 additions & 44 deletions

File tree

clients/Poke macOS Gate/Poke macOS Gate/GateService.swift

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22
import Combine
3+
import ScreenCaptureKit
34

45
@MainActor
56
class GateService: ObservableObject {
@@ -20,6 +21,8 @@ class GateService: ObservableObject {
2021
private var outputPipe: Pipe?
2122
private var shouldRestart = true
2223
private let maxLogs = 200
24+
private var restartAttempts = 0
25+
private var healthCheckTimer: Timer?
2326

2427
var apiKey: String {
2528
get { loadAPIKey() ?? "" }
@@ -41,6 +44,68 @@ class GateService: ObservableObject {
4144
appendLog("Launched poke login (npx: \(npxBin)) — check your browser.")
4245
}
4346

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+
}
107+
}
108+
44109
func autoStartIfNeeded() {
45110
guard !hasAutoStarted else { return }
46111
hasAutoStarted = true
@@ -80,7 +145,9 @@ class GateService: ObservableObject {
80145

81146
func stop() {
82147
shouldRestart = false
148+
stopHealthCheck()
83149
killProcess()
150+
restartAttempts = 0
84151
status = .stopped
85152
}
86153

@@ -235,6 +302,8 @@ class GateService: ObservableObject {
235302

236303
if line.contains("Tunnel connected") || line.contains("Ready") {
237304
status = .connected
305+
restartAttempts = 0
306+
startHealthCheck()
238307
} else if line.contains("Tunnel disconnected") || line.contains("Reconnecting") {
239308
status = .disconnected
240309
} else if line.contains("Failed to connect") || line.contains("error") {
@@ -247,10 +316,14 @@ class GateService: ObservableObject {
247316

248317
private func handleTermination(exitCode: Int32) {
249318
appendLog("Process exited with code \(exitCode)")
319+
stopHealthCheck()
320+
250321
if shouldRestart {
322+
restartAttempts += 1
323+
let delay = min(Double(2 * (1 << min(restartAttempts - 1, 5))), 60.0)
251324
status = .disconnected
252-
appendLog("Restarting in 2 seconds")
253-
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
325+
appendLog("Restarting in \(Int(delay))s (attempt \(restartAttempts))")
326+
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
254327
if self.shouldRestart {
255328
self.launchProcess()
256329
}
@@ -260,6 +333,24 @@ class GateService: ObservableObject {
260333
}
261334
}
262335

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+
263354
private func appendLog(_ line: String) {
264355
let ts = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
265356
logs.append("[\(ts)] \(line)")

clients/Poke macOS Gate/Poke macOS Gate/Info.plist

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,16 @@
44
<dict>
55
<key>LSUIElement</key>
66
<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>
718
</dict>
819
</plist>

clients/Poke macOS Gate/Poke macOS Gate/Poke_macOS_GateApp.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,30 @@
11
import SwiftUI
22

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+
316
@main
417
struct Poke_macOS_GateApp: App {
518
@StateObject private var service = GateService()
19+
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
620

721
var body: some Scene {
822
MenuBarExtra {
923
PopoverContent(service: service)
10-
.onAppear { service.autoStartIfNeeded() }
24+
.onAppear {
25+
service.autoStartIfNeeded()
26+
appDelegate.service = service
27+
}
1128
} label: {
1229
Image(systemName: menuBarIcon)
1330
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "poke-gate",
3-
"version": "0.1.5",
3+
"version": "0.1.6",
44
"description": "Expose your machine to your Poke AI assistant via MCP tunnel",
55
"type": "module",
66
"bin": {

src/app.js

Lines changed: 60 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ function log(msg) {
1111
console.log(`[${ts}] ${msg}`);
1212
}
1313

14+
function sleep(ms) {
15+
return new Promise((resolve) => setTimeout(resolve, ms));
16+
}
17+
1418
async function ensureAuthenticated() {
1519
if (!isLoggedIn()) {
1620
log("Signing in to Poke...");
@@ -25,6 +29,53 @@ async function ensureAuthenticated() {
2529
return token;
2630
}
2731

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+
2879
async function main() {
2980
log("poke-gate starting...");
3081

@@ -35,37 +86,7 @@ async function main() {
3586

3687
const mcpUrl = `http://localhost:${port}/mcp`;
3788

38-
log("Connecting tunnel to Poke...");
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);
6990
}
7091

7192
async function notifyPoke(connectionId, token) {
@@ -93,4 +114,12 @@ process.on("SIGTERM", () => {
93114
process.exit(0);
94115
});
95116

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+
96125
main();

src/mcp-server.js

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -234,16 +234,25 @@ function handleToolCall(name, args) {
234234
}
235235

236236
case "take_screenshot": {
237-
const ts = new Date().toISOString().replace(/[:.]/g, "-");
238-
const dest = args.path
239-
? resolve(args.path.replace(/^~/, homedir()))
240-
: join(homedir(), "Desktop", `screenshot-${ts}.png`);
241-
logTool(name, { path: dest });
242-
return runCommand(`/usr/sbin/screencapture -x "${dest}"`, homedir()).then((result) => {
243-
if (result.exitCode === 0) {
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+
});
245244
}
246-
return { content: [{ type: "text", text: `Screenshot failed: ${result.stderr || "unknown error"}` }], isError: true };
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+
});
247256
});
248257
}
249258

0 commit comments

Comments
 (0)