edge-install/Program.cs
Robert Morrison 79063f97ee
feat: Add installing
It can install now.
Requires pexec to be present

This may be due to an upstream issue with spectre.console affecting how
stdin/stdout are handled.

TODO:
 - Add backup/restore functionality
 - Refactor sections for neatness
 - Improve UX
2023-06-08 11:22:00 +01:00

342 lines
13 KiB
C#

//using System.CommandLine;
using Serilog;
using Spectre.Console;
using System.Text.Json;
using System.Text;
using SharpCompress.Compressors.Xz;
using System.Formats.Tar;
using System.Diagnostics;
namespace EdgeInstaller;
public static partial class EdgeInstall
{
// NOTE: tweaking these values can be used to make downloads look more "fancy"
// A.K.A make downloads take longer so the progress bar appears for a little longer
private static readonly int downloadDelayMS = 0;
private static readonly int downloadBufferBYTES = 10240;
// The default architecture to get
// likely will go unused until proper cli interface is added
private static readonly string _DefaultArch = "amd64";
private static HttpClient _client = new HttpClient();
private static readonly string DataDir =
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ClosedLess",
"EdgeInstall-Jankmode");
public static async Task<int> Main()
{
_client.BaseAddress = new Uri("https://packages.microsoft.com/repos/edge/");
// Ensure DataDir exists.
// NOTE: Directory.CreateDirectory will ALWAYS create the entire path
Directory.CreateDirectory(DataDir);
BUGFIX_ansiDetection();
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.File(Path.Combine(DataDir, ".EdgeInstaller.Log")) // Only ever log to the logfile
.CreateLogger();
var Release = AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Getting Latest Release data",
_ => GetRelease()
);
string ReleaseString = await Release;
Log.Debug("{rs}", ReleaseString);
AnsiConsole.MarkupLine("[green]Attempting to decode release data now[/]");
ReleaseData data = new(ReleaseString);
RenderReleaseTable(data);
// only select arch if needed
string TargetArch = data.Architectures.Count > 1 ? AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Choose Architecture")
.PageSize(5)
.MoreChoicesText("[grey]Scroll for more...[/]")
.AddChoices(data.Architectures)
) : data.Architectures.First();
// create a directory for the chosen architecture
// NOTE: we also grab the path here so we can use it later.
var Archdir = Directory.CreateDirectory(Path.Combine(DataDir, TargetArch)).FullName;
// Check if the Release data we just grabbed is newer than the one that exists.
// NOTE: we only do this if our chosen architecture hasn't been downloaded
bool NeedDownload = true;
Log.Information("Reached Package download Stage");
if (File.Exists(Path.Combine(Archdir, "Packages.gz")))
{
Log.Information("Checking for release.json");
// we need to check the release since we have an existing package.gz
string JSONstring = File.ReadAllText(Path.Combine(DataDir, "release.json"));
ReleaseData? oldData = JsonSerializer.Deserialize<ReleaseData>(JSONstring);
if (oldData is not null)
{
// if equal then our data is the newest available
if (DateTime.Compare(oldData.Date, data.Date) == 0)
{
Log.Information("Our packages.gz is up to date");
NeedDownload = false;
}
}
}
if (NeedDownload)
{
PackageFile gzFile =
data.PackageFiles
.Where(
file =>
file.filename.Contains(".gz") &&
file.filename.Contains(TargetArch)) // ensure we only fetch for the chosen architecture
.First();
if (gzFile is null)
{
throw new NullReferenceException("OOPS gzfile null");
}
string URL = "dists/stable/" + gzFile.filename;
string filename = await AnsiConsole.Progress()
.Columns(new ProgressColumn[]
{
new TaskDescriptionColumn(),
new ProgressBarColumn(),
new PercentageColumn(),
new RemainingTimeColumn(),
new SpinnerColumn(),
})
.StartAsync<string>(async ctx =>
{
var task = ctx.AddTask(URL, new ProgressTaskSettings { AutoStart = false });
return await downloadFile(URL, Archdir, task);
});
string JSONdata = JsonSerializer.Serialize<ReleaseData>(data);
File.WriteAllText(Path.Combine(DataDir, "release.json"), JSONdata);
if (!ValidateChecksum(new FileInfo(filename), gzFile.Checksum))
{
File.Delete(filename);
Environment.Exit(1);
}
}
string PackagesFile = Path.Combine(Archdir, "Packages.gz");
string PackagesString = GetPackagesString(PackagesFile);
AnsiConsole.MarkupLineInterpolated($"Read {PackagesFile} see log for contents");
Log.Debug("{packagesString}", PackagesString);
List<Package> packages = new();
StringBuilder sb = new();
foreach (var line in PackagesString.Split('\n'))
{
if (line == "")
{
string PackageString = sb.ToString();
if (PackageString == "")
{
continue; // The last "package" will be empty
}
sb.Clear(); // ensure we clear the StringBuilder when we are done with it.
try
{ // Just in case we somehow get a broken Package
Package p = new Package(PackageString);
packages.Add(p);
}
catch (Exception e)
{
AnsiConsole.MarkupLine("[red]Error:[/] Could not create package from string");
AnsiConsole.MarkupLine("[yellow i]see log for more details[/]");
Log.Error("Could not create Package from passed string:\n{string}", PackageString);
Log.Error(e, "Exception thrown");
}
}
sb.AppendLine(line);
}
/* INFO: This LINQ query allows sorting.
* Pay special attention to the usage of the version comparer
*/
packages = packages
.OrderBy(package => package.PackageName)
.ThenByDescending(package => package.Version).ToList();
// Print any packages we have
var Variant = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Choose variant")
.PageSize(4)
.AddChoices(new[] {
"stable",
"beta",
"dev",
"all"
})
);
List<Package> VariantPackages = new();
if (Variant == "" || Variant == "all")
{
VariantPackages = packages;
}
else
{
VariantPackages = packages
.Where(package =>
package.PackageName.Contains(Variant))
.ToList();
}
RenderPackagesTable(VariantPackages);
var Package = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Choose Package to Install")
.PageSize(5)
.AddChoices(VariantPackages.ConvertAll(pack => pack.PackageName).Distinct())
);
var ChosenPackages = packages
.Where(package =>
package.PackageName == Package)
.ToList();
Package LatestVersion = ChosenPackages.OrderByDescending(pack => pack.Version).First();
RenderPackageAsTable(LatestVersion);
var versionsDir = Path.Join(Archdir, "Versions");
var versionDir = Path.Join(versionsDir, LatestVersion.PackageName, LatestVersion.Version.ToString());
Directory.CreateDirectory(versionDir);
// Let's get and unpack the latest version
// for the moment we will always just grab
using (MemoryStream debStream =
await AnsiConsole.Progress()
.Columns(new ProgressColumn[]
{
new TaskDescriptionColumn(),
new ProgressBarColumn(),
new PercentageColumn(),
new RemainingTimeColumn(),
new SpinnerColumn(),
})
.StartAsync<MemoryStream>(async ctx =>
{
var task = ctx.AddTask(LatestVersion.Filename, new ProgressTaskSettings { AutoStart = false });
return await downloadStream(LatestVersion.Filename, task);
}))
{
if (!ValidateChecksum(debStream, LatestVersion.SHA256))
{
AnsiConsole.MarkupLine("[red b]ERROR:[/] checksum did not validate");
Environment.Exit(1);
}
using (DebFile debfile = new DebFile(debStream))
{
// NOTE: deb entries have trailing /
DebEntry? dataEntry = debfile.fileEntries.Find(file => file.name == "data.tar.xz/");
if (dataEntry is null)
{
AnsiConsole.MarkupLine("[red b]ERROR:[/] debfile does not contain data.tar.xz");
Environment.Exit(1);
}
// Looks like we have a hit. Lets try and extract it
if (!debfile.getFile(dataEntry, out Stream dataStream))
{
AnsiConsole.MarkupLine("[red b]ERROR:[/] couldn't get stream for data.tar.xz");
Environment.Exit(1);
}
using (XZStream dataXZStream = new(dataStream))
{
using (TarReader reader = new(dataXZStream))
{
while (true)
{
var tarEntry = reader.GetNextEntry();
if (tarEntry is null)
{
break; // TarReader returns null when we hit the end of the stream
}
AnsiConsole.MarkupLineInterpolated($"TarEntry {tarEntry.Name}");
// work out where we need to put the extracted file
var filepath = Path.Combine(versionDir, tarEntry.Name);
if (tarEntry.EntryType == TarEntryType.Directory)
{
// createDirectory doesn't fail if already exists
Directory.CreateDirectory(filepath);
continue;
}
if (tarEntry.LinkName != "")
{
// Handle links manually
if (File.Exists(filepath))
{
File.Delete(filepath);
}
File.CreateSymbolicLink(filepath, tarEntry.LinkName);
continue; // we are done with this file so keep going
}
tarEntry.ExtractToFile(filepath, overwrite: true);
}
}
}
// Now assuming nothing broke before now we can copy the files into the system directories.
// WARN: This is dangerous stuff and involves making system calls.
// NOTE: be sure when calling to other programs to wait until they exit.
var Mover = new ProcessStartInfo();
Mover.FileName = "pkexec";
Mover.Arguments = $"cp -dri {versionDir}/opt {versionDir}/usr /";
Mover.UseShellExecute = true;
var pr = Process.Start(Mover);
pr?.WaitForExit();
}
}
return 0;
}
/*TODO:
- Check version
- Offer update
- Make SENSIBLE backup
- Do install
= if install fails revert backup
*/
}