qshare 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.
- qshare-0.1.0/PKG-INFO +85 -0
- qshare-0.1.0/README.md +76 -0
- qshare-0.1.0/pyproject.toml +21 -0
- qshare-0.1.0/pyproject.toml.orig +22 -0
- qshare-0.1.0/src/qshare/__init__.py +5 -0
- qshare-0.1.0/src/qshare/__main__.py +4 -0
- qshare-0.1.0/src/qshare/cli.py +336 -0
qshare-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: qshare
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Quickly share local files through a public TryCloudflare tunnel.
|
|
5
|
+
Author: steinvenic
|
|
6
|
+
Author-email: steinvenic <761701732@qq.com>
|
|
7
|
+
Requires-Python: >=3.6
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# qshare
|
|
11
|
+
|
|
12
|
+
Quickly share a local file via a public TryCloudflare tunnel.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- Start a temporary local HTTP server on a random free port
|
|
17
|
+
- Detect and avoid occupied ports automatically
|
|
18
|
+
- Create a public TryCloudflare URL for the file
|
|
19
|
+
- Support a default lifetime of 2 hours, adjustable via CLI arguments
|
|
20
|
+
- Run in the background with `--daemon` for unattended usage
|
|
21
|
+
- Compatible with Python 3.6 and newer
|
|
22
|
+
- Ready for uv-managed development and PyPI packaging
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- Python 3.6+
|
|
27
|
+
- `cloudflared` is downloaded automatically if it is not already installed
|
|
28
|
+
|
|
29
|
+
`cloudflared` does not ship as an official Python package on PyPI. Instead, qshare will automatically download the official Cloudflare tunnel binary when it is missing.
|
|
30
|
+
|
|
31
|
+
If your network is limited or you want to use a mirror, set the environment variable before running qshare:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
export CLOUDFLARED_DOWNLOAD_URL="https://example.com/mirror/cloudflared-linux-amd64"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
If you want to install it manually for any reason, use:
|
|
38
|
+
|
|
39
|
+
- https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
Using uv:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv venv
|
|
47
|
+
source .venv/bin/activate
|
|
48
|
+
uv pip install -e .
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or install from PyPI after publishing:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install qshare
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
qshare /path/to/file.zip
|
|
61
|
+
qshare /path/to/file.zip --ttl 2h
|
|
62
|
+
qshare /path/to/file.zip --ttl 900
|
|
63
|
+
qshare /path/to/file.zip --port 8000
|
|
64
|
+
qshare /path/to/file.zip --daemon
|
|
65
|
+
qshare /path/to/file.zip --daemon --ttl 1h
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The command starts a local HTTP server on a random free port, creates a temporary public URL with TryCloudflare, and keeps the tunnel alive for the configured duration. If `cloudflared` is absent, qshare will fetch the official binary automatically into `~/.local/bin`.
|
|
69
|
+
|
|
70
|
+
Use `--daemon` to launch the share process in the background and exit immediately. This is useful for long-running uploads or starting a tunnel from a service manager or shell script.
|
|
71
|
+
|
|
72
|
+
## Build for PyPI
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
uv build
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Then upload the resulting artifacts from the `dist/` directory to PyPI.
|
|
79
|
+
|
|
80
|
+
## Notes
|
|
81
|
+
|
|
82
|
+
- The default lifetime is 2 hours.
|
|
83
|
+
- If a port is occupied, qshare automatically selects another free port.
|
|
84
|
+
- `--ttl` accepts values like `30m`, `90s`, `2h`, or a plain number in seconds.
|
|
85
|
+
- `--daemon` runs the program in the background and returns immediately.
|
qshare-0.1.0/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# qshare
|
|
2
|
+
|
|
3
|
+
Quickly share a local file via a public TryCloudflare tunnel.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Start a temporary local HTTP server on a random free port
|
|
8
|
+
- Detect and avoid occupied ports automatically
|
|
9
|
+
- Create a public TryCloudflare URL for the file
|
|
10
|
+
- Support a default lifetime of 2 hours, adjustable via CLI arguments
|
|
11
|
+
- Run in the background with `--daemon` for unattended usage
|
|
12
|
+
- Compatible with Python 3.6 and newer
|
|
13
|
+
- Ready for uv-managed development and PyPI packaging
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- Python 3.6+
|
|
18
|
+
- `cloudflared` is downloaded automatically if it is not already installed
|
|
19
|
+
|
|
20
|
+
`cloudflared` does not ship as an official Python package on PyPI. Instead, qshare will automatically download the official Cloudflare tunnel binary when it is missing.
|
|
21
|
+
|
|
22
|
+
If your network is limited or you want to use a mirror, set the environment variable before running qshare:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
export CLOUDFLARED_DOWNLOAD_URL="https://example.com/mirror/cloudflared-linux-amd64"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
If you want to install it manually for any reason, use:
|
|
29
|
+
|
|
30
|
+
- https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
Using uv:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uv venv
|
|
38
|
+
source .venv/bin/activate
|
|
39
|
+
uv pip install -e .
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or install from PyPI after publishing:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install qshare
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
qshare /path/to/file.zip
|
|
52
|
+
qshare /path/to/file.zip --ttl 2h
|
|
53
|
+
qshare /path/to/file.zip --ttl 900
|
|
54
|
+
qshare /path/to/file.zip --port 8000
|
|
55
|
+
qshare /path/to/file.zip --daemon
|
|
56
|
+
qshare /path/to/file.zip --daemon --ttl 1h
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The command starts a local HTTP server on a random free port, creates a temporary public URL with TryCloudflare, and keeps the tunnel alive for the configured duration. If `cloudflared` is absent, qshare will fetch the official binary automatically into `~/.local/bin`.
|
|
60
|
+
|
|
61
|
+
Use `--daemon` to launch the share process in the background and exit immediately. This is useful for long-running uploads or starting a tunnel from a service manager or shell script.
|
|
62
|
+
|
|
63
|
+
## Build for PyPI
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uv build
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Then upload the resulting artifacts from the `dist/` directory to PyPI.
|
|
70
|
+
|
|
71
|
+
## Notes
|
|
72
|
+
|
|
73
|
+
- The default lifetime is 2 hours.
|
|
74
|
+
- If a port is occupied, qshare automatically selects another free port.
|
|
75
|
+
- `--ttl` accepts values like `30m`, `90s`, `2h`, or a plain number in seconds.
|
|
76
|
+
- `--daemon` runs the program in the background and returns immediately.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "qshare"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Quickly share local files through a public TryCloudflare tunnel."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.6"
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
[[project.authors]]
|
|
10
|
+
name = "steinvenic"
|
|
11
|
+
email = "761701732@qq.com"
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
qshare = "qshare.cli:main"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.12.17,<0.13.0"]
|
|
18
|
+
build-backend = "uv_build"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = ["pytest>=9.1.1"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "qshare"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Quickly share local files through a public TryCloudflare tunnel."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "steinvenic", email = "761701732@qq.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.6"
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
qshare = "qshare.cli:main"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.12.17,<0.13.0"]
|
|
17
|
+
build-backend = "uv_build"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=9.1.1",
|
|
22
|
+
]
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import urllib.request
|
|
13
|
+
import uuid
|
|
14
|
+
from functools import partial
|
|
15
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Iterable, Optional, Sequence, Tuple, Union
|
|
18
|
+
|
|
19
|
+
DEFAULT_TTL_SECONDS = 2 * 60 * 60
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_duration(value: Union[str, int, float]) -> int:
|
|
23
|
+
"""Convert duration strings like 30m, 90s, 2h to seconds."""
|
|
24
|
+
if isinstance(value, (int, float)):
|
|
25
|
+
seconds = int(value)
|
|
26
|
+
if seconds <= 0:
|
|
27
|
+
raise ValueError("Duration must be positive.")
|
|
28
|
+
return seconds
|
|
29
|
+
|
|
30
|
+
text = str(value).strip().lower()
|
|
31
|
+
if not text:
|
|
32
|
+
raise ValueError("Duration is required.")
|
|
33
|
+
|
|
34
|
+
if text.isdigit():
|
|
35
|
+
return int(text)
|
|
36
|
+
|
|
37
|
+
match = re.fullmatch(r"(?P<number>\d+)(?P<unit>[smhd]?)", text)
|
|
38
|
+
if not match:
|
|
39
|
+
raise ValueError(f"Unsupported duration: {value!r}. Use 30m, 90s, 2h, or plain seconds.")
|
|
40
|
+
|
|
41
|
+
number = int(match.group("number"))
|
|
42
|
+
unit = match.group("unit") or "s"
|
|
43
|
+
multipliers = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
44
|
+
seconds = number * multipliers[unit]
|
|
45
|
+
if seconds <= 0:
|
|
46
|
+
raise ValueError("Duration must be greater than zero.")
|
|
47
|
+
return seconds
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def find_available_port(
|
|
51
|
+
start_port: int = 20000,
|
|
52
|
+
end_port: int = 65535,
|
|
53
|
+
used_ports: Optional[Iterable[int]] = None,
|
|
54
|
+
) -> int:
|
|
55
|
+
"""Return a free localhost port, avoiding the given ports."""
|
|
56
|
+
blacklisted = set(used_ports or [])
|
|
57
|
+
ports = list(range(start_port, end_port + 1))
|
|
58
|
+
import random
|
|
59
|
+
|
|
60
|
+
random.shuffle(ports)
|
|
61
|
+
for port in ports:
|
|
62
|
+
if port in blacklisted:
|
|
63
|
+
continue
|
|
64
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
65
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
66
|
+
try:
|
|
67
|
+
sock.bind(("127.0.0.1", port))
|
|
68
|
+
return port
|
|
69
|
+
except OSError:
|
|
70
|
+
continue
|
|
71
|
+
raise OSError(f"No free port found between {start_port} and {end_port}.")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def extract_trycloudflare_url(output: str) -> Optional[str]:
|
|
75
|
+
match = re.search(r"https?://[A-Za-z0-9.-]+\.trycloudflare\.com", output)
|
|
76
|
+
if match:
|
|
77
|
+
return match.group(0)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_download_url(base_url: str, file_name: str) -> str:
|
|
82
|
+
cleaned_base = base_url.rstrip("/")
|
|
83
|
+
cleaned_name = file_name.lstrip("/")
|
|
84
|
+
return f"{cleaned_base}/{cleaned_name}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ShareHandler(SimpleHTTPRequestHandler):
|
|
88
|
+
def log_message(self, format: str, *args: object) -> None: # noqa: A003
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def start_local_http_server(file_path: Path, port: int) -> Tuple[ThreadingHTTPServer, threading.Thread]:
|
|
93
|
+
if not file_path.exists():
|
|
94
|
+
raise FileNotFoundError(f"File does not exist: {file_path}")
|
|
95
|
+
if not file_path.is_file():
|
|
96
|
+
raise ValueError(f"Path is not a file: {file_path}")
|
|
97
|
+
|
|
98
|
+
server = ThreadingHTTPServer(("127.0.0.1", port), partial(ShareHandler, directory=str(file_path.parent)))
|
|
99
|
+
thread = threading.Thread(target=server.serve_forever, name="qshare-http", daemon=True)
|
|
100
|
+
thread.start()
|
|
101
|
+
return server, thread
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def stop_local_http_server(server: ThreadingHTTPServer) -> None:
|
|
105
|
+
try:
|
|
106
|
+
server.shutdown()
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
try:
|
|
110
|
+
server.server_close()
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_cloudflared_download_url() -> str:
|
|
116
|
+
override = os.getenv("CLOUDFLARED_DOWNLOAD_URL")
|
|
117
|
+
if override:
|
|
118
|
+
return override.strip()
|
|
119
|
+
|
|
120
|
+
system = platform.system().lower()
|
|
121
|
+
machine = platform.machine().lower()
|
|
122
|
+
mapping = {
|
|
123
|
+
("linux", "x86_64"): "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
|
|
124
|
+
("linux", "amd64"): "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
|
|
125
|
+
("linux", "aarch64"): "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64",
|
|
126
|
+
("linux", "arm64"): "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64",
|
|
127
|
+
}
|
|
128
|
+
key = (system, machine)
|
|
129
|
+
if key not in mapping:
|
|
130
|
+
raise RuntimeError(
|
|
131
|
+
f"Unsupported platform for automatic cloudflared install: {system}/{machine}. "
|
|
132
|
+
"Please install cloudflared manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
|
|
133
|
+
)
|
|
134
|
+
return mapping[key]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def install_cloudflared_binary() -> str:
|
|
138
|
+
target_dir = Path.home() / ".local" / "bin"
|
|
139
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
target = target_dir / "cloudflared"
|
|
141
|
+
|
|
142
|
+
if target.exists():
|
|
143
|
+
return str(target)
|
|
144
|
+
|
|
145
|
+
url = get_cloudflared_download_url()
|
|
146
|
+
try:
|
|
147
|
+
urllib.request.urlretrieve(url, str(target))
|
|
148
|
+
target.chmod(0o755)
|
|
149
|
+
return str(target)
|
|
150
|
+
except Exception:
|
|
151
|
+
if target.exists():
|
|
152
|
+
target.unlink()
|
|
153
|
+
raise
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def ensure_cloudflared() -> str:
|
|
157
|
+
binary_path = shutil.which("cloudflared")
|
|
158
|
+
if binary_path:
|
|
159
|
+
return binary_path
|
|
160
|
+
|
|
161
|
+
user_bin = Path.home() / ".local" / "bin" / "cloudflared"
|
|
162
|
+
if user_bin.exists():
|
|
163
|
+
return str(user_bin)
|
|
164
|
+
|
|
165
|
+
installed = install_cloudflared_binary()
|
|
166
|
+
os.environ["PATH"] = str(Path(installed).parent) + os.pathsep + os.environ.get("PATH", "")
|
|
167
|
+
return installed
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def launch_trycloudflare_tunnel(local_url: str, timeout_seconds: int) -> Tuple[subprocess.Popen, str]:
|
|
171
|
+
cloudflared_path = ensure_cloudflared()
|
|
172
|
+
logfile = Path(tempfile.gettempdir()) / f"qshare-{uuid.uuid4().hex}.log"
|
|
173
|
+
process = subprocess.Popen(
|
|
174
|
+
[cloudflared_path, "tunnel", "--url", local_url, "--no-autoupdate", "--logfile", str(logfile)],
|
|
175
|
+
stdout=subprocess.PIPE,
|
|
176
|
+
stderr=subprocess.STDOUT,
|
|
177
|
+
text=True,
|
|
178
|
+
bufsize=1,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if process.stdout is None:
|
|
182
|
+
raise RuntimeError("Unable to capture cloudflared output.")
|
|
183
|
+
|
|
184
|
+
deadline = time.monotonic() + timeout_seconds
|
|
185
|
+
while time.monotonic() < deadline:
|
|
186
|
+
line = process.stdout.readline()
|
|
187
|
+
if not line:
|
|
188
|
+
if process.poll() is not None:
|
|
189
|
+
output = ""
|
|
190
|
+
try:
|
|
191
|
+
output = process.stdout.read()
|
|
192
|
+
except Exception:
|
|
193
|
+
output = ""
|
|
194
|
+
raise RuntimeError(f"cloudflared exited before returning a tunnel URL.\n{output}")
|
|
195
|
+
time.sleep(0.2)
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
url = extract_trycloudflare_url(line)
|
|
199
|
+
if url:
|
|
200
|
+
return process, url
|
|
201
|
+
|
|
202
|
+
if process.poll() is None:
|
|
203
|
+
try:
|
|
204
|
+
process.terminate()
|
|
205
|
+
process.wait(timeout=5)
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
raise TimeoutError(f"Timed out waiting for a public TryCloudflare URL in {timeout_seconds} seconds.")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def stop_tunnel(process: subprocess.Popen) -> None:
|
|
213
|
+
if process.poll() is None:
|
|
214
|
+
try:
|
|
215
|
+
process.terminate()
|
|
216
|
+
process.wait(timeout=5)
|
|
217
|
+
except Exception:
|
|
218
|
+
try:
|
|
219
|
+
process.kill()
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def run_share(file_path: str, ttl_seconds: int = DEFAULT_TTL_SECONDS, port: Optional[int] = None) -> int:
|
|
225
|
+
source = Path(file_path).expanduser().resolve()
|
|
226
|
+
if not source.exists():
|
|
227
|
+
raise FileNotFoundError(f"File not found: {source}")
|
|
228
|
+
if not source.is_file():
|
|
229
|
+
raise ValueError(f"Not a file: {source}")
|
|
230
|
+
|
|
231
|
+
resolved_port = port or find_available_port()
|
|
232
|
+
server, server_thread = start_local_http_server(source, resolved_port)
|
|
233
|
+
local_url = f"http://127.0.0.1:{resolved_port}/{source.name}"
|
|
234
|
+
|
|
235
|
+
print(f"Local file: {source}")
|
|
236
|
+
print(f"Local HTTP URL: {local_url}")
|
|
237
|
+
print(f"Waiting for public TryCloudflare URL (ttl={ttl_seconds}s)...")
|
|
238
|
+
|
|
239
|
+
tunnel_process = None
|
|
240
|
+
tunnel_url = None
|
|
241
|
+
stop_event = threading.Event()
|
|
242
|
+
|
|
243
|
+
def stop_all() -> None:
|
|
244
|
+
stop_event.set()
|
|
245
|
+
if tunnel_process is not None:
|
|
246
|
+
stop_tunnel(tunnel_process)
|
|
247
|
+
stop_local_http_server(server)
|
|
248
|
+
if server_thread.is_alive():
|
|
249
|
+
server_thread.join(timeout=2)
|
|
250
|
+
|
|
251
|
+
timer = threading.Timer(ttl_seconds, stop_all)
|
|
252
|
+
timer.daemon = True
|
|
253
|
+
timer.start()
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
tunnel_process, tunnel_url = launch_trycloudflare_tunnel(local_url, timeout_seconds=min(ttl_seconds, 30))
|
|
257
|
+
public_file_url = build_download_url(tunnel_url, source.name)
|
|
258
|
+
print(f"Public file URL: {public_file_url}")
|
|
259
|
+
while not stop_event.is_set() and tunnel_process.poll() is None:
|
|
260
|
+
time.sleep(0.5)
|
|
261
|
+
except KeyboardInterrupt:
|
|
262
|
+
print("\nInterrupted by user.")
|
|
263
|
+
stop_all()
|
|
264
|
+
return 0
|
|
265
|
+
except Exception:
|
|
266
|
+
stop_all()
|
|
267
|
+
raise
|
|
268
|
+
finally:
|
|
269
|
+
timer.cancel()
|
|
270
|
+
stop_all()
|
|
271
|
+
|
|
272
|
+
return 0
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
276
|
+
parser = argparse.ArgumentParser(
|
|
277
|
+
description="Quickly share a local file over a public TryCloudflare tunnel.",
|
|
278
|
+
prog="qshare",
|
|
279
|
+
)
|
|
280
|
+
parser.add_argument("file", help="Local file to share publicly.")
|
|
281
|
+
parser.add_argument(
|
|
282
|
+
"-t",
|
|
283
|
+
"--ttl",
|
|
284
|
+
default="2h",
|
|
285
|
+
help="How long the tunnel remains active. Examples: 30m, 90s, 2h, or 600 (seconds).",
|
|
286
|
+
)
|
|
287
|
+
parser.add_argument(
|
|
288
|
+
"-p",
|
|
289
|
+
"--port",
|
|
290
|
+
type=int,
|
|
291
|
+
default=None,
|
|
292
|
+
help="Optional fixed port for the local HTTP server. A random free port is used when omitted.",
|
|
293
|
+
)
|
|
294
|
+
parser.add_argument(
|
|
295
|
+
"-d",
|
|
296
|
+
"--daemon",
|
|
297
|
+
action="store_true",
|
|
298
|
+
help="Run the share process in the background and exit immediately.",
|
|
299
|
+
)
|
|
300
|
+
return parser
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
304
|
+
parser = build_parser()
|
|
305
|
+
args = parser.parse_args(argv)
|
|
306
|
+
|
|
307
|
+
if args.daemon:
|
|
308
|
+
cmd = [sys.executable, os.path.abspath(sys.argv[0]), args.file]
|
|
309
|
+
if args.ttl != "2h":
|
|
310
|
+
cmd.extend(["--ttl", str(args.ttl)])
|
|
311
|
+
if args.port is not None:
|
|
312
|
+
cmd.extend(["--port", str(args.port)])
|
|
313
|
+
with open(os.devnull, "wb") as devnull:
|
|
314
|
+
subprocess.Popen(
|
|
315
|
+
cmd,
|
|
316
|
+
stdin=devnull,
|
|
317
|
+
stdout=devnull,
|
|
318
|
+
stderr=devnull,
|
|
319
|
+
close_fds=True,
|
|
320
|
+
start_new_session=True,
|
|
321
|
+
)
|
|
322
|
+
return 0
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
ttl_seconds = parse_duration(args.ttl)
|
|
326
|
+
return run_share(args.file, ttl_seconds=ttl_seconds, port=args.port)
|
|
327
|
+
except KeyboardInterrupt:
|
|
328
|
+
print("\nStopped.")
|
|
329
|
+
return 130
|
|
330
|
+
except Exception as exc: # pragma: no cover - CLI-level output
|
|
331
|
+
print(f"qshare error: {exc}", file=sys.stderr)
|
|
332
|
+
return 1
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
if __name__ == "__main__":
|
|
336
|
+
raise SystemExit(main())
|