positron.js 1.0.1 → 1.0.3

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/bin/positron.js CHANGED
@@ -35,6 +35,6 @@ switch (command) {
35
35
  break;
36
36
 
37
37
  default:
38
- console.log("Usage: npx positron [build | dev | run]");
38
+ console.log("Usage: npx positron [build | dev | run | package]");
39
39
  process.exit(0);
40
40
  }
package/builder.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
3
  const cp = require("child_process");
4
- const { success, error, info } = require("./logs");
4
+ const { success, error, info, warn } = require("./logs");
5
5
 
6
6
  const arch = process.argv.includes("--x64") ? "x64" : process.argv.includes("--arm64") ? "arm64" : process.arch;
7
7
 
@@ -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
@@ -168,6 +169,26 @@ struct IPCResponse: Codable {
168
169
  let data: [String: String]
169
170
  }
170
171
 
172
+ func GetIPCClient() -> IPCClient {
173
+ return AppDelegate.shared?.ipcClient ?? IPCClient()
174
+ }
175
+
176
+ func GetWebView(windowId: Int) -> WKWebView? {
177
+ guard let window = windows[windowId],
178
+ let webView = window.contentView as? WKWebView else {
179
+ printError("GetWebView failed: no webview found for window \(windowId)")
180
+ return nil
181
+ }
182
+ return webView
183
+ }
184
+
185
+ func GetWindow(windowId: Int) -> NSWindow? {
186
+ guard let window = windows[windowId] else {
187
+ printError("GetWindow failed: no window found with ID \(windowId)")
188
+ return nil
189
+ }
190
+ return window
191
+ }
171
192
 
172
193
  // MARK: - Command Handler
173
194
 
@@ -225,6 +246,9 @@ func handleCommand(windowId: Int, command: String, args: [String]) {
225
246
  config.userContentController.addUserScript(preload)
226
247
 
227
248
  let webView = PositronWebView(frame: NSRect(origin: .zero, size: frame.size), configuration: config)
249
+ let navDelegate = WebViewNavigationDelegate(windowId: windowId)
250
+ webView.navigationDelegate = navDelegate
251
+ navigationDelegates[windowId] = navDelegate
228
252
  // Resize webview automatically when the window resizes
229
253
  webView.autoresizingMask = [.width, .height]
230
254
  newWindow.contentView = webView
@@ -242,6 +266,7 @@ func handleCommand(windowId: Int, command: String, args: [String]) {
242
266
  observation.invalidate()
243
267
  windowObservations.removeValue(forKey: windowId)
244
268
  }
269
+ navigationDelegates.removeValue(forKey: windowId)
245
270
 
246
271
 
247
272
  windows.removeValue(forKey: windowId)
@@ -267,6 +292,19 @@ func handleCommand(windowId: Int, command: String, args: [String]) {
267
292
 
268
293
  window.performClose(nil)
269
294
 
295
+ case "setSwipeNav":
296
+ guard let window = windows[windowId],
297
+ let webView = window.contentView as? WKWebView else {
298
+ return
299
+ }
300
+
301
+ let enable = args.first?.lowercased() != "false"
302
+ webView.allowsBackForwardNavigationGestures = enable
303
+
304
+ GetIPCClient().send(
305
+ IPCResponse(windowId: windowId, event: "setSwipeNav-reply-\(windowId)", data: ["enabled": enable ? "true" : "false"])
306
+ )
307
+
270
308
  case "forceCloseWindow":
271
309
  guard let window = windows[windowId] else { return }
272
310
 
@@ -305,7 +343,6 @@ case "forceCloseWindow":
305
343
  }
306
344
  (window.contentView as? WKWebView)?.load(URLRequest(url: url))
307
345
 
308
-
309
346
  case "hide":
310
347
  guard let window = windows[windowId] else { return }
311
348
  window.orderOut(nil)
@@ -344,7 +381,6 @@ case "forceCloseWindow":
344
381
  (window.contentView as? WKWebView)?
345
382
  .loadFileURL(fileURL, allowingReadAccessTo: fileURL.deletingLastPathComponent())
346
383
 
347
-
348
384
  case "setBounds":
349
385
  guard let window = windows[windowId] else { return }
350
386
  guard args.count >= 4,
@@ -603,6 +639,24 @@ UNUserNotificationCenter.current().requestAuthorization(
603
639
  }
604
640
  }
605
641
 
