Skip to content

Commit 0f86bc7

Browse files
committed
feat: Software update
1 parent 5c18b92 commit 0f86bc7

File tree

13 files changed

+695
-101
lines changed

13 files changed

+695
-101
lines changed

3rd/shad-ui

src/Everywhere.Windows/Program.cs

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public static void Main(string[] args)
3434
.AddSingleton<IVisualElementContext, Win32VisualElementContext>()
3535
.AddSingleton<IHotkeyListener, Win32HotkeyListener>()
3636
.AddSingleton<INativeHelper, Win32NativeHelper>()
37+
.AddSingleton<ISoftwareUpdater, SoftwareUpdater>()
3738
.AddSettings()
3839

3940
#endregion
@@ -82,6 +83,7 @@ public static void Main(string[] args)
8283

8384
.AddTransient<IAsyncInitializer, HotkeyInitializer>()
8485
.AddTransient<IAsyncInitializer, SettingsInitializer>()
86+
.AddTransient<IAsyncInitializer, UpdaterInitializer>()
8587
.AddTransient<IAsyncInitializer>(xx => xx.GetRequiredService<ChatContextManager>())
8688

8789
#endregion
@@ -92,29 +94,6 @@ public static void Main(string[] args)
9294
.AddSingleton<ChatContextManager>()
9395
.AddSingleton<IChatContextManager>(xx => xx.GetRequiredService<ChatContextManager>())
9496
.AddSingleton<IChatService, ChatService>()
95-
// .AddSingleton<IKernelMemory>(xx => new KernelMemoryBuilder()
96-
// .Configure(builder =>
97-
// {
98-
// var baseFolder = Path.Combine(
99-
// Path.GetDirectoryName(Environment.ProcessPath) ?? Environment.CurrentDirectory,
100-
// "Assets",
101-
// "text2vec-chinese-base");
102-
// var generator = new Text2VecTextEmbeddingGenerator(
103-
// Path.Combine(baseFolder, "tokenizer.json"),
104-
// Path.Combine(baseFolder, "model.onnx"));
105-
// builder.AddSingleton<ITextEmbeddingGenerator>(generator);
106-
// builder.AddSingleton<ITextEmbeddingBatchGenerator>(generator);
107-
// builder.AddIngestionEmbeddingGenerator(generator);
108-
// builder.Services.AddSingleton<ITextGenerator>(_ => xx.GetRequiredService<ITextGenerator>());
109-
// builder.AddSingleton(
110-
// new TextPartitioningOptions
111-
// {
112-
// MaxTokensPerParagraph = generator.MaxTokens,
113-
// OverlappingTokens = generator.MaxTokens / 20
114-
// });
115-
// })
116-
// .Configure(builder => builder.Services.AddLogging(l => l.AddSimpleConsole()))
117-
// .Build<MemoryServerless>())
11897

