diff --git a/src/Prospect.Client.Config/Program.cs b/src/Prospect.Client.Config/Program.cs new file mode 100644 index 0000000..a04653f --- /dev/null +++ b/src/Prospect.Client.Config/Program.cs @@ -0,0 +1,260 @@ +using System.Diagnostics; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; + +namespace Prospect.Client.Config; + +// Cross-platform (Linux/Proton + Windows) helper to point a The Cycle: Frontier client at a +// server. The Prospect agent reads the target PlayFab URL from `backend.txt` next to the game +// executable, so switching servers = writing that one file. This tool also imports the server +// certificate and launches the game (natively on Windows, guided on Linux/Proton). +internal static class Program +{ + private const string AppId = "868270"; + + // Presets. LAN entries match a direct homelab connection (192.168.1.136); the internet + // entry (public domain) is for friends connecting from outside — the TLS cert covers the + // hostname and :8443 bypasses the reverse proxy. + private static readonly (string Key, string Label, string Url)[] Presets = + { + ("prod-lan", "PROD (LAN)", "https://192.168.1.136:8443"), + ("preprod-lan", "PREPROD (LAN)", "https://192.168.1.136:8444"), + ("prod", "PROD (amis / internet)", "https://tc.nfteam.ovh:8443"), + }; + + private static string SettingsPath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "ProspectSwitcher", "settings.json"); + + private static Settings _settings = new(); + + private static int Main(string[] args) + { + try { Console.OutputEncoding = Encoding.UTF8; } catch { /* some terminals reject it */ } + LoadSettings(); + return args.Length > 0 ? RunCli(args) : MenuLoop(); + } + + // ---- Scriptable CLI ---------------------------------------------------- + private static int RunCli(string[] args) + { + string? folder = null, target = null; + for (var i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--folder" when i + 1 < args.Length: folder = args[++i]; break; + case "--set" when i + 1 < args.Length: target = args[++i]; break; + case "-h" or "--help": PrintCliHelp(); return 0; + } + } + if (folder != null) { _settings.GameFolder = folder; SaveSettings(); } + if (target == null) { PrintCliHelp(); return folder != null ? 0 : 1; } + var url = ResolveTarget(target); + if (url == null) { Console.Error.WriteLine("Cible inconnue : " + target); return 1; } + return WriteBackend(url) ? 0 : 1; + } + + private static void PrintCliHelp() + { + Console.WriteLine("ProspectServerSwitcher — sélecteur de serveur The Cycle (multi-OS)"); + Console.WriteLine(); + Console.WriteLine(" (sans argument) menu interactif"); + Console.WriteLine(" --folder définit le dossier du jeu (mémorisé)"); + Console.WriteLine(" --set écrit backend.txt"); + Console.WriteLine(); + Console.WriteLine("Cibles : " + string.Join(", ", Presets.Select(p => p.Key)) + ", ou une URL https://…"); + Console.WriteLine("Ex : ProspectServerSwitcher --folder \"…/Prospect/Binaries/Win64\" --set preprod-lan"); + } + + private static string? ResolveTarget(string t) + { + foreach (var p in Presets) + if (string.Equals(p.Key, t, StringComparison.OrdinalIgnoreCase)) return p.Url; + if (t.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return t; + return null; + } + + // ---- Interactive menu -------------------------------------------------- + private static int MenuLoop() + { + while (true) + { + Console.WriteLine(); + Console.WriteLine("=== The Cycle — Sélecteur de serveur ==="); + Console.WriteLine("Dossier du jeu : " + (string.IsNullOrEmpty(_settings.GameFolder) ? "(non défini)" : _settings.GameFolder)); + Console.WriteLine("backend.txt : " + ReadCurrentBackend()); + Console.WriteLine(); + Console.WriteLine(" 1) Définir le dossier du jeu (Win64)"); + for (var i = 0; i < Presets.Length; i++) + Console.WriteLine($" {i + 2}) {Presets[i].Label,-24} {Presets[i].Url}"); + var n = Presets.Length + 2; + Console.WriteLine($" {n}) Serveur personnalisé…"); + Console.WriteLine($" {n + 1}) Importer un certificat…"); + Console.WriteLine($" {n + 2}) Lancer le jeu"); + Console.WriteLine(" 0) Quitter"); + Console.Write("> "); + + var raw = Console.ReadLine()?.Trim(); + if (raw is null or "0") return 0; + if (!int.TryParse(raw, out var choice)) { Console.WriteLine("Choix invalide."); continue; } + + if (choice == 1) SetFolder(); + else if (choice >= 2 && choice < n) WriteBackend(Presets[choice - 2].Url); + else if (choice == n) SetCustom(); + else if (choice == n + 1) ImportCert(); + else if (choice == n + 2) LaunchGame(); + else Console.WriteLine("Choix invalide."); + } + } + + private static void SetFolder() + { + Console.Write("Chemin du dossier Win64 : "); + var p = Console.ReadLine()?.Trim().Trim('"'); + if (string.IsNullOrEmpty(p)) return; + if (!Directory.Exists(p)) { Console.WriteLine("⚠ Dossier introuvable."); return; } + _settings.GameFolder = p; + SaveSettings(); + Console.WriteLine("✔ Dossier mémorisé."); + } + + private static void SetCustom() + { + Console.Write("URL du serveur (ex : https://mon-serveur:8443) : "); + var u = Console.ReadLine()?.Trim(); + if (!string.IsNullOrWhiteSpace(u)) WriteBackend(u); + } + + // ---- backend.txt ------------------------------------------------------- + private static bool WriteBackend(string url) + { + if (!EnsureFolder(out var dir)) return false; + try + { + // No trailing newline: the agent reads the raw file and requires it to start with https://. + File.WriteAllText(Path.Combine(dir, "backend.txt"), url, new UTF8Encoding(false)); + Console.WriteLine($"✔ backend.txt → {url}"); + Console.WriteLine(" (relance le jeu : backend.txt n'est lu qu'au démarrage du loader.)"); + return true; + } + catch (Exception ex) { Console.WriteLine("✖ " + ex.Message); return false; } + } + + private static string ReadCurrentBackend() + { + if (string.IsNullOrEmpty(_settings.GameFolder)) return "(dossier non défini)"; + var f = Path.Combine(_settings.GameFolder, "backend.txt"); + try + { + if (!File.Exists(f)) return "(absent)"; + var v = File.ReadAllText(f).Trim(); + var match = Presets.FirstOrDefault(p => p.Url == v); + return match.Key != null ? $"{v} [{match.Label.Trim()}]" : v; + } + catch { return "(illisible)"; } + } + + // ---- Certificate ------------------------------------------------------- + private static void ImportCert() + { + Console.Write("Chemin du certificat (.crt) : "); + var path = Console.ReadLine()?.Trim().Trim('"'); + if (string.IsNullOrEmpty(path) || !File.Exists(path)) { Console.WriteLine("⚠ Fichier introuvable."); return; } + + if (OperatingSystem.IsWindows()) + { + ImportCertWindows(path); + return; + } + + // Linux/Proton : le certificat doit être approuvé DANS le préfixe Wine du jeu. + var prefix = FindProtonPrefix() ?? $"~/.steam/steam/steamapps/compatdata/{AppId}/pfx"; + Console.WriteLine("Sous Proton/Wine, ajoute le certificat au préfixe du jeu (wine requis) :"); + Console.WriteLine($" WINEPREFIX=\"{prefix}\" wine certutil -addstore -f Root \"{path}\""); + } + + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + private static void ImportCertWindows(string path) + { + try + { + using var cert = new X509Certificate2(path); + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + store.Add(cert); + Console.WriteLine("✔ Certificat importé (Autorités racines de confiance, utilisateur)."); + } + catch (Exception ex) { Console.WriteLine("✖ Import : " + ex.Message); } + } + + private static string? FindProtonPrefix() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + foreach (var dir in new[] + { + Path.Combine(home, ".steam", "steam", "steamapps", "compatdata", AppId, "pfx"), + Path.Combine(home, ".local", "share", "Steam", "steamapps", "compatdata", AppId, "pfx"), + }) + if (Directory.Exists(dir)) return dir; + return null; + } + + // ---- Launch ------------------------------------------------------------ + private static void LaunchGame() + { + if (OperatingSystem.IsWindows()) + { + if (!EnsureFolder(out var dir)) return; + var loader = Path.Combine(dir, "Prospect.Client.Loader.exe"); + if (!File.Exists(loader)) { Console.WriteLine("⚠ Prospect.Client.Loader.exe introuvable dans le dossier."); return; } + try + { + Process.Start(new ProcessStartInfo(loader) { WorkingDirectory = dir, UseShellExecute = true }); + Console.WriteLine("▶ Jeu lancé."); + } + catch (Exception ex) { Console.WriteLine("✖ " + ex.Message); } + return; + } + Console.WriteLine("Sous Linux/Proton, lance via Steam (le loader doit tourner dans le préfixe Proton) :"); + Console.WriteLine(" steam steam://rungameid/" + AppId); + } + + // ---- Helpers ----------------------------------------------------------- + private static bool EnsureFolder(out string dir) + { + dir = _settings.GameFolder; + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) + { + Console.WriteLine("⚠ Définis d'abord le dossier du jeu (option 1)."); + return false; + } + return true; + } + + private static void LoadSettings() + { + try + { + if (File.Exists(SettingsPath)) + _settings = JsonSerializer.Deserialize(File.ReadAllText(SettingsPath)) ?? new Settings(); + } + catch { _settings = new Settings(); } + } + + private static void SaveSettings() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!); + File.WriteAllText(SettingsPath, JsonSerializer.Serialize(_settings)); + } + catch { /* best-effort persistence */ } + } + + private sealed class Settings + { + public string GameFolder { get; set; } = ""; + } +} diff --git a/src/Prospect.Client.Config/Prospect.Client.Config.csproj b/src/Prospect.Client.Config/Prospect.Client.Config.csproj new file mode 100644 index 0000000..c738d67 --- /dev/null +++ b/src/Prospect.Client.Config/Prospect.Client.Config.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + ProspectServerSwitcher + Prospect.Client.Config + true + + Debug;Release;Season 3 Release;Season 2 Release;Season 2 Debug;Season 3 Debug + + + diff --git a/src/Prospect.sln b/src/Prospect.sln index daef9cf..2f6d277 100644 --- a/src/Prospect.sln +++ b/src/Prospect.sln @@ -25,6 +25,8 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Prospect.Client.Loader", "P EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Prospect.Agent", "Prospect.Agent\Prospect.Agent.vcxproj", "{A9BA7D25-F239-4320-A0DC-85E8001FD669}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Prospect.Client.Config", "Prospect.Client.Config\Prospect.Client.Config.csproj", "{6DECEB07-996B-4EBC-927C-13E170161107}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -233,6 +235,30 @@ Global {A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x64.Build.0 = Release|x64 {A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x86.ActiveCfg = Release|x64 {A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x86.Build.0 = Release|x64 + {6DECEB07-996B-4EBC-927C-13E170161107}.Debug|x64.ActiveCfg = Debug|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Debug|x64.Build.0 = Debug|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Debug|x86.ActiveCfg = Debug|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Debug|x86.Build.0 = Debug|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Release|x64.ActiveCfg = Release|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Release|x64.Build.0 = Release|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Release|x86.ActiveCfg = Release|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Release|x86.Build.0 = Release|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Debug|x64.ActiveCfg = Season 2 Debug|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Debug|x64.Build.0 = Season 2 Debug|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Debug|x86.ActiveCfg = Season 2 Debug|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Debug|x86.Build.0 = Season 2 Debug|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Release|x64.ActiveCfg = Season 2 Release|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Release|x64.Build.0 = Season 2 Release|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Release|x86.ActiveCfg = Season 2 Release|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Release|x86.Build.0 = Season 2 Release|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Debug|x64.ActiveCfg = Season 3 Debug|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Debug|x64.Build.0 = Season 3 Debug|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Debug|x86.ActiveCfg = Season 3 Debug|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Debug|x86.Build.0 = Season 3 Debug|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Release|x64.ActiveCfg = Season 3 Release|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Release|x64.Build.0 = Season 3 Release|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Release|x86.ActiveCfg = Season 3 Release|Any CPU + {6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Release|x86.Build.0 = Season 3 Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE