@cloudgeek/glimpse 1.0.0

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/Info.plist ADDED
@@ -0,0 +1,24 @@
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>CFBundleDisplayName</key>
6
+ <string>Glimpse Host</string>
7
+ <key>CFBundleExecutable</key>
8
+ <string>glimpse</string>
9
+ <key>CFBundleIdentifier</key>
10
+ <string>com.cloudgeek.glimpse.host</string>
11
+ <key>CFBundleName</key>
12
+ <string>Glimpse Host</string>
13
+ <key>CFBundlePackageType</key>
14
+ <string>APPL</string>
15
+ <key>CFBundleShortVersionString</key>
16
+ <string>1.0.0</string>
17
+ <key>CFBundleVersion</key>
18
+ <string>1</string>
19
+ <key>NSCameraUsageDescription</key>
20
+ <string>This app needs camera access to detect hand gestures.</string>
21
+ <key>NSMicrophoneUsageDescription</key>
22
+ <string>Microphone permission is requested only when WKWebView asks for combined media capture. Audio is not used.</string>
23
+ </dict>
24
+ </plist>
package/glimpse.mjs ADDED
@@ -0,0 +1,135 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { spawn } from 'node:child_process';
3
+ import { createInterface } from 'node:readline';
4
+ import { existsSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const BINARY = join(__dirname, 'glimpse');
10
+
11
+ class GlimpseWindow extends EventEmitter {
12
+ #proc;
13
+ #closed = false;
14
+ #pendingHTML = null;
15
+
16
+ constructor(proc, initialHTML) {
17
+ super();
18
+ this.#proc = proc;
19
+ this.#pendingHTML = initialHTML;
20
+
21
+ proc.stdin.on('error', () => {});
22
+
23
+ const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
24
+ rl.on('line', (line) => {
25
+ let message;
26
+ try {
27
+ message = JSON.parse(line);
28
+ } catch {
29
+ this.emit('error', new Error(`Malformed protocol line: ${line}`));
30
+ return;
31
+ }
32
+
33
+ switch (message.type) {
34
+ case 'ready':
35
+ if (this.#pendingHTML !== null) {
36
+ this.setHTML(this.#pendingHTML);
37
+ this.#pendingHTML = null;
38
+ } else {
39
+ this.emit('ready');
40
+ }
41
+ break;
42
+ case 'message':
43
+ this.emit('message', message.data);
44
+ break;
45
+ case 'closed':
46
+ if (!this.#closed) {
47
+ this.#closed = true;
48
+ this.emit('closed');
49
+ }
50
+ break;
51
+ default:
52
+ break;
53
+ }
54
+ });
55
+
56
+ proc.on('error', (error) => this.emit('error', error));
57
+ proc.on('exit', () => {
58
+ if (!this.#closed) {
59
+ this.#closed = true;
60
+ this.emit('closed');
61
+ }
62
+ });
63
+ }
64
+
65
+ #write(payload) {
66
+ if (this.#closed) return;
67
+ this.#proc.stdin.write(JSON.stringify(payload) + '\n');
68
+ }
69
+
70
+ setHTML(html) {
71
+ this.#write({ type: 'html', html: Buffer.from(html).toString('base64') });
72
+ }
73
+
74
+ close() {
75
+ this.#write({ type: 'close' });
76
+ }
77
+ }
78
+
79
+ export function open(html, options = {}) {
80
+ if (!existsSync(BINARY)) {
81
+ throw new Error("Glimpse host binary not found. Run 'npm install @cloudgeek/glimpse' to build it.");
82
+ }
83
+
84
+ const args = [];
85
+ if (options.width != null) args.push('--width', String(options.width));
86
+ if (options.height != null) args.push('--height', String(options.height));
87
+ if (options.title != null) args.push('--title', options.title);
88
+ if (options.autoClose) args.push('--auto-close');
89
+ if (options.resizable) args.push('--resizable');
90
+
91
+ const proc = spawn(BINARY, args, { stdio: ['pipe', 'pipe', 'inherit'] });
92
+ return new GlimpseWindow(proc, html);
93
+ }
94
+
95
+ export function prompt(html, options = {}) {
96
+ return new Promise((resolve, reject) => {
97
+ const win = open(html, { ...options, autoClose: true });
98
+ let resolved = false;
99
+
100
+ const timer = options.timeout
101
+ ? setTimeout(() => {
102
+ if (!resolved) {
103
+ resolved = true;
104
+ win.close();
105
+ reject(new Error('Prompt timed out'));
106
+ }
107
+ }, options.timeout)
108
+ : null;
109
+
110
+ win.once('message', (data) => {
111
+ if (!resolved) {
112
+ resolved = true;
113
+ if (timer) clearTimeout(timer);
114
+ win.close();
115
+ resolve(data);
116
+ }
117
+ });
118
+
119
+ win.once('closed', () => {
120
+ if (timer) clearTimeout(timer);
121
+ if (!resolved) {
122
+ resolved = true;
123
+ resolve(null);
124
+ }
125
+ });
126
+
127
+ win.once('error', (error) => {
128
+ if (timer) clearTimeout(timer);
129
+ if (!resolved) {
130
+ resolved = true;
131
+ reject(error);
132
+ }
133
+ });
134
+ });
135
+ }
package/glimpse.swift ADDED
@@ -0,0 +1,277 @@
1
+ import AVFoundation
2
+ import Cocoa
3
+ import Foundation
4
+ import WebKit
5
+
6
+ func writeToStdout(_ dict: [String: Any]) {
7
+ guard let data = try? JSONSerialization.data(withJSONObject: dict),
8
+ let line = String(data: data, encoding: .utf8) else { return }
9
+ FileHandle.standardOutput.write((line + "\n").data(using: .utf8)!)
10
+ fflush(stdout)
11
+ }
12
+
13
+ func log(_ message: String) {
14
+ fputs("[glimpse] \(message)\n", stderr)
15
+ }
16
+
17
+ struct Config {
18
+ var width: Int? = nil
19
+ var height: Int? = nil
20
+ var title: String = "Glimpse"
21
+ var autoClose: Bool = false
22
+ var resizable: Bool = false
23
+ }
24
+
25
+ func parseArgs() -> Config {
26
+ var config = Config()
27
+ let args = CommandLine.arguments
28
+ var i = 1
29
+
30
+ while i < args.count {
31
+ switch args[i] {
32
+ case "--width":
33
+ i += 1
34
+ if i < args.count, let value = Int(args[i]) { config.width = value }
35
+ case "--height":
36
+ i += 1
37
+ if i < args.count, let value = Int(args[i]) { config.height = value }
38
+ case "--title":
39
+ i += 1
40
+ if i < args.count { config.title = args[i] }
41
+ case "--auto-close":
42
+ config.autoClose = true
43
+ case "--resizable":
44
+ config.resizable = true
45
+ default:
46
+ break
47
+ }
48
+ i += 1
49
+ }
50
+
51
+ return config
52
+ }
53
+
54
+ final class GlimpseWindow: NSWindow {
55
+ override var canBecomeKey: Bool { true }
56
+ override var canBecomeMain: Bool { true }
57
+ }
58
+
59
+ @MainActor
60
+ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate, NSWindowDelegate {
61
+ private let config: Config
62
+ private var window: NSWindow!
63
+ private var webView: WKWebView!
64
+ private var isExiting = false
65
+ private let localBaseURL = URL(string: "https://localhost/")!
66
+
67
+ nonisolated init(config: Config) {
68
+ self.config = config
69
+ }
70
+
71
+ func applicationDidFinishLaunching(_ notification: Notification) {
72
+ setupWindow()
73
+ setupWebView()
74
+ startStdinReader()
75
+ }
76
+
77
+ private func setupWindow() {
78
+ let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
79
+ let adaptiveWidth = Int(visibleFrame.width * 0.8)
80
+ let adaptiveHeight = Int(visibleFrame.height * 0.8)
81
+ let windowWidth = max(640, config.width ?? adaptiveWidth)
82
+ let windowHeight = max(480, config.height ?? adaptiveHeight)
83
+ let rect = NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight)
84
+
85
+ var styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable]
86
+ if config.resizable {
87
+ styleMask.insert(.resizable)
88
+ }
89
+
90
+ window = GlimpseWindow(
91
+ contentRect: rect,
92
+ styleMask: styleMask,
93
+ backing: .buffered,
94
+ defer: false
95
+ )
96
+ window.title = config.title
97
+ window.delegate = self
98
+ window.center()
99
+ window.makeKeyAndOrderFront(nil)
100
+ if #available(macOS 14.0, *) {
101
+ NSApp.activate()
102
+ } else {
103
+ NSApp.activate(ignoringOtherApps: true)
104
+ }
105
+ }
106
+
107
+ private func setupWebView() {
108
+ let controller = WKUserContentController()
109
+ let bridgeJS = """
110
+ window.glimpse = {
111
+ send: function(data) {
112
+ window.webkit.messageHandlers.glimpse.postMessage(JSON.stringify(data));
113
+ },
114
+ close: function() {
115
+ window.webkit.messageHandlers.glimpse.postMessage(JSON.stringify({__glimpse_close: true}));
116
+ }
117
+ };
118
+ """
119
+ controller.addUserScript(WKUserScript(source: bridgeJS, injectionTime: .atDocumentStart, forMainFrameOnly: true))
120
+ controller.add(self, name: "glimpse")
121
+
122
+ let webConfig = WKWebViewConfiguration()
123
+ webConfig.userContentController = controller
124
+ webConfig.defaultWebpagePreferences.allowsContentJavaScript = true
125
+
126
+ webView = WKWebView(frame: window.contentView!.bounds, configuration: webConfig)
127
+ webView.autoresizingMask = [.width, .height]
128
+ webView.navigationDelegate = self
129
+ webView.uiDelegate = self
130
+ window.contentView?.addSubview(webView)
131
+
132
+ webView.loadHTMLString("<!doctype html><html><body></body></html>", baseURL: localBaseURL)
133
+ }
134
+
135
+ private func loadHTML(_ html: String) {
136
+ webView.loadHTMLString(html, baseURL: localBaseURL)
137
+ }
138
+
139
+ private func startStdinReader() {
140
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
141
+ while let line = readLine() {
142
+ let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
143
+ guard !trimmed.isEmpty else { continue }
144
+ guard let data = trimmed.data(using: .utf8),
145
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
146
+ let type = json["type"] as? String else {
147
+ log("Skipping invalid JSON: \(trimmed)")
148
+ continue
149
+ }
150
+
151
+ DispatchQueue.main.async {
152
+ MainActor.assumeIsolated {
153
+ self?.handleCommand(type: type, payload: json)
154
+ }
155
+ }
156
+ }
157
+
158
+ DispatchQueue.main.async {
159
+ MainActor.assumeIsolated {
160
+ self?.closeAndExit()
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ private func handleCommand(type: String, payload: [String: Any]) {
167
+ switch type {
168
+ case "html":
169
+ guard let base64 = payload["html"] as? String,
170
+ let data = Data(base64Encoded: base64),
171
+ let html = String(data: data, encoding: .utf8) else {
172
+ log("html command missing valid base64 payload")
173
+ return
174
+ }
175
+ loadHTML(html)
176
+ case "close":
177
+ closeAndExit()
178
+ default:
179
+ log("Unknown command type: \(type)")
180
+ }
181
+ }
182
+
183
+ private func requestAuthorization(for mediaType: AVMediaType, completion: @escaping (Bool) -> Void) {
184
+ switch AVCaptureDevice.authorizationStatus(for: mediaType) {
185
+ case .authorized:
186
+ completion(true)
187
+ case .notDetermined:
188
+ AVCaptureDevice.requestAccess(for: mediaType) { granted in
189
+ DispatchQueue.main.async {
190
+ completion(granted)
191
+ }
192
+ }
193
+ case .denied, .restricted:
194
+ completion(false)
195
+ @unknown default:
196
+ completion(false)
197
+ }
198
+ }
199
+
200
+ @available(macOS 12.0, *)
201
+ private func requestCaptureAccess(for captureType: WKMediaCaptureType, completion: @escaping (Bool) -> Void) {
202
+ switch captureType {
203
+ case .camera:
204
+ requestAuthorization(for: .video, completion: completion)
205
+ case .microphone:
206
+ requestAuthorization(for: .audio, completion: completion)
207
+ case .cameraAndMicrophone:
208
+ requestAuthorization(for: .video) { [weak self] granted in
209
+ guard granted, let self else {
210
+ completion(false)
211
+ return
212
+ }
213
+ self.requestAuthorization(for: .audio, completion: completion)
214
+ }
215
+ @unknown default:
216
+ completion(false)
217
+ }
218
+ }
219
+
220
+ @available(macOS 12.0, *)
221
+ func webView(
222
+ _ webView: WKWebView,
223
+ requestMediaCapturePermissionFor origin: WKSecurityOrigin,
224
+ initiatedByFrame frame: WKFrameInfo,
225
+ type: WKMediaCaptureType,
226
+ decisionHandler: @escaping (WKPermissionDecision) -> Void
227
+ ) {
228
+ requestCaptureAccess(for: type) { granted in
229
+ decisionHandler(granted ? .grant : .deny)
230
+ }
231
+ }
232
+
233
+ nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
234
+ MainActor.assumeIsolated {
235
+ writeToStdout(["type": "ready"])
236
+ }
237
+ }
238
+
239
+ nonisolated func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
240
+ MainActor.assumeIsolated {
241
+ guard let body = message.body as? String,
242
+ let data = body.data(using: .utf8),
243
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
244
+ log("Received invalid message from webview")
245
+ return
246
+ }
247
+
248
+ if json["__glimpse_close"] as? Bool == true {
249
+ closeAndExit()
250
+ return
251
+ }
252
+
253
+ writeToStdout(["type": "message", "data": json])
254
+ if config.autoClose {
255
+ closeAndExit()
256
+ }
257
+ }
258
+ }
259
+
260
+ func windowWillClose(_ notification: Notification) {
261
+ closeAndExit()
262
+ }
263
+
264
+ private func closeAndExit() {
265
+ guard !isExiting else { return }
266
+ isExiting = true
267
+ writeToStdout(["type": "closed"])
268
+ exit(0)
269
+ }
270
+ }
271
+
272
+ let config = parseArgs()
273
+ let app = NSApplication.shared
274
+ let delegate = AppDelegate(config: config)
275
+ app.delegate = delegate
276
+ app.setActivationPolicy(.regular)
277
+ app.run()
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@cloudgeek/glimpse",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "glimpse.mjs",
6
+ "exports": {
7
+ ".": "./glimpse.mjs"
8
+ },
9
+ "files": [
10
+ "glimpse.mjs",
11
+ "glimpse.swift",
12
+ "Info.plist"
13
+ ],
14
+ "scripts": {
15
+ "build": "mkdir -p /tmp/glimpse-swift-cache && swiftc -module-cache-path /tmp/glimpse-swift-cache -O glimpse.swift -o glimpse -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker Info.plist",
16
+ "postinstall": "npm run build"
17
+ },
18
+ "os": ["darwin"],
19
+ "cpu": ["arm64", "x64"]
20
+ }