arcane-os 0.2.2 → 0.3.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 (117) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +8 -8
  3. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +29 -22
  4. package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +203 -0
  5. package/browser-runtime/ai/browser-kokoro-worker.mjs +11 -2
  6. package/browser-runtime/ai/browser-speech-artifacts.mjs +3230 -397
  7. package/browser-runtime/ai/browser-speech-providers.mjs +1141 -157
  8. package/browser-runtime/ai/browser-speech.mjs +2 -0
  9. package/browser-runtime/ai/browser-whisper-worker.mjs +11 -2
  10. package/browser-runtime/ai/model-controller.mjs +285 -95
  11. package/browser-runtime/ai/speech-worker-client.mjs +247 -32
  12. package/browser-runtime/ai/speech-worker-runtime.mjs +2310 -167
  13. package/browser-runtime/event-manager.mjs +1097 -1
  14. package/docs/architecture.md +2 -2
  15. package/docs/event-manager.md +155 -27
  16. package/docs/reference/README.md +27 -27
  17. package/docs/reference/ai/browser-speech-package-authority.json +835 -0
  18. package/docs/reference/ai/browser-speech.md +1162 -246
  19. package/docs/reference/ai/browser-wasm.md +18 -7
  20. package/docs/reference/availability-and-normalization.md +6 -3
  21. package/docs/reference/behavioral-testing.md +29 -6
  22. package/docs/reference/cli.md +117 -9
  23. package/docs/reference/core/arcane-ai-contracts.md +1 -1
  24. package/docs/reference/event-manager.md +577 -32
  25. package/docs/reference/inventory/package-api.json +478 -2
  26. package/docs/reference/inventory/runtime-components.json +108 -44
  27. package/docs/reference/inventory/runtime-modules.json +131 -53
  28. package/docs/reference/mail.md +316 -0
  29. package/docs/reference/protocols.md +157 -43
  30. package/docs/reference/runtime-components.md +258 -83
  31. package/docs/reference/runtime-modules.md +613 -77
  32. package/docs/reference/sdk-api.md +1014 -25
  33. package/package.json +5 -4
  34. package/runtime/ARCANE_RUNTIME_RELEASE.json +145 -140
  35. package/runtime/arcane/components/app-bar.html +34 -13
  36. package/runtime/arcane/components/assistant-panel.html +110 -57
  37. package/runtime/arcane/components/calculator.html +7 -4
  38. package/runtime/arcane/components/chart.html +58 -17
  39. package/runtime/arcane/components/chat.html +606 -136
  40. package/runtime/arcane/components/conversation-view.html +13 -6
  41. package/runtime/arcane/components/dashboard-config.html +96 -59
  42. package/runtime/arcane/components/data-maintenance.html +69 -14
  43. package/runtime/arcane/components/data-view.html +53 -7
  44. package/runtime/arcane/components/directory-picker.html +118 -32
  45. package/runtime/arcane/components/document-inspector.html +47 -10
  46. package/runtime/arcane/components/file-drop.html +81 -35
  47. package/runtime/arcane/components/file-inspector.html +72 -22
  48. package/runtime/arcane/components/file-manager.html +374 -79
  49. package/runtime/arcane/components/integration-settings.html +12 -5
  50. package/runtime/arcane/components/local-ai-status.html +48 -19
  51. package/runtime/arcane/components/markdown-document.html +161 -68
  52. package/runtime/arcane/components/markdown-editor.html +110 -33
  53. package/runtime/arcane/components/media-embed.html +8 -5
  54. package/runtime/arcane/components/modal.html +15 -5
  55. package/runtime/arcane/components/output-panel.html +28 -23
  56. package/runtime/arcane/components/preferences-form.html +22 -4
  57. package/runtime/arcane/components/record-timeline.html +18 -2
  58. package/runtime/arcane/components/relationship-board.html +23 -3
  59. package/runtime/arcane/components/screen-capture.html +10 -4
  60. package/runtime/arcane/components/source-code-viewer.html +76 -9
  61. package/runtime/arcane/components/source-explanation.html +23 -3
  62. package/runtime/arcane/components/speech.html +462 -384
  63. package/runtime/arcane/components/summary-strip.html +22 -11
  64. package/runtime/arcane/components/table.html +39 -21
  65. package/runtime/arcane/components/task-progress.html +79 -21
  66. package/runtime/arcane/components/terminal-workspace.html +7 -4
  67. package/runtime/arcane/components/theme-editor.html +7 -3
  68. package/runtime/arcane/components/unified-inbox.html +9 -4
  69. package/runtime/arcane/components/voice-transcription.html +639 -98
  70. package/runtime/arcane/components/weather-widget.html +5 -3
  71. package/runtime/arcane/components/web-navigator.html +48 -8
  72. package/runtime/arcane/entities/Chat.js +1 -1
  73. package/runtime/arcane/entities/User.js +110 -23
  74. package/runtime/arcane/modules/AI.js +2109 -130
  75. package/runtime/arcane/modules/AIProviderRuntime.js +720 -13
  76. package/runtime/arcane/modules/AIRuntimeState.js +109 -52
  77. package/runtime/arcane/modules/ApiModelDatabase.js +390 -17
  78. package/runtime/arcane/modules/BrowserTestSuite.js +205 -28
  79. package/runtime/arcane/modules/CalculatorEngine.js +63 -3
  80. package/runtime/arcane/modules/CommunicationAppController.js +588 -28
  81. package/runtime/arcane/modules/CommunicationHub.js +590 -10
  82. package/runtime/arcane/modules/ComponentContracts.js +470 -0
  83. package/runtime/arcane/modules/ConversationTimebox.js +152 -33
  84. package/runtime/arcane/modules/DBLS.js +40 -7
  85. package/runtime/arcane/modules/DBOPFS.js +35 -11
  86. package/runtime/arcane/modules/DataMaintenance.js +12 -2
  87. package/runtime/arcane/modules/Errors.js +65 -7
  88. package/runtime/arcane/modules/HTMLImport.js +198 -14
  89. package/runtime/arcane/modules/LocalAIReadinessController.js +208 -29
  90. package/runtime/arcane/modules/Mail.js +738 -115
  91. package/runtime/arcane/modules/MailOutbox.mjs +1395 -0
  92. package/runtime/arcane/modules/MailTransport.mjs +197 -39
  93. package/runtime/arcane/modules/Ollama.js +36 -1
  94. package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +583 -7
  95. package/runtime/arcane/modules/PreferenceStore.js +367 -33
  96. package/runtime/arcane/modules/RecordReviewStore.js +322 -23
  97. package/runtime/arcane/modules/ScreenCapture.js +1397 -15
  98. package/runtime/arcane/modules/SpeechPlayback.js +438 -41
  99. package/runtime/arcane/modules/TerminalClient.js +277 -12
  100. package/runtime/arcane/modules/ThemeBootstrap.js +80 -6
  101. package/runtime/arcane/modules/ThemeManager.js +39 -7
  102. package/runtime/arcane/modules/TimeGuard.js +131 -20
  103. package/runtime/arcane/modules/WaitForComponent.js +386 -33
  104. package/schemas/arcane-lock.schema.json +2 -2
  105. package/src/cli/main.mjs +435 -9
  106. package/src/event-manager.mjs +1097 -1
  107. package/src/import-map.mjs +21 -3
  108. package/src/index.mjs +13 -0
  109. package/src/installed-sdk-runtime.mjs +112 -0
  110. package/src/mail-api.mjs +22 -0
  111. package/src/mail-credentials.mjs +667 -0
  112. package/src/mail-server.mjs +1769 -0
  113. package/src/mail.mjs +261 -0
  114. package/src/sdk-browser-runtime.mjs +85 -41
  115. package/src/testing-loader.mjs +7 -0
  116. package/src/toolchain.mjs +3 -0
  117. package/src/workspace.mjs +1 -1
