Editor/MotionBricks/ModelInstaller.cs

Editor utility that downloads and installs a pinned model package into the ModelStore directory. It acquires a lock file to prevent concurrent installs, resumes partial downloads with HTTP range requests, verifies file size and SHA-256, and moves validated files into place while reporting progress.

NetworkingFile AccessHttp Calls
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Diagnostics;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;

namespace HumanoidMocap.EditorTools.MotionBricks;

/// <summary>
/// Downloads a pinned <see cref="ModelPackage"/> into its <see cref="ModelStore"/> folder. Only
/// ever runs when the user asks for it. Each file streams into a <c>.part</c> file (resumed with
/// an HTTP range request after an interruption), is checked against its pinned size and SHA-256,
/// and only then moved into place, so a crash or a bad network never leaves a file that looks
/// installed. A lock file keeps two editors from installing into the same folder at once.
/// </summary>
public sealed class ModelInstaller
{
    private static readonly HttpClient Http = CreateClient();
    private static readonly TimeSpan StallTimeout = TimeSpan.FromSeconds( 45 );
    private const int Attempts = 4;

    private readonly ModelStore _store;

    public ModelInstaller( ModelStore store )
    {
        _store = store;
    }

    private static HttpClient CreateClient()
    {
        var client = new HttpClient( new HttpClientHandler { AllowAutoRedirect = true, AutomaticDecompression = DecompressionMethods.None } )
        {
            Timeout = Timeout.InfiniteTimeSpan,
        };
        client.DefaultRequestHeaders.UserAgent.ParseAdd( "sbox-humanoid-mocap/1.0" );
        return client;
    }

    /// <summary>Downloads every missing or damaged file. Throws <see cref="ModelInstallException"/> with a readable message.</summary>
    public async Task InstallAsync( IProgress<InstallProgress>? progress = null, CancellationToken cancel = default )
    {
        Directory.CreateDirectory( _store.Directory );
        using var installLock = await AcquireLockAsync( progress, cancel );
        var package = _store.Package;
        var total = package.TotalBytes;
        long done = 0;
        var watch = Stopwatch.StartNew();
        long bytesAtStart = -1;

        void Report( long fileBytes, string name, string phase )
        {
            var current = done + fileBytes;
            if ( bytesAtStart < 0 )
                bytesAtStart = current;
            var seconds = watch.Elapsed.TotalSeconds;
            var speed = seconds > 0.5 ? (current - bytesAtStart) / seconds : 0;
            progress?.Report( new InstallProgress( current, total, name, speed, phase ) );
        }

        foreach ( var file in package.Files )
        {
            cancel.ThrowIfCancellationRequested();
            var path = _store.PathOf( file );
            if ( File.Exists( path ) && new FileInfo( path ).Length == file.Size )
            {
                Report( 0, file.LocalName, "Checking" );
                var existing = await ModelStore.HashAsync( path, b => Report( b, file.LocalName, "Checking" ), cancel );
                if ( existing == file.Sha256 )
                {
                    _store.Stamp( file );
                    done += file.Size;
                    continue;
                }
            }
            if ( File.Exists( path ) )
                File.Delete( path );

            await DownloadWithRetriesAsync( file, b => Report( b, file.LocalName, "Downloading" ), cancel );
            _store.Stamp( file );
            done += file.Size;
        }
        progress?.Report( new InstallProgress( total, total, "", 0, "Installed" ) );
    }

    private async Task DownloadWithRetriesAsync( ModelFile file, Action<long> report, CancellationToken cancel )
    {
        Exception? last = null;
        for ( var attempt = 1; attempt <= Attempts; attempt++ )
        {
            try
            {
                await DownloadAsync( file, report, cancel );
                return;
            }
            catch ( OperationCanceledException ) when ( cancel.IsCancellationRequested )
            {
                throw;
            }
            catch ( Exception e ) when ( e is HttpRequestException or IOException or OperationCanceledException or ModelInstallException )
            {
                last = e;
                if ( attempt < Attempts )
                    await Task.Delay( TimeSpan.FromSeconds( 2 * attempt ), cancel );
            }
        }
        throw new ModelInstallException( Friendly( last ), last );
    }

