pal-explorer-cli 0.4.6 → 0.4.8

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.
@@ -0,0 +1,203 @@
1
+ import zlib from 'zlib';
2
+ import { promisify } from 'util';
3
+ import { sendAuthenticatedRequest } from './signalingServer.js';
4
+ import { selectBestTransport, TRANSPORT } from './streamTransport.js';
5
+ import { getNearbyPeers } from './mdnsService.js';
6
+ import { getPrimaryServer } from './discoveryClient.js';
7
+ import config from '../utils/config.js';
8
+ import logger from '../utils/logger.js';
9
+
10
+ const gzip = promisify(zlib.gzip);
11
+ const gunzip = promisify(zlib.gunzip);
12
+
13
+ // Base transport class — all sync transports implement send() and sendFile()
14
+ class SyncTransportBase {
15
+ constructor(type) {
16
+ this.type = type;
17
+ }
18
+ async send(envelope) { throw new Error('Not implemented'); }
19
+ async sendFile(filePath, relativePath, opts) { throw new Error('Not implemented'); }
20
+ async sendChunk(data) { throw new Error('Not implemented'); }
21
+ close() {}
22
+ }
23
+
24
+ // LAN transport — send PAL/1.0 envelopes via signaling server TCP (port 7474)
25
+ export class LanTransport extends SyncTransportBase {
26
+ constructor(peerAddress) {
27
+ super('lan');
28
+ const [host, port] = peerAddress.split(':');
29
+ this.host = host;
30
+ this.port = parseInt(port, 10) || 7474;
31
+ }
32
+
33
+ async send(envelope) {
34
+ const identity = config.get('identity');
35
+ if (!identity?.privateKey || !identity?.publicKey) {
36
+ throw new Error('Identity not configured');
37
+ }
38
+ const result = await sendAuthenticatedRequest(
39
+ this.host, this.port,
40
+ { type: 'message', envelope },
41
+ identity.privateKey, identity.publicKey, 10000
42
+ );
43
+ if (result.error) throw new Error(result.error);
44
+ return result;
45
+ }
46
+
47
+ async sendFile(readStream, relativePath, { onChunk } = {}) {
48
+ // File data is sent as sync.request response chunks via the same TCP protocol
49
+ // For LAN, we embed file data in the envelope payload (chunked for large files)
50
+ const chunks = [];
51
+ for await (const chunk of readStream) {
52
+ chunks.push(chunk);
53
+ if (onChunk) onChunk(chunk.length);
54
+ }
55
+ return Buffer.concat(chunks);
56
+ }
57
+ }
58
+
59
+ // WebRTC transport — send via WebRTC data channel (for internet peers)
60
+ export class WebRTCTransport extends SyncTransportBase {
61
+ constructor(iceServers, localPK, remotePK) {
62
+ super('webrtc');
63
+ this.iceServers = iceServers;
64
+ this.localPK = localPK;
65
+ this.remotePK = remotePK;
66
+ this.dataChannel = null;
67
+ }
68
+
69
+ async send(envelope) {
70
+ if (!this.dataChannel) {
71
+ throw new Error('WebRTC data channel not established');
72
+ }
73
+ const data = JSON.stringify(envelope);
74
+ this.dataChannel.send(data);
75
+ return { received: true };
76
+ }
77
+
78
+ async sendChunk(data) {
79
+ if (!this.dataChannel) {
80
+ throw new Error('WebRTC data channel not established');
81
+ }
82
+ // 16KB chunks with backpressure
83
+ const CHUNK_SIZE = 16384;
84
+ for (let offset = 0; offset < data.length; offset += CHUNK_SIZE) {
85
+ const chunk = data.subarray(offset, offset + CHUNK_SIZE);
86
+ while (this.dataChannel.bufferedAmount > 1024 * 1024) {
87
+ await new Promise(r => setTimeout(r, 10));
88
+ }
89
+ this.dataChannel.send(chunk);
90
+ }
91
+ }
92
+
93
+ setDataChannel(dc) {
94
+ this.dataChannel = dc;
95
+ }
96
+
97
+ close() {
98
+ if (this.dataChannel) {
99
+ try { this.dataChannel.close(); } catch {}
100
+ this.dataChannel = null;
101
+ }
102
+ }
103
+ }
104
+
105
+ // Discovery transport — fallback to HTTP via discovery server (extension-only)
106
+ export class DiscoveryTransport extends SyncTransportBase {
107
+ constructor(toHandle, fromHandle) {
108
+ super('discovery');
109
+ this.toHandle = toHandle;
110
+ this.fromHandle = fromHandle;
111
+ }
112
+
113
+ async send(envelope) {
114
+ const serverUrl = getPrimaryServer();
115
+ const res = await fetch(`${serverUrl}/messages`, {
116
+ method: 'POST',
117
+ headers: { 'Content-Type': 'application/json' },
118
+ body: JSON.stringify({
119
+ toHandle: this.toHandle,
120
+ fromHandle: this.fromHandle,
121
+ payload: envelope,
122
+ }),
123
+ signal: AbortSignal.timeout(15000),
124
+ });
125
+ if (!res.ok) {
126
+ const err = await res.json().catch(() => ({}));
127
+ throw new Error(`Discovery server error: ${res.status} ${err.error || res.statusText}`);
128
+ }
129
+ return { received: true };
130
+ }
131
+ }
132
+
133
+ // Select the best available transport for a given peer
134
+ export async function selectSyncTransport(peerPK, { peerHandle } = {}) {
135
+ const identity = config.get('identity');
136
+
137
+ // Try P2P transports first (LAN, then WebRTC)
138
+ try {
139
+ const best = await selectBestTransport(peerPK);
140
+ if (best) {
141
+ if (best.transport === TRANSPORT.LAN) {
142
+ return new LanTransport(best.address);
143
+ }
144
+ if (best.transport === TRANSPORT.P2P) {
145
+ return new WebRTCTransport(best.iceServers, identity?.publicKey, peerPK);
146
+ }
147
+ }
148
+ } catch (err) {
149
+ logger.debug(`P2P transport selection failed: ${err.message}`);
150
+ }
151
+
152
+ // Fallback to discovery server if peer has a handle
153
+ if (peerHandle && identity?.handle) {
154
+ return new DiscoveryTransport(peerHandle, identity.handle);
155
+ }
156
+
157
+ throw new Error('No available transport to reach peer. Ensure both peers are on the same LAN or have Pro for internet sync.');
158
+ }
159
+
160
+ // Find a peer's LAN address by public key (used for direct connection)
161
+ export function findLanPeer(peerPK) {
162
+ const nearby = getNearbyPeers();
163
+ return nearby.find(p => p.publicKey === peerPK) || null;
164
+ }
165
+
166
+ // --- Compression helpers ---
167
+
168
+ export async function compressData(data) {
169
+ return gzip(data);
170
+ }
171
+
172
+ export async function decompressData(data) {
173
+ return gunzip(data);
174
+ }
175
+
176
+ // --- Bandwidth throttle (token bucket) ---
177
+
178
+ export class BandwidthThrottle {
179
+ constructor(kbps) {
180
+ this.bytesPerMs = (kbps * 1024) / 1000;
181
+ this.tokens = kbps * 1024; // start with 1 second of tokens
182
+ this.lastRefill = Date.now();
183
+ }
184
+
185
+ async throttle(bytes) {
186
+ if (this.bytesPerMs <= 0) return; // no limit
187
+
188
+ // Refill tokens
189
+ const now = Date.now();
190
+ const elapsed = now - this.lastRefill;
191
+ this.tokens = Math.min(this.bytesPerMs * 1000, this.tokens + elapsed * this.bytesPerMs);
192
+ this.lastRefill = now;
193
+
194
+ // Wait if we don't have enough tokens
195
+ if (this.tokens < bytes) {
196
+ const waitMs = Math.ceil((bytes - this.tokens) / this.bytesPerMs);
197
+ await new Promise(r => setTimeout(r, waitMs));
198
+ this.tokens = 0;
199
+ } else {
200
+ this.tokens -= bytes;
201
+ }
202
+ }
203
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pal-explorer-cli",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "P2P encrypted file sharing CLI — share files directly with friends, not with the cloud",
5
5
  "main": "bin/pal.js",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "bin/",
11
11
  "lib/",
12
- "extensions/",
12
+ "extensions/@palexplorer/*/extension.json",
13
+ "extensions/@palexplorer/*/index.js",
13
14
  "LICENSE.md"
14
15
  ],
