//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 Main() { if (Environment.OSVersion.Platform != PlatformID.Unix) { Console.ForegroundColor = Color.Red; Console.Error.WriteLine("ERROR: You are not running this on a *nix host this program is not designed for other system types"); Environment.Exit(1); } _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(); // Ensure that Spectre.Console is forced to work properly. // TODO: Add code to change this based on profile // And also allow changing at runtime based on flags 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); // Either prompt for architecture selection OR use th eonly one available string TargetArch = data.Architectures.Count > 1 ? AnsiConsole.Prompt( new SelectionPrompt() .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 if (File.Exists(Path.Combine(DataDir, "release.json"))) { string JSONstring = File.ReadAllText(Path.Combine(DataDir, "release.json")); ReleaseData? oldData = JsonSerializer.Deserialize(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(async ctx => { var task = ctx.AddTask(URL, new ProgressTaskSettings { AutoStart = false }); return await downloadFile(URL, Archdir, task); }); string JSONdata = JsonSerializer.Serialize(data); if (!ValidateChecksum(new FileInfo(filename), gzFile.Checksum)) { AnsiConsole.WriteLine("[red b]ERROR: Release data checksum invalid[]"); File.Delete(filename); Environment.Exit(1); } File.WriteAllText(Path.Combine(DataDir, "release.json"), JSONdata); // only do this if what we downloaded was valid. // Otherwise we will skip downloading in the future } string PackagesFile = Path.Combine(Archdir, "Packages.gz"); string PackagesString = GetPackagesString(PackagesFile); AnsiConsole.MarkupLineInterpolated($"Read {PackagesFile} see log for contents"); Log.Debug("{packagesString}", PackagesString); List packages = new(); StringBuilder sb = new(); foreach (var line in PackagesString.Split('\n')) { Log.Debug("PackageLine: {line}",line); if (line == "") { string PackageString = sb.ToString(); Log.Debug("Making Package from:\n{PackageString}",PackageString); 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() .Title("Choose variant") .PageSize(4) .AddChoices(new[] { "stable", "beta", "dev", "all" }) ); List 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() .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(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($"Extracting 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); } } } } // Patch the startup program to allow using UserFlags string startScript = Path.Combine(versionDir , "opt/microsoft/msedge/microsoft-edge"); Log.Information("Patching {startScript}",startScript); using (var scriptStream = File.Open(startScript,FileMode.Open)) using (var scriptRead = new StreamReader(scriptStream)) using (var scriptWrite = new StreamWriter(scriptStream)) { var newscriptString = new StringBuilder(); var currentScriptString = scriptRead.ReadToEnd(); if (currentScriptString is null) { return 1; } List currentScriptSplit = currentScriptString.Split("\n").ToList(); currentScriptSplit.RemoveRange(currentScriptSplit.Count - 3,3); currentScriptSplit.Add("if [ -r \"${XDG_CONFIG_HOME}/microsoft-edge-stable-flags.conf\" ]; then"); currentScriptSplit.Add(" EDGE_USER_FLAGS=\"$(cat \"$XDG_CONFIG_HOME/microsoft-edge-stable-flags.conf\")\""); currentScriptSplit.Add("fi"); currentScriptSplit.Add(""); currentScriptSplit.Add(""); currentScriptSplit.Add("# Note: exec -a below is a bashism"); currentScriptSplit.Add("exec -a \"$0\" \"$HERE/msedge\" $EDGE_USER_FLAGS \"$@\""); newscriptString.AppendJoin("\n",currentScriptSplit); scriptStream.Position = 0; scriptStream.SetLength(0); AnsiConsole.MarkupLineInterpolated($"[grey]{newscriptString.ToString()}[/]"); scriptWrite.Write(newscriptString.ToString()); scriptStream.Flush(); } // 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 -drv {versionDir}/opt {versionDir}/usr /"; Mover.UseShellExecute = true; var pr = Process.Start(Mover); pr?.WaitForExit(); return 0; } /*TODO: - Check version - Offer update - Make SENSIBLE backup = if install fails revert backup */ }