arcane-os 0.92.0 → 0.94.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/browser-runtime/ai/twin-cloud.mjs +109 -46
  4. package/browser-runtime/core/client.mjs +20 -0
  5. package/browser-runtime/core/contracts.mjs +20 -0
  6. package/browser-runtime/core/native-diagnostics.mjs +217 -0
  7. package/docs/reference/ai/twin-cloud.md +35 -14
  8. package/docs/reference/availability-and-normalization.md +1 -1
  9. package/docs/reference/core/arcane-api.md +32 -0
  10. package/docs/reference/core/arcane-events.md +25 -0
  11. package/docs/reference/core-client.md +99 -0
  12. package/docs/reference/inventory/package-api.json +97 -13
  13. package/docs/reference/local-ai.md +81 -5
  14. package/docs/reference/native-diagnostics.md +167 -0
  15. package/docs/reference/native-speech.md +220 -11
  16. package/docs/reference/runtime-modules.md +21 -18
  17. package/docs/reference/sdk-api.md +152 -11
  18. package/package.json +4 -1
  19. package/schemas/arcane-app.schema.json +2 -2
  20. package/schemas/arcane-package.schema.json +2 -2
  21. package/src/core/app-control.mjs +1 -0
  22. package/src/core/hosts/windows/ArcaneAppControl.cs +1 -1
  23. package/src/core/hosts/windows/ArcaneHost.cs +167 -0
  24. package/src/core/hosts/windows/ArcaneLauncher.cs +1 -0
  25. package/src/core/hosts/windows/ArcaneNativeDiagnostics.cs +137 -0
  26. package/src/core/hosts/windows/ArcaneNotifications.cs +822 -0
  27. package/src/core/hosts/windows/ArcaneWindowControl.cs +3 -2
  28. package/src/core/services/speech.mjs +85 -23
  29. package/src/core-native-diagnostics-source.mjs +13 -0
  30. package/src/import-map.mjs +1 -0
  31. package/src/local-ai/config.mjs +12 -1
  32. package/src/local-ai/dev.mjs +71 -4
  33. package/src/local-ai/install.mjs +113 -5
  34. package/src/local-ai/kokoro/frontend.mjs +219 -0
  35. package/src/local-ai/kokoro/helper.mjs +168 -0
  36. package/src/local-ai/kokoro/index.mjs +645 -0
  37. package/src/local-ai/kokoro/native/CMakeLists.txt +45 -0
  38. package/src/local-ai/kokoro/native/main.cpp +249 -0
  39. package/src/native/portable-layout.mjs +67 -9
@@ -82,6 +82,7 @@ runtime layouts.
82
82
  | `arcane-os/core/image` | Native image Core service with independent runtime preparation and retained model ownership. |
83
83
  | `arcane-os/core/speech` | Independent injected STT/TTS engine loading, request cancellation and joined Core service lifetime. |
84
84
  | `arcane-os/local-ai/whisper` | Retained native Whisper model, complete recordings and joined helper lifetime. |
85
+ | `arcane-os/local-ai/kokoro` | Retained native Kokoro synthesis using the existing ONNX/model-resource owners and a compiled phonemization/Opus helper. |
85
86
  | `arcane-os/local-ai/whisper/build` | First-party persistent helper build against selected installed Whisper headers and libraries. |
86
87
  | `arcane-os/local-ai/whisper/openvino-build` | Optional Windows x64 Intel NPU encoder runtime producer using selected installed sources and toolchain. |
87
88
  | `arcane-os/ai/core-model-assets` | Complete stored browser model members projected through Core into native working files. |
@@ -469,6 +470,12 @@ The remaining data-only subpaths are eight JSON Schemas and package metadata.
469
470
  | `createLocalImageService()` | function | `arcane-os/core/image` | Core local images | Node Core with selected stable-diffusion.cpp requirements |
470
471
  | `createLocalImageService default export` | function | `arcane-os/core/image` | Core local images | Same native image service factory |
471
472
  | `createSpeechService()` | function | `arcane-os/core/speech` | Core native speech | Node Core with explicitly supplied speech engines |
