Compare commits

...
3 Commits
Author SHA1 Message Date
james.maesandClaude Fable 5.2 e2e099ed08 feat(app): gclo opens one window per data folder, and a second start brings the open one to the front (#217)
windows / windows (push) Successful in 3m7s
build / windows (push) Successful in 3m7s
Two windows on one data folder each kept their own copy of the settings and
accounts, and the one that saved last undid what the other had saved. The
owner's decision (#216): one window per data folder.

- WindowClaim (gclo.ViewModels): a window holds window.lock in its data
  folder while it is open, shared with nobody. Windows gives it up when the
  process ends, however it ends. A folder that cannot be written to holds no
  claim and stops no window from opening.
- A second start finds the claim, asks the open window to come to the front
  through an event named after the folder, and ends before any window or
  file of its own exists. The open window comes out of the taskbar and
  before the other windows.
- The claim is given up first thing when the window closes, so a start right
  after closing opens a window of its own.
- Another data folder is another claim: the dev channel beside the stable
  one, and GCLO_DATA_DIR. The command line makes no claim.
- README, threat model (review log), CLAUDE.md.

Included, from the review of this pull request:
- fix(app): the claim on the data folder never stops a start, and is given up when no window came of it

Closes #216

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 22:55:16 +00:00
forge-recovery 9ab80f8c93 ci(gitea): build, test, coverage and format on the Windows host runner (#220)
windows / windows (push) Successful in 6m40s
build / windows (push) Successful in 6m40s
2026-11-11 22:42:24 +00:00
james.maesandClaude Fable 5.2 fa5887e6b3 docs(release): the documents name 1.2.0 as the release that moves updates to git.kof22.com (#219)
There is no release numbered 1.1.1: the fixes planned for it and Gitea
support go out together as 1.2.0 (owner's decision, #200). The README, the
CLI guide, ADR 0009, the header of the threat model and a comment in
Directory.Build.props still named 1.1.1.

docs/RELEASING.md is left to the work on the release pipeline (#197).

Closes #218

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 22:40:54 +00:00
13 changed files with 503 additions and 9 deletions

No files matched your search

+32
View File
@@ -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
+3
View File
@@ -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
+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, 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>
+3 -1
View File
@@ -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
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.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
+2 -1
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.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. |
+175
View File
@@ -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();
}
}
+41
View File
@@ -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();
}
}
}
}
+149
View File
@@ -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
View File
@@ -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;
}
}
}
+37
View File
@@ -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);
}
+43
View File
@@ -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);
}