Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e82c75c936
|
No files matched your search
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user