@await-widget/runtime 0.0.24 → 0.0.25

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,239 @@ 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
+ const name = value.replaceAll('-', '_');
674
+ if (!appCommands.some(command => command.name === name)) {
675
+ throw new Error(`Unsupported Await app command: ${value}`);
676
+ }
677
+
678
+ return name;
679
+ }
680
+
681
+ async function runAppCommand(options) {
682
+ const result = await callAppCommand(resolveBridgeUrl(options), options.tool, options.toolArguments);
683
+ console.log(JSON.stringify(appCommandResult(options, options.tool, result), null, '\t'));
684
+ }
685
+
686
+ function resolveBridgeUrl(options) {
687
+ if (options.bridgeUrl) {
688
+ return options.bridgeUrl;
689
+ }
690
+
691
+ const root = findPackageRoot(path.resolve(options.workspace ?? process.cwd()));
692
+ if (!root) {
693
+ throw new Error('Cannot find package.json for Await bridge lookup.');
694
+ }
695
+
696
+ return readBridgeUrl(bridgeInfoPath(root));
697
+ }
698
+
699
+ function readBridgeUrl(infoPath) {
700
+ if (!fs.existsSync(infoPath)) {
701
+ throw new Error('Cannot find Await bridge info. Run npx await-widget first.');
702
+ }
703
+
704
+ const info = JSON.parse(fs.readFileSync(infoPath, 'utf8'));
705
+ return info.bridgeUrl;
706
+ }
707
+
708
+ async function callAppCommand(bridgeUrl, tool, args) {
709
+ if (!appCommands.some(item => item.name === tool)) {
710
+ throw new Error(`Unsupported Await app command: ${tool}`);
711
+ }
712
+
713
+ const url = bridgeEndpoint(bridgeUrl, '/await-dev/app-command');
714
+ const response = await fetch(url, {
715
+ method: 'POST',
716
+ headers: {'Content-Type': 'application/json'},
717
+ body: JSON.stringify({tool, arguments: args}),
718
+ });
719
+ const body = await response.json();
720
+ if (!response.ok) {
721
+ throw new Error(body.error ?? `Await bridge request failed: ${response.status}`);
722
+ }
723
+
724
+ return body;
725
+ }
726
+
727
+ function appCommandResult(options, tool, result) {
728
+ if (tool === 'capture_current_preview' && result.imageBase64) {
729
+ const filePath = saveCapture(options, result);
730
+ return {...result, imageBase64: undefined, filePath};
731
+ }
732
+
733
+ return result;
734
+ }
735
+
736
+ function saveCapture(options, result) {
737
+ const root = findPackageRoot(options.workspace ? path.resolve(options.workspace) : process.cwd()) ?? process.cwd();
738
+ const directory = path.join(root, bridgeInfoDirectory, 'captures');
739
+ const name = safeCaptureFileName(String(result.widgetId ?? 'widget'));
740
+ const filePath = path.join(directory, `${name}-${Date.now()}.png`);
741
+ fs.mkdirSync(directory, {recursive: true});
742
+ fs.writeFileSync(filePath, Buffer.from(result.imageBase64, 'base64'));
743
+ return filePath;
744
+ }
745
+
746
+ function safeCaptureFileName(value) {
747
+ const name = value.replaceAll(/[^\w.-]+/g, '-').replaceAll(/^-+|-+$/g, '');
748
+ return name || 'widget';
749
+ }
750
+
751
+ function bridgeEndpoint(bridgeUrl, pathname) {
752
+ const url = new URL(bridgeUrl);
753
+ url.pathname = pathname;
754
+ return url;
755
+ }
756
+
757
+ async function readJsonBody(request) {
758
+ const body = await readBody(request);
759
+ return JSON.parse(body.toString('utf8'));
760
+ }
761
+
762
+ async function readBody(request) {
763
+ const chunks = [];
764
+ let size = 0;
765
+ for await (const chunk of request) {
766
+ size += chunk.length;
767
+ if (size > maxBodyBytes) {
768
+ throw new Error('Request body too large');
769
+ }
770
+
771
+ chunks.push(chunk);
772
+ }
773
+
774
+ return Buffer.concat(chunks);
775
+ }
776
+
210
777
  function makeCrcTable() {
211
778
  const table = new Uint32Array(256);
212
779
  for (let index = 0; index < table.length; index += 1) {
213
780
  let value = index;
214
781
  for (let bit = 0; bit < 8; bit += 1) {
215
- value = value & 1 ? 0xEDB88320 ^ (value >>> 1) : value >>> 1;
782
+ value = value & 1 ? 0xED_B8_83_20 ^ (value >>> 1) : value >>> 1;
216
783
  }
784
+
217
785
  table[index] = value >>> 0;
218
786
  }
787
+
219
788
  return table;
220
789
  }
