ezshare-cli 1.0.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 +311 -0
- package/bin/ezshare.js +2 -0
- package/dist/cli.js +60 -0
- package/dist/commands/receive.js +129 -0
- package/dist/commands/send.js +117 -0
- package/dist/components/FileBrowser.js +75 -0
- package/dist/components/HelpScreen.js +10 -0
- package/dist/components/MainMenu.js +9 -0
- package/dist/components/Shell.js +88 -0
- package/dist/components/TransferUI.js +6 -0
- package/dist/utils/compression.js +354 -0
- package/dist/utils/crypto.js +236 -0
- package/dist/utils/fileSystem.js +89 -0
- package/dist/utils/network.js +98 -0
- package/dist/utils/tar.js +169 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Blake
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# EzShare
|
|
2
|
+
|
|
3
|
+
**Secure P2P file transfer with end-to-end encryption - No servers, no signups, just share**
|
|
4
|
+
|
|
5
|
+
Share files and folders directly between peers using decentralized Hyperswarm DHT. Works across different networks with built-in NAT traversal.
|
|
6
|
+
|
|
7
|
+
[](https://nodejs.org/)
|
|
8
|
+
[](https://www.typescriptlang.org/)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
|
|
11
|
+
## ✨ Features
|
|
12
|
+
|
|
13
|
+
- **🔒 End-to-End Encryption** — AES-256-GCM authenticated encryption
|
|
14
|
+
- **🌐 Truly Decentralized** — Direct peer-to-peer via Hyperswarm DHT, no servers
|
|
15
|
+
- **🚀 NAT Traversal** — Works across different networks with automatic hole-punching
|
|
16
|
+
- **⚡ Adaptive Compression** — Zstandard compression (smart detection skips .zip, .mp4, etc.)
|
|
17
|
+
- **💻 Interactive Shell** — Claude Code-style TUI with slash commands and file browser
|
|
18
|
+
- **📦 Streaming Architecture** — Memory-efficient for files of any size
|
|
19
|
+
- **🔄 Cross-Platform** — Linux, macOS, and Windows
|
|
20
|
+
|
|
21
|
+
## 📦 Installation
|
|
22
|
+
|
|
23
|
+
### Prerequisites
|
|
24
|
+
|
|
25
|
+
- **Node.js 18+** (recommended: Node 20+)
|
|
26
|
+
- **Zstandard** (`zstd`) - required for compression
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Ubuntu/Debian
|
|
30
|
+
sudo apt install zstd
|
|
31
|
+
|
|
32
|
+
# macOS
|
|
33
|
+
brew install zstd
|
|
34
|
+
|
|
35
|
+
# Windows (chocolatey)
|
|
36
|
+
choco install zstd
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Install Globally
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install -g ezshare-cli
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Now you can use `ezshare` from anywhere!
|
|
46
|
+
|
|
47
|
+
### Or Use with npx (no install)
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx ezshare-cli
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 🚀 Quick Start
|
|
54
|
+
|
|
55
|
+
### Interactive Mode (Recommended)
|
|
56
|
+
|
|
57
|
+
Simply type `ezshare` to launch the interactive shell:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
ezshare
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
You'll see:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
EzShare CLI - P2P File Transfer
|
|
67
|
+
Type /ezshare to start or /help for commands
|
|
68
|
+
|
|
69
|
+
ezshare>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Available commands:**
|
|
73
|
+
- `/ezshare` - Open the main menu
|
|
74
|
+
- `/help` - Show help screen
|
|
75
|
+
- `/exit` - Exit the application
|
|
76
|
+
- `q` - Quick exit (when in command mode)
|
|
77
|
+
- `Esc` - Go back to command mode
|
|
78
|
+
|
|
79
|
+
#### Using the Interactive Shell
|
|
80
|
+
|
|
81
|
+
1. Type `/ezshare` to open the main menu
|
|
82
|
+
2. Choose **Send** or **Receive**
|
|
83
|
+
3. **For Send:**
|
|
84
|
+
- Use arrow keys (↑/↓) to navigate your file system
|
|
85
|
+
- Press `Enter` on a folder to open it
|
|
86
|
+
- Press `Enter` on a file to select it for sharing
|
|
87
|
+
- Copy the generated share key
|
|
88
|
+
4. **For Receive:**
|
|
89
|
+
- Paste the share key from the sender
|
|
90
|
+
- Files save to current directory by default
|
|
91
|
+
|
|
92
|
+
### Direct CLI Mode
|
|
93
|
+
|
|
94
|
+
Or use direct commands for quick transfers:
|
|
95
|
+
|
|
96
|
+
#### Send a file or directory
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
ezshare send <path>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Example:
|
|
103
|
+
```bash
|
|
104
|
+
$ ezshare send ./documents
|
|
105
|
+
|
|
106
|
+
📤 Sending: documents
|
|
107
|
+
Size: 2.4 MB | Files: 3
|
|
108
|
+
|
|
109
|
+
Share this key with receiver:
|
|
110
|
+
BhhzKS7G4iIq4okKNYhaoqljeLMAjMR8UkpaLyqP7EA
|
|
111
|
+
|
|
112
|
+
⠋ Waiting for peer to connect...
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
#### Receive a file
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
ezshare receive <share-key> [--output <directory>]
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
```bash
|
|
123
|
+
$ ezshare receive BhhzKS7G4iIq4okKNYhaoqljeLMAjMR8UkpaLyqP7EA
|
|
124
|
+
|
|
125
|
+
📥 Receiving file(s)
|
|
126
|
+
Size: 2.4 MB | Files: 3
|
|
127
|
+
|
|
128
|
+
⠋ Connecting to peer...
|
|
129
|
+
████████████████████████████████████████ 100%
|
|
130
|
+
|
|
131
|
+
✓ Transfer complete!
|
|
132
|
+
Saved to: /current/directory
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Specify output directory:
|
|
136
|
+
```bash
|
|
137
|
+
ezshare receive <key> --output ~/Downloads
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## 🔧 How It Works
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
┌─────────────┐ ┌─────────────┐
|
|
144
|
+
│ Sender │ │ Receiver │
|
|
145
|
+
└──────┬──────┘ └──────┬──────┘
|
|
146
|
+
│ │
|
|
147
|
+
│ 1. Generate random topic key │
|
|
148
|
+
│ 2. Display share key to user │
|
|
149
|
+
│ │
|
|
150
|
+
│ 3. Announce to Hyperswarm DHT ────────────────────►│ 4. Look up in DHT
|
|
151
|
+
│ │
|
|
152
|
+
│◄───────────────── 5. P2P Connection ──────────────┤
|
|
153
|
+
│ (NAT hole-punching) │
|
|
154
|
+
│ │
|
|
155
|
+
│ Files → Tar → Compress* → Encrypt (AES-256) │
|
|
156
|
+
│ │
|
|
157
|
+
├──────────────────► Transfer ──────────────────────┤
|
|
158
|
+
│ │
|
|
159
|
+
│ Decrypt → Decompress → Extract → Files
|
|
160
|
+
│ │
|
|
161
|
+
│◄───────────────── 6. Close Connection ────────────┤
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
*Compression is skipped for already-compressed formats (`.zip`, `.gz`, `.mp4`, `.jpg`, etc.)
|
|
165
|
+
|
|
166
|
+
## 🛡️ Security
|
|
167
|
+
|
|
168
|
+
- **Key Generation**: Random 32-byte topic key generated per transfer
|
|
169
|
+
- **Key Derivation**: AES key derived from topic using HKDF-SHA256
|
|
170
|
+
- **Encryption**: AES-256-GCM with unique nonces per 64KB chunk
|
|
171
|
+
- **Authentication**: GCM mode provides integrity verification
|
|
172
|
+
- **Transport**: Hyperswarm uses Noise protocol for transport encryption
|
|
173
|
+
|
|
174
|
+
⚠️ **Important**: The share key is the only secret. Anyone with the key can receive the file. Share it securely (Signal, encrypted email, etc.).
|
|
175
|
+
|
|
176
|
+
## 🏗️ Architecture
|
|
177
|
+
|
|
178
|
+
### Tech Stack
|
|
179
|
+
|
|
180
|
+
| Component | Technology |
|
|
181
|
+
|-----------|------------|
|
|
182
|
+
| Runtime | Node.js 18+ with TypeScript ES Modules |
|
|
183
|
+
| P2P Network | [Hyperswarm](https://github.com/holepunchto/hyperswarm) (Kademlia DHT + NAT traversal) |
|
|
184
|
+
| Encryption | AES-256-GCM (Node.js crypto) |
|
|
185
|
+
| Compression | [Zstandard](https://github.com/facebook/zstd) via simple-zstd |
|
|
186
|
+
| Archiving | tar-stream (streaming tar) |
|
|
187
|
+
| TUI | [Ink](https://github.com/vadimdemedes/ink) (React for CLI) |
|
|
188
|
+
| CLI Parser | meow |
|
|
189
|
+
|
|
190
|
+
### Project Structure
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
ezsharecli/
|
|
194
|
+
├── bin/
|
|
195
|
+
│ └── ezshare.js # Global CLI entry point
|
|
196
|
+
├── src/
|
|
197
|
+
│ ├── cli.tsx # Main CLI router (interactive vs direct mode)
|
|
198
|
+
│ ├── components/
|
|
199
|
+
│ │ ├── Shell.tsx # Interactive shell REPL
|
|
200
|
+
│ │ ├── MainMenu.tsx # Send/Receive menu
|
|
201
|
+
│ │ ├── FileBrowser.tsx # Arrow-key file navigator
|
|
202
|
+
│ │ ├── HelpScreen.tsx # Help documentation
|
|
203
|
+
│ │ └── TransferUI.tsx # Transfer progress UI
|
|
204
|
+
│ ├── commands/
|
|
205
|
+
│ │ ├── send.tsx # Send command implementation
|
|
206
|
+
│ │ └── receive.tsx # Receive command implementation
|
|
207
|
+
│ └── utils/
|
|
208
|
+
│ ├── crypto.ts # AES-256-GCM encryption/decryption streams
|
|
209
|
+
│ ├── compression.ts # Zstd compression with format detection
|
|
210
|
+
│ ├── network.ts # Hyperswarm connection management
|
|
211
|
+
│ ├── tar.ts # Tar pack/extract utilities
|
|
212
|
+
│ └── fileSystem.ts # File browser utilities
|
|
213
|
+
├── package.json
|
|
214
|
+
└── tsconfig.json
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## 🐛 Troubleshooting
|
|
218
|
+
|
|
219
|
+
### Connection Issues
|
|
220
|
+
|
|
221
|
+
**"Connection timeout after 30s"**
|
|
222
|
+
- Ensure both sender and receiver are started within ~10 seconds
|
|
223
|
+
- Check firewall settings (Hyperswarm needs UDP for DHT)
|
|
224
|
+
- Try on different networks if behind restrictive NAT
|
|
225
|
+
|
|
226
|
+
**"Could not find or connect to sender"**
|
|
227
|
+
- Verify the share key is correct (copy-paste to avoid typos)
|
|
228
|
+
- Ensure sender is still running and waiting
|
|
229
|
+
- Both peers need internet connectivity for DHT bootstrap
|
|
230
|
+
|
|
231
|
+
### Compression Issues
|
|
232
|
+
|
|
233
|
+
**"zstd: command not found"**
|
|
234
|
+
- Install zstd using your package manager (see Installation section)
|
|
235
|
+
|
|
236
|
+
### Performance Tips
|
|
237
|
+
|
|
238
|
+
- Large files (>1GB): Transfers work fine, but both peers should have stable connections
|
|
239
|
+
- Firewalls: Allow UDP traffic for best DHT performance
|
|
240
|
+
- Multiple files: Directory transfers are automatically tar-packed
|
|
241
|
+
|
|
242
|
+
## 💡 Examples
|
|
243
|
+
|
|
244
|
+
### Send a single file
|
|
245
|
+
```bash
|
|
246
|
+
ezshare send presentation.pdf
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Send a directory
|
|
250
|
+
```bash
|
|
251
|
+
ezshare send ./project-folder
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Receive to specific location
|
|
255
|
+
```bash
|
|
256
|
+
ezshare receive ABC123XYZ --output ~/Downloads
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Interactive mode with file browser
|
|
260
|
+
```bash
|
|
261
|
+
ezshare
|
|
262
|
+
# Then: /ezshare → Send → Navigate with arrows → Select file
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## 🔮 Roadmap
|
|
266
|
+
|
|
267
|
+
- [ ] Resume interrupted transfers
|
|
268
|
+
- [ ] Multiple simultaneous receivers
|
|
269
|
+
- [ ] Transfer speed indicator and ETA
|
|
270
|
+
- [ ] QR code for share keys (mobile)
|
|
271
|
+
- [ ] Custom encryption passphrases
|
|
272
|
+
- [ ] Web UI companion app
|
|
273
|
+
- [ ] Transfer history
|
|
274
|
+
|
|
275
|
+
## 🤝 Contributing
|
|
276
|
+
|
|
277
|
+
Contributions are welcome! Here's how:
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
# Clone the repository
|
|
281
|
+
git clone https://github.com/yourusername/ezsharecli.git
|
|
282
|
+
cd ezsharecli
|
|
283
|
+
|
|
284
|
+
# Install dependencies
|
|
285
|
+
npm install
|
|
286
|
+
|
|
287
|
+
# Run in development
|
|
288
|
+
npm run dev
|
|
289
|
+
|
|
290
|
+
# Build
|
|
291
|
+
npm run build
|
|
292
|
+
|
|
293
|
+
# Run tests
|
|
294
|
+
npm test
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Please open an issue before starting major features.
|
|
298
|
+
|
|
299
|
+
## 📝 License
|
|
300
|
+
|
|
301
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
|
302
|
+
|
|
303
|
+
## 🙏 Acknowledgments
|
|
304
|
+
|
|
305
|
+
- Built with [Hyperswarm](https://github.com/holepunchto/hyperswarm) by Holepunch
|
|
306
|
+
- Inspired by [Magic Wormhole](https://magic-wormhole.readthedocs.io/)
|
|
307
|
+
- TUI powered by [Ink](https://github.com/vadimdemedes/ink)
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
**Made with ❤️ for secure, decentralized file sharing**
|
package/bin/ezshare.js
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import meow from 'meow';
|
|
4
|
+
import { render } from 'ink';
|
|
5
|
+
import { Shell } from './components/Shell.js';
|
|
6
|
+
import { SendCommand } from './commands/send.js';
|
|
7
|
+
import { ReceiveCommand } from './commands/receive.js';
|
|
8
|
+
const cli = meow(`
|
|
9
|
+
Usage
|
|
10
|
+
$ ezshare # Interactive shell mode
|
|
11
|
+
$ ezshare send <path> # Direct send
|
|
12
|
+
$ ezshare receive <key> [--output] # Direct receive
|
|
13
|
+
|
|
14
|
+
Examples
|
|
15
|
+
$ ezshare # Launch interactive shell
|
|
16
|
+
$ ezshare send ./myfile.zip # Quick send
|
|
17
|
+
$ ezshare receive abc123... -o ./downloads
|
|
18
|
+
|
|
19
|
+
Options
|
|
20
|
+
--output, -o Output directory for received files (default: current directory)
|
|
21
|
+
|
|
22
|
+
Interactive Mode:
|
|
23
|
+
Launch with no arguments to enter interactive shell mode.
|
|
24
|
+
Use slash commands:
|
|
25
|
+
/ezshare - Start file transfer
|
|
26
|
+
/help - Show help
|
|
27
|
+
/exit - Exit shell
|
|
28
|
+
`, {
|
|
29
|
+
importMeta: import.meta,
|
|
30
|
+
flags: {
|
|
31
|
+
output: { type: 'string', shortFlag: 'o' }
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
const [command, arg] = cli.input;
|
|
35
|
+
// Interactive shell mode (no arguments)
|
|
36
|
+
if (!command) {
|
|
37
|
+
render(_jsx(Shell, {}));
|
|
38
|
+
}
|
|
39
|
+
// Direct CLI mode - Send
|
|
40
|
+
else if (command === 'send') {
|
|
41
|
+
if (!arg) {
|
|
42
|
+
console.error('Error: Please specify a file or directory to send');
|
|
43
|
+
console.log('Usage: ezshare send <path>');
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
render(_jsx(SendCommand, { path: arg, onComplete: () => process.exit(0), onError: () => process.exit(1) }));
|
|
47
|
+
}
|
|
48
|
+
// Direct CLI mode - Receive
|
|
49
|
+
else if (command === 'receive') {
|
|
50
|
+
if (!arg) {
|
|
51
|
+
console.error('Error: Please specify a share key');
|
|
52
|
+
console.log('Usage: ezshare receive <key> [--output <dir>]');
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
const outputPath = cli.flags.output || process.cwd();
|
|
56
|
+
render(_jsx(ReceiveCommand, { shareKey: arg, outputPath: outputPath, onComplete: () => process.exit(0), onError: () => process.exit(1) }));
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
cli.showHelp();
|
|
60
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { Spinner, ProgressBar } from '@inkjs/ui';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { Transform } from 'node:stream';
|
|
7
|
+
import { parseTopicKey, deriveKey, createDecryptStream } from '../utils/crypto.js';
|
|
8
|
+
import { createDecompressStream } from '../utils/compression.js';
|
|
9
|
+
import { createExtractStream } from '../utils/tar.js';
|
|
10
|
+
import { createReceiverSwarm, cleanupSwarm } from '../utils/network.js';
|
|
11
|
+
export function ReceiveCommand({ shareKey, outputPath = process.cwd(), onComplete, onError }) {
|
|
12
|
+
const [state, setState] = useState('connecting');
|
|
13
|
+
const [progress, setProgress] = useState(0);
|
|
14
|
+
const [error, setError] = useState(null);
|
|
15
|
+
const [metadata, setMetadata] = useState(null);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
startReceiving();
|
|
18
|
+
}, []);
|
|
19
|
+
const startReceiving = async () => {
|
|
20
|
+
try {
|
|
21
|
+
// Parse share key to get topic
|
|
22
|
+
const topic = parseTopicKey(shareKey);
|
|
23
|
+
// Derive encryption key from topic
|
|
24
|
+
const encryptionKey = deriveKey(topic);
|
|
25
|
+
// Create receiver swarm and get connection promise
|
|
26
|
+
const { swarm, connectionPromise } = await createReceiverSwarm(topic);
|
|
27
|
+
// Wait for peer connection (listener already registered)
|
|
28
|
+
const socket = await connectionPromise;
|
|
29
|
+
console.log('[Receiver] Connected to sender');
|
|
30
|
+
// Add socket error handler
|
|
31
|
+
socket.on('error', (err) => {
|
|
32
|
+
console.error('[Receiver] Socket error:', err);
|
|
33
|
+
});
|
|
34
|
+
socket.on('end', () => {
|
|
35
|
+
console.log('[Receiver] Socket end event received');
|
|
36
|
+
});
|
|
37
|
+
socket.on('close', () => {
|
|
38
|
+
console.log('[Receiver] Socket closed');
|
|
39
|
+
});
|
|
40
|
+
// Read metadata first (JSON header followed by newline)
|
|
41
|
+
const metadataBuffer = [];
|
|
42
|
+
let metadataReceived = false;
|
|
43
|
+
let transferMetadata = null;
|
|
44
|
+
// Create a passthrough to split metadata from file data
|
|
45
|
+
const metadataExtractor = new Transform({
|
|
46
|
+
transform(chunk, encoding, callback) {
|
|
47
|
+
if (!metadataReceived) {
|
|
48
|
+
metadataBuffer.push(chunk);
|
|
49
|
+
const combined = Buffer.concat(metadataBuffer);
|
|
50
|
+
const newlineIndex = combined.indexOf('\n');
|
|
51
|
+
if (newlineIndex !== -1) {
|
|
52
|
+
// Found metadata
|
|
53
|
+
try {
|
|
54
|
+
const metadataJson = combined.slice(0, newlineIndex).toString();
|
|
55
|
+
console.log('[Receiver] Received metadata:', metadataJson);
|
|
56
|
+
transferMetadata = JSON.parse(metadataJson);
|
|
57
|
+
setMetadata(transferMetadata);
|
|
58
|
+
// Push remaining data after newline
|
|
59
|
+
const remainingData = combined.slice(newlineIndex + 1);
|
|
60
|
+
console.log(`[Receiver] Metadata extracted, ${remainingData.length} bytes of data after newline`);
|
|
61
|
+
if (remainingData.length > 0) {
|
|
62
|
+
this.push(remainingData);
|
|
63
|
+
}
|
|
64
|
+
metadataReceived = true;
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
console.error('[Receiver] Failed to parse metadata:', err);
|
|
68
|
+
callback(err);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
callback();
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// Pass through file data
|
|
76
|
+
callback(null, chunk);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
// Create progress tracker
|
|
81
|
+
let transferred = 0;
|
|
82
|
+
const progressTracker = new Transform({
|
|
83
|
+
transform(chunk, encoding, callback) {
|
|
84
|
+
if (transferMetadata) {
|
|
85
|
+
transferred += chunk.length;
|
|
86
|
+
const pct = Math.round((transferred / transferMetadata.totalSize) * 100);
|
|
87
|
+
setProgress(Math.min(pct, 100));
|
|
88
|
+
}
|
|
89
|
+
callback(null, chunk);
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
// Start receiving
|
|
93
|
+
setState('receiving');
|
|
94
|
+
console.log('[Receiver] Starting receive pipeline');
|
|
95
|
+
// Build the pipeline: Socket → Metadata Extractor → Progress → Decrypt → Decompress → Tar Extract
|
|
96
|
+
const decryptStream = createDecryptStream(encryptionKey);
|
|
97
|
+
const decompressStream = await createDecompressStream();
|
|
98
|
+
const extractStream = createExtractStream(outputPath);
|
|
99
|
+
await pipeline(socket, metadataExtractor, progressTracker, decryptStream, decompressStream, extractStream);
|
|
100
|
+
console.log('[Receiver] Pipeline completed successfully');
|
|
101
|
+
// Transfer complete
|
|
102
|
+
setState('done');
|
|
103
|
+
// Receiver cleanup - small delay to ensure socket is fully closed
|
|
104
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
105
|
+
console.log('[Receiver] Cleaning up receiver swarm');
|
|
106
|
+
await cleanupSwarm(swarm);
|
|
107
|
+
if (onComplete) {
|
|
108
|
+
onComplete();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
|
113
|
+
setError(errorMessage);
|
|
114
|
+
setState('error');
|
|
115
|
+
if (onError) {
|
|
116
|
+
onError(err);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "blue", children: "\uD83D\uDCE5 Receiving file(s)" }), metadata && (_jsxs(Text, { dimColor: true, children: ["Size: ", formatBytes(metadata.totalSize), " | Files: ", metadata.fileCount] })), state === 'connecting' && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Connecting to peer..." }) })), state === 'receiving' && progress === 0 && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Starting transfer..." }) })), state === 'receiving' && progress > 0 && progress < 100 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(ProgressBar, { value: progress }), _jsxs(Text, { children: [" ", progress, "%"] })] })), state === 'done' && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Transfer complete!" }), _jsxs(Text, { dimColor: true, children: ["Saved to: ", outputPath] }), _jsx(Text, { dimColor: true, children: "Press Esc to return" })] })), state === 'error' && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "red", bold: true, children: "\u2717 Transfer failed" }), _jsx(Text, { color: "red", children: error }), _jsx(Text, { dimColor: true, children: "Press Esc to return" })] }))] }));
|
|
121
|
+
}
|
|
122
|
+
function formatBytes(bytes) {
|
|
123
|
+
if (bytes === 0)
|
|
124
|
+
return '0 B';
|
|
125
|
+
const k = 1024;
|
|
126
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
127
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
128
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
129
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { Spinner, ProgressBar } from '@inkjs/ui';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { Transform } from 'node:stream';
|
|
7
|
+
import { generateTopicKey, deriveKey, createEncryptStream } from '../utils/crypto.js';
|
|
8
|
+
import { createCompressStream, shouldCompress } from '../utils/compression.js';
|
|
9
|
+
import { createPackStream, getTransferMetadata } from '../utils/tar.js';
|
|
10
|
+
import { createSenderSwarm, cleanupSwarm } from '../utils/network.js';
|
|
11
|
+
export function SendCommand({ path, onComplete, onError }) {
|
|
12
|
+
const [state, setState] = useState('init');
|
|
13
|
+
const [shareKey, setShareKey] = useState('');
|
|
14
|
+
const [progress, setProgress] = useState(0);
|
|
15
|
+
const [error, setError] = useState(null);
|
|
16
|
+
const [metadata, setMetadata] = useState(null);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
startSending();
|
|
19
|
+
}, []);
|
|
20
|
+
const startSending = async () => {
|
|
21
|
+
try {
|
|
22
|
+
// Generate topic and display key
|
|
23
|
+
const { topic, displayKey } = generateTopicKey();
|
|
24
|
+
setShareKey(displayKey);
|
|
25
|
+
// Derive encryption key from topic
|
|
26
|
+
const encryptionKey = deriveKey(topic);
|
|
27
|
+
// Get transfer metadata
|
|
28
|
+
const transferMetadata = await getTransferMetadata(path);
|
|
29
|
+
setMetadata({
|
|
30
|
+
totalSize: transferMetadata.totalSize,
|
|
31
|
+
fileCount: transferMetadata.fileCount,
|
|
32
|
+
});
|
|
33
|
+
// Create sender swarm
|
|
34
|
+
setState('waiting');
|
|
35
|
+
const { swarm, waitForPeer } = await createSenderSwarm(topic);
|
|
36
|
+
// Wait for peer connection
|
|
37
|
+
const socket = await waitForPeer();
|
|
38
|
+
// Register socket close listener BEFORE pipeline starts
|
|
39
|
+
// This ensures we don't miss the close event
|
|
40
|
+
const socketClosed = new Promise((resolve) => {
|
|
41
|
+
socket.once('close', () => {
|
|
42
|
+
console.log('[Sender] Socket closed');
|
|
43
|
+
resolve();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
// Add socket error handler
|
|
47
|
+
socket.on('error', (err) => {
|
|
48
|
+
console.error('[Sender] Socket error:', err);
|
|
49
|
+
});
|
|
50
|
+
// Peer connected! Start sending
|
|
51
|
+
setState('sending');
|
|
52
|
+
console.log('[Sender] Starting transfer pipeline');
|
|
53
|
+
// Create metadata prepend stream
|
|
54
|
+
const metadataJson = JSON.stringify({
|
|
55
|
+
totalSize: transferMetadata.totalSize,
|
|
56
|
+
fileCount: transferMetadata.fileCount,
|
|
57
|
+
isDirectory: transferMetadata.isDirectory,
|
|
58
|
+
compressed: shouldCompress(path),
|
|
59
|
+
}) + '\n';
|
|
60
|
+
let metadataSent = false;
|
|
61
|
+
const metadataPrepender = new Transform({
|
|
62
|
+
transform(chunk, encoding, callback) {
|
|
63
|
+
if (!metadataSent) {
|
|
64
|
+
// Send metadata as first chunk
|
|
65
|
+
this.push(Buffer.from(metadataJson));
|
|
66
|
+
metadataSent = true;
|
|
67
|
+
}
|
|
68
|
+
callback(null, chunk);
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
// Create progress tracker
|
|
72
|
+
let transferred = 0;
|
|
73
|
+
const progressTracker = new Transform({
|
|
74
|
+
transform(chunk, encoding, callback) {
|
|
75
|
+
transferred += chunk.length;
|
|
76
|
+
const pct = Math.round((transferred / transferMetadata.totalSize) * 100);
|
|
77
|
+
setProgress(pct);
|
|
78
|
+
callback(null, chunk);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
// Build the pipeline: Tar → Compress → Encrypt → Metadata → Progress → Socket
|
|
82
|
+
// Note: Metadata is added AFTER encryption so it's sent in plaintext
|
|
83
|
+
const packStream = createPackStream(path);
|
|
84
|
+
const compressStream = await createCompressStream(shouldCompress(path));
|
|
85
|
+
const encryptStream = createEncryptStream(encryptionKey);
|
|
86
|
+
await pipeline(packStream, compressStream, encryptStream, metadataPrepender, progressTracker, socket);
|
|
87
|
+
console.log('[Sender] Pipeline completed, waiting for socket to close');
|
|
88
|
+
// Wait for socket to fully close before cleaning up
|
|
89
|
+
// This ensures the receiver has finished and closed their end
|
|
90
|
+
await socketClosed;
|
|
91
|
+
console.log('[Sender] Socket closed, cleaning up swarm');
|
|
92
|
+
// Transfer complete
|
|
93
|
+
setState('done');
|
|
94
|
+
await cleanupSwarm(swarm);
|
|
95
|
+
if (onComplete) {
|
|
96
|
+
onComplete();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
|
101
|
+
setError(errorMessage);
|
|
102
|
+
setState('error');
|
|
103
|
+
if (onError) {
|
|
104
|
+
onError(err);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { bold: true, color: "green", children: ["\uD83D\uDCE4 Sending: ", path.split('/').pop()] }), metadata && (_jsxs(Text, { dimColor: true, children: ["Size: ", formatBytes(metadata.totalSize), " | Files: ", metadata.fileCount] })), state === 'init' && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Preparing transfer..." }) })), (state === 'waiting' || state === 'sending') && shareKey && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: "Share this key with receiver:" }), _jsx(Text, { bold: true, children: shareKey })] })), state === 'waiting' && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Waiting for peer to connect..." }) })), state === 'sending' && progress > 0 && progress < 100 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(ProgressBar, { value: progress }), _jsxs(Text, { children: [" ", progress, "%"] })] })), state === 'done' && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Transfer complete!" }), _jsx(Text, { dimColor: true, children: "Press Esc to return" })] })), state === 'error' && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "red", bold: true, children: "\u2717 Transfer failed" }), _jsx(Text, { color: "red", children: error }), _jsx(Text, { dimColor: true, children: "Press Esc to return" })] }))] }));
|
|
109
|
+
}
|
|
110
|
+
function formatBytes(bytes) {
|
|
111
|
+
if (bytes === 0)
|
|
112
|
+
return '0 B';
|
|
113
|
+
const k = 1024;
|
|
114
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
115
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
116
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
117
|
+
}
|