arcane-os 0.92.0 → 0.93.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,167 @@
1
+ # Native application diagnostics
2
+
3
+ Native diagnostics retain errors from the actual packaged application's top-level
4
+ document in its native host session. Read them through the existing app-control
5
+ connection, without foregrounding a window, desktop input, or arbitrary script
6
+ execution. This is a developer inspection surface, separate from ordinary product
7
+ status and saved conversation history.
8
+
9
+ The Windows WebView2 host installs the fixed observer at document creation, before
10
+ the ordinary Core client and application scripts. It captures `window.error` and
11
+ `unhandledrejection` events and accepts explicit reports of application-caught
12
+ errors. It leaves the browser's ordinary error behavior intact. It does not
13
+ intercept console output, inspect application state, collect resource/request
14
+ bodies, or capture errors from other applications or frame documents.
15
+
16
+ ## Report a caught application error
17
+
18
+ ```js
19
+ import {reportNativeError} from 'arcane-os/core/native-diagnostics';
20
+
21
+ const receipt = await reportNativeError(error, {
22
+ details: {operation: 'source.load', sourceId}
23
+ });
24
+ // receipt: {accepted: true, sessionId, sequence}
25
+ ```
26
+
27
+ `error` is the actual caught value, including non-Error rejection values.
28
+ `details` is optional application-selected diagnostic context. Supply diagnostic
29
+ information only; credentials, secrets, user documents and provider/tool payloads
30
+ do not belong in this report. The SDK adds document/session metadata outside the
31
+ reported values and does not rewrite their text. Reporting does not change the
32
+ application's own handling of the original failure.
33
+
34
+ The promise resolves after the native host retains the record. A missing document
35
+ observer or native handler rejects with
36
+ `ARCANE_NATIVE_DIAGNOSTICS_UNAVAILABLE`. A negative host acknowledgement rejects
37
+ with `ARCANE_NATIVE_DIAGNOSTIC_REJECTED`, preserving the complete host reply in
38
+ `details` and its error in `cause`. Reflection, encoding and native transport
39
+ failures reject with their actual error. A lost reply can leave the caller's
40
+ outcome unknown; the SDK does not automatically retry and duplicate the report.
41
+
42
+ Automatic capture observes its own reporting failures through the existing
43
+ console, with the original error, without recursively manufacturing an unhandled
44
+ rejection. Console output remains ordinary console output; it is not retained
45
+ diagnostic history. Applications must observe explicit reporting promises.
46
+
47
+ ## Retrieve retained errors
48
+
49
+ Use the endpoint of the selected running app from its existing app-control launch
50
+ configuration. Connecting does not launch the application.
51
+
52
+ ```js
53
+ import {connectAppControl} from 'arcane-os/core/app-control';
54
+
55
+ const control = await connectAppControl({endpoint});
56
+ try {
57
+ const snapshot = await control.diagnostics();
58
+ console.dir(snapshot, {depth: null});
59
+ } finally {
60
+ await control.close();
61
+ }
62
+ ```
63
+
64
+ `control.diagnostics({afterSequence?, documentId?}, {signal?})` invokes
65
+ `app.control.diagnostics`. Omitted filters return every retained error.
66
+ `afterSequence` selects records with a greater session sequence;
67
+ `documentId` selects one actual document. Retrieval is non-consuming: independent
68
+ inspectors can read the same complete records. Connection/cancellation behavior is
69
+ the existing app-control contract. Retrieval does not wait for renderer, Core,
70
+ model or application readiness and does not evaluate a script in the document.
71
+
72
+ The result is:
73
+
74
+ ```js
75
+ {
76
+ sessionId,
77
+ captureStartedAt,
78
+ captureInstalled,
79
+ latestSequence,
80
+ documents: [{documentId, documentUrl, time, receivedAt}],
81
+ records: [{
82
+ sequence, receivedAt, documentId, documentUrl, time,
83
+ kind, error, details, valueFormat, values, location, message
84
+ }]
85
+ }
86
+ ```
87
+
88
+ `sessionId` identifies this native window/host lifetime. `captureStartedAt` is the
89
+ time its in-memory ledger was created. `captureInstalled` means document-start
90
+ script registration completed; it does not prove that any application failure
91
+ occurred or that a particular operation succeeded. `documents` records actual
92
+ ingress from observed documents. Each document has one identity, shared with app
93
+ control, independent of Core bridge generations. A cancelled navigation that
94
+ leaves the same document alive keeps that identity.
95
+
96
+ `sequence` is assigned when an error is retained, in host arrival order.
97
+ `latestSequence` describes the whole session even when filters select fewer
98
+ records. `time` comes from document capture; `receivedAt` comes from the host.
99
+ `kind` is `window.error`, `unhandledrejection`, or `application`. Window errors
100
+ include `location: {file, line, column}` when the browser exposes those fields.
101
+ Their browser `message` is retained separately from `error`, preserving a null
102
+ or undefined error value rather than replacing it with that message.
103
+ `details` and `location` are otherwise omitted.
104
+
105
+ Records survive document navigation and Core transport failure within the host
106
+ session. Closing the inspector leaves them retained. Closing the native host ends
107
+ the session; this API does not persist, migrate, prune, or recover records across
108
+ host restarts. An empty snapshot means no matching records were retained in this
109
+ session, not that earlier failures never occurred. Errors before capture existed,
110
+ handled failures never explicitly reported, errors the browser does not expose,
111
+ and vanished documents whose pending reports never reached the host remain
112
+ unavailable. Earlier never-retained failures cannot be reconstructed through this API.
113
+
114
+ ## Complete error property representation
115
+
116
+ Each record uses `valueFormat: 'arcane.diagnostic-value/1'`. `error` and optional
117
+ `details` are roots into the single `values` graph. JSON-native primitives remain
118
+ their original values. Undefined, bigint, non-finite numbers and negative zero
119
+ have explicit typed representations. `{ref: id}` identifies a graph node.
120
+ Repeated references and cycles retain the same identity within the record.
121
+
122
+ Object nodes retain every own property descriptor, including non-enumerable and
123
+ symbol-keyed fields. This includes full Error messages and stacks, `cause`,
124
+ AggregateError members and custom diagnostic details. Standard error fields
125
+ inherited from a prototype appear in `inherited`. Data descriptors contain
126
+ `value`; accessor descriptors contain references for `get` and `set` without
127
+ invoking either function. An accessor supplied as the `details` option is likewise
128
+ represented rather than called. No `toJSON` method on reported content is called.
129
+ Maps and sets retain their entries, dates their numeric time, and regular
130
+ expressions their source and flags, alongside their own properties.
131
+
132
+ This is a diagnostic property representation, not an executable object snapshot:
133
+ functions retain their property identity, not executable closures; platform-private
134
+ internal state and private class fields are not observable properties. The host
135
+ stores and returns the graph without reconstructing or executing those objects.
136
+ An object that rejects reflection causes an observable reporting failure rather
137
+ than a silently incomplete success. Inspect the complete graph in developer
138
+ diagnostics; do not flatten it into ordinary chat or user-facing status.
139
+
140
+ ## Host ownership and availability
141
+
142
+ `installNativeDiagnostics(globalThis)` installs one observer per document and
143
+ returns its owner `{documentId, ready, report}`. `ready` resolves after the document
144
+ announcement is accepted, and `report` has the same report contract. Importing
145
+ the ESM module alone installs no observer. The packaged native host owns
146
+ installation and lifetime; ordinary applications use `reportNativeError`.
147
+
148
+ `createNativeDiagnosticsSource()` from
149
+ `arcane-os/core/native-diagnostics-source` projects that same canonical helper
150
+ into the top-level document-start script. It adds no second observer or serializer.
151
+ Windows binds the script to the dedicated `arcaneDiagnostics.Send` host object.
152
+ This route is independent of `arcaneBridge` and the Core subprocess. Host close
153
+ stops new ingress and removes the object/script during existing shutdown.
154
+
155
+ The implementation here supplies Windows WebView2 capture and app-control
156
+ retrieval. macOS, Linux and Android require their native host ingress/session and
157
+ app-control adapters; this source change does not claim those adapters exist.
158
+ The helper recognizes the same fixed-name WebKit reply handler when a host
159
+ provides it; that transport seam is not platform availability. A browser preview
160
+ cannot establish actual native acceptance.
161
+
162
+ Native delivery requires the corresponding rebuilt SDK Windows host asset and
163
+ consumer adoption through the published SDK package. Source inspection, authored
164
+ fixtures and configured capture are distinct from executed built-application
165
+ verification. Existing host stderr/log diagnostics, live Core events, readiness
166
+ replay and `Arcane.diagnostics.recentErrors/get` retain their existing semantics;
167
+ this API does not turn them into a historical error service.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.92.0",
3
+ "version": "0.93.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -65,6 +65,8 @@
65
65
  "./core/stdio": "./src/core/stdio.mjs",
