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); }