positron.js 1.0.0 → 1.0.2

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.
@@ -18,6 +18,7 @@ let AUTH_TOKEN: String = {
18
18
  }()
19
19
 
20
20
  var windowObservations: [Int: NSKeyValueObservation] = [:]
21
+ var navigationDelegates: [Int: WebViewNavigationDelegate] = [:]
21
22
 
22
23
 
23
24
  import Foundation
@@ -225,6 +226,9 @@ func handleCommand(windowId: Int, command: String, args: [String]) {
225
226
  config.userContentController.addUserScript(preload)
226
227
 
227
228
  let webView = PositronWebView(frame: NSRect(origin: .zero, size: frame.size), configuration: config)
229
+ let navDelegate = WebViewNavigationDelegate(windowId: windowId)
230
+ webView.navigationDelegate = navDelegate
231
+ navigationDelegates[windowId] = navDelegate
228
232
  // Resize webview automatically when the window resizes
229
233
  webView.autoresizingMask = [.width, .height]
230
234
  newWindow.contentView = webView
@@ -242,6 +246,7 @@ func handleCommand(windowId: Int, command: String, args: [String]) {
242
246
  observation.invalidate()
243
247
  windowObservations.removeValue(forKey: windowId)
244
248
  }
249
+ navigationDelegates.removeValue(forKey: windowId)
245
250
 
246
251
 
247
252
  windows.removeValue(forKey: windowId)
@@ -305,7 +310,6 @@ case "forceCloseWindow":
305
310
  }
306
311
  (window.contentView as? WKWebView)?.load(URLRequest(url: url))
307
312
 
308
-
309
313
  case "hide":
310
314
  guard let window = windows[windowId] else { return }
311
315
  window.orderOut(nil)
@@ -344,7 +348,6 @@ case "forceCloseWindow":
344
348
  (window.contentView as? WKWebView)?
345
349
  .loadFileURL(fileURL, allowingReadAccessTo: fileURL.deletingLastPathComponent())
346
350
 
347
-
348
351
  case "setBounds":
349
352
  guard let window = windows[windowId] else { return }
350
353
  guard args.count >= 4,
@@ -682,6 +685,24 @@ case "resetMenu":
682
685
  }
683
686
  }
684
687
 
688
+ // MARK: - WebView Navigation Delegate
689
+
690
+ final class WebViewNavigationDelegate: NSObject, WKNavigationDelegate {
691
+ let windowId: Int
692
+
693
+ init(windowId: Int) {
694
+ self.windowId = windowId
695
+ }
696
+
697
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
698
+ let isFile = webView.url?.isFileURL ?? false
699
+ let eventName = isFile ? "loadFile-reply-\(windowId)" : "loadURL-reply-\(windowId)"
700
+ AppDelegate.shared?.ipcClient.send(
701
+ IPCResponse(windowId: windowId, event: eventName, data: [:])
702
+ )
703
+ }
704
+ }
705
+
685
706
  // MARK: - WebView → Swift IPC Handler
686
707
 
687
708
  /// Receives messages from renderer JS: window.webkit.messageHandlers.ipc.postMessage({...})
package/core/win/main.cs CHANGED
@@ -102,11 +102,10 @@ namespace PositronWindows
102
102
 
103
103
  public static bool IsPackaged { get; private set; } = false;
104
104
 
105
- private static IPCClient _ipcClient = null!;
105
+ public static IPCClient _ipcClient = null!;
106
106
  private static Process? _nodeProcess;
107
107
 
108
- /// <summary>All window access must happen on the UI thread.</summary>
109
- private static readonly Dictionary<int, Window> WindowsMap = new();
108
+ public static readonly Dictionary<int, Window> WindowsMap = new();
110
109
  private static readonly Dictionary<int, DockPanel> LayoutMap = new();
111
110
  private static readonly Dictionary<int, Menu> MenuMap = new();
112
111
  private static readonly HashSet<int> _forceClosing = new();
@@ -330,6 +329,17 @@ private void StartNodeProcess(string workingDirectory)
330
329
  window.Title = webView.CoreWebView2.DocumentTitle;
331
330
  };
332
331
 
