positron.js 1.0.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/LICENSE +7 -0
- package/README.md +154 -0
- package/bin/positron.js +40 -0
- package/builder.js +229 -0
- package/core/mac/main.swift +1154 -0
- package/core/win/PositronRuntime.csproj +14 -0
- package/core/win/main.cs +1124 -0
- package/extensions.js +42 -0
- package/findpackage.js +34 -0
- package/index.js +912 -0
- package/ipc.js +81 -0
- package/logs.js +19 -0
- package/menu.js +100 -0
- package/package.json +30 -0
- package/packager.js +260 -0
- package/pbannerfull.png +0 -0
- package/positronicon.png +0 -0
- package/screen.js +35 -0
- package/store.js +104 -0
package/core/win/main.cs
ADDED
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Collections.Generic;
|
|
3
|
+
using System.Diagnostics;
|
|
4
|
+
using System.IO;
|
|
5
|
+
using System.Net.WebSockets;
|
|
6
|
+
using System.Text;
|
|
7
|
+
using System.Text.Json;
|
|
8
|
+
using System.Text.Json.Nodes;
|
|
9
|
+
using System.Threading;
|
|
10
|
+
using System.Threading.Tasks;
|
|
11
|
+
using System.Windows;
|
|
12
|
+
using System.Windows.Controls;
|
|
13
|
+
using System.Windows.Input;
|
|
14
|
+
using Microsoft.Web.WebView2.Core;
|
|
15
|
+
using Microsoft.Web.WebView2.Wpf;
|
|
16
|
+
using System.Text.Json.Serialization;
|
|
17
|
+
using System.Net;
|
|
18
|
+
using System.Net.Sockets;
|
|
19
|
+
using Microsoft.VisualBasic;
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
namespace PositronWindows
|
|
23
|
+
{
|
|
24
|
+
|
|
25
|
+
// MARK: - IPC Message Types
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
public class IPCMessage
|
|
29
|
+
{
|
|
30
|
+
public int windowId { get; set; }
|
|
31
|
+
public string command { get; set; } = "";
|
|
32
|
+
|
|
33
|
+
[JsonConverter(typeof(IPCArgsConverter))]
|
|
34
|
+
public List<string> args { get; set; } = new();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public class IPCArgsConverter : JsonConverter<List<string>>
|
|
38
|
+
{
|
|
39
|
+
public override List<string> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
|
40
|
+
{
|
|
41
|
+
var list = new List<string>();
|
|
42
|
+
using (var doc = JsonDocument.ParseValue(ref reader))
|
|
43
|
+
{
|
|
44
|
+
foreach (var element in doc.RootElement.EnumerateArray())
|
|
45
|
+
{
|
|
46
|
+
// Keep strings as strings, but convert objects/arrays/numbers into raw JSON strings
|
|
47
|
+
if (element.ValueKind == JsonValueKind.String)
|
|
48
|
+
list.Add(element.GetString()!);
|
|
49
|
+
else
|
|
50
|
+
list.Add(element.GetRawText());
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return list;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public override void Write(Utf8JsonWriter writer, List<string> value, JsonSerializerOptions options)
|
|
57
|
+
{
|
|
58
|
+
writer.WriteStartArray();
|
|
59
|
+
foreach (var item in value) writer.WriteStringValue(item);
|
|
60
|
+
writer.WriteEndArray();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public class IPCResponse
|
|
65
|
+
{
|
|
66
|
+
public int windowId { get; set; }
|
|
67
|
+
public string @event { get; set; } = "";
|
|
68
|
+
public Dictionary<string, string> data { get; set; } = new();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public partial class App : Application
|
|
72
|
+
{
|
|
73
|
+
|
|
74
|
+
private static void error(string message)
|
|
75
|
+
{
|
|
76
|
+
string red = "\u001b[31m";
|
|
77
|
+
|
|
78
|
+
bool isWarning = message.StartsWith("WARNING");
|
|
79
|
+
|
|
80
|
+
bool isInfo = message.StartsWith("INFO");
|
|
81
|
+
|
|
82
|
+
if (isWarning)
|
|
83
|
+
{
|
|
84
|
+
red = "\u001b[33m";
|
|
85
|
+
message = message.Replace("WARNING: ", "");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (isInfo)
|
|
89
|
+
{
|
|
90
|
+
red = "\u001b[34m";
|
|
91
|
+
message = message.Replace("INFO: ", "");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
string tag = isWarning ? "WARNING" : (isInfo ? "INFO" : "ERROR");
|
|
95
|
+
|
|
96
|
+
string reset = "\u001b[0m";
|
|
97
|
+
Console.WriteLine($"{red}[C# {tag}] {message}{reset}");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private static readonly string AuthToken =
|
|
101
|
+
Environment.GetEnvironmentVariable("POSITRON_AUTH_TOKEN") ?? Guid.NewGuid().ToString();
|
|
102
|
+
|
|
103
|
+
public static bool IsPackaged { get; private set; } = false;
|
|
104
|
+
|
|
105
|
+
private static IPCClient _ipcClient = null!;
|
|
106
|
+
private static Process? _nodeProcess;
|
|
107
|
+
|
|
108
|
+
/// <summary>All window access must happen on the UI thread.</summary>
|
|
109
|
+
private static readonly Dictionary<int, Window> WindowsMap = new();
|
|
110
|
+
private static readonly Dictionary<int, DockPanel> LayoutMap = new();
|
|
111
|
+
private static readonly Dictionary<int, Menu> MenuMap = new();
|
|
112
|
+
private static readonly HashSet<int> _forceClosing = new();
|
|
113
|
+
private static readonly Dictionary<int, TaskCompletionSource> ReadyMap = new();
|
|
114
|
+
|
|
115
|
+
[STAThread]
|
|
116
|
+
public static void Main()
|
|
117
|
+
{
|
|
118
|
+
var app = new App();
|
|
119
|
+
app.Run();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
public static int GetRandomOpenPort()
|
|
123
|
+
{
|
|
124
|
+
// Passing 0 to the port tells the OS to assign an available one
|
|
125
|
+
TcpListener listener = new(IPAddress.Loopback, 0);
|
|
126
|
+
listener.Start();
|
|
127
|
+
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
128
|
+
listener.Stop();
|
|
129
|
+
return port;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
protected override async void OnStartup(StartupEventArgs e)
|
|
133
|
+
{
|
|
134
|
+
base.OnStartup(e);
|
|
135
|
+
this.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
|
136
|
+
|
|
137
|
+
string basePath = AppDomain.CurrentDomain.BaseDirectory;
|
|
138
|
+
string targetDir = Directory.Exists(Path.Combine(basePath, "resources"))
|
|
139
|
+
? Path.Combine(basePath, "resources")
|
|
140
|
+
: basePath;
|
|
141
|
+
|
|
142
|
+
if (File.Exists(Path.Combine(targetDir, "positron-backend.exe")))
|
|
143
|
+
{
|
|
144
|
+
// PACKAGED MODE — C# is the entry point; launch the Node backend
|
|
145
|
+
StartNodeProcess(targetDir);
|
|
146
|
+
}
|
|
147
|
+
else
|
|
148
|
+
{
|
|
149
|
+
// DEV MODE — Node launched us; read the port it set in the environment
|
|
150
|
+
var envPort = Environment.GetEnvironmentVariable("POSITRON_IPC_PORT");
|
|
151
|
+
if (!string.IsNullOrEmpty(envPort) && int.TryParse(envPort, out var port))
|
|
152
|
+
{
|
|
153
|
+
_ipcPort = port;
|
|
154
|
+
error("INFO: Dev mode — connecting to existing Node IPC server on port " + port);
|
|
155
|
+
}
|
|
156
|
+
else
|
|
157
|
+
{
|
|
158
|
+
error("No positron-backend.exe found and POSITRON_IPC_PORT not set. Cannot start.");
|
|
159
|
+
Shutdown();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
Console.CancelKeyPress += (sender, e) =>
|
|
165
|
+
{
|
|
166
|
+
try { Current.Shutdown(); } catch { }
|
|
167
|
+
try { _nodeProcess?.Kill(); } catch { }
|
|
168
|
+
error("INFO: Received SIGINT, shutting down…");
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
AppDomain.CurrentDomain.ProcessExit += (sender, e) =>
|
|
172
|
+
{
|
|
173
|
+
Current.Dispatcher.Invoke(() =>
|
|
174
|
+
{
|
|
175
|
+
try { Current.Shutdown(); } catch { }
|
|
176
|
+
try { _nodeProcess?.Kill(); } catch { }
|
|
177
|
+
error("INFO: Process exiting, shutting down…");
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
await Task.Delay(500);
|
|
182
|
+
_ipcClient = new IPCClient(new Uri($"ws://localhost:{_ipcPort}"));
|
|
183
|
+
_ = _ipcClient.ConnectAsync(AuthToken);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
protected override void OnExit(ExitEventArgs e)
|
|
187
|
+
{
|
|
188
|
+
try { _nodeProcess?.Kill(); } catch { }
|
|
189
|
+
base.OnExit(e);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private static int _ipcPort = 9000;
|
|
193
|
+
|
|
194
|
+
private void StartNodeProcess(string workingDirectory)
|
|
195
|
+
{
|
|
196
|
+
IsPackaged = true;
|
|
197
|
+
|
|
198
|
+
_ipcPort = GetRandomOpenPort();
|
|
199
|
+
|
|
200
|
+
string backendExe = Path.Combine(workingDirectory, "positron-backend.exe");
|
|
201
|
+
|
|
202
|
+
_nodeProcess = new Process
|
|
203
|
+
{
|
|
204
|
+
StartInfo = new ProcessStartInfo
|
|
205
|
+
{
|
|
206
|
+
FileName = backendExe,
|
|
207
|
+
Arguments = "",
|
|
208
|
+
WorkingDirectory = workingDirectory,
|
|
209
|
+
RedirectStandardOutput = true,
|
|
210
|
+
RedirectStandardError = true,
|
|
211
|
+
UseShellExecute = false,
|
|
212
|
+
CreateNoWindow = true,
|
|
213
|
+
EnvironmentVariables =
|
|
214
|
+
{
|
|
215
|
+
["POSITRON_PACKAGED"] = "true",
|
|
216
|
+
["POSITRON_AUTH_TOKEN"] = AuthToken,
|
|
217
|
+
["POSITRON_IPC_PORT"] = _ipcPort.ToString()
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
_nodeProcess.OutputDataReceived += (s, args) =>
|
|
223
|
+
{
|
|
224
|
+
if (args.Data != null) Console.WriteLine($"[NODE BACKGROUND] {args.Data}");
|
|
225
|
+
};
|
|
226
|
+
_nodeProcess.ErrorDataReceived += (s, args) =>
|
|
227
|
+
{
|
|
228
|
+
if (args.Data != null) Console.WriteLine($"[NODE BACKGROUND ERROR] {args.Data}");
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
_nodeProcess.Exited += (s, args) =>
|
|
232
|
+
{
|
|
233
|
+
error("INFO: Node process exited. Shutting down app.");
|
|
234
|
+
Current.Dispatcher.Invoke(() =>
|
|
235
|
+
{
|
|
236
|
+
try { Current.Shutdown(); } catch { }
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
try
|
|
241
|
+
{
|
|
242
|
+
_nodeProcess.Start();
|
|
243
|
+
_nodeProcess.BeginOutputReadLine();
|
|
244
|
+
_nodeProcess.BeginErrorReadLine();
|
|
245
|
+
}
|
|
246
|
+
catch (Exception ex)
|
|
247
|
+
{
|
|
248
|
+
error($"Failed to start Node process: {ex.Message}");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// MARK: - Command Handler
|
|
253
|
+
|
|
254
|
+
public static async Task HandleCommandAsync(int windowId, string command, List<string> args)
|
|
255
|
+
{
|
|
256
|
+
// For commands other than createWindow, wait until the window's WebView2 is fully ready
|
|
257
|
+
if (command != "createWindow" && ReadyMap.TryGetValue(windowId, out var tcs))
|
|
258
|
+
{
|
|
259
|
+
await tcs.Task;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
switch (command)
|
|
263
|
+
{
|
|
264
|
+
case "createWindow":
|
|
265
|
+
{
|
|
266
|
+
int width = args.Count > 0 && int.TryParse(args[0], out var w) ? w : 800;
|
|
267
|
+
int height = args.Count > 1 && int.TryParse(args[1], out var h) ? h : 600;
|
|
268
|
+
|
|
269
|
+
var window = new Window
|
|
270
|
+
{
|
|
271
|
+
Width = width,
|
|
272
|
+
Height = height,
|
|
273
|
+
MinWidth = 200,
|
|
274
|
+
MinHeight = 150,
|
|
275
|
+
Title = "Positron Window",
|
|
276
|
+
WindowStartupLocation = WindowStartupLocation.CenterScreen
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
var dockPanel = new DockPanel();
|
|
280
|
+
var webView = new WebView2();
|
|
281
|
+
|
|
282
|
+
dockPanel.Children.Add(webView);
|
|
283
|
+
DockPanel.SetDock(webView, Dock.Bottom);
|
|
284
|
+
window.Content = dockPanel;
|
|
285
|
+
|
|
286
|
+
// Intercept the close button — ask Node first
|
|
287
|
+
window.Closing += (s, cancelArgs) =>
|
|
288
|
+
{
|
|
289
|
+
if (_forceClosing.Remove(windowId))
|
|
290
|
+
return; // Allow the close (forceCloseWindow was called)
|
|
291
|
+
|
|
292
|
+
cancelArgs.Cancel = true;
|
|
293
|
+
_ipcClient.Send(new IPCResponse
|
|
294
|
+
{
|
|
295
|
+
windowId = windowId,
|
|
296
|
+
@event = "window-close-requested"
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Clean up after the window is actually destroyed (single subscription)
|
|
301
|
+
window.Closed += (s, e) =>
|
|
302
|
+
{
|
|
303
|
+
WindowsMap.Remove(windowId);
|
|
304
|
+
LayoutMap.Remove(windowId);
|
|
305
|
+
MenuMap.Remove(windowId);
|
|
306
|
+
ReadyMap.Remove(windowId);
|
|
307
|
+
_ipcClient.Send(new IPCResponse { windowId = windowId, @event = "windowClosed" });
|
|
308
|
+
|
|
309
|
+
if (WindowsMap.Count == 0)
|
|
310
|
+
Application.Current.Shutdown();
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
WindowsMap[windowId] = window;
|
|
314
|
+
LayoutMap[windowId] = dockPanel;
|
|
315
|
+
|
|
316
|
+
// Create a readiness gate — other commands for this window will await it
|
|
317
|
+
var readyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
318
|
+
ReadyMap[windowId] = readyTcs;
|
|
319
|
+
|
|
320
|
+
window.Show();
|
|
321
|
+
|
|
322
|
+
// Init WebView2 & inject preload script
|
|
323
|
+
await webView.EnsureCoreWebView2Async();
|
|
324
|
+
webView.CoreWebView2.Settings.AreDevToolsEnabled = !IsPackaged;
|
|
325
|
+
await webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(MakePreloadScript(windowId));
|
|
326
|
+
|
|
327
|
+
// Sync window title with page title
|
|
328
|
+
webView.CoreWebView2.DocumentTitleChanged += (s, _) =>
|
|
329
|
+
{
|
|
330
|
+
window.Title = webView.CoreWebView2.DocumentTitle;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
webView.CoreWebView2.ContextMenuRequested += (s, e) =>
|
|
334
|
+
{
|
|
335
|
+
if (LayoutMap.TryGetValue(windowId, out var l) && l.ContextMenu != null)
|
|
336
|
+
{
|
|
337
|
+
e.Handled = true;
|
|
338
|
+
l.ContextMenu.IsOpen = true;
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// WebView → C# IPC routing
|
|
343
|
+
webView.CoreWebView2.WebMessageReceived += (s, e) =>
|
|
344
|
+
{
|
|
345
|
+
HandleWebViewIPC(windowId, e.WebMessageAsJson);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// Signal that this window is fully ready for commands
|
|
349
|
+
readyTcs.TrySetResult();
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
case "setContextMenu":
|
|
354
|
+
if (!LayoutMap.TryGetValue(windowId, out var layout)) break;
|
|
355
|
+
if (args.Count == 0)
|
|
356
|
+
{
|
|
357
|
+
error("setContextMenu — missing menu descriptor argument");
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// 1. Parse the JSON string sent from Node
|
|
362
|
+
var ctxDescriptor = JsonSerializer.Deserialize<JsonArray>(args[0]);
|
|
363
|
+
if (ctxDescriptor == null)
|
|
364
|
+
{
|
|
365
|
+
error("setContextMenu — invalid JSON descriptor");
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 2. Build the context menu using our helper
|
|
370
|
+
var contextMenu = new ContextMenu();
|
|
371
|
+
PopulateMenu(contextMenu.Items, ctxDescriptor, windowId, "context-menu-action");
|
|
372
|
+
|
|
373
|
+
// 3. Attach it to the layout
|
|
374
|
+
layout.ContextMenu = contextMenu;
|
|
375
|
+
break;
|
|
376
|
+
|
|
377
|
+
case "closeWindow":
|
|
378
|
+
if (WindowsMap.TryGetValue(windowId, out var winToClose))
|
|
379
|
+
winToClose.Close(); // Triggers Closed → cleanup above
|
|
380
|
+
else
|
|
381
|
+
error($"closeWindow — no window found with ID {windowId}");
|
|
382
|
+
break;
|
|
383
|
+
|
|
384
|
+
case "terminate":
|
|
385
|
+
try { Current.Shutdown(); } catch { }
|
|
386
|
+
break;
|
|
387
|
+
|
|
388
|
+
case "triggerCloseSequence":
|
|
389
|
+
if (WindowsMap.TryGetValue(windowId, out var winTrigger))
|
|
390
|
+
{
|
|
391
|
+
// This fires the window.Closing event handler we set up above
|
|
392
|
+
winTrigger.Close();
|
|
393
|
+
}
|
|
394
|
+
break;
|
|
395
|
+
|
|
396
|
+
case "forceCloseWindow":
|
|
397
|
+
if (WindowsMap.TryGetValue(windowId, out var winForce))
|
|
398
|
+
{
|
|
399
|
+
_forceClosing.Add(windowId);
|
|
400
|
+
winForce.Close();
|
|
401
|
+
}
|
|
402
|
+
else
|
|
403
|
+
{
|
|
404
|
+
error($"forceCloseWindow — no window found with ID {windowId}");
|
|
405
|
+
}
|
|
406
|
+
break;
|
|
407
|
+
|
|
408
|
+
case "setTitle":
|
|
409
|
+
if (!WindowsMap.TryGetValue(windowId, out var winTitle)) break;
|
|
410
|
+
if (args.Count == 0)
|
|
411
|
+
{
|
|
412
|
+
error("setTitle — missing title argument");
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
winTitle.Title = args[0];
|
|
416
|
+
break;
|
|
417
|
+
|
|
418
|
+
case "resize":
|
|
419
|
+
if (!WindowsMap.TryGetValue(windowId, out var winSize)) break;
|
|
420
|
+
if (args.Count < 2 || !int.TryParse(args[0], out var newW) || !int.TryParse(args[1], out var newH))
|
|
421
|
+
{
|
|
422
|
+
error("resize — expected two integer arguments");
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
winSize.Width = newW;
|
|
426
|
+
winSize.Height = newH;
|
|
427
|
+
break;
|
|
428
|
+
|
|
429
|
+
case "loadURL":
|
|
430
|
+
if (!WindowsMap.TryGetValue(windowId, out _)) break;
|
|
431
|
+
if (args.Count == 0)
|
|
432
|
+
{
|
|
433
|
+
error("loadURL — invalid or missing URL");
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
{
|
|
437
|
+
var wv = GetWebView(windowId);
|
|
438
|
+
if (wv != null) wv.Source = new Uri(args[0]);
|
|
439
|
+
}
|
|
440
|
+
break;
|
|
441
|
+
|
|
442
|
+
case "loadFile":
|
|
443
|
+
if (!WindowsMap.TryGetValue(windowId, out _)) break;
|
|
444
|
+
if (args.Count == 0)
|
|
445
|
+
{
|
|
446
|
+
error("loadFile — missing path argument");
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
{
|
|
450
|
+
var wv = GetWebView(windowId);
|
|
451
|
+
if (wv != null) wv.Source = new Uri(Path.GetFullPath(args[0]));
|
|
452
|
+
}
|
|
453
|
+
break;
|
|
454
|
+
|
|
455
|
+
case "print":
|
|
456
|
+
{
|
|
457
|
+
var wv = GetWebView(windowId);
|
|
458
|
+
if (wv != null && wv.CoreWebView2 != null)
|
|
459
|
+
wv.CoreWebView2.ShowPrintUI();
|
|
460
|
+
}
|
|
461
|
+
break;
|
|
462
|
+
|
|
463
|
+
case "setUserAgent":
|
|
464
|
+
if (args.Count == 0)
|
|
465
|
+
{
|
|
466
|
+
error("setUserAgent — missing user agent string argument");
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
{
|
|
470
|
+
var wv = GetWebView(windowId);
|
|
471
|
+
if (wv != null && wv.CoreWebView2 != null)
|
|
472
|
+
wv.CoreWebView2.Settings.UserAgent = args[0];
|
|
473
|
+
}
|
|
474
|
+
break;
|
|
475
|
+
|
|
476
|
+
case "evaluateJS":
|
|
477
|
+
if (args.Count == 0)
|
|
478
|
+
{
|
|
479
|
+
error("evaluateJS — missing script argument");
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
{
|
|
483
|
+
var wv = GetWebView(windowId);
|
|
484
|
+
if (wv != null)
|
|
485
|
+
{
|
|
486
|
+
try
|
|
487
|
+
{
|
|
488
|
+
var result = await wv.ExecuteScriptAsync(args[0]);
|
|
489
|
+
_ipcClient.Send(new IPCResponse
|
|
490
|
+
{
|
|
491
|
+
windowId = windowId,
|
|
492
|
+
@event = "evaluateJS-reply-" + windowId,
|
|
493
|
+
data = new() { { "result", result ?? "null" } }
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
catch (Exception ex)
|
|
497
|
+
{
|
|
498
|
+
error($"evaluateJS failed: {ex.Message}");
|
|
499
|
+
_ipcClient.Send(new IPCResponse
|
|
500
|
+
{
|
|
501
|
+
windowId = windowId,
|
|
502
|
+
@event = "evaluateJS-reply-" + windowId,
|
|
503
|
+
data = new() { { "error", ex.Message } }
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
break;
|
|
509
|
+
|
|
510
|
+
case "isFocused":
|
|
511
|
+
if (!WindowsMap.TryGetValue(windowId, out var winFocusState)) break;
|
|
512
|
+
bool isFocused = winFocusState.IsActive;
|
|
513
|
+
_ipcClient.Send(new IPCResponse
|
|
514
|
+
{
|
|
515
|
+
windowId = windowId,
|
|
516
|
+
@event = "isFocused-reply-" + windowId,
|
|
517
|
+
data = new() { { "isFocused", isFocused.ToString().ToLower() } }
|
|
518
|
+
});
|
|
519
|
+
break;
|
|
520
|
+
|
|
521
|
+
case "showNotification":
|
|
522
|
+
if (args.Count < 2)
|
|
523
|
+
{
|
|
524
|
+
error("showNotification — expected title and body arguments");
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
{
|
|
528
|
+
var title = args[0];
|
|
529
|
+
var body = args[1];
|
|
530
|
+
var notification = new System.Windows.Forms.NotifyIcon
|
|
531
|
+
{
|
|
532
|
+
Visible = true,
|
|
533
|
+
Icon = System.Drawing.SystemIcons.Application,
|
|
534
|
+
BalloonTipTitle = title,
|
|
535
|
+
BalloonTipText = body
|
|
536
|
+
};
|
|
537
|
+
notification.ShowBalloonTip(3000);
|
|
538
|
+
}
|
|
539
|
+
break;
|
|
540
|
+
|
|
541
|
+
case "alert":
|
|
542
|
+
if (args.Count == 0) break;
|
|
543
|
+
MessageBox.Show(args[0], "", MessageBoxButton.OK, MessageBoxImage.None);
|
|
544
|
+
break;
|
|
545
|
+
|
|
546
|
+
// Node uses "resizeWindow", not "resize"
|
|
547
|
+
case "resizeWindow":
|
|
548
|
+
if (!WindowsMap.TryGetValue(windowId, out var winRsz)) break;
|
|
549
|
+
if (args.Count < 2 || !int.TryParse(args[0], out var rsW) || !int.TryParse(args[1], out var rsH)) break;
|
|
550
|
+
winRsz.Width = rsW;
|
|
551
|
+
winRsz.Height = rsH;
|
|
552
|
+
break;
|
|
553
|
+
|
|
554
|
+
// Node uses "hideWindow"/"showWindow", not "hide"/"show"
|
|
555
|
+
case "hideWindow":
|
|
556
|
+
if (WindowsMap.TryGetValue(windowId, out var winHideW)) winHideW.Hide();
|
|
557
|
+
break;
|
|
558
|
+
|
|
559
|
+
case "showWindow":
|
|
560
|
+
if (WindowsMap.TryGetValue(windowId, out var winShowW)) winShowW.Show();
|
|
561
|
+
break;
|
|
562
|
+
|
|
563
|
+
case "addUserScript":
|
|
564
|
+
if (args.Count == 0) break;
|
|
565
|
+
{
|
|
566
|
+
var wv = GetWebView(windowId);
|
|
567
|
+
if (wv?.CoreWebView2 != null)
|
|
568
|
+
await wv.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(args[0]);
|
|
569
|
+
}
|
|
570
|
+
break;
|
|
571
|
+
|
|
572
|
+
case "setCloseable":
|
|
573
|
+
// WPF doesn't support disabling the close button cleanly; silently ignore
|
|
574
|
+
break;
|
|
575
|
+
|
|
576
|
+
case "setResizable":
|
|
577
|
+
if (WindowsMap.TryGetValue(windowId, out var winRes))
|
|
578
|
+
winRes.ResizeMode = args[0] == "true" ? ResizeMode.CanResize : ResizeMode.NoResize;
|
|
579
|
+
break;
|
|
580
|
+
|
|
581
|
+
case "setMinimizable":
|
|
582
|
+
// No direct WPF equivalent; silently ignore
|
|
583
|
+
break;
|
|
584
|
+
|
|
585
|
+
case "setBounds":
|
|
586
|
+
if (!WindowsMap.TryGetValue(windowId, out var winBounds)) break;
|
|
587
|
+
if (args.Count < 4) break;
|
|
588
|
+
if (double.TryParse(args[0], out var bx)) winBounds.Left = bx;
|
|
589
|
+
if (double.TryParse(args[1], out var by)) winBounds.Top = by;
|
|
590
|
+
if (double.TryParse(args[2], out var bw)) winBounds.Width = bw;
|
|
591
|
+
if (double.TryParse(args[3], out var bh)) winBounds.Height = bh;
|
|
592
|
+
break;
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
case "prompt":
|
|
596
|
+
if (args.Count < 2)
|
|
597
|
+
{
|
|
598
|
+
error("prompt — expected message and defaultValue arguments");
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
{
|
|
602
|
+
var message = args[0];
|
|
603
|
+
var defaultValue = args[1];
|
|
604
|
+
string result = Interaction.InputBox(message, "Prompt", defaultValue);
|
|
605
|
+
_ipcClient.Send(new IPCResponse
|
|
606
|
+
{
|
|
607
|
+
windowId = windowId,
|
|
608
|
+
@event = "prompt-reply-" + windowId,
|
|
609
|
+
data = new() { { "input", result } }
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
break;
|
|
613
|
+
|
|
614
|
+
case "emitToRenderer":
|
|
615
|
+
if (args.Count < 2)
|
|
616
|
+
{
|
|
617
|
+
error("emitToRenderer — expected channel and payload arguments");
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
{
|
|
621
|
+
var channel = args[0];
|
|
622
|
+
var payload = args[1];
|
|
623
|
+
var escaped = payload
|
|
624
|
+
.Replace("\\", "\\\\")
|
|
625
|
+
.Replace("`", "\\`");
|
|
626
|
+
var script = $"window.ipc._emit(`{channel}`, JSON.parse(`{escaped}`));";
|
|
627
|
+
var wv = GetWebView(windowId);
|
|
628
|
+
if (wv != null)
|
|
629
|
+
{
|
|
630
|
+
try { await wv.ExecuteScriptAsync(script); }
|
|
631
|
+
catch (Exception ex) { error($"emitToRenderer failed: {ex.Message}"); }
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
break;
|
|
635
|
+
|
|
636
|
+
case "setMenu":
|
|
637
|
+
if (args.Count == 0)
|
|
638
|
+
{
|
|
639
|
+
error("setMenu — missing menu descriptor argument");
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
BuildAndAttachMenu(windowId, args[0]);
|
|
643
|
+
break;
|
|
644
|
+
|
|
645
|
+
case "resetMenu":
|
|
646
|
+
RemoveMenu(windowId);
|
|
647
|
+
break;
|
|
648
|
+
|
|
649
|
+
case "openDevTools":
|
|
650
|
+
{
|
|
651
|
+
var wv = GetWebView(windowId);
|
|
652
|
+
if (wv != null && wv.CoreWebView2 != null)
|
|
653
|
+
wv.CoreWebView2.OpenDevToolsWindow();
|
|
654
|
+
}
|
|
655
|
+
break;
|
|
656
|
+
|
|
657
|
+
case "hide":
|
|
658
|
+
if (WindowsMap.TryGetValue(windowId, out var winHide))
|
|
659
|
+
winHide.Hide();
|
|
660
|
+
break;
|
|
661
|
+
|
|
662
|
+
case "show":
|
|
663
|
+
if (WindowsMap.TryGetValue(windowId, out var winShow))
|
|
664
|
+
winShow.Show();
|
|
665
|
+
break;
|
|
666
|
+
|
|
667
|
+
case "minimize":
|
|
668
|
+
if (WindowsMap.TryGetValue(windowId, out var winMin))
|
|
669
|
+
winMin.WindowState = WindowState.Minimized;
|
|
670
|
+
break;
|
|
671
|
+
|
|
672
|
+
case "maximize":
|
|
673
|
+
if (WindowsMap.TryGetValue(windowId, out var winMax))
|
|
674
|
+
winMax.WindowState = WindowState.Maximized;
|
|
675
|
+
break;
|
|
676
|
+
|
|
677
|
+
case "focus":
|
|
678
|
+
if (WindowsMap.TryGetValue(windowId, out var winFocus))
|
|
679
|
+
winFocus.Focus();
|
|
680
|
+
break;
|
|
681
|
+
|
|
682
|
+
case "fullscreen":
|
|
683
|
+
if (WindowsMap.TryGetValue(windowId, out var winFS))
|
|
684
|
+
winFS.WindowState = WindowState.Maximized;
|
|
685
|
+
break;
|
|
686
|
+
|
|
687
|
+
case "exitFullscreen":
|
|
688
|
+
if (WindowsMap.TryGetValue(windowId, out var winExitFS))
|
|
689
|
+
winExitFS.WindowState = WindowState.Normal;
|
|
690
|
+
break;
|
|
691
|
+
|
|
692
|
+
case "toggleFullscreen":
|
|
693
|
+
if (WindowsMap.TryGetValue(windowId, out var winToggleFS))
|
|
694
|
+
winToggleFS.WindowState = winToggleFS.WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
|
|
695
|
+
break;
|
|
696
|
+
|
|
697
|
+
case "forward":
|
|
698
|
+
{
|
|
699
|
+
var wv = GetWebView(windowId);
|
|
700
|
+
if (wv != null && wv.CoreWebView2 != null && wv.CoreWebView2.CanGoForward)
|
|
701
|
+
wv.CoreWebView2.GoForward();
|
|
702
|
+
}
|
|
703
|
+
break;
|
|
704
|
+
|
|
705
|
+
case "back":
|
|
706
|
+
{
|
|
707
|
+
var wv = GetWebView(windowId);
|
|
708
|
+
if (wv != null && wv.CoreWebView2 != null && wv.CoreWebView2.CanGoBack)
|
|
709
|
+
wv.CoreWebView2.GoBack();
|
|
710
|
+
}
|
|
711
|
+
break;
|
|
712
|
+
|
|
713
|
+
case "reload":
|
|
714
|
+
{
|
|
715
|
+
var wv = GetWebView(windowId);
|
|
716
|
+
if (wv != null && wv.CoreWebView2 != null)
|
|
717
|
+
wv.CoreWebView2.Reload();
|
|
718
|
+
}
|
|
719
|
+
break;
|
|
720
|
+
|
|
721
|
+
case "capturePage":
|
|
722
|
+
{
|
|
723
|
+
var wv = GetWebView(windowId);
|
|
724
|
+
if (wv != null)
|
|
725
|
+
{
|
|
726
|
+
try
|
|
727
|
+
{
|
|
728
|
+
using var ms = new MemoryStream();
|
|
729
|
+
await wv.CoreWebView2.CapturePreviewAsync(CoreWebView2CapturePreviewImageFormat.Png, ms);
|
|
730
|
+
var base64 = Convert.ToBase64String(ms.ToArray());
|
|
731
|
+
_ipcClient.Send(new IPCResponse
|
|
732
|
+
{
|
|
733
|
+
windowId = windowId,
|
|
734
|
+
@event = "capture-page-result-" + windowId,
|
|
735
|
+
data = new() { { "imageData", base64 } }
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
catch (Exception ex)
|
|
739
|
+
{
|
|
740
|
+
error($"capturePage failed: {ex.Message}");
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
break;
|
|
745
|
+
|
|
746
|
+
case "canGoBack":
|
|
747
|
+
{
|
|
748
|
+
var wv = GetWebView(windowId);
|
|
749
|
+
if (wv != null && wv.CoreWebView2 != null)
|
|
750
|
+
{
|
|
751
|
+
bool canGoBack = wv.CoreWebView2.CanGoBack;
|
|
752
|
+
_ipcClient.Send(new IPCResponse
|
|
753
|
+
{
|
|
754
|
+
windowId = windowId,
|
|
755
|
+
@event = "canGoBack-reply-" + windowId,
|
|
756
|
+
data = new() { { "canGoBack", canGoBack.ToString().ToLower() } }
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
break;
|
|
761
|
+
|
|
762
|
+
case "canGoForward":
|
|
763
|
+
{
|
|
764
|
+
var wv = GetWebView(windowId);
|
|
765
|
+
if (wv != null && wv.CoreWebView2 != null)
|
|
766
|
+
{
|
|
767
|
+
bool canGoForward = wv.CoreWebView2.CanGoForward;
|
|
768
|
+
_ipcClient.Send(new IPCResponse
|
|
769
|
+
{
|
|
770
|
+
windowId = windowId,
|
|
771
|
+
@event = "canGoForward-reply-" + windowId,
|
|
772
|
+
data = new() { { "canGoForward", canGoForward.ToString().ToLower() } }
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
break;
|
|
777
|
+
|
|
778
|
+
case "getURL":
|
|
779
|
+
{
|
|
780
|
+
var wv = GetWebView(windowId);
|
|
781
|
+
if (wv != null && wv.CoreWebView2 != null)
|
|
782
|
+
{
|
|
783
|
+
string url = wv.CoreWebView2.Source;
|
|
784
|
+
_ipcClient.Send(new IPCResponse
|
|
785
|
+
{
|
|
786
|
+
windowId = windowId,
|
|
787
|
+
@event = "getURL-reply-" + windowId,
|
|
788
|
+
data = new() { { "url", url } }
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
break;
|
|
793
|
+
|
|
794
|
+
case "getTitle":
|
|
795
|
+
{
|
|
796
|
+
var wv = GetWebView(windowId);
|
|
797
|
+
if (wv != null && wv.CoreWebView2 != null)
|
|
798
|
+
{
|
|
799
|
+
string title = wv.CoreWebView2.DocumentTitle;
|
|
800
|
+
_ipcClient.Send(new IPCResponse
|
|
801
|
+
{
|
|
802
|
+
windowId = windowId,
|
|
803
|
+
@event = "getTitle-reply-" + windowId,
|
|
804
|
+
data = new() { { "title", title } }
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
break;
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
default:
|
|
812
|
+
Console.WriteLine($"[C#] Received command: {command} with args: {string.Join(", ", args)}");
|
|
813
|
+
var registry = ExtensionRegistry.GetExtensions();
|
|
814
|
+
if (registry.TryGetValue(command, out var handler))
|
|
815
|
+
handler(windowId, args);
|
|
816
|
+
else
|
|
817
|
+
error($"Unknown command '{command}' for window {windowId}");
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
private static WebView2? GetWebView(int windowId)
|
|
823
|
+
{
|
|
824
|
+
if (LayoutMap.TryGetValue(windowId, out var layout))
|
|
825
|
+
{
|
|
826
|
+
foreach (var child in layout.Children)
|
|
827
|
+
{
|
|
828
|
+
if (child is WebView2 wv) return wv;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// MARK: - Menu Management
|
|
835
|
+
|
|
836
|
+
private static void BuildAndAttachMenu(int windowId, string jsonStr)
|
|
837
|
+
{
|
|
838
|
+
if (!LayoutMap.TryGetValue(windowId, out var layout)) return;
|
|
839
|
+
|
|
840
|
+
RemoveMenu(windowId); // Clear old menu if any
|
|
841
|
+
|
|
842
|
+
var descriptor = JsonSerializer.Deserialize<List<JsonNode>>(jsonStr);
|
|
843
|
+
if (descriptor == null)
|
|
844
|
+
{
|
|
845
|
+
error("setMenu — invalid JSON descriptor");
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
var menu = new Menu();
|
|
850
|
+
DockPanel.SetDock(menu, Dock.Top);
|
|
851
|
+
|
|
852
|
+
foreach (var topLevel in descriptor)
|
|
853
|
+
{
|
|
854
|
+
var topItem = new MenuItem { Header = topLevel["label"]?.ToString() ?? "" };
|
|
855
|
+
menu.Items.Add(topItem);
|
|
856
|
+
|
|
857
|
+
var items = topLevel["items"]?.AsArray();
|
|
858
|
+
if (items != null)
|
|
859
|
+
PopulateMenu(topItem.Items, items, windowId, "menu-action");
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
layout.Children.Insert(0, menu); // Push menu to top of layout
|
|
863
|
+
MenuMap[windowId] = menu;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
private static void PopulateMenu(ItemCollection parentItems, JsonArray items, int windowId, string eventType = "menu-action")
|
|
867
|
+
{
|
|
868
|
+
foreach (var item in items)
|
|
869
|
+
{
|
|
870
|
+
if (item?["separator"]?.GetValue<bool>() == true)
|
|
871
|
+
{
|
|
872
|
+
parentItems.Add(new Separator());
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
var label = item?["label"]?.ToString() ?? "(untitled)";
|
|
877
|
+
var channel = item?["channel"]?.ToString() ?? "";
|
|
878
|
+
var payload = item?["payload"]?.ToString() ?? "null";
|
|
879
|
+
var enabled = item?["enabled"]?.GetValue<bool>() ?? true;
|
|
880
|
+
|
|
881
|
+
var menuItem = new MenuItem { Header = label, IsEnabled = enabled };
|
|
882
|
+
|
|
883
|
+
// Always attach a click handler — items with only a `click` callback (no channel)
|
|
884
|
+
// still need to send an event so Node can look up the handler by label.
|
|
885
|
+
menuItem.Click += (s, e) =>
|
|
886
|
+
{
|
|
887
|
+
_ipcClient.Send(new IPCResponse
|
|
888
|
+
{
|
|
889
|
+
windowId = windowId,
|
|
890
|
+
@event = eventType,
|
|
891
|
+
data = new() { { "channel", channel }, { "payload", payload }, { "label", label } }
|
|
892
|
+
});
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
var subItems = item?["items"]?.AsArray();
|
|
896
|
+
if (subItems != null && subItems.Count > 0)
|
|
897
|
+
PopulateMenu(menuItem.Items, subItems, windowId, eventType);
|
|
898
|
+
|
|
899
|
+
parentItems.Add(menuItem);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
private static void RemoveMenu(int windowId)
|
|
904
|
+
{
|
|
905
|
+
if (MenuMap.TryGetValue(windowId, out var menu) && LayoutMap.TryGetValue(windowId, out var layout))
|
|
906
|
+
{
|
|
907
|
+
layout.Children.Remove(menu);
|
|
908
|
+
MenuMap.Remove(windowId);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// MARK: - WebView IPC Handler
|
|
913
|
+
|
|
914
|
+
/// <summary>
|
|
915
|
+
/// Receives messages from renderer JS: window.chrome.webview.postMessage({...})
|
|
916
|
+
/// and forwards them upstream to Node over the WebSocket.
|
|
917
|
+
/// </summary>
|
|
918
|
+
private static void HandleWebViewIPC(int windowId, string rawJson)
|
|
919
|
+
{
|
|
920
|
+
try
|
|
921
|
+
{
|
|
922
|
+
var doc = JsonSerializer.Deserialize<JsonNode>(rawJson);
|
|
923
|
+
var channel = doc?["channel"]?.ToString();
|
|
924
|
+
|
|
925
|
+
if (channel == null)
|
|
926
|
+
{
|
|
927
|
+
error($"WebView IPC message malformed (windowId {windowId}): {rawJson}");
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Serialise payload back to a JSON string so it travels cleanly over the WebSocket
|
|
932
|
+
var payloadNode = doc?["payload"];
|
|
933
|
+
string payloadStr = payloadNode?.ToJsonString() ?? "null";
|
|
934
|
+
|
|
935
|
+
_ipcClient.Send(new IPCResponse
|
|
936
|
+
{
|
|
937
|
+
windowId = windowId,
|
|
938
|
+
@event = "ipcMessage",
|
|
939
|
+
data = new() { { "channel", channel }, { "payload", payloadStr } }
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
catch (Exception ex)
|
|
943
|
+
{
|
|
944
|
+
error($"Failed to handle WebView IPC message: {ex.Message}");
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// MARK: - Preload Script
|
|
949
|
+
|
|
950
|
+
private static string MakePreloadScript(int windowId)
|
|
951
|
+
{
|
|
952
|
+
return $@"(function () {{
|
|
953
|
+
if (window.__ipcInstalled) return;
|
|
954
|
+
window.__ipcInstalled = true;
|
|
955
|
+
|
|
956
|
+
const _listeners = {{}};
|
|
957
|
+
|
|
958
|
+
window.ipc = {{
|
|
959
|
+
/** Send a message to the Node/C# backend.
|
|
960
|
+
* @param {{string}} channel
|
|
961
|
+
* @param {{*}} payload — must be JSON-serialisable
|
|
962
|
+
*/
|
|
963
|
+
send(channel, payload = null) {{
|
|
964
|
+
if (typeof channel !== 'string') {{
|
|
965
|
+
console.warn('[ipc] send() failed: channel must be a string');
|
|
966
|
+
return;
|
|
967
|
+
}}
|
|
968
|
+
if (!payload) payload = {{}};
|
|
969
|
+
window.chrome.webview.postMessage({{ channel, payload }});
|
|
970
|
+
}},
|
|
971
|
+
|
|
972
|
+
/** Listen for a message pushed from the backend via ipc.emit().
|
|
973
|
+
* @param {{string}} channel
|
|
974
|
+
* @param {{Function}} listener
|
|
975
|
+
*/
|
|
976
|
+
on(channel, listener) {{
|
|
977
|
+
if (!_listeners[channel]) _listeners[channel] = [];
|
|
978
|
+
_listeners[channel].push(listener);
|
|
979
|
+
}},
|
|
980
|
+
|
|
981
|
+
/** Remove a previously registered listener. */
|
|
982
|
+
off(channel, listener) {{
|
|
983
|
+
if (!_listeners[channel]) return;
|
|
984
|
+
_listeners[channel] = _listeners[channel].filter(l => l !== listener);
|
|
985
|
+
}},
|
|
986
|
+
|
|
987
|
+
/** Called internally by C#'s ExecuteScriptAsync to deliver a push message. */
|
|
988
|
+
_emit(channel, payload) {{
|
|
989
|
+
(_listeners[channel] || []).forEach(fn => {{
|
|
990
|
+
try {{ fn(payload); }} catch(e) {{ console.error('[ipc] listener error:', e); }}
|
|
991
|
+
}});
|
|
992
|
+
}},
|
|
993
|
+
|
|
994
|
+
/** Window ID stamped in at injection time — useful for multi-window apps. */
|
|
995
|
+
windowId: {windowId},
|
|
996
|
+
}};
|
|
997
|
+
|
|
998
|
+
console.debug('[ipc] preload ready, windowId={windowId}');
|
|
999
|
+
}})();";
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// MARK: - IPC WebSocket Client
|
|
1004
|
+
|
|
1005
|
+
public class IPCClient
|
|
1006
|
+
{
|
|
1007
|
+
private readonly Uri _serverUri;
|
|
1008
|
+
private ClientWebSocket? _ws;
|
|
1009
|
+
private readonly CancellationTokenSource _cts = new();
|
|
1010
|
+
private int _reconnectAttempts = 0;
|
|
1011
|
+
private const int MaxReconnectAttempts = 10;
|
|
1012
|
+
private const int ReconnectDelayMs = 2000;
|
|
1013
|
+
|
|
1014
|
+
public IPCClient(Uri serverUri) => _serverUri = serverUri;
|
|
1015
|
+
|
|
1016
|
+
private static void error(string message)
|
|
1017
|
+
{
|
|
1018
|
+
string red = "\u001b[31m";
|
|
1019
|
+
string reset = "\u001b[0m";
|
|
1020
|
+
Console.WriteLine($"{red}[ERROR] {message}{reset}");
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
public async Task ConnectAsync(string authToken)
|
|
1024
|
+
{
|
|
1025
|
+
while (!_cts.IsCancellationRequested)
|
|
1026
|
+
{
|
|
1027
|
+
if (_reconnectAttempts >= MaxReconnectAttempts)
|
|
1028
|
+
{
|
|
1029
|
+
error($"Exceeded maximum reconnect attempts ({MaxReconnectAttempts}). Giving up.");
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
try
|
|
1034
|
+
{
|
|
1035
|
+
_ws = new ClientWebSocket();
|
|
1036
|
+
_ws.Options.SetRequestHeader("x-positron-auth-token", authToken);
|
|
1037
|
+
error($"INFO: Connecting to IPC server (attempt {_reconnectAttempts + 1})…");
|
|
1038
|
+
await _ws.ConnectAsync(_serverUri, _cts.Token);
|
|
1039
|
+
_reconnectAttempts = 0;
|
|
1040
|
+
error("INFO: Connected to IPC server.");
|
|
1041
|
+
await ReceiveLoopAsync();
|
|
1042
|
+
}
|
|
1043
|
+
catch (Exception ex)
|
|
1044
|
+
{
|
|
1045
|
+
_reconnectAttempts++;
|
|
1046
|
+
error($"WebSocket error: {ex.Message}. Reconnecting in {ReconnectDelayMs / 1000}s… (attempt {_reconnectAttempts}/{MaxReconnectAttempts})");
|
|
1047
|
+
await Task.Delay(ReconnectDelayMs);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
public void Send(IPCResponse response)
|
|
1053
|
+
{
|
|
1054
|
+
if (_ws == null || _ws.State != WebSocketState.Open) return;
|
|
1055
|
+
|
|
1056
|
+
try
|
|
1057
|
+
{
|
|
1058
|
+
var json = JsonSerializer.Serialize(response);
|
|
1059
|
+
var bytes = Encoding.UTF8.GetBytes(json);
|
|
1060
|
+
_ = _ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
|
1061
|
+
}
|
|
1062
|
+
catch (Exception ex)
|
|
1063
|
+
{
|
|
1064
|
+
error($"Failed to send IPC response: {ex.Message}");
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
private async Task ReceiveLoopAsync()
|
|
1069
|
+
{
|
|
1070
|
+
var buffer = new byte[1024 * 4];
|
|
1071
|
+
|
|
1072
|
+
while (_ws?.State == WebSocketState.Open)
|
|
1073
|
+
{
|
|
1074
|
+
using var ms = new MemoryStream();
|
|
1075
|
+
WebSocketReceiveResult result;
|
|
1076
|
+
do
|
|
1077
|
+
{
|
|
1078
|
+
result = await _ws.ReceiveAsync(new ArraySegment<byte>(buffer), _cts.Token);
|
|
1079
|
+
|
|
1080
|
+
if (result.MessageType == WebSocketMessageType.Close)
|
|
1081
|
+
{
|
|
1082
|
+
await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, _cts.Token);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
ms.Write(buffer, 0, result.Count);
|
|
1087
|
+
|
|
1088
|
+
} while (!result.EndOfMessage);
|
|
1089
|
+
|
|
1090
|
+
ms.Seek(0, SeekOrigin.Begin);
|
|
1091
|
+
using var reader = new StreamReader(ms, Encoding.UTF8);
|
|
1092
|
+
var messageText = await reader.ReadToEndAsync();
|
|
1093
|
+
|
|
1094
|
+
if (!string.IsNullOrWhiteSpace(messageText))
|
|
1095
|
+
ParseAndDispatch(messageText);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
private void ParseAndDispatch(string text)
|
|
1100
|
+
{
|
|
1101
|
+
try
|
|
1102
|
+
{
|
|
1103
|
+
var msg = JsonSerializer.Deserialize<IPCMessage>(text);
|
|
1104
|
+
if (msg == null) return;
|
|
1105
|
+
|
|
1106
|
+
_ = Application.Current.Dispatcher.InvokeAsync(async () =>
|
|
1107
|
+
{
|
|
1108
|
+
try
|
|
1109
|
+
{
|
|
1110
|
+
await App.HandleCommandAsync(msg.windowId, msg.command, msg.args);
|
|
1111
|
+
}
|
|
1112
|
+
catch (Exception ex)
|
|
1113
|
+
{
|
|
1114
|
+
error($"Failed to handle IPC command '{msg.command}': {ex.Message}\n{ex.StackTrace}");
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
catch (Exception ex)
|
|
1119
|
+
{
|
|
1120
|
+
error($"Failed to decode IPC message '{text}': {ex.Message}");
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|