pal-explorer-cli 0.4.10 → 0.4.12
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/extensions/@palexplorer/analytics/README.md +45 -0
- package/extensions/@palexplorer/analytics/docs/MONETIZATION.md +14 -0
- package/extensions/@palexplorer/analytics/docs/PLAN.md +23 -0
- package/extensions/@palexplorer/analytics/docs/PRIVACY.md +38 -0
- package/extensions/@palexplorer/analytics/test/analytics.test.js +82 -0
- package/lib/commands/sync.js +3 -3
- package/lib/commands/web-login.js +1 -1
- package/package.json +12 -3
- package/extensions/@palexplorer/explorer-integration/extension.json +0 -13
- package/extensions/@palexplorer/explorer-integration/index.js +0 -122
- package/extensions/@palexplorer/networks/extension.json +0 -17
- package/extensions/@palexplorer/networks/index.js +0 -2
- package/extensions/@palexplorer/vfs/extension.json +0 -17
- package/extensions/@palexplorer/vfs/index.js +0 -167
|
@@ -0,0 +1,45 @@
|
|
|
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.
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
});
|
package/lib/commands/sync.js
CHANGED
|
@@ -114,7 +114,7 @@ async function biSync(dirPath, palName, opts) {
|
|
|
114
114
|
try {
|
|
115
115
|
absolutePath = validateDir(dirPath);
|
|
116
116
|
pal = findPal(palName);
|
|
117
|
-
identity = getIdentity();
|
|
117
|
+
identity = await getIdentity();
|
|
118
118
|
keyPair = getKeyPair();
|
|
119
119
|
} catch (err) {
|
|
120
120
|
console.log(chalk.red(err.message));
|
|
@@ -201,7 +201,7 @@ async function pushSync(dirPath, palName, opts) {
|
|
|
201
201
|
try {
|
|
202
202
|
absolutePath = validateDir(dirPath);
|
|
203
203
|
pal = findPal(palName);
|
|
204
|
-
identity = getIdentity();
|
|
204
|
+
identity = await getIdentity();
|
|
205
205
|
keyPair = getKeyPair();
|
|
206
206
|
} catch (err) {
|
|
207
207
|
console.log(chalk.red(err.message));
|
|
@@ -305,7 +305,7 @@ async function pullSync(dirPath, palName, opts) {
|
|
|
305
305
|
try {
|
|
306
306
|
absolutePath = validateDir(dirPath);
|
|
307
307
|
pal = findPal(palName);
|
|
308
|
-
identity = getIdentity();
|
|
308
|
+
identity = await getIdentity();
|
|
309
309
|
keyPair = getKeyPair();
|
|
310
310
|
} catch (err) {
|
|
311
311
|
console.log(chalk.red(err.message));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pal-explorer-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.12",
|
|
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,8 +9,17 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/",
|
|
11
11
|
"lib/",
|
|
12
|
-
"extensions/@palexplorer
|
|
13
|
-
"extensions/@palexplorer
|
|
12
|
+
"extensions/@palexplorer/analytics/",
|
|
13
|
+
"extensions/@palexplorer/audit/",
|
|
14
|
+
"extensions/@palexplorer/auth-email/",
|
|
15
|
+
"extensions/@palexplorer/auth-oauth/",
|
|
16
|
+
"extensions/@palexplorer/chat/",
|
|
17
|
+
"extensions/@palexplorer/discovery/",
|
|
18
|
+
"extensions/@palexplorer/email-notifications/",
|
|
19
|
+
"extensions/@palexplorer/groups/",
|
|
20
|
+
"extensions/@palexplorer/share-links/",
|
|
21
|
+
"extensions/@palexplorer/sync/",
|
|
22
|
+
"extensions/@palexplorer/user-mgmt/",
|
|
14
23
|
"LICENSE.md"
|
|
15
24
|
],
|
|
16
25
|
"scripts": {
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "explorer-integration",
|
|
3
|
-
"version": "1.0.0",
|
|
4
|
-
"description": "Add 'Share with Pal' to your file manager context menu (Explorer, Finder, Nautilus)",
|
|
5
|
-
"author": "Palexplorer Team",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"main": "index.js",
|
|
8
|
-
"hooks": [],
|
|
9
|
-
"permissions": ["fs:write"],
|
|
10
|
-
"config": {},
|
|
11
|
-
"tier": "pro",
|
|
12
|
-
"minAppVersion": "0.5.0"
|
|
13
|
-
}
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import { exec } from 'child_process';
|
|
4
|
-
|
|
5
|
-
let ctx = null;
|
|
6
|
-
|
|
7
|
-
function isHeadless() {
|
|
8
|
-
return process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function activate(context) {
|
|
12
|
-
ctx = context;
|
|
13
|
-
if (!isHeadless()) {
|
|
14
|
-
ctx.logger.info('Explorer integration loaded. Use `pe explorer install` to set up.');
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function deactivate() {}
|
|
19
|
-
|
|
20
|
-
export async function install() {
|
|
21
|
-
const p = process.platform;
|
|
22
|
-
if (p === 'win32') return installWindows();
|
|
23
|
-
if (p === 'darwin') return installFinder();
|
|
24
|
-
if (p === 'linux') return installNautilus();
|
|
25
|
-
throw new Error(`No file manager integration for ${p}`);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export async function uninstall() {
|
|
29
|
-
const p = process.platform;
|
|
30
|
-
if (p === 'win32') return uninstallWindows();
|
|
31
|
-
if (p === 'darwin') return uninstallFinder();
|
|
32
|
-
if (p === 'linux') return uninstallNautilus();
|
|
33
|
-
throw new Error(`No file manager integration for ${p}`);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function installWindows() {
|
|
37
|
-
// Delegates to existing Windows explorer util
|
|
38
|
-
const { installExplorerContextMenu } = await import('../../../lib/utils/explorer.js');
|
|
39
|
-
await installExplorerContextMenu();
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function uninstallWindows() {
|
|
43
|
-
const { uninstallExplorerContextMenu } = await import('../../../lib/utils/explorer.js');
|
|
44
|
-
await uninstallExplorerContextMenu();
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function installFinder() {
|
|
48
|
-
const workflowDir = path.join(
|
|
49
|
-
process.env.HOME,
|
|
50
|
-
'Library/Services/Share with Pal.workflow/Contents'
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
fs.mkdirSync(workflowDir, { recursive: true });
|
|
54
|
-
|
|
55
|
-
fs.writeFileSync(path.join(workflowDir, 'Info.plist'), `<?xml version="1.0" encoding="UTF-8"?>
|
|
56
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
57
|
-
<plist version="1.0">
|
|
58
|
-
<dict>
|
|
59
|
-
<key>NSServices</key>
|
|
60
|
-
<array>
|
|
61
|
-
<dict>
|
|
62
|
-
<key>NSMenuItem</key>
|
|
63
|
-
<dict>
|
|
64
|
-
<key>default</key>
|
|
65
|
-
<string>Share with Pal</string>
|
|
66
|
-
</dict>
|
|
67
|
-
<key>NSMessage</key>
|
|
68
|
-
<string>runWorkflowAsService</string>
|
|
69
|
-
<key>NSSendFileTypes</key>
|
|
70
|
-
<array>
|
|
71
|
-
<string>public.item</string>
|
|
72
|
-
</array>
|
|
73
|
-
</dict>
|
|
74
|
-
</array>
|
|
75
|
-
</dict>
|
|
76
|
-
</plist>
|
|
77
|
-
`);
|
|
78
|
-
|
|
79
|
-
fs.writeFileSync(path.join(workflowDir, 'share-with-pal.sh'), `#!/bin/bash
|
|
80
|
-
for f in "$@"; do
|
|
81
|
-
pe share "$f"
|
|
82
|
-
done
|
|
83
|
-
`, { mode: 0o755 });
|
|
84
|
-
|
|
85
|
-
ctx?.logger.info(`Finder Quick Action created at ${workflowDir}`);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async function uninstallFinder() {
|
|
89
|
-
const dir = path.join(process.env.HOME, 'Library/Services/Share with Pal.workflow');
|
|
90
|
-
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true });
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
async function installNautilus() {
|
|
94
|
-
const scriptDir = path.join(process.env.HOME, '.local/share/nautilus/scripts');
|
|
95
|
-
fs.mkdirSync(scriptDir, { recursive: true });
|
|
96
|
-
|
|
97
|
-
fs.writeFileSync(path.join(scriptDir, 'Share with Pal'), `#!/bin/bash
|
|
98
|
-
IFS=$'\\n'
|
|
99
|
-
for f in $NAUTILUS_SCRIPT_SELECTED_FILE_PATHS; do
|
|
100
|
-
pe share "$f"
|
|
101
|
-
done
|
|
102
|
-
`, { mode: 0o755 });
|
|
103
|
-
|
|
104
|
-
const actionsDir = path.join(process.env.HOME, '.local/share/file-manager/actions');
|
|
105
|
-
fs.mkdirSync(actionsDir, { recursive: true });
|
|
106
|
-
|
|
107
|
-
fs.writeFileSync(path.join(actionsDir, 'palexplorer-share.desktop'), `[Desktop Action]
|
|
108
|
-
Name=Share with Pal
|
|
109
|
-
Icon=palexplorer
|
|
110
|
-
Exec=pe share %f
|
|
111
|
-
MimeType=inode/directory;application/octet-stream;
|
|
112
|
-
`);
|
|
113
|
-
|
|
114
|
-
ctx?.logger.info('Nautilus script installed');
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async function uninstallNautilus() {
|
|
118
|
-
const script = path.join(process.env.HOME, '.local/share/nautilus/scripts/Share with Pal');
|
|
119
|
-
const action = path.join(process.env.HOME, '.local/share/file-manager/actions/palexplorer-share.desktop');
|
|
120
|
-
if (fs.existsSync(script)) fs.unlinkSync(script);
|
|
121
|
-
if (fs.existsSync(action)) fs.unlinkSync(action);
|
|
122
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "networks",
|
|
3
|
-
"version": "1.0.0",
|
|
4
|
-
"description": "Private overlay networks for organizations",
|
|
5
|
-
"author": "Palexplorer Team",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"main": "index.js",
|
|
8
|
-
"hooks": ["on:app:ready"],
|
|
9
|
-
"permissions": [],
|
|
10
|
-
"contributes": {
|
|
11
|
-
"pages": [
|
|
12
|
-
{ "id": "networks", "label": "networks", "icon": "Globe", "section": "system" }
|
|
13
|
-
]
|
|
14
|
-
},
|
|
15
|
-
"config": { "enabled": { "type": "boolean", "default": false } },
|
|
16
|
-
"tier": "enterprise"
|
|
17
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "vfs",
|
|
3
|
-
"version": "1.0.0",
|
|
4
|
-
"description": "Mount Palexplorer shares as a virtual drive (WebDAV)",
|
|
5
|
-
"author": "Palexplorer Team",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"main": "index.js",
|
|
8
|
-
"hooks": ["on:app:ready", "on:app:shutdown", "after:share:create", "after:share:revoke"],
|
|
9
|
-
"permissions": ["shares:read", "config:read", "config:write"],
|
|
10
|
-
"config": {
|
|
11
|
-
"port": { "type": "number", "default": 1900, "description": "WebDAV server port" },
|
|
12
|
-
"driveLetter": { "type": "string", "default": "P", "description": "Windows drive letter" },
|
|
13
|
-
"autoMount": { "type": "boolean", "default": true, "description": "Auto-mount on startup" }
|
|
14
|
-
},
|
|
15
|
-
"tier": "pro",
|
|
16
|
-
"minAppVersion": "0.5.0"
|
|
17
|
-
}
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
import { v2 as webdav } from 'webdav-server';
|
|
2
|
-
import { exec } from 'child_process';
|
|
3
|
-
import crypto from 'crypto';
|
|
4
|
-
import path from 'path';
|
|
5
|
-
import fs from 'fs';
|
|
6
|
-
|
|
7
|
-
let server = null;
|
|
8
|
-
let token = null;
|
|
9
|
-
let mounted = false;
|
|
10
|
-
let shares = new Map();
|
|
11
|
-
let ctx = null;
|
|
12
|
-
|
|
13
|
-
export function activate(context) {
|
|
14
|
-
ctx = context;
|
|
15
|
-
|
|
16
|
-
context.hooks.on('on:app:ready', async () => {
|
|
17
|
-
const port = context.config.get('port') || 1900;
|
|
18
|
-
const autoMount = context.config.get('autoMount') !== false;
|
|
19
|
-
try {
|
|
20
|
-
await startServer(port);
|
|
21
|
-
await syncShares(context);
|
|
22
|
-
if (autoMount) {
|
|
23
|
-
const letter = context.config.get('driveLetter') || 'P';
|
|
24
|
-
await mount(letter, port);
|
|
25
|
-
}
|
|
26
|
-
} catch (err) {
|
|
27
|
-
context.logger.error('VFS startup failed:', err.message);
|
|
28
|
-
}
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
context.hooks.on('after:share:create', async () => {
|
|
32
|
-
if (server) await syncShares(context);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
context.hooks.on('after:share:revoke', async () => {
|
|
36
|
-
if (server) await syncShares(context);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
context.hooks.on('on:app:shutdown', async () => {
|
|
40
|
-
await stop();
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function deactivate() {
|
|
45
|
-
return stop();
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function getStatus() {
|
|
49
|
-
return {
|
|
50
|
-
running: !!server,
|
|
51
|
-
mounted,
|
|
52
|
-
shareCount: shares.size,
|
|
53
|
-
shares: Array.from(shares.entries()).map(([name, localPath]) => ({ name, localPath })),
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async function startServer(port = 1900) {
|
|
58
|
-
if (server) return;
|
|
59
|
-
token = crypto.randomBytes(32).toString('hex');
|
|
60
|
-
|
|
61
|
-
const userManager = new webdav.SimpleUserManager();
|
|
62
|
-
const user = userManager.addUser('pal', token, false);
|
|
63
|
-
const privilegeManager = new webdav.SimplePathPrivilegeManager();
|
|
64
|
-
privilegeManager.setRights(user, '/', ['all']);
|
|
65
|
-
|
|
66
|
-
server = new webdav.WebDAVServer({
|
|
67
|
-
port,
|
|
68
|
-
hostname: '127.0.0.1',
|
|
69
|
-
requireAuthentification: true,
|
|
70
|
-
httpAuthentication: new webdav.HTTPBasicAuthentication(userManager, 'Palexplorer VFS'),
|
|
71
|
-
privilegeManager,
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
return new Promise((resolve, reject) => {
|
|
75
|
-
server.start((s) => {
|
|
76
|
-
if (s instanceof Error) return reject(s);
|
|
77
|
-
ctx?.logger.info(`WebDAV server on http://127.0.0.1:${port}`);
|
|
78
|
-
resolve();
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function stop() {
|
|
84
|
-
if (mounted) {
|
|
85
|
-
try { await unmount(); } catch {}
|
|
86
|
-
}
|
|
87
|
-
if (server) {
|
|
88
|
-
return new Promise((resolve) => {
|
|
89
|
-
server.stop(() => { server = null; token = null; resolve(); });
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async function syncShares(context) {
|
|
95
|
-
const configShares = context.shares?.list() || [];
|
|
96
|
-
const activeNames = new Set();
|
|
97
|
-
|
|
98
|
-
for (const share of configShares) {
|
|
99
|
-
if (share.status !== 'active') continue;
|
|
100
|
-
const name = path.basename(share.path);
|
|
101
|
-
activeNames.add(name);
|
|
102
|
-
if (!shares.has(name)) {
|
|
103
|
-
try {
|
|
104
|
-
await new Promise((resolve, reject) => {
|
|
105
|
-
server.setFileSystem(`/${name}`, new webdav.PhysicalFileSystem(share.path), (ok) => {
|
|
106
|
-
if (ok) { shares.set(name, share.path); resolve(); }
|
|
107
|
-
else reject(new Error(`Failed to add: ${name}`));
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
} catch {}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
for (const [name] of shares) {
|
|
115
|
-
if (!activeNames.has(name)) {
|
|
116
|
-
server.removeFileSystem(`/${name}`, () => { shares.delete(name); });
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async function mount(driveLetter = 'P', port = 1900) {
|
|
122
|
-
const p = process.platform;
|
|
123
|
-
if (p === 'win32') {
|
|
124
|
-
const cmd = `net use ${driveLetter}: http://127.0.0.1:${port} /user:pal ${token} /persistent:yes`;
|
|
125
|
-
return new Promise((resolve) => {
|
|
126
|
-
exec(cmd, (err, stdout, stderr) => {
|
|
127
|
-
if (err && stderr?.includes('67')) {
|
|
128
|
-
ctx?.logger.warn('WebClient service not running, drive skipped.');
|
|
129
|
-
return resolve();
|
|
130
|
-
}
|
|
131
|
-
mounted = !err || stderr?.includes('85');
|
|
132
|
-
if (mounted) ctx?.logger.info(`Mounted at ${driveLetter}:`);
|
|
133
|
-
resolve();
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
if (p === 'darwin') {
|
|
138
|
-
const mp = '/Volumes/Palexplorer';
|
|
139
|
-
return new Promise((resolve) => {
|
|
140
|
-
exec(`mkdir -p "${mp}" && mount_webdav -s -S http://127.0.0.1:${port} "${mp}"`, (err) => {
|
|
141
|
-
mounted = !err;
|
|
142
|
-
if (mounted) ctx?.logger.info(`Mounted at ${mp}`);
|
|
143
|
-
resolve();
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
if (p === 'linux') {
|
|
148
|
-
const mp = `${process.env.HOME}/Palexplorer`;
|
|
149
|
-
return new Promise((resolve) => {
|
|
150
|
-
exec(`mkdir -p "${mp}" && mount -t davfs http://127.0.0.1:${port} "${mp}"`, (err) => {
|
|
151
|
-
mounted = !err;
|
|
152
|
-
if (mounted) ctx?.logger.info(`Mounted at ${mp}`);
|
|
153
|
-
resolve();
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
async function unmount() {
|
|
160
|
-
const p = process.platform;
|
|
161
|
-
const cmd = p === 'win32' ? `net use P: /delete /yes`
|
|
162
|
-
: p === 'darwin' ? `umount /Volumes/Palexplorer`
|
|
163
|
-
: `umount ${process.env.HOME}/Palexplorer`;
|
|
164
|
-
return new Promise((resolve) => {
|
|
165
|
-
exec(cmd, () => { mounted = false; resolve(); });
|
|
166
|
-
});
|
|
167
|
-
}
|