Added download settings and optimized the downloader system with concurrency.
This commit is contained in:
@@ -23,6 +23,7 @@ from ptdiag.logs import (
|
|||||||
from . import __version__
|
from . import __version__
|
||||||
from .command import CommandError, CommandOutcome, CommandRunner
|
from .command import CommandError, CommandOutcome, CommandRunner
|
||||||
from .depot import (
|
from .depot import (
|
||||||
|
DownloadOptions,
|
||||||
SyncProgress,
|
SyncProgress,
|
||||||
read_cached_depot_launch_catalog,
|
read_cached_depot_launch_catalog,
|
||||||
select_depot_start_option,
|
select_depot_start_option,
|
||||||
@@ -561,6 +562,7 @@ def _native_update(
|
|||||||
"""Use the native verified depot client for normal installs and updates."""
|
"""Use the native verified depot client for normal installs and updates."""
|
||||||
|
|
||||||
settings = _load_or_default_settings(paths, persist=not dry_run)
|
settings = _load_or_default_settings(paths, persist=not dry_run)
|
||||||
|
download_options = _configured_download_options(settings)
|
||||||
runner = _runner(paths, "native-update", dry_run)
|
runner = _runner(paths, "native-update", dry_run)
|
||||||
with _operation_guard(paths, "native-update", dry_run):
|
with _operation_guard(paths, "native-update", dry_run):
|
||||||
_print_operation_status(as_json, 1, 3, "Checking local game files…")
|
_print_operation_status(as_json, 1, 3, "Checking local game files…")
|
||||||
@@ -581,6 +583,11 @@ def _native_update(
|
|||||||
quarantine_unmanaged=verify_import,
|
quarantine_unmanaged=verify_import,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
progress=None if as_json else _print_sync_progress,
|
progress=None if as_json else _print_sync_progress,
|
||||||
|
**(
|
||||||
|
{"download_options": download_options}
|
||||||
|
if download_options is not None
|
||||||
|
else {}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if verify_import and not dry_run:
|
if verify_import and not dry_run:
|
||||||
mark_game_import_verified(
|
mark_game_import_verified(
|
||||||
@@ -620,6 +627,7 @@ def _wine_update(
|
|||||||
raise LauncherError("Wine, Wineboot, and Winetricks are required; run doctor")
|
raise LauncherError("Wine, Wineboot, and Winetricks are required; run doctor")
|
||||||
runner = _runner(paths, "update", dry_run)
|
runner = _runner(paths, "update", dry_run)
|
||||||
settings = _load_or_default_settings(paths, persist=not dry_run)
|
settings = _load_or_default_settings(paths, persist=not dry_run)
|
||||||
|
download_options = _configured_download_options(settings)
|
||||||
with _operation_guard(paths, "update", dry_run):
|
with _operation_guard(paths, "update", dry_run):
|
||||||
configure_launcher_prefix(
|
configure_launcher_prefix(
|
||||||
paths,
|
paths,
|
||||||
@@ -647,6 +655,11 @@ def _wine_update(
|
|||||||
repair=True,
|
repair=True,
|
||||||
quarantine_unmanaged=True,
|
quarantine_unmanaged=True,
|
||||||
progress=None if as_json else _print_sync_progress,
|
progress=None if as_json else _print_sync_progress,
|
||||||
|
**(
|
||||||
|
{"download_options": download_options}
|
||||||
|
if download_options is not None
|
||||||
|
else {}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
mark_game_import_verified(
|
mark_game_import_verified(
|
||||||
paths,
|
paths,
|
||||||
@@ -677,6 +690,20 @@ def _print_sync_progress(progress: SyncProgress) -> None:
|
|||||||
print(f"PROGRESS {json.dumps(asdict(progress), sort_keys=True)}", flush=True)
|
print(f"PROGRESS {json.dumps(asdict(progress), sort_keys=True)}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_download_options(settings: Settings) -> DownloadOptions | None:
|
||||||
|
if (
|
||||||
|
settings.download_speed_limit_bytes_per_second is None
|
||||||
|
and not settings.low_bandwidth_mode
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return DownloadOptions(
|
||||||
|
bandwidth_limit_bytes_per_second=(
|
||||||
|
settings.download_speed_limit_bytes_per_second
|
||||||
|
),
|
||||||
|
low_bandwidth=settings.low_bandwidth_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _print_operation_status(
|
def _print_operation_status(
|
||||||
as_json: bool,
|
as_json: bool,
|
||||||
step: int,
|
step: int,
|
||||||
|
|||||||
591
ptlinux/depot.py
591
ptlinux/depot.py
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from base64 import b64decode
|
from base64 import b64decode
|
||||||
from binascii import Error as BinAsciiError
|
from binascii import Error as BinAsciiError
|
||||||
|
from collections import deque
|
||||||
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -35,8 +36,15 @@ FALLBACK_SERVERS = (
|
|||||||
READ_SIZE = 128 * 1024
|
READ_SIZE = 128 * 1024
|
||||||
HASH_READ_SIZE = 1024 * 1024
|
HASH_READ_SIZE = 1024 * 1024
|
||||||
DOWNLOAD_WORKERS = 8
|
DOWNLOAD_WORKERS = 8
|
||||||
|
DOWNLOAD_INITIAL_WORKERS = DOWNLOAD_WORKERS
|
||||||
|
LOW_BANDWIDTH_WORKERS = 2
|
||||||
|
LOW_BANDWIDTH_BYTES_PER_SECOND = 5_000_000
|
||||||
DOWNLOAD_RETRY_DELAYS_SECONDS = (1.0, 2.0, 4.0, 8.0, 16.0)
|
DOWNLOAD_RETRY_DELAYS_SECONDS = (1.0, 2.0, 4.0, 8.0, 16.0)
|
||||||
PROGRESS_UPDATE_INTERVAL_SECONDS = 0.075
|
PROGRESS_UPDATE_INTERVAL_SECONDS = 0.075
|
||||||
|
ADAPTIVE_EVALUATION_INTERVAL_SECONDS = 1.0
|
||||||
|
ADAPTIVE_RETRY_WINDOW_SECONDS = 15.0
|
||||||
|
ADAPTIVE_RETRY_RECOVERY_SECONDS = 5.0
|
||||||
|
TRANSFER_RATE_WINDOW_SECONDS = 5.0
|
||||||
MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024
|
MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024
|
||||||
MAX_LINE_ENDING_RECOVERY_BYTES = 1024 * 1024
|
MAX_LINE_ENDING_RECOVERY_BYTES = 1024 * 1024
|
||||||
UNMANAGED_EXECUTABLE_SUFFIXES = frozenset(
|
UNMANAGED_EXECUTABLE_SUFFIXES = frozenset(
|
||||||
@@ -88,6 +96,275 @@ class _RetryableDownloadError(DepotError):
|
|||||||
self.discard_partial = discard_partial
|
self.discard_partial = discard_partial
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DownloadOptions:
|
||||||
|
"""User-selected limits around the adaptive depot scheduler."""
|
||||||
|
|
||||||
|
bandwidth_limit_bytes_per_second: int | None = None
|
||||||
|
low_bandwidth: bool = False
|
||||||
|
minimum_workers: int = 1
|
||||||
|
maximum_workers: int | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not isinstance(self.low_bandwidth, bool):
|
||||||
|
raise ValueError("low_bandwidth must be a boolean")
|
||||||
|
if (
|
||||||
|
self.bandwidth_limit_bytes_per_second is not None
|
||||||
|
and (
|
||||||
|
isinstance(self.bandwidth_limit_bytes_per_second, bool)
|
||||||
|
or self.bandwidth_limit_bytes_per_second <= 0
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError("bandwidth limit must be positive or None")
|
||||||
|
if isinstance(self.minimum_workers, bool) or self.minimum_workers <= 0:
|
||||||
|
raise ValueError("minimum download workers must be positive")
|
||||||
|
if self.maximum_workers is not None and (
|
||||||
|
isinstance(self.maximum_workers, bool) or self.maximum_workers <= 0
|
||||||
|
):
|
||||||
|
raise ValueError("maximum download workers must be positive or None")
|
||||||
|
if (
|
||||||
|
self.maximum_workers is not None
|
||||||
|
and self.minimum_workers > self.maximum_workers
|
||||||
|
):
|
||||||
|
raise ValueError("minimum download workers exceed the maximum")
|
||||||
|
if self.low_bandwidth and self.minimum_workers > LOW_BANDWIDTH_WORKERS:
|
||||||
|
raise ValueError("low-bandwidth minimum workers exceed its worker cap")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_bandwidth_limit(self) -> int | None:
|
||||||
|
configured = self.bandwidth_limit_bytes_per_second
|
||||||
|
if not self.low_bandwidth:
|
||||||
|
return configured
|
||||||
|
return min(
|
||||||
|
configured if configured is not None else LOW_BANDWIDTH_BYTES_PER_SECOND,
|
||||||
|
LOW_BANDWIDTH_BYTES_PER_SECOND,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_maximum_workers(self) -> int:
|
||||||
|
configured = self.maximum_workers or DOWNLOAD_WORKERS
|
||||||
|
if self.low_bandwidth:
|
||||||
|
configured = min(configured, LOW_BANDWIDTH_WORKERS)
|
||||||
|
return max(self.minimum_workers, configured)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DownloadTelemetry:
|
||||||
|
"""One internal measurement produced while retrieving a depot file."""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
file: str
|
||||||
|
server: str
|
||||||
|
attempt: int
|
||||||
|
latency_seconds: float | None = None
|
||||||
|
bytes_written: int = 0
|
||||||
|
write_seconds: float = 0.0
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
DownloadObserver = Callable[[DownloadTelemetry], None]
|
||||||
|
|
||||||
|
|
||||||
|
class BandwidthLimiter:
|
||||||
|
"""Thread-safe aggregate byte-rate limiter with a one-chunk burst allowance."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
bytes_per_second: int,
|
||||||
|
*,
|
||||||
|
clock: Callable[[], float] = time.monotonic,
|
||||||
|
sleeper: Callable[[float], None] = time.sleep,
|
||||||
|
) -> None:
|
||||||
|
if isinstance(bytes_per_second, bool) or bytes_per_second <= 0:
|
||||||
|
raise ValueError("bandwidth limit must be positive")
|
||||||
|
self.bytes_per_second = bytes_per_second
|
||||||
|
self._clock = clock
|
||||||
|
self._sleeper = sleeper
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._next_available_at = clock()
|
||||||
|
|
||||||
|
def consume(self, byte_count: int) -> None:
|
||||||
|
"""Wait until this newly received chunk's aggregate reservation begins.
|
||||||
|
|
||||||
|
The network read happens before this call. Reserving the chunk's *start*
|
||||||
|
keeps all workers under one aggregate rate without charging again for time
|
||||||
|
already spent receiving it. The first chunk is the bucket's burst allowance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if byte_count < 0:
|
||||||
|
raise ValueError("byte count must not be negative")
|
||||||
|
if byte_count == 0:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
now = self._clock()
|
||||||
|
start = max(now, self._next_available_at)
|
||||||
|
self._next_available_at = start + (byte_count / self.bytes_per_second)
|
||||||
|
delay = start - now
|
||||||
|
if delay > 0:
|
||||||
|
self._sleeper(delay)
|
||||||
|
|
||||||
|
|
||||||
|
class AdaptiveConcurrencyController:
|
||||||
|
"""Select a bounded worker target from live network, host, and plan data."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
options: DownloadOptions,
|
||||||
|
*,
|
||||||
|
clock: Callable[[], float] = time.monotonic,
|
||||||
|
cpu_load: Callable[[], float | None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.options = options
|
||||||
|
self._clock = clock
|
||||||
|
self._cpu_load = cpu_load or _normalized_cpu_load
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._target = max(
|
||||||
|
options.minimum_workers,
|
||||||
|
min(DOWNLOAD_INITIAL_WORKERS, options.effective_maximum_workers),
|
||||||
|
)
|
||||||
|
self._last_evaluation = clock()
|
||||||
|
self._transferred_bytes = 0
|
||||||
|
self._transfer_samples: deque[tuple[float, int]] = deque(
|
||||||
|
[(self._last_evaluation, 0)]
|
||||||
|
)
|
||||||
|
self._response_samples: deque[tuple[float, float]] = deque()
|
||||||
|
self._retry_samples: deque[float] = deque()
|
||||||
|
self._retry_count = 0
|
||||||
|
self._handled_retry_count = 0
|
||||||
|
self._last_retry_at: float | None = None
|
||||||
|
self._latency_seconds: float | None = None
|
||||||
|
self._disk_samples: deque[tuple[float, int, float]] = deque()
|
||||||
|
|
||||||
|
def record_response(self, latency_seconds: float) -> None:
|
||||||
|
if latency_seconds < 0:
|
||||||
|
return
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
self._response_samples.append((now, latency_seconds))
|
||||||
|
self._prune_health_samples(now)
|
||||||
|
if self._latency_seconds is None:
|
||||||
|
self._latency_seconds = latency_seconds
|
||||||
|
else:
|
||||||
|
self._latency_seconds = (
|
||||||
|
(self._latency_seconds * 0.75) + (latency_seconds * 0.25)
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_retry(self) -> None:
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
self._retry_count += 1
|
||||||
|
self._retry_samples.append(now)
|
||||||
|
self._last_retry_at = now
|
||||||
|
self._prune_health_samples(now)
|
||||||
|
|
||||||
|
def record_chunk(self, byte_count: int, write_seconds: float) -> None:
|
||||||
|
if byte_count <= 0:
|
||||||
|
return
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
self._transferred_bytes += byte_count
|
||||||
|
self._transfer_samples.append((now, self._transferred_bytes))
|
||||||
|
_prune_transfer_samples(self._transfer_samples, now)
|
||||||
|
self._disk_samples.append((now, byte_count, max(0.0, write_seconds)))
|
||||||
|
self._prune_disk_samples(now)
|
||||||
|
|
||||||
|
def _prune_health_samples(self, now: float) -> None:
|
||||||
|
cutoff = now - ADAPTIVE_RETRY_WINDOW_SECONDS
|
||||||
|
while self._response_samples and self._response_samples[0][0] < cutoff:
|
||||||
|
self._response_samples.popleft()
|
||||||
|
while self._retry_samples and self._retry_samples[0] < cutoff:
|
||||||
|
self._retry_samples.popleft()
|
||||||
|
|
||||||
|
def _prune_disk_samples(self, now: float) -> None:
|
||||||
|
cutoff = now - TRANSFER_RATE_WINDOW_SECONDS
|
||||||
|
while self._disk_samples and self._disk_samples[0][0] < cutoff:
|
||||||
|
self._disk_samples.popleft()
|
||||||
|
|
||||||
|
def target_workers(self, remaining_files: int, remaining_bytes: int) -> int:
|
||||||
|
"""Return the next target; active workers are allowed to drain naturally."""
|
||||||
|
|
||||||
|
if remaining_files <= 0:
|
||||||
|
return 0
|
||||||
|
maximum = min(self.options.effective_maximum_workers, remaining_files)
|
||||||
|
minimum = min(self.options.minimum_workers, maximum)
|
||||||
|
with self._lock:
|
||||||
|
self._target = min(maximum, max(minimum, self._target))
|
||||||
|
now = self._clock()
|
||||||
|
average_file_size = remaining_bytes / max(1, remaining_files)
|
||||||
|
if now - self._last_evaluation < ADAPTIVE_EVALUATION_INTERVAL_SECONDS:
|
||||||
|
return self._target
|
||||||
|
self._last_evaluation = now
|
||||||
|
_prune_transfer_samples(self._transfer_samples, now)
|
||||||
|
self._prune_health_samples(now)
|
||||||
|
self._prune_disk_samples(now)
|
||||||
|
rate = _sample_rate(self._transfer_samples)
|
||||||
|
attempts = len(self._response_samples) + len(self._retry_samples)
|
||||||
|
error_rate = len(self._retry_samples) / attempts if attempts else 0.0
|
||||||
|
latency = self._latency_seconds
|
||||||
|
disk_bytes = sum(sample[1] for sample in self._disk_samples)
|
||||||
|
disk_seconds = sum(sample[2] for sample in self._disk_samples)
|
||||||
|
disk_rate = (
|
||||||
|
disk_bytes / disk_seconds
|
||||||
|
if disk_seconds > 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
cpu_load = self._cpu_load()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
cpu_load = None
|
||||||
|
|
||||||
|
new_retries = self._retry_count - self._handled_retry_count
|
||||||
|
if new_retries:
|
||||||
|
self._handled_retry_count = self._retry_count
|
||||||
|
if error_rate >= 0.12 and self._target > minimum:
|
||||||
|
backoff = min(2, new_retries)
|
||||||
|
self._target = max(minimum, self._target - backoff)
|
||||||
|
return self._target
|
||||||
|
|
||||||
|
resource_constrained = (
|
||||||
|
(cpu_load is not None and cpu_load >= 0.90)
|
||||||
|
or (
|
||||||
|
rate is not None
|
||||||
|
and disk_rate is not None
|
||||||
|
and disk_rate < rate * 0.75
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if resource_constrained and self._target > minimum:
|
||||||
|
self._target -= 1
|
||||||
|
return self._target
|
||||||
|
|
||||||
|
limit = self.options.effective_bandwidth_limit
|
||||||
|
limit_saturated = (
|
||||||
|
limit is not None and rate is not None and rate >= limit * 0.90
|
||||||
|
)
|
||||||
|
healthy = (
|
||||||
|
(cpu_load is None or cpu_load < 0.80)
|
||||||
|
and (
|
||||||
|
rate is None
|
||||||
|
or disk_rate is None
|
||||||
|
or disk_rate >= rate
|
||||||
|
)
|
||||||
|
)
|
||||||
|
retry_recovery_pending = (
|
||||||
|
self._last_retry_at is not None
|
||||||
|
and now - self._last_retry_at < ADAPTIVE_RETRY_RECOVERY_SECONDS
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
healthy
|
||||||
|
and not retry_recovery_pending
|
||||||
|
and not limit_saturated
|
||||||
|
and self._target < maximum
|
||||||
|
):
|
||||||
|
# Parallel requests hide CDN round-trip time and are especially useful
|
||||||
|
# for this depot's large population of small files.
|
||||||
|
step = 2 if (
|
||||||
|
(latency is not None and latency >= 0.75)
|
||||||
|
or average_file_size <= 512 * 1024
|
||||||
|
) else 1
|
||||||
|
self._target = min(maximum, self._target + step)
|
||||||
|
return min(maximum, max(minimum, self._target))
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DepotFile:
|
class DepotFile:
|
||||||
"""One exact file in the published depot build."""
|
"""One exact file in the published depot build."""
|
||||||
@@ -258,6 +535,14 @@ class SyncProgress:
|
|||||||
bytes_total: int
|
bytes_total: int
|
||||||
current_file: str | None
|
current_file: str | None
|
||||||
bytes_transferred: int = 0
|
bytes_transferred: int = 0
|
||||||
|
bytes_remaining: int = 0
|
||||||
|
transfer_rate_bytes_per_second: float | None = None
|
||||||
|
eta_seconds: float | None = None
|
||||||
|
current_server: str | None = None
|
||||||
|
retry_count: int = 0
|
||||||
|
retry_reason: str | None = None
|
||||||
|
active_workers: int = 0
|
||||||
|
target_workers: int = 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -315,6 +600,7 @@ class DepotClient:
|
|||||||
opener: HttpOpener = urlopen,
|
opener: HttpOpener = urlopen,
|
||||||
retry_delays: tuple[float, ...] = DOWNLOAD_RETRY_DELAYS_SECONDS,
|
retry_delays: tuple[float, ...] = DOWNLOAD_RETRY_DELAYS_SECONDS,
|
||||||
sleeper: Callable[[float], None] = time.sleep,
|
sleeper: Callable[[float], None] = time.sleep,
|
||||||
|
download_observer: DownloadObserver | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if timeout_seconds <= 0:
|
if timeout_seconds <= 0:
|
||||||
raise ValueError("timeout_seconds must be positive")
|
raise ValueError("timeout_seconds must be positive")
|
||||||
@@ -325,6 +611,24 @@ class DepotClient:
|
|||||||
self._retry_delays = retry_delays
|
self._retry_delays = retry_delays
|
||||||
self._sleeper = sleeper
|
self._sleeper = sleeper
|
||||||
self._candidate_servers: tuple[str, ...] = ()
|
self._candidate_servers: tuple[str, ...] = ()
|
||||||
|
self._download_observer = download_observer
|
||||||
|
self._bandwidth_limiter: BandwidthLimiter | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def download_observer(self) -> DownloadObserver | None:
|
||||||
|
return self._download_observer
|
||||||
|
|
||||||
|
@download_observer.setter
|
||||||
|
def download_observer(self, observer: DownloadObserver | None) -> None:
|
||||||
|
self._download_observer = observer
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bandwidth_limiter(self) -> BandwidthLimiter | None:
|
||||||
|
return self._bandwidth_limiter
|
||||||
|
|
||||||
|
@bandwidth_limiter.setter
|
||||||
|
def bandwidth_limiter(self, limiter: BandwidthLimiter | None) -> None:
|
||||||
|
self._bandwidth_limiter = limiter
|
||||||
|
|
||||||
def fetch_snapshot(self, *, preferred_region: str = "Europe") -> DepotSnapshot:
|
def fetch_snapshot(self, *, preferred_region: str = "Europe") -> DepotSnapshot:
|
||||||
"""Fetch the live depot and build manifests, trying trusted regional fallbacks."""
|
"""Fetch the live depot and build manifests, trying trusted regional fallbacks."""
|
||||||
@@ -379,11 +683,21 @@ class DepotClient:
|
|||||||
attempt_server,
|
attempt_server,
|
||||||
file,
|
file,
|
||||||
destination,
|
destination,
|
||||||
|
attempt=attempt_index + 1,
|
||||||
progress=progress,
|
progress=progress,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
except _RetryableDownloadError as exc:
|
except _RetryableDownloadError as exc:
|
||||||
last_error = exc
|
last_error = exc
|
||||||
|
self._notify_download(
|
||||||
|
DownloadTelemetry(
|
||||||
|
kind="retry",
|
||||||
|
file=file.path,
|
||||||
|
server=attempt_server,
|
||||||
|
attempt=attempt_index + 1,
|
||||||
|
reason=str(exc),
|
||||||
|
)
|
||||||
|
)
|
||||||
if exc.discard_partial:
|
if exc.discard_partial:
|
||||||
temporary.unlink(missing_ok=True)
|
temporary.unlink(missing_ok=True)
|
||||||
if attempt_index >= len(self._retry_delays):
|
if attempt_index >= len(self._retry_delays):
|
||||||
@@ -407,6 +721,7 @@ class DepotClient:
|
|||||||
file: DepotFile,
|
file: DepotFile,
|
||||||
destination: Path,
|
destination: Path,
|
||||||
*,
|
*,
|
||||||
|
attempt: int,
|
||||||
progress: Callable[[int], None] | None,
|
progress: Callable[[int], None] | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
temporary = _download_partial_path(destination)
|
temporary = _download_partial_path(destination)
|
||||||
@@ -455,6 +770,15 @@ class DepotClient:
|
|||||||
headers["Range"] = f"bytes={requested_offset}-"
|
headers["Range"] = f"bytes={requested_offset}-"
|
||||||
url = _file_url(server, file)
|
url = _file_url(server, file)
|
||||||
request = Request(url, headers=headers, method="GET")
|
request = Request(url, headers=headers, method="GET")
|
||||||
|
self._notify_download(
|
||||||
|
DownloadTelemetry(
|
||||||
|
kind="request",
|
||||||
|
file=file.path,
|
||||||
|
server=server,
|
||||||
|
attempt=attempt,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
request_started_at = time.monotonic()
|
||||||
try:
|
try:
|
||||||
response = self._opener(request, timeout=self.timeout_seconds)
|
response = self._opener(request, timeout=self.timeout_seconds)
|
||||||
except HTTPError as exc:
|
except HTTPError as exc:
|
||||||
@@ -470,6 +794,15 @@ class DepotClient:
|
|||||||
raise _RetryableDownloadError(
|
raise _RetryableDownloadError(
|
||||||
f"could not connect to {server} for {file.path}: {exc}"
|
f"could not connect to {server} for {file.path}: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
self._notify_download(
|
||||||
|
DownloadTelemetry(
|
||||||
|
kind="response",
|
||||||
|
file=file.path,
|
||||||
|
server=server,
|
||||||
|
attempt=attempt,
|
||||||
|
latency_seconds=max(0.0, time.monotonic() - request_started_at),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
append = False
|
append = False
|
||||||
@@ -528,8 +861,24 @@ class DepotClient:
|
|||||||
f"download exceeds expected size for {file.path}",
|
f"download exceeds expected size for {file.path}",
|
||||||
discard_partial=True,
|
discard_partial=True,
|
||||||
)
|
)
|
||||||
|
if self._bandwidth_limiter is not None:
|
||||||
|
self._bandwidth_limiter.consume(len(chunk))
|
||||||
digest.update(chunk)
|
digest.update(chunk)
|
||||||
|
write_started_at = time.monotonic()
|
||||||
handle.write(chunk)
|
handle.write(chunk)
|
||||||
|
self._notify_download(
|
||||||
|
DownloadTelemetry(
|
||||||
|
kind="chunk",
|
||||||
|
file=file.path,
|
||||||
|
server=server,
|
||||||
|
attempt=attempt,
|
||||||
|
bytes_written=len(chunk),
|
||||||
|
write_seconds=max(
|
||||||
|
0.0,
|
||||||
|
time.monotonic() - write_started_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
if progress is not None:
|
if progress is not None:
|
||||||
progress(written)
|
progress(written)
|
||||||
except _RetryableDownloadError:
|
except _RetryableDownloadError:
|
||||||
@@ -559,6 +908,16 @@ class DepotClient:
|
|||||||
except (OSError, HTTPException):
|
except (OSError, HTTPException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _notify_download(self, event: DownloadTelemetry) -> None:
|
||||||
|
observer = self._download_observer
|
||||||
|
if observer is not None:
|
||||||
|
# noinspection PyBroadException
|
||||||
|
try:
|
||||||
|
observer(event)
|
||||||
|
except Exception:
|
||||||
|
# Telemetry must never weaken verified download behavior.
|
||||||
|
pass
|
||||||
|
|
||||||
def _get_json(self, url: str) -> Mapping[str, object]:
|
def _get_json(self, url: str) -> Mapping[str, object]:
|
||||||
request = Request(url, headers=_headers(), method="GET")
|
request = Request(url, headers=_headers(), method="GET")
|
||||||
try:
|
try:
|
||||||
@@ -642,6 +1001,7 @@ def sync_managed_game(
|
|||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
progress: ProgressCallback | None = None,
|
progress: ProgressCallback | None = None,
|
||||||
client: DepotClient | None = None,
|
client: DepotClient | None = None,
|
||||||
|
download_options: DownloadOptions | None = None,
|
||||||
) -> DepotSyncResult:
|
) -> DepotSyncResult:
|
||||||
"""Synchronize the managed game directory using only official public data."""
|
"""Synchronize the managed game directory using only official public data."""
|
||||||
|
|
||||||
@@ -676,7 +1036,14 @@ def sync_managed_game(
|
|||||||
)
|
)
|
||||||
|
|
||||||
root = _prepare_game_root(paths.source_game, create=True)
|
root = _prepare_game_root(paths.source_game, create=True)
|
||||||
_download_plan(depot_client, snapshot, root, plan, progress)
|
_download_plan(
|
||||||
|
depot_client,
|
||||||
|
snapshot,
|
||||||
|
root,
|
||||||
|
plan,
|
||||||
|
progress,
|
||||||
|
download_options or DownloadOptions(),
|
||||||
|
)
|
||||||
deleted = _apply_deletions(root, plan.deletes)
|
deleted = _apply_deletions(root, plan.deletes)
|
||||||
created = _apply_create_if_missing(root, snapshot.create_if_missing)
|
created = _apply_create_if_missing(root, snapshot.create_if_missing)
|
||||||
result = _sync_result(
|
result = _sync_result(
|
||||||
@@ -814,6 +1181,7 @@ def _download_plan(
|
|||||||
root: Path,
|
root: Path,
|
||||||
plan: UpdatePlan,
|
plan: UpdatePlan,
|
||||||
progress: ProgressCallback | None,
|
progress: ProgressCallback | None,
|
||||||
|
options: DownloadOptions,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not plan.downloads:
|
if not plan.downloads:
|
||||||
return
|
return
|
||||||
@@ -821,10 +1189,22 @@ def _download_plan(
|
|||||||
lock = threading.Lock()
|
lock = threading.Lock()
|
||||||
emit_lock = threading.Lock()
|
emit_lock = threading.Lock()
|
||||||
current_bytes: dict[str, int] = {}
|
current_bytes: dict[str, int] = {}
|
||||||
|
current_servers: dict[str, str] = {}
|
||||||
completed_files = 0
|
completed_files = 0
|
||||||
completed_bytes = 0
|
completed_bytes = 0
|
||||||
transferred_bytes = 0
|
transferred_bytes = 0
|
||||||
|
retry_count = 0
|
||||||
|
retry_reason: str | None = None
|
||||||
|
active_workers = 0
|
||||||
|
target_workers = 0
|
||||||
last_progress_emitted_at = 0.0
|
last_progress_emitted_at = 0.0
|
||||||
|
started_at = time.monotonic()
|
||||||
|
transfer_samples: deque[tuple[float, int]] = deque([(started_at, 0)])
|
||||||
|
controller = AdaptiveConcurrencyController(options)
|
||||||
|
bandwidth_limit = options.effective_bandwidth_limit
|
||||||
|
limiter = BandwidthLimiter(bandwidth_limit) if bandwidth_limit is not None else None
|
||||||
|
previous_observer = client.download_observer
|
||||||
|
previous_limiter = client.bandwidth_limiter
|
||||||
|
|
||||||
def emit_download_progress(
|
def emit_download_progress(
|
||||||
current_file: str,
|
current_file: str,
|
||||||
@@ -847,27 +1227,72 @@ def _download_plan(
|
|||||||
return
|
return
|
||||||
last_progress_emitted_at = now
|
last_progress_emitted_at = now
|
||||||
with lock:
|
with lock:
|
||||||
|
bytes_completed = min(
|
||||||
|
plan.bytes_total,
|
||||||
|
completed_bytes + sum(current_bytes.values()),
|
||||||
|
)
|
||||||
|
_prune_transfer_samples(transfer_samples, now)
|
||||||
|
transfer_rate = _sample_rate(transfer_samples)
|
||||||
|
bytes_remaining = max(0, plan.bytes_total - bytes_completed)
|
||||||
|
eta_seconds = (
|
||||||
|
bytes_remaining / transfer_rate
|
||||||
|
if transfer_rate is not None and transfer_rate > 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
event = SyncProgress(
|
event = SyncProgress(
|
||||||
"downloading",
|
"downloading",
|
||||||
completed_files,
|
completed_files,
|
||||||
len(plan.downloads),
|
len(plan.downloads),
|
||||||
completed_bytes + sum(current_bytes.values()),
|
bytes_completed,
|
||||||
plan.bytes_total,
|
plan.bytes_total,
|
||||||
current_file,
|
current_file,
|
||||||
transferred_bytes,
|
transferred_bytes,
|
||||||
|
bytes_remaining,
|
||||||
|
transfer_rate,
|
||||||
|
eta_seconds,
|
||||||
|
current_servers.get(
|
||||||
|
current_file,
|
||||||
|
_server_display_name(depot_snapshot.server),
|
||||||
|
),
|
||||||
|
retry_count,
|
||||||
|
retry_reason,
|
||||||
|
active_workers,
|
||||||
|
target_workers,
|
||||||
)
|
)
|
||||||
progress(event)
|
progress(event)
|
||||||
|
|
||||||
|
def observe_download(event: DownloadTelemetry) -> None:
|
||||||
|
nonlocal transferred_bytes, retry_count, retry_reason
|
||||||
|
if event.kind == "response" and event.latency_seconds is not None:
|
||||||
|
controller.record_response(event.latency_seconds)
|
||||||
|
elif event.kind == "retry":
|
||||||
|
controller.record_retry()
|
||||||
|
elif event.kind == "chunk":
|
||||||
|
controller.record_chunk(event.bytes_written, event.write_seconds)
|
||||||
|
|
||||||
|
force = event.kind == "retry"
|
||||||
|
if event.kind in {"request", "response", "retry", "chunk"}:
|
||||||
|
with lock:
|
||||||
|
current_servers[event.file] = _server_display_name(event.server)
|
||||||
|
if event.kind == "chunk":
|
||||||
|
transferred_bytes += event.bytes_written
|
||||||
|
now = time.monotonic()
|
||||||
|
transfer_samples.append((now, transferred_bytes))
|
||||||
|
_prune_transfer_samples(transfer_samples, now)
|
||||||
|
if event.kind == "retry":
|
||||||
|
retry_count += 1
|
||||||
|
retry_reason = event.reason
|
||||||
|
if previous_observer is not None:
|
||||||
|
previous_observer(event)
|
||||||
|
if event.kind != "chunk":
|
||||||
|
emit_download_progress(event.file, force=force)
|
||||||
|
|
||||||
def download(planned_download: PlannedDownload) -> None:
|
def download(planned_download: PlannedDownload) -> None:
|
||||||
target = _safe_target(root, planned_download.file.path)
|
target = _safe_target(root, planned_download.file.path)
|
||||||
_ensure_target_parent(root, target)
|
_ensure_target_parent(root, target)
|
||||||
|
|
||||||
def report_file_bytes(written: int) -> None:
|
def report_file_bytes(written: int) -> None:
|
||||||
nonlocal transferred_bytes
|
|
||||||
with lock:
|
with lock:
|
||||||
previous = current_bytes.get(planned_download.file.path)
|
|
||||||
if previous is not None and written > previous:
|
|
||||||
transferred_bytes += written - previous
|
|
||||||
current_bytes[planned_download.file.path] = written
|
current_bytes[planned_download.file.path] = written
|
||||||
emit_download_progress(planned_download.file.path)
|
emit_download_progress(planned_download.file.path)
|
||||||
|
|
||||||
@@ -878,49 +1303,84 @@ def _download_plan(
|
|||||||
progress=report_file_bytes if progress is not None else None,
|
progress=report_file_bytes if progress is not None else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
worker_count = min(DOWNLOAD_WORKERS, len(plan.downloads))
|
maximum_workers = min(options.effective_maximum_workers, len(plan.downloads))
|
||||||
remaining_items = iter(plan.downloads)
|
# Start large transfers first so they overlap the small-file request workload
|
||||||
with ThreadPoolExecutor(max_workers=worker_count) as executor:
|
# instead of becoming a single-connection tail at the end of an install.
|
||||||
future_items: dict[Future[None], PlannedDownload] = {}
|
remaining_items: deque[PlannedDownload] = deque(
|
||||||
|
sorted(plan.downloads, key=lambda item: item.file.size, reverse=True)
|
||||||
|
)
|
||||||
|
client.download_observer = observe_download
|
||||||
|
client.bandwidth_limiter = limiter
|
||||||
|
try:
|
||||||
|
with ThreadPoolExecutor(max_workers=maximum_workers) as executor:
|
||||||
|
future_items: dict[Future[None], PlannedDownload] = {}
|
||||||
|
|
||||||
def submit_next() -> bool:
|
def submit_until(worker_target: int) -> None:
|
||||||
try:
|
nonlocal active_workers, target_workers
|
||||||
next_item = next(remaining_items)
|
with lock:
|
||||||
except StopIteration:
|
target_workers = worker_target
|
||||||
return False
|
while remaining_items and len(future_items) < worker_target:
|
||||||
future_items[executor.submit(download, next_item)] = next_item
|
next_item = remaining_items.popleft()
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
for _worker in range(worker_count):
|
|
||||||
if not submit_next():
|
|
||||||
break
|
|
||||||
while future_items:
|
|
||||||
done, _pending = wait(
|
|
||||||
tuple(future_items),
|
|
||||||
return_when=FIRST_COMPLETED,
|
|
||||||
)
|
|
||||||
completed_items: list[PlannedDownload] = []
|
|
||||||
for future in done:
|
|
||||||
completed_item = future_items.pop(future)
|
|
||||||
future.result()
|
|
||||||
completed_items.append(completed_item)
|
|
||||||
for completed_item in completed_items:
|
|
||||||
with lock:
|
with lock:
|
||||||
completed_files += 1
|
active_workers += 1
|
||||||
completed_bytes += completed_item.file.size
|
try:
|
||||||
current_bytes.pop(completed_item.file.path, None)
|
submitted_future = executor.submit(download, next_item)
|
||||||
is_final_download = completed_files == len(plan.downloads)
|
except BaseException:
|
||||||
emit_download_progress(
|
with lock:
|
||||||
completed_item.file.path,
|
active_workers -= 1
|
||||||
force=is_final_download,
|
remaining_items.appendleft(next_item)
|
||||||
|
raise
|
||||||
|
future_items[submitted_future] = next_item
|
||||||
|
|
||||||
|
try:
|
||||||
|
initial_target = controller.target_workers(
|
||||||
|
len(remaining_items),
|
||||||
|
plan.bytes_total,
|
||||||
|
)
|
||||||
|
submit_until(initial_target)
|
||||||
|
while future_items:
|
||||||
|
done, _pending = wait(
|
||||||
|
tuple(future_items),
|
||||||
|
timeout=ADAPTIVE_EVALUATION_INTERVAL_SECONDS,
|
||||||
|
return_when=FIRST_COMPLETED,
|
||||||
)
|
)
|
||||||
for _item in completed_items:
|
completed_items: list[PlannedDownload] = []
|
||||||
submit_next()
|
for future in done:
|
||||||
except BaseException:
|
completed_item = future_items.pop(future)
|
||||||
for future in future_items:
|
future.result()
|
||||||
future.cancel()
|
completed_items.append(completed_item)
|
||||||
raise
|
for completed_item in completed_items:
|
||||||
|
with lock:
|
||||||
|
active_workers -= 1
|
||||||
|
completed_files += 1
|
||||||
|
completed_bytes += completed_item.file.size
|
||||||
|
current_bytes.pop(completed_item.file.path, None)
|
||||||
|
is_final_download = completed_files == len(plan.downloads)
|
||||||
|
emit_download_progress(
|
||||||
|
completed_item.file.path,
|
||||||
|
force=is_final_download,
|
||||||
|
)
|
||||||
|
with lock:
|
||||||
|
current_servers.pop(completed_item.file.path, None)
|
||||||
|
with lock:
|
||||||
|
in_progress_bytes = sum(current_bytes.values())
|
||||||
|
remaining_file_count = len(remaining_items) + len(future_items)
|
||||||
|
remaining_byte_count = max(
|
||||||
|
0,
|
||||||
|
plan.bytes_total - completed_bytes - in_progress_bytes,
|
||||||
|
)
|
||||||
|
next_target = controller.target_workers(
|
||||||
|
remaining_file_count,
|
||||||
|
remaining_byte_count,
|
||||||
|
)
|
||||||
|
submit_until(next_target)
|
||||||
|
except BaseException:
|
||||||
|
for future in future_items:
|
||||||
|
future.cancel()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
client.download_observer = previous_observer
|
||||||
|
client.bandwidth_limiter = previous_limiter
|
||||||
|
|
||||||
|
|
||||||
def _apply_deletions(root: Path, relative_paths: Iterable[str]) -> int:
|
def _apply_deletions(root: Path, relative_paths: Iterable[str]) -> int:
|
||||||
@@ -1415,6 +1875,44 @@ def _content_range(value: str | None) -> tuple[int, int, int]:
|
|||||||
return start, end, total
|
return start, end, total
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_cpu_load() -> float | None:
|
||||||
|
"""Return one-minute runnable load normalized by the available CPU count."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
load = os.getloadavg()[0]
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
return None
|
||||||
|
processors = os.cpu_count()
|
||||||
|
if processors is None or processors <= 0:
|
||||||
|
return None
|
||||||
|
return max(0.0, load / processors)
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_transfer_samples(
|
||||||
|
samples: deque[tuple[float, int]],
|
||||||
|
now: float,
|
||||||
|
) -> None:
|
||||||
|
cutoff = now - TRANSFER_RATE_WINDOW_SECONDS
|
||||||
|
while len(samples) > 2 and samples[1][0] <= cutoff:
|
||||||
|
samples.popleft()
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_rate(samples: deque[tuple[float, int]]) -> float | None:
|
||||||
|
if len(samples) < 2:
|
||||||
|
return None
|
||||||
|
first_time, first_bytes = samples[0]
|
||||||
|
last_time, last_bytes = samples[-1]
|
||||||
|
elapsed = last_time - first_time
|
||||||
|
if elapsed <= 0 or last_bytes < first_bytes:
|
||||||
|
return None
|
||||||
|
return (last_bytes - first_bytes) / elapsed
|
||||||
|
|
||||||
|
|
||||||
|
def _server_display_name(server: str) -> str:
|
||||||
|
parsed = urlparse(server if "://" in server else f"//{server}")
|
||||||
|
return parsed.hostname or server.split("/", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
def _headers() -> dict[str, str]:
|
def _headers() -> dict[str, str]:
|
||||||
return {"User-Agent": f"PT-Linux/{__version__} (+https://pristontale.eu/)"}
|
return {"User-Agent": f"PT-Linux/{__version__} (+https://pristontale.eu/)"}
|
||||||
|
|
||||||
@@ -1451,5 +1949,6 @@ def _emit_progress(
|
|||||||
bytes_completed,
|
bytes_completed,
|
||||||
bytes_total,
|
bytes_total,
|
||||||
current_file,
|
current_file,
|
||||||
|
bytes_remaining=max(0, bytes_total - bytes_completed),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -95,6 +95,17 @@ def fetch_news_image(
|
|||||||
return _fetch_png_image(article.image_url, cache_path, timeout_seconds, opener)
|
return _fetch_png_image(article.image_url, cache_path, timeout_seconds, opener)
|
||||||
|
|
||||||
|
|
||||||
|
def read_cached_news_image(
|
||||||
|
article: NewsArticle,
|
||||||
|
cache_directory: Path,
|
||||||
|
) -> bytes | None:
|
||||||
|
"""Read article artwork without contacting the network."""
|
||||||
|
|
||||||
|
if article.image_url is None:
|
||||||
|
return None
|
||||||
|
return _read_png(_image_cache_path(cache_directory, article))
|
||||||
|
|
||||||
|
|
||||||
def fetch_official_logo(
|
def fetch_official_logo(
|
||||||
cache_path: Path,
|
cache_path: Path,
|
||||||
*,
|
*,
|
||||||
@@ -106,6 +117,12 @@ def fetch_official_logo(
|
|||||||
return _fetch_png_image(OFFICIAL_LOGO_URL, cache_path, timeout_seconds, opener)
|
return _fetch_png_image(OFFICIAL_LOGO_URL, cache_path, timeout_seconds, opener)
|
||||||
|
|
||||||
|
|
||||||
|
def read_cached_official_logo(cache_path: Path) -> bytes | None:
|
||||||
|
"""Read the official logo cache without contacting the network."""
|
||||||
|
|
||||||
|
return _read_png(cache_path)
|
||||||
|
|
||||||
|
|
||||||
def _fetch_png_image(
|
def _fetch_png_image(
|
||||||
url: str,
|
url: str,
|
||||||
cache_path: Path,
|
cache_path: Path,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import asdict, dataclass, fields, replace
|
from dataclasses import asdict, dataclass, fields, replace
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import secrets
|
import secrets
|
||||||
@@ -41,11 +42,19 @@ class Settings:
|
|||||||
proton_sync: str = "auto"
|
proton_sync: str = "auto"
|
||||||
proton_path: str | None = None
|
proton_path: str | None = None
|
||||||
game_source: str | None = None
|
game_source: str | None = None
|
||||||
|
download_speed_limit_mbps: float | None = None
|
||||||
|
low_bandwidth_mode: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def source_override(self) -> Path | None:
|
def source_override(self) -> Path | None:
|
||||||
return Path(self.game_source).expanduser().resolve() if self.game_source else None
|
return Path(self.game_source).expanduser().resolve() if self.game_source else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def download_speed_limit_bytes_per_second(self) -> int | None:
|
||||||
|
if self.download_speed_limit_mbps is None:
|
||||||
|
return None
|
||||||
|
return round(self.download_speed_limit_mbps * 1_000_000)
|
||||||
|
|
||||||
|
|
||||||
def load_settings(paths: AppPaths) -> Settings:
|
def load_settings(paths: AppPaths) -> Settings:
|
||||||
value = _read_settings_object(paths.settings_file)
|
value = _read_settings_object(paths.settings_file)
|
||||||
@@ -153,6 +162,11 @@ def _settings_from_object(value: dict[str, object]) -> Settings:
|
|||||||
proton_sync=_string(value, "proton_sync", "auto"),
|
proton_sync=_string(value, "proton_sync", "auto"),
|
||||||
proton_path=_optional_string(value, "proton_path"),
|
proton_path=_optional_string(value, "proton_path"),
|
||||||
game_source=_optional_string(value, "game_source"),
|
game_source=_optional_string(value, "game_source"),
|
||||||
|
download_speed_limit_mbps=_optional_positive_number(
|
||||||
|
value,
|
||||||
|
"download_speed_limit_mbps",
|
||||||
|
),
|
||||||
|
low_bandwidth_mode=_boolean(value, "low_bandwidth_mode", False),
|
||||||
)
|
)
|
||||||
_validate(settings)
|
_validate(settings)
|
||||||
return settings
|
return settings
|
||||||
@@ -250,6 +264,17 @@ def _validate(settings: Settings) -> None:
|
|||||||
raise ValueError(f"unsupported runtime selection: {settings.runtime}")
|
raise ValueError(f"unsupported runtime selection: {settings.runtime}")
|
||||||
if settings.proton_sync not in {"auto", "fsync", "esync"}:
|
if settings.proton_sync not in {"auto", "fsync", "esync"}:
|
||||||
raise ValueError(f"unsupported Proton synchronization mode: {settings.proton_sync}")
|
raise ValueError(f"unsupported Proton synchronization mode: {settings.proton_sync}")
|
||||||
|
if (
|
||||||
|
settings.download_speed_limit_mbps is not None
|
||||||
|
and (
|
||||||
|
not math.isfinite(settings.download_speed_limit_mbps)
|
||||||
|
or settings.download_speed_limit_mbps <= 0
|
||||||
|
or settings.download_speed_limit_mbps > 10_000
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"download_speed_limit_mbps must be greater than 0 and at most 10000, or null"
|
||||||
|
)
|
||||||
|
|
||||||
def _boolean(value: dict[str, object], key: str, default: bool) -> bool:
|
def _boolean(value: dict[str, object], key: str, default: bool) -> bool:
|
||||||
item = value.get(key, default)
|
item = value.get(key, default)
|
||||||
@@ -274,4 +299,16 @@ def _optional_string(value: dict[str, object], key: str) -> str | None:
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_positive_number(value: dict[str, object], key: str) -> float | None:
|
||||||
|
item = value.get(key)
|
||||||
|
if item is None:
|
||||||
|
return None
|
||||||
|
if isinstance(item, bool) or not isinstance(item, (int, float)):
|
||||||
|
raise ValueError(f"{key} must be null or a positive number")
|
||||||
|
number = float(item)
|
||||||
|
if not math.isfinite(number) or number <= 0:
|
||||||
|
raise ValueError(f"{key} must be null or a positive number")
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
_SETTINGS_FIELD_NAMES = frozenset(field.name for field in fields(Settings))
|
_SETTINGS_FIELD_NAMES = frozenset(field.name for field in fields(Settings))
|
||||||
|
|||||||
366
ptlinux/ui.py
366
ptlinux/ui.py
@@ -63,6 +63,8 @@ from .news import (
|
|||||||
fetch_official_logo,
|
fetch_official_logo,
|
||||||
read_cached_broadcast,
|
read_cached_broadcast,
|
||||||
read_cached_news,
|
read_cached_news,
|
||||||
|
read_cached_news_image,
|
||||||
|
read_cached_official_logo,
|
||||||
)
|
)
|
||||||
from .overlay import RuntimeCompatibility, inspect_runtime_compatibility
|
from .overlay import RuntimeCompatibility, inspect_runtime_compatibility
|
||||||
from .paths import AppPaths
|
from .paths import AppPaths
|
||||||
@@ -120,6 +122,14 @@ PROTON_SYNC_LABELS: dict[str, str] = {
|
|||||||
"Fsync": "fsync",
|
"Fsync": "fsync",
|
||||||
"Esync": "esync",
|
"Esync": "esync",
|
||||||
}
|
}
|
||||||
|
DOWNLOAD_SPEED_PRESETS_MBPS: dict[str, float | None] = {
|
||||||
|
"Unlimited": None,
|
||||||
|
"5 MB/s": 5.0,
|
||||||
|
"10 MB/s": 10.0,
|
||||||
|
"25 MB/s": 25.0,
|
||||||
|
"50 MB/s": 50.0,
|
||||||
|
}
|
||||||
|
DOWNLOAD_SPEED_CUSTOM_LABEL = "Custom…"
|
||||||
NEWS_THREE_COLUMN_WIDTH = 900
|
NEWS_THREE_COLUMN_WIDTH = 900
|
||||||
NEWS_TWO_COLUMN_WIDTH = 620
|
NEWS_TWO_COLUMN_WIDTH = 620
|
||||||
STACKED_CONTROLS_WIDTH = 860
|
STACKED_CONTROLS_WIDTH = 860
|
||||||
@@ -305,6 +315,62 @@ def setting_value(label: str, choices: Mapping[str, str]) -> str:
|
|||||||
raise ValueError(f"unsupported setting choice: {label}") from error
|
raise ValueError(f"unsupported setting choice: {label}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def download_speed_setting_label(limit_mbps: float | None) -> str:
|
||||||
|
for label, preset in DOWNLOAD_SPEED_PRESETS_MBPS.items():
|
||||||
|
if preset == limit_mbps:
|
||||||
|
return label
|
||||||
|
return DOWNLOAD_SPEED_CUSTOM_LABEL
|
||||||
|
|
||||||
|
|
||||||
|
def download_speed_setting_value(label: str, custom_value: str) -> float | None:
|
||||||
|
if label in DOWNLOAD_SPEED_PRESETS_MBPS:
|
||||||
|
return DOWNLOAD_SPEED_PRESETS_MBPS[label]
|
||||||
|
if label != DOWNLOAD_SPEED_CUSTOM_LABEL:
|
||||||
|
raise ValueError(f"unsupported download speed choice: {label}")
|
||||||
|
try:
|
||||||
|
value = float(custom_value.strip())
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError("custom download speed must be a number in MB/s") from error
|
||||||
|
if not math.isfinite(value) or value <= 0 or value > 10_000:
|
||||||
|
raise ValueError(
|
||||||
|
"custom download speed must be greater than 0 and at most 10000 MB/s"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def download_activity_metadata(
|
||||||
|
*,
|
||||||
|
current_server: str | None,
|
||||||
|
retry_count: int,
|
||||||
|
retry_reason: str | None,
|
||||||
|
active_workers: int,
|
||||||
|
target_workers: int,
|
||||||
|
) -> str:
|
||||||
|
"""Render compact download details for the existing activity heading."""
|
||||||
|
|
||||||
|
if retry_count < 0 or active_workers < 0 or target_workers < 0:
|
||||||
|
raise ValueError("download activity counts must not be negative")
|
||||||
|
parts: list[str] = []
|
||||||
|
if current_server:
|
||||||
|
parts.append(f"CDN: {_ellipsize(current_server, 28)}")
|
||||||
|
if target_workers:
|
||||||
|
parts.append(f"Workers: {active_workers}/{target_workers}")
|
||||||
|
retry_text = f"Retries: {retry_count}"
|
||||||
|
if retry_reason:
|
||||||
|
retry_text += f" ({_ellipsize(retry_reason, 26, keep_tail=True)})"
|
||||||
|
parts.append(retry_text)
|
||||||
|
return " · ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _ellipsize(value: str, limit: int, *, keep_tail: bool = False) -> str:
|
||||||
|
normalized = " ".join(value.split())
|
||||||
|
if len(normalized) <= limit:
|
||||||
|
return normalized
|
||||||
|
if keep_tail:
|
||||||
|
return f"…{normalized[-(limit - 1):]}"
|
||||||
|
return f"{normalized[:limit - 1]}…"
|
||||||
|
|
||||||
|
|
||||||
def call_optional(callback: Callable[..., None] | None, *arguments: object) -> None:
|
def call_optional(callback: Callable[..., None] | None, *arguments: object) -> None:
|
||||||
if callback is not None:
|
if callback is not None:
|
||||||
callback(*arguments)
|
callback(*arguments)
|
||||||
@@ -1813,6 +1879,18 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
progress_text = tk.StringVar(value="Click Play to start your journey")
|
progress_text = tk.StringVar(value="Click Play to start your journey")
|
||||||
speed_text = tk.StringVar(value="")
|
speed_text = tk.StringVar(value="")
|
||||||
client_activity_text = tk.StringVar(value="")
|
client_activity_text = tk.StringVar(value="")
|
||||||
|
download_context_text = tk.StringVar(value="")
|
||||||
|
activity_meta_text = tk.StringVar(value="")
|
||||||
|
|
||||||
|
def refresh_activity_meta_text() -> None:
|
||||||
|
activity_meta_text.set(
|
||||||
|
download_context_text.get() or client_activity_text.get()
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_download_context_text(value: str) -> None:
|
||||||
|
download_context_text.set(value)
|
||||||
|
refresh_activity_meta_text()
|
||||||
|
|
||||||
activity_heading = ttk.Frame(activity_copy, style="Surface.TFrame")
|
activity_heading = ttk.Frame(activity_copy, style="Surface.TFrame")
|
||||||
activity_heading.pack(fill="x")
|
activity_heading.pack(fill="x")
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
@@ -1822,7 +1900,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
).pack(side="left", anchor="w")
|
).pack(side="left", anchor="w")
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
activity_heading,
|
activity_heading,
|
||||||
textvariable=client_activity_text,
|
textvariable=activity_meta_text,
|
||||||
style="Muted.TLabel",
|
style="Muted.TLabel",
|
||||||
).pack(side="right", anchor="e")
|
).pack(side="right", anchor="e")
|
||||||
progress_bar = ttk.Progressbar(
|
progress_bar = ttk.Progressbar(
|
||||||
@@ -1839,7 +1917,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
progress_details,
|
progress_details,
|
||||||
textvariable=speed_text,
|
textvariable=speed_text,
|
||||||
style="Speed.TLabel",
|
style="Speed.TLabel",
|
||||||
width=15,
|
width=28,
|
||||||
anchor="e",
|
anchor="e",
|
||||||
).pack(side="right")
|
).pack(side="right")
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
@@ -1857,6 +1935,24 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
).pack(side="left", fill="y", padx=(0, 8))
|
).pack(side="left", fill="y", padx=(0, 8))
|
||||||
server_status_content = ttk.Frame(server_status_row, style="Surface.TFrame")
|
server_status_content = ttk.Frame(server_status_row, style="Surface.TFrame")
|
||||||
server_status_content.pack(side="left", fill="y", anchor="w")
|
server_status_content.pack(side="left", fill="y", anchor="w")
|
||||||
|
log_toggle = tk.Button(
|
||||||
|
server_status_row,
|
||||||
|
text=">_",
|
||||||
|
background="#191311",
|
||||||
|
foreground="#f8fafc",
|
||||||
|
activebackground="#24170f",
|
||||||
|
activeforeground="#ffffff",
|
||||||
|
font=("Monospace", 8, "bold"),
|
||||||
|
padx=3,
|
||||||
|
pady=1,
|
||||||
|
borderwidth=0,
|
||||||
|
highlightthickness=1,
|
||||||
|
highlightbackground="#191311",
|
||||||
|
highlightcolor="#f4bd61",
|
||||||
|
relief="flat",
|
||||||
|
takefocus=True,
|
||||||
|
)
|
||||||
|
log_toggle.pack(side="right", anchor="e", padx=(8, 0))
|
||||||
server_status_debug_text = tk.StringVar(value="")
|
server_status_debug_text = tk.StringVar(value="")
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
server_status_row,
|
server_status_row,
|
||||||
@@ -1969,26 +2065,6 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
)
|
)
|
||||||
log.pack(fill="both", expand=True, padx=10, pady=10)
|
log.pack(fill="both", expand=True, padx=10, pady=10)
|
||||||
activity_window.withdraw()
|
activity_window.withdraw()
|
||||||
activity_footer = ttk.Frame(frame, style="Root.TFrame")
|
|
||||||
activity_footer.pack(fill="x", pady=(3, 0))
|
|
||||||
log_toggle = tk.Button(
|
|
||||||
activity_footer,
|
|
||||||
text=">_",
|
|
||||||
background="#0c0908",
|
|
||||||
foreground="#f8fafc",
|
|
||||||
activebackground="#24170f",
|
|
||||||
activeforeground="#ffffff",
|
|
||||||
font=("Monospace", 8, "bold"),
|
|
||||||
padx=3,
|
|
||||||
pady=2,
|
|
||||||
borderwidth=0,
|
|
||||||
highlightthickness=2,
|
|
||||||
highlightbackground="#0c0908",
|
|
||||||
highlightcolor="#f4bd61",
|
|
||||||
relief="flat",
|
|
||||||
takefocus=True,
|
|
||||||
)
|
|
||||||
log_toggle.pack(side="left", anchor="sw")
|
|
||||||
|
|
||||||
events: queue.Queue[tuple[str, object]] = queue.Queue()
|
events: queue.Queue[tuple[str, object]] = queue.Queue()
|
||||||
progress_updates = ProgressCoalescer()
|
progress_updates = ProgressCoalescer()
|
||||||
@@ -2111,28 +2187,67 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
if phase == "manifest":
|
if phase == "manifest":
|
||||||
transfer_rate.reset()
|
transfer_rate.reset()
|
||||||
speed_text.set("")
|
speed_text.set("")
|
||||||
|
set_download_context_text("")
|
||||||
set_operation_stage(1, 3, "Checking official CDN manifests…")
|
set_operation_stage(1, 3, "Checking official CDN manifests…")
|
||||||
return
|
return
|
||||||
progress_bar.stop()
|
progress_bar.stop()
|
||||||
progress_bar.configure(mode="determinate")
|
progress_bar.configure(mode="determinate")
|
||||||
if bytes_total:
|
if bytes_total:
|
||||||
progress_bar.configure(maximum=bytes_total, value=min(bytes_completed, bytes_total))
|
progress_bar.configure(maximum=bytes_total, value=min(bytes_completed, bytes_total))
|
||||||
|
bytes_remaining = _as_int(event.get("bytes_remaining"))
|
||||||
|
if "bytes_remaining" not in event:
|
||||||
|
bytes_remaining = max(0, bytes_total - bytes_completed)
|
||||||
|
percent = min(100, round((bytes_completed / bytes_total) * 100))
|
||||||
operation_progress_detail = (
|
operation_progress_detail = (
|
||||||
f"{files_completed}/{files_total} files · "
|
f"{percent}% · {files_completed:,}/{files_total:,} files · "
|
||||||
f"{_format_bytes(bytes_completed)} / {_format_bytes(bytes_total)}"
|
f"{_format_bytes(bytes_completed)} / {_format_bytes(bytes_total)} · "
|
||||||
|
f"{_format_bytes(bytes_remaining)} remaining"
|
||||||
)
|
)
|
||||||
if phase == "downloading":
|
if phase == "downloading":
|
||||||
bytes_transferred = _as_int(event.get("bytes_transferred"))
|
bytes_transferred = _as_int(event.get("bytes_transferred"))
|
||||||
rate = transfer_rate.update(bytes_transferred)
|
estimated_rate = transfer_rate.update(bytes_transferred)
|
||||||
speed_text.set(format_transfer_rate(rate) if rate is not None else "0 B/s")
|
reported_rate = _as_float(
|
||||||
|
event.get("transfer_rate_bytes_per_second")
|
||||||
|
)
|
||||||
|
rate = reported_rate if reported_rate is not None else estimated_rate
|
||||||
|
eta_seconds = _as_float(event.get("eta_seconds"))
|
||||||
|
if eta_seconds is None and rate is not None and rate > 0:
|
||||||
|
eta_seconds = bytes_remaining / rate
|
||||||
|
speed_text.set(
|
||||||
|
f"{format_transfer_rate(rate) if rate is not None else '0 B/s'} · "
|
||||||
|
f"ETA {format_eta(eta_seconds)}"
|
||||||
|
)
|
||||||
|
current_server = event.get("current_server")
|
||||||
|
active_workers = _as_int(event.get("active_workers"))
|
||||||
|
target_workers = _as_int(event.get("target_workers"))
|
||||||
|
retry_reason = event.get("retry_reason")
|
||||||
|
set_download_context_text(
|
||||||
|
download_activity_metadata(
|
||||||
|
current_server=(
|
||||||
|
current_server
|
||||||
|
if isinstance(current_server, str) and current_server
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
retry_count=_as_int(event.get("retry_count")),
|
||||||
|
retry_reason=(
|
||||||
|
retry_reason
|
||||||
|
if isinstance(retry_reason, str) and retry_reason
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
active_workers=active_workers,
|
||||||
|
target_workers=target_workers,
|
||||||
|
)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
transfer_rate.reset()
|
transfer_rate.reset()
|
||||||
speed_text.set("")
|
speed_text.set("")
|
||||||
|
set_download_context_text("")
|
||||||
else:
|
else:
|
||||||
progress_bar.configure(maximum=3, value=3)
|
progress_bar.configure(maximum=3, value=3)
|
||||||
operation_progress_detail = "Game files are already up to date."
|
operation_progress_detail = "Game files are already up to date."
|
||||||
transfer_rate.reset()
|
transfer_rate.reset()
|
||||||
speed_text.set("")
|
speed_text.set("")
|
||||||
|
set_download_context_text("")
|
||||||
progress_text.set(activity_text())
|
progress_text.set(activity_text())
|
||||||
if isinstance(current_file, str) and current_file:
|
if isinstance(current_file, str) and current_file:
|
||||||
status.set(
|
status.set(
|
||||||
@@ -2159,6 +2274,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
operation_progress_detail = None
|
operation_progress_detail = None
|
||||||
transfer_rate.reset()
|
transfer_rate.reset()
|
||||||
speed_text.set("")
|
speed_text.set("")
|
||||||
|
set_download_context_text("")
|
||||||
progress_bar.stop()
|
progress_bar.stop()
|
||||||
progress_bar.configure(
|
progress_bar.configure(
|
||||||
mode="determinate",
|
mode="determinate",
|
||||||
@@ -2370,6 +2486,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
progress_updates.clear()
|
progress_updates.clear()
|
||||||
transfer_rate.reset()
|
transfer_rate.reset()
|
||||||
speed_text.set("")
|
speed_text.set("")
|
||||||
|
set_download_context_text("")
|
||||||
set_busy(True)
|
set_busy(True)
|
||||||
status.set(label)
|
status.set(label)
|
||||||
progress_text.set("Waiting for the operation to report progress…")
|
progress_text.set("Waiting for the operation to report progress…")
|
||||||
@@ -2971,7 +3088,11 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def worker(news_article: NewsArticle) -> None:
|
def worker(news_article: NewsArticle) -> None:
|
||||||
image = fetch_news_image(news_article, paths.news_image_cache)
|
image = (
|
||||||
|
read_cached_news_image(news_article, paths.news_image_cache)
|
||||||
|
if startup_settings.low_bandwidth_mode
|
||||||
|
else fetch_news_image(news_article, paths.news_image_cache)
|
||||||
|
)
|
||||||
events.put(("news_image", (news_article.id, image)))
|
events.put(("news_image", (news_article.id, image)))
|
||||||
|
|
||||||
for article in image_articles:
|
for article in image_articles:
|
||||||
@@ -3087,7 +3208,11 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
|
|
||||||
def load_official_logo() -> None:
|
def load_official_logo() -> None:
|
||||||
def worker() -> None:
|
def worker() -> None:
|
||||||
image = fetch_official_logo(paths.logo_cache)
|
image = (
|
||||||
|
read_cached_official_logo(paths.logo_cache)
|
||||||
|
if startup_settings.low_bandwidth_mode
|
||||||
|
else fetch_official_logo(paths.logo_cache)
|
||||||
|
)
|
||||||
events.put(("logo_image", image))
|
events.put(("logo_image", image))
|
||||||
|
|
||||||
threading.Thread(target=worker, name="ptlinux-logo", daemon=True).start()
|
threading.Thread(target=worker, name="ptlinux-logo", daemon=True).start()
|
||||||
@@ -3529,6 +3654,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
latest_installation_status = installation
|
latest_installation_status = installation
|
||||||
additional_client_ready = game_clients.running_count > 0 and installation.installed
|
additional_client_ready = game_clients.running_count > 0 and installation.installed
|
||||||
client_activity_text.set(game_client_activity_text(game_clients.running_count))
|
client_activity_text.set(game_client_activity_text(game_clients.running_count))
|
||||||
|
refresh_activity_meta_text()
|
||||||
play_button.configure(
|
play_button.configure(
|
||||||
text="PLAY" if additional_client_ready else installation.action.value
|
text="PLAY" if additional_client_ready else installation.action.value
|
||||||
)
|
)
|
||||||
@@ -3685,16 +3811,42 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
)
|
)
|
||||||
events.put(("version", snapshot))
|
events.put(("version", snapshot))
|
||||||
|
|
||||||
threading.Thread(target=broadcast_worker, name="ptlinux-broadcast", daemon=True).start()
|
if startup_settings.low_bandwidth_mode:
|
||||||
threading.Thread(target=news_worker, name="ptlinux-news", daemon=True).start()
|
append_log(
|
||||||
threading.Thread(target=patches_worker, name="ptlinux-patches", daemon=True).start()
|
"Low-bandwidth mode: using cached news, broadcasts, patch notes, "
|
||||||
|
"and artwork."
|
||||||
|
)
|
||||||
|
for component, message in (
|
||||||
|
("broadcast", "Cached broadcast ready"),
|
||||||
|
("news", "Cached news ready"),
|
||||||
|
("patches", "Cached patch notes ready"),
|
||||||
|
("launcher_update", "Cached launcher update status ready"),
|
||||||
|
):
|
||||||
|
mark_startup_component_ready(component, message)
|
||||||
|
else:
|
||||||
|
threading.Thread(
|
||||||
|
target=broadcast_worker,
|
||||||
|
name="ptlinux-broadcast",
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
threading.Thread(
|
||||||
|
target=news_worker,
|
||||||
|
name="ptlinux-news",
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
threading.Thread(
|
||||||
|
target=patches_worker,
|
||||||
|
name="ptlinux-patches",
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=server_status_worker,
|
target=server_status_worker,
|
||||||
name="ptlinux-server-status",
|
name="ptlinux-server-status",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
).start()
|
).start()
|
||||||
threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start()
|
threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start()
|
||||||
refresh_launcher_update_check()
|
if not startup_settings.low_bandwidth_mode:
|
||||||
|
refresh_launcher_update_check()
|
||||||
|
|
||||||
def start_remote_content_refresh() -> None:
|
def start_remote_content_refresh() -> None:
|
||||||
nonlocal remote_refresh_started
|
nonlocal remote_refresh_started
|
||||||
@@ -3723,6 +3875,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
progress_updates.clear()
|
progress_updates.clear()
|
||||||
transfer_rate.reset()
|
transfer_rate.reset()
|
||||||
speed_text.set("")
|
speed_text.set("")
|
||||||
|
set_download_context_text("")
|
||||||
progress_bar.stop()
|
progress_bar.stop()
|
||||||
close_game_preloader()
|
close_game_preloader()
|
||||||
if not ready:
|
if not ready:
|
||||||
@@ -3756,6 +3909,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
progress_updates.clear()
|
progress_updates.clear()
|
||||||
transfer_rate.reset()
|
transfer_rate.reset()
|
||||||
speed_text.set("")
|
speed_text.set("")
|
||||||
|
set_download_context_text("")
|
||||||
progress_bar.stop()
|
progress_bar.stop()
|
||||||
close_game_preloader()
|
close_game_preloader()
|
||||||
root.deiconify()
|
root.deiconify()
|
||||||
@@ -4556,7 +4710,10 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
).pack(anchor="w", pady=(1, 0))
|
).pack(anchor="w", pady=(1, 0))
|
||||||
tk.Label(
|
tk.Label(
|
||||||
header_copy,
|
header_copy,
|
||||||
text="Tune launcher accessibility, the game window, and runtime preferences.",
|
text=(
|
||||||
|
"Tune launcher accessibility, downloads, the game window, and runtime "
|
||||||
|
"preferences."
|
||||||
|
),
|
||||||
background="#24170f",
|
background="#24170f",
|
||||||
foreground="#c9baaa",
|
foreground="#c9baaa",
|
||||||
font=("Sans", 9),
|
font=("Sans", 9),
|
||||||
@@ -4586,6 +4743,21 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
)
|
)
|
||||||
proton_path_value = tk.StringVar(value=current_settings.proton_path or "")
|
proton_path_value = tk.StringVar(value=current_settings.proton_path or "")
|
||||||
game_source_value = tk.StringVar(value=current_settings.game_source or "")
|
game_source_value = tk.StringVar(value=current_settings.game_source or "")
|
||||||
|
download_speed_value = tk.StringVar(
|
||||||
|
value=download_speed_setting_label(
|
||||||
|
current_settings.download_speed_limit_mbps
|
||||||
|
)
|
||||||
|
)
|
||||||
|
custom_download_speed_value = tk.StringVar(
|
||||||
|
value=(
|
||||||
|
f"{current_settings.download_speed_limit_mbps:g}"
|
||||||
|
if current_settings.download_speed_limit_mbps is not None
|
||||||
|
else "5"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
low_bandwidth_value = tk.BooleanVar(
|
||||||
|
value=current_settings.low_bandwidth_mode
|
||||||
|
)
|
||||||
|
|
||||||
def add_choice(
|
def add_choice(
|
||||||
parent: ttk.LabelFrame,
|
parent: ttk.LabelFrame,
|
||||||
@@ -4673,6 +4845,10 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
|
|
||||||
def save_current_settings() -> None:
|
def save_current_settings() -> None:
|
||||||
try:
|
try:
|
||||||
|
download_speed_limit = download_speed_setting_value(
|
||||||
|
download_speed_value.get(),
|
||||||
|
custom_download_speed_value.get(),
|
||||||
|
)
|
||||||
updated_settings = update_settings(
|
updated_settings = update_settings(
|
||||||
paths,
|
paths,
|
||||||
borderless=borderless_value.get(),
|
borderless=borderless_value.get(),
|
||||||
@@ -4685,6 +4861,8 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
),
|
),
|
||||||
proton_path=proton_path_value.get().strip() or None,
|
proton_path=proton_path_value.get().strip() or None,
|
||||||
game_source=game_source_value.get().strip() or None,
|
game_source=game_source_value.get().strip() or None,
|
||||||
|
download_speed_limit_mbps=download_speed_limit,
|
||||||
|
low_bandwidth_mode=low_bandwidth_value.get(),
|
||||||
)
|
)
|
||||||
except (OSError, ValueError) as error:
|
except (OSError, ValueError) as error:
|
||||||
show_themed_dialog(
|
show_themed_dialog(
|
||||||
@@ -4695,14 +4873,22 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
window_icon=window_icon,
|
window_icon=window_icon,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
saved_limit = (
|
||||||
|
f"{updated_settings.download_speed_limit_mbps:g} MB/s"
|
||||||
|
if updated_settings.download_speed_limit_mbps is not None
|
||||||
|
else "unlimited"
|
||||||
|
)
|
||||||
append_log(
|
append_log(
|
||||||
f"Saved launcher settings to {paths.settings_file}: "
|
f"Saved launcher settings to {paths.settings_file}: "
|
||||||
f"runtime={updated_settings.runtime}, "
|
f"runtime={updated_settings.runtime}, "
|
||||||
f"proton_sync={updated_settings.proton_sync}"
|
f"proton_sync={updated_settings.proton_sync}, "
|
||||||
|
f"download_limit={saved_limit}, "
|
||||||
|
f"low_bandwidth={updated_settings.low_bandwidth_mode}"
|
||||||
)
|
)
|
||||||
status.set("Settings saved.")
|
status.set("Settings saved.")
|
||||||
progress_text.set(
|
progress_text.set(
|
||||||
"Game settings apply on the next launch; launcher scale applies after restart."
|
"Game and download settings apply to the next operation; launcher scale "
|
||||||
|
"and cached-content mode apply after restart."
|
||||||
)
|
)
|
||||||
if show_dashboard_debug:
|
if show_dashboard_debug:
|
||||||
refresh_dashboard_debug_disk_state()
|
refresh_dashboard_debug_disk_state()
|
||||||
@@ -4782,13 +4968,97 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
tuple(PROTON_SYNC_LABELS),
|
tuple(PROTON_SYNC_LABELS),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
downloads_card = ttk.LabelFrame(
|
||||||
|
content,
|
||||||
|
text="DOWNLOADS",
|
||||||
|
style="SettingsCard.TLabelframe",
|
||||||
|
padding=14,
|
||||||
|
)
|
||||||
|
downloads_card.grid(
|
||||||
|
row=1,
|
||||||
|
column=0,
|
||||||
|
columnspan=2,
|
||||||
|
sticky="ew",
|
||||||
|
pady=(12, 0),
|
||||||
|
)
|
||||||
|
downloads_card.columnconfigure(0, weight=1)
|
||||||
|
downloads_card.columnconfigure(1, weight=1)
|
||||||
|
downloads_card.columnconfigure(2, weight=1)
|
||||||
|
ttk.Label(
|
||||||
|
downloads_card,
|
||||||
|
text=(
|
||||||
|
"Concurrency adapts to CDN latency, retries, transfer and disk speed, "
|
||||||
|
"CPU load, and the remaining files."
|
||||||
|
),
|
||||||
|
style="SettingsHint.TLabel",
|
||||||
|
).grid(row=0, column=0, columnspan=3, sticky="w", pady=(0, 9))
|
||||||
|
ttk.Checkbutton(
|
||||||
|
downloads_card,
|
||||||
|
text="Low-bandwidth mode (cached news, up to 2 workers and 5 MB/s)",
|
||||||
|
variable=low_bandwidth_value,
|
||||||
|
style="Settings.TCheckbutton",
|
||||||
|
).grid(row=1, column=0, sticky="w", padx=(0, 16))
|
||||||
|
ttk.Label(
|
||||||
|
downloads_card,
|
||||||
|
text="Speed limit",
|
||||||
|
style="SettingsText.TLabel",
|
||||||
|
).grid(row=1, column=1, sticky="w")
|
||||||
|
ttk.Label(
|
||||||
|
downloads_card,
|
||||||
|
text="Custom MB/s",
|
||||||
|
style="SettingsText.TLabel",
|
||||||
|
).grid(row=1, column=2, sticky="w", padx=(8, 0))
|
||||||
|
download_speed_selector = ttk.Combobox(
|
||||||
|
downloads_card,
|
||||||
|
textvariable=download_speed_value,
|
||||||
|
values=tuple(DOWNLOAD_SPEED_PRESETS_MBPS) + (
|
||||||
|
DOWNLOAD_SPEED_CUSTOM_LABEL,
|
||||||
|
),
|
||||||
|
state="readonly",
|
||||||
|
style="Settings.TCombobox",
|
||||||
|
)
|
||||||
|
download_speed_selector.grid(
|
||||||
|
row=2,
|
||||||
|
column=1,
|
||||||
|
sticky="ew",
|
||||||
|
pady=(3, 0),
|
||||||
|
)
|
||||||
|
custom_download_speed = ttk.Entry(
|
||||||
|
downloads_card,
|
||||||
|
textvariable=custom_download_speed_value,
|
||||||
|
style="Settings.TEntry",
|
||||||
|
width=12,
|
||||||
|
)
|
||||||
|
custom_download_speed.grid(
|
||||||
|
row=2,
|
||||||
|
column=2,
|
||||||
|
sticky="ew",
|
||||||
|
padx=(8, 0),
|
||||||
|
pady=(3, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_custom_download_state(_event: object | None = None) -> None:
|
||||||
|
custom_download_speed.configure(
|
||||||
|
state=(
|
||||||
|
"normal"
|
||||||
|
if download_speed_value.get() == DOWNLOAD_SPEED_CUSTOM_LABEL
|
||||||
|
else "disabled"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
download_speed_selector.bind(
|
||||||
|
"<<ComboboxSelected>>",
|
||||||
|
update_custom_download_state,
|
||||||
|
)
|
||||||
|
update_custom_download_state()
|
||||||
|
|
||||||
overrides_card = ttk.LabelFrame(
|
overrides_card = ttk.LabelFrame(
|
||||||
content,
|
content,
|
||||||
text="OPTIONAL OVERRIDES",
|
text="OPTIONAL OVERRIDES",
|
||||||
style="SettingsCard.TLabelframe",
|
style="SettingsCard.TLabelframe",
|
||||||
padding=14,
|
padding=14,
|
||||||
)
|
)
|
||||||
overrides_card.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(12, 0))
|
overrides_card.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(12, 0))
|
||||||
overrides_card.columnconfigure(0, weight=1)
|
overrides_card.columnconfigure(0, weight=1)
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
overrides_card,
|
overrides_card,
|
||||||
@@ -4813,7 +5083,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
footer = ttk.Frame(content, style="SettingsFooter.TFrame", padding=(12, 9))
|
footer = ttk.Frame(content, style="SettingsFooter.TFrame", padding=(12, 9))
|
||||||
footer.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(12, 0))
|
footer.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(12, 0))
|
||||||
footer.columnconfigure(0, weight=1)
|
footer.columnconfigure(0, weight=1)
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
footer,
|
footer,
|
||||||
@@ -4841,7 +5111,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
dialog.update_idletasks()
|
dialog.update_idletasks()
|
||||||
settings_width, settings_height = fitted_window_size(
|
settings_width, settings_height = fitted_window_size(
|
||||||
842,
|
842,
|
||||||
760,
|
880,
|
||||||
scale=launcher_ui_scale,
|
scale=launcher_ui_scale,
|
||||||
screen_width=dialog.winfo_screenwidth(),
|
screen_width=dialog.winfo_screenwidth(),
|
||||||
screen_height=dialog.winfo_screenheight(),
|
screen_height=dialog.winfo_screenheight(),
|
||||||
@@ -5037,6 +5307,13 @@ def _as_int(value: object) -> int:
|
|||||||
return value if isinstance(value, int) and value >= 0 else 0
|
return value if isinstance(value, int) and value >= 0 else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _as_float(value: object) -> float | None:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
return None
|
||||||
|
number = float(value)
|
||||||
|
return number if math.isfinite(number) and number >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
def dashboard_poll_interval(
|
def dashboard_poll_interval(
|
||||||
*,
|
*,
|
||||||
operation_active: bool,
|
operation_active: bool,
|
||||||
@@ -5064,3 +5341,16 @@ def _format_bytes(value: int) -> str:
|
|||||||
|
|
||||||
def format_transfer_rate(bytes_per_second: float) -> str:
|
def format_transfer_rate(bytes_per_second: float) -> str:
|
||||||
return f"{_format_bytes(max(0, round(bytes_per_second)))}/s"
|
return f"{_format_bytes(max(0, round(bytes_per_second)))}/s"
|
||||||
|
|
||||||
|
|
||||||
|
def format_eta(seconds: float | None) -> str:
|
||||||
|
if seconds is None or not math.isfinite(seconds) or seconds < 0:
|
||||||
|
return "calculating…"
|
||||||
|
total_seconds = max(0, math.ceil(seconds))
|
||||||
|
hours, remainder = divmod(total_seconds, 60 * 60)
|
||||||
|
minutes, seconds = divmod(remainder, 60)
|
||||||
|
if hours:
|
||||||
|
return f"{hours}h {minutes:02d}m"
|
||||||
|
if minutes:
|
||||||
|
return f"{minutes}m {seconds:02d}s"
|
||||||
|
return f"{seconds}s"
|
||||||
|
|||||||
Reference in New Issue
Block a user