Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable user-facing changes are documented here.

## [Unreleased]

## [0.1.5] - 2026-08-02

### Fixed

- Update downloads now close their file handles before promoting verified packages or replacing an invalid cached package, preventing Windows file-lock errors during one-click updates.

### Upgrade note

- Captail 0.1.3 and 0.1.4 cannot complete an in-app update because the bug is inside those installed versions. Download and run the 0.1.5 Setup EXE once; later in-app updates will work normally.

## [0.1.3] - 2026-07-26

### Added
Expand Down
2 changes: 1 addition & 1 deletion src/Captail/Captail.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<Product>Captail</Product>
<RootNamespace>Captail</RootNamespace>
<ApplicationIcon>Assets\Captail.ico</ApplicationIcon>
<Version>0.1.4</Version>
<Version>0.1.5</Version>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>

Expand Down
135 changes: 72 additions & 63 deletions src/Captail/UpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -422,16 +422,21 @@ private static async Task DownloadVerifiedAsync(
if (File.Exists(destinationPath) &&
new FileInfo(destinationPath).Length == asset.Size)
{
await using var cachedFile = new FileStream(
destinationPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
128 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
byte[] cachedHash = await SHA256.HashDataAsync(
cachedFile,
cancellationToken);
byte[] cachedHash;
await using (var cachedFile = new FileStream(
destinationPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
128 * 1024,
FileOptions.Asynchronous |
FileOptions.SequentialScan))
{
cachedHash = await SHA256.HashDataAsync(
cachedFile,
cancellationToken);
}

if (CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(
Convert.ToHexString(cachedHash)
Expand Down Expand Up @@ -460,66 +465,70 @@ private static async Task DownloadVerifiedAsync(
"Downloaded update size does not match release metadata.");
}

await using Stream source =
await response.Content.ReadAsStreamAsync(cancellationToken);
await using var destination = new FileStream(
temporaryPath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
128 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
using IncrementalHash hash =
IncrementalHash.CreateHash(HashAlgorithmName.SHA256);

byte[] buffer = new byte[128 * 1024];
long total = 0;
int lastProgress = -1;
while (true)
await using (Stream source =
await response.Content.ReadAsStreamAsync(
cancellationToken))
await using (var destination = new FileStream(
temporaryPath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
128 * 1024,
FileOptions.Asynchronous |
FileOptions.SequentialScan))
{
int read = await source.ReadAsync(
buffer,
cancellationToken);
if (read == 0)
break;
total += read;
if (total > asset.Size || total > MaximumAssetBytes)
using IncrementalHash hash =
IncrementalHash.CreateHash(HashAlgorithmName.SHA256);

byte[] buffer = new byte[128 * 1024];
long total = 0;
int lastProgress = -1;
while (true)
{
int read = await source.ReadAsync(
buffer,
cancellationToken);
if (read == 0)
break;
total += read;
if (total > asset.Size || total > MaximumAssetBytes)
{
throw new InvalidDataException(
"Downloaded update exceeds expected size.");
}
hash.AppendData(buffer, 0, read);
await destination.WriteAsync(
buffer.AsMemory(0, read),
cancellationToken);

int percent = (int)Math.Clamp(
total * 100L / asset.Size,
0,
100);
if (percent != lastProgress)
{
progress?.Report(percent);
lastProgress = percent;
}
}
await destination.FlushAsync(cancellationToken);

if (total != asset.Size)
{
throw new InvalidDataException(
"Downloaded update exceeds expected size.");
"Downloaded update is incomplete.");
}
hash.AppendData(buffer, 0, read);
await destination.WriteAsync(
buffer.AsMemory(0, read),
cancellationToken);

int percent = (int)Math.Clamp(
total * 100L / asset.Size,
0,
100);
if (percent != lastProgress)
string actualHash =
Convert.ToHexString(hash.GetHashAndReset())
.ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(actualHash),
Encoding.ASCII.GetBytes(expectedHash)))
{
progress?.Report(percent);
lastProgress = percent;
throw new InvalidDataException(
"Downloaded update failed SHA-256 verification.");
}
}
await destination.FlushAsync(cancellationToken);

if (total != asset.Size)
{
throw new InvalidDataException(
"Downloaded update is incomplete.");
}
string actualHash =
Convert.ToHexString(hash.GetHashAndReset())
.ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(actualHash),
Encoding.ASCII.GetBytes(expectedHash)))
{
throw new InvalidDataException(
"Downloaded update failed SHA-256 verification.");
}

File.Move(temporaryPath, destinationPath, overwrite: true);
progress?.Report(100);
Expand Down
Loading