@streamorchard/grapevine 0.0.0-stage → 0.1.1
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/src/handshake.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The hello: checking what an app says about itself, agreeing versions, and the pairing proofs.
|
|
2
|
+
// PROTOCOL.md, "Connecting and pairing" and "Versions".
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const { SLUG_RE } = require('./paths');
|
|
7
|
+
|
|
8
|
+
const PROTOCOL = { min: 1, max: 1 }; // the protocol versions this library speaks
|
|
9
|
+
const ID_RE = /^[a-z0-9][a-z0-9.-]{2,99}$/;
|
|
10
|
+
const VERSION_RE = /^(\d+)\.(\d+)$/;
|
|
11
|
+
const NONCE_BYTES = 32;
|
|
12
|
+
const KEY_BYTES = 32;
|
|
13
|
+
const MAX_ICON = 256 * 1024;
|
|
14
|
+
|
|
15
|
+
/** "1.2" -> { major: 1, minor: 2 }, or null. */
|
|
16
|
+
function parseVersion(v) {
|
|
17
|
+
const m = typeof v === 'string' ? VERSION_RE.exec(v) : null;
|
|
18
|
+
return m ? { major: Number(m[1]), minor: Number(m[2]) } : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* { cap: '1.0' | ['1.0', '2.1'] } -> { cap: ['1.0', ...] }, checking every version.
|
|
23
|
+
* @param {Record<string, string | string[]>} [list]
|
|
24
|
+
* @returns {Record<string, string[]>}
|
|
25
|
+
*/
|
|
26
|
+
function normalizeList(list = {}) {
|
|
27
|
+
/** @type {Record<string, string[]>} */
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const [cap, versions] of Object.entries(list)) {
|
|
30
|
+
/** @type {string[]} */
|
|
31
|
+
const all = [].concat(versions);
|
|
32
|
+
if (!all.length || !all.every(parseVersion)) throw new Error(`Bad versions for ${cap}: use "major.minor", like "1.0"`);
|
|
33
|
+
out[cap] = all;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What goes in the hello's "capabilities": { cap: { provides: [...], uses: [...] } }. */
|
|
39
|
+
function helloCapabilities(provides, uses) {
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const [cap, v] of Object.entries(provides)) out[cap] = { ...out[cap], provides: v };
|
|
42
|
+
for (const [cap, v] of Object.entries(uses)) out[cap] = { ...out[cap], uses: v };
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const majors = (versions) =>
|
|
47
|
+
new Map(
|
|
48
|
+
(versions ?? [])
|
|
49
|
+
.map(parseVersion)
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.map((v) => [v.major, v.minor]),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Agrees, per capability, the highest major both apps support.
|
|
56
|
+
* - incoming: capabilities this app uses that the other provides
|
|
57
|
+
* - outgoing: capabilities this app provides that the other uses
|
|
58
|
+
* - missing: ones one side wants and can't get (for the compatibility report)
|
|
59
|
+
*/
|
|
60
|
+
function agree(mine, theirs) {
|
|
61
|
+
const incoming = new Map();
|
|
62
|
+
const outgoing = new Map();
|
|
63
|
+
const missing = [];
|
|
64
|
+
const pick = (want, have) => {
|
|
65
|
+
const common = [...majors(want).keys()].filter((m) => majors(have).has(m));
|
|
66
|
+
return common.length ? Math.max(...common) : null;
|
|
67
|
+
};
|
|
68
|
+
for (const [cap, want] of Object.entries(mine.uses)) {
|
|
69
|
+
const have = theirs?.[cap]?.provides;
|
|
70
|
+
const major = pick(want, have);
|
|
71
|
+
if (major !== null) incoming.set(cap, { major, minor: majors(have).get(major) });
|
|
72
|
+
else missing.push({ capability: cap, direction: 'incoming', mine: [...majors(want).keys()], theirs: [...majors(have).keys()] });
|
|
73
|
+
}
|
|
74
|
+
for (const [cap, have] of Object.entries(mine.provides)) {
|
|
75
|
+
const want = theirs?.[cap]?.uses;
|
|
76
|
+
if (!want) continue; // they don't use it: nothing to send
|
|
77
|
+
const major = pick(want, have);
|
|
78
|
+
if (major !== null) outgoing.set(cap, { major, minor: majors(have).get(major) });
|
|
79
|
+
else missing.push({ capability: cap, direction: 'outgoing', mine: [...majors(have).keys()], theirs: [...majors(want).keys()] });
|
|
80
|
+
}
|
|
81
|
+
return { incoming, outgoing, missing };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The highest protocol version both ranges include, or null. */
|
|
85
|
+
function agreeProtocol(a, b) {
|
|
86
|
+
const top = Math.min(a.max, b.max);
|
|
87
|
+
return top >= Math.max(a.min, b.min) ? top : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const nonce = () => crypto.randomBytes(NONCE_BYTES).toString('base64');
|
|
91
|
+
const newKey = () => crypto.randomBytes(KEY_BYTES).toString('base64');
|
|
92
|
+
|
|
93
|
+
/** HMAC-SHA256 of "<role>:<nonce>" with the pairing key: proves an app knows the key without sending it. */
|
|
94
|
+
function proof(keyB64, role, n) {
|
|
95
|
+
return crypto.createHmac('sha256', Buffer.from(keyB64, 'base64')).update(`${role}:${n}`).digest('base64');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function proofOk(keyB64, role, n, given) {
|
|
99
|
+
if (typeof given !== 'string') return false;
|
|
100
|
+
const want = Buffer.from(proof(keyB64, role, n), 'base64');
|
|
101
|
+
const got = Buffer.from(given, 'base64');
|
|
102
|
+
return got.length === want.length && crypto.timingSafeEqual(got, want);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const isHttps = (u) => typeof u === 'string' && /^https:\/\/[^\s]+$/.test(u) && u.length <= 500;
|
|
106
|
+
|
|
107
|
+
/** Problems with an app's description in a hello ("app.id: …"), empty when it's fine. */
|
|
108
|
+
function checkApp(app) {
|
|
109
|
+
const p = [];
|
|
110
|
+
if (!app || typeof app !== 'object') return ['app: missing'];
|
|
111
|
+
if (typeof app.id !== 'string' || !ID_RE.test(app.id)) p.push('app.id: lowercase letters, digits, dots and dashes, like com.example.app');
|
|
112
|
+
if (typeof app.slug !== 'string' || !SLUG_RE.test(app.slug)) p.push('app.slug: lowercase letters, digits and dashes');
|
|
113
|
+
if (typeof app.name !== 'string' || !app.name || app.name.length > 100) p.push('app.name: 1 to 100 characters');
|
|
114
|
+
if (typeof app.version !== 'string' || !app.version || app.version.length > 40) p.push('app.version: 1 to 40 characters');
|
|
115
|
+
if (app.summary !== undefined && (typeof app.summary !== 'string' || app.summary.length > 300)) p.push('app.summary: up to 300 characters');
|
|
116
|
+
if (app.icon !== undefined && (typeof app.icon !== 'string' || !app.icon.startsWith('data:image/png;base64,') || app.icon.length > MAX_ICON))
|
|
117
|
+
p.push('app.icon: a PNG data URL, up to 256 KB');
|
|
118
|
+
for (const k of ['homepage', 'download']) if (app[k] !== undefined && !isHttps(app[k])) p.push(`app.${k}: an https address`);
|
|
119
|
+
return p;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Problems with a hello's params, empty when it's fine. */
|
|
123
|
+
function checkHello(params) {
|
|
124
|
+
const p = checkApp(params?.app);
|
|
125
|
+
const pr = params?.protocol;
|
|
126
|
+
if (!pr || !Number.isInteger(pr.min) || !Number.isInteger(pr.max) || pr.min < 1 || pr.max < pr.min) p.push('protocol: { min, max } whole numbers');
|
|
127
|
+
if (!params?.capabilities || typeof params.capabilities !== 'object' || Array.isArray(params.capabilities)) p.push('capabilities: an object');
|
|
128
|
+
const auth = params?.auth;
|
|
129
|
+
if (!auth || typeof auth.nonce !== 'string' || Buffer.from(auth.nonce, 'base64').length !== NONCE_BYTES) p.push('auth.nonce: 32 random bytes, base64');
|
|
130
|
+
if (auth && auth.pair !== undefined && typeof auth.pair !== 'boolean') p.push('auth.pair: true or false');
|
|
131
|
+
return p;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { PROTOCOL, ID_RE, parseVersion, normalizeList, helloCapabilities, agree, agreeProtocol, nonce, newKey, proof, proofOk, checkApp, checkHello };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// Grapevine: lets desktop apps on the same computer find each other and talk, privately.
|
|
2
|
+
// createLink() is all an app uses: start it, publish what it provides, listen for what it uses,
|
|
3
|
+
// and show peers() on its Connected apps page. PROTOCOL.md describes what goes over the wire.
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const net = require('net');
|
|
8
|
+
const { EventEmitter } = require('events');
|
|
9
|
+
const paths = require('./paths');
|
|
10
|
+
const hs = require('./handshake');
|
|
11
|
+
const { Connection, TIMING } = require('./connection');
|
|
12
|
+
const { Schemas } = require('./schema');
|
|
13
|
+
const { CODES, GrapevineError } = require('./errors');
|
|
14
|
+
const { KNOWN_APPS, isInstalled, iconDataUrl } = require('./known-apps');
|
|
15
|
+
const { compatibility, incompatible } = require('./report');
|
|
16
|
+
const { fileKeyStore, memoryKeyStore } = require('./keys');
|
|
17
|
+
|
|
18
|
+
const BACKOFF_MS = [1000, 2000, 5000, 10_000, 30_000];
|
|
19
|
+
const POLL_MS = 5000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {object} LinkOptions
|
|
23
|
+
* @property {{id: string, slug: string, name: string, version: string, summary?: string, icon?: string, homepage?: string, download?: string}} app
|
|
24
|
+
* This app, as other apps will see it. `icon` may be a PNG data URL or a file path.
|
|
25
|
+
* @property {Record<string, string | string[]>} [provides] capabilities this app provides, e.g. { 'drops.farming': '1.0' }
|
|
26
|
+
* @property {Record<string, string | string[]>} [uses] capabilities it uses, e.g. { players: '1.0' }
|
|
27
|
+
* @property {(appId: string) => boolean} allowed whether its Connected apps switch for that app is on
|
|
28
|
+
* @property {ReturnType<typeof fileKeyStore>} [keys] where pairing keys are kept (fileKeyStore(); in memory if left out)
|
|
29
|
+
* @property {string} [appDataDir] the OS's app data folder (Electron's app.getPath('appData')), to tell whether known apps are installed
|
|
30
|
+
* @property {import('./paths').Place} [place] for tests: the platform and environment to work out paths from
|
|
31
|
+
* @property {string[]} [schemaDirs] extra schema folders, for capabilities that aren't in this package yet
|
|
32
|
+
* @property {typeof KNOWN_APPS} [knownApps]
|
|
33
|
+
* @property {Partial<typeof TIMING> & {pollMs?: number, backoffMs?: number[]}} [timing]
|
|
34
|
+
* @property {boolean} [strict] throw on a message that doesn't match its schema (default: when NODE_ENV is "test")
|
|
35
|
+
* @property {(level: 'info' | 'warn' | 'error', text: string) => void} [log]
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
class Link extends EventEmitter {
|
|
39
|
+
/** @param {LinkOptions} opts */
|
|
40
|
+
constructor(opts) {
|
|
41
|
+
super();
|
|
42
|
+
const icon = opts.app.icon && !opts.app.icon.startsWith('data:') ? iconDataUrl(opts.app.icon) : opts.app.icon;
|
|
43
|
+
this.self = { ...opts.app, ...(icon ? { icon } : {}) };
|
|
44
|
+
const problems = hs.checkApp(this.self);
|
|
45
|
+
if (problems.length) throw new Error(`createLink: ${problems.join('; ')}`);
|
|
46
|
+
this.mine = { provides: hs.normalizeList(opts.provides), uses: hs.normalizeList(opts.uses) };
|
|
47
|
+
this.allowedFn = opts.allowed;
|
|
48
|
+
this.keys = opts.keys ?? memoryKeyStore();
|
|
49
|
+
this.appDataDir = opts.appDataDir ?? null;
|
|
50
|
+
this.place = opts.place ?? {};
|
|
51
|
+
this.schemas = Schemas.load(opts.schemaDirs);
|
|
52
|
+
this.knownApps = (opts.knownApps ?? KNOWN_APPS).filter((a) => a.id !== this.self.id);
|
|
53
|
+
this.timing = { ...TIMING, pollMs: POLL_MS, backoffMs: BACKOFF_MS, ...opts.timing };
|
|
54
|
+
this.strict = opts.strict ?? process.env.NODE_ENV === 'test';
|
|
55
|
+
this.logFn = opts.log ?? (() => {});
|
|
56
|
+
for (const cap of [...Object.keys(this.mine.provides), ...Object.keys(this.mine.uses)]) {
|
|
57
|
+
const listed = [...(this.mine.provides[cap] ?? []), ...(this.mine.uses[cap] ?? [])];
|
|
58
|
+
for (const v of listed) if (!this.schemas.get(cap, hs.parseVersion(v).major)) throw new Error(`createLink: no schema for ${cap} ${v}`);
|
|
59
|
+
}
|
|
60
|
+
/** @type {Map<string, Connection>} ready connections, by the other app's id */
|
|
61
|
+
this.conns = new Map();
|
|
62
|
+
/** @type {Set<Connection>} connections still saying hello */
|
|
63
|
+
this.opening = new Set();
|
|
64
|
+
/** @type {Map<string, {status: string, keyHeldBy?: string, theirProtocol?: any, reason?: string, theirApp?: any}>} why an app isn't connected */
|
|
65
|
+
this.why = new Map();
|
|
66
|
+
this.retry = new Map(); // app id -> { attempts, at }
|
|
67
|
+
this.latest = new Map(); // event -> its last params
|
|
68
|
+
this.seq = new Map();
|
|
69
|
+
this.handlers = new Map();
|
|
70
|
+
this.running = false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ------------------------------------------------------------------ starting and stopping
|
|
74
|
+
|
|
75
|
+
/** Listens for other apps and starts looking for them. */
|
|
76
|
+
async start() {
|
|
77
|
+
if (this.running) return;
|
|
78
|
+
paths.ensureDir(this.place);
|
|
79
|
+
this.socketPath = paths.socketPath(this.self.slug, this.place);
|
|
80
|
+
await this.listen();
|
|
81
|
+
this.running = true;
|
|
82
|
+
const platform = this.place.platform ?? process.platform;
|
|
83
|
+
if (platform !== 'win32') {
|
|
84
|
+
try {
|
|
85
|
+
this.watcher = fs.watch(paths.socketDir(this.place), () => this.pollSoon());
|
|
86
|
+
this.watcher.on('error', () => {});
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
this.pollTimer = setInterval(() => this.poll(), this.timing.pollMs);
|
|
90
|
+
this.pollTimer.unref?.();
|
|
91
|
+
this.poll();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
listen() {
|
|
95
|
+
const platform = this.place.platform ?? process.platform;
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const server = net.createServer((socket) => this.accept(socket));
|
|
98
|
+
const go = () =>
|
|
99
|
+
server.listen(this.socketPath, () => {
|
|
100
|
+
if (platform !== 'win32') fs.chmodSync(this.socketPath, 0o600);
|
|
101
|
+
this.server = server;
|
|
102
|
+
resolve();
|
|
103
|
+
});
|
|
104
|
+
server.on('error', (/** @type {NodeJS.ErrnoException} */ err) => {
|
|
105
|
+
if (err.code !== 'EADDRINUSE' || platform === 'win32') return reject(err);
|
|
106
|
+
// A socket file is there already. If nothing answers, a crash left it: replace it.
|
|
107
|
+
const probe = net.createConnection(this.socketPath);
|
|
108
|
+
probe.once('connect', () => {
|
|
109
|
+
probe.destroy();
|
|
110
|
+
reject(new GrapevineError(CODES.INTERNAL_ERROR, `Another copy of ${this.self.name} is already using Grapevine`));
|
|
111
|
+
});
|
|
112
|
+
probe.once('error', () => {
|
|
113
|
+
try {
|
|
114
|
+
fs.unlinkSync(this.socketPath);
|
|
115
|
+
} catch {}
|
|
116
|
+
go();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
go();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Says goodbye to every app and stops listening. */
|
|
124
|
+
async stop() {
|
|
125
|
+
if (!this.running) return;
|
|
126
|
+
this.running = false;
|
|
127
|
+
clearInterval(this.pollTimer);
|
|
128
|
+
clearTimeout(this.pollSoonTimer);
|
|
129
|
+
this.watcher?.close();
|
|
130
|
+
for (const c of [...this.opening, ...this.conns.values()]) c.bye(`${this.self.name} quit`);
|
|
131
|
+
await new Promise((resolve) => this.server.close(() => resolve()));
|
|
132
|
+
if ((this.place.platform ?? process.platform) !== 'win32') {
|
|
133
|
+
try {
|
|
134
|
+
fs.unlinkSync(this.socketPath);
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Call after a Connected apps switch changes: closes connections switched off, looks for ones switched on. */
|
|
140
|
+
refresh() {
|
|
141
|
+
for (const [id, c] of this.conns) if (!this.allowed(id)) c.bye(`Switched off in ${this.self.name}`);
|
|
142
|
+
for (const c of this.opening) if (c.peerId && !this.allowed(c.peerId)) c.close('Switched off');
|
|
143
|
+
for (const a of this.knownApps) if (this.allowed(a.id)) this.retry.delete(a.id);
|
|
144
|
+
this.poll();
|
|
145
|
+
this.changed();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
allowed(id) {
|
|
149
|
+
try {
|
|
150
|
+
return !!this.allowedFn(id);
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ------------------------------------------------------------------ finding apps
|
|
157
|
+
|
|
158
|
+
pollSoon() {
|
|
159
|
+
clearTimeout(this.pollSoonTimer);
|
|
160
|
+
this.pollSoonTimer = setTimeout(() => this.poll(), 100);
|
|
161
|
+
this.pollSoonTimer.unref?.();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Connects to apps that are running and switched on. Of two apps, the one whose id sorts first
|
|
166
|
+
* connects and the other waits, so they don't both connect at once.
|
|
167
|
+
*/
|
|
168
|
+
poll() {
|
|
169
|
+
if (!this.running) return;
|
|
170
|
+
const slugs = new Set(paths.listSlugs(this.place));
|
|
171
|
+
for (const app of this.knownApps) {
|
|
172
|
+
if (!slugs.has(app.slug) || !this.allowed(app.id) || this.conns.has(app.id) || this.self.id > app.id) continue;
|
|
173
|
+
if ([...this.opening].some((c) => (c.expectedId ?? c.peerId) === app.id)) continue;
|
|
174
|
+
const r = this.retry.get(app.id);
|
|
175
|
+
if (r && Date.now() < r.at) continue;
|
|
176
|
+
this.connect(app);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
connect(app) {
|
|
181
|
+
const socket = net.createConnection(paths.socketPath(app.slug, this.place));
|
|
182
|
+
const conn = new Connection(socket, this.host(), { initiator: true, expectedId: app.id });
|
|
183
|
+
this.track(conn);
|
|
184
|
+
socket.once('connect', () => conn.start());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
accept(socket) {
|
|
188
|
+
this.track(new Connection(socket, this.host(), { initiator: false }));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
track(conn) {
|
|
192
|
+
this.opening.add(conn);
|
|
193
|
+
conn.on('status', (s) => {
|
|
194
|
+
const id = conn.expectedId ?? s.peer?.id ?? conn.peerId;
|
|
195
|
+
if (!id) return;
|
|
196
|
+
this.why.set(id, { ...s, peer: undefined, theirApp: s.peer });
|
|
197
|
+
this.changed();
|
|
198
|
+
});
|
|
199
|
+
conn.on('ready', () => this.onReady(conn));
|
|
200
|
+
conn.on('event', (name, params) => this.emit('event', conn.peerId, name, params));
|
|
201
|
+
conn.on('unpaired', () => {
|
|
202
|
+
this.why.set(conn.peerId, { status: 'waiting', reason: 'disconnected-there' });
|
|
203
|
+
this.emit('unpaired', conn.peerId);
|
|
204
|
+
});
|
|
205
|
+
conn.on('close', (reason, wasReady) => this.onClose(conn, reason, wasReady));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
onReady(conn) {
|
|
209
|
+
this.opening.delete(conn);
|
|
210
|
+
const id = conn.peerId;
|
|
211
|
+
const other = this.conns.get(id);
|
|
212
|
+
if (other) {
|
|
213
|
+
// Both connected at once: keep the one made by the app whose id sorts first.
|
|
214
|
+
const firstOf = (c) => (c.initiator ? this.self.id : c.peerId);
|
|
215
|
+
const [keep, drop] = firstOf(conn) <= firstOf(other) ? [conn, other] : [other, conn];
|
|
216
|
+
this.conns.set(id, keep);
|
|
217
|
+
drop.bye('duplicate');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this.conns.set(id, conn);
|
|
221
|
+
this.why.delete(id);
|
|
222
|
+
this.retry.delete(id);
|
|
223
|
+
this.log('info', `Connected to ${conn.peer.name}`);
|
|
224
|
+
this.emit('connected', id);
|
|
225
|
+
this.changed();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
onClose(conn, reason, wasReady) {
|
|
229
|
+
this.opening.delete(conn);
|
|
230
|
+
const id = conn.peerId ?? conn.expectedId;
|
|
231
|
+
if (id && this.conns.get(id) === conn) {
|
|
232
|
+
this.conns.delete(id);
|
|
233
|
+
this.emit('disconnected', id, reason);
|
|
234
|
+
}
|
|
235
|
+
if (conn.initiator && id) {
|
|
236
|
+
const attempts = wasReady ? 0 : (this.retry.get(id)?.attempts ?? 0) + 1;
|
|
237
|
+
const wait = this.timing.backoffMs[Math.min(Math.max(attempts - 1, 0), this.timing.backoffMs.length - 1)];
|
|
238
|
+
this.retry.set(id, { attempts, at: Date.now() + (wasReady ? this.timing.backoffMs[0] : wait) });
|
|
239
|
+
}
|
|
240
|
+
this.changed();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ------------------------------------------------------------------ what connections ask of the link
|
|
244
|
+
|
|
245
|
+
host() {
|
|
246
|
+
return {
|
|
247
|
+
self: this.self,
|
|
248
|
+
mine: this.mine,
|
|
249
|
+
keys: this.keys,
|
|
250
|
+
allowed: (id) => this.allowed(id),
|
|
251
|
+
schemas: this.schemas,
|
|
252
|
+
timing: this.timing,
|
|
253
|
+
log: (level, text) => this.log(level, text),
|
|
254
|
+
call: (conn, method, params) => this.call(conn, method, params),
|
|
255
|
+
subscribe: (conn, events) => {
|
|
256
|
+
const offered = new Set();
|
|
257
|
+
for (const [cap, { major }] of conn.agreed.outgoing) for (const e of Object.keys(this.schemas.get(cap, major)?.events ?? {})) offered.add(e);
|
|
258
|
+
conn.subscribed = new Set(events.filter((e) => offered.has(e)));
|
|
259
|
+
return [...conn.subscribed];
|
|
260
|
+
},
|
|
261
|
+
afterSubscribe: (conn) => {
|
|
262
|
+
for (const e of conn.subscribed) if (this.latest.has(e)) this.sendEvent(conn, e, this.latest.get(e));
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async call(conn, method, params) {
|
|
268
|
+
const cap = this.schemas.capabilityOf(method);
|
|
269
|
+
if (!cap) throw new GrapevineError(CODES.METHOD_NOT_FOUND, `No such method: ${method}`);
|
|
270
|
+
const agreed = conn.agreed.outgoing.get(cap);
|
|
271
|
+
if (!agreed) throw new GrapevineError(CODES.NOT_AGREED, `${cap} wasn't agreed on this connection`);
|
|
272
|
+
const bad = this.schemas.validate(cap, agreed.major, 'params', method, params);
|
|
273
|
+
if (bad.length)
|
|
274
|
+
throw new GrapevineError(bad[0].includes("isn't part of") ? CODES.METHOD_NOT_FOUND : CODES.INVALID_PARAMS, `Invalid params: ${bad.join('; ')}`, {
|
|
275
|
+
problems: bad,
|
|
276
|
+
});
|
|
277
|
+
let result;
|
|
278
|
+
const fn = this.handlers.get(method);
|
|
279
|
+
if (fn) result = await fn(params, { appId: conn.peerId, app: conn.peer, major: agreed.major });
|
|
280
|
+
else {
|
|
281
|
+
// A "<capability>.get" with no handler answers with the last "<capability>.changed".
|
|
282
|
+
const latest = method === `${cap}.get` ? this.latest.get(`${cap}.changed`) : undefined;
|
|
283
|
+
if (!latest) throw new GrapevineError(CODES.METHOD_NOT_FOUND, `${this.self.name} doesn't answer ${method} yet`);
|
|
284
|
+
result = latest;
|
|
285
|
+
}
|
|
286
|
+
const wrong = this.schemas.validate(cap, agreed.major, 'result', method, result);
|
|
287
|
+
if (wrong.length) {
|
|
288
|
+
this.log('error', `${method} answered with an invalid result: ${wrong.join('; ')}`);
|
|
289
|
+
if (this.strict) throw new Error(`${method} returned an invalid result: ${wrong.join('; ')}`);
|
|
290
|
+
throw new GrapevineError(CODES.INTERNAL_ERROR, `${this.self.name} gave an invalid answer`);
|
|
291
|
+
}
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
sendEvent(conn, event, params) {
|
|
296
|
+
const cap = this.schemas.capabilityOf(event);
|
|
297
|
+
const { major } = conn.agreed.outgoing.get(cap) ?? {};
|
|
298
|
+
if (major === undefined) return;
|
|
299
|
+
const problems = this.schemas.validate(cap, major, 'event', event, params);
|
|
300
|
+
if (problems.length) return this.log('error', `Not sent: ${event} doesn't fit ${cap} ${major}: ${problems.join('; ')}`);
|
|
301
|
+
conn.rpc.notify(event, params);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ------------------------------------------------------------------ what apps call
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Tells every subscribed app something changed. `data` is the event's fields; `seq` and `at` are
|
|
308
|
+
* added. The last one is kept, for apps that subscribe later and for "<capability>.get".
|
|
309
|
+
* @returns {object} the params as sent
|
|
310
|
+
*/
|
|
311
|
+
publish(event, data) {
|
|
312
|
+
const cap = this.schemas.capabilityOf(event);
|
|
313
|
+
if (!cap || !this.mine.provides[cap]) throw new Error(`${event} isn't part of a capability ${this.self.name} provides`);
|
|
314
|
+
const seq = (this.seq.get(event) ?? 0) + 1;
|
|
315
|
+
this.seq.set(event, seq);
|
|
316
|
+
const params = { ...data, seq, at: new Date().toISOString() };
|
|
317
|
+
const top = Math.max(...this.mine.provides[cap].map((v) => hs.parseVersion(v).major));
|
|
318
|
+
const problems = this.schemas.validate(cap, top, 'event', event, params);
|
|
319
|
+
if (problems.length) {
|
|
320
|
+
this.log('error', `${event} doesn't fit ${cap} ${top}: ${problems.join('; ')}`);
|
|
321
|
+
if (this.strict) throw new Error(`${event} doesn't fit its schema: ${problems.join('; ')}`);
|
|
322
|
+
}
|
|
323
|
+
this.latest.set(event, params);
|
|
324
|
+
for (const c of this.conns.values()) if (c.subscribed.has(event)) this.sendEvent(c, event, params);
|
|
325
|
+
return params;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Answers a method of a capability this app provides: fn(params, { appId, app, major }) -> result. */
|
|
329
|
+
handle(method, fn) {
|
|
330
|
+
this.handlers.set(method, fn);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Asks a connected app something. Rejects with a GrapevineError (CLOSED if it isn't connected). */
|
|
334
|
+
async request(appId, method, params = {}, opts) {
|
|
335
|
+
const conn = this.conns.get(appId);
|
|
336
|
+
if (!conn) throw new GrapevineError(CODES.CLOSED, `Not connected to ${appId}`);
|
|
337
|
+
const cap = this.schemas.capabilityOf(method);
|
|
338
|
+
const agreed = cap && conn.agreed.incoming.get(cap);
|
|
339
|
+
if (!agreed) throw new GrapevineError(CODES.NOT_AGREED, `${method} isn't agreed with ${conn.peer.name}`);
|
|
340
|
+
const bad = this.schemas.validate(cap, agreed.major, 'params', method, params);
|
|
341
|
+
if (bad.length) throw new GrapevineError(CODES.INVALID_PARAMS, `Invalid params: ${bad.join('; ')}`, { problems: bad });
|
|
342
|
+
const result = await conn.rpc.request(method, params, opts);
|
|
343
|
+
const wrong = this.schemas.validate(cap, agreed.major, 'result', method, result);
|
|
344
|
+
if (wrong.length) throw new GrapevineError(CODES.INTERNAL_ERROR, `${conn.peer.name} gave an invalid answer: ${wrong.join('; ')}`);
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** 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
|
+
async disconnect(appId) {
|
|
350
|
+
const conn = this.conns.get(appId);
|
|
351
|
+
if (conn) {
|
|
352
|
+
await conn.rpc.request('link.unpair', {}, { timeout: 2000 }).catch(() => {});
|
|
353
|
+
conn.close('Disconnected');
|
|
354
|
+
}
|
|
355
|
+
this.keys.delete(appId);
|
|
356
|
+
this.why.delete(appId);
|
|
357
|
+
this.changed();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** "Pair again" on a card: forgets this app's old key, so the next connection pairs afresh. */
|
|
361
|
+
pairAgain(appId) {
|
|
362
|
+
this.keys.delete(appId);
|
|
363
|
+
this.why.delete(appId);
|
|
364
|
+
this.retry.delete(appId);
|
|
365
|
+
this.poll();
|
|
366
|
+
this.changed();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
isConnected(appId) {
|
|
370
|
+
return this.conns.has(appId);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Every known app, for the Connected apps page:
|
|
375
|
+
* status 'off' (its switch is off) | 'waiting' | 'connected' | 'pair-again' | 'incompatible'.
|
|
376
|
+
*/
|
|
377
|
+
peers() {
|
|
378
|
+
return this.knownApps.map((app) => {
|
|
379
|
+
const conn = this.conns.get(app.id);
|
|
380
|
+
const why = this.why.get(app.id);
|
|
381
|
+
const enabled = this.allowed(app.id);
|
|
382
|
+
const paired = this.keys.get(app.id);
|
|
383
|
+
let status = !enabled ? 'off' : conn ? 'connected' : (why?.status ?? 'waiting');
|
|
384
|
+
if (status === 'connected' && !conn) status = 'waiting';
|
|
385
|
+
let report = null;
|
|
386
|
+
if (conn) report = compatibility(this.self, conn.peer, conn.agreed);
|
|
387
|
+
else if (status === 'incompatible' && why?.theirProtocol) report = incompatible(this.self, why.theirApp ?? app, hs.PROTOCOL, why.theirProtocol);
|
|
388
|
+
return {
|
|
389
|
+
id: app.id,
|
|
390
|
+
name: app.name,
|
|
391
|
+
summary: app.summary,
|
|
392
|
+
icon: app.icon,
|
|
393
|
+
homepage: app.homepage,
|
|
394
|
+
download: app.download,
|
|
395
|
+
installed: this.appDataDir ? isInstalled(app, this.appDataDir) : null,
|
|
396
|
+
enabled,
|
|
397
|
+
status,
|
|
398
|
+
reason: why?.reason ?? null,
|
|
399
|
+
keyHeldBy: status === 'pair-again' ? (why?.keyHeldBy ?? null) : null,
|
|
400
|
+
version: conn?.peer.version ?? null,
|
|
401
|
+
report,
|
|
402
|
+
pairedAt: paired?.pairedAt ?? null,
|
|
403
|
+
keysEncrypted: !!this.keys.encrypted,
|
|
404
|
+
};
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
changed() {
|
|
409
|
+
this.emit('change');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
log(level, text) {
|
|
413
|
+
this.logFn(level, text);
|
|
414
|
+
this.emit('log', level, text);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** @param {LinkOptions} opts */
|
|
419
|
+
const createLink = (opts) => new Link(opts);
|
|
420
|
+
|
|
421
|
+
module.exports = { createLink, Link, CODES, GrapevineError, KNOWN_APPS, fileKeyStore, memoryKeyStore, isInstalled, iconDataUrl, PROTOCOL: hs.PROTOCOL, paths };
|
package/src/keys.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Where an app keeps its pairing keys: one small file in its own data folder, private to the user,
|
|
2
|
+
// encrypted with the app's own encrypt/decrypt (Electron's safeStorage) when it has them.
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {object} KeyStore
|
|
10
|
+
* @property {boolean} [encrypted] whether keys are encrypted at rest
|
|
11
|
+
* @property {(id: string) => {key: string, pairedAt?: string} | null} get
|
|
12
|
+
* @property {(id: string, key: string) => void} set
|
|
13
|
+
* @property {(id: string) => void} delete
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} file e.g. <userData>/grapevine.json
|
|
18
|
+
* @param {{encrypt?: (text: string) => string, decrypt?: (stored: string) => string}} [crypt]
|
|
19
|
+
* encrypt returns text to store (base64, say) and decrypt reverses it. Without them, keys are stored as they are.
|
|
20
|
+
* @returns {KeyStore}
|
|
21
|
+
*/
|
|
22
|
+
function fileKeyStore(file, { encrypt, decrypt } = {}) {
|
|
23
|
+
/** @type {Record<string, {key: string, pairedAt: string, encrypted?: boolean}>} */
|
|
24
|
+
let peers = {};
|
|
25
|
+
try {
|
|
26
|
+
peers = JSON.parse(fs.readFileSync(file, 'utf8')).peers ?? {};
|
|
27
|
+
} catch {}
|
|
28
|
+
|
|
29
|
+
const save = () => {
|
|
30
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
31
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
32
|
+
fs.writeFileSync(tmp, JSON.stringify({ peers }, null, 2), { mode: 0o600 });
|
|
33
|
+
fs.renameSync(tmp, file); // all at once: a crash never leaves half a file
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
/** Whether keys are encrypted at rest, for the card to say so if not. */
|
|
38
|
+
encrypted: !!(encrypt && decrypt),
|
|
39
|
+
get(id) {
|
|
40
|
+
const p = peers[id];
|
|
41
|
+
if (!p) return null;
|
|
42
|
+
try {
|
|
43
|
+
return { key: p.encrypted && decrypt ? decrypt(p.key) : p.key, pairedAt: p.pairedAt };
|
|
44
|
+
} catch {
|
|
45
|
+
return null; // can't decrypt (another user, a new keychain): as good as not paired
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
set(id, key) {
|
|
49
|
+
peers[id] = encrypt && decrypt ? { key: encrypt(key), encrypted: true, pairedAt: new Date().toISOString() } : { key, pairedAt: new Date().toISOString() };
|
|
50
|
+
save();
|
|
51
|
+
},
|
|
52
|
+
delete(id) {
|
|
53
|
+
if (!(id in peers)) return;
|
|
54
|
+
delete peers[id];
|
|
55
|
+
save();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Keys kept in memory only: for tests and the stand-in peer. @returns {KeyStore} */
|
|
61
|
+
function memoryKeyStore() {
|
|
62
|
+
const peers = new Map();
|
|
63
|
+
return {
|
|
64
|
+
encrypted: false,
|
|
65
|
+
get: (id) => peers.get(id) ?? null,
|
|
66
|
+
set: (id, key) => void peers.set(id, { key, pairedAt: new Date().toISOString() }),
|
|
67
|
+
delete: (id) => void peers.delete(id),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { fileKeyStore, memoryKeyStore };
|