@streamorchard/grapevine 0.3.0 → 0.5.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
@@ -197,18 +197,20 @@ A capability is a named feature one app provides and others use, defined by a JS
197
197
 
198
198
  What a drops app is farming, with enough for another app to show it, in a top-bar widget say. Provided by Pear Drops.
199
199
 
200
- | Field | |
201
- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
202
- | `state` | `farming` (its player is watching a stream for drop credit), `waiting`, `paused`, `done`, `signed-out`, or one added later |
203
- | `account` | The account it farms for, or `null` |
204
- | `channel` | The channel it's watching while `farming` (with its `avatar`, optional), else `null` |
205
- | `stream` | Optional, while farming: `{ title?, viewers? }` |
206
- | `drop` | While farming, `{ campaign, category, progress }`, else `null`. Optional extras below |
207
- | `drop.image` | The category's picture |
208
- | `drop.endsAt` | When the drop ends |
209
- | `drop.rewards` | `{ claimed, total }` |
210
- | `drop.next` | The reward being worked towards: `{ name, image?, required, unlocksAt }`, where `required` is minutes and `unlocksAt` is when it should unlock at the current rate |
211
- | `drop.doneAt` | When every reward should be earned at the current rate, or `null` |
200
+ Event `drops.farming.changed`, and `drops.farming.get` answers with the same: the whole state, below. `drops.farming.show` is further down.
201
+
202
+ | Field | |
203
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
204
+ | `state` | `farming` (its player is watching a stream for drop credit), `waiting`, `paused`, `done`, `signed-out`, or one added later |
205
+ | `account` | The account it farms for, or `null` |
206
+ | `channel` | The channel it's watching while `farming` (with its `avatar`, optional), else `null` |
207
+ | `stream` | Optional, while farming: `{ title?, viewers? }` |
208
+ | `drop` | While farming, `{ campaign, category, progress }`, else `null`. Optional extras below |
209
+ | `drop.image` | The category's picture |
210
+ | `drop.endsAt` | When the drop ends |
211
+ | `drop.rewards` | `{ claimed, total, items? }`. `items` lists every reward in the order they unlock (up to 20): `{ name, image?, required, state, unlocksAt? }`, where `state` is `claimed`, `ready` (unlocked, waiting to be claimed), `locked`, or one added later, and a locked one's `unlocksAt` is when it should unlock at the current rate |
212
+ | `drop.next` | The reward being worked towards: `{ name, image?, required, unlocksAt }`, where `required` is minutes and `unlocksAt` is when it should unlock at the current rate |
213
+ | `drop.doneAt` | When every reward should be earned at the current rate, or `null` |
212
214
 
213
215
  `drops.farming.show {}` asks the drops app to bring its window to the front, for a click on that widget. It replies `{ shown }`.
214
216
 
@@ -216,6 +218,8 @@ What a drops app is farming, with enough for another app to show it, in a top-ba
216
218
 
217
219
  An app's stream players, by site. Provided by Stream Multiview.
218
220
 
221
+ Event `players.changed`, and `players.get` answers with the same: the whole state, below.
222
+
219
223
  | Field | |
220
224
  | ----------- | --------------------------------------------------------------------------------------------------------------- |
221
225
  | `platforms` | An object keyed by `Platform`. Each site has `{ signedIn, mode, account, open }` |
@@ -259,7 +263,7 @@ After the hello, each app tells its user one of:
259
263
  | **Works, with a feature missing** | A capability one app wants has no major in common. It names the app with the older versions and, if known, the release to update to: "update Pear Drops to 0.1.7 or later" |
260
264
  | **Can't connect** | No protocol version in common. Again, it names the app to update |
261
265
 
262
- The releases come from the known-apps list (`src/known-apps.js`): `firstRelease` maps `"<capability>@<major>.<minor>"` and `"protocol@<n>"` to the first release of that app that had it.
266
+ The releases come from `schemas/released.json`, recorded as each app ships (`npm run released`), through the known-apps list's `firstRelease`: `"<capability>@<major>.<minor>"` and `"protocol@<n>"` mapped to the first release of that app that had them.
263
267
 