11998
#endregion
12099

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
using System.Diagnostics;
2+
using System.Reflection;
3+
using System.Security.Cryptography;
4+
using System.Text.Json;
5+
using System.Text.Json.Serialization;
6+
using CommunityToolkit.Mvvm.ComponentModel;
7+
using Everywhere.Enums;
8+
using Everywhere.Interfaces;
9+
using Everywhere.Utilities;
10+
using Microsoft.Extensions.Logging;
11+
12+
namespace Everywhere.Windows.Services;
13+
14+
public sealed partial class SoftwareUpdater(
15+
INativeHelper nativeHelper,
16+
IRuntimeConstantProvider runtimeConstantProvider,
17+
ILogger<SoftwareUpdater> logger
18+
) : ObservableObject, ISoftwareUpdater
19+
{
20+
// GitHub API and download URLs
21+
private const string GitHubApiUrl = "https://api.github.com/repos/DearVa/Everywhere/releases/latest";
22+
23+
// Proxies for robustness
24+
private static readonly string[] GitHubProxies = ["https://gh-proxy.com/"];
25+
26+
private readonly HttpClient _httpClient = new()
27+
{
28+
DefaultRequestHeaders =
29+
{
30+
{ "User-Agent", "libcurl/7.64.1 r-curl/4.3.2 httr/1.4.2 EverywhereUpdater" }
31+
}
32+
};
33+
34+
private PeriodicTimer? _timer;
35+
private Task? _updateTask;
36+
private Asset? _latestAsset;
37+
38+
public Version CurrentVersion { get; } = typeof(SoftwareUpdater).Assembly.GetName().Version ?? new Version(0, 0, 0);
39+
40+
[ObservableProperty] public partial DateTimeOffset? LastCheckTime { get; private set; }
41+
42+
[ObservableProperty] public partial Version? LatestVersion { get; private set; }
43+
44+
public void RunAutomaticCheckInBackground(TimeSpan interval, CancellationToken cancellationToken = default)
45+
{
46+
_timer = new PeriodicTimer(interval);
47+
cancellationToken.Register(Stop);
48+
49+
Task.Run(
50+
async () =>
51+
{
52+
await CheckForUpdatesAsync(cancellationToken); // check immediately on start
53+
54+
while (await _timer.WaitForNextTickAsync(cancellationToken))
55+
{
56+
await CheckForUpdatesAsync(cancellationToken);
57+
}
58+
},
59+
cancellationToken);
60+
61+
void Stop()
62+
{
63+
DisposeCollector.DisposeToDefault(ref _timer);
64+
}
65+
}
66+
67+
public async Task CheckForUpdatesAsync(CancellationToken cancellationToken = default)
68+
{
69+
try
70+
{
71+
if (_updateTask is not null) return;
72+
73+
var response = await GetResponseAsync(GitHubApiUrl);
74+
var jsonDoc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
75+
var root = jsonDoc.RootElement;
76+
77+
var latestTag = root.GetProperty("tag_name").GetString();
78+
if (latestTag is null) return;
79+
80+
var versionString = latestTag.StartsWith('v') ? latestTag[1..] : latestTag;
81+
if (!Version.TryParse(versionString, out var latestVersion))
82+
{
83+
logger.LogWarning("Could not parse version from tag: {Tag}", latestTag);
84+
return;
85+
}
86+
87+
var assets = root.GetProperty("assets").Deserialize<List<Asset>>();
88+
var isInstalled = nativeHelper.IsInstalled;
89+
_latestAsset = assets?.FirstOrDefault(
90+
a => isInstalled ?
91+
a.Name.EndsWith($"-Windows-x64-Setup-v{versionString}.exe", StringComparison.OrdinalIgnoreCase) :
92+
a.Name.EndsWith($"-Windows-x64-v{versionString}.zip", StringComparison.OrdinalIgnoreCase));
93+
94+
LatestVersion = latestVersion;
95+
}
96+
catch (Exception ex)
97+
{
98+
logger.LogWarning(ex, "Failed to check for updates.");
99+
LatestVersion = null;
100+
}
101+
102+
LastCheckTime = DateTimeOffset.UtcNow;
103+
}
104+
105+
public async Task PerformUpdateAsync(IProgress<double> progress)
106+
{
107+
if (_updateTask is not null)
108+
{
109+
await _updateTask;
110+
return;
111+
}
112+
113+
if (LatestVersion is null || _latestAsset is not { } asset)
114+
{
115+
logger.LogInformation("No new version available to update.");
116+
return;
117+
}
118+
119+
_updateTask = Task.Run(async () =>
120+
{
121+
try
122+
{
123+
var assetPath = await DownloadAssetAsync(asset, progress);
124+
125+
if (assetPath.EndsWith(".exe"))
126+
{
127+
UpdateViaInstaller(assetPath);
128+
}
129+
else
130+
{
131+
await UpdateViaPortableAsync(assetPath);
132+
}
133+
}
134+
finally
135+
{
136+
_updateTask = null;
137+
}
138+
});
139+
140+
await _updateTask;
141+
}
142+
143+
private async Task<string> DownloadAssetAsync(Asset asset, IProgress<double> progress)
144+
{
145+
var installPath = Path.Combine(runtimeConstantProvider.Get<string>(RuntimeConstantType.WritableDataPath), "updates");
146+
Directory.CreateDirectory(installPath);
147+
var assetDownloadPath = Path.Combine(installPath, asset.Name);
148+
149+
var fileInfo = new FileInfo(assetDownloadPath);
150+
if (fileInfo.Exists)
151+
{
152+
if (fileInfo.Length == asset.Size && string.Equals(await HashFileAsync(), asset.Digest, StringComparison.OrdinalIgnoreCase))
153+
{
154+
logger.LogInformation("Asset {AssetName} already exists and is valid, skipping download.", asset.Name);
155+
progress.Report(1.0);
156+
return assetDownloadPath;
157+
}
158+
159+
logger.LogInformation("Asset {AssetName} exists but is invalid, redownloading.", asset.Name);
160+
}
161+
162+
var response = await GetResponseAsync(asset.DownloadUrl);
163+
await using var fs = new FileStream(assetDownloadPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
164+
165+
var totalBytes = response.Content.Headers.ContentLength ?? asset.Size;
166+
await using var contentStream = await response.Content.ReadAsStreamAsync();
167+
var totalBytesRead = 0L;
168+
var buffer = new byte[81920];
169+
int bytesRead;
170+
171+
while ((bytesRead = await contentStream.ReadAsync(buffer)) > 0)
172+
{
173+
await fs.WriteAsync(buffer.AsMemory(0, bytesRead));
174+
totalBytesRead += bytesRead;
175+
progress.Report((double)totalBytesRead / totalBytes);
176+
}
177+
178+
fs.Position = 0;
179+
if (!string.Equals("sha256:" + Convert.ToHexString(await SHA256.HashDataAsync(fs)), asset.Digest, StringComparison.OrdinalIgnoreCase))
180+
{
181+
throw new InvalidOperationException($"Downloaded asset {asset.Name} hash does not match expected digest.");
182+
}
183+
184+
return assetDownloadPath;
185+
186+
async Task<string> HashFileAsync()
187+
{
188+
await using var fileStream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.Read);
189+
var sha256 = await SHA256.HashDataAsync(fileStream);
190+
return "sha256:" + Convert.ToHexString(sha256);
191+
}
192+
}
193+
194+
private static void UpdateViaInstaller(string installerPath)
195+
{
196+
Process.Start(new ProcessStartInfo(installerPath) { UseShellExecute = true });
197+
Environment.Exit(0);
198+
}
199+
200+
private async static Task UpdateViaPortableAsync(string zipPath)
201+
{
202+
var scriptPath = Path.Combine(Path.GetTempPath(), "update.bat");
203+
var exeLocation = Assembly.GetExecutingAssembly().Location;
204+
var currentDir = Path.GetDirectoryName(exeLocation)!;
205+
206+
var scriptContent =
207+
$"""
208+
@echo off
209+
ECHO Waiting for the application to close...
210+
TASKKILL /IM "{Path.GetFileName(exeLocation)}" /F >nul 2>nul
211+
timeout /t 2 /nobreak >nul
212+
ECHO Backing up old version...
213+
ren "{currentDir}" "{Path.GetFileName(currentDir)}_old"
214+
ECHO Unpacking new version...
215+
powershell -Command "Expand-Archive -LiteralPath '{zipPath}' -DestinationPath '{currentDir}' -Force"
216+
IF %ERRORLEVEL% NEQ 0 (
217+
ECHO Unpacking failed, restoring old version...
218+
ren "{Path.Combine(Path.GetDirectoryName(currentDir)!, Path.GetFileName(currentDir) + "_old")}" "{Path.GetFileName(currentDir)}"
219+
GOTO END
220+
)
221+
ECHO Cleaning up old files...
222+
rd /s /q "{Path.Combine(Path.GetDirectoryName(currentDir)!, Path.GetFileName(currentDir) + "_old")}"
223+
ECHO Starting new version...
224+
start "" "{exeLocation}"
225+
:END
226+
del "{scriptPath}"
227+
""";
228+
229+
await File.WriteAllTextAsync(scriptPath, scriptContent);
230+
231+
Process.Start(new ProcessStartInfo(scriptPath) { UseShellExecute = true, Verb = "runas" });
232+
Environment.Exit(0);
233+
}
234+
235+
private async Task<HttpResponseMessage> GetResponseAsync(string url)
236+
{
237+
ObjectDisposedException.ThrowIf(_httpClient is null, this);
238+
239+
try
240+
{
241+
return await GetResponseImplAsync(url);
242+
}
243+
catch (Exception ex1)
244+
{
245+
logger.LogWarning(ex1, "Direct request failed, trying GitHub proxies.");
246+
foreach (var proxy in GitHubProxies)
247+
{
248+
try
249+
{
250+
return await GetResponseImplAsync(proxy + url);
251+
}
252+
catch (Exception ex2)
253+
{
254+
logger.LogWarning(ex2, "Request via proxy {Proxy} failed.", proxy);
255+
}
256+
}
257+
}
258+
259+
throw new Exception("All attempts to get a valid response failed.");
260+
261+
async Task<HttpResponseMessage> GetResponseImplAsync(string actualUrl)
262+
{
263+
var response = await _httpClient.GetAsync(actualUrl, HttpCompletionOption.ResponseHeadersRead);
264+
response.EnsureSuccessStatusCode();
265+
return response;
266+
}
267+
}
268+
269+
[Serializable]
270+
private record Asset(
271+
[property: JsonPropertyName("name")] string Name,
272+
[property: JsonPropertyName("digest")] string Digest,
273+
[property: JsonPropertyName("size")] long Size,
274+
[property: JsonPropertyName("browser_download_url")] string DownloadUrl);
275+
}

0 commit comments

Comments
 (0)