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