positron.js 1.0.5 → 1.1.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.
@@ -127,6 +127,22 @@ func printError(_ message: String) {
127
127
  let tag = isWarning ? "WARNING" : (isInfo ? "INFO" : "ERROR")
128
128
 
129
129
  print("\(red)[SWIFT \(tag)] \(msg)\(reset)")
130
+
131
+ if(tag == "INFO") {
132
+ return
133
+ }
134
+
135
+ if msg.contains("IPC response") || msg.contains("WebSocket error") || msg.contains("Reconnecting to") || msg.contains("IPC message") {
136
+ return
137
+ }
138
+
139
+ if let ipcClient = AppDelegate.shared?.ipcClient {
140
+ ipcClient.send(
141
+ IPCResponse(windowId: -1, event: "nativeError", data: ["message": message, "type": tag])
142
+ )
143
+ } else {
144
+ print("\(red)[SWIFT ERROR] No IPC client available to send error message over. \(reset)")
145
+ }
130
146
  }
131
147
 
132
148
  public protocol PositronExtension {
@@ -370,6 +386,10 @@ case "forceCloseWindow":
370
386
  printError("loadURL — invalid or missing URL")
371
387
  return
372
388
  }
389
+ if let scheme = url.scheme?.lowercased(), !["http", "https", "file"].contains(scheme) {
390
+ printError("loadURL — blocked unauthorized URL scheme: \(scheme)")
391
+ return
392
+ }
373
393
  (window.contentView as? WKWebView)?.load(URLRequest(url: url))
374
394
 
375
395
  case "hide":
@@ -585,19 +605,6 @@ UNUserNotificationCenter.current().requestAuthorization(
585
605
  IPCResponse(windowId: windowId, event: args.last ?? "getTitle-reply-\(windowId)", data: ["title": title])
586
606
  )
587
607
 
588
- case "executeAppleScript":
589
- guard let scriptSource = args.first else {
590
- printError("executeAppleScript — missing script argument")
591
- return
592
- }
593
- let script = NSAppleScript(source: scriptSource)
594
- var errorInfo: NSDictionary?
595
- script?.executeAndReturnError(&errorInfo)
596
- if let errorInfo {
597
- let errorMessage = errorInfo[NSAppleScript.errorMessage] as? String ?? "Unknown error"
598
- printError("executeAppleScript failed: \(errorMessage)")
599
- }
600
-
601
608
  case "isVisible":
602
609
  guard let window = windows[windowId] else { return }
603
610
  let isVisible = window.isVisible
@@ -819,7 +826,7 @@ case "addToContentBlocker":
819
826
  alert.addButton(withTitle: "Cancel")
820
827
 
821
828
  alert.beginSheetModal(for: window) { response in
822
- let confirmed = (response == .alertFirstButtonReturn)
829
+ let confirmed = (response == .alertSecondButtonReturn) ? false : true
823
830
  AppDelegate.shared?.ipcClient.send(
824
831
  IPCResponse(windowId: windowId, event: args.last ?? "confirm-reply-\(windowId)", data: ["confirmed": confirmed ? "true" : "false"])
825
832
  )
@@ -832,6 +839,12 @@ case "addToContentBlocker":
832
839
  IPCResponse(windowId: windowId, event: args.last ?? "isFocused-reply-\(windowId)", data: ["isFocused": isFocused ? "true" : "false"])
833
840
  )
834
841
 
842
+ case "getFocusedWindowId":
843
+ let focusedWindowId = windows.first(where: { $0.value.isKeyWindow })?.key ?? -1
844
+ AppDelegate.shared?.ipcClient.send(
845
+ IPCResponse(windowId: windowId, event: args.last ?? "getFocusedWindowId-reply-\(windowId)", data: ["focusedWindowId": "\(focusedWindowId)"])
846
+ )
847
+
835
848
  case "emitToRenderer":
836
849
  guard let window = windows[windowId] else { return }
