arcane-os 0.67.0 → 0.69.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.
@@ -1,11 +1,62 @@
1
1
  <style>
2
- :host{display:inline-block;color:inherit}
3
- .switcher{align-items:center;display:flex;gap:.15rem;padding:.2rem;border:1px solid var(--app-bar-border,var(--border-color,rgba(255,255,255,.25)));border-radius:999px;background:color-mix(in srgb,currentColor 8%,transparent)}
4
- button{appearance:none;border:0;border-radius:999px;background:transparent;color:inherit;cursor:pointer;font:700 .68rem/1 system-ui,sans-serif;min-height:1.85rem;padding:.42rem .58rem}
5
- button:hover{background:color-mix(in srgb,currentColor 14%,transparent)}
6
- button[aria-pressed="true"]{background:var(--app-bar-accent,var(--secondary-color,#c49a52));color:var(--app-bar-active-text,var(--primary-color,#172238))}
7
- button:focus-visible{outline:.18rem solid var(--focus-color,currentColor);outline-offset:.12rem}
8
- @media(max-width:32rem){button{font-size:0;padding-inline:.5rem}button::first-letter{font-size:.72rem}}
2
+ :host {
3
+ display:inline-block;
4
+ min-inline-size:0;
5
+ max-inline-size:100%;
6
+ color:inherit;
7
+ }
8
+
9
+ .switcher {
10
+ display:flex;
11
+ flex-wrap:wrap;
12
+ align-items:center;
13
+ gap:.15rem;
14
+ box-sizing:border-box;
15
+ max-inline-size:100%;
16
+ padding:.2rem;
17
+ border:1px solid var(--app-bar-border,var(--border-color,rgba(255,255,255,.25)));
18
+ border-radius:1.6rem;
19
+ background:color-mix(in srgb,currentColor 8%,transparent);
20
+ }
21
+
22
+ button {
23
+ appearance:none;
24
+ flex:1 0 auto;
25
+ box-sizing:border-box;
26
+ min-inline-size:2.75rem;
27
+ max-inline-size:100%;
28
+ min-block-size:2.75rem;
29
+ padding:.5rem .75rem;
30
+ border:0;
31
+ border-radius:1.4rem;
32
+ background:transparent;
33
+ color:inherit;
34
+ cursor:pointer;
35
+ font:700 .875rem/1.25 system-ui,sans-serif;
36
+ text-align:center;
37
+ white-space:normal;
38
+ overflow-wrap:anywhere;
39
+ }
40
+
41
+ button:hover:not(:disabled):not([aria-pressed="true"]) {
42
+ background:color-mix(in srgb,currentColor 14%,transparent);
43
+ }
44
+
45
+ button[aria-pressed="true"] {
46
+ background:var(--app-bar-accent,var(--secondary-color,rgb(196,154,82)));
47
+ color:var(--app-bar-active-text,var(--primary-color,rgb(23,34,56)));
48
+ box-shadow:inset 0 0 0 .1rem currentColor;
49
+ }
50
+
51
+ button:disabled {
52
+ cursor:not-allowed;
53
+ opacity:.55;
54
+ }
55
+
56
+ button:focus-visible {
57
+ outline:.18rem solid var(--focus-color,currentColor);
58
+ outline-offset:.12rem;
59
+ }
9
60
  </style>
10
61
  <div class="switcher" role="group" aria-label="Color scheme">
11
62
  <button type="button" data-scheme="system" aria-pressed="false" title="Use device theme">Auto</button>
@@ -16,31 +67,79 @@
16
67
  <script type="module">
17
68
  const host=this;
18
69
  const root=host.shadowRoot;
70
+ const group=root.querySelector('.switcher');
19
71
  const buttons=Array.from(root.querySelectorAll('[data-scheme]'));
72
+ let modes=buttons.map(function scheme(button){return button.dataset.scheme;});
73
+ let labels=Object.fromEntries(buttons.map(function label(button){
74
+ return [button.dataset.scheme,button.textContent];
75
+ }));
76
+ let busy=false;
77
+
78
+ host.configure=configure;
79
+ configure({
80
+ modes:host.dataset.modes===undefined?modes:host.dataset.modes.split(/\s+/u).filter(Boolean),
81
+ labels:host.dataset.systemLabel===undefined?{}:{system:host.dataset.systemLabel}
82
+ });
83
+
20
84
  const {default:ThemeManager}=await import('../modules/ThemeManager.js');
21
85
  const manager=new ThemeManager();
22
86
 
23
87
  host.setMode=setMode;
24
88
  host.refresh=refresh;
25
- root.querySelector('.switcher').addEventListener('click',async event=>{
89
+ group.addEventListener('click',async function selectScheme(event){
26
90
  const button=event.target.closest('[data-scheme]');
27
- if(!button) return;
91
+ if(!button||button.disabled) return;
28
92
  setBusy(true);
29
93
  try{
30
94
  const state=button.dataset.scheme==='custom'?await manager.activateCustom():await manager.setScheme(button.dataset.scheme);
31
95
  setMode(state.mode);setCustomAvailable(Boolean(state.theme));
32
96
  }finally{setBusy(false);setCustomAvailable(Boolean(manager.customTheme));}
33
97
  });
34
- globalThis.addEventListener('arcane-theme-change',()=>refresh().catch(()=>{}));
98
+ globalThis.addEventListener('arcane-theme-change',function themeChanged(){
99
+ refresh().catch(function refreshRejected(){});
100
+ });
101
+
102
+ function configure(options={}){
103
+ const nextModes=options.modes===undefined?modes:[...options.modes];
104
+ const nextLabels={...labels,...options.labels};
105
+ const visible=[];
106
+ for(const mode of nextModes){
107
+ const button=buttons.find(function matchingScheme(candidate){return candidate.dataset.scheme===mode;});
108
+ if(!button) throw new TypeError(`Unknown theme-switcher mode: ${mode}`);
109
+ if(visible.includes(button)) throw new TypeError(`Repeated theme-switcher mode: ${mode}`);
110
+ visible.push(button);
111
+ }
112
+ for(const button of buttons){
113
+ const label=nextLabels[button.dataset.scheme];
114
+ if(typeof label!=='string') throw new TypeError('Theme-switcher labels must be strings.');
115
+ }
116
+ modes=nextModes;
117
+ labels=nextLabels;
118
+ for(const button of buttons) button.textContent=labels[button.dataset.scheme];
119
+ const reordered=group.children.length!==visible.length||visible.some(function moved(button,index){
120
+ return group.children[index]!==button;
121
+ });
122
+ if(reordered){
123
+ const focused=root.activeElement;
124
+ group.replaceChildren(...visible);
125
+ if(visible.includes(focused)&&!focused.disabled) focused.focus({preventScroll:true});
126
+ }
127
+ return {modes:[...modes],labels:{...labels}};
128
+ }
35
129
 
36
130
  function setMode(mode='system'){
37
131
  for(const button of buttons) button.setAttribute('aria-pressed',String(button.dataset.scheme===mode));
38
132
  host.dataset.mode=mode;
39
133
  return mode;
40
134
  }
41
- function setBusy(value){for(const button of buttons) button.disabled=Boolean(value);}
42
- function setCustomAvailable(value){root.querySelector('[data-scheme="custom"]').disabled=!value;}
135
+ function setBusy(value){
136
+ busy=Boolean(value);
137
+ for(const button of buttons) button.disabled=busy;
138
+ }
139
+ function setCustomAvailable(value){
140
+ buttons.find(function customScheme(button){return button.dataset.scheme==='custom';}).disabled=busy||!value;
141
+ }
43
142
  async function refresh(){const state=await manager.load();setMode(state.mode);setCustomAvailable(Boolean(state.theme));return state;}
44
143
 
45
- refresh().catch(()=>setMode('system'));
144
+ refresh().catch(function refreshRejected(){setMode('system');});
46
145
  </script>
@@ -6336,7 +6336,7 @@ class AI {
6336
6336
  return result;
6337
6337
  }
6338
6338
 
6339
- prepareTTS({parts, storage = null, identity = null, signal = null, onState = null} = {}) {
6339
+ prepareTTS({parts, textFormat, storage = null, identity = null, signal = null, onState = null} = {}) {
6340
6340
  if(this.#providerSpeechModel()?.speech?.playback==='native'){
6341
6341
  const error=new Error('Native browser speech has no audio file to prepare or store; use prepareTTSPlayback.');
6342
6342
  error.code='ARCANE_AI_TTS_AUDIO_EXPORT_UNAVAILABLE';
@@ -6384,7 +6384,7 @@ class AI {
6384
6384
  if(!is.finite(pauseAfterMs) || pauseAfterMs < 0) {
6385
6385
  throw new RangeError('Prepared speech pauses must be nonnegative milliseconds.');
6386
6386
  }
6387
- const cleaned = stripSpeechFormatting(source.input);
6387
+ const cleaned = textFormat === 'plain' ? source.input : stripSpeechFormatting(source.input);
6388
6388
  const {segments: outputs} = this.#segmentSpeechText(cleaned, true, segmentation);
6389
6389
  for(const [index, input] of outputs.entries()) {
6390
6390
  if(!input.trim()) continue;
@@ -7175,6 +7175,7 @@ class AI {
7175
7175
  'model',
7176
7176
  'voice',
7177
7177
  'input',
7178
+ 'textFormat',
7178
7179
  'responseFormat',
7179
7180
  'speed'
7180
7181
  ]);
@@ -7183,7 +7184,7 @@ class AI {
7183
7184
  ||!acceptedKeys.has(key)
7184
7185
  ||!Object.hasOwn(descriptors[key],'value')){
7185
7186
  const error=new TypeError(
7186
- 'AI.fetchTTS accepts only model, voice, input, responseFormat, and speed data properties.'
7187
+ 'AI.fetchTTS accepts only model, voice, input, textFormat, responseFormat, and speed data properties.'
7187
7188
  );
7188
7189
  error.code='ARCANE_AI_TTS_REQUEST_INVALID';
7189
7190
  throw error;
@@ -7208,7 +7209,7 @@ class AI {
7208
7209
  error.code='ARCANE_AI_TTS_INPUT_INVALID';
7209
7210
  throw error;
7210
7211
  }
7211
- const input=speechInputPrepared===true
7212
+ const input=speechInputPrepared===true||descriptors.textFormat?.value==='plain'
7212
7213
  ?suppliedInput
7213
7214
  :stripSpeechFormatting(suppliedInput);
7214
7215
  const selection=this.#providerRuntime.selection('tts');
@@ -3386,15 +3386,19 @@ export class AIProviderRuntime {
3386
3386
  if (options.signal?.aborted) {
3387
3387
  return Promise.reject(normalizedAbort());
3388
3388
  }
3389
- if (role === 'tts' && !queued && speechInputPrepared !== true) {
3389
+ if (role === 'tts' && !queued) {
3390
3390
  const payload = options.payload;
3391
3391
  const field = Object.hasOwn(payload ?? {}, 'input') ? 'input' : 'text';
3392
3392
  const text = Object.getOwnPropertyDescriptor(payload ?? {}, field)?.value;
3393
3393
  if (is.string(text)) {
3394
- const input = stripSpeechFormatting(text);
3395
- if (input !== text) {
3394
+ const textFormat = Object.getOwnPropertyDescriptor(payload, 'textFormat')?.value;
3395
+ const input = speechInputPrepared === true || textFormat === 'plain'
3396
+ ? text
3397
+ : stripSpeechFormatting(text);
3398
+ if (input !== text || Object.hasOwn(payload, 'textFormat')) {
3396
3399
  const payloadProperties = Object.getOwnPropertyDescriptors(payload);
3397
3400
  payloadProperties[field] = {...payloadProperties[field], value: input};
3401
+ delete payloadProperties.textFormat;
3398
3402
  const requestProperties = Object.getOwnPropertyDescriptors(options);
3399
3403
  requestProperties.payload = {
3400
3404
  ...requestProperties.payload,
@@ -153,21 +153,26 @@ function nativeAdapter(){
153
153
  return preferences;
154
154
  }
155
155
 
156
- function isUnsupportedNativeAdapter(error){
157
- return error?.code==='ANDROID_CAPABILITY_UNSUPPORTED';
156
+ function isUnsupportedNativeAdapter(error,method){
157
+ return error?.code==='ANDROID_CAPABILITY_UNSUPPORTED'
158
+ ||(error?.code==='METHOD_NOT_ALLOWED'
159
+ &&error.reason==='core-namespace-unavailable'
160
+ &&error.namespace==='preferences'
161
+ &&error.method===`preferences.${method}`);
158
162
  }
159
163
 
160
- function preferenceAdapter(){
164
+ function preferenceAdapter(assertActive){
161
165
  const local=localAdapter('arcane.preferences');
162
166
  if(is.function(globalThis.arcaneAndroid?.postMessage)) return local;
163
167
  const native=nativeAdapter();
164
168
  if(!native) return local;
165
169
  let active=native;
166
- async function call(method,args){
170
+ async function call(method,args,context){
167
171
  try{
168
172
  return await active[method](...args);
169
173
  }catch(error){
170
- if(active!==native||!isUnsupportedNativeAdapter(error)) throw error;
174
+ if(active!==native||!isUnsupportedNativeAdapter(error,method)) throw error;
175
+ assertActive(context?.signal);
171
176
  active=local;
172
177
  delete adapter.setMany;
173
178
  if(!is.function(active[method])) throw error;
@@ -175,19 +180,19 @@ function preferenceAdapter(){
175
180
  }
176
181
  }
177
182
  const adapter={
178
- async get(key){
179
- return call('get',[key]);
183
+ async get(key,context){
184
+ return call('get',[key,context],context);
180
185
  },
181
- async set(key,value){
182
- return call('set',[key,value]);
186
+ async set(key,value,context){
187
+ return call('set',[key,value,context],context);
183
188
  },
184
- async delete(key){
185
- return call('delete',[key]);
189
+ async delete(key,context){
190
+ return call('delete',[key,context],context);
186
191
  }
187
192
  };
188
193
  if(is.function(native.setMany)){
189
194
  adapter.setMany=async function setMany(entries,context){
190
- return call('setMany',[entries,context]);
195
+ return call('setMany',[entries,context],context);
191
196
  };
192
197
  }
193
198
  return adapter;
@@ -204,7 +209,7 @@ export default class PreferenceStore extends EventTarget{
204
209
  super();
205
210
  this.namespace=String(namespace||'arcane');
206
211
  this.schema=preferenceSchema(schema);
207
- this.adapter=validateAdapter(adapter||preferenceAdapter());
212
+ this.adapter=validateAdapter(adapter||preferenceAdapter(this.#assertOperationActive.bind(this)));
208
213
  this.values=this.defaults();
209
214
  this.#events=createArcaneEventSource(
210
215
  this,
@@ -8,16 +8,34 @@ export default class SystemAppearance{
8
8
 
9
9
  async current(){
10
10
  if(!this.api||!is.function(this.api.current)) return {supported:false,platform:'browser'};
11
- return this.api.current();
11
+ try{
12
+ return await this.api.current();
13
+ }catch(error){
14
+ return unsupportedNamespace(error,'current');
15
+ }
12
16
  }
13
17
 
14
18
  async apply(input={}){
15
19
  if(!this.available()) return {supported:false,platform:'browser'};
16
20
  const scheme=['system','light','dark'].includes(input.scheme)?input.scheme:'system';
17
- return this.api.apply({
18
- scheme,
19
- captionColor:scheme==='system'?null:input.captionColor||null,
20
- textColor:scheme==='system'?null:input.textColor||null
21
- });
21
+ try{
22
+ return await this.api.apply({
23
+ scheme,
24
+ captionColor:scheme==='system'?null:input.captionColor||null,
25
+ textColor:scheme==='system'?null:input.textColor||null
26
+ });
27
+ }catch(error){
28
+ return unsupportedNamespace(error,'apply');
29
+ }
22
30
  }
23
31
  }
32
+
33
+ function unsupportedNamespace(error,method){
34
+ if(error?.code==='METHOD_NOT_ALLOWED'
35
+ &&error.reason==='core-namespace-unavailable'
36
+ &&error.namespace==='appearance'
37
+ &&error.method===`appearance.${method}`){
38
+ return {supported:false,reason:'core-namespace-unavailable',error};
39
+ }
40
+ throw error;
41
+ }
@@ -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
  },
@@ -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
@@ -48,6 +48,7 @@ namespace Arcane.Core.Hosts.Windows
48
48
  Dictionary<string, object> manifest = ReadRecord(Path.Combine(directory, "arcane-native.json"));
49
49
  Dictionary<string, object> app = RequiredRecord(manifest, "app");
50
50
  Dictionary<string, object> client = RequiredRecord(manifest, "client");
51
+ Dictionary<string, object> windowOptions = manifest.ContainsKey("window") ? RequiredRecord(manifest, "window") : null;
51
52
  string appId = RequiredString(app, "id");
52
53
  object title;
53
54
  string name = app.TryGetValue("displayName", out title) && title is string ? (string)title : appId;
@@ -71,6 +72,9 @@ namespace Arcane.Core.Hosts.Windows
71
72
  OriginHost = "arcane.local",
72
73
  ProfileDirectory = Path.Combine(profileParent, "WebView2"),
73
74
  Title = name,
75
+ InitialClientWidth = OptionalWindowDimension(windowOptions, "width"),
76
+ InitialClientHeight = OptionalWindowDimension(windowOptions, "height"),
77
+ Resizable = OptionalWindowResizable(windowOptions),
74
78
  IconPath = String.IsNullOrEmpty(icon) ? null : Path.Combine(directory, icon),
75
79
  ClassicClientSource = File.ReadAllText(Path.Combine(directory, RequiredString(client, "source"))),
76
80
  CoreExecutable = Path.Combine(directory, "runtime", "ArcaneCore.exe"),
@@ -164,6 +168,23 @@ namespace Arcane.Core.Hosts.Windows
164
168
  return text;
165
169
  }
166
170
 
171
+ private static double? OptionalWindowDimension(Dictionary<string, object> window, string name)
172
+ {
173
+ object value;
174
+ if (window == null || !window.TryGetValue(name, out value)) return null;
175
+ if (value is int || value is long || value is decimal || value is double)
176
+ return Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture);
177
+ throw new FormatException("The native manifest needs a number at window." + name + ".");
178
+ }
179
+
180
+ private static bool? OptionalWindowResizable(Dictionary<string, object> window)
181
+ {
182
+ object value;
183
+ if (window == null || !window.TryGetValue("resizable", out value)) return null;
184
+ if (value is bool) return (bool)value;
185
+ throw new FormatException("The native manifest needs a boolean at window.resizable.");
186
+ }
187
+
167
188
  private static string QuoteArgument(string value)
168
189
  {
169
190
  // Windows command-line escaping is transport-local. The child
@@ -267,7 +267,21 @@ class CoreRuntime {
267
267
 
268
268
  #request(frame) {
269
269
  const registration = this.#methods.get(frame.method);
270
- if (!registration) throw failure('METHOD_NOT_ALLOWED', `Core does not expose ${frame.method}.`);
270
+ if (!registration) {
271
+ const namespace = frame.method.split('.')[0];
272
+ const prefix = `${namespace}.`;
273
+ const namespacePresent = this.#services.has(namespace)
274
+ || [...this.#methods.keys(), 'system.ping', 'app.current', 'version.current'].some(
275
+ function inNamespace(method) { return method.startsWith(prefix); }
276
+ );
277
+ throw new CoreError({
278
+ code: 'METHOD_NOT_ALLOWED',
279
+ message: `Core does not expose ${frame.method}.`,
280
+ reason: namespacePresent ? 'core-method-unavailable' : 'core-namespace-unavailable',
281
+ namespace,
282
+ method: frame.method
283
+ });
284
+ }
271
285
  const {service, handle, lifetime} = registration;
272
286
  const controller = new AbortController();
273
287
  const request = {id: frame.id, method: frame.method, lifetime, controller, task: null};
@@ -196,6 +196,7 @@ export function createPortableNativeProvider({services, packagedWeb = false, web
196
196
  target,
197
197
  webRoot: 'app',
198
198
  start: release.manifest?.app?.start ?? './index.html',
199
+ ...(appDescriptor.native?.window === undefined ? {} : {window: {...appDescriptor.native.window}}),
199
200
  core: {
200
201
  entry: 'runtime/arcane-core.mjs', transport: 'stdio', protocol: 'arcane/1', services: selectedServices,
201
202
  ...(localAI === undefined ? {} : {localAIRuntimes: bundled.runtimes})