642
+ case "confirm":
643
+ guard let window = windows[windowId] else { return }
644
+ guard let message = args.first else {
645
+ printError("confirm — missing message argument")
646
+ return
647
+ }
648
+ let alert = NSAlert()
649
+ alert.messageText = message
650
+ alert.addButton(withTitle: "OK")
651
+ alert.addButton(withTitle: "Cancel")
652
+
653
+ alert.beginSheetModal(for: window) { response in
654
+ let confirmed = (response == .alertFirstButtonReturn)
655
+ AppDelegate.shared?.ipcClient.send(
656
+ IPCResponse(windowId: windowId, event: "confirm-reply-\(windowId)", data: ["confirmed": confirmed ? "true" : "false"])
657
+ )
658
+ }
659
+
606
660
  case "isFocused":
607
661
  guard let window = windows[windowId] else { return }
608
662
  let isFocused = window.isKeyWindow
@@ -682,6 +736,24 @@ case "resetMenu":
682
736
  }
683
737
  }
684
738
 
739
+ // MARK: - WebView Navigation Delegate
740
+
741
+ final class WebViewNavigationDelegate: NSObject, WKNavigationDelegate {
742
+ let windowId: Int
743
+
744
+ init(windowId: Int) {
745
+ self.windowId = windowId
746
+ }
747
+
748
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
749
+ let isFile = webView.url?.isFileURL ?? false
750
+ let eventName = isFile ? "loadFile-reply-\(windowId)" : "loadURL-reply-\(windowId)"
751
+ AppDelegate.shared?.ipcClient.send(
752
+ IPCResponse(windowId: windowId, event: eventName, data: [:])
753
+ )
754
+ }
755
+ }
756
+
685
757
  // MARK: - WebView → Swift IPC Handler
686
758
 
687
759
  /// Receives messages from renderer JS: window.webkit.messageHandlers.ipc.postMessage({...})
package/core/win/main.cs CHANGED
@@ -329,6 +329,17 @@ private void StartNodeProcess(string workingDirectory)
329
329
  window.Title = webView.CoreWebView2.DocumentTitle;
330
330
  };
331
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
+
332
343
  webView.CoreWebView2.ContextMenuRequested += (s, e) =>
333
344
  {
334
345
  if (LayoutMap.TryGetValue(windowId, out var l) && l.ContextMenu != null)
@@ -371,6 +382,23 @@ private void StartNodeProcess(string workingDirectory)
371
382
 
372
383
  // 3. Attach it to the layout
373
384
  layout.ContextMenu = contextMenu;
385
+ break;
386
+
387
+ case "setSwipeNav":
388
+ if (!WindowsMap.TryGetValue(windowId, out var winSwipeNav)) break;
389
+ var wvSwipeNav = GetWebView(windowId);
390
+ if (wvSwipeNav != null) {
391
+ bool enable = args.Count == 0 || args[0].ToLower() != "false";
392
+ wvSwipeNav.CoreWebView2.Settings.IsSwipeNavigationEnabled = enable;
393
+ }
394
+
395
+ GetIPCClient().Send(new IPCResponse
396
+ {
397
+ windowId = windowId,
398
+ @event = "setSwipeNav-reply-" + windowId,
399
+ data = new() { { "enabled", (wvSwipeNav?.CoreWebView2.Settings.IsSwipeNavigationEnabled ?? false).ToString().ToLower() } }
400
+ });
401
+
374
402
  break;
375
403
 
376
404
  case "closeWindow":
@@ -610,6 +638,24 @@ case "setBounds":
610
638
  }
611
639
  break;
612
640
 
641
+ case "confirm":
642
+ if (args.Count < 1)
643
+ {
644
+ error("confirm — expected message argument");
645
+ break;
646
+ }
647
+ {
648
+ var message = args[0];
649
+ var result = MessageBox.Show(message, "Confirm", MessageBoxButton.YesNo) == MessageBoxResult.Yes;
650
+ _ipcClient.Send(new IPCResponse
651
+ {
652
+ windowId = windowId,
653
+ @event = "confirm-reply-" + windowId,
654
+ data = new() { { "confirmed", result.ToString().ToLower() } }
655
+ });
656
+ }
657
+ break;
658
+
613
659
  case "emitToRenderer":
614
660
  if (args.Count < 2)
615
661
  {
@@ -830,6 +876,15 @@ case "setBounds":
830
876
  return null;
831
877
  }
