redfetch 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- redfetch/__about__.py +24 -0
- redfetch/__init__.py +0 -0
- redfetch/api.py +134 -0
- redfetch/auth.py +573 -0
- redfetch/config.py +483 -0
- redfetch/config_firstrun.py +455 -0
- redfetch/desktop_shortcut.py +81 -0
- redfetch/detecteq.py +116 -0
- redfetch/download.py +312 -0
- redfetch/listener.py +216 -0
- redfetch/main.py +744 -0
- redfetch/meta.py +561 -0
- redfetch/navmesh.py +371 -0
- redfetch/net.py +109 -0
- redfetch/processes.py +118 -0
- redfetch/push.py +246 -0
- redfetch/redfetch.ico +0 -0
- redfetch/runtime_errors.py +96 -0
- redfetch/settings.toml +303 -0
- redfetch/special.py +81 -0
- redfetch/store.py +505 -0
- redfetch/sync.py +261 -0
- redfetch/sync_discovery.py +352 -0
- redfetch/sync_executor.py +164 -0
- redfetch/sync_planner.py +196 -0
- redfetch/sync_remote.py +182 -0
- redfetch/sync_types.py +348 -0
- redfetch/terminal_ui.py +2318 -0
- redfetch/terminal_ui.tcss +569 -0
- redfetch/unloadmq.py +148 -0
- redfetch/update_status.py +62 -0
- redfetch/utils.py +256 -0
- redfetch-1.3.0.dist-info/METADATA +257 -0
- redfetch-1.3.0.dist-info/RECORD +37 -0
- redfetch-1.3.0.dist-info/WHEEL +4 -0
- redfetch-1.3.0.dist-info/entry_points.txt +2 -0
- redfetch-1.3.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""Interactive first-run wizard(s)"""
|
|
2
|
+
|
|
3
|
+
# Standard
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
# Third-party
|
|
9
|
+
from platformdirs import user_config_dir
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.prompt import Prompt, Confirm, PromptBase
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from rich.box import ASCII2
|
|
15
|
+
from tomlkit import table, TOMLDocument
|
|
16
|
+
|
|
17
|
+
# Custom
|
|
18
|
+
from redfetch import utils
|
|
19
|
+
from redfetch.config import load_config, save_config
|
|
20
|
+
from redfetch.detecteq import find_everquest_uninstall_location
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# zork-like prompt
|
|
26
|
+
class CustomPrompt(PromptBase):
|
|
27
|
+
prompt_suffix = " > "
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CustomConfirm(Confirm):
|
|
31
|
+
validate_error_message = "[prompt.invalid]The wizards look at you with a blank stare."
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_rg_utility_paths():
|
|
35
|
+
"""Returns relevant paths from RedGuides Desktop Utility if installed."""
|
|
36
|
+
settings_path = r"C:\ProgramData\RedGuides\DesktopUtility\settings\settings.json"
|
|
37
|
+
relevant_keys = [
|
|
38
|
+
"MQNextInstallLocation",
|
|
39
|
+
"MQNextTestInstallLocation",
|
|
40
|
+
"EmuInstallLocation",
|
|
41
|
+
"MySeqLiveInstallLocation",
|
|
42
|
+
"MySeqTestInstallLocation"
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
if not os.path.exists(settings_path):
|
|
46
|
+
return {}
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
with open(settings_path, 'r') as f:
|
|
50
|
+
settings = json.load(f)
|
|
51
|
+
return {k: v for k, v in settings.items()
|
|
52
|
+
if k in relevant_keys and v} # Only return non-empty paths
|
|
53
|
+
except Exception:
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def setup_directories():
|
|
58
|
+
default_config_dir = user_config_dir("redfetch", "RedGuides")
|
|
59
|
+
windows_public_dir = os.path.expandvars(r'%PUBLIC%\redfetch') if platform.system() == "Windows" else None
|
|
60
|
+
|
|
61
|
+
options = []
|
|
62
|
+
if windows_public_dir:
|
|
63
|
+
options.append(f"1. Windows Public Directory ({windows_public_dir})")
|
|
64
|
+
options.append(f"2. OS Config Directory ({default_config_dir})")
|
|
65
|
+
options.append("3. Custom Directory")
|
|
66
|
+
default_choice = '1'
|
|
67
|
+
else:
|
|
68
|
+
options.append(f"1. OS Config Directory ({default_config_dir})")
|
|
69
|
+
options.append("2. Custom Directory")
|
|
70
|
+
default_choice = '1'
|
|
71
|
+
|
|
72
|
+
choice_text = "\n".join(options)
|
|
73
|
+
panel = Panel(
|
|
74
|
+
Text(choice_text, style="cyan"),
|
|
75
|
+
expand=False
|
|
76
|
+
)
|
|
77
|
+
console.print(panel)
|
|
78
|
+
|
|
79
|
+
choice = Prompt.ask("Enter your choice", choices=[str(i) for i in range(1, len(options) + 1)], default=default_choice)
|
|
80
|
+
|
|
81
|
+
if (windows_public_dir and choice == '3') or (not windows_public_dir and choice == '2'):
|
|
82
|
+
# User wants a custom directory
|
|
83
|
+
custom_dir = Prompt.ask("Enter the path to your custom directory")
|
|
84
|
+
custom_dir = os.path.expanduser(os.path.normpath(custom_dir))
|
|
85
|
+
|
|
86
|
+
# Check if the directory exists first
|
|
87
|
+
if not os.path.isdir(custom_dir):
|
|
88
|
+
console.print(f"[yellow]Directory does not exist: {custom_dir}[/yellow]")
|
|
89
|
+
create_dir = CustomConfirm.ask("Would you like to create this directory?")
|
|
90
|
+
if create_dir:
|
|
91
|
+
try:
|
|
92
|
+
os.makedirs(custom_dir, exist_ok=True)
|
|
93
|
+
console.print(f"[green]Directory created: {custom_dir}[/green]")
|
|
94
|
+
except Exception as e:
|
|
95
|
+
console.print(f"[bold red]Error creating directory: {e}[/bold red]")
|
|
96
|
+
console.print("[yellow]We will restart the directory selection process now.[/yellow]")
|
|
97
|
+
Prompt.ask("\nPress Enter to continue")
|
|
98
|
+
# Restart the directory selection process on failure
|
|
99
|
+
return setup_directories()
|
|
100
|
+
else:
|
|
101
|
+
console.print("\n[bold yellow]Directory creation canceled. We'll restart the selection process now.[/bold yellow]")
|
|
102
|
+
Prompt.ask("\nPress Enter to continue")
|
|
103
|
+
return setup_directories()
|
|
104
|
+
|
|
105
|
+
# At this point, the directory should exist (either pre-existing or newly created).
|
|
106
|
+
if utils.validate_file_in_path(custom_dir, 'eqgame.exe'):
|
|
107
|
+
console.print(
|
|
108
|
+
Panel(
|
|
109
|
+
Text.from_markup(
|
|
110
|
+
"[bold red blink]WHAT THE FUCK ARE YOU DOING?!?!![/bold red blink]\n"
|
|
111
|
+
"There's an EQGame.exe in this path!!\n"
|
|
112
|
+
"Please select a different folder, please, thank you in advance. :pray:",
|
|
113
|
+
justify="center"
|
|
114
|
+
),
|
|
115
|
+
title="[bold underline red]Critical Warning[/bold underline red]",
|
|
116
|
+
border_style="bold red",
|
|
117
|
+
expand=False
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
choice = CustomPrompt.ask(
|
|
121
|
+
"[o]k, / [f] you, I'm staying",
|
|
122
|
+
choices=["o", "f"],
|
|
123
|
+
default="o",
|
|
124
|
+
)
|
|
125
|
+
if choice == "o":
|
|
126
|
+
return setup_directories()
|
|
127
|
+
|
|
128
|
+
# Use the custom directory if it exists and is valid
|
|
129
|
+
config_dir = custom_dir
|
|
130
|
+
|
|
131
|
+
elif windows_public_dir and choice == '1':
|
|
132
|
+
config_dir = windows_public_dir
|
|
133
|
+
else:
|
|
134
|
+
config_dir = default_config_dir
|
|
135
|
+
|
|
136
|
+
os.makedirs(config_dir, exist_ok=True)
|
|
137
|
+
return config_dir
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def create_first_run_flag(default_config_dir, chosen_config_dir):
|
|
141
|
+
os.makedirs(default_config_dir, exist_ok=True)
|
|
142
|
+
first_run_flag = os.path.join(default_config_dir, 'first_run_complete')
|
|
143
|
+
with open(first_run_flag, 'w') as f:
|
|
144
|
+
f.write(chosen_config_dir)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_first_run(default_config_dir):
|
|
148
|
+
first_run_flag = os.path.join(default_config_dir, 'first_run_complete')
|
|
149
|
+
return not os.path.exists(first_run_flag)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def is_configured(default_config_dir: str | None = None) -> bool:
|
|
153
|
+
"""Can initialize_config() proceed without interactive prompts?"""
|
|
154
|
+
default_config_dir = default_config_dir or user_config_dir("redfetch", "RedGuides")
|
|
155
|
+
try:
|
|
156
|
+
with open(os.path.join(default_config_dir, "first_run_complete")) as f:
|
|
157
|
+
config_dir = f.read().strip()
|
|
158
|
+
except OSError:
|
|
159
|
+
return False
|
|
160
|
+
return os.path.exists(os.path.join(config_dir, ".env"))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_or_create_table(doc: TOMLDocument, table_path: str):
|
|
164
|
+
"""Navigate or create nested tables in the TOML document."""
|
|
165
|
+
current_section = doc
|
|
166
|
+
for key_name in table_path.split('.'):
|
|
167
|
+
if key_name not in current_section:
|
|
168
|
+
current_section.add(key_name, table())
|
|
169
|
+
current_section = current_section[key_name]
|
|
170
|
+
return current_section
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def update_setting(doc: TOMLDocument, table_path: str, key_name: str, new_value, friendly_name: str):
|
|
174
|
+
"""Update a setting in the TOML document with user confirmation."""
|
|
175
|
+
current_section = get_or_create_table(doc, table_path)
|
|
176
|
+
existing_value = current_section.get(key_name, None)
|
|
177
|
+
|
|
178
|
+
if existing_value == new_value:
|
|
179
|
+
console.print(f"[green]{friendly_name} already set to:[/green] {new_value}")
|
|
180
|
+
return False
|
|
181
|
+
elif existing_value:
|
|
182
|
+
console.print(f"\n[yellow]Existing {friendly_name} found:[/yellow] {existing_value}")
|
|
183
|
+
console.print(f"[yellow]New {friendly_name} detected:[/yellow] {new_value}")
|
|
184
|
+
if not Confirm.ask(f"Would you like to overwrite the existing {friendly_name} with the new one?"):
|
|
185
|
+
console.print(f"[cyan]Existing {friendly_name} retained.[/cyan]")
|
|
186
|
+
return False
|
|
187
|
+
else:
|
|
188
|
+
console.print(Panel(f"\n[bold green]{friendly_name}: {new_value}[/bold green]", expand=False))
|
|
189
|
+
|
|
190
|
+
current_section[key_name] = new_value
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def first_run_setup():
|
|
195
|
+
default_config_dir = user_config_dir("redfetch", "RedGuides")
|
|
196
|
+
|
|
197
|
+
# Check if running in a CI environment
|
|
198
|
+
if os.environ.get('CI') == 'true':
|
|
199
|
+
# Assume setup is complete and use the default config directory
|
|
200
|
+
config_dir = default_config_dir
|
|
201
|
+
os.makedirs(config_dir, exist_ok=True)
|
|
202
|
+
create_first_run_flag(default_config_dir, config_dir)
|
|
203
|
+
return config_dir
|
|
204
|
+
|
|
205
|
+
# Load existing settings early
|
|
206
|
+
settings_file = os.path.join(default_config_dir, "settings.local.toml")
|
|
207
|
+
doc = load_config(settings_file)
|
|
208
|
+
|
|
209
|
+
if not is_first_run(default_config_dir):
|
|
210
|
+
with open(os.path.join(default_config_dir, 'first_run_complete'), 'r') as f:
|
|
211
|
+
config_dir = f.read().strip()
|
|
212
|
+
|
|
213
|
+
# Check for .env file
|
|
214
|
+
env_file_path = os.path.join(config_dir, '.env')
|
|
215
|
+
if os.path.exists(env_file_path):
|
|
216
|
+
# Effective env: .env value, then REDFETCH_ENV, then LIVE
|
|
217
|
+
env_from_file = None
|
|
218
|
+
try:
|
|
219
|
+
with open(env_file_path, "r", encoding="utf-8") as env_file:
|
|
220
|
+
for line in env_file:
|
|
221
|
+
line = line.strip()
|
|
222
|
+
if line.startswith("REDFETCH_ENV="):
|
|
223
|
+
env_from_file = line.split("=", 1)[1].strip()
|
|
224
|
+
break
|
|
225
|
+
except OSError:
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
effective_env = env_from_file or os.environ.get("REDFETCH_ENV") or "LIVE"
|
|
229
|
+
|
|
230
|
+
# Banner always shows server/config dir, plus effective env overrides.
|
|
231
|
+
notice_lines: list[str] = [
|
|
232
|
+
f"[bold yellow]Server type: [cyan]{effective_env}[/cyan][/bold yellow]",
|
|
233
|
+
f"Configuration directory: {config_dir}",
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
# Base URL override (show if explicitly set via env).
|
|
237
|
+
base_url_override = os.environ.get("REDFETCH_BASE_URL")
|
|
238
|
+
if base_url_override:
|
|
239
|
+
notice_lines.append(f"[bold red]REDFETCH_BASE_URL:[/bold red] {base_url_override}")
|
|
240
|
+
|
|
241
|
+
# Auth overrides: API key always takes precedence over OAuth.
|
|
242
|
+
api_key_present = bool(os.environ.get("REDGUIDES_API_KEY"))
|
|
243
|
+
if api_key_present:
|
|
244
|
+
notice_lines.append("[bold yellow]Auth:[/bold yellow] API key via REDGUIDES_API_KEY")
|
|
245
|
+
# Only meaningful alongside API key (automation keys updating as other users).
|
|
246
|
+
if os.environ.get("REDGUIDES_USER_ID"):
|
|
247
|
+
notice_lines.append("[bold yellow]Auth:[/bold yellow] REDGUIDES_USER_ID set via env")
|
|
248
|
+
else:
|
|
249
|
+
# Only surface OAuth env vars when API key isn't overriding them.
|
|
250
|
+
if os.environ.get("REDFETCH_OAUTH_CLIENT_ID"):
|
|
251
|
+
notice_lines.append("[bold yellow]OAuth:[/bold yellow] Client ID set via env")
|
|
252
|
+
if os.environ.get("REDFETCH_OAUTH_CLIENT_SECRET"):
|
|
253
|
+
notice_lines.append("[bold yellow]OAuth:[/bold yellow] Client secret set via env")
|
|
254
|
+
oauth_redirect_override = os.environ.get("REDFETCH_OAUTH_REDIRECT_URI")
|
|
255
|
+
if oauth_redirect_override:
|
|
256
|
+
notice_lines.append(f"[bold yellow]OAuth redirect:[/bold yellow] {oauth_redirect_override}")
|
|
257
|
+
|
|
258
|
+
# PyPI URL override
|
|
259
|
+
pypi_url_override = os.environ.get("REDFETCH_PYPI_URL")
|
|
260
|
+
if pypi_url_override:
|
|
261
|
+
notice_lines.append(f"[bold yellow]REDFETCH_PYPI_URL:[/bold yellow] {pypi_url_override}")
|
|
262
|
+
|
|
263
|
+
console.print(Panel("\n".join(notice_lines), expand=False))
|
|
264
|
+
return config_dir
|
|
265
|
+
else:
|
|
266
|
+
console.print("[bold red]Environment file (.env) not found. Rerunning setup.[/bold red]")
|
|
267
|
+
|
|
268
|
+
# Add EQ detection before greeting
|
|
269
|
+
eq_path = find_everquest_uninstall_location()
|
|
270
|
+
|
|
271
|
+
castle_gate = (
|
|
272
|
+
"[red]/ \\ / \\\n[/]"
|
|
273
|
+
"[red]/ \\ / \\\n[/]"
|
|
274
|
+
"[red]([bright_black]_____[/]) ([bright_black]_____[/])\n[/]"
|
|
275
|
+
"[bright_black]| | _ _ _ | |[/]\n"
|
|
276
|
+
"[bright_black]| O |_| |_| |_| |[/][bright_black]_| O |[/]\n"
|
|
277
|
+
"[bright_black]|- |[/] [white]_[/] [bright_black]| [white]-[/] |[/]\n"
|
|
278
|
+
"[bright_black]| | - [red]_^_[/] | |[/]\n"
|
|
279
|
+
"[bright_black]| [white]_[/]| [red]//[white]|[/]\\\\ [/]- | |[/]\n"
|
|
280
|
+
"[bright_black]| |[/] [red]///[white]|[/]\\\\\\ [/] [bright_black]| [green])[/]|[/]\n"
|
|
281
|
+
"[bright_black]|- |_[/][red] |||[white]|[/]|||[/] [bright_black]| [green]([/]|[/]\n"
|
|
282
|
+
"[green])[/][bright_black] |[/] [red]|||[white]|[/]|||[/] [bright_black]|- [green])[/]|[/]\n"
|
|
283
|
+
"[green]([/][bright_black]___|___[/][red]|||[white]|[/]|||[/][bright_black]___|__[green]([/]|[/]\n"
|
|
284
|
+
" [yellow]( ([/]\n"
|
|
285
|
+
" [yellow]\\ \\\n[/]"
|
|
286
|
+
" [yellow]) )[/]\n"
|
|
287
|
+
" [yellow]| |[/]\n"
|
|
288
|
+
" [yellow]( ([/]\n"
|
|
289
|
+
" [yellow]\\ \\\n[/]"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
greeting_panel = Panel.fit(
|
|
293
|
+
Text.from_markup(
|
|
294
|
+
f"{castle_gate}\n"
|
|
295
|
+
"[bright_black]Six wizards, unnaturally identical in their red robes, meet you at the gate. They /wave and /say in chorus:[/]\n"
|
|
296
|
+
"[bold cyan][italic]\"Hail, and well met, wayfarer. Art thou [/italic][bright_white]\\[ready][/bright_white][italic] to cleave thy soul manifold, and become thyself a great company?\"[/italic][/bold cyan]",
|
|
297
|
+
justify="center"
|
|
298
|
+
),
|
|
299
|
+
style="white on black",
|
|
300
|
+
border_style="dim bright_red",
|
|
301
|
+
box=ASCII2
|
|
302
|
+
)
|
|
303
|
+
console.print(greeting_panel)
|
|
304
|
+
# Get user response to the wizard
|
|
305
|
+
response = CustomPrompt.ask().lower()
|
|
306
|
+
|
|
307
|
+
while any(word in response for word in ["what", "huh", "idk"]) or response == "":
|
|
308
|
+
console.print("\nThe wizards seem annoyed and speak plainly, [italic][bold cyan]\"We're talking about multiboxing EQ. Wanna do that?\"[/italic][/bold cyan]")
|
|
309
|
+
response = CustomPrompt.ask().lower()
|
|
310
|
+
|
|
311
|
+
if any(word in response for word in ["ready", "yes", "sure", "yup", "aye", "ok", "okay"]) or response == "y":
|
|
312
|
+
console.print("\nThe wizards /nod and beckon you to enter.")
|
|
313
|
+
console.print("\n[bold cyan][italic]\"Where shall we lay the hall of settings? Thou may wish to tinker with its keys in days yet to come.\"\n[italic][/bold cyan]")
|
|
314
|
+
elif any(word in response for word in ["no", "nope", "nah", "nay"]) or response == "n":
|
|
315
|
+
console.print("\n[bold red]The wizards point to the sky with their longest finger ... \"BEGONE!\"[/bold red]")
|
|
316
|
+
Prompt.ask("\nPress Enter to continue")
|
|
317
|
+
raise SystemExit(1)
|
|
318
|
+
elif any(phrase in response for phrase in ["xyzzy", "plugh", "hello sailor", "mailbox", "east", "leave house", "grue"]):
|
|
319
|
+
console.print(
|
|
320
|
+
"\nAs you utter the ancient words, the wizards eyes widen."
|
|
321
|
+
"\n[bold cyan][italic]\"Ah, a fellow traveler of twisty passages!\"[/italic][/bold cyan]"
|
|
322
|
+
)
|
|
323
|
+
console.print(
|
|
324
|
+
"\nWith a grand, unified, sweeping gesture, the wizards reveal a hidden passage into the castle."
|
|
325
|
+
"\n[bold cyan][italic]\"Step forth, brave soul, and enter many lives, each fraught with peril and wonder alike.\"[/italic][/bold cyan]"
|
|
326
|
+
"\nThey look at your empty hands and add, [bold cyan][italic]\"You'll need this.\"[/italic][/bold cyan]"
|
|
327
|
+
"\nA brass lantern is summoned into your possession."
|
|
328
|
+
)
|
|
329
|
+
console.print(
|
|
330
|
+
"\n[bold cyan][italic]\"Where shall we record your preferences?\"[/italic][/bold cyan]"
|
|
331
|
+
)
|
|
332
|
+
else:
|
|
333
|
+
console.print("\n[bold red]The wizards shake their heads sadly, \"Your riddle eludes us. Perhaps you should go east.\"[/bold red]")
|
|
334
|
+
Prompt.ask("\nPress Enter to continue")
|
|
335
|
+
raise SystemExit(1)
|
|
336
|
+
|
|
337
|
+
config_dir = setup_directories()
|
|
338
|
+
create_first_run_flag(default_config_dir, config_dir)
|
|
339
|
+
console.print(Panel(f"[bold green]Configuration directory: {config_dir}[/bold green]", expand=False))
|
|
340
|
+
|
|
341
|
+
settings_file = os.path.join(config_dir, "settings.local.toml")
|
|
342
|
+
doc = load_config(settings_file)
|
|
343
|
+
|
|
344
|
+
# Handle EQ path settings
|
|
345
|
+
try:
|
|
346
|
+
existing_eq_path = doc.get("LIVE", {}).get("EQPATH", None)
|
|
347
|
+
if eq_path:
|
|
348
|
+
if existing_eq_path:
|
|
349
|
+
if existing_eq_path == eq_path:
|
|
350
|
+
console.print(f"\n[green]EQ path is already set to the detected path:[/green] [yellow]{eq_path}[/yellow]")
|
|
351
|
+
else:
|
|
352
|
+
console.print(f"\n[cyan]Existing EQ path found:[/cyan] [yellow]{existing_eq_path}[/yellow]")
|
|
353
|
+
console.print(f"\n[bold cyan][italic]\"Behold, an elder realm forged before Napster's rise[/italic]\"[/bold cyan]\n[cyan]{eq_path}[/cyan]")
|
|
354
|
+
if Confirm.ask("Do you want to overwrite the existing EQ path with the detected one?"):
|
|
355
|
+
eq_path_updated = update_setting(
|
|
356
|
+
doc, 'LIVE', 'EQPATH', eq_path, 'EQ path'
|
|
357
|
+
)
|
|
358
|
+
if eq_path_updated:
|
|
359
|
+
save_config(settings_file, doc)
|
|
360
|
+
console.print(f"[green]EQ path updated in {settings_file}[/green]")
|
|
361
|
+
else:
|
|
362
|
+
console.print(f"[yellow]No changes made to EQ path in {settings_file}[/yellow]")
|
|
363
|
+
else:
|
|
364
|
+
console.print(f"\n[bold cyan][italic]\"Behold, an elder realm forged before Napster's rise[/italic]\":[/bold cyan]")
|
|
365
|
+
console.print(f"\n[yellow]EverQuest detected at:[/yellow]\n[cyan]{eq_path}[/cyan]")
|
|
366
|
+
if CustomConfirm.ask("Use this as your 'Live' EverQuest path?"):
|
|
367
|
+
eq_path_updated = update_setting(
|
|
368
|
+
doc, 'LIVE', 'EQPATH', eq_path, 'EQ path'
|
|
369
|
+
)
|
|
370
|
+
if eq_path_updated:
|
|
371
|
+
save_config(settings_file, doc)
|
|
372
|
+
else:
|
|
373
|
+
console.print(f"[yellow]No changes made to EQ path in {settings_file}[/yellow]")
|
|
374
|
+
except Exception as e:
|
|
375
|
+
console.print(f"[bold red]Error handling EQ path settings: {e}[/bold red]")
|
|
376
|
+
|
|
377
|
+
# Handle RedGuides Utility paths
|
|
378
|
+
utility_paths = get_rg_utility_paths()
|
|
379
|
+
if utility_paths:
|
|
380
|
+
console.print("\n[bold cyan][italic]\"Lo, we have unearthed eld halls of the RedGuides Launcher. Wilt thou make use of this trove?\"[/italic][/bold cyan]")
|
|
381
|
+
|
|
382
|
+
# Map utility paths to their corresponding TOML sections and resource IDs
|
|
383
|
+
path_mappings = {
|
|
384
|
+
"MQNextInstallLocation": ("LIVE.SPECIAL_RESOURCES.1974", "Very Vanilla MQ Live"),
|
|
385
|
+
"MQNextTestInstallLocation": ("TEST.SPECIAL_RESOURCES.2218", "Very Vanilla MQ Test"),
|
|
386
|
+
"EmuInstallLocation": ("EMU.SPECIAL_RESOURCES.60", "Very Vanilla MQ Emu"),
|
|
387
|
+
"MySeqLiveInstallLocation": ("LIVE.SPECIAL_RESOURCES.151", "MySEQ Live"),
|
|
388
|
+
"MySeqTestInstallLocation": ("TEST.SPECIAL_RESOURCES.164", "MySEQ Test")
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
settings_updated = False
|
|
392
|
+
for path_type, path in utility_paths.items():
|
|
393
|
+
if path_type not in path_mappings:
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
table_path_str, friendly_name = path_mappings[path_type]
|
|
397
|
+
|
|
398
|
+
# Retrieve or create the current section in the TOML document
|
|
399
|
+
current_section = get_or_create_table(doc, table_path_str)
|
|
400
|
+
|
|
401
|
+
# Set opt_in = true since we detected an existing directory
|
|
402
|
+
if not current_section.get('opt_in', False):
|
|
403
|
+
current_section['opt_in'] = True
|
|
404
|
+
settings_updated = True
|
|
405
|
+
|
|
406
|
+
# Retrieve existing path from settings
|
|
407
|
+
existing_path = current_section.get('custom_path', None)
|
|
408
|
+
|
|
409
|
+
if existing_path:
|
|
410
|
+
if existing_path == path:
|
|
411
|
+
console.print(f"\n[green]{friendly_name} path is already set to the detected path:[/green] [yellow]{path}[/yellow]")
|
|
412
|
+
continue # Skip to the next path
|
|
413
|
+
else:
|
|
414
|
+
console.print(f"\n[cyan]Existing {friendly_name} path found in your settings:[/cyan] [yellow]{existing_path}[/yellow]")
|
|
415
|
+
console.print(f"[yellow]{friendly_name} detected at:[/yellow]\n[cyan]{path}[/cyan]")
|
|
416
|
+
prompt_msg = f"Do you want to overwrite the existing {friendly_name} path with the detected one?"
|
|
417
|
+
else:
|
|
418
|
+
console.print(f"\n[yellow]{friendly_name} detected at:[/yellow]\n[cyan]{path}[/cyan]")
|
|
419
|
+
prompt_msg = f"Use this as your {friendly_name} path?"
|
|
420
|
+
|
|
421
|
+
if CustomConfirm.ask(prompt_msg):
|
|
422
|
+
current_section['custom_path'] = path
|
|
423
|
+
settings_updated = True
|
|
424
|
+
console.print(Panel(f"[bold green]{friendly_name}: {path}[/bold green]", expand=False))
|
|
425
|
+
else:
|
|
426
|
+
console.print(f"[cyan]No changes made to {friendly_name} path.[/cyan]")
|
|
427
|
+
|
|
428
|
+
if settings_updated:
|
|
429
|
+
save_config(settings_file, doc)
|
|
430
|
+
|
|
431
|
+
# Windows-only: optional Desktop shortcut (ask last)
|
|
432
|
+
if platform.system() == "Windows":
|
|
433
|
+
from redfetch import desktop_shortcut
|
|
434
|
+
|
|
435
|
+
# Default "yes" for standalone exe users, "no" for pipx/terminal users.
|
|
436
|
+
default_choice = bool(os.environ.get("PYAPP"))
|
|
437
|
+
|
|
438
|
+
console.print(
|
|
439
|
+
"\n[bold cyan][italic]\"Wilt thou we set a signe upon thy Desktop, whereby redfetch may be call'd forth "
|
|
440
|
+
"when thou list?\"[/italic][/bold cyan]"
|
|
441
|
+
)
|
|
442
|
+
wants_shortcut = CustomConfirm.ask(
|
|
443
|
+
"Create Desktop shortcut?",
|
|
444
|
+
default=default_choice,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
if wants_shortcut:
|
|
448
|
+
shortcut_path = desktop_shortcut.create_shortcut()
|
|
449
|
+
console.print(f"[green]Desktop shortcut created:[/green] {shortcut_path}")
|
|
450
|
+
|
|
451
|
+
return config_dir
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
if __name__ == "__main__":
|
|
455
|
+
first_run_setup()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
SHORTCUT_FILENAME = "redfetch.lnk"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_shortcut_path() -> Path:
|
|
12
|
+
"""Absolute path to the redfetch desktop shortcut."""
|
|
13
|
+
if sys.platform != "win32":
|
|
14
|
+
raise NotImplementedError("Desktop shortcuts are only supported on Windows.")
|
|
15
|
+
|
|
16
|
+
from win32com.shell import shell, shellcon # type: ignore
|
|
17
|
+
|
|
18
|
+
# CSIDL_DESKTOPDIRECTORY is the per-user Desktop path (handles redirection).
|
|
19
|
+
desktop_dir = Path(
|
|
20
|
+
shell.SHGetFolderPath(0, shellcon.CSIDL_DESKTOPDIRECTORY, None, 0)
|
|
21
|
+
)
|
|
22
|
+
return desktop_dir / SHORTCUT_FILENAME
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_shortcut(overwrite: bool = True) -> Path:
|
|
26
|
+
"""Create (or overwrite) the desktop shortcut."""
|
|
27
|
+
if sys.platform != "win32":
|
|
28
|
+
raise NotImplementedError("Desktop shortcuts are only supported on Windows.")
|
|
29
|
+
|
|
30
|
+
shortcut_path = get_shortcut_path()
|
|
31
|
+
if shortcut_path.exists() and not overwrite:
|
|
32
|
+
return shortcut_path
|
|
33
|
+
|
|
34
|
+
pyapp = os.environ.get("PYAPP")
|
|
35
|
+
if pyapp and Path(pyapp).exists():
|
|
36
|
+
target = Path(pyapp)
|
|
37
|
+
args = ""
|
|
38
|
+
else:
|
|
39
|
+
cmd = shutil.which("redfetch")
|
|
40
|
+
if cmd:
|
|
41
|
+
target = Path(cmd)
|
|
42
|
+
args = ""
|
|
43
|
+
else:
|
|
44
|
+
target = Path(sys.executable)
|
|
45
|
+
args = "-m redfetch.main"
|
|
46
|
+
|
|
47
|
+
icon_candidates = [
|
|
48
|
+
target.with_suffix(".ico"),
|
|
49
|
+
target.parent / "redfetch.ico",
|
|
50
|
+
Path.cwd() / "redfetch.ico",
|
|
51
|
+
Path(__file__).resolve().parent / "redfetch.ico",
|
|
52
|
+
]
|
|
53
|
+
icon_location = next(
|
|
54
|
+
(f"{p},0" for p in icon_candidates if p.exists()),
|
|
55
|
+
f"{target},0",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
from win32com.client import Dispatch # type: ignore
|
|
59
|
+
|
|
60
|
+
shell = Dispatch("WScript.Shell")
|
|
61
|
+
sc = shell.CreateShortcut(str(shortcut_path))
|
|
62
|
+
sc.TargetPath = str(target)
|
|
63
|
+
sc.Arguments = args
|
|
64
|
+
sc.WorkingDirectory = str(Path.home())
|
|
65
|
+
sc.Description = "redfetch"
|
|
66
|
+
sc.IconLocation = icon_location
|
|
67
|
+
sc.Save()
|
|
68
|
+
return shortcut_path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def remove_shortcut() -> Path:
|
|
72
|
+
"""Remove the desktop shortcut (no-op if missing)."""
|
|
73
|
+
if sys.platform != "win32":
|
|
74
|
+
raise NotImplementedError("Desktop shortcuts are only supported on Windows.")
|
|
75
|
+
shortcut_path = get_shortcut_path()
|
|
76
|
+
try:
|
|
77
|
+
shortcut_path.unlink()
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
pass
|
|
80
|
+
return shortcut_path
|
|
81
|
+
|
redfetch/detecteq.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
# Only import winreg on Windows
|
|
5
|
+
if sys.platform == 'win32':
|
|
6
|
+
import winreg as reg
|
|
7
|
+
else:
|
|
8
|
+
reg = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _extract_dir_from_value(raw_value: str) -> str:
|
|
12
|
+
"""Turn uninstall/display value into a directory."""
|
|
13
|
+
if not raw_value:
|
|
14
|
+
return ""
|
|
15
|
+
|
|
16
|
+
s = str(raw_value).strip()
|
|
17
|
+
# Common DisplayIcon form: C:\...\eqgame.exe,0 -> strip icon index
|
|
18
|
+
if "," in s and s.lower().split(",", 1)[0].endswith(".exe"):
|
|
19
|
+
s = s.split(",", 1)[0].strip()
|
|
20
|
+
|
|
21
|
+
# Remove surrounding quotes if present
|
|
22
|
+
s = s.strip().strip('"').strip()
|
|
23
|
+
|
|
24
|
+
lower = s.lower()
|
|
25
|
+
exe_idx = lower.find(".exe")
|
|
26
|
+
if exe_idx != -1:
|
|
27
|
+
exe_path = s[: exe_idx + 4].strip().strip('"')
|
|
28
|
+
return os.path.dirname(exe_path)
|
|
29
|
+
|
|
30
|
+
# Assume it's a directory already
|
|
31
|
+
return s
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_valid_eq_dir(path: str) -> bool:
|
|
35
|
+
if not path:
|
|
36
|
+
return False
|
|
37
|
+
eqgame_path = os.path.join(path, "eqgame.exe")
|
|
38
|
+
return os.path.isfile(eqgame_path)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _read_reg_value(hive_or_handle, subkey: str, name: str, view: int = 0):
|
|
42
|
+
"""Read a registry value safely; view can be 0/32/64."""
|
|
43
|
+
if reg is None:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
access = reg.KEY_READ
|
|
47
|
+
if view == 32 and hasattr(reg, "KEY_WOW64_32KEY"):
|
|
48
|
+
access |= reg.KEY_WOW64_32KEY
|
|
49
|
+
if view == 64 and hasattr(reg, "KEY_WOW64_64KEY"):
|
|
50
|
+
access |= reg.KEY_WOW64_64KEY
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
with reg.OpenKey(hive_or_handle, subkey, 0, access) as k:
|
|
54
|
+
return reg.QueryValueEx(k, name)[0]
|
|
55
|
+
except OSError:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def find_everquest_uninstall_location():
|
|
60
|
+
"""Return EverQuest install dir if found, else None."""
|
|
61
|
+
# Return None immediately if not on Windows
|
|
62
|
+
if sys.platform != 'win32' or reg is None:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
base_uninstall = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\DGC-EverQuest"
|
|
66
|
+
|
|
67
|
+
# 1) HKCU: prefer per-user installation
|
|
68
|
+
try:
|
|
69
|
+
with reg.ConnectRegistry(None, reg.HKEY_CURRENT_USER) as hkcu:
|
|
70
|
+
for valname in ("InstallLocation", "DisplayIcon", "UninstallString"):
|
|
71
|
+
val = _read_reg_value(hkcu, base_uninstall, valname)
|
|
72
|
+
if val:
|
|
73
|
+
candidate = _extract_dir_from_value(str(val))
|
|
74
|
+
if _is_valid_eq_dir(candidate):
|
|
75
|
+
return candidate
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
# 2) HKLM: system-wide installation (try both 64/32 views)
|
|
80
|
+
try:
|
|
81
|
+
with reg.ConnectRegistry(None, reg.HKEY_LOCAL_MACHINE) as hklm:
|
|
82
|
+
for view in (64, 32):
|
|
83
|
+
for valname in ("InstallLocation", "DisplayIcon", "UninstallString"):
|
|
84
|
+
val = _read_reg_value(hklm, base_uninstall, valname, view=view)
|
|
85
|
+
if val:
|
|
86
|
+
candidate = _extract_dir_from_value(str(val))
|
|
87
|
+
if _is_valid_eq_dir(candidate):
|
|
88
|
+
return candidate
|
|
89
|
+
except OSError:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
# 3) HKU: enumerate SIDs as final fallback
|
|
93
|
+
try:
|
|
94
|
+
with reg.ConnectRegistry(None, reg.HKEY_USERS) as hku:
|
|
95
|
+
i = 0
|
|
96
|
+
while True:
|
|
97
|
+
try:
|
|
98
|
+
sid = reg.EnumKey(hku, i)
|
|
99
|
+
i += 1
|
|
100
|
+
subkey = rf"{sid}\{base_uninstall}"
|
|
101
|
+
for valname in ("InstallLocation", "DisplayIcon", "UninstallString"):
|
|
102
|
+
val = _read_reg_value(hku, subkey, valname)
|
|
103
|
+
if val:
|
|
104
|
+
candidate = _extract_dir_from_value(str(val))
|
|
105
|
+
if _is_valid_eq_dir(candidate):
|
|
106
|
+
return candidate
|
|
107
|
+
except OSError:
|
|
108
|
+
break
|
|
109
|
+
except OSError:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
print(find_everquest_uninstall_location() or "")
|