diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md
index fe35048..bde780f 100644
--- a/CHANGELOG.en-US.md
+++ b/CHANGELOG.en-US.md
@@ -4,6 +4,13 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor
## [Unreleased]
+## [1.6.0-beta.5]
+### Heads-up: beta version, not the stable one
+- This is a test version of 1.6. The stable version is still 1.5.1, and people on it don't get this update automatically: only those who turned on Settings → "Receive test versions (alpha and beta)" or are already on an alpha/beta. If something breaks, go back to 1.5.1 and report it with the feedback button (attach `consolemode.log`).
+
+### What's new
+- Session menu: without ControlFS installed, pressing A on "File explorer (ControlFS)" now downloads and installs ControlFS right there, without leaving the controller: the row shows the progress, the installer is checked (SHA-256) before it runs and, when done, ControlFS opens full screen. If it can't (no internet, for example), the row says so and the next A opens the download page. (#136)
+
## [1.6.0-beta.4]
### Heads-up: beta version, not the stable one
- This is a test version of 1.6. The stable version is still 1.5.1, and people on it don't get this update automatically: only those who turned on Settings → "Receive test versions (alpha and beta)" or are already on an alpha/beta. If something breaks, go back to 1.5.1 and report it with the feedback button (attach `consolemode.log`).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5511142..ed707fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH
## [Unreleased]
+## [1.6.0-beta.5]
+### Atenção: versão beta, não é a estável
+- Esta é uma versão de teste da 1.6. A versão estável continua sendo a 1.5.1, e quem está nela não recebe esta atualização automaticamente: só quem ligou Ajustes → "Receber versões de teste (alpha e beta)" ou já está numa alpha/beta. Se algo falhar, volte para a 1.5.1 e conte pelo botão de feedback (anexe o `consolemode.log`).
+
+### Novidades
+- Menu da sessão: sem o ControlFS instalado, apertar A em "Explorador de arquivos (ControlFS)" agora baixa e instala o ControlFS ali mesmo, sem sair do controle: a linha mostra o progresso, o instalador é conferido (SHA-256) antes de rodar e, ao terminar, o ControlFS abre em tela cheia. Se não der (sem internet, por exemplo), a linha avisa e o próximo A abre a página de download. (#136)
+
## [1.6.0-beta.4]
### Atenção: versão beta, não é a estável
- Esta é uma versão de teste da 1.6. A versão estável continua sendo a 1.5.1, e quem está nela não recebe esta atualização automaticamente: só quem ligou Ajustes → "Receber versões de teste (alpha e beta)" ou já está numa alpha/beta. Se algo falhar, volte para a 1.5.1 e conte pelo botão de feedback (anexe o `consolemode.log`).
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 6e2834e..3ea4e4e 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -159,7 +159,7 @@ Pré-condições: sessão ativa com Big Picture (ou jogo borderless) na tela; co
## 1d. Explorador de arquivos (ControlFS) no menu da sessão
-Pré-condição: ControlFS instalado (https://github.com/nextestudios/ControlFS).
+Pré-condição: ControlFS instalado (https://github.com/nextestudios/ControlFS), exceto nos itens de instalação pelo menu.
- [ ] Select + Y: no topo da lateral esquerda, fora da lista que rola, há o painel "Explorador de arquivos (ControlFS)"; o restante das opções rola por baixo dele. Resultado: ______
- [ ] Direcional para cima a partir de "Voltar ao jogo" chega nele; A abre o ControlFS em tela cheia na TV, por cima do jogo/Big Picture, e o menu fecha. Resultado: ______
@@ -170,7 +170,11 @@ Pré-condição: ControlFS instalado (https://github.com/nextestudios/ControlFS)
- [ ] Com o ControlFS já aberto: A só o traz para a frente (não abre uma segunda instância). Resultado: ______
- [ ] A sessão **não** termina nem restaura a mesa enquanto o ControlFS está na frente; ao sair dele (Menu do ControlFS → Sair) volta-se ao jogo. Resultado: ______
- [ ] Select + Y com o ControlFS na frente abre o menu por cima dele (saída de emergência). Resultado: ______
-- [ ] Sem o ControlFS instalado: a linha diz "Não instalado · aperte para baixar" e A abre a página de releases no navegador. Resultado: ______
+- [ ] **Instalar pelo menu** (desinstale o ControlFS antes): a linha diz "Não instalado · aperte para baixar e instalar". A começa o download: a linha mostra "Baixando… N%" com a barra enchendo, depois "Instalando…" com a barra em movimento, sem nenhuma janela de instalador nem pedido de administrador. Ao terminar, o menu fecha e o ControlFS abre em tela cheia na tela da sessão. Resultado: ______
+- [ ] Durante o download o menu continua respondendo (direcional, volume); apertar A de novo na linha não começa um segundo download. Resultado: ______
+- [ ] Fechar o menu durante a instalação: ela termina em segundo plano e o ControlFS **não** abre por cima do jogo; ao reabrir o menu a linha já diz "Navegue pelos seus arquivos com o controle". Resultado: ______
+- [ ] Sem internet (cabo/Wi-Fi desligado): A na linha mostra "Não deu para instalar · aperte para abrir a página de download" e o log registra `ControlFS: instalar: ...`; o A seguinte abre a página de releases no navegador. Reabrindo o menu, a linha volta a oferecer a instalação. Resultado: ______
+- [ ] Depois de instalado pelo menu, o ControlFS aparece em Configurações → Aplicativos instalados e nenhum `ControlFS-Setup-x64.exe` sobra em `%TEMP%ConsoleModeUpdate`. Resultado: ______
- [ ] Fora da sessão (prévia do menu) o painel funciona igual. Resultado: ______
## 2. Links `consolemode://`
diff --git a/src/ConsoleMode/ConsoleMode.csproj b/src/ConsoleMode/ConsoleMode.csproj
index 64c9687..ae98e87 100644
--- a/src/ConsoleMode/ConsoleMode.csproj
+++ b/src/ConsoleMode/ConsoleMode.csproj
@@ -17,10 +17,10 @@
true
latest
x64
- 1.6.0-beta.4
+ 1.6.0-beta.5
1.6.0.0
1.6.0.0
- 1.6.0-beta.4
+ 1.6.0-beta.5
Console Mode
Console Mode
lippdev
diff --git a/src/ConsoleMode/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json
index 2c6fd27..8799bb7 100644
--- a/src/ConsoleMode/Resources/Strings.en-US.json
+++ b/src/ConsoleMode/Resources/Strings.en-US.json
@@ -89,7 +89,10 @@
"CloseMenu": "Close menu",
"ControlFsCard": "File explorer (ControlFS)",
"ControlFsOpen": "Browse your files with the controller",
- "ControlFsMissing": "Not installed · press to get it",
+ "ControlFsMissing": "Not installed · press to download and install",
+ "ControlFsDownloading": "Downloading… {0}%",
+ "ControlFsInstalling": "Installing…",
+ "ControlFsInstallFailed": "Couldn't install · press to open the download page",
"ControlFsMissingNote": "ControlFS is not installed. Opening its download page in the browser.",
"SessionWindowsCard": "Windows",
"SessionWindowsCount": "{0} open",
diff --git a/src/ConsoleMode/Resources/Strings.es-ES.json b/src/ConsoleMode/Resources/Strings.es-ES.json
index f8c7501..8cc3c18 100644
--- a/src/ConsoleMode/Resources/Strings.es-ES.json
+++ b/src/ConsoleMode/Resources/Strings.es-ES.json
@@ -89,7 +89,10 @@
"CloseMenu": "Cerrar menú",
"ControlFsCard": "Explorador de archivos (ControlFS)",
"ControlFsOpen": "Explora tus archivos con el mando",
- "ControlFsMissing": "No instalado · pulsa para descargarlo",
+ "ControlFsMissing": "No instalado · pulsa para descargar e instalar",
+ "ControlFsDownloading": "Descargando… {0}%",
+ "ControlFsInstalling": "Instalando…",
+ "ControlFsInstallFailed": "No se pudo instalar · pulsa para abrir la página de descarga",
"ControlFsMissingNote": "ControlFS no está instalado. Abriendo su página de descarga en el navegador.",
"SessionWindowsCard": "Ventanas",
"SessionWindowsCount": "{0} abiertas",
diff --git a/src/ConsoleMode/Resources/Strings.pt-BR.json b/src/ConsoleMode/Resources/Strings.pt-BR.json
index 94c44ea..f0725e0 100644
--- a/src/ConsoleMode/Resources/Strings.pt-BR.json
+++ b/src/ConsoleMode/Resources/Strings.pt-BR.json
@@ -89,7 +89,10 @@
"CloseMenu": "Fechar menu",
"ControlFsCard": "Explorador de arquivos (ControlFS)",
"ControlFsOpen": "Navegue pelos seus arquivos com o controle",
- "ControlFsMissing": "Não instalado · aperte para baixar",
+ "ControlFsMissing": "Não instalado · aperte para baixar e instalar",
+ "ControlFsDownloading": "Baixando… {0}%",
+ "ControlFsInstalling": "Instalando…",
+ "ControlFsInstallFailed": "Não deu para instalar · aperte para abrir a página de download",
"ControlFsMissingNote": "O ControlFS não está instalado. Abrindo a página de download no navegador.",
"SessionWindowsCard": "Janelas",
"SessionWindowsCount": "{0} abertas",
diff --git a/src/ConsoleMode/Services/ControlFsInstaller.cs b/src/ConsoleMode/Services/ControlFsInstaller.cs
new file mode 100644
index 0000000..9f514c7
--- /dev/null
+++ b/src/ConsoleMode/Services/ControlFsInstaller.cs
@@ -0,0 +1,78 @@
+using System.Diagnostics;
+
+namespace ConsoleMode.Services;
+
+///
+/// Installs ControlFS from the session menu, when the user asks for it there: downloads the setup of its newest
+/// release, checks it against GitHub's SHA-256 and runs it silently. The setup is per user (no admin prompt) and
+/// does not start ControlFS when silent. Updating it afterwards is ControlFS's own business.
+///
+public static class ControlFsInstaller
+{
+ private static readonly TimeSpan SetupTimeout = TimeSpan.FromMinutes(5);
+
+ ///
+ /// Returns the installed exe. Throws with the reason when it can't be installed. gets
+ /// the download from 0 to 1, then once the setup is running.
+ ///
+ public static async Task InstallAsync(IProgress? progress, CancellationToken ct = default)
+ {
+ if (!Environment.Is64BitOperatingSystem) throw new PlatformNotSupportedException("ControlFS só tem instalador x64.");
+ // A setup from an earlier attempt (or its Inno child, "ControlFS-Setup-x64.tmp") may still be at work:
+ // never start a second one next to it.
+ if (IsSetupRunning()) throw new InvalidOperationException("outro instalador do ControlFS ainda está rodando");
+
+ var setup = await UpdateService.FindControlFsSetupAsync(ct)
+ ?? throw new InvalidOperationException("nenhuma release com instalador verificável");
+ var dir = Path.Combine(Path.GetTempPath(), "ConsoleModeUpdate");
+ Directory.CreateDirectory(dir);
+ var file = Path.Combine(dir, setup.Name);
+ try
+ {
+ await UpdateService.DownloadAsync(setup.Url, file, progress, ct);
+ if (!await UpdateIntegrity.VerifyFileAsync(file, setup.Digest, ct))
+ throw new InvalidDataException("o SHA-256 do instalador não confere");
+ AppLog.Write($"ControlFS: instalador {setup.Version} baixado e verificado");
+
+ progress?.Report(double.PositiveInfinity);
+ using var process = Process.Start(new ProcessStartInfo
+ {
+ FileName = file,
+ Arguments = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART",
+ UseShellExecute = false
+ }) ?? throw new InvalidOperationException("o instalador não iniciou");
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeout.CancelAfter(SetupTimeout);
+ try { await process.WaitForExitAsync(timeout.Token); }
+ catch (OperationCanceledException)
+ {
+ // Given up on: stop it (Inno runs a second, child process), or it could finish later and a new
+ // attempt from the menu would start a second installer next to it.
+ try { process.Kill(entireProcessTree: true); } catch { /* already gone */ }
+ await process.WaitForExitAsync(CancellationToken.None);
+ throw new TimeoutException($"o instalador não terminou em {SetupTimeout.TotalMinutes:0} min e foi encerrado");
+ }
+ if (process.ExitCode != 0) throw new InvalidOperationException($"o instalador saiu com o código {process.ExitCode}");
+ }
+ finally
+ {
+ try { File.Delete(file); } catch { /* the temp folder is cleaned by Windows */ }
+ }
+
+ return ControlFsService.FindExe() ?? throw new InvalidOperationException("instalado, mas o ControlFS.exe não foi encontrado");
+ }
+
+ private static bool IsSetupRunning()
+ {
+ var running = false;
+ foreach (var process in Process.GetProcesses())
+ {
+ using (process)
+ {
+ try { running |= process.ProcessName.StartsWith("ControlFS-Setup", StringComparison.OrdinalIgnoreCase); }
+ catch { /* exited while listing */ }
+ }
+ }
+ return running;
+ }
+}
diff --git a/src/ConsoleMode/Services/ControlFsRelease.cs b/src/ConsoleMode/Services/ControlFsRelease.cs
new file mode 100644
index 0000000..3088f6c
--- /dev/null
+++ b/src/ConsoleMode/Services/ControlFsRelease.cs
@@ -0,0 +1,48 @@
+using System.Text.Json;
+
+namespace ConsoleMode.Services;
+
+/// The ControlFS installer of one release: what to download and the SHA-256 it must have.
+public sealed record ControlFsSetup(string Version, string Url, string Name, string Digest, long Size);
+
+///
+/// Picks the ControlFS installer out of GitHub's list of its releases. Pure, so the rules are tested: only a
+/// published release, only the x64 setup, only with GitHub's SHA-256 digest and only from that repository's
+/// own downloads. Anything else is not installed from the menu (the download page opens instead).
+///
+public static class ControlFsRelease
+{
+ public const string Repository = "nextestudios/ControlFS";
+ public const string SetupAssetName = "ControlFS-Setup-x64.exe";
+ public const string DownloadPrefix = "https://github.com/" + Repository + "/releases/download/";
+
+ /// The setup of the newest published release (GitHub lists them newest first), or null.
+ public static ControlFsSetup? Pick(JsonElement releases)
+ {
+ if (releases.ValueKind != JsonValueKind.Array) return null;
+ foreach (var release in releases.EnumerateArray())
+ {
+ if (release.ValueKind != JsonValueKind.Object) continue;
+ if (release.TryGetProperty("draft", out var draft) && draft.ValueKind == JsonValueKind.True) continue;
+ if (!release.TryGetProperty("assets", out var assets) || assets.ValueKind != JsonValueKind.Array) continue;
+ foreach (var asset in assets.EnumerateArray())
+ {
+ if (!string.Equals(Text(asset, "name"), SetupAssetName, StringComparison.OrdinalIgnoreCase)) continue;
+ var url = Text(asset, "browser_download_url");
+ var digest = Text(asset, "digest");
+ // The newest release decides: without a digest or from somewhere else, nothing is run.
+ if (!IsTrustedUrl(url) || !UpdateIntegrity.IsValidSha256Digest(digest)) return null;
+ var size = asset.TryGetProperty("size", out var s) && s.TryGetInt64(out var bytes) ? bytes : 0;
+ return new ControlFsSetup(Text(release, "tag_name").TrimStart('v', 'V'), url, SetupAssetName, digest, size);
+ }
+ }
+ return null;
+ }
+
+ public static bool IsTrustedUrl(string? url) =>
+ url is not null && url.StartsWith(DownloadPrefix, StringComparison.Ordinal);
+
+ private static string Text(JsonElement element, string name) =>
+ element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
+ ? value.GetString() ?? "" : "";
+}
diff --git a/src/ConsoleMode/Services/LocalizationService.cs b/src/ConsoleMode/Services/LocalizationService.cs
index 465b545..6c77de4 100644
--- a/src/ConsoleMode/Services/LocalizationService.cs
+++ b/src/ConsoleMode/Services/LocalizationService.cs
@@ -195,6 +195,8 @@ internal void NotifyAll()
public string ControlFsCard => LocalizationService.Get(nameof(ControlFsCard));
public string ControlFsOpen => LocalizationService.Get(nameof(ControlFsOpen));
public string ControlFsMissing => LocalizationService.Get(nameof(ControlFsMissing));
+ public string ControlFsInstalling => LocalizationService.Get(nameof(ControlFsInstalling));
+ public string ControlFsInstallFailed => LocalizationService.Get(nameof(ControlFsInstallFailed));
public string SessionWindowsCard => LocalizationService.Get(nameof(SessionWindowsCard));
public string SwitcherEmpty => LocalizationService.Get(nameof(SwitcherEmpty));
public string SwitcherOpen => LocalizationService.Get(nameof(SwitcherOpen));
diff --git a/src/ConsoleMode/Services/UpdateService.cs b/src/ConsoleMode/Services/UpdateService.cs
index 371e7c5..4f09409 100644
--- a/src/ConsoleMode/Services/UpdateService.cs
+++ b/src/ConsoleMode/Services/UpdateService.cs
@@ -157,7 +157,21 @@ public static async Task ApplyAsync(UpdateInfo update, IProgress? progre
});
}
- private static async Task DownloadAsync(string url, string file, IProgress? progress, CancellationToken ct)
+ /// The installer of ControlFS's newest release (see ), or null when there is none to trust.
+ internal static async Task FindControlFsSetupAsync(CancellationToken ct = default)
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeout.CancelAfter(CheckTimeout);
+ using var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.github.com/repos/{ControlFsRelease.Repository}/releases?per_page=10");
+ request.Headers.Accept.ParseAdd("application/vnd.github+json");
+ using var response = await Http.SendAsync(request, timeout.Token);
+ response.EnsureSuccessStatusCode();
+ await using var stream = await response.Content.ReadAsStreamAsync(timeout.Token);
+ using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: timeout.Token);
+ return ControlFsRelease.Pick(doc.RootElement);
+ }
+
+ internal static async Task DownloadAsync(string url, string file, IProgress? progress, CancellationToken ct)
{
// Cancelled only when no bytes arrive for StallTimeout, not after a fixed total time.
using var stall = CancellationTokenSource.CreateLinkedTokenSource(ct);
diff --git a/src/ConsoleMode/SessionMenuWindow.xaml b/src/ConsoleMode/SessionMenuWindow.xaml
index 9159529..02f7eb3 100644
--- a/src/ConsoleMode/SessionMenuWindow.xaml
+++ b/src/ConsoleMode/SessionMenuWindow.xaml
@@ -157,6 +157,11 @@
+
+
diff --git a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs
index f18c31a..64237ff 100644
--- a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs
+++ b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs
@@ -85,6 +85,8 @@ public void ToggleSessionMenu()
IsSessionMenuOpen = true;
IsSessionPickerOpen = false;
ControlFsInstalled = ControlFsService.FindExe() is not null;
+ // A failed install is offered again each time the menu opens (the connection may be back).
+ if (!ControlFsInstalling) ControlFsInstallFailed = false;
RefreshSessionHeader();
StartSessionClock();
_sessionMenu.Activate();
@@ -314,18 +316,39 @@ private async Task RunSessionActionAsync(Action action)
/// Whether ControlFS is installed; decides what the pinned row says. Read each time the menu opens.
[ObservableProperty] private bool _controlFsInstalled;
- public string ControlFsRowText => ControlFsInstalled ? Texts.ControlFsOpen : Texts.ControlFsMissing;
+ /// The install asked from the row is running (it goes on if the menu is closed meanwhile).
+ [ObservableProperty, NotifyPropertyChangedFor(nameof(ControlFsRowText))] private bool _controlFsInstalling;
+ /// Download of the setup, 0 to 100; 100 while the setup itself runs.
+ [ObservableProperty, NotifyPropertyChangedFor(nameof(ControlFsRowText))] private double _controlFsProgress;
+ [ObservableProperty, NotifyPropertyChangedFor(nameof(ControlFsRowText))] private bool _controlFsSetupRunning;
+ /// The install failed: the row says so and the next press opens the download page instead.
+ [ObservableProperty, NotifyPropertyChangedFor(nameof(ControlFsRowText))] private bool _controlFsInstallFailed;
+
+ public string ControlFsRowText =>
+ ControlFsSetupRunning ? Texts.ControlFsInstalling
+ : ControlFsInstalling ? LocalizationService.Get("ControlFsDownloading", (int)ControlFsProgress)
+ : ControlFsInstalled ? Texts.ControlFsOpen
+ : ControlFsInstallFailed ? Texts.ControlFsInstallFailed
+ : Texts.ControlFsMissing;
partial void OnControlFsInstalledChanged(bool value) => OnPropertyChanged(nameof(ControlFsRowText));
///
/// The pinned "File explorer (ControlFS)" row: the menu closes and ControlFS (a separate app, controller-first)
- /// opens or comes to the front. Without it installed, its download page opens instead. Outside a session it
+ /// opens or comes to the front. Without it installed, the same press downloads and installs it (the row shows
+ /// the progress) and then opens it; if that fails, the next press opens its download page. Outside a session it
/// works the same, so it can be tried in the preview.
///
[RelayCommand]
private void OpenControlFs()
{
+ if (ControlFsInstalling) return;
var installed = ControlFsService.FindExe() is not null;
+ ControlFsInstalled = installed;
+ if (!installed && !ControlFsInstallFailed)
+ {
+ _ = InstallControlFsAsync();
+ return;
+ }
var screen = IsConsoleActive ? Engine.State.FocusMonitorRect : Engine.Monitors.GetMonitorRect(SessionMonitorName, Engine.State);
CloseSessionMenu();
if (installed && ControlFsService.Open())
@@ -337,6 +360,37 @@ private void OpenControlFs()
ControlFsService.OpenDownloadPage();
}
+ private async Task InstallControlFsAsync()
+ {
+ ControlFsProgress = 0;
+ ControlFsInstalling = true;
+ AppLog.Write("ControlFS: não instalado, baixando e instalando a pedido do menu");
+ try
+ {
+ var progress = new Progress(p =>
+ {
+ if (!ControlFsInstalling) return; // a late report, after it ended
+ if (double.IsPositiveInfinity(p)) ControlFsSetupRunning = true;
+ else ControlFsProgress = p * 100;
+ });
+ await Task.Run(() => ControlFsInstaller.InstallAsync(progress));
+ AppLog.Write("ControlFS: instalado");
+ ControlFsInstalled = true;
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"ControlFS: instalar: {ex.Message}");
+ ControlFsInstallFailed = true;
+ }
+ finally
+ {
+ ControlFsSetupRunning = false;
+ ControlFsInstalling = false;
+ }
+ // Open it only if the menu that asked is still there: never pop it over a game the user went back to.
+ if (ControlFsInstalled && IsSessionMenuOpen) OpenControlFs();
+ }
+
[RelayCommand]
private async Task RestoreFromMenuAsync()
{
diff --git a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
index 198b0d9..63a2a62 100644
--- a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
+++ b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
@@ -39,6 +39,7 @@
+
diff --git a/tests/ConsoleMode.Tests/ControlFsReleaseTests.cs b/tests/ConsoleMode.Tests/ControlFsReleaseTests.cs
new file mode 100644
index 0000000..6a2d996
--- /dev/null
+++ b/tests/ConsoleMode.Tests/ControlFsReleaseTests.cs
@@ -0,0 +1,89 @@
+using System.Text.Json;
+using ConsoleMode.Services;
+
+namespace ConsoleMode.Tests;
+
+public class ControlFsReleaseTests
+{
+ private const string Digest = "sha256:b97a98bb42b56c572f92e08003c251a541e8d740a5769b4ba85cf686a1095347";
+ private const string Url = "https://github.com/nextestudios/ControlFS/releases/download/v0.17.0-alpha.1/ControlFS-Setup-x64.exe";
+
+ private static string Release(string tag, string assets, bool draft = false) =>
+ $$"""{"tag_name":"{{tag}}","draft":{{(draft ? "true" : "false")}},"assets":[{{assets}}]}""";
+
+ private static string Asset(string name, string url, string? digest, long size = 49030428) =>
+ $$"""{"name":"{{name}}","browser_download_url":"{{url}}","size":{{size}},"digest":{{(digest is null ? "null" : $"\"{digest}\"")}}}""";
+
+ private static ControlFsSetup? Pick(params string[] releases)
+ {
+ using var doc = JsonDocument.Parse($"[{string.Join(",", releases)}]");
+ return ControlFsRelease.Pick(doc.RootElement);
+ }
+
+ [Fact]
+ public void Picks_the_x64_setup_of_the_newest_release()
+ {
+ var setup = Pick(
+ Release("v0.17.0-alpha.1",
+ Asset("ControlFS-Portable-x64.exe", Url.Replace("Setup", "Portable"), Digest) + "," + Asset("ControlFS-Setup-x64.exe", Url, Digest)),
+ Release("v0.16.0-alpha.1", Asset("ControlFS-Setup-x64.exe", Url.Replace("0.17", "0.16"), Digest)));
+
+ Assert.NotNull(setup);
+ Assert.Equal("0.17.0-alpha.1", setup.Version);
+ Assert.Equal(Url, setup.Url);
+ Assert.Equal(Digest, setup.Digest);
+ Assert.Equal(49030428, setup.Size);
+ }
+
+ [Fact]
+ public void A_draft_is_skipped_for_the_next_published_release()
+ {
+ var setup = Pick(
+ Release("v0.18.0", Asset("ControlFS-Setup-x64.exe", Url.Replace("0.17.0-alpha.1", "0.18.0"), Digest), draft: true),
+ Release("v0.17.0-alpha.1", Asset("ControlFS-Setup-x64.exe", Url, Digest)));
+ Assert.Equal("0.17.0-alpha.1", setup?.Version);
+ }
+
+ [Fact]
+ public void A_release_without_the_setup_is_skipped()
+ {
+ var setup = Pick(
+ Release("v0.18.0", Asset("release-manifest.json", Url, Digest)),
+ Release("v0.17.0-alpha.1", Asset("ControlFS-Setup-x64.exe", Url, Digest)));
+ Assert.Equal("0.17.0-alpha.1", setup?.Version);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("sha256:abc")]
+ [InlineData("md5:b97a98bb42b56c572f92e08003c251a541e8d740a5769b4ba85cf686a1095347")]
+ public void A_setup_without_a_sha256_digest_is_never_installed(string? digest)
+ {
+ // The older release is not used instead: the newest setup decides.
+ Assert.Null(Pick(
+ Release("v0.17.0-alpha.1", Asset("ControlFS-Setup-x64.exe", Url, digest)),
+ Release("v0.16.0-alpha.1", Asset("ControlFS-Setup-x64.exe", Url, Digest))));
+ }
+
+ [Theory]
+ [InlineData("http://github.com/nextestudios/ControlFS/releases/download/v1/ControlFS-Setup-x64.exe")]
+ [InlineData("https://github.com/someone-else/ControlFS/releases/download/v1/ControlFS-Setup-x64.exe")]
+ [InlineData("https://example.com/ControlFS-Setup-x64.exe")]
+ [InlineData("https://github.com/nextestudios/ControlFS-evil/releases/download/v1/ControlFS-Setup-x64.exe")]
+ public void A_setup_from_anywhere_but_the_repository_downloads_is_never_installed(string url)
+ {
+ Assert.Null(Pick(Release("v0.17.0-alpha.1", Asset("ControlFS-Setup-x64.exe", url, Digest))));
+ }
+
+ [Theory]
+ [InlineData("{}")]
+ [InlineData("[]")]
+ [InlineData("[1, \"x\", null]")]
+ [InlineData("[{\"tag_name\":\"v1\"}]")]
+ public void Unexpected_json_gives_nothing(string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+ Assert.Null(ControlFsRelease.Pick(doc.RootElement));
+ }
+}