poke-gate 0.0.2 → 0.0.4

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.
Files changed (30) hide show
  1. package/.github/workflows/release.yml +124 -0
  2. package/README.md +98 -37
  3. package/assets/logo.png +0 -0
  4. package/build.sh +51 -0
  5. package/clients/Poke macOS Gate/Poke macOS Gate/Assets.xcassets/AccentColor.colorset/Contents.json +11 -0
  6. package/clients/Poke macOS Gate/Poke macOS Gate/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
  7. package/clients/Poke macOS Gate/Poke macOS Gate/Assets.xcassets/AppIcon.appiconset/icon_128x128.png +0 -0
  8. package/clients/Poke macOS Gate/Poke macOS Gate/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png +0 -0
  9. package/clients/Poke macOS Gate/Poke macOS Gate/Assets.xcassets/AppIcon.appiconset/icon_16x16.png +0 -0
  10. package/clients/Poke macOS Gate/Poke macOS Gate/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png +0 -0
  11. package/clients/Poke macOS Gate/Poke macOS Gate/Assets.xcassets/AppIcon.appiconset/icon_256x256.png +0 -0
  12. package/clients/Poke macOS Gate/Poke macOS Gate/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png +0 -0
  13. package/clients/Poke macOS Gate/Poke macOS Gate/Assets.xcassets/AppIcon.appiconset/icon_32x32.png +0 -0
  14. package/clients/Poke macOS Gate/Poke macOS Gate/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png +0 -0
  15. package/clients/Poke macOS Gate/Poke macOS Gate/Assets.xcassets/AppIcon.appiconset/icon_512x512.png +0 -0
  16. package/clients/Poke macOS Gate/Poke macOS Gate/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png +0 -0
  17. package/clients/Poke macOS Gate/Poke macOS Gate/Assets.xcassets/Contents.json +6 -0
  18. package/clients/Poke macOS Gate/Poke macOS Gate/ContentView.swift +8 -0
  19. package/clients/Poke macOS Gate/Poke macOS Gate/GateService.swift +241 -0
  20. package/clients/Poke macOS Gate/Poke macOS Gate/Info.plist +8 -0
  21. package/clients/Poke macOS Gate/Poke macOS Gate/Item.swift +2 -0
  22. package/clients/Poke macOS Gate/Poke macOS Gate/LogsView.swift +47 -0
  23. package/clients/Poke macOS Gate/Poke macOS Gate/Poke_macOS_GateApp.swift +140 -0
  24. package/clients/Poke macOS Gate/Poke macOS Gate/SettingsView.swift +50 -0
  25. package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/project.pbxproj +336 -0
  26. package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  27. package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/project.xcworkspace/xcuserdata/fka.xcuserdatad/UserInterfaceState.xcuserstate +0 -0
  28. package/clients/Poke macOS Gate/Poke macOS Gate.xcodeproj/xcuserdata/fka.xcuserdatad/xcschemes/xcschememanagement.plist +14 -0
  29. package/package.json +1 -1
  30. package/src/mcp-server.js +73 -1
