redfetch 1.4.3__tar.gz → 1.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {redfetch-1.4.3 → redfetch-1.5.0}/.gitignore +1 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/CHANGELOG.md +16 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/PKG-INFO +2 -2
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/__about__.py +2 -2
- redfetch-1.5.0/src/redfetch/__init__.py +4 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/auth.py +11 -41
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/config.py +37 -2
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/config_firstrun.py +1 -2
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/detecteq.py +0 -20
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/download.py +20 -15
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/listener.py +4 -4
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/main.py +125 -71
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/meta.py +80 -75
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/navmesh.py +70 -85
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/net.py +10 -1
- redfetch-1.5.0/src/redfetch/runtime_errors.py +57 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/sync.py +22 -25
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/sync_discovery.py +7 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/sync_planner.py +1 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/sync_remote.py +9 -3
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/sync_types.py +9 -19
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/terminal_ui.py +79 -119
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/terminal_ui.tcss +9 -17
- redfetch-1.5.0/src/redfetch/update_status.py +102 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/utils.py +8 -8
- {redfetch-1.4.3 → redfetch-1.5.0}/tests/cli_reference.py +1 -1
- {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_autologin_eqpath.py +0 -26
- {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_check.py +109 -26
- {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_close_gating.py +18 -36
- {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_env_selection.py +5 -8
- redfetch-1.5.0/tests/test_headless_update.py +327 -0
- redfetch-1.5.0/tests/test_listener.py +39 -0
- redfetch-1.5.0/tests/test_oauth_401_recovery.py +80 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_path_reset.py +0 -60
- {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_process_lock.py +20 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_remote_snapshot.py +0 -8
- {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_self_heal_eqpath.py +0 -12
- redfetch-1.5.0/tests/test_self_update_spawn.py +102 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_settings_local_deltas.py +16 -8
- {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_shortcuts.py +0 -19
- {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_staged_extraction.py +49 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_sync_pipeline_regressions.py +24 -15
- {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_sync_queue_rules.py +0 -2
- redfetch-1.4.3/src/redfetch/__init__.py +0 -0
- redfetch-1.4.3/src/redfetch/runtime_errors.py +0 -94
- redfetch-1.4.3/src/redfetch/special.py +0 -81
- redfetch-1.4.3/src/redfetch/update_status.py +0 -62
- redfetch-1.4.3/tests/test_is_special_or_dependency.py +0 -91
- {redfetch-1.4.3 → redfetch-1.5.0}/.github/workflows/pipeline.yml +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/AGENTS.md +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/LICENSE +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/README.md +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/pyapp_launcher.py +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/pyproject.toml +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/redfetch.ico +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/api.py +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/desktop_shortcut.py +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/post_update.py +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/processes.py +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/push.py +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/settings.toml +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/shortcuts.py +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/store.py +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/sync_executor.py +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_fetch_sync_info.py +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_first_run_setup.py +0 -0
- {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_licensed_resources_filtering.py +0 -0
|
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.5.0] - 2026-07-21
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Background updates triggered on each launch of "Very Vanilla MQ", so you no longer have to see redfetch. Run MacroQuest directly. Credit: all the TUI haters
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- Navigation meshes now download by default instead of prompting. Opt out in settings. (`PROTECTED_FILES_BY_RESOURCE` still protects your custom meshes)
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- pip/pipx in-app updates fixed on windows
|
|
18
|
+
|
|
19
|
+
## [1.4.4] - 2026-07-18
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- A rejected OAuth token will no longer assume you're level 1.
|
|
23
|
+
|
|
8
24
|
## [1.4.3] - 2026-07-06
|
|
9
25
|
|
|
10
26
|
### Added
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redfetch
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Download and publish EverQuest scripts and software using the RedGuides API
|
|
5
5
|
Project-URL: Homepage, https://www.redguides.com
|
|
6
6
|
Project-URL: Documentation, https://www.redguides.com/community/resources/redfetch.3177/
|
|
7
7
|
Project-URL: Repository, https://github.com/RedGuides/redfetch
|
|
8
8
|
Project-URL: Issues, https://github.com/RedGuides/redfetch/issues
|
|
9
9
|
Project-URL: Changelog, https://github.com/RedGuides/redfetch/blob/main/CHANGELOG.md
|
|
10
|
-
Project-URL: Source_archive, https://github.com/RedGuides/redfetch/archive/
|
|
10
|
+
Project-URL: Source_archive, https://github.com/RedGuides/redfetch/archive/9825ba1eaf7b60758f68420040e877f5bee93ddb.zip
|
|
11
11
|
Author-email: Redbot <ask@redguides.com>
|
|
12
12
|
License-Expression: GPL-3.0-or-later
|
|
13
13
|
License-File: LICENSE
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.
|
|
22
|
-
__version_tuple__ = version_tuple = (1,
|
|
21
|
+
__version__ = version = '1.5.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 5, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -55,7 +55,7 @@ def _get_setting(key: str, default=None):
|
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
# ---------------------------------------------------------------------------
|
|
58
|
-
# Disk cache for non-secret identity data (
|
|
58
|
+
# Disk cache for non-secret identity data (username, token_expiry)
|
|
59
59
|
# ---------------------------------------------------------------------------
|
|
60
60
|
|
|
61
61
|
_disk_cache = None
|
|
@@ -76,21 +76,6 @@ def _ensure_cache():
|
|
|
76
76
|
return _disk_cache
|
|
77
77
|
|
|
78
78
|
|
|
79
|
-
def get_disk_cache():
|
|
80
|
-
"""Return the shared disk cache (for non-identity caching by other modules)."""
|
|
81
|
-
return _ensure_cache()
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def set_user_id(user_id: str) -> None:
|
|
85
|
-
"""Store user_id in disk cache (non-sensitive public identifier)."""
|
|
86
|
-
_ensure_cache().set('user_id', str(user_id))
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def get_user_id() -> Optional[str]:
|
|
90
|
-
"""Retrieve user_id from disk cache."""
|
|
91
|
-
return _ensure_cache().get('user_id')
|
|
92
|
-
|
|
93
|
-
|
|
94
79
|
def set_username(username: str) -> None:
|
|
95
80
|
"""Store username in disk cache (non-sensitive public display name)."""
|
|
96
81
|
_ensure_cache().set('username', username)
|
|
@@ -241,7 +226,7 @@ def first_authorization(client_id: str, client_secret: str | None, *, scope: str
|
|
|
241
226
|
|
|
242
227
|
|
|
243
228
|
def _cache_user_info(access_token: str | None) -> None:
|
|
244
|
-
"""Fetch /api/me and cache username
|
|
229
|
+
"""Fetch /api/me and cache username (best-effort)."""
|
|
245
230
|
if not access_token:
|
|
246
231
|
return
|
|
247
232
|
headers = {"Authorization": f"Bearer {access_token}"}
|
|
@@ -251,8 +236,6 @@ def _cache_user_info(access_token: str | None) -> None:
|
|
|
251
236
|
me = data.get("me") or {}
|
|
252
237
|
if me.get("username"):
|
|
253
238
|
set_username(str(me["username"]))
|
|
254
|
-
if me.get("user_id"):
|
|
255
|
-
set_user_id(str(me["user_id"]))
|
|
256
239
|
|
|
257
240
|
|
|
258
241
|
def authorize():
|
|
@@ -381,7 +364,6 @@ def get_cached_tokens():
|
|
|
381
364
|
data["access_token"] = keyring.get_password(KEYRING_SERVICE_NAME, "access_token")
|
|
382
365
|
data["refresh_token"] = keyring.get_password(KEYRING_SERVICE_NAME, "refresh_token")
|
|
383
366
|
data["username"] = get_username_from_cache()
|
|
384
|
-
data["user_id"] = get_user_id()
|
|
385
367
|
return data
|
|
386
368
|
|
|
387
369
|
|
|
@@ -391,28 +373,19 @@ def logout():
|
|
|
391
373
|
|
|
392
374
|
keyring_credentials = ["access_token", "refresh_token", "api_key"]
|
|
393
375
|
legacy_credentials = ["user_id", "username", "expires_at"]
|
|
394
|
-
|
|
395
|
-
credentials_deleted = False
|
|
396
376
|
|
|
397
377
|
for credential in keyring_credentials + legacy_credentials:
|
|
398
378
|
try:
|
|
399
379
|
keyring.delete_password(KEYRING_SERVICE_NAME, credential)
|
|
400
|
-
credentials_deleted = True
|
|
401
380
|
except keyring.errors.PasswordDeleteError:
|
|
402
381
|
pass
|
|
403
382
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
pass
|
|
411
|
-
|
|
412
|
-
if credentials_deleted:
|
|
413
|
-
print("You have been logged out successfully.")
|
|
414
|
-
else:
|
|
415
|
-
print("No active session found. You were not logged in.")
|
|
383
|
+
# isolated so a locked cache can't block the remaining clears
|
|
384
|
+
for clear in (clear_disk_cache, meta.clear_pypi_cache, net.clear_manifest_cache):
|
|
385
|
+
try:
|
|
386
|
+
clear()
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
416
389
|
|
|
417
390
|
|
|
418
391
|
# ---------------------------------------------------------------------------
|
|
@@ -434,23 +407,21 @@ async def fetch_me(client: httpx.AsyncClient) -> Optional[dict]:
|
|
|
434
407
|
|
|
435
408
|
|
|
436
409
|
async def fetch_user_id_from_api(api_key):
|
|
437
|
-
"""Fetch user_id using the API key
|
|
410
|
+
"""Fetch user_id using the API key."""
|
|
438
411
|
async with httpx.AsyncClient(headers={'XF-Api-Key': api_key}, http2=True) as client:
|
|
439
412
|
me = await fetch_me(client)
|
|
440
413
|
if me:
|
|
441
|
-
set_user_id(me['user_id'])
|
|
442
414
|
return me['user_id']
|
|
443
415
|
return None
|
|
444
416
|
|
|
445
417
|
|
|
446
418
|
async def fetch_username(api_key, cache=True):
|
|
447
|
-
"""Fetch username via API key; caches username
|
|
419
|
+
"""Fetch username via API key; caches username."""
|
|
448
420
|
async with httpx.AsyncClient(headers={'XF-Api-Key': api_key}, http2=True) as client:
|
|
449
421
|
me = await fetch_me(client)
|
|
450
422
|
if me:
|
|
451
423
|
if cache:
|
|
452
424
|
set_username(me['username'])
|
|
453
|
-
set_user_id(me['user_id'])
|
|
454
425
|
return me['username']
|
|
455
426
|
return "Unknown"
|
|
456
427
|
|
|
@@ -499,7 +470,7 @@ async def get_api_headers():
|
|
|
499
470
|
if access_token:
|
|
500
471
|
return {"Authorization": f"Bearer {access_token}"}
|
|
501
472
|
|
|
502
|
-
raise RuntimeError("OAuth token refresh failed.
|
|
473
|
+
raise RuntimeError("OAuth token refresh failed. Restart redfetch to authorize again.")
|
|
503
474
|
|
|
504
475
|
raise RuntimeError("OAuth access token is expired and no refresh token is available. Please authorize again.")
|
|
505
476
|
|
|
@@ -534,7 +505,6 @@ async def get_username():
|
|
|
534
505
|
me = await fetch_me(client)
|
|
535
506
|
if me and me.get("username"):
|
|
536
507
|
set_username(me["username"])
|
|
537
|
-
set_user_id(me["user_id"])
|
|
538
508
|
return me["username"]
|
|
539
509
|
raise RuntimeError("Unable to retrieve username using the stored OAuth token.")
|
|
540
510
|
|
|
@@ -4,12 +4,14 @@ import os
|
|
|
4
4
|
import platform
|
|
5
5
|
import re
|
|
6
6
|
import shutil
|
|
7
|
+
import tomllib
|
|
7
8
|
|
|
8
9
|
# third-party
|
|
9
10
|
import tomlkit
|
|
10
11
|
from dynaconf import Dynaconf, Validator, ValidationError
|
|
11
12
|
from dynaconf.loaders import env_loader
|
|
12
13
|
from platformdirs import user_config_dir, user_data_dir
|
|
14
|
+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
|
|
13
15
|
|
|
14
16
|
# Parent Category to folder
|
|
15
17
|
CATEGORY_MAP = {
|
|
@@ -58,7 +60,6 @@ BREADCRUMB_FILENAME = "last_command.json"
|
|
|
58
60
|
DEFAULT_CONFIG_DIR = user_config_dir("redfetch", "RedGuides")
|
|
59
61
|
|
|
60
62
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
61
|
-
os.environ['REDFETCH_SCRIPT_DIR'] = script_dir
|
|
62
63
|
|
|
63
64
|
# Populated by initialize_config()
|
|
64
65
|
config_dir = None
|
|
@@ -139,6 +140,9 @@ def initialize_config():
|
|
|
139
140
|
atomic_write_text(env_file_path, 'REDFETCH_ENV=LIVE\n')
|
|
140
141
|
print(f".env file created with default environment set to 'LIVE' at {env_file_path}")
|
|
141
142
|
|
|
143
|
+
# Migrate any settings.local.toml written by older versions
|
|
144
|
+
_migrate_local_settings(config_dir)
|
|
145
|
+
|
|
142
146
|
# Initialize Dynaconf settings
|
|
143
147
|
settings = Dynaconf(
|
|
144
148
|
envvar_prefix="REDFETCH",
|
|
@@ -210,6 +214,17 @@ def _resolve_redfetch_executable():
|
|
|
210
214
|
return None
|
|
211
215
|
|
|
212
216
|
|
|
217
|
+
@retry(
|
|
218
|
+
retry=retry_if_exception_type(PermissionError),
|
|
219
|
+
stop=stop_after_attempt(5),
|
|
220
|
+
wait=wait_fixed(0.1),
|
|
221
|
+
reraise=True,
|
|
222
|
+
)
|
|
223
|
+
def _replace_with_retry(src: str, dst: str) -> None:
|
|
224
|
+
# MQ reads update_status.json
|
|
225
|
+
os.replace(src, dst)
|
|
226
|
+
|
|
227
|
+
|
|
213
228
|
def atomic_write_text(path: str, text: str) -> None:
|
|
214
229
|
"""Write UTF-8 text to `path` via a temp file + os.replace() so readers never see a partial write."""
|
|
215
230
|
directory = os.path.dirname(path)
|
|
@@ -218,7 +233,7 @@ def atomic_write_text(path: str, text: str) -> None:
|
|
|
218
233
|
tmp_path = f"{path}.tmp"
|
|
219
234
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
220
235
|
f.write(text)
|
|
221
|
-
|
|
236
|
+
_replace_with_retry(tmp_path, path)
|
|
222
237
|
|
|
223
238
|
|
|
224
239
|
def atomic_write_json(path: str, data) -> None:
|
|
@@ -435,6 +450,26 @@ def save_config(file_path, config_data):
|
|
|
435
450
|
atomic_write_text(file_path, toml_text)
|
|
436
451
|
|
|
437
452
|
|
|
453
|
+
def _migrate_local_settings(config_dir):
|
|
454
|
+
"""Carry renamed settings in an older settings.local.toml"""
|
|
455
|
+
config_file = os.path.join(config_dir, 'settings.local.toml')
|
|
456
|
+
if not os.path.exists(config_file):
|
|
457
|
+
return
|
|
458
|
+
try:
|
|
459
|
+
with open(config_file, "rb") as f:
|
|
460
|
+
data = tomllib.load(f)
|
|
461
|
+
except Exception:
|
|
462
|
+
return
|
|
463
|
+
# NAVMESH_OPT_IN became NAVMESH_DOWNLOADS
|
|
464
|
+
changed = False
|
|
465
|
+
for env_table in data.values():
|
|
466
|
+
if isinstance(env_table, dict) and "NAVMESH_OPT_IN" in env_table:
|
|
467
|
+
env_table.setdefault("NAVMESH_DOWNLOADS", env_table.pop("NAVMESH_OPT_IN"))
|
|
468
|
+
changed = True
|
|
469
|
+
if changed:
|
|
470
|
+
save_config(config_file, data)
|
|
471
|
+
|
|
472
|
+
|
|
438
473
|
def update_setting(setting_path, setting_value, env=None):
|
|
439
474
|
"""Update a specific setting in the settings.local.toml file and in memory,
|
|
440
475
|
optionally within a specific environment."""
|
|
@@ -460,8 +460,7 @@ def first_run_setup():
|
|
|
460
460
|
default_choice = bool(os.environ.get("PYAPP"))
|
|
461
461
|
|
|
462
462
|
console.print(
|
|
463
|
-
"\n[bold cyan][italic]\"
|
|
464
|
-
"when thou list?\"[/italic][/bold cyan]"
|
|
463
|
+
"\n[bold cyan][italic]\"Shall we set a signe upon thy fair anime walpaper?\"[/italic][/bold cyan]"
|
|
465
464
|
)
|
|
466
465
|
wants_shortcut = CustomConfirm.ask(
|
|
467
466
|
"Create Desktop shortcut?",
|
|
@@ -91,26 +91,6 @@ def find_everquest_uninstall_location():
|
|
|
91
91
|
except OSError:
|
|
92
92
|
pass
|
|
93
93
|
|
|
94
|
-
# 3) HKU: enumerate SIDs as final fallback
|
|
95
|
-
try:
|
|
96
|
-
with reg.ConnectRegistry(None, reg.HKEY_USERS) as hku:
|
|
97
|
-
i = 0
|
|
98
|
-
while True:
|
|
99
|
-
try:
|
|
100
|
-
sid = reg.EnumKey(hku, i)
|
|
101
|
-
i += 1
|
|
102
|
-
subkey = rf"{sid}\{base_uninstall}"
|
|
103
|
-
for valname in ("InstallLocation", "DisplayIcon", "UninstallString"):
|
|
104
|
-
val = _read_reg_value(hku, subkey, valname)
|
|
105
|
-
if val:
|
|
106
|
-
candidate = _extract_dir_from_value(str(val))
|
|
107
|
-
if _is_valid_eq_dir(candidate):
|
|
108
|
-
return candidate
|
|
109
|
-
except OSError:
|
|
110
|
-
break
|
|
111
|
-
except OSError:
|
|
112
|
-
pass
|
|
113
|
-
|
|
114
94
|
return None
|
|
115
95
|
|
|
116
96
|
|
|
@@ -7,7 +7,7 @@ import sys
|
|
|
7
7
|
import time
|
|
8
8
|
import zlib
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from zipfile import ZipFile, is_zipfile
|
|
10
|
+
from zipfile import BadZipFile, ZipFile, is_zipfile
|
|
11
11
|
import asyncio
|
|
12
12
|
|
|
13
13
|
# third-party
|
|
@@ -104,7 +104,17 @@ async def download_file_async(
|
|
|
104
104
|
if tmp_path is None:
|
|
105
105
|
return False
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
try:
|
|
108
|
+
displaced = _swap_into_place(tmp_path, file_path)
|
|
109
|
+
except PermissionError:
|
|
110
|
+
_report_locked_target(file_path)
|
|
111
|
+
return False
|
|
112
|
+
except OSError as e:
|
|
113
|
+
print(f"Could not update {os.path.basename(file_path)}: {e}")
|
|
114
|
+
_remove_if_exists(tmp_path)
|
|
115
|
+
return False
|
|
116
|
+
if displaced:
|
|
117
|
+
print(f"Staged update for {os.path.basename(file_path)}; applies on next launch.")
|
|
108
118
|
print(f"Downloaded file {file_path}")
|
|
109
119
|
return True
|
|
110
120
|
finally:
|
|
@@ -181,15 +191,6 @@ def extract_and_discard_zip(zip_path, extract_to, resource_id, should_flatten=Fa
|
|
|
181
191
|
return False
|
|
182
192
|
|
|
183
193
|
with ZipFile(zip_path, 'r') as zip_ref:
|
|
184
|
-
try:
|
|
185
|
-
bad_member = zip_ref.testzip()
|
|
186
|
-
if bad_member is not None:
|
|
187
|
-
print(f"ZIP integrity check failed at member: {bad_member}. Extraction aborted.")
|
|
188
|
-
return False
|
|
189
|
-
except Exception as e:
|
|
190
|
-
print(f"ZIP integrity check failed: {e}. Extraction aborted.")
|
|
191
|
-
return False
|
|
192
|
-
|
|
193
194
|
infos = zip_ref.infolist()
|
|
194
195
|
if len(infos) > MAX_FILES_PER_ZIP:
|
|
195
196
|
print(f"ZIP has too many files ({len(infos)} > {MAX_FILES_PER_ZIP}). Extraction aborted.")
|
|
@@ -211,10 +212,14 @@ def extract_and_discard_zip(zip_path, extract_to, resource_id, should_flatten=Fa
|
|
|
211
212
|
if protected_files is None:
|
|
212
213
|
protected_files = config.settings.from_env(config.settings.ENV).PROTECTED_FILES_BY_RESOURCE.get(resource_id, [])
|
|
213
214
|
sweep_stale_swap_files(extract_to)
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
215
|
+
try:
|
|
216
|
+
if should_flatten:
|
|
217
|
+
extract_flattened(zip_ref, extract_to, protected_files)
|
|
218
|
+
else:
|
|
219
|
+
extract_with_structure(zip_ref, extract_to, protected_files)
|
|
220
|
+
except (BadZipFile, zlib.error) as e: # bad CRC raises BadZipFile; truncated deflate raises zlib.error
|
|
221
|
+
print(f"ZIP integrity check failed: {e}. Extraction aborted.")
|
|
222
|
+
return False
|
|
218
223
|
return True
|
|
219
224
|
finally:
|
|
220
225
|
delete_zip_file(zip_path)
|
|
@@ -7,9 +7,9 @@ from typing import Any, Dict, Optional
|
|
|
7
7
|
|
|
8
8
|
from aiohttp import web
|
|
9
9
|
|
|
10
|
+
from redfetch import config
|
|
10
11
|
from redfetch import store
|
|
11
12
|
from redfetch import sync
|
|
12
|
-
from redfetch.special import compute_special_status
|
|
13
13
|
|
|
14
14
|
log = logging.getLogger(__name__)
|
|
15
15
|
|
|
@@ -169,9 +169,9 @@ async def handle_category_map(request: web.Request) -> web.Response:
|
|
|
169
169
|
|
|
170
170
|
|
|
171
171
|
async def handle_special_resource_ids(request: web.Request) -> web.Response:
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return web.json_response(
|
|
172
|
+
# from_env, not bare settings: --server swaps settings.ENV at runtime
|
|
173
|
+
special_resources = config.settings.from_env(config.settings.ENV).SPECIAL_RESOURCES
|
|
174
|
+
return web.json_response([int(rid) for rid, d in special_resources.items() if d.get('opt_in', False)])
|
|
175
175
|
|
|
176
176
|
|
|
177
177
|
async def create_app(
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# standard imports
|
|
2
2
|
import sys
|
|
3
3
|
import os
|
|
4
|
+
from contextlib import contextmanager
|
|
4
5
|
from enum import Enum
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Optional
|
|
7
8
|
import asyncio
|
|
8
9
|
|
|
9
10
|
# third-party imports
|
|
10
|
-
from rich.prompt import
|
|
11
|
+
from rich.prompt import Prompt
|
|
11
12
|
from rich.console import Console
|
|
12
13
|
import typer
|
|
13
14
|
|
|
@@ -25,6 +26,7 @@ from redfetch import sync
|
|
|
25
26
|
from redfetch import store
|
|
26
27
|
from redfetch import shortcuts
|
|
27
28
|
from redfetch.runtime_errors import exit_with_fatal_error
|
|
29
|
+
from redfetch.sync_types import SyncOutcome
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
app = typer.Typer(
|
|
@@ -43,13 +45,10 @@ class Env(str, Enum):
|
|
|
43
45
|
|
|
44
46
|
def parse_resource_id_or_fail(value: str) -> str:
|
|
45
47
|
"""Accept either an integer ID or a URL that includes a recognizable ID."""
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if parsed is None:
|
|
51
|
-
raise typer.BadParameter("Provide a resource ID or a recognized RedGuides URL.")
|
|
52
|
-
return parsed
|
|
48
|
+
try:
|
|
49
|
+
return utils.parse_resource_id(value.strip())
|
|
50
|
+
except ValueError as exc:
|
|
51
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
53
52
|
|
|
54
53
|
|
|
55
54
|
def _apply_server_override(server: "Optional[Env]" = None) -> None:
|
|
@@ -78,41 +77,6 @@ def initialize_db_only(server: "Optional[Env]" = None):
|
|
|
78
77
|
|
|
79
78
|
# ===== CLI prompt helpers =====
|
|
80
79
|
|
|
81
|
-
def prompt_navmesh_opt_in() -> bool | None:
|
|
82
|
-
"""Prompt user about navmesh downloads if not configured."""
|
|
83
|
-
from redfetch import navmesh
|
|
84
|
-
|
|
85
|
-
# Check if VVMQ path exists (navmesh requires it)
|
|
86
|
-
vvmq_path = utils.get_vvmq_path()
|
|
87
|
-
if not vvmq_path:
|
|
88
|
-
return None # Can't do navmesh without VVMQ
|
|
89
|
-
|
|
90
|
-
opt_in = navmesh.get_navmesh_opt_in()
|
|
91
|
-
|
|
92
|
-
if opt_in is not None:
|
|
93
|
-
return None # Already configured, use existing setting
|
|
94
|
-
|
|
95
|
-
# Prompt user
|
|
96
|
-
user_choice = Prompt.ask(
|
|
97
|
-
"🧭 Download navigation meshes? (will overwrite, protect your custom meshes in settings.local.toml)",
|
|
98
|
-
choices=["yes", "no", "always", "never"],
|
|
99
|
-
default="yes",
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
if user_choice == "yes":
|
|
103
|
-
return True # One-time yes
|
|
104
|
-
elif user_choice == "always":
|
|
105
|
-
config.update_setting(["NAVMESH_OPT_IN"], True)
|
|
106
|
-
console.print("Updated settings to always download navmeshes.")
|
|
107
|
-
return None # Config is now set, use it
|
|
108
|
-
elif user_choice == "never":
|
|
109
|
-
config.update_setting(["NAVMESH_OPT_IN"], False)
|
|
110
|
-
console.print("Updated settings to never download navmeshes.")
|
|
111
|
-
return None # Config is now set, use it
|
|
112
|
-
else: # "no"
|
|
113
|
-
return False # One-time no
|
|
114
|
-
|
|
115
|
-
|
|
116
80
|
class _CliPostUpdate:
|
|
117
81
|
"""CLI adapter for post_update.execute: rich prompts, console output."""
|
|
118
82
|
|
|
@@ -162,19 +126,8 @@ async def handle_download_watched_async(db_path: str, headers: dict) -> bool:
|
|
|
162
126
|
console.print(
|
|
163
127
|
"[bold yellow]Warning:[/bold yellow] [blink bold red]MQ appears to be down[/blink bold red] for a patch, so it's not likely to work."
|
|
164
128
|
)
|
|
165
|
-
continue_download = Confirm.ask(
|
|
166
|
-
"Do you want to continue with the download?", default=False
|
|
167
|
-
)
|
|
168
|
-
if not continue_download:
|
|
169
|
-
console.print("Download cancelled by user.")
|
|
170
|
-
return False
|
|
171
129
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
outcome = await sync.run_sync(
|
|
175
|
-
db_path, headers,
|
|
176
|
-
navmesh_override=navmesh_override,
|
|
177
|
-
)
|
|
130
|
+
outcome = await sync.run_sync(db_path, headers)
|
|
178
131
|
# offer regardless of overall success:
|
|
179
132
|
await post_update.offer(outcome, _CliPostUpdate())
|
|
180
133
|
return outcome.success
|
|
@@ -216,11 +169,113 @@ async def download_command_async(db_name: str, db_path: str, id_or_url: str, for
|
|
|
216
169
|
def update_command(
|
|
217
170
|
force: bool = typer.Option(False, "--force", "-f", help="Force re-download of all watched resources."),
|
|
218
171
|
server: Optional[Env] = typer.Option(None, "--server", "-s", case_sensitive=False, help="Update this server for this run only, without changing your current server ([green]LIVE[/green], [yellow]TEST[/yellow], [cyan]EMU[/cyan])."),
|
|
172
|
+
headless: bool = typer.Option(False, "--headless", hidden=True, help="MQ silent update: no prompts, no browser, no dialogs. writes update_status.json."),
|
|
219
173
|
):
|
|
174
|
+
if headless:
|
|
175
|
+
_headless_update(server=server, force=force)
|
|
176
|
+
return
|
|
220
177
|
db_name, db_path = initialize_db_only(server=server)
|
|
221
178
|
asyncio.run(update_command_async(db_name=db_name, db_path=db_path, force=force))
|
|
222
179
|
|
|
223
180
|
|
|
181
|
+
@contextmanager
|
|
182
|
+
def _exit_silently_on_error():
|
|
183
|
+
"""Anything unexpected for headless commands becomes a silent exit 1."""
|
|
184
|
+
try:
|
|
185
|
+
yield
|
|
186
|
+
except typer.Exit:
|
|
187
|
+
raise
|
|
188
|
+
except Exception:
|
|
189
|
+
raise typer.Exit(1)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _headless_update(server: "Optional[Env]", force: bool) -> None:
|
|
193
|
+
from redfetch.config_firstrun import is_configured
|
|
194
|
+
from redfetch import update_status
|
|
195
|
+
|
|
196
|
+
requested_env = server.value if server else None
|
|
197
|
+
|
|
198
|
+
with _exit_silently_on_error():
|
|
199
|
+
if not is_configured():
|
|
200
|
+
update_status.write_update_status(
|
|
201
|
+
env=requested_env or Env.LIVE.value,
|
|
202
|
+
auth_state="not_configured",
|
|
203
|
+
)
|
|
204
|
+
raise typer.Exit(0)
|
|
205
|
+
|
|
206
|
+
config.initialize_config()
|
|
207
|
+
if requested_env:
|
|
208
|
+
config.select_environment_in_memory(requested_env)
|
|
209
|
+
env = config.settings.ENV
|
|
210
|
+
auth.initialize_keyring()
|
|
211
|
+
|
|
212
|
+
auto_update = utils.is_auto_update_enabled()
|
|
213
|
+
if not auto_update:
|
|
214
|
+
raise typer.Exit(1)
|
|
215
|
+
|
|
216
|
+
managed_path = utils.get_vvmq_path()
|
|
217
|
+
|
|
218
|
+
if not _has_auth_credentials():
|
|
219
|
+
update_status.write_update_status(
|
|
220
|
+
env=env, auth_state="needs_login", managed_path=managed_path, auto_update=auto_update,
|
|
221
|
+
)
|
|
222
|
+
raise typer.Exit(0)
|
|
223
|
+
|
|
224
|
+
db_name = f"{env}_resources.db"
|
|
225
|
+
store.initialize_db(db_name)
|
|
226
|
+
db_path = store.get_db_path(db_name)
|
|
227
|
+
|
|
228
|
+
if force:
|
|
229
|
+
with store.get_db_connection(db_name) as conn:
|
|
230
|
+
store.reset_download_dates(conn.cursor())
|
|
231
|
+
|
|
232
|
+
outcome = asyncio.run(_headless_update_async(db_path))
|
|
233
|
+
|
|
234
|
+
if outcome is None:
|
|
235
|
+
# Mid-run silent-refresh failure handling
|
|
236
|
+
update_status.write_update_status(
|
|
237
|
+
env=env, auth_state="needs_login", managed_path=managed_path, auto_update=auto_update,
|
|
238
|
+
)
|
|
239
|
+
raise typer.Exit(0)
|
|
240
|
+
|
|
241
|
+
if outcome.execution_plan is None or outcome.execution_result is None:
|
|
242
|
+
raise typer.Exit(1) # busy or cancelled: nothing ran, nothing fresh
|
|
243
|
+
|
|
244
|
+
installed, remaining = update_status.split_items_by_outcome(
|
|
245
|
+
outcome.execution_plan, outcome.execution_result
|
|
246
|
+
)
|
|
247
|
+
vvmq_version = None
|
|
248
|
+
if outcome.vvmq_updated:
|
|
249
|
+
vvmq_id = utils.get_current_vvmq_id(env)
|
|
250
|
+
vvmq_version = next(
|
|
251
|
+
(item["version"] for item in installed if item["resource_id"] == vvmq_id), None
|
|
252
|
+
)
|
|
253
|
+
update_status.write_update_status(
|
|
254
|
+
env=env,
|
|
255
|
+
auth_state="ok",
|
|
256
|
+
items=remaining,
|
|
257
|
+
managed_path=managed_path,
|
|
258
|
+
auto_update=auto_update,
|
|
259
|
+
installed=installed,
|
|
260
|
+
pending_restart=outcome.vvmq_updated,
|
|
261
|
+
pending_restart_version=vvmq_version,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# the swap starts only when this run has nothing left to do.
|
|
265
|
+
meta.spawn_silent_self_update()
|
|
266
|
+
raise typer.Exit(0)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def _headless_update_async(db_path: str) -> SyncOutcome | None:
|
|
270
|
+
"""Returns the SyncOutcome or needs_login."""
|
|
271
|
+
await asyncio.to_thread(utils.sweep_stale_update_debris)
|
|
272
|
+
try:
|
|
273
|
+
headers = await auth.get_api_headers()
|
|
274
|
+
except RuntimeError:
|
|
275
|
+
return None
|
|
276
|
+
return await sync.run_sync(db_path, headers)
|
|
277
|
+
|
|
278
|
+
|
|
224
279
|
@app.command(
|
|
225
280
|
"download",
|
|
226
281
|
help="Download a specific resource by ID or URL.",
|
|
@@ -265,15 +320,14 @@ def check_command(
|
|
|
265
320
|
|
|
266
321
|
requested_env = server.value if server else None
|
|
267
322
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
323
|
+
with _exit_silently_on_error():
|
|
324
|
+
if not is_configured():
|
|
325
|
+
update_status.write_update_status(
|
|
326
|
+
env=requested_env or Env.LIVE.value,
|
|
327
|
+
auth_state="not_configured",
|
|
328
|
+
)
|
|
329
|
+
raise typer.Exit(0)
|
|
275
330
|
|
|
276
|
-
try:
|
|
277
331
|
config.initialize_config()
|
|
278
332
|
# Honor --server for this run only; never persist (a "check" must not change the user's env).
|
|
279
333
|
if requested_env:
|
|
@@ -284,8 +338,12 @@ def check_command(
|
|
|
284
338
|
# MQ matches this against its own root to ignore stray copies.
|
|
285
339
|
managed_path = utils.get_vvmq_path()
|
|
286
340
|
|
|
341
|
+
auto_update = utils.is_auto_update_enabled()
|
|
342
|
+
|
|
287
343
|
if not _has_auth_credentials():
|
|
288
|
-
update_status.write_update_status(
|
|
344
|
+
update_status.write_update_status(
|
|
345
|
+
env=env, auth_state="needs_login", managed_path=managed_path, auto_update=auto_update,
|
|
346
|
+
)
|
|
289
347
|
raise typer.Exit(0)
|
|
290
348
|
|
|
291
349
|
db_name = f"{env}_resources.db"
|
|
@@ -293,15 +351,11 @@ def check_command(
|
|
|
293
351
|
db_path = store.get_db_path(db_name)
|
|
294
352
|
|
|
295
353
|
auth_state, items = asyncio.run(_check_command_async(db_path))
|
|
296
|
-
update_status.write_update_status(
|
|
354
|
+
update_status.write_update_status(
|
|
355
|
+
env=env, auth_state=auth_state, items=items, managed_path=managed_path, auto_update=auto_update,
|
|
356
|
+
)
|
|
297
357
|
raise typer.Exit(0)
|
|
298
358
|
|
|
299
|
-
except typer.Exit:
|
|
300
|
-
raise
|
|
301
|
-
except Exception:
|
|
302
|
-
# Transient failure, no touch to update_status.json so we know checked_at didn't advance this cycle.
|
|
303
|
-
raise typer.Exit(1)
|
|
304
|
-
|
|
305
359
|
|
|
306
360
|
async def _check_command_async(db_path: str) -> tuple[str, list[dict] | None]:
|
|
307
361
|
from redfetch import update_status
|