264
268
  ## Renaming an app
265
269
 
package/README.md CHANGED
@@ -66,7 +66,15 @@ link.publish('drops.farming.changed', {
66
66
  progress: { done: 48, total: 120, unit: 'minutes' },
67
67
  image: 'https://files.kick.com/…/neon-rush.webp',
68
68
  endsAt: '2026-10-21T18:00:00Z',
69
- rewards: { claimed: 1, total: 3 },
69
+ rewards: {
70
+ claimed: 1,
71
+ total: 3,
72
+ items: [
73
+ { name: 'Pear Badge', required: 30, state: 'claimed' },
74
+ { name: 'Hype Rocket Emote', required: 60, state: 'locked', unlocksAt: '2026-10-18T09:24:00Z' },
75
+ { name: 'Neon Crown', required: 120, state: 'locked', unlocksAt: '2026-10-18T10:24:00Z' },
76
+ ],
77
+ },
70
78
  next: { name: 'Hype Rocket Emote', required: 60, unlocksAt: '2026-10-18T09:24:00Z' },
71
79
  doneAt: '2026-10-18T10:24:00Z',
72
80
  },
@@ -103,17 +111,18 @@ Editors suggest every event's fields and underline mistakes, in plain JavaScript
103
111
 
104
112
  `link.peers()` lists every known app with what its card needs, and `link.on('change', …)` says when to draw it again.
105
113
 
106
- | Field | |
107
- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
108
- | `name`, `summary`, `icon`, `download` | The app's logo (bundled, so it shows even when it isn't installed), one line about it, and where to get it |
109
- | `installed` | Whether its data folder exists (with `appDataDir`) |
110
- | `enabled` | Your switch for it |
111
- | `status` | `off`, `waiting`, `connected`, `pair-again` or `incompatible` |
112
- | `reason` | Why it's waiting: `switched-off` (its own switch is off), `disconnected-there` |
113
- | `keyHeldBy` | For `pair-again`: `us` means show **Pair again** here, `them` means it's pressed in the other app |
114
- | `version`, `report.text` | While connected: its version, and "Works" or what's missing ("update Pear Drops to 0.1.7 or later") |
115
- | `pairedAt`, `keysEncrypted` | When they paired, and whether keys are stored encrypted |
116
- | `formerIds` | Ids the app had before a rename |
114
+ | Field | |
115
+ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
116
+ | `name`, `summary`, `icon`, `download` | The app's logo (bundled, so it shows even when it isn't installed), one line about it, and where to get it |
117
+ | `installed` | Whether its data folder exists (with `appDataDir`) |
118
+ | `enabled` | Your switch for it |
119
+ | `status` | `off`, `waiting`, `connected`, `pair-again` or `incompatible` |
120
+ | `reason` | Why it's waiting: `switched-off` (its own switch is off), `disconnected-there` |
121
+ | `keyHeldBy` | For `pair-again`: `us` means show **Pair again** here, `them` means it's pressed in the other app |
122
+ | `version`, `report.text` | While connected: its version, and "Works" or what's missing ("update Pear Drops to 0.1.7 or later") |
123
+ | `pairedAt`, `keysEncrypted` | When they paired, and whether keys are stored encrypted |
124
+ | `formerIds` | Ids the app had before a rename |
125
+ | `shares` | What the two apps share: `{ out, in }`, capabilities this app provides that it uses, and the other way round |
117
126
 
118
127
  Two calls go with buttons on the card:
119
128
 
