using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Godot; using Renci.SshNet; using Zeroconf; namespace Laura.DeployToSteamOS; /// /// Scans the network for SteamOS Devkit devices via the ZeroConf / Bonjour protocol /// public class SteamOSDevkitManager { public const string SteamOSProtocol = "_steamos-devkit._tcp.local."; public const string CompatibleTextVersion = "1"; /// /// Scans the network for valid SteamOS devkit devices /// /// A list of valid SteamOS devkit devices public static async Task> ScanDevices() { var networkDevices = await ZeroconfResolver.ResolveAsync(SteamOSProtocol); List devices = new(); // Iterate through all network devices and request further connection info from the service foreach (var networkDevice in networkDevices) { var device = new Device { DisplayName = networkDevice.DisplayName, IPAdress = networkDevice.IPAddress, ServiceName = networkDevice.DisplayName + "." + SteamOSProtocol, }; var hasServiceData = networkDevice.Services.TryGetValue(device.ServiceName, out var serviceData); // This device is not a proper SteamOS device if(!hasServiceData) continue; var properties = serviceData.Properties.FirstOrDefault(); if (properties == null) continue; // Device is not compatible (version mismatch) // String"1" is for some reason not equal to String"1" //if (properties["txtvers"] != CompatibleTextVersion) continue; device.Settings = properties["settings"]; device.Login = properties["login"]; device.Devkit1 = properties["devkit1"]; devices.Add(device); } return devices; } /// /// Creates an SSH connection and runs a command /// /// A SteamOS devkit device /// The SSH command to run /// A callable for logging /// The SSH CLI output public static async Task RunSSHCommand(Device device, string command, Callable logCallable) { logCallable.CallDeferred($"Connecting to {device.Login}@{device.IPAdress}"); using var client = new SshClient(GetSSHConnectionInfo(device)); await client.ConnectAsync(CancellationToken.None); logCallable.CallDeferred($"Command: '{command}'"); var sshCommand = client.CreateCommand(command); var result = await Task.Factory.FromAsync(sshCommand.BeginExecute(), sshCommand.EndExecute); client.Disconnect(); return result; } /// /// Creates a new SCP connection and copies all local files to a remote path /// /// A SteamOS devkit device /// The path on the host /// The path on the device /// A callable for logging public static async Task CopyFiles(Device device, string localPath, string remotePath, Callable logCallable) { logCallable.CallDeferred($"Connecting to {device.Login}@{device.IPAdress}"); using var client = new ScpClient(GetSSHConnectionInfo(device)); await client.ConnectAsync(CancellationToken.None); logCallable.CallDeferred($"Uploading files"); // Run async method until upload is done // TODO: Set Progress based on files var lastUploadedFilename = ""; var uploadProgress = 0; var taskCompletion = new TaskCompletionSource(); client.Uploading += (sender, e) => { if (e.Filename != lastUploadedFilename) { lastUploadedFilename = e.Filename; uploadProgress = 0; } var progressPercentage = Mathf.CeilToInt((double)e.Uploaded / e.Size * 100); if (progressPercentage != uploadProgress) { uploadProgress = progressPercentage; logCallable.CallDeferred($"Uploading {lastUploadedFilename} ({progressPercentage}%)"); } if (e.Uploaded == e.Size) { taskCompletion.TrySetResult(true); } }; client.ErrorOccurred += (sender, args) => throw new Exception("Error while uploading build."); await Task.Run(() => client.Upload(new DirectoryInfo(localPath), remotePath)); await taskCompletion.Task; client.Disconnect(); logCallable.CallDeferred($"Fixing file permissions"); await RunSSHCommand(device, $"chmod +x -R {remotePath}", logCallable); } /// /// Runs an SSH command on the device that runs the steamos-prepare-upload script /// /// A SteamOS devkit device /// An ID for the game /// A callable for logging /// The CLI result public static async Task PrepareUpload(Device device, string gameId, Callable logCallable) { logCallable.CallDeferred("Preparing upload"); var resultRaw = await RunSSHCommand(device, "python3 ~/devkit-utils/steamos-prepare-upload --gameid " + gameId, logCallable); var result = JsonSerializer.Deserialize(resultRaw, DefaultSerializerOptions); return result; } /// /// Runs an SSH command on the device that runs the steamos-create-shortcut script /// /// A SteamOS devkit device /// Parameters for the shortcut /// A callable for logging /// The CLI result public static async Task CreateShortcut(Device device, CreateShortcutParameters parameters, Callable logCallable) { var parametersJson = JsonSerializer.Serialize(parameters); var command = $"python3 ~/devkit-utils/steam-client-create-shortcut --parms '{parametersJson}'"; var resultRaw = await RunSSHCommand(device, command, logCallable); var result = JsonSerializer.Deserialize(resultRaw, DefaultSerializerOptions); return result; } /// /// A SteamOS devkit device /// public class Device { public string DisplayName { get; set; } public string IPAdress { get; set; } public int Port { get; set; } public string ServiceName { get; set; } public string Settings { get; set; } public string Login { get; set; } public string Devkit1 { get; set; } } public class PrepareUploadResult { public string User { get; set; } public string Directory { get; set; } } public class CreateShortcutResult { public string Error { get; set; } public string Success { get; set; } } /// /// Parameters for the CreateShortcut method /// public struct CreateShortcutParameters { public string gameid { get; set; } public string directory { get; set; } public string[] argv { get; set; } public Dictionary settings { get; set; } } /// /// Returns the path to the devkit_rsa key generated by the official SteamOS devkit client /// /// The path to the "devkit_rsa" key public static string GetPrivateKeyPath() { string applicationDataPath; switch (System.Environment.OSVersion.Platform) { // TODO: Linux Support case PlatformID.Win32NT: applicationDataPath = Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData), "steamos-devkit"); break; case PlatformID.Unix: applicationDataPath = Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal), "Library", "Application Support"); break; default: applicationDataPath = ""; break; } var keyFolder = Path.Combine(applicationDataPath, "steamos-devkit"); return Path.Combine(keyFolder, "devkit_rsa"); } /// /// Creates a SSH Connection Info for a SteamOS devkit device /// /// A SteamOS devkit device /// An SSH ConnectionInfo /// Throws if there is no private key present public static ConnectionInfo GetSSHConnectionInfo(Device device) { var privateKeyPath = GetPrivateKeyPath(); if (!File.Exists(privateKeyPath)) throw new Exception("devkit_rsa key is missing. Have you connected to your device via the official devkit UI yet?"); var privateKeyFile = new PrivateKeyFile(privateKeyPath); return new ConnectionInfo(device.IPAdress, device.Login, new PrivateKeyAuthenticationMethod(device.Login, privateKeyFile)); } public static readonly JsonSerializerOptions DefaultSerializerOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, }; }