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.
@@ -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;