fileclip 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Osama Masoud
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.
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: fileclip
3
+ Version: 0.1.0
4
+ Summary: Local clipboard-envelope file transfer helper
5
+ Keywords: clipboard,file-transfer,fastapi,local-web-app
6
+ Author: Osama Masoud
7
+ Author-email: Osama Masoud <osama.masoud@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Framework :: FastAPI
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Utilities
17
+ Requires-Dist: fastapi>=0.138.0
18
+ Requires-Dist: typer>=0.26.7
19
+ Requires-Dist: uvicorn>=0.49.0
20
+ Requires-Python: >=3.14
21
+ Project-URL: Homepage, https://github.com/omasoud/fileclip
22
+ Project-URL: Repository, https://github.com/omasoud/fileclip
23
+ Project-URL: Issues, https://github.com/omasoud/fileclip/issues
24
+ Description-Content-Type: text/markdown
25
+
26
+ # FileClip
27
+
28
+ FileClip is a small local web app for moving one file through text clipboard synchronization. It packages a dropped file into a self-contained `FILECLIP/1` text envelope, copies that envelope to the local clipboard, and can later hydrate a compatible envelope back into a downloadable file.
29
+
30
+ FileClip does not sync clipboards, upload files, or store file contents on disk. It assumes another system already syncs clipboard text between the environments you care about.
31
+
32
+ ## Install
33
+
34
+ For CLI use:
35
+
36
+ ```bash
37
+ uv tool install fileclip
38
+ ```
39
+
40
+ Or with pipx:
41
+
42
+ ```bash
43
+ pipx install fileclip
44
+ ```
45
+
46
+ ## Run
47
+
48
+ ```bash
49
+ fileclip
50
+ ```
51
+
52
+ By default the server binds to `127.0.0.1`, chooses an available port, and opens the browser.
53
+
54
+ Useful options:
55
+
56
+ ```bash
57
+ fileclip --port 8080
58
+ fileclip --no-open
59
+ fileclip --passphrase-prompt
60
+ ```
61
+
62
+ ## Plain Mode
63
+
64
+ Without a passphrase, FileClip creates plain base64 clipboard envelopes. This is convenient for trusted local clipboard-sync paths, but the file bytes are visible to anything that can read the clipboard envelope.
65
+
66
+ ## Passphrase Mode
67
+
68
+ With `--passphrase` or `--passphrase-prompt`, FileClip encrypts file bytes in the browser with PBKDF2/SHA-256 and AES-GCM before building the clipboard envelope.
69
+
70
+ `--passphrase-prompt` avoids putting the passphrase in shell history or the process list. The browser still receives the passphrase through local launch configuration so it can encrypt and decrypt files. Passphrase mode is a practical guardrail, not a high-assurance security boundary.
71
+
72
+ Encrypted mode protects payload bytes but does not hide envelope metadata such as filename, MIME type, file size, or SHA-256 hash.
73
+
74
+ ## Limits
75
+
76
+ FileClip has no artificial file-size limit, but browsers, clipboards, clipboard managers, and external clipboard-sync channels do. Large payloads may fail to copy, paste, or sync.
77
+
78
+ Only schema `1` envelopes are supported. FileClip intentionally rejects unknown schema versions and mode mismatches.
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ uv sync
84
+ uv run pytest
85
+ uv run fileclip --no-open
86
+ ```
87
+
88
+ ## License
89
+
90
+ FileClip is distributed under the MIT License.
@@ -0,0 +1,65 @@
1
+ # FileClip
2
+
3
+ FileClip is a small local web app for moving one file through text clipboard synchronization. It packages a dropped file into a self-contained `FILECLIP/1` text envelope, copies that envelope to the local clipboard, and can later hydrate a compatible envelope back into a downloadable file.
4
+
5
+ FileClip does not sync clipboards, upload files, or store file contents on disk. It assumes another system already syncs clipboard text between the environments you care about.
6
+
7
+ ## Install
8
+
9
+ For CLI use:
10
+
11
+ ```bash
12
+ uv tool install fileclip
13
+ ```
14
+
15
+ Or with pipx:
16
+
17
+ ```bash
18
+ pipx install fileclip
19
+ ```
20
+
21
+ ## Run
22
+
23
+ ```bash
24
+ fileclip
25
+ ```
26
+
27
+ By default the server binds to `127.0.0.1`, chooses an available port, and opens the browser.
28
+
29
+ Useful options:
30
+
31
+ ```bash
32
+ fileclip --port 8080
33
+ fileclip --no-open
34
+ fileclip --passphrase-prompt
35
+ ```
36
+
37
+ ## Plain Mode
38
+
39
+ Without a passphrase, FileClip creates plain base64 clipboard envelopes. This is convenient for trusted local clipboard-sync paths, but the file bytes are visible to anything that can read the clipboard envelope.
40
+
41
+ ## Passphrase Mode
42
+
43
+ With `--passphrase` or `--passphrase-prompt`, FileClip encrypts file bytes in the browser with PBKDF2/SHA-256 and AES-GCM before building the clipboard envelope.
44
+
45
+ `--passphrase-prompt` avoids putting the passphrase in shell history or the process list. The browser still receives the passphrase through local launch configuration so it can encrypt and decrypt files. Passphrase mode is a practical guardrail, not a high-assurance security boundary.
46
+
47
+ Encrypted mode protects payload bytes but does not hide envelope metadata such as filename, MIME type, file size, or SHA-256 hash.
48
+
49
+ ## Limits
50
+
51
+ FileClip has no artificial file-size limit, but browsers, clipboards, clipboard managers, and external clipboard-sync channels do. Large payloads may fail to copy, paste, or sync.
52
+
53
+ Only schema `1` envelopes are supported. FileClip intentionally rejects unknown schema versions and mode mismatches.
54
+
55
+ ## Development
56
+
57
+ ```bash
58
+ uv sync
59
+ uv run pytest
60
+ uv run fileclip --no-open
61
+ ```
62
+
63
+ ## License
64
+
65
+ FileClip is distributed under the MIT License.
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "fileclip"
3
+ version = "0.1.0"
4
+ description = "Local clipboard-envelope file transfer helper"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Osama Masoud", email = "osama.masoud@gmail.com" }
8
+ ]
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ keywords = ["clipboard", "file-transfer", "fastapi", "local-web-app"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Environment :: Web Environment",
15
+ "Framework :: FastAPI",
16
+ "Intended Audience :: End Users/Desktop",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Topic :: Utilities",
20
+ ]
21
+ requires-python = ">=3.14"
22
+ dependencies = [
23
+ "fastapi>=0.138.0",
24
+ "typer>=0.26.7",
25
+ "uvicorn>=0.49.0",
26
+ ]
27
+
28
+ [project.scripts]
29
+ fileclip = "fileclip.cli:main"
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/omasoud/fileclip"
33
+ Repository = "https://github.com/omasoud/fileclip"
34
+ Issues = "https://github.com/omasoud/fileclip/issues"
35
+
36
+ [build-system]
37
+ requires = ["uv_build>=0.9.18,<0.12.0"]
38
+ build-backend = "uv_build"
39
+
40
+ [tool.uv]
41
+ exclude-newer = "2 days" # Exclude packages released in the last 2 days as a protection against undiscovered supply chain vulnerabilities.
42
+
43
+ [dependency-groups]
44
+ dev = [
45
+ "httpx2>=2.4.0",
46
+ "packaging>=26.2",
47
+ "pytest>=9.1.1",
48
+ ]
@@ -0,0 +1,8 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("fileclip")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0"
7
+
8
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """Module entry point for `python -m fileclip`."""
2
+
3
+ from fileclip.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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()
@@ -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