- load_settings/sanitize_settings: treat sources=[] as invalid; restore defaults (all([]) was true) - _build_pool: defensive default sources + clearer empty-fetch log; elite-only empty fetch message - FirstRun_Build_And_Install.bat: Python check + setup_and_build.ps1 - README + test for empty sources sanitize Made-with: Cursor
600 lines
26 KiB
Python
600 lines
26 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import random
|
|
import threading
|
|
import time
|
|
from collections.abc import Callable
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from typing import Any
|
|
|
|
from .config import (
|
|
Settings,
|
|
load_settings,
|
|
normalize_proxy_url,
|
|
redact_proxy_url,
|
|
save_settings,
|
|
)
|
|
from .fetcher import fetch_proxy_json, normalize_entries
|
|
from .firewall import disengage as fw_disengage, engage as fw_engage, is_admin
|
|
from .gost_util import build_gost_cmd, ensure_gost, popen_no_window, terminate_process
|
|
from .sysproxy import clear_system_proxy, set_system_proxy
|
|
from .validator import check_chain_exit_ip, get_direct_ip, validate_proxies
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
Notify = Callable[[dict[str, Any]], None]
|
|
|
|
# Thread pool for blocking I/O (proxy list fetching) so we don't stall asyncio
|
|
_FETCH_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="fetcher")
|
|
|
|
|
|
class ChainService:
|
|
"""Background rotating proxy chain using GOST."""
|
|
|
|
def __init__(self, notify: Notify) -> None:
|
|
self._notify = notify
|
|
self._stop = threading.Event()
|
|
self._force_rotate = threading.Event()
|
|
self._thread: threading.Thread | None = None
|
|
self._proc = None
|
|
self._settings = load_settings()
|
|
self._current_chain: list[str] = []
|
|
|
|
# Shuffle-and-drain pool state — never repeat a proxy within a cycle
|
|
self._available: list[str] = [] # proxies not yet used this cycle
|
|
self._used: set[str] = set() # proxies used this cycle
|
|
# Per-session blacklist: proxies that crashed GOST immediately
|
|
self._blacklist: set[str] = set()
|
|
|
|
def _manual_exit_url(self) -> str | None:
|
|
u = normalize_proxy_url(self._settings.manual_exit_proxy)
|
|
return u if u else None
|
|
|
|
@property
|
|
def settings(self) -> Settings:
|
|
return self._settings
|
|
|
|
@property
|
|
def current_chain(self) -> list[str]:
|
|
return list(self._current_chain)
|
|
|
|
def update_settings(self, s: Settings) -> None:
|
|
self._settings = s
|
|
save_settings(s)
|
|
|
|
def start(self) -> None:
|
|
if self._thread and self._thread.is_alive():
|
|
return
|
|
self._stop.clear()
|
|
self._thread = threading.Thread(
|
|
target=self._run_thread, name="ChainService", daemon=True
|
|
)
|
|
self._thread.start()
|
|
|
|
def stop(self) -> None:
|
|
self._stop.set()
|
|
terminate_process(self._proc)
|
|
self._proc = None
|
|
if self._thread:
|
|
self._thread.join(timeout=15)
|
|
self._teardown_network()
|
|
self._notify({"type": "state", "running": False})
|
|
|
|
def rotate_now(self) -> None:
|
|
self._force_rotate.set()
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
def _teardown_network(self) -> None:
|
|
clear_system_proxy()
|
|
self._notify({"type": "log", "text": "System proxy cleared."})
|
|
if is_admin() and self._settings.kill_switch_enabled:
|
|
ok, msg = fw_disengage()
|
|
self._notify({"type": "log", "text": msg})
|
|
self._notify({"type": "firewall", "engaged": False})
|
|
|
|
def _run_thread(self) -> None:
|
|
try:
|
|
asyncio.run(self._async_main())
|
|
except Exception:
|
|
log.exception("service thread failed")
|
|
self._notify({"type": "log", "text": "Fatal error in service thread (see log)."})
|
|
finally:
|
|
terminate_process(self._proc)
|
|
self._proc = None
|
|
self._teardown_network()
|
|
self._notify({"type": "state", "running": False})
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# MAIN ASYNC LOOP
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
async def _async_main(self) -> None:
|
|
self._notify({"type": "state", "running": True})
|
|
log.info(
|
|
"Service start: chain_length=%d mode=%s sources=%d pinned=%s kill_switch=%s",
|
|
self._settings.chain_length,
|
|
self._settings.obfuscation_mode,
|
|
len(self._settings.sources),
|
|
self._settings.use_pinned_chain,
|
|
self._settings.kill_switch_enabled,
|
|
)
|
|
|
|
# ── GOST setup ───────────────────────────────────────────────────────
|
|
try:
|
|
gost = ensure_gost()
|
|
self._notify({"type": "log", "text": f"GOST ready: {gost}"})
|
|
except Exception as e:
|
|
self._notify({"type": "log", "text": f"GOST setup failed: {e}"})
|
|
return
|
|
|
|
if gost.stat().st_size < 10_000:
|
|
self._notify({"type": "log", "text": "GOST appears quarantined. Re-downloading..."})
|
|
gost.unlink(missing_ok=True)
|
|
try:
|
|
gost = ensure_gost()
|
|
except Exception as e:
|
|
self._notify({"type": "log", "text": f"GOST re-download failed: {e}"})
|
|
return
|
|
|
|
# ── Real IP ──────────────────────────────────────────────────────────
|
|
real_ip = await get_direct_ip(self._settings.ip_check_url)
|
|
if real_ip:
|
|
self._notify({"type": "real_ip", "ip": real_ip})
|
|
self._notify({"type": "log", "text": f"Your real IP: {real_ip}"})
|
|
else:
|
|
self._notify({"type": "log", "text": "Could not determine real IP — leak detection disabled."})
|
|
|
|
# ── Firewall kill-switch ──────────────────────────────────────────────
|
|
if self._settings.kill_switch_enabled:
|
|
if is_admin():
|
|
ok, msg = fw_engage(gost)
|
|
self._notify({"type": "log", "text": msg})
|
|
self._notify({"type": "firewall", "engaged": ok})
|
|
else:
|
|
self._notify({"type": "log", "text": "Kill-switch skipped (not Admin)."})
|
|
self._notify({"type": "firewall", "engaged": False})
|
|
else:
|
|
self._notify({"type": "log", "text": "Kill-switch disabled in settings."})
|
|
self._notify({"type": "firewall", "engaged": False})
|
|
|
|
log.debug(
|
|
"Listener %s | refresh=%ds health=%ds max_candidates=%d concurrency=%d",
|
|
self._settings.listen_addr(),
|
|
int(self._settings.full_refresh_seconds),
|
|
int(self._settings.health_check_seconds),
|
|
int(self._settings.max_candidates),
|
|
int(self._settings.validation_concurrency),
|
|
)
|
|
|
|
# ── Main rotation loop ────────────────────────────────────────────────
|
|
full_pool: list[str] = []
|
|
last_full = 0.0
|
|
rotation_num = 0
|
|
|
|
while not self._stop.is_set():
|
|
now = time.monotonic()
|
|
need_refresh = (
|
|
not full_pool
|
|
or now - last_full >= float(self._settings.full_refresh_seconds)
|
|
)
|
|
log.debug(
|
|
"Loop tick: full_pool=%d available=%d used=%d blacklist=%d need_refresh=%s age=%.0fs",
|
|
len(full_pool),
|
|
len(self._available),
|
|
len(self._used),
|
|
len(self._blacklist),
|
|
need_refresh,
|
|
(now - last_full) if full_pool else 0.0,
|
|
)
|
|
|
|
# Pinned (manual) chain mode — skip pool management
|
|
if self._settings.use_pinned_chain and self._settings.pinned_chain:
|
|
chain = list(self._settings.pinned_chain)
|
|
rotation_num += 1
|
|
self._notify({"type": "rotation", "n": rotation_num})
|
|
log.info("Pinned chain run: %d hops", len(chain))
|
|
ok = await self._run_chain(gost, chain, real_ip)
|
|
if not ok:
|
|
await self._sleep_interruptible(10)
|
|
if self._stop.is_set():
|
|
break
|
|
continue
|
|
|
|
# Fixed exit only: hop count = 1 → chain is only the manual exit (no pool)
|
|
mex = self._manual_exit_url()
|
|
if mex and not self._settings.use_pinned_chain and self._settings.chain_length == 1:
|
|
rotation_num += 1
|
|
self._notify({"type": "rotation", "n": rotation_num})
|
|
ok = await self._run_chain(gost, [mex], real_ip)
|
|
if not ok:
|
|
await self._sleep_interruptible(15)
|
|
if self._stop.is_set():
|
|
break
|
|
continue
|
|
|
|
# ── Pool refresh ─────────────────────────────────────────────────
|
|
if need_refresh:
|
|
self._notify({"type": "phase", "phase": "fetch"})
|
|
log.info("Pool refresh: fetching %d source(s)…", len(self._settings.sources))
|
|
full_pool = await self._build_pool()
|
|
last_full = time.monotonic()
|
|
self._available = list(full_pool)
|
|
random.shuffle(self._available)
|
|
self._used.clear()
|
|
self._notify({"type": "pool", "count": len(full_pool)})
|
|
|
|
if not full_pool:
|
|
self._notify({"type": "log", "text": "Empty pool — retrying in 60s..."})
|
|
await self._sleep_interruptible(60)
|
|
last_full = 0.0
|
|
continue
|
|
|
|
# ── Pick next chain (shuffle-and-drain, no repeats per cycle) ────
|
|
chain = self._pick_chain()
|
|
if not chain:
|
|
# Pool exhausted for this cycle — reshuffle and restart
|
|
self._notify({"type": "log", "text": "Pool cycle complete — reshuffling for next round."})
|
|
self._available = list(full_pool)
|
|
random.shuffle(self._available)
|
|
self._used.clear()
|
|
chain = self._pick_chain()
|
|
if not chain:
|
|
await self._sleep_interruptible(15)
|
|
continue
|
|
|
|
rotation_num += 1
|
|
self._notify({"type": "rotation", "n": rotation_num})
|
|
log.info(
|
|
"Auto chain #%d: %d hops | obfuscation=%s | pool_remain=%d",
|
|
rotation_num,
|
|
len(chain),
|
|
self._settings.obfuscation_mode,
|
|
len(self._available),
|
|
)
|
|
await self._run_chain(gost, chain, real_ip)
|
|
|
|
if self._stop.is_set():
|
|
break
|
|
|
|
log.info("Main loop exit (stop requested or fatal).")
|
|
terminate_process(self._proc)
|
|
self._proc = None
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# CHAIN RUNNER
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
async def _run_chain(
|
|
self, gost: Any, chain: list[str], real_ip: str | None
|
|
) -> bool:
|
|
"""Start GOST with chain, verify exit IP, monitor until rotation/stop.
|
|
Returns True if chain ran successfully, False if it immediately failed."""
|
|
self._current_chain = list(chain)
|
|
self._notify({"type": "hops", "hops": chain, "status": "connecting"})
|
|
self._notify({"type": "phase", "phase": "gost_start"})
|
|
listen = self._settings.listen_addr()
|
|
cmd = build_gost_cmd(gost, listen, chain)
|
|
self._notify({
|
|
"type": "log",
|
|
"text": f"Chain #{len(self._used) // max(1, self._settings.chain_length)}: "
|
|
+ " → ".join(self._short(h) for h in chain),
|
|
})
|
|
red = " | ".join(redact_proxy_url(h) for h in chain)
|
|
log.debug("GOST listen=http://%s | forwards (redacted): %s", listen, red)
|
|
log.debug("GOST argv: %s … (%d args)", cmd[0], len(cmd))
|
|
|
|
terminate_process(self._proc)
|
|
self._proc = popen_no_window(cmd)
|
|
self._notify({"type": "log", "text": "GOST started — warming up (2s)…"})
|
|
|
|
for _ in range(4):
|
|
if self._stop.is_set():
|
|
terminate_process(self._proc)
|
|
self._proc = None
|
|
return False
|
|
await asyncio.sleep(0.5)
|
|
|
|
if self._proc.poll() is not None:
|
|
# GOST died immediately — blacklist pool proxies (never blacklist user fixed exit)
|
|
fixed = self._manual_exit_url()
|
|
for h in chain:
|
|
if fixed and h == fixed:
|
|
continue
|
|
self._blacklist.add(h)
|
|
self._available = [x for x in self._available if x != h]
|
|
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None})
|
|
self._notify({"type": "log", "text": "GOST exited immediately — proxies blacklisted."})
|
|
return False
|
|
|
|
local_proxy = f"http://{listen}"
|
|
timeout = min(30.0, self._settings.validation_timeout_seconds + 12.0)
|
|
self._notify({"type": "phase", "phase": "verify_chain"})
|
|
self._notify({
|
|
"type": "log",
|
|
"text": f"Exit IP check through local proxy (≤{int(timeout * 2 + 5)}s)…",
|
|
})
|
|
t0 = time.monotonic()
|
|
exit_ip = await check_chain_exit_ip(local_proxy, self._settings.ip_check_url, timeout)
|
|
log.debug("Initial exit IP check took %.2fs → %s", time.monotonic() - t0, exit_ip or "none")
|
|
|
|
if not exit_ip:
|
|
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None})
|
|
self._notify({"type": "log", "text": "Chain IP check failed. Rotating."})
|
|
terminate_process(self._proc)
|
|
self._proc = None
|
|
return False
|
|
|
|
if real_ip and exit_ip == real_ip:
|
|
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
|
|
self._notify({"type": "log", "text": f"Leak! Exit={exit_ip} == real IP. Rotating."})
|
|
terminate_process(self._proc)
|
|
self._proc = None
|
|
return False
|
|
|
|
self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})
|
|
self._notify({"type": "log", "text": f"✓ Chain healthy — Exit IP: {exit_ip}"})
|
|
self._notify({"type": "phase", "phase": "running"})
|
|
set_system_proxy(
|
|
self._settings.local_host,
|
|
self._settings.local_port,
|
|
self._settings.proxy_bypass,
|
|
)
|
|
self._notify({"type": "log", "text": f"System proxy → {self._settings.listen_addr()}"})
|
|
|
|
# ── Health monitor loop ───────────────────────────────────────────────
|
|
hc = int(self._settings.health_check_seconds)
|
|
while not self._stop.is_set():
|
|
log.debug("Health sleep: %ds until next exit check", hc)
|
|
result = await self._wait_health_interval()
|
|
if result in ("stop", "rotate"):
|
|
log.debug("Health loop break: %s", result)
|
|
break
|
|
|
|
if self._proc is None or self._proc.poll() is not None:
|
|
self._notify({"type": "log", "text": "GOST process died — rebuilding."})
|
|
break
|
|
|
|
self._notify({"type": "log", "text": "Health check..."})
|
|
t1 = time.monotonic()
|
|
exit_ip = await check_chain_exit_ip(local_proxy, self._settings.ip_check_url, timeout)
|
|
log.debug("Periodic exit IP check %.2fs → %s", time.monotonic() - t1, exit_ip or "none")
|
|
|
|
if not exit_ip or (real_ip and exit_ip == real_ip):
|
|
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
|
|
self._notify({"type": "log", "text": "Health check failed — rotating."})
|
|
break
|
|
|
|
self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})
|
|
self._notify({"type": "log", "text": f"✓ Still healthy — Exit IP: {exit_ip}"})
|
|
|
|
terminate_process(self._proc)
|
|
self._proc = None
|
|
return True
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# POOL MANAGEMENT
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
@staticmethod
|
|
def _apply_mode_filter(candidates: list[str], mode: str) -> list[str]:
|
|
if mode == "http_only":
|
|
return [u for u in candidates if u.startswith("http://")]
|
|
if mode == "socks5_only":
|
|
return [u for u in candidates if u.startswith("socks5://")]
|
|
if mode == "random_mix":
|
|
out = list(candidates)
|
|
random.shuffle(out)
|
|
return out
|
|
return list(candidates)
|
|
|
|
def _pick_chain(self) -> list[str]:
|
|
"""Pick chain_length hops: optional fixed last hop + random prefix from pool."""
|
|
s = self._settings
|
|
chain = self._pick_chain_for_mode(s.obfuscation_mode)
|
|
if chain:
|
|
log.debug(
|
|
"Picked chain len=%d mode=%s available_left=%d",
|
|
len(chain),
|
|
s.obfuscation_mode,
|
|
len(self._available),
|
|
)
|
|
return chain
|
|
if s.obfuscation_mode != "auto" and not s.use_pinned_chain:
|
|
log.debug("Pick empty under mode=%s — retrying as auto", s.obfuscation_mode)
|
|
self._notify({
|
|
"type": "log",
|
|
"text": "Obfuscation filter left nothing usable — retrying this pick with ALL protocols (auto).",
|
|
})
|
|
ch2 = self._pick_chain_for_mode("auto")
|
|
if ch2:
|
|
log.debug("Auto retry picked len=%d", len(ch2))
|
|
return ch2
|
|
log.debug("Pick chain returned empty (pool exhausted?)")
|
|
return []
|
|
|
|
def _pick_chain_for_mode(self, mode: str) -> list[str]:
|
|
"""Build one chain using given mode (http_only / socks5_only / random_mix / auto)."""
|
|
s = self._settings
|
|
k = max(1, s.chain_length)
|
|
manual = self._manual_exit_url()
|
|
if manual and not s.use_pinned_chain:
|
|
mid_need = k - 1
|
|
base = [
|
|
u for u in self._available
|
|
if u not in self._blacklist and u != manual
|
|
]
|
|
candidates = self._apply_mode_filter(base, mode)
|
|
|
|
if mid_need == 0:
|
|
return [manual]
|
|
if len(candidates) < mid_need:
|
|
return []
|
|
|
|
prefix = candidates[:mid_need]
|
|
pset = set(prefix)
|
|
self._available = [u for u in self._available if u not in pset]
|
|
self._used.update(prefix)
|
|
self._used.add(manual)
|
|
return prefix + [manual]
|
|
|
|
base = [u for u in self._available if u not in self._blacklist]
|
|
candidates = self._apply_mode_filter(base, mode)
|
|
|
|
if len(candidates) < k:
|
|
return []
|
|
|
|
picked = candidates[:k]
|
|
picked_set = set(picked)
|
|
self._available = [u for u in self._available if u not in picked_set]
|
|
self._used.update(picked)
|
|
return picked
|
|
|
|
async def _build_pool(self) -> list[str]:
|
|
"""Fetch and validate proxies. Runs blocking I/O in thread pool."""
|
|
s = self._settings
|
|
loop = asyncio.get_running_loop()
|
|
log.info("_build_pool: %d source URL(s), prefer_elite=%s", len(s.sources), s.prefer_elite)
|
|
|
|
# Fetch all sources concurrently in thread pool (they are blocking)
|
|
async def _fetch_one(url: str) -> list[str]:
|
|
try:
|
|
rows = await asyncio.wait_for(
|
|
loop.run_in_executor(
|
|
_FETCH_POOL,
|
|
lambda u=url: fetch_proxy_json(u, timeout=50.0),
|
|
),
|
|
timeout=55.0,
|
|
)
|
|
entries = normalize_entries(rows, s.prefer_elite)
|
|
if s.prefer_elite and rows and not entries:
|
|
self._notify({
|
|
"type": "log",
|
|
"text": (
|
|
f"Source returned {len(rows)} proxies but none marked “elite” — all skipped. "
|
|
"Turn off “Elite proxies only” in Chain Builder if every fetch is empty."
|
|
),
|
|
})
|
|
self._notify({"type": "log", "text": f"Fetched {len(entries)} from source."})
|
|
return entries
|
|
except asyncio.TimeoutError:
|
|
self._notify({"type": "log", "text": f"Fetch timed out (55s): {url[:60]}..."})
|
|
return []
|
|
except Exception as e:
|
|
self._notify({"type": "log", "text": f"Fetch error: {e!s}"})
|
|
return []
|
|
|
|
src_urls = list(s.sources) if s.sources else list(Settings().sources)
|
|
if not s.sources:
|
|
self._notify({
|
|
"type": "log",
|
|
"text": "Settings had no proxy sources — using built-in defaults (save Settings to persist).",
|
|
})
|
|
if not src_urls:
|
|
self._notify({"type": "log", "text": "No proxy source URLs configured — cannot build a pool."})
|
|
return []
|
|
|
|
results = await asyncio.gather(*(_fetch_one(u) for u in src_urls))
|
|
raw_urls: list[str] = []
|
|
for chunk in results:
|
|
raw_urls.extend(chunk)
|
|
|
|
if not raw_urls:
|
|
self._notify({
|
|
"type": "log",
|
|
"text": "No proxies fetched from any source (check network, URLs, or turn off “Elite only” if every list is filtered empty).",
|
|
})
|
|
return []
|
|
|
|
# Deduplicate, remove blacklisted, shuffle before capping
|
|
seen: set[str] = set()
|
|
unique: list[str] = []
|
|
for u in raw_urls:
|
|
if u not in seen and u not in self._blacklist:
|
|
seen.add(u)
|
|
unique.append(u)
|
|
|
|
random.shuffle(unique)
|
|
if len(unique) > s.max_candidates:
|
|
unique = unique[: s.max_candidates]
|
|
log.debug("Unique candidates after dedupe/cap: %d (max_candidates=%d)", len(unique), s.max_candidates)
|
|
|
|
self._notify({"type": "phase", "phase": "validate"})
|
|
self._notify({"type": "log", "text": f"Validating {len(unique)} candidates..."})
|
|
|
|
def on_prog(done: int, total: int) -> None:
|
|
self._notify({"type": "validate_progress", "done": done, "total": total})
|
|
|
|
good = await validate_proxies(
|
|
unique,
|
|
s.ip_check_url,
|
|
s.validation_concurrency,
|
|
s.validation_timeout_seconds,
|
|
on_progress=on_prog,
|
|
)
|
|
random.shuffle(good)
|
|
|
|
mex = normalize_proxy_url(s.manual_exit_proxy)
|
|
if mex and not s.use_pinned_chain:
|
|
self._notify({"type": "phase", "phase": "exit_check"})
|
|
self._notify({"type": "log", "text": "Validating fixed exit proxy…"})
|
|
v = await validate_proxies(
|
|
[mex],
|
|
s.ip_check_url,
|
|
1,
|
|
s.validation_timeout_seconds,
|
|
on_progress=None,
|
|
)
|
|
if v:
|
|
self._notify({"type": "log", "text": "Fixed exit proxy: OK (reachable)."})
|
|
else:
|
|
self._notify({
|
|
"type": "log",
|
|
"text": "Fixed exit proxy: validation failed — will still attempt; check URL/credentials.",
|
|
})
|
|
|
|
self._notify({"type": "log", "text": f"Valid proxies: {len(good)} / {len(unique)}"})
|
|
log.info("_build_pool done: valid=%d / tested=%d", len(good), len(unique))
|
|
return good
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# UTILITIES
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
async def _wait_health_interval(self) -> str | None:
|
|
"""Sleep for health_check_seconds. Returns 'stop', 'rotate', or None."""
|
|
total = float(self._settings.health_check_seconds)
|
|
end = time.monotonic() + total
|
|
while time.monotonic() < end:
|
|
if self._stop.is_set():
|
|
return "stop"
|
|
if self._force_rotate.is_set():
|
|
self._force_rotate.clear()
|
|
self._notify({"type": "log", "text": "Manual rotate triggered."})
|
|
return "rotate"
|
|
remaining = end - time.monotonic()
|
|
self._notify({"type": "countdown", "secs": max(0, int(remaining))})
|
|
await asyncio.sleep(1.0)
|
|
self._notify({"type": "countdown", "secs": 0})
|
|
return None
|
|
|
|
async def _sleep_interruptible(self, seconds: float) -> None:
|
|
end = time.monotonic() + seconds
|
|
while time.monotonic() < end:
|
|
if self._stop.is_set():
|
|
return
|
|
await asyncio.sleep(0.2)
|
|
|
|
@staticmethod
|
|
def _short(url: str) -> str:
|
|
url = redact_proxy_url(url)
|
|
url = (
|
|
url.replace("http://", "")
|
|
.replace("socks5://", "s5://")
|
|
.replace("socks4://", "s4://")
|
|
.replace("https://", "")
|
|
)
|
|
return url[:30] + "…" if len(url) > 32 else url
|