From 092db7b7c04aaa1ca92e56167f29dbac8c6b1f7d Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 23 Sep 2026 15:52:53 +0200 Subject: [PATCH 1/3] Ask the owner to review, name the support page, and shape four kinds of issue CODEOWNERS makes GitHub request a review from the owner on a pull request somebody else opens. Its comment describes this repository as measured: one active ruleset on the default branch, six required checks, no approval required, and code-owner review left off - which has to stay off while one person owns every path, or no pull request could ever be merged. FUNDING.yml puts the sponsor button on the repository, pointing at donislawdev.com/support. Four issue forms and a config. A bug report asks for the version, the surface, whether the tool ran as administrator, the display language of Windows, and any difference between a plan's preview and its run. A separate form takes a disagreement with sc.exe, with the exact commands to paste beside bws show --json. A feature request states three things the project will not revisit. Blank issues are off, and security reports go to the private channel. Every dropdown option is quoted - an unquoted No is a boolean in YAML 1.1, and the local structure check caught it. PublicSurfaceGuards reads CODEOWNERS now. It is the one file GitHub reads from .github that has no extension, so no pattern reached it. Co-Authored-By: Claude Opus 5.5 --- .github/CODEOWNERS | 39 ++++++ .github/FUNDING.yml | 8 ++ .github/ISSUE_TEMPLATE/bug_report.yml | 111 ++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 17 +++ .github/ISSUE_TEMPLATE/feature_request.yml | 45 +++++++ .github/ISSUE_TEMPLATE/question.yml | 53 +++++++++ .github/ISSUE_TEMPLATE/sc_disagreement.yml | 79 +++++++++++++ .../PublicSurfaceGuards.cs | 7 +- 8 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/question.yml create mode 100644 .github/ISSUE_TEMPLATE/sc_disagreement.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..6ed0e2b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,39 @@ +# Who owns this repository, and who GitHub asks to review a change to it. +# +# One owner, one line. Finer-grained rules would be fiction here: every path +# below has the same owner, and a list that pretends otherwise ages into a claim +# nobody checks. +# +# What this file DOES do +# ---------------------- +# A pull request opened by anyone else automatically requests a review from the +# owner. That is the whole reason it exists: an outside contribution should not +# sit unnoticed because nobody was asked. This project accepts translations of +# the window and evidence from machines that are not ours, so that path is real +# rather than hypothetical - see CONTRIBUTING.md. +# +# What it does NOT do +# ------------------- +# GitHub never requests a review from the person who opened the pull request, and +# nobody can approve their own. So on a change written by the owner this file is +# silent by design - and a rule that demands a code-owner approval would, on such +# a change, demand one from somebody who does not exist. That combination freezes +# the repository: no pull request could ever be merged. The ruleset on the +# default branch leaves `require_code_owner_review` off, and it has to stay off +# for as long as this file names one person. +# +# What actually guards the code today +# ----------------------------------- +# Measured on 2026-09-23: the default branch carries one active ruleset. It +# refuses deletion and force pushes, takes every change through a pull request +# with no approval required, and requires six named checks to pass - +# `build and the tests that do not need this machine`, `Semgrep`, +# `Dependency review`, `Analyse csharp`, `Analyse actions` and `submit-nuget`. +# So a red check blocks a merge and a missing review does not. That is a weaker +# guarantee than a second pair of eyes, and it is stated plainly rather than +# dressed up. +# +# Syntax note: gitignore-style patterns, with three exceptions - a pattern may not +# start with `#`, `!` does not negate, and `[ ]` character ranges do not work. + +* @donislawdev diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..7d5e4f1 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,8 @@ +# The sponsor button GitHub shows on the repository. +# +# One custom address rather than a platform, because the page behind it is ours +# and can say what the money is for. +# +# The quoting is not decoration. A YAML value holding a colon has to be quoted or +# the parser reads it as a mapping, and every https address holds one. +custom: ["https://donislawdev.com/support/"] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..a66112a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,111 @@ +name: Bug report +description: Something in Better Windows Services does not work as expected +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report this. The version, whether the session had + administrator rights, and the output the tool printed are the three most useful + things you can give us. + + **If the tool and `sc.exe` disagree about a service, use the "Disagreement with + sc.exe" template instead.** It asks for exactly the output that settles it. + + **Some answers are not bugs.** Without administrator rights Windows lists fewer + services and refuses more of what it lists, and the tool says so at the top of + both the window and the command line. Exit code 3 means a step did not get where + it was going, usually because the service manager refused, and 5 means a snapshot + comparison found differences. Report them if you believe the answer itself is + wrong. + - type: input + id: version + attributes: + label: Version + description: >- + `bws --version` prints it - paste the first two lines. For the window, run this + in PowerShell next to the executable: + `(Get-Item .\BetterWindowsServices.exe).VersionInfo.ProductVersion`. + placeholder: "bws 0.1.0, snapshot schema 4" + validations: + required: true + - type: dropdown + id: surface + attributes: + label: How were you running it? + description: >- + The window and the command line are two clients of one engine, so a bug in one + is often a bug in both. Which one you saw it in narrows it down. + options: + - "Window (BetterWindowsServices.exe)" + - "Command line (bws.exe)" + - "Both" + validations: + required: true + - type: dropdown + id: elevated + attributes: + label: Was it running as administrator? + description: >- + This changes what Windows hands over, so it splits most reports in half. When it + is not, the window says "Running without administrator rights" in its status + line and the command line prints a warning before anything else. + options: + - "Yes, as administrator" + - "No" + - "Not sure" + validations: + required: true + - type: textarea + id: what + attributes: + label: What happened? + description: What you expected, and what happened instead. + validations: + required: true + - type: textarea + id: steps + attributes: + label: The command, or the steps + description: >- + The smallest thing that triggers it. A single `bws` command line is ideal, so + paste it rather than describing it. For the window, the query you typed and + what you clicked. + render: shell + validations: + required: true + - type: textarea + id: plan + attributes: + label: If a plan was involved - the preview, and what happened + description: >- + The preview and the execution are the same plan, so any difference between what + `--dry-run` or the plan panel showed and what the run did is the worst bug this + product can have. Paste the preview, then say what actually happened. + render: shell + - type: input + id: exit + attributes: + label: Exit code + description: >- + `$LASTEXITCODE` in PowerShell, or `echo %ERRORLEVEL%` in cmd, right after the + command. The codes are part of this tool's contract, so a wrong one is a bug on + its own even when the output looked right. + placeholder: "3" + - type: textarea + id: output + attributes: + label: What the tool printed + description: >- + The output with `--json` is the most useful. Before pasting: it names services, + accounts, file paths and sometimes other machines, and this issue is public. + Read it first and remove anything you would rather not publish. + render: shell + - type: input + id: windows + attributes: + label: Windows version and display language + description: >- + Output of `winver`, and the display language if it is not English. Display + names and account names are translated, and the tool must not depend on either. + placeholder: "Windows Server 2022, German" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..aa554c8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,17 @@ +# Problems belong here, on the issue tracker, where the next person hitting the +# same thing can find the answer. There is deliberately no link out to a support +# page: the only other channel is for vulnerabilities, which must not be public +# until there is a fix. +# +# Blank issues are off because every template below asks for the few things that +# make a report answerable - the version, the surface, whether the session had +# administrator rights, and the output the tool already printed. A question that +# fits none of them still has a home: "Question or problem". +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/donislawdev/BetterWindowsServices/security/advisories/new + about: Please report security issues privately, never as a public issue. SECURITY.md says what is in scope. + - name: Read the documentation first + url: https://betterwindowsservices.donislawdev.com/ + about: The command reference, the query language and the honest limits are all there. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..d807b6f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,45 @@ +name: Feature request +description: Suggest an improvement or a new capability +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: What problem would this solve? + description: The administration task that is awkward today, and on what kind of machine. + validations: + required: true + - type: textarea + id: idea + attributes: + label: Proposed solution + description: What you would like the tool to do. + validations: + required: true + - type: textarea + id: today + attributes: + label: What you do today instead + description: >- + sc.exe, PowerShell, services.msc, a script of your own - and what it costs you. + - type: checkboxes + id: scope + attributes: + label: Scope + description: >- + Three things this project has decided and will not revisit without a new + argument. Saying so here saves you writing a proposal that was never going to be + accepted. + options: + - label: This does not need the tool to reach the network. No telemetry, no update check, no crash reporting and no client of any service - the tool is meant for production servers, and it never leaves the machine it runs on. + required: true + - label: This does not change anything without a plan. Every change builds a plan that can be previewed, turned into a command line and undone, and the preview is exactly what the execution does. There is no second path for a change, not even a quick one. + required: true + - label: This is about Windows services on the machine the tool runs on. Remote management is not in this version - to compare two machines, take a snapshot on each and compare the two files. + required: true + - type: checkboxes + id: surfaces + attributes: + label: Both surfaces + options: + - label: I understand that a capability lives in the engine and appears in both the window and the command line, rather than in one of them. diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..1e7c117 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,53 @@ +name: Question or problem +description: Ask about using the tool, or report something that does not fit the other templates +labels: ["question"] +body: + - type: markdown + attributes: + value: | + Questions belong here rather than in private mail, because the next person to hit + the same thing can find the answer. + + If it turns out to be a defect we will relabel it - you do not have to decide that + first. The same goes the other way: "the tool lists fewer services when I run it + without administrator rights, is that expected?" is a fair question, and the + answer is often yes with a reason. + - type: textarea + id: question + attributes: + label: What are you trying to do? + description: The task, and what you have tried so far. + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: >- + If the tool is already on the machine, `bws --version` prints it. + placeholder: "bws 0.1.0, snapshot schema 4" + - type: dropdown + id: surface + attributes: + label: Which surface, if it matters + options: + - "Window (BetterWindowsServices.exe)" + - "Command line (bws.exe)" + - "Both" + - "Not relevant" + - type: dropdown + id: elevated + attributes: + label: Was it running as administrator? + options: + - "Yes, as administrator" + - "No" + - "Not sure" + - type: textarea + id: extra + attributes: + label: The command or the output, if there is one + description: >- + Paste it rather than describing it. Anything you paste is public, and the output + names services, accounts and paths, so check it before it goes in. + render: shell diff --git a/.github/ISSUE_TEMPLATE/sc_disagreement.yml b/.github/ISSUE_TEMPLATE/sc_disagreement.yml new file mode 100644 index 0000000..f1497ad --- /dev/null +++ b/.github/ISSUE_TEMPLATE/sc_disagreement.yml @@ -0,0 +1,79 @@ +name: Disagreement with sc.exe +description: The tool and the sc.exe that ships with Windows say different things about a service +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + `sc.exe` is the reference this tool is checked against, entry by entry, on the + machines it is built on. Yours is different, and a disagreement on your machine + is a bug here until shown otherwise - even when every test passes. + + Run all of the commands below **in the same session**, so both tools had the same + rights. + - type: input + id: name + attributes: + label: Service name + description: >- + The internal name, as `sc query` shows it after SERVICE_NAME, and not the display + name. Display names are translated into the language of the machine, and the tool + never identifies a service by one. + placeholder: "Spooler" + validations: + required: true + - type: dropdown + id: elevated + attributes: + label: Was the session running as administrator? + description: >- + Without administrator rights Windows refuses some reads, and the two tools may + report a refusal differently. That is worth knowing before anything else. + options: + - "Yes, as administrator" + - "No" + - "Not sure" + validations: + required: true + - type: textarea + id: difference + attributes: + label: What differs? + description: >- + The field and the two values, in a sentence. For example, sc qc says the start + type is DEMAND_START and the tool says automatic. + validations: + required: true + - type: textarea + id: bws + attributes: + label: The output of bws show NAME --json + render: json + validations: + required: true + - type: textarea + id: sc + attributes: + label: The output of sc qc NAME, sc query NAME and sc qtriggerinfo NAME + description: >- + All three, one after another. Before pasting: they carry the launch path and the + account the service runs as, which can name a user or a machine, and this issue + is public. Remove anything you would rather not publish. + render: shell + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: "`bws --version` prints it - paste the first two lines." + placeholder: "bws 0.1.0, snapshot schema 4" + validations: + required: true + - type: input + id: windows + attributes: + label: Windows version and display language + description: >- + Output of `winver`, and the display language if it is not English. + placeholder: "Windows Server 2022, German" diff --git a/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs b/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs index 2904b16..b1fefba 100644 --- a/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs +++ b/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs @@ -362,7 +362,12 @@ internal static IEnumerable Published() // home directory or an address wanders into. .txt brings in the two NativeMethods.txt // lists that CsWin32 reads and the pinned scanner version - all three already ASCII, // checked when they were added here. - return new[] { "*.cs", "*.csproj", "*.xaml", "*.json", "*.yml", "*.md", "*.props", "*.slnx", "*.html", "*.css", "*.py", "*.txt" } + // + // CODEOWNERS joined it on 2026-09-23, the day it arrived in .github. It is the one file + // GitHub reads from there that has no extension at all, so no pattern above could ever + // reach it - and it is forty lines of prose about how this repository is guarded, shown + // to anybody who opens it. + return new[] { "*.cs", "*.csproj", "*.xaml", "*.json", "*.yml", "*.md", "*.props", "*.slnx", "*.html", "*.css", "*.py", "*.txt", "CODEOWNERS" } .SelectMany(pattern => Directory.EnumerateFiles(root, pattern, SearchOption.AllDirectories)) .Where(NotBuildOutput) .Where(InVersionControl) From d46926ac33c25ec9f6307d2ebc1c1104847d8f8e Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 23 Sep 2026 15:52:54 +0200 Subject: [PATCH 2/3] Add a Donate button that never hands the browser administrator rights A Donate button at the right end of the row above the search box, in the library's own button style like Columns, with a heart drawn as a shape rather than a glyph. Its tooltip says it opens the page in the browser and that the tool itself still connects to nothing. The window usually runs as administrator, and a process started from it inherits that token. So a window with those rights does not start the browser itself: it hands the address to the desktop's shell - ShellWindows, a local server that runs as the interactive user, then IShellDispatch2.ShellExecute, the sequence Microsoft published for exactly this. Whatever the desktop answers, it never falls back to a direct start. A window without those rights starts the browser directly. Any failure is a sentence in the status line with the address to type in. With User Account Control off the desktop itself is elevated, and that is stated rather than hidden. ExternalLinks.cs is the second file in the window allowed to start anything, and the address is a constant there. The process guard names both files and now also looks for a shell object asked to execute, which starts a browser without the word Process. The window binds six native modules, read from the built assembly: OLE32 is the one new module it calls, and four are release functions the generator declares beside handle types and nothing calls - each registered with that reason. ExternalLinksGuards holds the rule on the desktop's refusal rather than its success, a real shell refusal, the address as a constant, and the button's right edge on the line of Columns. DesktopShellContractTests asks the desktop and stops one step short of starting anything - it runs only on a real machine, so the window's internals are visible to the integration tests too. Four comments, CONTRIBUTING.md and the README said only one file may start a process or that the program opens no link, and now say what is true. Bws.Architecture.Tests 182/182, ExternalLinksGuards 7/7, the desktop test 1/1 on a session with a desktop, and eight mutation entries caught. Not checked by anything automatic, on purpose: the click itself, because a test must not open a browser on somebody's screen. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 + CONTRIBUTING.md | 5 +- README.md | 13 +- src/Bws.Gui/AssemblyInfo.cs | 6 + src/Bws.Gui/Elevation.cs | 12 +- src/Bws.Gui/ExternalLinks.cs | 197 ++++++++++++++++ src/Bws.Gui/MainWindow.xaml.cs | 34 ++- src/Bws.Gui/NativeMethods.txt | 29 ++- src/Bws.Gui/Resources/gui.en.json | 6 + src/Bws.Gui/ScopeBar.xaml | 89 +++++-- src/Bws.Gui/ScopeBar.xaml.cs | 9 + src/Bws.Gui/Themes/Controls.xaml | 23 ++ src/Bws.Gui/Themes/Values.xaml | 8 + .../Bws.Architecture.Tests/LayeringGuards.cs | 40 +++- .../OutboundRegisters.cs | 34 ++- tests/Bws.Gui.Tests/ExternalLinksGuards.cs | 218 ++++++++++++++++++ .../DesktopShellContractTests.cs | 54 +++++ 17 files changed, 723 insertions(+), 61 deletions(-) create mode 100644 src/Bws.Gui/ExternalLinks.cs create mode 100644 tests/Bws.Gui.Tests/ExternalLinksGuards.cs create mode 100644 tests/Bws.Integration.Tests/DesktopShellContractTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index a5e5602..dc8794a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ Nothing has been released yet. Everything below is what the tool does today. ### Added +- **A *Donate* button in the window**, at the right end of the row above the search box. It + opens the project's support page in your browser and says so before it is pressed - the tool + itself still opens no connection, your browser is what connects. In a window running as + administrator the page is handed to the desktop to open, so the browser starts with your own + rights rather than the tool's. If the page cannot be opened, the status line says why and + gives the address to type in. + - **`bws license`, and `bws license --components`.** What the program is licensed under, that it comes with no warranty, where the full text is, and what it carries that somebody else wrote. The second form turns that into every component with its version, its licence and where it came diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9522ddb..914690a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -162,8 +162,9 @@ a declaration: command line tool writes to the data channel. - **The core writes nothing to the console.** It returns results, and the layer above decides what happens to them. -- **A shipped assembly starts no process**, with one named exception in the window, in one file: - restarting itself as administrator. +- **A shipped assembly starts no process**, with two named exceptions in the window, each in a file + of its own: restarting itself as administrator, and handing the support page to the browser - + through the desktop, when the window has administrator rights, so that the browser does not. - **Every broad `catch`, every `async void`, every native call that drops its answer, and every place that does two things at once is on a list with the argument for it beside it** - and the list may not name a place that stopped doing it. diff --git a/README.md b/README.md index 9a18c9a..7217a6f 100644 --- a/README.md +++ b/README.md @@ -499,7 +499,10 @@ narrows the list to that value or puts the column away, and the layout you leave opens in. A row's menu previews every operation before offering it, and copies the name, the display name, the description or everything. *Export...* writes the rows on screen, in the columns you have on and the order you sorted them into, to a CSV file. Ctrl+C over the list copies -everything about the chosen entry. Escape backs out of the innermost thing first. +everything about the chosen entry. Escape backs out of the innermost thing first. *Donate*, at the +right end of the row above the search box, opens the project's support page in your browser - and +in a window running as administrator it asks the desktop to open it, so the browser does not get +those rights. --- @@ -518,7 +521,8 @@ test suite runs both and compares them entry by entry. Never. No telemetry, no update check, no crash reporting, no account, no client of anything. The one time it can touch a network at all is when a service's launch path points at another machine's share and you pass `--follow-network` to let it look there - off by default, and the -help says why. +help says why. The *Donate* button in the window hands one address to your browser when you press +it, and your browser is what connects. ### Does it need administrator rights? @@ -594,5 +598,6 @@ trademark of the Microsoft group of companies, and the name is used here only to manages. If it saved you an afternoon, [donislawdev.com/support](https://donislawdev.com/support/) is where -that can be said in a way that keeps the next afternoon funded. The program itself opens no link -and no socket - your browser is what connects. +that can be said in a way that keeps the next afternoon funded - the *Donate* button in the window +opens that page in your browser. The program itself opens no socket - your browser is what +connects. diff --git a/src/Bws.Gui/AssemblyInfo.cs b/src/Bws.Gui/AssemblyInfo.cs index ffef284..1bf2a3b 100644 --- a/src/Bws.Gui/AssemblyInfo.cs +++ b/src/Bws.Gui/AssemblyInfo.cs @@ -13,3 +13,9 @@ // nothing outside this assembly consumes them. Opening them to the test project is the // alternative to making them public for an audience of one. [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Bws.Gui.Tests")] + +// And to the tests that need a real machine, since 2026-09-23, for one question the window's own +// test project must not ask: whether the desktop's shell answers. That depends on the session the +// tests run in, and the build server runs Bws.Gui.Tests whole - where Bws.Integration.Tests runs +// there only what is marked as running anywhere. +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Bws.Integration.Tests")] diff --git a/src/Bws.Gui/Elevation.cs b/src/Bws.Gui/Elevation.cs index 7000991..3c8e00c 100644 --- a/src/Bws.Gui/Elevation.cs +++ b/src/Bws.Gui/Elevation.cs @@ -7,8 +7,10 @@ namespace Bws.Gui; /// /// Starting this program again, with the rights this session does not have. /// -/// THE ONLY FILE IN THIS PRODUCT ALLOWED TO NAME A PROCESS, AND THAT IS A DECISION OF THE -/// OWNER'S RATHER THAN AN ARRANGEMENT OF MINE. LayeringGuards has forbidden +/// THE FIRST FILE IN THIS PRODUCT ALLOWED TO NAME A PROCESS, AND THAT IS A DECISION OF THE +/// OWNER'S RATHER THAN AN ARRANGEMENT OF MINE. It was the only one until 2026-09-23, when the +/// Donate button made the second, by the same kind of decision. +/// LayeringGuards has forbidden /// System.Diagnostics.Process in every shipped assembly since 2026-08-02, with an argument /// that is still right: a tool running with administrator rights on somebody else's production /// machine, whose whole subject is which programs that machine launches, is the last place a quiet @@ -17,9 +19,9 @@ namespace Bws.Gui; /// /// The conversation happened on 2026-08-25 and the exception is deliberately narrow. The /// assembly guard still refuses Activator and AppDomain here, and a second guard - -/// LayeringGuards.Only_one_file_in_the_window_may_start_a_process - reads the sources and -/// reddens if the name appears anywhere but this file. So the exception has a name, a place and a -/// test, rather than being a door left open. +/// LayeringGuards.Only_the_named_files_in_the_window_may_start_a_process - reads the sources +/// and reddens if the name appears in any file it does not list. So the exception has a name, a +/// place and a test, rather than being a door left open. /// /// What it does NOT carry over, said rather than left to be found: the query somebody had /// typed, the columns they had turned on, or which list they were looking at. The new session reads diff --git a/src/Bws.Gui/ExternalLinks.cs b/src/Bws.Gui/ExternalLinks.cs new file mode 100644 index 0000000..17015db --- /dev/null +++ b/src/Bws.Gui/ExternalLinks.cs @@ -0,0 +1,197 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using Bws.Core; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.System.Com; +using Windows.Win32.UI.Shell; + +namespace Bws.Gui; + +/// +/// The one address this window hands to a browser, and the two ways it hands it over. +/// +/// THE SECOND FILE IN THIS WINDOW ALLOWED TO START SOMETHING, and that is the owner's decision +/// of 2026-09-23 rather than an arrangement of mine. was the only one +/// since 2026-08-25, and LayeringGuards.Only_the_named_files_in_the_window_may_start_a_process +/// now names both. ADR-19 allows exactly this and nothing wider: a page opened in the user's own +/// browser after the user pressed something. This program still opens no connection of its own - +/// the address goes to the shell, the shell starts the browser, and the browser is what connects. +/// +/// Every destination is a constant here and no caller supplies one. The shell runs whatever +/// string it is handed, so a caller free to pass its own would turn a Donate button into a way of +/// starting anything. ExternalLinksGuards reads the sources to keep it that way. +/// +/// WHY TWO WAYS, AND IT IS THE ONE THING HERE THAT IS NOT IN THE SIBLING PROJECT THIS WAS COPIED +/// FROM. This window usually runs as administrator, and a process started from it inherits its +/// token - so an ordinary shell start would open the browser with administrator rights whenever no +/// browser was already running to take the address over. A browser on the internet with those +/// rights is the last thing a tool for production servers should leave behind. So an elevated +/// window hands the address to the DESKTOP's shell instead, which runs as the person at the +/// desktop, and the page opens with their rights. A window without administrator rights has +/// nothing to hand down, and starts the browser directly. +/// +/// The desktop route is Microsoft's own, not an invention. Raymond Chen, "How can I launch +/// an unelevated process from my elevated process and vice versa?" (The Old New Thing, 2013-11-18) +/// and "Manipulating the positions of desktop icons" (2013-03-18) for the first half of the +/// sequence, both on devblogs.microsoft.com. Read on 2026-09-23. Measured on this machine the same +/// day: the ShellWindows class is registered as a local server only, with RunAs set to Interactive +/// User - which is why it answers as the person at the desktop even when an administrator of a +/// different account raised this window. +/// +/// WHAT IT CANNOT DO, said rather than left to be found. With User Account Control turned +/// off the desktop itself runs with administrator rights, and so does anything it starts - the +/// source above says so, and there is no unelevated token left on such a machine to hand anything +/// to. And where there is no desktop shell at all, on Server Core or with Explorer ended, the +/// elevated window says where the page is rather than starting a browser as administrator. It +/// never falls back to the direct start: that fallback is exactly the outcome this file exists to +/// avoid. +/// +internal static class ExternalLinks +{ + /// Where somebody who wants to support the project is sent. + internal const string Support = "https://donislawdev.com/support/"; + + /// + /// IDispatch, asked for by its identifier because the generator does not emit that interface. + /// The published sequence asks the folder view for exactly this and then for the dual + /// interface behind it, so this does the same rather than guessing that a shortcut works. + /// + private static readonly Guid DispatchInterface = new("00020400-0000-0000-C000-000000000046"); + + /// SW_SHOWNORMAL - the window the browser opens, shown the way it would be anyway. + private const int ShowNormally = 1; + + /// + /// Hands the support page to a browser, or says why it did not. + /// + /// Null means it was handed over, the same shape as , and + /// for the same reason: a failure here is a sentence a person reads, rule 8, and it carries the + /// address so that the way out is typing it into a browser by hand. + /// + internal static string? OpenSupport() => Open(Support, Session.IsElevated(), Start, HandToDesktop); + + /// + /// Which of the two ways, and what a failure says. + /// + /// A function of its inputs so that the rule can be tested without opening anything. The + /// rule is the whole safety property of this file: an elevated window goes to the desktop and + /// ONLY to the desktop, whatever the desktop answers. + /// + internal static string? Open( + string address, + bool elevated, + Func start, + Func handToDesktop) + { + var reason = elevated ? handToDesktop(address) : start(address); + + return reason is null ? null : Texts.Of("gui.support.failed", reason, address); + } + + /// + /// Starts the browser directly, with this process's own rights. + /// + /// Internal rather than private so that a test can hand it a path the shell is certain to + /// refuse - a file that does not exist - which is the only honest way to prove the failure + /// path, and it opens no window: the runtime asks the shell for no error dialog. + /// + internal static string? Start(string address) + { + try + { + // UseShellExecute is what makes this open the user's browser instead of trying to run + // the address as a program, and it is why every destination is a constant above. + using var started = Process.Start(new ProcessStartInfo(address) { UseShellExecute = true }); + return null; + } + catch (Win32Exception failure) + { + return failure.Message; + } + } + + /// + /// Asks the desktop's shell to open the address, so that it opens with the desktop's rights. + /// + /// The three exceptions are what COM interop raises for the three ways this realistically + /// fails - the shell's process refusing or gone, an interface it does not offer, and a + /// refusal of access. Anything else would mean the shell breaking its own published contract, + /// and it reaches the window's own last line in Mishaps, which also says what happened. + /// + private static string? HandToDesktop(string address) + { + try + { + if (Desktop() is not { } shell) + { + return Texts.Of("gui.support.noDesktop"); + } + + var file = Marshal.StringToBSTR(address); + + try + { + // The order differs from ShellExecute's own, which the source above warns about: + // file, parameters, directory, verb, show. An empty verb is the default one. + shell.ShellExecute(new BSTR(file), string.Empty, string.Empty, string.Empty, ShowNormally); + } + finally + { + Marshal.FreeBSTR(file); + } + + return null; + } + catch (COMException failure) + { + return failure.Message; + } + catch (InvalidCastException failure) + { + return failure.Message; + } + catch (UnauthorizedAccessException failure) + { + return failure.Message; + } + } + + /// + /// The shell object of the desktop, or null when there is no desktop to ask. + /// + /// Internal so that a test can reach the desktop without starting anything - every call + /// here asks and none of them acts, so a machine with a desktop can prove the route exists. + /// + internal static unsafe IShellDispatch2? Desktop() + { + // A local server and nothing else, which is what the registration says ShellWindows is. + // Asking for it any other way could only ever find a copy that is not the desktop's. + var created = PInvoke.CoCreateInstance( + typeof(ShellWindows).GUID, null, CLSCTX.CLSCTX_LOCAL_SERVER, out var windows); + + if (created.Failed) + { + throw new COMException(Texts.Of("gui.support.noShell"), created.Value); + } + + object location = (int)PInvoke.CSIDL_DESKTOP; + object empty = null!; + + // No desktop window is an answer rather than a failure - Server Core has none. + if (windows.FindWindowSW(in location, in empty, ShellWindowTypeConstants.SWC_DESKTOP, out _, + ShellWindowFindWindowOptions.SWFO_NEEDDISPATCH) is not Windows.Win32.System.Com.IServiceProvider desktop) + { + return null; + } + + desktop.QueryService(PInvoke.SID_STopLevelBrowser, out var browser); + browser.QueryActiveShellView(out var view); + + var dispatch = DispatchInterface; + view.GetItemObject(_SVGIO.SVGIO_BACKGROUND, &dispatch, out var background); + + return ((IShellFolderViewDual)background).Application as IShellDispatch2; + } +} diff --git a/src/Bws.Gui/MainWindow.xaml.cs b/src/Bws.Gui/MainWindow.xaml.cs index 4e19183..0ccd784 100644 --- a/src/Bws.Gui/MainWindow.xaml.cs +++ b/src/Bws.Gui/MainWindow.xaml.cs @@ -161,11 +161,17 @@ internal MainWindow(PreferencesFile preferences, MainViewModel model) // THE WAY OUT OF A SESSION WITHOUT RIGHTS, wired here rather than in the row that holds it - // 2026-08-25, when the status row moved into its own file. Pressing it starts a second copy - // of this program, and Elevation.cs is the only file allowed to name a process at all, held - // by a guard that reads the sources. A handler over there would be a second file reaching - // for that, reporting its failure through a model that row deliberately does not hold. + // of this program, and Elevation.cs is one of the two files allowed to name a process at + // all, held by a guard that reads the sources. A handler over there would be another file + // reaching for that, reporting its failure through a model that row deliberately does not + // hold. Status.Elevate.Click += RestartAsAdministrator; + // THE DONATE BUTTON, 2026-09-23, wired here for the reason the line above gives: pressing + // it hands an address to the shell, which only ExternalLinks.cs may do, and a failure is a + // sentence for the model this window holds and the row does not. + Scope.Donate.Click += OpenSupportPage; + // THE BAR OVER THE LIST, 2026-08-25. It asks and the window answers, which is the same // arrangement the plan panel uses for its own two buttons - a part of the window that // reaches into the model would be a second road to everything the model owns. @@ -384,13 +390,14 @@ private void QueryTyped(object sender, TextChangedEventArgs e) /// is read rather than assumed. /// /// Nothing about starting a process is here, and that is not tidiness: Elevation.cs is - /// the only file in this product allowed to name one, held by a guard that reads the sources. + /// one of the two files in this product allowed to name one, held by a guard that reads the + /// sources. /// /// /// Wired in the constructor since 2026-08-25, when the status row moved into its own /// file. The markup over there cannot carry the click: pressing this starts a process, and - /// is the only file in the product allowed to name one - so the handler - /// stays here, where the model that reports its failure is. + /// is where that is allowed - so the handler stays here, where the model + /// that reports its failure is. /// private void RestartAsAdministrator(object sender, RoutedEventArgs e) { @@ -403,6 +410,21 @@ private void RestartAsAdministrator(object sender, RoutedEventArgs e) Close(); } + /// + /// Hands the support page to a browser, or says in the status line why it did not and where + /// the page is. + /// + /// The window stays as it is either way - nothing about the list, the query or a plan + /// depends on a browser opening, so a failure here is a sentence rather than an interruption. + /// + private void OpenSupportPage(object sender, RoutedEventArgs e) + { + if (ExternalLinks.OpenSupport() is { } trouble) + { + _model.Says.CouldNotDo(trouble); + } + } + private void ListEngaged(object sender, RoutedEventArgs e) => _model.Interacting = true; private void ListReleased(object sender, RoutedEventArgs e) => diff --git a/src/Bws.Gui/NativeMethods.txt b/src/Bws.Gui/NativeMethods.txt index 1807777..932e58a 100644 --- a/src/Bws.Gui/NativeMethods.txt +++ b/src/Bws.Gui/NativeMethods.txt @@ -1,9 +1,34 @@ -// Win32 surface used by the window, and it is one call. +// Win32 surface used by the window, and it is two things. // // CsWin32 turns each name below into a P/Invoke at build time, so nothing here becomes a runtime // dependency - ADR-2 and ADR-15, the same standing this generator has in Bws.Core. // // Keeping the list explicit is the point: it is the honest inventory of what the window touches in -// the operating system, and it belongs under review like any other contract. One name today. +// the operating system, and it belongs under review like any other contract. +// +// The title bar - telling the window manager which colour the frame above this window is. DwmSetWindowAttribute DWMWINDOWATTRIBUTE +// +// The Donate button, since 2026-09-23 - handing one address to the desktop's own shell, so that the +// browser starts as the person at the desktop rather than with this window's administrator rights. +// The sequence is the one Microsoft published for exactly that, and ExternalLinks.cs names the source. +// None of these reaches a network: the desktop shell is a COM server on this machine, run by the +// interactive user, and the browser it starts is what connects. +// +// The generator also declares four release functions it never calls - DestroyMenu, CloseHandle, +// DestroyPropertySheetPage and SysFreeString - because the shell interfaces name handle types that +// carry them. OutboundRegisters in the architecture tests says so beside each module. +ShellWindows +IShellWindows +ShellWindowTypeConstants +ShellWindowFindWindowOptions +CSIDL_DESKTOP +IServiceProvider +SID_STopLevelBrowser +IShellBrowser +IShellView +_SVGIO +IShellFolderViewDual +IShellDispatch2 +CoCreateInstance diff --git a/src/Bws.Gui/Resources/gui.en.json b/src/Bws.Gui/Resources/gui.en.json index ac8a998..b5a2b1f 100644 --- a/src/Bws.Gui/Resources/gui.en.json +++ b/src/Bws.Gui/Resources/gui.en.json @@ -346,6 +346,12 @@ "gui.elevate.failed": "The tool could not be started again as administrator. {0}", "gui.elevate.noPath": "Windows does not say where this program is on disk, so it cannot be started again.", + "gui.support.button": "Donate", + "gui.support.hint": "Opens donislawdev.com/support in your browser. This tool itself still connects to nothing.", + "gui.support.failed": "The support page did not open. {0} It is at {1}, and any browser you open yourself will show it.", + "gui.support.noDesktop": "A window with administrator rights hands the page to the desktop, so that the browser does not get those rights, and there is no desktop here to take it.", + "gui.support.noShell": "The desktop did not answer.", + "gui.export.failed": "The list could not be written. {0}", "gui.export.name": "services", "gui.export.kind": "Comma separated values", diff --git a/src/Bws.Gui/ScopeBar.xaml b/src/Bws.Gui/ScopeBar.xaml index ff4937e..e757e8f 100644 --- a/src/Bws.Gui/ScopeBar.xaml +++ b/src/Bws.Gui/ScopeBar.xaml @@ -205,29 +205,68 @@ for five times: WPF builds an automation peer for controls, not for layout panels, so an identifier on a StackPanel sits on an element that is not in the tree at all. --> - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/src/Bws.Gui/ScopeBar.xaml.cs b/src/Bws.Gui/ScopeBar.xaml.cs index cf30f57..c181d99 100644 --- a/src/Bws.Gui/ScopeBar.xaml.cs +++ b/src/Bws.Gui/ScopeBar.xaml.cs @@ -17,4 +17,13 @@ namespace Bws.Gui; public partial class ScopeBar : UserControl { public ScopeBar() => InitializeComponent(); + + /// + /// The Donate button at the right end of this row, since 2026-09-23. + /// + /// Forwarded rather than handled here, the shape StatusRow gives its elevation button: + /// pressing it hands an address to the shell, and the window wires the click where the model + /// that reports a failure is. + /// + internal Button Donate => DonateButton; } diff --git a/src/Bws.Gui/Themes/Controls.xaml b/src/Bws.Gui/Themes/Controls.xaml index 940c237..2588d0b 100644 --- a/src/Bws.Gui/Themes/Controls.xaml +++ b/src/Bws.Gui/Themes/Controls.xaml @@ -289,6 +289,29 @@ + + + 8 + + + 10 1.5