473
+ | `createNativeKokoroRuntime()` | function | `arcane-os/local-ai/kokoro` | Core native speech | Node with matching prepared helper/data, selected model resources and an existing ONNX owner |
474
+ | `createNativeKokoroRuntime default export` | function | `arcane-os/local-ai/kokoro` | Core native speech | Same retained native Kokoro factory |
475
+ | `KOKORO_MODEL` | constant | `arcane-os/local-ai/kokoro` | Core native speech | Selected upstream model identifier |
476
+ | `KOKORO_REVISION` | constant | `arcane-os/local-ai/kokoro` | Core native speech | Selected upstream model revision |
477
+ | `KOKORO_SAMPLE_RATE` | constant | `arcane-os/local-ai/kokoro` | Core native speech | Native PCM sample rate |
478
+ | `KOKORO_VOICES` | constant | `arcane-os/local-ai/kokoro` | Core native speech | Complete 28-voice US/GB selection |
472
479
  | `createWhisperRuntime()` | function | `arcane-os/local-ai/whisper` | Core native speech | Node with a prepared matching helper, runtime, decoder and selected model |
473
480
  | `createWhisperRuntime default export` | function | `arcane-os/local-ai/whisper` | Core native speech | Same retained native engine factory |
474
481
  | `buildWhisperHelper()` | function | `arcane-os/local-ai/whisper/build` | Core native speech | Node with installed CMake/C++17 toolchain and matching Whisper source/runtime |
@@ -8842,6 +8849,118 @@ selected model and native engine.
8842
8849
  The default export from `arcane-os/core/image` is the same
8843
8850
  `createLocalImageService` factory and lifecycle described above.
8844
8851
 
