@await-widget/runtime 0.0.24 → 0.0.26

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,124 +1,475 @@
1
1
  #!/usr/bin/env node
2
- import crypto from "node:crypto";
3
- import fs from "node:fs";
4
- import http from "node:http";
5
- import os from "node:os";
6
- import path from "node:path";
2
+ /* eslint-disable n/prefer-global/buffer, no-bitwise, unicorn/no-array-sort */
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import http from 'node:http';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import process from 'node:process';
7
9
 
8
10
  const defaultPort = 4343;
11
+ const maxBodyBytes = 100 * 1024 * 1024;
9
12
  const zipDosTime = 0;
10
13
  const zipDosDate = 33;
11
- const excludedNames = new Set([".git", ".build", "node_modules"]);
14
+ const excludedNames = new Set(['.git', '.build', 'node_modules', 'dist', 'build']);
12
15
  const crcTable = makeCrcTable();
16
+ const bridgeInfoDirectory = '.await';
17
+ const bridgeInfoFileName = 'bridge.json';
18
+ const singleWidgetPath = '.';
13
19
 
14
20
  function main() {
15
- const [command, ...args] = process.argv.slice(2);
16
- if (command === "dev") {
17
- startDevServer(args);
21
+ const options = parseArgs(process.argv.slice(2));
22
+ if (options.help) {
23
+ printHelp();
18
24
  return;
19
25
  }
20
- printHelp();
21
- }
22
26
 
23
- function startDevServer(args) {
24
- const options = parseDevArgs(args);
25
- const root = path.resolve(options.root);
26
- if (!isSubdirectory(root)) {
27
- printHelp();
28
- process.exit(1);
29
- }
30
- const stats = fs.statSync(root);
31
- if (!stats.isDirectory()) {
32
- throw new Error(`${root} is not a directory`);
27
+ if (options.command === 'app') {
28
+ runAppCommand(options).catch(error => {
29
+ console.error(error.message);
30
+ process.exit(1);
31
+ });
32
+ return;
33
33
  }
34
34
 
35
- const token = crypto.randomBytes(6).toString("hex");
36
- const server = http.createServer((request, response) => {
37
- handleRequest({request, response, root, token});
38
- });
39
- server.listen(options.port, "0.0.0.0", () => {
40
- const address = server.address();
41
- const port = typeof address === "object" && address ? address.port : options.port;
42
- console.log(`Await dev server serving ${root}`);
43
- printServerUrls(port, token);
44
- console.log("Press Ctrl+C to stop.");
45
- });
35
+ startWorkspaceServer(options);
46
36
  }
47
37
 
48
- function parseDevArgs(args) {
49
- let root;
38
+ function parseArgs(args) {
39
+ if (args[0] === 'app') {
40
+ return parseAppArgs(args.slice(1));
41
+ }
42
+
50
43
  let port = defaultPort;
51
44
  for (let index = 0; index < args.length; index += 1) {
52
45
  const arg = args[index];
53
- if (arg === "--port") {
46
+ if (arg === '--help' || arg === '-h') {
47
+ return {help: true, port};
48
+ }
49
+
50
+ if (arg === '--port') {
54
51
  port = Number(args[index + 1]);
55
52
  index += 1;
56
53
  continue;
57
54
  }
58
- if (arg === "--help" || arg === "-h") {
59
- printHelp();
60
- process.exit(0);
61
- }
62
- root = arg;
55
+
56
+ printHelp();
57
+ process.exit(1);
63
58
  }
64
- if (!root) {
59
+
60
+ return {command: 'bridge', help: false, port};
61
+ }
62
+
63
+ function parseAppArgs(args) {
64
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
65
+ return {command: 'app', help: true};
66
+ }
67
+
68
+ const tool = appCommandName(args[0]);
69
+ let bridgeUrl;
70
+ let workspace;
71
+ const toolArguments = {};
72
+ for (let index = 1; index < args.length; index += 1) {
73
+ const arg = args[index];
74
+ if (arg === '--bridge-url' || arg === '--url') {
75
+ bridgeUrl = args[index + 1];
76
+ index += 1;
77
+ continue;
78
+ }
79
+
80
+ if (arg === '--workspace') {
81
+ workspace = args[index + 1];
82
+ index += 1;
83
+ continue;
84
+ }
85
+
86
+ if (arg === '--json') {
87
+ Object.assign(toolArguments, JSON.parse(args[index + 1]));
88
+ index += 1;
89
+ continue;
90
+ }
91
+
92
+ if (arg === '--widget-id') {
93
+ toolArguments.widgetId = args[index + 1];
94
+ index += 1;
95
+ continue;
96
+ }
97
+
98
+ if (arg === '--mode') {
99
+ toolArguments.mode = args[index + 1];
100
+ index += 1;
101
+ continue;
102
+ }
103
+
104
+ if (arg === '--timeout-ms') {
105
+ toolArguments.timeoutMs = Number(args[index + 1]);
106
+ index += 1;
107
+ continue;
108
+ }
109
+
110
+ if (arg === '--intent-id') {
111
+ toolArguments.intentId = args[index + 1];
112
+ index += 1;
113
+ continue;
114
+ }
115
+
116
+ if (arg === '--input') {
117
+ toolArguments.input = JSON.parse(args[index + 1]);
118
+ index += 1;
119
+ continue;
120
+ }
121
+
65
122
  printHelp();
66
123
  process.exit(1);
67
124
  }
68
- return {root, port};
125
+
126
+ return {
127
+ command: 'app', help: false, bridgeUrl, workspace, tool, toolArguments,
128
+ };
69
129
  }
70
130
 
71
- function isSubdirectory(root) {
72
- const relativePath = path.relative(process.cwd(), root);
73
- return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
131
+ function startWorkspaceServer(options) {
132
+ let roots;
133
+ try {
134
+ roots = resolveWidgetServerRoots(process.cwd());
135
+ } catch (error) {
136
+ console.error(error.message);
137
+ process.exit(1);
138
+ }
139
+
140
+ const {packageRoot, widgetRoot} = roots;
141
+
142
+ const token = crypto.randomBytes(6).toString('hex');
143
+ const bridge = createBridgeState(widgetRoot);
144
+ const server = http.createServer((request, response) => {
145
+ handleRequest({
146
+ request, response, root: widgetRoot, token, bridge,
147
+ }).catch(error => {
148
+ sendJson(response, {error: error.message}, 500);
149
+ });
150
+ });
151
+ startWorkspaceWatcher(widgetRoot, bridge);
152
+ server.listen(options.port, '0.0.0.0', () => {
153
+ const address = server.address();
154
+ const port = typeof address === 'object' && address ? address.port : options.port;
155
+ writeBridgeInfo(packageRoot, port, token);
156
+ console.log(`Await computer connection serving ${widgetRoot}`);
157
+ printServerUrls(port, token);
158
+ console.log('Press Ctrl+C to stop.');
159
+ });
160
+ }
161
+
162
+ function createBridgeState(root) {
163
+ return {
164
+ events: new Set(),
165
+ activeEvent: undefined,
166
+ pendingCommands: new Map(),
167
+ widgetChangeTimers: new Map(),
168
+ widgetsChangedTimer: undefined,
169
+ knownWidgets: widgetPathKey(listDesktopWidgets(root)),
170
+ };
74
171
  }
75
172
 
76
- function handleRequest({request, response, root, token}) {
77
- const url = new URL(request.url ?? "/", "http://localhost");
78
- if (url.pathname === "/") {
79
- sendText(response, `Await dev server\n${urlForRequest(request, token)}\n`);
173
+ async function handleRequest({request, response, root, token, bridge}) {
174
+ const url = new URL(request.url ?? '/', 'http://localhost');
175
+ if (url.pathname === '/') {
176
+ sendText(response, `Await computer connection\n${urlForRequest(request, token)}\n`);
80
177
  return;
81
178
  }
82
- if (url.searchParams.get("token") !== token) {
83
- sendText(response, "Forbidden\n", 403);
179
+
180
+ if (url.searchParams.get('token') !== token) {
181
+ sendText(response, 'Forbidden\n', 403);
84
182
  return;
85
183
  }
86
- if (url.pathname === "/await-dev/version") {
184
+
185
+ try {
186
+ await routeRequest({
187
+ request, response, root, url, bridge,
188
+ });
189
+ } catch (error) {
190
+ sendJson(response, {error: error.message}, errorStatus(error));
191
+ }
192
+ }
193
+
194
+ async function routeRequest({request, response, root, url, bridge}) {
195
+ if (request.method === 'GET' && url.pathname === '/await-dev/connection') {
87
196
  sendJson(response, {
88
- name: path.basename(root),
89
- version: projectVersion(root),
197
+ workspaceName: path.basename(root),
198
+ capabilities: {
199
+ events: true,
200
+ appCommands: true,
201
+ appMcp: true,
202
+ singleWidget: true,
203
+ },
204
+ widgets: listDesktopWidgets(root),
90
205
  });
91
206
  return;
92
207
  }
93
- if (url.pathname === "/await-dev/snapshot.zip") {
94
- const archive = zipFiles(scanFiles(root));
208
+
209
+ if (request.method === 'GET' && url.pathname === '/await-dev/widgets') {
210
+ sendJson(response, {widgets: listDesktopWidgets(root)});
211
+ return;
212
+ }
213
+
214
+ if (request.method === 'GET' && url.pathname === '/await-dev/events') {
215
+ addEventClient(bridge, response);
216
+ return;
217
+ }
218
+
219
+ if (request.method === 'GET' && url.pathname === '/await-dev/version') {
220
+ const widgetRoot = widgetRootFromQuery(root, url);
221
+ console.log(`Await version requested: ${path.basename(widgetRoot)}`);
222
+ sendJson(response, {
223
+ name: path.basename(widgetRoot),
224
+ path: singleWidgetPath,
225
+ version: projectVersion(widgetRoot),
226
+ });
227
+ return;
228
+ }
229
+
230
+ if (request.method === 'GET' && url.pathname === '/await-dev/snapshot.zip') {
231
+ const widgetRoot = widgetRootFromQuery(root, url);
232
+ console.log(`Await snapshot requested: ${path.basename(widgetRoot)}`);
233
+ const archive = zipFiles(scanFiles(widgetRoot));
95
234
  response.writeHead(200, {
96
- "Content-Type": "application/zip",
97
- "Content-Length": archive.length,
98
- "Cache-Control": "no-store",
235
+ 'Content-Type': 'application/zip',
236
+ 'Content-Length': archive.length,
237
+ 'Cache-Control': 'no-store',
99
238
  });
100
239
  response.end(archive);
101
240
  return;
102
241
  }
103
- sendText(response, "Not Found\n", 404);
242
+
243
+ if (request.method === 'POST' && url.pathname === '/await-dev/app-command') {
244
+ const body = await readJsonBody(request);
245
+ const result = await sendAppCommand(bridge, body.tool, body.arguments ?? {});
246
+ sendJson(response, result);
247
+ return;
248
+ }
249
+
250
+ if (request.method === 'POST' && url.pathname === '/await-dev/command-result') {
251
+ const body = await readJsonBody(request);
252
+ completeAppCommand(bridge, body);
253
+ sendJson(response, {ok: true});
254
+ return;
255
+ }
256
+
257
+ sendText(response, 'Not Found\n', 404);
258
+ }
259
+
260
+ function addEventClient(bridge, response) {
261
+ response.writeHead(200, {
262
+ 'Content-Type': 'text/event-stream',
263
+ 'Cache-Control': 'no-store',
264
+ Connection: 'keep-alive',
265
+ });
266
+ response.flushHeaders?.();
267
+ response.write(': connected\n\n');
268
+ console.log('Await app event stream connected.');
269
+ const heartbeat = setInterval(() => {
270
+ response.write(': ping\n\n');
271
+ }, 15_000);
272
+ bridge.events.add(response);
273
+ bridge.activeEvent = response;
274
+ response.on('close', () => {
275
+ clearInterval(heartbeat);
276
+ bridge.events.delete(response);
277
+ if (bridge.activeEvent === response) {
278
+ bridge.activeEvent = [...bridge.events].at(-1);
279
+ }
280
+
281
+ console.log('Await app event stream disconnected.');
282
+ });
283
+ }
284
+
285
+ function writeEvent(response, event, data) {
286
+ response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
287
+ }
288
+
289
+ function sendEvent(bridge, event, data) {
290
+ if (event === 'widgetChanged' || event === 'widgetsChanged') {
291
+ console.log(`Await event ${event}: ${JSON.stringify(data)}`);
292
+ }
293
+
294
+ for (const response of bridge.events) {
295
+ writeEvent(response, event, data);
296
+ }
297
+ }
298
+
299
+ function sendAppCommand(bridge, tool, args) {
300
+ if (!bridge.activeEvent) {
301
+ throw errorWithStatus('Await app is not connected.', 503);
302
+ }
303
+
304
+ const id = crypto.randomUUID();
305
+ const command = {id, tool, arguments: args};
306
+ return new Promise((resolve, reject) => {
307
+ const timer = setTimeout(() => {
308
+ bridge.pendingCommands.delete(id);
309
+ reject(errorWithStatus(`Await app command timed out: ${tool}`, 504));
310
+ }, 15_000);
311
+ bridge.pendingCommands.set(id, {resolve, reject, timer});
312
+ writeEvent(bridge.activeEvent, 'command', command);
313
+ });
314
+ }
315
+
316
+ function completeAppCommand(bridge, body) {
317
+ const id = String(body.id ?? '');
318
+ const pending = bridge.pendingCommands.get(id);
319
+ if (!pending) {
320
+ return;
321
+ }
322
+
323
+ clearTimeout(pending.timer);
324
+ bridge.pendingCommands.delete(id);
325
+ if (body.ok === false) {
326
+ pending.reject(errorWithStatus(String(body.error ?? 'Await app command failed.'), 500));
327
+ return;
328
+ }
329
+
330
+ pending.resolve(body.result ?? {});
331
+ }
332
+
333
+ function startWorkspaceWatcher(root, bridge) {
334
+ try {
335
+ fs.watch(root, {recursive: true}, (_eventType, filename) => {
336
+ handleWorkspaceFileEvent(root, bridge, filename);
337
+ });
338
+ } catch {
339
+ fs.watch(root, (_eventType, filename) => {
340
+ handleWorkspaceFileEvent(root, bridge, filename);
341
+ });
342
+ }
343
+ }
344
+
345
+ function handleWorkspaceFileEvent(root, bridge, filename) {
346
+ const relativePath = String(filename ?? '').replaceAll('\\', '/');
347
+ const [topLevel] = relativePath.split('/');
348
+ if (topLevel && !isExcludedName(topLevel)) {
349
+ scheduleWidgetChanged(bridge, singleWidgetPath);
350
+ }
351
+
352
+ scheduleWidgetsChanged(root, bridge);
353
+ }
354
+
355
+ function scheduleWidgetChanged(bridge, widgetPath) {
356
+ clearTimeout(bridge.widgetChangeTimers.get(widgetPath));
357
+ bridge.widgetChangeTimers.set(widgetPath, setTimeout(() => {
358
+ bridge.widgetChangeTimers.delete(widgetPath);
359
+ sendEvent(bridge, 'widgetChanged', {path: widgetPath});
360
+ }, 250));
361
+ }
362
+
363
+ function scheduleWidgetsChanged(root, bridge) {
364
+ clearTimeout(bridge.widgetsChangedTimer);
365
+ bridge.widgetsChangedTimer = setTimeout(() => {
366
+ const key = widgetPathKey(listDesktopWidgets(root));
367
+ if (key === bridge.knownWidgets) {
368
+ return;
369
+ }
370
+
371
+ bridge.knownWidgets = key;
372
+ sendEvent(bridge, 'widgetsChanged', {widgets: listDesktopWidgets(root)});
373
+ }, 250);
374
+ }
375
+
376
+ function widgetPathKey(widgets) {
377
+ return widgets.map(widget => widget.path).sort((left, right) => left.localeCompare(right)).join('\n');
378
+ }
379
+
380
+ function findPackageRoot(start) {
381
+ let current = path.resolve(start);
382
+ while (true) {
383
+ const candidate = path.join(current, 'package.json');
384
+ if (fs.existsSync(candidate)) {
385
+ return current;
386
+ }
387
+
388
+ const parent = path.dirname(current);
389
+ if (parent === current) {
390
+ return null;
391
+ }
392
+
393
+ current = parent;
394
+ }
395
+ }
396
+
397
+ function resolveWidgetServerRoots(start) {
398
+ const widgetRoot = path.resolve(start);
399
+ const packageRoot = findPackageRoot(widgetRoot);
400
+ if (
401
+ !packageRoot
402
+ || packageRoot === widgetRoot
403
+ || path.dirname(widgetRoot) !== packageRoot
404
+ || !isValidTopLevelName(path.basename(widgetRoot))
405
+ || !hasRuntimeDependency(packageRoot)
406
+ ) {
407
+ throw new Error('Run await-widget from a first-level widget folder inside a package whose package.json includes @await-widget/runtime.');
408
+ }
409
+
410
+ return {packageRoot, widgetRoot};
411
+ }
412
+
413
+ function hasRuntimeDependency(packageRoot) {
414
+ const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
415
+ return Boolean(manifest.dependencies?.['@await-widget/runtime'] ?? manifest.devDependencies?.['@await-widget/runtime']);
416
+ }
417
+
418
+ function listDesktopWidgets(root) {
419
+ return [{name: path.basename(root), path: singleWidgetPath}];
420
+ }
421
+
422
+ function isExcludedName(name) {
423
+ return name.startsWith('.') || excludedNames.has(name);
424
+ }
425
+
426
+ function widgetRootFromQuery(root, url) {
427
+ const widgetPath = requiredSearchParam(url, 'path');
428
+ return safeWidgetPath(root, widgetPath);
429
+ }
430
+
431
+ function requiredSearchParam(url, name) {
432
+ const value = url.searchParams.get(name);
433
+ if (!value) {
434
+ throw new Error(`Missing ${name}`);
435
+ }
436
+
437
+ return value;
438
+ }
439
+
440
+ function safeWidgetPath(root, widgetPath, options = {}) {
441
+ if (widgetPath !== singleWidgetPath) {
442
+ throw new Error(`Invalid widget path: ${widgetPath}`);
443
+ }
444
+
445
+ if (!options.allowMissing && !isDirectory(root)) {
446
+ throw errorWithStatus(`Widget directory does not exist: ${widgetPath}`, 404);
447
+ }
448
+
449
+ return root;
450
+ }
451
+
452
+ function isValidTopLevelName(name) {
453
+ return Boolean(name) && !name.includes('/') && !name.includes('\\') && !isExcludedName(name);
104
454
  }
105
455
 
106
456
  function projectVersion(root) {
107
- const hash = crypto.createHash("sha256");
457
+ const hash = crypto.createHash('sha256');
108
458
  for (const file of scanFiles(root)) {
109
459
  hash.update(file.relativePath);
110
- hash.update("\0");
460
+ hash.update('\0');
111
461
  hash.update(String(file.size));
112
- hash.update("\0");
113
- hash.update(String(Math.trunc(file.mtimeMs)));
114
- hash.update("\0");
462
+ hash.update('\0');
463
+ hash.update(String(file.mtimeMs));
464
+ hash.update('\0');
115
465
  }
116
- return `sha256-${hash.digest("hex")}`;
466
+
467
+ return `sha256-${hash.digest('hex')}`;
117
468
  }
118
469
 
119
470
  function scanFiles(root) {
120
471
  const files = [];
121
- walk(root, "", files);
472
+ walk(root, '', files);
122
473
  return files;
123
474
  }
124
475
 
@@ -126,18 +477,21 @@ function walk(directory, relativeDirectory, files) {
126
477
  const entries = fs.readdirSync(directory, {withFileTypes: true})
127
478
  .sort((left, right) => left.name.localeCompare(right.name));
128
479
  for (const entry of entries) {
129
- if (entry.name.startsWith(".") || excludedNames.has(entry.name)) {
480
+ if (isExcludedName(entry.name)) {
130
481
  continue;
131
482
  }
483
+
132
484
  const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
133
485
  const absolutePath = path.join(directory, entry.name);
134
486
  if (entry.isDirectory()) {
135
487
  walk(absolutePath, relativePath, files);
136
488
  continue;
137
489
  }
490
+
138
491
  if (!entry.isFile()) {
139
492
  continue;
140
493
  }
494
+
141
495
  const stats = fs.statSync(absolutePath);
142
496
  files.push({
143
497
  absolutePath,
@@ -153,13 +507,13 @@ function zipFiles(files) {
153
507
  const centralParts = [];
154
508
  let offset = 0;
155
509
  for (const file of files) {
156
- const name = Buffer.from(file.relativePath, "utf8");
510
+ const name = Buffer.from(file.relativePath, 'utf8');
157
511
  const data = fs.readFileSync(file.absolutePath);
158
512
  const crc = crc32(data);
159
513
  const localHeader = Buffer.concat([
160
- u32(0x04034B50),
514
+ u32(0x04_03_4B_50),
161
515
  u16(20),
162
- u16(0x0800),
516
+ u16(0x08_00),
163
517
  u16(0),
164
518
  u16(zipDosTime),
165
519
  u16(zipDosDate),
@@ -172,10 +526,10 @@ function zipFiles(files) {
172
526
  ]);
173
527
  localParts.push(localHeader, data);
174
528
  centralParts.push(Buffer.concat([
175
- u32(0x02014B50),
529
+ u32(0x02_01_4B_50),
176
530
  u16(20),
177
531
  u16(20),
178
- u16(0x0800),
532
+ u16(0x08_00),
179
533
  u16(0),
180
534
  u16(zipDosTime),
181
535
  u16(zipDosDate),
@@ -193,9 +547,10 @@ function zipFiles(files) {
193
547
  ]));
194
548
  offset += localHeader.length + data.length;
195
549
  }
550
+
196
551
  const centralDirectory = Buffer.concat(centralParts);
197
552
  const end = Buffer.concat([
198
- u32(0x06054B50),
553
+ u32(0x06_05_4B_50),
199
554
  u16(0),
200
555
  u16(0),
201
556
  u16(files.length),
@@ -207,24 +562,238 @@ function zipFiles(files) {
207
562
  return Buffer.concat([...localParts, centralDirectory, end]);
208
563
  }
209
564
 
565
+ const appCommands = [
566
+ {
567
+ name: 'list-app-widgets',
568
+ usage: 'list-app-widgets',
569
+ description: 'List Await widgets currently stored in the Await app.',
570
+ inputSchema: {type: 'object', properties: {}},
571
+ },
572
+ {
573
+ name: 'open-widget-detail',
574
+ usage: 'open-widget-detail --widget-id <id>',
575
+ description: 'Open a widget detail page in the Await app.',
576
+ inputSchema: {
577
+ type: 'object',
578
+ properties: {widgetId: {type: 'string'}},
579
+ required: ['widgetId'],
580
+ },
581
+ },
582
+ {
583
+ name: 'open-syncing-widget-detail',
584
+ usage: 'open-syncing-widget-detail',
585
+ description: 'Open the widget detail page for the widget currently syncing from this computer.',
586
+ inputSchema: {type: 'object', properties: {}},
587
+ },
588
+ {
589
+ name: 'set-preview-mode',
590
+ usage: 'set-preview-mode --mode <small|medium|large|extraLarge> [--widget-id <id>]',
591
+ description: 'Set the current widget detail preview mode.',
592
+ inputSchema: {
593
+ type: 'object',
594
+ properties: {
595
+ widgetId: {type: 'string'},
596
+ mode: {type: 'string', enum: ['small', 'medium', 'large', 'extraLarge']},
597
+ },
598
+ required: ['mode'],
599
+ },
600
+ },
601
+ {
602
+ name: 'capture-current-preview',
603
+ usage: 'capture-current-preview [--widget-id <id>]',
604
+ description: 'Capture the currently visible widget preview as PNG.',
605
+ inputSchema: {
606
+ type: 'object',
607
+ properties: {widgetId: {type: 'string'}},
608
+ },
609
+ },
610
+ {
611
+ name: 'get-widget-status',
612
+ usage: 'get-widget-status --widget-id <id>',
613
+ description: 'Get App-side status for a widget.',
614
+ inputSchema: {
615
+ type: 'object',
616
+ properties: {widgetId: {type: 'string'}},
617
+ required: ['widgetId'],
618
+ },
619
+ },
620
+ {
621
+ name: 'wait-for-widget-ready',
622
+ usage: 'wait-for-widget-ready --widget-id <id> [--timeout-ms <ms>]',
623
+ description: 'Wait for the current widget preview to have rendered data.',
624
+ inputSchema: {
625
+ type: 'object',
626
+ properties: {
627
+ widgetId: {type: 'string'},
628
+ timeoutMs: {type: 'number'},
629
+ },
630
+ required: ['widgetId'],
631
+ },
632
+ },
633
+ {
634
+ name: 'get-build-errors',
635
+ usage: 'get-build-errors --widget-id <id>',
636
+ description: 'Get recent build errors for a widget.',
637
+ inputSchema: {
638
+ type: 'object',
639
+ properties: {widgetId: {type: 'string'}},
640
+ required: ['widgetId'],
641
+ },
642
+ },
643
+ {
644
+ name: 'list-widget-intents',
645
+ usage: 'list-widget-intents --widget-id <id>',
646
+ description: 'List widget intents registered by a widget.',
647
+ inputSchema: {
648
+ type: 'object',
649
+ properties: {widgetId: {type: 'string'}},
650
+ required: ['widgetId'],
651
+ },
652
+ },
653
+ {
654
+ name: 'call-widget-intent',
655
+ usage: 'call-widget-intent --widget-id <id> --intent-id <id> [--input <json-array>]',
656
+ description: 'Call a widget intent with positional input arguments.',
657
+ inputSchema: {
658
+ type: 'object',
659
+ properties: {
660
+ widgetId: {type: 'string'},
661
+ intentId: {type: 'string'},
662
+ input: {
663
+ type: 'array',
664
+ items: {},
665
+ },
666
+ },
667
+ required: ['widgetId', 'intentId'],
668
+ },
669
+ },
670
+ ];
671
+
672
+ function appCommandName(value) {
673
+ if (!appCommands.some(command => command.name === value)) {
674
+ throw new Error(`Unsupported Await app command: ${value}`);
675
+ }
676
+
677
+ return value;
678
+ }
679
+
680
+ async function runAppCommand(options) {
681
+ const result = await callAppCommand(resolveBridgeUrl(options), options.tool, options.toolArguments);
682
+ console.log(JSON.stringify(appCommandResult(options, options.tool, result), null, '\t'));
683
+ }
684
+
685
+ function resolveBridgeUrl(options) {
686
+ if (options.bridgeUrl) {
687
+ return options.bridgeUrl;
688
+ }
689
+
690
+ const root = findPackageRoot(path.resolve(options.workspace ?? process.cwd()));
691
+ if (!root) {
692
+ throw new Error('Cannot find package.json for Await bridge lookup.');
693
+ }
694
+
695
+ return readBridgeUrl(bridgeInfoPath(root));
696
+ }
697
+
698
+ function readBridgeUrl(infoPath) {
699
+ if (!fs.existsSync(infoPath)) {
700
+ throw new Error('Cannot find Await bridge info. Run npx await-widget first.');
701
+ }
702
+
703
+ const info = JSON.parse(fs.readFileSync(infoPath, 'utf8'));
704
+ return info.bridgeUrl;
705
+ }
706
+
707
+ async function callAppCommand(bridgeUrl, tool, args) {
708
+ if (!appCommands.some(item => item.name === tool)) {
709
+ throw new Error(`Unsupported Await app command: ${tool}`);
710
+ }
711
+
712
+ const url = bridgeEndpoint(bridgeUrl, '/await-dev/app-command');
713
+ const response = await fetch(url, {
714
+ method: 'POST',
715
+ headers: {'Content-Type': 'application/json'},
716
+ body: JSON.stringify({tool, arguments: args}),
717
+ });
718
+ const body = await response.json();
719
+ if (!response.ok) {
720
+ throw new Error(body.error ?? `Await bridge request failed: ${response.status}`);
721
+ }
722
+
723
+ return body;
724
+ }
725
+
726
+ function appCommandResult(options, tool, result) {
727
+ if (tool === 'capture-current-preview' && result.imageBase64) {
728
+ const filePath = saveCapture(options, result);
729
+ return {...result, imageBase64: undefined, filePath};
730
+ }
731
+
732
+ return result;
733
+ }
734
+
735
+ function saveCapture(options, result) {
736
+ const root = findPackageRoot(options.workspace ? path.resolve(options.workspace) : process.cwd()) ?? process.cwd();
737
+ const directory = path.join(root, bridgeInfoDirectory, 'captures');
738
+ const name = safeCaptureFileName(String(result.widgetId ?? 'widget'));
739
+ const filePath = path.join(directory, `${name}-${Date.now()}.png`);
740
+ fs.mkdirSync(directory, {recursive: true});
741
+ fs.writeFileSync(filePath, Buffer.from(result.imageBase64, 'base64'));
742
+ return filePath;
743
+ }
744
+
745
+ function safeCaptureFileName(value) {
746
+ const name = value.replaceAll(/[^\w.-]+/g, '-').replaceAll(/^-+|-+$/g, '');
747
+ return name || 'widget';
748
+ }
749
+
750
+ function bridgeEndpoint(bridgeUrl, pathname) {
751
+ const url = new URL(bridgeUrl);
752
+ url.pathname = pathname;
753
+ return url;
754
+ }
755
+
756
+ async function readJsonBody(request) {
757
+ const body = await readBody(request);
758
+ return JSON.parse(body.toString('utf8'));
759
+ }
760
+
761
+ async function readBody(request) {
762
+ const chunks = [];
763
+ let size = 0;
764
+ for await (const chunk of request) {
765
+ size += chunk.length;
766
+ if (size > maxBodyBytes) {
767
+ throw new Error('Request body too large');
768
+ }
769
+
770
+ chunks.push(chunk);
771
+ }
772
+
773
+ return Buffer.concat(chunks);
774
+ }
775
+
210
776
  function makeCrcTable() {
211
777
  const table = new Uint32Array(256);
212
778
  for (let index = 0; index < table.length; index += 1) {
213
779
  let value = index;
214
780
  for (let bit = 0; bit < 8; bit += 1) {
215
- value = value & 1 ? 0xEDB88320 ^ (value >>> 1) : value >>> 1;
781
+ value = value & 1 ? 0xED_B8_83_20 ^ (value >>> 1) : value >>> 1;
216
782
  }
783
+
217
784
  table[index] = value >>> 0;
218
785
  }
786
+
219
787
  return table;
220
788
  }
221
789
 
222
790
  function crc32(data) {
223
- let crc = 0xFFFFFFFF;
791
+ let crc = 0xFF_FF_FF_FF;
224
792
  for (const byte of data) {
225
793
  crc = crcTable[(crc ^ byte) & 0xFF] ^ (crc >>> 8);
226
794
  }
227
- return (crc ^ 0xFFFFFFFF) >>> 0;
795
+
796
+ return (crc ^ 0xFF_FF_FF_FF) >>> 0;
228
797
  }
229
798
 
230
799
  function u16(value) {
@@ -239,50 +808,88 @@ function u32(value) {
239
808
  return buffer;
240
809
  }
241
810
 
242
- function sendJson(response, value) {
811
+ function sendJson(response, value, status = 200) {
243
812
  const body = Buffer.from(`${JSON.stringify(value)}\n`);
244
- response.writeHead(200, {
245
- "Content-Type": "application/json",
246
- "Content-Length": body.length,
247
- "Cache-Control": "no-store",
813
+ response.writeHead(status, {
814
+ 'Content-Type': 'application/json',
815
+ 'Content-Length': body.length,
816
+ 'Cache-Control': 'no-store',
248
817
  });
249
818
  response.end(body);
250
819
  }
251
820
 
821
+ function errorWithStatus(message, statusCode) {
822
+ const error = new Error(message);
823
+ error.statusCode = statusCode;
824
+ return error;
825
+ }
826
+
827
+ function errorStatus(error) {
828
+ return Number.isInteger(error.statusCode) ? error.statusCode : 500;
829
+ }
830
+
252
831
  function sendText(response, value, status = 200) {
253
832
  response.writeHead(status, {
254
- "Content-Type": "text/plain; charset=utf-8",
255
- "Cache-Control": "no-store",
833
+ 'Content-Type': 'text/plain; charset=utf-8',
834
+ 'Cache-Control': 'no-store',
256
835
  });
257
836
  response.end(value);
258
837
  }
259
838
 
260
839
  function printServerUrls(port, token) {
261
- const networkUrls = localNetworkUrls(port, token);
262
- const recommended = networkUrls.find(({address}) => isPrivateIPv4(address)) ?? networkUrls[0];
840
+ const {recommended, otherUrls} = serverUrls(port, token);
263
841
  if (recommended) {
264
- console.log("Paste this URL into Await on iPhone:");
265
- console.log("");
842
+ console.log('Paste this URL into Await:');
843
+ console.log('');
266
844
  console.log(recommended.url);
267
- console.log("");
845
+ console.log('');
268
846
  }
269
- const otherUrls = [
270
- {url: `http://127.0.0.1:${port}?token=${token}`},
271
- ...networkUrls.filter(item => item !== recommended),
272
- ];
847
+
273
848
  if (otherUrls.length > 0) {
274
- console.log("Other network interfaces, usually not needed:");
849
+ console.log('Other network interfaces, usually not needed:');
275
850
  for (const item of otherUrls) {
276
851
  console.log(item.url);
277
852
  }
278
853
  }
279
854
  }
280
855
 
856
+ function writeBridgeInfo(root, port, token) {
857
+ const {recommended, otherUrls} = serverUrls(port, token);
858
+ const info = {
859
+ workspaceRoot: root,
860
+ workspaceName: path.basename(root),
861
+ port,
862
+ token,
863
+ bridgeUrl: `http://127.0.0.1:${port}?token=${token}`,
864
+ appUrl: recommended?.url,
865
+ urls: [recommended, ...otherUrls].filter(Boolean).map(item => item.url),
866
+ updatedAt: new Date().toISOString(),
867
+ };
868
+ fs.mkdirSync(path.dirname(bridgeInfoPath(root)), {recursive: true});
869
+ fs.writeFileSync(bridgeInfoPath(root), `${JSON.stringify(info, null, '\t')}\n`);
870
+ }
871
+
872
+ function bridgeInfoPath(root) {
873
+ return path.join(root, bridgeInfoDirectory, bridgeInfoFileName);
874
+ }
875
+
876
+ function serverUrls(port, token) {
877
+ const networkUrls = localNetworkUrls(port, token);
878
+ const recommended = networkUrls.find(({address}) => isPrivateIPv4(address)) ?? networkUrls[0];
879
+ return {
880
+ recommended,
881
+ otherUrls: [
882
+ {url: `http://127.0.0.1:${port}?token=${token}`},
883
+ ...networkUrls.filter(item => item !== recommended),
884
+ ],
885
+ };
886
+ }
887
+
281
888
  function localNetworkUrls(port, token) {
282
889
  const urls = [];
283
890
  for (const interfaces of Object.values(os.networkInterfaces())) {
284
891
  for (const item of interfaces ?? []) {
285
- if (item.family === "IPv4" && !item.internal) {
892
+ if (item.family === 'IPv4' && !item.internal) {
286
893
  urls.push({
287
894
  address: item.address,
288
895
  url: `http://${item.address}:${port}?token=${token}`,
@@ -290,17 +897,19 @@ function localNetworkUrls(port, token) {
290
897
  }
291
898
  }
292
899
  }
900
+
293
901
  return urls;
294
902
  }
295
903
 
296
904
  function isPrivateIPv4(address) {
297
- const parts = address.split(".").map(part => Number(part));
905
+ const parts = address.split('.').map(Number);
298
906
  if (parts.length !== 4 || parts.some(part => !Number.isInteger(part))) {
299
907
  return false;
300
908
  }
301
- return parts[0] === 10 ||
302
- (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
303
- (parts[0] === 192 && parts[1] === 168);
909
+
910
+ return parts[0] === 10
911
+ || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
912
+ || (parts[0] === 192 && parts[1] === 168);
304
913
  }
305
914
 
306
915
  function urlForRequest(request, token) {
@@ -308,14 +917,50 @@ function urlForRequest(request, token) {
308
917
  return `http://${host}?token=${token}`;
309
918
  }
310
919
 
920
+ function isDirectory(value) {
921
+ try {
922
+ return fs.statSync(value).isDirectory();
923
+ } catch {
924
+ return false;
925
+ }
926
+ }
927
+
311
928
  function printHelp() {
929
+ const commandHelp = appCommands
930
+ .map(command => [
931
+ ` ${command.usage}`,
932
+ ` tool: ${command.name}`,
933
+ ` ${command.description}`,
934
+ ` inputSchema: ${JSON.stringify(command.inputSchema)}`,
935
+ ].join('\n'))
936
+ .join('\n');
312
937
  console.log(`Usage:
313
- await-widget dev <widget-directory> [--port ${defaultPort}]
938
+ await-widget [--port ${defaultPort}]
939
+ await-widget app <command> [options]
940
+
941
+ Start the Await computer connection from a first-level widget folder under a
942
+ package whose package.json includes @await-widget/runtime.
943
+
944
+ What it does:
945
+ - Serves the current widget folder to Await for live sync.
946
+ - Prints a URL to paste into Await's Connect Computer sheet.
947
+ - Writes .await/bridge.json in the package root for app commands.
948
+ - Sends one-shot JSON app commands with await-widget app <command>.
949
+
950
+ Examples:
951
+ npx await-widget
952
+ npx await-widget --port 4344
953
+ npx await-widget app open-syncing-widget-detail
954
+ npx await-widget app wait-for-widget-ready --widget-id 2
955
+ npx await-widget app capture-current-preview --widget-id 2
314
956
 
315
- Example:
316
- await-widget dev YourWidget
957
+ App command options:
958
+ --workspace <path> Read .await/bridge.json from another package/widget path.
959
+ --bridge-url <url> Send directly to a bridge URL instead of reading bridge.json.
960
+ --json <json> Merge raw JSON arguments into the app command.
317
961
 
318
- Pass a widget subdirectory, not the current package directory.
962
+ App commands:
963
+ ${commandHelp}
319
964
  `);
320
965
  }
321
966
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@await-widget/runtime",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
4
4
  "description": "TypeScript declarations for Await widgets.",
5
5
  "license": "MIT",
6
6
  "author": "LitoMore",
package/types/model.d.ts CHANGED
@@ -206,6 +206,12 @@ type AwaitLocationInfo = {
206
206
  altitudeMeters?: number;
207
207
  speedMetersPerSecond?: number;
208
208
  courseDegrees?: number;
209
+ country?: string;
210
+ administrativeArea?: string;
211
+ locality?: string;
212
+ subLocality?: string;
213
+ thoroughfare?: string;
214
+ subThoroughfare?: string;
209
215
  };
210
216
 
211
217
  type AwaitNowPlayingConfig = {