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.
- package/LICENSE +7 -0
- package/README.md +154 -0
- package/bin/positron.js +40 -0
- package/builder.js +229 -0
- package/core/mac/main.swift +1154 -0
- package/core/win/PositronRuntime.csproj +14 -0
- package/core/win/main.cs +1124 -0
- package/extensions.js +42 -0
- package/findpackage.js +34 -0
- package/index.js +912 -0
- package/ipc.js +81 -0
- package/logs.js +19 -0
- package/menu.js +100 -0
- package/package.json +30 -0
- package/packager.js +260 -0
- package/pbannerfull.png +0 -0
- package/positronicon.png +0 -0
- package/screen.js +35 -0
- package/store.js +104 -0
|
@@ -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()
|