fileclip 0.1.0__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.
- fileclip/__init__.py +8 -0
- fileclip/__main__.py +6 -0
- fileclip/cli.py +228 -0
- fileclip/server.py +88 -0
- fileclip/static/app.css +438 -0
- fileclip/static/app.js +943 -0
- fileclip/static/index.html +57 -0
- fileclip-0.1.0.dist-info/METADATA +90 -0
- fileclip-0.1.0.dist-info/RECORD +12 -0
- fileclip-0.1.0.dist-info/WHEEL +4 -0
- fileclip-0.1.0.dist-info/entry_points.txt +3 -0
- fileclip-0.1.0.dist-info/licenses/LICENSE +21 -0
fileclip/__init__.py
ADDED
fileclip/__main__.py
ADDED
fileclip/cli.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Command-line interface for FileClip."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import socket
|
|
7
|
+
import webbrowser
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
import uvicorn
|
|
14
|
+
|
|
15
|
+
from fileclip.server import LaunchConfig, create_app
|
|
16
|
+
|
|
17
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
18
|
+
DEFAULT_PORT = 0
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
add_completion=False,
|
|
22
|
+
help="Run the local FileClip clipboard-envelope web app.",
|
|
23
|
+
no_args_is_help=False,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ServerLaunch:
|
|
29
|
+
"""Resolved server launch details."""
|
|
30
|
+
|
|
31
|
+
host: str
|
|
32
|
+
requested_port: int
|
|
33
|
+
port: int
|
|
34
|
+
url: str
|
|
35
|
+
sockets: list[socket.socket] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_loopback_host(host: str) -> bool:
|
|
39
|
+
"""Return whether host is an accepted loopback bind address."""
|
|
40
|
+
|
|
41
|
+
normalized = host.strip().lower()
|
|
42
|
+
if normalized == "localhost":
|
|
43
|
+
return True
|
|
44
|
+
try:
|
|
45
|
+
return ipaddress.ip_address(normalized).is_loopback
|
|
46
|
+
except ValueError:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def format_host_for_url(host: str) -> str:
|
|
51
|
+
"""Format a host for use in an HTTP URL."""
|
|
52
|
+
|
|
53
|
+
if ":" in host and not host.startswith("["):
|
|
54
|
+
return f"[{host}]"
|
|
55
|
+
return host
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_app_url(host: str, port: int) -> str:
|
|
59
|
+
"""Build the browser URL for a FileClip server."""
|
|
60
|
+
|
|
61
|
+
return f"http://{format_host_for_url(host)}:{port}/"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _bind_socket(host: str) -> socket.socket:
|
|
65
|
+
"""Bind and listen on an available port for the requested host."""
|
|
66
|
+
|
|
67
|
+
last_error: OSError | None = None
|
|
68
|
+
for family, socktype, proto, _canonname, sockaddr in socket.getaddrinfo(
|
|
69
|
+
host,
|
|
70
|
+
0,
|
|
71
|
+
type=socket.SOCK_STREAM,
|
|
72
|
+
):
|
|
73
|
+
sock = socket.socket(family, socktype, proto)
|
|
74
|
+
try:
|
|
75
|
+
sock.bind(sockaddr)
|
|
76
|
+
sock.listen()
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
last_error = exc
|
|
79
|
+
sock.close()
|
|
80
|
+
continue
|
|
81
|
+
return sock
|
|
82
|
+
if last_error is not None:
|
|
83
|
+
raise last_error
|
|
84
|
+
raise OSError(f"Could not resolve a bind address for {host!r}.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def close_sockets(sockets: list[socket.socket] | None) -> None:
|
|
88
|
+
"""Close sockets that were pre-bound for uvicorn."""
|
|
89
|
+
|
|
90
|
+
if sockets is None:
|
|
91
|
+
return
|
|
92
|
+
for sock in sockets:
|
|
93
|
+
try:
|
|
94
|
+
sock.close()
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def prepare_server_launch(host: str, port: int) -> ServerLaunch:
|
|
100
|
+
"""Validate bind options and resolve the actual browser URL."""
|
|
101
|
+
|
|
102
|
+
if not is_loopback_host(host):
|
|
103
|
+
raise typer.BadParameter(
|
|
104
|
+
"Loopback-only host required.",
|
|
105
|
+
param_hint="--host",
|
|
106
|
+
)
|
|
107
|
+
if port < 0 or port > 65535:
|
|
108
|
+
raise typer.BadParameter(
|
|
109
|
+
"Port must be between 0 and 65535.",
|
|
110
|
+
param_hint="--port",
|
|
111
|
+
)
|
|
112
|
+
if port == 0:
|
|
113
|
+
sock = _bind_socket(host)
|
|
114
|
+
actual_port = int(sock.getsockname()[1])
|
|
115
|
+
return ServerLaunch(
|
|
116
|
+
host=host,
|
|
117
|
+
requested_port=port,
|
|
118
|
+
port=actual_port,
|
|
119
|
+
url=build_app_url(host, actual_port),
|
|
120
|
+
sockets=[sock],
|
|
121
|
+
)
|
|
122
|
+
return ServerLaunch(
|
|
123
|
+
host=host,
|
|
124
|
+
requested_port=port,
|
|
125
|
+
port=port,
|
|
126
|
+
url=build_app_url(host, port),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _prompt_passphrase() -> str:
|
|
131
|
+
"""Prompt for a passphrase without echoing it to the terminal."""
|
|
132
|
+
|
|
133
|
+
return str(typer.prompt("Passphrase", hide_input=True))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def resolve_passphrase(
|
|
137
|
+
passphrase: str | None,
|
|
138
|
+
passphrase_prompt: bool,
|
|
139
|
+
prompt_func: Callable[[], str] = _prompt_passphrase,
|
|
140
|
+
) -> str | None:
|
|
141
|
+
"""Resolve passphrase options into the configured passphrase."""
|
|
142
|
+
|
|
143
|
+
if passphrase is not None and passphrase_prompt:
|
|
144
|
+
raise typer.BadParameter(
|
|
145
|
+
"Use either --passphrase or --passphrase-prompt, not both.",
|
|
146
|
+
param_hint="--passphrase",
|
|
147
|
+
)
|
|
148
|
+
resolved = prompt_func() if passphrase_prompt else passphrase
|
|
149
|
+
if resolved == "":
|
|
150
|
+
raise typer.BadParameter(
|
|
151
|
+
"Passphrase cannot be empty.",
|
|
152
|
+
param_hint="--passphrase",
|
|
153
|
+
)
|
|
154
|
+
return resolved
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def run_local_server(
|
|
158
|
+
*,
|
|
159
|
+
host: str,
|
|
160
|
+
port: int,
|
|
161
|
+
open_browser: bool,
|
|
162
|
+
passphrase: str | None,
|
|
163
|
+
passphrase_prompt: bool,
|
|
164
|
+
browser_open: Callable[[str], bool] = webbrowser.open,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Run the local FileClip server."""
|
|
167
|
+
|
|
168
|
+
resolved_passphrase = resolve_passphrase(passphrase, passphrase_prompt)
|
|
169
|
+
launch = prepare_server_launch(host, port)
|
|
170
|
+
asgi_app = create_app(LaunchConfig(passphrase=resolved_passphrase))
|
|
171
|
+
uvicorn_config = uvicorn.Config(
|
|
172
|
+
asgi_app,
|
|
173
|
+
host=launch.host,
|
|
174
|
+
port=launch.port,
|
|
175
|
+
access_log=False,
|
|
176
|
+
log_level="info",
|
|
177
|
+
)
|
|
178
|
+
server = uvicorn.Server(uvicorn_config)
|
|
179
|
+
try:
|
|
180
|
+
typer.echo(f"Serving FileClip at {launch.url}")
|
|
181
|
+
if open_browser:
|
|
182
|
+
browser_open(launch.url)
|
|
183
|
+
server.run(sockets=launch.sockets)
|
|
184
|
+
finally:
|
|
185
|
+
close_sockets(launch.sockets)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@app.command()
|
|
189
|
+
def root(
|
|
190
|
+
host: Annotated[
|
|
191
|
+
str,
|
|
192
|
+
typer.Option("--host", help="Loopback host to bind."),
|
|
193
|
+
] = DEFAULT_HOST,
|
|
194
|
+
port: Annotated[
|
|
195
|
+
int,
|
|
196
|
+
typer.Option("--port", min=0, max=65535, help="Port to bind; 0 chooses one."),
|
|
197
|
+
] = DEFAULT_PORT,
|
|
198
|
+
open_browser: Annotated[
|
|
199
|
+
bool,
|
|
200
|
+
typer.Option("--open/--no-open", help="Open the browser after launch."),
|
|
201
|
+
] = True,
|
|
202
|
+
passphrase: Annotated[
|
|
203
|
+
str | None,
|
|
204
|
+
typer.Option("--passphrase", help="Enable encrypted mode with this passphrase."),
|
|
205
|
+
] = None,
|
|
206
|
+
passphrase_prompt: Annotated[
|
|
207
|
+
bool,
|
|
208
|
+
typer.Option(
|
|
209
|
+
"--passphrase-prompt",
|
|
210
|
+
help="Prompt for a passphrase without echoing it.",
|
|
211
|
+
),
|
|
212
|
+
] = False,
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Start the local FileClip browser app."""
|
|
215
|
+
|
|
216
|
+
run_local_server(
|
|
217
|
+
host=host,
|
|
218
|
+
port=port,
|
|
219
|
+
open_browser=open_browser,
|
|
220
|
+
passphrase=passphrase,
|
|
221
|
+
passphrase_prompt=passphrase_prompt,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def main() -> None:
|
|
226
|
+
"""Run the Typer application."""
|
|
227
|
+
|
|
228
|
+
app()
|
fileclip/server.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""FastAPI application factory for FileClip."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
11
|
+
from fastapi.staticfiles import StaticFiles
|
|
12
|
+
|
|
13
|
+
from fileclip import __version__
|
|
14
|
+
|
|
15
|
+
APP_NAME = "fileclip"
|
|
16
|
+
SCHEMA_VERSION = 1
|
|
17
|
+
PLAIN_MODE = "plain"
|
|
18
|
+
ENCRYPTED_MODE = "encrypted"
|
|
19
|
+
CONFIG_CACHE_CONTROL = "no-store"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class LaunchConfig:
|
|
24
|
+
"""Runtime configuration exposed to the browser app."""
|
|
25
|
+
|
|
26
|
+
passphrase: str | None = None
|
|
27
|
+
app_name: str = APP_NAME
|
|
28
|
+
schema: int = SCHEMA_VERSION
|
|
29
|
+
version: str = __version__
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def mode(self) -> str:
|
|
33
|
+
"""Return the public launch mode."""
|
|
34
|
+
|
|
35
|
+
if self.passphrase is None:
|
|
36
|
+
return PLAIN_MODE
|
|
37
|
+
return ENCRYPTED_MODE
|
|
38
|
+
|
|
39
|
+
def as_public_config(self) -> dict[str, Any]:
|
|
40
|
+
"""Return the JSON-safe browser launch configuration."""
|
|
41
|
+
|
|
42
|
+
config: dict[str, Any] = {
|
|
43
|
+
"app": self.app_name,
|
|
44
|
+
"version": self.version,
|
|
45
|
+
"schema": self.schema,
|
|
46
|
+
"mode": self.mode,
|
|
47
|
+
}
|
|
48
|
+
if self.passphrase is not None:
|
|
49
|
+
config["passphrase"] = self.passphrase
|
|
50
|
+
return config
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def static_dir() -> Path:
|
|
54
|
+
"""Return the packaged static asset directory."""
|
|
55
|
+
|
|
56
|
+
return Path(__file__).with_name("static")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_app(config: LaunchConfig | None = None) -> FastAPI:
|
|
60
|
+
"""Create the local FileClip ASGI application."""
|
|
61
|
+
|
|
62
|
+
launch_config = config or LaunchConfig()
|
|
63
|
+
assets = static_dir()
|
|
64
|
+
app = FastAPI(
|
|
65
|
+
title="FileClip",
|
|
66
|
+
docs_url=None,
|
|
67
|
+
redoc_url=None,
|
|
68
|
+
openapi_url=None,
|
|
69
|
+
)
|
|
70
|
+
app.state.launch_config = launch_config
|
|
71
|
+
|
|
72
|
+
@app.get("/", include_in_schema=False)
|
|
73
|
+
def index() -> FileResponse:
|
|
74
|
+
"""Serve the browser app shell."""
|
|
75
|
+
|
|
76
|
+
return FileResponse(assets / "index.html", media_type="text/html")
|
|
77
|
+
|
|
78
|
+
@app.get("/config.json", include_in_schema=False)
|
|
79
|
+
def config_json() -> JSONResponse:
|
|
80
|
+
"""Serve launch configuration without allowing browser caching."""
|
|
81
|
+
|
|
82
|
+
return JSONResponse(
|
|
83
|
+
launch_config.as_public_config(),
|
|
84
|
+
headers={"Cache-Control": CONFIG_CACHE_CONTROL},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
app.mount("/static", StaticFiles(directory=assets), name="static")
|
|
88
|
+
return app
|