Editor/Prism/Toolchain/SlangToolchain.cs

Editor-side utility that finds, validates and can download/install the Slang command-line compiler (slangc). It probes overrides, cached cookie, vendored project folder, engine folders, PATH and environment vars, runs slangc -v to get a version, and can download the latest Windows release from GitHub and extract required binaries into the project Tools/slang/bin folder.

NetworkingFile AccessProcess ExecutionHttp Calls
🌐 https://api.github.com/repos/shader-slang/slang/releases/latest
using Editor.Prism.Core;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;

namespace Editor.Prism.Toolchain;

/// <summary>Where a located <c>slangc</c> came from. Shown in Preferences so the choice is never a mystery.</summary>
public enum SlangToolchainSource
{
	/// <summary>Nothing was found.</summary>
	None,

	/// <summary>An explicit path the user set in Preferences.</summary>
	Override,

	/// <summary>Vendored in the project under <c>Tools/slang/bin</c>. The recommended location.</summary>
	Vendored,

	/// <summary>Shipped beside the engine, if Facepunch ever starts shipping it.</summary>
	Engine,

	/// <summary>Found on <c>PATH</c>.</summary>
	Path,

	/// <summary>Pointed at by an environment variable.</summary>
	Environment,

	/// <summary>Found by scanning the usual places.</summary>
	Discovered,

	/// <summary>Just installed by <see cref="SlangToolchain.Install"/>.</summary>
	Installed
}

/// <summary>A located Slang toolchain.</summary>
public sealed record SlangToolchainInfo( string ExecutablePath, string Version, SlangToolchainSource Source )
{
	/// <summary>The "there is no toolchain" value. Never null, so call sites never branch on null.</summary>
	public static readonly SlangToolchainInfo None = new( null, null, SlangToolchainSource.None );

	/// <summary>True when a working executable was located and it answered a version query.</summary>
	public bool IsAvailable => !string.IsNullOrWhiteSpace( ExecutablePath );

	/// <summary>The folder holding the executable, or null.</summary>
	public string Directory =>
		string.IsNullOrWhiteSpace( ExecutablePath ) ? null : System.IO.Path.GetDirectoryName( ExecutablePath );

	/// <summary>A one-line description for the status chip and the preferences page.</summary>
	public string Describe() => IsAvailable
		? $"slangc {Version ?? "?"} ({Source.ToString().ToLowerInvariant()})"
		: "slangc not found";

	/// <inheritdoc/>
	public override string ToString() => Describe();
}

/// <summary>Progress of a toolchain download, reported on whichever thread the work is running on.</summary>
/// <param name="Stage">What is happening right now, e.g. <c>Downloading</c>.</param>
/// <param name="Fraction">0..1 where known, negative when indeterminate.</param>
/// <param name="BytesReceived">Bytes transferred so far.</param>
/// <param name="BytesTotal">Total bytes, or 0 when the server did not say.</param>
public readonly record struct SlangInstallProgress( string Stage, float Fraction, long BytesReceived, long BytesTotal )
{
	/// <summary>A human-readable one-liner for a progress dialog.</summary>
	public string Describe() => BytesTotal > 0
		? $"{Stage} — {BytesReceived / 1048576.0:0.0} / {BytesTotal / 1048576.0:0.0} MB"
		: Stage;

	/// <inheritdoc/>
	public override string ToString() => Describe();
}

/// <summary>The outcome of an install attempt. Failure is a value, never an exception.</summary>
public sealed record SlangInstallResult( bool Ok, string Message, SlangToolchainInfo Toolchain )
{
	/// <summary>True when the user cancelled rather than anything going wrong.</summary>
	public bool Cancelled { get; init; }

	/// <inheritdoc/>
	public override string ToString() => Ok ? $"installed: {Toolchain}" : $"failed: {Message}";
}

