positron.js 1.0.3 → 1.0.4

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/builder.js CHANGED
@@ -144,10 +144,19 @@ function performNativeBuild() {
144
144
  });
145
145
 
146
146
  try {
147
- const iconPathEscaped = path.join(__dirname, "positronicon.png").replace(/"/g, '\\"');
147
+ let swiftScript = "";
148
+ if(fs.existsSync(path.join(appRoot, "icon.icns"))) {
149
+ const iconPathEscaped = path.join(appRoot, "icon.icns").replace(/"/g, '\\"');
148
150
  const binPathEscaped = path.join(outBinaryDir, binaryName).replace(/"/g, '\\"');
149
- const swiftScript = `import Cocoa; NSWorkspace.shared.setIcon(NSImage(contentsOfFile: "${iconPathEscaped}"), forFile: "${binPathEscaped}", options: [])`;
151
+ swiftScript = `import Cocoa; NSWorkspace.shared.setIcon(NSImage(contentsOfFile: "${iconPathEscaped}"), forFile: "${binPathEscaped}", options: []); `;
150
152
  cp.execFileSync("swift", ["-e", swiftScript], { stdio: "ignore" });
153
+ return true;
154
+ } else if(fs.existsSync(path.join(__dirname, ['positronicon', 'png'].join('.')))) {
155
+ const iconPathEscaped = path.join(__dirname, ['positronicon', 'png'].join('.')).replace(/"/g, '\\"');
156
+ const binPathEscaped = path.join(outBinaryDir, binaryName).replace(/"/g, '\\"');
157
+ swiftScript = `import Cocoa; NSWorkspace.shared.setIcon(NSImage(contentsOfFile: "${iconPathEscaped}"), forFile: "${binPathEscaped}", options: []);`;
158
+ }
159
+ cp.execFileSync("swift", ["-e", swiftScript], { stdio: "ignore" });
151
160
  } catch (err) {
152
161
  error("Failed to set custom icon on native binary:", err);
153
162
  }
@@ -141,13 +141,9 @@ func getBuiltInHandlers() -> [String: (Int, [String]) -> Void] {
141
141
  return
142
142
  }
143
143
 
144
- let selector = Selector(("_showDeveloperTools:"))
145
- if webView.responds(to: selector) {
146
- webView.perform(selector, with: nil)
147
- } else {
148
- printError("openDevTools failed: _showDeveloperTools: selector not found on WKWebView (windowId \(windowId))")
144
+ let inspector = webView.value(forKey: "inspector") as? NSObject
145
+ inspector?.perform(NSSelectorFromString("show"))
149
146
  }
150
- },
151
147
  ]
152
148
 
153
149
  return baseHandlers + getExtensionRegistry()
@@ -292,6 +288,13 @@ func handleCommand(windowId: Int, command: String, args: [String]) {
292
288
 
293
289
  window.performClose(nil)
294
290
 
291
+ case "isFullscreen":
292
+ guard let window = windows[windowId] else { return }
293
+ let isFullscreen = window.styleMask.contains(.fullScreen)
294
+ AppDelegate.shared?.ipcClient.send(
295
+ IPCResponse(windowId: windowId, event: "isFullscreen-reply-\(windowId)", data: ["isFullscreen": isFullscreen ? "true" : "false"])
296
+ )
297
+
295
298
  case "setSwipeNav":
296
299
  guard let window = windows[windowId],
297
300
  let webView = window.contentView as? WKWebView else {
@@ -521,6 +524,97 @@ UNUserNotificationCenter.current().requestAuthorization(
521
524
  printError("executeAppleScript failed: \(errorMessage)")
522
525
  }
523
526
 
527
+ case "isVisible":
528
+ guard let window = windows[windowId] else { return }
529
+ let isVisible = window.isVisible
530
+ AppDelegate.shared?.ipcClient.send(
531
+ IPCResponse(windowId: windowId, event: "isVisible-reply-\(windowId)", data: ["isVisible": isVisible ? "true" : "false"])
532
+ )
533
+
534
+ case "addToContentBlocker":
535
+ guard let window = windows[windowId],
536
+ let webView = window.contentView as? WKWebView,
537
+ let input = args.first
538
+ else {
539
+ printError("addToContentBlocker — missing rules")
540
+ return
541
+ }
542
+
543
+ let jsonStr: String
544
+
545
+ if FileManager.default.fileExists(atPath: input) {
546
+ do {
547
+ jsonStr = try String(contentsOfFile: input, encoding: .utf8)
548
+ } catch {
549
+ printError("Failed to read rule file: \(error.localizedDescription)")
550
+ return
551
+ }
552
+ } else {
553
+ jsonStr = input
554
+ }
555
+
556
+ guard let data = jsonStr.data(using: .utf8),
557
+ (try? JSONSerialization.jsonObject(with: data)) != nil
558
+ else {
559
+ printError("addToContentBlocker — invalid JSON rules")
560
+ return
561
+ }
562
+
563
+ let reload = args.count > 1
564
+ ? args[1].lowercased() == "true"
565
+ : true
566
+
567
+ let clearAll = args.count > 2
568
+ ? args[2].lowercased() == "true"
569
+ : false
570
+
571
+ let identifier = "dynamicRules-\(windowId)-\(UUID().uuidString)"
572
+
573
+ WKContentRuleListStore.default().compileContentRuleList(
574
+ forIdentifier: identifier,
575
+ encodedContentRuleList: jsonStr
576
+ ) { [weak webView] ruleList, error in
577
+
578
+ guard let webView else { return }
579
+
580
+ if let error {
581
+ printError("Failed to compile content blocker rules: \(error.localizedDescription)")
582
+ return
583
+ }
584
+
585
+ guard let ruleList else {
586
+ printError("Failed to compile content blocker rules: no rule list returned")
587
+ return
588
+ }
589
+
590
+ let controller = webView.configuration.userContentController
591
+
592
+ if clearAll {
593
+ controller.removeAllContentRuleLists()
594
+ }
595
+
596
+ controller.add(ruleList)
597
+
598
+ if reload {
599
+ webView.reload()
600
+ }
601
+ }
602
+
603
+ GetIPCClient().send(
604
+ IPCResponse(windowId: windowId, event: "addToContentBlocker-reply-\(windowId)", data: ["status": "success"])
605
+ )
606
+
607
+ case "isSwipeNavEnabled":
608
+ guard let window = windows[windowId],
609
+ let webView = window.contentView as? WKWebView else {
610
+ printError("isSwipeNavEnabled — webview not found for window \(windowId)")
611
+ return
612
+ }
613
+ let enabled = webView.allowsBackForwardNavigationGestures
614
+ AppDelegate.shared?.ipcClient.send(
615
+ IPCResponse(windowId: windowId, event: "isSwipeNavEnabled-reply-\(windowId)", data: ["enabled": enabled ? "true" : "false"])
616
+ )
617
+
524
618
  case "forward":
525
619
  guard let window = windows[windowId] else { return }
526
620
  (window.contentView as? WKWebView)?.goForward()
@@ -1067,6 +1161,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
1067
1161
  editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
1068
1162
  editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
1069
1163
 
1164
+ // Window menu
1165
+ let windowMenuItem = NSMenuItem()
1166
+ mainMenu.addItem(windowMenuItem)
1167
+ let windowMenu = NSMenu(title: "Window")
1168
+ windowMenuItem.submenu = windowMenu
1169
+ windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.miniaturize(_:)), keyEquivalent: "m")
1170
+ windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.zoom(_:)), keyEquivalent: "")
1171
+ NSApp.windowsMenu = windowMenu
1172
+
1070
1173
  NSApp.mainMenu = mainMenu
1071
1174
  }
