Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ move between levels.

**The C4 tab lists views; it does not navigate them.** With `c4-diagrams` on, the
**C4** tab shows the model, kept as Structurizr DSL in
`.devbook/arc42/workspace.dsl`, one view at a time, and links a view to the
`workspace.dsl` under `.devbook/arc42/`, one view at a time, and links a view to the
chapters that document it. Before devbook's contract 33 the model sat under
`.devbook/arc42/_c4/`. A reader who wants the containers of a system, then the
components of one container, picks each view by name. Nothing says that the second
Expand Down Expand Up @@ -70,7 +70,7 @@ after it is drawn.
flowchart LR
DSL["workspace.dsl: the model"] --> S["Architecture surface"]
M["Chapter fields: c4, c4-touches, severity, code"] --> S
E["Chapter embeds: ![…](c4:key)"] --> S
E["Chapter embeds: ![…](c4:<key>)"] --> S
J["_c4/references.json, retiring"] -.-> S
SRC["C# source in a building block's code folders"] --> R["Roslyn syntax trees, on demand"]
R --> S
Expand Down
107 changes: 106 additions & 1 deletion src/App/Backlog.Desktop.UI/Settings/McpServerSettingsPage.razor
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,18 @@
harness - serves the endpoint on its own origin behind no token, and Aspire
owns its port, so there the page draws that address and nothing to edit.

The third card is the app's life beside its window (local ADR 0043 §5): keep
running in the tray, and start at sign-in through IStartupRegistration. Only
the desktop head composes that port; elsewhere the card says there is no
window to keep running.

The namespace is the screen's rather than the folder's, and this folder has
no _Imports.razor, for the reason the note at the top of Settings.razor gives.
*@
@namespace Backlog.Desktop.UI.Shell
@inherits SettingsPageBase
@using Backlog.Desktop.UI.Extensions
@using Backlog.Infrastructure.FileSystem
@using Backlog.Modules.DevPc.Abstractions
@inject IServiceProvider Services
@inject NavigationManager Navigation
Expand Down Expand Up @@ -144,9 +150,96 @@
</div>
}
</SettingsCard>

@* The app's life beside its window (local ADR 0043 §5): whether closing the
main window keeps the server answering, and whether Backlog starts at
sign-in. Here because the reason to want either is the MCP server. *@
<SettingsCard Title="@BackgroundCardTitle"
Id="mcp-server-background-title"
Description="What closing the main window does, and whether Backlog starts when you sign in."
TestId="mcp-server-background-card">
@if (_startup is null || _settings is null)
{
<p class="setting__note" data-testid="mcp-server-background-note">
This host has no window of its own to keep running.
</p>
}
else
{
<Toggle Label="Keep running in the tray when closed"
Description="The MCP server keeps answering while the window is closed."
Checked="@_settings.KeepRunningInTray"
CheckedChanged="SetKeepRunningInTray"
TestId="mcp-server-keep-in-tray" />
@if (_trayNote is not null)
{
<Alert BaseClass="setting__status"
Role="status"
TestId="mcp-server-keep-in-tray-status"
Message="@_trayNote" />
}

<Toggle Label="Start Backlog when I sign in"
Description="@StartupDescription"
Checked="@(_startupState == StartupRegistrationState.Enabled)"
CheckedChanged="SetStartAtSignInAsync"
Disabled="@StartupLocked"
TestId="mcp-server-start-at-sign-in" />
}
</SettingsCard>
</div>

@code {
/// <summary>The card with the tray and sign-in switches - the third of the
/// page's <c>SettingsSection.CardTitles</c>.</summary>
public const string BackgroundCardTitle = "In the background";

/// <summary>The sign-in switch's line where the process has no package identity.</summary>
public const string StartupUnavailableNote = "Available in the installed app";

/// <summary>The sign-in switch's line where Windows reports the task turned off
/// by the person or by policy, which only they can change.</summary>
public const string StartupLockedNote = "Turned off in Task Manager's Startup apps";

/// <summary>The sign-in switch's line while the app may change it.</summary>
public const string StartupNote = "Backlog starts in the tray, with no window.";

/// <summary>The host's startup registration, or null in a host that composes none.</summary>
private IStartupRegistration? _startup;

/// <summary>The per-machine settings the tray switch writes, or null in a host
/// without them.</summary>
private WorkspaceSettingsStore? _settings;

/// <summary>What Windows last said about starting at sign-in; null until it answered.</summary>
private StartupRegistrationState? _startupState;

/// <summary>What the store said about a tray choice it took but could not save.</summary>
private string? _trayNote;

private bool StartupLocked => _startupState is null
or StartupRegistrationState.Unavailable
or StartupRegistrationState.DisabledByUser
or StartupRegistrationState.DisabledByPolicy;

private string StartupDescription => _startupState switch
{
StartupRegistrationState.Unavailable => StartupUnavailableNote,
StartupRegistrationState.DisabledByUser or StartupRegistrationState.DisabledByPolicy => StartupLockedNote,
_ => StartupNote,
};

private void SetKeepRunningInTray(bool keepRunning) => _trayNote = _settings?.SetKeepRunningInTray(keepRunning);

private async Task SetStartAtSignInAsync(bool enabled)
{
if (_startup is null || StartupLocked) return;

_startupState = await _startup.SetEnabledAsync(enabled);
}

private void OnTrayChanged() => _ = InvokeAsync(StateHasChanged);

/// <summary>The card with the listener's state, its address and its port -
/// one of the page's <c>SettingsSection.CardTitles</c>.</summary>
public const string EndpointCardTitle = "MCP endpoint";
Expand Down Expand Up @@ -237,7 +330,18 @@
_ => $"The MCP server is not listening on port {_source.Port}.",
};

