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 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
@@ -0,0 +1,4 @@
1
+ from .app import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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())
@@ -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
+