@@ -1,52 +1,317 @@
1
+ import {createArcaneEventSource} from 'arcane-os/event-manager';
1
2
  import TerminalSession from '../entities/TerminalSession.js';
2
3
 
4
+ export const TERMINAL_CLIENT_EVENT_TYPES=Object.freeze({
5
+ sessionStarted:'terminal-session',
6
+ sessionExited:'terminal-exit',
7
+ sessionError:'terminal-error',
8
+ sessionOutput:'terminal-output'
9
+ });
10
+ export const TERMINAL_CLIENT_ERROR_CODES=Object.freeze({
11
+ capabilityUnavailable:'ARCANE_TERMINAL_CAPABILITY_UNAVAILABLE',
12
+ disposed:'ARCANE_TERMINAL_CLIENT_DISPOSED',
13
+ hostEventSubscriptionInvalid:'ARCANE_TERMINAL_HOST_EVENT_SUBSCRIPTION_INVALID',
14
+ hostEventUnsubscribeRejected:'ARCANE_TERMINAL_HOST_EVENT_UNSUBSCRIBE_REJECTED',
15
+ eventTypeInvalid:'ARCANE_TERMINAL_CLIENT_EVENT_TYPE_INVALID',
16
+ staleSessionCleanupRejected:'ARCANE_TERMINAL_STALE_SESSION_CLEANUP_REJECTED',
17
+ sessionHostError:'ARCANE_TERMINAL_SESSION_HOST_ERROR'
18
+ });
19
+ export const TERMINAL_CLIENT_REASONS=Object.freeze({
20
+ capabilityUnavailable:'terminal-capability-unavailable',
21
+ disposed:'terminal-client-disposed',
22
+ hostEventSubscriptionInvalid:'terminal-host-event-subscription-invalid',
23
+ hostEventUnsubscribeRejected:'terminal-host-event-unsubscribe-rejected',
24
+ eventTypeInvalid:'terminal-client-event-type-invalid',
25
+ staleSessionCleanupRejected:'terminal-stale-session-cleanup-rejected',
26
+ sessionStarted:'terminal-session-started',
27
+ sessionExited:'terminal-session-exited',
28
+ sessionHostErrorReceived:'terminal-session-host-error-received',
29
+ sessionOutputReceived:'terminal-session-output-received'
30
+ });
31
+
32
+ function terminalClientError(code,reason,message,cause){
33
+ const error=cause===undefined
34
+ ?new Error(message)
35
+ :new Error(message,{cause});
36
+ error.code=code;
37
+ error.reason=reason;
38
+ return error;
39
+ }
40
+
41
+ function terminalEventReason(type){
42
+ if(type==='session')return TERMINAL_CLIENT_REASONS.sessionStarted;
43
+ if(type==='exit')return TERMINAL_CLIENT_REASONS.sessionExited;
44
+ if(type==='error')return TERMINAL_CLIENT_REASONS.sessionHostErrorReceived;
45
+ return TERMINAL_CLIENT_REASONS.sessionOutputReceived;
46
+ }
47
+
48
+ function terminalPublicDetail(detail,type,reason){
49
+ const id=detail?.session?.id??detail?.sessionId;
50
+ const output=typeof detail?.data==='string'?detail.data:'';
51
+ const code=type==='error'
52
+ ?typeof detail?.code==='string'&&detail.code
53
+ ?detail.code
54
+ :TERMINAL_CLIENT_ERROR_CODES.sessionHostError
55
+ :null;
56
+ return Object.freeze({
57
+ ...(typeof id==='string'&&id?{id}:{}),
58
+ ...(typeof detail?.stream==='string'?{stream:detail.stream}:{}),
59
+ ...(output?{byteCount:new TextEncoder().encode(output).byteLength}:{}),
60
+ ...(typeof detail?.session?.state==='string'?{status:detail.session.state}:{}),
61
+ ...(code?{code}:{}),
62
+ reason
63
+ });
64
+ }
65
+
3
66
  export default class TerminalClient extends EventTarget{
67
+ #destroyed=false;
68
+ #events;
69
+ #generation=0;
70
+ #operationSequence=0;
4
71
  constructor(api=globalThis.Arcane?.terminal){
5
72
  super();
6
73
  this.api=api||null;
7
74
  this.sessions=new Map();
8
75
  this.unsubscribe=[];
76
+ this.#events=createArcaneEventSource(this,{
77
+ source:'terminal-client',
78
+ eventTypes:Object.freeze(
79
+ Object.values(TERMINAL_CLIENT_EVENT_TYPES)
80
+ )
81
+ });
9
82
  const events=globalThis.Arcane?.events;