protected override void OnInitialized()
protected override async Task OnInitializedAsync()
{
_startup = Services.GetService<IStartupRegistration>();
_settings = _startup is null ? null : Services.GetService<WorkspaceSettingsStore>();
if (_settings is not null) _settings.TrayChanged += OnTrayChanged;

InitializeEndpoint();

if (_startup is not null && _settings is not null) _startupState = await _startup.GetStateAsync();
}

private void InitializeEndpoint()
{
_source = Services.GetService<IMcpEndpointSource>();
if (_source is null) return;
Expand Down Expand Up @@ -316,5 +420,6 @@
public void Dispose()
{
if (_source is not null) _source.Changed -= OnSourceChanged;
if (_settings is not null) _settings.TrayChanged -= OnTrayChanged;
}
}
2 changes: 1 addition & 1 deletion src/App/Backlog.Desktop.UI/Settings/SettingsPages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public static class SettingsPages
CardTitles: ["GitHub accounts", "Signed in with the GitHub CLI", "Add an account by login"]),
new(McpServer, "MCP server", 4, typeof(McpServerSettingsPage),
Group: SettingsGroup.Workspace,
CardTitles: [McpServerSettingsPage.EndpointCardTitle, McpServerSettingsPage.TokenCardTitle]),
CardTitles: [McpServerSettingsPage.EndpointCardTitle, McpServerSettingsPage.TokenCardTitle, McpServerSettingsPage.BackgroundCardTitle]),
new(Ai, "AI providers", 0, typeof(AiSettingsPage),
Group: SettingsGroup.Ai,
CardTitles: AiCardTitles(usageMetrics: true, aiAssistant: true)),
Expand Down
38 changes: 38 additions & 0 deletions src/App/Backlog.Desktop.UI/Shell/IStartupRegistration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
namespace Backlog.Desktop.UI.Shell;

/// <summary>Whether Backlog starts when the person signs in to Windows (local ADR
/// 0043 §5).</summary>
public enum StartupRegistrationState
{
/// <summary>Backlog starts at sign-in, straight to the tray.</summary>
Enabled,

/// <summary>Backlog does not start at sign-in, and the app may turn it on.</summary>
Disabled,

/// <summary>The person turned it off in Task Manager's Startup apps; only they
/// can turn it on again, there.</summary>
DisabledByUser,

/// <summary>Group policy turned it off.</summary>
DisabledByPolicy,

/// <summary>This process has no package identity — a Debug or Aspire build — so
/// there is no startup task to register.</summary>
Unavailable
}

/// <summary>
/// Starting Backlog when the person signs in: the desktop head answers it with the
/// MSIX <c>StartupTask</c>; a host with no such thing composes none, and the settings
/// page resolves it as optional.
/// </summary>
public interface IStartupRegistration
{
/// <summary>The registration as Windows reports it now.</summary>
Task<StartupRegistrationState> GetStateAsync();

/// <summary>Asks Windows to turn starting at sign-in on or off, and answers the
/// state it ended in — which may not be the one asked for.</summary>
Task<StartupRegistrationState> SetEnabledAsync(bool enabled);
}
25 changes: 25 additions & 0 deletions src/App/Backlog.Desktop.UI/Shell/MainWindowClosePolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace Backlog.Desktop.UI.Shell;

/// <summary>What closing the main window does (local ADR 0043 §5).</summary>
public enum MainWindowCloseAction
{
/// <summary>Cancel the close and hide the window to the notification area: the
/// app, its MCP server and every popped-out window keep running.</summary>
Hide,

/// <summary>Close every window and end the app.</summary>
Quit
}