@@ -0,0 +1,241 @@
1
+ import Foundation
2
+ import Combine
3
+ import ScreenCaptureKit
4
+
5
+ @MainActor
6
+ class GateService: ObservableObject {
7
+ enum Status: String {
8
+ case stopped = "Stopped"
9
+ case starting = "Starting…"
10
+ case connected = "Connected"
11
+ case disconnected = "Disconnected"
12
+ case error = "Error"
13
+ }
14
+
15
+ @Published var status: Status = .stopped
16
+ @Published var logs: [String] = []
17
+ @Published var userName: String? = nil
18
+
19
+ private var hasAutoStarted = false
20
+ private var process: Process?
21
+ private var outputPipe: Pipe?
22
+ private var shouldRestart = true
23
+ private let maxLogs = 200
24
+
25
+ var apiKey: String {
26
+ get { loadAPIKey() ?? "" }
27
+ set { saveAPIKey(newValue) }
28
+ }
29
+
30
+ var hasAPIKey: Bool {
31
+ let key = loadAPIKey()
32
+ return key != nil && !key!.isEmpty
33
+ }
34
+
35
+ func autoStartIfNeeded() {
36
+ guard !hasAutoStarted else { return }
37
+ hasAutoStarted = true
38
+ requestScreenCapturePermission()
39
+ if hasAPIKey {
40
+ start()
41
+ }
42
+ }
43
+
44
+ private func requestScreenCapturePermission() {
45
+ Task {
46
+ do {
47
+ _ = try await SCShareableContent.current
48
+ } catch {
49
+ appendLog("Screen capture permission not granted yet.")
50
+ }
51
+ }
52
+ }
53
+
54
+ func start() {
55
+ guard hasAPIKey else {
56
+ status = .error
57
+ appendLog("No API key configured.")
58
+ return
59
+ }
60
+ shouldRestart = true
61
+ fetchUserName()
62
+ launchProcess()
63
+ }
64
+
65
+ private func fetchUserName() {
66
+ guard let key = loadAPIKey() else { return }
67
+ Task {
68
+ let url = URL(string: "https://poke.com/api/v1/user/profile")!
69
+ var request = URLRequest(url: url)
70
+ request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
71
+ do {
72
+ let (data, response) = try await URLSession.shared.data(for: request)
73
+ guard let httpResp = response as? HTTPURLResponse, httpResp.statusCode == 200 else { return }
74
+ if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
75
+ let fullName = json["name"] as? String ?? json["email"] as? String {
76
+ let firstName = fullName.components(separatedBy: CharacterSet.whitespaces.union(CharacterSet(charactersIn: "@"))).first ?? fullName
77
+ self.userName = firstName
78
+ }
79
+ } catch {}
80
+ }
81
+ }
82
+
83
+ func stop() {
84
+ shouldRestart = false
85
+ killProcess()
86
+ status = .stopped
87
+ }
88
+
89
+ func restart() {
90
+ stop()
91
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
92
+ self.start()
93
+ }
94
+ }
95
+
96
+ private func shellPath() -> String {
97
+ let loginShell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
98
+ let pathProc = Process()
99
+ let pathPipe = Pipe()
100
+ pathProc.executableURL = URL(fileURLWithPath: loginShell)
101
+ pathProc.arguments = ["-ilc", "echo $PATH"]
102
+ pathProc.standardOutput = pathPipe
103
+ pathProc.standardError = FileHandle.nullDevice
104
+ pathProc.environment = ["HOME": NSHomeDirectory()]
105
+ try? pathProc.run()
106
+ pathProc.waitUntilExit()
107
+ let data = pathPipe.fileHandleForReading.readDataToEndOfFile()
108
+ return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
109
+ }
110
+
111
+ private func launchProcess() {
112
+ killProcess()
113
+
114
+ status = .starting
115
+ appendLog("Starting poke-gate…")
116
+
117
+ let fullPath = shellPath()
118
+
119
+ let proc = Process()
120
+ let pipe = Pipe()
121
+
122
+ proc.executableURL = URL(fileURLWithPath: "/bin/zsh")
123
+ proc.arguments = ["-c", "npx -y poke-gate --verbose"]
124
+ proc.environment = ProcessInfo.processInfo.environment.merging(
125
+ [
126
+ "POKE_API_KEY": loadAPIKey() ?? "",
127
+ "PATH": fullPath,
128
+ ],
129
+ uniquingKeysWith: { _, new in new }
130
+ )
131
+ proc.standardOutput = pipe
132
+ proc.standardError = pipe
133
+ proc.currentDirectoryURL = FileManager.default.homeDirectoryForCurrentUser
134
+
135
+ let handle = pipe.fileHandleForReading
136
+ handle.readabilityHandler = { [weak self] fh in
137
+ let data = fh.availableData
138
+ guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return }
139
+ DispatchQueue.main.async {
140
+ self?.handleOutput(line)
141
+ }
142
+ }
143
+
144
+ proc.terminationHandler = { [weak self] proc in
145
+ DispatchQueue.main.async {
146
+ self?.handleTermination(exitCode: proc.terminationStatus)
147
+ }
148
+ }
149
+
150
+ do {
151
+ try proc.run()
152
+ self.process = proc
153
+ self.outputPipe = pipe
154
+ } catch {
155
+ status = .error
156
+ appendLog("Failed to start: \(error.localizedDescription)")
157
+ }
158
+ }
159
+
160
+ private func killProcess() {
161
+ if let proc = process, proc.isRunning {
162
+ proc.terminate()
163
+ }
164
+ outputPipe?.fileHandleForReading.readabilityHandler = nil
165
+ process = nil
166
+ outputPipe = nil
167
+ }
168
+
169
+ private func handleOutput(_ raw: String) {
170
+ for line in raw.components(separatedBy: .newlines) where !line.isEmpty {
171
+ appendLog(line)
172
+
173
+ if line.contains("Tunnel connected") || line.contains("Ready") {
174
+ status = .connected
175
+ } else if line.contains("Tunnel disconnected") || line.contains("Reconnecting") {
176
+ status = .disconnected
177
+ } else if line.contains("Failed to connect") || line.contains("error") {
178
+ if status != .connected {
179
+ status = .error
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ private func handleTermination(exitCode: Int32) {
186
+ appendLog("Process exited with code \(exitCode)")
187
+ if shouldRestart {
188
+ status = .disconnected
189
+ appendLog("Restarting in 2 seconds…")
190
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
191
+ if self.shouldRestart {
192
+ self.launchProcess()
193
+ }
194
+ }
195
+ } else {
196
+ status = .stopped
197
+ }
198
+ }
199
+
200
+ private func appendLog(_ line: String) {
201
+ let ts = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
202
+ logs.append("[\(ts)] \(line)")
203
+ if logs.count > maxLogs {
204
+ logs.removeFirst(logs.count - maxLogs)
205
+ }
206
+ }
207
+
208
+ // MARK: - Config
209
+
210
+ private var configURL: URL {
211
+ let configDir: URL
212
+ if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] {
213
+ configDir = URL(fileURLWithPath: xdg)
214
+ } else {
215
+ configDir = FileManager.default.homeDirectoryForCurrentUser
216
+ .appendingPathComponent(".config")
217
+ }
218
+ return configDir
219
+ .appendingPathComponent("poke-gate")
220
+ .appendingPathComponent("config.json")
221
+ }
222
+
223
+ private func loadAPIKey() -> String? {
224
+ guard let data = try? Data(contentsOf: configURL),
225
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
226
+ let key = json["apiKey"] as? String else {
227
+ return nil
228
+ }
229
+ return key
230
+ }
231
+
232
+ private func saveAPIKey(_ key: String) {
233
+ let dir = configURL.deletingLastPathComponent()
234
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
235
+ let json: [String: Any] = ["apiKey": key]
236
+ if let data = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted]) {
237
+ try? data.write(to: configURL)
238
+ }
239
+ objectWillChange.send()
240
+ }
241
+ }
@@ -0,0 +1,8 @@
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>LSUIElement</key>
6
+ <true/>
7
+ </dict>
8
+ </plist>
@@ -0,0 +1,2 @@
1
+ // Unused — kept for Xcode project reference compatibility
2
+ import Foundation
@@ -0,0 +1,47 @@
1
+ import SwiftUI
2
+
3
+ struct LogsView: View {
4
+ @ObservedObject var service: GateService
5
+
6
+ var body: some View {
7
+ VStack(spacing: 0) {
8
+ HStack {
9
+ Text("Logs")
10
+ .font(.headline)
11
+ Spacer()
12
+ Button("Clear") {
13
+ service.logs.removeAll()
14
+ }
15
+ .buttonStyle(.plain)
16
+ .foregroundStyle(.secondary)
17
+ .font(.caption)
18
+ }
19
+ .padding(.horizontal, 12)
20
+ .padding(.vertical, 8)
21
+
22
+ Divider()
23
+
24
+ ScrollViewReader { proxy in
25
+ ScrollView {
26
+ LazyVStack(alignment: .leading, spacing: 2) {
27
+ ForEach(Array(service.logs.enumerated()), id: \.offset) { index, line in
28
+ Text(line)
29
+ .font(.system(.caption, design: .monospaced))
30
+ .foregroundStyle(.primary)
31
+ .textSelection(.enabled)
32
+ .id(index)
33
+ }
34
+ }
35
+ .padding(.horizontal, 12)
36
+ .padding(.vertical, 8)
37
+ }
38
+ .onChange(of: service.logs.count) { _, _ in
39
+ if let last = service.logs.indices.last {
40
+ proxy.scrollTo(last, anchor: .bottom)
41
+ }
42
+ }
43
+ }
44
+ }
45
+ .frame(width: 480, height: 320)
46
+ }
47
+ }
@@ -0,0 +1,140 @@
1
+ import SwiftUI
2
+
3
+ @main
4
+ struct Poke_macOS_GateApp: App {
5
+ @StateObject private var service = GateService()
6
+
7
+ var body: some Scene {
8
+ MenuBarExtra {
9
+ MenuBarContent(service: service)
10
+ .onAppear { service.autoStartIfNeeded() }
11
+ } label: {
12
+ Image(systemName: menuBarIcon)
13
+ }
14
+
15
+ Window("Logs", id: "logs") {
16
+ LogsView(service: service)
17
+ }
18
+ .defaultSize(width: 480, height: 320)
19
+
20
+ Window("Settings", id: "settings") {
21
+ SettingsView(service: service)
22
+ }
23
+ .windowResizability(.contentSize)
24
+ }
25
+
26
+ private var menuBarIcon: String {
27
+ switch service.status {
28
+ case .connected: "door.left.hand.open"
29
+ case .starting, .disconnected: "door.left.hand.closed"
30
+ case .error: "exclamationmark.triangle"
31
+ case .stopped: "door.left.hand.closed"
32
+ }
33
+ }
34
+ }
35
+
36
+ struct MenuBarContent: View {
37
+ @ObservedObject var service: GateService
38
+ @Environment(\.openWindow) private var openWindow
39
+
40
+ var body: some View {
41
+ Label(statusText, systemImage: statusIcon)
42
+ .foregroundStyle(statusColor)
43
+
44
+ if service.status == .connected {
45
+ Text("This machine is now accessible via Poke.")
46
+ .font(.caption2)
47
+ .foregroundStyle(.secondary)
48
+ Text("Ask your Poke to run commands or read files.")
49
+ .font(.caption2)
50
+ .foregroundStyle(.secondary)
51
+ } else if service.status == .starting {
52
+ Text("Establishing connection…")
53
+ .font(.caption2)
54
+ .foregroundStyle(.secondary)
55
+ } else if service.status == .error {
56
+ Text("Check Settings or view Logs for details.")
57
+ .font(.caption2)
58
+ .foregroundStyle(.secondary)
59
+ }
60
+
61
+ Divider()
62
+
63
+ Button("View Logs…") {
64
+ NSApp.activate(ignoringOtherApps: true)
65
+ openWindow(id: "logs")
66
+ }
67
+
68
+ Button("Settings…") {
69
+ NSApp.activate(ignoringOtherApps: true)
70
+ openWindow(id: "settings")
71
+ }
72
+
73
+ Divider()
74
+
75
+ if service.status == .connected || service.status == .starting || service.status == .disconnected {
76
+ Button("Restart") {
77
+ service.restart()
78
+ }
79
+ } else {
80
+ Button("Start") {
81
+ service.start()
82
+ }
83
+ }
84
+
85
+ Divider()
86
+
87
+ Button("Quit Poke Gate") {
88
+ service.stop()
89
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
90
+ NSApp.terminate(nil)
91
+ }
92
+ }
93
+ .keyboardShortcut("q")
94
+
95
+ Divider()
96
+
97
+ Text("Poke Gate v0.0.3")
98
+ .font(.caption)
99
+ .foregroundStyle(.secondary)
100
+ Text("Community project — not affiliated with Poke")
101
+ .font(.caption2)
102
+ .foregroundStyle(.secondary)
103
+ Button("GitHub") {
104
+ NSWorkspace.shared.open(URL(string: "https://github.com/f/poke-gate")!)
105
+ }
106
+ .font(.caption)
107
+ }
108
+
109
+ private var statusText: String {
110
+ switch service.status {
111
+ case .connected:
112
+ if let name = service.userName {
113
+ return "Connected to your Poke, \(name)"
114
+ }
115
+ return "Connected to your Poke"
116
+ case .starting: return "Connecting…"
117
+ case .disconnected: return "Reconnecting…"
118
+ case .error: return "Connection error"
119
+ case .stopped: return "Stopped"
120
+ }
121
+ }
122
+
123
+ private var statusIcon: String {
124
+ switch service.status {
125
+ case .connected: "circle.fill"
126
+ case .starting, .disconnected: "circle.dotted"
127
+ case .error: "exclamationmark.circle.fill"
128
+ case .stopped: "circle"
129
+ }
130
+ }
131
+
132
+ private var statusColor: Color {
133
+ switch service.status {
134
+ case .connected: .green
135
+ case .starting, .disconnected: .yellow
136
+ case .error: .red
137
+ case .stopped: .secondary
138
+ }
139
+ }
140
+ }
@@ -0,0 +1,50 @@
1
+ import SwiftUI
2
+
3
+ struct SettingsView: View {
4
+ @ObservedObject var service: GateService
5
+ @State private var apiKeyInput: String = ""
6
+ @Environment(\.dismiss) private var dismiss
7
+
8
+ var body: some View {
9
+ VStack(spacing: 16) {
10
+ Text("Poke Gate Settings")
11
+ .font(.headline)
12
+
13
+ VStack(alignment: .leading, spacing: 8) {
14
+ Text("API Key")
15
+ .font(.subheadline)
16
+ .foregroundStyle(.secondary)
17
+
18
+ SecureField("Paste your API key", text: $apiKeyInput)
19
+ .textFieldStyle(.roundedBorder)
20
+
21
+ Link("Get your key at poke.com/kitchen/api-keys",
22
+ destination: URL(string: "https://poke.com/kitchen/api-keys")!)
23
+ .font(.caption)
24
+ .foregroundStyle(.blue)
25
+ }
26
+
27
+ HStack {
28
+ Button("Cancel") {
29
+ dismiss()
30
+ }
31
+ .keyboardShortcut(.cancelAction)
32
+
33
+ Spacer()
34
+
35
+ Button("Save") {
36
+ service.apiKey = apiKeyInput
37
+ dismiss()
38
+ service.restart()
39
+ }
40
+ .keyboardShortcut(.defaultAction)
41
+ .disabled(apiKeyInput.isEmpty)
42
+ }
43
+ }
44
+ .padding(20)
45
+ .frame(width: 360)
46
+ .onAppear {
47
+ apiKeyInput = service.apiKey
48
+ }
49
+ }
50
+ }