10
83
  if(events?.on){
11
- for(const type of ['terminal.output','terminal.exit','terminal.error']){
12
- this.unsubscribe.push(events.on(type,data=>this.receive(type,data)));
84
+ const client=this;
85
+ try{
86
+ for(const type of [
87
+ 'terminal.output',
88
+ 'terminal.exit',
89
+ 'terminal.error'
90
+ ]){
91
+ function receiveTerminalHostEvent(data){
92
+ client.receive(type,data);
93
+ }
94
+ const unsubscribe=events.on(
95
+ type,
96
+ receiveTerminalHostEvent
97
+ );
98
+ if(typeof unsubscribe!=='function'
99
+ &&typeof unsubscribe?.dispose!=='function'){
100
+ throw terminalClientError(
101
+ TERMINAL_CLIENT_ERROR_CODES.hostEventSubscriptionInvalid,
102
+ TERMINAL_CLIENT_REASONS.hostEventSubscriptionInvalid,
103
+ `Arcane.events.on(${type}) must return an unsubscribe function or disposable handle.`
104
+ );
105
+ }
106
+ this.unsubscribe.push(unsubscribe);
107
+ }
108
+ }catch(cause){
109
+ this.#releaseHostSubscriptions();
110
+ this.#events.dispose();
111
+ if(cause?.code===TERMINAL_CLIENT_ERROR_CODES.hostEventSubscriptionInvalid){
112
+ throw cause;
113
+ }
114
+ throw terminalClientError(
115
+ TERMINAL_CLIENT_ERROR_CODES.hostEventSubscriptionInvalid,
116
+ TERMINAL_CLIENT_REASONS.hostEventSubscriptionInvalid,
117
+ 'Terminal host event subscription was rejected.',
118
+ cause
119
+ );
13
120
  }
14
121
  }
15
122
  }