/// <summary>
/// Finds, verifies and installs the <c>slangc</c> command-line compiler.
/// <para>
/// Slang validation is Tier 2 and never gates rendering, so every path through this class degrades
/// quietly: a missing toolchain is a value, not an exception, and a user who never installs Slang has a
/// fully working shader editor. Slang <em>emission</em> is pure text generation from the IR and does
/// not depend on any of this.
/// </para>
/// <para>
/// The probe order is: an explicit override, the cached answer, the project's vendored copy, the engine
/// folder, <c>PATH</c>, a handful of environment variables, then a bounded scan of the usual places.
/// The winner is cached in <c>EditorCookie</c> so the scan happens at most once per machine.
/// </para>
/// </summary>
public static class SlangToolchain
{
	/// <summary>The GitHub endpoint that names the newest release without hardcoding a version.</summary>
	public const string ReleasesApi = "https://api.github.com/repos/shader-slang/slang/releases/latest";

	/// <summary>GitHub rejects API requests with no user agent.</summary>
	const string UserAgent = "Prism-ShaderEditor";

	/// <summary>How long a <c>slangc -v</c> probe may take before we assume the binary is broken.</summary>
	const int VersionTimeoutMs = 8000;

	/// <summary>Upper bound on the discovery scan, so a pathological directory tree cannot hang the editor.</summary>
	const int ScanBudgetMs = 2500;

	/// <summary>The executable name, including its extension on Windows.</summary>
	public static string ExecutableName => OperatingSystem.IsWindows() ? "slangc.exe" : "slangc";

	static readonly object s_lock = new();

	static SlangToolchainInfo s_current;
	static bool s_probed;

	/// <summary>
	/// Files without which <c>slangc.exe</c> will not even start. Extracting only these keeps the
	/// install around 32 MB instead of the ZIP's 117.
	/// </summary>
	public static IReadOnlyList<string> RequiredFiles { get; } = new[]
	{
		"slangc.exe", "slang.dll", "slang-compiler.dll", "slang-glslang.dll"
	};

	/// <summary>Files worth having but not required: the language server and the GLSL front end.</summary>
	public static IReadOnlyList<string> OptionalFiles { get; } = new[]
	{
		"slangd.exe", "slang-glsl-module.dll"
	};

	/// <summary>Raised whenever the located toolchain changes, so the status chip can refresh.</summary>
	public static event Action Changed;

	/// <summary>The toolchain in use. Probes on first access; never null.</summary>
	public static SlangToolchainInfo Current
	{
		get
		{
			lock ( s_lock )
			{
				if ( s_probed && s_current is not null ) return s_current;
			}

			return Probe();
		}
	}

	/// <summary>True when a working <c>slangc</c> was located.</summary>
	public static bool IsAvailable => Current.IsAvailable;

	/// <summary>Absolute path of the located <c>slangc</c>, or null.</summary>
	public static string ExecutablePath => Current.ExecutablePath;

	/// <summary>Version string reported by the located <c>slangc</c>, or null.</summary>
	public static string Version => Current.Version;

	/// <summary>
	/// The user's explicit override, persisted in <c>EditorCookie</c>. Setting it re-probes immediately;
	/// setting it to null or empty clears it.
	/// </summary>
	public static string Override
	{
		get => PrismLog.Guard( "Reading the Slang override",
			() => EditorCookie.GetString( PrismConstants.CookieSlangPathOverride, null ), null );

		set
		{
			PrismLog.Guard( "Writing the Slang override", () =>
				EditorCookie.SetString( PrismConstants.CookieSlangPathOverride, value ?? string.Empty ) );

			Probe( true );
		}
	}

	/// <summary>Where <see cref="Install"/> puts the binaries: <c>&lt;project&gt;/Tools/slang/bin</c>.</summary>
	public static string InstallDirectory => PrismLog.Guard( "Resolving the Slang install directory", () =>
	{
		var root = Project.Current?.GetRootPath();

		if ( string.IsNullOrWhiteSpace( root ) ) return null;

		return Path.Combine( root, PrismConstants.SlangToolchainDir.Replace( '/', Path.DirectorySeparatorChar ) );
	}, null );

	// ---- probing ---------------------------------------------------------

