positron.js 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.
@@ -0,0 +1,1154 @@
1
+ import Cocoa
2
+ import WebKit
3
+ import Network
4
+ import Darwin
5
+ import UserNotifications
6
+
7
+ // MARK: - Globals
8
+
9
+ var IS_PACKAGED = false
10
+
11
+ let AUTH_TOKEN: String = {
12
+ if let envToken = ProcessInfo.processInfo.environment["POSITRON_AUTH_TOKEN"], !envToken.isEmpty {
13
+ // Dev Mode: Successfully grabbed the token passed down by Node!
14
+ return envToken
15
+ }
16
+ // Packaged Mode: We started first, so we generate the master token.
17
+ return UUID().uuidString
18
+ }()
19
+
20
+ var windowObservations: [Int: NSKeyValueObservation] = [:]
21
+
22
+
23
+ import Foundation
24
+
25
+ final class PositronWebView: WKWebView {
26
+ override func rightMouseDown(with event: NSEvent) {
27
+ if let customMenu = self.menu {
28
+ customMenu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil)
29
+ } else {
30
+ super.rightMouseDown(with: event)
31
+ }
32
+ }
33
+ }
34
+
35
+ func getRandomOpenPort() -> UInt16? {
36
+
37
+ if let envPort = ProcessInfo.processInfo.environment["POSITRON_IPC_PORT"], let portNum = UInt16(envPort) {
38
+ return portNum
39
+ }
40
+
41
+ // 1. Create a TCP socket
42
+ let socketFileDescriptor = socket(AF_INET, SOCK_STREAM, 0)
43
+ if socketFileDescriptor == -1 { return nil }
44
+
45
+ // 2. Set up the address structure, binding to port 0
46
+ var address = sockaddr_in()
47
+ address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
48
+ address.sin_family = sa_family_t(AF_INET)
49
+ address.sin_port = 0 // Port 0 tells the OS to pick one
50
+ address.sin_addr.s_addr = INADDR_ANY
51
+
52
+ // 3. Bind the socket
53
+ let bindResult = withUnsafePointer(to: &address) {
54
+ $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
55
+ bind(socketFileDescriptor, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
56
+ }
57
+ }
58
+
59
+ if bindResult == -1 {
60
+ close(socketFileDescriptor)
61
+ return nil
62
+ }
63
+
64
+ // 4. Retrieve the port assigned by the OS
65
+ var assignedAddress = sockaddr_in()
66
+ var addressLength = socklen_t(MemoryLayout<sockaddr_in>.size)
67
+ let getsockNameResult = withUnsafeMutablePointer(to: &assignedAddress) {
68
+ $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
69
+ getsockname(socketFileDescriptor, $0, &addressLength)
70
+ }
71
+ }
72
+
73
+ // 5. Clean up and return the port
74
+ close(socketFileDescriptor)
75
+
76
+ if getsockNameResult == 0 {
77
+ return assignedAddress.sin_port.bigEndian
78
+ }
79
+
80
+ return nil
81
+ }
82
+
83
+ let port = getRandomOpenPort()
84
+
85
+ func printError(_ message: String) {
86
+ var msg = message
87
+ var red = "\u{001B}[0;31m"
88
+ let isWarning = message.starts(with:"WARNING")
89
+ let isInfo = message.starts(with:"INFO")
90
+ if(isWarning) {
91
+ red = "\u{001B}[0;33m"
92
+ msg = message.replacingOccurrences(of: "WARNING: ", with: "")
93
+ }
94
+ if(isInfo) {
95
+ red = "\u{001B}[0;34m"
96
+ msg = message.replacingOccurrences(of: "INFO: ", with: "")
97
+ }
98
+ let reset = "\u{001B}[0m"
99
+
100
+ let tag = isWarning ? "WARNING" : (isInfo ? "INFO" : "ERROR")
101
+
102
+ print("\(red)[SWIFT \(tag)] \(msg)\(reset)")
103
+ }
104
+
105
+ public protocol PositronExtension {
106
+ static var commandName: String { get }
107
+ static func handle(windowId: Int, args: [String])
108
+ }
109
+
110
+ extension Dictionary {
111
+ static func + (lhs: [Key: Value], rhs: [Key: Value]) -> [Key: Value] {
112
+ return lhs.merging(rhs) { (_, new) in new }
113
+ }
114
+ }
115
+
116
+ func getBuiltInHandlers() -> [String: (Int, [String]) -> Void] {
117
+ let baseHandlers: [String: (Int, [String]) -> Void] = [
118
+ "alert": { windowId, args in
119
+ guard let window = windows[windowId] else { return }
120
+ let alert = NSAlert()
121
+ alert.messageText = args.first ?? "Alert"
122
+ alert.addButton(withTitle: "OK")
123
+ alert.beginSheetModal(for: window, completionHandler: nil)
124
+ },
125
+ "addUserScript": { windowId, args in
126
+ guard let window = windows[windowId],
127
+ let webView = window.contentView as? WKWebView,
128
+ let script = args.first else { return }
129
+ let userScript = WKUserScript(
130
+ source: script,
131
+ injectionTime: .atDocumentStart,
132
+ forMainFrameOnly: false
133
+ )
134
+ webView.configuration.userContentController.addUserScript(userScript)
135
+ },
136
+ "openDevTools": { windowId, _ in
137
+ guard let window = windows[windowId],
138
+ let webView = window.contentView as? WKWebView else {
139
+ printError("openDevTools — webview not found for window \(windowId)")
140
+ return
141
+ }
142
+
143
+ let selector = Selector(("_showDeveloperTools:"))
144
+ if webView.responds(to: selector) {
145
+ webView.perform(selector, with: nil)
146
+ } else {
147
+ printError("openDevTools failed: _showDeveloperTools: selector not found on WKWebView (windowId \(windowId))")
148
+ }
149
+ },
150
+ ]
151
+
152
+ return baseHandlers + getExtensionRegistry()
153
+ }
154
+
155
+ var windows: [Int: NSWindow] = [:]
156
+
157
+ // MARK: - IPC Message Types
158
+
159
+ struct IPCMessage: Codable {
160
+ let windowId: Int
161
+ let command: String
162
+ let args: [String]
163
+ }
164
+
165
+ struct IPCResponse: Codable {
166
+ let windowId: Int
167
+ let event: String
168
+ let data: [String: String]
169
+ }
170
+
171
+
172
+ // MARK: - Command Handler
173
+
174
+ func handleCommand(windowId: Int, command: String, args: [String]) {
175
+ switch command {
176
+
177
+ case "createWindow":
178
+ let width = args.count > 0 ? Int(args[0]) ?? 800 : 800
179
+ let height = args.count > 1 ? Int(args[1]) ?? 600 : 600
180
+
181
+ let closable = args.count > 2 ? (args[2].lowercased() == "true") : true
182
+ let resizable = args.count > 3 ? (args[3].lowercased() == "true") : true
183
+ let minimizable = args.count > 4 ? (args[4].lowercased() == "true") : true
184
+ let titlebarTransparent = args.count > 5 ? (args[5].lowercased() == "true") : false
185
+ let titlebarVisible = args.count > 6 ? (args[6].lowercased() == "true") : true
186
+
187
+ let styleMask: NSWindow.StyleMask = [
188
+ .titled,
189
+ closable ? .closable : [],
190
+ resizable ? .resizable : [],
191
+ minimizable ? .miniaturizable : []
192
+ ]
193
+
194
+ let frame = NSRect(x: 0, y: 0, width: width, height: height)
195
+ let newWindow = NSWindow(
196
+ contentRect: frame,
197
+ styleMask: styleMask,
198
+ backing: .buffered,
199
+ defer: false
200
+ )
201
+
202
+ newWindow.minSize = NSSize(width: 200, height: 150)
203
+
204
+ newWindow.isReleasedWhenClosed = false
205
+
206
+ newWindow.titlebarAppearsTransparent = titlebarTransparent
207
+ newWindow.titleVisibility = titlebarVisible ? .visible : .hidden
208
+
209
+
210
+ // --- WebView IPC setup ---
211
+ let config = WKWebViewConfiguration()
212
+
213
+ config.preferences.setValue(!IS_PACKAGED, forKey: "developerExtrasEnabled")
214
+
215
+ // 1. Register Swift as the handler for window.webkit.messageHandlers.ipc.postMessage(...)
216
+ let msgHandler = WebViewIPCHandler(windowId: windowId)
217
+ config.userContentController.add(msgHandler, name: "ipc")
218
+
219
+ // 2. Inject the preload script so renderer JS gets a nice window.ipc API
220
+ let preload = WKUserScript(
221
+ source: makePreloadScript(windowId: windowId),
222
+ injectionTime: .atDocumentStart,
223
+ forMainFrameOnly: false
224
+ )
225
+ config.userContentController.addUserScript(preload)
226
+
227
+ let webView = PositronWebView(frame: NSRect(origin: .zero, size: frame.size), configuration: config)
228
+ // Resize webview automatically when the window resizes
229
+ webView.autoresizingMask = [.width, .height]
230
+ newWindow.contentView = webView
231
+
232
+ newWindow.center()
233
+ windows[windowId] = newWindow
234
+
235
+ NotificationCenter.default.addObserver(
236
+ forName: NSWindow.willCloseNotification,
237
+ object: newWindow,
238
+ queue: .main
239
+ ) { _ in
240
+
241
+ if let observation = windowObservations[windowId] {
242
+ observation.invalidate()
243
+ windowObservations.removeValue(forKey: windowId)
244
+ }
245
+
246
+
247
+ windows.removeValue(forKey: windowId)
248
+ AppDelegate.shared?.ipcClient.send(
249
+ IPCResponse(windowId: windowId, event: "windowClosed", data: [:])
250
+ )
251
+ }
252
+
253
+ NSApp.setActivationPolicy(.regular)
254
+ newWindow.makeKeyAndOrderFront(nil)
255
+ NSApp.activate(ignoringOtherApps: true)
256
+
257
+ let observation = webView.observe(\.title, options: [.new]) { [weak newWindow] webView, change in
258
+ if let actualTitle = change.newValue as? String {
259
+ newWindow?.title = actualTitle
260
+ }
261
+ }
262
+
263
+ windowObservations[windowId] = observation
264
+
265
+ case "triggerCloseSequence":
266
+ guard let window = windows[windowId] else { return }
267
+
268
+ window.performClose(nil)
269
+
270
+ case "forceCloseWindow":
271
+ guard let window = windows[windowId] else { return }
272
+
273
+ window.delegate = nil
274
+ window.close()
275
+ windows.removeValue(forKey: windowId)
276
+
277
+ case "terminate":
278
+ NSApp.terminate(nil)
279
+
280
+ case "setTitle":
281
+ guard let window = windows[windowId] else { return }
282
+ guard let title = args.first else {
283
+ printError("setTitle — missing title argument")
284
+ return
285
+ }
286
+ window.title = title
287
+
288
+ case "resize":
289
+ guard let window = windows[windowId] else { return }
290
+ guard args.count >= 2,
291
+ let width = Int(args[0]),
292
+ let height = Int(args[1]) else {
293
+ printError("resize — expected two integer arguments")
294
+ return
295
+ }
296
+ var frame = window.frame
297
+ frame.size = NSSize(width: width, height: height)
298
+ window.setFrame(frame, display: true, animate: true)
299
+
300
+ case "loadURL":
301
+ guard let window = windows[windowId] else { return }
302
+ guard let urlStr = args.first, let url = URL(string: urlStr) else {
303
+ printError("loadURL — invalid or missing URL")
304
+ return
305
+ }
306
+ (window.contentView as? WKWebView)?.load(URLRequest(url: url))
307
+
308
+
309
+ case "hide":
310
+ guard let window = windows[windowId] else { return }
311
+ window.orderOut(nil)
312
+
313
+ case "show":
314
+ guard let window = windows[windowId] else { return }
315
+ window.makeKeyAndOrderFront(nil)
316
+ NSApp.activate(ignoringOtherApps: true)
317
+
318
+ case "focus":
319
+ guard let window = windows[windowId] else { return }
320
+ window.makeKeyAndOrderFront(nil)
321
+ NSApp.activate(ignoringOtherApps: true)
322
+
323
+ case "fullscreen":
324
+ guard let window = windows[windowId] else { return }
325
+ window.toggleFullScreen(nil)
326
+
327
+ case "exitFullscreen":
328
+ guard let window = windows[windowId] else { return }
329
+ if window.styleMask.contains(.fullScreen) {
330
+ window.toggleFullScreen(nil)
331
+ }
332
+
333
+ case "toggleFullscreen":
334
+ guard let window = windows[windowId] else { return }
335
+ window.toggleFullScreen(nil)
336
+
337
+ case "loadFile":
338
+ guard let window = windows[windowId] else { return }
339
+ guard let path = args.first else {
340
+ printError("loadFile — missing path argument")
341
+ return
342
+ }
343
+ let fileURL = URL(fileURLWithPath: path)
344
+ (window.contentView as? WKWebView)?
345
+ .loadFileURL(fileURL, allowingReadAccessTo: fileURL.deletingLastPathComponent())
346
+
347
+
348
+ case "setBounds":
349
+ guard let window = windows[windowId] else { return }
350
+ guard args.count >= 4,
351
+ let x = Int(args[0]),
352
+ let y = Int(args[1]),
353
+ let width = Int(args[2]),
354
+ let height = Int(args[3]) else {
355
+ printError("setBounds — expected four integer arguments")
356
+ return
357
+ }
358
+ let frame = NSRect(x: x, y: y, width: width, height: height)
359
+ window.setFrame(frame, display: true, animate: true)
360
+
361
+ case "getBounds":
362
+ guard let window = windows[windowId] else { return }
363
+ let frame = window.frame
364
+ let bounds = ["x": "\(Int(frame.origin.x))", "y": "\(Int(frame.origin.y))", "width": "\(Int(frame.size.width))", "height": "\(Int(frame.size.height))"]
365
+ AppDelegate.shared?.ipcClient.send(
366
+ IPCResponse(windowId: windowId, event: "getBounds-reply-\(windowId)", data: bounds)
367
+ )
368
+
369
+ case "setResizable":
370
+ guard let window = windows[windowId] else { return }
371
+ guard let resizableStr = args.first, let resizable = Bool(resizableStr) else {
372
+ printError("setResizable — expected boolean argument")
373
+ return
374
+ }
375
+ if resizable {
376
+ window.styleMask.insert(.resizable)
377
+ } else {
378
+ window.styleMask.remove(.resizable)
379
+ }
380
+
381
+ case "setMinimizible":
382
+ guard let window = windows[windowId] else { return }
383
+ guard let minimizableStr = args.first, let minimizable = Bool(minimizableStr) else {
384
+ printError("setMinimizable — expected boolean argument")
385
+ return
386
+ }
387
+ if minimizable {
388
+ window.styleMask.insert(.miniaturizable)
389
+ } else {
390
+ window.styleMask.remove(.miniaturizable)
391
+ }
392
+
393
+ case "setClosable":
394
+ guard let window = windows[windowId] else { return }
395
+ guard let closableStr = args.first, let closable = Bool(closableStr) else {
396
+ printError("setClosable — expected boolean argument")
397
+ return
398
+ }
399
+ if closable {
400
+ window.styleMask.insert(.closable)
401
+ } else {
402
+ window.styleMask.remove(.closable)
403
+ }
404
+
405
+ case "setTitlebarTransparent":
406
+ guard let window = windows[windowId] else { return }
407
+ guard let transparentStr = args.first, let transparent = Bool(transparentStr) else {
408
+ printError("setTitlebarTransparent — expected boolean argument")
409
+ return
410
+ }
411
+ window.titlebarAppearsTransparent = transparent
412
+
413
+ case "setTitlebarVisible":
414
+ guard let window = windows[windowId] else { return }
415
+ guard let visibleStr = args.first, let visible = Bool(visibleStr) else {
416
+ printError("setTitlebarVisible — expected boolean argument")
417
+ return
418
+ }
419
+ window.titleVisibility = visible ? .visible : .hidden
420
+
421
+ case "canGoBack":
422
+ guard let window = windows[windowId] else { return }
423
+ let canGoBack = (window.contentView as? WKWebView)?.canGoBack ?? false
424
+ AppDelegate.shared?.ipcClient.send(
425
+ IPCResponse(windowId: windowId, event: "canGoBack-reply-\(windowId)", data: ["canGoBack": canGoBack ? "true" : "false"])
426
+ )
427
+
428
+ case "canGoForward":
429
+ guard let window = windows[windowId] else { return }
430
+ let canGoForward = (window.contentView as? WKWebView)?.canGoForward ?? false
431
+ AppDelegate.shared?.ipcClient.send(
432
+ IPCResponse(windowId: windowId, event: "canGoForward-reply-\(windowId)", data: ["canGoForward": canGoForward ? "true" : "false"])
433
+ )
434
+
435
+ case "showNotification":
436
+
437
+ UNUserNotificationCenter.current().requestAuthorization(
438
+ options: [.alert, .sound, .badge]
439
+ ) { granted, error in
440
+ if let error {
441
+ print(error)
442
+ }
443
+ }
444
+
445
+ guard let title = args.first else {
446
+ printError("showNotification — missing title argument")
447
+ return
448
+ }
449
+ let notification = UNMutableNotificationContent()
450
+ notification.title = title
451
+ if args.count > 1 {
452
+ notification.body = args[1]
453
+ }
454
+ let request = UNNotificationRequest(identifier: UUID().uuidString, content: notification, trigger: nil)
455
+ UNUserNotificationCenter.current().add(request) { error in
456
+ if let error {
457
+ printError("Failed to show notification: \(error.localizedDescription)")
458
+ }
459
+ }
460
+
461
+ case "getURL":
462
+ guard let window = windows[windowId] else { return }
463
+ let url = (window.contentView as? WKWebView)?.url?.absoluteString ?? ""
464
+ AppDelegate.shared?.ipcClient.send(
465
+ IPCResponse(windowId: windowId, event: "getURL-reply-\(windowId)", data: ["url": url])
466
+ )
467
+
468
+ case "getTitle":
469
+ guard let window = windows[windowId] else { return }
470
+ let title = window.title
471
+ AppDelegate.shared?.ipcClient.send(
472
+ IPCResponse(windowId: windowId, event: "getTitle-reply-\(windowId)", data: ["title": title])
473
+ )
474
+
475
+ case "executeAppleScript":
476
+ guard let scriptSource = args.first else {
477
+ printError("executeAppleScript — missing script argument")
478
+ return
479
+ }
480
+ let script = NSAppleScript(source: scriptSource)
481
+ var errorInfo: NSDictionary?
482
+ script?.executeAndReturnError(&errorInfo)
483
+ if let errorInfo {
484
+ let errorMessage = errorInfo[NSAppleScript.errorMessage] as? String ?? "Unknown error"
485
+ printError("executeAppleScript failed: \(errorMessage)")
486
+ }
487
+
488
+ case "forward":
489
+ guard let window = windows[windowId] else { return }
490
+ (window.contentView as? WKWebView)?.goForward()
491
+
492
+ case "back":
493
+ guard let window = windows[windowId] else { return }
494
+ (window.contentView as? WKWebView)?.goBack()
495
+
496
+
497
+ case "reload":
498
+ guard let window = windows[windowId] else { return }
499
+ (window.contentView as? WKWebView)?.reload()
500
+
501
+ case "capturePage":
502
+ guard let window = windows[windowId] else { return }
503
+ (window.contentView as? WKWebView)?.takeSnapshot(with: nil) { image, error in
504
+ if let error {
505
+ printError("Failed to capture page: \(error.localizedDescription)")
506
+ return
507
+ }
508
+ guard let image = image else {
509
+ printError("Failed to capture page: no image returned")
510
+ return
511
+ }
512
+
513
+ guard let tiffData = image.tiffRepresentation,
514
+ let bitmap = NSBitmapImageRep(data: tiffData),
515
+ let pngData = bitmap.representation(using: .png, properties: [:]) else {
516
+ printError("Failed to capture page: unable to convert image to PNG")
517
+ return
518
+ }
519
+
520
+ let base64PNG = pngData.base64EncodedString()
521
+ AppDelegate.shared?.ipcClient.send(
522
+ IPCResponse(windowId: windowId, event: "capture-page-result-\(windowId)", data: ["image": base64PNG])
523
+ )
524
+ }
525
+
526
+ case "print":
527
+ guard let window = windows[windowId] else { return }
528
+ let printInfo = NSPrintInfo.shared
529
+ printInfo.horizontalPagination = .automatic
530
+ printInfo.verticalPagination = .automatic
531
+ printInfo.isHorizontallyCentered = true
532
+ printInfo.isVerticallyCentered = true
533
+
534
+ let printOperation = NSPrintOperation(view: window.contentView!, printInfo: printInfo)
535
+ printOperation.run()
536
+
537
+ case "setUserAgent":
538
+ guard let window = windows[windowId] else { return }
539
+ guard let userAgent = args.first else {
540
+ printError("setUserAgent — missing user agent string argument")
541
+ return
542
+ }
543
+ (window.contentView as? WKWebView)?.customUserAgent = userAgent
544
+
545
+ case "evaluateJS":
546
+ guard let window = windows[windowId] else { return }
547
+ guard let script = args.first else {
548
+ printError("evaluateJS — missing script argument")
549
+ return
550
+ }
551
+ (window.contentView as? WKWebView)?.evaluateJavaScript(script) { result, error in
552
+ if let error {
553
+ printError("evaluateJS failed: \(error)")
554
+ }
555
+ let resultStr: String
556
+ if let result = result {
557
+ if JSONSerialization.isValidJSONObject(result) {
558
+ if let data = try? JSONSerialization.data(withJSONObject: result),
559
+ let jsonStr = String(data: data, encoding: .utf8) {
560
+ resultStr = jsonStr
561
+ } else {
562
+ resultStr = "\"[Unserializable Result]\""
563
+ }
564
+ } else {
565
+ resultStr = "\"\(String(describing: result))\""
566
+ }
567
+ } else {
568
+ resultStr = "null"
569
+ }
570
+ AppDelegate.shared?.ipcClient.send(
571
+ IPCResponse(windowId: windowId, event: "evaluateJS-reply-\(windowId)", data: ["result": resultStr])
572
+ )
573
+ }
574
+
575
+ case "prompt":
576
+ guard let window = windows[windowId] else { return }
577
+ guard let message = args.first else {
578
+ printError("prompt — missing message argument")
579
+ return
580
+ }
581
+ let alert = NSAlert()
582
+ alert.messageText = message
583
+ alert.addButton(withTitle: "OK")
584
+ alert.addButton(withTitle: "Cancel")
585
+
586
+ let inputField = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24))
587
+ alert.accessoryView = inputField
588
+
589
+ if args.count > 1 {
590
+ inputField.stringValue = args[1]
591
+ }
592
+
593
+ alert.beginSheetModal(for: window) { response in
594
+ if response == .alertFirstButtonReturn {
595
+ let userInput = inputField.stringValue
596
+ AppDelegate.shared?.ipcClient.send(
597
+ IPCResponse(windowId: windowId, event: "prompt-reply-\(windowId)", data: ["input": userInput])
598
+ )
599
+ } else {
600
+ AppDelegate.shared?.ipcClient.send(
601
+ IPCResponse(windowId: windowId, event: "prompt-reply-\(windowId)", data: ["input": ""])
602
+ )
603
+ }
604
+ }
605
+
606
+ case "isFocused":
607
+ guard let window = windows[windowId] else { return }
608
+ let isFocused = window.isKeyWindow
609
+ AppDelegate.shared?.ipcClient.send(
610
+ IPCResponse(windowId: windowId, event: "isFocused-reply-\(windowId)", data: ["isFocused": isFocused ? "true" : "false"])
611
+ )
612
+
613
+ case "emitToRenderer":
614
+ guard let window = windows[windowId] else { return }
615
+ guard args.count >= 2 else {
616
+ printError("emitToRenderer — expected channel and payload arguments")
617
+ return
618
+ }
619
+ let channel = args[0]
620
+ let payload = args[1]
621
+ let escaped = payload
622
+ .replacingOccurrences(of: "\\", with: "\\\\")
623
+ .replacingOccurrences(of: "`", with: "\\`")
624
+ let script = "window.ipc._emit(`\(channel)`, JSON.parse(`\(escaped)`));"
625
+ (window.contentView as? WKWebView)?.evaluateJavaScript(script) { _, error in
626
+ if let error {
627
+ printError("emitToRenderer failed: \(error.localizedDescription)")
628
+ }
629
+ }
630
+
631
+ case "setAlwaysOnTop":
632
+ guard let window = windows[windowId] else { return }
633
+ guard let alwaysOnTopStr = args.first, let alwaysOnTop = Bool(alwaysOnTopStr) else {
634
+ printError("setAlwaysOnTop — expected boolean argument")
635
+ return
636
+ }
637
+ window.level = alwaysOnTop ? .floating : .normal
638
+
639
+ case "setContextMenu":
640
+ print("Setting context menu for window \(windowId)")
641
+ guard let window = windows[windowId] else { return }
642
+ guard let jsonStr = args.first,
643
+ let data = jsonStr.data(using: .utf8),
644
+ let desc = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
645
+ else {
646
+ printError("setContextMenu — invalid JSON descriptor")
647
+ return
648
+ }
649
+
650
+ let menu = buildContextMenu(from: desc, windowId: windowId)
651
+
652
+ DispatchQueue.main.async {
653
+ if let view = window.contentView {
654
+ print("Attaching context menu to content view for window \(windowId)")
655
+ view.menu = menu
656
+ } else {
657
+ printError("setContextMenu — no content view to attach menu")
658
+ }
659
+ }
660
+
661
+ case "setMenu":
662
+ guard let jsonStr = args.first,
663
+ let data = jsonStr.data(using: .utf8),
664
+ let desc = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
665
+ else {
666
+ printError("setMenu — invalid JSON descriptor")
667
+ return
668
+ }
669
+ NSApp.mainMenu = buildMenu(from: desc, windowId: windowId)
670
+
671
+ case "resetMenu":
672
+ // Restore the hardcoded default (just call the same helper)
673
+ AppDelegate.shared?.setupDefaultMenu()
674
+
675
+ default:
676
+ let registry = getBuiltInHandlers()
677
+ if let handler = registry[command] {
678
+ handler(windowId, args)
679
+ } else {
680
+ printError("Unknown command '\(command)' for window \(windowId)")
681
+ }
682
+ }
683
+ }
684
+
685
+ // MARK: - WebView → Swift IPC Handler
686
+
687
+ /// Receives messages from renderer JS: window.webkit.messageHandlers.ipc.postMessage({...})
688
+ /// and forwards them upstream to Node over the WebSocket.
689
+ final class WebViewIPCHandler: NSObject, WKScriptMessageHandler {
690
+ let windowId: Int
691
+
692
+ init(windowId: Int) {
693
+ self.windowId = windowId
694
+ }
695
+
696
+ func userContentController(
697
+ _ userContentController: WKUserContentController,
698
+ didReceive message: WKScriptMessage
699
+ ) {
700
+ // Renderer JS must post a plain object: { channel: String, payload: any }
701
+ guard let body = message.body as? [String: Any],
702
+ let channel = body["channel"] as? String else {
703
+ printError("Received malformed IPC message from renderer: \(message.body)")
704
+ return
705
+ }
706
+
707
+ // Serialise payload back to a JSON string so it travels cleanly over the WebSocket
708
+ let payloadString: String
709
+ if let payload = body["payload"],
710
+ let data = try? JSONSerialization.data(withJSONObject: payload),
711
+ let str = String(data: data, encoding: .utf8) {
712
+ payloadString = str
713
+ } else {
714
+ payloadString = "null"
715
+ }
716
+
717
+ // Forward to Node as a standard IPCResponse event
718
+ AppDelegate.shared?.ipcClient.send(
719
+ IPCResponse(
720
+ windowId: windowId,
721
+ event: "ipcMessage",
722
+ data: ["channel": channel, "payload": payloadString]
723
+ )
724
+ )
725
+ }
726
+ }
727
+
728
+ func makePreloadScript(windowId: Int) -> String {
729
+ return """
730
+ (function () {
731
+ if (window.__ipcInstalled) return;
732
+ window.__ipcInstalled = true;
733
+
734
+ const _listeners = {};
735
+
736
+ window.ipc = {
737
+ /** Send a message to the Node/Swift backend.
738
+ * @param {string} channel
739
+ * @param {*} payload — must be JSON-serialisable
740
+ */
741
+ send(channel, payload = null) {
742
+ if(typeof channel !== 'string') {
743
+ console.warn('[ipc] send() failed: channel must be a string');
744
+ return;
745
+ }
746
+ if (!payload) payload = {};
747
+ window.webkit.messageHandlers.ipc.postMessage({ channel, payload });
748
+ },
749
+
750
+ /** Listen for a message pushed from the backend via ipc.emit().
751
+ * @param {string} channel
752
+ * @param {Function} listener
753
+ */
754
+ on(channel, listener) {
755
+ if (!_listeners[channel]) _listeners[channel] = [];
756
+ _listeners[channel].push(listener);
757
+ },
758
+
759
+ /** Remove a previously registered listener. */
760
+ off(channel, listener) {
761
+ if (!_listeners[channel]) return;
762
+ _listeners[channel] = _listeners[channel].filter(l => l !== listener);
763
+ },
764
+
765
+ /** Called internally by Swift's evaluateJS to deliver a push message. */
766
+ _emit(channel, payload) {
767
+ (_listeners[channel] || []).forEach(fn => {
768
+ try { fn(payload); } catch(e) { console.printError('[ipc] listener error:', e); }
769
+ });
770
+ },
771
+
772
+ /** Window ID stamped in at injection time — useful for multi-window apps. */
773
+ windowId: \(windowId),
774
+ };
775
+
776
+ console.debug('[ipc] preload ready, windowId=\(windowId)');
777
+ })();
778
+ """
779
+ }
780
+
781
+ // MARK: - IPC Client
782
+
783
+ final class IPCClient {
784
+
785
+ private var webSocketTask: URLSessionWebSocketTask?
786
+ private let authToken: String
787
+ private let serverURL: URL
788
+ private var reconnectAttempts = 0
789
+ private let maxReconnectAttempts = 10
790
+ private let reconnectDelay: TimeInterval = 2.0
791
+
792
+ init(serverURL: URL = URL(string: "ws://localhost:9000")!) {
793
+ let POSITRON_IPC_PORT = port ?? 9000
794
+ self.serverURL = URL(string: "ws://localhost:\(POSITRON_IPC_PORT)")!
795
+ self.authToken = AUTH_TOKEN
796
+ }
797
+
798
+ func connect() {
799
+ guard reconnectAttempts < maxReconnectAttempts else {
800
+ printError("Exceeded maximum reconnect attempts (\(maxReconnectAttempts)). Giving up.")
801
+ return
802
+ }
803
+ let session = URLSession(configuration: .default)
804
+ var request = URLRequest(url: serverURL)
805
+ request.setValue(authToken, forHTTPHeaderField: "X-Positron-Auth-Token")
806
+ webSocketTask = session.webSocketTask(with: request)
807
+ webSocketTask?.resume()
808
+ printError("INFO: Connecting to IPC server (attempt \(reconnectAttempts + 1))…")
809
+ reconnectAttempts = 0 // reset on successful connect
810
+ receiveMessage()
811
+ }
812
+
813
+ func send(_ response: IPCResponse) {
814
+ guard let data = try? JSONEncoder().encode(response),
815
+ let text = String(data: data, encoding: .utf8) else {
816
+ printError("Failed to encode IPCResponse")
817
+ return
818
+ }
819
+ webSocketTask?.send(.string(text)) { error in
820
+ if let error {
821
+ printError("Failed to send IPC response: \(error.localizedDescription)")
822
+ }
823
+ }
824
+ }
825
+
826
+ private func receiveMessage() {
827
+ webSocketTask?.receive { [weak self] result in
828
+ guard let self else { return }
829
+ switch result {
830
+ case .failure(let error):
831
+ printError("WebSocket error: \(error.localizedDescription).")
832
+ self.scheduleReconnect()
833
+ case .success(let message):
834
+ switch message {
835
+ case .string(let text):
836
+ self.parseAndDispatch(text)
837
+ case .data(let data):
838
+ if let text = String(data: data, encoding: .utf8) {
839
+ self.parseAndDispatch(text)
840
+ }
841
+ @unknown default:
842
+ printError("Received unknown WebSocket message type")
843
+ }
844
+ self.receiveMessage() // Continue listening
845
+ }
846
+ }
847
+ }
848
+
849
+ private func parseAndDispatch(_ text: String) {
850
+ guard let data = text.data(using: .utf8) else { return }
851
+ do {
852
+ let msg = try JSONDecoder().decode(IPCMessage.self, from: data)
853
+ DispatchQueue.main.async {
854
+ handleCommand(windowId: msg.windowId, command: msg.command, args: msg.args)
855
+ }
856
+ } catch {
857
+ printError("Failed to decode IPC message '\(text)': \(error)")
858
+ }
859
+ }
860
+
861
+ private func scheduleReconnect() {
862
+ reconnectAttempts += 1
863
+ guard reconnectAttempts < maxReconnectAttempts else {
864
+ printError("Exceeded maximum reconnect attempts (\(maxReconnectAttempts)). Giving up.")
865
+ return
866
+ }
867
+ printError("Reconnecting to \(serverURL) in \(reconnectDelay)s… (attempt \(reconnectAttempts)/\(maxReconnectAttempts))")
868
+ DispatchQueue.global().asyncAfter(deadline: .now() + reconnectDelay) { [weak self] in
869
+ self?.connect()
870
+ }
871
+ }
872
+ }
873
+
874
+ // MARK: - App Delegate
875
+
876
+ class AppDelegate: NSObject, NSApplicationDelegate {
877
+ var ipcClient: IPCClient!
878
+ var nodeProcess: Process?
879
+
880
+ static weak var shared: AppDelegate?
881
+
882
+ func applicationDidFinishLaunching(_ notification: Notification) {
883
+
884
+ AppDelegate.shared = self
885
+
886
+ if Bundle.main.bundlePath.hasSuffix(".app") {
887
+ startNodeProcess()
888
+ }
889
+
890
+ ipcClient = IPCClient()
891
+ ipcClient.connect()
892
+
893
+ setupDefaultMenu()
894
+ }
895
+
896
+
897
+ func windowShouldClose(_ sender: NSWindow) -> Bool {
898
+ guard let windowId = windows.first(where: { $0.value == sender })?.key else {
899
+ return true
900
+ }
901
+ self.ipcClient.send(
902
+ IPCResponse(windowId: windowId, event: "window-close-requested", data: [:])
903
+ )
904
+
905
+ return false
906
+ }
907
+
908
+ func startNodeProcess() {
909
+ guard let resourcePath = Bundle.main.resourcePath else { return }
910
+
911
+ nodeProcess = Process()
912
+ nodeProcess?.executableURL = URL(fileURLWithPath: "/bin/zsh")
913
+
914
+ IS_PACKAGED = true
915
+
916
+ var command = """
917
+ export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
918
+ export POSITRON_PACKAGED=true
919
+ export POSITRON_AUTH_TOKEN="\(AUTH_TOKEN)"
920
+ if [ -f "$HOME/.zshrc" ]; then source "$HOME/.zshrc"; fi
921
+ if [ -f "$HOME/.bash_profile" ]; then source "$HOME/.bash_profile"; fi
922
+
923
+ cd "\(resourcePath)"
924
+
925
+ if [ -f "positron-backend" ]; then
926
+ exec "./positron-backend"
927
+ else
928
+ exec "node" "."
929
+ fi
930
+
931
+ """
932
+
933
+ if let port = port {
934
+ command.insert(contentsOf: "export POSITRON_IPC_PORT=\(port); ", at: command.startIndex)
935
+ } else {
936
+ printError("WARNING: Failed to get random open port for IPC. Defaulting to 9000, which may cause conflicts.")
937
+ }
938
+
939
+ nodeProcess?.arguments = ["-c", command]
940
+
941
+ let pipe = Pipe()
942
+ nodeProcess?.standardOutput = pipe
943
+ nodeProcess?.standardError = pipe
944
+
945
+ pipe.fileHandleForReading.readabilityHandler = { handle in
946
+ let data = handle.availableData
947
+ if data.count > 0, let str = String(data: data, encoding: .utf8) {
948
+ print("[NODE BACKGROUND] \(str)", terminator: "")
949
+ }
950
+ }
951
+
952
+ nodeProcess?.terminationHandler = { _ in
953
+ printError("INFO: Node process terminated. Shutting down app.")
954
+ NSApp.terminate(nil)
955
+ }
956
+
957
+ do {
958
+ try nodeProcess?.run()
959
+ } catch {
960
+ printError("Failed to start Node process: \(error)")
961
+ }
962
+ }
963
+
964
+ func applicationWillTerminate(_ notification: Notification) {
965
+ nodeProcess?.terminate()
966
+ }
967
+
968
+ func setupDefaultMenu() {
969
+ let mainMenu = NSMenu()
970
+
971
+ // App menu (first item's submenu is always the app menu on macOS)
972
+ let appMenuItem = NSMenuItem()
973
+ mainMenu.addItem(appMenuItem)
974
+ let appMenu = NSMenu()
975
+ appMenuItem.submenu = appMenu
976
+ appMenu.addItem(withTitle: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
977
+
978
+ // File menu
979
+ let fileMenuItem = NSMenuItem()
980
+ mainMenu.addItem(fileMenuItem)
981
+ let fileMenu = NSMenu(title: "File")
982
+ fileMenuItem.submenu = fileMenu
983
+ fileMenu.addItem(withTitle: "Close Window", action: #selector(NSWindow.close), keyEquivalent: "w")
984
+
985
+ // Edit menu (needed for cut/copy/paste/undo to work in WKWebView)
986
+ let editMenuItem = NSMenuItem()
987
+ mainMenu.addItem(editMenuItem)
988
+ let editMenu = NSMenu(title: "Edit")
989
+ editMenuItem.submenu = editMenu
990
+ editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
991
+ editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")
992
+ editMenu.addItem(.separator())
993
+ editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
994
+ editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
995
+ editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
996
+ editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
997
+
998
+ NSApp.mainMenu = mainMenu
999
+ }
1000
+ }
1001
+
1002
+ /// Receives menu item clicks and forwards them to Node as IPC events.
1003
+ final class MenuActionTarget: NSObject {
1004
+ let windowId: Int
1005
+ let channel: String // arbitrary string the JS side chooses
1006
+ let payload: String // JSON string forwarded verbatim
1007
+ let label: String? // for debugging
1008
+
1009
+ init(windowId: Int, channel: String, payload: String, label: String?) {
1010
+ self.windowId = windowId
1011
+ self.channel = channel
1012
+ self.payload = payload
1013
+ self.label = label
1014
+ }
1015
+
1016
+ @objc func fire(_ sender: Any?) {
1017
+ AppDelegate.shared?.ipcClient.send(
1018
+ IPCResponse(
1019
+ windowId: windowId,
1020
+ event: "menu-action",
1021
+ data: ["channel": channel, "payload": payload, "label": label ?? "label"]
1022
+ )
1023
+ )
1024
+ }
1025
+ }
1026
+
1027
+ // MARK: - Safe Menu Retention & Subclasses
1028
+
1029
+ final class PositronMenuItem: NSMenuItem {
1030
+ var retainedTarget: PositronMenuTarget? {
1031
+ didSet {
1032
+ self.target = retainedTarget
1033
+ }
1034
+ }
1035
+ }
1036
+
1037
+ final class PositronMenuTarget: NSObject {
1038
+ let windowId: Int
1039
+ let channel: String
1040
+ let payload: String
1041
+ let label: String?
1042
+ let isContextMenu: Bool
1043
+
1044
+ init(windowId: Int, channel: String, payload: String, label: String?, isContextMenu: Bool) {
1045
+ self.windowId = windowId
1046
+ self.channel = channel
1047
+ self.payload = payload
1048
+ self.label = label
1049
+ self.isContextMenu = isContextMenu
1050
+ }
1051
+
1052
+ @objc func fire(_ sender: Any?) {
1053
+ let eventName = isContextMenu ? "context-menu-action" : "menu-action"
1054
+ AppDelegate.shared?.ipcClient.send(
1055
+ IPCResponse(
1056
+ windowId: windowId,
1057
+ event: eventName,
1058
+ data: ["channel": channel, "payload": payload, "label": label ?? "label"]
1059
+ )
1060
+ )
1061
+ }
1062
+ }
1063
+
1064
+ // MARK: - Menu Builders
1065
+
1066
+ func buildMenu(from descriptor: [[String: Any]], windowId: Int) -> NSMenu {
1067
+ let menu = NSMenu()
1068
+ for topLevel in descriptor {
1069
+ let topItem = NSMenuItem()
1070
+ topItem.title = topLevel["label"] as? String ?? ""
1071
+ menu.addItem(topItem)
1072
+
1073
+ let sub = NSMenu(title: topItem.title)
1074
+ topItem.submenu = sub
1075
+
1076
+ if let items = topLevel["items"] as? [[String: Any]] {
1077
+ populateMenu(sub, with: items, windowId: windowId, isContextMenu: false)
1078
+ }
1079
+ }
1080
+ return menu
1081
+ }
1082
+
1083
+ func buildContextMenu(from descriptor: [[String: Any]], windowId: Int) -> NSMenu {
1084
+ let menu = NSMenu()
1085
+ populateMenu(menu, with: descriptor, windowId: windowId, isContextMenu: true)
1086
+ return menu
1087
+ }
1088
+
1089
+ private func populateMenu(_ menu: NSMenu, with items: [[String: Any]], windowId: Int, isContextMenu: Bool) {
1090
+ for item in items {
1091
+ if item["separator"] as? Bool == true {
1092
+ menu.addItem(.separator())
1093
+ continue
1094
+ }
1095
+
1096
+ let label = item["label"] as? String ?? "(untitled)"
1097
+ let key = item["key"] as? String ?? ""
1098
+ let channel = item["channel"] as? String ?? ""
1099
+ let payload = item["payload"] as? String ?? "null"
1100
+ let enabled = item["enabled"] as? Bool ?? true
1101
+
1102
+ let target = PositronMenuTarget(
1103
+ windowId: windowId,
1104
+ channel: channel,
1105
+ payload: payload,
1106
+ label: label,
1107
+ isContextMenu: isContextMenu
1108
+ )
1109
+
1110
+ // Use our subclass to guarantee the action target lives exactly as long as the item itself
1111
+ let menuItem = PositronMenuItem(title: label, action: #selector(PositronMenuTarget.fire(_:)), keyEquivalent: key)
1112
+ menuItem.retainedTarget = target
1113
+
1114
+ if enabled == false {
1115
+ menu.autoenablesItems = false
1116
+ menuItem.isEnabled = enabled
1117
+ }
1118
+
1119
+ if let subItems = item["items"] as? [[String: Any]], !subItems.isEmpty {
1120
+ let sub = NSMenu(title: label)
1121
+ populateMenu(sub, with: subItems, windowId: windowId, isContextMenu: isContextMenu)
1122
+ menuItem.submenu = sub
1123
+ }
1124
+
1125
+ menu.addItem(menuItem)
1126
+ }
1127
+ }
1128
+
1129
+ setbuf(__stdoutp, nil)
1130
+ setbuf(__stderrp, nil)
1131
+
1132
+ signal(SIGINT) { _ in
1133
+ printError("INFO: Received SIGINT, shutting down…")
1134
+ AppDelegate.shared?.nodeProcess?.terminate()
1135
+ exit(0)
1136
+ }
1137
+
1138
+ signal(SIGTERM) { _ in
1139
+ printError("INFO: Received SIGTERM, shutting down…")
1140
+ AppDelegate.shared?.nodeProcess?.terminate()
1141
+ exit(0)
1142
+ }
1143
+
1144
+ signal(SIGSEGV) { _ in
1145
+ printError("ERROR: Caught SIGSEGV (segmentation fault). This likely indicates a bug in the native code. Attempting to shut down gracefully…")
1146
+ AppDelegate.shared?.nodeProcess?.terminate()
1147
+ signal(SIGSEGV, SIG_DFL)
1148
+ raise(SIGSEGV)
1149
+ }
1150
+
1151
+ let app = NSApplication.shared
1152
+ let delegate = AppDelegate()
1153
+ app.delegate = delegate
1154
+ app.run()