arcane-os 0.68.0 → 0.70.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 (41) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +9 -4
  3. package/browser-runtime/ai/browser-decisions.mjs +46 -2
  4. package/browser-runtime/ai/browser-speech-artifacts.mjs +58 -62
  5. package/browser-runtime/ai/browser-speech-providers.mjs +8 -3
  6. package/browser-runtime/ai/browser-wasm-llm-provider.mjs +76 -164
  7. package/browser-runtime/ai/dbopfs-model-parts.mjs +344 -0
  8. package/browser-runtime/ai/dbopfs-model-resources.mjs +190 -0
  9. package/browser-runtime/ai/decision-runtime.mjs +17 -1
  10. package/browser-runtime/ai/decision-worker.mjs +21 -1
  11. package/browser-runtime/ai/model-resource-bridge.mjs +231 -0
  12. package/browser-runtime/ai/speech-worker-client.mjs +68 -28
  13. package/browser-runtime/ai/speech-worker-runtime.mjs +212 -97
  14. package/browser-runtime/core/contracts.mjs +1 -1
  15. package/docs/reference/README.md +2 -0
  16. package/docs/reference/ai/browser-decisions.md +57 -3
  17. package/docs/reference/ai/browser-speech.md +94 -26
  18. package/docs/reference/ai/browser-wasm.md +28 -20
  19. package/docs/reference/core-native-packaging.md +54 -0
  20. package/docs/reference/core-runtime.md +9 -0
  21. package/docs/reference/core-shared-host.md +190 -0
  22. package/docs/reference/inventory/package-api.json +3 -3
  23. package/docs/reference/inventory/runtime-modules.json +3 -3
  24. package/docs/reference/runtime-components.md +4 -2
  25. package/docs/reference/runtime-modules.md +120 -20
  26. package/docs/reference/sdk-api.md +64 -30
  27. package/package.json +2 -2
  28. package/runtime/arcane/components/theme-switcher.html +1 -1
  29. package/runtime/arcane/modules/AI.js +7 -22
  30. package/runtime/arcane/modules/AIProviderRuntime.js +84 -6
  31. package/runtime/arcane/modules/DBOPFS.js +180 -26
  32. package/schemas/arcane-app.schema.json +10 -0
  33. package/src/app-descriptor.mjs +23 -1
  34. package/src/core/host.mjs +2 -0
  35. package/src/core/hosts/windows/ArcaneHost.cs +46 -0
  36. package/src/core/hosts/windows/ArcaneLauncher.cs +21 -0
  37. package/src/core/runtime.mjs +9 -4
  38. package/src/core/services/packaged-web.mjs +8 -1
  39. package/src/core/shared-host.mjs +500 -0
  40. package/src/native/portable-layout.mjs +66 -34
  41. package/src/native/portable-provider.mjs +1 -0