8852
+ ## createNativeKokoroRuntime()
8853
+
8854
+ ### Overview and signature
8855
+
8856
+ The named export from `arcane-os/local-ai/kokoro` composes the selected Kokoro
8857
+ FP32 graph with the host's existing native ONNX owner, model-resource owner
8858
+ and prepared persistent phonemization/Opus helper. Construction starts no
8859
+ native work and acquires no model or runtime.
8860
+
8861
+ ```javascript
8862
+ createNativeKokoroRuntime({
8863
+ onnx, modelAssets, runtime, prepare, modelId = 'kokoro',
8864
+ model = KOKORO_MODEL, revision = KOKORO_REVISION, dtype = 'fp32',
8865
+ paths, assetProjectionId, resourcePaths, sessionOptions,
8866
+ executionPreference = 'gpu', executionTarget, signal, onEvent
8867
+ } = {})
8868
+ ```
8869
+
8870
+ Supply the existing `onnx` runtime or a `prepare` callback that provides it.
8871
+ `runtime` describes the matching native helper and data. `paths` supplies
8872
+ complete prepared graph/tokenizer/voice resources; a completed
8873
+ `assetProjectionId` instead retains projected resources through `modelAssets`.
8874
+ The selected model/revision/FP32 contract is fixed by this frontend.
8875
+ See [native Kokoro](native-speech.md) for exact resource mappings, preparation,
8876
+ runtime distribution and model ownership.
8877
+
8878
+ ### Members and results
8879
+
8880
+ - `current()` returns complete model/lifecycle state, execution configuration,
8881
+ current request, queue, progress, error, default voice and supported voices.
8882
+ - `subscribe(listener,{replay=true,signal}={})` returns an unsubscribe function
8883
+ and replays current state by default.
8884
+ - `load(options={})` activates the selected resources, retaining omitted
8885
+ selections. Explicit projection/resource mapping or execution-target changes
8886
+ join prior native retirement before loading the new activation.
8887
+ - `synthesize(request,{signal,onProgress,requestId=null}={})` requires a loaded
8888
+ activation. `request` contains the complete `input` string, optional `model`
8889
+ matching `modelId`, `voice` (default `af_heart`), positive finite `speed`
8890
+ (default `1`) and `responseFormat` (`wav` by default, `opus` or `ogg`).
8891
+ The result is `{audioBase64,contentType,sampleRate,channels,model,voice,speed}`;
8892
+ content type is `audio/wav` or `audio/ogg; codecs=opus`, with mono 24kHz PCM
8893
+ synthesis. The full input is processed in ordered model segments.
8894
+ - `unload()` retires the activation and joins owned native work. `close()`
8895
+ permanently closes the engine and releases its state subscriptions.
8896
+
8897
+ Queued requests retain order. Cancelling a queued request removes only that
8898
+ request. Active cancellation waits for actual helper/session retirement;
8899
+ surviving requests resume with the same accepted provider configuration.
8900
+ The ONNX owner may need a running native call to return before its worker exits.
8901
+ Readiness is lost while that retirement/reload occurs. Errors retain the actual
8902
+ engine and cleanup failures; unsupported explicit voices or formats are never
8903
+ substituted. No audio is played by this engine.
8904
+
8905
+ ### Example
8906
+
8907
+ ```javascript
8908
+ import {createNativeKokoroRuntime} from 'arcane-os/local-ai/kokoro';
8909
+
8910
+ // The host supplies its existing ONNX owner and already prepared resources.
8911
+ async function narrateMoonGoat({onnx, runtime, paths}) {
8912
+ const voice = createNativeKokoroRuntime({
8913
+ onnx, runtime, paths, executionPreference: 'cpu'
8914
+ });
8915
+ try {
8916
+ await voice.load();
8917
+ return await voice.synthesize({
8918
+ input: 'Captain Goat has landed. Guard the moon cheese!',
8919
+ voice: 'af_heart', responseFormat: 'wav'
8920
+ });
8921
+ } finally {
8922
+ await voice.close();
8923
+ }
8924
+ }
8925
+ ```
8926
+
8927
+ Changing `voice` to `bf_emma` selects British English. `responseFormat: 'opus'`
8928
+ selects Ogg Opus encoding. GPU/physical-device selection uses the existing
8929
+ [ONNX execution contract](local-ai.md#physical-execution-targets); an accepted
8930
+ provider configuration is separate from observed hardware execution.
8931
+ The SDK publishes a Windows x64 helper archive. Other platforms need a matching
8932
+ custom helper distribution and supported native ONNX host. Native helper
8933
+ production and platform execution evidence remain separate.
8934
+
8935
+ ## createNativeKokoroRuntime default export
8936
+
8937
+ The default export from `arcane-os/local-ai/kokoro` is the same
8938
+ `createNativeKokoroRuntime` factory with identical inputs, results and lifetime.
8939
+
8940
+ ## KOKORO_MODEL
8941
+
8942
+ Named string export from `arcane-os/local-ai/kokoro`, with value
8943
+ `onnx-community/Kokoro-82M-v1.0-ONNX`. It identifies this frontend's model family
8944
+ for matching graph, tokenizer and voice resource selection.
8945
+
8946
+ ## KOKORO_REVISION
8947
+
8948
+ Named string export from `arcane-os/local-ai/kokoro`, with value
8949
+ `1939ad2a8e416c0acfeecc08a694d14ef25f2231`. Resource preparation uses this
8950
+ upstream revision with `KOKORO_MODEL`.
8951
+
8952
+ ## KOKORO_SAMPLE_RATE
8953
+
8954
+ Named number export from `arcane-os/local-ai/kokoro`, with value `24000`.
8955
+ It describes the engine's mono PCM synthesis rate in samples per second.
8956
+
8957
+ ## KOKORO_VOICES
8958
+
8959
+ Named array export from `arcane-os/local-ai/kokoro` containing all 28 supported
8960
+ US/GB voice identifiers. The engine uses `af_heart` when the caller omits
8961
+ `voice`; an explicit unsupported selection reports an error. Use this complete
8962
+ array to populate a native Kokoro voice selector.
8963
+
8845
8964
  ## createWhisperRuntime()
8846
8965
 
8847
8966
  ### Overview
@@ -9018,6 +9137,13 @@ The returned object has `name: 'speech'`, `current`, `subscribe`, `close`,
9018
9137
  Core RPC methods. The context supplies `signal`, `requestId` and `emit`.
9019
9138
  The service publishes synchronous accepted `Thinking` before waiting for
9020
9139
  its role's load, then forwards the engine's complete progress and result.
9140
+ - `methods['speech.load'](request,context)` and
9141
+ `methods['speech.unload'](request,context)` route explicit activation to the
9142
+ explicit `role: 'stt' | 'tts'` and forward the remaining activation
9143
+ fields and result through that role's configured engine. A startup load remains owned independently of callers
9144
+ waiting for it. The browser facade exposes `Arcane.speech.load(request,options)`
9145
+ and `unload(request,options)` with `signal`, `onRequest` and no elapsed-time
9146
+ cutoff. See [activation routing](native-speech.md) for selection and errors.
9021
9147
  - `close()` and its `drain()` alias stop acceptance, abort the service lifetime,
9022
9148
  close both engines concurrently and await startup and request work. Repeated
9023
9149
  calls share one promise. `dispose()` also releases event subscriptions.
@@ -9123,7 +9249,8 @@ import createLocalAIService from 'arcane-os/core/local-ai';
9123
9249
  ### Overview
9124
9250
 
9125
9251
  Reuses matching managed runtimes or installs complete selected
9126
- llama.cpp/Ollama/ONNX/image distributions or NeMo libraries in an explicitly supplied directory.
9252
+ llama.cpp/Ollama/ONNX/image distributions, NeMo libraries or native Kokoro
9253
+ helper/data trees in an explicitly supplied directory.
9127
9254
 
9128
9255
  ### Signature and result
9129
9256
 
@@ -9146,6 +9273,12 @@ trees remain intact while a fresh installation is prepared. See
9146
9273
  Image requirements also accept `backend` and
9147
9274
  `models`; their pinned default runtime, CPU/Metal selection and separate model
9148
9275
  preparation are described in [local image generation](local-image-generation.md).
9276
+ `kokoro-native` returns `helperExecutable`, `espeakDataDirectory` and
9277
+ `libraryDirectory`. Windows x64 defaults to the helper archive from the
9278
+ installed SDK's numeric release; an explicit `url` or `helperRoot` selects a
9279
+ complete custom distribution. The data directory is the parent of
9280
+ `espeak-ng-data`. Helper preparation loads no Kokoro graph and keeps the
9281
+ existing ONNX/model-resource owners separate. See [native speech](native-speech.md).
9149
9282
  Matching concurrent
9150
9283
  installs share their work. Downloads retain runtime libraries and surface
9151
9284
  network/archive failures; cancellation and operation events remain observable.
@@ -9189,6 +9322,10 @@ The corrected Windows x64 ONNX selection retains its
9189
9322
  is relocated with the other runtime paths; bundling introduces no separate
9190
9323
  runtime selection. See [ONNX runtime preparation](local-ai.md#development).
9191
9324
 
9325
+ Kokoro bundling retains the complete helper/data tree and relocates
9326
+ `helperExecutable`, `espeakDataDirectory` and `libraryDirectory`. Its selected
9327
+ ONNX runtime and model resources retain their existing independent ownership.
9328
+
9192
9329
  Image records relocate the complete library, binding and variant paths. Model
9193
9330
  URL descriptors retain their original acquisition metadata for preparation
9194
9331
  after launch. Runtime-contained native model paths become artifact-relative;
@@ -11683,10 +11820,11 @@ the message or response callback payload.
11683
11820
 
11684
11821
  **Node and Browser; Cloud transport.** Uses standard Fetch and cancellation,
11685
11822
  without DOM, browser profiles, user singletons, or storage initialization.
11686
- Import starts no request. HTTP `429` whose message contains `overload`
11687
- (case-insensitive) waits
11688
- `3000` milliseconds and retries the complete request; other HTTP failures
11689
- throw their complete parsed JSON or text bodies. A missing key throws
11823
+ Import starts no request. Rejected Fetch and all HTTP `429`/`529` share three
11824
+ retries. Quota recovery honors `Retry-After`, otherwise waits `3000`, `6000`,
11825
+ or `12000` milliseconds by retry number. Active same-endpoint/credential calls
11826
+ share quota cooldown and paced dispatch. Complete error bodies remain unchanged
11827
+ on exhaustion; other HTTP failures are not retried. A missing key throws
11690
11828
  `AI_PROVIDER_NOT_CONFIGURED`; a missing model throws `TypeError`. `signal`
11691
11829
  cancellation during transport, body reading, retry waiting,
11692
11830
  or callback settlement prevents successful return and uses
@@ -11836,21 +11974,24 @@ Ordinary TWiN callers use `fetchRequest()` instead.
11836
11974
  ### Signature and result
11837
11975
 
11838
11976
  ```text
11839
- async fetchHTTPResponse(url,options)
11977
+ async fetchHTTPResponse(url,options,{onRetry=null}={})
11840
11978
  ```
11841
11979
 
11842
11980
  Returns the successful Fetch `Response` without consuming its body. Both URL
11843
11981
  and Fetch options are caller-supplied; this helper adds no key, model, or
11844
11982
  request envelope. Non-success responses are read completely as JSON when the
11845
- content type contains `application/json`, otherwise as text. Only status 429
11846
- with an overload message repeats after 3000 ms; other error bodies are thrown.
11983
+ content type contains `application/json`, otherwise as text. Rejected Fetch
11984
+ and all `429`/`529` share three retries with complete terminal errors.
11985
+ `429` honors `Retry-After` or exponential delay and coordinates a transient
11986
+ same-endpoint/credential cooldown. See [shared request behavior](ai/twin-cloud.md#shared-request-behavior).
11847
11987
 
11848
11988
  ### Availability and normalization
11849
11989
 
11850
11990
  **Node and Browser.** Exported from `arcane-os/ai/twin-cloud` for shared SDK
11851
- integration. `options.signal` cancels Fetch and the overload wait and is checked
11991
+ integration. `options.signal` cancels Fetch and recovery/pacing waits and is checked
11852
11992
  after response/error-body reads. Cancellation uses
11853
- `ARCANE_AI_REQUEST_ABORTED`. Overload warnings use the existing shared logger.
11993
+ `ARCANE_AI_REQUEST_ABORTED`. Retry warnings use the existing shared logger;
11994
+ `onRetry` observes complete waiting/requesting records outside the payload.
11854
11995
 
11855
11996
  ### Example
11856
11997
 
@@ -11870,7 +12011,7 @@ Shared complete JSON-body reader built on `fetchHTTPResponse()`.
11870
12011
  ### Signature and result
11871
12012
 
11872
12013
  ```text
11873
- async fetchJSONResponse(url,options)
12014
+ async fetchJSONResponse(url,options,{onRetry=null}={})
11874
12015
  ```
11875
12016
 
11876
12017
  Returns the full parsed JSON value. A successful response whose content type
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.92.0",
3
+ "version": "0.94.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",
@@ -87,6 +89,7 @@
87
89
  "./local-ai/decisions": "./src/local-ai/decisions.mjs",
88
90
  "./local-ai/image": "./src/local-ai/image-runtime.mjs",
89
91
  "./local-ai/whisper": "./src/local-ai/whisper/index.mjs",
92
+ "./local-ai/kokoro": "./src/local-ai/kokoro/index.mjs",
90
93
  "./local-ai/whisper/build": "./src/local-ai/whisper/build.mjs",
91
94
  "./local-ai/whisper/openvino-build": "./src/local-ai/whisper/openvino-build.mjs",
92
95
  "./integrated-provider": "./src/integrated-provider-loader.mjs",
@@ -277,12 +277,12 @@
277
277
  "type": "array",
278
278
  "items": {
279
279
  "oneOf": [
280
- { "enum": ["llama.cpp", "ollama", "onnx", "stable-diffusion.cpp", "whisper.cpp"] },
280
+ { "enum": ["llama.cpp", "ollama", "onnx", "stable-diffusion.cpp", "whisper.cpp", "kokoro-native"] },
281
281
  {
282
282
  "type": "object",
283
283
  "required": ["id"],
284
284
  "properties": {
285
- "id": { "enum": ["llama.cpp", "ollama", "onnx", "stable-diffusion.cpp"] },
285
+ "id": { "enum": ["llama.cpp", "ollama", "onnx", "stable-diffusion.cpp", "kokoro-native"] },
286
286
  "version": { "type": "string" },
287
287
  "url": { "type": "string" },
288
288
  "backend": { "type": "string", "description": "llama.cpp: auto, cpu or cuda. Image runtimes: auto, metal or cpu." },
@@ -156,12 +156,12 @@
156
156
  "type": "array",
157
157
  "items": {
158
158
  "oneOf": [
159
- { "enum": ["llama.cpp", "ollama", "onnx", "stable-diffusion.cpp", "whisper.cpp"] },
159
+ { "enum": ["llama.cpp", "ollama", "onnx", "stable-diffusion.cpp", "whisper.cpp", "kokoro-native"] },
160
160
  {
161
161
  "type": "object",
162
162
  "required": ["id"],
163
163
  "properties": {
164
- "id": { "enum": ["llama.cpp", "ollama", "onnx", "stable-diffusion.cpp"] },
164
+ "id": { "enum": ["llama.cpp", "ollama", "onnx", "stable-diffusion.cpp", "kokoro-native"] },
165
165
  "version": { "type": "string" },
166
166
  "url": { "type": "string" },
167
167
  "backend": { "type": "string", "description": "llama.cpp: auto, cpu or cuda. Image runtimes: auto, metal or cpu." },
@@ -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"),