66
66
  "./core/host": "./src/core/host.mjs",
67
67
  "./core/app-control": "./src/core/app-control.mjs",
68
+ "./core/native-diagnostics": "./browser-runtime/core/native-diagnostics.mjs",
69
+ "./core/native-diagnostics-source": "./src/core-native-diagnostics-source.mjs",
68
70
  "./codex/app-server": "./src/codex/app-server.mjs",
69
71
  "./mcp/stdio": "./src/mcp/stdio-server.mjs",
70
72
  "./core/repositories": "./src/core/repositories.mjs",
@@ -15,6 +15,7 @@ export async function connectAppControl({endpoint, signal, onError = reportAppCo
15
15
  endpoint,
16
16
  closed: connection.closed,
17
17
  status(options) { return client.invoke('app.control.status', {}, options); },
18
+ diagnostics(parameters = {}, options) { return client.invoke('app.control.diagnostics', parameters, options); },
18
19
  inspect(parameters = {}, options) { return client.invoke('app.control.inspect', parameters, options); },
19
20
  capture(parameters = {}, options) { return client.invoke('app.control.capture', parameters, options); },
20
21
  act(parameters, options) { return client.invoke('app.control.act', parameters, options); },
@@ -452,7 +452,7 @@ namespace Arcane.Core.Hosts.Windows
452
452
  try
453
453
  {
454
454
  cancellation.Token.ThrowIfCancellationRequested();
455
- if (method != "app.control.status" && method != "app.control.inspect"
455
+ if (method != "app.control.status" && method != "app.control.diagnostics" && method != "app.control.inspect"
456
456
  && method != "app.control.capture" && method != "app.control.act"
457
457
  && method != "app.control.key" && method != "app.control.resize")
458
458
  throw new NotSupportedException("The app does not expose " + method + ".");
@@ -24,6 +24,7 @@ namespace Arcane.Core.Hosts.Windows
24
24
  public string Title { get; set; }
25
25
  public string IconPath { get; set; }
26
26
  public string ClassicClientSource { get; set; }
27
+ public string NativeDiagnosticsSource { get; set; }
27
28
  public string AppControlEndpoint { get; set; }
28
29
  public string AppControlSource { get; set; }
29
30
  public string CoreExecutable { get; set; }
@@ -102,15 +103,20 @@ namespace Arcane.Core.Hosts.Windows
102
103
  private readonly Action<string> onDiagnostic;
103
104
  private readonly Action<Exception> onError;
104
105
  private readonly WebView2 webView;
106
+ private readonly ArcaneNativeDiagnostics nativeDiagnostics;
105
107
  private readonly object stateLock = new object();
106
108
  private readonly Queue<CoreDelivery> pending = new Queue<CoreDelivery>();
107
109
  private readonly Dictionary<string, long> requestGenerations = new Dictionary<string, long>();
110
+ private readonly Dictionary<string, DesktopNotificationRequest> desktopNotificationRequests =
111
+ new Dictionary<string, DesktopNotificationRequest>();
108
112
  private readonly List<Exception> failures = new List<Exception>();
109
113
  private readonly List<Task> failureNotifications = new List<Task>();
110
114
  private readonly TaskCompletionSource<object> ready = new TaskCompletionSource<object>();
111
115
  private readonly TaskCompletionSource<object> initialized = new TaskCompletionSource<object>();
112
116
  private readonly TaskCompletionSource<object> completion = new TaskCompletionSource<object>();
113
117
  private ArcaneCoreProcess core;
118
+ private ArcaneNotifications desktopNotifications;
119
+ private Task desktopNotificationsClosing;
114
120
  private ArcaneBridge bridge;
115
121
  private ArcaneBridge activeBridge;
116
122
  private Task initialization;
@@ -119,6 +125,8 @@ namespace Arcane.Core.Hosts.Windows
119
125
  private Task iconLoading;
120
126
  private Icon applicationIcon;
121
127
  private string injectedScript;
128
+ private string nativeDiagnosticsScript;
129
+ private bool nativeDiagnosticsRegistered;
122
130
  private Exception transportFailure;
123
131
  private long generation;
124
132
  private long nextGeneration;
@@ -144,6 +152,7 @@ namespace Arcane.Core.Hosts.Windows
144
152
  this.options = options;
145
153
  this.onDiagnostic = onDiagnostic;
146
154
  this.onError = onError;
155
+ nativeDiagnostics = new ArcaneNativeDiagnostics(Report);
147
156
  ready.Task.ContinueWith(ObserveReportedFailure, TaskContinuationOptions.OnlyOnFaulted);
148
157
  completion.Task.ContinueWith(ObserveReportedFailure, TaskContinuationOptions.OnlyOnFaulted);
149
158
  Text = options.Title;
@@ -494,6 +503,10 @@ namespace Arcane.Core.Hosts.Windows
494
503
  browser.SetVirtualHostNameToFolderMapping(options.OriginHost, applicationRoot,
495
504
  CoreWebView2HostResourceAccessKind.Allow);
496
505
  InstallDocumentBridge(generation);
506
+ // Register the diagnostic observer first so failures in the
507
+ // ordinary client itself use an ingress independent of Core.
508
+ await InstallNativeDiagnosticsAsync(browser);
509
+ if (closing) return;
497
510
  // The selected generator uses replayRuntimeState:true. Both RPC
498
511
  // installation and the shared event owner remain SDK-owned.
499
512
  Task scripts = Task.WhenAll(InstallClassicClientAsync(browser), InstallAppControlAsync(browser));
@@ -523,6 +536,15 @@ namespace Arcane.Core.Hosts.Windows
523
536
  injectedScript = await browser.AddScriptToExecuteOnDocumentCreatedAsync(options.ClassicClientSource);
524
537
  }
525
538
 
539
+ private async Task InstallNativeDiagnosticsAsync(CoreWebView2 browser)
540
+ {
541
+ if (String.IsNullOrEmpty(options.NativeDiagnosticsSource)) return;
542
+ browser.AddHostObjectToScript("arcaneDiagnostics", nativeDiagnostics);
543
+ nativeDiagnosticsRegistered = true;
544
+ nativeDiagnosticsScript = await browser.AddScriptToExecuteOnDocumentCreatedAsync(options.NativeDiagnosticsSource);
545
+ nativeDiagnostics.Installed();
546
+ }
547
+
526
548
  private void NavigationStarting(object sender, CoreWebView2NavigationStartingEventArgs args)
527
549
  {
528
550
  if (nextGeneration != 0 && navigationId == args.NavigationId) return;
@@ -561,6 +583,33 @@ namespace Arcane.Core.Hosts.Windows
561
583
  if (documentGeneration != generation || closing)
562
584
  throw new InvalidOperationException("The requesting document is no longer connected.");
563
585
  string method = EnvelopeString(envelope, "method");
586
+ if (EnvelopeString(envelope, "protocol") == "arcane/1")
587
+ {
588
+ if (requestId != null && (method == "notifications.status" || method == "notifications.show"
589
+ || method == "notifications.state" || method == "notifications.close"))
590
+ {
591
+ DesktopNotificationRequest operation = new DesktopNotificationRequest(documentGeneration);
592
+ desktopNotificationRequests.Add(requestId, operation);
593
+ operation.Completion = CompleteDesktopNotificationAsync(envelope, requestId, method, operation);
594
+ operation.Completion.ContinueWith(ObserveUnexpectedFailure, TaskContinuationOptions.OnlyOnFaulted);
595
+ // Bridge acceptance means this host owns the request;
596
+ // the correlated response carries its native outcome.
597
+ return Task.FromResult<object>(null);
598
+ }
599
+ if (EnvelopeString(envelope, "type") == "control")
600
+ {
601
+ string control = EnvelopeString(envelope, "control");
602
+ DesktopNotificationRequest selected;
603
+ if (control == "request.cancel" && desktopNotificationRequests.TryGetValue(
604
+ EnvelopeString(envelope, "requestId") ?? String.Empty, out selected)
605
+ && selected.Generation == documentGeneration)
606
+ {
607
+ selected.Cancellation.Cancel();
608
+ return Task.FromResult<object>(null);
609
+ }
610
+ if (control == "requests.cancelAll") CancelDesktopNotificationRequests(documentGeneration);
611
+ }
612
+ }
564
613
  if (requestId != null && EnvelopeString(envelope, "protocol") == "arcane/1"
565
614
  && (method == "window.setTheme" || method == "window.state" || method == "window.setState"))
566
615
  {
@@ -584,6 +633,115 @@ namespace Arcane.Core.Hosts.Windows
584
633
  }
585
634
  }
586
635
 
636
+ private async Task CompleteDesktopNotificationAsync(Dictionary<string, object> request,
637
+ string id, string method, DesktopNotificationRequest operation)
638
+ {
639
+ try
640
+ {
641
+ Dictionary<string, object> response = new Dictionary<string, object>
642
+ {
643
+ { "protocol", "arcane/1" }, { "type", "response" }, { "id", id }
644
+ };
645
+ try
646
+ {
647
+ object value;
648
+ Dictionary<string, object> parameters = request.TryGetValue("parameters", out value)
649
+ ? value as Dictionary<string, object> : new Dictionary<string, object>();
650
+ if (parameters == null) throw new ArgumentException("Supply a notification request object.");
651
+ if (desktopNotifications == null)
652
+ desktopNotifications = new ArcaneNotifications(options.ApplicationId, options.ProfileDirectory,
653
+ options.Title, options.IconPath, DesktopNotificationChanged);
654
+ Dictionary<string, object> result = await desktopNotifications.InvokeAsync(
655
+ method, parameters, operation.Cancellation.Token).ConfigureAwait(false);
656
+ // Cancellation after native submission does not rewrite an
657
+ // accepted result. Its record also remains in host state.
658
+ response["ok"] = true;
659
+ response["result"] = result;
660
+ }
661
+ catch (Exception error)
662
+ {
663
+ string code = error.Data["code"] as string;
664
+ if (code == null) code = error is OperationCanceledException ? "CANCELLED"
665
+ : error is ArgumentException ? "INVALID_ARGUMENT" : "ARCANE_NOTIFICATION_FAILED";
666
+ response["ok"] = false;
667
+ response["error"] = ArcaneHost.ErrorRecord(error, code);
668
+ }
669
+ response["time"] = DateTime.UtcNow.ToString("o");
670
+ string json = ArcaneHost.Serializer().Serialize(response);
671
+ lock (stateLock) pending.Enqueue(new CoreDelivery(operation.Generation, json));
672
+ ScheduleDelivery();
673
+ }
674
+ catch (Exception error)
675
+ {
676
+ // A bridge-accepted request must receive its response or the
677
+ // owning transport failure, including serialization failures.
678
+ CoreFailed(error);
679
+ }
680
+ finally
681
+ {
682
+ lock (stateLock)
683
+ {
684
+ desktopNotificationRequests.Remove(id);
685
+ operation.Cancellation.Dispose();
686
+ }
687
+ }
688
+ }
689
+
690
+ private void DesktopNotificationChanged(Dictionary<string, object> record)
691
+ {
692
+ string json = ArcaneHost.Serializer().Serialize(new Dictionary<string, object>
693
+ {
694
+ { "protocol", "arcane/1" }, { "type", "event" },
695
+ { "event", "notifications.state" }, { "data", record }, { "time", DateTime.UtcNow.ToString("o") }
696
+ });
697
+ lock (stateLock) pending.Enqueue(new CoreDelivery(generation, json));
698
+ ScheduleDelivery();
699
+ }
700
+
701
+ private void CancelDesktopNotificationRequests(long? documentGeneration)
702
+ {
703
+ lock (stateLock)
704
+ {
705
+ // Cancellation can settle a queued operation synchronously;
706
+ // iterate a snapshot while its completion removes correlation.
707
+ foreach (DesktopNotificationRequest operation in
708
+ new List<DesktopNotificationRequest>(desktopNotificationRequests.Values))
709
+ if (!documentGeneration.HasValue || operation.Generation == documentGeneration.Value)
710
+ operation.Cancellation.Cancel();
711
+ }
712
+ }
713
+
714
+ private void StopDesktopNotifications()
715
+ {
716
+ CancelDesktopNotificationRequests(null);
717
+ if (desktopNotifications == null || desktopNotificationsClosing != null) return;
718
+ try { desktopNotificationsClosing = desktopNotifications.CloseAsync(); }
719
+ catch (Exception error) { Report(error); }
720
+ }
721
+
722
+ private async Task DrainDesktopNotificationsAsync()
723
+ {
724
+ List<Task> operations = new List<Task>();
725
+ lock (stateLock)
726
+ {
727
+ foreach (DesktopNotificationRequest operation in desktopNotificationRequests.Values)
728
+ operations.Add(operation.Completion);
729
+ }
730
+ if (desktopNotificationsClosing != null) operations.Add(desktopNotificationsClosing);
731
+ Task settled = Task.WhenAll(operations);
732
+ try { await settled; }
733
+ catch (Exception error) { ReportTaskFailure(settled, error); }
734
+ }
735
+
736
+ private sealed class DesktopNotificationRequest
737
+ {
738
+ internal readonly long Generation;
739
+ internal readonly CancellationTokenSource Cancellation = new CancellationTokenSource();
740
+ internal Task Completion;
741
+
742
+ internal DesktopNotificationRequest(long generation) { Generation = generation; }
743
+ }
744
+
587
745
  private async Task LoadApplicationIconAsync()
588
746
  {
589
747
  if (String.IsNullOrEmpty(options.IconPath)) return;
@@ -762,12 +920,14 @@ namespace Arcane.Core.Hosts.Windows
762
920
  private void DocumentConnected(long documentGeneration)
763
921
  {
764
922
  ArcaneBridge previous;
923
+ long previousGeneration;
765
924
  lock (stateLock)
766
925
  {
767
926
  if (closing) return;
768
927
  if (documentGeneration == generation && documentReady) return;
769
928
  if (documentGeneration != registeredGeneration) return;
770
929
  previous = activeBridge;
930
+ previousGeneration = generation;
771
931
  generation = documentGeneration;
772
932
  activeBridge = bridge;
773
933
  documentReady = true;
@@ -775,6 +935,7 @@ namespace Arcane.Core.Hosts.Windows
775
935
  if (previous != null && previous != activeBridge)
776
936
  {
777
937
  previous.Stop(new OperationCanceledException("The previous document has navigated away."));
938
+ CancelDesktopNotificationRequests(previousGeneration);
778
939
  if (transportFailure == null)
779
940
  {
780
941
  try { ObserveSend(core.SendAsync(CancelRendererRequests)); }
@@ -960,6 +1121,7 @@ namespace Arcane.Core.Hosts.Windows
960
1121
  if (IsDisposed) return;
961
1122
  if (bridge != null) bridge.Stop(transportFailure);
962
1123
  if (activeBridge != null) activeBridge.Stop(transportFailure);
1124
+ CancelDesktopNotificationRequests(null);
963
1125
  NotifyTransportFailure();
964
1126
  if (core != null) core.CloseAsync();
965
1127
  ScheduleDelivery();
@@ -1010,10 +1172,12 @@ namespace Arcane.Core.Hosts.Windows
1010
1172
  {
1011
1173
  if (closing) return;
1012
1174
  lock (stateLock) closing = true;
1175
+ nativeDiagnostics.Stop();
1013
1176
  if (!started) initialized.TrySetResult(null);
1014
1177
  if (bridge != null) bridge.Stop(new InvalidOperationException("The application window is closing."));
1015
1178
  if (activeBridge != null) activeBridge.Stop(new InvalidOperationException("The application window is closing."));
1016
1179
  StopAppControl();
1180
+ StopDesktopNotifications();
1017
1181
  WindowStateTransition.ReleasePending(this);
1018
1182
  shutdown = DrainAndCloseAsync();
1019
1183
  shutdown.ContinueWith(ObserveUnexpectedFailure, TaskContinuationOptions.OnlyOnFaulted);
@@ -1042,6 +1206,7 @@ namespace Arcane.Core.Hosts.Windows
1042
1206
  }
1043
1207
  }
1044
1208
  await DrainAppControlAsync();
1209
+ await DrainDesktopNotificationsAsync();
1045
1210
  // Joining the independent image owner belongs after Core input has
1046
1211
  // closed; image I/O must never delay accepted service work draining.
1047
1212
  if (iconLoading != null)
@@ -1069,6 +1234,8 @@ namespace Arcane.Core.Hosts.Windows
1069
1234
  if (!browserProcessExited && webView.CoreWebView2 != null)
1070
1235
  {
1071
1236
  webView.CoreWebView2.RemoveHostObjectFromScript("arcaneBridge");
1237
+ if (nativeDiagnosticsRegistered) webView.CoreWebView2.RemoveHostObjectFromScript("arcaneDiagnostics");
1238
+ if (nativeDiagnosticsScript != null) webView.CoreWebView2.RemoveScriptToExecuteOnDocumentCreated(nativeDiagnosticsScript);
1072
1239
  if (injectedScript != null) webView.CoreWebView2.RemoveScriptToExecuteOnDocumentCreated(injectedScript);
1073
1240
  if (appControlScript != null) webView.CoreWebView2.RemoveScriptToExecuteOnDocumentCreated(appControlScript);
1074
1241
  }
@@ -101,6 +101,7 @@ namespace Arcane.Core.Hosts.Windows
101
101
  InitialWindowState = OptionalWindowState(windowOptions),
102
102
  IconPath = String.IsNullOrEmpty(icon) ? null : Path.Combine(directory, icon),
103
103
  ClassicClientSource = File.ReadAllText(Path.Combine(directory, RequiredString(client, "source"))),
104
+ NativeDiagnosticsSource = File.ReadAllText(Path.Combine(directory, "arcane-native-diagnostics.js")),
104
105
  AppControlEndpoint = appControl == null ? null : RequiredString(appControl, "endpoint"),
105
106
  AppControlSource = appControl == null ? null : File.ReadAllText(Path.Combine(directory, "arcane-app-control.js")),
106
107
  CoreExecutable = Path.Combine(directory, "runtime", "ArcaneCore.exe"),
@@ -0,0 +1,137 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.IO;
4
+ using System.Runtime.InteropServices;
5
+
6
+ namespace Arcane.Core.Hosts.Windows
7
+ {
8
+ /// <summary>
9
+ /// One host-session ledger, independent of Core and renderer readiness.
10
+ /// The dedicated document-start ingress acknowledges only retained records.
11
+ /// Diagnostic property graphs are data; the host never evaluates their fields.
12
+ /// </summary>
13
+ [ComVisible(true)]
14
+ [ClassInterface(ClassInterfaceType.AutoDual)]
15
+ public sealed class ArcaneNativeDiagnostics
16
+ {
17
+ private readonly object stateLock = new object();
18
+ private readonly string sessionId = Guid.NewGuid().ToString();
19
+ private readonly string captureStartedAt = DateTime.UtcNow.ToString("o");
20
+ private readonly List<Dictionary<string, object>> records = new List<Dictionary<string, object>>();
21
+ private readonly Dictionary<string, Dictionary<string, object>> documents =
22
+ new Dictionary<string, Dictionary<string, object>>();
23
+ private readonly Action<Exception> onFailure;
24
+ private long sequence;
25
+ private bool captureInstalled;
26
+ private bool stopped;
27
+
28
+ internal ArcaneNativeDiagnostics(Action<Exception> onFailure) { this.onFailure = onFailure; }
29
+
30
+ public string Send(string json)
31
+ {
32
+ try
33
+ {
34
+ Dictionary<string, object> frame = ArcaneHost.Serializer().DeserializeObject(json) as Dictionary<string, object>;
35
+ if (frame == null || Text(frame, "protocol") != "arcane.native-diagnostics/1")
36
+ throw new InvalidDataException("Expected an Arcane native diagnostic envelope.");
37
+ string type = Text(frame, "type");
38
+ string documentId = Text(frame, "documentId");
39
+ string documentUrl = Text(frame, "documentUrl");
40
+ string time = Text(frame, "time");
41
+ if (documentId == null || documentUrl == null || time == null)
42
+ throw new InvalidDataException("The diagnostic envelope needs its document identity, URL and time.");
43
+ object value;
44
+ Dictionary<string, object> record = frame.TryGetValue("record", out value)
45
+ ? value as Dictionary<string, object> : null;
46
+ if (type != "document" && (type != "error" || record == null))
47
+ throw new InvalidDataException("Expected a document announcement or an error record.");
48
+
49
+ Dictionary<string, object> result = new Dictionary<string, object>
50
+ {
51
+ { "accepted", true }, { "sessionId", sessionId }
52
+ };
53
+ lock (stateLock)
54
+ {
55
+ if (stopped) throw new InvalidOperationException("The native diagnostic session is closing.");
56
+ string receivedAt = DateTime.UtcNow.ToString("o");
57
+ if (!documents.ContainsKey(documentId))
58
+ documents.Add(documentId, new Dictionary<string, object>
59
+ {
60
+ { "documentId", documentId }, { "documentUrl", documentUrl },
61
+ { "time", time }, { "receivedAt", receivedAt }
62
+ });
63
+ if (type == "error")
64
+ {
65
+ Dictionary<string, object> retained = new Dictionary<string, object>(record);
66
+ retained["sequence"] = ++sequence;
67
+ retained["receivedAt"] = receivedAt;
68
+ retained["documentId"] = documentId;
69
+ retained["documentUrl"] = documentUrl;
70
+ retained["time"] = time;
71
+ records.Add(retained);
72
+ result["sequence"] = sequence;
73
+ }
74
+ }
75
+ return ArcaneHost.Serializer().Serialize(result);
76
+ }
77
+ catch (Exception error)
78
+ {
79
+ onFailure(error);
80
+ return ArcaneHost.Serializer().Serialize(new Dictionary<string, object>
81
+ {
82
+ { "accepted", false },
83
+ { "error", ArcaneHost.ErrorRecord(error, "ARCANE_NATIVE_DIAGNOSTIC_CAPTURE_FAILED") }
84
+ });
85
+ }
86
+ }
87
+
88
+ internal object Snapshot(Dictionary<string, object> parameters)
89
+ {
90
+ object selected;
91
+ long afterSequence = 0;
92
+ if (parameters.TryGetValue("afterSequence", out selected))
93
+ {
94
+ if (!(selected is int || selected is long || selected is decimal || selected is double))
95
+ throw new ArgumentException("afterSequence must be a nonnegative integer.");
96
+ afterSequence = Convert.ToInt64(selected);
97
+ if (afterSequence < 0 || Convert.ToDouble(selected) != afterSequence)
98
+ throw new ArgumentException("afterSequence must be a nonnegative integer.");
99
+ }
100
+ string documentId = null;
101
+ if (parameters.TryGetValue("documentId", out selected))
102
+ {
103
+ documentId = selected as string;
104
+ if (documentId == null) throw new ArgumentException("documentId must be a string.");
105
+ }
106
+ lock (stateLock)
107
+ {
108
+ List<object> found = new List<object>();
109
+ foreach (Dictionary<string, object> record in records)
110
+ {
111
+ if ((long)record["sequence"] <= afterSequence) continue;
112
+ if (documentId != null && (string)record["documentId"] != documentId) continue;
113
+ found.Add(record);
114
+ }
115
+ List<object> observedDocuments = new List<object>();
116
+ foreach (KeyValuePair<string, Dictionary<string, object>> document in documents)
117
+ if (documentId == null || document.Key == documentId) observedDocuments.Add(document.Value);
118
+ return new Dictionary<string, object>
119
+ {
120
+ { "sessionId", sessionId }, { "captureStartedAt", captureStartedAt },
121
+ { "captureInstalled", captureInstalled }, { "latestSequence", sequence },
122
+ { "documents", observedDocuments }, { "records", found }
123
+ };
124
+ }
125
+ }
126
+
127
+ internal void Installed() { lock (stateLock) captureInstalled = true; }
128
+
129
+ internal void Stop() { lock (stateLock) stopped = true; }
130
+
131
+ private static string Text(Dictionary<string, object> record, string key)
132
+ {
133
+ object value;
134
+ return record.TryGetValue(key, out value) ? value as string : null;
135
+ }
136
+ }
137
+ }