diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b2b4f9d..1603dff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,15 @@ -name: Release to Microsoft Store +name: Release # Dispara ao publicar uma tag "v*" (ex.: v1.2.3) ou manualmente pela aba Actions. -# Pré-requisitos (configurar uma única vez em Settings > Secrets and variables > Actions): +# +# Dois canais, propositalmente independentes: +# github - CANAL OFICIAL. Publica o instalador nos Releases e é de onde o aplicativo se +# atualiza sozinho (ver ServicoAtualizacao). Não depende de secret nenhum. +# store - Microsoft Store. Passa por análise e costuma sair depois. Um MSIX da Store e um +# pacote assinado localmente têm identidades diferentes, então um NUNCA atualiza o +# outro: quem instalou pela Store é apenas avisado da versão nova. +# +# Pré-requisitos do job da Store (Settings > Secrets and variables > Actions): # AZURE_AD_TENANT_ID - Tenant do Microsoft Entra ID associado ao Partner Center # AZURE_AD_APPLICATION_CLIENT_ID - Client ID do app do Entra ID # AZURE_AD_APPLICATION_SECRET - Client secret desse app @@ -26,6 +34,75 @@ concurrency: cancel-in-progress: false jobs: + github: + name: Publish installer to GitHub Releases + runs-on: windows-latest + # O canal oficial anexa arquivos ao release — o único job que escreve no repositório. + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Resolve version + id: ver + shell: pwsh + run: | + $raw = '${{ github.event.inputs.version }}' + if ([string]::IsNullOrWhiteSpace($raw)) { $raw = '${{ github.ref_name }}' } + $raw = $raw.TrimStart('v','V') + if ($raw -notmatch '^\d+\.\d+(\.\d+){0,2}$') { + throw "Versão inválida: '$raw'. Use algo como v1.2.3 ou 1.2.3." + } + "version=$raw" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + Write-Host "Versão: $raw" + + - name: Install vpk + run: dotnet tool install -g vpk + + # SEM PublishSingleFile: o Velopack empacota o conteúdo da pasta e monta atualizações + # incrementais comparando arquivo a arquivo. Num executável único, qualquer mudança + # reescreve o binário inteiro e todo update volta a baixar ~150 MB. + - name: Publish (win-x64) + run: > + dotnet publish src/ClefExplorer.csproj + -c Release + -r win-x64 + --self-contained + -p:PublishSingleFile=false + -o publish + + # Traz os pacotes já publicados para o vpk conseguir gerar o delta desta versão. + # Na primeira execução não há o que baixar e o comando segue sem erro. + - name: Download previous releases + continue-on-error: true + run: vpk download github --repoUrl https://github.com/${{ github.repository }} --token ${{ secrets.GITHUB_TOKEN }} + + - name: Build installer + run: > + vpk pack + --packId ClefExplorer + --packVersion ${{ steps.ver.outputs.version }} + --packDir publish + --mainExe ClefExplorer.exe + --packAuthors "Anderson Fernandes do Nascimento" + --icon src/app.ico + + # Anexa Setup.exe, .nupkg e releases.win.json ao release da tag. O manifesto é o que o + # aplicativo instalado lê para descobrir que existe versão nova. + - name: Upload to GitHub Release + run: > + vpk upload github + --repoUrl https://github.com/${{ github.repository }} + --token ${{ secrets.GITHUB_TOKEN }} + --tag ${{ github.ref_name }} + --releaseName "ClefExplorer ${{ steps.ver.outputs.version }}" + --publish + store: name: Package MSIX & publish to Store runs-on: windows-latest diff --git a/README.md b/README.md index 2d40456..b5e39bc 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,24 @@ O **ClefExplorer** é uma aplicação desktop moderna desenvolvida para facilita ![screenshot](screenshot.png?raw=true "Screenshot") ## Download -As versões estão disponíveis neste repositório do GitHub junto com a Windows Store -### Windows +### Instalador (recomendado) + +Baixe o **ClefExplorer-win-Setup.exe** na [página de releases](https://github.com/afernandes/ClefExplorer/releases/latest). +Instala para o usuário atual (sem pedir administrador) e **se mantém atualizado sozinho**: a +cada abertura o aplicativo procura versão nova, baixa em segundo plano e aplica no reinício. + +Também há o `ClefExplorer-win-Portable.zip`, para rodar sem instalar — este não se atualiza +sozinho, apenas avisa quando sai versão nova. + +### Microsoft Store + English badge +A versão da Store é atualizada pelo próprio Windows, mas cada envio passa por análise e +costuma sair alguns dias depois do GitHub — que é o canal oficial. Por isso o aplicativo +avisa quando há versão nova publicada aqui, com um link para o release. + ## 🚀 Funcionalidades Principais diff --git a/src/ClefExplorer.csproj b/src/ClefExplorer.csproj index e463b05..566ac38 100644 --- a/src/ClefExplorer.csproj +++ b/src/ClefExplorer.csproj @@ -41,6 +41,10 @@ Newtonsoft que vinha com ele). Do Serilog continuam em uso o MessageTemplate/ MessageTemplateParser, os LogEventPropertyValue e o JsonValueFormatter da exportação. --> + + diff --git a/src/Components/LogViewer.razor b/src/Components/LogViewer.razor index 4a0cc60..5ae1881 100644 --- a/src/Components/LogViewer.razor +++ b/src/Components/LogViewer.razor @@ -14,6 +14,8 @@ @inject NotificationService Notifications @inject UiPreferencesService UiPreferences @inject ConsultaLogs Consulta +@inject ServicoAtualizacao Atualizacao +@inject ExploradorArquivos Explorador @inject IJSRuntime JS
@@ -34,6 +36,29 @@ } + @if (_atualizacao is { } atualizacao && !_avisoAtualizacaoDispensado) + { + + + A versão @atualizacao.Versao do Clef Explorer está disponível. + @if (atualizacao.PodeReiniciar) + { + Ela já foi baixada e será aplicada ao reiniciar. + } + + + @if (atualizacao.PodeReiniciar) + { + + } + else + { + + } + + + } +
@@ -230,6 +255,8 @@ private DateTime? _dataInicial; private DateTime? _dataFinal; private bool _showDefaultAppNotification; + private InfoAtualizacao? _atualizacao; + private bool _avisoAtualizacaoDispensado; private bool _isExporting; private int _eventosExportados; private int _totalExportacao; @@ -395,6 +422,11 @@ AplicarFiltros(); } + Atualizacao.Changed += OnAtualizacaoEncontrada; + // Uma verificação que já tenha terminado antes deste componente existir (o serviço + // é singleton e sobrevive a recargas da UI) não dispara o evento de novo. + _atualizacao = Atualizacao.Disponivel; + CheckDefaultApp(); ReportarErrosDeInicializacao(); } @@ -524,6 +556,10 @@ // mas o resto do app não é afetado. AppLog.Warning("Não foi possível registrar o arraste interno", ex); } + + // Depois do primeiro render, sem await: a janela já está na tela e a faixa aparece + // quando (e se) a resposta chegar. O serviço trata as próprias exceções. + _ = Atualizacao.VerificarAsync(); } /// @@ -606,6 +642,7 @@ Store.Changed -= OnStoreChanged; Store.PathsLoaded -= OnPathsLoaded; GroupService.Changed -= OnGroupsChanged; + Atualizacao.Changed -= OnAtualizacaoEncontrada; Consulta.CancelarConsultaAtual(); foreach (var campo in new[] @@ -647,6 +684,38 @@ _showDefaultAppNotification = false; } + /// + /// Procura versão nova em segundo plano, depois que a janela já apareceu. Nunca no + /// caminho de abertura: é uma chamada de rede, e o aplicativo tem que abrir na mesma + /// velocidade com ou sem internet. + /// + private void OnAtualizacaoEncontrada() => DespacharNaUi(() => + { + _atualizacao = Atualizacao.Disponivel; + StateHasChanged(); + }); + + private void AplicarAtualizacao() => Atualizacao.AplicarEReiniciar(); + + private void AbrirPaginaDaAtualizacao() + { + if (_atualizacao is null) return; + if (!Explorador.AbrirUrl(_atualizacao.Url)) + { + Notifications.Warning( + "Não foi possível abrir a página da versão nova", + ServicoAtualizacao.UrlDosReleases); + } + } + + private void DispensarAvisoAtualizacao() + { + // Só até a próxima abertura: a verificação roda a cada início, então a faixa + // reaparece se a versão continuar disponível — sem virar insistência dentro da + // mesma sessão. + _avisoAtualizacaoDispensado = true; + } + private void Carregar() { AplicarFiltros(); diff --git a/src/Program.cs b/src/Program.cs index ece0657..b3d3fc5 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using ClefExplorer.Services; using Omni.Blazor; +using Velopack; namespace ClefExplorer { @@ -10,6 +11,13 @@ internal static class Program [STAThread] static void Main(string[] args) { + // PRIMEIRA linha do Main, antes de qualquer outra coisa: é aqui que o Velopack + // trata os hooks de instalação/atualização (o instalador reexecuta o próprio + // executável com argumentos próprios e espera que ele saia). Qualquer código + // antes disto — inclusive o mutex de instância única — rodaria durante a + // instalação e poderia travá-la. + VelopackApp.Build().Run(); + Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", Path.GetTempPath() + @"ClefExplorer"); // Preserve a origem dos caminhos relativos antes de mudar o diretório para a @@ -50,6 +58,10 @@ static void Main(string[] args) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton( + _ => new ConsultorReleasesGithub(ConsultorReleasesGithub.CriarCliente())); + services.AddSingleton(); #if DEBUG services.AddBlazorWebViewDeveloperTools(); diff --git a/src/Services/AtualizadorVelopack.cs b/src/Services/AtualizadorVelopack.cs new file mode 100644 index 0000000..350570d --- /dev/null +++ b/src/Services/AtualizadorVelopack.cs @@ -0,0 +1,68 @@ +using Velopack; +using Velopack.Sources; + +namespace ClefExplorer.Services +{ + /// + /// Atualização automática para quem instalou pelo canal oficial (o Setup publicado nos + /// Releases do GitHub). + /// + /// + /// + /// é falso em duas situações legítimas, e nas duas o + /// cai no caminho de apenas avisar: + /// + /// + /// + /// pacote da Microsoft Store — fica em Program Files\WindowsApps, somente + /// leitura, e a identidade de um MSIX não pode ser substituída pelo próprio + /// aplicativo; quem atualiza é a Store; + /// + /// + /// executável avulso — não existe estrutura de instalação para receber o pacote. + /// + /// + /// + public sealed class AtualizadorVelopack : IAtualizadorLocal + { + private const string RepositorioOficial = "https://github.com/afernandes/ClefExplorer"; + + private readonly UpdateManager? _gerenciador; + private UpdateInfo? _preparada; + + public AtualizadorVelopack() + { + try + { + _gerenciador = new UpdateManager(new GithubSource(RepositorioOficial, null, prerelease: false)); + } + catch (Exception ex) + { + // Sem gerenciador o aplicativo segue no modo "apenas avisa". + AppLog.Warning("Não foi possível iniciar o atualizador automático", ex); + _gerenciador = null; + } + } + + public bool PodeAplicar => _gerenciador?.IsInstalled ?? false; + + public async Task PrepararAsync(CancellationToken cancellationToken) + { + if (_gerenciador is null) return null; + + var novidade = await _gerenciador.CheckForUpdatesAsync().ConfigureAwait(false); + if (novidade is null) return null; + + await _gerenciador.DownloadUpdatesAsync(novidade, cancelToken: cancellationToken).ConfigureAwait(false); + _preparada = novidade; + return novidade.TargetFullRelease.Version.ToString(); + } + + public void AplicarEReiniciar() + { + if (_gerenciador is null || _preparada is null) return; + + _gerenciador.ApplyUpdatesAndRestart(_preparada.TargetFullRelease); + } + } +} diff --git a/src/Services/ConsultorReleasesGithub.cs b/src/Services/ConsultorReleasesGithub.cs new file mode 100644 index 0000000..10228cf --- /dev/null +++ b/src/Services/ConsultorReleasesGithub.cs @@ -0,0 +1,73 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +namespace ClefExplorer.Services +{ + /// + /// Lê o último release publicado em github.com/afernandes/ClefExplorer. + /// + /// + /// Usa /releases/latest, que a API do GitHub já define como o release mais + /// recente NÃO marcado como pré-lançamento nem rascunho — assim uma prévia publicada + /// para testes não vira aviso para todo mundo. + /// + public sealed class ConsultorReleasesGithub : IConsultorReleases + { + public const string UrlApi = "https://api.github.com/repos/afernandes/ClefExplorer/releases/latest"; + + private readonly HttpClient _http; + + public ConsultorReleasesGithub(HttpClient http) + { + _http = http; + } + + /// + /// Cliente com o que a API do GitHub exige: User-Agent (sem ele a resposta é 403) e + /// um tempo-limite curto, porque isto roda com o aplicativo já aberto e ninguém + /// deve esperar por ele. + /// + public static HttpClient CriarCliente() + { + var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("ClefExplorer", "1.0")); + http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + return http; + } + + public async Task UltimoAsync(CancellationToken cancellationToken) + { + using var resposta = await _http.GetAsync(UrlApi, cancellationToken).ConfigureAwait(false); + if (!resposta.IsSuccessStatusCode) + { + AppLog.Warning($"O canal oficial respondeu {(int)resposta.StatusCode} ao procurar a última versão."); + return null; + } + + await using var conteudo = await resposta.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var json = await JsonDocument.ParseAsync(conteudo, cancellationToken: cancellationToken).ConfigureAwait(false); + + return Interpretar(json.RootElement); + } + + /// + /// Separado do transporte para o formato do release ser testável sem rede. + /// + public static ReleaseGithub? Interpretar(JsonElement release) + { + if (!release.TryGetProperty("tag_name", out var tag) || tag.ValueKind != JsonValueKind.String) + { + return null; + } + + var texto = tag.GetString()!.TrimStart('v', 'V'); + if (!Version.TryParse(texto, out var versao)) return null; + + var url = release.TryGetProperty("html_url", out var link) && link.ValueKind == JsonValueKind.String + ? link.GetString()! + : ServicoAtualizacao.UrlDosReleases; + + return new ReleaseGithub(versao, url); + } + } +} diff --git a/src/Services/ExploradorArquivos.cs b/src/Services/ExploradorArquivos.cs index e92e84f..503cbc0 100644 --- a/src/Services/ExploradorArquivos.cs +++ b/src/Services/ExploradorArquivos.cs @@ -50,6 +50,35 @@ public bool Revelar(string? caminho) } } + /// + /// Abre um endereço no navegador padrão (usado pelo aviso de versão nova). + /// + /// + /// Só http/https: o endereço chega de uma resposta da API do GitHub, e + /// UseShellExecute com uma string arbitrária executaria um caminho local + /// como programa. Recusar o que não é endereço web mantém o shell fora do alcance + /// de qualquer coisa que venha da rede. + /// + public bool AbrirUrl(string? url) + { + if (!EhEnderecoWeb(url)) return false; + + try + { + Process.Start(new ProcessStartInfo(url!) { UseShellExecute = true }); + return true; + } + catch (Exception ex) + { + AppLog.Warning($"Não foi possível abrir '{url}' no navegador", ex); + return false; + } + } + + public static bool EhEnderecoWeb(string? url) => + Uri.TryCreate(url, UriKind.Absolute, out var destino) + && (destino.Scheme == Uri.UriSchemeHttp || destino.Scheme == Uri.UriSchemeHttps); + /// /// Linha de comando que abre a pasta com o arquivo já selecionado. As aspas não são /// opcionais: sem elas o Explorer quebra o caminho em vírgulas e espaços e acaba diff --git a/src/Services/InfoAtualizacao.cs b/src/Services/InfoAtualizacao.cs new file mode 100644 index 0000000..6fca6c4 --- /dev/null +++ b/src/Services/InfoAtualizacao.cs @@ -0,0 +1,43 @@ +namespace ClefExplorer.Services +{ + /// + /// Versão publicada mais recente, quando é maior que a que está rodando. + /// + /// Versão anunciada, já sem o "v" da tag (ex.: 1.4.0). + /// Página do release, para quem precisa baixar à mão. + /// + /// true quando o pacote já foi baixado e basta reiniciar para aplicá-lo. É falso + /// para quem instalou pela Microsoft Store ou roda o executável avulso: nesses casos o + /// aplicativo não tem como se substituir sozinho e o aviso leva ao release. + /// + public sealed record InfoAtualizacao(string Versao, string Url, bool PodeReiniciar); + + /// Último release publicado no canal oficial. + public sealed record ReleaseGithub(Version Versao, string Url); + + /// Consulta a versão publicada. Implementação real fala com a API do GitHub. + public interface IConsultorReleases + { + Task UltimoAsync(CancellationToken cancellationToken); + } + + /// + /// A parte que só existe para quem instalou pelo canal oficial: baixar o pacote e + /// aplicá-lo. Isolada em interface para o ser + /// testável sem instalar o aplicativo de verdade. + /// + public interface IAtualizadorLocal + { + /// Se esta instalação sabe se atualizar sozinha. + bool PodeAplicar { get; } + + /// + /// Procura, baixa e deixa o pacote pronto. Devolve a versão preparada, ou + /// null se já está atualizado. + /// + Task PrepararAsync(CancellationToken cancellationToken); + + /// Fecha o aplicativo, aplica o pacote preparado e abre de novo. + void AplicarEReiniciar(); + } +} diff --git a/src/Services/ServicoAtualizacao.cs b/src/Services/ServicoAtualizacao.cs new file mode 100644 index 0000000..d0a5299 --- /dev/null +++ b/src/Services/ServicoAtualizacao.cs @@ -0,0 +1,114 @@ +using System.Reflection; + +namespace ClefExplorer.Services +{ + /// + /// Avisa quando existe versão nova no canal oficial (Releases do GitHub) e, para quem + /// instalou por ele, deixa o pacote pronto para ser aplicado no reinício. + /// + /// + /// O canal oficial é único de propósito: o pacote da Microsoft Store passa por análise e + /// costuma sair depois, então quem instalou pela Store também é avisado daqui — só que + /// para esse público (e para quem roda o executável avulso) o aviso leva à página do + /// release, porque um pacote MSIX não pode ser substituído pelo próprio aplicativo. + /// + public sealed class ServicoAtualizacao + { + private readonly IAtualizadorLocal _atualizador; + private readonly IConsultorReleases _consultor; + private readonly Version _versaoAtual; + + public ServicoAtualizacao( + IAtualizadorLocal atualizador, + IConsultorReleases consultor, + Version? versaoAtual = null) + { + _atualizador = atualizador; + _consultor = consultor; + _versaoAtual = Normalizar( + versaoAtual ?? Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0)); + } + + /// Versão nova encontrada, ou null enquanto não houver. + public InfoAtualizacao? Disponivel { get; private set; } + + /// Disparado quando passa a ter valor. + public event Action? Changed; + + /// + /// Verifica o canal oficial. Nunca lança: uma falha de rede (ou uma máquina sem + /// internet) não pode atrapalhar a abertura do aplicativo — o aviso simplesmente + /// não aparece. + /// + public async Task VerificarAsync(CancellationToken cancellationToken = default) + { + try + { + var info = _atualizador.PodeAplicar + ? await PrepararInstalacaoAsync(cancellationToken).ConfigureAwait(false) + : await ApenasAvisarAsync(cancellationToken).ConfigureAwait(false); + + if (info is null) return; + + Disponivel = info; + Changed?.Invoke(); + } + catch (OperationCanceledException) + { + // Fechar o aplicativo durante a verificação é rotina, não é falha. + } + catch (Exception ex) + { + AppLog.Warning("Falha ao verificar se existe uma versão nova", ex); + } + } + + /// + /// Aplica o pacote já baixado e reabre o aplicativo. Só faz sentido quando + /// é verdadeiro. + /// + public void AplicarEReiniciar() + { + if (Disponivel is not { PodeReiniciar: true }) return; + + try + { + _atualizador.AplicarEReiniciar(); + } + catch (Exception ex) + { + // O aplicativo continua utilizável na versão atual; o usuário ainda pode + // baixar o pacote pela página do release. + AppLog.Warning("Falha ao aplicar a atualização", ex); + } + } + + private async Task PrepararInstalacaoAsync(CancellationToken cancellationToken) + { + var versao = await _atualizador.PrepararAsync(cancellationToken).ConfigureAwait(false); + if (versao is null) return null; + + AppLog.Info($"Versão {versao} baixada e pronta para ser aplicada no reinício."); + return new InfoAtualizacao(versao, UrlDosReleases, PodeReiniciar: true); + } + + private async Task ApenasAvisarAsync(CancellationToken cancellationToken) + { + var release = await _consultor.UltimoAsync(cancellationToken).ConfigureAwait(false); + if (release is null || Normalizar(release.Versao) <= _versaoAtual) return null; + + AppLog.Info($"Versão {release.Versao} publicada no canal oficial (esta instalação atualiza manualmente)."); + return new InfoAtualizacao(release.Versao.ToString(), release.Url, PodeReiniciar: false); + } + + /// + /// Compara sempre com três partes. O assembly carrega quatro (1.3.0.0) e a tag do + /// release, três (v1.3.0) — e considera 1.3.0 MENOR que + /// 1.3.0.0, o que anunciaria uma versão nova a cada abertura. + /// + private static Version Normalizar(Version versao) => + new(versao.Major, versao.Minor, Math.Max(versao.Build, 0)); + + public const string UrlDosReleases = "https://github.com/afernandes/ClefExplorer/releases/latest"; + } +} diff --git a/test/ClefExplorer.Tests/ServicoAtualizacaoTests.cs b/test/ClefExplorer.Tests/ServicoAtualizacaoTests.cs new file mode 100644 index 0000000..6a9ad2a --- /dev/null +++ b/test/ClefExplorer.Tests/ServicoAtualizacaoTests.cs @@ -0,0 +1,255 @@ +using System.Text.Json; +using ClefExplorer.Services; + +namespace ClefExplorer.Tests; + +/// +/// Regras do aviso de versão nova: quem instalou pelo canal oficial baixa e reinicia; quem +/// veio da Microsoft Store ou roda o executável avulso apenas recebe o aviso. +/// +public class ServicoAtualizacaoTests +{ + private sealed class AtualizadorFake : IAtualizadorLocal + { + public bool PodeAplicar { get; init; } + public string? VersaoPreparada { get; init; } + public bool Aplicou { get; private set; } + public Exception? FalhaAoPreparar { get; init; } + + public Task PrepararAsync(CancellationToken cancellationToken) + { + if (FalhaAoPreparar is not null) throw FalhaAoPreparar; + return Task.FromResult(VersaoPreparada); + } + + public void AplicarEReiniciar() => Aplicou = true; + } + + private sealed class ConsultorFake : IConsultorReleases + { + public ReleaseGithub? Release { get; init; } + public Exception? Falha { get; init; } + public int Chamadas { get; private set; } + + public Task UltimoAsync(CancellationToken cancellationToken) + { + Chamadas++; + if (Falha is not null) throw Falha; + return Task.FromResult(Release); + } + } + + private static ReleaseGithub Release(string versao) => + new(Version.Parse(versao), $"https://github.com/afernandes/ClefExplorer/releases/tag/v{versao}"); + + [Fact] + public async Task Quem_instalou_pelo_canal_oficial_baixa_e_pode_reiniciar() + { + var atualizador = new AtualizadorFake { PodeAplicar = true, VersaoPreparada = "1.4.0" }; + var servico = new ServicoAtualizacao(atualizador, new ConsultorFake(), new Version(1, 3, 0)); + + await servico.VerificarAsync(); + + Assert.NotNull(servico.Disponivel); + Assert.Equal("1.4.0", servico.Disponivel!.Versao); + Assert.True(servico.Disponivel.PodeReiniciar); + } + + [Fact] + public async Task Instalacao_da_Store_apenas_avisa_e_aponta_para_o_release() + { + // PodeAplicar falso é o que a Store (e o executável avulso) produz: o pacote não + // pode se substituir sozinho. + var consultor = new ConsultorFake { Release = Release("1.4.0") }; + var servico = new ServicoAtualizacao( + new AtualizadorFake { PodeAplicar = false }, consultor, new Version(1, 3, 0)); + + await servico.VerificarAsync(); + + Assert.NotNull(servico.Disponivel); + Assert.Equal("1.4.0", servico.Disponivel!.Versao); + Assert.False(servico.Disponivel.PodeReiniciar); + Assert.Contains("releases/tag/v1.4.0", servico.Disponivel.Url); + } + + [Fact] + public async Task A_versao_publicada_igual_a_atual_nao_vira_aviso() + { + // Regressão: o assembly tem QUATRO partes (1.3.0.0) e a tag do release, três + // (v1.3.0). Sem normalizar, Version considera 1.3.0 < 1.3.0.0 e o aplicativo + // anunciaria "versão nova" a cada abertura, para sempre. + var consultor = new ConsultorFake { Release = Release("1.3.0") }; + var servico = new ServicoAtualizacao( + new AtualizadorFake { PodeAplicar = false }, consultor, new Version(1, 3, 0, 0)); + + await servico.VerificarAsync(); + + Assert.Null(servico.Disponivel); + } + + [Fact] + public async Task Uma_versao_publicada_mais_antiga_nao_vira_aviso() + { + var consultor = new ConsultorFake { Release = Release("1.2.0") }; + var servico = new ServicoAtualizacao( + new AtualizadorFake { PodeAplicar = false }, consultor, new Version(1, 3, 0)); + + await servico.VerificarAsync(); + + Assert.Null(servico.Disponivel); + } + + [Fact] + public async Task Falha_de_rede_nao_derruba_a_abertura_do_aplicativo() + { + var consultor = new ConsultorFake { Falha = new HttpRequestException("sem rede") }; + var servico = new ServicoAtualizacao( + new AtualizadorFake { PodeAplicar = false }, consultor, new Version(1, 3, 0)); + + await servico.VerificarAsync(); + + Assert.Null(servico.Disponivel); + } + + [Fact] + public async Task Falha_ao_preparar_o_pacote_nao_derruba_a_abertura() + { + var atualizador = new AtualizadorFake + { + PodeAplicar = true, + FalhaAoPreparar = new IOException("disco cheio"), + }; + var servico = new ServicoAtualizacao(atualizador, new ConsultorFake(), new Version(1, 3, 0)); + + await servico.VerificarAsync(); + + Assert.Null(servico.Disponivel); + } + + [Fact] + public async Task Sem_versao_nova_o_evento_nao_dispara() + { + var servico = new ServicoAtualizacao( + new AtualizadorFake { PodeAplicar = true, VersaoPreparada = null }, + new ConsultorFake(), + new Version(1, 3, 0)); + + var disparos = 0; + servico.Changed += () => disparos++; + + await servico.VerificarAsync(); + + Assert.Equal(0, disparos); + Assert.Null(servico.Disponivel); + } + + [Fact] + public async Task O_evento_avisa_a_interface_quando_encontra_versao_nova() + { + var servico = new ServicoAtualizacao( + new AtualizadorFake { PodeAplicar = true, VersaoPreparada = "2.0.0" }, + new ConsultorFake(), + new Version(1, 3, 0)); + + var disparos = 0; + servico.Changed += () => disparos++; + + await servico.VerificarAsync(); + + Assert.Equal(1, disparos); + } + + [Fact] + public async Task Quem_pode_aplicar_nao_consulta_a_api_de_releases() + { + // O gerenciador do canal oficial já resolve a consulta; bater na API do GitHub de + // novo só gastaria a cota anônima (60 chamadas por hora). + var consultor = new ConsultorFake { Release = Release("9.9.9") }; + var servico = new ServicoAtualizacao( + new AtualizadorFake { PodeAplicar = true, VersaoPreparada = "1.4.0" }, + consultor, + new Version(1, 3, 0)); + + await servico.VerificarAsync(); + + Assert.Equal(0, consultor.Chamadas); + } + + [Fact] + public async Task Reiniciar_so_age_quando_o_pacote_esta_preparado() + { + var atualizador = new AtualizadorFake { PodeAplicar = false }; + var servico = new ServicoAtualizacao( + atualizador, new ConsultorFake { Release = Release("1.4.0") }, new Version(1, 3, 0)); + + await servico.VerificarAsync(); + servico.AplicarEReiniciar(); + + // O aviso da Store não tem pacote baixado: mandar aplicar aqui reiniciaria o + // aplicativo na mesma versão. + Assert.False(atualizador.Aplicou); + } + + [Fact] + public async Task Reiniciar_aplica_o_pacote_do_canal_oficial() + { + var atualizador = new AtualizadorFake { PodeAplicar = true, VersaoPreparada = "1.4.0" }; + var servico = new ServicoAtualizacao(atualizador, new ConsultorFake(), new Version(1, 3, 0)); + + await servico.VerificarAsync(); + servico.AplicarEReiniciar(); + + Assert.True(atualizador.Aplicou); + } + + // --- Leitura da resposta do GitHub ------------------------------------------- + + private static JsonElement Json(string texto) => JsonDocument.Parse(texto).RootElement; + + [Theory] + [InlineData("v1.4.0", "1.4.0")] + [InlineData("1.4.0", "1.4.0")] + [InlineData("V1.4.0", "1.4.0")] + [InlineData("v1.4.0.0", "1.4.0.0")] + public void A_tag_do_release_vira_versao(string tag, string esperada) + { + var release = ConsultorReleasesGithub.Interpretar( + Json($$"""{"tag_name":"{{tag}}","html_url":"https://github.com/x/y/releases/tag/{{tag}}"}""")); + + Assert.NotNull(release); + Assert.Equal(Version.Parse(esperada), release!.Versao); + } + + [Theory] + [InlineData("""{"html_url":"https://github.com"}""")] + [InlineData("""{"tag_name":"nightly"}""")] + [InlineData("""{"tag_name":123}""")] + public void Uma_tag_que_nao_e_versao_e_ignorada(string json) + { + Assert.Null(ConsultorReleasesGithub.Interpretar(Json(json))); + } + + [Fact] + public void Sem_html_url_o_aviso_cai_na_pagina_de_releases() + { + var release = ConsultorReleasesGithub.Interpretar(Json("""{"tag_name":"v1.4.0"}""")); + + Assert.NotNull(release); + Assert.Equal(ServicoAtualizacao.UrlDosReleases, release!.Url); + } + + [Theory] + [InlineData("https://github.com/afernandes/ClefExplorer/releases", true)] + [InlineData("http://exemplo.com", true)] + [InlineData(@"C:\Windows\System32\calc.exe", false)] + [InlineData("file:///C:/Windows/System32/calc.exe", false)] + [InlineData("javascript:alert(1)", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void So_endereco_web_chega_ao_shell(string? url, bool aceito) + { + // O endereço vem de uma resposta da API: sem esta barreira, UseShellExecute + // executaria um caminho local como programa. + Assert.Equal(aceito, ExploradorArquivos.EhEnderecoWeb(url)); + } +}