332
+ webView.CoreWebView2.NavigationCompleted += (s, e) =>
333
+ {
334
+ bool isFile = webView.Source != null && webView.Source.IsFile;
335
+ string eventName = isFile ? $"loadFile-reply-{windowId}" : $"loadURL-reply-{windowId}";
336
+ _ipcClient.Send(new IPCResponse
337
+ {
338
+ windowId = windowId,
339
+ @event = eventName
340
+ });
341
+ };
342
+
333
343
  webView.CoreWebView2.ContextMenuRequested += (s, e) =>
334
344
  {
335
345
  if (LayoutMap.TryGetValue(windowId, out var l) && l.ContextMenu != null)
@@ -819,7 +829,7 @@ case "setBounds":
819
829
  }
820
830
  }
821
831
 
822
- private static WebView2? GetWebView(int windowId)
832
+ public static WebView2? GetWebView(int windowId)
823
833
  {
824
834
  if (LayoutMap.TryGetValue(windowId, out var layout))
825
835
  {
package/index.js CHANGED
@@ -226,6 +226,8 @@ let _windowCounter = 0;
226
226
 
227
227
  class Window extends Events.EventEmitter {
228
228
 
229
+ id = 0;
230
+
229
231
  /** Creates a new window instance. */
230
232
  constructor(options = {
231
233
 
@@ -292,16 +294,6 @@ class Window extends Events.EventEmitter {
292
294
  this.emit("title-updated", title);
293
295
  }
294
296
 
295
- /**
296
- * Loads a remote URL in the window. Emits "url-loaded" and "navigated" events with the URL as data.
297
- * @param {string} url The URL to load.
298
- */
299
- loadURL(url) {
300
- this.sendCommand("loadURL", [url]);
301
- this.emit("url-loaded", url);
302
- this.emit("navigated", url);
303
- }
304
-
305
297
  /**
306
298
  * Triggers the print dialog for the window. Emits a "print" event. Note that the actual print functionality and dialog is handled by the native layer, so behavior may vary across platforms.
307
299
  */
@@ -320,13 +312,25 @@ class Window extends Events.EventEmitter {
320
312
  }
321
313
 
322
314
  /**
323
- * Loads a local file in the window. Emits "file-loaded" and "navigated" events with the file path as data.
315
+ * Loads a file into the window. The path can be an absolute file path or a relative path from the application's root directory. Emits a "file-loaded" event with the path as data, and a "navigated" event with the path as data.
324
316
  * @param {string} path The path to the file to load.
325
317
  */
326
- loadFile(path) {
327
- this.sendCommand("loadFile", [path]);
328
- this.emit("file-loaded", path);
329
- this.emit("navigated", path);
318
+ async loadFile(path) {
319
+ const res = await this.request("loadFile", `loadFile-reply-${this.id}`, path);
320
+ this.emit("file-loaded", path);
321
+ this.emit("navigated", path);
322
+ return res;
323
+ }
324
+
325
+ /**
326
+ * Loads a URL into the window. Emits a "url-loaded" event with the URL as data, and a "navigated" event with the URL as data.
327
+ * @param {string} url The URL to load.
328
+ */
329
+ async loadURL(url) {
330
+ const res = await this.request("loadURL", `loadURL-reply-${this.id}`, url);
331
+ this.emit("url-loaded", url);
332
+ this.emit("navigated", url);
333
+ return res;
330
334
  }
331
335
 
332
336
  /**
@@ -592,6 +596,15 @@ async request(command, replyChannel, ...args) {
592
596
  return new Promise((resolve, reject) => {
593
597
  let settled = false;
594
598
 
599
+ if(!command) {
600
+ reject(new Error("Command is required for request"));
601
+ return;
602
+ }
603
+
604
+ if(!replyChannel) {
605
+ replyChannel = `${command}-reply-${this.id}`;
606
+ }
607
+
595
608
  const unsubscribe = ipc.handle(replyChannel, (data) => {
596
609
  if (!settled) {
597
610
  settled = true;
@@ -601,15 +614,34 @@ async request(command, replyChannel, ...args) {
601
614
  }
602
615
  });
603
616
 
604
- const timeout = setTimeout(() => {
617
+ let timeout;
618
+
619
+ if(!args.includes("NO_TIMEOUT")) {
620
+ let timeoutDuration = 7000;
621
+
622
+ const timeoutArg = args.find(
623
+ arg => typeof arg === "string" && arg.startsWith("TIMEOUT=")
624
+ );
625
+
626
+ if (timeoutArg) {
627
+
628
+ args = args.filter(arg => arg !== timeoutArg);
629
+
630
+ timeoutDuration = parseInt(timeoutArg.split("=")[1], 10);
631
+ }
632
+
633
+ timeout = setTimeout(() => {
605
634
  if (!settled) {
606
635
  settled = true;
607
636
  unsubscribe();
608
637
  reject(new Error(`Request timed out waiting for reply on channel "${replyChannel}"`));
609
638
  }
610
- }, 5000);
639
+ }, timeoutDuration);
640
+ } else {
641
+ args = args.filter(arg => arg !== "NO_TIMEOUT");
642
+ }
611
643
 
612
- this.sendCommand(command, args);
644
+ this.sendCommand(command, [...args, replyChannel]);
613
645
  });
614
646
  }
615
647
 
@@ -772,6 +804,135 @@ const res = await this.request("evaluateJS", `evaluateJS-reply-${this.id}`, scri
772
804
  return res;
773
805
  }
774
806
 
807
+ /**
808
+ * Gets the user agent string of the window. Returns a Promise that resolves to the user agent as a string.
809
+ * @returns {Promise<string>} The user agent string of the window.
810
+ */
811
+ async getUserAgent() {
812
+ const res = await this.evaluateJavaScript("navigator.userAgent");
813
+ return res;
814
+ }
815
+
816
+ /**
817
+ * Sets the style of elements matching a CSS selector. Returns a Promise that resolves when the style has been applied.
818
+ * @param {string} selector The CSS selector for the elements to style.
819
+ * @param {Object} style The style properties to apply.
820
+ * @returns {Promise<void>} A Promise that resolves when the style has been applied.
821
+ */
822
+ async setStyleOf(selector, style) {
823
+ const styleString = Object.entries(style).map(([key, value]) => `${key}: ${value};`).join(" ");
824
+ const script = `
825
+ const elements = document.querySelectorAll(${JSON.stringify(selector)});
826
+ elements.forEach(el => {
827
+ el.style.cssText += ${JSON.stringify(styleString)};
828
+ });
829
+ `;
830
+ await this.evaluateJavaScript(script);
831
+ this.emit("style-updated", { selector, style });
832
+ }
833
+
834
+ /**
835
+ * Sets an attribute of elements matching a CSS selector. Returns a Promise that resolves when the attribute has been set.
836
+ * @param {string} selector The CSS selector for the elements to update.
837
+ * @param {string} attribute The name of the attribute to set.
838
+ * @param {string} value The value to set for the attribute.
839
+ * @returns {Promise<void>} A Promise that resolves when the attribute has been set.
840
+ */
841
+ async setAttributeOf(selector, attribute, value) {
842
+ const script = `
843
+ const elements = document.querySelectorAll(${JSON.stringify(selector)});
844
+ elements.forEach(el => {
845
+ el.setAttribute(${JSON.stringify(attribute)}, ${JSON.stringify(value)});
846
+ });
847
+ `;
848
+ await this.evaluateJavaScript(script);
849
+ this.emit("attribute-updated", { selector, attribute, value });
850
+ }
851
+
852
+ /**
853
+ * Removes an attribute from elements matching a CSS selector. Returns a Promise that resolves when the attribute has been removed.
854
+ * @param {string} selector The CSS selector for the elements to update.
855
+ * @param {string} attribute The name of the attribute to remove.
856
+ * @returns {Promise<void>} A Promise that resolves when the attribute has been removed.
857
+ */
858
+ async removeAttributeOf(selector, attribute) {
859
+ const script = `
860
+ const elements = document.querySelectorAll(${JSON.stringify(selector)});
861
+ elements.forEach(el => {
862
+ el.removeAttribute(${JSON.stringify(attribute)});
863
+ });
864
+ `;
865
+ await this.evaluateJavaScript(script);
866
+ this.emit("attribute-removed", { selector, attribute });
867
+ }
868
+
869
+ /**
870
+ * Removes specific style properties from elements matching a CSS selector. Returns a Promise that resolves when the styles have been removed.
871
+ * @param {string} selector The CSS selector for the elements to update.
872
+ * @param {string[]} styleProperties The style properties to remove.
873
+ * @returns {Promise<void>} A Promise that resolves when the styles have been removed.
874
+ */
875
+ async removeStyleOf(selector, styleProperties) {
876
+ const propertiesString = styleProperties.map(prop => `${prop}:`).join("|");
877
+ const script = `
878
+ const elements = document.querySelectorAll(${JSON.stringify(selector)});
879
+ elements.forEach(el => {
880
+ el.style.cssText = el.style.cssText.split(";").filter(rule => {
881
+ return !${JSON.stringify(propertiesString)}.includes(rule.trim().split(":")[0] + ":");
882
+ }).join(";");
883
+ });
884
+ `;
885
+ await this.evaluateJavaScript(script);
886
+ this.emit("style-removed", { selector, styleProperties });
887
+ }
888
+
889
+ /**
890
+ * Adds or replaces click handlers that emit IPC events.
891
+ *
892
+ * @param {string} selector - The CSS selector for the elements to attach the click handlers to.
893
+ * @param {string} channel - The IPC channel to emit events on when the elements are clicked.
894
+ * @param {{ replace?: boolean }} [options] - Optional settings for the click handlers. If `replace` is true, any existing IPC click handlers on the elements will be removed before adding the new handler. If false or omitted, the new handler will be added alongside existing handlers without removing them.
895
+ * @returns {Promise<void>} A Promise that resolves when the click handlers have been added.
896
+ */
897
+ async onClick(selector, channel, { replace = true } = {}) {
898
+ const script = `
899
+ const selector = ${JSON.stringify(selector)};
900
+ const channel = ${JSON.stringify(channel)};
901
+ const replace = ${replace};
902
+
903
+ const elements = document.querySelectorAll(selector);
904
+
905
+ elements.forEach(el => {
906
+ if (replace) {
907
+ el.onclick = () => {
908
+ window.ipc.send(channel, { selector });
909
+ }
910
+ } else {
911
+ el.addEventListener("click", () => {
912
+ window.ipc.send(channel, { selector });
913
+ });
914
+ }
915
+ })
916
+ `;
917
+
918
+ await this.evaluateJavaScript(script);
919
+ }
920
+
921
+ /**
922
+ * Removes click handlers that emit IPC events from elements matching the specified CSS selector. This will remove all click handlers that were added via the onClick method for the given selector, regardless of the channel or whether they were set to replace existing handlers.
923
+ * @param {string} selector The CSS selector for the elements to remove click handlers from.
924
+ * @returns {Promise<void>} A Promise that resolves when the click handlers have been removed.
925
+ */
926
+ async removeOnClick(selector) {
927
+ const script = `
928
+ const elements = document.querySelectorAll(${JSON.stringify(selector)});
929
+ elements.forEach(el => {
930
+ el.onclick = null;
931
+ });
932
+ `;
933
+ await this.evaluateJavaScript(script);
934
+ }
935
+
775
936
  }
776
937
 
777
938
  const app = {
package/package.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "name": "positron.js",
3
3
  "description": "A lightweight cross-platform hybrid application framework designed to build desktop applications using a native compiled runtime driven by a Node.js main process.",
4
4
  "repository": {
5
- "url": "https://github.com/systemsoftware/positron-app"
5
+ "url": "https://github.com/systemsoftware/positron.js"
6
6
  },
7
7
  "homepage": "https://positronjs.gitbook.io",
8
- "version": "1.0.0",
8
+ "version": "1.0.2",
9
9
  "main": "index.js",
10
10
  "scripts": {
11
11
  "test": "node --test"
@@ -21,7 +21,6 @@
21
21
  "dependencies": {
22
22
  "@yao-pkg/pkg": "^6.20.0",
23
23
  "esbuild": "^0.28.0",
24
- "resedit": "^3.0.2",
25
24
  "ws": "^8.20.1"
26
25
  },
27
26
  "bin": {