@@ -144,10 +153,10 @@ Example messages for every capability version, good and bad, are in `@streamorch
144
153
 
145
154
  ## Capabilities
146
155
 
147
- | Capability | Provided by | What it says |
148
- | ------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
149
- | `drops.farming` 1.0 | Pear Drops | Whether it's farming: the channel, stream and drop, with pictures, the next reward and when it unlocks, enough for a widget. Also `show`, to bring Pear Drops to the front |
150
- | `players` 1.0 | Stream Multiview | Its players by site: signed in or not, and which channels are open |
156
+ | Capability | Provided by | What it says |
157
+ | ------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
158
+ | `drops.farming` 1.0 | Pear Drops | Whether it's farming: the channel, stream and drop, with pictures and every reward's state and when it unlocks, enough for a widget. Also `show`, to bring Pear Drops to the front |
159
+ | `players` 1.0 | Stream Multiview | Its players by site: signed in or not, and which channels are open |
151
160
 
152
161
  To add one:
153
162
 
@@ -158,7 +167,17 @@ To change one, follow [the version rules](PROTOCOL.md#versions): additions are a
158
167
 
159
168
  ## Adding an app
160
169
 
161
- Add it to `src/known-apps.js`, with its id, slug, summary, a 256×256 logo in `assets/apps/`, its download page and its data folder's name. Every app that updates Grapevine then shows it on its Connected apps page.
170
+ Add it to `src/known-apps.js` with:
171
+
172
+ - its id and slug
173
+ - a one-line summary
174
+ - a 256×256 logo in `assets/apps/`
175
+ - its download page and its data folder's name
176
+ - the capabilities it `provides` and `uses`
177
+
178
+ 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
+
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.
162
181
 
163
182
  ## Renaming an app
164
183
 
@@ -175,6 +194,7 @@ npm run lint # ESLint and Prettier
175
194
  npm run format # fix what lint can
176
195
  npm run types # types/capabilities.d.ts from the schemas
177
196
  npm run check # the types are up to date, and tsc finds no type errors
197
+ npm run released -- <app id> <version> # an app shipped a release using Grapevine (see Adding an app)
178
198
  ```
179
199
 
180
200
  CI runs the tests on Windows, macOS and Linux, and lint, types and coverage on Linux.
@@ -0,0 +1,16 @@
1
+ {
2
+ "event": "drops.farming.changed",
3
+ "params": {
4
+ "seq": 1,
5
+ "at": "2026-10-18T09:12:04Z",
6
+ "state": "farming",
7
+ "account": null,
8
+ "channel": null,
9
+ "drop": {
10
+ "campaign": "c",
11
+ "category": "c",
12
+ "progress": { "done": 1, "total": 2, "unit": "minutes" },
13
+ "rewards": { "claimed": 0, "total": 1, "items": [{ "name": "Pear Badge", "required": 30 }] }
14
+ }
15
+ }
16
+ }
@@ -14,7 +14,21 @@
14
14
  "image": "https://files.kick.com/images/subcategories/1/banner/neon-rush.webp",
15
15
  "endsAt": "2026-10-21T18:00:00Z",
16
16
  "doneAt": "2026-10-18T10:24:00Z",
17
- "rewards": { "claimed": 1, "total": 3 },
17
+ "rewards": {
18
+ "claimed": 1,
19
+ "total": 3,
20
+ "items": [
21
+ { "name": "Pear Badge", "image": "https://files.kick.com/drops/reward/pear-badge.png", "required": 30, "state": "claimed" },
22
+ {
23
+ "name": "Hype Rocket Emote",
24
+ "image": "https://files.kick.com/drops/reward/hype-rocket.png",
25
+ "required": 60,
26
+ "state": "locked",
27
+ "unlocksAt": "2026-10-18T09:24:00Z"
28
+ },
29
+ { "name": "Neon Crown", "required": 120, "state": "locked", "unlocksAt": "2026-10-18T10:24:00Z" }
30
+ ]
31
+ },
18
32
  "next": {
19
33
  "name": "Hype Rocket Emote",
20
34
  "image": "https://files.kick.com/drops/reward/hype-rocket.png",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamorchard/grapevine",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",
@@ -52,7 +52,8 @@
52
52
  "lint": "eslint . && prettier --check .",
53
53
  "format": "prettier --write . && eslint --fix .",
54
54
  "types": "node scripts/types.js",
55
- "check": "node scripts/types.js --check && tsc -p jsconfig.json"
55
+ "check": "node scripts/types.js --check && tsc -p jsconfig.json",
56
+ "released": "node scripts/released.js"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@eslint/js": "^10.0.1",
@@ -121,6 +121,14 @@
121
121
  "total": {
122
122
  "type": "integer",
123
123
  "minimum": 0
124
+ },
125
+ "items": {
126
+ "type": "array",
127
+ "maxItems": 20,
128
+ "items": {
129
+ "$ref": "#Reward"
130
+ },
131
+ "description": "Every reward of the drop, in the order they unlock"
124
132
  }
125
133
  }
