Compare commits
3
Commits
ci/gitea-release
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2e099ed08
|
||
|
|
9ab80f8c93
|
||
|
|
fa5887e6b3
|
No files matched your search
@@ -0,0 +1,32 @@
|
||||
# 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
|
||||
@@ -145,6 +145,9 @@ 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, the version that brings Gitea support (1.1.1 is cut from
|
||||
the line before it). -->
|
||||
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). -->
|
||||
<PropertyGroup>
|
||||
<VersionPrefix>1.2.0</VersionPrefix>
|
||||
<VersionSuffix>dev</VersionSuffix>
|
||||
|
||||
@@ -117,11 +117,13 @@ 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.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).
|
||||
**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).
|
||||
|
||||
### 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.1.1, a refusal of access other than an invisible organization
|
||||
Before version 1.2, 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.1.1 (a893741d2)
|
||||
1.2.0 (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,7 +29,8 @@ 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.1.1 on the app looks on `git.kof22.com`.
|
||||
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).
|
||||
- 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.1.1 · **Last reviewed:** 2026-11-10
|
||||
- **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
|
||||
|
||||
## 1. What the product is and does
|
||||
|
||||
@@ -137,3 +137,4 @@ 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. |
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* 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,4 +429,45 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -79,7 +79,17 @@ 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)
|
||||
{
|
||||
_window = new MainWindow();
|
||||
_window.Activate();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,9 @@ 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;
|
||||
@@ -203,8 +206,16 @@ 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();
|
||||
@@ -1646,6 +1657,32 @@ 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,6 +20,19 @@ 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()
|
||||
{
|
||||
@@ -34,6 +47,31 @@ 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) =>
|
||||
@@ -43,4 +81,9 @@ 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