Compare commits

..
Author SHA1 Message Date
james.maesandClaude Fable 5.2 1cd3f98277 fix(app): a default token that is gone when Save is pressed is not taken up, and the saved default is not given up for it
Last finding of the review of the default token per forge (#212).

Taking up a token the vault still holds for another server removes the
default that is saved. Whether the vault holds that token was asked when the
address was typed. Removed elsewhere before Save (another window, Windows'
Credential Manager), the saved default was still removed, which left no
default token at all under the new address.

The token is looked for again when Save is pressed. Without it nothing is
changed: the address stays, the saved token stays, and the log says so.

Refs #212

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 16:28:16 -06:00
james.maesandClaude Fable 5.2 b49f5e8d38 fix(app): a window that closes does not put older settings back, and a token typed behind the mask is not rewritten in its box
Findings of the review of the previous fix commit (#212).

- What the app remembers by itself (the workspace that was open, Quick
  Sync's connection) is written over settings.json as it is on disk at that
  moment, those values only. With two windows open, the one that closed last
  wrote its whole older copy, which dropped settings saved in the other in
  the meantime, among them the address of the default Gitea server. A file
  that cannot be read at that moment is left alone, and the log says so.
- A default token the vault still holds for a server can be taken up while
  another server is the default. It was refused, so that token could not be
  seen or removed without typing a new one. Taking it up removes the other
  server's default token, as typing a token for another server does, and the
  caption says so and names the accounts that depend on it.
- The Settings token boxes are no longer rewritten while they are typed into
  (that could move the caret and scramble the token). The mask is taken out
  where the value is read and from what is stored.
- The vault is asked once for each server while an address is typed, and a
  credential store that cannot be read just then does not break typing.
- Two assertions that could not fail are replaced.
- ADR 0010.

Refs #212, #216

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 16:28:16 -06:00
james.maesandClaude Fable 5.2 46c246ce92 fix(app): a default token is never left out of sight, and a wizard is given the default token of its own place only
Findings of the review of the default token per forge (#212).

- A Gitea default token could stay in Credential Manager with nothing in
  Settings pointing at it (settings.json not written, or written back by an
  older gclo): still used for that server, but not shown and not removable.
  An address entered in Settings that already has a default token is now
  taken up as the default it is, so it can be seen, replaced and removed.
- Moving the Gitea default to another server stores the new token first and
  removes the old one after: a store that fails leaves the old default whole.
- The wizards for editing an account and for saving a Quick Sync connection
  take the default token per place, as the wizard for a new account does. A
  caller that knows one default token gives GitHub's, and it is offered and
  sent for GitHub only. The rule was in the shell; it is in the view model
  and has tests.
- "Open where you left off" is written whenever the window closes, not only
  when it differs from what was read at the start.
- Messages that tell two servers apart name the whole address, not the host
  (two servers can share a host).
- An address typed over and typed back is the server again without the box
  being left (Load and Find stayed withheld). A token typed behind the mask
  in Settings is stored without the mask's dots.
- A saved token without a server is refused when the Gitea group is made.
- Tests for: servers under a path on one host, a wizard for an account whose
  place cannot be used, a token typed with the address taken out, path
  recovery while the address is no place, GitHub having no address to edit.
  A test on a lookup in flight fails fast instead of waiting for ever.
- README, ADR 0010, threat model T13, comments and test names that still
  said "the default token is GitHub's".

Refs #212

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 16:28:15 -06:00
james.maesandClaude Fable 5.2 b12192f545 feat(app): Settings holds a default token per forge, and a default token is used only for the place it was saved for
Step 5 of Gitea support (#201). Settings was all about GitHub; GitHub and
Gitea are equal options there too.

- Settings has a "Default tokens" section with two groups, named as
  everywhere else: GitHub (token) and Gitea (the server's address and its
  token). The Gitea address and token are saved, replaced and removed
  together; the saved token is never carried to another address, and a token
  for an address that cannot be used is not saved (Save is withheld, with the
  reason).
- The rule "the default token is a GitHub token" is replaced by: a default
  token belongs to one place and is never used for another. It is in the name
  of the vault entry: GitHub's is the one it always was, a Gitea server's is
  made from the server's address. Asking for the default token of a place
  finds the one saved for exactly that place, or none.
- Quick Sync fills in the default token of the place on its card.
- An account that uses the default token gets the one of its own place. The
  wizard offers the choice for a place that has one; accounts add and
  accounts edit take --use-default-token for any place that has one.
- A workspace is told which place's default token changed, and takes only its
  own.
- README, docs/CLI.md, ADR 0010, threat model T16.

Closes #212

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

No files matched your search

-32
View File
@@ -1,32 +0,0 @@
# Gitea CI for gclo on the Windows host runner. Mirrors .github/workflows/ci.yml
# (build-test + format); the FlaUI ui-tests job needs an interactive desktop and stays on GitHub.
# Push only: host runners must never run fork pull requests.
name: build
on:
push:
branches: ['**']
tags: ['v*']
jobs:
windows:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/windows.yaml@v1
secrets: inherit
with:
commands: |
dotnet restore gclo.slnx --locked-mode
dotnet build gclo.slnx -p:Platform=x64 -warnaserror -p:RestoreLockedMode=true
dotnet test gclo.Engine.Tests --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
dotnet test gclo.Cli.Tests --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
$files = Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml
if (-not $files) { throw 'No coverage.cobertura.xml found under TestResults.' }
foreach ($name in 'gclo.Engine', 'gclo.ViewModels', 'gclo') {
$rates = foreach ($f in $files) {
[xml]$doc = Get-Content -LiteralPath $f.FullName
$pkg = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name }
if ($pkg) { [double]::Parse($pkg.'line-rate', [System.Globalization.CultureInfo]::InvariantCulture) }
}
if (-not $rates) { throw "Package '$name' not found in any coverage report." }
$best = ($rates | Measure-Object -Maximum).Maximum
Write-Output "$name line coverage: $([math]::Round($best * 100, 2))% (required 100%)"
if ($best -lt 1.0) { throw "Coverage gate FAILED: $name below 100%." }
}
dotnet format gclo.slnx --verify-no-changes --severity warn --no-restore
-3
View File
@@ -145,9 +145,6 @@ together are the "Core": all logic lives there, under the coverage gate.
`UiTestFixture`, which answers from a fixed offline organization only when
`GCLO_UITEST_FIXTURE=1` **and** the token is the fixture's magic token. `Program.cs`
replaces the XAML-generated `Main` so `VelopackApp.Build().Run()` executes first, and
ends at once when another window holds the data folder (`WindowClaim` in
`gclo.ViewModels`: one window per data folder, the open one is asked to come to
the front), and
switches `GcloPaths.Edition` to `dev` for the `gclo-dev` app id (the dev channel
installs beside stable with its own data root). `ImplicitUsings` is off in this
project only. Handlers and `x:Bind` targets look unused to analyzers; the relaxations
+2 -2
View File
@@ -53,8 +53,8 @@
built-in SourceLink appends the git commit as "+<sha>" build metadata on
AssemblyInformationalVersion, which the About dialog and the CLI's
version output surface as the short hash. Prefix tracks the next expected
release: 1.2.0, which carries the fixes that were planned as 1.1.1 and
Gitea support together (there is no release numbered 1.1.1). -->
release: 1.2.0, the version that brings Gitea support (1.1.1 is cut from
the line before it). -->
<PropertyGroup>
<VersionPrefix>1.2.0</VersionPrefix>
<VersionSuffix>dev</VersionSuffix>
+1 -3
View File
@@ -117,13 +117,11 @@ On launch, gclo shows a brief branded splash and then opens where you left off:
**Settings** (File > Settings…) let you set default tokens (one for GitHub, and one for a Gitea server together with its address; stored in the Windows Credential Manager), the default target folder (with a folder picker), the default parallelism, and the theme (System, Light, or Dark); an Advanced section toggles the startup splash and its duration.
**One window:** gclo opens one window per user. Starting it again brings the open window to the front instead of opening a second one (two windows would each keep their own copy of the settings and undo each other's changes). The command line is not affected and can run beside the app.
**Activity log:** View > Activity log… shows the tail of today's log and links to the logs folder. Logs live under `%LOCALAPPDATA%\KofTwentyTwo\gclo\logs`, one file per day, kept for 30 days; they record run parameters, per-repo failures, and when the window stopped responding (for how long, and what was running), and never contain your token (see [SECURITY.md](SECURITY.md)).
**Share:** Help > Share gclo by email… opens a new message in your email program with an invitation already written: what gclo is, where to download the latest release, and how to install it. Type an address and send; gclo itself sends nothing. If no email program is set up, the text is shown and copied to the clipboard, to paste into a message by hand.
**Updates:** Help > Check for updates… downloads and applies the latest release for your channel from the project's releases on git.kof22.com. Versions up to 1.1.0 look for updates on GitHub; if they do not find 1.2.0 there, install it once by hand, and it updates itself from then on. Self-update is only available in installed builds (not the portable zip or a local debug build).
**Updates:** Help > Check for updates… downloads and applies the latest release for your channel from the project's releases on git.kof22.com. Versions up to 1.1.0 look for updates on GitHub; if they do not find 1.1.1 there, install it once by hand, and it updates itself from then on. Self-update is only available in installed builds (not the portable zip or a local debug build).
### Saved accounts
+2 -2
View File
@@ -537,7 +537,7 @@ its offset from UTC (`2026-11-10 14:03:22.123 -06:00 [INFO] ...`). Text that cam
Codes 3 and 4 were part of code 2 before version 1.1; a script that tested
`-eq 2` for "usage error" should treat `3` and `4` as distinct outcomes.
Before version 1.2, a refusal of access other than an invisible organization
Before version 1.1.1, a refusal of access other than an invisible organization
(403), a rate limit answered with 429, a network failure, a timeout, and an
error on GitHub's side ended with code 70; they now end with `3` (the refusal)
or `4` (the rest).
@@ -653,7 +653,7 @@ done
```console
$ gclo --version
1.2.0 (a893741d2)
1.1.1 (a893741d2)
```
Released builds print the semantic version and the short commit hash; local builds print the next-release version with a `-dev` suffix (e.g. `1.1.2-dev (<hash>)`).
@@ -29,8 +29,7 @@ would install an old version for ever.
- Versions up to 1.1.0 look for updates on GitHub. They find a later version only
if it is also published there; otherwise they are updated once by hand. From
1.2.0 on the app looks on `git.kof22.com` (the change was made for a 1.1.1
that was not released on its own; it ships in 1.2.0).
1.1.1 on the app looks on `git.kof22.com`.
- The availability and the integrity of updates now rest on a self-hosted server
instead of GitHub's release hosting. The updater verifies a package against the
hash in the feed, and the feed comes from the same server; it checks no signature
+1 -2
View File
@@ -4,7 +4,7 @@
review it in every MINOR/MAJOR release checklist (K22-SEC-30).
Method: data-flow diagram + STRIDE per trust boundary. -->
- **Version reviewed:** v1.2.0 (in work; the rows dated 2026-11-10 were written for a 1.1.1 that ships as part of 1.2.0) · **Last reviewed:** 2026-11-11
- **Version reviewed:** v1.1.1 · **Last reviewed:** 2026-11-10
## 1. What the product is and does
@@ -137,4 +137,3 @@ flowchart LR
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 extended for the desktop app's Add account wizard (#206): the place is chosen before the token is typed, the token is checked with that place only, and nothing typed for one place is carried to another. |
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 extended for Quick Sync (#209): GitHub and Gitea are equal on the card the app opens on; nothing is preselected and nothing is sent without the user asking; `settings.json` now also holds the place Quick Sync last connected to (a server address, no token). |
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 restated for a default token per place (#212): Settings holds one for GitHub and one for a Gitea server, each under a vault entry named after its place; `settings.json` holds the address of that server. The rule "the default token is a GitHub token" is replaced by "a default token is never used for another place than its own". |
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | One window per data folder (#216): the app holds `window.lock` in its data folder while its window is open, and a second start asks the open window to come to the front through a named event and ends. No new trust boundary: the file and the event are the user's own. A process running as the same user can hold the file (gclo then opens no window) or raise the event (the window comes to the front); such a process can already read and change everything gclo keeps (T6, T13). Nothing secret is in either. |
-175
View File
@@ -1,175 +0,0 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Runtime.Versioning;
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// <see cref="WindowClaim"/>: one window per data folder (#216). The first claim
/// on a folder is the only one, a second is not and can ask the first to come to
/// the front, another folder is another claim, and a claim that is given up is free.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class WindowClaimTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root);
[Fact]
public void TheFirstClaimOnAFolder_IsTheOnlyOne_AndASecondIsNot_UntilTheFirstIsGivenUp()
{
string folder = Path.Combine(_root, "data");
using(var first = WindowClaim.For(folder))
{
Assert.True(first.IsTheOnlyOne);
Assert.True(File.Exists(Path.Combine(folder, "window.lock"))); // the folder was made for it
// The same folder, spelled another way: the same claim.
using(var second = WindowClaim.For(folder.ToUpperInvariant() + Path.DirectorySeparatorChar))
{
Assert.False(second.IsTheOnlyOne);
}
// A second that came and went does not free the first's claim.
using var third = WindowClaim.For(folder);
Assert.False(third.IsTheOnlyOne);
// Another folder is another claim.
using var elsewhere = WindowClaim.For(Path.Combine(_root, "other"));
Assert.True(elsewhere.IsTheOnlyOne);
}
// Given up: the file is gone, and the next start is the only one again.
Assert.False(File.Exists(Path.Combine(folder, "window.lock")));
using var next = WindowClaim.For(folder);
Assert.True(next.IsTheOnlyOne);
}
[Fact]
public void ASecondStart_AsksTheWindowThatHoldsTheFolder_ToComeToTheFront()
{
string folder = Path.Combine(_root, "data");
using var first = WindowClaim.For(folder);
using var replaced = new ManualResetEventSlim();
using var asked = new SemaphoreSlim(0);
first.OnAskedToShow(replaced.Set);
first.OnAskedToShow(() => asked.Release()); // set again: the one before is not called any more
using(var second = WindowClaim.For(folder))
{
Assert.False(second.IsTheOnlyOne);
second.AskTheOtherToShowItself();
}
Assert.True(asked.Wait(TimeSpan.FromSeconds(10)), "the first window was not asked to show itself");
// Once for each request.
using(var again = WindowClaim.For(folder))
{
again.AskTheOtherToShowItself();
}
Assert.True(asked.Wait(TimeSpan.FromSeconds(10)), "the first window was not asked a second time");
Assert.False(replaced.IsSet);
}
[Fact]
public void ARequestForAnotherFolder_DoesNotReachThisWindow()
{
using var here = WindowClaim.For(Path.Combine(_root, "data"));
using var asked = new ManualResetEventSlim();
here.OnAskedToShow(asked.Set);
using(var elsewhere = WindowClaim.For(Path.Combine(_root, "other")))
{
elsewhere.AskTheOtherToShowItself();
}
Assert.False(asked.Wait(TimeSpan.FromMilliseconds(300)));
}
[Fact]
public void AFolderThatCannotBeWrittenTo_HoldsNoClaim_AndStopsNoWindow()
{
// The data folder's path is taken by a file: no folder can be made there, and no lock file in it.
Directory.CreateDirectory(_root);
string blocked = Path.Combine(_root, "a-file");
File.WriteAllText(blocked, "not a folder");
using var first = WindowClaim.For(blocked);
using var second = WindowClaim.For(blocked);
Assert.True(first.IsTheOnlyOne);
Assert.True(second.IsTheOnlyOne);
}
[Fact]
public void WhenTheRequestToComeToTheFrontCannotBeMade_TheClaimStands_AndNothingFails()
{
// The name of the event is taken by something that is no event (it could as well be one this process may not use).
string folder = Path.Combine(_root, "data");
using var inTheWay = new Mutex(initiallyOwned: false, $@"Local\gclo-show-{WindowClaim.KeyFor(folder)}");
using var first = WindowClaim.For(folder);
using var second = WindowClaim.For(folder);
using var asked = new ManualResetEventSlim();
first.OnAskedToShow(asked.Set);
second.AskTheOtherToShowItself();
Assert.True(first.IsTheOnlyOne);
Assert.False(second.IsTheOnlyOne);
Assert.False(asked.Wait(TimeSpan.FromMilliseconds(300))); // nothing is asked, and nothing is heard
}
[Fact]
public void TheKeyOfAFolder_IsTheSameForEverySpellingOfIt_AndAnotherForAnotherFolder()
{
string folder = Path.Combine(_root, "Data");
string key = WindowClaim.KeyFor(folder);
Assert.Equal(32, key.Length);
Assert.Matches("^[0-9A-F]{32}$", key);
Assert.Equal(key, WindowClaim.KeyFor(folder.ToLowerInvariant()), StringComparer.Ordinal);
Assert.Equal(key, WindowClaim.KeyFor(folder + Path.DirectorySeparatorChar), StringComparer.Ordinal);
Assert.Equal(key, WindowClaim.KeyFor(Path.Combine(_root, "other", "..", "Data")), StringComparer.Ordinal);
Assert.NotEqual(key, WindowClaim.KeyFor(Path.Combine(_root, "Data2")), StringComparer.Ordinal);
}
[Fact]
public void AClaim_NeedsAFolder_AndSomethingToDoWhenAsked()
{
Assert.Throws<ArgumentException>(() => WindowClaim.For(" "));
Assert.Throws<ArgumentNullException>(() => WindowClaim.For(null!));
Assert.Throws<ArgumentException>(() => WindowClaim.KeyFor(""));
using var claim = WindowClaim.For(Path.Combine(_root, "data"));
Assert.Throws<ArgumentNullException>(() => claim.OnAskedToShow(null!));
// Given up twice is given up.
claim.Dispose();
}
}
-41
View File
@@ -429,45 +429,4 @@ public sealed partial class SmokeTests : UiTestBase
[GeneratedRegex(@"^Version \d+\.\d+\.\d+", RegexOptions.None, 1000)]
private static partial Regex VersionPattern();
// (12) One window per data folder (#216). gclo started again on the data folder
// of the open window opens no second window: the process ends by itself,
// and the window that is open comes out of the taskbar. The second process
// is the one this test starts, and the only one it ever ends.
[Fact]
public void StartingGcloAgain_OnTheSameDataFolder_EndsAtOnce_AndBringsTheOpenWindowBack()
{
FlaUI.Core.Patterns.IWindowPattern window = Session.MainWindow.Patterns.Window.Pattern;
window.SetWindowVisualState(FlaUI.Core.Definitions.WindowVisualState.Minimized);
Session.WaitFor(
() => window.WindowVisualState.Value == FlaUI.Core.Definitions.WindowVisualState.Minimized ? Session.MainWindow : null,
"the window to be minimized");
var startInfo = new System.Diagnostics.ProcessStartInfo(Session.ExePath)
{
WorkingDirectory = Path.GetDirectoryName(Session.ExePath) ?? "",
};
startInfo.Environment["GCLO_DATA_DIR"] = Session.DataDirectory;
startInfo.Environment["GCLO_UITEST_FIXTURE"] = "1";
using System.Diagnostics.Process second = System.Diagnostics.Process.Start(startInfo)!;
try
{
Assert.True(second.WaitForExit(TimeSpan.FromSeconds(30)), "the second gclo did not end by itself");
Assert.Equal(0, second.ExitCode);
Session.WaitFor(
() => window.WindowVisualState.Value != FlaUI.Core.Definitions.WindowVisualState.Minimized ? Session.MainWindow : null,
"the open window to come back from the taskbar");
Assert.False(Session.App.HasExited);
}
finally
{
if(!second.HasExited)
{
second.Kill();
}
}
}
}
-149
View File
@@ -1,149 +0,0 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
namespace gclo.ViewModels;
/// <summary>
/// One gclo window per data folder (#216). Two windows on one folder each kept
/// their own copy of the settings and of the accounts, and the one that saved
/// last undid what the other had saved. A window therefore claims its data
/// folder while it is open; a second start finds the claim, asks the open window
/// to come to the front, and ends.
/// <para>
/// The claim is a file in the data folder (<c>window.lock</c>) held open and
/// shared with nobody: Windows gives it up when the process ends, however it
/// ends, and the file is removed with it. The request to come to the front is a
/// named event, named after the folder. Another data folder (the dev channel
/// beside the stable one, <c>GCLO_DATA_DIR</c>) is another claim.
/// </para>
/// <para>
/// The command line makes no claim: it opens no window and may run beside the app.
/// </para>
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class WindowClaim : IDisposable
{
/// <summary>The file is open in another process (ERROR_SHARING_VIOLATION as an HRESULT).</summary>
private const int SharingViolation = unchecked((int)0x80070020);
private readonly FileStream? _held;
/// <summary>Null when the event could not be made or opened: nothing is asked then, and nothing heard.</summary>
private readonly EventWaitHandle? _show;
private RegisteredWaitHandle? _waiting;
private WindowClaim(FileStream? held, bool isTheOnlyOne, EventWaitHandle? show)
{
_held = held;
IsTheOnlyOne = isTheOnlyOne;
_show = show;
}
/// <summary>
/// Claims <paramref name="dataRoot"/> for a window of this process. When
/// another window holds it, <see cref="IsTheOnlyOne"/> is false. A folder that
/// cannot be written to holds no claim and stops no window from opening: what
/// cannot be saved there is reported where it is saved.
/// </summary>
public static WindowClaim For(string dataRoot)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dataRoot);
FileStream? held = null;
bool anotherHoldsIt = false;
try
{
Directory.CreateDirectory(dataRoot);
held = new FileStream(
Path.Combine(dataRoot, "window.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None,
bufferSize: 1, FileOptions.DeleteOnClose);
}
catch(IOException ex) when(ex.HResult == SharingViolation)
{
anotherHoldsIt = true;
}
catch(Exception ex) when(ex is IOException or UnauthorizedAccessException)
{
// No claim can be made here (a read-only profile, a path that is no folder): the window opens all the same.
}
EventWaitHandle? show = null;
try
{
show = new EventWaitHandle(initialState: false, EventResetMode.AutoReset, $@"Local\gclo-show-{KeyFor(dataRoot)}");
}
catch(Exception ex) when(ex is UnauthorizedAccessException or WaitHandleCannotBeOpenedException or IOException)
{
/////////////////////////////////////////////////////////////////////////////
// The name is taken by something this process may not use (a gclo that //
// runs elevated made it), or by something that is no event. The claim on //
// the folder stands as the file said; only the request to come to the //
// front is not made. This runs at every start: it must not end one. //
/////////////////////////////////////////////////////////////////////////////
}
return new WindowClaim(held, !anotherHoldsIt, show);
}
/// <summary>
/// What the request to come to the front is named after: the folder as Windows
/// knows it, whatever its spelling (case, a separator at the end, a relative path).
/// </summary>
public static string KeyFor(string dataRoot)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dataRoot);
string folder = Path.TrimEndingDirectorySeparator(Path.GetFullPath(dataRoot)).ToUpperInvariant();
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(folder)), 0, 16);
}
/// <summary>True when no other window holds the data folder: this process may open one.</summary>
public bool IsTheOnlyOne { get; }
/// <summary>Asks the window that holds the data folder to come to the front.</summary>
public void AskTheOtherToShowItself() => _show?.Set();
/// <summary>
/// What the window that holds the claim does when another start asks it to come
/// to the front. <paramref name="show"/> runs on a thread of the pool, once for
/// each request; setting it again replaces the one before.
/// </summary>
public void OnAskedToShow(Action show)
{
ArgumentNullException.ThrowIfNull(show);
_waiting?.Unregister(null);
_waiting = _show is null
? null
: ThreadPool.RegisterWaitForSingleObject(_show, (_, _) => show(), state: null, Timeout.Infinite, executeOnlyOnce: false);
}
/// <summary>Gives the claim up: the next start opens a window of its own.</summary>
public void Dispose()
{
_waiting?.Unregister(null);
_waiting = null;
_show?.Dispose();
_held?.Dispose();
}
}
+2 -12
View File
@@ -79,17 +79,7 @@ public partial class App : Application
/// <param name="args">Details about the launch request and process.</param>
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
{
try
{
_window = new MainWindow();
_window.Activate();
}
catch
{
// No window came of it, and this process may live on (the handler above keeps it alive): it must not
// hold the data folder, or every later start would end at once with nothing to bring to the front (#216).
Program.Claim?.Dispose();
throw;
}
_window = new MainWindow();
_window.Activate();
}
}
-37
View File
@@ -52,9 +52,6 @@ public sealed partial class MainWindow : Window
/// <summary>Set once the root's theme-change subscription exists.</summary>
private bool _themeHooked;
/// <summary>Set when the window has closed: a request to come to the front that arrives after that is dropped (#216).</summary>
private bool _closed;
private readonly AppSettings _settings;
private readonly UpdateService _updateService;
@@ -206,16 +203,8 @@ public sealed partial class MainWindow : Window
presenter.PreferredMinimumHeight = (int)(MinWindowHeight * scale);
}
// Another start of gclo on this data folder ends at once and asks this window to come to the front (#216).
Program.Claim?.OnAskedToShow(() => queue.TryEnqueue(ComeToTheFront));
Closed += (_, _) =>
{
// First: from here on a new start opens a window of its own, and does not ask one that is closing.
// A request that was already on its way finds the window closed and does nothing.
_closed = true;
Program.Claim?.Dispose();
// The workspace that is open now is where the next start opens: written here, whatever was written before.
_settings.SaveWhatWasRemembered(_log);
responsiveness.Dispose();
@@ -1657,32 +1646,6 @@ public sealed partial class MainWindow : Window
/// <summary>
/// Brings this window to the front because gclo was started again (#216): out of
/// the taskbar if it was minimized, and before the other windows.
/// </summary>
private void ComeToTheFront()
{
if(_closed)
{
return;
}
if(AppWindow.Presenter is OverlappedPresenter { State: OverlappedPresenterState.Minimized } presenter)
{
presenter.Restore();
}
Activate();
_ = SetForegroundWindow(WinRT.Interop.WindowNative.GetWindowHandle(this));
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern uint GetDpiForWindow(IntPtr hwnd);
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern int SetForegroundWindow(IntPtr hwnd);
}
-43
View File
@@ -20,19 +20,6 @@ namespace gclo;
/// </summary>
public static class Program
{
/// <summary>ASFW_ANY: any process may bring its window to the front.</summary>
private const int AnyProcess = -1;
/// <summary>
/// This process's claim on the data folder (#216): held from here until the
/// main window closes. Null only before <see cref="Main"/> has made it.
/// </summary>
internal static WindowClaim? Claim { get; private set; }
[STAThread]
private static void Main()
{
@@ -47,31 +34,6 @@ public static class Program
GcloPaths.Edition = "dev";
}
/////////////////////////////////////////////////////////////////////////////
// One window per data folder (#216). Another window on this folder would //
// keep its own copy of the settings and accounts and undo what this one //
// saves. The one that is open is asked to come to the front (this process //
// was started by the user, so it may pass that right on), and this one //
// ends before any window or file of its own exists. //
/////////////////////////////////////////////////////////////////////////////
try
{
Claim = WindowClaim.For(GcloPaths.DataRoot);
}
catch(Exception)
{
// No log exists yet, and nothing here is worth not opening for: without a claim the window opens as it always did.
Claim = null;
}
if(Claim is { IsTheOnlyOne: false })
{
_ = AllowSetForegroundWindow(AnyProcess);
Claim.AskTheOtherToShowItself();
Claim.Dispose();
return;
}
// The remainder mirrors the XAML-generated Main (obj\...\App.g.i.cs) verbatim.
global::WinRT.ComWrappersSupport.InitializeComWrappers();
global::Microsoft.UI.Xaml.Application.Start((p) =>
@@ -81,9 +43,4 @@ public static class Program
_ = new App();
});
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern int AllowSetForegroundWindow(int processId);
}