126
134
  },
@@ -190,6 +198,42 @@
190
198
  "description": "The stream being watched, while farming: its title and viewers"
191
199
  }
192
200
  }
201
+ },
202
+ "Reward": {
203
+ "type": "object",
204
+ "required": ["name", "required", "state"],
205
+ "properties": {
206
+ "name": {
207
+ "type": "string",
208
+ "maxLength": 200
209
+ },
210
+ "image": {
211
+ "$ref": "common:Image"
212
+ },
213
+ "required": {
214
+ "type": "number",
215
+ "minimum": 0,
216
+ "description": "Minutes of watching it unlocks at"
217
+ },
218
+ "state": {
219
+ "type": "string",
220
+ "minLength": 1,
221
+ "maxLength": 32,
222
+ "x-open": ["claimed", "ready", "locked"],
223
+ "description": "ready: unlocked, waiting to be claimed"
224
+ },
225
+ "unlocksAt": {
226
+ "anyOf": [
227
+ {
228
+ "$ref": "common:Timestamp"
229
+ },
230
+ {
231
+ "type": "null"
232
+ }
233
+ ],
234
+ "description": "For a locked one: when it should unlock, at the current rate"
235
+ }
236
+ }
193
237
  }
194
238
  }
195
239
  }
@@ -0,0 +1,5 @@
1
+ {
2
+ "description": "Capability versions that have shipped in an app's release, and which release of each app first had them. Written by `npm run released -- <app id> <version>`; never by hand. A version recorded here can't change in place: test/released.test.js fails until the change goes in a new minor.",
3
+ "schemas": {},
4
+ "firstRelease": {}
5
+ }
package/src/index.js CHANGED
@@ -415,6 +415,20 @@ class Link extends EventEmitter {
415
415
  return this.conns.has(this.currentId(appId));
416
416
  }
417
417
 
418
+ /**
419
+ * What this app and another share: `out`, capabilities this app provides that it uses; `in`,
420
+ * ones it provides that this app uses. Agreed in the hello while connected; from the known-apps
421
+ * list otherwise, so a card can say what linking would do before the two have met.
422
+ */
423
+ sharesWith(app, conn) {
424
+ if (conn) return { out: [...conn.agreed.outgoing.keys()].sort(), in: [...conn.agreed.incoming.keys()].sort() };
425
+ const mine = (list) => (cap) => cap in list;
426
+ return {
427
+ out: (app.uses ?? []).filter(mine(this.mine.provides)).sort(),
428
+ in: (app.provides ?? []).filter(mine(this.mine.uses)).sort(),
429
+ };
430
+ }
431
+
418
432
  /**
419
433
  * Every known app, for the Connected apps page:
420
434
  * status 'off' (its switch is off) | 'waiting' | 'connected' | 'pair-again' | 'incompatible'.
@@ -445,6 +459,7 @@ class Link extends EventEmitter {
445
459
  keyHeldBy: status === 'pair-again' ? (why?.keyHeldBy ?? null) : null,
446
460
  version: conn?.peer.version ?? null,
447
461
  report,
462
+ shares: this.sharesWith(app, conn),
448
463
  pairedAt: paired?.pairedAt ?? null,
449
464
  keysEncrypted: !!this.keys.encrypted,
450
465
  };
package/src/known-apps.js CHANGED
@@ -7,6 +7,8 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
 
9
9
  const ICONS = path.join(__dirname, '..', 'assets', 'apps');
10
+ // Which release of each app first had each capability version: written by `npm run released`.
11
+ const RELEASED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'schemas', 'released.json'), 'utf8')).firstRelease;
10
12
 
11
13
  /**
12
14
  * @typedef {object} KnownApp
@@ -19,6 +21,8 @@ const ICONS = path.join(__dirname, '..', 'assets', 'apps');
19
21
  * @property {string} homepage
20
22
  * @property {string} download its latest release
21
23
  * @property {string} dataFolder its folder in the OS's app data folder: no folder means not installed
24
+ * @property {string[]} provides the capabilities it provides (their names), so another app can say what the two share before they've met
25
+ * @property {string[]} uses the capabilities it uses
22
26
  * @property {Record<string, string>} firstRelease "capability@major.minor" (or "protocol@N") -> the app version that first had it
23
27
  */