832
878
 
879
+ public static Window? GetWindow(int windowId)
880
+ {
881
+ if (WindowsMap.TryGetValue(windowId, out var window))
882
+ return window;
883
+ return null;
884
+ }
885
+
886
+ public static IPCClient GetIPCClient() => _ipcClient;
887
+
833
888
  // MARK: - Menu Management
834
889
 
835
890
  private static void BuildAndAttachMenu(int windowId, string jsonStr)
package/index.js CHANGED
@@ -294,16 +294,6 @@ class Window extends Events.EventEmitter {
294
294
  this.emit("title-updated", title);
295
295
  }
296
296
 
297
- /**
298
- * Loads a remote URL in the window. Emits "url-loaded" and "navigated" events with the URL as data.
299
- * @param {string} url The URL to load.
300
- */
301
- loadURL(url) {
302
- this.sendCommand("loadURL", [url]);
303
- this.emit("url-loaded", url);
304
- this.emit("navigated", url);
305
- }
306
-
307
297
  /**
308
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.
309
299
  */
@@ -322,13 +312,25 @@ class Window extends Events.EventEmitter {
322
312
  }
323
313
 
324
314
  /**
325
- * 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.
326
316
  * @param {string} path The path to the file to load.
327
317
  */
328
- loadFile(path) {
329
- this.sendCommand("loadFile", [path]);
330
- this.emit("file-loaded", path);
331
- 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;
332
334
  }
333
335
 
334
336
  /**
@@ -625,7 +627,7 @@ if (timeoutArg) {
625
627
 
626
628
  args = args.filter(arg => arg !== timeoutArg);
627
629
 
628
- timeoutDuration = parseInt(timeoutArg.split("=")[1], 10);
630
+ timeoutDuration = parseInt(timeoutArg.split("=")[1]);
629
631
  }
630
632
 
631
633
  timeout = setTimeout(() => {
@@ -763,7 +765,7 @@ async getBounds() {
763
765
  * @returns {Promise<string>} The current URL loaded in the window.
764
766
  */
765
767
  async getURL() {
766
- return await this.request("getURL", `getURL-reply-${this.id}`);
768
+ return (await this.request("getURL", `getURL-reply-${this.id}`))?.url || "";
767
769
  }
768
770
 
769
771
  /**
@@ -771,7 +773,7 @@ async getURL() {
771
773
  * @returns {Promise<string>} The current title of the window.
772
774
  */
773
775
  async getTitle() {
774
- return await this.request("getTitle", `getTitle-reply-${this.id}`);
776
+ return (await this.request("getTitle", `getTitle-reply-${this.id}`)).title || "";
775
777
  }
776
778
 
777
779
  /**
@@ -799,7 +801,168 @@ setTitlebarTransparent(isTransparent) {
799
801
  */
