From b737ac96792af81eaae8a295fcb9d18c5a7ccd22 Mon Sep 17 00:00:00 2001 From: nextestudios <47460003+nextestudios@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:08:30 -0300 Subject: [PATCH] feat: check for updates on every open and update by itself when nothing was started Installed build only, verified update only, never over a user who already started using the app, never in CI, never retries the same version twice in a row. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.en-US.md | 2 + CHANGELOG.md | 2 + docs/GUIDE.md | 6 +- docs/GUIDE.pt-BR.md | 6 +- docs/PRIVACY.md | 6 +- docs/TESTING.md | 5 ++ src/ControlFS.App/Views/MainWindow.cs | 4 + .../AppController.Onboarding.cs | 2 +- .../AppController.Updates.cs | 32 +++++++- src/ControlFS.Application/AppController.cs | 1 + .../Contracts/ISettingsStore.cs | 14 +++- .../Updates/UpdateFlowTests.cs | 75 +++++++++++++++++-- 12 files changed, 135 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index be851529..6c22e89e 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -3,6 +3,8 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Before publishing a version, add a `## [VERSION]` section to **both** files: the release workflow uses the section matching the tag and fails if either is missing. ## [Unreleased] +### What's new +- **Checks and updates by itself on open**: ControlFS now looks for a new version **every time it opens** (before, at most once a day) and, in the installed build, if a verified version exists (signed manifest, SHA-256 and size) and you haven't started using the app and nothing is running, it **downloads, installs and reopens already updated**, without asking. If you already started using it, the usual notice stays (Install and restart / Later). Turn it off in Menu → Configurações → Atualizações → **Atualizar sozinho ao abrir**. The portable build only notifies. It never runs in CI or with `--no-auto-update`. ## [0.13.0-alpha.1] ### What's new diff --git a/CHANGELOG.md b/CHANGELOG.md index 93f50982..2bcc4781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CHANGELOG.en-US.md`. Antes de publicar uma versão, adicione uma seção `## [VERSÃO]` **nos dois arquivos**. O workflow de release usa a seção da tag e falha se faltar alguma. ## [Unreleased] +### Novidades +- **Verifica e se atualiza sozinho ao abrir**: o ControlFS agora procura versão nova **a cada vez que abre** (antes, no máximo uma vez por dia) e, na versão instalada, se há uma versão verificada (manifesto assinado, SHA-256 e tamanho) e você ainda não começou a usar o app e nada está em andamento, **baixa, instala e reabre já atualizado**, sem perguntar. Se você já começou a usar, fica o aviso de sempre (Instalar e reiniciar / Depois). Desligue em Menu → Configurações → Atualizações → **Atualizar sozinho ao abrir**. A versão portátil só avisa. Nunca roda em CI nem com `--no-auto-update`. ## [0.13.0-alpha.1] ### Novidades diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 8f528cf3..f0d6b6cc 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -290,11 +290,11 @@ North → **Abrir terminal aqui…** (open terminal here) opens **Windows Termin The **installed** version updates itself: -1. Once a day at most, it asks GitHub for the latest release of `nextestudios/ControlFS` (stable only, or also pre-releases if you are on one). +1. Each time the app opens, it asks GitHub for the latest release of `nextestudios/ControlFS` (stable only, or also pre-releases if you are on one). 2. It downloads the installer in the background and only accepts it if the **release manifest is signed with the project key** and the file's **SHA-256 and size** match. Same or older versions are refused. -3. It offers **Install and restart**. Postpone it and it installs silently when you quit. Nothing happens while a copy or extraction is running. +3. Right after opening, if you haven't started using the app yet and nothing is running, it **installs and reopens by itself on the new version** (**Atualizar sozinho ao abrir**, on by default). If you already started using it or turned the option off, it offers **Install and restart**; postpone it and it installs silently when you quit. Nothing happens while a copy or extraction is running. It never runs in CI or with `--no-auto-update` (or the `CONTROLFS_NO_AUTO_UPDATE` variable set). -Menu → Configurações → **Updates**: check now, automatic check on/off, install on quit on/off, pre-releases (automatic / yes / no). +Menu → Configurações → **Updates**: check now, automatic check on/off, update by itself on open on/off, install on quit on/off, pre-releases (automatic / yes / no). The **portable** version only tells you a new version exists; download it from the release page. ## Automation diff --git a/docs/GUIDE.pt-BR.md b/docs/GUIDE.pt-BR.md index 614b46f1..218cadbf 100644 --- a/docs/GUIDE.pt-BR.md +++ b/docs/GUIDE.pt-BR.md @@ -290,11 +290,11 @@ Norte → **Abrir terminal aqui…** abre o **Windows Terminal** na pasta atual, A versão **instalada** se atualiza sozinha: -1. No máximo uma vez por dia, pergunta ao GitHub qual é a última release de `nextestudios/ControlFS` (só estáveis, ou também pré-lançamentos se você estiver num). +1. A cada vez que o app abre, pergunta ao GitHub qual é a última release de `nextestudios/ControlFS` (só estáveis, ou também pré-lançamentos se você estiver num). 2. Baixa o instalador em segundo plano e só o aceita se o **manifesto da release estiver assinado com a chave do projeto** e o **SHA-256 e o tamanho** do arquivo conferirem. Versões iguais ou anteriores são recusadas. -3. Oferece **Instalar e reiniciar**. Se você adiar, instala em silêncio ao sair. Nada acontece enquanto uma cópia ou extração estiver em andamento. +3. Logo depois de abrir, se você ainda não começou a usar o app e nada está em andamento, **instala e reabre sozinho já na versão nova** (**Atualizar sozinho ao abrir**, ligado por padrão). Se você já começou a usar ou desligou a opção, oferece **Instalar e reiniciar**; se adiar, instala em silêncio ao sair. Nada acontece enquanto uma cópia ou extração estiver em andamento. -Menu → Configurações → **Atualizações**: verificar agora, verificação automática sim/não, instalar ao sair sim/não, pré-lançamentos (automático / sim / não). +Menu → Configurações → **Atualizações**: verificar agora, verificação automática sim/não, atualizar sozinho ao abrir sim/não, instalar ao sair sim/não, pré-lançamentos (automático / sim / não). A versão **portátil** só avisa que existe versão nova; baixe-a na página da release. ## Automação diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 40ec2d9b..80a6d4a5 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -6,8 +6,8 @@ the user or the person installing or operating it. - **No telemetry, no analytics, no accounts.** Files, folder names, search terms, passwords and settings never leave the PC. - **Local data only:** settings (including the names and searches you typed, kept for keyboard suggestions until you turn them off, and the folders of your open tabs, kept to restore them until you turn that off; never passwords), history, where each video stopped (only a SHA-256 digest of the file's path, size and date plus the seconds — never the name; Configurações → Apagar onde os vídeos pararam erases them), controller profiles and logs live in `%LOCALAPPDATA%\ControlFS` (installed) or in `ControlFS_Data` next to `ControlFS-Portable-x64.exe` (portable). Passwords are never saved or logged. -- **Update check (optional):** the app asks GitHub's servers (the GitHub API and release downloads) whether a newer release exists, at most - once a day, and downloads it only from the project's GitHub releases. The request carries only a `ControlFS/` +- **Update check (optional):** the app asks GitHub's servers (the GitHub API and release downloads) whether a newer release exists, each time + the app opens, and downloads it only from the project's GitHub releases. The request carries only a `ControlFS/` User-Agent; GitHub sees the IP address like for any web request ([GitHub privacy statement](https://docs.github.com/site-policy/privacy-policies/github-general-privacy-statement)). Turn it off in Menu → Atualizações → "Verificar automaticamente"; a manual "Verificar agora" still works on request. - **Network locations:** ControlFS lists the mapped drives and network locations Windows already knows (read locally) and @@ -34,7 +34,7 @@ especificamente pelo usuário ou por quem o instala ou opera. - **Só dados locais:** preferências (inclusive nomes e buscas digitados, guardados para as sugestões do teclado até você desligá-las, e as pastas das abas abertas, guardadas para restaurá-las até você desligar; nunca senhas), histórico, onde cada vídeo parou (só um resumo SHA-256 do caminho, tamanho e data do arquivo e os segundos — nunca o nome; Configurações → Apagar onde os vídeos pararam apaga), perfis de controle e logs ficam em `%LOCALAPPDATA%\ControlFS` (instalado) ou em `ControlFS_Data` ao lado do `ControlFS-Portable-x64.exe` (portátil). Senhas nunca são salvas nem registradas. - **Verificação de atualizações (opcional):** o app pergunta aos servidores do GitHub (API e downloads das releases) se há versão nova, - no máximo uma vez por dia, e só baixa das releases do projeto no GitHub. O pedido leva apenas o User-Agent + a cada abertura do app, e só baixa das releases do projeto no GitHub. O pedido leva apenas o User-Agent `ControlFS/`; o GitHub vê o endereço IP como em qualquer acesso à web. Desligue em Menu → Atualizações → "Verificar automaticamente"; "Verificar agora" continua funcionando quando pedido. - **Locais de rede:** o ControlFS lista as unidades mapeadas e os locais de rede que o Windows já conhece (lidos localmente) e só contata um servidor quando você abre aquele local. Não guarda credenciais de rede. diff --git a/docs/TESTING.md b/docs/TESTING.md index 6fc9b60c..836c6eaf 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -861,3 +861,8 @@ Só a CI (WinRAR 7.23 pelo winget, arquivo criado pelo Rar.exe real e extraído - [ ] Menu → Configurações → Sons do controle → baixo/médio/alto: mover o foco, confirmar, voltar e marcar tocam sons diferentes e discretos; o volume muda entre as opções; "desligados" silencia tudo. - [ ] Segurar o direcional numa lista longa: o tique não vira chiado nem atrasa o foco. O teclado (setas, Enter, Esc) não toca nada. - [ ] Com fones/alto-falantes diferentes ou sem saída de áudio: segue a saída padrão do Windows; sem dispositivo, o app funciona igual. + +## Atualização automática ao abrir — não validado em hardware +- [ ] Instalado numa versão antiga, com internet: abrir o app sem tocar em nada. Ele baixa a versão nova, fecha e reabre sozinho já atualizada (Menu → Sobre mostra a nova). +- [ ] Abrir e mexer no controle antes de terminar o download: aparece "Atualização pronta" (Instalar e reiniciar / Depois), sem reiniciar sozinho. +- [ ] Menu → Configurações → Atualizações → "Atualizar sozinho ao abrir: não": só o aviso. Com uma cópia em andamento nunca reinicia. diff --git a/src/ControlFS.App/Views/MainWindow.cs b/src/ControlFS.App/Views/MainWindow.cs index 4262493e..20e9e79b 100644 --- a/src/ControlFS.App/Views/MainWindow.cs +++ b/src/ControlFS.App/Views/MainWindow.cs @@ -150,6 +150,10 @@ internal MainWindow(string? dataDirectory) PlaybackPositions = new JsonPlaybackPositionStore(data), // Boas-vindas (#231) só na primeira execução do app de verdade: nunca nas capturas nem com --no-onboarding. DeferUpdateCheckToFirstFrame = dataDirectory is null, + // Nunca em CI nem quando um script de teste abre o app: a atualização automática fecharia o app no meio do smoke. + AllowAutoInstall = dataDirectory is null && Environment.GetEnvironmentVariable("CI") is null + && Environment.GetEnvironmentVariable("CONTROLFS_NO_AUTO_UPDATE") is null + && !Environment.GetCommandLineArgs().Contains("--no-auto-update", StringComparer.OrdinalIgnoreCase), Trace = dataDirectory is null ? AppLog.Info : null, OfferOnboarding = dataDirectory is null && !Environment.GetCommandLineArgs().Contains("--no-onboarding", StringComparer.OrdinalIgnoreCase), // "Mais da equipe": uma vez, depois das boas-vindas; mesma regra (nunca nas capturas nem com --no-onboarding). diff --git a/src/ControlFS.Application/AppController.Onboarding.cs b/src/ControlFS.Application/AppController.Onboarding.cs index 588a23cc..0324744e 100644 --- a/src/ControlFS.Application/AppController.Onboarding.cs +++ b/src/ControlFS.Application/AppController.Onboarding.cs @@ -207,7 +207,7 @@ private void LoadOnboardingStep(OnboardingModal modal, int index, bool keepFocus { UpdateSettings(s => s with { AutoCheckUpdates = !s.AutoCheckUpdates }); Reload(); - }, ActionIcon.Update, Settings.AutoCheckUpdates ? "sim" : "não", "No máximo uma vez por dia, só nas versões publicadas do ControlFS no GitHub."), + }, ActionIcon.Update, Settings.AutoCheckUpdates ? "sim" : "não", "A cada abertura, só nas versões publicadas do ControlFS no GitHub; a versão instalada se atualiza sozinha ao abrir (dá para desligar em Atualizações)."), next, ]; break; diff --git a/src/ControlFS.Application/AppController.Updates.cs b/src/ControlFS.Application/AppController.Updates.cs index 1b3c40c9..428d8168 100644 --- a/src/ControlFS.Application/AppController.Updates.cs +++ b/src/ControlFS.Application/AppController.Updates.cs @@ -20,7 +20,14 @@ public enum UpdateState public sealed partial class AppController { - private static readonly TimeSpan AutomaticCheckInterval = TimeSpan.FromHours(24); + /// + /// Falso em CI e quando o app é aberto por um script de teste (o app de verdade liga na janela): a atualização + /// automática fecharia o app no meio do smoke. Os testes deixam ligado. + /// + public bool AllowAutoInstall { get; init; } = true; + + /// Alguma ação do usuário já chegou desde a abertura: a atualização automática não reinicia mais o app por cima dele. + private bool _userActedSinceStart; private readonly IUpdateService? _updates; private bool _installerLaunched; @@ -69,7 +76,6 @@ private void StartAutomaticUpdateCheck() return; } if (_updates is null || !Settings.AutoCheckUpdates) return; - if (Settings.LastUpdateCheck is { } last && Now() - last < AutomaticCheckInterval) return; Track(CheckForUpdatesAsync(manual: false)); } @@ -117,6 +123,14 @@ private async Task DownloadUpdateAsync(UpdateManifest manifest, bool manual) { ReadyUpdate = await _updates!.DownloadAsync(manifest, null, CancellationToken.None); UpdateState = UpdateState.Ready; + if (!manual && CanInstallAutomatically() && Settings.LastAutoInstallAttempt != manifest.Version.ToString()) + { + UpdateSettings(s => s with { LastAutoInstallAttempt = manifest.Version.ToString() }); + // Logo depois de abrir, sem nada em andamento e sem o usuário ter começado a usar: instala e reabre já na versão nova. + StatusMessage = $"Atualizando para {manifest.Version}…"; + InstallUpdateNow(); + if (_installerLaunched) return; + } // Não interrompe o usuário no meio de um diálogo ou de uma operação de disco. if (manual || (TopModal is null && Operations.ActiveCount == 0)) ShowUpdateReady(); else StatusMessage = $"Atualização {manifest.Version} pronta. Abra o menu para instalar."; @@ -135,6 +149,14 @@ private async Task DownloadUpdateAsync(UpdateManifest manifest, bool manual) } } + /// + /// A atualização automática da abertura só vale para o app instalado, com a opção ligada, nas primeiras ações depois de abrir: + /// nenhuma operação, nenhuma janela aberta e nenhuma tecla ou botão ainda. Se o usuário já começou a usar, fica o aviso de sempre. + /// + private bool CanInstallAutomatically() => + AllowAutoInstall && Settings.AutoInstallUpdates && _updates is { IsInstalled: true } && !_installerLaunched + && !_userActedSinceStart && Operations.ActiveCount == 0 && TopModal is null; + private void ShowUpdateReady() { if (ReadyUpdate is not { } ready) return; @@ -211,7 +233,11 @@ private void ShowUpdatesMenu() Detail: $"Versão atual {_updates.CurrentVersion}" + (Settings.LastUpdateCheck is { } last ? $" · última verificação {last.LocalDateTime:g}" : string.Empty), Icon: ActionIcon.Refresh)); items.Add(new MenuItem($"Verificar automaticamente: {(Settings.AutoCheckUpdates ? "sim" : "não")}", () => UpdateSettings(s => s with { AutoCheckUpdates = !s.AutoCheckUpdates }), - Detail: "Uma consulta por dia às releases do GitHub; nenhum dado pessoal é enviado.", Icon: ActionIcon.Operations)); + Detail: "Uma consulta às releases do GitHub a cada vez que o app abre; nenhum dado pessoal é enviado.", Icon: ActionIcon.Operations)); + items.Add(new MenuItem($"Atualizar sozinho ao abrir: {(Settings.AutoInstallUpdates ? "sim" : "não")}", + () => UpdateSettings(s => s with { AutoInstallUpdates = !s.AutoInstallUpdates }), + _updates.IsInstalled ? null : "Somente na versão instalada; a portátil é atualizada manualmente.", + Detail: "Ao abrir, se há versão nova (verificada), o ControlFS instala e reabre sozinho, desde que você ainda não tenha começado a usar e nada esteja em andamento.", Icon: ActionIcon.Update)); items.Add(new MenuItem($"Instalar ao sair: {(Settings.InstallUpdatesOnExit ? "sim" : "não")}", () => UpdateSettings(s => s with { InstallUpdatesOnExit = !s.InstallUpdatesOnExit }), _updates.IsInstalled ? null : "Somente na versão instalada; a portátil é atualizada manualmente.", Icon: ActionIcon.Exit)); diff --git a/src/ControlFS.Application/AppController.cs b/src/ControlFS.Application/AppController.cs index b9b1224d..ab409ec1 100644 --- a/src/ControlFS.Application/AppController.cs +++ b/src/ControlFS.Application/AppController.cs @@ -196,6 +196,7 @@ public async Task WhenIdleAsync() public void Handle(InputAction action) { + _userActedSinceStart = true; if (action.IsScroll()) { // Rolagem contínua: chega muitas vezes por segundo; só redesenha quando algo mudou e mantém o aviso do rodapé. diff --git a/src/ControlFS.Core/Contracts/ISettingsStore.cs b/src/ControlFS.Core/Contracts/ISettingsStore.cs index 2a3149bc..5bad7065 100644 --- a/src/ControlFS.Core/Contracts/ISettingsStore.cs +++ b/src/ControlFS.Core/Contracts/ISettingsStore.cs @@ -111,9 +111,21 @@ public sealed record AppSettings /// Nomes e buscas concluídos no teclado virtual, o mais recente primeiro (lista limitada; nunca senhas). public IReadOnlyList TypedTexts { get; init; } = []; - /// Verifica novas versões ao abrir (no máximo uma vez por dia). Desligável no menu. + /// Verifica novas versões a cada abertura do app. Desligável no menu. public bool AutoCheckUpdates { get; init; } = true; + /// + /// Atualização automática ao abrir (versão instalada): se a verificação da abertura achar uma versão nova e verificada, e + /// o usuário ainda não começou a usar o app, instala e reabre sozinho. Desligado, só oferece "Instalar e reiniciar". + /// + public bool AutoInstallUpdates { get; init; } = true; + + /// + /// Última versão que a atualização automática tentou instalar. Se a mesma versão aparecer de novo na abertura seguinte + /// (a instalação não pegou), não tenta de novo sozinha: cai no aviso de sempre. Evita um laço de reinícios. + /// + public string? LastAutoInstallAttempt { get; init; } + /// Instala em silêncio, ao sair, uma atualização já baixada e verificada. public bool InstallUpdatesOnExit { get; init; } = true; diff --git a/tests/ControlFS.UnitTests/Updates/UpdateFlowTests.cs b/tests/ControlFS.UnitTests/Updates/UpdateFlowTests.cs index 2da40f4a..f9295344 100644 --- a/tests/ControlFS.UnitTests/Updates/UpdateFlowTests.cs +++ b/tests/ControlFS.UnitTests/Updates/UpdateFlowTests.cs @@ -74,7 +74,7 @@ public void Automatic_check_waits_for_the_first_frame_when_deferred() => UiConte [Fact] public void Automatic_check_downloads_and_offers_install_and_restart() => UiContext.Run(async () => { - var (app, updates, store) = Boot(); + var (app, updates, store) = Boot(new AppSettings { AutoInstallUpdates = false }); var exited = false; app.ExitRequested += () => exited = true; app.Start(); @@ -97,7 +97,7 @@ public void Automatic_check_downloads_and_offers_install_and_restart() => UiCont [Fact] public void Postponed_update_installs_silently_on_exit_without_relaunch() => UiContext.Run(async () => { - var (app, updates, _) = Boot(); + var (app, updates, _) = Boot(new AppSettings { AutoInstallUpdates = false }); app.Start(); await app.WhenIdleAsync(); app.Handle(InputAction.Back); // "Depois" @@ -109,7 +109,7 @@ public void Postponed_update_installs_silently_on_exit_without_relaunch() => UiC [Fact] public void Install_on_exit_can_be_turned_off() => UiContext.Run(async () => { - var (app, updates, _) = Boot(new AppSettings { InstallUpdatesOnExit = false }); + var (app, updates, _) = Boot(new AppSettings { InstallUpdatesOnExit = false, AutoInstallUpdates = false }); app.Start(); await app.WhenIdleAsync(); app.Handle(InputAction.Back); @@ -118,17 +118,80 @@ public void Install_on_exit_can_be_turned_off() => UiContext.Run(async () => }); [Fact] - public void Automatic_check_respects_the_setting_and_the_daily_interval() => UiContext.Run(async () => + public void Automatic_check_respects_the_setting_and_runs_on_every_open() => UiContext.Run(async () => { var (off, offUpdates, _) = Boot(new AppSettings { AutoCheckUpdates = false }); off.Start(); await off.WhenIdleAsync(); Assert.Equal(0, offUpdates.Checks); - var (recent, recentUpdates, _) = Boot(new AppSettings { LastUpdateCheck = DateTimeOffset.UtcNow.AddHours(-2) }); + var (recent, recentUpdates, _) = Boot(new AppSettings { LastUpdateCheck = DateTimeOffset.UtcNow.AddMinutes(-5), AutoInstallUpdates = false }); recent.Start(); await recent.WhenIdleAsync(); - Assert.Equal(0, recentUpdates.Checks); + Assert.Equal(1, recentUpdates.Checks); // a cada abertura, mesmo que tenha verificado há pouco + }); + + [Fact] + public void On_open_a_verified_update_installs_and_relaunches_by_itself_when_nothing_was_started() => UiContext.Run(async () => + { + var (app, updates, _) = Boot(); + var exited = false; + app.ExitRequested += () => exited = true; + app.Start(); + await app.WhenIdleAsync(); + + Assert.True(updates.LaunchedWithRelaunch); // instalador aberto com "reabrir depois" + Assert.True(exited); + Assert.Null(app.TopModal); // sem pergunta + }); + + [Fact] + public void A_version_the_automatic_install_already_tried_is_only_offered_never_retried_by_itself() => UiContext.Run(async () => + { + var (app, updates, store) = Boot(new AppSettings { LastAutoInstallAttempt = "0.1.0-alpha.2" }); // a mesma que o Fake oferece + app.Start(); + await app.WhenIdleAsync(); + Assert.Null(updates.LaunchedWithRelaunch); // sem laço de reinícios + Assert.IsType(app.TopModal); + + var (first, firstUpdates, firstStore) = Boot(); + first.Start(); + await first.WhenIdleAsync(); + Assert.True(firstUpdates.LaunchedWithRelaunch); + Assert.Equal("0.1.0-alpha.2", firstStore.Current.LastAutoInstallAttempt); + }); + + [Fact] + public void The_automatic_install_never_steps_over_the_user_the_portable_build_or_the_switch_or_ci() => UiContext.Run(async () => + { + // Já começou a usar antes de o download terminar: fica o aviso de sempre (Instalar e reiniciar / Depois). + var (busy, busyUpdates, _) = Boot(deferToFirstFrame: true); + busy.Start(); + busy.Handle(InputAction.NavigateDown); + busy.OnFirstFrame(); + await busy.WhenIdleAsync(); + Assert.Null(busyUpdates.LaunchedWithRelaunch); + Assert.IsType(busy.TopModal); + + // Desligado nas configurações. + var (off, offUpdates, _) = Boot(new AppSettings { AutoInstallUpdates = false }); + off.Start(); + await off.WhenIdleAsync(); + Assert.Null(offUpdates.LaunchedWithRelaunch); + + // Portátil: só avisa. + var (portable, portableUpdates, _) = Boot(installed: false); + portable.Start(); + await portable.WhenIdleAsync(); + Assert.Null(portableUpdates.LaunchedWithRelaunch); + + // CI / script de teste (o app de verdade desliga): o app não fecha sozinho. + var updates = new FakeUpdates(); + var guarded = new AppController(new TestFileSystem(_tmp.Path), new ArchiveService(), new MemorySettings(new AppSettings()), updates) { AllowAutoInstall = false }; + guarded.Start(); + await guarded.WhenIdleAsync(); + Assert.Null(updates.LaunchedWithRelaunch); + Assert.IsType(guarded.TopModal); }); [Fact]