221
790
 
222
791
  function crc32(data) {
223
- let crc = 0xFFFFFFFF;
792
+ let crc = 0xFF_FF_FF_FF;
224
793
  for (const byte of data) {
225
794
  crc = crcTable[(crc ^ byte) & 0xFF] ^ (crc >>> 8);
226
795
  }
227
- return (crc ^ 0xFFFFFFFF) >>> 0;
796
+
797
+ return (crc ^ 0xFF_FF_FF_FF) >>> 0;
228
798
  }
229
799
 
230
800
  function u16(value) {
@@ -239,50 +809,88 @@ function u32(value) {
239
809
  return buffer;
240
810
  }
241
811
 
242
- function sendJson(response, value) {
812
+ function sendJson(response, value, status = 200) {
243
813
  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",
814
+ response.writeHead(status, {
815
+ 'Content-Type': 'application/json',
816
+ 'Content-Length': body.length,
817
+ 'Cache-Control': 'no-store',
248
818
  });
249
819
  response.end(body);
250
820
  }
251
821
 
822
+ function errorWithStatus(message, statusCode) {
823
+ const error = new Error(message);
824
+ error.statusCode = statusCode;
825
+ return error;
826
+ }
827
+
828
+ function errorStatus(error) {
829
+ return Number.isInteger(error.statusCode) ? error.statusCode : 500;
830
+ }
831
+
252
832
  function sendText(response, value, status = 200) {
253
833
  response.writeHead(status, {
254
- "Content-Type": "text/plain; charset=utf-8",
255
- "Cache-Control": "no-store",
834
+ 'Content-Type': 'text/plain; charset=utf-8',
835
+ 'Cache-Control': 'no-store',
256
836
  });
257
837
  response.end(value);
258
838
  }
259
839
 
260
840
  function printServerUrls(port, token) {
261
- const networkUrls = localNetworkUrls(port, token);
262
- const recommended = networkUrls.find(({address}) => isPrivateIPv4(address)) ?? networkUrls[0];
841
+ const {recommended, otherUrls} = serverUrls(port, token);
263
842
  if (recommended) {
264
- console.log("Paste this URL into Await on iPhone:");
265
- console.log("");
843
+ console.log('Paste this URL into Await:');
844
+ console.log('');
266
845
  console.log(recommended.url);
267
- console.log("");
846
+ console.log('');
268
847
  }
269
- const otherUrls = [
270
- {url: `http://127.0.0.1:${port}?token=${token}`},
271
- ...networkUrls.filter(item => item !== recommended),
272
- ];
848
+
273
849
  if (otherUrls.length > 0) {
274
- console.log("Other network interfaces, usually not needed:");
850
+ console.log('Other network interfaces, usually not needed:');
275
851
  for (const item of otherUrls) {
276
852
  console.log(item.url);
277
853
  }
278
854
  }
279
855
  }
280
856
 
857
+ function writeBridgeInfo(root, port, token) {
858
+ const {recommended, otherUrls} = serverUrls(port, token);
859
+ const info = {
860
+ workspaceRoot: root,
861
+ workspaceName: path.basename(root),
862
+ port,
863
+ token,
864
+ bridgeUrl: `http://127.0.0.1:${port}?token=${token}`,
865
+ appUrl: recommended?.url,
866
+ urls: [recommended, ...otherUrls].filter(Boolean).map(item => item.url),
867
+ updatedAt: new Date().toISOString(),
868
+ };
869
+ fs.mkdirSync(path.dirname(bridgeInfoPath(root)), {recursive: true});
870
+ fs.writeFileSync(bridgeInfoPath(root), `${JSON.stringify(info, null, '\t')}\n`);
871
+ }
872
+
873
+ function bridgeInfoPath(root) {
874
+ return path.join(root, bridgeInfoDirectory, bridgeInfoFileName);
875
+ }
876
+
877
+ function serverUrls(port, token) {
878
+ const networkUrls = localNetworkUrls(port, token);
879
+ const recommended = networkUrls.find(({address}) => isPrivateIPv4(address)) ?? networkUrls[0];
880
+ return {
881
+ recommended,
882
+ otherUrls: [
883
+ {url: `http://127.0.0.1:${port}?token=${token}`},
884
+ ...networkUrls.filter(item => item !== recommended),
885
+ ],
886
+ };
887
+ }
888
+
281
889
  function localNetworkUrls(port, token) {
282
890
  const urls = [];
283
891
  for (const interfaces of Object.values(os.networkInterfaces())) {
284
892
  for (const item of interfaces ?? []) {
285
- if (item.family === "IPv4" && !item.internal) {
893
+ if (item.family === 'IPv4' && !item.internal) {
286
894
  urls.push({
287
895
  address: item.address,
288
896
  url: `http://${item.address}:${port}?token=${token}`,
@@ -290,17 +898,19 @@ function localNetworkUrls(port, token) {
290
898
  }
291
899
  }
292
900
  }
901
+
293
902
  return urls;
294
903
  }
295
904
 
296
905
  function isPrivateIPv4(address) {
297
- const parts = address.split(".").map(part => Number(part));
906
+ const parts = address.split('.').map(Number);
298
907
  if (parts.length !== 4 || parts.some(part => !Number.isInteger(part))) {
299
908
  return false;
300
909
  }
301
- return parts[0] === 10 ||
302
- (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
303
- (parts[0] === 192 && parts[1] === 168);
910
+
911
+ return parts[0] === 10
912
+ || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
913
+ || (parts[0] === 192 && parts[1] === 168);
304
914
  }
305
915
 
306
916
  function urlForRequest(request, token) {
@@ -308,14 +918,50 @@ function urlForRequest(request, token) {
308
918
  return `http://${host}?token=${token}`;
309
919
  }
310
920
 
921
+ function isDirectory(value) {
922
+ try {
923
+ return fs.statSync(value).isDirectory();
924
+ } catch {
925
+ return false;
926
+ }
927
+ }
928
+
311
929
  function printHelp() {
930
+ const commandHelp = appCommands
931
+ .map(command => [
932
+ ` ${command.usage}`,
933
+ ` tool: ${command.name}`,
934
+ ` ${command.description}`,
935
+ ` inputSchema: ${JSON.stringify(command.inputSchema)}`,
936
+ ].join('\n'))
937
+ .join('\n');
312
938
  console.log(`Usage:
313
- await-widget dev <widget-directory> [--port ${defaultPort}]
939
+ await-widget [--port ${defaultPort}]
940
+ await-widget app <command> [options]
941
+
942
+ Start the Await computer connection from a first-level widget folder under a
943
+ package whose package.json includes @await-widget/runtime.
944
+
945
+ What it does:
946
+ - Serves the current widget folder to Await for live sync.
947
+ - Prints a URL to paste into Await's Connect Computer sheet.
948
+ - Writes .await/bridge.json in the package root for app commands.
949
+ - Sends one-shot JSON app commands with await-widget app <command>.
950
+
951
+ Examples:
952
+ npx await-widget
953
+ npx await-widget --port 4344
954
+ npx await-widget app open-syncing-widget-detail
955
+ npx await-widget app wait-for-widget-ready --widget-id 2
956
+ npx await-widget app capture-current-preview --widget-id 2
314
957
 
315
- Example:
316
- await-widget dev YourWidget
958
+ App command options:
959
+ --workspace <path> Read .await/bridge.json from another package/widget path.
960
+ --bridge-url <url> Send directly to a bridge URL instead of reading bridge.json.
961
+ --json <json> Merge raw JSON arguments into the app command.
317
962
 
318
- Pass a widget subdirectory, not the current package directory.
963
+ App commands:
964
+ ${commandHelp}
319
965
  `);
320
966
  }
321
967
 
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.25",
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 = {