diff --git a/RobustToolbox b/RobustToolbox index e0512a1052..6d377ee1e3 160000 --- a/RobustToolbox +++ b/RobustToolbox @@ -1 +1 @@ -Subproject commit e0512a1052bcde98b0b8bb54bbb35dfcf12edd57 +Subproject commit 6d377ee1e3c7dd63b4813643cad519937763bd68 diff --git a/SS14.Launcher/Helpers.cs b/SS14.Launcher/Helpers.cs new file mode 100644 index 0000000000..e9b74dd5dd --- /dev/null +++ b/SS14.Launcher/Helpers.cs @@ -0,0 +1,82 @@ +using System; +using System.Buffers; +using System.IO; +using System.IO.Compression; +using System.Net.Http; +using System.Threading.Tasks; + +namespace SS14.Launcher +{ + internal static class Helpers + { + public static void ExtractZipToDirectory(string directory, Stream zipStream) + { + using (var zipArchive = new ZipArchive(zipStream)) + { + zipArchive.ExtractToDirectory(directory); + } + } + + public static void ClearDirectory(string directory) + { + var dirInfo = new DirectoryInfo(directory); + foreach (var fileInfo in dirInfo.EnumerateFiles()) + { + fileInfo.Delete(); + } + + foreach (var childDirInfo in dirInfo.EnumerateDirectories()) + { + childDirInfo.Delete(true); + } + } + + public static async Task DownloadToFile(this HttpClient client, Uri uri, string filePath, + Action progress = null) + { + await Task.Run(async () => + { + using (var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead)) + { + response.EnsureSuccessStatusCode(); + + using (var contentStream = await response.Content.ReadAsStreamAsync()) + using (var fileStream = File.OpenWrite(filePath)) + { + var totalLength = response.Content.Headers.ContentLength; + if (totalLength.HasValue) + { + progress?.Invoke(0); + } + + var totalRead = 0L; + var reads = 0L; + const int bufferLength = 8192; + var buffer = ArrayPool.Shared.Rent(bufferLength); + var isMoreToRead = true; + + do + { + var read = await contentStream.ReadAsync(buffer, 0, bufferLength); + if (read == 0) + { + isMoreToRead = false; + } + else + { + await fileStream.WriteAsync(buffer, 0, read); + + reads += 1; + totalRead += read; + if (totalLength.HasValue && reads % 20 == 0) + { + progress?.Invoke(totalRead / (float) totalLength.Value); + } + } + } while (isMoreToRead); + } + } + }); + } + } +} diff --git a/SS14.Launcher/JenkinsData.cs b/SS14.Launcher/JenkinsData.cs new file mode 100644 index 0000000000..3b133ab9a7 --- /dev/null +++ b/SS14.Launcher/JenkinsData.cs @@ -0,0 +1,19 @@ +using System; + +namespace SS14.Launcher +{ +#pragma warning disable 649 + [Serializable] + internal class JenkinsJobInfo + { + public JenkinsBuildRef LastSuccessfulBuild; + } + + [Serializable] + internal class JenkinsBuildRef + { + public int Number; + public string Url; + } +#pragma warning restore 649 +} diff --git a/SS14.Launcher/Program.cs b/SS14.Launcher/Program.cs new file mode 100644 index 0000000000..80ece2907b --- /dev/null +++ b/SS14.Launcher/Program.cs @@ -0,0 +1,392 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Content.Client.UserInterface; +using Content.Client.Utility; +using JetBrains.Annotations; +using Newtonsoft.Json; +using Robust.Client.Graphics.Drawing; +using Robust.Client.Interfaces.ResourceManagement; +using Robust.Client.Interfaces.UserInterface; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.Utility; +using Robust.Lite; +using Robust.Shared.Asynchronous; +using Robust.Shared.IoC; +using Robust.Shared.Localization; +using Robust.Shared.Log; +using Robust.Shared.Maths; +using Robust.Shared.Utility; +using static Robust.Client.UserInterface.Control; + +namespace SS14.Launcher +{ + internal class Program + { + private const string JenkinsBaseUrl = "https://builds.spacestation14.io/jenkins"; + private const string JenkinsJobName = "SS14 Content"; + private const string CurrentLauncherVersion = "1"; + + private readonly HttpClient _httpClient; + private string _dataDir; + + private LauncherInterface _interface; + private string ClientBin => Path.Combine(_dataDir, "client_bin"); + +#pragma warning disable 649 + [Dependency] private readonly ILocalizationManager _loc; + [Dependency] private readonly ITaskManager _taskManager; + [Dependency] private readonly IUriOpener _uriOpener; + [Dependency] private readonly IUserInterfaceManager _userInterfaceManager; +#pragma warning restore 649 + + public static void Main(string[] args) + { + LiteLoader.Run(() => new Program().Run(), new InitialWindowParameters + { + Size = (500, 250), + WindowTitle = "SS14 Launcher" + }); + } + + private Program() + { + IoCManager.InjectDependencies(this); + + _httpClient = new HttpClient(); + _httpClient.DefaultRequestHeaders.Add("User-Agent", $"SS14.Launcher v{CurrentLauncherVersion}"); + } + + private async void Run() + { + _dataDir = Path.Combine(UserDataDir.GetUserDataDir(), "launcher"); + + _userInterfaceManager.Stylesheet = new NanoStyle().Stylesheet; + + _interface = new LauncherInterface + { + ProgressBarVisible = false + }; + + _interface.StatusLabel.Text = _loc.GetString("Checking for launcher update.."); + _userInterfaceManager.StateRoot.AddChild(_interface.RootControl); + + try + { + var needsUpdate = await NeedLauncherUpdate(); + if (needsUpdate) + { + _interface.StatusLabel.Text = _loc.GetString("This launcher is out of date."); + _interface.LaunchButton.Text = _loc.GetString("Download update."); + _interface.LaunchButton.OnPressed += + _ => _uriOpener.OpenUri("https://spacestation14.io/about/nightlies/"); + _interface.LaunchButton.Disabled = false; + return; + } + + await RunUpdate(); + + _interface.StatusLabel.Text = _loc.GetString("Ready!"); + _interface.LaunchButton.Disabled = false; + _interface.LaunchButton.OnPressed += _ => LaunchClient(); + } + catch (Exception e) + { + Logger.ErrorS("launcher", "Exception while trying to run updates:\n{0}", e); + _interface.ProgressBarVisible = false; + _interface.StatusLabel.Text = + _loc.GetString("An error occured.\nMake sure you can access builds.spacestation14.io"); + } + } + + private async Task NeedLauncherUpdate() + { + var launcherVersionUri = + new Uri($"{JenkinsBaseUrl}/userContent/current_launcher_version.txt"); + var versionRequest = await _httpClient.GetAsync(launcherVersionUri); + versionRequest.EnsureSuccessStatusCode(); + return CurrentLauncherVersion != (await versionRequest.Content.ReadAsStringAsync()).Trim(); + } + + private async Task RunUpdate() + { + _interface.StatusLabel.Text = _loc.GetString("Checking for client update.."); + + Logger.InfoS("launcher", "Checking for update..."); + + var jobUri = new Uri($"{JenkinsBaseUrl}/job/{Uri.EscapeUriString(JenkinsJobName)}/api/json"); + var jobDataResponse = await _httpClient.GetAsync(jobUri); + if (!jobDataResponse.IsSuccessStatusCode) + { + throw new Exception($"Got bad status code {jobDataResponse.StatusCode} from Jenkins."); + } + + var jobInfo = JsonConvert.DeserializeObject( + await jobDataResponse.Content.ReadAsStringAsync()); + var latestBuildNumber = jobInfo.LastSuccessfulBuild.Number; + + var versionFile = Path.Combine(_dataDir, "current_build"); + bool needUpdate; + if (File.Exists(versionFile)) + { + var buildNumber = int.Parse(File.ReadAllText(versionFile, EncodingHelpers.UTF8), + CultureInfo.InvariantCulture); + needUpdate = buildNumber != latestBuildNumber; + if (needUpdate) + { + Logger.InfoS("launcher", "Current version ({0}) is out of date, updating to {1}.", buildNumber, + latestBuildNumber); + } + } + else + { + Logger.InfoS("launcher", "As it turns out, we don't have any version yet. Time to update."); + // Version file doesn't exist, assume first run or whatever. + needUpdate = true; + } + + if (!needUpdate) + { + Logger.InfoS("launcher", "No update needed!"); + return; + } + + _interface.StatusLabel.Text = _loc.GetString("Downloading client update.."); + _interface.ProgressBarVisible = true; + var binPath = Path.Combine(_dataDir, "client_bin"); + + await Task.Run(() => + { + if (!Directory.Exists(binPath)) + { + Directory.CreateDirectory(binPath); + } + else + { + Helpers.ClearDirectory(binPath); + } + }); + + // We download the artifact to a temporary file on disk. + // This is to avoid having to load the entire thing into memory. + // (.NET's zip code loads it into a memory stream if the stream you give it doesn't support seeking.) + // (this makes a lot of sense due to how the zip file format works.) + var tmpFile = await _downloadArtifactToTempFile(latestBuildNumber, GetBuildFilename()); + _interface.StatusLabel.Text = _loc.GetString("Extracting update.."); + _interface.ProgressBarVisible = false; + + await Task.Run(() => + { + using (var file = File.OpenRead(tmpFile)) + { + Helpers.ExtractZipToDirectory(binPath, file); + } + + File.Delete(tmpFile); + }); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + // .NET's zip extraction system doesn't seem to preserve +x. + // Technically can't blame it because there's no "official" way to store that, + // since zip files are DOS-centric. + + // Manually chmod +x the App bundle then. + var process = Process.Start(new ProcessStartInfo + { + FileName = "chmod", + Arguments = $"+x '{Path.Combine("Space Station 14.app", "Contents", "MacOS", "SS14")}'", + WorkingDirectory = binPath, + }); + process?.WaitForExit(); + } + + // Write version to disk. + File.WriteAllText(versionFile, latestBuildNumber.ToString(CultureInfo.InvariantCulture), + EncodingHelpers.UTF8); + + Logger.InfoS("launcher", "Update done!"); + } + + private async Task _downloadArtifactToTempFile(int buildNumber, string fileName) + { + var artifactUri + = new Uri( + $"{JenkinsBaseUrl}/job/{Uri.EscapeUriString(JenkinsJobName)}/{buildNumber}/artifact/release/{Uri.EscapeUriString(fileName)}"); + + var tmpFile = Path.GetTempFileName(); + Logger.InfoS("launcher", tmpFile); + await _httpClient.DownloadToFile(artifactUri, tmpFile, f => _taskManager.RunOnMainThread(() => + { + _interface.ProgressBarVisible = true; + _interface.ProgressBar.Value = f; + })); + + return tmpFile; + } + + private void LaunchClient() + { + var binPath = ClientBin; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "mono", + Arguments = "Robust.Client.exe", + WorkingDirectory = binPath, + UseShellExecute = false, + }, + }; + process.Start(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Process.Start(new ProcessStartInfo + { + FileName = Path.Combine(binPath, "Robust.Client.exe"), + }); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + // TODO: does this cause macOS to make a security warning? + // If it does we'll have to manually launch the contents, which is simple enough. + Process.Start(new ProcessStartInfo + { + FileName = "open", + Arguments = "'Space Station 14.app'", + WorkingDirectory = binPath, + }); + } + else + { + throw new NotSupportedException("Unsupported platform."); + } + } + + + [Pure] + private static string GetBuildFilename() + { + string platform; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + platform = "Windows"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + platform = "Linux"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + platform = "macOS"; + } + else + { + throw new NotSupportedException("Unsupported platform."); + } + + return $"SS14.Client_{platform}_x64.zip"; + } + + + private class LauncherInterface + { +#pragma warning disable 649 + [Dependency] private readonly IResourceCache _resourceCache; + [Dependency] private readonly ILocalizationManager _loc; + [Dependency] private readonly IUriOpener _uriOpener; + private bool _progressBarVisible; +#pragma warning restore 649 + + public Control RootControl { get; } + public Label StatusLabel { get; } + public ProgressBar ProgressBar { get; } + public Button LaunchButton { get; } + + public bool ProgressBarVisible + { + get => _progressBarVisible; + set + { + _progressBarVisible = value; + ProgressBar.Visible = value; + StatusLabel.SizeFlagsHorizontal = value ? SizeFlags.Fill : SizeFlags.FillExpand; + } + } + + public LauncherInterface() + { + IoCManager.InjectDependencies(this); + + Button visitWebsiteButton; + + RootControl = new PanelContainer + { + PanelOverride = new StyleBoxFlat + { + BackgroundColor = Color.FromHex("#20202a"), + ContentMarginLeftOverride = 4, + ContentMarginRightOverride = 4, + ContentMarginBottomOverride = 4, + ContentMarginTopOverride = 4 + }, + Children = + { + new VBoxContainer + { + Children = + { + new Label + { + Text = _loc.GetString("Space Station 14"), + FontOverride = _resourceCache.GetFont("/Fonts/Animal Silence.otf", 40), + SizeFlagsHorizontal = SizeFlags.ShrinkCenter + }, + + (visitWebsiteButton = new Button + { + SizeFlagsHorizontal = SizeFlags.ShrinkCenter, + SizeFlagsVertical = SizeFlags.Expand | SizeFlags.ShrinkCenter, + Text = _loc.GetString("Visit website") + }), + + new HBoxContainer + { + SizeFlagsVertical = SizeFlags.ShrinkEnd, + SeparationOverride = 5, + Children = + { + (StatusLabel = new Label()), + (ProgressBar = new ProgressBar + { + SizeFlagsHorizontal = SizeFlags.FillExpand, + MinValue = 0, + MaxValue = 1 + }), + (LaunchButton = new Button + { + Disabled = true, + Text = _loc.GetString("Launch!") + }) + } + } + } + } + } + }; + + visitWebsiteButton.OnPressed += _ => _uriOpener.OpenUri("https://spacestation14.io"); + + RootControl.SetAnchorPreset(LayoutPreset.Wide); + } + } + } +} diff --git a/SS14.Launcher/SS14.Launcher.csproj b/SS14.Launcher/SS14.Launcher.csproj new file mode 100644 index 0000000000..6198b6196b --- /dev/null +++ b/SS14.Launcher/SS14.Launcher.csproj @@ -0,0 +1,24 @@ + + + + net472 + 7.3 + false + x64;x86 + false + ..\bin\SS14.Launcher\ + Exe + + + + + + + + + + + + + + diff --git a/SpaceStation14.sln b/SpaceStation14.sln index 878f0c3e6d..734804dfef 100644 --- a/SpaceStation14.sln +++ b/SpaceStation14.sln @@ -40,6 +40,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Content.IntegrationTests", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Content.Benchmarks", "Content.Benchmarks\Content.Benchmarks.csproj", "{7AC832A1-2461-4EB5-AC26-26F6AFFA9E46}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Robust.Lite", "RobustToolbox\Robust.Lite\Robust.Lite.csproj", "{0131AAE0-EF7D-4985-86FD-59EEC31B9A5C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SS14.Launcher", "SS14.Launcher\SS14.Launcher.csproj", "{47B4D2F3-6767-4ACB-A803-972FEF7215E9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -151,6 +155,22 @@ Global {7AC832A1-2461-4EB5-AC26-26F6AFFA9E46}.Release|x64.Build.0 = Release|x64 {7AC832A1-2461-4EB5-AC26-26F6AFFA9E46}.Release|x86.ActiveCfg = Release|x86 {7AC832A1-2461-4EB5-AC26-26F6AFFA9E46}.Release|x86.Build.0 = Release|x86 + {0131AAE0-EF7D-4985-86FD-59EEC31B9A5C}.Debug|x64.ActiveCfg = Debug|x64 + {0131AAE0-EF7D-4985-86FD-59EEC31B9A5C}.Debug|x64.Build.0 = Debug|x64 + {0131AAE0-EF7D-4985-86FD-59EEC31B9A5C}.Debug|x86.ActiveCfg = Debug|x86 + {0131AAE0-EF7D-4985-86FD-59EEC31B9A5C}.Debug|x86.Build.0 = Debug|x86 + {0131AAE0-EF7D-4985-86FD-59EEC31B9A5C}.Release|x64.ActiveCfg = Release|x64 + {0131AAE0-EF7D-4985-86FD-59EEC31B9A5C}.Release|x64.Build.0 = Release|x64 + {0131AAE0-EF7D-4985-86FD-59EEC31B9A5C}.Release|x86.ActiveCfg = Release|x86 + {0131AAE0-EF7D-4985-86FD-59EEC31B9A5C}.Release|x86.Build.0 = Release|x86 + {47B4D2F3-6767-4ACB-A803-972FEF7215E9}.Debug|x64.ActiveCfg = Debug|x64 + {47B4D2F3-6767-4ACB-A803-972FEF7215E9}.Debug|x64.Build.0 = Debug|x64 + {47B4D2F3-6767-4ACB-A803-972FEF7215E9}.Debug|x86.ActiveCfg = Debug|x86 + {47B4D2F3-6767-4ACB-A803-972FEF7215E9}.Debug|x86.Build.0 = Debug|x86 + {47B4D2F3-6767-4ACB-A803-972FEF7215E9}.Release|x64.ActiveCfg = Release|x64 + {47B4D2F3-6767-4ACB-A803-972FEF7215E9}.Release|x64.Build.0 = Release|x64 + {47B4D2F3-6767-4ACB-A803-972FEF7215E9}.Release|x86.ActiveCfg = Release|x86 + {47B4D2F3-6767-4ACB-A803-972FEF7215E9}.Release|x86.Build.0 = Release|x86 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -163,6 +183,7 @@ Global {93F23A82-00C5-4572-964E-E7C9457726D4} = {83B4CBBA-547A-42F0-A7CD-8A67D93196CE} {F0ADA779-40B8-4F7E-BA6C-CDB19F3065D9} = {83B4CBBA-547A-42F0-A7CD-8A67D93196CE} {0529F740-0000-0000-0000-000000000000} = {83B4CBBA-547A-42F0-A7CD-8A67D93196CE} + {0131AAE0-EF7D-4985-86FD-59EEC31B9A5C} = {83B4CBBA-547A-42F0-A7CD-8A67D93196CE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AA37ED9F-F8D6-468E-A101-658AD605B09A}