24
28
 
@@ -34,7 +38,9 @@ const KNOWN_APPS = [
34
38
  homepage: 'https://github.com/Stream-Orchard/pear-drops',
35
39
  download: 'https://github.com/Stream-Orchard/pear-drops/releases/latest',
36
40
  dataFolder: 'Pear Drops',
37
- firstRelease: {},
41
+ provides: ['drops.farming'],
42
+ uses: ['players'],
43
+ firstRelease: { ...RELEASED['com.streamorchard.peardrops'] },
38
44
  },
39
45
  {
40
46
  id: 'com.glowingpear.streammultiview',
@@ -46,7 +52,9 @@ const KNOWN_APPS = [
46
52
  homepage: 'https://github.com/GlowingPear/stream-window-multiview',
47
53
  download: 'https://github.com/GlowingPear/stream-window-multiview/releases/latest',
48
54
  dataFolder: 'Stream Multiview',
49
- firstRelease: {},
55
+ provides: ['players'],
56
+ uses: ['drops.farming'],
57
+ firstRelease: { ...RELEASED['com.glowingpear.streammultiview'] },
50
58
  },
51
59
  ];
52
60
 
@@ -56,6 +56,8 @@ export type DropsFarmingV1State = {
56
56
  rewards?: {
57
57
  claimed: number;
58
58
  total: number;
59
+ /** Every reward of the drop, in the order they unlock */
60
+ items?: Array<DropsFarmingV1Reward>;
59
61
  };
60
62
  /** The reward being worked towards */
61
63
  next?: {
@@ -74,6 +76,17 @@ export type DropsFarmingV1State = {
74
76
  } | null;
75
77
  };
76
78
 
79
+ export type DropsFarmingV1Reward = {
80
+ name: string;
81
+ image?: Image;
82
+ /** Minutes of watching it unlocks at */
83
+ required: number;
84
+ /** ready: unlocked, waiting to be claimed */
85
+ state: "claimed" | "ready" | "locked" | (string & {});
86
+ /** For a locked one: when it should unlock, at the current rate */
87
+ unlocksAt?: Timestamp | null;
88
+ };
89
+
77
90
  // players 1.0: An app's stream players, by site: whether they're signed in, and which channels are open. Provided by Stream Multiview.
78
91
  export type PlayersV1State = {
79
92
  seq: number;
package/types/index.d.ts CHANGED
@@ -64,6 +64,9 @@ export interface KnownApp {
64
64
  download: string;
65
65
  /** Its folder in the OS's app data folder */
66
66
  dataFolder: string;
67
+ /** The capabilities it provides and uses (names), so a card can say what linking would do */
68
+ provides: string[];
69
+ uses: string[];
67
70
  /** "capability@major.minor" (or "protocol@N") -> the app version that first had it */
68
71
  firstRelease: Record<string, string>;
69
72
  }
@@ -119,6 +122,8 @@ export interface Peer {
119
122
  /** Its version, while connected */
120
123
  version: string | null;
121
124
  report: Report | null;
125
+ /** What the two apps share: `out`, capabilities this app provides that it uses; `in`, ones it provides that this app uses */
126
+ shares: { out: string[]; in: string[] };
122
127
  pairedAt: string | null;
123
128
  keysEncrypted: boolean;
124
129
  }