qshare 0.2.0__py3-none-linux_armv6l.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.
- qshare/__init__.py +5 -0
- qshare/__main__.py +4 -0
- qshare/_binaries/cloudflared +4 -0
- qshare/cli.py +464 -0
- qshare-0.2.0.dist-info/METADATA +74 -0
- qshare-0.2.0.dist-info/RECORD +10 -0
- qshare-0.2.0.dist-info/WHEEL +4 -0
- qshare-0.2.0.dist-info/entry_points.txt +3 -0
qshare/__init__.py
ADDED
qshare/__main__.py
ADDED
qshare/cli.py
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import signal
|
|
5
|
+
import shutil
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
import qrcode
|
|
14
|
+
from functools import partial
|
|
15
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Iterable, List, Optional, 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
|
+
for match in re.finditer(r"https://(?P<host>[A-Za-z0-9-]+\.trycloudflare\.com)", output, re.IGNORECASE):
|
|
76
|
+
if match.group("host").lower() != "api.trycloudflare.com":
|
|
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
|
+
def print_qr_code(url: str) -> None:
|
|
88
|
+
qr = qrcode.QRCode(
|
|
89
|
+
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
|
90
|
+
border=1,
|
|
91
|
+
)
|
|
92
|
+
qr.add_data(url)
|
|
93
|
+
qr.make(fit=True)
|
|
94
|
+
print("Scan this QR code to open the public file URL:")
|
|
95
|
+
# Two QR rows per terminal row keeps the code compact and scannable.
|
|
96
|
+
qr.print_ascii(invert=True)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ShareHandler(SimpleHTTPRequestHandler):
|
|
100
|
+
def log_message(self, format: str, *args: object) -> None: # noqa: A003
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def start_local_http_server(file_path: Path, port: int) -> Tuple[ThreadingHTTPServer, threading.Thread]:
|
|
105
|
+
if not file_path.exists():
|
|
106
|
+
raise FileNotFoundError(f"File does not exist: {file_path}")
|
|
107
|
+
if not file_path.is_file():
|
|
108
|
+
raise ValueError(f"Path is not a file: {file_path}")
|
|
109
|
+
|
|
110
|
+
server = ThreadingHTTPServer(("127.0.0.1", port), partial(ShareHandler, directory=str(file_path.parent)))
|
|
111
|
+
thread = threading.Thread(target=server.serve_forever, name="qshare-http", daemon=True)
|
|
112
|
+
thread.start()
|
|
113
|
+
return server, thread
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def stop_local_http_server(server: ThreadingHTTPServer) -> None:
|
|
117
|
+
try:
|
|
118
|
+
server.shutdown()
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
try:
|
|
122
|
+
server.server_close()
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_bundled_cloudflared_binary() -> Path:
|
|
128
|
+
name = "cloudflared.exe" if os.name == "nt" else "cloudflared"
|
|
129
|
+
bundled = Path(__file__).resolve().parent / "_binaries" / name
|
|
130
|
+
if not bundled.is_file():
|
|
131
|
+
raise RuntimeError(
|
|
132
|
+
"This qshare installation has no bundled cloudflared binary for this platform. "
|
|
133
|
+
"Install qshare from PyPI on a supported platform, or install cloudflared yourself and add it to PATH."
|
|
134
|
+
)
|
|
135
|
+
return bundled
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def install_cloudflared_binary() -> str:
|
|
139
|
+
target_dir = Path.home() / ".local" / "bin"
|
|
140
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
target = target_dir / ("cloudflared.exe" if os.name == "nt" else "cloudflared")
|
|
142
|
+
|
|
143
|
+
if target.exists():
|
|
144
|
+
return str(target)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
print("cloudflared was not found. Installing the binary bundled with qshare...")
|
|
148
|
+
shutil.copyfile(str(get_bundled_cloudflared_binary()), str(target))
|
|
149
|
+
if os.name != "nt":
|
|
150
|
+
target.chmod(0o755)
|
|
151
|
+
return str(target)
|
|
152
|
+
except Exception:
|
|
153
|
+
if target.exists():
|
|
154
|
+
target.unlink()
|
|
155
|
+
raise
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def ensure_cloudflared() -> str:
|
|
159
|
+
binary_path = shutil.which("cloudflared")
|
|
160
|
+
if binary_path:
|
|
161
|
+
return binary_path
|
|
162
|
+
|
|
163
|
+
user_bin = Path.home() / ".local" / "bin" / ("cloudflared.exe" if os.name == "nt" else "cloudflared")
|
|
164
|
+
if user_bin.exists():
|
|
165
|
+
return str(user_bin)
|
|
166
|
+
|
|
167
|
+
installed = install_cloudflared_binary()
|
|
168
|
+
os.environ["PATH"] = str(Path(installed).parent) + os.pathsep + os.environ.get("PATH", "")
|
|
169
|
+
return installed
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def launch_trycloudflare_tunnel(local_url: str, timeout_seconds: int) -> Tuple[subprocess.Popen, str]:
|
|
173
|
+
cloudflared_path = ensure_cloudflared()
|
|
174
|
+
deadline = time.monotonic() + timeout_seconds
|
|
175
|
+
for attempt in range(3):
|
|
176
|
+
logfile = Path(tempfile.gettempdir()) / f"qshare-{uuid.uuid4().hex}.log"
|
|
177
|
+
kwargs = {
|
|
178
|
+
"stdout": subprocess.PIPE,
|
|
179
|
+
"stderr": subprocess.STDOUT,
|
|
180
|
+
"universal_newlines": True,
|
|
181
|
+
"bufsize": 1,
|
|
182
|
+
}
|
|
183
|
+
if os.name == "nt":
|
|
184
|
+
kwargs["creationflags"] = (
|
|
185
|
+
getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
186
|
+
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
187
|
+
| getattr(subprocess, "DETACHED_PROCESS", 0)
|
|
188
|
+
)
|
|
189
|
+
process = subprocess.Popen(
|
|
190
|
+
[cloudflared_path, "tunnel", "--url", local_url, "--no-autoupdate", "--logfile", str(logfile)],
|
|
191
|
+
**kwargs,
|
|
192
|
+
)
|
|
193
|
+
if process.stdout is None:
|
|
194
|
+
raise RuntimeError("Unable to capture cloudflared output.")
|
|
195
|
+
|
|
196
|
+
attempt_deadline = min(deadline, time.monotonic() + 10)
|
|
197
|
+
while time.monotonic() < attempt_deadline:
|
|
198
|
+
line = process.stdout.readline()
|
|
199
|
+
if line:
|
|
200
|
+
url = extract_trycloudflare_url(line)
|
|
201
|
+
if url:
|
|
202
|
+
return process, url
|
|
203
|
+
continue
|
|
204
|
+
if process.poll() is not None:
|
|
205
|
+
break
|
|
206
|
+
time.sleep(0.2)
|
|
207
|
+
|
|
208
|
+
stop_tunnel(process)
|
|
209
|
+
if time.monotonic() < deadline and attempt < 2:
|
|
210
|
+
print("Cloudflare did not provide a valid public URL; retrying...")
|
|
211
|
+
|
|
212
|
+
raise TimeoutError(f"Timed out waiting for a valid public TryCloudflare URL in {timeout_seconds} seconds.")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def stop_tunnel(process: subprocess.Popen) -> None:
|
|
216
|
+
if process.poll() is None:
|
|
217
|
+
try:
|
|
218
|
+
process.terminate()
|
|
219
|
+
process.wait(timeout=5)
|
|
220
|
+
except Exception:
|
|
221
|
+
try:
|
|
222
|
+
process.kill()
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class DetachedTunnelProcess:
|
|
228
|
+
"""Minimal Popen-compatible handle for an inherited orphaned tunnel."""
|
|
229
|
+
|
|
230
|
+
def __init__(self, pid: int) -> None:
|
|
231
|
+
self.pid = pid
|
|
232
|
+
|
|
233
|
+
def poll(self) -> Optional[int]:
|
|
234
|
+
try:
|
|
235
|
+
os.kill(self.pid, 0)
|
|
236
|
+
except OSError:
|
|
237
|
+
return 0
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
def terminate(self) -> None:
|
|
241
|
+
os.kill(self.pid, signal.SIGTERM)
|
|
242
|
+
|
|
243
|
+
def wait(self, timeout: Optional[float] = None) -> int:
|
|
244
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
245
|
+
while self.poll() is None:
|
|
246
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
247
|
+
raise subprocess.TimeoutExpired(["cloudflared"], timeout)
|
|
248
|
+
time.sleep(0.1)
|
|
249
|
+
return 0
|
|
250
|
+
|
|
251
|
+
def kill(self) -> None:
|
|
252
|
+
os.kill(self.pid, signal.SIGKILL)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def run_share(file_path: str, ttl_seconds: int = DEFAULT_TTL_SECONDS, port: Optional[int] = None) -> int:
|
|
256
|
+
source = Path(file_path).expanduser().resolve()
|
|
257
|
+
if not source.exists():
|
|
258
|
+
raise FileNotFoundError(f"File not found: {source}")
|
|
259
|
+
if not source.is_file():
|
|
260
|
+
raise ValueError(f"Not a file: {source}")
|
|
261
|
+
|
|
262
|
+
resolved_port = port or find_available_port()
|
|
263
|
+
server, server_thread = start_local_http_server(source, resolved_port)
|
|
264
|
+
local_url = f"http://127.0.0.1:{resolved_port}/{source.name}"
|
|
265
|
+
|
|
266
|
+
print(f"Local file: {source}")
|
|
267
|
+
print(f"Local HTTP URL: {local_url}")
|
|
268
|
+
print(f"Waiting for public TryCloudflare URL (ttl={ttl_seconds}s)...")
|
|
269
|
+
|
|
270
|
+
tunnel_process = None
|
|
271
|
+
tunnel_url = None
|
|
272
|
+
stop_event = threading.Event()
|
|
273
|
+
|
|
274
|
+
detached = False
|
|
275
|
+
|
|
276
|
+
def stop_all() -> None:
|
|
277
|
+
stop_event.set()
|
|
278
|
+
if tunnel_process is not None:
|
|
279
|
+
stop_tunnel(tunnel_process)
|
|
280
|
+
stop_local_http_server(server)
|
|
281
|
+
if server_thread.is_alive():
|
|
282
|
+
server_thread.join(timeout=2)
|
|
283
|
+
|
|
284
|
+
def expire() -> None:
|
|
285
|
+
if not detached:
|
|
286
|
+
print("TTL expired; the public share has stopped.", flush=True)
|
|
287
|
+
stop_all()
|
|
288
|
+
|
|
289
|
+
timer = threading.Timer(ttl_seconds, expire)
|
|
290
|
+
timer.daemon = True
|
|
291
|
+
timer.start()
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
tunnel_process, tunnel_url = launch_trycloudflare_tunnel(local_url, timeout_seconds=min(ttl_seconds, 30))
|
|
295
|
+
public_file_url = build_download_url(tunnel_url, source.name)
|
|
296
|
+
print("\n" + "=" * 72)
|
|
297
|
+
print(f"PUBLIC FILE URL: {public_file_url}")
|
|
298
|
+
print("=" * 72 + "\n", flush=True)
|
|
299
|
+
print_qr_code(public_file_url)
|
|
300
|
+
# A detached child is already the background worker. Prompting it
|
|
301
|
+
# again (with stdin connected to DEVNULL) makes it take the
|
|
302
|
+
# foreground path accidentally and, more importantly, used to make
|
|
303
|
+
# the hand-off look as if the original service had simply died.
|
|
304
|
+
if os.name != "nt" and os.environ.get("QSHARE_BACKGROUND_CHILD") != "1" and ask_background_mode():
|
|
305
|
+
handoff = detach_existing_process(server)
|
|
306
|
+
if handoff is True:
|
|
307
|
+
# Parent exits, while the child keeps the inherited server
|
|
308
|
+
# socket and cloudflared process (therefore the same URL).
|
|
309
|
+
detached = True
|
|
310
|
+
return 0
|
|
311
|
+
if handoff is None:
|
|
312
|
+
# Windows cannot safely detach this interpreter from its
|
|
313
|
+
# console while retaining the inherited server/tunnel. Keep
|
|
314
|
+
# this process running instead of launching a replacement
|
|
315
|
+
# qshare (which would change the tunnel URL).
|
|
316
|
+
if os.name == "nt":
|
|
317
|
+
detached = True
|
|
318
|
+
print("Running in background mode; keep this window open to retain the same URL.")
|
|
319
|
+
# Fall through to the normal lifetime loop.
|
|
320
|
+
# Other non-fork platforms retain the independent-worker
|
|
321
|
+
# fallback.
|
|
322
|
+
detached = True
|
|
323
|
+
background_argv = [str(source), "--ttl", str(ttl_seconds)]
|
|
324
|
+
if port is not None:
|
|
325
|
+
background_argv.extend(["--port", str(port)])
|
|
326
|
+
return start_background_process(background_argv)
|
|
327
|
+
# Child: the pre-fork timer thread no longer exists, so recreate
|
|
328
|
+
# it before entering the normal lifetime loop.
|
|
329
|
+
detached = True
|
|
330
|
+
tunnel_process = DetachedTunnelProcess(tunnel_process.pid)
|
|
331
|
+
timer = threading.Timer(ttl_seconds, expire)
|
|
332
|
+
timer.daemon = True
|
|
333
|
+
timer.start()
|
|
334
|
+
while not stop_event.is_set() and tunnel_process.poll() is None:
|
|
335
|
+
time.sleep(0.5)
|
|
336
|
+
except KeyboardInterrupt:
|
|
337
|
+
print("\nInterrupted by user.")
|
|
338
|
+
stop_all()
|
|
339
|
+
return 0
|
|
340
|
+
except Exception:
|
|
341
|
+
stop_all()
|
|
342
|
+
raise
|
|
343
|
+
finally:
|
|
344
|
+
timer.cancel()
|
|
345
|
+
if not detached:
|
|
346
|
+
stop_all()
|
|
347
|
+
|
|
348
|
+
return 0
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def should_run_in_background(answer: str) -> bool:
|
|
352
|
+
return str(answer or "").strip().lower() == "d"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
356
|
+
parser = argparse.ArgumentParser(
|
|
357
|
+
description="Quickly share a local file over a public TryCloudflare tunnel.",
|
|
358
|
+
prog="qshare",
|
|
359
|
+
)
|
|
360
|
+
parser.add_argument("file", help="Local file to share publicly.")
|
|
361
|
+
parser.add_argument(
|
|
362
|
+
"-t",
|
|
363
|
+
"--ttl",
|
|
364
|
+
default="2h",
|
|
365
|
+
help="How long the tunnel remains active. Examples: 2h, 30m, 90s, or 600 (seconds).",
|
|
366
|
+
)
|
|
367
|
+
parser.add_argument(
|
|
368
|
+
"-p",
|
|
369
|
+
"--port",
|
|
370
|
+
type=int,
|
|
371
|
+
default=None,
|
|
372
|
+
help="Optional fixed port for the local HTTP server. A random free port is used when omitted.",
|
|
373
|
+
)
|
|
374
|
+
return parser
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def start_background_process(argv: List[str]) -> int:
|
|
378
|
+
cmd = [sys.executable, "-m", "qshare"] + argv
|
|
379
|
+
env = os.environ.copy()
|
|
380
|
+
env["QSHARE_BACKGROUND_CHILD"] = "1"
|
|
381
|
+
kwargs = {
|
|
382
|
+
"stdin": subprocess.DEVNULL,
|
|
383
|
+
# Keep the child's status and (most importantly) its replacement
|
|
384
|
+
# public URL visible to the user. The parent URL belongs to the
|
|
385
|
+
# short-lived process and cannot remain valid after hand-off.
|
|
386
|
+
"stdout": None,
|
|
387
|
+
"stderr": None,
|
|
388
|
+
"env": env,
|
|
389
|
+
}
|
|
390
|
+
if os.name == "nt":
|
|
391
|
+
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
392
|
+
kwargs["creationflags"] = creationflags
|
|
393
|
+
else:
|
|
394
|
+
kwargs["start_new_session"] = True
|
|
395
|
+
subprocess.Popen(cmd, **kwargs)
|
|
396
|
+
print("qshare started in background.")
|
|
397
|
+
return 0
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def detach_existing_process(server: ThreadingHTTPServer) -> Optional[bool]:
|
|
401
|
+
"""Detach while retaining the existing HTTP socket and tunnel process.
|
|
402
|
+
|
|
403
|
+
Returns True in the short-lived parent, False in the detached child, and
|
|
404
|
+
None on platforms without fork support.
|
|
405
|
+
"""
|
|
406
|
+
if os.name == "nt":
|
|
407
|
+
return None
|
|
408
|
+
if not hasattr(os, "fork"):
|
|
409
|
+
return None
|
|
410
|
+
child_pid = os.fork()
|
|
411
|
+
if child_pid:
|
|
412
|
+
return True
|
|
413
|
+
|
|
414
|
+
# The serving thread does not survive fork. Reuse the inherited socket
|
|
415
|
+
# and start a replacement thread; the cloudflared Popen process is also
|
|
416
|
+
# inherited, so its public hostname remains unchanged.
|
|
417
|
+
os.setsid()
|
|
418
|
+
try:
|
|
419
|
+
with open(os.devnull, "rb") as null_in, open(os.devnull, "ab") as null_out:
|
|
420
|
+
os.dup2(null_in.fileno(), sys.stdin.fileno())
|
|
421
|
+
os.dup2(null_out.fileno(), sys.stdout.fileno())
|
|
422
|
+
os.dup2(null_out.fileno(), sys.stderr.fileno())
|
|
423
|
+
except (OSError, ValueError):
|
|
424
|
+
pass
|
|
425
|
+
threading.Thread(target=server.serve_forever, name="qshare-http", daemon=True).start()
|
|
426
|
+
return False
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def ask_background_mode() -> bool:
|
|
430
|
+
try:
|
|
431
|
+
answer = input("Run in background? Press 'd' to detach, or press Enter to keep in the foreground: ")
|
|
432
|
+
except EOFError:
|
|
433
|
+
return False
|
|
434
|
+
return should_run_in_background(answer)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
438
|
+
parser = build_parser()
|
|
439
|
+
args = parser.parse_args(argv)
|
|
440
|
+
|
|
441
|
+
if os.environ.get("QSHARE_BACKGROUND_CHILD") == "1":
|
|
442
|
+
try:
|
|
443
|
+
ttl_seconds = parse_duration(args.ttl)
|
|
444
|
+
return run_share(args.file, ttl_seconds=ttl_seconds, port=args.port)
|
|
445
|
+
except KeyboardInterrupt:
|
|
446
|
+
print("\nStopped.")
|
|
447
|
+
return 130
|
|
448
|
+
except Exception as exc: # pragma: no cover - CLI-level output
|
|
449
|
+
print(f"qshare error: {exc}", file=sys.stderr)
|
|
450
|
+
return 1
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
ttl_seconds = parse_duration(args.ttl)
|
|
454
|
+
return run_share(args.file, ttl_seconds=ttl_seconds, port=args.port)
|
|
455
|
+
except KeyboardInterrupt:
|
|
456
|
+
print("\nStopped.")
|
|
457
|
+
return 130
|
|
458
|
+
except Exception as exc: # pragma: no cover - CLI-level output
|
|
459
|
+
print(f"qshare error: {exc}", file=sys.stderr)
|
|
460
|
+
return 1
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
if __name__ == "__main__":
|
|
464
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: qshare
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Quickly share local files through a public TryCloudflare tunnel.
|
|
5
|
+
Author: steinvenic
|
|
6
|
+
Author-email: steinvenic <761701732@qq.com>
|
|
7
|
+
Requires-Dist: qrcode==7.3
|
|
8
|
+
Requires-Python: >=3.6
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# qshare
|
|
12
|
+
|
|
13
|
+
Quickly share a local file via a public TryCloudflare tunnel.
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- Start a temporary local HTTP server on a random free port
|
|
18
|
+
- Detect and avoid occupied ports automatically
|
|
19
|
+
- Create a public TryCloudflare URL for the file
|
|
20
|
+
- Support a default lifetime of 2 hours, adjustable via CLI arguments
|
|
21
|
+
- Works on Linux, Windows, and macOS with a platform-specific bundled cloudflared binary
|
|
22
|
+
- Ready for uv-managed development and PyPI packaging
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- Python 3.6+
|
|
27
|
+
- qshare platform wheels include the matching official `cloudflared` binary. qshare never downloads a binary at runtime; update qshare from PyPI to receive an updated bundled binary.
|
|
28
|
+
|
|
29
|
+
On a platform without a matching wheel, install `cloudflared` manually and add it to `PATH`.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
Using uv:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv venv
|
|
37
|
+
source .venv/bin/activate
|
|
38
|
+
uv pip install -e .
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or install from PyPI after publishing:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install qshare
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
qshare /path/to/file.zip
|
|
51
|
+
qshare /path/to/file.zip --ttl 2h
|
|
52
|
+
qshare /path/to/file.zip --ttl 900
|
|
53
|
+
qshare /path/to/file.zip --port 8000
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
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 from `PATH`, qshare installs the binary bundled in the matching PyPI wheel into `~/.local/bin`. Platform wheels are available for Cloudflare's Linux (x86, x86_64, ARM, ARMHF, ARM64), Windows (x86, x86_64), and macOS (Intel, Apple Silicon) releases.
|
|
57
|
+
|
|
58
|
+
After the public URL is printed prominently, qshare displays a terminal QR code for the same link. qshare accepts only a valid share hostname such as `https://department-specialists-excellence-savings.trycloudflare.com`; Cloudflare API URLs are rejected and the tunnel is retried. On Unix-like systems it then prompts: `Run in background? Press 'd' to detach, or press Enter to keep in the foreground.` Windows always runs in the foreground. When the TTL expires in foreground mode, qshare prints a notification and stops the share.
|
|
59
|
+
|
|
60
|
+
## Build for PyPI
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
uv build
|
|
64
|
+
python scripts/build_platform_wheels.py --assets-dir /path/to/cloudflared-assets
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then upload the resulting artifacts from the `dist/` directory to PyPI.
|
|
68
|
+
|
|
69
|
+
## Notes
|
|
70
|
+
|
|
71
|
+
- The default lifetime is 2 hours.
|
|
72
|
+
- If a port is occupied, qshare automatically selects another free port.
|
|
73
|
+
- `--ttl` accepts values like `30m`, `90s`, `2h`, or a plain number in seconds.
|
|
74
|
+
- Background mode is triggered by entering `d` at the interactive prompt after the public URL appears.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
qshare-0.2.0.dist-info/,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
qshare-0.2.0.dist-info/METADATA,sha256=qJD28ujsZV8UZ7euyDrHgJwMUO1Zt4L0e4WqQCmZ1Ik,2836
|
|
3
|
+
qshare-0.2.0.dist-info/WHEEL,sha256=na9mNGrToiSQ7HGs7-Mj-CvbNN-qpWtFUMpMoSl9RiM,91
|
|
4
|
+
qshare-0.2.0.dist-info/entry_points.txt,sha256=4CVmwudLjYg8tF-MFd_5pl8jfvYjtwaDOtVkT8SkkM8,44
|
|
5
|
+
qshare/,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
qshare/__init__.py,sha256=37TXLwS35XmGk5BxwSjeitivOpazoHmUEs3b61kwSrU,65
|
|
7
|
+
qshare/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
|
|
8
|
+
qshare/_binaries/cloudflared,sha256=CT_6NjirK2Nt5jxDqMaPlqac9x-Wmd2Cd6kbFgsPT8A,36296127
|
|
9
|
+
qshare/cli.py,sha256=XNMMybQ961LGsSZk0KOidK4F8JYxHCCKe8W_0XM9DPs,16108
|
|
10
|
+
qshare-0.2.0.dist-info/RECORD,,
|