riptide-transfer 1.3.6__py3-none-any.whl
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.
- riptide/__init__.py +28 -0
- riptide/__main__.py +4 -0
- riptide/app.py +45 -0
- riptide/core/__init__.py +1 -0
- riptide/core/config.py +176 -0
- riptide/core/file_select.py +89 -0
- riptide/core/firewall.py +552 -0
- riptide/core/focus.py +82 -0
- riptide/core/identity.py +243 -0
- riptide/core/nat.py +102 -0
- riptide/core/session.py +887 -0
- riptide/core/transfer.py +89 -0
- riptide/screens/__init__.py +1 -0
- riptide/screens/create.py +529 -0
- riptide/screens/join.py +479 -0
- riptide/screens/menu.py +94 -0
- riptide/screens/session_screen.py +1059 -0
- riptide/theme.py +1049 -0
- riptide_transfer-1.3.6.dist-info/METADATA +176 -0
- riptide_transfer-1.3.6.dist-info/RECORD +23 -0
- riptide_transfer-1.3.6.dist-info/WHEEL +5 -0
- riptide_transfer-1.3.6.dist-info/entry_points.txt +2 -0
- riptide_transfer-1.3.6.dist-info/top_level.txt +1 -0
riptide/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Riptide: direct, encrypted PC-to-PC file transfer in the terminal."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import tomllib
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _load_version() -> str:
|
|
8
|
+
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
|
9
|
+
if pyproject_path.exists():
|
|
10
|
+
try:
|
|
11
|
+
data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
|
|
12
|
+
return str(data.get("project", {}).get("version", "0.0.0"))
|
|
13
|
+
except Exception:
|
|
14
|
+
pass
|
|
15
|
+
try:
|
|
16
|
+
from importlib.metadata import version
|
|
17
|
+
for dist_name in ("riptide-transfer", "riptide"):
|
|
18
|
+
try:
|
|
19
|
+
return version(dist_name)
|
|
20
|
+
except Exception:
|
|
21
|
+
continue
|
|
22
|
+
except Exception:
|
|
23
|
+
pass
|
|
24
|
+
return "0.0.0"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__version__ = _load_version()
|
|
28
|
+
__all__ = ["__version__"]
|
riptide/__main__.py
ADDED
riptide/app.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Riptide Textual app. Entry point: riptide = riptide.app:main"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from textual.app import App
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .theme import CSS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RiptideApp(App):
|
|
14
|
+
CSS = CSS
|
|
15
|
+
TITLE = "riptide"
|
|
16
|
+
|
|
17
|
+
def render(self):
|
|
18
|
+
from textual.renderables.blank import Blank
|
|
19
|
+
return Blank("transparent")
|
|
20
|
+
|
|
21
|
+
def on_mount(self) -> None:
|
|
22
|
+
self.styles.background = "transparent"
|
|
23
|
+
from .screens.menu import MenuScreen
|
|
24
|
+
|
|
25
|
+
self.push_screen(MenuScreen())
|
|
26
|
+
|
|
27
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="riptide",
|
|
30
|
+
description="Direct, encrypted PC-to-PC file transfer in the terminal.",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument("--version", action="version", version=f"riptide {__version__}")
|
|
33
|
+
return parser
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: list[str] | None = None) -> int:
|
|
37
|
+
parser = build_parser()
|
|
38
|
+
parser.parse_args(argv)
|
|
39
|
+
app = RiptideApp()
|
|
40
|
+
app.run()
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
sys.exit(main())
|
riptide/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core, non-UI logic for riptide."""
|
riptide/core/config.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Saved connections and settings.
|
|
2
|
+
|
|
3
|
+
Location is always Path.home() / ".riptide" / "config.toml", on every OS.
|
|
4
|
+
Private keys and certificates are never stored here. A peer is identified
|
|
5
|
+
by host + TLS certificate fingerprint, approved in the UI on first connect.
|
|
6
|
+
|
|
7
|
+
Since v1.1, the host side also keeps trusted clients, separate from
|
|
8
|
+
dial-out peers. A trusted client is identified by fingerprint, with
|
|
9
|
+
the device name as a display label only. The dial-out peer list is
|
|
10
|
+
keyed by host, for filling the Join address, while the trusted
|
|
11
|
+
client list is keyed by fingerprint, for host-side authentication.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import tomllib
|
|
20
|
+
except ModuleNotFoundError: # Python < 3.11, not supported by pyproject
|
|
21
|
+
import tomli as tomllib # type: ignore
|
|
22
|
+
|
|
23
|
+
# Session port. Kept in code/config only, except for the
|
|
24
|
+
# Create/Join header and waiting text.
|
|
25
|
+
# 50000 is in the dynamic/private range (49152-65535), so it is not
|
|
26
|
+
# IANA-reserved, but it is the de facto default for IBM DB2, Jenkins
|
|
27
|
+
# agents, and SAP NetWeaver Java, so a machine running one of those
|
|
28
|
+
# will already have it in use and Riptide will fail to bind.
|
|
29
|
+
DEFAULT_SESSION_PORT = 50000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def config_dir(home: Path | None = None) -> Path:
|
|
33
|
+
return (home or Path.home()) / ".riptide"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def config_path(home: Path | None = None) -> Path:
|
|
37
|
+
return config_dir(home) / "config.toml"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Peer:
|
|
42
|
+
name: str
|
|
43
|
+
host: str = ""
|
|
44
|
+
fingerprint: str = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Config:
|
|
49
|
+
peers: list[Peer] = field(default_factory=list)
|
|
50
|
+
trusted_clients: list[Peer] = field(default_factory=list)
|
|
51
|
+
session_port: int = DEFAULT_SESSION_PORT
|
|
52
|
+
firewall_port_open: bool | None = None
|
|
53
|
+
def peer_for(self, host: str) -> Peer | None:
|
|
54
|
+
for peer in self.peers:
|
|
55
|
+
if peer.host == host:
|
|
56
|
+
return peer
|
|
57
|
+
return None
|
|
58
|
+
def trusted_client_for_fingerprint(self, fingerprint: str) -> Peer | None:
|
|
59
|
+
for peer in self.trusted_clients:
|
|
60
|
+
if peer.fingerprint and peer.fingerprint == fingerprint:
|
|
61
|
+
return peer
|
|
62
|
+
return None
|
|
63
|
+
def trusted_client_for_device(self, device: str) -> Peer | None:
|
|
64
|
+
for peer in self.trusted_clients:
|
|
65
|
+
if peer.name and peer.name == device:
|
|
66
|
+
return peer
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _escape(value: str) -> str:
|
|
71
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _contains_private_key(text: str) -> bool:
|
|
75
|
+
return "PRIVATE KEY" in text or "-----BEGIN" in text
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_config(home: Path | None = None) -> Config:
|
|
79
|
+
path = config_path(home)
|
|
80
|
+
if not path.exists():
|
|
81
|
+
return Config()
|
|
82
|
+
try:
|
|
83
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
84
|
+
except Exception:
|
|
85
|
+
return Config()
|
|
86
|
+
peers = []
|
|
87
|
+
for item in data.get("peer", []) if isinstance(data.get("peer", []), list) else []:
|
|
88
|
+
if not isinstance(item, dict):
|
|
89
|
+
continue
|
|
90
|
+
peers.append(Peer(
|
|
91
|
+
name=str(item.get("name", "")),
|
|
92
|
+
host=str(item.get("host", "")),
|
|
93
|
+
fingerprint=str(item.get("fingerprint", "")),
|
|
94
|
+
))
|
|
95
|
+
trusted_clients = []
|
|
96
|
+
for item in data.get("trusted_client", []) if isinstance(data.get("trusted_client", []), list) else []:
|
|
97
|
+
if not isinstance(item, dict):
|
|
98
|
+
continue
|
|
99
|
+
trusted_clients.append(Peer(
|
|
100
|
+
name=str(item.get("name", "")),
|
|
101
|
+
host=str(item.get("host", "")),
|
|
102
|
+
fingerprint=str(item.get("fingerprint", "")),
|
|
103
|
+
))
|
|
104
|
+
session_port = data.get("session_port", DEFAULT_SESSION_PORT)
|
|
105
|
+
try:
|
|
106
|
+
session_port = int(session_port)
|
|
107
|
+
except Exception:
|
|
108
|
+
session_port = DEFAULT_SESSION_PORT
|
|
109
|
+
firewall_val = data.get("firewall_port_open")
|
|
110
|
+
firewall_port_open = bool(firewall_val) if firewall_val is not None else None
|
|
111
|
+
return Config(peers=peers, trusted_clients=trusted_clients, session_port=session_port, firewall_port_open=firewall_port_open)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def save_config(config: Config, home: Path | None = None) -> Path:
|
|
115
|
+
for peer in list(config.peers) + list(config.trusted_clients):
|
|
116
|
+
for value in (peer.name, peer.host, peer.fingerprint):
|
|
117
|
+
if _contains_private_key(value):
|
|
118
|
+
raise ValueError("refusing to store a private key in config")
|
|
119
|
+
directory = config_dir(home)
|
|
120
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
lines = [f"session_port = {int(config.session_port)}"]
|
|
122
|
+
if config.firewall_port_open is not None:
|
|
123
|
+
lines.append(f"firewall_port_open = {'true' if config.firewall_port_open else 'false'}")
|
|
124
|
+
lines.append("")
|
|
125
|
+
for peer in config.peers:
|
|
126
|
+
lines.append("[[peer]]")
|
|
127
|
+
lines.append(f'name = "{_escape(peer.name)}"')
|
|
128
|
+
lines.append(f'host = "{_escape(peer.host)}"')
|
|
129
|
+
lines.append(f'fingerprint = "{_escape(peer.fingerprint)}"')
|
|
130
|
+
lines.append("")
|
|
131
|
+
for peer in config.trusted_clients:
|
|
132
|
+
lines.append("[[trusted_client]]")
|
|
133
|
+
lines.append(f'name = "{_escape(peer.name)}"')
|
|
134
|
+
lines.append(f'host = "{_escape(peer.host)}"')
|
|
135
|
+
lines.append(f'fingerprint = "{_escape(peer.fingerprint)}"')
|
|
136
|
+
lines.append("")
|
|
137
|
+
path = config_path(home)
|
|
138
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
139
|
+
return path
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def add_peer(config: Config, peer: Peer, home: Path | None = None) -> Config:
|
|
143
|
+
existing = config.peer_for(peer.host) if peer.host else None
|
|
144
|
+
if existing is not None:
|
|
145
|
+
existing.name = peer.name or existing.name
|
|
146
|
+
existing.fingerprint = peer.fingerprint or existing.fingerprint
|
|
147
|
+
else:
|
|
148
|
+
config.peers.append(peer)
|
|
149
|
+
save_config(config, home=home)
|
|
150
|
+
return config
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def add_trusted_client(config: Config, peer: Peer, home: Path | None = None) -> Config:
|
|
154
|
+
"""Save a host-side trusted client, keyed by fingerprint.
|
|
155
|
+
|
|
156
|
+
If the fingerprint is already trusted, update its label.
|
|
157
|
+
If the device label is already trusted with a different
|
|
158
|
+
fingerprint, replace that fingerprint, which is the explicit
|
|
159
|
+
Replace flow after a changed-fingerprint warning. Otherwise
|
|
160
|
+
append a new trusted client.
|
|
161
|
+
"""
|
|
162
|
+
if peer.fingerprint:
|
|
163
|
+
existing = config.trusted_client_for_fingerprint(peer.fingerprint)
|
|
164
|
+
if existing is not None:
|
|
165
|
+
existing.name = peer.name or existing.name
|
|
166
|
+
save_config(config, home=home)
|
|
167
|
+
return config
|
|
168
|
+
if peer.name:
|
|
169
|
+
by_device = config.trusted_client_for_device(peer.name)
|
|
170
|
+
if by_device is not None:
|
|
171
|
+
by_device.fingerprint = peer.fingerprint
|
|
172
|
+
save_config(config, home=home)
|
|
173
|
+
return config
|
|
174
|
+
config.trusted_clients.append(peer)
|
|
175
|
+
save_config(config, home=home)
|
|
176
|
+
return config
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""File selection: the file manager listing and display.
|
|
2
|
+
|
|
3
|
+
Files are selected only from the file manager, with Space, and
|
|
4
|
+
sent with Enter. This module is deliberately stdlib-only so it
|
|
5
|
+
is easy to test.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def format_bytes(n: int) -> str:
|
|
15
|
+
n = int(n)
|
|
16
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
17
|
+
if n < 1024 or unit == "TB":
|
|
18
|
+
return f"{n:.1f} {unit}" if unit != "B" else f"{n} B"
|
|
19
|
+
n = n / 1024 # type: ignore[assignment]
|
|
20
|
+
return f"{n} B"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def total_size(paths: list[Path]) -> int:
|
|
24
|
+
total = 0
|
|
25
|
+
for path in paths:
|
|
26
|
+
try:
|
|
27
|
+
if path.is_file():
|
|
28
|
+
total += path.stat().st_size
|
|
29
|
+
except OSError:
|
|
30
|
+
pass
|
|
31
|
+
return total
|
|
32
|
+
|
|
33
|
+
def format_mtime(mtime: float) -> str:
|
|
34
|
+
"""Format modification timestamp as strictly dd-mm-yyyy hh:mm am/pm in lowercase."""
|
|
35
|
+
dt = datetime.fromtimestamp(mtime)
|
|
36
|
+
return dt.strftime("%d-%m-%Y %I:%M %p").lower()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def format_file_row(path: Path) -> tuple[str, str, str]:
|
|
40
|
+
"""Return (filename, size_str, mtime_str) for displaying in file tables."""
|
|
41
|
+
stat = path.stat()
|
|
42
|
+
return path.name, format_bytes(stat.st_size), format_mtime(stat.st_mtime)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def add_to_selection(selection: list[Path], new: list[Path]) -> list[Path]:
|
|
52
|
+
seen = set(selection)
|
|
53
|
+
for path in new:
|
|
54
|
+
if path not in seen:
|
|
55
|
+
seen.add(path)
|
|
56
|
+
selection.append(path)
|
|
57
|
+
return selection
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def list_browser_entries(directory: Path) -> list[Path]:
|
|
65
|
+
"""File-manager entries for a directory: folders first, then
|
|
66
|
+
files, each group alphabetical. Hidden entries are kept."""
|
|
67
|
+
if not directory.is_dir():
|
|
68
|
+
return []
|
|
69
|
+
try:
|
|
70
|
+
entries = list(directory.iterdir())
|
|
71
|
+
except OSError:
|
|
72
|
+
return []
|
|
73
|
+
folders = sorted((p for p in entries if p.is_dir()), key=lambda p: p.name.lower())
|
|
74
|
+
files = sorted((p for p in entries if p.is_file()), key=lambda p: p.name.lower())
|
|
75
|
+
return folders + files
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def format_browser_row(path: Path) -> tuple[str, str, str]:
|
|
79
|
+
"""Display row for the file manager: (display, size, mtime).
|
|
80
|
+
|
|
81
|
+
Folders get a folder icon and a trailing slash, and no size
|
|
82
|
+
or date: ``📁 docs/`` with empty size/mtime. Files keep the
|
|
83
|
+
name / size / mtime format."""
|
|
84
|
+
if path.is_dir():
|
|
85
|
+
return f"📁 {path.name}/", "", ""
|
|
86
|
+
name, size, mtime = format_file_row(path)
|
|
87
|
+
return name, size, mtime
|
|
88
|
+
|
|
89
|
+
|