16
123
 
17
- get available(){ return Boolean(this.api?.start&&this.api?.write&&this.api?.close); }
124
+ addEventListener(type,listener,options){return this.#events.addEventListener(type,listener,options);}
125
+ removeEventListener(type,listener,options){return this.#events.removeEventListener(type,listener,options);}
126
+ on(type,listener,options){return this.#events.on(type,listener,options);}
127
+ subscribe(type,listener,options){return this.#events.subscribe(type,listener,options);}
128
+ dispatchEvent(value){return this.#events.dispatchEvent(value);}
129
+
130
+ get available(){
131
+ return !this.#destroyed
132
+ &&Boolean(this.api?.start&&this.api?.write&&this.api?.close);
133
+ }
134
+
135
+ #disposedError(){
136
+ return terminalClientError(
137
+ TERMINAL_CLIENT_ERROR_CODES.disposed,
138
+ TERMINAL_CLIENT_REASONS.disposed,
139
+ 'The terminal client has been disposed.'
140
+ );
141
+ }
142
+
143
+ #assertOpen(){
144
+ if(this.#destroyed)throw this.#disposedError();
145
+ }
146
+
147
+ #assertCapability(method){
148
+ this.#assertOpen();
149
+ if(typeof this.api?.[method]!=='function'){
150
+ throw terminalClientError(
151
+ TERMINAL_CLIENT_ERROR_CODES.capabilityUnavailable,
152
+ TERMINAL_CLIENT_REASONS.capabilityUnavailable,
153
+ `The Arcane native terminal ${method} capability is unavailable. Open Terminal from the installed Arcane shell.`
154
+ );
155
+ }
156
+ }
157
+
158
+ #assertCurrent(generation){
159
+ if(this.#destroyed||generation!==this.#generation){
160
+ throw this.#disposedError();
161
+ }
162
+ }
163
+
164
+ #releaseHostSubscriptions(){
165
+ let firstError=null;
166
+ for(const subscription of this.unsubscribe){
167
+ try{
168
+ if(typeof subscription==='function')subscription();
169
+ else subscription.dispose();
170
+ }catch(error){
171
+ firstError??=error;
172
+ }
173
+ }
174
+ this.unsubscribe=[];
175
+ return firstError;
176
+ }
18
177
 
19
178
  async start(options={}){
20
- if(!this.available) throw new Error('The Arcane native terminal capability is unavailable. Open Terminal from the installed Arcane shell.');
179
+ this.#assertCapability('start');
180
+ const generation=this.#generation;
21
181
  const result=await this.api.start(options);
182
+ if(this.#destroyed||generation!==this.#generation){
183
+ let cleanupError=null;
184
+ if(typeof result?.id==='string'&&typeof this.api?.close==='function'){
185
+ try{
186
+ await this.api.close(result.id);
187
+ }catch(error){
188
+ cleanupError=error;
189
+ }
190
+ }
191
+ if(cleanupError){
192
+ throw terminalClientError(
193
+ TERMINAL_CLIENT_ERROR_CODES.staleSessionCleanupRejected,
194
+ TERMINAL_CLIENT_REASONS.staleSessionCleanupRejected,
195
+ 'The terminal client was disposed and cleanup of its newly started host session was rejected.',
196
+ cleanupError
197
+ );
198
+ }
199
+ throw this.#disposedError();
200
+ }
22
201
  const session=new TerminalSession({...result,state:'running'});
23
202
  this.sessions.set(session.id,session);
24
203
  this.emit('session',{session});
25
204
  return session;
26
205
  }
27
206
 
28
- async write(id,data){ TerminalSession.id(id);return this.api.write(id,String(data??'')); }
29
- async resize(id,columns,rows){ TerminalSession.id(id);return this.api.resize(id,Number(columns),Number(rows)); }
30
- async signal(id,signal='interrupt'){ TerminalSession.id(id);return this.api.signal(id,String(signal)); }
207
+ async write(id,data){
208
+ this.#assertCapability('write');
209
+ TerminalSession.id(id);
210
+ const generation=this.#generation;
211
+ const result=await this.api.write(id,String(data??''));
212
+ this.#assertCurrent(generation);
213
+ return result;
214
+ }
215
+
216
+ async resize(id,columns,rows){
217
+ this.#assertCapability('resize');
218
+ TerminalSession.id(id);
219
+ const generation=this.#generation;
220
+ const result=await this.api.resize(id,Number(columns),Number(rows));
221
+ this.#assertCurrent(generation);
222
+ return result;
223
+ }
224
+
225
+ async signal(id,signal='interrupt'){
226
+ this.#assertCapability('signal');
227
+ TerminalSession.id(id);
228
+ const generation=this.#generation;
229
+ const result=await this.api.signal(id,String(signal));
230
+ this.#assertCurrent(generation);
231
+ return result;
232
+ }
233
+
31
234
  async close(id){
235
+ this.#assertCapability('close');
32
236
  TerminalSession.id(id);
237
+ const generation=this.#generation;
33
238
  const result=await this.api.close(id);
239
+ this.#assertCurrent(generation);
34
240
  const session=this.sessions.get(id);
35
241
  if(session) this.sessions.set(id,session.with({state:'closed'}));
36
242
  return result;
37
243
  }
38
244
 
39
245
  receive(type,data={}){
246
+ if(this.#destroyed)return false;
40
247
  const id=String(data.sessionId||'');
41
- if(!id||!this.sessions.has(id)) return;
248
+ if(!id||!this.sessions.has(id))return false;
42
249
  if(type==='terminal.exit'){
43
250
  const session=this.sessions.get(id).with({state:'exited'});
44
251
  this.sessions.set(id,session);
45
252
  this.emit('exit',{...data,session});
46
- }else if(type==='terminal.error') this.emit('error',data);
47
- else this.emit('output',data);
253
+ }else if(type==='terminal.error'){
254
+ this.emit('error',data);
255
+ }else{
256
+ this.emit('output',data);
257
+ }
258
+ return true;
259
+ }
260
+
261
+ emit(type,detail){
262
+ this.#assertOpen();
263
+ if(!['session','exit','error','output'].includes(type)){
264
+ throw terminalClientError(
265
+ TERMINAL_CLIENT_ERROR_CODES.eventTypeInvalid,
266
+ TERMINAL_CLIENT_REASONS.eventTypeInvalid,
267
+ 'Terminal client events must be session, exit, error, or output.'
268
+ );
269
+ }
270
+ const eventType=TERMINAL_CLIENT_EVENT_TYPES[
271
+ type==='session'
272
+ ?'sessionStarted'
273
+ :type==='exit'
274
+ ?'sessionExited'
275
+ :type==='error'
276
+ ?'sessionError'
277
+ :'sessionOutput'
278
+ ];
279
+ const reason=terminalEventReason(type);
280
+ this.#operationSequence+=1;
281
+ const operationId=`${this.#events.instanceId}:${type}:${this.#operationSequence.toString(36)}`;
282
+ const compatibilityDetail=Object.freeze({
283
+ ...detail,
284
+ operationId,
285
+ reason
286
+ });
287
+ return this.#events.dispatch(
288
+ eventType,
289
+ compatibilityDetail,
290
+ {
291
+ operationId,
292
+ publicDetail:terminalPublicDetail(detail,type,reason)
293
+ }
294
+ ).occurrence;
295
+ }
296
+
297
+ destroy(){
298
+ if(this.#destroyed)return false;
299
+ this.#destroyed=true;
300
+ this.#generation+=1;
301
+ const unsubscribeError=this.#releaseHostSubscriptions();
302
+ this.#events.dispose();
303
+ if(unsubscribeError){
304
+ throw terminalClientError(
305
+ TERMINAL_CLIENT_ERROR_CODES.hostEventUnsubscribeRejected,
306
+ TERMINAL_CLIENT_REASONS.hostEventUnsubscribeRejected,
307
+ 'A terminal host event subscription rejected cleanup.',
308
+ unsubscribeError
309
+ );
310
+ }
311
+ return true;
48
312
  }
49
313
 
50
- emit(type,detail){ this.dispatchEvent(new CustomEvent(`terminal-${type}`,{detail})); }
51
- destroy(){ for(const off of this.unsubscribe) if(typeof off==='function') off();this.unsubscribe=[]; }
314
+ dispose(){
315
+ return this.destroy();
316
+ }
52
317
  }