1072
1175
  }
@@ -1145,6 +1248,12 @@ func buildMenu(from descriptor: [[String: Any]], windowId: Int) -> NSMenu {
1145
1248
  let sub = NSMenu(title: topItem.title)
1146
1249
  topItem.submenu = sub
1147
1250
 
1251
+ if let role = topLevel["role"] as? String, role.lowercased() == "window" {
1252
+ NSApp.windowsMenu = sub
1253
+ } else if topItem.title.lowercased() == "window" {
1254
+ NSApp.windowsMenu = sub
1255
+ }
1256
+
1148
1257
  if let items = topLevel["items"] as? [[String: Any]] {
1149
1258
  populateMenu(sub, with: items, windowId: windowId, isContextMenu: false)
1150
1259
  }
package/core/win/main.cs CHANGED
@@ -24,7 +24,6 @@ namespace PositronWindows
24
24
 
25
25
  // MARK: - IPC Message Types
26
26
 
27
-
28
27
  public class IPCMessage
29
28
  {
30
29
  public int windowId { get; set; }
@@ -399,6 +398,41 @@ private void StartNodeProcess(string workingDirectory)
399
398
  data = new() { { "enabled", (wvSwipeNav?.CoreWebView2.Settings.IsSwipeNavigationEnabled ?? false).ToString().ToLower() } }
400
399
  });
