|
|
|
@@ -83,7 +83,7 @@ from PyQt5.QtWidgets import (
|
|
|
|
|
|
|
|
|
|
APP_ORG = "ClearCore"
|
|
|
|
|
APP_NAME = "PGU4-Sync"
|
|
|
|
|
APP_VERSION = "1.0.4"
|
|
|
|
|
APP_VERSION = "1.0.5"
|
|
|
|
|
CONFIG_FILE_NAME = "PGU-NetFileSyncWin.ini"
|
|
|
|
|
PASSWORD_PROTECTED_KEY = "password_protected"
|
|
|
|
|
PASSWORD_LEGACY_KEY = "password"
|
|
|
|
@@ -100,13 +100,14 @@ CHUNK_SIZE = 16 * 1024 * 1024
|
|
|
|
|
SPLIT_THRESHOLD = 32 * 1024 * 1024
|
|
|
|
|
PROGRESS_EMIT_STEP = 512 * 1024
|
|
|
|
|
DOWNLOAD_RETRIES = 3
|
|
|
|
|
DOWNLOAD_RETRY_BACKOFF = 0.6
|
|
|
|
|
DOWNLOAD_RETRY_BACKOFF = 0.8
|
|
|
|
|
FAST_HTTP_PORT = 5924
|
|
|
|
|
FAST_HTTP_CHUNK = 4 * 1024 * 1024
|
|
|
|
|
FAST_HTTP_WORKERS = 12
|
|
|
|
|
FAST_HTTP_BIG_FILE_WORKERS = 4
|
|
|
|
|
FAST_HTTP_WORKERS = 8
|
|
|
|
|
FAST_HTTP_BIG_FILE_WORKERS = 3
|
|
|
|
|
FAST_HTTP_TIMEOUT = 45
|
|
|
|
|
FAST_HTTP_TOKEN = "PGU4_SYNC_FAST_V2"
|
|
|
|
|
FAST_SERVICE_VERSION = "2"
|
|
|
|
|
FAST_SERVICE_VERSION = "3"
|
|
|
|
|
FAST_SPLIT_THRESHOLD = 64 * 1024 * 1024
|
|
|
|
|
FAST_REMOTE_APP_DIR = ".local/share/pgu4_sync"
|
|
|
|
|
FAST_REMOTE_STATE_DIR = ".local/state/pgu4_sync"
|
|
|
|
@@ -122,13 +123,31 @@ FAST_HTTP_SERVER_SCRIPT = r'''#!/usr/bin/env python3
|
|
|
|
|
import argparse
|
|
|
|
|
import base64
|
|
|
|
|
import os
|
|
|
|
|
import threading
|
|
|
|
|
import urllib.parse
|
|
|
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
|
|
|
|
|
|
PGU_FAST_SERVICE_VERSION = "2"
|
|
|
|
|
PGU_FAST_SERVICE_VERSION = "3"
|
|
|
|
|
|
|
|
|
|
FAST_SERVER_CPU_COUNT = max(1, os.cpu_count() or 1)
|
|
|
|
|
FAST_SERVER_WORKER_LIMIT = max(2, min(16, FAST_SERVER_CPU_COUNT))
|
|
|
|
|
|
|
|
|
|
class FastThreadingHTTPServer(ThreadingHTTPServer):
|
|
|
|
|
daemon_threads = True
|
|
|
|
|
allow_reuse_address = True
|
|
|
|
|
request_queue_size = max(32, FAST_SERVER_WORKER_LIMIT * 8)
|
|
|
|
|
|
|
|
|
|
def __init__(self, server_address, handler_class):
|
|
|
|
|
super().__init__(server_address, handler_class)
|
|
|
|
|
self.request_slots = threading.BoundedSemaphore(FAST_SERVER_WORKER_LIMIT)
|
|
|
|
|
|
|
|
|
|
def process_request_thread(self, request, client_address):
|
|
|
|
|
with self.request_slots:
|
|
|
|
|
super().process_request_thread(request, client_address)
|
|
|
|
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
|
|
|
token = ""
|
|
|
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
|
|
|
|
|
|
def log_message(self, fmt, *args):
|
|
|
|
|
return
|
|
|
|
@@ -143,9 +162,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
|
|
self.send_error(403)
|
|
|
|
|
return
|
|
|
|
|
if parsed.path == "/health":
|
|
|
|
|
body = b"ok-v3"
|
|
|
|
|
self.send_response(200)
|
|
|
|
|
self.send_header("Content-Length", str(len(body)))
|
|
|
|
|
self.send_header("X-PGU-CPU-Count", str(FAST_SERVER_CPU_COUNT))
|
|
|
|
|
self.send_header("X-PGU-Worker-Limit", str(FAST_SERVER_WORKER_LIMIT))
|
|
|
|
|
self.end_headers()
|
|
|
|
|
self.wfile.write(b"ok-v2")
|
|
|
|
|
self.wfile.write(body)
|
|
|
|
|
return
|
|
|
|
|
if parsed.path != "/file":
|
|
|
|
|
self.send_error(404)
|
|
|
|
@@ -186,7 +209,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
|
|
data = f.read(min(1024 * 1024, remaining))
|
|
|
|
|
if not data:
|
|
|
|
|
break
|
|
|
|
|
self.wfile.write(data)
|
|
|
|
|
try:
|
|
|
|
|
self.wfile.write(data)
|
|
|
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
|
|
|
return
|
|
|
|
|
remaining -= len(data)
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
@@ -196,7 +222,7 @@ def main():
|
|
|
|
|
parser.add_argument("--token", required=True)
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
Handler.token = args.token
|
|
|
|
|
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
|
|
|
|
server = FastThreadingHTTPServer((args.host, args.port), Handler)
|
|
|
|
|
server.serve_forever()
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
@@ -237,6 +263,7 @@ class FastHttpProfile:
|
|
|
|
|
host: str
|
|
|
|
|
port: int
|
|
|
|
|
token: str
|
|
|
|
|
version: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
@@ -379,7 +406,7 @@ class RemoteTableModel(QAbstractTableModel):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ConnectWorker(QObject):
|
|
|
|
|
connected = pyqtSignal()
|
|
|
|
|
connected = pyqtSignal(int)
|
|
|
|
|
failed = pyqtSignal(str)
|
|
|
|
|
|
|
|
|
|
def __init__(self, factory: SSHClientFactory, profile: ConnectionProfile) -> None:
|
|
|
|
@@ -388,13 +415,26 @@ class ConnectWorker(QObject):
|
|
|
|
|
self.profile = profile
|
|
|
|
|
|
|
|
|
|
def run(self) -> None:
|
|
|
|
|
client = None
|
|
|
|
|
try:
|
|
|
|
|
self.factory.set_profile(self.profile)
|
|
|
|
|
client = self.factory.open_client()
|
|
|
|
|
client.close()
|
|
|
|
|
self.connected.emit()
|
|
|
|
|
_stdin, stdout, _stderr = client.exec_command(
|
|
|
|
|
"getconf _NPROCESSORS_ONLN 2>/dev/null || "
|
|
|
|
|
"nproc 2>/dev/null || grep -c '^processor' /proc/cpuinfo",
|
|
|
|
|
timeout=5,
|
|
|
|
|
)
|
|
|
|
|
output = stdout.read().decode(errors="ignore").strip().splitlines()
|
|
|
|
|
try:
|
|
|
|
|
remote_workers = max(1, min(256, int(output[0].strip()))) if output else 0
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
remote_workers = 0
|
|
|
|
|
self.connected.emit(remote_workers)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
self.failed.emit(str(exc))
|
|
|
|
|
finally:
|
|
|
|
|
if client is not None:
|
|
|
|
|
client.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ListWorker(QObject):
|
|
|
|
@@ -699,7 +739,7 @@ class UpdateDownloadWorker(QObject):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FastServiceWorker(QObject):
|
|
|
|
|
ready = pyqtSignal(str, int, str)
|
|
|
|
|
ready = pyqtSignal(str, int, str, str)
|
|
|
|
|
failed = pyqtSignal(str)
|
|
|
|
|
|
|
|
|
|
def __init__(self, factory: SSHClientFactory, host: str, port: int, token: str, replace_existing: bool = False) -> None:
|
|
|
|
@@ -715,7 +755,7 @@ class FastServiceWorker(QObject):
|
|
|
|
|
if self._has_existing_fast_service() and not self.replace_existing:
|
|
|
|
|
if self._healthcheck():
|
|
|
|
|
self._ensure_autostart_only()
|
|
|
|
|
self.ready.emit(self.host, self.port, self.token)
|
|
|
|
|
self.ready.emit(self.host, self.port, self.token, FAST_SERVICE_VERSION)
|
|
|
|
|
return
|
|
|
|
|
else:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
@@ -751,6 +791,8 @@ class FastServiceWorker(QObject):
|
|
|
|
|
f"done; "
|
|
|
|
|
f"pkill -f '[p]gu4_fast_http.py --port {self.port}' >/dev/null 2>&1 || true; "
|
|
|
|
|
f"chmod +x {self._shq(script_path)}; "
|
|
|
|
|
"user_name=$(id -un); loginctl enable-linger \"$user_name\" >/dev/null 2>&1 "
|
|
|
|
|
"|| sudo -n loginctl enable-linger \"$user_name\" >/dev/null 2>&1 || true; "
|
|
|
|
|
f"(crontab -l 2>/dev/null | grep -v 'pgu4_fast_http.py.*--port {self.port}' ; printf '%s\\n' {self._shq(cron_line)}) | crontab -; "
|
|
|
|
|
f"systemctl --user daemon-reload >/dev/null 2>&1 || true; "
|
|
|
|
|
f"systemctl --user enable {FAST_REMOTE_SERVICE_NAME} >/dev/null 2>&1 || true; "
|
|
|
|
@@ -773,7 +815,7 @@ class FastServiceWorker(QObject):
|
|
|
|
|
deadline = time.time() + 8
|
|
|
|
|
while time.time() < deadline:
|
|
|
|
|
if self._healthcheck():
|
|
|
|
|
self.ready.emit(self.host, self.port, self.token)
|
|
|
|
|
self.ready.emit(self.host, self.port, self.token, FAST_SERVICE_VERSION)
|
|
|
|
|
return
|
|
|
|
|
time.sleep(0.2)
|
|
|
|
|
client = self.factory.open_client()
|
|
|
|
@@ -814,6 +856,8 @@ class FastServiceWorker(QObject):
|
|
|
|
|
)
|
|
|
|
|
cmd = (
|
|
|
|
|
f"chmod +x {self._shq(script_path)}; "
|
|
|
|
|
"user_name=$(id -un); loginctl enable-linger \"$user_name\" >/dev/null 2>&1 "
|
|
|
|
|
"|| sudo -n loginctl enable-linger \"$user_name\" >/dev/null 2>&1 || true; "
|
|
|
|
|
f"(crontab -l 2>/dev/null | grep -v 'pgu4_fast_http.py.*--port {self.port}' ; printf '%s\\n' {self._shq(cron_line)}) | crontab -; "
|
|
|
|
|
f"systemctl --user daemon-reload >/dev/null 2>&1 || true; "
|
|
|
|
|
f"systemctl --user enable {FAST_REMOTE_SERVICE_NAME} >/dev/null 2>&1 || true"
|
|
|
|
@@ -854,7 +898,7 @@ class FastServiceWorker(QObject):
|
|
|
|
|
path = "/health?token={}".format(urllib.parse.quote(self.token))
|
|
|
|
|
conn.request("GET", path)
|
|
|
|
|
resp = conn.getresponse()
|
|
|
|
|
ok = resp.status == 200 and resp.read().startswith(b"ok-v2")
|
|
|
|
|
ok = resp.status == 200 and resp.read().startswith(b"ok-v3")
|
|
|
|
|
conn.close()
|
|
|
|
|
return ok
|
|
|
|
|
except Exception:
|
|
|
|
@@ -897,7 +941,7 @@ class StopFastServiceWorker(QObject):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CheckFastServiceWorker(QObject):
|
|
|
|
|
available = pyqtSignal(str, int, str)
|
|
|
|
|
available = pyqtSignal(str, int, str, str)
|
|
|
|
|
legacy = pyqtSignal(str)
|
|
|
|
|
repair = pyqtSignal(str)
|
|
|
|
|
unavailable = pyqtSignal()
|
|
|
|
@@ -910,28 +954,41 @@ class CheckFastServiceWorker(QObject):
|
|
|
|
|
self.token = token
|
|
|
|
|
|
|
|
|
|
def run(self) -> None:
|
|
|
|
|
if self._healthcheck():
|
|
|
|
|
healthy, version = self._healthcheck_details()
|
|
|
|
|
if healthy and version == FAST_SERVICE_VERSION:
|
|
|
|
|
try:
|
|
|
|
|
self._ensure_autostart()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
self.available.emit(self.host, self.port, self.token)
|
|
|
|
|
self.available.emit(self.host, self.port, self.token, version or FAST_SERVICE_VERSION)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if healthy and version:
|
|
|
|
|
self.legacy.emit(f"检测到 5924 上已有旧极速服务,当前版本 v{version}")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
installed_state = self._installed_service_state()
|
|
|
|
|
if installed_state == "current":
|
|
|
|
|
if self._restart_current_service():
|
|
|
|
|
self.available.emit(self.host, self.port, self.token)
|
|
|
|
|
self.available.emit(self.host, self.port, self.token, FAST_SERVICE_VERSION)
|
|
|
|
|
else:
|
|
|
|
|
self.repair.emit("当前版本极速服务已安装,但自动启动失败")
|
|
|
|
|
version = self._installed_service_version()
|
|
|
|
|
version_text = f"v{version}" if version else "当前版本"
|
|
|
|
|
self.repair.emit(f"极速服务 {version_text} 已安装,但自动启动失败")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if installed_state == "legacy" or self._has_legacy_service():
|
|
|
|
|
self.legacy.emit(f"检测到 5924 上已有旧极速服务或旧 token 服务")
|
|
|
|
|
version = self._installed_service_version()
|
|
|
|
|
version_text = f"v{version}" if version else "未知版本"
|
|
|
|
|
self.legacy.emit(f"检测到 5924 上已有旧极速服务,当前版本 {version_text}")
|
|
|
|
|
else:
|
|
|
|
|
self.unavailable.emit()
|
|
|
|
|
|
|
|
|
|
def _healthcheck(self) -> bool:
|
|
|
|
|
healthy, _version = self._healthcheck_details()
|
|
|
|
|
return healthy
|
|
|
|
|
|
|
|
|
|
def _healthcheck_details(self) -> tuple[bool, str]:
|
|
|
|
|
try:
|
|
|
|
|
conn = http.client.HTTPConnection(self.host, self.port, timeout=1.5)
|
|
|
|
|
path = "/health?token={}".format(
|
|
|
|
@@ -939,11 +996,14 @@ class CheckFastServiceWorker(QObject):
|
|
|
|
|
)
|
|
|
|
|
conn.request("GET", path)
|
|
|
|
|
resp = conn.getresponse()
|
|
|
|
|
ok = resp.status == 200 and resp.read().startswith(b"ok-v2")
|
|
|
|
|
body = resp.read()
|
|
|
|
|
match = re.match(rb"ok-v(\d+)", body)
|
|
|
|
|
version = match.group(1).decode() if match else ""
|
|
|
|
|
ok = resp.status == 200 and bool(match)
|
|
|
|
|
conn.close()
|
|
|
|
|
return ok
|
|
|
|
|
return ok, version
|
|
|
|
|
except Exception:
|
|
|
|
|
return False
|
|
|
|
|
return False, ""
|
|
|
|
|
|
|
|
|
|
def _installed_service_state(self) -> str:
|
|
|
|
|
try:
|
|
|
|
@@ -956,7 +1016,7 @@ class CheckFastServiceWorker(QObject):
|
|
|
|
|
f"script=\"$HOME/{script_rel}\"; "
|
|
|
|
|
f"if [ ! -f \"$script\" ]; then echo missing; "
|
|
|
|
|
f"elif grep -q 'PGU_FAST_SERVICE_VERSION = \"{FAST_SERVICE_VERSION}\"' \"$script\" "
|
|
|
|
|
f"|| grep -q 'ok-v2' \"$script\"; then echo current; "
|
|
|
|
|
f"|| grep -q 'ok-v3' \"$script\"; then echo current; "
|
|
|
|
|
f"else echo legacy; fi"
|
|
|
|
|
)
|
|
|
|
|
_stdin, stdout, _stderr = client.exec_command(cmd, timeout=4)
|
|
|
|
@@ -966,6 +1026,22 @@ class CheckFastServiceWorker(QObject):
|
|
|
|
|
except Exception:
|
|
|
|
|
return "missing"
|
|
|
|
|
|
|
|
|
|
def _installed_service_version(self) -> str:
|
|
|
|
|
try:
|
|
|
|
|
client = self.factory.open_client()
|
|
|
|
|
script_rel = posixpath.join(FAST_REMOTE_APP_DIR, FAST_REMOTE_SCRIPT_NAME)
|
|
|
|
|
cmd = (
|
|
|
|
|
f"script=\"$HOME/{script_rel}\"; "
|
|
|
|
|
"grep -o 'PGU_FAST_SERVICE_VERSION = \"[0-9][0-9]*\"' \"$script\" "
|
|
|
|
|
"| grep -o '[0-9][0-9]*' | head -1"
|
|
|
|
|
)
|
|
|
|
|
_stdin, stdout, _stderr = client.exec_command(cmd, timeout=4)
|
|
|
|
|
version = stdout.read().decode(errors="ignore").strip()
|
|
|
|
|
client.close()
|
|
|
|
|
return version
|
|
|
|
|
except Exception:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
def _restart_current_service(self) -> bool:
|
|
|
|
|
try:
|
|
|
|
|
self._ensure_autostart()
|
|
|
|
@@ -978,6 +1054,8 @@ class CheckFastServiceWorker(QObject):
|
|
|
|
|
f"tr '\\0' ' ' < /proc/$pid/cmdline | grep -q 'pgu4_fast_http.py' "
|
|
|
|
|
f"&& kill $pid >/dev/null 2>&1 || true; done; "
|
|
|
|
|
f"pkill -f '[p]gu4_fast_http.py --port {self.port}' >/dev/null 2>&1 || true; "
|
|
|
|
|
"user_name=$(id -un); loginctl enable-linger \"$user_name\" >/dev/null 2>&1 "
|
|
|
|
|
"|| sudo -n loginctl enable-linger \"$user_name\" >/dev/null 2>&1 || true; "
|
|
|
|
|
f"systemctl --user daemon-reload >/dev/null 2>&1 || true; "
|
|
|
|
|
f"systemctl --user enable {FAST_REMOTE_SERVICE_NAME} >/dev/null 2>&1 || true; "
|
|
|
|
|
f"if ! systemctl --user restart {FAST_REMOTE_SERVICE_NAME} >/dev/null 2>&1; then "
|
|
|
|
@@ -1052,6 +1130,8 @@ class CheckFastServiceWorker(QObject):
|
|
|
|
|
)
|
|
|
|
|
cmd = (
|
|
|
|
|
f"chmod +x {shq(script_path)}; "
|
|
|
|
|
"user_name=$(id -un); loginctl enable-linger \"$user_name\" >/dev/null 2>&1 "
|
|
|
|
|
"|| sudo -n loginctl enable-linger \"$user_name\" >/dev/null 2>&1 || true; "
|
|
|
|
|
f"(crontab -l 2>/dev/null | grep -v 'pgu4_fast_http.py.*--port {self.port}' ; printf '%s\\n' {shq(cron_line)}) | crontab -; "
|
|
|
|
|
f"systemctl --user daemon-reload >/dev/null 2>&1 || true; "
|
|
|
|
|
f"systemctl --user enable {FAST_REMOTE_SERVICE_NAME} >/dev/null 2>&1 || true"
|
|
|
|
@@ -1484,6 +1564,8 @@ class DownloadJob(QRunnable):
|
|
|
|
|
self._thread_local = threading.local()
|
|
|
|
|
self._clients: list[paramiko.SSHClient] = []
|
|
|
|
|
self._clients_lock = threading.Lock()
|
|
|
|
|
self._http_connections: list[http.client.HTTPConnection] = []
|
|
|
|
|
self._http_connections_lock = threading.Lock()
|
|
|
|
|
self._file_locks: dict[Path, threading.Lock] = {}
|
|
|
|
|
self._progress_lock = threading.Lock()
|
|
|
|
|
self._progress_by_path: dict[str, int] = {}
|
|
|
|
@@ -1522,6 +1604,7 @@ class DownloadJob(QRunnable):
|
|
|
|
|
for worker in workers:
|
|
|
|
|
worker.join(timeout=2)
|
|
|
|
|
self._close_thread_clients()
|
|
|
|
|
self._close_http_connections()
|
|
|
|
|
self.signals.all_done.emit(cancelled or self.controller.cancel_event.is_set())
|
|
|
|
|
|
|
|
|
|
def _transfer_worker_count(self) -> int:
|
|
|
|
@@ -1709,9 +1792,12 @@ class DownloadJob(QRunnable):
|
|
|
|
|
self.signals.item_done.emit(file.remote_path)
|
|
|
|
|
return
|
|
|
|
|
last_error = None
|
|
|
|
|
for attempt in range(1, DOWNLOAD_RETRIES + 1):
|
|
|
|
|
outer_attempts = 1 if self.fast_http is not None else DOWNLOAD_RETRIES
|
|
|
|
|
for attempt in range(1, outer_attempts + 1):
|
|
|
|
|
try:
|
|
|
|
|
if self.fast_http is not None:
|
|
|
|
|
# The HTTP path owns retries so a broken connection can
|
|
|
|
|
# resume the unfinished range instead of restarting it.
|
|
|
|
|
self._download_chunk_http(file, offset, length)
|
|
|
|
|
else:
|
|
|
|
|
self._download_chunk_sftp_once(file, offset, length)
|
|
|
|
@@ -1721,13 +1807,21 @@ class DownloadJob(QRunnable):
|
|
|
|
|
self._reset_thread_client()
|
|
|
|
|
if self.controller.cancel_event.is_set():
|
|
|
|
|
return
|
|
|
|
|
if attempt < DOWNLOAD_RETRIES:
|
|
|
|
|
if attempt < DOWNLOAD_RETRIES and self.fast_http is None:
|
|
|
|
|
self.signals.status.emit(
|
|
|
|
|
f"重试 {attempt}/{DOWNLOAD_RETRIES - 1}: {Path(file.remote_path).name}"
|
|
|
|
|
)
|
|
|
|
|
time.sleep(DOWNLOAD_RETRY_BACKOFF * attempt)
|
|
|
|
|
self._retry_wait(attempt)
|
|
|
|
|
self.signals.failed.emit(file.remote_path, str(last_error) if last_error else "下载失败")
|
|
|
|
|
|
|
|
|
|
def _retry_wait(self, attempt: int) -> None:
|
|
|
|
|
deadline = time.monotonic() + DOWNLOAD_RETRY_BACKOFF * (2 ** max(0, attempt - 1))
|
|
|
|
|
while time.monotonic() < deadline:
|
|
|
|
|
if self.controller.cancel_event.is_set():
|
|
|
|
|
return
|
|
|
|
|
self.controller.wait_if_paused()
|
|
|
|
|
time.sleep(min(0.1, max(0.0, deadline - time.monotonic())))
|
|
|
|
|
|
|
|
|
|
def _download_chunk_sftp_once(self, file: FileTask, offset: int, length: int) -> None:
|
|
|
|
|
if self.fast_http is not None:
|
|
|
|
|
return
|
|
|
|
@@ -1782,41 +1876,89 @@ class DownloadJob(QRunnable):
|
|
|
|
|
def _download_chunk_http_locked(self, file: FileTask, offset: int, length: int) -> None:
|
|
|
|
|
written = 0
|
|
|
|
|
emit_acc = 0
|
|
|
|
|
conn = None
|
|
|
|
|
path = "/file?token={}&path_b64={}".format(
|
|
|
|
|
urllib.parse.quote(self.fast_http.token, safe=""),
|
|
|
|
|
urllib.parse.quote(encode_fast_remote_path(file.remote_path), safe=""),
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
conn = http.client.HTTPConnection(self.fast_http.host, self.fast_http.port, timeout=15)
|
|
|
|
|
headers = {"Range": f"bytes={offset}-{offset + length - 1}"}
|
|
|
|
|
conn.request("GET", path, headers=headers)
|
|
|
|
|
resp = conn.getresponse()
|
|
|
|
|
if resp.status not in (200, 206):
|
|
|
|
|
raise RuntimeError(f"HTTP {resp.status} {resp.reason}")
|
|
|
|
|
with file.local_path.open("r+b") as local:
|
|
|
|
|
while written < length:
|
|
|
|
|
self.controller.wait_if_paused()
|
|
|
|
|
if self.controller.cancel_event.is_set():
|
|
|
|
|
return
|
|
|
|
|
data = resp.read(min(FAST_HTTP_CHUNK, length - written))
|
|
|
|
|
if not data:
|
|
|
|
|
break
|
|
|
|
|
with self._file_locks[file.local_path]:
|
|
|
|
|
local.seek(offset + written)
|
|
|
|
|
local.write(data)
|
|
|
|
|
written += len(data)
|
|
|
|
|
emit_acc += len(data)
|
|
|
|
|
if emit_acc >= PROGRESS_EMIT_STEP:
|
|
|
|
|
last_error = None
|
|
|
|
|
for attempt in range(1, DOWNLOAD_RETRIES + 1):
|
|
|
|
|
try:
|
|
|
|
|
self.controller.wait_if_paused()
|
|
|
|
|
if self.controller.cancel_event.is_set():
|
|
|
|
|
return
|
|
|
|
|
conn = self._get_http_connection()
|
|
|
|
|
range_start = offset + written
|
|
|
|
|
headers = {
|
|
|
|
|
"Range": f"bytes={range_start}-{offset + length - 1}",
|
|
|
|
|
"Connection": "keep-alive",
|
|
|
|
|
}
|
|
|
|
|
conn.request("GET", path, headers=headers)
|
|
|
|
|
resp = conn.getresponse()
|
|
|
|
|
expected_status = 206
|
|
|
|
|
if resp.status != expected_status:
|
|
|
|
|
raise RuntimeError(f"HTTP {resp.status} {resp.reason}")
|
|
|
|
|
with file.local_path.open("r+b") as local:
|
|
|
|
|
while written < length:
|
|
|
|
|
self.controller.wait_if_paused()
|
|
|
|
|
if self.controller.cancel_event.is_set():
|
|
|
|
|
self._reset_thread_http_connection()
|
|
|
|
|
return
|
|
|
|
|
data = resp.read(min(FAST_HTTP_CHUNK, length - written))
|
|
|
|
|
if not data:
|
|
|
|
|
break
|
|
|
|
|
with self._file_locks[file.local_path]:
|
|
|
|
|
local.seek(offset + written)
|
|
|
|
|
local.write(data)
|
|
|
|
|
written += len(data)
|
|
|
|
|
emit_acc += len(data)
|
|
|
|
|
if emit_acc >= PROGRESS_EMIT_STEP:
|
|
|
|
|
self._emit_chunk_progress(file, offset, written)
|
|
|
|
|
emit_acc = 0
|
|
|
|
|
if emit_acc:
|
|
|
|
|
self._emit_chunk_progress(file, offset, written)
|
|
|
|
|
emit_acc = 0
|
|
|
|
|
if emit_acc:
|
|
|
|
|
self._emit_chunk_progress(file, offset, written)
|
|
|
|
|
if written < length and not self.controller.cancel_event.is_set():
|
|
|
|
|
if written >= length:
|
|
|
|
|
return
|
|
|
|
|
raise IOError(f"连接提前结束: {written}/{length}")
|
|
|
|
|
finally:
|
|
|
|
|
if conn is not None:
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
last_error = exc
|
|
|
|
|
self._reset_thread_http_connection()
|
|
|
|
|
if self.controller.cancel_event.is_set():
|
|
|
|
|
return
|
|
|
|
|
if attempt < DOWNLOAD_RETRIES:
|
|
|
|
|
self.signals.status.emit(
|
|
|
|
|
f"极速通道重试 {attempt}/{DOWNLOAD_RETRIES - 1}: {Path(file.remote_path).name}"
|
|
|
|
|
)
|
|
|
|
|
self._retry_wait(attempt)
|
|
|
|
|
raise last_error or IOError("极速通道下载失败")
|
|
|
|
|
|
|
|
|
|
def _get_http_connection(self) -> http.client.HTTPConnection:
|
|
|
|
|
conn = getattr(self._thread_local, "http_conn", None)
|
|
|
|
|
if conn is None:
|
|
|
|
|
conn = http.client.HTTPConnection(
|
|
|
|
|
self.fast_http.host,
|
|
|
|
|
self.fast_http.port,
|
|
|
|
|
timeout=FAST_HTTP_TIMEOUT,
|
|
|
|
|
)
|
|
|
|
|
self._thread_local.http_conn = conn
|
|
|
|
|
with self._http_connections_lock:
|
|
|
|
|
self._http_connections.append(conn)
|
|
|
|
|
return conn
|
|
|
|
|
|
|
|
|
|
def _reset_thread_http_connection(self) -> None:
|
|
|
|
|
conn = getattr(self._thread_local, "http_conn", None)
|
|
|
|
|
if conn is None:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
with self._http_connections_lock:
|
|
|
|
|
if conn in self._http_connections:
|
|
|
|
|
self._http_connections.remove(conn)
|
|
|
|
|
try:
|
|
|
|
|
delattr(self._thread_local, "http_conn")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _emit_chunk_progress(self, file: FileTask, offset: int, chunk_written: int) -> None:
|
|
|
|
|
if chunk_written <= 0:
|
|
|
|
@@ -1871,6 +2013,16 @@ class DownloadJob(QRunnable):
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _close_http_connections(self) -> None:
|
|
|
|
|
with self._http_connections_lock:
|
|
|
|
|
connections = list(self._http_connections)
|
|
|
|
|
self._http_connections.clear()
|
|
|
|
|
for conn in connections:
|
|
|
|
|
try:
|
|
|
|
|
conn.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RemoteTableView(QTableView):
|
|
|
|
|
external_drag_requested = pyqtSignal()
|
|
|
|
@@ -2165,7 +2317,9 @@ class MainWindow(QMainWindow):
|
|
|
|
|
self.download_signals = DownloadSignals()
|
|
|
|
|
self.controller: TransferController | None = None
|
|
|
|
|
self.fast_http: FastHttpProfile | None = None
|
|
|
|
|
self.max_workers = get_logical_thread_count()
|
|
|
|
|
self.local_workers = get_logical_thread_count()
|
|
|
|
|
self.remote_workers = 0
|
|
|
|
|
self.max_workers = self.local_workers
|
|
|
|
|
self._threads: list[QThread] = []
|
|
|
|
|
self._active_download = False
|
|
|
|
|
self._active_delete = False
|
|
|
|
@@ -2176,6 +2330,7 @@ class MainWindow(QMainWindow):
|
|
|
|
|
self._download_start_time = 0.0
|
|
|
|
|
self._current_speed = 0.0
|
|
|
|
|
self._channel_label = "普通通道"
|
|
|
|
|
self._fast_service_version = ""
|
|
|
|
|
self._download_errors: list[tuple[str, str]] = []
|
|
|
|
|
self._fast_service_legacy = False
|
|
|
|
|
self._fast_service_repair = False
|
|
|
|
@@ -3707,7 +3862,9 @@ class MainWindow(QMainWindow):
|
|
|
|
|
else:
|
|
|
|
|
self.start_fast_service()
|
|
|
|
|
|
|
|
|
|
def set_fast_button_state(self, state: str) -> None:
|
|
|
|
|
def set_fast_button_state(self, state: str, version: str | None = None) -> None:
|
|
|
|
|
if version is not None:
|
|
|
|
|
self._fast_service_version = version.strip().lstrip("vV")
|
|
|
|
|
self.fast_btn.setProperty("fastState", state)
|
|
|
|
|
self.fast_btn.style().unpolish(self.fast_btn)
|
|
|
|
|
self.fast_btn.style().polish(self.fast_btn)
|
|
|
|
@@ -3720,7 +3877,14 @@ class MainWindow(QMainWindow):
|
|
|
|
|
"stop": "极速已启用",
|
|
|
|
|
"busy": "极速检测中",
|
|
|
|
|
}
|
|
|
|
|
self.fast_status_badge.setText(labels.get(state, "极速未启用"))
|
|
|
|
|
label = labels.get(state, "极速未启用")
|
|
|
|
|
if self._fast_service_version and state in {"stop", "upgrade", "repair"}:
|
|
|
|
|
label = f"极速 v{self._fast_service_version}"
|
|
|
|
|
if state == "upgrade":
|
|
|
|
|
label += " · 可升级"
|
|
|
|
|
elif state == "repair":
|
|
|
|
|
label += " · 需修复"
|
|
|
|
|
self.fast_status_badge.setText(label)
|
|
|
|
|
self.fast_status_badge.setProperty("fastState", state)
|
|
|
|
|
self.fast_status_badge.style().unpolish(self.fast_status_badge)
|
|
|
|
|
self.fast_status_badge.style().polish(self.fast_status_badge)
|
|
|
|
@@ -3768,7 +3932,7 @@ class MainWindow(QMainWindow):
|
|
|
|
|
thread = QThread(self)
|
|
|
|
|
worker.moveToThread(thread)
|
|
|
|
|
thread.worker = worker
|
|
|
|
|
worker.available.connect(lambda h, p, t: self.on_fast_service_detected(h, p, t, thread))
|
|
|
|
|
worker.available.connect(lambda h, p, t, v: self.on_fast_service_detected(h, p, t, v, thread))
|
|
|
|
|
worker.legacy.connect(lambda msg: self.on_fast_service_legacy(msg, thread))
|
|
|
|
|
worker.repair.connect(lambda msg: self.on_fast_service_repair(msg, thread))
|
|
|
|
|
worker.unavailable.connect(lambda: self.on_fast_service_unavailable(thread))
|
|
|
|
@@ -3777,17 +3941,18 @@ class MainWindow(QMainWindow):
|
|
|
|
|
self._threads.append(thread)
|
|
|
|
|
thread.start()
|
|
|
|
|
|
|
|
|
|
def on_fast_service_detected(self, host: str, port: int, token: str, thread: QThread) -> None:
|
|
|
|
|
def on_fast_service_detected(self, host: str, port: int, token: str, version: str, thread: QThread) -> None:
|
|
|
|
|
if self._closing:
|
|
|
|
|
self._finish_thread(thread)
|
|
|
|
|
return
|
|
|
|
|
self.fast_http = FastHttpProfile(host, port, token)
|
|
|
|
|
self.fast_http = FastHttpProfile(host, port, token, version)
|
|
|
|
|
self._fast_service_version = version
|
|
|
|
|
self._fast_service_legacy = False
|
|
|
|
|
self._fast_service_repair = False
|
|
|
|
|
self.fast_btn.setEnabled(True)
|
|
|
|
|
self.fast_btn.setText("停止极速")
|
|
|
|
|
self.set_fast_button_state("stop")
|
|
|
|
|
self.statusBar().showMessage("检测到远端极速服务,已启用极速通道", 5000)
|
|
|
|
|
self.set_fast_button_state("stop", version)
|
|
|
|
|
self.statusBar().showMessage(f"检测到远端极速服务 v{version},已启用极速通道", 5000)
|
|
|
|
|
self._finish_thread(thread)
|
|
|
|
|
|
|
|
|
|
def on_fast_service_unavailable(self, thread: QThread) -> None:
|
|
|
|
@@ -3795,6 +3960,7 @@ class MainWindow(QMainWindow):
|
|
|
|
|
self._finish_thread(thread)
|
|
|
|
|
return
|
|
|
|
|
self.fast_http = None
|
|
|
|
|
self._fast_service_version = ""
|
|
|
|
|
self._fast_service_legacy = False
|
|
|
|
|
self._fast_service_repair = False
|
|
|
|
|
self.fast_btn.setEnabled(True)
|
|
|
|
@@ -3810,9 +3976,11 @@ class MainWindow(QMainWindow):
|
|
|
|
|
self.fast_http = None
|
|
|
|
|
self._fast_service_legacy = True
|
|
|
|
|
self._fast_service_repair = False
|
|
|
|
|
match = re.search(r"当前版本\s+v?([0-9]+)", message)
|
|
|
|
|
self._fast_service_version = match.group(1) if match else ""
|
|
|
|
|
self.fast_btn.setEnabled(True)
|
|
|
|
|
self.fast_btn.setText("升级极速")
|
|
|
|
|
self.set_fast_button_state("upgrade")
|
|
|
|
|
self.set_fast_button_state("upgrade", self._fast_service_version)
|
|
|
|
|
self.statusBar().showMessage(message + ",可点击升级极速", 7000)
|
|
|
|
|
self._finish_thread(thread)
|
|
|
|
|
|
|
|
|
@@ -3823,9 +3991,11 @@ class MainWindow(QMainWindow):
|
|
|
|
|
self.fast_http = None
|
|
|
|
|
self._fast_service_legacy = False
|
|
|
|
|
self._fast_service_repair = True
|
|
|
|
|
match = re.search(r"极速服务\s+v?([0-9]+)", message)
|
|
|
|
|
self._fast_service_version = match.group(1) if match else self._fast_service_version
|
|
|
|
|
self.fast_btn.setEnabled(True)
|
|
|
|
|
self.fast_btn.setText("修复极速")
|
|
|
|
|
self.set_fast_button_state("repair")
|
|
|
|
|
self.set_fast_button_state("repair", self._fast_service_version)
|
|
|
|
|
self.statusBar().showMessage(message + ",可点击修复极速", 7000)
|
|
|
|
|
self._finish_thread(thread)
|
|
|
|
|
|
|
|
|
@@ -3854,7 +4024,7 @@ class MainWindow(QMainWindow):
|
|
|
|
|
thread = QThread(self)
|
|
|
|
|
worker.moveToThread(thread)
|
|
|
|
|
thread.worker = worker
|
|
|
|
|
worker.ready.connect(lambda host, port, t: self.on_fast_service_ready(host, port, t, thread))
|
|
|
|
|
worker.ready.connect(lambda host, port, t, v: self.on_fast_service_ready(host, port, t, v, thread))
|
|
|
|
|
worker.failed.connect(lambda msg: self.on_fast_service_failed(msg, thread))
|
|
|
|
|
thread.started.connect(worker.run)
|
|
|
|
|
thread.finished.connect(lambda: self._threads.remove(thread) if thread in self._threads else None)
|
|
|
|
@@ -3862,17 +4032,18 @@ class MainWindow(QMainWindow):
|
|
|
|
|
thread.start()
|
|
|
|
|
QTimer.singleShot(20000, lambda: self.on_fast_service_timeout(thread))
|
|
|
|
|
|
|
|
|
|
def on_fast_service_ready(self, host: str, port: int, token: str, thread: QThread) -> None:
|
|
|
|
|
def on_fast_service_ready(self, host: str, port: int, token: str, version: str, thread: QThread) -> None:
|
|
|
|
|
if self._closing:
|
|
|
|
|
self._finish_thread(thread)
|
|
|
|
|
return
|
|
|
|
|
self.fast_http = FastHttpProfile(host, port, token)
|
|
|
|
|
self.fast_http = FastHttpProfile(host, port, token, version)
|
|
|
|
|
self._fast_service_version = version
|
|
|
|
|
self._fast_service_legacy = False
|
|
|
|
|
self._fast_service_repair = False
|
|
|
|
|
self.fast_btn.setEnabled(True)
|
|
|
|
|
self.fast_btn.setText("停止极速")
|
|
|
|
|
self.set_fast_button_state("stop")
|
|
|
|
|
self.statusBar().showMessage(f"远端极速服务已运行,已启用极速通道: http://{host}:{port}", 5000)
|
|
|
|
|
self.set_fast_button_state("stop", version)
|
|
|
|
|
self.statusBar().showMessage(f"远端极速服务 v{version} 已运行,已启用极速通道: http://{host}:{port}", 5000)
|
|
|
|
|
self._finish_thread(thread)
|
|
|
|
|
|
|
|
|
|
def on_fast_service_failed(self, message: str, thread: QThread) -> None:
|
|
|
|
@@ -3880,6 +4051,8 @@ class MainWindow(QMainWindow):
|
|
|
|
|
self._finish_thread(thread)
|
|
|
|
|
return
|
|
|
|
|
self.fast_http = None
|
|
|
|
|
if not self._fast_service_legacy and not self._fast_service_repair:
|
|
|
|
|
self._fast_service_version = ""
|
|
|
|
|
self.fast_btn.setEnabled(True)
|
|
|
|
|
if self._fast_service_repair:
|
|
|
|
|
self.fast_btn.setText("修复极速")
|
|
|
|
@@ -3975,10 +4148,10 @@ class MainWindow(QMainWindow):
|
|
|
|
|
self.statusBar().showMessage("正在连接设备...")
|
|
|
|
|
worker = ConnectWorker(self.factory, profile)
|
|
|
|
|
thread = self._run_worker(worker, worker.run)
|
|
|
|
|
worker.connected.connect(lambda: self.on_connected(profile, thread))
|
|
|
|
|
worker.connected.connect(lambda remote_workers: self.on_connected(profile, remote_workers, thread))
|
|
|
|
|
worker.failed.connect(lambda msg: self.on_connect_failed(msg, thread))
|
|
|
|
|
|
|
|
|
|
def on_connected(self, profile: ConnectionProfile, thread: QThread) -> None:
|
|
|
|
|
def on_connected(self, profile: ConnectionProfile, remote_workers: int, thread: QThread) -> None:
|
|
|
|
|
if self._closing:
|
|
|
|
|
self._finish_thread(thread)
|
|
|
|
|
return
|
|
|
|
@@ -3986,6 +4159,18 @@ class MainWindow(QMainWindow):
|
|
|
|
|
self.settings.setValue("port", profile.port)
|
|
|
|
|
self.settings.setValue("username", profile.username)
|
|
|
|
|
save_password_setting(self.settings, profile.password)
|
|
|
|
|
self.remote_workers = remote_workers
|
|
|
|
|
self.max_workers = adaptive_worker_count(self.local_workers, remote_workers)
|
|
|
|
|
if hasattr(self, "worker_label"):
|
|
|
|
|
self.worker_label.setText(
|
|
|
|
|
f"{self.max_workers} 线程 · {min(FAST_HTTP_WORKERS, self.max_workers)} 路并发"
|
|
|
|
|
)
|
|
|
|
|
if remote_workers > 0:
|
|
|
|
|
self.worker_label.setToolTip(
|
|
|
|
|
f"Windows 本机 {self.local_workers} 线程,远端设备 {remote_workers} 线程,自动取较小值"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
self.worker_label.setToolTip(f"Windows 本机 {self.local_workers} 线程")
|
|
|
|
|
self._connected = True
|
|
|
|
|
self._path_history = []
|
|
|
|
|
self._history_index = -1
|
|
|
|
@@ -5058,6 +5243,12 @@ def get_logical_thread_count() -> int:
|
|
|
|
|
return max(1, os.cpu_count() or 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def adaptive_worker_count(local_workers: int, remote_workers: int) -> int:
|
|
|
|
|
local = max(1, int(local_workers or 1))
|
|
|
|
|
remote = max(0, int(remote_workers or 0))
|
|
|
|
|
return min(local, remote) if remote else local
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class POINT(ctypes.Structure):
|
|
|
|
|
_fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)]
|
|
|
|
|
|
|
|
|
|