launcher/Kiseki.Launcher.Windows/Bootstrapper.cs

517 lines
20 KiB
C#

namespace Kiseki.Launcher.Windows;
using System.Diagnostics;
using System.IO.Compression;
using System.Net;
using System.Reflection;
using System.Security.Cryptography;
using Kiseki.Launcher.Utilities;
using Kiseki.Launcher.Models;
using Microsoft.Win32;
using Syroot.Windows.IO;
public class Bootstrapper : Interfaces.IBootstrapper
{
public readonly static string Version = Assembly.GetExecutingAssembly().GetName().Version!.ToString()[..^2];
private readonly string Payload;
private readonly Dictionary<string, string> Arguments = new();
public event EventHandler<string>? OnHeadingChange;
public event EventHandler<int>? OnProgressBarSet;
public event EventHandler<Enums.ProgressBarState>? OnProgressBarStateChange;
public event EventHandler<string[]>? OnError;
public Bootstrapper(string payload)
{
// TODO: Do this in a better way?
Payload = payload.Replace($"{Constants.PROTOCOL_KEY}://", "");
if (Payload[^1] == '/')
Payload = Payload[..^1];
}
public bool Initialize()
{
if (!Base64.IsBase64String(Payload))
{
MessageBox.Show(Payload);
return false;
}
// { mode, version, ticket, joinscript }
string[] pieces = Base64.ConvertBase64ToString(Payload).Split("|");
if (pieces.Length != 4)
{
MessageBox.Show(pieces.Length.ToString());
return false;
}
Arguments["Mode"] = pieces[0];
Arguments["Version"] = pieces[1];
Arguments["Ticket"] = pieces[2];
Arguments["JoinScript"] = pieces[3];
return true;
}
public async void Run()
{
// Check for updates
HeadingChange("Checking for updates...");
/*
// Check for a new launcher release from GitHub
var launcherRelease = await Http.GetJson<GitHubRelease>($"https://api.github.com/repos/{Constants.PROJECT_REPOSITORY}/releases/latest");
bool launcherUpToDate = true;
// TODO: We can remove this check once we do our first release.
if (launcherRelease is not null && launcherRelease.Assets is not null)
{
launcherUpToDate = Version == launcherRelease.TagName[1..];
if (!launcherUpToDate)
{
// Update the launcher
HeadingChange("Getting the latest launcher...");
ProgressBarStateChange(Enums.ProgressBarState.Normal);
// TODO: This needs to be rewritten. It's a mess.
// REF: https://stackoverflow.com/a/9459441
Thread thread = new(() => {
using WebClient client = new();
client.DownloadProgressChanged += (_, e) => {
double bytesIn = double.Parse(e.BytesReceived.ToString());
double totalBytes = double.Parse(e.TotalBytesToReceive.ToString());
double percentage = bytesIn / totalBytes * 100;
ProgressBarSet(int.Parse(Math.Truncate(percentage).ToString()));
};
client.DownloadFileCompleted += (_, _) => {
HeadingChange("Installing the latest launcher...");
ProgressBarStateChange(Enums.ProgressBarState.Marquee);
// Rename Kiseki.Launcher.exe.new -> Kiseki.Launcher.exe, and launch it with our payload
string command = $"del /Q \"{Paths.Application}\" && move /Y \"{Paths.Application}.new\" \"{Paths.Application}\" && start \"\" \"{Paths.Application}\" {Payload}";
Process.Start(new ProcessStartInfo()
{
FileName = "cmd.exe",
Arguments = $"/c timeout 1 && {command}",
UseShellExecute = true,
WindowStyle = ProcessWindowStyle.Hidden
});
Environment.Exit((int)Win32.ErrorCode.ERROR_SUCCESS);
};
client.DownloadFileAsync(new Uri(launcherRelease.Assets[0].BrowserDownloadUrl), $"{Paths.Application}.new");
});
thread.Start();
return;
}
}
*/
var clientRelease = await Http.GetJson<ClientRelease>(Web.FormatUrl($"/api/setup/{Arguments["Version"]}"));
bool clientUpToDate = true;
bool createStudioShortcut = false;
if (clientRelease is null)
{
Error($"Failed to check for {Constants.PROJECT_NAME} updates", $"Failed to check for {Constants.PROJECT_NAME} updates. Please try again later.");
return;
}
if (!Directory.Exists(Path.Combine(Paths.Versions, Arguments["Version"])))
{
Directory.CreateDirectory(Path.Combine(Paths.Versions, Arguments["Version"]));
clientUpToDate = false;
createStudioShortcut = true;
}
else
{
// Compute checksums of the required binaries
for (int i = 0; i < clientRelease.Checksums.Count; i++)
{
string file = clientRelease.Checksums.ElementAt(i).Key;
string checksum = clientRelease.Checksums.ElementAt(i).Value;
if (!File.Exists(Path.Combine(Paths.Versions, Arguments["Version"], file)))
{
clientUpToDate = false;
createStudioShortcut = true;
break;
}
using SHA256 SHA256 = SHA256.Create();
using FileStream fileStream = File.OpenRead(Path.Combine(Paths.Versions, Arguments["Version"], file));
byte[] hashBytes = SHA256.ComputeHash(fileStream);
string computedChecksum = BitConverter.ToString(hashBytes).Replace("-", "");
if (checksum.ToLower() != computedChecksum.ToLower())
{
clientUpToDate = false;
break;
}
}
}
bool deleteArchive = false;
if (!clientUpToDate)
{
// Download the required binaries
HeadingChange($"Getting the latest Kiseki {Arguments["Version"]}...");
// Delete all files in the version directory
Directory.Delete(Path.Combine(Paths.Versions, Arguments["Version"]), true);
Directory.CreateDirectory(Path.Combine(Paths.Versions, Arguments["Version"]));
// Download archive in a synchronous way so that checksum doesn't get tangled
// Create a new HttpClient
using (HttpClient client = new())
{
// Send a GET request to the URL specified in clientRelease.Asset.Url
using (HttpResponseMessage response = await client.GetAsync(clientRelease.Asset.Url))
{
// Get the response content as a stream
using (Stream responseStream = await response.Content.ReadAsStreamAsync())
{
// Create a new file stream to save the downloaded file
using (FileStream archiveStream = new FileStream(Path.Combine(Paths.Versions, Arguments["Version"], "archive.zip"), FileMode.Create))
{
// Read the response stream and write it to the file stream
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = await responseStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
await archiveStream.WriteAsync(buffer, 0, bytesRead);
}
}
}
}
}
// Compare archive checksum
using SHA256 SHA256 = SHA256.Create();
using FileStream fileStream = File.OpenRead(Path.Combine(Paths.Versions, Arguments["Version"], "archive.zip"));
byte[] hashBytes = SHA256.ComputeHash(fileStream);
string computedChecksum = BitConverter.ToString(hashBytes).Replace("-", "");
if (clientRelease.Asset.Checksum.ToLower() != computedChecksum.ToLower())
{
Error($"Failed to update {Constants.PROJECT_NAME} {Arguments["Version"]}", $"Failed to update {Constants.PROJECT_NAME}. Please try again later.");
return;
}
// Extract archive
HeadingChange($"Installing Kiseki {Arguments["Version"]}...");
ProgressBarStateChange(Enums.ProgressBarState.Marquee);
ZipFile.ExtractToDirectory(Path.Combine(Paths.Versions, Arguments["Version"], "archive.zip"), Path.Combine(Paths.Versions, Arguments["Version"]));
deleteArchive = true;
}
if (createStudioShortcut)
{
if (!Directory.Exists(Paths.StartMenu))
Directory.CreateDirectory(Paths.StartMenu);
if (File.Exists(Path.Combine(Paths.StartMenu, $"{Constants.PROJECT_NAME} Studio {Arguments["Version"]}.lnk")))
File.Delete(Path.Combine(Paths.StartMenu, $"{Constants.PROJECT_NAME} Studio {Arguments["Version"]}.lnk"));
string studioPath = Path.Combine(Paths.Versions, Arguments["Version"], $"{Constants.PROJECT_NAME}.Studio.exe");
ShellLink.Shortcut.CreateShortcut(studioPath, "", studioPath, 0)
.WriteToFile(Path.Combine(Paths.StartMenu, $"{Constants.PROJECT_NAME} Studio {Arguments["Version"]}.lnk"));
}
// We're done! Launch the game.
HeadingChange("Launching Kiseki...");
Process player = new()
{
StartInfo = new()
{
FileName = Path.Combine(Paths.Versions, Arguments["Version"], $"{Constants.PROJECT_NAME}.Player.exe"),
Arguments = $"-a \"{Web.FormatUrl("/Login/Negotiate.ashx", null, true)}\" -t \"{Arguments["Ticket"]}\" -j \"{Arguments["JoinScript"]}\" ",
UseShellExecute = true,
}
};
Task waiter = new(async () => {
bool launched = false;
while (!launched)
{
await Task.Delay(100);
launched = Win32.IsWindowVisible(player.MainWindowHandle);
}
if (deleteArchive)
File.Delete(Path.Combine(Paths.Versions, Arguments["Version"], "archive.zip"));
Environment.Exit((int)Win32.ErrorCode.ERROR_SUCCESS);
});
await Task.Run(() => {
player.Start();
player.WaitForInputIdle();
waiter.Start();
});
}
#region MainWindow
protected virtual void HeadingChange(string heading)
{
OnHeadingChange!.Invoke(this, heading);
}
protected virtual void ProgressBarSet(int value)
{
OnProgressBarSet!.Invoke(this, value);
}
protected virtual void ProgressBarStateChange(Enums.ProgressBarState state)
{
OnProgressBarStateChange!.Invoke(this, state);
}
protected virtual void Error(string heading, string text)
{
// Ugly hack for now (I don't want to derive EventHandler just for this)
OnError!.Invoke(this, new string[] { heading, text });
}
#endregion
#region Installation
public static void Install()
{
// Cleanup our registry entries beforehand (if they even exist)
Protocol.Unregister();
Register();
// Create paths
Directory.CreateDirectory(Paths.Base);
Directory.CreateDirectory(Paths.Versions);
Directory.CreateDirectory(Paths.Logs);
// Copy ourselves
if (!File.Exists(Paths.Application))
File.Copy(Application.ExecutablePath, Paths.Application, true);
// Register us and our protocol handler system-wide
Register();
Protocol.Register();
// Create shortcuts
if (!Directory.Exists(Paths.StartMenu))
Directory.CreateDirectory(Paths.StartMenu);
if (File.Exists(Path.Combine(Paths.StartMenu, $"Play {Constants.PROJECT_NAME}.lnk")))
File.Delete(Path.Combine(Paths.StartMenu, $"Play {Constants.PROJECT_NAME}.lnk"));
if (File.Exists(Path.Combine(Paths.Desktop, $"{Constants.PROJECT_NAME}.lnk")))
File.Delete(Path.Combine(Paths.Desktop, $"{Constants.PROJECT_NAME}.lnk"));
if (!Directory.Exists(Paths.StartMenu))
Directory.CreateDirectory(Paths.StartMenu);
ShellLink.Shortcut.CreateShortcut(Paths.Application, "", Paths.Application, 0)
.WriteToFile(Path.Combine(Paths.StartMenu, $"{Constants.PROJECT_NAME}.lnk"));
ShellLink.Shortcut.CreateShortcut(Paths.Application, "", Paths.Application, 0)
.WriteToFile(Path.Combine(Paths.Desktop, $"{Constants.PROJECT_NAME}.lnk"));
// We're finished
MessageBox.Show($"Sucessfully installed {Constants.PROJECT_NAME}!", Constants.PROJECT_NAME, MessageBoxButtons.OK, MessageBoxIcon.Information);
Environment.Exit((int)Win32.ErrorCode.ERROR_SUCCESS);
}
public static void Uninstall(bool quiet = false)
{
DialogResult answer = quiet ? DialogResult.Yes : MessageBox.Show($"Are you sure you want to uninstall {Constants.PROJECT_NAME}?", Constants.PROJECT_NAME, MessageBoxButtons.YesNo, MessageBoxIcon.Warning);
if (answer != DialogResult.Yes)
Environment.Exit((int)Win32.ErrorCode.ERROR_CANCELLED);
// Close active processes
if (Process.GetProcessesByName($"{Constants.PROJECT_NAME}.Player").Any() || Process.GetProcessesByName($"{Constants.PROJECT_NAME}.Studio").Any())
{
answer = quiet ? DialogResult.Yes : MessageBox.Show($"Kiseki is currently running. Would you like to close Kiseki now?", Constants.PROJECT_NAME, MessageBoxButtons.YesNo, MessageBoxIcon.Warning);
if (answer != DialogResult.Yes)
Environment.Exit((int)Win32.ErrorCode.ERROR_CANCELLED);
try
{
foreach (Process process in Process.GetProcessesByName($"{Constants.PROJECT_NAME}.Player"))
process.Kill();
foreach (Process process in Process.GetProcessesByName($"{Constants.PROJECT_NAME}.Studio"))
process.Kill();
}
catch
{
Environment.Exit((int)Win32.ErrorCode.ERROR_INTERNAL_ERROR);
}
}
// Delete all files
if (Directory.Exists(Paths.Logs))
Directory.Delete(Paths.Logs, true);
if (Directory.Exists(Paths.Versions))
Directory.Delete(Paths.Versions, true);
if (File.Exists(Paths.License))
File.Delete(Paths.License);
// Delete our shortcuts
if (File.Exists(Path.Combine(Paths.StartMenu, $"{Constants.PROJECT_NAME}.lnk")))
File.Delete(Path.Combine(Paths.StartMenu, $"{Constants.PROJECT_NAME}.lnk"));
if (File.Exists(Path.Combine(Paths.Desktop, $"{Constants.PROJECT_NAME}.lnk")))
File.Delete(Path.Combine(Paths.Desktop, $"{Constants.PROJECT_NAME}.lnk"));
// Cleanup our registry entries
Unregister();
Protocol.Unregister();
answer = quiet ? DialogResult.OK : MessageBox.Show($"Sucessfully uninstalled {Constants.PROJECT_NAME}!", Constants.PROJECT_NAME, MessageBoxButtons.OK, MessageBoxIcon.Information);
if (answer == DialogResult.OK || answer == DialogResult.Cancel)
{
string command = $"del /Q \"{Paths.Application}\"";
if (Directory.GetFiles(Paths.Base, "*", SearchOption.AllDirectories).Length == 1)
{
// We're the only file in the directory, so we can delete the entire directory
command += $" && rmdir \"{Paths.Base}\"";
}
Process.Start(new ProcessStartInfo()
{
FileName = "cmd.exe",
Arguments = $"/c timeout 5 && {command}",
UseShellExecute = true,
WindowStyle = ProcessWindowStyle.Hidden
});
Environment.Exit((int)Win32.ErrorCode.ERROR_SUCCESS);
}
}
#endregion
#region Registration
public static void Register()
{
using RegistryKey uninstallKey = Registry.CurrentUser.CreateSubKey($@"Software\Microsoft\Windows\CurrentVersion\Uninstall\{Constants.PROJECT_NAME}");
uninstallKey.SetValue("NoModify", 1);
uninstallKey.SetValue("NoRepair", 1);
uninstallKey.SetValue("DisplayIcon", $"{Paths.Application},0");
uninstallKey.SetValue("DisplayName", Constants.PROJECT_NAME);
uninstallKey.SetValue("DisplayVersion", Version);
if (uninstallKey.GetValue("InstallDate") is null)
uninstallKey.SetValue("InstallDate", DateTime.Now.ToString("yyyyMMdd"));
uninstallKey.SetValue("InstallLocation", Paths.Base);
uninstallKey.SetValue("Publisher", Constants.PROJECT_NAME);
uninstallKey.SetValue("QuietUninstallString", $"\"{Paths.Application}\" -uninstall -quiet");
uninstallKey.SetValue("UninstallString", $"\"{Paths.Application}\" -uninstall");
uninstallKey.SetValue("URLInfoAbout", $"https://github.com/{Constants.PROJECT_REPOSITORY}");
uninstallKey.SetValue("URLUpdateInfo", $"https://github.com/{Constants.PROJECT_REPOSITORY}/releases/latest");
}
public static void Unregister()
{
try
{
Registry.CurrentUser.DeleteSubKey($@"Software\Microsoft\Windows\CurrentVersion\Uninstall\{Constants.PROJECT_NAME}");
}
catch
{
#if DEBUG
throw;
#endif
}
}
#endregion
#region Licensing
public static bool License()
{
if (!File.Exists(Paths.License))
{
if (!AskForLicense(Paths.License))
{
// User doesn't want to license this launcher
return false;
}
}
// Load the license...
while (!Web.License(File.ReadAllText(Paths.License)))
{
// ...and if it's corrupt, keep asking for a new one.
File.Delete(Paths.License);
MessageBox.Show($"Corrupt license file! Please verify the contents of your license file (it should be named \"license.bin\".)", Constants.PROJECT_NAME, MessageBoxButtons.OK, MessageBoxIcon.Error);
AskForLicense(Paths.License, false);
}
return true;
}
public static void Unlicense()
{
if (File.Exists(Paths.License))
{
File.Delete(Paths.License);
}
}
private static bool AskForLicense(string licensePath, bool showDialog = true)
{
DialogResult answer = showDialog ? MessageBox.Show($"{Constants.PROJECT_NAME} is currently undergoing maintenance and requires a license in order to access the test site. Would you like to look for the license file now?", Constants.PROJECT_NAME, MessageBoxButtons.YesNo, MessageBoxIcon.Warning) : DialogResult.Yes;
if (answer == DialogResult.Yes)
{
using OpenFileDialog dialog = new()
{
Title = "Select your license file",
Filter = "License files (*.bin)|*.bin",
InitialDirectory = KnownFolders.Downloads.Path
};
if (dialog.ShowDialog() == DialogResult.OK)
{
File.Copy(dialog.FileName, licensePath, true);
}
return true;
}
return false;
}
#endregion
}