	/// <summary>
	/// Locate a toolchain. Cheap and idempotent after the first call; pass <paramref name="force"/> to
	/// ignore both the in-memory result and the cookie. Never throws.
	/// </summary>
	public static SlangToolchainInfo Probe( bool force = false )
	{
		lock ( s_lock )
		{
			if ( !force && s_probed && s_current is not null ) return s_current;
		}

		var found = PrismLog.Guard( "Probing for slangc", () => Locate( force ), SlangToolchainInfo.None )
			?? SlangToolchainInfo.None;

		bool changed;

		lock ( s_lock )
		{
			changed = s_current is null || s_current != found;
			s_current = found;
			s_probed = true;
		}

		Remember( found );

		if ( changed )
		{
			PrismLog.Trace( $"Slang toolchain: {found.Describe()}" );
			PrismLog.Guard( "Raising SlangToolchain.Changed", () => Changed?.Invoke() );
		}

		return found;
	}

	/// <summary>Probe off the UI thread. The scan and the version query both spawn processes.</summary>
	public static Task<SlangToolchainInfo> ProbeAsync( bool force = false, CancellationToken ct = default ) =>
		Task.Run( () => Probe( force ), ct );

	/// <summary>Forget everything we know, including the cookie. The next probe starts from scratch.</summary>
	public static void Forget()
	{
		lock ( s_lock )
		{
			s_current = null;
			s_probed = false;
		}

		PrismLog.Guard( "Clearing the Slang cookie", () =>
		{
			EditorCookie.SetString( PrismConstants.CookieSlangPath, string.Empty );
			EditorCookie.SetString( PrismConstants.CookieSlangVersion, string.Empty );
		} );
	}

	/// <summary>
	/// The validator matching the current toolchain: a real <see cref="SlangcValidator"/> when one was
	/// found, the honest no-op otherwise. Call sites never branch on availability themselves.
	/// </summary>
	public static ISlangValidator CreateValidator()
	{
		var toolchain = Current;

		if ( !toolchain.IsAvailable ) return NullSlangValidator.Instance;

		return new SlangcValidator( toolchain.ExecutablePath, toolchain.Version );
	}

	static SlangToolchainInfo Locate( bool force )
	{
		// 1. an explicit override always wins, even if it is broken — the user asked for it.
		var over = Override;

		if ( Usable( over, out var resolved ) && TryGetVersion( resolved, out var overrideVersion ) )
		{
			return new SlangToolchainInfo( resolved, overrideVersion, SlangToolchainSource.Override );
		}

		// 2. what we found last time, if it is still there.
		if ( !force )
		{
			var cached = PrismLog.Guard( "Reading the Slang cookie",
				() => EditorCookie.GetString( PrismConstants.CookieSlangPath, null ), null );

			if ( Usable( cached, out var cachedPath ) )
			{
				var cachedVersion = PrismLog.Guard( "Reading the Slang version cookie",
					() => EditorCookie.GetString( PrismConstants.CookieSlangVersion, null ), null );

				if ( string.IsNullOrWhiteSpace( cachedVersion ) && !TryGetVersion( cachedPath, out cachedVersion ) )
				{
					cachedVersion = null;
				}

				if ( !string.IsNullOrWhiteSpace( cachedVersion ) )
				{
					return new SlangToolchainInfo( cachedPath, cachedVersion, SlangToolchainSource.Discovered );
				}
			}
		}

		foreach ( var (path, source) in Candidates() )
		{
			if ( !Usable( path, out var candidate ) ) continue;
			if ( !TryGetVersion( candidate, out var version ) ) continue;

			return new SlangToolchainInfo( candidate, version, source );
		}

		return SlangToolchainInfo.None;
	}

