arcane-os 0.59.0 → 0.60.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.
@@ -3,11 +3,12 @@ const is=new Is(false);
3
3
 
4
4
  import { arcaneLogging } from 'arcane-os/logging';
5
5
  import {applyUserSkin,loadAndApplyTheme} from './ThemeManager.js';
6
- import {createArcaneEventSource} from 'arcane-os/event-manager';
6
+ import {arcaneEvents,createArcaneEventSource} from 'arcane-os/event-manager';
7
7
 
8
8
  const sharedKey='arcaneThemeReady';
9
9
  const listenerKey='arcaneThemeAppearanceListener';
10
10
  const userListenerKey='arcaneThemeUserListener';
11
+ const windowListenerKey='arcaneThemeWindowListener';
11
12
  const THEME_BOOTSTRAP_EVENT_OWNER={};
12
13
  let themeBootstrapOperationSequence=0;
13
14
  const themeBootstrapEvents=createArcaneEventSource(
@@ -99,11 +100,222 @@ function installUserSkinListener(){
99
100
  applyReadyUserSkin();
100
101
  }
101
102
 
103
+ function installWindowThemeListener(ready){
104
+ const host=globalThis.Arcane;
105
+ const document=globalThis.document;
106
+ if(globalThis[windowListenerKey]||!host?.runtime?.current?.().native
107
+ ||!is.function(host?.window?.setTheme)||!document?.documentElement) return;
108
+
109
+ let active=true;
110
+ let suspended=false;
111
+ let scheduled=false;
112
+ let lastPresentation=null;
113
+ let pending=null;
114
+ let observedBody=null;
115
+ let canvasContext=null;
116
+ const root=document.documentElement;
117
+ const scheme=globalThis.matchMedia('(prefers-color-scheme: dark)');
118
+ const print=globalThis.matchMedia('print');
119
+ const probe=document.createElement('span');
120
+ probe.style.setProperty('display','none','important');
121
+
122
+ function report(error){
123
+ arcaneLogging.warn('[Arcane theme] Unable to update the native window colors.',error);
124
+ }
125
+ function colorRecord(resolved){
126
+ // Read the browser's resolved color, not an authored CSS grammar. Keep
127
+ // its alpha separately so raster conversion cannot round translucency away.
128
+ const rgb=resolved.match(/^rgba?\(([^)]+)\)$/u);
129
+ if(rgb){
130
+ const values=rgb[1].split(/[\s,/]+/u).filter(Boolean).map(Number);
131
+ if((values.length===3||values.length===4)&&values.every(Number.isFinite)){
132
+ return {red:values[0],green:values[1],blue:values[2],alpha:values[3]??1};
133
+ }
134
+ }
135
+ const alphaMatch=resolved.match(/\/\s*(none|[-+\d.eE]+%?)\s*\)$/u);
136
+ const alphaValue=alphaMatch?.[1];
137
+ const alpha=alphaValue===undefined?1:alphaValue==='none'?0:
138
+ Number.parseFloat(alphaValue)/(alphaValue.endsWith('%')?100:1);
139
+ if(!Number.isFinite(alpha)||(resolved.includes('/')&&!alphaMatch)){
140
+ throw new TypeError(`Unable to read the resolved CSS color: ${resolved}`);
141
+ }
142
+ const opaque=alphaMatch?resolved.replace(/\/[^/]*\)$/u,'/ 1)'):resolved;
143
+ if(!canvasContext){
144
+ const canvas=document.createElement('canvas');
145
+ canvas.width=1;
146
+ canvas.height=1;
147
+ canvasContext=canvas.getContext(
148
+ '2d',
149
+ {colorSpace:'srgb'}
150
+ );
151
+ if(!canvasContext) throw new Error('The browser cannot convert the resolved color to sRGB.');
152
+ }
153
+ // An unsupported fillStyle assignment leaves the old value intact.
154
+ // Two different starting values distinguish rejection from a valid black.
155
+ canvasContext.fillStyle='#000000';
156
+ canvasContext.fillStyle=opaque;
157
+ const accepted=canvasContext.fillStyle;
158
+ canvasContext.fillStyle='#ffffff';
159
+ canvasContext.fillStyle=opaque;
160
+ if(canvasContext.fillStyle!==accepted){
161
+ throw new TypeError(`The browser canvas cannot convert the resolved CSS color: ${resolved}`);
162
+ }
163
+ canvasContext.clearRect(0,0,1,1);
164
+ canvasContext.fillRect(0,0,1,1);
165
+ const [red,green,blue]=canvasContext.getImageData(0,0,1,1).data;
166
+ return {red,green,blue,alpha};
167
+ }
168
+ function readPresentation(){
169
+ const target=document.body;
170
+ if(!target) return {};
171
+ const styles=globalThis.getComputedStyle(target);
172
+ const presentation={};
173
+ target.appendChild(probe);
174
+ try {
175
+ const properties=[
176
+ ['backgroundColor','--background'],
177
+ ['textColor','--text-color']
178
+ ];
179
+ for(const [field,property] of properties){
180
+ const authored=styles.getPropertyValue(property);
181
+ if(!authored.trim()) continue;
182
+ if(!globalThis.CSS.supports('color',authored)){
183
+ report(
184
+ new TypeError(`The app theme ${property} is not a supported CSS color: ${authored}`)
185
+ );
186
+ continue;
187
+ }
188
+ // Use the target's resolved value: registered properties may
189
+ // deliberately not inherit into this child resolution element.
190
+ probe.style.setProperty('color',authored,'important');
191
+ try {
192
+ const resolved=globalThis.getComputedStyle(probe).color;
193
+ presentation[field]=colorRecord(resolved);
194
+ } catch (error) {
195
+ report(error);
196
+ }
197
+ }
198
+ } finally {
199
+ probe.remove();
200
+ }
201
+ return presentation;
202
+ }
203
+ function samePresentation(next){
204
+ return lastPresentation&&['backgroundColor','textColor'].every(
205
+ function sameColor(field){
206
+ const before=lastPresentation[field];
207
+ const after=next[field];
208
+ if(before===after) return true;
209
+ if(!before||!after) return false;
210
+ return ['red','green','blue','alpha'].every(
211
+ function sameChannel(channel){
212
+ return before[channel]===after[channel];
213
+ }
214
+ );
215
+ }
216
+ );
217
+ }
218
+ function observeElements(){
219
+ if(observedBody===document.body) return;
220
+ observer.disconnect();
221
+ observer.observe(
222
+ root,
223
+ {attributes:true,childList:true,attributeFilter:['class','style','data-color-scheme','data-user-skin','data-arcane-skin']}
224
+ );
225
+ observedBody=document.body;
226
+ if(observedBody) observer.observe(
227
+ observedBody,
228
+ {attributes:true,attributeFilter:['class','style']}
229
+ );
230
+ }
231
+ function sample(){
232
+ scheduled=false;
233
+ if(!active||suspended||print.matches) return;
234
+ observeElements();
235
+ const presentation=readPresentation();
236
+ if(!Object.keys(presentation).length||samePresentation(presentation)) return;
237
+ lastPresentation=presentation;
238
+ pending?.abort();
239
+ const controller=new AbortController();
240
+ pending=controller;
241
+ try {
242
+ const operation=host.window.setTheme(
243
+ presentation,
244
+ {signal:controller.signal}
245
+ );
246
+ Promise.resolve(operation).catch(
247
+ function reportWindowFailure(error){
248
+ if(error?.code==='ARCANE_REQUEST_ABORTED'||(controller.signal.aborted&&error?.name==='AbortError')) return;
249
+ report(error);
250
+ if(error?.code==='METHOD_NOT_ALLOWED') dispose();
251
+ }
252
+ ).finally(
253
+ function finishWindowUpdate(){
254
+ if(pending===controller) pending=null;
255
+ }
256
+ );
257
+ } catch (error) {
258
+ if(pending===controller) pending=null;
259
+ report(error);
260
+ }
261
+ }
262
+ function schedule(){
263
+ if(!active||suspended||scheduled) return;
264
+ scheduled=true;
265
+ Promise.resolve().then(sample).catch(report);
266
+ }
267
+ function stylesheetLoaded(event){
268
+ if(event.target?.tagName==='LINK'&&event.target.relList?.contains('stylesheet')) schedule();
269
+ }
270
+ function hide(){
271
+ suspended=true;
272
+ pending?.abort();
273
+ }
274
+ function show(){
275
+ suspended=false;
276
+ lastPresentation=null;
277
+ schedule();
278
+ }
279
+ function dispose(){
280
+ if(!active) return false;
281
+ active=false;
282
+ pending?.abort();
283
+ observer.disconnect();
284
+ unsubscribe();
285
+ scheme.removeEventListener('change',schedule);
286
+ print.removeEventListener('change',schedule);
287
+ document.removeEventListener('DOMContentLoaded',schedule);
288
+ document.removeEventListener('load',stylesheetLoaded,true);
289
+ globalThis.removeEventListener('pagehide',hide);
290
+ globalThis.removeEventListener('pageshow',show);
291
+ canvasContext=null;
292
+ if(globalThis[windowListenerKey]===dispose) delete globalThis[windowListenerKey];
293
+ return true;
294
+ }
295
+ const observer=new globalThis.MutationObserver(schedule);
296
+ const unsubscribe=arcaneEvents.subscribe('arcane-theme-change',schedule);
297
+ observer.observe(
298
+ root,
299
+ {attributes:true,childList:true,attributeFilter:['class','style','data-color-scheme','data-user-skin','data-arcane-skin']}
300
+ );
301
+ observeElements();
302
+ scheme.addEventListener('change',schedule);
303
+ print.addEventListener('change',schedule);
304
+ document.addEventListener('DOMContentLoaded',schedule);
305
+ document.addEventListener('load',stylesheetLoaded,true);
306
+ globalThis.addEventListener('pagehide',hide);
307
+ globalThis.addEventListener('pageshow',show);
308
+ globalThis[windowListenerKey]=dispose;
309
+ schedule();
310
+ Promise.resolve(ready).then(schedule).catch(report);
311
+ }
312
+
102
313
  export function bootstrapArcaneTheme(options={}){
103
314
  const useSharedPromise=Object.keys(options).length===0;
104
315
  if(useSharedPromise&&globalThis[sharedKey]){
105
316
  installAppearanceListener(globalThis[sharedKey]);
106
317
  installUserSkinListener();
318
+ installWindowThemeListener(globalThis[sharedKey]);
107
319
  return globalThis[sharedKey];
108
320
  }
109
321
 
@@ -116,6 +328,7 @@ export function bootstrapArcaneTheme(options={}){
116
328
  if(useSharedPromise){
117
329
  installAppearanceListener(ready);
118
330
  installUserSkinListener();
331
+ installWindowThemeListener(ready);
119
332
  }
120
333
  return ready;
121
334
  }
@@ -123,9 +336,11 @@ export function bootstrapArcaneTheme(options={}){
123
336
  export function disposeArcaneThemeBootstrap(){
124
337
  const dispose=globalThis[listenerKey];
125
338
  const disposeUser=globalThis[userListenerKey];
339
+ const disposeWindow=globalThis[windowListenerKey];
126
340
  const appearanceDisposed=is.function(dispose)?dispose():false;
127
341
  const userDisposed=is.function(disposeUser)?disposeUser():false;
128
- return appearanceDisposed||userDisposed;
342
+ const windowDisposed=is.function(disposeWindow)?disposeWindow():false;
343
+ return appearanceDisposed||userDisposed||windowDisposed;
129
344
  }
130
345
 
131
346
  export const arcaneThemeReady=bootstrapArcaneTheme();
@@ -1,6 +1,7 @@
1
1
  using System;
2
2
  using System.Collections;
3
3
  using System.Collections.Generic;
4
+ using System.Drawing;
4
5
  using System.IO;
5
6
  using System.Runtime.InteropServices;
6
7
  using System.Threading;
@@ -20,6 +21,7 @@ namespace Arcane.Core.Hosts.Windows
20
21
  public string OriginHost { get; set; }
21
22
  public string ProfileDirectory { get; set; }
22
23
  public string Title { get; set; }
24
+ public string IconPath { get; set; }
23
25
  public string ClassicClientSource { get; set; }
24
26
  public string CoreExecutable { get; set; }
25
27
  public string CoreArguments { get; set; }
@@ -106,6 +108,8 @@ namespace Arcane.Core.Hosts.Windows
106
108
  private Task initialization;
107
109
  private Task coreLifetime;
108
110
  private Task shutdown;
111
+ private Task iconLoading;
112
+ private Icon applicationIcon;
109
113
  private string injectedScript;
110
114
  private Exception transportFailure;
111
115
  private long generation;
@@ -162,6 +166,8 @@ namespace Arcane.Core.Hosts.Windows
162
166
  string profile = Path.GetFullPath(options.ProfileDirectory);
163
167
  Uri origin = new UriBuilder(Uri.UriSchemeHttps, options.OriginHost).Uri;
164
168
  Uri start = new Uri(origin, options.StartPath);
169
+ iconLoading = LoadApplicationIconAsync();
170
+ iconLoading.ContinueWith(ObserveUnexpectedFailure, TaskContinuationOptions.OnlyOnFaulted);
165
171
  core = ArcaneCoreProcess.Start(options.CoreExecutable, options.CoreArguments,
166
172
  options.CoreWorkingDirectory, ReceiveCoreMessage, DeliverDiagnostic, CoreFailed);
167
173
  coreLifetime = ObserveCoreLifetimeAsync();
@@ -228,6 +234,18 @@ namespace Arcane.Core.Hosts.Windows
228
234
  {
229
235
  if (documentGeneration != generation || closing)
230
236
  throw new InvalidOperationException("The requesting document is no longer connected.");
237
+ if (requestId != null && EnvelopeString(envelope, "protocol") == "arcane/1"
238
+ && EnvelopeString(envelope, "method") == "window.setTheme")
239
+ {
240
+ // This method belongs to this exact window, not the Core
241
+ // child or a machine-wide appearance service.
242
+ string response = WindowThemeResponse(envelope, requestId);
243
+ pending.Enqueue(
244
+ new CoreDelivery(documentGeneration, response)
245
+ );
246
+ ScheduleDelivery();
247
+ return Task.FromResult<object>(null);
248
+ }
231
249
  Task accepted = core.SendAsync(json);
232
250
  // Hold the same lock used by the reader so an immediate response
233
251
  // cannot overtake its accepted transport correlation record.
@@ -237,6 +255,181 @@ namespace Arcane.Core.Hosts.Windows
237
255
  }
238
256
  }
239
257
 
258
+ private async Task LoadApplicationIconAsync()
259
+ {
260
+ if (String.IsNullOrEmpty(options.IconPath)) return;
261
+ try
262
+ {
263
+ Icon loaded = await Task.Run(new Func<Icon>(ReadApplicationIcon));
264
+ if (closing)
265
+ {
266
+ loaded.Dispose();
267
+ return;
268
+ }
269
+ applicationIcon = loaded;
270
+ Icon = loaded;
271
+ }
272
+ catch (Exception error)
273
+ {
274
+ // Image codec availability is separate from application
275
+ // execution. Preserve ordinary startup and complete diagnostics.
276
+ object record = ArcaneHost.ErrorRecord(error, "ARCANE_WINDOW_ICON_UNAVAILABLE");
277
+ string diagnostic = ArcaneHost.Serializer().Serialize(record);
278
+ DeliverDiagnostic(diagnostic + Environment.NewLine);
279
+ }
280
+ }
281
+
282
+ private Icon ReadApplicationIcon()
283
+ {
284
+ string extension = Path.GetExtension(options.IconPath);
285
+ if (String.Equals(extension, ".ico", StringComparison.OrdinalIgnoreCase))
286
+ return new Icon(options.IconPath);
287
+ using (Bitmap bitmap = new Bitmap(options.IconPath))
288
+ {
289
+ IntPtr handle = bitmap.GetHicon();
290
+ try
291
+ {
292
+ using (Icon borrowed = System.Drawing.Icon.FromHandle(handle))
293
+ {
294
+ return (Icon)borrowed.Clone();
295
+ }
296
+ }
297
+ finally
298
+ {
299
+ DestroyIcon(handle);
300
+ }
301
+ }
302
+ }
303
+
304
+ private string WindowThemeResponse(Dictionary<string, object> request, string id)
305
+ {
306
+ Dictionary<string, object> applied = new Dictionary<string, object>();
307
+ List<string> unsupported = new List<string>();
308
+ Dictionary<string, object> response = new Dictionary<string, object>
309
+ {
310
+ { "protocol", "arcane/1" },
311
+ { "type", "response" },
312
+ { "id", id }
313
+ };
314
+ try
315
+ {
316
+ object parameters;
317
+ Dictionary<string, object> presentation = request.TryGetValue("parameters", out parameters)
318
+ ? parameters as Dictionary<string, object> : null;
319
+ if (presentation == null) throw new ArgumentException("Window presentation must be an object.");
320
+ ApplyWindowColor(presentation, "backgroundColor", 35, applied, unsupported);
321
+ ApplyWindowColor(presentation, "textColor", 36, applied, unsupported);
322
+ response["ok"] = true;
323
+ response["result"] = new Dictionary<string, object>
324
+ {
325
+ { "platform", "windows" },
326
+ { "supported", applied.Count != 0 },
327
+ { "applied", applied },
328
+ { "unsupported", unsupported }
329
+ };
330
+ }
331
+ catch (Exception error)
332
+ {
333
+ Dictionary<string, object> record = (Dictionary<string, object>)ArcaneHost.ErrorRecord(error,
334
+ error is ArgumentException ? "INVALID_ARGUMENT" : "ARCANE_WINDOW_THEME_FAILED");
335
+ record["details"] = new Dictionary<string, object>
336
+ {
337
+ { "applied", applied },
338
+ { "unsupported", unsupported }
339
+ };
340
+ response["ok"] = false;
341
+ response["error"] = record;
342
+ }
343
+ return ArcaneHost.Serializer().Serialize(response);
344
+ }
345
+
346
+ private void ApplyWindowColor(Dictionary<string, object> presentation, string field, int attribute,
347
+ Dictionary<string, object> applied, List<string> unsupported)
348
+ {
349
+ object supplied;
350
+ if (!presentation.TryGetValue(field, out supplied)) return;
351
+ uint nativeColor = UInt32.MaxValue;
352
+ object accepted = null;
353
+ double alpha = 1;
354
+ if (supplied != null)
355
+ {
356
+ Dictionary<string, object> color = supplied as Dictionary<string, object>;
357
+ if (color == null) throw new ArgumentException(field + " must be an RGBA record or null.");
358
+ int red = (int)Math.Round(
359
+ ColorChannel(color, "red", 255),
360
+ MidpointRounding.AwayFromZero
361
+ );
362
+ int green = (int)Math.Round(
363
+ ColorChannel(color, "green", 255),
364
+ MidpointRounding.AwayFromZero
365
+ );
366
+ int blue = (int)Math.Round(
367
+ ColorChannel(color, "blue", 255),
368
+ MidpointRounding.AwayFromZero
369
+ );
370
+ alpha = ColorChannel(color, "alpha", 1);
371
+ nativeColor = (uint)(red | green << 8 | blue << 16);
372
+ accepted = new Dictionary<string, object>
373
+ {
374
+ { "red", red },
375
+ { "green", green },
376
+ { "blue", blue },
377
+ { "alpha", alpha }
378
+ };
379
+ }
380
+ if (alpha != 1 || !WindowColorsSupported())
381
+ {
382
+ unsupported.Add(field);
383
+ return;
384
+ }
385
+ int result = DwmSetWindowAttribute(Handle, attribute, ref nativeColor, sizeof(uint));
386
+ if (result < 0) throw new COMException("Applying window " + field + " failed.", result);
387
+ applied[field] = accepted;
388
+ }
389
+
390
+ private static double ColorChannel(Dictionary<string, object> color, string name, double maximum)
391
+ {
392
+ object value;
393
+ if (!color.TryGetValue(name, out value)
394
+ || !(value is int || value is long || value is double || value is decimal))
395
+ throw new ArgumentException("Window color " + name + " must be numeric.");
396
+ double number = Convert.ToDouble(value);
397
+ if (Double.IsNaN(number) || Double.IsInfinity(number) || number < 0 || number > maximum)
398
+ throw new ArgumentException("Window color " + name + " is outside its color-channel range.");
399
+ return number;
400
+ }
401
+
402
+ private static bool WindowColorsSupported()
403
+ {
404
+ WindowsVersion version = new WindowsVersion();
405
+ version.StructureSize = (uint)Marshal.SizeOf(typeof(WindowsVersion));
406
+ int result = RtlGetVersion(ref version);
407
+ if (result != 0) throw new ExternalException("Reading the Windows version failed.", result);
408
+ return version.Major > 10 || (version.Major == 10 && version.Build >= 22000);
409
+ }
410
+
411
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
412
+ private struct WindowsVersion
413
+ {
414
+ internal uint StructureSize;
415
+ internal uint Major;
416
+ internal uint Minor;
417
+ internal uint Build;
418
+ internal uint Platform;
419
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
420
+ internal string ServicePack;
421
+ }
422
+
423
+ [DllImport("ntdll.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
424
+ private static extern int RtlGetVersion(ref WindowsVersion version);
425
+
426
+ [DllImport("dwmapi.dll")]
427
+ private static extern int DwmSetWindowAttribute(IntPtr window, int attribute, ref uint value, int valueSize);
428
+
429
+ [DllImport("user32.dll", SetLastError = true)]
430
+ [return: MarshalAs(UnmanagedType.Bool)]
431
+ private static extern bool DestroyIcon(IntPtr icon);
432
+
240
433
  private void DocumentConnected(long documentGeneration)
241
434
  {
242
435
  ArcaneBridge previous;
@@ -498,6 +691,19 @@ namespace Arcane.Core.Hosts.Windows
498
691
  catch (Exception error) { ReportTaskFailure(coreLifetime, error); }
499
692
  }
500
693
  }
694
+ // Joining the independent image owner belongs after Core input has
695
+ // closed; image I/O must never delay accepted service work draining.
696
+ if (iconLoading != null)
697
+ {
698
+ try
699
+ {
700
+ await iconLoading;
701
+ }
702
+ catch (Exception error)
703
+ {
704
+ ReportTaskFailure(iconLoading, error);
705
+ }
706
+ }
501
707
  Task notifications = Task.WhenAll(failureNotifications);
502
708
  try { await notifications; }
503
709
  catch (Exception error) { ReportTaskFailure(notifications, error); }
@@ -546,6 +752,11 @@ namespace Arcane.Core.Hosts.Windows
546
752
  protected override void OnFormClosed(FormClosedEventArgs args)
547
753
  {
548
754
  base.OnFormClosed(args);
755
+ if (applicationIcon != null)
756
+ {
757
+ applicationIcon.Dispose();
758
+ applicationIcon = null;
759
+ }
549
760
  CompleteWindow();
550
761
  }
551
762
 
@@ -51,6 +51,10 @@ namespace Arcane.Core.Hosts.Windows
51
51
  string appId = RequiredString(app, "id");
52
52
  object title;
53
53
  string name = app.TryGetValue("displayName", out title) && title is string ? (string)title : appId;
54
+ object hostValue;
55
+ object iconValue;
56
+ Dictionary<string, object> host = manifest.TryGetValue("host", out hostValue) ? hostValue as Dictionary<string, object> : null;
57
+ string icon = host != null && host.TryGetValue("icon", out iconValue) ? iconValue as string : null;
54
58
 
55
59
  // Core rereads this exact selected file. Do not normalize,
56
60
  // rewrite, filter or copy the application's launch record.
@@ -67,6 +71,7 @@ namespace Arcane.Core.Hosts.Windows
67
71
  OriginHost = "arcane.local",
68
72
  ProfileDirectory = Path.Combine(profileParent, "WebView2"),
69
73
  Title = name,
74
+ IconPath = String.IsNullOrEmpty(icon) ? null : Path.Combine(directory, icon),
70
75
  ClassicClientSource = File.ReadAllText(Path.Combine(directory, RequiredString(client, "source"))),
71
76
  CoreExecutable = Path.Combine(directory, "runtime", "ArcaneCore.exe"),
72
77
  // Forward the actual host selection separately; Core keeps