adams-toolkit

codigo-fonte GPL-3.0 · espelho oficial · commit b208bae5
Core/SelfUpdater.cs · 216 linhas · raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Reflection;
using System.Security.Cryptography;
using System.Text.Json;

namespace AdamsToolkit.Core;

/// <summary>
/// Auto-update: no arranque compara a versão local com version.json no nosso servidor;
/// se houver mais recente, descarrega, valida sha256, troca o exe e reinicia.
/// O exe em execução não pode ser apagado mas PODE ser renomeado — truque clássico:
/// atual → ".old" (limpo no próximo arranque), novo → caminho original.
/// </summary>
public static class SelfUpdater
{
    private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromMinutes(10) };

    public static string CurrentVersion =>
        (Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0)).ToString(3);

    // O exe antigo vai para %AppData%\AdamsToolkit — nunca fica visível ao lado
    // do exe do utilizador. Fallback (volume diferente): ".old" escondido.
    private static string OldExePath => Path.Combine(ConfigService.DataDir, "previous.exe");

    /// <summary>Apaga restos da versão anterior; retry em background porque o
    /// processo antigo pode ainda estar a terminar quando o novo arranca.</summary>
    public static void CleanupOldVersion()
    {
        CleanupOldInstallFolders();
        var legacy = Environment.ProcessPath + ".old";
        _ = Task.Run(async () =>
        {
            for (var i = 0; i < 15; i++)
            {
                var allGone = true;
                foreach (var f in new[] { OldExePath, legacy })
                {
                    try { if (File.Exists(f)) File.Delete(f); }
                    catch { allGone = false; }
                }
                if (allGone) return;
                await Task.Delay(2000);
            }
        });
    }

    /// <summary>Raiz da instalação por pastas de versão (a mesma que o AdamsToolkitSetup usa).</summary>
    private static string InstallRoot => Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AdamsToolkit");

    /// <summary>
    /// Instala a versão nova numa pasta própria e relança-a a partir de lá.
    /// Pasta por versão = nada é sobrescrito enquanto está em uso, por isso não há o
    /// truque do rename nem ficheiros bloqueados; a antiga é apagada no arranque seguinte.
    /// </summary>
    private static async Task<bool> TryUpdateFromZipAsync(string zipUrl, string? sha, string ver, Action<string> status)
    {
        try
        {
            status($"Nova versão {ver} — a descarregar…");
            var bytes = await Http.GetByteArrayAsync(zipUrl);

            if (!string.IsNullOrEmpty(sha))
            {
                var hash = Convert.ToHexString(SHA256.HashData(bytes));
                if (!hash.Equals(sha, StringComparison.OrdinalIgnoreCase))
                {
                    status("Atualização com hash inválido — ignorada.");
                    return false;
                }
            }

            status("A instalar atualização…");
            var dir = Path.Combine(InstallRoot, "app", ver);
            var staging = dir + ".tmp";
            var zip = Path.Combine(Path.GetTempPath(), $"AdamsToolkit-{ver}.zip");
            await File.WriteAllBytesAsync(zip, bytes);
            try
            {
                if (Directory.Exists(staging)) Directory.Delete(staging, true);
                ZipFile.ExtractToDirectory(zip, staging);
                if (Directory.Exists(dir)) Directory.Delete(dir, true);
                Directory.CreateDirectory(Path.GetDirectoryName(dir)!);
                Directory.Move(staging, dir);
            }
            finally { try { File.Delete(zip); } catch { } }

            var exe = Path.Combine(dir, "Adams Toolkit.exe");
            if (!File.Exists(exe)) return false;

            Process.Start(new ProcessStartInfo(exe)
            {
                UseShellExecute = true,
                WorkingDirectory = dir,
                Arguments = App.StartMinimized ? "--startup" : "",
            });
            return true;
        }
        catch
        {
            return false;
        }
    }

    /// <summary>
    /// Apaga pastas de versões antigas e o exe single-file legado. Só corre depois de a
    /// versão atual já estar a correr, por isso o que fica é sempre o que está em uso.
    /// </summary>
    private static void CleanupOldInstallFolders()
    {
        try
        {
            var here = Path.GetDirectoryName(Environment.ProcessPath);
            var root = Path.Combine(InstallRoot, "app");
            if (!Directory.Exists(root)) return;
            foreach (var d in Directory.GetDirectories(root))
            {
                if (string.Equals(d, here, StringComparison.OrdinalIgnoreCase)) continue;
                try { Directory.Delete(d, true); } catch { }
            }
            // exe do formato antigo: só some quando já não é ele a correr
            var old = Path.Combine(InstallRoot, "AdamsToolkit.exe");
            if (File.Exists(old) && !string.Equals(old, Environment.ProcessPath, StringComparison.OrdinalIgnoreCase))
                try { File.Delete(old); } catch { }
        }
        catch { }
    }

    /// <summary>Devolve true se atualizou (o chamador deve fechar a app; a nova já foi lançada).</summary>
    public static async Task<bool> TryUpdateAsync(Action<string> status)
    {
        try
        {
            var baseUrl = ConfigService.Current.AuthApiBase.TrimEnd('/');
            var json = await Http.GetStringAsync($"{baseUrl}/files/version.json");
            var doc = JsonDocument.Parse(json).RootElement;

            var remote = Version.Parse(doc.GetProperty("version").GetString()!);
            var local = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0);
            if (remote <= local) return false;

            // Formato atual = zip com pasta normal (exe + DLLs). O exe single-file
            // continua publicado em "url" só para clientes <= 1.90.0, que não sabem ler
            // isto; a partir daqui a app instala-se em %LOCALAPPDATA%\AdamsToolkit\app\<versão>.
            if (doc.TryGetProperty("zip", out var zurl) && zurl.GetString() is string zipUrl)
            {
                var zsha = doc.TryGetProperty("zipSha256", out var zh) ? zh.GetString() : null;
                if (await TryUpdateFromZipAsync(zipUrl, zsha, remote.ToString(3), status)) return true;
                // zip falhou (rede/hash) — cai para o exe único em vez de ficar sem update
            }

            var url = doc.GetProperty("url").GetString()!;
            var sha = doc.TryGetProperty("sha256", out var s) ? s.GetString() : null;

            status($"Nova versão {remote.ToString(3)} — a descarregar…");
            var bytes = await Http.GetByteArrayAsync(url);

            if (!string.IsNullOrEmpty(sha))
            {
                var hash = Convert.ToHexString(SHA256.HashData(bytes));
                if (!hash.Equals(sha, StringComparison.OrdinalIgnoreCase))
                {
                    status("Atualização com hash inválido — ignorada.");
                    return false;
                }
            }

            var current = Environment.ProcessPath;
            if (current == null) return false;
            var tmp = Path.Combine(ConfigService.DataDir, "update.tmp.exe");
            await File.WriteAllBytesAsync(tmp, bytes);

            status("A instalar atualização…");
            var old = OldExePath;
            try
            {
                if (File.Exists(old)) File.Delete(old);
                File.Move(current, old); // rename para AppData (mesmo volume) — some da pasta do user
            }
            catch
            {
                // volume diferente: rename cross-volume falha — usa ".old" escondido ao lado
                old = current + ".old";
                if (File.Exists(old)) File.Delete(old);
                File.Move(current, old);
                try { File.SetAttributes(old, FileAttributes.Hidden); } catch { }
            }
            try
            {
                File.Copy(tmp, current, true);
                File.Delete(tmp);
            }
            catch
            {
                File.Move(old, current); // restaura se a troca falhar
                throw;
            }

            // Mantém o modo de arranque: update silencioso na bandeja não deve
            // abrir a janela ao relançar.
            Process.Start(new ProcessStartInfo(current)
            {
                UseShellExecute = true,
                Arguments = App.StartMinimized ? "--startup" : "",
            });
            return true;
        }
        catch
        {
            return false; // offline / servidor sem update — segue arranque normal
        }
    }
}