	/// <summary>Every place worth looking, in priority order, cheapest first.</summary>
	static IEnumerable<(string Path, SlangToolchainSource Source)> Candidates()
	{
		var install = InstallDirectory;

		if ( !string.IsNullOrWhiteSpace( install ) )
		{
			yield return (Path.Combine( install, ExecutableName ), SlangToolchainSource.Vendored);
		}

		// Beside the engine. It does not ship slangc today, but it ships slang.dll, so it might.
		foreach ( var engine in EngineDirectories() )
		{
			yield return (Path.Combine( engine, ExecutableName ), SlangToolchainSource.Engine);
		}

		foreach ( var variable in new[] { "PRISM_SLANGC", "SLANGC", "SLANG_ROOT", "SLANG_DIR", "SLANG_HOME" } )
		{
			var value = SafeEnvironment( variable );

			if ( string.IsNullOrWhiteSpace( value ) ) continue;

			yield return (value, SlangToolchainSource.Environment);
			yield return (Path.Combine( value, ExecutableName ), SlangToolchainSource.Environment);
			yield return (Path.Combine( value, "bin", ExecutableName ), SlangToolchainSource.Environment);
		}

		foreach ( var entry in PathEntries() )
		{
			yield return (Path.Combine( entry, ExecutableName ), SlangToolchainSource.Path);
		}

		foreach ( var found in Discover() )
		{
			yield return (found, SlangToolchainSource.Discovered);
		}
	}

	/// <summary>Folders the engine keeps native tooling in.</summary>
	static IEnumerable<string> EngineDirectories()
	{
		var root = PrismLog.Guard( "Resolving the engine folder",
			() => Editor.FileSystem.Root?.GetFullPath( "/" ), null );

		if ( string.IsNullOrWhiteSpace( root ) ) yield break;

		yield return Path.Combine( root, "bin", "win64" );
		yield return Path.Combine( root, "bin", "managed" );
	}

	static IEnumerable<string> PathEntries()
	{
		var path = SafeEnvironment( "PATH" );

		if ( string.IsNullOrWhiteSpace( path ) ) yield break;

		foreach ( var entry in path.Split( Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries ) )
		{
			var trimmed = entry.Trim().Trim( '"' );

			if ( trimmed.Length == 0 ) continue;

			yield return trimmed;
		}
	}

	/// <summary>
	/// A bounded scan of the places a Slang release typically gets unpacked to. Depth and wall time are
	/// both capped, and the result is cached in a cookie, so this runs at most once and never hangs.
	/// </summary>
	static IEnumerable<string> Discover()
	{
		var roots = new List<string>();

		void Add( string root, params string[] parts )
		{
			if ( string.IsNullOrWhiteSpace( root ) ) return;

			var combined = parts is { Length: > 0 } ? Path.Combine( new[] { root }.Concat( parts ).ToArray() ) : root;

			if ( !roots.Contains( combined, StringComparer.OrdinalIgnoreCase ) ) roots.Add( combined );
		}

		Add( Project.Current?.GetRootPath(), "Tools" );
		Add( SafeEnvironment( "LOCALAPPDATA" ), "Prism" );
		Add( SafeEnvironment( "LOCALAPPDATA" ), "slang" );
		Add( SafeEnvironment( "USERPROFILE" ), "scoop", "apps", "slang" );
		Add( SafeEnvironment( "USERPROFILE" ), ".slang" );
		Add( SafeEnvironment( "ProgramFiles" ), "slang" );
		Add( Path.GetTempPath() );

		var deadline = Stopwatch.StartNew();
		var results = new List<string>();

		foreach ( var root in roots )
		{
			if ( deadline.ElapsedMilliseconds > ScanBudgetMs ) break;

			PrismLog.Guard( $"Scanning {root} for slangc", () =>
			{
				if ( !Directory.Exists( root ) ) return;

				var options = new EnumerationOptions
				{
					RecurseSubdirectories = true,
					IgnoreInaccessible = true,
					MaxRecursionDepth = 8,
					AttributesToSkip = FileAttributes.ReparsePoint
				};

				foreach ( var file in Directory.EnumerateFiles( root, ExecutableName, options ) )
				{
					results.Add( file );

					if ( results.Count >= 8 ) break;
					if ( deadline.ElapsedMilliseconds > ScanBudgetMs ) break;
				}
			} );
		}

		return results;
	}

