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.
- package/README.md +7 -8
- package/builder.js +160 -3
- package/core/linux/main.cpp +1123 -0
- package/core/mac/main.swift +52 -21
- package/core/mac/tray.swift +95 -0
- package/core/win/main.cs +58 -13
- package/core/win/tray.cs +142 -0
- package/index.js +91 -38
- package/package.json +3 -2
- package/packager.js +118 -12
- package/screen.js +7 -5
- package/tray.js +72 -0
package/core/mac/main.swift
CHANGED
|
@@ -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 == .
|
|
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.
|
|
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://
|
|
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://
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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://
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1278
|
-
|
|
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
|
}
|
package/core/win/tray.cs
ADDED
|
@@ -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
|
+
}
|