@@ -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));
+ }
+}