	static string SafeEnvironment( string name ) =>
		PrismLog.Guard( $"Reading %{name}%", () => Environment.GetEnvironmentVariable( name ), null );

	/// <summary>True when the path names a file that exists; resolves to a full path on the way out.</summary>
	static bool Usable( string path, out string full )
	{
		full = Resolve( path );

		return full is not null;
	}

	/// <summary>The absolute path of an existing candidate, or null. A folder resolves to the exe inside it.</summary>
	static string Resolve( string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) ) return null;

		return PrismLog.Guard( "Checking a slangc candidate", () =>
		{
			var candidate = path.Trim().Trim( '"' );

			if ( Directory.Exists( candidate ) ) candidate = Path.Combine( candidate, ExecutableName );

			return File.Exists( candidate ) ? Path.GetFullPath( candidate ) : null;
		}, null );
	}

	// ---- version ---------------------------------------------------------

	static readonly Regex s_version = new( @"\d+\.\d+(\.\d+)*", RegexOptions.Compiled | RegexOptions.CultureInvariant );

	/// <summary>
	/// Run <c>slangc -v</c> and read the version back. This is also the "does this binary actually work"
	/// test: a <c>slangc.exe</c> without <c>slang-compiler.dll</c> beside it fails to start, and that
	/// shows up here rather than at the first validation.
	/// </summary>
	public static bool TryGetVersion( string executablePath, out string version )
	{
		version = null;

		if ( Resolve( executablePath ) is not { } resolved ) return false;

		var captured = PrismLog.Guard( $"Running {Path.GetFileName( resolved )} -v", () =>
		{
			var info = new ProcessStartInfo( resolved, "-v" )
			{
				RedirectStandardOutput = true,
				RedirectStandardError = true,
				UseShellExecute = false,
				CreateNoWindow = true,
				WorkingDirectory = Path.GetDirectoryName( resolved ) ?? string.Empty
			};

			using var process = Process.Start( info );

			if ( process is null ) return null;

			// Both pipes are drained concurrently, and the timeout is applied to the drain as well as to
			// the exit. Reading them one after the other is the classic child-process deadlock: the
			// child blocks writing to a full stderr buffer, ReadToEnd on stdout never returns, and the
			// WaitForExit below is never reached — an unbounded hang rather than an eight-second one.
			// `slangc -v` prints a few bytes so this would not bite today, but a mis-vendored binary of
			// the same name would, and this runs on the main thread on the install path.
			var stdoutTask = process.StandardOutput.ReadToEndAsync();
			var stderrTask = process.StandardError.ReadToEndAsync();

			if ( !process.WaitForExit( VersionTimeoutMs ) )
			{
				try { process.Kill( true ); } catch ( InvalidOperationException ) { }

				return null;
			}

			if ( !Task.WaitAll( new Task[] { stdoutTask, stderrTask }, VersionTimeoutMs ) )
			{
				try { process.Kill( true ); } catch ( InvalidOperationException ) { }

				return null;
			}

			var stdout = stdoutTask.Result;
			var stderr = stderrTask.Result;

			// slangc prints its version on stdout, but some builds use stderr; take whichever has one.
			return string.IsNullOrWhiteSpace( stdout ) ? stderr : stdout;
		}, null );

		if ( string.IsNullOrWhiteSpace( captured ) ) return false;

		foreach ( var line in captured.Split( '\n' ) )
		{
			var trimmed = line.Trim();

			if ( trimmed.Length == 0 ) continue;

			var match = s_version.Match( trimmed );

			version = match.Success ? match.Value : trimmed;

			return true;
		}

		return false;
	}

	static void Remember( SlangToolchainInfo info )
	{
		if ( info is null || !info.IsAvailable ) return;

		PrismLog.Guard( "Caching the Slang toolchain", () =>
		{
			EditorCookie.SetString( PrismConstants.CookieSlangPath, info.ExecutablePath );
			EditorCookie.SetString( PrismConstants.CookieSlangVersion, info.Version ?? string.Empty );
		} );
	}

	// ---- installation ----------------------------------------------------

	/// <summary>
	/// Download the official Windows release from GitHub and extract just the binaries <c>slangc</c>
	/// needs into <c>&lt;project&gt;/Tools/slang/bin</c>, then verify the result by running it.
	/// <para>
	/// Cancellable at every step. A cancelled or failed install removes whatever it had written so far,
	/// so the vendored folder is either complete or absent, never half a toolchain. Editor assemblies
	/// are unsandboxed, so <see cref="HttpClient"/>, <see cref="ZipArchive"/> and
	/// <see cref="Process"/> are all legal here.
	/// </para>
	/// </summary>
	public static async Task<SlangInstallResult> Install( IProgress<SlangInstallProgress> progress = null,
		CancellationToken ct = default )
	{
		var target = InstallDirectory;

		if ( string.IsNullOrWhiteSpace( target ) )
		{
			return new SlangInstallResult( false, "There is no current project to install into.", SlangToolchainInfo.None );
		}

		var archive = Path.Combine( Path.GetTempPath(), $"prism-slang-{Guid.NewGuid():N}.zip" );
		var written = new List<string>();

		try
		{
			Report( progress, "Finding the latest release", -1f, 0, 0 );

			var url = await ResolveDownloadUrl( ct ).ConfigureAwait( false );

			if ( string.IsNullOrWhiteSpace( url ) )
			{
				return new SlangInstallResult( false,
					"Could not find a Windows build in the latest Slang release.", SlangToolchainInfo.None );
			}

			await Download( url, archive, progress, ct ).ConfigureAwait( false );

			Report( progress, "Extracting", -1f, 0, 0 );

			Directory.CreateDirectory( target );

			var wanted = new HashSet<string>( RequiredFiles.Concat( OptionalFiles ), StringComparer.OrdinalIgnoreCase );

			using ( var zip = ZipFile.OpenRead( archive ) )
			{
				foreach ( var entry in zip.Entries )
				{
					ct.ThrowIfCancellationRequested();

					if ( string.IsNullOrEmpty( entry.Name ) ) continue;
					if ( !wanted.Contains( entry.Name ) ) continue;

					var destination = Path.Combine( target, entry.Name );

					entry.ExtractToFile( destination, true );
					written.Add( destination );
				}
			}

			var missing = RequiredFiles
				.Where( x => !File.Exists( Path.Combine( target, x ) ) )
				.ToArray();

			if ( missing.Length > 0 )
			{
				Rollback( written );

				return new SlangInstallResult( false,
					$"The release archive did not contain {string.Join( ", ", missing )}.", SlangToolchainInfo.None );
			}

			Report( progress, "Verifying", -1f, 0, 0 );

			var executable = Path.Combine( target, ExecutableName );

			if ( !TryGetVersion( executable, out var version ) )
			{
				Rollback( written );

				return new SlangInstallResult( false,
					"slangc was installed but would not start. Its companion DLLs may be missing or blocked.",
					SlangToolchainInfo.None );
			}

			var info = new SlangToolchainInfo( executable, version, SlangToolchainSource.Installed );

			lock ( s_lock )
			{
				s_current = info;
				s_probed = true;
			}

			Remember( info );
			PrismLog.Guard( "Raising SlangToolchain.Changed", () => Changed?.Invoke() );
			Report( progress, "Done", 1f, 0, 0 );

			return new SlangInstallResult( true, $"Installed slangc {version}.", info );
		}
		catch ( OperationCanceledException )
		{
			Rollback( written );

			return new SlangInstallResult( false, "Cancelled.", SlangToolchainInfo.None ) { Cancelled = true };
		}
		catch ( Exception e )
		{
			PrismLog.Error( e, "Installing the Slang toolchain failed" );
			Rollback( written );

			return new SlangInstallResult( false, e.Message, SlangToolchainInfo.None );
		}
		finally
		{
			PrismLog.Guard( "Removing the downloaded Slang archive", () =>
			{
				if ( File.Exists( archive ) ) File.Delete( archive );
			} );
		}
	}

	/// <summary>
	/// Ask GitHub for the newest release and pick the asset for this machine's architecture. Returns
	/// null when the request fails or nothing matches, never throws for a network problem.
	/// </summary>
	public static async Task<string> ResolveDownloadUrl( CancellationToken ct = default )
	{
		var suffix = RuntimeInformation.OSArchitecture == Architecture.Arm64
			? "windows-aarch64.zip"
			: "windows-x86_64.zip";

		try
		{
			using var client = CreateClient();

			var json = await client.GetStringAsync( ReleasesApi, ct ).ConfigureAwait( false );
			var document = JsonNode.Parse( json );

			if ( document?["assets"] is not JsonArray assets ) return null;

			string fallback = null;

			foreach ( var asset in assets )
			{
				var name = asset?["name"]?.GetValue<string>();
				var url = asset?["browser_download_url"]?.GetValue<string>();

				if ( string.IsNullOrWhiteSpace( name ) || string.IsNullOrWhiteSpace( url ) ) continue;
				if ( name.Contains( "debug-info", StringComparison.OrdinalIgnoreCase ) ) continue;
				if ( !name.StartsWith( "slang-", StringComparison.OrdinalIgnoreCase ) ) continue;

				if ( name.EndsWith( suffix, StringComparison.OrdinalIgnoreCase ) ) return url;

				if ( fallback is null && name.Contains( "windows", StringComparison.OrdinalIgnoreCase ) &&
					name.EndsWith( ".zip", StringComparison.OrdinalIgnoreCase ) )
				{
					fallback = url;
				}
			}

			return fallback;
		}
		catch ( OperationCanceledException )
		{
			throw;
		}
		catch ( Exception e )
		{
			PrismLog.Error( e, "Could not reach the Slang release feed" );

			return null;
		}
	}

	static async Task Download( string url, string destination, IProgress<SlangInstallProgress> progress,
		CancellationToken ct )
	{
		using var client = CreateClient();
		using var response = await client
			.GetAsync( url, HttpCompletionOption.ResponseHeadersRead, ct )
			.ConfigureAwait( false );

		response.EnsureSuccessStatusCode();

		var total = response.Content.Headers.ContentLength ?? 0L;

		using var source = await response.Content.ReadAsStreamAsync( ct ).ConfigureAwait( false );
		using var sink = File.Create( destination );

		var buffer = new byte[128 * 1024];
		var received = 0L;
		var lastReport = 0L;

		while ( true )
		{
			var read = await source.ReadAsync( buffer, ct ).ConfigureAwait( false );

			if ( read <= 0 ) break;

			await sink.WriteAsync( buffer.AsMemory( 0, read ), ct ).ConfigureAwait( false );

			received += read;

			// Reporting every chunk would flood the UI; every megabyte is plenty.
			if ( received - lastReport < 1048576 && received != total ) continue;

			lastReport = received;

			Report( progress, "Downloading", total > 0 ? (float)( received / (double)total ) : -1f, received, total );
		}
	}

	static HttpClient CreateClient()
	{
		var client = new HttpClient { Timeout = TimeSpan.FromMinutes( 10 ) };

		client.DefaultRequestHeaders.Add( "User-Agent", UserAgent );
		client.DefaultRequestHeaders.Add( "Accept", "application/vnd.github+json" );

		return client;
	}

	static void Report( IProgress<SlangInstallProgress> progress, string stage, float fraction,
		long received, long total )
	{
		if ( progress is null ) return;

		PrismLog.Guard( "Reporting Slang install progress",
			() => progress.Report( new SlangInstallProgress( stage, fraction, received, total ) ) );
	}

	static void Rollback( IEnumerable<string> written )
	{
		foreach ( var file in written )
		{
			PrismLog.Guard( "Rolling back a partial Slang install", () =>
			{
				if ( File.Exists( file ) ) File.Delete( file );
			} );
		}
	}
}