401
400
 
401
+ break;
402
+
403
+ case "isSwipeNavEnabled":
404
+ if (!WindowsMap.TryGetValue(windowId, out var winCheckSwipe)) break;
405
+ var wvCheckSwipe = GetWebView(windowId);
406
+ if (wvCheckSwipe != null) {
407
+ bool isEnabled = wvCheckSwipe.CoreWebView2.Settings.IsSwipeNavigationEnabled;
408
+ GetIPCClient().Send(new IPCResponse
409
+ {
410
+ windowId = windowId,
411
+ @event = "isSwipeNavEnabled-reply-" + windowId,
412
+ data = new() { { "enabled", isEnabled.ToString().ToLower() } }
413
+ }
414
+ );
415
+ }
416
+ break;
417
+
418
+ case "isVisible":
419
+ if (!WindowsMap.TryGetValue(windowId, out var winVisible)) break;
420
+ bool isVisible = winVisible.IsVisible;
421
+ GetIPCClient().Send(new IPCResponse
422
+ { windowId = windowId,
423
+ @event = "isVisible-reply-" + windowId,
424
+ data = new() { { "isVisible", isVisible.ToString().ToLower() } }
425
+ });
426
+ break;
427
+
428
+ case "isFullscreen":
429
+ if (!WindowsMap.TryGetValue(windowId, out var winFullscreen)) break;
430
+ bool isFullscreen = winFullscreen.WindowState == WindowState.Maximized;
431
+ GetIPCClient().Send(new IPCResponse
432
+ { windowId = windowId,
433
+ @event = "isFullscreen-reply-" + windowId,
434
+ data = new() { { "isFullscreen", isFullscreen.ToString().ToLower() } }
435
+ });
402
436
  break;
403
437
 
404
438
  case "closeWindow":
