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.
@@ -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
+ }