@@ -1,11 +1,83 @@
1
1
  import {loadAndApplyTheme} from './ThemeManager.js';
2
+ import {createArcaneEventSource} from 'arcane-os/event-manager';
2
3
 
3
4
  const sharedKey='arcaneThemeReady';
4
5
  const listenerKey='arcaneThemeAppearanceListener';
6
+ const THEME_BOOTSTRAP_EVENT_OWNER=Object.freeze({});
7
+ let themeBootstrapOperationSequence=0;
8
+ const themeBootstrapEvents=createArcaneEventSource(
9
+ THEME_BOOTSTRAP_EVENT_OWNER,
10
+ {
11
+ source:'theme-bootstrap',
12
+ eventTypes:Object.freeze(['appearance.changed'])
13
+ }
14
+ );
15
+
16
+ function installAppearanceListener(ready){
17
+ const hostEvents=globalThis.Arcane?.events;
18
+ if(typeof hostEvents?.on!=='function'||globalThis[listenerKey]){
19
+ return;
20
+ }
21
+
22
+ let active=true;
23
+ const hostUnsubscribe=hostEvents.on(
24
+ 'appearance.changed',
25
+ function forwardAppearanceChange(detail={}){
26
+ themeBootstrapOperationSequence+=1;
27
+ const forwarded=Object.freeze({
28
+ scheme:typeof detail?.scheme==='string'?detail.scheme:null,
29
+ effectiveScheme:typeof detail?.effectiveScheme==='string'
30
+ ?detail.effectiveScheme
31
+ :null,
32
+ source:typeof detail?.source==='string'?detail.source:null,
33
+ reason:'host-appearance-changed'
34
+ });
35
+ themeBootstrapEvents.dispatch(
36
+ 'appearance.changed',
37
+ forwarded,
38
+ {
39
+ operationId:`theme-bootstrap-${themeBootstrapEvents.instanceId}-${themeBootstrapOperationSequence}`,
40
+ publicDetail:forwarded
41
+ }
42
+ );
43
+ Promise.resolve(ready).then(function reloadTheme(result){
44
+ return result?.manager?.load?.();
45
+ }).catch(function reportThemeReloadFailure(error){
46
+ console.warn(
47
+ '[Arcane theme] Unable to apply the changed host appearance.',
48
+ error
49
+ );
50
+ });
51
+ }
52
+ );
53
+ const dispose=function disposeArcaneThemeAppearanceListener(){
54
+ if(!active){
55
+ return false;
56
+ }
57
+ active=false;
58
+ if(typeof hostUnsubscribe==='function'){
59
+ hostUnsubscribe();
60
+ }
61
+ if(globalThis[listenerKey]===dispose){
62
+ delete globalThis[listenerKey];
63
+ }
64
+ return true;
65
+ };
66
+ Object.defineProperty(dispose,'dispose',{
67
+ value:dispose,
68
+ enumerable:false,
69
+ configurable:false,
70
+ writable:false
71
+ });
72
+ globalThis[listenerKey]=dispose;
73
+ }
5
74
 
