@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 +24 -0
- package/glimpse.mjs +135 -0
- package/glimpse.swift +277 -0
- package/package.json +20 -0
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
|
+
}
|