diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e7fd56..4598571 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,30 @@ jobs: path: TestResults/**/coverage.cobertura.xml if-no-files-found: warn + ui-tests: + # End-to-end FlaUI/UIA tests against the real gclo.exe. Not in the branch + # ruleset's required checks yet: hosted-runner UIA is new here, so this job + # earns required status by proving itself stable first. + name: UI end-to-end tests (x64) + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + # Self-contained Windows App SDK: hosted runners do not have the runtime + # installed. The output path is the same one AppSession resolves by default. + - name: Build app (unpackaged, self-contained Windows App SDK) + run: dotnet build gclo/gclo.csproj -p:Platform=x64 -p:WindowsPackageType=None -p:WindowsAppSDKSelfContained=true + + - name: Run UI tests + run: dotnet test gclo.UiTests/gclo.UiTests.csproj + format: name: Format (style gate) runs-on: windows-latest diff --git a/gclo.UiTests/.editorconfig b/gclo.UiTests/.editorconfig new file mode 100644 index 0000000..f3f6e18 --- /dev/null +++ b/gclo.UiTests/.editorconfig @@ -0,0 +1,6 @@ +# Analyzer tuning local to gclo.UiTests (inherits the repo root .editorconfig). +# Mirrors the gclo.Engine.Tests suppressions, which are keyed to that project's +# path in the root file and therefore do not reach this one. +[*.cs] +# CA1707: xunit's Method_Scenario_Expectation naming uses underscores by convention. +dotnet_diagnostic.CA1707.severity = none diff --git a/gclo.UiTests/AppSession.cs b/gclo.UiTests/AppSession.cs new file mode 100644 index 0000000..5ce9374 --- /dev/null +++ b/gclo.UiTests/AppSession.cs @@ -0,0 +1,418 @@ +using System.Diagnostics; +using FlaUI.Core; +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Conditions; +using FlaUI.Core.Definitions; +using FlaUI.Core.Input; +using FlaUI.Core.Tools; +using FlaUI.UIA3; + +namespace gclo.UiTests; + +/// +/// One launched gclo.exe plus the UIA3 automation that drives it, shared by every +/// UI test in the collection (see ). +/// +/// The exe comes from the GCLO_UITEST_EXE environment variable when set, otherwise +/// from the repo-relative Debug/x64 output (found by walking up from the test's +/// base directory to the folder containing gclo.slnx). Each session gets a unique +/// temp directory passed to the app as GCLO_DATA_DIR, so tests always start with +/// no settings, no accounts, and no logs — and never touch the real user profile. +/// +/// Dispose closes the app (kill as fallback), disposes the automation only after +/// the app is gone, and best-effort deletes the temp data directory. +/// +public sealed class AppSession : IDisposable +{ + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan LaunchTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(250); + + /// Launches gclo against a fresh temp GCLO_DATA_DIR and waits for its main window. + public AppSession() + { + ExePath = ResolveExePath(); + DataDirectory = Path.Combine(Path.GetTempPath(), "gclo-uitests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(DataDirectory); + + var startInfo = new ProcessStartInfo(ExePath) + { + WorkingDirectory = Path.GetDirectoryName(ExePath) ?? "", + }; + startInfo.Environment["GCLO_DATA_DIR"] = DataDirectory; + + Automation = new UIA3Automation(); + try + { + App = Application.Launch(startInfo); + MainWindow = WaitForMainWindow(); + } + catch + { + // A half-launched session must not leak the process, the automation, + // or the temp directory; xunit will surface the original exception. + Dispose(); + throw; + } + } + + /// The gclo.exe under test. + public string ExePath { get; } + + /// The temp directory the app sees as its data root (via GCLO_DATA_DIR). + public string DataDirectory { get; } + + /// The UIA3 automation used for every lookup and pattern call. + public UIA3Automation Automation { get; } + + /// The launched application (never null after construction). + public Application App { get; } + + /// The app's main window, resolved once at launch. + public Window MainWindow { get; } + + // ---------------------------------------------------------------- lookups + + /// + /// Finds the first element matching in the main + /// window — or in any other top-level window of the app's process. The second + /// leg matters because WinAppSDK menu flyouts open in windowed popups (separate + /// top-level HWNDs), so their items are not descendants of the main window. + /// + public AutomationElement? FindInApp(Func condition) + { + AutomationElement? found = MainWindow.FindFirstDescendant(condition); + if (found is not null) + { + return found; + } + + foreach (AutomationElement topLevel in Automation.GetDesktop() + .FindAllChildren(cf => cf.ByProcessId(App.ProcessId))) + { + if (topLevel.Equals(MainWindow)) + { + continue; + } + found = topLevel.FindFirstDescendant(condition); + if (found is not null) + { + return found; + } + } + return null; + } + + /// + /// Retries until it returns an element, throwing a + /// naming when the + /// timeout (default 10s) elapses first. Exceptions inside the lookup (stale + /// elements mid-transition) count as "not found yet" and are retried. + /// + public AutomationElement WaitFor( + Func lookup, string description, TimeSpan? timeout = null) + { + ArgumentNullException.ThrowIfNull(lookup); + TimeSpan effective = timeout ?? DefaultTimeout; + RetryResult result = Retry.WhileNull( + lookup, timeout: effective, interval: PollInterval, ignoreException: true); + if (result.Result is { } element) + { + return element; + } + throw new TimeoutException( + $"Timed out after {effective.TotalSeconds:0}s waiting for {description}. " + + $"(app exited: {App.HasExited})"); + } + + /// + /// Retries until returns null (the element left the + /// tree), throwing a naming + /// when it is still present at the timeout. + /// + public static void WaitUntilGone( + Func lookup, string description, TimeSpan? timeout = null) + { + ArgumentNullException.ThrowIfNull(lookup); + TimeSpan effective = timeout ?? DefaultTimeout; + RetryResult result = Retry.WhileTrue( + () => lookup() is not null, timeout: effective, interval: PollInterval, ignoreException: true); + if (!result.Success) + { + throw new TimeoutException( + $"Timed out after {effective.TotalSeconds:0}s waiting for {description} to go away."); + } + } + + /// Waits for the element carrying . + public AutomationElement WaitForElement(string automationId, TimeSpan? timeout = null) + => WaitFor( + () => FindInApp(cf => cf.ByAutomationId(automationId)), + $"element '{automationId}'", + timeout); + + /// Waits until no element carries anymore. + public void WaitForElementGone(string automationId, TimeSpan? timeout = null) + => WaitUntilGone( + () => FindInApp(cf => cf.ByAutomationId(automationId)), + $"element '{automationId}'", + timeout); + + /// Waits for a text element whose name is exactly . + public AutomationElement WaitForText(string text, TimeSpan? timeout = null) + => WaitFor( + () => FindInApp(cf => cf.ByControlType(ControlType.Text).And(cf.ByName(text))), + $"text '{text}'", + timeout); + + /// Waits until no text element named remains. + public void WaitForTextGone(string text, TimeSpan? timeout = null) + => WaitUntilGone( + () => FindInApp(cf => cf.ByControlType(ControlType.Text).And(cf.ByName(text))), + $"text '{text}'", + timeout); + + // ---------------------------------------------------------------- actions + + /// + /// Opens the menu bar item named (e.g. "File") + /// and invokes the flyout item carrying . + /// The bar item is opened via its ExpandCollapse pattern (WinUI's MenuBarItem + /// exposes it) with Invoke as fallback; the flyout item is then looked up across + /// the app's windows because menu popups are windowed in WinAppSDK. + /// + public void InvokeMenuItem(string menuBarItemName, string itemAutomationId) + { + AutomationElement barItem = WaitFor( + () => MainWindow.FindFirstDescendant(cf => + cf.ByControlType(ControlType.MenuItem).And(cf.ByName(menuBarItemName))) + ?? MainWindow.FindFirstDescendant(cf => cf.ByName(menuBarItemName)), + $"menu bar item '{menuBarItemName}'"); + + if (barItem.Patterns.ExpandCollapse.TryGetPattern(out var expandCollapse)) + { + expandCollapse.Expand(); + Wait.UntilInputIsProcessed(); + } + else + { + InvokeElement(barItem); + } + + AutomationElement item = WaitFor( + () => FindInApp(cf => cf.ByAutomationId(itemAutomationId)), + $"menu item '{itemAutomationId}' under '{menuBarItemName}'"); + InvokeElement(item); + } + + /// + /// Activates a NavigationView item by AutomationId. NavigationViewItem exposes + /// Invoke (preferred, it routes through the pane's selection logic) and + /// SelectionItem; a raw click is the last resort. + /// + public void InvokeNavItem(string automationId) + => InvokeElement(WaitForElement(automationId)); + + /// + /// Invokes the XAML button named . The ClassName filter + /// ("Button") keeps ContentDialog buttons (real XAML buttons named by their + /// *ButtonText) from colliding with the window's non-client caption buttons, + /// whose UIA names ("Close", ...) overlap dialog button captions. + /// + public void ClickButton(string name, TimeSpan? timeout = null) + { + AutomationElement button = WaitFor( + () => FindInApp(cf => cf.ByControlType(ControlType.Button) + .And(cf.ByName(name)) + .And(cf.ByClassName("Button"))), + $"button '{name}'", + timeout); + InvokeElement(button); + } + + /// + /// Best-effort variant of for finally cleanup: + /// clicks the button when it is currently present and swallows every failure, + /// so closing a leftover dialog can never mask the test's own exception. + /// + public void TryClickButton(string name) + { + try + { + AutomationElement? button = FindInApp(cf => cf.ByControlType(ControlType.Button) + .And(cf.ByName(name)) + .And(cf.ByClassName("Button"))); + if (button is not null) + { + InvokeElement(button); + } + } + catch + { + // Cleanup only — the test outcome was decided in the try body. + } + } + + /// + /// Writes into : the UIA Value + /// pattern when available and writable (works without window focus), otherwise + /// focus plus real keystrokes via . + /// + public void EnterText(AutomationElement element, string text) + { + ArgumentNullException.ThrowIfNull(element); + try + { + if (element.Patterns.Value.TryGetPattern(out var value) + && !value.IsReadOnly.ValueOrDefault) + { + value.SetValue(text); + return; + } + } + catch + { + // Some boxes reject SetValue (or the element re-rendered); type instead. + } + + MainWindow.Focus(); + element.Focus(); + Keyboard.Type(text); + Wait.UntilInputIsProcessed(); + } + + /// Invoke pattern first, SelectionItem second, mouse click as last resort. + private static void InvokeElement(AutomationElement element) + { + if (element.Patterns.Invoke.TryGetPattern(out var invoke)) + { + invoke.Invoke(); + } + else if (element.Patterns.SelectionItem.TryGetPattern(out var selectionItem)) + { + selectionItem.Select(); + } + else + { + element.Click(); + } + Wait.UntilInputIsProcessed(); + } + + // ---------------------------------------------------------------- lifecycle + + private Window WaitForMainWindow() + { + RetryResult result = Retry.WhileNull( + () => + { + if (App.HasExited) + { + throw new InvalidOperationException( + $"gclo exited while waiting for its main window (exit code {App.ExitCode}). " + + $"Exe: {ExePath}"); + } + try + { + return App.GetMainWindow(Automation, TimeSpan.FromMilliseconds(500)); + } + catch + { + return null; // not ready yet; retried until the outer timeout + } + }, + timeout: LaunchTimeout, + interval: PollInterval); + + if (result.Result is { } window) + { + return window; + } + throw new TimeoutException( + $"gclo's main window did not appear within {LaunchTimeout.TotalSeconds:0}s. Exe: {ExePath}"); + } + + /// + /// The exe to drive: GCLO_UITEST_EXE when set, else the repo's Debug/x64 output, + /// located by walking up from the test assembly to the folder holding gclo.slnx. + /// + private static string ResolveExePath() + { + string? fromEnv = Environment.GetEnvironmentVariable("GCLO_UITEST_EXE"); + if (!string.IsNullOrWhiteSpace(fromEnv)) + { + if (!File.Exists(fromEnv)) + { + throw new FileNotFoundException( + $"GCLO_UITEST_EXE points at '{fromEnv}', which does not exist.", fromEnv); + } + return fromEnv; + } + + for (DirectoryInfo? current = new(AppContext.BaseDirectory); + current is not null; + current = current.Parent) + { + if (!File.Exists(Path.Combine(current.FullName, "gclo.slnx"))) + { + continue; + } + + string exePath = Path.Combine( + current.FullName, "gclo", "bin", "x64", "Debug", + "net10.0-windows10.0.19041.0", "win-x64", "gclo.exe"); + if (!File.Exists(exePath)) + { + throw new FileNotFoundException( + $"gclo.exe not found at '{exePath}'. Build the app first: " + + "dotnet build gclo/gclo.csproj -p:Platform=x64 -p:WindowsPackageType=None " + + "— or set GCLO_UITEST_EXE to the exe to test.", + exePath); + } + return exePath; + } + + throw new FileNotFoundException( + $"Could not find the repo root (no gclo.slnx above '{AppContext.BaseDirectory}'). " + + "Set GCLO_UITEST_EXE to the gclo.exe to test."); + } + + /// + public void Dispose() + { + // Order matters: take the app down first, dispose the automation only after + // the process is gone, then sweep the temp data directory. Every step is + // best-effort so one failure cannot mask the test outcome. + try + { + App?.Close(); // FlaUI kills the process itself if closing stalls + } + catch + { + // The process may already be gone; Kill below double-checks. + } + try + { + if (App is { HasExited: false }) + { + App.Kill(); + } + } + catch + { + // Best effort only. + } + App?.Dispose(); + Automation?.Dispose(); + + try + { + Directory.Delete(DataDirectory, recursive: true); + } + catch + { + // A straggling handle keeps the directory; it lives under %TEMP% anyway. + } + GC.SuppressFinalize(this); + } +} diff --git a/gclo.UiTests/SmokeTests.cs b/gclo.UiTests/SmokeTests.cs new file mode 100644 index 0000000..39279b5 --- /dev/null +++ b/gclo.UiTests/SmokeTests.cs @@ -0,0 +1,177 @@ +using System.Text.RegularExpressions; +using FlaUI.Core.AutomationElements; + +namespace gclo.UiTests; + +/// +/// Token-free, deterministic smoke tests against the launched app: the shell and +/// its navigation render, the core dialogs open and close, and offline input does +/// not crash anything. No test talks to GitHub — the app runs on an isolated +/// GCLO_DATA_DIR with no accounts and no stored settings (see ). +/// Dialog tests close what they open on the happy path and fall back to a +/// best-effort in finally, so one +/// failure cannot strand a dialog in front of the next test. +/// +[Collection(nameof(UiSessionTests))] +public sealed partial class SmokeTests : UiTestBase +{ + /// Small pause after a button invoke whose handler runs behind a dialog deferral. + private static readonly TimeSpan PostInvokeSettle = TimeSpan.FromMilliseconds(300); + + public SmokeTests(AppSession session) + : base(session) + { + } + + // (1) The app launches and puts up its shell window. + [Fact] + public void MainWindow_AfterLaunch_TitleContainsGclo() + => Assert.Contains("gclo", Session.MainWindow.Title, StringComparison.Ordinal); + + // (2) The navigation pane carries the pinned Quick Sync plus both footer commands. + [Fact] + public void NavigationPane_AfterLaunch_ShowsQuickSyncAddAccountAndSyncAll() + { + Assert.NotNull(Session.WaitForElement("NavQuickSync")); + Assert.NotNull(Session.WaitForElement("NavAddAccount")); + Assert.NotNull(Session.WaitForElement("NavSyncAll")); + } + + // (3) With no accounts and no prior load, the Quick Sync workspace shows the + // connect card, and the 'Sync all' footer command has nothing to run. + [Fact] + public void ConnectCard_FreshDataDir_ShowsTokenAndLoadAndDisablesSyncAll() + { + Assert.NotNull(Session.WaitForElement("ConnectTokenBox")); + Assert.NotNull(Session.WaitForElement("LoadReposButton")); + + AutomationElement syncAll = Session.WaitForElement("NavSyncAll"); + Assert.False(syncAll.IsEnabled, "'Sync all' should be disabled while no accounts exist."); + } + + // (4) File -> Settings opens the Settings dialog; Cancel closes it. + [Fact] + public void FileMenu_Settings_OpensDialogAndCancelCloses() + { + Session.InvokeMenuItem("File", "MenuSettings"); + try + { + Session.WaitForText("Settings"); // the ContentDialog's title + Session.ClickButton("Cancel"); + Session.WaitForTextGone("Settings"); + } + finally + { + Session.TryClickButton("Cancel"); // no-op when the happy path closed it + } + } + + // (5) Help -> About shows the semantic version plus the parenthesized git hash. + [Fact] + public void HelpMenu_About_ShowsSemanticVersionWithCommitHash() + { + Session.InvokeMenuItem("Help", "MenuAbout"); + try + { + // The TextBlock exists as soon as the dialog opens; retry until its + // text has been populated by the AboutDialog constructor. + AutomationElement version = Session.WaitFor( + () => + { + AutomationElement? element = + Session.FindInApp(cf => cf.ByAutomationId("AboutVersionText")); + return element is not null + && element.Name.StartsWith("Version ", StringComparison.Ordinal) + ? element + : null; + }, + "the About dialog's version text"); + + string text = version.Name; + Assert.Matches(VersionPattern(), text); + Assert.Contains("(", text, StringComparison.Ordinal); // "()" + + Session.ClickButton("Close"); + Session.WaitForElementGone("AboutVersionText"); + } + finally + { + Session.TryClickButton("Close"); // no-op when the happy path closed it + } + } + + // (6) The Add account wizard appears; Next with an empty name keeps it open + // (step 1 refuses to advance); Cancel closes it. + [Fact] + public void AddAccountWizard_NextWithEmptyName_StaysOpenAndCancelCloses() + { + Session.InvokeNavItem("NavAddAccount"); + try + { + Session.WaitForElement("WizardNameBox"); // wizard step 1 is on screen + Session.WaitForText("Step 1 of 4: Identity"); // right dialog, right step + + Session.ClickButton("Next"); + Thread.Sleep(PostInvokeSettle); // validation runs behind the dialog deferral + + Assert.NotNull(Session.WaitForElement("WizardNameBox")); // still open + Assert.False(Session.App.HasExited); + + Session.ClickButton("Cancel"); + Session.WaitForElementGone("WizardNameBox"); + } + finally + { + Session.TryClickButton("Cancel"); // no-op when the happy path closed it + } + } + + // (7) Typing into the token box (which kicks the offline org-listing path) does + // not crash the app, and the org combo stays present. No token validation + // is asserted — these tests run without network access or credentials. + [Fact] + public void ConnectTokenBox_TypingText_KeepsAppAliveAndOrgBoxPresent() + { + AutomationElement tokenBox = Session.WaitForElement("ConnectTokenBox"); + Session.EnterText(tokenBox, "ghp_uitests_offline_dummy_token"); + + Assert.NotNull(Session.WaitForElement("ConnectOrgBox")); + Assert.False(Session.App.HasExited); + } + + // (8) Regression for the dialog-collision crash: WinUI allows one open + // ContentDialog per XamlRoot, and a second ShowAsync throws a COMException + // that — escaping an async void handler — used to kill the whole process + // (0xc000027b). Commanding 'Add account' while About is open must now be + // ignored: the app stays alive, About stays up, and no wizard appears. + [Fact] + public void AddAccountWhileAboutDialogOpen_IsIgnoredAndAppSurvives() + { + Session.InvokeMenuItem("Help", "MenuAbout"); + try + { + Session.WaitForElement("AboutVersionText"); + + Session.InvokeNavItem("NavAddAccount"); // second dialog: must be a no-op + Thread.Sleep(PostInvokeSettle); + + Assert.False(Session.App.HasExited); + Assert.Null(Session.FindInApp(cf => cf.ByAutomationId("WizardNameBox"))); + Assert.NotNull(Session.FindInApp(cf => cf.ByAutomationId("AboutVersionText"))); + + Session.ClickButton("Close"); + Session.WaitForElementGone("AboutVersionText"); + } + finally + { + Session.TryClickButton("Close"); // no-op when the happy path closed it + } + } + + // (9) Resizing below the minimum window size is intentionally not covered: + // driving OS-level resize through UIA transforms is flaky across DPI + // scales, and the minimum is already enforced by OverlappedPresenter. + + [GeneratedRegex(@"^Version \d+\.\d+\.\d+")] + private static partial Regex VersionPattern(); +} diff --git a/gclo.UiTests/UiSessionTests.cs b/gclo.UiTests/UiSessionTests.cs new file mode 100644 index 0000000..5e4681b --- /dev/null +++ b/gclo.UiTests/UiSessionTests.cs @@ -0,0 +1,31 @@ +namespace gclo.UiTests; + +/// +/// The single xunit collection every UI test belongs to (referenced via +/// nameof(UiSessionTests)). All UI tests share one launched gclo instance +/// (the collection fixture) and run sequentially +/// (DisableParallelization), so two tests never drive two overlapping app +/// instances or interleave input on the same one. +/// +[CollectionDefinition(nameof(UiSessionTests), DisableParallelization = true)] +public sealed class UiSessionTests : ICollectionFixture +{ +} + +/// +/// Base class for UI tests: holds the shared so test +/// bodies read as session-relative steps. Derived classes must carry +/// [Collection(nameof(UiSessionTests))] — xunit does not inherit it. +/// +public abstract class UiTestBase +{ + /// Captures the collection fixture xunit injects per test class. + protected UiTestBase(AppSession session) + { + ArgumentNullException.ThrowIfNull(session); + Session = session; + } + + /// The app instance shared by every test in the collection. + protected AppSession Session { get; } +} diff --git a/gclo.UiTests/gclo.UiTests.csproj b/gclo.UiTests/gclo.UiTests.csproj new file mode 100644 index 0000000..0b3ddf2 --- /dev/null +++ b/gclo.UiTests/gclo.UiTests.csproj @@ -0,0 +1,27 @@ + + + + + net10.0-windows10.0.19041.0 + enable + enable + false + + + + + + + + + + + + + + diff --git a/gclo.ViewModels/AccountsStore.cs b/gclo.ViewModels/AccountsStore.cs index b50f179..efdf04d 100644 --- a/gclo.ViewModels/AccountsStore.cs +++ b/gclo.ViewModels/AccountsStore.cs @@ -5,7 +5,8 @@ using gclo.Engine; namespace gclo.ViewModels; /// -/// Persists account profiles as JSON at %LOCALAPPDATA%\gclo\accounts.json and their +/// Persists account profiles as JSON at accounts.json under +/// (default %LOCALAPPDATA%\gclo) and their /// tokens in an ; the file holds metadata only and never a /// token. Unlike , accounts are primary user data, so write /// failures in , , and @@ -22,17 +23,16 @@ public sealed class AccountsStore /// /// Loads existing accounts from (default: - /// %LOCALAPPDATA%\gclo). A missing file simply means no accounts yet; a corrupt - /// or unreadable one is logged (pass the app's to surface - /// that) and treated as empty rather than blocking startup. + /// ). A missing file simply means no accounts yet; + /// a corrupt or unreadable one is logged (pass the app's to + /// surface that) and treated as empty rather than blocking startup. /// public AccountsStore(ITokenVault vault, string? directory = null, IActivityLog? log = null) { ArgumentNullException.ThrowIfNull(vault); _vault = vault; _log = log ?? new NullActivityLog(); - directory ??= Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "gclo"); + directory ??= GcloPaths.DataRoot; _filePath = Path.Combine(directory, "accounts.json"); _accounts = Load(); } diff --git a/gclo.ViewModels/AppSettings.cs b/gclo.ViewModels/AppSettings.cs index 574bfd0..3692af1 100644 --- a/gclo.ViewModels/AppSettings.cs +++ b/gclo.ViewModels/AppSettings.cs @@ -4,9 +4,10 @@ using System.Text.Json.Serialization; namespace gclo.ViewModels; /// -/// User preferences persisted as JSON at %LOCALAPPDATA%\gclo\settings.json. -/// Uses Environment.GetFolderPath (not Windows.Storage.ApplicationData) so the same -/// path works whether the app runs packaged or unpackaged. +/// User preferences persisted as JSON at settings.json under +/// (default %LOCALAPPDATA%\gclo, overridable via +/// GCLO_DATA_DIR). Uses Environment.GetFolderPath (not Windows.Storage.ApplicationData) +/// so the same path works whether the app runs packaged or unpackaged. /// public sealed class AppSettings { @@ -23,10 +24,7 @@ public sealed class AppSettings /// App theme: "System", "Light", or "Dark". Unknown values fall back to "System". public string Theme { get; set; } = "System"; - private static string SettingsPath => Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "gclo", - "settings.json"); + private static string SettingsPath => Path.Combine(GcloPaths.DataRoot, "settings.json"); /// /// Loads settings from disk. Never throws: a missing, corrupt, or unreadable file diff --git a/gclo.ViewModels/GcloPaths.cs b/gclo.ViewModels/GcloPaths.cs new file mode 100644 index 0000000..4ca28bf --- /dev/null +++ b/gclo.ViewModels/GcloPaths.cs @@ -0,0 +1,22 @@ +namespace gclo.ViewModels; + +/// +/// The single seam for where gclo keeps its per-user data (settings, accounts, +/// logs). Defaults to %LOCALAPPDATA%\gclo; the GCLO_DATA_DIR environment variable +/// overrides it so UI tests (and portable setups) can point the whole app at an +/// isolated directory without touching the real profile. Components that accept +/// an explicit directory (test seams) are unaffected — this only feeds defaults. +/// +public static class GcloPaths +{ + /// + /// Root directory for gclo's per-user data: GCLO_DATA_DIR when set and + /// non-empty, otherwise %LOCALAPPDATA%\gclo. Read on every access so a + /// value set before launch always wins over any cached default. + /// + public static string DataRoot => + Environment.GetEnvironmentVariable("GCLO_DATA_DIR") is { Length: > 0 } dir + ? dir + : Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "gclo"); +} diff --git a/gclo.slnx b/gclo.slnx index af49b94..9c4e8a8 100644 --- a/gclo.slnx +++ b/gclo.slnx @@ -7,6 +7,7 @@ + diff --git a/gclo/AboutDialog.xaml b/gclo/AboutDialog.xaml index ab703ed..869b78c 100644 --- a/gclo/AboutDialog.xaml +++ b/gclo/AboutDialog.xaml @@ -21,6 +21,7 @@ TextWrapping="Wrap" /> diff --git a/gclo/AccountWizardDialog.xaml b/gclo/AccountWizardDialog.xaml index 8b90ddd..f605ac8 100644 --- a/gclo/AccountWizardDialog.xaml +++ b/gclo/AccountWizardDialog.xaml @@ -28,6 +28,7 @@ Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Step 1 of 4: Identity" /> @@ -53,6 +54,7 @@ Text="Step 2 of 4: Access token" /> @@ -94,6 +96,7 @@ Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Step 3 of 4: Organization" /> diff --git a/gclo/App.xaml.cs b/gclo/App.xaml.cs index 0c4c833..3782190 100644 --- a/gclo/App.xaml.cs +++ b/gclo/App.xaml.cs @@ -1,3 +1,4 @@ +using gclo.Engine; using gclo.ViewModels; using Microsoft.UI.Xaml; @@ -10,6 +11,12 @@ namespace gclo { private Window? _window; + /// + /// Where the unhandled-exception net writes; set by MainWindow once the shared + /// activity log exists. Null only during the first instants of startup. + /// + internal static IActivityLog? CrashLog { get; set; } + /// /// Constructed by Program.Main inside Application.Start after VelopackApp handled /// install/update hooks — see Program.cs. @@ -18,6 +25,26 @@ namespace gclo { InitializeComponent(); + // Last-resort net: an exception escaping an async void event handler is + // rethrown on the dispatcher with no frame above it, and the process dies + // as a stowed exception (0xc000027b) with nothing in the log. The known + // collision (two ContentDialogs) is prevented at the source by + // DialogGuard; this catches whatever else slips through, records it, and + // keeps the app running. + UnhandledException += (_, e) => + { + e.Handled = true; + try + { + CrashLog?.Error($"Unhandled UI exception: {e.Message}", e.Exception); + } + catch + { + // Logging must never turn a survivable error fatal; the stowed + // Exception property itself can throw when type info was lost. + } + }; + // App-level so Application.Current.Resources theme lookups (the status // brushes) agree with the visible theme. Only settable here, before any // window exists; mid-session theme changes still use the root element's @@ -34,7 +61,9 @@ namespace gclo } /// - /// Invoked when the application is launched. + /// Invoked when the application is launched. The branded splash is an overlay + /// inside (see for why a + /// separate splash window is a UIA crash hazard). /// /// Details about the launch request and process. protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) diff --git a/gclo/DialogGuard.cs b/gclo/DialogGuard.cs new file mode 100644 index 0000000..2705648 --- /dev/null +++ b/gclo/DialogGuard.cs @@ -0,0 +1,43 @@ +using System; // the GetAwaiter extension for IAsyncOperation lives here (CsWinRT) +using System.Threading.Tasks; +using Microsoft.UI.Xaml.Controls; + +namespace gclo +{ + /// + /// Serializes ContentDialog display. WinUI allows a single open ContentDialog + /// per XamlRoot, and a second ShowAsync does not queue — it throws a + /// COMException which, escaping an async void event handler, kills the process + /// with a stowed exception (0xc000027b). Every dialog in the app opens through + /// , which ignores the request while another dialog is + /// on screen — the same no-op a user expects from commands behind a modal. + /// All calls run on the single UI thread, so the flag needs no locking. + /// + internal static class DialogGuard + { + private static bool _dialogOpen; + + /// + /// Shows unless another dialog is on screen. + /// Returns the dialog result, or null when the request was ignored — + /// callers treat null like a dismissal. + /// + public static async Task ShowAsync(ContentDialog dialog) + { + if (_dialogOpen) + { + return null; + } + + _dialogOpen = true; + try + { + return await dialog.ShowAsync(); + } + finally + { + _dialogOpen = false; + } + } + } +} diff --git a/gclo/MainWindow.xaml b/gclo/MainWindow.xaml index cc2091b..b686399 100644 --- a/gclo/MainWindow.xaml +++ b/gclo/MainWindow.xaml @@ -21,18 +21,18 @@ - + - + - + - + - + @@ -45,6 +45,7 @@ Content hosts the selected workspace's cached WorkspacePage. --> - + @@ -62,6 +66,7 @@ @@ -69,12 +74,22 @@ - + + + + diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs index a12a537..91ab8d4 100644 --- a/gclo/MainWindow.xaml.cs +++ b/gclo/MainWindow.xaml.cs @@ -66,7 +66,8 @@ namespace gclo public MainWindow() { - _log = new FileActivityLog(); + _log = new FileActivityLog(System.IO.Path.Combine(GcloPaths.DataRoot, "logs")); + App.CrashLog = _log; // the unhandled-exception net now reaches the activity log _tokenVault = new CredentialManagerVault(); _accountsStore = new AccountsStore(_tokenVault, log: _log); @@ -109,8 +110,19 @@ namespace gclo } Closed += (_, _) => DisposeWorkspaces(); + + // Dismiss the startup splash overlay once it has been visible long enough + // to read as an intentional brand moment rather than a flicker. + DispatcherQueue.TryEnqueue(async () => + { + await Task.Delay(MinimumSplashMilliseconds); + await StartupSplash.DismissAsync(); + }); } + /// Minimum splash display, so a fast startup never shows a flicker. + private const int MinimumSplashMilliseconds = 800; + private void ApplySettings() { if (Content is FrameworkElement root) @@ -389,7 +401,7 @@ namespace gclo { XamlRoot = Content.XamlRoot, }; - await dialog.ShowAsync(); + await DialogGuard.ShowAsync(dialog); if (!dialog.Saved) { return; @@ -486,7 +498,7 @@ namespace gclo DefaultButton = ContentDialogButton.Close, XamlRoot = Content.XamlRoot, }; - if (await confirm.ShowAsync() != ContentDialogResult.Primary) + if (await DialogGuard.ShowAsync(confirm) != ContentDialogResult.Primary) { return; } @@ -598,9 +610,9 @@ namespace gclo catch (Exception ex) { // Unexpected only: the coordinator reports per-account trouble by - // skipping. (Also lands here when the summary dialog cannot open - // because another ContentDialog is showing; the activity log carries - // the per-account outcomes either way.) + // skipping, and DialogGuard turns a blocked summary dialog into a + // no-op instead of a throw; the activity log carries the + // per-account outcomes either way. _log.Error($"Sync all did not finish cleanly: {ex.Message}", ex); } finally @@ -651,7 +663,7 @@ namespace gclo private async void SettingsMenuItem_Click(object sender, RoutedEventArgs e) { var dialog = new SettingsDialog(_settings) { XamlRoot = Content.XamlRoot }; - if (await dialog.ShowAsync() == ContentDialogResult.Primary) + if (await DialogGuard.ShowAsync(dialog) == ContentDialogResult.Primary) { dialog.ApplyAndSave(); ApplySettings(); @@ -663,7 +675,7 @@ namespace gclo private async void ActivityLogMenuItem_Click(object sender, RoutedEventArgs e) { var dialog = new LogViewerDialog(_log) { XamlRoot = Content.XamlRoot }; - await dialog.ShowAsync(); + await DialogGuard.ShowAsync(dialog); } private async void GitHubMenuItem_Click(object sender, RoutedEventArgs e) @@ -674,7 +686,7 @@ namespace gclo private async void AboutMenuItem_Click(object sender, RoutedEventArgs e) { var dialog = new AboutDialog { XamlRoot = Content.XamlRoot }; - await dialog.ShowAsync(); + await DialogGuard.ShowAsync(dialog); } private async void CheckForUpdatesMenuItem_Click(object sender, RoutedEventArgs e) @@ -713,7 +725,7 @@ namespace gclo DefaultButton = ContentDialogButton.Primary, XamlRoot = Content.XamlRoot, }; - if (await confirm.ShowAsync() != ContentDialogResult.Primary) + if (await DialogGuard.ShowAsync(confirm) != ContentDialogResult.Primary) { return; } @@ -736,7 +748,7 @@ namespace gclo CloseButtonText = "OK", XamlRoot = Content.XamlRoot, }; - await dialog.ShowAsync(); + await DialogGuard.ShowAsync(dialog); } [System.Runtime.InteropServices.DllImport("user32.dll")] diff --git a/gclo/SplashOverlay.xaml b/gclo/SplashOverlay.xaml new file mode 100644 index 0000000..cc29045 --- /dev/null +++ b/gclo/SplashOverlay.xaml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 3 + + + + diff --git a/gclo/SplashOverlay.xaml.cs b/gclo/SplashOverlay.xaml.cs new file mode 100644 index 0000000..4d4df88 --- /dev/null +++ b/gclo/SplashOverlay.xaml.cs @@ -0,0 +1,49 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.UI.Xaml.Controls; + +namespace gclo +{ + /// + /// Branded startup splash rendered as a full-window overlay inside + /// . An overlay instead of a separate splash window by + /// design: closing a real window while assistive technology or UI automation is + /// querying it crashes the process with a stowed XAML exception, whereas fading + /// out and removing an element is ordinary, race-free XAML work. + /// + public sealed partial class SplashOverlay : UserControl + { + private bool _dismissed; + + /// Builds the overlay and stamps the build identity line. + public SplashOverlay() + { + InitializeComponent(); + VersionText.Text = gclo.Engine.BuildVersion.Describe(Assembly.GetExecutingAssembly()); + } + + /// + /// Fades the overlay out and removes it from its parent panel. Safe to call + /// more than once; only the first call does anything. + /// + public async Task DismissAsync() + { + if (_dismissed) + { + return; + } + _dismissed = true; + + // Input must fall through to the app the moment dismissal starts. + IsHitTestVisible = false; + + var completion = new TaskCompletionSource(); + FadeOut.Completed += (_, _) => completion.TrySetResult(); + FadeOut.Begin(); + await completion.Task; + + (Parent as Panel)?.Children.Remove(this); + } + } +} diff --git a/gclo/WorkspacePage.xaml b/gclo/WorkspacePage.xaml index 6706e10..1c03cb8 100644 --- a/gclo/WorkspacePage.xaml +++ b/gclo/WorkspacePage.xaml @@ -41,6 +41,7 @@ @@ -55,6 +56,7 @@