From 4cf7bd1a9b41918ea8b686499f5ee28abad98a1c Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Sat, 3 Oct 2026 13:00:24 -0300 Subject: [PATCH 1/5] feat: install ControlFS from the session menu when it is missing Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.en-US.md | 3 + CHANGELOG.md | 3 + docs/TESTING.md | 8 +- src/ConsoleMode/Resources/Strings.en-US.json | 5 +- src/ConsoleMode/Resources/Strings.es-ES.json | 5 +- src/ConsoleMode/Resources/Strings.pt-BR.json | 5 +- .../Services/ControlFsInstaller.cs | 53 +++++++++++ src/ConsoleMode/Services/ControlFsRelease.cs | 48 ++++++++++ .../Services/LocalizationService.cs | 2 + src/ConsoleMode/Services/UpdateService.cs | 16 +++- src/ConsoleMode/SessionMenuWindow.xaml | 5 ++ .../ViewModels/MainViewModel.SessionMenu.cs | 58 +++++++++++- .../ConsoleMode.Tests.csproj | 1 + .../ControlFsReleaseTests.cs | 89 +++++++++++++++++++ 14 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 src/ConsoleMode/Services/ControlFsInstaller.cs create mode 100644 src/ConsoleMode/Services/ControlFsRelease.cs create mode 100644 tests/ConsoleMode.Tests/ControlFsReleaseTests.cs diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index fe35048..95e73ec 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -4,6 +4,9 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ## [Unreleased] +### 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. + ## [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..7f26f9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ## [Unreleased] +### 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. + ## [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/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..a36c9d3 --- /dev/null +++ b/src/ConsoleMode/Services/ControlFsInstaller.cs @@ -0,0 +1,53 @@ +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."); + + 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); + await process.WaitForExitAsync(timeout.Token); + 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"); + } +} 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)); + } +} From ce5b8955feffe35f2c3ecab868b7ba1ef5c68a87 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Sat, 3 Oct 2026 13:12:34 -0300 Subject: [PATCH 2/5] docs: link ControlFS install to PR 136 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.en-US.md | 2 +- CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 95e73ec..ce4e339 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -5,7 +5,7 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ## [Unreleased] ### 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. +- 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f26f9f..c129f0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ## [Unreleased] ### 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. +- 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 From 0efd3f02b1624910c85afe1724fe7c06125eb846 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Sat, 3 Oct 2026 13:12:43 -0300 Subject: [PATCH 3/5] chore: prepare 1.6.0-beta.5 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.en-US.md | 4 ++++ CHANGELOG.md | 4 ++++ src/ConsoleMode/ConsoleMode.csproj | 4 ++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index ce4e339..bde780f 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -4,6 +4,10 @@ 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) diff --git a/CHANGELOG.md b/CHANGELOG.md index c129f0d..ed707fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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) 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 From a480291a8b3681f74ee57b2b5abc838c756fa218 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Sat, 3 Oct 2026 13:21:41 -0300 Subject: [PATCH 4/5] fix: stop the ControlFS installer when it times out Co-Authored-By: Claude Opus 5.5 --- src/ConsoleMode/Services/ControlFsInstaller.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/ConsoleMode/Services/ControlFsInstaller.cs b/src/ConsoleMode/Services/ControlFsInstaller.cs index a36c9d3..71c0086 100644 --- a/src/ConsoleMode/Services/ControlFsInstaller.cs +++ b/src/ConsoleMode/Services/ControlFsInstaller.cs @@ -40,7 +40,15 @@ public static async Task InstallAsync(IProgress? progress, Cance }) ?? throw new InvalidOperationException("o instalador não iniciou"); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(SetupTimeout); - await process.WaitForExitAsync(timeout.Token); + 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 From 4d29176d00fbd3628459813bf612d62774e306fb Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Sat, 3 Oct 2026 13:27:15 -0300 Subject: [PATCH 5/5] fix: never start a ControlFS install while an earlier setup is still running Co-Authored-By: Claude Opus 5.5 --- src/ConsoleMode/Services/ControlFsInstaller.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/ConsoleMode/Services/ControlFsInstaller.cs b/src/ConsoleMode/Services/ControlFsInstaller.cs index 71c0086..9f514c7 100644 --- a/src/ConsoleMode/Services/ControlFsInstaller.cs +++ b/src/ConsoleMode/Services/ControlFsInstaller.cs @@ -18,6 +18,9 @@ public static class ControlFsInstaller 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"); @@ -58,4 +61,18 @@ public static async Task InstallAsync(IProgress? progress, Cance 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; + } }