@@ -1688,6 +1688,7 @@ export class AIProviderRuntime {
1688
1688
  #speechDesiredMuted = true;
1689
1689
  #speechTransition = Promise.resolve();
1690
1690
  #speechTransitionOwners = 0;
1691
+ #speechActivation = null;
1691
1692
  #unsubscribeIntents = null;
1692
1693
  #closed = false;
1693
1694
  #closing = false;
@@ -2784,6 +2785,11 @@ export class AIProviderRuntime {
2784
2785
  }
2785
2786
  if (slot.loadPromise) {
2786
2787
  if (this.#sameSelection(slot.selection, targetSelection)) {
2788
+ const activation = this.#speechActivation;
2789
+ if (role === 'tts' && activation?.loadPromise === slot.loadPromise
2790
+ && signal !== activation.controller.signal) {
2791
+ return this.#observeSpeechActivation(activation, signal);
2792
+ }
2787
2793
  return slot.loadPromise;
2788
2794
  }
2789
2795
  return Promise.reject(
@@ -2957,6 +2963,10 @@ export class AIProviderRuntime {
2957
2963
  }
2958
2964
  );
2959
2965
  slot.loadPromise = loadOperation;
2966
+ if (role === 'tts' && this.#speechActivation
2967
+ && signal === this.#speechActivation.controller.signal) {
2968
+ this.#speechActivation.loadPromise = loadOperation;
2969
+ }
2960
2970
  publishAIRuntimeRoleState(
2961
2971
  role,
2962
2972
  roleRecord(
@@ -3386,15 +3396,19 @@ export class AIProviderRuntime {
3386
3396
  if (options.signal?.aborted) {
3387
3397
  return Promise.reject(normalizedAbort());
3388
3398
  }
3389
- if (role === 'tts' && !queued && speechInputPrepared !== true) {
3399
+ if (role === 'tts' && !queued) {
3390
3400
  const payload = options.payload;
3391
3401
  const field = Object.hasOwn(payload ?? {}, 'input') ? 'input' : 'text';
3392
3402
  const text = Object.getOwnPropertyDescriptor(payload ?? {}, field)?.value;
3393
3403
  if (is.string(text)) {
3394
- const input = stripSpeechFormatting(text);
3395
- if (input !== text) {
3404
+ const textFormat = Object.getOwnPropertyDescriptor(payload, 'textFormat')?.value;
3405
+ const input = speechInputPrepared === true || textFormat === 'plain'
3406
+ ? text
3407
+ : stripSpeechFormatting(text);
3408
+ if (input !== text || Object.hasOwn(payload, 'textFormat')) {
3396
3409
  const payloadProperties = Object.getOwnPropertyDescriptors(payload);
3397
3410
  payloadProperties[field] = {...payloadProperties[field], value: input};
3411
+ delete payloadProperties.textFormat;
3398
3412
  const requestProperties = Object.getOwnPropertyDescriptors(options);
3399
3413
  requestProperties.payload = {
3400
3414
  ...requestProperties.payload,
@@ -4266,19 +4280,34 @@ export class AIProviderRuntime {
4266
4280
  return true;
4267
4281
  }
4268
4282
 
4269
- async setSpeechMuted(muted) {
4283
+ async setSpeechMuted(muted, {signal = null} = {}) {
4270
4284
  arcaneLogging.debug('[Arcane speech runtime] setSpeechMuted', muted);
4271
4285
  this.#assertOpen();
4272
4286
  this.#assertNotConfiguring();
4273
4287
  if (!is.boolean(muted)) {
4274
4288
  fail('AI speech muted state must be a boolean.');
4275
4289
  }
4290
+ assertAbortSignal(signal);
4291
+ if (signal?.aborted) throw normalizedAbort(signal.reason);
4292
+ if (!muted && !this.#speechDesiredMuted && this.#speechActivation
4293
+ && !this.#speechActivation.controller.signal.aborted) {
4294
+ return this.#observeSpeechActivation(this.#speechActivation, signal);
4295
+ }
4276
4296
  this.#speechDesiredMuted = muted;
4277
4297
  if (muted) {
4278
4298
  this.#speechMuted = true;
4299
+ this.#speechActivation?.controller.abort();
4279
4300
  this.#slots.tts.loadController?.abort();
4280
4301
  this.cancel('tts');
4281
4302
  }
4303
+ const activation = muted ? null : {
4304
+ controller: new AbortController(),
4305
+ interests: new Set(),
4306
+ promise: null,
4307
+ loadPromise: null,
4308
+ settled: false
4309
+ };
4310
+ this.#speechActivation = activation;
4282
4311
  const runtime = this;
4283
4312
  this.#speechTransitionOwners += 1;
4284
4313
  const transition = this.#speechTransition.catch(
@@ -4286,6 +4315,9 @@ export class AIProviderRuntime {
4286
4315
  ).then(
4287
4316
  async function reconcileLatestAISpeechPreference() {
4288
4317
  while (true) {
4318
+ if (activation?.controller.signal.aborted) {
4319
+ throw normalizedAbort(activation.controller.signal.reason);
4320
+ }
4289
4321
  const desiredMuted = runtime.#speechDesiredMuted;
4290
4322
  if (desiredMuted) {
4291
4323
  runtime.cancel('tts');
@@ -4302,7 +4334,11 @@ export class AIProviderRuntime {
4302
4334
  if (runtime.#speechDesiredMuted) {
4303
4335
  continue;
4304
4336
  }
4305
- await runtime.load('tts');
4337
+ const currentActivation = runtime.#speechActivation;
4338
+ if (currentActivation?.controller.signal.aborted) {
4339
+ throw normalizedAbort(currentActivation.controller.signal.reason);
4340
+ }
4341
+ await runtime.load('tts', {signal: currentActivation?.controller.signal ?? null});
4306
4342
  }
4307
4343
  if (runtime.#speechDesiredMuted === desiredMuted) {
4308
4344
  runtime.#speechMuted = desiredMuted;
@@ -4314,10 +4350,52 @@ export class AIProviderRuntime {
4314
4350
  ).finally(
4315
4351
  function releaseAIProviderSpeechTransitionOwnership() {
4316
4352
  runtime.#speechTransitionOwners -= 1;
4353
+ if (activation) {
4354
+ activation.settled = true;
4355
+ if (runtime.#speechActivation === activation) {
4356
+ runtime.#speechActivation = null;
4357
+ }
4358
+ }
4317
4359
  }
4318
4360
  );
4319
4361
  this.#speechTransition = transition;
4320
- return transition;
4362
+ if (!activation) return transition;
4363
+ activation.promise = transition;
4364
+ return this.#observeSpeechActivation(activation, signal);
4365
+ }
4366
+
4367
+ #observeSpeechActivation(activation, signal) {
4368
+ return new Promise(function observeAIProviderSpeechActivation(resolve, reject) {
4369
+ const interest = {};
4370
+ let settled = false;
4371
+ activation.interests.add(interest);
4372
+ function releaseSpeechActivationInterest() {
4373
+ activation.interests.delete(interest);
4374
+ signal?.removeEventListener('abort', cancelSpeechActivationInterest);
4375
+ }
4376
+ function cancelSpeechActivationInterest() {
4377
+ if (settled) return;
4378
+ settled = true;
4379
+ releaseSpeechActivationInterest();
4380
+ if (!activation.settled && activation.interests.size === 0) {
4381
+ activation.controller.abort(signal?.reason);
4382
+ }
4383
+ reject(normalizedAbort(signal?.reason));
4384
+ }
4385
+ signal?.addEventListener('abort', cancelSpeechActivationInterest, {once: true});
4386
+ activation.promise.then(function speechActivationReady(status) {
4387
+ if (settled) return;
4388
+ settled = true;
4389
+ releaseSpeechActivationInterest();
4390
+ resolve(status);
4391
+ }, function speechActivationFailed(error) {
4392
+ if (settled) return;
4393
+ settled = true;
4394
+ releaseSpeechActivationInterest();
4395
+ reject(error);
4396
+ });
4397
+ if (signal?.aborted) cancelSpeechActivationInterest();
4398
+ });
4321
4399
  }
4322
4400
 
4323
4401
  #providerFor(slot) {
@@ -8,7 +8,8 @@ import {
8
8
  const is=new Is(false);
9
9
 
10
10
  const dbopfsEventTypes={
11
- ready:'dbopfs-ready'
11
+ ready:'dbopfs-ready',
12
+ change:'dbopfs-change'
12
13
  };
13
14
  const dbopfsReasons={
14
15
  ready:'opfs-database-ready'
@@ -117,6 +118,13 @@ if(navigator.storage?.persist){
117
118
  class DBOPFS {
118
119
 
119
120
  #events;
121
+ #changeChannel=null;
122
+ #changeOriginId=globalThis.crypto?.randomUUID?.()
123
+ ||`dbopfs-${Date.now()}-${Math.random().toString(36).slice(2)}`;
124
+ #changeSequence=0;
125
+ #changeWindow=window;
126
+ #changesSuspended=false;
127
+ #cacheStates=new Map();
120
128
 
121
129
  /** @type {FileSystemDirectoryHandle|Object} */
122
130
  #db={}
@@ -286,6 +294,113 @@ class DBOPFS {
286
294
  return `${tableName}:${fileName}`
287
295
  }
288
296
 
297
+ #fileCacheState(tableName,fileName){
298
+ const directoryName=directoryNameForTable(tableName);
299
+ let table=this.#cacheStates.get(directoryName);
300
+ if(!table){
301
+ table=new Map();
302
+ this.#cacheStates.set(directoryName,table);
303
+ }
304
+ let state=table.get(fileName);
305
+ if(!state){
306
+ state={revision:0};
307
+ table.set(fileName,state);
308
+ }
309
+ return state;
310
+ }
311
+
312
+ #forgetFile(tableName,fileName,retire=false){
313
+ const directoryName=directoryNameForTable(tableName);
314
+ const registeredTableName=tableNameForDirectory(directoryName);
315
+ const states=this.#cacheStates.get(directoryName);
316
+ const state=states?.get(fileName);
317
+ if(state)state.revision+=1;
318
+ if(retire&&states){
319
+ states.delete(fileName);
320
+ if(states.size===0)this.#cacheStates.delete(directoryName);
321
+ }
322
+ for(const name of new Set([tableName,directoryName,registeredTableName])){
323
+ if(this.#tables[name])delete this.#tables[name][fileName];
324
+ }
325
+ }
326
+
327
+ #openChangeChannel(){
328
+ if(this.#changesSuspended||this.#changeChannel||!this.#applicationId)return;
329
+ const Channel=this.#changeWindow.BroadcastChannel;
330
+ if(!is.function(Channel))return;
331
+ try{
332
+ const channel=new Channel(`arcane.dbopfs.changes:${this.#storagePath}`);
333
+ const database=this;
334
+ channel.onmessage=function receiveCommittedChange(event){
335
+ const change=event.data;
336
+ if(!change||change.applicationId!==database.#applicationId
337
+ ||change.storagePath!==database.#storagePath
338
+ ||change.originId===database.#changeOriginId)return;
339
+ if(!['write','append','delete','table-delete'].includes(change.action)
340
+ ||typeof change.tableName!=='string'
341
+ ||typeof change.directoryName!=='string'
342
+ ||typeof change.changeId!=='string'
343
+ ||(change.action!=='table-delete'&&typeof change.fileName!=='string'))return;
344
+ database.#receiveChange(change);
345
+ };
346
+ channel.onmessageerror=function reportChangeMessageError(event){
347
+ arcaneLogging.error('DBOPFS could not receive a committed-change notification.',event);
348
+ };
349
+ this.#changeChannel=channel;
350
+ }catch(error){
351
+ arcaneLogging.error('DBOPFS cross-document notifications are unavailable; local storage remains available.',error);
352
+ }
353
+ }
354
+
355
+ #closeChangeChannel(){
356
+ if(!this.#changeChannel)return;
357
+ this.#changeChannel.onmessage=null;
358
+ this.#changeChannel.onmessageerror=null;
359
+ this.#changeChannel.close();
360
+ this.#changeChannel=null;
361
+ }
362
+
363
+ #receiveChange(change){
364
+ if(change.action==='table-delete'){
365
+ this.#forgetTable(change.tableName,change.directoryName,tableNameForDirectory(change.directoryName));
366
+ }else{
367
+ this.#forgetFile(change.tableName,change.fileName,change.action==='delete');
368
+ }
369
+ this.#events.dispatch(dbopfsEventTypes.change,{...change,remote:true},{operationId:change.changeId});
370
+ }
371
+
372
+ #commitChange(tableName,fileName,action){
373
+ const directoryName=directoryNameForTable(tableName);
374
+ const registeredTableName=tableNameForDirectory(directoryName);
375
+ if(action==='table-delete'){
376
+ this.#forgetTable(tableName,directoryName,registeredTableName);
377
+ }else{
378
+ this.#forgetFile(tableName,fileName,action==='delete');
379
+ }
380
+ this.#changeSequence+=1;
381
+ const change={
382
+ applicationId:this.#applicationId,
383
+ storagePath:this.#storagePath,
384
+ tableName:registeredTableName,
385
+ directoryName,
386
+ fileName,
387
+ action,
388
+ originId:this.#changeOriginId,
389
+ sequence:this.#changeSequence,
390
+ changeId:`${this.#changeOriginId}:${this.#changeSequence}`
391
+ };
392
+ // Notify the remote transport before synchronous local observers can
393
+ // start another mutation. Transport failure cannot undo a saved record.
394
+ if(this.#changeChannel){
395
+ try{
396
+ this.#changeChannel.postMessage(change);
397
+ }catch(error){
398
+ arcaneLogging.error('DBOPFS saved a change whose cross-document notification failed.',error);
399
+ }
400
+ }
401
+ this.#events.dispatch(dbopfsEventTypes.change,{...change,remote:false},{operationId:change.changeId});
402
+ }
403
+
289
404
  /**
290
405
  * Invalidates every known cache key for one logical/physical table pair.
291
406
  * @private
@@ -294,6 +409,7 @@ class DBOPFS {
294
409
  * @param {string} registeredTableName
295
410
  */
296
411
  #forgetTable(tableName,directoryName,registeredTableName){
412
+ this.#cacheStates.delete(directoryName);
297
413
  for(const name of new Set([
298
414
  tableName,
299
415
  directoryName,
@@ -320,6 +436,22 @@ class DBOPFS {
320
436
  source:'dbopfs',
321
437
  eventTypes:Object.values(dbopfsEventTypes)
322
438
  });
439
+ const database=this;
440
+ this.#changeWindow.addEventListener('pagehide',function suspendDBOPFSChanges(){
441
+ database.#changesSuspended=true;
442
+ database.#closeChangeChannel();
443
+ });
444
+ this.#changeWindow.addEventListener('pageshow',function resumeDBOPFSChanges(){
445
+ if(!database.#changesSuspended)return;
446
+ database.#changesSuspended=false;
447
+ // Notifications have no history. Discard only this page's cached
448
+ // views after suspension; storage and committed events are unchanged.
449
+ database.#tables={};
450
+ database.#tableHandles={};
451
+ database.#tableHandlePromises={};
452
+ database.#cacheStates.clear();
453
+ database.#openChangeChannel();
454
+ });
323
455
  this.readyPromise=this.#init(options);
324
456
  }
325
457
 
@@ -340,6 +472,7 @@ class DBOPFS {
340
472
  this.#applicationId=scope.applicationId;
341
473
  this.#storagePath=scope.path;
342
474
  this.#db=scope.directory;
475
+ this.#openChangeChannel();
343
476
 
344
477
  this.ready=true;
345
478
 
@@ -382,6 +515,17 @@ class DBOPFS {
382
515
  return this.#storagePath;
383
516
  }
384
517
 
518
+ /**
519
+ * Observes committed record/table changes through the canonical event owner.
520
+ * Subscription is live-only; it does not replay records or load a table.
521
+ * @param {function} listener Receives an event whose detail is change metadata.
522
+ * @param {{signal?:AbortSignal,once?:boolean}} options
523
+ * @returns {function} Idempotent unsubscribe with a matching dispose method.
524
+ */
525
+ subscribeChanges(listener,options={}){
526
+ return this.#events.subscribe(dbopfsEventTypes.change,listener,options);
527
+ }
528
+
385
529
  /**
386
530
  * Returns in-memory table cache.
387
531
  *
@@ -444,7 +588,9 @@ class DBOPFS {
444
588
  {create:true}
445
589
  ).then(
446
590
  function registerRequestedTable(handle){
447
- this.#tableHandles[registeredTableName]=handle;
591
+ if(this.#tableHandlePromises[registeredTableName]===handlePromise){
592
+ this.#tableHandles[registeredTableName]=handle;
593
+ }
448
594
  return handle;
449
595
  }.bind(this)
450
596
  );
@@ -489,6 +635,10 @@ class DBOPFS {
489
635
  dataToWrite=JSON.stringify(dataToWrite)
490
636
  }
491
637
 
638
+ const cacheState=this.#fileCacheState(tableName,fileName);
639
+ const cacheRevision=cacheState.revision;
640
+ const result=append?true:parseFileValue(fileName,String(dataToWrite));
641
+
492
642
  try{
493
643
  await this.writeFile(
494
644
  tableName,
@@ -497,20 +647,20 @@ class DBOPFS {
497
647
  append
498
648
  );
499
649
 
500
- if(append){
501
- delete this.#tables[tableName][fileName];
502
- }else{
503
- this.#tables[tableName][fileName]=parseFileValue(
504
- fileName,
505
- String(dataToWrite)
506
- );
650
+ if(!append&&this.#fileCacheState(tableName,fileName)===cacheState
651
+ &&cacheState.revision<=cacheRevision+1){
652
+ if(!this.#tables[tableName])this.#tables[tableName]={};
653
+ this.#tables[tableName][fileName]=result;
654
+ }
655
+ if(append&&cacheState.revision===cacheRevision){
656
+ this.#forgetFile(tableName,fileName);
507
657
  }
508
658
  }catch(error){
509
659
  arcaneLogging.error(`Error writing file '${fileName}' to table '${tableName}':`,error)
510
660
  throw error
511
661
  }
512
662
 
513
- return append?true:this.#tables[tableName][fileName]
663
+ return result
514
664
  }.bind(this)
515
665
  )
516
666
 
@@ -547,12 +697,14 @@ class DBOPFS {
547
697
  );
548
698
 
549
699
  if(!is.function(handle.createWritable)){
550
- return this.#writeFileWithWorker(
700
+ await this.#writeFileWithWorker(
551
701
  table.name,
552
702
  fileName,
553
703
  fileData,
554
704
  append
555
705
  );
706
+ this.#commitChange(tableName,fileName,append?'append':'write');
707
+ return true;
556
708
  }
557
709
 
558
710
  const writable=await handle.createWritable(
@@ -572,6 +724,7 @@ class DBOPFS {
572
724
 
573
725
  await writable.write(blob);
574
726
  await writable.close();
727
+ this.#commitChange(tableName,fileName,append?'append':'write');
575
728
 
576
729
  return true
577
730
  }
@@ -669,6 +822,8 @@ class DBOPFS {
669
822
  */
670
823
  async get(tableName='',fileName='',force=false){
671
824
  if(force||!this.#tables[tableName]?.[fileName]){
825
+ const cacheState=this.#fileCacheState(tableName,fileName);
826
+ const cacheRevision=cacheState.revision;
672
827
  try{
673
828
  const file=await this.readFile(tableName,fileName)
674
829
  const textContent=await file.text()
@@ -677,12 +832,15 @@ class DBOPFS {
677
832
  this.#tables[tableName]={}
678
833
  }
679
834
 
680
- this.#tables[tableName][fileName]=parseFileValue(
681
- fileName,
682
- textContent
683
- );
835
+ const value=parseFileValue(fileName,textContent);
836
+ if(this.#fileCacheState(tableName,fileName)===cacheState
837
+ &&cacheState.revision===cacheRevision){
838
+ this.#tables[tableName][fileName]=value;
839
+ }
840
+ return value;
684
841
  }catch(error){
685
842
  if(error.name==='NotFoundError'){
843
+ this.#forgetFile(tableName,fileName,true);
686
844
  return null
687
845
  }
688
846
 
@@ -788,15 +946,13 @@ class DBOPFS {
788
946
 
789
947
  try{
790
948
  await table.removeEntry(fileName)
949
+ this.#commitChange(tableName,fileName,'delete');
791
950
  }catch(error){
792
951
  if(error.name!=='NotFoundError'){
793
952
  arcaneLogging.error(error)
794
953
  throw error
795
954
  }
796
- }
797
-
798
- if(this.#tables[tableName]){
799
- delete this.#tables[tableName][fileName]
955
+ this.#forgetFile(tableName,fileName,true);
800
956
  }
801
957
 
802
958
  return true
@@ -831,13 +987,9 @@ class DBOPFS {
831
987
 
832
988
  try{
833
989
  await this.#db.removeEntry(directoryName,{recursive:true})
834
-
835
- delete this.#tables[tableName]
836
- delete this.#tables[directoryName]
837
- delete this.#tables[registeredTableName]
838
- delete this.#tableHandles[registeredTableName]
839
- delete this.#tableHandlePromises[registeredTableName]
990
+ this.#commitChange(tableName,null,'table-delete');
840
991
  }catch(error){
992
+ if(error.name==='NotFoundError')this.#forgetTable(tableName,directoryName,registeredTableName);
841
993
  arcaneLogging.error(error)
842
994
  }
843
995
 
@@ -894,7 +1046,7 @@ class DBOPFS {
894
1046
  }
895
1047
  }
896
1048
 
897
- this.#forgetTable(tableName,directoryName,registeredTableName)
1049
+ this.#commitChange(tableName,null,'table-delete');
898
1050
 
899
1051
  return {
900
1052
  status:'removed',
@@ -916,11 +1068,13 @@ class DBOPFS {
916
1068
 
917
1069
  for await(const [name]of this.#db.entries()){
918
1070
  await this.#db.removeEntry(name,{recursive:true})
1071
+ this.#commitChange(name,null,'table-delete');
919
1072
  }
920
1073
 
921
1074
  this.#tables={}
922
1075
  this.#tableHandles={}
923
1076
  this.#tableHandlePromises={}
1077
+ this.#cacheStates.clear();
924
1078
  this.#writeLocks={}
925
1079
 
926
1080
  return this
@@ -132,6 +132,16 @@
132
132
  }
133
133
  },
134
134
  "localAI": { "$ref": "#/$defs/localAI" },
135
+ "window": {
136
+ "type": "object",
137
+ "additionalProperties": false,
138
+ "description": "Initial native client dimensions in logical pixels at 96 DPI, and whether the user can resize the window. Omitted fields retain the host defaults.",
139
+ "properties": {
140
+ "width": { "type": "integer", "minimum": 1 },
141
+ "height": { "type": "integer", "minimum": 1 },
142
+ "resizable": { "type": "boolean" }
143
+ }
144
+ },
135
145
  "bundledApps": {
136
146
  "type": "array",
137
147
  "uniqueItems": true,
@@ -235,8 +235,26 @@ function validateDocumentCatalog(value){
235
235
  });
236
236
  }
237
237
 
238
+ function validateNativeWindow(value){
239
+ if(value===undefined)return undefined;
240
+ assertOnlyKeys(value,new Set(['width','height','resizable']),'descriptor.native.window');
241
+ for(const dimension of ['width','height']){
242
+ if(value[dimension]!==undefined&&(!is.integer(value[dimension])||value[dimension]<=0)){
243
+ fail(`descriptor.native.window.${dimension} must be a positive integer.`);
244
+ }
245
+ }
246
+ if(value.resizable!==undefined&&!is.boolean(value.resizable)){
247
+ fail('descriptor.native.window.resizable must be a boolean.');
248
+ }
249
+ return completeValue({
250
+ ...(value.width===undefined?{}:{width:value.width}),
251
+ ...(value.height===undefined?{}:{height:value.height}),
252
+ ...(value.resizable===undefined?{}:{resizable:value.resizable})
253
+ });
254
+ }
255
+
238
256
  function validateNative(value,appId){
239
- assertOnlyKeys(value,new Set(['type','icon','order','bundledApps','documentCatalog','localAI','services']),'descriptor.native');
257
+ assertOnlyKeys(value,new Set(['type','icon','order','bundledApps','documentCatalog','localAI','services','window']),'descriptor.native');
240
258
  if(!NATIVE_TYPES.has(value.type))fail('descriptor.native.type is unsupported.');
241
259
  const icon=value.icon===null?null:normalizeRelativePath(value.icon,'descriptor.native.icon');
242
260
  if(!is.integer(value.order)||value.order<0){
@@ -246,6 +264,7 @@ function validateNative(value,appId){
246
264
  pattern:APP_ID_PATTERN
247
265
  });
248
266
  const documentCatalog=validateDocumentCatalog(value.documentCatalog);
267
+ const window=validateNativeWindow(value.window);
249
268
  let services;
250
269
  if(value.services!==undefined){
251
270
  if(!is.array(value.services))fail('descriptor.native.services must be an array.');
@@ -259,6 +278,7 @@ function validateNative(value,appId){
259
278
  icon,
260
279
  order:value.order,
261
280
  bundledApps,
281
+ ...(window===undefined?{}:{window}),
262
282
  ...(services===undefined?{}:{services}),
263
283
  ...(value.localAI===undefined?{}:{localAI:normalizeLocalAIConfig(value.localAI)}),
264
284
  ...(documentCatalog?{documentCatalog}:{})
@@ -395,6 +415,7 @@ export function projectNativeDescriptor(descriptor,{source}={}){
395
415
  ...(value.native.bundledApps.length?{bundledApps:[...value.native.bundledApps]}:{}),
396
416
  ...(value.native.documentCatalog?{documentCatalog:value.native.documentCatalog}:{}),
397
417
  ...(value.native.services===undefined?{}:{services:value.native.services}),
418
+ ...(value.native.window===undefined?{}:{window:value.native.window}),
398
419
  ...(value.native.localAI===undefined?{}:{localAI:value.native.localAI}),
399
420
  include:[...value.package.include]
400
421
  };
@@ -440,6 +461,7 @@ function synthesizedDescriptor(packageManifest,nativeDescriptor){
440
461
  order:is.integer(native.order)?native.order:100,
441
462
  bundledApps:[...(native.bundledApps??[])],
442
463
  ...(native.services===undefined?{}:{services:native.services}),
464
+ ...(native.window===undefined?{}:{window:native.window}),
443
465
  ...((packageManifest.localAI??native.localAI)===undefined?{}:{localAI:packageManifest.localAI??native.localAI}),
444
466
  ...(native.documentCatalog?{documentCatalog:native.documentCatalog}:{})
445
467
  },
package/src/core/host.mjs CHANGED
@@ -3,6 +3,8 @@ import Is from 'strong-type';
3
3
  import {createCoreRuntime} from './runtime.mjs';
4
4
  import {startCoreStdio} from './stdio.mjs';
5
5
 
6
+ export {startSharedCoreHost, connectSharedCoreHost, runSharedCoreHost, startSharedCoreBridge} from './shared-host.mjs';
7
+
6
8
  const is = new Is(false);
7
9
 
8
10
  /**
@@ -26,6 +26,10 @@ namespace Arcane.Core.Hosts.Windows
26
26
  public string CoreExecutable { get; set; }
27
27
  public string CoreArguments { get; set; }
28
28
  public string CoreWorkingDirectory { get; set; }
29
+ /// <summary>Optional initial client dimensions in logical pixels at 96 DPI.</summary>
30
+ public double? InitialClientWidth { get; set; }
31
+ public double? InitialClientHeight { get; set; }
32
+ public bool? Resizable { get; set; }
29
33
  }
30
34
 
31
35
  public static class ArcaneHost
@@ -128,6 +132,8 @@ namespace Arcane.Core.Hosts.Windows
128
132
  {
129
133
  if (options == null) throw new ArgumentNullException("options");
130
134
  if (onDiagnostic == null) throw new ArgumentNullException("onDiagnostic");
135
+ ValidateInitialDimension(options.InitialClientWidth, "InitialClientWidth");
136
+ ValidateInitialDimension(options.InitialClientHeight, "InitialClientHeight");
131
137
  this.options = options;
132
138
  this.onDiagnostic = onDiagnostic;
133
139
  this.onError = onError;
@@ -135,6 +141,11 @@ namespace Arcane.Core.Hosts.Windows
135
141
  completion.Task.ContinueWith(ObserveReportedFailure, TaskContinuationOptions.OnlyOnFaulted);
136
142
  Text = options.Title;
137
143
  AutoScaleMode = AutoScaleMode.Dpi;
144
+ if (options.Resizable.HasValue)
145
+ {
146
+ FormBorderStyle = options.Resizable.Value ? FormBorderStyle.Sizable : FormBorderStyle.FixedSingle;
147
+ MaximizeBox = options.Resizable.Value;
148
+ }
138
149
  webView = new WebView2 { Dock = DockStyle.Fill };
139
150
  Controls.Add(webView);
140
151
  }
@@ -145,6 +156,9 @@ namespace Arcane.Core.Hosts.Windows
145
156
 
146
157
  protected override void OnLoad(EventArgs args)
147
158
  {
159
+ // Apply the initial choice once. A composing launcher's Load handler
160
+ // can then restore its own saved state; later user sizing is untouched.
161
+ if (!started && !closing) ApplyInitialWindowSize();
148
162
  base.OnLoad(args);
149
163
  if (started || closing) return;
150
164
  started = true;
@@ -152,6 +166,38 @@ namespace Arcane.Core.Hosts.Windows
152
166
  initialization.ContinueWith(ObserveUnexpectedFailure, TaskContinuationOptions.OnlyOnFaulted);
153
167
  }
154
168
 
169
+ private static void ValidateInitialDimension(double? value, string name)
170
+ {
171
+ if (value.HasValue && (Double.IsNaN(value.Value) || Double.IsInfinity(value.Value)
172
+ || value.Value <= 0 || Math.Truncate(value.Value) != value.Value))
173
+ throw new ArgumentOutOfRangeException(name, "Select a positive integral logical client dimension.");
174
+ }
175
+
176
+ private void ApplyInitialWindowSize()
177
+ {
178
+ if ((!options.InitialClientWidth.HasValue && !options.InitialClientHeight.HasValue)
179
+ || WindowState != FormWindowState.Normal) return;
180
+
181
+ Rectangle workArea = Screen.FromControl(this).WorkingArea;
182
+ int availableWidth = Math.Max(1, workArea.Width - (Width - ClientSize.Width));
183
+ int availableHeight = Math.Max(1, workArea.Height - (Height - ClientSize.Height));
184
+ using (Graphics display = CreateGraphics())
185
+ {
186
+ double width = options.InitialClientWidth.HasValue
187
+ ? options.InitialClientWidth.Value * display.DpiX / 96.0 : ClientSize.Width;
188
+ double height = options.InitialClientHeight.HasValue
189
+ ? options.InitialClientHeight.Value * display.DpiY / 96.0 : ClientSize.Height;
190
+ // Fit before converting to native integer coordinates. This is
191
+ // an initial screen fit, not a persistent maximum window size.
192
+ ClientSize = new Size(
193
+ (int)Math.Max(1, Math.Min(availableWidth, Math.Round(width))),
194
+ (int)Math.Max(1, Math.Min(availableHeight, Math.Round(height))));
195
+ }
196
+ Location = new Point(
197
+ Math.Max(workArea.Left, Math.Min(Left, workArea.Right - Width)),
198
+ Math.Max(workArea.Top, Math.Min(Top, workArea.Bottom - Height)));
199
+ }
200
+
155
201
  private async Task InitializeAsync()
156
202
  {
157
203
  try