arcane-os 0.81.0 → 0.83.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.
@@ -0,0 +1,111 @@
1
+ import {readFile, writeFile} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {connectAppControl} from '../core/app-control.mjs';
4
+ import {CoreError, serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
5
+
6
+ const HELP_TEXT=`Usage:
7
+ arcane app-control status --endpoint <pipe-or-socket>
8
+ arcane app-control inspect --endpoint <pipe-or-socket> [--selector <css-selector> | --request <json-file>]
9
+ arcane app-control capture --endpoint <pipe-or-socket> --output <png-file>
10
+ arcane app-control act --endpoint <pipe-or-socket> --request <json-file>
11
+
12
+ Connects to the selected running native window. Results are complete JSON.
13
+ Capture writes the PNG to --output and prints its metadata. Request and output
14
+ filenames are relative to the current directory. This command does not launch
15
+ an application or change its profile. --output selects the capture filename;
16
+ the other Arcane commands retain their existing output-format option.
17
+ Inspect --request reads the complete parameter object, including shadowPath and
18
+ optional documentGeneration. It cannot be combined with --selector.
19
+ `;
20
+
21
+ function usage(message) {
22
+ throw new CoreError({code: 'APP_CONTROL_USAGE', message, exitCode: 2});
23
+ }
24
+
25
+ function parseArguments(argv) {
26
+ if (!argv.length || argv.includes('--help') || argv.includes('-h')) return {help: true};
27
+ const [action] = argv;
28
+ const allowed = {
29
+ status: ['endpoint'],
30
+ inspect: ['endpoint', 'selector', 'request'],
31
+ capture: ['endpoint', 'output'],
32
+ act: ['endpoint', 'request']
33
+ }[action];
34
+ if (!Array.isArray(allowed)) usage('Expected app-control status, inspect, capture, or act.');
35
+ const values = {};
36
+ for (let index = 1; index < argv.length; index += 1) {
37
+ const argument = argv[index];
38
+ const equal = argument.indexOf('=');
39
+ const option = equal === -1 ? argument : argument.slice(0, equal);
40
+ if (!option.startsWith('--') || !allowed.includes(option.slice(2))) {
41
+ usage(`Unexpected argument for app-control ${action}: ${argument}`);
42
+ }
43
+ const value = equal === -1 ? argv[++index] : argument.slice(equal + 1);
44
+ if (value === undefined || value === '' || (equal === -1 && value.startsWith('--'))) {
45
+ usage(`${option} requires a value.`);
46
+ }
47
+ values[option.slice(2)] = value;
48
+ }
49
+ if (!values.endpoint) usage('App control requires --endpoint <pipe-or-socket>.');
50
+ if (action === 'capture' && !values.output) usage('App-control capture requires --output <png-file>.');
51
+ if (action === 'act' && !values.request) usage('App-control act requires --request <json-file>.');
52
+ if (action === 'inspect' && values.request !== undefined && values.selector !== undefined) {
53
+ usage('App-control inspect accepts --selector or --request, not both.');
54
+ }
55
+ return {action, ...values};
56
+ }
57
+
58
+ /** This command owns its filename option before the general output-mode parser. */
59
+ export async function runAppControlCli(argv, {cwd = process.cwd(), stdout = process.stdout,
60
+ stderr = process.stderr, controller = new AbortController()} = {}) {
61
+ let connection;
62
+ let result;
63
+ const failures = [];
64
+ function failed(error) {
65
+ if (!failures.includes(error)) failures.push(error);
66
+ }
67
+ function interrupted() { controller.abort(new Error('Interrupted')); }
68
+ process.once('SIGINT', interrupted);
69
+ process.once('SIGTERM', interrupted);
70
+ try {
71
+ const selection = parseArguments(argv);
72
+ if (selection.help) {
73
+ stdout.write(HELP_TEXT);
74
+ return 0;
75
+ }
76
+ stderr.write(`${JSON.stringify({status: 'running', operation: selection.action,
77
+ endpoint: selection.endpoint})}\n`);
78
+ const signal = controller.signal;
79
+ const parameters = selection.request !== undefined
80
+ ? JSON.parse(await readFile(path.resolve(cwd, selection.request), {encoding: 'utf8', signal}))
81
+ : selection.selector === undefined ? {} : {selector: selection.selector};
82
+ connection = await connectAppControl({endpoint: selection.endpoint, signal, onError: failed});
83
+ if (selection.action === 'status') result = await connection.status({signal});
84
+ else if (selection.action === 'inspect') result = await connection.inspect(parameters, {signal});
85
+ else if (selection.action === 'act') result = await connection.act(parameters, {signal});
86
+ else {
87
+ result = await connection.capture(parameters, {signal});
88
+ const output = path.resolve(cwd, selection.output);
89
+ await writeFile(output, Buffer.from(result.data, 'base64'), {signal});
90
+ const {data, ...metadata} = result;
91
+ result = {...metadata, output};
92
+ }
93
+ } catch (error) {
94
+ failed(error);
95
+ } finally {
96
+ if (connection) {
97
+ try { await connection.close(); } catch (error) { failed(error); }
98
+ }
99
+ process.removeListener('SIGINT', interrupted);
100
+ process.removeListener('SIGTERM', interrupted);
101
+ }
102
+ if (failures.length) {
103
+ const error = failures.length === 1 ? failures[0]
104
+ : new AggregateError(failures, 'App control completed with errors.');
105
+ stderr.write(`${JSON.stringify({...(result === undefined ? {} : {result}),
106
+ error: serializeCoreError(error)}, null, 2)}\n`);
107
+ return controller.signal.aborted ? 130 : error.exitCode ?? 1;
108
+ }
109
+ stdout.write(`${JSON.stringify(result, null, 2)}\n`);
110
+ return 0;
111
+ }
package/src/cli/main.mjs CHANGED
@@ -6,6 +6,7 @@ import {ArcaneError,ERROR_CODES,normalizeError} from '../errors.mjs';
6
6
  import {CLI_NAME,SDK_NAME,SDK_VERSION,OUTPUT_MODES} from '../constants.mjs';
7
7
  import {loadArcaneNativeProvider} from '../native-provider-loader.mjs';
8
8
  import {APP_BUNDLE_EXTENSION} from '../release-bundle.mjs';
9
+ import {runAppControlCli} from './app-control.mjs';
9
10
 
10
11
  const is = new Is(false);
11
12
 
@@ -82,6 +83,7 @@ Usage:
82
83
  ${CLI_NAME} run [--target <target>] [--app <id>] [--cert <pem>] [--key <pem>] [--port 8000] [--http-port 0] [--arcane-root <directory>] [--output-root <directory>] [--format <format>] [--signing <mode>]
83
84
  ${CLI_NAME} update-check
84
85
  ${CLI_NAME} targets
86
+ ${CLI_NAME} app-control status|inspect|capture|act --endpoint <pipe-or-socket> [--help]
85
87
  ${CLI_NAME} repo status|pull|push
86
88
  ${CLI_NAME} mail key set [profile] [--secret-stdin]
87
89
  ${CLI_NAME} mail key status [profile]
@@ -942,6 +944,9 @@ export async function runCli(argv=process.argv.slice(2),{
942
944
  loadNativeProvider=loadArcaneNativeProvider,
943
945
  controller=new AbortController()
944
946
  }={}){
947
+ if(argv[0]==='app-control'){
948
+ return runAppControlCli(argv.slice(1),{cwd,stdout,stderr,controller});
949
+ }
945
950
  let reporter;
946
951
  let command='help';
947
952
  const onSignal=()=>controller.abort(new Error('Interrupted'));
@@ -0,0 +1,23 @@
1
+ import {connectCoreSocket, createCoreSocketConnection} from './socket-connection.mjs';
2
+
3
+ function reportAppControlError(error) { console.error('Arcane app control failed:', error); }
4
+
5
+ /** Connect to one explicitly selected running native window without launching it. */
6
+ export async function connectAppControl({endpoint, signal, onError = reportAppControlError} = {}) {
7
+ if (typeof endpoint !== 'string' || !endpoint) {
8
+ throw new TypeError('App control requires an explicit local pipe/socket endpoint.');
9
+ }
10
+ const socket = await connectCoreSocket(endpoint, signal, onError);
11
+ const connection = createCoreSocketConnection({socket, endpoint, signal, onError,
12
+ name: 'app-control', closedMessage: 'The app-control connection closed.'});
13
+ const {client} = connection;
14
+ return {
15
+ endpoint,
16
+ closed: connection.closed,
17
+ status(options) { return client.invoke('app.control.status', {}, options); },
18
+ inspect(parameters = {}, options) { return client.invoke('app.control.inspect', parameters, options); },
19
+ capture(parameters = {}, options) { return client.invoke('app.control.capture', parameters, options); },
20
+ act(parameters, options) { return client.invoke('app.control.act', parameters, options); },
21
+ close() { return connection.close(); }
22
+ };
23
+ }
@@ -0,0 +1,525 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.ComponentModel;
4
+ using System.IO;
5
+ using System.IO.Pipes;
6
+ using System.Runtime.InteropServices;
7
+ using System.Threading;
8
+ using System.Threading.Tasks;
9
+ using Microsoft.Win32.SafeHandles;
10
+
11
+ namespace Arcane.Core.Hosts.Windows
12
+ {
13
+ /// <summary>Controls one existing app window through its explicit pipe endpoint.</summary>
14
+ internal sealed class ArcaneAppControl
15
+ {
16
+ private readonly string endpoint;
17
+ private readonly Func<string, Dictionary<string, object>, CancellationToken, Task<object>> dispatch;
18
+ private readonly Action<string> onDiagnostic;
19
+ private readonly Action<Exception> onError;
20
+ private readonly object stateLock = new object();
21
+ private readonly HashSet<Connection> connections = new HashSet<Connection>();
22
+ private readonly List<Exception> failures = new List<Exception>();
23
+ private readonly CancellationTokenSource accepting = new CancellationTokenSource();
24
+ private readonly TaskCompletionSource<object> completion = new TaskCompletionSource<object>();
25
+ private NamedPipeServerStream listener;
26
+ private Task lifetime;
27
+ private bool closing;
28
+
29
+ internal ArcaneAppControl(string endpoint,
30
+ Func<string, Dictionary<string, object>, CancellationToken, Task<object>> dispatch,
31
+ Action<string> onDiagnostic, Action<Exception> onError)
32
+ {
33
+ if (String.IsNullOrEmpty(endpoint)) throw new ArgumentException("Select the full app-control pipe endpoint.", "endpoint");
34
+ if (dispatch == null) throw new ArgumentNullException("dispatch");
35
+ if (onDiagnostic == null) throw new ArgumentNullException("onDiagnostic");
36
+ if (onError == null) throw new ArgumentNullException("onError");
37
+ this.endpoint = endpoint;
38
+ this.dispatch = dispatch;
39
+ this.onDiagnostic = onDiagnostic;
40
+ this.onError = onError;
41
+ // Claim before returning. An occupied endpoint never joins another
42
+ // app instance's pipe group or connects to that app as a client.
43
+ listener = CreateListener(true);
44
+ completion.Task.ContinueWith(ObserveReportedFailure, TaskContinuationOptions.OnlyOnFaulted);
45
+ lifetime = ListenAsync();
46
+ lifetime.ContinueWith(ListenerFailed, TaskContinuationOptions.OnlyOnFaulted);
47
+ }
48
+
49
+ internal Task Completion { get { return completion.Task; } }
50
+
51
+ internal Task CloseAsync()
52
+ {
53
+ StopIngress();
54
+ return Completion;
55
+ }
56
+
57
+ private NamedPipeServerStream CreateListener(bool first)
58
+ {
59
+ const uint duplex = 0x00000003;
60
+ const uint overlapped = 0x40000000;
61
+ const uint firstInstance = 0x00080000;
62
+ // Zero selects the platform's stream/wait modes and buffer defaults.
63
+ SafePipeHandle handle = CreateNamedPipe(endpoint,
64
+ duplex | overlapped | (first ? firstInstance : 0), 0, 255, 0, 0, 0, IntPtr.Zero);
65
+ if (handle.IsInvalid)
66
+ {
67
+ int code = Marshal.GetLastWin32Error();
68
+ handle.Dispose();
69
+ IOException error = new IOException("The app-control pipe endpoint could not be opened.", new Win32Exception(code));
70
+ error.Data["endpoint"] = endpoint;
71
+ throw error;
72
+ }
73
+ try { return new NamedPipeServerStream(PipeDirection.InOut, true, false, handle); }
74
+ catch { handle.Dispose(); throw; }
75
+ }
76
+
77
+ [DllImport("kernel32.dll", EntryPoint = "CreateNamedPipeW", CharSet = CharSet.Unicode, SetLastError = true)]
78
+ private static extern SafePipeHandle CreateNamedPipe(string name, uint openMode, uint pipeMode,
79
+ uint maximumInstances, uint outputBuffer, uint inputBuffer, uint defaultTimeout, IntPtr attributes);
80
+
81
+ private async Task ListenAsync()
82
+ {
83
+ try
84
+ {
85
+ while (true)
86
+ {
87
+ NamedPipeServerStream current;
88
+ lock (stateLock)
89
+ {
90
+ if (closing) break;
91
+ current = listener;
92
+ }
93
+ await current.WaitForConnectionAsync(accepting.Token).ConfigureAwait(false);
94
+ Connection connection;
95
+ lock (stateLock)
96
+ {
97
+ if (closing) break;
98
+ // Retain an instance continuously, including between
99
+ // accepts, so another host cannot claim this endpoint.
100
+ listener = CreateListener(false);
101
+ connection = new Connection(this, current);
102
+ connections.Add(connection);
103
+ }
104
+ connection.Start();
105
+ }
106
+ }
107
+ catch (OperationCanceledException error)
108
+ {
109
+ if (!accepting.IsCancellationRequested) Report(error);
110
+ }
111
+ catch (Exception error) { Report(error); }
112
+ StopIngress();
113
+ Connection[] active;
114
+ lock (stateLock) active = SnapshotConnections();
115
+ List<Task> drains = new List<Task>();
116
+ foreach (Connection connection in active) drains.Add(connection.Completion);
117
+ Task draining = Task.WhenAll(drains);
118
+ try { await draining.ConfigureAwait(false); }
119
+ catch (Exception error) { ReportTaskFailure(draining, error); }
120
+ // The final pipe handle owns the endpoint until accepted work
121
+ // settles, even though cancellation has stopped further accepts.
122
+ try { listener.Dispose(); }
123
+ catch (Exception error) { Report(error); }
124
+ accepting.Dispose();
125
+ Exception[] errors;
126
+ lock (stateLock) errors = failures.ToArray();
127
+ if (errors.Length == 0) completion.TrySetResult(null);
128
+ else completion.TrySetException(new AggregateException("App-control transport closed with errors.", errors));
129
+ }
130
+
131
+ private Connection[] SnapshotConnections()
132
+ {
133
+ Connection[] active = new Connection[connections.Count];
134
+ connections.CopyTo(active);
135
+ return active;
136
+ }
137
+
138
+ private void StopIngress()
139
+ {
140
+ Connection[] active;
141
+ lock (stateLock)
142
+ {
143
+ if (closing) return;
144
+ closing = true;
145
+ // The listener stays open as the endpoint claim. Cancellation
146
+ // stops its overlapped accept without releasing that ownership.
147
+ try { accepting.Cancel(); }
148
+ catch (Exception error) { Report(error); }
149
+ active = SnapshotConnections();
150
+ }
151
+ foreach (Connection connection in active) connection.Stop();
152
+ }
153
+
154
+ private void Remove(Connection connection)
155
+ {
156
+ lock (stateLock) connections.Remove(connection);
157
+ }
158
+
159
+ private void Report(Exception error)
160
+ {
161
+ lock (stateLock)
162
+ {
163
+ if (failures.Contains(error)) return;
164
+ failures.Add(error);
165
+ }
166
+ try { onError(error); }
167
+ catch (Exception callbackError) { lock (stateLock) failures.Add(callbackError); }
168
+ }
169
+
170
+ private void Diagnose(object record)
171
+ {
172
+ string diagnostic = null;
173
+ try
174
+ {
175
+ diagnostic = ArcaneHost.Serializer().Serialize(record) + Environment.NewLine;
176
+ onDiagnostic(diagnostic);
177
+ }
178
+ catch (Exception error)
179
+ {
180
+ IOException failure = new IOException("The app-control diagnostic could not be delivered.", error);
181
+ failure.Data["diagnosticRecord"] = record;
182
+ if (diagnostic != null) failure.Data["diagnostic"] = diagnostic;
183
+ Report(failure);
184
+ }
185
+ }
186
+
187
+ private void ReportTaskFailure(Task task, Exception caught)
188
+ {
189
+ if (task.Exception == null) { Report(caught); return; }
190
+ foreach (Exception error in task.Exception.InnerExceptions) Report(error);
191
+ }
192
+
193
+ private void ListenerFailed(Task task)
194
+ {
195
+ ReportTaskFailure(task, task.Exception);
196
+ completion.TrySetException(task.Exception);
197
+ }
198
+
199
+ private static void ObserveReportedFailure(Task task) { task.Exception.Handle(IgnoreReportedFailure); }
200
+ private static bool IgnoreReportedFailure(Exception error) { return true; }
201
+
202
+ private sealed class Connection
203
+ {
204
+ private readonly ArcaneAppControl owner;
205
+ private readonly NamedPipeServerStream pipe;
206
+ private readonly object stateLock = new object();
207
+ private readonly Dictionary<string, Request> requests = new Dictionary<string, Request>();
208
+ private readonly SemaphoreSlim writing = new SemaphoreSlim(1, 1);
209
+ private readonly TaskCompletionSource<object> completion = new TaskCompletionSource<object>();
210
+ private Task lifetime;
211
+ private bool closed;
212
+
213
+ internal Connection(ArcaneAppControl owner, NamedPipeServerStream pipe)
214
+ {
215
+ this.owner = owner;
216
+ this.pipe = pipe;
217
+ }
218
+
219
+ internal Task Completion { get { return completion.Task; } }
220
+
221
+ internal void Start()
222
+ {
223
+ lifetime = ReadAsync();
224
+ lifetime.ContinueWith(Failed, TaskContinuationOptions.OnlyOnFaulted);
225
+ }
226
+
227
+ private async Task ReadAsync()
228
+ {
229
+ try
230
+ {
231
+ while (true)
232
+ {
233
+ lock (stateLock) { if (closed) break; }
234
+ string json = await ArcaneFrameTransport.ReadAsync(pipe).ConfigureAwait(false);
235
+ if (json == null) break;
236
+ Receive(json);
237
+ }
238
+ }
239
+ catch (ObjectDisposedException error) { ReadStopped(error); }
240
+ catch (OperationCanceledException error) { ReadStopped(error); }
241
+ catch (IOException error)
242
+ {
243
+ if (IsClosed()) ReadStopped(error);
244
+ else owner.Report(error);
245
+ }
246
+ catch (Exception error) { owner.Report(error); }
247
+ Stop();
248
+ List<Task> drains = new List<Task>();
249
+ lock (stateLock)
250
+ {
251
+ foreach (Request request in requests.Values) drains.Add(request.Completion);
252
+ }
253
+ Task draining = Task.WhenAll(drains);
254
+ try { await draining.ConfigureAwait(false); }
255
+ catch (Exception error) { owner.ReportTaskFailure(draining, error); }
256
+ writing.Dispose();
257
+ owner.Remove(this);
258
+ completion.TrySetResult(null);
259
+ }
260
+
261
+ private bool IsClosed() { lock (stateLock) return closed; }
262
+
263
+ private void ReadStopped(Exception error)
264
+ {
265
+ if (IsClosed()) owner.Diagnose(ArcaneHost.ErrorRecord(error, "ARCANE_APP_CONTROL_INPUT_CLOSED"));
266
+ else owner.Report(error);
267
+ }
268
+
269
+ private void Receive(string json)
270
+ {
271
+ try
272
+ {
273
+ Dictionary<string, object> frame = ArcaneHost.Serializer().DeserializeObject(json) as Dictionary<string, object>;
274
+ if (frame == null || Text(frame, "protocol") != "arcane/1")
275
+ throw new FormatException("Unknown app-control protocol.");
276
+ if (Text(frame, "type") == "control")
277
+ {
278
+ string control = Text(frame, "control");
279
+ if (control == "requests.cancelAll") { CancelRequests(); return; }
280
+ if (control == "request.cancel")
281
+ {
282
+ string id = Text(frame, "requestId");
283
+ if (String.IsNullOrEmpty(id)) throw new FormatException("Request cancellation requires a requestId.");
284
+ Request request;
285
+ lock (stateLock) requests.TryGetValue(id, out request);
286
+ if (request != null) request.Cancel();
287
+ return;
288
+ }
289
+ throw new FormatException("Unknown app-control cancellation control.");
290
+ }
291
+ string requestId = Text(frame, "id");
292
+ string method = Text(frame, "method");
293
+ if (Text(frame, "type") != "request" || String.IsNullOrEmpty(requestId) || method == null)
294
+ throw new FormatException("An app-control request requires an id and method.");
295
+ object parameters;
296
+ frame.TryGetValue("parameters", out parameters);
297
+ Request accepted;
298
+ lock (stateLock)
299
+ {
300
+ if (closed)
301
+ {
302
+ owner.Diagnose(new Dictionary<string, object>
303
+ {
304
+ { "appControlFrame", json }, { "reason", "connection-closed-before-dispatch" }
305
+ });
306
+ return;
307
+ }
308
+ if (requests.ContainsKey(requestId))
309
+ throw new FormatException("The app-control request id is already active on this connection.");
310
+ accepted = new Request(this, requestId, method, parameters, json);
311
+ requests.Add(requestId, accepted);
312
+ }
313
+ accepted.Start();
314
+ }
315
+ catch (Exception error)
316
+ {
317
+ error.Data["appControlFrame"] = json;
318
+ throw;
319
+ }
320
+ }
321
+
322
+ private static string Text(Dictionary<string, object> frame, string key)
323
+ {
324
+ object value;
325
+ return frame.TryGetValue(key, out value) ? value as string : null;
326
+ }
327
+
328
+ private void CancelRequests()
329
+ {
330
+ Request[] active;
331
+ lock (stateLock)
332
+ {
333
+ active = new Request[requests.Count];
334
+ requests.Values.CopyTo(active, 0);
335
+ }
336
+ foreach (Request request in active) request.Cancel();
337
+ }
338
+
339
+ internal void Stop()
340
+ {
341
+ lock (stateLock)
342
+ {
343
+ if (closed) return;
344
+ closed = true;
345
+ }
346
+ CancelRequests();
347
+ try { pipe.Dispose(); }
348
+ catch (Exception error) { owner.Report(error); }
349
+ }
350
+
351
+ private async Task<bool> SendAsync(Dictionary<string, object> response)
352
+ {
353
+ string json = ArcaneHost.Serializer().Serialize(response);
354
+ await writing.WaitAsync().ConfigureAwait(false);
355
+ try
356
+ {
357
+ if (IsClosed()) { RetireResponse(json, null); return true; }
358
+ await ArcaneFrameTransport.WriteAsync(pipe, json).ConfigureAwait(false);
359
+ return false;
360
+ }
361
+ catch (Exception error)
362
+ {
363
+ if (PeerDisconnected(error)
364
+ || (IsClosed() && (error is IOException || error is ObjectDisposedException || error is OperationCanceledException)))
365
+ {
366
+ Stop();
367
+ RetireResponse(json, error);
368
+ return true;
369
+ }
370
+ error.Data["appControlResponse"] = json;
371
+ throw;
372
+ }
373
+ finally { writing.Release(); }
374
+ }
375
+
376
+ private static bool PeerDisconnected(Exception error)
377
+ {
378
+ if (!(error is IOException)) return false;
379
+ // These platform results mean the peer ended the pipe, even
380
+ // when the write observes that before the reader sees EOF.
381
+ int code = error.HResult & 0xffff;
382
+ return code == 109 || code == 232 || code == 233;
383
+ }
384
+
385
+ private void RetireResponse(string json, Exception error)
386
+ {
387
+ Dictionary<string, object> record = new Dictionary<string, object>
388
+ {
389
+ { "appControlResponse", json }, { "reason", "connection-closed" }
390
+ };
391
+ if (error != null) record["error"] = ArcaneHost.ErrorRecord(error, "ARCANE_APP_CONTROL_OUTPUT_CLOSED");
392
+ owner.Diagnose(record);
393
+ }
394
+
395
+ private void Failed(Task task)
396
+ {
397
+ owner.ReportTaskFailure(task, task.Exception);
398
+ Stop();
399
+ owner.Remove(this);
400
+ completion.TrySetException(task.Exception);
401
+ }
402
+
403
+ private sealed class Request
404
+ {
405
+ private readonly Connection connection;
406
+ private readonly string id;
407
+ private readonly string method;
408
+ private readonly object parameters;
409
+ private readonly string frame;
410
+ private readonly object stateLock = new object();
411
+ private readonly CancellationTokenSource cancellation = new CancellationTokenSource();
412
+ private readonly TaskCompletionSource<object> completion = new TaskCompletionSource<object>();
413
+ private Task lifetime;
414
+ private bool settled;
415
+
416
+ internal Request(Connection connection, string id, string method, object parameters, string frame)
417
+ {
418
+ this.connection = connection;
419
+ this.id = id;
420
+ this.method = method;
421
+ this.parameters = parameters;
422
+ this.frame = frame;
423
+ }
424
+
425
+ internal Task Completion { get { return completion.Task; } }
426
+
427
+ internal void Start()
428
+ {
429
+ lifetime = AnswerAsync();
430
+ lifetime.ContinueWith(Failed, TaskContinuationOptions.OnlyOnFaulted);
431
+ }
432
+
433
+ internal void Cancel()
434
+ {
435
+ lock (stateLock)
436
+ {
437
+ if (settled || cancellation.IsCancellationRequested) return;
438
+ try { cancellation.Cancel(); }
439
+ catch (Exception error) { connection.owner.Report(error); }
440
+ }
441
+ }
442
+
443
+ private async Task AnswerAsync()
444
+ {
445
+ Dictionary<string, object> response = new Dictionary<string, object>
446
+ {
447
+ { "protocol", "arcane/1" }, { "type", "response" }, { "id", id }
448
+ };
449
+ bool responseRecorded = false;
450
+ try
451
+ {
452
+ try
453
+ {
454
+ cancellation.Token.ThrowIfCancellationRequested();
455
+ if (method != "app.control.status" && method != "app.control.inspect"
456
+ && method != "app.control.capture" && method != "app.control.act")
457
+ throw new NotSupportedException("The app does not expose " + method + ".");
458
+ Dictionary<string, object> arguments = parameters as Dictionary<string, object>;
459
+ if (arguments == null)
460
+ throw new ArgumentException("App-control parameters must be an object.", "parameters");
461
+ // Dispatch owns the actual UI operation. Cancellation
462
+ // after it starts never fabricates a reversed action.
463
+ object result = await connection.owner.dispatch(method, arguments, cancellation.Token).ConfigureAwait(false);
464
+ response["ok"] = true;
465
+ response["result"] = result;
466
+ }
467
+ catch (Exception error)
468
+ {
469
+ error.Data["appControlFrame"] = frame;
470
+ response["ok"] = false;
471
+ string code = error is OperationCanceledException ? "REQUEST_ABORTED"
472
+ : error is NotSupportedException ? "METHOD_NOT_ALLOWED"
473
+ : error is ArgumentException ? "INVALID_ARGUMENT" : "ARCANE_APP_CONTROL_FAILED";
474
+ response["error"] = ArcaneHost.ErrorRecord(error, code);
475
+ }
476
+ response["time"] = DateTime.UtcNow.ToString("o");
477
+ responseRecorded = await connection.SendAsync(response).ConfigureAwait(false);
478
+ }
479
+ catch (Exception error)
480
+ {
481
+ if (!error.Data.Contains("appControlResponse")) error.Data["appControlResponse"] = response;
482
+ connection.owner.Report(error);
483
+ connection.Stop();
484
+ }
485
+ finally { Complete(response, responseRecorded); }
486
+ }
487
+
488
+ private void Complete(Dictionary<string, object> response, bool responseRecorded)
489
+ {
490
+ bool cancelled;
491
+ lock (stateLock)
492
+ {
493
+ if (settled) return;
494
+ cancelled = cancellation.IsCancellationRequested;
495
+ settled = true;
496
+ }
497
+ try
498
+ {
499
+ try { cancellation.Dispose(); }
500
+ catch (Exception error) { connection.owner.Report(error); }
501
+ // A live peer retires its cancelled request before this
502
+ // actual operation finishes, even while the pipe stays open.
503
+ if (cancelled && response != null && !responseRecorded)
504
+ connection.owner.Diagnose(new Dictionary<string, object>
505
+ {
506
+ { "appControlResponse", response }, { "reason", "request-cancelled" }
507
+ });
508
+ }
509
+ catch (Exception error) { connection.owner.Report(error); }
510
+ finally
511
+ {
512
+ try { lock (connection.stateLock) connection.requests.Remove(id); }
513
+ finally { completion.TrySetResult(null); }
514
+ }
515
+ }
516
+
517
+ private void Failed(Task task)
518
+ {
519
+ connection.owner.ReportTaskFailure(task, task.Exception);
520
+ Complete(null, false);
521
+ }
522
+ }
523
+ }
524
+ }
525
+ }