@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/LICENSE +674 -0
- package/PROTOCOL.md +266 -0
- package/README.md +161 -2
- package/assets/apps/pear-drops.png +0 -0
- package/assets/apps/stream-multiview.png +0 -0
- package/fixtures/drops.farming/1/invalid/bad-time.json +1 -0
- package/fixtures/drops.farming/1/invalid/channel-without-slug.json +4 -0
- package/fixtures/drops.farming/1/invalid/no-state.json +1 -0
- package/fixtures/drops.farming/1/valid/farming.json +11 -0
- package/fixtures/drops.farming/1/valid/future-state-and-field.json +13 -0
- package/fixtures/drops.farming/1/valid/get-result.json +1 -0
- package/fixtures/drops.farming/1/valid/waiting.json +4 -0
- package/fixtures/index.js +36 -0
- package/fixtures/players/1/invalid/no-open-list.json +4 -0
- package/fixtures/players/1/invalid/signed-in-not-boolean.json +4 -0
- package/fixtures/players/1/valid/no-sites.json +1 -0
- package/fixtures/players/1/valid/signed-out.json +18 -0
- package/package.json +66 -4
- package/schemas/common.json +40 -0
- package/schemas/drops.farming/1.json +45 -0
- package/schemas/players/1.json +43 -0
- package/src/connection.js +316 -0
- package/src/errors.js +39 -0
- package/src/framing.js +65 -0
- package/src/handshake.js +134 -0
- package/src/index.js +421 -0
- package/src/keys.js +71 -0
- package/src/known-apps.js +63 -0
- package/src/paths.js +85 -0
- package/src/report.js +49 -0
- package/src/rpc.js +92 -0
- package/src/schema.js +125 -0
- package/stand-in-peer.js +109 -0
- package/types/capabilities.d.ts +77 -0
- package/types/index.d.ts +198 -0
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
|
-
|
|
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
|
-
|
|
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
|
+
[](https://github.com/Stream-Orchard/grapevine/actions/workflows/test.yml) [](https://www.npmjs.com/package/@streamorchard/grapevine)  
|
|
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 @@
|
|
|
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,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 @@
|
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|