800
802
  async evaluateJavaScript(script) {
801
803
  const res = await this.request("evaluateJS", `evaluateJS-reply-${this.id}`, script);
802
- return res;
804
+ return res.result;
805
+ }
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
+ return await this.evaluateJavaScript("navigator.userAgent");
813
+ }
814
+
815
+ /**
816
+ * Sets the style of elements matching a CSS selector. Returns a Promise that resolves when the style has been applied.
817
+ * @param {string} selector The CSS selector for the elements to style.
818
+ * @param {Object} style The style properties to apply.
819
+ * @returns {Promise<void>} A Promise that resolves when the style has been applied.
820
+ */
821
+ async setStyleOf(selector, style) {
822
+ const styleString = Object.entries(style).map(([key, value]) => `${key}: ${value};`).join(" ");
823
+ const script = `
824
+ (function() {
825
+ const elements = document.querySelectorAll(${JSON.stringify(selector)});
826
+ elements.forEach(el => {
827
+ Object.entries(${JSON.stringify(style)}).forEach(([key, value]) => {
828
+ el.style[key] = value;
829
+ });
830
+ });
831
+ })();
832
+ `;
833
+ await this.evaluateJavaScript(script);
834
+ this.emit("style-updated", { selector, style });
835
+ }
836
+
837
+ /**
838
+ * Sets an attribute of elements matching a CSS selector. Returns a Promise that resolves when the attribute has been set.
839
+ * @param {string} selector The CSS selector for the elements to update.
840
+ * @param {string} attribute The name of the attribute to set.
841
+ * @param {string} value The value to set for the attribute.
842
+ * @returns {Promise<void>} A Promise that resolves when the attribute has been set.
843
+ */
844
+ async setAttributeOf(selector, attribute, value) {
845
+ const script = `
846
+ (function() {
847
+ const elements = document.querySelectorAll(${JSON.stringify(selector)});
848
+ elements.forEach(el => {
849
+ el.setAttribute(${JSON.stringify(attribute)}, ${JSON.stringify(value)});
850
+ });
851
+ })();
852
+ `;
853
+ await this.evaluateJavaScript(script);
854
+ this.emit("attribute-updated", { selector, attribute, value });
855
+ }
856
+
857
+ /**
858
+ * Removes an attribute from elements matching a CSS selector. Returns a Promise that resolves when the attribute has been removed.
859
+ * @param {string} selector The CSS selector for the elements to update.
860
+ * @param {string} attribute The name of the attribute to remove.
861
+ * @returns {Promise<void>} A Promise that resolves when the attribute has been removed.
862
+ */
863
+ async removeAttributeOf(selector, attribute) {
864
+ const script = `
865
+ (function() {
866
+ const elements = document.querySelectorAll(${JSON.stringify(selector)});
867
+ elements.forEach(el => {
868
+ el.removeAttribute(${JSON.stringify(attribute)});
869
+ });
870
+ })();
871
+ `;
872
+ await this.evaluateJavaScript(script);
873
+ this.emit("attribute-removed", { selector, attribute });
874
+ }
875
+
876
+ /**
877
+ * Removes specific style properties from elements matching a CSS selector. Returns a Promise that resolves when the styles have been removed.
878
+ * @param {string} selector The CSS selector for the elements to update.
879
+ * @param {string[]} styleProperties The style properties to remove.
880
+ * @returns {Promise<void>} A Promise that resolves when the styles have been removed.
881
+ */
882
+ async removeStyleOf(selector, styleProperties) {
883
+ const propertiesString = styleProperties.map(prop => `${prop}:`).join("|");
884
+ const script = `
885
+ (function() {
886
+ const elements = document.querySelectorAll(${JSON.stringify(selector)});
887
+ elements.forEach(el => {
888
+ el.style.cssText = el.style.cssText.split(";").filter(rule => {
889
+ return !${JSON.stringify(propertiesString)}.includes(rule.trim().split(":")[0] + ":");
890
+ }).join(";");
891
+ });
892
+ })();
893
+ `;
894
+ await this.evaluateJavaScript(script);
895
+ this.emit("style-removed", { selector, styleProperties });
896
+ }
897
+
898
+ /**
899
+ * Adds or replaces click handlers that emit IPC events.
900
+ *
901
+ * @param {string} selector - The CSS selector for the elements to attach the click handlers to.
902
+ * @param {string} channel - The IPC channel to emit events on when the elements are clicked.
903
+ * @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.
904
+ * @returns {Promise<void>} A Promise that resolves when the click handlers have been added.
905
+ */
906
+ async onClick(selector, channel, { replace = true } = {}) {
907
+ const script = `
908
+ (function() {
909
+ const selector = ${JSON.stringify(selector)};
910
+ const channel = ${JSON.stringify(channel)};
911
+ const replace = ${replace};
912
+
913
+ const elements = document.querySelectorAll(selector);
914
+
915
+ elements.forEach(el => {
916
+ if (replace) {
917
+ el.onclick = () => {
918
+ window.ipc.send(channel, { selector });
919
+ }
920
+ } else {
921
+ el.addEventListener("click", () => {
922
+ window.ipc.send(channel, { selector });
923
+ });
924
+ }
925
+ })
926
+ })();
927
+ `;
928
+
929
+ await this.evaluateJavaScript(script);
930
+ }
931
+
932
+ /**
933
+ * 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.
934
+ * @param {string} selector The CSS selector for the elements to remove click handlers from.
935
+ * @returns {Promise<void>} A Promise that resolves when the click handlers have been removed.
936
+ */
937
+ async removeOnClick(selector) {
938
+ const script = `
939
+ const elements = document.querySelectorAll(${JSON.stringify(selector)});
940
+ elements.forEach(el => {
941
+ el.onclick = null;
942
+ });
943
+ `;
944
+ await this.evaluateJavaScript(script);
945
+ }
946
+
947
+ /**
948
+ * Displays a confirmation dialog with the given message. Returns a Promise that resolves to true if the user confirmed, or false if the user cancelled. Emits a "confirm" event with the message as data when done.
949
+ * @param {string} message The message to display in the confirmation dialog.
950
+ * @returns {Promise<boolean>} True if the user confirmed, false if the user cancelled.
951
+ */
952
+ async confirm(message) {
953
+ const res = await this.request("confirm", `confirm-reply-${this.id}`, message);
954
+ this.emit("confirm", message);
955
+ return res?.confirmed === "true";
956
+ }
957
+
958
+ /**
959
+ * Enables or disables swipe navigation for the window. When enabled, users can navigate back and forward through their history by swiping left or right on a trackpad or touchscreen. Emits a "swipe-navigation-updated" event with the new value when done.
960
+ * @param {boolean} enabled Whether swipe navigation should be enabled.
961
+ * @returns {Promise<void>} A Promise that resolves when the swipe navigation setting has been updated.
962
+ */
963
+ async setSwipeNavigation(enabled) {
964
+ const res = await this.request("setSwipeNav", `setSwipeNav-reply-${this.id}`, String(enabled));
965
+ this.emit("swipe-navigation-updated", enabled);
803
966
  }
