Build & Deploy / build (push) Successful in 28s
On Linux the tool now actually trusts the server certificate inside the game's Proton/Wine prefix (the wine certutil tool is broken under Proton): it fetches the live cert from the server (backend.txt), builds the wine serialized cert blob (SHA1 + encoded cert) and writes it to the prefix Root store via `wine reg import`. Auto-detects the Proton wine and lists compatdata prefixes (most-recent first) so the non-Steam-shortcut prefix is picked correctly; the chosen prefix is remembered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
405 lines
17 KiB
C#
405 lines
17 KiB
C#
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 use the public domains so the same target works for everyone (LAN or internet):
|
|
// DNS resolves to the public IP for friends, and the unified TLS cert covers both hostnames.
|
|
// On the same LAN, add a hosts entry (domain -> 192.168.1.136) to avoid hairpin NAT.
|
|
private static readonly (string Key, string Label, string Url)[] Presets =
|
|
{
|
|
("prod", "PROD", "https://tc.nfteam.ovh:8443"),
|
|
("preprod", "PREPROD", "https://rd-tc.nfteam.ovh:8444"),
|
|
};
|
|
|
|
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()
|
|
{
|
|
// Prefer fetching the cert live from the server the client points at (backend.txt), so
|
|
// it's always the current one; fall back to a .crt file.
|
|
X509Certificate2? cert = null;
|
|
var target = ReadBackendUrl();
|
|
if (target != null && TryFetchServerCert(target, out cert))
|
|
Console.WriteLine($"Certificat récupéré depuis {target}");
|
|
else
|
|
{
|
|
Console.Write("Chemin d'un certificat (.crt) : ");
|
|
var p = Console.ReadLine()?.Trim().Trim('"');
|
|
if (string.IsNullOrEmpty(p) || !File.Exists(p)) { Console.WriteLine("⚠ Annulé."); return; }
|
|
try { cert = new X509Certificate2(p); } catch (Exception ex) { Console.WriteLine("✖ " + ex.Message); return; }
|
|
}
|
|
if (cert == null) { Console.WriteLine("⚠ Aucun certificat."); return; }
|
|
Console.WriteLine($" empreinte : {cert.Thumbprint}");
|
|
|
|
if (OperatingSystem.IsWindows()) ImportCertWindows(cert);
|
|
else ImportCertProton(cert);
|
|
}
|
|
|
|
private static string? ReadBackendUrl()
|
|
{
|
|
if (string.IsNullOrEmpty(_settings.GameFolder)) return null;
|
|
var f = Path.Combine(_settings.GameFolder, "backend.txt");
|
|
try { return File.Exists(f) ? File.ReadAllText(f).Trim() : null; } catch { return null; }
|
|
}
|
|
|
|
private static bool TryFetchServerCert(string url, out X509Certificate2? cert)
|
|
{
|
|
cert = null;
|
|
try
|
|
{
|
|
var uri = new Uri(url);
|
|
using var tcp = new System.Net.Sockets.TcpClient();
|
|
tcp.Connect(uri.Host, uri.Port <= 0 ? 443 : uri.Port);
|
|
X509Certificate2? captured = null;
|
|
using var ssl = new System.Net.Security.SslStream(tcp.GetStream(), false,
|
|
(_, c, _, _) => { if (c != null) captured = new X509Certificate2(c.Export(X509ContentType.Cert)); return true; });
|
|
ssl.AuthenticateAsClient(uri.Host);
|
|
cert = captured;
|
|
return cert != null;
|
|
}
|
|
catch { return false; }
|
|
}
|
|
|
|
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
|
private static void ImportCertWindows(X509Certificate2 cert)
|
|
{
|
|
try
|
|
{
|
|
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); }
|
|
}
|
|
|
|
// Trust the cert inside the game's Proton/Wine prefix by writing the serialized cert blob
|
|
// into the prefix Root store via `wine reg import` (the wine `certutil` tool is unreliable
|
|
// under Proton). The prefix is chosen explicitly, so it works even though the client runs
|
|
// as a non-Steam shortcut (whose appid isn't the game's 868270).
|
|
private static void ImportCertProton(X509Certificate2 cert)
|
|
{
|
|
var wine = FindProtonWine();
|
|
if (wine == null) { Console.WriteLine("⚠ Aucun wine Proton trouvé (Steam/Proton installé ?)."); return; }
|
|
var prefix = SelectPrefix();
|
|
if (prefix == null) return;
|
|
|
|
// Wine serialized cert blob: SHA1-hash property (id 3) + encoded cert property (id 0x20).
|
|
var der = cert.RawData;
|
|
var sha1 = cert.GetCertHash();
|
|
byte[] blob;
|
|
using (var ms = new MemoryStream())
|
|
{
|
|
void Prop(uint id, byte[] data)
|
|
{
|
|
ms.Write(BitConverter.GetBytes(id));
|
|
ms.Write(BitConverter.GetBytes(1u));
|
|
ms.Write(BitConverter.GetBytes((uint)data.Length));
|
|
ms.Write(data);
|
|
}
|
|
Prop(3, sha1);
|
|
Prop(0x20, der);
|
|
blob = ms.ToArray();
|
|
}
|
|
var thumb = Convert.ToHexString(sha1);
|
|
var hex = string.Join(",", blob.Select(b => b.ToString("x2")));
|
|
var regFile = Path.Combine(Path.GetTempPath(), "prospect-cert.reg");
|
|
File.WriteAllText(regFile,
|
|
"REGEDIT4\r\n\r\n" +
|
|
$"[HKEY_LOCAL_MACHINE\\Software\\Microsoft\\SystemCertificates\\Root\\Certificates\\{thumb}]\r\n" +
|
|
$"\"Blob\"=hex:{hex}\r\n");
|
|
|
|
Console.WriteLine("⚠ Ferme complètement le jeu avant de continuer (sinon le registre ne sera pas pris en compte).");
|
|
Console.Write("Appuie sur Entrée pour importer… ");
|
|
Console.ReadLine();
|
|
|
|
var wineserver = Path.Combine(Path.GetDirectoryName(wine)!, "wineserver");
|
|
var regWinePath = "Z:" + regFile.Replace('/', '\\'); // Z: maps to / inside the prefix
|
|
RunWine(wineserver, "-k", prefix); // stop any running wineserver (flush)
|
|
RunWine(wine, $"reg import \"{regWinePath}\"", prefix);
|
|
RunWine(wineserver, "-k", prefix); // flush the change to disk
|
|
|
|
var sysreg = Path.Combine(prefix, "system.reg");
|
|
var ok = File.Exists(sysreg) && File.ReadAllText(sysreg).Contains(thumb, StringComparison.OrdinalIgnoreCase);
|
|
Console.WriteLine(ok
|
|
? $"✔ Certificat importé dans le préfixe : {prefix}\n Relance le jeu."
|
|
: "✖ Import non confirmé — assure-toi que le jeu est fermé et réessaie.");
|
|
}
|
|
|
|
private static void RunWine(string exe, string args, string prefix)
|
|
{
|
|
try
|
|
{
|
|
var psi = new ProcessStartInfo(exe, args)
|
|
{
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
};
|
|
psi.Environment["WINEPREFIX"] = prefix;
|
|
psi.Environment["WINEDEBUG"] = "-all";
|
|
using var p = Process.Start(psi);
|
|
p?.WaitForExit(20000);
|
|
}
|
|
catch (Exception ex) { Console.WriteLine(" (wine) " + ex.Message); }
|
|
}
|
|
|
|
private static string? FindProtonWine()
|
|
{
|
|
foreach (var root in SteamRoots())
|
|
{
|
|
var common = Path.Combine(root, "steamapps", "common");
|
|
if (!Directory.Exists(common)) continue;
|
|
var candidates = Directory.GetDirectories(common, "Proton*")
|
|
.Select(d => Path.Combine(d, "files", "bin", "wine"))
|
|
.Where(File.Exists)
|
|
.OrderByDescending(w => w.Contains("Experimental"))
|
|
.ThenByDescending(File.GetLastWriteTimeUtc)
|
|
.ToList();
|
|
if (candidates.Count > 0) return candidates[0];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static string? SelectPrefix()
|
|
{
|
|
if (!string.IsNullOrEmpty(_settings.WinePrefix) && Directory.Exists(_settings.WinePrefix))
|
|
return _settings.WinePrefix;
|
|
|
|
var prefixes = new List<string>();
|
|
foreach (var root in SteamRoots())
|
|
{
|
|
var cd = Path.Combine(root, "steamapps", "compatdata");
|
|
if (!Directory.Exists(cd)) continue;
|
|
foreach (var d in Directory.GetDirectories(cd))
|
|
{
|
|
var pfx = Path.Combine(d, "pfx");
|
|
if (File.Exists(Path.Combine(pfx, "system.reg"))) prefixes.Add(Path.GetFullPath(pfx));
|
|
}
|
|
}
|
|
prefixes = prefixes.Distinct()
|
|
.OrderByDescending(p => File.GetLastWriteTimeUtc(Path.Combine(p, "system.reg")))
|
|
.ToList();
|
|
if (prefixes.Count == 0) { Console.WriteLine("⚠ Aucun préfixe Proton trouvé."); return null; }
|
|
|
|
Console.WriteLine("Préfixes Proton détectés (le plus récemment utilisé en premier = sûrement ton jeu) :");
|
|
for (var i = 0; i < prefixes.Count; i++)
|
|
Console.WriteLine($" {i + 1}) {prefixes[i]}");
|
|
Console.Write("Numéro du préfixe (Entrée = 1) : ");
|
|
var raw = Console.ReadLine()?.Trim();
|
|
var idx = string.IsNullOrEmpty(raw) ? 0 : (int.TryParse(raw, out var n) ? n - 1 : -1);
|
|
if (idx < 0 || idx >= prefixes.Count) { Console.WriteLine("⚠ Choix invalide."); return null; }
|
|
_settings.WinePrefix = prefixes[idx];
|
|
SaveSettings();
|
|
return _settings.WinePrefix;
|
|
}
|
|
|
|
private static IEnumerable<string> SteamRoots()
|
|
{
|
|
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
yield return Path.Combine(home, ".steam", "steam");
|
|
yield return Path.Combine(home, ".local", "share", "Steam");
|
|
}
|
|
|
|
// ---- 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; } = "";
|
|
public string WinePrefix { get; set; } = "";
|
|
}
|
|
}
|