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.
@@ -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 "")