torrent-tui 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/bin/torrent-tui +26 -0
- package/package.json +58 -0
- package/src/app.ts +157 -0
- package/src/config/index.ts +41 -0
- package/src/config/settings.ts +15 -0
- package/src/constants/index.ts +16 -0
- package/src/controllers/app-controller.ts +180 -0
- package/src/index.ts +320 -0
- package/src/layout/add-torrent-dialog.ts +170 -0
- package/src/layout/confirm-dialog.ts +141 -0
- package/src/layout/content-window.ts +80 -0
- package/src/layout/sidebar.ts +121 -0
- package/src/layout/status-bar.ts +79 -0
- package/src/layout/toast-manager.ts +109 -0
- package/src/layout/toast.ts +257 -0
- package/src/layout/torrent-view.ts +250 -0
- package/src/store/index.ts +51 -0
- package/src/theme/default.ts +22 -0
- package/src/theme/index.ts +19 -0
- package/src/theme/types.ts +26 -0
- package/src/torrent/bridge.ts +301 -0
- package/src/torrent/downloader.ts +415 -0
- package/src/torrent/get_peers.ts +212 -0
- package/src/torrent/metadata.ts +190 -0
- package/src/torrent/parser.ts +216 -0
- package/src/torrent/peer/connection.ts +278 -0
- package/src/torrent/peer/handshake.ts +48 -0
- package/src/torrent/peer/listener.ts +52 -0
- package/src/torrent/peer/manager.ts +233 -0
- package/src/torrent/peer/message-buffer.ts +31 -0
- package/src/torrent/peer/peer-id.ts +21 -0
- package/src/torrent/peer/protocol.ts +123 -0
- package/src/torrent/piece-picker.ts +58 -0
- package/src/torrent/session.ts +56 -0
- package/src/torrent/storage.ts +197 -0
- package/src/torrent/tracker/announce.ts +36 -0
- package/src/torrent/tracker/http-tracker.ts +143 -0
- package/src/torrent/tracker/udp-tracker.ts +136 -0
- package/src/torrent/types.ts +25 -0
- package/src/types/layout.ts +6 -0
- package/src/utils/env.ts +8 -0
- package/src/utils/filter.ts +12 -0
- package/src/utils/layout.ts +32 -0
- package/src/utils/paths.ts +17 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aditya
|
|
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,156 @@
|
|
|
1
|
+
# torrent-tui
|
|
2
|
+
|
|
3
|
+
**A Bun-powered terminal BitTorrent client.** Add `.torrent` files, manage active downloads, and inspect transfer state from a focused TUI.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/torrent-tui)
|
|
6
|
+
[](https://github.com/ryadios/torrent-tui/actions/workflows/ci.yml)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
|
|
9
|
+
[Install](#install) · [Quickstart](#quickstart) · [Commands](#commands) · [Configuration](#configuration) · [Development](#development)
|
|
10
|
+
|
|
11
|
+
> [!NOTE]
|
|
12
|
+
> `torrent-tui` currently requires [Bun](https://bun.sh). Standalone binaries are planned after the npm CLI release path is stable.
|
|
13
|
+
|
|
14
|
+
> [!IMPORTANT]
|
|
15
|
+
> Use torrent clients only with content you have the right to download or share. `torrent-tui` is a client implementation, not a content source.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
Run without installing:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bunx torrent-tui@latest
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or install globally:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bun add -g torrent-tui
|
|
29
|
+
torrent-tui
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
> [!TIP]
|
|
33
|
+
> If the global command is not found, add Bun's global bin directory to your shell path:
|
|
34
|
+
>
|
|
35
|
+
> ```bash
|
|
36
|
+
> export PATH="$HOME/.bun/bin:$HOME/.cache/.bun/bin:$PATH"
|
|
37
|
+
> ```
|
|
38
|
+
|
|
39
|
+
## Quickstart
|
|
40
|
+
|
|
41
|
+
Start the TUI:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
torrent-tui
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
From inside the app:
|
|
48
|
+
|
|
49
|
+
| Key | Action |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `j` / `k` or arrow keys | Move selection |
|
|
52
|
+
| `Tab` | Change focus |
|
|
53
|
+
| `a` | Add a `.torrent` file |
|
|
54
|
+
| `Space` | Pause or resume the selected torrent |
|
|
55
|
+
| `d` | Remove the selected torrent |
|
|
56
|
+
| `D` | Remove the selected torrent and downloaded files |
|
|
57
|
+
| `q` | Quit |
|
|
58
|
+
|
|
59
|
+
## Commands
|
|
60
|
+
|
|
61
|
+
The package also exposes a few command-line checks around the same torrent engine:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
torrent-tui --help
|
|
65
|
+
torrent-tui --version
|
|
66
|
+
torrent-tui file.torrent --verify
|
|
67
|
+
torrent-tui file.torrent --handshake
|
|
68
|
+
torrent-tui file.torrent --download
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Command | Description |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `torrent-tui` | Start the terminal UI. |
|
|
74
|
+
| `torrent-tui <file.torrent>` | Announce to trackers and print peers. |
|
|
75
|
+
| `torrent-tui <file.torrent> --verify` | Create storage and verify local pieces. |
|
|
76
|
+
| `torrent-tui <file.torrent> --handshake` | Connect to peers and print a connection summary. |
|
|
77
|
+
| `torrent-tui <file.torrent> --download` | Run the downloader without launching the TUI. |
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
Settings are stored at:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
${XDG_CONFIG_HOME:-~/.config}/torrent-tui/settings.json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Default settings:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"downloadPath": "~/Downloads",
|
|
92
|
+
"maxConnections": 50,
|
|
93
|
+
"torrentFolder": "~/Downloads"
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Resume data is stored under:
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
${XDG_DATA_HOME:-~/.local/share}/torrent-tui/resume
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Status
|
|
104
|
+
|
|
105
|
+
`0.0.1` is a basic release intended for early CLI usage.
|
|
106
|
+
|
|
107
|
+
| Area | Status |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| `.torrent` metadata parsing | Available |
|
|
110
|
+
| HTTP and UDP trackers | Available |
|
|
111
|
+
| Peer handshakes and piece download | Available |
|
|
112
|
+
| Resume data | Available |
|
|
113
|
+
| Multi-torrent TUI | Available |
|
|
114
|
+
| Magnet links | Not included yet |
|
|
115
|
+
| Standalone binaries | Not included yet |
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
bun install
|
|
121
|
+
bun run dev
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Before opening a PR:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
bun run typecheck
|
|
128
|
+
bun publish --dry-run
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
For formatting and lint fixes:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
bun run check:fix
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Release Flow
|
|
138
|
+
|
|
139
|
+
Releases are published from GitHub Actions with generated GitHub release notes.
|
|
140
|
+
|
|
141
|
+
1. Update `package.json` and `src/constants/index.ts` to the new version.
|
|
142
|
+
2. Run local checks:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
bun run typecheck
|
|
146
|
+
bun publish --dry-run
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
3. Commit and push the version change.
|
|
150
|
+
4. Run the **Release** workflow manually with the version number.
|
|
151
|
+
|
|
152
|
+
The workflow publishes to npm with trusted publishing, creates `vX.Y.Z`, and creates a GitHub release with `--generate-notes`.
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
package/bin/torrent-tui
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const binDir = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const entrypoint = join(binDir, "../src/index.ts");
|
|
9
|
+
const bun = process.platform === "win32" ? "bun.exe" : "bun";
|
|
10
|
+
|
|
11
|
+
const result = spawnSync(bun, [entrypoint, ...process.argv.slice(2)], {
|
|
12
|
+
stdio: "inherit",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
if (result.error) {
|
|
16
|
+
if ("code" in result.error && result.error.code === "ENOENT") {
|
|
17
|
+
console.error("torrent-tui requires Bun. Install it from https://bun.sh");
|
|
18
|
+
} else {
|
|
19
|
+
console.error(result.error.message);
|
|
20
|
+
}
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
} else if (result.signal) {
|
|
23
|
+
process.kill(process.pid, result.signal);
|
|
24
|
+
} else {
|
|
25
|
+
process.exitCode = result.status ?? 0;
|
|
26
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "torrent-tui",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A Bun-powered terminal BitTorrent client.",
|
|
5
|
+
"module": "src/index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"private": false,
|
|
8
|
+
"packageManager": "bun@1.2.21",
|
|
9
|
+
"bin": {
|
|
10
|
+
"torrent-tui": "bin/torrent-tui"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "bun src/index.ts",
|
|
20
|
+
"dev": "bun --watch src/index.ts",
|
|
21
|
+
"check": "bun src/index.ts --download",
|
|
22
|
+
"smoke": "bin/torrent-tui --help && bin/torrent-tui --version && if bin/torrent-tui nope.txt; then echo \"Expected invalid torrent path to fail\"; exit 1; fi",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"check:fix": "biome check --write --unsafe",
|
|
25
|
+
"release:check": "bun run typecheck && bun publish --dry-run"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"bittorrent",
|
|
29
|
+
"torrent",
|
|
30
|
+
"tui",
|
|
31
|
+
"terminal",
|
|
32
|
+
"cli",
|
|
33
|
+
"bun",
|
|
34
|
+
"opentui"
|
|
35
|
+
],
|
|
36
|
+
"author": "Aditya",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/ryadios/torrent-tui.git"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/ryadios/torrent-tui/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/ryadios/torrent-tui#readme",
|
|
46
|
+
"engines": {
|
|
47
|
+
"bun": ">=1.0.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@biomejs/biome": "2.4.8",
|
|
51
|
+
"@types/bun": "latest",
|
|
52
|
+
"typescript": "^5"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@opentui/core": "^0.1.90",
|
|
56
|
+
"zod": "^3.23.8"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { type CliRenderer, createCliRenderer } from "@opentui/core";
|
|
2
|
+
import { loadConfig } from "./config";
|
|
3
|
+
import { AppController } from "./controllers/app-controller";
|
|
4
|
+
import { AddTorrentDialog } from "./layout/add-torrent-dialog";
|
|
5
|
+
import { ConfirmDialog } from "./layout/confirm-dialog";
|
|
6
|
+
import { ContentWindow } from "./layout/content-window";
|
|
7
|
+
import { Sidebar } from "./layout/sidebar";
|
|
8
|
+
import { StatusBar } from "./layout/status-bar";
|
|
9
|
+
import { ToastManager } from "./layout/toast-manager";
|
|
10
|
+
import { Store } from "./store";
|
|
11
|
+
import { TorrentBridge } from "./torrent/bridge";
|
|
12
|
+
import type { LayoutDimensions } from "./types/layout";
|
|
13
|
+
import { calculateLayout } from "./utils/layout";
|
|
14
|
+
|
|
15
|
+
const INITIAL_STATE = {
|
|
16
|
+
selectedIndex: 0,
|
|
17
|
+
selectedView: "All",
|
|
18
|
+
torrents: [],
|
|
19
|
+
totalDownloadBps: 0,
|
|
20
|
+
totalUploadBps: 0,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class App {
|
|
24
|
+
private renderer!: CliRenderer;
|
|
25
|
+
private store!: Store;
|
|
26
|
+
private sidebar!: Sidebar;
|
|
27
|
+
private contentWindow!: ContentWindow;
|
|
28
|
+
private statusBar!: StatusBar;
|
|
29
|
+
private toastManager!: ToastManager;
|
|
30
|
+
private controller!: AppController;
|
|
31
|
+
private bridge!: TorrentBridge;
|
|
32
|
+
private addDialog!: AddTorrentDialog;
|
|
33
|
+
private confirmDialog!: ConfirmDialog;
|
|
34
|
+
private layout!: LayoutDimensions;
|
|
35
|
+
private resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
36
|
+
|
|
37
|
+
async start(): Promise<void> {
|
|
38
|
+
const config = loadConfig();
|
|
39
|
+
|
|
40
|
+
this.renderer = await createCliRenderer({ exitOnCtrlC: true });
|
|
41
|
+
this.store = new Store(INITIAL_STATE);
|
|
42
|
+
this.layout = calculateLayout(this.renderer.width, this.renderer.height);
|
|
43
|
+
this.bridge = new TorrentBridge(this.store, config);
|
|
44
|
+
|
|
45
|
+
this.sidebar = new Sidebar(this.renderer, this.store, this.layout);
|
|
46
|
+
this.contentWindow = new ContentWindow(
|
|
47
|
+
this.renderer,
|
|
48
|
+
this.store,
|
|
49
|
+
this.layout,
|
|
50
|
+
);
|
|
51
|
+
this.statusBar = new StatusBar(this.renderer, this.layout);
|
|
52
|
+
this.toastManager = new ToastManager(this.renderer, this.layout);
|
|
53
|
+
this.addDialog = new AddTorrentDialog(
|
|
54
|
+
this.renderer,
|
|
55
|
+
this.layout,
|
|
56
|
+
config.torrentFolder,
|
|
57
|
+
);
|
|
58
|
+
this.confirmDialog = new ConfirmDialog(this.renderer, this.layout);
|
|
59
|
+
|
|
60
|
+
this.controller = new AppController(
|
|
61
|
+
this.renderer,
|
|
62
|
+
this.store,
|
|
63
|
+
this.sidebar,
|
|
64
|
+
this.contentWindow,
|
|
65
|
+
this.toastManager,
|
|
66
|
+
);
|
|
67
|
+
// Wire ConfirmDialog — callbacks are set inside the controller setter
|
|
68
|
+
this.controller.confirmDialog = this.confirmDialog;
|
|
69
|
+
|
|
70
|
+
// Wire controller callbacks
|
|
71
|
+
this.controller.onAddTorrent = () => {
|
|
72
|
+
this.addDialog.open();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
this.controller.onDialogClose = () => {
|
|
76
|
+
this.addDialog.close();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
this.controller.onDialogInput = (key) => {
|
|
80
|
+
return this.addDialog.handleInput(key);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
this.controller.onQuit = async () => {
|
|
84
|
+
await this.bridge.stopAll();
|
|
85
|
+
this.renderer.destroy();
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
this.controller.onPauseTorrent = (id) => {
|
|
89
|
+
this.bridge.pauseTorrent(id);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
this.controller.onResumeTorrent = (id) => {
|
|
93
|
+
this.bridge.resumeTorrent(id);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
this.controller.onStartTorrent = (id) => {
|
|
97
|
+
this.bridge.startTorrent(id);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
this.controller.onRemoveTorrent = (id, deleteFiles) => {
|
|
101
|
+
this.bridge.removeTorrent(id, deleteFiles);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
this.addDialog.onSelect = async (filePath) => {
|
|
105
|
+
this.controller.focusMode = "global";
|
|
106
|
+
const filename = filePath.split("/").pop() ?? filePath;
|
|
107
|
+
this.toastManager.show({
|
|
108
|
+
id: `add-${Date.now()}`,
|
|
109
|
+
type: "info",
|
|
110
|
+
title: "Adding torrent",
|
|
111
|
+
message: filename,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await this.bridge.addTorrent(filePath);
|
|
116
|
+
this.toastManager.show({
|
|
117
|
+
id: `added-${Date.now()}`,
|
|
118
|
+
type: "success",
|
|
119
|
+
title: "Download started",
|
|
120
|
+
message: filename,
|
|
121
|
+
});
|
|
122
|
+
} catch (err) {
|
|
123
|
+
this.toastManager.show({
|
|
124
|
+
id: `err-${Date.now()}`,
|
|
125
|
+
type: "error",
|
|
126
|
+
title: "Failed to add",
|
|
127
|
+
message: err instanceof Error ? err.message : String(err),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
this.store.subscribe((state) => {
|
|
133
|
+
this.statusBar.update(state);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await this.bridge.restoreSession();
|
|
137
|
+
this.controller.start();
|
|
138
|
+
|
|
139
|
+
this.renderer.on("resize", (width: number, height: number) => {
|
|
140
|
+
this.handleResize(width, height);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private handleResize(width: number, height: number): void {
|
|
145
|
+
if (this.resizeTimeout) clearTimeout(this.resizeTimeout);
|
|
146
|
+
this.resizeTimeout = setTimeout(() => {
|
|
147
|
+
this.resizeTimeout = null;
|
|
148
|
+
this.layout = calculateLayout(width, height);
|
|
149
|
+
this.sidebar.updateLayout(this.layout);
|
|
150
|
+
this.contentWindow.updateLayout(this.layout);
|
|
151
|
+
this.statusBar.updateLayout(this.layout);
|
|
152
|
+
this.toastManager.updateLayout(this.layout);
|
|
153
|
+
this.addDialog.updateLayout(this.layout);
|
|
154
|
+
this.confirmDialog.updateLayout(this.layout);
|
|
155
|
+
}, 100);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Config loader - reads from ~/.config/torrent-tui/settings.json
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { getConfigDir, getConfigPath } from "../utils/paths";
|
|
5
|
+
import { type AppSettings, DEFAULT_SETTINGS, settingsSchema } from "./settings";
|
|
6
|
+
|
|
7
|
+
const SETTINGS_FILE = "settings.json";
|
|
8
|
+
|
|
9
|
+
export function loadConfig(): AppSettings {
|
|
10
|
+
const configPath = getConfigPath(SETTINGS_FILE);
|
|
11
|
+
|
|
12
|
+
if (!existsSync(configPath)) {
|
|
13
|
+
// Create config dir and save default settings on first run
|
|
14
|
+
saveConfig(DEFAULT_SETTINGS);
|
|
15
|
+
return { ...DEFAULT_SETTINGS };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const content = readFileSync(configPath, "utf-8");
|
|
20
|
+
const raw = JSON.parse(content);
|
|
21
|
+
const result = settingsSchema.safeParse(raw);
|
|
22
|
+
|
|
23
|
+
if (result.success) {
|
|
24
|
+
return result.data;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { ...DEFAULT_SETTINGS };
|
|
28
|
+
} catch {
|
|
29
|
+
return { ...DEFAULT_SETTINGS };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function saveConfig(settings: AppSettings): void {
|
|
34
|
+
try {
|
|
35
|
+
mkdirSync(getConfigDir(), { recursive: true });
|
|
36
|
+
const configPath = getConfigPath(SETTINGS_FILE);
|
|
37
|
+
writeFileSync(configPath, JSON.stringify(settings, null, 2), "utf-8");
|
|
38
|
+
} catch {
|
|
39
|
+
// Silently fail
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const settingsSchema = z.object({
|
|
4
|
+
downloadPath: z.string().default("~/Downloads"),
|
|
5
|
+
maxConnections: z.number().min(1).max(500).default(50),
|
|
6
|
+
torrentFolder: z.string().default("~/Downloads"),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export type AppSettings = z.infer<typeof settingsSchema>;
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_SETTINGS: AppSettings = {
|
|
12
|
+
downloadPath: "~/Downloads",
|
|
13
|
+
maxConnections: 50,
|
|
14
|
+
torrentFolder: "~/Downloads",
|
|
15
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const APP_NAME = "torrent-tui";
|
|
2
|
+
export const VERSION = "0.0.1";
|
|
3
|
+
|
|
4
|
+
export const SIDEBAR_WIDTH = 20;
|
|
5
|
+
|
|
6
|
+
export const SIDEBAR_ITEMS = {
|
|
7
|
+
status: ["All", "Downloading", "Seeding", "Completed", "Stopped"],
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export type SidebarSection = keyof typeof SIDEBAR_ITEMS;
|
|
11
|
+
|
|
12
|
+
// Toast configuration
|
|
13
|
+
export const TOAST_WIDTH = 40;
|
|
14
|
+
export const TOAST_MARGIN = 1;
|
|
15
|
+
export const TOAST_DEFAULT_DURATION = 3000;
|
|
16
|
+
export const TOAST_MAX_COUNT = 3;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { CliRenderer, KeyEvent } from "@opentui/core";
|
|
2
|
+
import { SIDEBAR_ITEMS } from "../constants";
|
|
3
|
+
import type { ConfirmDialog } from "../layout/confirm-dialog";
|
|
4
|
+
import type { ContentWindow } from "../layout/content-window";
|
|
5
|
+
import type { Sidebar } from "../layout/sidebar";
|
|
6
|
+
import type { ToastManager } from "../layout/toast-manager";
|
|
7
|
+
import type { Store } from "../store";
|
|
8
|
+
import { filterTorrents } from "../utils/filter";
|
|
9
|
+
|
|
10
|
+
type FocusMode = "global" | "dialog";
|
|
11
|
+
type FocusArea = "sidebar" | "table";
|
|
12
|
+
|
|
13
|
+
export class AppController {
|
|
14
|
+
private renderer: CliRenderer;
|
|
15
|
+
private store: Store;
|
|
16
|
+
private sidebar: Sidebar;
|
|
17
|
+
private contentWindow: ContentWindow;
|
|
18
|
+
private toastManager: ToastManager;
|
|
19
|
+
focusMode: FocusMode = "global";
|
|
20
|
+
focusArea: FocusArea = "sidebar";
|
|
21
|
+
private tableSelectedIndex = 0;
|
|
22
|
+
private pendingDeleteId: string | null = null;
|
|
23
|
+
|
|
24
|
+
// Injected by App after bridge/dialog are created
|
|
25
|
+
onAddTorrent?: () => void;
|
|
26
|
+
onQuit?: () => void;
|
|
27
|
+
onDialogClose?: () => void;
|
|
28
|
+
onDialogInput?: (key: string) => boolean;
|
|
29
|
+
onPauseTorrent?: (id: string) => void;
|
|
30
|
+
onResumeTorrent?: (id: string) => void;
|
|
31
|
+
onStartTorrent?: (id: string) => void;
|
|
32
|
+
onRemoveTorrent?: (id: string, deleteFiles: boolean) => void;
|
|
33
|
+
|
|
34
|
+
private _confirmDialog: ConfirmDialog | null = null;
|
|
35
|
+
set confirmDialog(dialog: ConfirmDialog) {
|
|
36
|
+
this._confirmDialog = dialog;
|
|
37
|
+
dialog.onConfirm = () => {
|
|
38
|
+
this.focusMode = "global";
|
|
39
|
+
if (this.pendingDeleteId) {
|
|
40
|
+
this.onRemoveTorrent?.(this.pendingDeleteId, true);
|
|
41
|
+
this.pendingDeleteId = null;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
dialog.onCancel = () => {
|
|
45
|
+
this.focusMode = "global";
|
|
46
|
+
this.pendingDeleteId = null;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
renderer: CliRenderer,
|
|
52
|
+
store: Store,
|
|
53
|
+
sidebar: Sidebar,
|
|
54
|
+
contentWindow: ContentWindow,
|
|
55
|
+
toastManager: ToastManager,
|
|
56
|
+
) {
|
|
57
|
+
this.renderer = renderer;
|
|
58
|
+
this.store = store;
|
|
59
|
+
this.sidebar = sidebar;
|
|
60
|
+
this.contentWindow = contentWindow;
|
|
61
|
+
this.toastManager = toastManager;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
start(): void {
|
|
65
|
+
this.store.subscribe((state) => {
|
|
66
|
+
const len = filterTorrents(state.torrents, state.selectedView).length;
|
|
67
|
+
if (len > 0 && this.tableSelectedIndex >= len) {
|
|
68
|
+
this.tableSelectedIndex = len - 1;
|
|
69
|
+
} else if (len === 0) {
|
|
70
|
+
this.tableSelectedIndex = 0;
|
|
71
|
+
}
|
|
72
|
+
this.sidebar.update(state, this.focusArea);
|
|
73
|
+
this.contentWindow.update(this.focusArea, this.tableSelectedIndex);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
this.renderer.keyInput.on("keypress", (key) => {
|
|
77
|
+
this.handleKeyPress(key);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
this.refreshView();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private refreshView(): void {
|
|
84
|
+
const state = this.store.getState();
|
|
85
|
+
this.sidebar.update(state, this.focusArea);
|
|
86
|
+
this.contentWindow.update(this.focusArea, this.tableSelectedIndex);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private getSelectedId(): string | null {
|
|
90
|
+
const state = this.store.getState();
|
|
91
|
+
return filterTorrents(state.torrents, state.selectedView)[this.tableSelectedIndex]?.id ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private handleKeyPress(key: KeyEvent): void {
|
|
95
|
+
if (this.focusMode === "dialog") {
|
|
96
|
+
if (this._confirmDialog?.getIsOpen()) {
|
|
97
|
+
this._confirmDialog.handleInput(key.name);
|
|
98
|
+
} else {
|
|
99
|
+
if (key.name === "escape") {
|
|
100
|
+
this.focusMode = "global";
|
|
101
|
+
this.onDialogClose?.();
|
|
102
|
+
} else {
|
|
103
|
+
this.onDialogInput?.(key.name);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (this.toastManager.handleInput(key.name)) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (key.name === "tab") {
|
|
114
|
+
this.focusArea = this.focusArea === "sidebar" ? "table" : "sidebar";
|
|
115
|
+
this.refreshView();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (key.name === "j" || key.name === "down") {
|
|
120
|
+
if (this.focusArea === "sidebar") {
|
|
121
|
+
const total = SIDEBAR_ITEMS.status.length;
|
|
122
|
+
const state = this.store.getState();
|
|
123
|
+
const next = (state.selectedIndex + 1) % total;
|
|
124
|
+
this.store.setState({ selectedIndex: next, selectedView: SIDEBAR_ITEMS.status[next] ?? "All" });
|
|
125
|
+
} else {
|
|
126
|
+
const state = this.store.getState();
|
|
127
|
+
const len = filterTorrents(state.torrents, state.selectedView).length;
|
|
128
|
+
if (len > 0) {
|
|
129
|
+
this.tableSelectedIndex = Math.min(this.tableSelectedIndex + 1, len - 1);
|
|
130
|
+
this.refreshView();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} else if (key.name === "k" || key.name === "up") {
|
|
134
|
+
if (this.focusArea === "sidebar") {
|
|
135
|
+
const total = SIDEBAR_ITEMS.status.length;
|
|
136
|
+
const state = this.store.getState();
|
|
137
|
+
const prev = (state.selectedIndex - 1 + total) % total;
|
|
138
|
+
this.store.setState({ selectedIndex: prev, selectedView: SIDEBAR_ITEMS.status[prev] ?? "All" });
|
|
139
|
+
} else {
|
|
140
|
+
if (this.tableSelectedIndex > 0) {
|
|
141
|
+
this.tableSelectedIndex--;
|
|
142
|
+
this.refreshView();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} else if (key.name === "space") {
|
|
146
|
+
if (this.focusArea === "table") {
|
|
147
|
+
const id = this.getSelectedId();
|
|
148
|
+
if (!id) return;
|
|
149
|
+
const state = this.store.getState();
|
|
150
|
+
const torrent = filterTorrents(state.torrents, state.selectedView)[this.tableSelectedIndex];
|
|
151
|
+
if (!torrent) return;
|
|
152
|
+
if (torrent.status === "downloading") {
|
|
153
|
+
this.onPauseTorrent?.(id);
|
|
154
|
+
} else if (torrent.status === "paused") {
|
|
155
|
+
this.onResumeTorrent?.(id);
|
|
156
|
+
} else if (torrent.status === "stopped") {
|
|
157
|
+
this.onStartTorrent?.(id);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} else if (key.name === "d" && !key.shift) {
|
|
161
|
+
if (this.focusArea === "table") {
|
|
162
|
+
const id = this.getSelectedId();
|
|
163
|
+
if (id) this.onRemoveTorrent?.(id, false);
|
|
164
|
+
}
|
|
165
|
+
} else if (key.name === "d" && key.shift) {
|
|
166
|
+
if (this.focusArea === "table") {
|
|
167
|
+
const id = this.getSelectedId();
|
|
168
|
+
if (!id) return;
|
|
169
|
+
this.pendingDeleteId = id;
|
|
170
|
+
this.focusMode = "dialog";
|
|
171
|
+
this._confirmDialog?.open("Delete torrent and files?");
|
|
172
|
+
}
|
|
173
|
+
} else if (key.name === "a") {
|
|
174
|
+
this.focusMode = "dialog";
|
|
175
|
+
this.onAddTorrent?.();
|
|
176
|
+
} else if (key.name === "q") {
|
|
177
|
+
this.onQuit?.();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|