arcane-os 0.59.1 → 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.
- package/CHANGELOG.md +20 -0
- package/README.md +1 -1
- package/browser-runtime/ai/core-model-assets.mjs +152 -0
- package/browser-runtime/core/client.mjs +8 -0
- package/browser-runtime/core/contracts.mjs +5 -0
- package/docs/reference/core/arcane-ai-contracts.md +17 -1
- package/docs/reference/core/arcane-api.md +12 -1
- package/docs/reference/core/reference/arcane-api/ai-and-ollama.md +20 -11
- package/docs/reference/core/reference/arcane-api/namespaces.md +14 -0
- package/docs/reference/core-client.md +67 -0
- package/docs/reference/core-native-packaging.md +66 -2
- package/docs/reference/inventory/package-api.json +2 -2
- package/docs/reference/inventory/runtime-modules.json +2 -2
- package/docs/reference/local-image-editing.md +79 -0
- package/docs/reference/model-assets.md +144 -0
- package/docs/reference/runtime-modules.md +37 -5
- package/docs/reference/sdk-api.md +9 -5
- package/package.json +3 -3
- package/runtime/arcane/modules/ThemeBootstrap.js +217 -2
- package/src/core/hosts/windows/ArcaneHost.cs +211 -0
- package/src/core/hosts/windows/ArcaneLauncher.cs +5 -0
- package/src/core/services/model-assets.mjs +267 -0
- package/src/local-ai/image-editing.mjs +159 -0
- package/src/native/windows-icon.mjs +582 -0
- package/src/native/windows-provider.mjs +60 -2
|
@@ -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
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import {mkdir, mkdtemp, open, rm} from 'node:fs/promises';
|
|
2
|
+
import {Buffer} from 'node:buffer';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import Is from 'strong-type';
|
|
5
|
+
import {CoreError, serializeCoreError} from '../../../browser-runtime/core/contracts.mjs';
|
|
6
|
+
|
|
7
|
+
const is = new Is(false);
|
|
8
|
+
|
|
9
|
+
function failure(code, message) {
|
|
10
|
+
return new CoreError({code, message});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Working files for native engines. The browser's stored assets stay authoritative. */
|
|
14
|
+
export function createModelAssetService({appRoot = process.cwd()} = {}) {
|
|
15
|
+
const projections = new Map();
|
|
16
|
+
let context;
|
|
17
|
+
let closing;
|
|
18
|
+
|
|
19
|
+
function snapshot(record) {
|
|
20
|
+
return {
|
|
21
|
+
id: record.id, directory: record.directory, state: record.state,
|
|
22
|
+
members: record.members.map(function memberLocation(member) {
|
|
23
|
+
return {path: member.path, nativePath: member.nativePath};
|
|
24
|
+
}),
|
|
25
|
+
preparationOwned: record.preparationOwned, uses: record.uses.size,
|
|
26
|
+
error: record.error ? serializeCoreError(record.error) : null
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function current() {
|
|
31
|
+
return {closing: Boolean(closing), projections: [...projections.values()].map(snapshot)};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function publish(record) {
|
|
35
|
+
context?.emit('modelAssets.state', snapshot(record));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function requireProjection(id) {
|
|
39
|
+
const record = projections.get(id);
|
|
40
|
+
if (!record) throw failure('MODEL_ASSET_PROJECTION_UNAVAILABLE', `Model asset projection ${id} is unavailable.`);
|
|
41
|
+
return record;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function closeMembers(record) {
|
|
45
|
+
const results = await Promise.allSettled(record.members.map(async function closeMember(member) {
|
|
46
|
+
await member.tail;
|
|
47
|
+
if (!member.handle) return;
|
|
48
|
+
const handle = member.handle;
|
|
49
|
+
await handle.close();
|
|
50
|
+
member.handle = null;
|
|
51
|
+
}));
|
|
52
|
+
const errors = results.filter(function rejected(result) { return result.status === 'rejected'; })
|
|
53
|
+
.map(function reason(result) { return result.reason; });
|
|
54
|
+
if (errors.length) throw new AggregateError(errors, 'Closing model asset files failed.');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function cleanup(record) {
|
|
58
|
+
if (record.preparationOwned || record.uses.size) return Promise.resolve(snapshot(record));
|
|
59
|
+
if (record.cleanup) return record.cleanup;
|
|
60
|
+
record.state = 'releasing';
|
|
61
|
+
// Retain the cleanup task before events can trigger another release.
|
|
62
|
+
record.cleanup = Promise.resolve().then(async function removeWorkingProjection() {
|
|
63
|
+
const errors = [];
|
|
64
|
+
let filesClosed = false;
|
|
65
|
+
try { await record.opening; } catch (error) { record.error ??= error; }
|
|
66
|
+
try { await record.finishing; } catch (error) { record.error ??= error; }
|
|
67
|
+
try { await closeMembers(record); filesClosed = true; } catch (error) { errors.push(error); }
|
|
68
|
+
if (record.directory && filesClosed) {
|
|
69
|
+
try { await rm(record.directory, {recursive: true}); }
|
|
70
|
+
catch (error) { if (error.code !== 'ENOENT') errors.push(error); }
|
|
71
|
+
}
|
|
72
|
+
if (errors.length) throw new AggregateError(errors, 'Releasing model asset working files failed.');
|
|
73
|
+
record.state = 'released';
|
|
74
|
+
projections.delete(record.id);
|
|
75
|
+
publish(record);
|
|
76
|
+
return snapshot(record);
|
|
77
|
+
});
|
|
78
|
+
record.cleanup.then(record.resolveReleased, function cleanupFailed(error) {
|
|
79
|
+
record.error = error;
|
|
80
|
+
record.state = 'error';
|
|
81
|
+
publish(record);
|
|
82
|
+
record.rejectReleased(error);
|
|
83
|
+
});
|
|
84
|
+
publish(record);
|
|
85
|
+
return record.cleanup;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function releasePreparation(record) {
|
|
89
|
+
record.preparationOwned = false;
|
|
90
|
+
publish(record);
|
|
91
|
+
return cleanup(record);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function failedOperation(record, error) {
|
|
95
|
+
record.error = error;
|
|
96
|
+
record.state = 'error';
|
|
97
|
+
publish(record);
|
|
98
|
+
try { await releasePreparation(record); }
|
|
99
|
+
catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Preparing model assets failed and cleanup failed.'); }
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function openProjection({id, workingDirectory, members}, request) {
|
|
104
|
+
request.signal.throwIfAborted();
|
|
105
|
+
if (closing) throw failure('MODEL_ASSETS_CLOSING', 'Model asset preparation is closing.');
|
|
106
|
+
if (!is.string(id) || !id) throw new TypeError('A model asset projection needs an operation id.');
|
|
107
|
+
if (!is.string(workingDirectory) || !workingDirectory) {
|
|
108
|
+
throw new TypeError('Model asset preparation needs an explicit working directory.');
|
|
109
|
+
}
|
|
110
|
+
if (!is.array(members) || !members.length) throw new TypeError('Select the complete model asset members.');
|
|
111
|
+
if (projections.has(id)) throw failure('MODEL_ASSET_PROJECTION_ACTIVE', `Model asset projection ${id} already exists.`);
|
|
112
|
+
const record = {
|
|
113
|
+
id, state: 'preparing', directory: null, members: [], uses: new Set(),
|
|
114
|
+
preparationOwned: true, error: null, opening: null, finishing: null, cleanup: null
|
|
115
|
+
};
|
|
116
|
+
record.released = new Promise(function projectionReleased(resolve, reject) {
|
|
117
|
+
record.resolveReleased = resolve;
|
|
118
|
+
record.rejectReleased = reject;
|
|
119
|
+
});
|
|
120
|
+
record.released.catch(function observeReleaseFailure() {});
|
|
121
|
+
projections.set(id, record);
|
|
122
|
+
record.opening = Promise.resolve().then(async function createWorkingFiles() {
|
|
123
|
+
const selectedDirectory = path.resolve(appRoot, workingDirectory);
|
|
124
|
+
await mkdir(selectedDirectory, {recursive: true});
|
|
125
|
+
request.signal.throwIfAborted();
|
|
126
|
+
record.directory = await mkdtemp(path.join(selectedDirectory, 'arcane-model-'));
|
|
127
|
+
record.members = members.map(function memberRecord(member) {
|
|
128
|
+
const nativePath = path.resolve(record.directory, member.path);
|
|
129
|
+
const relative = path.relative(record.directory, nativePath);
|
|
130
|
+
// A projection consists of files under its one owned directory.
|
|
131
|
+
// Absolute or escaping members cannot participate in that lifetime.
|
|
132
|
+
if (path.isAbsolute(member.path) || !relative || relative === '..'
|
|
133
|
+
|| relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
134
|
+
throw new TypeError('Model asset member paths must be relative files within their projection.');
|
|
135
|
+
}
|
|
136
|
+
return {path: member.path, nativePath, handle: null, tail: Promise.resolve()};
|
|
137
|
+
});
|
|
138
|
+
const results = await Promise.allSettled(record.members.map(async function createMember(member) {
|
|
139
|
+
request.signal.throwIfAborted();
|
|
140
|
+
await mkdir(path.dirname(member.nativePath), {recursive: true});
|
|
141
|
+
request.signal.throwIfAborted();
|
|
142
|
+
member.handle = await open(member.nativePath, 'wx');
|
|
143
|
+
request.signal.throwIfAborted();
|
|
144
|
+
}));
|
|
145
|
+
const errors = results.filter(function rejected(result) { return result.status === 'rejected'; })
|
|
146
|
+
.map(function reason(result) { return result.reason; });
|
|
147
|
+
if (errors.length) throw new AggregateError(errors, 'Opening model asset working files failed.');
|
|
148
|
+
});
|
|
149
|
+
publish(record);
|
|
150
|
+
try {
|
|
151
|
+
await record.opening;
|
|
152
|
+
request.signal.throwIfAborted();
|
|
153
|
+
if (!record.preparationOwned) throw failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.');
|
|
154
|
+
return snapshot(record);
|
|
155
|
+
} catch (error) { return failedOperation(record, error); }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function writeMember({id, memberIndex, contentBase64}, request) {
|
|
159
|
+
const record = requireProjection(id);
|
|
160
|
+
await record.opening;
|
|
161
|
+
request.signal.throwIfAborted();
|
|
162
|
+
if (record.state !== 'preparing' || !record.preparationOwned) {
|
|
163
|
+
throw failure('MODEL_ASSET_PROJECTION_NOT_WRITABLE', 'Model asset preparation is no longer accepting content.');
|
|
164
|
+
}
|
|
165
|
+
const member = record.members[memberIndex];
|
|
166
|
+
if (!member) throw new TypeError('Select an existing model asset member.');
|
|
167
|
+
try {
|
|
168
|
+
const task = member.tail.then(async function appendOriginalContent() {
|
|
169
|
+
request.signal.throwIfAborted();
|
|
170
|
+
if (!record.preparationOwned) throw failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.');
|
|
171
|
+
await member.handle.appendFile(Buffer.from(contentBase64, 'base64'));
|
|
172
|
+
request.signal.throwIfAborted();
|
|
173
|
+
});
|
|
174
|
+
// File writes remain ordered; unrelated member files write independently.
|
|
175
|
+
member.tail = task.catch(function observeWriteFailure() {});
|
|
176
|
+
await task;
|
|
177
|
+
return {id, memberIndex, written: true};
|
|
178
|
+
} catch (error) { return failedOperation(record, error); }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function completeProjection({id}, request) {
|
|
182
|
+
const record = requireProjection(id);
|
|
183
|
+
await record.opening;
|
|
184
|
+
request.signal.throwIfAborted();
|
|
185
|
+
if (record.state === 'completing') {
|
|
186
|
+
await record.finishing;
|
|
187
|
+
request.signal.throwIfAborted();
|
|
188
|
+
}
|
|
189
|
+
if (record.state === 'ready') return snapshot(record);
|
|
190
|
+
if (record.state !== 'preparing' || !record.preparationOwned) {
|
|
191
|
+
throw failure('MODEL_ASSET_PROJECTION_NOT_WRITABLE', 'Model asset preparation cannot be completed in its current state.');
|
|
192
|
+
}
|
|
193
|
+
record.state = 'completing';
|
|
194
|
+
record.finishing = Promise.resolve().then(async function finishProjection() {
|
|
195
|
+
await closeMembers(record);
|
|
196
|
+
request.signal.throwIfAborted();
|
|
197
|
+
if (!record.preparationOwned || record.error) {
|
|
198
|
+
throw record.error ?? failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.');
|
|
199
|
+
}
|
|
200
|
+
record.state = 'ready';
|
|
201
|
+
publish(record);
|
|
202
|
+
return snapshot(record);
|
|
203
|
+
});
|
|
204
|
+
try { return await record.finishing; }
|
|
205
|
+
catch (error) { return failedOperation(record, error); }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function retain(id) {
|
|
209
|
+
const record = requireProjection(id);
|
|
210
|
+
if (closing || record.state !== 'ready') {
|
|
211
|
+
throw failure('MODEL_ASSET_PROJECTION_NOT_READY', 'The complete native model asset projection is not ready.');
|
|
212
|
+
}
|
|
213
|
+
const use = {};
|
|
214
|
+
record.uses.add(use);
|
|
215
|
+
let released;
|
|
216
|
+
publish(record);
|
|
217
|
+
return {
|
|
218
|
+
id: record.id, directory: record.directory, members: snapshot(record).members,
|
|
219
|
+
release() {
|
|
220
|
+
if (released) return released;
|
|
221
|
+
record.uses.delete(use);
|
|
222
|
+
// Assign before publishing so reentrant release remains idempotent.
|
|
223
|
+
released = Promise.resolve().then(function releaseEngineUse() { return cleanup(record); });
|
|
224
|
+
publish(record);
|
|
225
|
+
return released;
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function dispose() {
|
|
231
|
+
if (closing) return closing;
|
|
232
|
+
closing = Promise.resolve().then(async function releaseAllProjections() {
|
|
233
|
+
const records = [...projections.values()];
|
|
234
|
+
for (const record of records) {
|
|
235
|
+
releasePreparation(record).catch(function observeCleanupFailure() {});
|
|
236
|
+
}
|
|
237
|
+
// Core closes services concurrently. Engine retain handles keep files
|
|
238
|
+
// alive until those owners finish actual native unload/worker exit.
|
|
239
|
+
const results = await Promise.allSettled(records.map(function awaitRelease(record) { return record.released; }));
|
|
240
|
+
const errors = results.filter(function rejected(result) { return result.status === 'rejected'; })
|
|
241
|
+
.map(function reason(result) { return result.reason; });
|
|
242
|
+
if (errors.length) throw new AggregateError(errors, 'Closing model asset preparation failed.');
|
|
243
|
+
});
|
|
244
|
+
return closing;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
name: 'model-assets', current, retain,
|
|
249
|
+
start(serviceContext) { context = serviceContext; },
|
|
250
|
+
methods: {
|
|
251
|
+
'modelAssets.open': openProjection,
|
|
252
|
+
'modelAssets.write': writeMember,
|
|
253
|
+
'modelAssets.complete': completeProjection,
|
|
254
|
+
'modelAssets.status': function status() { return current(); },
|
|
255
|
+
'modelAssets.release': {
|
|
256
|
+
lifetime: 'service',
|
|
257
|
+
handle({id}) {
|
|
258
|
+
const record = projections.get(id);
|
|
259
|
+
return record ? releasePreparation(record) : {id, state: 'released'};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
dispose
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export default createModelAssetService;
|