@streamorchard/grapevine 0.1.1 → 0.3.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 +34 -11
- package/README.md +35 -7
- package/fixtures/drops.farming/1/invalid/image-not-https.json +11 -0
- package/fixtures/drops.farming/1/invalid/next-without-name.json +11 -0
- package/fixtures/drops.farming/1/valid/farming-plain.json +12 -0
- package/fixtures/drops.farming/1/valid/farming.json +17 -2
- package/fixtures/drops.farming/1/valid/show-result.json +1 -0
- package/package.json +1 -1
- package/schemas/common.json +52 -10
- package/schemas/drops.farming/1.json +161 -11
- package/src/connection.js +20 -9
- package/src/handshake.js +6 -0
- package/src/index.js +69 -23
- package/src/known-apps.js +3 -0
- package/stand-in-peer.js +16 -2
- package/types/capabilities.d.ts +34 -1
- package/types/index.d.ts +8 -0
package/PROTOCOL.md
CHANGED
|
@@ -10,6 +10,7 @@ Version 1. How desktop apps on the same computer find each other, pair, and talk
|
|
|
10
10
|
- [Errors](#errors)
|
|
11
11
|
- [Capabilities](#capabilities)
|
|
12
12
|
- [Versions](#versions)
|
|
13
|
+
- [Renaming an app](#renaming-an-app)
|
|
13
14
|
- [Compatibility](#compatibility)
|
|
14
15
|
- [Security](#security)
|
|
15
16
|
|
|
@@ -31,7 +32,7 @@ Every app has a **slug** (`a-z`, `0-9` and `-`, up to 40 characters, starting wi
|
|
|
31
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.
|
|
32
33
|
- An app only listens, and only looks for others, while at least one of its Connected apps switches is on.
|
|
33
34
|
|
|
34
|
-
**Who connects.** Of two apps, the one whose
|
|
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
36
|
|
|
36
37
|
## Messages
|
|
37
38
|
|
|
@@ -61,6 +62,7 @@ The connecting app sends `link.hello` first:
|
|
|
61
62
|
"protocol": { "min": 1, "max": 1 },
|
|
62
63
|
"app": {
|
|
63
64
|
"id": "com.glowingpear.streammultiview",
|
|
65
|
+
"formerIds": [],
|
|
64
66
|
"slug": "stream-multiview",
|
|
65
67
|
"name": "Stream Multiview",
|
|
66
68
|
"version": "0.4.0",
|
|
@@ -79,6 +81,7 @@ The connecting app sends `link.hello` first:
|
|
|
79
81
|
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
80
82
|
| `protocol` | The range of protocol versions it speaks |
|
|
81
83
|
| `app.id` | Its install id: lowercase letters, digits, dots and dashes, like `com.example.app` (3–100 characters) |
|
|
84
|
+
| `app.formerIds` | The ids it had before a rename, up to 10. Always sent: `[]` until the app is renamed |
|
|
82
85
|
| `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
86
|
| `capabilities` | For each capability, the versions it `provides` and `uses`, as `"major.minor"` |
|
|
84
87
|
| `auth.nonce` | 32 random bytes, base64, new for every connection |
|
|
@@ -88,12 +91,13 @@ The listening app checks it, in this order:
|
|
|
88
91
|
|
|
89
92
|
1. A hello that doesn't fit the table gets `-32602` with `data.problems` (a list of strings), and the connection closes.
|
|
90
93
|
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.
|
|
94
|
+
3. Its switch for that app is off: `-32005`, and close. The app is known by `app.id` or any of `app.formerIds`, and the switch counts under any of them.
|
|
92
95
|
4. **Pairing.**
|
|
93
96
|
- `pair: true` and it has no key for that app: it makes a new 32-byte random key `K`.
|
|
94
97
|
- `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
98
|
- `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
99
|
- Otherwise `K` is the key it has.
|
|
100
|
+
- "Has a key" means one filed under `app.id` or any of `app.formerIds` (or a former id its own known-apps list gives that app).
|
|
97
101
|
5. It replies:
|
|
98
102
|
|
|
99
103
|
```json
|
|
@@ -107,7 +111,7 @@ The listening app checks it, in this order:
|
|
|
107
111
|
|
|
108
112
|
The connecting app checks the reply:
|
|
109
113
|
|
|
110
|
-
- its `app.id` is the app it meant to connect to;
|
|
114
|
+
- its `app.id`, or one of its `app.formerIds`, is the app it meant to connect to;
|
|
111
115
|
- it speaks the `protocol` chosen;
|
|
112
116
|
- the `proof` matches its key, or the `key` it was just given when pairing.
|
|
113
117
|
|
|
@@ -177,7 +181,8 @@ A capability is a named feature one app provides and others use, defined by a JS
|
|
|
177
181
|
| ----------- | ----------------------------------------------------------------- |
|
|
178
182
|
| `Timestamp` | ISO 8601, in UTC |
|
|
179
183
|
| `Platform` | A streaming site: `kick`, `twitch`, `youtube`, or one added later |
|
|
180
|
-
| `Channel` | `{ platform, slug, name? }`
|
|
184
|
+
| `Channel` | `{ platform, slug, name?, avatar? }` |
|
|
185
|
+
| `Image` | A picture's `https` address, as the site gives it |
|
|
181
186
|
| `Account` | `{ platform, user }`: the username only, never a token or cookie |
|
|
182
187
|
| `Progress` | `{ done, total, unit }`, where `unit` is `minutes` for now |
|
|
183
188
|
|
|
@@ -190,14 +195,22 @@ A capability is a named feature one app provides and others use, defined by a JS
|
|
|
190
195
|
|
|
191
196
|
### `drops.farming` 1.0
|
|
192
197
|
|
|
193
|
-
What a drops app is farming. Provided by Pear Drops.
|
|
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.
|
|
194
199
|
|
|
195
|
-
| Field
|
|
196
|
-
|
|
|
197
|
-
| `state`
|
|
198
|
-
| `account`
|
|
199
|
-
| `channel`
|
|
200
|
-
| `
|
|
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` |
|
|
212
|
+
|
|
213
|
+
`drops.farming.show {}` asks the drops app to bring its window to the front, for a click on that widget. It replies `{ shown }`.
|
|
201
214
|
|
|
202
215
|
### `players` 1.0
|
|
203
216
|
|
|
@@ -248,6 +261,16 @@ After the hello, each app tells its user one of:
|
|
|
248
261
|
|
|
249
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.
|
|
250
263
|
|
|
264
|
+
## Renaming an app
|
|
265
|
+
|
|
266
|
+
An app's id can change, say from `com.glowingpear.streammultiview` to `com.streamorchard.streammultiview`. Apps paired with it under the old id carry on without pairing again:
|
|
267
|
+
|
|
268
|
+
1. **The renamed app lists its old id in its hello:** `"formerIds": ["com.glowingpear.streammultiview"]`. That's the only change it needs.
|
|
269
|
+
2. **Other apps find their key and their switch under any of its ids.** An app whose Grapevine predates the rename still knows it by the old id, and keeps working.
|
|
270
|
+
3. **The known-apps list gets the new id,** with the old one in its own `formerIds`. An app updated to it shows the new id, finds what was saved under the old one, and can move its own settings across.
|
|
271
|
+
|
|
272
|
+
Claiming a former id only says where to look for a key. The app still has to prove it holds that key, so it can't take over another app's pairing.
|
|
273
|
+
|
|
251
274
|
## Security
|
|
252
275
|
|
|
253
276
|
**Who can connect:**
|
package/README.md
CHANGED
|
@@ -53,12 +53,29 @@ const link = createLink({
|
|
|
53
53
|
|
|
54
54
|
await link.start();
|
|
55
55
|
|
|
56
|
-
// Tell every app that subscribed. seq and at are added for you
|
|
56
|
+
// Tell every app that subscribed. seq and at are added for you, and the last one also answers
|
|
57
|
+
// drops.farming.get. Everything past progress is optional: it's what a widget would show.
|
|
57
58
|
link.publish('drops.farming.changed', {
|
|
58
59
|
state: 'farming',
|
|
59
60
|
account: { platform: 'kick', user: 'pearfan' },
|
|
60
|
-
channel: { platform: 'kick', slug: 'pixelpear' },
|
|
61
|
-
|
|
61
|
+
channel: { platform: 'kick', slug: 'pixelpear', name: 'PixelPear', avatar: 'https://files.kick.com/…/pixelpear.webp' },
|
|
62
|
+
stream: { title: 'Neon Rush ranked, chill vibes', viewers: 1240 },
|
|
63
|
+
drop: {
|
|
64
|
+
campaign: 'Neon Rush Launch Week',
|
|
65
|
+
category: 'Neon Rush',
|
|
66
|
+
progress: { done: 48, total: 120, unit: 'minutes' },
|
|
67
|
+
image: 'https://files.kick.com/…/neon-rush.webp',
|
|
68
|
+
endsAt: '2026-10-21T18:00:00Z',
|
|
69
|
+
rewards: { claimed: 1, total: 3 },
|
|
70
|
+
next: { name: 'Hype Rocket Emote', required: 60, unlocksAt: '2026-10-18T09:24:00Z' },
|
|
71
|
+
doneAt: '2026-10-18T10:24:00Z',
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// A click on another app's widget asks to see Pear Drops.
|
|
76
|
+
link.handle('drops.farming.show', () => {
|
|
77
|
+
mainWindow.show();
|
|
78
|
+
return { shown: true };
|
|
62
79
|
});
|
|
63
80
|
|
|
64
81
|
// Hear what the other apps provide.
|
|
@@ -69,6 +86,10 @@ link.on('event', (fromAppId, name, params) => {
|
|
|
69
86
|
// Ask one of them something.
|
|
70
87
|
const players = await link.request('com.glowingpear.streammultiview', 'players.get');
|
|
71
88
|
|
|
89
|
+
// On the other side, an app showing a farming widget (Stream Multiview, say):
|
|
90
|
+
// link.on('event', (from, name, f) => name === 'drops.farming.changed' && drawWidget(f));
|
|
91
|
+
// widget.onclick = () => link.request('com.streamorchard.peardrops', 'drops.farming.show');
|
|
92
|
+
|
|
72
93
|
// When a Connected apps switch changes:
|
|
73
94
|
link.refresh();
|
|
74
95
|
|
|
@@ -92,6 +113,7 @@ Editors suggest every event's fields and underline mistakes, in plain JavaScript
|
|
|
92
113
|
| `keyHeldBy` | For `pair-again`: `us` means show **Pair again** here, `them` means it's pressed in the other app |
|
|
93
114
|
| `version`, `report.text` | While connected: its version, and "Works" or what's missing ("update Pear Drops to 0.1.7 or later") |
|
|
94
115
|
| `pairedAt`, `keysEncrypted` | When they paired, and whether keys are stored encrypted |
|
|
116
|
+
| `formerIds` | Ids the app had before a rename |
|
|
95
117
|
|
|
96
118
|
Two calls go with buttons on the card:
|
|
97
119
|
|
|
@@ -122,10 +144,10 @@ Example messages for every capability version, good and bad, are in `@streamorch
|
|
|
122
144
|
|
|
123
145
|
## Capabilities
|
|
124
146
|
|
|
125
|
-
| Capability | Provided by | What it says
|
|
126
|
-
| ------------------- | ---------------- |
|
|
127
|
-
| `drops.farming` 1.0 | Pear Drops | Whether it's farming
|
|
128
|
-
| `players` 1.0 | Stream Multiview | Its players by site: signed in or not, and which channels are open
|
|
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 |
|
|
129
151
|
|
|
130
152
|
To add one:
|
|
131
153
|
|
|
@@ -138,6 +160,12 @@ To change one, follow [the version rules](PROTOCOL.md#versions): additions are a
|
|
|
138
160
|
|
|
139
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.
|
|
140
162
|
|
|
163
|
+
## Renaming an app
|
|
164
|
+
|
|
165
|
+
If your app's id changes, list the old one: `createLink({ app: { id: 'com.new.id', formerIds: ['com.old.id'], … } })`. Apps paired with it carry on without pairing again. Then add the new id to `src/known-apps.js`, with the old one in its `formerIds`.
|
|
166
|
+
|
|
167
|
+
When another app was renamed, `link.currentId(oldId)` gives its new id, so you can move your own settings across (its Connect switch, say). [`PROTOCOL.md`](PROTOCOL.md#renaming-an-app) has the details.
|
|
168
|
+
|
|
141
169
|
## Developing
|
|
142
170
|
|
|
143
171
|
```sh
|
|
@@ -0,0 +1,11 @@
|
|
|
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": { "campaign": "c", "category": "c", "progress": { "done": 1, "total": 2, "unit": "minutes" }, "next": { "required": 60 } }
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"event": "drops.farming.changed",
|
|
3
|
+
"note": "Only the required fields: the rest are optional",
|
|
4
|
+
"params": {
|
|
5
|
+
"seq": 41,
|
|
6
|
+
"at": "2026-10-18T09:10:00Z",
|
|
7
|
+
"state": "farming",
|
|
8
|
+
"account": null,
|
|
9
|
+
"channel": { "platform": "kick", "slug": "pixelpear" },
|
|
10
|
+
"drop": { "campaign": "Neon Rush Launch Week", "category": "Neon Rush", "progress": { "done": 46, "total": 120, "unit": "minutes" } }
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -5,7 +5,22 @@
|
|
|
5
5
|
"at": "2026-10-18T09:12:04.120Z",
|
|
6
6
|
"state": "farming",
|
|
7
7
|
"account": { "platform": "kick", "user": "pearfan" },
|
|
8
|
-
"channel": { "platform": "kick", "slug": "pixelpear", "name": "PixelPear" },
|
|
9
|
-
"
|
|
8
|
+
"channel": { "platform": "kick", "slug": "pixelpear", "name": "PixelPear", "avatar": "https://files.kick.com/images/user/1/profile_image/pixelpear.webp" },
|
|
9
|
+
"stream": { "title": "Neon Rush ranked, chill vibes", "viewers": 1240 },
|
|
10
|
+
"drop": {
|
|
11
|
+
"campaign": "Neon Rush Launch Week",
|
|
12
|
+
"category": "Neon Rush",
|
|
13
|
+
"progress": { "done": 48, "total": 120, "unit": "minutes" },
|
|
14
|
+
"image": "https://files.kick.com/images/subcategories/1/banner/neon-rush.webp",
|
|
15
|
+
"endsAt": "2026-10-21T18:00:00Z",
|
|
16
|
+
"doneAt": "2026-10-18T10:24:00Z",
|
|
17
|
+
"rewards": { "claimed": 1, "total": 3 },
|
|
18
|
+
"next": {
|
|
19
|
+
"name": "Hype Rocket Emote",
|
|
20
|
+
"image": "https://files.kick.com/drops/reward/hype-rocket.png",
|
|
21
|
+
"required": 60,
|
|
22
|
+
"unlocksAt": "2026-10-18T09:24:00Z"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
10
25
|
}
|
|
11
26
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "result": "drops.farming.show", "params": { "shown": true } }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@streamorchard/grapevine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
package/schemas/common.json
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"description": "Types every capability shares. Lists marked x-open may gain values: an app must accept a value it doesn't know.",
|
|
3
3
|
"defs": {
|
|
4
|
-
"Timestamp": {
|
|
4
|
+
"Timestamp": {
|
|
5
|
+
"type": "string",
|
|
6
|
+
"format": "date-time",
|
|
7
|
+
"description": "ISO 8601, in UTC"
|
|
8
|
+
},
|
|
9
|
+
"Image": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"pattern": "^https://",
|
|
12
|
+
"maxLength": 2000,
|
|
13
|
+
"description": "A picture's https address, as the site gives it"
|
|
14
|
+
},
|
|
5
15
|
"Platform": {
|
|
6
16
|
"type": "string",
|
|
7
17
|
"pattern": "^[a-z0-9-]{1,32}$",
|
|
@@ -12,9 +22,24 @@
|
|
|
12
22
|
"type": "object",
|
|
13
23
|
"required": ["platform", "slug"],
|
|
14
24
|
"properties": {
|
|
15
|
-
"platform": {
|
|
16
|
-
|
|
17
|
-
|
|
25
|
+
"platform": {
|
|
26
|
+
"$ref": "common:Platform"
|
|
27
|
+
},
|
|
28
|
+
"slug": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"minLength": 1,
|
|
31
|
+
"maxLength": 100,
|
|
32
|
+
"description": "As in the channel's address"
|
|
33
|
+
},
|
|
34
|
+
"name": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"maxLength": 200,
|
|
37
|
+
"description": "As shown on the site"
|
|
38
|
+
},
|
|
39
|
+
"avatar": {
|
|
40
|
+
"$ref": "common:Image",
|
|
41
|
+
"description": "The channel's picture"
|
|
42
|
+
}
|
|
18
43
|
}
|
|
19
44
|
},
|
|
20
45
|
"Account": {
|
|
@@ -22,19 +47,36 @@
|
|
|
22
47
|
"required": ["platform", "user"],
|
|
23
48
|
"description": "Who an app is signed in as: the username only, never a token or cookie",
|
|
24
49
|
"properties": {
|
|
25
|
-
"platform": {
|
|
26
|
-
|
|
50
|
+
"platform": {
|
|
51
|
+
"$ref": "common:Platform"
|
|
52
|
+
},
|
|
53
|
+
"user": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"minLength": 1,
|
|
56
|
+
"maxLength": 100
|
|
57
|
+
}
|
|
27
58
|
}
|
|
28
59
|
},
|
|
29
60
|
"Progress": {
|
|
30
61
|
"type": "object",
|
|
31
62
|
"required": ["done", "total", "unit"],
|
|
32
63
|
"properties": {
|
|
33
|
-
"done": {
|
|
34
|
-
|
|
35
|
-
|
|
64
|
+
"done": {
|
|
65
|
+
"type": "number",
|
|
66
|
+
"minimum": 0
|
|
67
|
+
},
|
|
68
|
+
"total": {
|
|
69
|
+
"type": "number",
|
|
70
|
+
"minimum": 0
|
|
71
|
+
},
|
|
72
|
+
"unit": {
|
|
73
|
+
"type": "string",
|
|
74
|
+
"x-open": ["minutes"]
|
|
75
|
+
}
|
|
36
76
|
}
|
|
37
77
|
},
|
|
38
|
-
"Empty": {
|
|
78
|
+
"Empty": {
|
|
79
|
+
"type": "object"
|
|
80
|
+
}
|
|
39
81
|
}
|
|
40
82
|
}
|
|
@@ -2,20 +2,50 @@
|
|
|
2
2
|
"capability": "drops.farming",
|
|
3
3
|
"major": 1,
|
|
4
4
|
"minor": 0,
|
|
5
|
-
"description": "What a drops app is farming. Provided by Pear Drops.",
|
|
5
|
+
"description": "What a drops app is farming, with enough to show it (a top-bar widget, say). Provided by Pear Drops.",
|
|
6
6
|
"events": {
|
|
7
|
-
"drops.farming.changed": {
|
|
7
|
+
"drops.farming.changed": {
|
|
8
|
+
"$ref": "#State"
|
|
9
|
+
}
|
|
8
10
|
},
|
|
9
11
|
"methods": {
|
|
10
|
-
"drops.farming.get": {
|
|
12
|
+
"drops.farming.get": {
|
|
13
|
+
"params": {
|
|
14
|
+
"$ref": "common:Empty"
|
|
15
|
+
},
|
|
16
|
+
"result": {
|
|
17
|
+
"$ref": "#State"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"drops.farming.show": {
|
|
21
|
+
"params": {
|
|
22
|
+
"$ref": "common:Empty"
|
|
23
|
+
},
|
|
24
|
+
"result": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"required": ["shown"],
|
|
27
|
+
"properties": {
|
|
28
|
+
"shown": {
|
|
29
|
+
"type": "boolean",
|
|
30
|
+
"description": "Whether its window came to the front"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
11
35
|
},
|
|
12
36
|
"defs": {
|
|
13
37
|
"State": {
|
|
14
38
|
"type": "object",
|
|
15
39
|
"required": ["seq", "at", "state", "account", "channel", "drop"],
|
|
16
40
|
"properties": {
|
|
17
|
-
"seq": {
|
|
18
|
-
|
|
41
|
+
"seq": {
|
|
42
|
+
"type": "integer",
|
|
43
|
+
"minimum": 0,
|
|
44
|
+
"description": "Counts up with each change; 0 for a reply to drops.farming.get"
|
|
45
|
+
},
|
|
46
|
+
"at": {
|
|
47
|
+
"$ref": "common:Timestamp"
|
|
48
|
+
},
|
|
19
49
|
"state": {
|
|
20
50
|
"type": "string",
|
|
21
51
|
"minLength": 1,
|
|
@@ -23,21 +53,141 @@
|
|
|
23
53
|
"x-open": ["farming", "waiting", "paused", "done", "signed-out"],
|
|
24
54
|
"description": "farming: its player is watching a stream for drop credit. Any other value, known or not, means it isn't."
|
|
25
55
|
},
|
|
26
|
-
"account": {
|
|
27
|
-
|
|
56
|
+
"account": {
|
|
57
|
+
"anyOf": [
|
|
58
|
+
{
|
|
59
|
+
"$ref": "common:Account"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"type": "null"
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
"channel": {
|
|
67
|
+
"anyOf": [
|
|
68
|
+
{
|
|
69
|
+
"$ref": "common:Channel"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"type": "null"
|
|
73
|
+
}
|
|
74
|
+
],
|
|
75
|
+
"description": "The stream being watched, while farming"
|
|
76
|
+
},
|
|
28
77
|
"drop": {
|
|
29
78
|
"anyOf": [
|
|
30
79
|
{
|
|
31
80
|
"type": "object",
|
|
32
81
|
"required": ["campaign", "category", "progress"],
|
|
33
82
|
"properties": {
|
|
34
|
-
"campaign": {
|
|
35
|
-
|
|
36
|
-
|
|
83
|
+
"campaign": {
|
|
84
|
+
"type": "string",
|
|
85
|
+
"maxLength": 200
|
|
86
|
+
},
|
|
87
|
+
"category": {
|
|
88
|
+
"type": "string",
|
|
89
|
+
"maxLength": 200
|
|
90
|
+
},
|
|
91
|
+
"progress": {
|
|
92
|
+
"$ref": "common:Progress"
|
|
93
|
+
},
|
|
94
|
+
"image": {
|
|
95
|
+
"$ref": "common:Image",
|
|
96
|
+
"description": "The category's picture"
|
|
97
|
+
},
|
|
98
|
+
"endsAt": {
|
|
99
|
+
"$ref": "common:Timestamp",
|
|
100
|
+
"description": "When the drop ends"
|
|
101
|
+
},
|
|
102
|
+
"doneAt": {
|
|
103
|
+
"anyOf": [
|
|
104
|
+
{
|
|
105
|
+
"$ref": "common:Timestamp"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"type": "null"
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
"description": "When every reward should be earned, at the current rate"
|
|
112
|
+
},
|
|
113
|
+
"rewards": {
|
|
114
|
+
"type": "object",
|
|
115
|
+
"required": ["claimed", "total"],
|
|
116
|
+
"properties": {
|
|
117
|
+
"claimed": {
|
|
118
|
+
"type": "integer",
|
|
119
|
+
"minimum": 0
|
|
120
|
+
},
|
|
121
|
+
"total": {
|
|
122
|
+
"type": "integer",
|
|
123
|
+
"minimum": 0
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
"next": {
|
|
128
|
+
"anyOf": [
|
|
129
|
+
{
|
|
130
|
+
"type": "object",
|
|
131
|
+
"required": ["name", "required"],
|
|
132
|
+
"properties": {
|
|
133
|
+
"name": {
|
|
134
|
+
"type": "string",
|
|
135
|
+
"maxLength": 200
|
|
136
|
+
},
|
|
137
|
+
"image": {
|
|
138
|
+
"$ref": "common:Image"
|
|
139
|
+
},
|
|
140
|
+
"required": {
|
|
141
|
+
"type": "number",
|
|
142
|
+
"minimum": 0,
|
|
143
|
+
"description": "Minutes of watching it unlocks at"
|
|
144
|
+
},
|
|
145
|
+
"unlocksAt": {
|
|
146
|
+
"anyOf": [
|
|
147
|
+
{
|
|
148
|
+
"$ref": "common:Timestamp"
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"type": "null"
|
|
152
|
+
}
|
|
153
|
+
],
|
|
154
|
+
"description": "When it should unlock, at the current rate"
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"type": "null"
|
|
160
|
+
}
|
|
161
|
+
],
|
|
162
|
+
"description": "The reward being worked towards"
|
|
163
|
+
}
|
|
37
164
|
}
|
|
38
165
|
},
|
|
39
|
-
{
|
|
166
|
+
{
|
|
167
|
+
"type": "null"
|
|
168
|
+
}
|
|
40
169
|
]
|
|
170
|
+
},
|
|
171
|
+
"stream": {
|
|
172
|
+
"anyOf": [
|
|
173
|
+
{
|
|
174
|
+
"type": "object",
|
|
175
|
+
"properties": {
|
|
176
|
+
"title": {
|
|
177
|
+
"type": "string",
|
|
178
|
+
"maxLength": 300
|
|
179
|
+
},
|
|
180
|
+
"viewers": {
|
|
181
|
+
"type": "integer",
|
|
182
|
+
"minimum": 0
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"type": "null"
|
|
188
|
+
}
|
|
189
|
+
],
|
|
190
|
+
"description": "The stream being watched, while farming: its title and viewers"
|
|
41
191
|
}
|
|
42
192
|
}
|
|
43
193
|
}
|
package/src/connection.js
CHANGED
|
@@ -22,7 +22,11 @@ const TIMING = Object.freeze({
|
|
|
22
22
|
* @property {object} self this app's description (the hello's "app")
|
|
23
23
|
* @property {{provides: Record<string, string[]>, uses: Record<string, string[]>}} mine
|
|
24
24
|
* @property {{get(id: string): {key: string, pairedAt?: string} | null, set(id: string, key: string): void, delete(id: string): void}} keys
|
|
25
|
-
* @property {(
|
|
25
|
+
* @property {(ids: string[]) => boolean} allowed whether this app's switch for that app (under any of its ids) is on
|
|
26
|
+
* @property {(id: string) => string[]} idsOf a known app's id and former ids
|
|
27
|
+
* @property {(app: {id: string, slug: string, formerIds?: string[]}) => {id: string, ids: string[]}} resolve
|
|
28
|
+
* the id this app knows another app by (its known-apps entry, matched by any id), and every id it has had
|
|
29
|
+
* @property {(ids: string[]) => {key: string, pairedAt?: string} | null} findKey the pairing key, filed under any of those ids
|
|
26
30
|
* @property {import('./schema').Schemas} schemas
|
|
27
31
|
* @property {(conn: Connection, method: string, params: any) => Promise<any>} call runs a capability method
|
|
28
32
|
* @property {(conn: Connection, events: string[]) => string[]} subscribe
|
|
@@ -45,7 +49,9 @@ class Connection extends EventEmitter {
|
|
|
45
49
|
this.expectedId = expectedId ?? null;
|
|
46
50
|
this.state = 'hello'; // hello -> auth -> ready -> closed (the connecting app: hello -> authing -> ready)
|
|
47
51
|
this.peer = null; // the other app's description, from its hello
|
|
48
|
-
this.peerId = null;
|
|
52
|
+
this.peerId = null; // the id this app knows the other one by
|
|
53
|
+
/** @type {string[]} */
|
|
54
|
+
this.peerIds = []; // and every id it has had
|
|
49
55
|
this.agreed = { incoming: new Map(), outgoing: new Map(), missing: [] };
|
|
50
56
|
this.protocol = null;
|
|
51
57
|
this.subscribed = new Set(); // events the other app subscribed to
|
|
@@ -85,7 +91,9 @@ class Connection extends EventEmitter {
|
|
|
85
91
|
/** Says hello, checks the reply's proof, proves itself, and is ready. */
|
|
86
92
|
async start() {
|
|
87
93
|
const { self, mine, keys } = this.host;
|
|
88
|
-
|
|
94
|
+
// The key may be filed under a former id of that app: one it had before a rename.
|
|
95
|
+
const expectedIds = this.host.idsOf(this.expectedId);
|
|
96
|
+
const stored = this.host.findKey(expectedIds);
|
|
89
97
|
const clientNonce = hs.nonce();
|
|
90
98
|
let res;
|
|
91
99
|
try {
|
|
@@ -99,7 +107,8 @@ class Connection extends EventEmitter {
|
|
|
99
107
|
}
|
|
100
108
|
const problems = hs.checkApp(res?.app);
|
|
101
109
|
if (problems.length) return this.close(`Its hello reply isn't valid: ${problems.join('; ')}`);
|
|
102
|
-
|
|
110
|
+
const theirIds = [res.app.id, ...(res.app.formerIds ?? [])];
|
|
111
|
+
if (!theirIds.some((i) => expectedIds.includes(i))) return this.close(`Expected ${this.expectedId}, but ${res.app.id} answered`);
|
|
103
112
|
if (hs.agreeProtocol(hs.PROTOCOL, { min: res.protocol, max: res.protocol }) === null)
|
|
104
113
|
return this.close(`It chose protocol ${res.protocol}, which this app doesn't speak`);
|
|
105
114
|
const key = stored?.key ?? res.auth?.key;
|
|
@@ -109,7 +118,8 @@ class Connection extends EventEmitter {
|
|
|
109
118
|
return this.close("Its proof was wrong: the pairing doesn't match");
|
|
110
119
|
}
|
|
111
120
|
this.peer = res.app;
|
|
112
|
-
this.peerId =
|
|
121
|
+
this.peerId = this.expectedId; // the id this app knows it by, whatever it calls itself now
|
|
122
|
+
this.peerIds = [...new Set([...expectedIds, ...theirIds])];
|
|
113
123
|
this.protocol = res.protocol;
|
|
114
124
|
this.agreed = hs.agree(mine, res.capabilities);
|
|
115
125
|
this.state = 'authing';
|
|
@@ -141,10 +151,10 @@ class Connection extends EventEmitter {
|
|
|
141
151
|
this.emit('status', { status: 'incompatible', peer: params.app, theirProtocol: params.protocol });
|
|
142
152
|
return this.reject(id, new GrapevineError(CODES.UNSUPPORTED, 'No protocol version in common', { protocol: hs.PROTOCOL }));
|
|
143
153
|
}
|
|
144
|
-
const peerId = params.app
|
|
145
|
-
if (!this.host.allowed(
|
|
154
|
+
const { id: peerId, ids } = this.host.resolve(params.app);
|
|
155
|
+
if (!this.host.allowed(ids))
|
|
146
156
|
return this.reject(id, new GrapevineError(CODES.SWITCHED_OFF, `${this.host.self.name} isn't set to connect with ${params.app.name}`));
|
|
147
|
-
const stored = this.host.
|
|
157
|
+
const stored = this.host.findKey(ids);
|
|
148
158
|
let key;
|
|
149
159
|
if (params.auth.pair) {
|
|
150
160
|
// An app asking to pair while this one still has a key: only once you press Pair again here.
|
|
@@ -164,6 +174,7 @@ class Connection extends EventEmitter {
|
|
|
164
174
|
this.pending = { key, serverNonce, pairing: !!params.auth.pair };
|
|
165
175
|
this.peer = params.app;
|
|
166
176
|
this.peerId = peerId;
|
|
177
|
+
this.peerIds = ids;
|
|
167
178
|
this.protocol = protocol;
|
|
168
179
|
this.agreed = hs.agree(this.host.mine, params.capabilities);
|
|
169
180
|
this.state = 'auth';
|
|
@@ -265,7 +276,7 @@ class Connection extends EventEmitter {
|
|
|
265
276
|
return this.host.afterSubscribe?.(this);
|
|
266
277
|
}
|
|
267
278
|
if (method === 'link.unpair') {
|
|
268
|
-
this.host.keys.delete(
|
|
279
|
+
for (const i of this.peerIds) this.host.keys.delete(i);
|
|
269
280
|
this.rpc.reply(id, {});
|
|
270
281
|
this.emit('unpaired');
|
|
271
282
|
return this.close('Disconnected in the other app');
|
package/src/handshake.js
CHANGED
|
@@ -116,6 +116,12 @@ function checkApp(app) {
|
|
|
116
116
|
if (app.icon !== undefined && (typeof app.icon !== 'string' || !app.icon.startsWith('data:image/png;base64,') || app.icon.length > MAX_ICON))
|
|
117
117
|
p.push('app.icon: a PNG data URL, up to 256 KB');
|
|
118
118
|
for (const k of ['homepage', 'download']) if (app[k] !== undefined && !isHttps(app[k])) p.push(`app.${k}: an https address`);
|
|
119
|
+
if (
|
|
120
|
+
app.formerIds !== undefined &&
|
|
121
|
+
(!Array.isArray(app.formerIds) || app.formerIds.length > 10 || !app.formerIds.every((i) => typeof i === 'string' && ID_RE.test(i) && i !== app.id))
|
|
122
|
+
) {
|
|
123
|
+
p.push('app.formerIds: up to 10 ids it had before, not its own');
|
|
124
|
+
}
|
|
119
125
|
return p;
|
|
120
126
|
}
|
|
121
127
|
|
package/src/index.js
CHANGED
|
@@ -20,7 +20,7 @@ const POLL_MS = 5000;
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* @typedef {object} LinkOptions
|
|
23
|
-
* @property {{id: string, slug: string, name: string, version: string, summary?: string, icon?: string, homepage?: string, download?: string}} app
|
|
23
|
+
* @property {{id: string, slug: string, name: string, version: string, formerIds?: string[], summary?: string, icon?: string, homepage?: string, download?: string}} app
|
|
24
24
|
* This app, as other apps will see it. `icon` may be a PNG data URL or a file path.
|
|
25
25
|
* @property {Record<string, string | string[]>} [provides] capabilities this app provides, e.g. { 'drops.farming': '1.0' }
|
|
26
26
|
* @property {Record<string, string | string[]>} [uses] capabilities it uses, e.g. { players: '1.0' }
|
|
@@ -40,7 +40,8 @@ class Link extends EventEmitter {
|
|
|
40
40
|
constructor(opts) {
|
|
41
41
|
super();
|
|
42
42
|
const icon = opts.app.icon && !opts.app.icon.startsWith('data:') ? iconDataUrl(opts.app.icon) : opts.app.icon;
|
|
43
|
-
this
|
|
43
|
+
// formerIds: ids this app had before a rename, so apps paired with it then still know it. Sent every time, [] until then.
|
|
44
|
+
this.self = { ...opts.app, formerIds: opts.app.formerIds ?? [], ...(icon ? { icon } : {}) };
|
|
44
45
|
const problems = hs.checkApp(this.self);
|
|
45
46
|
if (problems.length) throw new Error(`createLink: ${problems.join('; ')}`);
|
|
46
47
|
this.mine = { provides: hs.normalizeList(opts.provides), uses: hs.normalizeList(opts.uses) };
|
|
@@ -49,7 +50,8 @@ class Link extends EventEmitter {
|
|
|
49
50
|
this.appDataDir = opts.appDataDir ?? null;
|
|
50
51
|
this.place = opts.place ?? {};
|
|
51
52
|
this.schemas = Schemas.load(opts.schemaDirs);
|
|
52
|
-
|
|
53
|
+
const mine = new Set([this.self.id, ...this.self.formerIds]);
|
|
54
|
+
this.knownApps = (opts.knownApps ?? KNOWN_APPS).filter((a) => !mine.has(a.id) && !(a.formerIds ?? []).some((i) => mine.has(i)));
|
|
53
55
|
this.timing = { ...TIMING, pollMs: POLL_MS, backoffMs: BACKOFF_MS, ...opts.timing };
|
|
54
56
|
this.strict = opts.strict ?? process.env.NODE_ENV === 'test';
|
|
55
57
|
this.logFn = opts.log ?? (() => {});
|
|
@@ -138,9 +140,9 @@ class Link extends EventEmitter {
|
|
|
138
140
|
|
|
139
141
|
/** Call after a Connected apps switch changes: closes connections switched off, looks for ones switched on. */
|
|
140
142
|
refresh() {
|
|
141
|
-
for (const [
|
|
142
|
-
for (const c of this.opening) if (c.
|
|
143
|
-
for (const a of this.knownApps) if (this.
|
|
143
|
+
for (const [, c] of this.conns) if (!this.allowedAny(c.peerIds)) c.bye(`Switched off in ${this.self.name}`);
|
|
144
|
+
for (const c of this.opening) if (c.peerIds.length && !this.allowedAny(c.peerIds)) c.close('Switched off');
|
|
145
|
+
for (const a of this.knownApps) if (this.allowedAny(this.idsOf(a.id))) this.retry.delete(a.id);
|
|
144
146
|
this.poll();
|
|
145
147
|
this.changed();
|
|
146
148
|
}
|
|
@@ -153,6 +155,43 @@ class Link extends EventEmitter {
|
|
|
153
155
|
}
|
|
154
156
|
}
|
|
155
157
|
|
|
158
|
+
/** Whether the switch is on under any of an app's ids (it may still be saved under a former one). */
|
|
159
|
+
allowedAny(ids) {
|
|
160
|
+
return ids.some((id) => this.allowed(id));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A known app's id and its former ids; just the id for an app that isn't known. */
|
|
164
|
+
idsOf(id) {
|
|
165
|
+
const app = this.knownApps.find((a) => a.id === id || (a.formerIds ?? []).includes(id));
|
|
166
|
+
return app ? [app.id, ...(app.formerIds ?? [])] : [id];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The id this app knows another one by (its known-apps entry, matched on any id either side lists), and every id it has had. */
|
|
170
|
+
resolve(app) {
|
|
171
|
+
const theirs = [app.id, ...(app.formerIds ?? [])];
|
|
172
|
+
const known = this.knownApps.find((a) => [a.id, ...(a.formerIds ?? [])].some((i) => theirs.includes(i)));
|
|
173
|
+
const id = known?.id ?? app.id;
|
|
174
|
+
return { id, ids: [...new Set([...(known ? this.idsOf(known.id) : []), ...theirs])] };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The pairing key, under whichever of these ids it was filed. */
|
|
178
|
+
findKey(ids) {
|
|
179
|
+
for (const id of ids) {
|
|
180
|
+
const k = this.keys.get(id);
|
|
181
|
+
if (k) return k;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The id to use for an app: its current id, given any it has had. For moving an app's own
|
|
188
|
+
* settings across after the other app was renamed: settings[link.currentId(old)] = settings[old].
|
|
189
|
+
*/
|
|
190
|
+
currentId(id) {
|
|
191
|
+
for (const c of this.conns.values()) if (c.peerIds.includes(id)) return c.peerId;
|
|
192
|
+
return this.idsOf(id)[0];
|
|
193
|
+
}
|
|
194
|
+
|
|
156
195
|
// ------------------------------------------------------------------ finding apps
|
|
157
196
|
|
|
158
197
|
pollSoon() {
|
|
@@ -162,14 +201,15 @@ class Link extends EventEmitter {
|
|
|
162
201
|
}
|
|
163
202
|
|
|
164
203
|
/**
|
|
165
|
-
* Connects to apps that are running and switched on. Of two apps, the one whose
|
|
166
|
-
* connects and the other waits, so they don't both connect at once.
|
|
204
|
+
* Connects to apps that are running and switched on. Of two apps, the one whose slug sorts first
|
|
205
|
+
* connects and the other waits, so they don't both connect at once. (Slugs, not ids: an app's id
|
|
206
|
+
* may change, its socket's name doesn't.)
|
|
167
207
|
*/
|
|
168
208
|
poll() {
|
|
169
209
|
if (!this.running) return;
|
|
170
210
|
const slugs = new Set(paths.listSlugs(this.place));
|
|
171
211
|
for (const app of this.knownApps) {
|
|
172
|
-
if (!slugs.has(app.slug) || !this.
|
|
212
|
+
if (!slugs.has(app.slug) || !this.allowedAny(this.idsOf(app.id)) || this.conns.has(app.id) || this.self.slug > app.slug) continue;
|
|
173
213
|
if ([...this.opening].some((c) => (c.expectedId ?? c.peerId) === app.id)) continue;
|
|
174
214
|
const r = this.retry.get(app.id);
|
|
175
215
|
if (r && Date.now() < r.at) continue;
|
|
@@ -191,7 +231,7 @@ class Link extends EventEmitter {
|
|
|
191
231
|
track(conn) {
|
|
192
232
|
this.opening.add(conn);
|
|
193
233
|
conn.on('status', (s) => {
|
|
194
|
-
const id = conn.expectedId ?? s.peer
|
|
234
|
+
const id = conn.expectedId ?? (s.peer ? this.resolve(s.peer).id : conn.peerId);
|
|
195
235
|
if (!id) return;
|
|
196
236
|
this.why.set(id, { ...s, peer: undefined, theirApp: s.peer });
|
|
197
237
|
this.changed();
|
|
@@ -210,8 +250,8 @@ class Link extends EventEmitter {
|
|
|
210
250
|
const id = conn.peerId;
|
|
211
251
|
const other = this.conns.get(id);
|
|
212
252
|
if (other) {
|
|
213
|
-
// Both connected at once: keep the one made by the app whose
|
|
214
|
-
const firstOf = (c) => (c.initiator ? this.self.
|
|
253
|
+
// Both connected at once: keep the one made by the app whose slug sorts first.
|
|
254
|
+
const firstOf = (c) => (c.initiator ? this.self.slug : c.peer.slug);
|
|
215
255
|
const [keep, drop] = firstOf(conn) <= firstOf(other) ? [conn, other] : [other, conn];
|
|
216
256
|
this.conns.set(id, keep);
|
|
217
257
|
drop.bye('duplicate');
|
|
@@ -247,7 +287,10 @@ class Link extends EventEmitter {
|
|
|
247
287
|
self: this.self,
|
|
248
288
|
mine: this.mine,
|
|
249
289
|
keys: this.keys,
|
|
250
|
-
allowed: (
|
|
290
|
+
allowed: (ids) => this.allowedAny(ids),
|
|
291
|
+
idsOf: (id) => this.idsOf(id),
|
|
292
|
+
resolve: (app) => this.resolve(app),
|
|
293
|
+
findKey: (ids) => this.findKey(ids),
|
|
251
294
|
schemas: this.schemas,
|
|
252
295
|
timing: this.timing,
|
|
253
296
|
log: (level, text) => this.log(level, text),
|
|
@@ -332,7 +375,7 @@ class Link extends EventEmitter {
|
|
|
332
375
|
|
|
333
376
|
/** Asks a connected app something. Rejects with a GrapevineError (CLOSED if it isn't connected). */
|
|
334
377
|
async request(appId, method, params = {}, opts) {
|
|
335
|
-
const conn = this.conns.get(appId);
|
|
378
|
+
const conn = this.conns.get(this.currentId(appId));
|
|
336
379
|
if (!conn) throw new GrapevineError(CODES.CLOSED, `Not connected to ${appId}`);
|
|
337
380
|
const cap = this.schemas.capabilityOf(method);
|
|
338
381
|
const agreed = cap && conn.agreed.incoming.get(cap);
|
|
@@ -347,27 +390,29 @@ class Link extends EventEmitter {
|
|
|
347
390
|
|
|
348
391
|
/** Disconnect on a card: tells the other app to forget the pairing, and forgets it here. Switch this app's own setting off too. */
|
|
349
392
|
async disconnect(appId) {
|
|
350
|
-
const
|
|
393
|
+
const id = this.currentId(appId);
|
|
394
|
+
const conn = this.conns.get(id);
|
|
351
395
|
if (conn) {
|
|
352
396
|
await conn.rpc.request('link.unpair', {}, { timeout: 2000 }).catch(() => {});
|
|
353
397
|
conn.close('Disconnected');
|
|
354
398
|
}
|
|
355
|
-
this.keys.delete(
|
|
356
|
-
this.why.delete(
|
|
399
|
+
for (const i of new Set([...this.idsOf(id), ...(conn?.peerIds ?? [])])) this.keys.delete(i);
|
|
400
|
+
this.why.delete(id);
|
|
357
401
|
this.changed();
|
|
358
402
|
}
|
|
359
403
|
|
|
360
404
|
/** "Pair again" on a card: forgets this app's old key, so the next connection pairs afresh. */
|
|
361
405
|
pairAgain(appId) {
|
|
362
|
-
this.
|
|
363
|
-
this.
|
|
364
|
-
this.
|
|
406
|
+
const id = this.currentId(appId);
|
|
407
|
+
for (const i of this.idsOf(id)) this.keys.delete(i);
|
|
408
|
+
this.why.delete(id);
|
|
409
|
+
this.retry.delete(id);
|
|
365
410
|
this.poll();
|
|
366
411
|
this.changed();
|
|
367
412
|
}
|
|
368
413
|
|
|
369
414
|
isConnected(appId) {
|
|
370
|
-
return this.conns.has(appId);
|
|
415
|
+
return this.conns.has(this.currentId(appId));
|
|
371
416
|
}
|
|
372
417
|
|
|
373
418
|
/**
|
|
@@ -378,8 +423,8 @@ class Link extends EventEmitter {
|
|
|
378
423
|
return this.knownApps.map((app) => {
|
|
379
424
|
const conn = this.conns.get(app.id);
|
|
380
425
|
const why = this.why.get(app.id);
|
|
381
|
-
const enabled = this.
|
|
382
|
-
const paired = this.
|
|
426
|
+
const enabled = this.allowedAny(this.idsOf(app.id));
|
|
427
|
+
const paired = this.findKey(this.idsOf(app.id));
|
|
383
428
|
let status = !enabled ? 'off' : conn ? 'connected' : (why?.status ?? 'waiting');
|
|
384
429
|
if (status === 'connected' && !conn) status = 'waiting';
|
|
385
430
|
let report = null;
|
|
@@ -387,6 +432,7 @@ class Link extends EventEmitter {
|
|
|
387
432
|
else if (status === 'incompatible' && why?.theirProtocol) report = incompatible(this.self, why.theirApp ?? app, hs.PROTOCOL, why.theirProtocol);
|
|
388
433
|
return {
|
|
389
434
|
id: app.id,
|
|
435
|
+
formerIds: app.formerIds ?? [],
|
|
390
436
|
name: app.name,
|
|
391
437
|
summary: app.summary,
|
|
392
438
|
icon: app.icon,
|
package/src/known-apps.js
CHANGED
|
@@ -11,6 +11,7 @@ const ICONS = path.join(__dirname, '..', 'assets', 'apps');
|
|
|
11
11
|
/**
|
|
12
12
|
* @typedef {object} KnownApp
|
|
13
13
|
* @property {string} id the app's install id (the hello's app.id)
|
|
14
|
+
* @property {string[]} formerIds ids it had before a rename: an app paired under one of them is still known
|
|
14
15
|
* @property {string} slug its socket name
|
|
15
16
|
* @property {string} name
|
|
16
17
|
* @property {string} summary one sentence, for its card
|
|
@@ -25,6 +26,7 @@ const ICONS = path.join(__dirname, '..', 'assets', 'apps');
|
|
|
25
26
|
const KNOWN_APPS = [
|
|
26
27
|
{
|
|
27
28
|
id: 'com.streamorchard.peardrops',
|
|
29
|
+
formerIds: [],
|
|
28
30
|
slug: 'pear-drops',
|
|
29
31
|
name: 'Pear Drops',
|
|
30
32
|
summary: "Collects your Kick emote and badge drops for you, watching with Kick's own player.",
|
|
@@ -36,6 +38,7 @@ const KNOWN_APPS = [
|
|
|
36
38
|
},
|
|
37
39
|
{
|
|
38
40
|
id: 'com.glowingpear.streammultiview',
|
|
41
|
+
formerIds: [],
|
|
39
42
|
slug: 'stream-multiview',
|
|
40
43
|
name: 'Stream Multiview',
|
|
41
44
|
summary: 'Watch Kick, Twitch and YouTube streams and chats side by side, with custom layouts, live alerts and color themes.',
|
package/stand-in-peer.js
CHANGED
|
@@ -26,13 +26,27 @@ const { createLink, KNOWN_APPS, memoryKeyStore } = require('./src');
|
|
|
26
26
|
* @param {(appId: string) => boolean} [o.allowed] its switch (default: on for every app)
|
|
27
27
|
* @param {object} [o.timing]
|
|
28
28
|
* @param {import('./src/keys').KeyStore} [o.keys]
|
|
29
|
+
* @param {string} [o.id] a new id to answer with in place of the known app's (to test a rename)
|
|
30
|
+
* @param {string[]} [o.formerIds]
|
|
29
31
|
*/
|
|
30
|
-
async function standInPeer({
|
|
32
|
+
async function standInPeer({
|
|
33
|
+
as,
|
|
34
|
+
dir,
|
|
35
|
+
version = '99.0.0',
|
|
36
|
+
provides = {},
|
|
37
|
+
uses = {},
|
|
38
|
+
allowed = () => true,
|
|
39
|
+
timing,
|
|
40
|
+
keys = memoryKeyStore(),
|
|
41
|
+
id,
|
|
42
|
+
formerIds = [],
|
|
43
|
+
}) {
|
|
31
44
|
const known = KNOWN_APPS.find((a) => a.id === as);
|
|
32
45
|
if (!known) throw new Error(`stand-in-peer: ${as} isn't a known app`);
|
|
33
46
|
const link = createLink({
|
|
34
47
|
app: {
|
|
35
|
-
id: known.id,
|
|
48
|
+
id: id ?? known.id,
|
|
49
|
+
formerIds,
|
|
36
50
|
slug: known.slug,
|
|
37
51
|
name: `${known.name} (stand-in)`,
|
|
38
52
|
version,
|
package/types/capabilities.d.ts
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
/** ISO 8601, in UTC */
|
|
4
4
|
export type Timestamp = string;
|
|
5
5
|
|
|
6
|
+
/** A picture's https address, as the site gives it */
|
|
7
|
+
export type Image = string;
|
|
8
|
+
|
|
6
9
|
/** A streaming site */
|
|
7
10
|
export type Platform = "kick" | "twitch" | "youtube" | (string & {});
|
|
8
11
|
|
|
@@ -12,6 +15,8 @@ export type Channel = {
|
|
|
12
15
|
slug: string;
|
|
13
16
|
/** As shown on the site */
|
|
14
17
|
name?: string;
|
|
18
|
+
/** The channel's picture */
|
|
19
|
+
avatar?: Image;
|
|
15
20
|
};
|
|
16
21
|
|
|
17
22
|
/** Who an app is signed in as: the username only, never a token or cookie */
|
|
@@ -28,7 +33,7 @@ export type Progress = {
|
|
|
28
33
|
|
|
29
34
|
export type Empty = Record<string, unknown>;
|
|
30
35
|
|
|
31
|
-
// drops.farming 1.0: What a drops app is farming. Provided by Pear Drops.
|
|
36
|
+
// drops.farming 1.0: What a drops app is farming, with enough to show it (a top-bar widget, say). Provided by Pear Drops.
|
|
32
37
|
export type DropsFarmingV1State = {
|
|
33
38
|
/** Counts up with each change; 0 for a reply to drops.farming.get */
|
|
34
39
|
seq: number;
|
|
@@ -42,6 +47,30 @@ export type DropsFarmingV1State = {
|
|
|
42
47
|
campaign: string;
|
|
43
48
|
category: string;
|
|
44
49
|
progress: Progress;
|
|
50
|
+
/** The category's picture */
|
|
51
|
+
image?: Image;
|
|
52
|
+
/** When the drop ends */
|
|
53
|
+
endsAt?: Timestamp;
|
|
54
|
+
/** When every reward should be earned, at the current rate */
|
|
55
|
+
doneAt?: Timestamp | null;
|
|
56
|
+
rewards?: {
|
|
57
|
+
claimed: number;
|
|
58
|
+
total: number;
|
|
59
|
+
};
|
|
60
|
+
/** The reward being worked towards */
|
|
61
|
+
next?: {
|
|
62
|
+
name: string;
|
|
63
|
+
image?: Image;
|
|
64
|
+
/** Minutes of watching it unlocks at */
|
|
65
|
+
required: number;
|
|
66
|
+
/** When it should unlock, at the current rate */
|
|
67
|
+
unlocksAt?: Timestamp | null;
|
|
68
|
+
} | null;
|
|
69
|
+
} | null;
|
|
70
|
+
/** The stream being watched, while farming: its title and viewers */
|
|
71
|
+
stream?: {
|
|
72
|
+
title?: string;
|
|
73
|
+
viewers?: number;
|
|
45
74
|
} | null;
|
|
46
75
|
};
|
|
47
76
|
|
|
@@ -73,5 +102,9 @@ export interface Events {
|
|
|
73
102
|
/** Every method, by name: its params and result. */
|
|
74
103
|
export interface Methods {
|
|
75
104
|
"drops.farming.get": { params: Empty; result: DropsFarmingV1State };
|
|
105
|
+
"drops.farming.show": { params: Empty; result: {
|
|
106
|
+
/** Whether its window came to the front */
|
|
107
|
+
shown: boolean;
|
|
108
|
+
} };
|
|
76
109
|
"players.get": { params: Empty; result: PlayersV1State };
|
|
77
110
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export type EventData<E extends EventName> = Omit<Events[E], 'seq' | 'at'>;
|
|
|
13
13
|
export interface AppInfo {
|
|
14
14
|
/** The app's install id, like com.example.app */
|
|
15
15
|
id: string;
|
|
16
|
+
/** Ids it had before a rename (up to 10), so apps paired with it then still know it. Sent as [] until then. */
|
|
17
|
+
formerIds?: string[];
|
|
16
18
|
/** Its socket name: a-z, 0-9 and dashes */
|
|
17
19
|
slug: string;
|
|
18
20
|
name: string;
|
|
@@ -51,6 +53,8 @@ export interface Timing {
|
|
|
51
53
|
|
|
52
54
|
export interface KnownApp {
|
|
53
55
|
id: string;
|
|
56
|
+
/** Ids it had before a rename */
|
|
57
|
+
formerIds: string[];
|
|
54
58
|
slug: string;
|
|
55
59
|
name: string;
|
|
56
60
|
summary: string;
|
|
@@ -94,7 +98,9 @@ export interface Report {
|
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
export interface Peer {
|
|
101
|
+
/** The id this app knows it by */
|
|
97
102
|
id: string;
|
|
103
|
+
formerIds: string[];
|
|
98
104
|
name: string;
|
|
99
105
|
summary: string;
|
|
100
106
|
/** Path of its bundled logo */
|
|
@@ -145,6 +151,8 @@ export declare class Link extends EventEmitter {
|
|
|
145
151
|
/** Pair again on a card: forgets this app's old key. */
|
|
146
152
|
pairAgain(appId: string): void;
|
|
147
153
|
isConnected(appId: string): boolean;
|
|
154
|
+
/** Its current id, given any id it has had: for moving your own settings across after another app is renamed. */
|
|
155
|
+
currentId(appId: string): string;
|
|
148
156
|
/** Every known app, for the Connected apps page. */
|
|
149
157
|
peers(): Peer[];
|
|
150
158
|
|