804
967
 
805
968
  }
@@ -832,7 +995,7 @@ const app = {
832
995
  },
833
996
 
834
997
  /**
835
- * Adds an event listener for application-level events. Supported events include "before-quit" and "quit".
998
+ * Adds an event listener for application-level events.
836
999
  * @param {string} event The name of the event to listen for.
837
1000
  * @param {Function} listener The callback function to invoke when the event is emitted.
838
1001
  */
@@ -858,11 +1021,19 @@ const app = {
858
1021
  this.events.once(event, listener);
859
1022
  },
860
1023
 
1024
+ /**
1025
+ * Gets the currently focused window. Returns a Promise that resolves to the focused Window instance, or null if no windows are currently focused. Emits a "focused-window-retrieved" event with the focused window as data when done.
1026
+ * @returns {Promise<Window|null>} The currently focused window, or null if no windows are focused.
1027
+ */
861
1028
  async getFocusedWindow() {
862
1029
  const results = await Promise.all([...activeWindows].map(win => win.isFocused().then(isFocused => ({ win, isFocused }))));
863
1030
  return results.find(({ isFocused }) => isFocused)?.win || null;
864
1031
  },
865
1032
 
1033
+ /**
1034
+ * Sets the application name, which is used for things like the user data directory and may be used by the native layer for other purposes.
1035
+ * @param {*} name The new name for the application. This will be converted to a string before being used.
1036
+ */
866
1037
  setName(name) {
867
1038
  process.env.POSITRON_APP_NAME = name;
868
1039
  this.events.emit("name-updated", name);
@@ -874,6 +1045,10 @@ const app = {
874
1045
  },
875
1046
 
876
1047
  userData: {
1048
+ /**
1049
+ * Gets the path to the user data directory for the application. The path is determined based on the operating system and the application name. If the directory does not exist, it will be created. Emits a "user-data-path-retrieved" event with the path as data when done.
1050
+ * @returns {string} The path to the user data directory.
1051
+ */
877
1052
  getPath() {
878
1053
  let userPath = null;
879
1054
 
@@ -902,6 +1077,9 @@ userData: {
902
1077
  return userPath;
903
1078
  },
904
1079
 
1080
+ /**
1081
+ * Creates the user data directory if it does not already exist. Emits a "user-data-created" event when the directory is created successfully.
1082
+ */
905
1083
  create() {
906
1084
  const userPath = this.getPath();
907
1085
 
@@ -911,6 +1089,10 @@ userData: {
911
1089
  }
912
1090
  },
913
1091
 
1092
+ /**
1093
+ * Deletes the user data directory and all of its contents. Use with caution, as this will permanently remove all user data for the application. Emits a "user-data-deleted" event when the directory is deleted successfully.
1094
+ */
1095
+
914
1096
  delete() {
915
1097
  const userPath = this.getPath();
916
1098
 
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "url": "https://github.com/systemsoftware/positron.js"
6
6
  },
7
7
  "homepage": "https://positronjs.gitbook.io",
8
- "version": "1.0.1",
8
+ "version": "1.0.3",
9
9
  "main": "index.js",
10
10
  "scripts": {
11
11
  "test": "node --test"