/// <summary>
/// Decides whether closing the main window hides it or quits the app (local ADR 0043
/// §5). Hide while the person keeps the app running in the tray, nothing asked to
/// quit, and the tray icon is there to bring the window back; Quit when the setting is
/// off, the tray's Quit, sign-out or shutdown asked, or Windows refused the icon — a
/// hidden window with no icon would leave no way to quit.
/// </summary>
public static class MainWindowClosePolicy
{
public static MainWindowCloseAction For(bool keepRunningInTray, bool quitRequested, bool trayIconShown) =>
keepRunningInTray && !quitRequested && trayIconShown ? MainWindowCloseAction.Hide : MainWindowCloseAction.Quit;
}
11 changes: 7 additions & 4 deletions src/App/Backlog.Desktop/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,27 @@ public App()
/// <summary>
/// The main window. <see cref="ShellWindows"/> makes it, as it makes every
/// popped-out window beside it (local ADR 0043 §4), so the store flush on close
/// and the rule that closing the main window quits the app are one class's.
/// and what closing the main window does — hide it to the tray, or quit — are one
/// class's. A start by the sign-in startup task creates it hidden (local ADR 0043 §5).
/// </summary>
protected override Window CreateWindow(IActivationState? activationState)
{
var services = activationState?.Context.Services
?? throw new InvalidOperationException("The app has no services to open its main window with.");

var windows = services.GetRequiredService<ShellWindows>();
var window = windows.CreateMainWindow();
var hidden = BacklogLinkInstance.StartedAtSignIn;
var window = windows.CreateMainWindow(hidden);

// The windows the person remembered reopen once the main window is up, beside it
// (local ADR 0043 §5): queued like the launch link, so the main window finishes
// opening first, and a failure there never holds the main window up.
// opening first, and a failure there never holds the main window up. A start to
// the tray opens them the first time the main window is shown instead.
window.Created += (_, _) => MainThread.BeginInvokeOnMainThread(() =>
{
try
{
windows.ReopenRemembered();
windows.ReopenRememberedWhenShown(hidden);
}
catch (Exception)
{
Expand Down
9 changes: 8 additions & 1 deletion src/App/Backlog.Desktop/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ public static MauiApp CreateMauiApp()
// What a backlog:// link opens (local ADR 0043 §7): BacklogLinkInstance hands it
// the link the app was launched with and every one redirected here later.
builder.Services.TryAddSingleton<BacklogLinkActivation>();
// Starting at sign-in (local ADR 0043 §5): the packaged head's MSIX startup
// task, which an unpackaged build answers as unavailable.
builder.Services.TryAddSingleton<IStartupRegistration, WindowsStartupRegistration>();
// The Brain card's Browse: this head has a window to open the Windows
// folder picker over, so it is the one host that registers one. One per
// window, so it stands over the window whose Settings asked for it.
Expand Down Expand Up @@ -383,7 +386,11 @@ public static MauiApp CreateMauiApp()
// And the backlog:// links (local ADR 0043 §7): the one this launch carried and
// any redirected here since the App constructor claimed the instance, held until
// the main window is up.
BacklogLinkInstance.Attach(app.Services.GetRequiredService<BacklogLinkActivation>());
// A plain launch redirected here brings the main window back from the tray
// (local ADR 0043 §5).
BacklogLinkInstance.Attach(
app.Services.GetRequiredService<BacklogLinkActivation>(),
app.Services.GetRequiredService<ShellWindows>().ActivateMainWindow);

return app;
}
Expand Down
12 changes: 11 additions & 1 deletion src/App/Backlog.Desktop/Platforms/Windows/Package.appxmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
IgnorableNamespaces="uap rescap desktop6 desktop com">
xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"
IgnorableNamespaces="uap rescap desktop6 desktop com uap5">

<Identity Name="JSdotNet.Backlog.Desktop" Publisher="CN=JSdotNet" Version="0.0.0.0" />

Expand Down Expand Up @@ -79,6 +80,15 @@
<uap:DisplayName>Backlog</uap:DisplayName>
</uap:Protocol>
</uap:Extension>
<!--
Start Backlog when I sign in (local ADR 0043 §5): off until the person turns it
on in the MCP server settings (Services/WindowsStartupRegistration.cs). A start
through it opens straight to the tray with no window
(Services/BacklogLinkInstance.cs). The TaskId is what the code asks for.
-->
<uap5:Extension Category="windows.startupTask">
<uap5:StartupTask TaskId="BacklogStartup" Enabled="false" DisplayName="Backlog" />
</uap5:Extension>
</Extensions>
</Application>
</Applications>
Expand Down
Loading
Loading