6
75
  export function bootstrapArcaneTheme(options={}){
7
76
  const useSharedPromise=Object.keys(options).length===0;
8
- if(useSharedPromise&&globalThis[sharedKey]) return globalThis[sharedKey];
77
+ if(useSharedPromise&&globalThis[sharedKey]){
78
+ installAppearanceListener(globalThis[sharedKey]);
79
+ return globalThis[sharedKey];
80
+ }
9
81
 
10
82
  const ready=loadAndApplyTheme(options).catch(error=>{
11
83
  console.warn('[Arcane theme] Unable to load the saved appearance; using the system theme.',error);
@@ -13,14 +85,16 @@ export function bootstrapArcaneTheme(options={}){
13
85
  });
14
86
 
15
87
  if(useSharedPromise) globalThis[sharedKey]=ready;
16
- if(useSharedPromise&&globalThis.Arcane?.events?.on&&!globalThis[listenerKey]){
17
- globalThis[listenerKey]=globalThis.Arcane.events.on('appearance.changed',async()=>{
18
- const result=await ready;
19
- if(result?.manager) await result.manager.load();
20
- });
88
+ if(useSharedPromise){
89
+ installAppearanceListener(ready);
21
90
  }
22
91
  return ready;
23
92
  }
24
93
 
94
+ export function disposeArcaneThemeBootstrap(){
95
+ const dispose=globalThis[listenerKey];
96
+ return typeof dispose==='function'?dispose():false;
97
+ }
98
+
25
99
  export const arcaneThemeReady=bootstrapArcaneTheme();
26
100
  export default arcaneThemeReady;
@@ -2,6 +2,10 @@ import Theme,{arcaneDarkThemeTokens,arcaneLightThemeTokens} from '../entities/Th
2
2
  import PreferenceStore from './PreferenceStore.js';
3
3
  import {applyAppearancePreferences,createAppearancePreferenceStore} from './AppearancePreferences.js';
4
4
  import SystemAppearance from './SystemAppearance.js';
5
+ import {
6
+ createArcaneEventSource,
7
+ projectArcaneDOMEvent
8
+ } from 'arcane-os/event-manager';
5
9
 
6
10
  const skinSchema=Object.freeze([
7
11
  {key:'appearance.activeSkin',type:'text',defaultValue:''},
@@ -9,6 +13,9 @@ const skinSchema=Object.freeze([
9
13
  ]);
10
14
 
11
15
  export default class ThemeManager{
16
+ #events;
17
+ #operationSequence=0;
18
+
12
19
  constructor({appearanceStore=null,skinStore=null,systemAppearance=null,root=null}={}){
13
20
  this.appearanceStore=appearanceStore||createAppearancePreferenceStore();
14
21
  this.skinStore=skinStore||new PreferenceStore({namespace:'arcane',schema:skinSchema});
@@ -17,6 +24,10 @@ export default class ThemeManager{
17
24
  this.appearance=this.appearanceStore.defaults();
18
25
  this.skinState=this.skinStore.defaults();
19
26
  this.customTheme=null;
27
+ this.#events=createArcaneEventSource(this,{
28
+ source:'theme-manager',
29
+ eventTypes:Object.freeze(['arcane-theme-change'])
30
+ });
20
31
  }
21
32
 
22
33
  async load(){
@@ -53,7 +64,7 @@ export default class ThemeManager{
53
64
  ]);
54
65
  this.appearance={...this.appearance,'appearance.colorScheme':normalized};
55
66
  this.skinState={...this.skinState,'appearance.activeSkin':''};
56
- this.apply();await this.syncSystemAppearance();this.emit();
67
+ this.apply();await this.syncSystemAppearance();this.emit('color-scheme-changed');
57
68
  return this.current();
58
69
  }
59
70
 
@@ -67,7 +78,7 @@ export default class ThemeManager{
67
78
  this.customTheme=theme;
68
79
  this.skinState={...this.skinState,'appearance.customSkin':JSON.stringify(theme),'appearance.activeSkin':'custom'};
69
80
  this.appearance={...this.appearance,'appearance.colorScheme':theme.scheme};
70
- this.apply();await this.syncSystemAppearance();this.emit();
81
+ this.apply();await this.syncSystemAppearance();this.emit('custom-theme-activated');
71
82
  return this.current();
72
83
  }
73
84
 
@@ -80,7 +91,7 @@ export default class ThemeManager{
80
91
  ]);
81
92
  this.skinState={...this.skinState,'appearance.activeSkin':'custom'};
82
93
  this.appearance={...this.appearance,'appearance.colorScheme':this.customTheme.scheme};
83
- this.apply();await this.syncSystemAppearance();this.emit();
94
+ this.apply();await this.syncSystemAppearance();this.emit('custom-theme-activated');
84
95
  return this.current();
85
96
  }
86
97
 
@@ -97,7 +108,7 @@ export default class ThemeManager{
97
108
  ]);
