From 96cf2fcabc24c8ecd951badb09aaadb74d699d9d Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 11 Nov 2026 16:37:12 -0600 Subject: [PATCH 1/2] 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 Signed-off-by: James Maes --- CLAUDE.md | 3 + README.md | 2 + docs/security/threat-model.md | 1 + gclo.Engine.Tests/WindowClaimTests.cs | 155 ++++++++++++++++++++++++++ gclo.UiTests/SmokeTests.cs | 41 +++++++ gclo.ViewModels/WindowClaim.cs | 132 ++++++++++++++++++++++ gclo/MainWindow.xaml.cs | 27 +++++ gclo/Program.cs | 34 ++++++ 8 files changed, 395 insertions(+) create mode 100644 gclo.Engine.Tests/WindowClaimTests.cs create mode 100644 gclo.ViewModels/WindowClaim.cs 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..0233a50 --- /dev/null +++ b/gclo.Engine.Tests/WindowClaimTests.cs @@ -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; + + +/// +/// : 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 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..8303aab --- /dev/null +++ b/gclo.ViewModels/WindowClaim.cs @@ -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; + + +/// +/// 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; + + 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. + } + + var show = new EventWaitHandle(initialState: false, EventResetMode.AutoReset, $@"Local\gclo-show-{KeyFor(dataRoot)}"); + 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 = 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/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs index b8e5f0b..527ecc0 100644 --- a/gclo/MainWindow.xaml.cs +++ b/gclo/MainWindow.xaml.cs @@ -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 + /// + /// 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(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..319b906 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,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); } -- 2.54.0 From 15f263676ec50ad24dcbbd680179085dcb93d4bd Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 11 Nov 2026 16:54:50 -0600 Subject: [PATCH 2/2] fix(app): the claim on the data folder never stops a start, and is given up when no window came of it From the review of #217. This code runs at every start, before a window or the log exists. - The event that asks the open window to come to the front may be taken by something this process cannot use (a gclo that runs elevated made it) or that is no event. That ended the start with an unhandled exception. The claim now stands as the lock file says and only the request is not made; whatever else fails while the claim is made, the window opens without one. - A window that could not be made gives the claim up: the process can live on without a window, and every later start would have ended at once with nothing to bring to the front. - A request to come to the front that arrives while the window closes is dropped, and is queued on the dispatcher captured at start, not read from a window that may be gone. Refs #216 Co-Authored-By: Claude Fable 5.2 Signed-off-by: James Maes --- gclo.Engine.Tests/WindowClaimTests.cs | 20 ++++++++++++++++++ gclo.ViewModels/WindowClaim.cs | 29 +++++++++++++++++++++------ gclo/App.xaml.cs | 14 +++++++++++-- gclo/MainWindow.xaml.cs | 12 ++++++++++- gclo/Program.cs | 13 ++++++++++-- 5 files changed, 77 insertions(+), 11 deletions(-) diff --git a/gclo.Engine.Tests/WindowClaimTests.cs b/gclo.Engine.Tests/WindowClaimTests.cs index 0233a50..4695f8d 100644 --- a/gclo.Engine.Tests/WindowClaimTests.cs +++ b/gclo.Engine.Tests/WindowClaimTests.cs @@ -123,6 +123,26 @@ public sealed class WindowClaimTests : IDisposable + [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() { diff --git a/gclo.ViewModels/WindowClaim.cs b/gclo.ViewModels/WindowClaim.cs index 8303aab..82a3a82 100644 --- a/gclo.ViewModels/WindowClaim.cs +++ b/gclo.ViewModels/WindowClaim.cs @@ -36,13 +36,14 @@ public sealed class WindowClaim : IDisposable private readonly FileStream? _held; - private readonly EventWaitHandle _show; + /// 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) + private WindowClaim(FileStream? held, bool isTheOnlyOne, EventWaitHandle? show) { _held = held; IsTheOnlyOne = isTheOnlyOne; @@ -78,7 +79,21 @@ public sealed class WindowClaim : IDisposable // 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)}"); + 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); } @@ -103,7 +118,7 @@ public sealed class WindowClaim : IDisposable /// Asks the window that holds the data folder to come to the front. - public void AskTheOtherToShowItself() => _show.Set(); + public void AskTheOtherToShowItself() => _show?.Set(); @@ -116,7 +131,9 @@ public sealed class WindowClaim : IDisposable { ArgumentNullException.ThrowIfNull(show); _waiting?.Unregister(null); - _waiting = ThreadPool.RegisterWaitForSingleObject(_show, (_, _) => show(), state: null, Timeout.Infinite, executeOnlyOnce: false); + _waiting = _show is null + ? null + : ThreadPool.RegisterWaitForSingleObject(_show, (_, _) => show(), state: null, Timeout.Infinite, executeOnlyOnce: false); } @@ -126,7 +143,7 @@ public sealed class WindowClaim : IDisposable { _waiting?.Unregister(null); _waiting = null; - _show.Dispose(); + _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 527ecc0..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; @@ -204,11 +207,13 @@ public sealed partial class MainWindow : Window } // 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)); + 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. @@ -1658,6 +1663,11 @@ public sealed partial class MainWindow : Window /// private void ComeToTheFront() { + if(_closed) + { + return; + } + if(AppWindow.Presenter is OverlappedPresenter { State: OverlappedPresenterState.Minimized } presenter) { presenter.Restore(); diff --git a/gclo/Program.cs b/gclo/Program.cs index 319b906..1fb99e4 100644 --- a/gclo/Program.cs +++ b/gclo/Program.cs @@ -54,8 +54,17 @@ public static class Program // 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) + 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(); -- 2.54.0