package/index.js CHANGED
@@ -267,7 +267,8 @@ class Window extends Events.EventEmitter {
267
267
  * @param {string} command
268
268
  * @param {string[]} args
269
269
  */
270
- sendCommand(command, args = []) {
270
+ sendCommand(command, ...args) {
271
+ if(args[0] instanceof Array) args = args[0].concat(args.slice(1));
271
272
  const normalizedArgs = Array.isArray(args) ? args.map(String) : [String(args)];
272
273
 
273
274
  const payload = JSON.stringify({
@@ -338,7 +339,7 @@ class Window extends Events.EventEmitter {
338
339
  * @param {string} channel The IPC channel to send the message on.
339
340
  * @param {string[]} args The arguments to send with the message.
340
341
  */
341
- sendIpc(channel, args = []) {
342
+ emitToRenderer(channel, args = []) {
342
343
  if (activeSocket && activeSocket.readyState === WebSocket.OPEN) {
343
344
  const payload = JSON.stringify({
344
345
  windowId: this.id,
@@ -352,6 +353,15 @@ sendIpc(channel, args = []) {
352
353
  }
353
354
  }
354
355
 
356
+
357
+ /**
358
+ * @deprecated Use emitToRenderer instead.
359
+ */
360
+ sendIpc = (channel, args = []) => {
361
+ warn("sendIpc is deprecated. Use emitToRenderer instead.");
362
+ this.emitToRenderer(channel, args);
363
+ }
364
+
355
365
  #created = false;
356
366
 
357
367
  /**
@@ -405,6 +415,10 @@ create(width, height, darwinOptions = {
405
415
  this.sendCommand("forceCloseWindow");
406
416
  }
407
417
 
418
+ isClosed() {
419
+ return !activeWindows.has(this);
420
+ }
421
+
408
422
 
409
423
  /**
410
424
  * Sets the application menu for this window.
@@ -488,10 +502,6 @@ resize(width, height) {
488
502
  * Opens the developer tools for the window. Emits a "devtools-opened" event when done. Does not work on macOS. For macOS, right-click the window and select "Inspect Element" to open dev tools for that window.
489
503
  */
490
504
  openDevTools() {
491
- if(process.platform === "darwin") {
492
- warn("The openDevTools command is not supported on macOS due to OS limitations. Please right-click the window and select 'Inspect Element' to access developer tools.");
493
- return;
494
- }
495
505
  this.sendCommand("openDevTools");
496
506
  this.emit("devtools-opened");
497
507
  }
@@ -560,6 +570,26 @@ focus() {
560
570
  this.emit("focused");
561
571
  }
562
572
 
573
+ /**
574
+ * Checks if the window is currently visible. Returns a Promise that resolves to true if the window is visible, or false if it is hidden. Emits an "is-visible-checked" event with the result as data when done.
575
+ * @returns {Promise<boolean>} True if the window is visible, false otherwise.
576
+ */
577
+ async isVisible() {
578
+ const res = await this.request("isVisible", `isVisible-reply-${this.id}`);
579
+ return res?.isVisible === "true";
580
+ }
581
+
582
+ /**
583
+ * Checks if the window is currently in fullscreen mode.
584
+ * @returns {Promise<boolean>} True if the window is fullscreen, false otherwise.
585
+ */
586
+ async isFullscreen() {
587
+ const res = await this.request("isFullscreen", `isFullscreen-reply-${this.id}`);
588
+ return res?.isFullscreen === "true";
589
+ }
590
+
591
+
592
+
563
593
  /**
564
594
  * Reloads the window. Emits a "reloaded" event when done.
565
595
  */
@@ -747,6 +777,10 @@ setContextMenu(menuTemplate) {
747
777
  this.emit("context-menu-updated", menuTemplate);
748
778
  }
749
779
 
780
+ /**
781
+ * Checks if the window is currently focused.
782
+ * @returns {Promise<boolean>} True if the window is focused, false otherwise.
783
+ */
750
784
  async isFocused() {
751
785
  const res = await this.request("isFocused", `isFocused-reply-${this.id}`);
752
786
  return res?.isFocused === "true";
@@ -965,6 +999,57 @@ const res = await this.request("setSwipeNav", `setSwipeNav-reply-${this.id}`, St
965
999
  this.emit("swipe-navigation-updated", enabled);
966
1000
  }
967
1001
 
1002
+ /**
1003
+ * Checks if swipe navigation is enabled for the window. Returns a Promise that resolves to true if swipe navigation is enabled, or false if it is disabled. Emits an "is-swipe-navigation-enabled-checked" event with the result as data when done.
1004
+ * @returns {Promise<boolean>} True if swipe navigation is enabled, false otherwise.
1005
+ */
1006
+ async isSwipeNavigationEnabled() {
1007
+ const res = await this.request("isSwipeNavEnabled", `isSwipeNavEnabled-reply-${this.id}`);
1008
+ return res?.enabled === "true";
1009
+ }
1010
+
1011
+ /**
1012
+ * Adds content blocker rules to the window. The rules can be provided as a JSON object, loaded from a URL that returns JSON, or loaded from a local file.
1013
+ * Once the rules are added, the window will block content according to the specified rules. Emits a "content-blocker-updated" event with the new rules as data when done.
1014
+ * @param {Object} config The configuration for adding content blocker rules.
1015
+ * @param {Object[]} [config.json] An array of content blocker rules as JSON objects. Each rule should follow the format specified by the native layer's content blocking implementation.
1016
+ * @param {string} [config.url] A URL that returns a JSON array of content blocker rules. If provided, the rules will be loaded from this URL instead of using the `json` property.
1017
+ * @param {string} [config.file] A local file path containing the content blocker rules in JSON format. If provided, the rules will be loaded from this file instead of using the `json` or `url` properties.
1018
+ * @param {boolean} [config.reload] Whether to reload the window after adding the content blocker rules. Reloading may be necessary for the new rules to take effect immediately, but it can also be set to false if you want to add rules without interrupting the user's current session.
1019
+ * @param {boolean} [config.clearExisting] Whether to clear existing content blocker rules before adding the new ones. If false, the new rules will be added alongside any existing rules. If true, all existing rules will be removed before adding the new ones.
1020
+ * @platform macOS only
1021
+ * @see https://webkit.org/blog/3476/content-blockers-first-look/
1022
+ */
1023
+ async addToContentBlocker(config={ json:[], url:"", file:"", reload:true, clearExisting: false }) {
1024
+
1025
+ if(process.platform !== "darwin") return;
1026
+
1027
+ let json = config.json || [];
1028
+
1029
+ if(config.file) {
1030
+ json = config.file
1031
+ } else {
1032
+ if(config.url) {
1033
+ let req = (await fetch(config.url));
1034
+ let _json = await req.json();
1035
+
1036
+ if(json.length) {
1037
+ json = json.concat(_json);
1038
+ } else {
1039
+ json = _json;
1040
+ }
1041
+ }
1042
+
1043
+ json = JSON.stringify(json);
1044
+ }
1045
+
1046
+
1047
+ const res = await this.request("addToContentBlocker", `addToContentBlocker-reply-${this.id}`, json, config.reload, config.clearExisting);
1048
+ this.emit("content-blocker-updated", json);
1049
+ }
1050
+
1051
+
1052
+
968
1053
  }
969
1054
 
970
1055
  const app = {
@@ -1047,9 +1132,10 @@ const app = {
1047
1132
  userData: {
1048
1133
  /**
1049
1134
  * 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.
1135
+ * @param {string} [append] An optional string to append to the user data path. This can be used to create subdirectories within the user data directory for organizing different types of data. If provided, the appended path will also be created if it does not exist.
1050
1136
  * @returns {string} The path to the user data directory.
1051
1137
  */
1052
- getPath() {
1138
+ getPath(append) {
1053
1139
  let userPath = null;
1054
1140
 
1055
1141
  if (process.platform === "win32") {
@@ -1074,7 +1160,7 @@ userData: {
1074
1160
  fs.mkdirSync(userPath, { recursive: true });
1075
1161
  }
1076
1162
 
1077
- return userPath;
1163
+ return append ? path.join(userPath, append) : userPath;
1078
1164
  },
1079
1165
 
1080
1166
  /**
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.3",
8
+ "version": "1.0.4",
9
9
  "main": "index.js",
10
10
  "scripts": {
11
11
  "test": "node --test"