diff --git a/.github/workflows/buildexe.yml b/.github/workflows/buildexe.yml
new file mode 100644
index 000000000..819c3a283
--- /dev/null
+++ b/.github/workflows/buildexe.yml
@@ -0,0 +1,47 @@
+name: Build Stability Matrix (Windows)
+
+on:
+ push:
+ branches: [ "clean-main" ]
+ pull_request:
+ branches: [ "clean-main" ]
+ workflow_dispatch:
+
+jobs:
+ build-windows:
+ runs-on: windows-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup .NET SDK 9.x
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.0.x'
+
+ - name: Restore Avalonia project
+ run: dotnet restore StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
+
+ - name: Restore Tests project
+ run: dotnet restore StabilityMatrix.Tests/StabilityMatrix.Tests.csproj
+
+ - name: Build Avalonia project
+ run: dotnet build StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj -c Release --no-restore --no-cache
+
+ - name: Build Tests project
+ run: dotnet build StabilityMatrix.Tests/StabilityMatrix.Tests.csproj -c Release --no-restore
+
+ - name: Run Tests
+ run: dotnet test StabilityMatrix.Tests/StabilityMatrix.Tests.csproj -c Release --no-build
+
+ - name: Publish Avalonia app (win10-x64)
+ run: dotnet publish StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o ./publish/win10-x64
+
+ - name: Upload Artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: StabilityMatrix-win10-x64
+ path: ./publish/win10-x64/*
+ compression-level: 6
+ retention-days: 14
diff --git a/StabilityMatrix.Avalonia/Controls/Inference/TiledVAECard.axaml b/StabilityMatrix.Avalonia/Controls/Inference/TiledVAECard.axaml
index 9c47cbb3c..f5bc44cf8 100644
--- a/StabilityMatrix.Avalonia/Controls/Inference/TiledVAECard.axaml
+++ b/StabilityMatrix.Avalonia/Controls/Inference/TiledVAECard.axaml
@@ -5,15 +5,16 @@
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:vmInference="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Inference"
x:DataType="vmInference:TiledVAECardViewModel">
+
+
-
+
+
-
+
-
+
-
+
+ IsChecked="{Binding UseCustomTemporalTiling}" />
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs
index 40131ee01..5635e75e3 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs
+++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs
@@ -162,6 +162,68 @@ await updateHelper.DownloadUpdate(
);
}
+ // Handle Linux AppImage update
+ if (Compat.IsLinux && Environment.GetEnvironmentVariable("APPIMAGE") is { } appImage)
+ {
+ try
+ {
+ var updateScriptPath = UpdateHelper.UpdateFolder.JoinFile("update_script.sh").FullPath;
+ var newAppImage = UpdateHelper.ExecutablePath.FullPath;
+
+ var scriptContent = """
+#!/bin/bash
+set -e
+
+# Wait for the process to exit
+while kill -0 "$PID" 2>/dev/null; do
+ sleep 0.5
+done
+
+# Move the new AppImage over the old one
+mv -f "$NEW_APPIMAGE" "$OLD_APPIMAGE"
+chmod +x "$OLD_APPIMAGE"
+
+# Launch the new AppImage detached
+"$OLD_APPIMAGE" > /dev/null 2>&1 &
+disown
+""";
+ await File.WriteAllTextAsync(updateScriptPath, scriptContent);
+
+ File.SetUnixFileMode(
+ updateScriptPath,
+ UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute
+ );
+
+ var startInfo = new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = "/usr/bin/env",
+ Arguments = $"bash \"{updateScriptPath}\"",
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ startInfo.EnvironmentVariables["PID"] = Environment.ProcessId.ToString();
+ startInfo.EnvironmentVariables["NEW_APPIMAGE"] = newAppImage;
+ startInfo.EnvironmentVariables["OLD_APPIMAGE"] = appImage;
+
+ System.Diagnostics.Process.Start(startInfo);
+
+ App.Shutdown();
+ return;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to execute AppImage update script");
+
+ var dialog = DialogHelper.CreateMarkdownDialog(
+ "AppImage update script failed. \nCould not replace old AppImage with new version. Please check directory permissions. \nFalling back to standard update process. User intervention required: \nAfter program closes, \nplease move the new AppImage extracted in the '.StabilityMatrixUpdate' hidden directory to the old AppImage overwriting it. \n\nClose this dialog to continue with standard update process.",
+ Resources.Label_UnexpectedErrorOccurred
+ );
+
+ await dialog.ShowAsync();
+ }
+ }
+
// Set current version for update messages
settingsManager.Transaction(
s => s.UpdatingFromVersion = Compat.AppVersion,
diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceWanTextToVideoViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceWanTextToVideoViewModel.cs
index 4b2ac1815..e5ef2da79 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceWanTextToVideoViewModel.cs
+++ b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceWanTextToVideoViewModel.cs
@@ -70,8 +70,8 @@ RunningPackageService runningPackageService
BatchSizeCardViewModel = vmFactory.Get();
- VideoOutputSettingsCardViewModel = vmFactory.Get(
- vm => vm.Fps = 16.0d
+ VideoOutputSettingsCardViewModel = vmFactory.Get(vm =>
+ vm.Fps = 16.0d
);
StackCardViewModel = vmFactory.Get();
@@ -94,7 +94,7 @@ protected override void BuildPrompt(BuildPromptEventArgs args)
builder.Connections.Seed = args.SeedOverride switch
{
{ } seed => Convert.ToUInt64(seed),
- _ => Convert.ToUInt64(SeedCardViewModel.Seed)
+ _ => Convert.ToUInt64(SeedCardViewModel.Seed),
};
// Load models
@@ -115,7 +115,6 @@ protected override void BuildPrompt(BuildPromptEventArgs args)
SamplerCardViewModel.ApplyStep(args);
- // Animated webp output
VideoOutputSettingsCardViewModel.ApplyStep(args);
}
@@ -165,13 +164,13 @@ CancellationToken cancellationToken
OutputNodeNames = buildPromptArgs.Builder.Connections.OutputNodeNames.ToArray(),
Parameters = SaveStateToParameters(new GenerationParameters()) with
{
- Seed = Convert.ToUInt64(seed)
+ Seed = Convert.ToUInt64(seed),
},
Project = inferenceProject,
FilesToTransfer = buildPromptArgs.FilesToTransfer,
BatchIndex = i,
// Only clear output images on the first batch
- ClearOutputImages = i == 0
+ ClearOutputImages = i == 0,
};
batchArgs.Add(generationArgs);
diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/TiledVAEModule.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/TiledVAEModule.cs
index 3199a53fa..36db2f0f1 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/TiledVAEModule.cs
+++ b/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/TiledVAEModule.cs
@@ -4,6 +4,7 @@
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Models.Api.Comfy.Nodes;
+using NLog;
namespace StabilityMatrix.Avalonia.ViewModels.Inference.Modules;
@@ -11,6 +12,8 @@ namespace StabilityMatrix.Avalonia.ViewModels.Inference.Modules;
[RegisterTransient]
public class TiledVAEModule : ModuleBase
{
+ private static readonly Logger logger = LogManager.GetCurrentClassLogger();
+
public TiledVAEModule(IServiceManager vmFactory)
: base(vmFactory)
{
@@ -22,7 +25,6 @@ protected override void OnApplyStep(ModuleApplyStepEventArgs e)
{
var card = GetCard();
- // Register a pre-output action that replaces standard VAE decode with tiled decode
e.PreOutputActions.Add(args =>
{
var builder = args.Builder;
@@ -34,8 +36,10 @@ protected override void OnApplyStep(ModuleApplyStepEventArgs e)
var latent = builder.Connections.Primary.AsT0;
var vae = builder.Connections.GetDefaultVAE();
- // Use tiled VAE decode instead of standard decode
- var tiledDecode = builder.Nodes.AddTypedNode(
+ logger.Debug("TiledVAE: Injecting TiledVAEDecode");
+ logger.Debug("UseCustomTemporalTiling value at runtime: {value}", card.UseCustomTemporalTiling);
+
+ var node = builder.Nodes.AddTypedNode(
new ComfyNodeBuilder.TiledVAEDecode
{
Name = builder.Nodes.GetUniqueName("TiledVAEDecode"),
@@ -43,13 +47,15 @@ protected override void OnApplyStep(ModuleApplyStepEventArgs e)
Vae = vae,
TileSize = card.TileSize,
Overlap = card.Overlap,
- TemporalSize = card.TemporalSize,
- TemporalOverlap = card.TemporalOverlap
+
+ // Temporal tiling (WAN requires temporal tiling)
+ TemporalSize = card.UseCustomTemporalTiling ? card.TemporalSize : 64,
+ TemporalOverlap = card.UseCustomTemporalTiling ? card.TemporalOverlap : 8,
}
);
// Update primary connection to the decoded image
- builder.Connections.Primary = tiledDecode.Output;
+ builder.Connections.Primary = node.Output;
});
}
}
diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/TiledVAECardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/TiledVAECardViewModel.cs
index ae2920778..a89ba141f 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Inference/TiledVAECardViewModel.cs
+++ b/StabilityMatrix.Avalonia/ViewModels/Inference/TiledVAECardViewModel.cs
@@ -14,24 +14,32 @@ public partial class TiledVAECardViewModel : LoadableViewModelBase
{
public const string ModuleKey = "TiledVAE";
+ // Spatial tile size (valid for Wan)
[ObservableProperty]
[NotifyDataErrorInfo]
[Required]
[Range(64, 4096)]
private int tileSize = 512;
+ // Spatial overlap
[ObservableProperty]
[NotifyDataErrorInfo]
[Required]
[Range(0, 4096)]
private int overlap = 64;
+ // Toggle: Use custom temporal tiling settings
+ [ObservableProperty]
+ private bool useCustomTemporalTiling = false;
+
+ // Temporal tile size (must be >= 8)
[ObservableProperty]
[NotifyDataErrorInfo]
[Required]
[Range(8, 4096)]
private int temporalSize = 64;
+ // Temporal overlap (must be >= 4)
[ObservableProperty]
[NotifyDataErrorInfo]
[Required]
diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs
index 8cc7e19a4..e54d1bace 100644
--- a/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs
+++ b/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs
@@ -191,7 +191,7 @@ ISettingsManager settingsManager
settings => settings.InferenceDimensionStepChange,
true
);
-
+
FavoriteDimensions
.ToObservableChangeSet()
.Throttle(TimeSpan.FromMilliseconds(50))
diff --git a/StabilityMatrix.Core/Inference/ComfyClient.cs b/StabilityMatrix.Core/Inference/ComfyClient.cs
index 197adde6e..dbca6a66f 100644
--- a/StabilityMatrix.Core/Inference/ComfyClient.cs
+++ b/StabilityMatrix.Core/Inference/ComfyClient.cs
@@ -29,16 +29,15 @@ public class ComfyClient : InferenceClientBase
private readonly IComfyApi comfyApi;
private bool isDisposed;
- private readonly JsonSerializerOptions jsonSerializerOptions =
- new()
+ private readonly JsonSerializerOptions jsonSerializerOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicies.SnakeCaseLower,
+ Converters =
{
- PropertyNamingPolicy = JsonNamingPolicies.SnakeCaseLower,
- Converters =
- {
- new NodeConnectionBaseJsonConverter(),
- new OneOfJsonConverter()
- }
- };
+ new NodeConnectionBaseJsonConverter(),
+ new OneOfJsonConverter(),
+ },
+ };
// ReSharper disable once MemberCanBePrivate.Global
public string ClientId { get; } = Guid.NewGuid().ToString();
@@ -111,20 +110,20 @@ public ComfyClient(IApiFactory apiFactory, Uri baseAddress)
{
Scheme = "ws",
Path = "/ws",
- Query = $"clientId={ClientId}"
+ Query = $"clientId={ClientId}",
}.Uri;
webSocketClient = new WebsocketClient(wsUri)
{
Name = nameof(ComfyClient),
- ReconnectTimeout = TimeSpan.FromSeconds(30)
+ ReconnectTimeout = TimeSpan.FromSeconds(30),
};
- webSocketClient.DisconnectionHappened.Subscribe(
- info => Logger.Info("Websocket Disconnected, ({Type})", info.Type)
+ webSocketClient.DisconnectionHappened.Subscribe(info =>
+ Logger.Info("Websocket Disconnected, ({Type})", info.Type)
);
- webSocketClient.ReconnectionHappened.Subscribe(
- info => Logger.Info("Websocket Reconnected, ({Type})", info.Type)
+ webSocketClient.ReconnectionHappened.Subscribe(info =>
+ Logger.Info("Websocket Reconnected, ({Type})", info.Type)
);
webSocketClient.MessageReceived.Subscribe(OnMessageReceived);
@@ -287,7 +286,7 @@ private void HandleBinaryMessage(byte[] data)
Array.Reverse(typeBytes);
}*/
- PreviewImageReceived?.Invoke(this, new ComfyWebSocketImageData { ImageBytes = data[8..], });
+ PreviewImageReceived?.Invoke(this, new ComfyWebSocketImageData { ImageBytes = data[8..] });
}
public override async Task ConnectAsync(CancellationToken cancellationToken = default)
@@ -332,6 +331,7 @@ public async Task QueuePromptAsync(
)
{
var request = new ComfyPromptRequest { ClientId = ClientId, Prompt = nodes };
+
var result = await comfyApi.PostPrompt(request, cancellationToken).ConfigureAwait(false);
// Add task to dictionary and set it as the current task
diff --git a/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs b/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs
index c164a6324..baf394808 100644
--- a/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs
+++ b/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs
@@ -67,16 +67,16 @@ public record TiledVAEDecode : ComfyTypedNodeBase
{
public required LatentNodeConnection Samples { get; init; }
public required VAENodeConnection Vae { get; init; }
-
+
[Range(64, 4096)]
public int TileSize { get; init; } = 512;
-
+
[Range(0, 4096)]
public int Overlap { get; init; } = 64;
-
+
[Range(8, 4096)]
public int TemporalSize { get; init; } = 64;
-
+
[Range(4, 4096)]
public int TemporalOverlap { get; init; } = 8;
}
@@ -1097,19 +1097,48 @@ public record NRS : ComfyTypedNodeBase
public required double Squash { get; set; }
}
- public ImageNodeConnection Lambda_LatentToImage(LatentNodeConnection latent, VAENodeConnection vae)
+ public ImageNodeConnection Lambda_LatentToImage(
+ LatentNodeConnection latent,
+ VAENodeConnection vae,
+ bool useTiledVAE = false,
+ int tileSize = 512,
+ int overlap = 64,
+ int temporalSize = 64,
+ int temporalOverlap = 8
+ )
{
- var name = GetUniqueName("VAEDecode");
- return Nodes
- .AddTypedNode(
- new VAEDecode
- {
- Name = name,
- Samples = latent,
- Vae = vae,
- }
- )
- .Output;
+ if (useTiledVAE)
+ {
+ var name = GetUniqueName("VAEDecodeTiled");
+ return Nodes
+ .AddTypedNode(
+ new TiledVAEDecode
+ {
+ Name = name,
+ Samples = latent,
+ Vae = vae,
+ TileSize = tileSize,
+ Overlap = overlap,
+ TemporalSize = temporalSize,
+ TemporalOverlap = temporalOverlap,
+ }
+ )
+ .Output;
+ }
+ else
+ {
+ var name = GetUniqueName("VAEDecode");
+ return Nodes
+ .AddTypedNode(
+ new VAEDecode
+ {
+ Name = name,
+ Samples = latent,
+ Vae = vae,
+ }
+ )
+ .Output;
+ }
}
public LatentNodeConnection Lambda_ImageToLatent(ImageNodeConnection pixels, VAENodeConnection vae)
@@ -1519,11 +1548,33 @@ public ImageNodeConnection GetPrimaryAsImage()
///
/// Get or convert latest primary connection to image
///
- public ImageNodeConnection GetPrimaryAsImage(PrimaryNodeConnection primary, VAENodeConnection vae)
+ public ImageNodeConnection GetPrimaryAsImage(
+ PrimaryNodeConnection primary,
+ VAENodeConnection vae,
+ bool useTiledVAE = false,
+ int tileSize = 512,
+ int overlap = 64,
+ int temporalSize = 64,
+ int temporalOverlap = 8
+ )
{
- return primary.Match(latent => Lambda_LatentToImage(latent, vae), image => image);
+ return primary.Match(
+ latent =>
+ Lambda_LatentToImage(
+ latent,
+ vae,
+ useTiledVAE,
+ tileSize,
+ overlap,
+ temporalSize,
+ temporalOverlap
+ ),
+ image => image
+ );
}
+
+
///
/// Get or convert latest primary connection to image
///
diff --git a/StabilityMatrix.Core/Models/Packages/AiToolkit.cs b/StabilityMatrix.Core/Models/Packages/AiToolkit.cs
index 24009cef2..fc1e388e7 100644
--- a/StabilityMatrix.Core/Models/Packages/AiToolkit.cs
+++ b/StabilityMatrix.Core/Models/Packages/AiToolkit.cs
@@ -35,10 +35,7 @@ IPyInstallationManager pyInstallationManager
public override string LicenseUrl => "https://github.com/ostris/ai-toolkit/blob/main/LICENSE";
public override string LaunchCommand => string.Empty;
- public override Uri PreviewImageUri =>
- new(
- "https://camo.githubusercontent.com/ea35b399e0d659f9f2ee09cbedb58e1a3ec7a0eab763e8ae8d11d076aad5be40/68747470733a2f2f6f73747269732e636f6d2f77702d636f6e74656e742f75706c6f6164732f323032352f30322f746f6f6c6b69742d75692e6a7067"
- );
+ public override Uri PreviewImageUri => new("https://cdn.lykos.ai/sm/packages/aitoolkit/preview.webp");
public override string OutputFolderName => "output";
public override IEnumerable AvailableTorchIndices => [TorchIndex.Cuda];
diff --git a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs
index 6af2b047f..e0fe67e6a 100644
--- a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs
+++ b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs
@@ -382,7 +382,7 @@ await StandardPipInstallProcessAsync(
var indexUrl = gfxArch switch
{
"gfx1151" => "https://rocm.nightlies.amd.com/v2/gfx1151",
- _ when gfxArch.StartsWith("gfx110") => "https://rocm.nightlies.amd.com/v2/gfx110X-dgpu",
+ _ when gfxArch.StartsWith("gfx110") => "https://rocm.nightlies.amd.com/v2/gfx110X-all",
_ when gfxArch.StartsWith("gfx120") => "https://rocm.nightlies.amd.com/v2/gfx120X-all",
_ => throw new ArgumentOutOfRangeException(
nameof(gfxArch),
@@ -870,6 +870,8 @@ private ImmutableDictionary GetEnvVars(ImmutableDictionary
"Prerequisite install may require admin privileges and a reboot. "
+ "Visual Studio Build Tools for C++ Desktop Development will be installed automatically. "
- + "AMD GPUs under the RX 6800 may require additional manual setup.";
+ + "AMD GPUs under the RX 6800 may require additional manual setup. ";
public override string LaunchCommand => Path.Combine("zluda", "zluda.exe");
+
+ public override List LaunchOptions
+ {
+ get
+ {
+ var options = new List
+ {
+ new()
+ {
+ Name = "Cross Attention Method",
+ Type = LaunchOptionType.Bool,
+ InitialValue = "--use-quad-cross-attention",
+ Options =
+ [
+ "--use-split-cross-attention",
+ "--use-quad-cross-attention",
+ "--use-pytorch-cross-attention",
+ "--use-sage-attention",
+ ],
+ },
+ new()
+ {
+ Name = "Disable Async Offload",
+ Type = LaunchOptionType.Bool,
+ InitialValue = true,
+ Options = ["--disable-async-offload"],
+ },
+ new()
+ {
+ Name = "Disable Pinned Memory",
+ Type = LaunchOptionType.Bool,
+ InitialValue = true,
+ Options = ["--disable-pinned-memory"],
+ },
+ new()
+ {
+ Name = "Disable Smart Memory",
+ Type = LaunchOptionType.Bool,
+ InitialValue = false,
+ Options = ["--disable-smart-memory"],
+ },
+ new()
+ {
+ Name = "Disable Model/Node Caching",
+ Type = LaunchOptionType.Bool,
+ InitialValue = false,
+ Options = ["--cache-none"],
+ },
+ };
+
+ options.AddRange(
+ base.LaunchOptions.Where(x => x.Name != "Cross Attention Method")
+ );
+ return options;
+ }
+ }
+
public override IEnumerable AvailableTorchIndices => [TorchIndex.Zluda];
public override TorchIndex GetRecommendedTorchVersion() => TorchIndex.Zluda;
diff --git a/StabilityMatrix.Tests/Models/Packages/PackageLinkTests.cs b/StabilityMatrix.Tests/Models/Packages/PackageLinkTests.cs
index c0b3113e4..6c21c92d2 100644
--- a/StabilityMatrix.Tests/Models/Packages/PackageLinkTests.cs
+++ b/StabilityMatrix.Tests/Models/Packages/PackageLinkTests.cs
@@ -25,7 +25,6 @@ public sealed class PackageLinkTests
Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromMilliseconds(200), 3),
onRetry: (outcome, timespan, retryAttempt, context) =>
{
- // Log retry attempt if needed
Console.WriteLine($"Retry attempt {retryAttempt}, waiting {timespan.TotalSeconds} seconds");
}
);
@@ -36,19 +35,23 @@ public async Task TestPreviewImageUri(BasePackage package)
{
var imageUri = package.PreviewImageUri;
- // If is GitHub Uri, use jsdelivr instead due to rate limiting
+ // If GitHub URL, use jsDelivr to avoid rate limiting
imageUri = GitHubToJsDelivr(imageUri);
- // Test http head is successful with retry policy
var response = await RetryPolicy.ExecuteAsync(async () =>
await HttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, imageUri))
);
+ // 403 should fail — URL is invalid or blocked
+ Assert.AreNotEqual(
+ System.Net.HttpStatusCode.Forbidden,
+ response.StatusCode,
+ $"PreviewImageUri returned 403 Forbidden: {imageUri}"
+ );
+
Assert.IsTrue(
response.IsSuccessStatusCode,
- "Failed to get PreviewImageUri at {0}: {1}",
- imageUri,
- response
+ $"Failed to get PreviewImageUri at {imageUri}: {response}"
);
}
@@ -63,34 +66,32 @@ public async Task TestLicenseUrl(BasePackage package)
var licenseUri = new Uri(package.LicenseUrl);
- // If is GitHub Uri, use jsdelivr instead due to rate limiting
+ // If GitHub URL, use jsDelivr to avoid rate limiting
licenseUri = GitHubToJsDelivr(licenseUri);
- // Test http head is successful with retry policy
var response = await RetryPolicy.ExecuteAsync(async () =>
await HttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, licenseUri))
);
- Assert.IsTrue(
- response.IsSuccessStatusCode,
- "Failed to get LicenseUrl at {0}: {1}",
- licenseUri,
- response
- );
+ Assert.IsTrue(response.IsSuccessStatusCode, $"Failed to get LicenseUrl at {licenseUri}: {response}");
}
private static Uri GitHubToJsDelivr(Uri uri)
{
- // Like https://github.com/user/Repo/blob/main/LICENSE
- // becomes: https://cdn.jsdelivr.net/gh/user/Repo@main/LICENSE
- if (uri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase))
+ // Example:
+ // https://github.com/user/Repo/blob/main/LICENSE
+ // becomes:
+ // https://cdn.jsdelivr.net/gh/user/Repo@main/LICENSE
+
+ if (!uri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase))
+ return uri;
+
+ var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
+
+ if (segments is [var user, var repo, "blob", var branch, ..])
{
- var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
- if (segments is [var user, var repo, "blob", var branch, ..])
- {
- var path = string.Join("/", segments.Skip(4));
- return new Uri($"https://cdn.jsdelivr.net/gh/{user}/{repo}@{branch}/{path}");
- }
+ var path = string.Join("/", segments.Skip(4));
+ return new Uri($"https://cdn.jsdelivr.net/gh/{user}/{repo}@{branch}/{path}");
}
return uri;