@streamorchard/grapevine 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,266 @@
1
+ # The Grapevine protocol
2
+
3
+ Version 1. How desktop apps on the same computer find each other, pair, and talk. The library in `src/` implements all of it; another implementation that follows this document can talk to it.
4
+
5
+ - [Finding apps](#finding-apps)
6
+ - [Messages](#messages)
7
+ - [Connecting and pairing](#connecting-and-pairing)
8
+ - [Once connected](#once-connected)
9
+ - [Limits](#limits)
10
+ - [Errors](#errors)
11
+ - [Capabilities](#capabilities)
12
+ - [Versions](#versions)
13
+ - [Compatibility](#compatibility)
14
+ - [Security](#security)
15
+
16
+ ## Finding apps
17
+
18
+ Every app has a **slug** (`a-z`, `0-9` and `-`, up to 40 characters, starting with a letter or digit) and listens on a socket named after it, in one folder shared by every Grapevine app of the same user.
19
+
20
+ | OS | Shared folder | Socket |
21
+ | ------- | ---------------------------------------------------------------------- | ------------------------------------------------ |
22
+ | macOS | `~/Library/Application Support/Grapevine` | `<folder>/<slug>.sock` |
23
+ | Linux | `$XDG_RUNTIME_DIR/grapevine`, or `~/.local/state/grapevine` without it | `<folder>/<slug>.sock` |
24
+ | Windows | `%APPDATA%\Grapevine` | the named pipe `\\.\pipe\grapevine-<tag>-<slug>` |
25
+
26
+ - `GRAPEVINE_DIR` replaces the shared folder (tests, developer copies).
27
+ - `<tag>` is the first 8 hex digits of the SHA-256 of the shared folder's path in lowercase. Pipes are visible to every user of a Windows computer; the tag keeps each user's (and each test folder's) apart.
28
+ - If `<folder>/<a 40-character slug>.sock` would be over 100 bytes (macOS allows 104 for a socket path), the sockets go in `/tmp/grapevine-<uid>-<tag>/` instead.
29
+ - The folder is created with mode `0700`, and an app refuses one owned by another user. Sockets are set to `0600`.
30
+ - An app finds the others by listing the folder (on Windows, the pipes with its `grapevine-<tag>-` prefix). It watches the folder for changes and lists it again every 5 seconds as well.
31
+ - 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.
32
+ - An app only listens, and only looks for others, while at least one of its Connected apps switches is on.
33
+
34
+ **Who connects.** Of two apps, the one whose id 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 id sorts first and close the other with `link.bye { reason: "duplicate" }`.
35
+
36
+ ## Messages
37
+
38
+ [JSON-RPC 2.0](https://www.jsonrpc.org/specification): one UTF-8 JSON object per line, ending in `\n`.
39
+
40
+ ```text
41
+ {"jsonrpc":"2.0","id":7,"method":"drops.farming.get","params":{}} request
42
+ {"jsonrpc":"2.0","id":7,"result":{ … }} its reply
43
+ {"jsonrpc":"2.0","id":7,"error":{"code":-32002,"message":"…","data":{ … }}} an error reply
44
+ {"jsonrpc":"2.0","method":"drops.farming.changed","params":{ … }} an event (no id)
45
+ ```
46
+
47
+ - `params` is always an object. Batches (an array of messages) aren't used, and get `-32600`.
48
+ - Names are `<capability>.<name>`. `link.*` is reserved for the link itself.
49
+ - Unknown fields anywhere are ignored, never an error.
50
+
51
+ ## Connecting and pairing
52
+
53
+ The connecting app sends `link.hello` first:
54
+
55
+ ```json
56
+ {
57
+ "jsonrpc": "2.0",
58
+ "id": 1,
59
+ "method": "link.hello",
60
+ "params": {
61
+ "protocol": { "min": 1, "max": 1 },
62
+ "app": {
63
+ "id": "com.glowingpear.streammultiview",
64
+ "slug": "stream-multiview",
65
+ "name": "Stream Multiview",
66
+ "version": "0.4.0",
67
+ "summary": "Watch Kick, Twitch and YouTube streams…",
68
+ "icon": "data:image/png;base64,…",
69
+ "homepage": "https://github.com/GlowingPear/stream-window-multiview",
70
+ "download": "https://github.com/GlowingPear/stream-window-multiview/releases/latest"
71
+ },
72
+ "capabilities": { "drops.farming": { "uses": ["1.0"] }, "players": { "provides": ["1.0"] } },
73
+ "auth": { "nonce": "<32 random bytes, base64>", "pair": false }
74
+ }
75
+ }
76
+ ```
77
+
78
+ | Field | |
79
+ | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
80
+ | `protocol` | The range of protocol versions it speaks |
81
+ | `app.id` | Its install id: lowercase letters, digits, dots and dashes, like `com.example.app` (3–100 characters) |
82
+ | `app.slug`, `app.name`, `app.version` | Required. `summary` (up to 300 characters), `icon` (a PNG data URL, up to 256 KB), `homepage` and `download` (`https` addresses) are optional |
83
+ | `capabilities` | For each capability, the versions it `provides` and `uses`, as `"major.minor"` |
84
+ | `auth.nonce` | 32 random bytes, base64, new for every connection |
85
+ | `auth.pair` | `true` when it has no key for this app yet |
86
+
87
+ The listening app checks it, in this order:
88
+
89
+ 1. A hello that doesn't fit the table gets `-32602` with `data.problems` (a list of strings), and the connection closes.
90
+ 2. No protocol version in both ranges: `-32003` with `data.protocol` (its own range), and close.
91
+ 3. Its switch for that app is off: `-32005`, and close.
92
+ 4. **Pairing.**
93
+ - `pair: true` and it has no key for that app: it makes a new 32-byte random key `K`.
94
+ - `pair: true` but it still has a key: `-32001` with `data: { reason: "pair-again", keyHeldBy: "listener" }`, and close. It accepts a new pairing only once its user presses **Pair again**, which deletes its key.
95
+ - `pair: false` and it has no key: `-32001` with `data: { reason: "pair-again", keyHeldBy: "connector" }`, and close. The connecting app's user presses **Pair again** there.
96
+ - Otherwise `K` is the key it has.
97
+ 5. It replies:
98
+
99
+ ```json
100
+ {"jsonrpc":"2.0","id":1,"result":{
101
+ "protocol":1,
102
+ "app":{ … the listening app … },
103
+ "capabilities":{ … its own … },
104
+ "agreed":{"drops.farming":"1","players":"1"},
105
+ "auth":{"proof":"<HMAC-SHA256(K, 'server:' + client nonce), base64>","nonce":"<32 random bytes>","key":"<K, only when pairing>"}}}
106
+ ```
107
+
108
+ The connecting app checks the reply:
109
+
110
+ - its `app.id` is the app it meant to connect to;
111
+ - it speaks the `protocol` chosen;
112
+ - the `proof` matches its key, or the `key` it was just given when pairing.
113
+
114
+ If any check fails, it closes the connection. Then it proves it knows the key:
115
+
116
+ ```json
117
+ { "jsonrpc": "2.0", "id": 2, "method": "link.auth", "params": { "proof": "<HMAC-SHA256(K, 'client:' + listener nonce), base64>" } }
118
+ ```
119
+
120
+ The listening app replies `{}` if the proof matches, or `-32001` with `reason: "pair-again"` and closes if it doesn't. Only now does either app keep a new key. The connection is ready.
121
+
122
+ - **Deadline:** `link.hello` and `link.auth` must both be done within 3 seconds of connecting.
123
+ - **Before ready:** any other request gets `-32001`.
124
+ - **Comparing proofs:** in constant time.
125
+ - **The roles in the proof** (`server:`, `client:`) mean a proof can't be sent back to the app that made it.
126
+
127
+ ## Once connected
128
+
129
+ | Message | | |
130
+ | ------------------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
+ | `link.subscribe { events: [names] }` | request | The events it wants. The reply is `{ subscribed: [names] }`: the ones the other app provides, of capabilities agreed on this connection. The latest of each comes straight after the reply. Each app subscribes to every event of every capability it uses as soon as the connection is ready. |
132
+ | `link.ping {}` | request | Sent every 20 seconds by the connecting app. No reply within 10 seconds closes the connection. The listening app closes a connection it has heard nothing on for 60 seconds. |
133
+ | `link.unpair {}` | request | Disconnect: the app receiving it deletes its key, replies `{}`, and closes. The app sending it deletes its own and switches its own switch off, so the two don't pair straight back. |
134
+ | `link.bye { reason }` | event | Closing on purpose: quitting, switched off, a duplicate. `reason` is a sentence the other app may show. |
135
+
136
+ When an app quits or crashes, its socket closes and the other app knows at once.
137
+
138
+ ## Limits
139
+
140
+ | | |
141
+ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
142
+ | A line | 1 MiB. A longer one closes the connection |
143
+ | Messages | 50 a second. Beyond that, a request gets `-32004` and other messages are dropped. Going over three times within a minute closes the connection |
144
+ | A reply | Expected within 10 seconds, unless the method says otherwise |
145
+
146
+ ## Errors
147
+
148
+ The standard JSON-RPC codes, and Grapevine's:
149
+
150
+ | Code | Meaning |
151
+ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
152
+ | `-32700` | Not JSON |
153
+ | `-32600` | Not a valid request (not an object, no method, a bad id) |
154
+ | `-32601` | No such method, or the app doesn't answer it |
155
+ | `-32602` | Invalid params (`data.problems` lists what's wrong) |
156
+ | `-32603` | The app failed to answer |
157
+ | `-32001` | Not paired, or the proof was wrong. `data.reason` is `"pair-again"` when one app has a key the other doesn't, and `data.keyHeldBy` says which: `"listener"` or `"connector"` |
158
+ | `-32002` | The capability wasn't agreed on this connection |
159
+ | `-32003` | No version both apps support (`data.protocol` is the sender's range) |
160
+ | `-32004` | Too many messages |
161
+ | `-32005` | Switched off in the app's Connected apps |
162
+
163
+ ## Capabilities
164
+
165
+ A capability is a named feature one app provides and others use, defined by a JSON Schema in `schemas/<capability>/<major>.json`:
166
+
167
+ ```json
168
+ { "capability": "drops.farming", "major": 1, "minor": 0, "description": "…",
169
+ "events": { "drops.farming.changed": { "$ref": "#State" } },
170
+ "methods": { "drops.farming.get": { "params": { … }, "result": { "$ref": "#State" } } },
171
+ "defs": { "State": { … } } }
172
+ ```
173
+
174
+ `"$ref": "#Name"` points at the file's own `defs`, and `"$ref": "common:Name"` at the shared types in `schemas/common.json`:
175
+
176
+ | Type | |
177
+ | ----------- | ----------------------------------------------------------------- |
178
+ | `Timestamp` | ISO 8601, in UTC |
179
+ | `Platform` | A streaming site: `kick`, `twitch`, `youtube`, or one added later |
180
+ | `Channel` | `{ platform, slug, name? }` |
181
+ | `Account` | `{ platform, user }`: the username only, never a token or cookie |
182
+ | `Progress` | `{ done, total, unit }`, where `unit` is `minutes` for now |
183
+
184
+ **Conventions.**
185
+
186
+ - **Events carry the whole state, not a change,** so a missed one is corrected by the next.
187
+ - **Every event has `seq`,** which counts up for each event name (from 1), **and `at`.**
188
+ - **A capability with a `<capability>.changed` event also answers `<capability>.get`** with the same shape (`seq` is the last one sent, or 0).
189
+ - **Lists marked `x-open` may gain values:** an app treats a value it doesn't know as the nearest safe meaning (for `drops.farming`'s `state`, "not farming").
190
+
191
+ ### `drops.farming` 1.0
192
+
193
+ What a drops app is farming. Provided by Pear Drops.
194
+
195
+ | Field | |
196
+ | --------- | -------------------------------------------------------------------------------------------------------------------------- |
197
+ | `state` | `farming` (its player is watching a stream for drop credit), `waiting`, `paused`, `done`, `signed-out`, or one added later |
198
+ | `account` | The account it farms for, or `null` |
199
+ | `channel` | The stream it's watching while `farming`, else `null` |
200
+ | `drop` | `{ campaign, category, progress }` while farming, else `null` |
201
+
202
+ ### `players` 1.0
203
+
204
+ An app's stream players, by site. Provided by Stream Multiview.
205
+
206
+ | Field | |
207
+ | ----------- | --------------------------------------------------------------------------------------------------------------- |
208
+ | `platforms` | An object keyed by `Platform`. Each site has `{ signedIn, mode, account, open }` |
209
+ | `signedIn` | Whether its players play as the account right now |
210
+ | `mode` | `signed-in`, `signed-out`, `auto` (signed in except while a connected drops app is farming), or one added later |
211
+ | `open` | The `Channel`s with a player open |
212
+
213
+ ## Versions
214
+
215
+ **Two kinds of version.**
216
+
217
+ - **The protocol** (a whole number) covers this document's wire format, the hello and `link.*`. It changes only if those change in a way an older app can't handle.
218
+ - **Each capability** has `major.minor`.
219
+
220
+ **A minor version only adds:**
221
+
222
+ - optional fields
223
+ - new events or methods
224
+ - values in `x-open` lists
225
+
226
+ An app that knows `1.0` works with `1.3` unchanged. An app mustn't depend on a field the other app's minor version doesn't have.
227
+
228
+ **A major version is a break:**
229
+
230
+ - a field removed or renamed
231
+ - a type or meaning changed
232
+ - an optional field made required
233
+ - a value added to a closed list
234
+
235
+ **Apps agree a major per capability in the hello:** the highest major both list. A provider keeps offering the old major next to the new one for at least two of its releases, so apps can update in either order. A version may be listed as deprecated in the provider's release notes before it's dropped.
236
+
237
+ **Messages carry no version.** Everything on a connection uses the majors agreed in its hello.
238
+
239
+ ## Compatibility
240
+
241
+ After the hello, each app tells its user one of:
242
+
243
+ | | |
244
+ | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
245
+ | **Works** | Every capability it uses was agreed |
246
+ | **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" |
247
+ | **Can't connect** | No protocol version in common. Again, it names the app to update |
248
+
249
+ 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.
250
+
251
+ ## Security
252
+
253
+ **Who can connect:**
254
+
255
+ - **Only programs running as the same user.** The folder and sockets are private to them. Windows pipes are named per user, but any user of the computer can open one; pairing covers that.
256
+ - **Never a web page.** Browsers can't open local sockets or named pipes at all.
257
+ - **Never another computer.** Nothing listens on the network.
258
+
259
+ **What pairing does:**
260
+
261
+ - **Proves it's the same app as last time.** After the first pairing, both apps prove they hold the same random key on every connection, without sending it.
262
+ - **The first pairing trusts whichever app answers to that id.** It happens only after the user has switched the link on in both apps.
263
+ - **Replacing a pairing takes a user's press of Pair again.**
264
+ - **What it can't stop:** a program already running as the user could read the keys, as it could read the apps' own logins.
265
+
266
+ **What goes across:** capabilities describe what an app is doing (farming a channel, players signed in or out). They never carry logins, tokens or cookies.
package/README.md CHANGED
@@ -1,3 +1,162 @@
1
- # Temporary Holding Version
1
+ <p align="center"><img src=".github/assets/hero.png" alt="Grapevine: lets your desktop apps find each other and talk, privately, right on your computer" width="100%"></p>
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # Grapevine
4
+
5
+ Lets desktop apps on the same computer find each other and talk to each other directly. Nothing goes online, and there's no account or server.
6
+
7
+ It's how [**Pear Drops**](https://github.com/Stream-Orchard/pear-drops) tells [**Stream Multiview**](https://github.com/GlowingPear/stream-window-multiview) when it's farming drops, so multiview's Kick players sign out and leave the drop credit to Pear Drops. Any app that installs it can join in.
8
+
9
+ [![Tests](https://github.com/Stream-Orchard/grapevine/actions/workflows/test.yml/badge.svg)](https://github.com/Stream-Orchard/grapevine/actions/workflows/test.yml) [![npm](https://img.shields.io/npm/v/@streamorchard/grapevine?color=4cf56a)](https://www.npmjs.com/package/@streamorchard/grapevine) ![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey) ![License](https://img.shields.io/badge/license-GPL--3.0-blue)
10
+
11
+ ---
12
+
13
+ ## What it does
14
+
15
+ - **Finds apps.** Each app listens on a socket in one private folder (a named pipe on Windows), so apps see each other as they start and stop.
16
+ - **Connects safely.** Two apps pair the first time you switch the link on in both, and after that prove it's them every time. Web pages, other computers and unpaired programs can't connect.
17
+ - **Talks.** Apps send each other events ("farming PixelPear now") and answer requests, as [JSON-RPC 2.0](https://www.jsonrpc.org/specification). Every message is checked against its schema.
18
+ - **Copes with updates.** Each feature is a versioned _capability_. Apps agree the newest version both support, and when one is out of date, they say which one to update.
19
+ - **Recovers.** If an app quits or crashes, the other knows at once, and they reconnect when it's back.
20
+
21
+ It has no dependencies, needs nothing from Electron, and runs on Windows, macOS and Linux with Node 22 or later. [`PROTOCOL.md`](PROTOCOL.md) has the whole protocol.
22
+
23
+ ## Install
24
+
25
+ ```sh
26
+ npm install @streamorchard/grapevine
27
+ ```
28
+
29
+ ## Use it
30
+
31
+ ```js
32
+ const { app, safeStorage } = require('electron');
33
+ const path = require('path');
34
+ const { createLink, fileKeyStore } = require('@streamorchard/grapevine');
35
+
36
+ const link = createLink({
37
+ app: {
38
+ id: 'com.streamorchard.peardrops', // your app's install id
39
+ slug: 'pear-drops', // its socket name
40
+ name: 'Pear Drops',
41
+ version: app.getVersion(),
42
+ icon: path.join(__dirname, 'icon.png'),
43
+ },
44
+ provides: { 'drops.farming': '1.0' }, // what it tells other apps
45
+ uses: { players: '1.0' }, // what it listens to
46
+ allowed: (appId) => settings.connectedApps[appId]?.enabled === true, // your Connected apps switches
47
+ keys: fileKeyStore(path.join(app.getPath('userData'), 'grapevine.json'), {
48
+ encrypt: (t) => safeStorage.encryptString(t).toString('base64'),
49
+ decrypt: (t) => safeStorage.decryptString(Buffer.from(t, 'base64')),
50
+ }),
51
+ appDataDir: app.getPath('appData'), // to show "Not installed" for apps that aren't
52
+ });
53
+
54
+ await link.start();
55
+
56
+ // Tell every app that subscribed. seq and at are added for you.
57
+ link.publish('drops.farming.changed', {
58
+ state: 'farming',
59
+ account: { platform: 'kick', user: 'pearfan' },
60
+ channel: { platform: 'kick', slug: 'pixelpear' },
61
+ drop: null,
62
+ });
63
+
64
+ // Hear what the other apps provide.
65
+ link.on('event', (fromAppId, name, params) => {
66
+ if (name === 'players.changed') console.log(params.platforms.kick?.signedIn);
67
+ });
68
+
69
+ // Ask one of them something.
70
+ const players = await link.request('com.glowingpear.streammultiview', 'players.get');
71
+
72
+ // When a Connected apps switch changes:
73
+ link.refresh();
74
+
75
+ // When the app quits:
76
+ await link.stop();
77
+ ```
78
+
79
+ Editors suggest every event's fields and underline mistakes, in plain JavaScript too. The types come from the schemas.
80
+
81
+ ### A Connected apps page
82
+
83
+ `link.peers()` lists every known app with what its card needs, and `link.on('change', …)` says when to draw it again.
84
+
85
+ | Field | |
86
+ | ------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
87
+ | `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 |
88
+ | `installed` | Whether its data folder exists (with `appDataDir`) |
89
+ | `enabled` | Your switch for it |
90
+ | `status` | `off`, `waiting`, `connected`, `pair-again` or `incompatible` |
91
+ | `reason` | Why it's waiting: `switched-off` (its own switch is off), `disconnected-there` |
92
+ | `keyHeldBy` | For `pair-again`: `us` means show **Pair again** here, `them` means it's pressed in the other app |
93
+ | `version`, `report.text` | While connected: its version, and "Works" or what's missing ("update Pear Drops to 0.1.7 or later") |
94
+ | `pairedAt`, `keysEncrypted` | When they paired, and whether keys are stored encrypted |
95
+
96
+ Two calls go with buttons on the card:
97
+
98
+ - **Disconnect:** `link.disconnect(appId)`. Turn your own switch for that app off too.
99
+ - **Pair again:** `link.pairAgain(appId)`.
100
+
101
+ ### In your app's tests
102
+
103
+ A stand-in plays the other app, at any version, so your tests don't need it installed:
104
+
105
+ ```js
106
+ const { standInPeer } = require('@streamorchard/grapevine/stand-in-peer');
107
+ const peer = await standInPeer({ as: 'com.glowingpear.streammultiview', dir: testDir, uses: { 'drops.farming': '1.0' } });
108
+ peer.link.on('event', (from, name, params) => {
109
+ /* … */
110
+ });
111
+ ```
112
+
113
+ Give your app the same `GRAPEVINE_DIR` (or `place: { env: { GRAPEVINE_DIR: testDir } }`), so tests never touch the real folder.
114
+
115
+ From the command line, for launch tests, it prints a JSON line for everything that happens and takes commands on stdin:
116
+
117
+ ```sh
118
+ npx grapevine-stand-in --as com.glowingpear.streammultiview --dir /tmp/test --uses drops.farming@1.0 --provides players@1.0
119
+ ```
120
+
121
+ Example messages for every capability version, good and bad, are in `@streamorchard/grapevine/fixtures`. Check that what your app sends passes them.
122
+
123
+ ## Capabilities
124
+
125
+ | Capability | Provided by | What it says |
126
+ | ------------------- | ---------------- | ------------------------------------------------------------------ |
127
+ | `drops.farming` 1.0 | Pear Drops | Whether it's farming, which channel, and the drop's progress |
128
+ | `players` 1.0 | Stream Multiview | Its players by site: signed in or not, and which channels are open |
129
+
130
+ To add one:
131
+
132
+ 1. Write `schemas/<name>/1.json`, with an example of each event in `fixtures/<name>/1/`, good and bad.
133
+ 2. Run `npm run types`.
134
+
135
+ To change one, follow [the version rules](PROTOCOL.md#versions): additions are a minor version, anything else a new major alongside the old one.
136
+
137
+ ## Adding an app
138
+
139
+ 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.
140
+
141
+ ## Developing
142
+
143
+ ```sh
144
+ npm test # every test, real sockets included
145
+ npm run test:coverage # with the coverage minimums
146
+ npm run lint # ESLint and Prettier
147
+ npm run format # fix what lint can
148
+ npm run types # types/capabilities.d.ts from the schemas
149
+ npm run check # the types are up to date, and tsc finds no type errors
150
+ ```
151
+
152
+ CI runs the tests on Windows, macOS and Linux, and lint, types and coverage on Linux.
153
+
154
+ **Releasing:**
155
+
156
+ 1. In a pull request, change `version` in `package.json` and add a section to `CHANGELOG.md`.
157
+ 2. Once it's merged, publish a GitHub release tagged `v<version>`.
158
+ 3. The **Publish** workflow tests it and publishes it to npm, with provenance. No npm token is stored anywhere.
159
+
160
+ ## License
161
+
162
+ [GPL-3.0](LICENSE). Apps that use Grapevine must be GPL-compatible.
Binary file
Binary file
@@ -0,0 +1 @@
1
+ { "event": "drops.farming.changed", "params": { "seq": 1, "at": "yesterday", "state": "farming", "account": null, "channel": null, "drop": null } }
@@ -0,0 +1,4 @@
1
+ {
2
+ "event": "drops.farming.changed",
3
+ "params": { "seq": 1, "at": "2026-10-18T09:12:04Z", "state": "farming", "account": null, "channel": { "platform": "kick" }, "drop": null }
4
+ }
@@ -0,0 +1 @@
1
+ { "event": "drops.farming.changed", "params": { "seq": 1, "at": "2026-10-18T09:12:04Z", "account": null, "channel": null, "drop": null } }
@@ -0,0 +1,11 @@
1
+ {
2
+ "event": "drops.farming.changed",
3
+ "params": {
4
+ "seq": 42,
5
+ "at": "2026-10-18T09:12:04.120Z",
6
+ "state": "farming",
7
+ "account": { "platform": "kick", "user": "pearfan" },
8
+ "channel": { "platform": "kick", "slug": "pixelpear", "name": "PixelPear" },
9
+ "drop": { "campaign": "Neon Rush Launch Week", "category": "Neon Rush", "progress": { "done": 48, "total": 120, "unit": "minutes" } }
10
+ }
11
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "event": "drops.farming.changed",
3
+ "note": "A state and a field from a later minor version: both must be accepted",
4
+ "params": {
5
+ "seq": 44,
6
+ "at": "2026-10-18T10:00:00Z",
7
+ "state": "cooling-down",
8
+ "account": null,
9
+ "channel": null,
10
+ "drop": null,
11
+ "nextCheckAt": "2026-10-18T10:05:00Z"
12
+ }
13
+ }
@@ -0,0 +1 @@
1
+ { "result": "drops.farming.get", "params": { "seq": 0, "at": "2026-10-18T09:12:04Z", "state": "paused", "account": null, "channel": null, "drop": null } }
@@ -0,0 +1,4 @@
1
+ {
2
+ "event": "drops.farming.changed",
3
+ "params": { "seq": 43, "at": "2026-10-18T09:40:00Z", "state": "waiting", "account": { "platform": "kick", "user": "pearfan" }, "channel": null, "drop": null }
4
+ }
@@ -0,0 +1,36 @@
1
+ // Example messages for every capability version, good and bad. Each app's tests run them, so an
2
+ // app that says it supports a version is checked against that version's own examples.
3
+ // const { fixtures } = require('@streamorchard/grapevine/fixtures');
4
+ // for (const f of fixtures('drops.farming', 1).valid) …
5
+ 'use strict';
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ /**
11
+ * @typedef {{name: string, event?: string, result?: string, note?: string, params: any}} Fixture
12
+ * @returns {{valid: Fixture[], invalid: Fixture[]}}
13
+ */
14
+ function fixtures(capability, major) {
15
+ const dir = path.join(__dirname, capability, String(major));
16
+ const read = (kind) => {
17
+ const folder = path.join(dir, kind);
18
+ if (!fs.existsSync(folder)) return [];
19
+ return fs
20
+ .readdirSync(folder)
21
+ .filter((f) => f.endsWith('.json'))
22
+ .sort()
23
+ .map((f) => ({ name: f.slice(0, -5), ...JSON.parse(fs.readFileSync(path.join(folder, f), 'utf8')) }));
24
+ };
25
+ return { valid: read('valid'), invalid: read('invalid') };
26
+ }
27
+
28
+ /** Every capability@major there are examples for. */
29
+ function all() {
30
+ return fs
31
+ .readdirSync(__dirname)
32
+ .filter((d) => fs.statSync(path.join(__dirname, d)).isDirectory())
33
+ .flatMap((cap) => fs.readdirSync(path.join(__dirname, cap)).map((m) => ({ capability: cap, major: Number(m) })));
34
+ }
35
+
36
+ module.exports = { fixtures, all };
@@ -0,0 +1,4 @@
1
+ {
2
+ "event": "players.changed",
3
+ "params": { "seq": 2, "at": "2026-10-18T09:00:00Z", "platforms": { "kick": { "signedIn": true, "mode": "signed-in", "account": null } } }
4
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "event": "players.changed",
3
+ "params": { "seq": 2, "at": "2026-10-18T09:00:00Z", "platforms": { "kick": { "signedIn": "yes", "mode": "auto", "account": null, "open": [] } } }
4
+ }
@@ -0,0 +1 @@
1
+ { "event": "players.changed", "params": { "seq": 1, "at": "2026-10-18T09:00:00Z", "platforms": {} } }
@@ -0,0 +1,18 @@
1
+ {
2
+ "event": "players.changed",
3
+ "params": {
4
+ "seq": 7,
5
+ "at": "2026-10-18T09:12:05Z",
6
+ "platforms": {
7
+ "kick": {
8
+ "signedIn": false,
9
+ "mode": "auto",
10
+ "account": { "platform": "kick", "user": "pearfan" },
11
+ "open": [
12
+ { "platform": "kick", "slug": "pixelpear" },
13
+ { "platform": "kick", "slug": "moonlitmaple" }
14
+ ]
15
+ }
16
+ }
17
+ }
18
+ }
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
2
  "name": "@streamorchard/grapevine",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Lets desktop apps on the same computer find each other and talk, privately: local sockets, JSON-RPC, pairing and versioned capabilities.",
5
+ "license": "GPL-3.0-only",
6
+ "author": "StreamOrchard",
7
+ "homepage": "https://github.com/Stream-Orchard/grapevine#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Stream-Orchard/grapevine.git"
11
+ },
12
+ "bugs": "https://github.com/Stream-Orchard/grapevine/issues",
13
+ "keywords": [
14
+ "ipc",
15
+ "local",
16
+ "json-rpc",
17
+ "unix-socket",
18
+ "named-pipe",
19
+ "electron",
20
+ "desktop",
21
+ "pairing"
22
+ ],
23
+ "main": "src/index.js",
24
+ "types": "types/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./types/index.d.ts",
28
+ "default": "./src/index.js"
29
+ },
30
+ "./stand-in-peer": "./stand-in-peer.js",
31
+ "./fixtures": "./fixtures/index.js",
32
+ "./package.json": "./package.json"
33
+ },
34
+ "bin": {
35
+ "grapevine-stand-in": "stand-in-peer.js"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "types",
40
+ "schemas",
41
+ "fixtures",
42
+ "assets/apps",
43
+ "stand-in-peer.js",
44
+ "PROTOCOL.md"
45
+ ],
46
+ "engines": {
47
+ "node": ">=22"
48
+ },
49
+ "scripts": {
50
+ "test": "node --test --test-force-exit --test-timeout=60000 \"test/**/*.test.js\"",
51
+ "test:coverage": "node --test --test-force-exit --test-timeout=60000 --experimental-test-coverage --test-coverage-lines=90 --test-coverage-branches=80 --test-coverage-functions=90 --test-coverage-exclude=\"test/**\" \"test/**/*.test.js\"",
52
+ "lint": "eslint . && prettier --check .",
53
+ "format": "prettier --write . && eslint --fix .",
54
+ "types": "node scripts/types.js",
55
+ "check": "node scripts/types.js --check && tsc -p jsconfig.json"
56
+ },
57
+ "devDependencies": {
58
+ "@eslint/js": "^10.0.1",
59
+ "@types/node": "^26.6.5",
60
+ "eslint": "^10.13.0",
61
+ "globals": "^17.13.0",
62
+ "prettier": "^3.9.10",
63
+ "typescript": "^5.9.3"
64
+ },
65
+ "publishConfig": {
66
+ "access": "public"
67
+ }
68
+ }