Compare commits

..
Author SHA1 Message Date
james.maesandClaude Sonnet 5.5 e82c75c936 ci(gitea): sign and publish gclo releases from Gitea
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 16:51:58 -06:00
15 changed files with 163 additions and 503 deletions

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
#requires -Version 7
# gclo release on a Gitea Windows host runner. Mirrors the shared Velopack workflow's commands
# (standards release-velopack.yml): locked restore, Release build, tests and 100% coverage,
# self-contained publish, sign owned binaries, vpk pack (signing Velopack helpers), verify, publish.
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
Set-StrictMode -Version Latest
$tag = $env:GITHUB_REF_NAME
if ($env:GITHUB_REF_TYPE -ne 'tag') { throw "Releases run only for tags; got '$tag'." }
$semver = '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?$'
if ($tag -cnotmatch $semver) { throw "Tag '$tag' is not vMAJOR.MINOR.PATCH with an optional prerelease suffix." }
$version = $tag.Substring(1)
$prerelease = $version.Contains('-')
$channel = if ($prerelease) { 'dev' } else { 'stable' }
$appId = if ($prerelease) { 'gclo-dev' } else { 'gclo' }
# Fail before building: every signing input must be present.
foreach ($name in 'AZURE_CLIENT_ID', 'AZURE_TENANT_ID', 'AZURE_CLIENT_SECRET', 'ARTIFACT_SIGNING_ENDPOINT', 'ARTIFACT_SIGNING_ACCOUNT', 'ARTIFACT_SIGNING_PROFILE', 'ARTIFACT_SIGNING_SUBJECT') {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { throw "Missing signing configuration: $name" }
}
# Releases are cut from main.
git fetch --quiet origin main
if ((git rev-parse --is-shallow-repository) -eq 'true') { git fetch --quiet --unshallow origin }
git merge-base --is-ancestor $env:GITHUB_SHA origin/main
Write-Output "OK: $tag is contained in main."
dotnet restore gclo.slnx -p:Platform=x64 --locked-mode
dotnet build gclo.slnx -c Release -p:Platform=x64 "-p:Version=$version" -warnaserror --no-restore
foreach ($project in 'gclo.Engine.Tests', 'gclo.Cli.Tests') {
dotnet test $project -c Release --no-build --settings coverage.runsettings --collect:'XPlat Code Coverage' --results-directory TestResults
}
$reports = Get-ChildItem TestResults -Recurse -Filter coverage.cobertura.xml | ForEach-Object { [xml](Get-Content -LiteralPath $_.FullName -Raw) }
if (-not $reports) { throw 'No coverage reports.' }
foreach ($name in 'gclo.Engine', 'gclo.ViewModels', 'gclo') {
$rates = foreach ($r in $reports) { $p = @($r.coverage.packages.package) | Where-Object { $_.name -eq $name }; if ($p) { [double]::Parse($p.'line-rate', [Globalization.CultureInfo]::InvariantCulture) } }
if (-not $rates -or ($rates | Measure-Object -Maximum).Maximum -lt 1.0) { throw "Coverage gate failed for $name." }
}
dotnet publish gclo/gclo.csproj -c Release -p:Platform=x64 -r win-x64 --self-contained "-p:Version=$version" -p:RestoreLockedMode=true -p:WindowsPackageType=None -p:WindowsAppSDKSelfContained=true -p:PublishTrimmed=false -o publish/app
dotnet publish gclo.Cli/gclo.Cli.csproj -c Release -r win-x64 --self-contained "-p:Version=$version" -p:RestoreLockedMode=true -p:PublishSingleFile=true -o publish/cli
# Signing tools: x64 SignTool plus the Artifact Signing dlib, authenticating as the Entra app
# (EnvironmentCredential only; every other credential type is excluded).
$tools = Join-Path $env:RUNNER_TEMP 'ArtifactSigning'
New-Item -ItemType Directory -Force -Path $tools | Out-Null
$nuget = Join-Path $tools 'nuget.exe'
Invoke-WebRequest -Uri 'https://dist.nuget.org/win-x86-commandline/v6.12.1/nuget.exe' -OutFile $nuget
& $nuget install Microsoft.ArtifactSigning.Client -x -OutputDirectory $tools | Out-Null
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe | Where-Object FullName -Match '\\x64\\' | Sort-Object FullName -Descending | Select-Object -First 1
$dlib = Get-ChildItem $tools -Recurse -Filter Azure.CodeSigning.Dlib.dll | Where-Object FullName -Match '\\x64\\' | Select-Object -First 1
if (-not $signtool -or -not $dlib) { throw 'x64 SignTool or the Artifact Signing dlib was not found.' }
$metadata = Join-Path $tools 'metadata.json'
@{
Endpoint = $env:ARTIFACT_SIGNING_ENDPOINT
CodeSigningAccountName = $env:ARTIFACT_SIGNING_ACCOUNT
CertificateProfileName = $env:ARTIFACT_SIGNING_PROFILE
ExcludeCredentials = @('WorkloadIdentityCredential', 'ManagedIdentityCredential', 'SharedTokenCacheCredential', 'VisualStudioCredential', 'VisualStudioCodeCredential', 'AzurePowerShellCredential', 'AzureCliCredential', 'AzureDeveloperCliCredential', 'InteractiveBrowserCredential')
} | ConvertTo-Json | Set-Content -LiteralPath $metadata -Encoding utf8
function Test-Subject($file) {
$sig = Get-AuthenticodeSignature -LiteralPath $file
$sig.Status -eq 'Valid' -and $sig.SignerCertificate.Subject -like "*$env:ARTIFACT_SIGNING_SUBJECT*" -and $null -ne $sig.TimeStamperCertificate
}
function Sign-File($file) {
$current = Get-AuthenticodeSignature -LiteralPath $file
if ($current.Status -eq 'Valid') { return }
if ($current.Status -ne 'NotSigned') { throw "Refusing to replace an invalid signature on $file." }
& $signtool.FullName sign /fd SHA256 /tr http://timestamp.acs.microsoft.com /td SHA256 /dlib $dlib.FullName /dmdf $metadata $file
& $signtool.FullName verify /pa /all /v $file
}
# Only this project's own binaries; third-party and framework files keep their publishers' signatures.
$owned = Get-ChildItem publish -Recurse -File | Where-Object { $_.Name -match '^gclo.*\.(exe|dll)$' }
if (-not $owned) { throw 'No owned binaries to sign.' }
$owned | ForEach-Object { Sign-File $_.FullName }
# Velopack: pack, signing only its own helpers (Squirrel/stubs) and Setup.exe with the same identity.
dotnet tool install --global vpk --version 1.2.161
$signOne = Join-Path $tools 'sign-one.ps1'
$template = @'
param([Parameter(Mandatory)][string] $File)
$sig = Get-AuthenticodeSignature -LiteralPath $File
if ($sig.Status -eq 'Valid') { exit 0 }
if ($sig.Status -ne 'NotSigned') { throw "Refusing to replace an invalid signature on $File." }
& '@@TOOL@@' sign /fd SHA256 /tr http://timestamp.acs.microsoft.com /td SHA256 /dlib '@@DLIB@@' /dmdf '@@META@@' $File
if ($LASTEXITCODE -ne 0) { throw "Signing failed on $File." }
'@
$template.Replace('@@TOOL@@', $signtool.FullName).Replace('@@DLIB@@', $dlib.FullName).Replace('@@META@@', $metadata) |
Set-Content -LiteralPath $signOne -Encoding utf8NoBOM
$exclude = '(?i)[\\/](?!(?:Squirrel\.exe|.*_ExecutionStub\.exe)$)[^\\/]+$'
$env:VELOPACK_TEMP = Join-Path $env:RUNNER_TEMP 'k22-velopack'
# No Gitea delta baseline exists for the first release; later releases can add one.
vpk pack --runtime win-x64 --packId $appId --packVersion $version --packDir publish/app --mainExe gclo.exe --channel $channel --icon gclo/Assets/Brand/gclo.ico --outputDir Releases --signTemplate "pwsh -NoProfile -NonInteractive -File `"$signOne`" `"{{file}}`"" --signExclude $exclude
New-Item -ItemType Directory -Force -Path release | Out-Null
$assets = @(Get-Content -LiteralPath "Releases/assets.$channel.json" -Raw | ConvertFrom-Json)
if ($assets.Count -eq 0) { throw "vpk produced no assets for channel $channel." }
$names = foreach ($a in $assets) { Copy-Item -LiteralPath (Join-Path Releases $a.RelativeFileName) -Destination release; [IO.Path]::GetFileName($a.RelativeFileName) }
$feed = Get-Content -LiteralPath "Releases/releases.$channel.json" -Raw | ConvertFrom-Json
$feed.Assets = @($feed.Assets | Where-Object { $_.FileName -in $names })
$feed | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath "release/releases.$channel.json" -Encoding utf8NoBOM
Compress-Archive -Path publish/cli/* -DestinationPath "release/$appId-cli-win-x64-$version.zip"
# Verify what ships: Setup.exe and the CLI exe must be validly signed, timestamped, by the expected subject.
$setup = Get-ChildItem release -Filter '*-Setup.exe' | Select-Object -First 1
if (-not $setup -or -not (Test-Subject $setup.FullName)) { throw 'Setup.exe is not signed by the expected publisher.' }
foreach ($f in Get-ChildItem publish/cli -Filter 'gclo*.exe') { if (-not (Test-Subject $f.FullName)) { throw "$($f.Name) is not signed by the expected publisher." } }
# Checksums and Gitea release (draft until every asset is uploaded).
$files = Get-ChildItem release -File
$sums = Join-Path $env:RUNNER_TEMP 'SHA256SUMS'
$files | ForEach-Object { '{0} {1}' -f (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant(), $_.Name } | Set-Content -LiteralPath $sums -Encoding ascii
$api = "$env:GITHUB_SERVER_URL/api/v1/repos/$env:GITHUB_REPOSITORY"
$headers = @{ Authorization = "token $env:TOKEN" }
$body = @{ tag_name = $tag; name = $tag; prerelease = $prerelease; draft = $true } | ConvertTo-Json
$release = Invoke-RestMethod -Method Post -Headers $headers -ContentType 'application/json' -Body $body "$api/releases"
foreach ($f in @($files.FullName) + $sums) {
$leaf = [uri]::EscapeDataString([IO.Path]::GetFileName($f))
$null = Invoke-RestMethod -Method Post -Headers $headers -Form @{ attachment = Get-Item -LiteralPath $f } "$api/releases/$($release.id)/assets?name=$leaf"
}
$null = Invoke-RestMethod -Method Patch -Headers $headers -ContentType 'application/json' -Body (@{ draft = $false } | ConvertTo-Json) "$api/releases/$($release.id)"
Write-Output "Published signed $tag to $($release.html_url)"
-32
View File
@@ -1,32 +0,0 @@
# Gitea CI for gclo on the Windows host runner. Mirrors .github/workflows/ci.yml
# (build-test + format); the FlaUI ui-tests job needs an interactive desktop and stays on GitHub.
# Push only: host runners must never run fork pull requests.
name: build
on:
push:
branches: ['**']
tags: ['v*']
jobs:
windows:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/windows.yaml@v1
secrets: inherit
with:
commands: |
dotnet restore gclo.slnx --locked-mode
dotnet build gclo.slnx -p:Platform=x64 -warnaserror -p:RestoreLockedMode=true
dotnet test gclo.Engine.Tests --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
dotnet test gclo.Cli.Tests --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
$files = Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml
if (-not $files) { throw 'No coverage.cobertura.xml found under TestResults.' }
foreach ($name in 'gclo.Engine', 'gclo.ViewModels', 'gclo') {
$rates = foreach ($f in $files) {
[xml]$doc = Get-Content -LiteralPath $f.FullName
$pkg = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name }
if ($pkg) { [double]::Parse($pkg.'line-rate', [System.Globalization.CultureInfo]::InvariantCulture) }
}
if (-not $rates) { throw "Package '$name' not found in any coverage report." }
$best = ($rates | Measure-Object -Maximum).Maximum
Write-Output "$name line coverage: $([math]::Round($best * 100, 2))% (required 100%)"
if ($best -lt 1.0) { throw "Coverage gate FAILED: $name below 100%." }
}
dotnet format gclo.slnx --verify-no-changes --severity warn --no-restore
+30
View File
@@ -0,0 +1,30 @@
# Gitea release for gclo: tag v* -> verified, built, Authenticode-signed (Azure Artifact Signing),
# Velopack-packed and published to this repository's Gitea release. Replaces release.yml's
# build/sign/publish path. Gitea issues no OIDC token, so the identity is the existing
# gclo-release-signing Entra app authenticated by the AZURE_CLIENT_SECRET repository secret
# (variables AZURE_* / ARTIFACT_SIGNING_*). A missing value fails the run; nothing unsigned is published.
name: release
on:
push:
tags: ['v*']
jobs:
release:
runs-on: kof-windows-amd64
timeout-minutes: 90
env:
CI: 'true'
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
DOTNET_NOLOGO: '1'
AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
ARTIFACT_SIGNING_ENDPOINT: ${{ vars.ARTIFACT_SIGNING_ENDPOINT }}
ARTIFACT_SIGNING_ACCOUNT: ${{ vars.ARTIFACT_SIGNING_ACCOUNT }}
ARTIFACT_SIGNING_PROFILE: ${{ vars.ARTIFACT_SIGNING_PROFILE }}
ARTIFACT_SIGNING_SUBJECT: ${{ vars.ARTIFACT_SIGNING_SUBJECT }}
TOKEN: ${{ secrets.PACKAGES_TOKEN || github.token }}
steps:
- uses: builtin:checkout
- name: Release
shell: pwsh
run: ./.gitea/scripts/Release.ps1
-3
View File
@@ -145,9 +145,6 @@ together are the "Core": all logic lives there, under the coverage gate.
`UiTestFixture`, which answers from a fixed offline organization only when
`GCLO_UITEST_FIXTURE=1` **and** the token is the fixture's magic token. `Program.cs`
replaces the XAML-generated `Main` so `VelopackApp.Build().Run()` executes first, and
ends at once when another window holds the data folder (`WindowClaim` in
`gclo.ViewModels`: one window per data folder, the open one is asked to come to
the front), and
switches `GcloPaths.Edition` to `dev` for the `gclo-dev` app id (the dev channel
installs beside stable with its own data root). `ImplicitUsings` is off in this
project only. Handlers and `x:Bind` targets look unused to analyzers; the relaxations
+2 -2
View File
@@ -53,8 +53,8 @@
built-in SourceLink appends the git commit as "+<sha>" build metadata on
AssemblyInformationalVersion, which the About dialog and the CLI's
version output surface as the short hash. Prefix tracks the next expected
release: 1.2.0, which carries the fixes that were planned as 1.1.1 and
Gitea support together (there is no release numbered 1.1.1). -->
release: 1.2.0, the version that brings Gitea support (1.1.1 is cut from
the line before it). -->
<PropertyGroup>
<VersionPrefix>1.2.0</VersionPrefix>
<VersionSuffix>dev</VersionSuffix>
+1 -3
View File
@@ -117,13 +117,11 @@ On launch, gclo shows a brief branded splash and then opens where you left off:
**Settings** (File > Settings…) let you set default tokens (one for GitHub, and one for a Gitea server together with its address; stored in the Windows Credential Manager), the default target folder (with a folder picker), the default parallelism, and the theme (System, Light, or Dark); an Advanced section toggles the startup splash and its duration.
**One window:** gclo opens one window per user. Starting it again brings the open window to the front instead of opening a second one (two windows would each keep their own copy of the settings and undo each other's changes). The command line is not affected and can run beside the app.
**Activity log:** View > Activity log… shows the tail of today's log and links to the logs folder. Logs live under `%LOCALAPPDATA%\KofTwentyTwo\gclo\logs`, one file per day, kept for 30 days; they record run parameters, per-repo failures, and when the window stopped responding (for how long, and what was running), and never contain your token (see [SECURITY.md](SECURITY.md)).
**Share:** Help > Share gclo by email… opens a new message in your email program with an invitation already written: what gclo is, where to download the latest release, and how to install it. Type an address and send; gclo itself sends nothing. If no email program is set up, the text is shown and copied to the clipboard, to paste into a message by hand.
**Updates:** Help > Check for updates… downloads and applies the latest release for your channel from the project's releases on git.kof22.com. Versions up to 1.1.0 look for updates on GitHub; if they do not find 1.2.0 there, install it once by hand, and it updates itself from then on. Self-update is only available in installed builds (not the portable zip or a local debug build).
**Updates:** Help > Check for updates… downloads and applies the latest release for your channel from the project's releases on git.kof22.com. Versions up to 1.1.0 look for updates on GitHub; if they do not find 1.1.1 there, install it once by hand, and it updates itself from then on. Self-update is only available in installed builds (not the portable zip or a local debug build).
### Saved accounts
+2 -2
View File
@@ -537,7 +537,7 @@ its offset from UTC (`2026-11-10 14:03:22.123 -06:00 [INFO] ...`). Text that cam
Codes 3 and 4 were part of code 2 before version 1.1; a script that tested
`-eq 2` for "usage error" should treat `3` and `4` as distinct outcomes.
Before version 1.2, a refusal of access other than an invisible organization
Before version 1.1.1, a refusal of access other than an invisible organization
(403), a rate limit answered with 429, a network failure, a timeout, and an
error on GitHub's side ended with code 70; they now end with `3` (the refusal)
or `4` (the rest).
@@ -653,7 +653,7 @@ done
```console
$ gclo --version
1.2.0 (a893741d2)
1.1.1 (a893741d2)
```
Released builds print the semantic version and the short commit hash; local builds print the next-release version with a `-dev` suffix (e.g. `1.1.2-dev (<hash>)`).
@@ -29,8 +29,7 @@ would install an old version for ever.
- Versions up to 1.1.0 look for updates on GitHub. They find a later version only
if it is also published there; otherwise they are updated once by hand. From
1.2.0 on the app looks on `git.kof22.com` (the change was made for a 1.1.1
that was not released on its own; it ships in 1.2.0).
1.1.1 on the app looks on `git.kof22.com`.
- The availability and the integrity of updates now rest on a self-hosted server
instead of GitHub's release hosting. The updater verifies a package against the
hash in the feed, and the feed comes from the same server; it checks no signature
+1 -2
View File
@@ -4,7 +4,7 @@
review it in every MINOR/MAJOR release checklist (K22-SEC-30).
Method: data-flow diagram + STRIDE per trust boundary. -->
- **Version reviewed:** v1.2.0 (in work; the rows dated 2026-11-10 were written for a 1.1.1 that ships as part of 1.2.0) · **Last reviewed:** 2026-11-11
- **Version reviewed:** v1.1.1 · **Last reviewed:** 2026-11-10
## 1. What the product is and does
@@ -137,4 +137,3 @@ flowchart LR
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 extended for the desktop app's Add account wizard (#206): the place is chosen before the token is typed, the token is checked with that place only, and nothing typed for one place is carried to another. |
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 extended for Quick Sync (#209): GitHub and Gitea are equal on the card the app opens on; nothing is preselected and nothing is sent without the user asking; `settings.json` now also holds the place Quick Sync last connected to (a server address, no token). |
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 restated for a default token per place (#212): Settings holds one for GitHub and one for a Gitea server, each under a vault entry named after its place; `settings.json` holds the address of that server. The rule "the default token is a GitHub token" is replaced by "a default token is never used for another place than its own". |
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | One window per data folder (#216): the app holds `window.lock` in its data folder while its window is open, and a second start asks the open window to come to the front through a named event and ends. No new trust boundary: the file and the event are the user's own. A process running as the same user can hold the file (gclo then opens no window) or raise the event (the window comes to the front); such a process can already read and change everything gclo keeps (T6, T13). Nothing secret is in either. |
-175
View File
@@ -1,175 +0,0 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Runtime.Versioning;
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// <see cref="WindowClaim"/>: one window per data folder (#216). The first claim
/// on a folder is the only one, a second is not and can ask the first to come to
/// the front, another folder is another claim, and a claim that is given up is free.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class WindowClaimTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root);
[Fact]
public void TheFirstClaimOnAFolder_IsTheOnlyOne_AndASecondIsNot_UntilTheFirstIsGivenUp()
{
string folder = Path.Combine(_root, "data");
using(var first = WindowClaim.For(folder))
{
Assert.True(first.IsTheOnlyOne);
Assert.True(File.Exists(Path.Combine(folder, "window.lock"))); // the folder was made for it
// The same folder, spelled another way: the same claim.
using(var second = WindowClaim.For(folder.ToUpperInvariant() + Path.DirectorySeparatorChar))
{
Assert.False(second.IsTheOnlyOne);
}
// A second that came and went does not free the first's claim.
using var third = WindowClaim.For(folder);
Assert.False(third.IsTheOnlyOne);
// Another folder is another claim.
using var elsewhere = WindowClaim.For(Path.Combine(_root, "other"));
Assert.True(elsewhere.IsTheOnlyOne);
}
// Given up: the file is gone, and the next start is the only one again.
Assert.False(File.Exists(Path.Combine(folder, "window.lock")));
using var next = WindowClaim.For(folder);
Assert.True(next.IsTheOnlyOne);
}
[Fact]
public void ASecondStart_AsksTheWindowThatHoldsTheFolder_ToComeToTheFront()
{
string folder = Path.Combine(_root, "data");
using var first = WindowClaim.For(folder);
using var replaced = new ManualResetEventSlim();
using var asked = new SemaphoreSlim(0);
first.OnAskedToShow(replaced.Set);
first.OnAskedToShow(() => asked.Release()); // set again: the one before is not called any more
using(var second = WindowClaim.For(folder))
{
Assert.False(second.IsTheOnlyOne);
second.AskTheOtherToShowItself();
}
Assert.True(asked.Wait(TimeSpan.FromSeconds(10)), "the first window was not asked to show itself");
// Once for each request.
using(var again = WindowClaim.For(folder))
{
again.AskTheOtherToShowItself();
}
Assert.True(asked.Wait(TimeSpan.FromSeconds(10)), "the first window was not asked a second time");
Assert.False(replaced.IsSet);
}
[Fact]
public void ARequestForAnotherFolder_DoesNotReachThisWindow()
{
using var here = WindowClaim.For(Path.Combine(_root, "data"));
using var asked = new ManualResetEventSlim();
here.OnAskedToShow(asked.Set);
using(var elsewhere = WindowClaim.For(Path.Combine(_root, "other")))
{
elsewhere.AskTheOtherToShowItself();
}
Assert.False(asked.Wait(TimeSpan.FromMilliseconds(300)));
}
[Fact]
public void AFolderThatCannotBeWrittenTo_HoldsNoClaim_AndStopsNoWindow()
{
// The data folder's path is taken by a file: no folder can be made there, and no lock file in it.
Directory.CreateDirectory(_root);
string blocked = Path.Combine(_root, "a-file");
File.WriteAllText(blocked, "not a folder");
using var first = WindowClaim.For(blocked);
using var second = WindowClaim.For(blocked);
Assert.True(first.IsTheOnlyOne);
Assert.True(second.IsTheOnlyOne);
}
[Fact]
public void WhenTheRequestToComeToTheFrontCannotBeMade_TheClaimStands_AndNothingFails()
{
// The name of the event is taken by something that is no event (it could as well be one this process may not use).
string folder = Path.Combine(_root, "data");
using var inTheWay = new Mutex(initiallyOwned: false, $@"Local\gclo-show-{WindowClaim.KeyFor(folder)}");
using var first = WindowClaim.For(folder);
using var second = WindowClaim.For(folder);
using var asked = new ManualResetEventSlim();
first.OnAskedToShow(asked.Set);
second.AskTheOtherToShowItself();
Assert.True(first.IsTheOnlyOne);
Assert.False(second.IsTheOnlyOne);
Assert.False(asked.Wait(TimeSpan.FromMilliseconds(300))); // nothing is asked, and nothing is heard
}
[Fact]
public void TheKeyOfAFolder_IsTheSameForEverySpellingOfIt_AndAnotherForAnotherFolder()
{
string folder = Path.Combine(_root, "Data");
string key = WindowClaim.KeyFor(folder);
Assert.Equal(32, key.Length);
Assert.Matches("^[0-9A-F]{32}$", key);
Assert.Equal(key, WindowClaim.KeyFor(folder.ToLowerInvariant()), StringComparer.Ordinal);
Assert.Equal(key, WindowClaim.KeyFor(folder + Path.DirectorySeparatorChar), StringComparer.Ordinal);
Assert.Equal(key, WindowClaim.KeyFor(Path.Combine(_root, "other", "..", "Data")), StringComparer.Ordinal);
Assert.NotEqual(key, WindowClaim.KeyFor(Path.Combine(_root, "Data2")), StringComparer.Ordinal);
}
[Fact]
public void AClaim_NeedsAFolder_AndSomethingToDoWhenAsked()
{
Assert.Throws<ArgumentException>(() => WindowClaim.For(" "));
Assert.Throws<ArgumentNullException>(() => WindowClaim.For(null!));
Assert.Throws<ArgumentException>(() => WindowClaim.KeyFor(""));
using var claim = WindowClaim.For(Path.Combine(_root, "data"));
Assert.Throws<ArgumentNullException>(() => claim.OnAskedToShow(null!));
// Given up twice is given up.
claim.Dispose();
}
}
-41
View File
@@ -429,45 +429,4 @@ public sealed partial class SmokeTests : UiTestBase
[GeneratedRegex(@"^Version \d+\.\d+\.\d+", RegexOptions.None, 1000)]
private static partial Regex VersionPattern();
// (12) One window per data folder (#216). gclo started again on the data folder
// of the open window opens no second window: the process ends by itself,
// and the window that is open comes out of the taskbar. The second process
// is the one this test starts, and the only one it ever ends.
[Fact]
public void StartingGcloAgain_OnTheSameDataFolder_EndsAtOnce_AndBringsTheOpenWindowBack()
{
FlaUI.Core.Patterns.IWindowPattern window = Session.MainWindow.Patterns.Window.Pattern;
window.SetWindowVisualState(FlaUI.Core.Definitions.WindowVisualState.Minimized);
Session.WaitFor(
() => window.WindowVisualState.Value == FlaUI.Core.Definitions.WindowVisualState.Minimized ? Session.MainWindow : null,
"the window to be minimized");
var startInfo = new System.Diagnostics.ProcessStartInfo(Session.ExePath)
{
WorkingDirectory = Path.GetDirectoryName(Session.ExePath) ?? "",
};
startInfo.Environment["GCLO_DATA_DIR"] = Session.DataDirectory;
startInfo.Environment["GCLO_UITEST_FIXTURE"] = "1";
using System.Diagnostics.Process second = System.Diagnostics.Process.Start(startInfo)!;
try
{
Assert.True(second.WaitForExit(TimeSpan.FromSeconds(30)), "the second gclo did not end by itself");
Assert.Equal(0, second.ExitCode);
Session.WaitFor(
() => window.WindowVisualState.Value != FlaUI.Core.Definitions.WindowVisualState.Minimized ? Session.MainWindow : null,
"the open window to come back from the taskbar");
Assert.False(Session.App.HasExited);
}
finally
{
if(!second.HasExited)
{
second.Kill();
}
}
}
}
-149
View File
@@ -1,149 +0,0 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
namespace gclo.ViewModels;
/// <summary>
/// One gclo window per data folder (#216). Two windows on one folder each kept
/// their own copy of the settings and of the accounts, and the one that saved
/// last undid what the other had saved. A window therefore claims its data
/// folder while it is open; a second start finds the claim, asks the open window
/// to come to the front, and ends.
/// <para>
/// The claim is a file in the data folder (<c>window.lock</c>) held open and
/// shared with nobody: Windows gives it up when the process ends, however it
/// ends, and the file is removed with it. The request to come to the front is a
/// named event, named after the folder. Another data folder (the dev channel
/// beside the stable one, <c>GCLO_DATA_DIR</c>) is another claim.
/// </para>
/// <para>
/// The command line makes no claim: it opens no window and may run beside the app.
/// </para>
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class WindowClaim : IDisposable
{
/// <summary>The file is open in another process (ERROR_SHARING_VIOLATION as an HRESULT).</summary>
private const int SharingViolation = unchecked((int)0x80070020);
private readonly FileStream? _held;
/// <summary>Null when the event could not be made or opened: nothing is asked then, and nothing heard.</summary>
private readonly EventWaitHandle? _show;
private RegisteredWaitHandle? _waiting;
private WindowClaim(FileStream? held, bool isTheOnlyOne, EventWaitHandle? show)
{
_held = held;
IsTheOnlyOne = isTheOnlyOne;
_show = show;
}
/// <summary>
/// Claims <paramref name="dataRoot"/> for a window of this process. When
/// another window holds it, <see cref="IsTheOnlyOne"/> is false. A folder that
/// cannot be written to holds no claim and stops no window from opening: what
/// cannot be saved there is reported where it is saved.
/// </summary>
public static WindowClaim For(string dataRoot)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dataRoot);
FileStream? held = null;
bool anotherHoldsIt = false;
try
{
Directory.CreateDirectory(dataRoot);
held = new FileStream(
Path.Combine(dataRoot, "window.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None,
bufferSize: 1, FileOptions.DeleteOnClose);
}
catch(IOException ex) when(ex.HResult == SharingViolation)
{
anotherHoldsIt = true;
}
catch(Exception ex) when(ex is IOException or UnauthorizedAccessException)
{
// No claim can be made here (a read-only profile, a path that is no folder): the window opens all the same.
}
EventWaitHandle? show = null;
try
{
show = new EventWaitHandle(initialState: false, EventResetMode.AutoReset, $@"Local\gclo-show-{KeyFor(dataRoot)}");
}
catch(Exception ex) when(ex is UnauthorizedAccessException or WaitHandleCannotBeOpenedException or IOException)
{
/////////////////////////////////////////////////////////////////////////////
// The name is taken by something this process may not use (a gclo that //
// runs elevated made it), or by something that is no event. The claim on //
// the folder stands as the file said; only the request to come to the //
// front is not made. This runs at every start: it must not end one. //
/////////////////////////////////////////////////////////////////////////////
}
return new WindowClaim(held, !anotherHoldsIt, show);
}
/// <summary>
/// What the request to come to the front is named after: the folder as Windows
/// knows it, whatever its spelling (case, a separator at the end, a relative path).
/// </summary>
public static string KeyFor(string dataRoot)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dataRoot);
string folder = Path.TrimEndingDirectorySeparator(Path.GetFullPath(dataRoot)).ToUpperInvariant();
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(folder)), 0, 16);
}
/// <summary>True when no other window holds the data folder: this process may open one.</summary>
public bool IsTheOnlyOne { get; }
/// <summary>Asks the window that holds the data folder to come to the front.</summary>
public void AskTheOtherToShowItself() => _show?.Set();
/// <summary>
/// What the window that holds the claim does when another start asks it to come
/// to the front. <paramref name="show"/> runs on a thread of the pool, once for
/// each request; setting it again replaces the one before.
/// </summary>
public void OnAskedToShow(Action show)
{
ArgumentNullException.ThrowIfNull(show);
_waiting?.Unregister(null);
_waiting = _show is null
? null
: ThreadPool.RegisterWaitForSingleObject(_show, (_, _) => show(), state: null, Timeout.Infinite, executeOnlyOnce: false);
}
/// <summary>Gives the claim up: the next start opens a window of its own.</summary>
public void Dispose()
{
_waiting?.Unregister(null);
_waiting = null;
_show?.Dispose();
_held?.Dispose();
}
}
+2 -12
View File
@@ -79,17 +79,7 @@ public partial class App : Application
/// <param name="args">Details about the launch request and process.</param>
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
{
try
{
_window = new MainWindow();
_window.Activate();
}
catch
{
// No window came of it, and this process may live on (the handler above keeps it alive): it must not
// hold the data folder, or every later start would end at once with nothing to bring to the front (#216).
Program.Claim?.Dispose();
throw;
}
_window = new MainWindow();
_window.Activate();
}
}
-37
View File
@@ -52,9 +52,6 @@ public sealed partial class MainWindow : Window
/// <summary>Set once the root's theme-change subscription exists.</summary>
private bool _themeHooked;
/// <summary>Set when the window has closed: a request to come to the front that arrives after that is dropped (#216).</summary>
private bool _closed;
private readonly AppSettings _settings;
private readonly UpdateService _updateService;
@@ -206,16 +203,8 @@ public sealed partial class MainWindow : Window
presenter.PreferredMinimumHeight = (int)(MinWindowHeight * scale);
}
// Another start of gclo on this data folder ends at once and asks this window to come to the front (#216).
Program.Claim?.OnAskedToShow(() => queue.TryEnqueue(ComeToTheFront));
Closed += (_, _) =>
{
// First: from here on a new start opens a window of its own, and does not ask one that is closing.
// A request that was already on its way finds the window closed and does nothing.
_closed = true;
Program.Claim?.Dispose();
// The workspace that is open now is where the next start opens: written here, whatever was written before.
_settings.SaveWhatWasRemembered(_log);
responsiveness.Dispose();
@@ -1657,32 +1646,6 @@ public sealed partial class MainWindow : Window
/// <summary>
/// Brings this window to the front because gclo was started again (#216): out of
/// the taskbar if it was minimized, and before the other windows.
/// </summary>
private void ComeToTheFront()
{
if(_closed)
{
return;
}
if(AppWindow.Presenter is OverlappedPresenter { State: OverlappedPresenterState.Minimized } presenter)
{
presenter.Restore();
}
Activate();
_ = SetForegroundWindow(WinRT.Interop.WindowNative.GetWindowHandle(this));
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern uint GetDpiForWindow(IntPtr hwnd);
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern int SetForegroundWindow(IntPtr hwnd);
}
-43
View File
@@ -20,19 +20,6 @@ namespace gclo;
/// </summary>
public static class Program
{
/// <summary>ASFW_ANY: any process may bring its window to the front.</summary>
private const int AnyProcess = -1;
/// <summary>
/// This process's claim on the data folder (#216): held from here until the
/// main window closes. Null only before <see cref="Main"/> has made it.
/// </summary>
internal static WindowClaim? Claim { get; private set; }
[STAThread]
private static void Main()
{
@@ -47,31 +34,6 @@ public static class Program
GcloPaths.Edition = "dev";
}
/////////////////////////////////////////////////////////////////////////////
// One window per data folder (#216). Another window on this folder would //
// keep its own copy of the settings and accounts and undo what this one //
// saves. The one that is open is asked to come to the front (this process //
// was started by the user, so it may pass that right on), and this one //
// ends before any window or file of its own exists. //
/////////////////////////////////////////////////////////////////////////////
try
{
Claim = WindowClaim.For(GcloPaths.DataRoot);
}
catch(Exception)
{
// No log exists yet, and nothing here is worth not opening for: without a claim the window opens as it always did.
Claim = null;
}
if(Claim is { IsTheOnlyOne: false })
{
_ = AllowSetForegroundWindow(AnyProcess);
Claim.AskTheOtherToShowItself();
Claim.Dispose();
return;
}
// The remainder mirrors the XAML-generated Main (obj\...\App.g.i.cs) verbatim.
global::WinRT.ComWrappersSupport.InitializeComWrappers();
global::Microsoft.UI.Xaml.Application.Start((p) =>
@@ -81,9 +43,4 @@ public static class Program
_ = new App();
});
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern int AllowSetForegroundWindow(int processId);
}