@streamorchard/grapevine 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PROTOCOL.md CHANGED
@@ -32,7 +32,7 @@ Every app has a **slug** (`a-z`, `0-9` and `-`, up to 40 characters, starting wi
32
32
  - A socket left by a crash answers nothing (`ECONNREFUSED`): its app deletes it and listens again when it starts. If something does answer, another copy of the app is running, and the new copy doesn't start its link.
33
33
  - An app only listens, and only looks for others, while at least one of its Connected apps switches is on.
34
34
 
35
- **Who connects.** Of two apps, the one whose slug sorts first (plain string order) connects; the other waits for it. So a pair never opens two connections. If they ever do, both keep the connection opened by the app whose slug sorts first and close the other with `link.bye { reason: "duplicate" }`. It's the slug, not the id, because an app's id can change (see [Renaming an app](#renaming-an-app)) and its socket's name doesn't.
35
+ **Who connects.** Of two apps, the one whose slug sorts first (plain string order) connects; the other waits for it, so a pair normally opens one connection. An app may still connect the other way to check the link on its user's request (a Test button). If they ever do have two, both keep the connection opened by the app whose slug sorts first and close the other with `link.bye { reason: "duplicate" }`. It's the slug, not the id, because an app's id can change (see [Renaming an app](#renaming-an-app)) and its socket's name doesn't.
36
36
 
37
37
  ## Messages
38
38
 
package/README.md CHANGED
@@ -28,10 +28,17 @@ npm install @streamorchard/grapevine
28
28
 
29
29
  ## Use it
30
30
 
31
+ Say what your app speaks in its `package.json`. It's the one place it's written, and every release tag keeps it, which is how `grapevine-check` knows what each release shipped (see [Recording releases](#recording-releases)):
32
+
33
+ ```json
34
+ "grapevine": { "app": "com.streamorchard.peardrops", "provides": { "drops.farming": "1.0" }, "uses": { "players": "1.0" } }
35
+ ```
36
+
31
37
  ```js
32
38
  const { app, safeStorage } = require('electron');
33
39
  const path = require('path');
34
40
  const { createLink, fileKeyStore } = require('@streamorchard/grapevine');
41
+ const { grapevine } = require('./package.json');
35
42
 
36
43
  const link = createLink({
37
44
  app: {
@@ -41,8 +48,8 @@ const link = createLink({
41
48
  version: app.getVersion(),
42
49
  icon: path.join(__dirname, 'icon.png'),
43
50
  },
44
- provides: { 'drops.farming': '1.0' }, // what it tells other apps
45
- uses: { players: '1.0' }, // what it listens to
51
+ provides: grapevine.provides, // what it tells other apps
52
+ uses: grapevine.uses, // what it listens to
46
53
  allowed: (appId) => settings.connectedApps[appId]?.enabled === true, // your Connected apps switches
47
54
  keys: fileKeyStore(path.join(app.getPath('userData'), 'grapevine.json'), {
48
55
  encrypt: (t) => safeStorage.encryptString(t).toString('base64'),
@@ -124,8 +131,15 @@ Editors suggest every event's fields and underline mistakes, in plain JavaScript
124
131
  | `formerIds` | Ids the app had before a rename |
125
132
  | `shares` | What the two apps share: `{ out, in }`, capabilities this app provides that it uses, and the other way round |
126
133
 
127
- Two calls go with buttons on the card:
134
+ Three calls go with buttons on the card:
128
135
 
136
+ - **Test:** `await link.test(appId)`. Connected, it's a round trip, `{ ok: true, ms, version, text }`, with `text` like "Stream Multiview 0.4.0 answered in 3 ms". Otherwise it tries straight away, from this side too, and says why it couldn't: `{ ok: false, reason, text }`. `reason` is one of:
137
+ - `not-running`: "Stream Multiview isn't open"
138
+ - `switched-off`: its Connect switch is off
139
+ - `off`: this app's is
140
+ - `pair-again`: with where to press it
141
+ - `incompatible`: which app to update
142
+ - `no-answer`, `not-started`, `unknown`
129
143
  - **Disconnect:** `link.disconnect(appId)`. Turn your own switch for that app off too.
130
144
  - **Pair again:** `link.pairAgain(appId)`.
131
145
 
@@ -177,7 +191,22 @@ Add it to `src/known-apps.js` with:
177
191
 
178
192
  Every app that updates Grapevine then shows it on its Connected apps page, with what the two would share. `link.peers()` gives that as `shares`, from this list until they meet and from the hello after.
179
193
 
180
- When it ships a release that uses Grapevine, record it: `npm run released -- <its id> <its version>`. That fixes the capability versions it uses (they can't change in place after that) and tells other apps which release to ask for.
194
+ When it ships a release that uses Grapevine, record it (next section).
195
+
196
+ ## Recording releases
197
+
198
+ Once an app ships a capability version, Grapevine records it. From then on that version can't change in place, and other apps can say which release to update to.
199
+
200
+ In the app's repo, at the start of every change:
201
+
202
+ ```sh
203
+ npx grapevine-check
204
+ ```
205
+
206
+ - **It reads** the `grapevine` field from each of the app's release tags (`v*`), and Grapevine's record from GitHub (the installed copy if it's offline, or with `--offline`).
207
+ - **It lists** any capability version a release shipped that isn't recorded yet, with the command to record it, e.g. `npm run released -- com.streamorchard.peardrops 0.1.6 drops.farming@1.0 players@1.0`.
208
+ - **That command runs in a Grapevine checkout** (its `app-shipped` skill), followed by a pull request and a patch release.
209
+ - **`--strict`** exits 1 while something is unrecorded, for CI.
181
210
 
182
211
  ## Renaming an app
183
212
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamorchard/grapevine",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Lets desktop apps on the same computer find each other and talk, privately: local sockets, JSON-RPC, pairing and versioned capabilities.",
5
5
  "license": "GPL-3.0-only",
6
6
  "author": "StreamOrchard",
@@ -32,7 +32,8 @@
32
32
  "./package.json": "./package.json"
33
33
  },
34
34
  "bin": {
35
- "grapevine-stand-in": "stand-in-peer.js"
35
+ "grapevine-stand-in": "stand-in-peer.js",
36
+ "grapevine-check": "scripts/check-shipped.js"
36
37
  },
37
38
  "files": [
38
39
  "src",
@@ -41,7 +42,8 @@
41
42
  "fixtures",
42
43
  "assets/apps",
43
44
  "stand-in-peer.js",
44
- "PROTOCOL.md"
45
+ "PROTOCOL.md",
46
+ "scripts/check-shipped.js"
45
47
  ],
46
48
  "engines": {
47
49
  "node": ">=22"
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ // Run in an app's repo (npx grapevine-check): lists the capability versions the app's releases
3
+ // have shipped that Grapevine hasn't recorded yet, with the command that records them (Grapevine's
4
+ // app-shipped skill). Until they're recorded, those versions can still be changed in place, and
5
+ // other apps can't say which release to update to.
6
+ //
7
+ // The app says what it speaks in its package.json, so every release tag has it:
8
+ // "grapevine": { "app": "com.streamorchard.peardrops", "provides": { "drops.farming": "1.0" }, "uses": { "players": "1.0" } }
9
+ //
10
+ // npx grapevine-check prints what needs recording (exit 0)
11
+ // npx grapevine-check --strict exits 1 when something does
12
+ // npx grapevine-check --offline uses the record in the installed package, not GitHub's
13
+ 'use strict';
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { execFileSync } = require('child_process');
18
+
19
+ const RECORD_URL = 'https://raw.githubusercontent.com/Stream-Orchard/grapevine/main/schemas/released.json';
20
+ const INSTALLED = path.join(__dirname, '..', 'schemas', 'released.json');
21
+ const VERSION = /^v?(\d+)\.(\d+)\.(\d+)$/;
22
+
23
+ /** "capability@major.minor" for each capability a package.json's grapevine field provides or uses. */
24
+ function declaredBy(grapevine) {
25
+ const out = new Set();
26
+ for (const list of [grapevine?.provides, grapevine?.uses]) {
27
+ for (const [cap, versions] of Object.entries(list ?? {})) for (const v of [].concat(versions)) out.add(`${cap}@${v}`);
28
+ }
29
+ return [...out].sort();
30
+ }
31
+
32
+ const byVersion = (a, b) => {
33
+ const [x, y] = [VERSION.exec(a.version), VERSION.exec(b.version)];
34
+ for (let i = 1; i <= 3; i++) if (Number(x[i]) !== Number(y[i])) return Number(x[i]) - Number(y[i]);
35
+ return 0;
36
+ };
37
+
38
+ /**
39
+ * What hasn't been recorded: for each capability version any release declared, the first release
40
+ * that did, unless the record already has it.
41
+ * @param {{version: string, grapevine: any}[]} releases every release tag, with its package.json's grapevine field (or none)
42
+ * @param {{firstRelease: Record<string, Record<string, string>>}} released Grapevine's record
43
+ * @returns {{app: string, version: string, capabilities: string[], command: string}[]} one per release to record, oldest first
44
+ */
45
+ function findUnrecorded(releases, released) {
46
+ const first = new Map(); // "app capability@version" -> the first release with it
47
+ for (const r of [...releases].filter((x) => VERSION.test(x.version)).sort(byVersion)) {
48
+ if (!r.grapevine?.app) continue;
49
+ for (const cap of declaredBy(r.grapevine)) {
50
+ const key = `${r.grapevine.app} ${cap}`;
51
+ if (!first.has(key)) first.set(key, { app: r.grapevine.app, version: r.version.replace(/^v/, ''), cap });
52
+ }
53
+ }
54
+ const todo = new Map(); // "app version" -> capabilities
55
+ for (const { app, version, cap } of first.values()) {
56
+ if (released?.firstRelease?.[app]?.[cap]) continue;
57
+ const key = `${app} ${version}`;
58
+ todo.set(key, { app, version, capabilities: [...(todo.get(key)?.capabilities ?? []), cap] });
59
+ }
60
+ return [...todo.values()]
61
+ .sort((a, b) => byVersion(a, b))
62
+ .map((t) => ({ ...t, command: `npm run released -- ${t.app} ${t.version} ${t.capabilities.join(' ')}` }));
63
+ }
64
+
65
+ const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
66
+
67
+ /** Every v* tag in the repo, with its package.json's grapevine field. */
68
+ function releasesIn(cwd) {
69
+ const tags = git(cwd, ['tag', '-l', 'v*'])
70
+ .split('\n')
71
+ .map((t) => t.trim())
72
+ .filter((t) => VERSION.test(t));
73
+ return tags.map((tag) => {
74
+ let grapevine = null;
75
+ try {
76
+ grapevine = JSON.parse(git(cwd, ['show', `${tag}:package.json`])).grapevine ?? null;
77
+ } catch {}
78
+ return { version: tag, grapevine };
79
+ });
80
+ }
81
+
82
+ /** Grapevine's record: GitHub's copy (the newest), or the installed package's when offline or it can't be fetched. */
83
+ async function loadRecord({ offline = false, fetchImpl = globalThis.fetch, installed = INSTALLED } = {}) {
84
+ if (!offline && fetchImpl) {
85
+ try {
86
+ const res = await fetchImpl(RECORD_URL, { signal: AbortSignal.timeout(5000) });
87
+ if (res.ok) return { record: await res.json(), source: 'GitHub' };
88
+ } catch {}
89
+ }
90
+ return { record: JSON.parse(fs.readFileSync(installed, 'utf8')), source: 'the installed package (it may be behind GitHub)' };
91
+ }
92
+
93
+ /**
94
+ * @param {string[]} argv
95
+ * @param {{cwd?: string, out?: (text: string) => void, fetchImpl?: typeof fetch}} [opts]
96
+ */
97
+ async function main(argv, { cwd = process.cwd(), out = (t) => void process.stdout.write(t), fetchImpl = globalThis.fetch } = {}) {
98
+ const strict = argv.includes('--strict');
99
+ const offline = argv.includes('--offline');
100
+ try {
101
+ if (!offline) git(cwd, ['fetch', '--tags', '--quiet']);
102
+ } catch {} // no remote, or offline: the tags already here
103
+ const releases = releasesIn(cwd);
104
+ if (!releases.some((r) => r.grapevine?.app)) {
105
+ out('No release of this app declares Grapevine capabilities yet (package.json "grapevine"). Nothing to record.\n');
106
+ return 0;
107
+ }
108
+ const { record, source } = await loadRecord({ offline, fetchImpl });
109
+ const todo = findUnrecorded(releases, record);
110
+ if (!todo.length) {
111
+ out(`Every capability version this app has released is recorded in Grapevine (checked against ${source}).\n`);
112
+ return 0;
113
+ }
114
+ out(`Grapevine hasn't recorded what these releases shipped (checked against ${source}):\n`);
115
+ for (const t of todo) out(` ${t.version}: ${t.capabilities.join(', ')}\n`);
116
+ out(`Record them in a Grapevine checkout (its app-shipped skill), then a pull request and a patch release:\n`);
117
+ for (const t of todo) out(` ${t.command}\n`);
118
+ return strict ? 1 : 0;
119
+ }
120
+
121
+ if (require.main === module) {
122
+ main(process.argv.slice(2)).then(
123
+ (code) => process.exit(code),
124
+ (err) => {
125
+ process.stderr.write(`grapevine-check: ${err.message}\n`);
126
+ process.exit(2);
127
+ },
128
+ );
129
+ }
130
+
131
+ module.exports = { declaredBy, findUnrecorded, releasesIn, loadRecord, main, RECORD_URL };
package/src/index.js CHANGED
@@ -418,6 +418,59 @@ class Link extends EventEmitter {
418
418
  this.changed();
419
419
  }
420
420
 
421
+ /**
422
+ * Test on a card: checks the link to that app now, and says how it went in words. Connected, a
423
+ * round trip and back; otherwise it tries straight away (from this side too, whichever app
424
+ * normally connects) and says why it couldn't.
425
+ * @returns {Promise<{ok: true, ms: number, version: string, text: string} | {ok: false, reason: string, text: string}>}
426
+ * reason: 'unknown' | 'off' | 'not-started' | 'not-running' | 'switched-off' | 'pair-again' | 'incompatible' | 'no-answer'
427
+ */
428
+ async test(appId, { timeout = 4000 } = {}) {
429
+ const id = this.currentId(appId);
430
+ const app = this.knownApps.find((a) => a.id === id);
431
+ if (!app) return { ok: false, reason: 'unknown', text: `${appId} isn't an app Grapevine knows` };
432
+ if (!this.allowedAny(this.idsOf(id))) return { ok: false, reason: 'off', text: `Switched off here: switch on Connect with ${app.name} first` };
433
+ if (!this.running) return { ok: false, reason: 'not-started', text: `${this.self.name} isn't listening for other apps yet` };
434
+ if (!this.conns.has(id)) {
435
+ if (!paths.listSlugs(this.place).includes(app.slug)) return { ok: false, reason: 'not-running', text: `${app.name} isn't open` };
436
+ this.retry.delete(id);
437
+ this.why.delete(id);
438
+ const trying = () => [...this.opening].some((c) => (c.expectedId ?? c.peerId) === id);
439
+ if (!trying()) this.connect(app);
440
+ await new Promise((resolve) => {
441
+ const done = () => {
442
+ clearTimeout(timer);
443
+ this.off('change', check);
444
+ resolve();
445
+ };
446
+ const check = () => (this.conns.has(id) || this.why.has(id) || !trying()) && done();
447
+ const timer = setTimeout(done, timeout);
448
+ this.on('change', check);
449
+ check();
450
+ });
451
+ }
452
+ const conn = this.conns.get(id);
453
+ if (!conn) {
454
+ const why = this.why.get(id);
455
+ if (why?.status === 'pair-again') {
456
+ return { ok: false, reason: 'pair-again', text: `The pairing doesn't match: press Pair again ${why.keyHeldBy === 'them' ? `in ${app.name}` : 'here'}` };
457
+ }
458
+ if (why?.status === 'incompatible') {
459
+ return { ok: false, reason: 'incompatible', text: incompatible(this.self, why.theirApp ?? app, hs.PROTOCOL, why.theirProtocol ?? hs.PROTOCOL).text };
460
+ }
461
+ if (why?.reason === 'switched-off') return { ok: false, reason: 'switched-off', text: `${app.name} is open, but its Connect switch is off` };
462
+ return { ok: false, reason: 'no-answer', text: `${app.name} is open, but didn't answer` };
463
+ }
464
+ const started = performance.now();
465
+ try {
466
+ await conn.rpc.request('link.ping', {}, { timeout });
467
+ } catch {
468
+ return { ok: false, reason: 'no-answer', text: `${app.name} didn't answer` };
469
+ }
470
+ const ms = Math.max(1, Math.round(performance.now() - started));
471
+ return { ok: true, ms, version: conn.peer.version, text: `${app.name} ${conn.peer.version} answered in ${ms} ms` };
472
+ }
473
+
421
474
  isConnected(appId) {
422
475
  return this.conns.has(this.currentId(appId));
423
476
  }
package/types/index.d.ts CHANGED
@@ -128,6 +128,15 @@ export interface Peer {
128
128
  keysEncrypted: boolean;
129
129
  }
130
130
 
131
+ export type TestResult =
132
+ | { ok: true; ms: number; version: string; text: string }
133
+ | {
134
+ ok: false;
135
+ reason: 'unknown' | 'off' | 'not-started' | 'not-running' | 'switched-off' | 'pair-again' | 'incompatible' | 'no-answer';
136
+ /** In words, for the card: "Stream Multiview isn't open" */
137
+ text: string;
138
+ };
139
+
131
140
  export interface CallContext {
132
141
  appId: string;
133
142
  app: AppInfo;
@@ -156,6 +165,8 @@ export declare class Link extends EventEmitter {
156
165
  /** Pair again on a card: forgets this app's old key. */
157
166
  pairAgain(appId: string): void;
158
167
  isConnected(appId: string): boolean;
168
+ /** Test on a card: checks the link now, and says how it went. Tries straight away when not connected. */
169
+ test(appId: string, opts?: { timeout?: number }): Promise<TestResult>;
159
170
  /** Its current id, given any id it has had: for moving your own settings across after another app is renamed. */
160
171
  currentId(appId: string): string;
161
172
  /** Every known app, for the Connected apps page. */