feat(app): gclo opens one window per data folder, and a second start brings the open one to the front

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.

Closes #216

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
james.maesandClaude Fable 5.2 committed 2026-11-11 16:51:19 -06:00
1 parent 9ab80f8c93
commit 96cf2fcabc
8 files changed
+395

No files matched your search

+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
View File
@@ -117,6 +117,8 @@ 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.
+1
View File
@@ -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. |
+155
View File
@@ -0,0 +1,155 @@
/*
* 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 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();
}
}
}
}
+132
View File
@@ -0,0 +1,132 @@
/*
* 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;
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.
}
var show = new EventWaitHandle(initialState: false, EventResetMode.AutoReset, $@"Local\gclo-show-{KeyFor(dataRoot)}");
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 = 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();
}
}
+27
View File
@@ -203,8 +203,14 @@ 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(() => DispatcherQueue.TryEnqueue(ComeToTheFront));
Closed += (_, _) =>
{
// First: from here on a new start opens a window of its own, and does not ask one that is closing.
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 +1652,27 @@ 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(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);
}
+34
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,22 @@ 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. //
/////////////////////////////////////////////////////////////////////////////
Claim = WindowClaim.For(GcloPaths.DataRoot);
if(!Claim.IsTheOnlyOne)
{
_ = 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 +72,9 @@ public static class Program
_ = new App();
});
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern int AllowSetForegroundWindow(int processId);
}