feat(tool): cross-platform server switcher (Prospect.Client.Config)
Build & Deploy / build (push) Successful in 25s
Build & Deploy / build (push) Successful in 25s
New console utility (net8.0, Linux/Proton + Windows) to point a client at a server by writing backend.txt — the single value the Prospect agent reads. Presets for PROD/PREPROD LAN and PROD internet, plus custom URL, certificate import (native on Windows, guided with Proton-prefix detection on Linux) and game launch. Interactive menu + scriptable CLI (--folder/--set). Added to the solution; not built by the server CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <chemin Win64> définit le dossier du jeu (mémorisé)");
|
||||
Console.WriteLine(" --set <cible> é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<Settings>(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; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>ProspectServerSwitcher</AssemblyName>
|
||||
<RootNamespace>Prospect.Client.Config</RootNamespace>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<!-- Cross-platform client tool (Linux/Proton + Windows). Never built by the Linux server CI. -->
|
||||
<Configurations>Debug;Release;Season 3 Release;Season 2 Release;Season 2 Debug;Season 3 Debug</Configurations>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user