p2p-envsync 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 +21 -0
- package/README.md +201 -0
- package/backup.js +211 -0
- package/cli.js +469 -0
- package/lib.js +355 -0
- package/mesh.js +94 -0
- package/net.js +335 -0
- package/notify.js +48 -0
- package/package.json +33 -0
- package/signal.js +83 -0
- package/tray/icons.js +136 -0
- package/tray/main.js +281 -0
- package/tray/prompt.js +53 -0
package/net.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Phase 2: LAN P2P sync. No new deps -- `dgram` multicast stands in for
|
|
4
|
+
// mDNS discovery, raw `net` sockets + a room-key HMAC handshake stand in
|
|
5
|
+
// for the Noise/libp2p transport. Good enough for one office LAN; swap for
|
|
6
|
+
// libp2p if you ever need real NAT traversal or a relay path (Phase 5).
|
|
7
|
+
|
|
8
|
+
const net = require('net');
|
|
9
|
+
const dgram = require('dgram');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const lib = require('./lib');
|
|
13
|
+
const { notify } = require('./notify');
|
|
14
|
+
const backup = require('./backup');
|
|
15
|
+
const mesh = require('./mesh');
|
|
16
|
+
const signal = require('./signal');
|
|
17
|
+
|
|
18
|
+
const MCAST_ADDR = '239.255.42.99';
|
|
19
|
+
const MCAST_PORT = 41234;
|
|
20
|
+
const ANNOUNCE_INTERVAL_MS = 3000;
|
|
21
|
+
const SIGNAL_INTERVAL_MS = 10000;
|
|
22
|
+
|
|
23
|
+
function getLocalIp() {
|
|
24
|
+
const ifaces = require('os').networkInterfaces();
|
|
25
|
+
for (const list of Object.values(ifaces)) {
|
|
26
|
+
for (const iface of list) {
|
|
27
|
+
if (iface.family === 'IPv4' && !iface.internal) return iface.address;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return '127.0.0.1';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function log(...args) {
|
|
34
|
+
console.log(`[${new Date().toISOString()}]`, ...args);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function startSync(name) {
|
|
38
|
+
const config = lib.loadConfig(name);
|
|
39
|
+
if (!config.peerId) {
|
|
40
|
+
config.peerId = lib.getDeviceIdentity().publicKey;
|
|
41
|
+
lib.saveConfig(name, config);
|
|
42
|
+
}
|
|
43
|
+
const key = config.key;
|
|
44
|
+
const roomHash = lib.roomHash(key);
|
|
45
|
+
lib.recordPeer(name, config.peerId, lib.getDeviceIdentity().label);
|
|
46
|
+
|
|
47
|
+
let merged = lib.loadMerged(name);
|
|
48
|
+
// Reconcile against whatever is on disk right now instead of overwriting
|
|
49
|
+
// it: any edits made while sync wasn't running are local changes, not
|
|
50
|
+
// stale data to discard. Vault-only rooms (config.filePath === null) have
|
|
51
|
+
// no file at all -- skip this entirely, or an absent file would read as
|
|
52
|
+
// "the user deleted everything" and wipe the vault.
|
|
53
|
+
let localValues = lib.mergedToValues(merged);
|
|
54
|
+
if (config.filePath) {
|
|
55
|
+
const onDisk = fs.existsSync(config.filePath) ? lib.parseEnv(fs.readFileSync(config.filePath, 'utf8')) : {};
|
|
56
|
+
const startupDiff = lib.diffValues(lib.mergedToValues(merged), onDisk);
|
|
57
|
+
if (Object.keys(startupDiff).length > 0) {
|
|
58
|
+
const ts = Date.now();
|
|
59
|
+
for (const [k, change] of Object.entries(startupDiff)) {
|
|
60
|
+
if (change.type === 'removed') delete merged[k];
|
|
61
|
+
else merged[k] = { value: onDisk[k], ts, peer: config.peerId };
|
|
62
|
+
}
|
|
63
|
+
lib.saveMerged(name, merged);
|
|
64
|
+
lib.appendHistory(name, { ts, values: onDisk, diff: startupDiff, source: config.peerId });
|
|
65
|
+
log('picked up local changes made while sync was stopped:', startupDiff);
|
|
66
|
+
}
|
|
67
|
+
localValues = onDisk;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sockets = new Map(); // peerId -> socket
|
|
71
|
+
const connecting = new Set(); // "host:port" currently being dialed
|
|
72
|
+
|
|
73
|
+
function sendEncrypted(socket, payload) {
|
|
74
|
+
socket.write(JSON.stringify(lib.encrypt(key, payload)) + '\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function broadcastState() {
|
|
78
|
+
for (const socket of sockets.values()) sendEncrypted(socket, { type: 'state', merged });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Best-effort GitHub backup push: debounced so a burst of merges (e.g. a
|
|
82
|
+
// fresh peer's initial full-state exchange) triggers one push, not many.
|
|
83
|
+
// If there's no repo configured yet, or no internet right now, it just
|
|
84
|
+
// fails silently and the periodic retry below picks it up once connected.
|
|
85
|
+
let backupPushTimer = null;
|
|
86
|
+
function scheduleBackupPush() {
|
|
87
|
+
if (!backup.isConnected()) return;
|
|
88
|
+
clearTimeout(backupPushTimer);
|
|
89
|
+
backupPushTimer = setTimeout(() => {
|
|
90
|
+
const result = backup.pushBackup(name);
|
|
91
|
+
if (!result.ok) log(`backup push skipped (${result.reason})`);
|
|
92
|
+
}, 3000);
|
|
93
|
+
}
|
|
94
|
+
setInterval(scheduleBackupPush, 60000);
|
|
95
|
+
|
|
96
|
+
// Phase 5: async off-LAN relay. Reuses the GitHub backup as a transport,
|
|
97
|
+
// not just a backup -- periodically pull the shared repo and merge it
|
|
98
|
+
// through the exact same last-write-wins logic used for LAN peers
|
|
99
|
+
// (applyMergedUpdate), so it can never blindly overwrite local state.
|
|
100
|
+
// This is what lets two peers who are never on the same LAN converge --
|
|
101
|
+
// slower than LAN (bounded by RELAY_PULL_MS), but with no server to run.
|
|
102
|
+
const RELAY_PEER_ID = 'github-relay';
|
|
103
|
+
const RELAY_PULL_MS = 45000;
|
|
104
|
+
lib.recordPeer(name, RELAY_PEER_ID, 'GitHub backup');
|
|
105
|
+
function relayPull() {
|
|
106
|
+
if (!backup.isConnected()) return;
|
|
107
|
+
const result = backup.fetchRemoteMerged(name);
|
|
108
|
+
if (!result.ok) { log(`relay pull skipped (${result.reason})`); return; }
|
|
109
|
+
applyMergedUpdate(result.merged, RELAY_PEER_ID);
|
|
110
|
+
}
|
|
111
|
+
setInterval(relayPull, RELAY_PULL_MS);
|
|
112
|
+
relayPull();
|
|
113
|
+
|
|
114
|
+
function applyMergedUpdate(remoteMerged, sourcePeer) {
|
|
115
|
+
let changed = false;
|
|
116
|
+
const diff = {};
|
|
117
|
+
for (const [k, remoteEntry] of Object.entries(remoteMerged)) {
|
|
118
|
+
const localEntry = merged[k];
|
|
119
|
+
const remoteWins = !localEntry
|
|
120
|
+
|| remoteEntry.ts > localEntry.ts
|
|
121
|
+
|| (remoteEntry.ts === localEntry.ts && remoteEntry.peer > localEntry.peer);
|
|
122
|
+
if (remoteWins && (!localEntry || localEntry.value !== remoteEntry.value)) {
|
|
123
|
+
merged[k] = remoteEntry;
|
|
124
|
+
diff[k] = { type: localEntry ? 'changed' : 'added', from: localEntry?.value, to: remoteEntry.value, peer: remoteEntry.peer };
|
|
125
|
+
changed = true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!changed) return;
|
|
129
|
+
lib.saveMerged(name, merged);
|
|
130
|
+
localValues = lib.mergedToValues(merged);
|
|
131
|
+
if (config.filePath) fs.writeFileSync(config.filePath, lib.serializeEnv(localValues));
|
|
132
|
+
lib.appendHistory(name, { ts: Date.now(), values: localValues, diff, source: sourcePeer });
|
|
133
|
+
const sourceLabel = lib.peerLabel(name, sourcePeer);
|
|
134
|
+
log(`merged update from ${sourceLabel}:`, diff);
|
|
135
|
+
if (sourcePeer !== config.peerId) {
|
|
136
|
+
notify(`envsync: ${name}`, `${sourceLabel} changed ${Object.keys(diff).join(', ')} -- run "envsync review ${name}"`);
|
|
137
|
+
}
|
|
138
|
+
broadcastState();
|
|
139
|
+
scheduleBackupPush();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- TCP: mutual auth (prove room-key knowledge both ways) then encrypted state exchange ---
|
|
143
|
+
function attachSocket(socket, isServer) {
|
|
144
|
+
let authed = false;
|
|
145
|
+
let remotePeerId = null;
|
|
146
|
+
let buf = '';
|
|
147
|
+
const myNonce = crypto.randomBytes(16).toString('hex');
|
|
148
|
+
|
|
149
|
+
function proof(nonce) {
|
|
150
|
+
return crypto.createHmac('sha256', Buffer.from(key, 'hex')).update(nonce).digest('hex');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
socket.on('data', (chunk) => {
|
|
154
|
+
buf += chunk.toString('utf8');
|
|
155
|
+
let idx;
|
|
156
|
+
while ((idx = buf.indexOf('\n')) !== -1) {
|
|
157
|
+
const line = buf.slice(0, idx);
|
|
158
|
+
buf = buf.slice(idx + 1);
|
|
159
|
+
if (!line) continue;
|
|
160
|
+
const msg = JSON.parse(line);
|
|
161
|
+
if (!authed) {
|
|
162
|
+
if (msg.type === 'challenge') {
|
|
163
|
+
socket.write(JSON.stringify({ type: 'response', proof: proof(msg.nonce), nonce: myNonce, peerId: config.peerId, label: lib.getDeviceIdentity().label }) + '\n');
|
|
164
|
+
} else if (msg.type === 'response') {
|
|
165
|
+
if (msg.proof !== proof(myNonce)) { socket.destroy(); return; }
|
|
166
|
+
remotePeerId = msg.peerId;
|
|
167
|
+
sockets.set(remotePeerId, socket);
|
|
168
|
+
lib.recordPeer(name, msg.peerId, msg.label);
|
|
169
|
+
if (isServer) socket.write(JSON.stringify({ type: 'ack', proof: proof(msg.nonce), peerId: config.peerId, label: lib.getDeviceIdentity().label }) + '\n');
|
|
170
|
+
authed = true;
|
|
171
|
+
// Always send full state once authed, regardless of side --
|
|
172
|
+
// a brand-new joiner's empty state would otherwise never
|
|
173
|
+
// trigger the other side to send its data back.
|
|
174
|
+
sendEncrypted(socket, { type: 'state', merged });
|
|
175
|
+
} else if (msg.type === 'ack') {
|
|
176
|
+
if (msg.proof !== proof(myNonce)) { socket.destroy(); return; }
|
|
177
|
+
remotePeerId = msg.peerId;
|
|
178
|
+
sockets.set(remotePeerId, socket);
|
|
179
|
+
lib.recordPeer(name, msg.peerId, msg.label);
|
|
180
|
+
authed = true;
|
|
181
|
+
sendEncrypted(socket, { type: 'state', merged });
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const payload = lib.decrypt(key, msg);
|
|
186
|
+
if (payload.type === 'state') applyMergedUpdate(payload.merged, remotePeerId);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
socket.on('close', () => {
|
|
190
|
+
for (const [pid, s] of sockets) if (s === socket) sockets.delete(pid);
|
|
191
|
+
});
|
|
192
|
+
socket.on('error', () => socket.destroy());
|
|
193
|
+
if (isServer) socket.write(JSON.stringify({ type: 'challenge', nonce: myNonce }) + '\n');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function dialPeer(host, port) {
|
|
197
|
+
const addrKey = `${host}:${port}`;
|
|
198
|
+
if (connecting.has(addrKey)) return;
|
|
199
|
+
connecting.add(addrKey);
|
|
200
|
+
const socket = net.createConnection({ host, port }, () => {
|
|
201
|
+
log(`connected to peer at ${addrKey}`);
|
|
202
|
+
attachSocket(socket, false);
|
|
203
|
+
});
|
|
204
|
+
socket.on('error', () => connecting.delete(addrKey));
|
|
205
|
+
socket.on('close', () => connecting.delete(addrKey));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const server = net.createServer((socket) => attachSocket(socket, true));
|
|
209
|
+
server.listen(0, () => {
|
|
210
|
+
log(`TCP listening on port ${server.address().port}`);
|
|
211
|
+
|
|
212
|
+
setInterval(() => {
|
|
213
|
+
try {
|
|
214
|
+
signal.announcePresence(roomHash, {
|
|
215
|
+
peerId: config.peerId,
|
|
216
|
+
label: lib.getDeviceIdentity().label,
|
|
217
|
+
ip: getLocalIp(),
|
|
218
|
+
port: server.address().port,
|
|
219
|
+
meshIps: mesh.getMyMeshIps().map((m) => m.ip),
|
|
220
|
+
ts: Date.now(),
|
|
221
|
+
});
|
|
222
|
+
const peers = signal.discoverPeers(roomHash);
|
|
223
|
+
for (const peer of peers) {
|
|
224
|
+
if (peer.peerId === config.peerId) continue;
|
|
225
|
+
if (sockets.has(peer.peerId)) continue;
|
|
226
|
+
if (config.peerId > peer.peerId) continue;
|
|
227
|
+
if (peer.meshIps && peer.meshIps.length) {
|
|
228
|
+
for (const meshIp of peer.meshIps) dialPeer(meshIp, peer.port);
|
|
229
|
+
}
|
|
230
|
+
dialPeer(peer.ip, peer.port);
|
|
231
|
+
}
|
|
232
|
+
} catch (err) {
|
|
233
|
+
log('gist signaling failed:', err.message);
|
|
234
|
+
}
|
|
235
|
+
}, SIGNAL_INTERVAL_MS);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const udp = dgram.createSocket({ type: 'udp4', reuseAddr: true });
|
|
239
|
+
udp.on('message', (msg, rinfo) => {
|
|
240
|
+
let announce;
|
|
241
|
+
try { announce = JSON.parse(msg.toString('utf8')); } catch { return; }
|
|
242
|
+
if (announce.roomHash !== roomHash || announce.peerId === config.peerId) return;
|
|
243
|
+
if (sockets.has(announce.peerId)) return;
|
|
244
|
+
// avoid both sides dialing each other: lower peerId initiates.
|
|
245
|
+
if (config.peerId > announce.peerId) return;
|
|
246
|
+
const addrKey = `${rinfo.address}:${announce.port}`;
|
|
247
|
+
if (connecting.has(addrKey)) return;
|
|
248
|
+
connecting.add(addrKey);
|
|
249
|
+
const socket = net.createConnection({ host: rinfo.address, port: announce.port }, () => {
|
|
250
|
+
log(`connected to peer at ${addrKey}`);
|
|
251
|
+
attachSocket(socket, false);
|
|
252
|
+
});
|
|
253
|
+
socket.on('error', () => connecting.delete(addrKey));
|
|
254
|
+
socket.on('close', () => connecting.delete(addrKey));
|
|
255
|
+
});
|
|
256
|
+
udp.bind(MCAST_PORT, () => {
|
|
257
|
+
udp.addMembership(MCAST_ADDR);
|
|
258
|
+
setInterval(() => {
|
|
259
|
+
const payload = Buffer.from(JSON.stringify({ roomHash, peerId: config.peerId, port: server.address().port }));
|
|
260
|
+
udp.send(payload, MCAST_PORT, MCAST_ADDR);
|
|
261
|
+
}, ANNOUNCE_INTERVAL_MS);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// local file watch -> update merged, write history, broadcast
|
|
265
|
+
// (vault-only rooms have no file, so there's nothing to watch here --
|
|
266
|
+
// editing happens via `envsync set`/`unset` instead, picked up below)
|
|
267
|
+
if (config.filePath) {
|
|
268
|
+
fs.watchFile(config.filePath, { interval: 1000 }, () => {
|
|
269
|
+
if (!fs.existsSync(config.filePath)) return;
|
|
270
|
+
const next = lib.parseEnv(fs.readFileSync(config.filePath, 'utf8'));
|
|
271
|
+
const diff = lib.diffValues(localValues, next);
|
|
272
|
+
if (Object.keys(diff).length === 0) return;
|
|
273
|
+
const ts = Date.now();
|
|
274
|
+
for (const [k, change] of Object.entries(diff)) {
|
|
275
|
+
if (change.type === 'removed') delete merged[k];
|
|
276
|
+
else merged[k] = { value: next[k], ts, peer: config.peerId };
|
|
277
|
+
}
|
|
278
|
+
lib.saveMerged(name, merged);
|
|
279
|
+
localValues = next;
|
|
280
|
+
lib.appendHistory(name, { ts, values: next, diff, source: config.peerId });
|
|
281
|
+
log('local change:', diff);
|
|
282
|
+
broadcastState();
|
|
283
|
+
scheduleBackupPush();
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// `envsync set`/`unset` run from another terminal write straight to the
|
|
288
|
+
// vault (lib.setValue/unsetValue), bypassing this process's in-memory
|
|
289
|
+
// state entirely. Watch the vault file itself so a running `sync` still
|
|
290
|
+
// notices, merges, and broadcasts those edits -- this is the only edit
|
|
291
|
+
// path for vault-only rooms, and works for file-mode rooms too.
|
|
292
|
+
fs.watchFile(lib.mergedFile(name), { interval: 1000 }, () => {
|
|
293
|
+
let fresh;
|
|
294
|
+
try { fresh = lib.loadMerged(name); } catch { return; }
|
|
295
|
+
const diff = {};
|
|
296
|
+
let changed = false;
|
|
297
|
+
for (const [k, entry] of Object.entries(fresh)) {
|
|
298
|
+
if (!merged[k] || merged[k].value !== entry.value) {
|
|
299
|
+
diff[k] = { type: merged[k] ? 'changed' : 'added', from: merged[k]?.value, to: entry.value };
|
|
300
|
+
changed = true;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
for (const k of Object.keys(merged)) {
|
|
304
|
+
if (!(k in fresh)) { diff[k] = { type: 'removed' }; changed = true; }
|
|
305
|
+
}
|
|
306
|
+
if (!changed) return;
|
|
307
|
+
merged = fresh;
|
|
308
|
+
localValues = lib.mergedToValues(merged);
|
|
309
|
+
if (config.filePath) fs.writeFileSync(config.filePath, lib.serializeEnv(localValues));
|
|
310
|
+
log('vault edited directly (set/unset):', diff);
|
|
311
|
+
broadcastState();
|
|
312
|
+
scheduleBackupPush();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
log(`syncing room "${name}" (peer ${config.peerId}, room ${roomHash.slice(0, 8)}...)`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function startAllRooms() {
|
|
319
|
+
const rooms = lib.listRoomStatuses();
|
|
320
|
+
if (!rooms.length) {
|
|
321
|
+
log('no rooms found -- daemon staying alive with nothing to sync yet');
|
|
322
|
+
setInterval(() => {}, 60000);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
for (const { name } of rooms) {
|
|
326
|
+
try {
|
|
327
|
+
startSync(name);
|
|
328
|
+
log(`daemon: started sync for room "${name}"`);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
log(`daemon: skipping room "${name}" (${err.message})`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
module.exports = { startSync, startAllRooms };
|
package/notify.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Native OS notifications -- no GUI framework, no new deps. Each platform's
|
|
4
|
+
// own bundled tool does the work: osascript (mac), notify-send (Linux,
|
|
5
|
+
// ships with most desktop distros), PowerShell toast (Windows 10+).
|
|
6
|
+
|
|
7
|
+
const { execFileSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
function escapeAppleScript(str) {
|
|
10
|
+
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function notifyDarwin(title, message) {
|
|
14
|
+
execFileSync('osascript', ['-e', `display notification "${escapeAppleScript(message)}" with title "${escapeAppleScript(title)}"`]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function notifyLinux(title, message) {
|
|
18
|
+
execFileSync('notify-send', [title, message]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ponytail: shells out to powershell for a toast; untested on real Windows,
|
|
22
|
+
// fix on first report if the XML namespace/API surface has drifted.
|
|
23
|
+
function notifyWindows(title, message) {
|
|
24
|
+
const escape = (s) => s.replace(/'/g, "''");
|
|
25
|
+
const script = `
|
|
26
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
|
27
|
+
$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
|
|
28
|
+
$texts = $template.GetElementsByTagName('text')
|
|
29
|
+
$texts.Item(0).AppendChild($template.CreateTextNode('${escape(title)}')) > $null
|
|
30
|
+
$texts.Item(1).AppendChild($template.CreateTextNode('${escape(message)}')) > $null
|
|
31
|
+
$toast = [Windows.UI.Notifications.ToastNotification]::new($template)
|
|
32
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('envsync').Show($toast)
|
|
33
|
+
`;
|
|
34
|
+
execFileSync('powershell', ['-NoProfile', '-Command', script]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function notify(title, message) {
|
|
38
|
+
try {
|
|
39
|
+
if (process.platform === 'darwin') return notifyDarwin(title, message);
|
|
40
|
+
if (process.platform === 'linux') return notifyLinux(title, message);
|
|
41
|
+
if (process.platform === 'win32') return notifyWindows(title, message);
|
|
42
|
+
} catch {
|
|
43
|
+
// fall through to console
|
|
44
|
+
}
|
|
45
|
+
console.log(`[notify] ${title}: ${message}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { notify };
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "p2p-envsync",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "P2P encrypted .env sync for dev teams — LAN-first, offline-first, zero server",
|
|
5
|
+
"keywords": ["env", "dotenv", "sync", "p2p", "encrypted", "secrets", "team", "developer-tools"],
|
|
6
|
+
"author": "RismanRJ",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"type": "commonjs",
|
|
9
|
+
"bin": {
|
|
10
|
+
"envsync": "./cli.js"
|
|
11
|
+
},
|
|
12
|
+
"main": "tray/main.js",
|
|
13
|
+
"files": ["lib.js", "cli.js", "net.js", "backup.js", "notify.js", "mesh.js", "signal.js", "tray/", "LICENSE"],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test test/",
|
|
16
|
+
"tray": "electron ."
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/RismanRJ/envsync.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/RismanRJ/envsync#readme",
|
|
23
|
+
"bugs": "https://github.com/RismanRJ/envsync/issues",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18.0.0"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"qrcode-terminal": "^0.12.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"electron": "^41.7.1"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/signal.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { execFileSync } = require('child_process');
|
|
7
|
+
const backup = require('./backup.js');
|
|
8
|
+
|
|
9
|
+
const STALE_MS = 60 * 1000;
|
|
10
|
+
const gistIdCache = {};
|
|
11
|
+
|
|
12
|
+
function descFor(roomHash) {
|
|
13
|
+
return `envsync-signal-${roomHash.slice(0, 16)}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function tmpFile(contents) {
|
|
17
|
+
const file = path.join(os.tmpdir(), `envsync-peers-${process.pid}-${Date.now()}.json`);
|
|
18
|
+
fs.writeFileSync(file, contents);
|
|
19
|
+
return file;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function findSignalGist(roomHash) {
|
|
23
|
+
if (gistIdCache[roomHash]) return gistIdCache[roomHash];
|
|
24
|
+
const desc = descFor(roomHash);
|
|
25
|
+
try {
|
|
26
|
+
const gists = JSON.parse(execFileSync('gh', ['api', '/gists?per_page=100']).toString());
|
|
27
|
+
const match = gists.find(g => g.description === desc);
|
|
28
|
+
if (!match) return null;
|
|
29
|
+
gistIdCache[roomHash] = match.id;
|
|
30
|
+
return match.id;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readGistPeers(gistId) {
|
|
37
|
+
try {
|
|
38
|
+
const data = JSON.parse(execFileSync('gh', ['api', `/gists/${gistId}`]).toString());
|
|
39
|
+
const content = data.files && data.files['peers.json'] && data.files['peers.json'].content;
|
|
40
|
+
return content ? JSON.parse(content) : {};
|
|
41
|
+
} catch {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function announcePresence(roomHash, announcement) {
|
|
47
|
+
if (!backup.isGhAuthenticated()) return;
|
|
48
|
+
const desc = descFor(roomHash);
|
|
49
|
+
let file;
|
|
50
|
+
try {
|
|
51
|
+
let gistId = findSignalGist(roomHash);
|
|
52
|
+
if (!gistId) {
|
|
53
|
+
file = tmpFile(JSON.stringify({ [announcement.peerId]: announcement }));
|
|
54
|
+
const out = execFileSync('gh', ['gist', 'create', '--filename', 'peers.json', '--desc', desc, '--public=false', file]).toString();
|
|
55
|
+
const match = out.trim().match(/([a-f0-9]{20,})/);
|
|
56
|
+
if (match) gistIdCache[roomHash] = match[1];
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const peers = readGistPeers(gistId);
|
|
60
|
+
peers[announcement.peerId] = announcement;
|
|
61
|
+
file = tmpFile(JSON.stringify(peers));
|
|
62
|
+
execFileSync('gh', ['gist', 'edit', gistId, '-f', 'peers.json', file]);
|
|
63
|
+
} catch {
|
|
64
|
+
// no internet, not authed, rate limited -- signaling is best-effort
|
|
65
|
+
} finally {
|
|
66
|
+
if (file) { try { fs.unlinkSync(file); } catch {} }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function discoverPeers(roomHash) {
|
|
71
|
+
if (!backup.isGhAuthenticated()) return [];
|
|
72
|
+
try {
|
|
73
|
+
const gistId = findSignalGist(roomHash);
|
|
74
|
+
if (!gistId) return [];
|
|
75
|
+
const peers = readGistPeers(gistId);
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
return Object.values(peers).filter(p => now - p.ts < STALE_MS);
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { announcePresence, discoverPeers, findSignalGist };
|
package/tray/icons.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Renders a "sync" glyph (two circular arrows, the universal refresh/sync
|
|
4
|
+
// symbol) as a solid-color PNG -- pure pixel math + Node's built-in zlib,
|
|
5
|
+
// no image/canvas library. Supersampled 4x then box-downsampled for
|
|
6
|
+
// anti-aliased edges at tray-icon size.
|
|
7
|
+
|
|
8
|
+
const zlib = require('zlib');
|
|
9
|
+
|
|
10
|
+
const CRC_TABLE = (() => {
|
|
11
|
+
const table = new Uint32Array(256);
|
|
12
|
+
for (let n = 0; n < 256; n++) {
|
|
13
|
+
let c = n;
|
|
14
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
15
|
+
table[n] = c;
|
|
16
|
+
}
|
|
17
|
+
return table;
|
|
18
|
+
})();
|
|
19
|
+
|
|
20
|
+
function crc32(buf) {
|
|
21
|
+
let crc = 0xffffffff;
|
|
22
|
+
for (const byte of buf) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
23
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function chunk(type, data) {
|
|
27
|
+
const typeBuf = Buffer.from(type, 'ascii');
|
|
28
|
+
const len = Buffer.alloc(4);
|
|
29
|
+
len.writeUInt32BE(data.length);
|
|
30
|
+
const crc = Buffer.alloc(4);
|
|
31
|
+
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])));
|
|
32
|
+
return Buffer.concat([len, typeBuf, data, crc]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function encodePng(pixels, size) {
|
|
36
|
+
const raw = Buffer.alloc(size * (1 + size * 4));
|
|
37
|
+
for (let y = 0; y < size; y++) {
|
|
38
|
+
const rowStart = y * (1 + size * 4);
|
|
39
|
+
raw[rowStart] = 0; // filter: none
|
|
40
|
+
for (let x = 0; x < size; x++) {
|
|
41
|
+
const [r, g, b, a] = pixels[y * size + x];
|
|
42
|
+
const o = rowStart + 1 + x * 4;
|
|
43
|
+
raw[o] = r; raw[o + 1] = g; raw[o + 2] = b; raw[o + 3] = a;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const ihdr = Buffer.alloc(13);
|
|
47
|
+
ihdr.writeUInt32BE(size, 0);
|
|
48
|
+
ihdr.writeUInt32BE(size, 4);
|
|
49
|
+
ihdr[8] = 8; ihdr[9] = 6; // 8-bit depth, RGBA color type
|
|
50
|
+
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
51
|
+
return Buffer.concat([
|
|
52
|
+
signature,
|
|
53
|
+
chunk('IHDR', ihdr),
|
|
54
|
+
chunk('IDAT', zlib.deflateSync(raw)),
|
|
55
|
+
chunk('IEND', Buffer.alloc(0)),
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function angleDelta(a, b) {
|
|
60
|
+
let d = Math.abs(a - b) % 360;
|
|
61
|
+
return d > 180 ? 360 - d : d;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// One arc of the sync glyph: a ring segment from `start` to `end` degrees,
|
|
65
|
+
// with a triangular arrowhead at the `end`.
|
|
66
|
+
function arcCoverage(px, py, cx, cy, radius, thickness, start, end, arrowSize) {
|
|
67
|
+
const dx = px - cx;
|
|
68
|
+
const dy = py - cy;
|
|
69
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
70
|
+
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
71
|
+
if (angle < 0) angle += 360;
|
|
72
|
+
|
|
73
|
+
const within = (a, s, e) => {
|
|
74
|
+
const span = ((e - s) % 360 + 360) % 360;
|
|
75
|
+
const rel = ((a - s) % 360 + 360) % 360;
|
|
76
|
+
return rel <= span;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (within(angle, start, end) && Math.abs(dist - radius) <= thickness / 2) return true;
|
|
80
|
+
|
|
81
|
+
// arrowhead: a triangle centered on `end`, widening as it nears the tip radius
|
|
82
|
+
const tipDelta = angleDelta(angle, end);
|
|
83
|
+
const arrowAngularWidth = (arrowSize / radius) * (180 / Math.PI) * 1.8;
|
|
84
|
+
if (tipDelta <= arrowAngularWidth) {
|
|
85
|
+
const taper = 1 - tipDelta / arrowAngularWidth;
|
|
86
|
+
const halfBand = (thickness / 2) + arrowSize * taper;
|
|
87
|
+
if (Math.abs(dist - radius) <= halfBand) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function renderSyncGlyph(rgb, size) {
|
|
93
|
+
const SS = 4; // supersampling factor
|
|
94
|
+
const big = size * SS;
|
|
95
|
+
const cx = big / 2;
|
|
96
|
+
const cy = big / 2;
|
|
97
|
+
const radius = big * 0.32;
|
|
98
|
+
const thickness = big * 0.11;
|
|
99
|
+
const arrowSize = big * 0.09;
|
|
100
|
+
|
|
101
|
+
const bigPixels = new Array(big * big);
|
|
102
|
+
for (let y = 0; y < big; y++) {
|
|
103
|
+
for (let x = 0; x < big; x++) {
|
|
104
|
+
const hit = arcCoverage(x, y, cx, cy, radius, thickness, 15, 165, arrowSize)
|
|
105
|
+
|| arcCoverage(x, y, cx, cy, radius, thickness, 195, 345, arrowSize);
|
|
106
|
+
bigPixels[y * big + x] = hit ? [...rgb, 255] : [0, 0, 0, 0];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// box downsample for anti-aliasing
|
|
111
|
+
const pixels = new Array(size * size);
|
|
112
|
+
for (let y = 0; y < size; y++) {
|
|
113
|
+
for (let x = 0; x < size; x++) {
|
|
114
|
+
let r = 0, g = 0, b = 0, a = 0;
|
|
115
|
+
for (let sy = 0; sy < SS; sy++) {
|
|
116
|
+
for (let sx = 0; sx < SS; sx++) {
|
|
117
|
+
const p = bigPixels[(y * SS + sy) * big + (x * SS + sx)];
|
|
118
|
+
r += p[0]; g += p[1]; b += p[2]; a += p[3];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const n = SS * SS;
|
|
122
|
+
pixels[y * size + x] = [Math.round(r / n), Math.round(g / n), Math.round(b / n), Math.round(a / n)];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return encodePng(pixels, size);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function dataUrl(rgb) {
|
|
129
|
+
return `data:image/png;base64,${renderSyncGlyph(rgb, 32).toString('base64')}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
green: dataUrl([48, 209, 88]),
|
|
134
|
+
yellow: dataUrl([255, 190, 10]),
|
|
135
|
+
gray: dataUrl([142, 142, 147]),
|
|
136
|
+
};
|