    private async Task DownloadAsync( ModelFile file, Action<long> report, CancellationToken cancel )
    {
        var part = _store.PartialPathOf( file );
        long existing = File.Exists( part ) ? new FileInfo( part ).Length : 0;
        if ( existing > file.Size )
        {
            File.Delete( part );
            existing = 0;
        }

        using var request = new HttpRequestMessage( HttpMethod.Get, _store.Package.UrlOf( file ) );
        if ( existing > 0 && existing < file.Size )
            request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue( existing, null );

        using var stall = CancellationTokenSource.CreateLinkedTokenSource( cancel );
        stall.CancelAfter( StallTimeout );
        using var response = await Http.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, stall.Token );
        if ( response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable )
        {
            File.Delete( part );
            throw new ModelInstallException( "The download restarted." );
        }
        response.EnsureSuccessStatusCode();
        var resumed = existing > 0 && response.StatusCode == HttpStatusCode.PartialContent;
        if ( !resumed )
            existing = 0;

        using var sha = IncrementalHash.CreateHash( HashAlgorithmName.SHA256 );
        await using ( var output = new FileStream( part, resumed ? FileMode.Open : FileMode.Create, FileAccess.ReadWrite, FileShare.None, 1 << 20, useAsync: true ) )
        {
            var buffer = new byte[1 << 20];
            if ( resumed )
            {
                // Re-hash what is already on disk so the final checksum covers the whole file.
                int read;
                while ( output.Position < existing && (read = await output.ReadAsync( buffer.AsMemory( 0, (int)Math.Min( buffer.Length, existing - output.Position ) ), cancel )) > 0 )
                    sha.AppendData( buffer, 0, read );
                output.SetLength( existing );
            }

            await using var input = await response.Content.ReadAsStreamAsync( cancel );
            var written = existing;
            report( written );
            while ( true )
            {
                stall.CancelAfter( StallTimeout );
                var read = await input.ReadAsync( buffer, stall.Token );
                if ( read <= 0 )
                    break;
                if ( written + read > file.Size )
                    throw new ModelInstallException( $"{file.LocalName} is larger than expected." );
                await output.WriteAsync( buffer.AsMemory( 0, read ), cancel );
                sha.AppendData( buffer, 0, read );
                written += read;
                report( written );
            }
            await output.FlushAsync( cancel );
            if ( written != file.Size )
                throw new ModelInstallException( $"The download of {file.LocalName} ended early." );
        }

        var hash = Convert.ToHexString( sha.GetHashAndReset() ).ToLowerInvariant();
        if ( hash != file.Sha256 )
        {
            File.Delete( part );
            throw new ModelInstallException( $"{file.LocalName} failed its integrity check, so it was discarded. Try again.", new CryptographicException( "checksum mismatch" ) );
        }
        File.Move( part, _store.PathOf( file ), overwrite: true );
    }

    /// <summary>Waits while another editor installs into the same folder.</summary>
    private async Task<IDisposable> AcquireLockAsync( IProgress<InstallProgress>? progress, CancellationToken cancel )
    {
        var path = System.IO.Path.Combine( _store.Directory, "install.lock" );
        while ( true )
        {
            cancel.ThrowIfCancellationRequested();
            try
            {
                return new FileStream( path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, 1, FileOptions.DeleteOnClose );
            }
            catch ( IOException )
            {
                progress?.Report( new InstallProgress( 0, _store.Package.TotalBytes, "", 0, "Waiting for another s&box window to finish installing" ) );
                await Task.Delay( 1000, cancel );
            }
        }
    }

    private static string Friendly( Exception? e ) => e switch
    {
        HttpRequestException { StatusCode: HttpStatusCode.NotFound } => "The model files were not found on the server. The download link may have changed; update AI Animator.",
        HttpRequestException { StatusCode: { } code } => $"The server refused the download ({(int)code}). Try again later.",
        HttpRequestException => "Could not reach the download server. Check your internet connection and try again.",
        OperationCanceledException => "The download stalled. Check your internet connection and try again.",
        IOException io when io.HResult is unchecked((int)0x80070070) or unchecked((int)0x80070027) => "There is not enough free disk space for the model (about 750 MB).",
        IOException => "The model could not be written to disk. Check that the folder is writable.",
        ModelInstallException m => m.Message,
        _ => "The download failed. Try again.",
    };
}