@ -1,9 +1,7 @@
using System.Globalization ;
using System.Text.Json ;
using System.Text.Json ;
using Microsoft.Extensions.Logging ;
using Microsoft.Extensions.Logging ;
using Microsoft.Extensions.Options ;
using Microsoft.Extensions.Options ;
using StabilityMatrix.Core.Attributes ;
using StabilityMatrix.Core.Attributes ;
using StabilityMatrix.Core.Extensions ;
using StabilityMatrix.Core.Helper ;
using StabilityMatrix.Core.Helper ;
using StabilityMatrix.Core.Models.Configs ;
using StabilityMatrix.Core.Models.Configs ;
using StabilityMatrix.Core.Models.FileInterfaces ;
using StabilityMatrix.Core.Models.FileInterfaces ;
@ -19,11 +17,12 @@ public class UpdateHelper : IUpdateHelper
private readonly ILogger < UpdateHelper > logger ;
private readonly ILogger < UpdateHelper > logger ;
private readonly IHttpClientFactory httpClientFactory ;
private readonly IHttpClientFactory httpClientFactory ;
private readonly IDownloadService downloadService ;
private readonly IDownloadService downloadService ;
private readonly ISettingsManager settingsManager ;
private readonly DebugOptions debugOptions ;
private readonly DebugOptions debugOptions ;
private readonly System . Timers . Timer timer = new ( TimeSpan . FromMinutes ( 6 0 ) ) ;
private readonly System . Timers . Timer timer = new ( TimeSpan . FromMinutes ( 6 0 ) ) ;
private string UpdateManifestUrl = >
private string UpdateManifestUrl = >
debugOptions . UpdateManifestUrl ? ? "https://cdn.lykos.ai/update-v2 .json" ;
debugOptions . UpdateManifestUrl ? ? "https://cdn.lykos.ai/update-v3 .json" ;
public const string UpdateFolderName = ".StabilityMatrixUpdate" ;
public const string UpdateFolderName = ".StabilityMatrixUpdate" ;
public static DirectoryPath UpdateFolder = > Compat . AppCurrentDir . JoinDir ( UpdateFolderName ) ;
public static DirectoryPath UpdateFolder = > Compat . AppCurrentDir . JoinDir ( UpdateFolderName ) ;
@ -34,12 +33,14 @@ public class UpdateHelper : IUpdateHelper
ILogger < UpdateHelper > logger ,
ILogger < UpdateHelper > logger ,
IHttpClientFactory httpClientFactory ,
IHttpClientFactory httpClientFactory ,
IDownloadService downloadService ,
IDownloadService downloadService ,
IOptions < DebugOptions > debugOptions
IOptions < DebugOptions > debugOptions ,
ISettingsManager settingsManager
)
)
{
{
this . logger = logger ;
this . logger = logger ;
this . httpClientFactory = httpClientFactory ;
this . httpClientFactory = httpClientFactory ;
this . downloadService = downloadService ;
this . downloadService = downloadService ;
this . settingsManager = settingsManager ;
this . debugOptions = debugOptions . Value ;
this . debugOptions = debugOptions . Value ;
timer . Elapsed + = async ( _ , _ ) = >
timer . Elapsed + = async ( _ , _ ) = >
@ -57,45 +58,64 @@ public class UpdateHelper : IUpdateHelper
public async Task DownloadUpdate ( UpdateInfo updateInfo , IProgress < ProgressReport > progress )
public async Task DownloadUpdate ( UpdateInfo updateInfo , IProgress < ProgressReport > progress )
{
{
var downloadUrl = updateInfo . DownloadUrl ;
UpdateFolder . Create ( ) ;
UpdateFolder . Create ( ) ;
UpdateFolder . Info . Attributes | = FileAttributes . Hidden ;
UpdateFolder . Info . Attributes | = FileAttributes . Hidden ;
var downloadFile = UpdateFolder . JoinFile ( Path . GetFileName ( updateInfo . Url . ToString ( ) ) ) ;
var extractDir = UpdateFolder . JoinDir ( "extract" ) ;
try
{
// download the file from URL
// download the file from URL
await downloadService
await downloadService
. DownloadToFileAsync (
. DownloadToFileAsync (
downloadUrl ,
updateInfo . Url . ToString ( ) ,
ExecutablePath ,
downloadFile ,
progress : progress ,
progress : progress ,
httpClientName : "UpdateClient"
httpClientName : "UpdateClient"
)
)
. ConfigureAwait ( false ) ;
. ConfigureAwait ( false ) ;
}
/// <summary>
// Unzip if needed
/// Format a DatetimeOffset to a culture invariant string for use in signature verification.
if ( downloadFile . Extension = = ".zip" )
/// </summary>
private static string FormatDateTimeOffsetInvariant ( DateTimeOffset dateTimeOffset )
{
{
return dateTimeOffset . ToString (
if ( extractDir . Exists )
@"yyyy-MM-ddTHH\:mm\:ss.ffffffzzz" ,
{
CultureInfo . InvariantCulture
await extractDir . DeleteAsync ( true ) . ConfigureAwait ( false ) ;
}
extractDir . Create ( ) ;
progress . Report (
new ProgressReport ( - 1 , isIndeterminate : true , type : ProgressType . Extract )
) ;
) ;
await ArchiveHelper . Extract ( downloadFile , extractDir ) . ConfigureAwait ( false ) ;
// Find binary and move it up to the root
var binaryFile = extractDir
. EnumerateFiles ( "*.*" , SearchOption . AllDirectories )
. First ( f = > f . Extension . ToLowerInvariant ( ) is ".exe" or ".appimage" ) ;
await binaryFile . MoveToAsync ( ExecutablePath ) . ConfigureAwait ( false ) ;
}
// Otherwise just rename
else
{
downloadFile . Rename ( ExecutablePath . Name ) ;
}
}
/// <summary>
progress . Report ( new ProgressReport ( 1d ) ) ;
/// Data for use in signature verification.
}
/// Semicolon separated string of fields:
finally
/// "version, releaseDate, channel, type, url, changelog, hashBlake3"
{
/// </summary>
// Clean up original download
private static string GetUpdateInfoSignedData ( UpdateInfo updateInfo )
await downloadFile . DeleteAsync ( ) . ConfigureAwait ( false ) ;
// Clean up extract dir
if ( extractDir . Exists )
{
{
var channel = updateInfo . Channel . GetStringValue ( ) . ToLowerInvariant ( ) ;
await extractDir . DeleteAsync ( true ) . ConfigureAwait ( false ) ;
var date = FormatDateTimeOffsetInvariant ( updateInfo . ReleaseDate ) ;
}
return $"{updateInfo.Version};{date};{channel};"
}
+ $"{(int)updateInfo.Type};{updateInfo.DownloadUrl};{updateInfo.ChangelogUrl};"
+ $"{updateInfo.HashBlake3}" ;
}
}
private async Task CheckForUpdate ( )
private async Task CheckForUpdate ( )
@ -114,85 +134,94 @@ public class UpdateHelper : IUpdateHelper
return ;
return ;
}
}
var updateCollection = await JsonSerializer
var updateManifest = await JsonSerializer
. DeserializeAsync < UpdateCollection > (
. DeserializeAsync < UpdateManifest > (
await response . Content . ReadAsStreamAsync ( ) . ConfigureAwait ( false )
await response . Content . ReadAsStreamAsync ( ) . ConfigureAwait ( false ) ,
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy . CamelCase }
)
)
. ConfigureAwait ( false ) ;
. ConfigureAwait ( false ) ;
if ( updateCollection is null )
if ( updateManifest is null )
{
{
logger . LogError ( "UpdateCollection is null" ) ;
logger . LogError ( "UpdateManifest is null" ) ;
return ;
return ;
}
}
// Get the update info for our platform
foreach (
var updateInfo = updateCollection switch
var channel in Enum . GetValues ( typeof ( UpdateChannel ) )
. Cast < UpdateChannel > ( )
. Where ( c = > c > UpdateChannel . Unknown )
)
{
{
_ when Compat . IsWindows & & Compat . IsX64 = > updateCollection . WindowsX64 ,
if (
_ when Compat . IsLinux & & Compat . IsX64 = > updateCollection . LinuxX64 ,
updateManifest . Updates . TryGetValue ( channel , out var platforms )
_ = > null
& & platforms . GetInfoForCurrentPlatform ( ) is { } update
} ;
& & ValidateUpdate ( update )
)
if ( updateInfo is null )
{
{
logger . LogWarning (
NotifyUpdateAvailable ( update ) ;
"Could not find compatible update info for the platform {Platform}" ,
Compat . Platform
) ;
return ;
return ;
}
}
}
logger . LogInformation ( "UpdateInfo signature: {Signature}" , updateInfo . Signature ) ;
logger . LogInformation ( "No update available" ) ;
}
catch ( Exception e )
{
logger . LogError ( e , "Couldn't check for update" ) ;
}
}
var updateInfoSignData = GetUpdateInfoSignedData ( updateInfo ) ;
private bool ValidateUpdate ( UpdateInfo ? update )
logger . LogInformation ( "UpdateInfo signed data: {SignData}" , updateInfoSignData ) ;
{
if ( update is null )
return false ;
// Verify signature
// Verify signature
var checker = new SignatureChecker ( ) ;
var checker = new SignatureChecker ( ) ;
if ( ! checker . Verify ( updateInfoSignData , updateInfo . Signature ) )
var signedData = update . GetSignedData ( ) ;
if ( ! checker . Verify ( signedData , update . Signature ) )
{
{
logger . LogError ( "UpdateInfo signature is invalid: {Info}" , updateInfo ) ;
logger . LogError (
return ;
"UpdateInfo signature {Signature} is invalid, Data = {Data}, UpdateInfo = {Info}" ,
update . Signature ,
signedData ,
update
) ;
return false ;
}
}
logger . LogInformation ( "UpdateInfo signature verified" ) ;
var order = updateInfo . Version . ComparePrecedenceTo ( Compat . AppVersion ) ;
switch ( update . Version . ComparePrecedenceTo ( Compat . AppVersion ) )
if ( order > 0 )
{
{
case > 0 :
// Newer version available
// Newer version available
logger . LogInformation (
return true ;
"Update available {AppVer} -> {UpdateVer}" ,
case 0 :
Compat . AppVersion ,
updateInfo . Version
) ;
EventManager . Instance . OnUpdateAvailable ( updateInfo ) ;
return ;
}
if ( order = = 0 )
{
{
// Same version available, check if we both have commit hash metadata
// Same version available, check if we both have commit hash metadata
var updateHash = updateInfo . Version . Metadata ;
var updateHash = update . Version . Metadata ;
var appHash = Compat . AppVersion . Metadata ;
var appHash = Compat . AppVersion . Metadata ;
// If different, we can update
// If different, we can update
if ( updateHash ! = appHash )
if ( updateHash ! = appHash )
{
{
logger . LogInformation (
return true ;
"Update available {AppVer} -> {UpdateVer}" ,
}
Compat . AppVersion ,
updateInfo . Version
break ;
) ;
EventManager . Instance . OnUpdateAvailable ( updateInfo ) ;
return ;
}
}
}
}
logger . LogInformation ( "No update available" ) ;
return false ;
}
}
catch ( Exception e )
private void NotifyUpdateAvailable ( UpdateInfo update )
{
{
logger . LogError ( e , "Couldn't check for update" ) ;
logger . LogInformation (
}
"Update available {AppVer} -> {UpdateVer}" ,
Compat . AppVersion ,
update . Version
) ;
EventManager . Instance . OnUpdateAvailable ( update ) ;
}
}
}
}