837
850
  guard args.count >= 2 else {
@@ -920,6 +933,14 @@ final class WebViewNavigationDelegate: NSObject, WKNavigationDelegate {
920
933
  IPCResponse(windowId: windowId, event: eventName, data: ["url": webView.url?.absoluteString ?? "", "title": webView.title ?? "", "canGoBack": (webView.canGoBack ? "true" : "false"), "canGoForward": (webView.canGoForward ? "true" : "false")])
921
934
  )
922
935
  }
936
+
937
+ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
938
+ printError("Navigation failed: \(error.localizedDescription)")
939
+ }
940
+
941
+ func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
942
+ printError("Provisional navigation failed: \(error.localizedDescription)")
943
+ }
923
944
  }
924
945
 
925
946
  // MARK: - WebView → Swift IPC Handler
@@ -1005,7 +1026,7 @@ func makePreloadScript(windowId: Int) -> String {
1005
1026
  /** Called internally by Swift's evaluateJS to deliver a push message. */
1006
1027
  _emit(channel, payload) {
1007
1028
  (_listeners[channel] || []).forEach(fn => {
1008
- try { fn(payload); } catch(e) { console.printError('[ipc] listener error:', e); }
1029
+ try { fn(payload); } catch(e) { console.error('[ipc] listener error:', e); }
1009
1030
  });
1010
1031
  },
1011
1032
 
@@ -1029,9 +1050,9 @@ final class IPCClient {
1029
1050
  private let maxReconnectAttempts = 10
1030
1051
  private let reconnectDelay: TimeInterval = 2.0
1031
1052
 
1032
- init(serverURL: URL = URL(string: "ws://localhost:9000")!) {
1053
+ init(serverURL: URL = URL(string: "ws://127.0.0.1:9000")!) {
1033
1054
  let POSITRON_IPC_PORT = port ?? 9000
1034
- self.serverURL = URL(string: "ws://localhost:\(POSITRON_IPC_PORT)")!
1055
+ self.serverURL = URL(string: "ws://127.0.0.1:\(POSITRON_IPC_PORT)")!
1035
1056
  self.authToken = AUTH_TOKEN
1036
1057
  }
1037
1058
 
@@ -1046,7 +1067,6 @@ final class IPCClient {
1046
1067
  webSocketTask = session.webSocketTask(with: request)
1047
1068
  webSocketTask?.resume()
1048
1069
  printError("INFO: Connecting to IPC server (attempt \(reconnectAttempts + 1))…")
1049
- reconnectAttempts = 0 // reset on successful connect
1050
1070
  receiveMessage()
1051
1071
  }
1052
1072
 
@@ -1058,7 +1078,12 @@ final class IPCClient {
1058
1078
  }
1059
1079
  webSocketTask?.send(.string(text)) { error in
1060
1080
  if let error {
1061
- printError("Failed to send IPC response: \(error.localizedDescription)")
1081
+ let errorMsg = error.localizedDescription
1082
+ printError("Failed to send IPC response: \(errorMsg)")
1083
+ if errorMsg.lowercased().contains("bad response from the server") {
1084
+ printError("Fatal connection error (Unauthorized or Port Hijacked). Exiting immediately.")
1085
+ exit(1)
1086
+ }
1062
1087
  }
1063
1088
  }
1064
1089
  }
@@ -1068,9 +1093,15 @@ final class IPCClient {
1068
1093
  guard let self else { return }
1069
1094
  switch result {
1070
1095
  case .failure(let error):
1071
- printError("WebSocket error: \(error.localizedDescription).")
1096
+ let errorMsg = error.localizedDescription
1097
+ printError("WebSocket error: \(errorMsg).")
1098
+ if errorMsg.contains("503") || errorMsg.contains("401") || errorMsg.contains("403") || errorMsg.lowercased().contains("bad response from the server") {
1099
+ printError("Fatal connection error (Unauthorized or Port Hijacked). Exiting immediately.")
1100
+ exit(1)
1101
+ }
1072
1102
  self.scheduleReconnect()
1073
1103
  case .success(let message):
1104
+ self.reconnectAttempts = 0
1074
1105
  switch message {
1075
1106
  case .string(let text):
1076
1107
  self.parseAndDispatch(text)
@@ -1102,7 +1133,7 @@ final class IPCClient {
1102
1133
  reconnectAttempts += 1
1103
1134
  guard reconnectAttempts < maxReconnectAttempts else {
1104
1135
  printError("Exceeded maximum reconnect attempts (\(maxReconnectAttempts)). Giving up.")
1105
- return
1136
+ exit(1)
1106
1137
  }
1107
1138
  printError("Reconnecting to \(serverURL) in \(reconnectDelay)s… (attempt \(reconnectAttempts)/\(maxReconnectAttempts))")
1108
1139
  DispatchQueue.global().asyncAfter(deadline: .now() + reconnectDelay) { [weak self] in
@@ -0,0 +1,95 @@
1
+ import Cocoa
2
+
3
+ class TrayManager {
4
+ static let shared = TrayManager()
5
+ var statusItem: NSStatusItem?
6
+
7
+ func setupTray() {
8
+ if statusItem == nil {
9
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
10
+ if let button = statusItem?.button {
11
+ button.title = "App"
12
+ }
13
+ }
14
+ }
15
+
16
+ func setMenu(_ menu: NSMenu) {
17
+ statusItem?.menu = menu
18
+ }
19
+
20
+ func setTitle(_ title: String) {
21
+ statusItem?.button?.title = title
22
+ }
23
+
24
+ func setIcon(_ iconPath: String) {
25
+ guard let button = statusItem?.button else { return printError("Tray button not initialized") }
26
+
27
+ if iconPath.isEmpty {
28
+ DispatchQueue.main.async {
29
+ button.image = nil
30
+ button.imagePosition = .imageLeft
31
+ }
32
+ return
33
+ }
34
+
35
+ guard FileManager.default.fileExists(atPath: iconPath) else {
36
+ printError("Icon path does not exist: \(iconPath)")
37
+ return
38
+ }
39
+
40
+ DispatchQueue.main.async {
41
+ if let img = NSImage(contentsOfFile: iconPath) {
42
+ img.size = NSSize(width: 18, height: 18)
43
+ img.isTemplate = true
44
+ button.image = img
45
+ button.imagePosition = .imageLeft
46
+ } else {
47
+ button.image = nil
48
+ button.imagePosition = .imageLeft
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ public struct TrayExtension {
55
+ public static func handle(windowId: Int, args: [String]) {
56
+
57
+ if(args.last == "setTitle") {
58
+ TrayManager.shared.setTitle(args[0])
59
+ return
60
+ }
61
+
62
+ if(args.last == "setIcon") {
63
+ TrayManager.shared.setIcon(args[0])
64
+ return
65
+ }
66
+
67
+ guard let descString = args.first,
68
+ let data = descString.data(using: .utf8),
69
+ let desc = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
70
+ printError("tray setMenu — invalid JSON descriptor")
71
+ return
72
+ }
73
+
74
+ if args.last == "setMenu" {
75
+ TrayManager.shared.setMenu(buildContextMenu(from: desc, windowId: windowId))
76
+ return
77
+ }
78
+
79
+ DispatchQueue.main.async {
80
+
81
+ TrayManager.shared.setupTray()
82
+
83
+ let title = args.count > 1 ? args[1] : ""
84
+ TrayManager.shared.setTitle(title)
85
+
86
+ let imagePath = args.count > 2 ? args[2] : nil
87
+ if let imagePath = imagePath {
88
+ TrayManager.shared.setIcon(imagePath)
89
+ }
90
+
91
+ let menu = buildContextMenu(from: desc, windowId: windowId)
92
+ TrayManager.shared.setMenu(menu)
93
+ }
94
+ }
95
+ }
package/core/win/main.cs CHANGED
@@ -18,6 +18,7 @@ using System.Net.Sockets;
18
18
  using Microsoft.VisualBasic;
19
19
  using System.Runtime.InteropServices;
20
20
  using Microsoft.Win32;
21
+ using System.Linq;
21
22
 
22
23
  class PowerSaveBlocker
23
24
  {
@@ -130,6 +131,20 @@ namespace PositronWindows
130
131
 
131
132
  string reset = "\u001b[0m";
132
133
  Console.WriteLine($"{red}[C# {tag}] {message}{reset}");
134
+
135
+ if (_ipcClient != null) {
136
+ if (tag != "INFO") {
137
+ _ipcClient.Send(new IPCResponse
138
+ {
139
+ windowId = -1,
140
+ @event = "nativeError",
141
+ data = new() { { "message", message }, { "type", tag } }
142
+ });
143
+ }
144
+ } else
145
+ {
146
+ Console.WriteLine($"{red}[C# ERROR] No IPC client available to send error message over. {reset}");
147
+ }
133
148
  }
134
149
 
135
150
  private static readonly string AuthToken =
@@ -221,7 +236,7 @@ namespace PositronWindows
221
236
  };
222
237
 
223
238
  await Task.Delay(500);
224
- _ipcClient = new IPCClient(new Uri($"ws://localhost:{_ipcPort}"));
239
+ _ipcClient = new IPCClient(new Uri($"ws://127.0.0.1:{_ipcPort}"));
225
240
  _ = _ipcClient.ConnectAsync(AuthToken);
226
241
  }
227
242
 
@@ -376,12 +391,16 @@ private void StartNodeProcess(string workingDirectory, string backendExeName)
376
391
  {
377
392
  bool isFile = webView.Source != null && webView.Source.IsFile;
378
393
  string eventName = isFile ? $"loadFile-reply-{windowId}" : $"loadURL-reply-{windowId}";
379
- _ipcClient.Send(new IPCResponse
380
- {
381
- windowId = windowId,
382
- @event = eventName,
383
- data = new() { { "url", webView.Source?.ToString() ?? "" }, { "title", webView.CoreWebView2.DocumentTitle }, { "canGoBack", webView.CoreWebView2.CanGoBack.ToString().ToLower() }, { "canGoForward", webView.CoreWebView2.CanGoForward.ToString().ToLower() } }
384
- });
394
+ if (e.IsSuccess) {
395
+ _ipcClient.Send(new IPCResponse
396
+ {
397
+ windowId = windowId,
398
+ @event = eventName,
399
+ data = new() { { "url", webView.Source?.ToString() ?? "" }, { "title", webView.CoreWebView2.DocumentTitle }, { "canGoBack", webView.CoreWebView2.CanGoBack.ToString().ToLower() }, { "canGoForward", webView.CoreWebView2.CanGoForward.ToString().ToLower() } }
400
+ });
401
+ } else {
402
+ error($"Navigation failed: {e.WebErrorStatus}");
403
+ }
385
404
  };
386
405
 
387
406
  webView.CoreWebView2.ContextMenuRequested += (s, e) =>
@@ -623,14 +642,19 @@ case "forceCloseWindow":
623
642
 
624
643
  case "loadURL":
625
644
  if (!WindowsMap.TryGetValue(windowId, out _)) break;
626
- if (args.Count == 0)
645
+ if (args.Count == 0 || !Uri.TryCreate(args[0], UriKind.Absolute, out var loadUrlUri))
627
646
  {
628
647
  error("loadURL — invalid or missing URL");
629
648
  break;
630
649
  }
650
+ if (loadUrlUri.Scheme != Uri.UriSchemeHttp && loadUrlUri.Scheme != Uri.UriSchemeHttps && loadUrlUri.Scheme != Uri.UriSchemeFile)
651
+ {
652
+ error($"loadURL — blocked unauthorized URL scheme: {loadUrlUri.Scheme}");
653
+ break;
654
+ }
631
655
  {
632
656
  var wv = GetWebView(windowId);
633
- if (wv != null) wv.Source = new Uri(args[0]);
657
+ if (wv != null) wv.Source = loadUrlUri;
634
658
  }
635
659
  break;
636
660
 
@@ -722,6 +746,16 @@ case "forceCloseWindow":
722
746
  });
723
747
  break;
724
748
 
749
+ case "getFocusedWindowId":
750
+ int focusedWindowId = WindowsMap.FirstOrDefault(kv => kv.Value.IsActive).Key;
751
+ _ipcClient.Send(new IPCResponse
752
+ {
753
+ windowId = windowId,
754
+ @event = args[^1] ?? "getFocusedWindowId-reply-" + windowId,
755
+ data = new() { { "focusedWindowId", focusedWindowId.ToString() } }
756
+ });
757
+ break;
758
+
725
759
  case "showNotification":
726
760
  if (args.Count < 2)
727
761
  {
@@ -1094,7 +1128,7 @@ case "setBounds":
1094
1128
  MenuMap[windowId] = menu;
1095
1129
  }
1096
1130
 
1097
- private static void PopulateMenu(ItemCollection parentItems, JsonArray items, int windowId, string eventType = "menu-action")
1131
+ internal static void PopulateMenu(ItemCollection parentItems, JsonArray items, int windowId, string eventType = "menu-action")
1098
1132
  {
1099
1133
  foreach (var item in items)
1100
1134
  {
@@ -1258,11 +1292,12 @@ case "setBounds":
1258
1292
  if (_reconnectAttempts >= MaxReconnectAttempts)
1259
1293
  {
1260
1294
  error($"Exceeded maximum reconnect attempts ({MaxReconnectAttempts}). Giving up.");
1261
- return;
1295
+ Environment.Exit(1);
1262
1296
  }
1263
1297
 
1264
1298
  try
1265
1299
  {
1300
+ if (_ws != null) _ws.Dispose();
1266
1301
  _ws = new ClientWebSocket();
1267
1302
  _ws.Options.SetRequestHeader("x-positron-auth-token", authToken);
1268
1303
  error($"INFO: Connecting to IPC server (attempt {_reconnectAttempts + 1})…");
@@ -1274,8 +1309,18 @@ case "setBounds":
1274
1309
  catch (Exception ex)
1275
1310
  {
1276
1311
  _reconnectAttempts++;
1277
- error($"WebSocket error: {ex.Message}. Reconnecting in {ReconnectDelayMs / 1000}s… (attempt {_reconnectAttempts}/{MaxReconnectAttempts})");
1278
- await Task.Delay(ReconnectDelayMs);
1312
+ string errorMsg = ex.Message;
1313
+ if (ex.InnerException != null) errorMsg += " | " + ex.InnerException.Message;
1314
+
1315
+ error($"WebSocket error: {errorMsg}. Reconnecting in {ReconnectDelayMs / 1000}s… (attempt {_reconnectAttempts}/{MaxReconnectAttempts})");
1316
+
1317
+ if (errorMsg.Contains("503") || errorMsg.Contains("401") || errorMsg.Contains("403"))
1318
+ {
1319
+ error("Fatal connection error (Unauthorized or Port Hijacked). Exiting immediately.");
1320
+ Environment.Exit(1);
1321
+ }
1322
+
1323
+ await Task.Delay(ReconnectDelayMs, _cts.Token);
1279
1324
  }
1280
1325
  }
1281
1326
  }
@@ -0,0 +1,142 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.Windows.Controls;
4
+ using System.Text.Json;
5
+ using System.Text.Json.Nodes;
6
+ using System.Windows;
7
+ using System.Drawing;
8
+
9
+ namespace PositronWindows
10
+ {
11
+ public class TrayManager
12
+ {
13
+ private static TrayManager? _shared;
14
+ public static TrayManager Shared => _shared ??= new TrayManager();
15
+
16
+ public System.Windows.Forms.NotifyIcon? NotifyIcon { get; private set; }
17
+ private ContextMenu? _wpfContextMenu;
18
+
19
+ public void SetupTray()
20
+ {
21
+ if (NotifyIcon == null)
22
+ {
23
+ NotifyIcon = new System.Windows.Forms.NotifyIcon();
24
+ NotifyIcon.Visible = true;
25
+ NotifyIcon.Text = "App";
26
+ NotifyIcon.Icon = SystemIcons.Application;
27
+
28
+ NotifyIcon.MouseUp += (s, e) =>
29
+ {
30
+ if (e.Button == System.Windows.Forms.MouseButtons.Right || e.Button == System.Windows.Forms.MouseButtons.Left)
31
+ {
32
+ if (_wpfContextMenu != null)
33
+ {
34
+ _wpfContextMenu.IsOpen = true;
35
+ if (Application.Current.MainWindow != null)
36
+ {
37
+ Application.Current.MainWindow.Activate();
38
+ }
39
+ }
40
+ }
41
+ };
42
+ }
43
+ }
44
+
45
+ public void SetMenu(ContextMenu menu)
46
+ {
47
+ _wpfContextMenu = menu;
48
+ }
49
+
50
+ public void SetTitle(string title)
51
+ {
52
+ if (NotifyIcon != null && !string.IsNullOrEmpty(title))
53
+ {
54
+ NotifyIcon.Text = title.Length > 63 ? title.Substring(0, 63) : title;
55
+ }
56
+ }
57
+
58
+ public void SetIcon(string iconPath)
59
+ {
60
+ if (NotifyIcon != null && !string.IsNullOrEmpty(iconPath))
61
+ {
62
+ try
63
+ {
64
+ NotifyIcon.Icon = new Icon(iconPath);
65
+ }
66
+ catch (Exception ex)
67
+ {
68
+ Console.WriteLine($"Failed to set tray icon: {ex.Message}");
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ public static class TrayExtension
75
+ {
76
+ public static void Handle(int windowId, List<string> args)
77
+ {
78
+ if (args.Count == 0)
79
+ {
80
+ Console.WriteLine("tray:setMenu — missing JSON descriptor");
81
+ return;
82
+ }
83
+
84
+ if (args[^1] == "setTitle")
85
+ {
86
+ string title = args[0];
87
+ Application.Current.Dispatcher.Invoke(() =>
88
+ {
89
+ TrayManager.Shared.SetTitle(title);
90
+ });
91
+ return;
92
+ }
93
+
94
+ if (args[^1] == "setIcon")
95
+ {
96
+ string iconPath = args[0];
97
+ Application.Current.Dispatcher.Invoke(() =>
98
+ {
99
+ TrayManager.Shared.SetIcon(iconPath);
100
+ });
101
+ return;
102
+ }
103
+
104
+ var descString = args[0];
105
+ var ctxDescriptor = JsonSerializer.Deserialize<JsonArray>(descString);
106
+ if (ctxDescriptor == null)
107
+ {
108
+ Console.WriteLine("tray:setMenu — invalid JSON descriptor");
109
+ return;
110
+ }
111
+
112
+ if(args[^1] == "setMenu")
113
+ {
114
+ Application.Current.Dispatcher.Invoke(() =>
115
+ {
116
+ var contextMenu = new ContextMenu();
117
+ App.PopulateMenu(contextMenu.Items, ctxDescriptor, windowId, "context-menu-action");
118
+ TrayManager.Shared.SetMenu(contextMenu);
119
+ });
120
+ return;
121
+ }
122
+
123
+ Application.Current.Dispatcher.Invoke(() =>
124
+ {
125
+ TrayManager.Shared.SetupTray();
126
+
127
+ string? title = args.Count > 1 ? args[1] : "";
128
+ TrayManager.Shared.SetTitle(title);
129
+
130
+ string? imagePath = args.Count > 2 ? args[2] : null;
131
+ if (imagePath != null)
132
+ {
133
+ TrayManager.Shared.SetIcon(imagePath);
134
+ }
135
+
136
+ var contextMenu = new ContextMenu();
137
+ App.PopulateMenu(contextMenu.Items, ctxDescriptor, windowId, "context-menu-action");
138
+ TrayManager.Shared.SetMenu(contextMenu);
139
+ });
140
+ }
141
+ }
142
+ }