15
16
  "scripts": {
@@ -1,45 +0,0 @@
1
- # Analytics Extension
2
-
3
- Anonymous, opt-in usage analytics for Palexplorer via PostHog.
4
-
5
- ## Privacy Guarantees
6
-
7
- - **Opt-in only** — disabled by default, must be explicitly enabled
8
- - **No PII** — device ID is a random UUID, not tied to identity
9
- - **No content** — never tracks file names, share names, peer identities, or message content
10
- - **No IP logging** — PostHog configured to anonymize IPs
11
- - **Transparent** — all tracked events listed below
12
-
13
- ## Events Tracked
14
-
15
- | Event | Properties | When |
16
- |-------|-----------|------|
17
- | `app_open` | platform, version, arch | App starts |
18
- | `app_close` | sessionDurationSec | App closes |
19
- | `share_created` | visibility, recipientCount | Share created |
20
- | `share_revoked` | — | Share revoked |
21
- | `transfer_complete` | size, duration, speed | Download finishes |
22
- | `peer_connected` | — | Peer connects |
23
- | `peer_disconnected` | — | Peer disconnects |
24
- | `settings_changed` | key (setting name only) | Setting modified |
25
-
26
- ## Configuration
27
-
28
- | Key | Type | Default | Description |
29
- |-----|------|---------|-------------|
30
- | `enabled` | boolean | `false` | Enable analytics (opt-in) |
31
- | `posthogKey` | string | (built-in) | PostHog project API key |
32
- | `posthogHost` | string | `https://us.i.posthog.com` | PostHog host |
33
- | `sessionTracking` | boolean | `true` | Track session duration |
34
-
35
- ```bash
36
- pe ext config analytics enabled true
37
- pe ext config analytics sessionTracking false
38
- ```
39
-
40
- ## How to Opt Out
41
-
42
- ```bash
43
- pe ext config analytics enabled false
44
- ```
45
- Or toggle "Usage Analytics" in Settings > Privacy.
@@ -1,14 +0,0 @@
1
- # Analytics Extension — Monetization
2
-
3
- ## Tier
4
-
5
- - [x] Free — available to all users
6
-
7
- ## Rationale
8
-
9
- Analytics benefits us (product decisions, bug detection, growth tracking), not the user. It must be free and opt-in to maintain trust. Charging for analytics would be counterproductive — we want maximum opt-in rate.
10
-
11
- ## Revenue Potential
12
-
13
- - No direct revenue
14
- - Indirect value: data-driven feature prioritization, retention insights, conversion tracking for Pro upsells
@@ -1,23 +0,0 @@
1
- # Analytics Extension — Plan
2
-
3
- ## Goal
4
-
5
- Move PostHog analytics out of core into an extension. Core should be pure P2P with no server dependencies. Analytics phones home to PostHog, so it must be an extension.
6
-
7
- ## Design
8
-
9
- - Bundled extension (`@palexplorer/analytics`) — runs in-process, no sandbox
10
- - Listens to existing core hooks — no changes to core event emission needed
11
- - PostHog client initialized only when enabled (opt-in)
12
- - Device ID stored in extension's own store (not core config)
13
- - Offline queue with periodic flush — same pattern as before
14
- - Error reporting (`reportCrash`/`reportError`) stays in core as a safety feature
15
-
16
- ## What Changed
17
-
18
- - Moved from: `lib/core/analytics.js` (PostHog + track functions)
19
- - Moved to: `extensions/@palexplorer/analytics/index.js`
20
- - Core `analytics.js` slimmed to error reporting only
21
- - `posthog-node` dependency moved from root to extension
22
- - Removed direct `track()` imports from `shares.js`, `transfers.js`, `billing.js`
23
- - Those events now flow through hooks → analytics extension
@@ -1,38 +0,0 @@
1
- # Analytics Extension — Privacy
2
-
3
- ## What We Collect
4
-
5
- - App open/close events with session duration
6
- - Feature usage counts (shares, transfers, peer connections)
7
- - Setting change events (key name only, not values)
8
- - Platform, app version, architecture
9
-
10
- ## What We Never Collect
11
-
12
- - File names, paths, or content
13
- - Share names, IDs, or recipients
14
- - Peer identities, handles, or public keys
15
- - IP addresses (PostHog IP anonymization enabled)
16
- - Messages or chat content
17
- - Encryption keys or credentials
18
- - Browsing/usage patterns that could identify individuals
19
-
20
- ## Device ID
21
-
22
- A random UUID generated on first use. Not derived from hardware, identity, or any personal data. Stored locally in the extension's store. Can be reset by disabling and re-enabling the extension.
23
-
24
- ## Data Flow
25
-
26
- ```
27
- App Events → Core Hooks → Analytics Extension → PostHog (US Cloud)
28
- ```
29
-
30
- No data is sent to Palexplorer servers. Only PostHog receives analytics data.
31
-
32
- ## User Control
33
-
34
- - Disabled by default (opt-in required)
35
- - Toggle in Settings > Privacy
36
- - First-launch setup wizard asks for consent
37
- - Can be disabled at any time via GUI or CLI
38
- - Disabling immediately stops all data collection
@@ -1,82 +0,0 @@
1
- import { describe, it, beforeEach, afterEach, mock } from 'node:test';
2
- import assert from 'node:assert/strict';
3
-
4
- function createMockContext(overrides = {}) {
5
- const config = { enabled: true, posthogKey: '', posthogHost: '', sessionTracking: true };
6
- return {
7
- hooks: { on: mock.fn() },
8
- config: {
9
- get: mock.fn((key) => config[key]),
10
- set: mock.fn((key, val) => { config[key] = val; }),
11
- },
12
- store: {
13
- get: mock.fn(() => undefined),
14
- set: mock.fn(),
15
- delete: mock.fn(),
16
- },
17
- logger: { info: mock.fn(), warn: mock.fn(), error: mock.fn() },
18
- app: { version: '0.5.0', platform: 'linux', dataDir: '/tmp/test', appRoot: '/tmp/test' },
19
- ...overrides,
20
- };
21
- }
22
-
23
- describe('analytics extension', () => {
24
- let ext;
25
- let ctx;
26
-
27
- beforeEach(async () => {
28
- ext = await import('../index.js');
29
- ctx = createMockContext();
30
- });
31
-
32
- afterEach(async () => {
33
- await ext.deactivate();
34
- });
35
-
36
- it('should register all expected hooks when enabled', async () => {
37
- await ext.activate(ctx);
38
- const hookNames = ctx.hooks.on.mock.calls.map(c => c.arguments[0]);
39
- assert.ok(hookNames.includes('on:app:ready'));
40
- assert.ok(hookNames.includes('on:app:shutdown'));
41
- assert.ok(hookNames.includes('after:share:create'));
42
- assert.ok(hookNames.includes('after:download:complete'));
43
- assert.ok(hookNames.includes('on:config:change'));
44
- });
45
-
46
- it('should still register hooks when disabled (for hot-enable support)', async () => {
47
- ctx.config.get = mock.fn((key) => key === 'enabled' ? false : undefined);
48
- await ext.activate(ctx);
49
- // Hooks are always registered so analytics can be hot-enabled via config change
50
- const hookNames = ctx.hooks.on.mock.calls.map(c => c.arguments[0]);
51
- assert.ok(hookNames.includes('on:config:change'));
52
- assert.ok(hookNames.includes('on:app:shutdown'));
53
- });
54
-
55
- it('should deactivate cleanly', async () => {
56
- await ext.activate(ctx);
57
- await ext.deactivate();
58
- });
59
-
60
- it('should generate and persist device ID', async () => {
61
- await ext.activate(ctx);
62
- // Device ID is generated on first track call — trigger via hook
63
- // The store.set should be called with deviceId
64
- const setCalls = ctx.store.set.mock.calls;
65
- const deviceIdCall = setCalls.find(c => c.arguments[0] === 'deviceId');
66
- if (deviceIdCall) {
67
- assert.ok(deviceIdCall.arguments[1].length > 0);
68
- }
69
- });
70
-
71
- it('should reuse existing device ID', async () => {
72
- const existingId = 'test-uuid-1234';
73
- ctx.store.get = mock.fn((key) => key === 'deviceId' ? existingId : undefined);
74
- await ext.activate(ctx);
75
- });
76
-
77
- it('should queue events when offline', async () => {
78
- await ext.activate(ctx);
79
- ext.setOnline(false);
80
- ext.setOnline(true);
81
- });
82
- });