diff --git a/CLAUDE.md b/CLAUDE.md
index c47248e..ef1640d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/README.md b/README.md
index fd54b68..0188ee2 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md
index 0e8a632..10d940d 100644
--- a/docs/security/threat-model.md
+++ b/docs/security/threat-model.md
@@ -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. |
diff --git a/gclo.Engine.Tests/WindowClaimTests.cs b/gclo.Engine.Tests/WindowClaimTests.cs
new file mode 100644
index 0000000..4695f8d
--- /dev/null
+++ b/gclo.Engine.Tests/WindowClaimTests.cs
@@ -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;
+
+
+///
+/// : 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.
+///
+[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(() => WindowClaim.For(" "));
+ Assert.Throws(() => WindowClaim.For(null!));
+ Assert.Throws(() => WindowClaim.KeyFor(""));
+
+ using var claim = WindowClaim.For(Path.Combine(_root, "data"));
+ Assert.Throws(() => claim.OnAskedToShow(null!));
+
+ // Given up twice is given up.
+ claim.Dispose();
+ }
+}
diff --git a/gclo.UiTests/SmokeTests.cs b/gclo.UiTests/SmokeTests.cs
index 89cfd24..92bb442 100644
--- a/gclo.UiTests/SmokeTests.cs
+++ b/gclo.UiTests/SmokeTests.cs
@@ -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();
+ }
+ }
+ }
}
diff --git a/gclo.ViewModels/WindowClaim.cs b/gclo.ViewModels/WindowClaim.cs
new file mode 100644
index 0000000..82a3a82
--- /dev/null
+++ b/gclo.ViewModels/WindowClaim.cs
@@ -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;
+
+
+///
+/// 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.
+///
+/// The claim is a file in the data folder (window.lock) 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, GCLO_DATA_DIR) is another claim.
+///
+///
+/// The command line makes no claim: it opens no window and may run beside the app.
+///
+///
+[SupportedOSPlatform("windows")]
+public sealed class WindowClaim : IDisposable
+{
+ /// The file is open in another process (ERROR_SHARING_VIOLATION as an HRESULT).
+ private const int SharingViolation = unchecked((int)0x80070020);
+
+ private readonly FileStream? _held;
+
+ /// Null when the event could not be made or opened: nothing is asked then, and nothing heard.
+ private readonly EventWaitHandle? _show;
+
+ private RegisteredWaitHandle? _waiting;
+
+
+
+ private WindowClaim(FileStream? held, bool isTheOnlyOne, EventWaitHandle? show)
+ {
+ _held = held;
+ IsTheOnlyOne = isTheOnlyOne;
+ _show = show;
+ }
+
+
+
+ ///
+ /// Claims for a window of this process. When
+ /// another window holds it, 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.
+ ///
+ 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);
+ }
+
+
+
+ ///
+ /// 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).
+ ///
+ 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);
+ }
+
+
+
+ /// True when no other window holds the data folder: this process may open one.
+ public bool IsTheOnlyOne { get; }
+
+
+
+ /// Asks the window that holds the data folder to come to the front.
+ public void AskTheOtherToShowItself() => _show?.Set();
+
+
+
+ ///
+ /// What the window that holds the claim does when another start asks it to come
+ /// to the front. runs on a thread of the pool, once for
+ /// each request; setting it again replaces the one before.
+ ///
+ 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);
+ }
+
+
+
+ /// Gives the claim up: the next start opens a window of its own.
+ public void Dispose()
+ {
+ _waiting?.Unregister(null);
+ _waiting = null;
+ _show?.Dispose();
+ _held?.Dispose();
+ }
+}
diff --git a/gclo/App.xaml.cs b/gclo/App.xaml.cs
index e0d81a8..498799d 100644
--- a/gclo/App.xaml.cs
+++ b/gclo/App.xaml.cs
@@ -79,7 +79,17 @@ public partial class App : Application
/// Details about the launch request and process.
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;
+ }
}
}
diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs
index b8e5f0b..8eb6956 100644
--- a/gclo/MainWindow.xaml.cs
+++ b/gclo/MainWindow.xaml.cs
@@ -52,6 +52,9 @@ public sealed partial class MainWindow : Window
/// Set once the root's theme-change subscription exists.
private bool _themeHooked;
+ /// Set when the window has closed: a request to come to the front that arrives after that is dropped (#216).
+ 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
+ ///
+ /// 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.
+ ///
+ 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);
}
diff --git a/gclo/Program.cs b/gclo/Program.cs
index c864d3a..1fb99e4 100644
--- a/gclo/Program.cs
+++ b/gclo/Program.cs
@@ -20,6 +20,19 @@ namespace gclo;
///
public static class Program
{
+ /// ASFW_ANY: any process may bring its window to the front.
+ private const int AnyProcess = -1;
+
+
+
+ ///
+ /// This process's claim on the data folder (#216): held from here until the
+ /// main window closes. Null only before has made it.
+ ///
+ 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);
}