98
109
  this.customTheme=null;
99
110
  this.skinState={...this.skinState,'appearance.customSkin':'','appearance.activeSkin':''};
100
- this.apply();await this.syncSystemAppearance();this.emit();
111
+ this.apply();await this.syncSystemAppearance();this.emit('custom-theme-reset');
101
112
  return this.current();
102
113
  }
103
114
 
@@ -112,10 +123,31 @@ export default class ThemeManager{
112
123
  });
113
124
  }
114
125
 
115
- emit(){
116
- if(typeof globalThis.CustomEvent==='function'&&typeof globalThis.dispatchEvent==='function'){
117
- globalThis.dispatchEvent(new CustomEvent('arcane-theme-change',{detail:this.current()}));
126
+ emit(reason='theme-state-applied'){
127
+ const state=this.current();
128
+ const detail=Object.freeze({...state,reason});
129
+ this.#operationSequence+=1;
130
+ const {occurrence}=this.#events.dispatch(
131
+ 'arcane-theme-change',
132
+ detail,
133
+ {
134
+ operationId:`theme-manager-${this.#events.instanceId}-${this.#operationSequence}`,
135
+ publicDetail:Object.freeze({mode:detail.mode,reason})
136
+ }
137
+ );
138
+ if(typeof globalThis.CustomEvent==='function'
139
+ &&typeof globalThis.dispatchEvent==='function'){
140
+ projectArcaneDOMEvent(globalThis,occurrence);
118
141
  }
142
+ return occurrence;
143
+ }
144
+
145
+ dispose(){
146
+ return this.#events.dispose();
147
+ }
148
+
149
+ destroy(){
150
+ return this.dispose();
119
151
  }
120
152
  }
121
153